Merge branch 'main' into dependabot/npm_and_yarn/elliptic-6.5.3
This commit is contained in:
Коммит
9a947ee2be
11
src/App.tsx
11
src/App.tsx
|
@ -1,21 +1,20 @@
|
|||
import React from 'react';
|
||||
import { Switch, Route, Redirect } from 'react-router-dom';
|
||||
import { Switch, Route } from 'react-router-dom';
|
||||
import 'normalize.css';
|
||||
|
||||
import Layout from './components/Layout';
|
||||
import NotFoundPage from './pages/NotFoundPage';
|
||||
import ElectionPage from './pages/ElectionPage';
|
||||
import HomePage from './pages/HomePage';
|
||||
|
||||
import './App.css';
|
||||
import ElectionPage from './pages/ElectionPage';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Layout>
|
||||
<Switch>
|
||||
<Route path="/" exact>
|
||||
<Redirect to="/election" />
|
||||
</Route>
|
||||
<Route path="/election" component={ElectionPage} />
|
||||
<Route path="/" exact component={HomePage} />
|
||||
<Route path="/:electionId" component={ElectionPage} />
|
||||
<Route component={NotFoundPage} />
|
||||
</Switch>
|
||||
</Layout>
|
||||
|
|
|
@ -0,0 +1,23 @@
|
|||
import React from 'react';
|
||||
import { Text } from '@fluentui/react';
|
||||
import moment from 'moment';
|
||||
import { useTheme } from '@fluentui/react-theme-provider';
|
||||
|
||||
// TODO #?? Resolve internalization of dates using i18n
|
||||
const dateFormat = 'MM/DD/YYYY h:mm a';
|
||||
|
||||
export interface ElectionPlaceholderMessageProps {
|
||||
endDate: string;
|
||||
}
|
||||
|
||||
const ElectionPlaceholderMessage: React.FunctionComponent<ElectionPlaceholderMessageProps> = ({ endDate }) => {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<Text variant="large" styles={{ root: { color: theme.palette.neutralSecondary } }}>
|
||||
The election is not finished at this time. Please come back to check for results after
|
||||
{' ' + moment(endDate).format(dateFormat)}.
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
export default ElectionPlaceholderMessage;
|
|
@ -4,12 +4,8 @@ import { Story, Meta } from '@storybook/react/types-6-0';
|
|||
import ElectionResults from './ElectionResults';
|
||||
|
||||
import electionDescription from '../mocks/description.json';
|
||||
import tally from '../mocks/tally.json';
|
||||
import { transformTallyResults } from '../models/electionguard';
|
||||
import { ElectionDescription } from '../models/election';
|
||||
import { getDummyQueryResult, queryArgTypes, QueryStoryArgs } from '../util/queryStory';
|
||||
|
||||
const electionResults = transformTallyResults('fake-election', tally);
|
||||
import { Election } from '../models/election';
|
||||
import { queryArgTypes, QueryStoryArgs } from '../util/queryStory';
|
||||
|
||||
export default {
|
||||
title: 'Components/ElectionResults',
|
||||
|
@ -22,22 +18,14 @@ export default {
|
|||
interface StoryArgs extends QueryStoryArgs {}
|
||||
|
||||
const Template: Story<StoryArgs> = ({ queryState }) => {
|
||||
return (
|
||||
<ElectionResults
|
||||
election={electionDescription as ElectionDescription}
|
||||
electionResultsQuery={getDummyQueryResult(queryState, electionResults)}
|
||||
/>
|
||||
);
|
||||
var election = {
|
||||
id: electionDescription.election_scope_id,
|
||||
election_description: electionDescription,
|
||||
state: 'Published',
|
||||
} as Election;
|
||||
return <ElectionResults election={election} />;
|
||||
};
|
||||
|
||||
export const Success = Template.bind({});
|
||||
Success.storyName = 'Election Results loaded';
|
||||
Success.args = { queryState: 'success' };
|
||||
|
||||
export const Loading = Template.bind({});
|
||||
Loading.storyName = 'Election Results loading';
|
||||
Loading.args = { queryState: 'loading' };
|
||||
|
||||
export const Error = Template.bind({});
|
||||
Error.storyName = 'Election Results failed';
|
||||
Error.args = { queryState: 'error' };
|
||||
Success.args = {};
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import React from 'react';
|
||||
import { QueryResult } from '../data/queries';
|
||||
import { ElectionDescription } from '../models/election';
|
||||
import { useElectionResults } from '../data/queries';
|
||||
import { Election } from '../models/election';
|
||||
import { ElectionResultsSummary } from '../models/tally';
|
||||
import AsyncContent from './AsyncContent';
|
||||
import ContestResults from './ContestResults';
|
||||
|
@ -9,14 +9,15 @@ import LargeCard from './LargeCard';
|
|||
const errorMessage = 'Unable to retrieve election results at this time.';
|
||||
|
||||
export interface ElectionResultsProps {
|
||||
election: ElectionDescription;
|
||||
electionResultsQuery: QueryResult<ElectionResultsSummary>;
|
||||
election: Election;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the results of the election
|
||||
*/
|
||||
const ElectionResults: React.FunctionComponent<ElectionResultsProps> = ({ election, electionResultsQuery }) => {
|
||||
const ElectionResults: React.FunctionComponent<ElectionResultsProps> = ({ election }) => {
|
||||
const electionResultsQuery = useElectionResults(election.id);
|
||||
|
||||
return (
|
||||
<AsyncContent query={electionResultsQuery} errorMessage={errorMessage}>
|
||||
{(results) => {
|
||||
|
@ -28,7 +29,7 @@ const ElectionResults: React.FunctionComponent<ElectionResultsProps> = ({ electi
|
|||
<ContestResults
|
||||
results={contest.results}
|
||||
contest={contest.description!}
|
||||
candidates={election.candidates}
|
||||
candidates={election.election_description.candidates}
|
||||
/>
|
||||
</LargeCard>
|
||||
))}
|
||||
|
@ -39,12 +40,12 @@ const ElectionResults: React.FunctionComponent<ElectionResultsProps> = ({ electi
|
|||
);
|
||||
};
|
||||
|
||||
function getContests(election: ElectionDescription, results: ElectionResultsSummary) {
|
||||
function getContests(election: Election, results: ElectionResultsSummary) {
|
||||
const contests = Object.entries(results.election_results)
|
||||
.map(([contestId, contestResults]) => ({
|
||||
id: contestId,
|
||||
results: contestResults,
|
||||
description: election.contests.find((c) => c.object_id === contestId),
|
||||
description: election.election_description.contests.find((c) => c.object_id === contestId),
|
||||
}))
|
||||
.filter((c) => !!c.description);
|
||||
return contests;
|
||||
|
|
|
@ -7,7 +7,7 @@ import { useTheme } from '@fluentui/react-theme-provider';
|
|||
// TODO #?? Resolve internalization of language using i18n
|
||||
const defaultElectionName = 'Election';
|
||||
// TODO #?? Resolve internalization of dates using i18n
|
||||
const dateFormat = 'MM/DD/YYYY';
|
||||
const dateFormat = 'MM/DD/YYYY h:mm a';
|
||||
|
||||
export interface ElectionTitleProps {
|
||||
electionName?: string;
|
||||
|
@ -21,7 +21,7 @@ const ElectionTitle: React.FunctionComponent<ElectionTitleProps> = ({ electionNa
|
|||
<Title title={electionName ?? defaultElectionName}>
|
||||
<Text as="span" styles={{ root: { color: theme.palette.neutralSecondary } }}>{`${moment(startDate).format(
|
||||
dateFormat
|
||||
)}-${moment(endDate).format(dateFormat)}`}</Text>
|
||||
)} - ${moment(endDate).format(dateFormat)}`}</Text>
|
||||
</Title>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import React from 'react';
|
||||
import { useTheme } from '@fluentui/react-theme-provider';
|
||||
import { Card, CardSection } from '@uifabric/react-cards';
|
||||
|
||||
export interface LargeCardProps {
|
||||
alignToStart?: boolean;
|
||||
|
@ -9,20 +8,23 @@ export interface LargeCardProps {
|
|||
const LargeCard: React.FunctionComponent<LargeCardProps> = ({ alignToStart, children }) => {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<Card
|
||||
styles={{
|
||||
root: {
|
||||
padding: theme.spacing.l1,
|
||||
backgroundColor: theme.palette.white,
|
||||
marginBottom: theme.spacing.l1,
|
||||
alignItems: alignToStart ? 'flex-start' : 'stretch',
|
||||
height: 'auto',
|
||||
maxWidth: 'auto',
|
||||
},
|
||||
<div
|
||||
style={{
|
||||
padding: theme.spacing.l1,
|
||||
backgroundColor: theme.palette.white,
|
||||
marginBottom: theme.spacing.l1,
|
||||
alignItems: alignToStart ? 'flex-start' : 'stretch',
|
||||
height: 'auto',
|
||||
maxWidth: 'auto',
|
||||
borderTopLeftRadius: 2,
|
||||
borderTopRightRadius: 2,
|
||||
borderBottomRightRadius: 2,
|
||||
borderBottomLeftRadius: 2,
|
||||
boxShadow: 'rgba(0, 0, 0, 0.133) 0px 1.6px 3.6px 0px, rgba(0, 0, 0, 0.11) 0px 0.3px 0.9px 0px',
|
||||
}}
|
||||
>
|
||||
<CardSection styles={{ root: { width: '100%' } }}>{children || null}</CardSection>
|
||||
</Card>
|
||||
{children || null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -10,7 +10,7 @@ export default {
|
|||
component: TrackerDialog,
|
||||
} as Meta;
|
||||
|
||||
const Template: Story<TrackerDialogProps> = ({ tracker, confirmed, isLoading }) => {
|
||||
const Template: Story<TrackerDialogProps> = ({ tracker, trackerState }) => {
|
||||
initializeIcons();
|
||||
const [hidden, { toggle: toggleHideDialog }] = useBoolean(true);
|
||||
|
||||
|
@ -18,13 +18,7 @@ const Template: Story<TrackerDialogProps> = ({ tracker, confirmed, isLoading })
|
|||
<>
|
||||
<DefaultButton secondaryText="Opens the Sample Dialog" onClick={toggleHideDialog} text="Open Dialog" />
|
||||
|
||||
<TrackerDialog
|
||||
hidden={hidden}
|
||||
isLoading={isLoading}
|
||||
tracker={tracker}
|
||||
onDismiss={toggleHideDialog}
|
||||
confirmed={confirmed}
|
||||
/>
|
||||
<TrackerDialog hidden={hidden} tracker={tracker} onDismiss={toggleHideDialog} trackerState={trackerState} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -32,32 +26,35 @@ const Template: Story<TrackerDialogProps> = ({ tracker, confirmed, isLoading })
|
|||
export const SuccessTrackerDialog = Template.bind({});
|
||||
SuccessTrackerDialog.storyName = 'Confirmed Tracker Dialog';
|
||||
SuccessTrackerDialog.args = {
|
||||
isLoading: false,
|
||||
tracker: 'confirmed-confirmed-confirmed-confirmed-confirmed',
|
||||
confirmed: true,
|
||||
trackerState: 'confirmed',
|
||||
};
|
||||
|
||||
export const SpoiledTrackerDialog = Template.bind({});
|
||||
SpoiledTrackerDialog.storyName = 'Spoiled Tracker Dialog';
|
||||
SpoiledTrackerDialog.args = {
|
||||
tracker: 'spoiled-spoiled-spoiled-spoiled-spoiled',
|
||||
trackerState: 'spoiled',
|
||||
};
|
||||
|
||||
export const UnknownTrackerDialog = Template.bind({});
|
||||
UnknownTrackerDialog.storyName = 'Unknown Tracker Dialog';
|
||||
UnknownTrackerDialog.args = {
|
||||
isLoading: false,
|
||||
tracker: 'unknown-unknown-unknown-unknown-unknown-unknown',
|
||||
confirmed: false,
|
||||
trackerState: 'unknown',
|
||||
};
|
||||
|
||||
export const LongTrackerDialog = Template.bind({});
|
||||
LongTrackerDialog.storyName = 'Long Tracker Dialog';
|
||||
LongTrackerDialog.args = {
|
||||
isLoading: false,
|
||||
tracker:
|
||||
'long-long-long-long-long-long-long-long-long-long-long-long-long-long-long-long-long-long-long-long-long-long-long',
|
||||
confirmed: true,
|
||||
trackerState: 'confirmed',
|
||||
};
|
||||
|
||||
export const LoadingTrackerDialog = Template.bind({});
|
||||
LoadingTrackerDialog.storyName = 'Loading Dialog';
|
||||
LoadingTrackerDialog.args = {
|
||||
isLoading: true,
|
||||
tracker: '',
|
||||
confirmed: false,
|
||||
trackerState: 'loading',
|
||||
};
|
||||
|
|
|
@ -1,74 +1,44 @@
|
|||
import React from 'react';
|
||||
import { Dialog, DialogType, IDialogContentProps } from 'office-ui-fabric-react/lib/Dialog';
|
||||
import { Dialog, DialogType } from 'office-ui-fabric-react/lib/Dialog';
|
||||
import { useTheme } from '@fluentui/react-theme-provider';
|
||||
import StatusMessage from './StatusMessage';
|
||||
import { Spinner, SpinnerSize } from '@fluentui/react';
|
||||
import { IPalette, Spinner, SpinnerSize } from '@fluentui/react';
|
||||
|
||||
// Text for Internationalization
|
||||
const confirmedTrackerTitle = 'Ballot Confirmed';
|
||||
const confirmedTrackerMessage =
|
||||
'The following ballot was securely submitted and counted as part of the election tally.';
|
||||
const loadingTrackerTitle = 'Searching';
|
||||
const loadingTrackerMessage = '';
|
||||
const unknownTrackerTitle = 'Ballot Not Found';
|
||||
const unknownTrackerMessage = `
|
||||
There was no record found of the following ballot and it is not part of the official tally.
|
||||
Please double check the spelling of the tracker and search again.
|
||||
`;
|
||||
export type TrackerDialogStateOption = 'loading' | 'spoiled' | 'confirmed' | 'unknown';
|
||||
|
||||
// Icons
|
||||
const confirmedIcon = 'Completed';
|
||||
const loadingIcon = 'Search';
|
||||
const unknownIcon = 'Unknown';
|
||||
|
||||
interface TrackerCodeProps {
|
||||
tracker: string;
|
||||
confirmed: boolean;
|
||||
interface TrackerDialogStateSettings {
|
||||
icon: string;
|
||||
color: keyof IPalette;
|
||||
title: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
const TrackerCode: React.FunctionComponent<TrackerCodeProps> = ({ tracker, confirmed }) => {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
borderColor: confirmed ? theme.palette.green : theme.palette.themePrimary,
|
||||
borderStyle: 'solid',
|
||||
borderWidth: 2,
|
||||
padding: 8,
|
||||
}}
|
||||
>
|
||||
{tracker}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const dialogStyles = { main: { maxWidth: 450 } };
|
||||
const confirmedDialogContentProps: IDialogContentProps = {
|
||||
type: DialogType.largeHeader,
|
||||
title: <StatusMessage icon={confirmedIcon} colorName="green" message={confirmedTrackerTitle} />,
|
||||
subText: confirmedTrackerMessage,
|
||||
styles: ({ theme }) => ({
|
||||
content: { borderColor: theme.palette.green },
|
||||
}),
|
||||
};
|
||||
|
||||
const unknownDialogContentProps: IDialogContentProps = {
|
||||
type: DialogType.largeHeader,
|
||||
title: <StatusMessage icon={unknownIcon} colorName="themePrimary" message={unknownTrackerTitle} />,
|
||||
subText: unknownTrackerMessage,
|
||||
styles: ({ theme }) => ({
|
||||
content: { borderColor: theme.palette.themePrimary },
|
||||
}),
|
||||
};
|
||||
|
||||
const loadingDialogContentProps: IDialogContentProps = {
|
||||
type: DialogType.largeHeader,
|
||||
title: <StatusMessage icon={loadingIcon} colorName="neutralPrimary" message={loadingTrackerTitle} />,
|
||||
subText: loadingTrackerMessage,
|
||||
styles: ({ theme }) => ({
|
||||
content: { borderColor: theme.palette.neutralLight },
|
||||
}),
|
||||
const states: { [state in TrackerDialogStateOption]: TrackerDialogStateSettings } = {
|
||||
loading: {
|
||||
icon: 'Search',
|
||||
color: 'neutralPrimary',
|
||||
title: 'Searching',
|
||||
message: '',
|
||||
},
|
||||
confirmed: {
|
||||
icon: 'Completed',
|
||||
color: 'green',
|
||||
title: 'Ballot Confirmed',
|
||||
message: 'The following ballot was securely submitted and counted as part of the election tally.',
|
||||
},
|
||||
spoiled: {
|
||||
icon: 'Error',
|
||||
color: 'redDark',
|
||||
title: 'Ballot Spoiled',
|
||||
message: 'The following ballot was not included in the election tally.',
|
||||
},
|
||||
unknown: {
|
||||
icon: 'Unknown',
|
||||
color: 'themePrimary',
|
||||
title: 'Ballot Not Found',
|
||||
message: `There was no record found of the following ballot and it is not part of the official tally.
|
||||
Please double check the spelling of the tracker and search again.`,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
@ -83,10 +53,8 @@ export interface TrackerDialogProps {
|
|||
onDismissed?: () => void;
|
||||
/** Tracker code */
|
||||
tracker: string;
|
||||
/** Confirmation status of tracker */
|
||||
confirmed: boolean;
|
||||
/** The application is looking for the tracker */
|
||||
isLoading?: boolean;
|
||||
/** The visual state of the dialog */
|
||||
trackerState: TrackerDialogStateOption;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -94,11 +62,10 @@ export interface TrackerDialogProps {
|
|||
*/
|
||||
const TrackerDialog: React.FunctionComponent<TrackerDialogProps> = ({
|
||||
hidden,
|
||||
isLoading = false,
|
||||
onDismiss,
|
||||
onDismissed,
|
||||
tracker,
|
||||
confirmed,
|
||||
trackerState,
|
||||
}) => {
|
||||
const modalProps = React.useMemo(
|
||||
() => ({
|
||||
|
@ -109,17 +76,50 @@ const TrackerDialog: React.FunctionComponent<TrackerDialogProps> = ({
|
|||
[onDismissed]
|
||||
);
|
||||
|
||||
const dialogContentProps = isLoading
|
||||
? loadingDialogContentProps
|
||||
: confirmed
|
||||
? confirmedDialogContentProps
|
||||
: unknownDialogContentProps;
|
||||
const { color, icon, message, title } = states[trackerState];
|
||||
const isLoading = trackerState === 'loading';
|
||||
|
||||
return (
|
||||
<Dialog hidden={hidden} onDismiss={onDismiss} dialogContentProps={dialogContentProps} modalProps={modalProps}>
|
||||
{isLoading ? <Spinner size={SpinnerSize.large} /> : <TrackerCode tracker={tracker} confirmed={confirmed} />}
|
||||
<Dialog
|
||||
hidden={hidden}
|
||||
onDismiss={onDismiss}
|
||||
dialogContentProps={{
|
||||
type: DialogType.largeHeader,
|
||||
title: <StatusMessage icon={icon} colorName={color} message={title} />,
|
||||
subText: message,
|
||||
styles: ({ theme }) => ({
|
||||
content: { borderColor: theme.palette[color] },
|
||||
}),
|
||||
}}
|
||||
modalProps={modalProps}
|
||||
>
|
||||
{isLoading ? <Spinner size={SpinnerSize.large} /> : <TrackerCode tracker={tracker} color={color} />}
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
interface TrackerCodeProps {
|
||||
tracker: string;
|
||||
color: keyof IPalette;
|
||||
}
|
||||
|
||||
const TrackerCode: React.FunctionComponent<TrackerCodeProps> = ({ tracker, color }) => {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
borderColor: theme.palette[color],
|
||||
borderStyle: 'solid',
|
||||
borderWidth: 2,
|
||||
padding: 8,
|
||||
}}
|
||||
>
|
||||
{tracker}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const dialogStyles = { main: { maxWidth: 450 } };
|
||||
|
||||
export default TrackerDialog;
|
||||
|
|
|
@ -4,7 +4,7 @@ import { useSearchBallots } from '../data/queries';
|
|||
import { TrackedBallot } from '../models/tracking';
|
||||
|
||||
const DEFAULT_MINIMUM_QUERY_LENGTH = 3;
|
||||
const DEFAULT_DEBOUNCE_TIME_MS = 300;
|
||||
const DEFAULT_DEBOUNCE_TIME_MS = 250;
|
||||
|
||||
export interface SearchOptions {
|
||||
minimumQueryLength?: number;
|
||||
|
@ -47,7 +47,7 @@ export function useSearch(electionId: string, options: SearchOptions = {}): Sear
|
|||
// If the query is not valid, the search results will be undefined and the state of the fetch
|
||||
// will be "idle".
|
||||
const { data: searchResults, isIdle, isLoading } = useSearchBallots(electionId, preparedQuery, isValidQuery);
|
||||
if (searchResults) {
|
||||
if (searchResults !== undefined) {
|
||||
latestQuery.current = query;
|
||||
latestResults.current = searchResults;
|
||||
}
|
||||
|
@ -62,7 +62,7 @@ export function useSearch(electionId: string, options: SearchOptions = {}): Sear
|
|||
}, [search]);
|
||||
|
||||
return {
|
||||
results: searchResults || latestResults.current,
|
||||
results: isValidQuery ? searchResults || latestResults.current : [],
|
||||
isLoading: isIdle || isLoading,
|
||||
search,
|
||||
clear,
|
||||
|
|
|
@ -2,13 +2,16 @@ import React, { useEffect, useState } from 'react';
|
|||
import { Label } from '@fluentui/react';
|
||||
import { Theme, useTheme } from '@fluentui/react-theme-provider';
|
||||
import Autosuggest, { InputProps } from 'react-autosuggest';
|
||||
import { Route, Switch, useHistory, useRouteMatch } from 'react-router-dom';
|
||||
import { Route, Switch, useHistory, useParams, useRouteMatch } from 'react-router-dom';
|
||||
import take from 'lodash/take';
|
||||
|
||||
import LargeCard from './LargeCard';
|
||||
import { TrackedBallot } from '../models/tracking';
|
||||
import TrackerDialog from './TrackerDialog';
|
||||
import TrackerDialog, { TrackerDialogStateOption } from './TrackerDialog';
|
||||
import { useSearch } from './TrackerSearch.hooks';
|
||||
|
||||
const MAX_RESULTS_TO_SHOW = 5;
|
||||
|
||||
export interface TrackerSearchProps {
|
||||
electionId: string;
|
||||
}
|
||||
|
@ -70,13 +73,13 @@ const fromTheme = (theme: Theme) => {
|
|||
const TrackerSearch: React.FunctionComponent<TrackerSearchProps> = ({ electionId }) => {
|
||||
const history = useHistory();
|
||||
const { path, url } = useRouteMatch();
|
||||
|
||||
const theme = useTheme();
|
||||
|
||||
// Track the raw input value from the user
|
||||
const [inputValue, setInputValue] = useState<string>('');
|
||||
const [selectedBallot, setSelectedBallot] = useState<TrackedBallot | undefined>();
|
||||
|
||||
const { results, isLoading, search, clear } = useSearch(electionId);
|
||||
const { results, search, clear } = useSearch(electionId);
|
||||
|
||||
// Wire up the input element to the search input value
|
||||
const inputProps: InputProps<TrackedBallot> = {
|
||||
|
@ -93,7 +96,7 @@ const TrackerSearch: React.FunctionComponent<TrackerSearchProps> = ({ electionId
|
|||
<Label>Ballot Search</Label>
|
||||
<Autosuggest
|
||||
theme={fromTheme(theme)}
|
||||
suggestions={results}
|
||||
suggestions={take(results, MAX_RESULTS_TO_SHOW)}
|
||||
onSuggestionsFetchRequested={({ value }) => {
|
||||
search(value);
|
||||
}}
|
||||
|
@ -101,7 +104,8 @@ const TrackerSearch: React.FunctionComponent<TrackerSearchProps> = ({ electionId
|
|||
setInputValue('');
|
||||
clear();
|
||||
}}
|
||||
onSuggestionSelected={(event, { suggestion }) => {
|
||||
onSuggestionSelected={(_event, { suggestion }) => {
|
||||
setSelectedBallot(suggestion);
|
||||
const tracker = suggestion.tracker_words;
|
||||
history.push(`${url}/track/${tracker}`);
|
||||
}}
|
||||
|
@ -112,16 +116,7 @@ const TrackerSearch: React.FunctionComponent<TrackerSearchProps> = ({ electionId
|
|||
<Switch>
|
||||
<Route
|
||||
path={`${path}/track/:tracker`}
|
||||
render={() => (
|
||||
<TrackerResults
|
||||
searchResults={results}
|
||||
updateQuery={(newQuery) => {
|
||||
setInputValue(newQuery);
|
||||
search(newQuery);
|
||||
}}
|
||||
isQuerying={isLoading}
|
||||
/>
|
||||
)}
|
||||
render={() => <TrackerResults parentPath={url} selectedBallot={selectedBallot} />}
|
||||
/>
|
||||
</Switch>
|
||||
</LargeCard>
|
||||
|
@ -130,35 +125,54 @@ const TrackerSearch: React.FunctionComponent<TrackerSearchProps> = ({ electionId
|
|||
};
|
||||
|
||||
interface TrackerResultsProps {
|
||||
isQuerying: boolean;
|
||||
searchResults: TrackedBallot[];
|
||||
updateQuery: (query: string) => void;
|
||||
parentPath: string;
|
||||
selectedBallot: TrackedBallot | undefined;
|
||||
}
|
||||
|
||||
const TrackerResults: React.FunctionComponent<TrackerResultsProps> = ({ searchResults, isQuerying, updateQuery }) => {
|
||||
/**
|
||||
* Displays a dialog of search results.
|
||||
*
|
||||
* If launched from a search, the selected ballot is provided directly.
|
||||
* Otherwise, it will need to trigger a search for the ballot.
|
||||
*/
|
||||
const TrackerResults: React.FunctionComponent<TrackerResultsProps> = ({ selectedBallot, parentPath }) => {
|
||||
const history = useHistory();
|
||||
const { params } = useRouteMatch<{ tracker: string }>();
|
||||
const tracker = params.tracker;
|
||||
const { electionId, tracker } = useParams<{ electionId: string; tracker: string }>();
|
||||
|
||||
const isMatch = (ballot: TrackedBallot) => ballot.tracker_words === tracker;
|
||||
const existingBallot = searchResults?.find(isMatch);
|
||||
|
||||
const isLoading = !existingBallot && isQuerying;
|
||||
const { results, search, isLoading } = useSearch(electionId, { debounceTimeInMs: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
if (!existingBallot) {
|
||||
updateQuery(tracker);
|
||||
// If navigating directly to this route, we only have a tracker and must search for it
|
||||
if (!selectedBallot) {
|
||||
search(tracker);
|
||||
}
|
||||
}, [tracker, existingBallot, updateQuery]);
|
||||
}, [search, selectedBallot, tracker]);
|
||||
|
||||
// Check for a ballot to display
|
||||
let existingBallot: TrackedBallot | undefined;
|
||||
if (selectedBallot) {
|
||||
existingBallot = selectedBallot;
|
||||
} else {
|
||||
existingBallot = results.find((ballot) => ballot.tracker_words === tracker);
|
||||
}
|
||||
|
||||
const showLoading = !existingBallot && isLoading;
|
||||
let trackerState: TrackerDialogStateOption;
|
||||
if (showLoading) {
|
||||
trackerState = 'loading';
|
||||
} else if (existingBallot) {
|
||||
trackerState = existingBallot.state === 'Cast' ? 'confirmed' : 'spoiled';
|
||||
} else {
|
||||
trackerState = 'unknown';
|
||||
}
|
||||
|
||||
return (
|
||||
<TrackerDialog
|
||||
hidden={false}
|
||||
isLoading={isLoading}
|
||||
tracker={tracker}
|
||||
confirmed={!!existingBallot}
|
||||
trackerState={trackerState}
|
||||
onDismiss={() => {
|
||||
history.replace('/');
|
||||
history.replace(parentPath);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { ElectionDescription } from '../models/election';
|
||||
import { Election } from '../models/election';
|
||||
import { ElectionResultsSummary } from '../models/tally';
|
||||
import { TrackedBallot } from '../models/tracking';
|
||||
|
||||
|
@ -6,7 +6,7 @@ import { TrackedBallot } from '../models/tracking';
|
|||
* Provides access to election data and search functionality.
|
||||
*/
|
||||
export interface DataAccess {
|
||||
getElectionDescription(): Promise<ElectionDescription>;
|
||||
getElections(): Promise<Election[]>;
|
||||
getElectionResults(electionId: string): Promise<ElectionResultsSummary>;
|
||||
searchBallots(electionId: string, query: string): Promise<TrackedBallot[]>;
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import path from 'path';
|
||||
import { ElectionDescription } from '../models/election';
|
||||
import { Election, ElectionDescription } from '../models/election';
|
||||
import {
|
||||
CiphertextAcceptedBallot,
|
||||
PlaintextTally,
|
||||
|
@ -14,8 +14,14 @@ import { DataAccess } from './DataAccess';
|
|||
* DataAccess implementation for static published ElectionGuard data.
|
||||
*/
|
||||
export class PublishedDataAccess implements DataAccess {
|
||||
getElectionDescription(): Promise<ElectionDescription> {
|
||||
return loadPublishedFile('description.json');
|
||||
async getElections(): Promise<Election[]> {
|
||||
const electionDescription = await loadPublishedFile<ElectionDescription>('description.json');
|
||||
const election: Election = {
|
||||
id: electionDescription.election_scope_id,
|
||||
election_description: electionDescription,
|
||||
state: 'Published',
|
||||
};
|
||||
return [election];
|
||||
}
|
||||
|
||||
async getElectionResults(electionId: string): Promise<ElectionResultsSummary> {
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
import { useQuery } from 'react-query';
|
||||
import { ElectionDescription } from '../models/election';
|
||||
import { Election } from '../models/election';
|
||||
import { ElectionResultsSummary } from '../models/tally';
|
||||
import { TrackedBallot } from '../models/tracking';
|
||||
import { useDataAccess } from './DataAccessProvider';
|
||||
|
||||
export const QUERIES = {
|
||||
ELECTION_DESCRIPTION: 'ELECTION_DESCRIPTION',
|
||||
ELECTIONS: 'ELECTIONS',
|
||||
ELECTION_RESULTS: 'ELECTION_RESULTS',
|
||||
SEARCH_BALLOTS: 'SEARCH_BALLOTS',
|
||||
};
|
||||
|
@ -18,12 +18,12 @@ export interface QueryResult<T> {
|
|||
}
|
||||
|
||||
/**
|
||||
* Fetch the election description
|
||||
* Fetch the available elections
|
||||
* @param condition An optional boolean value which, if false, will prevent the query from running.
|
||||
*/
|
||||
export function useElectionDescription(condition: boolean = true): QueryResult<ElectionDescription> {
|
||||
export function useElections(condition: boolean = true): QueryResult<Election[]> {
|
||||
const dataAccess = useDataAccess();
|
||||
return useQuery(QUERIES.ELECTION_DESCRIPTION, () => dataAccess.getElectionDescription(), { enabled: condition });
|
||||
return useQuery(QUERIES.ELECTIONS, () => dataAccess.getElections(), { enabled: condition });
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -34,7 +34,7 @@ export function useElectionDescription(condition: boolean = true): QueryResult<E
|
|||
*/
|
||||
export function useElectionResults(electionId: string, condition: boolean = true): QueryResult<ElectionResultsSummary> {
|
||||
const dataAccess = useDataAccess();
|
||||
return useQuery([QUERIES.ELECTION_DESCRIPTION, electionId], () => dataAccess.getElectionResults(electionId), {
|
||||
return useQuery([QUERIES.ELECTION_RESULTS, electionId], () => dataAccess.getElectionResults(electionId), {
|
||||
enabled: condition && electionId,
|
||||
});
|
||||
}
|
||||
|
@ -53,7 +53,7 @@ export function useSearchBallots(
|
|||
): QueryResult<TrackedBallot[]> {
|
||||
const dataAccess = useDataAccess();
|
||||
return useQuery(
|
||||
[QUERIES.ELECTION_DESCRIPTION, electionId, trackerQuery],
|
||||
[QUERIES.SEARCH_BALLOTS, electionId, trackerQuery],
|
||||
() => dataAccess.searchBallots(electionId, trackerQuery),
|
||||
{
|
||||
// The search will stay in 'idle' state until this is satisfied
|
||||
|
|
|
@ -4,7 +4,7 @@ import mockDescription from './description.json';
|
|||
import mockTally from './tally.json';
|
||||
import mockBallots from './ballots.json';
|
||||
import { CiphertextAcceptedBallot, transformBallotForTracking, transformTallyResults } from '../models/electionguard';
|
||||
import { ElectionDescription } from '../models/election';
|
||||
import { Election } from '../models/election';
|
||||
import { ElectionResultsSummary } from '../models/tally';
|
||||
import { TrackedBallot } from '../models/tracking';
|
||||
|
||||
|
@ -17,8 +17,13 @@ const trackedBallots = (mockBallots as CiphertextAcceptedBallot[]).map((ballot)
|
|||
* DataAccess implementation for in-memory synchronous mocked data
|
||||
*/
|
||||
export class MockDataAccess implements DataAccess {
|
||||
async getElectionDescription(): Promise<ElectionDescription> {
|
||||
return mockDescription as any;
|
||||
async getElections(): Promise<Election[]> {
|
||||
const mockElection: Election = {
|
||||
id: mockDescription.election_scope_id,
|
||||
election_description: mockDescription as any,
|
||||
state: 'Published',
|
||||
};
|
||||
return [mockElection];
|
||||
}
|
||||
|
||||
async getElectionResults(electionId: string): Promise<ElectionResultsSummary> {
|
||||
|
|
|
@ -1,5 +1,13 @@
|
|||
import { InternationalizedText } from './internationalizedText';
|
||||
|
||||
export type ElectionState = 'New' | 'Open' | 'Closed' | 'Published';
|
||||
|
||||
export interface Election {
|
||||
id: string;
|
||||
election_description: ElectionDescription;
|
||||
state: ElectionState;
|
||||
}
|
||||
|
||||
export interface ElectionDescription {
|
||||
election_scope_id: string;
|
||||
start_date: string;
|
||||
|
|
|
@ -1,14 +0,0 @@
|
|||
import React from 'react';
|
||||
import { Story, Meta } from '@storybook/react/types-6-0';
|
||||
import ElectionPage, { ElectionPageProps } from './ElectionPage';
|
||||
|
||||
export default {
|
||||
title: 'Pages/ElectionPage',
|
||||
component: ElectionPage,
|
||||
} as Meta;
|
||||
|
||||
const Template: Story<ElectionPageProps> = (props) => <ElectionPage {...props} />;
|
||||
|
||||
export const Page = Template.bind({});
|
||||
Page.storyName = 'Render Election';
|
||||
Page.args = {};
|
|
@ -2,37 +2,50 @@ import React from 'react';
|
|||
import { Stack } from '@fluentui/react';
|
||||
|
||||
import AsyncContent from '../components/AsyncContent';
|
||||
import ElectionResults from '../components/ElectionResults';
|
||||
import ElectionHeader from '../components/ElectionTitle';
|
||||
import ElectionTitle from '../components/ElectionTitle';
|
||||
import ElectionPlaceholderMessage from '../components/ElectionPlaceholderMessage';
|
||||
import TrackerSearch from '../components/TrackerSearch';
|
||||
import { useElectionDescription, useElectionResults } from '../data/queries';
|
||||
import ElectionResults from '../components/ElectionResults';
|
||||
import { useElections } from '../data/queries';
|
||||
import { useLocalization } from '../localization/LocalizationProvider';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import Title from '../components/Title';
|
||||
|
||||
export interface ElectionPageProps {}
|
||||
|
||||
const ElectionPage: React.FunctionComponent<ElectionPageProps> = () => {
|
||||
const { electionId } = useParams<{ electionId: string }>();
|
||||
const { translate } = useLocalization();
|
||||
const electionQuery = useElectionDescription();
|
||||
|
||||
const electionId = electionQuery.data?.election_scope_id || '';
|
||||
const electionResultsQuery = useElectionResults(electionId);
|
||||
const electionsQuery = useElections();
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<AsyncContent query={electionQuery} errorMessage="Unable to load the election at this time.">
|
||||
{(election) => (
|
||||
<>
|
||||
<ElectionHeader
|
||||
electionName={translate(election.name)}
|
||||
startDate={election.start_date}
|
||||
endDate={election.end_date}
|
||||
/>
|
||||
<AsyncContent query={electionsQuery} errorMessage="Unable to load the election at this time.">
|
||||
{(elections) => {
|
||||
const election = elections.find((e) => e.id === electionId);
|
||||
|
||||
<TrackerSearch electionId={election.election_scope_id} />
|
||||
if (!election) {
|
||||
return <Title title="We're having trouble finding this election. Please try again." />;
|
||||
}
|
||||
|
||||
<ElectionResults election={election} electionResultsQuery={electionResultsQuery} />
|
||||
</>
|
||||
)}
|
||||
return (
|
||||
<>
|
||||
<ElectionTitle
|
||||
electionName={translate(election!.election_description.name)}
|
||||
startDate={election!.election_description.start_date}
|
||||
endDate={election!.election_description.end_date}
|
||||
/>
|
||||
{election.state === 'Published' ? (
|
||||
<>
|
||||
<TrackerSearch electionId={election!.id} />
|
||||
<ElectionResults election={election!} />
|
||||
</>
|
||||
) : (
|
||||
<ElectionPlaceholderMessage endDate={election!.election_description.end_date} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</AsyncContent>
|
||||
</Stack>
|
||||
);
|
||||
|
|
|
@ -0,0 +1,25 @@
|
|||
import React from 'react';
|
||||
import { Stack } from '@fluentui/react';
|
||||
|
||||
import AsyncContent from '../components/AsyncContent';
|
||||
import { useElections } from '../data/queries';
|
||||
import { Redirect } from 'react-router-dom';
|
||||
|
||||
export interface HomePageProps {}
|
||||
|
||||
const ElectionPage: React.FunctionComponent<HomePageProps> = () => {
|
||||
const electionsQuery = useElections();
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<AsyncContent query={electionsQuery} errorMessage="Unable to load any elections at this time.">
|
||||
{(elections) => {
|
||||
const election = elections[0];
|
||||
return <Redirect to={`/${election.id}`} />;
|
||||
}}
|
||||
</AsyncContent>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default ElectionPage;
|
Загрузка…
Ссылка в новой задаче