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
41 changes: 39 additions & 2 deletions frontend-v2/src/app/(authed)/activists/[id]/activist-detail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import Link from 'next/link'
import { ArrowLeft, Pencil } from 'lucide-react'
import { ArrowLeft, EyeOff, GitMerge, Pencil } from 'lucide-react'
import {
API_PATH,
apiClient,
Expand All @@ -22,6 +22,8 @@ import { FieldDescriptionPopover } from '../field-description-popover'
import { formatValue } from '../format-value'
import { LinkedValue } from '../linked-value'
import { ActivistSectionForm } from './section-form'
import { HideActivistDialog } from './hide-activist-dialog'
import { MergeActivistDialog } from './merge-activist-dialog'

const NOTES_SECTION_KEY = '__notes__'
type SectionKey = ColumnCategory | typeof NOTES_SECTION_KEY
Expand Down Expand Up @@ -67,6 +69,8 @@ export function ActivistDetail({ activistId }: { activistId: number }) {
const { data: activist, isError, isLoading } = useActivist(activistId)
const [editingSection, setEditingSection] = useState<SectionKey | null>(null)
const [isFormDirty, setIsFormDirty] = useState(false)
const [isHideDialogOpen, setIsHideDialogOpen] = useState(false)
const [isMergeDialogOpen, setIsMergeDialogOpen] = useState(false)

const confirmDiscard = useCallback(() => {
if (!isFormDirty) return true
Expand Down Expand Up @@ -145,16 +149,49 @@ export function ActivistDetail({ activistId }: { activistId: number }) {
</Link>
</div>

<div className="flex flex-col gap-1">
<div className="flex flex-wrap items-center justify-between gap-3">
<h1
className={`text-3xl font-bold ${
displayName.isPlaceholder ? 'italic text-muted-foreground' : ''
}`}
>
{displayName.text}
</h1>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setIsMergeDialogOpen(true)}
>
<GitMerge className="h-4 w-4" />
Merge
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setIsHideDialogOpen(true)}
>
<EyeOff className="h-4 w-4" />
Hide
</Button>
Comment thread
alexsapps marked this conversation as resolved.
</div>
</div>

<HideActivistDialog
open={isHideDialogOpen}
onOpenChange={setIsHideDialogOpen}
activistId={activistId}
activistName={displayName.text ?? ''}
/>
<MergeActivistDialog
open={isMergeDialogOpen}
onOpenChange={setIsMergeDialogOpen}
activistId={activistId}
activistName={displayName.text ?? ''}
/>

<div className="flex flex-col gap-8">
{SECTION_ORDER.map((category) => {
const fields = groupedFields.get(category) ?? []
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
'use client'

import { useRouter } from 'next/navigation'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import toast from 'react-hot-toast'
import { API_PATH, apiClient } from '@/lib/api'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'

type Props = {
open: boolean
onOpenChange: (open: boolean) => void
activistId: number
activistName: string
}

export function HideActivistDialog({
open,
onOpenChange,
activistId,
activistName,
}: Props) {
const router = useRouter()
const queryClient = useQueryClient()

const mutation = useMutation({
mutationFn: () => apiClient.hideActivist(activistId),
onSuccess: () => {
toast.success(`${activistName} was hidden`)
queryClient.invalidateQueries({ queryKey: [API_PATH.ACTIVISTS_SEARCH] })
queryClient.invalidateQueries({
queryKey: [API_PATH.ACTIVIST_LIST_BASIC],
})
onOpenChange(false)
router.push('/activists')
},
onError: (err: Error) => {
toast.error(err.message || 'Failed to hide activist')
},
})

const handleOpenChange = (next: boolean) => {
if (mutation.isPending) return
onOpenChange(next)
}

return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Hide activist</DialogTitle>
</DialogHeader>
<p className="text-sm">
WARNING: Hiding this activist will make them inaccessible unless they
are unhidden by Tech. Are you sure you want to hide this activist?
</p>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => handleOpenChange(false)}
disabled={mutation.isPending}
>
Cancel
</Button>
<Button
type="button"
variant="destructive"
onClick={() => mutation.mutate()}
disabled={mutation.isPending}
>
{mutation.isPending ? 'Hiding...' : 'Hide activist'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
'use client'

import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import toast from 'react-hot-toast'
import { API_PATH, apiClient } from '@/lib/api'
import { useAuthedPageContext } from '@/hooks/useAuthedPageContext'
import { useActivistRegistry } from '../../events/useActivistRegistry'
import { SuggestionInput } from '../../events/suggestion-input'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'

type Props = {
open: boolean
onOpenChange: (open: boolean) => void
activistId: number
activistName: string
}

export function MergeActivistDialog({
open,
onOpenChange,
activistId,
activistName,
}: Props) {
const router = useRouter()
const queryClient = useQueryClient()
const { user } = useAuthedPageContext()
const { registry } = useActivistRegistry(user.ChapterID)

// Raw text in the input as the user types.
const [inputValue, setInputValue] = useState('')
// Name of an activist explicitly selected from the suggestion list.
const [selectedValue, setSelectedValue] = useState('')

const mutation = useMutation({
mutationFn: (targetName: string) =>
apiClient.mergeActivist(activistId, targetName),
Comment thread
alexsapps marked this conversation as resolved.
onSuccess: (_data, targetName) => {
toast.success(`${activistName} was merged into ${targetName}`)
queryClient.invalidateQueries({ queryKey: [API_PATH.ACTIVISTS_SEARCH] })
queryClient.invalidateQueries({
queryKey: [API_PATH.ACTIVIST_LIST_BASIC],
})
onOpenChange(false)
router.push('/activists')
},
onError: (err: Error) => {
toast.error(err.message || 'Failed to merge activist')
},
})

const handleOpenChange = (next: boolean) => {
if (mutation.isPending) return
if (!next) {
setInputValue('')
setSelectedValue('')
}
onOpenChange(next)
}

const getSuggestions = (input: string) =>
registry.getSuggestions(input).filter((name) => name !== activistName)

const isTargetValid =
selectedValue.trim().length > 0 &&
// Cannot merge activist with self. (Names are unique within chapter, and
// backend endpoint will not allow merging across chapters.)
selectedValue !== activistName &&
registry.getActivist(selectedValue) !== null

const handleSubmit = () => {
if (!isTargetValid) {
toast.error('Choose an activist to merge into')
return
}
mutation.mutate(selectedValue)
}

return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Merge activist</DialogTitle>
</DialogHeader>
<div className="text-sm space-y-2">
<p>
Merging activists is used to combine redundant activist entries.
</p>
<p>Merging this activist does the following:</p>
<ul className="list-disc pl-5 space-y-1">
<li>
All of {activistName}&apos;s event attendance will be moved to the
target activist.
</li>
<li>
The target&apos;s name will be kept &mdash; {activistName}&apos;s
name will not replace it.
</li>
<li>
{activistName}&apos;s email, phone, and address / location will
replace the corresponding fields in the target{' '}
<em>if updated more recently</em>.
</li>
<li>
{activistName}&apos;s pronouns, language, notes, and most other
fields will each replace the corresponding field in the target{' '}
<em>only if the target&apos;s value is blank</em>.
</li>
<li>
Yes / no flags (e.g. MPI, hiatus, voting agreement) will be set to
&ldquo;yes&rdquo; <em>if either activist has them set</em>.
</li>
<li>
The target&apos;s activist level will be set to the{' '}
<em>higher of the two activists&apos; levels</em>.
</li>
<li>{activistName} will be hidden.</li>
</ul>
<p className="font-semibold pt-2">
Merge {activistName} into another activist:
</p>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="merge-target">Target activist</Label>
<SuggestionInput
id="merge-target"
value={inputValue}
onValueChange={(v) => {
setInputValue(v)
setSelectedValue('')
}}
getSuggestions={getSuggestions}
onCommit={(meta) => {
if (meta.fromSuggestion) {
setSelectedValue(meta.value)
}
}}
placeholder="Start typing a name..."
/>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => handleOpenChange(false)}
disabled={mutation.isPending}
>
Cancel
</Button>
<Button
type="button"
variant="destructive"
onClick={handleSubmit}
disabled={!isTargetValid || mutation.isPending}
>
{mutation.isPending ? 'Merging...' : 'Merge activist'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
Loading
Loading