Merge pull request #28 from microsoft/feature/multi-election
Support multiple elections in the API and routes
This commit is contained in:
Коммит
01982bf55f
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>
|
||||
|
|
|
@ -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 { queryArgTypes, QueryStoryArgs } from '../util/queryStory';
|
||||
|
||||
export default {
|
||||
title: 'Components/ElectionResults',
|
||||
|
@ -22,22 +18,9 @@ export default {
|
|||
interface StoryArgs extends QueryStoryArgs {}
|
||||
|
||||
const Template: Story<StoryArgs> = ({ queryState }) => {
|
||||
return (
|
||||
<ElectionResults
|
||||
election={electionDescription as ElectionDescription}
|
||||
electionResultsQuery={getDummyQueryResult(queryState, electionResults)}
|
||||
/>
|
||||
);
|
||||
return <ElectionResults election={electionDescription as ElectionDescription} />;
|
||||
};
|
||||
|
||||
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,5 +1,5 @@
|
|||
import React from 'react';
|
||||
import { QueryResult } from '../data/queries';
|
||||
import { useElectionResults } from '../data/queries';
|
||||
import { ElectionDescription } from '../models/election';
|
||||
import { ElectionResultsSummary } from '../models/tally';
|
||||
import AsyncContent from './AsyncContent';
|
||||
|
@ -10,13 +10,14 @@ const errorMessage = 'Unable to retrieve election results at this time.';
|
|||
|
||||
export interface ElectionResultsProps {
|
||||
election: ElectionDescription;
|
||||
electionResultsQuery: QueryResult<ElectionResultsSummary>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the results of the election
|
||||
*/
|
||||
const ElectionResults: React.FunctionComponent<ElectionResultsProps> = ({ election, electionResultsQuery }) => {
|
||||
const ElectionResults: React.FunctionComponent<ElectionResultsProps> = ({ election }) => {
|
||||
const electionResultsQuery = useElectionResults(election.election_scope_id);
|
||||
|
||||
return (
|
||||
<AsyncContent query={electionResultsQuery} errorMessage={errorMessage}>
|
||||
{(results) => {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -3,12 +3,15 @@ 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 take from 'lodash/take';
|
||||
|
||||
import LargeCard from './LargeCard';
|
||||
import { TrackedBallot } from '../models/tracking';
|
||||
import TrackerDialog from './TrackerDialog';
|
||||
import { useSearch } from './TrackerSearch.hooks';
|
||||
|
||||
const MAX_RESULTS_TO_SHOW = 5;
|
||||
|
||||
export interface TrackerSearchProps {
|
||||
electionId: string;
|
||||
}
|
||||
|
@ -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);
|
||||
}}
|
||||
|
@ -114,6 +117,7 @@ const TrackerSearch: React.FunctionComponent<TrackerSearchProps> = ({ electionId
|
|||
path={`${path}/track/:tracker`}
|
||||
render={() => (
|
||||
<TrackerResults
|
||||
parentPath={url}
|
||||
searchResults={results}
|
||||
updateQuery={(newQuery) => {
|
||||
setInputValue(newQuery);
|
||||
|
@ -131,11 +135,17 @@ const TrackerSearch: React.FunctionComponent<TrackerSearchProps> = ({ electionId
|
|||
|
||||
interface TrackerResultsProps {
|
||||
isQuerying: boolean;
|
||||
parentPath: string;
|
||||
searchResults: TrackedBallot[];
|
||||
updateQuery: (query: string) => void;
|
||||
}
|
||||
|
||||
const TrackerResults: React.FunctionComponent<TrackerResultsProps> = ({ searchResults, isQuerying, updateQuery }) => {
|
||||
const TrackerResults: React.FunctionComponent<TrackerResultsProps> = ({
|
||||
searchResults,
|
||||
parentPath,
|
||||
isQuerying,
|
||||
updateQuery,
|
||||
}) => {
|
||||
const history = useHistory();
|
||||
const { params } = useRouteMatch<{ tracker: string }>();
|
||||
const tracker = params.tracker;
|
||||
|
@ -158,7 +168,7 @@ const TrackerResults: React.FunctionComponent<TrackerResultsProps> = ({ searchRe
|
|||
tracker={tracker}
|
||||
confirmed={!!existingBallot}
|
||||
onDismiss={() => {
|
||||
history.replace('/');
|
||||
history.replace(parentPath);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -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<ElectionDescription[]>;
|
||||
getElectionResults(electionId: string): Promise<ElectionResultsSummary>;
|
||||
searchBallots(electionId: string, query: string): Promise<TrackedBallot[]>;
|
||||
}
|
||||
|
|
|
@ -14,8 +14,9 @@ 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<ElectionDescription[]> {
|
||||
const election = await loadPublishedFile('description.json');
|
||||
return [election as ElectionDescription];
|
||||
}
|
||||
|
||||
async getElectionResults(electionId: string): Promise<ElectionResultsSummary> {
|
||||
|
|
|
@ -5,7 +5,7 @@ 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 election descriptions
|
||||
* @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<ElectionDescription[]> {
|
||||
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
|
||||
|
|
|
@ -17,8 +17,8 @@ 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<ElectionDescription[]> {
|
||||
return [mockDescription as any];
|
||||
}
|
||||
|
||||
async getElectionResults(electionId: string): Promise<ElectionResultsSummary> {
|
||||
|
|
|
@ -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,45 @@ 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 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.election_scope_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!.name)}
|
||||
startDate={election!.start_date}
|
||||
endDate={election!.end_date}
|
||||
/>
|
||||
|
||||
<TrackerSearch electionId={election!.election_scope_id} />
|
||||
|
||||
<ElectionResults election={election!} />
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</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.election_scope_id}`} />;
|
||||
}}
|
||||
</AsyncContent>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default ElectionPage;
|
Загрузка…
Ссылка в новой задаче