Skip to content

Commit 788aef7

Browse files
committed
fix(people): keep provisioning responsive during directory sync
1 parent 878abcc commit 788aef7

39 files changed

Lines changed: 33146 additions & 192 deletions

File tree

‎apps/docs/content/docs/search/people.mdx.draft‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ The directory controls the name, title, department, and reporting line of the pe
4646

4747
People keeps the last published chart visible until the full replacement passes validation. If a provider asks Sim to slow down, the status shows when the next attempt is allowed. Sim keeps its place and resumes from the last completed page. A refresh still unfinished after seven days restarts from the source on its next eligible run. An empty directory removes its entries only after a successful refresh.
4848

49-
SCIM user changes queue a refresh for the next scheduler pass. Sim reads the organization’s locally provisioned profiles together and publishes their reporting lines in one update.
49+
SCIM user changes queue a refresh for the next scheduler pass, including changes that arrive while a refresh is running. Sim reads locally provisioned profiles together and publishes their reporting lines in one update. When SCIM deactivates or removes someone, their directory-managed profile is hidden immediately.
5050

5151
After a failure, check the status for the cause. Correct invalid or duplicate email addresses and reporting loops in the source. Check credentials and directory permissions for an access failure, then select **Retry sync** when available.
5252

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
import { listDepartmentsContract } from '@/lib/api/contracts/people'
22
import { cursorRoute, cursorScopeKey } from '@/lib/api/cursor-binding'
3+
import { departmentFilterKey } from '@/lib/api/server/people-presenters'
34
import {
45
defineInternalJsonRoute,
56
internalOrchestrationErrorPolicy,
67
internalRateLimits,
78
internalSessionAuth,
89
} from '@/lib/api/server/routes'
910
import { listDepartments } from '@/lib/people/application/use-cases'
10-
import { readSortedCursor, writeSortedCursor } from '@/app/api/v2/lib/response'
11+
import { cursorSortKey, decodeOffsetCursor, encodeOffsetCursor } from '@/app/api/v2/lib/response'
12+
13+
const SORT = cursorSortKey('department', 'asc')
1114

