Skip to content

[6.x] Element select field - #19476

Draft
brianjhanson wants to merge 27 commits into
6.xfrom
feature/element-select-field
Draft

[6.x] Element select field#19476
brianjhanson wants to merge 27 commits into
6.xfrom
feature/element-select-field

Conversation

@brianjhanson

Copy link
Copy Markdown
Contributor

Description

Related issues

The modal classes were ported to modern TypeScript, but the registry that
resolves one for an element type stayed in the legacy bundle -- so the
modern module had to reach back into `Craft._elementSelectorModalClasses`
to register its own asset modal.

The registry and factory move here. In-repo callers import
`createElementSelectorModal` directly instead of going through `Craft`,
and the legacy trio is deleted from `Craft.js`.

`Craft.createElementSelectorModal` and `registerElementSelectorModalClass`
are still assigned, so plugins are unaffected, as are the PHP-emitted
`new Craft.VolumeFolderSelectorModal(...)` boots. The legacy bundle runs
before this module, so anything already sitting on the old registry object
is adopted on load, and the built-in asset registration yields to it.
ContentIndexViewModel (the Inertia payload) and ElementIndexHtml (the
server-rendered shell behind the element-selector modal and the remaining
legacy screens) had grown independent implementations of the same
pre-query decisions: which sources an index lists, which table columns and
sort options are selectable, and whether the site/status menus apply.

ElementIndexState is now the one implementation, and both call it. Source
restriction — previously only in ElementIndexHtml, inline — is exposed as
an explicit `restrictTo` parameter, along with the heading pruning, nested
key recovery and sidebar computation that go with it. Context stays a
first-class parameter so each caller keeps resolving under the context it
always did.

No intended behavior change, with two exceptions that fall out of the
shared implementation always returning a Collection of sources:

- `sources: false` (the deletion-blocker detail panes) fatally errored
  with "Call to a member function every() on array" whenever the site menu
  resolved to true, which it does for any localized element type.
- Restricting to a nested source key that had to be slotted in behind
  another key hit array_splice() with a Collection, a TypeError.

ElementIndexHtml keeps passing `showStatusMenu: 'auto'` straight through
rather than resolving it: the toolbar template treats any truthy value as
"render the menu" and lets the JS index fill in the statuses, so resolving
it server-side would change what renders.
The modal assembled its own DOM with jQuery — `$('<div/>', {…}).appendTo()`
chains plus `Craft.ui.createButton`/`createSubmitButton` — which meant its
markup was only readable by executing it.

The chrome is now two Lit templates: `chromeTemplate()` for the shell and the
footer buttons, and the sidebar toggle view's header pair. The `$`-prefixed
fields keep handing back jQuery objects, captured with Lit's `ref` directive,
because subclasses and the element select input manipulate them.

The button markup is reproduced rather than delegated, so the `btn submit`
classes, the `.label` inside its `.inline-flex` wrapper and the absolutely
positioned spinner all survive — the CP stylesheet selects on each. Tests now
pin that markup, and no longer stub `Craft.ui` at all.

The sidebar headers render detached and are moved into place: the element
index owns those containers, and `render()` claims whatever it renders into.

One behavior difference: the main heading interpolated the active source name
into an HTML string, so a name containing markup was parsed as HTML. As a Lit
text binding it is escaped.
Step toward the modal rendering the same index the CP screens do.

`element-selector-modals/body` now returns the index payload alongside the
HTML it already returned. Nothing consumes the props yet — the modal still
boots the legacy index off `html` — so this is additive and changes no
behavior.

The payload comes from a new `ModalIndexViewModel`, which is
`ContentIndexViewModel` with two differences the modal needs: sources resolve
in the `modal` context rather than `index`, and they are narrowed to the
source keys the opener allows, since a relation field may offer only some of
an element type's sources. Both ride on the resolution `ElementIndexState`
already shares with the server-rendered shell.

`indexState()` becomes protected so the subclass can reach it.
`useContentIndexData()` read the index payload straight off `usePage()`, so
every composable built on it — view state, filters, columns, sort,
pagination — could only run inside an Inertia page.

It now takes an optional source to read from instead. `usePage()` is only
called when none is given, so an index outside an Inertia app doesn't reach
for one. The keys and their reactivity are identical either way, which is what
lets the rest of the `useElementIndex*` stack stay unaware of where its
payload came from.

