Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,12 @@
}

.selectUserHandlesCustomMultiValue {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
padding: 0 8px;
line-height: 1.4;
padding: 2px 8px;
margin-right: 6px;
color: $black-60;
background-color: $black-10;
Expand All @@ -28,6 +32,24 @@
}
}

// Sized here so the control works outside admin-app (which provides svg.icon globals).
.removeIcon {
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
line-height: 1;

:global(svg) {
display: block;
width: 12px;
height: 12px;
max-width: 12px;
max-height: 12px;
stroke: currentColor;
}
}

.selectUserHandlesDropdownContainer {
z-index: 9999 !important;
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,14 @@
color: #6b6f75;
font-style: italic;
}

.handleLink {
color: #0f62fe;
font-weight: 700;
text-decoration: none;

&:hover {
color: #0043ce;
text-decoration: underline;
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { Dispatch, FC, SetStateAction, useCallback, useMemo, useState } from 'react'

import { InputHandlesSelector } from '~/apps/admin/src/lib/components/InputHandlesSelector'
import { SearchUserInfo } from '~/apps/admin/src/lib/models'
import { EnvironmentConfig } from '~/config'
import {
Button,
InputFilePicker,
Expand Down Expand Up @@ -63,7 +66,6 @@ const parseLookupResults = async (blob: Blob): Promise<BulkMemberRow[]> => {
* Parses uploaded text/CSV content into a normalized handle list.
* @param file Uploaded `.txt` or `.csv` file.
* @returns Ordered non-empty handles from the file.
* @throws Error when no handles are found in the uploaded content.
*/
const parseHandlesFromFile = async (file: File): Promise<string[]> => {
const content = (await file.text())
Expand All @@ -81,8 +83,37 @@ const parseHandlesFromFile = async (file: File): Promise<string[]> => {
handles.shift()
}

return handles
}

/**
* Merges typed and file-sourced handles, preserving order and dropping duplicates.
* @param selectedHandles Handles chosen in the multi-entry selector.
* @param file Optional uploaded handles file.
* @returns Combined unique handle list.
* @throws Error when neither source provides handles.
*/
const collectHandles = async (
selectedHandles: SearchUserInfo[],
file: File | undefined,
): Promise<string[]> => {
const fromInput = selectedHandles.map(item => item.handle.trim())
.filter(Boolean)
const fromFile = file ? await parseHandlesFromFile(file) : []
const seen = new Set<string>()
const handles: string[] = []

for (const handle of [...fromInput, ...fromFile]) {
const key = handle.toLowerCase()

if (!seen.has(key)) {
seen.add(key)
handles.push(handle)
}
}

if (!handles.length) {
throw new Error('Uploaded file does not contain any handles.')
throw new Error('Enter at least one handle or upload a file that contains handles.')
}

return handles
Expand All @@ -106,14 +137,19 @@ const downloadBlob = (blob: Blob, fileName: string): void => {
}

/**
* Bulk Member Lookup page for uploading handles and resolving account details.
* Bulk Member Lookup page for resolving account details by handle.
*
* Users upload a `.txt` or `.csv` file of handles, submit for lookup,
* review results in a table, and optionally download JSON/CSV output.
* Users can enter handles in a multi-entry selector and/or upload a `.txt` or
* `.csv` file, submit for lookup, review results in a table, and optionally
* download JSON/CSV output.
*/
export const BulkMemberLookupPage: FC = () => {
const [file, setFile]: [File | undefined, Dispatch<SetStateAction<File | undefined>>]
= useState<File | undefined>(undefined)
const [selectedHandles, setSelectedHandles]: [
SearchUserInfo[],
Dispatch<SetStateAction<SearchUserInfo[]>>
] = useState<SearchUserInfo[]>([])
const [isSubmitting, setIsSubmitting]: [boolean, Dispatch<SetStateAction<boolean>>]
= useState<boolean>(false)
const [results, setResults]: [BulkMemberRow[], Dispatch<SetStateAction<BulkMemberRow[]>>]
Expand All @@ -125,6 +161,9 @@ export const BulkMemberLookupPage: FC = () => {
Dispatch<SetStateAction<'json' | 'csv' | undefined>>
] = useState<'json' | 'csv' | undefined>(undefined)

const hasHandlesInput = selectedHandles.length > 0 || !!file
const isBusy = isSubmitting || isDownloading !== undefined

const tableColumns = useMemo<TableColumn<BulkMemberRow>[]>(() => ([
{
label: 'User ID',
Expand All @@ -135,7 +174,19 @@ export const BulkMemberLookupPage: FC = () => {
{
label: 'Handle',
propertyName: 'handle',
type: 'text',
renderer: data => (
data.handle ? (
<a
className={styles.handleLink}
href={`${EnvironmentConfig.USER_PROFILE_URL}/${encodeURIComponent(data.handle)}`}
target='_blank'
rel='noreferrer'
>
{data.handle}
</a>
) : <>{emptyValue}</>
),
type: 'element',
},
{
label: 'First Name',
Expand Down Expand Up @@ -175,14 +226,20 @@ export const BulkMemberLookupPage: FC = () => {
setResults([])
}, [])

const handleHandlesChange = useCallback((handles: SearchUserInfo[]): void => {
setSelectedHandles(handles)
setHasSubmitted(false)
setResults([])
}, [])

const handleLookupMembers = useCallback(async (): Promise<void> => {
if (!file) {
if (!hasHandlesInput) {
return
}

try {
setIsSubmitting(true)
const handles = await parseHandlesFromFile(file)
const handles = await collectHandles(selectedHandles, file)
const responseBlob = await postReportAsJson(bulkMembersByHandlesPath, { handles })
const lookupResults = await parseLookupResults(responseBlob)

Expand All @@ -193,16 +250,16 @@ export const BulkMemberLookupPage: FC = () => {
} finally {
setIsSubmitting(false)
}
}, [file])
}, [file, hasHandlesInput, selectedHandles])

const handleDownload = useCallback(async (format: 'json' | 'csv'): Promise<void> => {
if (!file) {
if (!hasHandlesInput) {
return
}

try {
setIsDownloading(format)
const handles = await parseHandlesFromFile(file)
const handles = await collectHandles(selectedHandles, file)

const blob = format === 'json'
? await postReportAsJson(bulkMembersByHandlesPath, { handles })
Expand All @@ -214,7 +271,7 @@ export const BulkMemberLookupPage: FC = () => {
} finally {
setIsDownloading(undefined)
}
}, [file])
}, [file, hasHandlesInput, selectedHandles])

const handleJsonDownload = useCallback(() => {
handleDownload('json')
Expand All @@ -224,7 +281,7 @@ export const BulkMemberLookupPage: FC = () => {
handleDownload('csv')
}, [handleDownload])

const isDownloadDisabled = !file || isSubmitting || isDownloading !== undefined
const isDownloadDisabled = !hasHandlesInput || isBusy

return (
<>
Expand All @@ -235,11 +292,19 @@ export const BulkMemberLookupPage: FC = () => {
<PageTitle>{pageTitle}</PageTitle>

<p className={styles.instructions}>
Upload a TXT or CSV file that contains one member handle per line,
then submit to resolve user details.
Enter member handles below and/or upload a TXT or CSV file that
contains one member handle per line, then submit to resolve user details.
</p>

<div className={styles.uploadSection}>
<InputHandlesSelector
label='User Handles'
placeholder='Enter handles you are searching for...'
value={selectedHandles}
onChange={handleHandlesChange}
disabled={isBusy}
/>

<InputFilePicker
fileConfig={{
acceptFileType: '.txt,.csv',
Expand All @@ -251,7 +316,7 @@ export const BulkMemberLookupPage: FC = () => {
<div className={styles.actions}>
<Button
primary
disabled={!file || isSubmitting || isDownloading !== undefined}
disabled={!hasHandlesInput || isBusy}
onClick={handleLookupMembers}
>
Look Up Members
Expand Down Expand Up @@ -286,7 +351,7 @@ export const BulkMemberLookupPage: FC = () => {
<Table columns={tableColumns} data={results} />
) : (
<div className={styles.emptyState}>
No members were returned for the uploaded handles.
No members were returned for the provided handles.
</div>
)}
</div>
Expand Down
Loading