Skip to content

Commit b890e24

Browse files
authored
fix(emcn): keep the segmented control hugging its segments (#7470)
1 parent da37d41 commit b890e24

8 files changed

Lines changed: 140 additions & 19 deletions

File tree

apps/sim/app/workspace/[workspaceId]/settings/[section]/search-params.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,22 @@ export const credentialGroupProviderSearchUrlKeys = {
132132
clearOnDefault: true,
133133
} as const
134134

135+
/**
136+
* Filters the people enrolled in a credential group by email. Its own key rather than the
137+
* provider filter's, so switching tabs does not carry a term that matches nothing on the
138+
* other side.
139+
*/
140+
export const credentialGroupPeopleSearchParam = {
141+
key: 'credential-group-people',
142+
parser: parseAsString.withDefault(''),
143+
} as const
144+
145+
/** A transient list filter: no back-stack entry, and absent from the URL when empty. */
146+
export const credentialGroupPeopleSearchUrlKeys = {
147+
history: 'replace',
148+
clearOnDefault: true,
149+
} as const
150+
135151
/**
136152
* `group-tab` is the active tab inside the deep-linked permission-group detail
137153
* view, so a shared `group-id` link can land on the same tab (mirrors

apps/sim/ee/credential-groups/components/credential-group-access.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -221,7 +221,6 @@ export function CredentialGroupAccess({
221221

222222
const sectionAction = (
223223
<Chip
224-
variant='primary'
225224
onClick={() => setShowAddWorkflow(true)}
226225
disabled={
227226
saving ||

apps/sim/ee/credential-groups/components/credential-group-detail.tsx

Lines changed: 42 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import { useState } from 'react'
44
import { Chip, ChipConfirmModal, ChipModalTabs, toast } from '@sim/emcn'
5-
import { ArrowLeft, Plus, User } from '@sim/emcn/icons'
5+
import { ArrowLeft, Plus } from '@sim/emcn/icons'
66
import { getErrorMessage } from '@sim/utils/errors'
77
import { useQueryState } from 'nuqs'
88
import { McpIcon } from '@/components/icons'
@@ -17,11 +17,14 @@ import { getCredentialGroupProviderService } from '@/lib/credential-groups/provi
1717
import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types'
1818
import { UnsavedChangesModal } from '@/app/workspace/[workspaceId]/components/credential-detail'
1919
import {
20+
credentialGroupPeopleSearchParam,
21+
credentialGroupPeopleSearchUrlKeys,
2022
credentialGroupProviderSearchParam,
2123
credentialGroupProviderSearchUrlKeys,
2224
credentialGroupTabParam,
2325
credentialGroupTabUrlKeys,
2426
} from '@/app/workspace/[workspaceId]/settings/[section]/search-params'
27+
import { MemberAvatar } from '@/app/workspace/[workspaceId]/settings/components/member-list'
2528
import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu'
2629
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
2730
import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header'
@@ -125,13 +128,35 @@ export function CredentialGroupDetail({
125128
{ ...credentialGroupProviderSearchParam.parser, ...credentialGroupProviderSearchUrlKeys }
126129
)
127130
const setProviderSearch = useDebouncedSearchSetter(setProviderSearchParam)
131+
const [peopleSearch, setPeopleSearchParam] = useQueryState(credentialGroupPeopleSearchParam.key, {
132+
...credentialGroupPeopleSearchParam.parser,
133+
...credentialGroupPeopleSearchUrlKeys,
134+
})
135+
const setPeopleSearch = useDebouncedSearchSetter(setPeopleSearchParam)
128136
const [showInvite, setShowInvite] = useState(false)
129137
const [showDelete, setShowDelete] = useState(false)
130138
const [deletingEnrollmentId, setDeletingEnrollmentId] = useState<string | null>(null)
131139
const [draftName, setDraftName] = useState<string | null>(null)
132140
const [draftDescription, setDraftDescription] = useState<string | null>(null)
133141
const credentialGroup = detail.data?.pages[0]?.credentialGroup
134142
const enrollments = detail.data?.pages.flatMap((page) => page.enrollments) ?? []
143+
const peopleFilter = peopleSearch.trim().toLowerCase()
144+
/**
145+
* Only the pages already loaded: the enrollment list is cursor-paginated with no
146+
* server-side term, so a match on a later page appears only once it is fetched.
147+
*/
148+
const visibleEnrollments = peopleFilter
149+
? enrollments.filter((enrollment) => enrollment.email.toLowerCase().includes(peopleFilter))
150+
: enrollments
151+
/**
152+
* `+` means more people exist than are loaded, so it stays on the total. While
153+
* filtering, the match count is reported against that total rather than replacing
154+
* it — otherwise `People (2+)` reads as a two-person group with more to come.
155+
*/
156+
const loadedTotal = `${enrollments.length}${detail.hasNextPage ? '+' : ''}`
157+
const peopleLabel = peopleFilter
158+
? `People (${visibleEnrollments.length} of ${loadedTotal})`
159+
: `People (${loadedTotal})`
135160
const deletingEnrollment = deletingEnrollmentId
136161
? (enrollments.find((enrollment) => enrollment.id === deletingEnrollmentId) ?? null)
137162
: null
@@ -290,7 +315,14 @@ export function CredentialGroupDetail({
290315
placeholder: 'Search accounts and MCP servers...',
291316
disabled: detail.isPending,
292317
}
293-
: undefined
318+
: activeTab === 'people'
319+
? {
320+
value: peopleSearch,
321+
onChange: setPeopleSearch,
322+
placeholder: 'Search people...',
323+
disabled: detail.isPending,
324+
}
325+
: undefined
294326
}
295327
>
296328
{detail.error ? (
@@ -320,7 +352,7 @@ export function CredentialGroupDetail({
320352

321353
{activeTab === 'people' && (
322354
<SettingsSection
323-
label={`People (${enrollments.length}${detail.hasNextPage ? '+' : ''})`}
355+
label={peopleLabel}
324356
action={
325357
detail.hasNextPage ? (
326358
<Chip
@@ -332,16 +364,18 @@ export function CredentialGroupDetail({
332364
) : undefined
333365
}
334366
>
335-
{enrollments.length === 0 ? (
336-
<SettingsEmptyState variant='inline'>No people invited yet</SettingsEmptyState>
367+
{visibleEnrollments.length === 0 ? (
368+
<SettingsEmptyState variant='inline'>
369+
{peopleFilter ? 'No people match your search' : 'No people invited yet'}
370+
</SettingsEmptyState>
337371
) : (
338372
<div className={RESOURCE_LIST_STACK}>
339-
{enrollments.map((enrollment) => {
373+
{visibleEnrollments.map((enrollment) => {
340374
return (
341375
<SettingsResourceRow
342376
key={enrollment.id}
343-
icon={<User className='text-[var(--text-icon)]' />}
344-
iconFilled
377+
icon={<MemberAvatar name={enrollment.email} image={null} />}
378+
iconVariant='custom'
345379
title={enrollment.email}
346380
description={
347381
<EnrollmentConnections

apps/sim/ee/credential-groups/components/credential-group-details.tsx

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -242,7 +242,7 @@ export function CredentialGroupDetails({
242242
error={!name.trim()}
243243
/>
244244
</SettingRow>
245-
<SettingRow label='Description' optional htmlFor='credential-group-description'>
245+
<SettingRow label='Description' htmlFor='credential-group-description'>
246246
<ChipTextarea
247247
id='credential-group-description'
248248
value={description}
@@ -437,7 +437,6 @@ export function CredentialGroupDetails({
437437
title={`Remove ${
438438
removingProvider ? getCredentialGroupProviderService(removingProvider).name : 'account'
439439
}`}
440-
defaultAction='confirm'
441440
text='People will no longer be asked to connect this account. Existing credentials are retained but will no longer be returned by this group.'
442441
dismissLabel='Cancel'
443442
confirm={{
@@ -454,7 +453,6 @@ export function CredentialGroupDetails({
454453
title={`Remove ${
455454
removingMcpConnector ? MANAGED_MCP_CONNECTORS[removingMcpConnector].name : 'MCP app'
456455
}`}
457-
defaultAction='confirm'
458456
text='People will no longer be able to connect this app. Existing OAuth grants and saved tool metadata will be revoked.'
459457
dismissLabel='Cancel'
460458
confirm={{

apps/sim/ee/credential-groups/components/credential-groups-settings.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import { useQueryState } from 'nuqs'
88
import {
99
credentialGroupIdParam,
1010
credentialGroupIdUrlKeys,
11+
credentialGroupPeopleSearchParam,
12+
credentialGroupPeopleSearchUrlKeys,
1113
credentialGroupProviderSearchParam,
1214
credentialGroupProviderSearchUrlKeys,
1315
credentialGroupTabParam,
@@ -54,15 +56,22 @@ export function CredentialGroupsSettings({ workspaceId }: CredentialGroupsSettin
5456
...credentialGroupProviderSearchParam.parser,
5557
...credentialGroupProviderSearchUrlKeys,
5658
})
59+
/** Scoped to one group's enrolled people, and reset alongside the other two. */
60+
const [, setPeopleSearch] = useQueryState(credentialGroupPeopleSearchParam.key, {
61+
...credentialGroupPeopleSearchParam.parser,
62+
...credentialGroupPeopleSearchUrlKeys,
63+
})
5764
const openGroup = (groupId: string) => {
5865
void setSelectedGroupId(groupId)
5966
void setSelectedTab(null)
6067
void setProviderSearch(null)
68+
void setPeopleSearch(null)
6169
}
6270
const closeGroup = () => {
6371
void setSelectedGroupId(null, { history: 'replace' })
6472
void setSelectedTab(null)
6573
void setProviderSearch(null)
74+
void setPeopleSearch(null)
6675
}
6776
const selectedGroup = selectedGroupId
6877
? groups.find((group) => group.id === selectedGroupId)

packages/emcn/src/components/chip-modal/chip-modal.tsx

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -359,11 +359,8 @@ export interface ChipModalTabsProps {
359359
* content conditionally below.
360360
*
361361
* Reusing `ChipSwitch` keeps every tabbed modal visually identical to the
362-
* segmented toggles elsewhere in the app (e.g. the billing-period switch).
363-
*
364-
* Pinned to `w-fit` so the pill always hugs its tabs: dropped directly into a
365-
* flex column the `inline-flex` trough is otherwise blockified and stretched
366-
* full-width by `align-items: stretch`. A caller-supplied width class still wins.
362+
* segmented toggles elsewhere in the app (e.g. the billing-period switch),
363+
* including the `w-fit` trough that hugs its tabs in a flex column.
367364
*
368365
* @example
369366
* ```tsx
@@ -390,7 +387,7 @@ function ChipModalTabs({
390387
onChange={onChange}
391388
aria-label={ariaLabel}
392389
options={tabs.map((tab) => ({ value: tab.value, label: tab.label, icon: tab.icon }))}
393-
className={cn('w-fit', className)}
390+
className={className}
394391
/>
395392
)
396393
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
import { act } from 'react'
5+
import { createRoot, type Root } from 'react-dom/client'
6+
import { afterEach, describe, expect, it } from 'vitest'
7+
import { ChipSwitch } from './chip-switch'
8+
9+
let root: Root | null = null
10+
let container: HTMLDivElement | null = null
11+
12+
const OPTIONS = [
13+
{ value: 'logs', label: 'Logs' },
14+
{ value: 'input', label: 'Workflow input' },
15+
] as const
16+
17+
function mount(className?: string): HTMLElement {
18+
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
19+
container = document.createElement('div')
20+
document.body.appendChild(container)
21+
root = createRoot(container)
22+
act(() =>
23+
root?.render(
24+
<ChipSwitch
25+
value='logs'
26+
onChange={() => {}}
27+
options={OPTIONS}
28+
aria-label='Stage'
29+
className={className}
30+
/>
31+
)
32+
)
33+
const trough = container.querySelector<HTMLElement>('[role="radiogroup"]')
34+
if (!trough) throw new Error('trough not rendered')
35+
return trough
36+
}
37+
38+
afterEach(() => {
39+
if (root) act(() => root?.unmount())
40+
container?.remove()
41+
root = null
42+
container = null
43+
})
44+
45+
describe('ChipSwitch', () => {
46+
it('hugs its segments by default', () => {
47+
expect(mount().className).toContain('w-fit')
48+
})
49+
50+
it('lets a caller-supplied width win', () => {
51+
const className = mount('w-full').className
52+
expect(className).toContain('w-full')
53+
expect(className).not.toContain('w-fit')
54+
})
55+
56+
it('marks only the active segment as checked', () => {
57+
const trough = mount()
58+
const checked = [...trough.querySelectorAll('[role="radio"]')].map((segment) =>
59+
segment.getAttribute('aria-checked')
60+
)
61+
expect(checked).toEqual(['true', 'false'])
62+
})
63+
})

packages/emcn/src/components/chip-switch/chip-switch.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,11 @@ export interface ChipSwitchProps<T extends string = string> {
4040
* exactly. The active segment is a flat lifted surface against the trough
4141
* (`--surface-2` light / `--surface-6` dark, no shadow) for a clean, even pill.
4242
*
43+
* The trough is pinned to `w-fit` so it always hugs its segments: `inline-flex`
44+
* alone does not survive a flex column, where `align-items: stretch` blockifies
45+
* the container and pulls it edge to edge. A caller-supplied width class still
46+
* wins through {@link cn}.
47+
*
4348
* @example
4449
* <ChipSwitch
4550
* value={view}
@@ -62,7 +67,7 @@ export function ChipSwitch<T extends string>({
6267
role='radiogroup'
6368
aria-label={ariaLabel}
6469
className={cn(
65-
'inline-flex items-center rounded-[10px] bg-[var(--surface-5)] p-[2px] dark:bg-[var(--surface-4)]',
70+
'inline-flex w-fit items-center rounded-[10px] bg-[var(--surface-5)] p-[2px] dark:bg-[var(--surface-4)]',
6671
className
6772
)}
6873
>

0 commit comments

Comments
 (0)