1215
function scope(id: string, search?: string) {
1316
return cursorScopeKey(cursorRoute(listDepartmentsContract, { id }), { search })
@@ -24,15 +27,16 @@ export const GET = defineInternalJsonRoute({
2427
mapInput: ({ params, query }) => ({
2528
organizationId: params.id,
2629
search: query.search,
27-
cursorKeys: readSortedCursor(query.cursor, 'department', 'asc', scope(params.id, query.search)),
30+
offset: decodeOffsetCursor(query.cursor, SORT, scope(params.id, query.search)),
2831
}),
29-
present: ({ data, nextCursorKeys }, { input }) => ({
30-
departments: data,
31-
nextCursor: writeSortedCursor(
32-
nextCursorKeys,
33-
'department',
34-
'asc',
35-
scope(input.organizationId, input.search)
36-
),
32+
present: ({ data, nextOffset }, { input }) => ({
33+
departments: data.map((department) => ({
34+
...department,
35+
departmentKey: departmentFilterKey(department.name),
36+
})),
37+
nextCursor:
38+
nextOffset === null
39+
? null
40+
: encodeOffsetCursor(SORT, scope(input.organizationId, input.search), nextOffset),
3741
}),
3842
})

‎apps/sim/app/api/organizations/[id]/people/route.ts‎

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,15 @@ function cursorScope(
1515
id: string,
1616
search: string | undefined,
1717
department: string | undefined,
18+
departmentKey: string | undefined,
1819
scope?: 'roots' | 'members'
1920
) {
20-
return cursorScopeKey(cursorRoute(listPeopleContract, { id }), { search, department, scope })
21+
return cursorScopeKey(cursorRoute(listPeopleContract, { id }), {
22+
search,
23+
department,
24+
departmentKey,
25+
scope,
26+
})
2127
}
2228

2329
export const GET = defineInternalJsonRoute({
@@ -32,21 +38,29 @@ export const GET = defineInternalJsonRoute({
3238
organizationId: params.id,
3339
search: query.search,
3440
department: query.department,
41+
departmentKey: query.departmentKey,
3542
scope: query.scope,
3643
cursorKeys: readSortedCursor(
3744
query.cursor,
3845
'name',
3946
'asc',
40-
cursorScope(params.id, query.search, query.department, query.scope)
47+
cursorScope(params.id, query.search, query.department, query.departmentKey, query.scope)
4148
),
4249
}),
43-
present: ({ data, nextCursorKeys }, { input }) => ({
50+
present: ({ data, nextCursorKeys, selectedDepartment }, { input }) => ({
4451
people: data.map(presentPerson),
52+
selectedDepartment,
4553
nextCursor: writeSortedCursor(
4654
nextCursorKeys,
4755
'name',
4856
'asc',
49-
cursorScope(input.organizationId, input.search, input.department, input.scope)
57+
cursorScope(
58+
input.organizationId,
59+
input.search,
60+
input.department,
61+
input.departmentKey,
62+
input.scope
63+
)
5064
),
5165
}),
5266
})

‎apps/sim/app/o/[organizationId]/people/[memberId]/person.tsx‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -167,13 +167,14 @@ export function Person({ organizationId, memberId }: PersonProps) {
167167
<SettingsField label='Department'>
168168
{data.person.department ? (
169169
<Link
170-
className='text-[var(--text-body)] text-sm hover:underline'
170+
className='break-all text-[var(--text-body)] text-sm hover:underline'
171171
href={serializePeopleView(routes.people, {
172172
...origin,
173173
view: 'directory',
174174
search: '',
175175
panel: null,
176-
department: data.person.department,
176+
department: data.person.departmentKey ? null : data.person.department,
177+
departmentKey: data.person.departmentKey ?? null,
177178
})}
178179
>
179180
{data.person.department}

‎apps/sim/app/o/[organizationId]/people/chart-canvas.tsx‎

Lines changed: 11 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
'use client'
22

33
import { type MouseEvent, useMemo } from 'react'
4-
import { Avatar, cn, Tooltip } from '@sim/emcn'
4+
import { Avatar, cn, OverflowText, Tooltip } from '@sim/emcn'
55
import { ArrowUpRight, ChevronRight, Minus, Plus } from '@sim/emcn/icons'
66
import {
77
BLOCK_DIMENSIONS,
@@ -68,24 +68,19 @@ function PersonCard({ data }: NodeProps<CardNode>) {
6868
<span className='relative flex flex-1 items-center gap-2.5 px-3'>
6969
<Avatar size='md' name={person.name} src={person.image} aria-hidden />
7070
<span className={cn('flex min-w-0 flex-col gap-0.5', !focused && 'pr-5')}>
71-
<span
72-
className='truncate font-medium text-[var(--text-primary)] text-base'
73-
title={person.name}
74-
>
75-
{person.name}
76-
</span>
77-
<span
78-
className='truncate text-[var(--text-secondary)] text-small'
79-
title={person.title || person.email}
80-
>
81-
{person.title || person.email}
82-
</span>
71+
<OverflowText
72+
label={person.name}
73+
focusTarget='nearest-interactive'
74+
className='font-medium text-[var(--text-primary)] text-base'
75+
/>
76+
<OverflowText
77+
label={person.title || person.email}
78+
className='text-[var(--text-secondary)] text-small'
79+
/>
8380
</span>
8481
</span>
8582
<span className='relative mx-3 flex h-8 min-w-0 shrink-0 items-center justify-between gap-2 border-[var(--border)] border-t text-[var(--text-secondary)] text-caption'>
86-
<span className='min-w-0 truncate' title={focused ? (department ?? undefined) : undefined}>
87-
{focused ? department || relation : relation}
88-
</span>
83+
<OverflowText label={focused ? department || relation : relation} />
8984
<span className='flex shrink-0 items-center gap-1 text-[var(--text-body)]'>
9085
{focused ? (
9186
<>

‎apps/sim/app/o/[organizationId]/people/loading-status.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ export function LoadingStatus({ children }: LoadingStatusProps) {
1010
role='status'
1111
className='m-auto flex items-center gap-2 text-[var(--text-muted)] text-small'
1212
>
13-
<Loader />
13+
<Loader animate />
1414
{children}
1515
</div>
1616
)

‎apps/sim/app/o/[organizationId]/people/people-chart.tsx‎

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,15 @@ import {
88
useLayoutEffect,
99
useRef,
1010
} from 'react'
11-
import { Button, Chip, ChipCombobox } from '@sim/emcn'
11+
import {
12+
Button,
13+
Chip,
14+
ChipCombobox,
15+
cn,
16+
scrollFadeAttributes,
17+
scrollFadeXClass,
18+
useScrollEdges,
19+
} from '@sim/emcn'
1220
import { ChevronLeft, ChevronRight, Search } from '@sim/emcn/icons'
1321
import { useQueryStates } from 'nuqs'
1422
import { EmptyState } from '@/components/empty-state/empty-state'
@@ -54,6 +62,7 @@ function FindPerson({ organizationId, onSelect, rootsLabel }: FindPersonProps) {
5462
</span>
5563
)
5664
}
65+
overlayLabel={rootsLabel ?? 'Find a person'}
5766
placeholder={rootsLabel ?? 'Find a person'}
5867
searchPlaceholder={rootsLabel ? 'Search team leads…' : 'Search name, title or email…'}
5968
className={rootsLabel ? 'max-w-48' : 'w-44 max-w-full'}
@@ -67,6 +76,26 @@ function FindPerson({ organizationId, onSelect, rootsLabel }: FindPersonProps) {
6776
)
6877
}
6978

79+
interface ReportingPathProps {
80+
children: ReactNode
81+
}
82+
83+
function ReportingPath({ children }: ReportingPathProps) {
84+
const scrollRef = useRef<HTMLDivElement>(null)
85+
const edges = useScrollEdges(scrollRef, { axis: 'x' })
86+
return (
87+
<nav aria-label='Reporting path' className='shrink-0 border-[var(--border)] border-y'>
88+
<div
89+
ref={scrollRef}
90+
className={cn('overflow-x-auto px-4 py-1.5 text-small', scrollFadeXClass)}
91+
{...scrollFadeAttributes(edges)}
92+
>
93+
<div className='flex w-max min-w-full items-center gap-1'>{children}</div>
94+
</div>
95+
</nav>
96+
)
97+
}
98+
7099
export function PeopleChart({ organizationId, viewSwitch, onImport, onSync }: PeopleChartProps) {
71100
const { organization } = useOrganizationContext()
72101
const [origin, setView] = useQueryStates(peopleParsers, peopleUrlKeys)
@@ -155,10 +184,7 @@ export function PeopleChart({ organizationId, viewSwitch, onImport, onSync }: Pe
155184
</div>
156185
) : branch ? (
157186
<>
158-
<nav
159-
aria-label='Reporting path'
160-
className='flex shrink-0 items-center gap-1 overflow-x-auto border-[var(--border)] border-y px-4 py-1.5 text-small'
161-
>
187+
<ReportingPath>
162188
<div className='shrink-0'>
163189
<FindPerson
164190
organizationId={organizationId}
@@ -186,7 +212,7 @@ export function PeopleChart({ organizationId, viewSwitch, onImport, onSync }: Pe
186212
>
187213
{branch.person.name}
188214
</h2>
189-
</nav>
215+
</ReportingPath>
190216
<ChartCanvas
191217
key={`${branch.person.memberId}/${branch.page}/${size}`}
192218
organizationId={organizationId}

0 commit comments

Comments
 (0)