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
@@ -1,14 +1,15 @@
import { describe, it, expect } from 'vitest';
import { PageComponentType } from '@objectstack/spec/ui';
import { BLOCK_CONFIG, blockHasConfig } from '../block-config';
import { BLOCK_TYPE_META } from '../block-types';
import { BLOCK_TYPE_META, PALETTE_EXCLUSIONS } from '../block-types';

describe('block-config', () => {
it('exposes a configurable panel for every content block with authorable props', () => {
for (const type of [
'element:text', 'element:image', 'element:number', 'element:button',
'page:header', 'page:card', 'page:tabs', 'page:accordion',
'record:related_list', 'record:highlights', 'record:details', 'record:alert',
'record:path', 'record:quick_actions', 'ai:chat_window', 'ai:input',
'record:path', 'record:quick_actions', 'ai:input',
'element:definition-list', 'element:repeater',
]) {
expect(blockHasConfig(type), type).toBe(true);
Expand All @@ -22,19 +23,6 @@ describe('block-config', () => {
expect(blockHasConfig(undefined)).toBe(false);
});

it('prunes shell-singleton and unimplemented blocks from the page palette', () => {
for (const type of [
'app:launcher', 'global:notifications', 'user:profile', // shell singletons
'element:form', 'element:filter', 'element:record_picker', // no renderer
]) {
expect((BLOCK_TYPE_META as any)[type]).toBeUndefined();
}
// page-content navigation stays; the real list primitives are now in the palette
expect((BLOCK_TYPE_META as any)['nav:menu']).toBeTruthy();
expect((BLOCK_TYPE_META as any)['element:definition-list']).toBeTruthy();
expect((BLOCK_TYPE_META as any)['element:repeater']).toBeTruthy();
});

it('also exposes the array-valued blocks', () => {
for (const type of ['page:tabs', 'record:details', 'record:highlights']) {
expect(blockHasConfig(type)).toBe(true);
Expand All @@ -61,3 +49,68 @@ describe('block-config', () => {
}
});
});

/**
* Palette coverage ↔ spec `PageComponentType` (#2943).
*
* The previous version of this suite hand-asserted a handful of palette
* EXCLUSIONS (`expect(BLOCK_TYPE_META['element:form']).toBeUndefined()`), which
* locks drift in rather than detecting it: a new spec block type could land and
* simply never reach the palette, with nothing failing. These derive coverage
* from the enum instead, so every value must be an explicit decision —
* offered, or excluded with a documented reason.
*/
describe('page palette ↔ spec PageComponentType coverage', () => {
const specNames: string[] = (() => {
const raw = (PageComponentType as unknown as { options?: readonly string[] }).options;
return Array.isArray(raw) ? [...raw] : [];
})();

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

it('every spec block type is either offered or explicitly excluded', () => {
const undecided = specNames.filter(
(t) => !(t in (BLOCK_TYPE_META as Record<string, unknown>)) && !(t in PALETTE_EXCLUSIONS),
);
// If this fails: a spec page-block type has no palette decision. Either add
// it to BLOCK_TYPE_META (offered — it needs a renderer!) or to
// PALETTE_EXCLUSIONS with the reason it is unauthorable.
expect(undecided).toEqual([]);
});

it('no type is both offered and excluded', () => {
const both = Object.keys(PALETTE_EXCLUSIONS).filter(
(t) => t in (BLOCK_TYPE_META as Record<string, unknown>),
);
expect(both, 'a block cannot be offered and excluded at once').toEqual([]);
});

it('every exclusion names a real spec type and carries a reason', () => {
for (const [type, reason] of Object.entries(PALETTE_EXCLUSIONS)) {
expect(specNames, `'${type}' is not a spec PageComponentType — stale exclusion`).toContain(type);
expect(reason.length, `'${type}' needs a reason`).toBeGreaterThan(10);
}
});

it('ai:chat_window is not offered — it has no inline renderer', () => {
// The palette used to offer it WITH a config panel while
// `components/renderers/placeholders.tsx` deliberately excluded it to force
// a loud error: an author dragged a block Studio advertised and got a red
// "Unknown component type" box.
expect((BLOCK_TYPE_META as any)['ai:chat_window']).toBeUndefined();
expect(blockHasConfig('ai:chat_window')).toBe(false);
expect(PALETTE_EXCLUSIONS['ai:chat_window']).toBeTruthy();
});

it('a block with a config panel is a block the palette offers', () => {
// A panel for an unauthorable block is how the ai:chat_window
// contradiction stayed invisible. Non-spec objectui blocks (`object-grid`,
// `grid`, …) are exempt — they are palette-native, not PageComponentType.
const orphanPanels = Object.keys(BLOCK_CONFIG).filter(
(t) => specNames.includes(t) && !(t in (BLOCK_TYPE_META as Record<string, unknown>)),
);
expect(orphanPanels, 'these expose a config panel but cannot be authored').toEqual([]);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -349,10 +349,9 @@ export const BLOCK_CONFIG: Record<string, BlockPropField[]> = {
],

// ── AI ────────────────────────────────────────────────────────────────────
'ai:chat_window': [
{ name: 'agentName', label: 'Agent', kind: 'text', placeholder: 'agent name' },
{ name: 'placeholder', label: 'Input placeholder', kind: 'text' },
],
// No `ai:chat_window` panel: the block is not in the palette (no inline
// renderer — see block-types.ts PALETTE_EXCLUSIONS, #2943). A config panel
// for an unauthorable block is how the contradiction stayed invisible.
'ai:input': [
{ name: 'agentName', label: 'Agent', kind: 'text', placeholder: 'agent name' },
{ name: 'placeholder', label: 'Input placeholder', kind: 'text' },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ export type BlockTypeId =
// global:*
| 'global:search'
// ai:*
| 'ai:chat_window' | 'ai:suggestion'
| 'ai:suggestion'
// element:*
| 'element:text' | 'element:number' | 'element:image' | 'element:divider'
| 'element:button' | 'element:definition-list' | 'element:repeater';
Expand Down Expand Up @@ -113,7 +113,12 @@ export const BLOCK_TYPE_META: Record<BlockTypeId, Omit<BlockTypeMeta, 'id'>> = {
'global:search': { label: 'Global search', category: 'navigation', Icon: Search },

// AI
'ai:chat_window': { label: 'AI chat window', category: 'ai', Icon: Bot },
// `ai:chat_window` is deliberately NOT in the palette: the floating chat
// overlay (plugin-chatbot) is the canonical entry point and there is no
// inline page-level renderer — `components/renderers/placeholders.tsx`
// documents that exclusion. The palette used to offer it (with a config
// panel), so an author dragged a block Studio advertised and got a red
// "Unknown component type" box (#2943). See PALETTE_EXCLUSIONS.
'ai:suggestion': { label: 'AI suggestion', category: 'ai', Icon: Sparkles },

// Elements
Expand All @@ -126,6 +131,34 @@ export const BLOCK_TYPE_META: Record<BlockTypeId, Omit<BlockTypeMeta, 'id'>> = {
'element:repeater': { label: 'Repeater', category: 'element', Icon: Rows3 },
};

/**
* Every spec `PageComponentType` the page palette deliberately does NOT offer,
* each with the reason it is unauthorable. This is the inverse of the old
* guard (#2943): `block-config.test.ts` used to hand-assert a few exclusions,
* which locks drift IN — a new spec block type could land and simply never
* appear in the palette, unnoticed. The test now derives coverage from
* `PageComponentType` and requires every value to be either in
* {@link BLOCK_TYPE_META} or listed here, so a new one fails until someone
* decides about it.
*
* A palette entry with no renderer is the failure this closes: `ai:chat_window`
* was offered WITH a config panel while `placeholders.tsx` deliberately
* excluded it to force a loud error — an author dragged a block Studio
* advertised and got a red box.
*/
export const PALETTE_EXCLUSIONS: Record<string, string> = {
// Shell singletons — chrome the app shell owns, not page content.
'app:launcher': 'shell singleton — the app shell renders it, not a page',
'global:notifications': 'shell singleton — lives in the app shell header',
'user:profile': 'shell singleton — lives in the app shell header',
// No renderer, by decision.
'ai:chat_window': 'no inline renderer — the floating chat overlay (plugin-chatbot) is canonical',
'element:filter': 'no renderer — list surfaces own filtering (userFilters / filter builder)',
'element:form': 'no renderer — use the object-bound `object-form` block',
'element:record_picker': 'no renderer — record picking is a field widget, not a page block',
'element:text_input': 'no renderer — bare inputs belong to a form, not a page block',
};

export const CATEGORY_LABEL_EN: Record<BlockCategory, string> = {
data: 'Data',
layout: 'Layout',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/**
* 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.
*/

/**
* Palette-offered page blocks render in EVERY host (#2943).
*
* Placeholder registration was entirely opt-in (`registerPlaceholders()`), and
* only `apps/console` calls it. So `nav:menu`, `nav:breadcrumb`,
* `global:search` and `ai:suggestion` — blocks the Studio palette offers —
* rendered `SchemaRenderer`'s red `role="alert"` panel in every other host,
* dumping the page JSON, purely because that host skipped an optional
* bootstrap.
*
* These four now register eagerly through the `./renderers` barrel. The rest of
* the protocol vocabulary stays opt-in on purpose: a genuinely missing renderer
* must keep failing loudly so the misconfiguration gets fixed at the source.
*/
import { describe, it, expect, beforeAll } from 'vitest';
import '@testing-library/jest-dom';
import { ComponentRegistry } from '@object-ui/core';
import { PALETTE_PLACEHOLDER_BLOCKS } from '../renderers/placeholders';

beforeAll(async () => {
// Import the barrel ONLY — never `registerPlaceholders()`, which is what
// this test exists to prove is not required for these blocks.
await import('../renderers');
}, 30000);

describe('palette-offered blocks render without the optional bootstrap', () => {
it('declares a non-empty eager set', () => {
expect(PALETTE_PLACEHOLDER_BLOCKS.length).toBeGreaterThan(0);
});

it.each(PALETTE_PLACEHOLDER_BLOCKS)('%s resolves to a renderer', (type) => {
expect(
ComponentRegistry.get(type),
`${type} is offered by the Studio palette — it must not red-box in a host that skips registerPlaceholders()`,
).toBeTruthy();
});

it('keeps the rest of the protocol vocabulary opt-in (loud by design)', () => {
// A sample of PROTOCOL_COMPONENTS members outside the eager set: these are
// NOT palette blocks, and a page referencing one is a misconfiguration that
// should stay loud.
for (const type of ['view:kanban', 'widget:heatmap', 'field:duration']) {
expect(
ComponentRegistry.get(type),
`${type} must stay opt-in — auto-registering it would mask a missing renderer`,
).toBeFalsy();
}
});

it('ai:chat_window stays unregistered — the exclusion is deliberate', () => {
// The floating chat overlay (plugin-chatbot) is canonical; #2943 pruned the
// palette entry rather than adding a placeholder for it.
expect(ComponentRegistry.get('ai:chat_window')).toBeFalsy();
});
});
4 changes: 4 additions & 0 deletions packages/components/src/renderers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,7 @@ import './overlay';
import './disclosure';
import './complex';
import './action';
// Registers placeholders for the page blocks the Studio palette offers but no
// package renders yet, so they don't red-box in hosts that skip the optional
// `registerPlaceholders()` bootstrap (#2943). The rest stay opt-in.
import './placeholders';
41 changes: 35 additions & 6 deletions packages/components/src/renderers/placeholders.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,11 +102,40 @@ const PROTOCOL_COMPONENTS = [
'ai:input', 'ai:suggestion', 'ai:feedback'
];

/**
* Page blocks the Studio palette OFFERS but no package renders yet.
*
* These are registered eagerly (see the side-effect call below, reached through
* the `./renderers` barrel) rather than waiting for a host to opt in via
* {@link registerPlaceholders} — which only `apps/console` does. A block Studio
* advertises must not render a red "Unknown component type" panel in every
* other host just because that host didn't call an optional bootstrap (#2943).
*
* Deliberately NARROW: the rest of {@link PROTOCOL_COMPONENTS} stays opt-in so
* a genuinely missing renderer keeps failing loudly, which is what makes the
* misconfiguration get fixed at the source (the same reasoning that keeps
* `ai:chat_window` out of this file entirely — and, since #2943, out of the
* palette too).
*/
export const PALETTE_PLACEHOLDER_BLOCKS = [
'nav:menu', 'nav:breadcrumb', 'global:search', 'ai:suggestion',
];

/** Register one placeholder, never overwriting a real implementation. */
function registerPlaceholder(type: string) {
if (!ComponentRegistry.get(type)) {
ComponentRegistry.register(type, PlaceholderRenderer, { namespace: 'protocol-placeholder' });
}
}

/**
* Register placeholders for the whole protocol vocabulary. Opt-in: hosts that
* want every declared component to render *something* (the console's preview
* gallery) call this; others keep the loud unknown-type error.
*/
export function registerPlaceholders() {
PROTOCOL_COMPONENTS.forEach(type => {
// Only register if not already registered (to avoid overwriting real implementations)
if (!ComponentRegistry.get(type)) {
ComponentRegistry.register(type, PlaceholderRenderer, { namespace: 'protocol-placeholder' });
}
});
PROTOCOL_COMPONENTS.forEach(registerPlaceholder);
}

// Palette-offered page blocks render everywhere, in every host (see above).
PALETTE_PLACEHOLDER_BLOCKS.forEach(registerPlaceholder);
Loading
Loading