src/components/dives/use-pending-dive-files.ts
Rama main · réplica de lectura del repositorio original.
"use client";
import { useState } from "react";
import { divesAPI, type Recording } from "@/lib/api/dives";
import { getApiErrorMessage } from "@/lib/api/error";
import type { PendingDiveFile } from "@/components/dives/dive-recording-files";
import { useToast } from "@/components/ui/use-toast";
/** What `DiveFileImport` takes to list a dive's files and change them. */
export interface DiveFileListProps {
onFileAdded: (pending: PendingDiveFile) => void;
pending: PendingDiveFile[];
onRemovePending: (id: string) => void;
removedStored: string[];
onRemoveStored: (fileUuid: string) => void;
onRestoreStored: (fileUuid: string) => void;
}
export interface SaveDiveFilesOptions {
/** Leads each failure's title with what did save, e.g. "Dive updated, but ". */
saved?: string;
/** A failed attach's description when the API gives no reason. */
attachFallback?: string;
}
/**
* The files a dive form has picked and struck off, held until the dive is saved: a
* picked file can only be attached once the dive exists, and a stored one is deleted
* in the same pass rather than when it is struck off, so that Cancel leaves the dive's
* files as it found them - see "The edit forms delete a stored file on save" in
* DECISIONS.md.
*
* `recordings` are the dive's stored files, which name a struck-off file in a
* failure toast.
*/
export function usePendingDiveFiles(recordings: readonly Recording[] = []) {
const { toast } = useToast();
const [pending, setPending] = useState<PendingDiveFile[]>([]);
const [removedStored, setRemovedStored] = useState<string[]>([]);
const fileList: DiveFileListProps = {
onFileAdded: (item) => setPending((files) => [...files, item]),
pending,
onRemovePending: (id) =>
setPending((files) => files.filter((item) => item.id !== id)),
removedStored,
onRemoveStored: (fileUuid) =>
setRemovedStored((uuids) =>
uuids.includes(fileUuid) ? uuids : [...uuids, fileUuid],
),
onRestoreStored: (fileUuid) =>
setRemovedStored((uuids) => uuids.filter((uuid) => uuid !== fileUuid)),
};
// The uuid is the fallback for a dive re-read out from under the form.
const storedName = (fileUuid: string) =>
recordings
.flatMap((recording) => recording.files)
.find((file) => file.uuid === fileUuid)?.original_filename ?? "a file";
/**
* Deletes what was struck off, then attaches what was picked, once the dive itself
* has saved. Removals first, so each attach lands beside the rows the diver was
* looking at. Attaches one at a time and in pick order: the API decides per file
* which recording it joins, and racing two would make that depend on arrival order.
*
* Each failure is its own toast and none stops the next. The dive is saved either
* way, and a file left behind is a smaller loss than a save made twice - the 409 of
* an export already on another dive, the 422 of an expired import and the 413 of a
* storage limit all arrive here in the API's own words.
*/
const save = async (
diveUuid: string,
{
saved = "",
attachFallback = "Try importing the file again.",
}: SaveDiveFilesOptions = {},
) => {
for (const fileUuid of removedStored) {
try {
await divesAPI.deleteDiveFile(diveUuid, fileUuid);
} catch (error) {
console.error("Failed to delete the dive file:", error);
toast({
title: `${saved}${storedName(fileUuid)} wasn't deleted`,
description: getApiErrorMessage(
error,
"Try removing the file again.",
),
variant: "destructive",
});
}
}
for (const item of pending) {
try {
await divesAPI.attachRecordingFile(diveUuid, item.file, item.token);
} catch (error) {
console.error("Failed to attach the dive file:", error);
toast({
title: `${saved}${item.file.name} wasn't attached`,
description: getApiErrorMessage(error, attachFallback),
variant: "destructive",
});
}
}
};
return {
fileList,
hasChanges: pending.length > 0 || removedStored.length > 0,
save,
};
}