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.