Fix pagination query parsing, repertoire entry editor
This commit is contained in:
@@ -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"),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user