status colours

This commit is contained in:
Oliver Bryan
2026-01-10 21:49:26 +00:00
parent 1a8dc1a57e
commit 5db22961c5
20 changed files with 2033 additions and 62 deletions

View File

@@ -28,7 +28,7 @@ export function CreateIssue({
}: {
projectId?: number;
members?: UserRecord[];
statuses?: string[];
statuses: Record<string, string>;
trigger?: React.ReactNode;
completeAction?: (issueId: number) => void | Promise<void>;
}) {
@@ -38,7 +38,7 @@ export function CreateIssue({
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [assigneeId, setAssigneeId] = useState<string>("unassigned");
const [status, setStatus] = useState<string>(statuses?.[0] ?? "");
const [status, setStatus] = useState<string>(Object.keys(statuses)[0] ?? "");
const [submitAttempted, setSubmitAttempted] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -130,7 +130,7 @@ export function CreateIssue({
<form onSubmit={handleSubmit}>
<div className="grid">
{statuses && statuses.length > 0 && (
{statuses && Object.keys(statuses).length > 0 && (
<div className="flex flex-col gap-2 mb-4">
<Label>Status</Label>
<StatusSelect
@@ -150,7 +150,8 @@ export function CreateIssue({
>
<StatusTag
status={value}
className="group-hover:bg-foreground/75"
colour={statuses[value]}
className="hover:opacity-85"
/>
</SelectTrigger>
)}

View File

@@ -23,7 +23,7 @@ export function IssueDetailPane({
project: ProjectResponse;
issueData: IssueResponse;
members: UserRecord[];
statuses: string[];
statuses: Record<string, string>;
close: () => void;
onIssueUpdate?: () => void;
}) {
@@ -98,7 +98,11 @@ export function IssueDetailPane({
chevronClassName="hidden"
isOpen={isOpen}
>
<StatusTag status={value} className="group-hover:bg-foreground/75" />
<StatusTag
status={value}
colour={statuses[value]}
className="hover:opacity-85"
/>
</SelectTrigger>
)}
/>

View File

@@ -8,11 +8,13 @@ export function IssuesTable({
issuesData,
columns = {},
issueSelectAction,
statuses,
className,
}: {
issuesData: IssueResponse[];
columns?: { id?: boolean; title?: boolean; description?: boolean; status?: boolean; assignee?: boolean };
issueSelectAction?: (issue: IssueResponse) => void;
statuses: Record<string, string>;
className: string;
}) {
return (
@@ -48,7 +50,10 @@ export function IssuesTable({
<TableCell>
<span className="flex items-center gap-2 max-w-full truncate">
{(columns.status == null || columns.status === true) && (
<StatusTag status={issueData.Issue.status} />
<StatusTag
status={issueData.Issue.status}
colour={statuses[issueData.Issue.status]}
/>
)}
{issueData.Issue.title}
</span>

View File

@@ -1,4 +1,5 @@
import {
DEFAULT_STATUS_COLOUR,
ISSUE_STATUS_MAX_LENGTH,
type OrganisationMemberResponse,
type OrganisationResponse,
@@ -38,7 +39,7 @@ function OrganisationsDialog({
const [activeTab, setActiveTab] = useState("info");
const [members, setMembers] = useState<OrganisationMemberResponse[]>([]);
const [statuses, setStatuses] = useState<string[]>([]);
const [statuses, setStatuses] = useState<Record<string, string>>({});
const [isCreatingStatus, setIsCreatingStatus] = useState(false);
const [newStatusName, setNewStatusName] = useState("");
const [statusError, setStatusError] = useState<string | null>(null);
@@ -161,16 +162,13 @@ function OrganisationsDialog({
useEffect(() => {
if (selectedOrganisation) {
const orgStatuses = (selectedOrganisation.Organisation as unknown as { statuses: string[] })
.statuses;
setStatuses(
Array.isArray(orgStatuses) ? orgStatuses : ["TO DO", "IN PROGRESS", "REVIEW", "DONE"],
);
setStatuses(selectedOrganisation.Organisation.statuses);
}
}, [selectedOrganisation]);
const updateStatuses = async (newStatuses: string[]) => {
const updateStatuses = async (newStatuses: Record<string, string>) => {
if (!selectedOrganisation) return;
try {
await organisation.update({
organisationId: selectedOrganisation.Organisation.id,
@@ -197,14 +195,14 @@ function OrganisationsDialog({
return;
}
if (statuses.includes(trimmed)) {
if (Object.keys(statuses).includes(trimmed)) {
setNewStatusName("");
setIsCreatingStatus(false);
setStatusError(null);
return;
}
const newStatuses = [...statuses, trimmed];
const newStatuses = { ...statuses };
newStatuses[trimmed] = DEFAULT_STATUS_COLOUR;
await updateStatuses(newStatuses);
setNewStatusName("");
setIsCreatingStatus(false);
@@ -212,26 +210,25 @@ function OrganisationsDialog({
};
const handleRemoveStatusClick = (status: string) => {
if (statuses.length <= 1) return;
if (Object.keys(statuses).length <= 1) return;
setStatusToRemove(status);
const remaining = statuses.filter((s) => s !== status);
const remaining = Object.keys(statuses).filter((s) => s !== status);
setReassignToStatus(remaining[0] || "");
};
const moveStatus = async (status: string, direction: "up" | "down") => {
const currentIndex = statuses.indexOf(status);
const currentIndex = Object.keys(statuses).indexOf(status);
if (currentIndex === -1) return;
const nextIndex = direction === "up" ? currentIndex - 1 : currentIndex + 1;
if (nextIndex < 0 || nextIndex >= statuses.length) return;
const nextStatuses = [...statuses];
if (nextIndex < 0 || nextIndex >= Object.keys(statuses).length) return;
const nextStatuses = [...Object.keys(statuses)];
[nextStatuses[currentIndex], nextStatuses[nextIndex]] = [
nextStatuses[nextIndex],
nextStatuses[currentIndex],
];
await updateStatuses(nextStatuses);
await updateStatuses(Object.fromEntries(nextStatuses.map((status) => [status, statuses[status]])));
};
const confirmRemoveStatus = async () => {
@@ -242,8 +239,10 @@ function OrganisationsDialog({
oldStatus: statusToRemove,
newStatus: reassignToStatus,
onSuccess: async () => {
const newStatuses = statuses.filter((s) => s !== statusToRemove);
await updateStatuses(newStatuses);
const newStatuses = Object.keys(statuses).filter((s) => s !== statusToRemove);
await updateStatuses(
Object.fromEntries(newStatuses.map((status) => [status, statuses[status]])),
);
setStatusToRemove(null);
setReassignToStatus("");
},
@@ -406,13 +405,19 @@ function OrganisationsDialog({
<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">
<div className="flex flex-col gap-2 max-h-56 overflow-y-scroll">
{statuses.map((status, index) => (
<div className="flex flex-col gap-2 max-h-86 overflow-y-scroll grid grid-cols-2">
{Object.keys(statuses).map((status, index) => (
<div
key={status}
className="flex items-center justify-between p-2 border"
>
<StatusTag status={status} />
<div className="flex items-center gap-2">
<span className="text-sm">{index + 1}</span>
<StatusTag
status={status}
colour={statuses[status]}
/>
</div>
{isAdmin && (
<div className="flex items-center gap-2">
<Button
@@ -427,7 +432,9 @@ function OrganisationsDialog({
<Button
variant="dummy"
size="none"
disabled={index === statuses.length - 1}
disabled={
index === Object.keys(statuses).length - 1
}
onClick={() =>
void moveStatus(status, "down")
}
@@ -435,7 +442,7 @@ function OrganisationsDialog({
>
<ChevronDown className="size-5 text-muted-foreground" />
</Button>
{statuses.length > 1 && (
{Object.keys(statuses).length > 1 && (
<Button
variant="dummy"
size="none"
@@ -563,11 +570,11 @@ function OrganisationsDialog({
<SelectValue placeholder="Select status" />
</SelectTrigger>
<SelectContent>
{statuses
{Object.keys(statuses)
.filter((s) => s !== statusToRemove)
.map((status) => (
<SelectItem key={status} value={status}>
{status}
<StatusTag status={status} colour={statuses[status]} />
</SelectItem>
))}
</SelectContent>

View File

@@ -10,7 +10,7 @@ export function StatusSelect({
placeholder = "Select status",
trigger,
}: {
statuses: string[];
statuses: Record<string, string>;
value: string;
onChange: (value: string) => void;
placeholder?: string;
@@ -33,9 +33,9 @@ export function StatusSelect({
</SelectTrigger>
)}
<SelectContent side="bottom" position="popper" align="start">
{statuses.map((status) => (
{Object.entries(statuses).map(([status, colour]) => (
<SelectItem key={status} value={status} textClassName="text-xs">
<StatusTag status={status} className="" />
<StatusTag status={status} colour={colour} />
</SelectItem>
))}
</SelectContent>

View File

@@ -1,12 +1,35 @@
import { cn } from "@/lib/utils";
export default function StatusTag({ status, className }: { status: string; className?: string }) {
const DARK_TEXT_COLOUR = "#0a0a0a";
const THRESHOLD = 0.6;
const isLight = (hex: string): boolean => {
const num = Number.parseInt(hex.replace("#", ""), 16);
const r = (num >> 16) & 255;
const g = (num >> 8) & 255;
const b = num & 255;
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
return luminance > THRESHOLD;
};
export default function StatusTag({
status,
colour,
className,
}: {
status: string;
colour: string;
className?: string;
}) {
const textColour = isLight(colour) ? DARK_TEXT_COLOUR : "var(--foreground)";
return (
<div
className={cn(
"text-xs px-1 bg-foreground/85 rounded text-background inline-flex whitespace-nowrap",
"text-xs px-1 rounded inline-flex whitespace-nowrap border border-foreground/10",
className,
)}
style={{ backgroundColor: colour, color: textColour }}
>
{status}
</div>

View File

@@ -14,14 +14,16 @@ export async function update({
name?: string;
description?: string;
slug?: string;
statuses?: string[];
statuses?: Record<string, string>;
} & ServerQueryInput) {
const url = new URL(`${getServerURL()}/organisation/update`);
url.searchParams.set("id", `${organisationId}`);
if (name !== undefined) url.searchParams.set("name", name);
if (description !== undefined) url.searchParams.set("description", description);
if (slug !== undefined) url.searchParams.set("slug", slug);
if (statuses !== undefined) url.searchParams.set("statuses", JSON.stringify(statuses));
if (statuses !== undefined) {
url.searchParams.set("statuses", JSON.stringify(statuses));
}
const csrfToken = getCsrfToken();
const headers: HeadersInit = {};

View File

@@ -1,4 +1,5 @@
/** biome-ignore-all lint/correctness/useExhaustiveDependencies: <> */
import type {
IssueResponse,
OrganisationMemberResponse,
@@ -240,7 +241,7 @@ export default function App() {
<CreateIssue
projectId={selectedProject?.Project.id}
members={members}
statuses={selectedOrganisation.Organisation.statuses as unknown as string[]}
statuses={selectedOrganisation.Organisation.statuses}
completeAction={async () => {
if (!selectedProject) return;
await refetchIssues();
@@ -288,13 +289,14 @@ export default function App() {
</div>
{/* main body */}
{selectedProject && issues.length > 0 && (
{selectedOrganisation && selectedProject && issues.length > 0 && (
<ResizablePanelGroup className={`flex-1`}>
<ResizablePanel id={"left"} minSize={400}>
{/* issues list (table) */}
<IssuesTable
issuesData={issues}
columns={{ description: false }}
statuses={selectedOrganisation.Organisation.statuses}
issueSelectAction={(issue) => {
if (issue.Issue.id === selectedIssue?.Issue.id) setSelectedIssue(null);
else setSelectedIssue(issue);
@@ -313,9 +315,7 @@ export default function App() {
project={selectedProject}
issueData={selectedIssue}
members={members}
statuses={
selectedOrganisation.Organisation.statuses as unknown as string[]
}
statuses={selectedOrganisation.Organisation.statuses}
close={() => setSelectedIssue(null)}
onIssueUpdate={refetchIssues}
/>