Skip to content

Commit 5b74901

Browse files
authored
Merge pull request #811 from objectstack-ai/copilot/add-conditional-formatting-support
2 parents 6bd658c + 1208c19 commit 5b74901

File tree

8 files changed

+218
-65
lines changed

8 files changed

+218
-65
lines changed

ROADMAP.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -737,7 +737,7 @@ The `FlowDesigner` is a canvas-based flow editor that bridges the gap between th
737737

738738
**P1 — Structural Alignment:**
739739
- [x] `quickFilters` structure reconciliation: Auto-normalizes spec `{ field, operator, value }` format into ObjectUI `{ id, label, filters[] }` format. Both formats supported simultaneously. Dual-format type union (`QuickFilterItem = ObjectUIQuickFilterItem | SpecQuickFilterItem`) exported from `@object-ui/types`. Standalone `normalizeQuickFilter()` / `normalizeQuickFilters()` adapter functions in `@object-ui/core`. Bridge (`list-view.ts`) normalizes at spec→SchemaNode transform time. Spec shorthand operators (`eq`, `ne`, `gt`, `gte`, `lt`, `lte`) mapped to ObjectStack AST operators. Mixed-format arrays handled transparently.
740-
- [x] `conditionalFormatting` expression reconciliation: Supports spec `{ condition, style }` format alongside ObjectUI field/operator/value rules. `condition` is treated as alias for `expression`, `style` object merged into CSS properties.
740+
- [x] `conditionalFormatting` expression reconciliation: Supports spec `{ condition, style }` format alongside ObjectUI field/operator/value rules. Dual-format type union (`ConditionalFormattingRule = ObjectUIConditionalFormattingRule | SpecConditionalFormattingRule`) exported from `@object-ui/types`. Zod validator updated with `z.union()` for both formats. `evaluatePlainCondition()` convenience function in `@object-ui/core` for safe plain/template expression evaluation with record context. Plain expressions (e.g., `status == 'overdue'`) evaluated directly without `${}` wrapper; record fields spread into evaluator context for direct field references alongside `data.` namespace. Mixed-format arrays handled transparently.
741741
- [x] `exportOptions` schema reconciliation: Accepts both spec `string[]` format (e.g., `['csv', 'xlsx']`) and ObjectUI object format `{ formats, maxRecords, includeHeaders, fileNamePrefix }`.
742742
- [x] Column `pinned`: `pinned` property added to ListViewSchema column type. Bridge passes through to ObjectGrid which supports `frozenColumns`. ObjectGrid reorders columns (left-pinned first, right-pinned last with sticky CSS). Zod schema updated with `pinned` field. `useColumnSummary` hook created.
743743
- [x] Column `summary`: `summary` property added to ListViewSchema column type. Bridge passes through for aggregation rendering. ObjectGrid renders summary footer with count/sum/avg/min/max aggregations via `useColumnSummary` hook. Zod schema updated with `summary` field.

