Open a TaskStream board for a project that has none yet
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / test-postgres (push) Has been cancelled
Tests / mcp (push) Has been cancelled

The project form gets «Crear en TaskStream» while the project has no
board, and `taskstream: true` on a PATCH does the same through the API.
A refusal leaves the edit unsaved; a project with a board keeps it, and
`taskstream_key` without the flag is still ignored.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 20:21:38 +02:00
parent 7e7d2847fe
commit 3c119d425e
13 changed files with 207 additions and 28 deletions

View File

@@ -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_…`) 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 allí no significa nada, así que esas peticiones usan `TASKSTREAM_TOKEN`; sin
él, no pueden pedir tablero. él, no pueden pedir tablero.
- Solo al crear: en una edición los dos campos se ignoran, y un proyecto - **Después**: un proyecto que se creó sin tablero puede tenerlo luego, con
duplicado no hereda el tablero del original. «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 ```dotenv
TASKSTREAM_API_URL=https://api.taskstream.pireon.dev # por defecto TASKSTREAM_API_URL=https://api.taskstream.pireon.dev # por defecto

View File

@@ -13,11 +13,11 @@ use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\HttpException; use Symfony\Component\HttpKernel\Exception\HttpException;
/** /**
* Opens a board in TaskStream for a project that is about to be created * Opens a board in TaskStream for a project, new or one without a board
* here, and points the project at it. * yet, and points the project at it.
* *
* It runs before the project is saved: if TaskStream says no, nothing is * 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. * The board is public, like the project it tracks is meant to be.
*/ */
class OpenInTaskStream class OpenInTaskStream

View File

@@ -4,15 +4,22 @@ declare(strict_types=1);
namespace App\Filament\Resources\Projects\Pages; namespace App\Filament\Resources\Projects\Pages;
use App\Actions\Projects\OpenInTaskStream;
use App\Filament\Concerns\WarnsOfConcurrentEditing; use App\Filament\Concerns\WarnsOfConcurrentEditing;
use App\Filament\Resources\Projects\ProjectResource; use App\Filament\Resources\Projects\ProjectResource;
use App\Filament\Support\DuplicateAction; use App\Filament\Support\DuplicateAction;
use App\Filament\Support\PreviewAction; use App\Filament\Support\PreviewAction;
use App\Filament\Support\TranslateAction; use App\Filament\Support\TranslateAction;
use App\Services\Pireon\TokenStore;
use App\Services\TaskStream\TaskStreamClient; use App\Services\TaskStream\TaskStreamClient;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Actions\DeleteAction; use Filament\Actions\DeleteAction;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\EditRecord; use Filament\Resources\Pages\EditRecord;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\HttpException;
class EditProject extends EditRecord class EditProject extends EditRecord
{ {
@@ -32,9 +39,72 @@ class EditProject extends EditRecord
->url(fn () => app(TaskStreamClient::class)->projectUrl((string) $this->record->taskstream_key)) ->url(fn () => app(TaskStreamClient::class)->projectUrl((string) $this->record->taskstream_key))
->openUrlInNewTab() ->openUrlInNewTab()
->visible(fn () => filled($this->record->taskstream_key)), ->visible(fn () => filled($this->record->taskstream_key)),
$this->createInTaskStream(),
DuplicateAction::make(), DuplicateAction::make(),
TranslateAction::make(), TranslateAction::make(),
DeleteAction::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();
});
}
} }

View File

@@ -72,11 +72,22 @@ class ProjectController extends Controller
return ProjectResource::make($project->load(self::WITH)); 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); $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 public function destroy(Project $project): JsonResponse

View File

@@ -20,8 +20,9 @@ use Illuminate\Validation\Validator;
* Relations travel as ids and are replaced whole when sent: `technology_ids`, * Relations travel as ids and are replaced whole when sent: `technology_ids`,
* `service_ids`, and `members` with each one's role on the project. * `service_ids`, and `members` with each one's role on the project.
* *
* `taskstream` (on creation only) opens a public board in TaskStream for the * `taskstream` opens a public board in TaskStream for the project, keyed
* project, keyed `taskstream_key` or one derived from the slug. * `taskstream_key` or one derived from the slug; on an update, only when the
* project has none yet.
*/ */
class ProjectRequest extends FormRequest 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.*.id' => ['required', 'integer', 'distinct', Rule::exists('members', 'id')->whereNull('deleted_at')],
'members.*.role' => ['nullable', 'string', 'max:120'], 'members.*.role' => ['nullable', 'string', 'max:120'],
// Only when creating; a client PATCHing back what it read sends // The key is only read along with `taskstream`: a client PATCHing
// the key it got, which is ignored rather than refused. // back what it read sends the key it got, which changes nothing.
'taskstream' => [$creating ? 'sometimes' : 'exclude', 'boolean'], 'taskstream' => ['sometimes', 'boolean'],
'taskstream_key' => [$creating ? 'sometimes' : 'exclude', 'nullable', 'string', 'regex:/^[A-Za-z][A-Za-z0-9]{1,9}$/'], 'taskstream_key' => ['sometimes', 'nullable', 'string', 'regex:/^[A-Za-z][A-Za-z0-9]{1,9}$/'],
]; ];
} }

