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();
|
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"),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user