From c951e936ee65d484a56e85f9ae9aeb5753d77f82 Mon Sep 17 00:00:00 2001 From: Manuel Christlieb Date: Sat, 5 Sep 2026 16:44:16 +0200 Subject: [PATCH 1/5] fix(css): point Tailwind at the Lattice runtime packages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @lattice-php/ui/css sources its own dist and the Vite plugin sources every discovered component package, but neither covers the sibling runtime packages. @lattice-php/core owns the responsive-visibility wrapper (`hidden md:contents`), so `md:contents` was never generated and every visibleFrom()/hiddenFrom() node stayed hidden at all widths — silently, since the class simply did not exist. The same glob restores the table header and cell utilities that only appear in the table package's dist, which is why the members and invitations tables rendered without a header row. --- .ai/rules/frontend.md | 8 ++++++++ resources/css/app.css | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/.ai/rules/frontend.md b/.ai/rules/frontend.md index b4e8e20..d1c02b1 100644 --- a/.ai/rules/frontend.md +++ b/.ai/rules/frontend.md @@ -25,6 +25,14 @@ dependencies — do not reach through the umbrella for something it does not re- `declare module "@lattice-php/core"` block for a component that has a PHP counterpart; run `php artisan lattice:typescript` instead. +## Tailwind has to be pointed at the Lattice packages + +`@lattice-php/ui/css` sources its own `dist`, and the Vite plugin sources every discovered _component_ package — but +neither covers the sibling runtime packages. `@lattice-php/core` owns the responsive-visibility wrapper +(`hidden md:contents`), so without the `@source '../../node_modules/@lattice-php/*/dist'` glob in `app.css`, +`visibleFrom()`/`hiddenFrom()` compile to a class that does not exist and hide the node at every width. Keep the glob, +and suspect it first when a Lattice utility class silently does nothing. + ## Lattice owns the design tokens `@import '@lattice-php/lattice/css'` pulls in the token layer. Override a token unlayered on `:root` so it wins diff --git a/resources/css/app.css b/resources/css/app.css index da792a3..2aeb525 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -4,6 +4,14 @@ @import '@fontsource-variable/instrument-sans/wght.css'; @source '../views'; +/* + * The lattice vite plugin sources every discovered *component* package, and + * @lattice-php/ui/css sources its own dist — but not the sibling runtime + * packages. @lattice-php/core owns the responsive-visibility wrapper + * (`hidden md:contents`), so without this glob `visibleFrom()`/`hiddenFrom()` + * silently hide a node at every width. + */ +@source '../../node_modules/@lattice-php/*/dist'; @source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php'; @custom-variant dark (&:is(.dark *)); From a7e666752fae296ac155293072f0d13440b6b915 Mon Sep 17 00:00:00 2001 From: Manuel Christlieb Date: Sat, 5 Sep 2026 16:44:26 +0200 Subject: [PATCH 2/5] fix(ui): give the sidebar a trigger and render the breadcrumb trail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sidebar was collapsible but nothing could toggle it, and Lattice ships no trigger of its own — below the md breakpoint the drawer stayed shut, so a phone had no team switcher, no navigation and no way to log out. A topbar now carries the toggle, the breadcrumb trail, and the user menu. DashboardPage has declared breadcrumbs() since it was written, but no layout ever rendered a Breadcrumbs node, so the trail was dead code. Callouts joins it so an effect flashed from a controller or listener has somewhere to land. --- app/Layouts/AppLayout.php | 52 +++++++++++++++++++++++--------- lang/de/navigation.php | 1 + lang/en/navigation.php | 1 + tests/Browser/NavigationTest.php | 19 ++++++++++++ tests/Browser/TeamsTest.php | 1 + 5 files changed, 59 insertions(+), 15 deletions(-) create mode 100644 tests/Browser/NavigationTest.php diff --git a/app/Layouts/AppLayout.php b/app/Layouts/AppLayout.php index a30e78d..e95d5d2 100644 --- a/app/Layouts/AppLayout.php +++ b/app/Layouts/AppLayout.php @@ -11,11 +11,16 @@ use App\Pages\SettingsPage; use Illuminate\Http\Request; use Lattice\Core\Attributes\AsLayout; +use Lattice\Core\Enums\Breakpoint; use Lattice\Core\Enums\ColorName; use Lattice\Core\Support\Affix; +use Lattice\Facades\Effects; use Lattice\Layouts\Components\Outlet; use Lattice\Layouts\LayoutDefinition; use Lattice\Ui\Components\Avatar; +use Lattice\Ui\Components\Breadcrumbs; +use Lattice\Ui\Components\Button; +use Lattice\Ui\Components\Callouts; use Lattice\Ui\Components\Component; use Lattice\Ui\Components\Dropdown; use Lattice\Ui\Components\Icon as IconComponent; @@ -24,15 +29,17 @@ use Lattice\Ui\Components\Sidebar; use Lattice\Ui\Components\Stack; use Lattice\Ui\Components\Text; +use Lattice\Ui\Components\Topbar; use Lattice\Ui\Enums\Align; use Lattice\Ui\Enums\AvatarShape; +use Lattice\Ui\Enums\Emphasis; use Lattice\Ui\Enums\Gap; use Lattice\Ui\Enums\Height; use Lattice\Ui\Enums\HttpMethod; use Lattice\Ui\Enums\Icon; -use Lattice\Ui\Enums\Justify; use Lattice\Ui\Enums\Orientation; use Lattice\Ui\Enums\Placement; +use Lattice\Ui\Enums\Side; use Lattice\Ui\Enums\Size; use Lattice\Ui\Enums\Width; use Lattice\Ui\PageSchema; @@ -51,23 +58,37 @@ public function schema(PageSchema $schema, Request $request): PageSchema ->direction(Orientation::Horizontal) ->height(Height::Screen) ->schema([ - Sidebar::make('app-sidebar')->collapsible()->items([ - Stack::make('sidebar-body') - ->width(Width::Fill) - ->justify(Justify::Between) - ->schema([ - Stack::make('sidebar-top')->schema([ - $this->teamSwitcher($user), - Menu::make('sidebar')->items([ - MenuItem::fromPage(DashboardPage::class)->label(__('navigation.dashboard'))->prefix(Icon::LayoutDashboard), - ]), - ]), - $this->userMenu($user), + Sidebar::make('app-sidebar') + ->collapsible() + ->items([ + $this->teamSwitcher($user), + Menu::make('sidebar')->items([ + MenuItem::fromPage(DashboardPage::class)->label(__('navigation.dashboard'))->prefix(Icon::LayoutDashboard), ]), - ]), + ]), Stack::make('app-main') ->width(Width::Fill) ->schema([ + Topbar::make('app-topbar')->sticky()->items([ + // The sidebar ships no trigger of its own: without this + // button the mobile drawer can never open and the desktop + // rail can never collapse. + Button::make(__('navigation.toggle-sidebar'), 'sidebar-toggle') + ->icon(Icon::PanelLeft) + ->emphasis(Emphasis::Ghost) + ->effects(Effects::toggleSidebar('app-sidebar')), + Breadcrumbs::make('app-breadcrumbs')->visibleFrom(Breakpoint::Md), + Stack::make('topbar-end') + ->float(Side::End) + ->width(Width::Auto) + ->direction(Orientation::Horizontal) + ->align(Align::Center) + ->gap(Gap::Small) + ->schema([ + $this->userMenu($user), + ]), + ]), + Callouts::make('app-callouts'), Outlet::make(), ]), ]), @@ -100,7 +121,7 @@ private function teamSwitcher(User $user): Dropdown private function userMenu(User $user): Dropdown { return Dropdown::make('user-menu') - ->placement(Placement::Top) + ->placement(Placement::Bottom) ->trigger([ Stack::make() ->direction(Orientation::Horizontal) @@ -111,6 +132,7 @@ private function userMenu(User $user): Dropdown Stack::make() ->width(Width::Fill) ->gap(Gap::None) + ->visibleFrom(Breakpoint::Md) ->schema([ Text::make($user->name) ->size(Size::Sm) diff --git a/lang/de/navigation.php b/lang/de/navigation.php index 6dcd115..cbc342b 100644 --- a/lang/de/navigation.php +++ b/lang/de/navigation.php @@ -5,5 +5,6 @@ 'dashboard' => 'Dashboard', 'settings' => 'Einstellungen', 'team-settings' => 'Team-Einstellungen', + 'toggle-sidebar' => 'Seitenleiste umschalten', 'select-team' => 'Team auswählen', ]; diff --git a/lang/en/navigation.php b/lang/en/navigation.php index 6bed68a..20949df 100644 --- a/lang/en/navigation.php +++ b/lang/en/navigation.php @@ -5,5 +5,6 @@ 'dashboard' => 'Dashboard', 'settings' => 'Settings', 'team-settings' => 'Team settings', + 'toggle-sidebar' => 'Toggle sidebar', 'select-team' => 'Select team', ]; diff --git a/tests/Browser/NavigationTest.php b/tests/Browser/NavigationTest.php new file mode 100644 index 0000000..29b3fbb --- /dev/null +++ b/tests/Browser/NavigationTest.php @@ -0,0 +1,19 @@ +create(['name' => 'Ada Lovelace']); + + $this->actingAs($user); + + // The sidebar ships no trigger of its own, so the topbar button is the only + // way into the navigation below the md breakpoint. + visit('/dashboard')->on()->mobile() + ->assertMissing('[data-test="sidebar-backdrop"]') + ->click('[data-test="sidebar-toggle"]') + ->assertVisible('[data-test="sidebar-backdrop"]') + ->assertSee("Ada Lovelace's Team") + ->assertNoJavaScriptErrors(); +}); diff --git a/tests/Browser/TeamsTest.php b/tests/Browser/TeamsTest.php index c0a2620..7354262 100644 --- a/tests/Browser/TeamsTest.php +++ b/tests/Browser/TeamsTest.php @@ -39,6 +39,7 @@ ->assertNoJavaScriptErrors() ->click('[data-test="action-teams.'.$team->id.'.edit"]') ->assertPathIs('/settings/teams/lattice-core') + ->assertSeeIn('[data-test="app-breadcrumbs"]', 'Lattice Core') ->assertSee('Manage team settings, members, and invitations.') ->assertSee('Grace Hopper') ->click('[data-test="teams.members.'.$member->id.'.actions"]') From 6bcebcd59d7e1143ae3ea16b2568b4712c84e84b Mon Sep 17 00:00:00 2001 From: Manuel Christlieb Date: Sat, 5 Sep 2026 16:44:34 +0200 Subject: [PATCH 3/5] perf(teams): stop querying once per table row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TeamsTable asked teamRole() for a role the membership pivot had already loaded — 8 queries for 6 teams. User::teams() now uses the Membership pivot class, so the cast role is read straight off the row: 1 query. TeamMembersTable and TeamInvitationsTable ran their policy checks inside actions(), which a table calls once per row: 13 queries for 6 members. The new AuthorizesRowActions concern caches the answer on the definition instance, which lives exactly one render — 2 queries, one per ability. Caching on the User model instead looked tempting and is wrong: a later attach() leaves it stale, which is exactly what the suite caught. --- .ai/rules/lattice.md | 6 +++++ app/Models/User.php | 3 ++- app/Tables/Concerns/AuthorizesRowActions.php | 24 ++++++++++++++++++++ app/Tables/Teams/TeamInvitationsTable.php | 7 +++--- app/Tables/Teams/TeamMembersTable.php | 10 ++++---- app/Tables/Teams/TeamsTable.php | 6 +++-- 6 files changed, 46 insertions(+), 10 deletions(-) create mode 100644 app/Tables/Concerns/AuthorizesRowActions.php diff --git a/.ai/rules/lattice.md b/.ai/rules/lattice.md index 14ef27b..773dc49 100644 --- a/.ai/rules/lattice.md +++ b/.ai/rules/lattice.md @@ -49,6 +49,12 @@ Lattice validates a form or action form before `handle()` runs and passes the re rest of the `ValidatedInput` API. Never call `$this->validate($request)` yourself, and never read raw request input for a field the schema declares. +## A table calls actions() once per row + +An unmemoized `$user->can(...)` in `actions()` is a query per row. `App\Tables\Concerns\AuthorizesRowActions` caches +the answer on the definition instance, which lives exactly one render — the window in which it cannot change. Reach +for it rather than a cache on the model, where a later `attach()` would leave it stale. + ## An empty ActionGroup still renders Lattice drops an unauthorized row action, but the `ActionGroup` wrapping it is a plain container and would render as diff --git a/app/Models/User.php b/app/Models/User.php index 1619c6a..896e0ce 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -82,11 +82,12 @@ class User extends Authenticatable implements HasLocalePreference, MustVerifyEma use HasFactory, Notifiable, PasskeyAuthenticatable, TwoFactorAuthenticatable; /** - * @return BelongsToMany + * @return BelongsToMany */ public function teams(): BelongsToMany { return $this->belongsToMany(Team::class, 'team_members', 'user_id', 'team_id') + ->using(Membership::class) ->withPivot(['role']) ->withTimestamps(); } diff --git a/app/Tables/Concerns/AuthorizesRowActions.php b/app/Tables/Concerns/AuthorizesRowActions.php new file mode 100644 index 0000000..4599946 --- /dev/null +++ b/app/Tables/Concerns/AuthorizesRowActions.php @@ -0,0 +1,24 @@ + + */ + private array $allowed = []; + + private function allows(string $ability, Model $subject): bool + { + return $this->allowed[$ability] ??= auth()->user()?->can($ability, $subject) ?? false; + } +} diff --git a/app/Tables/Teams/TeamInvitationsTable.php b/app/Tables/Teams/TeamInvitationsTable.php index 79b5b3e..fe849f7 100644 --- a/app/Tables/Teams/TeamInvitationsTable.php +++ b/app/Tables/Teams/TeamInvitationsTable.php @@ -6,7 +6,7 @@ use App\Actions\Teams\CancelInvitation; use App\Models\Team; use App\Models\TeamInvitation; -use App\Models\User; +use App\Tables\Concerns\AuthorizesRowActions; use Lattice\Actions\Components\Action; use Lattice\Actions\Components\ActionGroup; use Lattice\Core\Enums\ColorName; @@ -25,6 +25,8 @@ #[AsTable('teams.invitations', can: 'view', on: 'team')] class TeamInvitationsTable extends TableDefinition { + use AuthorizesRowActions; + public function layout(): string { return 'grid'; @@ -52,9 +54,8 @@ public function actions(array $row): array { /** @var Team $team */ $team = $this->contextModel('team'); - $user = auth()->user(); - if (! $user instanceof User || ! $user->can('cancelInvitation', $team)) { + if (! $this->allows('cancelInvitation', $team)) { return []; } diff --git a/app/Tables/Teams/TeamMembersTable.php b/app/Tables/Teams/TeamMembersTable.php index d5880f1..6f2bcfb 100644 --- a/app/Tables/Teams/TeamMembersTable.php +++ b/app/Tables/Teams/TeamMembersTable.php @@ -8,6 +8,7 @@ use App\Enums\TeamRole; use App\Models\Team; use App\Models\User; +use App\Tables\Concerns\AuthorizesRowActions; use Lattice\Actions\Components\Action; use Lattice\Actions\Components\ActionGroup; use Lattice\Core\Enums\ColorName; @@ -26,6 +27,8 @@ #[AsTable('teams.members', can: 'view', on: 'team')] class TeamMembersTable extends TableDefinition { + use AuthorizesRowActions; + public function layout(): string { return 'grid'; @@ -53,20 +56,19 @@ public function actions(array $row): array { /** @var Team $team */ $team = $this->contextModel('team'); - $user = auth()->user(); - if (! $user instanceof User || TeamRole::tryFrom((string) ($row['role'] ?? '')) === TeamRole::Owner) { + if (TeamRole::tryFrom((string) ($row['role'] ?? '')) === TeamRole::Owner) { return []; } $context = ['member' => $row['id']]; $actions = []; - if ($user->can('updateMember', $team)) { + if ($this->allows('updateMember', $team)) { $actions[] = Action::use(UpdateMemberRole::class, $context); } - if ($user->can('removeMember', $team)) { + if ($this->allows('removeMember', $team)) { $actions[] = Action::use(RemoveMember::class, $context); } diff --git a/app/Tables/Teams/TeamsTable.php b/app/Tables/Teams/TeamsTable.php index 2d1cf2c..d6f0ecd 100644 --- a/app/Tables/Teams/TeamsTable.php +++ b/app/Tables/Teams/TeamsTable.php @@ -78,19 +78,21 @@ public function source(): TableSource return TableResult::fromItems([]); } + // The membership pivot already carries the role, so reading it off the + // loaded row keeps this to one query instead of one per team. return TableResult::fromItems( $user->teams()->get()->map(fn (Team $team): array => [ 'id' => $team->id, 'name' => $team->name, 'slug' => $team->slug, - 'roleLabel' => $user->teamRole($team)?->getLabel(), + 'roleLabel' => $team->pivot->role->getLabel(), 'status' => $this->statusFor($team->is_personal, $user->isCurrentTeam($team)), ]), ); }); } - private function statusFor(bool $isPersonal, ?bool $isCurrent): string + private function statusFor(bool $isPersonal, bool $isCurrent): string { return collect([ $isPersonal ? __('teams.status.personal') : null, From 3de45dd049e28f4f21d2e94e8afd347bb1b9dcf1 Mon Sep 17 00:00:00 2001 From: Manuel Christlieb Date: Sat, 5 Sep 2026 16:44:41 +0200 Subject: [PATCH 4/5] refactor: share the app shell and the page header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four pages repeated the same layout and middleware; AppPage carries them and the notification listeners, leaving each page its own route, name and gate. SettingsPage overrides the middleware to drop `verified` — its profile tab is where an unverified user resends the mail. PageHeader replaces ten hand-built heading-plus-lead stacks, and TeamPage and TeamsPage gain the breadcrumbs the new trail can now show. --- app/Components/PageHeader.php | 38 ++++++++++++++++++++++++ app/Pages/AppPage.php | 19 ++++++++++++ app/Pages/DashboardPage.php | 31 ++++++++++---------- app/Pages/SettingsPage.php | 46 +++++++---------------------- app/Pages/Teams/TeamPage.php | 54 ++++++++++++++++++----------------- app/Pages/Teams/TeamsPage.php | 34 +++++++++++----------- 6 files changed, 127 insertions(+), 95 deletions(-) create mode 100644 app/Components/PageHeader.php create mode 100644 app/Pages/AppPage.php diff --git a/app/Components/PageHeader.php b/app/Components/PageHeader.php new file mode 100644 index 0000000..7790bb1 --- /dev/null +++ b/app/Components/PageHeader.php @@ -0,0 +1,38 @@ +gap(Gap::Small)->schema($schema); + } +} diff --git a/app/Pages/AppPage.php b/app/Pages/AppPage.php new file mode 100644 index 0000000..adb079c --- /dev/null +++ b/app/Pages/AppPage.php @@ -0,0 +1,19 @@ +currentUser(); @@ -62,12 +62,11 @@ public function render(PageSchema $schema, Request $request, Team $current_team) ->gap(Gap::Large) ->width(Width::Large) ->schema([ - Stack::make('dashboard-heading') - ->gap(Gap::Small) - ->schema([ - Heading::make(__('dashboard.heading'), 1), - Text::make(__('dashboard.welcome', ['name' => $user->name, 'team' => $current_team->name])), - ]), + PageHeader::make( + 'dashboard-heading', + __('dashboard.heading'), + __('dashboard.welcome', ['name' => $user->name, 'team' => $current_team->name]), + ), Grid::make('dashboard-overview') ->columns(3) ->schema([ diff --git a/app/Pages/SettingsPage.php b/app/Pages/SettingsPage.php index 0038170..088fbdb 100644 --- a/app/Pages/SettingsPage.php +++ b/app/Pages/SettingsPage.php @@ -6,21 +6,19 @@ use App\Actions\Settings\DisableTwoFactorAuthenticationAction; use App\Actions\Settings\EnableTwoFactorAuthenticationAction; use App\Actions\Settings\RegenerateRecoveryCodesAction; +use App\Components\PageHeader; use App\Components\Settings\PasskeyRegistration; use App\Concerns\ResolvesCurrentUser; use App\Forms\Settings\DeleteAccountForm; use App\Forms\Settings\PasswordSettingsForm; use App\Forms\Settings\ProfileSettingsForm; use App\Http\Requests\Settings\TwoFactorAuthenticationRequest; -use App\Pages\Concerns\ListensForUserNotifications; use App\Tables\Settings\PasskeysTable; use Illuminate\Http\Request; use Laravel\Fortify\Features; use Lattice\Actions\Components\Action; use Lattice\Core\Attributes\AsPage; -use Lattice\Core\Enums\PageLayout; use Lattice\Form\Components\Form; -use Lattice\Http\Page; use Lattice\Table\Components\Table; use Lattice\Ui\Components\Component; use Lattice\Ui\Components\Heading; @@ -33,10 +31,11 @@ use Lattice\Ui\Enums\Width; use Lattice\Ui\PageSchema; -#[AsPage(route: 'settings', name: 'settings.edit', layout: PageLayout::App, middleware: ['auth'])] -class SettingsPage extends Page +// No `verified` here, unlike every other app page: the profile tab is where an +// unverified user resends their verification mail. +#[AsPage(route: 'settings', name: 'settings.edit', middleware: ['auth'])] +class SettingsPage extends AppPage { - use ListensForUserNotifications; use ResolvesCurrentUser; public function title(): string @@ -55,12 +54,7 @@ public function render(PageSchema $schema, TwoFactorAuthenticationRequest $reque ->gap(Gap::Large) ->width(Width::Medium) ->schema([ - Stack::make('settings-heading') - ->gap(Gap::Small) - ->schema([ - Heading::make(__('settings.heading'), 1), - Text::make(__('settings.subtitle')), - ]), + PageHeader::make('settings-heading', __('settings.heading'), __('settings.subtitle')), Tabs::make('settings-tabs') ->defaultValue('profile') ->schema([ @@ -89,12 +83,7 @@ public function render(PageSchema $schema, TwoFactorAuthenticationRequest $reque private function profileTab(): array { return [ - Stack::make('profile-heading') - ->gap(Gap::Small) - ->schema([ - Heading::make(__('settings.profile.heading'), 2), - Text::make(__('settings.profile.subtitle')), - ]), + PageHeader::section('profile-heading', __('settings.profile.heading'), __('settings.profile.subtitle')), Form::use(ProfileSettingsForm::class), Form::use(DeleteAccountForm::class), ]; @@ -111,12 +100,7 @@ private function securityTab( array $recoveryCodes = [], ): array { return [ - Stack::make('security-heading') - ->gap(Gap::Small) - ->schema([ - Heading::make(__('settings.security.heading'), 2), - Text::make(__('settings.security.subtitle')), - ]), + PageHeader::section('security-heading', __('settings.security.heading'), __('settings.security.subtitle')), Form::use(PasswordSettingsForm::class), Stack::make('two-factor-authentication') ->gap(Gap::Small) @@ -131,12 +115,7 @@ private function securityTab( ->visible($canManageTwoFactor), $this->recoveryCodesSection($recoveryCodes) ->visible($canManageTwoFactor && $twoFactorEnabled), - Stack::make('passkey-heading') - ->gap(Gap::Small) - ->schema([ - Heading::make(__('settings.passkeys.heading'), 2), - Text::make(__('settings.passkeys.subtitle')), - ]) + PageHeader::section('passkey-heading', __('settings.passkeys.heading'), __('settings.passkeys.subtitle')) ->visible($canManagePasskeys), Table::lazy(PasskeysTable::class) ->visible($canManagePasskeys), @@ -183,12 +162,7 @@ private function recoveryCodesSection(array $recoveryCodes): Stack private function appearanceTab(string $appearance): array { return [ - Stack::make('appearance-heading') - ->gap(Gap::Small) - ->schema([ - Heading::make(__('settings.appearance.heading'), 2), - Text::make(__('settings.appearance.subtitle')), - ]), + PageHeader::section('appearance-heading', __('settings.appearance.heading'), __('settings.appearance.subtitle')), SegmentedControl::make('appearance', __('settings.appearance.label')) ->value($appearance) ->emits('lattice:appearance-change') diff --git a/app/Pages/Teams/TeamPage.php b/app/Pages/Teams/TeamPage.php index 91f6e1f..18f0f05 100644 --- a/app/Pages/Teams/TeamPage.php +++ b/app/Pages/Teams/TeamPage.php @@ -3,31 +3,26 @@ namespace App\Pages\Teams; +use App\Components\PageHeader; use App\Forms\Teams\DeleteTeamForm; use App\Forms\Teams\InviteTeamMemberForm; use App\Forms\Teams\UpdateTeamForm; use App\Models\Team; -use App\Pages\Concerns\ListensForUserNotifications; +use App\Pages\AppPage; use App\Tables\Teams\TeamInvitationsTable; use App\Tables\Teams\TeamMembersTable; -use Illuminate\Http\Request; use Lattice\Core\Attributes\AsPage; -use Lattice\Core\Enums\PageLayout; +use Lattice\Core\Breadcrumb; use Lattice\Form\Components\Form; -use Lattice\Http\Page; use Lattice\Table\Components\Table; -use Lattice\Ui\Components\Heading; use Lattice\Ui\Components\Stack; -use Lattice\Ui\Components\Text; use Lattice\Ui\Enums\Gap; use Lattice\Ui\Enums\Width; use Lattice\Ui\PageSchema; -#[AsPage(route: 'settings/teams/{team}', name: 'teams.edit', layout: PageLayout::App, middleware: ['auth', 'verified'], can: 'view', on: 'team')] -class TeamPage extends Page +#[AsPage(route: 'settings/teams/{team}', name: 'teams.edit', can: 'view', on: 'team')] +class TeamPage extends AppPage { - use ListensForUserNotifications; - private ?Team $team = null; public function title(): ?string @@ -35,7 +30,24 @@ public function title(): ?string return $this->team?->name; } - public function render(PageSchema $schema, Request $request, Team $team): PageSchema + /** + * @return array + */ + public function breadcrumbs(): array + { + $trail = [Breadcrumb::make(__('teams.index.title'), route('teams.index', absolute: false))]; + + if ($this->team instanceof Team) { + $trail[] = Breadcrumb::make( + $this->team->name, + route('teams.edit', ['team' => $this->team->slug], absolute: false), + ); + } + + return $trail; + } + + public function render(PageSchema $schema, Team $team): PageSchema { $this->team = $team; @@ -44,29 +56,19 @@ public function render(PageSchema $schema, Request $request, Team $team): PageSc ->gap(Gap::Large) ->width(Width::Medium) ->schema([ - $this->section('team-heading', $team->name, __('teams.show.subtitle'), 1), - $this->section('team-details-heading', __('teams.show.details-heading'), __('teams.show.details-subtitle')) + PageHeader::make('team-heading', $team->name, __('teams.show.subtitle')), + PageHeader::section('team-details-heading', __('teams.show.details-heading'), __('teams.show.details-subtitle')) ->can('update', on: 'team'), Form::use(UpdateTeamForm::class), - $this->section('team-invite-heading', __('teams.show.invite-heading'), __('teams.show.invite-subtitle')) + PageHeader::section('team-invite-heading', __('teams.show.invite-heading'), __('teams.show.invite-subtitle')) ->can('inviteMember', on: 'team'), Form::use(InviteTeamMemberForm::class), - $this->section('team-members-heading', __('teams.show.members-heading'), __('teams.show.members-subtitle')), + PageHeader::section('team-members-heading', __('teams.show.members-heading'), __('teams.show.members-subtitle')), Table::lazy(TeamMembersTable::class), - $this->section('team-invitations-heading', __('teams.show.invitations-heading'), __('teams.show.invitations-subtitle')), + PageHeader::section('team-invitations-heading', __('teams.show.invitations-heading'), __('teams.show.invitations-subtitle')), Table::lazy(TeamInvitationsTable::class), Form::use(DeleteTeamForm::class), ]), ]); } - - private function section(string $key, string $heading, string $subtitle, int $level = 2): Stack - { - return Stack::make($key) - ->gap(Gap::Small) - ->schema([ - Heading::make($heading, $level), - Text::make($subtitle), - ]); - } } diff --git a/app/Pages/Teams/TeamsPage.php b/app/Pages/Teams/TeamsPage.php index 0c92373..7b6858b 100644 --- a/app/Pages/Teams/TeamsPage.php +++ b/app/Pages/Teams/TeamsPage.php @@ -3,45 +3,45 @@ namespace App\Pages\Teams; +use App\Components\PageHeader; use App\Forms\Teams\CreateTeamForm; -use App\Pages\Concerns\ListensForUserNotifications; +use App\Pages\AppPage; use App\Tables\Teams\TeamsTable; -use Illuminate\Http\Request; use Lattice\Core\Attributes\AsPage; -use Lattice\Core\Enums\PageLayout; +use Lattice\Core\Breadcrumb; use Lattice\Form\Components\Form; -use Lattice\Http\Page; use Lattice\Table\Components\Table; -use Lattice\Ui\Components\Heading; use Lattice\Ui\Components\Stack; -use Lattice\Ui\Components\Text; use Lattice\Ui\Enums\Gap; use Lattice\Ui\Enums\Width; use Lattice\Ui\PageSchema; -#[AsPage(route: 'settings/teams', name: 'teams.index', layout: PageLayout::App, middleware: ['auth', 'verified'])] -class TeamsPage extends Page +#[AsPage(route: 'settings/teams', name: 'teams.index')] +class TeamsPage extends AppPage { - use ListensForUserNotifications; - public function title(): string { return __('teams.index.title'); } - public function render(PageSchema $schema, Request $request): PageSchema + /** + * @return array + */ + public function breadcrumbs(): array + { + return [ + Breadcrumb::make(__('teams.index.title'), route('teams.index', absolute: false)), + ]; + } + + public function render(PageSchema $schema): PageSchema { return $schema->schema([ Stack::make('teams-page') ->gap(Gap::Large) ->width(Width::Large) ->schema([ - Stack::make('teams-heading') - ->gap(Gap::Small) - ->schema([ - Heading::make(__('teams.index.heading'), 1), - Text::make(__('teams.index.subtitle')), - ]), + PageHeader::make('teams-heading', __('teams.index.heading'), __('teams.index.subtitle')), Form::use(CreateTeamForm::class), Table::use(TeamsTable::class), ]), From b911408f7281167c799327dd9c24bed0640ea3e4 Mon Sep 17 00:00:00 2001 From: Manuel Christlieb Date: Sat, 5 Sep 2026 16:45:10 +0200 Subject: [PATCH 5/5] docs: list app/Components in the project layout --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 889af26..fd38912 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,7 @@ There are no page components to write. A screen is a PHP class: | `app/Forms/` | `#[AsForm]` classes — fields, validation, and submit handling | | `app/Tables/` | `#[AsTable]` classes — columns, sources, and row actions | | `app/Actions/` | `#[AsAction]` classes — server-side effects (toast, redirect, reload) | +| `app/Components/` | custom wire components, plus the `PageHeader` factory pages open with | | `resources/js/components/` | the handful of custom React components, registered in `app.tsx` | Records reach a definition through Lattice's context registry rather than through route parameters —