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();
return res.map(({ repertoireId }) => repertoireId);
}, {
query: t.Object({
name: t.Optional(t.String()),
offset: t.Optional(t.Integer({ minimum: 0 })),
limit: t.Optional(t.Integer({ minimum: 1, maximum: 100 })),
}),
query: Model.Repertoire_Query,
response: {
200: t.Array(Model.RepertoireId),
401: t.Literal("Session invalid or expired"),

View File

@@ -22,8 +22,8 @@ const SystemInformation = Object.freeze({
});
const Pagination = Object.freeze({
offset: t.Optional(t.Integer({ minimum: 0 })),
limit: t.Optional(t.Integer({ minimum: 1, maximum: 100 })),
offset: t.Optional(t.Numeric({ minimum: 0 })),
limit: t.Optional(t.Numeric({ minimum: 1, maximum: 100 })),
});
export const AccessLog = t.Object({
@@ -74,6 +74,11 @@ export const Repertoire = t.Object({
...SystemInformation,
});
export const Repertoire_Query = t.Object({
name: t.Optional(t.String()),
...Pagination,
});
export const User = t.Object({
userId: UserId,
username: t.String(),
@@ -103,6 +108,7 @@ export type Piece = typeof Piece.static;
export type Piece_Post = typeof Piece_Post.static;
export type Piece_Query = typeof Piece_Query.static;
export type Repertoire = typeof Repertoire.static;
export type Repertoire_Query = typeof Repertoire_Query.static;
export type User = typeof User.static;
export type User_Patch = typeof User_Patch.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 { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { useLoadingEffect } from "@/hooks/useLoading";
import { mapProp, Update, Updater } from "@/hooks/useStore";
import { authors, saveDelay } from "@/snippets";
import { authors, DEBOUNCE, saveDelay } from "@/snippets";
import { PieceId, RepertoireId } from "common";
import { Array, Cause, Effect, pipe } from "effect";
import { CircleMinus, Loader2 } from "lucide-react";
import { FormEventHandler, useCallback, useId, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { Array, Cause, Effect, Option, pipe } from "effect";
import { ChevronDown, ChevronUp, CircleMinus, Loader2, Plus } from "lucide-react";
import { FormEventHandler, useCallback, useId, useMemo, useRef, useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
export function Repertoire() {
@@ -141,12 +142,15 @@ namespace Entries {
}
function Entries(props: Entries.Props) {
const [dialogOpen, setDialogOpen] = useState(false);
return (
<div className="grow">
<div className="flex flex-col gap-4 items-start grow">
<Table>
<TableHeader>
<TableRow>
<TableHead>Lp.</TableHead>
<TableHead className="text-center w-[8ch]">Lp.</TableHead>
<TableHead>Tytuł</TableHead>
<TableHead>Twórcy</TableHead>
<TableHead className="text-center">Akcje</TableHead>
@@ -164,6 +168,14 @@ function Entries(props: Entries.Props) {
))}
</TableBody>
</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>
);
}
@@ -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 { error } = await client
.repertoire({ repertoireId: props.repertoire.repertoireId })
.repertoire({ repertoireId: repertoire.repertoireId })
.put({
name: props.repertoire.name,
entries: pipe(props.repertoire.entries, filter, mapToId),
name: repertoire.name,
entries: pipe(repertoire.entries, action, mapToId),
});
if (error !== null) {
@@ -196,22 +231,33 @@ function EntryRow(props: EntryRow.Props) {
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 (
<TableRow>
<TableCell>
{props.no}
<TableCell className="text-center">
{no}
</TableCell>
<TableCell>
{props.piece.name}
<Link className="underline" to={`/piece/${piece.pieceId}`}>
{piece.name}
</Link>
</TableCell>
<TableCell>
{authors(props.piece)}
{authors(piece)}
</TableCell>
<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}>
<CircleMinus />
</Button>
@@ -219,3 +265,164 @@ function EntryRow(props: EntryRow.Props) {
</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>
);
}