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
25 changes: 4 additions & 21 deletions packages/app-shell/src/views/RecordDetailView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ import { decisionOutputDefs, decisionOutputParams, foldDecisionOutputs } from '.
import { interpretActionResponse } from '../utils/actionResponse';
import { interpretFlowResponse } from '../utils/flowResponse';
import { useRecordBreadcrumbTitle } from '../context/NavigationContext';
// Audit provenance renders as the one-line <RecordMetaFooter>; the other
// framework-injected bookkeeping columns are hidden from the body outright.
// Both sets are derived, not restated — see record-detail-system-fields.ts.
import { AUDIT_FIELD_NAMES, HIDDEN_SYSTEM_FIELD_NAMES } from './record-detail-system-fields';
import type { FeedItem } from '@object-ui/types';
import type { ActionDef, ActionParamDef } from '@object-ui/core';
import { useRecordApprovals, recordLockedByApproval } from '../hooks/useRecordApprovals';
Expand Down Expand Up @@ -107,27 +111,6 @@ export function resolveActionUser(
: identity;
}

/**
* Audit field names auto-injected by the framework's `applySystemFields`.
* Filtered out of the auto-generated body sections — they are rendered
* separately as a single subtle one-line `<RecordMetaFooter>` (see
* `@object-ui/plugin-detail`) so provenance stays discoverable without a
* heavy "System Information" panel. The inline-edit drawer also hides
* them via `DEFAULT_SYSTEM_FIELDS` in
* `@object-ui/plugin-detail/RecordDetailDrawer`.
*/
const AUDIT_FIELD_NAMES = new Set(['created_at', 'created_by', 'updated_at', 'updated_by']);

/**
* System/tenant fields that the framework auto-injects on every record but
* which carry no business value on a detail page. Hidden from the
* auto-generated sections. Authors who really want to surface one can
* assign it to a `fieldGroups` group explicitly (explicit listing wins).
*/
const HIDDEN_SYSTEM_FIELD_NAMES = new Set([
'organization_id', 'tenant_id', 'is_deleted', 'deleted_at',
]);

/**
* Field-type signals that suggest a "secondary / system / metadata"
* placement when auto-grouping fields. These move out of the main
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,41 +18,18 @@
* is destroyed.
*/

import { DESIGNER_FIELD_TYPES } from '@object-ui/types';
import type {
DesignerFieldDefinition,
DesignerFieldType,
} from '@object-ui/types';

/** Set of types the designer can edit losslessly. Keep in sync with FieldDesigner. */
const DESIGNER_TYPES = new Set<DesignerFieldType>([
'text',
'textarea',
'number',
'boolean',
'date',
'datetime',
'time',
'select',
'email',
'phone',
'url',
'password',
'currency',
'percent',
'lookup',
'formula',
'autonumber',
'file',
'image',
'markdown',
'html',
'color',
'code',
'location',
'address',
'rating',
'slider',
]);
/**
* Set of types the designer can edit losslessly — derived from the canonical
* `DESIGNER_FIELD_TYPES` vocabulary rather than restated (objectui#3017), so
* this bridge and `FieldDesigner`'s palette cannot drift by construction.
*/
const DESIGNER_TYPES: ReadonlySet<DesignerFieldType> = new Set(DESIGNER_FIELD_TYPES);

