Open a TaskStream board from the panel's project form
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:
10
README.md
10
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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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')),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</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>
|
||||
<p>
|
||||
Es la sección plegada del formulario, y es la que hace que un proyecto merezca
|
||||
|
||||
Binary file not shown.
@@ -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'],
|
||||
],
|
||||
|
||||
@@ -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'],
|
||||
],
|
||||
|
||||
@@ -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'],
|
||||
],
|
||||
|
||||
134
tests/Feature/Panel/TaskStreamPanelTest.php
Normal file
134
tests/Feature/Panel/TaskStreamPanelTest.php
Normal 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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user