diff --git a/ui-v2/package-lock.json b/ui-v2/package-lock.json index ba67571b4e1f3..97803c3c45014 100644 --- a/ui-v2/package-lock.json +++ b/ui-v2/package-lock.json @@ -22,6 +22,7 @@ "@radix-ui/react-select": "^2.1.2", "@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-slot": "^1.1.0", + "@radix-ui/react-switch": "^1.1.1", "@radix-ui/react-tabs": "^1.1.1", "@radix-ui/react-toast": "^1.2.2", "@radix-ui/react-tooltip": "^1.1.3", @@ -2377,6 +2378,35 @@ } } }, + "node_modules/@radix-ui/react-switch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.1.1.tgz", + "integrity": "sha512-diPqDDoBcZPSicYoMWdWx+bCPuTRH4QSp9J+65IvtdS0Kuzt67bI6n32vCj8q6NZmYW/ah+2orOtMwcX5eQwIg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-use-previous": "1.1.0", + "@radix-ui/react-use-size": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-tabs": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.1.tgz", diff --git a/ui-v2/package.json b/ui-v2/package.json index 46fe5dbd6922c..43c190b4969bc 100644 --- a/ui-v2/package.json +++ b/ui-v2/package.json @@ -31,6 +31,7 @@ "@radix-ui/react-select": "^2.1.2", "@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-slot": "^1.1.0", + "@radix-ui/react-switch": "^1.1.1", "@radix-ui/react-tabs": "^1.1.1", "@radix-ui/react-toast": "^1.2.2", "@radix-ui/react-tooltip": "^1.1.3", diff --git a/ui-v2/src/components/concurrency/global-concurrency-view/create-or-edit-limit-dialog/index.tsx b/ui-v2/src/components/concurrency/global-concurrency-view/create-or-edit-limit-dialog/index.tsx new file mode 100644 index 0000000000000..01e640e3d8335 --- /dev/null +++ b/ui-v2/src/components/concurrency/global-concurrency-view/create-or-edit-limit-dialog/index.tsx @@ -0,0 +1,125 @@ +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; + +import { type GlobalConcurrencyLimit } from "@/hooks/global-concurrency-limits"; +import { useCreateOrEditLimitDialog } from "./use-create-or-edit-limit-dialog"; + +type Props = { + limitToUpdate: undefined | GlobalConcurrencyLimit; + onOpenChange: (open: boolean) => void; + onSubmit: () => void; + open: boolean; +}; + +export const CreateOrEditLimitDialog = ({ + limitToUpdate, + onOpenChange, + onSubmit, + open, +}: Props) => { + const { dialogTitle, form, isLoading, saveOrUpdate } = + useCreateOrEditLimitDialog({ + limitToUpdate, + onSubmit, + }); + + return ( + + + + {dialogTitle} + + +
+ void form.handleSubmit(saveOrUpdate)(e)} + className="space-y-4" + > + {form.formState.errors.root?.message} + ( + + Name + + + + + + )} + /> + ( + + Concurrency Limit + + + + + + )} + /> + ( + + Slot Decay Per Second + + + + + + )} + /> + ( + + Active + + + + + + )} + /> + + + + + + + + +
+
+ ); +}; diff --git a/ui-v2/src/components/concurrency/global-concurrency-view/create-or-edit-limit-dialog/use-create-or-edit-limit-dialog.ts b/ui-v2/src/components/concurrency/global-concurrency-view/create-or-edit-limit-dialog/use-create-or-edit-limit-dialog.ts new file mode 100644 index 0000000000000..f09e6f221c2cd --- /dev/null +++ b/ui-v2/src/components/concurrency/global-concurrency-view/create-or-edit-limit-dialog/use-create-or-edit-limit-dialog.ts @@ -0,0 +1,124 @@ +import { + GlobalConcurrencyLimit, + useCreateGlobalConcurrencyLimit, + useUpdateGlobalConcurrencyLimit, +} from "@/hooks/global-concurrency-limits"; +import { useToast } from "@/hooks/use-toast"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useEffect } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +const formSchema = z.object({ + active: z.boolean().default(true), + /** Coerce to solve common issue of transforming a string number to a number type */ + denied_slots: z.number().default(0).or(z.string()).pipe(z.coerce.number()), + /** Coerce to solve common issue of transforming a string number to a number type */ + limit: z.number().default(0).or(z.string()).pipe(z.coerce.number()), + name: z + .string() + .min(2, { message: "Name must be at least 2 characters" }) + .default(""), + /** Coerce to solve common issue of transforming a string number to a number type */ + slot_decay_per_second: z + .number() + .default(0) + .or(z.string()) + .pipe(z.coerce.number()), + /** Additional fields post creation. Coerce to solve common issue of transforming a string number to a number type */ + active_slots: z.number().default(0).or(z.string()).pipe(z.coerce.number()), +}); + +const DEFAULT_VALUES = { + active: true, + name: "", + limit: 0, + slot_decay_per_second: 0, + denied_slots: 0, + active_slots: 0, +} as const; + +type UseCreateOrEditLimitDialog = { + /** Limit to edit. Pass undefined if creating a new limit */ + limitToUpdate: GlobalConcurrencyLimit | undefined; + /** Callback after hitting Save or Update */ + onSubmit: () => void; +}; + +export const useCreateOrEditLimitDialog = ({ + limitToUpdate, + onSubmit, +}: UseCreateOrEditLimitDialog) => { + const { toast } = useToast(); + + const { createGlobalConcurrencyLimit, status: createStatus } = + useCreateGlobalConcurrencyLimit(); + const { updateGlobalConcurrencyLimit, status: updateStatus } = + useUpdateGlobalConcurrencyLimit(); + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: DEFAULT_VALUES, + }); + + // Sync form data with limit-to-edit data + useEffect(() => { + if (limitToUpdate) { + const { active, name, limit, slot_decay_per_second, active_slots } = + limitToUpdate; + form.reset({ active, name, limit, slot_decay_per_second, active_slots }); + } else { + form.reset(DEFAULT_VALUES); + } + }, [form, limitToUpdate]); + + const saveOrUpdate = (values: z.infer) => { + const onSettled = () => { + form.reset(DEFAULT_VALUES); + onSubmit(); + }; + + if (limitToUpdate?.id) { + updateGlobalConcurrencyLimit( + { + id_or_name: limitToUpdate.id, + ...values, + }, + { + onSuccess: () => { + toast({ title: "Limit updated" }); + }, + onError: (error) => { + const message = + error.message || "Unknown error while updating limit."; + form.setError("root", { message }); + }, + onSettled, + }, + ); + } else { + createGlobalConcurrencyLimit(values, { + onSuccess: () => { + toast({ title: "Limit created" }); + }, + onError: (error) => { + const message = + error.message || "Unknown error while creating variable."; + form.setError("root", { + message, + }); + }, + onSettled, + }); + } + }; + + return { + form, + saveOrUpdate, + dialogTitle: limitToUpdate + ? `Update ${limitToUpdate.name}` + : "Add Concurrency Limit", + isLoading: createStatus === "pending" || updateStatus === "pending", + }; +}; diff --git a/ui-v2/src/components/concurrency/global-concurrency-view/index.tsx b/ui-v2/src/components/concurrency/global-concurrency-view/index.tsx index 033d5fb162ed1..f61e30f4ba60e 100644 --- a/ui-v2/src/components/concurrency/global-concurrency-view/index.tsx +++ b/ui-v2/src/components/concurrency/global-concurrency-view/index.tsx @@ -1,28 +1,58 @@ import { useListGlobalConcurrencyLimits } from "@/hooks/global-concurrency-limits"; -import { useState } from "react"; - +import { useMemo, useState } from "react"; +import { CreateOrEditLimitDialog } from "./create-or-edit-limit-dialog"; +import { GlobalConcurrencyLimitEmptyState } from "./global-concurrency-limit-empty-state"; import { GlobalConcurrencyLimitsHeader } from "./global-concurrency-limits-header"; +type AddOrEditDialogState = { + open: boolean; + limitIdToEdit?: string; +}; + export const GlobalConcurrencyView = () => { - const [showAddDialog, setShowAddDialog] = useState(false); + const [openAddOrEditDialog, setOpenAddOrEditDialog] = + useState({ + open: false, + }); const { data } = useListGlobalConcurrencyLimits(); - const openAddDialog = () => setShowAddDialog(true); - const closeAddDialog = () => setShowAddDialog(false); + const selectedlimitToUpdate = useMemo(() => { + if (!openAddOrEditDialog.limitIdToEdit) { + return undefined; + } + return data.find((limit) => limit.id === openAddOrEditDialog.limitIdToEdit); + }, [data, openAddOrEditDialog.limitIdToEdit]); + + const openAddDialog = () => + setOpenAddOrEditDialog((curr) => ({ ...curr, open: true })); + + // close and deselect any selected limits + const closeAddDialog = () => setOpenAddOrEditDialog({ open: false }); return ( <> -
- -
-
TODO
-
    - {data.map((limit) => ( -
  • {JSON.stringify(limit)}
  • - ))} -
- {showAddDialog &&
TODO: DIALOG
} + {data.length === 0 ? ( + + ) : ( +
+ +
TODO
+
    + {data.map((limit) => ( +
  • {JSON.stringify(limit)}
  • + ))} +
+
+ )} + + setOpenAddOrEditDialog((curr) => ({ ...curr, open })) + } + limitToUpdate={selectedlimitToUpdate} + onSubmit={closeAddDialog} + /> ); }; diff --git a/ui-v2/src/components/ui/switch.tsx b/ui-v2/src/components/ui/switch.tsx new file mode 100644 index 0000000000000..e20fb378e4edc --- /dev/null +++ b/ui-v2/src/components/ui/switch.tsx @@ -0,0 +1,29 @@ +import * as SwitchPrimitives from "@radix-ui/react-switch"; +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +const Switch = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + className: string; + } +>(({ className, ...props }, ref) => ( + + + +)); +Switch.displayName = SwitchPrimitives.Root.displayName; + +export { Switch };