Skip to content

Commit

Permalink
✨ Export accounts from workspace to csv and add items from drag-n-dro…
Browse files Browse the repository at this point in the history
…p csv (#184)

* ✨ Export accounts from workspace to csv

* ✨ Add column selector and admin field protection

* ✨ Allow dragging and dropping/uploading file to import into workspace

* 🧹 Cleanup

* 🐛 Handle quoted header and cell

* ✨ Add keyword search in workspace
  • Loading branch information
foysalit authored Dec 9, 2024
1 parent 7aab439 commit 325581a
Show file tree
Hide file tree
Showing 12 changed files with 781 additions and 188 deletions.
4 changes: 2 additions & 2 deletions components/common/Dropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,11 @@ export const Dropdown = ({
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
{/* z-30 value is important because we want all dropdowns to draw over other elements in the page and besides mobile menu, z-30 is the highest z-index we use in this codebase */}
{/* z-50 value is important because we want all dropdowns to draw over other elements in the page and besides mobile menu, z-40 is the highest z-index we use in this codebase */}
<Menu.Items
className={classNames(
rightAligned ? 'right-0' : '',
'absolute z-30 mt-2 w-48 origin-top-right rounded-md bg-white dark:bg-slate-800 py-1 shadow-lg dark:shadow-slate-900 ring-1 ring-black ring-opacity-5 focus:outline-none',
'absolute z-50 mt-2 w-48 origin-top-right rounded-md bg-white dark:bg-slate-800 py-1 shadow-lg dark:shadow-slate-900 ring-1 ring-black ring-opacity-5 focus:outline-none',
)}
>
{items.map((item) => (
Expand Down
4 changes: 2 additions & 2 deletions components/shell/CommandPalette/Root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,8 @@ export const CommandPaletteRoot = ({
return (
<KBarProvider actions={staticActions}>
<KBarPortal>
{/* z-40 value is important because we want the cmd palette to be able above all panels and currently, the highest z-index we use is z-40 */}
<KBarPositioner className="p-2 bg-gray-900/80 flex items-center pb-4 z-40">
{/* z-50 value is important because we want the cmd palette to be able above all panels and currently, the highest z-index we use is z-50 */}
<KBarPositioner className="p-2 bg-gray-900/80 flex items-center pb-4 z-50">
<KBarAnimator className="w-full md:w-2/3 lg:w-1/2 w-max-[600px] overflow-hidden p-2 bg-white dark:bg-slate-800 rounded-xl">
<KBarSearch
defaultPlaceholder="Search by DID, bsky url or handle"
Expand Down
129 changes: 129 additions & 0 deletions components/workspace/ExportPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { ActionButton } from '@/common/buttons'
import { Popover, Transition } from '@headlessui/react'
import { useWorkspaceExport } from './hooks'
import { WorkspaceListData } from './useWorkspaceListData'
import { Checkbox, FormLabel, Input } from '@/common/forms'

export const WorkspaceExportPanel = ({
listData,
}: {
listData: WorkspaceListData
}) => {
const {
mutateAsync,
isLoading,
headers,
filename,
setFilename,
selectedColumns,
setSelectedColumns,
} = useWorkspaceExport()

const canDownload = selectedColumns.length > 0 && !isLoading && !!filename

return (
<Popover className="relative z-30">
{({ open, close }) => (
<>
<Popover.Button className="text-sm flex flex-row items-center z-20">
<ActionButton
size="sm"
appearance="outlined"
title="Export all users from workspace"
>
<span className="text-xs">
{isLoading ? 'Exporting...' : 'Export'}
</span>
</ActionButton>
</Popover.Button>

{/* Use the `Transition` component. */}
<Transition
show={open}
enter="transition duration-100 ease-out"
enterFrom="transform scale-95 opacity-0"
enterTo="transform scale-100 opacity-100"
leave="transition duration-75 ease-out"
leaveFrom="transform scale-100 opacity-100"
leaveTo="transform scale-95 opacity-0"
>
<Popover.Panel className="absolute right-0 z-30 mt-1 flex w-screen max-w-max -translate-x-1/5 px-4">
<div className="w-fit-content flex-auto rounded bg-white dark:bg-slate-800 p-4 text-sm leading-6 shadow-lg dark:shadow-slate-900 ring-1 ring-gray-900/5">
<div className="">
<FormLabel label="Export File Name" className="mb-3" required>
<Input
type="text"
name="filename"
id="filename"
className="py-2 w-full"
placeholder="Enter the file name for your export"
required
value={filename}
onChange={(e) => setFilename(e.target.value)}
/>
</FormLabel>

<FormLabel
label="Select Columns to Export"
className="mb-1"
required
/>

{headers.map((fieldName) => {
return (
<Checkbox
key={fieldName}
value="true"
id={fieldName}
name={fieldName}
checked={selectedColumns.includes(fieldName)}
className="mb-2 flex items-center"
label={fieldName}
onChange={(e) =>
e.target.checked
? setSelectedColumns([
...selectedColumns,
fieldName,
])
: setSelectedColumns(
selectedColumns.filter(
(col) => col !== fieldName,
),
)
}
/>
)
})}
</div>
<p className="py-2 block max-w-lg text-gray-500 dark:text-gray-300 text-xs">
Note:{' '}
<i>
The exported file will only contain the selected columns.
<br />
When exporting from records (posts, profiles etc.) the
exported file will only contain the author account details
of the records.
</i>
</p>
<div className="flex flex-row mt-2 gap-2">
<ActionButton
size="xs"
appearance="outlined"
disabled={!canDownload}
onClick={() => {
mutateAsync(listData).then(() => close())
}}
>
<span className="text-xs">
{isLoading ? 'Downloading...' : 'Download'}
</span>
</ActionButton>
</div>
</div>
</Popover.Panel>
</Transition>
</>
)}
</Popover>
)
}
91 changes: 76 additions & 15 deletions components/workspace/FilterSelector.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import { Popover, Transition } from '@headlessui/react'
import { ActionButton } from '@/common/buttons'
import { CheckIcon } from '@heroicons/react/24/outline'
import { Checkbox } from '@/common/forms'
import { Checkbox, FormLabel, Input } from '@/common/forms'
import { useState } from 'react'
import { WorkspaceListData } from './useWorkspaceListData'
import { ToolsOzoneModerationDefs } from '@atproto/api'
import {
AppBskyActorDefs,
AppBskyActorProfile,
ToolsOzoneModerationDefs,
} from '@atproto/api'
import { getSubjectStatusFromItemData } from './utils'

const toggleItemCheck = (item: string, select: boolean = true) => {
Expand Down Expand Up @@ -47,6 +51,14 @@ const ContentFilterOptions = {
contentWithVideoEmbed: { label: 'Content with video embed' },
}

const matchKeyword = (keyword?: string, subject?: string) => {
if (!keyword || !subject) return false
const keywords = keyword.split('||')
return keywords.some((k) =>
subject.toLowerCase().includes(k.trim().toLowerCase()),
)
}

export const WorkspaceFilterSelector = ({
listData,
}: {
Expand All @@ -66,6 +78,7 @@ export const WorkspaceFilterSelector = ({
contentAuthorDeactivated: false,
contentWithImageEmbed: false,
contentWithVideoEmbed: false,
keyword: '',
})

const handleFilterChange = (e: React.ChangeEvent<HTMLInputElement>) => {
Expand All @@ -86,6 +99,13 @@ export const WorkspaceFilterSelector = ({
})
}

const unselectAll = () => {
if (!listData) return
Object.keys(listData).forEach((uri) => {
toggleItemCheck(uri, false)
})
}

const toggleFilteredItems = (select: boolean = true) => {
if (!listData) return
Object.entries(listData).forEach(([uri, item]) => {
Expand All @@ -104,7 +124,14 @@ export const WorkspaceFilterSelector = ({
(filters.accountEmailUnConfirmed &&
isRepo &&
!item.emailConfirmedAt) ||
(filters.accountDeactivated && isRepo && item.deactivatedAt)
(filters.accountDeactivated && isRepo && item.deactivatedAt) ||
(filters.keyword &&
isRepo &&
matchKeyword(
filters.keyword,
item.relatedRecords?.find(AppBskyActorProfile.isRecord)
?.description,
))
) {
toggleItemCheck(uri, select)
}
Expand All @@ -125,7 +152,10 @@ export const WorkspaceFilterSelector = ({
(filters.contentWithImageEmbed &&
subjectStatus?.tags?.includes('embed:image')) ||
(filters.contentWithVideoEmbed &&
subjectStatus?.tags?.includes('embed:video'))
subjectStatus?.tags?.includes('embed:video')) ||
(filters.keyword &&
isRecord &&
matchKeyword(filters.keyword, item.value?.['text']))
) {
toggleItemCheck(uri, select)
}
Expand All @@ -134,7 +164,7 @@ export const WorkspaceFilterSelector = ({
}

return (
<Popover className="relative z-20">
<Popover className="relative z-30">
{({ open }) => (
<>
<Popover.Button className="text-sm flex flex-row items-center z-20">
Expand Down Expand Up @@ -204,24 +234,46 @@ export const WorkspaceFilterSelector = ({
)}
</div>
</div>
<div className="mb-2">
<FormLabel
label="Keyword"
htmlFor="keyword"
className="flex-1"
>
<Input
type="text"
id="keyword"
name="keyword"
required
list="subject-suggestions"
placeholder="Keyword"
className="block w-full"
value={filters.keyword}
onChange={(ev) =>
setFilters({ ...filters, keyword: ev.target.value })
}
autoComplete="off"
/>
</FormLabel>
</div>
<p className="py-2 block max-w-lg text-gray-500 dark:text-gray-300 text-xs">
Note:{' '}
<i>
You can select or unselect all items that matches the above
configured filters. The configured filters work with OR
operator. <br />
So, if you select {'"Deactivated accounts"'} and
{'"Accounts in appealed state"'}, all accounts that are
either deactivated OR in appealed state will be selected
</i>
You can select or unselect all items that matches the above
configured filters. The configured filters work with OR
operator. <br />
So, if you select {'"Deactivated accounts"'} and
{'"Accounts in appealed state"'}, all accounts that are either
deactivated OR in appealed state will be selected. <br />
<br />
You can use {'||'} separator in the keyword filter to look for
multiple keywords in either {"user's"} profile bio or record
content
</p>
<div className="flex flex-row mt-2 gap-2">
<ActionButton
size="xs"
appearance="outlined"
onClick={() => {
toggleFilteredItems()
// close()
}}
>
<span className="text-xs">Select Filtered</span>
Expand All @@ -235,6 +287,15 @@ export const WorkspaceFilterSelector = ({
>
<span className="text-xs">Unselect Filtered</span>
</ActionButton>
<ActionButton
size="xs"
appearance="outlined"
onClick={() => {
unselectAll()
}}
>
<span className="text-xs">Unselect All</span>
</ActionButton>
<ActionButton
size="xs"
appearance="outlined"
Expand Down
25 changes: 23 additions & 2 deletions components/workspace/ItemCreator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,16 @@ import { PlusIcon, XMarkIcon } from '@heroicons/react/20/solid'
import { toast } from 'react-toastify'
import { buildItemsSummary, groupSubjects } from './utils'
import { getDidFromHandleInBatch } from '@/lib/identity'
import { ArrowPathIcon } from '@heroicons/react/24/solid'
import { ArrowPathIcon, PaperClipIcon } from '@heroicons/react/24/solid'

interface WorkspaceItemCreatorProps {
onFileUploadClick: () => void
onCancel?: () => void
size?: 'sm' | 'lg'
}

const WorkspaceItemCreator: React.FC<WorkspaceItemCreatorProps> = ({
onFileUploadClick,
onCancel,
size = 'lg',
}) => {
Expand All @@ -30,6 +32,12 @@ const WorkspaceItemCreator: React.FC<WorkspaceItemCreatorProps> = ({
try {
const formData = new FormData(event.currentTarget)
const items = formData.get('items') as string

if (!items) {
setIsAdding(false)
return false
}

const isDid = (item) => item.startsWith('did:')
const isAtUri = (item) => item.startsWith('at://')
// if it's not did or at-uri but contains .s it's possibly a handle
Expand Down Expand Up @@ -77,7 +85,6 @@ const WorkspaceItemCreator: React.FC<WorkspaceItemCreatorProps> = ({
setIsAdding(false)
return false
} catch (error) {
console.error(error)
setIsAdding(false)
toast.error(
`Failed to add items to workspace. ${(error as Error).message}`,
Expand Down Expand Up @@ -115,6 +122,20 @@ const WorkspaceItemCreator: React.FC<WorkspaceItemCreatorProps> = ({
<PlusIcon className={size === 'lg' ? 'h-5 w-5' : 'h-3 w-3'} />
)}
</ActionButton>
<ActionButton
type="button"
appearance="outlined"
size={size}
onClick={onFileUploadClick}
disabled={isAdding}
title="Import from csv/json file with DIDs/AT-URIs"
>
{isAdding ? (
<ArrowPathIcon className={size === 'lg' ? 'h-5 w-5' : 'h-3 w-3'} />
) : (
<PaperClipIcon className={size === 'lg' ? 'h-5 w-5' : 'h-3 w-3'} />
)}
</ActionButton>
{!!onCancel && (
<ActionButton
type="button"
Expand Down
Loading

0 comments on commit 325581a

Please sign in to comment.