mirror of
https://github.com/fosrl/pangolin.git
synced 2026-05-21 16:25:19 +00:00
✨ Create label dialog
This commit is contained in:
@@ -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.",
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
export type CreateEditOrgLabelProps = {};
|
|
||||||
|
|
||||||
export function CreateEditOrgLabel({}: CreateEditOrgLabelProps) {
|
|
||||||
return <></>;
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
|
|||||||
125
src/components/OrgLabelForm.tsx
Normal file
125
src/components/OrgLabelForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,6 +174,7 @@ export default function OrgLabelsTable({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{selectedLabel && (
|
{selectedLabel && (
|
||||||
|
<>
|
||||||
<ConfirmDeleteDialog
|
<ConfirmDeleteDialog
|
||||||
open={isDeleteModalOpen}
|
open={isDeleteModalOpen}
|
||||||
setOpen={(val) => {
|
setOpen={(val) => {
|
||||||
@@ -188,10 +192,21 @@ export default function OrgLabelsTable({
|
|||||||
string={selectedLabel.name}
|
string={selectedLabel.name}
|
||||||
title={t("labelDelete")}
|
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}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user