diff --git a/.phpstan/baseline.neon b/.phpstan/baseline.neon index 5e43a294010..865f5ca2805 100644 --- a/.phpstan/baseline.neon +++ b/.phpstan/baseline.neon @@ -66,12 +66,6 @@ parameters: count: 1 path: ../src/Facades/Endpoint/Parse.php - - - message: '#^Method Illuminate\\Contracts\\Validation\\DataAwareRule@anonymous/Fieldtypes/Bard\.php\:934\:\:setData\(\) should return \$this\(Illuminate\\Contracts\\Validation\\DataAwareRule@anonymous/Fieldtypes/Bard\.php\:934\) but return statement is missing\.$#' - identifier: return.missing - count: 1 - path: ../src/Fieldtypes/Bard.php - - message: '#^Access to an undefined property Statamic\\Filesystem\\AbstractAdapter\:\:\$filesystem\.$#' identifier: property.notFound diff --git a/resources/css/components/fieldtypes/bard.css b/resources/css/components/fieldtypes/bard.css index 1477f7ddead..4aa2f6daabd 100644 --- a/resources/css/components/fieldtypes/bard.css +++ b/resources/css/components/fieldtypes/bard.css @@ -257,6 +257,48 @@ } } } + +/* BARD / DRAGGING +=================================================== */ +/* Hide set bodies synchronously (classList, not waiting on Vue) so collapse-on-drag + doesn't leave the pointer over empty space where the expanded set used to be. */ +.bard-dragging [data-set-body] { + display: none; +} + +/* BARD / DROP + GAP CURSORS +=================================================== */ +/* Unlayered so we beat ProseMirror's injected 1px black defaults. */ +.bard-dropcursor { + border-radius: 999px; + background-color: var(--focus-outline-color, var(--color-blue-400)); +} + +/* Leading disc on block (horizontal) drops — Notion-style slot marker. + Inline (vertical) carets stay a rounded pill so they don't grow a blob. */ +.bard-dropcursor.prosemirror-dropcursor-block::before { + content: ''; + position: absolute; + inset-inline-start: 0; + top: 50%; + width: 6px; + height: 6px; + border-radius: 999px; + background-color: inherit; + translate: -30% -50%; +} + +[dir='rtl'] .bard-dropcursor.prosemirror-dropcursor-block::before { + translate: 30% -50%; +} + +/* Gapcursor: click-between-blocks caret. Same token as dropcursor. */ +.ProseMirror-gapcursor:after { + border-top: 2px solid var(--focus-outline-color, var(--color-blue-400)); + border-radius: 999px; + width: 1.5rem; +} + /* BARD / FULL SCREEN =================================================== */ @layer ui-states { diff --git a/resources/css/components/fieldtypes/replicator.css b/resources/css/components/fieldtypes/replicator.css index cbf0f8dc3bf..b65819e04a6 100644 --- a/resources/css/components/fieldtypes/replicator.css +++ b/resources/css/components/fieldtypes/replicator.css @@ -1,3 +1,9 @@ /* ========================================================================== REPLICATOR FIELDTYPE ========================================================================== */ + +/* Collapse set bodies to header bars while dragging so swaps only relayout + header-height rows instead of full expanded field trees. */ +.replicator-dragging [data-replicator-set] > [data-set-body] { + display: none; +} diff --git a/resources/css/core/layout.css b/resources/css/core/layout.css index f258de42bc9..f9bc73f0507 100644 --- a/resources/css/core/layout.css +++ b/resources/css/core/layout.css @@ -163,3 +163,8 @@ main.nav-closed { body > .draggable-mirror { z-index: var(--z-index-draggable); } + +/* Don't clone expanded set bodies into the drag mirror. */ +body > .draggable-mirror [data-set-body] { + display: none; +} diff --git a/resources/css/cp.css b/resources/css/cp.css index dd8ab0f1172..7d532f95c00 100644 --- a/resources/css/cp.css +++ b/resources/css/cp.css @@ -35,6 +35,7 @@ @import './components/fieldtypes/markdown.css'; @import './components/fieldtypes/partial.css'; @import './components/fieldtypes/relationship.css'; +@import './components/fieldtypes/replicator.css'; @import './components/fieldtypes/section.css'; @import './components/fieldtypes/table.css'; @import './components/fieldtypes/width.css'; diff --git a/resources/js/bootstrap/globals.js b/resources/js/bootstrap/globals.js index 3310ec80d83..f518279cbf7 100644 --- a/resources/js/bootstrap/globals.js +++ b/resources/js/bootstrap/globals.js @@ -120,6 +120,8 @@ export function truncate(string, length, ending = '...') { } export function escapeHtml(string) { + if (typeof string !== 'string') return string; + return string .replaceAll('&', '&') .replaceAll('<', '<') diff --git a/resources/js/components/Reveal.js b/resources/js/components/Reveal.js index 83a903246bf..bf25d6255d3 100644 --- a/resources/js/components/Reveal.js +++ b/resources/js/components/Reveal.js @@ -8,6 +8,9 @@ class Reveal { } mount(el, callback) { + // Progressive set mounting can call this before the template ref exists. + if (!el) return; + registry.set(el, callback); onBeforeUnmount(() => registry.delete(el)); diff --git a/resources/js/components/field-conditions/ShowField.js b/resources/js/components/field-conditions/ShowField.js index 963ca7c7be9..5bc32211241 100644 --- a/resources/js/components/field-conditions/ShowField.js +++ b/resources/js/components/field-conditions/ShowField.js @@ -5,7 +5,10 @@ import { nextTick } from 'vue'; export default class { constructor(values, extraValues, rootValues, revealerValues, hiddenFields, setHiddenField, extraPayload) { this.values = values; + // Merge once per instance — reused across showField() calls when Sections/Tabs + // construct a single ShowField for a filter loop. this.extraValues = { ...extraValues, ...revealerValues }; + this.mergedValues = { ...values, ...this.extraValues }; this.rootValues = rootValues; this.revealerValues = revealerValues; this.hiddenFields = hiddenFields; @@ -31,7 +34,14 @@ export default class { } // Use validation to determine whether field should be shown. - let validator = new Validator(field, { ...this.values, ...this.extraValues }, this.rootValues, dottedFieldPath, Object.keys(this.revealerValues), this.extraPayload); + let validator = new Validator( + field, + this.mergedValues, + this.rootValues, + dottedFieldPath, + Object.keys(this.revealerValues), + this.extraPayload, + ); let passes = validator.passesConditions(); // If the field is configured to always save, never omit value. @@ -45,12 +55,17 @@ export default class { return passes; } + // With no revealers registered, passesNonRevealerConditions === passesConditions. + const hasRevealers = Object.keys(this.revealerValues).length > 0; + // Ensure DOM is updated to ensure all revealers are properly loaded and tracked before committing to store. nextTick(() => { this.setHiddenFieldState({ dottedKey: dottedFieldPath, hidden: !passes, - omitValue: field.type === 'revealer' || !validator.passesNonRevealerConditions(dottedPrefix), + omitValue: + field.type === 'revealer' || + (hasRevealers ? !validator.passesNonRevealerConditions(dottedPrefix) : !passes), }); }); diff --git a/resources/js/components/field-conditions/Validator.js b/resources/js/components/field-conditions/Validator.js index 1183a12e81e..58c3fe4cb3d 100644 --- a/resources/js/components/field-conditions/Validator.js +++ b/resources/js/components/field-conditions/Validator.js @@ -5,6 +5,9 @@ import { data_get } from '../../util/data_get.js'; import { isObject, intersection } from 'lodash-es'; const NUMBER_SPECIFIC_COMPARISONS = ['>', '>=', '<', '<=']; +const CUSTOM_PREFIX_RE = /^custom /; +const ROOT_PREFIX_RE = /^\$?root\./; +const TRAILING_FIELD_RE = /\.[^.]+$/; const isEmpty = (value) => { if (value === null || value === undefined) return true; @@ -25,6 +28,7 @@ export default class { this.passOnAny = false; this.showOnPass = true; this.converter = new Converter(); + this._conditionsResolved = false; } usingRootValues() { @@ -56,25 +60,42 @@ export default class { } getConditions() { + // Memoized per Validator instance — field config is static for the evaluation cycle. + // Side-effect flags (passOnAny / showOnPass) are restored on subsequent calls. + if (this._conditionsResolved) { + this.passOnAny = this._passOnAny; + this.showOnPass = this._showOnPass; + return this._conditions; + } + + this._conditionsResolved = true; + this._passOnAny = false; + this._showOnPass = true; + let key = KEYS.filter((key) => this.field[key])[0]; if (!key) { + this._conditions = undefined; return undefined; } if (key.includes('any')) { this.passOnAny = true; + this._passOnAny = true; } if (key.includes('unless') || key.includes('hide_when')) { this.showOnPass = false; + this._showOnPass = false; } let conditions = this.field[key]; - return this.isCustomConditionWithoutTarget(conditions) + this._conditions = this.isCustomConditionWithoutTarget(conditions) ? conditions : this.converter.fromBlueprint(conditions, this.field.prefix); + + return this._conditions; } isCustomConditionWithoutTarget(conditions) { @@ -189,7 +210,7 @@ export default class { } prepareFunctionName(condition) { - return condition.replace(new RegExp('^custom '), '').split(':')[0]; + return condition.replace(CUSTOM_PREFIX_RE, '').split(':')[0]; } prepareParams(condition) { @@ -204,7 +225,7 @@ export default class { } if (field.startsWith('$root.') || field.startsWith('root.')) { - return data_get(this.rootValues, field.replace(new RegExp('^\\$?root\\.'), '')); + return data_get(this.rootValues, field.replace(ROOT_PREFIX_RE, '')); } return data_get(this.values, field); @@ -293,14 +314,14 @@ export default class { } if (lhs.startsWith('$root.') || lhs.startsWith('root.')) { - return lhs.replace(new RegExp('^\\$?root\\.'), ''); + return lhs.replace(ROOT_PREFIX_RE, ''); } return dottedPrefix ? dottedPrefix + '.' + lhs : lhs; } scopeValuesToParent() { - let scope = this.currentFieldPath.replace(new RegExp('\.[^\.]+$'), ''); + let scope = this.currentFieldPath.replace(TRAILING_FIELD_RE, ''); this.values = data_get(this.rootValues, scope); diff --git a/resources/js/components/field-conditions/analyzeSetConditions.js b/resources/js/components/field-conditions/analyzeSetConditions.js new file mode 100644 index 00000000000..45df56fffb0 --- /dev/null +++ b/resources/js/components/field-conditions/analyzeSetConditions.js @@ -0,0 +1,152 @@ +import { isPlainObject } from 'lodash-es'; +import { KEYS } from './Constants.js'; + +// A never-mounted set body has its conditions evaluated by a headless watcher instead +// of by the Field components. That evaluation is only as reactive as the watcher's +// sources, so we work out up front what a set's conditions actually depend on. +// +// Every unrecognised shape has to fall into the slower branch. A set that watches too +// much is slow; a set that watches too little writes a stale `omitValue` and silently +// drops the value from the save payload. + +const OUTSIDE_SET_RE = /^(\$root\.|root\.|\$parent\.)/; +const ROOT_PREFIX_RE = /^\$?root\./; +const CUSTOM_CONDITION_RE = /^\s*custom\s/; + +const cache = new WeakMap(); + +export default function analyzeSetConditions(config) { + if (!isPlainObject(config)) { + return { needsRootValues: true, canDeferMount: false, hasRevealer: true, rootPaths: [] }; + } + + if (!cache.has(config)) cache.set(config, analyze(config)); + + return cache.get(config); +} + +function analyze(config) { + const fields = fieldList(config.fields); + + if (fields === null) { + return { needsRootValues: true, canDeferMount: false, hasRevealer: true, rootPaths: [] }; + } + + let needsRootValues = false; + let canDeferMount = true; + let hasRevealer = false; + const rootPaths = new Set(); + + fields.forEach((field) => { + if (!isPlainObject(field)) { + needsRootValues = true; + canDeferMount = false; + hasRevealer = true; + return; + } + + if (conditionsFor(field).some(dependsOutsideSet)) needsRootValues = true; + + conditionsFor(field).forEach((conditions) => { + rootTargets(conditions).forEach((path) => rootPaths.add(path)); + }); + + // Revealers register themselves with the container when they mount, and that + // registration changes how every other field's `omitValue` is worked out. + if (field.type === 'revealer') { + canDeferMount = false; + hasRevealer = true; + } + + const nested = nestedFields(field); + + // An unrecognised shape could be hiding anything, including a revealer. + if (nested === null) { + canDeferMount = false; + hasRevealer = true; + return; + } + + if (nested.some((child) => child.type === 'revealer')) { + canDeferMount = false; + hasRevealer = true; + } + + // Nothing evaluates the conditions of fields nested inside this set's fields, + // so those only stay correct if the set actually mounts. + if (nested.some((child) => conditionsFor(child).length > 0)) canDeferMount = false; + }); + + return { needsRootValues, canDeferMount, hasRevealer, rootPaths: [...rootPaths] }; +} + +function conditionsFor(field) { + return KEYS.filter((key) => field[key]).map((key) => field[key]); +} + +// The paths a set's conditions read out of the container's root values, with the prefix +// stripped so they're the paths `data_get` is handed. Converter leaves `$root.`/`root.` +// handles alone, so what's written in the blueprint is what gets resolved. +function rootTargets(conditions) { + if (!isPlainObject(conditions)) return []; + + return Object.keys(conditions) + .filter((lhs) => typeof lhs === 'string' && ROOT_PREFIX_RE.test(lhs)) + .map((lhs) => lhs.replace(ROOT_PREFIX_RE, '')); +} + +function dependsOutsideSet(conditions) { + // A bare string is a custom condition without a target. The callback is handed the + // root values, so we have to assume it reads them. + if (typeof conditions === 'string') return true; + + if (!isPlainObject(conditions)) return true; + + return Object.entries(conditions).some(([lhs, rhs]) => { + if (typeof lhs !== 'string') return true; + if (OUTSIDE_SET_RE.test(lhs)) return true; + if (typeof rhs === 'string' && CUSTOM_CONDITION_RE.test(rhs)) return true; + + return !isScalar(rhs); + }); +} + +// Every field config underneath this one, at any depth. Grids and groups keep theirs in +// `fields`, replicators and Bards in `sets` (either groups of sets, or bare sets). +// Returns null if anything along the way isn't a shape we recognise. +function nestedFields(field) { + const found = []; + const queue = [field]; + const seen = new Set(); + + while (queue.length) { + const current = queue.shift(); + + if (seen.has(current)) continue; + seen.add(current); + + if (!isPlainObject(current)) return null; + + const children = fieldList(current.fields); + const sets = fieldList(current.sets); + + if (children === null || sets === null) return null; + + found.push(...children); + queue.push(...children, ...sets); + } + + return found; +} + +function fieldList(fields) { + if (fields === undefined || fields === null) return []; + if (Array.isArray(fields)) return fields; + if (isPlainObject(fields)) return Object.values(fields); + + return null; +} + +function isScalar(value) { + return value === null || ['string', 'number', 'boolean'].includes(typeof value); +} diff --git a/resources/js/components/fieldtypes/TemplateFieldtype.vue b/resources/js/components/fieldtypes/TemplateFieldtype.vue index 673f0f0a7bb..89cdb8d301a 100644 --- a/resources/js/components/fieldtypes/TemplateFieldtype.vue +++ b/resources/js/components/fieldtypes/TemplateFieldtype.vue @@ -22,6 +22,20 @@ diff --git a/resources/js/components/fieldtypes/assets/AssetsFieldtype.vue b/resources/js/components/fieldtypes/assets/AssetsFieldtype.vue index d1c52c6dc5a..ea6cc68c59e 100644 --- a/resources/js/components/fieldtypes/assets/AssetsFieldtype.vue +++ b/resources/js/components/fieldtypes/assets/AssetsFieldtype.vue @@ -201,6 +201,7 @@ import { isEqual } from 'lodash-es'; import { Button, Dropdown, DropdownMenu, DropdownItem, Stack } from '@/components/ui'; import ItemActions from '@/components/actions/ItemActions.vue'; import useCheckerboard from '@/composables/checkerboard.js'; +import { dedupeInFlight } from '@/util/dedupeInFlight.js'; export default { components: { @@ -496,14 +497,16 @@ export default { this.loading = true; - this.$axios - .post(cp_url('assets-fieldtype'), { - assets, - }) - .then((response) => { - this.assets = response.data; - this.loading = false; - }); + const cacheKey = JSON.stringify([...assets].slice().sort()); + + dedupeInFlight('assets-fieldtype', cacheKey, () => + this.$axios.post(cp_url('assets-fieldtype'), { assets }), + ).then((response) => { + // Clone so mutations on one field's asset rows don't bleed into others + // sharing the same in-flight response. + this.assets = clone(response.data); + this.loading = false; + }); }, /** diff --git a/resources/js/components/fieldtypes/bard/BardFieldtype.vue b/resources/js/components/fieldtypes/bard/BardFieldtype.vue index acfacc43a41..24bcf69cd94 100644 --- a/resources/js/components/fieldtypes/bard/BardFieldtype.vue +++ b/resources/js/components/fieldtypes/bard/BardFieldtype.vue @@ -7,7 +7,7 @@
set.handle === handle); - text += ` [${__(set ? set.display : handle)}]`; - } - if (text.length > 150) { - break; - } - if (node.content) { - stack.unshift(...node.content); - } - } - return text; + return extractBardText(this.value, 150, this.setConfigs); }, inputIsInline() { @@ -376,6 +368,7 @@ export default { }, shouldShowAddSetHelperText() { + if (!this.editor) return false; return !this.$refs.setPicker?.isOpen && this.suitableToShowSetButton(this.editor); }, }, @@ -390,17 +383,12 @@ export default { } }, - async mounted() { - tiptap = await importTiptap(); + mounted() { + this.watchSetConditionsWithoutEditor(); + // Preview text and value watchers work without TipTap. Defer the expensive + // Editor construction until this field is (nearly) visible or focused. this.initToolbarButtons(); - this.initEditor(); - - this.json = this.editor.getJSON().content; - this.html = this.editor.getHTML(); - - this.$nextTick(() => this.mounted = true); - this.pageHeader = document.querySelector('.global-header'); if (!commandPaletteCallbackRegistered) { @@ -415,16 +403,19 @@ export default { } this.$nextTick(() => { + this.setupLazyEditor(); + let el = document.querySelector(`label[for="${this.fieldId}"]`); if (el) { el.addEventListener('click', () => { - this.editor.commands.focus(); + this.ensureEditor().then(() => this.editor?.commands.focus()); }); } }); }, beforeUnmount() { + this._intersectionObserver?.disconnect(); this.editor?.destroy(); this.escBinding?.destroy(); }, @@ -461,7 +452,7 @@ export default { }, readOnly(readOnly) { - this.editor.setEditable(!this.readOnly); + this.editor?.setEditable(!this.readOnly); }, collapsed(value) { @@ -471,19 +462,22 @@ export default { }, fullScreenMode(fullScreenMode) { - this.initEditor(); - - if (fullScreenMode) { - this.escBinding = this.$keys.bindGlobal('esc', this.closeFullscreen); - // Focus the editor content when entering fullscreen mode - this.$nextTick(() => { - if (this.editor) { - this.editor.commands.focus(); - } - }); - } else { - this.escBinding?.destroy(); - } + // Portal remount needs a fresh TipTap instance bound to the new DOM. + // ensureEditor() covers the lazy-init case; initEditor() recreates when + // an editor already existed outside the portal. + const hadEditor = !!this.editor; + this.ensureEditor().then(() => { + if (hadEditor) this.initEditor(); + + if (fullScreenMode) { + this.escBinding = this.$keys.bindGlobal('esc', this.closeFullscreen); + this.$nextTick(() => { + this.editor?.commands.focus(); + }); + } else { + this.escBinding?.destroy(); + } + }); }, loadingSet(loading) { @@ -513,6 +507,112 @@ export default { }, methods: { + // Until the editor is built, there are no set node views to do the condition + // bookkeeping the save payload is built from, so do it from the value. Stops for + // good once an editor exists — see evaluateSetConditions.js. + watchSetConditionsWithoutEditor() { + if (this.setConfigs.length === 0) return; + + const container = this.injectedPublishContainer; + + // Same narrow/wide split as a collapsed set: only take a dependency on the + // whole form when a set's conditions can actually reach outside itself. + const needsRootValues = this.setConfigs.some( + (config) => analyzeSetConditions(config).needsRootValues, + ); + + const sources = [ + () => this.value, + () => this.editor, + () => container.revealerValues.value, + ]; + + if (needsRootValues) sources.push(() => container.visibleValues.value); + + let stop = null; + + stop = watch( + sources, + () => { + if (this.editor) { + stop?.(); + return; + } + + evaluateBardSetConditions({ + value: this.value, + setConfigs: this.setConfigs, + fieldPathPrefix: this.setFieldPathPrefix, + container, + }); + }, + { deep: true, immediate: true }, + ); + + this._stopHeadlessSetConditions = stop; + }, + + setupLazyEditor() { + const el = this.$refs.container; + if (!el || typeof IntersectionObserver === 'undefined') { + this.ensureEditor(); + return; + } + + // Already in view (e.g. first expanded set) — init immediately. + const rect = el.getBoundingClientRect(); + const inView = rect.top < window.innerHeight + 100 && rect.bottom > -100; + if (inView) { + this.ensureEditor(); + return; + } + + this._intersectionObserver = new IntersectionObserver( + (entries) => { + if (entries.some((entry) => entry.isIntersecting)) { + this._intersectionObserver?.disconnect(); + this._intersectionObserver = null; + this.ensureEditor(); + } + }, + { rootMargin: '100px' }, + ); + this._intersectionObserver.observe(el); + }, + + async ensureEditor() { + if (this.editor) return this.editor; + if (this._editorInitPromise) return this._editorInitPromise; + + this._editorInitPromise = (async () => { + try { + tiptap = await importTiptap(); + this.initEditor(); + // The set node views take over the condition bookkeeping from here. + this._stopHeadlessSetConditions?.(); + // Seed json from the editor once. Skip getHTML() here — it's a full-doc + // serialize and only needed when reading-time/footer config is enabled + // (computed lazily via onUpdate / readingTime). + // Defer `mounted` so the json watcher skips this seed — TipTap often + // normalizes content slightly, and pushing that into values was + // refreshing Live Preview on scroll/expand (lazy init). + this.json = this.editor.getJSON().content; + await this.$nextTick(); + this.mounted = true; + return this.editor; + } catch (error) { + this.initError = error.message || String(error); + throw error; + } + })(); + + try { + return await this._editorInitPromise; + } finally { + this._editorInitPromise = null; + } + }, + addSet(handle) { this.loadingSet = handle; @@ -823,6 +923,8 @@ export default { }, buttonIsActive(button) { + // Toolbar can render before lazy TipTap init finishes. + if (!this.editor) return false; if (button.hasOwnProperty('active')) { return button.active(this.editor, button.args); } @@ -832,6 +934,7 @@ export default { }, buttonIsVisible(button) { + if (!this.editor) return !button.hasOwnProperty('visibleWhenActive'); if (button.hasOwnProperty('visible')) { return button.visible(this.editor, button.args); } @@ -893,7 +996,10 @@ export default { if (countNodes(oldJson) !== countNodes(newJson)) this.debounceNextUpdate = false; this.json = newJson; - this.html = this.editor.getHTML(); + + if (this.config.reading_time) { + this.html = this.editor.getHTML(); + } }, onCreate: ({ editor }) => { const state = editor.view.state; @@ -943,7 +1049,7 @@ export default { }, valueToContent(value) { - return value.length ? { type: 'doc', content: value } : null; + return value?.length ? { type: 'doc', content: value } : null; }, getExtensions() { @@ -1011,7 +1117,11 @@ export default { setConfigs: this.setConfigs, addSet: this.addSet, }), - Dropcursor, + Dropcursor.configure({ + color: false, + width: 2, + class: 'bard-dropcursor', + }), Gapcursor, History, Paragraph, diff --git a/resources/js/components/fieldtypes/bard/Image.vue b/resources/js/components/fieldtypes/bard/Image.vue index 0dead0b49ed..5f6fc51cab7 100644 --- a/resources/js/components/fieldtypes/bard/Image.vue +++ b/resources/js/components/fieldtypes/bard/Image.vue @@ -71,6 +71,7 @@ import { NodeViewWrapper } from '@tiptap/vue-3'; import Selector from '../../assets/Selector.vue'; import { Input, Button, Stack } from '@ui'; import { containerContextKey } from '@/components/ui/Publish/Container.vue'; +import { dedupeInFlight } from '@/util/dedupeInFlight.js'; export default { mixins: [Asset], @@ -184,13 +185,13 @@ export default { return; } - this.$axios - .post(cp_url('assets-fieldtype'), { - assets: [id], - }) - .then((response) => { - this.setAsset(response.data[0]); - }); + const cacheKey = JSON.stringify([id]); + + dedupeInFlight('assets-fieldtype', cacheKey, () => + this.$axios.post(cp_url('assets-fieldtype'), { assets: [id] }), + ).then((response) => { + this.setAsset(response.data[0]); + }); }, setAsset(asset) { diff --git a/resources/js/components/fieldtypes/bard/Set.vue b/resources/js/components/fieldtypes/bard/Set.vue index 1c99d83d775..b88d8d66b8a 100644 --- a/resources/js/components/fieldtypes/bard/Set.vue +++ b/resources/js/components/fieldtypes/bard/Set.vue @@ -22,8 +22,9 @@
@@ -80,8 +81,9 @@
@@ -117,9 +119,13 @@ import { PublishFields as Fields } from '@ui'; import { containerContextKey } from '@/components/ui/Publish/Container.vue'; -import { watch } from 'vue'; +import { watch, inject } from 'vue'; import { reveal } from '@api'; import { useUiDirection } from '@/composables/ui-direction'; +import { createMountScheduler } from '@/util/createMountScheduler.js'; +import ShowField from '@/components/field-conditions/ShowField.js'; +import analyzeSetConditions from '@/components/field-conditions/analyzeSetConditions.js'; +import { keepElementUnderPointer } from '@/util/keepElementUnderPointer.js'; export default { props: nodeViewProps, @@ -127,6 +133,17 @@ export default { setup() { return { uiDirection: useUiDirection().direction, + mountScheduler: inject('mountScheduler', createMountScheduler()), + }; + }, + + data() { + const collapsedIds = this.extension.options.bard.collapsed || []; + const initiallyCollapsed = collapsedIds.includes(this.node.attrs.id); + + return { + hasBeenExpanded: !initiallyCollapsed, + fieldsReady: !initiallyCollapsed, }; }, @@ -154,6 +171,10 @@ export default { }, computed: { + conditionScope() { + return analyzeSetConditions(this.config); + }, + fields() { return this.config.fields; }, @@ -321,6 +342,16 @@ export default { } }, + prewarmFields() { + if (this.hasBeenExpanded || !this.hasFields) return; + this.hasBeenExpanded = true; + this.mountScheduler.schedule(() => { + if (!this._setUnmounted) { + this.fieldsReady = true; + } + }); + }, + collapse() { // this.$events.$emit('collapsed', this.node.attrs.id); this.extension.options.bard.collapseSet(this.node.attrs.id); @@ -344,13 +375,43 @@ export default { this._draggableObserver?.disconnect(); this.$el.setAttribute('draggable', true); + // dragstart fires on this.$el (the draggable wrapper), not the inner container. + this.$el.addEventListener('dragstart', this.hideSetBodiesForDrag, { once: true }); + // The drop recreates this node view, so dragend fires on an element that's no + // longer in the document and never reaches the listener below. No mouseup is + // dispatched during a native drag either, hence the listener on the element too. + this.$el.addEventListener('dragend', this.disableDragging, { once: true }); document.addEventListener('mouseup', this.disableDragging, { once: true }); document.addEventListener('dragend', this.disableDragging, { once: true }); }, + // The .bard-dragging class hides the set bodies for the duration of the drag. + // Don't actually collapse the sets — that would persist to meta and leave + // everything collapsed after the drop. + hideSetBodiesForDrag(event) { + const bard = this.extension.options.bard; + + // Held onto so it can be cleaned up from a detached element. + this._dragRoot = this.$el.closest('.bard-fieldtype'); + + keepElementUnderPointer(this.$el, () => { + bard.dragging = true; + this._dragRoot?.classList.add('bard-dragging'); + }); + + const rect = this.$el.getBoundingClientRect(); + event.dataTransfer?.setDragImage(this.$el, event.clientX - rect.left, event.clientY - rect.top); + }, + disableDragging() { + this.$el.removeEventListener('dragstart', this.hideSetBodiesForDrag); this.$el.setAttribute('draggable', false); this._draggableObserver?.observe(this.$el, { attributes: true, attributeFilter: ['draggable'] }); + + const bard = this.extension.options.bard; + bard.dragging = false; + this._dragRoot?.classList.remove('bard-dragging'); + this._dragRoot = null; }, preventNodeSelectionDrag(event) { @@ -379,6 +440,78 @@ export default { { deep: true } ); + watch( + () => this.collapsed, + (collapsed) => { + if (!collapsed && !this.hasBeenExpanded) { + this.hasBeenExpanded = true; + this.mountScheduler.schedule(() => { + if (!this._setUnmounted) { + this.fieldsReady = true; + } + }); + } else if (!collapsed) { + this.fieldsReady = true; + } + }, + ); + + // Some of what mounting used to do can't be done headlessly — nothing evaluates + // the conditions of fields nested inside this set's fields, and revealers only + // register themselves when they mount. Those sets get mounted anyway, but off + // the critical path. + if (!this.conditionScope.canDeferMount) this.prewarmFields(); + + // Headlessly evaluate conditions for never-mounted sets so omitValue + // bookkeeping stays correct for the save payload. Sets whose conditions only + // look at their own values keep a narrow dependency; the rest have to watch + // the whole tree. + const sources = [ + () => this.values, + () => this.fieldsReady, + () => this.publishContainer.revealerValues.value, + ]; + + if (this.conditionScope.needsRootValues) { + sources.push(() => this.publishContainer.visibleValues.value); + } + + watch( + sources, + () => { + if (this.fieldsReady || !this.hasFields) return; + + const fields = Array.isArray(this.fields) + ? this.fields + : Object.values(this.fields || {}); + + const showField = new ShowField( + this.values || {}, + {}, + this.publishContainer.visibleValues.value, + this.publishContainer.revealerValues.value, + this.publishContainer.hiddenFields.value, + this.publishContainer.setHiddenField, + { container: this.publishContainer.container }, + ); + + fields.forEach((field) => { + showField.showField(field, `${this.fieldPathPrefix}.${field.handle}`); + }); + }, + { deep: true, immediate: true }, + ); + + // Auto-expand when validation errors point at this set. Errors arrive from a + // failed save without remounting, so mount alone isn't enough of a hook. + watch( + () => this.hasError, + (hasError) => { + if (hasError && this.collapsed) this.expand(); + }, + { immediate: true }, + ); + reveal.mount(this.$refs.container, this.expand); // Firefox bug 739071: text selection doesn't work inside elements with a @@ -398,6 +531,7 @@ export default { }, beforeUnmount() { + this._setUnmounted = true; this._draggableObserver?.disconnect(); }, }; diff --git a/resources/js/components/fieldtypes/bard/evaluateSetConditions.js b/resources/js/components/fieldtypes/bard/evaluateSetConditions.js new file mode 100644 index 00000000000..0b6b2516095 --- /dev/null +++ b/resources/js/components/fieldtypes/bard/evaluateSetConditions.js @@ -0,0 +1,168 @@ +import { isPlainObject } from 'lodash-es'; +import ShowField from '@/components/field-conditions/ShowField.js'; +import analyzeSetConditions from '@/components/field-conditions/analyzeSetConditions.js'; +import { KEYS } from '@/components/field-conditions/Constants.js'; + +// A Bard field that has never been scrolled into view has no editor, so it has no set +// node views either — there is no `bard/Set.vue` to evaluate the conditions of the fields +// inside its sets, headlessly or otherwise. Nothing writes their omitValue bookkeeping, +// and a save keeps fields the blueprint says are hidden. +// +// So evaluate them from the stored value instead. The paths have to match exactly what a +// mounted set would write, because a wrong key writes bookkeeping for a field that isn't +// the one we evaluated. A set's fields live at `..attrs.values.`, +// where the index is the node's position in the document. Without an editor the stored +// value *is* the document — and it's the tree the container omits from — so the index is +// unambiguous. +// +// That last part is also why this must stop the moment an editor exists. From then on the +// editor's copy of the document is the authoritative one, the indexes are its, and the +// node views own the bookkeeping. +export default function evaluateBardSetConditions({ value, setConfigs, fieldPathPrefix, container }) { + if (!Array.isArray(value) || !Array.isArray(setConfigs) || setConfigs.length === 0) return; + + // Revealers are the other thing this path can't answer for. A field gated on one is + // only kept because the revealer registered itself with the container when it mounted, + // and the condition pointing at a registered revealer is then filtered out before + // anything decides to omit the value. Nothing in a field with no editor ever mounts, + // so its revealers never register, and every field gated on one looks like it simply + // failed an ordinary condition — dropped from the save. + // + // `analyzeSetConditions` handles this everywhere else by refusing to defer the set's + // mount. There's no mount to force here, so decline instead. + // + // The decline covers the whole field rather than only the sets that contain a + // revealer, and it doesn't look at what the conditions target. A narrower rule would + // have to trust that it recognises every way a condition can reach a revealer, and + // that's the trust that lost data before: a `$root.` condition can point across sets, + // a bare custom condition names no target at all, and `prefix` rewrites handles before + // they're compared. Being wrong about one of those drops content; declining too often + // only keeps a value the blueprint would have hidden, and only until the field is + // scrolled into view. + // + // A revealer in *another* never-rendered field is the same bug seen from the far side: + // that field's registration is missing too, and a `$root.` condition can point straight + // at it. Nothing here can tell whose revealer it is, so the second half of the rule is + // blunter — decline whenever a `$root.` condition targets a path that isn't in the root + // values at all. An unregistered revealer is always such a path, because a revealer's + // own value is omitted and so never survives into `visibleValues`. + // + // Absent has to mean genuinely missing. A field that's present and null, or present and + // an empty string, resolves fine and its conditions are still evaluated normally — + // treating those as unresolvable would switch omission off across most forms. + // + // This does catch more than revealers: a typo'd handle, or a target that some other + // condition has already omitted, declines too. Accepted — the alternative is guessing + // at what a path was meant to reach. + const declineAll = setConfigs.some((config) => { + const { hasRevealer, rootPaths } = analyzeSetConditions(config); + + return hasRevealer || rootPaths.some((path) => !isPresent(container.visibleValues.value, path)); + }); + + value.forEach((node, index) => { + if (!isPlainObject(node) || node.type !== 'set') return; + + const values = node.attrs?.values; + if (!isPlainObject(values)) return; + + const config = setConfigs.find((set) => set?.handle === values.type); + if (!isPlainObject(config)) return; + + const fields = fieldList(config.fields); + if (fields.length === 0) return; + + const prefix = `${fieldPathPrefix}.${index}.attrs.values`; + + let showField = null; + + fields.forEach((field) => { + if (!isPlainObject(field) || !field.handle) return; + + const dottedKey = `${prefix}.${field.handle}`; + + // A revealer's own value is never saved whatever its conditions resolve to, + // so the decline doesn't need to cover it — and covering it would write the + // toggle state into the entry. + const declining = declineAll && field.type !== 'revealer'; + + if (declining || !conditionsResolveFromPath(field)) { + keepValue(container, dottedKey); + return; + } + + showField ??= new ShowField( + values, + {}, + container.visibleValues.value, + container.revealerValues.value, + container.hiddenFields.value, + container.setHiddenField, + { container: container.container }, + ); + + showField.showField(field, dottedKey); + }); + }); +} + +// The field path isn't only a key to write bookkeeping under, it's an input to the +// conditions themselves: `$parent.` is resolved by walking up it. A Bard set's field path +// carries two more segments than a Replicator's — `bard.0.attrs.values.foo` against +// `rep.0.foo` — so the walk lands on a path that doesn't exist and the condition can never +// pass. That's a pre-existing bug, and a mounted set gets the same wrong answer; the +// difference is that a mounted set is on screen, whereas here the answer would be applied +// to a field nobody has ever looked at, and would drop it from every save. +// +// So don't answer at all. Keeping a field the blueprint would have dropped is noise; +// dropping one it would have kept is lost content. +// +// Nothing else about a set's conditions reads the field path. `$root.` reads the +// container's values and a bare or dotted handle reads the set's own, and both are the +// same values a mounted set would be handed. +const PARENT_RE = /^\$parent\./; + +function conditionsResolveFromPath(field) { + return KEYS.filter((key) => field[key]).every((key) => resolvableConditions(field[key])); +} + +function resolvableConditions(conditions) { + // A bare string is a custom condition without a target. Its callback is handed the + // same field path either way, so there's nothing here a mounted set resolves better. + if (typeof conditions === 'string') return true; + + if (!isPlainObject(conditions)) return false; + + return Object.keys(conditions).every((lhs) => !PARENT_RE.test(lhs)); +} + +// `data_get` can't answer this: it returns its fallback for a missing path and for a null +// one alike, and it short-circuits on any falsy step along the way. +function isPresent(values, path) { + let current = values; + + for (const segment of path.split('.')) { + if (current === null || typeof current !== 'object') return false; + if (!(segment in current)) return false; + + current = current[segment]; + } + + return true; +} + +function keepValue(container, dottedKey) { + const current = container.hiddenFields.value[dottedKey]; + + // Writing unconditionally would retrigger the watcher this runs from. + if (current && current.hidden === false && current.omitValue === false) return; + + container.setHiddenField({ dottedKey, hidden: false, omitValue: false }); +} + +function fieldList(fields) { + if (Array.isArray(fields)) return fields; + if (isPlainObject(fields)) return Object.values(fields); + + return []; +} diff --git a/resources/js/components/fieldtypes/markdown/MarkdownFieldtype.vue b/resources/js/components/fieldtypes/markdown/MarkdownFieldtype.vue index e2b6adb043c..c140ecfd078 100644 --- a/resources/js/components/fieldtypes/markdown/MarkdownFieldtype.vue +++ b/resources/js/components/fieldtypes/markdown/MarkdownFieldtype.vue @@ -186,6 +186,7 @@ import Uploader from '../../assets/Uploader.vue'; import Uploads from '../../assets/Uploads.vue'; import MarkdownToolbar from './MarkdownToolbar.vue'; import { useContentDirection } from '@/composables/content-direction'; +import { dedupeInFlight } from '@/util/dedupeInFlight.js'; // Keymaps import 'codemirror/keymap/sublime'; @@ -587,8 +588,12 @@ export default { this.closeAssetSelector(); this.selectedAssets = []; - this.$axios.post(cp_url('assets-fieldtype'), { assets }).then(({ data }) => { - data.forEach(asset => { + const cacheKey = JSON.stringify([...assets].slice().sort()); + + dedupeInFlight('assets-fieldtype', cacheKey, () => + this.$axios.post(cp_url('assets-fieldtype'), { assets }), + ).then(({ data }) => { + data.forEach((asset) => { const alt = asset.values.alt || ''; const url = encodeURI(`statamic://${asset.reference}`); const method = assets.length === 1 ? 'insert' : 'append'; diff --git a/resources/js/components/fieldtypes/replicator/ManagesPreviewText.js b/resources/js/components/fieldtypes/replicator/ManagesPreviewText.js index 44149c7d579..b9b5f7ec479 100644 --- a/resources/js/components/fieldtypes/replicator/ManagesPreviewText.js +++ b/resources/js/components/fieldtypes/replicator/ManagesPreviewText.js @@ -1,30 +1,22 @@ -import PreviewHtml from './PreviewHtml'; +import { buildPreviewText } from '@/util/buildPreviewText'; +import formatPreviewValueUtil from '@/util/formatPreviewValue'; export default { computed: { previewText() { - return Object.entries(this.previews) - .filter(([handle, value]) => { - if (!handle.endsWith('_')) return false; - handle = handle.substr(0, handle.length - 1); // Remove the trailing underscore. - const config = this.config.fields.find((f) => f.handle === handle); - if (!config) return false; - return config.replicator_preview === undefined ? this.showFieldPreviews : config.replicator_preview; - }) - .map(([handle, value]) => value) - .filter((value) => (['null', '[]', '{}', '', undefined].includes(JSON.stringify(value)) ? null : value)) - .map((value) => { - if (value instanceof PreviewHtml) return value.html; - - if (typeof value === 'string') return escapeHtml(value); - - if (Array.isArray(value) && typeof value[0] === 'string') { - return escapeHtml(value.join(', ')); - } + return buildPreviewText({ + previews: this.previews, + config: this.config, + values: this.values, + showFieldPreviews: this.showFieldPreviews, + separator: ' / ', + }); + }, + }, - return escapeHtml(JSON.stringify(value)); - }) - .join(' / '); + methods: { + formatPreviewValue(value, fieldConfig) { + return formatPreviewValueUtil(value, fieldConfig, { escape: false }); }, }, }; diff --git a/resources/js/components/fieldtypes/replicator/Replicator.vue b/resources/js/components/fieldtypes/replicator/Replicator.vue index d2843c3f0ba..f81d3b3818d 100644 --- a/resources/js/components/fieldtypes/replicator/Replicator.vue +++ b/resources/js/components/fieldtypes/replicator/Replicator.vue @@ -16,17 +16,21 @@ @close="toggleFullscreen" /> -
+
@@ -95,6 +99,8 @@ import AddSetButton from './AddSetButton.vue'; import ManagesSetMeta from './ManagesSetMeta'; import { SortableList } from '../../sortable/Sortable'; import { data_get } from "@/bootstrap/globals.js"; +import { createMountScheduler } from '@/util/createMountScheduler.js'; +import { keepElementUnderPointer } from '@/util/keepElementUnderPointer.js'; export default { mixins: [Fieldtype, ManagesSetMeta], @@ -114,10 +120,12 @@ export default { provide: { replicatorSets: this.config.sets, showReplicatorFieldPreviews: this.config.previews, + mountScheduler: createMountScheduler(), }, errorsById: {}, setsCache: {}, loadingSet: null, + dragging: false, }; }, @@ -210,6 +218,27 @@ export default { this.update(value); }, + dragStarted(event) { + const source = event?.source || event?.originalSource; + const root = this.$refs.sets; + + // The .replicator-dragging class hides the set bodies for the duration of the + // drag. Don't actually collapse the sets — that would persist to meta and + // leave everything collapsed after the drop. + keepElementUnderPointer(source, () => { + this.dragging = true; + root?.classList.add('replicator-dragging'); + }); + + this.$emit('focus'); + }, + + dragEnded() { + this.dragging = false; + this.$refs.sets?.classList.remove('replicator-dragging'); + this.$emit('blur'); + }, + addSet(handle, index) { this.loadingSet = handle; diff --git a/resources/js/components/fieldtypes/replicator/Set.vue b/resources/js/components/fieldtypes/replicator/Set.vue index 3b7ef4c3a72..3cb70b385bb 100644 --- a/resources/js/components/fieldtypes/replicator/Set.vue +++ b/resources/js/components/fieldtypes/replicator/Set.vue @@ -1,5 +1,5 @@ @@ -143,8 +216,9 @@ reveal.use(rootEl, () => emit('expanded'));
emit('expanded'));
@@ -211,7 +287,7 @@ reveal.use(rootEl, () => emit('expanded')); diff --git a/resources/js/components/inputs/relationship/RelationshipInput.vue b/resources/js/components/inputs/relationship/RelationshipInput.vue index a6bb3dd7d9c..b4e94fad73e 100644 --- a/resources/js/components/inputs/relationship/RelationshipInput.vue +++ b/resources/js/components/inputs/relationship/RelationshipInput.vue @@ -109,6 +109,19 @@ import { router } from '@inertiajs/vue3'; import axios from 'axios'; const inFlightRequests = new Map(); +// Settled responses reused for the page-view lifetime. Cleared on Inertia +// navigation and when selections are confirmed from the selector stack +// (items may have been edited there). +const settledResponses = new Map(); +let navigationListenerAttached = false; + +function ensureSettledCacheClearedOnNavigation() { + if (navigationListenerAttached) return; + navigationListenerAttached = true; + router.on('before', () => { + settledResponses.clear(); + }); +} function detachFromInFlightRequest(component) { const entry = component._activeRequest; @@ -258,6 +271,8 @@ export default { }, created() { + ensureSettledCacheClearedOnNavigation(); + this.removeNavigationListener = router.on('before', () => { detachFromInFlightRequest(this); }); @@ -334,6 +349,9 @@ export default { }, selectionsUpdated(selections) { + // Items may have been edited inside the selector stack — invalidate settled cache. + settledResponses.clear(); + this.getDataForSelections(selections).then(() => { this.update(selections); }); @@ -354,6 +372,14 @@ export default { detachFromInFlightRequest(this); const cacheKey = JSON.stringify([this.itemDataUrl, this.site, selections?.slice().sort()]); + + const settled = settledResponses.get(cacheKey); + if (settled) { + this.$emit('item-data-updated', settled.data.data); + this.loading = false; + return Promise.resolve(settled); + } + let entry = inFlightRequests.get(cacheKey); if (!entry) { @@ -361,6 +387,10 @@ export default { entry = { cacheKey, controller, subscribers: 0 }; entry.promise = this.$axios .post(this.itemDataUrl, { site: this.site, selections }, { signal: controller.signal }) + .then((response) => { + settledResponses.set(cacheKey, response); + return response; + }) .finally(() => { if (inFlightRequests.get(cacheKey) === entry) { inFlightRequests.delete(cacheKey); diff --git a/resources/js/components/inputs/relationship/SelectField.vue b/resources/js/components/inputs/relationship/SelectField.vue index 628d716ae12..d5b1b9d5b21 100644 --- a/resources/js/components/inputs/relationship/SelectField.vue +++ b/resources/js/components/inputs/relationship/SelectField.vue @@ -41,12 +41,34 @@