diff --git a/.gitignore b/.gitignore index 714d6b65..d3c5b15e 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ pnpm-debug.log* lerna-debug.log* node_modules +todo dist dist-ssr *.local @@ -28,7 +29,4 @@ dist-ssr .playwright-cli .vitest-attachments -todo - -# vitest-browser snapshot output — regenerated on every run, never check in. -__screenshots__/ \ No newline at end of file +todo \ No newline at end of file diff --git a/.yarnrc.yml b/.yarnrc.yml index 3f8de620..a1ceb033 100644 --- a/.yarnrc.yml +++ b/.yarnrc.yml @@ -8,3 +8,5 @@ enableGlobalCache: false enableScripts: true nodeLinker: node-modules + +npmMinimalAgeGate: 0 diff --git a/package.json b/package.json index 63489f43..4e584db0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@anzusystems/common-admin", - "packageManager": "yarn@4.14.1", + "packageManager": "yarn@4.17.1", "files": [ "dist", "src/eslint" @@ -20,7 +20,7 @@ "./eslint": "./src/eslint/plugin.mjs", "./*": "./*" }, - "version": "1.47.0-beta.362", + "version": "1.47.0-beta.dev-1784059870", "license": "Apache-2.0", "repository": { "type": "git", @@ -42,7 +42,7 @@ "lint": "[ \"$*\" = \"--fix\" ] && yarn lint:fix || yarn ci", "lint:fix": "run-s --print-name lint:tsc format lint:oxlint:fix lint:eslint:fix lint:stylelint:fix", "lint:tsc": "NODE_OPTIONS=--max-old-space-size=4096 vue-tsc --build", - "lint:tsc:test": "NODE_OPTIONS=--max-old-space-size=4096 tsc --build tsconfig.test.json", + "lint:tsc:test": "NODE_OPTIONS=--max-old-space-size=4096 vue-tsc --build tsconfig.test.json", "lint:eslint": "eslint", "lint:eslint:fix": "eslint --fix", "lint:oxlint": "oxlint .", @@ -57,65 +57,68 @@ }, "devDependencies": { "@anzusystems/common-admin": "workspace:*", - "@intlify/unplugin-vue-i18n": "^11.1.2", + "@intlify/unplugin-vue-i18n": "^11.2.4", "@kyvg/vue3-notification": "^3.4.2", "@mdi/font": "^7.4.47", - "@sentry/vue": "^10.50.0", - "@shikijs/vitepress-twoslash": "^4.0.2", + "@microsoft/api-extractor": "^7.58.12", + "@sentry/vue": "^10.68.0", + "@shikijs/vitepress-twoslash": "^4.3.1", "@stylistic/eslint-plugin": "^5.10.0", "@tsconfig/node22": "^22.0.5", - "@types/node": "^24.12.2", + "@types/node": "^24.13.3", "@types/rusha": "^0.8.3", "@types/sortablejs": "^1.15.9", "@types/webfontloader": "^1.6.38", - "@vitejs/plugin-vue": "^6.0.6", - "@vitest/browser": "^4.1.5", - "@vitest/browser-playwright": "^4.1.5", - "@vitest/ui": "^4.1.5", - "@vue/eslint-config-typescript": "^14.7.0", - "@vue/language-server": "3.2.7", - "@vue/test-utils": "^2.4.9", + "@vitejs/plugin-vue": "^6.0.8", + "@vitest/browser": "^4.1.10", + "@vitest/browser-playwright": "^4.1.10", + "@vitest/ui": "^4.1.10", + "@vue/compiler-dom": "3.5.40", + "@vue/devtools-api": "^8.2.1", + "@vue/eslint-config-typescript": "^14.9.0", + "@vue/language-server": "3.3.8", + "@vue/test-utils": "^2.4.11", "@vue/tsconfig": "0.9.1", "@vuelidate/core": "^2.0.3", "@vuelidate/validators": "^2.0.4", - "@vueuse/core": "^14.2.1", - "@vueuse/integrations": "^14.2.1", - "axios": "1.15.2", + "@vueuse/core": "14.3.0", + "@vueuse/integrations": "14.3.0", + "axios": "1.18.1", "cropperjs": "^1.6.2", - "dayjs": "1.11.20", - "eslint": "^10.2.1", - "eslint-plugin-oxlint": "1.62.0", - "eslint-plugin-vue": "10.9.0", + "dayjs": "1.11.21", + "eslint": "^10.8.0", + "eslint-plugin-oxlint": "1.75.0", + "eslint-plugin-vue": "10.10.0", "eslint-plugin-vuetify": "^2.7.2", - "npm-run-all2": "^8.0.4", - "oxfmt": "^0.47.0", - "oxlint": "1.62.0", - "pinia": "3.0.4", - "playwright": "^1.59.1", - "postcss": "^8.5.12", + "npm-run-all2": "^9.0.2", + "oxfmt": "^0.60.0", + "oxlint": "1.75.0", + "pinia": "4.0.2", + "playwright": "^1.62.0", + "postcss": "^8.5.23", "postcss-html": "^1.8.1", "postcss-prefix-selector": "^2.1.1", "rusha": "^0.8.14", - "sass": "1.99.0", + "sass": "1.102.0", "socket.io-client": "4.8.3", "sortablejs": "^1.15.7", - "stylelint": "17.9.1", + "stylelint": "17.14.1", "stylelint-config-recommended-vue": "^1.6.1", "stylelint-config-standard-scss": "^17.0.0", "typescript": "5.9.3", - "unplugin": "3.0.0", - "uuid": "^14.0.0", - "vite": "7.3.2", - "vite-plugin-dts": "4.5.4", + "unplugin": "3.3.0", + "unplugin-dts": "^1.0.3", + "uuid": "^14.0.1", + "vite": "7.3.6", "vite-plugin-vuetify": "^2.1.3", "vitepress": "1.6.4", - "vitest": "^4.1.5", - "vue": "3.5.33", - "vue-eslint-parser": "^10.4.0", - "vue-i18n": "11.4.0", - "vue-router": "5.0.6", - "vue-tsc": "3.2.7", - "vuetify": "4.0.6", + "vitest": "^4.1.10", + "vue": "3.5.40", + "vue-eslint-parser": "^10.4.1", + "vue-i18n": "11.4.8", + "vue-router": "5.2.0", + "vue-tsc": "3.3.8", + "vuetify": "4.1.6", "webfontloader": "^1.6.28" }, "peerDependencies": { @@ -126,7 +129,7 @@ "@vueuse/integrations": "^14.1.0", "axios": "^1.13.4", "dayjs": "^1.11.19", - "pinia": "^3.0.4", + "pinia": "^4.0.2", "socket.io-client": "^4.8.3", "unplugin": "^3.0.0", "vue": "^3.5.27", diff --git a/src/components/buttons/action/AActionDeleteButton.vue b/src/components/buttons/action/AActionDeleteButton.vue index dcf113a2..84e9ddda 100644 --- a/src/components/buttons/action/AActionDeleteButton.vue +++ b/src/components/buttons/action/AActionDeleteButton.vue @@ -22,6 +22,9 @@ const props = withDefaults( loading?: boolean color?: string | undefined size?: number + // When set, deleting acknowledges the unsaved-changes leave guard first, so the delete's follow-up + // navigation is not blocked by a now-meaningless "unsaved changes, really leave?" prompt. (BUG-08) + guard?: { acknowledge: () => void } }>(), { variant: 'icon', @@ -38,6 +41,7 @@ const props = withDefaults( disableCloseAfterConfirm: false, color: undefined, size: 36, + guard: undefined, }, ) const emit = defineEmits<{ @@ -51,6 +55,7 @@ const onClick = (event: Event) => { dialog.value = true } const onConfirm = () => { + props.guard?.acknowledge() emit('deleteRecord') if (!props.disableCloseAfterConfirm) closeDialog() } diff --git a/src/components/collab/composables/collabInit.ts b/src/components/collab/composables/collabInit.ts index 51cae642..5876b95b 100644 --- a/src/components/collab/composables/collabInit.ts +++ b/src/components/collab/composables/collabInit.ts @@ -131,6 +131,9 @@ export function useCollabInit() { ) collabSocket.value.on('connect', async () => { collabRoomInfoState.clear() + // Without the reset a transient network error burns the flag and a later JWT + // expiration never triggers a token refresh. + authorizationReconnectTriggered = false const connectedBefore = collabConnected.value collabConnected.value = collabSocket.value?.connected ?? false if (!connectedBefore) { @@ -146,7 +149,12 @@ export function useCollabInit() { return } collabConnected.value = collabSocket.value?.connected ?? false - logError(error, { level: 'error', message: 'Collab connect_error' }) + // active === true means socket.io will reconnect on its own; only a permanent + // rejection is worth reporting. + if (collabSocket.value?.active) { + return + } + logError(error, { level: 'error', tags: { collabPhase: 'connectRejected' } }) }) collabSocket.value.on('disconnect', async (reason) => { collabRoomInfoState.forEach( diff --git a/src/components/createCachedChip.ts b/src/components/createCachedChip.ts new file mode 100644 index 00000000..dc1dc5ff --- /dev/null +++ b/src/components/createCachedChip.ts @@ -0,0 +1,66 @@ +import { type Component, defineComponent, h, type PropType } from 'vue' +import ACachedChip from '@/components/ACachedChip.vue' +import type { DocId, IntegerId } from '@/types/common' + +export type CachedChipId = null | undefined | IntegerId | DocId + +export interface CreateCachedChipOptions { + /** + * Composable returning the per-id cache getter — invoked inside the chip's + * setup so the backing cache store resolves in component scope. The `id` + * param is `any` to match `ACachedChip` and accept both numeric- and + * doc-id getters regardless of their exact parameter type. + */ + useGetCachedFn: () => (id: any) => unknown + /** Named route the chip links to (e.g. `'/(cms)/desks/[id]'`). */ + route: string + /** Dot-path into the cached entity for the chip label (e.g. `'name'`). */ + displayTextPath: string + /** Static props baked onto every instance (e.g. a fixed `textOnly`). */ + chipProps?: Record + /** Component name for devtools / warnings. */ + name?: string +} + +/** + * Builds a cached-entity chip from a domain's cache composable + route, so each + * per-domain `CachedXChip.vue` collapses to a single factory call instead of a + * full SFC re-binding `ACachedChip`. Caller-passed attributes (`size`, `color`, + * `disable-click`, …) and slots fall through to `ACachedChip`. + * + * Use in an SFC's plain ` + + diff --git a/src/components/damImage/uploadQueue/composables/damConfigState.ts b/src/components/damImage/uploadQueue/composables/damConfigState.ts index 7eaa71c8..7fcc8da6 100644 --- a/src/components/damImage/uploadQueue/composables/damConfigState.ts +++ b/src/components/damImage/uploadQueue/composables/damConfigState.ts @@ -149,15 +149,31 @@ export function useDamConfigState(client: undefined | (() => AxiosInstance) = un } const promises = types.map((type) => fetchAssetCustomFormElements(client, extSystemId, type)) - Promise.all(promises) - .then((responses) => { - if ( - responses.length !== types.length || - responses.some((response) => Object.keys(response).length < 1) - ) { + // allSettled: one unavailable type must not discard the successfully loaded ones. + Promise.allSettled(promises) + .then((results) => { + const loadedTypes: DamAssetTypeType[] = [] + const loadedResponses: Awaited<{ data: CustomDataFormElement[] }>[] = [] + const failedTypes: DamAssetTypeType[] = [] + + results.forEach((result, index) => { + if (result.status === 'fulfilled' && Object.keys(result.value).length > 0) { + loadedTypes.push(types[index]) + loadedResponses.push(result.value) + return + } + failedTypes.push(types[index]) + }) + + if (loadedTypes.length === 0 && types.length > 0) { throw new Error('Unable to load asset custom form config. Incorrect response body.') } - setDamConfigAssetCustomFormElements(responses, extSystemId, types) + setDamConfigAssetCustomFormElements(loadedResponses, extSystemId, loadedTypes) + if (failedTypes.length > 0) { + onConfigError( + new Error(`Unable to load asset custom form config for: ${failedTypes.join(', ')}.`), + ) + } resolve(true) }) .catch((err) => { diff --git a/src/components/file/AFileDropzone.vue b/src/components/file/AFileDropzone.vue index cdfbbb51..108fb590 100644 --- a/src/components/file/AFileDropzone.vue +++ b/src/components/file/AFileDropzone.vue @@ -106,11 +106,6 @@ $class-name-root: 'a-file-dropzone'; border-radius: 5px; } - &--fill { - position: absolute !important; - inset: 0; - } - &--bg { width: 100%; } @@ -123,6 +118,12 @@ $class-name-root: 'a-file-dropzone'; &--small { min-height: 70px; + + .text { + font-size: 0.75rem; + line-height: 1.2; + padding: 4px; + } } &--default { @@ -133,6 +134,12 @@ $class-name-root: 'a-file-dropzone'; min-height: 210px; } + &--fill { + position: absolute !important; + inset: 0; + min-height: 40px; + } + &--hover-only { display: none; } diff --git a/src/components/form/AFormValueObjectOptionsSelect.vue b/src/components/form/AFormValueObjectOptionsSelect.vue index e4e886dc..82382252 100644 --- a/src/components/form/AFormValueObjectOptionsSelect.vue +++ b/src/components/form/AFormValueObjectOptionsSelect.vue @@ -2,7 +2,7 @@ import { computed, inject, ref, watch } from 'vue' import { stringSplitOnFirstOccurrence } from '@/utils/string' import type { ErrorObject } from '@vuelidate/core' -import { cloneDeep, isDefined, isUndefined } from '@/utils/common' +import { cloneDeep, isDefined, isNull, isUndefined } from '@/utils/common' import { SubjectScopeSymbol, SystemScopeSymbol } from '@/components/injectionKeys' import { useI18n } from 'vue-i18n' import ACollabLockedByUser from '@/components/collab/components/ACollabLockedByUser.vue' @@ -26,6 +26,7 @@ const props = withDefaults( dataCy?: string collab?: CollabComponentConfig disabled?: boolean + readonly?: boolean }>(), { label: undefined, @@ -39,6 +40,7 @@ const props = withDefaults( dataCy: '', collab: undefined, disabled: undefined, + readonly: undefined, }, ) const emit = defineEmits<{ @@ -52,6 +54,8 @@ const modelValue = computed({ return props.modelValue }, set(newValue) { + // VAutocomplete drops the selection when its search text is emptied, emitting null even when not clearable + if (!props.clearable && !props.multiple && isNull(newValue) && !isNull(props.modelValue)) return emit('update:modelValue', cloneDeep(newValue)) }, }) @@ -149,6 +153,7 @@ watch( item-value="value" :multiple="multipleComputedVuetifyTypeFix" :disabled="disabledComputed" + :readonly="readonly" :clearable="clearable" :error-messages="errorMessageComputed" :data-cy="dataCy" diff --git a/src/composables/system/defineCached.ts b/src/composables/system/defineCached.ts index f424e69e..ee4c1f2a 100644 --- a/src/composables/system/defineCached.ts +++ b/src/composables/system/defineCached.ts @@ -2,8 +2,13 @@ import { type Ref, ref } from 'vue' import { useDebounceFn } from '@vueuse/core' import type { DocId, IntegerId } from '@/types/common' import { isArray, isNull, isUndefined } from '@/utils/common' +import { useSentry } from '@/services/sentry' -export type CachedItem = T & { _loaded: boolean } +/** + * `_unresolved` is terminal: the fetch settled without resolving the item, either because + * it failed or because the server did not return it. + */ +export type CachedItem = T & { _loaded: boolean; _unresolved?: boolean } export type AddToCachedArgs = | Array @@ -27,6 +32,13 @@ export function defineCached< ) { const cache: Ref>> = ref(new Map()) const toFetch = ref(new Set()) as Ref> + const { logError } = useSentry() + + /** Unresolved items are re-queued on the next `add()`, never retried from the queue itself. */ + const isCached = (id: I) => { + const item = cache.value.get(id) + return !isUndefined(item) && item._unresolved !== true + } const add = (...args: AddToCachedArgs) => { const toAdd = >new Set() @@ -37,12 +49,14 @@ export function defineCached< for (let j = 0; j < arg.length; j++) { const item = arg[j] if (isNull(item) || isUndefined(item)) continue - if (!cache.value.has(item)) toAdd.add(item) + if (!isCached(item)) toAdd.add(item) } continue } - if (!cache.value.has(arg)) toAdd.add(arg) + if (!isCached(arg)) toAdd.add(arg) } + if (toAdd.size === 0) return + prune(toAdd) toAdd.forEach((id) => { cache.value.set(id, { ...mapIdToMinimal(id), @@ -70,14 +84,32 @@ export function defineCached< } } - const updateMap = (data: T[]) => { - if (cache.value.size >= maxLimit) { - for (const [key, value] of cache.value) { - if (value._loaded) { - cache.value.delete(key) - } + /** + * Drops only as many oldest terminal entries as the overflow requires; dropping every one + * of them would send currently rendered chips back to their loading state. + * Must include `_unresolved`, otherwise repeated failures grow the cache past `maxLimit`. + */ + const prune = (protectedIds?: Set) => { + let incoming = 0 + if (protectedIds) { + // Re-queued unresolved ids are already in the cache and do not grow it. + for (const id of protectedIds) if (!cache.value.has(id)) incoming += 1 + } + const overflow = cache.value.size + incoming - maxLimit + if (overflow <= 0) return + let removed = 0 + for (const [key, value] of cache.value) { + if (removed >= overflow) break + if (protectedIds?.has(key)) continue + if (value._loaded || value._unresolved) { + cache.value.delete(key) + removed += 1 } } + } + + const updateMap = (data: T[]) => { + prune() for (let i = 0; i < data.length; i += 1) { cache.value.set(data[i][idProp] as I, { ...mapFullToMinimal(data[i]), @@ -92,20 +124,42 @@ export function defineCached< } } + const markUnresolved = (ids: Array) => { + for (let i = 0; i < ids.length; i += 1) { + const item = cache.value.get(ids[i]) + if (isUndefined(item) || item._loaded) continue + cache.value.set(ids[i], { ...item, _unresolved: true }) + } + } + async function apiFetch() { - if (toFetch.value.size > 0) { - const ids = Array.from(toFetch.value) + if (toFetch.value.size === 0) return [] + const ids = Array.from(toFetch.value) + try { const res = await fetchCallback(ids) - updateToFetch(ids) updateMap(res) return res + } finally { + // Always drain, otherwise a failing batch is resent on every subsequent fetch(). + updateToFetch(ids) + markUnresolved(ids) } - return [] } + // Caught inside the debounced callback, not on the promise it returns: the maxWait timer + // is a second invocation path whose promise never reaches the caller, so a rejection + // there would escape as an unhandled rejection. const debouncedFetch = useDebounceFn( async () => { - return await apiFetch() + try { + return await apiFetch() + } catch (error: unknown) { + logError(error instanceof Error ? error : new Error(String(error)), { + level: 'warning', + tags: { cachedFetch: 'failed' }, + }) + return [] as T[] + } }, 1500, { maxWait: 5000 }, @@ -114,6 +168,8 @@ export function defineCached< /** * Debounced fetch for best performance. * For general usage. + * + * Called fire-and-forget, so it never rejects. Use `immediateFetch()` to handle errors. */ const fetch = () => { return debouncedFetch() @@ -148,6 +204,12 @@ export function defineCached< return item._loaded } + /** Terminal counterpart of `isLoaded`: the fetch settled without resolving the item. */ + const isUnresolved = (id: I | null | undefined): boolean => { + if (!id) return false + return cache.value.get(id)?._unresolved === true + } + return { cache, toFetch, @@ -160,5 +222,6 @@ export function defineCached< get, clear, isLoaded, + isUnresolved, } } diff --git a/src/composables/system/useCachedItem.ts b/src/composables/system/useCachedItem.ts index 5969422a..a0f2318a 100644 --- a/src/composables/system/useCachedItem.ts +++ b/src/composables/system/useCachedItem.ts @@ -1,22 +1,15 @@ -import { type ShallowRef, nextTick, shallowRef, watch } from 'vue' -import { isUndefined } from '@/utils/common' +import { type ComputedRef, computed } from 'vue' -export function useCachedItem( +export function useCachedItem( getter: () => T | undefined, -): { cached: ShallowRef; loaded: ShallowRef } { - const cached = shallowRef(undefined) as ShallowRef - const loaded = shallowRef(false) - - const stopWatch = watch( - getter, - (newValue) => { - if (isUndefined(newValue) || newValue._loaded === false) return - cached.value = newValue - loaded.value = true - nextTick(() => stopWatch()) - }, - { immediate: true }, - ) +): { cached: ComputedRef; loaded: ComputedRef } { + const cached = computed(() => { + const value = getter() + if (!value) return undefined + // `_unresolved` is terminal: return the placeholder so consumers stop showing a spinner. + return value._loaded !== false || value._unresolved === true ? value : undefined + }) + const loaded = computed(() => cached.value !== undefined) return { cached, loaded } } diff --git a/src/labs.ts b/src/labs.ts index 15a9439c..5694200d 100644 --- a/src/labs.ts +++ b/src/labs.ts @@ -3,12 +3,15 @@ import AFilterDatetimePicker from '@/labs/filters/AFilterDatetimePicker.vue' import AFilterInteger from '@/labs/filters/AFilterInteger.vue' import AFilterRemoteAutocomplete from '@/labs/filters/AFilterRemoteAutocomplete.vue' import AFormRemoteAutocomplete from '@/labs/form/AFormRemoteAutocomplete.vue' +import AFormRemoteAutocompleteWithCached from '@/labs/form/AFormRemoteAutocompleteWithCached.vue' import AFilterRemoteAutocompleteWithMinimal from '@/labs/filters/AFilterRemoteAutocompleteWithMinimal.vue' import AFilterString from '@/labs/filters/AFilterString.vue' import AFilterTimeInterval from '@/labs/filters/AFilterTimeInterval.vue' import AFilterValueObjectOptionsSelect from '@/labs/filters/AFilterValueObjectOptionsSelect.vue' import AFilterWrapper from '@/labs/filters/AFilterWrapper.vue' +import AFilterWrapperSidebar from '@/labs/filters/AFilterWrapperSidebar.vue' import AFilterWrapperSubjectSelect from '@/labs/subjectSelect/AFilterWrapperSubjectSelect.vue' +import FiltersSelected from '@/labs/filters/FiltersSelected.vue' import ADatatableOrdering from '@/labs/filters/ADatatableOrdering.vue' import ADatatablePagination from '@/labs/filters/ADatatablePagination.vue' import { useApiFetchByIds } from '@/labs/api/useApiFetchByIds' @@ -47,15 +50,43 @@ import ASortableListEditor from '@/labs/listEditor/ASortableListEditor.vue' import ANestedSortableListEditor from '@/labs/listEditor/ANestedSortableListEditor.vue' import AUnsavedConfirmDialog from '@/labs/unsavedGuard/AUnsavedConfirmDialog.vue' import { useUnsavedChangesGuard } from '@/labs/unsavedGuard/useUnsavedChangesGuard' +import { useGuardedDelete } from '@/labs/unsavedGuard/useGuardedDelete' import { - useListEditor, - type ListEditorApi, -} from '@/labs/listEditor/composables/useListEditor' + useUnsavedSection, + type UnsavedSectionDescriptor, + type UnsavedSectionSource, +} from '@/labs/unsavedGuard/useUnsavedSection' +import { useListEditor, type ListEditorApi } from '@/labs/listEditor/composables/useListEditor' +import { + useListEditorController, + type ListEditorHandle, + type ExposedListEditorHandle, + type UseListEditorControllerOptions, + type ListEditorChanges, + type ListEditorValidationResult, + type GetKey, + type PositionOption, + type PositionStrategy, + type PositionAction, +} from '@/labs/listEditor/composables/useListEditorController' import { - useListEditorItemValidation, - ListEditorValidationKey, - type ListEditorValidationRegistry, -} from '@/labs/listEditor/composables/useListEditorItemValidation' + createListEditorStateScope, + provideListEditorStateScope, + useListEditorStateEntry, + useNestedListEditorStateEntry, + ListEditorStateScopeKey, + type ListEditorStateScope, + type ListEditorStateBindings, + type NestedListEditorStateBindings, + type ListEditorStateEntry, +} from '@/labs/listEditor/composables/useListEditorStateScope' +import { + renumberPositions, + sortByPosition, + sortByPositionDeep, + type RenumberPositionsOptions, +} from '@/labs/listEditor/utils/positions' +import { nextListEditorTempId } from '@/labs/listEditor/utils/tempId' import { useNestedUnsavedKeys, type UseNestedUnsavedKeysApi, @@ -70,6 +101,13 @@ import { type NestedListEditorApi, type NestedViewItem, } from '@/labs/listEditor/composables/useNestedListEditor' +import { + useNestedListEditorController, + type NestedListEditorHandle, + type ExposedNestedListEditorHandle, + type UseNestedListEditorControllerOptions, + type NestedListEditorChanges, +} from '@/labs/listEditor/composables/useNestedListEditorController' import type { ListEditorKey, ListEditorValidationState, @@ -99,7 +137,9 @@ import { export { // V2 FILTERS AFilterWrapper, + AFilterWrapperSidebar, AFilterWrapperSubjectSelect, + FiltersSelected, AFilterBooleanSelect, AFilterDatetimePicker, AFilterInteger, @@ -116,6 +156,7 @@ export { ADatatablePagination, DatatablePaginationKey, AFormRemoteAutocomplete, + AFormRemoteAutocompleteWithCached, createFilter, createFilterStore, useFilterHelpers, @@ -141,10 +182,35 @@ export { ANestedSortableListEditor, AUnsavedConfirmDialog, useUnsavedChangesGuard, + useGuardedDelete, + useUnsavedSection, + type UnsavedSectionDescriptor, + type UnsavedSectionSource, useListEditor, - useListEditorItemValidation, - ListEditorValidationKey, - type ListEditorValidationRegistry, + useListEditorController, + type ListEditorHandle, + type ExposedListEditorHandle, + type UseListEditorControllerOptions, + type ListEditorChanges, + type ListEditorValidationResult, + type GetKey, + type PositionOption, + type PositionStrategy, + type PositionAction, + createListEditorStateScope, + provideListEditorStateScope, + useListEditorStateEntry, + useNestedListEditorStateEntry, + ListEditorStateScopeKey, + type ListEditorStateScope, + type ListEditorStateBindings, + type NestedListEditorStateBindings, + type ListEditorStateEntry, + renumberPositions, + sortByPosition, + sortByPositionDeep, + type RenumberPositionsOptions, + nextListEditorTempId, useNestedUnsavedKeys, type UseNestedUnsavedKeysApi, type ReorderModeValue, @@ -154,6 +220,11 @@ export { useNestedListEditor, type NestedListEditorApi, type NestedViewItem, + useNestedListEditorController, + type NestedListEditorHandle, + type ExposedNestedListEditorHandle, + type UseNestedListEditorControllerOptions, + type NestedListEditorChanges, type ListEditorKey, type ListEditorValidationState, type ListViewItem, diff --git a/src/labs/filters/AFilterRemoteAutocomplete.vue b/src/labs/filters/AFilterRemoteAutocomplete.vue index d155ca40..809d519d 100644 --- a/src/labs/filters/AFilterRemoteAutocomplete.vue +++ b/src/labs/filters/AFilterRemoteAutocomplete.vue @@ -197,6 +197,8 @@ const tryAutoFetch = async (mode: 'focus' | 'hover' | 'mounted') => { fetchedItems.value = res } prefetchCompleted.value = true + } catch (e) { + showErrorsDefault(e) } finally { loading.value = false } diff --git a/src/labs/filters/AFilterWrapperSidebar.vue b/src/labs/filters/AFilterWrapperSidebar.vue new file mode 100644 index 00000000..9cd6b349 --- /dev/null +++ b/src/labs/filters/AFilterWrapperSidebar.vue @@ -0,0 +1,60 @@ + + + diff --git a/src/labs/filters/FiltersSelected.vue b/src/labs/filters/FiltersSelected.vue index 9fccd4e0..b59b3e38 100644 --- a/src/labs/filters/FiltersSelected.vue +++ b/src/labs/filters/FiltersSelected.vue @@ -28,6 +28,7 @@ const getTitleFromConfig = (name: string) => { const selectedArray = computed(() => { const fieldOrder = Object.keys(filterConfig.fields) return Array.from(filterSelected.value) + .filter(([key]) => filterConfig.fields?.[key]?.render?.selected !== false) .map(([key, valueArray]) => ({ name: key, title: getTitleFromConfig(key), diff --git a/src/labs/filters/filterFactory.ts b/src/labs/filters/filterFactory.ts index ccbf12f7..e44a4037 100644 --- a/src/labs/filters/filterFactory.ts +++ b/src/labs/filters/filterFactory.ts @@ -24,6 +24,7 @@ const SORT_URL_PARAM = '_sort' const defaultRenderOptions: FilerRenderOptions = { skip: false, + selected: true, xs: undefined, sm: undefined, md: undefined, @@ -446,6 +447,9 @@ export interface GeneralFilterOptions { export interface FilerRenderOptions { skip: boolean + // Whether the field is shown as a selected-filter chip (FiltersSelected). Default true. + // Optional for backward compatibility (other admins may construct FilerRenderOptions). + selected?: boolean xs: number | undefined sm: number | undefined md: number | undefined diff --git a/src/labs/form/AFormRemoteAutocomplete.vue b/src/labs/form/AFormRemoteAutocomplete.vue index 9f323a50..a15f8a94 100644 --- a/src/labs/form/AFormRemoteAutocomplete.vue +++ b/src/labs/form/AFormRemoteAutocomplete.vue @@ -241,6 +241,9 @@ const loadListItems = async (ids: T[] | T) => { modelValueSelected.value = selectedNewValue modelValueAutocomplete.value = selectedNewValue return selectedItemsCache.value + } catch (e) { + // Mirror tryLoadModelValue: don't let a failed by-ids resolve become a generic global toast (QA 85050). + showErrorsDefault(e) } finally { loadingLocal.value = false } @@ -316,6 +319,8 @@ const tryAutoFetch = async ( } } prefetchCompleted.value = true + } catch (e) { + showErrorsDefault(e) } finally { loadingLocal.value = false } diff --git a/src/labs/listEditor/AListEditor.vue b/src/labs/listEditor/AListEditor.vue index 75dce1a1..e1099298 100644 --- a/src/labs/listEditor/AListEditor.vue +++ b/src/labs/listEditor/AListEditor.vue @@ -3,21 +3,34 @@ import { computed, ref, useSlots, useTemplateRef, watch } from 'vue' import { useI18n } from 'vue-i18n' import { useDisplay } from 'vuetify' import { useKeyboardNav } from '@/labs/listEditor/composables/useKeyboardNav' -import { useValidationRegistry } from '@/labs/listEditor/composables/useValidationRegistry' -import { useListEditor } from '@/labs/listEditor/composables/useListEditor' +import { + useListEditorController, + type GetKey, + type ListEditorHandle, + type ListEditorValidationResult, + type PositionAction, + type PositionOption, + type PositionStrategy, +} from '@/labs/listEditor/composables/useListEditorController' +import { + provideListEditorStateScope, + useListEditorStateEntry, + type ListEditorStateBindings, +} from '@/labs/listEditor/composables/useListEditorStateScope' import { resolveCompactText as resolveCompactTextUtil } from '@/labs/listEditor/composables/resolveCompactText' -import { useUnsavedKeysSync } from '@/labs/listEditor/composables/useUnsavedKeysSync' -import { useDirtyBaseline } from '@/labs/listEditor/composables/useDirtyBaseline' +import { useUnsavedSection } from '@/labs/unsavedGuard/useUnsavedSection' import { useDeleteDialog } from '@/labs/listEditor/composables/useDeleteDialog' import { useInlineEditing } from '@/labs/listEditor/composables/useInlineEditing' +import { validateAllAndReveal } from '@/labs/listEditor/utils/revealInvalidRows' +import { useListEditorScopeValidity } from '@/labs/listEditor/composables/useListEditorScopeValidity' import LeDeleteDialog from '@/labs/listEditor/internal/LeDeleteDialog.vue' import LeEmptyState from '@/labs/listEditor/internal/LeEmptyState.vue' import LeUnsavedLabel from '@/labs/listEditor/internal/LeUnsavedLabel.vue' import type { ListEditorKey, + ListEditorValidationScope, ListEditorValidationState, ListViewItem, - PositionHint, } from '@/labs/listEditor/types/listEditorTypes' export interface DecoratedViewItem extends ListViewItem { @@ -25,12 +38,11 @@ export interface DecoratedViewItem extends ListViewItem { expanded: boolean loading: boolean dirty: boolean + unsaved: boolean + validationState: ListEditorValidationState } -// Public slot scope shapes — mirror what `buildSlotProps(vi)` actually emits. -// Hoisted to the top-level so vite-plugin-dts can include them in the .d.ts; -// putting them after `defineProps`/`defineEmits` would emit `private name` -// errors during d.ts rollup. +// Hoisted for vite-plugin-dts d.ts rollup. export interface RowActions { edit: () => void save: () => Promise | void @@ -39,10 +51,10 @@ export interface RowActions { delete: () => Promise addAfter: () => void toggleExpand: () => void - update: (data: TItem) => TItem[] + update: (next: TItem | Partial | ((current: TItem) => TItem)) => void } export interface RowSlotProps> { - item: DecoratedViewItem & { validationState: ListEditorValidationState } + item: DecoratedViewItem raw: TItem index: number key: ListEditorKey @@ -51,13 +63,16 @@ export interface RowSlotProps> { expanded: boolean editing: boolean dirty: boolean - /** - * Aliases `dirty` on the flat editor (no reorder mode → no `moved` to OR - * with). Mirrors the field name on the sortable + nested variants so a - * consumer slot template can use the same name across all three editors. - */ + /** Aliases `dirty` here (no reorder mode → no `moved` to OR). Same field name as on sortable/nested variants. */ unsaved: boolean touch: boolean + /** + * Row-scoped `state-key` prefix. Thread it down to any list editor rendered in this slot and set + * its `:state-key="`${stateKeyPrefix}:someList`"` — that editor's controller then lives in THIS + * editor's scope and survives the row collapsing (which unmounts the whole slot subtree). Entries + * under this prefix are disposed when the row is removed. + */ + stateKeyPrefix: string actions: RowActions } export interface EmptySlotProps { @@ -76,10 +91,64 @@ export interface HeaderSlotProps { } export interface Props> { - keyField?: string - positionField?: string - positionMultiplier?: number - updatePosition?: boolean + /** + * New-row factory. Add / "add after" insert `factory()` through the controller + * (positions renumbered) — no consumer `@add` push handler is needed. + */ + factory?: () => TItem + /** + * Stable row identity. Default `'id'`; never the position field. Typed `string` + * not `keyof TItem` because the latter compiles to a Boolean-only runtime prop + * type that silently coerces `get-key="id"` to `true`. + */ + getKey?: string | ((item: TItem) => ListEditorKey) + /** + * Managed order field. Default `'position'`; `false` opts out. `string` must be + * listed before `false` (and not be `keyof TItem`): otherwise Vue's runtime + * type goes Boolean-first and coerces `position="position"` to `true`. + */ + position?: + | string + | false + | { + field: string + multiplier?: number + strategy?: PositionStrategy + strategyOverrides?: Partial> + } + /** + * Extra fields to drop from the dirty content-hash (position is always dropped). + * Use when a SEPARATE nested editor already tracks a child collection, so editing + * a child doesn't flip the parent row amber. Still saved; only the diff ignores it. + */ + dirtyExclude?: string[] + /** Per-row validity — `true` (or `{ valid: true }`) = VALID. Drives the red rail + save guard. */ + validate?: (item: TItem) => ListEditorValidationResult + /** + * Vuelidate `$scope` (the same one the consumer's row forms / save-gate collector use). When set, + * the editor registers its aggregate validity under it, so a plain `v$.$invalid` save gate blocks + * AND reveals a collapsed invalid row — no `validateAll()` call needed in the save flow. Omit to + * keep legacy behavior (gate the save on the exposed `validateAll()` yourself); `false` opts out. + */ + validationScope?: ListEditorValidationScope | false + /** + * Opt-in lifted controller from `useListEditorController()` — pass it so editor + * state survives this component's unmount/remount. Omitted: the editor owns one + * internally. Either way the `ListEditorHandle` is reachable via `useTemplateRef`. + */ + editor?: ListEditorHandle + /** + * Persist this editor's controller in the nearest ANCESTOR editor's row-state scope under this + * key, instead of owning it. Use it for an editor rendered in another editor's `#item` slot: that + * slot is behind a `v-if`, so collapsing the row unmounts this editor and a component-owned + * controller would re-baseline the already-edited data on re-expand (losing amber, tombstones and + * the red rail). Derive the key from the row slot's `stateKeyPrefix`, e.g. + * `:state-key="`${stateKeyPrefix}:adverts`"`. Options stay declared here, on this tag: the + * controller is built from THIS editor's `factory`/`get-key`/`position`/`dirty-exclude`/`validate` + * and rebound to each new instance's model on remount. Ignored when `:editor` is passed, and a + * no-op without an ancestor scope. + */ + stateKey?: string readonly?: boolean disabled?: boolean @@ -100,33 +169,51 @@ export interface Props> { addLabel?: string | null emptyTitle?: string | null - emptyText?: string | null disableRowClick?: boolean disableDeleteConfirm?: boolean + /** Disable unsaved-state tracking — no dirty markers, never reads as unsaved. */ + disableUnsaved?: boolean deleteConfirmTitle?: string | null deleteConfirmText?: string | null + /** + * How a row delete persists. `deferred` (default): the row disappears but the deletion counts as an + * unconfirmed change (`unsavedCount`) until save, and is revertible until then. `immediate`: the + * consumer deletes on the backend (`:on-delete`) — the dialog states it is irreversible and the + * delete does NOT read as unsaved. + */ + deleteMode?: 'immediate' | 'deferred' closeVariant?: 'auto' | 'icon' | 'labeled' - loadingKeys?: Set | null + /** + * Render every `#item` slot expanded — no edit pencil, inline footer, or row-click + * toggle. Use when all forms should be visible at once (e.g. ThirdPartyTracker). + */ + defaultExpanded?: boolean - getValidationState?: ( - item: TItem, - key: ListEditorKey, - index: number, - ) => ListEditorValidationState + loadingKeys?: Set | null onDeleteConfirm?: (item: TItem) => Promise | boolean onDelete?: (item: TItem) => Promise | void onItemSave?: (item: TItem) => Promise | void + + /** + * Registers this editor as a named unsaved-changes section under this (already + * translated) label — replaces a per-consumer `useUnsavedSection` call. + */ + unsavedSectionLabel?: string } const props = withDefaults(defineProps>(), { - keyField: 'id', - positionField: 'position', - positionMultiplier: 1, - updatePosition: false, + factory: undefined, + getKey: undefined, + position: undefined, + dirtyExclude: undefined, + validate: undefined, + validationScope: undefined, + editor: undefined, + stateKey: undefined, readonly: false, disabled: false, loading: false, @@ -142,21 +229,22 @@ const props = withDefaults(defineProps>(), { showAddAfterAction: false, addLabel: null, emptyTitle: null, - emptyText: null, disableRowClick: false, disableDeleteConfirm: false, + disableUnsaved: false, deleteConfirmTitle: null, deleteConfirmText: null, + deleteMode: 'deferred', closeVariant: 'auto', + defaultExpanded: false, loadingKeys: null, - getValidationState: undefined, onDeleteConfirm: undefined, onDelete: undefined, onItemSave: undefined, + unsavedSectionLabel: undefined, }) const emit = defineEmits<{ - add: [positionHint: PositionHint | undefined] edit: [item: ListViewItem] deleted: [item: ListViewItem] close: [item: ListViewItem] @@ -188,15 +276,108 @@ const rootEl = useTemplateRef('rootEl') const isTouch = computed(() => display.platform.value.touch) -// Options are captured once at setup; list-editor config is expected to be stable. -// eslint-disable-next-line vue/no-setup-props-reactivity-loss -const editor = useListEditor(modelValue, { - keyField: props.keyField, - positionField: props.positionField, - positionMultiplier: props.positionMultiplier, - updatePosition: props.updatePosition, +// State controller (v2). `factory`/`getKey`/`position`/`validate` are construction-time options read +// once to seed it (undefined → controller defaults 'id'/'position'/always-valid), not reactive props +// — so the reactivity-loss rule is intentionally suppressed here. +/* eslint-disable vue/no-setup-props-reactivity-loss */ +const controllerOptions: ListEditorStateBindings = { + get: () => modelValue.value, + set: (v) => (modelValue.value = v), + factory: props.factory, + getKey: props.getKey as GetKey | undefined, + position: props.position as PositionOption | undefined, + dirtyExclude: () => props.dirtyExclude ?? [], + validate: props.validate, +} +// Precedence: an explicitly lifted `:editor` wins; then a `state-key`'d entry persisted in the +// nearest ancestor editor's row-state scope (built from the options above, rebound to this instance +// on every remount); else this component owns the controller — today's behaviour, unchanged. +const stateEntry = props.editor + ? null + : useListEditorStateEntry(props.stateKey, controllerOptions) +const controller = + props.editor ?? stateEntry?.handle ?? useListEditorController(controllerOptions) + +// Mirror the controller's key resolution so rendered rows key the way it tracks them. +const getKeyOpt = props.getKey ?? 'id' +/* eslint-enable vue/no-setup-props-reactivity-loss */ + +// Row-state scope for THIS editor's `#item` slot subtree: a nested editor with a `state-key` builds +// its controller in here, so it outlives the row collapsing (which unmounts the slot). When this +// editor is itself persisted, the scope comes from its own entry — so the chain holds at any depth. +const rowStateScope = provideListEditorStateScope(stateEntry?.childScope ?? null) +const keyOf = (item: TItem): ListEditorKey => + typeof getKeyOpt === 'function' + ? getKeyOpt(item) + : (item[getKeyOpt as keyof TItem] as ListEditorKey) + +const keyFieldName = computed(() => + typeof getKeyOpt === 'function' ? '(fn)' : (getKeyOpt as string), +) + +// Managed position field name, mirroring the controller's resolution. +const positionFieldName = computed(() => { + const p = props.position + if (p === undefined) return 'position' + if (p === false) return 'position' + if (typeof p === 'object') return p.field as string + return p as string }) +// Render projection only — the controller owns the data (key + index + raw + position). +const viewItems = computed[]>(() => + modelValue.value.map((raw, index) => ({ + key: keyOf(raw), + index, + raw, + position: raw[positionFieldName.value] as number | undefined, + })), +) + +// Surfaces row-key wiring bugs loudly: undefined or duplicate keys silently break +// dirty tracking, validity rails and reorder targeting and are hard to trace from +// symptoms. Deduped per signature so a stable bad state warns once, a new one again. +const warnedKeySignatures = new Set() +const warnOnBadKeys = (items: ListViewItem[]): void => { + const seen = new Set() + const duplicates = new Set() + let missing = 0 + for (const vi of items) { + if (vi.key === undefined || vi.key === null) { + missing++ + continue + } + if (seen.has(vi.key)) duplicates.add(String(vi.key)) + seen.add(vi.key) + } + if (missing === 0 && duplicates.size === 0) return + const signature = `${missing}|${[...duplicates].sort().join(',')}` + if (warnedKeySignatures.has(signature)) return + warnedKeySignatures.add(signature) + if (missing > 0) { + console.warn( + `[list-editor] ${missing} row(s) resolve to an undefined key (key-field "${keyFieldName.value}"). ` + + 'Point get-key at a field every item has, or give new items unique temp ids ' + + '(see nextListEditorTempId).', + ) + } + if (duplicates.size > 0) { + console.warn( + `[list-editor] duplicate row keys (key-field "${keyFieldName.value}"): ${[...duplicates].join(', ')}. ` + + 'Row keys must be unique — dirty tracking and validation rails target rows by key.', + ) + } +} +watch(viewItems, (items) => warnOnBadKeys(items), { immediate: true }) + +// Drop the persisted controllers of rows that no longer exist. `post` so the removed row's subtree +// has already unmounted (its editors released their entries) before we stop their effect scopes. +watch( + () => viewItems.value.map((vi) => String(vi.key)).join('|'), + () => rowStateScope.retainOwners(viewItems.value.map((vi) => vi.key)), + { flush: 'post' }, +) + const expandedKeys = ref>(new Set()) const rowsContainer = useTemplateRef('rowsContainer') @@ -204,15 +385,6 @@ const rowsContainer = useTemplateRef('rowsContainer') const isInlineEdit = computed(() => !props.chips && !!slots.item) const hasReadonlyDetail = computed(() => !props.chips && !!slots['item-readonly']) -// Initial snapshot of each item, keyed by row key. Compared against current data to -// detect "dirty" (unsaved) rows. Reset externally after a successful parent-form save. -const { captureDirtyBaseline, rebaselineKey, isItemDirty } = useDirtyBaseline(() => - modelValue.value.map((item) => ({ - key: item[props.keyField] as ListEditorKey, - data: item, - })), -) - const { editingKeys, editingSnapshots, @@ -225,12 +397,10 @@ const { rowsContainer, rowSelector: '.a-le-row', isInlineEdit, - restoreSnapshot: (key, data) => editor.updateItem(key, data), - watchKeys: () => modelValue.value.map((it) => it[props.keyField] as ListEditorKey), + restoreSnapshot: (key, data) => controller.updateItem(key, data), + watchKeys: () => modelValue.value.map((it) => keyOf(it)), findEntry: (key) => { - const hit = modelValue.value.find( - (it) => (it[props.keyField] as ListEditorKey) === key, - ) + const hit = modelValue.value.find((it) => keyOf(it) === key) return hit ? { data: hit } : null }, afterAutoOpen: (key) => { @@ -238,64 +408,67 @@ const { }, }) -const addLabelResolved = computed(() => props.addLabel ?? t('common.sortable.add')) +const addLabelResolved = computed(() => + props.addLabel ? t(props.addLabel) : t('common.sortable.add'), +) const emptyTitleResolved = computed(() => props.emptyTitle ?? t('common.sortable.emptyTitle')) -const emptyTextResolved = computed(() => props.emptyText ?? t('common.sortable.emptyText')) const deleteConfirmTitleResolved = computed( () => props.deleteConfirmTitle ?? t('common.sortable.deleteConfirmTitle'), ) const deleteConfirmTextResolved = computed( - () => props.deleteConfirmText ?? t('common.sortable.deleteConfirmText'), + () => + props.deleteConfirmText ?? + (props.deleteMode === 'immediate' + ? t('common.sortable.deleteConfirmText') + : t('common.sortable.deleteConfirmTextDeferred')), ) const canInteract = computed(() => !props.readonly && !props.disabled && !props.loading) const canAdd = computed(() => canInteract.value && props.showAddButton) -const headerVisible = computed(() => !!(props.title || slots.header)) +// Total unconfirmed-change count (added/edited/moved rows + deferred deletions). A delete lights this +// up even though its row is gone. Shown as a header badge + exposed on the handle. +const unsavedCount = controller.unsavedCount +const unsavedCountVisible = computed( + () => !props.readonly && !props.disableUnsaved && unsavedCount.value > 0, +) +const headerVisible = computed(() => !!(props.title || slots.header) || unsavedCountVisible.value) -// Per-row edit footer (Save + Cancel) is only meaningful if the consumer wants a -// per-item persist callback. Without it the expectation is that the parent form's -// global save flushes everything, so we hide the per-row buttons by default. +// Per-row Save/Cancel footer only makes sense with a per-item persist callback; +// without one the parent form's global save flushes everything, so hide it. const showInlineSaveFooter = computed(() => !!props.onItemSave) -// Decoupled dirty pass: depends only on modelValue (via stringifyContent -// reading nested fields) and dirtyBaseline. Editing/expanded/loading flag -// changes do NOT re-trigger this — viewItemsDecorated reads dirtyKeys.has() -// instead of calling isItemDirty inline, so we avoid stringifying every -// row whenever the user clicks edit on one row. -const dirtyKeys = computed>(() => { - const out = new Set() - for (const item of modelValue.value) { - const key = item[props.keyField] as ListEditorKey - if (isItemDirty(key, item)) out.add(key) - } - return out -}) - -// Per-key decorator cache: we reuse the cached object when its base view item -// AND every flag matches, giving slot consumers a stable reference for rows -// whose state didn't change. Saves allocation on every render where only one -// row's flag flipped. +// Per-key decorator cache: reuse the cached object when base item AND every flag +// match, giving slot consumers a stable reference for unchanged rows. `disableUnsaved` +// suppresses only the amber marker — the validation rail still shows, since hiding +// dirty-state shouldn't hide a real error. const decoratorCache = new Map>() const viewItemsDecorated = computed[]>(() => { const next: DecoratedViewItem[] = [] const liveKeys = new Set() - for (const vi of editor.viewItems.value) { + for (const vi of viewItems.value) { liveKeys.add(vi.key) const editing = editingKeys.value.has(vi.key) const expanded = expandedKeys.value.has(vi.key) const loading = props.loadingKeys?.has(vi.key) ?? false - const dirty = dirtyKeys.value.has(vi.key) + // Readonly can't have user-unsaved changes; never paint amber there (also avoids + // a mount-before-load baseline marking every loaded row added). (QA 85050 sweep) + const unsaved = props.disableUnsaved || props.readonly ? false : controller.isUnsaved(vi.key) + const dirty = unsaved + // `editing` → the controller reads amber (not red) while the row is being filled in. (QA 85050 b7) + const validationState = controller.rowState(vi.raw, vi.key, editing) const cached = decoratorCache.get(vi.key) if ( - cached - && cached.raw === vi.raw - && cached.index === vi.index - && cached.position === vi.position - && cached.editing === editing - && cached.expanded === expanded - && cached.loading === loading - && cached.dirty === dirty + cached && + cached.raw === vi.raw && + cached.index === vi.index && + cached.position === vi.position && + cached.editing === editing && + cached.expanded === expanded && + cached.loading === loading && + cached.dirty === dirty && + cached.unsaved === unsaved && + cached.validationState === validationState ) { next.push(cached) continue @@ -306,6 +479,8 @@ const viewItemsDecorated = computed[]>(() => { expanded, loading, dirty, + unsaved, + validationState, } decoratorCache.set(vi.key, decorated) next.push(decorated) @@ -337,32 +512,26 @@ const keyboardNav = useKeyboardNav({ }, }) -const resolveCompactText = (raw: TItem, key: ListEditorKey): string => - resolveCompactTextUtil(raw, key, { - compactField: props.compactField, - fallback: t('common.sortable.itemFallback'), - }) - -const { resolveValidation } = useValidationRegistry({ - getValidationState: (item, key, index) => props.getValidationState?.(item, key, index) ?? null, -}) +const resolveCompactText = (raw: TItem): string => + resolveCompactTextUtil(raw, { compactField: props.compactField }) +// Managed add: the controller inserts `factory()` and renumbers; the inline-editing +// watch picks up the new key off the model change and auto-opens it. const onAddClick = () => { if (!canAdd.value) return requestAutoOpen() - emit('add', undefined) + controller.addItem(undefined, undefined) } const onRowAddAfterClick = (vi: ListViewItem) => { if (!canInteract.value) return requestAutoOpen() - emit('add', { afterId: vi.key }) + controller.addItem(undefined, { afterId: vi.key }) } const onEditClick = (vi: ListViewItem) => { if (!canInteract.value) return - // Toggle: clicking edit while already editing closes the form, matching the - // row-header click behaviour. + // Toggle: edit while already editing closes the form, matching row-header click. if (editingKeys.value.has(vi.key)) { onCloseClick(vi) return @@ -390,10 +559,10 @@ const onExpandClick = (vi: ListViewItem) => { const isRowClickable = (vi: DecoratedViewItem): boolean => { if (props.chips) return false if (props.disableRowClick) return false + if (props.defaultExpanded) return false if (props.disabled || props.loading) return false if (vi.editing || vi.expanded) return true if (!props.readonly && props.showEditButton) return true - if (props.readonly && hasReadonlyDetail.value) return true return false } @@ -421,10 +590,13 @@ const { onDeleteConfirm: (raw) => (props.onDeleteConfirm ? props.onDeleteConfirm(raw) : true), onDelete: (raw) => props.onDelete?.(raw), onDeleted: (vi) => { - editor.deleteItem(vi.key) editingKeys.value.delete(vi.key) editingSnapshots.value.delete(vi.key) expandedKeys.value.delete(vi.key) + // Controller owns removal: a temp row vanishes; a saved row is recorded in `getChanges().deleted` + // and counts as an unconfirmed change (deferred) — unless immediate mode (already deleted on the + // backend), which skips the tombstone. + controller.deleteItem(vi.key, { trackDeleted: props.deleteMode === 'deferred' }) emit('deleted', vi) }, disableDeleteConfirm: () => props.disableDeleteConfirm || props.chips, @@ -453,11 +625,9 @@ const onCloseClick = (vi: ListViewItem) => { emit('close', vi) } -// Per-key actions cache: each row's `actions` bundle is allocated once and -// reused on every render. Slot consumers receive a stable identity for -// `actions.update` etc., so they don't see prop-ref churn that would -// trigger spurious re-renders. Closures capture the row key (stable) and -// look up the current decorator via `findVi(key)` on call. +// Per-key actions cache: each row's `actions` bundle is allocated once so slot +// consumers get a stable identity (no prop-ref churn → no spurious re-renders). +// Closures capture the stable key and look up the live decorator via `findVi(key)`. type ActionsBundle = { edit: () => void save: () => Promise | void @@ -466,21 +636,42 @@ type ActionsBundle = { delete: () => Promise addAfter: () => void toggleExpand: () => void - update: (data: TItem) => TItem[] + update: (next: TItem | Partial | ((current: TItem) => TItem)) => void } const actionsCache = new Map() const getActions = (key: ListEditorKey): ActionsBundle => { let actions = actionsCache.get(key) if (!actions) { actions = { - edit: () => { const vi = findVi(key); if (vi) onEditClick(vi) }, - save: () => { const vi = findVi(key); if (vi) return onSaveClick(vi) }, - cancel: () => { const vi = findVi(key); if (vi) onCancelClick(vi) }, - close: () => { const vi = findVi(key); if (vi) onCloseClick(vi) }, - delete: async () => { const vi = findVi(key); if (vi) await onDeleteClick(vi) }, - addAfter: () => { const vi = findVi(key); if (vi) onRowAddAfterClick(vi) }, - toggleExpand: () => { const vi = findVi(key); if (vi) onExpandClick(vi) }, - update: (data: TItem) => editor.updateItem(key, data), + edit: () => { + const vi = findVi(key) + if (vi) onEditClick(vi) + }, + save: () => { + const vi = findVi(key) + if (vi) return onSaveClick(vi) + }, + cancel: () => { + const vi = findVi(key) + if (vi) onCancelClick(vi) + }, + close: () => { + const vi = findVi(key) + if (vi) onCloseClick(vi) + }, + delete: async () => { + const vi = findVi(key) + if (vi) await onDeleteClick(vi) + }, + addAfter: () => { + const vi = findVi(key) + if (vi) onRowAddAfterClick(vi) + }, + toggleExpand: () => { + const vi = findVi(key) + if (vi) onExpandClick(vi) + }, + update: (next) => controller.updateItem(key, next), } actionsCache.set(key, actions) } @@ -499,50 +690,51 @@ watch( ) const buildSlotProps = (vi: DecoratedViewItem) => ({ - item: { ...vi, validationState: resolveValidation(vi.raw as TItem, vi.key, vi.index) }, + item: vi, raw: vi.raw, index: vi.index, key: vi.key, readonly: props.readonly, disabled: props.disabled, expanded: vi.expanded, - editing: vi.editing, + editing: vi.editing || props.defaultExpanded, dirty: vi.dirty, - unsaved: vi.dirty, + unsaved: vi.unsaved, touch: isTouch.value, + stateKeyPrefix: rowStateScope.prefixFor(vi.key), actions: getActions(vi.key), }) -const unsavedKeysModel = defineModel>('unsavedKeys', { - default: () => new Set(), -}) +// Named unsaved-changes section, registered only when a label is passed. +useUnsavedSection(() => + props.unsavedSectionLabel && !props.disableUnsaved + ? { label: props.unsavedSectionLabel, dirty: controller.hasUnsaved.value } + : [], +) -const internalUnsavedKeys = computed>(() => { - const out = new Set() - for (const vi of viewItemsDecorated.value) { - if (vi.dirty) out.add(vi.key) - } - return out -}) +// Opens invalid rows so a blocked save surfaces WHICH rows are wrong instead of a collapsed red +// rail. The body is gated on `editing` not `expanded`, so drive `beginEdit` not `expandedKeys` +// (QA 85050 B4-17). Shared by the exposed `validateAll` and the scope-collector reveal-on-touch. +const runValidateAndReveal = (): boolean => + validateAllAndReveal(controller, (key) => { + const vi = viewItems.value.find((v) => v.key === key) + if (vi) beginEdit(vi) + }) -const { hasUnsavedChanges, unsavedCount, clearUnsavedState } = useUnsavedKeysSync({ - unsavedKeysModel, - internalUnsavedKeys, - onClearAll: () => captureDirtyBaseline(), - onClearKey: (key) => rebaselineKey(key), +// Auto-bridge the editor's aggregate validity into the consumer's `validation-scope` collector so a +// plain `$invalid` save gate blocks (and reveals) a collapsed invalid row. No-op without the prop. +// eslint-disable-next-line vue/no-setup-props-reactivity-loss -- construction-time opt-in, read once +useListEditorScopeValidity({ + hasErrors: controller.hasErrors, + validationScope: props.validationScope, + validateProvided: props.validate !== undefined, + reveal: runValidateAndReveal, }) -defineExpose({ - addItem: editor.addItem, - deleteItem: editor.deleteItem, - updateItem: editor.updateItem, - moveItem: editor.moveItem, - recalculatePositions: editor.recalculatePositions, - viewItems: editor.viewItems, - resetDirtyBaseline: captureDirtyBaseline, - hasUnsavedChanges, - unsavedCount, - clearUnsavedState, +// Expose the controller handle via `useTemplateRef>`. +defineExpose>({ + ...controller, + validateAll: runValidateAndReveal, }) @@ -579,6 +771,13 @@ defineExpose({ {{ title }} + + {{ t('common.sortable.pendingChanges', { count: unsavedCount }) }} +
+
-
- - - {{ resolveCompactText(vi.raw, vi.key) }} - - - -
-
- - + - {{ vi.raw[statusField] }} - - -
+ + {{ resolveCompactText(vi.raw) }} + + + +
-
- - - - - - - -
-
- -
{ edit: () => void save: () => Promise | void @@ -92,7 +99,7 @@ export interface RowActions { moveBottom: () => void indent: () => void outdent: () => void - update: (data: TItem) => NestedTree + update: (data: TItem) => void } export interface RowSlotProps> { item: DecoratedNestedViewItem & { validationState: ListEditorValidationState } @@ -122,6 +129,13 @@ export interface RowSlotProps> { canOutdent: boolean canAddChild: boolean touch: boolean + /** + * Row-scoped `state-key` prefix. Thread it down to any list editor rendered in this slot and set + * its `:state-key="`${stateKeyPrefix}:someList`"` — that editor's controller then lives in THIS + * editor's scope and survives the row collapsing (which unmounts the whole slot subtree). Entries + * under this prefix are disposed when the row is removed. + */ + stateKeyPrefix: string actions: RowActions } export interface ToolbarSlotProps { @@ -155,10 +169,55 @@ export interface AddButtonSlotProps { export interface Props> { maxDepth: number - keyField?: string - positionField?: string + /** + * New-row factory; if set, add/add-after/add-inside insert through the + * controller (no `@add` handler needed). Optional for read-only trees. + */ + factory?: () => TItem + /** + * Stable row identity. Default `'id'`. Never point at the position field. + * Typed `string` (not `keyof TItem`): a bare keyof compiles to a Boolean-only + * runtime type that silently coerces `get-key="id"` to `true`. + */ + getKey?: string | ((item: TItem) => ListEditorKey) + /** + * Managed order field. Default `'position'`; `false` opts out. Typed + * `[String, Boolean, Object]` with `string` before `false`: a bare keyof + * compiles Boolean-only, and `false`-first would coerce `position="position"` + * (value == prop name) to `true`. Object form folds in `{ field, multiplier }`. + */ + position?: string | false | { field: string; multiplier?: number; strategy?: PositionStrategy } + /** Parent-key field written onto reparented rows. Default `'parent'`. */ parentField?: string - positionMultiplier?: number + /** + * Extra fields dropped from the dirty content-hash (position + parent always + * dropped), e.g. a child collection tracked by a separate editor. + */ + dirtyExclude?: string[] + /** Per-row validity — `true` (or `{ valid: true }`) = VALID. Drives the red rail + save guard. */ + validate?: (item: TItem) => ListEditorValidationResult + /** + * Vuelidate `$scope` (the same one the consumer's row forms / save-gate collector use). When set, + * the editor registers its aggregate validity under it, so a plain `v$.$invalid` save gate blocks + * AND reveals a collapsed invalid row — no `validateAll()` call needed in the save flow. Omit to + * keep legacy behavior (gate the save on the exposed `validateAll()` yourself); `false` opts out. + */ + validationScope?: ListEditorValidationScope | false + /** + * Opt-in lifted controller (`useNestedListEditorController()`) so state + * survives unmount/remount; omitted = internal controller. Same handle + * reachable via `useTemplateRef`. + */ + editor?: NestedListEditorHandle + /** + * Persist this editor's controller in the nearest ANCESTOR editor's row-state scope under this + * key, instead of owning it. Use it for an editor rendered in another editor's `#item` slot (that + * slot is behind a `v-if`, so collapsing the row unmounts this editor and a component-owned + * controller would re-baseline the already-edited tree on re-expand). Derive the key from the row + * slot's `stateKeyPrefix`. Options stay declared here, on this tag. Ignored when `:editor` is + * passed, and a no-op without an ancestor scope. + */ + stateKey?: string readonly?: boolean disabled?: boolean @@ -179,20 +238,19 @@ export interface Props> { showChangeParent?: boolean showExpandToggle?: boolean - getValidationState?: ( - item: TItem, - key: ListEditorKey, - index: number, - ) => ListEditorValidationState - addLabel?: string | null emptyTitle?: string | null - emptyText?: string | null disableRowClick?: boolean disableDeleteConfirm?: boolean deleteConfirmTitle?: string | null deleteConfirmText?: string | null + /** + * How a row delete persists. `deferred` (default): the row disappears but the deletion counts as an + * unconfirmed change until save (revertible until then, incl. via reorder Cancel). `immediate`: the + * consumer deletes on the backend — the dialog says it is irreversible and it does NOT read as unsaved. + */ + deleteMode?: 'immediate' | 'deferred' closeVariant?: 'auto' | 'icon' | 'labeled' @@ -206,13 +264,22 @@ export interface Props> { onDelete?: (item: TItem) => Promise | void onItemSave?: (item: TItem) => Promise | void onReorderApply?: (tree: NestedTree) => Promise | void + + /** Registers this editor as a named unsaved-changes section under the given (translated) label. */ + unsavedSectionLabel?: string } const props = withDefaults(defineProps>(), { - keyField: 'id', - positionField: 'position', + unsavedSectionLabel: undefined, + factory: undefined, + getKey: undefined, + position: undefined, parentField: 'parent', - positionMultiplier: 1, + dirtyExclude: undefined, + validate: undefined, + validationScope: undefined, + editor: undefined, + stateKey: undefined, readonly: false, disabled: false, loading: false, @@ -228,14 +295,13 @@ const props = withDefaults(defineProps>(), { showMoveToPosition: false, showChangeParent: false, showExpandToggle: true, - getValidationState: undefined, addLabel: null, emptyTitle: null, - emptyText: null, disableRowClick: false, disableDeleteConfirm: false, deleteConfirmTitle: null, deleteConfirmText: null, + deleteMode: 'deferred', closeVariant: 'auto', loadingKeys: null, showReorderToggle: true, @@ -285,13 +351,12 @@ defineSlots<{ const { t } = useI18n() const slots = useSlots() -const display = useDisplay() const { showWarningT } = useAlerts() const rootEl = useTemplateRef('rootEl') const { isNarrow } = useContainerWidth(rootEl) -const isTouch = computed(() => display.platform.value.touch) +const isTouch = useIsTouchDevice() const effectiveCloseVariant = computed<'icon' | 'labeled'>(() => { if (props.closeVariant === 'icon') return 'icon' @@ -299,149 +364,186 @@ const effectiveCloseVariant = computed<'icon' | 'labeled'>(() => { return isNarrow.value ? 'icon' : 'labeled' }) -// Tree-level expand/collapse — controls which descendants are visible in the flat -// viewItems list. Auto-populated at mount for every node that has children. const childrenExpandedKeys = ref>(new Set()) -// Row-level readonly detail visibility — used in readonly mode with #item-readonly slot. -// Independent of children expansion, so a node can have its subtree visible while its -// own detail body is collapsed, and vice versa. +// Readonly detail body visibility — independent of subtree expansion. const detailExpandedKeys = ref>(new Set()) +// Local mirror of the controller's key resolution so walkers + the template key +// rows the same way the controller tracks them. Construction-time config, read once. +/* eslint-disable vue/no-setup-props-reactivity-loss */ +const getKeyOpt = props.getKey ?? 'id' +const keyFieldName = (typeof getKeyOpt === 'function' ? 'id' : getKeyOpt) as string +const keyOf = (data: TItem): ListEditorKey => + typeof getKeyOpt === 'function' + ? getKeyOpt(data) + : (data[getKeyOpt as keyof TItem] as ListEditorKey) + +// Managed position field (row position, move-to-position dialog, flatViewItems). Mirrors the controller. +const positionFieldName = ((): string => { + const p = props.position + if (p === undefined || p === false) return 'position' + if (typeof p === 'object') return p.field + return p +})() + const initChildrenExpanded = (tree: NestedTree) => { const walk = (nodes: NestedTreeNode[]) => { for (const n of nodes) { if (n.children && n.children.length > 0) { - childrenExpandedKeys.value.add(n.data[props.keyField] as ListEditorKey) + childrenExpandedKeys.value.add(keyOf(n.data)) walk(n.children) } } } walk(tree.children) } -// One-shot init at setup time — subsequent expansions are user-driven. // eslint-disable-next-line vue/no-ref-object-reactivity-loss initChildrenExpanded(modelValue.value) -// eslint-disable-next-line vue/no-setup-props-reactivity-loss -const editor = useNestedListEditor(modelValue, { - keyField: props.keyField, - positionField: props.positionField, +// State controller (v2). Undefined getKey/position/validate fall back to controller defaults +// ('id' / 'position' / always-valid). Its `viewItems` ignores expand state, so we re-project an +// expand-aware list below (flatViewItems). +const controllerOptions: NestedListEditorStateBindings = { + get: () => modelValue.value, + set: (v) => (modelValue.value = v), + factory: props.factory, + getKey: props.getKey as GetKey | undefined, + position: props.position as PositionOption | undefined, parentField: props.parentField, - positionMultiplier: props.positionMultiplier, maxDepth: props.maxDepth, - expandedKeys: childrenExpandedKeys, + dirtyExclude: () => props.dirtyExclude ?? [], + validate: props.validate, +} +// Precedence: an explicitly lifted `:editor` wins; then a `state-key`'d entry persisted in the +// nearest ancestor editor's row-state scope (rebound to this instance on every remount); else this +// component owns the controller — today's behaviour, unchanged. +const stateEntry = props.editor + ? null + : useNestedListEditorStateEntry(props.stateKey, controllerOptions) +const controller = + props.editor ?? stateEntry?.handle ?? useNestedListEditorController(controllerOptions) +/* eslint-enable vue/no-setup-props-reactivity-loss */ + +// Row-state scope for THIS editor's `#item` slot subtree — see ASortableListEditor. +const rowStateScope = provideListEditorStateScope(stateEntry?.childScope ?? null) + +// FULL flattened list (collapsed-branch rows included) built directly off the +// tree: the recursive renderer (LeNestedRow) filters by parentKey + childrenExpandedKeys, +// so it needs every row present in viewItemsDecorated even when hidden. +const flatViewItems = computed[]>(() => { + const flat: NestedViewItem[] = [] + let flatIndex = 0 + const maxDepth = props.maxDepth + const walk = ( + nodes: NestedTreeNode[], + depth: number, + parentNode: NestedTreeNode | null, + ) => { + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i] + const key = keyOf(node.data) + const childrenAllowed = node.children !== undefined + const hasChildren = childrenAllowed && (node.children?.length ?? 0) > 0 + const childrenCount = node.children?.length ?? 0 + const remainingDepth = maxDepth - (depth + 1) + flat.push({ + key, + index: flatIndex++, + raw: node.data, + position: node.data[positionFieldName] as number | undefined, + node, + depth, + parent: parentNode?.data ?? null, + parentKey: parentNode ? keyOf(parentNode.data) : null, + childrenCount, + hasChildren, + childrenAllowed, + siblingIndex: i, + siblingCount: nodes.length, + firstInParent: i === 0, + lastInParent: i === nodes.length - 1, + canAddChild: childrenAllowed && remainingDepth > 0, + canIndent: i > 0, + canOutdent: depth > 0, + }) + if (hasChildren) walk(node.children as NestedTreeNode[], depth + 1, node) + } + } + walk(modelValue.value.children, 0, null) + return flat }) -// Dirty baseline — compares content (title, user-editable fields) against the -// initial snapshot. `position` and `parent` are deliberately stripped out: -// drag-drop rewrites those on side-effect rows (siblings shifted to keep the -// position sequence consistent), and flagging those unmoved rows as dirty -// would produce ghost "unsaved" markers everywhere. The consumer saves the -// whole tree on apply anyway — we only care about the per-row visual cue. -// eslint-disable-next-line vue/no-setup-props-reactivity-loss -const { captureDirtyBaseline, rebaselineKey, isItemDirty } = useDirtyBaseline( - () => { - const out: Array<{ key: ListEditorKey; data: TItem }> = [] - const walk = (nodes: NestedTreeNode[]) => { - for (const n of nodes) { - out.push({ key: n.data[props.keyField] as ListEditorKey, data: n.data }) - if (n.children && n.children.length) walk(n.children) - } - } - walk(modelValue.value.children) - return out - }, - { excludeFields: [props.positionField, props.parentField] }, +// Drop the persisted controllers of rows that no longer exist. `post` so the removed row's subtree +// has already unmounted (its editors released their entries) before we stop their effect scopes. +watch( + () => flatViewItems.value.map((vi) => String(vi.key)).join('|'), + () => rowStateScope.retainOwners(flatViewItems.value.map((vi) => vi.key)), + { flush: 'post' }, ) -// Reorder snapshot — captures the tree at reorder-start so we can restore it -// on cancel. Dirty/moved detection does NOT derive from the snapshot: we -// used to compare each row's current parent + sibling-index against snapshot -// values, which flagged unmoved rows as "moved" whenever a neighbour shifted -// sibling positions as a side-effect. Instead we track explicit user actions -// in `movedKeys` below — only rows the user actively moved get marked. +// Snapshot for reorder-cancel restore only; dirty detection lives in the controller. +// `movedKeys` is component view state driving the toolbar "N pending" counter, +// cleared on every mode transition by `useReorderMode`. const snapshot = shallowRef | null>(null) -// Keys the user has actively moved during this reorder session (drag, arrow -// buttons, indent/outdent). Clearing this set is paired with -// captureDirtyBaseline in the exposed resetDirtyBaseline cycle — consumers -// expect the orange badges to go away once a server save confirms. const movedKeys = ref>(new Set()) +// Deletes made during the current reorder session, split by mode — deferred ones count in the toolbar +// and revert on Cancel; immediate (backend) ones are re-applied on Cancel so they stay gone (else the +// snapshot-restore would resurrect a server-deleted row). Mirrors ASortableListEditor. +const sessionDeferredDeletes = ref>(new Set()) +const sessionImmediateDeletes = ref>(new Set()) +// Re-baseline current tree as saved. Legacy name kept; maps onto `controller.commit()`. const resetDirtyBaseline = () => { - captureDirtyBaseline() + controller.commit() movedKeys.value = new Set() } -// Mark the row AND every descendant. Moving a parent visually carries its -// whole subtree to the new location, so the children are "moved" too from -// the user's perspective — even though they stayed in place relative to -// their parent. Without this, only the grabbed parent would light up orange -// and its descendants would silently travel along, which reads as missing -// feedback when nested trees are moved around. +// Mark row + every descendant moved — a moved parent visually carries its subtree. const markMoved = (key: ListEditorKey) => { - const { node } = editor.findNode(key) + const { node } = controller.findNode(key) if (!node) { movedKeys.value.add(key) return } const collect = (n: NestedTreeNode) => { - movedKeys.value.add(n.data[props.keyField] as ListEditorKey) + movedKeys.value.add(keyOf(n.data)) if (n.children) n.children.forEach(collect) } collect(node) } -// Decoupled dirty pass — see AListEditor for rationale. Walk the tree once -// to collect every dirty key; viewItemsDecorated then reads dirtyKeys.has() -// instead of calling isItemDirty inline so flag-only re-renders skip the -// stringify pass. -const dirtyKeys = computed>(() => { - const out = new Set() - const walk = (nodes: typeof modelValue.value.children) => { - for (const n of nodes) { - const key = n.data[props.keyField] as ListEditorKey - if (isItemDirty(key, n.data as TItem)) out.add(key) - if (n.children?.length) walk(n.children) - } - } - walk(modelValue.value.children) - return out -}) - -// Per-key decorator cache — see AListEditor for rationale. The base view item -// for nested already carries a lot of stable shape info (parent, depth, -// childrenCount, sibling-position flags, *Allowed); we only need to invalidate -// when those, or any per-row flag, changes. const decoratorCache = new Map>() const viewItemsDecorated = computed[]>(() => { const next: DecoratedNestedViewItem[] = [] const liveKeys = new Set() - for (const vi of editor.viewItems.value) { + for (const vi of flatViewItems.value) { liveKeys.add(vi.key) const editing = editingKeys.value.has(vi.key) const expanded = detailExpandedKeys.value.has(vi.key) const childrenExpanded = childrenExpandedKeys.value.has(vi.key) const loading = props.loadingKeys?.has(vi.key) ?? false const moved = movedKeys.value.has(vi.key) - const dirty = dirtyKeys.value.has(vi.key) - const unsaved = dirty || moved + // Amber = controller dirty OR reorder-session moved. readonly suppresses it + // (read-only views can't be unsaved; also dodges a mount-before-load baseline). (QA 85050 sweep) + const dirty = props.readonly ? false : controller.isUnsaved(vi.key) + const unsaved = props.readonly ? false : dirty || moved const cached = decoratorCache.get(vi.key) if ( - cached - && cached.raw === vi.raw - && cached.index === vi.index - && cached.position === vi.position - && cached.depth === vi.depth - && cached.parentKey === vi.parentKey - && cached.childrenCount === vi.childrenCount - && cached.firstInParent === vi.firstInParent - && cached.lastInParent === vi.lastInParent - && cached.editing === editing - && cached.expanded === expanded - && cached.childrenExpanded === childrenExpanded - && cached.loading === loading - && cached.dirty === dirty - && cached.moved === moved - && cached.unsaved === unsaved + cached && + cached.raw === vi.raw && + cached.index === vi.index && + cached.position === vi.position && + cached.depth === vi.depth && + cached.parentKey === vi.parentKey && + cached.childrenCount === vi.childrenCount && + cached.firstInParent === vi.firstInParent && + cached.lastInParent === vi.lastInParent && + cached.editing === editing && + cached.expanded === expanded && + cached.childrenExpanded === childrenExpanded && + cached.loading === loading && + cached.dirty === dirty && + cached.moved === moved && + cached.unsaved === unsaved ) { next.push(cached) continue @@ -466,7 +568,7 @@ const viewItemsDecorated = computed[]>(() => { }) const isEmpty = computed(() => modelValue.value.children.length === 0) -const totalItemCount = computed(() => editor.viewItems.value.length) +const totalItemCount = computed(() => flatViewItems.value.length) const rowsContainer = useTemplateRef('rowsContainer') @@ -476,9 +578,7 @@ const canEnterReorder = computed( ) const isInlineEdit = computed(() => !!(slots as Record).item) -const hasReadonlyDetail = computed( - () => !!(slots as Record)['item-readonly'], -) +const hasReadonlyDetail = computed(() => !!(slots as Record)['item-readonly']) const showInlineSaveFooter = computed(() => !!props.onItemSave) const { @@ -494,12 +594,14 @@ const { rowsContainer, rowSelector: '.a-le-row-wrapper', isInlineEdit, - restoreSnapshot: (key, data) => editor.updateItem(key, data), + // markDirty=false: restoring the pre-edit snapshot on cancel must not flag the + // node dirty (it would otherwise be resent by partial-multi saves). + restoreSnapshot: (key, data) => controller.updateItem(key, data, false), watchKeys: () => { const keys: ListEditorKey[] = [] const walk = (nodes: NestedTreeNode[]) => { for (const n of nodes) { - keys.push(n.data[props.keyField] as ListEditorKey) + keys.push(keyOf(n.data)) if (n.children && n.children.length) walk(n.children) } } @@ -507,13 +609,13 @@ const { return keys }, findEntry: (key) => { - const { node } = editor.findNode(key) + const { node } = controller.findNode(key) return node ? { data: node.data } : null }, afterAutoOpen: (key) => { - const { parent } = editor.findNode(key) + const { parent } = controller.findNode(key) if (parent) { - childrenExpandedKeys.value.add(parent.data[props.keyField] as ListEditorKey) + childrenExpandedKeys.value.add(keyOf(parent.data)) } }, }) @@ -538,22 +640,34 @@ const { }, canEnterReorder, onEnter: () => { + sessionDeferredDeletes.value = new Set() + sessionImmediateDeletes.value = new Set() clearEditing() - // Expand every branch so the user can see (and reach) every row before - // picking something to drag — otherwise collapsed subtrees would be invisible - // reorder targets. + // Expand every branch so all rows are reachable drag targets. for (const k of expandableKeys.value) childrenExpandedKeys.value.add(k) nextTick(() => { if (dragEnabled.value) initSortables() }) }, onExternalEnter: () => { + sessionDeferredDeletes.value = new Set() + sessionImmediateDeletes.value = new Set() clearEditing() }, + // Cancel: the snapshot-restore has brought every deleted row back; reconcile the controller so a + // deferred delete truly reverts (clear tombstone, row stays) and an immediate delete stays gone (row + // removed again — it is deleted on the backend and must not resurrect / be re-created by a save). onCancel: () => { + for (const key of sessionDeferredDeletes.value) controller.restoreDeleted(key) + for (const key of sessionImmediateDeletes.value) + controller.deleteItem(key, { trackDeleted: false }) + sessionDeferredDeletes.value = new Set() + sessionImmediateDeletes.value = new Set() destroySortables() }, onApplyEnd: () => { + sessionDeferredDeletes.value = new Set() + sessionImmediateDeletes.value = new Set() destroySortables() }, onReorderApply: (tree) => props.onReorderApply?.(tree), @@ -566,40 +680,47 @@ const { }, }) -const canAdd = computed(() => canInteract.value && props.showAddButton && !reorderMode.value) -const dragEnabled = computed( - () => reorderMode.value && !isTouch.value && !props.disableDrag, +// Reorder-toolbar pending indicator = session moves + deferred deletes made in the session, so a delete +// in reorder mode no longer reads as "no pending changes" and Apply stays enabled. +const nestedPendingCount = computed( + () => movedCount.value + sessionDeferredDeletes.value.size, +) +const nestedPendingChanges = computed( + () => hasPendingChanges.value || sessionDeferredDeletes.value.size > 0, ) -const addLabelResolved = computed(() => props.addLabel ?? t('common.sortable.add')) +const canAdd = computed(() => canInteract.value && props.showAddButton && !reorderMode.value) +const dragEnabled = computed(() => reorderMode.value && !isTouch.value && !props.disableDrag) + +const addLabelResolved = computed(() => + props.addLabel ? t(props.addLabel) : t('common.sortable.add'), +) const emptyTitleResolved = computed(() => props.emptyTitle ?? t('common.sortable.emptyTitle')) -const emptyTextResolved = computed(() => props.emptyText ?? t('common.sortable.emptyText')) const deleteConfirmTitleResolved = computed( () => props.deleteConfirmTitle ?? t('common.sortable.deleteConfirmTitle'), ) const deleteConfirmTextResolved = computed( - () => props.deleteConfirmText ?? t('common.sortable.deleteConfirmText'), + () => + props.deleteConfirmText ?? + (props.deleteMode === 'immediate' + ? t('common.sortable.deleteConfirmText') + : t('common.sortable.deleteConfirmTextDeferred')), ) const reorderToggleVisible = computed( (): boolean => - !props.readonly - && props.showReorderToggle - && !reorderMode.value - && totalItemCount.value > 0, + !props.readonly && props.showReorderToggle && !reorderMode.value && totalItemCount.value > 0, ) -const compactReorderButton = computed( - (): boolean => !!props.title && isNarrow.value, -) +const compactReorderButton = computed((): boolean => !!props.title && isNarrow.value) -// Keys of every node that *has* children — the candidates for expand/collapse. +// Keys of every node that has children — the expand/collapse candidates. const expandableKeys = computed(() => { const out: ListEditorKey[] = [] const walk = (nodes: NestedTreeNode[]) => { for (const n of nodes) { if (n.children && n.children.length > 0) { - out.push(n.data[props.keyField] as ListEditorKey) + out.push(keyOf(n.data)) walk(n.children) } } @@ -608,9 +729,10 @@ const expandableKeys = computed(() => { return out }) -const allExpanded = computed(() => - expandableKeys.value.length > 0 - && expandableKeys.value.every((k) => childrenExpandedKeys.value.has(k)), +const allExpanded = computed( + () => + expandableKeys.value.length > 0 && + expandableKeys.value.every((k) => childrenExpandedKeys.value.has(k)), ) const expandAllVisible = computed( @@ -628,21 +750,20 @@ const toggleExpandAll = () => { const headerVisible = computed( (): boolean => !!( - props.title - || (slots as Record).header - || (slots as Record)['reorder-toggle'] - || reorderToggleVisible.value - || expandAllVisible.value - || reorderMode.value + props.title || + (slots as Record).header || + (slots as Record)['reorder-toggle'] || + reorderToggleVisible.value || + expandAllVisible.value || + reorderMode.value ), ) -// Initialize nested SortableJS groups. We create one Sortable instance per group -// so drag/drop can move items within/between groups — SortableJS handles the -// pointer events; onEnd reconciles via editor.moveTo(). -const sortableInstances = ref void; option?: (k: string, v: unknown) => void }>>( - [], -) +// One Sortable instance per group so drag/drop moves items within/between groups; +// SortableJS owns the pointer events, onEnd reconciles via editor.moveTo(). +const sortableInstances = ref< + Array<{ stop: () => void; option?: (k: string, v: unknown) => void }> +>([]) const forceRerender = ref(0) const destroySortables = () => { @@ -659,22 +780,15 @@ const destroySortables = () => { const GROUP_CLASS = 'a-nested-list-editor__group' const HANDLE_CLASS = 'a-le-drag-handle' -// Live drag state. `instruction` is recomputed on every pointermove while -// drag is active — it encodes WHERE the dragged item will land (sibling-above/ -// below/make-child/blocked) and at WHAT DEPTH. The overlay template reads it -// to render the drop indicator; `onEnd` applies it via `editor.moveTo`. -// -// We don't rely on SortableJS to move the DOM — onMove always returns false. -// SortableJS is reduced to "drag lifecycle + floating clone"; hit-testing, -// depth picking and the final mutation are all ours. +// Live drag state. `instruction` is recomputed on every pointermove and encodes +// where + at what depth the dragged item lands (sibling-above/below/make-child/blocked); +// the overlay reads it, onEnd applies it via `editor.moveTo`. SortableJS never moves +// the DOM (onMove always returns false) — hit-testing, depth and the mutation are all ours. const dragState = ref(null) -// Drop indicator anchor geometry — kept in sync with CSS: -// row-header padding-left (reorder mode) = 12px + depth * --ansle-indent(24) -// drag handle icon size = 20px (VIcon size="20") -// Anchor X at depth 0 = padding-left (12) + half handle width (10) = 22px, so -// the dot sits on top of the drag handle's visual centre and the line walks -// right/left in 24px steps, visually matching the actual row indent hierarchy. +// Drop indicator anchor geometry, kept in sync with CSS (reorder-mode header +// padding-left = 12 + depth*24, drag handle = 20px). ANCHOR_X = 12 + half handle +// (10) = 22 puts the dot on the handle's centre; the line steps 24px per depth. const INDENT_PX = 24 const ANCHOR_X = 22 @@ -686,8 +800,8 @@ const hitTestRow = ( if (!hit) return null const wrapper = hit.closest('.a-le-row-wrapper') as HTMLElement | null if (!wrapper) return null - // Only consider wrappers inside our rowsContainer — elementFromPoint could - // hit a different ANestedSortableListEditor instance on the same page. + // Only wrappers inside our rowsContainer — elementFromPoint could hit a + // different ANestedSortableListEditor instance on the same page. if (!rowsContainer.value || !rowsContainer.value.contains(wrapper)) return null const key = parseKey(wrapper.getAttribute('data-id')) if (key === null) return null @@ -704,12 +818,9 @@ const recomputeInstruction = (clientX: number, clientY: number) => { return } const containerRect = rowsContainer.value.getBoundingClientRect() - // For hovered-row rect we read the row element (not the whole wrapper, - // whose height balloons when children are rendered). The wrapper's first - // `.a-le-row` child is the header+body we want to hit-test. - const rowEl = hit.el.querySelector( - ':scope > .a-le-row', - ) as HTMLElement | null + // Hit-test the `.a-le-row` child, not the wrapper — the wrapper's height + // balloons when children are rendered. + const rowEl = hit.el.querySelector(':scope > .a-le-row') as HTMLElement | null const rowRect = (rowEl ?? hit.el).getBoundingClientRect() dragState.value.instruction = computeInstruction({ pointer: { x: clientX, y: clientY }, @@ -736,17 +847,15 @@ const onPointerMove = (e: PointerEvent) => { } const applyInstruction = (inst: ExecutableInstruction, sourceKey: ListEditorKey) => { - const res = editor.moveTo(sourceKey, inst.parentKey, inst.index) - if (res === null) { + const ok = controller.moveTo(sourceKey, inst.parentKey, inst.index) + if (!ok) { showWarningT('common.sortable.error.maxDeepExceed') forceRerender.value++ nextTick(() => initSortables()) return } markMoved(sourceKey) - // The dragged row landed as a new first child — expand the target so the - // just-moved row stays visible instead of disappearing into a collapsed - // branch. Only needed when we created a new parent (makeChild === true). + // makeChild: expand the new parent so the just-moved row doesn't vanish into a collapsed branch. if (inst.makeChild && inst.parentKey !== null) { childrenExpandedKeys.value.add(inst.parentKey) } @@ -756,9 +865,7 @@ const initSortables = () => { destroySortables() if (!dragEnabled.value) return if (!rowsContainer.value) return - const groups = Array.from( - rowsContainer.value.querySelectorAll('.' + GROUP_CLASS), - ) + const groups = Array.from(rowsContainer.value.querySelectorAll('.' + GROUP_CLASS)) for (const group of groups) { const sortable = useSortable(group, [], { group: { name: 'a-nested', pull: true, put: true }, @@ -774,8 +881,8 @@ const initSortables = () => { const draggedEl = event.item as HTMLElement const id = parseKey(draggedEl.getAttribute('data-id')) if (id === null) return - const draggedNode = editor.findNode(id).node - const subtreeDepth = draggedNode ? editor.calculateSubtreeDepth(draggedNode) : 1 + const draggedNode = controller.findNode(id).node + const subtreeDepth = draggedNode ? controller.calculateSubtreeDepth(draggedNode) : 1 dragState.value = { sourceKey: id, sourceSubtreeDepth: subtreeDepth, @@ -784,9 +891,8 @@ const initSortables = () => { document.addEventListener('pointermove', onPointerMove, { passive: true }) }, onMove: (event) => { - // Track pointer via SortableJS's event stream too (redundant with - // document pointermove but keeps `instruction` in sync when the - // browser coalesces pointermove events during fast drags). + // Also track the pointer via SortableJS's stream — keeps `instruction` + // in sync when the browser coalesces pointermove events during fast drags. const orig = (event as unknown as { originalEvent?: Event }).originalEvent if (orig) { if ('clientX' in orig && 'clientY' in orig) { @@ -798,8 +904,7 @@ const initSortables = () => { } } } - // Always refuse SortableJS's own DOM insertion — our overlay and - // `editor.moveTo` in onEnd drive the actual move. + // Refuse SortableJS's own DOM insertion — onEnd's `editor.moveTo` drives the move. return false }, onEnd: () => { @@ -819,10 +924,11 @@ const initSortables = () => { const parseKey = (raw: string | null): ListEditorKey | null => { if (raw === null || raw === '') return null + // Numeric key only when `data-id` is a pure integer string (incl. negative temp + // ids like "-1"); else a DocId/UUID. The old `n > 0` test sent negative temp ids + // to the string branch, so a freshly-added row's move marked the wrong key. const n = stringToInt(raw) - if (n > 0) return n - // Fallback to string keys (DocId) - return raw + return String(n) === raw ? n : raw } watch( @@ -840,8 +946,7 @@ watch( }, ) -// Rebuild sortable instances whenever the tree shape changes during drag/drop mode — -// otherwise newly rendered groups would not be draggable. +// Rebuild sortables when the tree shape changes during drag mode, else newly rendered groups aren't draggable. watch( () => viewItemsDecorated.value.map((v) => v.key).join('|'), () => { @@ -857,15 +962,11 @@ onBeforeUnmount(() => { document.removeEventListener('pointermove', onPointerMove) }) -// Visual props derived from the current instruction. `null` = overlay hidden -// — either because no instruction is active, or because the instruction is -// `blocked`. The optional connector is a thin rail from the drop line up to -// the row whose level dictates the insert (previous sibling or the ancestor -// being joined as sibling of). Positions are computed from getBoundingClientRect -// rather than CSS anchor positioning: nested row elements in a deep tree -// aren't always reachable from an overlay-level anchor() call (Chrome's -// anchor reachability rule excludes some deeply-nested descendants), so -// JS measurement is both simpler and more reliable here. +// Overlay visual props from the current instruction; `null` = hidden (no +// instruction, or blocked). The optional connector is a rail from the drop line +// up to the row whose level dictates the insert. Measured via getBoundingClientRect +// rather than CSS anchor positioning: Chrome's anchor-reachability rule excludes +// some deeply-nested descendants, so JS measurement is simpler and more reliable. type OverlayVisual = { line: { top: number; left: number; right: number } connector: { top: number; height: number; left: number } | null @@ -875,24 +976,19 @@ const overlayVisual = computed(() => { const state = dragState.value if (!state || state.instruction === null || !rowsContainer.value) return null const inst = state.instruction - // Blocked drops render nothing on purpose — the silent empty space IS the - // "not here" signal. No warning-coloured ghost of the attempted target. + // Blocked drops render nothing — the silent empty space is the "not here" signal. if (inst.type === 'blocked') return null const refWrapper = rowsContainer.value.querySelector( `.a-le-row-wrapper[data-id="${CSS.escape(String(inst.refKey))}"]`, ) if (!refWrapper) return null - const rowEl = refWrapper.querySelector( - ':scope > .a-le-row', - ) + const rowEl = refWrapper.querySelector(':scope > .a-le-row') const containerRect = rowsContainer.value.getBoundingClientRect() const rowRect = (rowEl ?? refWrapper).getBoundingClientRect() const lineLeft = ANCHOR_X + inst.depth * INDENT_PX const lineTop = - inst.refEdge === 'top' - ? rowRect.top - containerRect.top - : rowRect.bottom - containerRect.top + inst.refEdge === 'top' ? rowRect.top - containerRect.top : rowRect.bottom - containerRect.top const line = { top: lineTop, left: lineLeft, right: 16 } let connector: OverlayVisual['connector'] = null @@ -901,9 +997,7 @@ const overlayVisual = computed(() => { `.a-le-row-wrapper[data-id="${CSS.escape(String(inst.levelRowKey))}"]`, ) if (levelWrapper) { - const levelRow = levelWrapper.querySelector( - ':scope > .a-le-row', - ) + const levelRow = levelWrapper.querySelector(':scope > .a-le-row') const levelRect = (levelRow ?? levelWrapper).getBoundingClientRect() const levelCentreY = levelRect.top - containerRect.top + levelRect.height / 2 if (levelCentreY < lineTop) { @@ -919,15 +1013,20 @@ const overlayVisual = computed(() => { return { line, connector } }) +// With a `factory`, the editor inserts through the controller; without one it +// stays emit-only so legacy `@add` consumers drive the insert. The `add` event +// fires either way. const onAddClick = () => { if (!canAdd.value) return requestAutoOpen() + if (props.factory) controller.addItem() emit('add', undefined) } const onRowAddAfterClick = (vi: NestedViewItem) => { if (!canInteract.value) return requestAutoOpen() + if (props.factory) controller.addAfter(vi.key, undefined, vi.childrenAllowed) emit('add', { afterId: vi.key, childrenAllowed: vi.childrenAllowed }) } @@ -937,17 +1036,15 @@ const onAddChildClick = (vi: NestedViewItem) => { requestAutoOpen() childrenExpandedKeys.value.add(vi.key) emit('add-child', vi) - // Append to the end of existing children — matches the root-level "Add - // item" button's semantic (append to end of root). Drag-drop make-child - // still lands at index 0 because the drop line there is visually at the - // gap immediately below the parent. + // Append to the end of children, matching the root "Add item" semantic. + // (Drag-drop make-child still lands at index 0 — its drop line sits just below the parent.) + if (props.factory) controller.addChild(vi.key, undefined, true) emit('add', { parentId: vi.key, childrenAllowed: true }) } const onEditClick = (vi: NestedViewItem) => { if (!canInteract.value || reorderMode.value) return - // Clicking the edit affordance while already editing closes the form — so the - // pencil button works as a toggle just like clicking the row header. + // Clicking edit while already editing closes the form, so the pencil toggles like the row header. if (editingKeys.value.has(vi.key)) { onCloseClick(vi) return @@ -958,7 +1055,7 @@ const onEditClick = (vi: NestedViewItem) => { emit('edit', vi) } -// Chevron click — toggles tree children visibility. +// Toggles tree children visibility (distinct from the readonly-detail body below). const onChevronClick = (vi: NestedViewItem) => { if (props.disabled || props.loading) return const key = vi.key @@ -968,7 +1065,7 @@ const onChevronClick = (vi: NestedViewItem) => { emit('item-expand', vi, !currently) } -// Detail body toggle — controls the row's readonly-detail body (separate from tree). +// Toggles the row's readonly-detail body (separate from tree children). const onDetailToggle = (vi: NestedViewItem) => { if (props.disabled || props.loading) return const key = vi.key @@ -984,7 +1081,6 @@ const isRowClickable = (vi: DecoratedNestedViewItem): boolean => { if (reorderMode.value) return false if (vi.editing || vi.expanded) return true if (!props.readonly && props.showEditButton) return true - if (props.readonly && hasReadonlyDetail.value) return true return false } @@ -1009,11 +1105,21 @@ const { onDeleteConfirm: (raw) => (props.onDeleteConfirm ? props.onDeleteConfirm(raw) : true), onDelete: (raw) => props.onDelete?.(raw), onDeleted: (vi) => { - editor.deleteItem(vi.key) editingKeys.value.delete(vi.key) editingSnapshots.value.delete(vi.key) detailExpandedKeys.value.delete(vi.key) childrenExpandedKeys.value.delete(vi.key) + const deferred = props.deleteMode === 'deferred' + // A delete in reorder mode is a session change: deferred reverts on Cancel, immediate stays gone + // (re-applied after Cancel's snapshot-restore). + if (reorderMode.value) { + if (deferred) sessionDeferredDeletes.value.add(vi.key) + else sessionImmediateDeletes.value.add(vi.key) + } + // Controller owns removal (mirrors the flat editors): a deferred saved-row delete is tombstoned + // (counts as unsaved); immediate skips it (already deleted on the backend). Runs before the + // `deleted` emit so a consumer's handler still receives `vi.raw`. + controller.deleteItem(vi.key, { trackDeleted: deferred }) emit('deleted', vi) }, disableDeleteConfirm: () => props.disableDeleteConfirm, @@ -1040,20 +1146,19 @@ const onCloseClick = (vi: NestedViewItem) => { } const moveUp = (id: ListEditorKey) => { - if (editor.moveUp(id)) markMoved(id) + if (controller.moveUp(id)) markMoved(id) } const moveDown = (id: ListEditorKey) => { - if (editor.moveDown(id)) markMoved(id) + if (controller.moveDown(id)) markMoved(id) } const moveTop = (id: ListEditorKey) => { - if (editor.moveTop(id)) markMoved(id) + if (controller.moveTop(id)) markMoved(id) } const moveBottom = (id: ListEditorKey) => { - if (editor.moveBottom(id)) markMoved(id) + if (controller.moveBottom(id)) markMoved(id) } const doIndent = (vi: NestedViewItem) => { - const res = editor.indent(vi.key) - if (res === null) { + if (!controller.indent(vi.key)) { showWarningT('common.sortable.error.maxDeepExceed') return } @@ -1061,25 +1166,22 @@ const doIndent = (vi: NestedViewItem) => { emit('indent', vi) } const doOutdent = (vi: NestedViewItem) => { - const res = editor.outdent(vi.key) - if (res === null) return + if (!controller.outdent(vi.key)) return markMoved(vi.key) emit('outdent', vi) } -const resolveCompactText = (raw: TItem, key: ListEditorKey): string => - resolveCompactTextUtil(raw, key, { - compactField: props.compactField, - fallback: t('common.sortable.itemFallback'), - }) +const resolveCompactText = (raw: TItem): string => + resolveCompactTextUtil(raw, { compactField: props.compactField }) -const { resolveValidation } = useValidationRegistry({ - getValidationState: (item, key, index) => props.getValidationState?.(item, key, index) ?? null, -}) +// Row validation from the controller's gated `rowState` — red rail shows only +// once the row is unsaved or `validateAll()` ran. +// `editing` → the controller reads amber (not red) while the row is being filled in. (QA 85050 b7) +const resolveValidation = (raw: TItem, key?: ListEditorKey): ListEditorValidationState => + key === undefined ? null : controller.rowState(raw, key, editingKeys.value.has(key)) -// Per-key actions cache: stable identity per row, see equivalent block in -// AListEditor for rationale. Closures capture key (stable) and look up -// the current vi via findVi at call time. +// Per-key actions cache for stable per-row identity (see AListEditor). Closures +// capture the stable key and look up the current vi via findVi at call time. type ActionsBundle = { edit: () => void save: () => Promise | void @@ -1096,29 +1198,62 @@ type ActionsBundle = { moveBottom: () => void indent: () => void outdent: () => void - update: (data: TItem) => NestedTree + update: (data: TItem) => void } const actionsCache = new Map() const getActions = (key: ListEditorKey): ActionsBundle => { let actions = actionsCache.get(key) if (!actions) { actions = { - edit: () => { const vi = findVi(key); if (vi) onEditClick(vi) }, - save: () => { const vi = findVi(key); if (vi) return onSaveClick(vi) }, - cancel: () => { const vi = findVi(key); if (vi) onCancelClick(vi) }, - close: () => { const vi = findVi(key); if (vi) onCloseClick(vi) }, - delete: async () => { const vi = findVi(key); if (vi) await onDeleteClick(vi) }, - addAfter: () => { const vi = findVi(key); if (vi) onRowAddAfterClick(vi) }, - addChild: () => { const vi = findVi(key); if (vi) onAddChildClick(vi) }, - toggleExpand: () => { const vi = findVi(key); if (vi) onChevronClick(vi) }, - toggleDetail: () => { const vi = findVi(key); if (vi) onDetailToggle(vi) }, + edit: () => { + const vi = findVi(key) + if (vi) onEditClick(vi) + }, + save: () => { + const vi = findVi(key) + if (vi) return onSaveClick(vi) + }, + cancel: () => { + const vi = findVi(key) + if (vi) onCancelClick(vi) + }, + close: () => { + const vi = findVi(key) + if (vi) onCloseClick(vi) + }, + delete: async () => { + const vi = findVi(key) + if (vi) await onDeleteClick(vi) + }, + addAfter: () => { + const vi = findVi(key) + if (vi) onRowAddAfterClick(vi) + }, + addChild: () => { + const vi = findVi(key) + if (vi) onAddChildClick(vi) + }, + toggleExpand: () => { + const vi = findVi(key) + if (vi) onChevronClick(vi) + }, + toggleDetail: () => { + const vi = findVi(key) + if (vi) onDetailToggle(vi) + }, moveUp: () => moveUp(key), moveDown: () => moveDown(key), moveTop: () => moveTop(key), moveBottom: () => moveBottom(key), - indent: () => { const vi = findVi(key); if (vi) doIndent(vi) }, - outdent: () => { const vi = findVi(key); if (vi) doOutdent(vi) }, - update: (data: TItem) => editor.updateItem(key, data), + indent: () => { + const vi = findVi(key) + if (vi) doIndent(vi) + }, + outdent: () => { + const vi = findVi(key) + if (vi) doOutdent(vi) + }, + update: (data: TItem) => controller.updateItem(key, data), } actionsCache.set(key, actions) } @@ -1136,7 +1271,7 @@ watch( ) const buildSlotProps = (vi: DecoratedNestedViewItem) => ({ - item: { ...vi, validationState: resolveValidation(vi.raw as TItem, vi.key, vi.index) }, + item: { ...vi, validationState: resolveValidation(vi.raw as TItem, vi.key) }, raw: vi.raw, index: vi.index, key: vi.key, @@ -1163,13 +1298,14 @@ const buildSlotProps = (vi: DecoratedNestedViewItem) => ({ canOutdent: vi.canOutdent, canAddChild: vi.canAddChild, touch: isTouch.value, + stateKeyPrefix: rowStateScope.prefixFor(vi.key), actions: getActions(vi.key), }) const toolbarSlotProps = computed(() => ({ applying: applying.value, - hasPendingChanges: hasPendingChanges.value, - movedCount: movedCount.value, + hasPendingChanges: nestedPendingChanges.value, + movedCount: nestedPendingCount.value, error: applyError.value, actions: { apply: applyReorder, @@ -1242,21 +1378,17 @@ const moveToPositionContext = computed<{ } | null>(() => { const target = moveToPositionTarget.value if (!target) return null - const found = editor.findNode(target.key) + const found = controller.findNode(target.key) const siblings = found.parent?.children ?? modelValue.value.children - const idx = siblings.findIndex( - (s) => (s.data[props.keyField] as ListEditorKey) === target.key, - ) + const idx = siblings.findIndex((s) => keyOf(s.data) === target.key) return { - parentId: found.parent ? (found.parent.data[props.keyField] as ListEditorKey) : null, + parentId: found.parent ? keyOf(found.parent.data) : null, total: siblings.length, currentIndex: idx, } }) const moveToPositionLabel = computed(() => - moveToPositionTarget.value - ? resolveCompactText(moveToPositionTarget.value.raw, moveToPositionTarget.value.key) - : '', + moveToPositionTarget.value ? resolveCompactText(moveToPositionTarget.value.raw) : '', ) const openMoveToPosition = (vi: DecoratedNestedViewItem) => { if (!props.showMoveToPosition) return @@ -1269,7 +1401,7 @@ const onMoveToPositionConfirm = (newIndex: number) => { moveToPositionTarget.value = null if (!ctx || !target) return if (newIndex === ctx.currentIndex) return - if (editor.moveTo(target.key, ctx.parentId, newIndex)) { + if (controller.moveTo(target.key, ctx.parentId, newIndex)) { markMoved(target.key) } } @@ -1281,33 +1413,29 @@ const openChangeParent = (vi: DecoratedNestedViewItem) => { changeParentTarget.value = vi changeParentDialogOpen.value = true } -const onChangeParentConfirm = ( - parentId: ListEditorKey | null, - position: 'first' | 'last', -) => { +const onChangeParentConfirm = (parentId: ListEditorKey | null, position: 'first' | 'last') => { const target = changeParentTarget.value changeParentTarget.value = null if (!target) return - // Determine sibling count under the new parent so 'last' becomes the right - // numeric index. For 'first' the index is 0. + // Sibling count under the new parent so 'last' resolves to the right index ('first' = 0). let siblingCount = 0 if (parentId === null) { siblingCount = modelValue.value.children.length } else { - const found = editor.findNode(parentId) + const found = controller.findNode(parentId) siblingCount = found.node?.children?.length ?? 0 } const targetIndex = position === 'first' ? 0 : siblingCount - if (editor.moveTo(target.key, parentId, targetIndex)) { + if (controller.moveTo(target.key, parentId, targetIndex)) { markMoved(target.key) if (parentId !== null) childrenExpandedKeys.value.add(parentId) } } -// Aggregated display flags + helpers passed into each . Recomputed -// reactively when any underlying dependency changes. +// Aggregated display flags + helpers passed into each . const rowContext = computed(() => ({ reorderMode: reorderMode.value, + readonly: props.readonly, canInteract: canInteract.value, dragEnabled: dragEnabled.value, showExpandToggle: props.showExpandToggle, @@ -1323,13 +1451,11 @@ const rowContext = computed(() => ({ keyboardNav, isRowClickable, resolveCompactText, - resolveValidation: (raw: TItem, key?: ListEditorKey, index?: number) => - resolveValidation(raw, key, index), + resolveValidation: (raw: TItem, key?: ListEditorKey) => resolveValidation(raw, key), buildSlotProps, })) -// Event callback bundle — each handler mutates the main component's local state -// (editingKeys, expandedKeys, etc.) or delegates to the editor composable. +// Event callback bundle passed to each row. const rowCallbacks = { onRowClick, onChevronClick, @@ -1350,53 +1476,37 @@ const rowCallbacks = { openChangeParent, } -const rootViewItems = computed(() => - viewItemsDecorated.value.filter((v) => v.parentKey === null), -) +const rootViewItems = computed(() => viewItemsDecorated.value.filter((v) => v.parentKey === null)) -// Expose imperative API — mirrors legacy ASortableNested signatures for easier -// migration of admin-cms consumers (LinkedListManage calls these on the ref). -// These methods assume the caller has already persisted the change server-side, -// so they re-capture the dirty baseline — the affected items should not render -// as "unsaved" immediately after this call. -// -// All four `*ById` aliases are kept for backward-compat with the pre-Phase-7 -// `ASortableNested` API. New consumers should call `editor.addItem` / -// `editor.deleteItem` / `editor.updateItem` directly via the exposed handle -// (canonical names match the flat editors). The aliases will be removed in -// the next major. - -/** @deprecated Use `addItem(data, { afterId, childrenAllowed })` directly. */ -const addAfterId = ( - targetId: ListEditorKey | null, - data: TItem, - childrenAllowed: boolean, -) => { - const res = editor.addItem(data, { afterId: targetId ?? undefined, childrenAllowed }) - nextTick(() => captureDirtyBaseline()) - return res +// Imperative aliases mirroring the legacy ASortableNested signatures so existing +// consumers keep working. They assume the caller persists server-side, so they +// re-baseline via `controller.commit()` (rows don't render "unsaved" after the +// call) and return the live model. New consumers should use the controller handle directly. + +/** @deprecated Use `addItem(data, { afterId, childrenAllowed })` on the handle. */ +const addAfterId = (targetId: ListEditorKey | null, data: TItem, childrenAllowed: boolean) => { + controller.addItem(data, { afterId: targetId ?? undefined, childrenAllowed }) + nextTick(() => controller.commit()) + return modelValue.value } -/** @deprecated Use `addItem(data, { parentId, asFirstChild: true, childrenAllowed })`. */ -const addChildToId = ( - targetId: ListEditorKey, - data: TItem, - childrenAllowed: boolean, -) => { +/** @deprecated Use `addChild(parentKey, data, childrenAllowed)` on the handle. */ +const addChildToId = (targetId: ListEditorKey, data: TItem, childrenAllowed: boolean) => { childrenExpandedKeys.value.add(targetId) - const res = editor.addItem(data, { parentId: targetId, asFirstChild: true, childrenAllowed }) - nextTick(() => captureDirtyBaseline()) - return res + // Legacy "prepend as first child" semantic (canonical `addChild` appends to the end). + controller.addItem(data, { parentId: targetId, asFirstChild: true, childrenAllowed }) + nextTick(() => controller.commit()) + return modelValue.value } -/** @deprecated Use `deleteItem(id)` instead. */ +/** @deprecated Use `deleteItem(id)` on the handle. */ const removeById = (id: ListEditorKey) => { - editor.deleteItem(id) + controller.deleteItem(id) editingKeys.value.delete(id) editingSnapshots.value.delete(id) detailExpandedKeys.value.delete(id) childrenExpandedKeys.value.delete(id) - nextTick(() => captureDirtyBaseline()) + nextTick(() => controller.commit()) } -/** @deprecated Use `updateItem(id, data)` instead. */ +/** @deprecated Use `updateItem(id, data)` on the handle. */ const updateData = ( id: ListEditorKey, data: TItem, @@ -1404,71 +1514,99 @@ const updateData = ( _position: unknown = null, _markUnsaved: unknown = null, ) => { - editor.updateItem(id, data) - nextTick(() => captureDirtyBaseline()) + controller.updateItem(id, data) + nextTick(() => controller.commit()) } -const unsavedKeysModel = defineModel>('unsavedKeys', { - default: () => new Set(), -}) +// Total distinct unconfirmed changes (added/edited/moved/reparented rows + deferred deletions) — a +// delete counts even though its row is gone. Drives the unsaved-section label + the exposed handle. +const unsavedCount = controller.unsavedCount -const internalUnsavedKeys = computed>(() => { - const out = new Set() - for (const vi of viewItemsDecorated.value) { - if (vi.unsaved) out.add(vi.key) - } - return out -}) +// Re-baseline as saved, clear the moved set, close open edits. Legacy name kept. +const clearUnsavedState = () => { + controller.commit() + movedKeys.value = new Set() + clearEditing() +} -const { hasUnsavedChanges, unsavedCount, clearUnsavedState } = useUnsavedKeysSync({ - unsavedKeysModel, - internalUnsavedKeys, - onClearAll: () => { - captureDirtyBaseline() - movedKeys.value = new Set() - }, - onClearKey: (key) => { - rebaselineKey(key) - movedKeys.value.delete(key) - }, +// Registers this editor as a named unsaved-changes section when a label is passed. +useUnsavedSection(() => + props.unsavedSectionLabel + ? { label: props.unsavedSectionLabel, dirty: controller.hasUnsaved.value } + : [], +) + +// Expose the controller handle plus legacy aliases and reorder/expand controls. +// Entries after the spread override controller methods where the historic name or +// return shape differs (e.g. `viewItems` is this component's expand-aware list). +// Reveal for a blocked save: open invalid rows AND expand the offender's ancestor chain so a +// collapsed nested row becomes visible. Shared by the exposed validateAll + scope reveal-on-touch. +const revealNestedInvalid = (): boolean => + validateAllAndReveal(controller, (key) => { + let ancestor = controller.findNode(key).parent + while (ancestor) { + childrenExpandedKeys.value.add(keyOf(ancestor.data)) + ancestor = controller.findNode(keyOf(ancestor.data)).parent + } + const vi = controller.viewItems.value.find((v) => v.key === key) + if (vi) beginEdit(vi) + }) + +// Auto-bridge the editor's aggregate validity into the consumer's `validation-scope` collector so a +// plain `$invalid` save gate blocks (and reveals) a collapsed invalid row. No-op without the prop. +// eslint-disable-next-line vue/no-setup-props-reactivity-loss -- construction-time opt-in, read once +useListEditorScopeValidity({ + hasErrors: controller.hasErrors, + validationScope: props.validationScope, + validateProvided: props.validate !== undefined, + reveal: revealNestedInvalid, }) -defineExpose({ - addItem: editor.addItem, +defineExpose< + NestedListEditorHandle & { + addAfterId: typeof addAfterId + addChildToId: typeof addChildToId + removeById: typeof removeById + updateData: typeof updateData + resetDirtyBaseline: () => void + hasUnsavedChanges: typeof controller.hasUnsaved + unsavedCount: typeof unsavedCount + clearUnsavedState: () => void + enterReorderMode: () => void + cancelReorderMode: () => void + applyReorder: () => Promise + expand: (id: ListEditorKey) => void + collapse: (id: ListEditorKey) => void + toggleExpand: (id: ListEditorKey) => void + expandDetail: (id: ListEditorKey) => void + collapseDetail: (id: ListEditorKey) => void + } +>({ + ...controller, + validateAll: revealNestedInvalid, + // Legacy aliases (pre-v2 ASortableNested API). addAfterId, addChildToId, removeById, updateData, - updateItem: editor.updateItem, - deleteItem: editor.deleteItem, - moveUp: editor.moveUp, - moveDown: editor.moveDown, - moveTop: editor.moveTop, - moveBottom: editor.moveBottom, - indent: editor.indent, - outdent: editor.outdent, - moveTo: editor.moveTo, - recalculatePositions: editor.recalculatePositions, - viewItems: editor.viewItems, resetDirtyBaseline, - hasUnsavedChanges, + hasUnsavedChanges: controller.hasUnsaved, unsavedCount, clearUnsavedState, + // Expand-aware flattened view (controller's own `viewItems` ignores collapse state). + viewItems: flatViewItems, enterReorderMode, cancelReorderMode, applyReorder, - // Tree children visibility (expand/collapse a branch) expand: (id: ListEditorKey) => childrenExpandedKeys.value.add(id), collapse: (id: ListEditorKey) => childrenExpandedKeys.value.delete(id), toggleExpand: (id: ListEditorKey) => { if (childrenExpandedKeys.value.has(id)) childrenExpandedKeys.value.delete(id) else childrenExpandedKeys.value.add(id) }, - // Row readonly-detail body visibility expandDetail: (id: ListEditorKey) => detailExpandedKeys.value.add(id), collapseDetail: (id: ListEditorKey) => detailExpandedKeys.value.delete(id), }) -