diff --git a/resources/css/components/fieldtypes/bard.css b/resources/css/components/fieldtypes/bard.css index 1477f7ddead..26f4faf292f 100644 --- a/resources/css/components/fieldtypes/bard.css +++ b/resources/css/components/fieldtypes/bard.css @@ -1,5 +1,11 @@ /* BARD =================================================== */ + +.bard-bulk-op [data-type] header, +.bard-bulk-op [data-type] header * { + transition: none !important; +} + @layer ui { /* .grid-cell, [data-ui-input-group] are exceptions for Bard fieldtypes inside a Grid fieldtype, since these cause double borders */ .bard-fieldtype:not(.form-group, .grid-cell, [data-ui-input-group]) { diff --git a/resources/css/components/fieldtypes/replicator.css b/resources/css/components/fieldtypes/replicator.css index cbf0f8dc3bf..ecad8e9eea8 100644 --- a/resources/css/components/fieldtypes/replicator.css +++ b/resources/css/components/fieldtypes/replicator.css @@ -1,3 +1,8 @@ /* ========================================================================== REPLICATOR FIELDTYPE ========================================================================== */ + +.replicator-bulk-op [data-replicator-set] header, +.replicator-bulk-op [data-replicator-set] header * { + transition: none !important; +} diff --git a/resources/js/bootstrap/statamic.js b/resources/js/bootstrap/statamic.js index ba48aaa3a17..3a5eda975b2 100644 --- a/resources/js/bootstrap/statamic.js +++ b/resources/js/bootstrap/statamic.js @@ -198,9 +198,11 @@ export default { const bladeContent = el?.innerHTML || ''; const _this = this; + const corePages = import.meta.glob('../pages/**/*.vue'); + await createInertiaApp({ id: 'statamic', - resolve: name => { + resolve: async name => { if (name === 'NonInertiaPage') { return { default: { @@ -211,8 +213,8 @@ export default { } // Resolve core pages - const pages = import.meta.glob('../pages/**/*.vue', { eager: true }); - let page = pages[`../pages/${name}.vue`]; + const pageImport = corePages[`../pages/${name}.vue`]; + let page = pageImport ? await pageImport() : null; // Resolve addon pages if (!page) { diff --git a/resources/js/components/field-actions/HasFieldActions.js b/resources/js/components/field-actions/HasFieldActions.js index a73637e3ecc..f5546349896 100644 --- a/resources/js/components/field-actions/HasFieldActions.js +++ b/resources/js/components/field-actions/HasFieldActions.js @@ -1,13 +1,21 @@ import toFieldActions from './toFieldActions.js'; export default { + data() { + return { + _cachedFieldActions: null, + }; + }, + computed: { fieldActions() { - return toFieldActions( + if (this._cachedFieldActions) return this._cachedFieldActions; + this._cachedFieldActions = toFieldActions( this.fieldActionBinding, this.fieldActionPayload, this.internalFieldActions, ); + return this._cachedFieldActions; }, internalFieldActions() { diff --git a/resources/js/components/field-actions/toFieldActions.js b/resources/js/components/field-actions/toFieldActions.js index b9dcefb6dc9..d94c9a8677f 100644 --- a/resources/js/components/field-actions/toFieldActions.js +++ b/resources/js/components/field-actions/toFieldActions.js @@ -1,7 +1,8 @@ +import { markRaw } from 'vue'; import FieldAction from './FieldAction.js'; export default function toFieldActions(binding, payload, extraActions = []) { return [...Statamic.$fieldActions.get(binding), ...extraActions] - .map((action) => new FieldAction(action, payload)) + .map((action) => markRaw(new FieldAction(action, payload))) .filter((action) => action.visible); } diff --git a/resources/js/components/fieldtypes/Fieldtype.vue b/resources/js/components/fieldtypes/Fieldtype.vue index 2004e662b6e..cfc6ce5aef3 100644 --- a/resources/js/components/fieldtypes/Fieldtype.vue +++ b/resources/js/components/fieldtypes/Fieldtype.vue @@ -42,12 +42,31 @@ export default { // When using the Options API, this feels more natural. However since this is a // computed, it won't be avaialble within data(). In those cases you will // need to use this.injectedPublishContainer.someValue.value directly. - return Object.fromEntries( - Object.entries(this.injectedPublishContainer).map(([key, value]) => [ - key, - isRef(value) ? value.value : value, - ]) - ); + // + // We build the cache once with lazy getters, so the computed has zero reactive deps + // and is never invalidated. Dep tracking happens in the consumer's reactive scope. + if (this._publishContainerCache) return this._publishContainerCache; + const cache = {}; + const src = this.injectedPublishContainer; + for (const key in src) { + const val = src[key]; + if (isRef(val)) { + Object.defineProperty(cache, key, { + enumerable: true, + configurable: true, + get: () => val.value, + }); + } else { + Object.defineProperty(cache, key, { + enumerable: true, + configurable: true, + writable: false, + value: val, + }); + } + } + this._publishContainerCache = cache; + return cache; }, name() { diff --git a/resources/js/components/fieldtypes/LinkFieldtype.vue b/resources/js/components/fieldtypes/LinkFieldtype.vue index 6ac64115f14..d11af2e7310 100644 --- a/resources/js/components/fieldtypes/LinkFieldtype.vue +++ b/resources/js/components/fieldtypes/LinkFieldtype.vue @@ -40,6 +40,8 @@ diff --git a/resources/js/components/fieldtypes/grid/Row.vue b/resources/js/components/fieldtypes/grid/Row.vue index 1dfd4c61c9e..b28b4f6f4c6 100644 --- a/resources/js/components/fieldtypes/grid/Row.vue +++ b/resources/js/components/fieldtypes/grid/Row.vue @@ -98,15 +98,11 @@ export default { methods: { updated(handle, value) { - let row = JSON.parse(JSON.stringify(this.values)); - row[handle] = value; - this.$emit('updated', this.index, row); + this.$emit('updated', this.index, { ...this.values, [handle]: value }); }, metaUpdated(handle, value) { - let meta = clone(this.meta); - meta[handle] = value; - this.$emit('meta-updated', meta); + this.$emit('meta-updated', { ...this.meta, [handle]: value }); }, fieldPath(handle) { 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 085eafa56f7..eb013e202df 100644 --- a/resources/js/components/fieldtypes/replicator/Replicator.vue +++ b/resources/js/components/fieldtypes/replicator/Replicator.vue @@ -7,7 +7,7 @@
v._id); + this.$nextTick(() => requestAnimationFrame(() => { + this.suppressTransitions = false; + })); }, expandAll() { + this.suppressTransitions = true; this.collapsed = []; + this.$nextTick(() => requestAnimationFrame(() => { + this.suppressTransitions = false; + })); }, toggleFullscreen() { diff --git a/resources/js/components/fieldtypes/replicator/Set.vue b/resources/js/components/fieldtypes/replicator/Set.vue index 3e48b0fb8f6..ebfd875d135 100644 --- a/resources/js/components/fieldtypes/replicator/Set.vue +++ b/resources/js/components/fieldtypes/replicator/Set.vue @@ -1,5 +1,6 @@ @@ -203,20 +214,23 @@ reveal.use(rootEl, () => emit('expanded'));
- - - +
diff --git a/resources/js/components/inputs/relationship/RelationshipInput.vue b/resources/js/components/inputs/relationship/RelationshipInput.vue index cdcf943082f..a5df7e3d729 100644 --- a/resources/js/components/inputs/relationship/RelationshipInput.vue +++ b/resources/js/components/inputs/relationship/RelationshipInput.vue @@ -108,6 +108,8 @@ import { Button, Icon, Stack } from '@/components/ui'; import { router } from '@inertiajs/vue3'; import axios from 'axios'; +const inFlightRequests = new Map(); + export default { props: { canCreate: { type: Boolean }, @@ -246,7 +248,7 @@ export default { created() { this.removeNavigationListener = router.on('before', () => { - if (this.abortController) this.abortController.abort(); + if (this.abortController && this._ownsRequest) this.abortController.abort(); }); }, @@ -260,7 +262,7 @@ export default { }, beforeUnmount() { - if (this.abortController) this.abortController.abort(); + if (this.abortController && this._ownsRequest) this.abortController.abort(); if (this.removeNavigationListener) this.removeNavigationListener(); if (this.sortable) { this.sortable.destroy(); @@ -331,8 +333,18 @@ export default { if (this.abortController) this.abortController.abort(); this.abortController = new AbortController(); - return this.$axios + const cacheKey = this.itemDataUrl + '|' + (this.site || '') + '|' + JSON.stringify(selections?.slice().sort()); + const existing = inFlightRequests.get(cacheKey); + + this._ownsRequest = !existing; + + const request = existing ?? this.$axios .post(this.itemDataUrl, { site: this.site, selections }, { signal: this.abortController.signal }) + .finally(() => inFlightRequests.delete(cacheKey)); + + if (!existing) inFlightRequests.set(cacheKey, request); + + return request .then((response) => { this.$emit('item-data-updated', response.data.data); }) diff --git a/resources/js/components/ui/LivePreview/LivePreview.vue b/resources/js/components/ui/LivePreview/LivePreview.vue index 52de68f3ef6..b95535e9ec0 100644 --- a/resources/js/components/ui/LivePreview/LivePreview.vue +++ b/resources/js/components/ui/LivePreview/LivePreview.vue @@ -283,10 +283,14 @@ const keybinding = ref( }), ); -onUnmounted(() => keybinding.value.destroy()); +const refreshHandler = () => { if (props.enabled) update(); }; +const refreshEvent = `live-preview.${name.value}.refresh`; -Statamic.$events.$on(`live-preview.${name.value}.refresh`, () => { - if (props.enabled) update(); +Statamic.$events.$on(refreshEvent, refreshHandler); + +onUnmounted(() => { + keybinding.value.destroy(); + Statamic.$events.$off(refreshEvent, refreshHandler); }); diff --git a/resources/js/components/ui/Publish/Container.vue b/resources/js/components/ui/Publish/Container.vue index 11834fe0e08..34c67903602 100644 --- a/resources/js/components/ui/Publish/Container.vue +++ b/resources/js/components/ui/Publish/Container.vue @@ -9,8 +9,7 @@ import { nanoid as uniqid } from 'nanoid'; import { onMounted, onUnmounted, watch, ref, computed, toRef, nextTick } from 'vue'; import Component from '@/components/Component.js'; import Tabs from './Tabs.vue'; -import Values from '@/components/publish/Values.js'; -import { data_get } from '@/bootstrap/globals.js'; +import { clone, data_get } from '@/bootstrap/globals.js'; const emit = defineEmits(['update:modelValue', 'update:visibleValues', 'update:modifiedFields', 'update:meta']); @@ -106,11 +105,32 @@ const localizedFields = ref(props.modifiedFields || []); const components = ref([]); const direction = computed(() => Statamic.$config.get('sites').find(s => s.handle === props.site)?.direction ?? document.documentElement.dir ?? 'ltr'); +// File-scoped helper: walk the dotted path and delete the leaf if present. +function forgetAtPath(obj, path) { + const parts = path.split('.'); + while (parts.length > 1) { + const key = parts.shift(); + if (!obj || typeof obj !== 'object' || !(key in obj)) return; + obj = obj[key]; + } + if (obj && typeof obj === 'object') delete obj[parts[0]]; +} + const visibleValues = computed(() => { - const omittable = Object.keys(hiddenFields.value).filter( - (field) => hiddenFields.value[field].omitValue, - ); - return new Values(values.value).except(omittable); + const hf = hiddenFields.value; + let omittable = null; + for (const key in hf) { + if (hf[key].omitValue) { + if (omittable === null) omittable = []; + omittable.push(key); + } + } + + if (omittable === null) return values.value; + + const out = clone(values.value); + for (const key of omittable) forgetAtPath(out, key); + return out; }); const revealerValues = computed(() => { @@ -160,16 +180,19 @@ watch( watch( values, - (values) => { + (v) => { dirty(); - emit('update:modelValue', values); + emit('update:modelValue', v); + emit('update:visibleValues', visibleValues.value); }, { deep: true }, ); watch( - visibleValues, - (values) => emit('update:visibleValues', values), + hiddenFields, + () => { + emit('update:visibleValues', visibleValues.value); + }, { deep: true }, ); diff --git a/resources/js/components/ui/Publish/Field.vue b/resources/js/components/ui/Publish/Field.vue index d366abfcac4..42d44a8c76b 100644 --- a/resources/js/components/ui/Publish/Field.vue +++ b/resources/js/components/ui/Publish/Field.vue @@ -9,6 +9,7 @@ import { } from '@ui'; import FieldActions from '@/components/field-actions/FieldActions.vue'; import ShowField from '@/components/field-conditions/ShowField.js'; +import { KEYS } from '@/components/field-conditions/Constants.js'; const props = defineProps({ config: { @@ -137,7 +138,15 @@ const extraValues = computed(() => { return fieldPathPrefix.value ? data_get(containerExtraValues.value, fieldPathPrefix.value) : containerExtraValues.value; }); -const shouldShowField = computed(() => { +const conditionHandles = computed(() => { + const conditionKey = KEYS.find((k) => props.config[k]); + if (!conditionKey) return null; + const conditions = props.config[conditionKey]; + if (typeof conditions === 'string') return null; + return Object.keys(conditions); +}); + +function evaluateShowField() { return new ShowField( values.value, extraValues.value, @@ -147,8 +156,46 @@ const shouldShowField = computed(() => { setHiddenField, { container }, ).showField(props.config, fullPath.value); +} + +const hasConditions = computed(() => { + if (props.config.visibility === 'hidden') return false; + return KEYS.some((k) => props.config[k]); }); +const shouldShowField = ref(props.config.visibility !== 'hidden'); + +const isCustomCondition = computed(() => { + const conditionKey = KEYS.find((k) => props.config[k]); + return conditionKey ? typeof props.config[conditionKey] === 'string' : false; +}); + +if (hasConditions.value) { + shouldShowField.value = evaluateShowField(); + + watch( + () => { + if (isCustomCondition.value) return values.value; + const handles = conditionHandles.value; + if (!handles) return null; + const src = values.value ?? {}; + const rootSrc = containerValues.value ?? {}; + return handles.map((handle) => { + if (handle.startsWith('$root.') || handle.startsWith('root.')) { + return data_get(rootSrc, handle.replace(/^\$?root\./, '')); + } + return data_get(src, handle); + }); + }, + () => { shouldShowField.value = evaluateShowField(); }, + { deep: isCustomCondition.value }, + ); + + watch(hiddenFields, () => { + shouldShowField.value = evaluateShowField(); + }); +} + const shouldShowLabelText = computed(() => !props.config.hide_display); const shouldShowLabel = computed( diff --git a/resources/js/composables/use-preview-text.js b/resources/js/composables/use-preview-text.js new file mode 100644 index 00000000000..a8323a99983 --- /dev/null +++ b/resources/js/composables/use-preview-text.js @@ -0,0 +1,40 @@ +import { computed } from 'vue'; +import { buildPreviewText } from '@/util/buildPreviewText'; +import { data_get } from '@/bootstrap/globals.js'; + +/** + * Composable for generating preview text in replicator sets. + * + * @param {Object} options - Configuration options + * @param {Object} options.config - The set configuration with fields array + * @param {Object} options.values - The current field values + * @param {Object} options.previews - The preview data from mounted fieldtype components + * @param {string} options.fieldPathPrefix - The field path prefix for looking up previews + * @param {boolean} options.showFieldPreviews - Whether to show field previews by default + * @returns {Object} - Object containing the previewText computed property + */ +export default function usePreviewText(options) { + const { + config, + values, + previews, + fieldPathPrefix, + showFieldPreviews, + } = options; + + const previewText = computed(() => { + const previewData = data_get(previews.value, fieldPathPrefix.value) || {}; + + return buildPreviewText({ + previews: previewData, + config: config.value, + values: values.value, + showFieldPreviews: showFieldPreviews.value, + separator: ' / ', + }); + }); + + return { + previewText, + }; +} diff --git a/resources/js/tests/PublishContainerVisibleValues.test.js b/resources/js/tests/PublishContainerVisibleValues.test.js new file mode 100644 index 00000000000..c8499d6ebf6 --- /dev/null +++ b/resources/js/tests/PublishContainerVisibleValues.test.js @@ -0,0 +1,94 @@ +import { test, expect } from 'vitest'; +import { mount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import PublishContainer from '@ui/Publish/Container.vue'; + +window.Statamic = { + $dirty: { + add: () => {}, + remove: () => {}, + has: () => false, + }, + $events: { + $emit: () => {}, + }, + $config: { + get: (key) => (key === 'sites' ? [{ handle: 'default', direction: 'ltr' }] : undefined), + }, +}; +window.__ = (msg) => msg; + +const TestComponent = { template: '
' }; + +const createContainer = (props = {}) => mount(PublishContainer, { + props: { + blueprint: { publish: [], tabs: { main: { fields: [] } } }, + modelValue: {}, + trackDirtyState: false, + ...props, + }, + slots: { default: TestComponent }, +}); + +test('it returns values by reference when no hidden fields', () => { + const values = { title: 'Hello', body: 'World' }; + const wrapper = createContainer({ modelValue: values }); + + expect(wrapper.vm.visibleValues).toBe(wrapper.vm.values); +}); + +test('it omits top-level keys with omitValue true', async () => { + const values = { title: 'Hello', secret: 'hidden', body: 'World' }; + const wrapper = createContainer({ modelValue: values }); + + wrapper.vm.setHiddenField({ dottedKey: 'secret', hidden: true, omitValue: true }); + await nextTick(); + + expect(wrapper.vm.visibleValues).not.toHaveProperty('secret'); + expect(wrapper.vm.visibleValues).toHaveProperty('title', 'Hello'); + expect(wrapper.vm.visibleValues).toHaveProperty('body', 'World'); + expect(wrapper.vm.values).toHaveProperty('secret', 'hidden'); +}); + +test('it omits nested paths correctly', async () => { + const values = { + features: [ + { textcontent: 'Feature 1', price: 100 }, + { textcontent: 'Feature 2', price: 200 }, + ], + }; + const wrapper = createContainer({ modelValue: values }); + + wrapper.vm.setHiddenField({ dottedKey: 'features.0.textcontent', hidden: true, omitValue: true }); + await nextTick(); + + expect(wrapper.vm.visibleValues.features[0]).not.toHaveProperty('textcontent'); + expect(wrapper.vm.visibleValues.features[0]).toHaveProperty('price', 100); + expect(wrapper.vm.visibleValues.features[1]).toHaveProperty('textcontent', 'Feature 2'); +}); + +test('it returns values by reference when hidden field changes to not omit', async () => { + const values = { title: 'Hello', secret: 'hidden' }; + const wrapper = createContainer({ modelValue: values }); + + wrapper.vm.setHiddenField({ dottedKey: 'secret', hidden: true, omitValue: true }); + await nextTick(); + expect(wrapper.vm.visibleValues).not.toBe(wrapper.vm.values); + + wrapper.vm.setHiddenField({ dottedKey: 'secret', hidden: true, omitValue: false }); + await nextTick(); + expect(wrapper.vm.visibleValues).toBe(wrapper.vm.values); +}); + +test('it emits update:visibleValues on deep changes', async () => { + const values = { title: 'Hello', nested: { key: 'value' } }; + const wrapper = createContainer({ modelValue: values }); + + wrapper.emitted()['update:visibleValues'] = []; + + wrapper.vm.values.nested.key = 'updated'; + await nextTick(); + + expect(wrapper.emitted()['update:visibleValues']).toBeTruthy(); + expect(wrapper.emitted()['update:visibleValues'].length).toBeGreaterThan(0); +}); diff --git a/resources/js/tests/createMountScheduler.test.js b/resources/js/tests/createMountScheduler.test.js new file mode 100644 index 00000000000..380df6c2dec --- /dev/null +++ b/resources/js/tests/createMountScheduler.test.js @@ -0,0 +1,282 @@ +import { test, expect, vi, beforeEach, afterEach } from 'vitest'; +import { createMountScheduler } from '../util/createMountScheduler.js'; +import { mount } from '@vue/test-utils'; +import { nextTick, ref, h } from 'vue'; + +beforeEach(() => { + vi.useFakeTimers({ + toFake: [ + 'setTimeout', 'clearTimeout', + 'setInterval', 'clearInterval', + 'setImmediate', 'clearImmediate', + 'queueMicrotask', + 'requestAnimationFrame', 'cancelAnimationFrame', + 'requestIdleCallback', 'cancelIdleCallback', + 'Date', + ], + }); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +test('zero-arg factory still works (backward compat)', () => { + const scheduler = createMountScheduler(); + expect(scheduler).toHaveProperty('schedule'); + expect(typeof scheduler.schedule).toBe('function'); +}); + +// Helper to flush requestAnimationFrame / requestIdleCallback +const flushTick = async () => { + await vi.advanceTimersToNextTimerAsync(); +}; + +test('processes multiple cheap callbacks within budget', async () => { + const scheduler = createMountScheduler({ budgetMs: 8 }); + const results = []; + + // Schedule 5 cheap callbacks + for (let i = 0; i < 5; i++) { + scheduler.schedule(() => results.push(i)); + } + + await flushTick(); + + // All 5 should complete in one tick since they're cheap + expect(results).toEqual([0, 1, 2, 3, 4]); +}); + +test('resumes remaining callbacks on next tick when budget exceeded', async () => { + let processedCount = 0; + const scheduler = createMountScheduler({ budgetMs: 5 }); + + // Schedule callbacks - first one is slow, others are fast + scheduler.schedule(() => { + const start = performance.now(); + while (performance.now() - start < 10) {} // 10ms sync delay + processedCount++; + }); + + for (let i = 0; i < 4; i++) { + scheduler.schedule(() => processedCount++); + } + + await flushTick(); + // Budget was blown by first callback, so only 1 processed + expect(processedCount).toBe(1); + + await flushTick(); + // Next tick should process the rest + expect(processedCount).toBe(5); +}); + +test('error in one callback does not prevent others from running', async () => { + const scheduler = createMountScheduler({ budgetMs: 8 }); + const results = []; + + scheduler.schedule(() => results.push(1)); + scheduler.schedule(() => { throw new Error('Intentional error'); }); + scheduler.schedule(() => results.push(3)); + + // Spy on console.error + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + await flushTick(); + + expect(results).toEqual([1, 3]); + expect(consoleSpy).toHaveBeenCalledOnce(); + + consoleSpy.mockRestore(); +}); + +test('many cheap callbacks finish within reasonable iterations', async () => { + const scheduler = createMountScheduler({ budgetMs: 8 }); + const results = []; + + // 20 cheap callbacks + for (let i = 0; i < 20; i++) { + scheduler.schedule(() => results.push(i)); + } + + let iterations = 0; + while (results.length < 20 && iterations < 10) { + await flushTick(); + iterations++; + } + + expect(results.length).toBe(20); + expect(iterations).toBeLessThanOrEqual(3); +}); + +test('vue render time from a callback counts against the budget', async () => { + // Component that takes measurable time to render when it mounts. + const HeavyChild = { + setup() { + // Synthetic cost at setup time. + const start = performance.now(); + while (performance.now() - start < 6) {} + return () => h('div'); + }, + }; + const Parent = { + setup() { + const show = ref(false); + return { show }; + }, + render() { + return this.show ? h(HeavyChild) : h('div'); + }, + }; + + const scheduler = createMountScheduler({ budgetMs: 5 }); + const wrappers = [mount(Parent), mount(Parent), mount(Parent)]; + const mounted = []; + + wrappers.forEach((w, i) => { + scheduler.schedule(() => { + w.vm.show = true; + mounted.push(i); + }); + }); + + await flushTick(); + // First flip triggers HeavyChild mount (~6ms real time via nextTick); + // budget is 5ms, so only one should process this tick. + expect(mounted.length).toBe(1); + + await flushTick(); + await flushTick(); + expect(mounted.length).toBe(3); + + wrappers.forEach(w => w.unmount()); +}); + +test('scheduler recovers after a nextTick rejection', async () => { + const scheduler = createMountScheduler({ budgetMs: 8 }); + const results = []; + + // Mock nextTick to reject on the first invocation only. + const realNextTick = nextTick; + let first = true; + const spy = vi.spyOn(await import('vue'), 'nextTick').mockImplementation((...args) => { + if (first) { first = false; return Promise.reject(new Error('boom')); } + return realNextTick(...args); + }); + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + scheduler.schedule(() => results.push('a')); + scheduler.schedule(() => results.push('b')); + + await flushTick(); + await flushTick(); + + expect(results).toEqual(['a', 'b']); + expect(consoleSpy).toHaveBeenCalled(); + + spy.mockRestore(); + consoleSpy.mockRestore(); +}); + +test('a callback that schedules more work runs on a later tick', async () => { + const scheduler = createMountScheduler({ budgetMs: 8 }); + const order = []; + + scheduler.schedule(() => { + order.push('a'); + scheduler.schedule(() => order.push('b')); + }); + + await flushTick(); + expect(order).toEqual(['a', 'b']); +}); + +test('respects budgetMs when requestIdleCallback fires with didTimeout', async () => { + const originalRIC = globalThis.requestIdleCallback; + let ricCallCount = 0; + globalThis.requestIdleCallback = (cb) => { + ricCallCount++; + setTimeout(() => cb({ didTimeout: true, timeRemaining: () => 0 }), 0); + return 0; + }; + + try { + const scheduler = createMountScheduler({ budgetMs: 5 }); + const results = []; + + // First callback exceeds the 5ms budget. + scheduler.schedule(() => { + const start = performance.now(); + while (performance.now() - start < 10) {} + results.push('heavy'); + }); + for (let i = 0; i < 4; i++) scheduler.schedule(() => results.push(i)); + + // Drain until all five have run; cap iterations to avoid infinite loops on regression. + for (let i = 0; i < 10 && results.length < 5; i++) { + await flushTick(); + } + + expect(results).toEqual(['heavy', 0, 1, 2, 3]); + + // With the fix the busy callback forces a yield, so rIC is entered at + // least twice. With the old `return false` bypass, all 5 drain in one + // batch and ricCallCount stays at 1. + expect(ricCallCount).toBeGreaterThan(1); + } finally { + if (originalRIC) globalThis.requestIdleCallback = originalRIC; + else delete globalThis.requestIdleCallback; + } +}); + +test('yields based on IdleDeadline.timeRemaining when idle is granted', async () => { + const originalRIC = globalThis.requestIdleCallback; + let ricCallCount = 0; + globalThis.requestIdleCallback = (cb) => { + ricCallCount++; + const grantTime = performance.now(); + const deadline = { + didTimeout: false, + timeRemaining: () => Math.max(0, 10 - (performance.now() - grantTime)), + }; + setTimeout(() => cb(deadline), 0); + return 0; + }; + + try { + const scheduler = createMountScheduler({ budgetMs: 100 }); + const results = []; + + for (let i = 0; i < 3; i++) { + scheduler.schedule(() => { + const start = performance.now(); + while (performance.now() - start < 6) {} + results.push(i); + }); + } + + for (let i = 0; i < 10 && results.length < 3; i++) { + await flushTick(); + } + + expect(results).toEqual([0, 1, 2]); + expect(ricCallCount).toBeGreaterThan(1); + } finally { + if (originalRIC) globalThis.requestIdleCallback = originalRIC; + else delete globalThis.requestIdleCallback; + } +}); + +test('scheduler resumes cleanly after an earlier flush completes', async () => { + const scheduler = createMountScheduler({ budgetMs: 8 }); + const results = []; + + scheduler.schedule(() => results.push('a')); + await flushTick(); + expect(results).toEqual(['a']); + + scheduler.schedule(() => results.push('b')); + scheduler.schedule(() => results.push('c')); + await flushTick(); + expect(results).toEqual(['a', 'b', 'c']); +}); diff --git a/resources/js/tests/toFieldActions.test.js b/resources/js/tests/toFieldActions.test.js new file mode 100644 index 00000000000..9a5f24894a9 --- /dev/null +++ b/resources/js/tests/toFieldActions.test.js @@ -0,0 +1,46 @@ +import { test, expect, beforeEach, afterEach } from 'vitest'; +import { ref, reactive } from 'vue'; +import toFieldActions from '../components/field-actions/toFieldActions.js'; + +let originalStatamic; + +beforeEach(() => { + originalStatamic = globalThis.Statamic; + globalThis.Statamic = { + $fieldActions: { + get: () => [ + { title: 'Quick', quick: true, run: () => {} }, + { title: 'Slow', quick: false, run: () => {} }, + { title: 'Hidden', quick: false, visible: false, run: () => {} }, + ], + }, + }; +}); + +afterEach(() => { + globalThis.Statamic = originalStatamic; +}); + +test('returns FieldAction instances safe for reactive containers (ref)', () => { + const actions = toFieldActions('some-binding', {}); + const r = ref(actions); + + // Without markRaw, accessing a getter that reads a private field + // throws "can't access private field or method: object is not the right class". + expect(() => r.value[0].quick).not.toThrow(); + expect(r.value[0].quick).toBe(true); + expect(r.value[1].quick).toBe(false); +}); + +test('returns FieldAction instances safe for reactive containers (reactive)', () => { + const actions = toFieldActions('some-binding', {}); + const state = reactive({ list: actions }); + + expect(() => state.list[0].quick).not.toThrow(); + expect(state.list.filter((a) => !a.quick).map((a) => a.title)).toEqual(['Slow']); +}); + +test('filters out non-visible actions', () => { + const actions = toFieldActions('some-binding', {}); + expect(actions.map((a) => a.title)).toEqual(['Quick', 'Slow']); +}); diff --git a/resources/js/util/buildPreviewText.js b/resources/js/util/buildPreviewText.js new file mode 100644 index 00000000000..cadbb4cb8c2 --- /dev/null +++ b/resources/js/util/buildPreviewText.js @@ -0,0 +1,72 @@ +import PreviewHtml from '@/components/fieldtypes/replicator/PreviewHtml.js'; +import formatPreviewValue from '@/util/formatPreviewValue'; +import { escapeHtml } from '@/bootstrap/globals.js'; + +/** + * Build preview text from field values and mounted component previews. + * + * @param {Object} params - Parameters + * @param {Object} params.previews - Preview data from mounted fieldtype components + * @param {Object} params.config - Field configuration with fields array + * @param {Object} params.values - Current field values + * @param {boolean} params.showFieldPreviews - Whether to show field previews by default + * @param {string} params.separator - Separator string to use between preview values + * @returns {string} - The formatted preview text + */ +export function buildPreviewText({ + previews, + config, + values, + showFieldPreviews, + separator, +}) { + const hasMountedPreviews = Object.keys(previews).length > 0; + + let previewValues; + + if (hasMountedPreviews) { + // Use previews from mounted fieldtype components + previewValues = Object.entries(previews) + .filter(([handle, value]) => { + if (!handle.endsWith('_')) return false; + handle = handle.slice(0, -1); // Remove the trailing underscore + const fieldConfig = config.fields?.find((f) => f.handle === handle); + if (!fieldConfig) return false; + return fieldConfig.replicator_preview === undefined ? showFieldPreviews : fieldConfig.replicator_preview; + }) + .map(([handle, value]) => value) + .filter((value) => { + if (value == null || value === '') return false; + if (typeof value === 'object' && !(value instanceof PreviewHtml) && !Array.isArray(value)) { + return false; + } + return true; + }) + .map((value) => { + if (value instanceof PreviewHtml) return value.html; + if (typeof value === 'string') return escapeHtml(value); + if (Array.isArray(value)) return escapeHtml(value.join(', ')); + return escapeHtml(String(value)); + }) + .filter((html) => html && html.trim() !== ''); + } else { + // Fallback: extract values directly from values + const fields = config.fields || []; + previewValues = fields + .filter((field) => { + const shouldShow = field.replicator_preview === undefined ? showFieldPreviews : field.replicator_preview; + if (!shouldShow) return false; + const value = values?.[field.handle]; + if (value == null || value === '') return false; + if (Array.isArray(value)) return value.length > 0; + if (typeof value === 'object') return Object.keys(value).length > 0; + return true; + }) + .map((field) => formatPreviewValue(values?.[field.handle], field, { escape: true })) + .filter((value) => value && value.trim() !== ''); + } + + return previewValues.join(separator); +} + +export default buildPreviewText; diff --git a/resources/js/util/createMountScheduler.js b/resources/js/util/createMountScheduler.js new file mode 100644 index 00000000000..2554fcf0937 --- /dev/null +++ b/resources/js/util/createMountScheduler.js @@ -0,0 +1,49 @@ +import { nextTick } from 'vue'; + +const DEFAULT_BUDGET_MS = 8; + +export function createMountScheduler({ budgetMs = DEFAULT_BUDGET_MS } = {}) { + const queue = []; + let flushing = false; + + const waitForIdle = () => new Promise((resolve) => { + if (typeof requestIdleCallback === 'function') { + requestIdleCallback(resolve, { timeout: 50 }); + } else { + requestAnimationFrame(() => resolve()); + } + }); + + function schedule(callback) { + queue.push(callback); + if (!flushing) flush(); + } + + async function flush() { + flushing = true; + try { + while (queue.length) { + let deadline; + deadline = await waitForIdle(); + + const frameStart = performance.now(); + const shouldYield = () => { + if (deadline && typeof deadline.timeRemaining === 'function' && !deadline.didTimeout) { + return deadline.timeRemaining() < 1; + } + return performance.now() - frameStart >= budgetMs; + }; + + while (queue.length && !shouldYield()) { + const cb = queue.shift(); + try { cb?.(); } catch (e) { console.error(e); } + try { await nextTick(); } catch (e) { console.error(e); } + } + } + } finally { + flushing = false; + } + } + + return { schedule }; +} diff --git a/resources/js/util/extractBardText.js b/resources/js/util/extractBardText.js new file mode 100644 index 00000000000..3a3f0e6b29d --- /dev/null +++ b/resources/js/util/extractBardText.js @@ -0,0 +1,23 @@ +export default function extractBardText( + prosemirrorNodes, + limit = 150, + setConfigs = null, +) { + if (!Array.isArray(prosemirrorNodes)) return ""; + + const stack = [...prosemirrorNodes]; + let text = ""; + while (stack.length && text.length < limit) { + const node = stack.shift(); + if (node.type === 'text') { + text += ` ${node.text || ''}`; + } else if (node.type === 'set' && setConfigs) { + const handle = node.attrs?.values?.type; + const set = setConfigs.find((s) => s.handle === handle); + text += ` [${__(set ? set.display : handle)}]`; + } else { + if (node.content) stack.unshift(...node.content); + } + } + return text.trim(); +} diff --git a/resources/js/util/formatPreviewValue.js b/resources/js/util/formatPreviewValue.js new file mode 100644 index 00000000000..ee23331f3a8 --- /dev/null +++ b/resources/js/util/formatPreviewValue.js @@ -0,0 +1,247 @@ +import PreviewHtml from '@/components/fieldtypes/replicator/PreviewHtml.js'; +import extractBardText from '@/util/extractBardText'; +import { escapeHtml } from '@/bootstrap/globals.js'; + +/** + * Normalize field options to a consistent format for lookup. + * Handles array-of-strings, array-of-objects {value, label} or {key, value}, and plain objects. + * + * @param {Array|Object} options - The options configuration + * @returns {Array} - Array of {value, label} objects + */ +function resolveOptions(options) { + if (!options) return []; + + // Plain object: {key: value, key2: value2} + if (!Array.isArray(options)) { + return Object.entries(options).map(([key, val]) => ({ + value: key, + label: val, + })); + } + + // Array of strings: ['option1', 'option2'] + if (options.length > 0 && typeof options[0] === 'string') { + return options.map((opt) => ({ + value: opt, + label: opt, + })); + } + + // Array of objects - normalize key/value to value/label + return options.map((opt) => { + if (typeof opt === 'object' && opt !== null) { + return { + value: opt.value !== undefined ? opt.value : opt.key, + label: opt.label !== undefined ? opt.label : opt.value, + }; + } + return { value: opt, label: opt }; + }); +} + +/** + * Resolve option label(s) for a given value. + * + * @param {*} value - The selected value(s) + * @param {Object} fieldConfig - The field configuration + * @returns {string|null} - The resolved label(s) or null + */ +function resolveOptionLabel(value, fieldConfig) { + const options = resolveOptions(fieldConfig.options); + if (options.length === 0) return null; + + const findLabel = (val) => { + const option = options.find((opt) => opt.value === val); + return option ? option.label : val; + }; + + if (Array.isArray(value)) { + if (value.length === 0) return null; + return value.map(findLabel).join(', '); + } + + return findLabel(value); +} + +/** + * Truncate a string to a maximum length. + * + * @param {string} str - The string to truncate + * @param {number} maxLength - Maximum length + * @returns {string} - Truncated string + */ +function truncate(str, maxLength) { + if (!str || str.length <= maxLength) return str; + return str.slice(0, maxLength) + '...'; +} + +/** + * Format a preview value for display in replicator sets. + * + * @param {*} value - The value to format + * @param {Object} fieldConfig - The field configuration + * @param {Object} options - Options for formatting + * @param {boolean} options.escape - Whether to escape HTML in the output (default: false) + * @returns {string|null} - The formatted preview value, or null if the value should be skipped + */ +export default function formatPreviewValue(value, fieldConfig, options = {}) { + const { escape = false } = options; + + if (value == null || value === '') return null; + + // Handle PreviewHtml instances + if (value instanceof PreviewHtml) { + return value.html; + } + + const type = fieldConfig?.type; + + // Type-specific handling (ordered before generic fallbacks) + + // Toggle: ✓ Field Label / ✗ Field Label + if (type === 'toggle') { + const display = fieldConfig.display || 'Toggle'; + const prefix = value ? '✓' : '✗'; + const result = display ? `${prefix} ${display}` : prefix; + return escape ? escapeHtml(result) : result; + } + + // Select, Radio, Button Group: resolved option label + if (type === 'select' || type === 'radio' || type === 'button_group') { + const label = resolveOptionLabel(value, fieldConfig); + if (!label) return null; + return escape ? escapeHtml(label) : label; + } + + // Checkboxes: labels joined by ', ' + if (type === 'checkboxes') { + const label = resolveOptionLabel(value, fieldConfig); + if (!label) return null; + return escape ? escapeHtml(label) : label; + } + + // Dictionary: same as select (uses config.options with loaded meta) + if (type === 'dictionary') { + const label = resolveOptionLabel(value, fieldConfig); + if (!label) return null; + return escape ? escapeHtml(label) : label; + } + + // Replicator: Display: N set(s) + if (type === 'replicator') { + const display = fieldConfig.display || 'Replicator'; + const count = Array.isArray(value) ? value.length : 0; + const result = `${display}: ${count} ${count === 1 ? 'Set' : 'Sets'}`; + return escape ? escapeHtml(result) : result; + } + + // Grid: Display: N row(s) + if (type === 'grid') { + const display = fieldConfig.display || 'Grid'; + const count = Array.isArray(value) ? value.length : 0; + const result = `${display}: ${count} ${count === 1 ? 'Row' : 'Rows'}`; + return escape ? escapeHtml(result) : result; + } + + // Assets: simplified checkmark + if (type === 'assets') { + const hasAssets = Array.isArray(value) && value.length > 0; + const result = hasAssets ? '✓' : '✗'; + return escape ? escapeHtml(result) : result; + } + + // Color: show hex string + if (type === 'color') { + const colorValue = typeof value === 'string' ? value : value?.hex || value?.color; + if (!colorValue) return null; + return escape ? escapeHtml(String(colorValue)) : String(colorValue); + } + + // Code: truncate code content + if (type === 'code') { + const codeValue = typeof value === 'string' ? value : value?.code; + if (!codeValue) return null; + const truncated = truncate(codeValue, 60); + return escape ? escapeHtml(truncated) : truncated; + } + + // Table: joined cell values + if (type === 'table') { + if (!Array.isArray(value)) return null; + const rows = value + .map((row) => { + if (!row || !Array.isArray(row.cells)) return ''; + return row.cells.filter(Boolean).join(', '); + }) + .filter(Boolean); + if (rows.length === 0) return null; + const result = rows.join(', '); + return escape ? escapeHtml(result) : result; + } + + // Array: key: value pairs joined + if (type === 'array') { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return null; + const entries = Object.entries(value) + .map(([k, v]) => `${k}: ${v}`) + .join(', '); + if (!entries) return null; + return escape ? escapeHtml(entries) : entries; + } + + // Entries / Terms / Users: count only + if (type === 'entries' || type === 'terms' || type === 'users') { + const count = Array.isArray(value) ? value.length : 0; + const result = `${count} ${count === 1 ? 'Item' : 'Items'}`; + return escape ? escapeHtml(result) : result; + } + + // Link: show raw string value (usually a URL) + if (type === 'link') { + const linkValue = typeof value === 'string' ? value : value?.url || value?.permalink; + if (!linkValue) return null; + return escape ? escapeHtml(String(linkValue)) : String(linkValue); + } + + // Revealer: always hidden + if (type === 'revealer') { + return null; + } + + // Bard: Display: N block(s) + if (type === 'bard' && Array.isArray(value)) { + const display = fieldConfig.display || 'Content'; + const count = value.length; + const result = `${display}: ${count} ${count === 1 ? 'Block' : 'Blocks'}`; + return escape ? escapeHtml(result) : result; + } + + // Markdown: pass through as-is (markdown is human-readable) + if (type === 'markdown') { + const mdValue = typeof value === 'string' ? value : null; + if (!mdValue) return null; + return escape ? escapeHtml(mdValue) : mdValue; + } + + // Handle array of strings (e.g., select, tags) - fallback for non-typed arrays + if ( + Array.isArray(value) && + value.length > 0 && + typeof value[0] === 'string' + ) { + const joined = value.join(', '); + return escape ? escapeHtml(joined) : joined; + } + + // Skip complex objects/arrays that would show as [object Object] or JSON + if ( + Array.isArray(value) || + (typeof value === 'object' && !(value instanceof PreviewHtml)) + ) { + return null; + } + + const stringValue = String(value); + return escape ? escapeHtml(stringValue) : stringValue; +}