+
{title}
{description &&
{description}
}
- {action}
+ {action &&
{action}
}
)
}
diff --git a/apps/sim/platform/design-system/components/index.ts b/apps/sim/platform/design-system/components/index.ts
index 4f98b66353a..a17e03fb1b1 100644
--- a/apps/sim/platform/design-system/components/index.ts
+++ b/apps/sim/platform/design-system/components/index.ts
@@ -38,6 +38,7 @@ export { OverflowText } from './overflow-text/overflow-text'
export { Pagination } from './pagination/pagination'
export { DatePicker, DateRangePicker } from './picker/date-picker'
export { MultiSelect } from './picker/multi-select'
+export { SearchCombobox } from './picker/search-combobox'
export { SearchableSelect } from './picker/searchable-select'
export { Select } from './picker/select'
export { PropertyList } from './property-list/property-list'
diff --git a/apps/sim/platform/design-system/components/picker/picker.module.css b/apps/sim/platform/design-system/components/picker/picker.module.css
index 02726aaf6bb..222867310a0 100644
--- a/apps/sim/platform/design-system/components/picker/picker.module.css
+++ b/apps/sim/platform/design-system/components/picker/picker.module.css
@@ -50,6 +50,94 @@
background: var(--settings-picker-surface);
}
+.trigger[data-variant="text"] {
+ inline-size: auto;
+ min-block-size: var(--spacing-app-control-sm);
+ padding-inline: var(--spacing-4);
+ border-color: transparent;
+ background: transparent;
+ color: var(--ui-secondary);
+}
+
+.trigger[data-variant="text"]:hover:enabled {
+ border-color: transparent;
+ background: var(--ui-hover);
+}
+
+.detachedPopup {
+ inline-size: min(calc(var(--spacing-320) + var(--spacing-80)), var(--available-width));
+}
+
+.searchField {
+ display: flex;
+ align-items: center;
+ min-inline-size: 0;
+ min-block-size: var(--spacing-app-chip);
+ padding-inline: var(--spacing-7);
+ gap: var(--spacing-6);
+ border: var(--spacing-stroke-default) solid var(--ui-border);
+ border-radius: var(--radius-control);
+ background: var(--ui-reading);
+ color: var(--ui-secondary);
+}
+
+.searchField input {
+ flex: 1;
+ min-inline-size: 0;
+ background: transparent;
+ color: var(--ui-ink);
+ outline: none;
+}
+
+.searchField input::placeholder {
+ color: var(--ui-secondary);
+}
+
+.results {
+ max-block-size: var(--spacing-320);
+}
+
+.result {
+ align-items: flex-start;
+}
+
+.resultCopy {
+ display: grid;
+ flex: 1;
+ min-inline-size: 0;
+ gap: var(--ui-stack-gap);
+ overflow-wrap: anywhere;
+}
+
+.resultDetail,
+.hints {
+ color: var(--ui-secondary);
+}
+
+.resultEnter {
+ flex: none;
+ color: var(--ui-secondary);
+ visibility: hidden;
+}
+
+.result[data-highlighted] .resultEnter {
+ visibility: visible;
+}
+
+.hints {
+ display: flex;
+ justify-content: space-between;
+ gap: var(--spacing-12);
+ padding: var(--spacing-8) var(--ui-row-inset) var(--spacing-4);
+}
+
+@media (pointer: coarse) {
+ .hints,
+ .resultEnter {
+ display: none;
+ }
+}
+
.trigger[data-empty] .value {
color: var(--ui-secondary);
}
diff --git a/apps/sim/platform/design-system/components/picker/search-combobox.tsx b/apps/sim/platform/design-system/components/picker/search-combobox.tsx
new file mode 100644
index 00000000000..d9d239e6d2d
--- /dev/null
+++ b/apps/sim/platform/design-system/components/picker/search-combobox.tsx
@@ -0,0 +1,134 @@
+'use client'
+
+import { type ReactNode, useMemo } from 'react'
+import { Combobox } from '@base-ui/react/combobox'
+import { Icon } from '@/platform/design-system/components/icon/icon'
+import popup from '@/platform/design-system/components/menu/popup.shared.module.css'
+import { usePopoverContainer } from '@/platform/design-system/components/menu/use-popover-container'
+import picker from '@/platform/design-system/components/picker/picker.module.css'
+import type { PickerServerSearch } from '@/platform/design-system/components/picker/search-popup'
+import { LoadMoreFooter } from '@/platform/design-system/components/scroll-area/load-more-footer'
+import { useScrollEdges } from '@/platform/design-system/components/scroll-area/use-scroll-edges'
+
+interface SearchComboboxOption {
+ value: string
+ label: string
+ /** Lines under the label that tell results with the same label apart. */
+ details?: readonly string[]
+ leading?: ReactNode
+}
+
+const OPTION_KEYS = {
+ getValue: (option: SearchComboboxOption) => option.value,
+ getLabel: (option: SearchComboboxOption) => option.label,
+}
+
+interface SearchComboboxProps {
+ label: string
+ placeholder: string
+ /** Names the results above the list. */
+ heading: string
+ emptyLabel: string
+ /** What Enter does to the highlighted result, in the keyboard hints. */
+ enterHint: string
+ options: readonly SearchComboboxOption[]
+ search: PickerServerSearch
+ onSelect: (value: string) => void
+ /** Lets a caller read its options only once the viewer asks for them. */
+ onOpenChange?: (open: boolean) => void
+}
+
+/** A search field that stays on the page, with its server results in a popup under it. */
+export function SearchCombobox({
+ label,
+ placeholder,
+ heading,
+ emptyLabel,
+ enterHint,
+ options,
+ search,
+ onSelect,
+ onOpenChange,
+}: SearchComboboxProps) {
+ const { trigger, container, opener } = usePopoverContainer()
+ const scrollRef = useScrollEdges
()
+ const items = useMemo(() => Combobox.createItems(options, OPTION_KEYS), [options])
+ return (
+ {
+ if (details.reason !== 'item-press') search.onQueryChange(query)
+ }}
+ onOpenChange={onOpenChange}
+ onValueChange={(next) => {
+ if (!next) return
+ search.onQueryChange('')
+ onSelect(next)
+ }}
+ autoHighlight
+ modal={false}
+ >
+
+
+
+
+ {container && (
+
+
+
+
+ {heading}
+
+
+ {search.loading || search.error ? null : emptyLabel}
+
+
+
+ {(option: SearchComboboxOption) => (
+
+ {option.leading}
+
+ {option.label}
+ {option.details?.map((detail) => (
+
+ {detail}
+
+ ))}
+
+
+ ↵
+
+
+ )}
+
+
+
+
+ ↑↓ Navigate
+ {enterHint}
+ Esc to close
+
+
+
+
+ )}
+
+ )
+}
diff --git a/apps/sim/platform/design-system/components/picker/search-popup.tsx b/apps/sim/platform/design-system/components/picker/search-popup.tsx
index 9905d884c9f..4c3bf6b2d9e 100644
--- a/apps/sim/platform/design-system/components/picker/search-popup.tsx
+++ b/apps/sim/platform/design-system/components/picker/search-popup.tsx
@@ -28,11 +28,20 @@ interface SearchPopupProps {
searchLabel: string
container: HTMLElement
search?: PickerServerSearch
+ /** Size the popup for its rows instead of matching its trigger's width. */
+ detached?: boolean
children?: ReactNode
}
/** The floating part of a searchable picker. Must sit inside a `Combobox.Root`. */
-export function SearchPopup({ label, searchLabel, container, search, children }: SearchPopupProps) {
+export function SearchPopup({
+ label,
+ searchLabel,
+ container,
+ search,
+ detached,
+ children,
+}: SearchPopupProps) {
const input = useRef(null)
const queryEdges = useScrollEdges('horizontal', { viewport: input })
const scrollRef = useScrollEdges()
@@ -42,7 +51,7 @@ export function SearchPopup({ label, searchLabel, container, search, children }:
align='start'
positionMethod='fixed'
sideOffset={8}
- className={popup.anchorWidth}
+ className={detached ? `${popup.positioner} ${picker.detachedPopup}` : popup.anchorWidth}
>
@@ -104,6 +108,7 @@ export function SearchableSelect({
searchLabel={searchLabel}
container={selection.container}
search={search}
+ detached={variant === 'text'}
>
{createLabel && (
<>
diff --git a/apps/sim/platform/lib/data/people.ts b/apps/sim/platform/lib/data/people.ts
new file mode 100644
index 00000000000..16702606328
--- /dev/null
+++ b/apps/sim/platform/lib/data/people.ts
@@ -0,0 +1,445 @@
+'use client'
+
+import { useMemo, useState } from 'react'
+import { useQueryStates } from 'nuqs'
+import { isOrganizationSettingsSectionAvailable } from '@/components/settings/navigation'
+import { isApiClientError } from '@/lib/api/client/errors'
+import type {
+ ChartBranch as ContractChartBranch,
+ PeopleImportResult as ContractImportResult,
+ Person as ContractPerson,
+ PersonSummary as ContractPersonSummary,
+ PeopleImportBody,
+ PersonBody,
+} from '@/lib/api/contracts/people'
+import {
+ type PeopleSyncConfig,
+ type PeopleSyncStatus,
+ peopleSyncConfigSchema,
+} from '@/lib/api/contracts/people-sync'
+import { flattenFieldErrors } from '@/lib/api/contracts/primitives'
+import { useDeploymentShape } from '@/lib/core/config/deployment-shape'
+import { organizationRoutes } from '@/lib/navigation/paths'
+import { readPeopleSheet } from '@/lib/people/read-sheet'
+import {
+ boundedSearch,
+ peopleParsers,
+ peopleUrlKeys,
+ serializePeopleView,
+} from '@/lib/people/search-params'
+import { memberProfileFieldsSchema } from '@/lib/people/validation'
+import { saveBlob } from '@/lib/uploads/client/download'
+import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state'
+import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider'
+import { useScimConnection } from '@/ee/scim/hooks/scim'
+import { serializeSsoSettings } from '@/ee/sso/search-params'
+import {
+ useDepartments,
+ useImportPeople,
+ usePeople,
+ usePeopleChart,
+ usePerson,
+ useUpdatePerson,
+} from '@/hooks/queries/people'
+import {
+ isDirectorySyncAccessError,
+ usePeopleSyncActions,
+ usePeopleSyncStatus,
+} from '@/hooks/queries/people-sync'
+import { useDebounce } from '@/hooks/use-debounce'
+import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter'
+import { useIsMobile } from '@/hooks/use-is-mobile'
+import { useSearchFilterValue } from '@/hooks/use-search-filter-value'
+
+export type ChartBranch = ContractChartBranch
+export type PeopleImportResult = ContractImportResult
+export type Person = ContractPerson
+export type PersonSummary = ContractPersonSummary
+export type SyncStatus = PeopleSyncStatus
+export type SyncSource = PeopleSyncConfig['source']
+
+export const SYNC_SOURCE_NAMES: Record = {
+ okta: 'Okta',
+ entra: 'Microsoft Entra ID',
+ scim: 'SCIM provisioning',
+}
+
+/** Who is viewing, and which of the page's administration they may reach. */
+export function usePeopleViewer() {
+ const { organization, viewer, settingsFeatures } = useOrganizationContext()
+ const { features, hosted } = useDeploymentShape()
+ return {
+ organizationName: organization.name,
+ isAdmin: viewer.isAdmin,
+ canProvision: features.scim && (!hosted || settingsFeatures.hasEnterprisePlan),
+ canOpenProvisioningSettings: isOrganizationSettingsSectionAvailable('sso', settingsFeatures),
+ provisioningSettingsHref: serializeSsoSettings(
+ organizationRoutes(organization.id).settingsSection('sso'),
+ { tab: 'provisioning' }
+ ),
+ }
+}
+
+export function personHref(organizationId: string, memberId: string, originQuery = ''): string {
+ return `${organizationRoutes(organizationId).person(memberId)}${originQuery}`
+}
+
+export function isApiConflict(error: unknown): boolean {
+ return isApiClientError(error) && error.status === 409
+}
+
+const REFUSAL_STATUSES = new Set([400, 401, 403, 404])
+
+/** A refusal replaces what is on screen; a transient failure, including a 429, keeps it. */
+export function isRefusal(error: unknown): boolean {
+ return isApiClientError(error) && REFUSAL_STATUSES.has(error.status)
+}
+
+export function usePeopleUrl() {
+ return useQueryStates(peopleParsers, peopleUrlKeys)
+}
+
+type PeopleUrl = ReturnType[0]
+type PeopleUrlOverrides = { [Key in keyof PeopleUrl]?: PeopleUrl[Key] | null }
+
+function profileOriginQuery(origin: PeopleUrl, overrides: PeopleUrlOverrides): string {
+ return serializePeopleView({ ...origin, panel: null, ...overrides })
+}
+
+function peopleViewHref(
+ organizationId: string,
+ origin: PeopleUrl,
+ overrides: PeopleUrlOverrides
+): string {
+ return serializePeopleView(organizationRoutes(organizationId).people, {
+ ...origin,
+ panel: null,
+ ...overrides,
+ })
+}
+
+export function usePeopleDirectory(organizationId: string) {
+ const [origin, setView] = usePeopleUrl()
+ const { search, department, departmentKey } = origin
+ const setSearch = useDebouncedSearchSetter((value, options) =>
+ setView({ view: 'directory', search: value }, options)
+ )
+ const debounced = useSearchFilterValue(search, SEARCH_DEBOUNCE_MS)
+ const query = debounced.trim()
+ const people = usePeople(organizationId, query, department, { departmentKey })
+ const changingFilter = people.isPlaceholderData || query !== search.trim()
+ const pages = people.isRefetchError || changingFilter ? undefined : people.data?.pages
+ const rows = useMemo(() => (pages ?? []).flatMap((page) => page.people), [pages])
+ const hasDepartment = Boolean(departmentKey || department)
+ const departmentLabel = departmentKey
+ ? (!people.isPlaceholderData && people.data?.pages[0]?.selectedDepartment) ||
+ 'Selected department'
+ : department
+ return {
+ origin,
+ search,
+ query,
+ setSearch: (value: string) => setSearch(boundedSearch(value)),
+ rows,
+ hasDepartment,
+ departmentLabel,
+ originQuery: profileOriginQuery(origin, { view: 'directory' }),
+ loading: people.isPending || changingFilter,
+ error: people.isLoadingError || people.isRefetchError ? people.error : null,
+ retry: () => void people.refetch(),
+ loadMore: {
+ hasMore: people.hasNextPage && !changingFilter,
+ loading: people.isFetchingNextPage,
+ error: people.isFetchNextPageError ? people.error.message : undefined,
+ onLoadMore: () => void people.fetchNextPage(),
+ },
+ setDepartment: (next: { name: string; key?: string } | null) =>
+ void setView({
+ view: 'directory',
+ department: next && !next.key ? next.name : null,
+ departmentKey: next?.key ?? null,
+ }),
+ clearFilters: () =>
+ void setView({ view: 'directory', search: '', department: '', departmentKey: '' }),
+ }
+}
+
+/** The filter's option values: stored text can't hold a NUL, so no department collides with these. */
+const DEPARTMENT_KEY_PREFIX = '\u0000key:'
+const ALL_DEPARTMENTS = '\u0000all'
+
+export function useDepartmentOptions(
+ organizationId: string,
+ selected: { department: string; departmentKey: string }
+) {
+ const [open, setOpen] = useState(false)
+ const [term, setTerm] = useState('')
+ const debounced = useSearchFilterValue(term, SEARCH_DEBOUNCE_MS).trim()
+ const departments = useDepartments(organizationId, debounced, open)
+ const waiting = term.trim() !== debounced
+ const options = useMemo(() => {
+ const entries = waiting
+ ? []
+ : (departments.data?.pages.flatMap((page) => page.departments) ?? [])
+ return [
+ { value: ALL_DEPARTMENTS, label: 'All departments' },
+ ...entries.map(({ name, count, departmentKey }) => ({
+ value: departmentKey ? `${DEPARTMENT_KEY_PREFIX}${departmentKey}` : name,
+ label: `${name} (${count})`,
+ })),
+ ]
+ }, [waiting, departments.data])
+ return {
+ options,
+ onOpenChange: setOpen,
+ value: selected.departmentKey
+ ? `${DEPARTMENT_KEY_PREFIX}${selected.departmentKey}`
+ : selected.department || ALL_DEPARTMENTS,
+ /** Maps an option back to the URL's split between a plain name and an opaque key. */
+ parse: (value: string | null): { name: string; key?: string } | null => {
+ if (!value || value === ALL_DEPARTMENTS) return null
+ return value.startsWith(DEPARTMENT_KEY_PREFIX)
+ ? { name: '', key: value.slice(DEPARTMENT_KEY_PREFIX.length) }
+ : { name: value }
+ },
+ search: {
+ query: term,
+ onQueryChange: (query: string) => setTerm(boundedSearch(query)),
+ hasMore: !waiting && departments.hasNextPage,
+ loading: waiting || departments.isFetching,
+ error: waiting ? undefined : departments.error?.message,
+ onLoadMore: () => void departments.fetchNextPage(),
+ onRetry: () => void departments.refetch(),
+ },
+ }
+}
+
+/** Paginated directory search for the chart's pickers and for choosing a manager. */
+export function usePeoplePicker(
+ organizationId: string,
+ scope?: 'roots' | 'members',
+ excludeMemberId?: string
+) {
+ const [open, setOpen] = useState(false)
+ const [term, setTerm] = useState('')
+ const debounced = useDebounce(term.trim(), SEARCH_DEBOUNCE_MS)
+ const query = usePeople(organizationId, debounced, '', { scope, enabled: open })
+ const waiting = term.trim() !== debounced || query.isPlaceholderData
+ const pages = waiting || query.isRefetchError ? undefined : query.data?.pages
+ const people = useMemo(
+ () =>
+ (pages ?? []).flatMap((page) =>
+ page.people.filter((person) => person.memberId !== excludeMemberId)
+ ),
+ [pages, excludeMemberId]
+ )
+ const options = useMemo(
+ () =>
+ people.map((person) => ({
+ value: person.memberId,
+ label: [person.name, person.title, person.email].filter(Boolean).join(' · '),
+ })),
+ [people]
+ )
+ return {
+ people,
+ options,
+ onOpenChange: setOpen,
+ search: {
+ query: term,
+ onQueryChange: (next: string) => setTerm(boundedSearch(next)),
+ hasMore: query.hasNextPage && !waiting,
+ loading: waiting || query.isFetching,
+ error: waiting ? undefined : query.error?.message,
+ onLoadMore: () => void query.fetchNextPage(),
+ onRetry: () => void query.refetch(),
+ },
+ }
+}
+
+const CHART_MAX_PAGE = 1_000_000
+
+export function useOrgChart(organizationId: string) {
+ const [origin, setView] = usePeopleUrl()
+ const { focus, page } = origin
+ const compact = useIsMobile()
+ const size = compact ? 1 : 4
+ const chart = usePeopleChart(
+ organizationId,
+ focus,
+ Math.max(1, Math.min(page, CHART_MAX_PAGE)),
+ size
+ )
+ const branch = isRefusal(chart.error) ? undefined : chart.data
+ return {
+ setView,
+ focus,
+ page,
+ size,
+ compact,
+ branch,
+ error: chart.error,
+ retry: () => void chart.refetch(),
+ profileQuery: profileOriginQuery(origin, {
+ view: 'chart',
+ focus: branch?.person.memberId ?? focus,
+ page: branch?.page ?? page,
+ }),
+ }
+}
+
+/** Whether any reporting line exists yet, and the first team to offer someone who has none. */
+export function useChartRoots(organizationId: string, enabled = true) {
+ const roots = usePeople(organizationId, '', '', { scope: 'roots', enabled })
+ const firstTeam = roots.data?.pages[0]?.people[0]
+ return {
+ firstTeam,
+ emptyOrganization: Boolean(roots.data) && !firstTeam,
+ settled: !roots.isPending,
+ }
+}
+
+export function usePersonProfile(organizationId: string, memberId: string) {
+ const [origin] = usePeopleUrl()
+ const query = usePerson(organizationId, memberId)
+ const loadError = query.data && !isRefusal(query.error) ? null : query.error
+ const data = loadError ? undefined : query.data
+ const originView = origin.view ?? 'directory'
+ return {
+ data,
+ error: loadError,
+ retrying: query.isFetching,
+ retry: () => void query.refetch(),
+ originView,
+ originQuery: profileOriginQuery(origin, { view: originView }),
+ chartHref: peopleViewHref(organizationId, origin, {
+ view: 'chart',
+ focus: data?.person.memberId ?? memberId,
+ page: origin.focus === data?.person.memberId ? origin.page : 1,
+ }),
+ departmentHref: peopleViewHref(organizationId, origin, {
+ view: 'directory',
+ search: '',
+ department: data?.person.departmentKey ? null : data?.person.department,
+ departmentKey: data?.person.departmentKey ?? null,
+ }),
+ backHref: peopleViewHref(organizationId, origin, { view: originView }),
+ directoryHref: peopleViewHref(organizationId, origin, { view: 'directory' }),
+ }
+}
+
+export type PersonDraft = PersonBody
+
+export function useEditPerson(organizationId: string, memberId: string) {
+ const update = useUpdatePerson(organizationId)
+ return {
+ /** Field problems, or null once the update went through. A failed request rejects. */
+ save: async (
+ draft: PersonDraft
+ ): Promise> | null> => {
+ const parsed = memberProfileFieldsSchema.safeParse(draft)
+ if (!parsed.success) return flattenFieldErrors(parsed.error)
+ await update.mutateAsync({ memberId, body: parsed.data })
+ return null
+ },
+ }
+}
+
+export type ImportBody = PeopleImportBody
+
+const IMPORT_TEMPLATE_CSV =
+ 'email,title,department,location,start_date,manager_email\nada@example.com,Engineer,Platform,Berlin,2024-01-31,grace@example.com\n'
+
+export function downloadImportTemplate() {
+ saveBlob(new Blob([IMPORT_TEMPLATE_CSV], { type: 'text/csv' }), 'people-template.csv')
+}
+
+/** The import mutation that keeps provider tokens out of the mutation cache, and its sheet reader. */
+export function usePeopleImport(organizationId: string) {
+ const importPeople = useImportPeople(organizationId)
+ return {
+ submit: importPeople.submit,
+ pending: importPeople.isPending,
+ readSheet: readPeopleSheet,
+ }
+}
+
+export function useProvisioningConnection(organizationId: string, enabled: boolean) {
+ const connection = useScimConnection(organizationId, enabled)
+ const directory = connection.data?.connection
+ return {
+ loading: connection.isLoading,
+ failed: connection.isError,
+ fetching: connection.isFetching,
+ active: directory?.status === 'active',
+ ready: connection.isSuccess && directory?.status === 'active',
+ userCount: directory?.userCount ?? 0,
+ refresh: () => void connection.refetch(),
+ }
+}
+
+export interface SyncDraft {
+ domain: string
+ token: string
+ tenantId: string
+ clientId: string
+ clientSecret: string
+}
+
+export type SyncFieldErrors = Partial>
+
+/**
+ * The connection as the page header reads it. Members never ask: its reads are admin-only. Reads
+ * only the result, so a poll starting or settling doesn't re-render the page under it.
+ */
+export function useDirectorySyncStatus(organizationId: string, enabled: boolean) {
+ const query = usePeopleSyncStatus(organizationId, enabled)
+ const status = isDirectorySyncAccessError(query.error) ? undefined : query.data
+ return { loaded: Boolean(status), settled: !query.isPending, sync: status?.sync ?? null }
+}
+
+/** The status with the action state machine the hook keeps out of the cache. */
+export function useDirectorySync(organizationId: string) {
+ const query = usePeopleSyncStatus(organizationId)
+ const actions = usePeopleSyncActions(organizationId)
+ const denied = isDirectorySyncAccessError(query.error)
+ const status = denied ? undefined : query.data
+ const connect = (
+ source: SyncSource,
+ draft: SyncDraft,
+ onConnected: () => void
+ ): SyncFieldErrors | null => {
+ const parsed = peopleSyncConfigSchema.safeParse(
+ source === 'okta'
+ ? { source: 'okta', domain: draft.domain, token: draft.token }
+ : source === 'entra'
+ ? {
+ source: 'entra',
+ tenantId: draft.tenantId.trim(),
+ clientId: draft.clientId.trim(),
+ clientSecret: draft.clientSecret,
+ }
+ : { source: 'scim' }
+ )
+ if (!parsed.success) return flattenFieldErrors(parsed.error)
+ actions.configure(parsed.data, onConnected)
+ return null
+ }
+ return {
+ denied,
+ loading: query.isPending,
+ loaded: Boolean(status),
+ loadError: query.isError && !status ? query.error : null,
+ fetching: query.isFetching,
+ retry: () => void query.refetch(),
+ sync: status?.sync ?? null,
+ pending: actions.pending,
+ running: actions.action === 'run',
+ actionError: actions.error,
+ reset: actions.reset,
+ run: actions.run,
+ /** Settles with the request, so a dialog can hold itself open as pending. */
+ disconnect: () => new Promise((resolve, reject) => actions.disconnect(resolve, reject)),
+ connect,
+ }
+}
diff --git a/apps/sim/scripts/test-org-redesign-e2e.ts b/apps/sim/scripts/test-org-redesign-e2e.ts
index 445ecb7eeb1..0ded3ed4c11 100644
--- a/apps/sim/scripts/test-org-redesign-e2e.ts
+++ b/apps/sim/scripts/test-org-redesign-e2e.ts
@@ -3227,27 +3227,23 @@ async function peoplePhaseChecks() {
})
})
await check(
- 'a member reads that an administrator connects the directory, and no name',
+ 'a member reads that an organization admin connects the directory, and no name',
async () => {
await withSession(MEMBER, async ({ page }) => {
await page.goto(orgUrl('/people'))
- await expect(
- page
- .getByRole('radio', { name: 'Org chart' })
- .or(page.getByRole('button', { name: 'Org chart' }))
- .first()
- ).toBeVisible()
- await expect(page.getByText(/administrator/i).first()).toBeVisible()
+ await expect(page.getByRole('button', { name: 'Org chart' })).toBeVisible()
+ await expect(page.getByText(/organization admin/i).first()).toBeVisible()
await expect(page.getByRole('button', { name: 'Directory sync' })).toHaveCount(0)
- await expect(page.getByRole('button', { name: 'Import people' })).toHaveCount(0)
+ await expect(page.getByRole('button', { name: 'Import', exact: true })).toHaveCount(0)
+ await expect(page.getByRole('button', { name: 'Connect Okta' })).toHaveCount(0)
})
}
)
await check('an admin can sync and import from the empty state', async () => {
await withSession(ADMIN, async ({ page }) => {
await page.goto(orgUrl('/people'))
- await expect(page.getByRole('button', { name: /Directory sync/ }).first()).toBeVisible()
- await expect(page.getByRole('button', { name: /Import/ }).first()).toBeVisible()
+ await expect(page.getByRole('button', { name: 'Connect Okta' })).toBeVisible()
+ await expect(page.getByRole('button', { name: 'Import profiles' })).toBeVisible()
})
})
await check('the directory lists the organization', async () => {
diff --git a/apps/sim/scripts/test-people-e2e.ts b/apps/sim/scripts/test-people-e2e.ts
index b90de740bf1..1d52aa65a1a 100644
--- a/apps/sim/scripts/test-people-e2e.ts
+++ b/apps/sim/scripts/test-people-e2e.ts
@@ -566,31 +566,40 @@ async function run() {
waitUntil: 'domcontentloaded',
timeout: 180_000,
})
- const focused = (name: string) =>
+ const trail = page.getByRole('navigation', { name: 'Reporting path' })
+ const focused = (name: string) => trail.getByRole('heading', { name, exact: true })
+ const profileOf = (name: string) =>
page.getByRole('link', { name: `View profile for ${name}` })
+ const viewTab = (name: string) =>
+ page
+ .getByRole('navigation', { name: 'People view' })
+ .getByRole('button', { name, exact: true })
await expect(focused('Maya Chen')).toBeVisible({ timeout: 120_000 })
await expect(page.getByRole('button', { name: 'Explore Felix Ward' })).toBeVisible()
const longTitleLabel = page
.getByRole('button', { name: 'Explore Amara Cole' })
.getByText(longTitle, { exact: true })
- await longTitleLabel.hover()
- await expect(
- page.locator('[data-native-surface-overlay]').filter({ hasText: longTitle })
- ).toBeVisible()
+ await longTitleLabel.hover({ position: { x: 4, y: 4 } })
+ await expect(page.locator('[role=tooltip]').filter({ hasText: longTitle })).toBeVisible()
await page.mouse.move(0, 0)
+ await expect(page.getByRole('button', { name: 'Import', exact: true })).toBeVisible()
+ await page.getByRole('button', { name: 'Directory sync', exact: true }).click()
+ await page.getByRole('heading', { name: 'Directory sync', level: 1 }).waitFor()
+ await page.goBack()
+ await expect(focused('Maya Chen')).toBeVisible()
await page.screenshot({ path: join(screenshotDir, 'desktop.png') })
- const card = focused('Maya Chen')
+ const card = page.getByRole('button', { name: 'Explore Felix Ward' })
const cardWidth = await card.evaluate((node) => node.getBoundingClientRect().width)
await page.getByRole('button', { name: 'Zoom in' }).click()
await expect
.poll(() => card.evaluate((node) => node.getBoundingClientRect().width))
.toBeGreaterThan(cardWidth)
await page.getByRole('button', { name: 'Fit chart' }).click()
- const lightCardColor = await card.evaluate((node) => getComputedStyle(node).backgroundColor)
+ const cardColor = () =>
+ card.evaluate((node) => getComputedStyle(node.parentElement as Element).backgroundColor)
+ const lightCardColor = await cardColor()
await page.emulateMedia({ colorScheme: 'dark' })
- await expect
- .poll(() => card.evaluate((node) => getComputedStyle(node).backgroundColor))
- .not.toBe(lightCardColor)
+ await expect.poll(cardColor).not.toBe(lightCardColor)
await page.screenshot({ path: join(screenshotDir, 'desktop-dark.png') })
await page.emulateMedia({ colorScheme: 'light' })
await page.getByRole('button', { name: 'Next reports' }).focus()
@@ -623,8 +632,8 @@ async function run() {
(text) => navigator.clipboard.writeText(text),
`${boundedPaste}${'界'.repeat(4096)}`
)
- await page.getByLabel('Find a person').click()
- const pickerSearch = page.getByPlaceholder('Search name, title or email…')
+ const pickerSearch = page.getByRole('combobox', { name: 'Find a person' })
+ await pickerSearch.click()
await pickerSearch.focus()
await pickerSearch.press('ControlOrMeta+v')
await expect(pickerSearch).toHaveValue(boundedPaste, { timeout: 5_000 })
@@ -639,8 +648,6 @@ async function run() {
try {
await pickerSearch.fill(pagedSearch)
await expect(page.getByRole('option', { name: /^Directory colleague 001/ })).toBeVisible()
- const more = page.getByRole('button', { name: 'Load more', exact: true })
- await expect(more).toBeVisible({ timeout: 5_000 })
await Promise.all([
page.waitForResponse((response) => {
const url = new URL(response.url())
@@ -651,14 +658,15 @@ async function run() {
response.ok()
)
}),
- more.click(),
+ page
+ .getByRole('listbox', { name: 'Find a person' })
+ .evaluate((list) => list.scrollTo(0, list.scrollHeight)),
])
- await more.scrollIntoViewIfNeeded()
await page.getByRole('option', { name: /^Directory colleague 100/ }).click()
await expect(
page.getByText('No reporting lines for Directory colleague 100', { exact: true })
).toBeVisible()
- assert.equal(requestedPages.length, 2, 'One click loads exactly one additional page')
+ assert.equal(requestedPages.length, 2, 'Reaching the end loads one additional page')
assert.equal(requestedPages[0], null)
assert(requestedPages[1], 'Page two resumes with a cursor')
await page.goBack()
@@ -666,17 +674,19 @@ async function run() {
} finally {
await sql`delete from people_directory_sync where organization_id = ${teamOrg} and source_target = 'picker.okta.com'`
}
- await page.getByLabel('Find a person').click()
- await page.getByPlaceholder('Search name, title or email…').fill(' Engineering ')
- await page.getByText(/Ben Ortega · Design Lead/).click()
- await focused('Ben Ortega').click()
+ await pickerSearch.click()
+ await pickerSearch.fill(' Engineering ')
+ await page.getByRole('option', { name: /^Ben Ortega.*Design Lead/ }).click()
+ await expect(focused('Ben Ortega')).toBeVisible()
+ await expect(pickerSearch).toHaveValue('')
+ await profileOf('Ben Ortega').click()
await page.getByRole('link', { name: 'View in org chart' }).click()
await expect(focused('Ben Ortega')).toBeVisible()
- await page.getByLabel('Browse top-level teams').click()
- await page.getByText(/Maya Chen · VP Engineering/).click()
+ await page.getByRole('combobox', { name: 'Browse top-level teams' }).click()
+ await page.getByRole('option', { name: /^Maya Chen · VP Engineering/ }).click()
await expect(focused('Maya Chen')).toBeVisible()
- await page.getByRole('radio', { name: 'Directory' }).click()
- const directorySearch = page.getByRole('textbox', { name: 'Search people...', exact: true })
+ await viewTab('Directory').click()
+ const directorySearch = page.getByRole('searchbox', { name: 'Search people' })
await directorySearch.focus()
await directorySearch.press('ControlOrMeta+v')
await expect(directorySearch).toHaveValue(boundedPaste)
@@ -692,12 +702,12 @@ async function run() {
'People sent an oversized search'
)
await directorySearch.fill('Amara')
- const profileLink = page.getByRole('link', { name: 'Amara Cole', exact: true })
+ const profileLink = page.getByRole('link', { name: /^Amara Cole/ })
await profileLink.focus()
await page.keyboard.press('Enter')
await page.getByRole('link', { name: 'Open Maya Chen' }).click()
const backToResults = page
- .getByRole('link', { name: 'People', exact: true })
+ .getByRole('link', { name: 'Back to directory', exact: true })
.and(page.locator('a[href*="search=Amara"]'))
await backToResults.click()
await expect(directorySearch).toHaveValue('Amara')
@@ -708,9 +718,9 @@ async function run() {
chartOrigin.searchParams.set('search', 'Amara')
chartOrigin.searchParams.set('department', 'Engineering')
await page.goto(chartOrigin.href)
- await focused('Maya Chen').click()
+ await profileOf('Maya Chen').click()
await page
- .getByRole('link', { name: 'People', exact: true })
+ .getByRole('link', { name: 'Back to org chart', exact: true })
.and(page.locator('a[href*="department=Engineering"]'))
.click()
await page.waitForURL(
@@ -726,8 +736,8 @@ async function run() {
await page.waitForURL(
(url) => url.searchParams.get('view') === 'directory' && !url.searchParams.get('search')
)
- await expect(page.getByRole('radio', { name: 'Directory' })).toBeChecked()
- await page.getByRole('radio', { name: 'Org chart' }).click()
+ await expect(viewTab('Directory')).toHaveAttribute('aria-current', 'page')
+ await viewTab('Org chart').click()
await page.goto(new URL(`${path}?focus=${unlinked.memberId}`, baseUrl).href)
await page.getByText('No reporting lines for Rae Dunn').waitFor()
await page.getByRole('button', { name: 'Explore a team' }).click()
@@ -741,15 +751,20 @@ async function run() {
{ name: 'better-auth.session_token', value: outsider.cookie, url: baseUrl.origin },
])
await empty.goto(new URL(`/o/${outsider.orgId}/people`, baseUrl).href)
- await empty.getByText('No reporting lines yet', { exact: true }).waitFor()
+ await empty.getByText('Bring your organization into view', { exact: true }).waitFor()
await empty.screenshot({ path: join(screenshotDir, 'empty-chart.png') })
- await empty.getByRole('button', { name: 'Import people', exact: true }).click()
- const picker = empty.getByRole('dialog', { name: 'Import people data' })
- await picker.getByRole('button', { name: 'Okta', exact: true }).waitFor()
- await expect(picker.getByRole('button', { name: 'SCIM provisioning' })).toHaveCount(0)
+ await empty.getByRole('button', { name: 'Connect Okta', exact: true }).waitFor()
+ await expect(empty.getByRole('button', { name: 'Connect SCIM provisioning' })).toHaveCount(
+ 0
+ )
+ await empty.getByRole('button', { name: 'Import profiles', exact: true }).click()
+ const importDialog = empty.getByRole('dialog', { name: 'Import profiles' })
+ await importDialog.getByRole('combobox', { name: 'Source' }).click()
+ await empty.getByRole('option', { name: 'Okta', exact: true }).waitFor()
+ await expect(empty.getByRole('option', { name: 'SCIM provisioning' })).toHaveCount(0)
await empty.screenshot({ path: join(screenshotDir, 'import-sources.png') })
- await picker.getByRole('button', { name: 'Microsoft Entra ID' }).click()
- await empty.getByRole('dialog', { name: 'Import from Microsoft Entra ID' }).waitFor()
+ await empty.getByRole('option', { name: 'Microsoft Entra ID' }).click()
+ await importDialog.getByLabel('Access token', { exact: true }).waitFor()
await empty.close()
const mobile = await browser.newPage({
isMobile: true,
@@ -781,11 +796,11 @@ async function run() {
}
await mobile.getByRole('button', { name: 'Explore Amara Cole' }).tap()
await mobile.getByRole('button', { name: 'Explore June Evans' }).tap()
- const trail = mobile.getByRole('navigation', { name: 'Reporting path' })
- const heading = trail.getByRole('heading', { name: 'June Evans' })
+ const mobileTrail = mobile.getByRole('navigation', { name: 'Reporting path' })
+ const heading = mobileTrail.getByRole('heading', { name: 'June Evans' })
await heading.scrollIntoViewIfNeeded()
await expect(heading).toBeInViewport()
- const manager = trail.getByRole('button', { name: 'Maya Chen' })
+ const manager = mobileTrail.getByRole('button', { name: 'Maya Chen' })
await manager.scrollIntoViewIfNeeded()
await expect(manager).toBeInViewport()
await manager.tap()
@@ -824,7 +839,7 @@ async function run() {
})
await page.clock.install()
await page.goto(new URL(`/o/${teamOrg}/people?panel=sync`, baseUrl).href)
- await page.getByRole('button', { name: 'Update credentials' }).click()
+ await page.getByRole('button', { name: 'Update connection' }).click()
const token = page
.getByRole('dialog', { name: 'Update Okta credentials' })
.getByLabel('API token', { exact: true })
diff --git a/apps/sim/scripts/test-scim-e2e.ts b/apps/sim/scripts/test-scim-e2e.ts
index 146887ddb52..e099685ba5c 100644
--- a/apps/sim/scripts/test-scim-e2e.ts
+++ b/apps/sim/scripts/test-scim-e2e.ts
@@ -421,18 +421,16 @@ async function run() {
waitUntil: 'domcontentloaded',
timeout: 180_000,
})
- await page.getByRole('button', { name: 'Import people', exact: true }).click()
- const picker = page.getByRole('dialog', { name: 'Import people data' })
- await picker.getByRole('button', { name: 'SCIM provisioning' }).click()
- await page
- .getByRole('dialog', { name: 'Import from SCIM provisioning' })
- .getByRole('button', { name: 'Preview import' })
- .click()
- const modal = page.getByRole('dialog', { name: 'Review import' })
- await expect(modal.getByText(/1 profile will be updated/)).toBeVisible()
+ await page.getByRole('button', { name: /^Import( profiles)?$/ }).click()
+ const importDialog = page.getByRole('dialog', { name: 'Import profiles' })
+ await importDialog.getByRole('combobox', { name: 'Source' }).click()
+ await page.getByRole('option', { name: 'SCIM provisioning' }).click()
+ await importDialog.getByRole('button', { name: 'Preview import' }).click()
+ const modal = page.getByRole('dialog', { name: 'Review profile import' })
+ await expect(modal.getByText('To update', { exact: true })).toBeVisible()
await mkdir(screenshotDir, { recursive: true })
await page.screenshot({ path: join(screenshotDir, 'provisioned-import.png') })
- await modal.getByRole('button', { name: 'Import', exact: true }).click()
+ await modal.getByRole('button', { name: 'Import 1 profile', exact: true }).click()
await expect(modal).toHaveCount(0)
} finally {
await browser.close()
@@ -537,30 +535,33 @@ async function run() {
.addCookies([
{ name: 'better-auth.session_token', value: primary.cookie, url: baseUrl.origin },
])
- await page.goto(
- new URL(`/o/${primary.orgId}/people?focus=${aliceMemberId}`, baseUrl).href
- )
const statusUrl = new URL(syncPath, baseUrl).href
await page.route(statusUrl, (route) =>
route.fulfill({ status: 503, json: { error: 'Unavailable' } })
)
- await page.getByRole('button', { name: 'Directory sync', exact: true }).click()
- const modal = page.getByRole('dialog', { name: 'Directory sync', exact: true })
- await modal.getByRole('button', { name: 'Retry connection' }).waitFor()
+ await page.goto(
+ new URL(`/o/${primary.orgId}/people?focus=${aliceMemberId}`, baseUrl).href
+ )
+ const openSync = page.getByRole('button', { name: 'Directory sync', exact: true })
+ await openSync.click()
+ await page.getByRole('heading', { name: 'Directory sync', level: 1 }).waitFor()
+ await page.getByRole('button', { name: 'Retry', exact: true }).waitFor()
await page.unroute(statusUrl)
- await expect(modal.getByText('Sync failed', { exact: true })).toBeVisible({
+ await expect(page.getByText('Sync failed', { exact: true })).toBeVisible({
timeout: 60_000,
})
- await expect(modal.getByText(/reporting loop/)).toBeVisible()
+ await expect(page.getByText(/reporting loop/)).toBeVisible()
await page.screenshot({ path: join(screenshotDir, 'sync-failure.png') })
- await modal.getByRole('button', { name: 'Retry sync' }).click()
- await expect(modal.getByText('Connected', { exact: true })).toBeVisible()
- await expect(modal.getByText('Last successful sync')).toBeVisible()
+ await page.getByRole('button', { name: 'Retry sync' }).click()
+ await expect(page.getByText('Connected', { exact: true })).toBeVisible()
+ await expect(page.getByText('Last successful sync')).toBeVisible()
await page.setViewportSize({ width: 390, height: 844 })
await page.screenshot({ path: join(screenshotDir, 'sync-recovered-mobile.png') })
- const dialogBox = await modal.boundingBox()
- assert(dialogBox && dialogBox.x >= 0 && dialogBox.x + dialogBox.width <= 391)
+ assert(
+ await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
+ )
assert.equal(record((await chart()).manager).email, bobEmail)
+ await page.getByRole('button', { name: 'Back to people' }).click()
await expect(page.getByText('Principal Engineer', { exact: true })).toBeAttached()
await patch(token, `/Users/${aliceId}`, [
{ op: 'replace', path: 'title', value: 'Staff Platform Engineer' },
@@ -569,20 +570,20 @@ async function run() {
await expect(page.getByText('Staff Platform Engineer', { exact: true })).toBeAttached({
timeout: 60_000,
})
- await modal.getByRole('button', { name: 'Disconnect', exact: true }).click()
+ await openSync.click()
+ await page.getByRole('button', { name: 'Disconnect SCIM provisioning' }).click()
await page
.getByRole('dialog', { name: 'Disconnect directory' })
.getByRole('button', { name: 'Disconnect', exact: true })
.click()
- await expect(
- modal.getByRole('button', { name: 'Connect SCIM provisioning' })
- ).toBeVisible()
- await modal.getByRole('button', { name: 'Connect SCIM provisioning' }).click()
+ const connectScim = page.getByRole('button', { name: 'Connect SCIM provisioning' })
+ await expect(connectScim).toBeVisible()
+ await connectScim.click()
await page
.getByRole('dialog', { name: 'Connect SCIM provisioning' })
.getByRole('button', { name: 'Connect and sync' })
.click()
- await expect(modal.getByText('Connected', { exact: true })).toBeVisible()
+ await expect(page.getByText('Connected', { exact: true })).toBeVisible()
} finally {
await browser.close()
}