diff --git a/bun.lockb b/bun.lockb index e267079..8cd3e05 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 61b48a7..7f190ec 100644 --- a/package.json +++ b/package.json @@ -16,14 +16,18 @@ "@tanstack/yup-form-adapter": "^0.33.0", "appwrite": "^16.0.2", "classnames": "^2.5.1", + "papaparse": "^5.4.1", "react": "^18.3.1", "react-dom": "^18.3.1", + "showdown": "^2.1.0", "yup": "^1.4.0" }, "devDependencies": { "@eslint/js": "^9.11.1", + "@types/papaparse": "^5.3.15", "@types/react": "^18.3.10", "@types/react-dom": "^18.3.0", + "@types/showdown": "^2.0.6", "@vitejs/plugin-react-swc": "^3.5.0", "autoprefixer": "^10.4.20", "eslint": "^9.11.1", diff --git a/src/components/Button.tsx b/src/components/Button.tsx index f89c1c6..f392699 100644 --- a/src/components/Button.tsx +++ b/src/components/Button.tsx @@ -20,6 +20,7 @@ const Button: React.FC = ({ fullWidth = false, disabled = false, isLoading = false, + className, ...props }) => { disabled = disabled || isLoading; @@ -39,6 +40,7 @@ const Button: React.FC = ({ 'text-white': !disabled, 'w-full': fullWidth, }, + className, ); return ( diff --git a/src/components/Card.tsx b/src/components/Card.tsx index f862969..6f0ad86 100644 --- a/src/components/Card.tsx +++ b/src/components/Card.tsx @@ -1,14 +1,20 @@ import classNames from 'classnames'; import { PencilIcon } from './icons'; -interface CardProps { +interface CardProps extends React.PropsWithChildren { title: string; description?: string; onClick?: () => void; onEdit?: () => void; } -const Card: React.FC = ({ title, description, onClick, onEdit }) => { +const Card: React.FC = ({ + title, + description, + onClick, + onEdit, + children, +}) => { const className = classNames( 'p-4 border rounded-lg shadow-sm transition', { @@ -20,7 +26,7 @@ const Card: React.FC = ({ title, description, onClick, onEdit }) => { ); return ( -
+

{title}

{onEdit && ( @@ -36,6 +42,7 @@ const Card: React.FC = ({ title, description, onClick, onEdit }) => { )}
+ {children} {description &&

{description}

}
); diff --git a/src/components/Drawer.tsx b/src/components/Drawer.tsx index 3a93a3d..5d5b227 100644 --- a/src/components/Drawer.tsx +++ b/src/components/Drawer.tsx @@ -32,7 +32,7 @@ const Drawer: React.FC = ({ isOpen, onClose, children }) => {
= ({ body, className }) => { diff --git a/src/index.css b/src/index.css index 6e569a5..61625a2 100644 --- a/src/index.css +++ b/src/index.css @@ -62,3 +62,15 @@ button:focus-visible { 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 */ + } +} diff --git a/src/lib/context/estimation.tsx b/src/lib/context/estimation.tsx index 4985b80..cad23b3 100644 --- a/src/lib/context/estimation.tsx +++ b/src/lib/context/estimation.tsx @@ -23,6 +23,7 @@ interface EstimationContextType { setRevealed: (revealed: boolean) => Promise; setVote: (estimate: string) => Promise; createTicket: (ticket: CreateTicketRequest) => Promise; + createTickets: (tickets: CreateTicketRequest[]) => Promise; updateTicket: (ticket: EditTicketRequest) => Promise; currentSessionData?: EntityModels.EstimationSession; } @@ -120,14 +121,19 @@ export const EstimationContextProvider = (props: PropsWithChildren) => { }); }; - const createTicket = async ({ name, content }: CreateTicketRequest) => { - const newTicket: EstimationSessionTicket = { - id: crypto.randomUUID(), - name, - content, - }; + const createTicket = (ticket: CreateTicketRequest) => createTickets([ticket]); - const newTicketsValue = [newTicket] + const createTickets = async (tickets: CreateTicketRequest[]) => { + const newTickets = tickets.map( + ({ content, name, estimate }) => ({ + id: crypto.randomUUID(), + name, + content, + estimate, + }), + ); + + const newTicketsValue = newTickets .concat(currentSessionData?.tickets ?? []) .map((x) => JSON.stringify(x)); @@ -186,6 +192,7 @@ export const EstimationContextProvider = (props: PropsWithChildren) => { setRevealed, setVote, createTicket, + createTickets, updateTicket, currentSessionData, }} diff --git a/src/lib/parsers/ticketUpload.ts b/src/lib/parsers/ticketUpload.ts new file mode 100644 index 0000000..118a2a3 --- /dev/null +++ b/src/lib/parsers/ticketUpload.ts @@ -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, + 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((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; + } +}; diff --git a/src/main.tsx b/src/main.tsx index 19be276..f961e9c 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -46,7 +46,7 @@ const authenticatedRoute = createRoute({ return (
-
+
diff --git a/src/pages/Estimation/Estimation.tsx b/src/pages/Estimation/Estimation.tsx index 76bea8f..f387925 100644 --- a/src/pages/Estimation/Estimation.tsx +++ b/src/pages/Estimation/Estimation.tsx @@ -47,7 +47,7 @@ const Estimation: React.FC = () => { return (
setActiveTicket(ticket.id)} onAddTicket={() => setIsDrawerOpen(true)} diff --git a/src/pages/Estimation/components/PlayerList.tsx b/src/pages/Estimation/components/PlayerList.tsx index a95698d..c803ade 100644 --- a/src/pages/Estimation/components/PlayerList.tsx +++ b/src/pages/Estimation/components/PlayerList.tsx @@ -14,12 +14,12 @@ const PlayerList: React.FC = ({ title = 'Players', }) => { return ( -
+

{title}

-
    +
      {players.length > 0 ? ( players.map((player) => (
    • = ({ onAddTicket, onEditTicket, }) => { + const [isDrawerOpen, setIsDrawerOpen] = useState(false); + const containerClassName = classNames( + className, + 'flex flex-col justify-between', + ); + return ( -
      +
      ( @@ -30,8 +40,15 @@ const TaskSidebar: React.FC = ({ onEdit={() => onEditTicket(item.id)} /> )} + addItemLabel="+ Add Ticket" onAddItem={onAddTicket} /> + + setIsDrawerOpen(false)}> + setIsDrawerOpen(false)} /> +
      ); }; diff --git a/src/pages/Estimation/components/TicketImportForm.tsx b/src/pages/Estimation/components/TicketImportForm.tsx new file mode 100644 index 0000000..c50f7e6 --- /dev/null +++ b/src/pages/Estimation/components/TicketImportForm.tsx @@ -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 = ({ + onTicketsImported, +}) => { + const [error, setError] = useState(''); + const [tickets, setTickets] = useState([]); + const estimationContext = useEstimationContext(); + + if (!estimationContext) { + return ; + } + + const { createTickets } = estimationContext; + + const onParsedTickets = ({ tickets, error }: TicketFileUploadResponse) => { + if (error) { + setError(error); + } + setTickets(tickets); + }; + + const onCreateTickets = async () => { + await createTickets(tickets); + onTicketsImported(); + }; + + return ( +
      +

      + Upload Ticket List CSV +

      + + { + setTickets([]); + setError(''); + handleTicketFileUpload(e, onParsedTickets); + }} + className="mb-4 block w-full text-sm text-gray-900 dark:text-gray-100" + /> + + {error &&

      {error}

      } + + {tickets.length > 0 && ( +
      +

      + Tickets to be imported +

      + ( + + + + )} + /> + +
      + )} +
      + ); +}; + +export default TicketImportForm;