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
9 changes: 7 additions & 2 deletions packages/app-shell/src/chrome/ThemeProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { createContext, useContext, useEffect, useState } from "react"

type Theme = "dark" | "light" | "system"
// `auto` is the spec's OS-following mode (ThemeModeSchema, ui/theme.zod.ts);
// `system` is this provider's pre-spec spelling, kept for stored values.
type Theme = "dark" | "light" | "auto" | "system"

type ThemeProviderProps = {
children: React.ReactNode
Expand Down Expand Up @@ -35,7 +37,10 @@ export function ThemeProvider({

root.classList.remove("light", "dark")

if (theme === "system") {
// Branching on `system` alone sent the spec's `auto` into
// `classList.add('auto')` — a class no Tailwind variant matches, locking
// the light theme with the OS preference ignored (#2942).
if (theme === "auto" || theme === "system") {
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
.matches
? "dark"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/**
* 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.
*/

/**
* Toaster position ↔ spec vocabulary parity + behavior (#2942).
*
* The `toaster` block used to mount `<SonnerToaster />` bare with
* `inputs: []` — all six `NotificationPositionSchema` values (and `limit`)
* validated and were discarded.
*/
import { describe, it, expect, vi, beforeAll } from 'vitest';
import React from 'react';
import '@testing-library/jest-dom';
import { NotificationPositionSchema } from '@objectstack/spec/ui';
import { renderComponent } from './test-utils';
import { TOASTER_POSITIONS } from '../renderers/feedback/toaster';

// Sonner defers mounting its DOM container, so the behavior tests assert on
// the props our registration hands it rather than sonner internals.
vi.mock('../ui/sonner', () => ({
Toaster: (props: { position?: string; visibleToasts?: number }) => (
<div data-testid="sonner-mock" data-position={props.position} data-limit={props.visibleToasts} />
),
toast: Object.assign(() => undefined, {
success: () => undefined,
error: () => undefined,
info: () => undefined,
warning: () => undefined,
}),
}));

beforeAll(async () => {
await import('../renderers');
}, 30000);

describe('toaster covers the spec notification-position vocabulary', () => {
const rawOptions = (NotificationPositionSchema as unknown as { options?: readonly string[] }).options;
const specNames: string[] = Array.isArray(rawOptions) ? [...rawOptions] : [];

it('reads a non-empty enum from the spec', () => {
expect(specNames, 'could not read NotificationPositionSchema.options from the spec').not.toEqual([]);
});

it('maps every spec position onto a sonner position', () => {
const unmapped = specNames.filter((name) => !(name in TOASTER_POSITIONS));
expect(unmapped, 'these validate and then the toaster discards them').toEqual([]);
});

it('maps only spec positions and the ToasterSchema hyphen dialect', () => {
const sonnerIds = new Set(Object.values(TOASTER_POSITIONS));
const extra = Object.keys(TOASTER_POSITIONS).filter(
(name) => !specNames.includes(name) && !sonnerIds.has(name as never),
);
expect(extra, 'unexpected renderer-local position dialect').toEqual([]);
});
});

describe('toaster behavior', () => {
it('a spec underscore position (and limit) reaches sonner translated', () => {
const { getByTestId } = renderComponent({ type: 'toaster', position: 'top_center', limit: 3 } as any);
const toaster = getByTestId('sonner-mock');
expect(toaster).toHaveAttribute('data-position', 'top-center');
expect(toaster).toHaveAttribute('data-limit', '3');
});

it('the hyphen dialect keeps working', () => {
const { getByTestId } = renderComponent({ type: 'toaster', position: 'bottom-left' } as any);
expect(getByTestId('sonner-mock')).toHaveAttribute('data-position', 'bottom-left');
});
});
29 changes: 23 additions & 6 deletions packages/components/src/custom/filter-builder.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,16 @@ const defaultOperators = [
{ value: "between", label: "Between" },
{ value: "in", label: "In" },
{ value: "notIn", label: "Not in" },
// String-specific spec operators ($startsWith / $endsWith) — they validated
// at the data layer but were unreachable from this dropdown (#2942).
{ value: "startsWith", label: "Starts with" },
{ value: "endsWith", label: "Ends with" },
// Null / existence spec operators ($null / $exists). Distinct from
// isEmpty/isNotEmpty, which also treat '' as empty.
{ value: "isNull", label: "Is null" },
{ value: "isNotNull", label: "Is not null" },
{ value: "exists", label: "Is set" },
{ value: "notExists", label: "Is not set" },
] as const

/**
Expand Down Expand Up @@ -121,16 +131,23 @@ const useSafeFilterTranslation = createSafeTranslation(
'filterBuilder.operators.between': 'Between',
'filterBuilder.operators.in': 'In',
'filterBuilder.operators.notIn': 'Not in',
'filterBuilder.operators.startsWith': 'Starts with',
'filterBuilder.operators.endsWith': 'Ends with',
'filterBuilder.operators.isNull': 'Is null',
'filterBuilder.operators.isNotNull': 'Is not null',
'filterBuilder.operators.exists': 'Is set',
'filterBuilder.operators.notExists': 'Is not set',
},
'filterBuilder.where',
)

const textOperators = ["equals", "notEquals", "contains", "notContains", "isEmpty", "isNotEmpty"]
const numberOperators = ["equals", "notEquals", "greaterThan", "lessThan", "greaterOrEqual", "lessOrEqual", "isEmpty", "isNotEmpty"]
const NULLNESS_OPERATORS = ["isNull", "isNotNull", "exists", "notExists"]
const textOperators = ["equals", "notEquals", "contains", "notContains", "startsWith", "endsWith", "isEmpty", "isNotEmpty", ...NULLNESS_OPERATORS]
const numberOperators = ["equals", "notEquals", "greaterThan", "lessThan", "greaterOrEqual", "lessOrEqual", "isEmpty", "isNotEmpty", ...NULLNESS_OPERATORS]
const booleanOperators = ["equals", "notEquals"]
const dateOperators = ["equals", "notEquals", "before", "after", "between", "isEmpty", "isNotEmpty"]
const selectOperators = ["equals", "notEquals", "in", "notIn", "isEmpty", "isNotEmpty"]
const lookupOperators = ["equals", "notEquals", "in", "notIn", "isEmpty", "isNotEmpty"]
const dateOperators = ["equals", "notEquals", "before", "after", "between", "isEmpty", "isNotEmpty", ...NULLNESS_OPERATORS]
const selectOperators = ["equals", "notEquals", "in", "notIn", "isEmpty", "isNotEmpty", ...NULLNESS_OPERATORS]
const lookupOperators = ["equals", "notEquals", "in", "notIn", "isEmpty", "isNotEmpty", ...NULLNESS_OPERATORS]

/** Field types that share the same operator/input behavior as number (numeric comparison operators, number input) */
const numberLikeTypes = ["number", "currency", "percent", "rating"]
Expand Down Expand Up @@ -256,7 +273,7 @@ function FilterBuilder({
}

const needsValueInput = (operator: string) => {
return !["isEmpty", "isNotEmpty"].includes(operator)
return !["isEmpty", "isNotEmpty", "isNull", "isNotNull", "exists", "notExists"].includes(operator)
}

const getInputType = (fieldValue: string) => {
Expand Down
56 changes: 50 additions & 6 deletions packages/components/src/renderers/feedback/toaster.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,58 @@ import { ComponentRegistry } from '@object-ui/core';
import type { ToasterSchema } from '@object-ui/types';
import { Toaster as SonnerToaster } from '../../ui/sonner';

ComponentRegistry.register('toaster',
() => {
return <SonnerToaster />;
/**
* Spec `NotificationPositionSchema` (`ui/notification.zod.ts`, underscore
* spellings) → sonner's hyphenated position ids. The hyphen spellings are
* `ToasterSchema`'s own dialect and map through as identities, so both
* registers work. Before #2942 the renderer mounted `<SonnerToaster />` bare
* (`inputs: []`) and every authored position — all six spec values — plus
* `limit` validated and changed nothing. Exported for the spec-parity test.
*/
export const TOASTER_POSITIONS: Record<
string,
'top-left' | 'top-center' | 'top-right' | 'bottom-left' | 'bottom-center' | 'bottom-right'
> = {
top_left: 'top-left',
top_center: 'top-center',
top_right: 'top-right',
bottom_left: 'bottom-left',
bottom_center: 'bottom-center',
bottom_right: 'bottom-right',
// ToasterSchema's hyphen dialect — identity entries.
'top-left': 'top-left',
'top-center': 'top-center',
'top-right': 'top-right',
'bottom-left': 'bottom-left',
'bottom-center': 'bottom-center',
'bottom-right': 'bottom-right',
};

ComponentRegistry.register(
'toaster',
({ schema }: { schema: ToasterSchema }) => {
const position = schema?.position ? TOASTER_POSITIONS[schema.position] : undefined;
const limit = typeof schema?.limit === 'number' && schema.limit > 0 ? schema.limit : undefined;
return (
<SonnerToaster
{...(position ? { position } : {})}
{...(limit ? { visibleToasts: limit } : {})}
/>
);
},
{
namespace: 'ui',
label: 'Toaster',
inputs: [],
defaultProps: {}
}
inputs: [
{
name: 'position',
type: 'enum',
enum: ['top-left', 'top-center', 'top-right', 'bottom-left', 'bottom-center', 'bottom-right'],
defaultValue: 'bottom-right',
label: 'Position',
},
{ name: 'limit', type: 'number', label: 'Max visible toasts' },
],
defaultProps: {},
},
);
1 change: 1 addition & 0 deletions packages/fields/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
"react-dom": "^18.0.0 || ^19.0.0"
},
"devDependencies": {
"@objectstack/spec": "^17.0.0-rc.0",
"@types/react": "19.2.17",
"@types/react-dom": "19.2.3",
"@vitejs/plugin-react": "^6.0.4",
Expand Down
43 changes: 43 additions & 0 deletions packages/fields/src/FieldEditWidget.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@
*/

import { describe, it, expect } from 'vitest';
import { FieldType } from '@objectstack/spec/data';
import {
FORM_FIELD_TYPES,
INLINE_EXCLUDED_FIELD_TYPES,
hasFieldEditWidget,
isInlineExcludedFieldType,
mapFieldTypeToFormType,
} from './index';

Expand Down Expand Up @@ -88,3 +90,44 @@ describe('inline editor ↔ form widget parity', () => {
}
});
});

/**
* #2942 — the guard above iterates FORM_FIELD_TYPES (the form widget-map
* keys), so a SPEC field type that reaches the form only through the alias
* table could sit in neither set and silently fall back to the grid's plain
* text input: `json`, `composite`, `record`, `repeater`, `tree`, `video`,
* `audio`, `autonumber` all did. This pins the contract at the spec boundary:
* every `FieldType` member must resolve (alias-aware) to an inline editor or
* a documented exclusion.
*/
describe('inline editor ↔ SPEC FieldType parity (#2942)', () => {
const specTypes: string[] = Array.isArray((FieldType as { options?: readonly string[] }).options)
? [...(FieldType as { options: readonly string[] }).options]
: [];

it('reads a non-empty enum from the spec', () => {
expect(specTypes, 'could not read FieldType.options from the spec').not.toEqual([]);
});

it('every spec field type resolves to an inline decision (editor or exclusion)', () => {
const undecided = specTypes.filter(
(t) => !hasFieldEditWidget(t) && !isInlineExcludedFieldType(t),
);
// If this fails: a spec field type would fall back to a plain text input
// inline. Give it a widget (EDIT_WIDGETS / the alias table) or a
// documented exclusion (INLINE_EXCLUDED_FIELD_TYPES).
expect(undecided).toEqual([]);
});

it('the structured/computed spec spellings are closed corruption paths', () => {
// Excluded (alias-aware): editing these through a text box corrupts the value.
for (const t of ['composite', 'record', 'repeater', 'video', 'audio', 'autonumber']) {
expect(isInlineExcludedFieldType(t), `${t} must be excluded from inline editing`).toBe(true);
expect(hasFieldEditWidget(t), `${t} must not resolve to an editor`).toBe(false);
}
// Editable through their form widgets: json → code editor, tree → lookup picker.
for (const t of ['json', 'tree']) {
expect(hasFieldEditWidget(t), `${t} must resolve to its form widget`).toBe(true);
}
});
});
40 changes: 37 additions & 3 deletions packages/fields/src/FieldEditWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ import { LocationField } from './widgets/LocationField';
import { GeolocationField } from './widgets/GeolocationField';
import { CodeField } from './widgets/CodeField';
import { QRCodeField } from './widgets/QRCodeField';
// The FORM's spec-alias table (`json` → `field:code`, `tree` → `field:lookup`,
// …) — inline resolution reuses it so spec spellings get the form's decision.
import { mapFieldTypeToFormType } from './field-type-alias';

/**
* Field types that edit in place with a dedicated widget. Keyed by the raw
Expand Down Expand Up @@ -122,9 +125,39 @@ export const DISCRETE_EDIT_TYPES = new Set<string>([
'boolean', 'toggle', 'select', 'status', 'radio', 'rating',
]);

/**
* Resolve a field type to its inline-editing key: the raw type when the
* widget/exclusion tables name it directly, otherwise its form-alias target
* (`mapFieldTypeToFormType`, `field:` prefix stripped).
*
* The tables are keyed by the FORM's spellings, but spec field types reach
* this layer in the SPEC's spellings — `json`, `tree`, `composite`, `record`,
* `repeater`, `video`, `audio`, `autonumber` are aliases in the form map and
* were keys in NEITHER table here, so the grid silently fell back to a plain
* text input for all of them: typing over structured JSON or a hierarchy ref
* is a value-corruption path (#2942). Resolving through the same alias table
* the form uses gives each spec type the form's own decision: `json` → the
* code editor, `tree` → the lookup picker, and the container/computed/binary
* families → their documented exclusions.
*/
function resolveInlineEditType(type: string): string {
if (type in EDIT_WIDGETS || INLINE_EXCLUDED_FIELD_TYPES.has(type)) return type;
return mapFieldTypeToFormType(type).replace(/^field:/, '');
}

/** True when a field type has a dedicated in-place edit widget. */
export function hasFieldEditWidget(type: string | undefined): boolean {
return !!type && type in EDIT_WIDGETS;
return !!type && resolveInlineEditType(type) in EDIT_WIDGETS;
}

/**
* True when a field type is deliberately NOT inline-editable (alias-aware:
* `composite` resolves to the excluded `object`, `video` to `file`, …).
* Grid hosts must treat these as read-only cells — falling back to a plain
* text editor is exactly the corruption path the exclusion exists to close.
*/
export function isInlineExcludedFieldType(type: string | undefined): boolean {
return !!type && INLINE_EXCLUDED_FIELD_TYPES.has(resolveInlineEditType(type));
}

/**
Expand All @@ -148,8 +181,9 @@ export function FieldEditWidget({
onChange,
readonly,
}: FieldWidgetProps<any>): React.ReactElement | null {
const Widget = field?.type ? EDIT_WIDGETS[field.type] : undefined;
const resolved = field?.type ? resolveInlineEditType(field.type) : undefined;
const Widget = resolved ? EDIT_WIDGETS[resolved] : undefined;
if (!Widget) return null;
const compactProps = field?.type && COMPACT_EDIT_TYPES.has(field.type) ? { compact: true } : {};
const compactProps = resolved && COMPACT_EDIT_TYPES.has(resolved) ? { compact: true } : {};
return <Widget field={field} value={value} onChange={onChange} readonly={readonly} {...(compactProps as any)} />;
}
Loading
Loading