diff --git a/client/src/components/AuditAdmin/__snapshots__/ActivityLog.test.tsx.snap b/client/src/components/AuditAdmin/__snapshots__/ActivityLog.test.tsx.snap index c4b2c1625..2fbc5a01d 100644 --- a/client/src/components/AuditAdmin/__snapshots__/ActivityLog.test.tsx.snap +++ b/client/src/components/AuditAdmin/__snapshots__/ActivityLog.test.tsx.snap @@ -2,7 +2,7 @@ exports[`Activity Log shows a table of activity for the org 1`] = ` diff --git a/client/src/components/AuditBoard/__snapshots__/AuditBoardView.test.tsx.snap b/client/src/components/AuditBoard/__snapshots__/AuditBoardView.test.tsx.snap index b25800acf..d258bb633 100644 --- a/client/src/components/AuditBoard/__snapshots__/AuditBoardView.test.tsx.snap +++ b/client/src/components/AuditBoard/__snapshots__/AuditBoardView.test.tsx.snap @@ -597,7 +597,7 @@ exports[`AuditBoardView ballot interaction renders board table with ballots 1`] class="sc-GMQeP FVNiK" >
@@ -2878,7 +2878,7 @@ exports[`AuditBoardView ballot interaction renders board table with no audited b class="sc-GMQeP FVNiK" >
@@ -4781,7 +4781,7 @@ exports[`AuditBoardView ballot interaction renders board table with no ballots 1 class="sc-GMQeP FVNiK" >
@@ -5105,7 +5105,7 @@ exports[`AuditBoardView member form submits, goes to ballot table, and header sh class="sc-GMQeP FVNiK" >
diff --git a/client/src/components/SupportTools/Audit.tsx b/client/src/components/SupportTools/Audit.tsx new file mode 100644 index 000000000..d480a58e2 --- /dev/null +++ b/client/src/components/SupportTools/Audit.tsx @@ -0,0 +1,109 @@ +import React from 'react' +import { Link } from 'react-router-dom' +import { H2, AnchorButton, Tag, H4 } from '@blueprintjs/core' +import { useElection, IElection } from './support-api' +import RoundsTable from './RoundsTable' +import { List, LinkItem } from './List' +import Breadcrumbs from './Breadcrumbs' +import { Column, Row } from './shared' + +const prettyAuditType = (auditType: IElection['auditType']) => + ({ + BALLOT_POLLING: 'Ballot Polling', + BALLOT_COMPARISON: 'Ballot Comparison', + BATCH_COMPARISON: 'Batch Comparison', + HYBRID: 'Hybrid', + }[auditType]) + +const Audit = ({ electionId }: { electionId: string }) => { + const election = useElection(electionId) + + if (!election.isSuccess) return null + + const { + id, + auditName, + auditType, + organization, + jurisdictions, + rounds, + } = election.data + + return ( +
+ + + + {organization.name} + + + + +
+
+

{auditName}

+ {prettyAuditType(auditType)} +
+ + Log in as audit admin + +
+
+ + +

Jurisdictions

+ + {jurisdictions.map(jurisdiction => ( + + {jurisdiction.name} + e.stopPropagation()} + > + Log in + + + ))} + +
+ +

Rounds

+
+
+ +
+
+
+
+ ) +} + +export default Audit diff --git a/client/src/components/SupportTools/Jurisdiction.tsx b/client/src/components/SupportTools/Jurisdiction.tsx new file mode 100644 index 000000000..34ef5a774 --- /dev/null +++ b/client/src/components/SupportTools/Jurisdiction.tsx @@ -0,0 +1,321 @@ +import React from 'react' +import { Link } from 'react-router-dom' +import { toast } from 'react-toastify' +import styled from 'styled-components' +import { + H4, + Button, + Classes, + H2, + AnchorButton, + Intent, + Card, + MenuItem, +} from '@blueprintjs/core' +import { MultiSelect } from '@blueprintjs/select' +import { useForm, Controller } from 'react-hook-form' +import { + useJurisdiction, + useClearAuditBoards, + useClearOfflineResults, + useJurisdictionBatches, + useCreateCombinedBatch, + useDeleteCombinedBatch, +} from './support-api' +import { useConfirm, Confirm } from '../Atoms/Confirm' +import AuditBoardsTable from '../AuditAdmin/Progress/AuditBoardsTable' +import Breadcrumbs from './Breadcrumbs' +import { Column, Row, Table } from './shared' + +const CombinedBatchForm = styled.form` + display: flex; + flex-direction: column; + gap: 1rem; + + label { + font-weight: 500; + display: block; + margin-bottom: 0.25rem; + } +` + +const Jurisdiction = ({ jurisdictionId }: { jurisdictionId: string }) => { + const jurisdiction = useJurisdiction(jurisdictionId) + const clearAuditBoards = useClearAuditBoards() + const clearOfflineResults = useClearOfflineResults() + const batches = useJurisdictionBatches(jurisdictionId) + const createCombinedBatch = useCreateCombinedBatch() + const deleteCombinedBatch = useDeleteCombinedBatch() + const { confirm, confirmProps } = useConfirm() + + const { register, handleSubmit, reset, control, formState } = useForm<{ + name: string + subBatchIds: string[] + }>() + + if (!jurisdiction.isSuccess) return null + + const { + name, + organization, + election, + jurisdictionAdmins, + auditBoards, + recordedResultsAt, + } = jurisdiction.data + + const onClickClearAuditBoards = () => { + confirm({ + title: 'Confirm', + description: `Are you sure you want to clear the audit boards for ${name}?`, + yesButtonLabel: 'Clear audit boards', + onYesClick: async () => { + await clearAuditBoards.mutateAsync({ jurisdictionId }) + toast.success(`Cleared audit boards for ${name}`) + }, + }) + } + + const onClickClearOfflineResults = () => { + confirm({ + title: 'Confirm', + description: `Are you sure you want to clear results for ${name}?`, + yesButtonLabel: 'Clear results', + onYesClick: async () => { + await clearOfflineResults.mutateAsync({ + jurisdictionId, + }) + toast.success(`Cleared results for ${name}`) + }, + }) + } + + const onSubmitCreateCombinedBatch = async ({ + // eslint-disable-next-line no-shadow + name, + subBatchIds, + }: { + name: string + subBatchIds: string[] + }) => { + try { + await createCombinedBatch.mutateAsync({ + jurisdictionId, + name, + subBatchIds, + }) + reset() + } catch (error) { + // Do nothing - errors toasted by queryClient + } + } + + return ( +
+ + {organization.name} + {election.auditName} + +

{name}

+ + + {election.auditType !== 'BATCH_COMPARISON' && ( + <> +

Current Round Audit Boards

+ {auditBoards.length === 0 ? ( +

The jurisdiction hasn't created audit boards yet.

+ ) : ( + <> + + + + )} + + )} + {election.auditType === 'BALLOT_POLLING' && !election.online && ( + <> +

Offline Results

+ {recordedResultsAt ? ( + <> +

+ Results recorded at{' '} + {new Date(recordedResultsAt).toLocaleString()}. +

+ + + ) : ( +

No results recorded yet.

+ )} + + )} + {election.auditType === 'BATCH_COMPARISON' && batches.isSuccess && ( + <> +

Combined Batches

+ + +
+ + +
+
+ + void + }) => ( + + value.includes(batch.id) + )} + onItemSelect={item => { + onChange( + value.includes(item.id) + ? value.filter(id => id !== item.id) + : [...value, item.id] + ) + }} + onRemove={item => { + onChange( + value.filter((id: string) => id !== item.id) + ) + }} + itemRenderer={(item, { handleClick, modifiers }) => ( + + )} + tagRenderer={item => item.name} + itemPredicate={(query, item) => + item.name + .toLowerCase() + .includes(query.toLowerCase()) + } + placeholder="Select batches..." + resetOnSelect + fill + popoverProps={{ minimal: true }} + tagInputProps={{ tagProps: { minimal: true } }} + /> + )} + /> +
+ +
+
+ {batches.data.combinedBatches.length > 0 && ( +
+ + + + + + + + {batches.data.combinedBatches.map(combinedBatch => ( + + + + + + ))} + +
NameBatches +
{combinedBatch.name} + {combinedBatch.subBatches + .map(subBatch => subBatch.name) + .join(', ')} + + +
+ )} + + )} + + +

