Skip to content

Commit f8a95e5

Browse files
authored
fix(fields): the criteria builder stops calling an empty criteria "All records" (#2962)
Renderer half of objectstack#3896 / objectstack-ai/objectstack#3929. `FilterConditionField` renders `sys_sharing_rule.criteria_json`. With no criteria it displayed "All records" — describing a bug as a feature, since the server evaluated that shape as a match-all grant. The server now refuses to save such a rule; this is the renderer catching up. The empty read-only state now says the rule shares nothing, in destructive styling, retranslated across all ten locales. A new `criteriaRequired` hint shows under the builder and the JSON editor while the criteria is empty, because the server's rejection only arrives as a toast after Save — the form does not map server `fields[]` onto per-field errors. `isMatchAllCriteria` is exported as a client-side mirror of the server predicate and only decides whether the hint shows; the server stays authoritative.
1 parent 8529444 commit f8a95e5

14 files changed

Lines changed: 195 additions & 14 deletions

File tree

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
---
2+
"@object-ui/fields": minor
3+
"@object-ui/i18n": minor
4+
---
5+
6+
fix(fields): the sharing-criteria builder stops calling an empty criteria "All records" (objectstack#3896)
7+
8+
`FilterConditionField` renders `sys_sharing_rule.criteria_json`. With no
9+
criteria it displayed **"All records"**, and `filterGroupToMongo` carried a
10+
matching `// empty = match all` comment. That was describing a bug as a
11+
feature: a sharing rule with no predicate was stored as `criteria_json: null`
12+
and evaluated as `find(object, { filter: {} })` under the system context —
13+
every record of the object, granted to the recipient. `SharingRuleSchema` had
14+
always forbidden the shape ("never seeded as a permissive match-all",
15+
ADR-0049); the REST and data-API entries just never checked.
16+
17+
objectstack#3896 closes those entries: the server now refuses to save a rule
18+
whose criteria would match everything, and one already stored shares nothing.
19+
This is the renderer catching up.
20+
21+
- **The empty read-only state now says the rule shares nothing**, in
22+
`destructive` styling — key renamed `fields.filterCondition.allRecords`
23+
`fields.filterCondition.noCriteria`, retranslated across all ten locales.
24+
Nothing else read the old key.
25+
- **A new `fields.filterCondition.criteriaRequired` hint** renders under the
26+
builder (and the JSON editor) while the criteria is empty. The server's
27+
rejection is precise but only arrives as a toast *after* Save; this says it
28+
while the admin is still looking at the empty builder.
29+
- **`isMatchAllCriteria` is exported** — a client-side mirror of the server
30+
predicate covering `{}`, `[]`, and the vacuous combinators (`{ $and: [] }`,
31+
`{ $or: [{}] }`), conservative in the same direction. The server stays
32+
authoritative; this only decides whether to show the hint.
33+
34+
Unparsable JSON keeps its own `invalidJson` message and does **not** also
35+
collect the empty-criteria hint.
36+
37+
Note for anyone wiring this end-to-end: the Criteria field is not marked
38+
`required` in the object metadata, deliberately — `sys_sharing_rule.criteria_json`
39+
is nullable in deployed tenants, so `required: true` would only produce a
40+
destructive `NOT NULL` migration that those nulls block. The invariant lives in
41+
the server's write guards; this change makes the UI stop contradicting it.

packages/fields/src/widgets/FilterConditionField.tsx

Lines changed: 61 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,11 @@ function filterGroupToMongo(group: BuilderGroup, typeOf: (f: string) => string |
144144
const frags = (group?.conditions ?? [])
145145
.map((c) => condToMongo(c, typeOf))
146146
.filter((x): x is Record<string, any> => !!x);
147-
if (frags.length === 0) return null; // empty = match all
147+
// No conditions → no predicate. NOT "match all": a sharing rule with an
148+
// empty criteria is refused on save, and one already stored shares nothing
149+
// (objectstack#3896 / ADR-0049). `null` becomes an empty stored value, which
150+
// is what the required-criteria messaging below keys off.
151+
if (frags.length === 0) return null;
148152
if (group.logic === 'or') return { $or: frags };
149153
if (frags.length === 1) return frags[0];
150154
const keys = frags.flatMap((f) => Object.keys(f));
@@ -233,6 +237,37 @@ function mongoToFilterGroup(mongo: any): BuilderGroup | null {
233237
return { id: 'root', logic: 'and', conditions };
234238
}
235239

240+
/**
241+
* Would this criteria select EVERY record of the object?
242+
*
243+
* Mirrors the server's `isMatchAllCriteria` (objectstack `plugin-sharing`,
244+
* #3896) closely enough to warn before the round-trip: blank, `{}`, `[]`, and
245+
* the vacuous combinators. Deliberately conservative in the same direction —
246+
* the cost of a false positive is one extra hint, the cost of a false negative
247+
* is a save that fails with a toast. The server stays authoritative.
248+
*
249+
* @internal exported for tests
250+
*/
251+
export function isMatchAllCriteria(parsed: any): boolean {
252+
if (parsed == null) return true;
253+
if (Array.isArray(parsed)) return parsed.every(isMatchAllCriteria);
254+
if (typeof parsed !== 'object') return true;
255+
const entries = Object.entries(parsed);
256+
if (entries.length === 0) return true;
257+
for (const [key, value] of entries) {
258+
if (key === '$and') {
259+
if (!Array.isArray(value) || value.every(isMatchAllCriteria)) continue;
260+
return false;
261+
}
262+
if (key === '$or') {
263+
if (!Array.isArray(value) || value.length === 0 || value.some(isMatchAllCriteria)) continue;
264+
return false;
265+
}
266+
return false;
267+
}
268+
return true;
269+
}
270+
236271
function stringifyValue(value: string | object | undefined | null): string {
237272
if (value == null || value === '') return '';
238273
if (typeof value === 'string') return value;
@@ -291,6 +326,13 @@ export function FilterConditionField({
291326
[parsed],
292327
);
293328

329+
// Only flag a criteria that PARSES to match-all. Unparsable JSON has its own
330+
// message (`invalidJson`) and must not collect a second, contradictory one.
331+
const isEmptyCriteria = React.useMemo(
332+
() => parsed.ok && isMatchAllCriteria(parsed.mongo),
333+
[parsed],
334+
);
335+
294336
// Raw JSON mode: forced when the stored value can't be represented in the
295337
// builder; otherwise opt-in via the toggle.
296338
const representable = parsed.ok && group !== null;
@@ -319,9 +361,13 @@ export function FilterConditionField({
319361

320362
if (readonly) {
321363
if (!rawValue.trim()) {
364+
// Used to read "All records" — which was both wrong and the most
365+
// dangerous thing this widget could say (objectstack#3896). A rule with
366+
// no criteria has never usefully shared everything; it now shares
367+
// nothing and is refused on save, so name that instead.
322368
return (
323-
<span className={cn('text-sm text-muted-foreground', className)}>
324-
{t('fields.filterCondition.allRecords')}
369+
<span className={cn('text-sm text-destructive', className)}>
370+
{t('fields.filterCondition.noCriteria')}
325371
</span>
326372
);
327373
}
@@ -353,6 +399,18 @@ export function FilterConditionField({
353399
onChange={handleBuilderChange as any}
354400
/>
355401
)}
402+
{/*
403+
The server refuses to save a criteria that would select every record
404+
(objectstack#3896), but that rejection only arrives as a toast after
405+
the admin hits Save. Say it here, while they are still looking at the
406+
empty builder — and never imply that leaving it empty means "share
407+
everything", which is what this widget used to do.
408+
*/}
409+
{isEmptyCriteria && (
410+
<span className="text-xs text-destructive">
411+
{t('fields.filterCondition.criteriaRequired')}
412+
</span>
413+
)}
356414
<button
357415
type="button"
358416
className="self-start text-xs text-muted-foreground underline-offset-2 hover:underline"
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
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+
* Empty-criteria guard for the sharing-rule criteria builder
11+
* (objectstack#3896).
12+
*
13+
* This widget used to tell an admin that an empty criteria meant "All
14+
* records" — the most dangerous thing it could say, because it was describing
15+
* a bug as a feature. A sharing rule with no predicate was stored as
16+
* `criteria_json: null` and then evaluated as `find(object, { filter: {} })`,
17+
* i.e. every record of the object granted to the recipient. `SharingRuleSchema`
18+
* had always forbidden that shape ("never seeded as a permissive match-all",
19+
* ADR-0049); the REST and data-API entries just never checked.
20+
*
21+
* The server now refuses to save such a rule and shares nothing for one
22+
* already stored. These tests pin the client-side mirror of that predicate, so
23+
* the widget warns while the admin is still looking at the empty builder
24+
* rather than after a rejected save.
25+
*/
26+
import { describe, it, expect } from 'vitest';
27+
import { isMatchAllCriteria } from '../FilterConditionField';
28+
29+
describe('isMatchAllCriteria — shapes that would share every record', () => {
30+
it('flags the empty and vacuous shapes', () => {
31+
for (const shape of [
32+
undefined,
33+
null,
34+
{}, // what a blank builder / blank textarea parses to
35+
[],
36+
true,
37+
42,
38+
{ $and: [] },
39+
{ $or: [] },
40+
{ $and: [{}] },
41+
{ $or: [{ stage: 'won' }, {}] }, // one unconstrained branch opens the OR
42+
]) {
43+
expect(isMatchAllCriteria(shape), `${JSON.stringify(shape) ?? String(shape)}`).toBe(true);
44+
}
45+
});
46+
47+
it('passes anything that actually narrows the result set', () => {
48+
for (const shape of [
49+
{ stage: 'won' },
50+
{ amount: { $gte: 100000 } },
51+
[{ stage: 'won' }],
52+
{ $and: [{ stage: 'won' }] },
53+
{ $or: [{ a: 1 }, { b: 2 }] },
54+
{ health: 'red', budget: { $gt: 1 } },
55+
]) {
56+
expect(isMatchAllCriteria(shape), JSON.stringify(shape)).toBe(false);
57+
}
58+
});
59+
60+
it('agrees with the server on the reported case: a rule saved with no criteria', () => {
61+
// The builder emits '' for an empty group, which the widget parses to {}.
62+
expect(isMatchAllCriteria({})).toBe(true);
63+
// …and one real condition is enough to clear the gate.
64+
expect(isMatchAllCriteria({ health: 'red' })).toBe(false);
65+
});
66+
});

packages/fields/src/widgets/useFieldTranslation.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,13 @@ const FIELD_DEFAULTS: Record<string, string> = {
5858
'fields.recipient.selectPosition': 'Select a position',
5959
'fields.recipient.selectUnitAndSubordinates': 'Select a business unit',
6060
'fields.filterCondition.selectObjectFirst': 'Select an object first.',
61-
'fields.filterCondition.allRecords': 'All records',
61+
// objectstack#3896 — this used to be 'All records'. An empty criteria never
62+
// meant "share everything"; it meant the predicate was missing, and the
63+
// sharing evaluator failed open on it. Such a rule is now refused on save
64+
// and shares nothing, so say that rather than advertise the old bug.
65+
'fields.filterCondition.noCriteria': 'No criteria — this rule shares nothing',
66+
'fields.filterCondition.criteriaRequired':
67+
'Add at least one condition. A rule with no criteria would share every record, so it cannot be saved.',
6268
'fields.filterCondition.invalidJson': 'Invalid JSON — the rule will match no records until fixed.',
6369
'fields.filterCondition.jsonOnly': 'This criteria can only be edited as JSON',
6470
'fields.filterCondition.editAsJson': 'Edit as JSON',

packages/i18n/src/locales/ar.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,8 @@ const ar = {
190190
},
191191
filterCondition: {
192192
selectObjectFirst: "اختر كائناً أولاً.",
193-
allRecords: "كل السجلات",
193+
noCriteria: "لا توجد معايير — لا تشارك هذه القاعدة أي شيء",
194+
criteriaRequired: "أضف شرطًا واحدًا على الأقل. القاعدة بدون معايير ستشارك كل السجلات، لذا لا يمكن حفظها.",
194195
invalidJson: "JSON غير صالح — لن تطابق القاعدة أي سجل حتى يتم إصلاحه.",
195196
jsonOnly: "لا يمكن تحرير هذا المعيار إلا بصيغة JSON",
196197
editAsJson: "التحرير بصيغة JSON",

packages/i18n/src/locales/de.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,8 @@ const de = {
190190
},
191191
filterCondition: {
192192
selectObjectFirst: "Wählen Sie zuerst ein Objekt.",
193-
allRecords: "Alle Datensätze",
193+
noCriteria: "Keine Kriterien – diese Regel teilt nichts",
194+
criteriaRequired: "Fügen Sie mindestens eine Bedingung hinzu. Eine Regel ohne Kriterien würde jeden Datensatz teilen und kann daher nicht gespeichert werden.",
194195
invalidJson: "Ungültiges JSON — die Regel trifft auf keinen Datensatz zu, bis dies behoben ist.",
195196
jsonOnly: "Dieses Kriterium kann nur als JSON bearbeitet werden",
196197
editAsJson: "Als JSON bearbeiten",

packages/i18n/src/locales/en.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,8 @@ const en = {
196196
},
197197
filterCondition: {
198198
selectObjectFirst: 'Select an object first.',
199-
allRecords: 'All records',
199+
noCriteria: 'No criteria — this rule shares nothing',
200+
criteriaRequired: 'Add at least one condition. A rule with no criteria would share every record, so it cannot be saved.',
200201
invalidJson: 'Invalid JSON — the rule will match no records until fixed.',
201202
jsonOnly: 'This criteria can only be edited as JSON',
202203
editAsJson: 'Edit as JSON',

packages/i18n/src/locales/es.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,8 @@ const es = {
190190
},
191191
filterCondition: {
192192
selectObjectFirst: "Selecciona primero un objeto.",
193-
allRecords: "Todos los registros",
193+
noCriteria: "Sin criterios: esta regla no comparte nada",
194+
criteriaRequired: "Añade al menos una condición. Una regla sin criterios compartiría todos los registros, por lo que no se puede guardar.",
194195
invalidJson: "JSON no válido: la regla no coincidirá con ningún registro hasta que se corrija.",
195196
jsonOnly: "Este criterio solo se puede editar como JSON",
196197
editAsJson: "Editar como JSON",

packages/i18n/src/locales/fr.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,8 @@ const fr = {
190190
},
191191
filterCondition: {
192192
selectObjectFirst: "Sélectionnez d'abord un objet.",
193-
allRecords: "Tous les enregistrements",
193+
noCriteria: "Aucun critère — cette règle ne partage rien",
194+
criteriaRequired: "Ajoutez au moins une condition. Une règle sans critères partagerait tous les enregistrements ; elle ne peut donc pas être enregistrée.",
194195
invalidJson: "JSON invalide — la règle ne correspondra à aucun enregistrement tant qu'elle n'est pas corrigée.",
195196
jsonOnly: "Ce critère ne peut être modifié qu'en JSON",
196197
editAsJson: "Modifier en JSON",

packages/i18n/src/locales/ja.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,8 @@ const ja = {
190190
},
191191
filterCondition: {
192192
selectObjectFirst: "先にオブジェクトを選択してください。",
193-
allRecords: "すべてのレコード",
193+
noCriteria: "条件が未設定です — このルールは何も共有しません",
194+
criteriaRequired: "条件を1つ以上追加してください。条件のないルールは全レコードを共有してしまうため、保存できません。",
194195
invalidJson: "JSON が不正です — 修正するまでこのルールはどのレコードにも一致しません。",
195196
jsonOnly: "この条件は JSON でのみ編集できます",
196197
editAsJson: "JSON で編集",

0 commit comments

Comments
 (0)