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>
249 lines
11 KiB
PHP
249 lines
11 KiB
PHP
<?php
|
|
|
|
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;
|
|
use App\Models\Project;
|
|
use App\Rules\UniqueTranslatedSlug;
|
|
use App\Support\DuplicateTitles;
|
|
use Filament\Forms\Components\DatePicker;
|
|
use Filament\Forms\Components\DateTimePicker;
|
|
use Filament\Forms\Components\FileUpload;
|
|
use Filament\Forms\Components\RichEditor;
|
|
use Filament\Forms\Components\Select;
|
|
use Filament\Forms\Components\Textarea;
|
|
use Filament\Forms\Components\TextInput;
|
|
use Filament\Forms\Components\Toggle;
|
|
use Filament\Schemas\Components\Section;
|
|
use Filament\Schemas\Schema;
|
|
use Illuminate\Database\Eloquent\Builder;
|
|
use Illuminate\Database\Eloquent\Model;
|
|
use Illuminate\Support\Facades\Auth;
|
|
use Illuminate\Support\Str;
|
|
|
|
class ProjectForm
|
|
{
|
|
public static function configure(Schema $schema): Schema
|
|
{
|
|
return $schema->components([
|
|
Section::make(__('panel.projects.sections.content'))
|
|
->schema([
|
|
TranslatableTabs::make(fn (string $locale, bool $isDefault) => [
|
|
TextInput::make("title.{$locale}")
|
|
->label(__('panel.projects.fields.title'))
|
|
->required($isDefault)
|
|
->maxLength(255)
|
|
->columnSpanFull()
|
|
->live(onBlur: true)
|
|
->afterStateUpdated(function (?string $state, ?Model $record, callable $get, callable $set) use ($locale, $isDefault) {
|
|
if (blank($get("slug.{$locale}"))) {
|
|
$set("slug.{$locale}", Str::slug((string) $state));
|
|
}
|
|
|
|
if ($isDefault) {
|
|
DuplicateTitles::warnIfSimilar(
|
|
Project::class,
|
|
ProjectResource::class,
|
|
$state,
|
|
$locale,
|
|
$record?->getKey(),
|
|
);
|
|
}
|
|
}),
|
|
|
|
TextInput::make("slug.{$locale}")
|
|
->label(__('panel.projects.fields.slug'))
|
|
->required($isDefault)
|
|
->maxLength(255)
|
|
->columnSpanFull()
|
|
->rules(fn ($record) => [new UniqueTranslatedSlug(Project::class, $record?->id)]),
|
|
|
|
Textarea::make("tagline.{$locale}")
|
|
->label(__('panel.projects.fields.tagline'))
|
|
->rows(2)
|
|
->maxLength(300)
|
|
->columnSpanFull(),
|
|
|
|
RichEditor::make("description.{$locale}")
|
|
->label(__('panel.projects.fields.description'))
|
|
->columnSpanFull(),
|
|
]),
|
|
]),
|
|
|
|
// The part that makes a project worth reading rather than just
|
|
// looking at. Collapsed by default so it never blocks publishing.
|
|
Section::make(__('panel.projects.sections.writeup'))
|
|
->description(__('panel.projects.sections.writeup_hint'))
|
|
->collapsed()
|
|
->schema([
|
|
TranslatableTabs::make(fn (string $locale) => [
|
|
RichEditor::make("decisions.{$locale}")
|
|
->label(__('panel.projects.fields.decisions'))
|
|
->helperText(__('panel.projects.fields.decisions_hint'))
|
|
->columnSpanFull(),
|
|
|
|
RichEditor::make("postmortem.{$locale}")
|
|
->label(__('panel.projects.fields.postmortem'))
|
|
->helperText(__('panel.projects.fields.postmortem_hint'))
|
|
->columnSpanFull(),
|
|
], label: __('panel.projects.sections.writeup')),
|
|
]),
|
|
|
|
Section::make(__('panel.projects.sections.details'))
|
|
->columns(2)
|
|
->schema([
|
|
Select::make('status')
|
|
->label(__('panel.projects.fields.status'))
|
|
->options(ProjectStatus::class)
|
|
->default(ProjectStatus::Idea)
|
|
->required(),
|
|
|
|
DateTimePicker::make('published_at')
|
|
->label(__('panel.projects.fields.published_at'))
|
|
->seconds(false)
|
|
->helperText(__('panel.projects.fields.published_at_hint'))
|
|
->disabled(fn () => Auth::user()?->cannot('projects.publish'))
|
|
->dehydrated(),
|
|
|
|
DateTimePicker::make('unpublish_at')
|
|
->label(__('panel.projects.fields.unpublish_at'))
|
|
->helperText(__('panel.projects.fields.unpublish_at_hint'))
|
|
->seconds(false)
|
|
->after('published_at')
|
|
->disabled(fn () => Auth::user()?->cannot('projects.publish'))
|
|
->dehydrated(),
|
|
|
|
Select::make('technologies')
|
|
->label(__('panel.projects.fields.technologies'))
|
|
->relationship('technologies', 'name')
|
|
->multiple()
|
|
->preload()
|
|
->searchable()
|
|
->columnSpanFull(),
|
|
|
|
Select::make('lead_member_id')
|
|
->label(__('panel.projects.fields.lead'))
|
|
->relationship('lead', 'name')
|
|
->searchable()
|
|
->preload(),
|
|
|
|
Select::make('members')
|
|
->label(__('panel.projects.fields.members'))
|
|
->relationship('members', 'name')
|
|
->multiple()
|
|
->preload()
|
|
->searchable(),
|
|
|
|
// Was two free-text fields, which made the same
|
|
// association two entities and left the logo nowhere to
|
|
// live. New ones can still be added without leaving the
|
|
// project.
|
|
Select::make('client_id')
|
|
->label(__('panel.projects.fields.client'))
|
|
->helperText(__('panel.projects.fields.client_hint'))
|
|
->relationship('client', 'name')
|
|
->searchable()
|
|
->preload()
|
|
->createOptionForm([
|
|
TextInput::make('name')
|
|
->label(__('panel.clients.fields.name'))
|
|
->required()
|
|
->maxLength(255),
|
|
|
|
TextInput::make('slug')
|
|
->label(__('panel.clients.fields.slug'))
|
|
->required()
|
|
->maxLength(255)
|
|
->rules(['unique:clients,slug']),
|
|
|
|
TextInput::make('url')
|
|
->label(__('panel.clients.fields.url'))
|
|
->url()
|
|
->maxLength(2048),
|
|
]),
|
|
|
|
Select::make('services')
|
|
->label(__('panel.projects.fields.services'))
|
|
// The order is given explicitly because Filament
|
|
// would otherwise sort a `select distinct` by a JSON
|
|
// path, and Postgres refuses an ORDER BY expression
|
|
// that is not in the select list.
|
|
->relationship(
|
|
name: 'services',
|
|
titleAttribute: 'title->'.config('locales.default'),
|
|
modifyQueryUsing: fn (Builder $query) => $query->orderBy('position'),
|
|
)
|
|
->multiple()
|
|
->preload()
|
|
->searchable(),
|
|
|
|
TextInput::make('demo_url')
|
|
->label(__('panel.projects.fields.demo_url'))
|
|
->url()
|
|
->maxLength(2048),
|
|
|
|
FileUpload::make('cover_image')
|
|
->label(__('panel.projects.fields.cover_image'))
|
|
->image()
|
|
// Explicit: the default disk is private, and the
|
|
// frontend runs on another host.
|
|
->disk(config('media.disk'))
|
|
->directory('projects')
|
|
->imageEditor()
|
|
->live(),
|
|
|
|
TranslatableTabs::make(fn (string $locale) => [
|
|
TextInput::make("cover_image_alt.{$locale}")
|
|
->label(__('panel.projects.fields.cover_image_alt'))
|
|
->helperText(__('panel.projects.fields.cover_image_alt_hint'))
|
|
->maxLength(255)
|
|
->columnSpanFull(),
|
|
])
|
|
->visible(fn (callable $get) => filled($get('cover_image')))
|
|
->columnSpanFull(),
|
|
|
|
DatePicker::make('started_at')
|
|
->label(__('panel.projects.fields.started_at')),
|
|
|
|
DatePicker::make('ended_at')
|
|
->label(__('panel.projects.fields.ended_at')),
|
|
|
|
Toggle::make('is_featured')
|
|
->label(__('panel.projects.fields.is_featured')),
|
|
|
|
// Drives the "we need help" listings on the public site,
|
|
// which is the main thing the site is trying to achieve.
|
|
Toggle::make('needs_help')
|
|
->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')),
|
|
]),
|
|
]);
|
|
}
|
|
}
|