Jurisdiction Admins

+ + + {jurisdictionAdmins.map(jurisdictionAdmin => ( + + + + + ))} + +
{jurisdictionAdmin.email} + + Log in as + +
+
+ + + + ) +} + +export default Jurisdiction diff --git a/client/src/components/SupportTools/Organization.tsx b/client/src/components/SupportTools/Organization.tsx new file mode 100644 index 000000000..c87709aa1 --- /dev/null +++ b/client/src/components/SupportTools/Organization.tsx @@ -0,0 +1,285 @@ +import React from 'react' +import { toast } from 'react-toastify' +import { + Button, + Classes, + H2, + AnchorButton, + Intent, + HTMLSelect, + Tag, + H4, +} from '@blueprintjs/core' +import { useForm } from 'react-hook-form' +import { + useOrganization, + useCreateAuditAdmin, + IAuditAdmin, + useDeleteOrganization, + useUpdateOrganization, + useRemoveAuditAdmin, + useDeleteElection, + IElectionBase, +} from './support-api' +import { useConfirm, Confirm } from '../Atoms/Confirm' +import { List, LinkItem } from './List' +import { stateOptions, states } from '../AuditAdmin/Setup/Settings/states' +import { sortBy } from '../../utils/array' +import { AuditStatusTag, Column, Row, Table } from './shared' + +const Organization = ({ organizationId }: { organizationId: string }) => { + const organization = useOrganization(organizationId) + const createAuditAdmin = useCreateAuditAdmin(organizationId) + const removeAuditAdmin = useRemoveAuditAdmin(organizationId) + const deleteOrganization = useDeleteOrganization(organizationId) + const updateOrganization = useUpdateOrganization(organizationId) + const deleteElection = useDeleteElection() + const { confirm, confirmProps } = useConfirm() + + const { + register: registerCreateAdmin, + handleSubmit: handleSubmitCreateAdmin, + reset: resetCreateAdmin, + formState: formStateCreateAdmin, + } = useForm() + const { + register: registerEditOrg, + handleSubmit: handleSubmitEditOrg, + } = useForm<{ name: string; defaultState?: string | null }>() + + if (!organization.isSuccess) return null + + const onSubmitCreateAuditAdmin = async (auditAdmin: IAuditAdmin) => { + try { + await createAuditAdmin.mutateAsync(auditAdmin) + resetCreateAdmin() + toast.success(`Created audit admin: ${auditAdmin.email}`) + } catch (error) { + // Do nothing - errors toasted by queryClient + } + } + + const { name, defaultState, elections, auditAdmins } = organization.data + + const sortedElections = sortBy(elections, a => + new Date(a.createdAt).getTime() + ).reverse() + + const onClickRemoveAuditAdmin = (auditAdmin: IAuditAdmin) => + confirm({ + title: 'Confirm', + description: `Are you sure you want to remove audit admin ${auditAdmin.email} from organization ${name}?`, + yesButtonLabel: 'Remove', + onYesClick: async () => { + await removeAuditAdmin.mutateAsync({ auditAdminId: auditAdmin.id }) + toast.success(`Removed audit admin ${auditAdmin.email}`) + }, + }) + + const onClickDeleteOrg = () => + confirm({ + title: 'Confirm', + description: `Are you sure you want to delete organization ${name}?`, + yesButtonLabel: 'Delete', + onYesClick: async () => { + await deleteOrganization.mutateAsync() + toast.success(`Deleted organization ${name}`) + }, + }) + + const onClickEditOrg = () => + confirm({ + title: 'Edit Organization', + description: ( +
+ + + + + + {stateOptions.map(({ value, label }) => ( + + ))} + +
+ ), + yesButtonLabel: 'Submit', + onYesClick: handleSubmitEditOrg(async values => { + await updateOrganization.mutateAsync({ + name: values.name, + defaultState: values.defaultState || null, + }) + }), + }) + + const onClickPermanentlyDeleteAudit = ({ auditName, id }: IElectionBase) => { + confirm({ + title: 'Confirm', + description: `Are you sure you want to permanently delete ${auditName}?`, + yesButtonLabel: 'Delete', + onYesClick: async () => { + await deleteElection.mutateAsync({ electionId: id, organizationId }) + toast.success(`Deleted ${auditName}`) + }, + }) + } + + return ( +
+
+

{name}

+
+ + +
+
+ + {`Default State: ${defaultState ? states[defaultState] : 'None'}`} + + + +

Audits

+ + {sortedElections + .filter(election => !election.deletedAt) + .map(election => { + return ( + + {election.auditName} + + + ) + })} + +

Deleted Audits

+ + + {elections + .filter(election => election.deletedAt) + .map(election => ( + + + + + ))} + +
{election.auditName} + +
+
+ +

Audit Admins

+
+ + +
+ + + {auditAdmins.map(auditAdmin => ( + + + + + ))} + +
{auditAdmin.email} + + Log in as + + +
+
+
+ +
+ ) +} + +export default Organization diff --git a/client/src/components/SupportTools/SupportTools.tsx b/client/src/components/SupportTools/SupportTools.tsx index dd227b63c..fa830882f 100644 --- a/client/src/components/SupportTools/SupportTools.tsx +++ b/client/src/components/SupportTools/SupportTools.tsx @@ -1,74 +1,23 @@ import React, { useState } from 'react' -import { Redirect, Route, Switch, Link } from 'react-router-dom' +import { Redirect, Route, Switch } from 'react-router-dom' import { toast } from 'react-toastify' import styled from 'styled-components' -import { - H3, - Button, - HTMLTable, - Classes, - H2, - AnchorButton, - Tag, - Intent, - HTMLSelect, - Card, - MenuItem, - Tooltip, -} from '@blueprintjs/core' -import { MultiSelect } from '@blueprintjs/select' -import { useForm, Controller } from 'react-hook-form' +import { H3, Button, Classes, AnchorButton, Tooltip } from '@blueprintjs/core' +import { useForm } from 'react-hook-form' import { useAuthDataContext } from '../UserContext' import { Wrapper, SupportToolsInner } from '../Atoms/Wrapper' import { useOrganizations, - useOrganization, - useCreateAuditAdmin, - useElection, - IAuditAdmin, - IElection, useCreateOrganization, - useJurisdiction, - useClearAuditBoards, - useClearOfflineResults, - useDeleteOrganization, - useUpdateOrganization, - useRemoveAuditAdmin, - useDeleteElection, - IElectionBase, useActiveElections, - useJurisdictionBatches, - useCreateCombinedBatch, - useDeleteCombinedBatch, - IRound, } from './support-api' -import { useConfirm, Confirm } from '../Atoms/Confirm' -import AuditBoardsTable from '../AuditAdmin/Progress/AuditBoardsTable' -import RoundsTable from './RoundsTable' import { List, LinkItem } from './List' -import Breadcrumbs from './Breadcrumbs' -import { stateOptions, states } from '../AuditAdmin/Setup/Settings/states' -import StatusTag from '../Atoms/StatusTag' -import { sortBy } from '../../utils/array' +import Audit from './Audit' +import Organization from './Organization' +import Jurisdiction from './Jurisdiction' +import { AuditStatusTag, Row } from './shared' import { FilterInput } from '../Atoms/Table' -const Table = styled(HTMLTable)` - margin: 10px 0; - width: 100%; - table-layout: fixed; - td:first-child { - overflow: hidden; - text-overflow: ellipsis; - } - td:last-child:not(:first-child) { - padding-right: 15px; - text-align: right; - } - tr td { - vertical-align: baseline; - } -` - const SupportTools: React.FC = () => { const auth = useAuthDataContext() if (!auth) return null // Still loading @@ -92,15 +41,17 @@ const SupportTools: React.FC = () => { - {({ match }) => ( + {({ match }: any) => ( )} - {({ match }) => } + {({ match }: any) => ( + + )} - {({ match }) => ( + {({ match }: any) => ( )} @@ -110,31 +61,6 @@ const SupportTools: React.FC = () => { ) } -const Column = styled.div` - width: 50%; - padding-right: 30px; -` - -const Row = styled.div` - display: flex; - gap: 30px; - width: 100%; -` - -const AuditStatusTag = ({ currentRound }: { currentRound: IRound | null }) => { - if (!currentRound) { - return Not Started - } - if (currentRound.endedAt) { - return Completed - } - return ( - - Round {currentRound.roundNum} In Progress - - ) -} - const ActiveAudits = () => { const elections = useActiveElections() @@ -237,10 +163,7 @@ const Tools = () => { }} >
- - -

Default State: {defaultState ? states[defaultState] : 'None'}

-
- -

Audits

- - {sortedElections - .filter(election => !election.deletedAt) - .map(election => { - return ( - - {election.auditName} - - - ) - })} - -

Deleted Audits

- - - {elections - .filter(election => election.deletedAt) - .map(election => ( - - - - - ))} - -
{election.auditName} - -
-
- -

Audit Admins

-
- - -
- - - {auditAdmins.map(auditAdmin => ( - - - - - ))} - -
{auditAdmin.email} - - Log in as - - -
-
-
- - - ) -} - -const prettyAuditType = (auditType: IElection['auditType']) => - ({ - BALLOT_POLLING: 'Ballot Polling', - BALLOT_COMPARISON: 'Ballot Comparison', - BATCH_COMPARISON: 'Batch Comparison', - HYBRID: 'Hybrid', - }[auditType]) - -const Audit = ({ electionId }: { electionId: string }) => { - const election = useElection(electionId) - - if (!election.isSuccess) return null - - const { - id, - auditName, - auditType, - organization, - jurisdictions, - rounds, - } = election.data - - return ( -
- - {organization.name} - -

{auditName}

- -
- - {prettyAuditType(auditType)} - - - Log in as audit admin - -
-
- -
-

Jurisdictions

- - {jurisdictions.map(jurisdiction => ( - - {jurisdiction.name} - e.stopPropagation()} - > - Log in - - - ))} - -
-
- ) -} - -const CombinedBatchForm = styled.form` - display: flex; - flex-direction: column; - gap: 1rem; - - label { - font-weight: 500; - display: block; - margin-bottom: 0.25rem; - } -` - -const Jurisdiction = ({ jurisdictionId }: { jurisdictionId: string }) => { - const jurisdiction = useJurisdiction(jurisdictionId) - const clearAuditBoards = useClearAuditBoards() - const clearOfflineResults = useClearOfflineResults() - const batches = useJurisdictionBatches(jurisdictionId) - const createCombinedBatch = useCreateCombinedBatch() - const deleteCombinedBatch = useDeleteCombinedBatch() - const { confirm, confirmProps } = useConfirm() - - const { register, handleSubmit, reset, control, formState } = useForm<{ - name: string - subBatchIds: string[] - }>() - - if (!jurisdiction.isSuccess) return null - - const { - name, - organization, - election, - jurisdictionAdmins, - auditBoards, - recordedResultsAt, - } = jurisdiction.data - - const onClickClearAuditBoards = () => { - confirm({ - title: 'Confirm', - description: `Are you sure you want to clear the audit boards for ${name}?`, - yesButtonLabel: 'Clear audit boards', - onYesClick: async () => { - await clearAuditBoards.mutateAsync({ jurisdictionId }) - toast.success(`Cleared audit boards for ${name}`) - }, - }) - } - - const onClickClearOfflineResults = () => { - confirm({ - title: 'Confirm', - description: `Are you sure you want to clear results for ${name}?`, - yesButtonLabel: 'Clear results', - onYesClick: async () => { - await clearOfflineResults.mutateAsync({ - jurisdictionId, - }) - toast.success(`Cleared results for ${name}`) - }, - }) - } - - const onSubmitCreateCombinedBatch = async ({ - // eslint-disable-next-line no-shadow - name, - subBatchIds, - }: { - name: string - subBatchIds: string[] - }) => { - try { - await createCombinedBatch.mutateAsync({ - jurisdictionId, - name, - subBatchIds, - }) - reset() - } catch (error) { - // Do nothing - errors toasted by queryClient - } - } - - return ( -
- - {organization.name} - {election.auditName} - -

{name}

-
- - {election.auditType !== 'BATCH_COMPARISON' && ( - <> -

Current Round Audit Boards

- {auditBoards.length === 0 ? ( -

The jurisdiction hasn't created audit boards yet.

- ) : ( - <> - - - - )} - - )} - {election.auditType === 'BALLOT_POLLING' && !election.online && ( - <> -

Offline Results

- {recordedResultsAt ? ( - <> -

- Results recorded at{' '} - {new Date(recordedResultsAt).toLocaleString()}. -

- - - ) : ( -

No results recorded yet.

- )} - - )} - {election.auditType === 'BATCH_COMPARISON' && batches.isSuccess && ( - <> -

Combined Batches

- - -
- - -
-
- - void - }) => ( - - value.includes(batch.id) - )} - onItemSelect={item => { - onChange( - value.includes(item.id) - ? value.filter(id => id !== item.id) - : [...value, item.id] - ) - }} - onRemove={item => { - onChange( - value.filter((id: string) => id !== item.id) - ) - }} - itemRenderer={(item, { handleClick, modifiers }) => ( - - )} - tagRenderer={item => item.name} - itemPredicate={(query, item) => - item.name - .toLowerCase() - .includes(query.toLowerCase()) - } - placeholder="Select batches..." - resetOnSelect - fill - popoverProps={{ minimal: true }} - tagInputProps={{ tagProps: { minimal: true } }} - /> - )} - /> -
- -
-
- {batches.data.combinedBatches.length > 0 && ( - - - - - - - - - {batches.data.combinedBatches.map(combinedBatch => ( - - - - - - ))} - -
NameBatches -
{combinedBatch.name} - {combinedBatch.subBatches - .map(subBatch => subBatch.name) - .join(', ')} - - -
- )} - - )} -
- -

