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
24 changes: 24 additions & 0 deletions src/apps/duly.app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,30 @@ export const DulyApp = App.create({
// nav item carries `dashboardName` (resolved against the dashboards
// barrel), never an `objectName` — nothing on it is entered.
{ id: 'nav_duty_health', type: 'dashboard', dashboardName: 'duly_duty_health', label: 'Duty health', icon: 'activity' },
// The way into `duly_member` (`src/pages/member.page.ts`) — "点开任何
// 一个人看全貌". A record page is reached by opening a RECORD, so the
// nav entry is the people list, not the page: a `type: 'page'` item
// routes through objectui's `PageView`, which mounts no
// `RecordContextProvider`, and every `record:related_list` on that page
// would then have a null parent and render nothing. Placed directly
// under the dashboard because the dashboard names who to look at and
// this is where you go to look at them.
//
// `requiresObject` is load-bearing twice over. It is the platform's own
// idiom for pointing nav at a RUNTIME-provided object, and without it
// `defineStack` refuses the stack outright: the cross-reference check
// resolves `objectName` against `config.objects` only, and exempts an
// entry that declares the dependency. It is also the right runtime
// behaviour — the entry hides instead of 404-ing where `sys_user` is
// not registered.
{
id: 'nav_people',
type: 'object',
objectName: 'sys_user',
requiresObject: 'sys_user',
label: 'People',
icon: 'users-round',
},
{ id: 'nav_late', type: 'object', objectName: 'duly_task', viewName: 'late', label: 'Late', icon: 'alert-circle' },
{ id: 'nav_stalled', type: 'object', objectName: 'duly_task', viewName: 'stalled', label: 'Not moving', icon: 'pause-circle' },
{ id: 'nav_assignments', type: 'object', objectName: 'duly_assignment', viewName: 'sent_by_me', label: 'Assignments', icon: 'send' },
Expand Down
5 changes: 4 additions & 1 deletion src/pages/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import { MemberPage } from './member.page.js';

//
// Barrel for src/pages/.
//
Expand All @@ -13,4 +16,4 @@
// makes `name` optional and fails the assignment. A named array is `never[]`
// while empty and infers correctly the moment something is pushed into it.

export const dulyPages = [];
export const dulyPages = [MemberPage];
547 changes: 547 additions & 0 deletions src/pages/member.page.ts

Large diffs are not rendered by default.

92 changes: 92 additions & 0 deletions src/translations/authored-text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,30 @@ const translate = (key: (ctx: KeyContext) => readonly string[] | undefined): Ver

const id = (ctx: KeyContext): string | undefined => str(ctx.ids.name);

/**
* The `id` of the page component a string was found inside.
*
* A page nests its labels two levels below the surface root
* (`regions[i].components[j].properties.title`), so `ctx.parent` is the
* `properties` bag — which carries no identity. The addressable id is on the
* component, one level up, and the only way back to it is the concrete path:
* `ctx.ids` IS the page node for a `simpleSurfaces` surface, so walk it.
*
* Addressing by the authored `id` rather than by position is what keeps a
* bundle key stable when a component moves — reordering the page must not
* silently re-point every translation, which `regions.1.components.7` would.
*/
const pageComponentId = (ctx: KeyContext): string | undefined => {
const [regionsKey, regionIndex, componentsKey, componentIndex] = ctx.path;
if (regionsKey !== 'regions' || componentsKey !== 'components') return undefined;
const regions = ctx.ids.regions;
if (!Array.isArray(regions)) return undefined;
const region = regions[Number(regionIndex)];
if (!isRec(region) || !Array.isArray(region.components)) return undefined;
const component = region.components[Number(componentIndex)];
return isRec(component) ? str(component.id) : undefined;
};

/**
* Subtrees the walk does not descend into, each with the reason. An opaque
* subtree holds machine values only — a field payload, a filter, a binding
Expand Down Expand Up @@ -200,6 +224,18 @@ const OPAQUE: Readonly<Record<string, string>> = {
'permissionSet.objects': 'per-object CRUD scopes',
'permissionSet.fieldPermissions': 'per-field read/write flags',
'permissionSet.tabPermissions': 'per-app tab visibility',
// ── page ──────────────────────────────────────────────────────────────
// The same three value bags `view.*` above declares opaque, at their
// page-component position. A related list's `columns` / `sort` / `filter`
// carry exactly what a view's do — field paths, directions, operators,
// stored values and `{date-macros}` — and the prose net below is the
// backstop if one of them ever grows a sentence.
'page.regions[].components[].properties.columns': 'column list — field paths',
'page.regions[].components[].properties.sort': 'sort list — field paths and directions',
'page.regions[].components[].properties.filter':
'related-list filter rules — field paths, operators and machine values / date macros',
'page.regions[].components[].responsiveStyles':
'per-breakpoint CSS declarations (ADR-0065 scoped styles) — property names and values',
};

/**
Expand Down Expand Up @@ -312,6 +348,7 @@ const VERDICTS: Readonly<Record<string, Verdict>> = {
'app.navigation[].children[].objectName': machine('bound object'),
'app.navigation[].children[].viewName': machine('bound view'),
'app.navigation[].children[].dashboardName': machine('bound dashboard'),
'app.navigation[].children[].requiresObject': machine('capability gate — the runtime object name this entry needs registered'),

// ── dashboard ─────────────────────────────────────────────────────────
'dashboard.name': machine('dashboard name'),
Expand All @@ -332,6 +369,61 @@ const VERDICTS: Readonly<Record<string, Verdict>> = {
'dashboard.widgets[].values[]': machine('dataset measure names'),
'dashboard.widgets[].colorVariant': machine('tile colour role'),

// ── page ──────────────────────────────────────────────────────────────
'page.name': machine('page name — its routing identity'),
'page.icon': machine('icon name'),
'page.type': machine('page kind'),
'page.kind': machine('page override mode'),
'page.template': machine('layout template name'),
'page.object': machine('bound object'),
'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'),
'page.regions[].width': machine('layout region width'),
'page.regions[].components[].id': machine('component id — the translation key itself'),
'page.regions[].components[].type': machine('component kind'),
'page.regions[].components[].properties.objectName': machine('bound object'),
'page.regions[].components[].properties.relationshipField': machine('field on the child object holding this record'),
'page.regions[].components[].properties.layout': machine('highlight strip orientation'),
'page.regions[].components[].properties.position': machine('panel dock position'),
'page.regions[].components[].properties.variant': machine('text style variant'),
// `element:text`'s `content` is the ONE string that component renders, and
// `PageTranslation.components` has no key for it — measured on
// `@objectstack/spec` 17.2.0: the face is
// `title | description | label | placeholder | emptyText | submitLabel`,
// its alias table maps `text → label` and `caption → description`, and
// `content` appears nowhere in either. The face's own comment says the keys
// were derived from the copy props components declare, so this is a missed
// one rather than a deliberate exclusion (the two exclusions it DOES make
// are named there: `help` and `subtitle`).
//
// The route that works is the other one the same comment blesses:
// `content` is `I18nLabelSchema`, which accepts an inline `{ en, 'zh-CN' }`
// locale map resolved by `pickLocalized` at render — the route the
// platform's own `sys-user.page.ts` uses for its eight `element:text`
// nodes. So these strings ARE localized; they are localized at the
// authoring site instead of in the bundle, which is why they are excused
// here rather than keyed. Filed upstream; see the PR body. When the key
// lands, replace these two verdicts with a `translate` on
// `…properties.content` and move the copy into the bundles.
'page.regions[].components[].properties.content.en':
untranslatable('`element:text` copy, localized inline — the bundle has no `content` key for this component'),
'page.regions[].components[].properties.content.zh-CN':
untranslatable('`element:text` copy, localized inline — the bundle has no `content` key for this component'),
'page.regions[].components[].properties.fields[].name': machine('field name'),
// The two strings a page component actually SHOWS. Both live under
// `properties` because that is where the renderer reads them from — a
// component's own top-level `label` is not what `record:related_list` draws
// as its heading, nor what `element:text` renders as its body.
'page.regions[].components[].properties.title': translate((c) => {
const component = pageComponentId(c);
return id(c) && component ? ['pages', id(c)!, 'components', component, 'title'] : undefined;
}),
'page.regions[].components[].properties.content': translate((c) => {
const component = pageComponentId(c);
return id(c) && component ? ['pages', id(c)!, 'components', component, 'content'] : undefined;
}),

// ── action ────────────────────────────────────────────────────────────
// An object-bound action is addressed under its object; an object-less one
// under `globalActions`. `validateTranslationReferences` refuses the wrong
Expand Down
25 changes: 25 additions & 0 deletions src/translations/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -464,6 +464,7 @@ export const dulyChinese = defineTranslationBundle({
nav_board: { label: '看板' },
group_team: { label: '团队' },
nav_duty_health: { label: '职责健康度' },
nav_people: { label: '成员' },
nav_late: { label: '逾期' },
nav_stalled: { label: '停滞' },
nav_assignments: { label: '指派' },
Expand All @@ -478,6 +479,30 @@ export const dulyChinese = defineTranslationBundle({
},
},

pages: {
duly_member: {
// 「看全貌」页:主管打开一个人,不必先问他任何问题。
label: '成员',
description:
'一个人的全貌:当下有什么未完成、本周期应尽什么、长期持有什么、最近动了什么——只读,不需要任何人录入。',
components: {
// 岗位来自 `sys_user_position` 联结表,不是 `sys_user` 上的字段。
member_position: { title: '岗位' },
right_now_open: { title: '未完成' },
// 逾期 = 过了派发当时该职责给出的宽限期,且仍未完成。
right_now_late: { title: '逾期' },
// 与「停滞」视图同名:同一个判断在产品里只能有一种说法。
right_now_stalled: { title: '停滞' },
// 「受治理」= 来自岗位职责库或主管指派;自行申报的不在此列。
this_period_duties: { title: '受治理的职责' },
// ⛔ 不要写成「常设任务」:常设职责永不产生任务,也永不完成。
standing_duties: { title: '长期持有' },
recent_activity: { title: '最近触碰' },
assigned_tasks: { title: '被指派的工作' },
},
},
},

dashboards: {
duly_duty_health: {
label: '职责健康度',
Expand Down
8 changes: 8 additions & 0 deletions test/i18n-coverage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,8 @@ describe('untranslatable display text is declared rather than dropped', () => {
'job.description',
'job.label',
'object.validations[].message',
'page.regions[].components[].properties.content.en',
'page.regions[].components[].properties.content.zh-CN',
'permissionSet.description',
'permissionSet.label',
'position.description',
Expand Down Expand Up @@ -306,6 +308,12 @@ describe('untranslatable display text is declared rather than dropped', () => {
// checked below — so this is a gap that CLOSED, pinned at zero so it
// cannot silently reopen as inline copy.
expect(count('flow.nodes[].config.'), 'inline notification copy — closed by #69').toBe(0);
// `element:text` copy on `duly_member`, which IS end-user-facing and IS
// localized — inline, because `PageTranslation.components` has no
// `content` key (see the verdict's own note). Eight nodes × two locales.
// The number is the size of the filed spec gap; when the key lands these
// strings move into the bundles and this drops to 0.
expect(count('page.regions'), '`element:text` copy localized inline').toBe(16);
});
});

Expand Down
Loading
Loading