Create label dialog

This commit is contained in:
Fred KISSIE
2026-05-18 21:57:44 +02:00
parent 68d7b0a416
commit 25c08e7279
6 changed files with 208 additions and 38 deletions

View File

@@ -256,6 +256,15 @@
"resourceDelete": "Delete Resource", "resourceDelete": "Delete Resource",
"resourceDeleteConfirm": "Confirm Delete Resource", "resourceDeleteConfirm": "Confirm Delete Resource",
"labelDelete": "Delete Label", "labelDelete": "Delete Label",
"labelAdd": "Add Label",
"labelCreateSuccessMessage": "Label Created Successfully",
"labelEditSuccess": "Label Modified Successfully",
"labelNameField": "Label Name",
"labelColorField": "Label Color",
"labelPlaceholder": "Ex: homelab",
"labelCreate": "Create Label",
"createLabelDialogTitle": "Create Label",
"createLabelDialogDescription": "Create a new label that can be attached to this organization",
"labelDeleteConfirm": "Confirm Delete Label", "labelDeleteConfirm": "Confirm Delete Label",
"labelErrorDelete": "Failed to delete label", "labelErrorDelete": "Failed to delete label",
"labelMessageRemove": "This action is permanent. All sites, resources, and clients tagged with this label will be untagged.", "labelMessageRemove": "This action is permanent. All sites, resources, and clients tagged with this label will be untagged.",

View File

@@ -1,7 +0,0 @@
"use client";
export type CreateEditOrgLabelProps = {};
export function CreateEditOrgLabel({}: CreateEditOrgLabelProps) {
return <></>;
}

View File

