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
18 changes: 18 additions & 0 deletions .changeset/7265-plugin-detail-record-alert-renderer-props.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
---

Internal, type-only: `@object-ui/plugin-detail`'s `record:alert` renderer renames
its module-local props interface from `RecordAlertProps` to
`RecordAlertRendererProps`, the spelling every other renderer in that directory
already uses. `@objectstack/spec/ui` exports `RecordAlertProps` for the block's
AUTHORED properties — the bag this interface nests under `schema.properties` —
so the two names described different concepts, which is what
`pnpm check:spec-symbols` had been flagging. Nothing is published: the interface
was never exported, the renderer's own name and runtime behaviour are unchanged,
and the declaration's member list is byte-for-byte what it was.

This is the last entry in that gate's rule-1 `DEBT` ledger (objectui#7265). The
same change rewords the gate's stale-entry message so it no longer spells a
GitHub closing keyword in front of the ledger anchor's own number — that message
is quoted into pull-request bodies by design on this card, and the keyword put a
card-ending trigger in the merge path of every slice that quoted its own reading.
83 changes: 83 additions & 0 deletions packages/plugin-detail/src/__tests__/spec-symbol-batch7.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,19 @@
*/

import { describe, it, expect } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import type * as React from 'react';
import { FeedFilterMode as SpecFeedFilterModeEnum } from '@objectstack/spec/data';
import type { FeedFilterMode as SpecFeedFilterMode } from '@objectstack/spec/data';
import type { ObjectFieldLike as SpecObjectFieldLike } from '@objectstack/spec/system';
import type { RecordAlertProps as SpecRecordAlertProps } from '@objectstack/spec/ui';

import type { FeedFilterMode } from '../RecordActivityTimeline';
import type { ObjectDefFieldLike } from '../synth/buildDefaultPageSchema';
import { normalizeFilterMode } from '../renderers/recordActivityFeed';
import type { RecordAlertRenderer } from '../renderers/record-alert';

