Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 16 additions & 16 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,24 +24,24 @@
"verify": "pnpm validate && pnpm typecheck && pnpm test"
},
"dependencies": {
"@objectstack/spec": "^18.2.0",
"@objectstack/runtime": "^18.2.0",
"@objectstack/plugin-hono-server": "^18.2.0",
"@objectstack/connector-rest": "^18.2.0",
"@objectstack/connector-openapi": "^18.2.0",
"@objectstack/connector-mcp": "^18.2.0"
"@objectstack/spec": "^18.3.0",
"@objectstack/runtime": "^18.3.0",
"@objectstack/plugin-hono-server": "^18.3.0",
"@objectstack/connector-rest": "^18.3.0",
"@objectstack/connector-openapi": "^18.3.0",
"@objectstack/connector-mcp": "^18.3.0"
},
"devDependencies": {
"@objectstack/cli": "^18.2.0",
"@objectstack/platform-objects": "^18.2.0",
"@objectstack/plugin-auth": "^18.2.0",
"@objectstack/plugin-email": "^18.2.0",
"@objectstack/plugin-security": "^18.2.0",
"@objectstack/plugin-sharing": "^18.2.0",
"@objectstack/service-automation": "^18.2.0",
"@objectstack/service-job": "^18.2.0",
"@objectstack/service-messaging": "^18.2.0",
"@objectstack/trigger-schedule": "^18.2.0",
"@objectstack/cli": "^18.3.0",
"@objectstack/platform-objects": "^18.3.0",
"@objectstack/plugin-auth": "^18.3.0",
"@objectstack/plugin-email": "^18.3.0",
"@objectstack/plugin-security": "^18.3.0",
"@objectstack/plugin-sharing": "^18.3.0",
"@objectstack/service-automation": "^18.3.0",
"@objectstack/service-job": "^18.3.0",
"@objectstack/service-messaging": "^18.3.0",
"@objectstack/trigger-schedule": "^18.3.0",
"typescript": "^6.0.0",
"vitest": "^4.1.10"
}
Expand Down
1,117 changes: 567 additions & 550 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions src/actions/catalog.actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,10 +87,10 @@ export const CatalogApplyAction = defineAction({
// of every per-unit reading, so the toast says how many and why, and the
// result's `unanchored_owners` says who. Inherited by the twin below.
outcomeMessages: {
created: 'Created ${result.created} duties. ${result.skipped} already existed and were skipped.',
created: 'Created {{ result.created }} duties. {{ result.skipped }} already existed and were skipped.',
created_unanchored:
'Created ${result.created} duties. ${result.unanchored} have no business unit, because none could be determined for their owner. ${result.skipped} already existed and were skipped.',
nothing_created: 'Nothing new: all ${result.skipped} duties already existed.',
'Created {{ result.created }} duties. {{ result.unanchored }} have no business unit, because none could be determined for their owner. {{ result.skipped }} already existed and were skipped.',
nothing_created: 'Nothing new: all {{ result.skipped }} duties already existed.',
},
params: [
{
Expand Down
29 changes: 24 additions & 5 deletions src/pages/member.page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,8 +147,9 @@ import { definePage } from '@objectstack/spec/ui';
* error, and "showing who assigned each" is the one line of this card that
* is not fully delivered. Recorded on the issue and filed upstream.
*
* **§5 — Opening a person does NOT land here; plugin-auth's default wins**
* (objectstack#28979; on 17.2 it was objectui#7298).
* **§5 — Opening a person lands here only because of `apps`**
* (objectstack#28979, shipped in 18.3 as `PageSchema.apps`; on 17.2 it was
* objectui#7298).
* `@objectstack/platform-objects` ships `sys_user_detail`
* (`type: 'record'`, `object: 'sys_user'`, `isDefault: true`). On 17.2 the
* console chose between the two pages by list order, and this page happened
Expand All @@ -168,9 +169,22 @@ import { definePage } from '@objectstack/spec/ui';
* "⋯" menu. It is not on the People row menu, because 18.2 runs a row
* url action with no record in context. That is one click more than it
* should be, labelled a stopgap, and deleted when #28979 lands.
* `isDefault` stays `false`. `test/member-page.test.ts` mirrors the
* console's rule and pins that a person resolves to `sys_user_detail`
* today, so the day that changes is a red test, not a silent one.
* `isDefault` stays `false`.
* → FROM 18.3: `apps: ['duly_app']`. A record page that names an app opens
* for its object inside that app, before the object's default; everywhere
* else, Setup → Users included, `sys_user_detail` still opens. Measured on
* 18.3.0 against a live `pnpm demo`, en and zh: Duly > People > a person
* opens this page in the record drawer, and Setup → Users opens
* `sys_user_detail`. It is also the ONLY entry on 18.3: the console no
* longer renders `duly_member_open` in the person's header menu. Removing
* that stopgap is its own card.
* ⚠️ Known 18.3 limit: the drawer's "Open full page", and the explicit
* `/page/duly_member` route, answer 400. The record-page request refuses
* a related list over a `user` field (`duly_task.owner`), because the
* REST route accepts only lookup and master_detail
* (objectstack#29946). The drawer renders every region.
* `test/member-page.test.ts` mirrors the console's rule and pins both
* answers, in the app and outside it.
*
* **§7 — The auto-appended discussion panel cannot be declined by a page, and
* it carries a comment box** (objectui#7298, the second instance there). Found in the browser, not in the schema.
Expand Down Expand Up @@ -223,6 +237,11 @@ export const MemberPage = definePage({
// already declares `isDefault: true` for this object, and from 18 on a
// second default refuses the boot (DEFAULT_RECORD_PAGE_CONFLICT).
isDefault: false,
// The app slot, not the deployment default (gap §5, objectstack#28979):
// inside Duly a person opens this page; Setup → Users keeps
// `sys_user_detail`. An app NAME (`AppSchema.name`), never the route's
// package-id segment.
apps: ['duly_app'],

regions: [
// ── 1. Header — who this is ───────────────────────────────────────────
Expand Down
48 changes: 33 additions & 15 deletions src/translations/authored-text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,15 +78,13 @@ import { TRANSLATABLE_METADATA_TYPES } from '@objectstack/spec/system';
* `objects.<o>.fields.<f>.help`. So `help` is the bundle
* slot for an authored field `description`, which is why
* the key builder below spells it that way.
* translateAction label / confirmText / successMessage only. `description`
* and `params.*` are declared by `TranslationItemSchema`,
* and `@objectstack/rest`'s `validateTranslationReferences`
* lints their KEYS — but `translateAction` applies neither.
* They are authored anyway, for the same reason the gantt's
* `viewMode` stays authored in `src/views/task.view.ts`:
* the key is the spec's own, it is served to API and MCP
* callers, and it starts rendering the moment the resolver
* is fixed. Filed upstream; see the PR body.
* translateAction label / confirmText / successMessage, and from 18.3
* also `description`, `outcomeMessages.<o>` and each
* param's `label` / `helpText` / `placeholder`, read at the
* same `objects.<o>._actions.<a>` / `globalActions.<a>`
* addresses this repo already authored (on 17.2 it applied
* only the first three; the others were authored ahead of
* the resolver, and 18.3's `translateAction` now reads them).
* translateApp app label / description, and each navigation node's
* label addressed by its stable `id`, at any tree depth.
* translateDashboard dashboard label / description, widget title /
Expand Down Expand Up @@ -497,6 +495,7 @@ const VERDICTS: Readonly<Record<string, Verdict>> = {
'page.kind': machine('page override mode'),
'page.template': machine('layout template name'),
'page.object': machine('bound object'),
'page.apps[]': machine('app names whose routes open this record page (objectstack#28979)'),
'page.label': translate((c) => (id(c) ? ['pages', id(c)!, 'label'] : undefined)),
'page.description': translate((c) => (id(c) ? ['pages', id(c)!, 'description'] : undefined)),
'page.regions[].name': machine('layout region name'),
Expand Down Expand Up @@ -550,22 +549,31 @@ const VERDICTS: Readonly<Record<string, Verdict>> = {
// under `globalActions`. `validateTranslationReferences` refuses the wrong
// one of the two explicitly, so the branch below is the platform's rule.
'action.label': translate((c) => actionKey(c, 'label')),
'action.description': translate((c) => actionKey(c, 'description')),
// `description`, each param's `helpText` and each `outcomeMessages` entry
// are template slots from 18.3 (`action-dialog-outcome-copy-template-holes`),
// like `successMessage` from 18.2: the sentence sits at `.source`, and the
// bundle key stays the action's own `description` / `outcomeMessages.<o>` /
// `params.<p>.helpText`, which is where 18.3's `translateAction` reads it.
'action.description.source': translate((c) => actionKey(c, 'description')),
'action.description.dialect': machine('template dialect'),
'action.confirmText': translate((c) => actionKey(c, 'confirmText')),
// A template slot from 18.2, like a bulk action's `confirmText`: the sentence
// sits at `.source` and keeps the action's own `successMessage` key.
'action.successMessage.source': translate((c) => actionKey(c, 'successMessage')),
'action.successMessage.dialect': machine('template dialect'),
// Success copy per handler outcome (#179). The bundle address is the
// action's own entry, `outcomeMessages.<outcome>`, the key the platform's
// translation-reference check names; the outcome is the last path segment.
'action.outcomeMessages{}': translate((c) => {
const outcome = c.path[c.path.length - 1];
// translation-reference check names; the outcome is the segment above the
// envelope's `.source`.
'action.outcomeMessages{}.source': translate((c) => {
const [mapKey, outcome] = c.path;
const base = actionKey(c, 'outcomeMessages');
return outcome && base ? [...base, outcome] : undefined;
return mapKey === 'outcomeMessages' && outcome && base ? [...base, outcome] : undefined;
}),
'action.outcomeMessages{}.dialect': machine('template dialect'),
'action.params[].label': translate((c) => actionParamKey(c, 'label')),
'action.params[].helpText': translate((c) => actionParamKey(c, 'helpText')),
'action.params[].helpText.source': translate((c) => actionParamKeyAt(c, 'helpText')),
'action.params[].helpText.dialect': machine('template dialect'),
'action.params[].placeholder': translate((c) => actionParamKey(c, 'placeholder')),
'action.name': machine('action name'),
'action.objectName': machine('bound object'),
Expand Down Expand Up @@ -779,6 +787,16 @@ function actionParamKey(ctx: KeyContext, leaf: string): readonly string[] | unde
return param && base ? [...base, param, leaf] : undefined;
}

/** A param's template slot (`helpText.source`) sits inside the envelope, so the param is found by its index. */
function actionParamKeyAt(ctx: KeyContext, leaf: string): readonly string[] | undefined {
const [paramsKey, paramIndex] = ctx.path;
const params = ctx.ids.params;
if (paramsKey !== 'params' || !Array.isArray(params) || paramIndex === undefined) return undefined;
const param = str((params[Number(paramIndex)] as Rec | undefined)?.name);
const base = actionKey(ctx, 'params');
return param && base ? [...base, param, leaf] : undefined;
}

// ─── The walk ────────────────────────────────────────────────────────────

/**
Expand Down
12 changes: 6 additions & 6 deletions src/translations/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -518,10 +518,10 @@ export const dulyChinese = defineTranslationBundle({
description: '为选中的每个人创建这个岗位应尽的职责。可以放心重复执行——已经从职责库条目获得过职责的人会被跳过,不会重复创建。',
// 操作完成后的提示,按处理器返回的 outcome 选取(#179)。
outcomeMessages: {
created: '已创建 ${result.created} 条职责。${result.skipped} 条已存在,已跳过。',
created: '已创建 {{ result.created }} 条职责。{{ result.skipped }} 条已存在,已跳过。',
created_unanchored:
'已创建 ${result.created} 条职责。其中 ${result.unanchored} 条没有业务单元,因为无法确定负责人所属的业务单元。${result.skipped} 条已存在,已跳过。',
nothing_created: '没有新职责:全部 ${result.skipped} 条都已存在。',
'已创建 {{ result.created }} 条职责。其中 {{ result.unanchored }} 条没有业务单元,因为无法确定负责人所属的业务单元。{{ result.skipped }} 条已存在,已跳过。',
nothing_created: '没有新职责:全部 {{ result.skipped }} 条都已存在。',
},
params: {
position_code: {
Expand Down Expand Up @@ -833,10 +833,10 @@ export const dulyChinese = defineTranslationBundle({
description: '为选中的每个人创建这个岗位应尽的职责。可以放心重复执行——已经从职责库条目获得过职责的人会被跳过,不会重复创建。',
// 操作完成后的提示,按处理器返回的 outcome 选取(#179)。
outcomeMessages: {
created: '已创建 ${result.created} 条职责。${result.skipped} 条已存在,已跳过。',
created: '已创建 {{ result.created }} 条职责。{{ result.skipped }} 条已存在,已跳过。',
created_unanchored:
'已创建 ${result.created} 条职责。其中 ${result.unanchored} 条没有业务单元,因为无法确定负责人所属的业务单元。${result.skipped} 条已存在,已跳过。',
nothing_created: '没有新职责:全部 ${result.skipped} 条都已存在。',
'已创建 {{ result.created }} 条职责。其中 {{ result.unanchored }} 条没有业务单元,因为无法确定负责人所属的业务单元。{{ result.skipped }} 条已存在,已跳过。',
nothing_created: '没有新职责:全部 {{ result.skipped }} 条都已存在。',
},
params: {
position_code: {
Expand Down
21 changes: 15 additions & 6 deletions test/catalog-apply-unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,17 +177,23 @@ describe('Apply catalog on the demo organisation (#179)', () => {
describe('the toast the console shows for each outcome', () => {
const OUTCOMES: CatalogApplyOutcome[] = ['created', 'created_unanchored', 'nothing_created'];
const RESULT_KEYS = new Set<keyof CatalogApplyResult>(['created', 'skipped', 'unanchored']);
/** A `{{ result.<key> }}` hole, the only spelling a template slot reads. */
const HOLE = /\{\{\s*result\.(\w+)\s*\}\}/g;

for (const name of [CATALOG_APPLY_ACTION, CATALOG_APPLY_TO_PEOPLE_ACTION]) {
it(`${name} declares one message per outcome, each reading only real result keys`, () => {
const action = dulyActions.find((candidate) => candidate.name === name) as { outcomeMessages?: Record<string, string> };
// A template slot from 18.3: each entry parses to `{ dialect, source }`.
const action = dulyActions.find((candidate) => candidate.name === name) as {
outcomeMessages?: Record<string, { dialect: string; source: string }>;
};
expect(Object.keys(action.outcomeMessages ?? {}).sort()).toEqual([...OUTCOMES].sort());
for (const message of Object.values(action.outcomeMessages ?? {})) {
for (const [, key] of message.matchAll(/\$\{result\.(\w+)\}/g)) {
expect(RESULT_KEYS.has(key as keyof CatalogApplyResult), `${name}: \${result.${key}}`).toBe(true);
expect(message.dialect).toBe('template');
for (const [, key] of message.source.matchAll(HOLE)) {
expect(RESULT_KEYS.has(key as keyof CatalogApplyResult), `${name}: {{ result.${key} }}`).toBe(true);
}
}
expect(action.outcomeMessages?.created_unanchored).toContain('${result.unanchored}');
expect(action.outcomeMessages?.created_unanchored?.source).toContain('{{ result.unanchored }}');
});
}

Expand All @@ -198,10 +204,13 @@ describe('the toast the console shows for each outcome', () => {
bundle.objects?.duly_catalog_item?._actions?.[CATALOG_APPLY_TO_PEOPLE_ACTION]?.outcomeMessages,
];
const en = (dulyActions.find((candidate) => candidate.name === CATALOG_APPLY_ACTION) as any).outcomeMessages;
const placeholders = (text: string) => [...text.matchAll(/\$\{result\.\w+\}/g)].map((m) => m[0]).sort();
const placeholders = (text: string) => [...text.matchAll(HOLE)].map((m) => m[1]).sort();
for (const messages of zh) {
expect(Object.keys(messages ?? {}).sort()).toEqual([...OUTCOMES].sort());
for (const outcome of OUTCOMES) expect(placeholders(messages[outcome])).toEqual(placeholders(en[outcome]));
for (const outcome of OUTCOMES) {
expect(placeholders(messages[outcome]).length).toBeGreaterThan(0);
expect(placeholders(messages[outcome])).toEqual(placeholders(en[outcome].source));
}
}
});
});
20 changes: 13 additions & 7 deletions test/email-templates.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -422,8 +422,7 @@ const DELIVERY_TERMINAL = new Set(['success', 'failed', 'dead']);
/**
* The recipient's language is read per recipient, after fan-out, off
* `sys_user.locale` (falling back to the deployment default), and handed to
* the resolver — which matches it exactly, then tries the literal `en-US`
* floor, and stops. `sys_user.locale` is free-text BCP-47 the user can edit;
* the resolver. `sys_user.locale` is free-text BCP-47 the user can edit;
* it is NOT constrained to `i18n.supportedLocales`.
*
* Measured on @objectstack 18.2.0 with the English rows tagged `en`, through
Expand All @@ -433,9 +432,16 @@ const DELIVERY_TERMINAL = new Set(['success', 'failed', 'dead']);
* delivery row's status as well as on the inbox body: a dead letter is not a
* thrown error anywhere a flow run can see, and the run reports success.
*
* `zh` and `zh-TW` land on ENGLISH, not on the zh-CN row. That is what the
* platform gives today — there is no subtag folding — and it is pinned so the
* day the resolver learns to fold, this goes red and says so.
* On 18.2.0 the resolver matched exactly, then tried the literal `en-US`
* floor, and stopped, so `zh` and `zh-TW` landed on ENGLISH. That was pinned
* so the day the resolver learned to fold, this would go red and say so.
*
* It did, on 18.3.0 (objectstack#28880): the built-in loader now folds the
* recipient's tag against the template's own locales by the platform's
* BCP-47 rules — exact, case-insensitive, base language (`en-GB` → `en`),
* then variant expansion (`zh` / `zh-TW` → `zh-CN`) — before the stack's
* declared fallback and the `en-US` floor. So `zh` and `zh-TW` now read the
* Chinese row; a language the app never authored still gets English.
*/
describe('a recipient whose locale no row lists gets the en-US floor, not a dead letter', () => {
const ENGLISH = `This is now on your list. Due ${utcDay(7)}.`;
Expand All @@ -444,8 +450,8 @@ describe('a recipient whose locale no row lists gets the en-US floor, not a dead
/** user id → `sys_user.locale`, and the body that user must receive. */
const RECIPIENTS: { id: string; locale: string; body: string }[] = [
{ id: 'user_fr', locale: 'fr-FR', body: ENGLISH }, // a language the app never authored
{ id: 'user_zh_tw', locale: 'zh-TW', body: ENGLISH }, // no folding to zh-CN
{ id: 'user_zh', locale: 'zh', body: ENGLISH }, // ditto, bare language subtag
{ id: 'user_zh_tw', locale: 'zh-TW', body: CHINESE }, // folds to zh-CN (18.3, objectstack#28880)
{ id: 'user_zh', locale: 'zh', body: CHINESE }, // ditto, bare language subtag
{ id: 'user_en_us', locale: 'en-US', body: ENGLISH }, // the floor, matched exactly
{ id: 'user_en_gb', locale: 'en-GB', body: ENGLISH },
{ id: 'user_en', locale: 'en', body: ENGLISH }, // the app's own tag: exact miss, then floor
Expand Down
Loading
Loading