From 9a72bad465c6e7d54a9d8dd59337155389ad8cf9 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 6 Nov 2026 10:29:11 -0800 Subject: [PATCH 1/4] Revert "improvement(org): move the People page onto the new design system (#9720)" This reverts commit 76259293f26c0669efd8ad1238431c390538dd24. --- .../_redesign/redesign-host.tsx | 9 - .../[organizationId]/links/links-surface.tsx | 15 + .../app/o/[organizationId]/links/loading.tsx | 6 +- .../sim/app/o/[organizationId]/links/page.tsx | 6 +- .../people/[memberId]/loading.tsx | 11 +- .../people/[memberId]/page.tsx | 19 +- .../people/[memberId]/person-modal.tsx | 148 ++++++ .../people/[memberId]/person.tsx | 233 +++++++++ .../[organizationId]/people/chart-canvas.tsx | 299 ++++++++++++ .../people/chart-empty-state.tsx | 113 +++++ .../app/o/[organizationId]/people/columns.ts | 15 + .../people/directory-sync-form.tsx | 223 +++++++++ .../people/directory-sync-modal.tsx | 289 ++++++++++++ .../[organizationId]/people/import-source.tsx | 183 +++++++ .../people/loading-status.tsx | 17 + .../app/o/[organizationId]/people/loading.tsx | 14 +- .../app/o/[organizationId]/people/page.tsx | 19 +- .../[organizationId]/people/people-chart.tsx | 290 ++++++++++++ .../people/people-directory.tsx | 227 +++++++++ .../people/people-import-modal.tsx | 331 +++++++++++++ .../app/o/[organizationId]/people/people.tsx | 99 ++++ .../people/read-people-sheet.test.ts} | 2 +- .../people/read-people-sheet.ts} | 0 .../[organizationId]}/people/search-params.ts | 5 +- .../people/use-people-picker.tsx | 46 ++ .../components/resource-empty-state/index.ts | 1 + .../resource-load-error.tsx | 16 + apps/sim/hooks/queries/people-sync.ts | 40 +- apps/sim/lib/people/feature-flag.ts | 14 +- .../directory-sync/connect-modal.tsx | 185 -------- .../directory-sync/directory-sources.tsx | 62 --- .../directory-sync/directory-sync-page.tsx | 303 ------------ .../directory-sync/directory-sync.module.css | 202 -------- .../directory/directory.module.css | 45 -- .../_components/directory/directory.tsx | 117 ----- .../external-link/external-link.tsx | 21 - .../_components/import/import-modal.tsx | 319 ------------- .../_components/import/import.module.css | 44 -- .../_components/org-chart/chart-canvas.tsx | 295 ------------ .../org-chart/chart-empty-state.tsx | 69 --- .../org-chart/org-chart.module.css | 272 ----------- .../_components/org-chart/org-chart.tsx | 315 ------------- .../_components/people-page/people-frame.tsx | 46 -- .../people-page/people-page.module.css | 70 --- .../_components/people-page/people-page.tsx | 133 ------ .../people-page/people-skeleton.tsx | 10 - .../_components/people-page/people-status.tsx | 14 - .../people-page/profile-unavailable.tsx | 17 - .../person-identity.module.css | 17 - .../person-identity/person-identity.tsx | 22 - .../_components/person/person-modal.tsx | 114 ----- .../_components/person/person-skeleton.tsx | 12 - .../_components/person/person.module.css | 136 ------ .../people/_components/person/person.tsx | 263 ----------- .../design-system/catalog/icon-geometry.tsx | 15 - .../components/avatar/avatar.module.css | 15 - .../components/avatar/avatar.tsx | 6 +- .../data-display/data-display.module.css | 7 - .../components/data-display/data-display.tsx | 22 +- .../empty-state/empty-state.module.css | 8 +- .../components/empty-state/empty-state.tsx | 4 +- .../design-system/components/index.ts | 1 - .../components/picker/picker.module.css | 88 ---- .../components/picker/search-combobox.tsx | 134 ------ .../components/picker/search-popup.tsx | 13 +- .../components/picker/searchable-select.tsx | 5 - apps/sim/platform/lib/data/people.ts | 445 ------------------ apps/sim/scripts/test-org-redesign-e2e.ts | 18 +- apps/sim/scripts/test-people-e2e.ts | 99 ++-- apps/sim/scripts/test-scim-e2e.ts | 59 ++- 70 files changed, 2687 insertions(+), 4045 deletions(-) create mode 100644 apps/sim/app/o/[organizationId]/links/links-surface.tsx create mode 100644 apps/sim/app/o/[organizationId]/people/[memberId]/person-modal.tsx create mode 100644 apps/sim/app/o/[organizationId]/people/[memberId]/person.tsx create mode 100644 apps/sim/app/o/[organizationId]/people/chart-canvas.tsx create mode 100644 apps/sim/app/o/[organizationId]/people/chart-empty-state.tsx create mode 100644 apps/sim/app/o/[organizationId]/people/columns.ts create mode 100644 apps/sim/app/o/[organizationId]/people/directory-sync-form.tsx create mode 100644 apps/sim/app/o/[organizationId]/people/directory-sync-modal.tsx create mode 100644 apps/sim/app/o/[organizationId]/people/import-source.tsx create mode 100644 apps/sim/app/o/[organizationId]/people/loading-status.tsx create mode 100644 apps/sim/app/o/[organizationId]/people/people-chart.tsx create mode 100644 apps/sim/app/o/[organizationId]/people/people-directory.tsx create mode 100644 apps/sim/app/o/[organizationId]/people/people-import-modal.tsx create mode 100644 apps/sim/app/o/[organizationId]/people/people.tsx rename apps/sim/{lib/people/read-sheet.test.ts => app/o/[organizationId]/people/read-people-sheet.test.ts} (97%) rename apps/sim/{lib/people/read-sheet.ts => app/o/[organizationId]/people/read-people-sheet.ts} (100%) rename apps/sim/{lib => app/o/[organizationId]}/people/search-params.ts (84%) create mode 100644 apps/sim/app/o/[organizationId]/people/use-people-picker.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/resource-load-error.tsx delete mode 100644 apps/sim/platform/app/(shell)/people/_components/directory-sync/connect-modal.tsx delete mode 100644 apps/sim/platform/app/(shell)/people/_components/directory-sync/directory-sources.tsx delete mode 100644 apps/sim/platform/app/(shell)/people/_components/directory-sync/directory-sync-page.tsx delete mode 100644 apps/sim/platform/app/(shell)/people/_components/directory-sync/directory-sync.module.css delete mode 100644 apps/sim/platform/app/(shell)/people/_components/directory/directory.module.css delete mode 100644 apps/sim/platform/app/(shell)/people/_components/directory/directory.tsx delete mode 100644 apps/sim/platform/app/(shell)/people/_components/external-link/external-link.tsx delete mode 100644 apps/sim/platform/app/(shell)/people/_components/import/import-modal.tsx delete mode 100644 apps/sim/platform/app/(shell)/people/_components/import/import.module.css delete mode 100644 apps/sim/platform/app/(shell)/people/_components/org-chart/chart-canvas.tsx delete mode 100644 apps/sim/platform/app/(shell)/people/_components/org-chart/chart-empty-state.tsx delete mode 100644 apps/sim/platform/app/(shell)/people/_components/org-chart/org-chart.module.css delete mode 100644 apps/sim/platform/app/(shell)/people/_components/org-chart/org-chart.tsx delete mode 100644 apps/sim/platform/app/(shell)/people/_components/people-page/people-frame.tsx delete mode 100644 apps/sim/platform/app/(shell)/people/_components/people-page/people-page.module.css delete mode 100644 apps/sim/platform/app/(shell)/people/_components/people-page/people-page.tsx delete mode 100644 apps/sim/platform/app/(shell)/people/_components/people-page/people-skeleton.tsx delete mode 100644 apps/sim/platform/app/(shell)/people/_components/people-page/people-status.tsx delete mode 100644 apps/sim/platform/app/(shell)/people/_components/people-page/profile-unavailable.tsx delete mode 100644 apps/sim/platform/app/(shell)/people/_components/person-identity/person-identity.module.css delete mode 100644 apps/sim/platform/app/(shell)/people/_components/person-identity/person-identity.tsx delete mode 100644 apps/sim/platform/app/(shell)/people/_components/person/person-modal.tsx delete mode 100644 apps/sim/platform/app/(shell)/people/_components/person/person-skeleton.tsx delete mode 100644 apps/sim/platform/app/(shell)/people/_components/person/person.module.css delete mode 100644 apps/sim/platform/app/(shell)/people/_components/person/person.tsx delete mode 100644 apps/sim/platform/design-system/components/picker/search-combobox.tsx delete mode 100644 apps/sim/platform/lib/data/people.ts diff --git a/apps/sim/app/o/[organizationId]/_redesign/redesign-host.tsx b/apps/sim/app/o/[organizationId]/_redesign/redesign-host.tsx index ecac86ac933..857f03e6182 100644 --- a/apps/sim/app/o/[organizationId]/_redesign/redesign-host.tsx +++ b/apps/sim/app/o/[organizationId]/_redesign/redesign-host.tsx @@ -18,12 +18,3 @@ interface RedesignSurfaceProps { export function RedesignSurface({ children }: RedesignSurfaceProps) { return {children} } - -/** Fills the app's content slot, and the grid stretches the surface so its canvas reaches the end. */ -export function RedesignPageSurface({ children }: RedesignSurfaceProps) { - return ( -
- {children} -
- ) -} diff --git a/apps/sim/app/o/[organizationId]/links/links-surface.tsx b/apps/sim/app/o/[organizationId]/links/links-surface.tsx new file mode 100644 index 00000000000..9ea6175bb9c --- /dev/null +++ b/apps/sim/app/o/[organizationId]/links/links-surface.tsx @@ -0,0 +1,15 @@ +import type { ReactNode } from 'react' +import { RedesignSurface } from '@/app/o/[organizationId]/_redesign/redesign-host' + +interface LinksSurfaceProps { + children: ReactNode +} + +/** Fills the app's content slot, and the grid stretches the surface so its canvas reaches the end. */ +export function LinksSurface({ children }: LinksSurfaceProps) { + return ( +
+ {children} +
+ ) +} diff --git a/apps/sim/app/o/[organizationId]/links/loading.tsx b/apps/sim/app/o/[organizationId]/links/loading.tsx index 2aa2aba9ade..004a9e26083 100644 --- a/apps/sim/app/o/[organizationId]/links/loading.tsx +++ b/apps/sim/app/o/[organizationId]/links/loading.tsx @@ -1,4 +1,4 @@ -import { RedesignPageSurface } from '@/app/o/[organizationId]/_redesign/redesign-host' +import { LinksSurface } from '@/app/o/[organizationId]/links/links-surface' import { LinkRowsSkeleton, LinksFrame, @@ -6,10 +6,10 @@ import { export default function SimLinksLoading() { return ( - + - + ) } diff --git a/apps/sim/app/o/[organizationId]/links/page.tsx b/apps/sim/app/o/[organizationId]/links/page.tsx index b788502566e..77b12a2deac 100644 --- a/apps/sim/app/o/[organizationId]/links/page.tsx +++ b/apps/sim/app/o/[organizationId]/links/page.tsx @@ -5,7 +5,7 @@ import { getSession } from '@/lib/auth' import { organizationRoutes } from '@/lib/navigation/paths' import { isSimLinksEnabled } from '@/lib/sim-links/feature-flag' import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect' -import { RedesignPageSurface } from '@/app/o/[organizationId]/_redesign/redesign-host' +import { LinksSurface } from '@/app/o/[organizationId]/links/links-surface' import { loadSimLinksListView } from '@/app/o/[organizationId]/links/search-params' import { LinksPage } from '@/platform/app/(shell)/links/_components/links-page/links-page' import { prefetchSimLinks } from '@/platform/lib/data/prefetch' @@ -34,10 +34,10 @@ export default async function SimLinksPage({ params, searchParams }: SimLinksPag loadSimLinksListView(query) ) return ( - + - + ) } diff --git a/apps/sim/app/o/[organizationId]/people/[memberId]/loading.tsx b/apps/sim/app/o/[organizationId]/people/[memberId]/loading.tsx index e3a7a006921..4d1e787708c 100644 --- a/apps/sim/app/o/[organizationId]/people/[memberId]/loading.tsx +++ b/apps/sim/app/o/[organizationId]/people/[memberId]/loading.tsx @@ -1,10 +1,5 @@ -import { RedesignPageSurface } from '@/app/o/[organizationId]/_redesign/redesign-host' -import { PersonSkeleton } from '@/platform/app/(shell)/people/_components/person/person-skeleton' +import { ApplicationLoading } from '@/app/_shell/application-loading' -export default function PersonLoading() { - return ( - - - - ) +export default function Loading() { + return } diff --git a/apps/sim/app/o/[organizationId]/people/[memberId]/page.tsx b/apps/sim/app/o/[organizationId]/people/[memberId]/page.tsx index cc7373cd2aa..5c3144801f6 100644 --- a/apps/sim/app/o/[organizationId]/people/[memberId]/page.tsx +++ b/apps/sim/app/o/[organizationId]/people/[memberId]/page.tsx @@ -3,12 +3,10 @@ import type { Metadata } from 'next' import { notFound, redirect } from 'next/navigation' import { getSession } from '@/lib/auth' import { organizationRoutes } from '@/lib/navigation/paths' -import { getOrganizationSurfaceContext } from '@/lib/organizations/surface' -import { canBrowsePeople } from '@/lib/people/feature-flag' +import { isPeopleEnabled } from '@/lib/people/feature-flag' import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect' -import { RedesignPageSurface } from '@/app/o/[organizationId]/_redesign/redesign-host' -import { PersonProfile } from '@/platform/app/(shell)/people/_components/person/person' -import { PersonSkeleton } from '@/platform/app/(shell)/people/_components/person/person-skeleton' +import PageLoading from '@/app/o/[organizationId]/people/[memberId]/loading' +import { Person } from '@/app/o/[organizationId]/people/[memberId]/person' export const metadata: Metadata = { title: 'People' } @@ -26,13 +24,10 @@ export default async function PersonPage({ params }: PersonPageProps) { isInviteFlow: false, }) ) - const context = await getOrganizationSurfaceContext(organizationId, session.user.id) - if (!context || !(await canBrowsePeople(organizationId, context.viewer.role))) notFound() + if (!(await isPeopleEnabled(organizationId))) notFound() return ( - - }> - - - + }> + + ) } diff --git a/apps/sim/app/o/[organizationId]/people/[memberId]/person-modal.tsx b/apps/sim/app/o/[organizationId]/people/[memberId]/person-modal.tsx new file mode 100644 index 00000000000..8783105b517 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/people/[memberId]/person-modal.tsx @@ -0,0 +1,148 @@ +'use client' + +import { useState } from 'react' +import { + Chip, + ChipCombobox, + ChipDatePicker, + ChipModal, + ChipModalBody, + ChipModalError, + ChipModalField, + ChipModalFooter, + ChipModalHeader, +} from '@sim/emcn' +import type { Person, PersonBody } from '@/lib/api/contracts/people' +import { memberProfileFieldsSchema, PROFILE_TEXT_MAX_LENGTH } from '@/lib/people/validation' +import { usePeoplePicker } from '@/app/o/[organizationId]/people/use-people-picker' +import { useUpdatePerson } from '@/hooks/queries/people' + +type TextField = 'title' | 'department' | 'location' + +/** The picker's value for no manager; membership ids are never this. */ +const NO_MANAGER = 'no-manager' + +const TEXT_FIELDS: readonly { field: TextField; title: string; placeholder: string }[] = [ + { field: 'title', title: 'Title', placeholder: 'Staff Engineer' }, + { field: 'department', title: 'Department', placeholder: 'Engineering' }, + { field: 'location', title: 'Location', placeholder: 'San Francisco' }, +] + +interface PersonModalProps { + organizationId: string + person: Person + onClose: () => void +} + +/** Edits one person's profile; managers are chosen from the organization's people. */ +export function PersonModal({ organizationId, person, onClose }: PersonModalProps) { + const update = useUpdatePerson(organizationId) + const [draft, setDraft] = useState({ + title: person.title, + department: person.department, + location: person.location, + startDate: person.startDate, + managerMemberId: person.managerMemberId, + }) + const [errors, setErrors] = useState>>({}) + /** + * The chosen manager's name, kept with the choice: the search results may no longer include them. + * Only a stored manager who is suspended has an id and no name. + */ + const [managerName, setManagerName] = useState(person.manager?.name ?? null) + const { people, pickerProps } = usePeoplePicker(organizationId, 'members', person.memberId) + const managerLabel = draft.managerMemberId ? (managerName ?? 'Suspended member') : 'No manager' + + const change = (field: K, value: PersonBody[K]) => { + setDraft((current) => ({ ...current, [field]: value })) + setErrors((current) => ({ ...current, [field]: undefined })) + if (update.isError) update.reset() + } + + const save = () => { + const parsed = memberProfileFieldsSchema.safeParse(draft) + if (!parsed.success) { + setErrors( + Object.fromEntries( + parsed.error.issues.map((issue) => [String(issue.path[0]), issue.message]) + ) + ) + return + } + update.mutate({ memberId: person.memberId, body: parsed.data }, { onSuccess: onClose }) + } + + const title = `Edit ${person.name}` + return ( + !open && onClose()} + dismissDisabled={update.isPending} + srTitle={title} + > + {title} + + {TEXT_FIELDS.map(({ field, title: label, placeholder }) => ( + change(field, value || null)} + error={errors[field]} + /> + ))} + + {() => ( +
+ change('startDate', value)} + placeholder='Not recorded' + disabled={update.isPending} + fullWidth + /> + {draft.startDate && ( + change('startDate', null)}> + Clear + + )} +
+ )} +
+ + {() => ( + { + const manager = people.find((candidate) => candidate.memberId === value) + setManagerName(manager?.name ?? null) + change('managerMemberId', manager?.memberId ?? null) + }} + overlayContent={managerLabel} + searchPlaceholder='Search people...' + emptyMessage='No one matches' + disabled={update.isPending} + className='w-full' + /> + )} + + {update.error?.message} +
+ +
+ ) +} diff --git a/apps/sim/app/o/[organizationId]/people/[memberId]/person.tsx b/apps/sim/app/o/[organizationId]/people/[memberId]/person.tsx new file mode 100644 index 00000000000..c4f9f04103e --- /dev/null +++ b/apps/sim/app/o/[organizationId]/people/[memberId]/person.tsx @@ -0,0 +1,233 @@ +'use client' + +import { useState } from 'react' +import { Avatar, Chip, ChipLink } from '@sim/emcn' +import { ArrowLeft, Workflow } from '@sim/emcn/icons' +import Link from 'next/link' +import { useQueryStates } from 'nuqs' +import { isApiClientError } from '@/lib/api/client/errors' +import type { PersonSummary } from '@/lib/api/contracts/people' +import { organizationRoutes } from '@/lib/navigation/paths' +import { PersonModal } from '@/app/o/[organizationId]/people/[memberId]/person-modal' +import { peopleParsers, serializePeopleView } from '@/app/o/[organizationId]/people/search-params' +import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider' +import { + CredentialDetailHeading, + CredentialDetailLayout, + DetailSection, +} from '@/app/workspace/[workspaceId]/components/credential-detail' +import { + SettingsEmptyState, + SettingsQueryErrorState, +} from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' +import { SettingsField } from '@/app/workspace/[workspaceId]/settings/components/settings-field' +import { + RESOURCE_LIST_STACK, + SettingsResourceRow, +} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' +import { usePerson } from '@/hooks/queries/people' + +const SOURCE_NAMES = { + scim: 'SCIM provisioning', + okta: 'Okta', + entra: 'Microsoft Entra ID', +} as const +const NOT_RECORDED =

Not recorded

