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,12 +170,14 @@ export function IssuesTable({
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{issues.map((issueData) => ( {issues.map((issueData) => {
const isSelected = issueData.Issue.id === selectedIssueId;
return (
<TableRow <TableRow
key={issueData.Issue.id} key={issueData.Issue.id}
className={cn("cursor-pointer max-w-full")} className={cn("cursor-pointer max-w-full")}
onClick={() => { onClick={() => {
if (issueData.Issue.id === selectedIssueId) { if (isSelected) {
selectIssue(null); selectIssue(null);
return; return;
} }
@@ -186,7 +188,8 @@ export function IssuesTable({
<TableCell <TableCell
className={cn( className={cn(
"font-medium border-r text-right p-0", "font-medium border-r text-right p-0",
issueData.Issue.id === selectedIssueId && "shadow-[inset_2px_0_0_0_var(--personality)]", isSelected &&
"shadow-[inset_2px_2px_0_0_var(--personality),inset_0_-2px_0_0_var(--personality)]",
)} )}
> >
<a <a
@@ -202,9 +205,8 @@ export function IssuesTable({
<TableCell <TableCell
className={cn( className={cn(
"min-w-0 p-0", "min-w-0 p-0",
!showId && isSelected &&
issueData.Issue.id === selectedIssueId && "shadow-[inset_0_2px_0_0_var(--personality),inset_0_-2px_0_0_var(--personality)]",
"shadow-[inset_2px_0_0_0_var(--personality)]",
)} )}
> >
<a <a
@@ -222,14 +224,23 @@ export function IssuesTable({
)} )}
{selectedOrganisation?.Organisation.features.issueStatus && {selectedOrganisation?.Organisation.features.issueStatus &&
(columns.status == null || columns.status === true) && ( (columns.status == null || columns.status === true) && (
<StatusTag status={issueData.Issue.status} colour={statuses[issueData.Issue.status]} /> <StatusTag
status={issueData.Issue.status}
colour={statuses[issueData.Issue.status]}
/>
)} )}
<span className="truncate">{issueData.Issue.title}</span> <span className="truncate">{issueData.Issue.title}</span>
</a> </a>
</TableCell> </TableCell>
)} )}
{showDescription && ( {showDescription && (
<TableCell className="overflow-hidden p-0"> <TableCell
className={cn(
"overflow-hidden p-0",
isSelected &&
"shadow-[inset_0_2px_0_0_var(--personality),inset_0_-2px_0_0_var(--personality)]",
)}
>
<a <a
href={getIssueUrl(issueData.Issue.number)} href={getIssueUrl(issueData.Issue.number)}
onClick={handleLinkClick} onClick={handleLinkClick}
@@ -240,7 +251,13 @@ export function IssuesTable({
</TableCell> </TableCell>
)} )}
{showAssignee && ( {showAssignee && (
<TableCell className="h-[32px] p-0"> <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)]",
)}
>
<a <a
href={getIssueUrl(issueData.Issue.number)} href={getIssueUrl(issueData.Issue.number)}
onClick={handleLinkClick} onClick={handleLinkClick}
@@ -271,7 +288,8 @@ export function IssuesTable({
</TableCell> </TableCell>
)} )}
</TableRow> </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>
)} )}