From 8618aadc8c90cd5fd1f6973e75e4392c2a37ce3e Mon Sep 17 00:00:00 2001 From: Brian Hanson Date: Thu, 28 May 2026 17:09:29 -0500 Subject: [PATCH 1/5] Initial refactor of element index views to inertia --- .../cp/src/js/ElementActionTrigger.js | 4 + .../js/modules/elements/ElementSources.vue | 45 +++ resources/js/pages/content/Index.vue | 140 +++++++ routes/cp.php | 10 +- src/Element/ElementIndexParams.php | 186 ++++++++++ src/Element/ElementIndexService.php | 347 ++++++++++++++++++ .../Controllers/ContentIndexController.php | 92 +++++ src/Http/Resources/ElementIndexResource.php | 86 +---- .../Element/ElementIndexServiceTest.php | 240 ++++++++++++ .../ContentIndexControllerTest.php | 78 ++++ tests/Unit/Element/ElementIndexParamsTest.php | 119 ++++++ 11 files changed, 1269 insertions(+), 78 deletions(-) create mode 100644 resources/js/modules/elements/ElementSources.vue create mode 100644 resources/js/pages/content/Index.vue create mode 100644 src/Element/ElementIndexParams.php create mode 100644 src/Element/ElementIndexService.php create mode 100644 src/Http/Controllers/ContentIndexController.php create mode 100644 tests/Feature/Element/ElementIndexServiceTest.php create mode 100644 tests/Feature/Http/Controllers/ContentIndexControllerTest.php create mode 100644 tests/Unit/Element/ElementIndexParamsTest.php diff --git a/packages/craftcms-legacy/cp/src/js/ElementActionTrigger.js b/packages/craftcms-legacy/cp/src/js/ElementActionTrigger.js index 0218fe5026a..43601a81e11 100644 --- a/packages/craftcms-legacy/cp/src/js/ElementActionTrigger.js +++ b/packages/craftcms-legacy/cp/src/js/ElementActionTrigger.js @@ -16,6 +16,10 @@ Craft.ElementActionTrigger = Garnish.Base.extend( // Save a reference to the element index that this trigger will be used with this.elementIndex = Craft.currentElementIndex; + if (!this.elementIndex?.triggers) { + return; + } + // Register the trigger on the element index, so it can be destroyed when the view is updated this.elementIndex.triggers.push(this); diff --git a/resources/js/modules/elements/ElementSources.vue b/resources/js/modules/elements/ElementSources.vue new file mode 100644 index 00000000000..ea5ea250d2b --- /dev/null +++ b/resources/js/modules/elements/ElementSources.vue @@ -0,0 +1,45 @@ + + + + + diff --git a/resources/js/pages/content/Index.vue b/resources/js/pages/content/Index.vue new file mode 100644 index 00000000000..f7025a21ebc --- /dev/null +++ b/resources/js/pages/content/Index.vue @@ -0,0 +1,140 @@ + + + + + diff --git a/routes/cp.php b/routes/cp.php index 41ac234a0c9..e42d3f71457 100644 --- a/routes/cp.php +++ b/routes/cp.php @@ -9,6 +9,7 @@ use CraftCms\Cms\Http\Controllers\Auth\SetPasswordController; use CraftCms\Cms\Http\Controllers\Auth\TwoFactorAuthenticationController; use CraftCms\Cms\Http\Controllers\Auth\VerifyEmailController; +use CraftCms\Cms\Http\Controllers\ContentIndexController; use CraftCms\Cms\Http\Controllers\Dashboard\DashboardController; use CraftCms\Cms\Http\Controllers\Elements\EditElementController; use CraftCms\Cms\Http\Controllers\Elements\ElementRedirectController; @@ -132,8 +133,13 @@ Route::get('entries/{section}/new', CreateEntryController::class); Route::get('content', EntriesIndexController::class); - Route::view('content/{page}', 'entries.index')->where('page', '[^\/]+'); - Route::view('content/{page}/{sectionHandle}', 'entries.index')->where('page', '[^\/]+'); + + // Route::view('content/{page}', 'entries.index')->where('page', '[^\/]+'); + // Route::view('content/{page}/{sectionHandle}', 'entries.index')->where('page', '[^\/]+'); + Route::get('content/{page}/{sectionHandle?}', ContentIndexController::class) + ->name('content.index') + ->where('page', '[^\/]+'); + Route::get('content/{section}/new', CreateEntryController::class); /** diff --git a/src/Element/ElementIndexParams.php b/src/Element/ElementIndexParams.php new file mode 100644 index 00000000000..621dcf9c065 --- /dev/null +++ b/src/Element/ElementIndexParams.php @@ -0,0 +1,186 @@ + $elementType + * @param FieldLayout[]|null $fieldLayouts + */ + public function __construct( + public string $elementType, + public ?string $sourceKey = null, + public ?array $source = null, + public ?ElementConditionInterface $condition = null, + public array $viewState = ['mode' => 'table'], + public string $context = ElementSources::CONTEXT_INDEX, + public array $disabledElementIds = [], + public ?string $returnUrl = null, + public array $baseCriteria = [], + public array $criteria = [], + public ?array $filterConfig = null, + public ?array $collapsedElementIds = null, + public bool $isAdministrative = true, + public bool $selectable = false, + public bool $sortable = false, + public bool $includeContainer = true, + public bool $includeActions = true, + public ?array $fieldLayouts = null, + public int $page = 1, + public int $perPage = 100, + public array $sort = [], + ) {} + + /** + * Build params from an ElementIndexRequest (for legacy action endpoints). + */ + public static function fromRequest( + ElementIndexRequest $request, + ElementSources $elementSources, + bool $includeContainer = true, + bool $includeActions = true, + ): self { + $elementType = $request->elementType(); + $context = $request->context(); + $sourceKey = $request->input('source'); + + [$resolvedSourceKey, $source] = self::resolveSourceFromKey($elementType, $sourceKey, $context, $elementSources); + + $fieldLayouts = self::resolveFieldLayoutsFromRequest($request); + + $viewState = $request->input('viewState', []); + if (empty($viewState['mode'])) { + $viewState['mode'] = 'table'; + } + + $filterConfig = $request->input('filterConfig'); + if (! $filterConfig && $filterStr = $request->input('filters')) { + parse_str((string) $filterStr, $filterConfig); + $filterConfig = $filterConfig['condition'] ?? null; + } + + $returnUrl = $request->input('returnUrl'); + if ($returnUrl) { + $returnUrl = str_replace('?', ':QS:', $returnUrl); + } + + return new self( + elementType: $elementType, + sourceKey: $resolvedSourceKey, + source: $source, + condition: $request->condition(), + viewState: $viewState, + context: $context, + disabledElementIds: $request->array('disabledElementIds'), + returnUrl: $returnUrl, + baseCriteria: $request->input('baseCriteria') ?? [], + criteria: $request->input('criteria') ?? [], + filterConfig: $filterConfig, + collapsedElementIds: $request->input('collapsedElementIds'), + isAdministrative: $request->isAdministrative(), + selectable: $request->boolean('selectable'), + sortable: $request->isAdministrative() && $request->boolean('sortable'), + includeContainer: $includeContainer, + includeActions: $includeActions, + fieldLayouts: $fieldLayouts, + ); + } + + /** + * Build params from known context (for ContentIndexController / Inertia pages). + * + * @param class-string $elementType + */ + public static function fromContext( + string $elementType, + ?string $sourceKey, + string $context = ElementSources::CONTEXT_INDEX, + ?ElementSources $elementSources = null, + array $criteria = [], + array $baseCriteria = [], + int $page = 1, + int $perPage = 100, + array $sort = [], + ): self { + $source = null; + + if ($sourceKey !== null && $elementSources !== null) { + [$sourceKey, $source] = self::resolveSourceFromKey($elementType, $sourceKey, $context, $elementSources); + } + + return new self( + elementType: $elementType, + sourceKey: $sourceKey, + source: $source, + context: $context, + baseCriteria: $baseCriteria, + criteria: $criteria, + isAdministrative: in_array($context, [ElementSources::CONTEXT_INDEX, ElementSources::CONTEXT_EMBEDDED_INDEX]), + page: $page, + perPage: $perPage, + sort: $sort, + ); + } + + /** + * @param class-string $elementType + * @return array{0: ?string, 1: ?array} + */ + private static function resolveSourceFromKey( + string $elementType, + ?string $sourceKey, + string $context, + ElementSources $elementSources, + ): array { + if ($sourceKey === null) { + return [null, null]; + } + + if ($sourceKey === '__IMP__') { + return [ + $sourceKey, [ + 'type' => ElementSources::TYPE_NATIVE, + 'key' => '__IMP__', + 'label' => \CraftCms\Cms\t('All elements'), + 'hasThumbs' => $elementType::hasThumbs(), + ], + ]; + } + + $source = $elementSources->findSource($elementType, $sourceKey, $context); + + if ($source === null) { + $sourceKey = null; + } + + return [$sourceKey, $source]; + } + + /** + * @return FieldLayout[]|null + */ + private static function resolveFieldLayoutsFromRequest(ElementIndexRequest $request): ?array + { + $fieldLayouts = $request->input('fieldLayouts'); + + if (empty($fieldLayouts) || ! is_array($fieldLayouts)) { + return null; + } + + return array_map( + FieldLayout::createFromConfig(...), + $fieldLayouts, + ); + } +} diff --git a/src/Element/ElementIndexService.php b/src/Element/ElementIndexService.php new file mode 100644 index 00000000000..bd1836662a5 --- /dev/null +++ b/src/Element/ElementIndexService.php @@ -0,0 +1,347 @@ +elementType::find(); + + if (! $params->source) { + $query->id(false); + + return [ + 'query' => $query, + 'unfilteredQuery' => null, + ]; + } + + if ($params->source['type'] === ElementSources::TYPE_CUSTOM) { + /** @var ElementConditionInterface $sourceCondition */ + $sourceCondition = Conditions::createCondition($params->source['condition']); + $sourceCondition->modifyQuery($query); + } + + $applyCriteria = function (array $criteria) use ($query): bool { + if (! $criteria) { + return false; + } + + if (isset($criteria['trashed'])) { + $criteria['trashed'] = filter_var($criteria['trashed'], FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) ?? false; + } + + if (isset($criteria['drafts'])) { + $criteria['drafts'] = filter_var($criteria['drafts'], FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) ?? false; + } + + if (isset($criteria['draftOf'])) { + if (is_numeric($criteria['draftOf']) && $criteria['draftOf'] != 0) { + $criteria['draftOf'] = (int) $criteria['draftOf']; + } else { + $criteria['draftOf'] = filter_var($criteria['draftOf'], FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + } + } + + Typecast::configure($query, ElementHelper::cleanseQueryCriteria($criteria)); + + return true; + }; + + $applyCriteria($params->baseCriteria); + + $unfilteredQuery = clone $query; + $hasFilters = false; + + if ($params->condition) { + $params->condition->modifyQuery($query); + $hasFilters = true; + } + + if ($applyCriteria($params->criteria)) { + $hasFilters = true; + } + + if ($params->filterConfig) { + /** @var ElementConditionInterface $filterCondition */ + $filterCondition = Conditions::createCondition($params->filterConfig); + $filterCondition->modifyQuery($query); + $hasFilters = true; + } + + if (! $params->collapsedElementIds) { + return [ + 'query' => $query, + 'unfilteredQuery' => $hasFilters ? $unfilteredQuery : null, + ]; + } + + $descendantQuery = (clone $query) + ->offset(null) + ->limit(null) + ->reorder() + ->positionedAfter(null) + ->positionedBefore(null) + ->status(null); + + $collapsedElements = (clone $descendantQuery) + ->id($params->collapsedElementIds) + ->orderBy('lft') + ->all(); + + if (empty($collapsedElements)) { + return [ + 'query' => $query, + 'unfilteredQuery' => $hasFilters ? $unfilteredQuery : null, + ]; + } + + $descendantIds = []; + + foreach ($collapsedElements as $element) { + if (in_array($element->id, $descendantIds, false)) { + continue; + } + + $elementDescendantIds = (clone $descendantQuery) + ->descendantOf($element) + ->ids(); + + $descendantIds = array_merge($descendantIds, $elementDescendantIds); + } + + if (empty($descendantIds)) { + return [ + 'query' => $query, + 'unfilteredQuery' => $hasFilters ? $unfilteredQuery : null, + ]; + } + + $query->where(new ExcludeDescendantIdsExpression($descendantIds)); + + return [ + 'query' => $query, + 'unfilteredQuery' => $unfilteredQuery, + ]; + } + + /** + * Get elements as HTML response data (legacy format for action endpoints). + * + * @return array + */ + public function getElementsHtml(ElementIndexParams $params): array + { + $elementQuery = $this->buildQueryState($params)['query']; + + $this->currentElementIndex->activate($elementQuery); + + $responseData = []; + $actions = null; + $exporters = null; + + if ($params->includeActions) { + if ($params->isAdministrative && isset($params->sourceKey)) { + $actions = ElementActions::availableActions($params->elementType, $params->sourceKey, $elementQuery); + $exporters = $this->availableExporters($params->elementType, $params->sourceKey); + } + + $responseData['actions'] = match (true) { + ($params->viewState['static'] ?? false) === true => [], + empty($actions) => null, + default => ElementActions::serializeActions($actions), + }; + + $responseData['actionsHeadHtml'] = HtmlStack::headHtml(); + $responseData['actionsBodyHtml'] = HtmlStack::bodyHtml(); + $responseData['exporters'] = empty($exporters) ? null : ElementExporters::serializeExporters($exporters); + } + + if (! $params->sourceKey) { + $responseData['html'] = Html::tag('div', t('Nothing yet.'), [ + 'class' => ['zilch', 'small'], + ]); + + return $responseData; + } + + $responseData['html'] = $params->elementType::indexHtml( + elementQuery: $elementQuery, + disabledElementIds: $params->disabledElementIds, + viewState: [ + ...$params->viewState, + 'fieldLayouts' => $params->fieldLayouts, + 'returnUrl' => $params->returnUrl, + ], + sourceKey: $params->sourceKey, + context: $params->context, + includeContainer: $params->includeContainer, + selectable: ( + ((! empty($actions)) || $params->selectable) && + empty($params->viewState['inlineEditing']) + ), + sortable: $params->sortable, + ); + + $responseData['headHtml'] = HtmlStack::headHtml(); + $responseData['bodyHtml'] = HtmlStack::bodyHtml(); + + return $responseData; + } + + /** + * Get elements as structured JSON data (for Inertia/Vue rendering). + * + * @return array + */ + public function getElementsJson(ElementIndexParams $params): array + { + $elementQuery = $this->buildQueryState($params)['query']; + + $this->currentElementIndex->activate($elementQuery); + + // Apply sorting + if (! empty($params->sort)) { + $orderBy = []; + foreach ($params->sort as $sortItem) { + $field = $sortItem['field'] ?? null; + $direction = ($sortItem['direction'] ?? 'asc') === 'desc' ? SORT_DESC : SORT_ASC; + if ($field) { + $orderBy[$field] = $direction; + } + } + if (! empty($orderBy)) { + $elementQuery->orderBy($orderBy); + } + } + + // Get total count before applying pagination + $total = (clone $elementQuery)->count(); + + // Apply pagination + $perPage = $params->perPage; + $page = max(1, $params->page); + $lastPage = max(1, (int) ceil($total / $perPage)); + $page = min($page, $lastPage); + $offset = ($page - 1) * $perPage; + + $elements = $elementQuery->offset($offset)->limit($perPage)->all(); + + $from = $total > 0 ? $offset + 1 : 0; + $to = $total > 0 ? min($offset + $perPage, $total) : 0; + + $responseData = [ + 'elements' => array_map( + fn (ElementInterface $element) => $this->serializeElement($element, $params), + $elements, + ), + 'pagination' => [ + 'total' => $total, + 'per_page' => $perPage, + 'current_page' => $page, + 'last_page' => $lastPage, + 'next_page_url' => $page < $lastPage ? $this->buildPageUrl($page + 1, $perPage) : null, + 'prev_page_url' => $page > 1 ? $this->buildPageUrl($page - 1, $perPage) : null, + 'from' => $from, + 'to' => $to, + ], + ]; + + if ($params->includeActions && $params->isAdministrative && isset($params->sourceKey)) { + $actions = ElementActions::availableActions($params->elementType, $params->sourceKey, $elementQuery); + $responseData['actions'] = empty($actions) ? null : ElementActions::serializeActions($actions); + + $exporters = $this->availableExporters($params->elementType, $params->sourceKey); + $responseData['exporters'] = empty($exporters) ? null : ElementExporters::serializeExporters($exporters); + } + + return $responseData; + } + + private function buildPageUrl(int $page, int $perPage): string + { + $currentUrl = request()->url(); + $query = request()->query(); + $query['page'] = $page; + $query['per_page'] = $perPage; + + return $currentUrl.'?'.http_build_query($query); + } + + /** + * Serialize a single element to an array for JSON output. + * + * @return array + */ + protected function serializeElement(ElementInterface $element, ElementIndexParams $params): array + { + $data = [ + 'id' => $element->id, + 'title' => $element->title, + 'slug' => $element->slug, + 'uri' => $element->uri, + 'url' => $element->getUrl(), + 'status' => $element->getStatus(), + 'enabled' => $element->enabled, + 'cpEditUrl' => $element->getCpEditUrl(), + 'dateCreated' => $element->dateCreated?->format('c'), + 'dateUpdated' => $element->dateUpdated?->format('c'), + ]; + + // Include table attribute values if a source is selected + if ($params->sourceKey) { + $attributes = $this->elementSources->getTableAttributes( + elementType: $params->elementType, + sourceKey: $params->sourceKey, + customAttributes: $params->viewState['tableColumns'] ?? null, + fieldLayouts: $params->fieldLayouts, + ); + + $attributeValues = []; + foreach ($attributes as [$attribute]) { + $attributeValues[$attribute] = $element->getAttributeHtml($attribute); + } + $data['attributes'] = $attributeValues; + } + + return $data; + } + + /** + * @param class-string $elementType + */ + private function availableExporters(string $elementType, string $sourceKey): ?array + { + if (request()->isMobileBrowser()) { + return null; + } + + return ElementExporters::availableExporters($elementType, $sourceKey); + } +} diff --git a/src/Http/Controllers/ContentIndexController.php b/src/Http/Controllers/ContentIndexController.php new file mode 100644 index 00000000000..7ef838859fd --- /dev/null +++ b/src/Http/Controllers/ContentIndexController.php @@ -0,0 +1,92 @@ + $elementType, + 'page' => $page, + ]; + + ($this->prepareElementIndexVariables)($context); + + // Determine the initial source key from the resolved sources + $sourceKey = null; + if (! empty($context['sources'])) { + // If a section handle is provided, find the matching source + if ($sectionHandle) { + foreach ($context['sources'] as $source) { + if (isset($source['key']) && str_contains($source['key'], $sectionHandle)) { + $sourceKey = $source['key']; + break; + } + } + } + + // Fall back to the first source key + if ($sourceKey === null) { + $sourceKey = $context['sources'][0]['key'] ?? null; + } + } + + $sort = ! empty($request->array('sort')) ? $request->array('sort') : [ + ['field' => 'dateCreated', 'direction' => 'desc'], + ]; + + $params = ElementIndexParams::fromContext( + elementType: $elementType, + sourceKey: $sourceKey, + elementSources: $this->elementSources, + page: max(1, $request->integer('page', 1)), + perPage: max(1, $request->integer('per_page', 100)), + sort: $sort, + ); + + $result = $this->elementIndexService->getElementsJson($params); + + return new CpScreenResponse() + ->title(t('Entries')) + ->crumbs([ + [ + 'label' => t('Content'), + 'url' => 'content', + ], + [ + 'label' => t('Entries'), + ], + ]) + ->inertiaPage('content/Index', Arr::merge($context, [ + 'sectionHandle' => $sectionHandle, + 'elements' => $result['elements'], + 'pagination' => $result['pagination'], + 'sort' => $sort, + ])); + } +} diff --git a/src/Http/Resources/ElementIndexResource.php b/src/Http/Resources/ElementIndexResource.php index 04b5974de88..fcf6cbcbccf 100644 --- a/src/Http/Resources/ElementIndexResource.php +++ b/src/Http/Resources/ElementIndexResource.php @@ -5,22 +5,15 @@ namespace CraftCms\Cms\Http\Resources; use CraftCms\Cms\Cp\JsonResource; -use CraftCms\Cms\Element\CurrentElementIndex; -use CraftCms\Cms\Http\Controllers\Elements\Concerns\InteractsWithElementIndexes; +use CraftCms\Cms\Element\ElementIndexParams; +use CraftCms\Cms\Element\ElementIndexService; +use CraftCms\Cms\Element\ElementSources; use CraftCms\Cms\Http\Requests\ElementIndexRequest; -use CraftCms\Cms\Support\Facades\ElementActions; -use CraftCms\Cms\Support\Facades\ElementExporters; -use CraftCms\Cms\Support\Facades\HtmlStack; -use CraftCms\Cms\Support\Html; use Illuminate\Http\Request; use Override; -use function CraftCms\Cms\t; - class ElementIndexResource extends JsonResource { - use InteractsWithElementIndexes; - #[Override] public static $wrap; @@ -35,75 +28,16 @@ public function __construct( public function toArray(Request $_): array { $request = app(ElementIndexRequest::class); + $elementSources = app(ElementSources::class); + $service = app(ElementIndexService::class); - $elementType = $request->elementType(); - [$sourceKey, $source] = $this->resolveSource($elementType, $request->input('source'), $request->context()); - $elementQuery = $this->buildElementQueryState( - elementType: $elementType, - source: $source, - condition: $request->condition(), - )['query']; - - app(CurrentElementIndex::class)->activate($elementQuery); - - $viewState = $this->resolveViewState(); - - $responseData = []; - $actions = null; - $exporters = null; - - if ($this->includeActions) { - if ($request->isAdministrative() && isset($sourceKey)) { - $actions = ElementActions::availableActions($elementType, $sourceKey, $elementQuery); - $exporters = $this->availableExporters($elementType, $sourceKey); - } - - $responseData['actions'] = match (true) { - ($viewState['static'] ?? false) === true => [], - empty($actions) => null, - default => ElementActions::serializeActions($actions), - }; - - $responseData['actionsHeadHtml'] = HtmlStack::headHtml(); - $responseData['actionsBodyHtml'] = HtmlStack::bodyHtml(); - $responseData['exporters'] = empty($exporters) ? null : ElementExporters::serializeExporters($exporters); - } - - if (! $sourceKey) { - $responseData['html'] = Html::tag('div', t('Nothing yet.'), [ - 'class' => ['zilch', 'small'], - ]); - - return $responseData; - } - - // get the return URL with `?` replaced with a token - // (see https://github.com/craftcms/cms/issues/18923) - if ($returnUrl = $request->input('returnUrl')) { - $returnUrl = str_replace('?', ':QS:', $returnUrl); - } - - $responseData['html'] = $elementType::indexHtml( - elementQuery: $elementQuery, - disabledElementIds: $request->array('disabledElementIds'), - viewState: [ - ...$viewState, - 'fieldLayouts' => $this->resolveFieldLayouts(), - 'returnUrl' => $returnUrl, - ], - sourceKey: $sourceKey, - context: $request->context(), + $params = ElementIndexParams::fromRequest( + request: $request, + elementSources: $elementSources, includeContainer: $this->includeContainer, - selectable: ( - ((! empty($actions)) || request()->boolean('selectable')) && - empty($viewState['inlineEditing']) - ), - sortable: $request->isAdministrative() && $request->boolean('sortable'), + includeActions: $this->includeActions, ); - $responseData['headHtml'] = HtmlStack::headHtml(); - $responseData['bodyHtml'] = HtmlStack::bodyHtml(); - - return $responseData; + return $service->getElementsHtml($params); } } diff --git a/tests/Feature/Element/ElementIndexServiceTest.php b/tests/Feature/Element/ElementIndexServiceTest.php new file mode 100644 index 00000000000..05de43c036b --- /dev/null +++ b/tests/Feature/Element/ElementIndexServiceTest.php @@ -0,0 +1,240 @@ +buildQueryState($params); + + expect($result['query'])->not->toBeNull() + ->and($result['unfilteredQuery'])->toBeNull(); + }); + + it('returns a valid query when source is resolved', function () { + $service = app(ElementIndexService::class); + $params = new ElementIndexParams( + elementType: Entry::class, + sourceKey: '*', + source: [ + 'type' => ElementSources::TYPE_NATIVE, + 'key' => '*', + 'label' => 'All entries', + ], + ); + + $result = $service->buildQueryState($params); + + expect($result['query'])->not->toBeNull() + ->and($result['unfilteredQuery'])->toBeNull(); + }); +}); + +describe('getElementsJson', function () { + it('returns paginated elements with correct structure', function () { + EntryModel::factory()->count(3)->create(); + + $service = app(ElementIndexService::class); + $params = new ElementIndexParams( + elementType: Entry::class, + sourceKey: '*', + source: [ + 'type' => ElementSources::TYPE_NATIVE, + 'key' => '*', + 'label' => 'All entries', + ], + perPage: 2, + page: 1, + ); + + $result = $service->getElementsJson($params); + + expect($result)->toHaveKeys(['elements', 'pagination']) + ->and($result['elements'])->toHaveCount(2) + ->and($result['pagination']['total'])->toBe(3) + ->and($result['pagination']['per_page'])->toBe(2) + ->and($result['pagination']['current_page'])->toBe(1) + ->and($result['pagination']['last_page'])->toBe(2) + ->and($result['pagination']['from'])->toBe(1) + ->and($result['pagination']['to'])->toBe(2); + }); + + it('returns second page of results', function () { + EntryModel::factory()->count(3)->create(); + + $service = app(ElementIndexService::class); + $params = new ElementIndexParams( + elementType: Entry::class, + sourceKey: '*', + source: [ + 'type' => ElementSources::TYPE_NATIVE, + 'key' => '*', + 'label' => 'All entries', + ], + perPage: 2, + page: 2, + ); + + $result = $service->getElementsJson($params); + + expect($result['elements'])->toHaveCount(1) + ->and($result['pagination']['current_page'])->toBe(2) + ->and($result['pagination']['from'])->toBe(3) + ->and($result['pagination']['to'])->toBe(3); + }); + + it('serializes element data correctly', function () { + EntryModel::factory()->createElement(['title' => 'My Test Entry']); + + $service = app(ElementIndexService::class); + $params = new ElementIndexParams( + elementType: Entry::class, + sourceKey: '*', + source: [ + 'type' => ElementSources::TYPE_NATIVE, + 'key' => '*', + 'label' => 'All entries', + ], + ); + + $result = $service->getElementsJson($params); + $element = $result['elements'][0]; + + expect($element)->toHaveKeys(['id', 'title', 'slug', 'status', 'enabled', 'dateCreated', 'dateUpdated']) + ->and($element['title'])->toBe('My Test Entry') + ->and($element['enabled'])->toBeTrue(); + }); + + it('applies sorting', function () { + EntryModel::factory()->createElement(['title' => 'Charlie']); + EntryModel::factory()->createElement(['title' => 'Alpha']); + EntryModel::factory()->createElement(['title' => 'Bravo']); + + $service = app(ElementIndexService::class); + $params = new ElementIndexParams( + elementType: Entry::class, + sourceKey: '*', + source: [ + 'type' => ElementSources::TYPE_NATIVE, + 'key' => '*', + 'label' => 'All entries', + ], + sort: [['field' => 'title', 'direction' => 'asc']], + ); + + $result = $service->getElementsJson($params); + $titles = array_column($result['elements'], 'title'); + + expect($titles)->toBe(['Alpha', 'Bravo', 'Charlie']); + }); + + it('applies descending sort', function () { + EntryModel::factory()->createElement(['title' => 'Alpha']); + EntryModel::factory()->createElement(['title' => 'Bravo']); + + $service = app(ElementIndexService::class); + $params = new ElementIndexParams( + elementType: Entry::class, + sourceKey: '*', + source: [ + 'type' => ElementSources::TYPE_NATIVE, + 'key' => '*', + 'label' => 'All entries', + ], + sort: [['field' => 'title', 'direction' => 'desc']], + ); + + $result = $service->getElementsJson($params); + $titles = array_column($result['elements'], 'title'); + + expect($titles)->toBe(['Bravo', 'Alpha']); + }); + + it('returns empty elements when source is null', function () { + EntryModel::factory()->count(2)->create(); + + $service = app(ElementIndexService::class); + $params = ElementIndexParams::fromContext( + elementType: Entry::class, + sourceKey: null, + ); + + $result = $service->getElementsJson($params); + + expect($result['elements'])->toBeEmpty() + ->and($result['pagination']['total'])->toBe(0); + }); + + it('clamps page to last page when exceeding total', function () { + EntryModel::factory()->count(3)->create(); + + $service = app(ElementIndexService::class); + $params = new ElementIndexParams( + elementType: Entry::class, + sourceKey: '*', + source: [ + 'type' => ElementSources::TYPE_NATIVE, + 'key' => '*', + 'label' => 'All entries', + ], + perPage: 2, + page: 99, + ); + + $result = $service->getElementsJson($params); + + expect($result['pagination']['current_page'])->toBe(2) + ->and($result['elements'])->toHaveCount(1); + }); +}); + +describe('getElementsHtml', function () { + it('returns HTML response with expected keys', function () { + EntryModel::factory()->create(); + + $service = app(ElementIndexService::class); + $params = new ElementIndexParams( + elementType: Entry::class, + sourceKey: '*', + source: [ + 'type' => ElementSources::TYPE_NATIVE, + 'key' => '*', + 'label' => 'All entries', + ], + ); + + $result = $service->getElementsHtml($params); + + expect($result)->toHaveKeys(['html', 'headHtml', 'bodyHtml', 'actions', 'actionsHeadHtml', 'actionsBodyHtml', 'exporters']); + }); + + it('returns nothing-yet message when source is null', function () { + $service = app(ElementIndexService::class); + $params = ElementIndexParams::fromContext( + elementType: Entry::class, + sourceKey: null, + ); + + $result = $service->getElementsHtml($params); + + expect($result['html'])->toContain('Nothing yet'); + }); +}); diff --git a/tests/Feature/Http/Controllers/ContentIndexControllerTest.php b/tests/Feature/Http/Controllers/ContentIndexControllerTest.php new file mode 100644 index 00000000000..138b6f5c203 --- /dev/null +++ b/tests/Feature/Http/Controllers/ContentIndexControllerTest.php @@ -0,0 +1,78 @@ +one()); + $this->cpTrigger = Cms::config()->cpTrigger; +}); + +it('returns an Inertia response with elements and pagination', function () { + EntryModel::factory()->count(3)->create(); + + get("/{$this->cpTrigger}/content/entries") + ->assertOk() + ->assertInertia(fn (AssertableInertia $page) => $page + ->component('content/Index') + ->has('elements') + ->has('pagination') + ->has('sort') + ->has('sources') + ); +}); + +it('paginates elements via query params', function () { + EntryModel::factory()->count(5)->create(); + + get("/{$this->cpTrigger}/content/entries?".http_build_query(['per_page' => 2, 'page' => 1])) + ->assertOk() + ->assertInertia(fn (AssertableInertia $page) => $page + ->component('content/Index') + ->where('pagination.per_page', 2) + ->where('pagination.total', 5) + ->where('pagination.current_page', 1) + ); +}); + +it('accepts sort parameters', function () { + EntryModel::factory()->createElement(['title' => 'Zebra']); + EntryModel::factory()->createElement(['title' => 'Apple']); + + get("/{$this->cpTrigger}/content/entries?".http_build_query([ + 'sort' => [['field' => 'title', 'direction' => 'asc']], + ])) + ->assertOk() + ->assertInertia(fn (AssertableInertia $page) => $page + ->where('sort.0.field', 'title') + ->where('sort.0.direction', 'asc') + ); +}); + +it('defaults to dateCreated desc sort', function () { + EntryModel::factory()->create(); + + get("/{$this->cpTrigger}/content/entries") + ->assertOk() + ->assertInertia(fn (AssertableInertia $page) => $page + ->where('sort.0.field', 'dateCreated') + ->where('sort.0.direction', 'desc') + ); +}); + +it('clamps per_page to minimum of 1', function () { + EntryModel::factory()->create(); + + get("/{$this->cpTrigger}/content/entries?per_page=0") + ->assertOk() + ->assertInertia(fn (AssertableInertia $page) => $page + ->where('pagination.per_page', 1) + ); +}); diff --git a/tests/Unit/Element/ElementIndexParamsTest.php b/tests/Unit/Element/ElementIndexParamsTest.php new file mode 100644 index 00000000000..a482f89bb34 --- /dev/null +++ b/tests/Unit/Element/ElementIndexParamsTest.php @@ -0,0 +1,119 @@ +elementType)->toBe(Entry::class) + ->and($params->sourceKey)->toBeNull() + ->and($params->source)->toBeNull() + ->and($params->context)->toBe(ElementSources::CONTEXT_INDEX) + ->and($params->page)->toBe(1) + ->and($params->perPage)->toBe(100) + ->and($params->sort)->toBe([]) + ->and($params->isAdministrative)->toBeTrue() + ->and($params->includeActions)->toBeTrue() + ->and($params->includeContainer)->toBeTrue(); + }); + + it('accepts custom pagination and sort', function () { + $sort = [['field' => 'title', 'direction' => 'asc']]; + + $params = ElementIndexParams::fromContext( + elementType: Entry::class, + sourceKey: null, + page: 3, + perPage: 25, + sort: $sort, + ); + + expect($params->page)->toBe(3) + ->and($params->perPage)->toBe(25) + ->and($params->sort)->toBe($sort); + }); + + it('sets isAdministrative false for non-index contexts', function () { + $params = ElementIndexParams::fromContext( + elementType: Entry::class, + sourceKey: null, + context: ElementSources::CONTEXT_MODAL, + ); + + expect($params->isAdministrative)->toBeFalse(); + }); + + it('sets isAdministrative true for embedded index context', function () { + $params = ElementIndexParams::fromContext( + elementType: Entry::class, + sourceKey: null, + context: ElementSources::CONTEXT_EMBEDDED_INDEX, + ); + + expect($params->isAdministrative)->toBeTrue(); + }); + + it('passes criteria through', function () { + $params = ElementIndexParams::fromContext( + elementType: Entry::class, + sourceKey: null, + criteria: ['title' => 'Test'], + baseCriteria: ['status' => 'live'], + ); + + expect($params->criteria)->toBe(['title' => 'Test']) + ->and($params->baseCriteria)->toBe(['status' => 'live']); + }); +}); + +describe('ElementIndexParams constructor', function () { + it('uses default viewState with table mode', function () { + $params = new ElementIndexParams( + elementType: Entry::class, + ); + + expect($params->viewState)->toBe(['mode' => 'table']); + }); + + it('accepts all parameters', function () { + $params = new ElementIndexParams( + elementType: Entry::class, + sourceKey: 'section:123', + source: ['type' => ElementSources::TYPE_NATIVE, 'key' => 'section:123'], + viewState: ['mode' => 'cards'], + context: ElementSources::CONTEXT_MODAL, + disabledElementIds: [1, 2, 3], + returnUrl: '/entries', + isAdministrative: false, + selectable: true, + sortable: true, + includeContainer: false, + includeActions: false, + page: 2, + perPage: 50, + sort: [['field' => 'title', 'direction' => 'desc']], + ); + + expect($params->sourceKey)->toBe('section:123') + ->and($params->viewState)->toBe(['mode' => 'cards']) + ->and($params->context)->toBe(ElementSources::CONTEXT_MODAL) + ->and($params->disabledElementIds)->toBe([1, 2, 3]) + ->and($params->returnUrl)->toBe('/entries') + ->and($params->isAdministrative)->toBeFalse() + ->and($params->selectable)->toBeTrue() + ->and($params->sortable)->toBeTrue() + ->and($params->includeContainer)->toBeFalse() + ->and($params->includeActions)->toBeFalse() + ->and($params->page)->toBe(2) + ->and($params->perPage)->toBe(50) + ->and($params->sort)->toBe([['field' => 'title', 'direction' => 'desc']]); + }); +}); From 9585dfe0ce6b2509831b4c01de26ccb4566518f3 Mon Sep 17 00:00:00 2001 From: Brian Hanson Date: Thu, 28 May 2026 17:10:07 -0500 Subject: [PATCH 2/5] Improve AGENTS.md --- AGENTS.md | 65 +++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 63 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 59cb48036af..53b8c996213 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,6 +10,38 @@ New core work should be Laravel-first. Do not add Yii dependencies to `src/`; pu This is a large codebase with some large files. Search narrowly before reading full files. +## Commands + +### PHP + +```bash +composer tests # Run all Pest tests +composer tests-adapter # Run yii2-adapter tests only +./vendor/bin/pest path/to/TestFile.php # Run a single test file +./vendor/bin/pest --filter "test description" # Run tests matching a name +composer fix-cs # Run Rector + Pint + ECS (auto-fixes code style) +composer phpstan # Run PHPStan static analysis (level 5) +composer ci # Full CI pipeline: pint, rector, phpstan, tests, tests-adapter +composer serve # Start the testbench dev server +``` + +### Frontend + +```bash +npm run dev # Vite dev server (HMR) for the Inertia/Vue CP +npm run build # Production Vite build (cp.ts + legacy.ts + cp.css) +npm run build:all # Build legacy bundles + CP component package + Vite +npm run dev:bundles # Webpack dev watch for legacy jQuery bundles +npm run dev:cp # Dev build for the @craftcms/cp component package +npm run build:cp # Production build for the @craftcms/cp component package +npm run lint # ESLint + Stylelint + TypeScript type-check +npm run typecheck # TypeScript type-check only (vue-tsc) +npm run test:cp # Vitest tests for the @craftcms/cp package +``` + +> **Note:** `@craftcms/cp` must be built (`npm run build:cp`) before building or running the main Vite app if you've +> made changes to it. + ## Testing - Pest tests using `tests/TestCase.php` or `yii2-adapter/tests-laravel/TestCase.php` share a database lock. If another process has the lock, the next process will wait and print `Another Pest process is already using the shared test database. Waiting for the lock...`. @@ -28,9 +60,38 @@ This is a large codebase with some large files. Search narrowly before reading f - Laravel events are the native event system. Yii event constants and bridge registration belong in `yii2-adapter` for compatibility only. - Services that should be singletons generally use Laravel's `#[Singleton]` or `#[Scoped]` attribute. -## Frontend +## Frontend Architecture + +The CP has two parallel rendering stacks that are actively being consolidated: + +**Inertia/Vue (new):** `resources/js/cp.ts` is the entrypoint. Inertia pages live in `resources/js/pages/`, shared Vue +components in `resources/js/common/`. `HandleInertiaRequests` middleware provides shared CP config, navigation, and +global props to all Inertia pages. The root Blade template is `resources/views/app.blade.php`. + +**Legacy jQuery (old):** `resources/js/legacy.ts` loads the old surface. The individual jQuery modules live in +`packages/craftcms-legacy/` and are bundled with webpack (separate from Vite). Pages still on this stack return `view()` +from their controllers. + +**`CpScreenResponse`** is an intermediate state used by pages mid-migration: the outer CP shell is rendered via Inertia, +but the inner content is PHP-rendered HTML injected into the page. Controllers returning `CpScreenResponse` are +partially migrated; full migration means converting the inner form to a Vue component and switching to +`Inertia::render()`. + +**Packages:** + +- `packages/craftcms-cp` — the `@craftcms/cp` component library (Web Components built on Lit/WebAwesome). Imported as + `@craftcms/cp` in Vue pages. Has its own build (`npm run build:cp`) and Vitest tests (`npm run test:cp`). +- `packages/craftcms-legacy` — webpack-bundled jQuery modules used by legacy CP surfaces. + +**TypeScript types** for PHP classes are auto-generated via `spatie/laravel-typescript-transformer` and written to +`resources/js/generated/`. This runs automatically on `vite dev`/`vite build` when relevant PHP files change; run +`./vendor/bin/testbench typescript:transform` manually if needed. + +**Wayfinder** generates typed route URL helpers into `resources/js/` from Laravel routes. Regenerate with +`./vendor/bin/testbench wayfinder:generate`. -The Control Panel contains both legacy Twig/jQuery surfaces and newer Inertia + Vue screens. Prefer `@craftcms/cp` components when building UI, and match whichever surface the surrounding feature already uses. +**Custom elements** (anything with a hyphen in the tag name) are treated as native web components by the Vue compiler — +they pass through to the browser without Vue trying to resolve them as Vue components. ## Adapter Work From 68506cb0e87acfbb5380829cff64516440aefd62 Mon Sep 17 00:00:00 2001 From: Brian Hanson Date: Thu, 28 May 2026 17:11:07 -0500 Subject: [PATCH 3/5] Use date columns --- resources/js/pages/content/Index.vue | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/resources/js/pages/content/Index.vue b/resources/js/pages/content/Index.vue index f7025a21ebc..b0e132aeb14 100644 --- a/resources/js/pages/content/Index.vue +++ b/resources/js/pages/content/Index.vue @@ -77,10 +77,10 @@ columnHelper.accessor('slug', { header: t('Slug'), }), - columnHelper.accessor('dateCreated', { + columnHelper.date('dateCreated', { header: t('Date Created'), }), - columnHelper.accessor('dateUpdated', { + columnHelper.date('dateUpdated', { header: t('Date Updated'), }), columnHelper.accessor('status', { From 6859c7862fb20ea3fe64f76056aec484009110c6 Mon Sep 17 00:00:00 2001 From: Brian Hanson Date: Sat, 30 May 2026 11:38:07 -0500 Subject: [PATCH 4/5] Add the status filter --- src/Http/Controllers/ContentIndexController.php | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/Http/Controllers/ContentIndexController.php b/src/Http/Controllers/ContentIndexController.php index 7ef838859fd..02e0a6f2f5d 100644 --- a/src/Http/Controllers/ContentIndexController.php +++ b/src/Http/Controllers/ContentIndexController.php @@ -60,9 +60,15 @@ public function __invoke(Request $request, string $page, ?string $sectionHandle ['field' => 'dateCreated', 'direction' => 'desc'], ]; + $criteria = []; + if ($request->has('status')) { + $criteria['status'] = $request->input('status'); + } + $params = ElementIndexParams::fromContext( elementType: $elementType, sourceKey: $sourceKey, + criteria: $criteria, elementSources: $this->elementSources, page: max(1, $request->integer('page', 1)), perPage: max(1, $request->integer('per_page', 100)), @@ -70,6 +76,12 @@ public function __invoke(Request $request, string $page, ?string $sectionHandle ); $result = $this->elementIndexService->getElementsJson($params); + $elementStatuses = $elementType::statuses(); + + $statusOptions = collect($elementStatuses) + ->map(fn ($label, $value) => ['label' => $label, 'value' => $value]) + ->values() + ->all(); return new CpScreenResponse() ->title(t('Entries')) @@ -87,6 +99,11 @@ public function __invoke(Request $request, string $page, ?string $sectionHandle 'elements' => $result['elements'], 'pagination' => $result['pagination'], 'sort' => $sort, + 'q' => $request->input('q'), + 'viewMode' => $request->input('viewMode', 'table'), + 'status' => $request->input('status'), + 'elementStatuses' => $elementStatuses, + 'statusOptions' => $statusOptions, ])); } } From a639854491551948fe08812eb41d1602ae95c9ae Mon Sep 17 00:00:00 2001 From: Brian Hanson Date: Sat, 30 May 2026 11:58:20 -0500 Subject: [PATCH 5/5] Add status filter --- .../scripts/generate-vue-wrappers.js | 86 ++++++++- .../src/components/indicator/indicator.ts | 5 +- .../select-rich/select-invoker.styles.ts | 34 ++++ .../components/select-rich/select-invoker.ts | 21 +++ .../select-rich/select-rich.stories.ts | 89 ++++++++++ .../select-rich/select-rich.styles.ts | 39 +++++ .../src/components/select-rich/select-rich.ts | 76 ++++++++ packages/craftcms-cp/src/index.ts | 1 + .../craftcms-cp/src/styles/form.styles.ts | 1 + resources/js/common/components/CpLink.vue | 2 +- .../js/common/composables/useCraftData.ts | 5 +- .../js/modules/elements/ElementStatus.vue | 84 +++++++++ resources/js/pages/content/Index.vue | 163 ++++++++++++++++-- resources/js/pages/settings/Sites.vue | 4 +- resources/legacy/cp/dist/cp.js | 2 +- resources/legacy/cp/dist/cp.js.map | 2 +- src/Http/Middleware/HandleInertiaRequests.php | 6 +- 17 files changed, 590 insertions(+), 30 deletions(-) create mode 100644 packages/craftcms-cp/src/components/select-rich/select-invoker.styles.ts create mode 100644 packages/craftcms-cp/src/components/select-rich/select-invoker.ts create mode 100644 packages/craftcms-cp/src/components/select-rich/select-rich.stories.ts create mode 100644 packages/craftcms-cp/src/components/select-rich/select-rich.styles.ts create mode 100644 packages/craftcms-cp/src/components/select-rich/select-rich.ts create mode 100644 resources/js/modules/elements/ElementStatus.vue diff --git a/packages/craftcms-cp/scripts/generate-vue-wrappers.js b/packages/craftcms-cp/scripts/generate-vue-wrappers.js index 4d15498a802..daf4c9bc62c 100644 --- a/packages/craftcms-cp/scripts/generate-vue-wrappers.js +++ b/packages/craftcms-cp/scripts/generate-vue-wrappers.js @@ -194,6 +194,18 @@ const GROUP_COMPONENTS = [ }, ]; +/** + * Select rich component — uses modelValue like VALUE_COMPONENTS but needs + * a custom wrapper template for additional behaviour. + */ +const SELECT_RICH_COMPONENT = { + tagName: 'craft-select-rich', + className: 'CraftSelectRich', + fileName: 'CraftSelectRich', + modelType: 'string', + importPath: '../components/select-rich/select-rich', +}; + // ─── Template Generators ──────────────────────────────────────────────────── function generateSlotForwards(slots) { @@ -237,7 +249,6 @@ function generateValueWrapper(component) {