Add ability to edit and select cases for exports

This commit is contained in:
Nicolas Mowen 2025-12-15 11:18:33 -07:00
parent 7b4f747b6a
commit c8aae7745f
4 changed files with 341 additions and 5 deletions

View File

@ -17,11 +17,21 @@
"shareExport": "Share export", "shareExport": "Share export",
"downloadVideo": "Download video", "downloadVideo": "Download video",
"editName": "Edit name", "editName": "Edit name",
"deleteExport": "Delete export" "deleteExport": "Delete export",
"assignToCase": "Add to case"
}, },
"toast": { "toast": {
"error": { "error": {
"renameExportFailed": "Failed to rename export: {{errorMessage}}" "renameExportFailed": "Failed to rename export: {{errorMessage}}",
"assignCaseFailed": "Failed to update case assignment: {{errorMessage}}"
} }
},
"caseDialog": {
"title": "Add to case",
"description": "Choose an existing case or create a new one.",
"selectLabel": "Case",
"newCaseOption": "Create new case",
"nameLabel": "Case name",
"descriptionLabel": "Description"
} }
} }

View File

@ -35,8 +35,6 @@ type CaseCardProps = {
onSelect: () => void; onSelect: () => void;
}; };
export function CaseCard({ className, exportCase, onSelect }: CaseCardProps) { export function CaseCard({ className, exportCase, onSelect }: CaseCardProps) {
const { t } = useTranslation(["views/exports"]);
return ( return (
<div <div
className={cn( className={cn(
@ -59,6 +57,7 @@ type ExportCardProps = {
onSelect: (selected: Export) => void; onSelect: (selected: Export) => void;
onRename: (original: string, update: string) => void; onRename: (original: string, update: string) => void;
onDelete: ({ file, exportName }: DeleteClipType) => void; onDelete: ({ file, exportName }: DeleteClipType) => void;
onAssignToCase?: (selected: Export) => void;
}; };
export function ExportCard({ export function ExportCard({
className, className,
@ -66,6 +65,7 @@ export function ExportCard({
onSelect, onSelect,
onRename, onRename,
onDelete, onDelete,
onAssignToCase,
}: ExportCardProps) { }: ExportCardProps) {
const { t } = useTranslation(["views/exports"]); const { t } = useTranslation(["views/exports"]);
const isAdmin = useIsAdmin(); const isAdmin = useIsAdmin();
@ -223,6 +223,18 @@ export function ExportCard({
{t("tooltip.downloadVideo")} {t("tooltip.downloadVideo")}
</a> </a>
</DropdownMenuItem> </DropdownMenuItem>
{isAdmin && onAssignToCase && (
<DropdownMenuItem
className="cursor-pointer"
aria-label={t("tooltip.assignToCase")}
onClick={(e) => {
e.stopPropagation();
onAssignToCase(exportedRecording);
}}
>
{t("tooltip.assignToCase")}
</DropdownMenuItem>
)}
{isAdmin && ( {isAdmin && (
<DropdownMenuItem <DropdownMenuItem
className="cursor-pointer" className="cursor-pointer"

View File

@ -0,0 +1,166 @@
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
import { isMobile } from "react-device-detect";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
type Option = {
value: string;
label: string;
};
type OptionAndInputDialogProps = {
open: boolean;
title: string;
description?: string;
options: Option[];
newValueKey: string;
initialValue?: string;
nameLabel: string;
descriptionLabel: string;
setOpen: (open: boolean) => void;
onSave: (value: string) => void;
onCreateNew: (name: string, description: string) => void;
};
export default function OptionAndInputDialog({
open,
title,
description,
options,
newValueKey,
initialValue,
nameLabel,
descriptionLabel,
setOpen,
onSave,
onCreateNew,
}: OptionAndInputDialogProps) {
const { t } = useTranslation("common");
const firstOption = useMemo(() => options[0]?.value, [options]);
const [selectedValue, setSelectedValue] = useState<string | undefined>(
initialValue ?? firstOption,
);
const [name, setName] = useState("");
const [descriptionValue, setDescriptionValue] = useState("");
useEffect(() => {
if (open) {
setSelectedValue(initialValue ?? firstOption);
setName("");
setDescriptionValue("");
}
}, [open, initialValue, firstOption]);
const isNew = selectedValue === newValueKey;
const disableSave = !selectedValue || (isNew && name.trim().length === 0);
const handleSave = () => {
if (!selectedValue) {
return;
}
const trimmedName = name.trim();
const trimmedDescription = descriptionValue.trim();
if (isNew) {
onCreateNew(trimmedName, trimmedDescription);
} else {
onSave(selectedValue);
}
setOpen(false);
};
return (
<Dialog open={open} defaultOpen={false} onOpenChange={setOpen}>
<DialogContent
className={cn("space-y-4", isMobile && "px-4")}
onOpenAutoFocus={(e) => {
if (isMobile) {
e.preventDefault();
}
}}
>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
{description && <DialogDescription>{description}</DialogDescription>}
</DialogHeader>
<div className="space-y-2">
<Select
value={selectedValue}
onValueChange={(val) => setSelectedValue(val)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{isNew && (
<div className="space-y-4">
<div className="space-y-1">
<label className="text-sm font-medium text-secondary-foreground">
{nameLabel}
</label>
<Input value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div className="space-y-1">
<label className="text-sm font-medium text-secondary-foreground">
{descriptionLabel}
</label>
<Input
value={descriptionValue}
onChange={(e) => setDescriptionValue(e.target.value)}
/>
</div>
</div>
)}
<DialogFooter className={cn("pt-2", isMobile && "gap-2")}>
<Button
type="button"
variant="outline"
onClick={() => {
setOpen(false);
}}
>
{t("button.cancel")}
</Button>
<Button
type="button"
variant="select"
disabled={disableSave}
onClick={handleSave}
>
{t("button.save")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -18,6 +18,7 @@ import useKeyboardListener from "@/hooks/use-keyboard-listener";
import { useOverlayState, useSearchEffect } from "@/hooks/use-overlay-state"; import { useOverlayState, useSearchEffect } from "@/hooks/use-overlay-state";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { DeleteClipType, Export, ExportCase } from "@/types/export"; import { DeleteClipType, Export, ExportCase } from "@/types/export";
import OptionAndInputDialog from "@/components/overlay/dialog/OptionAndInputDialog";
import axios from "axios"; import axios from "axios";
import { import {
@ -97,6 +98,7 @@ function Exports() {
// Modifying // Modifying
const [deleteClip, setDeleteClip] = useState<DeleteClipType | undefined>(); const [deleteClip, setDeleteClip] = useState<DeleteClipType | undefined>();
const [exportToAssign, setExportToAssign] = useState<Export | undefined>();
const onHandleDelete = useCallback(() => { const onHandleDelete = useCallback(() => {
if (!deleteClip) { if (!deleteClip) {
@ -146,10 +148,22 @@ function Exports() {
[cases, selectedCaseId], [cases, selectedCaseId],
); );
const resetCaseDialog = useCallback(() => {
setExportToAssign(undefined);
}, []);
return ( return (
<div className="flex size-full flex-col gap-2 overflow-hidden px-1 pt-2 md:p-2"> <div className="flex size-full flex-col gap-2 overflow-hidden px-1 pt-2 md:p-2">
<Toaster closeButton={true} /> <Toaster closeButton={true} />
<CaseAssignmentDialog
exportToAssign={exportToAssign}
cases={cases}
selectedCaseId={selectedCaseId}
onClose={resetCaseDialog}
mutate={mutate}
/>
<AlertDialog <AlertDialog
open={deleteClip != undefined} open={deleteClip != undefined}
onOpenChange={() => setDeleteClip(undefined)} onOpenChange={() => setDeleteClip(undefined)}
@ -238,6 +252,7 @@ function Exports() {
setSelected={setSelected} setSelected={setSelected}
renameClip={onHandleRename} renameClip={onHandleRename}
setDeleteClip={setDeleteClip} setDeleteClip={setDeleteClip}
onAssignToCase={setExportToAssign}
/> />
) : ( ) : (
<AllExportsView <AllExportsView
@ -249,6 +264,7 @@ function Exports() {
setSelected={setSelected} setSelected={setSelected}
renameClip={onHandleRename} renameClip={onHandleRename}
setDeleteClip={setDeleteClip} setDeleteClip={setDeleteClip}
onAssignToCase={setExportToAssign}
/> />
)} )}
</div> </div>
@ -264,6 +280,7 @@ type AllExportsViewProps = {
setSelected: (e: Export) => void; setSelected: (e: Export) => void;
renameClip: (id: string, update: string) => void; renameClip: (id: string, update: string) => void;
setDeleteClip: (d: DeleteClipType | undefined) => void; setDeleteClip: (d: DeleteClipType | undefined) => void;
onAssignToCase: (e: Export) => void;
}; };
function AllExportsView({ function AllExportsView({
contentRef, contentRef,
@ -274,6 +291,7 @@ function AllExportsView({
setSelected, setSelected,
renameClip, renameClip,
setDeleteClip, setDeleteClip,
onAssignToCase,
}: AllExportsViewProps) { }: AllExportsViewProps) {
const { t } = useTranslation(["views/exports"]); const { t } = useTranslation(["views/exports"]);
@ -354,6 +372,7 @@ function AllExportsView({
onDelete={({ file, exportName }) => onDelete={({ file, exportName }) =>
setDeleteClip({ file, exportName }) setDeleteClip({ file, exportName })
} }
onAssignToCase={onAssignToCase}
/> />
))} ))}
</div> </div>
@ -377,6 +396,7 @@ type CaseViewProps = {
setSelected: (e: Export) => void; setSelected: (e: Export) => void;
renameClip: (id: string, update: string) => void; renameClip: (id: string, update: string) => void;
setDeleteClip: (d: DeleteClipType | undefined) => void; setDeleteClip: (d: DeleteClipType | undefined) => void;
onAssignToCase: (e: Export) => void;
}; };
function CaseView({ function CaseView({
contentRef, contentRef,
@ -386,6 +406,7 @@ function CaseView({
setSelected, setSelected,
renameClip, renameClip,
setDeleteClip, setDeleteClip,
onAssignToCase,
}: CaseViewProps) { }: CaseViewProps) {
const filteredExports = useMemo<Export[]>(() => { const filteredExports = useMemo<Export[]>(() => {
const caseExports = (exports || []).filter( const caseExports = (exports || []).filter(
@ -418,7 +439,7 @@ function CaseView({
ref={contentRef} ref={contentRef}
className="scrollbar-container grid size-full gap-2 overflow-y-auto sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4" className="scrollbar-container grid size-full gap-2 overflow-y-auto sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
> >
{exports.map((item) => ( {exports?.map((item) => (
<ExportCard <ExportCard
key={item.name} key={item.name}
className={filteredExports.includes(item) ? "" : "hidden"} className={filteredExports.includes(item) ? "" : "hidden"}
@ -428,6 +449,7 @@ function CaseView({
onDelete={({ file, exportName }) => onDelete={({ file, exportName }) =>
setDeleteClip({ file, exportName }) setDeleteClip({ file, exportName })
} }
onAssignToCase={onAssignToCase}
/> />
))} ))}
</div> </div>
@ -435,4 +457,130 @@ function CaseView({
); );
} }
type CaseAssignmentDialogProps = {
exportToAssign?: Export;
cases?: ExportCase[];
selectedCaseId?: string;
onClose: () => void;
mutate: () => void;
};
function CaseAssignmentDialog({
exportToAssign,
cases,
selectedCaseId,
onClose,
mutate,
}: CaseAssignmentDialogProps) {
const { t } = useTranslation(["views/exports"]);
const caseOptions = useMemo(
() => [
...(cases ?? []).map((c) => ({
value: c.id,
label: c.name,
})),
{
value: "new",
label: t("caseDialog.newCaseOption"),
},
],
[cases, t],
);
const handleSave = useCallback(
async (caseId: string) => {
if (!exportToAssign) return;
try {
await axios.patch(`export/${exportToAssign.id}/case`, {
export_case_id: caseId,
});
mutate();
onClose();
} catch (error: unknown) {
const errorMessage =
(
error as {
response?: { data?: { message?: string; detail?: string } };
}
).response?.data?.message ||
(
error as {
response?: { data?: { message?: string; detail?: string } };
}
).response?.data?.detail ||
"Unknown error";
toast.error(t("toast.error.assignCaseFailed", { errorMessage }), {
position: "top-center",
});
}
},
[exportToAssign, mutate, onClose, t],
);
const handleCreateNew = useCallback(
async (name: string, description: string) => {
if (!exportToAssign) return;
try {
const createResp = await axios.post("cases", {
name,
description,
});
const newCaseId: string | undefined = createResp.data?.id;
if (newCaseId) {
await axios.patch(`export/${exportToAssign.id}/case`, {
export_case_id: newCaseId,
});
}
mutate();
onClose();
} catch (error: unknown) {
const errorMessage =
(
error as {
response?: { data?: { message?: string; detail?: string } };
}
).response?.data?.message ||
(
error as {
response?: { data?: { message?: string; detail?: string } };
}
).response?.data?.detail ||
"Unknown error";
toast.error(t("toast.error.assignCaseFailed", { errorMessage }), {
position: "top-center",
});
}
},
[exportToAssign, mutate, onClose, t],
);
if (!exportToAssign) {
return null;
}
return (
<OptionAndInputDialog
open={!!exportToAssign}
title={t("caseDialog.title")}
description={t("caseDialog.description")}
setOpen={(open) => {
if (!open) {
onClose();
}
}}
options={caseOptions}
nameLabel={t("caseDialog.nameLabel")}
descriptionLabel={t("caseDialog.descriptionLabel")}
initialValue={selectedCaseId}
newValueKey="new"
onSave={handleSave}
onCreateNew={handleCreateNew}
/>
);
}
export default Exports; export default Exports;