diff --git a/README.md b/README.md index 73243aa..5b7d83c 100644 --- a/README.md +++ b/README.md @@ -527,9 +527,11 @@ registrarla en `config/git.php`, sin tocar nada más. ## Tableros en TaskStream -Al crear un proyecto por la API, `taskstream: true` le abre también un tablero -en [TaskStream](https://taskstream.pireon.dev), el gestor de tareas del -colectivo, y la respuesta trae el enlace: +Al crear un proyecto, la casilla «Crear también en TaskStream» del panel o +`taskstream: true` en la API le abren también un tablero en +[TaskStream](https://taskstream.pireon.dev), el gestor de tareas del colectivo. +El panel avisa con el enlace y, al editar, ofrece «Abrir en TaskStream»; la API +lo devuelve en la respuesta: ```json POST /api/v1/projects @@ -548,7 +550,7 @@ POST /api/v1/projects responde, 502. Así no quedan proyectos a medias ni webhooks de algo que no existe. - **A nombre de quien lo pide**: TaskStream acepta los mismos tokens de Pireon - que esta API, y se le pasa el de quien llama. Un token de máquina (`pmt_…`) + 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 diff --git a/app/Filament/Resources/Projects/Pages/CreateProject.php b/app/Filament/Resources/Projects/Pages/CreateProject.php index c4d4140..8e99d5c 100644 --- a/app/Filament/Resources/Projects/Pages/CreateProject.php +++ b/app/Filament/Resources/Projects/Pages/CreateProject.php @@ -4,10 +4,79 @@ declare(strict_types=1); namespace App\Filament\Resources\Projects\Pages; +use App\Actions\Projects\OpenInTaskStream; use App\Filament\Resources\Projects\ProjectResource; +use App\Models\Project; +use App\Services\Pireon\TokenStore; +use App\Services\TaskStream\TaskStreamClient; +use Filament\Actions\Action; +use Filament\Notifications\Notification; use Filament\Resources\Pages\CreateRecord; +use Filament\Support\Exceptions\Halt; +use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Arr; +use Illuminate\Support\Facades\Auth; +use Illuminate\Validation\ValidationException; +use Symfony\Component\HttpKernel\Exception\HttpException; class CreateProject extends CreateRecord { protected static string $resource = ProjectResource::class; + + /** + * With the TaskStream toggle on, the board is opened before the project + * is saved, as the person signed in (their Pireon token from the session). + */ + protected function handleRecordCreation(array $data): Model + { + $taskstream = (bool) Arr::pull($data, 'taskstream', false); + $key = Arr::pull($data, 'taskstream_key'); + + $project = new Project($data); + + if ($taskstream) { + $this->openInTaskStream($project, filled($key) ? $key : null); + } + + $project->save(); + + return $project; + } + + protected function openInTaskStream(Project $project, ?string $key): void + { + try { + app(OpenInTaskStream::class)->handle($project, Auth::user(), app(TokenStore::class)->accessToken(), $key); + } catch (ValidationException $e) { + // The action names the API's fields; here they live under `data`. + throw ValidationException::withMessages( + collect($e->errors())->mapWithKeys(fn (array $messages, string $field) => ["data.{$field}" => $messages])->all(), + ); + } catch (HttpException $e) { + Notification::make()->danger()->title($e->getMessage())->send(); + + throw (new Halt)->rollBackDatabaseTransaction(); + } + } + + protected function getCreatedNotification(): ?Notification + { + $notification = parent::getCreatedNotification(); + $key = $this->getRecord()->taskstream_key; + + if ($notification === null || $key === null) { + return $notification; + } + + $url = app(TaskStreamClient::class)->projectUrl($key); + + return $notification + ->body(__('panel.projects.taskstream.created', ['url' => $url])) + ->actions([ + Action::make('taskstream') + ->label(__('panel.projects.taskstream.open')) + ->url($url, shouldOpenInNewTab: true), + ]) + ->persistent(); + } } diff --git a/app/Filament/Resources/Projects/Pages/EditProject.php b/app/Filament/Resources/Projects/Pages/EditProject.php index ecfd0c1..f0cddeb 100644 --- a/app/Filament/Resources/Projects/Pages/EditProject.php +++ b/app/Filament/Resources/Projects/Pages/EditProject.php @@ -9,6 +9,8 @@ use App\Filament\Resources\Projects\ProjectResource; use App\Filament\Support\DuplicateAction; use App\Filament\Support\PreviewAction; use App\Filament\Support\TranslateAction; +use App\Services\TaskStream\TaskStreamClient; +use Filament\Actions\Action; use Filament\Actions\DeleteAction; use Filament\Resources\Pages\EditRecord; @@ -23,6 +25,13 @@ class EditProject extends EditRecord return [ // Seeing it on the real site without leaving the form. PreviewAction::for('project'), + Action::make('taskstream') + ->label(__('panel.projects.taskstream.open')) + ->icon('heroicon-o-arrow-top-right-on-square') + ->color('gray') + ->url(fn () => app(TaskStreamClient::class)->projectUrl((string) $this->record->taskstream_key)) + ->openUrlInNewTab() + ->visible(fn () => filled($this->record->taskstream_key)), DuplicateAction::make(), TranslateAction::make(), DeleteAction::make(), diff --git a/app/Filament/Resources/Projects/Schemas/ProjectForm.php b/app/Filament/Resources/Projects/Schemas/ProjectForm.php index 38220e5..7e6b78c 100644 --- a/app/Filament/Resources/Projects/Schemas/ProjectForm.php +++ b/app/Filament/Resources/Projects/Schemas/ProjectForm.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Filament\Resources\Projects\Schemas; +use App\Actions\Projects\OpenInTaskStream; use App\Enums\ProjectStatus; use App\Filament\Resources\Projects\ProjectResource; use App\Filament\Support\TranslatableTabs; @@ -221,6 +222,27 @@ class ProjectForm ->label(__('panel.projects.fields.needs_help')) ->helperText(__('panel.projects.fields.needs_help_hint')), ]), + + // Only when creating: CreateProject opens the board before + // saving, so a refusal there leaves nothing half made here. + Section::make(__('panel.projects.taskstream.section')) + ->description(__('panel.projects.taskstream.section_hint')) + ->visibleOn('create') + ->columns(2) + ->schema([ + Toggle::make('taskstream') + ->label(__('panel.projects.taskstream.toggle')) + ->live(), + + TextInput::make('taskstream_key') + ->label(__('panel.projects.taskstream.key')) + ->helperText(__('panel.projects.taskstream.key_hint')) + ->placeholder(fn (callable $get) => app(OpenInTaskStream::class) + ->keyFrom((string) $get('slug.'.config('locales.default')))) + ->maxLength(10) + ->regex('/^[A-Za-z][A-Za-z0-9]{1,9}$/') + ->visible(fn (callable $get) => (bool) $get('taskstream')), + ]), ]); } } diff --git a/docs/manual-de-uso.html b/docs/manual-de-uso.html index c65151a..7c6c73d 100644 --- a/docs/manual-de-uso.html +++ b/docs/manual-de-uso.html @@ -372,6 +372,21 @@ catalán /ca/projectes/gestor-de-socis Busca genteLo saca en la sección de colaboración de la web. +

