Skip to content

Commit 5174603

Browse files
fix(ui): Setup record pages show names, not ids, for sharing-rule recipients, conditions and sign-in audit rows (#30496)
<!-- claude-projects-attribution: {"github_login":"os-sales"} --> _Requested by **Sales** · [project thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4UuT8Khw8CoWCTDdkBhufE8uP)_ domain:ui#6 · os-sales · session_01L6bhgWs5eCv64CbCPwjWhV Refs #30209 (rows 2 and the record-label half of row 3; rows 1, 3-rest, 4, 5 and 6 are not in this PR, see Acceptance notes) Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:Setup 的共享规则记录页,「接收方」显示用户/团队/岗位名称而不是 id,「条件」显示成「优先级 等于 紧急」这样的文字而不是 JSON;审计日志列表里登录行的「记录」列显示会话所属用户名,不再是用户 id。 - 为什么改:管理员排查权限问题时读不懂这些页面(#30209 实测)。列表页早已显示名称,记录页没把同行数据交给显示组件,条件字段也没有阅读态。 - 风险与回滚:只动 ui 显示层,三处改动各自独立,不改任何存储或接口;回滚即还原本 PR。条件无法用可视化表达时仍显示原始 JSON。 - 先看:`ui/packages/plugin-grid/src/RecordPointerCell.tsx`(读取等定义返回后再查记录,名称字段是引用时才 `$expand`)、`ui/packages/fields/src/widgets/FilterConditionField.tsx`(只读态的文字化)、`ui/packages/plugin-detail/src/DetailSection.tsx`。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/fields/src/widgets/__tests__/FilterConditionField.readWords-30209.test.tsx packages/plugin-detail/src/__tests__/recordFace.siblingValues-30209.test.tsx packages/plugin-grid/src/__tests__/ObjectGrid.recordPointerCell-12608.test.tsx`,预期全部通过;或在控制台打开一条共享规则记录,看「接收方」是人名、「条件」是一句话。 - 需要你做的:无,席位落地。 ## What changes - **Record page faces get the record** (`@object-ui/plugin-detail`). `HeaderHighlight` and `DetailSection` hand each read cell `dependentValues` (the staged record / the record), as the list grid hands its row. The `recipient-picker` face reads `recipient_type` from it, so a sharing rule's 接收方 reads the recipient's name instead of the stored id. - **`filter-condition` has a read face** (`@object-ui/fields`). The key joins `WIDGET_CELL_RENDERER_TYPES` and its cell (`FilterConditionCell`, lazy, stored text until the widget module lands) draws the widget's read-only display. That display now says a representable criteria in words, in the object's field and option labels, joined by the AND / OR word, with booleans as words and both bounds of a range; the words are the filter builder's own `filterBuilder.*` rows, so no language-pack key is added. A criteria the builder cannot represent still reads as the stored JSON; a non-string value reads as the text cell reads it; a read-only widget no longer refuses to speak without `object_name` (fields keep their api names). - **Pointer cells name a lookup-named record** (`@object-ui/plugin-grid`). `RecordPointerCell`'s batched read now waits for the target definition (already in flight) and adds `$expand: [nameField]` when the object's name field is a reference type the viewer may read (`buildExpandFields` over that one field, gated by `checkField` once the policy has answered). `sys_session` is named by `user_id`, so the audit list's sign-in rows read "Dev Admin" instead of the user id. Any other name field is read exactly as before, with no `$expand`. ## Tests - New `ui/packages/fields/src/widgets/__tests__/FilterConditionField.readWords-30209.test.tsx` (zh provider): one condition reads `优先级 等于 紧急`; an OR group with a boolean reads `优先级 等于 低 或 已完成 等于 是`; a nested criteria stays the stored JSON (control). - New `ui/packages/plugin-detail/src/__tests__/recordFace.siblingValues-30209.test.tsx`: a probe face registered under `recipient-picker` receives the record from the highlights strip and from the details body. - `ui/packages/plugin-grid/src/__tests__/ObjectGrid.recordPointerCell-12608.test.tsx` gains a top-level `describe`: a session row names its user and the read expands only `user_id`; a text-named record is read with no `$expand` (control). - Two registry censuses gain the new key's row: `cellRenderers.objectLiteral-8596` and `summaryChip.badgeFitCensus-8464` (fit side 40 → 41). ## Readings (head `f122316d`, base `c5800818`) - Positive control: with the three source files restored to the base, 5 of the new assertions are red and both controls stay green. - Ablations (each restored, `git diff HEAD` empty): `DetailSection` `dependentValues={data}` removed → the details case red (1 failed / 1 passed); the words branch disabled (`if (false)`) → 2 failed / 1 passed; the `$expand` spread deleted → 1 failed / 4 passed. - Suites: `fields`, `plugin-detail`, `plugin-grid` and the app-shell widget-ref-cells / record-preview directories: the only reds were the four registry censuses, now carrying the new key (rerun 36 files / 453 tests green). - Type-check: `turbo run type-check --filter=@object-ui/fields --filter=@object-ui/plugin-detail --filter=@object-ui/plugin-grid`, 55/55. - Gates: the 35 derived dispatch gates exit 0 (`vitest run scripts/` rerun alone: 171 files passed); ui `check:spec-symbols`, `check:i18n-keys`, `check:unreferenced-sources`, `check:side-effects-array`, `check:phantom-deps`, `check:esm-specifiers`, `check:i18n-drift`, `check:new-line-citations` and root `check:doc-authoring` exit 0. eslint on the changed files: 0 errors. ## Acceptance notes Re-measured on `origin/main` `c5800818` with app-showcase (`objectstack dev --seed-admin`) and the console on vite, zh-CN, before any edit: | Row | Reading on main | This PR | Not shipped: reason · owning lane | | --- | --- | --- | --- | | 1 | 林伟's 权限集 tab: `0`, 暂无相关记录 | — | The tab is page metadata (`packages/platform-objects/src/pages/sys-user.page.ts`, a `record:related_list` over `sys_user_permission_set`). Showing effective access needs a page node that calls the explain door, i.e. a page-component vocabulary entry in `packages/spec` plus the page edit in `packages/platform-objects`, then the ui block. ⛔ `packages/**` in this claim · spec + platform-objects (server) lanes, then ui | | 2 | 接收方 `zlom5o5m8OyCwF3aBgywwFWOHKJ03mou`; 条件 `{"priority":"urgent"}` | 接收方 林伟; 条件 优先级 等于 紧急 | — | | 3 | 记录 column: sign-in rows `05bdsJ9C…` (a `sys_session`, named by its `user_id` lookup); other rows already named | sign-in rows name the user | Object labels: `sys_platform_setting_audit` (and others) have no zh-CN row in `packages/platform-objects` translations, so the label is the English source · i18n source, server/platform lane. "One sign-in writes several rows" and a default view that hides them are the audit writer and the object's list views in `packages/**` · server lane | | 4 | not measured (claim ⛔) | — | What is secret is a server decision · server lane | | 5 | not measured (claim ⛔, reading only) | — | security-sensitive picker filtering · security lane | | 6 | queue record shows only its label; members only under Queue Members | — | `sys_queue_member.queue` is a text field holding the queue name, not a lookup, so no related list is synthesized and none can be authored without metadata: a lookup, or a `sys_queue` page with a members list, both in `packages/plugins/plugin-sharing` · server lane | - Noted, not filed: on the same pages the Setup navigation and the `sys_queue` labels ("Queues", "Queue", "Display Label") read English under zh-CN; that is translation coverage in `packages/**`. - Trade-off: a filter-condition cell in a grid column now loads the widget chunk once per page that shows one (stored text until it lands). ## Verification Screenshots (zh-CN console, 1440×900 @2x) on the never-merged evidence branch: | | Before (main) | After (this PR) | | --- | --- | --- | | Sharing rule record (row 2) | ![before](https://github.com/objectstack-ai/objectstack/blob/994f8a845a98a971ab51cd20cd56de7ce2e00b90/evidence/30209/before-1-sharing-rule-zh-1440.png?raw=true) | ![after](https://github.com/objectstack-ai/objectstack/blob/994f8a845a98a971ab51cd20cd56de7ce2e00b90/evidence/30209/after-1-sharing-rule-zh-1440.png?raw=true) | | Audit list 记录 column (row 3) | ![before](https://github.com/objectstack-ai/objectstack/blob/994f8a845a98a971ab51cd20cd56de7ce2e00b90/evidence/30209/before-2-audit-list-zh-1440.png?raw=true) | ![after](https://github.com/objectstack-ai/objectstack/blob/994f8a845a98a971ab51cd20cd56de7ce2e00b90/evidence/30209/after-2-audit-list-zh-1440.png?raw=true) | Evidence commit `994f8a84` (removed again in `165c14ce`, so the PR diff carries no images). 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01L6bhgWs5eCv64CbCPwjWhV --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent ea5807e commit 5174603

14 files changed

Lines changed: 530 additions & 24 deletions
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@object-ui/fields': patch
3+
'@object-ui/plugin-detail': patch
4+
'@object-ui/plugin-grid': patch
5+
---
6+
7+
fix(ui): Setup record pages name a sharing rule's recipient and read its condition in words, and the audit list names a sign-in row's session by its user
8+
9+
Clause-②: no
10+
Metadata-protocol: none
11+
12+
- **`@object-ui/plugin-detail`.** The record page's highlights strip and details body hand each read face the record as `dependentValues`, as a list grid hands its row. A `recipient-picker` value is an id that means something only beside its `recipient_type`, so a sharing rule's page read the stored user id under 接收方 where its list read the user's name; it now reads the name.
13+
- **`@object-ui/fields`.** `widget: 'filter-condition'` has a read face of its own: a list or detail cell draws the criteria through the widget's read-only display, which says it in the object's field and option labels ("Priority equals Urgent"; an OR group joined by its word, a boolean as a word). A criteria the builder cannot represent still reads as the stored JSON, and an empty one is the empty cell. A read-only widget no longer waits for its `object_name` to say anything: without it, the fields keep their api names.
14+
- **`@object-ui/plugin-grid`.** A pointer cell (`referenceVia`) reads the target record with its name field expanded when that field is a reference the viewer may read, so a record named by a lookup reads as the referenced record's name: the audit list's sign-in rows name the session by its user instead of printing the user id. Any other name field is read as before, with no `$expand`.

‎ui/packages/fields/src/__tests__/cellRenderers.objectLiteral-8596.test.tsx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,9 @@ const OBJECT_LITERAL_CENSUS: ReadonlyArray<readonly [type: string, text: string,
302302
// face for it, exactly as `text` does.
303303
['position-ref', '[Object]', false],
304304
['permission-ref', '[Object]', false],
305+
// ── objectstack#30209: the criteria read face ───────────────────────────
306+
// `{}` is not stored criteria text, so it reads as the text cell reads it.
307+
['filter-condition', '[Object]', false],
305308
];
306309

307310
/** A real value per type — the POPULATED half, which refuses the caricature. */
@@ -364,6 +367,7 @@ const POPULATED_CENSUS: ReadonlyArray<readonly [type: string, value: unknown]> =
364367
['recipient-picker', 'sales_manager'],
365368
['position-ref', 'sales_manager'],
366369
['permission-ref', 'admin_full_access'],
370+
['filter-condition', '{"priority":"urgent"}'],
367371
];
368372

