From cdd470fcb4bf4af4ae86b4d044bba279e90741e2 Mon Sep 17 00:00:00 2001 From: Brian Hanson Date: Fri, 17 Jul 2026 10:45:23 -0500 Subject: [PATCH 01/23] Refactor into ViewModel --- .../Controllers/Assets/IndexController.php | 59 +---------- src/Http/ViewModels/AssetIndexViewModel.php | 98 +++++++++++++++++++ 2 files changed, 102 insertions(+), 55 deletions(-) create mode 100644 src/Http/ViewModels/AssetIndexViewModel.php diff --git a/src/Http/Controllers/Assets/IndexController.php b/src/Http/Controllers/Assets/IndexController.php index 543442d9b1b..3914e62fec9 100644 --- a/src/Http/Controllers/Assets/IndexController.php +++ b/src/Http/Controllers/Assets/IndexController.php @@ -4,10 +4,8 @@ namespace CraftCms\Cms\Http\Controllers\Assets; -use CraftCms\Cms\Asset\Folders; -use CraftCms\Cms\Asset\Volumes; use CraftCms\Cms\Http\RespondsWithFlash; -use CraftCms\Cms\Support\Arr; +use CraftCms\Cms\Http\ViewModels\AssetIndexViewModel; use Illuminate\Contracts\View\View; use Illuminate\Http\Request; @@ -15,59 +13,10 @@ { use RespondsWithFlash; - public function __construct( - private Volumes $volumes, - private Folders $folders, - ) {} - public function __invoke(Request $request, ?string $defaultSource = null): View { - $variables = []; - - if (! $defaultSource = $request->input('defaultSource', $defaultSource)) { - return view('assets/_index', $variables); - } - - $defaultSourcePath = Arr::whereNotEmpty(explode('/', (string) $defaultSource)); - $volume = $this->volumes->getVolumeByHandle(array_shift($defaultSourcePath)); - - if (! $volume) { - return view('assets/_index', $variables); - } - - $variables['defaultSource'] = "volume:$volume->uid"; - - if (empty($defaultSourcePath)) { - return view('assets/_index', $variables); - } - - $subfolder = $this->folders->findFolder([ - 'volumeId' => $volume->id, - 'path' => sprintf('%s/', implode('/', $defaultSourcePath)), - ]); - - if (! $subfolder) { - return view('assets/_index', $variables); - } - - $sourcePath = []; - $folderChain = []; - - while ($subfolder) { - array_unshift($folderChain, $subfolder); - $subfolder = $subfolder->getParent(); - } - - foreach ($folderChain as $i => $folder) { - if ($i < count($folderChain) - 1) { - $folder->setHasChildren(true); - } - - $sourcePath[] = $folder->getSourcePathInfo(); - } - - $variables['defaultSourcePath'] = $sourcePath; - - return view('assets/_index', $variables); + return view('assets/_index', new AssetIndexViewModel( + $request->input('defaultSource', $defaultSource), + )); } } diff --git a/src/Http/ViewModels/AssetIndexViewModel.php b/src/Http/ViewModels/AssetIndexViewModel.php new file mode 100644 index 00000000000..ca9cc7cb133 --- /dev/null +++ b/src/Http/ViewModels/AssetIndexViewModel.php @@ -0,0 +1,98 @@ +resolveSource(); + + return $volume === null ? null : "volume:{$volume->uid}"; + } + + /** @return array|null */ + public function defaultSourcePath(): ?array + { + $subfolder = $this->subfolder(); + + if ($subfolder === null) { + return null; + } + + $folderChain = []; + + while ($subfolder) { + array_unshift($folderChain, $subfolder); + $subfolder = $subfolder->getParent(); + } + + $sourcePath = []; + + foreach ($folderChain as $i => $folder) { + if ($i < count($folderChain) - 1) { + $folder->setHasChildren(true); + } + + $sourcePath[] = $folder->getSourcePathInfo(); + } + + return $sourcePath; + } + + /** + * The volume named by the first path segment, and the remaining + * subfolder segments. + * + * @return array{0: Volume|null, 1: string[]} + */ + private function resolveSource(): array + { + if ($this->resolvedSource !== null) { + return $this->resolvedSource; + } + + $segments = Arr::whereNotEmpty(explode('/', (string) $this->defaultSource)); + + $volume = $segments === [] + ? null + : Volumes::getVolumeByHandle(array_shift($segments)); + + return $this->resolvedSource = [$volume, $segments]; + } + + private function subfolder(): ?VolumeFolder + { + [$volume, $segments] = $this->resolveSource(); + + if ($volume === null || $segments === []) { + return null; + } + + return Folders::findFolder([ + 'volumeId' => $volume->id, + 'path' => sprintf('%s/', implode('/', $segments)), + ]); + } +} From 384c390ea585b693837a8eb54bf4717d9cc04d68 Mon Sep 17 00:00:00 2001 From: Brian Hanson Date: Fri, 17 Jul 2026 11:58:33 -0500 Subject: [PATCH 02/23] Consolidate into ContentIndexViewModel --- .../Controllers/Assets/IndexController.php | 10 +- .../Controllers/ContentIndexController.php | 42 +--- src/Http/ViewModels/AssetIndexViewModel.php | 44 +++-- src/Http/ViewModels/ContentIndexViewModel.php | 180 ++++++++++++++---- src/Http/ViewModels/EntryIndexViewModel.php | 56 ++++++ .../TypeScriptTransformerServiceProvider.php | 4 + .../app/TypeScript/ViewModelTransformer.php | 6 +- 7 files changed, 245 insertions(+), 97 deletions(-) create mode 100644 src/Http/ViewModels/EntryIndexViewModel.php diff --git a/src/Http/Controllers/Assets/IndexController.php b/src/Http/Controllers/Assets/IndexController.php index 3914e62fec9..79e9540d4a1 100644 --- a/src/Http/Controllers/Assets/IndexController.php +++ b/src/Http/Controllers/Assets/IndexController.php @@ -4,18 +4,20 @@ namespace CraftCms\Cms\Http\Controllers\Assets; +use CraftCms\Cms\Http\Requests\ElementIndexRequest; use CraftCms\Cms\Http\RespondsWithFlash; use CraftCms\Cms\Http\ViewModels\AssetIndexViewModel; -use Illuminate\Contracts\View\View; -use Illuminate\Http\Request; +use Inertia\Inertia; +use Inertia\Response; readonly class IndexController { use RespondsWithFlash; - public function __invoke(Request $request, ?string $defaultSource = null): View + public function __invoke(ElementIndexRequest $request, ?string $defaultSource = null): Response { - return view('assets/_index', new AssetIndexViewModel( + return Inertia::render('assets/Index', new AssetIndexViewModel( + $request, $request->input('defaultSource', $defaultSource), )); } diff --git a/src/Http/Controllers/ContentIndexController.php b/src/Http/Controllers/ContentIndexController.php index 68520567e32..ce98a3a9087 100644 --- a/src/Http/Controllers/ContentIndexController.php +++ b/src/Http/Controllers/ContentIndexController.php @@ -4,49 +4,19 @@ namespace CraftCms\Cms\Http\Controllers; -use CraftCms\Cms\Entry\Elements\Entry; use CraftCms\Cms\Http\Requests\ElementIndexRequest; -use CraftCms\Cms\Http\ViewModels\ContentIndexViewModel; -use CraftCms\Cms\View\Hooks\PrepareElementIndexVariables; -use CraftCms\Cms\View\Hooks\PrepareElementSourcesVariables; -use CraftCms\Cms\View\Hooks\PrepareElementToolbarVariables; +use CraftCms\Cms\Http\ViewModels\EntryIndexViewModel; use Inertia\Inertia; +use Inertia\Response; -/** - * @TODO Make this more generic, for now I'm just replacing entries - */ readonly class ContentIndexController { - public function __construct( - private PrepareElementIndexVariables $prepareElementIndexVariables, - private PrepareElementToolbarVariables $prepareElementToolbarVariables, - private PrepareElementSourcesVariables $prepareElementSourcesVariables, - ) {} - - public function __invoke(ElementIndexRequest $request, string $page, ?string $sectionHandle = null) + public function __invoke(ElementIndexRequest $request, string $page, ?string $sectionHandle = null): Response { - $elementType = Entry::class; - $context = [ - 'page' => $page, - 'sectionHandle' => $sectionHandle ?? '', - 'elementType' => $elementType, - ]; - - ($this->prepareElementIndexVariables)($context); - ($this->prepareElementToolbarVariables)($context); - ($this->prepareElementSourcesVariables)($context); - - $viewModel = new ContentIndexViewModel( - elementType: $elementType, + return Inertia::render('content/Index', new EntryIndexViewModel( request: $request, + page: $page, sectionHandle: $sectionHandle, - elementStatuses: $context['elementStatuses'] ?? [], - ); - - // Plain merge, view model last: its payload keys override any - // same-named context vars from the prepare hooks (e.g. the legacy - // assoc-keyed `tableColumns`) — a recursive merge would interleave - // them into a JSON object instead of a list. - return Inertia::render('content/Index', array_merge($context, $viewModel->toArray())); + )); } } diff --git a/src/Http/ViewModels/AssetIndexViewModel.php b/src/Http/ViewModels/AssetIndexViewModel.php index ca9cc7cb133..41dcce0b72e 100644 --- a/src/Http/ViewModels/AssetIndexViewModel.php +++ b/src/Http/ViewModels/AssetIndexViewModel.php @@ -6,30 +6,34 @@ use CraftCms\Cms\Asset\Data\Volume; use CraftCms\Cms\Asset\Data\VolumeFolder; +use CraftCms\Cms\Asset\Elements\Asset; +use CraftCms\Cms\Http\Requests\ElementIndexRequest; use CraftCms\Cms\Support\Arr; use CraftCms\Cms\Support\Facades\Folders; use CraftCms\Cms\Support\Facades\Volumes; +use Override; /** - * Payload for the asset index screen (`assets/_index`). + * The Inertia payload for the asset index screen (`assets/Index`). * - * Resolves a `defaultSource` path like `volumeHandle/sub/folder` into the - * source key and source-path chain the element index expects. + * A `defaultSource` path like `volumeHandle/sub/folder` selects the volume's + * source and resolves the subfolder chain into `defaultSourcePath`. */ -class AssetIndexViewModel extends ViewModel +class AssetIndexViewModel extends ContentIndexViewModel { /** @var array{0: Volume|null, 1: string[]}|null */ - private ?array $resolvedSource = null; + private ?array $resolvedDefaultSource = null; public function __construct( + ElementIndexRequest $request, private readonly ?string $defaultSource = null, - ) {} + ) { + parent::__construct(Asset::class, $request); + } public function defaultSource(): ?string { - [$volume] = $this->resolveSource(); - - return $volume === null ? null : "volume:{$volume->uid}"; + return $this->defaultSourceKey(); } /** @return array|null */ @@ -61,16 +65,24 @@ public function defaultSourcePath(): ?array return $sourcePath; } + #[Override] + protected function defaultSourceKey(): ?string + { + [$volume] = $this->resolveDefaultSource(); + + return $volume === null ? null : "volume:{$volume->uid}"; + } + /** - * The volume named by the first path segment, and the remaining - * subfolder segments. + * The volume named by the first `defaultSource` path segment, and the + * remaining subfolder segments. * * @return array{0: Volume|null, 1: string[]} */ - private function resolveSource(): array + private function resolveDefaultSource(): array { - if ($this->resolvedSource !== null) { - return $this->resolvedSource; + if ($this->resolvedDefaultSource !== null) { + return $this->resolvedDefaultSource; } $segments = Arr::whereNotEmpty(explode('/', (string) $this->defaultSource)); @@ -79,12 +91,12 @@ private function resolveSource(): array ? null : Volumes::getVolumeByHandle(array_shift($segments)); - return $this->resolvedSource = [$volume, $segments]; + return $this->resolvedDefaultSource = [$volume, $segments]; } private function subfolder(): ?VolumeFolder { - [$volume, $segments] = $this->resolveSource(); + [$volume, $segments] = $this->resolveDefaultSource(); if ($volume === null || $segments === []) { return null; diff --git a/src/Http/ViewModels/ContentIndexViewModel.php b/src/Http/ViewModels/ContentIndexViewModel.php index 06045968323..def8f581783 100644 --- a/src/Http/ViewModels/ContentIndexViewModel.php +++ b/src/Http/ViewModels/ContentIndexViewModel.php @@ -10,31 +10,33 @@ use CraftCms\Cms\Element\ElementIndexes; use CraftCms\Cms\Element\Queries\Contracts\ElementQueryInterface; use CraftCms\Cms\Http\Requests\ElementIndexRequest; -use CraftCms\Cms\Section\Data\Section; -use CraftCms\Cms\Section\Resources\SectionResource; use CraftCms\Cms\Support\Facades\ElementActions; use CraftCms\Cms\Support\Facades\ElementSources; -use CraftCms\Cms\Support\Facades\Sections; +use CraftCms\Cms\Support\Facades\Sites; use CraftCms\Cms\Support\Html; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use function CraftCms\Cms\t; /** - * The Inertia payload for an element index screen (`content/Index`). + * The shared Inertia payload for an element index screen. * * Query building goes through the shared {@see ElementIndexes} kernel — the * same one the legacy XHR endpoints use — while everything page-shaped * (view state, pagination, bulk-action items, column/sort metadata, and * row/card serialization) lives here. * + * Element-type view models extend this with their own payload keys (public + * methods) and can supply a default source via {@see defaultSourceKey()} — + * e.g. entries map a section-handle URL segment, assets a volume path. + * * Public methods are payload keys (see {@see ViewModel}); shared intermediates * (resolved source, query, index data, paginator) are memoized privately since * payload methods may be invoked in any order. */ -class ContentIndexViewModel extends ViewModel +abstract class ContentIndexViewModel extends ViewModel { - private const string RENDER_CONTEXT = 'index'; + protected const string RENDER_CONTEXT = 'index'; /** @var array{0: ?string, 1: ?array}|null */ private ?array $resolvedSource = null; @@ -53,14 +55,25 @@ class ContentIndexViewModel extends ViewModel /** @var string[]|null */ private ?array $visibleColumns = null; + private ?array $resolvedSources = null; + public function __construct( /** @var class-string */ - private readonly string $elementType, - private readonly ElementIndexRequest $request, - private readonly ?string $sectionHandle = null, - private readonly array $elementStatuses = [], + protected readonly string $elementType, + protected readonly ElementIndexRequest $request, + protected readonly ?string $page = null, ) {} + /** + * The source key to fall back to when the request doesn't name one — + * how a type-specific URL (section handle, volume path, …) selects its + * source. `null` falls through to the “all elements” source. + */ + protected function defaultSourceKey(): ?string + { + return null; + } + public function status(): string { return $this->request->input('status', '') ?? ''; @@ -79,6 +92,10 @@ public function source(): ?array /** @return array{id: int, editable: bool}|null */ public function structure(): ?array { + if ($this->sourceState()[0] === null) { + return null; + } + $indexData = $this->resolveIndexData(); return isset($indexData['structure']) @@ -125,13 +142,88 @@ public function viewState(): array /** @return array */ public function statusOptions(): array { - return collect($this->elementStatuses) + return collect($this->elementType::statuses()) ->map(fn ($label, $value) => ['label' => $label, 'value' => $value]) ->prepend(['label' => t('All'), 'value' => '']) ->values() ->all(); } + public function showStatusMenu(): bool + { + return $this->elementType::hasStatuses() + && count($this->elementType::statuses()) >= 2; + } + + public function showSiteMenu(): bool + { + return Sites::isMultiSite() && $this->elementType::isLocalized(); + } + + /** + * The element index page title: a custom index page's own name wins, + * otherwise the element type's plural display name. + */ + public function title(): string + { + if ($this->page !== null) { + $pageName = $this->sources()[0]['page'] ?? null; + + if ($pageName !== null) { + return t($pageName, category: 'site'); + } + } + + return $this->elementType::pluralDisplayName(); + } + + public function sources(): array + { + return $this->resolvedSources ??= ElementSources::getSources( + $this->elementType, + withDisabled: true, + page: $this->page, + )->all(); + } + + /** @return class-string */ + public function elementType(): string + { + return $this->elementType; + } + + public function page(): ?string + { + return $this->page; + } + + public function elementDisplayName(): string + { + return $this->elementType::displayName(); + } + + public function elementPluralDisplayName(): string + { + return $this->elementType::pluralDisplayName(); + } + + public function canHaveDrafts(): bool + { + return $this->elementType::hasDrafts(); + } + + public function viewModes(): array + { + return $this->elementType::indexViewModes(); + } + + public function selectedSubnavItem(): ?string + { + return $this->page !== null + ? ElementSources::pageNameId($this->page) + : null; + } + /** * The sortable attributes: the element type's own sort options plus the * source's field-layout options. `orderBy` can be a query expression or @@ -233,6 +325,10 @@ public function defaultTableColumns(): array public function data(): array { + if ($this->sourceState()[0] === null) { + return []; + } + $elements = $this->resolvePaginator()->items(); return $this->mode() === 'cards' @@ -287,11 +383,6 @@ public function sort(): array return $this->resolvedSort = [['field' => 'dateCreated', 'direction' => 'desc']]; } - public function publishableSections(): array - { - return SectionResource::collection(Sections::getPublishableSections())->resolve(); - } - /** * @return array{ * total: int, @@ -306,6 +397,19 @@ public function publishableSections(): array */ public function pagination(): array { + if ($this->sourceState()[0] === null) { + return [ + 'total' => 0, + 'per_page' => max(1, $this->request->integer('per_page', 50)), + 'current_page' => 1, + 'last_page' => 1, + 'next_page_url' => null, + 'prev_page_url' => null, + 'from' => null, + 'to' => null, + ]; + } + $paginator = $this->resolvePaginator(); return [ @@ -321,39 +425,35 @@ public function pagination(): array } /** @return array{0: ?string, 1: ?array} */ - private function sourceState(): array + protected function sourceState(): array { if ($this->resolvedSource !== null) { return $this->resolvedSource; } - // An explicit ?source= wins; otherwise a section-handle URL (e.g. - // content/entries/blog, content/entries/singles) selects that source. + // An explicit ?source= wins; otherwise the element type's default + // (e.g. a section-handle or volume-path URL) selects its source. $requestedSource = $this->request->input('source') - ?? $this->sourceKeyForSectionHandle() + ?? $this->defaultSourceKey() ?? '*'; - return $this->resolvedSource = app(ElementIndexes::class) - ->resolveSource($this->elementType, $requestedSource, self::RENDER_CONTEXT); - } + $resolved = app(ElementIndexes::class) + ->resolveSource($this->elementType, $requestedSource, static::RENDER_CONTEXT); - /** - * Maps the section-handle route segment to its source key (`singles` for - * Single sections, `section:{uid}` otherwise). - */ - private function sourceKeyForSectionHandle(): ?string - { - if ($this->sectionHandle === null || $this->sectionHandle === '') { - return null; - } + // Not every element type has a `*` source (assets index per-volume, + // for example), so mirror the legacy index's behavior and fall back + // to the first available source. + if ($resolved[0] === null) { + $firstSourceKey = ElementSources::getSources($this->elementType, static::RENDER_CONTEXT) + ->first(fn (array $source): bool => isset($source['key']))['key'] ?? null; - if ($this->sectionHandle === 'singles') { - return 'singles'; + if ($firstSourceKey !== null && $firstSourceKey !== $requestedSource) { + $resolved = app(ElementIndexes::class) + ->resolveSource($this->elementType, $firstSourceKey, static::RENDER_CONTEXT); + } } - $section = Sections::getSectionByHandle($this->sectionHandle); - - return $section ? "section:$section->uid" : null; + return $this->resolvedSource = $resolved; } /** @@ -434,7 +534,7 @@ private function resolveIndexData(): array disabledElementIds: $this->request->array('disabledElementIds'), viewState: $viewState, sourceKey: $sourceKey, - context: self::RENDER_CONTEXT, + context: static::RENDER_CONTEXT, selectable: true, sortable: false, ); @@ -496,7 +596,7 @@ private function tableRows(array $elements): array $attribute => $attribute === 'title' ? Html::tag('CpLink', $elementHtml->chipHtml($element, [ - 'context' => self::RENDER_CONTEXT, + 'context' => static::RENDER_CONTEXT, 'appearance' => 'plain', ]), ['href' => $element->getCpEditUrl(), 'inertia' => false] @@ -523,7 +623,7 @@ private function cardData(array $elements): array // client-side, while staying unique per card. $cardConfig = [ 'id' => sprintf('card-%s', mt_rand()), - 'context' => self::RENDER_CONTEXT, + 'context' => static::RENDER_CONTEXT, 'hyperlink' => true, 'showEditButton' => false, 'autoReload' => false, diff --git a/src/Http/ViewModels/EntryIndexViewModel.php b/src/Http/ViewModels/EntryIndexViewModel.php new file mode 100644 index 00000000000..5f512c28be4 --- /dev/null +++ b/src/Http/ViewModels/EntryIndexViewModel.php @@ -0,0 +1,56 @@ +sectionHandle ?? ''; + } + + public function publishableSections(): array + { + return SectionResource::collection(Sections::getPublishableSections())->resolve(); + } + + /** + * Maps the section-handle route segment (e.g. `content/entries/blog`, + * `content/entries/singles`) to its source key (`singles` for Single + * sections, `section:{uid}` otherwise). + */ + #[Override] + protected function defaultSourceKey(): ?string + { + if ($this->sectionHandle === null || $this->sectionHandle === '') { + return null; + } + + if ($this->sectionHandle === 'singles') { + return 'singles'; + } + + $section = Sections::getSectionByHandle($this->sectionHandle); + + return $section ? "section:$section->uid" : null; + } +} diff --git a/workbench/app/Providers/TypeScriptTransformerServiceProvider.php b/workbench/app/Providers/TypeScriptTransformerServiceProvider.php index 1c5675102d2..03f09f0a3b5 100644 --- a/workbench/app/Providers/TypeScriptTransformerServiceProvider.php +++ b/workbench/app/Providers/TypeScriptTransformerServiceProvider.php @@ -9,6 +9,8 @@ use CraftCms\Cms\Entry\Data\EntryTypeIndexData; use CraftCms\Cms\Gql\Data\GqlSchema; use CraftCms\Cms\Gql\Data\GqlToken; +use CraftCms\Cms\Http\ViewModels\AssetIndexViewModel; +use CraftCms\Cms\Http\ViewModels\EntryIndexViewModel; use CraftCms\Cms\Http\ViewModels\FieldEditViewModel; use CraftCms\Cms\Http\ViewModels\UserPermissionsViewModel; use CraftCms\Cms\Http\ViewModels\UserPreferencesViewModel; @@ -50,6 +52,8 @@ protected function configure(TypeScriptTransformerConfigFactory $config): void Route::class, Updates::class, HtmlFragment::class, + AssetIndexViewModel::class, + EntryIndexViewModel::class, FieldEditViewModel::class, UserPermissionsViewModel::class, UserPreferencesViewModel::class, diff --git a/workbench/app/TypeScript/ViewModelTransformer.php b/workbench/app/TypeScript/ViewModelTransformer.php index 8d1a2111d98..5c780d57f83 100644 --- a/workbench/app/TypeScript/ViewModelTransformer.php +++ b/workbench/app/TypeScript/ViewModelTransformer.php @@ -52,7 +52,11 @@ private function getViewModelMethods(PhpClassNode $phpClassNode): array { return array_filter( $phpClassNode->getMethods(ReflectionMethod::IS_PUBLIC), - fn (PhpMethodNode $method): bool => $method->getDeclaringClass()->reflection->getName() === $phpClassNode->reflection->getName() + // Payload methods can be declared anywhere below the ViewModel base + // (e.g. inherited from ContentIndexViewModel), so filter on the + // declaring class being a ViewModel subclass rather than the + // transformed class itself. + fn (PhpMethodNode $method): bool => is_subclass_of($method->getDeclaringClass()->reflection->getName(), ViewModel::class) && count($method->getParameters()) === 0 && ! $method->reflection->isConstructor() && ! $method->reflection->isStatic(), From 88f0e3f2dd337f43bdec02ff66874c0a4739d053 Mon Sep 17 00:00:00 2001 From: Brian Hanson Date: Fri, 17 Jul 2026 12:37:41 -0500 Subject: [PATCH 03/23] Extract shared element index page pipeline --- .../elements/components/ElementIndexPage.vue | 138 +++++++++ .../composables/useContentIndexData.ts | 109 +++++++ .../composables/useElementIndexPage.ts | 182 +++++++++++ resources/js/pages/content/Index.vue | 293 ++---------------- src/Http/ViewModels/ContentIndexViewModel.php | 9 + .../TypeScriptTransformerServiceProvider.php | 2 + 6 files changed, 460 insertions(+), 273 deletions(-) create mode 100644 resources/js/modules/elements/components/ElementIndexPage.vue create mode 100644 resources/js/modules/elements/composables/useContentIndexData.ts create mode 100644 resources/js/modules/elements/composables/useElementIndexPage.ts diff --git a/resources/js/modules/elements/components/ElementIndexPage.vue b/resources/js/modules/elements/components/ElementIndexPage.vue new file mode 100644 index 00000000000..a48e2683147 --- /dev/null +++ b/resources/js/modules/elements/components/ElementIndexPage.vue @@ -0,0 +1,138 @@ + + + + + diff --git a/resources/js/modules/elements/composables/useContentIndexData.ts b/resources/js/modules/elements/composables/useContentIndexData.ts new file mode 100644 index 00000000000..934540058d2 --- /dev/null +++ b/resources/js/modules/elements/composables/useContentIndexData.ts @@ -0,0 +1,109 @@ +import {usePage} from '@inertiajs/vue3'; +import {computed, reactive} from 'vue'; +import type {PaginationData, SortItem} from '@/common/types'; +import type {ConditionConfig} from '@/modules/elements/composables/useConditionBuilder'; +import type {BulkActionItem} from '@/modules/elements/types/actions'; +import type {Source, SourceItem} from '@/modules/elements/types/sources'; +import type { + SortOption, + ViewMode, + ViewState, +} from '@/modules/elements/types/view-state'; + +type GeneratedProps = CraftCms.Cms.Http.ViewModels.ContentIndexViewModel; + +/** + * The `ContentIndexViewModel` payload, with the element-index domain types + * layered over the keys the PHP type generator can only express loosely + * (`Array`). The generated type stays the source of truth for which keys + * exist; this narrows what they contain. + */ +export type ContentIndexData = Omit< + GeneratedProps, + | 'source' + | 'sources' + | 'currentCondition' + | 'viewState' + | 'viewModes' + | 'sortOptions' + | 'sort' + | 'data' + | 'actions' + | 'pagination' +> & { + source: SourceItem | null; + sources: Source[]; + currentCondition: ConditionConfig | null; + viewState: Partial; + viewModes: ViewMode[]; + sortOptions: SortOption[]; + sort: SortItem[]; + data: Array>; + actions: BulkActionItem[] | null; + pagination: PaginationData; +}; + +/** + * Typed, reactive access to the `ContentIndexViewModel` payload shared by + * every element index screen (entries, assets, …). + * + * Returns a reactive object shaped like the payload, so it can be handed + * directly to the `useElementIndex*` composables in place of a props object, + * while staying in sync across partial Inertia visits (sorting, filtering, + * pagination). Read keys off the returned object (or `toRef()` them) — + * destructuring would snapshot the current values. + * + * `extra` merges page-supplied data (refs unwrap reactively) into the same + * object — e.g. route-param props or local state an embedded index owns. + * Extra keys win over payload keys, so a caller can also deliberately + * override a payload value. + */ +export function useContentIndexData< + Extra extends object = Record, +>(extra?: Extra) { + const inertiaPage = usePage(); + + return reactive({ + // Element type + elementType: computed(() => inertiaPage.props.elementType), + elementDisplayName: computed(() => inertiaPage.props.elementDisplayName), + elementPluralDisplayName: computed( + () => inertiaPage.props.elementPluralDisplayName + ), + canHaveDrafts: computed(() => inertiaPage.props.canHaveDrafts), + + // Page chrome + context: computed(() => inertiaPage.props.context), + title: computed(() => inertiaPage.props.title), + page: computed(() => inertiaPage.props.page), + selectedSubnavItem: computed(() => inertiaPage.props.selectedSubnavItem), + showSiteMenu: computed(() => inertiaPage.props.showSiteMenu), + showStatusMenu: computed(() => inertiaPage.props.showStatusMenu), + + // Sources + sources: computed(() => inertiaPage.props.sources), + source: computed(() => inertiaPage.props.source), + structure: computed(() => inertiaPage.props.structure), + + // Filtering + status: computed(() => inertiaPage.props.status), + statusOptions: computed(() => inertiaPage.props.statusOptions), + search: computed(() => inertiaPage.props.search), + currentCondition: computed(() => inertiaPage.props.currentCondition), + + // View state + viewState: computed(() => inertiaPage.props.viewState), + viewModes: computed(() => inertiaPage.props.viewModes), + tableColumns: computed(() => inertiaPage.props.tableColumns), + defaultTableColumns: computed(() => inertiaPage.props.defaultTableColumns), + sort: computed(() => inertiaPage.props.sort), + sortOptions: computed(() => inertiaPage.props.sortOptions), + + // Results + data: computed(() => inertiaPage.props.data), + actions: computed(() => inertiaPage.props.actions), + pagination: computed(() => inertiaPage.props.pagination), + + ...(extra ?? ({} as Extra)), + }); +} diff --git a/resources/js/modules/elements/composables/useElementIndexPage.ts b/resources/js/modules/elements/composables/useElementIndexPage.ts new file mode 100644 index 00000000000..498bf0f8071 --- /dev/null +++ b/resources/js/modules/elements/composables/useElementIndexPage.ts @@ -0,0 +1,182 @@ +import {router} from '@inertiajs/vue3'; +import { + getCoreRowModel, + type RowSelectionState, + useVueTable, +} from '@tanstack/vue-table'; +import {computed, ref} from 'vue'; +import {useContentIndexData} from '@/modules/elements/composables/useContentIndexData'; +import {useConditionBuilder} from '@/modules/elements/composables/useConditionBuilder'; +import {useElementIndexColumns} from '@/modules/elements/composables/useElementIndexColumns'; +import {useElementIndexFilters} from '@/modules/elements/composables/useElementIndexFilters'; +import {useElementIndexLoading} from '@/modules/elements/composables/useElementIndexLoading'; +import {useElementIndexPagination} from '@/modules/elements/composables/useElementIndexPagination'; +import {useElementIndexSort} from '@/modules/elements/composables/useElementIndexSort'; +import {useElementIndexViewMode} from '@/modules/elements/composables/useElementIndexViewMode'; +import {useElementIndexViewState} from '@/modules/elements/composables/useElementIndexViewState'; +import type {ElementIndexRoute} from '@/modules/elements/composables/useElementIndexVisits'; + +type Element = Record; + +interface UseElementIndexPageOptions { + /** The page's index route — the one per-page piece of the pipeline. */ + route: ElementIndexRoute; + /** + * The always-first, non-toggleable column. Defaults to the element's + * `title` labeled with the payload's `elementDisplayName`. + */ + pinnedColumn?: {key: string; label: string}; +} + +/** + * The full element index page pipeline: the shared + * {@link useContentIndexData} payload wired through view state, filters, + * columns, sorting, pagination, and view mode into a ready TanStack table, + * plus the page-level behaviors every index repeats (selection keyed by + * element id, the post-bulk-action partial reload, structure-only view-mode + * filtering, and the Customize Sources modal shim). + * + * Pages supply only their route (and optional pinned-column override); + * type-specific chrome stays in the page. + */ +export function useElementIndexPage(options: UseElementIndexPageOptions) { + const elementIndex = useContentIndexData(); + + const viewState = useElementIndexViewState(elementIndex); + const {conditions} = useConditionBuilder({ + initialState: elementIndex.currentCondition ?? null, + }); + const filters = useElementIndexFilters( + elementIndex, + viewState, + options.route, + conditions + ); + const {columns, columnOrder, columnOptions, reorder, tableColumns} = + useElementIndexColumns( + elementIndex, + viewState, + options.pinnedColumn ?? { + key: 'title', + label: elementIndex.elementDisplayName, + }, + options.route + ); + const {sortingState, sortingConfig, sortField, sortDirection} = + useElementIndexSort(elementIndex, viewState, {route: options.route}); + const {paginationState, paginationConfig} = useElementIndexPagination( + elementIndex, + options.route + ); + const {mode} = useElementIndexViewMode(options.route, viewState); + const {loading} = useElementIndexLoading(); + + // The structure view mode only applies to structure sources, so hide it + // (and any other `structuresOnly` mode) unless the active source is one. + const visibleViewModes = computed(() => + elementIndex.viewModes.filter( + (viewMode) => + !viewMode.structuresOnly || elementIndex.source?.structureId != null + ) + ); + + const visibleColumns = ref({}); + + // Selection is keyed by element id (see `getRowId`), so it survives sorting + // and pagination. Read the current selection from `rowSelection` (a map of + // element id → selected) or via `elementTable.getSelectedRowModel()`. + const rowSelection = ref({}); + + // After a bulk action succeeds, refresh the server-rendered list + counts the + // same way the view-mode/filter composables do (a partial Inertia reload that + // only re-pulls the index props), then clear any lingering selection. The + // table also clears its own selection optimistically when the action fires. + function onActionPerformed() { + rowSelection.value = {}; + router.reload({ + only: ['data', 'pagination', 'badgeCounts'], + }); + } + + function createCustomizeSourcesModal() { + // The modal was written for the legacy BaseElementIndex instance, but it + // only reads a few things off it: the element type (to load/save settings), + // the current `settings.page`, and the current/root source key (to preselect + // a row). Its save flow reloads the page, so `asyncSelectSourceByKey` / + // `$visibleSources` only need to be safe no-ops here. + const elementIndexShim = { + elementType: elementIndex.elementType, + settings: {page: elementIndex.page}, + sourceKey: elementIndex.source?.key ?? null, + rootSourceKey: elementIndex.source?.key ?? null, + $visibleSources: {first: () => ({data: () => null})}, + asyncSelectSourceByKey: () => Promise.resolve(), + }; + + // Recreate it each time, mirroring the legacy implementation. + const modal = new Craft.CustomizeSourcesModal(elementIndexShim, { + hideOnEsc: false, + hideOnShadeClick: false, + onFadeOut: function () { + modal.destroy(); + }, + }); + + return modal; + } + + const elementTable = useVueTable({ + get data() { + return elementIndex.data ?? []; + }, + get columns() { + return columns.value; + }, + state: { + get columnOrder() { + return columnOrder.value; + }, + get columnVisibility() { + return visibleColumns.value; + }, + get sorting() { + return sortingState.value; + }, + get pagination() { + return paginationState.value; + }, + get rowSelection() { + return rowSelection.value; + }, + }, + getRowId: (row) => String(row.id), + enableRowSelection: true, + onRowSelectionChange: (updater) => { + rowSelection.value = + typeof updater === 'function' ? updater(rowSelection.value) : updater; + }, + getCoreRowModel: getCoreRowModel(), + ...sortingConfig, + ...paginationConfig, + enableMultiSort: false, + }); + + return { + elementIndex, + elementTable, + viewState, + conditions, + filters, + columnOptions, + tableColumns, + reorder, + sortField, + sortDirection, + mode, + loading, + visibleViewModes, + rowSelection, + onActionPerformed, + createCustomizeSourcesModal, + }; +} diff --git a/resources/js/pages/content/Index.vue b/resources/js/pages/content/Index.vue index 157c780f2e1..dca69c6b9e4 100644 --- a/resources/js/pages/content/Index.vue +++ b/resources/js/pages/content/Index.vue @@ -1,299 +1,46 @@ diff --git a/src/Http/ViewModels/ContentIndexViewModel.php b/src/Http/ViewModels/ContentIndexViewModel.php index def8f581783..46c4937b769 100644 --- a/src/Http/ViewModels/ContentIndexViewModel.php +++ b/src/Http/ViewModels/ContentIndexViewModel.php @@ -74,6 +74,15 @@ protected function defaultSourceKey(): ?string return null; } + /** + * The render context — index screens always render in the `index` + * context, and the client echoes it back on XHR element endpoints. + */ + public function context(): string + { + return static::RENDER_CONTEXT; + } + public function status(): string { return $this->request->input('status', '') ?? ''; diff --git a/workbench/app/Providers/TypeScriptTransformerServiceProvider.php b/workbench/app/Providers/TypeScriptTransformerServiceProvider.php index 03f09f0a3b5..cce8d32e989 100644 --- a/workbench/app/Providers/TypeScriptTransformerServiceProvider.php +++ b/workbench/app/Providers/TypeScriptTransformerServiceProvider.php @@ -10,6 +10,7 @@ use CraftCms\Cms\Gql\Data\GqlSchema; use CraftCms\Cms\Gql\Data\GqlToken; use CraftCms\Cms\Http\ViewModels\AssetIndexViewModel; +use CraftCms\Cms\Http\ViewModels\ContentIndexViewModel; use CraftCms\Cms\Http\ViewModels\EntryIndexViewModel; use CraftCms\Cms\Http\ViewModels\FieldEditViewModel; use CraftCms\Cms\Http\ViewModels\UserPermissionsViewModel; @@ -53,6 +54,7 @@ protected function configure(TypeScriptTransformerConfigFactory $config): void Updates::class, HtmlFragment::class, AssetIndexViewModel::class, + ContentIndexViewModel::class, EntryIndexViewModel::class, FieldEditViewModel::class, UserPermissionsViewModel::class, From 35df770709ec6f26def48a5f0c56d3d1736b9e38 Mon Sep 17 00:00:00 2001 From: Brian Hanson Date: Fri, 17 Jul 2026 13:42:30 -0500 Subject: [PATCH 04/23] Initial rendering of Asset index page --- .../components/ElementIndexToolbar.vue | 2 +- resources/js/pages/assets/Index.vue | 36 +++++++++++++++++++ routes/cp.php | 1 + .../Controllers/Assets/IndexController.php | 5 +-- src/Http/ViewModels/AssetIndexViewModel.php | 11 ++++-- src/Http/ViewModels/ContentIndexViewModel.php | 6 ++++ 6 files changed, 56 insertions(+), 5 deletions(-) create mode 100644 resources/js/pages/assets/Index.vue diff --git a/resources/js/modules/elements/components/ElementIndexToolbar.vue b/resources/js/modules/elements/components/ElementIndexToolbar.vue index 2fb1e20c59f..7048254810a 100644 --- a/resources/js/modules/elements/components/ElementIndexToolbar.vue +++ b/resources/js/modules/elements/components/ElementIndexToolbar.vue @@ -43,7 +43,7 @@