Improved filters, emailVerified for seeded data

Improved filters, emailVerified for seeded data
This commit is contained in:
Oliver Bryan
2026-01-30 16:24:05 +00:00
committed by GitHub
6 changed files with 311 additions and 118 deletions

View File

@@ -10,7 +10,7 @@ S3_PUBLIC_URL=https://images.sprintpm.org
S3_ENDPOINT=https://account_id.r2.cloudflarestorage.com/sprint S3_ENDPOINT=https://account_id.r2.cloudflarestorage.com/sprint
S3_ACCESS_KEY_ID=your_access_key_id S3_ACCESS_KEY_ID=your_access_key_id
S3_SECRET_ACCESS_KEY=your_secret_access_key S3_SECRET_ACCESS_KEY=your_secret_access_key
S3_BUCKET_NAME=issue S3_BUCKET_NAME=sprint
STRIPE_PUBLISHABLE_KEY=your_stripe_publishable_key STRIPE_PUBLISHABLE_KEY=your_stripe_publishable_key
STRIPE_SECRET_KEY=your_stripe_secret_key STRIPE_SECRET_KEY=your_stripe_secret_key

View File

@@ -103,16 +103,81 @@ if (!SEED_PASSWORD) {
} }
const passwordHash = await hashPassword(SEED_PASSWORD); const passwordHash = await hashPassword(SEED_PASSWORD);
const now = new Date();
const users = [ const users = [
{ name: "demo user 1", username: "demo1", email: "demo1@example.com", passwordHash, avatarURL: null }, {
{ name: "demo user 2", username: "demo2", email: "demo2@example.com", passwordHash, avatarURL: null }, name: "demo user 1",
username: "demo1",
email: "demo1@example.com",
passwordHash,
avatarURL: null,
emailVerified: true,
emailVerifiedAt: now,
},
{
name: "demo user 2",
username: "demo2",
email: "demo2@example.com",
passwordHash,
avatarURL: null,
emailVerified: true,
emailVerifiedAt: now,
},
// anything past here is just to have more users to assign issues to // anything past here is just to have more users to assign issues to
{ name: "demo user 3", username: "demo3", email: "demo3@example.com", passwordHash, avatarURL: null }, {
{ name: "demo user 4", username: "demo4", email: "demo4@example.com", passwordHash, avatarURL: null }, name: "demo user 3",
{ name: "demo user 5", username: "demo5", email: "demo5@example.com", passwordHash, avatarURL: null }, username: "demo3",
{ name: "demo user 6", username: "demo6", email: "demo6@example.com", passwordHash, avatarURL: null }, email: "demo3@example.com",
{ name: "demo user 7", username: "demo7", email: "demo7@example.com", passwordHash, avatarURL: null }, passwordHash,
{ name: "demo user 8", username: "demo8", email: "demo8@example.com", passwordHash, avatarURL: null }, avatarURL: null,
emailVerified: true,
emailVerifiedAt: now,
},
{
name: "demo user 4",
username: "demo4",
email: "demo4@example.com",
passwordHash,
avatarURL: null,
emailVerified: true,
emailVerifiedAt: now,
},
{
name: "demo user 5",
username: "demo5",
email: "demo5@example.com",
passwordHash,
avatarURL: null,
emailVerified: true,
emailVerifiedAt: now,
},
{
name: "demo user 6",
username: "demo6",
email: "demo6@example.com",
passwordHash,
avatarURL: null,
emailVerified: true,
emailVerifiedAt: now,
},
{
name: "demo user 7",
username: "demo7",
email: "demo7@example.com",
passwordHash,
avatarURL: null,
emailVerified: true,
emailVerifiedAt: now,
},
{
name: "demo user 8",
username: "demo8",
email: "demo8@example.com",
passwordHash,
avatarURL: null,
emailVerified: true,
emailVerifiedAt: now,
},
]; ];
async function seed() { async function seed() {

View File

@@ -170,108 +170,126 @@ export function IssuesTable({
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{issues.map((issueData) => ( {issues.map((issueData) => {
<TableRow const isSelected = issueData.Issue.id === selectedIssueId;
key={issueData.Issue.id} return (
className={cn("cursor-pointer max-w-full")} <TableRow
onClick={() => { key={issueData.Issue.id}
if (issueData.Issue.id === selectedIssueId) { className={cn("cursor-pointer max-w-full")}
selectIssue(null); onClick={() => {
return; if (isSelected) {
} selectIssue(null);
selectIssue(issueData); return;
}} }
> selectIssue(issueData);
{showId && ( }}
<TableCell >
className={cn( {showId && (
"font-medium border-r text-right p-0", <TableCell
issueData.Issue.id === selectedIssueId && "shadow-[inset_2px_0_0_0_var(--personality)]", className={cn(
)} "font-medium border-r text-right p-0",
> isSelected &&
<a "shadow-[inset_2px_2px_0_0_var(--personality),inset_0_-2px_0_0_var(--personality)]",
href={getIssueUrl(issueData.Issue.number)} )}
onClick={handleLinkClick}
className="block w-full h-full px-2 py-1 text-inherit hover:underline decoration-transparent"
> >
{issueData.Issue.number.toString().padStart(3, "0")} <a
</a> href={getIssueUrl(issueData.Issue.number)}
</TableCell> onClick={handleLinkClick}
)} className="block w-full h-full px-2 py-1 text-inherit hover:underline decoration-transparent"
{showTitle && ( >
<TableCell {issueData.Issue.number.toString().padStart(3, "0")}
className={cn( </a>
"min-w-0 p-0", </TableCell>
!showId && )}
issueData.Issue.id === selectedIssueId && {showTitle && (
"shadow-[inset_2px_0_0_0_var(--personality)]", <TableCell
)} className={cn(
> "min-w-0 p-0",
<a isSelected &&
href={getIssueUrl(issueData.Issue.number)} "shadow-[inset_0_2px_0_0_var(--personality),inset_0_-2px_0_0_var(--personality)]",
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 && <a
issueTypes[issueData.Issue.type] && ( href={getIssueUrl(issueData.Issue.number)}
<Icon onClick={handleLinkClick}
icon={issueTypes[issueData.Issue.type].icon as IconName} className="flex items-center gap-2 min-w-0 w-full h-full px-2 py-1 text-inherit hover:underline decoration-transparent"
size={16} >
color={issueTypes[issueData.Issue.type].color} {selectedOrganisation?.Organisation.features.issueTypes &&
/> issueTypes[issueData.Issue.type] && (
)} <Icon
{selectedOrganisation?.Organisation.features.issueStatus && icon={issueTypes[issueData.Issue.type].icon as IconName}
(columns.status == null || columns.status === true) && ( size={16}
<StatusTag status={issueData.Issue.status} colour={statuses[issueData.Issue.status]} /> color={issueTypes[issueData.Issue.type].color}
)} />
<span className="truncate">{issueData.Issue.title}</span> )}
</a> {selectedOrganisation?.Organisation.features.issueStatus &&
</TableCell> (columns.status == null || columns.status === true) && (
)} <StatusTag
{showDescription && ( status={issueData.Issue.status}
<TableCell className="overflow-hidden p-0"> colour={statuses[issueData.Issue.status]}
<a />
href={getIssueUrl(issueData.Issue.number)} )}
onClick={handleLinkClick} <span className="truncate">{issueData.Issue.title}</span>
className="block w-full h-full px-2 py-1 text-inherit hover:underline decoration-transparent" </a>
</TableCell>
)}
{showDescription && (
<TableCell
className={cn(
"overflow-hidden p-0",
isSelected &&
"shadow-[inset_0_2px_0_0_var(--personality),inset_0_-2px_0_0_var(--personality)]",
)}
> >
{issueData.Issue.description} <a
</a> href={getIssueUrl(issueData.Issue.number)}
</TableCell> onClick={handleLinkClick}
)} className="block w-full h-full px-2 py-1 text-inherit hover:underline decoration-transparent"
{showAssignee && ( >
<TableCell className="h-[32px] p-0"> {issueData.Issue.description}
<a </a>
href={getIssueUrl(issueData.Issue.number)} </TableCell>
onClick={handleLinkClick} )}
className="flex items-center justify-end w-full h-full px-2" {showAssignee && (
<TableCell
className={cn(
"h-[32px] p-0",
isSelected &&
"shadow-[inset_0_2px_0_0_var(--personality),inset_-2px_0_0_0_var(--personality),inset_0_-2px_0_0_var(--personality)]",
)}
> >
{selectedOrganisation?.Organisation.features.issueAssigneesShownInTable && <a
issueData.Assignees && href={getIssueUrl(issueData.Issue.number)}
issueData.Assignees.length > 0 && ( onClick={handleLinkClick}
<div className="flex items-center -space-x-2 pr-1.5"> className="flex items-center justify-end w-full h-full px-2"
{issueData.Assignees.slice(0, 3).map((assignee) => ( >
<Avatar {selectedOrganisation?.Organisation.features.issueAssigneesShownInTable &&
key={assignee.id} issueData.Assignees &&
name={assignee.name} issueData.Assignees.length > 0 && (
username={assignee.username} <div className="flex items-center -space-x-2 pr-1.5">
avatarURL={assignee.avatarURL} {issueData.Assignees.slice(0, 3).map((assignee) => (
textClass="text-xs" <Avatar
className="ring-1 ring-background" key={assignee.id}
/> name={assignee.name}
))} username={assignee.username}
{issueData.Assignees.length > 3 && ( avatarURL={assignee.avatarURL}
<span className="flex items-center justify-center w-6 h-6 text-[10px] font-medium bg-muted text-muted-foreground rounded-full ring-1 ring-background"> textClass="text-xs"
+{issueData.Assignees.length - 3} className="ring-1 ring-background"
</span> />
)} ))}
</div> {issueData.Assignees.length > 3 && (
)} <span className="flex items-center justify-center w-6 h-6 text-[10px] font-medium bg-muted text-muted-foreground rounded-full ring-1 ring-background">
</a> +{issueData.Assignees.length - 3}
</TableCell> </span>
)} )}
</TableRow> </div>
))} )}
</a>
</TableCell>
)}
</TableRow>
);
})}
</TableBody> </TableBody>
</Table> </Table>
); );

View File

@@ -203,13 +203,14 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {
// generate CSV or JSON // generate CSV or JSON
if (format === "csv") { if (format === "csv") {
const headers = ["User ID", "Name", "Username", "Total Time (ms)", "Total Time (formatted)"]; const headers = ["User ID", "Name", "Username", "Total Time (ms)", "Total Time (formatted)", "Hours"];
const rows = data.map((user) => [ const rows = data.map((user) => [
user.userId, user.userId,
user.name, user.name,
user.username, user.username,
user.totalTimeMs, user.totalTimeMs,
formatDuration(user.totalTimeMs), formatDuration(user.totalTimeMs),
(user.totalTimeMs / 3600000).toFixed(2),
]); ]);
const csv = [headers.join(","), ...rows.map((row) => row.map((cell) => `"${cell}"`).join(","))].join( const csv = [headers.join(","), ...rows.map((row) => row.map((cell) => `"${cell}"`).join(","))].join(
"\n", "\n",
@@ -234,6 +235,7 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {
members: data.map((user) => ({ members: data.map((user) => ({
...user, ...user,
totalTimeFormatted: formatDuration(user.totalTimeMs), totalTimeFormatted: formatDuration(user.totalTimeMs),
hours: Number((user.totalTimeMs / 3600000).toFixed(2)),
})), })),
}, },
null, null,

View File

@@ -11,6 +11,7 @@ import {
Clock as PixelClock, Clock as PixelClock,
Close as PixelClose, Close as PixelClose,
MessagePlus as PixelCommentSend, MessagePlus as PixelCommentSend,
Copy as PixelCopy,
CreditCard as PixelCreditCard, CreditCard as PixelCreditCard,
CreditCardDelete as PixelCreditCardDelete, CreditCardDelete as PixelCreditCardDelete,
Dashboard as PixelDashboard, Dashboard as PixelDashboard,
@@ -53,6 +54,7 @@ import {
CaretUpIcon as PhosphorChevronUp, CaretUpIcon as PhosphorChevronUp,
CircleIcon as PhosphorCircle, CircleIcon as PhosphorCircle,
ChatTextIcon as PhosphorComment, ChatTextIcon as PhosphorComment,
CopyIcon as PhosphorCopy,
CreditCardIcon as PhosphorCreditCard, CreditCardIcon as PhosphorCreditCard,
CubeIcon as PhosphorCube, CubeIcon as PhosphorCube,
DotsSixVerticalIcon as PhosphorDotsSixVertical, DotsSixVerticalIcon as PhosphorDotsSixVertical,
@@ -102,6 +104,7 @@ import {
CircleCheckIcon, CircleCheckIcon,
CircleIcon, CircleIcon,
CircleQuestionMark, CircleQuestionMark,
Copy,
CreditCard, CreditCard,
Edit, Edit,
EllipsisVertical, EllipsisVertical,
@@ -161,6 +164,7 @@ const icons = {
circleIcon: { lucide: CircleIcon, pixel: PixelCircle, phosphor: PhosphorCircle }, circleIcon: { lucide: CircleIcon, pixel: PixelCircle, phosphor: PhosphorCircle },
circleQuestionMark: { lucide: CircleQuestionMark, pixel: PixelNoteDelete, phosphor: PhosphorQuestion }, circleQuestionMark: { lucide: CircleQuestionMark, pixel: PixelNoteDelete, phosphor: PhosphorQuestion },
comment: { lucide: MessageSquarePlus, pixel: PixelCommentSend, phosphor: PhosphorComment }, comment: { lucide: MessageSquarePlus, pixel: PixelCommentSend, phosphor: PhosphorComment },
copy: { lucide: Copy, pixel: PixelCopy, phosphor: PhosphorCopy },
creditCard: { lucide: CreditCard, pixel: PixelCreditCard, phosphor: PhosphorCreditCard }, creditCard: { lucide: CreditCard, pixel: PixelCreditCard, phosphor: PhosphorCreditCard },
creditCardDelete: { lucide: CreditCard, pixel: PixelCreditCardDelete, phosphor: PhosphorCreditCard }, creditCardDelete: { lucide: CreditCard, pixel: PixelCreditCardDelete, phosphor: PhosphorCreditCard },
edit: { lucide: Edit, pixel: PixelEdit, phosphor: PhosphorEdit }, edit: { lucide: Edit, pixel: PixelEdit, phosphor: PhosphorEdit },

View File

@@ -2,10 +2,12 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { useLocation } from "react-router-dom"; import { useLocation } from "react-router-dom";
import Avatar from "@/components/avatar";
import { IssueDetailPane } from "@/components/issue-detail-pane"; import { IssueDetailPane } from "@/components/issue-detail-pane";
import { IssueModal } from "@/components/issue-modal"; import { IssueModal } from "@/components/issue-modal";
import { defaultIssuesTableFilters, IssuesTable, type IssuesTableFilters } from "@/components/issues-table"; import { defaultIssuesTableFilters, IssuesTable, type IssuesTableFilters } from "@/components/issues-table";
import { useSelection } from "@/components/selection-provider"; import { useSelection } from "@/components/selection-provider";
import SmallSprintDisplay from "@/components/small-sprint-display";
import SmallUserDisplay from "@/components/small-user-display"; import SmallUserDisplay from "@/components/small-user-display";
import StatusTag from "@/components/status-tag"; import StatusTag from "@/components/status-tag";
import TopBar from "@/components/top-bar"; import TopBar from "@/components/top-bar";
@@ -353,12 +355,6 @@ export default function Issues() {
"title-desc": "Title Z-A", "title-desc": "Title Z-A",
status: "Status", status: "Status",
}; };
const sprintLabel = useMemo(() => {
if (issueFilters.sprintId === "all") return "Sprint";
if (issueFilters.sprintId === "none") return "No sprint";
const sprintMatch = sprintsData.find((sprint) => sprint.id === issueFilters.sprintId);
return sprintMatch?.name ?? "Sprint";
}, [issueFilters.sprintId, sprintsData]);
return ( return (
<main className={`w-full h-screen flex flex-col gap-${BREATHING_ROOM} p-${BREATHING_ROOM}`}> <main className={`w-full h-screen flex flex-col gap-${BREATHING_ROOM} p-${BREATHING_ROOM}`}>
@@ -384,7 +380,17 @@ export default function Issues() {
{selectedOrganisation?.Organisation.features.issueStatus && ( {selectedOrganisation?.Organisation.features.issueStatus && (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger size="default" className="h-9"> <DropdownMenuTrigger size="default" className="h-9">
Status {issueFilters.statuses.length === 0 ? (
"Status"
) : (
<div className="flex items-center gap-1.5">
{Object.entries(statuses)
.filter(([status]) => issueFilters.statuses.includes(status))
.map(([status, colour]) => (
<StatusTag key={status} status={status} colour={colour} />
))}
</div>
)}
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="start"> <DropdownMenuContent align="start">
<DropdownMenuLabel>Status</DropdownMenuLabel> <DropdownMenuLabel>Status</DropdownMenuLabel>
@@ -414,7 +420,20 @@ export default function Issues() {
{selectedOrganisation?.Organisation.features.issueTypes && ( {selectedOrganisation?.Organisation.features.issueTypes && (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger size="default" className="h-9"> <DropdownMenuTrigger size="default" className="h-9">
Type {issueFilters.types.length === 0 ? (
"Type"
) : (
<div className="flex items-center gap-3">
{Object.entries(issueTypes)
.filter(([type]) => issueFilters.types.includes(type))
.map(([type, definition]) => (
<div key={type} className="flex items-center gap-1.5">
<Icon icon={definition.icon} size={14} color={definition.color} />
<span>{type}</span>
</div>
))}
</div>
)}
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="start"> <DropdownMenuContent align="start">
<DropdownMenuLabel>Type</DropdownMenuLabel> <DropdownMenuLabel>Type</DropdownMenuLabel>
@@ -446,7 +465,25 @@ export default function Issues() {
)} )}
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger size="default" className="h-9"> <DropdownMenuTrigger size="default" className="h-9">
Assignee {issueFilters.assignees.length === 0 ? (
"Assignee"
) : (
<div className="flex items-center gap-1.5">
{issueFilters.assignees.includes("unassigned") && <span>Unassigned</span>}
{members
.filter((member) => issueFilters.assignees.includes(String(member.id)))
.map((member) => (
<Avatar
key={member.id}
name={member.name}
username={member.username}
avatarURL={member.avatarURL}
size={6}
textClass="text-[10px]"
/>
))}
</div>
)}
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="start"> <DropdownMenuContent align="start">
<DropdownMenuLabel>Assignee</DropdownMenuLabel> <DropdownMenuLabel>Assignee</DropdownMenuLabel>
@@ -486,7 +523,16 @@ export default function Issues() {
{selectedOrganisation?.Organisation.features.sprints && ( {selectedOrganisation?.Organisation.features.sprints && (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger size="default" className="h-9"> <DropdownMenuTrigger size="default" className="h-9">
{sprintLabel} {issueFilters.sprintId === "all" ? (
"Sprint"
) : issueFilters.sprintId === "none" ? (
<SmallSprintDisplay />
) : (
(() => {
const sprint = sprintsData.find((s) => s.id === issueFilters.sprintId);
return sprint ? <SmallSprintDisplay sprint={sprint} /> : "Sprint";
})()
)}
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="start"> <DropdownMenuContent align="start">
<DropdownMenuLabel>Sprint</DropdownMenuLabel> <DropdownMenuLabel>Sprint</DropdownMenuLabel>
@@ -555,6 +601,64 @@ export default function Issues() {
> >
<Icon icon="undo" /> <Icon icon="undo" />
</IconButton> </IconButton>
<IconButton
variant="outline"
className="w-9 h-9"
aria-label="Copy filters URL"
title="Copy filters URL"
disabled={
!issueFilters.query &&
issueFilters.statuses.length === 0 &&
issueFilters.types.length === 0 &&
issueFilters.assignees.length === 0 &&
issueFilters.sprintId === defaultIssuesTableFilters.sprintId &&
issueFilters.sort === defaultIssuesTableFilters.sort
}
onClick={() => {
const params = new URLSearchParams(window.location.search);
if (issueFilters.query) {
params.set("q", issueFilters.query);
} else {
params.delete("q");
}
if (issueFilters.statuses.length > 0) {
params.set("status", issueFilters.statuses.join(","));
} else {
params.delete("status");
}
if (issueFilters.types.length > 0) {
params.set("type", issueFilters.types.join(","));
} else {
params.delete("type");
}
if (issueFilters.assignees.length > 0) {
params.set("assignee", issueFilters.assignees.join(","));
} else {
params.delete("assignee");
}
if (issueFilters.sprintId !== defaultIssuesTableFilters.sprintId) {
params.set("sprint", String(issueFilters.sprintId));
} else {
params.delete("sprint");
}
if (issueFilters.sort !== defaultIssuesTableFilters.sort) {
params.set("sort", issueFilters.sort);
} else {
params.delete("sort");
}
const url = `${window.location.origin}${window.location.pathname}?${params.toString()}`;
navigator.clipboard.writeText(url);
}}
>
<Icon icon="copy" />
</IconButton>
</div> </div>
)} )}