Add and fix react hooks lint rules, single repertoire view
This commit is contained in:
@@ -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),
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ export function useStore<T = Store>(selector: Selector<T> = Function.identity as
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}, []);
|
||||
}, [selector]);
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ export default function Attachment() {
|
||||
URL.revokeObjectURL(url);
|
||||
window.removeEventListener("resize", render);
|
||||
};
|
||||
}, [isLoading, data]);
|
||||
}, [data, error, isLoading]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ export function Root() {
|
||||
setUser(data);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
useEffect(() => void init(), []);
|
||||
|
||||
const onLogoutClick = async () => {
|
||||
|
||||
Reference in New Issue
Block a user