interface FrameworkFieldDef {
type?: string;
Expand Down
51 changes: 51 additions & 0 deletions packages/app-shell/src/views/record-detail-system-fields.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/**
* 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.
*/

/**
* **The detail page's system-field partition is derived, not restated**
* (objectui#3017). `HIDDEN_SYSTEM_FIELD_NAMES` used to hard-code four
* bookkeeping columns under a comment; it is now the spec's
* `FIELD_GROUP_SYSTEM_FIELDS` minus the audit set, so a system column the
* framework starts injecting tomorrow defaults to hidden instead of leaking
* into the auto-generated body sections until someone edits a hand list.
*
* What is worth asserting is that the partition stays a partition, and that
* the columns each side exists for are still on that side.
*/

import { describe, it, expect } from 'vitest';
import { FIELD_GROUP_SYSTEM_FIELDS } from '@objectstack/spec/data';
import { AUDIT_FIELD_NAMES, HIDDEN_SYSTEM_FIELD_NAMES } from './record-detail-system-fields';

describe('record-detail system-field partition (objectui#3017)', () => {
it('audit ∪ hidden covers the spec vocabulary exactly — nothing leaks into the body', () => {
const union = [...AUDIT_FIELD_NAMES, ...HIDDEN_SYSTEM_FIELD_NAMES].sort();
expect(union).toEqual([...FIELD_GROUP_SYSTEM_FIELDS].sort());
});

it('audit and hidden are disjoint — a column gets one treatment, not two', () => {
for (const name of AUDIT_FIELD_NAMES) {
expect(HIDDEN_SYSTEM_FIELD_NAMES.has(name), `'${name}' is both footer-rendered and hidden`).toBe(false);
}
});

it('is not vacuous — the bookkeeping columns the hidden set exists for are still hidden', () => {
// If the spec dropped or renamed one of these, the derivation would
// silently stop hiding it and the column would surface as a raw field on
// every detail page. Fail loudly and make that a deliberate decision.
for (const name of ['organization_id', 'tenant_id', 'is_deleted', 'deleted_at']) {
expect(HIDDEN_SYSTEM_FIELD_NAMES.has(name), `'${name}' no longer derived as hidden — spec vocabulary moved?`).toBe(true);
}
});

it('provenance stays in the footer, never silently swallowed by the hidden set', () => {
for (const name of ['created_at', 'created_by', 'updated_at', 'updated_by']) {
expect(AUDIT_FIELD_NAMES.has(name)).toBe(true);
}
});
});
37 changes: 37 additions & 0 deletions packages/app-shell/src/views/record-detail-system-fields.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/**
* 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.
*/

/**
* How `RecordDetailView` partitions the framework-injected system columns
* (spec `FIELD_GROUP_SYSTEM_FIELDS`) between its two treatments:
*
* - the four audit-provenance columns (`AUDIT_FIELD_NAMES`, single source in
* `@object-ui/types`) are rendered as a single subtle one-line
* `<RecordMetaFooter>` (see `@object-ui/plugin-detail`) so provenance stays
* discoverable without a heavy "System Information" panel;
* - every other system column is hidden from the auto-generated body
* sections outright — tenancy / soft-delete bookkeeping carries no business
* value on a detail page. Authors who really want to surface one can
* assign it to a `fieldGroups` group explicitly (explicit listing wins).
*
* `HIDDEN_SYSTEM_FIELD_NAMES` is derived as spec-set minus audit-set rather
* than restated (objectui#3017): a system column the framework starts
* injecting tomorrow is bookkeeping until someone deliberately surfaces it,
* so it should default to hidden without waiting for a hand-list edit here.
* `record-detail-system-fields.test.ts` pins the partition's invariants.
*/

import { AUDIT_FIELD_NAMES } from '@object-ui/types';
import { FIELD_GROUP_SYSTEM_FIELDS } from '@objectstack/spec/data';

export { AUDIT_FIELD_NAMES };

/** Framework-injected bookkeeping columns hidden from detail body sections. */
export const HIDDEN_SYSTEM_FIELD_NAMES: ReadonlySet<string> = new Set(
[...FIELD_GROUP_SYSTEM_FIELDS].filter((name) => !AUDIT_FIELD_NAMES.has(name)),
);
13 changes: 9 additions & 4 deletions packages/plugin-designer/src/FieldDesigner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,11 +102,16 @@ const FIELD_TYPE_META: Record<DesignerFieldType, { label: string; Icon: React.FC
slider: { label: 'Slider', Icon: SlidersHorizontal },
};

const ALL_FIELD_TYPES = Object.keys(FIELD_TYPE_META) as DesignerFieldType[];

type FieldTypeCategory = 'text' | 'number' | 'date' | 'choice' | 'relation' | 'advanced';

const FIELD_TYPE_CATEGORIES: Record<FieldTypeCategory, DesignerFieldType[]> = {
/**
* Category partition of the designer vocabulary, driving the type filter and
* the drawer's type `<select>`. Exported (with {@link CATEGORY_ORDER}) so
* `fieldTypeCategories.test.ts` can assert it stays a complete partition of
* `DESIGNER_FIELD_TYPES` — a type added to the vocabulary but left out here
* would otherwise silently vanish from those pickers (objectui#3017).
*/
export const FIELD_TYPE_CATEGORIES: Record<FieldTypeCategory, DesignerFieldType[]> = {
text: ['text', 'textarea', 'email', 'phone', 'url', 'password', 'markdown', 'html'],
number: ['number', 'currency', 'percent', 'autonumber', 'rating', 'slider'],
date: ['date', 'datetime', 'time'],
Expand All @@ -115,7 +120,7 @@ const FIELD_TYPE_CATEGORIES: Record<FieldTypeCategory, DesignerFieldType[]> = {
advanced: ['formula', 'file', 'image', 'color', 'code', 'location', 'address'],
};

const CATEGORY_ORDER: FieldTypeCategory[] = ['text', 'number', 'date', 'choice', 'relation', 'advanced'];
export const CATEGORY_ORDER: FieldTypeCategory[] = ['text', 'number', 'date', 'choice', 'relation', 'advanced'];


// ============================================================================
Expand Down
9 changes: 3 additions & 6 deletions packages/plugin-designer/src/MetadataFieldsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
*/

import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { DESIGNER_FIELD_TYPES } from '@object-ui/types';
import type { DesignerFieldDefinition, DesignerFieldType } from '@object-ui/types';
import { MetadataClient, type MetadataClientConfig } from '@object-ui/data-objectstack';
import { FieldDesigner } from './FieldDesigner';
Expand Down Expand Up @@ -68,12 +69,8 @@ interface ServerObjectSchema {
[key: string]: unknown;
}

const KNOWN_FIELD_TYPES = new Set<DesignerFieldType>([
'text', 'textarea', 'number', 'boolean', 'date', 'datetime', 'time', 'select',
'email', 'phone', 'url', 'password', 'currency', 'percent', 'lookup', 'formula',
'autonumber', 'file', 'image', 'markdown', 'html', 'color', 'code', 'location',
'address', 'rating', 'slider',
]);
// Derived from the canonical vocabulary rather than restated (objectui#3017).
const KNOWN_FIELD_TYPES: ReadonlySet<DesignerFieldType> = new Set(DESIGNER_FIELD_TYPES);

function toDesignerType(raw: string | undefined): DesignerFieldType {
if (raw && KNOWN_FIELD_TYPES.has(raw as DesignerFieldType)) {
Expand Down
34 changes: 34 additions & 0 deletions packages/plugin-designer/src/__tests__/fieldTypeCategories.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/**
* 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.
*/

/**
* `FIELD_TYPE_CATEGORIES` partitions the designer vocabulary for the type
* filter and the drawer's type `<select>`. tsc guarantees every entry IS a
* `DesignerFieldType` (and that `FIELD_TYPE_META` covers the whole union),
* but nothing at compile time guarantees every type LANDS in a category — a
* freshly added type would silently be missing from those pickers. This is
* that gate (objectui#3017).
*/

import { describe, it, expect } from 'vitest';
import { DESIGNER_FIELD_TYPES } from '@object-ui/types';
import { CATEGORY_ORDER, FIELD_TYPE_CATEGORIES } from '../FieldDesigner';

describe('FIELD_TYPE_CATEGORIES covers the designer vocabulary (objectui#3017)', () => {
const flattened = Object.values(FIELD_TYPE_CATEGORIES).flat();

it('every DesignerFieldType appears in exactly one category', () => {
expect(new Set(flattened).size, 'a type appears in two categories').toBe(flattened.length);
expect([...flattened].sort()).toEqual([...DESIGNER_FIELD_TYPES].sort());
});

it('CATEGORY_ORDER renders every category — a dropped entry hides its whole group', () => {
expect([...CATEGORY_ORDER].sort()).toEqual(Object.keys(FIELD_TYPE_CATEGORIES).sort());
expect(new Set(CATEGORY_ORDER).size).toBe(CATEGORY_ORDER.length);
});
});
13 changes: 5 additions & 8 deletions packages/plugin-detail/src/RecordMetaFooter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,16 @@
import * as React from 'react';
import { cn, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@object-ui/components';
import { getCellRenderer, resolveCellRendererType } from '@object-ui/fields';
import { AUDIT_FIELD_BY_ROLE } from '@object-ui/types';
import type { FieldMetadata } from '@object-ui/types';
import { useDetailTranslation } from './useDetailTranslation';

/**
* Audit field names auto-injected by the framework's `applySystemFields`.
* Kept in sync with `AUDIT_FIELD_NAMES` in `RecordDetailView`.
* Audit field names auto-injected by the framework's `applySystemFields` —
* the shared vocabulary `RecordDetailView` also uses to keep these out of the
* body sections (single source in `@object-ui/types`, objectui#3017).
*/
const AUDIT_FIELDS = {
createdAt: 'created_at',
createdBy: 'created_by',
updatedAt: 'updated_at',
updatedBy: 'updated_by',
} as const;
const AUDIT_FIELDS = AUDIT_FIELD_BY_ROLE;

export interface RecordMetaFooterProps {
/** The current record data; expected to contain audit fields when available. */
Expand Down
1 change: 1 addition & 0 deletions packages/plugin-grid/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"@object-ui/permissions": "workspace:*",
"@object-ui/react": "workspace:*",
"@object-ui/types": "workspace:*",
"@objectstack/spec": "^17.0.0-rc.0",
"@tanstack/react-virtual": "^3.14.8",
"exceljs": "^4.4.0",
"lucide-react": "^1.25.0"
Expand Down
16 changes: 1 addition & 15 deletions packages/plugin-grid/src/ImportWizard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
import { Upload, FileSpreadsheet, CheckCircle2, AlertCircle, X, ArrowRight, ArrowLeft, Save, Trash2, ClipboardPaste, Download, Undo2 } from 'lucide-react';
import { useObjectTranslation } from '@object-ui/react';
import { sanitizeFileNameBase } from '@object-ui/core';
import { BOOLEAN_IMPORT_TOKENS, REFERENCE_IMPORT_TYPES } from './importCoercionContract';
import type {
DataSource,
ImportRequestOptions,
Expand Down Expand Up @@ -361,21 +362,6 @@ const CONFIDENCE_CLASS: Record<MappingConfidence, string> = {
low: 'text-muted-foreground',
};

/** Boolean tokens the server's import coercion accepts (import-coerce.ts).
* Kept in sync so the preview step doesn't flag a cell the server would take
* (e.g. Chinese 是/否, on/off, ✓/×). Compared case-insensitively. */
const BOOLEAN_IMPORT_TOKENS = new Set([
'true', 't', 'yes', 'y', '1', 'on', '是', '对', '✓', '√',
'false', 'f', 'no', 'n', '0', 'off', '否', '错', '✗', '×',
]);

/** Field types the server resolves from display text to record IDs during
* `/import` (kept in step with the server's import-coerce REFERENCE_TYPES).
* The legacy per-row create fallback has no resolution step — raw cell text
* would be stored verbatim into relation fields — so the fallback must refuse
* to run when any mapped column targets one of these types. */
const REFERENCE_IMPORT_TYPES = new Set(['lookup', 'master_detail', 'user', 'reference', 'tree']);

/** Mapped fields whose type the legacy fallback cannot import safely. */
function mappedReferenceFields(
mapping: Record<number, string>,
Expand Down
47 changes: 47 additions & 0 deletions packages/plugin-grid/src/hooks/multiValueFields.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/**
* 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.
*/

/**
* `isMultiValueField` consumes the spec's `MULTI_OPTION_TYPES` /
* `MULTI_CAPABLE_TYPES` instead of restating them (objectui#3017). The
* anchors below are the non-vacuity gate: if the spec vocabulary silently
* shrank, `normalizeMultiValuePatch` would stop wrapping lone scalars for
* that type and the column-shape corruption of #2204 would return — fail
* loudly here instead.
*/

import { describe, it, expect } from 'vitest';
import { isMultiValueField, normalizeMultiValuePatch } from './multiValueFields';

describe('isMultiValueField (spec-derived, objectui#3017)', () => {
it('always-multi option types stay covered', () => {
for (const type of ['multiselect', 'checkboxes', 'tags']) {
expect(isMultiValueField({ type }), `'${type}' no longer always-multi — spec vocabulary moved?`).toBe(true);
}
});

it('multi-capable types stay covered when flagged multiple', () => {
for (const type of ['select', 'radio', 'lookup', 'user', 'file', 'image']) {
expect(isMultiValueField({ type, multiple: true }), `'${type}' no longer multi-capable — spec vocabulary moved?`).toBe(true);
expect(isMultiValueField({ type }), `'${type}' must stay single-value without the flag`).toBe(false);
}
});

it('stays null-tolerant and conservative for everything else', () => {
expect(isMultiValueField(undefined)).toBe(false);
expect(isMultiValueField(null)).toBe(false);
expect(isMultiValueField({})).toBe(false);
expect(isMultiValueField({ type: 'text', multiple: true })).toBe(false);
});

it('normalizeMultiValuePatch still wraps a lone scalar for a spec-classified field', () => {
const fields = { assignees: { type: 'user', multiple: true } };
expect(normalizeMultiValuePatch({ assignees: 'u1' }, fields)).toEqual({ assignees: ['u1'] });
expect(normalizeMultiValuePatch({ assignees: ['u1'] }, fields)).toEqual({ assignees: ['u1'] });
});
});
Loading
Loading