+ +const START_DATE_FORMAT = new Intl.DateTimeFormat(undefined, { + dateStyle: 'medium', + timeZone: 'UTC', +}) + +/** `2024-01-31` as a calendar date, with how long ago it was: "Jan 31, 2024 · 2 yrs". */ +function describeStartDate(startDate: string): string { + const start = new Date(`${startDate}T00:00:00Z`) + const now = new Date() + const months = + (now.getUTCFullYear() - start.getUTCFullYear()) * 12 + + now.getUTCMonth() - + start.getUTCMonth() - + (now.getUTCDate() < start.getUTCDate() ? 1 : 0) + const tenure = + months < 0 + ? 'starts soon' + : months < 1 + ? 'new' + : months < 12 + ? `${months} mo` + : `${Math.floor(months / 12)} yr${months >= 24 ? 's' : ''}` + return `${START_DATE_FORMAT.format(start)} · ${tenure}` +} + +interface PersonRowsProps { + organizationId: string + originQuery: string + people: readonly PersonSummary[] + empty: string +} + +function PersonRows({ organizationId, originQuery, people, empty }: PersonRowsProps) { + if (!people.length) return {empty} + const routes = organizationRoutes(organizationId) + return ( +
+ {people.map((person) => ( + } + title={person.name} + description={person.title ?? person.email} + href={`${routes.person(person.memberId)}${originQuery}`} + clickLabel={`Open ${person.name}`} + navigable + /> + ))} +
+ ) +} + +interface PersonProps { + organizationId: string + memberId: string +} + +/** One person's profile: what they do, where they are, and where they sit in the organization. */ +export function Person({ organizationId, memberId }: PersonProps) { + const { viewer } = useOrganizationContext() + const [origin] = useQueryStates(peopleParsers) + const query = usePerson(organizationId, memberId) + const [editing, setEditing] = useState(false) + const routes = organizationRoutes(organizationId) + const originQuery = serializePeopleView({ ...origin, view: origin.view ?? 'directory' }) + const notFound = isApiClientError(query.error) && query.error.status === 404 + const refused = isApiClientError(query.error) && query.error.status < 500 + // A transient refresh failure keeps the loaded profile and any edit open on it; a refusal does not. + const loadError = query.data && !refused ? null : query.error + const data = loadError ? undefined : query.data + + const chartHref = serializePeopleView(routes.people, { + ...origin, + view: 'chart', + focus: data?.person.memberId ?? memberId, + page: origin.focus === data?.person.memberId ? origin.page : 1, + panel: null, + }) + return ( + + People + + } + actions={ + data ? ( + <> + + View in org chart + + {viewer.isAdmin && !data.person.directorySource && ( + setEditing(true)}>Edit + )} + + ) : undefined + } + > + {loadError ? ( + notFound ? ( + + This person isn't in your organization. + + ) : ( + void query.refetch()} + variant='inline' + /> + ) + ) : !data ? ( + Loading… + ) : ( + <> + + } + title={data.person.name} + subtitle={data.person.title} + /> + +
+ + + {data.person.email} + + + + {data.person.department ? ( + + {data.person.department} + + ) : ( + NOT_RECORDED + )} + + {data.person.directorySource && ( + + {SOURCE_NAMES[data.person.directorySource]} + + )} + {data.person.location ?? NOT_RECORDED} + + {data.person.startDate ? describeStartDate(data.person.startDate) : NOT_RECORDED} + +
+
+ + + + + + {data.totalReports > data.reports.length && ( + Browse all {data.totalReports} reports + )} + + + )} + {editing && data && !data.person.directorySource && ( + setEditing(false)} + /> + )} +
+ ) +} diff --git a/apps/sim/app/o/[organizationId]/people/chart-canvas.tsx b/apps/sim/app/o/[organizationId]/people/chart-canvas.tsx new file mode 100644 index 00000000000..f7ae966a52d --- /dev/null +++ b/apps/sim/app/o/[organizationId]/people/chart-canvas.tsx @@ -0,0 +1,299 @@ +'use client' + +import { type MouseEvent, useMemo } from 'react' +import { Avatar, cn, OverflowText, Tooltip } from '@sim/emcn' +import { ArrowUpRight, ChevronRight, Minus, Plus } from '@sim/emcn/icons' +import { + BLOCK_DIMENSIONS, + CONNECTION_KNOB_PEAK_PX, + useCanvasColorMode, + WorkflowBlockBorder, + type WorkflowBorderPort, +} from '@sim/workflow-renderer' +import { + BaseEdge, + type Edge, + type EdgeProps, + Handle, + type Node, + type NodeProps, + Position, + ReactFlow, + useReactFlow, + useStore, +} from '@xyflow/react' +import Link from 'next/link' +import { CanvasToolbar, CanvasToolbarButton } from '@/components/canvas-toolbar' +import type { ChartBranch, PersonSummary } from '@/lib/api/contracts/people' +import { organizationRoutes } from '@/lib/navigation/paths' + +import '@xyflow/react/dist/style.css' + +type CardData = { + person: PersonSummary + relation: 'Manager' | 'In focus' | 'Direct report' + department?: string | null + reportCount?: number + ports: WorkflowBorderPort[] + profileUrl: string + onFocus: (id: string, event: MouseEvent) => void +} +type CardNode = Node +const PORT_POSITIONS = { + top: Position.Top, + bottom: Position.Bottom, + left: Position.Left, + right: Position.Right, +} as const +const WIDTH = BLOCK_DIMENSIONS.FIXED_WIDTH +const HEIGHT = BLOCK_DIMENSIONS.MIN_HEIGHT +const LEVEL = HEIGHT + 48 +const COLUMN = WIDTH / 2 + 32 + +function PersonCard({ data }: NodeProps) { + const { person, relation, reportCount, department, onFocus, profileUrl, ports } = data + const focused = relation === 'In focus' + const contents = ( + <> + + + + + + + + + + + + {focused ? ( + <> + View profile + + + ) : ( + <> + {reportCount === undefined + ? 'Explore team' + : `${reportCount} report${reportCount === 1 ? '' : 's'}`} + + + )} + + + + ) + const actionClass = + 'nodrag nopan pointer-events-auto relative flex size-full cursor-pointer flex-col rounded-2xl bg-[var(--person-card-fill)] text-left outline-offset-2 [--person-card-fill:var(--surface-2)] hover-hover:[--person-card-fill:var(--surface-1)] focus-visible:outline-2 focus-visible:outline-[var(--brand-secondary)] [&_[data-workflow-block-border]_path]:pointer-events-none' + return ( + <> + {ports.map((port) => ( + + ))} + {focused ? ( + + {contents} + + ) : ( + <> + + + + + + + + View profile + + + )} + + ) +} + +function ReportingLine({ sourceX, sourceY, targetX, targetY }: EdgeProps) { + const direction = Math.sign(targetX - sourceX) + const radius = Math.min(9, Math.abs(targetX - sourceX), Math.abs(targetY - sourceY)) + const path = + direction === 0 + ? `M ${sourceX} ${sourceY} V ${targetY}` + : `M ${sourceX} ${sourceY} V ${targetY - radius} Q ${sourceX} ${targetY} ${sourceX + direction * radius} ${targetY} H ${targetX}` + return ( + + ) +} + +const NODE_TYPES = { person: PersonCard } +const EDGE_TYPES = { reporting: ReportingLine } +const MIN_ZOOM = 0.35 +const MAX_ZOOM = 1.6 +const FIT = { padding: 0.22, minZoom: 0.45, maxZoom: 1 } + +function ChartControls() { + const { zoomIn, zoomOut, fitView } = useReactFlow() + const zoom = useStore((state) => state.transform[2]) + return ( + void fitView(FIT)}> + void zoomOut()} + /> + = MAX_ZOOM} + onClick={() => void zoomIn()} + /> + + ) +} + +interface ChartCanvasProps { + profileQuery: string + organizationId: string + branch: ChartBranch + onFocus: CardData['onFocus'] +} + +export function ChartCanvas({ organizationId, branch, onFocus, profileQuery }: ChartCanvasProps) { + const colorMode = useCanvasColorMode() + const { person, reports, chain } = branch + const { nodes, edges } = useMemo(() => { + const routes = organizationRoutes(organizationId) + const nodes: CardNode[] = [] + const edges: Edge[] = [] + function addCard( + member: PersonSummary, + relation: CardData['relation'], + x: number, + y: number, + sides: Position[], + reportCount?: number, + department?: string | null + ) { + nodes.push({ + id: member.memberId, + type: 'person', + position: { x: x - WIDTH / 2, y }, + width: WIDTH, + height: HEIGHT, + data: { + person: member, + relation, + ports: sides.map((side) => ({ + id: side, + side, + position: 'center', + plateau: 14, + hoverAmplitude: 1, + magnetizable: false, + })), + profileUrl: `${routes.person(member.memberId)}${profileQuery}`, + reportCount, + department, + onFocus, + }, + }) + } + function connect(source: string, target: string) { + edges.push({ id: `${source}/${target}`, source, target, type: 'reporting' }) + } + const manager = chain[0] + if (manager) { + addCard(manager, 'Manager', 0, -LEVEL, [Position.Bottom]) + connect(manager.memberId, person.memberId) + } + const focusPorts = [ + ...(manager ? [Position.Top] : []), + ...(reports.length ? [Position.Bottom] : []), + ] + addCard(person, 'In focus', 0, 0, focusPorts, undefined, person.department) + reports.forEach((report, i) => { + const single = reports.length === 1 + const left = i % 2 === 0 + addCard( + report, + 'Direct report', + single ? 0 : left ? -COLUMN : COLUMN, + LEVEL + Math.floor(i / 2) * (HEIGHT + 24), + [single ? Position.Top : left ? Position.Right : Position.Left], + report.reportCount + ) + connect(person.memberId, report.memberId) + }) + return { nodes, edges } + }, [organizationId, person, chain, reports, profileQuery, onFocus]) + + return ( +
+ + + +
+ ) +} diff --git a/apps/sim/app/o/[organizationId]/people/chart-empty-state.tsx b/apps/sim/app/o/[organizationId]/people/chart-empty-state.tsx new file mode 100644 index 00000000000..6125fc95cd0 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/people/chart-empty-state.tsx @@ -0,0 +1,113 @@ +import type { MouseEvent, RefCallback } from 'react' +import { Chip, ChipLink } from '@sim/emcn' +import { ArrowUpRight, ChevronRight, RefreshCw, Upload } from '@sim/emcn/icons' +import { EmptyState } from '@/components/empty-state/empty-state' +import type { PersonSummary } from '@/lib/api/contracts/people' +import { organizationRoutes } from '@/lib/navigation/paths' +import { HAIRLINE } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/hairline' +import { usePeople } from '@/hooks/queries/people' + +function ReportingLinesGraphic() { + return ( + + ) +} + +interface ChartEmptyStateProps { + profileQuery: string + organizationId: string + person: PersonSummary + onFocus: (memberId: string, event: MouseEvent) => void + currentPersonRef: RefCallback + onImport?: () => void + onSync?: () => void +} + +export function ChartEmptyState({ + organizationId, + profileQuery, + person, + onFocus, + currentPersonRef, + onImport, + onSync, +}: ChartEmptyStateProps) { + const roots = usePeople(organizationId, '', '', { scope: 'roots' }) + const firstTeam = roots.isSuccess ? roots.data.pages[0]?.people[0] : undefined + const emptyOrganization = roots.isSuccess && !firstTeam + const routes = organizationRoutes(organizationId) + + return ( + } + title={emptyOrganization ? 'No reporting lines yet' : `No reporting lines for ${person.name}`} + description={ + emptyOrganization + ? onImport + ? 'Connect a directory, import people data, or add a manager to a profile to build your org chart.' + : 'An administrator needs to connect the company directory, such as Okta, to build the org chart.' + : 'This profile has no visible manager or direct reports.' + } + action={ +
+ {firstTeam ? ( + onFocus(firstTeam.memberId, event)} + > + Explore a team + + ) : emptyOrganization && onSync ? ( + + Manage directory sync + + ) : null} + {emptyOrganization && onImport && ( + + Import people + + )} + + View profile + +
+ } + /> + ) +} diff --git a/apps/sim/app/o/[organizationId]/people/columns.ts b/apps/sim/app/o/[organizationId]/people/columns.ts new file mode 100644 index 00000000000..9a7a45f83fd --- /dev/null +++ b/apps/sim/app/o/[organizationId]/people/columns.ts @@ -0,0 +1,15 @@ +import { Upload } from '@sim/emcn/icons' +import type { ResourceColumn } from '@/app/workspace/[workspaceId]/components/resource/resource' + +/** Shared by the page and its loading chrome so the skeleton matches the table. */ +export const PEOPLE_COLUMNS = [ + { id: 'name', header: 'Name', widthMultiplier: 1.4 }, + { id: 'title', header: 'Title', widthMultiplier: 1.4 }, + { id: 'department', header: 'Department' }, + { id: 'location', header: 'Location' }, + { id: 'manager', header: 'Manager', widthMultiplier: 1.2 }, +] as const satisfies readonly ResourceColumn[] + +export const PEOPLE_SEARCH_PLACEHOLDER = 'Search people...' + +export const IMPORT_PEOPLE_ACTION = { text: 'Import', icon: Upload } as const diff --git a/apps/sim/app/o/[organizationId]/people/directory-sync-form.tsx b/apps/sim/app/o/[organizationId]/people/directory-sync-form.tsx new file mode 100644 index 00000000000..9d68645cd42 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/people/directory-sync-form.tsx @@ -0,0 +1,223 @@ +import { useState } from 'react' +import { + Chip, + ChipLink, + ChipModal, + ChipModalBody, + ChipModalDescription, + ChipModalError, + ChipModalField, + ChipModalFooter, + ChipModalHeader, +} from '@sim/emcn' +import { isOrganizationSettingsSectionAvailable } from '@/components/settings/navigation' +import { + type PeopleSyncConfig, + type PeopleSyncStatus, + peopleSyncConfigSchema, +} from '@/lib/api/contracts/people-sync' +import { flattenFieldErrors } from '@/lib/api/contracts/primitives' +import { organizationRoutes } from '@/lib/navigation/paths' +import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider' +import { useScimConnection } from '@/ee/scim/hooks/scim' +import { serializeSsoSettings } from '@/ee/sso/search-params' +import type { usePeopleSyncActions } from '@/hooks/queries/people-sync' + +interface DirectorySyncFormProps { + organizationId: string + source: PeopleSyncConfig['source'] + name: string + current: PeopleSyncStatus | null + actions: ReturnType + onBack: () => void + onClose: () => void +} + +export function DirectorySyncForm({ + organizationId, + source, + name, + current, + actions, + onBack, + onClose, +}: DirectorySyncFormProps) { + const [domain, setDomain] = useState(current?.domain ?? '') + const [tenantId, setTenantId] = useState(current?.tenantId ?? '') + const [clientId, setClientId] = useState(current?.clientId ?? '') + const [secret, setSecret] = useState('') + const [fieldErrors, setFieldErrors] = useState< + Partial> + >({}) + const { settingsFeatures } = useOrganizationContext() + const provisioning = useScimConnection(organizationId, source === 'scim') + const scimReady = provisioning.isSuccess && provisioning.data.connection?.status === 'active' + const title = current ? `Update ${name} credentials` : `Connect ${name}` + const disabled = actions.pending + const canSubmit = + source === 'scim' + ? scimReady + : secret.trim() && (source === 'okta' ? domain.trim() : tenantId.trim() && clientId.trim()) + + function connect() { + if (disabled) return + const config = peopleSyncConfigSchema.safeParse( + source === 'okta' + ? { source, domain, token: secret } + : source === 'entra' + ? { source, tenantId: tenantId.trim(), clientId: clientId.trim(), clientSecret: secret } + : { source } + ) + if (!config.success) { + setFieldErrors(flattenFieldErrors(config.error)) + return + } + setFieldErrors({}) + actions.configure(config.data, onBack) + } + + return ( + !open && onClose()} srTitle={title}> + {title} + + + {source === 'okta' + ? 'Use an Okta API token with permission to list users in your organization.' + : source === 'entra' + ? 'Register an app in your Microsoft Entra tenant and grant administrator consent for the Microsoft Graph User.Read.All application permission. Use the client secret value.' + : 'Use profiles from your organization’s existing SCIM provisioning connection.'} + + {source === 'okta' && ( + <> + + +
+ + Create an Okta API token + +
+ + )} + {source === 'entra' && ( + <> + + + +
+ + Register an application + +
+ + )} + {source === 'scim' && ( + <> + + {() => ( +
+

+ {provisioning.isPending + ? 'Checking provisioning…' + : provisioning.isError + ? 'Could not read the provisioning connection.' + : scimReady + ? 'Active' + : 'Enable provisioning before connecting this source.'} +

+ {isOrganizationSettingsSectionAvailable('sso', settingsFeatures) && ( + + Manage provisioning + + )} + {!provisioning.isFetching && !scimReady && ( + void provisioning.refetch()}>Check again + )} +
+ )} +
+ + )} + {source !== 'scim' && ( + + Sim stores these credentials encrypted for scheduled syncs. + + )} + {disabled && ( + + Sync is running. You can close this window and return for the result. + + )} + {actions.error?.message} +
+ +
+ ) +} diff --git a/apps/sim/app/o/[organizationId]/people/directory-sync-modal.tsx b/apps/sim/app/o/[organizationId]/people/directory-sync-modal.tsx new file mode 100644 index 00000000000..062b4589bf0 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/people/directory-sync-modal.tsx @@ -0,0 +1,289 @@ +import { useState } from 'react' +import { + Badge, + Chip, + ChipConfirmModal, + ChipModal, + ChipModalBody, + ChipModalDescription, + ChipModalError, + ChipModalField, + ChipModalFooter, + ChipModalHeader, +} from '@sim/emcn' +import type { PeopleSyncConfig, PeopleSyncStatus } from '@/lib/api/contracts/people-sync' +import { useDeploymentShape } from '@/lib/core/config/deployment-shape' +import { DirectorySyncForm } from '@/app/o/[organizationId]/people/directory-sync-form' +import { IMPORT_SOURCES } from '@/app/o/[organizationId]/people/import-source' +import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider' +import { + RESOURCE_LIST_STACK, + SettingsResourceRow, +} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' +import { + isDirectorySyncAccessError, + usePeopleSyncActions, + usePeopleSyncStatus, +} from '@/hooks/queries/people-sync' + +const SOURCES = { + okta: IMPORT_SOURCES.okta, + entra: IMPORT_SOURCES.entra, + scim: IMPORT_SOURCES.directory, +} as const +const STATES = { + scheduled: { label: 'Connected', variant: 'green' }, + syncing: { label: 'Syncing', variant: 'amber' }, + queued: { label: 'Queued', variant: 'amber' }, + failed: { label: 'Sync failed', variant: 'red' }, + paused: { label: 'Paused', variant: 'orange' }, +} as const +const dateFormat = new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }) +const PHASE_LABELS = { + users: 'Reading people', + relationships: 'Reading reporting lines', + publish: 'Validating and publishing', +} as const + +interface DirectorySyncModalProps { + organizationId: string + onClose: () => void +} + +interface SyncStatusProps { + sync: PeopleSyncStatus +} +function SyncStatus({ sync }: SyncStatusProps) { + const paused = sync.state === 'paused' + const { label, variant } = + !paused && sync.retryBlocked && sync.state !== 'syncing' + ? { label: 'Waiting to retry', variant: 'amber' as const } + : sync.progress && sync.state === 'queued' + ? { label: 'Refresh in progress', variant: 'blue' as const } + : STATES[sync.state] + const { progress } = sync + const dates = [ + ['Last attempted', sync.lastAttemptAt], + ['Last successful sync', sync.lastSuccessAt], + ...(sync.lastFailedAt ? [['Last failed sync', sync.lastFailedAt]] : []), + ...(!paused + ? [sync.retryAt ? ['Next retry', sync.retryAt] : ['Next scheduled sync', sync.nextSyncAt]] + : []), + ] as const + return ( + <> + + {() => ( +
+ + {SOURCES[sync.source].name} + {sync.domain && ` · ${sync.domain}`} + + + + {label} + + +
+ )} +
+ {progress && !paused && ( + + {() => ( +
+

+ {PHASE_LABELS[progress.phase]} · {progress.usersRead.toLocaleString()} users read +

+

+ The chart updates after the full directory has been validated. +

+
+ )} +
+ )} + + {() => ( +
+ {dates.map(([label, value]) => ( +
+
{label}
+
+ {value ? : '—'} +
+
+ ))} +
+ )} +
+ {sync.lastError && + (paused ? ( + Last sync error: {sync.lastError} + ) : ( + {sync.lastError} + ))} + + ) +} + +export function DirectorySyncModal({ organizationId, onClose }: DirectorySyncModalProps) { + const { settingsFeatures } = useOrganizationContext() + const { features, hosted } = useDeploymentShape() + const canProvision = features.scim && (!hosted || settingsFeatures.hasEnterprisePlan) + const query = usePeopleSyncStatus(organizationId) + const actions = usePeopleSyncActions(organizationId) + const [mode, setMode] = useState(null) + const status = isDirectorySyncAccessError(query.error) ? undefined : query.data + const sync = status?.sync ?? null + const activeMode = status ? mode : null + const goBack = () => { + setMode(null) + actions.reset() + } + if (activeMode === 'disconnect') + return ( + !open && goBack()} + title='Disconnect directory' + text='Disconnecting removes the profile details and reporting lines supplied by this directory. Sim keeps organization memberships and manually recorded profiles.' + confirm={{ + label: 'Disconnect', + pending: actions.pending, + pendingLabel: 'Disconnecting…', + onClick: () => actions.disconnect(goBack), + }} + > + {actions.error?.message} + + ) + if (activeMode) + return ( + + ) + const title = 'Directory sync' + const syncing = sync?.state === 'syncing' || actions.pending + const paused = sync?.state === 'paused' + const waitingToRetry = Boolean(sync?.retryBlocked) + return ( + !open && onClose()} srTitle={title}> + {title} + + {query.isPending ? ( + Loading directory connection… + ) : query.isError && !status ? ( + <> + {query.error.message} +
+ void query.refetch()}> + Retry connection + +
+ + ) : sync ? ( + <> + + {paused + ? 'SCIM provisioning is disabled. Re-enable it in organization settings to resume updates. Your last published chart remains available.' + : 'People updates from this directory automatically. You can also start a sync now.'} + + +
+ {sync.source !== 'scim' && ( + { + actions.reset() + setMode(sync.source) + }} + > + Update credentials + + )} + { + actions.reset() + setMode('disconnect') + }} + > + Disconnect + +
+ {actions.action === 'run' && !paused && ( + + Sync is running. You can close this window and return for the result. + + )} + {actions.error?.message} + + ) : ( + <> + + Connect a directory to keep profiles and reporting lines up to date. + + + {() => ( +
+ {(['okta', 'entra', 'scim'] as const).map((source) => { + if (source === 'scim' && !canProvision) return null + const { name, icon: Icon } = SOURCES[source] + return ( + } + clickLabel={`Connect ${name}`} + onClick={() => setMode(source)} + navigable + /> + ) + })} +
+ )} +
+ + )} +
+ +
+ ) +} diff --git a/apps/sim/app/o/[organizationId]/people/import-source.tsx b/apps/sim/app/o/[organizationId]/people/import-source.tsx new file mode 100644 index 00000000000..b1b0d1d0872 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/people/import-source.tsx @@ -0,0 +1,183 @@ +import { Chip, ChipLink, ChipModalDescription, ChipModalField } from '@sim/emcn' +import { ShieldCheck, Table } from '@sim/emcn/icons' +import { MicrosoftIcon, OktaIcon } from '@/components/icons' +import { organizationRoutes } from '@/lib/navigation/paths' +import { + RESOURCE_LIST_STACK, + SettingsResourceRow, +} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' +import type { useScimConnection } from '@/ee/scim/hooks/scim' +import { serializeSsoSettings } from '@/ee/sso/search-params' + +export const IMPORT_SOURCES = { + okta: { name: 'Okta', description: 'Read profiles from your Okta directory', icon: OktaIcon }, + entra: { + name: 'Microsoft Entra ID', + description: 'Read profiles from Microsoft Graph', + icon: MicrosoftIcon, + }, + directory: { + name: 'SCIM provisioning', + description: 'Use directory data already provisioned to Sim', + icon: ShieldCheck, + }, + file: { name: 'Spreadsheet', description: 'Upload a CSV or Excel file', icon: Table }, +} as const + +export type ImportSource = keyof typeof IMPORT_SOURCES + +interface ImportSourcePickerProps { + canProvision: boolean + onChoose: (source: ImportSource) => void +} + +export function ImportSourcePicker({ canProvision, onChoose }: ImportSourcePickerProps) { + return ( + <> + + Choose a source for a one-time update to members’ job titles, departments, and reporting + lines. + + + {() => ( +
+ {(['okta', 'entra', 'directory', 'file'] as const).map((source) => { + if (source === 'directory' && !canProvision) return null + const { name, description, icon: Icon } = IMPORT_SOURCES[source] + return ( + } + title={name} + description={description} + clickLabel={name} + onClick={() => onChoose(source)} + navigable + /> + ) + })} +
+ )} +
+ + ) +} + +interface ProviderFieldsProps { + source: 'okta' | 'entra' + domain: string + onDomainChange: (value: string) => void + token: string + onTokenChange: (value: string) => void + disabled: boolean +} + +export function ProviderFields({ + source, + domain, + onDomainChange, + token, + onTokenChange, + disabled, +}: ProviderFieldsProps) { + const okta = source === 'okta' + return ( + <> + + {okta + ? 'Use an Okta API token that can read users in your organization.' + : 'In Graph Explorer, sign in with your work or school account, grant delegated User.Read.All permission, and copy your access token. Microsoft commercial cloud is supported.'}{' '} + Sim uses the token for this import only. + + {okta && ( + + )} + +
+ + {okta ? 'Create an Okta API token' : 'Open Graph Explorer'} + +
+ + ) +} + +interface ProvisioningSourceProps { + organizationId: string + connection: ReturnType + canOpenSettings: boolean +} + +export function ProvisioningSource({ + organizationId, + connection, + canOpenSettings, +}: ProvisioningSourceProps) { + const directory = connection.data?.connection + const active = directory?.status === 'active' + return ( + <> + + {connection.isLoading + ? 'Checking your provisioning connection…' + : connection.isError + ? 'Could not load your provisioning connection.' + : !active + ? 'Enable SCIM provisioning and assign accounts in your provider to import their profiles.' + : directory.userCount === 0 + ? 'Your connection is active. Assign accounts to Sim in your identity provider, then refresh here.' + : `Your connection has ${directory.userCount} provisioned ${directory.userCount === 1 ? 'account' : 'accounts'}. Preview the profile fields and reporting lines sent by your provider.`} + +
+ {canOpenSettings && !connection.isLoading && !connection.error && ( + + {active ? 'Manage provisioning' : 'Set up provisioning'} + + )} + {!connection.isLoading && !active && ( + + Provisioning guide + + )} + {!connection.isLoading && ( + void connection.refetch()}> + {connection.isError ? 'Retry connection' : 'Refresh'} + + )} +
+ + ) +} diff --git a/apps/sim/app/o/[organizationId]/people/loading-status.tsx b/apps/sim/app/o/[organizationId]/people/loading-status.tsx new file mode 100644 index 00000000000..d5bcd1daf60 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/people/loading-status.tsx @@ -0,0 +1,17 @@ +import { Loader } from '@sim/emcn' + +interface LoadingStatusProps { + children: string +} + +export function LoadingStatus({ children }: LoadingStatusProps) { + return ( +
+ + {children} +
+ ) +} diff --git a/apps/sim/app/o/[organizationId]/people/loading.tsx b/apps/sim/app/o/[organizationId]/people/loading.tsx index 4a966f50016..0bedf388c48 100644 --- a/apps/sim/app/o/[organizationId]/people/loading.tsx +++ b/apps/sim/app/o/[organizationId]/people/loading.tsx @@ -1,10 +1,14 @@ -import { RedesignPageSurface } from '@/app/o/[organizationId]/_redesign/redesign-host' -import { PeopleSkeleton } from '@/platform/app/(shell)/people/_components/people-page/people-skeleton' +'use client' + +import { Users } from '@sim/emcn/icons' +import { LoadingStatus } from '@/app/o/[organizationId]/people/loading-status' +import { Resource } from '@/app/workspace/[workspaceId]/components/resource/resource' export default function PeopleLoading() { return ( - - - + + + Loading people… + ) } diff --git a/apps/sim/app/o/[organizationId]/people/page.tsx b/apps/sim/app/o/[organizationId]/people/page.tsx index dc128179cd7..a8927477798 100644 --- a/apps/sim/app/o/[organizationId]/people/page.tsx +++ b/apps/sim/app/o/[organizationId]/people/page.tsx @@ -3,12 +3,10 @@ import type { Metadata } from 'next' import { notFound, redirect } from 'next/navigation' import { getSession } from '@/lib/auth' import { organizationRoutes } from '@/lib/navigation/paths' -import { getOrganizationSurfaceContext } from '@/lib/organizations/surface' -import { canBrowsePeople } from '@/lib/people/feature-flag' +import { isPeopleEnabled } from '@/lib/people/feature-flag' import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect' -import { RedesignPageSurface } from '@/app/o/[organizationId]/_redesign/redesign-host' -import { PeoplePage as PeopleView } from '@/platform/app/(shell)/people/_components/people-page/people-page' -import { PeopleSkeleton } from '@/platform/app/(shell)/people/_components/people-page/people-skeleton' +import PageLoading from '@/app/o/[organizationId]/people/loading' +import { People } from '@/app/o/[organizationId]/people/people' export const metadata: Metadata = { title: 'People' } @@ -26,13 +24,10 @@ export default async function PeoplePage({ params }: PeoplePageProps) { isInviteFlow: false, }) ) - const context = await getOrganizationSurfaceContext(organizationId, session.user.id) - if (!context || !(await canBrowsePeople(organizationId, context.viewer.role))) notFound() + if (!(await isPeopleEnabled(organizationId))) notFound() return ( - - }> - - - + }> + + ) } diff --git a/apps/sim/app/o/[organizationId]/people/people-chart.tsx b/apps/sim/app/o/[organizationId]/people/people-chart.tsx new file mode 100644 index 00000000000..d8a07255ebf --- /dev/null +++ b/apps/sim/app/o/[organizationId]/people/people-chart.tsx @@ -0,0 +1,290 @@ +'use client' + +import { + Fragment, + type MouseEvent, + type ReactNode, + useCallback, + useLayoutEffect, + useRef, +} from 'react' +import { + Button, + Chip, + ChipCombobox, + cn, + scrollFadeAttributes, + scrollFadeXClass, + useScrollEdges, +} from '@sim/emcn' +import { ChevronLeft, ChevronRight, Search } from '@sim/emcn/icons' +import { useQueryStates } from 'nuqs' +import { EmptyState } from '@/components/empty-state/empty-state' +import { isApiClientError } from '@/lib/api/client/errors' +import { ChartCanvas } from '@/app/o/[organizationId]/people/chart-canvas' +import { ChartEmptyState } from '@/app/o/[organizationId]/people/chart-empty-state' +import { LoadingStatus } from '@/app/o/[organizationId]/people/loading-status' +import { + peopleParsers, + peopleUrlKeys, + serializePeopleView, +} from '@/app/o/[organizationId]/people/search-params' +import { usePeoplePicker } from '@/app/o/[organizationId]/people/use-people-picker' +import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider' +import { ResourceLoadError } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state' +import { usePeopleChart } from '@/hooks/queries/people' +import { useIsMobile } from '@/hooks/use-is-mobile' + +interface PeopleChartProps { + organizationId: string + onImport?: () => void + onSync?: () => void + viewSwitch: ReactNode +} + +interface FindPersonProps { + rootsLabel?: string + organizationId: string + onSelect: (id: string) => void +} + +function FindPerson({ organizationId, onSelect, rootsLabel }: FindPersonProps) { + const { search, pickerProps } = usePeoplePicker(organizationId, rootsLabel ? 'roots' : undefined) + return ( + + + Find a person + + ) + } + overlayLabel={rootsLabel ?? 'Find a person'} + placeholder={rootsLabel ?? 'Find a person'} + searchPlaceholder={rootsLabel ? 'Search team leads…' : 'Search name, title or email…'} + className={rootsLabel ? 'max-w-48' : 'w-44 max-w-full'} + dropdownWidth={300} + align='start' + onChange={onSelect} + emptyMessage={ + rootsLabel && !search ? 'No top-level teams with reporting lines yet' : 'No matching people' + } + /> + ) +} + +interface ReportingPathProps { + children: ReactNode +} + +function ReportingPath({ children }: ReportingPathProps) { + const scrollRef = useRef(null) + const edges = useScrollEdges(scrollRef, { axis: 'x' }) + return ( + + ) +} + +export function PeopleChart({ organizationId, viewSwitch, onImport, onSync }: PeopleChartProps) { + const { organization } = useOrganizationContext() + const [origin, setView] = useQueryStates(peopleParsers, peopleUrlKeys) + const { focus, page } = origin + const size = useIsMobile() ? 1 : 4 + const chart = usePeopleChart(organizationId, focus, Math.max(1, Math.min(page, 1_000_000)), size) + const notFound = isApiClientError(chart.error) && chart.error.status === 404 + const refused = isApiClientError(chart.error) && chart.error.status < 500 + // A transient refresh failure keeps the chart on screen; a refusal replaces it. + const branch = refused ? undefined : chart.data + const profileQuery = serializePeopleView({ + ...origin, + view: 'chart', + focus: branch?.person.memberId ?? focus, + page: branch?.page ?? page, + panel: null, + }) + const currentPerson = useRef(null) + const previousReports = useRef(null) + const nextReports = useRef(null) + const pendingFocus = useRef<{ + focus: string + page: number + trigger: HTMLElement + target: 'person' | 'previous' | 'next' + } | null>(null) + const focusPerson = useCallback( + (id: string, event?: MouseEvent) => { + pendingFocus.current = + event?.detail === 0 + ? { focus: id, page: 1, trigger: event.currentTarget, target: 'person' } + : null + void setView({ focus: id, page: 1, view: 'chart' }, { history: 'push' }) + }, + [setView] + ) + const changePage = ( + value: number, + target: 'previous' | 'next', + event: MouseEvent + ) => { + pendingFocus.current = + event.detail === 0 ? { focus, page: value, trigger: event.currentTarget, target } : null + void setView({ page: value }) + } + useLayoutEffect(() => { + const pending = pendingFocus.current + if (!branch || !pending || pending.focus !== focus || pending.page !== page) return + pendingFocus.current = null + if (document.activeElement !== document.body && document.activeElement !== pending.trigger) + return + const controls = + pending.target === 'next' + ? [nextReports.current, previousReports.current] + : [previousReports.current, nextReports.current] + const target = + pending.target === 'person' + ? currentPerson.current + : (controls.find((button) => button && !button.disabled) ?? currentPerson.current) + target?.focus() + }, [branch, focus, page]) + const setCurrentPerson = useCallback((element: HTMLElement | null) => { + currentPerson.current = element + }, []) + return ( + <> +
+ {viewSwitch} +
+
+ +
+ focusPerson('', event)} disabled={!focus && page === 1}> + My position + +
+
+ {branch && !branch.chain.length && !branch.totalReports ? ( +
+ +
+ ) : branch ? ( + <> + +
+ +
+ {branch.chain + .slice() + .reverse() + .map((manager) => ( + + + focusPerson(manager.memberId, event)}> + {manager.name} + + + ))} + +

+ {branch.person.name} +

+
+ +
+ + {branch.totalReports + ? `${branch.totalReports} direct report${branch.totalReports === 1 ? '' : 's'}` + : 'No direct reports'} + + {branch.totalReports > size && ( +
+ + {size === 1 + ? branch.page + : `${(branch.page - 1) * size + 1}–${(branch.page - 1) * size + branch.reports.length}`}{' '} + of {branch.totalReports} + + + +
+ )} +
+ + ) : chart.error ? ( + notFound ? ( + focusPerson('', event)}>My position + ) : ( + void setView({ view: 'directory' })}>Open directory + ) + } + /> + ) : ( + void chart.refetch()} /> + ) + ) : ( + Loading org chart… + )} + + ) +} diff --git a/apps/sim/app/o/[organizationId]/people/people-directory.tsx b/apps/sim/app/o/[organizationId]/people/people-directory.tsx new file mode 100644 index 00000000000..9f7cac3bf23 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/people/people-directory.tsx @@ -0,0 +1,227 @@ +'use client' + +import { useCallback, useMemo, useState } from 'react' +import { Avatar, Chip, ChipCombobox } from '@sim/emcn' +import Link from 'next/link' +import { useRouter } from 'next/navigation' +import { useQueryStates } from 'nuqs' +import { EmptyState } from '@/components/empty-state/empty-state' +import type { PersonSummary } from '@/lib/api/contracts/people' +import { organizationRoutes } from '@/lib/navigation/paths' +import { PEOPLE_SEARCH_MAX_LENGTH } from '@/lib/people/validation' +import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state' +import { PEOPLE_COLUMNS, PEOPLE_SEARCH_PLACEHOLDER } from '@/app/o/[organizationId]/people/columns' +import { LoadingStatus } from '@/app/o/[organizationId]/people/loading-status' +import { + peopleParsers, + peopleUrlKeys, + serializePeopleView, +} from '@/app/o/[organizationId]/people/search-params' +import { + ResourceLoadError, + ResourceNoResults, +} from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state' +import { + type FilterTag, + ResourceFilterPanel, + ResourceFilterSection, + type SearchConfig, +} from '@/app/workspace/[workspaceId]/components/resource/components/resource-options' +import { + Resource, + type ResourceRow, +} from '@/app/workspace/[workspaceId]/components/resource/resource' +import { useDepartments, usePeople } from '@/hooks/queries/people' +import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter' +import { useSearchFilterValue } from '@/hooks/use-search-filter-value' + +/** The filter's value for every department: stored text can't hold a NUL, so no department is this. */ +const KEY_PREFIX = '\u0000key:' +const ALL_DEPARTMENTS = '\u0000all' + +function personCell(person: PersonSummary) { + return { + icon: , + label: person.name, + } +} + +interface PeopleProps { + organizationId: string +} + +export function PeopleDirectory({ organizationId }: PeopleProps) { + const router = useRouter() + const [origin, setView] = useQueryStates(peopleParsers, peopleUrlKeys) + const { search, department, departmentKey } = origin + const setSearch = useDebouncedSearchSetter((value, options) => + setView({ view: 'directory', search: value }, options) + ) + const debouncedSearch = useSearchFilterValue(search, SEARCH_DEBOUNCE_MS) + const query = debouncedSearch.trim() + const people = usePeople(organizationId, query, department, { departmentKey }) + const profileQuery = serializePeopleView({ ...origin, view: 'directory', panel: null }) + const profileHref = useCallback( + (memberId: string) => `${organizationRoutes(organizationId).person(memberId)}${profileQuery}`, + [organizationId, profileQuery] + ) + + const [departmentSearch, setDepartmentSearch] = useState('') + const [departmentsOpen, setDepartmentsOpen] = useState(false) + const departmentTerm = useSearchFilterValue(departmentSearch, SEARCH_DEBOUNCE_MS).trim() + const departments = useDepartments(organizationId, departmentTerm, departmentsOpen) + const departmentWaiting = departmentSearch.trim() !== departmentTerm + const changingFilter = people.isPlaceholderData || query !== search.trim() + + const visiblePages = people.isRefetchError || changingFilter ? undefined : people.data?.pages + const rows: ResourceRow[] = useMemo( + () => + (visiblePages ?? []).flatMap((page) => + page.people.map((person) => ({ + id: person.memberId, + cells: { + name: { + content: ( + event.stopPropagation()} + className='flex min-w-0 items-center gap-2 rounded-sm text-[var(--text-body)] hover:underline focus-visible:outline-2 focus-visible:outline-[var(--brand-secondary)]' + > + + + {person.name} + + + ), + }, + title: { label: person.title }, + department: { label: person.department }, + location: { label: person.location }, + manager: person.manager ? personCell(person.manager) : { label: null }, + }, + })) + ), + [visiblePages, profileHref] + ) + + const searchConfig: SearchConfig = { + value: search, + maxLength: PEOPLE_SEARCH_MAX_LENGTH, + onChange: setSearch, + onClearAll: () => setSearch(''), + placeholder: PEOPLE_SEARCH_PLACEHOLDER, + } + const setDepartment = (value: string) => { + const keyed = value.startsWith(KEY_PREFIX) + void setView({ + view: 'directory', + department: keyed ? null : value, + departmentKey: keyed ? value.slice(KEY_PREFIX.length) : null, + }) + } + const hasDepartment = Boolean(departmentKey || department) + const selectedValue = departmentKey + ? `${KEY_PREFIX}${departmentKey}` + : department || ALL_DEPARTMENTS + const departmentLabel = departmentKey + ? (!people.isPlaceholderData && people.data?.pages[0]?.selectedDepartment) || + 'Selected department' + : department + const filterTags: FilterTag[] = hasDepartment + ? [{ label: `Department: ${departmentLabel}`, onRemove: () => setDepartment('') }] + : [] + + return ( + <> + + + page.departments) ?? []) + ).map(({ name, count, departmentKey: key }) => ({ + value: key ? `${KEY_PREFIX}${key}` : name, + label: `${name} (${count})`, + })), + ]} + value={selectedValue} + onChange={(value) => setDepartment(value === ALL_DEPARTMENTS ? '' : value)} + overlayContent={departmentLabel || 'All'} + overlayLabel={departmentLabel || 'All'} + searchable + searchMaxLength={PEOPLE_SEARCH_MAX_LENGTH} + searchMode='server' + onSearchChange={setDepartmentSearch} + onOpenChange={setDepartmentsOpen} + hasMore={!departmentWaiting && departments.hasNextPage} + onLoadMore={() => void departments.fetchNextPage()} + isLoadingMore={departments.isFetchingNextPage} + isLoading={departmentWaiting || departments.isPending} + error={departments.error?.message} + searchPlaceholder='Search departments...' + emptyMessage='No departments match' + className='w-full' + /> + {departments.isError && ( + void departments.refetch()} + > + Retry departments + + )} + + + ), + }} + filterTags={filterTags} + /> + router.push(profileHref(memberId))} + onLoadMore={() => void people.fetchNextPage()} + hasMore={people.hasNextPage && !changingFilter} + isLoadingMore={people.isFetchingNextPage} + isFetching={people.isFetching} + loadMoreFailed={people.isFetchNextPageError} + emptyState={ + people.isPending || changingFilter ? ( + Loading people… + ) : people.isLoadingError || people.isRefetchError ? ( + void people.refetch()} + /> + ) : !query && !hasDepartment ? ( + + ) : ( +
+ + void setView({ view: 'directory', search: '', department: '', departmentKey: '' }) + } + /> +
+ ) + } + /> + + ) +} diff --git a/apps/sim/app/o/[organizationId]/people/people-import-modal.tsx b/apps/sim/app/o/[organizationId]/people/people-import-modal.tsx new file mode 100644 index 00000000000..7d6bdc7e66e --- /dev/null +++ b/apps/sim/app/o/[organizationId]/people/people-import-modal.tsx @@ -0,0 +1,331 @@ +'use client' + +import { useRef, useState } from 'react' +import { + ChipModal, + ChipModalBody, + ChipModalDescription, + ChipModalError, + ChipModalField, + ChipModalFooter, + ChipModalHeader, + cn, + scrollFadeAttributes, + scrollFadeClass, + toast, + useScrollEdges, +} from '@sim/emcn' +import { getErrorMessage } from '@sim/utils/errors' +import { isOrganizationSettingsSectionAvailable } from '@/components/settings/navigation' +import { isApiClientError } from '@/lib/api/client/errors' +import type { PeopleImportBody, PeopleImportResult } from '@/lib/api/contracts/people' +import { useDeploymentShape } from '@/lib/core/config/deployment-shape' +import { saveBlob } from '@/lib/uploads/client/download' +import { + IMPORT_SOURCES, + type ImportSource, + ImportSourcePicker, + ProviderFields, + ProvisioningSource, +} from '@/app/o/[organizationId]/people/import-source' +import { + type PeopleSheet, + readPeopleSheet, +} from '@/app/o/[organizationId]/people/read-people-sheet' +import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider' +import { useScimConnection } from '@/ee/scim/hooks/scim' +import { useImportPeople } from '@/hooks/queries/people' + +const ACCEPTED_FILES = '.csv,.tsv,.xlsx,.xls' +const TEMPLATE_CSV = + 'email,title,department,location,start_date,manager_email\nada@example.com,Engineer,Platform,Berlin,2024-01-31,grace@example.com\n' + +function downloadTemplate() { + saveBlob(new Blob([TEMPLATE_CSV], { type: 'text/csv' }), 'people-template.csv') +} + +function plural(count: number, noun: string): string { + return `${count} ${noun}${count === 1 ? '' : 's'}` +} + +/** The preview's issues in file order, with a count of any past the ones listed. */ +interface ImportIssueListProps { + result: PeopleImportResult + spreadsheet: boolean +} + +function ImportIssueList({ result, spreadsheet }: ImportIssueListProps) { + const listRef = useRef(null) + const edges = useScrollEdges(listRef) + const unlisted = result.skipped + result.warnings - result.issues.length + return ( +
    + {result.issues.map((issue) => ( +
  • + {spreadsheet && Row {issue.row}}{' '} + + {issue.email ? `${issue.email}: ` : ''} + {issue.message} + +
  • + ))} + {unlisted > 0 && ( +
  • and {plural(unlisted, 'more issue')}
  • + )} +
+ ) +} + +interface PeopleImportModalProps { + organizationId: string + onClose: () => void +} + +export function PeopleImportModal({ organizationId, onClose }: PeopleImportModalProps) { + const [source, setSource] = useState(null) + const { settingsFeatures } = useOrganizationContext() + const { features, hosted } = useDeploymentShape() + const canProvision = features.scim && (!hosted || settingsFeatures.hasEnterprisePlan) + + if (source && (source !== 'directory' || canProvision)) { + return ( + setSource(null)} + canOpenSettings={isOrganizationSettingsSectionAvailable('sso', settingsFeatures)} + /> + ) + } + + return ( + !open && onClose()} srTitle='Import people data'> + Import people data + + + + + + ) +} + +interface SourceImportModalProps extends PeopleImportModalProps { + source: ImportSource + onBack: () => void + canOpenSettings: boolean +} + +function SourceImportModal({ + organizationId, + source, + onBack, + onClose, + canOpenSettings, +}: SourceImportModalProps) { + const connection = useScimConnection(organizationId, source === 'directory') + const importPeople = useImportPeople(organizationId) + const [domain, setDomain] = useState('') + const [token, setToken] = useState('') + const [sheet, setSheet] = useState(null) + const [reading, setReading] = useState(false) + const [error, setError] = useState(null) + const [preview, setPreview] = useState(null) + const busy = useRef(false) + const pending = reading || importPeople.isPending + const provider = source === 'okta' || source === 'entra' + const ready = + source === 'file' + ? Boolean(sheet) + : source === 'directory' + ? connection.isSuccess && + connection.data.connection?.status === 'active' && + connection.data.connection.userCount > 0 + : Boolean(token.trim() && (source !== 'okta' || domain.trim())) + const sourceName = IMPORT_SOURCES[source].name + const title = preview + ? 'Review import' + : source === 'file' + ? 'Import a spreadsheet' + : `Import from ${sourceName}` + + const submit = async (apply: boolean, file?: File) => { + if (busy.current || (apply && !preview?.updated)) return + busy.current = true + setError(null) + try { + let body: PeopleImportBody + if (source === 'file') { + let selected = sheet + if (file) { + setReading(true) + setSheet(null) + selected = await readPeopleSheet(file) + setSheet(selected) + } + if (!selected) return + body = { headers: selected.headers, rows: selected.rows, apply } + } else if (source === 'okta') { + body = { + source, + domain: domain.trim(), + token: token.trim(), + apply, + previewDigest: apply ? preview?.previewDigest : undefined, + } + } else if (source === 'entra') { + body = { + source, + token: token.trim(), + apply, + previewDigest: apply ? preview?.previewDigest : undefined, + } + } else { + body = { source, apply, previewDigest: apply ? preview?.previewDigest : undefined } + } + const result = await importPeople.submit(body) + if (apply) { + toast.success(`Updated ${plural(result.updated, 'profile')}`) + onClose() + } else { + setPreview(result) + } + } catch (cause) { + if (source !== 'file' && apply && isApiClientError(cause) && cause.status === 409) + setPreview(null) + setError(getErrorMessage(cause, 'Could not import people data')) + } finally { + busy.current = false + setReading(false) + } + } + + const goBack = () => { + if (busy.current) return + if (preview) { + setPreview(null) + setError(null) + } else { + onBack() + } + } + + const done = preview?.updated === 0 + return ( + !open && onClose()} + dismissDisabled={pending} + srTitle={title} + > + {title} + + {preview ? ( + <> + + {source === 'file' ? sheet?.fileName : sourceName} + + + {() => ( +
+

+ {done + ? 'No profiles to update.' + : `${plural(preview.updated, 'profile')} will be updated.`} +

+

+ {[ + `${preview.unchanged} unchanged`, + `${preview.skipped} skipped`, + plural(preview.warnings, 'warning'), + ].join(' · ')} +

+ {preview.issues.length > 0 && ( + + )} +
+ )} +
+ {!done && ( + + {source !== 'file' + ? 'Import updates matching members’ profiles. If the directory changes, you’ll be asked to preview it again.' + : 'Import applies these profile updates to matching members.'} + + )} + + ) : provider ? ( + { + setDomain(value) + setError(null) + }} + token={token} + onTokenChange={(value) => { + setToken(value) + setError(null) + }} + disabled={pending} + /> + ) : source === 'directory' ? ( + + ) : ( + file && void submit(false, file)} + /> + )} + {error} +
+ void submit(Boolean(preview)), + }} + /> +
+ ) +} diff --git a/apps/sim/app/o/[organizationId]/people/people.tsx b/apps/sim/app/o/[organizationId]/people/people.tsx new file mode 100644 index 00000000000..6e3b3ed806a --- /dev/null +++ b/apps/sim/app/o/[organizationId]/people/people.tsx @@ -0,0 +1,99 @@ +'use client' + +import { useState } from 'react' +import { ChipSwitch } from '@sim/emcn' +import { List, RefreshCw, Users, Workflow } from '@sim/emcn/icons' +import dynamic from 'next/dynamic' +import { useQueryStates } from 'nuqs' +import { IMPORT_PEOPLE_ACTION } from '@/app/o/[organizationId]/people/columns' +import { PeopleDirectory } from '@/app/o/[organizationId]/people/people-directory' +import { PeopleImportModal } from '@/app/o/[organizationId]/people/people-import-modal' +import { peopleParsers, peopleUrlKeys } from '@/app/o/[organizationId]/people/search-params' +import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider' +import { Resource } from '@/app/workspace/[workspaceId]/components/resource/resource' + +const PeopleChart = dynamic(() => + import('@/app/o/[organizationId]/people/people-chart').then((module) => module.PeopleChart) +) +const DirectorySyncModal = dynamic(() => + import('@/app/o/[organizationId]/people/directory-sync-modal').then( + (module) => module.DirectorySyncModal + ) +) +const VIEWS = [ + { value: 'chart', label: 'Org chart', icon: Workflow }, + { value: 'directory', label: 'Directory', icon: List }, +] as const + +interface PeopleProps { + organizationId: string +} + +export function People({ organizationId }: PeopleProps) { + const { viewer } = useOrganizationContext() + const [{ view, search, department, departmentKey, panel }, setView] = useQueryStates( + peopleParsers, + peopleUrlKeys + ) + const [importOpen, setImportOpen] = useState(false) + const openSync = () => void setView({ panel: 'sync' }, { history: 'push' }) + const currentView = view ?? (search || department || departmentKey ? 'directory' : 'chart') + + const viewSwitch = ( + void setView({ view: next }, { history: 'push' })} + /> + ) + + return ( + + setImportOpen(true), + }, + ] + : [] + } + /> + {currentView === 'chart' ? ( + setImportOpen(true) : undefined} + onSync={viewer.isAdmin ? openSync : undefined} + /> + ) : ( + <> +
{viewSwitch}
+ + + )} + {importOpen && !panel && viewer.isAdmin && ( + setImportOpen(false)} /> + )} + {panel === 'sync' && viewer.isAdmin && ( + void setView({ panel: null })} + /> + )} +
+ ) +} diff --git a/apps/sim/lib/people/read-sheet.test.ts b/apps/sim/app/o/[organizationId]/people/read-people-sheet.test.ts similarity index 97% rename from apps/sim/lib/people/read-sheet.test.ts rename to apps/sim/app/o/[organizationId]/people/read-people-sheet.test.ts index c0d4307eb91..3babf9af531 100644 --- a/apps/sim/lib/people/read-sheet.test.ts +++ b/apps/sim/app/o/[organizationId]/people/read-people-sheet.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import * as XLSX from 'xlsx' -import { readPeopleSheet } from '@/lib/people/read-sheet' +import { readPeopleSheet } from '@/app/o/[organizationId]/people/read-people-sheet' /** January 31, 2024 as a date serial in each of Excel's two date systems. */ const SERIAL_1900 = 45322 diff --git a/apps/sim/lib/people/read-sheet.ts b/apps/sim/app/o/[organizationId]/people/read-people-sheet.ts similarity index 100% rename from apps/sim/lib/people/read-sheet.ts rename to apps/sim/app/o/[organizationId]/people/read-people-sheet.ts diff --git a/apps/sim/lib/people/search-params.ts b/apps/sim/app/o/[organizationId]/people/search-params.ts similarity index 84% rename from apps/sim/lib/people/search-params.ts rename to apps/sim/app/o/[organizationId]/people/search-params.ts index c2aacdee25f..a34714387f1 100644 --- a/apps/sim/lib/people/search-params.ts +++ b/apps/sim/app/o/[organizationId]/people/search-params.ts @@ -7,14 +7,11 @@ import { PEOPLE_SEARCH_MAX_LENGTH, } from '@/lib/people/validation' -export const boundedSearch = (value: string) => - truncateAtCodePoint(value, PEOPLE_SEARCH_MAX_LENGTH, '') +const boundedSearch = (value: string) => truncateAtCodePoint(value, PEOPLE_SEARCH_MAX_LENGTH, '') /** `search` matches name, email, title, department, or location; `department` narrows to one. */ export const peopleParsers = { - /** Nullable: with no explicit view, the default depends on whether a filter is set. */ view: parseAsStringLiteral(['chart', 'directory'] as const), - /** Nullable: absent means no panel is open; there is no default panel. */ panel: parseAsStringLiteral(['sync'] as const), focus: createParser({ parse: (value) => (value.length > 0 && value.length <= PEOPLE_ID_MAX_LENGTH ? value : null), diff --git a/apps/sim/app/o/[organizationId]/people/use-people-picker.tsx b/apps/sim/app/o/[organizationId]/people/use-people-picker.tsx new file mode 100644 index 00000000000..36b77209cff --- /dev/null +++ b/apps/sim/app/o/[organizationId]/people/use-people-picker.tsx @@ -0,0 +1,46 @@ +import { useState } from 'react' +import { Avatar } from '@sim/emcn' +import { PEOPLE_SEARCH_MAX_LENGTH } from '@/lib/people/validation' +import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state' +import { usePeople } from '@/hooks/queries/people' +import { useDebounce } from '@/hooks/use-debounce' + +/** Shared, paginated directory search for chart navigation and editing a person's manager. */ +export function usePeoplePicker( + organizationId: string, + scope?: 'roots' | 'members', + excludeMemberId?: string +) { + const [search, setSearch] = useState('') + const [open, setOpen] = useState(false) + const term = useDebounce(search.trim(), SEARCH_DEBOUNCE_MS) + const query = usePeople(organizationId, term, '', { scope, enabled: open }) + const waiting = search.trim() !== term || query.isPlaceholderData + const people = + waiting || query.isRefetchError + ? [] + : (query.data?.pages ?? []).flatMap((page) => + page.people.filter((person) => person.memberId !== excludeMemberId) + ) + return { + search, + people, + pickerProps: { + searchable: true, + searchMaxLength: PEOPLE_SEARCH_MAX_LENGTH, + searchMode: 'server' as const, + onSearchChange: setSearch, + onOpenChange: setOpen, + options: people.map((person) => ({ + value: person.memberId, + label: [person.name, person.title, person.email].filter(Boolean).join(' · '), + iconElement: , + })), + hasMore: query.hasNextPage && !waiting, + onLoadMore: () => void query.fetchNextPage(), + isLoading: waiting || (query.isFetching && !query.isFetchingNextPage), + isLoadingMore: query.isFetchingNextPage, + error: query.error?.message, + }, + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/index.ts b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/index.ts index cd37a146c47..514db4ca670 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/index.ts @@ -4,6 +4,7 @@ export { DocumentsEmptyState } from '@/app/workspace/[workspaceId]/components/re export { FilesEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/files-empty-state' export { KnowledgeEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/knowledge-empty-state' export { LogsEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/logs-empty-state' +export { ResourceLoadError } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/resource-load-error' export { ResourceNoResults } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/resource-no-results' export { TablesEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/tables-empty-state' export { TestsEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/tests-empty-state' diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/resource-load-error.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/resource-load-error.tsx new file mode 100644 index 00000000000..eaff2e66928 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/resource-load-error.tsx @@ -0,0 +1,16 @@ +import { Chip } from '@sim/emcn' + +interface ResourceLoadErrorProps { + message: string + onRetry: () => void +} + +/** What a resource table shows when its first page failed to load. */ +export function ResourceLoadError({ message, onRetry }: ResourceLoadErrorProps) { + return ( +
+

{message}

+ Retry +
+ ) +} diff --git a/apps/sim/hooks/queries/people-sync.ts b/apps/sim/hooks/queries/people-sync.ts index 407256f3a7c..f5e49767251 100644 --- a/apps/sim/hooks/queries/people-sync.ts +++ b/apps/sim/hooks/queries/people-sync.ts @@ -1,11 +1,5 @@ import { useRef } from 'react' -import { - type QueryClient, - useIsMutating, - useMutation, - useQuery, - useQueryClient, -} from '@tanstack/react-query' +import { type QueryClient, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { isApiClientError } from '@/lib/api/client/errors' import { requestJson } from '@/lib/api/client/request' import type { ContractJsonResponse } from '@/lib/api/contracts' @@ -25,8 +19,6 @@ const IDLE_SYNC_POLL_MS = 30_000 const syncKeys = { all: ['people-directory-sync'] as const, organization: (organizationId: string) => [...syncKeys.all, organizationId] as const, - actions: (organizationId: string) => - [...syncKeys.organization(organizationId), 'action'] as const, } type SyncResult = ContractJsonResponse @@ -42,10 +34,9 @@ function refreshPublishedPeople(client: QueryClient, organizationId: string, res } } -export function usePeopleSyncStatus(organizationId: string, enabled = true) { +export function usePeopleSyncStatus(organizationId: string) { const client = useQueryClient() return useQuery({ - enabled, queryKey: syncKeys.organization(organizationId), queryFn: async ({ signal }) => { const result = await requestJson(getPeopleSyncContract, { @@ -73,10 +64,8 @@ export function usePeopleSyncActions(organizationId: string) { const client = useQueryClient() // Only the action name may enter the mutation cache or its devtools. const nextConfig = useRef(null) - const mutationKey = syncKeys.actions(organizationId) - const pending = useIsMutating({ mutationKey }) > 0 + const busy = useRef(false) const mutation = useMutation({ - mutationKey, gcTime: 0, retry: false, networkMode: 'always', @@ -91,40 +80,35 @@ export function usePeopleSyncActions(organizationId: string) { if (!body) throw new Error('Choose a directory connection') return requestJson(configurePeopleSyncContract, { params, body }) }, - onSuccess: async (result, action) => { + onSuccess: async (result) => { const queryKey = syncKeys.organization(organizationId) await client.cancelQueries({ queryKey }) - // A sync can publish while the disconnect is in flight, so its last-success time can't say - // whether the cached people still exist. - const refreshed = - action === 'disconnect' - ? client.invalidateQueries({ queryKey: peopleKeys.organization(organizationId) }) - : refreshPublishedPeople(client, organizationId, result) + const refreshed = refreshPublishedPeople(client, organizationId, result) client.setQueryData(queryKey, result) await refreshed }, onError: () => client.invalidateQueries({ queryKey: syncKeys.organization(organizationId) }), onSettled: () => { + busy.current = false nextConfig.current = null }, }) function submit( action: 'configure' | 'run' | 'disconnect', config?: PeopleSyncConfig, - onSuccess?: () => void, - onError?: (error: Error) => void + onSuccess?: () => void ) { - if (client.isMutating({ mutationKey }) > 0) return + if (busy.current) return + busy.current = true nextConfig.current = config ?? null - mutation.mutate(action, { onSuccess: () => onSuccess?.(), onError }) + mutation.mutate(action, { onSuccess }) } return { configure: (config: PeopleSyncConfig, onSuccess: () => void) => submit('configure', config, onSuccess), run: () => submit('run'), - disconnect: (onSuccess: () => void, onError?: (error: Error) => void) => - submit('disconnect', undefined, onSuccess, onError), - pending, + disconnect: (onSuccess: () => void) => submit('disconnect', undefined, onSuccess), + pending: mutation.isPending, action: mutation.isPending ? mutation.variables : null, error: mutation.error, reset: mutation.reset, diff --git a/apps/sim/lib/people/feature-flag.ts b/apps/sim/lib/people/feature-flag.ts index 185d5a99553..7054328e162 100644 --- a/apps/sim/lib/people/feature-flag.ts +++ b/apps/sim/lib/people/feature-flag.ts @@ -1,4 +1,3 @@ -import { cache } from 'react' import { createLogger } from '@sim/logger' import { isOrgAdminRole } from '@sim/platform-authz/workspace' import { getErrorMessage } from '@sim/utils/errors' @@ -6,7 +5,7 @@ import { isFeatureEnabled } from '@/lib/core/config/feature-flags' import { OrchestrationError } from '@/lib/core/orchestration/types' import { isOrganizationCapabilityWithheld } from '@/lib/permission-groups/capability-assertions' -function isPeopleEnabled(organizationId: string): Promise { +export function isPeopleEnabled(organizationId: string): Promise { return isFeatureEnabled('people', { orgId: organizationId }) } @@ -21,13 +20,10 @@ const logger = createLogger('People') /** * Whether to show the People page to a member with `role`: it is on for `organizationId`, and its * directory is open to them — administrators always, other members unless their permission group - * withholds `organization.member_directory`, the capability its reads enforce. For navigation and - * the page gate, so a lookup that fails hides People rather than failing the page around it. + * withholds `organization.member_directory`, the capability its reads enforce. For navigation + * only, so a lookup that fails hides People rather than failing the page around it. */ -export const canBrowsePeople = cache(async function canBrowsePeople( - organizationId: string, - role: string -): Promise { +export async function canBrowsePeople(organizationId: string, role: string): Promise { try { if (!(await isPeopleEnabled(organizationId))) return false return ( @@ -41,4 +37,4 @@ export const canBrowsePeople = cache(async function canBrowsePeople( }) return false } -}) +} diff --git a/apps/sim/platform/app/(shell)/people/_components/directory-sync/connect-modal.tsx b/apps/sim/platform/app/(shell)/people/_components/directory-sync/connect-modal.tsx deleted file mode 100644 index 4251f9828bd..00000000000 --- a/apps/sim/platform/app/(shell)/people/_components/directory-sync/connect-modal.tsx +++ /dev/null @@ -1,185 +0,0 @@ -'use client' - -import { useState } from 'react' -import Link from 'next/link' -import styles from '@/platform/app/(shell)/people/_components/directory-sync/directory-sync.module.css' -import { ExternalLink } from '@/platform/app/(shell)/people/_components/external-link/external-link' -import { - Button, - InlineNotice, - Modal, - SecretField, - TextField, -} from '@/platform/design-system/components' -import { - SYNC_SOURCE_NAMES, - type SyncDraft, - type SyncFieldErrors, - type SyncSource, - useDirectorySync, - usePeopleViewer, - useProvisioningConnection, -} from '@/platform/lib/data/people' - -const SOURCE_DESCRIPTIONS: Record = { - okta: 'Keep your org chart current with an hourly directory sync. You can disconnect at any time.', - entra: - 'Register an app in your Microsoft Entra tenant and grant administrator consent for the Microsoft Graph User.Read.All application permission. Use the client secret value.', - scim: 'Use profiles from your organization’s existing SCIM provisioning connection.', -} - -interface ConnectModalProps { - organizationId: string - source: SyncSource - onClose: () => void - onConnected: () => void -} - -/** - * Connects a directory, or replaces a connected one's credentials. Credentials stay in this - * component's state; the action hook keeps them out of the mutation cache. A status poll that fails - * keeps the draft, and one that says the viewer lost access takes the form away. - */ -export function ConnectModal({ organizationId, source, onClose, onConnected }: ConnectModalProps) { - const { canOpenProvisioningSettings, provisioningSettingsHref } = usePeopleViewer() - const directory = useDirectorySync(organizationId) - const { sync } = directory - const current = sync?.source === source ? sync : null - const provisioning = useProvisioningConnection(organizationId, source === 'scim') - const [draft, setDraft] = useState({ - domain: current?.domain ?? '', - token: '', - tenantId: current?.tenantId ?? '', - clientId: current?.clientId ?? '', - clientSecret: '', - }) - const [errors, setErrors] = useState({}) - const change = (next: Partial) => setDraft((previous) => ({ ...previous, ...next })) - const name = SYNC_SOURCE_NAMES[source] - const disabled = directory.pending - const canConnect = - source === 'scim' - ? provisioning.ready - : source === 'okta' - ? Boolean(draft.token.trim() && draft.domain.trim()) - : Boolean(draft.clientSecret.trim() && draft.tenantId.trim() && draft.clientId.trim()) - - if (directory.denied) return null - - return ( - { - setErrors(directory.connect(source, draft, onConnected) ?? {}) - return false - }} - > -

- {SOURCE_DESCRIPTIONS[source]} -

- {source === 'okta' && ( - <> - change({ domain })} - /> - change({ token })} - /> - - Create an Okta API token - - - )} - {source === 'entra' && ( - <> - change({ tenantId })} - /> - change({ clientId })} - /> - change({ clientSecret })} - /> - - Register an application - - - )} - {source === 'scim' && ( -
-

- {provisioning.loading - ? 'Checking provisioning…' - : provisioning.failed - ? 'Could not read the provisioning connection.' - : provisioning.ready - ? 'Active' - : 'Enable provisioning before connecting this source.'} -

-
- {canOpenProvisioningSettings && ( - - )} - {!provisioning.fetching && !provisioning.ready && ( - - )} -
-
- )} - - {disabled && ( -

- Sync is running. You can close this window and return for the result. -

- )} - {directory.actionError && ( - - )} -
- ) -} diff --git a/apps/sim/platform/app/(shell)/people/_components/directory-sync/directory-sources.tsx b/apps/sim/platform/app/(shell)/people/_components/directory-sync/directory-sources.tsx deleted file mode 100644 index 7c26e751a9c..00000000000 --- a/apps/sim/platform/app/(shell)/people/_components/directory-sync/directory-sources.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import styles from '@/platform/app/(shell)/people/_components/directory-sync/directory-sync.module.css' -import { Icon } from '@/platform/design-system/components' -import { SYNC_SOURCE_NAMES, type SyncSource } from '@/platform/lib/data/people' - -export const SOURCE_MARKS: Record = { okta: 'O', entra: 'E', scim: 'S' } - -const SOURCES = [ - { source: 'okta', hint: 'Sync your Okta directory' }, - { source: 'entra', hint: 'Sync your Entra directory' }, - { source: 'scim', hint: 'Use existing provisioning' }, -] as const - -interface DirectorySourcesProps { - canProvision: boolean - onConnect: (source: SyncSource) => void -} - -/** The directories an organization can connect, shared by first use and the sync page. */ -export function DirectorySources({ canProvision, onConnect }: DirectorySourcesProps) { - return ( -
- {SOURCES.filter(({ source }) => source !== 'scim' || canProvision).map(({ source, hint }) => ( - - ))} -
- ) -} - -interface ImportPromptProps { - onImport: () => void -} - -export function ImportPrompt({ onImport }: ImportPromptProps) { - return ( -

- Need a one-time update? - -

- ) -} diff --git a/apps/sim/platform/app/(shell)/people/_components/directory-sync/directory-sync-page.tsx b/apps/sim/platform/app/(shell)/people/_components/directory-sync/directory-sync-page.tsx deleted file mode 100644 index 718910c8298..00000000000 --- a/apps/sim/platform/app/(shell)/people/_components/directory-sync/directory-sync-page.tsx +++ /dev/null @@ -1,303 +0,0 @@ -'use client' - -import { useState } from 'react' -import { formatDateTime } from '@sim/utils/formatting' -import { - DirectorySources, - ImportPrompt, - SOURCE_MARKS, -} from '@/platform/app/(shell)/people/_components/directory-sync/directory-sources' -import styles from '@/platform/app/(shell)/people/_components/directory-sync/directory-sync.module.css' -import { PeopleStatus } from '@/platform/app/(shell)/people/_components/people-page/people-status' -import { - Button, - FailureNotice, - InlineNotice, - Modal, - StatusTag, -} from '@/platform/design-system/components' -import { - SYNC_SOURCE_NAMES, - type SyncSource, - type SyncStatus, - useDirectorySync, - usePeopleViewer, -} from '@/platform/lib/data/people' - -const PHASE_TITLES = { - users: 'Reading people', - relationships: 'Reading reporting lines', - publish: 'Validating and publishing', -} as const - -type SyncIntent = 'info' | 'success' | 'warning' | 'danger' - -/** What the card says about a connection: a heading for its state, and its tag. */ -function describeSync(sync: SyncStatus): { title: string; tag: string; intent: SyncIntent } { - if (sync.state === 'paused') - return { title: 'SCIM provisioning is disabled', tag: 'Paused', intent: 'warning' } - if (sync.retryBlocked && sync.state !== 'syncing') - return { - title: `${SYNC_SOURCE_NAMES[sync.source]} asked us to slow down`, - tag: 'Waiting to retry', - intent: 'warning', - } - if (sync.state === 'failed') - return { title: 'Couldn’t refresh the directory', tag: 'Sync failed', intent: 'danger' } - if (sync.progress?.phase === 'publish') - return { title: 'Finishing your refresh', tag: 'Publishing', intent: 'info' } - if (sync.state === 'syncing') - return { - title: sync.lastSuccessAt ? 'Refreshing your directory' : 'Reading your directory', - tag: sync.lastSuccessAt ? 'Syncing' : 'First sync', - intent: 'info', - } - if (sync.state === 'queued') - return { - title: sync.progress ? 'Refresh in progress' : 'Sync queued', - tag: sync.progress ? 'Refresh in progress' : 'Queued', - intent: 'info', - } - return { title: 'Directory is up to date', tag: 'Connected', intent: 'success' } -} - -function syncActionLabel(sync: SyncStatus, syncing: boolean): string { - if (sync.state === 'paused') return 'Sync paused' - if (syncing) return 'Sync in progress' - if (sync.retryBlocked) return 'Waiting to retry' - if (sync.state === 'failed') return 'Retry sync' - return sync.progress ? 'Continue sync' : 'Sync now' -} - -function formatInstant(value: string | null): string { - return value ? formatDateTime(new Date(value)) : '—' -} - -interface SyncCardProps { - sync: SyncStatus - syncing: boolean - running: boolean - onUpdate: () => void - onRun: () => void -} - -function SyncCard({ sync, syncing, running, onUpdate, onRun }: SyncCardProps) { - const paused = sync.state === 'paused' - const { title, tag, intent } = describeSync(sync) - const { progress } = sync - const facts: [label: string, value: string | null][] = [ - ['Last successful sync', sync.lastSuccessAt], - [syncing ? 'Current attempt started' : 'Last attempted', sync.lastAttemptAt], - ] - if (sync.lastFailedAt) facts.push(['Last failed sync', sync.lastFailedAt]) - if (!paused) - facts.push( - sync.retryAt ? ['Next retry', sync.retryAt] : ['Next scheduled sync', sync.nextSyncAt] - ) - return ( -
-
- - - {SYNC_SOURCE_NAMES[sync.source]} - {sync.domain && ( - - {sync.domain} - - )} - - -
-
-

{title}

- {paused && ( -

- Re-enable provisioning in organization settings to resume. Your last published chart - remains available. -

- )} -
- {progress && !paused && ( - - {progress.usersRead.toLocaleString()} read - - } - /> - )} - {sync.lastError && - (paused ? ( -

- Last sync error: {sync.lastError} -

- ) : ( - - ))} -
- {facts.map(([label, value]) => ( -
-
- {label} -
-
{formatInstant(value)}
-
- ))} -
- {running && !paused && ( -

- Sync is running. You can leave this page and return for the result. -

- )} -
- {sync.source !== 'scim' && ( - - )} -
- -
-
-
- ) -} - -interface DirectorySyncPageProps { - organizationId: string - onBack: () => void - onConnect: (source: SyncSource) => void - onImport: () => void -} - -/** - * The connection's status and its administration. A sync can outlast this page, so nothing here - * holds it open: the actions report through the hook's state, and status polling shows the result. - */ -export function DirectorySyncPage({ - organizationId, - onBack, - onConnect, - onImport, -}: DirectorySyncPageProps) { - const { canProvision } = usePeopleViewer() - const directory = useDirectorySync(organizationId) - const { sync } = directory - const [disconnecting, setDisconnecting] = useState(false) - const syncing = sync?.state === 'syncing' || directory.pending - return ( -
-
- -
-
-

Directory sync

-

- Keep people, roles, and reporting lines up to date from your identity provider. -

-
- {directory.loading ? ( - Loading directory connection… - ) : directory.loadError ? ( - - ) : sync ? ( -
-
- { - directory.reset() - onConnect(sync.source) - }} - onRun={directory.run} - /> - {directory.actionError && !disconnecting && ( - - )} -
-

- Disconnect directory -

-

- Remove synced profiles and reporting lines. Existing Sim memberships stay unchanged. -

-
- -
-
-
- - {disconnecting && ( - setDisconnecting(false)} - title='Disconnect directory' - confirmLabel='Disconnect' - destructive - onConfirm={directory.disconnect} - > -

- Disconnecting removes the profile details and reporting lines supplied by this - directory. Sim keeps organization memberships and manually recorded profiles. -

-
- )} -
- ) : ( -
- - -
- )} -
- ) -} diff --git a/apps/sim/platform/app/(shell)/people/_components/directory-sync/directory-sync.module.css b/apps/sim/platform/app/(shell)/people/_components/directory-sync/directory-sync.module.css deleted file mode 100644 index 8743cb24280..00000000000 --- a/apps/sim/platform/app/(shell)/people/_components/directory-sync/directory-sync.module.css +++ /dev/null @@ -1,202 +0,0 @@ -.page { - display: flex; - flex-direction: column; - min-block-size: 100%; - padding: var(--shell-home-row-start) var(--ui-page-inline-gutter) var(--ui-page-inset-block); - gap: var(--spacing-32); -} - -.back { - margin-inline-start: calc(-1 * var(--spacing-control-inline)); -} - -.heading { - display: grid; - gap: var(--spacing-8); -} - -.secondary { - color: var(--ui-secondary); -} - -.columns { - display: grid; - grid-template-columns: minmax(0, calc(2 * var(--spacing-320))) minmax( - 0, - calc(var(--spacing-320) + var(--spacing-64)) - ); - align-items: start; - gap: var(--spacing-32) var(--spacing-64); -} - -.main, -.notes { - display: flex; - flex-direction: column; - min-inline-size: 0; - gap: var(--spacing-32); -} - -.notes { - gap: var(--spacing-24); - padding-block-start: var(--spacing-8); -} - -.note { - display: grid; - gap: var(--spacing-8); -} - -.disconnect { - display: grid; - gap: var(--spacing-12); - padding-block-start: var(--spacing-8); -} - -.card { - display: flex; - flex-direction: column; - gap: var(--spacing-24); - padding: var(--spacing-24); - border: var(--spacing-stroke-default) solid var(--ui-border); - border-radius: var(--radius-12); -} - -.cardHeader { - display: flex; - align-items: center; - gap: var(--spacing-12); -} - -.cardHeader .sourceCopy { - flex: 1; -} - -.summary { - display: grid; - gap: var(--spacing-4); -} - -.mark { - display: inline-flex; - flex: none; - align-items: center; - justify-content: center; - inline-size: var(--spacing-32); - block-size: var(--spacing-32); - border: var(--spacing-stroke-default) solid var(--ui-border); - border-radius: var(--ui-control-radius); -} - -.sourceCopy { - display: grid; - min-inline-size: 0; - gap: var(--ui-stack-gap); - overflow-wrap: anywhere; -} - -.facts { - display: grid; - gap: var(--spacing-16); -} - -.fact { - display: flex; - flex-wrap: wrap; - justify-content: space-between; - gap: var(--spacing-4) var(--spacing-12); -} - -.cardHeader .mark { - inline-size: var(--spacing-40); - block-size: var(--spacing-40); -} - -.run { - margin-inline-start: auto; -} - -.cardFooter { - display: flex; - flex-wrap: wrap; - gap: var(--spacing-8); - padding-block-start: var(--spacing-16); - border-block-start: var(--spacing-stroke-default) solid var(--ui-divider); -} - -.sources { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(min(100%, 13rem), 16rem)); - justify-content: center; - inline-size: 100%; - gap: var(--spacing-16); -} - -.source { - display: flex; - flex-direction: column; - align-items: flex-start; - gap: var(--spacing-16); - padding: var(--spacing-20); - border: var(--spacing-stroke-default) solid var(--ui-border); - border-radius: var(--radius-12); - background: var(--ui-canvas); - color: var(--ui-ink); - text-align: start; - cursor: pointer; -} - -.source:hover { - background: var(--ui-hover); -} - -.sourceAction { - display: flex; - align-items: center; - justify-content: space-between; - align-self: stretch; - margin-block-start: var(--spacing-4); -} - -.unconnected { - display: flex; - flex-direction: column; - align-items: flex-start; - gap: var(--spacing-24); -} - -.unconnected .sources { - justify-content: start; -} - -.importPrompt { - display: flex; - flex-wrap: wrap; - justify-content: center; - margin-block-start: var(--spacing-8); - gap: var(--spacing-8); - color: var(--ui-secondary); -} - -.importLink { - color: var(--ui-ink); - text-decoration: underline; - cursor: pointer; -} - -.provisioning { - display: grid; - gap: var(--ui-stack-gap); -} - -.quietActions { - display: flex; - flex-wrap: wrap; - gap: var(--ui-action-gap); -} - -@media (width < 64rem) { - .columns { - grid-template-columns: minmax(0, 1fr); - } -} diff --git a/apps/sim/platform/app/(shell)/people/_components/directory/directory.module.css b/apps/sim/platform/app/(shell)/people/_components/directory/directory.module.css deleted file mode 100644 index fa20c43d1fd..00000000000 --- a/apps/sim/platform/app/(shell)/people/_components/directory/directory.module.css +++ /dev/null @@ -1,45 +0,0 @@ -.directory { - /* Everything above and below the table's own scroller, so the page itself stays still. */ - --ui-table-viewport-offset: calc( - var(--shell-home-row-start) + - 2 * - var(--spacing-app-control-md) + - 2 * - var(--spacing-48) + - var(--spacing-48) - ); - display: flex; - flex-direction: column; - gap: var(--spacing-24); - padding-inline: var(--ui-page-inline-gutter); -} - -.filters { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: var(--spacing-12); -} - -.search { - flex: 0 1 var(--spacing-320); - min-inline-size: var(--spacing-192); -} - -.department { - flex: 0 1 var(--spacing-192); - min-inline-size: var(--spacing-128); -} - -.scope { - flex: 1; - color: var(--ui-secondary); - text-align: end; - white-space: nowrap; -} - -@media (width < 48rem) { - .scope { - display: none; - } -} diff --git a/apps/sim/platform/app/(shell)/people/_components/directory/directory.tsx b/apps/sim/platform/app/(shell)/people/_components/directory/directory.tsx deleted file mode 100644 index 566842df7bc..00000000000 --- a/apps/sim/platform/app/(shell)/people/_components/directory/directory.tsx +++ /dev/null @@ -1,117 +0,0 @@ -'use client' - -import { useMemo } from 'react' -import styles from '@/platform/app/(shell)/people/_components/directory/directory.module.css' -import { PeopleStatus } from '@/platform/app/(shell)/people/_components/people-page/people-status' -import { PersonIdentity } from '@/platform/app/(shell)/people/_components/person-identity/person-identity' -import { - Button, - DataTable, - EmptyState, - FailureNotice, - SearchableSelect, - SearchField, -} from '@/platform/design-system/components' -import { personHref, useDepartmentOptions, usePeopleDirectory } from '@/platform/lib/data/people' - -const COLUMNS = [ - { key: 'person', label: 'Person', kind: 'text', primary: true }, - { key: 'department', label: 'Department', kind: 'text', showFrom: 'compact' }, - { key: 'location', label: 'Location', kind: 'text', showFrom: 'wide' }, - { key: 'manager', label: 'Manager', kind: 'text', showFrom: 'wide' }, -] as const - -interface DirectoryCellProps { - value: string | null | undefined -} - -function DirectoryCell({ value }: DirectoryCellProps) { - return {value || '—'} -} - -interface PeopleDirectoryProps { - organizationId: string -} - -export function PeopleDirectory({ organizationId }: PeopleDirectoryProps) { - const directory = usePeopleDirectory(organizationId) - const departments = useDepartmentOptions(organizationId, directory.origin) - const { originQuery } = directory - const rows = useMemo( - () => - directory.rows.map((person) => ({ - id: person.memberId, - values: {}, - cells: { - person: , - department: , - location: , - manager: , - }, - href: personHref(organizationId, person.memberId, originQuery), - })), - [directory.rows, organizationId, originQuery] - ) - return ( -
-
-
- -
-
- directory.setDepartment(departments.parse(next))} - /> -
- - People in your organization - -
- {directory.loading ? ( - Loading people… - ) : directory.error ? ( - - ) : rows.length === 0 ? ( - !directory.query && !directory.hasDepartment ? ( - - ) : ( - Clear search} - /> - ) - ) : ( - - )} -
- ) -} diff --git a/apps/sim/platform/app/(shell)/people/_components/external-link/external-link.tsx b/apps/sim/platform/app/(shell)/people/_components/external-link/external-link.tsx deleted file mode 100644 index 6c3268689ed..00000000000 --- a/apps/sim/platform/app/(shell)/people/_components/external-link/external-link.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import Link from 'next/link' -import { Button } from '@/platform/design-system/components' - -interface ExternalLinkProps { - href: string - children: string -} - -export function ExternalLink({ href, children }: ExternalLinkProps) { - return ( -
- -
- ) -} diff --git a/apps/sim/platform/app/(shell)/people/_components/import/import-modal.tsx b/apps/sim/platform/app/(shell)/people/_components/import/import-modal.tsx deleted file mode 100644 index bc5c26f15fd..00000000000 --- a/apps/sim/platform/app/(shell)/people/_components/import/import-modal.tsx +++ /dev/null @@ -1,319 +0,0 @@ -'use client' - -import { useState } from 'react' -import { toast } from '@sim/emcn' -import { getErrorMessage } from '@sim/utils/errors' -import Link from 'next/link' -import { ExternalLink } from '@/platform/app/(shell)/people/_components/external-link/external-link' -import styles from '@/platform/app/(shell)/people/_components/import/import.module.css' -import { - Button, - FileField, - InlineNotice, - MetricGroup, - Modal, - SecretField, - SelectField, - TextField, -} from '@/platform/design-system/components' -import { useScrollEdges } from '@/platform/design-system/components/scroll-area/use-scroll-edges' -import { - downloadImportTemplate, - type ImportBody, - isApiConflict, - type PeopleImportResult, - usePeopleImport, - usePeopleViewer, - useProvisioningConnection, -} from '@/platform/lib/data/people' -import { plural } from '@/platform/lib/plural' - -const ACCEPTED_FILES = '.csv,.tsv,.xlsx,.xls' - -const SOURCES = { - file: { name: 'Spreadsheet', description: 'Upload a CSV or Excel file' }, - okta: { name: 'Okta', description: 'Read profiles from your Okta directory' }, - entra: { name: 'Microsoft Entra ID', description: 'Read profiles from Microsoft Graph' }, - directory: { - name: 'SCIM provisioning', - description: 'Use directory data already provisioned to Sim', - }, -} as const - -type ImportSource = keyof typeof SOURCES - -const TOKEN_SOURCES = { - okta: { - instructions: 'Use an Okta API token that can read users in your organization.', - tokenLabel: 'API token', - tokenPlaceholder: 'Paste your token without SSWS', - helpHref: 'https://developer.okta.com/docs/guides/create-an-api-token/main/', - helpLabel: 'Create an Okta API token', - }, - entra: { - instructions: - 'In Graph Explorer, sign in with your work or school account, grant delegated User.Read.All permission, and copy your access token. Microsoft commercial cloud is supported.', - tokenLabel: 'Access token', - tokenPlaceholder: 'Paste your token without Bearer', - helpHref: 'https://developer.microsoft.com/en-us/graph/graph-explorer', - helpLabel: 'Open Graph Explorer', - }, -} as const - -interface IssueListProps { - result: PeopleImportResult - spreadsheet: boolean -} - -function IssueList({ result, spreadsheet }: IssueListProps) { - const unlisted = result.skipped + result.warnings - result.issues.length - const issueEdges = useScrollEdges() - return ( -
-

- {`${plural(result.skipped + result.warnings, 'row')} need${result.skipped + result.warnings === 1 ? 's' : ''} attention`} -

-
    - {result.issues.map((issue) => ( -
  • - {spreadsheet && Row {issue.row}} - - {issue.email ? `${issue.email}: ` : ''} - {issue.message} - - - {issue.severity === 'skipped' ? 'Skipped' : 'Warning'} - -
  • - ))} - {unlisted > 0 &&
  • and {plural(unlisted, 'more issue')}
  • } -
-
- ) -} - -interface ImportModalProps { - organizationId: string - onClose: () => void - onOpenSync: () => void -} - -/** - * One-time import: choose a source, preview, then apply exactly what was previewed. A directory - * that moved after its preview answers 409 and returns here for a new one. Tokens stay in this - * component's state and reach the request through the mutation that keeps them out of the cache. - */ -export function ImportModal({ organizationId, onClose, onOpenSync }: ImportModalProps) { - const { canProvision, canOpenProvisioningSettings, provisioningSettingsHref } = usePeopleViewer() - const importPeople = usePeopleImport(organizationId) - const [source, setSource] = useState('file') - const connection = useProvisioningConnection(organizationId, source === 'directory') - const [domain, setDomain] = useState('') - const [token, setToken] = useState('') - const [sheet, setSheet] = useState> | null>( - null - ) - const [reading, setReading] = useState(false) - const [fileError, setFileError] = useState() - const [preview, setPreview] = useState(null) - const pending = reading || importPeople.pending - const done = preview?.updated === 0 - const spreadsheet = source === 'file' - const ready = spreadsheet - ? Boolean(sheet) - : source === 'directory' - ? connection.ready && connection.userCount > 0 - : Boolean(token.trim() && (source !== 'okta' || domain.trim())) - - function requestBody(apply: boolean): ImportBody | null { - const previewDigest = apply ? preview?.previewDigest : undefined - if (source === 'file') return sheet ? { headers: sheet.headers, rows: sheet.rows, apply } : null - if (source === 'okta') - return { source, domain: domain.trim(), token: token.trim(), apply, previewDigest } - if (source === 'entra') return { source, token: token.trim(), apply, previewDigest } - return { source, apply, previewDigest } - } - - async function chooseFile(file: File) { - setFileError(undefined) - setReading(true) - setSheet(null) - try { - const selected = await importPeople.readSheet(file) - setSheet(selected) - setPreview( - await importPeople.submit({ headers: selected.headers, rows: selected.rows, apply: false }) - ) - } catch (error) { - setFileError(getErrorMessage(error, 'Could not import people data')) - } finally { - setReading(false) - } - } - - return ( - setPreview(null) : undefined} - onConfirm={ - done - ? undefined - : async () => { - const body = requestBody(Boolean(preview)) - if (!body) return - try { - const result = await importPeople.submit(body) - if (preview) { - toast.success(`Updated ${plural(result.updated, 'profile')}`) - return - } - setPreview(result) - return false - } catch (error) { - if (!spreadsheet && preview && isApiConflict(error)) setPreview(null) - throw error - } - } - } - > - {preview ? ( - <> -

- {spreadsheet - ? `${sheet?.fileName} updates profiles for existing organization members. It does not create accounts or an ongoing connection.` - : `${SOURCES[source].name} updates profiles for existing organization members. If the directory changes, you’ll be asked to preview it again.`} -

- - {preview.issues.length > 0 && } - {spreadsheet && ( -

- To include people without Sim accounts and keep profiles current, connect Okta or - Microsoft Entra ID through{' '} - - . -

- )} - - ) : ( - <> - key !== 'directory' || canProvision) - .map((key) => ({ value: key, label: SOURCES[key].name }))} - value={source} - hint={SOURCES[source].description} - disabled={pending} - onValueChange={(next) => { - setSource(next as ImportSource) - setToken('') - }} - /> - {(source === 'okta' || source === 'entra') && ( - <> -

- {TOKEN_SOURCES[source].instructions} Sim uses the token for this import only. -

- {source === 'okta' && ( - - )} - - - {TOKEN_SOURCES[source].helpLabel} - - - )} - {source === 'directory' && ( - <> -

- {connection.loading - ? 'Checking your provisioning connection…' - : connection.failed - ? 'Could not load your provisioning connection.' - : !connection.active - ? 'Enable SCIM provisioning and assign accounts in your provider to import their profiles.' - : connection.userCount === 0 - ? 'Your connection is active. Assign accounts to Sim in your identity provider, then refresh here.' - : `Your connection has ${plural(connection.userCount, 'provisioned account')}. Preview the profile fields and reporting lines sent by your provider.`} -

- {!connection.loading && ( -
- {canOpenProvisioningSettings && !connection.failed && ( - - )} - {!connection.active && ( - - Provisioning guide - - )} - -
- )} - - )} - {source === 'file' && ( - <> - setSheet(null)} - onFileChange={(file) => void chooseFile(file)} - disabled={pending} - error={fileError} - hint='CSV or Excel with an email column, plus any of title, department, location, start date, and manager email.' - /> - {reading && } -
- -
- - )} - - )} -
- ) -} diff --git a/apps/sim/platform/app/(shell)/people/_components/import/import.module.css b/apps/sim/platform/app/(shell)/people/_components/import/import.module.css deleted file mode 100644 index d3fd69ccc23..00000000000 --- a/apps/sim/platform/app/(shell)/people/_components/import/import.module.css +++ /dev/null @@ -1,44 +0,0 @@ -.secondary { - color: var(--ui-secondary); -} - -.links { - display: flex; - flex-wrap: wrap; - gap: var(--ui-action-gap); -} - -.attention { - display: grid; - gap: var(--spacing-8); -} - -.attentionTitle { - color: var(--status-warning); -} - -.issues { - display: grid; - gap: var(--spacing-8); - max-block-size: var(--spacing-192); - overflow-y: auto; - overflow-wrap: anywhere; -} - -.issue { - display: flex; - flex-wrap: wrap; - align-items: baseline; - gap: var(--spacing-4) var(--spacing-12); -} - -.issueMessage { - flex: 1; - min-inline-size: var(--spacing-128); -} - -.inlineLink { - color: var(--ui-ink); - text-decoration: underline; - cursor: pointer; -} diff --git a/apps/sim/platform/app/(shell)/people/_components/org-chart/chart-canvas.tsx b/apps/sim/platform/app/(shell)/people/_components/org-chart/chart-canvas.tsx deleted file mode 100644 index 62bb3a46fdb..00000000000 --- a/apps/sim/platform/app/(shell)/people/_components/org-chart/chart-canvas.tsx +++ /dev/null @@ -1,295 +0,0 @@ -'use client' - -import { type MouseEvent, type ReactNode, useMemo } from 'react' -import { - BaseEdge, - type Edge, - type EdgeProps, - Handle, - type Node, - type NodeProps, - Position, - ReactFlow, - ReactFlowProvider, - useReactFlow, - useStore, -} from '@xyflow/react' -import Link from 'next/link' -import styles from '@/platform/app/(shell)/people/_components/org-chart/org-chart.module.css' -import { Avatar, Icon, IconButton, OverflowText } from '@/platform/design-system/components' -import { type ChartBranch, type PersonSummary, personHref } from '@/platform/lib/data/people' -import { plural } from '@/platform/lib/plural' - -import '@xyflow/react/dist/style.css' - -type ChartFocus = (memberId: string, event: MouseEvent) => void - -interface PersonCardData extends Record { - person: PersonSummary - footer: string - focused: boolean - compact: boolean - hasManagerLine: boolean - hasReportLine: boolean - profileHref: string - onFocus: ChartFocus -} - -type PersonCardNode = Node - -const CARD_WIDTH = 224 -const CARD_HEIGHT = 184 -const COMPACT_WIDTH = 310 -const COMPACT_HEIGHT = 76 -const COLUMN_STEP = 240 -const MANAGER_GAP = 32 -const REPORT_GAP = 52 -const REPORT_BUS_DROP = 28 -const LINE_RADIUS = 8 -const MIN_ZOOM = 0.35 -const MAX_ZOOM = 1.6 -const FIT = { padding: 0.08, minZoom: 0.45, maxZoom: 1 } - -function PersonCard({ data }: NodeProps) { - const { person, footer, focused, compact, hasManagerLine, hasReportLine } = data - const copy = ( - - - {person.name} - - - - {person.title || person.email} - - - - ) - const lines = ( - <> - {hasManagerLine && } - {hasReportLine && } - - ) - const explore = { - type: 'button', - 'aria-label': `Explore ${person.name}`, - onClick: (event: MouseEvent) => data.onFocus(person.memberId, event), - } as const - if (compact) - return ( - <> - {lines} - - - ) - const body = ( - <> - - {copy} - - ) - return ( -
- {lines} - {focused ? ( -
{body}
- ) : ( - - )} -
- - - {footer} - - - Profile - - -
-
- ) -} - -function ReportingLine({ sourceX, sourceY, targetX, targetY }: EdgeProps) { - const direction = Math.sign(targetX - sourceX) - const busY = sourceY + REPORT_BUS_DROP - const path = - direction === 0 - ? `M ${sourceX} ${sourceY} V ${targetY}` - : `M ${sourceX} ${sourceY} V ${busY - LINE_RADIUS} Q ${sourceX} ${busY} ${sourceX + direction * LINE_RADIUS} ${busY} H ${targetX - direction * LINE_RADIUS} Q ${targetX} ${busY} ${targetX} ${busY + LINE_RADIUS} V ${targetY}` - return -} - -const NODE_TYPES = { person: PersonCard } -const EDGE_TYPES = { reporting: ReportingLine } - -interface ChartControlsProps { - compact: boolean -} - -function ChartControls({ compact }: ChartControlsProps) { - const { zoomIn, zoomOut, fitView } = useReactFlow() - const zoom = useStore((state) => state.transform[2]) - return ( -
- {!compact && ( - <> - void zoomOut()} - /> - - {Math.round(zoom * 100)}% - - = MAX_ZOOM} - onClick={() => void zoomIn()} - /> -
- ) -} - -function chartGraph( - { person, chain, reports, totalReports }: ChartBranch, - organizationId: string, - profileQuery: string, - compact: boolean, - onFocus: ChartFocus -) { - const nodes: PersonCardNode[] = [] - const edges: Edge[] = [] - const add = ( - member: PersonSummary, - footer: string, - x: number, - y: number, - lines: Pick - ) => { - const width = lines.compact ? COMPACT_WIDTH : CARD_WIDTH - nodes.push({ - id: member.memberId, - type: 'person', - position: { x: x - width / 2, y }, - width, - height: lines.compact ? COMPACT_HEIGHT : CARD_HEIGHT, - data: { - person: member, - footer, - ...lines, - profileHref: personHref(organizationId, member.memberId, profileQuery), - onFocus, - }, - }) - } - const connect = (source: string, target: string) => - edges.push({ id: `${source}/${target}`, source, target, type: 'reporting' }) - - const [manager] = chain - if (manager) { - const height = compact ? COMPACT_HEIGHT : CARD_HEIGHT - add(manager, 'Manager', 0, -(height + MANAGER_GAP), { - focused: false, - compact, - hasManagerLine: false, - hasReportLine: true, - }) - connect(manager.memberId, person.memberId) - } - add(person, totalReports ? plural(totalReports, 'report') : 'No reports', 0, 0, { - focused: true, - compact: false, - hasManagerLine: Boolean(manager), - hasReportLine: reports.length > 0, - }) - reports.forEach((report, index) => { - add( - report, - report.reportCount ? plural(report.reportCount, 'report') : 'No reports', - (index - (reports.length - 1) / 2) * COLUMN_STEP, - CARD_HEIGHT + REPORT_GAP, - { focused: false, compact: false, hasManagerLine: true, hasReportLine: false } - ) - connect(person.memberId, report.memberId) - }) - return { nodes, edges } -} - -interface ChartCanvasProps { - organizationId: string - branch: ChartBranch - profileQuery: string - compact: boolean - onFocus: ChartFocus - /** The report pager, between the canvas and its controls. */ - pager?: ReactNode -} - -/** - * The manager, the person in focus and a page of their reports, on a canvas a viewer can pan and - * zoom. Remount it per branch: the first fit is what centers each new set of cards. - */ -export function ChartCanvas({ - organizationId, - branch, - profileQuery, - compact, - onFocus, - pager, -}: ChartCanvasProps) { - const { nodes, edges } = useMemo( - () => chartGraph(branch, organizationId, profileQuery, compact, onFocus), - [branch, organizationId, profileQuery, compact, onFocus] - ) - return ( - -
-
- -
-
-
- {pager} - -
-
- ) -} diff --git a/apps/sim/platform/app/(shell)/people/_components/org-chart/chart-empty-state.tsx b/apps/sim/platform/app/(shell)/people/_components/org-chart/chart-empty-state.tsx deleted file mode 100644 index d8f4b6ba9f8..00000000000 --- a/apps/sim/platform/app/(shell)/people/_components/org-chart/chart-empty-state.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import type { MouseEvent, RefCallback } from 'react' -import Link from 'next/link' -import { Button, EmptyState } from '@/platform/design-system/components' -import { type PersonSummary, personHref, useChartRoots } from '@/platform/lib/data/people' - -interface ChartEmptyStateProps { - organizationId: string - person: PersonSummary - profileQuery: string - onFocus: (memberId: string, event: MouseEvent) => void - onBrowseDirectory: () => void - currentPersonRef: RefCallback - /** Present for an administrator, whose connection is what a first chart waits on. */ - onSync?: () => void -} - -export function ChartEmptyState({ - organizationId, - person, - profileQuery, - onFocus, - onBrowseDirectory, - currentPersonRef, - onSync, -}: ChartEmptyStateProps) { - const { firstTeam, emptyOrganization } = useChartRoots(organizationId) - if (emptyOrganization) - return ( - - - {onSync && } - - } - /> - ) - return ( - - {firstTeam && ( - - )} - - - } - /> - ) -} diff --git a/apps/sim/platform/app/(shell)/people/_components/org-chart/org-chart.module.css b/apps/sim/platform/app/(shell)/people/_components/org-chart/org-chart.module.css deleted file mode 100644 index f9e51661c72..00000000000 --- a/apps/sim/platform/app/(shell)/people/_components/org-chart/org-chart.module.css +++ /dev/null @@ -1,272 +0,0 @@ -.chart { - display: flex; - flex: 1; - flex-direction: column; - min-block-size: 0; - gap: var(--spacing-12); -} - -.toolbar { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: var(--spacing-8) var(--spacing-16); - padding-inline: var(--ui-page-inline-gutter); -} - -.search { - flex: 0 1 calc(var(--spacing-320) - var(--spacing-32)); - min-inline-size: var(--spacing-128); -} - -.pathNav { - flex: 1; - min-inline-size: 0; -} - -.path { - display: flex; - align-items: center; - gap: var(--spacing-4); - overflow-x: auto; - scrollbar-width: none; - white-space: nowrap; - color: var(--ui-secondary); -} - -.crumb { - flex: none; - padding-inline: var(--spacing-4); - border-radius: var(--ui-control-radius); - cursor: pointer; -} - -.crumb:hover { - color: var(--ui-ink); -} - -.current { - flex: none; - padding-inline: var(--spacing-4); - color: var(--ui-ink); -} - -.canvas { - position: relative; - flex: 1; - min-block-size: calc(var(--spacing-320) + 2 * var(--spacing-80)); -} - -.flowFrame { - position: absolute; - inset: 0; -} - -.flow { - --xy-background-color: transparent; - --xy-edge-stroke: var(--ui-border); -} - -.port { - visibility: hidden; - inline-size: 0; - min-inline-size: 0; - block-size: 0; - min-block-size: 0; - border: 0; -} - -.card, -.compactCard { - position: relative; - block-size: 100%; - inline-size: 100%; - border: var(--spacing-stroke-default) solid var(--ui-border); - border-radius: var(--radius-12); - background: var(--ui-canvas); - color: var(--ui-ink); - pointer-events: auto; -} - -.card { - display: flex; - flex-direction: column; -} - -button.cardBody:hover, -.compactCard:hover { - background: var(--ui-hover); -} - -.card[data-focused] { - border-color: var(--status-info); - box-shadow: 0 0 0 var(--spacing-stroke-focus) var(--status-info-soft); -} - -.compactCard { - display: flex; - align-items: center; - gap: var(--spacing-12); - padding-inline: var(--spacing-16); - text-align: start; - cursor: pointer; -} - -.cardBody { - display: flex; - flex: 1; - flex-direction: column; - gap: var(--spacing-16); - min-block-size: 0; - padding: var(--spacing-20); - text-align: start; -} - -button.cardBody { - border-start-start-radius: inherit; - border-start-end-radius: inherit; - cursor: pointer; -} - -.names { - display: grid; - flex: 1; - grid-template-columns: minmax(0, 1fr); - min-inline-size: 0; - gap: var(--spacing-4); -} - -.secondary { - color: var(--ui-secondary); -} - -.cardFooter { - position: relative; - display: flex; - flex: none; - align-items: center; - justify-content: space-between; - min-block-size: var(--spacing-40); - padding-inline: var(--spacing-20); - border-block-start: var(--spacing-stroke-default) solid var(--ui-divider); - color: var(--ui-secondary); -} - -.cardMeta, -.profileLink { - display: inline-flex; - align-items: center; - gap: var(--spacing-8); - min-inline-size: 0; -} - -.profileLink { - gap: var(--spacing-4); - color: var(--ui-ink); - border-radius: var(--ui-control-radius); -} - -.profileLink:hover { - text-decoration: underline; -} - -.footer { - display: grid; - grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); - align-items: center; - min-block-size: var(--spacing-app-control-md); - gap: var(--spacing-8); - padding: var(--spacing-8) var(--ui-page-inline-gutter) var(--spacing-16); -} - -.pager { - grid-column: 2; - display: flex; - align-items: center; - gap: var(--spacing-8); - color: var(--ui-secondary); - font-variant-numeric: tabular-nums; -} - -.pagerLabel { - white-space: nowrap; -} - -.controls { - grid-column: 3; - display: inline-flex; - align-items: center; - justify-self: end; - gap: var(--spacing-4); - padding: var(--spacing-4); - border: var(--spacing-stroke-default) solid var(--ui-border); - border-radius: var(--ui-control-radius); -} - -.zoomLevel { - min-inline-size: var(--spacing-40); - text-align: center; - font-variant-numeric: tabular-nums; -} - -.controlsRule { - block-size: var(--spacing-16); - border-inline-start: var(--spacing-stroke-default) solid var(--ui-border); -} - -.inset { - padding-inline: var(--ui-page-inline-gutter); -} - -.firstUse { - display: flex; - flex: 1; - flex-direction: column; - align-items: center; - padding: calc(var(--spacing-96) - var(--spacing-20)) var(--ui-page-inline-gutter); - gap: var(--spacing-24); - text-align: center; -} - -.firstUseHeading { - display: grid; - justify-items: center; - gap: var(--spacing-12); - margin-block-end: var(--spacing-8); -} - -.firstUseCopy { - max-inline-size: var(--container-app-panel-start); - color: var(--ui-secondary); - text-wrap: balance; -} - -.graphic { - inline-size: calc(var(--spacing-96) + var(--spacing-16)); - fill: none; - stroke: var(--ui-border); - stroke-width: var(--spacing-stroke-default); -} - -.graphic [data-root] { - fill: var(--ui-hover); -} - -@media (width < 48rem) { - .search { - flex-basis: 100%; - } - - .position { - order: 1; - } - - .pathNav { - flex-basis: 100%; - order: 2; - } - - .canvas { - min-block-size: calc(var(--spacing-320) + var(--spacing-80)); - } -} diff --git a/apps/sim/platform/app/(shell)/people/_components/org-chart/org-chart.tsx b/apps/sim/platform/app/(shell)/people/_components/org-chart/org-chart.tsx deleted file mode 100644 index 7469cbbfd82..00000000000 --- a/apps/sim/platform/app/(shell)/people/_components/org-chart/org-chart.tsx +++ /dev/null @@ -1,315 +0,0 @@ -'use client' - -import { Fragment, type MouseEvent, useCallback, useLayoutEffect, useMemo, useRef } from 'react' -import { - DirectorySources, - ImportPrompt, -} from '@/platform/app/(shell)/people/_components/directory-sync/directory-sources' -import { ChartCanvas } from '@/platform/app/(shell)/people/_components/org-chart/chart-canvas' -import { ChartEmptyState } from '@/platform/app/(shell)/people/_components/org-chart/chart-empty-state' -import styles from '@/platform/app/(shell)/people/_components/org-chart/org-chart.module.css' -import { PeopleStatus } from '@/platform/app/(shell)/people/_components/people-page/people-status' -import { ProfileUnavailable } from '@/platform/app/(shell)/people/_components/people-page/profile-unavailable' -import { - Avatar, - Button, - FailureNotice, - Icon, - IconButton, - SearchableSelect, - SearchCombobox, -} from '@/platform/design-system/components' -import { useScrollEdges } from '@/platform/design-system/components/scroll-area/use-scroll-edges' -import { - isRefusal, - type SyncSource, - useOrgChart, - usePeoplePicker, - usePeopleViewer, -} from '@/platform/lib/data/people' - -function ReportingLinesGraphic() { - return ( - - - - - - - ) -} - -interface FindPersonProps { - organizationId: string - onSelect: (memberId: string) => void -} - -function FindPerson({ organizationId, onSelect }: FindPersonProps) { - const picker = usePeoplePicker(organizationId) - const options = useMemo( - () => - picker.people.map((person) => ({ - value: person.memberId, - label: person.name, - leading: , - details: [person.title || person.email, person.department].filter( - (detail): detail is string => Boolean(detail) - ), - })), - [picker.people] - ) - return ( - - ) -} - -interface BrowseTeamsProps { - organizationId: string - organizationName: string - onSelect: (memberId: string) => void -} - -function BrowseTeams({ organizationId, organizationName, onSelect }: BrowseTeamsProps) { - const picker = usePeoplePicker(organizationId, 'roots') - return ( - { - if (next) onSelect(next) - }} - /> - ) -} - -type FocusTarget = 'person' | 'previous' | 'next' - -interface OrgChartProps { - organizationId: string - /** An administrator with no reporting lines and no connection: the chart offers to start one. */ - firstUse: boolean - onConnect: (source: SyncSource) => void - onImport: () => void - onSync?: () => void -} - -export function OrgChart({ organizationId, firstUse, onConnect, onImport, onSync }: OrgChartProps) { - const { organizationName, canProvision } = usePeopleViewer() - const chart = useOrgChart(organizationId) - const { branch, focus, page, size, compact, setView, profileQuery } = chart - const pathEdges = useScrollEdges('horizontal') - const currentPerson = useRef(null) - const previousReports = useRef(null) - const nextReports = useRef(null) - const pendingFocus = useRef<{ - focus: string - page: number - trigger: HTMLElement - target: FocusTarget - } | null>(null) - const focusPerson = useCallback( - (id: string, event?: MouseEvent) => { - // `detail === 0` is a keyboard click: focus follows the navigation instead of being lost. - pendingFocus.current = - event?.detail === 0 - ? { focus: id, page: 1, trigger: event.currentTarget, target: 'person' } - : null - void setView({ focus: id, page: 1, view: 'chart' }, { history: 'push' }) - }, - [setView] - ) - const changePage = ( - value: number, - target: 'previous' | 'next', - event: MouseEvent - ) => { - pendingFocus.current = - event.detail === 0 ? { focus, page: value, trigger: event.currentTarget, target } : null - void setView({ page: value }) - } - useLayoutEffect(() => { - const pending = pendingFocus.current - if (!branch || !pending || pending.focus !== focus || pending.page !== page) return - pendingFocus.current = null - const active = document.activeElement - if (active !== document.body && active !== pending.trigger) return - const pagers = [previousReports.current, nextReports.current].map((slot) => - slot?.querySelector('button') - ) - const controls = pending.target === 'next' ? pagers.reverse() : pagers - const target = - pending.target === 'person' - ? currentPerson.current - : (controls.find((button) => button && !button.disabled) ?? currentPerson.current) - target?.focus() - }, [branch, focus, page]) - const setCurrentPerson = useCallback((element: HTMLElement | null) => { - currentPerson.current = element - }, []) - const browseDirectory = () => void setView({ view: 'directory' }, { history: 'push' }) - const noLines = branch && !branch.chain.length && !branch.totalReports - - if (noLines && firstUse) - return ( -
- -
-

Bring your organization into view

-

- Connect your identity provider to keep people and reporting lines current, including - people who haven’t joined Sim. -

-
- - -

- You can also edit an existing member’s profile to add their manager. -

-
- ) - - return ( -
-
-
- -
- {branch && !noLines && ( - - )} -
- -
-
- {noLines ? ( -
- -
- ) : branch ? ( - size && ( -
- - changePage(branch.page - 1, 'previous', event)} - /> - - - {size === 1 - ? branch.page - : `${(branch.page - 1) * size + 1}–${(branch.page - 1) * size + branch.reports.length}`}{' '} - of {branch.totalReports} direct reports - - - = branch.totalReports} - onClick={(event) => changePage(branch.page + 1, 'next', event)} - /> - -
- ) - } - /> - ) : chart.error ? ( -
- {isRefusal(chart.error) ? ( - focusPerson('', event)}>My position - ) : ( - - ) - } - /> - ) : ( - - )} -
- ) : ( - Loading org chart… - )} -
- ) -} diff --git a/apps/sim/platform/app/(shell)/people/_components/people-page/people-frame.tsx b/apps/sim/platform/app/(shell)/people/_components/people-page/people-frame.tsx deleted file mode 100644 index 1f707deb947..00000000000 --- a/apps/sim/platform/app/(shell)/people/_components/people-page/people-frame.tsx +++ /dev/null @@ -1,46 +0,0 @@ -'use client' - -import type { ReactNode } from 'react' -import styles from '@/platform/app/(shell)/people/_components/people-page/people-page.module.css' - -type PeopleView = 'chart' | 'directory' - -const VIEWS = [ - { value: 'chart', label: 'Org chart' }, - { value: 'directory', label: 'Directory' }, -] as const - -interface PeopleFrameProps { - actions?: ReactNode - view?: PeopleView - onViewChange?: (view: PeopleView) => void - children: ReactNode -} - -/** The title, its actions and the view tabs, shared with the loading state so nothing shifts. */ -export function PeopleFrame({ actions, view, onViewChange, children }: PeopleFrameProps) { - return ( -
-
-

People

- {actions &&
{actions}
} -
- -
{children}
-
- ) -} diff --git a/apps/sim/platform/app/(shell)/people/_components/people-page/people-page.module.css b/apps/sim/platform/app/(shell)/people/_components/people-page/people-page.module.css deleted file mode 100644 index ea76ea1df46..00000000000 --- a/apps/sim/platform/app/(shell)/people/_components/people-page/people-page.module.css +++ /dev/null @@ -1,70 +0,0 @@ -.page { - display: flex; - flex-direction: column; - min-block-size: 100%; - padding-block-start: var(--shell-home-row-start); -} - -.header { - display: flex; - flex-wrap: wrap; - align-items: center; - justify-content: space-between; - gap: var(--spacing-8) var(--spacing-16); - padding-inline: var(--ui-page-inline-gutter); -} - -.actions { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: var(--spacing-8); -} - -.updated { - color: var(--ui-secondary); - margin-inline-end: var(--spacing-8); -} - -.tabs { - display: flex; - gap: var(--spacing-24); - margin-block-start: var(--spacing-24); - padding-inline: var(--ui-page-inline-gutter); - border-block-end: var(--spacing-stroke-default) solid var(--ui-divider); -} - -.tab { - margin-block-end: calc(-1 * var(--spacing-stroke-default)); - min-block-size: var(--spacing-40); - border-block-end: var(--spacing-stroke-focus) solid transparent; - color: var(--ui-secondary); - cursor: pointer; -} - -.tab:hover:enabled { - color: var(--ui-ink); -} - -.tab[aria-current="page"] { - border-block-end-color: var(--ui-ink); - color: var(--ui-ink); -} - -.tab:disabled { - cursor: default; -} - -.body { - display: flex; - flex: 1; - flex-direction: column; - min-block-size: 0; - padding-block-start: var(--spacing-20); -} - -.status { - color: var(--ui-secondary); - padding-block: var(--spacing-48); - text-align: center; -} diff --git a/apps/sim/platform/app/(shell)/people/_components/people-page/people-page.tsx b/apps/sim/platform/app/(shell)/people/_components/people-page/people-page.tsx deleted file mode 100644 index cdb9ad18ed6..00000000000 --- a/apps/sim/platform/app/(shell)/people/_components/people-page/people-page.tsx +++ /dev/null @@ -1,133 +0,0 @@ -'use client' - -import { useState } from 'react' -import dynamic from 'next/dynamic' -import { PeopleDirectory } from '@/platform/app/(shell)/people/_components/directory/directory' -import { PeopleFrame } from '@/platform/app/(shell)/people/_components/people-page/people-frame' -import styles from '@/platform/app/(shell)/people/_components/people-page/people-page.module.css' -import { Button } from '@/platform/design-system/components' -import { - type SyncSource, - useChartRoots, - useDirectorySyncStatus, - usePeopleUrl, - usePeopleViewer, -} from '@/platform/lib/data/people' -import { formatRelativeTime } from '@/platform/lib/time' - -const OrgChart = dynamic(() => - import('@/platform/app/(shell)/people/_components/org-chart/org-chart').then( - (module) => module.OrgChart - ) -) -const ImportModal = dynamic(() => - import('@/platform/app/(shell)/people/_components/import/import-modal').then( - (module) => module.ImportModal - ) -) -const DirectorySyncPage = dynamic(() => - import('@/platform/app/(shell)/people/_components/directory-sync/directory-sync-page').then( - (module) => module.DirectorySyncPage - ) -) -const ConnectModal = dynamic(() => - import('@/platform/app/(shell)/people/_components/directory-sync/connect-modal').then( - (module) => module.ConnectModal - ) -) - -interface PeoplePageProps { - organizationId: string -} - -export function PeoplePage({ organizationId }: PeoplePageProps) { - const { isAdmin } = usePeopleViewer() - const [{ view, search, department, departmentKey, panel }, setView] = usePeopleUrl() - const [importing, setImporting] = useState(false) - const [connecting, setConnecting] = useState(null) - const currentView = view ?? (search || department || departmentKey ? 'directory' : 'chart') - const status = useDirectorySyncStatus(organizationId, isAdmin) - const roots = useChartRoots(organizationId, isAdmin && currentView === 'chart') - const openSync = () => void setView({ panel: 'sync' }, { history: 'push' }) - const openImport = () => setImporting(true) - const firstUse = isAdmin && roots.emptyOrganization && status.loaded && !status.sync - // First use has no header actions, so they wait for the reads that decide it rather than flash. - const showActions = - isAdmin && (currentView !== 'chart' || (roots.settled && status.settled && !firstUse)) - const lastSuccessAt = status.sync?.lastSuccessAt - const modals = ( - <> - {importing && isAdmin && ( - setImporting(false)} - onOpenSync={() => { - setImporting(false) - openSync() - }} - /> - )} - {connecting && isAdmin && ( - setConnecting(null)} - onConnected={() => { - setConnecting(null) - openSync() - }} - /> - )} - - ) - - if (panel === 'sync' && isAdmin) - return ( - <> - void setView({ panel: null }, { history: 'push' })} - onConnect={setConnecting} - onImport={openImport} - /> - {modals} - - ) - - return ( - void setView({ view: next }, { history: 'push' })} - actions={ - showActions ? ( - <> - {lastSuccessAt && ( - - Updated {formatRelativeTime(Date.parse(lastSuccessAt), Date.now())} - - )} - - - - ) : undefined - } - > - {currentView === 'chart' ? ( - - ) : ( - - )} - {modals} - - ) -} diff --git a/apps/sim/platform/app/(shell)/people/_components/people-page/people-skeleton.tsx b/apps/sim/platform/app/(shell)/people/_components/people-page/people-skeleton.tsx deleted file mode 100644 index a26e6165ef6..00000000000 --- a/apps/sim/platform/app/(shell)/people/_components/people-page/people-skeleton.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { PeopleFrame } from '@/platform/app/(shell)/people/_components/people-page/people-frame' -import { PeopleStatus } from '@/platform/app/(shell)/people/_components/people-page/people-status' - -export function PeopleSkeleton() { - return ( - - Loading people… - - ) -} diff --git a/apps/sim/platform/app/(shell)/people/_components/people-page/people-status.tsx b/apps/sim/platform/app/(shell)/people/_components/people-page/people-status.tsx deleted file mode 100644 index 294efbefe98..00000000000 --- a/apps/sim/platform/app/(shell)/people/_components/people-page/people-status.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import styles from '@/platform/app/(shell)/people/_components/people-page/people-page.module.css' - -interface PeopleStatusProps { - children: string -} - -/** The body of a People view while its first read is pending, the same size in every view. */ -export function PeopleStatus({ children }: PeopleStatusProps) { - return ( -

- {children} -

- ) -} diff --git a/apps/sim/platform/app/(shell)/people/_components/people-page/profile-unavailable.tsx b/apps/sim/platform/app/(shell)/people/_components/people-page/profile-unavailable.tsx deleted file mode 100644 index 275f1722ae1..00000000000 --- a/apps/sim/platform/app/(shell)/people/_components/people-page/profile-unavailable.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import type { ReactNode } from 'react' -import { EmptyState } from '@/platform/design-system/components' - -interface ProfileUnavailableProps { - action: ReactNode -} - -/** The neutral answer for a person this viewer can't reach, whether gone or refused. */ -export function ProfileUnavailable({ action }: ProfileUnavailableProps) { - return ( - - ) -} diff --git a/apps/sim/platform/app/(shell)/people/_components/person-identity/person-identity.module.css b/apps/sim/platform/app/(shell)/people/_components/person-identity/person-identity.module.css deleted file mode 100644 index 11d185b820c..00000000000 --- a/apps/sim/platform/app/(shell)/people/_components/person-identity/person-identity.module.css +++ /dev/null @@ -1,17 +0,0 @@ -.identity { - display: flex; - align-items: center; - min-inline-size: 0; - gap: var(--spacing-12); -} - -.copy { - display: grid; - min-inline-size: 0; - gap: var(--ui-stack-gap); - overflow-wrap: anywhere; -} - -.detail { - color: var(--ui-secondary); -} diff --git a/apps/sim/platform/app/(shell)/people/_components/person-identity/person-identity.tsx b/apps/sim/platform/app/(shell)/people/_components/person-identity/person-identity.tsx deleted file mode 100644 index a053d435883..00000000000 --- a/apps/sim/platform/app/(shell)/people/_components/person-identity/person-identity.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import styles from '@/platform/app/(shell)/people/_components/person-identity/person-identity.module.css' -import { Avatar } from '@/platform/design-system/components' -import type { PersonSummary } from '@/platform/lib/data/people' - -interface PersonIdentityProps { - person: Pick -} - -/** A person's avatar, name and what they do: the row that directory and profile lists share. */ -export function PersonIdentity({ person }: PersonIdentityProps) { - return ( - - - - {person.name} - - {person.title || person.email} - - - - ) -} diff --git a/apps/sim/platform/app/(shell)/people/_components/person/person-modal.tsx b/apps/sim/platform/app/(shell)/people/_components/person/person-modal.tsx deleted file mode 100644 index b754bd45ce3..00000000000 --- a/apps/sim/platform/app/(shell)/people/_components/person/person-modal.tsx +++ /dev/null @@ -1,114 +0,0 @@ -'use client' - -import { useMemo, useState } from 'react' -import { DatePicker, Modal, SearchableSelect, TextField } from '@/platform/design-system/components' -import { - type Person, - type PersonDraft, - useEditPerson, - usePeoplePicker, -} from '@/platform/lib/data/people' - -/** Profile text's server bound; a longer draft would only come back refused. */ -const PROFILE_TEXT_MAX_LENGTH = 200 - -/** The picker's value for no manager; membership ids are never this. */ -const NO_MANAGER = 'no-manager' - -const TEXT_FIELDS = [ - { field: 'title', label: 'Title', placeholder: 'Staff Engineer' }, - { field: 'department', label: 'Department', placeholder: 'Engineering' }, - { field: 'location', label: 'Location', placeholder: 'San Francisco' }, -] as const - -interface PersonModalProps { - organizationId: string - person: Person - onClose: () => void -} - -/** A validation problem keeps the dialog open like a refused save, with its message on the field. */ -class FieldProblems extends Error {} - -export function PersonModal({ organizationId, person, onClose }: PersonModalProps) { - const edit = useEditPerson(organizationId, person.memberId) - const picker = usePeoplePicker(organizationId, 'members', person.memberId) - const [draft, setDraft] = useState({ - title: person.title, - department: person.department, - location: person.location, - startDate: person.startDate, - managerMemberId: person.managerMemberId, - }) - const [saving, setSaving] = useState(false) - const [errors, setErrors] = useState>>({}) - // The chosen manager's name, kept with the choice: the search results may no longer include them. - // Only a stored manager who is suspended has an id and no name. - const [managerName, setManagerName] = useState(person.manager?.name ?? null) - const managerOptions = useMemo( - () => [{ value: NO_MANAGER, label: 'No manager' }, ...picker.options], - [picker.options] - ) - const change = (field: Key, value: PersonDraft[Key]) => { - setDraft((current) => ({ ...current, [field]: value })) - setErrors((current) => ({ ...current, [field]: undefined })) - } - return ( - !open && onClose()} - title={`Edit ${person.name}`} - confirmLabel='Save' - onConfirm={async () => { - setSaving(true) - try { - const problems = await edit.save(draft) - if (problems) { - setErrors(problems) - throw new FieldProblems('Check the highlighted fields.') - } - } finally { - setSaving(false) - } - }} - > - {TEXT_FIELDS.map(({ field, label, placeholder }) => ( - change(field, value || null)} - error={errors[field]} - disabled={saving} - /> - ))} - change('startDate', value)} - error={errors.startDate} - disabled={saving} - /> - { - const manager = picker.people.find((candidate) => candidate.memberId === value) - setManagerName(manager?.name ?? null) - change('managerMemberId', manager?.memberId ?? null) - }} - /> - - ) -} diff --git a/apps/sim/platform/app/(shell)/people/_components/person/person-skeleton.tsx b/apps/sim/platform/app/(shell)/people/_components/person/person-skeleton.tsx deleted file mode 100644 index c73e5347d07..00000000000 --- a/apps/sim/platform/app/(shell)/people/_components/person/person-skeleton.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { PeopleStatus } from '@/platform/app/(shell)/people/_components/people-page/people-status' -import styles from '@/platform/app/(shell)/people/_components/person/person.module.css' - -/** A profile's frame while the route loads, with room where its way back will appear. */ -export function PersonSkeleton() { - return ( -
-
- Loading profile… -
- ) -} diff --git a/apps/sim/platform/app/(shell)/people/_components/person/person.module.css b/apps/sim/platform/app/(shell)/people/_components/person/person.module.css deleted file mode 100644 index 7cd69ffe8d5..00000000000 --- a/apps/sim/platform/app/(shell)/people/_components/person/person.module.css +++ /dev/null @@ -1,136 +0,0 @@ -.page { - display: flex; - flex-direction: column; - min-block-size: 100%; - padding: var(--shell-home-row-start) var(--ui-page-inline-gutter) var(--ui-page-inset-block); - gap: var(--spacing-32); -} - -.nav, -.heading { - display: flex; - flex-wrap: wrap; - align-items: center; - justify-content: space-between; - gap: var(--spacing-12); -} - -.nav { - min-block-size: var(--spacing-app-chip); -} - -.back { - margin-inline-start: calc(-1 * var(--spacing-control-inline)); -} - -.heading { - padding-block: var(--spacing-16) var(--spacing-8); -} - -.identity { - display: flex; - align-items: center; - min-inline-size: 0; - gap: var(--spacing-24); -} - -.titles { - display: grid; - min-inline-size: 0; - gap: var(--spacing-8); - overflow-wrap: anywhere; -} - -.actions { - display: flex; - flex-wrap: wrap; - gap: var(--spacing-8); -} - -.secondary { - color: var(--ui-secondary); -} - -.columns { - display: grid; - grid-template-columns: minmax(0, var(--spacing-320)) minmax(0, 1fr); - gap: var(--spacing-32) var(--spacing-64); -} - -.about, -.facts, -.lines, -.lineSection { - display: flex; - flex-direction: column; - min-inline-size: 0; -} - -.about, -.lineSection, -.facts { - gap: var(--spacing-24); -} - -.lineSection { - gap: var(--spacing-16); -} - -.fact { - display: grid; - gap: var(--spacing-6); - overflow-wrap: anywhere; -} - -.factLink:hover { - text-decoration: underline; -} - -.managed { - display: grid; - gap: var(--spacing-8); - padding-block-start: var(--spacing-16); -} - -.managedBy { - display: flex; - align-items: center; - gap: var(--spacing-8); - color: var(--ui-secondary); -} - -.lines { - gap: var(--spacing-32); -} - -.lineHeading { - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--spacing-8); -} - -.list { - display: grid; - gap: var(--ui-list-gap); - margin-inline: calc(-1 * var(--ui-row-inset)); -} - -.row { - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--spacing-12); - padding: var(--spacing-12) var(--ui-row-inset); - border-radius: var(--ui-control-radius); -} - -.row:hover { - background: var(--ui-hover); -} - -@media (width < 48rem) { - .columns { - grid-template-columns: minmax(0, 1fr); - } -} diff --git a/apps/sim/platform/app/(shell)/people/_components/person/person.tsx b/apps/sim/platform/app/(shell)/people/_components/person/person.tsx deleted file mode 100644 index 9789b2ffd2b..00000000000 --- a/apps/sim/platform/app/(shell)/people/_components/person/person.tsx +++ /dev/null @@ -1,263 +0,0 @@ -'use client' - -import { useState } from 'react' -import Link from 'next/link' -import { PeopleStatus } from '@/platform/app/(shell)/people/_components/people-page/people-status' -import { ProfileUnavailable } from '@/platform/app/(shell)/people/_components/people-page/profile-unavailable' -import styles from '@/platform/app/(shell)/people/_components/person/person.module.css' -import { PersonModal } from '@/platform/app/(shell)/people/_components/person/person-modal' -import { PersonIdentity } from '@/platform/app/(shell)/people/_components/person-identity/person-identity' -import { Avatar, Button, FailureNotice, Icon } from '@/platform/design-system/components' -import { useClipboard } from '@/platform/design-system/components/feedback/use-clipboard' -import { - isRefusal, - type Person, - type PersonSummary, - personHref, - SYNC_SOURCE_NAMES, - usePeopleViewer, - usePersonProfile, -} from '@/platform/lib/data/people' -import { formatCalendarDate } from '@/platform/lib/time' - -const NOT_PROVIDED = 'Not provided' - -/** `2024-01-31` as a calendar day, with how long ago it was: "Jan 31, 2024 · 2 yrs". */ -function describeStartDate(startDate: string): string { - const start = new Date(`${startDate}T00:00:00Z`) - const now = new Date() - const months = - (now.getUTCFullYear() - start.getUTCFullYear()) * 12 + - now.getUTCMonth() - - start.getUTCMonth() - - (now.getUTCDate() < start.getUTCDate() ? 1 : 0) - const tenure = - months < 0 - ? 'starts soon' - : months < 1 - ? 'new' - : months < 12 - ? `${months} mo` - : `${Math.floor(months / 12)} yr${months >= 24 ? 's' : ''}` - return `${formatCalendarDate(startDate, 'full')} · ${tenure}` -} - -interface AboutProps { - person: Person - departmentHref: string -} - -function About({ person, departmentHref }: AboutProps) { - const facts: { label: string; value: string; href?: string }[] = [ - { label: 'Email', value: person.email, href: `mailto:${person.email}` }, - { - label: 'Department', - value: person.department ?? NOT_PROVIDED, - href: person.department ? departmentHref : undefined, - }, - { label: 'Location', value: person.location ?? NOT_PROVIDED }, - { - label: 'Start date', - value: person.startDate ? describeStartDate(person.startDate) : NOT_PROVIDED, - }, - ] - return ( -
-

- About -

-
- {facts.map(({ label, value, href }) => ( -
-
- {label} -
-
- {href ? ( - - {value} - - ) : ( - value - )} -
-
- ))} -
- {person.directorySource && ( -
-

- - Managed by {SYNC_SOURCE_NAMES[person.directorySource]} -

-

- Profile details update from your directory. Contact an admin to make a correction. -

-
- )} -
- ) -} - -interface PersonRowsProps { - organizationId: string - originQuery: string - people: readonly PersonSummary[] -} - -function PersonRows({ organizationId, originQuery, people }: PersonRowsProps) { - return ( -
    - {people.map((person) => ( -
  • - - - - -
  • - ))} -
- ) -} - -interface PersonProfileProps { - organizationId: string - memberId: string -} - -export function PersonProfile({ organizationId, memberId }: PersonProfileProps) { - const { isAdmin } = usePeopleViewer() - const profile = usePersonProfile(organizationId, memberId) - const { copied, copy } = useClipboard() - const [editing, setEditing] = useState(false) - const { data } = profile - const canEdit = isAdmin && Boolean(data) && !data?.person.directorySource - const unavailable = isRefusal(profile.error) - return ( -
-
- {!unavailable && ( -
- -
- )} - {data && ( - - )} -
- {profile.error ? ( - unavailable ? ( - }>Back to directory - } - /> - ) : ( - - ) - ) : !data ? ( - Loading profile… - ) : ( - <> -
-
- -
-

{data.person.name}

- {data.person.title && ( -

- {data.person.title} -

- )} -
-
-
- - {canEdit && } -
-
-
- -
-
-

- Reports to -

- {data.chain.length > 0 ? ( - - ) : ( -

- {data.person.managerMemberId - ? 'Their manager is suspended.' - : 'No manager listed. This person is still part of your organization, and their reporting line will appear when it is provided.'} -

- )} -
-
-
-

- Direct reports -

- {data.totalReports > 0 && ( - - {data.totalReports === 1 ? '1 person' : `${data.totalReports} people`} - - )} -
- {data.reports.length > 0 ? ( - - ) : ( -

- No one reports to this person. -

- )} - {data.totalReports > data.reports.length && ( -
- -
- )} -
-
-
- {editing && canEdit && ( - setEditing(false)} - /> - )} - - )} -
- ) -} diff --git a/apps/sim/platform/design-system/catalog/icon-geometry.tsx b/apps/sim/platform/design-system/catalog/icon-geometry.tsx index aab76568475..e4c671ba2c4 100644 --- a/apps/sim/platform/design-system/catalog/icon-geometry.tsx +++ b/apps/sim/platform/design-system/catalog/icon-geometry.tsx @@ -859,21 +859,6 @@ export const glyphs = { ), }, - I133: { - label: 'users', - viewBox: '0 0 24 24', - draw: (): ReactNode => ( - <> - - - - ), - }, - I134: { - label: 'minus', - viewBox: '0 0 24 24', - draw: (): ReactNode => , - }, } as const export type IconName = keyof typeof glyphs diff --git a/apps/sim/platform/design-system/components/avatar/avatar.module.css b/apps/sim/platform/design-system/components/avatar/avatar.module.css index 18c02db66b3..c6e53805809 100644 --- a/apps/sim/platform/design-system/components/avatar/avatar.module.css +++ b/apps/sim/platform/design-system/components/avatar/avatar.module.css @@ -22,21 +22,6 @@ height: var(--ui-icon-size); } -.avatar[data-size="row"] { - width: var(--spacing-36); - height: var(--spacing-36); -} - -.avatar[data-size="card"] { - width: var(--spacing-40); - height: var(--spacing-40); -} - -.avatar[data-size="hero"] { - width: var(--spacing-80); - height: var(--spacing-80); -} - .avatarImage { display: block; width: 100%; diff --git a/apps/sim/platform/design-system/components/avatar/avatar.tsx b/apps/sim/platform/design-system/components/avatar/avatar.tsx index 8b064f7e938..b9eb3094637 100644 --- a/apps/sim/platform/design-system/components/avatar/avatar.tsx +++ b/apps/sim/platform/design-system/components/avatar/avatar.tsx @@ -14,13 +14,11 @@ export function AvatarImage({ src, name = '' }: AvatarImageProps) { return {name} } -const AVATAR_TYPE = { inline: 'T021', row: 'T050', card: 'T042', hero: 'T076' } as const - interface AvatarProps { name: string src?: string organization?: boolean - size?: 'inline' | 'row' | 'card' | 'hero' + size?: 'inline' } export function Avatar({ name, src, organization, size }: AvatarProps) { @@ -31,7 +29,7 @@ export function Avatar({ name, src, organization, size }: AvatarProps) { data-size={size} role='img' aria-label={name} - data-type={size ? AVATAR_TYPE[size] : organization ? 'T034' : 'T050'} + data-type={size ? 'T021' : organization ? 'T034' : 'T050'} > {src ? : Array.from(name.trim().toUpperCase())[0]} diff --git a/apps/sim/platform/design-system/components/data-display/data-display.module.css b/apps/sim/platform/design-system/components/data-display/data-display.module.css index 67a58d8f8a6..3ccb05689cf 100644 --- a/apps/sim/platform/design-system/components/data-display/data-display.module.css +++ b/apps/sim/platform/design-system/components/data-display/data-display.module.css @@ -55,9 +55,6 @@ color: var(--ui-secondary); overflow-wrap: anywhere; } -.table [data-primary] { - inline-size: 32%; -} .table [data-numeric="true"] { inline-size: calc(var(--ui-table-compact) + var(--spacing-16)); text-align: end; @@ -108,10 +105,6 @@ padding-inline: 0; text-align: end; } -.table [data-kind="action"][data-passive] { - padding-inline-end: var(--spacing-8); - pointer-events: none; -} .sort { display: inline-flex; align-items: center; diff --git a/apps/sim/platform/design-system/components/data-display/data-display.tsx b/apps/sim/platform/design-system/components/data-display/data-display.tsx index e8e2107e038..580c8e617ad 100644 --- a/apps/sim/platform/design-system/components/data-display/data-display.tsx +++ b/apps/sim/platform/design-system/components/data-display/data-display.tsx @@ -46,8 +46,6 @@ type TableColumn = { key: string label: string showFrom?: 'compact' | 'wide' - /** The column a row is read by, wider than the columns that share the rest. */ - primary?: boolean /** Offer this column to `onSortChange`. The caller owns the order of `rows`. */ sortable?: boolean } & ( @@ -87,8 +85,6 @@ interface DataTableProps { loadMore?: LoadMoreState /** Window a long list inside a bounded scroller with a sticky header. */ virtualized?: boolean - /** Mark rows that open a destination with a trailing chevron, at every width. */ - rowChevron?: boolean } function columnFormat(column: TableColumn, locale: string) { @@ -126,7 +122,6 @@ interface TableRowViewProps { row: TableRow fields: readonly TableField[] actionColumn: boolean - rowChevron?: boolean index?: number rowRef?: Ref } @@ -135,7 +130,6 @@ const TableRowView = memo(function TableRowView({ row, fields, actionColumn, - rowChevron, index, rowRef, }: TableRowViewProps) { @@ -155,7 +149,6 @@ const TableRowView = memo(function TableRowView({ key={column.key} data-kind={column.kind} data-from={column.showFrom} - data-primary={column.primary || undefined} data-numeric={Boolean(column.format)} data-type={column.kind === 'text' ? 'T059' : 'T060'} > @@ -173,11 +166,7 @@ const TableRowView = memo(function TableRowView({ ) })} - {actionColumn && ( - - {row.action ?? (rowChevron && )} - - )} + {actionColumn && {row.action}} ) }) @@ -205,7 +194,6 @@ function TableHead({ fields, actionColumn, sort, onSortChange }: TableHeadProps) aria-sort={sortable ? ARIA_SORT[direction ?? 'none'] : undefined} data-kind={column.kind} data-from={column.showFrom} - data-primary={column.primary || undefined} data-numeric={Boolean(column.format)} data-type='T050' > @@ -248,7 +236,6 @@ interface VirtualTableProps extends TableHeadProps { title: string rows: readonly TableRow[] loadMore?: LoadMoreState - rowChevron?: boolean } function VirtualTable({ @@ -259,7 +246,6 @@ function VirtualTable({ sort, onSortChange, loadMore, - rowChevron, }: VirtualTableProps) { const viewport = useRef(null) const scrollRef = useScrollEdges('vertical', { viewport, tabStop: true }) @@ -299,7 +285,6 @@ function VirtualTable({ row={rows[item.index]} fields={fields} actionColumn={actionColumn} - rowChevron={rowChevron} index={item.index} rowRef={virtualizer.measureElement} /> @@ -343,10 +328,9 @@ export function DataTable({ onSortChange, loadMore, virtualized, - rowChevron, }: DataTableProps) { const fields = columns.map((column) => ({ ...column, format: columnFormat(column, locale) })) - const actionColumn = Boolean(rowChevron) || rows.some((row) => row.action) + const actionColumn = rows.some((row) => row.action) return (
{(!titleHidden || control) && ( @@ -365,7 +349,6 @@ export function DataTable({ sort={sort} onSortChange={onSortChange} loadMore={loadMore} - rowChevron={rowChevron} /> ) : ( <> @@ -384,7 +367,6 @@ export function DataTable({ row={row} fields={fields} actionColumn={actionColumn} - rowChevron={rowChevron} /> ))} diff --git a/apps/sim/platform/design-system/components/empty-state/empty-state.module.css b/apps/sim/platform/design-system/components/empty-state/empty-state.module.css index a8a57b15643..38781a5e3ab 100644 --- a/apps/sim/platform/design-system/components/empty-state/empty-state.module.css +++ b/apps/sim/platform/design-system/components/empty-state/empty-state.module.css @@ -1,5 +1,5 @@ .empty, -.copy { +.empty > div { display: flex; flex-direction: column; align-items: flex-start; @@ -18,12 +18,6 @@ gap: var(--spacing-12); } -.actions { - display: flex; - flex-wrap: wrap; - gap: var(--spacing-8); -} - .empty p { color: var(--ui-secondary); } diff --git a/apps/sim/platform/design-system/components/empty-state/empty-state.tsx b/apps/sim/platform/design-system/components/empty-state/empty-state.tsx index 049406b9e6e..9edf920d8da 100644 --- a/apps/sim/platform/design-system/components/empty-state/empty-state.tsx +++ b/apps/sim/platform/design-system/components/empty-state/empty-state.tsx @@ -17,11 +17,11 @@ export function EmptyState({ title, description, action, graphic }: EmptyStatePr {graphic} )} -
+

{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 a17e03fb1b1..4f98b66353a 100644 --- a/apps/sim/platform/design-system/components/index.ts +++ b/apps/sim/platform/design-system/components/index.ts @@ -38,7 +38,6 @@ 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 222867310a0..02726aaf6bb 100644 --- a/apps/sim/platform/design-system/components/picker/picker.module.css +++ b/apps/sim/platform/design-system/components/picker/picker.module.css @@ -50,94 +50,6 @@ 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 deleted file mode 100644 index d9d239e6d2d..00000000000 --- a/apps/sim/platform/design-system/components/picker/search-combobox.tsx +++ /dev/null @@ -1,134 +0,0 @@ -'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} - - ))} - - - - )} - - - - - - - - )} -
- ) -} 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 4c3bf6b2d9e..9905d884c9f 100644 --- a/apps/sim/platform/design-system/components/picker/search-popup.tsx +++ b/apps/sim/platform/design-system/components/picker/search-popup.tsx @@ -28,20 +28,11 @@ 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, - detached, - children, -}: SearchPopupProps) { +export function SearchPopup({ label, searchLabel, container, search, children }: SearchPopupProps) { const input = useRef(null) const queryEdges = useScrollEdges('horizontal', { viewport: input }) const scrollRef = useScrollEdges() @@ -51,7 +42,7 @@ export function SearchPopup({ align='start' positionMethod='fixed' sideOffset={8} - className={detached ? `${popup.positioner} ${picker.detachedPopup}` : popup.anchorWidth} + className={popup.anchorWidth} > @@ -108,7 +104,6 @@ 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 deleted file mode 100644 index 16702606328..00000000000 --- a/apps/sim/platform/lib/data/people.ts +++ /dev/null @@ -1,445 +0,0 @@ -'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 9d3e4c85913..36554a53cbd 100644 --- a/apps/sim/scripts/test-org-redesign-e2e.ts +++ b/apps/sim/scripts/test-org-redesign-e2e.ts @@ -3280,23 +3280,27 @@ async function peoplePhaseChecks() { }) }) await check( - 'a member reads that an organization admin connects the directory, and no name', + 'a member reads that an administrator connects the directory, and no name', async () => { await withSession(MEMBER, async ({ page }) => { await page.goto(orgUrl('/people')) - await expect(page.getByRole('button', { name: 'Org chart' })).toBeVisible() - await expect(page.getByText(/organization admin/i).first()).toBeVisible() + 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: 'Directory sync' })).toHaveCount(0) - await expect(page.getByRole('button', { name: 'Import', exact: true })).toHaveCount(0) - await expect(page.getByRole('button', { name: 'Connect Okta' })).toHaveCount(0) + await expect(page.getByRole('button', { name: 'Import people' })).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: 'Connect Okta' })).toBeVisible() - await expect(page.getByRole('button', { name: 'Import profiles' })).toBeVisible() + await expect(page.getByRole('button', { name: /Directory sync/ }).first()).toBeVisible() + await expect(page.getByRole('button', { name: /Import/ }).first()).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 1d52aa65a1a..b90de740bf1 100644 --- a/apps/sim/scripts/test-people-e2e.ts +++ b/apps/sim/scripts/test-people-e2e.ts @@ -566,40 +566,31 @@ async function run() { waitUntil: 'domcontentloaded', timeout: 180_000, }) - const trail = page.getByRole('navigation', { name: 'Reporting path' }) - const focused = (name: string) => trail.getByRole('heading', { name, exact: true }) - const profileOf = (name: string) => + const focused = (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({ position: { x: 4, y: 4 } }) - await expect(page.locator('[role=tooltip]').filter({ hasText: longTitle })).toBeVisible() + await longTitleLabel.hover() + await expect( + page.locator('[data-native-surface-overlay]').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 = page.getByRole('button', { name: 'Explore Felix Ward' }) + const card = focused('Maya Chen') 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 cardColor = () => - card.evaluate((node) => getComputedStyle(node.parentElement as Element).backgroundColor) - const lightCardColor = await cardColor() + const lightCardColor = await card.evaluate((node) => getComputedStyle(node).backgroundColor) await page.emulateMedia({ colorScheme: 'dark' }) - await expect.poll(cardColor).not.toBe(lightCardColor) + await expect + .poll(() => card.evaluate((node) => getComputedStyle(node).backgroundColor)) + .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() @@ -632,8 +623,8 @@ async function run() { (text) => navigator.clipboard.writeText(text), `${boundedPaste}${'界'.repeat(4096)}` ) - const pickerSearch = page.getByRole('combobox', { name: 'Find a person' }) - await pickerSearch.click() + await page.getByLabel('Find a person').click() + const pickerSearch = page.getByPlaceholder('Search name, title or email…') await pickerSearch.focus() await pickerSearch.press('ControlOrMeta+v') await expect(pickerSearch).toHaveValue(boundedPaste, { timeout: 5_000 }) @@ -648,6 +639,8 @@ 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()) @@ -658,15 +651,14 @@ async function run() { response.ok() ) }), - page - .getByRole('listbox', { name: 'Find a person' }) - .evaluate((list) => list.scrollTo(0, list.scrollHeight)), + more.click(), ]) + 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, 'Reaching the end loads one additional page') + assert.equal(requestedPages.length, 2, 'One click loads exactly one additional page') assert.equal(requestedPages[0], null) assert(requestedPages[1], 'Page two resumes with a cursor') await page.goBack() @@ -674,19 +666,17 @@ async function run() { } finally { await sql`delete from people_directory_sync where organization_id = ${teamOrg} and source_target = 'picker.okta.com'` } - 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.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 page.getByRole('link', { name: 'View in org chart' }).click() await expect(focused('Ben Ortega')).toBeVisible() - await page.getByRole('combobox', { name: 'Browse top-level teams' }).click() - await page.getByRole('option', { name: /^Maya Chen · VP Engineering/ }).click() + await page.getByLabel('Browse top-level teams').click() + await page.getByText(/Maya Chen · VP Engineering/).click() await expect(focused('Maya Chen')).toBeVisible() - await viewTab('Directory').click() - const directorySearch = page.getByRole('searchbox', { name: 'Search people' }) + await page.getByRole('radio', { name: 'Directory' }).click() + const directorySearch = page.getByRole('textbox', { name: 'Search people...', exact: true }) await directorySearch.focus() await directorySearch.press('ControlOrMeta+v') await expect(directorySearch).toHaveValue(boundedPaste) @@ -702,12 +692,12 @@ async function run() { 'People sent an oversized search' ) await directorySearch.fill('Amara') - const profileLink = page.getByRole('link', { name: /^Amara Cole/ }) + const profileLink = page.getByRole('link', { name: 'Amara Cole', exact: true }) await profileLink.focus() await page.keyboard.press('Enter') await page.getByRole('link', { name: 'Open Maya Chen' }).click() const backToResults = page - .getByRole('link', { name: 'Back to directory', exact: true }) + .getByRole('link', { name: 'People', exact: true }) .and(page.locator('a[href*="search=Amara"]')) await backToResults.click() await expect(directorySearch).toHaveValue('Amara') @@ -718,9 +708,9 @@ async function run() { chartOrigin.searchParams.set('search', 'Amara') chartOrigin.searchParams.set('department', 'Engineering') await page.goto(chartOrigin.href) - await profileOf('Maya Chen').click() + await focused('Maya Chen').click() await page - .getByRole('link', { name: 'Back to org chart', exact: true }) + .getByRole('link', { name: 'People', exact: true }) .and(page.locator('a[href*="department=Engineering"]')) .click() await page.waitForURL( @@ -736,8 +726,8 @@ async function run() { await page.waitForURL( (url) => url.searchParams.get('view') === 'directory' && !url.searchParams.get('search') ) - await expect(viewTab('Directory')).toHaveAttribute('aria-current', 'page') - await viewTab('Org chart').click() + await expect(page.getByRole('radio', { name: 'Directory' })).toBeChecked() + await page.getByRole('radio', { name: '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() @@ -751,20 +741,15 @@ 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('Bring your organization into view', { exact: true }).waitFor() + await empty.getByText('No reporting lines yet', { exact: true }).waitFor() await empty.screenshot({ path: join(screenshotDir, 'empty-chart.png') }) - 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.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.screenshot({ path: join(screenshotDir, 'import-sources.png') }) - await empty.getByRole('option', { name: 'Microsoft Entra ID' }).click() - await importDialog.getByLabel('Access token', { exact: true }).waitFor() + await picker.getByRole('button', { name: 'Microsoft Entra ID' }).click() + await empty.getByRole('dialog', { name: 'Import from Microsoft Entra ID' }).waitFor() await empty.close() const mobile = await browser.newPage({ isMobile: true, @@ -796,11 +781,11 @@ async function run() { } await mobile.getByRole('button', { name: 'Explore Amara Cole' }).tap() await mobile.getByRole('button', { name: 'Explore June Evans' }).tap() - const mobileTrail = mobile.getByRole('navigation', { name: 'Reporting path' }) - const heading = mobileTrail.getByRole('heading', { name: 'June Evans' }) + const trail = mobile.getByRole('navigation', { name: 'Reporting path' }) + const heading = trail.getByRole('heading', { name: 'June Evans' }) await heading.scrollIntoViewIfNeeded() await expect(heading).toBeInViewport() - const manager = mobileTrail.getByRole('button', { name: 'Maya Chen' }) + const manager = trail.getByRole('button', { name: 'Maya Chen' }) await manager.scrollIntoViewIfNeeded() await expect(manager).toBeInViewport() await manager.tap() @@ -839,7 +824,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 connection' }).click() + await page.getByRole('button', { name: 'Update credentials' }).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 e099685ba5c..146887ddb52 100644 --- a/apps/sim/scripts/test-scim-e2e.ts +++ b/apps/sim/scripts/test-scim-e2e.ts @@ -421,16 +421,18 @@ async function run() { waitUntil: 'domcontentloaded', timeout: 180_000, }) - 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 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 mkdir(screenshotDir, { recursive: true }) await page.screenshot({ path: join(screenshotDir, 'provisioned-import.png') }) - await modal.getByRole('button', { name: 'Import 1 profile', exact: true }).click() + await modal.getByRole('button', { name: 'Import', exact: true }).click() await expect(modal).toHaveCount(0) } finally { await browser.close() @@ -535,33 +537,30 @@ 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.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.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.unroute(statusUrl) - await expect(page.getByText('Sync failed', { exact: true })).toBeVisible({ + await expect(modal.getByText('Sync failed', { exact: true })).toBeVisible({ timeout: 60_000, }) - await expect(page.getByText(/reporting loop/)).toBeVisible() + await expect(modal.getByText(/reporting loop/)).toBeVisible() await page.screenshot({ path: join(screenshotDir, 'sync-failure.png') }) - 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 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.setViewportSize({ width: 390, height: 844 }) await page.screenshot({ path: join(screenshotDir, 'sync-recovered-mobile.png') }) - assert( - await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth) - ) + const dialogBox = await modal.boundingBox() + assert(dialogBox && dialogBox.x >= 0 && dialogBox.x + dialogBox.width <= 391) 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' }, @@ -570,20 +569,20 @@ async function run() { await expect(page.getByText('Staff Platform Engineer', { exact: true })).toBeAttached({ timeout: 60_000, }) - await openSync.click() - await page.getByRole('button', { name: 'Disconnect SCIM provisioning' }).click() + await modal.getByRole('button', { name: 'Disconnect', exact: true }).click() await page .getByRole('dialog', { name: 'Disconnect directory' }) .getByRole('button', { name: 'Disconnect', exact: true }) .click() - const connectScim = page.getByRole('button', { name: 'Connect SCIM provisioning' }) - await expect(connectScim).toBeVisible() - await connectScim.click() + await expect( + modal.getByRole('button', { name: 'Connect SCIM provisioning' }) + ).toBeVisible() + await modal.getByRole('button', { name: 'Connect SCIM provisioning' }).click() await page .getByRole('dialog', { name: 'Connect SCIM provisioning' }) .getByRole('button', { name: 'Connect and sync' }) .click() - await expect(page.getByText('Connected', { exact: true })).toBeVisible() + await expect(modal.getByText('Connected', { exact: true })).toBeVisible() } finally { await browser.close() } From eb6eb997477eb09b483c048d51cf23974a2646dc Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 6 Nov 2026 10:29:11 -0800 Subject: [PATCH 2/4] Revert "fix(org): drop the removed shortcuts slot from the landing test (#9715)" This reverts commit f17f1b6795dad0875a51b276ee807b9730d1afd0. --- .../components/organization-conversation-landing.test.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/sim/app/o/[organizationId]/components/organization-conversation-landing.test.tsx b/apps/sim/app/o/[organizationId]/components/organization-conversation-landing.test.tsx index 28a38980e0c..22e806c6fe2 100644 --- a/apps/sim/app/o/[organizationId]/components/organization-conversation-landing.test.tsx +++ b/apps/sim/app/o/[organizationId]/components/organization-conversation-landing.test.tsx @@ -76,10 +76,11 @@ import { const landing = { fallback:
Dashboard skeleton
, - render: ({ requestMode, composer }: OrganizationLandingSlot) => ( + render: ({ requestMode, composer, shortcuts }: OrganizationLandingSlot) => (
Dashboard for {requestMode}
{composer} +
{shortcuts}
), } From b5732eccd0889c94e60d20db25b226d1168710de Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 6 Nov 2026 10:36:44 -0800 Subject: [PATCH 3/4] Revert "improvement(org): move the organization view onto the new design system (#9708)" The redesigned org view ships all at once with #9687. The shared modules later staging work builds on stay: member-integrations and the accounts key factory. The legacy integration detail opens the saved-credential modal for Jama and Flow, which #9723 added only to the new view. --- .github/ci-suites.json | 4 - .github/scripts/ci-plan.gql | 2 +- .github/scripts/http-e2e.sh | 69 +- .github/workflows/checks.yml | 8 +- .../organizations/[id]/home/calendar/route.ts | 25 - .../organizations/[id]/home/email/route.ts | 25 - .../[id]/search/history/sources/route.ts | 23 - .../api/organizations/[id]/sim-links/route.ts | 4 +- .../o/[organizationId]/_redesign/hydrated.tsx | 15 - .../_redesign/redesign-host.tsx | 20 - .../o/[organizationId]/chat/[chatId]/page.tsx | 13 +- ...organization-conversation-landing.test.tsx | 134 - .../components/organization-conversation.tsx | 51 +- .../components/organization-page/constants.ts | 5 + .../components/organization-page/index.ts | 3 + .../organization-page/organization-page.tsx | 185 + .../organization-page/search-params.ts | 22 + .../use-organization-page-filters.ts | 21 + .../chats-section/chats-section.tsx | 235 ++ .../components/chats-section/index.ts | 1 + .../organization-sidebar/components/index.ts | 4 + .../components/organization-footer/index.ts | 1 + .../organization-footer.tsx | 28 + .../components/organization-header/index.ts | 1 + .../organization-header.tsx | 278 ++ .../components/workspaces-section/index.ts | 1 + .../workspaces-section/workspace-list.tsx | 249 ++ .../workspaces-section/workspaces-section.tsx | 104 + .../organization-sidebar/hooks/index.ts | 3 + .../hooks/use-organization-chat-actions.ts | 225 ++ .../hooks/use-organization-chats.ts | 30 + .../hooks/use-organization-workspaces.ts | 22 + .../components/organization-sidebar/index.ts | 1 + .../organization-sidebar/navigation.ts} | 36 +- .../organization-sidebar.tsx | 344 ++ .../components/search-landing-history.tsx | 116 + .../components/get-started/get-started.tsx | 139 +- .../get-started/use-get-started-steps.ts | 50 - .../o/[organizationId]/home/home-redirect.ts | 28 +- .../app/o/[organizationId]/home/loading.tsx | 6 - .../app/o/[organizationId]/home/page.test.tsx | 61 +- apps/sim/app/o/[organizationId]/home/page.tsx | 19 +- .../integrations/[provider]/page.tsx | 22 - .../o/[organizationId]/integrations/access.ts | 22 - .../account-connection-actions.tsx | 93 + .../accounts/[credentialId]/page.tsx | 18 +- .../[credentialId]/tool-permissions.tsx | 237 ++ .../integrations/hydrated-integrations.tsx | 28 - .../integrations/integration-detail.tsx | 140 + .../integrations/integrations-host.tsx | 15 - .../integrations/integrations.tsx | 49 + .../integrations/live-member-integrations.tsx | 209 + .../integrations/page.test.tsx | 9 +- .../o/[organizationId]/integrations/page.tsx | 61 +- .../integrations/search-params.ts | 19 +- .../integrations/secrets/page.tsx | 8 +- .../integrations}/slack-search-actions.tsx | 47 +- .../o/[organizationId]/integrations/utils.ts | 291 ++ .../app/o/[organizationId]/layout.test.tsx | 4 +- apps/sim/app/o/[organizationId]/layout.tsx | 4 +- .../app/o/[organizationId]/links/columns.ts | 17 + .../[organizationId]/links/links-surface.tsx | 15 - .../app/o/[organizationId]/links/loading.tsx | 30 +- .../sim/app/o/[organizationId]/links/page.tsx | 24 +- .../o/[organizationId]/links/search-params.ts | 11 +- .../links/sim-link-context-menu.tsx | 76 + .../[organizationId]/links/sim-link-icon.tsx | 14 + .../links/sim-link-shortcut.tsx | 4 +- .../o/[organizationId]/links/sim-links.tsx | 264 ++ apps/sim/app/o/[organizationId]/page.tsx | 3 +- .../people/chart-empty-state.tsx | 2 +- .../search/chat/[chatId]/page.tsx | 4 +- .../app/o/[organizationId]/search/page.tsx | 25 +- .../[organizationId]/search/search-frame.tsx | 65 - .../[organizationId]/search/search-params.ts | 4 +- .../search/search-results-view.tsx | 80 + .../app/o/[organizationId]/search/search.tsx | 320 ++ .../search/use-organization-search.ts | 211 - .../integrations/live-search-settings.tsx | 2 +- .../settings/navigation.test.ts | 6 + .../settings/organization-settings-rail.tsx | 22 - .../components/invite-modal/invite-modal.tsx | 36 +- .../components/resource-empty-state/index.ts | 1 + .../sim-links-empty-state.tsx | 127 + .../workspace-chrome/workspace-chrome.tsx | 2 +- .../knowledge-search-results.tsx | 130 +- .../search-filters.tsx | 54 +- .../search-transitions.test.tsx | 77 +- .../use-live-search-results.ts | 89 - .../use-search-filter-controls.ts | 55 - .../knowledge-search-results/utils.ts | 12 +- .../components/source-card/source-card.tsx | 34 +- .../search-integration-connection.tsx | 30 +- .../special-tags/special-tags.test.tsx | 2 +- .../components/mic-button/mic-button.tsx | 2 +- .../home/hooks/use-chat.dom.test.tsx | 43 +- .../components/help-modal/help-modal.tsx | 93 +- .../sidebar-footer/sidebar-footer.tsx | 31 +- .../hooks/use-usage-window.ts | 8 +- apps/sim/hooks/queries/mothership-chats.ts | 4 +- apps/sim/hooks/queries/oauth-provider.ts | 16 - .../hooks/queries/organization-accounts.ts | 2 +- apps/sim/hooks/queries/organization-home.ts | 62 - .../sim/hooks/queries/organization-secrets.ts | 16 +- apps/sim/hooks/queries/search-history.ts | 49 +- apps/sim/hooks/queries/search-integrations.ts | 21 +- apps/sim/hooks/queries/sim-links.ts | 31 +- .../queries/utils/organization-secret-keys.ts | 18 - .../queries/utils/prefetch-query-on-intent.ts | 25 +- .../queries/utils/search-history-keys.ts | 8 - .../queries/utils/search-integration-keys.ts | 2 - apps/sim/hooks/queries/utils/sim-link-keys.ts | 17 - apps/sim/hooks/use-desktop-update-state.ts | 20 - .../api/contracts/knowledge/search-history.ts | 7 - .../lib/api/contracts/organization-home.ts | 91 - .../sim/lib/api/server/sim-link-presenters.ts | 4 - apps/sim/lib/core/utils/timezone.ts | 14 - .../credential-groups/member-integrations.ts | 13 - .../lib/credential-groups/search-readiness.ts | 52 - .../lib/credential-groups/viewer-accounts.ts | 2 +- apps/sim/lib/help/request.ts | 96 - apps/sim/lib/invitations/error-messages.ts | 36 - .../knowledge/application/search-history.ts | 20 +- .../search/history/history.integration.ts | 27 - .../knowledge/search/history/repository.ts | 3 - .../tools/server/settings-sim-links.ts | 13 +- .../application/home-widgets.integration.ts | 479 --- .../organizations/application/home-widgets.ts | 317 -- apps/sim/lib/organizations/home/limits.ts | 8 - .../organizations/home/provider-reads.test.ts | 270 -- .../lib/organizations/home/provider-reads.ts | 347 -- apps/sim/lib/organizations/surface.ts | 14 +- apps/sim/lib/postcss/platform-embed.cjs | 39 - apps/sim/package.json | 5 - .../_components/command-menu/command-menu.tsx | 46 - .../_components/invite-modal/invite-modal.tsx | 70 - .../platform/app/(shell)/_components/model.ts | 55 - .../notification-tray/notification-tray.tsx | 43 - .../organization-menu.module.css | 31 - .../organization-menu/organization-menu.tsx | 205 - .../_components/search/search-results.tsx | 140 - .../_components/search/search.module.css | 102 - .../(shell)/_components/search/source-row.tsx | 189 - .../_components/sidebar/account-menu.tsx | 78 - .../_components/sidebar/chats-section.tsx | 263 -- .../(shell)/_components/sidebar/help-menu.tsx | 80 - .../_components/sidebar/name-modal.tsx | 50 - .../_components/sidebar/rail-flyout.tsx | 74 - .../(shell)/_components/sidebar/row-links.ts | 36 - .../_components/sidebar/section-row.tsx | 64 - .../_components/sidebar/section-status.tsx | 29 - .../sidebar/sidebar-collections.module.css | 32 - .../_components/sidebar/sidebar.module.css | 169 - .../(shell)/_components/sidebar/sidebar.tsx | 268 -- .../_components/sidebar/support-modal.tsx | 122 - .../sidebar/workspaces-section.tsx | 165 - .../activity-chart/activity-chart.module.css | 90 - .../activity-chart/activity-chart.tsx | 132 - .../activity-list/activity-list.module.css | 10 - .../activity-list/activity-list.tsx | 109 - .../calendar/calendar-day.module.css | 137 - .../_components/calendar/calendar-day.tsx | 242 -- .../connected-widget.module.css | 5 - .../connected-widget/connected-widget.tsx | 193 - .../get-started/get-started.module.css | 6 - .../_components/get-started/get-started.tsx | 27 - .../home-conversation/home-conversation.tsx | 74 - .../home-dashboard-skeleton.tsx | 47 - .../home-dashboard/home-dashboard.module.css | 119 - .../home-dashboard/home-dashboard.tsx | 170 - .../home-section/home-section.module.css | 19 - .../_components/home-section/home-section.tsx | 26 - .../home/_components/live-home/live-home.tsx | 35 - .../app/(shell)/home/_components/model.ts | 124 - .../_components/retry-button/retry-button.tsx | 14 - .../widget-skeleton.module.css | 54 - .../widget-skeleton/widget-skeleton.tsx | 40 - .../app/(shell)/home/search-params.ts | 11 - .../connected-credential.module.css | 34 - .../connected-credential.tsx | 113 - .../connection-access.module.css | 26 - .../connection-access/connection-access.tsx | 64 - .../connection-access/scope-groups.ts | 35 - .../integration-details.tsx | 110 - .../integration-list.module.css | 17 - .../integration-list/integration-list.tsx | 117 - .../integration-mark.module.css | 5 - .../integration-mark/integration-mark.tsx | 18 - .../integrations-page.module.css | 24 - .../integrations-page/integrations-page.tsx | 39 - .../integrations-page/integrations-route.tsx | 220 -- .../member-secrets/member-secrets.tsx | 20 - .../provider-heading.module.css | 20 - .../provider-heading/provider-heading.tsx | 33 - .../query-failure/query-failure.tsx | 16 - .../search-connection-request.tsx | 71 - .../tool-permissions-panel.tsx | 49 - .../tool-permissions.module.css | 56 - .../tool-permissions/tool-permissions.tsx | 135 - .../_components/links-page/link-editor.tsx | 107 - .../_components/links-page/links-frame.tsx | 71 - .../links-page/links-page.module.css | 58 - .../_components/links-page/links-page.tsx | 275 -- .../search-surface/search-history.tsx | 131 - .../search-surface/search-surface-header.tsx | 75 - .../search-surface/search-surface.module.css | 224 -- .../search-surface/search-surface.tsx | 180 - .../design-system/catalog/icon-geometry.tsx | 864 ----- .../design-system/catalog/plots.module.css | 80 - .../platform/design-system/catalog/plots.tsx | 166 - .../components/activity-row/activity-body.tsx | 21 - .../activity-row/activity-row.module.css | 76 - .../components/activity-row/activity-row.tsx | 70 - .../attachments/attachment-grid.tsx | 37 - .../attachments/attachment-tile.tsx | 54 - .../attachments/attachments.module.css | 28 - .../components/attachments/file-thumbnail.tsx | 14 - .../components/attachments/use-file-url.ts | 13 - .../components/avatar/avatar.module.css | 69 - .../components/avatar/avatar.tsx | 117 - .../button/button.shared.module.css | 198 - .../components/button/button.tsx | 151 - .../components/button/control-row.tsx | 71 - .../components/button/copy-button.tsx | 21 - .../components/chart/chart-format.ts | 6 - .../components/chart/chart-tooltip.module.css | 29 - .../components/chart/chart-tooltip.tsx | 24 - .../components/chart/charts.module.css | 189 - .../design-system/components/chart/charts.tsx | 253 -- .../components/chart/use-plot-inspection.ts | 84 - .../components/checkbox/checkbox.module.css | 56 - .../components/checkbox/checkbox.tsx | 62 - .../command-palette.module.css | 55 - .../command-palette/command-palette.tsx | 85 - .../composer-input/composer-input.module.css | 21 - .../composer-input/composer-input.tsx | 77 - .../connection-prompt.module.css | 21 - .../connection-prompt/connection-prompt.tsx | 38 - .../data-display/data-display.module.css | 165 - .../components/data-display/data-display.tsx | 381 -- .../destination-tabs.module.css | 10 - .../destination-tabs/destination-tabs.tsx | 34 - .../disclosure/disclosure.module.css | 71 - .../components/disclosure/disclosure.tsx | 78 - .../empty-state/empty-state.module.css | 33 - .../components/empty-state/empty-state.tsx | 27 - .../execution-steps.module.css | 31 - .../execution-steps/execution-steps.tsx | 28 - .../components/feedback/use-clipboard.ts | 18 - .../feedback/use-feedback-expiry.ts | 15 - .../components/field/field.module.css | 148 - .../design-system/components/field/fields.tsx | 211 - .../components/field/file-field.tsx | 87 - .../design-system/components/field/inputs.tsx | 140 - .../components/field/value-fields.tsx | 102 - .../components/icon/icon.module.css | 19 - .../design-system/components/icon/icon.tsx | 33 - .../components/icon/provider-marks.ts | 33 - .../identity-button/identity-button.tsx | 34 - .../design-system/components/index.ts | 56 - .../components/layout/use-compact-viewport.ts | 22 - .../components/menu/action-menu.tsx | 607 --- .../components/menu/anchored-popover.tsx | 73 - .../components/menu/menu.module.css | 55 - .../components/menu/popup.shared.module.css | 93 - .../components/menu/range-picker.tsx | 55 - .../components/menu/use-popover-container.ts | 14 - .../components/modal/modal-parts.tsx | 85 - .../components/modal/modal.module.css | 117 - .../design-system/components/modal/modal.tsx | 230 -- .../components/modal/overlay-dialog.tsx | 121 - .../overflow-text/counted-label.tsx | 19 - .../overflow-text/overflow-text.module.css | 35 - .../overflow-text/overflow-text.tsx | 58 - .../pagination/pagination.module.css | 12 - .../components/pagination/pagination.tsx | 48 - .../components/picker/calendar.module.css | 100 - .../components/picker/calendar.tsx | 194 - .../components/picker/date-picker.tsx | 337 -- .../components/picker/multi-select.tsx | 102 - .../components/picker/picker.module.css | 55 - .../components/picker/search-popup.tsx | 85 - .../components/picker/searchable-select.tsx | 130 - .../components/picker/select.tsx | 97 - .../components/picker/use-picker-value.ts | 36 - .../property-list/property-list.module.css | 49 - .../property-list/property-list.tsx | 43 - .../recent-items/recent-items.module.css | 22 - .../components/recent-items/recent-items.tsx | 64 - .../scroll-area/load-more-footer.tsx | 38 - .../scroll-area/scroll-area.module.css | 72 - .../components/scroll-area/scroll-area.tsx | 60 - .../components/scroll-area/use-load-more.ts | 42 - .../scroll-area/use-scroll-edges.ts | 95 - .../search-field/menu-search-field.tsx | 20 - .../search-field/search-field.module.css | 55 - .../components/search-field/search-field.tsx | 105 - .../segmented-control.module.css | 66 - .../segmented-control/segmented-control.tsx | 107 - .../settings-section.module.css | 86 - .../settings-section/settings-section.tsx | 63 - .../components/status/status.module.css | 62 - .../components/status/status.tsx | 74 - .../components/surface/platform-surface.tsx | 22 - .../components/switch/switch.module.css | 46 - .../components/switch/switch.tsx | 29 - .../components/tabs/tabs.module.css | 26 - .../design-system/components/tabs/tabs.tsx | 82 - .../components/tooltip/tooltip.module.css | 41 - .../components/tooltip/tooltip.tsx | 129 - .../touch-target/touch-target.module.css | 12 - .../components/touch-target/touch-target.tsx | 10 - .../workspace-fields.module.css | 101 - .../workspace-fields/workspace-fields.tsx | 219 -- .../workspace-list/workspace-list.module.css | 42 - .../workspace-list/workspace-list.tsx | 52 - apps/sim/platform/design-system/fonts/OFL.txt | 93 - .../design-system/fonts/geist-mono.woff2 | Bin 71596 -> 0 bytes .../platform/design-system/fonts/geist.woff2 | Bin 69760 -> 0 bytes apps/sim/platform/design-system/navigation.ts | 36 - .../design-system/styles/chart-roles.css | 28 - .../design-system/styles/choice-roles.css | 16 - .../design-system/styles/collection-roles.css | 9 - .../design-system/styles/control-roles.css | 100 - .../design-system/styles/embedded.css | 73 - .../design-system/styles/materials.css | 159 - .../design-system/styles/panel-roles.css | 6 - .../design-system/styles/recipe-values.css | 68 - .../platform/design-system/styles/rhythm.css | 77 - .../design-system/styles/shell-roles.css | 42 - .../platform/design-system/styles/theme.css | 20 - .../platform/design-system/styles/tokens.css | 710 ---- .../design-system/styles/typography.css | 352 -- apps/sim/platform/lib/data/chat-list.ts | 59 - apps/sim/platform/lib/data/home-model.test.ts | 221 -- apps/sim/platform/lib/data/home-model.ts | 322 -- apps/sim/platform/lib/data/home.ts | 410 -- .../platform/lib/data/integrations-model.ts | 107 - apps/sim/platform/lib/data/integrations.ts | 122 - apps/sim/platform/lib/data/invite.ts | 69 - apps/sim/platform/lib/data/links.ts | 204 - apps/sim/platform/lib/data/nav-warmers.ts | 69 - .../lib/data/prefetch-member-inventory.ts | 73 - apps/sim/platform/lib/data/prefetch.test.ts | 193 - apps/sim/platform/lib/data/prefetch.ts | 113 - apps/sim/platform/lib/data/shell-actions.ts | 88 - apps/sim/platform/lib/data/shell.ts | 109 - apps/sim/platform/lib/data/workspaces.ts | 29 - .../lib/integrations/metadata.generated.ts | 428 --- apps/sim/platform/lib/integrations/model.ts | 95 - apps/sim/platform/lib/plural.ts | 4 - apps/sim/platform/lib/time.ts | 61 - apps/sim/postcss.config.mjs | 3 - apps/sim/public/provider-marks/coda.svg | 1 - apps/sim/public/provider-marks/confluence.svg | 1 - apps/sim/public/provider-marks/fireflies.svg | 1 - apps/sim/public/provider-marks/gitlab.svg | 1 - .../public/provider-marks/google-drive.svg | 1 - .../sim/public/provider-marks/google-meet.svg | 1 - apps/sim/public/provider-marks/granola.svg | 1 - apps/sim/public/provider-marks/hubspot.svg | 1 - apps/sim/public/provider-marks/jira.svg | 1 - apps/sim/public/provider-marks/lucid.svg | 1 - apps/sim/public/provider-marks/zoom.svg | 1 - apps/sim/scripts/org-redesign-e2e/fixture.ts | 156 - .../org-redesign-e2e/scripted-agent.ts | 90 - apps/sim/scripts/org-redesign-seed.ts | 423 --- .../sim/scripts/test-browser-extension-e2e.ts | 11 - apps/sim/scripts/test-org-redesign-e2e.ts | 3383 ----------------- .../scripts/test-organization-search-e2e.ts | 27 +- apps/sim/stores/organization-home/store.ts | 45 - apps/sim/turbo.json | 21 - bun.lock | 29 +- knip.jsonc | 8 - package.json | 2 - scripts/check-platform-css.test.ts | 70 - scripts/check-platform-css.ts | 211 - scripts/check-unused-exports.baseline.json | 9 + scripts/ci-changes.test.ts | 3 - 379 files changed, 4581 insertions(+), 28402 deletions(-) delete mode 100644 apps/sim/app/api/organizations/[id]/home/calendar/route.ts delete mode 100644 apps/sim/app/api/organizations/[id]/home/email/route.ts delete mode 100644 apps/sim/app/api/organizations/[id]/search/history/sources/route.ts delete mode 100644 apps/sim/app/o/[organizationId]/_redesign/hydrated.tsx delete mode 100644 apps/sim/app/o/[organizationId]/_redesign/redesign-host.tsx delete mode 100644 apps/sim/app/o/[organizationId]/components/organization-conversation-landing.test.tsx create mode 100644 apps/sim/app/o/[organizationId]/components/organization-page/constants.ts create mode 100644 apps/sim/app/o/[organizationId]/components/organization-page/index.ts create mode 100644 apps/sim/app/o/[organizationId]/components/organization-page/organization-page.tsx create mode 100644 apps/sim/app/o/[organizationId]/components/organization-page/search-params.ts create mode 100644 apps/sim/app/o/[organizationId]/components/organization-page/use-organization-page-filters.ts create mode 100644 apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/chats-section.tsx create mode 100644 apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/index.ts create mode 100644 apps/sim/app/o/[organizationId]/components/organization-sidebar/components/index.ts create mode 100644 apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-footer/index.ts create mode 100644 apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-footer/organization-footer.tsx create mode 100644 apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-header/index.ts create mode 100644 apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-header/organization-header.tsx create mode 100644 apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-section/index.ts create mode 100644 apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-section/workspace-list.tsx create mode 100644 apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-section/workspaces-section.tsx create mode 100644 apps/sim/app/o/[organizationId]/components/organization-sidebar/hooks/index.ts create mode 100644 apps/sim/app/o/[organizationId]/components/organization-sidebar/hooks/use-organization-chat-actions.ts create mode 100644 apps/sim/app/o/[organizationId]/components/organization-sidebar/hooks/use-organization-chats.ts create mode 100644 apps/sim/app/o/[organizationId]/components/organization-sidebar/hooks/use-organization-workspaces.ts create mode 100644 apps/sim/app/o/[organizationId]/components/organization-sidebar/index.ts rename apps/sim/{platform/app/(shell)/_components/destinations.ts => app/o/[organizationId]/components/organization-sidebar/navigation.ts} (50%) create mode 100644 apps/sim/app/o/[organizationId]/components/organization-sidebar/organization-sidebar.tsx create mode 100644 apps/sim/app/o/[organizationId]/components/search-landing-history.tsx delete mode 100644 apps/sim/app/o/[organizationId]/home/components/get-started/use-get-started-steps.ts delete mode 100644 apps/sim/app/o/[organizationId]/home/loading.tsx delete mode 100644 apps/sim/app/o/[organizationId]/integrations/[provider]/page.tsx delete mode 100644 apps/sim/app/o/[organizationId]/integrations/access.ts create mode 100644 apps/sim/app/o/[organizationId]/integrations/account-connection-actions.tsx create mode 100644 apps/sim/app/o/[organizationId]/integrations/accounts/[credentialId]/tool-permissions.tsx delete mode 100644 apps/sim/app/o/[organizationId]/integrations/hydrated-integrations.tsx create mode 100644 apps/sim/app/o/[organizationId]/integrations/integration-detail.tsx delete mode 100644 apps/sim/app/o/[organizationId]/integrations/integrations-host.tsx create mode 100644 apps/sim/app/o/[organizationId]/integrations/integrations.tsx create mode 100644 apps/sim/app/o/[organizationId]/integrations/live-member-integrations.tsx rename apps/sim/{platform/app/(shell)/integrations/_components/slack-search-actions => app/o/[organizationId]/integrations}/slack-search-actions.tsx (53%) create mode 100644 apps/sim/app/o/[organizationId]/integrations/utils.ts create mode 100644 apps/sim/app/o/[organizationId]/links/columns.ts delete mode 100644 apps/sim/app/o/[organizationId]/links/links-surface.tsx create mode 100644 apps/sim/app/o/[organizationId]/links/sim-link-context-menu.tsx create mode 100644 apps/sim/app/o/[organizationId]/links/sim-link-icon.tsx create mode 100644 apps/sim/app/o/[organizationId]/links/sim-links.tsx delete mode 100644 apps/sim/app/o/[organizationId]/search/search-frame.tsx create mode 100644 apps/sim/app/o/[organizationId]/search/search-results-view.tsx create mode 100644 apps/sim/app/o/[organizationId]/search/search.tsx delete mode 100644 apps/sim/app/o/[organizationId]/search/use-organization-search.ts delete mode 100644 apps/sim/app/o/[organizationId]/settings/organization-settings-rail.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/sim-links-empty-state.tsx delete mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/use-live-search-results.ts delete mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/use-search-filter-controls.ts delete mode 100644 apps/sim/hooks/queries/organization-home.ts delete mode 100644 apps/sim/hooks/queries/utils/organization-secret-keys.ts delete mode 100644 apps/sim/hooks/queries/utils/search-history-keys.ts delete mode 100644 apps/sim/hooks/queries/utils/sim-link-keys.ts delete mode 100644 apps/sim/lib/api/contracts/organization-home.ts delete mode 100644 apps/sim/lib/credential-groups/search-readiness.ts delete mode 100644 apps/sim/lib/help/request.ts delete mode 100644 apps/sim/lib/organizations/application/home-widgets.integration.ts delete mode 100644 apps/sim/lib/organizations/application/home-widgets.ts delete mode 100644 apps/sim/lib/organizations/home/limits.ts delete mode 100644 apps/sim/lib/organizations/home/provider-reads.test.ts delete mode 100644 apps/sim/lib/organizations/home/provider-reads.ts delete mode 100644 apps/sim/lib/postcss/platform-embed.cjs delete mode 100644 apps/sim/platform/app/(shell)/_components/command-menu/command-menu.tsx delete mode 100644 apps/sim/platform/app/(shell)/_components/invite-modal/invite-modal.tsx delete mode 100644 apps/sim/platform/app/(shell)/_components/model.ts delete mode 100644 apps/sim/platform/app/(shell)/_components/notification-tray/notification-tray.tsx delete mode 100644 apps/sim/platform/app/(shell)/_components/organization-menu/organization-menu.module.css delete mode 100644 apps/sim/platform/app/(shell)/_components/organization-menu/organization-menu.tsx delete mode 100644 apps/sim/platform/app/(shell)/_components/search/search-results.tsx delete mode 100644 apps/sim/platform/app/(shell)/_components/search/search.module.css delete mode 100644 apps/sim/platform/app/(shell)/_components/search/source-row.tsx delete mode 100644 apps/sim/platform/app/(shell)/_components/sidebar/account-menu.tsx delete mode 100644 apps/sim/platform/app/(shell)/_components/sidebar/chats-section.tsx delete mode 100644 apps/sim/platform/app/(shell)/_components/sidebar/help-menu.tsx delete mode 100644 apps/sim/platform/app/(shell)/_components/sidebar/name-modal.tsx delete mode 100644 apps/sim/platform/app/(shell)/_components/sidebar/rail-flyout.tsx delete mode 100644 apps/sim/platform/app/(shell)/_components/sidebar/row-links.ts delete mode 100644 apps/sim/platform/app/(shell)/_components/sidebar/section-row.tsx delete mode 100644 apps/sim/platform/app/(shell)/_components/sidebar/section-status.tsx delete mode 100644 apps/sim/platform/app/(shell)/_components/sidebar/sidebar-collections.module.css delete mode 100644 apps/sim/platform/app/(shell)/_components/sidebar/sidebar.module.css delete mode 100644 apps/sim/platform/app/(shell)/_components/sidebar/sidebar.tsx delete mode 100644 apps/sim/platform/app/(shell)/_components/sidebar/support-modal.tsx delete mode 100644 apps/sim/platform/app/(shell)/_components/sidebar/workspaces-section.tsx delete mode 100644 apps/sim/platform/app/(shell)/home/_components/activity-chart/activity-chart.module.css delete mode 100644 apps/sim/platform/app/(shell)/home/_components/activity-chart/activity-chart.tsx delete mode 100644 apps/sim/platform/app/(shell)/home/_components/activity-list/activity-list.module.css delete mode 100644 apps/sim/platform/app/(shell)/home/_components/activity-list/activity-list.tsx delete mode 100644 apps/sim/platform/app/(shell)/home/_components/calendar/calendar-day.module.css delete mode 100644 apps/sim/platform/app/(shell)/home/_components/calendar/calendar-day.tsx delete mode 100644 apps/sim/platform/app/(shell)/home/_components/connected-widget/connected-widget.module.css delete mode 100644 apps/sim/platform/app/(shell)/home/_components/connected-widget/connected-widget.tsx delete mode 100644 apps/sim/platform/app/(shell)/home/_components/get-started/get-started.module.css delete mode 100644 apps/sim/platform/app/(shell)/home/_components/get-started/get-started.tsx delete mode 100644 apps/sim/platform/app/(shell)/home/_components/home-conversation/home-conversation.tsx delete mode 100644 apps/sim/platform/app/(shell)/home/_components/home-dashboard/home-dashboard-skeleton.tsx delete mode 100644 apps/sim/platform/app/(shell)/home/_components/home-dashboard/home-dashboard.module.css delete mode 100644 apps/sim/platform/app/(shell)/home/_components/home-dashboard/home-dashboard.tsx delete mode 100644 apps/sim/platform/app/(shell)/home/_components/home-section/home-section.module.css delete mode 100644 apps/sim/platform/app/(shell)/home/_components/home-section/home-section.tsx delete mode 100644 apps/sim/platform/app/(shell)/home/_components/live-home/live-home.tsx delete mode 100644 apps/sim/platform/app/(shell)/home/_components/model.ts delete mode 100644 apps/sim/platform/app/(shell)/home/_components/retry-button/retry-button.tsx delete mode 100644 apps/sim/platform/app/(shell)/home/_components/widget-skeleton/widget-skeleton.module.css delete mode 100644 apps/sim/platform/app/(shell)/home/_components/widget-skeleton/widget-skeleton.tsx delete mode 100644 apps/sim/platform/app/(shell)/home/search-params.ts delete mode 100644 apps/sim/platform/app/(shell)/integrations/_components/connected-credential/connected-credential.module.css delete mode 100644 apps/sim/platform/app/(shell)/integrations/_components/connected-credential/connected-credential.tsx delete mode 100644 apps/sim/platform/app/(shell)/integrations/_components/connection-access/connection-access.module.css delete mode 100644 apps/sim/platform/app/(shell)/integrations/_components/connection-access/connection-access.tsx delete mode 100644 apps/sim/platform/app/(shell)/integrations/_components/connection-access/scope-groups.ts delete mode 100644 apps/sim/platform/app/(shell)/integrations/_components/integration-details/integration-details.tsx delete mode 100644 apps/sim/platform/app/(shell)/integrations/_components/integration-list/integration-list.module.css delete mode 100644 apps/sim/platform/app/(shell)/integrations/_components/integration-list/integration-list.tsx delete mode 100644 apps/sim/platform/app/(shell)/integrations/_components/integration-mark/integration-mark.module.css delete mode 100644 apps/sim/platform/app/(shell)/integrations/_components/integration-mark/integration-mark.tsx delete mode 100644 apps/sim/platform/app/(shell)/integrations/_components/integrations-page/integrations-page.module.css delete mode 100644 apps/sim/platform/app/(shell)/integrations/_components/integrations-page/integrations-page.tsx delete mode 100644 apps/sim/platform/app/(shell)/integrations/_components/integrations-page/integrations-route.tsx delete mode 100644 apps/sim/platform/app/(shell)/integrations/_components/member-secrets/member-secrets.tsx delete mode 100644 apps/sim/platform/app/(shell)/integrations/_components/provider-heading/provider-heading.module.css delete mode 100644 apps/sim/platform/app/(shell)/integrations/_components/provider-heading/provider-heading.tsx delete mode 100644 apps/sim/platform/app/(shell)/integrations/_components/query-failure/query-failure.tsx delete mode 100644 apps/sim/platform/app/(shell)/integrations/_components/search-connection-request/search-connection-request.tsx delete mode 100644 apps/sim/platform/app/(shell)/integrations/_components/tool-permissions/tool-permissions-panel.tsx delete mode 100644 apps/sim/platform/app/(shell)/integrations/_components/tool-permissions/tool-permissions.module.css delete mode 100644 apps/sim/platform/app/(shell)/integrations/_components/tool-permissions/tool-permissions.tsx delete mode 100644 apps/sim/platform/app/(shell)/links/_components/links-page/link-editor.tsx delete mode 100644 apps/sim/platform/app/(shell)/links/_components/links-page/links-frame.tsx delete mode 100644 apps/sim/platform/app/(shell)/links/_components/links-page/links-page.module.css delete mode 100644 apps/sim/platform/app/(shell)/links/_components/links-page/links-page.tsx delete mode 100644 apps/sim/platform/app/(shell)/search/_components/search-surface/search-history.tsx delete mode 100644 apps/sim/platform/app/(shell)/search/_components/search-surface/search-surface-header.tsx delete mode 100644 apps/sim/platform/app/(shell)/search/_components/search-surface/search-surface.module.css delete mode 100644 apps/sim/platform/app/(shell)/search/_components/search-surface/search-surface.tsx delete mode 100644 apps/sim/platform/design-system/catalog/icon-geometry.tsx delete mode 100644 apps/sim/platform/design-system/catalog/plots.module.css delete mode 100644 apps/sim/platform/design-system/catalog/plots.tsx delete mode 100644 apps/sim/platform/design-system/components/activity-row/activity-body.tsx delete mode 100644 apps/sim/platform/design-system/components/activity-row/activity-row.module.css delete mode 100644 apps/sim/platform/design-system/components/activity-row/activity-row.tsx delete mode 100644 apps/sim/platform/design-system/components/attachments/attachment-grid.tsx delete mode 100644 apps/sim/platform/design-system/components/attachments/attachment-tile.tsx delete mode 100644 apps/sim/platform/design-system/components/attachments/attachments.module.css delete mode 100644 apps/sim/platform/design-system/components/attachments/file-thumbnail.tsx delete mode 100644 apps/sim/platform/design-system/components/attachments/use-file-url.ts delete mode 100644 apps/sim/platform/design-system/components/avatar/avatar.module.css delete mode 100644 apps/sim/platform/design-system/components/avatar/avatar.tsx delete mode 100644 apps/sim/platform/design-system/components/button/button.shared.module.css delete mode 100644 apps/sim/platform/design-system/components/button/button.tsx delete mode 100644 apps/sim/platform/design-system/components/button/control-row.tsx delete mode 100644 apps/sim/platform/design-system/components/button/copy-button.tsx delete mode 100644 apps/sim/platform/design-system/components/chart/chart-format.ts delete mode 100644 apps/sim/platform/design-system/components/chart/chart-tooltip.module.css delete mode 100644 apps/sim/platform/design-system/components/chart/chart-tooltip.tsx delete mode 100644 apps/sim/platform/design-system/components/chart/charts.module.css delete mode 100644 apps/sim/platform/design-system/components/chart/charts.tsx delete mode 100644 apps/sim/platform/design-system/components/chart/use-plot-inspection.ts delete mode 100644 apps/sim/platform/design-system/components/checkbox/checkbox.module.css delete mode 100644 apps/sim/platform/design-system/components/checkbox/checkbox.tsx delete mode 100644 apps/sim/platform/design-system/components/command-palette/command-palette.module.css delete mode 100644 apps/sim/platform/design-system/components/command-palette/command-palette.tsx delete mode 100644 apps/sim/platform/design-system/components/composer-input/composer-input.module.css delete mode 100644 apps/sim/platform/design-system/components/composer-input/composer-input.tsx delete mode 100644 apps/sim/platform/design-system/components/connection-prompt/connection-prompt.module.css delete mode 100644 apps/sim/platform/design-system/components/connection-prompt/connection-prompt.tsx delete mode 100644 apps/sim/platform/design-system/components/data-display/data-display.module.css delete mode 100644 apps/sim/platform/design-system/components/data-display/data-display.tsx delete mode 100644 apps/sim/platform/design-system/components/destination-tabs/destination-tabs.module.css delete mode 100644 apps/sim/platform/design-system/components/destination-tabs/destination-tabs.tsx delete mode 100644 apps/sim/platform/design-system/components/disclosure/disclosure.module.css delete mode 100644 apps/sim/platform/design-system/components/disclosure/disclosure.tsx delete mode 100644 apps/sim/platform/design-system/components/empty-state/empty-state.module.css delete mode 100644 apps/sim/platform/design-system/components/empty-state/empty-state.tsx delete mode 100644 apps/sim/platform/design-system/components/execution-steps/execution-steps.module.css delete mode 100644 apps/sim/platform/design-system/components/execution-steps/execution-steps.tsx delete mode 100644 apps/sim/platform/design-system/components/feedback/use-clipboard.ts delete mode 100644 apps/sim/platform/design-system/components/feedback/use-feedback-expiry.ts delete mode 100644 apps/sim/platform/design-system/components/field/field.module.css delete mode 100644 apps/sim/platform/design-system/components/field/fields.tsx delete mode 100644 apps/sim/platform/design-system/components/field/file-field.tsx delete mode 100644 apps/sim/platform/design-system/components/field/inputs.tsx delete mode 100644 apps/sim/platform/design-system/components/field/value-fields.tsx delete mode 100644 apps/sim/platform/design-system/components/icon/icon.module.css delete mode 100644 apps/sim/platform/design-system/components/icon/icon.tsx delete mode 100644 apps/sim/platform/design-system/components/icon/provider-marks.ts delete mode 100644 apps/sim/platform/design-system/components/identity-button/identity-button.tsx delete mode 100644 apps/sim/platform/design-system/components/index.ts delete mode 100644 apps/sim/platform/design-system/components/layout/use-compact-viewport.ts delete mode 100644 apps/sim/platform/design-system/components/menu/action-menu.tsx delete mode 100644 apps/sim/platform/design-system/components/menu/anchored-popover.tsx delete mode 100644 apps/sim/platform/design-system/components/menu/menu.module.css delete mode 100644 apps/sim/platform/design-system/components/menu/popup.shared.module.css delete mode 100644 apps/sim/platform/design-system/components/menu/range-picker.tsx delete mode 100644 apps/sim/platform/design-system/components/menu/use-popover-container.ts delete mode 100644 apps/sim/platform/design-system/components/modal/modal-parts.tsx delete mode 100644 apps/sim/platform/design-system/components/modal/modal.module.css delete mode 100644 apps/sim/platform/design-system/components/modal/modal.tsx delete mode 100644 apps/sim/platform/design-system/components/modal/overlay-dialog.tsx delete mode 100644 apps/sim/platform/design-system/components/overflow-text/counted-label.tsx delete mode 100644 apps/sim/platform/design-system/components/overflow-text/overflow-text.module.css delete mode 100644 apps/sim/platform/design-system/components/overflow-text/overflow-text.tsx delete mode 100644 apps/sim/platform/design-system/components/pagination/pagination.module.css delete mode 100644 apps/sim/platform/design-system/components/pagination/pagination.tsx delete mode 100644 apps/sim/platform/design-system/components/picker/calendar.module.css delete mode 100644 apps/sim/platform/design-system/components/picker/calendar.tsx delete mode 100644 apps/sim/platform/design-system/components/picker/date-picker.tsx delete mode 100644 apps/sim/platform/design-system/components/picker/multi-select.tsx delete mode 100644 apps/sim/platform/design-system/components/picker/picker.module.css delete mode 100644 apps/sim/platform/design-system/components/picker/search-popup.tsx delete mode 100644 apps/sim/platform/design-system/components/picker/searchable-select.tsx delete mode 100644 apps/sim/platform/design-system/components/picker/select.tsx delete mode 100644 apps/sim/platform/design-system/components/picker/use-picker-value.ts delete mode 100644 apps/sim/platform/design-system/components/property-list/property-list.module.css delete mode 100644 apps/sim/platform/design-system/components/property-list/property-list.tsx delete mode 100644 apps/sim/platform/design-system/components/recent-items/recent-items.module.css delete mode 100644 apps/sim/platform/design-system/components/recent-items/recent-items.tsx delete mode 100644 apps/sim/platform/design-system/components/scroll-area/load-more-footer.tsx delete mode 100644 apps/sim/platform/design-system/components/scroll-area/scroll-area.module.css delete mode 100644 apps/sim/platform/design-system/components/scroll-area/scroll-area.tsx delete mode 100644 apps/sim/platform/design-system/components/scroll-area/use-load-more.ts delete mode 100644 apps/sim/platform/design-system/components/scroll-area/use-scroll-edges.ts delete mode 100644 apps/sim/platform/design-system/components/search-field/menu-search-field.tsx delete mode 100644 apps/sim/platform/design-system/components/search-field/search-field.module.css delete mode 100644 apps/sim/platform/design-system/components/search-field/search-field.tsx delete mode 100644 apps/sim/platform/design-system/components/segmented-control/segmented-control.module.css delete mode 100644 apps/sim/platform/design-system/components/segmented-control/segmented-control.tsx delete mode 100644 apps/sim/platform/design-system/components/settings-section/settings-section.module.css delete mode 100644 apps/sim/platform/design-system/components/settings-section/settings-section.tsx delete mode 100644 apps/sim/platform/design-system/components/status/status.module.css delete mode 100644 apps/sim/platform/design-system/components/status/status.tsx delete mode 100644 apps/sim/platform/design-system/components/surface/platform-surface.tsx delete mode 100644 apps/sim/platform/design-system/components/switch/switch.module.css delete mode 100644 apps/sim/platform/design-system/components/switch/switch.tsx delete mode 100644 apps/sim/platform/design-system/components/tabs/tabs.module.css delete mode 100644 apps/sim/platform/design-system/components/tabs/tabs.tsx delete mode 100644 apps/sim/platform/design-system/components/tooltip/tooltip.module.css delete mode 100644 apps/sim/platform/design-system/components/tooltip/tooltip.tsx delete mode 100644 apps/sim/platform/design-system/components/touch-target/touch-target.module.css delete mode 100644 apps/sim/platform/design-system/components/touch-target/touch-target.tsx delete mode 100644 apps/sim/platform/design-system/components/workspace-fields/workspace-fields.module.css delete mode 100644 apps/sim/platform/design-system/components/workspace-fields/workspace-fields.tsx delete mode 100644 apps/sim/platform/design-system/components/workspace-list/workspace-list.module.css delete mode 100644 apps/sim/platform/design-system/components/workspace-list/workspace-list.tsx delete mode 100644 apps/sim/platform/design-system/fonts/OFL.txt delete mode 100644 apps/sim/platform/design-system/fonts/geist-mono.woff2 delete mode 100644 apps/sim/platform/design-system/fonts/geist.woff2 delete mode 100644 apps/sim/platform/design-system/navigation.ts delete mode 100644 apps/sim/platform/design-system/styles/chart-roles.css delete mode 100644 apps/sim/platform/design-system/styles/choice-roles.css delete mode 100644 apps/sim/platform/design-system/styles/collection-roles.css delete mode 100644 apps/sim/platform/design-system/styles/control-roles.css delete mode 100644 apps/sim/platform/design-system/styles/embedded.css delete mode 100644 apps/sim/platform/design-system/styles/materials.css delete mode 100644 apps/sim/platform/design-system/styles/panel-roles.css delete mode 100644 apps/sim/platform/design-system/styles/recipe-values.css delete mode 100644 apps/sim/platform/design-system/styles/rhythm.css delete mode 100644 apps/sim/platform/design-system/styles/shell-roles.css delete mode 100644 apps/sim/platform/design-system/styles/theme.css delete mode 100644 apps/sim/platform/design-system/styles/tokens.css delete mode 100644 apps/sim/platform/design-system/styles/typography.css delete mode 100644 apps/sim/platform/lib/data/chat-list.ts delete mode 100644 apps/sim/platform/lib/data/home-model.test.ts delete mode 100644 apps/sim/platform/lib/data/home-model.ts delete mode 100644 apps/sim/platform/lib/data/home.ts delete mode 100644 apps/sim/platform/lib/data/integrations-model.ts delete mode 100644 apps/sim/platform/lib/data/integrations.ts delete mode 100644 apps/sim/platform/lib/data/invite.ts delete mode 100644 apps/sim/platform/lib/data/links.ts delete mode 100644 apps/sim/platform/lib/data/nav-warmers.ts delete mode 100644 apps/sim/platform/lib/data/prefetch-member-inventory.ts delete mode 100644 apps/sim/platform/lib/data/prefetch.test.ts delete mode 100644 apps/sim/platform/lib/data/prefetch.ts delete mode 100644 apps/sim/platform/lib/data/shell-actions.ts delete mode 100644 apps/sim/platform/lib/data/shell.ts delete mode 100644 apps/sim/platform/lib/data/workspaces.ts delete mode 100644 apps/sim/platform/lib/integrations/metadata.generated.ts delete mode 100644 apps/sim/platform/lib/integrations/model.ts delete mode 100644 apps/sim/platform/lib/plural.ts delete mode 100644 apps/sim/platform/lib/time.ts delete mode 100644 apps/sim/public/provider-marks/coda.svg delete mode 100644 apps/sim/public/provider-marks/confluence.svg delete mode 100644 apps/sim/public/provider-marks/fireflies.svg delete mode 100644 apps/sim/public/provider-marks/gitlab.svg delete mode 100644 apps/sim/public/provider-marks/google-drive.svg delete mode 100644 apps/sim/public/provider-marks/google-meet.svg delete mode 100644 apps/sim/public/provider-marks/granola.svg delete mode 100644 apps/sim/public/provider-marks/hubspot.svg delete mode 100644 apps/sim/public/provider-marks/jira.svg delete mode 100644 apps/sim/public/provider-marks/lucid.svg delete mode 100644 apps/sim/public/provider-marks/zoom.svg delete mode 100644 apps/sim/scripts/org-redesign-e2e/fixture.ts delete mode 100644 apps/sim/scripts/org-redesign-e2e/scripted-agent.ts delete mode 100644 apps/sim/scripts/org-redesign-seed.ts delete mode 100644 apps/sim/scripts/test-org-redesign-e2e.ts delete mode 100644 apps/sim/stores/organization-home/store.ts delete mode 100644 scripts/check-platform-css.test.ts delete mode 100644 scripts/check-platform-css.ts diff --git a/.github/ci-suites.json b/.github/ci-suites.json index fbd721966a9..48b3bf98c08 100644 --- a/.github/ci-suites.json +++ b/.github/ci-suites.json @@ -24,10 +24,6 @@ "group": "organization-search", "task": "ci:e2e-organization-search" }, - { - "group": "org-redesign", - "task": "ci:e2e-org-redesign" - }, { "group": "browser-extension-navigation", "task": "ci:e2e-browser-extension" diff --git a/.github/scripts/ci-plan.gql b/.github/scripts/ci-plan.gql index 5f44d548823..78dcbf3eaab 100644 --- a/.github/scripts/ci-plan.gql +++ b/.github/scripts/ci-plan.gql @@ -3,7 +3,7 @@ query($base: String!, $head: String!) { affectedTasks(base: $base, head: $head, tasks: [ "test", "build", "type-check", "ci:integration", "ci:desktop-live", "ci:e2e-scim", "ci:e2e-cli", "ci:e2e-stop-after", "ci:e2e-desktop-inbox", - "ci:e2e-assistant-actions", "ci:e2e-organization-search", "ci:e2e-org-redesign", + "ci:e2e-assistant-actions", "ci:e2e-organization-search", "ci:e2e-docs-platform", "ci:e2e-browser-extension", "ci:e2e-project-files", "ci:e2e-mobile", "ci:e2e-seo" ]) { diff --git a/.github/scripts/http-e2e.sh b/.github/scripts/http-e2e.sh index f3e43d7cadb..8c5f185e7cd 100755 --- a/.github/scripts/http-e2e.sh +++ b/.github/scripts/http-e2e.sh @@ -3,7 +3,7 @@ # CI groups, which reuse the production build from `e2e-build`. # The job extracts it into apps/sim/.next before running these suites. # -# Usage: http-e2e.sh (run from apps/sim) +# Usage: http-e2e.sh (run from apps/sim) # # The job provides DATABASE_URL, BETTER_AUTH_SECRET and ENCRYPTION_KEY; each group sets the rest of # its app's environment here. Reports and server logs land in $RUNNER_TEMP/e2e. @@ -20,7 +20,7 @@ # telemetry flush runs detached and still writes .next/dev). set -euo pipefail -group=${1:?usage: http-e2e.sh } +group=${1:?usage: http-e2e.sh } report_dir="$RUNNER_TEMP/e2e" ready_timeout_seconds=600 mkdir -p "$report_dir" @@ -32,7 +32,6 @@ status_log='' realtime_pid='' realtime_tag='' realtime_log='' -agent_pid='' browsers_pid='' local_app_url='' # `dev` or `start`; a group sets it before start_app. @@ -64,10 +63,6 @@ finish() { tail -n 100 "$realtime_log" fi fi - if [ -n "$agent_pid" ]; then - kill "$agent_pid" 2>/dev/null || true - wait "$agent_pid" 2>/dev/null || true - fi if [ -n "$browsers_pid" ]; then kill -- "-$browsers_pid" 2>/dev/null || true wait "$browsers_pid" 2>/dev/null || true @@ -138,13 +133,6 @@ start_realtime() { wait_ready "$realtime_pid" "http://127.0.0.1:$port/health" "$name realtime server" } -# stop_app: stops the app start_app started, so a group can start it again under other flags. -stop_app() { - bash "$GITHUB_WORKSPACE/.github/scripts/stop-session.sh" "$server_pid" "$app_tag" - wait "$server_pid" 2>/dev/null || true - server_pid='' -} - # wait_ready