packages/core/src/evaluator/ExpressionEvaluator.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,3 +291,25 @@ export function evaluateCondition(
291291
const evaluator = new ExpressionEvaluator(context, globalCache, globalFormulas);
292292
return evaluator.evaluateCondition(condition);
293293
}
294+
295+
/**
296+
* Convenience function to evaluate a plain condition string against a data record.
297+
* Supports both template expressions (e.g., '${data.amount > 1000}') and
298+
* plain expressions (e.g., "status == 'overdue'").
299+
* Record fields are available both directly (status) and namespaced (data.status).
300+
*/
301+
export function evaluatePlainCondition(
302+
condition: string,
303+
record: Record<string, any>
304+
): boolean {
305+
const evaluator = new ExpressionEvaluator({ ...record, data: record }, globalCache, globalFormulas);
306+
try {
307+
const isTemplate = /\$\{/.test(condition);
308+
const result = isTemplate
309+
? evaluator.evaluate(condition, { throwOnError: true })
310+
: evaluator.evaluateExpression(condition);
311+
return result === true;
312+
} catch {
313+
return false;
314+
}
315+
}

packages/core/src/evaluator/__tests__/ExpressionEvaluator.test.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
*/
88

99
import { describe, it, expect } from 'vitest';
10-
import { ExpressionEvaluator, evaluateExpression, evaluateCondition } from '../ExpressionEvaluator';
10+
import { ExpressionEvaluator, evaluateExpression, evaluateCondition, evaluatePlainCondition } from '../ExpressionEvaluator';
1111
import { ExpressionContext } from '../ExpressionContext';
1212

1313
describe('ExpressionContext', () => {
@@ -99,3 +99,33 @@ describe('ExpressionEvaluator', () => {
9999
});
100100
});
101101
});
102+
103+
describe('evaluatePlainCondition', () => {
104+
it('should evaluate a plain condition with direct field references', () => {
105+
expect(evaluatePlainCondition("status == 'overdue'", { status: 'overdue' })).toBe(true);
106+
expect(evaluatePlainCondition("status == 'overdue'", { status: 'active' })).toBe(false);
107+
});
108+
109+
it('should evaluate numeric comparisons', () => {
110+
expect(evaluatePlainCondition('amount > 1000', { amount: 2500 })).toBe(true);
111+
expect(evaluatePlainCondition('amount > 1000', { amount: 500 })).toBe(false);
112+
});
113+
114+
it('should evaluate compound conditions', () => {
115+
expect(evaluatePlainCondition("amount > 1000 && status === 'urgent'", { amount: 2000, status: 'urgent' })).toBe(true);
116+
expect(evaluatePlainCondition("amount > 1000 && status === 'urgent'", { amount: 2000, status: 'normal' })).toBe(false);
117+
});
118+
119+
it('should support data.field references in template expressions', () => {
120+
expect(evaluatePlainCondition('${data.amount > 1000}', { amount: 2000 })).toBe(true);
121+
expect(evaluatePlainCondition('${data.amount > 1000}', { amount: 500 })).toBe(false);
122+
});
123+
124+
it('should return false for invalid expressions', () => {
125+
expect(evaluatePlainCondition('!!!invalidSyntax', { status: 'ok' })).toBe(false);
126+
});
127+
128+
it('should return false for non-boolean results', () => {
129+
expect(evaluatePlainCondition('status', { status: 'active' })).toBe(false);
130+
});
131+
});

packages/plugin-list/src/ListView.tsx

Lines changed: 16 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import { SchemaRenderer, useNavigationOverlay } from '@object-ui/react';
1919
import { useDensityMode } from '@object-ui/react';
2020
import type { ListViewSchema } from '@object-ui/types';
2121
import { usePullToRefresh } from '@object-ui/mobile';
22-
import { ExpressionEvaluator, normalizeQuickFilters } from '@object-ui/core';
22+
import { evaluatePlainCondition, normalizeQuickFilters } from '@object-ui/core';
2323
import { useObjectTranslation } from '@object-ui/i18n';
2424

2525
export interface ListViewProps {
@@ -139,20 +139,19 @@ export function evaluateConditionalFormatting(
139139
for (const rule of rules) {
140140
let match = false;
141141

142-
// Normalize: spec uses 'condition' as alias for 'expression'
143-
const expression = rule.expression || rule.condition;
142+
// Determine expression: spec uses 'condition', ObjectUI uses 'expression'
143+
const expression =
144+
('condition' in rule ? rule.condition : undefined)
145+
|| ('expression' in rule ? rule.expression : undefined)
146+
|| undefined;
144147

145-
// Expression-based evaluation (L2 feature) using safe ExpressionEvaluator
148+
// Expression-based evaluation using safe ExpressionEvaluator
149+
// Supports both template expressions (${data.field > value}) and
150+
// plain Spec expressions (field == 'value').
146151
if (expression) {
147-
try {
148-
const evaluator = new ExpressionEvaluator({ data: record });
149-
const result = evaluator.evaluate(expression, { throwOnError: true });
150-
match = result === true;
151-
} catch {
152-
match = false;
153-
}
154-
} else if (rule.field && rule.operator) {
155-
// Standard field/operator/value evaluation
152+
match = evaluatePlainCondition(expression, record as Record<string, any>);
153+
} else if ('field' in rule && 'operator' in rule && rule.field && rule.operator) {
154+
// Standard field/operator/value evaluation (ObjectUI format)
156155
const fieldValue = record[rule.field];
157156
switch (rule.operator) {
158157
case 'equals':
@@ -179,10 +178,10 @@ export function evaluateConditionalFormatting(
179178
if (match) {
180179
// Build style: spec 'style' object is base, individual properties override
181180
const style: React.CSSProperties = {};
182-
if (rule.style) Object.assign(style, rule.style);
183-
if (rule.backgroundColor) style.backgroundColor = rule.backgroundColor;
184-
if (rule.textColor) style.color = rule.textColor;
185-
if (rule.borderColor) style.borderColor = rule.borderColor;
181+
if ('style' in rule && rule.style) Object.assign(style, rule.style);
182+
if ('backgroundColor' in rule && rule.backgroundColor) style.backgroundColor = rule.backgroundColor;
183+
if ('textColor' in rule && rule.textColor) style.color = rule.textColor;
184+
if ('borderColor' in rule && rule.borderColor) style.borderColor = rule.borderColor;
186185
return style;
187186
}
188187
}

packages/plugin-list/src/__tests__/ConditionalFormatting.test.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,63 @@ describe('evaluateConditionalFormatting', () => {
141141
});
142142
});
143143

144+
// =========================================================================
145+
// Spec expression format (plain condition + style object)
146+
// =========================================================================
147+
describe('spec expression format', () => {
148+
it('evaluates a plain condition string via "condition" field', () => {
149+
const result = evaluateConditionalFormatting(
150+
{ status: 'overdue' },
151+
[{ condition: "status == 'overdue'", style: { backgroundColor: 'red' } }],
152+
);
153+
expect(result).toEqual({ backgroundColor: 'red' });
154+
});
155+
156+
it('evaluates a numeric comparison in plain condition', () => {
157+
const result = evaluateConditionalFormatting(
158+
{ amount: 2500 },
159+
[{ condition: 'amount > 1000', style: { backgroundColor: '#fee2e2', color: '#991b1b' } }],
160+
);
161+
expect(result).toEqual({ backgroundColor: '#fee2e2', color: '#991b1b' });
162+
});
163+
164+
it('returns empty when plain condition does not match', () => {
165+
const result = evaluateConditionalFormatting(
166+
{ status: 'active' },
167+
[{ condition: "status == 'overdue'", style: { backgroundColor: 'red' } }],
168+
);
169+
expect(result).toEqual({});
170+
});
171+
172+
it('supports compound conditions with && operator', () => {
173+
const result = evaluateConditionalFormatting(
174+
{ amount: 2000, status: 'urgent' },
175+
[{ condition: "amount > 1000 && status === 'urgent'", style: { backgroundColor: '#fee' } }],
176+
);
177+
expect(result).toEqual({ backgroundColor: '#fee' });
178+
});
179+
180+
it('merges style with individual color properties', () => {
181+
const result = evaluateConditionalFormatting(
182+
{ status: 'overdue' },
183+
[{
184+
condition: "status == 'overdue'",
185+
style: { fontWeight: 'bold' },
186+
backgroundColor: '#f00',
187+
}],
188+
);
189+
expect(result).toEqual({ fontWeight: 'bold', backgroundColor: '#f00' });
190+
});
191+
192+
it('does not throw on invalid plain condition', () => {
193+
const result = evaluateConditionalFormatting(
194+
{ status: 'ok' },
195+
[{ condition: '!!!invalidSyntax', style: { backgroundColor: 'red' } }],
196+
);
197+
expect(result).toEqual({});
198+
});
199+
});
200+
144201
// =========================================================================
145202
// Mixed rules (expression + standard) – first match wins
146203
// =========================================================================
@@ -188,6 +245,29 @@ describe('evaluateConditionalFormatting', () => {
188245
);
189246
expect(result).toEqual({ backgroundColor: '#operator_match' });
190247
});
248+
249+
it('handles mixed spec condition + ObjectUI field rules', () => {
250+
const result = evaluateConditionalFormatting(
251+
{ status: 'overdue', priority: 'low' },
252+
[
253+
{ condition: "status == 'overdue'", style: { backgroundColor: 'red' } },
254+
{ field: 'priority', operator: 'equals', value: 'low', backgroundColor: '#ccc' },
255+
],
256+
);
257+
// First matching rule wins
258+
expect(result).toEqual({ backgroundColor: 'red' });
259+
});
260+
261+
it('falls through non-matching spec condition to ObjectUI field rule', () => {
262+
const result = evaluateConditionalFormatting(
263+
{ status: 'active', priority: 'low' },
264+
[
265+
{ condition: "status == 'overdue'", style: { backgroundColor: 'red' } },
266+
{ field: 'priority', operator: 'equals', value: 'low', backgroundColor: '#ccc' },
267+
],
268+
);
269+
expect(result).toEqual({ backgroundColor: '#ccc' });
270+
});
191271
});
192272

193273
// =========================================================================

packages/types/src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,10 @@ export type {
304304
ObjectUIQuickFilterItem,
305305
SpecQuickFilterItem,
306306
QuickFilterItem,
307+
// ConditionalFormatting dual-format types
308+
ObjectUIConditionalFormattingRule,
309+
SpecConditionalFormattingRule,
310+
ConditionalFormattingRule,
307311
// Component schemas
308312
ObjectMapSchema,
309313
ObjectGanttSchema,

packages/types/src/objectql.ts

Lines changed: 49 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,49 @@ export interface SpecQuickFilterItem {
207207
*/
208208
export type QuickFilterItem = ObjectUIQuickFilterItem | SpecQuickFilterItem;
209209

210+
// ============================================================================
211+
// ConditionalFormatting Types — Dual-format support
212+
// ============================================================================
213+
214+
/**
215+
* ObjectUI-native ConditionalFormatting rule.
216+
* Uses field/operator/value for declarative comparisons.
217+
*/
218+
export interface ObjectUIConditionalFormattingRule {
219+
/** Field name to evaluate */
220+
field: string;
221+
/** Comparison operator */
222+
operator: 'equals' | 'not_equals' | 'contains' | 'greater_than' | 'less_than' | 'in';
223+
/** Value to compare against */
224+
value: unknown;
225+
/** CSS-compatible background color */
226+
backgroundColor?: string;
227+
/** CSS-compatible text color */
228+
textColor?: string;
229+
/** CSS-compatible border color */
230+
borderColor?: string;
231+
/** Template expression override (e.g., '${data.amount > 1000}') */
232+
expression?: string;
233+
}
234+
235+
/**
236+
* Spec-format ConditionalFormatting rule (from @objectstack/spec).
237+
* Uses a plain expression string with a style map.
238+
* Automatically evaluated at runtime via ExpressionEvaluator.
239+
*/
240+
export interface SpecConditionalFormattingRule {
241+
/** Plain condition expression (e.g., "status == 'overdue'") or template expression (e.g., "${data.amount > 1000}") */
242+
condition: string;
243+
/** Style map to apply when condition matches (e.g., { backgroundColor: '#fee2e2', color: '#991b1b' }) */
244+
style: Record<string, string>;
245+
}
246+
247+
/**
248+
* Union type for ConditionalFormatting rules — accepts both ObjectUI and Spec formats.
249+
* Rules are evaluated in order; first matching rule wins.
250+
*/
251+
export type ConditionalFormattingRule = ObjectUIConditionalFormattingRule | SpecConditionalFormattingRule;
252+
210253
/**
211254
* ObjectGrid Schema
212255
* A specialized grid component that automatically fetches and displays data from ObjectQL objects.
@@ -525,14 +568,9 @@ export interface ObjectGridSchema extends BaseSchema {
525568
/**
526569
* Conditional formatting rules for row/cell styling.
527570
* Aligned with @objectstack/spec ListViewSchema.conditionalFormatting.
528-
* Uses expression-based conditions with style maps.
571+
* Supports both ObjectUI field/operator/value rules and Spec expression-based { condition, style } rules.
529572
*/
530-
conditionalFormatting?: Array<{
531-
/** Expression-based condition (e.g., '${data.amount > 1000}') */
532-
condition: string;
533-
/** Style map (e.g., { backgroundColor: '#fee2e2', textColor: '#991b1b' }) */
534-
style: Record<string, string>;
535-
}>;
573+
conditionalFormatting?: ConditionalFormattingRule[];
536574

537575
/**
538576
* Enable virtual scrolling for large datasets.
@@ -1193,16 +1231,9 @@ export interface NamedListView {
11931231
formView?: string;
11941232
};
11951233

1196-
/** Conditional formatting rules */
1197-
conditionalFormatting?: Array<{
1198-
field: string;
1199-
operator: 'equals' | 'not_equals' | 'contains' | 'greater_than' | 'less_than' | 'in';
1200-
value: unknown;
1201-
backgroundColor?: string;
1202-
textColor?: string;
1203-
borderColor?: string;
1204-
expression?: string;
1205-
}>;
1234+
/** Conditional formatting rules.
1235+
* Supports both ObjectUI field/operator/value rules and Spec expression-based { condition, style } rules. */
1236+
conditionalFormatting?: ConditionalFormattingRule[];
12061237

12071238
/** Quick filter buttons for predefined filter presets.
12081239
* Supports both ObjectUI format and Spec format (auto-converted at runtime). */
@@ -1491,26 +1522,7 @@ export interface ListViewSchema extends BaseSchema {
14911522
* Rules are evaluated in order; first matching rule wins.
14921523
* Supports both ObjectUI field/operator/value rules and spec expression-based { condition, style } rules.
14931524
*/
1494-
conditionalFormatting?: Array<{
1495-
/** Field to evaluate (ObjectUI format) */
1496-
field?: string;
1497-
/** Comparison operator (ObjectUI format) */
1498-
operator?: 'equals' | 'not_equals' | 'contains' | 'greater_than' | 'less_than' | 'in';
1499-
/** Value to compare against (ObjectUI format) */
1500-
value?: unknown;
1501-
/** CSS-compatible background color (e.g., '#fee2e2', 'rgb(254,226,226)') */
1502-
backgroundColor?: string;
1503-
/** CSS-compatible text color */
1504-
textColor?: string;
1505-
/** CSS-compatible border color */
1506-
borderColor?: string;
1507-
/** Expression-based condition (e.g., '${data.amount > 1000 && data.status === "urgent"}'). Overrides field/operator/value when provided. */
1508-
expression?: string;
1509-
/** Spec expression-based condition string (alias for expression) */
1510-
condition?: string;
1511-
/** Spec style object (e.g., { backgroundColor: '#fee2e2', color: '#991b1b' }) */
1512-
style?: Record<string, string>;
1513-
}>;
1525+
conditionalFormatting?: ConditionalFormattingRule[];
15141526

15151527
/**
15161528
* Enable inline editing for list view fields.

packages/types/src/zod/objectql.zod.ts

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -320,15 +320,21 @@ export const ListViewSchema = BaseSchema.extend({
320320
mode: z.string().optional(),
321321
formView: z.string().optional(),
322322
}).optional().describe('Add record configuration'),
323-
conditionalFormatting: z.array(z.object({
324-
field: z.string(),
325-
operator: z.enum(['equals', 'not_equals', 'contains', 'greater_than', 'less_than', 'in']),
326-
value: z.any(),
327-
backgroundColor: z.string().optional(),
328-
textColor: z.string().optional(),
329-
borderColor: z.string().optional(),
330-
expression: z.string().optional(),
331-
})).optional().describe('Conditional formatting rules'),
323+
conditionalFormatting: z.array(z.union([
324+
z.object({
325+
field: z.string(),
326+
operator: z.enum(['equals', 'not_equals', 'contains', 'greater_than', 'less_than', 'in']),
327+
value: z.any(),
328+
backgroundColor: z.string().optional(),
329+
textColor: z.string().optional(),
330+
borderColor: z.string().optional(),
331+
expression: z.string().optional(),
332+
}),
333+
z.object({
334+
condition: z.string(),
335+
style: z.record(z.string(), z.string()),
336+
}),
337+
])).optional().describe('Conditional formatting rules'),
332338
quickFilters: z.array(z.object({
333339
id: z.string(),
334340
label: z.string(),

0 commit comments

Comments
 (0)