@@ -1,9 +1,12 @@
"use client"; "use client";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import { createApiClient } from "@app/lib/api"; import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import type { CreateOrEditLabelResponse } from "@server/routers/labels/types";
import type { AxiosResponse } from "axios";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useState, useTransition } from "react"; import { useTransition } from "react";
import { import {
Credenza, Credenza,
CredenzaBody, CredenzaBody,
@@ -14,6 +17,7 @@ import {
CredenzaHeader, CredenzaHeader,
CredenzaTitle CredenzaTitle
} from "./Credenza"; } from "./Credenza";
import { OrgLabelForm } from "./OrgLabelForm";
import { Button } from "./ui/button"; import { Button } from "./ui/button";
export type CreateOrgLabelDialogProps = { export type CreateOrgLabelDialogProps = {
@@ -33,21 +37,45 @@ export function CreateOrgLabelDialog({
const api = createApiClient(useEnvContext()); const api = createApiClient(useEnvContext());
const [isSubmitting, startTransition] = useTransition(); const [isSubmitting, startTransition] = useTransition();
async function createOrgLabel(data: { name: string; color: string }) {
try {
const res = await api.post<
AxiosResponse<CreateOrEditLabelResponse>
>(`/org/${orgId}/labels`, data);
if (res.status === 201) {
setOpen(false);
onSuccess?.();
toast({
title: t("success"),
description: t("labelCreateSuccessMessage")
});
}
} catch (e) {
toast({
title: t("error"),
description: formatAxiosError(e, t("errorOccurred")),
variant: "destructive"
});
}
}
return ( return (
<Credenza open={open} onOpenChange={setOpen}> <Credenza open={open} onOpenChange={setOpen}>
<CredenzaContent className="max-w-3xl"> <CredenzaContent className="max-w-md">
<CredenzaHeader> <CredenzaHeader>
<CredenzaTitle> <CredenzaTitle>{t("createLabelDialogTitle")}</CredenzaTitle>
{t("createInternalResourceDialogCreateClientResource")}
</CredenzaTitle>
<CredenzaDescription> <CredenzaDescription>
{t( {t("createLabelDialogDescription")}
"createInternalResourceDialogCreateClientResourceDescription"
)}
</CredenzaDescription> </CredenzaDescription>
</CredenzaHeader> </CredenzaHeader>
<CredenzaBody> <CredenzaBody>
<></> <OrgLabelForm
onSubmit={(data) => {
startTransition(async () => createOrgLabel(data));
}}
/>
</CredenzaBody> </CredenzaBody>
<CredenzaFooter> <CredenzaFooter>
<CredenzaClose asChild> <CredenzaClose asChild>
@@ -56,16 +84,16 @@ export function CreateOrgLabelDialog({
onClick={() => setOpen(false)} onClick={() => setOpen(false)}
disabled={isSubmitting} disabled={isSubmitting}
> >
{t("createInternalResourceDialogCancel")} {t("cancel")}
</Button> </Button>
</CredenzaClose> </CredenzaClose>
<Button <Button
type="submit" type="submit"
form="create-internal-resource-form" form="org-label-form"
disabled={isSubmitting} disabled={isSubmitting}
loading={isSubmitting} loading={isSubmitting}
> >
{t("createInternalResourceDialogCreateResource")} {t("labelCreate")}
</Button> </Button>
</CredenzaFooter> </CredenzaFooter>
</CredenzaContent> </CredenzaContent>

View File

@@ -0,0 +1,125 @@
"use client";
import z from "zod";
import { Input } from "./ui/input";
import { useTranslations } from "use-intl";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
} from "./ui/form";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "./ui/select";
import { LABEL_COLORS } from "./labels-selector";
const labelFormSchema = z.object({
name: z.string().nonempty(),
color: z
.string()
.regex(/^#?([0-9a-f]{6}|[0-9a-f]{3})$/i)
.nonempty()
});
export type LabelFormData = z.infer<typeof labelFormSchema>;
export type OrgLabelFormProps = {
onSubmit: (data: LabelFormData) => void;
};
export function OrgLabelForm({ onSubmit }: OrgLabelFormProps) {
const t = useTranslations();
const colorValues = Object.values(LABEL_COLORS);
const randomColor =
colorValues[Math.floor(Math.random() * colorValues.length)];
const form = useForm({
resolver: zodResolver(labelFormSchema),
defaultValues: {
name: "",
color: randomColor
}
});
return (
<Form {...form}>
<form
id="org-label-form"
className="flex flex-col gap-4"
action={async () => {
if (await form.trigger()) {
onSubmit(form.getValues());
}
}}
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>{t("labelNameField")}</FormLabel>
<FormControl>
<Input
{...field}
placeholder={t("labelPlaceholder")}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="color"
render={({ field }) => (
<FormItem>
<FormLabel>{t("labelColorField")}</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
>
<SelectTrigger className="w-full">
<SelectValue
placeholder={t("selectColor")}
/>
</SelectTrigger>
<SelectContent>
{Object.entries(LABEL_COLORS).map(
([color, value]) => (
<SelectItem
value={value}
key={color}
className="flex items-center gap-2"
>
<div
className="size-4 rounded-full bg-(--color) flex-none"
style={{
// @ts-expect-error css color
"--color": value
}}
/>
<span data-name>{color}</span>
</SelectItem>
)
)}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
);
}

View File

@@ -32,6 +32,7 @@ import { LabelBadge } from "./label-badge";
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn"; import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
import { cn } from "@app/lib/cn"; import { cn } from "@app/lib/cn";
import ConfirmDeleteDialog from "./ConfirmDeleteDialog"; import ConfirmDeleteDialog from "./ConfirmDeleteDialog";
import { CreateOrgLabelDialog } from "./CreateOrgLabelDialog";
export type LabelRow = { export type LabelRow = {
labelId: number; labelId: number;
@@ -62,6 +63,8 @@ export default function OrgLabelsTable({
const [selectedLabel, setSelectedLabel] = useState<LabelRow | null>(null); const [selectedLabel, setSelectedLabel] = useState<LabelRow | null>(null);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [isRefreshing, startTransition] = useTransition(); const [isRefreshing, startTransition] = useTransition();
@@ -171,27 +174,39 @@ export default function OrgLabelsTable({
return ( return (
<> <>
{selectedLabel && ( {selectedLabel && (
<ConfirmDeleteDialog <>
open={isDeleteModalOpen} <ConfirmDeleteDialog
setOpen={(val) => { open={isDeleteModalOpen}
setIsDeleteModalOpen(val); setOpen={(val) => {
setSelectedLabel(null); setIsDeleteModalOpen(val);
}} setSelectedLabel(null);
dialog={ }}
<div className="space-y-2"> dialog={
<p>{t("labelQuestionRemove")}</p> <div className="space-y-2">
<p>{t("labelMessageRemove")}</p> <p>{t("labelQuestionRemove")}</p>
</div> <p>{t("labelMessageRemove")}</p>
} </div>
buttonText={t("labelDeleteConfirm")} }
onConfirm={async () => deleteLabel(selectedLabel)} buttonText={t("labelDeleteConfirm")}
string={selectedLabel.name} onConfirm={async () => deleteLabel(selectedLabel)}
title={t("labelDelete")} string={selectedLabel.name}
/> title={t("labelDelete")}
/>
</>
)} )}
<CreateOrgLabelDialog
open={isCreateModalOpen}
setOpen={setIsCreateModalOpen}
orgId={orgId}
onSuccess={() => startTransition(() => router.refresh())}
/>
<ControlledDataTable <ControlledDataTable
columns={columns} columns={columns}
rows={labels} rows={labels}
addButtonText={t("labelAdd")}
onAdd={() => setIsCreateModalOpen(true)}
tableId="org-labels-table" tableId="org-labels-table"
searchPlaceholder={t("labelSearch")} searchPlaceholder={t("labelSearch")}
pagination={pagination} pagination={pagination}

View File

@@ -38,7 +38,7 @@ export type LabelsSelectorProps = {
toggleLabel: (newlabel: SelectedLabel, action: "detach" | "attach") => void; toggleLabel: (newlabel: SelectedLabel, action: "detach" | "attach") => void;
}; };
const LABEL_COLORS = { export const LABEL_COLORS = {
red: "#ff6467", red: "#ff6467",
green: "#05df72", green: "#05df72",
blue: "#51a2ff", blue: "#51a2ff",