feat(web): authoring query/mutation hooks + MSW handlers + shadcn select/checkbox/alert-dialog
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -25,6 +25,7 @@
|
|||||||
"openapi-fetch": "^0.17.0",
|
"openapi-fetch": "^0.17.0",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
|
"react-hook-form": "^7.77.0",
|
||||||
"react-i18next": "^17.0.8",
|
"react-i18next": "^17.0.8",
|
||||||
"react-router-dom": "^7.16.0",
|
"react-router-dom": "^7.16.0",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
|
|||||||
Generated
+13
@@ -38,6 +38,9 @@ importers:
|
|||||||
react-dom:
|
react-dom:
|
||||||
specifier: ^19.1.0
|
specifier: ^19.1.0
|
||||||
version: 19.2.7(react@19.2.7)
|
version: 19.2.7(react@19.2.7)
|
||||||
|
react-hook-form:
|
||||||
|
specifier: ^7.77.0
|
||||||
|
version: 7.77.0(react@19.2.7)
|
||||||
react-i18next:
|
react-i18next:
|
||||||
specifier: ^17.0.8
|
specifier: ^17.0.8
|
||||||
version: 17.0.8(i18next@26.3.0(typescript@5.8.3))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.8.3)
|
version: 17.0.8(i18next@26.3.0(typescript@5.8.3))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.8.3)
|
||||||
@@ -2420,6 +2423,12 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^19.2.7
|
react: ^19.2.7
|
||||||
|
|
||||||
|
react-hook-form@7.77.0:
|
||||||
|
resolution: {integrity: sha512-Sslh9YDYc0GDlWT/lxasnIduNo4v3yyvqRGvmGKUre5AFjDs/HV9/OafHGD8d+sB2yoL4UIL9L8X9i0WlZZebg==}
|
||||||
|
engines: {node: '>=18.0.0'}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^16.8.0 || ^17 || ^18 || ^19
|
||||||
|
|
||||||
react-i18next@17.0.8:
|
react-i18next@17.0.8:
|
||||||
resolution: {integrity: sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==}
|
resolution: {integrity: sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -5200,6 +5209,10 @@ snapshots:
|
|||||||
react: 19.2.7
|
react: 19.2.7
|
||||||
scheduler: 0.27.0
|
scheduler: 0.27.0
|
||||||
|
|
||||||
|
react-hook-form@7.77.0(react@19.2.7):
|
||||||
|
dependencies:
|
||||||
|
react: 19.2.7
|
||||||
|
|
||||||
react-i18next@17.0.8(i18next@26.3.0(typescript@5.8.3))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.8.3):
|
react-i18next@17.0.8(i18next@26.3.0(typescript@5.8.3))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.8.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/runtime': 7.29.7
|
'@babel/runtime': 7.29.7
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
import { renderHook, waitFor } from "@testing-library/react";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
useAuthorities,
|
||||||
|
useCreateObject,
|
||||||
|
useDeleteObject,
|
||||||
|
useSetFields,
|
||||||
|
useTerms,
|
||||||
|
useUpdateObject,
|
||||||
|
} from "./queries";
|
||||||
|
|
||||||
|
function wrapper({ children }: { children: ReactNode }) {
|
||||||
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
|
||||||
|
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("authoring hooks", () => {
|
||||||
|
test("useTerms loads a vocabulary's terms", async () => {
|
||||||
|
const { result } = renderHook(() => useTerms("v-material"), { wrapper });
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.data).toBeDefined());
|
||||||
|
expect(result.current.data?.[0].id).toBe("t-bronze");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("useAuthorities loads by kind", async () => {
|
||||||
|
const { result } = renderHook(() => useAuthorities("person"), { wrapper });
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.data?.length).toBe(1));
|
||||||
|
expect(result.current.data?.[0].id).toBe("a-ada");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("useCreateObject returns the new id", async () => {
|
||||||
|
const { result } = renderHook(() => useCreateObject(), { wrapper });
|
||||||
|
|
||||||
|
const created = await result.current.mutateAsync({
|
||||||
|
object_number: "A-1",
|
||||||
|
object_name: "x",
|
||||||
|
number_of_objects: 1,
|
||||||
|
visibility: "draft",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(created.id).toBe("11111111-1111-1111-1111-111111111111");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("useSetFields / useUpdateObject / useDeleteObject resolve", async () => {
|
||||||
|
const setFields = renderHook(() => useSetFields(), { wrapper });
|
||||||
|
|
||||||
|
await setFields.result.current.mutateAsync({ id: "o1", fields: { inscription: "hi" } });
|
||||||
|
|
||||||
|
const update = renderHook(() => useUpdateObject(), { wrapper });
|
||||||
|
|
||||||
|
await update.result.current.mutateAsync({
|
||||||
|
id: "o1",
|
||||||
|
body: { object_number: "A-1", object_name: "x", number_of_objects: 1 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const del = renderHook(() => useDeleteObject(), { wrapper });
|
||||||
|
|
||||||
|
await del.result.current.mutateAsync("o1");
|
||||||
|
|
||||||
|
expect(true).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -95,3 +95,107 @@ export function useLogout() {
|
|||||||
onSuccess: () => qc.setQueryData(["me"], null),
|
onSuccess: () => qc.setQueryData(["me"], null),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ObjectCreateRequest = components["schemas"]["ObjectCreateRequest"];
|
||||||
|
type ObjectUpdateRequest = components["schemas"]["ObjectUpdateRequest"];
|
||||||
|
|
||||||
|
export function useTerms(vocabularyId: string | null | undefined) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["terms", vocabularyId],
|
||||||
|
enabled: !!vocabularyId,
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data, error } = await api.GET("/api/admin/vocabularies/{id}/terms", {
|
||||||
|
params: { path: { id: vocabularyId! } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error || !data) throw new Error("failed to load terms");
|
||||||
|
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuthorities(kind: string | null | undefined) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["authorities", kind],
|
||||||
|
enabled: !!kind,
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data, error } = await api.GET("/api/admin/authorities", {
|
||||||
|
params: { query: { kind: kind! } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error || !data) throw new Error("failed to load authorities");
|
||||||
|
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateObject() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (body: ObjectCreateRequest) => {
|
||||||
|
const { data, error } = await api.POST("/api/admin/objects", { body });
|
||||||
|
|
||||||
|
if (error || !data) throw new Error("create failed");
|
||||||
|
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["objects"] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateObject() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ id, body }: { id: string; body: ObjectUpdateRequest }) => {
|
||||||
|
const { response } = await api.PUT("/api/admin/objects/{id}", {
|
||||||
|
params: { path: { id } },
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status !== 204) throw new Error("update failed");
|
||||||
|
},
|
||||||
|
onSuccess: (_d, { id }) => {
|
||||||
|
void qc.invalidateQueries({ queryKey: ["objects"] });
|
||||||
|
void qc.invalidateQueries({ queryKey: ["object", id] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSetFields() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ id, fields }: { id: string; fields: Record<string, unknown> }) => {
|
||||||
|
const { response } = await api.PUT("/api/admin/objects/{id}/fields", {
|
||||||
|
params: { path: { id } },
|
||||||
|
body: fields as Record<string, never>,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status !== 204) throw new Error("set fields failed");
|
||||||
|
},
|
||||||
|
onSuccess: (_d, { id }) => {
|
||||||
|
void qc.invalidateQueries({ queryKey: ["object", id] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteObject() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (id: string) => {
|
||||||
|
const { response } = await api.DELETE("/api/admin/objects/{id}", {
|
||||||
|
params: { path: { id } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status !== 204) throw new Error("delete failed");
|
||||||
|
},
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["objects"] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
|
||||||
|
function AlertDialog({ ...props }: AlertDialogPrimitive.Root.Props) {
|
||||||
|
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: AlertDialogPrimitive.Backdrop.Props) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Backdrop
|
||||||
|
data-slot="alert-dialog-overlay"
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogContent({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: AlertDialogPrimitive.Popup.Props & {
|
||||||
|
size?: "default" | "sm"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPortal>
|
||||||
|
<AlertDialogOverlay />
|
||||||
|
<AlertDialogPrimitive.Popup
|
||||||
|
data-slot="alert-dialog-content"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</AlertDialogPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogHeader({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-header"
|
||||||
|
className={cn(
|
||||||
|
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-4 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogFooter({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-footer"
|
||||||
|
className={cn(
|
||||||
|
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogMedia({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-media"
|
||||||
|
className={cn(
|
||||||
|
"mb-2 inline-flex size-10 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-6",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Title
|
||||||
|
data-slot="alert-dialog-title"
|
||||||
|
className={cn(
|
||||||
|
"text-base font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Description
|
||||||
|
data-slot="alert-dialog-description"
|
||||||
|
className={cn(
|
||||||
|
"text-sm text-balance text-muted-foreground md:text-pretty *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogAction({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Button>) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
data-slot="alert-dialog-action"
|
||||||
|
className={cn(className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogCancel({
|
||||||
|
className,
|
||||||
|
variant = "outline",
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: AlertDialogPrimitive.Close.Props &
|
||||||
|
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Close
|
||||||
|
data-slot="alert-dialog-cancel"
|
||||||
|
className={cn(className)}
|
||||||
|
render={<Button variant={variant} size={size} />}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogMedia,
|
||||||
|
AlertDialogOverlay,
|
||||||
|
AlertDialogPortal,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { CheckIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
|
||||||
|
return (
|
||||||
|
<CheckboxPrimitive.Root
|
||||||
|
data-slot="checkbox"
|
||||||
|
className={cn(
|
||||||
|
"peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<CheckboxPrimitive.Indicator
|
||||||
|
data-slot="checkbox-indicator"
|
||||||
|
className="grid place-content-center text-current transition-none [&>svg]:size-3.5"
|
||||||
|
>
|
||||||
|
<CheckIcon
|
||||||
|
/>
|
||||||
|
</CheckboxPrimitive.Indicator>
|
||||||
|
</CheckboxPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Checkbox }
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Select as SelectPrimitive } from "@base-ui/react/select"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
|
||||||
|
|
||||||
|
const Select = SelectPrimitive.Root
|
||||||
|
|
||||||
|
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Group
|
||||||
|
data-slot="select-group"
|
||||||
|
className={cn("scroll-my-1 p-1", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Value
|
||||||
|
data-slot="select-value"
|
||||||
|
className={cn("flex flex-1 text-left", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectTrigger({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: SelectPrimitive.Trigger.Props & {
|
||||||
|
size?: "sm" | "default"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
data-slot="select-trigger"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon
|
||||||
|
render={
|
||||||
|
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
side = "bottom",
|
||||||
|
sideOffset = 4,
|
||||||
|
align = "center",
|
||||||
|
alignOffset = 0,
|
||||||
|
alignItemWithTrigger = true,
|
||||||
|
...props
|
||||||
|
}: SelectPrimitive.Popup.Props &
|
||||||
|
Pick<
|
||||||
|
SelectPrimitive.Positioner.Props,
|
||||||
|
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
|
||||||
|
>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Positioner
|
||||||
|
side={side}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
align={align}
|
||||||
|
alignOffset={alignOffset}
|
||||||
|
alignItemWithTrigger={alignItemWithTrigger}
|
||||||
|
className="isolate z-50"
|
||||||
|
>
|
||||||
|
<SelectPrimitive.Popup
|
||||||
|
data-slot="select-content"
|
||||||
|
data-align-trigger={alignItemWithTrigger}
|
||||||
|
className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectPrimitive.List>{children}</SelectPrimitive.List>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Popup>
|
||||||
|
</SelectPrimitive.Positioner>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectLabel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: SelectPrimitive.GroupLabel.Props) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.GroupLabel
|
||||||
|
data-slot="select-label"
|
||||||
|
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: SelectPrimitive.Item.Props) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
data-slot="select-item"
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.ItemText>
|
||||||
|
<SelectPrimitive.ItemIndicator
|
||||||
|
render={
|
||||||
|
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<CheckIcon className="pointer-events-none" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: SelectPrimitive.Separator.Props) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Separator
|
||||||
|
data-slot="select-separator"
|
||||||
|
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollUpButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollUpArrow
|
||||||
|
data-slot="select-scroll-up-button"
|
||||||
|
className={cn(
|
||||||
|
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronUpIcon
|
||||||
|
/>
|
||||||
|
</SelectPrimitive.ScrollUpArrow>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollDownButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollDownArrow
|
||||||
|
data-slot="select-scroll-down-button"
|
||||||
|
className={cn(
|
||||||
|
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronDownIcon
|
||||||
|
/>
|
||||||
|
</SelectPrimitive.ScrollDownArrow>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
|
SelectScrollDownButton,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
}
|
||||||
@@ -32,3 +32,33 @@ export const objectsPage: AdminObjectPage = {
|
|||||||
limit: 50,
|
limit: 50,
|
||||||
offset: 0,
|
offset: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type FieldDefinitionView = components["schemas"]["FieldDefinitionView"];
|
||||||
|
export type TermView = components["schemas"]["TermView"];
|
||||||
|
export type AuthorityView = components["schemas"]["AuthorityView"];
|
||||||
|
|
||||||
|
export const fieldDefinitions: FieldDefinitionView[] = [
|
||||||
|
{ key: "inscription", data_type: "text", vocabulary_id: null, authority_kind: null,
|
||||||
|
required: true, group: "Description", labels: [{ lang: "en", label: "Inscription" }, { lang: "sv", label: "Inskription" }] },
|
||||||
|
{ key: "count_seen", data_type: "integer", vocabulary_id: null, authority_kind: null,
|
||||||
|
required: false, group: null, labels: [{ lang: "en", label: "Count seen" }] },
|
||||||
|
{ key: "made_on", data_type: "date", vocabulary_id: null, authority_kind: null,
|
||||||
|
required: false, group: null, labels: [{ lang: "en", label: "Made on" }] },
|
||||||
|
{ key: "is_fragment", data_type: "boolean", vocabulary_id: null, authority_kind: null,
|
||||||
|
required: false, group: null, labels: [{ lang: "en", label: "Is fragment" }] },
|
||||||
|
{ key: "title_ml", data_type: "localized_text", vocabulary_id: null, authority_kind: null,
|
||||||
|
required: false, group: null, labels: [{ lang: "en", label: "Title" }] },
|
||||||
|
{ key: "material", data_type: "term", vocabulary_id: "v-material", authority_kind: null,
|
||||||
|
required: false, group: null, labels: [{ lang: "en", label: "Material" }] },
|
||||||
|
{ key: "maker", data_type: "authority", vocabulary_id: null, authority_kind: "person",
|
||||||
|
required: false, group: null, labels: [{ lang: "en", label: "Maker" }] },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const materialTerms: TermView[] = [
|
||||||
|
{ id: "t-bronze", external_uri: null, labels: [{ lang: "en", label: "Bronze" }, { lang: "sv", label: "Brons" }] },
|
||||||
|
{ id: "t-wood", external_uri: null, labels: [{ lang: "en", label: "Wood" }] },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const personAuthorities: AuthorityView[] = [
|
||||||
|
{ id: "a-ada", kind: "person", external_uri: null, labels: [{ lang: "en", label: "Ada Lovelace" }] },
|
||||||
|
];
|
||||||
|
|||||||
+19
-13
@@ -1,6 +1,6 @@
|
|||||||
import { http, HttpResponse } from "msw";
|
import { http, HttpResponse } from "msw";
|
||||||
|
|
||||||
import { amphora, fibula, objectsPage } from "./fixtures";
|
import { amphora, fibula, fieldDefinitions, materialTerms, objectsPage, personAuthorities } from "./fixtures";
|
||||||
|
|
||||||
export const handlers = [
|
export const handlers = [
|
||||||
http.get("/api/admin/me", () =>
|
http.get("/api/admin/me", () =>
|
||||||
@@ -15,20 +15,26 @@ export const handlers = [
|
|||||||
return found ? HttpResponse.json(found) : new HttpResponse(null, { status: 404 });
|
return found ? HttpResponse.json(found) : new HttpResponse(null, { status: 404 });
|
||||||
}),
|
}),
|
||||||
|
|
||||||
http.get("/api/admin/field-definitions", () =>
|
http.get("/api/admin/field-definitions", () => HttpResponse.json(fieldDefinitions)),
|
||||||
HttpResponse.json([
|
|
||||||
{
|
http.get("/api/admin/vocabularies/:id/terms", () => HttpResponse.json(materialTerms)),
|
||||||
key: "material",
|
|
||||||
data_type: "term",
|
http.get("/api/admin/authorities", ({ request }) => {
|
||||||
vocabulary_id: "v1",
|
const kind = new URL(request.url).searchParams.get("kind");
|
||||||
authority_kind: null,
|
|
||||||
required: false,
|
return HttpResponse.json(kind === "person" ? personAuthorities : []);
|
||||||
group: null,
|
}),
|
||||||
labels: [{ lang: "en", label: "Material" }],
|
|
||||||
},
|
http.post("/api/admin/objects", () =>
|
||||||
]),
|
HttpResponse.json({ id: "11111111-1111-1111-1111-111111111111" }, { status: 201 }),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
http.put("/api/admin/objects/:id", () => new HttpResponse(null, { status: 204 })),
|
||||||
|
|
||||||
|
http.put("/api/admin/objects/:id/fields", () => new HttpResponse(null, { status: 204 })),
|
||||||
|
|
||||||
|
http.delete("/api/admin/objects/:id", () => new HttpResponse(null, { status: 204 })),
|
||||||
|
|
||||||
http.post("/api/admin/login", () => new HttpResponse(null, { status: 204 })),
|
http.post("/api/admin/login", () => new HttpResponse(null, { status: 204 })),
|
||||||
|
|
||||||
http.post("/api/admin/logout", () => new HttpResponse(null, { status: 204 })),
|
http.post("/api/admin/logout", () => new HttpResponse(null, { status: 204 })),
|
||||||
|
|||||||
Reference in New Issue
Block a user