diff --git a/README.md b/README.md index cb2d382..4a14a49 100644 --- a/README.md +++ b/README.md @@ -553,8 +553,12 @@ POST /api/v1/projects que esta API, y se le pasa el de quien llama (en el panel, el de su sesión). Un token de máquina (`pmt_…`) allí no significa nada, así que esas peticiones usan `TASKSTREAM_TOKEN`; sin él, no pueden pedir tablero. -- Solo al crear: en una edición los dos campos se ignoran, y un proyecto - duplicado no hereda el tablero del original. +- **Después**: un proyecto que se creó sin tablero puede tenerlo luego, con + «Crear en TaskStream» en su ficha o `taskstream: true` en un `PATCH` (si + TaskStream lo rechaza, la edición tampoco se guarda). Uno que ya tiene tablero + lo conserva, y `taskstream_key` sin la casilla se ignora, así que un cliente + puede devolver tal cual lo que leyó. Un proyecto duplicado no hereda el + tablero del original. ```dotenv TASKSTREAM_API_URL=https://api.taskstream.pireon.dev # por defecto diff --git a/app/Actions/Projects/OpenInTaskStream.php b/app/Actions/Projects/OpenInTaskStream.php index c8eb396..c2e7fcd 100644 --- a/app/Actions/Projects/OpenInTaskStream.php +++ b/app/Actions/Projects/OpenInTaskStream.php @@ -13,11 +13,11 @@ use Illuminate\Validation\ValidationException; use Symfony\Component\HttpKernel\Exception\HttpException; /** - * Opens a board in TaskStream for a project that is about to be created - * here, and points the project at it. + * Opens a board in TaskStream for a project, new or one without a board + * yet, and points the project at it. * * It runs before the project is saved: if TaskStream says no, nothing is - * created here either, and the caller gets the reason to fix and retry. + * saved here either, and the caller gets the reason to fix and retry. * The board is public, like the project it tracks is meant to be. */ class OpenInTaskStream diff --git a/app/Filament/Resources/Projects/Pages/EditProject.php b/app/Filament/Resources/Projects/Pages/EditProject.php index f0cddeb..d5f0ed0 100644 --- a/app/Filament/Resources/Projects/Pages/EditProject.php +++ b/app/Filament/Resources/Projects/Pages/EditProject.php @@ -4,15 +4,22 @@ declare(strict_types=1); namespace App\Filament\Resources\Projects\Pages; +use App\Actions\Projects\OpenInTaskStream; use App\Filament\Concerns\WarnsOfConcurrentEditing; use App\Filament\Resources\Projects\ProjectResource; use App\Filament\Support\DuplicateAction; use App\Filament\Support\PreviewAction; use App\Filament\Support\TranslateAction; +use App\Services\Pireon\TokenStore; use App\Services\TaskStream\TaskStreamClient; use Filament\Actions\Action; use Filament\Actions\DeleteAction; +use Filament\Forms\Components\TextInput; +use Filament\Notifications\Notification; use Filament\Resources\Pages\EditRecord; +use Illuminate\Support\Facades\Auth; +use Illuminate\Validation\ValidationException; +use Symfony\Component\HttpKernel\Exception\HttpException; class EditProject extends EditRecord { @@ -32,9 +39,72 @@ class EditProject extends EditRecord ->url(fn () => app(TaskStreamClient::class)->projectUrl((string) $this->record->taskstream_key)) ->openUrlInNewTab() ->visible(fn () => filled($this->record->taskstream_key)), + $this->createInTaskStream(), DuplicateAction::make(), TranslateAction::make(), DeleteAction::make(), ]; } + + /** + * For a project made without a board: opens one now, as the person + * signed in, and saves only the link (whatever is being edited in the + * form stays as it is, unsaved). + */ + protected function createInTaskStream(): Action + { + return Action::make('createInTaskStream') + ->label(__('panel.projects.taskstream.create')) + ->icon('heroicon-o-plus') + ->color('gray') + ->visible(fn () => blank($this->record->taskstream_key)) + ->authorize('update') + ->modalHeading(__('panel.projects.taskstream.create_heading')) + ->modalDescription(__('panel.projects.taskstream.section_hint')) + ->modalSubmitActionLabel(__('panel.projects.taskstream.create')) + ->schema([ + TextInput::make('key') + ->label(__('panel.projects.taskstream.key')) + ->helperText(__('panel.projects.taskstream.key_hint')) + ->placeholder(fn () => app(OpenInTaskStream::class) + ->keyFrom((string) $this->record->getTranslation('slug', (string) config('locales.default'), false))) + ->maxLength(10) + ->regex('/^[A-Za-z][A-Za-z0-9]{1,9}$/'), + ]) + ->action(function (array $data, Action $action): void { + $project = $this->record; + + try { + app(OpenInTaskStream::class)->handle( + $project, + Auth::user(), + app(TokenStore::class)->accessToken(), + filled($data['key'] ?? null) ? $data['key'] : null, + ); + } catch (ValidationException|HttpException $e) { + // The modal stays open, to change the key and try again. + Notification::make() + ->danger() + ->title($e instanceof ValidationException ? collect($e->errors())->flatten()->first() : $e->getMessage()) + ->send(); + + $action->halt(); + } + + $project->save(); + + $url = app(TaskStreamClient::class)->projectUrl((string) $project->taskstream_key); + + Notification::make() + ->success() + ->title(__('panel.projects.taskstream.created', ['url' => $url])) + ->actions([ + Action::make('taskstream') + ->label(__('panel.projects.taskstream.open')) + ->url($url, shouldOpenInNewTab: true), + ]) + ->persistent() + ->send(); + }); + } } diff --git a/app/Http/Controllers/Api/V1/ProjectController.php b/app/Http/Controllers/Api/V1/ProjectController.php index 365551f..f7515ce 100644 --- a/app/Http/Controllers/Api/V1/ProjectController.php +++ b/app/Http/Controllers/Api/V1/ProjectController.php @@ -72,11 +72,22 @@ class ProjectController extends Controller return ProjectResource::make($project->load(self::WITH)); } - public function update(ProjectRequest $request, Project $project): ProjectResource + /** + * `taskstream: true` opens a board for a project that has none yet, as on + * creation (and nothing is saved if that fails); one that has a board + * keeps it. + */ + public function update(ProjectRequest $request, Project $project, OpenInTaskStream $openInTaskStream): ProjectResource { $this->authorize('update', $project); - return ProjectResource::make($this->persist($this->fill($project, $request), $request)); + $project = $this->fill($project, $request); + + if ($request->boolean('taskstream') && $project->taskstream_project_id === null) { + $openInTaskStream->handle($project, $request->user(), $request->bearerToken(), $request->validated('taskstream_key')); + } + + return ProjectResource::make($this->persist($project, $request)); } public function destroy(Project $project): JsonResponse diff --git a/app/Http/Requests/Api/V1/ProjectRequest.php b/app/Http/Requests/Api/V1/ProjectRequest.php index ff53595..a836ff7 100644 --- a/app/Http/Requests/Api/V1/ProjectRequest.php +++ b/app/Http/Requests/Api/V1/ProjectRequest.php @@ -20,8 +20,9 @@ use Illuminate\Validation\Validator; * Relations travel as ids and are replaced whole when sent: `technology_ids`, * `service_ids`, and `members` with each one's role on the project. * - * `taskstream` (on creation only) opens a public board in TaskStream for the - * project, keyed `taskstream_key` or one derived from the slug. + * `taskstream` opens a public board in TaskStream for the project, keyed + * `taskstream_key` or one derived from the slug; on an update, only when the + * project has none yet. */ class ProjectRequest extends FormRequest { @@ -78,10 +79,10 @@ class ProjectRequest extends FormRequest 'members.*.id' => ['required', 'integer', 'distinct', Rule::exists('members', 'id')->whereNull('deleted_at')], 'members.*.role' => ['nullable', 'string', 'max:120'], - // Only when creating; a client PATCHing back what it read sends - // the key it got, which is ignored rather than refused. - 'taskstream' => [$creating ? 'sometimes' : 'exclude', 'boolean'], - 'taskstream_key' => [$creating ? 'sometimes' : 'exclude', 'nullable', 'string', 'regex:/^[A-Za-z][A-Za-z0-9]{1,9}$/'], + // The key is only read along with `taskstream`: a client PATCHing + // back what it read sends the key it got, which changes nothing. + 'taskstream' => ['sometimes', 'boolean'], + 'taskstream_key' => ['sometimes', 'nullable', 'string', 'regex:/^[A-Za-z][A-Za-z0-9]{1,9}$/'], ]; } diff --git a/docs/manual-de-uso.html b/docs/manual-de-uso.html index 7c6c73d..6fe560f 100644 --- a/docs/manual-de-uso.html +++ b/docs/manual-de-uso.html @@ -383,8 +383,9 @@ catalán /ca/projectes/gestor-de-socis

Si TaskStream no lo acepta (porque esa clave ya existe, por ejemplo), el proyecto - tampoco se guarda aquí: cambia la clave y vuelve a guardar. Solo se puede hacer al - crearlo. + tampoco se guarda aquí: cambia la clave y vuelve a guardar. Si lo creaste sin tablero, + el botón Crear en TaskStream de su ficha se lo abre después, con la + misma clave a elegir.

Decisiones y postmortem

diff --git a/docs/manual-de-uso.pdf b/docs/manual-de-uso.pdf index 3565a50..3b14fcd 100644 Binary files a/docs/manual-de-uso.pdf and b/docs/manual-de-uso.pdf differ diff --git a/lang/ca/panel.php b/lang/ca/panel.php index cf689f4..8ebc12e 100644 --- a/lang/ca/panel.php +++ b/lang/ca/panel.php @@ -529,6 +529,8 @@ return [ 'key_hint' => 'De 2 a 10 lletres o xifres, començant per lletra. Si la deixes buida, es treu de l\'slug.', 'created' => 'Tauler creat a TaskStream: :url', 'open' => 'Obrir a TaskStream', + 'create' => 'Crear a TaskStream', + 'create_heading' => 'Obrir un tauler a TaskStream', ], 'filters' => ['unpublished' => 'Sense publicar'], 'actions' => ['publish' => 'Publica'], diff --git a/lang/en/panel.php b/lang/en/panel.php index 4f31def..ad7d92c 100644 --- a/lang/en/panel.php +++ b/lang/en/panel.php @@ -527,6 +527,8 @@ return [ 'key_hint' => '2 to 10 letters or digits, starting with a letter. Left empty, it is made from the slug.', 'created' => 'Board created in TaskStream: :url', 'open' => 'Open in TaskStream', + 'create' => 'Create in TaskStream', + 'create_heading' => 'Open a board in TaskStream', ], 'filters' => ['unpublished' => 'Unpublished'], 'actions' => ['publish' => 'Publish'], diff --git a/lang/es/panel.php b/lang/es/panel.php index a0048fd..30e63f3 100644 --- a/lang/es/panel.php +++ b/lang/es/panel.php @@ -529,6 +529,8 @@ return [ 'key_hint' => 'De 2 a 10 letras o cifras, empezando por letra. Si la dejas vacía, se saca del slug.', 'created' => 'Tablero creado en TaskStream: :url', 'open' => 'Abrir en TaskStream', + 'create' => 'Crear en TaskStream', + 'create_heading' => 'Abrir un tablero en TaskStream', ], 'filters' => ['unpublished' => 'Sin publicar'], 'actions' => ['publish' => 'Publicar'], diff --git a/public/openapi.yaml b/public/openapi.yaml index 375da4e..2605010 100644 --- a/public/openapi.yaml +++ b/public/openapi.yaml @@ -1621,7 +1621,14 @@ paths: patch: tags: [Projects] summary: Update a project - description: Partial update. Translatable fields merge into the existing map. + description: | + Partial update. Translatable fields merge into the existing map. + + `taskstream: true` opens a TaskStream board for a project that has none + yet, as on creation (`taskstream_key`, or one made from the slug); if + TaskStream refuses, the update is not saved either. A project that + already has a board keeps it, and `taskstream_key` without the flag is + ignored, so a client can PATCH back what it read. requestBody: required: true content: @@ -5574,12 +5581,12 @@ components: demo_url: { type: string, format: uri, nullable: true } taskstream: type: boolean - description: On creation only. Also open a public board for it in TaskStream; see `POST /v1/projects`. + description: Also open a public board for it in TaskStream, on creation or when it has none yet; see `POST /v1/projects`. taskstream_key: type: string nullable: true pattern: "^[A-Za-z][A-Za-z0-9]{1,9}$" - description: On creation only, with `taskstream`. The key in TaskStream; made from the slug when left out. + description: Only read with `taskstream`. The key in TaskStream; made from the slug when left out. cover_image: { type: string, nullable: true, description: A path from `POST /v1/media` with `for=projects`. } started_at: { type: string, format: date, nullable: true } ended_at: { type: string, format: date, nullable: true } diff --git a/tests/Feature/Api/TaskStreamProjectTest.php b/tests/Feature/Api/TaskStreamProjectTest.php index 04862bd..d20e157 100644 --- a/tests/Feature/Api/TaskStreamProjectTest.php +++ b/tests/Feature/Api/TaskStreamProjectTest.php @@ -18,8 +18,8 @@ use Tests\FakesPireon; use Tests\TestCase; /** - * `taskstream: true` on a new project opens its board in TaskStream too, and - * the answer carries the link. + * `taskstream: true` on a new project, or on one without a board yet, opens + * its board in TaskStream too, and the answer carries the link. */ class TaskStreamProjectTest extends TestCase { @@ -211,23 +211,64 @@ class TaskStreamProjectTest extends TestCase } #[Test] - public function an_update_ignores_the_flag(): void + public function an_update_opens_a_board_for_a_project_without_one(): void + { + $this->fakeTaskStream(); + $this->withHeaders($this->apiHeadersFor($this->editor())); + $project = Project::factory()->create(['slug' => ['es' => 'catalogo']]); + + $this->patchJson("/api/v1/projects/{$project->id}", ['taskstream' => true, 'needs_help' => true]) + ->assertOk() + ->assertJsonPath('data.taskstream_key', 'CATALOGO') + ->assertJsonPath('data.taskstream_url', 'https://taskstream.test/p/CATALOGO') + ->assertJsonPath('data.needs_help', true); + + $this->assertSame('ts-1', $project->fresh()->taskstream_project_id); + } + + #[Test] + public function a_project_with_a_board_keeps_it(): void + { + $this->fakeTaskStream(); + $this->withHeaders($this->apiHeadersFor($this->editor())); + $project = Project::factory()->create(['taskstream_project_id' => 'ts-0', 'taskstream_key' => 'CAT']); + + $this->patchJson("/api/v1/projects/{$project->id}", ['taskstream' => true, 'taskstream_key' => 'OTRA']) + ->assertOk() + ->assertJsonPath('data.taskstream_key', 'CAT'); + + $this->assertSame(0, $this->sentToTaskStream()); + } + + #[Test] + public function an_update_without_the_flag_ignores_the_key(): void { $this->fakeTaskStream(); $this->withHeaders($this->apiHeadersFor($this->editor())); $project = Project::factory()->create(); - $this->patchJson("/api/v1/projects/{$project->id}", [ - 'taskstream' => true, - 'taskstream_key' => 'OTRA', - 'needs_help' => true, - ]) + // What a client sends back after reading a project. + $this->patchJson("/api/v1/projects/{$project->id}", ['taskstream_key' => 'OTRA', 'needs_help' => true]) ->assertOk() ->assertJsonPath('data.taskstream_key', null); $this->assertSame(0, $this->sentToTaskStream()); } + #[Test] + public function a_refused_board_leaves_the_update_unsaved(): void + { + $this->fakeTaskStream(409, ['statusCode' => 409, 'message' => 'Project key WEB is already in use']); + $this->withHeaders($this->apiHeadersFor($this->editor())); + $project = Project::factory()->create(['needs_help' => false]); + + $this->patchJson("/api/v1/projects/{$project->id}", ['taskstream' => true, 'taskstream_key' => 'WEB', 'needs_help' => true]) + ->assertUnprocessable() + ->assertJsonValidationErrors('taskstream_key'); + + $this->assertFalse($project->fresh()->needs_help); + } + #[Test] public function keys_are_made_from_the_slug_in_taskstreams_style(): void { diff --git a/tests/Feature/Panel/TaskStreamPanelTest.php b/tests/Feature/Panel/TaskStreamPanelTest.php index e9c27d5..609cfc5 100644 --- a/tests/Feature/Panel/TaskStreamPanelTest.php +++ b/tests/Feature/Panel/TaskStreamPanelTest.php @@ -19,8 +19,9 @@ use Tests\FakesPireon; use Tests\TestCase; /** - * The panel's way to open a project's board in TaskStream: a toggle on the - * create form, as the person signed in. + * The panel's way to open a project's board in TaskStream, as the person + * signed in: a toggle on the create form, or an action on a project that + * has none yet. */ class TaskStreamPanelTest extends TestCase { @@ -131,4 +132,41 @@ class TaskStreamPanelTest extends TestCase $this->assertSame(0, Project::withTrashed()->count()); } + + #[Test] + public function a_project_without_a_board_can_get_one_later(): void + { + $this->fakeTaskStream(); + $project = Project::factory()->create(['slug' => ['es' => 'catalogo']]); + + Livewire::test(EditProject::class, ['record' => $project->getRouteKey()]) + ->assertActionHidden('taskstream') + ->assertActionVisible('createInTaskStream') + ->callAction('createInTaskStream', data: ['key' => 'cat']) + ->assertHasNoActionErrors() + ->assertNotified(); + + Http::assertSent(fn (Request $request) => $request->hasHeader('Authorization', 'Bearer session-token') + && $request['key'] === 'CAT' + && $request['isPublic'] === true); + + $this->assertSame('CAT', $project->fresh()->taskstream_key); + + Livewire::test(EditProject::class, ['record' => $project->getRouteKey()]) + ->assertActionVisible('taskstream') + ->assertActionHidden('createInTaskStream'); + } + + #[Test] + public function a_refusal_later_keeps_the_project_without_a_board(): void + { + $this->fakeTaskStream(409, ['statusCode' => 409, 'message' => 'Project key WEB is already in use']); + $project = Project::factory()->create(); + + Livewire::test(EditProject::class, ['record' => $project->getRouteKey()]) + ->callAction('createInTaskStream', data: ['key' => 'WEB']) + ->assertNotified(__('rules.taskstream.refused', ['reason' => 'Project key WEB is already in use'])); + + $this->assertNull($project->fresh()->taskstream_key); + } }