Skip to content

Commit 9458187

Browse files
committed
fix(people): clearable manager and department pickers, exact spreadsheet dates and row numbers, loop checks through suspended members
1 parent e49ccce commit 9458187

9 files changed

Lines changed: 122 additions & 53 deletions

File tree

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

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ import { useDebounce } from '@/hooks/use-debounce'
2020

2121
type TextField = 'title' | 'department' | 'location'
2222

23+
/** The picker's value for no manager; membership ids are never this. */
24+
const NO_MANAGER = 'no-manager'
25+
2326
const TEXT_FIELDS: readonly { field: TextField; title: string; placeholder: string }[] = [
2427
{ field: 'title', title: 'Title', placeholder: 'Staff Engineer' },
2528
{ field: 'department', title: 'Department', placeholder: 'Engineering' },
@@ -51,11 +54,16 @@ export function PersonModal({ organizationId, person, onClose }: PersonModalProp
5154
useDebounce(managerSearch.trim(), SEARCH_DEBOUNCE_MS),
5255
''
5356
)
54-
const managerOptions = (candidates.data?.pages ?? []).flatMap((page) =>
55-
page.people
56-
.filter((candidate) => candidate.memberId !== person.memberId)
57-
.map((candidate) => ({ value: candidate.memberId, label: candidate.name }))
57+
const found = (candidates.data?.pages ?? []).flatMap((page) =>
58+
page.people.filter((candidate) => candidate.memberId !== person.memberId)
5859
)
60+
const managerOptions = [
61+
{ value: NO_MANAGER, label: 'No manager' },
62+
...found.map((candidate) => ({
63+
value: candidate.memberId,
64+
label: `${candidate.name} (${candidate.email})`,
65+
})),
66+
]
5967
const managerLabel = (draft.managerMemberId && managerName) || 'No manager'
6068

6169
const change = <K extends keyof PersonBody>(field: K, value: PersonBody[K]) => {
@@ -122,20 +130,20 @@ export function PersonModal({ organizationId, person, onClose }: PersonModalProp
122130
{() => (
123131
<ChipCombobox
124132
options={managerOptions}
125-
value={draft.managerMemberId ?? ''}
133+
value={draft.managerMemberId ?? NO_MANAGER}
126134
onChange={(value) => {
127-
setManagerName(
128-
managerOptions.find((option) => option.value === value)?.label ?? null
129-
)
130-
change('managerMemberId', value || null)
135+
const manager = found.find((candidate) => candidate.memberId === value)
136+
setManagerName(manager?.name ?? null)
137+
change('managerMemberId', manager?.memberId ?? null)
131138
}}
132139
overlayContent={managerLabel}
133140
searchable
134141
onSearchChange={setManagerSearch}
135142
searchPlaceholder='Search people...'
136-
isLoading={candidates.isFetching}
137-
showAllOption
138-
allOptionLabel='No manager'
143+
isLoading={candidates.isFetching && !candidates.isFetchingNextPage}
144+
hasMore={candidates.hasNextPage}
145+
isLoadingMore={candidates.isFetchingNextPage}
146+
onLoadMore={() => void candidates.fetchNextPage()}
139147
emptyMessage='No one matches'
140148
disabled={update.isPending}
141149
className='w-full'

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

Lines changed: 33 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -30,39 +30,54 @@ interface PeopleSheet {
3030
}
3131

3232
/**
33-
* The first sheet as text rows keyed by header. Delimited text is read verbatim so the server
34-
* interprets dates the same way for every file; spreadsheet date cells become `YYYY-MM-DD`.
33+
* The first sheet as text rows keyed by header, one per sheet row (blank ones included) so the
34+
* row numbers in the preview match the spreadsheet. Delimited text is read verbatim so the server
35+
* interprets dates the same way for every file. A spreadsheet date cell is read from its stored
36+
* value, not the text its locale displays, so `31/01/2024` and `1/31/24` both arrive as
37+
* `2024-01-31`.
3538
*/
3639
async function readPeopleSheet(file: File): Promise<PeopleSheet> {
3740
if (file.size > MAX_FILE_BYTES) throw new Error('Choose a file under 5 MB')
3841
const XLSX = await import('xlsx')
39-
const delimited = /\.(csv|tsv)$/i.test(file.name)
4042
const workbook = XLSX.read(await file.arrayBuffer(), {
4143
type: 'array',
42-
raw: delimited,
43-
cellDates: !delimited,
44+
raw: /\.(csv|tsv)$/i.test(file.name),
4445
dense: true,
4546
})
4647
const sheet = workbook.Sheets[workbook.SheetNames[0] ?? '']
47-
if (!sheet) throw new Error('The file has no sheets')
48-
const [headerRow = [], ...body] = XLSX.utils.sheet_to_json<unknown[]>(sheet, {
49-
header: 1,
50-
raw: false,
51-
dateNF: 'yyyy-mm-dd',
52-
defval: '',
53-
blankrows: false,
54-
})
55-
const headers = headerRow.map((value) => String(value).trim())
56-
const columns = headers.flatMap((header, index) => (header ? [{ header, index }] : []))
48+
if (!sheet?.['!ref']) throw new Error('The file has no rows')
49+
const range = XLSX.utils.decode_range(sheet['!ref'])
50+
const grid = sheet['!data'] ?? []
51+
const text = (row: number, column: number): string => {
52+
const cell = grid[row]?.[column]
53+
if (!cell || cell.v === undefined || cell.v === null) return ''
54+
if (cell.t === 'n' && XLSX.SSF.is_date(cell.z ?? '')) {
55+
const date = XLSX.SSF.parse_date_code(Number(cell.v))
56+
return `${date.y}-${String(date.m).padStart(2, '0')}-${String(date.d).padStart(2, '0')}`
57+
}
58+
return String(cell.w ?? cell.v).trim()
59+
}
60+
const columns: { header: string; index: number }[] = []
61+
for (let index = range.s.c; index <= range.e.c; index++) {
62+
const header = text(range.s.r, index)
63+
if (!header) continue
64+
if (columns.some((column) => column.header === header)) {
65+
throw new Error(`The ${header} column appears more than once`)
66+
}
67+
columns.push({ header, index })
68+
}
5769
if (!columns.length) throw new Error('The first row should name the columns')
58-
if (body.length > PEOPLE_IMPORT_MAX_ROWS) {
70+
const rowCount = range.e.r - range.s.r
71+
if (rowCount > PEOPLE_IMPORT_MAX_ROWS) {
5972
throw new Error(`Import at most ${PEOPLE_IMPORT_MAX_ROWS} rows at a time`)
6073
}
6174
return {
6275
fileName: file.name,
6376
headers: columns.map(({ header }) => header),
64-
rows: body.map((row) =>
65-
Object.fromEntries(columns.map(({ header, index }) => [header, String(row[index] ?? '')]))
77+
rows: Array.from({ length: rowCount }, (_, offset) =>
78+
Object.fromEntries(
79+
columns.map(({ header, index }) => [header, text(range.s.r + 1 + offset, index)])
80+
)
6681
),
6782
}
6883
}

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

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ import { useDepartments, usePeople } from '@/hooks/queries/people'
3535
import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter'
3636
import { useSearchFilterValue } from '@/hooks/use-search-filter-value'
3737

38+
/** The filter's value for every department: stored text can't hold a NUL, so no department is this. */
39+
const ALL_DEPARTMENTS = '\u0000all'
40+
3841
function personCell(person: PersonSummary) {
3942
return {
4043
icon: <Avatar size='xs' name={person.name} src={person.image} aria-hidden />,
@@ -117,18 +120,19 @@ export function People({ organizationId }: PeopleProps) {
117120
<ResourceFilterPanel>
118121
<ResourceFilterSection label='Department'>
119122
<ChipCombobox
120-
options={departments.map(({ name, count }) => ({
121-
value: name,
122-
label: `${name} (${count})`,
123-
}))}
124-
value={department}
125-
onChange={setDepartment}
123+
options={[
124+
{ value: ALL_DEPARTMENTS, label: 'All' },
125+
...departments.map(({ name, count }) => ({
126+
value: name,
127+
label: `${name} (${count})`,
128+
})),
129+
]}
130+
value={department || ALL_DEPARTMENTS}
131+
onChange={(value) => setDepartment(value === ALL_DEPARTMENTS ? '' : value)}
126132
overlayContent={department || 'All'}
127133
searchable
128134
searchPlaceholder='Search departments...'
129-
showAllOption
130-
allOptionLabel='All'
131-
emptyMessage='No departments recorded yet'
135+
emptyMessage='No departments match'
132136
className='w-full'
133137
/>
134138
</ResourceFilterSection>

‎apps/sim/lib/api/contracts/people.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,14 @@ export const importPeopleContract = defineRouteContract({
106106
params,
107107
body: z
108108
.object({
109-
headers: z.array(z.string().max(200)).min(1).max(100),
109+
headers: z
110+
.array(z.string().max(200))
111+
.min(1)
112+
.max(100)
113+
.refine(
114+
(headers) => new Set(headers).size === headers.length,
115+
'Each column must have its own name'
116+
),
110117
rows: z
111118
.array(z.record(z.string().max(200), z.string().max(1000)))
112119
.min(1, 'The file has no rows')

‎apps/sim/lib/people/application/use-cases.ts‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,18 +24,19 @@ interface PersonInput extends OrganizationInput {
2424
/** Issues past this many are counted, not listed, so a broken file can't flood the response. */
2525
const MAX_REPORTED_ISSUES = 200
2626

27-
async function authorizeDirectory({
27+
async function authorizeDirectory<I extends OrganizationInput>({
2828
input,
2929
context,
30-
}: OrganizationUseCaseContext<OrganizationInput>): Promise<void> {
30+
}: OrganizationUseCaseContext<I>): Promise<void> {
3131
await Promise.all([
3232
requirePeopleEnabled(input.organizationId),
3333
requireOrganizationMemberDirectory(input.organizationId, context.role),
3434
])
3535
}
3636

37-
const authorizeAdministration = ({ input }: OrganizationUseCaseContext<OrganizationInput>) =>
38-
requirePeopleEnabled(input.organizationId)
37+
const authorizeAdministration = <I extends OrganizationInput>({
38+
input,
39+
}: OrganizationUseCaseContext<I>) => requirePeopleEnabled(input.organizationId)
3940

4041
export const listPeople = defineAuthorizedOrganizationUseCase({
4142
operation: peopleOperations.list,

‎apps/sim/lib/people/import.test.ts‎

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,9 @@ function profile(fields: Partial<MemberProfileFields>): MemberProfileFields {
1414
}
1515
}
1616

17-
const ada = { memberId: 'm-ada', email: 'ada@example.com', profile: null }
18-
const bo = { memberId: 'm-bo', email: 'bo@example.com', profile: null }
19-
const cy = { memberId: 'm-cy', email: 'cy@example.com', profile: null }
17+
const ada = { memberId: 'm-ada', email: 'ada@example.com', profile: null, active: true }
18+
const bo = { memberId: 'm-bo', email: 'bo@example.com', profile: null, active: true }
19+
const cy = { memberId: 'm-cy', email: 'cy@example.com', profile: null, active: true }
2020

2121
function plan(
2222
headers: string[],
@@ -313,6 +313,31 @@ describe('planPeopleImport', () => {
313313
expect(result.issues).toEqual([expect.objectContaining({ row: 2, severity: 'skipped' })])
314314
})
315315

316+
it('follows a suspended manager when checking for loops, but never matches them', () => {
317+
const suspended = {
318+
memberId: 'm-sus',
319+
email: 'sus@example.com',
320+
active: false,
321+
profile: profile({ managerMemberId: 'm-ada' }),
322+
}
323+
const reportsToSuspended = { ...bo, profile: profile({ managerMemberId: 'm-sus' }) }
324+
const result = plan(
325+
['email', 'manager email'],
326+
[
327+
{ email: 'ada@example.com', 'manager email': 'bo@example.com' },
328+
{ email: 'cy@example.com', 'manager email': 'sus@example.com' },
329+
{ email: 'sus@example.com', 'manager email': '' },
330+
],
331+
[ada, reportsToSuspended, cy, suspended]
332+
)
333+
expect(result.changes).toEqual([])
334+
expect(result.issues.map((issue) => [issue.row, issue.severity])).toEqual([
335+
[2, 'warning'],
336+
[3, 'warning'],
337+
[4, 'skipped'],
338+
])
339+
})
340+
316341
it('combines location parts, leaving out the blank ones', () => {
317342
const result = plan(
318343
['email', 'city', 'state', 'country'],

‎apps/sim/lib/people/import.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,8 @@ export interface PeopleImportMember {
9292
memberId: string
9393
email: string
9494
profile: MemberProfileFields | null
95+
/** Suspended members keep their reporting lines for loop checks but are never matched. */
96+
active: boolean
9597
}
9698

9799
interface PeopleImportIssue {
@@ -177,7 +179,9 @@ export function planPeopleImport({
177179
rows: readonly Record<string, string>[]
178180
members: readonly PeopleImportMember[]
179181
}): PeopleImportPlan {
180-
const byEmail = new Map(members.map((member) => [member.email.toLowerCase(), member]))
182+
const byEmail = new Map(
183+
members.filter((member) => member.active).map((member) => [member.email.toLowerCase(), member])
184+
)
181185
const managers = new Map(
182186
members.map((member) => [member.memberId, member.profile?.managerMemberId ?? null])
183187
)

‎apps/sim/lib/people/repository.ts‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -267,7 +267,10 @@ export async function isInReportingChain(
267267
return row?.found === true
268268
}
269269

270-
/** Every active member with the email they are matched by and their stored profile. */
270+
/**
271+
* Every member with the email they are matched by, their stored profile, and whether they are
272+
* active: a suspended member's reporting lines still close loops.
273+
*/
271274
export async function listMemberProfiles(
272275
executor: DbOrTx,
273276
organizationId: string
@@ -282,14 +285,16 @@ export async function listMemberProfiles(
282285
startDate: memberProfile.startDate,
283286
managerMemberId: memberProfile.managerMemberId,
284287
hasProfile: sql<boolean>`${memberProfile.memberId} IS NOT NULL`,
288+
active: sql<boolean>`${user.suspendedAt} IS NULL`,
285289
})
286290
.from(member)
287291
.innerJoin(user, eq(user.id, member.userId))
288292
.leftJoin(memberProfile, eq(memberProfile.memberId, member.id))
289-
.where(and(eq(member.organizationId, organizationId), ACTIVE_USER))
290-
return rows.map(({ memberId, email, hasProfile, ...profile }) => ({
293+
.where(eq(member.organizationId, organizationId))
294+
return rows.map(({ memberId, email, hasProfile, active, ...profile }) => ({
291295
memberId,
292296
email,
297+
active,
293298
profile: hasProfile ? profile : null,
294299
}))
295300
}

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -166,8 +166,8 @@ async function run() {
166166
const { people } = await api(ada, '', {})
167167
assert(Array.isArray(people) && people.length === 3, `Expected 3 people, got ${people}`)
168168
assert.deepEqual((await api(ada, '/departments', {})).departments, [])
169-
await api({ ...outsider, orgId }, '', { expected: 403 })
170-
await api({ ...outsider, orgId }, `/${ada.memberId}`, { expected: 403 })
169+
await api({ ...outsider, orgId }, '', { expected: 404 })
170+
await api({ ...outsider, orgId }, `/${ada.memberId}`, { expected: 404 })
171171
await api(outsider, `/${ada.memberId}`, { expected: 404 })
172172
})
173173

0 commit comments

Comments
 (0)