Open a TaskStream board from the panel's project form
Some checks failed
Tests / test (push) Has been cancelled
Tests / test-postgres (push) Has been cancelled
Tests / mcp (push) Has been cancelled
Tests / lint (push) Has been cancelled

A "TaskStream" section on the create form (toggle and optional key,
suggested from the slug) opens the board before saving, as the person
signed in, with the Pireon token of their session. A refusal shows on the
key field and a TaskStream outage as a notification; either way nothing is
saved. The created notification carries the link, and the edit page has
an "Open in TaskStream" button.

Documented in the user manual (and its PDF) and the README.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 23:01:55 +02:00
parent cdfc8763a5
commit b32d6bf001
10 changed files with 282 additions and 4 deletions

View File

@@ -527,9 +527,11 @@ registrarla en `config/git.php`, sin tocar nada más.
## Tableros en TaskStream ## Tableros en TaskStream
Al crear un proyecto por la API, `taskstream: true` le abre también un tablero Al crear un proyecto, la casilla «Crear también en TaskStream» del panel o
en [TaskStream](https://taskstream.pireon.dev), el gestor de tareas del `taskstream: true` en la API le abren también un tablero en
colectivo, y la respuesta trae el enlace: [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 ```json
POST /api/v1/projects 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 responde, 502. Así no quedan proyectos a medias ni webhooks de algo que no
existe. existe.
- **A nombre de quien lo pide**: TaskStream acepta los mismos tokens de Pireon - **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 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 - Solo al crear: en una edición los dos campos se ignoran, y un proyecto

View File

@@ -4,10 +4,79 @@ declare(strict_types=1);
namespace App\Filament\Resources\Projects\Pages; namespace App\Filament\Resources\Projects\Pages;
use App\Actions\Projects\OpenInTaskStream;
use App\Filament\Resources\Projects\ProjectResource; 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\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 class CreateProject extends CreateRecord
{ {
protected static string $resource = ProjectResource::class; 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();
}
} }

View File

@@ -9,6 +9,8 @@ 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\TaskStream\TaskStreamClient;
use Filament\Actions\Action;
use Filament\Actions\DeleteAction; use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord; use Filament\Resources\Pages\EditRecord;
@@ -23,6 +25,13 @@ class EditProject extends EditRecord
return [ return [
// Seeing it on the real site without leaving the form. // Seeing it on the real site without leaving the form.
PreviewAction::for('project'), 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(), DuplicateAction::make(),
TranslateAction::make(), TranslateAction::make(),
DeleteAction::make(), DeleteAction::make(),

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Filament\Resources\Projects\Schemas; namespace App\Filament\Resources\Projects\Schemas;
use App\Actions\Projects\OpenInTaskStream;
use App\Enums\ProjectStatus; use App\Enums\ProjectStatus;
use App\Filament\Resources\Projects\ProjectResource; use App\Filament\Resources\Projects\ProjectResource;
use App\Filament\Support\TranslatableTabs; use App\Filament\Support\TranslatableTabs;
@@ -221,6 +222,27 @@ class ProjectForm
->label(__('panel.projects.fields.needs_help')) ->label(__('panel.projects.fields.needs_help'))
->helperText(__('panel.projects.fields.needs_help_hint')), ->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')),
]),
]); ]);
} }
} }

View File

@@ -372,6 +372,21 @@ catalán /ca/projectes/gestor-de-socis</code></pre>
<tr><td>Busca gente</td><td>Lo saca en la sección de colaboración de la web.</td></tr> <tr><td>Busca gente</td><td>Lo saca en la sección de colaboración de la web.</td></tr>
</table> </table>
<h3>Su tablero en TaskStream</h3>
<p>
Al crearlo, la casilla <strong>Crear también en TaskStream</strong> le abre un tablero
público en <a href="https://taskstream.pireon.dev">TaskStream</a>, el gestor de tareas
del colectivo, a tu nombre. La clave (la de los tickets: <code>WEB-1</code>,
<code>WEB-2</code>…) 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 <strong>Abrir en TaskStream</strong> de la ficha lleva al tablero.
</p>
<p>
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.
</p>
<h3>Decisiones y postmortem</h3> <h3>Decisiones y postmortem</h3>
<p> <p>
Es la sección plegada del formulario, y es la que hace que un proyecto merezca Es la sección plegada del formulario, y es la que hace que un proyecto merezca

Binary file not shown.

View File

@@ -521,6 +521,15 @@ return [
'needs_help' => 'Busca gent', 'needs_help' => 'Busca gent',
'needs_help_hint' => 'El mostra a la secció de col·laboració del web.', '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'], 'filters' => ['unpublished' => 'Sense publicar'],
'actions' => ['publish' => 'Publica'], 'actions' => ['publish' => 'Publica'],
], ],

View File

@@ -519,6 +519,15 @@ return [
'needs_help' => 'Looking for help', 'needs_help' => 'Looking for help',
'needs_help_hint' => 'Surfaces it in the contribute section of the site.', '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'], 'filters' => ['unpublished' => 'Unpublished'],
'actions' => ['publish' => 'Publish'], 'actions' => ['publish' => 'Publish'],
], ],

View File

@@ -521,6 +521,15 @@ return [
'needs_help' => 'Busca gente', 'needs_help' => 'Busca gente',
'needs_help_hint' => 'Lo muestra en la sección de colaboración de la web.', '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'], 'filters' => ['unpublished' => 'Sin publicar'],
'actions' => ['publish' => 'Publicar'], 'actions' => ['publish' => 'Publicar'],
], ],

View File

@@ -0,0 +1,134 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Panel;
use App\Filament\Resources\Projects\Pages\CreateProject;
use App\Filament\Resources\Projects\Pages\EditProject;
use App\Models\Project;
use App\Services\Pireon\AccessToken;
use App\Services\Pireon\TokenStore;
use Database\Seeders\RolePermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use Livewire\Livewire;
use PHPUnit\Framework\Attributes\Test;
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.
*/
class TaskStreamPanelTest extends TestCase
{
use FakesPireon, RefreshDatabase;
protected string $taskstream = 'https://api.taskstream.test';
protected function setUp(): void
{
parent::setUp();
$this->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());
}
}