Skip to content

Commit b9aae51

Browse files
os-zhuangclaude
andauthored
fix(fields): emit the spec's $notContains, and keep secret out of inline edit (#2901) (#2940)
Two defects the #2901 audit turned up that need no design decision. **`$ncontains` was never a real operator.** The sharing-rule criteria builder emitted `{ $ncontains: v }` for "does not contain" — a token that appears zero times in `@objectstack/spec`, and that objectui's own `convertFiltersToAST` throws on while naming the correct spelling (`$notContains`) in its error message. Every such rule validated in the UI and was rejected downstream. `kvToCondition` keeps reading the old spelling so criteria saved before this still load instead of failing as "can't be represented". **`secret` was inline-editable as plaintext.** It sat in neither `EDIT_WIDGETS` nor `INLINE_EXCLUDED_FIELD_TYPES`, so the grid fell back to a plain text input — two lines below `password`, which is correctly excluded. Both are masked on read, so that input renders the mask as the value and writes it back; `secret` additionally round-trips through an encrypted store (ADR-0100), meaning the cell holds an opaque ref, not the value. Adding it tripped the exclusion-set staleness guard, which is itself mis-specified: it treats `Object.keys(fieldWidgetMap)` as the set of real field types, but spec spellings that reach a widget through the alias table (`secret` → `field:password`, `autonumber` → `field:auto_number`, …) are not keys — as index.tsx's own `resolveFormWidgetType` docs note. Made the check alias-aware so it still catches typos without rejecting real types. New `FilterConditionField.operators` test pins every builder token to a `FieldOperatorsSchema` spelling and pins the round trip; verified it fails against the old `$ncontains`. Refs #2901 Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7ee6c90 commit b9aae51

4 files changed

Lines changed: 161 additions & 6 deletions

File tree

packages/fields/src/FieldEditWidget.test.ts

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,27 @@ import {
1111
FORM_FIELD_TYPES,
1212
INLINE_EXCLUDED_FIELD_TYPES,
1313
hasFieldEditWidget,
14+
mapFieldTypeToFormType,
1415
} from './index';
1516