369373
describe('objectui#8596 — an object literal is not a cell value, and these renderers invented one', () => {

‎ui/packages/fields/src/__tests__/fieldFaceSeam-13216.test.tsx‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,8 +99,9 @@ describe('objectui#13216 — the field-face seam answers the registry face of a
9999

100100
// No widget.
101101
expect(resolveFieldReadFace({ name: 'subject', type: 'text' } as any)).toBeUndefined();
102-
// A widget with no read face of its own draws as its type.
103-
expect(resolveFieldReadFace({ name: 'criteria', type: 'textarea', widget: 'filter-condition' } as any)).toBeUndefined();
102+
// A widget with no read face of its own draws as its type. (`code`: since
103+
// objectstack#30209 `filter-condition` has a face of its own.)
104+
expect(resolveFieldReadFace({ name: 'snippet', type: 'textarea', widget: 'code' } as any)).toBeUndefined();
104105
// A non-textual base type keeps its own key, so the widget does not promote.
105106
expect(resolveFieldReadFace({ name: 'kind', type: 'select', widget: 'object-ref' } as any)).toBeUndefined();
106107
// A `format` hint promotes to a richer cell, but it is not a widget's face.

‎ui/packages/fields/src/__tests__/widgetRefCells-12613.test.tsx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,9 @@ describe('objectui#12613 — the declared widget picks the cell key', () => {
4949
// Controls: no widget, a widget with no read cell of its own, and a
5050
// non-textual base type keep the answer they had.
5151
expect(resolveCellRendererType({ type: 'text' })).toBe('text');
52-
expect(resolveCellRendererType({ type: 'textarea', widget: 'filter-condition' })).toBe('textarea');
52+
// (`code`: since objectstack#30209 `filter-condition` has a read cell.)
53+
expect(resolveCellRendererType({ type: 'textarea', widget: 'code' })).toBe('textarea');
54+
expect(resolveCellRendererType({ type: 'textarea', widget: 'filter-condition' })).toBe('filter-condition');
5355
expect(resolveCellRendererType({ type: 'select', widget: 'object-ref' })).toBe('select');
5456
// The widget wins over a `format` hint on the same text field: it names
5557
// what the value IS, the hint only how plain text is drawn.

‎ui/packages/fields/src/index.tsx‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3692,11 +3692,13 @@ const TEXTUAL_BASE_TYPES = new Set(['text', 'textarea', 'string', 'longtext', ''
36923692
*
36933693
* Only a widget listed here promotes, and only over a textual base type, the
36943694
* same bound the `format` hints keep: a widget with no read face of its own
3695-
* (`filter-condition`, …) still draws as its `type`.
3695+
* still draws as its `type`. `filter-condition` is not a reference: its face
3696+
* draws the stored criteria as a sentence instead of JSON.
36963697
*/
36973698
const WIDGET_CELL_RENDERER_TYPES: ReadonlySet<string> = new Set([
36983699
'object-ref',
36993700
'recipient-picker',
3701+
'filter-condition',
37003702
'position-ref',
37013703
'permission-ref',
37023704
]);
@@ -4417,6 +4419,7 @@ export function getCellRenderer(fieldType: string): React.FC<CellRendererProps>
44174419
}
44184420

44194421
import { PositionRefCell, PermissionRefCell } from './widgets/RegistryRefCell.js';
4422+
import { FilterConditionCell } from './widgets/FilterConditionCell.js';
44204423
// Register standard renderers immediately
44214424
registerFieldRenderer('lookup', LookupCellRenderer);
44224425
registerFieldRenderer('master_detail', LookupCellRenderer);
@@ -4442,6 +4445,9 @@ registerFieldRenderer('recipient-picker', TextCellRenderer);
44424445
// source and type, which `check:eager-closure` weighs with the rest of it.
44434446
registerFieldRenderer('position-ref', PositionRefCell);
44444447
registerFieldRenderer('permission-ref', PermissionRefCell);
4448+
// The criteria key draws its sentence through the widget's own read display,
4449+
// loaded lazily on the first cell that shows one (the stored text until then).
4450+
registerFieldRenderer('filter-condition', FilterConditionCell);
44454451
// `owner` was registered here to the same UserCellRenderer until objectui#4814
44464452
// retired the spelling — see the TOMBSTONE below. `getCellRenderer('owner')`
44474453
// now reports the prescription and falls to the text cell.
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* The READ face of the `filter-condition` key: a list or detail cell of a
11+
* textual field declaring `widget: 'filter-condition'`
12+
* (`resolveCellRendererType`) draws the criteria the way the widget's own
13+
* read-only display does, as a sentence in the object's field and option
14+
* labels (`优先级 等于 紧急`), instead of the stored JSON. The object the
15+
* criteria is scoped to is the row's `object_name`, so the face reads
16+
* `dependentValues`, as the `recipient-picker` face does.
17+
*
18+
* The widget module loads LAZILY, on the first cell that draws one, so a page
19+
* that shows no criteria pays nothing; until it arrives the cell draws the
20+
* stored text, exactly what the text cell drew.
21+
*/
22+
23+
import React from 'react';
24+
import { EmptyValue } from '@object-ui/components';
25+
import { isEmptyValue } from '@object-ui/core';
26+
import { coerceToSafeValue } from '../coerceToSafeValue.js';
27+
import type { CellRendererProps } from '../index.js';
28+
29+
const LazyFilterConditionField = React.lazy(() =>
30+
import('./FilterConditionField.js').then((m) => ({ default: m.FilterConditionField })),
31+
);
32+
33+
function StoredText({ text }: { text: string }) {
34+
return (
35+
<span className="block truncate" title={text}>
36+
{text}
37+
</span>
38+
);
39+
}
40+
41+
export function FilterConditionCell({ value, field, dependentValues }: CellRendererProps) {
42+
const safe = coerceToSafeValue(value);
43+
if (isEmptyValue(safe)) return <EmptyValue />;
44+
const text = String(safe);
45+
// The key's storage is JSON text (a `textarea`); any other value is not a
46+
// stored criteria and reads as the text cell reads it.
47+
if (typeof value !== 'string') return <StoredText text={text} />;
48+
return (
49+
<React.Suspense fallback={<StoredText text={text} />}>
50+
<LazyFilterConditionField
51+
value={value}
52+
onChange={ignoreChange}
53+
readonly
54+
field={field}
55+
dependentValues={dependentValues}
56+
/>
57+
</React.Suspense>
58+
);
59+
}
60+
61+
function ignoreChange(): void {}

‎ui/packages/fields/src/widgets/FilterConditionField.tsx‎

Lines changed: 106 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,45 @@ import { FilterBuilder, cn } from '@object-ui/components';
33
import { SchemaRendererContext } from '@object-ui/react';
44
import type { FieldWidgetComponentProps } from './types.js';
55
import { toDomProps } from './toDomProps.js';
6+
import { createSafeTranslation } from '@object-ui/i18n';
67
import { useFieldTranslation } from './useFieldTranslation.js';
78

9+
/**
10+
* The words a read-only criteria is said in: the filter builder's own
11+
* operator, AND / OR and boolean rows, so the sentence names each operator as
12+
* the builder's dropdown does. The English rows are the provider-less render's
13+
* stand-in, the same values as the `en` pack.
14+
*/
15+
const useCriteriaWordsTranslation = createSafeTranslation(
16+
{
17+
'filterBuilder.and': 'AND',
18+
'filterBuilder.or': 'OR',
19+
'filterBuilder.trueLabel': 'True',
20+
'filterBuilder.falseLabel': 'False',
21+
'filterBuilder.operators.equals': 'Equals',
22+
'filterBuilder.operators.not_equals': 'Does not equal',
23+
'filterBuilder.operators.contains': 'Contains',
24+
'filterBuilder.operators.icontains': 'Contains (ignore case)',
25+
'filterBuilder.operators.not_contains': 'Does not contain',
26+
'filterBuilder.operators.is_empty': 'Is empty',
27+
'filterBuilder.operators.is_not_empty': 'Is not empty',
28+
'filterBuilder.operators.greater_than': 'Greater than',
29+
'filterBuilder.operators.less_than': 'Less than',
30+
'filterBuilder.operators.greater_than_or_equal': 'Greater than or equal',
31+
'filterBuilder.operators.less_than_or_equal': 'Less than or equal',
32+
'filterBuilder.operators.between': 'Between',
33+
'filterBuilder.operators.in': 'In',
34+
'filterBuilder.operators.not_in': 'Not in',
35+
'filterBuilder.operators.starts_with': 'Starts with',
36+
'filterBuilder.operators.ends_with': 'Ends with',
37+
'filterBuilder.operators.is_null': 'Is null',
38+
'filterBuilder.operators.is_not_null': 'Is not null',
39+
'filterBuilder.operators.exists': 'Is set',
40+
'filterBuilder.operators.notExists': 'Is not set',
41+
},
42+
'filterBuilder.operators.equals',
43+
);
44+
845
/**
946
* FilterConditionField — visual criteria builder for a stored FilterCondition
1047
* (e.g. `sys_sharing_rule.criteria_json`), scoped to the object chosen in a
@@ -565,6 +602,56 @@ export function isMatchAllCriteria(parsed: any): boolean {
565602
return true;
566603
}
567604

605+
/** Builder operators that take no comparand: the row reads "FIELD OPERATOR". */
606+
const VALUELESS_OPERATORS: ReadonlySet<string> = new Set([
607+
'is_empty', 'is_not_empty', 'is_null', 'is_not_null', 'exists', 'notExists',
608+
]);
609+
610+
/**
611+
* One builder row in words: the field's label, the operator's label and the
612+
* comparand as the field draws it (an option's label for a select, the boolean
613+
* words for a boolean, both bounds for a range). A field the schema does not
614+
* list keeps its api name, and a value no option holds stays as stored, so the
615+
* sentence never invents a label.
616+
*/
617+
function describeCondition(
618+
cond: BuilderCondition,
619+
fieldByName: ReadonlyMap<string, FilterFieldDef>,
620+
t: (key: string) => string,
621+
): string {
622+
const def = fieldByName.get(cond.field);
623+
const parts = [def?.label || cond.field, t(`filterBuilder.operators.${cond.operator}`)];
624+
if (VALUELESS_OPERATORS.has(cond.operator)) return parts.join(' ');
625+
const word = (v: unknown): string => {
626+
if (typeof v === 'boolean') return t(v ? 'filterBuilder.trueLabel' : 'filterBuilder.falseLabel');
627+
const option = def?.options?.find((o) => o.value === String(v));
628+
return option ? option.label : stringifyValue(v as string | object | null | undefined);
629+
};
630+
const value = cond.value;
631+
if (cond.operator === 'between' && Array.isArray(value)) {
632+
parts.push(`${word(value[0])} – ${word(value[1])}`);
633+
} else if (Array.isArray(value)) {
634+
parts.push(value.map(word).join(', '));
635+
} else {
636+
parts.push(word(value));
637+
}
638+
return parts.join(' ');
639+
}
640+
641+
/**
642+
* A criteria the builder can represent, as one sentence: each row in words,
643+
* joined by the group's AND / OR word.
644+
*/
645+
function describeFilterGroup(
646+
group: BuilderGroup,
647+
fields: readonly FilterFieldDef[],
648+
t: (key: string) => string,
649+
): string {
650+
const fieldByName = new Map(fields.map((f) => [f.value, f]));
651+
const joiner = ` ${t(group.logic === 'or' ? 'filterBuilder.or' : 'filterBuilder.and')} `;
652+
return group.conditions.map((c) => describeCondition(c, fieldByName, t)).join(joiner);
653+
}
654+
568655
function stringifyValue(value: string | object | undefined | null): string {
569656
if (value == null || value === '') return '';
570657
if (typeof value === 'string') return value;
@@ -585,6 +672,7 @@ export function FilterConditionField({
585672
}: FieldWidgetComponentProps<string | object>) {
586673
const ctx = React.useContext(SchemaRendererContext);
587674
const { t } = useFieldTranslation();
675+
const { t: tWords } = useCriteriaWordsTranslation();
588676
// Cast-free context read (objectui#7912); the local stays `any` for the
589677
// `FieldWidgetProps.dataSource?: unknown` channel it merges with.
590678
const dataSource: any = props.dataSource ?? ctx?.dataSource ?? null;
@@ -689,14 +777,6 @@ export function FilterConditionField({
689777
onChange((mongo == null ? '' : JSON.stringify(mongo)) as any);
690778
};
691779

692-
if (!objectName) {
693-
return (
694-
<p className={cn('text-sm text-muted-foreground', className)}>
695-
{t('fields.filterCondition.selectObjectFirst')}
696-
</p>
697-
);
698-
}
699-
700780
if (readonly) {
701781
if (!rawValue.trim()) {
702782
// Used to read "All records" — which was both wrong and the most
@@ -709,13 +789,31 @@ export function FilterConditionField({
709789
</span>
710790
);
711791
}
792+
// A criteria the builder can represent reads as a sentence, in the
793+
// object's field and option labels once its schema answers; one it cannot
794+
// represent stays the stored JSON, so nothing is hidden or reworded.
795+
if (group && group.conditions.length > 0) {
796+
return (
797+
<span className={cn('text-sm', className)} data-filter-condition="words" title={rawValue}>
798+
{describeFilterGroup(group, fields ?? [], tWords)}
799+
</span>
800+
);
801+
}
712802
return (
713803
<pre className={cn('overflow-x-auto rounded bg-muted/40 p-2 text-xs', className)}>
714804
{rawValue}
715805
</pre>
716806
);
717807
}
718808

809+
if (!objectName) {
810+
return (
811+
<p className={cn('text-sm text-muted-foreground', className)}>
812+
{t('fields.filterCondition.selectObjectFirst')}
813+
</p>
814+
);
815+
}
816+
719817
return (
720818
<div className={cn('flex flex-col gap-2', className)}>
721819
{rawMode ? (
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* A stored criteria reads as words (objectstack#30209 row 2).
11+
*
12+
* A sharing rule's `criteria_json` declares `widget: 'filter-condition'`. Its
13+
* record page drew the stored JSON, `{"priority":"urgent"}`, because the key
14+
* had no read face and fell to its `textarea` type. The key now draws through
15+
* the widget's read display, which says the criteria in the object's field and
16+
* option labels. Red before the fix (the cell printed the JSON), green after.
17+
*
18+
* Two controls keep the change narrow: a criteria the builder cannot represent
19+
* still reads as the stored JSON, and an empty one is the empty cell.
20+
*/
21+
22+
import React from 'react';
23+
import { describe, it, expect, afterEach } from 'vitest';
24+
import { render, screen, cleanup } from '@testing-library/react';
25+
import '@testing-library/jest-dom';
26+
import { I18nProvider } from '@object-ui/i18n';
27+
import { SchemaRendererProvider } from '@object-ui/react';
28+
import { getCellRenderer, resolveCellRendererType } from '../../index';
29+
30+
afterEach(cleanup);
31+
32+
const TASK_SCHEMA = {
33+
name: 'task',
34+
fields: {
35+
priority: {
36+
name: 'priority',
37+
label: '优先级',
38+
type: 'select',
39+
options: [
40+
{ value: 'urgent', label: '紧急' },
41+
{ value: 'low', label: '低' },
42+
],
43+
},
44+
done: { name: 'done', label: '已完成', type: 'boolean' },
45+
},
46+
};
47+
48+
const dataSource = { getObjectSchema: async () => TASK_SCHEMA } as any;
49+
50+
const criteriaField = { name: 'criteria_json', label: '条件', type: 'textarea', widget: 'filter-condition' } as any;
51+
52+
function renderCell(value: string) {
53+
const Cell = getCellRenderer(resolveCellRendererType(criteriaField));
54+
return render(
55+
<I18nProvider config={{ defaultLanguage: 'zh', detectBrowserLanguage: false }}>
56+
<SchemaRendererProvider dataSource={dataSource}>
57+
<Cell value={value} field={criteriaField} dependentValues={{ object_name: 'task' }} />
58+
</SchemaRendererProvider>
59+
</I18nProvider>,
60+
);
61+
}
62+
63+
describe('a `filter-condition` value reads as words (objectstack#30209)', () => {
64+
it('says one condition in the field and option labels', async () => {
65+
renderCell('{"priority":"urgent"}');
66+
expect(await screen.findByText('优先级 等于 紧急')).toBeInTheDocument();
67+
expect(screen.queryByText('{"priority":"urgent"}')).toBeNull();
68+
});
69+
70+
it('joins an OR group with its word and draws a boolean as a word', async () => {
71+
renderCell('{"$or":[{"priority":"low"},{"done":true}]}');
72+
expect(await screen.findByText('优先级 等于 低 或 已完成 等于 是')).toBeInTheDocument();
73+
});
74+
75+
it('keeps a criteria the builder cannot represent as the stored JSON (control)', async () => {
76+
const nested = '{"$or":[{"$and":[{"priority":"low"},{"done":true}]},{"priority":"urgent"}]}';
77+
const { container } = renderCell(nested);
78+
await screen.findByText(nested);
79+
expect(container.querySelector('[data-filter-condition="words"]')).toBeNull();
80+
});
81+
});

0 commit comments

Comments
 (0)