mirror of
https://github.com/fosrl/pangolin.git
synced 2026-05-21 00:05:25 +00:00
✨ delete org label endpoint
This commit is contained in:
@@ -1141,6 +1141,7 @@
|
|||||||
"idpErrorNotFound": "IdP not found",
|
"idpErrorNotFound": "IdP not found",
|
||||||
"inviteInvalid": "Invalid Invite",
|
"inviteInvalid": "Invalid Invite",
|
||||||
"labels": "Labels",
|
"labels": "Labels",
|
||||||
|
"orgLabelsDescription": "Manage labels in this organization.",
|
||||||
"addLabels": "Add labels",
|
"addLabels": "Add labels",
|
||||||
"siteLabelsTab": "Labels",
|
"siteLabelsTab": "Labels",
|
||||||
"siteLabelsDescription": "Manage labels associated with this site.",
|
"siteLabelsDescription": "Manage labels associated with this site.",
|
||||||
|
|||||||
@@ -151,6 +151,7 @@ export enum ActionsEnum {
|
|||||||
listOrgLabels = "listOrgLabels",
|
listOrgLabels = "listOrgLabels",
|
||||||
createOrgLabel = "createOrgLabel",
|
createOrgLabel = "createOrgLabel",
|
||||||
updateOrgLabel = "updateOrgLabel",
|
updateOrgLabel = "updateOrgLabel",
|
||||||
|
deleteOrgLabel = "deleteOrgLabel",
|
||||||
attachLabelToItem = "attachLabelToItem",
|
attachLabelToItem = "attachLabelToItem",
|
||||||
detachLabelFromItem = "detachLabelFromItem",
|
detachLabelFromItem = "detachLabelFromItem",
|
||||||
getAlertRule = "getAlertRule",
|
getAlertRule = "getAlertRule",
|
||||||
|
|||||||
@@ -757,6 +757,14 @@ authenticated.patch(
|
|||||||
labels.updateOrgLabel
|
labels.updateOrgLabel
|
||||||
);
|
);
|
||||||
|
|
||||||
|
authenticated.delete(
|
||||||
|
"/org/:orgId/label/:labelId",
|
||||||
|
verifyValidLicense,
|
||||||
|
verifyOrgAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.deleteOrgLabel),
|
||||||
|
labels.deleteOrgLabel
|
||||||
|
);
|
||||||
|
|
||||||
authenticated.put(
|
authenticated.put(
|
||||||
"/org/:orgId/label/:labelId/attach",
|
"/org/:orgId/label/:labelId/attach",
|
||||||
verifyValidLicense,
|
verifyValidLicense,
|
||||||
|
|||||||
72
server/private/routers/labels/deleteOrgLabel.ts
Normal file
72
server/private/routers/labels/deleteOrgLabel.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
/*
|
||||||
|
* This file is part of a proprietary work.
|
||||||
|
*
|
||||||
|
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||||
|
* All rights reserved.
|
||||||
|
*
|
||||||
|
* This file is licensed under the Fossorial Commercial License.
|
||||||
|
* You may not use this file except in compliance with the License.
|
||||||
|
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
*
|
||||||
|
* This file is not licensed under the AGPLv3.
|
||||||
|
*/
|
||||||
|
import { db, labels } from "@server/db";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import { NextFunction, Request, Response } from "express";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
|
||||||
|
const paramsSchema = z.strictObject({
|
||||||
|
orgId: z.string().nonempty(),
|
||||||
|
labelId: z.string().transform(Number).pipe(z.int().positive())
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function deleteOrgLabel(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const parsedParams = paramsSchema.safeParse(req.params);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { orgId, labelId } = parsedParams.data;
|
||||||
|
|
||||||
|
const [existing] = await db
|
||||||
|
.select()
|
||||||
|
.from(labels)
|
||||||
|
.where(and(eq(labels.labelId, labelId), eq(labels.orgId, orgId)));
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return next(createHttpError(HttpCode.NOT_FOUND, "Label not found"));
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.delete(labels)
|
||||||
|
.where(and(eq(labels.labelId, labelId), eq(labels.orgId, orgId)));
|
||||||
|
|
||||||
|
return response(res, {
|
||||||
|
data: null,
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Label deleted successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,3 +16,4 @@ export * from "./createOrgLabel";
|
|||||||
export * from "./updateOrgLabel";
|
export * from "./updateOrgLabel";
|
||||||
export * from "./attachLabelToItem";
|
export * from "./attachLabelToItem";
|
||||||
export * from "./detachLabelFromItem";
|
export * from "./detachLabelFromItem";
|
||||||
|
export * from "./deleteOrgLabel";
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
|
import { internal } from "@app/lib/api";
|
||||||
|
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||||
|
import { ListOrgLabelsResponse } from "@server/routers/labels/types";
|
||||||
|
import { AxiosResponse } from "axios";
|
||||||
|
import OrgLabelsTable from "@app/components/OrgLabelsTable";
|
||||||
|
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Enterprise Licenses"
|
title: "Labels"
|
||||||
};
|
};
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -14,7 +21,43 @@ export const dynamic = "force-dynamic";
|
|||||||
export default async function LabelsPage({ params, searchParams }: Props) {
|
export default async function LabelsPage({ params, searchParams }: Props) {
|
||||||
const { orgId } = await params;
|
const { orgId } = await params;
|
||||||
|
|
||||||
const sp = await searchParams;
|
const searchParamsObj = new URLSearchParams(await searchParams);
|
||||||
|
|
||||||
return <></>;
|
let labels: ListOrgLabelsResponse["labels"] = [];
|
||||||
|
let pagination: ListOrgLabelsResponse["pagination"] = {
|
||||||
|
total: 0,
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await internal.get<AxiosResponse<ListOrgLabelsResponse>>(
|
||||||
|
`/org/${orgId}/labels?${searchParamsObj.toString()}`,
|
||||||
|
await authCookieHeader()
|
||||||
|
);
|
||||||
|
const responseData = res.data.data;
|
||||||
|
labels = responseData.labels;
|
||||||
|
pagination = responseData.pagination;
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
const t = await getTranslations();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SettingsSectionTitle
|
||||||
|
title={t("labels")}
|
||||||
|
description={t("orgLabelsDescription")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<OrgLabelsTable
|
||||||
|
labels={labels}
|
||||||
|
orgId={orgId}
|
||||||
|
rowCount={pagination.total}
|
||||||
|
pagination={{
|
||||||
|
pageIndex: pagination.page - 1,
|
||||||
|
pageSize: pagination.pageSize
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1,231 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { Button } from "@app/components/ui/button";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger
|
||||||
|
} from "@app/components/ui/dropdown-menu";
|
||||||
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
|
import { useNavigationContext } from "@app/hooks/useNavigationContext";
|
||||||
|
import { toast } from "@app/hooks/useToast";
|
||||||
|
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||||
|
import { type PaginationState } from "@tanstack/react-table";
|
||||||
|
import {
|
||||||
|
ArrowDown01Icon,
|
||||||
|
ArrowUp10Icon,
|
||||||
|
ChevronsUpDownIcon,
|
||||||
|
MoreHorizontal,
|
||||||
|
PencilIcon,
|
||||||
|
PencilLineIcon
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
|
import { useActionState, useMemo, useState, useTransition } from "react";
|
||||||
|
import { useDebouncedCallback } from "use-debounce";
|
||||||
|
import {
|
||||||
|
ControlledDataTable,
|
||||||
|
type ExtendedColumnDef
|
||||||
|
} from "./ui/controlled-data-table";
|
||||||
|
import { LabelBadge } from "./label-badge";
|
||||||
|
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
|
||||||
|
import { cn } from "@app/lib/cn";
|
||||||
|
import ConfirmDeleteDialog from "./ConfirmDeleteDialog";
|
||||||
|
|
||||||
|
export type LabelRow = {
|
||||||
|
labelId: number;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type OrgLabelsTableProps = {
|
||||||
|
labels: LabelRow[];
|
||||||
|
pagination: PaginationState;
|
||||||
|
orgId: string;
|
||||||
|
rowCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function OrgLabelsTable({
|
||||||
|
labels,
|
||||||
|
orgId,
|
||||||
|
pagination,
|
||||||
|
rowCount
|
||||||
|
}: OrgLabelsTableProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const {
|
||||||
|
navigate: filter,
|
||||||
|
isNavigating: isFiltering,
|
||||||
|
searchParams
|
||||||
|
} = useNavigationContext();
|
||||||
|
|
||||||
|
const [selectedLabel, setSelectedLabel] = useState<LabelRow | null>(null);
|
||||||
|
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||||
|
|
||||||
|
const [isRefreshing, startRefreshTransition] = useTransition();
|
||||||
|
|
||||||
|
const api = createApiClient(useEnvContext());
|
||||||
|
const t = useTranslations();
|
||||||
|
|
||||||
|
function refreshData() {
|
||||||
|
startRefreshTransition(async () => {
|
||||||
|
try {
|
||||||
|
router.refresh();
|
||||||
|
} catch {
|
||||||
|
toast({
|
||||||
|
title: t("error"),
|
||||||
|
description: t("refreshError"),
|
||||||
|
variant: "destructive"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSort(column: string) {
|
||||||
|
const newSearch = getNextSortOrder(column, searchParams);
|
||||||
|
filter({ searchParams: newSearch });
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePaginationChange = (newPage: PaginationState) => {
|
||||||
|
searchParams.set("page", (newPage.pageIndex + 1).toString());
|
||||||
|
searchParams.set("pageSize", newPage.pageSize.toString());
|
||||||
|
filter({ searchParams });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSearchChange = useDebouncedCallback((query: string) => {
|
||||||
|
searchParams.set("query", query);
|
||||||
|
searchParams.delete("page");
|
||||||
|
filter({ searchParams });
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
const columns = useMemo<ExtendedColumnDef<LabelRow>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
accessorKey: "name",
|
||||||
|
enableHiding: false,
|
||||||
|
header: () => {
|
||||||
|
const nameOrder = getSortDirection("name", searchParams);
|
||||||
|
const Icon =
|
||||||
|
nameOrder === "asc"
|
||||||
|
? ArrowDown01Icon
|
||||||
|
: nameOrder === "desc"
|
||||||
|
? ArrowUp10Icon
|
||||||
|
: ChevronsUpDownIcon;
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="p-3"
|
||||||
|
onClick={() => toggleSort("name")}
|
||||||
|
>
|
||||||
|
{t("name")}
|
||||||
|
<Icon className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
cell: ({ row }) => <EditLabelCell label={row.original} />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "actions",
|
||||||
|
enableHiding: false,
|
||||||
|
header: () => {
|
||||||
|
return <span className="p-3">{t("actions")}</span>;
|
||||||
|
},
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||||
|
<span className="sr-only">{t("openMenu")}</span>
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem>{t("edit")}</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => {}}>
|
||||||
|
<span className="text-red-500">
|
||||||
|
{t("delete")}
|
||||||
|
</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[searchParams, t]
|
||||||
|
);
|
||||||
|
|
||||||
|
async function deleteLabel() {
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{selectedLabel && (
|
||||||
|
<ConfirmDeleteDialog
|
||||||
|
open={isDeleteModalOpen}
|
||||||
|
setOpen={(val) => {
|
||||||
|
setIsDeleteModalOpen(val);
|
||||||
|
setSelectedLabel(null);
|
||||||
|
}}
|
||||||
|
dialog={
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p>{t("resourceQuestionRemove")}</p>
|
||||||
|
<p>{t("resourceMessageRemove")}</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
buttonText={t("resourceDeleteConfirm")}
|
||||||
|
onConfirm={async () => {}}
|
||||||
|
string={selectedLabel.name}
|
||||||
|
title={t("resourceDelete")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<ControlledDataTable
|
||||||
|
columns={columns}
|
||||||
|
rows={labels}
|
||||||
|
tableId="org-labels-table"
|
||||||
|
searchPlaceholder={t("labelSearch")}
|
||||||
|
pagination={pagination}
|
||||||
|
onPaginationChange={handlePaginationChange}
|
||||||
|
searchQuery={searchParams.get("query")?.toString()}
|
||||||
|
onSearch={handleSearchChange}
|
||||||
|
onRefresh={refreshData}
|
||||||
|
isRefreshing={isRefreshing || isFiltering}
|
||||||
|
rowCount={rowCount}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type EditLabelCellProps = {
|
||||||
|
label: LabelRow;
|
||||||
|
};
|
||||||
|
|
||||||
|
function EditLabelCell({ label }: EditLabelCellProps) {
|
||||||
|
const t = useTranslations();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5 group">
|
||||||
|
<div
|
||||||
|
className="size-2.5 rounded-full bg-(--color) flex-none"
|
||||||
|
style={{
|
||||||
|
// @ts-expect-error css color
|
||||||
|
"--color": label.color
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{label.name}
|
||||||
|
|
||||||
|
{/* <Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className={cn(
|
||||||
|
"opacity-0 group-hover:opacity-100 text-xs",
|
||||||
|
"inline-flex gap-2 items-center"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t("edit")}
|
||||||
|
<PencilIcon className="size-3 flex-none" />
|
||||||
|
</Button> */}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
|
import { toast } from "@app/hooks/useToast";
|
||||||
|
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||||
import { orgQueries } from "@app/lib/queries";
|
import { orgQueries } from "@app/lib/queries";
|
||||||
|
import type { CreateOrEditLabelResponse } from "@server/routers/labels/types";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useActionState, useMemo, useState, useTransition } from "react";
|
import type { AxiosResponse } from "axios";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useActionState, useMemo, useState } from "react";
|
||||||
|
import { useDebounce } from "use-debounce";
|
||||||
|
import { Button } from "./ui/button";
|
||||||
|
import { Checkbox } from "./ui/checkbox";
|
||||||
import {
|
import {
|
||||||
Command,
|
Command,
|
||||||
CommandEmpty,
|
CommandEmpty,
|
||||||
@@ -9,11 +18,6 @@ import {
|
|||||||
CommandItem,
|
CommandItem,
|
||||||
CommandList
|
CommandList
|
||||||
} from "./ui/command";
|
} from "./ui/command";
|
||||||
import { Checkbox } from "./ui/checkbox";
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
import { useDebounce } from "use-debounce";
|
|
||||||
import { type Selectedsite, SiteOnlineStatus } from "./site-selector";
|
|
||||||
import { Button } from "./ui/button";
|
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -21,11 +25,6 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue
|
SelectValue
|
||||||
} from "./ui/select";
|
} from "./ui/select";
|
||||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
|
||||||
import type { CreateOrEditLabelResponse } from "@server/routers/labels/types";
|
|
||||||
import type { AxiosResponse } from "axios";
|
|
||||||
import { toast } from "@app/hooks/useToast";
|
|
||||||
|
|
||||||
export type SelectedLabel = {
|
export type SelectedLabel = {
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user