Add and fix react hooks lint rules, single repertoire view

This commit is contained in:
2024-12-28 20:54:17 +01:00
parent 29636d9946
commit 7c7bac5a9f
11 changed files with 237 additions and 16 deletions

View File

@@ -33,6 +33,12 @@ export interface Piece extends SystemInformation {
readonly attachments: readonly Attachment[];
}
export interface Repertoire extends SystemInformation {
readonly repertoireId: RepertoireId;
readonly name: string;
readonly entries: readonly Piece[];
}
interface DbSystemInformation {
readonly createdBy: UserId | null;
readonly createdAt: string;
@@ -108,6 +114,7 @@ const RepertoireSemaphore = Effect.unsafeMakeSemaphore(1);
export const userLookup = (userId: UserId) => pipe(
Effect.promise((signal) => client.user({ userId }).get({ fetch: { signal } })),
Effect.flatMap(mapResponse),
Effect.map((x): User => x), // safely coerce to interface
UserSemaphore.withPermits(1),
);
@@ -115,6 +122,7 @@ export const pieceLookup = (pieceId: PieceId) => pipe(
Effect.promise((signal) => client.piece({ pieceId }).get({ fetch: { signal } })),
Effect.flatMap(mapResponse),
Effect.flatMap(denormalizePiece),
Effect.map((x): Piece => x), // safely coerce to interface
CacheSemaphore.withPermits(1),
);
@@ -122,6 +130,7 @@ export const repertoireLookup = (repertoireId: RepertoireId) => pipe(
Effect.promise((signal) => client.repertoire({ repertoireId }).get({ fetch: { signal } })),
Effect.flatMap(mapResponse),
Effect.flatMap(denormalizeRepertoire),
Effect.map((x): Repertoire => x), // safely coerce to interface
RepertoireSemaphore.withPermits(1),
);

View File

@@ -81,7 +81,8 @@ export function useLoading<R extends Record<number, unknown>>(fn: () => Promise<
});
return () => { cancelled = true; };
}, deps);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fn, navigate, setUser, ...deps]);
return result;
}
@@ -125,7 +126,8 @@ export function useLoadingEffect<A, E>(effect: Effect.Effect<A, E>, deps: React.
return () => {
Effect.runFork(interruptEffect);
};
}, deps);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [effect, setResultEffect, ...deps]);
return result;
}

View File

@@ -88,7 +88,7 @@ export function useStore<T = Store>(selector: Selector<T> = Function.identity as
return () => {
listeners.delete(listener);
};
}, []);
}, [selector]);
return state;
}

View File

