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..8efcdfba 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@anzusystems/common-admin", - "packageManager": "yarn@4.14.1", + "packageManager": "yarn@4.18.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-1784059876", "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.70.0", + "@shikijs/vitepress-twoslash": "^4.4.3", "@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.41", + "@vue/devtools-api": "^8.2.1", + "@vue/eslint-config-typescript": "^14.9.0", + "@vue/language-server": "3.3.9", + "@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.4.0", + "@vueuse/integrations": "14.4.0", + "axios": "1.19.0", "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.1", + "eslint-plugin-oxlint": "1.78.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.3", + "oxfmt": "^0.63.0", + "oxlint": "1.78.0", + "pinia": "4.0.2", + "playwright": "^1.62.1", + "postcss": "^8.5.26", "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.41", + "vue-eslint-parser": "^10.4.1", + "vue-i18n": "11.4.8", + "vue-router": "5.2.0", + "vue-tsc": "3.3.9", + "vuetify": "4.1.8", "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..1da269ff 100644 --- a/src/components/collab/composables/collabInit.ts +++ b/src/components/collab/composables/collabInit.ts @@ -130,7 +130,19 @@ export function useCollabInit() { }, ) collabSocket.value.on('connect', async () => { + /* Memberships from before a reconnect are stale; the server does not re-announce them. + * + * The write claims behind them are deliberately left alone. Socket.io drops the + * acknowledgement of a packet it already put on the wire (`_clearAcks`), so one emitted + * before the reconnect can no longer land here — but it keeps the acknowledgement of a + * packet still in `sendBuffer`, which it delivers on this connection. Resetting the claims + * would discard exactly those: a join emitted while the socket was still connecting is + * flushed just before this handler runs, and its acknowledgement arrives a round trip + * later to find its claim gone. */ 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 +158,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/collab/composables/collabRoom.ts b/src/components/collab/composables/collabRoom.ts index 6209a35f..66eb69f9 100644 --- a/src/components/collab/composables/collabRoom.ts +++ b/src/components/collab/composables/collabRoom.ts @@ -56,8 +56,13 @@ export function useCollabRoom( fetchCachedUsers: (() => Promisify>) | undefined = undefined, disableAutoUnsubscribe = false, ) { - const { collabSocket, collabRoomInfoState, collabFieldDataBufferState, collabFieldLocksState } = - useCollabState() + const { + collabSocket, + collabRoomInfoState, + collabFieldDataBufferState, + collabFieldLocksState, + claimRoomInfoWrite, + } = useCollabState() const reconnectEventBus = useCollabReconnectEventBus() const unsubscribeCollabReconnectListener = ref() @@ -220,6 +225,9 @@ export function useCollabRoom( tryOnBeforeUnmount(() => { if (disableAutoUnsubscribe) return + if (isDefined(unsubscribeCollabReconnectListener.value)) { + unsubscribeCollabReconnectListener.value() + } if (isDefined(unsubscribeJoinRequestListener.value)) { unsubscribeJoinRequestListener.value() } @@ -250,17 +258,21 @@ export function useCollabRoom( const subscribeCollabRoomInfo = () => { if (!collabOptions.value.enabled || isUndefined(collabSocket.value)) return + const isNewestWrite = claimRoomInfoWrite(room) collabSocket.value.emit('subscribeCollabRoomInfo', room, (response: CollabRoomInfoCallback) => { + if (!isNewestWrite()) return collabRoomInfoState.set(room, response.room) }) } const unsubscribeCollabRoomInfo = () => { if (!collabOptions.value.enabled || isUndefined(collabSocket.value)) return + const isNewestWrite = claimRoomInfoWrite(room) collabSocket.value.emit( 'unsubscribeCollabRoomInfo', room, (response: CollabRoomInfoCallback) => { + if (!isNewestWrite()) return collabRoomInfoState.set(room, response.room) }, ) @@ -272,32 +284,65 @@ export function useCollabRoom( return new Promise((resolve, reject) => { if (!collabOptions.value.enabled || isUndefined(collabSocket.value)) return reject(CollabAccessRoomStatus.Failed) + const isNewestWrite = claimRoomInfoWrite(room) collabSocket.value ?.timeout(5000) .emit('joinCollabRoom', room, options, (error, response: CollabAccessRoomCallbackTypes) => { if (error) { + markRoomInactiveOnFailedClaim() + /* No cleanup leave here, though a timed-out join can leave the server holding a + * membership this client never hears about: a leave is not tied to the join it cleans up, + * so it could remove the membership of a remount that succeeded in the meantime. Marking + * inactive keeps the failure on the safe side — the stale membership is released on + * disconnect. Closing it properly needs a generation the server can compare. */ return void reject(CollabAccessRoomStatus.Failed) } if (isCollabSuccessAccessRoomCallback(response)) { - collabRoomInfoState.set(room, response.room) + if (isNewestWrite()) collabRoomInfoState.set(room, response.room) return void resolve(response.status) } + markRoomInactiveOnFailedClaim() return void reject(response.status) }) + + /** + * A failed claim writes nothing yet still suppresses older acknowledgements, so without this a + * failed join would keep showing the membership from before the leave. Recorded as an explicitly + * inactive room, never deleted: the mutation guards test `roomInfo && status === Inactive`, so a + * missing entry falls through and emits. Only while this claim is still the newest. + */ + function markRoomInactiveOnFailedClaim() { + if (isNewestWrite()) collabRoomInfoState.set(room, createDefaultCollabRoomInfo()) + } }) } - const leaveCollabRoom = () => { - if (!collabOptions.value.enabled || isUndefined(collabSocket.value)) return - collabSocket.value.emit('leaveCollabRoom', room, (response: CollabAccessRoomCallbackTypes) => { - if (isCollabSuccessAccessRoomCallback(response)) { - collabRoomInfoState.set(room, response.room) - } + /** + * Resolves once the server has acknowledged the leave, so a caller that re-joins the same room can + * serialise the two. Never rejects: every existing caller invokes it without handling the result, + * mostly from unmount hooks. A missing ack resolves on the timeout rather than hanging. + */ + const leaveCollabRoom = (): Promise => { + return new Promise((resolve) => { + if (!collabOptions.value.enabled || isUndefined(collabSocket.value)) return void resolve() + const isNewestWrite = claimRoomInfoWrite(room) + collabSocket.value + ?.timeout(5000) + .emit('leaveCollabRoom', room, (error, response: CollabAccessRoomCallbackTypes) => { + if (!error && isNewestWrite() && isCollabSuccessAccessRoomCallback(response)) { + collabRoomInfoState.set(room, response.room) + } + resolve() + }) }) } const enteredCollabRoom = () => { if (!collabOptions.value.enabled || isUndefined(collabSocket.value)) return + /* Anything remembered from a previous stay in this room is a guess: the server answers with the + * locks it holds only when it holds some, so a lock released while this client was away produces + * no event and the field would read as locked until some later one arrives. */ + collabFieldLocksState.set(room, new Map()) collabSocket.value?.emit('enteredCollabRoom', room) } diff --git a/src/components/collab/composables/collabState.ts b/src/components/collab/composables/collabState.ts index f362179e..78ba36dc 100644 --- a/src/components/collab/composables/collabState.ts +++ b/src/components/collab/composables/collabState.ts @@ -18,6 +18,9 @@ const collabSocket: Ref< Socket | undefined > = ref() const collabRoomInfoState = reactive(new Map()) +// Plain, not reactive: bookkeeping for the map above, nothing renders from it. +let collabRoomInfoWriteCounter = 0 +const collabRoomInfoWriteSeq = new Map() const collabFieldLocksState = reactive(new Map>()) const collabFieldDataBufferState = reactive( new Map>(), @@ -40,11 +43,29 @@ export function useCollabState() { return dataBuffer } + /** + * Call before emitting anything whose acknowledgement writes `collabRoomInfoState`, and let the + * returned predicate decide whether that write still applies. + * + * The server serialises join and leave per room only for the lifetime of its lease, so a leave that + * outruns it can acknowledge after a following join and mark a room inactive while the client is in + * it — after which the client goes quiet with nothing visible to show for it. + */ + const claimRoomInfoWrite = (room: CollabRoom) => { + /* Global and never restarting, so a number is never handed out twice. Claims outlive a + * reconnect on purpose — see the `connect` handler in `collabInit.ts`. */ + const seq = ++collabRoomInfoWriteCounter + collabRoomInfoWriteSeq.set(room, seq) + + return () => collabRoomInfoWriteSeq.get(room) === seq + } + return { collabReconnecting, collabConnected, collabSocket, collabRoomInfoState, + claimRoomInfoWrite, collabFieldLocksState, collabFieldDataBufferState, gatherBufferData, 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/datetime/ADatetimePicker.vue b/src/components/datetime/ADatetimePicker.vue index 196b6f30..4be10581 100644 --- a/src/components/datetime/ADatetimePicker.vue +++ b/src/components/datetime/ADatetimePicker.vue @@ -232,7 +232,14 @@ const onTextFieldBlur = () => { const modelDate = dayjs(props.modelValue, 'YYYY-MM-DDTHH:mm:ss.SSSSSSZ') if (modelDate.isValid()) seconds = modelDate.second() } - datetimeInternal.value = parsed.second(seconds) + const reparsed = parsed.second(seconds) + // A day click blurs this field before the picker emits, so re-assigning an unchanged datetime + // would push the stale date back into it. By second, as `lastMinuteMoment` holds ms at 999. + if (reparsed.isSame(toRaw(datetimeInternal.value), 'second')) { + textFieldValue.value = reparsed.format(displayFormat.value) + } else { + datetimeInternal.value = reparsed + } v$.value.textFieldValue.$touch() emit('blur') return 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/flags/FlagSk.vue b/src/components/flags/FlagSk.vue index 76868b82..59a42df2 100644 --- a/src/components/flags/FlagSk.vue +++ b/src/components/flags/FlagSk.vue @@ -1,5 +1,5 @@