Su tablero en TaskStream

+

+ Al crearlo, la casilla Crear también en TaskStream le abre un tablero + público en TaskStream, el gestor de tareas + del colectivo, a tu nombre. La clave (la de los tickets: WEB-1, + WEB-2…) se saca del slug, o puedes escribirla tú: de 2 a 10 letras o + cifras, empezando por letra. Al guardar, el aviso trae el enlace, y desde entonces + el botón Abrir en TaskStream de la ficha lleva al tablero. +

+

+ 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. +

+

Decisiones y postmortem

Es la sección plegada del formulario, y es la que hace que un proyecto merezca diff --git a/docs/manual-de-uso.pdf b/docs/manual-de-uso.pdf index 81d4592..3565a50 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 eb4af34..cf689f4 100644 --- a/lang/ca/panel.php +++ b/lang/ca/panel.php @@ -521,6 +521,15 @@ return [ 'needs_help' => 'Busca gent', 'needs_help_hint' => 'El mostra a la secció de col·laboració del web.', ], + 'taskstream' => [ + 'section' => 'TaskStream', + 'section_hint' => 'Un tauler públic a TaskStream, el gestor de tasques del col·lectiu, al teu nom.', + 'toggle' => 'Crear-lo també a TaskStream', + 'key' => 'Clau a TaskStream', + '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', + ], 'filters' => ['unpublished' => 'Sense publicar'], 'actions' => ['publish' => 'Publica'], ], diff --git a/lang/en/panel.php b/lang/en/panel.php index 677ce45..4f31def 100644 --- a/lang/en/panel.php +++ b/lang/en/panel.php @@ -519,6 +519,15 @@ return [ 'needs_help' => 'Looking for help', 'needs_help_hint' => 'Surfaces it in the contribute section of the site.', ], + 'taskstream' => [ + 'section' => 'TaskStream', + 'section_hint' => 'A public board in TaskStream, the collective\'s issue tracker, in your name.', + 'toggle' => 'Also create it in TaskStream', + 'key' => 'Key in TaskStream', + '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', + ], 'filters' => ['unpublished' => 'Unpublished'], 'actions' => ['publish' => 'Publish'], ], diff --git a/lang/es/panel.php b/lang/es/panel.php index 430b905..a0048fd 100644 --- a/lang/es/panel.php +++ b/lang/es/panel.php @@ -521,6 +521,15 @@ return [ 'needs_help' => 'Busca gente', 'needs_help_hint' => 'Lo muestra en la sección de colaboración de la web.', ], + 'taskstream' => [ + 'section' => 'TaskStream', + 'section_hint' => 'Un tablero público en TaskStream, el gestor de tareas del colectivo, a tu nombre.', + 'toggle' => 'Crear también en TaskStream', + 'key' => 'Clave en TaskStream', + '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', + ], 'filters' => ['unpublished' => 'Sin publicar'], 'actions' => ['publish' => 'Publicar'], ], diff --git a/tests/Feature/Panel/TaskStreamPanelTest.php b/tests/Feature/Panel/TaskStreamPanelTest.php new file mode 100644 index 0000000..e9c27d5 --- /dev/null +++ b/tests/Feature/Panel/TaskStreamPanelTest.php @@ -0,0 +1,134 @@ +seed(RolePermissionSeeder::class); + $this->actingAs($this->userWithRoles('editor')); + + config([ + 'services.taskstream.api_url' => $this->taskstream, + 'services.taskstream.web_url' => 'https://taskstream.test', + ]); + + // What signing in to the panel leaves in the session. + app(TokenStore::class)->put(new AccessToken('session-token', null, now()->addHour()->toImmutable())); + } + + protected function fakeTaskStream(int $status = 201, array $body = []): void + { + Http::fake([ + "{$this->taskstream}/api/projects" => fn (Request $request) => Http::response( + $status === 201 ? ['id' => 'ts-1', 'key' => $request['key'], 'version' => 1] : $body, + $status, + ), + ]); + } + + #[Test] + public function the_toggle_opens_a_board_with_the_session_token(): void + { + $this->fakeTaskStream(); + + Livewire::test(CreateProject::class) + ->fillForm([ + 'title' => ['es' => 'Catálogo abierto'], + 'slug' => ['es' => 'catalogo'], + 'taskstream' => true, + ]) + ->assertFormFieldExists('taskstream_key') + ->call('create') + ->assertHasNoFormErrors() + ->assertNotified(); + + Http::assertSent(fn (Request $request) => $request->url() === "{$this->taskstream}/api/projects" + && $request->hasHeader('Authorization', 'Bearer session-token') + && $request['key'] === 'CATALOGO' + && $request['isPublic'] === true); + + $project = Project::query()->sole(); + $this->assertSame('CATALOGO', $project->taskstream_key); + + Livewire::test(EditProject::class, ['record' => $project->getRouteKey()]) + ->assertActionVisible('taskstream') + ->assertActionHasUrl('taskstream', 'https://taskstream.test/p/CATALOGO') + ->assertFormFieldDoesNotExist('taskstream'); + } + + #[Test] + public function without_the_toggle_taskstream_is_left_alone(): void + { + $this->fakeTaskStream(); + + Livewire::test(CreateProject::class) + ->fillForm(['title' => ['es' => 'Solo aquí'], 'slug' => ['es' => 'solo-aqui']]) + ->assertFormFieldHidden('taskstream_key') + ->call('create') + ->assertHasNoFormErrors(); + + Http::assertNothingSent(); + + Livewire::test(EditProject::class, ['record' => Project::query()->sole()->getRouteKey()]) + ->assertActionHidden('taskstream'); + } + + #[Test] + public function a_refusal_shows_on_the_key_and_saves_nothing(): void + { + $this->fakeTaskStream(409, ['statusCode' => 409, 'message' => 'Project key WEB is already in use']); + + Livewire::test(CreateProject::class) + ->fillForm([ + 'title' => ['es' => 'Web'], + 'slug' => ['es' => 'web'], + 'taskstream' => true, + 'taskstream_key' => 'web', + ]) + ->call('create') + ->assertHasFormErrors(['taskstream_key']); + + $this->assertSame(0, Project::withTrashed()->count()); + } + + #[Test] + public function taskstream_being_down_saves_nothing(): void + { + Http::fake(["{$this->taskstream}/*" => Http::failedConnection()]); + + Livewire::test(CreateProject::class) + ->fillForm(['title' => ['es' => 'Web'], 'slug' => ['es' => 'web'], 'taskstream' => true]) + ->call('create') + ->assertNotified(__('rules.taskstream.unavailable')); + + $this->assertSame(0, Project::withTrashed()->count()); + } +}