@@ -40,7 +40,7 @@ export default function Attachment() {
URL.revokeObjectURL(url);
window.removeEventListener("resize", render);
};
}, [isLoading, data]);
}, [data, error, isLoading]);
if (isLoading) {
return (

View File

@@ -25,7 +25,7 @@ export function Piece() {
const setAttachments = useCallback((action: Update<readonly Attachment[]>) => {
setData!(mapProp("attachments", action));
Effect.runFork(pieceCache.invalidate(id));
}, [setData]);
}, [id, setData]);
if (isLoading) {
return (
@@ -236,7 +236,7 @@ function AttachmentRow(props: AttachmentRow.Props) {
const url = URL.createObjectURL(data);
window.open(url, "_target");
URL.revokeObjectURL(url);
}, [props.attachment.mediaType, props.attachment.attachmentId, props.attachment.pieceId]);
}, [props.attachment.attachmentId, props.attachment.mediaType, props.attachment.pieceId]);
const doDelete = useCallback(async () => {
@@ -252,7 +252,7 @@ function AttachmentRow(props: AttachmentRow.Props) {
props.setAttachments((prev) => prev.filter((a) => a.attachmentId !== props.attachment.attachmentId));
}, [props.attachment.attachmentId, props.attachment.pieceId]);
}, [props]);
return (
<TableRow>

View File

@@ -1,10 +1,17 @@
import { Piece, repertoireCache } from "@/cache";
import { Piece, type Repertoire, repertoireCache } from "@/cache";
import { client } from "@/client";
import { Button } from "@/components/ui/button";
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 } from "@/hooks/useStore";
import { RepertoireId } from "common";
import { Cause, Effect } from "effect";
import { useCallback } from "react";
import { useParams } from "react-router-dom";
import { mapProp, Update, Updater } from "@/hooks/useStore";
import { timeout } from "@/lib/utils";
import { PieceId, RepertoireId } from "common";
import { Array, Cause, Effect, Option, pipe } from "effect";
import { CircleMinus, Loader2 } from "lucide-react";
import { FormEventHandler, ReactNode, useCallback, useId, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
export function Repertoire() {
@@ -15,7 +22,7 @@ export function Repertoire() {
const setEntries = useCallback((action: Update<readonly Piece[]>) => {
setData!(mapProp("entries", action));
Effect.runFork(repertoireCache.invalidate(id));
}, [setData]);
}, [id, setData]);
if (isLoading) {
return (
@@ -34,8 +41,188 @@ export function Repertoire() {
<h3 className="font-bold">Repertuar</h3>
<RepertoireForm repertoire={data} />
</div>
<Entries repertoireId={id} entries={data.entries} setData={setData} />
<Entries repertoire={data} setEntries={setEntries} />
</>)}
</div>
);
}
namespace RepertoireForm {
export interface Props {
readonly repertoire: Repertoire;
}
}
function RepertoireForm(props: RepertoireForm.Props) {
const navigate = useNavigate();
const [name, setName] = useState(props.repertoire.name);
const nameId = useId();
const [isSaving, setIsSaving] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const onSubmit: FormEventHandler<HTMLFormElement> = async (e) => {
e.preventDefault();
const delay = timeout(250);
try {
setIsSaving(true);
const { error } = await client.repertoire({ repertoireId: props.repertoire.repertoireId }).put({
name,
entries: props.repertoire.entries.map(({ pieceId }) => pieceId),
});
if (error !== null) {
console.error(error.value);
return;
}
} finally {
await delay;
setIsSaving(false);
}
};
const doDelete = useCallback(async () => {
try {
setIsDeleting(true);
const { error } = await client
.repertoire({ repertoireId: props.repertoire.repertoireId })
.delete();
if (error !== null) {
console.error(error.value);
return;
}
Effect.runFork(repertoireCache.invalidate(props.repertoire.repertoireId));
navigate("..");
} finally {
setIsDeleting(false);
}
}, [props.repertoire.repertoireId, navigate]);
return (
<form className="flex flex-col gap-4" onSubmit={onSubmit}>
<div className="grid items-baseline grid-cols-4 gap-4">
<Label htmlFor={nameId} className="text-right">Tytuł</Label>
<Input
id={nameId}
className="col-span-3"
type="text"
value={name}
required
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="flex justify-between">
<Button type="button" variant="destructive" disabled={isDeleting} onClick={doDelete}>
{isDeleting && <Loader2 className="animate-spin" />}
Usuń
</Button>
<Button type="submit" disabled={isSaving}>
{isSaving && <Loader2 className="animate-spin" />}
Zapisz
</Button>
</div>
</form>
);
}
namespace Entries {
export interface Props {
readonly repertoire: Repertoire;
readonly setEntries: Updater<readonly Piece[]>;
}
}
function Entries(props: Entries.Props) {
return (
<div className="grow">
<Table>
<TableHeader>
<TableRow>
<TableHead>Lp.</TableHead>
<TableHead>Tytuł</TableHead>
<TableHead>Twórcy</TableHead>
<TableHead className="text-center">Akcje</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{props.repertoire.entries.map((entry, i) => (
<EntryRow
key={entry.pieceId}
repertoire={props.repertoire}
piece={entry}
no={i + 1}
setEntries={props.setEntries}
/>
))}
</TableBody>
</Table>
</div>
);
}
namespace EntryRow {
export interface Props {
readonly repertoire: Repertoire;
readonly piece: Piece;
readonly no: number;
readonly setEntries: Updater<readonly Piece[]>;
}
}
function EntryRow(props: EntryRow.Props) {
const remove = useCallback(async () => {
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 })
.put({
name: props.repertoire.name,
entries: pipe(props.repertoire.entries, filter, mapToId),
});
if (error !== null) {
console.error(error.value);
return;
}
props.setEntries(filter);
}, [props]);
const composerParts: ReactNode[] = [];
if (Option.isSome(props.piece.composer)) composerParts.push(props.piece.composer.value);
if (Option.isSome(props.piece.arranger)) composerParts.push(`opracowanie: ${props.piece.arranger.value}`);
if (Option.isSome(props.piece.lyricist)) composerParts.push(`słowa: ${props.piece.lyricist.value}`);
if (composerParts.length === 0) composerParts.push(<em>Nieznani</em>);
return (
<TableRow>
<TableCell>
{props.no}
</TableCell>
<TableCell>
{props.piece.name}
</TableCell>
<TableCell>
{...composerParts.flatMap((x, i, a) => i < a.length - 1 ? [x, <br />] : [x])}
</TableCell>
<TableCell className="text-center flex justify-center gap-4">
<Button type="button" variant="ghost" size="icon" title="Usuń" onClick={remove}>
<CircleMinus />
</Button>
</TableCell>
</TableRow>
);
}

View File

@@ -26,6 +26,7 @@ export function Root() {
setUser(data);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => void init(), []);
const onLogoutClick = async () => {