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
15 changes: 15 additions & 0 deletions .changeset/24944-export-lookup-title-field.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@objectstack/rest': patch
---

fix(rest): a lookup cell in the list export names the record by the target object's title field (#24944)

Clause-②: no

`GET /api/v1/data/:object/export` (Display → Export as CSV / XLSX) wrote a lookup, master-detail, user or tree cell from the lookup field's own `displayField`. Without one, it took the first key of a fixed list (`name`, `title`, `label`, `full_name`, …) found on the expanded record. It never read the target object's `nameField`. So a contact that declares `nameField: 'full_name'` and has no `name` column exported as its job `title` ("VP of Engineering" instead of "John Smith").

- **Now.** The export reads each target object's title field once per export, by ADR-0079's `resolveDisplayField`: the `nameField`, then the deprecated `displayNameField` alias. The registry sets that pointer on every object it holds. The cell shows that field's value from the expanded record. This applies to csv, xlsx and json.
- **Precedence.** The lookup field's own `displayField` still wins when it is set. The key list is used only when the target resolves no title field, or when its schema cannot be read.
- **Field-level security.** When the caller cannot read the target's title field, the `$expand` read leaves it off the record. The cell then shows the record's id. Another column, such as the job title, no longer stands in for the name.
- **Visible change.** A target with a literal `name` column and a different declared title now exports the declared title. For example, an account with `nameField: 'display_title'` exports its display title instead of `name`.
- **Unchanged.** No query parameter, header, column set or payload shape moves. Only the text of a lookup cell changes.
39 changes: 26 additions & 13 deletions packages/rest/src/data-action-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,8 @@ import {
formatRowForJson,
cellFontColor,
exportContentDisposition,
type ExportFieldMeta,
withReferenceTitleFields,
type ExportCellMeta,
} from './export-format.js';
import { importControlOptOutRefusal, prepareImportRequest } from './import-prepare.js';
import { readTemplateMode, templateFieldDefs } from './import-template.js';
Expand Down Expand Up @@ -1086,27 +1087,32 @@ export function registerDataActionEndpoints(this: DataActionRouteHost, basePath:
// labels, 是/否, formatted dates) and the $expand that resolves
// references. Best-effort: when the schema is unavailable the export
// falls back to raw values, byte-identical to the un-formatted path.
let metaMap = new Map<string, ExportFieldMeta>();
let metaMap = new Map<string, ExportCellMeta>();
// Localized object display label (e.g. 合同) — drives the
// suggested download filename below.
let objectLabel: string | undefined;
try {
// Field metadata comes from the same place `findData` resolves
// the object: `getMetaItem` is registry-first (DB fallback), so
// it returns the live `ObjectSchema` whose `fields` is an object
// map. The read hands back the envelope `{ type, name, item }`
// — one shape, unconditionally, since #5563 — so the schema
// document is read straight off `.item`. Legacy
// `getObjectSchema` is consulted as a last resort so existing
// test doubles keep working.
// Field metadata comes from the same place `findData` resolves
// the object: `getMetaItem` is registry-first (DB fallback), so
// it returns the live `ObjectSchema` whose `fields` is an object
// map. The read hands back the envelope `{ type, name, item }`
// — one shape, unconditionally, since #5563 — so the schema
// document is read straight off `.item`. Legacy
// `getObjectSchema` is consulted as a last resort so existing
// test doubles keep working. The same read serves the export's
// object and each reference field's target object below.
const readObjectSchema = async (name: string): Promise<any> => {
let schema: any = undefined;
if (typeof (p as any).getMetaItem === 'function') {
const res: any = await (p as any).getMetaItem({ type: 'object', name: objectName });
const res: any = await (p as any).getMetaItem({ type: 'object', name });
schema = res?.item;
}
if (!schema && typeof (p as any).getObjectSchema === 'function') {
schema = await (p as any).getObjectSchema(objectName, environmentId);
schema = await (p as any).getObjectSchema(name, environmentId);
}
return schema;
};
try {
let schema: any = await readObjectSchema(objectName);
// Localize field labels to the request locale (Accept-Language /
// `?locale=`) the same way the metadata endpoints do, so the
// export header row matches the UI column headers instead of
Expand Down Expand Up @@ -1134,6 +1140,13 @@ export function registerDataActionEndpoints(this: DataActionRouteHost, basePath:
}
} catch { /* fall back to first-row derivation + raw values */ }

// A lookup cell names the referenced record by the TARGET
// object's title field (ADR-0079), read once per target here and
// handed to the formatter on the field meta. Best-effort per
// target, never throws: an unreadable target schema keeps that
// column's key-list fallback.
metaMap = await withReferenceTitleFields(metaMap, readObjectSchema);

// Expand reference fields so lookup/user ids resolve to their record
// (and thus a name). Batched $in inside findData — no N+1.
const expandFields = referenceFieldNames(metaMap);
Expand Down
103 changes: 93 additions & 10 deletions packages/rest/src/export-format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,29 @@
*/

import { temporalStorageForm, type ExportFieldMeta } from '@objectstack/core';
import { resolveDisplayField } from '@objectstack/spec/data';

// `ExportFieldMeta` and `buildFieldMetaMap` live in `@objectstack/core`
// (`utils/import-field-meta.ts`), beside the import runner that reads them.
export { buildFieldMetaMap, type ExportFieldMeta } from '@objectstack/core';

/**
* The field metadata the export renderers read: core's {@link ExportFieldMeta}
* plus one export-only key, the title field of a reference field's TARGET
* object. It is resolved by the route ({@link withReferenceTitleFields}), never
* by the formatter, so the cell renderer holds no title rule of its own.
*/
export interface ExportCellMeta extends ExportFieldMeta {
/**
* The field that titles a record of this reference field's target object:
* ADR-0079's `resolveDisplayField` over the target schema the protocol
* serves (`nameField`, then the deprecated `displayNameField` alias — the
* pointer the registry designates on every object it holds). `undefined`
* when the target schema could not be read or resolves no title.
*/
titleField?: string;
}

/**
* Build the `Content-Disposition` header for an export download.
*
Expand Down Expand Up @@ -102,7 +120,9 @@ const MULTI_OPTION_TYPES = new Set(['multiselect', 'checkboxes', 'tags']);

/**
* Keys tried, in order, to derive a referenced record's display value when the
* field carries no explicit `displayField`.
* field carries no explicit `displayField` AND the target object resolves no
* title field ({@link ExportCellMeta.titleField}). Only a target with no title
* pointer reaches this list: a declared title is never second-guessed by it.
*/
const NAME_KEY_FALLBACKS = [
'name', 'title', 'label', 'full_name', 'fullName', 'display_name', 'username', 'email',
Expand All @@ -121,6 +141,50 @@ export function referenceFieldNames(metaMap: Map<string, ExportFieldMeta>): stri
return out;
}

/**
* Stamp every reference field's {@link ExportCellMeta.titleField}: the title
* field of its target object, read once per distinct target per export.
*
* The rule is ADR-0079's `resolveDisplayField`, the spec's single arbiter of
* "which field titles a record of this object" — the same function the
* registry designates `nameField` with at registration, so over a schema the
* protocol serves it answers that designated pointer. A lookup cell therefore
* names the record the way the record names itself, rather than by whichever
* key of a fixed list the expanded record happens to carry (a contact's job
* `title` ahead of its `full_name` title).
*
* Best-effort, per target: a schema read that throws or returns nothing leaves
* that target's fields without a `titleField`, so their cells keep the
* key-list fallback exactly as before. Never throws.
*/
export async function withReferenceTitleFields(
metaMap: Map<string, ExportFieldMeta>,
readSchema: (object: string) => Promise<unknown>,
): Promise<Map<string, ExportCellMeta>> {
const titles = new Map<string, Promise<string | undefined>>();
const titleOf = (target: string): Promise<string | undefined> => {
let pending = titles.get(target);
if (!pending) {
pending = readSchema(target).then(
(schema) => (schema && typeof schema === 'object' ? resolveDisplayField(schema as never) : undefined),
() => undefined,
);
titles.set(target, pending);
}
return pending;
};
const out = new Map<string, ExportCellMeta>();
for (const [key, meta] of metaMap) {
if (meta.type && REFERENCE_TYPES.has(meta.type) && meta.reference) {
const titleField = await titleOf(meta.reference);
out.set(key, titleField ? { ...meta, titleField } : meta);
} else {
out.set(key, meta);
}
}
return out;
}

/** Header label for a column: schema label when present, else the field name. */
export function headerLabel(field: string, metaMap: Map<string, ExportFieldMeta>): string {
return metaMap.get(field)?.label || field;
Expand Down Expand Up @@ -333,19 +397,38 @@ export function cellFontColor(value: unknown, meta?: ExportFieldMeta): string |
return toArgb(hit?.color);
}

function displayFromRecord(rec: Record<string, unknown>, displayField?: string): string {
/**
* The display text of one expanded referenced record.
*
* In order: the lookup field's own `displayField` (the field author's explicit
* choice); else the target object's resolved `titleField`; else — only for a
* target that resolves no title — the first scalar hit of
* {@link NAME_KEY_FALLBACKS}; else the record's id.
*
* A target that HAS a title field never consults the key list, also when the
* title is absent from this record: field-level security deletes an unreadable
* field from the expanded record, and the key list would then put another
* column in the name's place (a contact named by its job title). The id is the
* cell's floor instead.
*/
function displayFromRecord(rec: Record<string, unknown>, displayField?: string, titleField?: string): string {
if (displayField && rec[displayField] != null) return String(rec[displayField]);
for (const k of NAME_KEY_FALLBACKS) {
const v = rec[k];
if (titleField) {
const v = rec[titleField];
if (v != null && typeof v !== 'object') return String(v);
} else {
for (const k of NAME_KEY_FALLBACKS) {
const v = rec[k];
if (v != null && typeof v !== 'object') return String(v);
}
}
if (rec.id != null) return String(rec.id);
try { return JSON.stringify(rec); } catch { return String(rec); }
}

function formatReference(value: unknown, displayField?: string): unknown {
function formatReference(value: unknown, displayField?: string, titleField?: string): unknown {
const one = (v: unknown): unknown =>
v && typeof v === 'object' ? displayFromRecord(v as Record<string, unknown>, displayField) : v;
v && typeof v === 'object' ? displayFromRecord(v as Record<string, unknown>, displayField, titleField) : v;
if (Array.isArray(value)) return value.map(one).join(', ');
return one(value);
}
Expand All @@ -359,7 +442,7 @@ function formatReference(value: unknown, displayField?: string): unknown {
*/
export function formatCellValue(
value: unknown,
meta?: ExportFieldMeta,
meta?: ExportCellMeta,
timezone?: string,
): unknown {
if (value === null || value === undefined) return value;
Expand All @@ -377,7 +460,7 @@ export function formatCellValue(
}
if (t === 'date') return formatDate(value, false);
if (t === 'datetime') return formatDate(value, true, timezone);
if (REFERENCE_TYPES.has(t)) return formatReference(value, meta.displayField);
if (REFERENCE_TYPES.has(t)) return formatReference(value, meta.displayField, meta.titleField);
return value;
}

Expand All @@ -390,7 +473,7 @@ export function formatCellValue(
export function formatRowCells(
row: Record<string, unknown>,
fields: string[],
metaMap: Map<string, ExportFieldMeta>,
metaMap: Map<string, ExportCellMeta>,
timezone?: string,
): unknown[] {
return fields.map((f) => formatCellValue(row?.[f], metaMap.get(f), timezone));
Expand All @@ -403,7 +486,7 @@ export function formatRowCells(
*/
export function formatRowForJson(
row: Record<string, unknown>,
metaMap: Map<string, ExportFieldMeta>,
metaMap: Map<string, ExportCellMeta>,
timezone?: string,
): Record<string, unknown> {
if (metaMap.size === 0 || !row || typeof row !== 'object') return row;
Expand Down
Loading
Loading