+
@@ -150,29 +239,29 @@ SPDX-License-Identifier: Apache-2.0
+
+
diff --git a/modules/web/web/components/field/ChoyPropertiesDefinitionSelectionEditor.test.ts b/modules/web/web/components/field/ChoyPropertiesDefinitionSelectionEditor.test.ts
index 12522be93..5d612829c 100644
--- a/modules/web/web/components/field/ChoyPropertiesDefinitionSelectionEditor.test.ts
+++ b/modules/web/web/components/field/ChoyPropertiesDefinitionSelectionEditor.test.ts
@@ -111,13 +111,16 @@ describe('ChoyPropertiesDefinitionSelectionEditor', () => {
mounted.unmount();
});
- test('readonly hides add/remove/translate controls', async () => {
+ test('readonly hides add/remove/translate controls and uses display text', async () => {
const mounted = mountEditor([{ value: 'x', label: 'X' }], { readonly: true });
await nextTick();
expect(mounted.q('[data-testid="choy-properties-definition-selection-add"]')).toBeFalsy();
expect(mounted.q('[data-testid="choy-properties-definition-selection-remove"]')).toBeFalsy();
expect(mounted.q('[data-testid="choy-properties-definition-selection-translate"]')).toBeFalsy();
expect(mounted.q('[data-testid="choy-properties-definition-selection-handle"]')).toBeFalsy();
+ expect(mounted.q('input')).toBeFalsy();
+ expect(mounted.q('[data-testid="choy-properties-definition-selection-value"]')?.textContent).toBe('x');
+ expect(mounted.q('[data-testid="choy-properties-definition-selection-label"]')?.textContent).toBe('X');
mounted.unmount();
});
diff --git a/modules/web/web/components/field/ChoyPropertiesDefinitionSelectionEditor.vue b/modules/web/web/components/field/ChoyPropertiesDefinitionSelectionEditor.vue
index 112cf9757..0b3fdc865 100644
--- a/modules/web/web/components/field/ChoyPropertiesDefinitionSelectionEditor.vue
+++ b/modules/web/web/components/field/ChoyPropertiesDefinitionSelectionEditor.vue
@@ -15,12 +15,12 @@ SPDX-License-Identifier: Apache-2.0
- |
+ |
{{ _t('No options') }}
|
@@ -49,25 +49,33 @@ SPDX-License-Identifier: Apache-2.0
+ {{ displayOrDash(row.value) }}
|
-
+ {{ displayOrDash(row.label) }}
+
|
-
+ |
{
ChoyMessage.error = origError;
}
});
+
+ test('cancelHandler runs via exposed cancel()', async () => {
+ const cancelHandler = fnRecorder(async () => {});
+ const { router } = createFeStubRouter({
+ route: { name: 'login', path: '/web/login', fullPath: '/web/login', params: {}, query: {}, meta: {} },
+ });
+ const wrapper = mountApp(FormView as any, {
+ props: {
+ store: loginStore(),
+ viewMode: 'create',
+ embedded: true,
+ showHeader: false,
+ showActions: false,
+ showMessages: false,
+ resolveRecordIdFromRoute: false,
+ initialValues: { Username: 'admin' },
+ cancelHandler,
+ },
+ plugins: [createPinia(), router, NoopLoading],
+ slots: {
+ default: () => h(SlotSubmit()),
+ },
+ });
+ try {
+ await flushPromises();
+ expect(typeof wrapper.root.cancel).toBe('function');
+ await wrapper.root.cancel();
+ await flushPromises();
+ expect(cancelHandler.calls.length).toBe(1);
+ } finally {
+ wrapper.unmount();
+ }
+ });
});
diff --git a/modules/web/web/components/view/ChoyFormView.vue b/modules/web/web/components/view/ChoyFormView.vue
index 7051f6591..e2968f573 100644
--- a/modules/web/web/components/view/ChoyFormView.vue
+++ b/modules/web/web/components/view/ChoyFormView.vue
@@ -230,6 +230,11 @@ const props = withDefaults(
onchangeDebounceMs?: number;
onchangeImmediateFirst?: boolean;
submitHandler?: FormSubmitHandler;
+ /**
+ * Optional cancel override (after dirty confirm). Dialog / non-route embedded
+ * hosts use this instead of create→router.back or edit→beginDisplay.
+ */
+ cancelHandler?: () => void | Promise;
resolveRecordIdFromRoute?: boolean;
/** Opt out of page IO action-target registration (embedded views pass false). */
registerActionTarget?: boolean;
@@ -240,6 +245,7 @@ const props = withDefaults(
onchangeDebounceMs: 150,
onchangeImmediateFirst: false,
submitHandler: undefined,
+ cancelHandler: undefined,
// Keep omitted as undefined (not false) so matching page-store views auto-register.
registerActionTarget: undefined,
}
@@ -427,7 +433,12 @@ const {
handleCopy,
handleDelete,
watchFormIdentity,
-} = useFormViewLifecycle(lifecycleDeps);
+} = useFormViewLifecycle({
+ ...lifecycleDeps,
+ get cancelHandler() {
+ return props.cancelHandler;
+ },
+});
const { submitting, handleSubmit, onNativeSubmit } = useFormViewSubmit({
controller,
@@ -507,6 +518,8 @@ onBeforeUnmount(() => {
defineExpose({
submit: handleSubmit,
+ /** Runs dirty confirmation, then cancelHandler / route back / display reload. */
+ cancel: handleCancel,
refresh: handleRefresh,
reset: handleReset,
copy: handleCopy,
diff --git a/modules/web/web/composables/useFormViewLifecycle.test.ts b/modules/web/web/composables/useFormViewLifecycle.test.ts
index 34307159d..a0d096449 100644
--- a/modules/web/web/composables/useFormViewLifecycle.test.ts
+++ b/modules/web/web/composables/useFormViewLifecycle.test.ts
@@ -74,6 +74,7 @@ type MountOpts = {
viewMode?: ViewMode;
onAfterInit?: () => void;
watchIdentity?: boolean;
+ cancelHandler?: () => void | Promise;
};
function mountLifecycle(opts: MountOpts = {}) {
@@ -127,6 +128,7 @@ function mountLifecycle(opts: MountOpts = {}) {
emitCancelable,
emit,
_t: (k: string) => k,
+ cancelHandler: opts.cancelHandler,
});
if (opts.watchIdentity !== false) {
api.watchFormIdentity(opts.onAfterInit);
@@ -328,6 +330,23 @@ describe('useFormViewLifecycle', () => {
m.unmount();
});
+ test('handleCancel prefers cancelHandler over router.back', async () => {
+ const cancelHandler = fnRecorder(async () => {});
+ const m = mountLifecycle({
+ watchIdentity: false,
+ resolveRecordIdFromRoute: false,
+ viewMode: 'create',
+ identity: { viewMode: 'create', recordId: undefined },
+ cancelHandler,
+ });
+ const back = fnRecorder(async () => {});
+ m.router.back = back as any;
+ await m.api().handleCancel();
+ expect(cancelHandler.calls.length).toBe(1);
+ expect(back.calls.length).toBe(0);
+ m.unmount();
+ });
+
test('handleCancel in create mode calls router.back', async () => {
const m = mountLifecycle({ viewMode: 'create', watchIdentity: false });
const back = fnRecorder(() => {});
diff --git a/modules/web/web/composables/useFormViewLifecycle.ts b/modules/web/web/composables/useFormViewLifecycle.ts
index 714682caa..12e9d3d05 100644
--- a/modules/web/web/composables/useFormViewLifecycle.ts
+++ b/modules/web/web/composables/useFormViewLifecycle.ts
@@ -42,6 +42,11 @@ export type UseFormViewLifecycleOpts = {
emit: (event: string, payload?: any) => void;
_t: (key: string, ...args: any[]) => string;
fieldsMetadata?: () => Record | undefined;
+ /**
+ * When set (e.g. dialog hosts), runs after dirty confirm instead of create→router.back /
+ * edit→beginDisplay. Used by embedded FormViews that are not route-backed.
+ */
+ cancelHandler?: () => void | Promise;
};
function normalizeRecordId(raw: unknown): string | undefined {
@@ -227,6 +232,11 @@ export function useFormViewLifecycle(opts: UseFormViewLifec
try {
opts.onchangeCtrl.reset();
opts.resetOnchangeAgg();
+ if (opts.cancelHandler) {
+ createBaseline.value = null;
+ await opts.cancelHandler();
+ return;
+ }
if (opts.viewMode.value === 'create') {
await awaitCreateCancelBack();
return;
diff --git a/modules/web/web/menu/menus.ts b/modules/web/web/menu/menus.ts
index 9f0afc4b7..2e2c52831 100644
--- a/modules/web/web/menu/menus.ts
+++ b/modules/web/web/menu/menus.ts
@@ -11,7 +11,8 @@ const { _lt } = createTranslate('web', { scope: 'web/menu/menus' });
/**
* Platform shell menus. Domain modules attach their own roots; Settings is the
* cross-cutting configuration mount point. Each installed application registers
- * Settings → {app} → Translations; web hosts core + web here.
+ * Settings → {app} → Translations / Record Properties; web hosts core
+ * terminology and web's own entries here. core has no PropertyDefinition store.
*/
export const menus: MenuItem[] = [
defineMenu('web.menu.settings', {
@@ -45,6 +46,12 @@ export const menus: MenuItem[] = [
sequence: 10,
defaultRoles: ['sys.admin'],
}),
+ defineMenu('web.menu.property_definitions', {
+ title: _lt('Record Properties'),
+ path: '/web/property-definitions/web',
+ sequence: 20,
+ defaultRoles: ['sys.admin'],
+ }),
],
}),
],
diff --git a/modules/web/web/pages/PropertyDefinition.mount.test.ts b/modules/web/web/pages/PropertyDefinition.mount.test.ts
new file mode 100644
index 000000000..4c9f210d8
--- /dev/null
+++ b/modules/web/web/pages/PropertyDefinition.mount.test.ts
@@ -0,0 +1,38 @@
+// SPDX-FileCopyrightText: 2026-present Brian Wang
+// SPDX-License-Identifier: Apache-2.0
+
+import * as VueRouter from 'vue-router';
+const createFeStubRouter = (VueRouter as any).createFeStubRouter;
+import { createPinia, setActivePinia } from 'pinia';
+import { createI18n } from 'vue-i18n';
+import { flushPromises, mountApp, stub } from '@/web/web/__tests__/mountApp';
+import { replaceStoreFactory } from '@/web/web/stores/registry';
+import PropertyDefinition from './PropertyDefinition.vue';
+
+test('PropertyDefinition.vue wires usePropertyDefinitionDetailPage into ChoyPage', async () => {
+ setActivePinia(createPinia());
+ const restore = replaceStoreFactory('web.PropertyDefinition', () => ({ id: 'web-pd' }));
+ const { router } = createFeStubRouter({
+ route: {
+ path: '/web/property-definitions/web/1',
+ fullPath: '/web/property-definitions/web/1',
+ params: { application: 'web', id: '1' },
+ meta: {},
+ },
+ });
+ const i18n = createI18n({ legacy: false, locale: 'en', messages: { en: {} } });
+ try {
+ const mounted = mountApp(PropertyDefinition as any, {
+ plugins: [router, i18n],
+ stubs: {
+ ChoyPage: stub('ChoyPage'),
+ PropertyDefinitionFormView: stub('PropertyDefinitionFormView'),
+ },
+ });
+ await flushPromises();
+ expect(mounted.root).toBeTruthy();
+ mounted.unmount();
+ } finally {
+ restore();
+ }
+});
diff --git a/modules/web/web/pages/PropertyDefinition.vue b/modules/web/web/pages/PropertyDefinition.vue
new file mode 100644
index 000000000..4172adb6b
--- /dev/null
+++ b/modules/web/web/pages/PropertyDefinition.vue
@@ -0,0 +1,48 @@
+
+
+
+
+
+
+
+
+
diff --git a/modules/web/web/pages/PropertyDefinitionList.mount.test.ts b/modules/web/web/pages/PropertyDefinitionList.mount.test.ts
new file mode 100644
index 000000000..f05431afa
--- /dev/null
+++ b/modules/web/web/pages/PropertyDefinitionList.mount.test.ts
@@ -0,0 +1,38 @@
+// SPDX-FileCopyrightText: 2026-present Brian Wang
+// SPDX-License-Identifier: Apache-2.0
+
+import * as VueRouter from 'vue-router';
+const createFeStubRouter = (VueRouter as any).createFeStubRouter;
+import { createPinia, setActivePinia } from 'pinia';
+import { createI18n } from 'vue-i18n';
+import { flushPromises, mountApp, stub } from '@/web/web/__tests__/mountApp';
+import { replaceStoreFactory } from '@/web/web/stores/registry';
+import PropertyDefinitionList from './PropertyDefinitionList.vue';
+
+test('PropertyDefinitionList.vue wires usePropertyDefinitionListPage into ChoyPage', async () => {
+ setActivePinia(createPinia());
+ const restore = replaceStoreFactory('auth.PropertyDefinition', () => ({ id: 'auth-pd' }));
+ const { router } = createFeStubRouter({
+ route: {
+ path: '/web/property-definitions/auth',
+ fullPath: '/web/property-definitions/auth',
+ params: {},
+ meta: { propertyDefinitionApplication: 'auth' },
+ },
+ });
+ const i18n = createI18n({ legacy: false, locale: 'en', messages: { en: {} } });
+ try {
+ const mounted = mountApp(PropertyDefinitionList as any, {
+ plugins: [router, i18n],
+ stubs: {
+ ChoyPage: stub('ChoyPage'),
+ PropertyDefinitionListView: stub('PropertyDefinitionListView'),
+ },
+ });
+ await flushPromises();
+ expect(mounted.root).toBeTruthy();
+ mounted.unmount();
+ } finally {
+ restore();
+ }
+});
diff --git a/modules/web/web/pages/PropertyDefinitionList.vue b/modules/web/web/pages/PropertyDefinitionList.vue
new file mode 100644
index 000000000..a2a0df3de
--- /dev/null
+++ b/modules/web/web/pages/PropertyDefinitionList.vue
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+
+
+
diff --git a/modules/web/web/pages/property_definition_detail_page.test.ts b/modules/web/web/pages/property_definition_detail_page.test.ts
new file mode 100644
index 000000000..184a88b45
--- /dev/null
+++ b/modules/web/web/pages/property_definition_detail_page.test.ts
@@ -0,0 +1,39 @@
+// SPDX-FileCopyrightText: 2026-present Brian Wang
+// SPDX-License-Identifier: Apache-2.0
+
+import { effectScope } from 'vue';
+import { usePropertyDefinitionDetailPage } from './property_definition_detail_page';
+
+test('usePropertyDefinitionDetailPage binds and clears store from application param', () => {
+ const created: string[] = [];
+ const scope = effectScope();
+ const api = scope.run(() =>
+ usePropertyDefinitionDetailPage({
+ route: {
+ fullPath: '/web/property-definitions/web/1',
+ params: { application: 'web', id: '1' },
+ } as any,
+ scopeManager: { id: 'menu' },
+ createStore: ((model: string) => {
+ created.push(model);
+ return { model } as any;
+ }) as any,
+ })
+ )!;
+ expect(api.application.value).toBe('web');
+ expect(created).toEqual(['web.PropertyDefinition']);
+ expect(api.definitionStore.value).toEqual({ model: 'web.PropertyDefinition' });
+ scope.stop();
+
+ const emptyScope = effectScope();
+ const empty = emptyScope.run(() =>
+ usePropertyDefinitionDetailPage({
+ route: { fullPath: '/x', params: { application: ' ' } } as any,
+ scopeManager: {},
+ createStore: (() => ({})) as any,
+ })
+ )!;
+ expect(empty.application.value).toBe('');
+ expect(empty.definitionStore.value).toBeNull();
+ emptyScope.stop();
+});
diff --git a/modules/web/web/pages/property_definition_detail_page.ts b/modules/web/web/pages/property_definition_detail_page.ts
new file mode 100644
index 000000000..73d38b308
--- /dev/null
+++ b/modules/web/web/pages/property_definition_detail_page.ts
@@ -0,0 +1,43 @@
+// SPDX-FileCopyrightText: 2026-present Brian Wang
+// SPDX-License-Identifier: Apache-2.0
+
+import { computed, shallowRef, watch, type Ref } from 'vue';
+import type { RouteLocationNormalizedLoaded } from 'vue-router';
+import { createStoreByModel } from '@/web/web/stores/registry';
+import type { WebModelStore } from '@/web/web/stores/modelStore';
+import { resolvePropertyDefinitionDetailApplication } from './property_definition_page_bind';
+
+export type PropertyDefinitionDetailPageDeps = {
+ route: RouteLocationNormalizedLoaded;
+ scopeManager: unknown;
+ createStore?: typeof createStoreByModel;
+};
+
+/**
+ * Create/detail page bindings: resolve application and PropertyDefinition store.
+ */
+export function usePropertyDefinitionDetailPage(deps: PropertyDefinitionDetailPageDeps): {
+ application: Ref;
+ definitionStore: Ref | null>;
+} {
+ const createStore = deps.createStore ?? createStoreByModel;
+ const application = computed(() => resolvePropertyDefinitionDetailApplication(deps.route));
+ const definitionStore = shallowRef | null>(null);
+
+ watch(
+ application,
+ (app) => {
+ if (!app) {
+ definitionStore.value = null;
+ return;
+ }
+ definitionStore.value = createStore(`${app}.PropertyDefinition`, {
+ storeId: `PropertyDefinition_${app}`,
+ scopeManager: deps.scopeManager,
+ }) as WebModelStore;
+ },
+ { immediate: true }
+ );
+
+ return { application, definitionStore };
+}
diff --git a/modules/web/web/pages/property_definition_list_page.test.ts b/modules/web/web/pages/property_definition_list_page.test.ts
new file mode 100644
index 000000000..2dbc73380
--- /dev/null
+++ b/modules/web/web/pages/property_definition_list_page.test.ts
@@ -0,0 +1,48 @@
+// SPDX-FileCopyrightText: 2026-present Brian Wang
+// SPDX-License-Identifier: Apache-2.0
+
+import { effectScope } from 'vue';
+import { usePropertyDefinitionListPage } from './property_definition_list_page';
+
+test('usePropertyDefinitionListPage binds store from meta application and clears when empty', () => {
+ const created: Array<{ model: string; storeId: string }> = [];
+ const route: any = {
+ fullPath: '/web/property-definitions/auth',
+ path: '/web/property-definitions/auth',
+ params: {},
+ meta: { propertyDefinitionApplication: 'auth' },
+ };
+ const scope = effectScope();
+ const api = scope.run(() =>
+ usePropertyDefinitionListPage({
+ route,
+ scopeManager: { id: 'menu' },
+ createStore: ((model: string, opts?: any) => {
+ created.push({ model, storeId: String(opts?.storeId || '') });
+ return { model } as any;
+ }) as any,
+ translate: (msgid, arg) => `${msgid}:${arg}`,
+ })
+ )!;
+
+ expect(api.application.value).toBe('auth');
+ expect(api.pageTitle.value).toContain('auth');
+ expect(created).toEqual([
+ { model: 'auth.PropertyDefinition', storeId: 'PropertyDefinition_auth' },
+ ]);
+ expect(api.definitionStore.value).toEqual({ model: 'auth.PropertyDefinition' });
+
+ scope.stop();
+ const emptyScope = effectScope();
+ const empty = emptyScope.run(() =>
+ usePropertyDefinitionListPage({
+ route: { fullPath: '/settings', path: '/settings', params: {}, meta: {} } as any,
+ scopeManager: {},
+ createStore: (() => ({})) as any,
+ translate: (msgid, arg) => `${msgid}:${arg}`,
+ })
+ )!;
+ expect(empty.application.value).toBe('');
+ expect(empty.definitionStore.value).toBeNull();
+ emptyScope.stop();
+});
diff --git a/modules/web/web/pages/property_definition_list_page.ts b/modules/web/web/pages/property_definition_list_page.ts
new file mode 100644
index 000000000..a33f5f00c
--- /dev/null
+++ b/modules/web/web/pages/property_definition_list_page.ts
@@ -0,0 +1,56 @@
+// SPDX-FileCopyrightText: 2026-present Brian Wang
+// SPDX-License-Identifier: Apache-2.0
+
+import { computed, shallowRef, watch, type Ref } from 'vue';
+import type { RouteLocationNormalizedLoaded } from 'vue-router';
+import { createStoreByModel } from '@/web/web/stores/registry';
+import { createTranslate } from '@/web/web/i18n';
+import type { WebModelStore } from '@/web/web/stores/modelStore';
+import { resolvePropertyDefinitionListApplication } from './property_definition_page_bind';
+
+const { _t: defaultTranslate } = createTranslate('web', {
+ scope: 'web/pages/PropertyDefinitionList',
+});
+
+export type PropertyDefinitionListPageDeps = {
+ route: RouteLocationNormalizedLoaded;
+ scopeManager: unknown;
+ createStore?: typeof createStoreByModel;
+ translate?: (msgid: string, ...args: unknown[]) => string;
+};
+
+/**
+ * List page bindings: resolve application, title, and PropertyDefinition store.
+ */
+export function usePropertyDefinitionListPage(deps: PropertyDefinitionListPageDeps): {
+ application: Ref;
+ pageTitle: Ref;
+ definitionStore: Ref | null>;
+} {
+ const createStore = deps.createStore ?? createStoreByModel;
+
+ const application = computed(() => resolvePropertyDefinitionListApplication(deps.route));
+ const pageTitle = computed(() => {
+ const appLabel = application.value || '—';
+ if (deps.translate) {
+ return deps.translate('Record Properties: %s', appLabel);
+ }
+ return String(defaultTranslate('Record Properties: %s', appLabel));
+ });
+ const definitionStore = shallowRef | null>(null);
+
+ function bindStore(app: string) {
+ if (!app) {
+ definitionStore.value = null;
+ return;
+ }
+ definitionStore.value = createStore(`${app}.PropertyDefinition`, {
+ storeId: `PropertyDefinition_${app}`,
+ scopeManager: deps.scopeManager,
+ }) as WebModelStore;
+ }
+
+ watch(application, (app) => bindStore(app), { immediate: true });
+
+ return { application, pageTitle, definitionStore };
+}
diff --git a/modules/web/web/pages/property_definition_page_bind.test.ts b/modules/web/web/pages/property_definition_page_bind.test.ts
new file mode 100644
index 000000000..8237f7abb
--- /dev/null
+++ b/modules/web/web/pages/property_definition_page_bind.test.ts
@@ -0,0 +1,39 @@
+// SPDX-FileCopyrightText: 2026-present Brian Wang
+// SPDX-License-Identifier: Apache-2.0
+
+import {
+ resolvePropertyDefinitionDetailApplication,
+ resolvePropertyDefinitionListApplication,
+} from './property_definition_page_bind';
+
+test('resolvePropertyDefinitionListApplication prefers meta then param then path', () => {
+ expect(
+ resolvePropertyDefinitionListApplication({
+ meta: { propertyDefinitionApplication: 'auth' },
+ params: { application: 'web' },
+ path: '/web/property-definitions/base',
+ })
+ ).toBe('auth');
+ expect(
+ resolvePropertyDefinitionListApplication({
+ meta: {},
+ params: { application: 'partner' },
+ path: '/web/property-definitions/base',
+ })
+ ).toBe('partner');
+ expect(
+ resolvePropertyDefinitionListApplication({
+ meta: {},
+ params: {},
+ path: '/web/property-definitions/sandbox',
+ })
+ ).toBe('sandbox');
+ expect(resolvePropertyDefinitionListApplication({ meta: {}, params: {}, path: '/x' })).toBe('');
+});
+
+test('resolvePropertyDefinitionDetailApplication reads params.application', () => {
+ expect(resolvePropertyDefinitionDetailApplication({ params: { application: 'meta' } })).toBe(
+ 'meta'
+ );
+ expect(resolvePropertyDefinitionDetailApplication({ params: { application: ' ' } })).toBe('');
+});
diff --git a/modules/web/web/pages/property_definition_page_bind.ts b/modules/web/web/pages/property_definition_page_bind.ts
new file mode 100644
index 000000000..f92c494f4
--- /dev/null
+++ b/modules/web/web/pages/property_definition_page_bind.ts
@@ -0,0 +1,25 @@
+// SPDX-FileCopyrightText: 2026-present Brian Wang
+// SPDX-License-Identifier: Apache-2.0
+
+/** Resolve the PropertyDefinition host application for a list route. */
+export function resolvePropertyDefinitionListApplication(route: {
+ meta?: Record | null;
+ params?: Record | null;
+ path?: string;
+}): string {
+ const fromMeta = String(
+ (route.meta as { propertyDefinitionApplication?: string } | null)?.propertyDefinitionApplication || ''
+ ).trim();
+ if (fromMeta) return fromMeta;
+ const fromParam = String(route.params?.application || '').trim();
+ if (fromParam) return fromParam;
+ const m = String(route.path || '').match(/\/web\/property-definitions\/([^/]+)/);
+ return String(m?.[1] || '').trim();
+}
+
+/** Resolve the PropertyDefinition host application for create/detail routes. */
+export function resolvePropertyDefinitionDetailApplication(route: {
+ params?: Record | null;
+}): string {
+ return String(route.params?.application || '').trim();
+}
diff --git a/modules/web/web/route/index.ts b/modules/web/web/route/index.ts
index c3284a341..5863425b6 100644
--- a/modules/web/web/route/index.ts
+++ b/modules/web/web/route/index.ts
@@ -3,6 +3,10 @@
import type { Router } from 'vue-router';
import { terminologyListComponent, terminologyRoutes } from './terminologyRoutes';
+import {
+ propertyDefinitionListComponent,
+ propertyDefinitionRoutes,
+} from './propertyDefinitionRoutes';
/** Register Settings → {app} → Translations pages under the authenticated shell layout. */
export function registerTerminologyRoutes(router: Router): void {
@@ -11,4 +15,16 @@ export function registerTerminologyRoutes(router: Router): void {
}
}
-export { terminologyListComponent, terminologyRoutes };
+/** Register Settings → {app} → Record Properties pages under the authenticated shell layout. */
+export function registerPropertyDefinitionRoutes(router: Router): void {
+ for (const route of propertyDefinitionRoutes) {
+ router.addRoute('AppLayout', route);
+ }
+}
+
+export {
+ terminologyListComponent,
+ terminologyRoutes,
+ propertyDefinitionListComponent,
+ propertyDefinitionRoutes,
+};
diff --git a/modules/web/web/route/propertyDefinitionRoutes.ts b/modules/web/web/route/propertyDefinitionRoutes.ts
new file mode 100644
index 000000000..602dd15ec
--- /dev/null
+++ b/modules/web/web/route/propertyDefinitionRoutes.ts
@@ -0,0 +1,75 @@
+// SPDX-FileCopyrightText: 2026-present Brian Wang
+// SPDX-License-Identifier: Apache-2.0
+
+import type { RouteRecordRaw } from 'vue-router';
+import { defineRoute } from '@/core/web/resource';
+import { createTranslate } from '@/web/web/i18n';
+// Side-effect: register web.action.property_definition_* UI resources.
+import '../views/property_definition_actions';
+
+const { _lt } = createTranslate('web', { scope: 'web/route/propertyDefinitionRoutes' });
+
+/**
+ * Shared PropertyDefinition list page. Domain modules mount their own list
+ * defineRoute with a literal path so UI_VAL_006 can match menu paths.
+ */
+export const propertyDefinitionListComponent = () => import('../pages/PropertyDefinitionList.vue');
+
+const propertyDefinitionFormComponent = () => import('../pages/PropertyDefinition.vue');
+
+/**
+ * web hosts the web list route plus shared create/detail routes.
+ * Other apps declare list routes in their own modules. core is excluded
+ * (PropertyDefinition is not injected for application=core).
+ *
+ * actions must be string-literal arrays (UI_DECL_008); ids match
+ * defineModelActions('web.PropertyDefinition').
+ */
+export const propertyDefinitionRoutes: RouteRecordRaw[] = [
+ defineRoute('web.route.property_definition_list', {
+ sequence: 50,
+ title: _lt('Record Properties'),
+ path: 'web/property-definitions/web',
+ name: 'PropertyDefinitionList_web',
+ component: propertyDefinitionListComponent,
+ actions: [
+ 'web.action.property_definition_create',
+ 'web.action.property_definition_edit',
+ 'web.action.property_definition_delete',
+ 'web.action.property_definition_copy',
+ ],
+ defaultRoles: ['sys.admin'],
+ meta: { requiresAuth: true, propertyDefinitionApplication: 'web' },
+ }),
+ // Register /new before /:id so "new" is not captured as an id.
+ defineRoute('web.route.property_definition_create', {
+ sequence: 51,
+ title: _lt('New Property Definition'),
+ path: 'web/property-definitions/:application/new',
+ name: 'PropertyDefinitionCreate',
+ component: propertyDefinitionFormComponent,
+ actions: [
+ 'web.action.property_definition_create',
+ 'web.action.property_definition_edit',
+ 'web.action.property_definition_delete',
+ 'web.action.property_definition_copy',
+ ],
+ defaultRoles: ['sys.admin'],
+ meta: { requiresAuth: true },
+ }),
+ defineRoute('web.route.property_definition_detail', {
+ sequence: 52,
+ title: _lt('Property Definition'),
+ path: 'web/property-definitions/:application/:id',
+ name: 'PropertyDefinitionDetail',
+ component: propertyDefinitionFormComponent,
+ actions: [
+ 'web.action.property_definition_create',
+ 'web.action.property_definition_edit',
+ 'web.action.property_definition_delete',
+ 'web.action.property_definition_copy',
+ ],
+ defaultRoles: ['sys.admin'],
+ meta: { requiresAuth: true },
+ }),
+];
diff --git a/modules/web/web/route/property_definition_route.test.ts b/modules/web/web/route/property_definition_route.test.ts
new file mode 100644
index 000000000..82c543d49
--- /dev/null
+++ b/modules/web/web/route/property_definition_route.test.ts
@@ -0,0 +1,36 @@
+// SPDX-FileCopyrightText: 2026-present Brian Wang
+// SPDX-License-Identifier: Apache-2.0
+
+import {
+ propertyDefinitionListComponent,
+ propertyDefinitionRoutes,
+} from './propertyDefinitionRoutes';
+
+test('propertyDefinitionListComponent is exported for domain list routes', () => {
+ expect(typeof propertyDefinitionListComponent).toBe('function');
+});
+
+test('propertyDefinitionRoutes host web list plus create/detail; no core', () => {
+ const names = propertyDefinitionRoutes.map(r => String(r.name));
+ expect(names).toEqual([
+ 'PropertyDefinitionList_web',
+ 'PropertyDefinitionCreate',
+ 'PropertyDefinitionDetail',
+ ]);
+
+ const webList = propertyDefinitionRoutes.find(
+ r => r.name === 'PropertyDefinitionList_web'
+ ) as any;
+ expect(webList.path).toBe('web/property-definitions/web');
+ expect(webList.meta?.propertyDefinitionApplication).toBe('web');
+ expect(webList.meta?.resource?.defaultRoles).toEqual(['sys.admin']);
+ expect(webList.component).toBe(propertyDefinitionListComponent);
+
+ const create = propertyDefinitionRoutes.find(r => r.name === 'PropertyDefinitionCreate') as any;
+ expect(create.path).toBe('web/property-definitions/:application/new');
+
+ const detail = propertyDefinitionRoutes.find(r => r.name === 'PropertyDefinitionDetail') as any;
+ expect(detail.path).toBe('web/property-definitions/:application/:id');
+
+ expect(names.some(n => n.includes('core'))).toBe(false);
+});
diff --git a/modules/web/web/stores/localFormStore.test.ts b/modules/web/web/stores/localFormStore.test.ts
index 4cacb23f8..179844f4c 100644
--- a/modules/web/web/stores/localFormStore.test.ts
+++ b/modules/web/web/stores/localFormStore.test.ts
@@ -184,6 +184,54 @@ test('createLocalFormStore rejects duplicate or empty field names', () => {
expect(() => createLocalFormStore({ fields: [null as any] })).toThrow(/must be an object/);
});
+test('createLocalFormStore pure-scalar types set defaults and selection meta', () => {
+ const store = createLocalFormStore({
+ fields: [
+ { name: 'Title', label: 'Title', type: 'char', size: 32 },
+ { name: 'Body', label: 'Body', type: 'text' },
+ { name: 'Kind', label: 'Kind', type: 'selection', selection: [{ value: 'a', label: 'A' }] },
+ { name: 'Count', label: 'Count', type: 'integer' },
+ { name: 'Big', label: 'Big', type: 'bigint' },
+ { name: 'Amt', label: 'Amt', type: 'float' },
+ { name: 'Day', label: 'Day', type: 'date' },
+ { name: 'At', label: 'At', type: 'datetime' },
+ { name: 'Clock', label: 'Clock', type: 'time' },
+ { name: 'Html', label: 'Html', type: 'html' },
+ { name: 'Payload', label: 'Payload', type: 'jsonobject' },
+ ],
+ });
+
+ expect(store.getField('Title')).toBe('');
+ expect(store.getFieldMeta('Title')?.size).toBe(32);
+ expect(store.getFieldMeta('Title')?.typeAnnotation).toBe('string');
+ expect(store.getField('Body')).toBe('');
+ expect(store.getField('Kind')).toBeNull();
+ expect(store.getFieldMeta('Kind')?.selectionKind).toBe('static');
+ expect(store.getFieldMeta('Kind')?.selection).toEqual([{ value: 'a', label: 'A' }]);
+ expect(store.getField('Count')).toBeNull();
+ expect(store.getFieldMeta('Count')?.typeAnnotation).toBe('number');
+ expect(store.getField('Big')).toBeNull();
+ expect(store.getFieldMeta('Big')?.typeAnnotation).toBe('string');
+ expect(store.getField('Amt')).toBeNull();
+ expect(store.getField('Day')).toBeNull();
+ expect(store.getField('At')).toBeNull();
+ expect(store.getField('Clock')).toBeNull();
+ expect(store.getField('Html')).toBe('');
+ expect(store.getField('Payload')).toEqual({});
+ expect(store.getFieldMeta('Payload')?.typeAnnotation).toBe('object');
+});
+
+test('createLocalFormStore selection requires a non-empty selection list', () => {
+ expect(() =>
+ createLocalFormStore({ fields: [{ name: 'Kind', label: 'Kind', type: 'selection' }] })
+ ).toThrow(/requires a non-empty selection/);
+ expect(() =>
+ createLocalFormStore({
+ fields: [{ name: 'Kind', label: 'Kind', type: 'selection', selection: [] }],
+ })
+ ).toThrow(/requires a non-empty selection/);
+});
+
describe('createLocalFormStore + ChoyFormView', () => {
beforeEach(() => {
setActivePinia(createPinia());
diff --git a/modules/web/web/stores/localFormStore.ts b/modules/web/web/stores/localFormStore.ts
index 593d600fb..7f5e104a0 100644
--- a/modules/web/web/stores/localFormStore.ts
+++ b/modules/web/web/stores/localFormStore.ts
@@ -2,22 +2,43 @@
// SPDX-License-Identifier: Apache-2.0
/**
- * In-memory form store for pages with no ORM model (login/register drafts).
+ * In-memory form store for pages with no ORM model (login/register drafts,
+ * PropertyDefinition item dialogs, and similar pure-scalar wizards).
* Satisfies the WebModelStore slice that useField / ChoyFieldBase / ChoyFormView
* read (storeId, fieldsMetadata, getFieldMeta). Does not call gRPC.
+ *
+ * Pure scalars only. Relation / monetary / binary / properties / statusbar and
+ * pluggable adapters belong in a follow-up wizard store design.
*/
import { reactive } from 'vue';
import { createEmptyQueryState } from '@/web/web/query/state';
-import type { WebFieldMetadata, WebModelStore } from '@/web/web/stores/modelStore';
+import type { SelectionItem, WebFieldMetadata, WebModelStore } from '@/web/web/stores/modelStore';
-export type LocalFormFieldType = 'varchar' | 'boolean';
+export type LocalFormFieldType =
+ | 'varchar'
+ | 'char'
+ | 'text'
+ | 'boolean'
+ | 'selection'
+ | 'integer'
+ | 'bigint'
+ | 'float'
+ | 'date'
+ | 'datetime'
+ | 'time'
+ | 'html'
+ | 'jsonobject';
export type LocalFormFieldDef = {
name: string;
label: string;
type: LocalFormFieldType;
help?: string;
+ /** Required when type is selection (static options for ChoySelectionField). */
+ selection?: readonly SelectionItem[];
+ /** Optional char/varchar length hint written to meta.size. */
+ size?: number;
};
export type CreateLocalFormStoreOptions = {
@@ -26,16 +47,57 @@ export type CreateLocalFormStoreOptions = {
storeId?: string;
};
-const LOCAL_FIELD_TYPES = new Set(['varchar', 'boolean']);
+const LOCAL_FIELD_TYPES = new Set([
+ 'varchar',
+ 'char',
+ 'text',
+ 'boolean',
+ 'selection',
+ 'integer',
+ 'bigint',
+ 'float',
+ 'date',
+ 'datetime',
+ 'time',
+ 'html',
+ 'jsonobject',
+]);
let localFormStoreSeq = 0;
function defaultValueForType(type: LocalFormFieldType): unknown {
- return type === 'boolean' ? false : '';
+ switch (type) {
+ case 'boolean':
+ return false;
+ case 'integer':
+ case 'bigint':
+ case 'float':
+ case 'selection':
+ case 'date':
+ case 'datetime':
+ case 'time':
+ return null;
+ case 'jsonobject':
+ return {};
+ default:
+ return '';
+ }
}
function typeAnnotationFor(type: LocalFormFieldType): string {
- return type === 'boolean' ? 'boolean' : 'string';
+ switch (type) {
+ case 'boolean':
+ return 'boolean';
+ case 'integer':
+ case 'float':
+ return 'number';
+ case 'bigint':
+ return 'string';
+ case 'jsonobject':
+ return 'object';
+ default:
+ return 'string';
+ }
}
function unsupported(method: string): () => never {
@@ -65,14 +127,35 @@ function buildFieldsMetadata(fields: readonly LocalFormFieldDef[]): Record ({
+ value: String(item.value),
+ label: String(item.label ?? item.value),
+ }));
+ entry.selectionKind = 'static';
+ }
+ if (
+ (field.type === 'char' || field.type === 'varchar') &&
+ typeof field.size === 'number' &&
+ Number.isInteger(field.size) &&
+ field.size > 0
+ ) {
+ entry.size = field.size;
+ }
+ meta[name] = entry;
}
return meta;
}
diff --git a/modules/sandbox/web/views/SandboxPropertyDefinitionFormView.vue b/modules/web/web/views/PropertyDefinitionFormView.vue
similarity index 86%
rename from modules/sandbox/web/views/SandboxPropertyDefinitionFormView.vue
rename to modules/web/web/views/PropertyDefinitionFormView.vue
index ba969cdc4..6c8b4778a 100644
--- a/modules/sandbox/web/views/SandboxPropertyDefinitionFormView.vue
+++ b/modules/web/web/views/PropertyDefinitionFormView.vue
@@ -74,15 +74,16 @@ import {
ChoyVarcharField,
type ChoyViewMode as ViewMode,
} from '@/web';
-import { sandboxPropertyDefinitionActions } from './sandbox_property_definition_actions';
+import { propertyDefinitionActions } from './property_definition_actions';
-defineOptions({ name: 'SandboxPropertyDefinitionFormView', inheritAttrs: true });
-const { _t } = createTranslate('sandbox', { scope: 'web/views/SandboxPropertyDefinitionFormView' });
+defineOptions({ name: 'PropertyDefinitionFormView', inheritAttrs: true });
+const { _t } = createTranslate('web', { scope: 'web/views/PropertyDefinitionFormView' });
const requiredRules = computed(() => [{ required: true, message: _t('Required') }]);
const props = withDefaults(
defineProps<{
store?: WebModelStore;
+ application: string;
recordId?: string;
initialValues?: Record;
viewMode?: ViewMode;
@@ -92,11 +93,10 @@ const props = withDefaults(
{
showHeader: true,
createAction: undefined,
- },
+ }
);
-const propertyDefinitionActions = sandboxPropertyDefinitionActions;
const { hasAction } = usePermission();
-const store = resolvePageStore(props.store, 'SandboxPropertyDefinitionFormView');
+const store = resolvePageStore(props.store, 'PropertyDefinitionFormView');
const { recordId, initialValues, viewMode, showHeader, createAction } = props;
diff --git a/modules/sandbox/web/views/SandboxPropertyDefinitionListView.vue b/modules/web/web/views/PropertyDefinitionListView.vue
similarity index 75%
rename from modules/sandbox/web/views/SandboxPropertyDefinitionListView.vue
rename to modules/web/web/views/PropertyDefinitionListView.vue
index 0980e4f2a..45f1f32b1 100644
--- a/modules/sandbox/web/views/SandboxPropertyDefinitionListView.vue
+++ b/modules/web/web/views/PropertyDefinitionListView.vue
@@ -9,7 +9,7 @@ SPDX-License-Identifier: Apache-2.0
v-bind="$attrs"
:store="store"
:searchView="ChoySearchView"
- create-action="/sandbox/configuration/record-properties/new"
+ :create-action="createPath"
:action-ids="{
create: propertyDefinitionActions.create,
delete: propertyDefinitionActions.delete,
@@ -53,6 +53,7 @@ SPDX-License-Identifier: Apache-2.0
|