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

@@ -1,6 +1,7 @@
import eslint from "@eslint/js"; import eslint from "@eslint/js";
import tseslint from "typescript-eslint";
import stylistic from "@stylistic/eslint-plugin"; import stylistic from "@stylistic/eslint-plugin";
import reactHooks from "eslint-plugin-react-hooks";
import tseslint from "typescript-eslint";
export default tseslint.config({ export default tseslint.config({
extends: [ extends: [
@@ -10,6 +11,7 @@ export default tseslint.config({
files: ["packages/*/src/**/*.{ts,tsx}"], files: ["packages/*/src/**/*.{ts,tsx}"],
plugins: { plugins: {
"@stylistic": stylistic, "@stylistic": stylistic,
"react-hooks": reactHooks,
}, },
languageOptions: { languageOptions: {
parserOptions: { parserOptions: {
@@ -43,5 +45,7 @@ export default tseslint.config({
"@typescript-eslint/no-explicit-any": "off", "@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-namespace": "off", "@typescript-eslint/no-namespace": "off",
"@typescript-eslint/no-misused-promises": ["error", { checksVoidReturn: false }], "@typescript-eslint/no-misused-promises": ["error", { checksVoidReturn: false }],
"react-hooks/exhaustive-deps": "error",
"react-hooks/rules-of-hooks": "error",
}, },
}); });

View File

@@ -13,6 +13,7 @@
"devDependencies": { "devDependencies": {
"@eslint/js": "catalog:", "@eslint/js": "catalog:",
"@stylistic/eslint-plugin": "catalog:", "@stylistic/eslint-plugin": "catalog:",
"eslint-plugin-react-hooks": "catalog:",
"typescript": "catalog:", "typescript": "catalog:",
"typescript-eslint": "catalog:" "typescript-eslint": "catalog:"
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -25,7 +25,7 @@ export function Piece() {
const setAttachments = useCallback((action: Update<readonly Attachment[]>) => { const setAttachments = useCallback((action: Update<readonly Attachment[]>) => {
setData!(mapProp("attachments", action)); setData!(mapProp("attachments", action));
Effect.runFork(pieceCache.invalidate(id)); Effect.runFork(pieceCache.invalidate(id));
}, [setData]); }, [id, setData]);
if (isLoading) { if (isLoading) {
return ( return (
@@ -236,7 +236,7 @@ function AttachmentRow(props: AttachmentRow.Props) {
const url = URL.createObjectURL(data); const url = URL.createObjectURL(data);
window.open(url, "_target"); window.open(url, "_target");
URL.revokeObjectURL(url); 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 () => { 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.setAttachments((prev) => prev.filter((a) => a.attachmentId !== props.attachment.attachmentId));
}, [props.attachment.attachmentId, props.attachment.pieceId]); }, [props]);
return ( return (
<TableRow> <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 { useLoadingEffect } from "@/hooks/useLoading";
import { mapProp, Update } from "@/hooks/useStore"; import { mapProp, Update, Updater } from "@/hooks/useStore";
import { RepertoireId } from "common"; import { timeout } from "@/lib/utils";
import { Cause, Effect } from "effect"; import { PieceId, RepertoireId } from "common";
import { useCallback } from "react"; import { Array, Cause, Effect, Option, pipe } from "effect";
import { useParams } from "react-router-dom"; import { CircleMinus, Loader2 } from "lucide-react";
import { FormEventHandler, ReactNode, useCallback, useId, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
export function Repertoire() { export function Repertoire() {
@@ -15,7 +22,7 @@ export function Repertoire() {
const setEntries = useCallback((action: Update<readonly Piece[]>) => { const setEntries = useCallback((action: Update<readonly Piece[]>) => {
setData!(mapProp("entries", action)); setData!(mapProp("entries", action));
Effect.runFork(repertoireCache.invalidate(id)); Effect.runFork(repertoireCache.invalidate(id));
}, [setData]); }, [id, setData]);
if (isLoading) { if (isLoading) {
return ( return (
@@ -34,8 +41,188 @@ export function Repertoire() {
<h3 className="font-bold">Repertuar</h3> <h3 className="font-bold">Repertuar</h3>
<RepertoireForm repertoire={data} /> <RepertoireForm repertoire={data} />
</div> </div>
<Entries repertoireId={id} entries={data.entries} setData={setData} /> <Entries repertoire={data} setEntries={setEntries} />
</>)} </>)}
</div> </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); setUser(data);
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => void init(), []); useEffect(() => void init(), []);
const onLogoutClick = async () => { const onLogoutClick = async () => {

16
pnpm-lock.yaml generated
View File

@@ -63,6 +63,9 @@ catalogs:
elysia: elysia:
specifier: ^1.1.25 specifier: ^1.1.25
version: 1.1.25 version: 1.1.25
eslint-plugin-react-hooks:
specifier: ^5.1.0
version: 5.1.0
kysely: kysely:
specifier: ^0.27.4 specifier: ^0.27.4
version: 0.27.4 version: 0.27.4
@@ -116,6 +119,9 @@ importers:
'@stylistic/eslint-plugin': '@stylistic/eslint-plugin':
specifier: 'catalog:' specifier: 'catalog:'
version: 2.12.1(eslint@9.17.0(jiti@1.21.6))(typescript@5.7.2) version: 2.12.1(eslint@9.17.0(jiti@1.21.6))(typescript@5.7.2)
eslint-plugin-react-hooks:
specifier: 'catalog:'
version: 5.1.0(eslint@9.17.0(jiti@1.21.6))
typescript: typescript:
specifier: 'catalog:' specifier: 'catalog:'
version: 5.7.2 version: 5.7.2
@@ -1422,6 +1428,12 @@ packages:
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
engines: {node: '>=10'} engines: {node: '>=10'}
eslint-plugin-react-hooks@5.1.0:
resolution: {integrity: sha512-mpJRtPgHN2tNAvZ35AMfqeB3Xqeo273QxrHJsbBEPWODRM4r0yB6jfoROqKEYrOn27UtRPpcpHc2UqyBSuUNTw==}
engines: {node: '>=10'}
peerDependencies:
eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0
eslint-scope@8.2.0: eslint-scope@8.2.0:
resolution: {integrity: sha512-PHlWUfG6lvPc3yvP5A4PNyBL1W8fkDUccmI21JUu/+GKZBoH/W5u6usENXUrWFRsyoW5ACUjFGgAFQp5gUlb/A==} resolution: {integrity: sha512-PHlWUfG6lvPc3yvP5A4PNyBL1W8fkDUccmI21JUu/+GKZBoH/W5u6usENXUrWFRsyoW5ACUjFGgAFQp5gUlb/A==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -3695,6 +3707,10 @@ snapshots:
escape-string-regexp@4.0.0: {} escape-string-regexp@4.0.0: {}
eslint-plugin-react-hooks@5.1.0(eslint@9.17.0(jiti@1.21.6)):
dependencies:
eslint: 9.17.0(jiti@1.21.6)
eslint-scope@8.2.0: eslint-scope@8.2.0:
dependencies: dependencies:
esrecurse: 4.3.0 esrecurse: 4.3.0

View File

@@ -21,6 +21,7 @@ catalog:
clsx: '^2.1.1' clsx: '^2.1.1'
effect: '^3.11.4' effect: '^3.11.4'
elysia: '^1.1.25' elysia: '^1.1.25'
eslint-plugin-react-hooks: '^5.1.0'
kysely: '^0.27.4' kysely: '^0.27.4'
kysely-bun-sqlite: '^0.3.2' kysely-bun-sqlite: '^0.3.2'
lucide-react: '^0.462.0' lucide-react: '^0.462.0'