Jurisdiction Admins

- - - {jurisdictionAdmins.map(jurisdictionAdmin => ( - - - - - ))} - -
{jurisdictionAdmin.email} - - Log in as - -
-
- -
-
- ) -} - export default SupportTools diff --git a/client/src/components/SupportTools/shared.tsx b/client/src/components/SupportTools/shared.tsx new file mode 100644 index 000000000..ad380275c --- /dev/null +++ b/client/src/components/SupportTools/shared.tsx @@ -0,0 +1,55 @@ +import React from 'react' + +import { HTMLTable } from '@blueprintjs/core' +import styled from 'styled-components' +import StatusTag from '../Atoms/StatusTag' +import { IRound } from './support-api' + +interface ColumnProps { + isLast?: boolean +} + +export const Column = styled.div` + width: 50%; +` + +export const Row = styled.div` + display: flex; + gap: 30px; + width: 100%; +` + +export const Table = styled(HTMLTable)` + margin: 10px 0; + width: 100%; + table-layout: fixed; + td:first-child { + overflow: hidden; + text-overflow: ellipsis; + } + td:last-child:not(:first-child) { + padding-right: 15px; + text-align: right; + } + tr td { + vertical-align: baseline; + } +` + +export const AuditStatusTag = ({ + currentRound, +}: { + currentRound: IRound | null +}) => { + if (!currentRound) { + return Not Started + } + if (currentRound.endedAt) { + return Completed + } + return ( + + Round {currentRound.roundNum} In Progress + + ) +}