mirror of
https://github.com/simstudioai/sim.git
synced 2026-10-13 07:38:33 +08:00
improvement(headers): one action cluster and one ordering app-wide (#6210)
Extends the settings-header ordering past detail pages. Five headers hand-rolled their own action wrapper. `resource-header` used `flex shrink-0 items-center` — no height, no gap, so chips on tables, files, knowledge, logs and scheduled tasks sat flush against each other; the integrations tab strip and the integration block detail each used `ml-auto flex items-center`. Only the settings shell and credential detail wore the intended `flex h-[30px] items-center gap-1`. That string is now HEADER_ACTION_CLUSTER, next to PAGE_HEADER_BAR, and all five compose it. `Resource.Header` now ranks its actions through orderHeaderActions too, so the resource pages inherit the same order as settings rather than rendering their array verbatim. `ResourceAction` gains `id`, which was the only field keeping it from being a subset of `SettingsAction`. Delete is now ranked by its `id` rather than by where the caller put it. That matters for a header with no primary action: the file detail listed `Download → Share → Delete`, leaving a destructive chip in the slot a primary would occupy. Tagging it `id:'delete'` fixes that without inventing a primary.
This commit is contained in:
@@ -256,9 +256,20 @@ Every detail header reads left→right:
|
||||
```
|
||||
|
||||
You do not have to get the array order right — `orderHeaderActions()` ranks them
|
||||
(secondary → `id:'discard'` → `variant:'primary'`), order-stable within each
|
||||
band, so spreading `saveDiscardActions()` first still renders Save last. Both
|
||||
action stacks apply it: `SettingsHeaderShell` and `SettingsActionChips`.
|
||||
(secondary → `id:'delete'` → `id:'discard'` → `variant:'primary'`), order-stable
|
||||
within each band, so spreading `saveDiscardActions()` first still renders Save
|
||||
last. Three stacks apply it: `SettingsHeaderShell`, `SettingsActionChips`, and
|
||||
`Resource.Header` — so tables, files, knowledge and logs get the same ordering
|
||||
as settings.
|
||||
|
||||
Delete is placed by its **`id`**, not by position, which is why `id:'delete'` is
|
||||
required rather than cosmetic: a page with no primary action still must not
|
||||
leave a destructive chip in the slot a primary would occupy.
|
||||
|
||||
The bar geometry and the action cluster are both single-sourced in
|
||||
`@/components/page-header-bar` — `PAGE_HEADER_BAR` (or `Resource.Header`'s
|
||||
bordered variant) and `HEADER_ACTION_CLUSTER`. Never re-derive `h-[30px]`,
|
||||
`gap-1`, or the lane padding per header.
|
||||
Covered by `settings-header-order.test.ts` and `settings-header-shell.test.tsx`
|
||||
— the latter pins that a reordered chip still routes to its own handler.
|
||||
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn } from '@sim/emcn'
|
||||
import { PAGE_HEADER_BAR } from '@/components/page-header-bar'
|
||||
import { HEADER_ACTION_CLUSTER, PAGE_HEADER_BAR } from '@/components/page-header-bar'
|
||||
|
||||
interface CredentialDetailLayoutProps {
|
||||
/** Back link rendered at the start of the fixed action bar. */
|
||||
@@ -21,7 +21,7 @@ export function CredentialDetailLayout({ back, actions, children }: CredentialDe
|
||||
<div className='flex h-full flex-col bg-[var(--bg)]'>
|
||||
<div className={cn(PAGE_HEADER_BAR, 'justify-between')}>
|
||||
{back}
|
||||
{actions ? <div className='flex h-[30px] items-center gap-1'>{actions}</div> : null}
|
||||
{actions ? <div className={HEADER_ACTION_CLUSTER}>{actions}</div> : null}
|
||||
</div>
|
||||
<div className='min-h-0 flex-1 overflow-y-auto px-6 [scrollbar-gutter:stable_both-edges]'>
|
||||
<div className='mx-auto flex w-full max-w-[48rem] flex-col gap-7 pb-6'>{children}</div>
|
||||
|
||||
+12
-4
@@ -31,7 +31,8 @@ import {
|
||||
} from '@sim/emcn'
|
||||
import { ArrowUpLeft } from 'lucide-react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { TITLE_BAR_LANE_PT } from '@/components/page-header-bar'
|
||||
import { HEADER_ACTION_CLUSTER, TITLE_BAR_LANE_PT } from '@/components/page-header-bar'
|
||||
import { orderHeaderActions } from '@/components/settings/settings-header'
|
||||
import { InlineRenameInput } from '@/app/workspace/[workspaceId]/components/inline-rename-input'
|
||||
|
||||
export interface DropdownOption {
|
||||
@@ -77,6 +78,13 @@ export interface BreadcrumbItem {
|
||||
* a selected/toggle state with `active` (e.g. the Logs/Dashboard view toggle).
|
||||
*/
|
||||
export interface ResourceAction {
|
||||
/**
|
||||
* Stable render identity, and the action's slot in the row. `'delete'` and
|
||||
* `'discard'` are ordered by {@link orderHeaderActions} rather than by where the
|
||||
* caller listed them; any other id is just a key. Falls back to `text`, which
|
||||
* remounts the chip whenever the label flips (Delete → Deleting...).
|
||||
*/
|
||||
id?: string
|
||||
icon?: ComponentType<{ className?: string }>
|
||||
text: string
|
||||
variant?: 'primary' | 'destructive'
|
||||
@@ -202,11 +210,11 @@ export const ResourceHeader = memo(function ResourceHeader({
|
||||
)}
|
||||
</div>
|
||||
{(aside || (actions && actions.length > 0)) && (
|
||||
<div className='flex shrink-0 items-center'>
|
||||
<div className={cn(HEADER_ACTION_CLUSTER, 'shrink-0')}>
|
||||
{aside}
|
||||
{actions?.map((action) => (
|
||||
{orderHeaderActions(actions).map(({ action }) => (
|
||||
<Chip
|
||||
key={action.text}
|
||||
key={action.id ?? action.text}
|
||||
variant={action.variant}
|
||||
active={action.active}
|
||||
leftIcon={action.icon}
|
||||
|
||||
@@ -1634,6 +1634,7 @@ export function Files() {
|
||||
onSelect: handleShareSelected,
|
||||
},
|
||||
{
|
||||
id: 'delete',
|
||||
text: 'Delete',
|
||||
icon: Trash,
|
||||
onSelect: handleDeleteSelected,
|
||||
|
||||
+2
-2
@@ -5,7 +5,7 @@ import { Chip, ChipDropdown, ChipLink, cn } from '@sim/emcn'
|
||||
import { ArrowLeft, Plus } from 'lucide-react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useQueryState } from 'nuqs'
|
||||
import { PAGE_HEADER_BAR } from '@/components/page-header-bar'
|
||||
import { HEADER_ACTION_CLUSTER, PAGE_HEADER_BAR } from '@/components/page-header-bar'
|
||||
import { isChatEnabled } from '@/lib/core/config/env-flags'
|
||||
import {
|
||||
blockTypeToIconMap,
|
||||
@@ -146,7 +146,7 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration
|
||||
<ChipLink href={`/workspace/${workspaceId}/integrations`} leftIcon={ArrowLeft}>
|
||||
Integrations
|
||||
</ChipLink>
|
||||
<div className='ml-auto flex items-center'>
|
||||
<div className={cn('ml-auto', HEADER_ACTION_CLUSTER)}>
|
||||
{oauthService ? (
|
||||
hasServiceAccount ? (
|
||||
<ChipDropdown
|
||||
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { ChipLink } from '@sim/emcn'
|
||||
import { PAGE_HEADER_BAR } from '@/components/page-header-bar'
|
||||
import { ChipLink, cn } from '@sim/emcn'
|
||||
import { HEADER_ACTION_CLUSTER, PAGE_HEADER_BAR } from '@/components/page-header-bar'
|
||||
|
||||
interface IntegrationTabsHeaderProps {
|
||||
active: 'integrations' | 'skills'
|
||||
@@ -26,7 +26,7 @@ export function IntegrationTabsHeader({
|
||||
<ChipLink href={`/workspace/${workspaceId}/skills`} active={active === 'skills'}>
|
||||
Skills
|
||||
</ChipLink>
|
||||
{rightSlot && <div className='ml-auto flex items-center'>{rightSlot}</div>}
|
||||
{rightSlot && <div className={cn('ml-auto', HEADER_ACTION_CLUSTER)}>{rightSlot}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -23,3 +23,12 @@ export const TITLE_BAR_LANE_PT = 'pt-[calc(8.5px+var(--workspace-content-title-b
|
||||
* Single source of truth for this geometry — never re-derive it per page.
|
||||
*/
|
||||
export const PAGE_HEADER_BAR = `flex flex-shrink-0 items-center bg-[var(--bg)] px-4 ${TITLE_BAR_LANE_PT} pb-[8.5px]`
|
||||
|
||||
/**
|
||||
* The right-hand action cluster inside a top bar. Every header — settings,
|
||||
* credential detail, `Resource` pages, the integrations tab strip — wears this,
|
||||
* so a chip row is the same height and rhythm wherever it appears.
|
||||
*
|
||||
* Single source of truth: never re-derive `h-[30px]`/`gap-1` per header.
|
||||
*/
|
||||
export const HEADER_ACTION_CLUSTER = 'flex h-[30px] items-center gap-1'
|
||||
|
||||
@@ -56,6 +56,18 @@ describe('orderHeaderActions', () => {
|
||||
expect(rendered(actions)).toEqual(['Edit server', 'Delete', 'Add workflows'])
|
||||
})
|
||||
|
||||
it('places Delete by its id, not by where the caller listed it', () => {
|
||||
// A page with no primary action still must not leave Delete in the slot a
|
||||
// primary would occupy — files detail is exactly this shape.
|
||||
const actions: SettingsAction[] = [
|
||||
{ id: 'delete', text: 'Delete', onSelect: noop },
|
||||
{ text: 'Download', onSelect: noop },
|
||||
{ text: 'Share', onSelect: noop },
|
||||
]
|
||||
|
||||
expect(rendered(actions)).toEqual(['Download', 'Share', 'Delete'])
|
||||
})
|
||||
|
||||
it('preserves caller order within a band', () => {
|
||||
const actions: SettingsAction[] = [
|
||||
{ text: 'Refresh', onSelect: noop },
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
useState,
|
||||
} from 'react'
|
||||
import { Chip, ChipInput, ChipLink, cn, Search, Tooltip } from '@sim/emcn'
|
||||
import { PAGE_HEADER_BAR } from '@/components/page-header-bar'
|
||||
import { HEADER_ACTION_CLUSTER, PAGE_HEADER_BAR } from '@/components/page-header-bar'
|
||||
|
||||
const useIsomorphicLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect
|
||||
|
||||
@@ -191,9 +191,13 @@ export function SettingsActionChips({ actions }: { actions: SettingsAction[] })
|
||||
}
|
||||
|
||||
/**
|
||||
* Every detail header reads left→right as
|
||||
* Every header reads left→right as
|
||||
* `[secondary actions] → [Delete] → [Discard] → [Save]`.
|
||||
*
|
||||
* Delete is placed by its `id`, not by where the caller happened to put it, so a
|
||||
* page with no primary action still can't leave a destructive chip in the slot a
|
||||
* primary would occupy.
|
||||
*
|
||||
* The shell enforces it rather than trusting callsites, because the natural way
|
||||
* to write the array — spreading {@link saveDiscardActions} first, then adding a
|
||||
* Delete — produces the opposite order and puts a destructive chip to the right
|
||||
@@ -208,8 +212,9 @@ export function orderHeaderActions(
|
||||
actions: SettingsAction[] | undefined
|
||||
): { action: SettingsAction; index: number }[] {
|
||||
const rank = (action: SettingsAction) => {
|
||||
if (action.variant === 'primary') return 2
|
||||
if (action.id === 'discard') return 1
|
||||
if (action.variant === 'primary') return 3
|
||||
if (action.id === 'discard') return 2
|
||||
if (action.id === 'delete') return 1
|
||||
return 0
|
||||
}
|
||||
return (actions ?? [])
|
||||
@@ -233,7 +238,7 @@ export function SettingsHeaderShell({ children }: { children: ReactNode }) {
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
<div className='flex h-[30px] items-center gap-1'>
|
||||
<div className={HEADER_ACTION_CLUSTER}>
|
||||
{docsLink && (
|
||||
<ChipLink href={docsLink} target='_blank' rel='noopener noreferrer'>
|
||||
Docs
|
||||
|
||||
Reference in New Issue
Block a user