describe('FeedFilterMode is the spec enum, at runtime as well as in types', () => {
it('accepts every member the spec declares — read from the spec, not restated', () => {
Expand Down Expand Up @@ -85,3 +91,80 @@ describe('the two verdicts are pinned at compile time', () => {
expect(true).toBe(true);
});
});

/* -------------------------------------------------------------------------- */
/* objectui#7265, this package's slice — `RecordAlertProps`. */
/* */
/* Appended here rather than given a file of its own: this IS the package's */
/* spec-symbol parity file, and the shape it already holds (a RENAME, pinned */
/* by what the spec means rather than by what the copy said) is exactly the */
/* shape this one needs. The block, the site and the empty-ledger path are */
/* pinned one level up, in scripts/__tests__ beside the gate they are about. */
/* -------------------------------------------------------------------------- */

describe('the renderer props convention this directory already kept', () => {
// ⛔ Rooted at THIS FILE, never at `process.cwd()` — a package test runs under
// two different cwds depending on the invocation (`check:test-path-roots`).
const renderersDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../renderers');

/** Every `React.FC<Name>` annotation in the renderers directory, file by file. */
const annotations = (): Array<{ file: string; propsType: string }> => {
const out: Array<{ file: string; propsType: string }> = [];
for (const name of fs.readdirSync(renderersDir).sort()) {
if (!name.endsWith('.tsx')) continue;
const text = fs.readFileSync(path.join(renderersDir, name), 'utf8');
for (const hit of text.matchAll(/React\.FC<\s*([A-Za-z0-9_]+)\s*>/g)) {
out.push({ file: name, propsType: hit[1] });
}
}
return out;
};

it('the scan finds annotations at all — the control', () => {
// A convention derived from an empty population is a green that means
// nothing. This leg fails if the directory moves, is renamed, or stops
// spelling its components `React.FC<…>`.
expect(annotations().length).toBeGreaterThan(1);
});

it('every renderer names its props type `…RendererProps`, never the block name', () => {
// This is the reason `RecordAlertProps` was the LAST entry in the gate's
// DEBT ledger while its siblings were never in it: `@objectstack/spec/ui`
// owns a `Record<Block>Props` for each of the blocks rendered here, so the
// `Renderer` infix is what keeps a renderer's props type out of the spec's
// namespace. Derived from the directory rather than listed, so a renderer
// added later is judged too.
const offenders = annotations().filter((a) => !a.propsType.endsWith('RendererProps'));
expect(
offenders,
'a renderer in this directory types its component with a props type that is not '
+ 'spelled `…RendererProps`. If the name is one @objectstack/spec exports, '
+ '`pnpm check:spec-symbols` will fail on it as a hand-written mirror of the '
+ "block's authored properties — which is what objectui#7265's last slice "
+ 'repaired. Rename the props type, or delete this assertion deliberately.',
).toEqual([]);
});
});

describe('RecordAlertRendererProps is NOT the spec bag it wraps', () => {
it('is pinned at compile time', () => {
// The measurement that refused BIND, as assertions. The spec's
// `RecordAlertProps` is the block's AUTHORED property bag; the renderer's
// props are the React envelope that carries it. `severity` belongs to the
// first and `schema` to the second, and the spec's declaration has no index
// signature, so both questions have real answers on that side.
type _SpecIsReal = Assert<Equal<IsAny<SpecRecordAlertProps>, false>>;
type _SpecCarriesSeverity = Assert<HasKey<SpecRecordAlertProps, 'severity'>>;
type _SpecIsNotTheEnvelope = Assert<Equal<HasKey<SpecRecordAlertProps, 'schema'>, false>>;

// ⛔ Deliberately NOT asserted with `HasKey` on the renderer's side: its
// props carry `[k: string]: any`, so `K extends keyof T` is true for every
// K there and the question would answer itself. Identity is the probe that
// still discriminates.
type _RendererIsNotTheBag = Assert<
Equal<Equal<React.ComponentProps<typeof RecordAlertRenderer>, SpecRecordAlertProps>, false>
>;

expect(true).toBe(true);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -201,13 +201,14 @@ describe('RecordAlertRenderer', () => {
// objectui#4998 — `action.label` accepts the inline locale map, not just a
// plain string. The renderer already resolved one before this pin existed
// (`const ctaLabel = pickLocalized(props.action?.label, language)`), so a
// plain-string CTA label is green whether or not the local `RecordAlertProps`
// type admits a map — that's the `label: 'Send again'` case just above, and
// it cannot tell the widened declaration apart from the narrow one it
// replaced. This case can: a bare `label?: string` refused a locale-map
// value at the TYPE level even though `pickLocalized` (an `unknown` input)
// already resolved it correctly at runtime, exactly the `title` / `body`
// contradiction objectui#4970 fixed one level up in the same interface.
// plain-string CTA label is green whether or not the local
// `RecordAlertRendererProps` type admits a map — that's the
// `label: 'Send again'` case just above, and it cannot tell the widened
// declaration apart from the narrow one it replaced. This case can: a bare
// `label?: string` refused a locale-map value at the TYPE level even though
// `pickLocalized` (an `unknown` input) already resolved it correctly at
// runtime, exactly the `title` / `body` contradiction objectui#4970 fixed
// one level up in the same interface.
it('resolves an inline locale-map action.label to the active-language string (objectui#4998)', () => {
render(
<I18nProvider config={{ defaultLanguage: 'en', detectBrowserLanguage: false }}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ import { isConfigBag } from '@object-ui/react';

/**
* The config bag `record:alert` reads: the node's own keys UNDERNEATH (the
* legacy flat spelling `RecordAlertProps` tolerates) and `properties` on top.
* It is the sixth member of the `readProps()` family that
* legacy flat spelling `RecordAlertRendererProps` tolerates) and `properties`
* on top. It is the sixth member of the `readProps()` family that
* `packages/components/src/__tests__/alias-precedence-cross-channel.test.tsx`
* names, spelled differently — `{ ...schema, ...schema.properties }`, no
* `props` alias leg — and since objectui#6790 it asks the same question the
Expand Down
19 changes: 17 additions & 2 deletions packages/plugin-detail/src/renderers/record-alert.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,21 @@ type Severity = 'info' | 'warning' | 'error' | 'success';
/**
* Local (unexported) prop shape for the renderer below.
*
* ⚠️ Spelled `RecordAlertRendererProps`, NOT `RecordAlertProps`. At the RESOLVED
* `@objectstack/spec` pin, `@objectstack/spec/ui` exports `RecordAlertProps` —
* and a `Record<Block>Props` for every sibling block this directory renders —
* for the block's AUTHORED properties — the key set nested under
* `schema.properties` below, and nothing else. What this declaration holds is a
* different thing one level up: the React props this renderer is called with, a
* `schema` node plus `className` plus an open tail. Every other renderer beside
* this one already spells that distinction `…RendererProps`; this file did not,
* which is why its name was rule 1's last entry in the `check:spec-symbols` DEBT
* ledger (objectui#7265) while its structurally identical siblings were never
* in it. Both halves are pinned — that the spec still owns the plain name, and
* that it does not own this one — in this package's spec-symbol file, together
* with the sibling convention itself, re-derived from the directory rather than
* restated here.
*
* `title` / `body` accept the inline locale map as well as a plain string
* (objectui#4970): both are read through `pickLocalized` further down, and the
* block's published authoring surface declares the two arms
Expand All @@ -120,7 +135,7 @@ type Severity = 'info' | 'warning' | 'error' | 'success';
* (`plugin-detail/src/index.tsx`), so there is no manifest arm to align — that
* half stays parked on the `ComponentInput` member-shape question (PR #3795).
*/
interface RecordAlertProps {
interface RecordAlertRendererProps {
schema?: {
properties?: {
severity?: Severity;
Expand Down Expand Up @@ -169,7 +184,7 @@ const SEVERITY_STYLES: Record<Severity, { wrap: string; icon: string }> = {
},
};

export const RecordAlertRenderer: React.FC<RecordAlertProps> = ({ schema = {}, className }) => {
export const RecordAlertRenderer: React.FC<RecordAlertRendererProps> = ({ schema = {}, className }) => {
const props = readProps(schema);
const recordCtx = useRecordContext();
const record = recordCtx?.data;
Expand Down
30 changes: 22 additions & 8 deletions scripts/__tests__/check-spec-symbol-derivation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -741,9 +741,10 @@ describe('the guard file itself', () => {
* now judged structurally and none of them has an entry.
*
* The near neighbours are the point: `rendersJsx` must not become "functions are
* exempt" (that would silence `isContextToken` and `normalizeFilterOperator`,
* both real mirrors), and `isPureAlias` must not become "type aliases are
* exempt" (that would silence every hand-written union under a spec name).
* exempt" (that would have silenced `isContextToken` and
* `normalizeFilterOperator`, the two module-local functions the census above
* classified as real mirrors), and `isPureAlias` must not become "type aliases
* are exempt" (that would silence every hand-written union under a spec name).
*/
describe("rule 1 sees module-local declarations, and the narrowings say which it may not", () => {
const RULE1_SPEC_NAMES = new Map<string, Set<string>>([
Expand Down Expand Up @@ -790,11 +791,24 @@ const NavigationConfig = () => <span />;

it('…but a module-local FUNCTION that renders nothing is still a fork', () => {
// The near neighbour that keeps `rendersJsx` from decaying into
// `isRendererLike`. `isContextToken` (@object-ui/core) and
// `normalizeFilterOperator` (@object-ui/data-objectstack) are the live
// instances: non-exported functions under spec export names, both real
// mirrors, both DEBT entries today. A blanket "functions are renderers"
// would have made them invisible instead — silently, and for good.
// `isRendererLike`. On the objectui#6291 commit — the widening this block
// proves — the module-local functions the census classified as real mirrors
// were `isContextToken` (@object-ui/core) and `normalizeFilterOperator`
// (@object-ui/data-objectstack): non-exported functions under spec export
// names, and both went into the DEBT ledger. A blanket "functions are
// renderers" would have made them invisible instead — silently, and for
// good.
//
// ⚠️ Read that as the measurement it was, ⛔ not as a census of today's tree.
// objectui#7265 burned both names down — `isContextToken` BOUND to the
// spec's own export, so no local declaration of it survives anywhere under
// `packages/**/src/**`, and `normalizeFilterOperator` RENAMED to
// `toAstFilterOperator` because the two folds have different codomains. The
// fixture below is therefore the shape the narrowing was designed against,
// not a name you will find in the ledger; what has to keep holding is the
// narrowing, which is why the fixture is written out here instead of read
// off the tree. Whether any name is in the ledger at all is a question
// `--ledger` answers and this comment deliberately does not.
withFixture(
{
'predicate.ts': `
Expand Down
Loading
Loading