Skip to content

Commit fd71904

Browse files
committed
fix(people): settle header actions, narrow refusals, refresh people on disconnect
1 parent d2ff3b7 commit fd71904

5 files changed

Lines changed: 63 additions & 39 deletions

File tree

‎apps/sim/hooks/queries/people-sync.ts‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,10 +91,15 @@ export function usePeopleSyncActions(organizationId: string) {
9191
if (!body) throw new Error('Choose a directory connection')
9292
return requestJson(configurePeopleSyncContract, { params, body })
9393
},
94-
onSuccess: async (result) => {
94+
onSuccess: async (result, action) => {
9595
const queryKey = syncKeys.organization(organizationId)
9696
await client.cancelQueries({ queryKey })
97-
const refreshed = refreshPublishedPeople(client, organizationId, result)
97+
// A sync can publish while the disconnect is in flight, so its last-success time can't say
98+
// whether the cached people still exist.
99+
const refreshed =
100+
action === 'disconnect'
101+
? client.invalidateQueries({ queryKey: peopleKeys.organization(organizationId) })
102+
: refreshPublishedPeople(client, organizationId, result)
98103
client.setQueryData(queryKey, result)
99104
await refreshed
100105
},

‎apps/sim/platform/app/(shell)/people/_components/people-page/people-page.tsx‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,10 +47,13 @@ export function PeoplePage({ organizationId }: PeoplePageProps) {
4747
const [connecting, setConnecting] = useState<SyncSource | null>(null)
4848
const currentView = view ?? (search || department || departmentKey ? 'directory' : 'chart')
4949
const status = useDirectorySyncStatus(organizationId, isAdmin)
50-
const { emptyOrganization } = useChartRoots(organizationId, isAdmin && currentView === 'chart')
50+
const roots = useChartRoots(organizationId, isAdmin && currentView === 'chart')
5151
const openSync = () => void setView({ panel: 'sync' }, { history: 'push' })
5252
const openImport = () => setImporting(true)
53-
const firstUse = isAdmin && emptyOrganization && status.loaded && !status.sync
53+
const firstUse = isAdmin && roots.emptyOrganization && status.loaded && !status.sync
54+
// First use has no header actions, so they wait for the reads that decide it rather than flash.
55+
const showActions =
56+
isAdmin && (currentView !== 'chart' || (roots.settled && status.settled && !firstUse))
5457
const lastSuccessAt = status.sync?.lastSuccessAt
5558
const modals = (
5659
<>
@@ -96,7 +99,7 @@ export function PeoplePage({ organizationId }: PeoplePageProps) {
9699
view={currentView}
97100
onViewChange={(next) => void setView({ view: next }, { history: 'push' })}
98101
actions={
99-
isAdmin && !(firstUse && currentView === 'chart') ? (
102+
showActions ? (
100103
<>
101104
{lastSuccessAt && (
102105
<span className={styles.updated} data-type='T050'>

‎apps/sim/platform/app/(shell)/people/_components/person/person-modal.tsx‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ export function PersonModal({ organizationId, person, onClose }: PersonModalProp
4040
startDate: person.startDate,
4141
managerMemberId: person.managerMemberId,
4242
})
43+
const [saving, setSaving] = useState(false)
4344
const [errors, setErrors] = useState<Partial<Record<keyof PersonDraft, string>>>({})
4445
// The chosen manager's name, kept with the choice: the search results may no longer include them.
4546
// Only a stored manager who is suspended has an id and no name.
@@ -59,10 +60,15 @@ export function PersonModal({ organizationId, person, onClose }: PersonModalProp
5960
title={`Edit ${person.name}`}
6061
confirmLabel='Save'
6162
onConfirm={async () => {
62-
const problems = await edit.save(draft)
63-
if (problems) {
64-
setErrors(problems)
65-
throw new FieldProblems('Check the highlighted fields.')
63+
setSaving(true)
64+
try {
65+
const problems = await edit.save(draft)
66+
if (problems) {
67+
setErrors(problems)
68+
throw new FieldProblems('Check the highlighted fields.')
69+
}
70+
} finally {
71+
setSaving(false)
6672
}
6773
}}
6874
>
@@ -75,6 +81,7 @@ export function PersonModal({ organizationId, person, onClose }: PersonModalProp
7581
value={draft[field] ?? ''}
7682
onChange={(value) => change(field, value || null)}
7783
error={errors[field]}
84+
disabled={saving}
7885
/>
7986
))}
8087
<DatePicker
@@ -84,6 +91,7 @@ export function PersonModal({ organizationId, person, onClose }: PersonModalProp
8491
value={draft.startDate ?? null}
8592
onValueChange={(value) => change('startDate', value)}
8693
error={errors.startDate}
94+
disabled={saving}
8795
/>
8896
<SearchableSelect
8997
label='Manager'
@@ -94,6 +102,7 @@ export function PersonModal({ organizationId, person, onClose }: PersonModalProp
94102
search={picker.search}
95103
onOpenChange={picker.onOpenChange}
96104
error={errors.managerMemberId}
105+
disabled={saving}
97106
onValueChange={(value) => {
98107
const manager = picker.people.find((candidate) => candidate.memberId === value)
99108
setManagerName(manager?.name ?? null)

‎apps/sim/platform/lib/data/people.ts‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -88,9 +88,11 @@ export function isApiConflict(error: unknown): boolean {
8888
return isApiClientError(error) && error.status === 409
8989
}
9090

91-
/** A refusal replaces what is on screen; a transient failure keeps it. */
91+
const REFUSAL_STATUSES = new Set([400, 401, 403, 404])
92+
93+
/** A refusal replaces what is on screen; a transient failure, including a 429, keeps it. */
9294
export function isRefusal(error: unknown): boolean {
93-
return isApiClientError(error) && error.status < 500
95+
return isApiClientError(error) && REFUSAL_STATUSES.has(error.status)
9496
}
9597

9698
export function usePeopleUrl() {
@@ -290,7 +292,11 @@ export function useOrgChart(organizationId: string) {
290292
export function useChartRoots(organizationId: string, enabled = true) {
291293
const roots = usePeople(organizationId, '', '', { scope: 'roots', enabled })
292294
const firstTeam = roots.data?.pages[0]?.people[0]
293-
return { firstTeam, emptyOrganization: Boolean(roots.data) && !firstTeam }
295+
return {
296+
firstTeam,
297+
emptyOrganization: Boolean(roots.data) && !firstTeam,
298+
settled: !roots.isPending,
299+
}
294300
}
295301

296302
export function usePersonProfile(organizationId: string, memberId: string) {
@@ -389,7 +395,7 @@ export type SyncFieldErrors = Partial<Record<keyof SyncDraft, string>>
389395
export function useDirectorySyncStatus(organizationId: string, enabled: boolean) {
390396
const query = usePeopleSyncStatus(organizationId, enabled)
391397
const status = isDirectorySyncAccessError(query.error) ? undefined : query.data
392-
return { loaded: Boolean(status), sync: status?.sync ?? null }
398+
return { loaded: Boolean(status), settled: !query.isPending, sync: status?.sync ?? null }
393399
}
394400

395401
/** The status with the action state machine the hook keeps out of the cache. */

‎apps/sim/scripts/test-scim-e2e.ts‎

Lines changed: 27 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -421,18 +421,16 @@ async function run() {
421421
waitUntil: 'domcontentloaded',
422422
timeout: 180_000,
423423
})
424-
await page.getByRole('button', { name: 'Import people', exact: true }).click()
425-
const picker = page.getByRole('dialog', { name: 'Import people data' })
426-
await picker.getByRole('button', { name: 'SCIM provisioning' }).click()
427-
await page
428-
.getByRole('dialog', { name: 'Import from SCIM provisioning' })
429-
.getByRole('button', { name: 'Preview import' })
430-
.click()
431-
const modal = page.getByRole('dialog', { name: 'Review import' })
432-
await expect(modal.getByText(/1 profile will be updated/)).toBeVisible()
424+
await page.getByRole('button', { name: /^Import( profiles)?$/ }).click()
425+
const importDialog = page.getByRole('dialog', { name: 'Import profiles' })
426+
await importDialog.getByRole('combobox', { name: 'Source' }).click()
427+
await page.getByRole('option', { name: 'SCIM provisioning' }).click()
428+
await importDialog.getByRole('button', { name: 'Preview import' }).click()
429+
const modal = page.getByRole('dialog', { name: 'Review profile import' })
430+
await expect(modal.getByText('To update', { exact: true })).toBeVisible()
433431
await mkdir(screenshotDir, { recursive: true })
434432
await page.screenshot({ path: join(screenshotDir, 'provisioned-import.png') })
435-
await modal.getByRole('button', { name: 'Import', exact: true }).click()
433+
await modal.getByRole('button', { name: 'Import 1 profile', exact: true }).click()
436434
await expect(modal).toHaveCount(0)
437435
} finally {
438436
await browser.close()
@@ -544,23 +542,26 @@ async function run() {
544542
await page.route(statusUrl, (route) =>
545543
route.fulfill({ status: 503, json: { error: 'Unavailable' } })
546544
)
547-
await page.getByRole('button', { name: 'Directory sync', exact: true }).click()
548-
const modal = page.getByRole('dialog', { name: 'Directory sync', exact: true })
549-
await modal.getByRole('button', { name: 'Retry connection' }).waitFor()
545+
const openSync = page.getByRole('button', { name: 'Directory sync', exact: true })
546+
await openSync.click()
547+
await page.getByRole('heading', { name: 'Directory sync', level: 1 }).waitFor()
548+
await page.getByRole('button', { name: 'Retry', exact: true }).waitFor()
550549
await page.unroute(statusUrl)
551-
await expect(modal.getByText('Sync failed', { exact: true })).toBeVisible({
550+
await expect(page.getByText('Sync failed', { exact: true })).toBeVisible({
552551
timeout: 60_000,
553552
})
554-
await expect(modal.getByText(/reporting loop/)).toBeVisible()
553+
await expect(page.getByText(/reporting loop/)).toBeVisible()
555554
await page.screenshot({ path: join(screenshotDir, 'sync-failure.png') })
556-
await modal.getByRole('button', { name: 'Retry sync' }).click()
557-
await expect(modal.getByText('Connected', { exact: true })).toBeVisible()
558-
await expect(modal.getByText('Last successful sync')).toBeVisible()
555+
await page.getByRole('button', { name: 'Retry sync' }).click()
556+
await expect(page.getByText('Connected', { exact: true })).toBeVisible()
557+
await expect(page.getByText('Last successful sync')).toBeVisible()
559558
await page.setViewportSize({ width: 390, height: 844 })
560559
await page.screenshot({ path: join(screenshotDir, 'sync-recovered-mobile.png') })
561-
const dialogBox = await modal.boundingBox()
562-
assert(dialogBox && dialogBox.x >= 0 && dialogBox.x + dialogBox.width <= 391)
560+
assert(
561+
await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
562+
)
563563
assert.equal(record((await chart()).manager).email, bobEmail)
564+
await page.getByRole('button', { name: 'Back to people' }).click()
564565
await expect(page.getByText('Principal Engineer', { exact: true })).toBeAttached()
565566
await patch(token, `/Users/${aliceId}`, [
566567
{ op: 'replace', path: 'title', value: 'Staff Platform Engineer' },
@@ -569,20 +570,20 @@ async function run() {
569570
await expect(page.getByText('Staff Platform Engineer', { exact: true })).toBeAttached({
570571
timeout: 60_000,
571572
})
572-
await modal.getByRole('button', { name: 'Disconnect', exact: true }).click()
573+
await openSync.click()
574+
await page.getByRole('button', { name: 'Disconnect SCIM provisioning' }).click()
573575
await page
574576
.getByRole('dialog', { name: 'Disconnect directory' })
575577
.getByRole('button', { name: 'Disconnect', exact: true })
576578
.click()
577-
await expect(
578-
modal.getByRole('button', { name: 'Connect SCIM provisioning' })
579-
).toBeVisible()
580-
await modal.getByRole('button', { name: 'Connect SCIM provisioning' }).click()
579+
const connectScim = page.getByRole('button', { name: 'Connect SCIM provisioning' })
580+
await expect(connectScim).toBeVisible()
581+
await connectScim.click()
581582
await page
582583
.getByRole('dialog', { name: 'Connect SCIM provisioning' })
583584
.getByRole('button', { name: 'Connect and sync' })
584585
.click()
585-
await expect(modal.getByText('Connected', { exact: true })).toBeVisible()
586+
await expect(page.getByText('Connected', { exact: true })).toBeVisible()
586587
} finally {
587588
await browser.close()
588589
}

0 commit comments

Comments
 (0)