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
47 changes: 47 additions & 0 deletions .changeset/adr-0085-2548-detail-ux-followups.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
---
'@object-ui/plugin-detail': patch
'@object-ui/fields': patch
'@object-ui/i18n': patch
'@object-ui/app-shell': patch
---

Detail-page UX follow-ups from the ADR-0085 PR4 real-backend browser pass (framework#2548):

- **Highlight strip no longer repeats the record title.** A declared
`highlightFields` list containing the title field rendered it as the first
chip — truncated — directly under the identical page H1. `deriveHighlightFields`
now resolves the title (`primaryField` / `nameField` / deprecated
`displayNameField`, else the conventional display-field names) via the new
exported `resolveTitleField` and filters it from declared lists before the
4-chip cap, matching what the heuristic branch always did. app-shell's
`RecordDetailView` synthParts (which pre-computes the list and bypasses the
derivation) applies the same filter.
- **Per-field currency reaches the renderers.** The spec channel
(`currencyConfig.defaultCurrency`) was dropped by the highlight-strip and
detail-section field enrichment, so a spec-authored currency field could
never show its symbol ("25,000,000" instead of "$25,000,000");
`resolveFieldCurrency` reads it second after the designer-only bare
`currency` key.
- **app-shell approvals fetches send the Bearer token.** The header badge
poll, home-inbox count, and record-page approvals panel were cookie-only
(new shared `bearerAuthHeaders()` util) — same split-origin failure mode as
the console `approvalsApi` fix below.
- **`fieldGroups[].icon` / `description` reach detail pages.** The shared
derivation (ADR-0085 §5) already passed them through; the detail synth
dropped them. Sections now carry both, and `DetailSection` renders a real
Lucide icon for identifier-shaped names (emoji/text values keep the
historical text rendering).
- **Record meta footer stops dangling without an actor.** Seeded/system rows
with `created_by: null` rendered "Created by · 10m ago"; the footer now
falls back to actor-less labels ("Created / Updated"), with new i18n keys in
all six locales (and the zh `createdBy`/`updatedBy` mistranslation fixed:
创建人/更新人, not 创建于/更新于).
- **Select badges ellipsize instead of clipping mid-glyph.** In bounded
containers (highlight-strip columns, grid cells) an overlong option label
used to be cut at the container edge ("Technolog…"); badges now shrink with
an inner truncate and expose the full label as a hover title. The highlight
strip's hover title also prefers the option label over the raw stored value.

Console app (unversioned): `approvalsApi` now sends the stored Bearer token
like every other console call — cookie-only auth silently lost the approvals
surface on split-origin deployments where the SameSite cookie doesn't flow.
14 changes: 12 additions & 2 deletions apps/console/src/services/approvalsApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,16 @@
* Approvals REST helper.
*
* Thin fetch wrapper around the framework's approval endpoints
* (`/api/v1/approvals/*`). Sends cookies for auth.
* (`/api/v1/approvals/*`). Auth mirrors the rest of the console: the stored
* Bearer token when present, plus cookies. Cookie-only auth silently lost
* the approvals surface on split-origin deployments (custom-domain console ↔
* API, or a dev console pointed at a remote backend) where the SameSite
* cookie never flows — every other console call already sends the Bearer.
*
* Mirrors the shape exposed by `@objectstack/plugin-approvals` /
* `packages/rest/src/rest-server.ts`.
*/
import { TokenStorage } from '@object-ui/auth';

const SERVER_URL = (import.meta.env.VITE_SERVER_URL || '').replace(/\/$/, '');
const API_BASE = `${SERVER_URL}/api/v1`;
Expand Down Expand Up @@ -73,9 +78,14 @@ export interface ApprovalActionRow {
}

async function call<T>(path: string, init?: RequestInit): Promise<T> {
const token = TokenStorage.get();
const res = await fetch(`${API_BASE}${path}`, {
credentials: 'include',
headers: { 'Content-Type': 'application/json', ...(init?.headers || {}) },
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(init?.headers || {}),
},
...init,
});
let payload: any = null;
Expand Down
7 changes: 6 additions & 1 deletion packages/app-shell/src/hooks/useHomeInbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import { useEffect, useRef, useState } from 'react';
import { useAdapter } from '../providers/AdapterProvider';
import { useAuth } from '@object-ui/auth';
import { bearerAuthHeaders } from '../utils/authToken';
import type { ActivityItem } from '../layout/ActivityFeed';

export interface HomeNotification {
Expand Down Expand Up @@ -134,7 +135,11 @@ export function useHomeInbox(limit = 5): HomeInboxData {
if (identities.length === 0) return;
let cancelled = false;
const qs = new URLSearchParams({ status: 'pending', approverId: identities.join(',') });
fetch(`${serverUrl}/api/v1/approvals/requests?${qs}`, { credentials: 'include' })
fetch(`${serverUrl}/api/v1/approvals/requests?${qs}`, {
credentials: 'include',
// Bearer too — see utils/authToken (#2548 split-origin fix).
headers: bearerAuthHeaders(),
})
.then(async (res) => {
if (!res.ok) return;
const payload = await res.json().catch(() => null);
Expand Down
9 changes: 8 additions & 1 deletion packages/app-shell/src/hooks/useRecordApprovals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
*/

import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { bearerAuthHeaders } from '../utils/authToken';

export interface ApprovalRequestLite {
id: string;
Expand Down Expand Up @@ -53,7 +54,13 @@ function apiBase() {
async function fetchJson<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${apiBase()}${path}`, {
credentials: 'include',
headers: { 'Content-Type': 'application/json', ...(init?.headers || {}) },
headers: {
'Content-Type': 'application/json',
// Bearer too — cookie-only auth loses this surface on split-origin
// deployments where the SameSite cookie doesn't flow (#2548).
...bearerAuthHeaders(),
...(init?.headers || {}),
},
...init,
});
let payload: any = null;
Expand Down
7 changes: 6 additions & 1 deletion packages/app-shell/src/layout/AppHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ import { useAuth, getUserInitials, useIsWorkspaceAdmin } from '@object-ui/auth';
import { useMetadata } from '../providers/MetadataProvider';
import { resolveI18nLabel, preferLocal, matchAppBySegment, appRouteSegment, appStudioRoutePath } from '../utils';
import { getIcon } from '../utils/getIcon';
import { bearerAuthHeaders } from '../utils/authToken';
import { useMobileViewSwitcher } from './MobileViewSwitcherContext';
import { useNavigationContext } from '../context/NavigationContext';
import { useCommandPalette } from '../context/CommandPaletteProvider';
Expand Down Expand Up @@ -440,7 +441,11 @@ export function AppHeader({
approvalsInFlightRef.current = true;
try {
const qs = new URLSearchParams({ status: 'pending', approverId: identities.join(',') });
const res = await fetch(`${base}?${qs}`, { credentials: 'include' });
const res = await fetch(`${base}?${qs}`, {
credentials: 'include',
// Bearer too — see utils/authToken (#2548 split-origin fix).
headers: bearerAuthHeaders(),
});
if (res.status === 404) { approvalsUnavailableRef.current = true; return; }
if (!res.ok) return;
const payload = await res.json().catch(() => null);
Expand Down
28 changes: 28 additions & 0 deletions packages/app-shell/src/utils/authToken.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/**
* 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.
*/

import { TokenStorage } from '@object-ui/auth';

/**
* Bearer auth header from the console's stored session token
* (`@object-ui/auth` TokenStorage — localStorage with in-memory fallback).
*
* For app-shell's few direct `fetch` call sites (approvals badge/panel,
* home inbox): cookie-only fetches silently lose their surface on
* split-origin deployments (custom-domain console ↔ API) where the
* SameSite cookie never flows — every dataSource call already sends this
* bearer, so these fetches must too (#2548).
*/
export function bearerAuthHeaders(): Record<string, string> {
try {
const token = TokenStorage.get();
return token ? { Authorization: `Bearer ${token}` } : {};
} catch {
return {};
}
}
9 changes: 7 additions & 2 deletions packages/app-shell/src/views/RecordDetailView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { useParams, useNavigate, useLocation, useSearchParams, Link } from 'react-router-dom';
import { RecordChatterPanel, InlineEditSaveBar, buildDefaultPageSchema, deriveFieldGroupDetailSections, extractMentions } from '@object-ui/plugin-detail';
import { RecordChatterPanel, InlineEditSaveBar, buildDefaultPageSchema, deriveFieldGroupDetailSections, extractMentions, resolveTitleField } from '@object-ui/plugin-detail';
import { Empty, EmptyTitle, EmptyDescription } from '@object-ui/components';
import { useAuth, createAuthenticatedFetch } from '@object-ui/auth';
import { usePermissions } from '@object-ui/permissions';
Expand Down Expand Up @@ -1513,9 +1513,14 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
// to their name. Empty → undefined below, so the synthesizer
// auto-derives instead.
const rawHighlightFields = (objectDef as any).highlightFields ?? [];
// Drop the record's title field: it is the page H1 and repeating it as
// the first chip duplicated the name, truncated, right under the
// heading. Mirrors deriveHighlightFields' declared-list handling —
// this pre-computed list bypasses that derivation (#2548).
const titleField = resolveTitleField(objectDef as any);
const highlightFields: string[] = (Array.isArray(rawHighlightFields) ? rawHighlightFields : [])
.map((f: any) => (typeof f === 'string' ? f : f?.name))
.filter((n: any): n is string => typeof n === 'string' && n.length > 0);
.filter((n: any): n is string => typeof n === 'string' && n.length > 0 && n !== titleField);

// Related child lists from reverse-reference relationships, in
// `buildDefaultPageSchema`'s `related` shape. `relationshipField` is
Expand Down
9 changes: 7 additions & 2 deletions packages/fields/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1050,13 +1050,18 @@ export function SelectCellRenderer({ value, field }: CellRendererProps): React.R
}

const colorClasses = getBadgeColorClasses(option?.color, val);
// max-w-full + inner truncate: in bounded containers (detail highlight
// strip columns, grid cells) an overlong label used to clip mid-glyph at
// the container edge; now the badge shrinks and ellipsizes, with the full
// label on hover.
return (
<Badge
key={key}
variant="outline"
className={colorClasses}
className={cn('max-w-full min-w-0', colorClasses)}
title={label}
>
{label}
<span className="truncate">{label}</span>
</Badge>
);
};
Expand Down
2 changes: 2 additions & 0 deletions packages/i18n/src/locales/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -510,6 +510,8 @@ const de = {
highlightFields: "Schlüsselfelder",
createdBy: "Erstellt von",
updatedBy: "Aktualisiert von",
created: "Erstellt",
updated: "Aktualisiert",
showEmptyRelated_one: "+ {{count}} leer",
showEmptyRelated_other: "+ {{count}} leer",
copyEmail: "E-Mail kopieren",
Expand Down
6 changes: 5 additions & 1 deletion packages/i18n/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -614,9 +614,13 @@ const en = {
// Activity feed actors
systemActor: 'System',
unknownUser: 'Unknown',
// Record meta footer (audit provenance)
// Record meta footer (audit provenance). `created`/`updated` are the
// actor-less variants — "Created by · 5m ago" dangles when created_by
// is null (system/seeded rows), so the footer falls back to these.
createdBy: 'Created by',
updatedBy: 'Updated by',
created: 'Created',
updated: 'Updated',
// Attachments
dropFilesToUpload: 'Drop files here or click to upload',
attachmentCount: '{{count}} attachment',
Expand Down
2 changes: 2 additions & 0 deletions packages/i18n/src/locales/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -510,6 +510,8 @@ const es = {
highlightFields: "Campos clave",
createdBy: "Creado por",
updatedBy: "Actualizado por",
created: "Creado",
updated: "Actualizado",
showEmptyRelated_one: "+ {{count}} vacío",
showEmptyRelated_other: "+ {{count}} vacíos",
copyEmail: "Copiar correo",
Expand Down
2 changes: 2 additions & 0 deletions packages/i18n/src/locales/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -510,6 +510,8 @@ const ja = {
deleteRowTitle: "レコードを削除",
createdBy: "作成者",
updatedBy: "更新者",
created: "作成",
updated: "更新",
showEmptyRelated_one: "+ {{count}} 件(空)",
showEmptyRelated_other: "+ {{count}} 件(空)",
copyEmail: "メールをコピー",
Expand Down
2 changes: 2 additions & 0 deletions packages/i18n/src/locales/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -510,6 +510,8 @@ const ko = {
highlightFields: "주요 필드",
createdBy: "작성자",
updatedBy: "업데이트한 사람",
created: "작성됨",
updated: "업데이트됨",
showEmptyRelated_one: "+ {{count}}개 비어 있음",
showEmptyRelated_other: "+ {{count}}개 비어 있음",
copyEmail: "이메일 복사",
Expand Down
10 changes: 7 additions & 3 deletions packages/i18n/src/locales/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -695,9 +695,13 @@ const zh = {
// Activity feed actors
systemActor: '系统',
unknownUser: '未知用户',
// Record meta footer (audit provenance)
createdBy: '创建于',
updatedBy: '更新于',
// Record meta footer (audit provenance)。createdBy/updatedBy 后面跟的是
// 操作者("创建人 Dev Admin · 5 分钟前");created/updated 是无操作者时的
// 兜底("创建于 5 分钟前")。旧文案把"创建于"用在了操作者位置,属误译。
createdBy: '创建人',
updatedBy: '更新人',
created: '创建于',
updated: '更新于',
// Attachments
dropFilesToUpload: '拖拽文件到此处或点击上传',
attachmentCount: '{{count}} 个附件',
Expand Down
19 changes: 17 additions & 2 deletions packages/plugin-detail/src/DetailSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
TooltipProvider,
TooltipTrigger,
useIsMobile,
LazyIcon,
} from '@object-ui/components';
import { ChevronDown, ChevronRight, Copy, Check, Eye, EyeOff, Pencil } from 'lucide-react';
import { SchemaRenderer } from '@object-ui/react';
Expand All @@ -34,6 +35,20 @@ import { PermissionFacetLink } from './renderers/PermissionFacetLink';
import { NON_EDITABLE_SYSTEM_FIELDS } from './systemFields';
import { InlineFieldInput, TEXTUAL_REF_FALLBACK_TYPES } from './InlineFieldInput';

/**
* Section-header icon. `fieldGroups[].icon` declares a Lucide name (spec),
* so ASCII-identifier-ish values render as the real icon; anything else
* (emoji / CJK text from hand-authored sections that predate the spec key)
* keeps the historical text rendering instead of degrading to the generic
* fallback icon.
*/
const SectionIcon: React.FC<{ name: string }> = ({ name }) => {
if (!/^[a-z0-9][a-z0-9-]*$/i.test(name)) {
return <span className="text-muted-foreground">{name}</span>;
}
return <LazyIcon name={name} className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />;
};

/**
* Compute responsive col-span classes so that col-span never exceeds the
* visible column count at each Tailwind breakpoint.
Expand Down Expand Up @@ -498,7 +513,7 @@ export const DetailSection: React.FC<DetailSectionProps> = ({
<CardHeader className={cn('py-3 px-4 sm:py-4 sm:px-6', section.headerColor && `bg-${section.headerColor}`)}>
<CardTitle className="flex items-center justify-between text-base font-semibold tracking-tight">
<div className="flex items-center gap-2">
{section.icon && <span className="text-muted-foreground">{section.icon}</span>}
{section.icon && <SectionIcon name={section.icon} />}
<span>{section.title}</span>
</div>
</CardTitle>
Expand Down Expand Up @@ -528,7 +543,7 @@ export const DetailSection: React.FC<DetailSectionProps> = ({
)}>
<CardTitle className="flex items-center justify-between text-base font-semibold tracking-tight">
<div className="flex items-center gap-2">
{section.icon && <span className="text-muted-foreground">{section.icon}</span>}
{section.icon && <SectionIcon name={section.icon} />}
<span>{section.title}</span>
</div>
<div className="flex items-center gap-2">
Expand Down
18 changes: 17 additions & 1 deletion packages/plugin-detail/src/HeaderHighlight.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,10 @@ export const HeaderHighlight: React.FC<HeaderHighlightProps> = ({
type: resolvedType || 'text',
...(objectDefField?.options && { options: objectDefField.options }),
...(objectDefField?.currency && { currency: objectDefField.currency }),
// The SPEC channel for a per-field currency (a bare `currency`
// key is designer/DB-only) — resolveFieldCurrency reads
// currencyConfig.defaultCurrency second (#2548).
...(objectDefField?.currencyConfig && { currencyConfig: objectDefField.currencyConfig }),
...(objectDefField?.precision !== undefined && { precision: objectDefField.precision }),
...((objectDefField as any)?.scale !== undefined && { scale: (objectDefField as any).scale }),
...(objectDefField?.format && { format: objectDefField.format }),
Expand Down Expand Up @@ -196,7 +200,19 @@ export const HeaderHighlight: React.FC<HeaderHighlightProps> = ({
</span>
) : (
<span
title={typeof value === 'string' ? value : undefined}
// Hover reveals the full value; for option-backed
// fields prefer the option LABEL over the raw
// stored value ('Technology', not 'technology').
title={
(Array.isArray((enrichedField as any).options)
? (enrichedField as any).options.find(
(o: any) => o?.value === value,
)?.label
: undefined) ??
(typeof value === 'string' || typeof value === 'number'
? String(value)
: undefined)
}
className={cn(
'block min-w-0 truncate text-sm font-semibold',
isKpi && 'tabular-nums tracking-tight',
Expand Down
6 changes: 4 additions & 2 deletions packages/plugin-detail/src/RecordMetaFooter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,9 @@ export const RecordMetaFooter: React.FC<RecordMetaFooterProps> = ({
>
{hasCreated && (
<MetaEntry
label={t('detail.createdBy')}
// No actor (system/seeded rows) → the "by"-less label; "Created
// by · 5m ago" read as a dangling phrase.
label={createdBy ? t('detail.createdBy') : t('detail.created')}
user={createdBy}
date={createdAt}
objectSchema={objectSchema}
Expand All @@ -201,7 +203,7 @@ export const RecordMetaFooter: React.FC<RecordMetaFooterProps> = ({
)}
{hasUpdated && (
<MetaEntry
label={t('detail.updatedBy')}
label={updatedBy ? t('detail.updatedBy') : t('detail.updated')}
user={updatedBy}
date={updatedAt}
objectSchema={objectSchema}
Expand Down
1 change: 1 addition & 0 deletions packages/plugin-detail/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ export {
deriveHighlightFields,
deriveFieldGroupDetailSections,
resolveDetailSections,
resolveTitleField,
} from './synth/buildDefaultPageSchema';
export type {
ObjectDefLike,
Expand Down
Loading
Loading