From e5a0d7ece5b68a010750fad0049a3673d63680b9 Mon Sep 17 00:00:00 2001 From: Brian Wang Date: Wed, 28 Oct 2026 11:03:19 +0800 Subject: [PATCH 01/23] feat(orm): store PropertyDefinition labels as lang maps ResolveProperties unwraps string/selection labels for the request language so schema chrome can ship without TranslationTerm scopes. --- modules/core/service/orm/model/index.ts | 7 + .../orm/model/properties_label_i18n.test.ts | 124 +++++++++++ .../orm/model/properties_label_i18n.ts | 197 ++++++++++++++++++ .../orm/model/properties_resolve.test.ts | 42 ++++ .../service/orm/model/properties_resolve.ts | 6 +- .../service/orm/model/properties_types.ts | 32 ++- 6 files changed, 404 insertions(+), 4 deletions(-) create mode 100644 modules/core/service/orm/model/properties_label_i18n.test.ts create mode 100644 modules/core/service/orm/model/properties_label_i18n.ts diff --git a/modules/core/service/orm/model/index.ts b/modules/core/service/orm/model/index.ts index 3fb6c6c61..0a121bb95 100644 --- a/modules/core/service/orm/model/index.ts +++ b/modules/core/service/orm/model/index.ts @@ -24,8 +24,15 @@ export { normalizePropertiesMap, isPlainPropertiesMap, type PropertyItemDefinition, + type PropertyLabel, type ResolvedPropertyItem, } from './properties_types'; +export { + mergePropertyLabelWrite, + mergeSelectionLabelsWrite, + translatePropertyDefinitionItems, + unwrapPropertyLabel, +} from './properties_label_i18n'; export { lookupPropertyDefinitionModel, __setLookupPropertyDefinitionModelForTest, diff --git a/modules/core/service/orm/model/properties_label_i18n.test.ts b/modules/core/service/orm/model/properties_label_i18n.test.ts new file mode 100644 index 000000000..71da5aba6 --- /dev/null +++ b/modules/core/service/orm/model/properties_label_i18n.test.ts @@ -0,0 +1,124 @@ +// SPDX-FileCopyrightText: 2026-present Brian Wang +// SPDX-License-Identifier: Apache-2.0 + +import { + mergePropertyLabelWrite, + mergeSelectionLabelsWrite, + normalizePropertyLabelForStorage, + parsePropertyLabelMap, + translatePropertyDefinitionItems, + unwrapPropertyLabel, +} from './properties_label_i18n'; +import { assertValidPropertyDefinitionItems } from './properties_types'; + +test('parsePropertyLabelMap treats plain string as en_US', () => { + expect(parsePropertyLabelMap('Priority')).toEqual({ en_US: 'Priority' }); + expect(parsePropertyLabelMap({ en_US: 'Priority', zh_CN: '优先级' })).toEqual({ + en_US: 'Priority', + zh_CN: '优先级', + }); + expect(parsePropertyLabelMap(null)).toBeNull(); + expect(parsePropertyLabelMap(['x'])).toBeNull(); +}); + +test('unwrapPropertyLabel falls back to en_US and keeps explicit empty', () => { + expect(unwrapPropertyLabel({ en_US: 'Priority', zh_CN: '优先级' }, 'zh_CN')).toBe('优先级'); + expect(unwrapPropertyLabel({ en_US: 'Priority' }, 'zh_CN')).toBe('Priority'); + expect(unwrapPropertyLabel({ en_US: 'Priority', zh_CN: '' }, 'zh_CN')).toBe(''); + expect(unwrapPropertyLabel('Priority', 'zh_CN')).toBe('Priority'); + expect(unwrapPropertyLabel(undefined, 'zh_CN')).toBe(''); +}); + +test('normalizePropertyLabelForStorage keeps string and cleans maps', () => { + expect(normalizePropertyLabelForStorage('Priority')).toBe('Priority'); + expect(normalizePropertyLabelForStorage({ en_US: 'Priority' })).toBe('Priority'); + expect(normalizePropertyLabelForStorage({ en_US: 'Priority', zh_CN: '优先级' })).toEqual({ + en_US: 'Priority', + zh_CN: '优先级', + }); + expect(() => normalizePropertyLabelForStorage({ 'zh-CN': 'x' })).toThrow(/terminology code/); + expect(() => normalizePropertyLabelForStorage({ en_US: { nested: 1 } as any })).toThrow(/must be a string/); +}); + +test('mergePropertyLabelWrite preserves other langs and seeds en_US', () => { + expect(mergePropertyLabelWrite({ en_US: 'Priority', zh_CN: '旧' }, '优先级', 'zh_CN')).toEqual({ + en_US: 'Priority', + zh_CN: '优先级', + }); + expect(mergePropertyLabelWrite(undefined, '优先级', 'zh_CN')).toEqual({ + en_US: '优先级', + zh_CN: '优先级', + }); + expect(mergePropertyLabelWrite('Priority', '优先级', 'zh_CN')).toEqual({ + en_US: 'Priority', + zh_CN: '优先级', + }); +}); + +test('mergeSelectionLabelsWrite merges by option value', () => { + const merged = mergeSelectionLabelsWrite( + [ + ['high', { en_US: 'High', zh_CN: '高' }], + ['low', 'Low'], + ], + [ + { value: 'high', label: '很高' }, + { value: 'mid', label: '中' }, + ], + 'zh_CN' + ); + expect(merged).toEqual([ + ['high', { en_US: 'High', zh_CN: '很高' }], + ['mid', { en_US: '中', zh_CN: '中' }], + ]); +}); + +test('translatePropertyDefinitionItems unwraps string and selection labels only', () => { + const items = translatePropertyDefinitionItems( + [ + { + name: 'priority', + type: 'selection', + string: { en_US: 'Priority', zh_CN: '优先级' }, + selection: [ + ['high', { en_US: 'High', zh_CN: '高' }], + { value: 'low', label: { en_US: 'Low', zh_CN: '低' } }, + ], + default: 'high', + }, + ], + 'zh_CN' + ); + expect(items[0].string).toBe('优先级'); + expect(items[0].default).toBe('high'); + expect(items[0].selection).toEqual([ + ['high', '高'], + { value: 'low', label: '低' }, + ]); +}); + +test('assertValidPropertyDefinitionItems accepts lang-map labels', () => { + const out = assertValidPropertyDefinitionItems([ + { + name: 'priority', + type: 'selection', + string: { en_US: 'Priority', zh_CN: '优先级' }, + selection: [['high', { en_US: 'High', zh_CN: '高' }]], + default: 'high', + }, + { + name: 'code', + type: 'char', + string: 'External Code', + }, + ]); + expect(out[0].string).toEqual({ en_US: 'Priority', zh_CN: '优先级' }); + expect(out[0].selection).toEqual([['high', { en_US: 'High', zh_CN: '高' }]]); + expect(out[1].string).toBe('External Code'); +}); + +test('assertValidPropertyDefinitionItems rejects invalid label maps', () => { + expect(() => + assertValidPropertyDefinitionItems([{ name: 'x', type: 'char', string: { en_US: { bad: 1 } as any } }]) + ).toThrow(/must be a string/); +}); diff --git a/modules/core/service/orm/model/properties_label_i18n.ts b/modules/core/service/orm/model/properties_label_i18n.ts new file mode 100644 index 000000000..60601d763 --- /dev/null +++ b/modules/core/service/orm/model/properties_label_i18n.ts @@ -0,0 +1,197 @@ +// SPDX-FileCopyrightText: 2026-present Brian Wang +// SPDX-License-Identifier: Apache-2.0 + +import { + TRANSLATED_BASE_LANG, + assertTranslatedLangKey, + isTranslatedLangMap, + unwrapTranslatedValue, +} from '../repository/projection/translated_field_codec'; +import type { PropertyItemDefinition, PropertyLabel } from './properties_types'; + +function hasOwn(map: object, key: string): boolean { + return Object.prototype.hasOwnProperty.call(map, key); +} + +/** Parse a stored property label into a lang map (plain string → `{ en_US: s }`). */ +export function parsePropertyLabelMap(value: unknown): Record | null { + if (value == null) return null; + if (typeof value === 'string') { + return { [TRANSLATED_BASE_LANG]: value }; + } + if (!isTranslatedLangMap(value)) return null; + const out: Record = {}; + for (const [rawKey, rawVal] of Object.entries(value)) { + const lang = String(rawKey || '').trim(); + if (!lang || lang.includes('-')) continue; + if (rawVal == null) { + out[lang] = ''; + continue; + } + if (typeof rawVal === 'string' || typeof rawVal === 'number' || typeof rawVal === 'boolean') { + out[lang] = String(rawVal); + } + } + return Object.keys(out).length ? out : null; +} + +/** + * Normalize a label for Definition write storage. + * Plain strings are kept; lang maps are cleaned (string values only). + * Returns `undefined` when the label should be omitted. + */ +export function normalizePropertyLabelForStorage(value: unknown, fieldHint = 'string'): PropertyLabel | undefined { + if (value == null) return undefined; + if (typeof value === 'string') { + return value; + } + if (!isTranslatedLangMap(value) || Array.isArray(value)) { + throw new Error(`PropertyDefinition label "${fieldHint}" must be a string or lang map`); + } + const out: Record = {}; + for (const [rawKey, rawVal] of Object.entries(value)) { + const lang = assertTranslatedLangKey(rawKey, fieldHint); + if (rawVal == null) { + out[lang] = ''; + continue; + } + if (typeof rawVal !== 'string' && typeof rawVal !== 'number' && typeof rawVal !== 'boolean') { + throw new Error(`PropertyDefinition label "${fieldHint}" lang map value for "${lang}" must be a string`); + } + out[lang] = String(rawVal); + } + if (!Object.keys(out).length) return undefined; + // Single en_US-only map collapses to plain string for compact storage. + const keys = Object.keys(out); + if (keys.length === 1 && keys[0] === TRANSLATED_BASE_LANG) { + return out[TRANSLATED_BASE_LANG]; + } + return out; +} + +/** Unwrap a stored label for a request language (missing → en_US → ''). */ +export function unwrapPropertyLabel(label: unknown, lang: string): string { + const map = parsePropertyLabelMap(label); + if (!map) return ''; + const unwrapped = unwrapTranslatedValue(map, lang); + return unwrapped == null ? '' : unwrapped; +} + +/** + * Merge a current-language label write into a previous PropertyLabel. + * Preserves other language keys; seeds en_US on first write when missing. + */ +export function mergePropertyLabelWrite( + previous: unknown, + currentLangValue: string, + lang: string, +): PropertyLabel { + const key = assertTranslatedLangKey(lang, 'string'); + const current = String(currentLangValue ?? ''); + const prevMap = parsePropertyLabelMap(previous) || {}; + const next: Record = { ...prevMap, [key]: current }; + if (!hasOwn(next, TRANSLATED_BASE_LANG)) { + next[TRANSLATED_BASE_LANG] = current; + } + return normalizePropertyLabelForStorage(next, 'string') ?? current; +} + +function selectionOptionValue(opt: unknown): string | null { + if (Array.isArray(opt) && typeof opt[0] === 'string') return opt[0]; + if (opt && typeof opt === 'object' && !Array.isArray(opt) && typeof (opt as { value?: unknown }).value === 'string') { + return String((opt as { value: string }).value); + } + return null; +} + +function selectionOptionLabel(opt: unknown): unknown { + if (Array.isArray(opt)) return opt[1] ?? opt[0]; + if (opt && typeof opt === 'object' && !Array.isArray(opt)) { + return (opt as { label?: unknown }).label ?? (opt as { value?: unknown }).value; + } + return undefined; +} + +/** Normalize selection options; labels may be string or lang map. */ +export function normalizeSelectionLabelsForStorage( + selection: unknown, + itemName: string +): PropertyItemDefinition['selection'] { + if (!Array.isArray(selection)) { + throw new Error(`PropertyDefinition.Definition item "${itemName}" selection options are invalid`); + } + const out: NonNullable = []; + for (let i = 0; i < selection.length; i++) { + const opt = selection[i]; + const value = selectionOptionValue(opt); + if (value == null) { + throw new Error(`PropertyDefinition.Definition item "${itemName}" selection options are invalid`); + } + const rawLabel = selectionOptionLabel(opt); + const label = + rawLabel === undefined || rawLabel === null + ? value + : (normalizePropertyLabelForStorage(rawLabel, `${itemName}.selection.${value}`) ?? value); + if (Array.isArray(opt)) { + out.push([value, label] as [string, PropertyLabel]); + } else { + out.push({ value, label }); + } + } + return out; +} + +/** + * Merge current-lang selection labels into previous options (by value). + * Used by the definition editor so saving does not wipe other languages. + */ +export function mergeSelectionLabelsWrite( + previous: PropertyItemDefinition['selection'] | undefined, + incoming: Array<{ value: string; label: string }>, + lang: string +): PropertyItemDefinition['selection'] { + const prevByValue = new Map(); + for (const opt of previous || []) { + const value = selectionOptionValue(opt); + if (value == null) continue; + prevByValue.set(value, selectionOptionLabel(opt)); + } + return incoming.map(opt => { + const prevLabel = prevByValue.get(opt.value); + const merged = mergePropertyLabelWrite(prevLabel, opt.label, lang); + return [opt.value, merged] as [string, PropertyLabel]; + }); +} + +/** + * Expand item `string` / selection labels to the request language. + * Returns a new array; does not mutate input. Selection values are unchanged. + */ +export function translatePropertyDefinitionItems( + items: PropertyItemDefinition[], + lang: string +): PropertyItemDefinition[] { + const code = String(lang || '').trim() || TRANSLATED_BASE_LANG; + return (items || []).map(item => { + const next: PropertyItemDefinition = { ...item }; + if (Object.prototype.hasOwnProperty.call(item, 'string')) { + next.string = unwrapPropertyLabel(item.string, code); + } + if (Array.isArray(item.selection)) { + next.selection = item.selection.map(opt => { + if (Array.isArray(opt) && typeof opt[0] === 'string') { + const value = opt[0]; + const label = unwrapPropertyLabel(opt[1] ?? value, code) || value; + return [value, label] as [string, string]; + } + if (opt && typeof opt === 'object' && !Array.isArray(opt) && typeof (opt as { value?: unknown }).value === 'string') { + const value = String((opt as { value: string }).value); + const label = unwrapPropertyLabel((opt as { label?: unknown }).label ?? value, code) || value; + return { value, label }; + } + return opt; + }) as PropertyItemDefinition['selection']; + } + return next; + }); +} diff --git a/modules/core/service/orm/model/properties_resolve.test.ts b/modules/core/service/orm/model/properties_resolve.test.ts index 334126973..4d560ff46 100644 --- a/modules/core/service/orm/model/properties_resolve.test.ts +++ b/modules/core/service/orm/model/properties_resolve.test.ts @@ -3,6 +3,10 @@ import { Model } from '../decorator/model'; import { Field } from '../decorator/field'; +import { + clearGlobalRequestContextProvider, + setGlobalRequestContextProvider, +} from '../../../rpc/context'; import BaseModel from './model'; import PropertyDefinitionBaseModel from './property_definition_base_model'; import { @@ -132,6 +136,44 @@ test('properties resolve: App-level merges schema ⊕ value', async () => { } }); +test('properties resolve: unwraps Definition label lang maps by request lang', async () => { + installDefinitionRows([ + { + TargetModel: 'Pp1Partner', + PropertiesField: 'PartnerProperties', + ContainerId: null, + Definition: [ + { + name: 'priority', + type: 'selection', + string: { en_US: 'Priority', zh_CN: '优先级' }, + selection: [ + ['high', { en_US: 'High', zh_CN: '高' }], + ['low', 'Low'], + ], + default: 'high', + }, + { name: 'code', type: 'char', string: 'External Code' }, + ], + }, + ]); + setGlobalRequestContextProvider({ lang: 'zh_CN' }); + try { + const items = await resolveProperties(Pp1Partner as any, { PartnerProperties: { priority: 'high' } }, 'PartnerProperties'); + expect(items[0]?.string).toBe('优先级'); + expect(items[0]?.selection).toEqual([ + ['high', '高'], + ['low', 'Low'], + ]); + expect(items[0]?.value).toBe('high'); + expect(items[0]?.default).toBe('high'); + expect(items[1]?.string).toBe('External Code'); + } finally { + clearGlobalRequestContextProvider(); + __clearLookupPropertyDefinitionModelForTest(); + } +}); + test('properties resolve: empty parent id → [] without App-level fallback (PP2)', async () => { installDefinitionRows([ { diff --git a/modules/core/service/orm/model/properties_resolve.ts b/modules/core/service/orm/model/properties_resolve.ts index e357223bc..32a3bec7a 100644 --- a/modules/core/service/orm/model/properties_resolve.ts +++ b/modules/core/service/orm/model/properties_resolve.ts @@ -7,7 +7,9 @@ import { asObjectRecord } from '../../../utils/object'; import type { ObjectRecord } from '../../../utils/types'; import type BaseModel from './model'; import type { ModelCtor } from './types'; +import { resolveTranslatedFieldLang } from '../repository/projection/translated_field_codec'; import { lookupPropertyDefinitionModel } from './properties_lookup'; +import { translatePropertyDefinitionItems } from './properties_label_i18n'; import { filterReadablePropertyDefinitionItems, normalizePropertiesMap, @@ -153,7 +155,9 @@ export async function resolveProperties( items = await loadDefinitionItems(application, targetModel, fieldName, 'parent', containerModel, containerId); } - return items.map(item => { + // Expand Definition label lang maps for the request language; do not cache translated items. + const localized = translatePropertyDefinitionItems(items, resolveTranslatedFieldLang()); + return localized.map(item => { const resolved: ResolvedPropertyItem = { ...item }; if (Object.prototype.hasOwnProperty.call(valueMap, item.name)) { resolved.value = valueMap[item.name]; diff --git a/modules/core/service/orm/model/properties_types.ts b/modules/core/service/orm/model/properties_types.ts index cabe0d289..2b4a56e50 100644 --- a/modules/core/service/orm/model/properties_types.ts +++ b/modules/core/service/orm/model/properties_types.ts @@ -1,6 +1,8 @@ // SPDX-FileCopyrightText: 2026-present Brian Wang // SPDX-License-Identifier: Apache-2.0 +import { normalizePropertyLabelForStorage, normalizeSelectionLabelsForStorage } from './properties_label_i18n'; + /** V1 property item types (PP7). */ export const PROPERTIES_V1_TYPES = new Set([ 'boolean', @@ -13,13 +15,16 @@ export const PROPERTIES_V1_TYPES = new Set([ 'selection', ]); +/** Definition item label: plain string (≡ `{ en_US: s }`) or lang map. */ +export type PropertyLabel = string | Record; + export type PropertyItemDefinition = { name: string; type: string; - string?: string; + string?: PropertyLabel; default?: unknown; readonly?: boolean; - selection?: Array<{ value: string; label?: string } | [string, string]>; + selection?: Array<{ value: string; label?: PropertyLabel } | [string, PropertyLabel]>; [key: string]: unknown; }; @@ -132,6 +137,19 @@ export function assertValidPropertyDefinitionItems(raw: unknown): PropertyItemDe ); } const normalized = { ...rec, name, type } as PropertyItemDefinition; + if (Object.prototype.hasOwnProperty.call(rec, 'string')) { + try { + const label = normalizePropertyLabelForStorage(rec.string, `${name}.string`); + if (label === undefined) delete normalized.string; + else normalized.string = label; + } catch (err) { + throw new Error( + err instanceof Error + ? err.message + : `PropertyDefinition.Definition item "${name}" string label is invalid` + ); + } + } if (type === 'selection') { const values = selectionOptionValues(rec.selection); if (values.length === 0) { @@ -141,7 +159,15 @@ export function assertValidPropertyDefinitionItems(raw: unknown): PropertyItemDe if (values.length !== (rec.selection as unknown[]).length) { throw new Error(`PropertyDefinition.Definition item "${name}" selection options are invalid`); } - normalized.selection = rec.selection as PropertyItemDefinition['selection']; + try { + normalized.selection = normalizeSelectionLabelsForStorage(rec.selection, name); + } catch (err) { + throw new Error( + err instanceof Error + ? err.message + : `PropertyDefinition.Definition item "${name}" selection options are invalid` + ); + } } if (Object.prototype.hasOwnProperty.call(rec, 'default') && !propertyValueMatchesType(normalized, rec.default)) { throw new Error(`PropertyDefinition.Definition item "${name}" default does not match type "${type}"`); From 557dc04cc7995f53dca9974bb67a8f44df98ea89 Mon Sep 17 00:00:00 2001 From: Brian Wang Date: Wed, 28 Oct 2026 11:03:28 +0800 Subject: [PATCH 02/23] feat(web): merge-safe Properties definition label editing Drop secondary schema _t on the value field, merge current-lang writes into Definition lang maps, and add an in-memory translate dialog. --- modules/web/i18n/web.pot | 98 +++++--- modules/web/i18n/zh_CN.po | 94 +++++--- .../field/ChoyPropertiesDefinitionEditor.vue | 130 ++++++++++- .../components/field/ChoyPropertiesField.vue | 24 +- .../ChoyPropertyLabelTranslationsDialog.vue | 169 ++++++++++++++ .../field/propertiesDefinitionHelpers.test.ts | 215 ++++++++++++------ .../field/propertiesDefinitionHelpers.ts | 148 +++++++++++- 7 files changed, 723 insertions(+), 155 deletions(-) create mode 100644 modules/web/web/components/field/ChoyPropertyLabelTranslationsDialog.vue diff --git a/modules/web/i18n/web.pot b/modules/web/i18n/web.pot index 64661e8dc..250636bd5 100644 --- a/modules/web/i18n/web.pot +++ b/modules/web/i18n/web.pot @@ -780,45 +780,45 @@ msgctxt "web/components/field/ChoyOneToManyField" msgid "Delete" msgstr "" -#: web/components/field/ChoyOneToManyKanbanField.vue:143 +#: web/components/field/ChoyOneToManyKanbanField.vue:186 msgctxt "web/components/field/ChoyOneToManyKanbanField" msgid "Add a line…" msgstr "" -#: web/components/field/ChoyOneToManyKanbanField.vue:384 +#: web/components/field/ChoyOneToManyKanbanField.vue:429 msgctxt "web/components/field/ChoyOneToManyKanbanField" msgid "Are you sure you want to delete this record? This action cannot be undone." msgstr "" -#: web/components/field/ChoyOneToManyKanbanField.vue:389 +#: web/components/field/ChoyOneToManyKanbanField.vue:434 #: web/components/field/ChoyOneToManyKanbanField.vue:93 msgctxt "web/components/field/ChoyOneToManyKanbanField" msgid "Cancel" msgstr "" -#: web/components/field/ChoyOneToManyKanbanField.vue:385 +#: web/components/field/ChoyOneToManyKanbanField.vue:430 msgctxt "web/components/field/ChoyOneToManyKanbanField" msgid "Confirm delete" msgstr "" #: web/components/field/ChoyOneToManyKanbanField.vue:20 -#: web/components/field/ChoyOneToManyKanbanField.vue:388 +#: web/components/field/ChoyOneToManyKanbanField.vue:433 #: web/components/field/ChoyOneToManyKanbanField.vue:90 msgctxt "web/components/field/ChoyOneToManyKanbanField" msgid "Delete" msgstr "" -#: web/components/field/ChoyOneToManyKanbanField.vue:146 +#: web/components/field/ChoyOneToManyKanbanField.vue:189 msgctxt "web/components/field/ChoyOneToManyKanbanField" msgid "Edit record" msgstr "" -#: web/components/field/ChoyOneToManyKanbanField.vue:145 +#: web/components/field/ChoyOneToManyKanbanField.vue:188 msgctxt "web/components/field/ChoyOneToManyKanbanField" msgid "New record" msgstr "" -#: web/components/field/ChoyOneToManyKanbanField.vue:144 +#: web/components/field/ChoyOneToManyKanbanField.vue:187 msgctxt "web/components/field/ChoyOneToManyKanbanField" msgid "No data" msgstr "" @@ -838,12 +838,12 @@ msgctxt "web/components/field/ChoyOneToManyKanbanField" msgid "Save" msgstr "" -#: web/components/field/ChoyOneToManyKanbanField.vue:323 +#: web/components/field/ChoyOneToManyKanbanField.vue:368 msgctxt "web/components/field/ChoyOneToManyKanbanField" msgid "Untitled" msgstr "" -#: web/components/field/ChoyOneToManyKanbanField.vue:147 +#: web/components/field/ChoyOneToManyKanbanField.vue:190 msgctxt "web/components/field/ChoyOneToManyKanbanField" msgid "View record" msgstr "" @@ -853,12 +853,14 @@ msgctxt "web/components/field/ChoyPropertiesDefinitionEditor" msgid "Add property" msgstr "" -#: web/components/field/ChoyPropertiesDefinitionEditor.vue:33 +#: web/components/field/ChoyPropertiesDefinitionEditor.vue:38 msgctxt "web/components/field/ChoyPropertiesDefinitionEditor" msgid "Default" msgstr "" -#: web/components/field/ChoyPropertiesDefinitionEditor.vue:32 +#: web/components/field/ChoyPropertiesDefinitionEditor.vue:183 +#: web/components/field/ChoyPropertiesDefinitionEditor.vue:193 +#: web/components/field/ChoyPropertiesDefinitionEditor.vue:33 msgctxt "web/components/field/ChoyPropertiesDefinitionEditor" msgid "Label" msgstr "" @@ -873,18 +875,18 @@ msgctxt "web/components/field/ChoyPropertiesDefinitionEditor" msgid "No properties defined" msgstr "" -#: web/components/field/ChoyPropertiesDefinitionEditor.vue:143 -#: web/components/field/ChoyPropertiesDefinitionEditor.vue:89 +#: web/components/field/ChoyPropertiesDefinitionEditor.vue:126 +#: web/components/field/ChoyPropertiesDefinitionEditor.vue:221 msgctxt "web/components/field/ChoyPropertiesDefinitionEditor" msgid "PropertyDefinition store is unavailable" msgstr "" -#: web/components/field/ChoyPropertiesDefinitionEditor.vue:36 +#: web/components/field/ChoyPropertiesDefinitionEditor.vue:41 msgctxt "web/components/field/ChoyPropertiesDefinitionEditor" msgid "Readonly" msgstr "" -#: web/components/field/ChoyPropertiesDefinitionEditor.vue:41 +#: web/components/field/ChoyPropertiesDefinitionEditor.vue:51 msgctxt "web/components/field/ChoyPropertiesDefinitionEditor" msgid "Remove" msgstr "" @@ -894,22 +896,32 @@ msgctxt "web/components/field/ChoyPropertiesDefinitionEditor" msgid "Save" msgstr "" -#: web/components/field/ChoyPropertiesDefinitionEditor.vue:38 +#: web/components/field/ChoyPropertiesDefinitionEditor.vue:43 msgctxt "web/components/field/ChoyPropertiesDefinitionEditor" msgid "Selection JSON" msgstr "" +#: web/components/field/ChoyPropertiesDefinitionEditor.vue:35 +msgctxt "web/components/field/ChoyPropertiesDefinitionEditor" +msgid "Translate" +msgstr "" + +#: web/components/field/ChoyPropertiesDefinitionEditor.vue:46 +msgctxt "web/components/field/ChoyPropertiesDefinitionEditor" +msgid "Translate %s" +msgstr "" + #: web/components/field/ChoyPropertiesDefinitionEditor.vue:25 msgctxt "web/components/field/ChoyPropertiesDefinitionEditor" msgid "Type" msgstr "" -#: web/components/field/ChoyPropertiesField.vue:239 +#: web/components/field/ChoyPropertiesField.vue:243 msgctxt "web/components/field/ChoyPropertiesField" msgid "+%s more" msgstr "" -#: web/components/field/ChoyPropertiesField.vue:223 +#: web/components/field/ChoyPropertiesField.vue:227 msgctxt "web/components/field/ChoyPropertiesField" msgid "No" msgstr "" @@ -920,7 +932,7 @@ msgctxt "web/components/field/ChoyPropertiesField" msgid "No properties defined" msgstr "" -#: web/components/field/ChoyPropertiesField.vue:223 +#: web/components/field/ChoyPropertiesField.vue:227 msgctxt "web/components/field/ChoyPropertiesField" msgid "Yes" msgstr "" @@ -930,7 +942,7 @@ msgctxt "web/components/field/ChoySelectionField" msgid "Clear" msgstr "" -#: web/components/field/ChoySelectionField.vue:343 +#: web/components/field/ChoySelectionField.vue:355 msgctxt "web/components/field/ChoySelectionField" msgid "Invalid option value: %s" msgstr "" @@ -955,7 +967,7 @@ msgctxt "web/components/field/ChoySelectionField" msgid "Search…" msgstr "" -#: web/components/field/ChoySelectionField.vue:341 +#: web/components/field/ChoySelectionField.vue:353 msgctxt "web/components/field/ChoySelectionField" msgid "Value must be a string" msgstr "" @@ -995,32 +1007,32 @@ msgctxt "web/components/field/ChoyTimeField" msgid "Select time" msgstr "" -#: web/components/field/ChoyVarcharField.vue:154 +#: web/components/field/ChoyVarcharField.vue:168 msgctxt "web/components/field/ChoyVarcharField" msgid "Copied" msgstr "" -#: web/components/field/ChoyVarcharField.vue:124 +#: web/components/field/ChoyVarcharField.vue:138 msgctxt "web/components/field/ChoyVarcharField" msgid "Copy" msgstr "" -#: web/components/field/ChoyVarcharField.vue:156 +#: web/components/field/ChoyVarcharField.vue:170 msgctxt "web/components/field/ChoyVarcharField" msgid "Copy failed" msgstr "" -#: web/components/field/ChoyVarcharField.vue:280 +#: web/components/field/ChoyVarcharField.vue:294 msgctxt "web/components/field/ChoyVarcharField" msgid "Length must not exceed %s" msgstr "" -#: web/components/field/ChoyVarcharField.vue:274 +#: web/components/field/ChoyVarcharField.vue:288 msgctxt "web/components/field/ChoyVarcharField" msgid "Value is required" msgstr "" -#: web/components/field/ChoyVarcharField.vue:276 +#: web/components/field/ChoyVarcharField.vue:290 msgctxt "web/components/field/ChoyVarcharField" msgid "Value must be a string" msgstr "" @@ -1100,6 +1112,36 @@ msgctxt "web/components/field/FieldTranslationsDialog" msgid "Translations saved" msgstr "" +#: web/components/field/ChoyPropertyLabelTranslationsDialog.vue:12 +msgctxt "web/components/field/PropertyLabelTranslationsDialog" +msgid "Base language (cannot be deleted)" +msgstr "" + +#: web/components/field/ChoyPropertyLabelTranslationsDialog.vue:19 +msgctxt "web/components/field/PropertyLabelTranslationsDialog" +msgid "Cancel" +msgstr "" + +#: web/components/field/ChoyPropertyLabelTranslationsDialog.vue:96 +msgctxt "web/components/field/PropertyLabelTranslationsDialog" +msgid "Failed to load translations" +msgstr "" + +#: web/components/field/ChoyPropertyLabelTranslationsDialog.vue:21 +msgctxt "web/components/field/PropertyLabelTranslationsDialog" +msgid "Save translations" +msgstr "" + +#: web/components/field/ChoyPropertyLabelTranslationsDialog.vue:42 +msgctxt "web/components/field/PropertyLabelTranslationsDialog" +msgid "Translate label" +msgstr "" + +#: web/components/field/ChoyPropertyLabelTranslationsDialog.vue:42 +msgctxt "web/components/field/PropertyLabelTranslationsDialog" +msgid "Translate: %s" +msgstr "" + #: web/components/field/relationCountSummary.ts:14 msgctxt "web/components/field/relationCountSummary@formatRelationCountSummary" msgid "%s record" diff --git a/modules/web/i18n/zh_CN.po b/modules/web/i18n/zh_CN.po index 59cd3f57f..df862f93a 100644 --- a/modules/web/i18n/zh_CN.po +++ b/modules/web/i18n/zh_CN.po @@ -761,42 +761,42 @@ msgctxt "web/components/field/ChoyOneToManyField" msgid "Delete" msgstr "删除" -#: web/components/field/ChoyOneToManyKanbanField.vue:143 +#: web/components/field/ChoyOneToManyKanbanField.vue:186 msgctxt "web/components/field/ChoyOneToManyKanbanField" msgid "Add a line…" msgstr "添加一行…" -#: web/components/field/ChoyOneToManyKanbanField.vue:384 +#: web/components/field/ChoyOneToManyKanbanField.vue:429 msgctxt "web/components/field/ChoyOneToManyKanbanField" msgid "Are you sure you want to delete this record? This action cannot be undone." msgstr "确定要删除此记录吗?此操作无法撤销。" -#: web/components/field/ChoyOneToManyKanbanField.vue:389 web/components/field/ChoyOneToManyKanbanField.vue:93 +#: web/components/field/ChoyOneToManyKanbanField.vue:434 web/components/field/ChoyOneToManyKanbanField.vue:93 msgctxt "web/components/field/ChoyOneToManyKanbanField" msgid "Cancel" msgstr "取消" -#: web/components/field/ChoyOneToManyKanbanField.vue:385 +#: web/components/field/ChoyOneToManyKanbanField.vue:430 msgctxt "web/components/field/ChoyOneToManyKanbanField" msgid "Confirm delete" msgstr "确认删除" -#: web/components/field/ChoyOneToManyKanbanField.vue:20 web/components/field/ChoyOneToManyKanbanField.vue:388 web/components/field/ChoyOneToManyKanbanField.vue:90 +#: web/components/field/ChoyOneToManyKanbanField.vue:20 web/components/field/ChoyOneToManyKanbanField.vue:433 web/components/field/ChoyOneToManyKanbanField.vue:90 msgctxt "web/components/field/ChoyOneToManyKanbanField" msgid "Delete" msgstr "删除" -#: web/components/field/ChoyOneToManyKanbanField.vue:146 +#: web/components/field/ChoyOneToManyKanbanField.vue:189 msgctxt "web/components/field/ChoyOneToManyKanbanField" msgid "Edit record" msgstr "编辑记录" -#: web/components/field/ChoyOneToManyKanbanField.vue:145 +#: web/components/field/ChoyOneToManyKanbanField.vue:188 msgctxt "web/components/field/ChoyOneToManyKanbanField" msgid "New record" msgstr "新建记录" -#: web/components/field/ChoyOneToManyKanbanField.vue:144 +#: web/components/field/ChoyOneToManyKanbanField.vue:187 msgctxt "web/components/field/ChoyOneToManyKanbanField" msgid "No data" msgstr "暂无数据" @@ -816,12 +816,12 @@ msgctxt "web/components/field/ChoyOneToManyKanbanField" msgid "Save" msgstr "保存" -#: web/components/field/ChoyOneToManyKanbanField.vue:323 +#: web/components/field/ChoyOneToManyKanbanField.vue:368 msgctxt "web/components/field/ChoyOneToManyKanbanField" msgid "Untitled" msgstr "未命名" -#: web/components/field/ChoyOneToManyKanbanField.vue:147 +#: web/components/field/ChoyOneToManyKanbanField.vue:190 msgctxt "web/components/field/ChoyOneToManyKanbanField" msgid "View record" msgstr "查看记录" @@ -831,12 +831,12 @@ msgctxt "web/components/field/ChoyPropertiesDefinitionEditor" msgid "Add property" msgstr "添加属性" -#: web/components/field/ChoyPropertiesDefinitionEditor.vue:33 +#: web/components/field/ChoyPropertiesDefinitionEditor.vue:38 msgctxt "web/components/field/ChoyPropertiesDefinitionEditor" msgid "Default" msgstr "默认" -#: web/components/field/ChoyPropertiesDefinitionEditor.vue:32 +#: web/components/field/ChoyPropertiesDefinitionEditor.vue:183 web/components/field/ChoyPropertiesDefinitionEditor.vue:193 web/components/field/ChoyPropertiesDefinitionEditor.vue:33 msgctxt "web/components/field/ChoyPropertiesDefinitionEditor" msgid "Label" msgstr "标签" @@ -851,17 +851,17 @@ msgctxt "web/components/field/ChoyPropertiesDefinitionEditor" msgid "No properties defined" msgstr "未定义属性" -#: web/components/field/ChoyPropertiesDefinitionEditor.vue:143 web/components/field/ChoyPropertiesDefinitionEditor.vue:89 +#: web/components/field/ChoyPropertiesDefinitionEditor.vue:126 web/components/field/ChoyPropertiesDefinitionEditor.vue:221 msgctxt "web/components/field/ChoyPropertiesDefinitionEditor" msgid "PropertyDefinition store is unavailable" msgstr "PropertyDefinition 存储不可用" -#: web/components/field/ChoyPropertiesDefinitionEditor.vue:36 +#: web/components/field/ChoyPropertiesDefinitionEditor.vue:41 msgctxt "web/components/field/ChoyPropertiesDefinitionEditor" msgid "Readonly" msgstr "只读" -#: web/components/field/ChoyPropertiesDefinitionEditor.vue:41 +#: web/components/field/ChoyPropertiesDefinitionEditor.vue:51 msgctxt "web/components/field/ChoyPropertiesDefinitionEditor" msgid "Remove" msgstr "移除" @@ -871,22 +871,32 @@ msgctxt "web/components/field/ChoyPropertiesDefinitionEditor" msgid "Save" msgstr "保存" -#: web/components/field/ChoyPropertiesDefinitionEditor.vue:38 +#: web/components/field/ChoyPropertiesDefinitionEditor.vue:43 msgctxt "web/components/field/ChoyPropertiesDefinitionEditor" msgid "Selection JSON" msgstr "选择项 JSON" +#: web/components/field/ChoyPropertiesDefinitionEditor.vue:35 +msgctxt "web/components/field/ChoyPropertiesDefinitionEditor" +msgid "Translate" +msgstr "翻译" + +#: web/components/field/ChoyPropertiesDefinitionEditor.vue:46 +msgctxt "web/components/field/ChoyPropertiesDefinitionEditor" +msgid "Translate %s" +msgstr "翻译 %s" + #: web/components/field/ChoyPropertiesDefinitionEditor.vue:25 msgctxt "web/components/field/ChoyPropertiesDefinitionEditor" msgid "Type" msgstr "类型" -#: web/components/field/ChoyPropertiesField.vue:239 +#: web/components/field/ChoyPropertiesField.vue:243 msgctxt "web/components/field/ChoyPropertiesField" msgid "+%s more" msgstr "+%s 项" -#: web/components/field/ChoyPropertiesField.vue:223 +#: web/components/field/ChoyPropertiesField.vue:227 msgctxt "web/components/field/ChoyPropertiesField" msgid "No" msgstr "否" @@ -896,7 +906,7 @@ msgctxt "web/components/field/ChoyPropertiesField" msgid "No properties defined" msgstr "暂无属性定义" -#: web/components/field/ChoyPropertiesField.vue:223 +#: web/components/field/ChoyPropertiesField.vue:227 msgctxt "web/components/field/ChoyPropertiesField" msgid "Yes" msgstr "是" @@ -906,7 +916,7 @@ msgctxt "web/components/field/ChoySelectionField" msgid "Clear" msgstr "清除" -#: web/components/field/ChoySelectionField.vue:343 +#: web/components/field/ChoySelectionField.vue:355 msgctxt "web/components/field/ChoySelectionField" msgid "Invalid option value: %s" msgstr "无效的选项值:%s" @@ -931,7 +941,7 @@ msgctxt "web/components/field/ChoySelectionField" msgid "Search…" msgstr "搜索…" -#: web/components/field/ChoySelectionField.vue:341 +#: web/components/field/ChoySelectionField.vue:353 msgctxt "web/components/field/ChoySelectionField" msgid "Value must be a string" msgstr "值必须为字符串" @@ -971,32 +981,32 @@ msgctxt "web/components/field/ChoyTimeField" msgid "Select time" msgstr "选择时间" -#: web/components/field/ChoyVarcharField.vue:154 +#: web/components/field/ChoyVarcharField.vue:168 msgctxt "web/components/field/ChoyVarcharField" msgid "Copied" msgstr "已复制" -#: web/components/field/ChoyVarcharField.vue:124 +#: web/components/field/ChoyVarcharField.vue:138 msgctxt "web/components/field/ChoyVarcharField" msgid "Copy" msgstr "复制" -#: web/components/field/ChoyVarcharField.vue:156 +#: web/components/field/ChoyVarcharField.vue:170 msgctxt "web/components/field/ChoyVarcharField" msgid "Copy failed" msgstr "复制失败" -#: web/components/field/ChoyVarcharField.vue:280 +#: web/components/field/ChoyVarcharField.vue:294 msgctxt "web/components/field/ChoyVarcharField" msgid "Length must not exceed %s" msgstr "长度不得超过 %s" -#: web/components/field/ChoyVarcharField.vue:274 +#: web/components/field/ChoyVarcharField.vue:288 msgctxt "web/components/field/ChoyVarcharField" msgid "Value is required" msgstr "值为必填项" -#: web/components/field/ChoyVarcharField.vue:276 +#: web/components/field/ChoyVarcharField.vue:290 msgctxt "web/components/field/ChoyVarcharField" msgid "Value must be a string" msgstr "值必须为字符串" @@ -1076,6 +1086,36 @@ msgctxt "web/components/field/FieldTranslationsDialog" msgid "Translations saved" msgstr "翻译已保存" +#: web/components/field/ChoyPropertyLabelTranslationsDialog.vue:12 +msgctxt "web/components/field/PropertyLabelTranslationsDialog" +msgid "Base language (cannot be deleted)" +msgstr "基础语言(不可删除)" + +#: web/components/field/ChoyPropertyLabelTranslationsDialog.vue:19 +msgctxt "web/components/field/PropertyLabelTranslationsDialog" +msgid "Cancel" +msgstr "取消" + +#: web/components/field/ChoyPropertyLabelTranslationsDialog.vue:96 +msgctxt "web/components/field/PropertyLabelTranslationsDialog" +msgid "Failed to load translations" +msgstr "加载翻译失败" + +#: web/components/field/ChoyPropertyLabelTranslationsDialog.vue:21 +msgctxt "web/components/field/PropertyLabelTranslationsDialog" +msgid "Save translations" +msgstr "保存翻译" + +#: web/components/field/ChoyPropertyLabelTranslationsDialog.vue:42 +msgctxt "web/components/field/PropertyLabelTranslationsDialog" +msgid "Translate label" +msgstr "翻译标签" + +#: web/components/field/ChoyPropertyLabelTranslationsDialog.vue:42 +msgctxt "web/components/field/PropertyLabelTranslationsDialog" +msgid "Translate: %s" +msgstr "翻译:%s" + #: web/components/field/relationCountSummary.ts:14 msgctxt "web/components/field/relationCountSummary@formatRelationCountSummary" msgid "%s record" diff --git a/modules/web/web/components/field/ChoyPropertiesDefinitionEditor.vue b/modules/web/web/components/field/ChoyPropertiesDefinitionEditor.vue index fa94ca9e0..3ce10ddc8 100644 --- a/modules/web/web/components/field/ChoyPropertiesDefinitionEditor.vue +++ b/modules/web/web/components/field/ChoyPropertiesDefinitionEditor.vue @@ -76,13 +76,24 @@ SPDX-License-Identifier: Apache-2.0 - +
+ + + {{ _t('Translate') }} + +
+
+ + {{ _t('Translate %s', opt) }} + +
+ + diff --git a/modules/web/web/components/field/ChoyPropertiesField.vue b/modules/web/web/components/field/ChoyPropertiesField.vue index e2abda3ad..1a561bab9 100644 --- a/modules/web/web/components/field/ChoyPropertiesField.vue +++ b/modules/web/web/components/field/ChoyPropertiesField.vue @@ -114,7 +114,7 @@ SPDX-License-Identifier: Apache-2.0 :key="opt.value" :value="opt.value" > - {{ translateText(opt.label) }} + {{ opt.label }} @@ -169,7 +169,9 @@ import { computed, ref, watch, type WritableComputedRef } from 'vue'; import { useField } from '@/web/web/composables/useField'; import type { UseField } from '@/web/web/composables/useField'; import ChoyFieldBase, { type FieldStateExpr, type FormItemProps } from './ChoyFieldBase.vue'; +import { getActivePinia } from 'pinia'; import { createTranslate } from '@/web/web/i18n'; +import { useI18nStore } from '@/web/web/stores/i18nStore'; import Input from '../vendor/ui/input/Input.vue'; import Textarea from '../vendor/ui/textarea/Textarea.vue'; import Switch from '../vendor/ui/switch/Switch.vue'; @@ -206,6 +208,12 @@ const { _t } = createTranslate('web', { scope: 'web/components/field/ChoyPropert defineOptions({ name: 'ChoyPropertiesField' }); +/** Request-lang fingerprint for Resolve; empty when Pinia is unavailable (unit stubs). */ +function currentTerminologyLang(): string { + if (!getActivePinia()) return ''; + return String(useI18nStore().terminologyLang || '').trim(); +} + defineSlots<{ /** Optional empty-state chrome (e.g. link to a definition editor). */ empty?: () => unknown; @@ -290,14 +298,10 @@ function setNumberInputHost(name: string, el: unknown) { else numberInputHosts.delete(name); } -/** Schema / selection labels are msgid-shaped; missing catalog entries fall back to the source string. */ -function translateText(text: unknown): string { - const s = String(text ?? '').trim(); - return s ? _t(s) : ''; -} - +/** ResolveProperties already unwraps Definition labels for the request lang — display as-is. */ function itemLabel(item: ResolvedPropertyItem): string { - return translateText(item.string || item.name) || String(item.name); + const s = item.string == null ? '' : String(item.string).trim(); + return s || String(item.name); } function controlId(item: ResolvedPropertyItem): string { @@ -385,7 +389,7 @@ function displayItemValue(map: unknown, item: ResolvedPropertyItem): string { if (item.type === 'boolean') return v === true ? _t('Yes') : _t('No'); if (item.type === 'selection') { const opt = selectionOptions(item).find(o => o.value === v); - return translateText(opt?.label ?? v); + return String(opt?.label ?? v); } if (item.type === 'date') return propertyDateToDisplay(v); if (item.type === 'datetime') return propertyDatetimeToDisplay(v); @@ -528,6 +532,8 @@ watch( isTableLike.value, JSON.stringify(map ?? null), propertiesRecordWatchFingerprint(rec, name), + // Re-resolve when UI terminology lang changes (labels come from Resolve, not FE _t). + currentTerminologyLang(), ] as const; }, () => { diff --git a/modules/web/web/components/field/ChoyPropertyLabelTranslationsDialog.vue b/modules/web/web/components/field/ChoyPropertyLabelTranslationsDialog.vue new file mode 100644 index 000000000..ca69e74b6 --- /dev/null +++ b/modules/web/web/components/field/ChoyPropertyLabelTranslationsDialog.vue @@ -0,0 +1,169 @@ + + + + + diff --git a/modules/web/web/components/field/propertiesDefinitionHelpers.test.ts b/modules/web/web/components/field/propertiesDefinitionHelpers.test.ts index adecd8dba..c753161b8 100644 --- a/modules/web/web/components/field/propertiesDefinitionHelpers.test.ts +++ b/modules/web/web/components/field/propertiesDefinitionHelpers.test.ts @@ -3,23 +3,26 @@ import { PROPERTY_DEFINITION_V1_TYPE_OPTIONS, + applyDraftCurrentLangToMaps, buildDefinitionScopeCondition, definitionItemsToDrafts, draftsToDefinitionItems, emptyDraftItem, + syncDraftLabelsForLang, } from './propertiesDefinitionHelpers'; describe('propertiesDefinitionHelpers', () => { test('emptyDraftItem defaults to char', () => { expect(PROPERTY_DEFINITION_V1_TYPE_OPTIONS).toContain('char'); expect(emptyDraftItem().type).toBe('char'); + expect(emptyDraftItem().stringMap).toEqual({}); }); - test('definitionItemsToDrafts skips non-items and stringifies selection', () => { + test('definitionItemsToDrafts unwraps lang maps for the requested lang', () => { expect(definitionItemsToDrafts(null)).toEqual([]); expect(definitionItemsToDrafts('x' as unknown as unknown[])).toEqual([]); - expect( - definitionItemsToDrafts([ + const drafts = definitionItemsToDrafts( + [ null, [], { name: '' }, @@ -27,31 +30,41 @@ describe('propertiesDefinitionHelpers', () => { { name: 'sel', type: 'selection', + string: { en_US: 'Priority', zh_CN: '优先级' }, selection: [ - ['a', 'A'], + ['a', { en_US: 'A', zh_CN: '甲' }], { value: 'b', label: 'B' }, ], }, - ]), - ).toEqual([ + ], + 'zh_CN', + ); + expect(drafts).toEqual([ { name: 'ok', type: 'char', string: '', + stringMap: {}, default: '', readonly: false, selectionText: '', + selectionLabelMaps: {}, }, { name: 'sel', type: 'selection', - string: '', + string: '优先级', + stringMap: { en_US: 'Priority', zh_CN: '优先级' }, default: '', readonly: false, selectionText: JSON.stringify([ - ['a', 'A'], - { value: 'b', label: 'B' }, + ['a', '甲'], + ['b', 'B'], ]), + selectionLabelMaps: { + a: { en_US: 'A', zh_CN: '甲' }, + b: { en_US: 'B' }, + }, }, ]); }); @@ -77,28 +90,63 @@ describe('propertiesDefinitionHelpers', () => { expect(definitionItemsToDrafts([{ name: 'emptytype', type: '' }])[0]?.type).toBe('char'); }); - test('definitionItemsToDrafts / draftsToDefinitionItems round-trip', () => { - const drafts = definitionItemsToDrafts([ - { name: 'tier', type: 'selection', string: 'Tier', selection: [['a', 'A'], ['b', 'B']] }, - { name: 'flag', type: 'boolean', default: true, readonly: true }, + test('draftsToDefinitionItems merges current lang without wiping other langs', () => { + const previous = [ + { + name: 'tier', + type: 'selection', + string: { en_US: 'Tier', zh_CN: '等级' }, + selection: [ + ['a', { en_US: 'A', zh_CN: '甲' }], + ['b', { en_US: 'B', zh_CN: '乙' }], + ], + }, + ]; + const drafts = definitionItemsToDrafts(previous, 'zh_CN'); + drafts[0]!.string = '级别'; + drafts[0]!.selectionText = JSON.stringify([ + ['a', '甲级'], + ['b', '乙'], ]); + const items = draftsToDefinitionItems(drafts, 'zh_CN', previous as any); + expect(items[0]!.string).toEqual({ en_US: 'Tier', zh_CN: '级别' }); + expect(items[0]!.selection).toEqual([ + ['a', { en_US: 'A', zh_CN: '甲级' }], + ['b', { en_US: 'B', zh_CN: '乙' }], + ]); + }); + + test('definitionItemsToDrafts / draftsToDefinitionItems round-trip', () => { + const drafts = definitionItemsToDrafts( + [ + { name: 'tier', type: 'selection', string: 'Tier', selection: [['a', 'A'], ['b', 'B']] }, + { name: 'flag', type: 'boolean', default: true, readonly: true }, + ], + 'en_US', + ); expect(drafts).toHaveLength(2); - const items = draftsToDefinitionItems(drafts); + const items = draftsToDefinitionItems(drafts, 'en_US'); expect(items[0]!.name).toBe('tier'); - expect(items[0]!.selection).toEqual([['a', 'A'], ['b', 'B']]); + expect(items[0]!.selection).toEqual([ + ['a', 'A'], + ['b', 'B'], + ]); expect(items[1]!.readonly).toBe(true); expect(items[1]!.default).toBe(true); }); test('draftsToDefinitionItems persists only normalized selection options', () => { - const items = draftsToDefinitionItems([ - { - ...emptyDraftItem(), - name: 'tier', - type: 'selection', - selectionText: JSON.stringify([['a', 'A'], 1, { value: 'b', label: 'B' }, { value: 2 }]), - }, - ]); + const items = draftsToDefinitionItems( + [ + { + ...emptyDraftItem(), + name: 'tier', + type: 'selection', + selectionText: JSON.stringify([['a', 'A'], 1, { value: 'b', label: 'B' }, { value: 2 }]), + }, + ], + 'en_US', + ); expect(items[0]!.selection).toEqual([ ['a', 'A'], ['b', 'B'], @@ -106,19 +154,22 @@ describe('propertiesDefinitionHelpers', () => { }); test('draftsToDefinitionItems coerces defaults and skips blank names', () => { - const items = draftsToDefinitionItems([ - { ...emptyDraftItem(), name: ' ' }, - { ...emptyDraftItem(), name: 'flag', type: 'boolean', default: 'false' }, - { ...emptyDraftItem(), name: 'flag2', type: 'boolean', default: '0' }, - { ...emptyDraftItem(), name: 'flag3', type: 'boolean', default: '1' }, - { ...emptyDraftItem(), name: 'flag4', type: 'boolean', default: 'maybe' }, - { ...emptyDraftItem(), name: 'n', type: 'integer', default: '3.9' }, - { ...emptyDraftItem(), name: 'n2', type: 'integer', default: 'nope' }, - { ...emptyDraftItem(), name: 'f', type: 'float', default: '1.5' }, - { ...emptyDraftItem(), name: 'f2', type: 'float', default: 'nope' }, - { ...emptyDraftItem(), name: 'c', type: 'char', default: 'x', string: ' Label ' }, - { ...emptyDraftItem(), name: 'ws', type: 'integer', default: ' ' }, - ]); + const items = draftsToDefinitionItems( + [ + { ...emptyDraftItem(), name: ' ' }, + { ...emptyDraftItem(), name: 'flag', type: 'boolean', default: 'false' }, + { ...emptyDraftItem(), name: 'flag2', type: 'boolean', default: '0' }, + { ...emptyDraftItem(), name: 'flag3', type: 'boolean', default: '1' }, + { ...emptyDraftItem(), name: 'flag4', type: 'boolean', default: 'maybe' }, + { ...emptyDraftItem(), name: 'n', type: 'integer', default: '3.9' }, + { ...emptyDraftItem(), name: 'n2', type: 'integer', default: 'nope' }, + { ...emptyDraftItem(), name: 'f', type: 'float', default: '1.5' }, + { ...emptyDraftItem(), name: 'f2', type: 'float', default: 'nope' }, + { ...emptyDraftItem(), name: 'c', type: 'char', default: 'x', string: ' Label ' }, + { ...emptyDraftItem(), name: 'ws', type: 'integer', default: ' ' }, + ], + 'en_US', + ); expect(items.find(i => i.name === 'flag')!.default).toBe(false); expect(items.find(i => i.name === 'flag2')!.default).toBe(false); expect(items.find(i => i.name === 'flag3')!.default).toBe(true); @@ -135,57 +186,83 @@ describe('propertiesDefinitionHelpers', () => { test('draftsToDefinitionItems rejects bad selection and unsupported types', () => { expect(() => - draftsToDefinitionItems([{ ...emptyDraftItem(), name: 's', type: 'selection', selectionText: '' }]), + draftsToDefinitionItems([{ ...emptyDraftItem(), name: 's', type: 'selection', selectionText: '' }], 'en_US'), ).toThrow(/requires selection JSON/); expect(() => - draftsToDefinitionItems([ - { ...emptyDraftItem(), name: 's', type: 'selection', selectionText: '{' }, - ]), + draftsToDefinitionItems( + [{ ...emptyDraftItem(), name: 's', type: 'selection', selectionText: '{' }], + 'en_US', + ), ).toThrow(/invalid JSON/); expect(() => - draftsToDefinitionItems([ - { ...emptyDraftItem(), name: 's', type: 'selection', selectionText: '[]' }, - ]), + draftsToDefinitionItems( + [{ ...emptyDraftItem(), name: 's', type: 'selection', selectionText: '[]' }], + 'en_US', + ), ).toThrow(/non-empty selection array/); expect(() => - draftsToDefinitionItems([ - { ...emptyDraftItem(), name: 's', type: 'selection', selectionText: '[1]' }, - ]), + draftsToDefinitionItems( + [{ ...emptyDraftItem(), name: 's', type: 'selection', selectionText: '[1]' }], + 'en_US', + ), ).toThrow(/no usable options/); expect(() => - draftsToDefinitionItems([ - { - ...emptyDraftItem(), - name: 's', - type: 'selection', - default: 'missing', - selectionText: JSON.stringify([['a', 'A']]), - }, - ]), + draftsToDefinitionItems( + [ + { + ...emptyDraftItem(), + name: 's', + type: 'selection', + default: 'missing', + selectionText: JSON.stringify([['a', 'A']]), + }, + ], + 'en_US', + ), ).toThrow(/default is not one of the options/); expect( - draftsToDefinitionItems([ - { - ...emptyDraftItem(), - name: 's', - type: 'selection', - default: 'a', - selectionText: JSON.stringify([['a', 'A']]), - }, - ])[0]!.default, + draftsToDefinitionItems( + [ + { + ...emptyDraftItem(), + name: 's', + type: 'selection', + default: 'a', + selectionText: JSON.stringify([['a', 'A']]), + }, + ], + 'en_US', + )[0]!.default, ).toBe('a'); expect(() => - draftsToDefinitionItems([{ ...emptyDraftItem(), name: 'x', type: 'binary' }]), + draftsToDefinitionItems([{ ...emptyDraftItem(), name: 'x', type: 'binary' }], 'en_US'), ).toThrow(/unsupported property type/); expect(() => - draftsToDefinitionItems([ - { ...emptyDraftItem(), name: 'dup', type: 'char' }, - { ...emptyDraftItem(), name: 'dup', type: 'integer' }, - ]), + draftsToDefinitionItems( + [ + { ...emptyDraftItem(), name: 'dup', type: 'char' }, + { ...emptyDraftItem(), name: 'dup', type: 'integer' }, + ], + 'en_US', + ), ).toThrow(/duplicate property name/); expect(draftsToDefinitionItems(null)).toEqual([]); }); + test('applyDraftCurrentLangToMaps / syncDraftLabelsForLang round-trip UI fields', () => { + const draft = emptyDraftItem(); + draft.name = 'prio'; + draft.type = 'selection'; + draft.stringMap = { en_US: 'Priority', zh_CN: '优先级' }; + draft.selectionLabelMaps = { high: { en_US: 'High', zh_CN: '高' } }; + syncDraftLabelsForLang(draft, 'zh_CN'); + expect(draft.string).toBe('优先级'); + expect(draft.selectionText).toBe(JSON.stringify([['high', '高']])); + draft.string = '优先'; + applyDraftCurrentLangToMaps(draft, 'zh_CN'); + expect(draft.stringMap).toEqual({ en_US: 'Priority', zh_CN: '优先' }); + }); + test('buildDefinitionScopeCondition', () => { expect( buildDefinitionScopeCondition({ diff --git a/modules/web/web/components/field/propertiesDefinitionHelpers.ts b/modules/web/web/components/field/propertiesDefinitionHelpers.ts index 6b2acb40d..c96fefc18 100644 --- a/modules/web/web/components/field/propertiesDefinitionHelpers.ts +++ b/modules/web/web/components/field/propertiesDefinitionHelpers.ts @@ -1,7 +1,14 @@ // SPDX-FileCopyrightText: 2026-present Brian Wang // SPDX-License-Identifier: Apache-2.0 -import { PROPERTIES_V1_TYPES, type PropertyItemDefinition } from '@/core/service/orm/model/properties_types'; +import { + mergePropertyLabelWrite, + mergeSelectionLabelsWrite, + parsePropertyLabelMap, + unwrapPropertyLabel, +} from '@/core/service/orm/model/properties_label_i18n'; +import { TRANSLATED_BASE_LANG } from '@/core/service/orm/repository/projection/translated_field_codec'; +import { PROPERTIES_V1_TYPES, type PropertyItemDefinition, type PropertyLabel } from '@/core/service/orm/model/properties_types'; import { normalizeSelectionOptions } from './propertiesHelpers'; @@ -10,10 +17,16 @@ export const PROPERTY_DEFINITION_V1_TYPE_OPTIONS = [...PROPERTIES_V1_TYPES].sort export type DefinitionEditorDraftItem = { name: string; type: string; + /** Current-language label shown in the main input. */ string: string; + /** Full label lang map (source of truth for Save / translate dialog). */ + stringMap: Record; default: string; readonly: boolean; + /** Selection JSON with current-language labels for the textarea. */ selectionText: string; + /** Option value → label lang map (survives current-lang textarea edits via merge). */ + selectionLabelMaps: Record>; }; export function emptyDraftItem(): DefinitionEditorDraftItem { @@ -21,25 +34,119 @@ export function emptyDraftItem(): DefinitionEditorDraftItem { name: '', type: 'char', string: '', + stringMap: {}, default: '', readonly: false, selectionText: '', + selectionLabelMaps: {}, }; } -export function definitionItemsToDrafts(items: unknown): DefinitionEditorDraftItem[] { +function asLabelMap(value: unknown): Record { + return parsePropertyLabelMap(value) || {}; +} + +function selectionRawLabel(opt: unknown): unknown { + if (Array.isArray(opt)) return opt[1] ?? opt[0]; + if (opt && typeof opt === 'object' && !Array.isArray(opt)) { + return (opt as { label?: unknown }).label ?? (opt as { value?: unknown }).value; + } + return undefined; +} + +function selectionRawValue(opt: unknown): string | null { + if (Array.isArray(opt) && typeof opt[0] === 'string') return opt[0]; + if (opt && typeof opt === 'object' && !Array.isArray(opt) && typeof (opt as { value?: unknown }).value === 'string') { + return String((opt as { value: string }).value); + } + return null; +} + +/** Sync the current-lang `string` / selectionText views from stored maps. */ +export function syncDraftLabelsForLang(draft: DefinitionEditorDraftItem, lang: string): void { + const code = String(lang || '').trim() || TRANSLATED_BASE_LANG; + draft.string = unwrapPropertyLabel(draft.stringMap, code); + if (draft.type !== 'selection') return; + const options = normalizeSelectionOptions( + (() => { + try { + return draft.selectionText ? JSON.parse(draft.selectionText) : []; + } catch { + return []; + } + })(), + ); + const values = options.length + ? options.map(o => o.value) + : Object.keys(draft.selectionLabelMaps); + const display = values.map(value => { + const map = draft.selectionLabelMaps[value] || {}; + return [value, unwrapPropertyLabel(map, code) || value] as [string, string]; + }); + try { + draft.selectionText = display.length ? JSON.stringify(display) : ''; + } catch { + draft.selectionText = ''; + } +} + +/** Apply the current-lang UI fields into the draft maps (before Save / before opening translate dialog). */ +export function applyDraftCurrentLangToMaps(draft: DefinitionEditorDraftItem, lang: string): void { + const code = String(lang || '').trim() || TRANSLATED_BASE_LANG; + draft.string = String(draft.string || '').trim(); + draft.stringMap = asLabelMap(mergePropertyLabelWrite(draft.stringMap, draft.string, code)); + if (draft.type !== 'selection') return; + const text = String(draft.selectionText || '').trim(); + if (!text) return; + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + return; + } + const options = normalizeSelectionOptions(parsed); + if (!options.length) return; + const merged = mergeSelectionLabelsWrite( + Object.entries(draft.selectionLabelMaps).map(([value, labelMap]) => [value, labelMap] as [string, PropertyLabel]), + options, + code, + ); + const nextMaps: Record> = {}; + for (const opt of merged || []) { + const value = selectionRawValue(opt); + if (value == null) continue; + nextMaps[value] = asLabelMap(selectionRawLabel(opt)); + } + draft.selectionLabelMaps = nextMaps; +} + +export function definitionItemsToDrafts( + items: unknown, + lang: string = TRANSLATED_BASE_LANG, +): DefinitionEditorDraftItem[] { if (!Array.isArray(items)) return []; + const code = String(lang || '').trim() || TRANSLATED_BASE_LANG; const out: DefinitionEditorDraftItem[] = []; for (const raw of items) { if (!raw || typeof raw !== 'object' || Array.isArray(raw)) continue; const item = raw as Record; const name = String(item.name || '').trim(); if (!name) continue; - const type = String(item.type || 'char'); + const type = String(item.type || 'char') || 'char'; + const stringMap = asLabelMap(item.string); + const selectionLabelMaps: Record> = {}; let selectionText = ''; if (Array.isArray(item.selection)) { + const display: Array<[string, string]> = []; + for (const opt of item.selection) { + const value = selectionRawValue(opt); + if (value == null) continue; + const map = asLabelMap(selectionRawLabel(opt)); + selectionLabelMaps[value] = map; + display.push([value, unwrapPropertyLabel(map, code) || value]); + } try { - selectionText = JSON.stringify(item.selection); + selectionText = display.length ? JSON.stringify(display) : ''; } catch { selectionText = ''; } @@ -47,10 +154,12 @@ export function definitionItemsToDrafts(items: unknown): DefinitionEditorDraftIt out.push({ name, type, - string: item.string == null ? '' : String(item.string), + string: unwrapPropertyLabel(stringMap, code), + stringMap, default: item.default == null ? '' : String(item.default), readonly: item.readonly === true, selectionText, + selectionLabelMaps, }); } return out; @@ -59,10 +168,18 @@ export function definitionItemsToDrafts(items: unknown): DefinitionEditorDraftIt /** Build Definition payload for Create/Update. Invalid selection JSON → throws. */ export function draftsToDefinitionItems( drafts: DefinitionEditorDraftItem[] | null | undefined, + lang: string = TRANSLATED_BASE_LANG, + previousItems?: PropertyItemDefinition[] | null, ): PropertyItemDefinition[] { + const code = String(lang || '').trim() || TRANSLATED_BASE_LANG; + const prevByName = new Map(); + for (const prev of previousItems || []) { + if (prev?.name) prevByName.set(prev.name, prev); + } const out: PropertyItemDefinition[] = []; const seenNames = new Set(); for (const draft of drafts || []) { + applyDraftCurrentLangToMaps(draft, code); const name = String(draft.name || '').trim(); if (!name) continue; if (seenNames.has(name)) { @@ -73,9 +190,13 @@ export function draftsToDefinitionItems( if (!PROPERTIES_V1_TYPES.has(type)) { throw new Error(`unsupported property type '${type}' (${name})`); } + const previous = prevByName.get(name); const item: PropertyItemDefinition = { name, type }; const label = String(draft.string || '').trim(); - if (label) item.string = label; + draft.string = label; + if (label || Object.keys(draft.stringMap).length) { + item.string = mergePropertyLabelWrite(previous?.string ?? draft.stringMap, label, code); + } if (draft.readonly) item.readonly = true; const defRaw = String(draft.default ?? ''); if (defRaw.trim() !== '') { @@ -102,8 +223,7 @@ export function draftsToDefinitionItems( if (item.default != null && !options.some(o => o.value === String(item.default))) { throw new Error(`selection property '${name}' default is not one of the options`); } - // Persist only usable options so malformed entries are not stored. - item.selection = options.map(o => [o.value, o.label]) as PropertyItemDefinition['selection']; + item.selection = mergeSelectionLabelsWrite(previous?.selection, options, code); } out.push(item); } @@ -140,13 +260,17 @@ export function buildDefinitionScopeCondition(opts: { const targetModel = String(opts.targetModel || '').trim(); const propertiesField = String(opts.propertiesField || '').trim(); const containerModel = - opts.containerModel == null || opts.containerModel === '' ? null : String(opts.containerModel); + opts.containerModel == null || String(opts.containerModel).trim() === '' + ? null + : String(opts.containerModel).trim(); const containerId = - opts.containerId == null || opts.containerId === '' ? null : String(opts.containerId); + opts.containerId == null || String(opts.containerId).trim() === '' + ? null + : String(opts.containerId).trim(); return [ ['TargetModel', '=', targetModel], ['PropertiesField', '=', propertiesField], - containerModel == null ? ['ContainerModel', '=', null] : ['ContainerModel', '=', containerModel], - containerId == null ? ['ContainerId', '=', null] : ['ContainerId', '=', containerId], + ['ContainerModel', '=', containerModel], + ['ContainerId', '=', containerId], ]; } From 2fdfc5ca0bf92defe6e9ce926532e095aed1d58f Mon Sep 17 00:00:00 2001 From: Brian Wang Date: Wed, 28 Oct 2026 11:03:28 +0800 Subject: [PATCH 03/23] feat(sandbox): expose Record Properties configuration entry Seed bilingual Definition labels and add a Configuration menu route to the app-level PropertyDefinition editor for kitchen-sink verification. --- modules/sandbox/data/bootstrap.json | 33 +++++++---------- modules/sandbox/i18n/sandbox.pot | 32 +++++++++++++---- modules/sandbox/i18n/zh_CN.po | 32 +++++++++++++---- .../sandbox/web/__tests__/lt_titles.test.ts | 19 +++++++++- .../web/__tests__/resourceWiring.test.ts | 6 ++++ modules/sandbox/web/menu/menus.ts | 19 +++++++++- modules/sandbox/web/menu/titles.ts | 2 ++ .../web/pages/SandboxRecordProperties.vue | 17 +++++++++ .../web/pages/page_mount_coverage.test.ts | 7 ++++ modules/sandbox/web/route/routes.ts | 10 ++++++ .../SandboxRecordPropertiesDefinitionView.vue | 35 +++++++++++++++++++ 11 files changed, 177 insertions(+), 35 deletions(-) create mode 100644 modules/sandbox/web/pages/SandboxRecordProperties.vue create mode 100644 modules/sandbox/web/views/SandboxRecordPropertiesDefinitionView.vue diff --git a/modules/sandbox/data/bootstrap.json b/modules/sandbox/data/bootstrap.json index c3093dc87..fa8a969ff 100644 --- a/modules/sandbox/data/bootstrap.json +++ b/modules/sandbox/data/bootstrap.json @@ -72,63 +72,54 @@ { "name": "priority", "type": "selection", - "string": "Priority", + "string": { "en_US": "Priority", "zh_CN": "优先级" }, "selection": [ - [ - "low", - "Low" - ], - [ - "normal", - "Normal" - ], - [ - "high", - "High" - ] + ["low", { "en_US": "Low", "zh_CN": "低" }], + ["normal", { "en_US": "Normal", "zh_CN": "普通" }], + ["high", { "en_US": "High", "zh_CN": "高" }] ], "default": "normal" }, { "name": "external_code", "type": "char", - "string": "External Code" + "string": { "en_US": "External Code", "zh_CN": "外部编码" } }, { "name": "is_vip", "type": "boolean", - "string": "VIP", + "string": { "en_US": "VIP", "zh_CN": "VIP" }, "default": false }, { "name": "qty_hint", "type": "integer", - "string": "Qty Hint" + "string": { "en_US": "Qty Hint", "zh_CN": "数量提示" } }, { "name": "score", "type": "float", - "string": "Score" + "string": { "en_US": "Score", "zh_CN": "分数" } }, { "name": "note", "type": "text", - "string": "Note" + "string": { "en_US": "Note", "zh_CN": "备注" } }, { "name": "due_date", "type": "date", - "string": "Due Date" + "string": { "en_US": "Due Date", "zh_CN": "截止日期" } }, { "name": "reviewed_at", "type": "datetime", - "string": "Reviewed At" + "string": { "en_US": "Reviewed At", "zh_CN": "审核时间" } }, { "name": "locked_code", "type": "char", - "string": "Locked Code", + "string": { "en_US": "Locked Code", "zh_CN": "锁定编码" }, "readonly": true, "default": "RO" } diff --git a/modules/sandbox/i18n/sandbox.pot b/modules/sandbox/i18n/sandbox.pot index 81cd084b5..9c5891011 100644 --- a/modules/sandbox/i18n/sandbox.pot +++ b/modules/sandbox/i18n/sandbox.pot @@ -332,32 +332,32 @@ msgctxt "sandbox.model.SandboxRecord.fields" msgid "Translatable varchar; use the translation action on the field." msgstr "" -#: service/models/sandbox_record_category.ts:28 +#: service/models/sandbox_record_category.ts:31 msgctxt "sandbox.model.SandboxRecordCategory.fields" msgid "Category" msgstr "" -#: service/models/sandbox_record_category.ts:18 +#: service/models/sandbox_record_category.ts:21 msgctxt "sandbox.model.SandboxRecordCategory.fields" msgid "Record" msgstr "" -#: service/models/sandbox_record_tag.ts:18 +#: service/models/sandbox_record_tag.ts:21 msgctxt "sandbox.model.SandboxRecordTag.fields" msgid "Record" msgstr "" -#: service/models/sandbox_record_tag.ts:29 +#: service/models/sandbox_record_tag.ts:32 msgctxt "sandbox.model.SandboxRecordTag.fields" msgid "Tag" msgstr "" -#: service/models/sandbox_record_tag_chip.ts:18 +#: service/models/sandbox_record_tag_chip.ts:21 msgctxt "sandbox.model.SandboxRecordTagChip.fields" msgid "Record" msgstr "" -#: service/models/sandbox_record_tag_chip.ts:29 +#: service/models/sandbox_record_tag_chip.ts:32 msgctxt "sandbox.model.SandboxRecordTagChip.fields" msgid "Tag" msgstr "" @@ -377,11 +377,21 @@ msgctxt "sandbox.model.SandboxTag.fields" msgid "Name" msgstr "" +#: web/menu/titles.ts:10 +msgctxt "web/menu/titles" +msgid "Configuration" +msgstr "" + #: web/menu/titles.ts:9 msgctxt "web/menu/titles" msgid "Field Kitchen Sink" msgstr "" +#: web/menu/titles.ts:11 +msgctxt "web/menu/titles" +msgid "Record Properties" +msgstr "" + #: web/menu/titles.ts:8 msgctxt "web/menu/titles" msgid "Sandbox" @@ -412,6 +422,11 @@ msgctxt "web/route/routes" msgid "New Sandbox Tag" msgstr "" +#: web/route/routes.ts:142 +msgctxt "web/route/routes" +msgid "Record Properties" +msgstr "" + #: web/route/routes.ts:61 msgctxt "web/route/routes" msgid "Sandbox Record Chart" @@ -800,6 +815,11 @@ msgctxt "web/views/SandboxRecordListView" msgid "View switcher" msgstr "" +#: web/views/SandboxRecordPropertiesDefinitionView.vue:5 +msgctxt "web/views/SandboxRecordPropertiesDefinitionView" +msgid "Edit the app-level schema for SandboxRecord.Properties. Labels support multiple languages; use Translate next to each label (and selection options) before Save." +msgstr "" + #: web/views/sandbox_tag_actions.ts:10 msgctxt "web/views/SandboxTagFormView" msgid "Copy Sandbox Tag" diff --git a/modules/sandbox/i18n/zh_CN.po b/modules/sandbox/i18n/zh_CN.po index e37f32ad7..ba019801a 100644 --- a/modules/sandbox/i18n/zh_CN.po +++ b/modules/sandbox/i18n/zh_CN.po @@ -326,32 +326,32 @@ msgctxt "sandbox.model.SandboxRecord.fields" msgid "Translatable varchar; use the translation action on the field." msgstr "可翻译 varchar;请使用字段上的翻译操作。" -#: service/models/sandbox_record_category.ts:28 +#: service/models/sandbox_record_category.ts:31 msgctxt "sandbox.model.SandboxRecordCategory.fields" msgid "Category" msgstr "分类" -#: service/models/sandbox_record_category.ts:18 +#: service/models/sandbox_record_category.ts:21 msgctxt "sandbox.model.SandboxRecordCategory.fields" msgid "Record" msgstr "记录" -#: service/models/sandbox_record_tag.ts:18 +#: service/models/sandbox_record_tag.ts:21 msgctxt "sandbox.model.SandboxRecordTag.fields" msgid "Record" msgstr "记录" -#: service/models/sandbox_record_tag.ts:29 +#: service/models/sandbox_record_tag.ts:32 msgctxt "sandbox.model.SandboxRecordTag.fields" msgid "Tag" msgstr "标签" -#: service/models/sandbox_record_tag_chip.ts:18 +#: service/models/sandbox_record_tag_chip.ts:21 msgctxt "sandbox.model.SandboxRecordTagChip.fields" msgid "Record" msgstr "记录" -#: service/models/sandbox_record_tag_chip.ts:29 +#: service/models/sandbox_record_tag_chip.ts:32 msgctxt "sandbox.model.SandboxRecordTagChip.fields" msgid "Tag" msgstr "标签" @@ -371,11 +371,21 @@ msgctxt "sandbox.model.SandboxTag.fields" msgid "Name" msgstr "名称" +#: web/menu/titles.ts:10 +msgctxt "web/menu/titles" +msgid "Configuration" +msgstr "配置" + #: web/menu/titles.ts:9 msgctxt "web/menu/titles" msgid "Field Kitchen Sink" msgstr "字段试验场" +#: web/menu/titles.ts:11 +msgctxt "web/menu/titles" +msgid "Record Properties" +msgstr "记录属性" + #: web/menu/titles.ts:8 msgctxt "web/menu/titles" msgid "Sandbox" @@ -406,6 +416,11 @@ msgctxt "web/route/routes" msgid "New Sandbox Tag" msgstr "新建沙箱标签" +#: web/route/routes.ts:142 +msgctxt "web/route/routes" +msgid "Record Properties" +msgstr "记录属性" + #: web/route/routes.ts:61 msgctxt "web/route/routes" msgid "Sandbox Record Chart" @@ -791,6 +806,11 @@ msgctxt "web/views/SandboxRecordListView" msgid "View switcher" msgstr "视图切换" +#: web/views/SandboxRecordPropertiesDefinitionView.vue:5 +msgctxt "web/views/SandboxRecordPropertiesDefinitionView" +msgid "Edit the app-level schema for SandboxRecord.Properties. Labels support multiple languages; use Translate next to each label (and selection options) before Save." +msgstr "编辑 SandboxRecord.Properties 的应用级 schema。标签支持多语言;请在保存前使用每项标签(及选项)旁的「翻译」。" + #: web/views/sandbox_tag_actions.ts:10 msgctxt "web/views/SandboxTagFormView" msgid "Copy Sandbox Tag" diff --git a/modules/sandbox/web/__tests__/lt_titles.test.ts b/modules/sandbox/web/__tests__/lt_titles.test.ts index 5d8e46017..b9205b50c 100644 --- a/modules/sandbox/web/__tests__/lt_titles.test.ts +++ b/modules/sandbox/web/__tests__/lt_titles.test.ts @@ -2,15 +2,25 @@ // SPDX-License-Identifier: Apache-2.0 import { createTranslate } from '@/core/service/i18n'; -import { sandboxRecordsMenuTitle, sandboxRootMenuTitle } from '../menu/titles'; +import { + sandboxConfigurationMenuTitle, + sandboxRecordPropertiesMenuTitle, + sandboxRecordsMenuTitle, + sandboxRootMenuTitle, +} from '../menu/titles'; import { sandboxRoutes } from '../route/routes'; const listRouteTitle = createTranslate('sandbox', { scope: 'web/route/routes' })._lt('Sandbox Record List'); +const recordPropertiesRouteTitle = createTranslate('sandbox', { scope: 'web/route/routes' })._lt( + 'Record Properties', +); test('sandbox menu titles: titles.ts registers shared TermReferences', () => { expect(sandboxRootMenuTitle.src).toBe('Sandbox'); expect(sandboxRootMenuTitle.module).toBe('sandbox'); expect(sandboxRecordsMenuTitle.src).toBe('Field Kitchen Sink'); + expect(sandboxConfigurationMenuTitle.src).toBe('Configuration'); + expect(sandboxRecordPropertiesMenuTitle.src).toBe('Record Properties'); }); test('sandbox route titles: list route pins pageTitleText', () => { @@ -19,3 +29,10 @@ test('sandbox route titles: list route pins pageTitleText', () => { expect(list.meta?.pageTitle).toBe('Sandbox Record List'); expect(list.meta?.pageTitleText).toEqual(listRouteTitle); }); + +test('sandbox route titles: record properties configuration pins pageTitleText', () => { + const route = sandboxRoutes.find(r => r.name === 'SandboxRecordProperties') as any; + expect(route).toBeTruthy(); + expect(route.meta?.pageTitle).toBe('Record Properties'); + expect(route.meta?.pageTitleText).toEqual(recordPropertiesRouteTitle); +}); diff --git a/modules/sandbox/web/__tests__/resourceWiring.test.ts b/modules/sandbox/web/__tests__/resourceWiring.test.ts index d21b30eff..26afaae0d 100644 --- a/modules/sandbox/web/__tests__/resourceWiring.test.ts +++ b/modules/sandbox/web/__tests__/resourceWiring.test.ts @@ -85,3 +85,9 @@ test('sandbox resource wiring: list/kanban/chart routes expose view switcher met viewPaths, }); }); + +test('sandbox resource wiring: record properties configuration route', () => { + const byName = Object.fromEntries(sandboxRoutes.map(route => [String(route.name), route])); + expect(byName.SandboxRecordProperties?.path).toBe('sandbox/configuration/record-properties'); + expect(byName.SandboxRecordProperties?.meta).toMatchObject({ requiresAuth: true }); +}); diff --git a/modules/sandbox/web/menu/menus.ts b/modules/sandbox/web/menu/menus.ts index 0427fe48c..2376bad61 100644 --- a/modules/sandbox/web/menu/menus.ts +++ b/modules/sandbox/web/menu/menus.ts @@ -4,7 +4,12 @@ import type { MenuItem } from '@/core/web/menu'; import { defineMenu } from '@/core/web/resource'; import { FlaskConical } from 'lucide-vue-next'; -import { sandboxRecordsMenuTitle, sandboxRootMenuTitle } from './titles'; +import { + sandboxConfigurationMenuTitle, + sandboxRecordPropertiesMenuTitle, + sandboxRecordsMenuTitle, + sandboxRootMenuTitle, +} from './titles'; export const sandboxMenus: MenuItem[] = [ defineMenu('sandbox.menu.root', { @@ -18,6 +23,18 @@ export const sandboxMenus: MenuItem[] = [ path: '/sandbox/records', sequence: 10, }), + // Pathless folder: app configuration (properties schema, future settings). + defineMenu('sandbox.menu.configuration', { + title: sandboxConfigurationMenuTitle, + sequence: 90, + children: [ + defineMenu('sandbox.menu.record_properties', { + title: sandboxRecordPropertiesMenuTitle, + path: '/sandbox/configuration/record-properties', + sequence: 10, + }), + ], + }), ], }), ]; diff --git a/modules/sandbox/web/menu/titles.ts b/modules/sandbox/web/menu/titles.ts index 6e4f895fa..af3f4edba 100644 --- a/modules/sandbox/web/menu/titles.ts +++ b/modules/sandbox/web/menu/titles.ts @@ -7,3 +7,5 @@ const { _lt } = createTranslate('sandbox', { scope: 'web/menu/titles' }); export const sandboxRootMenuTitle = _lt('Sandbox'); export const sandboxRecordsMenuTitle = _lt('Field Kitchen Sink'); +export const sandboxConfigurationMenuTitle = _lt('Configuration'); +export const sandboxRecordPropertiesMenuTitle = _lt('Record Properties'); diff --git a/modules/sandbox/web/pages/SandboxRecordProperties.vue b/modules/sandbox/web/pages/SandboxRecordProperties.vue new file mode 100644 index 000000000..efb3649a8 --- /dev/null +++ b/modules/sandbox/web/pages/SandboxRecordProperties.vue @@ -0,0 +1,17 @@ + + + + + diff --git a/modules/sandbox/web/pages/page_mount_coverage.test.ts b/modules/sandbox/web/pages/page_mount_coverage.test.ts index c615ada48..72c2810bd 100644 --- a/modules/sandbox/web/pages/page_mount_coverage.test.ts +++ b/modules/sandbox/web/pages/page_mount_coverage.test.ts @@ -8,6 +8,7 @@ import SandboxRecordList from './SandboxRecordList.vue'; import SandboxRecordKanban from './SandboxRecordKanban.vue'; import SandboxRecordChart from './SandboxRecordChart.vue'; import SandboxTag from './SandboxTag.vue'; +import SandboxRecordProperties from './SandboxRecordProperties.vue'; import { sandboxMenus } from '../menu/menus'; const pages: Array<[string, any, string]> = [ @@ -16,6 +17,7 @@ const pages: Array<[string, any, string]> = [ ['SandboxRecordChart', SandboxRecordChart, '/sandbox/records/chart'], ['SandboxRecord', SandboxRecord, '/sandbox/records/1'], ['SandboxTag', SandboxTag, '/sandbox/tags/new'], + ['SandboxRecordProperties', SandboxRecordProperties, '/sandbox/configuration/record-properties'], ]; test('sandbox page mount: every ChoyPage host mounts under choysumMount', async () => { @@ -45,4 +47,9 @@ test('sandbox menus: root declares an icon and leaves stay text-only', () => { expect(sandboxMenus.length).toBeGreaterThan(0); expect(sandboxMenus[0]!.icon).toBeTruthy(); expect(sandboxMenus[0]!.children?.every(child => child.icon == null)).toBe(true); + const configuration = sandboxMenus[0]!.children?.find(child => child.id === 'sandbox.menu.configuration'); + expect(configuration?.path).toBeFalsy(); + expect(configuration?.children?.some(child => child.path === '/sandbox/configuration/record-properties')).toBe( + true, + ); }); diff --git a/modules/sandbox/web/route/routes.ts b/modules/sandbox/web/route/routes.ts index 6459bcd66..db60a9eae 100644 --- a/modules/sandbox/web/route/routes.ts +++ b/modules/sandbox/web/route/routes.ts @@ -135,4 +135,14 @@ export const sandboxRoutes: RouteRecordRaw[] = [ ], meta: { requiresAuth: true }, }), + // App-level PropertyDefinition editor for SandboxRecord.Properties (label i18n UX). + // Write ACL comes from platform PropertyDefinition logical packs (auth RMA/RFR). + defineRoute('sandbox.route.record_properties', { + sequence: 80, + title: _lt('Record Properties'), + path: 'sandbox/configuration/record-properties', + name: 'SandboxRecordProperties', + component: () => import('../pages/SandboxRecordProperties.vue'), + meta: { requiresAuth: true }, + }), ]; diff --git a/modules/sandbox/web/views/SandboxRecordPropertiesDefinitionView.vue b/modules/sandbox/web/views/SandboxRecordPropertiesDefinitionView.vue new file mode 100644 index 000000000..49a7ff45e --- /dev/null +++ b/modules/sandbox/web/views/SandboxRecordPropertiesDefinitionView.vue @@ -0,0 +1,35 @@ + + + + + From 3a7d75117611c583346be18379b44f4bdb05afa4 Mon Sep 17 00:00:00 2001 From: Brian Wang Date: Wed, 28 Oct 2026 11:03:28 +0800 Subject: [PATCH 04/23] fix(schema): skip FKs for non-stored compute ManyToOne fields Also enrich create-FK errors with table/column context and log failed module commit holds at Error with the error value for stack traces. --- .../module/evolution/schema/foreignkey.go | 62 +++++++++++++- .../evolution/schema/foreignkey_test.go | 83 ++++++++++++++++++- internal/module/evolution/schema/migrator.go | 5 +- .../module/lifecycle/install_tx_timing.go | 11 +-- .../lifecycle/install_tx_timing_test.go | 29 ++++++- 5 files changed, 177 insertions(+), 13 deletions(-) diff --git a/internal/module/evolution/schema/foreignkey.go b/internal/module/evolution/schema/foreignkey.go index 9226b28fc..30ea4b1ed 100644 --- a/internal/module/evolution/schema/foreignkey.go +++ b/internal/module/evolution/schema/foreignkey.go @@ -11,6 +11,7 @@ import ( "github.com/choysum-dev/choysum/pkg/meta" "github.com/choysum-dev/choysum/pkg/scope" "github.com/ettle/strcase" + xfmt "golang.org/x/exp/errors/fmt" ) // ForeignKeyInfo describes a foreign key relationship. @@ -98,8 +99,11 @@ func (m *foreignKeyMigrator) getManyToOneKeys() ([]ForeignKeyInfo, error) { continue } - // Treat fields with a select clause as virtual and skip FK creation. - if _, hasSelect := opts["select"]; hasSelect { + skip, skipErr := shouldSkipManyToOneForeignKey(field, opts) + if skipErr != nil { + return nil, skipErr + } + if skip { continue } @@ -139,6 +143,51 @@ func (m *foreignKeyMigrator) getManyToOneKeys() ([]ForeignKeyInfo, error) { return foreignKeys, nil } +// shouldSkipManyToOneForeignKey skips virtual / non-column ManyToOne fields. +// Prefer ResolvedSpec.Migration.ShouldCreateColumn (COMPUTE_STORE_FALSE, related, +// sqlCompute, …); also skip @Field({ select }) and bare @Compute({ store: false }) +// when ResolvedSpec is absent. +func shouldSkipManyToOneForeignKey(field *meta.Field, opts map[string]interface{}) (bool, error) { + if _, hasSelect := opts["select"]; hasSelect { + return true, nil + } + if field == nil { + return false, nil + } + resolved, err := field.GetResolvedSpec() + if err != nil { + return false, fmt.Errorf("error unmarshal field resolved spec for %s: %w", field.Name, err) + } + if resolved != nil { + return !resolved.Migration.ShouldCreateColumn, nil + } + return fieldHasNonStoredComputeDecorator(field), nil +} + +func fieldHasNonStoredComputeDecorator(field *meta.Field) bool { + if field == nil { + return false + } + for _, decorator := range field.Decorators { + if decorator == nil || decorator.Name != "Compute" { + continue + } + for _, arg := range decorator.Arguments { + if arg == nil || arg.Type != "ObjectLiteral" || arg.Value == "" { + continue + } + var computeOpts map[string]interface{} + if err := json.Unmarshal([]byte(arg.Value), &computeOpts); err != nil { + continue + } + if store, ok := computeOpts["store"].(bool); ok && !store { + return true + } + } + } + return false +} + func (m *foreignKeyMigrator) resolveTargetModelByPath(targetModelPath string) (*meta.Model, error) { for _, model := range m.models { if model != nil && model.Path == targetModelPath { @@ -235,7 +284,14 @@ func (m *foreignKeyMigrator) createForeignKeys(foreignKeys []ForeignKeyInfo) err // Create the new foreign key. createSQL := builder.BuildForeignKeySQL(fk) if err := db.Exec(createSQL).Error; err != nil { - return fmt.Errorf("create foreign key failed: %v", err) + return xfmt.Errorf( + "create foreign key failed: table=%s column=%s refer=%s.%s: %w", + fk.TableName, + fk.ColumnName, + fk.ReferTableName, + fk.ReferColumnName, + err, + ) } } diff --git a/internal/module/evolution/schema/foreignkey_test.go b/internal/module/evolution/schema/foreignkey_test.go index bc245ec7f..69e0536f6 100644 --- a/internal/module/evolution/schema/foreignkey_test.go +++ b/internal/module/evolution/schema/foreignkey_test.go @@ -250,11 +250,92 @@ func TestForeignKeyMigratorRuntimePaths(t *testing.T) { fkMigrator := newForeignKeyMigrator(runtimeScope, nil, nil).(*foreignKeyMigrator) fks := []ForeignKeyInfo{{TableName: "sales_order", ColumnName: "owner_id", ReferTableName: "sales_user", ReferColumnName: "id", OnDelete: "CASCADE"}} - if err := fkMigrator.createForeignKeys(fks); err == nil || !strings.Contains(err.Error(), "create foreign key failed") { + err := fkMigrator.createForeignKeys(fks) + if err == nil || !strings.Contains(err.Error(), "create foreign key failed") { t.Fatalf("expected create foreign key error, got %v", err) } + for _, want := range []string{ + "table=sales_order", + "column=owner_id", + "refer=sales_user.id", + } { + if !strings.Contains(err.Error(), want) { + t.Fatalf("expected %q in error, got %v", want, err) + } + } if err := fkMigrator.MigrateForeignKeys(); err != nil { t.Fatalf("MigrateForeignKeys() with no keys error = %v", err) } }) + + t.Run("skips non-stored compute many-to-one via ResolvedSpec", func(t *testing.T) { + runtimeScope := newSchemaTestScope(t) + target := &meta.Model{Name: "Line", Path: "sandbox/line.ts", ModelTable: "sandbox_line"} + stored := newRelationField("OwnerId", "sandbox/line", `{"type":"ManyToOne"}`) + virtual := newRelationField("PrimaryLineId", "sandbox/line", `{"type":"ManyToOne","index":true,"readonly":true}`) + if err := virtual.SetResolvedSpec(&meta.FieldResolvedSpec{ + FieldName: "PrimaryLineId", + Structural: meta.FieldStructuralSpec{ + Name: "PrimaryLineId", + FieldType: "ManyToOne", + }, + Migration: meta.FieldMigrationDecision{ + StorageKind: "virtualRuntime", + ShouldCreateColumn: false, + ReasonCode: "COMPUTE_STORE_FALSE", + }, + }); err != nil { + t.Fatalf("SetResolvedSpec: %v", err) + } + source := &meta.Model{ + Name: "Record", + Path: "sandbox/record.ts", + ModelTable: "sandbox_record", + Fields: []*meta.Field{stored, virtual}, + } + + keys, err := newForeignKeyMigrator(runtimeScope, nil, []*meta.Model{source, target}).(*foreignKeyMigrator).getManyToOneKeys() + if err != nil { + t.Fatalf("getManyToOneKeys() error = %v", err) + } + if len(keys) != 1 || keys[0].ColumnName != "owner_id" { + t.Fatalf("expected only stored owner_id FK, got %#v", keys) + } + }) + + t.Run("skips non-stored compute many-to-one via Compute decorator fallback", func(t *testing.T) { + runtimeScope := newSchemaTestScope(t) + target := &meta.Model{Name: "Line", Path: "sandbox/line.ts", ModelTable: "sandbox_line"} + virtual := &meta.Field{ + Name: "PrimaryLineId", + ModuleSpecPath: "sandbox/line", + Decorators: []*meta.Decorator{ + { + Name: "Field", + Arguments: []*meta.Argument{{Type: "ObjectLiteral", Value: `{"type":"ManyToOne"}`}}, + }, + { + Name: "Compute", + Arguments: []*meta.Argument{ + {Type: "StringLiteral", Value: "PrimaryLineId"}, + {Type: "ObjectLiteral", Value: `{"deps":["Lines.Id"],"store":false}`}, + }, + }, + }, + } + source := &meta.Model{ + Name: "Record", + Path: "sandbox/record.ts", + ModelTable: "sandbox_record", + Fields: []*meta.Field{virtual}, + } + + keys, err := newForeignKeyMigrator(runtimeScope, nil, []*meta.Model{source, target}).(*foreignKeyMigrator).getManyToOneKeys() + if err != nil { + t.Fatalf("getManyToOneKeys() error = %v", err) + } + if len(keys) != 0 { + t.Fatalf("expected no FK for store:false Compute, got %#v", keys) + } + }) } diff --git a/internal/module/evolution/schema/migrator.go b/internal/module/evolution/schema/migrator.go index 508709656..24c9ff073 100644 --- a/internal/module/evolution/schema/migrator.go +++ b/internal/module/evolution/schema/migrator.go @@ -8,6 +8,7 @@ import ( "github.com/choysum-dev/choysum/pkg/meta" "github.com/choysum-dev/choysum/pkg/scope" + xfmt "golang.org/x/exp/errors/fmt" ) type Migrator interface { @@ -82,10 +83,10 @@ type migrator struct { func (m *migrator) Migrate() error { if err := m.modelMigrator.MigrateSchema(); err != nil { - return fmt.Errorf("migrate schema: %w", err) + return xfmt.Errorf("migrate schema: %w", err) } if err := m.foreignKeyMigrator.MigrateForeignKeys(); err != nil { - return fmt.Errorf("migrate foreign keys: %w", err) + return xfmt.Errorf("migrate foreign keys: %w", err) } return nil } diff --git a/internal/module/lifecycle/install_tx_timing.go b/internal/module/lifecycle/install_tx_timing.go index 112db4456..c7a8608fb 100644 --- a/internal/module/lifecycle/install_tx_timing.go +++ b/internal/module/lifecycle/install_tx_timing.go @@ -11,7 +11,7 @@ import ( // LogModuleCommitTxHold records how long a per-module Commit Required // transaction was held (install / upgrade / uninstall). Successful holds stay -// Debug (ops metric); failures stay Info. +// Debug (ops metric); failures log at Error. func LogModuleCommitTxHold(logger *slog.Logger, op string, caller string, started time.Time, err error) { if logger == nil || started.IsZero() { return @@ -36,13 +36,14 @@ func logModuleCommitTxHold(logger *slog.Logger, op string, caller string, durati "caller", caller, "duration_ms", duration.Milliseconds(), } - msg := op + " commit transaction hold completed" if err != nil { - attrs = append(attrs, "error", err.Error()) - logger.Info(msg, attrs...) + // Pass the error value (not err.Error()) so console/devslog/json + // ReplaceAttr can expand xfmt / QuickJS frames into a stack trace. + attrs = append(attrs, "error", err) + logger.Error(op+" commit transaction hold failed", attrs...) return } - logger.Debug(msg, attrs...) + logger.Debug(op+" commit transaction hold completed", attrs...) } // LogInstallOuterTxHold records install Commit / legacy outer Required hold time. diff --git a/internal/module/lifecycle/install_tx_timing_test.go b/internal/module/lifecycle/install_tx_timing_test.go index 88c8159c6..9a78a0e85 100644 --- a/internal/module/lifecycle/install_tx_timing_test.go +++ b/internal/module/lifecycle/install_tx_timing_test.go @@ -69,8 +69,8 @@ func TestLogModuleCommitTxHoldIncludesError(t *testing.T) { logs := logBuf.String() for _, want := range []string{ - `"level":"INFO"`, - `"msg":"uninstall commit transaction hold completed"`, + `"level":"ERROR"`, + `"msg":"uninstall commit transaction hold failed"`, `"error":"commit failed"`, } { if !strings.Contains(logs, want) { @@ -79,6 +79,28 @@ func TestLogModuleCommitTxHoldIncludesError(t *testing.T) { } } +func TestLogModuleCommitTxHoldPassesErrorValueForStack(t *testing.T) { + t.Parallel() + var logBuf bytes.Buffer + // Capture attrs via a thin handler so we can assert the error is not stringified. + logger := slog.New(slog.NewJSONHandler(&logBuf, &slog.HandlerOptions{ + Level: slog.LevelError, + ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr { + if a.Key == "error" && a.Value.Kind() == slog.KindAny { + if _, ok := a.Value.Any().(error); !ok { + t.Fatalf("error attr must remain an error value, got %#v", a.Value.Any()) + } + } + return a + }, + })) + errSample := errors.New("commit failed") + logModuleCommitTxHold(logger, "install", "module_commit", 10*time.Millisecond, errSample) + if !strings.Contains(logBuf.String(), "commit failed") { + t.Fatalf("expected error text in logs, got %q", logBuf.String()) + } +} + func TestLogInstallOuterTxHoldEmitsDuration(t *testing.T) { t.Parallel() var logBuf bytes.Buffer @@ -127,6 +149,9 @@ func TestLogInstallOuterTxHoldIncludesError(t *testing.T) { LogInstallOuterTxHold(logger, "cli", time.Now().Add(-10*time.Millisecond), errSample) logs := logBuf.String() + if !strings.Contains(logs, `"level":"ERROR"`) { + t.Fatalf("expected ERROR level, got %q", logs) + } if !strings.Contains(logs, `"error":"outer failed"`) { t.Fatalf("expected error attr, got %q", logs) } From ce8a8cc075dd9d7e54652d02bb54dcac5415c7eb Mon Sep 17 00:00:00 2001 From: Brian Wang Date: Wed, 28 Oct 2026 14:39:59 +0800 Subject: [PATCH 05/23] =?UTF-8?q?feat(web):=20PropertyDefinition=20List?= =?UTF-8?q?=E2=86=92Form=20editor=20with=20selection=20list=20and=20handle?= =?UTF-8?q?=20drag=20UX?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the standalone definition editor with a form Field, item dialog, and O2M-style selection options (reorder, translate icon). Keep jsonobject Definition arrays intact in buildUpdatePayload, and share HTML5 drag-image plus overIndex feedback across list handles, O2M Kanban, and definition tables. --- modules/core/i18n/core.pot | 69 ++- modules/core/i18n/zh_CN.po | 68 ++- .../model/property_definition_base_model.ts | 36 +- .../orm/utils/relation_patch_diff.test.ts | 21 + modules/core/utils/diff.ts | 5 + modules/sandbox/i18n/sandbox.pot | 115 +++- modules/sandbox/i18n/zh_CN.po | 120 +++- .../sandbox/web/__tests__/lt_titles.test.ts | 4 +- .../web/__tests__/resourceWiring.test.ts | 10 +- .../web/pages/SandboxPropertyDefinition.vue | 37 ++ .../pages/SandboxPropertyDefinitionList.vue | 28 + .../web/pages/SandboxRecordProperties.vue | 17 - .../web/pages/page_mount_coverage.test.ts | 6 +- modules/sandbox/web/route/routes.ts | 42 +- .../SandboxPropertyDefinitionFormView.vue | 102 ++++ .../SandboxPropertyDefinitionListView.vue | 92 +++ .../SandboxRecordPropertiesDefinitionView.vue | 35 -- .../sandbox_property_definition_actions.ts | 12 + modules/web/i18n/web.pot | 174 ++++-- modules/web/i18n/zh_CN.po | 247 ++++++-- .../field/ChoyOneToManyKanbanField.vue | 25 +- .../ChoyPropertiesDefinitionEditor.test.ts | 236 -------- .../field/ChoyPropertiesDefinitionEditor.vue | 435 -------------- .../ChoyPropertiesDefinitionField.test.ts | 369 ++++++++++++ .../field/ChoyPropertiesDefinitionField.vue | 568 ++++++++++++++++++ .../ChoyPropertiesDefinitionItemDialog.vue | 299 +++++++++ ...ropertiesDefinitionSelectionEditor.test.ts | 148 +++++ ...hoyPropertiesDefinitionSelectionEditor.vue | 224 +++++++ .../field/propertiesDefinitionHelpers.test.ts | 36 ++ .../field/propertiesDefinitionHelpers.ts | 69 ++- .../web/web/components/internal/DataTable.vue | 45 +- .../table/ChoyTableColumn.handle.test.ts | 28 +- .../web/components/table/ChoyTableColumn.vue | 7 +- .../view/ChoyKanbanVirtualLaneBody.vue | 12 +- .../composables/useListHandleReorder.test.ts | 74 ++- .../web/composables/useListHandleReorder.ts | 68 ++- modules/web/web/kit.ts | 4 +- modules/web/web/utils/diff_payload.test.ts | 13 + 38 files changed, 2997 insertions(+), 903 deletions(-) create mode 100644 modules/sandbox/web/pages/SandboxPropertyDefinition.vue create mode 100644 modules/sandbox/web/pages/SandboxPropertyDefinitionList.vue delete mode 100644 modules/sandbox/web/pages/SandboxRecordProperties.vue create mode 100644 modules/sandbox/web/views/SandboxPropertyDefinitionFormView.vue create mode 100644 modules/sandbox/web/views/SandboxPropertyDefinitionListView.vue delete mode 100644 modules/sandbox/web/views/SandboxRecordPropertiesDefinitionView.vue create mode 100644 modules/sandbox/web/views/sandbox_property_definition_actions.ts delete mode 100644 modules/web/web/components/field/ChoyPropertiesDefinitionEditor.test.ts delete mode 100644 modules/web/web/components/field/ChoyPropertiesDefinitionEditor.vue create mode 100644 modules/web/web/components/field/ChoyPropertiesDefinitionField.test.ts create mode 100644 modules/web/web/components/field/ChoyPropertiesDefinitionField.vue create mode 100644 modules/web/web/components/field/ChoyPropertiesDefinitionItemDialog.vue create mode 100644 modules/web/web/components/field/ChoyPropertiesDefinitionSelectionEditor.test.ts create mode 100644 modules/web/web/components/field/ChoyPropertiesDefinitionSelectionEditor.vue diff --git a/modules/core/i18n/core.pot b/modules/core/i18n/core.pot index 4f7f7c6c8..91d630075 100644 --- a/modules/core/i18n/core.pot +++ b/modules/core/i18n/core.pot @@ -7,27 +7,27 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -#: service/orm/model/model.ts:430 +#: service/orm/model/model.ts:455 msgctxt "core.model.BaseModel.fields" msgid "Created At" msgstr "" -#: service/orm/model/model.ts:466 +#: service/orm/model/model.ts:491 msgctxt "core.model.BaseModel.fields" msgid "Created By" msgstr "" -#: service/orm/model/model.ts:452 +#: service/orm/model/model.ts:477 msgctxt "core.model.BaseModel.fields" msgid "Deleted At" msgstr "" -#: service/orm/model/model.ts:494 +#: service/orm/model/model.ts:519 msgctxt "core.model.BaseModel.fields" msgid "Deleted By" msgstr "" -#: service/orm/model/model.ts:408 +#: service/orm/model/model.ts:433 msgctxt "core.model.BaseModel.fields" msgid "Display Name" msgstr "" @@ -37,48 +37,73 @@ msgctxt "core.model.BaseModel.fields" msgid "Parent Path" msgstr "" -#: service/orm/model/model.ts:441 +#: service/orm/model/model.ts:466 msgctxt "core.model.BaseModel.fields" msgid "Updated At" msgstr "" -#: service/orm/model/model.ts:480 +#: service/orm/model/model.ts:505 msgctxt "core.model.BaseModel.fields" msgid "Updated By" msgstr "" -#: service/orm/model/model.ts:287 +#: service/orm/model/property_definition_base_model.ts:216 +msgctxt "core.model.PropertyDefinition.fields" +msgid "Container Id" +msgstr "" + +#: service/orm/model/property_definition_base_model.ts:207 +msgctxt "core.model.PropertyDefinition.fields" +msgid "Container Model" +msgstr "" + +#: service/orm/model/property_definition_base_model.ts:224 +msgctxt "core.model.PropertyDefinition.fields" +msgid "Definition" +msgstr "" + +#: service/orm/model/property_definition_base_model.ts:199 +msgctxt "core.model.PropertyDefinition.fields" +msgid "Properties Field" +msgstr "" + +#: service/orm/model/property_definition_base_model.ts:190 +msgctxt "core.model.PropertyDefinition.fields" +msgid "Target Model" +msgstr "" + +#: service/orm/model/model.ts:308 msgctxt "service/orm/model/model" msgid "current company is required" msgstr "" -#: service/orm/model/model.ts:276 +#: service/orm/model/model.ts:297 msgctxt "service/orm/model/model" msgid "current user is required" msgstr "" -#: service/orm/model/model_instance.ts:189 +#: service/orm/model/model_instance.ts:197 msgctxt "service/orm/model/model_instance" msgid "Delete failed: Record with Id %s not found or could not be deleted" msgstr "" -#: service/orm/model/model_instance.ts:226 -#: service/orm/model/model_instance.ts:89 +#: service/orm/model/model_instance.ts:238 +#: service/orm/model/model_instance.ts:92 msgctxt "service/orm/model/model_instance" msgid "Record with Id %s no longer exists" msgstr "" -#: service/orm/model/model_read.ts:318 +#: service/orm/model/model_read.ts:324 msgctxt "service/orm/model/model_read" msgid "%s not found" msgstr "" -#: service/orm/model/model_soft_delete_scope.ts:29 +#: service/orm/model/model_soft_delete_scope.ts:23 msgctxt "service/orm/model/model_soft_delete_scope" msgid "withDeleted and onlyDeleted cannot both be true" msgstr "" -#: service/orm/model/model_update.ts:664 +#: service/orm/model/model_update.ts:667 msgctxt "service/orm/model/model_update" msgid "Update failed: Record %s not found." msgstr "" @@ -155,32 +180,32 @@ msgctxt "service/orm/repository/authz/record_rule_helpers" msgid "failed to fetch record rule condition" msgstr "" -#: service/orm/repository/authz/record_rule_helpers.ts:203 +#: service/orm/repository/authz/record_rule_helpers.ts:214 msgctxt "service/orm/repository/authz/record_rule_helpers" msgid "invalid JSON in record rule condition" msgstr "" -#: service/orm/repository/authz/record_rule_helpers.ts:228 +#: service/orm/repository/authz/record_rule_helpers.ts:239 msgctxt "service/orm/repository/authz/record_rule_helpers" msgid "invalid record rule condition shape (type=%s, isArray=%s, preview=%s)" msgstr "" -#: service/orm/repository/authz/record_rule_helpers.ts:154 +#: service/orm/repository/authz/record_rule_helpers.ts:165 msgctxt "service/orm/repository/authz/record_rule_helpers" msgid "missing ctx.activeCompanyId for $companyId token" msgstr "" -#: service/orm/repository/authz/record_rule_helpers.ts:166 +#: service/orm/repository/authz/record_rule_helpers.ts:177 msgctxt "service/orm/repository/authz/record_rule_helpers" msgid "missing ctx.enabledCompanyIds for $companyIds token" msgstr "" -#: service/orm/repository/authz/record_rule_helpers.ts:252 +#: service/orm/repository/authz/record_rule_helpers.ts:263 msgctxt "service/orm/repository/authz/record_rule_helpers" msgid "missing identity.userId for $userId token" msgstr "" -#: service/orm/repository/authz/record_rule_helpers.ts:266 +#: service/orm/repository/authz/record_rule_helpers.ts:277 msgctxt "service/orm/repository/authz/record_rule_helpers" msgid "unknown token in record rule condition" msgstr "" @@ -211,7 +236,7 @@ msgctxt "service/orm/repository/validation/kernel" msgid "Field \"%s\" is required" msgstr "" -#: service/orm/repository/validation/kernel.ts:224 +#: service/orm/repository/validation/kernel.ts:231 msgctxt "service/orm/repository/validation/kernel" msgid "Field \"%s\" must be a valid decimal that satisfies the precision and scale limits" msgstr "" diff --git a/modules/core/i18n/zh_CN.po b/modules/core/i18n/zh_CN.po index 9e44aa5a6..a4e5313ef 100644 --- a/modules/core/i18n/zh_CN.po +++ b/modules/core/i18n/zh_CN.po @@ -1,27 +1,27 @@ msgid "" msgstr "Content-Type: text/plain; charset=UTF-8\nContent-Transfer-Encoding: 8bit\nLanguage: zh_CN\n" -#: service/orm/model/model.ts:430 +#: service/orm/model/model.ts:455 msgctxt "core.model.BaseModel.fields" msgid "Created At" msgstr "创建时间" -#: service/orm/model/model.ts:466 +#: service/orm/model/model.ts:491 msgctxt "core.model.BaseModel.fields" msgid "Created By" msgstr "创建人" -#: service/orm/model/model.ts:452 +#: service/orm/model/model.ts:477 msgctxt "core.model.BaseModel.fields" msgid "Deleted At" msgstr "删除时间" -#: service/orm/model/model.ts:494 +#: service/orm/model/model.ts:519 msgctxt "core.model.BaseModel.fields" msgid "Deleted By" msgstr "删除人" -#: service/orm/model/model.ts:408 +#: service/orm/model/model.ts:433 msgctxt "core.model.BaseModel.fields" msgid "Display Name" msgstr "显示名称" @@ -31,47 +31,72 @@ msgctxt "core.model.BaseModel.fields" msgid "Parent Path" msgstr "父级路径" -#: service/orm/model/model.ts:441 +#: service/orm/model/model.ts:466 msgctxt "core.model.BaseModel.fields" msgid "Updated At" msgstr "更新时间" -#: service/orm/model/model.ts:480 +#: service/orm/model/model.ts:505 msgctxt "core.model.BaseModel.fields" msgid "Updated By" msgstr "更新人" -#: service/orm/model/model.ts:287 +#: service/orm/model/property_definition_base_model.ts:216 +msgctxt "core.model.PropertyDefinition.fields" +msgid "Container Id" +msgstr "容器 ID" + +#: service/orm/model/property_definition_base_model.ts:207 +msgctxt "core.model.PropertyDefinition.fields" +msgid "Container Model" +msgstr "容器模型" + +#: service/orm/model/property_definition_base_model.ts:224 +msgctxt "core.model.PropertyDefinition.fields" +msgid "Definition" +msgstr "定义" + +#: service/orm/model/property_definition_base_model.ts:199 +msgctxt "core.model.PropertyDefinition.fields" +msgid "Properties Field" +msgstr "属性字段" + +#: service/orm/model/property_definition_base_model.ts:190 +msgctxt "core.model.PropertyDefinition.fields" +msgid "Target Model" +msgstr "目标模型" + +#: service/orm/model/model.ts:308 msgctxt "service/orm/model/model" msgid "current company is required" msgstr "需要当前公司" -#: service/orm/model/model.ts:276 +#: service/orm/model/model.ts:297 msgctxt "service/orm/model/model" msgid "current user is required" msgstr "需要当前用户" -#: service/orm/model/model_instance.ts:189 +#: service/orm/model/model_instance.ts:197 msgctxt "service/orm/model/model_instance" msgid "Delete failed: Record with Id %s not found or could not be deleted" msgstr "删除失败:未找到 Id 为 %s 的记录,或无法删除" -#: service/orm/model/model_instance.ts:226 service/orm/model/model_instance.ts:89 +#: service/orm/model/model_instance.ts:238 service/orm/model/model_instance.ts:92 msgctxt "service/orm/model/model_instance" msgid "Record with Id %s no longer exists" msgstr "Id 为 %s 的记录已不存在" -#: service/orm/model/model_read.ts:318 +#: service/orm/model/model_read.ts:324 msgctxt "service/orm/model/model_read" msgid "%s not found" msgstr "未找到 %s" -#: service/orm/model/model_soft_delete_scope.ts:29 +#: service/orm/model/model_soft_delete_scope.ts:23 msgctxt "service/orm/model/model_soft_delete_scope" msgid "withDeleted and onlyDeleted cannot both be true" msgstr "withDeleted 与 onlyDeleted 不能同时为 true" -#: service/orm/model/model_update.ts:664 +#: service/orm/model/model_update.ts:667 msgctxt "service/orm/model/model_update" msgid "Update failed: Record %s not found." msgstr "更新失败:未找到记录 %s。" @@ -146,32 +171,32 @@ msgctxt "service/orm/repository/authz/record_rule_helpers" msgid "failed to fetch record rule condition" msgstr "获取记录规则条件失败" -#: service/orm/repository/authz/record_rule_helpers.ts:203 +#: service/orm/repository/authz/record_rule_helpers.ts:214 msgctxt "service/orm/repository/authz/record_rule_helpers" msgid "invalid JSON in record rule condition" msgstr "记录规则条件中的 JSON 无效" -#: service/orm/repository/authz/record_rule_helpers.ts:228 +#: service/orm/repository/authz/record_rule_helpers.ts:239 msgctxt "service/orm/repository/authz/record_rule_helpers" msgid "invalid record rule condition shape (type=%s, isArray=%s, preview=%s)" msgstr "记录规则条件形状无效(type=%s, isArray=%s, preview=%s)" -#: service/orm/repository/authz/record_rule_helpers.ts:154 +#: service/orm/repository/authz/record_rule_helpers.ts:165 msgctxt "service/orm/repository/authz/record_rule_helpers" msgid "missing ctx.activeCompanyId for $companyId token" msgstr "$companyId 令牌缺少 ctx.activeCompanyId" -#: service/orm/repository/authz/record_rule_helpers.ts:166 +#: service/orm/repository/authz/record_rule_helpers.ts:177 msgctxt "service/orm/repository/authz/record_rule_helpers" msgid "missing ctx.enabledCompanyIds for $companyIds token" msgstr "$companyIds 令牌缺少 ctx.enabledCompanyIds" -#: service/orm/repository/authz/record_rule_helpers.ts:252 +#: service/orm/repository/authz/record_rule_helpers.ts:263 msgctxt "service/orm/repository/authz/record_rule_helpers" msgid "missing identity.userId for $userId token" msgstr "$userId 令牌缺少 identity.userId" -#: service/orm/repository/authz/record_rule_helpers.ts:266 +#: service/orm/repository/authz/record_rule_helpers.ts:277 msgctxt "service/orm/repository/authz/record_rule_helpers" msgid "unknown token in record rule condition" msgstr "记录规则条件中存在未知令牌" @@ -201,7 +226,7 @@ msgctxt "service/orm/repository/validation/kernel" msgid "Field \"%s\" is required" msgstr "字段 \"%s\" 为必填" -#: service/orm/repository/validation/kernel.ts:224 +#: service/orm/repository/validation/kernel.ts:231 msgctxt "service/orm/repository/validation/kernel" msgid "Field \"%s\" must be a valid decimal that satisfies the precision and scale limits" msgstr "字段 \"%s\" 必须是满足精度与小数位限制的有效小数" @@ -290,3 +315,4 @@ msgstr "意外错误" msgctxt "web/rpc/errors" msgid "Unknown API error" msgstr "未知 API 错误" + diff --git a/modules/core/service/orm/model/property_definition_base_model.ts b/modules/core/service/orm/model/property_definition_base_model.ts index 84b7cb1f4..8f49dd29d 100644 --- a/modules/core/service/orm/model/property_definition_base_model.ts +++ b/modules/core/service/orm/model/property_definition_base_model.ts @@ -3,6 +3,7 @@ import { Field } from '../decorator/field'; import { raiseDomainError } from '@/core/service/error'; +import { _lt } from '@/core/service/i18n_binder'; import { MetadataStorage } from '../metadata/storage'; import BaseModel from './model'; import { registerLogicalModelName } from './logical_model_registry'; @@ -181,22 +182,47 @@ async function ensureDefinitionUniqueIndex(ctor: ModelCtor> | undefined = undefined>( diff --git a/modules/core/service/orm/utils/relation_patch_diff.test.ts b/modules/core/service/orm/utils/relation_patch_diff.test.ts index 9afbe4673..89dcaa086 100644 --- a/modules/core/service/orm/utils/relation_patch_diff.test.ts +++ b/modules/core/service/orm/utils/relation_patch_diff.test.ts @@ -378,6 +378,27 @@ test('buildUpdatePayload treats jsonobject via resolvedColumnType as whole-map r expect(payload.Meta).toEqual({ a: 9, b: 2 }); }); +test('buildUpdatePayload keeps jsonobject array columns as arrays (not o2m / numeric-key maps)', () => { + const original = { + Definition: [ + { name: 'external_code', type: 'char', string: { en_US: 'External Code', zh_CN: '外部编码' } }, + { name: 'is_vip', type: 'boolean', string: { en_US: 'VIP', zh_CN: 'VIP' } }, + ], + }; + const current = { + Definition: [ + { name: 'external_code', type: 'char', string: { en_US: 'External Code', zh_CN: '外部' } }, + { name: 'is_vip', type: 'boolean', string: { en_US: 'VIP', zh_CN: 'VIP' } }, + ], + }; + const payload = buildUpdatePayload(original, current, { + Definition: { type: 'jsonobject' }, + }); + expect(Array.isArray(payload.Definition)).toBe(true); + expect(payload.Definition).toEqual(current.Definition); + expect((payload.Definition as object | undefined)?.constructor).toBe(Array); +}); + test('buildUpdatePayload clears whole-map column when current is null', () => { const payload = buildUpdatePayload( { Properties: { a: 1 } }, diff --git a/modules/core/utils/diff.ts b/modules/core/utils/diff.ts index 20901e36e..a1facf8db 100644 --- a/modules/core/utils/diff.ts +++ b/modules/core/utils/diff.ts @@ -32,6 +32,9 @@ function isWholeMapReplaceFieldMeta(fm: { type?: string; resolvedColumnType?: st function mergePlainMaps(original: unknown, current: unknown): Plain | unknown { if (current === null || current === undefined) return current; + // jsonobject may store a JSON array (e.g. PropertyDefinition.Definition). + // Spreading arrays into a plain object yields {0:…,1:…} and breaks BE Array.isArray checks. + if (Array.isArray(current) || Array.isArray(original)) return current; const oMap = asObjectRecord(original); const cMap = asObjectRecord(current); if (oMap && cMap) return { ...oMap, ...cMap }; @@ -409,6 +412,8 @@ export function buildUpdatePayload(original: Plain, current: Plain, fieldsMeta?: for (const key of allKeys) { if (processed.has(key) || readonlyKeys.has(key)) continue; if (fieldsMeta?.[key]?.relation) continue; + // jsonobject / properties may hold JSON arrays; never treat them as o2m. + if (isWholeMapReplaceFieldMeta(fieldsMeta?.[key])) continue; const oVal = original?.[key]; const cVal = current?.[key]; diff --git a/modules/sandbox/i18n/sandbox.pot b/modules/sandbox/i18n/sandbox.pot index 9c5891011..17f052526 100644 --- a/modules/sandbox/i18n/sandbox.pot +++ b/modules/sandbox/i18n/sandbox.pot @@ -397,6 +397,11 @@ msgctxt "web/menu/titles" msgid "Sandbox" msgstr "" +#: web/pages/SandboxPropertyDefinitionList.vue:11 +msgctxt "web/pages/SandboxPropertyDefinitionList" +msgid "Record Properties" +msgstr "" + #: web/pages/SandboxRecordChart.vue:12 msgctxt "web/pages/SandboxRecordChart" msgid "Sandbox Record Chart" @@ -412,6 +417,11 @@ msgctxt "web/pages/SandboxRecordList" msgid "Field Kitchen Sink" msgstr "" +#: web/route/routes.ts:156 +msgctxt "web/route/routes" +msgid "New Property Definition" +msgstr "" + #: web/route/routes.ts:82 msgctxt "web/route/routes" msgid "New Sandbox Record" @@ -422,6 +432,11 @@ msgctxt "web/route/routes" msgid "New Sandbox Tag" msgstr "" +#: web/route/routes.ts:170 +msgctxt "web/route/routes" +msgid "Property Definition" +msgstr "" + #: web/route/routes.ts:142 msgctxt "web/route/routes" msgid "Record Properties" @@ -502,6 +517,101 @@ msgctxt "web/views/SandboxLinesVirtTools" msgid "Short Lines stay content-sized. Open VIRT-LINES or fill rows to exercise capped table / kanban virtualize (not saved until you Save)." msgstr "" +#: web/views/SandboxPropertyDefinitionFormView.vue:90 +msgctxt "web/views/SandboxPropertyDefinitionFormView" +msgid "Container Id" +msgstr "" + +#: web/views/SandboxPropertyDefinitionFormView.vue:83 +msgctxt "web/views/SandboxPropertyDefinitionFormView" +msgid "Container Model" +msgstr "" + +#: web/views/SandboxPropertyDefinitionFormView.vue:70 +msgctxt "web/views/SandboxPropertyDefinitionFormView" +msgid "Container scope" +msgstr "" + +#: web/views/sandbox_property_definition_actions.ts:10 +msgctxt "web/views/SandboxPropertyDefinitionFormView" +msgid "Copy Property Definition" +msgstr "" + +#: web/views/sandbox_property_definition_actions.ts:10 +msgctxt "web/views/SandboxPropertyDefinitionFormView" +msgid "Create Property Definition" +msgstr "" + +#: web/views/SandboxPropertyDefinitionFormView.vue:23 +msgctxt "web/views/SandboxPropertyDefinitionFormView" +msgid "Definition" +msgstr "" + +#: web/views/sandbox_property_definition_actions.ts:10 +msgctxt "web/views/SandboxPropertyDefinitionFormView" +msgid "Delete Property Definition" +msgstr "" + +#: web/views/sandbox_property_definition_actions.ts:10 +msgctxt "web/views/SandboxPropertyDefinitionFormView" +msgid "Edit Property Definition" +msgstr "" + +#: web/views/SandboxPropertyDefinitionFormView.vue:73 +msgctxt "web/views/SandboxPropertyDefinitionFormView" +msgid "Optional parent container for record-scoped definitions. Leave empty for app-level properties (e.g. SandboxRecord.Properties)." +msgstr "" + +#: web/views/SandboxPropertyDefinitionFormView.vue:15 +msgctxt "web/views/SandboxPropertyDefinitionFormView" +msgid "Properties Field" +msgstr "" + +#: web/views/sandbox_property_definition_actions.ts:11 +msgctxt "web/views/SandboxPropertyDefinitionFormView" +msgid "Property Definition" +msgstr "" + +#: web/views/SandboxPropertyDefinitionFormView.vue:21 +msgctxt "web/views/SandboxPropertyDefinitionFormView" +msgid "Required" +msgstr "" + +#: web/views/SandboxPropertyDefinitionFormView.vue:9 +msgctxt "web/views/SandboxPropertyDefinitionFormView" +msgid "Scope" +msgstr "" + +#: web/views/SandboxPropertyDefinitionFormView.vue:12 +msgctxt "web/views/SandboxPropertyDefinitionFormView" +msgid "Target Model" +msgstr "" + +#: web/views/SandboxPropertyDefinitionListView.vue:11 +msgctxt "web/views/SandboxPropertyDefinitionListView" +msgid "Container Id" +msgstr "" + +#: web/views/SandboxPropertyDefinitionListView.vue:10 +msgctxt "web/views/SandboxPropertyDefinitionListView" +msgid "Container Model" +msgstr "" + +#: web/views/SandboxPropertyDefinitionListView.vue:12 +msgctxt "web/views/SandboxPropertyDefinitionListView" +msgid "Definition" +msgstr "" + +#: web/views/SandboxPropertyDefinitionListView.vue:9 +msgctxt "web/views/SandboxPropertyDefinitionListView" +msgid "Properties Field" +msgstr "" + +#: web/views/SandboxPropertyDefinitionListView.vue:8 +msgctxt "web/views/SandboxPropertyDefinitionListView" +msgid "Target Model" +msgstr "" + #: web/views/SandboxRecordChartView.vue:43 msgctxt "web/views/SandboxRecordChartView" msgid "Chart View" @@ -815,11 +925,6 @@ msgctxt "web/views/SandboxRecordListView" msgid "View switcher" msgstr "" -#: web/views/SandboxRecordPropertiesDefinitionView.vue:5 -msgctxt "web/views/SandboxRecordPropertiesDefinitionView" -msgid "Edit the app-level schema for SandboxRecord.Properties. Labels support multiple languages; use Translate next to each label (and selection options) before Save." -msgstr "" - #: web/views/sandbox_tag_actions.ts:10 msgctxt "web/views/SandboxTagFormView" msgid "Copy Sandbox Tag" diff --git a/modules/sandbox/i18n/zh_CN.po b/modules/sandbox/i18n/zh_CN.po index ba019801a..1b7debe08 100644 --- a/modules/sandbox/i18n/zh_CN.po +++ b/modules/sandbox/i18n/zh_CN.po @@ -391,6 +391,11 @@ msgctxt "web/menu/titles" msgid "Sandbox" msgstr "沙箱" +#: web/pages/SandboxPropertyDefinitionList.vue:11 +msgctxt "web/pages/SandboxPropertyDefinitionList" +msgid "Record Properties" +msgstr "记录属性" + #: web/pages/SandboxRecordChart.vue:12 msgctxt "web/pages/SandboxRecordChart" msgid "Sandbox Record Chart" @@ -406,6 +411,11 @@ msgctxt "web/pages/SandboxRecordList" msgid "Field Kitchen Sink" msgstr "字段试验场" +#: web/route/routes.ts:156 +msgctxt "web/route/routes" +msgid "New Property Definition" +msgstr "新建属性定义" + #: web/route/routes.ts:82 msgctxt "web/route/routes" msgid "New Sandbox Record" @@ -416,6 +426,11 @@ msgctxt "web/route/routes" msgid "New Sandbox Tag" msgstr "新建沙箱标签" +#: web/route/routes.ts:170 +msgctxt "web/route/routes" +msgid "Property Definition" +msgstr "属性定义" + #: web/route/routes.ts:142 msgctxt "web/route/routes" msgid "Record Properties" @@ -496,6 +511,101 @@ msgctxt "web/views/SandboxLinesVirtTools" msgid "Short Lines stay content-sized. Open VIRT-LINES or fill rows to exercise capped table / kanban virtualize (not saved until you Save)." msgstr "短 Lines 保持内容高度。打开 VIRT-LINES 或填充行以练习封顶表格 / kanban 虚拟化(保存前不落库)。" +#: web/views/SandboxPropertyDefinitionFormView.vue:90 +msgctxt "web/views/SandboxPropertyDefinitionFormView" +msgid "Container Id" +msgstr "容器 ID" + +#: web/views/SandboxPropertyDefinitionFormView.vue:83 +msgctxt "web/views/SandboxPropertyDefinitionFormView" +msgid "Container Model" +msgstr "容器模型" + +#: web/views/SandboxPropertyDefinitionFormView.vue:70 +msgctxt "web/views/SandboxPropertyDefinitionFormView" +msgid "Container scope" +msgstr "容器作用域" + +#: web/views/sandbox_property_definition_actions.ts:10 +msgctxt "web/views/SandboxPropertyDefinitionFormView" +msgid "Copy Property Definition" +msgstr "复制属性定义" + +#: web/views/sandbox_property_definition_actions.ts:10 +msgctxt "web/views/SandboxPropertyDefinitionFormView" +msgid "Create Property Definition" +msgstr "创建属性定义" + +#: web/views/SandboxPropertyDefinitionFormView.vue:23 +msgctxt "web/views/SandboxPropertyDefinitionFormView" +msgid "Definition" +msgstr "定义" + +#: web/views/sandbox_property_definition_actions.ts:10 +msgctxt "web/views/SandboxPropertyDefinitionFormView" +msgid "Delete Property Definition" +msgstr "删除属性定义" + +#: web/views/sandbox_property_definition_actions.ts:10 +msgctxt "web/views/SandboxPropertyDefinitionFormView" +msgid "Edit Property Definition" +msgstr "编辑属性定义" + +#: web/views/SandboxPropertyDefinitionFormView.vue:73 +msgctxt "web/views/SandboxPropertyDefinitionFormView" +msgid "Optional parent container for record-scoped definitions. Leave empty for app-level properties (e.g. SandboxRecord.Properties)." +msgstr "可选的父级容器,用于记录级定义。应用级属性(如 SandboxRecord.Properties)请留空。" + +#: web/views/SandboxPropertyDefinitionFormView.vue:15 +msgctxt "web/views/SandboxPropertyDefinitionFormView" +msgid "Properties Field" +msgstr "属性字段" + +#: web/views/sandbox_property_definition_actions.ts:11 +msgctxt "web/views/SandboxPropertyDefinitionFormView" +msgid "Property Definition" +msgstr "属性定义" + +#: web/views/SandboxPropertyDefinitionFormView.vue:21 +msgctxt "web/views/SandboxPropertyDefinitionFormView" +msgid "Required" +msgstr "必填" + +#: web/views/SandboxPropertyDefinitionFormView.vue:9 +msgctxt "web/views/SandboxPropertyDefinitionFormView" +msgid "Scope" +msgstr "作用域" + +#: web/views/SandboxPropertyDefinitionFormView.vue:12 +msgctxt "web/views/SandboxPropertyDefinitionFormView" +msgid "Target Model" +msgstr "目标模型" + +#: web/views/SandboxPropertyDefinitionListView.vue:11 +msgctxt "web/views/SandboxPropertyDefinitionListView" +msgid "Container Id" +msgstr "容器 ID" + +#: web/views/SandboxPropertyDefinitionListView.vue:10 +msgctxt "web/views/SandboxPropertyDefinitionListView" +msgid "Container Model" +msgstr "容器模型" + +#: web/views/SandboxPropertyDefinitionListView.vue:12 +msgctxt "web/views/SandboxPropertyDefinitionListView" +msgid "Definition" +msgstr "定义" + +#: web/views/SandboxPropertyDefinitionListView.vue:9 +msgctxt "web/views/SandboxPropertyDefinitionListView" +msgid "Properties Field" +msgstr "属性字段" + +#: web/views/SandboxPropertyDefinitionListView.vue:8 +msgctxt "web/views/SandboxPropertyDefinitionListView" +msgid "Target Model" +msgstr "目标模型" + #: web/views/SandboxRecordChartView.vue:43 msgctxt "web/views/SandboxRecordChartView" msgid "Chart View" @@ -806,11 +916,6 @@ msgctxt "web/views/SandboxRecordListView" msgid "View switcher" msgstr "视图切换" -#: web/views/SandboxRecordPropertiesDefinitionView.vue:5 -msgctxt "web/views/SandboxRecordPropertiesDefinitionView" -msgid "Edit the app-level schema for SandboxRecord.Properties. Labels support multiple languages; use Translate next to each label (and selection options) before Save." -msgstr "编辑 SandboxRecord.Properties 的应用级 schema。标签支持多语言;请在保存前使用每项标签(及选项)旁的「翻译」。" - #: web/views/sandbox_tag_actions.ts:10 msgctxt "web/views/SandboxTagFormView" msgid "Copy Sandbox Tag" @@ -858,6 +963,10 @@ msgstr "标签" #~ msgid "Add Line" #~ msgstr "添加明细" +#~ msgctxt "web/views/SandboxRecordPropertiesDefinitionView" +#~ msgid "Edit the app-level schema for SandboxRecord.Properties. Labels support multiple languages; use Translate next to each label (and selection options) before Save." +#~ msgstr "编辑 SandboxRecord.Properties 的应用级 schema。标签支持多语言;请在保存前使用每项标签(及选项)旁的「翻译」。" + #~ msgctxt "web/views/SandboxTagListView" #~ msgid "Copy Sandbox Tag" #~ msgstr "复制沙箱标签" @@ -877,3 +986,4 @@ msgstr "标签" #~ msgctxt "web/views/SandboxTagListView" #~ msgid "Sandbox Tag" #~ msgstr "沙箱标签" + diff --git a/modules/sandbox/web/__tests__/lt_titles.test.ts b/modules/sandbox/web/__tests__/lt_titles.test.ts index b9205b50c..9a6a0bbc2 100644 --- a/modules/sandbox/web/__tests__/lt_titles.test.ts +++ b/modules/sandbox/web/__tests__/lt_titles.test.ts @@ -30,8 +30,8 @@ test('sandbox route titles: list route pins pageTitleText', () => { expect(list.meta?.pageTitleText).toEqual(listRouteTitle); }); -test('sandbox route titles: record properties configuration pins pageTitleText', () => { - const route = sandboxRoutes.find(r => r.name === 'SandboxRecordProperties') as any; +test('sandbox route titles: PropertyDefinition list pins pageTitleText', () => { + const route = sandboxRoutes.find(r => r.name === 'SandboxPropertyDefinitionList') as any; expect(route).toBeTruthy(); expect(route.meta?.pageTitle).toBe('Record Properties'); expect(route.meta?.pageTitleText).toEqual(recordPropertiesRouteTitle); diff --git a/modules/sandbox/web/__tests__/resourceWiring.test.ts b/modules/sandbox/web/__tests__/resourceWiring.test.ts index 26afaae0d..ed6778dbd 100644 --- a/modules/sandbox/web/__tests__/resourceWiring.test.ts +++ b/modules/sandbox/web/__tests__/resourceWiring.test.ts @@ -86,8 +86,12 @@ test('sandbox resource wiring: list/kanban/chart routes expose view switcher met }); }); -test('sandbox resource wiring: record properties configuration route', () => { +test('sandbox resource wiring: PropertyDefinition List→Form routes', () => { const byName = Object.fromEntries(sandboxRoutes.map(route => [String(route.name), route])); - expect(byName.SandboxRecordProperties?.path).toBe('sandbox/configuration/record-properties'); - expect(byName.SandboxRecordProperties?.meta).toMatchObject({ requiresAuth: true }); + expect(byName.SandboxPropertyDefinitionList?.path).toBe('sandbox/configuration/record-properties'); + expect(byName.SandboxPropertyDefinitionCreate?.path).toBe('sandbox/configuration/record-properties/new'); + expect(byName.SandboxPropertyDefinitionDetail?.path).toBe('sandbox/configuration/record-properties/:id'); + expect(byName.SandboxPropertyDefinitionList?.meta).toMatchObject({ requiresAuth: true }); + expect(byName.SandboxPropertyDefinitionCreate?.meta).toMatchObject({ requiresAuth: true }); + expect(byName.SandboxPropertyDefinitionDetail?.meta).toMatchObject({ requiresAuth: true }); }); diff --git a/modules/sandbox/web/pages/SandboxPropertyDefinition.vue b/modules/sandbox/web/pages/SandboxPropertyDefinition.vue new file mode 100644 index 000000000..61aacabc7 --- /dev/null +++ b/modules/sandbox/web/pages/SandboxPropertyDefinition.vue @@ -0,0 +1,37 @@ + + + + + diff --git a/modules/sandbox/web/pages/SandboxPropertyDefinitionList.vue b/modules/sandbox/web/pages/SandboxPropertyDefinitionList.vue new file mode 100644 index 000000000..75d742945 --- /dev/null +++ b/modules/sandbox/web/pages/SandboxPropertyDefinitionList.vue @@ -0,0 +1,28 @@ + + + + + diff --git a/modules/sandbox/web/pages/SandboxRecordProperties.vue b/modules/sandbox/web/pages/SandboxRecordProperties.vue deleted file mode 100644 index efb3649a8..000000000 --- a/modules/sandbox/web/pages/SandboxRecordProperties.vue +++ /dev/null @@ -1,17 +0,0 @@ - - - - - diff --git a/modules/sandbox/web/pages/page_mount_coverage.test.ts b/modules/sandbox/web/pages/page_mount_coverage.test.ts index 72c2810bd..0feb350bb 100644 --- a/modules/sandbox/web/pages/page_mount_coverage.test.ts +++ b/modules/sandbox/web/pages/page_mount_coverage.test.ts @@ -8,7 +8,8 @@ import SandboxRecordList from './SandboxRecordList.vue'; import SandboxRecordKanban from './SandboxRecordKanban.vue'; import SandboxRecordChart from './SandboxRecordChart.vue'; import SandboxTag from './SandboxTag.vue'; -import SandboxRecordProperties from './SandboxRecordProperties.vue'; +import SandboxPropertyDefinitionList from './SandboxPropertyDefinitionList.vue'; +import SandboxPropertyDefinition from './SandboxPropertyDefinition.vue'; import { sandboxMenus } from '../menu/menus'; const pages: Array<[string, any, string]> = [ @@ -17,7 +18,8 @@ const pages: Array<[string, any, string]> = [ ['SandboxRecordChart', SandboxRecordChart, '/sandbox/records/chart'], ['SandboxRecord', SandboxRecord, '/sandbox/records/1'], ['SandboxTag', SandboxTag, '/sandbox/tags/new'], - ['SandboxRecordProperties', SandboxRecordProperties, '/sandbox/configuration/record-properties'], + ['SandboxPropertyDefinitionList', SandboxPropertyDefinitionList, '/sandbox/configuration/record-properties'], + ['SandboxPropertyDefinition', SandboxPropertyDefinition, '/sandbox/configuration/record-properties/new'], ]; test('sandbox page mount: every ChoyPage host mounts under choysumMount', async () => { diff --git a/modules/sandbox/web/route/routes.ts b/modules/sandbox/web/route/routes.ts index db60a9eae..0682b0edc 100644 --- a/modules/sandbox/web/route/routes.ts +++ b/modules/sandbox/web/route/routes.ts @@ -135,14 +135,48 @@ export const sandboxRoutes: RouteRecordRaw[] = [ ], meta: { requiresAuth: true }, }), - // App-level PropertyDefinition editor for SandboxRecord.Properties (label i18n UX). + // PropertyDefinition List → Form (Definition edited via ChoyPropertiesDefinitionField). // Write ACL comes from platform PropertyDefinition logical packs (auth RMA/RFR). - defineRoute('sandbox.route.record_properties', { + defineRoute('sandbox.route.property_definition_list', { sequence: 80, title: _lt('Record Properties'), path: 'sandbox/configuration/record-properties', - name: 'SandboxRecordProperties', - component: () => import('../pages/SandboxRecordProperties.vue'), + name: 'SandboxPropertyDefinitionList', + component: () => import('../pages/SandboxPropertyDefinitionList.vue'), + actions: [ + 'sandbox.action.property_definition_create', + 'sandbox.action.property_definition_edit', + 'sandbox.action.property_definition_delete', + 'sandbox.action.property_definition_copy', + ], + meta: { requiresAuth: true }, + }), + defineRoute('sandbox.route.property_definition_create', { + sequence: 81, + title: _lt('New Property Definition'), + path: 'sandbox/configuration/record-properties/new', + name: 'SandboxPropertyDefinitionCreate', + component: () => import('../pages/SandboxPropertyDefinition.vue'), + actions: [ + 'sandbox.action.property_definition_create', + 'sandbox.action.property_definition_edit', + 'sandbox.action.property_definition_delete', + 'sandbox.action.property_definition_copy', + ], + meta: { requiresAuth: true }, + }), + defineRoute('sandbox.route.property_definition_detail', { + sequence: 82, + title: _lt('Property Definition'), + path: 'sandbox/configuration/record-properties/:id', + name: 'SandboxPropertyDefinitionDetail', + component: () => import('../pages/SandboxPropertyDefinition.vue'), + actions: [ + 'sandbox.action.property_definition_create', + 'sandbox.action.property_definition_edit', + 'sandbox.action.property_definition_delete', + 'sandbox.action.property_definition_copy', + ], meta: { requiresAuth: true }, }), ]; diff --git a/modules/sandbox/web/views/SandboxPropertyDefinitionFormView.vue b/modules/sandbox/web/views/SandboxPropertyDefinitionFormView.vue new file mode 100644 index 000000000..ba969cdc4 --- /dev/null +++ b/modules/sandbox/web/views/SandboxPropertyDefinitionFormView.vue @@ -0,0 +1,102 @@ + + + + + diff --git a/modules/sandbox/web/views/SandboxPropertyDefinitionListView.vue b/modules/sandbox/web/views/SandboxPropertyDefinitionListView.vue new file mode 100644 index 000000000..0980e4f2a --- /dev/null +++ b/modules/sandbox/web/views/SandboxPropertyDefinitionListView.vue @@ -0,0 +1,92 @@ + + + + + diff --git a/modules/sandbox/web/views/SandboxRecordPropertiesDefinitionView.vue b/modules/sandbox/web/views/SandboxRecordPropertiesDefinitionView.vue deleted file mode 100644 index 49a7ff45e..000000000 --- a/modules/sandbox/web/views/SandboxRecordPropertiesDefinitionView.vue +++ /dev/null @@ -1,35 +0,0 @@ - - - - - diff --git a/modules/sandbox/web/views/sandbox_property_definition_actions.ts b/modules/sandbox/web/views/sandbox_property_definition_actions.ts new file mode 100644 index 000000000..bfa39bbe8 --- /dev/null +++ b/modules/sandbox/web/views/sandbox_property_definition_actions.ts @@ -0,0 +1,12 @@ +// SPDX-FileCopyrightText: 2026-present Brian Wang +// SPDX-License-Identifier: Apache-2.0 + +import { defineModelActions } from '@/core/web/resource'; +import { createTranslate } from '@/web/web/i18n'; + +const { _lt } = createTranslate('sandbox', { scope: 'web/views/SandboxPropertyDefinitionFormView' }); + +/** Standard CRUD action ids for sandbox.PropertyDefinition (C2-injected logical model). */ +export const sandboxPropertyDefinitionActions = defineModelActions('sandbox.PropertyDefinition', { + entityTitle: _lt('Property Definition'), +}); diff --git a/modules/web/i18n/web.pot b/modules/web/i18n/web.pot index 250636bd5..5548299f4 100644 --- a/modules/web/i18n/web.pot +++ b/modules/web/i18n/web.pot @@ -848,74 +848,172 @@ msgctxt "web/components/field/ChoyOneToManyKanbanField" msgid "View record" msgstr "" -#: web/components/field/ChoyPropertiesDefinitionEditor.vue:8 -msgctxt "web/components/field/ChoyPropertiesDefinitionEditor" -msgid "Add property" +#: web/components/field/ChoyPropertiesDefinitionField.vue:279 +msgctxt "web/components/field/ChoyPropertiesDefinitionField" +msgid "%s properties" msgstr "" -#: web/components/field/ChoyPropertiesDefinitionEditor.vue:38 -msgctxt "web/components/field/ChoyPropertiesDefinitionEditor" -msgid "Default" +#: web/components/field/ChoyPropertiesDefinitionField.vue:281 +msgctxt "web/components/field/ChoyPropertiesDefinitionField" +msgid "%s properties (%s, …)" +msgstr "" + +#: web/components/field/ChoyPropertiesDefinitionField.vue:9 +msgctxt "web/components/field/ChoyPropertiesDefinitionField" +msgid "Add a line…" msgstr "" -#: web/components/field/ChoyPropertiesDefinitionEditor.vue:183 -#: web/components/field/ChoyPropertiesDefinitionEditor.vue:193 -#: web/components/field/ChoyPropertiesDefinitionEditor.vue:33 -msgctxt "web/components/field/ChoyPropertiesDefinitionEditor" +#: web/components/field/ChoyPropertiesDefinitionField.vue:16 +#: web/components/field/ChoyPropertiesDefinitionField.vue:9 +msgctxt "web/components/field/ChoyPropertiesDefinitionField" +msgid "Edit" +msgstr "" + +#: web/components/field/ChoyPropertiesDefinitionField.vue:16 +#: web/components/field/ChoyPropertiesDefinitionField.vue:9 +msgctxt "web/components/field/ChoyPropertiesDefinitionField" msgid "Label" msgstr "" -#: web/components/field/ChoyPropertiesDefinitionEditor.vue:23 -msgctxt "web/components/field/ChoyPropertiesDefinitionEditor" +#: web/components/field/ChoyPropertiesDefinitionField.vue:16 +#: web/components/field/ChoyPropertiesDefinitionField.vue:9 +msgctxt "web/components/field/ChoyPropertiesDefinitionField" msgid "Name" msgstr "" -#: web/components/field/ChoyPropertiesDefinitionEditor.vue:19 -msgctxt "web/components/field/ChoyPropertiesDefinitionEditor" +#: web/components/field/ChoyPropertiesDefinitionField.vue:275 +msgctxt "web/components/field/ChoyPropertiesDefinitionField" +msgid "No properties" +msgstr "" + +#: web/components/field/ChoyPropertiesDefinitionField.vue:16 +#: web/components/field/ChoyPropertiesDefinitionField.vue:9 +msgctxt "web/components/field/ChoyPropertiesDefinitionField" msgid "No properties defined" msgstr "" -#: web/components/field/ChoyPropertiesDefinitionEditor.vue:126 -#: web/components/field/ChoyPropertiesDefinitionEditor.vue:221 -msgctxt "web/components/field/ChoyPropertiesDefinitionEditor" -msgid "PropertyDefinition store is unavailable" +#: web/components/field/ChoyPropertiesDefinitionField.vue:16 +#: web/components/field/ChoyPropertiesDefinitionField.vue:9 +msgctxt "web/components/field/ChoyPropertiesDefinitionField" +msgid "Remove" msgstr "" -#: web/components/field/ChoyPropertiesDefinitionEditor.vue:41 -msgctxt "web/components/field/ChoyPropertiesDefinitionEditor" -msgid "Readonly" +#: web/components/field/ChoyPropertiesDefinitionField.vue +msgctxt "web/components/field/ChoyPropertiesDefinitionField" +msgid "Reorder" msgstr "" -#: web/components/field/ChoyPropertiesDefinitionEditor.vue:51 -msgctxt "web/components/field/ChoyPropertiesDefinitionEditor" -msgid "Remove" +#: web/components/field/ChoyPropertiesDefinitionField.vue:16 +#: web/components/field/ChoyPropertiesDefinitionField.vue:9 +msgctxt "web/components/field/ChoyPropertiesDefinitionField" +msgid "Type" msgstr "" -#: web/components/field/ChoyPropertiesDefinitionEditor.vue:5 -msgctxt "web/components/field/ChoyPropertiesDefinitionEditor" -msgid "Save" +#: web/components/field/ChoyPropertiesDefinitionField.vue:16 +#: web/components/field/ChoyPropertiesDefinitionField.vue:9 +msgctxt "web/components/field/ChoyPropertiesDefinitionField" +msgid "View" msgstr "" -#: web/components/field/ChoyPropertiesDefinitionEditor.vue:43 -msgctxt "web/components/field/ChoyPropertiesDefinitionEditor" -msgid "Selection JSON" +#: web/components/field/ChoyPropertiesDefinitionItemDialog.vue +msgctxt "web/components/field/ChoyPropertiesDefinitionItemDialog" +msgid "Cancel" +msgstr "" + +#: web/components/field/ChoyPropertiesDefinitionItemDialog.vue +msgctxt "web/components/field/ChoyPropertiesDefinitionItemDialog" +msgid "Default" msgstr "" -#: web/components/field/ChoyPropertiesDefinitionEditor.vue:35 -msgctxt "web/components/field/ChoyPropertiesDefinitionEditor" -msgid "Translate" +#: web/components/field/ChoyPropertiesDefinitionItemDialog.vue +msgctxt "web/components/field/ChoyPropertiesDefinitionItemDialog" +msgid "Edit property: %s" msgstr "" -#: web/components/field/ChoyPropertiesDefinitionEditor.vue:46 -msgctxt "web/components/field/ChoyPropertiesDefinitionEditor" -msgid "Translate %s" +#: web/components/field/ChoyPropertiesDefinitionItemDialog.vue +msgctxt "web/components/field/ChoyPropertiesDefinitionItemDialog" +msgid "Label" +msgstr "" + +#: web/components/field/ChoyPropertiesDefinitionItemDialog.vue +msgctxt "web/components/field/ChoyPropertiesDefinitionItemDialog" +msgid "Name" msgstr "" -#: web/components/field/ChoyPropertiesDefinitionEditor.vue:25 -msgctxt "web/components/field/ChoyPropertiesDefinitionEditor" +#: web/components/field/ChoyPropertiesDefinitionItemDialog.vue +msgctxt "web/components/field/ChoyPropertiesDefinitionItemDialog" +msgid "Name is required" +msgstr "" + +#: web/components/field/ChoyPropertiesDefinitionItemDialog.vue +msgctxt "web/components/field/ChoyPropertiesDefinitionItemDialog" +msgid "New property" +msgstr "" + +#: web/components/field/ChoyPropertiesDefinitionItemDialog.vue +msgctxt "web/components/field/ChoyPropertiesDefinitionItemDialog" +msgid "Property" +msgstr "" + +#: web/components/field/ChoyPropertiesDefinitionItemDialog.vue +msgctxt "web/components/field/ChoyPropertiesDefinitionItemDialog" +msgid "Property: %s" +msgstr "" + +#: web/components/field/ChoyPropertiesDefinitionItemDialog.vue +msgctxt "web/components/field/ChoyPropertiesDefinitionItemDialog" +msgid "Readonly" +msgstr "" + +#: web/components/field/ChoyPropertiesDefinitionItemDialog.vue +msgctxt "web/components/field/ChoyPropertiesDefinitionItemDialog" +msgid "Save" +msgstr "" + +#: web/components/field/ChoyPropertiesDefinitionItemDialog.vue +msgctxt "web/components/field/ChoyPropertiesDefinitionItemDialog" +msgid "Translate label" +msgstr "" + +#: web/components/field/ChoyPropertiesDefinitionItemDialog.vue +msgctxt "web/components/field/ChoyPropertiesDefinitionItemDialog" msgid "Type" msgstr "" +#: web/components/field/ChoyPropertiesDefinitionSelectionEditor.vue +msgctxt "web/components/field/ChoyPropertiesDefinitionSelectionEditor" +msgid "Add a line…" +msgstr "" + +#: web/components/field/ChoyPropertiesDefinitionSelectionEditor.vue +msgctxt "web/components/field/ChoyPropertiesDefinitionSelectionEditor" +msgid "Delete" +msgstr "" + +#: web/components/field/ChoyPropertiesDefinitionSelectionEditor.vue +msgctxt "web/components/field/ChoyPropertiesDefinitionSelectionEditor" +msgid "Label" +msgstr "" + +#: web/components/field/ChoyPropertiesDefinitionSelectionEditor.vue +msgctxt "web/components/field/ChoyPropertiesDefinitionSelectionEditor" +msgid "No options" +msgstr "" + +#: web/components/field/ChoyPropertiesDefinitionSelectionEditor.vue +msgctxt "web/components/field/ChoyPropertiesDefinitionSelectionEditor" +msgid "Reorder" +msgstr "" + +#: web/components/field/ChoyPropertiesDefinitionSelectionEditor.vue +msgctxt "web/components/field/ChoyPropertiesDefinitionSelectionEditor" +msgid "Translate option" +msgstr "" + +#: web/components/field/ChoyPropertiesDefinitionSelectionEditor.vue +msgctxt "web/components/field/ChoyPropertiesDefinitionSelectionEditor" +msgid "Value" +msgstr "" #: web/components/field/ChoyPropertiesField.vue:243 msgctxt "web/components/field/ChoyPropertiesField" msgid "+%s more" diff --git a/modules/web/i18n/zh_CN.po b/modules/web/i18n/zh_CN.po index df862f93a..14e0999a8 100644 --- a/modules/web/i18n/zh_CN.po +++ b/modules/web/i18n/zh_CN.po @@ -826,71 +826,165 @@ msgctxt "web/components/field/ChoyOneToManyKanbanField" msgid "View record" msgstr "查看记录" -#: web/components/field/ChoyPropertiesDefinitionEditor.vue:8 -msgctxt "web/components/field/ChoyPropertiesDefinitionEditor" -msgid "Add property" -msgstr "添加属性" +#: web/components/field/ChoyPropertiesDefinitionField.vue:279 +msgctxt "web/components/field/ChoyPropertiesDefinitionField" +msgid "%s properties" +msgstr "%s 个属性" + +#: web/components/field/ChoyPropertiesDefinitionField.vue:281 +msgctxt "web/components/field/ChoyPropertiesDefinitionField" +msgid "%s properties (%s, …)" +msgstr "%s 个属性(%s,…)" + +#: web/components/field/ChoyPropertiesDefinitionField.vue:9 +msgctxt "web/components/field/ChoyPropertiesDefinitionField" +msgid "Add a line…" +msgstr "添加一行…" -#: web/components/field/ChoyPropertiesDefinitionEditor.vue:38 -msgctxt "web/components/field/ChoyPropertiesDefinitionEditor" -msgid "Default" -msgstr "默认" +#: web/components/field/ChoyPropertiesDefinitionField.vue:16 web/components/field/ChoyPropertiesDefinitionField.vue:9 +msgctxt "web/components/field/ChoyPropertiesDefinitionField" +msgid "Edit" +msgstr "编辑" -#: web/components/field/ChoyPropertiesDefinitionEditor.vue:183 web/components/field/ChoyPropertiesDefinitionEditor.vue:193 web/components/field/ChoyPropertiesDefinitionEditor.vue:33 -msgctxt "web/components/field/ChoyPropertiesDefinitionEditor" +#: web/components/field/ChoyPropertiesDefinitionField.vue:16 web/components/field/ChoyPropertiesDefinitionField.vue:9 +msgctxt "web/components/field/ChoyPropertiesDefinitionField" msgid "Label" msgstr "标签" -#: web/components/field/ChoyPropertiesDefinitionEditor.vue:23 -msgctxt "web/components/field/ChoyPropertiesDefinitionEditor" +#: web/components/field/ChoyPropertiesDefinitionField.vue:16 web/components/field/ChoyPropertiesDefinitionField.vue:9 +msgctxt "web/components/field/ChoyPropertiesDefinitionField" msgid "Name" msgstr "名称" -#: web/components/field/ChoyPropertiesDefinitionEditor.vue:19 -msgctxt "web/components/field/ChoyPropertiesDefinitionEditor" +#: web/components/field/ChoyPropertiesDefinitionField.vue:275 +msgctxt "web/components/field/ChoyPropertiesDefinitionField" +msgid "No properties" +msgstr "无属性" + +#: web/components/field/ChoyPropertiesDefinitionField.vue:16 web/components/field/ChoyPropertiesDefinitionField.vue:9 +msgctxt "web/components/field/ChoyPropertiesDefinitionField" msgid "No properties defined" -msgstr "未定义属性" +msgstr "尚未定义属性" + +#: web/components/field/ChoyPropertiesDefinitionField.vue:16 web/components/field/ChoyPropertiesDefinitionField.vue:9 +msgctxt "web/components/field/ChoyPropertiesDefinitionField" +msgid "Remove" +msgstr "移除" + +#: web/components/field/ChoyPropertiesDefinitionField.vue +msgctxt "web/components/field/ChoyPropertiesDefinitionField" +msgid "Reorder" +msgstr "调整顺序" + +#: web/components/field/ChoyPropertiesDefinitionField.vue:16 web/components/field/ChoyPropertiesDefinitionField.vue:9 +msgctxt "web/components/field/ChoyPropertiesDefinitionField" +msgid "Type" +msgstr "类型" + +#: web/components/field/ChoyPropertiesDefinitionField.vue:16 web/components/field/ChoyPropertiesDefinitionField.vue:9 +msgctxt "web/components/field/ChoyPropertiesDefinitionField" +msgid "View" +msgstr "查看" + +#: web/components/field/ChoyPropertiesDefinitionItemDialog.vue +msgctxt "web/components/field/ChoyPropertiesDefinitionItemDialog" +msgid "Cancel" +msgstr "取消" + +#: web/components/field/ChoyPropertiesDefinitionItemDialog.vue +msgctxt "web/components/field/ChoyPropertiesDefinitionItemDialog" +msgid "Default" +msgstr "默认值" + +#: web/components/field/ChoyPropertiesDefinitionItemDialog.vue +msgctxt "web/components/field/ChoyPropertiesDefinitionItemDialog" +msgid "Edit property: %s" +msgstr "编辑属性:%s" + +#: web/components/field/ChoyPropertiesDefinitionItemDialog.vue +msgctxt "web/components/field/ChoyPropertiesDefinitionItemDialog" +msgid "Label" +msgstr "标签" + +#: web/components/field/ChoyPropertiesDefinitionItemDialog.vue +msgctxt "web/components/field/ChoyPropertiesDefinitionItemDialog" +msgid "Name" +msgstr "名称" -#: web/components/field/ChoyPropertiesDefinitionEditor.vue:126 web/components/field/ChoyPropertiesDefinitionEditor.vue:221 -msgctxt "web/components/field/ChoyPropertiesDefinitionEditor" -msgid "PropertyDefinition store is unavailable" -msgstr "PropertyDefinition 存储不可用" +#: web/components/field/ChoyPropertiesDefinitionItemDialog.vue +msgctxt "web/components/field/ChoyPropertiesDefinitionItemDialog" +msgid "Name is required" +msgstr "名称为必填项" -#: web/components/field/ChoyPropertiesDefinitionEditor.vue:41 -msgctxt "web/components/field/ChoyPropertiesDefinitionEditor" +#: web/components/field/ChoyPropertiesDefinitionItemDialog.vue +msgctxt "web/components/field/ChoyPropertiesDefinitionItemDialog" +msgid "New property" +msgstr "新建属性" + +#: web/components/field/ChoyPropertiesDefinitionItemDialog.vue +msgctxt "web/components/field/ChoyPropertiesDefinitionItemDialog" +msgid "Property" +msgstr "属性" + +#: web/components/field/ChoyPropertiesDefinitionItemDialog.vue +msgctxt "web/components/field/ChoyPropertiesDefinitionItemDialog" +msgid "Property: %s" +msgstr "属性:%s" + +#: web/components/field/ChoyPropertiesDefinitionItemDialog.vue +msgctxt "web/components/field/ChoyPropertiesDefinitionItemDialog" msgid "Readonly" msgstr "只读" -#: web/components/field/ChoyPropertiesDefinitionEditor.vue:51 -msgctxt "web/components/field/ChoyPropertiesDefinitionEditor" -msgid "Remove" -msgstr "移除" - -#: web/components/field/ChoyPropertiesDefinitionEditor.vue:5 -msgctxt "web/components/field/ChoyPropertiesDefinitionEditor" +#: web/components/field/ChoyPropertiesDefinitionItemDialog.vue +msgctxt "web/components/field/ChoyPropertiesDefinitionItemDialog" msgid "Save" msgstr "保存" -#: web/components/field/ChoyPropertiesDefinitionEditor.vue:43 -msgctxt "web/components/field/ChoyPropertiesDefinitionEditor" -msgid "Selection JSON" -msgstr "选择项 JSON" - -#: web/components/field/ChoyPropertiesDefinitionEditor.vue:35 -msgctxt "web/components/field/ChoyPropertiesDefinitionEditor" -msgid "Translate" -msgstr "翻译" - -#: web/components/field/ChoyPropertiesDefinitionEditor.vue:46 -msgctxt "web/components/field/ChoyPropertiesDefinitionEditor" -msgid "Translate %s" -msgstr "翻译 %s" +#: web/components/field/ChoyPropertiesDefinitionItemDialog.vue +msgctxt "web/components/field/ChoyPropertiesDefinitionItemDialog" +msgid "Translate label" +msgstr "翻译标签" -#: web/components/field/ChoyPropertiesDefinitionEditor.vue:25 -msgctxt "web/components/field/ChoyPropertiesDefinitionEditor" +#: web/components/field/ChoyPropertiesDefinitionItemDialog.vue +msgctxt "web/components/field/ChoyPropertiesDefinitionItemDialog" msgid "Type" msgstr "类型" +#: web/components/field/ChoyPropertiesDefinitionSelectionEditor.vue +msgctxt "web/components/field/ChoyPropertiesDefinitionSelectionEditor" +msgid "Add a line…" +msgstr "添加一行…" + +#: web/components/field/ChoyPropertiesDefinitionSelectionEditor.vue +msgctxt "web/components/field/ChoyPropertiesDefinitionSelectionEditor" +msgid "Delete" +msgstr "删除" + +#: web/components/field/ChoyPropertiesDefinitionSelectionEditor.vue +msgctxt "web/components/field/ChoyPropertiesDefinitionSelectionEditor" +msgid "Label" +msgstr "标签" + +#: web/components/field/ChoyPropertiesDefinitionSelectionEditor.vue +msgctxt "web/components/field/ChoyPropertiesDefinitionSelectionEditor" +msgid "No options" +msgstr "暂无选项" + +#: web/components/field/ChoyPropertiesDefinitionSelectionEditor.vue +msgctxt "web/components/field/ChoyPropertiesDefinitionSelectionEditor" +msgid "Reorder" +msgstr "调整顺序" + +#: web/components/field/ChoyPropertiesDefinitionSelectionEditor.vue +msgctxt "web/components/field/ChoyPropertiesDefinitionSelectionEditor" +msgid "Translate option" +msgstr "翻译选项" + +#: web/components/field/ChoyPropertiesDefinitionSelectionEditor.vue +msgctxt "web/components/field/ChoyPropertiesDefinitionSelectionEditor" +msgid "Value" +msgstr "值" #: web/components/field/ChoyPropertiesField.vue:243 msgctxt "web/components/field/ChoyPropertiesField" msgid "+%s more" @@ -2666,6 +2760,70 @@ msgstr "页面" #~ msgid "New" #~ msgstr "新建" +#~ msgctxt "web/components/field/ChoyPropertiesDefinitionEditor" +#~ msgid "Add property" +#~ msgstr "添加属性" + +#~ msgctxt "web/components/field/ChoyPropertiesDefinitionEditor" +#~ msgid "Default" +#~ msgstr "默认" + +#~ msgctxt "web/components/field/ChoyPropertiesDefinitionEditor" +#~ msgid "Label" +#~ msgstr "标签" + +#~ msgctxt "web/components/field/ChoyPropertiesDefinitionEditor" +#~ msgid "Name" +#~ msgstr "名称" + +#~ msgctxt "web/components/field/ChoyPropertiesDefinitionEditor" +#~ msgid "No properties defined" +#~ msgstr "未定义属性" + +#~ msgctxt "web/components/field/ChoyPropertiesDefinitionEditor" +#~ msgid "PropertyDefinition store is unavailable" +#~ msgstr "PropertyDefinition 存储不可用" + +#~ msgctxt "web/components/field/ChoyPropertiesDefinitionEditor" +#~ msgid "Readonly" +#~ msgstr "只读" + +#~ msgctxt "web/components/field/ChoyPropertiesDefinitionEditor" +#~ msgid "Remove" +#~ msgstr "移除" + +#~ msgctxt "web/components/field/ChoyPropertiesDefinitionEditor" +#~ msgid "Save" +#~ msgstr "保存" + +#~ msgctxt "web/components/field/ChoyPropertiesDefinitionEditor" +#~ msgid "Selection JSON" +#~ msgstr "选择项 JSON" + +#~ msgctxt "web/components/field/ChoyPropertiesDefinitionEditor" +#~ msgid "Translate" +#~ msgstr "翻译" + +#~ msgctxt "web/components/field/ChoyPropertiesDefinitionEditor" +#~ msgid "Translate %s" +#~ msgstr "翻译 %s" + +#~ msgctxt "web/components/field/ChoyPropertiesDefinitionEditor" +#~ msgid "Type" +#~ msgstr "类型" + +#~ msgctxt "web/components/field/ChoyPropertiesDefinitionField" +#~ msgid "Add property" +#~ msgstr "添加属性" + +#~ msgctxt "web/components/field/ChoyPropertiesDefinitionField" +#~ msgid "Translate label" +#~ msgstr "翻译标签" + +#~ msgctxt "web/components/field/ChoyPropertiesDefinitionItemDialog" +#~ msgid "Translate" +#~ msgstr "翻译" + #~ msgctxt "web/components/field/ChoyTextField" #~ msgid "No content" #~ msgstr "无内容" @@ -2889,3 +3047,4 @@ msgstr "页面" #~ msgctxt "web/composables/useMenu" #~ msgid "No menus available" #~ msgstr "暂无可用菜单" + diff --git a/modules/web/web/components/field/ChoyOneToManyKanbanField.vue b/modules/web/web/components/field/ChoyOneToManyKanbanField.vue index c2af6e6e2..44cd5e3f8 100644 --- a/modules/web/web/components/field/ChoyOneToManyKanbanField.vue +++ b/modules/web/web/components/field/ChoyOneToManyKanbanField.vue @@ -64,7 +64,7 @@ SPDX-License-Identifier: Apache-2.0