mirror of
https://github.com/hex248/sprint.git
synced 2026-02-08 02:33:01 +00:00
issue types (task/bug)
This commit is contained in:
@@ -122,7 +122,7 @@ export function IssueComments({ issueId, className }: { issueId: number; classNa
|
||||
disabled={deletingId === comment.Comment.id}
|
||||
title="Delete comment"
|
||||
>
|
||||
<Icon icon="trash" className="size-4" />
|
||||
<Icon icon="trash" color="var(--destructive)" />
|
||||
</IconButton>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -11,9 +11,10 @@ import { StatusSelect } from "@/components/status-select";
|
||||
import StatusTag from "@/components/status-tag";
|
||||
import { TimerDisplay } from "@/components/timer-display";
|
||||
import { TimerModal } from "@/components/timer-modal";
|
||||
import { TypeSelect } from "@/components/type-select";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||
import Icon from "@/components/ui/icon";
|
||||
import Icon, { type IconName } from "@/components/ui/icon";
|
||||
import { IconButton } from "@/components/ui/icon-button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { SelectTrigger } from "@/components/ui/select";
|
||||
@@ -58,6 +59,7 @@ export function IssueDetails({
|
||||
const [assigneeIds, setAssigneeIds] = useState<string[]>([]);
|
||||
const [sprintId, setSprintId] = useState<string>("unassigned");
|
||||
const [status, setStatus] = useState<string>("");
|
||||
const [type, setType] = useState<string>("");
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [linkCopied, setLinkCopied] = useState(false);
|
||||
const copyTimeoutRef = useRef<number | null>(null);
|
||||
@@ -72,6 +74,11 @@ export function IssueDetails({
|
||||
const [isSavingDescription, setIsSavingDescription] = useState(false);
|
||||
const descriptionRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const issueTypes = (organisation?.Organisation.issueTypes ?? {}) as Record<
|
||||
string,
|
||||
{ icon: string; color: string }
|
||||
>;
|
||||
|
||||
const isAssignee = assigneeIds.some((id) => user?.id === Number(id));
|
||||
const actualAssigneeIds = assigneeIds.filter((id) => id !== "unassigned");
|
||||
const hasMultipleAssignees = actualAssigneeIds.length > 1;
|
||||
@@ -80,6 +87,7 @@ export function IssueDetails({
|
||||
setSprintId(issueData.Issue.sprintId?.toString() ?? "unassigned");
|
||||
setAssigneeIds(assigneesToStringArray(issueData.Assignees));
|
||||
setStatus(issueData.Issue.status);
|
||||
setType(issueData.Issue.type);
|
||||
setTitle(issueData.Issue.title);
|
||||
setOriginalTitle(issueData.Issue.title);
|
||||
setDescription(issueData.Issue.description);
|
||||
@@ -206,6 +214,38 @@ export function IssueDetails({
|
||||
}
|
||||
};
|
||||
|
||||
const handleTypeChange = async (value: string) => {
|
||||
setType(value);
|
||||
const typeConfig = issueTypes[value];
|
||||
|
||||
try {
|
||||
await updateIssue.mutateAsync({
|
||||
id: issueData.Issue.id,
|
||||
type: value,
|
||||
});
|
||||
toast.success(
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{issueID(projectKey, issueData.Issue.number)}'s type updated to{" "}
|
||||
{typeConfig ? (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Icon icon={typeConfig.icon as IconName} size={16} color={typeConfig.color} />
|
||||
{value}
|
||||
</span>
|
||||
) : (
|
||||
value
|
||||
)}
|
||||
</span>,
|
||||
{ dismissible: false },
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("error updating type:", error);
|
||||
setType(issueData.Issue.type);
|
||||
toast.error(`Error updating type: ${parseError(error as Error)}`, {
|
||||
dismissible: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
setDeleteOpen(true);
|
||||
};
|
||||
@@ -310,7 +350,7 @@ export function IssueDetails({
|
||||
{linkCopied ? <Icon icon="check" /> : <Icon icon="link" />}
|
||||
</IconButton>
|
||||
<IconButton variant="destructive" onClick={handleDelete} title={"Delete issue"}>
|
||||
<Icon icon="trash" />
|
||||
<Icon icon="trash" color="var(--destructive)" />
|
||||
</IconButton>
|
||||
<IconButton onClick={onClose} title={"Close"}>
|
||||
<Icon icon="x" />
|
||||
@@ -321,6 +361,30 @@ export function IssueDetails({
|
||||
|
||||
<div className="flex flex-col w-full p-2 py-2 gap-2 max-h-[75vh] overflow-y-scroll">
|
||||
<div className="flex gap-2">
|
||||
{organisation?.Organisation.features.issueTypes && Object.keys(issueTypes).length > 0 && (
|
||||
<TypeSelect
|
||||
issueTypes={issueTypes}
|
||||
value={type}
|
||||
onChange={handleTypeChange}
|
||||
trigger={({ isOpen, value }) => {
|
||||
const typeConfig = issueTypes[value];
|
||||
return (
|
||||
<SelectTrigger
|
||||
className="group w-auto flex items-center"
|
||||
variant="unstyled"
|
||||
chevronClassName="hidden"
|
||||
isOpen={isOpen}
|
||||
>
|
||||
{typeConfig ? (
|
||||
<Icon icon={typeConfig.icon as IconName} size={18} color={typeConfig.color} />
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">Type</span>
|
||||
)}
|
||||
</SelectTrigger>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{organisation?.Organisation.features.issueStatus && (
|
||||
<StatusSelect
|
||||
statuses={statuses}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useAuthenticatedSession } from "@/components/session-provider";
|
||||
import { SprintSelect } from "@/components/sprint-select";
|
||||
import { StatusSelect } from "@/components/status-select";
|
||||
import StatusTag from "@/components/status-tag";
|
||||
import { TypeSelect } from "@/components/type-select";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Field } from "@/components/ui/field";
|
||||
import Icon, { type IconName } from "@/components/ui/icon";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { SelectTrigger } from "@/components/ui/select";
|
||||
import {
|
||||
@@ -39,8 +41,14 @@ export function IssueForm({ trigger }: { trigger?: React.ReactNode }) {
|
||||
|
||||
const members = useMemo(() => membersData.map((member) => member.User), [membersData]);
|
||||
const statuses = selectedOrganisation?.Organisation.statuses ?? {};
|
||||
const issueTypes = (selectedOrganisation?.Organisation.issueTypes ?? {}) as Record<
|
||||
string,
|
||||
{ icon: string; color: string }
|
||||
>;
|
||||
const statusOptions = useMemo(() => Object.keys(statuses), [statuses]);
|
||||
const typeOptions = useMemo(() => Object.keys(issueTypes), [issueTypes]);
|
||||
const defaultStatus = statusOptions[0] ?? "";
|
||||
const defaultType = typeOptions[0] ?? "";
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [title, setTitle] = useState("");
|
||||
@@ -48,6 +56,7 @@ export function IssueForm({ trigger }: { trigger?: React.ReactNode }) {
|
||||
const [sprintId, setSprintId] = useState<string>("unassigned");
|
||||
const [assigneeIds, setAssigneeIds] = useState<string[]>(["unassigned"]);
|
||||
const [status, setStatus] = useState<string>(defaultStatus);
|
||||
const [type, setType] = useState<string>(defaultType);
|
||||
const [submitAttempted, setSubmitAttempted] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -58,6 +67,7 @@ export function IssueForm({ trigger }: { trigger?: React.ReactNode }) {
|
||||
setSprintId("unassigned");
|
||||
setAssigneeIds(["unassigned"]);
|
||||
setStatus(defaultStatus);
|
||||
setType(defaultType);
|
||||
setSubmitAttempted(false);
|
||||
setSubmitting(false);
|
||||
setError(null);
|
||||
@@ -103,6 +113,7 @@ export function IssueForm({ trigger }: { trigger?: React.ReactNode }) {
|
||||
sprintId: sprintId === "unassigned" ? null : Number(sprintId),
|
||||
assigneeIds: assigneeIds.filter((id) => id !== "unassigned").map((id) => Number(id)),
|
||||
status: status.trim() === "" ? undefined : status,
|
||||
type: type.trim() === "" ? undefined : type,
|
||||
});
|
||||
setOpen(false);
|
||||
reset();
|
||||
@@ -136,27 +147,61 @@ export function IssueForm({ trigger }: { trigger?: React.ReactNode }) {
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid">
|
||||
{statusOptions.length > 0 && (
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Label>Status</Label>
|
||||
<StatusSelect
|
||||
statuses={statuses}
|
||||
value={status}
|
||||
onChange={(newValue) => {
|
||||
if (newValue.trim() === "") return;
|
||||
setStatus(newValue);
|
||||
}}
|
||||
trigger={({ isOpen, value }) => (
|
||||
<SelectTrigger
|
||||
className="group flex items-center w-min"
|
||||
variant="unstyled"
|
||||
chevronClassName="hidden"
|
||||
isOpen={isOpen}
|
||||
>
|
||||
<StatusTag status={value} colour={statuses[value]} className="hover:opacity-85" />
|
||||
</SelectTrigger>
|
||||
)}
|
||||
/>
|
||||
{(typeOptions.length > 0 || statusOptions.length > 0) && (
|
||||
<div className="flex items-center gap-3 mb-4 flex-wrap">
|
||||
{selectedOrganisation?.Organisation.features.issueTypes && typeOptions.length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Label>Type</Label>
|
||||
<TypeSelect
|
||||
issueTypes={issueTypes}
|
||||
value={type}
|
||||
onChange={(newValue) => {
|
||||
if (newValue.trim() === "") return;
|
||||
setType(newValue);
|
||||
}}
|
||||
trigger={({ isOpen, value }) => {
|
||||
const typeConfig = issueTypes[value];
|
||||
return (
|
||||
<SelectTrigger
|
||||
className="group flex items-center w-min"
|
||||
variant="unstyled"
|
||||
chevronClassName="hidden"
|
||||
isOpen={isOpen}
|
||||
>
|
||||
{typeConfig ? (
|
||||
<Icon icon={typeConfig.icon as IconName} size={20} color={typeConfig.color} />
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">Type</span>
|
||||
)}
|
||||
</SelectTrigger>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{statusOptions.length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Label>Status</Label>
|
||||
<StatusSelect
|
||||
statuses={statuses}
|
||||
value={status}
|
||||
onChange={(newValue) => {
|
||||
if (newValue.trim() === "") return;
|
||||
setStatus(newValue);
|
||||
}}
|
||||
trigger={({ isOpen, value }) => (
|
||||
<SelectTrigger
|
||||
className="group flex items-center w-min"
|
||||
variant="unstyled"
|
||||
chevronClassName="hidden"
|
||||
isOpen={isOpen}
|
||||
>
|
||||
<StatusTag status={value} colour={statuses[value]} className="hover:opacity-85" />
|
||||
</SelectTrigger>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useMemo } from "react";
|
||||
import Avatar from "@/components/avatar";
|
||||
import { useSelection } from "@/components/selection-provider";
|
||||
import StatusTag from "@/components/status-tag";
|
||||
import Icon, { type IconName } from "@/components/ui/icon";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { useIssues, useSelectedOrganisation, useSelectedProject } from "@/lib/query/hooks";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -18,6 +19,10 @@ export function IssuesTable({
|
||||
const selectedOrganisation = useSelectedOrganisation();
|
||||
const selectedProject = useSelectedProject();
|
||||
const statuses = selectedOrganisation?.Organisation.statuses ?? {};
|
||||
const issueTypes = (selectedOrganisation?.Organisation.issueTypes ?? {}) as Record<
|
||||
string,
|
||||
{ icon: string; color: string }
|
||||
>;
|
||||
|
||||
const issues = useMemo(() => [...issuesData].reverse(), [issuesData]);
|
||||
|
||||
@@ -90,6 +95,14 @@ export function IssuesTable({
|
||||
onClick={handleLinkClick}
|
||||
className="flex items-center gap-2 min-w-0 w-full h-full px-2 py-1 text-inherit hover:underline decoration-transparent"
|
||||
>
|
||||
{selectedOrganisation?.Organisation.features.issueTypes &&
|
||||
issueTypes[issueData.Issue.type] && (
|
||||
<Icon
|
||||
icon={issueTypes[issueData.Issue.type].icon as IconName}
|
||||
size={16}
|
||||
color={issueTypes[issueData.Issue.type].color}
|
||||
/>
|
||||
)}
|
||||
{selectedOrganisation?.Organisation.features.issueStatus &&
|
||||
(columns.status == null || columns.status === true) && (
|
||||
<StatusTag status={issueData.Issue.status} colour={statuses[issueData.Issue.status]} />
|
||||
|
||||
59
packages/frontend/src/components/type-select.tsx
Normal file
59
packages/frontend/src/components/type-select.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useState } from "react";
|
||||
import Icon, { type IconName } from "@/components/ui/icon";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
|
||||
type IssueTypeConfig = {
|
||||
icon: string;
|
||||
color: string;
|
||||
};
|
||||
|
||||
export function TypeSelect({
|
||||
issueTypes,
|
||||
value,
|
||||
onChange,
|
||||
placeholder = "Select type",
|
||||
trigger,
|
||||
}: {
|
||||
issueTypes: Record<string, IssueTypeConfig>;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
trigger?: (args: { isOpen: boolean; value: string }) => ReactNode;
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const selectedType = issueTypes[value];
|
||||
|
||||
return (
|
||||
<Select value={value} onValueChange={onChange} onOpenChange={setIsOpen}>
|
||||
{trigger ? (
|
||||
trigger({ isOpen, value })
|
||||
) : (
|
||||
<SelectTrigger
|
||||
className="w-fit px-2 text-sm gap-1"
|
||||
size="sm"
|
||||
chevronClassName="size-3 -mr-1"
|
||||
isOpen={isOpen}
|
||||
>
|
||||
{selectedType ? (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Icon icon={selectedType.icon as IconName} size={20} color={selectedType.color} />
|
||||
</span>
|
||||
) : (
|
||||
<SelectValue placeholder={placeholder} />
|
||||
)}
|
||||
</SelectTrigger>
|
||||
)}
|
||||
<SelectContent side="bottom" position="popper" align="start">
|
||||
{Object.entries(issueTypes).map(([typeName, typeConfig]) => (
|
||||
<SelectItem key={typeName} value={typeName} textClassName="text-sm">
|
||||
<span className="flex items-center gap-2">
|
||||
<Icon icon={typeConfig.icon as IconName} size={20} color={typeConfig.color} />
|
||||
<span>{typeName}</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
Alert as PixelAlert,
|
||||
Check as PixelCheck,
|
||||
Checkbox as PixelCheckbox,
|
||||
ChevronDown as PixelChevronDown,
|
||||
ChevronLeft as PixelChevronLeft,
|
||||
ChevronRight as PixelChevronRight,
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
Circle as PixelCircle,
|
||||
Clock as PixelClock,
|
||||
Close as PixelClose,
|
||||
Debug as PixelDebug,
|
||||
Edit as PixelEdit,
|
||||
Home as PixelHome,
|
||||
InfoBox as PixelInfo,
|
||||
@@ -26,8 +28,10 @@ import {
|
||||
ViewportWide as PixelViewportWide,
|
||||
} from "@nsmr/pixelart-react";
|
||||
import {
|
||||
BugIcon as PhosphorBug,
|
||||
CheckIcon as PhosphorCheck,
|
||||
CheckCircleIcon as PhosphorCheckCircle,
|
||||
CheckSquareIcon as PhosphorCheckSquare,
|
||||
CaretDownIcon as PhosphorChevronDown,
|
||||
CaretLeftIcon as PhosphorChevronLeft,
|
||||
CaretRightIcon as PhosphorChevronRight,
|
||||
@@ -59,6 +63,7 @@ import {
|
||||
import type { IconStyle } from "@sprint/shared";
|
||||
import {
|
||||
AlertTriangle,
|
||||
Bug,
|
||||
Check,
|
||||
CheckIcon,
|
||||
ChevronDown,
|
||||
@@ -84,6 +89,7 @@ import {
|
||||
OctagonXIcon,
|
||||
Plus,
|
||||
ServerIcon,
|
||||
SquareCheck,
|
||||
Sun,
|
||||
Timer,
|
||||
Trash,
|
||||
@@ -95,10 +101,14 @@ import {
|
||||
} from "lucide-react";
|
||||
import { useSessionSafe } from "@/components/session-provider";
|
||||
|
||||
// lucide: https://lucide.dev/icons
|
||||
// pixel: https://pixelarticons.com/ (CLICK "Legacy") - these ones are free
|
||||
const icons = {
|
||||
alertTriangle: { lucide: AlertTriangle, pixel: PixelAlert, phosphor: PhosphorWarning },
|
||||
bug: { lucide: Bug, pixel: PixelDebug, phosphor: PhosphorBug },
|
||||
check: { lucide: Check, pixel: PixelCheck, phosphor: PhosphorCheck },
|
||||
checkIcon: { lucide: CheckIcon, pixel: PixelCheck, phosphor: PhosphorCheck },
|
||||
checkBox: { lucide: SquareCheck, pixel: PixelCheckbox, phosphor: PhosphorCheckSquare },
|
||||
chevronDown: { lucide: ChevronDown, pixel: PixelChevronDown, phosphor: PhosphorChevronDown },
|
||||
chevronDownIcon: { lucide: ChevronDownIcon, pixel: PixelChevronDown, phosphor: PhosphorChevronDown },
|
||||
chevronLeftIcon: { lucide: ChevronLeftIcon, pixel: PixelChevronLeft, phosphor: PhosphorChevronLeft },
|
||||
@@ -149,6 +159,7 @@ export default function Icon({
|
||||
icon,
|
||||
iconStyle,
|
||||
size = 24,
|
||||
color,
|
||||
...props
|
||||
}: {
|
||||
icon: IconName;
|
||||
@@ -174,12 +185,14 @@ export default function Icon({
|
||||
<IconComponent
|
||||
size={size}
|
||||
fill={
|
||||
(resolvedStyle === "pixel" && icon === "moon") ||
|
||||
(resolvedStyle === "pixel" && icon === "hash") ||
|
||||
resolvedStyle === "phosphor"
|
||||
? "var(--foreground)"
|
||||
: "transparent"
|
||||
color
|
||||
? color
|
||||
: (resolvedStyle === "pixel" && ["bug", "moon", "hash"].includes(icon)) ||
|
||||
resolvedStyle === "phosphor"
|
||||
? "var(--foreground)"
|
||||
: "transparent"
|
||||
}
|
||||
style={{ color: color ? color : "var(--foreground)" }}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import "./App.css";
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
|
||||
import { BrowserRouter, Route, Routes } from "react-router-dom";
|
||||
import { QueryProvider } from "@/components/query-provider";
|
||||
import { SelectionProvider } from "@/components/selection-provider";
|
||||
import { RequireAuth, SessionProvider } from "@/components/session-provider";
|
||||
|
||||
Reference in New Issue
Block a user