Groundwork for the element selector modal, which will drive the same payload
over XHR from `element-selector-modals/body` rather than a page visit. No
behavior change for the index screens.
The view-mode, sort and pagination composables each built their own
`createIndexVisitor(route)`, which keeps index state in the page URL and
re-requests through an Inertia visit. Neither works for an index inside a
modal: it has no URL of its own, and the visit would replace the page behind
it.

Each now accepts a visitor instead of always making one. Pages pass nothing
and are unchanged.

Adds the modal's implementation of that contract: the query lives in a ref
and `visit()` asks a loader for a fresh payload. Same shape, so the
`useElementIndex*` composables drive it without knowing the difference.
Composes the same stack the index screens use — view state, columns, sort,
pagination, view mode — against a payload refreshed over XHR from
`element-selector-modals/body` and the modal's visitor.

Not `useElementIndexPage`: that registers itself as the screen's active index,
which would hijack the page behind the modal, and its filter composable
submits through an Inertia form. Search and status go through the visitor
here instead.

Exposes selection in the shape the relation field consumes — the same keys
`Craft.getElementInfo()` read off chip data attributes, since `onModalSelect`
and `app/render-elements` both depend on them.
The modal rendered a server-built HTML shell and booted the legacy jQuery
index into it. It now mounts the same Vue index the CP screens use, driven by
the props the body action already returns.

Selection moves with it. The modal no longer asks an index for jQuery sets:
the index pushes plain objects up, `hasSelection()` reads their count, and
`getElementInfo()` passes them through. The relation field's
`updateDisabledElementsInModal()` assigns the whole disabled set rather than
enabling and disabling ids one at a time, which also lets `removeElements()`
stop reaching into the modal's internals.

`VolumeFolderSelectorModal` deliberately keeps the legacy index: folder
picking reads `sourcePath` — the breadcrumb of the folder you navigated into,
which is what selecting the current folder means — and the Vue index has no
equivalent yet. That's now the only thing keeping `ElementIndexHtml` and the
HTML `element-indexes/*` endpoints alive.
Selecting an element returned a 422 from `app/render-elements`, which
requires a siteId.

Index rows are `id` plus column HTML — enough to render a table, nothing that
describes the element. The legacy modal didn't have this problem because it
read each row's chip data attributes via `Craft.getElementInfo()`; with rows
as plain data there is nowhere for that to come from.

The modal's view model now adds the keys that reader produced — siteId, label,
status, url, hasThumb — through the existing `extraRowData()` hook, so they
ride along in every view mode.

`hasThumb` asks the element for its thumbnail rather than the type for
`hasThumbs()`: an asset with no preview has none even where its type does.

Verified end to end in the CP: the modal lists assets, selecting one enables
Select, choosing adds the chip, and saving persists the relation.
Importing the registry from the relation field pulled the modal, Lit, Vue and
the whole element index component tree into every page carrying a relation
field, whether or not a modal was ever opened.

`createModal()` imports the registry on demand and `showModal()` awaits it,
guarded so a double click can't open two modals. The index component, Vue and
the component registry are likewise imported when the body action resolves
rather than at module scope.

Verified in the CP after the change: the modal still opens on the first click,
selection still enables Select, and choosing still adds the chip.
…-field

# Conflicts:
#	resources/js/common/types/globals.d.ts
#	resources/js/modules/element-selector-modal/asset-selector-modal.ts
#	resources/js/modules/element-selector-modal/base-element-selector-modal.test.ts
#	resources/js/modules/element-selector-modal/base-element-selector-modal.ts
#	resources/js/modules/element-selector-modal/index.ts
#	resources/js/modules/element-selector-modal/volume-folder-selector-modal.ts
#	resources/js/modules/elements/composables/useContentIndexData.ts
#	resources/js/modules/elements/composables/useElementEditor.ts
#	resources/js/modules/link-field/craft-link-field.ts
#	resources/js/modules/markdown-field/behaviors/assets.ts
@github-actions

Copy link
Copy Markdown

📚 Storybook previews

@craftcms/uiopen Storybook

Changed components:

resources/jsopen Storybook

No changed components detected in this Storybook.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant