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
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,12 @@
import * as React from 'react';
import { describe, it, expect, afterEach, vi } from 'vitest';
import { render, screen, cleanup, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { evalRowPredicate } from '@object-ui/core';
import {
ConditionalFormattingEditor,
normalizeRule,
ROW_PREDICATE_ROOTS,
type ConditionalFormattingRuleDraft,
} from './ConditionalFormattingEditor';
import { __setCelFormulaLoader } from './celAuthoring';
Expand Down Expand Up @@ -128,3 +131,81 @@ describe('ConditionalFormattingEditor', () => {
expect(state()[0]).toEqual({ condition: "record.status == 'x'", style: { backgroundColor: '#f00' } });
});
});

describe('ConditionalFormattingEditor · CEL authoring scope (#2571 follow-up)', () => {
it('lints a BARE field condition clean — row predicates bind fields bare at runtime', async () => {
render(<Harness initial={[{ condition: "status == 'overdue'", style: {} }]} />);
// The real engine must accept the bare form (evalRowPredicate spreads the
// row); flipping this editor to scope="record" would break this test.
expect(await screen.findByText('perm.cel.valid', {}, { timeout: 3000 })).toBeTruthy();
const ta = document.getElementById('cf-condition-0') as HTMLTextAreaElement;
expect(ta.getAttribute('aria-invalid')).not.toBe('true');
});

it('still flags an unknown record.<field> with did-you-mean', async () => {
render(<Harness initial={[{ condition: "record.statu == 'x'", style: {} }]} />);
expect(await screen.findByText(/did you mean/i, {}, { timeout: 3000 })).toBeTruthy();
});

it('advertises runtime-bound roots and withholds unbound engine roots', async () => {
__setCelFormulaLoader(() =>
Promise.resolve({
validateExpression: () => ({ ok: true, errors: [], warnings: [] }),
// The engine's default advertisement — the editor's roots override
// must win over it (introspectCelScope: hint.roots ?? engine roots).
introspectScope: () => ({
fields: ['status', 'amount'],
roots: ['record', 'previous', 'input', 'os', 'current_user', 'user', 'vars'],
functions: [],
}),
}),
);
const user = userEvent.setup();
render(<Harness initial={[{ condition: '', style: {} }]} />);
const ta = document.getElementById('cf-condition-0') as HTMLTextAreaElement;
await user.click(ta);
// `features` is bound at runtime (host predicate scope) — advertised.
await user.type(ta, 'fea');
expect(await screen.findByRole('option', { name: /features/ }, { timeout: 3000 })).toBeTruthy();
// `vars` is an engine-default root NOT bound for row predicates — withheld.
await user.clear(ta);
await user.type(ta, 'va');
await new Promise((r) => setTimeout(r, 150));
expect(screen.queryByRole('option')).toBeNull();
});
});

describe('ROW_PREDICATE_ROOTS ↔ evalRowPredicate runtime contract', () => {
// Shaped like the app-shell global predicate scope (ExpressionProvider,
// #1583/ADR-0068) that hosts pass into the shared row-predicate evaluator.
const u = { id: 'u1' };
const hostScope = {
current_user: u,
user: u,
ctx: { user: u },
app: { name: 'crm' },
data: {},
features: { beta: true },
};

it('every advertised root is bound when a row predicate evaluates', () => {
for (const root of ROW_PREDICATE_ROOTS) {
// `size(<root>) >= 0` is true iff the root resolves to a bound map —
// an unbound root faults and falls back to `false`.
expect(
evalRowPredicate(`size(${root}) >= 0`, { id: 'r1' }, { fallback: false, scope: hostScope }),
`root "${root}" should be bound at runtime`,
).toBe(true);
}
});

it('the engine-default extras stay unadvertised because they are NOT bound', () => {
for (const root of ['previous', 'input', 'os', 'vars']) {
expect(ROW_PREDICATE_ROOTS).not.toContain(root);
expect(
evalRowPredicate(`size(${root}) >= 0`, { id: 'r1' }, { fallback: false, scope: hostScope }),
`root "${root}" should NOT be bound at runtime`,
).toBe(false);
}
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,28 @@ import { Button, Input, cn } from '@object-ui/components';
import { Plus, Trash2, ChevronUp, ChevronDown } from 'lucide-react';
import { CelPredicateField } from './CelPredicateField';

/**
* Scope roots bound at RUNTIME for a row predicate, advertised to autocomplete.
*
* A formatting `condition` is evaluated by `@object-ui/core`'s
* `evalRowPredicate` (ADR-0058 — list rows, grid rows, kanban cards), which
* binds the row's fields BARE, under `record.*`, and under `data.*`, plus the
* host shell's global predicate scope (`ExpressionProvider`, #1583/ADR-0068:
* `current_user` / `user` / `ctx` / `app` / `features`). The engine's default
* advertisement adds `previous` / `input` / `os` / `vars`, which are NOT bound
* for row predicates — suggesting those would author a condition that silently
* never matches, so this override pins the truthful catalog (#2571 follow-up).
*/
export const ROW_PREDICATE_ROOTS = [
'record',
'current_user',
'user',
'features',
'app',
'data',
'ctx',
];

/** The canonical authoring shape this editor reads and writes. */
export interface ConditionalFormattingRuleDraft {
condition: string;
Expand Down Expand Up @@ -254,6 +276,12 @@ export function ConditionalFormattingEditor({
placeholder="record.status == 'overdue'"
objectName={objectName}
fieldNames={fieldNames}
// Row predicates bind the row's fields BARE at runtime
// (`status == 'overdue'` works — evalRowPredicate spreads the
// row), so lint stays in the flattened scope; only the advertised
// roots change to the runtime-bound set.
scope="flattened"
roots={ROW_PREDICATE_ROOTS}
onChange={(v) => setRule(i, { condition: v })}
t={t}
id={`cf-condition-${i}`}
Expand Down
14 changes: 11 additions & 3 deletions packages/plugin-kanban/src/KanbanEnhanced.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
import { CSS } from "@dnd-kit/utilities"
import { Badge, Card, CardHeader, CardTitle, CardDescription, CardContent, Button, Input } from "@object-ui/components"
import { resolveConditionalFormatting } from "@object-ui/core"
import { usePredicateScope } from "@object-ui/react"
import type { KanbanConditionalFormattingRule } from "@object-ui/types"
import { ChevronDown, ChevronRight, AlertTriangle, Plus } from "lucide-react"

Expand Down Expand Up @@ -70,8 +71,14 @@ export interface KanbanEnhancedProps {
// (issue #1584 / ADR-0058) so kanban cards, list rows, and grid rows reach the
// identical verdict. Beyond the native `{ field, operator, value }` rules the
// kanban schema declares, this also accepts spec `{ condition, style }` rules.
function getCardStyles(card: KanbanCard, rules?: ConditionalFormattingRule[]): React.CSSProperties {
return resolveConditionalFormatting(card as Record<string, unknown>, rules as any) as React.CSSProperties
// The host predicate scope is bound alongside the card so `features.*` /
// `current_user.*` conditions resolve here exactly as they do on grid rows.
function getCardStyles(
card: KanbanCard,
rules?: ConditionalFormattingRule[],
scope?: Record<string, unknown>,
): React.CSSProperties {
return resolveConditionalFormatting(card as Record<string, unknown>, rules as any, scope) as React.CSSProperties
}

function SortableCard({ card, conditionalFormatting }: { card: KanbanCard; conditionalFormatting?: ConditionalFormattingRule[] }) {
Expand All @@ -90,7 +97,8 @@ function SortableCard({ card, conditionalFormatting }: { card: KanbanCard; condi
opacity: isDragging ? 0.5 : undefined,
}

const cardStyles = getCardStyles(card, conditionalFormatting)
const predicateScope = usePredicateScope()
const cardStyles = getCardStyles(card, conditionalFormatting, predicateScope)

return (
<div ref={setNodeRef} style={style} {...attributes} {...listeners}>
Expand Down
15 changes: 11 additions & 4 deletions packages/plugin-kanban/src/KanbanImpl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import {
} from "@dnd-kit/sortable"
import { CSS } from "@dnd-kit/utilities"
import { Badge, Card, CardHeader, CardTitle, CardDescription, CardContent, ScrollArea, Button, Input, useResizeObserver, DataEmptyState } from "@object-ui/components"
import { useHasDndProvider, useDnd } from "@object-ui/react"
import { useHasDndProvider, useDnd, usePredicateScope } from "@object-ui/react"
import { resolveConditionalFormatting } from "@object-ui/core"
import type { KanbanConditionalFormattingRule } from "@object-ui/types"
import { createSafeTranslation } from "@object-ui/i18n"
Expand Down Expand Up @@ -106,8 +106,14 @@ export interface KanbanBoardProps {
// (issue #1584 / ADR-0058) so kanban cards, list rows, and grid rows reach the
// identical verdict. Beyond the native `{ field, operator, value }` rules the
// kanban schema declares, this also accepts spec `{ condition, style }` rules.
function getCardStyles(card: KanbanCard, rules?: ConditionalFormattingRule[]): React.CSSProperties {
return resolveConditionalFormatting(card as Record<string, unknown>, rules as any) as React.CSSProperties
// The host predicate scope is bound alongside the card so `features.*` /
// `current_user.*` conditions resolve here exactly as they do on grid rows.
function getCardStyles(
card: KanbanCard,
rules?: ConditionalFormattingRule[],
scope?: Record<string, unknown>,
): React.CSSProperties {
return resolveConditionalFormatting(card as Record<string, unknown>, rules as any, scope) as React.CSSProperties
}

function SortableCard({ card, onCardClick, conditionalFormatting }: { card: KanbanCard; onCardClick?: (card: KanbanCard, event?: React.MouseEvent) => void; conditionalFormatting?: ConditionalFormattingRule[] }) {
Expand All @@ -126,7 +132,8 @@ function SortableCard({ card, onCardClick, conditionalFormatting }: { card: Kanb
opacity: isDragging ? 0.5 : undefined,
}

const cardStyles = getCardStyles(card, conditionalFormatting)
const predicateScope = usePredicateScope()
const cardStyles = getCardStyles(card, conditionalFormatting, predicateScope)

return (
<div ref={setNodeRef} style={style} {...attributes} {...listeners} role="listitem" aria-label={card.title}
Expand Down
86 changes: 86 additions & 0 deletions packages/plugin-kanban/src/cardPredicateScope.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* Kanban card conditional formatting must bind the HOST PREDICATE SCOPE
* alongside the card (ADR-0058 / #1583 parity with grid rows): a
* `features.*` / `current_user.*` condition authored once has to reach the
* identical verdict on grid rows and kanban cards. Before this suite, kanban
* evaluated cards without the ambient scope, so such a condition silently
* never matched here while working on the grid.
*/

import { describe, it, expect, afterEach } from 'vitest';
import * as React from 'react';
import { render, cleanup } from '@testing-library/react';
import { PredicateScopeProvider } from '@object-ui/react';
import { KanbanEnhanced } from './KanbanEnhanced';
import KanbanBoard from './KanbanImpl';

// happy-dom may lack ResizeObserver (KanbanBoard's container-aware column
// sizing) — a no-op polyfill keeps the static render working.
if (typeof globalThis.ResizeObserver === 'undefined') {
class NoopResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
(globalThis as any).ResizeObserver = NoopResizeObserver;
}

const HOT_BG = 'rgb(254, 226, 226)';
const columns = [{ id: 'todo', title: 'Todo', cards: [{ id: 'c1', title: 'Card One' }] }];
// A features-gated spec rule — resolves ONLY when the host predicate scope is
// bound alongside the card.
const featureRules = [
{ condition: 'features.urgentHighlight && record.id == "c1"', style: { backgroundColor: HOT_BG } },
] as any;

/** The first element carrying the given inline background, if any. */
function findByBg(container: HTMLElement, bg: string): HTMLElement | undefined {
return Array.from(container.querySelectorAll<HTMLElement>('*')).find(
(el) => el.style && el.style.backgroundColor === bg,
);
}

afterEach(cleanup);

describe('kanban card conditional formatting · host predicate scope (ADR-0058)', () => {
it('KanbanEnhanced binds the ambient predicate scope alongside the card', () => {
const { container } = render(
<PredicateScopeProvider scope={{ features: { urgentHighlight: true } }}>
<KanbanEnhanced columns={columns} conditionalFormatting={featureRules} />
</PredicateScopeProvider>,
);
expect(findByBg(container, HOT_BG)).toBeTruthy();
});

it('KanbanBoard (impl) binds the ambient predicate scope alongside the card', () => {
const { container } = render(
<PredicateScopeProvider scope={{ features: { urgentHighlight: true } }}>
<KanbanBoard columns={columns} conditionalFormatting={featureRules} />
</PredicateScopeProvider>,
);
expect(findByBg(container, HOT_BG)).toBeTruthy();
});

it('a scope-gated condition fails SOFT (no style) outside the provider', () => {
const { container } = render(
<KanbanEnhanced columns={columns} conditionalFormatting={featureRules} />,
);
expect(findByBg(container, HOT_BG)).toBeUndefined();
});

it('bare-field conditions keep working without any provider (row spread)', () => {
const bareRules = [{ condition: 'id == "c1"', style: { backgroundColor: HOT_BG } }] as any;
const { container } = render(
<KanbanEnhanced columns={columns} conditionalFormatting={bareRules} />,
);
expect(findByBg(container, HOT_BG)).toBeTruthy();
});
});
9 changes: 7 additions & 2 deletions packages/plugin-list/src/ListView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -167,12 +167,17 @@ export function convertFilterGroupToAST(group: FilterGroup): any[] {
* fallback) so a list view speaks the same expression dialect the server does
* (issue #1584 / ADR-0058). Kept exported for back-compat with consumers that
* evaluate formatting outside the ListView component.
*
* @param scope Extra top-level scope (the host predicate scope) bound
* alongside the row, so `features.*` / `current_user.*`
* conditions resolve as they do on grid rows / kanban cards.
*/
export function evaluateConditionalFormatting(
record: Record<string, unknown>,
rules?: ListViewSchema['conditionalFormatting']
rules?: ListViewSchema['conditionalFormatting'],
scope?: Record<string, unknown>
): React.CSSProperties {
return resolveConditionalFormatting(record, rules as any) as React.CSSProperties;
return resolveConditionalFormatting(record, rules as any, scope) as React.CSSProperties;
}

// Default English translations for fallback when I18nProvider is not available
Expand Down
11 changes: 11 additions & 0 deletions packages/plugin-list/src/__tests__/ListView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2098,6 +2098,17 @@ describe('ListView', () => {
);
expect(result).toEqual({ backgroundColor: '#e0ffe0', color: '#0a0' });
});

it('binds a host predicate scope alongside the row (grid/kanban parity)', () => {
const rules = [
{ condition: 'features.highlight && record.status == "active"', style: { backgroundColor: '#fee' } },
] as any;
expect(
evaluateConditionalFormatting({ status: 'active' }, rules, { features: { highlight: true } }),
).toEqual({ backgroundColor: '#fee' });
// Without the scope the features.* condition fails soft — no style.
expect(evaluateConditionalFormatting({ status: 'active' }, rules)).toEqual({});
});
});

// ============================
Expand Down
Loading