Fix pagination query parsing, repertoire entry editor

This commit is contained in:
2024-12-30 11:02:07 +01:00
parent d7e6df14d4
commit fc64f77335
3 changed files with 236 additions and 27 deletions

View File

@@ -802,11 +802,7 @@ const app = new Elysia()
const res = await q.execute(); const res = await q.execute();
return res.map(({ repertoireId }) => repertoireId); return res.map(({ repertoireId }) => repertoireId);
}, { }, {
query: t.Object({ query: Model.Repertoire_Query,
name: t.Optional(t.String()),
offset: t.Optional(t.Integer({ minimum: 0 })),
limit: t.Optional(t.Integer({ minimum: 1, maximum: 100 })),
}),
response: { response: {
200: t.Array(Model.RepertoireId), 200: t.Array(Model.RepertoireId),
401: t.Literal("Session invalid or expired"), 401: t.Literal("Session invalid or expired"),

View File

@@ -22,8 +22,8 @@ const SystemInformation = Object.freeze({
}); });
const Pagination = Object.freeze({ const Pagination = Object.freeze({
offset: t.Optional(t.Integer({ minimum: 0 })), offset: t.Optional(t.Numeric({ minimum: 0 })),
limit: t.Optional(t.Integer({ minimum: 1, maximum: 100 })), limit: t.Optional(t.Numeric({ minimum: 1, maximum: 100 })),
}); });
export const AccessLog = t.Object({ export const AccessLog = t.Object({
@@ -74,6 +74,11 @@ export const Repertoire = t.Object({
...SystemInformation, ...SystemInformation,
}); });
export const Repertoire_Query = t.Object({
name: t.Optional(t.String()),
...Pagination,
});
export const User = t.Object({ export const User = t.Object({
userId: UserId, userId: UserId,
username: t.String(), username: t.String(),
@@ -103,6 +108,7 @@ export type Piece = typeof Piece.static;
export type Piece_Post = typeof Piece_Post.static; export type Piece_Post = typeof Piece_Post.static;
export type Piece_Query = typeof Piece_Query.static; export type Piece_Query = typeof Piece_Query.static;
export type Repertoire = typeof Repertoire.static; export type Repertoire = typeof Repertoire.static;
export type Repertoire_Query = typeof Repertoire_Query.static;
export type User = typeof User.static; export type User = typeof User.static;
export type User_Patch = typeof User_Patch.static; export type User_Patch = typeof User_Patch.static;
export type User_Post = typeof User_Post.static; export type User_Post = typeof User_Post.static;

View File

@@ -1,17 +1,18 @@
import { Piece, type Repertoire, repertoireCache } from "@/cache"; import { Piece, pieceCache, type Repertoire, repertoireCache } from "@/cache";
import { client } from "@/client"; import { client } from "@/client";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { useLoadingEffect } from "@/hooks/useLoading"; import { useLoadingEffect } from "@/hooks/useLoading";
import { mapProp, Update, Updater } from "@/hooks/useStore"; import { mapProp, Update, Updater } from "@/hooks/useStore";
import { authors, saveDelay } from "@/snippets"; import { authors, DEBOUNCE, saveDelay } from "@/snippets";
import { PieceId, RepertoireId } from "common"; import { PieceId, RepertoireId } from "common";
import { Array, Cause, Effect, pipe } from "effect"; import { Array, Cause, Effect, Option, pipe } from "effect";
import { CircleMinus, Loader2 } from "lucide-react"; import { ChevronDown, ChevronUp, CircleMinus, Loader2, Plus } from "lucide-react";
import { FormEventHandler, useCallback, useId, useState } from "react"; import { FormEventHandler, useCallback, useId, useMemo, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router-dom"; import { Link, useNavigate, useParams } from "react-router-dom";
export function Repertoire() { export function Repertoire() {
@@ -141,12 +142,15 @@ namespace Entries {
} }
function Entries(props: Entries.Props) { function Entries(props: Entries.Props) {
const [dialogOpen, setDialogOpen] = useState(false);
return ( return (
<div className="grow"> <div className="flex flex-col gap-4 items-start grow">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Lp.</TableHead> <TableHead className="text-center w-[8ch]">Lp.</TableHead>
<TableHead>Tytuł</TableHead> <TableHead>Tytuł</TableHead>
<TableHead>Twórcy</TableHead> <TableHead>Twórcy</TableHead>
<TableHead className="text-center">Akcje</TableHead> <TableHead className="text-center">Akcje</TableHead>
@@ -164,6 +168,14 @@ function Entries(props: Entries.Props) {
))} ))}
</TableBody> </TableBody>
</Table> </Table>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogTrigger asChild>
<Button variant="outline">
<Plus />Dodaj utwór
</Button>
</DialogTrigger>
<AddEntryDialogContent repertoire={props.repertoire} setEntries={props.setEntries} setDialogOpen={setDialogOpen} />
</Dialog>
</div> </div>
); );
} }
@@ -177,18 +189,41 @@ namespace EntryRow {
} }
} }
function EntryRow(props: EntryRow.Props) { function EntryRow({
repertoire,
piece,
no,
setEntries,
}: EntryRow.Props) {
const remove = useCallback(async () => { const moveUpAction = useCallback((entries: readonly Piece[]) => pipe(
entries,
Array.remove(no - 1),
Array.insertAt(no - 2, piece),
Option.getOrThrow,
), [no, piece]);
const moveDownAction = useCallback((entries: readonly Piece[]) => pipe(
entries,
Array.remove(no - 1),
Array.insertAt(no, piece),
Option.getOrThrow,
), [no, piece]);
const removeAction = useCallback((entries: readonly Piece[]) => pipe(
entries,
Array.filter((p) => p.pieceId !== piece.pieceId),
), [piece.pieceId]);
const update = useCallback(async (action: (prev: readonly Piece[]) => readonly Piece[]) => {
const filter = Array.filter<Piece>((piece) => piece.pieceId !== props.piece.pieceId);
const mapToId = Array.map<readonly Piece[], PieceId>(({ pieceId }) => pieceId); const mapToId = Array.map<readonly Piece[], PieceId>(({ pieceId }) => pieceId);
const { error } = await client const { error } = await client
.repertoire({ repertoireId: props.repertoire.repertoireId }) .repertoire({ repertoireId: repertoire.repertoireId })
.put({ .put({
name: props.repertoire.name, name: repertoire.name,
entries: pipe(props.repertoire.entries, filter, mapToId), entries: pipe(repertoire.entries, action, mapToId),
}); });
if (error !== null) { if (error !== null) {
@@ -196,22 +231,33 @@ function EntryRow(props: EntryRow.Props) {
return; return;
} }
props.setEntries(filter); setEntries(action);
}, [repertoire.entries, repertoire.name, repertoire.repertoireId, setEntries]);
}, [props]); const moveUp = useMemo(() => update.bind(undefined, moveUpAction), [moveUpAction, update]);
const moveDown = useMemo(() => update.bind(undefined, moveDownAction), [moveDownAction, update]);
const remove = useMemo(() => update.bind(undefined, removeAction), [removeAction, update]);
return ( return (
<TableRow> <TableRow>
<TableCell> <TableCell className="text-center">
{props.no} {no}
</TableCell> </TableCell>
<TableCell> <TableCell>
{props.piece.name} <Link className="underline" to={`/piece/${piece.pieceId}`}>
{piece.name}
</Link>
</TableCell> </TableCell>
<TableCell> <TableCell>
{authors(props.piece)} {authors(piece)}
</TableCell> </TableCell>
<TableCell className="text-center flex justify-center gap-4"> <TableCell className="text-center flex justify-center gap-4">
<Button type="button" variant="ghost" size="icon" title="Przesuń w górę" onClick={moveUp} disabled={no === 1}>
<ChevronUp />
</Button>
<Button type="button" variant="ghost" size="icon" title="Przesuń w dół" onClick={moveDown} disabled={no === repertoire.entries.length}>
<ChevronDown />
</Button>
<Button type="button" variant="ghost" size="icon" title="Usuń" onClick={remove}> <Button type="button" variant="ghost" size="icon" title="Usuń" onClick={remove}>
<CircleMinus /> <CircleMinus />
</Button> </Button>
@@ -219,3 +265,164 @@ function EntryRow(props: EntryRow.Props) {
</TableRow> </TableRow>
); );
} }
const ADD_ENTRY_DIALOG_LIMIT = 10;
namespace AddEntryDialogContent {
export interface Props {
readonly repertoire: Repertoire;
readonly setEntries: Updater<readonly Piece[]>;
readonly setDialogOpen: Updater<boolean>;
}
}
function AddEntryDialogContent(props: AddEntryDialogContent.Props) {
const [name, setName] = useState("");
const [author, setAuthor] = useState("");
const debounce = useRef(Effect.void);
const { isLoading, error, data: pieceIds } = useLoadingEffect(Effect.gen(function* () {
yield* debounce.current;
const { error, data } = yield* Effect.promise((signal) => client.piece.get({
query: {
...(name !== "" ? { name } : undefined),
...(author !== "" ? { author } : undefined),
limit: ADD_ENTRY_DIALOG_LIMIT,
},
fetch: { signal },
}));
if (error !== null) {
return yield* Effect.fail(error);
} else {
return data;
}
}), [name, author]);
return (
<DialogContent>
<DialogHeader>
<DialogTitle>Dodaj utwór</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4">
<div className="flex flex-row gap-4">
<Input
className="grow"
type="text"
placeholder="Tytuł"
value={name}
onChange={(e) => {
setName(e.target.value);
debounce.current = DEBOUNCE;
}}
/>
<Input
className="grow"
type="text"
placeholder="Twórcy"
value={author}
onChange={(e) => {
setAuthor(e.target.value);
debounce.current = DEBOUNCE;
}}
/>
</div>
<Table className="overflow-y-hidden">
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={4} >
<div className="flex items-center justify-center gap-2">
<Loader2 className="animate-spin" />
Ładowanie
</div>
</TableCell>
</TableRow>
) : error !== null ? (
<TableRow>
<TableCell colSpan={4} className="text-center">
{Cause.isUnknownException(error) ? "Wystąpił nieznany błąd" : `Wystąpił błąd: ${error.value}`}
</TableCell>
</TableRow>
) : (
pieceIds.map((pieceId) => (
<EntryDialogPieceRow
key={pieceId}
repertoire={props.repertoire}
pieceId={pieceId}
setEntries={props.setEntries}
setDialogOpen={props.setDialogOpen}
/>
))
)}
</TableBody>
</Table>
</div>
</DialogContent>
);
}
namespace EntryDialogPieceRow {
export interface Props {
readonly repertoire: Repertoire;
readonly pieceId: PieceId;
readonly setEntries: Updater<readonly Piece[]>;
readonly setDialogOpen: Updater<boolean>;
}
}
function EntryDialogPieceRow(props: EntryDialogPieceRow.Props) {
const { isLoading, error, data: piece } = useLoadingEffect(Effect.uninterruptible(pieceCache.get(props.pieceId)), [props.pieceId]);
const onClick = useCallback(async () => {
const action = Array.append(piece!);
const mapToId = Array.map<readonly Piece[], PieceId>(({ pieceId }) => pieceId);
const { error } = await client
.repertoire({ repertoireId: props.repertoire.repertoireId })
.put({
name: props.repertoire.name,
entries: pipe(props.repertoire.entries, action, mapToId),
});
if (error !== null) {
console.error(error.value);
return;
}
props.setEntries(action);
props.setDialogOpen(false);
}, [piece, props]);
if (isLoading) {
return (
<TableRow>
<TableCell colSpan={2}>Ładowanie</TableCell>
</TableRow>
);
}
if (error !== null) {
return (
<TableRow>
<TableCell colSpan={2}>Wystąpił błąd: {error.value}</TableCell>
</TableRow>
);
}
return (
<TableRow className="select-none cursor-pointer" onClick={onClick}>
<TableCell>
{piece.name}
</TableCell>
<TableCell>
{authors(piece)}
</TableCell>
</TableRow>
);
}