Skip to content

Commit 3821308

Browse files
BillLeoutsakosvl346Bill Leoutsakos
andauthored
refactor(ui): use EMCN fields in deployment and setup modals (#8009)
* improvement(emcn): share larger chip and field geometry * refactor(ui): use EMCN fields in deployment and setup modals * improvement(ui): reuse chip for PII configuration action (#8173) Co-authored-by: Bill Leoutsakos <billleoutsakos@Bills-MacBook-Pro.local> * improvement(ui): compose horizontal and credential fields in EMCN (#8164) * improvement(ui): compose horizontal and credential fields in EMCN * Share deploy chat field error treatment (#8255) * refactor(ui): share deploy chat field error treatment * refactor(ui): name deploy error props --------- Co-authored-by: Bill Leoutsakos <billleoutsakos@Bills-MacBook-Pro.local> --------- Co-authored-by: Bill Leoutsakos <billleoutsakos@Bills-MacBook-Pro.local> --------- Co-authored-by: Bill Leoutsakos <billleoutsakos@Bills-MacBook-Pro.local>
1 parent 63aa37e commit 3821308

17 files changed

Lines changed: 468 additions & 227 deletions

File tree

‎.claude/rules/emcn-components.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,10 @@ The menu surface intentionally diverges from the pill: `dropdown-menu.tsx` items
2424
- **`Chip` / `ChipLink`** — the pill button (`<button>` / Next `<Link>`). Variants: `primary`, `destructive`, `border-shadow`, `border`, `outline` (a true `--border` border, no shadow or hover fill); the bare chip is implicit (omit `variant`). `filled` is deliberately NOT a `Chip` variant — it is reserved for chip fields/triggers. For a selected/toggle chip use the `active` prop, never a variant. `leftIcon`/`rightIcon`, `active`, `fullWidth`. Chips carry **no outer margin** — space between them is the parent's `gap`. The old `mx-0.5` default and its `flush` opt-out are gone; do not reintroduce either, and never add a margin to a chip through `className`.
2525
- **`ChipInput`** — single-line text field. `icon`, `endAdornment`, `error`, `inputClassName` (inner `<input>`); `className` styles the chrome wrapper.
2626
- **`ChipCopyInput`** — the canonical view-only field: a read-only `ChipInput` at full opacity with a trailing copy-to-clipboard button. View-only is a display mode, not a disabled state — reach for it (or `ChipModalField type='copy'`) over a `disabled` (greyed) input for values the user cannot edit.
27-
- **`ChipTextarea`** — multi-line sibling. `error`, `resizable` (off by default), `viewOnly` (read-only at full opacity with the default cursor — the multi-line counterpart of `ChipCopyInput`).
27+
- **`ChipTextarea`** — multi-line sibling. `monospace` for structured text, `error`, `resizable` (off by default), `viewOnly` (read-only at full opacity with the default cursor — the multi-line counterpart of `ChipCopyInput`).
2828
- **`ChipSelect`** — canonical menu selection. Single selection uses `value`/`onChange`; `multiSelect` uses `multiSelectValues`/`onMultiSelectChange`, checkbox rows, and stays open. Supports groups, search aliases, rich labels/icons, disabled options, optional single-selection checks, and trigger refs. Rich labels require a nonempty `searchTerms` array containing their visible text, followed by any aliases; plain string/number labels are searchable automatically. `showAllOption` clears to an empty selection; without it, use `placeholder` for the empty state. Owns its chevron. `dropdownWidth` accepts `'trigger'`, `'content'`, or pixels; `modal={false}` keeps outside-dialog filters non-modal, while nested dialogs retain their focus lock.
2929
- **`ChipCombobox`** — shared chip styling over the separate `Combobox` engine for editable values, asynchronous results, and virtualized lists. Plain menu selection, including searchable menus, uses `ChipSelect`.
30-
- **`ChipModal` + `ChipModalField`** — declarative compact modal. The field's `type` (`input` | `email` | `textarea` | `dropdown` | `copy` | `file` | `emails` | `custom`) picks the control and **owns all chrome** — consumers describe intent, never pass `variant`/`className`/`id` to the inner control. `custom` is the escape hatch. **Every body field MUST be a `ChipModalField`** — never hand-roll a field row (raw `<div>` + hand-rolled `<p>`/`<label>` title + bare `ChipInput`/`ChipTextarea`). `ChipModalBody` applies `px-2` + `gap-4`; `ChipModalField` adds another `px-2`, so each field lands at effective `px-4`, exactly matching the `px-4` header/footer — a hand-rolled row skips that gutter and sits misaligned at `px-2`. For controls the field doesn't cover (`ChipCombobox`, `ChipSelect`, `DatePicker`, `TimePicker`, `ButtonGroup`, arbitrary JSX), use `type='custom'` with a `title` — it still applies the gutter and renders the canonical `Label`.
30+
- **`ChipModal` + `ChipModalField`** — declarative compact modal. `orientation='horizontal'` places a field title beside its control, wraps on narrow surfaces, and keeps error/hint text below the row; vertical is the default. The field's `type` (`input` | `email` | `textarea` | `dropdown` | `copy` | `file` | `emails` | `custom`) picks the control and **owns all chrome** — consumers describe intent, never pass `variant`/`className`/`id` to the inner control. `custom` is the escape hatch. **Every body field MUST be a `ChipModalField`** — never hand-roll a field row (raw `<div>` + hand-rolled `<p>`/`<label>` title + bare `ChipInput`/`ChipTextarea`). `ChipModalBody` applies `px-2` + `gap-4`; `ChipModalField` adds another `px-2`, so each field lands at effective `px-4`, exactly matching the `px-4` header/footer — a hand-rolled row skips that gutter and sits misaligned at `px-2`. For controls the field doesn't cover (`ChipCombobox`, `ChipSelect`, `DatePicker`, `TimePicker`, `ButtonGroup`, arbitrary JSX), use `type='custom'` with a `title` — it still applies the gutter and renders the canonical `Label`.
3131
- **`ChipButtonGroup` / `ChipSwitch`** — one Radix radio-group implementation with zero-gap segments and `default`/`compact` density. `ChipButtonGroup` accepts compound children; `ChipSwitch` adapts an options array.
3232
- **`ChipTag`** — 20px inline tag/badge (`mono`/`gray`/`invite`), not a pill trigger.
3333
- **`ChipDatePicker`** — chip-styled date field.

‎.cursor/rules/emcn-components.mdc‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,10 @@ The menu surface intentionally diverges from the pill: `dropdown-menu.tsx` items
2525
- **`Chip` / `ChipLink`** — the pill button (`<button>` / Next `<Link>`). Variants: `primary`, `destructive`, `border-shadow`, `border`, `outline` (a true `--border` border, no shadow or hover fill); the bare chip is implicit (omit `variant`). `filled` is deliberately NOT a `Chip` variant — it is reserved for chip fields/triggers. For a selected/toggle chip use the `active` prop, never a variant. `leftIcon`/`rightIcon`, `active`, `fullWidth`. Chips carry **no outer margin** — space between them is the parent's `gap`. The old `mx-0.5` default and its `flush` opt-out are gone; do not reintroduce either, and never add a margin to a chip through `className`.
2626
- **`ChipInput`** — single-line text field. `icon`, `endAdornment`, `error`, `inputClassName` (inner `<input>`); `className` styles the chrome wrapper.
2727
- **`ChipCopyInput`** — the canonical view-only field: a read-only `ChipInput` at full opacity with a trailing copy-to-clipboard button. View-only is a display mode, not a disabled state — reach for it (or `ChipModalField type='copy'`) over a `disabled` (greyed) input for values the user cannot edit.
28-
- **`ChipTextarea`** — multi-line sibling. `error`, `resizable` (off by default), `viewOnly` (read-only at full opacity with the default cursor — the multi-line counterpart of `ChipCopyInput`).
28+
- **`ChipTextarea`** — multi-line sibling. `monospace` for structured text, `error`, `resizable` (off by default), `viewOnly` (read-only at full opacity with the default cursor — the multi-line counterpart of `ChipCopyInput`).
2929
- **`ChipSelect`** — canonical menu selection. Single selection uses `value`/`onChange`; `multiSelect` uses `multiSelectValues`/`onMultiSelectChange`, checkbox rows, and stays open. Supports groups, search aliases, rich labels/icons, disabled options, optional single-selection checks, and trigger refs. Rich labels require a nonempty `searchTerms` array containing their visible text, followed by any aliases; plain string/number labels are searchable automatically. `showAllOption` clears to an empty selection; without it, use `placeholder` for the empty state. Owns its chevron. `dropdownWidth` accepts `'trigger'`, `'content'`, or pixels; `modal={false}` keeps outside-dialog filters non-modal, while nested dialogs retain their focus lock.
3030
- **`ChipCombobox`** — shared chip styling over the separate `Combobox` engine for editable values, asynchronous results, and virtualized lists. Plain menu selection, including searchable menus, uses `ChipSelect`.
31-
- **`ChipModal` + `ChipModalField`** — declarative compact modal. The field's `type` (`input` | `email` | `textarea` | `dropdown` | `copy` | `file` | `emails` | `custom`) picks the control and **owns all chrome** — consumers describe intent, never pass `variant`/`className`/`id` to the inner control. `custom` is the escape hatch. **Every body field MUST be a `ChipModalField`** — never hand-roll a field row (raw `<div>` + hand-rolled `<p>`/`<label>` title + bare `ChipInput`/`ChipTextarea`). `ChipModalBody` applies `px-2` + `gap-4`; `ChipModalField` adds another `px-2`, so each field lands at effective `px-4`, exactly matching the `px-4` header/footer — a hand-rolled row skips that gutter and sits misaligned at `px-2`. For controls the field doesn't cover (`ChipCombobox`, `ChipSelect`, `DatePicker`, `TimePicker`, `ButtonGroup`, arbitrary JSX), use `type='custom'` with a `title` — it still applies the gutter and renders the canonical `Label`.
31+
- **`ChipModal` + `ChipModalField`** — declarative compact modal. `orientation='horizontal'` places a field title beside its control, wraps on narrow surfaces, and keeps error/hint text below the row; vertical is the default. The field's `type` (`input` | `email` | `textarea` | `dropdown` | `copy` | `file` | `emails` | `custom`) picks the control and **owns all chrome** — consumers describe intent, never pass `variant`/`className`/`id` to the inner control. `custom` is the escape hatch. **Every body field MUST be a `ChipModalField`** — never hand-roll a field row (raw `<div>` + hand-rolled `<p>`/`<label>` title + bare `ChipInput`/`ChipTextarea`). `ChipModalBody` applies `px-2` + `gap-4`; `ChipModalField` adds another `px-2`, so each field lands at effective `px-4`, exactly matching the `px-4` header/footer — a hand-rolled row skips that gutter and sits misaligned at `px-2`. For controls the field doesn't cover (`ChipCombobox`, `ChipSelect`, `DatePicker`, `TimePicker`, `ButtonGroup`, arbitrary JSX), use `type='custom'` with a `title` — it still applies the gutter and renders the canonical `Label`.
3232
- **`ChipButtonGroup` / `ChipSwitch`** — one Radix radio-group implementation with zero-gap segments and `default`/`compact` density. `ChipButtonGroup` accepts compound children; `ChipSwitch` adapts an options array.
3333
- **`ChipTag`** — 20px inline tag/badge (`mono`/`gray`/`invite`), not a pill trigger.
3434
- **`ChipDatePicker`** — chip-styled date field.

‎apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/resume-page-client.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1020,7 +1020,8 @@ export default function ResumeExecutionPage({
10201020
placeholder='{"example": "value"}'
10211021
rows={6}
10221022
spellCheck={false}
1023-
className='min-h-[180px] font-mono'
1023+
monospace
1024+
className='min-h-[180px]'
10241025
/>
10251026
</div>
10261027
</div>

‎apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/client-credential-account-modal.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -275,7 +275,8 @@ function ClientCredentialAccountModalForm({
275275
value={value}
276276
onChange={(event) => setField(field.id, event.target.value)}
277277
placeholder={field.placeholder}
278-
className='min-h-[120px] font-mono'
278+
monospace
279+
className='min-h-[120px]'
279280
// Browser spell-check and autofill ship textarea contents to
280281
// third-party services — an exfiltration route for a pasted
281282
// private key. `ChipModalField type='textarea'` exposes none

‎apps/sim/app/workspace/[workspaceId]/settings/components/mcp/components/mcp-server-form-modal/mcp-server-form-modal.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -763,7 +763,8 @@ export function McpServerFormModal({
763763
type='button'
764764
variant='ghost'
765765
onClick={() => setShowAdvanced((v) => !v)}
766-
className='gap-1 self-start py-0 text-small'
766+
size='inline'
767+
className='gap-1 self-start'
767768
>
768769
{showAdvanced ? (
769770
<ChevronDown className='size-[14px]' />

‎apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/no-organization-view/no-organization-view.tsx‎

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -83,18 +83,16 @@ export function NoOrganizationView({
8383

8484
<div>
8585
<Label htmlFor='orgSlug'>Team URL</Label>
86-
<div className='mt-1 flex items-center'>
87-
<div className='rounded-l-[6px] border border-[var(--border-1)] border-r-0 bg-[var(--surface-4)] px-3 py-1.5 text-[var(--text-muted)] text-small'>
88-
sim.ai/team/
89-
</div>
90-
<ChipInput
91-
id='orgSlug'
92-
value={orgSlug}
93-
onChange={(e) => setOrgSlug(e.target.value)}
94-
placeholder='my-team'
95-
className='rounded-l-none'
96-
/>
97-
</div>
86+
<ChipInput
87+
id='orgSlug'
88+
value={orgSlug}
89+
onChange={(e) => setOrgSlug(e.target.value)}
90+
placeholder='my-team'
91+
className='mt-1'
92+
startAdornment={
93+
<span className='shrink-0 text-[var(--text-muted)] text-small'>sim.ai/team/</span>
94+
}
95+
/>
9896
</div>
9997

10098
<div className='flex flex-col gap-2'>

‎apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/lock-settings-modal/lock-settings-modal.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ export function LockSettingsModal({
9595
<ChipModalField
9696
key={field.key}
9797
type='custom'
98-
className='flex-row items-center justify-between'
98+
orientation='horizontal'
9999
title={
100100
<span className='inline-flex items-center gap-1.5'>
101101
{field.label}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import type { ComponentProps, PropsWithChildren } from 'react'
2+
import { JSDOM } from 'jsdom'
3+
import { renderToStaticMarkup } from 'react-dom/server'
4+
import { describe, expect, it, vi } from 'vitest'
5+
6+
const validation = vi.hoisted(() => ({
7+
current: { isChecking: false, error: 'Use lowercase letters', isValid: false } as {
8+
isChecking: boolean
9+
error: string | null
10+
isValid: boolean
11+
},
12+
}))
13+
14+
vi.mock('@sim/emcn', () => ({
15+
Input: (props: ComponentProps<'input'>) => <input {...props} />,
16+
Label: (props: ComponentProps<'label'>) => (
17+
<label htmlFor={props.htmlFor} className={props.className}>
18+
{props.children}
19+
</label>
20+
),
21+
cn: (...values: unknown[]) => values.filter(Boolean).join(' '),
22+
Tooltip: {
23+
Root: ({ children }: PropsWithChildren) => <>{children}</>,
24+
Trigger: ({ children }: PropsWithChildren) => <>{children}</>,
25+
Content: ({ children }: PropsWithChildren) => <>{children}</>,
26+
},
27+
}))
28+
vi.mock('@sim/emcn/icons', () => ({ Check: () => null, TriangleAlert: () => null }))
29+
vi.mock('@sim/logger', () => ({ createLogger: () => ({}) }))
30+
vi.mock('@/components/ui', () => ({ GeneratedPasswordInput: () => null }))
31+
vi.mock('@/lib/core/config/deployment-shape', () => ({ useDeploymentShape: () => ({}) }))
32+
vi.mock('@/lib/core/utils/urls', () => ({
33+
getBaseUrl: () => 'https://sim.ai',
34+
getEmailDomain: () => 'sim.ai',
35+
}))
36+
vi.mock('@/lib/messaging/email/validation', () => ({ validateAllowlistEntry: () => true }))
37+
vi.mock('@/lib/workflows/streaming/output-selector', () => ({
38+
formatInternalOutputSelector: () => '',
39+
}))
40+
vi.mock(
41+
'@/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select',
42+
() => ({
43+
OutputSelect: () => null,
44+
})
45+
)
46+
vi.mock('@/hooks/queries/chats', () => ({
47+
useCreateChat: () => ({}),
48+
useDeleteChat: () => ({}),
49+
useRevealChatPassword: () => ({}),
50+
useUpdateChat: () => ({}),
51+
}))
52+
vi.mock('@/hooks/use-permission-config', () => ({ usePermissionConfig: () => ({}) }))
53+
vi.mock('./hooks', () => ({ useIdentifierValidation: () => validation.current }))
54+
55+
import { IdentifierInput } from './chat'
56+
57+
function renderIdentifier() {
58+
return new JSDOM(renderToStaticMarkup(<IdentifierInput value='bad path' onChange={vi.fn()} />))
59+
.window.document
60+
}
61+
62+
describe('deploy URL field error', () => {
63+
it('announces and associates the URL validation error with its input', () => {
64+
validation.current = { isChecking: false, error: 'Use lowercase letters', isValid: false }
65+
const document = renderIdentifier()
66+
const input = document.querySelector<HTMLInputElement>('#chat-url')
67+
const alert = document.querySelector<HTMLElement>('[role="alert"]')
68+
69+
expect(alert?.textContent).toBe('Use lowercase letters')
70+
expect(alert?.className).toBe('mt-[6.5px] text-[var(--text-error)] text-caption')
71+
expect(input?.getAttribute('aria-invalid')).toBe('true')
72+
expect(input?.getAttribute('aria-describedby')).toBe(alert?.id)
73+
expect(alert?.id).toBeTruthy()
74+
expect(document.querySelector('label')?.htmlFor).toBe(input?.id)
75+
})
76+
77+
it('omits the error relationship when the URL is valid', () => {
78+
validation.current = { isChecking: false, error: null, isValid: true }
79+
const document = renderIdentifier()
80+
const input = document.querySelector<HTMLInputElement>('#chat-url')
81+
82+
expect(document.querySelector('[role="alert"]')).toBeNull()
83+
expect(input?.getAttribute('aria-invalid')).toBe('false')
84+
expect(input?.hasAttribute('aria-describedby')).toBe(false)
85+
})
86+
})

0 commit comments

Comments
 (0)