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_ACCESS_KEY_ID=your_access_key_id
S3_SECRET_ACCESS_KEY=your_secret_access_key
S3_BUCKET_NAME=issue
S3_BUCKET_NAME=sprint
STRIPE_PUBLISHABLE_KEY=your_stripe_publishable_key
STRIPE_SECRET_KEY=your_stripe_secret_key

View File

@@ -103,16 +103,81 @@ if (!SEED_PASSWORD) {
}
const passwordHash = await hashPassword(SEED_PASSWORD);
const now = new Date();
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
{ 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 5", username: "demo5", email: "demo5@example.com", passwordHash, avatarURL: null },
{ name: "demo user 6", username: "demo6", email: "demo6@example.com", passwordHash, avatarURL: null },
{ name: "demo user 7", username: "demo7", email: "demo7@example.com", passwordHash, avatarURL: null },
{ name: "demo user 8", username: "demo8", email: "demo8@example.com", passwordHash, avatarURL: null },
{
name: "demo user 3",
username: "demo3",
email: "demo3@example.com",
passwordHash,
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() {

View File

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

View File

@@ -203,13 +203,14 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {
// generate CSV or JSON
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) => [
user.userId,
user.name,
user.username,
user.totalTimeMs,
formatDuration(user.totalTimeMs),
(user.totalTimeMs / 3600000).toFixed(2),
]);
const csv = [headers.join(","), ...rows.map((row) => row.map((cell) => `"${cell}"`).join(","))].join(
"\n",
@@ -234,6 +235,7 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {
members: data.map((user) => ({
...user,
totalTimeFormatted: formatDuration(user.totalTimeMs),
hours: Number((user.totalTimeMs / 3600000).toFixed(2)),
})),
},
null,

View File

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

View File

@@ -2,10 +2,12 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useLocation } from "react-router-dom";
import Avatar from "@/components/avatar";
import { IssueDetailPane } from "@/components/issue-detail-pane";
import { IssueModal } from "@/components/issue-modal";
import { defaultIssuesTableFilters, IssuesTable, type IssuesTableFilters } from "@/components/issues-table";
import { useSelection } from "@/components/selection-provider";
import SmallSprintDisplay from "@/components/small-sprint-display";
import SmallUserDisplay from "@/components/small-user-display";
import StatusTag from "@/components/status-tag";
import TopBar from "@/components/top-bar";
@@ -353,12 +355,6 @@ export default function Issues() {
"title-desc": "Title Z-A",
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 (
<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 && (
<DropdownMenu>
<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>
<DropdownMenuContent align="start">
<DropdownMenuLabel>Status</DropdownMenuLabel>
@@ -414,7 +420,20 @@ export default function Issues() {
{selectedOrganisation?.Organisation.features.issueTypes && (
<DropdownMenu>
<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>
<DropdownMenuContent align="start">
<DropdownMenuLabel>Type</DropdownMenuLabel>
@@ -446,7 +465,25 @@ export default function Issues() {
)}
<DropdownMenu>
<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>
<DropdownMenuContent align="start">
<DropdownMenuLabel>Assignee</DropdownMenuLabel>
@@ -486,7 +523,16 @@ export default function Issues() {
{selectedOrganisation?.Organisation.features.sprints && (
<DropdownMenu>
<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>
<DropdownMenuContent align="start">
<DropdownMenuLabel>Sprint</DropdownMenuLabel>
@@ -555,6 +601,64 @@ export default function Issues() {
>
<Icon icon="undo" />
</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>
)}