Allow user to import tickets from JIRA via CSV upload
This commit is contained in:
parent
904dbdee7c
commit
b2e6b02b2e
|
@ -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",
|
||||||
|
|
|
@ -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 (
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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 }) => {
|
||||||
|
|
|
@ -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 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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 = {
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
name,
|
|
||||||
content,
|
|
||||||
};
|
|
||||||
|
|
||||||
const newTicketsValue = [newTicket]
|
const createTickets = async (tickets: CreateTicketRequest[]) => {
|
||||||
|
const newTickets = tickets.map<EstimationSessionTicket>(
|
||||||
|
({ content, name, estimate }) => ({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
name,
|
||||||
|
content,
|
||||||
|
estimate,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
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,
|
||||||
}}
|
}}
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
};
|
|
@ -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>
|
||||||
|
|
|
@ -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)}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -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;
|
Loading…
Reference in New Issue