17+
/**
18+
* A field type the form can render — either a direct widget-map key, or a spec
19+
* spelling that resolves onto one through the alias table (`secret` →
20+
* `field:password`, `autonumber` → `field:auto_number`, …).
21+
*
22+
* `FORM_FIELD_TYPES` alone is `Object.keys(fieldWidgetMap)`, which does NOT
23+
* include the alias-only spellings — see the note at index.tsx's
24+
* `resolveFormWidgetType`. Using it as the definition of "a real type" made the
25+
* staleness check below reject `secret`, a genuine spec field type the form
26+
* renders, purely because it arrives via an alias.
27+
*/
28+
function isRenderableFormType(t: string): boolean {
29+
// `text` is the alias table's fallback, so a non-`field:text` result means an
30+
// explicit entry exists. `text` itself is a direct widget key, so it is
31+
// already covered by the first clause and no real type is missed here.
32+
return FORM_FIELD_TYPES.includes(t) || mapFieldTypeToFormType(t) !== 'field:text';
33+
}
34+
1635
/**
1736
* Drift-guard: the inline cell editor reuses the form's field widgets, but the
1837
* two lists were hand-maintained separately and drifted — `lookup` (and other
@@ -34,11 +53,21 @@ describe('inline editor ↔ form widget parity', () => {
3453
});
3554

3655
it('the exclusion set lists only real form types (no stale entries)', () => {
37-
const known = new Set(FORM_FIELD_TYPES);
38-
const stale = [...INLINE_EXCLUDED_FIELD_TYPES].filter((t) => !known.has(t));
56+
const stale = [...INLINE_EXCLUDED_FIELD_TYPES].filter((t) => !isRenderableFormType(t));
3957
expect(stale).toEqual([]);
4058
});
4159

60+
it('credential types are never inline-editable', () => {
61+
// The grid's fallback for a type with no inline editor is a PLAIN TEXT input.
62+
// Both of these are masked on read, so that input would show the mask as the
63+
// value and write it straight back; `secret` also round-trips through an
64+
// encrypted store (ADR-0100), so the cell holds an opaque ref, not the value.
65+
for (const t of ['password', 'secret']) {
66+
expect(hasFieldEditWidget(t), `${t} must not have an inline editor`).toBe(false);
67+
expect(INLINE_EXCLUDED_FIELD_TYPES.has(t), `${t} must be explicitly excluded`).toBe(true);
68+
}
69+
});
70+
4271
it('relational fields use the standard picker inline (regression: lookup was a text box)', () => {
4372
for (const t of ['lookup', 'master_detail', 'user', 'owner']) {
4473
expect(hasFieldEditWidget(t)).toBe(true);

packages/fields/src/FieldEditWidget.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,13 @@ export const INLINE_EXCLUDED_FIELD_TYPES = new Set<string>([
102102
// Binary / attachment — edited from the record form, shown read-only in the grid.
103103
'file', 'image', 'avatar', 'signature',
104104
// Heavy / full editors — better in the record form than a cell.
105-
'markdown', 'html', 'richtext', 'password',
105+
'markdown', 'html', 'richtext',
106+
// Credentials — never inline-editable. Both are masked on read, so the cell has
107+
// no real value to seed an editor with, and the grid's fallback is a PLAIN TEXT
108+
// input: it would render the mask as if it were the value and write it straight
109+
// back. `secret` additionally round-trips through an encrypted store (ADR-0100,
110+
// data/field.zod.ts) — the cell holds an opaque ref, not the secret.
111+
'password', 'secret',
106112
// Containers / non-authorable — a sub-form / sub-grid / embedding vector
107113
// doesn't belong in a single cell.
108114
'object', 'grid', 'vector',

packages/fields/src/widgets/FilterConditionField.tsx

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,13 @@ function toArray(value: any): any[] {
102102
return value == null ? [] : [value];
103103
}
104104

105-
function condToMongo(c: BuilderCondition, typeOf: (f: string) => string | undefined): Record<string, any> | null {
105+
/**
106+
* Builder condition → `$`-operator criteria. Exported for tests: this is the
107+
* chokepoint where a builder token becomes a spec `FieldOperatorsSchema` key,
108+
* and a wrong spelling here is rejected downstream by `convertFiltersToAST`
109+
* rather than at authoring time. @internal
110+
*/
111+
export function condToMongo(c: BuilderCondition, typeOf: (f: string) => string | undefined): Record<string, any> | null {
106112
const { field, operator, value } = c || ({} as BuilderCondition);
107113
if (!field) return null;
108114
const t = typeOf(field);
@@ -111,7 +117,11 @@ function condToMongo(c: BuilderCondition, typeOf: (f: string) => string | undefi
111117
case 'equals': return { [field]: cv };
112118
case 'notEquals': return { [field]: { $ne: cv } };
113119
case 'contains': return { [field]: { $contains: value } };
114-
case 'notContains': return { [field]: { $ncontains: value } };
120+
// `$notContains` is the spec spelling (FieldOperatorsSchema, data/filter.zod.ts).
121+
// This emitted `$ncontains` — a token that appears nowhere in @objectstack/spec and
122+
// that convertFiltersToAST throws on, so every "does not contain" rule authored here
123+
// was rejected downstream. See kvToCondition for reading the old spelling back.
124+
case 'notContains': return { [field]: { $notContains: value } };
115125
case 'isEmpty': return { [field]: { $in: [null, ''] } };
116126
case 'isNotEmpty': return { [field]: { $nin: [null, ''] } };
117127
case 'greaterThan':
@@ -146,7 +156,13 @@ function arraysEqual(a: any, b: any[]): boolean {
146156
return Array.isArray(a) && a.length === b.length && a.every((v, i) => v === b[i]);
147157
}
148158

149-
function kvToCondition(field: string, v: any, idx: number): BuilderCondition | null {
159+
/**
160+
* Criteria → builder condition (the reverse of {@link condToMongo}). Returning
161+
* `null` makes the builder refuse to load the rule ("criteria can't be
162+
* represented"), so this must keep accepting spellings previously written.
163+
* @internal
164+
*/
165+
export function kvToCondition(field: string, v: any, idx: number): BuilderCondition | null {
150166
const id = `c_${idx}_${field}`;
151167
if (v === null || typeof v !== 'object' || Array.isArray(v)) {
152168
return { id, field, operator: 'equals', value: v };
@@ -158,6 +174,10 @@ function kvToCondition(field: string, v: any, idx: number): BuilderCondition | n
158174
switch (op) {
159175
case '$ne': return { id, field, operator: 'notEquals', value: val };
160176
case '$contains': return { id, field, operator: 'contains', value: val };
177+
// `$ncontains` is the pre-fix spelling this widget used to emit. Criteria saved
178+
// before the fix still carry it, so keep reading it — dropping it here would make
179+
// those rules fail to load ("criteria can't be represented") instead of migrating.
180+
case '$notContains':
161181
case '$ncontains': return { id, field, operator: 'notContains', value: val };
162182
case '$gt': return { id, field, operator: 'greaterThan', value: val };
163183
case '$lt': return { id, field, operator: 'lessThan', value: val };
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
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+
* Operator spelling guard for the sharing-rule criteria builder (#2901).
11+
*
12+
* `condToMongo` emitted `$ncontains` — a token that appears nowhere in
13+
* `@objectstack/spec` and that objectui's own `convertFiltersToAST` throws on,
14+
* naming the correct spelling (`$notContains`) in its error message. So every
15+
* "does not contain" rule authored here validated in the UI and was then
16+
* rejected downstream. Nothing caught it because the builder's operator list is
17+
* hand-written and never compared against the spec vocabulary.
18+
*
19+
* These tests pin the emitted spellings against `FieldOperatorsSchema`'s keys
20+
* and pin the round-trip, including the pre-fix spelling that stored criteria
21+
* still carry.
22+
*/
23+
import { describe, it, expect } from 'vitest';
24+
import { condToMongo, kvToCondition } from '../FilterConditionField';
25+
26+
/** The `$`-prefixed operator keys of the spec's `FieldOperatorsSchema`. */
27+
const SPEC_OPERATORS = new Set([
28+
'$eq', '$ne', '$gt', '$gte', '$lt', '$lte', '$in', '$nin',
29+
'$between', '$contains', '$notContains', '$startsWith', '$endsWith',
30+
'$null', '$exists',
31+
]);
32+
33+
const noTypes = () => undefined;
34+
35+
/** Pull the operator keys out of a `{ field: { $op: v } }` fragment. */
36+
function operatorsOf(frag: Record<string, any> | null): string[] {
37+
if (!frag) return [];
38+
return Object.values(frag).flatMap((v) =>
39+
v !== null && typeof v === 'object' && !Array.isArray(v) ? Object.keys(v) : [],
40+
);
41+
}
42+
43+
describe('condToMongo emits only spec operator spellings', () => {
44+
const builderOperators = [
45+
'equals', 'notEquals', 'contains', 'notContains', 'isEmpty', 'isNotEmpty',
46+
'greaterThan', 'lessThan', 'greaterOrEqual', 'lessOrEqual', 'in', 'notIn',
47+
'before', 'after',
48+
];
49+
50+
it.each(builderOperators)('%s emits a spec-defined operator', (operator) => {
51+
const frag = condToMongo(
52+
{ id: 'c1', field: 'name', operator, value: operator === 'in' || operator === 'notIn' ? ['a'] : 'a' } as any,
53+
noTypes,
54+
);
55+
const unknown = operatorsOf(frag).filter((op) => !SPEC_OPERATORS.has(op));
56+
expect(unknown, `${operator} emits an operator @objectstack/spec does not define`).toEqual([]);
57+
});
58+
59+
it('notContains emits $notContains, not the pre-fix $ncontains', () => {
60+
const frag = condToMongo({ id: 'c1', field: 'name', operator: 'notContains', value: 'x' } as any, noTypes);
61+
expect(frag).toEqual({ name: { $notContains: 'x' } });
62+
});
63+
64+
it('between emits a range the spec defines', () => {
65+
const frag = condToMongo({ id: 'c1', field: 'age', operator: 'between', value: [1, 5] } as any, noTypes);
66+
expect(operatorsOf(frag).every((op) => SPEC_OPERATORS.has(op))).toBe(true);
67+
});
68+
});
69+
70+
describe('kvToCondition round-trips what condToMongo writes', () => {
71+
const cases: Array<[string, unknown]> = [
72+
['notEquals', 'a'],
73+
['contains', 'a'],
74+
['notContains', 'a'],
75+
['greaterThan', 1],
76+
['lessThan', 1],
77+
['greaterOrEqual', 1],
78+
['lessOrEqual', 1],
79+
];
80+
81+
it.each(cases)('%s survives the round trip', (operator, value) => {
82+
const frag = condToMongo({ id: 'c1', field: 'f', operator, value } as any, noTypes)!;
83+
const [[field, v]] = Object.entries(frag);
84+
expect(kvToCondition(field, v, 0)).toMatchObject({ field: 'f', operator });
85+
});
86+
87+
it('still reads the pre-fix $ncontains so stored criteria keep loading', () => {
88+
// Dropping this would make rules saved before the fix fail to load entirely
89+
// ("criteria can't be represented") rather than migrate.
90+
expect(kvToCondition('name', { $ncontains: 'x' }, 0)).toMatchObject({
91+
field: 'name',
92+
operator: 'notContains',
93+
value: 'x',
94+
});
95+
});
96+
97+
it('rejects an operator it cannot represent rather than guessing', () => {
98+
expect(kvToCondition('name', { $nope: 'x' }, 0)).toBeNull();
99+
});
100+
});

0 commit comments

Comments
 (0)