mirror of
https://github.com/hex248/sprint.git
synced 2026-02-08 02:33:01 +00:00
customise organisation issue types
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
import {
|
||||
DEFAULT_FEATURES,
|
||||
DEFAULT_ISSUE_TYPES,
|
||||
DEFAULT_STATUS_COLOUR,
|
||||
ISSUE_STATUS_MAX_LENGTH,
|
||||
ISSUE_TYPE_MAX_LENGTH,
|
||||
type SprintRecord,
|
||||
} from "@sprint/shared";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
@@ -29,7 +31,7 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import Icon from "@/components/ui/icon";
|
||||
import Icon, { type IconName, iconNames } from "@/components/ui/icon";
|
||||
import { IconButton } from "@/components/ui/icon-button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
@@ -43,6 +45,7 @@ import {
|
||||
useProjects,
|
||||
useRemoveOrganisationMember,
|
||||
useReplaceIssueStatus,
|
||||
useReplaceIssueType,
|
||||
useSprints,
|
||||
useUpdateOrganisation,
|
||||
useUpdateOrganisationMemberRole,
|
||||
@@ -67,6 +70,7 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {
|
||||
const deleteProject = useDeleteProject();
|
||||
const deleteSprint = useDeleteSprint();
|
||||
const replaceIssueStatus = useReplaceIssueStatus();
|
||||
const replaceIssueType = useReplaceIssueType();
|
||||
|
||||
const organisations = useMemo(
|
||||
() => [...organisationsData].sort((a, b) => a.Organisation.name.localeCompare(b.Organisation.name)),
|
||||
@@ -122,6 +126,18 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {
|
||||
const [issuesUsingStatus, setIssuesUsingStatus] = useState<number>(0);
|
||||
const [reassignToStatus, setReassignToStatus] = useState<string>("");
|
||||
|
||||
// issue types state
|
||||
type IssueTypeConfig = { icon: string; color: string };
|
||||
const [issueTypes, setIssueTypes] = useState<Record<string, IssueTypeConfig>>({});
|
||||
const [isCreatingType, setIsCreatingType] = useState(false);
|
||||
const [newTypeName, setNewTypeName] = useState("");
|
||||
const [newTypeIcon, setNewTypeIcon] = useState<IconName>("checkBox");
|
||||
const [newTypeColour, setNewTypeColour] = useState(DEFAULT_ISSUE_TYPES.Task.color);
|
||||
const [typeError, setTypeError] = useState<string | null>(null);
|
||||
const [typeToRemove, setTypeToRemove] = useState<string | null>(null);
|
||||
const [issuesUsingType, setIssuesUsingType] = useState<number>(0);
|
||||
const [reassignToType, setReassignToType] = useState<string>("");
|
||||
|
||||
// edit/delete state for organisations, projects, and sprints
|
||||
const [editOrgOpen, setEditOrgOpen] = useState(false);
|
||||
const [editProjectOpen, setEditProjectOpen] = useState(false);
|
||||
@@ -239,6 +255,8 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {
|
||||
useEffect(() => {
|
||||
if (selectedOrganisation) {
|
||||
setStatuses(selectedOrganisation.Organisation.statuses);
|
||||
const orgIssueTypes = selectedOrganisation.Organisation.issueTypes as Record<string, IssueTypeConfig>;
|
||||
setIssueTypes(orgIssueTypes ?? {});
|
||||
}
|
||||
}, [selectedOrganisation]);
|
||||
|
||||
@@ -431,6 +449,183 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {
|
||||
}
|
||||
};
|
||||
|
||||
// issue types functions
|
||||
const updateIssueTypes = async (
|
||||
newIssueTypes: Record<string, IssueTypeConfig>,
|
||||
typeRemoved?: { name: string; icon: string; color: string },
|
||||
typeAdded?: { name: string; icon: string; color: string },
|
||||
typeMoved?: { name: string; icon: string; color: string; currentIndex: number; nextIndex: number },
|
||||
) => {
|
||||
if (!selectedOrganisation) return;
|
||||
|
||||
try {
|
||||
await updateOrganisation.mutateAsync({
|
||||
id: selectedOrganisation.Organisation.id,
|
||||
issueTypes: newIssueTypes,
|
||||
});
|
||||
setIssueTypes(newIssueTypes);
|
||||
if (typeAdded) {
|
||||
toast.success(
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
Created <Icon icon={typeAdded.icon as IconName} size={14} color={typeAdded.color} />
|
||||
{typeAdded.name} type successfully
|
||||
</span>,
|
||||
{ dismissible: false },
|
||||
);
|
||||
} else if (typeRemoved) {
|
||||
toast.success(
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
Removed <Icon icon={typeRemoved.icon as IconName} size={14} color={typeRemoved.color} />
|
||||
{typeRemoved.name} type successfully
|
||||
</span>,
|
||||
{ dismissible: false },
|
||||
);
|
||||
} else if (typeMoved) {
|
||||
toast.success(
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
Moved <Icon icon={typeMoved.icon as IconName} size={14} color={typeMoved.color} />
|
||||
{typeMoved.name} from position {typeMoved.currentIndex + 1} to {typeMoved.nextIndex + 1}
|
||||
</span>,
|
||||
{ dismissible: false },
|
||||
);
|
||||
}
|
||||
await invalidateOrganisations();
|
||||
} catch (err) {
|
||||
console.error("error updating issue types:", err);
|
||||
if (typeAdded) {
|
||||
toast.error(
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
Error adding <Icon icon={typeAdded.icon as IconName} size={14} color={typeAdded.color} />
|
||||
{typeAdded.name} to {selectedOrganisation.Organisation.name}: {String(err)}
|
||||
</span>,
|
||||
{ dismissible: false },
|
||||
);
|
||||
} else if (typeRemoved) {
|
||||
toast.error(
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
Error removing <Icon icon={typeRemoved.icon as IconName} size={14} color={typeRemoved.color} />
|
||||
{typeRemoved.name} from {selectedOrganisation.Organisation.name}: {String(err)}
|
||||
</span>,
|
||||
{ dismissible: false },
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateType = async () => {
|
||||
const trimmed = newTypeName.trim();
|
||||
if (!trimmed) return;
|
||||
|
||||
if (trimmed.length > ISSUE_TYPE_MAX_LENGTH) {
|
||||
setTypeError(`type name must be <= ${ISSUE_TYPE_MAX_LENGTH} characters`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Object.keys(issueTypes).includes(trimmed)) {
|
||||
setNewTypeName("");
|
||||
setIsCreatingType(false);
|
||||
setTypeError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const newIssueTypes = { ...issueTypes };
|
||||
newIssueTypes[trimmed] = { icon: newTypeIcon, color: newTypeColour };
|
||||
await updateIssueTypes(newIssueTypes, undefined, {
|
||||
name: trimmed,
|
||||
icon: newTypeIcon,
|
||||
color: newTypeColour,
|
||||
});
|
||||
|
||||
setNewTypeName("");
|
||||
setNewTypeIcon("checkBox");
|
||||
setNewTypeColour(DEFAULT_ISSUE_TYPES.Task.color);
|
||||
setIsCreatingType(false);
|
||||
setTypeError(null);
|
||||
};
|
||||
|
||||
const handleRemoveTypeClick = async (typeName: string) => {
|
||||
if (Object.keys(issueTypes).length <= 1 || !selectedOrganisation) return;
|
||||
try {
|
||||
const data = await issue.typeCount(selectedOrganisation.Organisation.id, typeName);
|
||||
const count = data.count ?? 0;
|
||||
if (count > 0) {
|
||||
setTypeToRemove(typeName);
|
||||
setIssuesUsingType(count);
|
||||
const remaining = Object.keys(issueTypes).filter((t) => t !== typeName);
|
||||
setReassignToType(remaining[0] || "");
|
||||
return;
|
||||
}
|
||||
|
||||
const nextTypes = Object.keys(issueTypes).filter((t) => t !== typeName);
|
||||
await updateIssueTypes(Object.fromEntries(nextTypes.map((t) => [t, issueTypes[t]])), {
|
||||
name: typeName,
|
||||
...issueTypes[typeName],
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("error checking type usage:", err);
|
||||
toast.error(
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
Error checking type usage for{" "}
|
||||
<Icon icon={issueTypes[typeName].icon as IconName} size={14} color={issueTypes[typeName].color} />
|
||||
{typeName}: {String(err)}
|
||||
</span>,
|
||||
{ dismissible: false },
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const moveType = async (typeName: string, direction: "up" | "down") => {
|
||||
const keys = Object.keys(issueTypes);
|
||||
const currentIndex = keys.indexOf(typeName);
|
||||
if (currentIndex === -1) return;
|
||||
|
||||
const nextIndex = direction === "up" ? currentIndex - 1 : currentIndex + 1;
|
||||
if (nextIndex < 0 || nextIndex >= keys.length) return;
|
||||
|
||||
const nextKeys = [...keys];
|
||||
[nextKeys[currentIndex], nextKeys[nextIndex]] = [nextKeys[nextIndex], nextKeys[currentIndex]];
|
||||
|
||||
await updateIssueTypes(
|
||||
Object.fromEntries(nextKeys.map((t) => [t, issueTypes[t]])),
|
||||
undefined,
|
||||
undefined,
|
||||
{ name: typeName, ...issueTypes[typeName], currentIndex, nextIndex },
|
||||
);
|
||||
};
|
||||
|
||||
const confirmRemoveType = async () => {
|
||||
if (!typeToRemove || !reassignToType || !selectedOrganisation) return;
|
||||
|
||||
try {
|
||||
await replaceIssueType.mutateAsync({
|
||||
organisationId: selectedOrganisation.Organisation.id,
|
||||
oldType: typeToRemove,
|
||||
newType: reassignToType,
|
||||
});
|
||||
const nextTypes = Object.keys(issueTypes).filter((t) => t !== typeToRemove);
|
||||
await updateIssueTypes(Object.fromEntries(nextTypes.map((t) => [t, issueTypes[t]])), {
|
||||
name: typeToRemove,
|
||||
...issueTypes[typeToRemove],
|
||||
});
|
||||
setTypeToRemove(null);
|
||||
setReassignToType("");
|
||||
} catch (error) {
|
||||
console.error("error replacing type:", error);
|
||||
toast.error(
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
Error removing{" "}
|
||||
<Icon
|
||||
icon={issueTypes[typeToRemove].icon as IconName}
|
||||
size={14}
|
||||
color={issueTypes[typeToRemove].color}
|
||||
/>
|
||||
{typeToRemove} from {selectedOrganisation.Organisation.name}: {String(error)}
|
||||
</span>,
|
||||
{ dismissible: false },
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !selectedOrganisationId) return;
|
||||
void invalidateMembers();
|
||||
@@ -815,6 +1010,148 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="issues">
|
||||
{/* Issue Types section */}
|
||||
<div className="border p-2 min-w-0 overflow-hidden mb-2">
|
||||
<h2 className="text-xl font-600 mb-2">Issue Types</h2>
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<div className="flex flex-col gap-2 max-h-86 overflow-y-scroll grid grid-cols-2">
|
||||
{Object.keys(issueTypes).map((typeName, index) => {
|
||||
const typeConfig = issueTypes[typeName];
|
||||
return (
|
||||
<div key={typeName} className="flex items-center justify-between p-2 border">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm tabular-nums">{index + 1}</span>
|
||||
<Icon icon={typeConfig.icon as IconName} size={16} color={typeConfig.color} />
|
||||
<span className="text-sm">{typeName}</span>
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
asChild
|
||||
size={"sm"}
|
||||
noStyle
|
||||
className="hover:opacity-80 cursor-pointer"
|
||||
>
|
||||
<Icon icon="ellipsisVertical" className="size-4 text-foreground" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" sideOffset={4} className="bg-background">
|
||||
<DropdownMenuItem
|
||||
disabled={index === 0}
|
||||
onSelect={() => void moveType(typeName, "up")}
|
||||
className="hover:bg-primary-foreground"
|
||||
>
|
||||
<Icon icon="chevronUp" className="size-4 text-muted-foreground" />
|
||||
Move up
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
disabled={index === Object.keys(issueTypes).length - 1}
|
||||
onSelect={() => void moveType(typeName, "down")}
|
||||
className="hover:bg-primary-foreground"
|
||||
>
|
||||
<Icon icon="chevronDown" className="size-4 text-muted-foreground" />
|
||||
Move down
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
disabled={Object.keys(issueTypes).length <= 1}
|
||||
onSelect={() => void handleRemoveTypeClick(typeName)}
|
||||
className="hover:bg-destructive/10"
|
||||
>
|
||||
<Icon icon="x" className="size-4" />
|
||||
Remove
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{isAdmin &&
|
||||
(isCreatingType ? (
|
||||
<>
|
||||
<div className="flex gap-2 w-full min-w-0">
|
||||
<Input
|
||||
value={newTypeName}
|
||||
maxLength={ISSUE_TYPE_MAX_LENGTH}
|
||||
onChange={(e) => {
|
||||
setNewTypeName(e.target.value);
|
||||
if (typeError) setTypeError(null);
|
||||
}}
|
||||
placeholder="Type name"
|
||||
className="flex-1 w-0 min-w-0"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
void handleCreateType();
|
||||
} else if (e.key === "Escape") {
|
||||
setIsCreatingType(false);
|
||||
setNewTypeName("");
|
||||
setTypeError(null);
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<Select value={newTypeIcon} onValueChange={(v) => setNewTypeIcon(v as IconName)}>
|
||||
<SelectTrigger
|
||||
className="group flex items-center w-min"
|
||||
variant="default"
|
||||
chevronClassName="hidden"
|
||||
>
|
||||
<Icon icon={newTypeIcon} size={20} color={newTypeColour} />
|
||||
</SelectTrigger>
|
||||
<SelectContent
|
||||
side="bottom"
|
||||
position="popper"
|
||||
align="start"
|
||||
className="max-h-64"
|
||||
>
|
||||
{iconNames.map((iconName) => (
|
||||
<SelectItem key={iconName} value={iconName}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon icon={iconName} size={16} color={newTypeColour} />
|
||||
<span className="text-xs">
|
||||
{unCamelCase(iconName).replace(" Icon", "").replace("2Icon", " 2")}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<ColourPicker
|
||||
colour={newTypeColour}
|
||||
onChange={setNewTypeColour}
|
||||
asChild={false}
|
||||
className="w-9 h-9"
|
||||
/>
|
||||
<IconButton
|
||||
variant="outline"
|
||||
size="md"
|
||||
onClick={() => void handleCreateType()}
|
||||
disabled={newTypeName.trim().length > ISSUE_TYPE_MAX_LENGTH}
|
||||
>
|
||||
<Icon icon="plus" className="size-4" />
|
||||
</IconButton>
|
||||
</div>
|
||||
{typeError && <p className="text-xs text-destructive">{typeError}</p>}
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setIsCreatingType(true);
|
||||
setTypeError(null);
|
||||
}}
|
||||
className="flex gap-2 w-full min-w-0"
|
||||
>
|
||||
Create type <Icon icon="plus" className="size-4" />
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Issue Statuses section */}
|
||||
<div className="border p-2 min-w-0 overflow-hidden">
|
||||
<h2 className="text-xl font-600 mb-2">Issue Statuses</h2>
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
@@ -1033,6 +1370,77 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Type removal dialog with reassignment */}
|
||||
<Dialog
|
||||
open={typeToRemove !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setTypeToRemove(null);
|
||||
setReassignToType("");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Remove Type</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Are you sure you want to remove the{" "}
|
||||
{typeToRemove && issueTypes[typeToRemove] ? (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Icon
|
||||
icon={issueTypes[typeToRemove].icon as IconName}
|
||||
size={14}
|
||||
color={issueTypes[typeToRemove].color}
|
||||
/>
|
||||
{typeToRemove}
|
||||
</span>
|
||||
) : null}{" "}
|
||||
type? <span className="font-700 text-foreground">{issuesUsingType}</span> issues are using it.
|
||||
Which type would you like these issues to use instead?
|
||||
</p>
|
||||
<Select value={reassignToType} onValueChange={setReassignToType}>
|
||||
<SelectTrigger className="w-min">
|
||||
<SelectValue placeholder="Select type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent side={"bottom"} position="popper" align="start">
|
||||
{Object.keys(issueTypes)
|
||||
.filter((t) => t !== typeToRemove)
|
||||
.map((typeName) => (
|
||||
<SelectItem key={typeName} value={typeName}>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Icon
|
||||
icon={issueTypes[typeName].icon as IconName}
|
||||
size={14}
|
||||
color={issueTypes[typeName].color}
|
||||
/>
|
||||
{typeName}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex gap-2 justify-end mt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setTypeToRemove(null);
|
||||
setReassignToType("");
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => void confirmRemoveType()}
|
||||
disabled={!reassignToType}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -181,17 +181,20 @@ export default function Icon({
|
||||
return null;
|
||||
}
|
||||
|
||||
let fill = "transparent";
|
||||
// lucide fills sillily
|
||||
if (color && resolvedStyle !== "lucide") {
|
||||
fill = color;
|
||||
} else if (resolvedStyle === "pixel" && ["bug", "moon", "hash"].includes(icon)) {
|
||||
fill = "var(--foreground)";
|
||||
} else if (resolvedStyle === "phosphor") {
|
||||
fill = "var(--foreground)";
|
||||
}
|
||||
|
||||
return (
|
||||
<IconComponent
|
||||
size={size}
|
||||
fill={
|
||||
color
|
||||
? color
|
||||
: (resolvedStyle === "pixel" && ["bug", "moon", "hash"].includes(icon)) ||
|
||||
resolvedStyle === "phosphor"
|
||||
? "var(--foreground)"
|
||||
: "transparent"
|
||||
}
|
||||
fill={fill}
|
||||
style={{ color: color ? color : "var(--foreground)" }}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
@@ -3,9 +3,11 @@ import type {
|
||||
IssueRecord,
|
||||
IssueResponse,
|
||||
IssuesReplaceStatusRequest,
|
||||
IssuesReplaceTypeRequest,
|
||||
IssueUpdateRequest,
|
||||
StatusCountResponse,
|
||||
SuccessResponse,
|
||||
TypeCountResponse,
|
||||
} from "@sprint/shared";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { queryKeys } from "@/lib/query/keys";
|
||||
@@ -76,3 +78,23 @@ export function useReplaceIssueStatus() {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useIssueTypeCount(organisationId?: number | null, type?: string | null) {
|
||||
return useQuery<TypeCountResponse>({
|
||||
queryKey: queryKeys.issues.typeCount(organisationId ?? 0, type ?? ""),
|
||||
queryFn: () => issue.typeCount(organisationId ?? 0, type ?? ""),
|
||||
enabled: Boolean(organisationId && type),
|
||||
});
|
||||
}
|
||||
|
||||
export function useReplaceIssueType() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<unknown, Error, IssuesReplaceTypeRequest>({
|
||||
mutationKey: ["issues", "replace-type"],
|
||||
mutationFn: issue.replaceType,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ export const queryKeys = {
|
||||
byProject: (projectId: number) => [...queryKeys.issues.all, "by-project", projectId] as const,
|
||||
statusCount: (organisationId: number, status: string) =>
|
||||
[...queryKeys.issues.all, "status-count", organisationId, status] as const,
|
||||
typeCount: (organisationId: number, type: string) =>
|
||||
[...queryKeys.issues.all, "type-count", organisationId, type] as const,
|
||||
},
|
||||
issueComments: {
|
||||
all: ["issue-comments"] as const,
|
||||
|
||||
@@ -2,5 +2,7 @@ export { byProject } from "@/lib/server/issue/byProject";
|
||||
export { create } from "@/lib/server/issue/create";
|
||||
export { remove as delete } from "@/lib/server/issue/delete";
|
||||
export { replaceStatus } from "@/lib/server/issue/replaceStatus";
|
||||
export { replaceType } from "@/lib/server/issue/replaceType";
|
||||
export { statusCount } from "@/lib/server/issue/statusCount";
|
||||
export { typeCount } from "@/lib/server/issue/typeCount";
|
||||
export { update } from "@/lib/server/issue/update";
|
||||
|
||||
24
packages/frontend/src/lib/server/issue/replaceType.ts
Normal file
24
packages/frontend/src/lib/server/issue/replaceType.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { IssuesReplaceTypeRequest, ReplaceTypeResponse } from "@sprint/shared";
|
||||
import { getCsrfToken, getServerURL } from "@/lib/utils";
|
||||
import { getErrorMessage } from "..";
|
||||
|
||||
export async function replaceType(request: IssuesReplaceTypeRequest): Promise<ReplaceTypeResponse> {
|
||||
const csrfToken = getCsrfToken();
|
||||
|
||||
const res = await fetch(`${getServerURL()}/issues/replace-type`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(csrfToken ? { "X-CSRF-Token": csrfToken } : {}),
|
||||
},
|
||||
body: JSON.stringify(request),
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const message = await getErrorMessage(res, `failed to replace type (${res.status})`);
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
20
packages/frontend/src/lib/server/issue/typeCount.ts
Normal file
20
packages/frontend/src/lib/server/issue/typeCount.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { TypeCountResponse } from "@sprint/shared";
|
||||
import { getServerURL } from "@/lib/utils";
|
||||
import { getErrorMessage } from "..";
|
||||
|
||||
export async function typeCount(organisationId: number, type: string): Promise<TypeCountResponse> {
|
||||
const url = new URL(`${getServerURL()}/issues/type-count`);
|
||||
url.searchParams.set("organisationId", `${organisationId}`);
|
||||
url.searchParams.set("type", type);
|
||||
|
||||
const res = await fetch(url.toString(), {
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const message = await getErrorMessage(res, `failed to get issue type count (${res.status})`);
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
Reference in New Issue
Block a user