aboutsummaryrefslogtreecommitdiffhomepage
path: root/src/client
diff options
context:
space:
mode:
Diffstat (limited to 'src/client')
-rw-r--r--src/client/components/NoteTypeEditor.test.tsx229
-rw-r--r--src/client/components/NoteTypeEditor.tsx125
2 files changed, 329 insertions, 25 deletions
diff --git a/src/client/components/NoteTypeEditor.test.tsx b/src/client/components/NoteTypeEditor.test.tsx
index a628859..a8e8c51 100644
--- a/src/client/components/NoteTypeEditor.test.tsx
+++ b/src/client/components/NoteTypeEditor.test.tsx
@@ -12,6 +12,7 @@ const mockFieldPut = vi.fn();
const mockFieldDelete = vi.fn();
const mockFieldsReorder = vi.fn();
const mockHandleResponse = vi.fn();
+const mockAuthenticatedFetch = vi.fn();
vi.mock("../api/client", () => ({
apiClient: {
@@ -36,6 +37,8 @@ vi.mock("../api/client", () => ({
},
},
handleResponse: (res: unknown) => mockHandleResponse(res),
+ authenticatedFetch: (input: unknown, init: unknown) =>
+ mockAuthenticatedFetch(input, init),
},
ApiClientError: class ApiClientError extends Error {
constructor(
@@ -91,7 +94,7 @@ describe("NoteTypeEditor", () => {
mockNoteTypePut.mockResolvedValue({ ok: true });
mockFieldPost.mockResolvedValue({ ok: true });
mockFieldPut.mockResolvedValue({ ok: true });
- mockFieldDelete.mockResolvedValue({ ok: true });
+ mockFieldDelete.mockResolvedValue({ ok: true, status: 200 });
mockFieldsReorder.mockResolvedValue({ ok: true });
});
@@ -305,9 +308,9 @@ describe("NoteTypeEditor", () => {
it("deletes a field", async () => {
const user = userEvent.setup();
- mockHandleResponse
- .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields })
- .mockResolvedValueOnce({ success: true });
+ mockHandleResponse.mockResolvedValueOnce({
+ noteType: mockNoteTypeWithFields,
+ });
render(<NoteTypeEditor {...defaultProps} />);
@@ -330,14 +333,24 @@ describe("NoteTypeEditor", () => {
});
});
- it("displays error when field deletion fails", async () => {
+ it("shows confirmation when deleting field with values", async () => {
const user = userEvent.setup();
- mockHandleResponse
- .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields })
- .mockRejectedValueOnce(
- new ApiClientError("Cannot delete field with existing values", 409),
- );
+ mockHandleResponse.mockResolvedValueOnce({
+ noteType: mockNoteTypeWithFields,
+ });
+ mockFieldDelete.mockResolvedValueOnce({
+ ok: false,
+ status: 409,
+ json: () =>
+ Promise.resolve({
+ error: {
+ message: "Cannot delete field with existing values",
+ code: "FIELD_HAS_VALUES",
+ },
+ cardCount: 5,
+ }),
+ });
render(<NoteTypeEditor {...defaultProps} />);
@@ -345,9 +358,7 @@ describe("NoteTypeEditor", () => {
expect(screen.getByText("Front")).toBeDefined();
});
- // Find the delete button for the "Front" field (first delete button)
const deleteButtons = screen.getAllByTitle("Delete field");
- expect(deleteButtons.length).toBeGreaterThan(0);
const deleteButton = deleteButtons.at(0);
if (!deleteButton) throw new Error("Delete button not found");
@@ -356,13 +367,197 @@ describe("NoteTypeEditor", () => {
await waitFor(() => {
const alerts = screen.getAllByRole("alert");
expect(
- alerts.some((alert) =>
- alert.textContent?.includes(
- "Cannot delete field with existing values",
- ),
- ),
+ alerts.some((alert) => alert.textContent?.includes("5 card(s)")),
+ ).toBe(true);
+ expect(alerts.some((alert) => alert.textContent?.includes("Front"))).toBe(
+ true,
+ );
+ });
+ });
+
+ it("shows second confirmation after first is accepted", async () => {
+ const user = userEvent.setup();
+
+ mockHandleResponse.mockResolvedValueOnce({
+ noteType: mockNoteTypeWithFields,
+ });
+ mockFieldDelete.mockResolvedValueOnce({
+ ok: false,
+ status: 409,
+ json: () =>
+ Promise.resolve({
+ error: {
+ message: "Cannot delete field with existing values",
+ code: "FIELD_HAS_VALUES",
+ },
+ cardCount: 3,
+ }),
+ });
+
+ render(<NoteTypeEditor {...defaultProps} />);
+
+ await waitFor(() => {
+ expect(screen.getByText("Front")).toBeDefined();
+ });
+
+ // Click delete
+ const deleteButtons = screen.getAllByTitle("Delete field");
+ const deleteButton = deleteButtons.at(0);
+ if (!deleteButton) throw new Error("Delete button not found");
+ await user.click(deleteButton);
+
+ // First confirmation should appear
+ await waitFor(() => {
+ expect(
+ screen
+ .getAllByRole("alert")
+ .some((a) => a.textContent?.includes("3 card(s)")),
+ ).toBe(true);
+ });
+
+ // Click Delete on first confirmation
+ const confirmDeleteButton = screen.getByRole("button", { name: "Delete" });
+ await user.click(confirmDeleteButton);
+
+ // Second confirmation should appear
+ await waitFor(() => {
+ expect(
+ screen
+ .getAllByRole("alert")
+ .some((a) => a.textContent?.includes("cannot be undone")),
+ ).toBe(true);
+ });
+ });
+
+ it("force deletes field after two confirmations", async () => {
+ const user = userEvent.setup();
+
+ mockHandleResponse.mockResolvedValueOnce({
+ noteType: mockNoteTypeWithFields,
+ });
+ mockFieldDelete.mockResolvedValueOnce({
+ ok: false,
+ status: 409,
+ json: () =>
+ Promise.resolve({
+ error: {
+ message: "Cannot delete field with existing values",
+ code: "FIELD_HAS_VALUES",
+ },
+ cardCount: 2,
+ }),
+ });
+ mockAuthenticatedFetch.mockResolvedValueOnce({
+ ok: true,
+ status: 200,
+ json: () => Promise.resolve({ success: true }),
+ });
+
+ render(<NoteTypeEditor {...defaultProps} />);
+
+ await waitFor(() => {
+ expect(screen.getByText("Front")).toBeDefined();
+ });
+
+ // Click delete
+ const deleteButtons = screen.getAllByTitle("Delete field");
+ const deleteButton = deleteButtons.at(0);
+ if (!deleteButton) throw new Error("Delete button not found");
+ await user.click(deleteButton);
+
+ // First confirmation
+ await waitFor(() => {
+ expect(
+ screen
+ .getAllByRole("alert")
+ .some((a) => a.textContent?.includes("2 card(s)")),
).toBe(true);
});
+ await user.click(screen.getByRole("button", { name: "Delete" }));
+
+ // Second confirmation
+ await waitFor(() => {
+ expect(
+ screen
+ .getAllByRole("alert")
+ .some((a) => a.textContent?.includes("cannot be undone")),
+ ).toBe(true);
+ });
+ await user.click(screen.getByRole("button", { name: "Delete" }));
+
+ // Force delete should be called
+ await waitFor(() => {
+ expect(mockAuthenticatedFetch).toHaveBeenCalledWith(
+ "/api/note-types/note-type-123/fields/field-1?force=true",
+ { method: "DELETE" },
+ );
+ });
+
+ // Field should be removed from the list
+ await waitFor(() => {
+ expect(screen.queryByText("Front")).toBeNull();
+ });
+ });
+
+ it("cancels deletion when Cancel is clicked on confirmation", async () => {
+ const user = userEvent.setup();
+
+ mockHandleResponse.mockResolvedValueOnce({
+ noteType: mockNoteTypeWithFields,
+ });
+ mockFieldDelete.mockResolvedValueOnce({
+ ok: false,
+ status: 409,
+ json: () =>
+ Promise.resolve({
+ error: {
+ message: "Cannot delete field with existing values",
+ code: "FIELD_HAS_VALUES",
+ },
+ cardCount: 5,
+ }),
+ });
+
+ render(<NoteTypeEditor {...defaultProps} />);
+
+ await waitFor(() => {
+ expect(screen.getByText("Front")).toBeDefined();
+ });
+
+ // Click delete
+ const deleteButtons = screen.getAllByTitle("Delete field");
+ const deleteButton = deleteButtons.at(0);
+ if (!deleteButton) throw new Error("Delete button not found");
+ await user.click(deleteButton);
+
+ // Confirmation should appear
+ await waitFor(() => {
+ expect(
+ screen
+ .getAllByRole("alert")
+ .some((a) => a.textContent?.includes("5 card(s)")),
+ ).toBe(true);
+ });
+
+ // Click Cancel
+ const cancelButtons = screen.getAllByRole("button", { name: "Cancel" });
+ // The confirmation Cancel button (not the main dialog Cancel)
+ const confirmCancelButton = cancelButtons.find((btn) =>
+ btn.closest(".bg-amber-50"),
+ );
+ if (!confirmCancelButton) throw new Error("Cancel button not found");
+ await user.click(confirmCancelButton);
+
+ // Confirmation should disappear
+ await waitFor(() => {
+ const alerts = screen.queryAllByRole("alert");
+ expect(alerts.some((a) => a.textContent?.includes("5 card(s)"))).toBe(
+ false,
+ );
+ });
+
+ // Field should still be there
+ expect(screen.getByText("Front")).toBeDefined();
});
it("moves a field up", async () => {
diff --git a/src/client/components/NoteTypeEditor.tsx b/src/client/components/NoteTypeEditor.tsx
index 2487c62..f514653 100644
--- a/src/client/components/NoteTypeEditor.tsx
+++ b/src/client/components/NoteTypeEditor.tsx
@@ -42,6 +42,12 @@ interface NoteTypeEditorProps {
onNoteTypeUpdated: () => void;
}
+interface DeleteConfirmation {
+ fieldId: string;
+ cardCount: number;
+ step: 1 | 2;
+}
+
export function NoteTypeEditor({
isOpen,
noteTypeId,
@@ -62,6 +68,9 @@ export function NoteTypeEditor({
const [editingFieldId, setEditingFieldId] = useState<string | null>(null);
const [editingFieldName, setEditingFieldName] = useState("");
const [fieldError, setFieldError] = useState<string | null>(null);
+ const [deleteConfirm, setDeleteConfirm] = useState<DeleteConfirmation | null>(
+ null,
+ );
const editInputRef = useRef<HTMLInputElement>(null);
const fetchNoteType = useCallback(async () => {
@@ -114,6 +123,7 @@ export function NoteTypeEditor({
setIsAddingField(false);
setEditingFieldId(null);
setNoteType(null);
+ setDeleteConfirm(null);
onClose();
};
@@ -212,19 +222,50 @@ export function NoteTypeEditor({
}
};
- const handleDeleteField = async (fieldId: string) => {
+ const handleDeleteField = async (fieldId: string, force = false) => {
if (!noteType) return;
setFieldError(null);
try {
- const res = await apiClient.rpc.api["note-types"][":id"].fields[
- ":fieldId"
- ].$delete({
- param: { id: noteType.id, fieldId },
- });
- await apiClient.handleResponse(res);
- setFields(fields.filter((f) => f.id !== fieldId));
+ let res: Response;
+ if (force) {
+ res = await apiClient.authenticatedFetch(
+ `/api/note-types/${noteType.id}/fields/${fieldId}?force=true`,
+ { method: "DELETE" },
+ );
+ } else {
+ res = await apiClient.rpc.api["note-types"][":id"].fields[
+ ":fieldId"
+ ].$delete({
+ param: { id: noteType.id, fieldId },
+ });
+ }
+
+ if (res.ok) {
+ setFields(fields.filter((f) => f.id !== fieldId));
+ setDeleteConfirm(null);
+ return;
+ }
+
+ const body = (await res.json()) as {
+ error?: { message?: string; code?: string };
+ cardCount?: number;
+ };
+ if (
+ res.status === 409 &&
+ body.error?.code === "FIELD_HAS_VALUES" &&
+ body.cardCount !== undefined
+ ) {
+ setDeleteConfirm({ fieldId, cardCount: body.cardCount, step: 1 });
+ return;
+ }
+
+ throw new ApiClientError(
+ body.error?.message || `Request failed with status ${res.status}`,
+ res.status,
+ body.error?.code,
+ );
} catch (err) {
if (err instanceof ApiClientError) {
setFieldError(err.message);
@@ -290,6 +331,10 @@ export function NoteTypeEditor({
return null;
}
+ const confirmFieldName = deleteConfirm
+ ? fields.find((f) => f.id === deleteConfirm.fieldId)?.name
+ : null;
+
return (
<div
role="dialog"
@@ -390,6 +435,70 @@ export function NoteTypeEditor({
</div>
)}
+ {deleteConfirm && (
+ <div
+ role="alert"
+ className="bg-amber-50 text-amber-800 text-sm px-4 py-3 rounded-lg border border-amber-200 mb-3"
+ >
+ {deleteConfirm.step === 1 ? (
+ <>
+ <p>
+ This note type has{" "}
+ <strong>{deleteConfirm.cardCount} card(s)</strong>.
+ Deleting the field &ldquo;{confirmFieldName}
+ &rdquo; will remove its values from all cards.
+ </p>
+ <div className="flex gap-2 mt-2">
+ <button
+ type="button"
+ onClick={() =>
+ setDeleteConfirm({
+ ...deleteConfirm,
+ step: 2,
+ })
+ }
+ className="px-3 py-1.5 bg-error hover:bg-error/90 text-white text-xs font-medium rounded-lg transition-colors"
+ >
+ Delete
+ </button>
+ <button
+ type="button"
+ onClick={() => setDeleteConfirm(null)}
+ className="px-3 py-1.5 text-slate hover:bg-ivory text-xs font-medium rounded-lg transition-colors"
+ >
+ Cancel
+ </button>
+ </div>
+ </>
+ ) : (
+ <>
+ <p>
+ This action cannot be undone. Are you sure you want to
+ delete the field &ldquo;{confirmFieldName}&rdquo;?
+ </p>
+ <div className="flex gap-2 mt-2">
+ <button
+ type="button"
+ onClick={() =>
+ handleDeleteField(deleteConfirm.fieldId, true)
+ }
+ className="px-3 py-1.5 bg-error hover:bg-error/90 text-white text-xs font-medium rounded-lg transition-colors"
+ >
+ Delete
+ </button>
+ <button
+ type="button"
+ onClick={() => setDeleteConfirm(null)}
+ className="px-3 py-1.5 text-slate hover:bg-ivory text-xs font-medium rounded-lg transition-colors"
+ >
+ Cancel
+ </button>
+ </div>
+ </>
+ )}
+ </div>
+ )}
+
<div className="space-y-2 mb-4">
{fields.map((field, index) => (
<div