Allow user to import tickets from JIRA via CSV upload

This commit is contained in:
Pijus Kamandulis 2024-10-19 17:07:17 +03:00
parent 904dbdee7c
commit b2e6b02b2e
14 changed files with 220 additions and 18 deletions

BIN
bun.lockb

Binary file not shown.

View File

@ -16,14 +16,18 @@
"@tanstack/yup-form-adapter": "^0.33.0", "@tanstack/yup-form-adapter": "^0.33.0",
"appwrite": "^16.0.2", "appwrite": "^16.0.2",
"classnames": "^2.5.1", "classnames": "^2.5.1",
"papaparse": "^5.4.1",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"showdown": "^2.1.0",
"yup": "^1.4.0" "yup": "^1.4.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.11.1", "@eslint/js": "^9.11.1",
"@types/papaparse": "^5.3.15",
"@types/react": "^18.3.10", "@types/react": "^18.3.10",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
"@types/showdown": "^2.0.6",
"@vitejs/plugin-react-swc": "^3.5.0", "@vitejs/plugin-react-swc": "^3.5.0",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"eslint": "^9.11.1", "eslint": "^9.11.1",

View File

@ -20,6 +20,7 @@ const Button: React.FC<ButtonProps> = ({
fullWidth = false, fullWidth = false,
disabled = false, disabled = false,
isLoading = false, isLoading = false,
className,
...props ...props
}) => { }) => {
disabled = disabled || isLoading; disabled = disabled || isLoading;
@ -39,6 +40,7 @@ const Button: React.FC<ButtonProps> = ({
'text-white': !disabled, 'text-white': !disabled,
'w-full': fullWidth, 'w-full': fullWidth,
}, },
className,
); );
return ( return (

View File

@ -1,14 +1,20 @@
import classNames from 'classnames'; import classNames from 'classnames';
import { PencilIcon } from './icons'; import { PencilIcon } from './icons';
interface CardProps { interface CardProps extends React.PropsWithChildren {
title: string; title: string;
description?: string; description?: string;
onClick?: () => void; onClick?: () => void;
onEdit?: () => void; onEdit?: () => void;
} }
const Card: React.FC<CardProps> = ({ title, description, onClick, onEdit }) => { const Card: React.FC<CardProps> = ({
title,
description,
onClick,
onEdit,
children,
}) => {
const className = classNames( const className = classNames(
'p-4 border rounded-lg shadow-sm transition', 'p-4 border rounded-lg shadow-sm transition',
{ {
@ -20,7 +26,7 @@ const Card: React.FC<CardProps> = ({ title, description, onClick, onEdit }) => {
); );
return ( return (
<div className={className} onClick={onClick}> <div className={className} onClick={onClick} title={title}>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="text-lg font-bold">{title}</h3> <h3 className="text-lg font-bold">{title}</h3>
{onEdit && ( {onEdit && (
@ -36,6 +42,7 @@ const Card: React.FC<CardProps> = ({ title, description, onClick, onEdit }) => {
</button> </button>
)} )}
</div> </div>
{children}
{description && <p className="mt-2">{description}</p>} {description && <p className="mt-2">{description}</p>}
</div> </div>
); );

View File

@ -32,7 +32,7 @@ const Drawer: React.FC<DrawerProps> = ({ isOpen, onClose, children }) => {
<div <div
className={classNames( className={classNames(
'relative h-full w-2/3 transform space-y-6 bg-white p-6 shadow-lg transition-transform dark:bg-nero-900', 'relative h-full w-2/3 transform space-y-6 overflow-auto bg-white p-6 shadow-lg transition-transform dark:bg-nero-900',
{ {
'translate-x-0': isOpen, 'translate-x-0': isOpen,
'translate-x-full': !isOpen, 'translate-x-full': !isOpen,

View File

@ -1,6 +1,6 @@
interface HtmlEmbedProps { interface HtmlEmbedProps {
body: string; body: string;
className: string; className?: string;
} }
const HtmlEmbed: React.FC<HtmlEmbedProps> = ({ body, className }) => { const HtmlEmbed: React.FC<HtmlEmbedProps> = ({ body, className }) => {

View File

@ -62,3 +62,15 @@ button:focus-visible {
background-color: #f9f9f9; background-color: #f9f9f9;
} }
} }
@layer utilities {
/* Hide scrollbar for Chrome, Safari and Opera */
.no-scrollbar::-webkit-scrollbar {
display: none;
}
/* Hide scrollbar for IE, Edge and Firefox */
.no-scrollbar {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
}

View File

@ -23,6 +23,7 @@ interface EstimationContextType {
setRevealed: (revealed: boolean) => Promise<void>; setRevealed: (revealed: boolean) => Promise<void>;
setVote: (estimate: string) => Promise<void>; setVote: (estimate: string) => Promise<void>;
createTicket: (ticket: CreateTicketRequest) => Promise<void>; createTicket: (ticket: CreateTicketRequest) => Promise<void>;
createTickets: (tickets: CreateTicketRequest[]) => Promise<void>;
updateTicket: (ticket: EditTicketRequest) => Promise<void>; updateTicket: (ticket: EditTicketRequest) => Promise<void>;
currentSessionData?: EntityModels.EstimationSession; currentSessionData?: EntityModels.EstimationSession;
} }
@ -120,14 +121,19 @@ export const EstimationContextProvider = (props: PropsWithChildren) => {
}); });
}; };
const createTicket = async ({ name, content }: CreateTicketRequest) => { const createTicket = (ticket: CreateTicketRequest) => createTickets([ticket]);
const newTicket: EstimationSessionTicket = {
const createTickets = async (tickets: CreateTicketRequest[]) => {
const newTickets = tickets.map<EstimationSessionTicket>(
({ content, name, estimate }) => ({
id: crypto.randomUUID(), id: crypto.randomUUID(),
name, name,
content, content,
}; estimate,
}),
);
const newTicketsValue = [newTicket] const newTicketsValue = newTickets
.concat(currentSessionData?.tickets ?? []) .concat(currentSessionData?.tickets ?? [])
.map((x) => JSON.stringify(x)); .map((x) => JSON.stringify(x));
@ -186,6 +192,7 @@ export const EstimationContextProvider = (props: PropsWithChildren) => {
setRevealed, setRevealed,
setVote, setVote,
createTicket, createTicket,
createTickets,
updateTicket, updateTicket,
currentSessionData, currentSessionData,
}} }}

View File

@ -0,0 +1,66 @@
import { EntityModels } from '../types';
import Papa from 'papaparse';
import Showdown from 'showdown';
export interface TicketFileUploadResponse {
tickets: EntityModels.EstimationSessionTicket[];
error?: string;
}
export enum FileSchema {
Jira,
}
export const handleTicketFileUpload = (
event: React.ChangeEvent<HTMLInputElement>,
callback: (response: TicketFileUploadResponse) => void,
fileSchema: FileSchema = FileSchema.Jira,
) => {
const file = event.target.files?.[0];
if (!file) return;
// Parse the CSV file using PapaParse
Papa.parse(file, {
header: true,
skipEmptyLines: true,
complete: (results) => {
let parsedTickets: EntityModels.EstimationSessionTicket[] | null = null;
switch (fileSchema) {
// TODO: Add more file schemas
case FileSchema.Jira:
parsedTickets = parseJiraCSV(results.data);
break;
default:
}
callback({
tickets: parsedTickets ?? [],
error: parsedTickets ? undefined : 'Failed to parse JIRA CSV file.',
});
},
error: (err) => {
callback({
tickets: [],
error: `Error parsing CSV file: ${err.message}`,
});
},
});
};
const parseJiraCSV = (
data: any[],
): EntityModels.EstimationSessionTicket[] | null => {
const converter = new Showdown.Converter();
try {
return data.map<EntityModels.EstimationSessionTicket>((row) => ({
id: crypto.randomUUID(),
name: row['Summary'],
estimate: row['Story point estimate'] || '',
content: converter.makeHtml(row['Description'] || ''),
}));
} catch (error) {
console.error('Error parsing CSV:', error);
return null;
}
};

View File

@ -46,7 +46,7 @@ const authenticatedRoute = createRoute({
return ( return (
<div className="flex h-screen flex-col"> <div className="flex h-screen flex-col">
<Header /> <Header />
<div className="flex-grow"> <div className="flex-grow overflow-auto">
<Outlet /> <Outlet />
</div> </div>
</div> </div>

View File

@ -47,7 +47,7 @@ const Estimation: React.FC = () => {
return ( return (
<div className="flex h-full"> <div className="flex h-full">
<TaskSidebar <TaskSidebar
className="w-64 overflow-y-scroll bg-gray-50 p-4 dark:bg-nero-800" className="w-96 overflow-y-scroll bg-gray-50 p-4 dark:bg-nero-800"
tickets={tickets} tickets={tickets}
onSelectTicket={(ticket) => setActiveTicket(ticket.id)} onSelectTicket={(ticket) => setActiveTicket(ticket.id)}
onAddTicket={() => setIsDrawerOpen(true)} onAddTicket={() => setIsDrawerOpen(true)}

View File

@ -14,12 +14,12 @@ const PlayerList: React.FC<PlayerListProps> = ({
title = 'Players', title = 'Players',
}) => { }) => {
return ( return (
<div className="w-full max-w-sm rounded-lg bg-white p-6 shadow-lg dark:bg-nero-800"> <div className="flex w-full max-w-sm flex-col justify-between rounded-lg bg-white p-6 shadow-lg dark:bg-nero-800">
<h2 className="mb-4 text-xl font-semibold text-gray-900 dark:text-gray-100"> <h2 className="mb-4 text-xl font-semibold text-gray-900 dark:text-gray-100">
{title} {title}
</h2> </h2>
<ul className="max-h-48 divide-y divide-gray-300 overflow-y-auto dark:divide-gray-600"> <ul className="flex-grow divide-y divide-gray-300 overflow-y-auto dark:divide-gray-600">
{players.length > 0 ? ( {players.length > 0 ? (
players.map((player) => ( players.map((player) => (
<li <li

View File

@ -1,5 +1,8 @@
import { Card, GridList } from '../../../components'; import classNames from 'classnames';
import { Button, Card, Drawer, GridList } from '../../../components';
import { EstimationSessionTicket } from '../../../lib/types/entityModels'; import { EstimationSessionTicket } from '../../../lib/types/entityModels';
import TicketImportForm from './TicketImportForm';
import { useState } from 'react';
interface TaskSidebarProps { interface TaskSidebarProps {
className?: string; className?: string;
@ -16,9 +19,16 @@ const TaskSidebar: React.FC<TaskSidebarProps> = ({
onAddTicket, onAddTicket,
onEditTicket, onEditTicket,
}) => { }) => {
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
const containerClassName = classNames(
className,
'flex flex-col justify-between',
);
return ( return (
<div className={className}> <div className={containerClassName}>
<GridList <GridList
className="no-scrollbar overflow-y-scroll"
items={tickets} items={tickets}
colNum={1} colNum={1}
itemComponent={({ item }) => ( itemComponent={({ item }) => (
@ -30,8 +40,15 @@ const TaskSidebar: React.FC<TaskSidebarProps> = ({
onEdit={() => onEditTicket(item.id)} onEdit={() => onEditTicket(item.id)}
/> />
)} )}
addItemLabel="+ Add Ticket"
onAddItem={onAddTicket} onAddItem={onAddTicket}
/> />
<Button className="mt-2" fullWidth onClick={() => setIsDrawerOpen(true)}>
Import Tickets
</Button>
<Drawer isOpen={isDrawerOpen} onClose={() => setIsDrawerOpen(false)}>
<TicketImportForm onTicketsImported={() => setIsDrawerOpen(false)} />
</Drawer>
</div> </div>
); );
}; };

View File

@ -0,0 +1,87 @@
import { useState } from 'react';
import {
handleTicketFileUpload,
TicketFileUploadResponse,
} from '../../../lib/parsers/ticketUpload';
import { EstimationSessionTicket } from '../../../lib/types/entityModels';
import { Button, Card, GridList, Loader } from '../../../components';
import HtmlEmbed from '../../../components/HtmlEmbed';
import { useEstimationContext } from '../../../lib/context/estimation';
interface TicketImportFormProps {
onTicketsImported: () => void;
}
const TicketImportForm: React.FC<TicketImportFormProps> = ({
onTicketsImported,
}) => {
const [error, setError] = useState<string>('');
const [tickets, setTickets] = useState<EstimationSessionTicket[]>([]);
const estimationContext = useEstimationContext();
if (!estimationContext) {
return <Loader center fullHeight />;
}
const { createTickets } = estimationContext;
const onParsedTickets = ({ tickets, error }: TicketFileUploadResponse) => {
if (error) {
setError(error);
}
setTickets(tickets);
};
const onCreateTickets = async () => {
await createTickets(tickets);
onTicketsImported();
};
return (
<div>
<h2 className="mb-4 text-xl font-semibold text-gray-900 dark:text-gray-100">
Upload Ticket List CSV
</h2>
<input
type="file"
accept=".csv"
onChange={(e) => {
setTickets([]);
setError('');
handleTicketFileUpload(e, onParsedTickets);
}}
className="mb-4 block w-full text-sm text-gray-900 dark:text-gray-100"
/>
{error && <p className="text-red-500">{error}</p>}
{tickets.length > 0 && (
<div className="mt-4">
<h3 className="mb-2 mt-2 text-lg font-medium text-gray-900 dark:text-gray-100">
Tickets to be imported
</h3>
<GridList
className="no-scrollbar overflow-y-scroll"
items={tickets}
colNum={1}
itemComponent={({ item }) => (
<Card
key={item.id}
title={item.name}
description={`Estimate: ${item.estimate || 'N/A'}`}
>
<HtmlEmbed className="h-16 w-full" body={item.content} />
</Card>
)}
/>
<Button className="mt-4" fullWidth onClick={onCreateTickets}>
Import
</Button>
</div>
)}
</div>
);
};
export default TicketImportForm;