Skip to content

Commit

Permalink
Add API for entering individual attempts (#195)
Browse files Browse the repository at this point in the history
  • Loading branch information
jonatanklosko authored Oct 9, 2023
1 parent 4101973 commit 5413b4f
Show file tree
Hide file tree
Showing 24 changed files with 969 additions and 61 deletions.
4 changes: 2 additions & 2 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,8 @@ jobs:
- name: Install Erlang & Elixir
uses: erlef/setup-beam@v1
with:
otp-version: '25.2.3'
elixir-version: '1.14.2'
otp-version: '26.0.2'
elixir-version: '1.15.2'
- name: Install system dependencies
run: sudo apt-get update && sudo apt-get install wkhtmltopdf
- name: Cache Mix
Expand Down
13 changes: 10 additions & 3 deletions client/src/components/Account/Account.jsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
import { Box } from "@mui/material";
import { Box, Grid } from "@mui/material";
import OneTimeCode from "../OneTimeCode/OneTimeCode";
import ScoretakingTokens from "../ScoretakingTokens/ScoretakingTokens";

function Account() {
// It's pretty boring right now, but there's more to come.
return (
<Box p={{ xs: 2, sm: 3 }}>
<OneTimeCode />
<Grid container direction="column" gap={6}>
<Grid item>
<OneTimeCode />
</Grid>
<Grid item>
<ScoretakingTokens />
</Grid>
</Grid>
</Box>
);
}
Expand Down
10 changes: 6 additions & 4 deletions client/src/components/CompetitionSearch/CompetitionSearch.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ const COMPETITIONS = gql`

const DEBOUNCE_MS = 250;

function CompetitionSearch({ onChange, TextFieldProps = {} }) {
function CompetitionSearch({ value = null, onChange, TextFieldProps = {} }) {
const [search, setSearch] = useState("");
const debouncedSearch = useDebounce(search, DEBOUNCE_MS);

Expand All @@ -31,9 +31,9 @@ function CompetitionSearch({ onChange, TextFieldProps = {} }) {
}
}

function handleChange(event, user, reason) {
function handleChange(event, competition, reason) {
if (reason === "selectOption") {
onChange(user);
onChange(competition);
}
}

Expand All @@ -45,8 +45,10 @@ function CompetitionSearch({ onChange, TextFieldProps = {} }) {
getOptionLabel={(competition) => competition.name}
loading={loading}
onInputChange={handleInputChange}
value={null}
value={value}
onChange={handleChange}
forcePopupIcon={false}
disableClearable={true}
renderInput={(params) => <TextField {...params} {...TextFieldProps} />}
/>
);
Expand Down
36 changes: 36 additions & 0 deletions client/src/components/ScoretakingTokens/ScoretakingTokenDialog.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import {
Grid,
Typography,
Dialog,
DialogContent,
DialogActions,
Button,
} from "@mui/material";

function ScoretakingTokenDialog({ token, open, onClose }) {
return (
<Dialog open={open} onClose={onClose}>
<DialogContent>
<Grid container direction="column" spacing={2} alignItems="center">
<Grid item>
<Typography color="textSecondary">
Copy and save the token, you won't be able to see it again.
</Typography>
</Grid>
<Grid item>
<Typography variant="subtitle1" align="center">
{token}
</Typography>
</Grid>
</Grid>
</DialogContent>
<DialogActions>
<Button color="primary" onClick={() => onClose()}>
Close
</Button>
</DialogActions>
</Dialog>
);
}

export default ScoretakingTokenDialog;
128 changes: 128 additions & 0 deletions client/src/components/ScoretakingTokens/ScoretakingTokens.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { useState } from "react";
import { gql, useMutation, useQuery } from "@apollo/client";
import { Button, Grid, Link, Typography } from "@mui/material";

import useApolloErrorHandler from "../../hooks/useApolloErrorHandler";
import CompetitionSearch from "../CompetitionSearch/CompetitionSearch";
import Loading from "../Loading/Loading";
import Error from "../Error/Error";
import ScoretakingTokensTable from "../ScoretakingTokensTable/ScoretakingTokensTable";
import ScoretakingTokenDialog from "./ScoretakingTokenDialog";

const ACTIVE_SCORETAKING_TOKENS_QUERY = gql`
query ActiveScoretakingTokensQuery {
activeScoretakingTokens {
id
insertedAt
competition {
id
name
}
}
}
`;

const GENERATE_SCORETAKING_TOKEN = gql`
mutation GenerateScoretakingToken($input: GenerateScoretakingTokenInput!) {
generateScoretakingToken(input: $input) {
token
}
}
`;

function ScoretakingTokens() {
const apolloErrorHandler = useApolloErrorHandler();

const [competition, setCompetition] = useState(null);
const [dialogOpen, setDialogOpen] = useState(false);

const { data, loading, error } = useQuery(ACTIVE_SCORETAKING_TOKENS_QUERY);

const [
generateScoretakingToken,
{ data: generatedTokenData, loading: mutationLoading },
] = useMutation(GENERATE_SCORETAKING_TOKEN, {
variables: {
input: {
competitionId: competition && competition.id,
},
},
onError: apolloErrorHandler,
onCompleted: () => {
setCompetition(null);
setDialogOpen(true);
},
refetchQueries: [ACTIVE_SCORETAKING_TOKENS_QUERY],
});

if (loading && !data) return <Loading />;
if (error) return <Error error={error} />;

const { activeScoretakingTokens } = data;

const token = generatedTokenData
? generatedTokenData.generateScoretakingToken.token
: null;

return (
<>
<Grid container direction="column" spacing={2}>
<Grid item>
<Typography variant="h5" gutterBottom>
Scoretaking tokens
</Typography>
<Typography color="textSecondary">
Generate a personal token for a specific competition. You can use
this token programmatically enter results from an external software
(such as a dedicated scoretaking device). For API specifics check
out{" "}
<Link
href="https://github.com/thewca/wca-live/wiki/Entering-attempts-with-external-devices"
underline="hover"
>
this page
</Link>
.
</Typography>
</Grid>
<Grid item>
<CompetitionSearch
onChange={(competition) => setCompetition(competition)}
value={competition}
TextFieldProps={{
placeholder: "Competition",
size: "small",
style: { width: 350 },
}}
/>
</Grid>
<Grid item>
<Button
variant="contained"
color="primary"
disabled={mutationLoading || !competition}
onClick={() => generateScoretakingToken()}
>
Generate
</Button>
</Grid>
<Grid item>
<Typography variant="subtitle2" mb={0.5}>
Active tokens
</Typography>
<ScoretakingTokensTable
scoretakingTokens={activeScoretakingTokens}
activeScoretakingTokensQuery={ACTIVE_SCORETAKING_TOKENS_QUERY}
/>
</Grid>
</Grid>
<ScoretakingTokenDialog
token={token}
open={dialogOpen}
onClose={() => setDialogOpen(false)}
/>
</>
);
}

export default ScoretakingTokens;
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { gql, useMutation } from "@apollo/client";
import {
Table,
TableBody,
TableCell,
TableHead,
TableRow,
TableContainer,
Paper,
IconButton,
} from "@mui/material";
import DeleteIcon from "@mui/icons-material/Delete";
import TimeAgo from "react-timeago";
import { parseISO } from "date-fns";
import { useConfirm } from "material-ui-confirm";

import { orderBy } from "../../lib/utils";
import useApolloErrorHandler from "../../hooks/useApolloErrorHandler";

const DELETE_SCORETAKING_TOKEN_MUTATION = gql`
mutation DeleteScoretakingToken($input: DeleteScoretakingTokenInput!) {
deleteScoretakingToken(input: $input) {
scoretakingToken {
id
}
}
}
`;

function ScoretakingTokensTable({
scoretakingTokens,
activeScoretakingTokensQuery,
}) {
const confirm = useConfirm();
const apolloErrorHandler = useApolloErrorHandler();

const [deleteScoretakingToken, { loading }] = useMutation(
DELETE_SCORETAKING_TOKEN_MUTATION,
{
onError: apolloErrorHandler,
refetchQueries: [activeScoretakingTokensQuery],
}
);

function handleDelete(scoretakingToken) {
confirm({
description: `
This will remove the scoretaking token for ${scoretakingToken.competition.name}.
`,
}).then(() => {
deleteScoretakingToken({
variables: { input: { id: scoretakingToken.id } },
});
});
}

const sortedScoretakingTokens = orderBy(
scoretakingTokens,
(token) => [token.insertedAt],
["desc"]
);

return (
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Competition</TableCell>
<TableCell>Created</TableCell>
<TableCell></TableCell>
</TableRow>
</TableHead>
<TableBody>
{sortedScoretakingTokens.map((scoretakingToken) => (
<TableRow key={scoretakingToken.id}>
<TableCell>{scoretakingToken.competition.name}</TableCell>
<TableCell>
<TimeAgo date={parseISO(scoretakingToken.insertedAt)} />
</TableCell>
<TableCell align="right">
<IconButton
onClick={() => handleDelete(scoretakingToken)}
size="small"
disabled={loading}
>
<DeleteIcon />
</IconButton>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
);
}

export default ScoretakingTokensTable;
39 changes: 38 additions & 1 deletion lib/wca_live/accounts.ex
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ defmodule WcaLive.Accounts do
import Ecto.Query, warn: false

alias WcaLive.Repo
alias WcaLive.Accounts.{User, AccessToken, OneTimeCode, UserToken}
alias WcaLive.Accounts.{User, AccessToken, OneTimeCode, UserToken, ScoretakingToken}
alias WcaLive.Wca

@doc """
Expand Down Expand Up @@ -152,4 +152,41 @@ defmodule WcaLive.Accounts do
end
end
end

@doc """
Generates a new scoretaking token.
"""
def generate_scoretaking_token(user, competition) do
scoretaking_token = ScoretakingToken.build_scoretaking_token(user, competition)
Repo.insert!(scoretaking_token)
end

@doc """
Gets the user with the given token.
"""
def get_user_and_competition_by_scoretaking_token(token) do
Repo.one(ScoretakingToken.verify_token_query(token))
end

@doc """
Gets scoretaking token by id.
"""
def get_scoretaking_token!(id) do
Repo.get!(ScoretakingToken, id)
end

@doc """
Deletes scoretaking token.
"""
def delete_scoretaking_token(scoretaking_token) do
Repo.delete!(scoretaking_token)
:ok
end

@doc """
Lists all active scoretaking tokens for the given user.
"""
def list_active_scoretaking_tokens(user) do
Repo.all(ScoretakingToken.active_tokens_for_user_query(user))
end
end
Loading

0 comments on commit 5413b4f

Please sign in to comment.