View File

@@ -383,8 +383,9 @@ catalán /ca/projectes/gestor-de-socis</code></pre>
</p> </p>
<p> <p>
Si TaskStream no lo acepta (porque esa clave ya existe, por ejemplo), el proyecto 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 tampoco se guarda aquí: cambia la clave y vuelve a guardar. Si lo creaste sin tablero,
crearlo. el botón <strong>Crear en TaskStream</strong> de su ficha se lo abre después, con la
misma clave a elegir.
</p> </p>
<h3>Decisiones y postmortem</h3> <h3>Decisiones y postmortem</h3>

Binary file not shown.

View File

@@ -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.', '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', 'created' => 'Tauler creat a TaskStream: :url',
'open' => 'Obrir a TaskStream', 'open' => 'Obrir a TaskStream',
'create' => 'Crear a TaskStream',
'create_heading' => 'Obrir un tauler a TaskStream',
], ],
'filters' => ['unpublished' => 'Sense publicar'], 'filters' => ['unpublished' => 'Sense publicar'],
'actions' => ['publish' => 'Publica'], 'actions' => ['publish' => 'Publica'],

View File

@@ -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.', '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', 'created' => 'Board created in TaskStream: :url',
'open' => 'Open in TaskStream', 'open' => 'Open in TaskStream',
'create' => 'Create in TaskStream',
'create_heading' => 'Open a board in TaskStream',
], ],
'filters' => ['unpublished' => 'Unpublished'], 'filters' => ['unpublished' => 'Unpublished'],
'actions' => ['publish' => 'Publish'], 'actions' => ['publish' => 'Publish'],

View File

@@ -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.', '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', 'created' => 'Tablero creado en TaskStream: :url',
'open' => 'Abrir en TaskStream', 'open' => 'Abrir en TaskStream',
'create' => 'Crear en TaskStream',
'create_heading' => 'Abrir un tablero en TaskStream',
], ],
'filters' => ['unpublished' => 'Sin publicar'], 'filters' => ['unpublished' => 'Sin publicar'],
'actions' => ['publish' => 'Publicar'], 'actions' => ['publish' => 'Publicar'],

View File

@@ -1621,7 +1621,14 @@ paths:
patch: patch:
tags: [Projects] tags: [Projects]
summary: Update a project 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: requestBody:
required: true required: true
content: content:
@@ -5574,12 +5581,12 @@ components:
demo_url: { type: string, format: uri, nullable: true } demo_url: { type: string, format: uri, nullable: true }
taskstream: taskstream:
type: boolean 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: taskstream_key:
type: string type: string
nullable: true nullable: true
pattern: "^[A-Za-z][A-Za-z0-9]{1,9}$" 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`. } cover_image: { type: string, nullable: true, description: A path from `POST /v1/media` with `for=projects`. }
started_at: { type: string, format: date, nullable: true } started_at: { type: string, format: date, nullable: true }
ended_at: { type: string, format: date, nullable: true } ended_at: { type: string, format: date, nullable: true }

View File

@@ -18,8 +18,8 @@ use Tests\FakesPireon;
use Tests\TestCase; use Tests\TestCase;
/** /**
* `taskstream: true` on a new project opens its board in TaskStream too, and * `taskstream: true` on a new project, or on one without a board yet, opens
* the answer carries the link. * its board in TaskStream too, and the answer carries the link.
*/ */
class TaskStreamProjectTest extends TestCase class TaskStreamProjectTest extends TestCase
{ {
@@ -211,23 +211,64 @@ class TaskStreamProjectTest extends TestCase
} }
#[Test] #[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->fakeTaskStream();
$this->withHeaders($this->apiHeadersFor($this->editor())); $this->withHeaders($this->apiHeadersFor($this->editor()));
$project = Project::factory()->create(); $project = Project::factory()->create();
$this->patchJson("/api/v1/projects/{$project->id}", [ // What a client sends back after reading a project.
'taskstream' => true, $this->patchJson("/api/v1/projects/{$project->id}", ['taskstream_key' => 'OTRA', 'needs_help' => true])
'taskstream_key' => 'OTRA',
'needs_help' => true,
])
->assertOk() ->assertOk()
->assertJsonPath('data.taskstream_key', null); ->assertJsonPath('data.taskstream_key', null);
$this->assertSame(0, $this->sentToTaskStream()); $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] #[Test]
public function keys_are_made_from_the_slug_in_taskstreams_style(): void public function keys_are_made_from_the_slug_in_taskstreams_style(): void
{ {

View File

@@ -19,8 +19,9 @@ use Tests\FakesPireon;
use Tests\TestCase; use Tests\TestCase;
/** /**
* The panel's way to open a project's board in TaskStream: a toggle on the * The panel's way to open a project's board in TaskStream, as the person
* create form, as the person signed in. * signed in: a toggle on the create form, or an action on a project that
* has none yet.
*/ */
class TaskStreamPanelTest extends TestCase class TaskStreamPanelTest extends TestCase
{ {
@@ -131,4 +132,41 @@ class TaskStreamPanelTest extends TestCase
$this->assertSame(0, Project::withTrashed()->count()); $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);
}
} }