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')), ]), ]); } }