, refSchema, displayField) ||
String((item as any).id || (item as any)._id || '[Object]'),
- muted: false,
+ unresolved: false,
};
}
const r = resolveLabel(item);
- return { label: r.text, muted: r.muted };
+ return { label: r.text, unresolved: r.unresolved };
};
// Cap the chips the same way UserCellRenderer caps its avatars: a
@@ -2363,7 +2396,7 @@ export function LookupCellRenderer({ value, field }: CellRendererProps): React.R
return (
{visible.map((item, idx) => {
- const { label, muted } = itemDisplay(item);
+ const { label, unresolved } = itemDisplay(item);
// Each chip is one referenced record, so each links on its own —
// there is no single destination a multi-value cell could point at.
return (
@@ -2376,12 +2409,18 @@ export function LookupCellRenderer({ value, field }: CellRendererProps): React.R
- {label}
+ {/* The multi-value shape gets the same ruling as the scalar
+ one, one input-shape over (objectui#8695): a chip must not
+ be honest about an unresolved reference on one shape and
+ silent about it on the other. The chip's muted background
+ is unchanged — what changes is that the raw value survives
+ inside it instead of being replaced by `—`. */}
+ {unresolved ? : label}
);
@@ -2409,14 +2448,19 @@ export function LookupCellRenderer({ value, field }: CellRendererProps): React.R
);
}
- // Primitive value (e.g. raw ID): try options → resolver → opaque-ID placeholder → raw
- // The value IS the foreign key, so it addresses the record even when the
- // display name could not be resolved (the muted placeholder) — a reference
- // that is present but unnamed is still worth being able to open.
- const { text, muted } = resolveLabel(value);
+ // Primitive value (e.g. raw ID): try options → resolver → UNRESOLVED.
+ // The value IS the foreign key, so it addresses the record even when no
+ // display name could be resolved — a reference that is present but unnamed
+ // is still worth being able to open, which is why the affordance stays
+ // INSIDE the link rather than replacing it.
+ const { text, unresolved } = resolveLabel(value);
return (
-
+ {unresolved ? (
+
+ ) : (
+
+ )}
);
}
@@ -2492,11 +2536,13 @@ function UnresolvedUserReference({
}): React.ReactElement {
const t = useFieldTranslate();
const raw = String(value);
- // The key is written as a LITERAL at both sites on purpose:
+ // The key is written as a LITERAL at every site on purpose:
// `check:i18n-keys` judges a literal key against the `en` pack and checks
// that the arguments here are exactly the holes that value has, and it
// downgrades a key read from a constant to report-only. A shared constant
- // would have bought tidiness at the cost of the gate.
+ // would have bought tidiness at the cost of the gate — which is also why
+ // `UnresolvedLookupReference` below spells its own key out rather than
+ // taking one as a prop.
const translated = t?.('detail.unresolvedReference', { value: raw });
// Same provider-less rule `useFieldLabel` documents: i18next echoes the key
// when nothing resolves it, and the English fallback applies then. That
@@ -2506,6 +2552,107 @@ function UnresolvedUserReference({
!translated || translated === 'detail.unresolvedReference'
? `Unresolved reference: ${raw} was not resolved to a user`
: translated;
+ return ;
+}
+
+/**
+ * A `lookup` / `master_detail` / `tree` reference this screen did NOT resolve
+ * to a record (objectui#8695), carrying objectui#8434's ruling to the second
+ * renderer that had the same defect.
+ *
+ * ## The state this names, and how many causes hide behind it
+ *
+ * `LookupCellRenderer` reaches here when neither the author's `options` nor
+ * `useLookupName` produced a name. Measured on this base, that ONE seam is fed
+ * by at least six distinct causes, and the renderer can tell apart NONE of
+ * them — `useLookupName` returns `string | undefined`, so the
+ * `pending` / `err` / `ok` discriminator its own cache stores is dropped
+ * before any caller sees it:
+ *
+ * 1. never fetched — no `dataSource`, or no `reference_to` on the field;
+ * 2. IN FLIGHT — the first paint of every successful resolve passes through
+ * here (measured: the settled paint replaces it);
+ * 3. the resolver threw (`state: 'err'`);
+ * 4. the resolver answered with no record — "fetched and absent";
+ * 5. it answered with a record no display field could name;
+ * 6. not attempted BY POLICY — only the FIRST primitive of an array is
+ * auto-resolved (`primaryPrimitiveId`), so entries 2..n never ask.
+ *
+ * ⇒ the card's premise that this renderer "can genuinely distinguish 'fetched
+ * and absent' from 'never fetched'" is FALSE as the code stands. And even a
+ * hook that surfaced the discriminator could not upgrade the sentence: (3) and
+ * (4) also cover a record the VIEWER may not read, and "cannot read" versus
+ * "does not exist" is an existence-oracle boundary this lane does not cross
+ * (objectui#8631). What is true of all six is epistemic, and it is all this
+ * affordance says: this screen did not resolve it.
+ *
+ * ## Why the raw value stays, and the `—` does not
+ *
+ * ⛔ This deliberately does NOT keep the muted em-dash this arm used to draw
+ * for `isLikelyOpaqueId` strings. objectui#8434's triage named that treatment
+ * by name and ruled against it — the raw string "is the only clue for
+ * diagnosing existing dirty rows" — and the mother fix's own docblock says it
+ * again: that treatment buys tidiness by destroying the evidence. The tidiness
+ * it bought is real and it is the trade-off objectui#8695 flagged against
+ * itself; it is bought back by TRUNCATION, which hides the id without deleting
+ * it. The `—` also collided with `EmptyValue`'s glyph, so a cell with no value
+ * and a cell whose value failed to resolve read identically to a person.
+ *
+ * ⚠️ The sentence is a SIBLING key, not the `user` one: that pack value ends
+ * "was not resolved to a user", which is false on a `lookup` pointing at any
+ * other object, and it is pinned byte-for-byte by two existing tests.
+ */
+function UnresolvedLookupReference({
+ value,
+ className,
+}: {
+ value: unknown;
+ className?: string;
+}): React.ReactElement {
+ const t = useFieldTranslate();
+ const raw = String(value);
+ // Literal key — see `UnresolvedUserReference` above for what reading it
+ // from a constant would cost at `check:i18n-keys`.
+ const translated = t?.('detail.unresolvedLookupReference', { value: raw });
+ const hint =
+ !translated || translated === 'detail.unresolvedLookupReference'
+ ? `Unresolved reference: ${raw} was not resolved to a record on this screen`
+ : translated;
+ return ;
+}
+
+/**
+ * The shipped PRESENTATION of an unresolved reference, shared by the two
+ * renderers that state one (objectui#8434 for `user`, objectui#8695 for
+ * `lookup` / `master_detail` / `tree`).
+ *
+ * Only the drawing is shared. Each caller keeps its own literal i18n key and
+ * its own English fallback, because a key reaching this component as a prop
+ * would be a key `check:i18n-keys` can no longer judge — and because the two
+ * sentences are genuinely different claims: one is about a person, the other
+ * about a record of whatever object the lookup points at.
+ *
+ * ⚠️ No `pointer-events-none` here, unlike `EmptyValue`: that utility stops the
+ * span being a hit target, so a `title` on it never renders a tooltip
+ * (objectui#8506). The stated sentence has to be reachable by hovering.
+ *
+ * ⚠️ `truncate` on the inner span rather than the outer one, and the outer is
+ * `inline-flex`: `overflow: hidden` gives a flex item an automatic minimum
+ * size of zero, so the text shrinks and ellipsises instead of forcing the row
+ * wider. The full value stays reachable through the `title` sentence, which
+ * names it — that is how this shape meets objectui#3466's truncation contract
+ * (a single-line value must never expand its column and must expose its full
+ * text) with an icon in front of the text.
+ */
+function UnresolvedReferenceMark({
+ raw,
+ hint,
+ className,
+}: {
+ raw: string;
+ hint: string;
+ className?: string;
+}): React.ReactElement {
return (
` floor.
* - Footer: `created_by` / `updated_by` are always user references on
* ObjectStack; when the fetched schema omits the audit system fields the
- * footer must still render them through the reference renderer (which shows
- * a resolved name or a muted placeholder) — never the raw opaque id.
+ * footer must still render them through the REFERENCE RENDERER rather than
+ * degrading to a `text` cell that prints `String(value)`.
+ *
+ * ## The footer assertions were re-derived at objectui#8695 (PR objectui#9078)
+ *
+ * They used to read `expect(queryByText(OPAQUE_ID)).toBeNull()` — "the raw id
+ * must not appear". ⛔ That was never objectui#2688's ask, and it is now false.
+ *
+ * objectui#2688's own expected-correct column is `创建人 Dev Admin · 47分钟前`
+ * — the RESOLVED NAME — and its card records that the id it complains about
+ * DOES exist in `sys_user` with `name = Dev Admin`. In the scenario the card
+ * describes, the id disappears because it RESOLVES, not because anything hides
+ * it. The located defect the card names is the degradation itself:
+ * `objectSchema.fields.created_by` absent ⇒ `type:'text'` ⇒ `String(value)`.
+ * So "the id is absent" was only ever a PROXY for "this went through the
+ * reference renderer", read off the placeholder that renderer happened to draw
+ * when nothing resolved.
+ *
+ * The proxy was weak even then. Measured on this fixture: the old placeholder
+ * was a muted `—`, which is byte-identical to `EmptyValue`'s glyph, and a
+ * footer rendered with NO `created_by` at all omits the actor entirely — both
+ * satisfy `queryByText(OPAQUE_ID) === null`. The assertion could not tell the
+ * reference renderer from a blank cell.
+ *
+ * objectui#8434 then ruled on that placeholder directly: the affordance for an
+ * unresolved reference must be ADDITIVE (a stated marker, not an absence),
+ * EPISTEMIC ("this screen did not resolve it", never "not found"), and it must
+ * keep the raw value VISIBLE because it "is the only clue for diagnosing
+ * existing dirty rows". objectui#8695 carried that ruling to the second
+ * renderer with the same defect — the one this footer routes through.
+ *
+ * ⇒ The assertions below now name what objectui#2688 actually asked for, and
+ * name it directly instead of through a placeholder: the footer must render
+ * the audit actor through the reference renderer's unresolved affordance, not
+ * as a bare text cell. That is STRICTLY STRONGER than the assertion it
+ * replaces, which passed for an empty cell too.
*/
import { describe, it, expect } from 'vitest';
@@ -69,7 +103,15 @@ describe('DetailView header title — record-key probe before the Record # floor
describe('RecordMetaFooter — audit fields default to a sys_user reference (#2688)', () => {
const OPAQUE_ID = 'g3WkZnvugj4DnYw8u5Mo6ig3ljDhiFGO';
- it('never prints the raw created_by id when the schema omits the audit field', () => {
+ /**
+ * What a `text` cell would have printed: the bare string as the span's whole
+ * content, with no marker element around it. This is the degradation
+ * objectui#2688 located, and it is what these assertions refuse.
+ */
+ const renderedAsBareTextCell = (el: HTMLElement | null): boolean =>
+ el !== null && el.closest('[data-slot="unresolved-reference"]') === null;
+
+ it('routes created_by through the reference renderer when the schema omits the audit field', () => {
render(
,
);
expect(screen.getByTestId('record-meta-footer')).toBeInTheDocument();
- // Reference renderer shows a resolved name or a muted placeholder — the
- // opaque id itself must not leak into the footer text.
- expect(screen.queryByText(OPAQUE_ID)).toBeNull();
+
+ // Nothing in this fixture can resolve the reference (no dataSource, no
+ // options), so the renderer reaches its unresolved arm — and that arm is
+ // the observable proof the value did NOT degrade to a `text` cell.
+ const mark = document.querySelector('[data-slot="unresolved-reference"]');
+ expect(mark).not.toBeNull();
+
+ // objectui#8434: additive and epistemic. The sentence must be reachable
+ // (it rides on `title`) and must state non-resolution, not absence.
+ expect(mark?.getAttribute('title')).toContain(OPAQUE_ID);
+ expect(mark?.getAttribute('title')).toMatch(/not resolved/i);
+
+ // objectui#8434: the raw value STAYS — it is the only clue for diagnosing
+ // an existing dirty row — and it stays INSIDE the affordance, which is
+ // exactly the distinction the retired `queryByText(...).toBeNull()` could
+ // not draw.
+ expect(renderedAsBareTextCell(screen.queryByText(OPAQUE_ID))).toBe(false);
});
it('still honours an explicit audit-field definition from the schema', () => {
@@ -93,6 +149,7 @@ describe('RecordMetaFooter — audit fields default to a sys_user reference (#26
objectName="production_plan"
/>,
);
- expect(screen.queryByText(OPAQUE_ID)).toBeNull();
+ expect(document.querySelector('[data-slot="unresolved-reference"]')).not.toBeNull();
+ expect(renderedAsBareTextCell(screen.queryByText(OPAQUE_ID))).toBe(false);
});
});