customise organisation issue types

This commit is contained in:
2026-01-25 00:51:49 +00:00
parent d5a0829bad
commit f11c9fa826
17 changed files with 629 additions and 15 deletions

View File

@@ -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>

View File

@@ -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}
/>

View File

@@ -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 });
},
});
}

View File

@@ -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,

View File

@@ -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";

View 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();
}

View 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();
}