Files
Nexus/apps/web/src/components/resources/BulkEditModal.tsx
T
HartmutandClaude Opus 4.6 e08ee94546 fix(web): accessibility pass — add aria-labels, dialog roles, and pressed states
- KeyboardShortcutOverlay: add role="dialog", aria-modal, aria-labelledby, close button aria-label
- Timeline popovers (5 files): add aria-label="Close" to symbol-only close buttons
- TimelineToolbar: add aria-label to navigation and undo/redo icon buttons
- ComputationGraphClient: add aria-pressed to 2D/3D and view mode toggle buttons
- BulkEditModal: fix type mismatch from jsonb field hardening

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 23:27:56 +02:00

250 lines
7.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState } from "react";
import { FieldType } from "@capakraken/shared";
import type { BlueprintFieldDefinition } from "@capakraken/shared";
import { trpc } from "~/lib/trpc/client.js";
interface Props {
selectedIds: string[];
fieldDefs: BlueprintFieldDefinition[];
onClose: () => void;
onSuccess: () => void;
}
export function BulkEditModal({ selectedIds, fieldDefs, onClose, onSuccess }: Props) {
// Track which fields are included in the bulk update + their new values
const [included, setIncluded] = useState<Set<string>>(new Set());
const [values, setValues] = useState<Record<string, unknown>>({});
const [error, setError] = useState<string | null>(null);
const utils = trpc.useUtils();
const mutation = trpc.resource.batchUpdateCustomFields.useMutation({
onSuccess: () => {
void utils.resource.directory.invalidate();
void utils.resource.listStaff.invalidate();
onSuccess();
onClose();
},
onError: (err) => setError(err.message),
});
function toggleInclude(key: string) {
setIncluded((prev) => {
const next = new Set(prev);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
return next;
});
}
function setValue(key: string, value: unknown) {
setValues((prev) => ({ ...prev, [key]: value }));
}
function handleSave() {
setError(null);
const fields: Record<string, string | number | boolean | null> = {};
for (const key of included) {
const val = values[key] ?? "";
fields[key] =
typeof val === "string" || typeof val === "number" || typeof val === "boolean"
? val
: String(val);
}
if (Object.keys(fields).length === 0) {
setError("Select at least one field to update.");
return;
}
mutation.mutate({ ids: selectedIds, fields });
}
function handleBackdrop(e: React.MouseEvent<HTMLDivElement>) {
if (e.target === e.currentTarget) onClose();
}
const editableFields = fieldDefs.filter((f) => !f.required || included.has(f.key));
return (
<div
className="fixed inset-0 bg-black/50 z-50 flex items-start justify-center overflow-y-auto py-8"
onClick={handleBackdrop}
>
<div className="bg-white rounded-xl shadow-2xl w-full max-w-lg mx-4">
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
<div>
<h2 className="text-lg font-semibold text-gray-900">Bulk Edit Custom Fields</h2>
<p className="text-sm text-gray-500 mt-0.5">
Updating {selectedIds.length} resource{selectedIds.length !== 1 ? "s" : ""}
</p>
</div>
<button
type="button"
onClick={onClose}
className="text-gray-400 hover:text-gray-600 text-2xl leading-none"
aria-label="Close"
>
×
</button>
</div>
<div className="px-6 py-4 space-y-3 max-h-[60vh] overflow-y-auto">
{fieldDefs.length === 0 && (
<p className="text-sm text-gray-400 text-center py-6">
No custom fields defined. Configure them in Admin Blueprints.
</p>
)}
{fieldDefs.map((field) => (
<div
key={field.key}
className={`border rounded-lg p-3 transition-colors ${included.has(field.key) ? "border-brand-300 bg-brand-50" : "border-gray-200"}`}
>
<label className="flex items-center gap-2 mb-2 cursor-pointer">
<input
type="checkbox"
checked={included.has(field.key)}
onChange={() => toggleInclude(field.key)}
className="rounded border-gray-300 text-brand-600"
/>
<span className="text-sm font-medium text-gray-700">{field.label}</span>
{field.required && <span className="text-xs text-red-500">required</span>}
</label>
{included.has(field.key) && (
<FieldInput
field={field}
value={values[field.key]}
onChange={(v) => setValue(field.key, v)}
/>
)}
</div>
))}
</div>
{error && (
<div className="mx-6 mb-2 px-3 py-2 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
{error}
</div>
)}
<div className="flex items-center justify-between px-6 py-4 border-t border-gray-200">
<p className="text-xs text-gray-400">
{included.size} field{included.size !== 1 ? "s" : ""} selected
</p>
<div className="flex gap-3">
<button
type="button"
onClick={onClose}
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 text-sm font-medium"
>
Cancel
</button>
<button
type="button"
onClick={handleSave}
disabled={mutation.isPending || included.size === 0}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
>
{mutation.isPending
? "Saving…"
: `Apply to ${selectedIds.length} resource${selectedIds.length !== 1 ? "s" : ""}`}
</button>
</div>
</div>
</div>
</div>
);
}
function FieldInput({
field,
value,
onChange,
}: {
field: BlueprintFieldDefinition;
value: unknown;
onChange: (v: unknown) => void;
}) {
const str = value !== undefined && value !== null ? String(value) : "";
if (field.type === FieldType.BOOLEAN) {
return (
<select
value={str}
onChange={(e) => onChange(e.target.value === "true")}
className="app-input"
>
<option value=""> select </option>
<option value="true">Yes</option>
<option value="false">No</option>
</select>
);
}
if (field.type === FieldType.SELECT && field.options) {
return (
<select value={str} onChange={(e) => onChange(e.target.value)} className="app-input">
<option value=""> select </option>
{field.options.map((o) => (
<option key={o.value} value={o.value}>
{o.label || o.value}
</option>
))}
</select>
);
}
if (field.type === FieldType.NUMBER) {
return (
<input
type="number"
value={str}
onChange={(e) => onChange(e.target.value ? Number(e.target.value) : "")}
placeholder={field.placeholder}
className="app-input"
/>
);
}
if (field.type === FieldType.DATE) {
return (
<input
type="date"
value={str}
onChange={(e) => onChange(e.target.value)}
className="app-input"
/>
);
}
if (field.type === FieldType.TEXTAREA) {
return (
<textarea
value={str}
onChange={(e) => onChange(e.target.value)}
placeholder={field.placeholder}
className="app-input resize-none"
rows={3}
/>
);
}
return (
<input
type={
field.type === FieldType.EMAIL ? "email" : field.type === FieldType.URL ? "url" : "text"
}
value={str}
onChange={(e) => onChange(e.target.value)}
placeholder={field.placeholder}
className="app-input"
/>
);
}
// Re-export for convenience
export type { Props as BulkEditModalProps };