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..929c03af 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@anzusystems/common-admin", - "packageManager": "yarn@4.14.1", + "packageManager": "yarn@4.17.0", "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-1782072599", "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,67 @@ }, "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.9", + "@sentry/vue": "^10.62.0", + "@shikijs/vitepress-twoslash": "^4.3.0", "@stylistic/eslint-plugin": "^5.10.0", "@tsconfig/node22": "^22.0.5", - "@types/node": "^24.12.2", + "@types/node": "^24.13.2", "@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.7", + "@vitest/browser": "^4.1.9", + "@vitest/browser-playwright": "^4.1.9", + "@vitest/ui": "^4.1.9", + "@vue/compiler-dom": "3.5.39", + "@vue/eslint-config-typescript": "^14.9.0", + "@vue/language-server": "3.3.6", + "@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.2.1", + "@vueuse/integrations": "14.2.1", + "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.6.0", + "eslint-plugin-oxlint": "1.72.0", + "eslint-plugin-vue": "10.9.2", "eslint-plugin-vuetify": "^2.7.2", - "npm-run-all2": "^8.0.4", - "oxfmt": "^0.47.0", - "oxlint": "1.62.0", + "npm-run-all2": "^9.0.2", + "oxfmt": "^0.57.0", + "oxlint": "1.72.0", "pinia": "3.0.4", - "playwright": "^1.59.1", - "postcss": "^8.5.12", + "playwright": "^1.61.1", + "postcss": "^8.5.16", "postcss-html": "^1.8.1", "postcss-prefix-selector": "^2.1.1", "rusha": "^0.8.14", - "sass": "1.99.0", + "sass": "1.101.0", "socket.io-client": "4.8.3", "sortablejs": "^1.15.7", - "stylelint": "17.9.1", + "stylelint": "17.14.0", "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.9", + "vue": "3.5.39", + "vue-eslint-parser": "^10.4.1", + "vue-i18n": "11.4.6", + "vue-router": "5.1.0", + "vue-tsc": "3.3.6", + "vuetify": "4.1.2", "webfontloader": "^1.6.28" }, "peerDependencies": { 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/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..c23701af 100644 --- a/src/components/form/AFormValueObjectOptionsSelect.vue +++ b/src/components/form/AFormValueObjectOptionsSelect.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<{ @@ -149,6 +151,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/useCachedItem.ts b/src/composables/system/useCachedItem.ts index 5969422a..6445d05b 100644 --- a/src/composables/system/useCachedItem.ts +++ b/src/composables/system/useCachedItem.ts @@ -1,22 +1,13 @@ -import { type ShallowRef, nextTick, shallowRef, watch } from 'vue' -import { isUndefined } from '@/utils/common' +import { type ComputedRef, computed } from 'vue' 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() + return value && value._loaded !== false ? value : undefined + }) + const loaded = computed(() => cached.value !== undefined) return { cached, loaded } } diff --git a/src/labs.ts b/src/labs.ts index 15a9439c..aec43f96 100644 --- a/src/labs.ts +++ b/src/labs.ts @@ -3,11 +3,13 @@ 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 ADatatableOrdering from '@/labs/filters/ADatatableOrdering.vue' import ADatatablePagination from '@/labs/filters/ADatatablePagination.vue' @@ -48,14 +50,36 @@ import ANestedSortableListEditor from '@/labs/listEditor/ANestedSortableListEdit import AUnsavedConfirmDialog from '@/labs/unsavedGuard/AUnsavedConfirmDialog.vue' import { useUnsavedChangesGuard } from '@/labs/unsavedGuard/useUnsavedChangesGuard' 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 UseListEditorControllerOptions, + type ListEditorChanges, + type ListEditorValidationResult, + type GetKey, + type PositionOption, +} from '@/labs/listEditor/composables/useListEditorController' import { useListEditorItemValidation, ListEditorValidationKey, type ListEditorValidationRegistry, } from '@/labs/listEditor/composables/useListEditorItemValidation' +import { + useListEditorVuelidateSentinel, + type VuelidateSentinelSource, +} from '@/labs/listEditor/composables/useListEditorVuelidateSentinel' +import { + renumberPositions, + sortByPosition, + sortByPositionDeep, + type RenumberPositionsOptions, +} from '@/labs/listEditor/utils/positions' +import { nextListEditorTempId } from '@/labs/listEditor/utils/tempId' import { useNestedUnsavedKeys, type UseNestedUnsavedKeysApi, @@ -70,6 +94,12 @@ import { type NestedListEditorApi, type NestedViewItem, } from '@/labs/listEditor/composables/useNestedListEditor' +import { + useNestedListEditorController, + type NestedListEditorHandle, + type UseNestedListEditorControllerOptions, + type NestedListEditorChanges, +} from '@/labs/listEditor/composables/useNestedListEditorController' import type { ListEditorKey, ListEditorValidationState, @@ -99,6 +129,7 @@ import { export { // V2 FILTERS AFilterWrapper, + AFilterWrapperSidebar, AFilterWrapperSubjectSelect, AFilterBooleanSelect, AFilterDatetimePicker, @@ -116,6 +147,7 @@ export { ADatatablePagination, DatatablePaginationKey, AFormRemoteAutocomplete, + AFormRemoteAutocompleteWithCached, createFilter, createFilterStore, useFilterHelpers, @@ -141,10 +173,27 @@ export { ANestedSortableListEditor, AUnsavedConfirmDialog, useUnsavedChangesGuard, + useUnsavedSection, + type UnsavedSectionDescriptor, + type UnsavedSectionSource, useListEditor, + useListEditorController, + type ListEditorHandle, + type UseListEditorControllerOptions, + type ListEditorChanges, + type ListEditorValidationResult, + type GetKey, + type PositionOption, useListEditorItemValidation, ListEditorValidationKey, type ListEditorValidationRegistry, + useListEditorVuelidateSentinel, + type VuelidateSentinelSource, + renumberPositions, + sortByPosition, + sortByPositionDeep, + type RenumberPositionsOptions, + nextListEditorTempId, useNestedUnsavedKeys, type UseNestedUnsavedKeysApi, type ReorderModeValue, @@ -154,6 +203,10 @@ export { useNestedListEditor, type NestedListEditorApi, type NestedViewItem, + useNestedListEditorController, + type NestedListEditorHandle, + 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..fd9a47ef --- /dev/null +++ b/src/labs/filters/AFilterWrapperSidebar.vue @@ -0,0 +1,45 @@ + + + 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..ca069a30 100644 --- a/src/labs/listEditor/AListEditor.vue +++ b/src/labs/listEditor/AListEditor.vue @@ -3,13 +3,18 @@ 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 PositionOption, +} from '@/labs/listEditor/composables/useListEditorController' 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 LeDeleteDialog from '@/labs/listEditor/internal/LeDeleteDialog.vue' import LeEmptyState from '@/labs/listEditor/internal/LeEmptyState.vue' import LeUnsavedLabel from '@/labs/listEditor/internal/LeUnsavedLabel.vue' @@ -17,7 +22,6 @@ import type { ListEditorKey, ListEditorValidationState, ListViewItem, - PositionHint, } from '@/labs/listEditor/types/listEditorTypes' export interface DecoratedViewItem extends ListViewItem { @@ -25,12 +29,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 +42,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,11 +54,7 @@ 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 actions: RowActions @@ -76,10 +75,37 @@ 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 } + /** + * 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 + /** + * 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 readonly?: boolean disabled?: boolean @@ -100,33 +126,42 @@ 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 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, + editor: undefined, readonly: false, disabled: false, loading: false, @@ -142,21 +177,21 @@ const props = withDefaults(defineProps>(), { showAddAfterAction: false, addLabel: null, emptyTitle: null, - emptyText: null, disableRowClick: false, disableDeleteConfirm: false, + disableUnsaved: false, deleteConfirmTitle: null, deleteConfirmText: null, 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 +223,90 @@ 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): this editor owns one unless a lifted `:editor` is passed. +// `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 controller = + props.editor ?? + useListEditorController({ + 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, + }) + +// 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 */ +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 }) + const expandedKeys = ref>(new Set()) const rowsContainer = useTemplateRef('rowsContainer') @@ -204,15 +314,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 +326,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,9 +337,10 @@ 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'), ) @@ -253,49 +353,40 @@ const canAdd = computed(() => canInteract.value && props.showAddButton) const headerVisible = computed(() => !!(props.title || slots.header)) -// 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 + const validationState = controller.rowState(vi.raw, vi.key) 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 +397,8 @@ const viewItemsDecorated = computed[]>(() => { expanded, loading, dirty, + unsaved, + validationState, } decoratorCache.set(vi.key, decorated) next.push(decorated) @@ -337,32 +430,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 +477,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 +508,12 @@ 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`. + controller.deleteItem(vi.key) emit('deleted', vi) }, disableDeleteConfirm: () => props.disableDeleteConfirm || props.chips, @@ -453,11 +542,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 +553,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 +607,38 @@ 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, actions: getActions(vi.key), }) -const unsavedKeysModel = defineModel>('unsavedKeys', { - default: () => new Set(), -}) - -const internalUnsavedKeys = computed>(() => { - const out = new Set() - for (const vi of viewItemsDecorated.value) { - if (vi.dirty) out.add(vi.key) - } - return out -}) - -const { hasUnsavedChanges, unsavedCount, clearUnsavedState } = useUnsavedKeysSync({ - unsavedKeysModel, - internalUnsavedKeys, - onClearAll: () => captureDirtyBaseline(), - onClearKey: (key) => rebaselineKey(key), -}) +// Named unsaved-changes section, registered only when a label is passed. +useUnsavedSection(() => + props.unsavedSectionLabel && !props.disableUnsaved + ? { label: props.unsavedSectionLabel, dirty: controller.hasUnsaved.value } + : [], +) -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, + // 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 via the helper. + validateAll: () => + validateAllAndReveal(controller, (key) => { + const vi = viewItems.value.find((v) => v.key === key) + if (vi) beginEdit(vi) + }), }) @@ -618,7 +714,6 @@ defineExpose({ > +
-
- - - {{ resolveCompactText(vi.raw, vi.key) }} - - - -
-
- - + - {{ vi.raw[statusField] }} - - -
+ + {{ resolveCompactText(vi.raw) }} + + + +
-
- - - - - - - -
-
- -
{ edit: () => void save: () => Promise | void @@ -92,7 +91,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 } @@ -155,10 +154,39 @@ 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 } + /** 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 + /** + * Opt-in lifted controller (`useNestedListEditorController()`) so state + * survives unmount/remount; omitted = internal controller. Same handle + * reachable via `useTemplateRef`. + */ + editor?: NestedListEditorHandle readonly?: boolean disabled?: boolean @@ -179,15 +207,8 @@ 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 @@ -206,13 +227,20 @@ 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, + editor: undefined, readonly: false, disabled: false, loading: false, @@ -228,10 +256,8 @@ const props = withDefaults(defineProps>(), { showMoveToPosition: false, showChangeParent: false, showExpandToggle: true, - getValidationState: undefined, addLabel: null, emptyTitle: null, - emptyText: null, disableRowClick: false, disableDeleteConfirm: false, deleteConfirmTitle: null, @@ -285,13 +311,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 +324,165 @@ 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, - parentField: props.parentField, - positionMultiplier: props.positionMultiplier, - maxDepth: props.maxDepth, - expandedKeys: childrenExpandedKeys, -}) - -// 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) - } +// State controller (v2). Default: this editor owns it; opt-in `:editor` lift lets +// state survive unmount/remount. 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 controller = + props.editor ?? + useNestedListEditorController({ + 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, + maxDepth: props.maxDepth, + dirtyExclude: () => props.dirtyExclude ?? [], + validate: props.validate, + }) +/* eslint-enable vue/no-setup-props-reactivity-loss */ + +// 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) - return out - }, - { excludeFields: [props.positionField, props.parentField] }, -) + } + walk(modelValue.value.children, 0, null) + return flat +}) -// 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()) +// 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 +507,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 +517,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 +533,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 +548,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)) } }, }) @@ -539,9 +580,7 @@ const { canEnterReorder, onEnter: () => { 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() @@ -567,13 +606,12 @@ const { }) const canAdd = computed(() => canInteract.value && props.showAddButton && !reorderMode.value) -const dragEnabled = computed( - () => reorderMode.value && !isTouch.value && !props.disableDrag, -) +const dragEnabled = computed(() => reorderMode.value && !isTouch.value && !props.disableDrag) -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'), ) @@ -583,23 +621,18 @@ const deleteConfirmTextResolved = computed( 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 +641,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 +662,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 +692,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 +712,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 +730,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 +759,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 +777,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 +793,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 +803,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 +816,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 +836,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 +858,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 +874,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 +888,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 +909,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 +925,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 +948,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 +967,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 +977,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 +993,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,7 +1017,6 @@ 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) @@ -1040,20 +1047,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 +1067,21 @@ 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. +const resolveValidation = (raw: TItem, key?: ListEditorKey): ListEditorValidationState => + key === undefined ? null : controller.rowState(raw, 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 +1098,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 +1171,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, @@ -1242,21 +1277,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 +1300,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 +1312,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 +1350,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 +1375,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 +1413,89 @@ 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(), -}) +// Count of unsaved rows (controller dirty ∪ reorder-session moved) — drives the unsaved-section label. +const unsavedCount = computed( + () => viewItemsDecorated.value.filter((v) => v.unsaved).length, +) -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: unsavedCount.value > 0 } + : [], +) -defineExpose({ - addItem: editor.addItem, +// 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). +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() opens invalid rows so a blocked save surfaces which are wrong + // (B4-17 reveal; `...controller` alone only flips the red rail). Nested also + // expands the offender's ancestor chain so a collapsed row becomes visible. + validateAll: () => + 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) + }), + // 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), }) -