Merge pull request #28 from microsoft/feature/multi-election

Support multiple elections in the API and routes
This commit is contained in:
Ryan Korsak 2020-10-05 16:56:58 -04:00 коммит произвёл GitHub
Родитель ac38a1fb52 ebf7fe15ae
Коммит 01982bf55f
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
12 изменённых файлов: 91 добавлений и 78 удалений

Просмотреть файл

@ -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>
);

25
src/pages/HomePage.tsx Normal file
Просмотреть файл

@ -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;