diff --git a/app/actions.ts b/app/actions.ts index 878e836..070352e 100644 --- a/app/actions.ts +++ b/app/actions.ts @@ -15,7 +15,7 @@ export type Option = { export type PrimaryDataType = 'issue' | 'commit' | 'pull-request' | 'snippet' | 'item'; type GridCellState = 'empty' | 'generating' | 'done' | 'error'; -export type ColumnType = 'text' | 'select' | 'select-user' | 'file' | 'issue-pr' | 'commit'; +export type ColumnType = 'text' | 'select' | 'select-user' | 'file' | 'boolean' | 'issue-pr' | 'commit'; export type ColumnResponse = { text: string; @@ -24,6 +24,7 @@ export type ColumnResponse = { file: | { file: { path: string; repository: string } } | { files: { path: string; repository: string }[] }; + boolean: { value: boolean }; 'issue-pr': { reference?: { number: number; @@ -50,7 +51,6 @@ export type ColumnResponse = { message?: string; }>; }; - boolean: boolean; }; export type GridCell = { diff --git a/app/columns/IssuePRColumnType.tsx b/app/columns/IssuePRColumnType.tsx index bf612ce..efeb0ce 100644 --- a/app/columns/IssuePRColumnType.tsx +++ b/app/columns/IssuePRColumnType.tsx @@ -110,7 +110,7 @@ export const IssuePRColumnType: BaseColumnType<'issue-pr'> = { parsed = JSON.parse(responseContent); } catch (error) { console.error('Failed to parse response content:', error); - return multiple ? { references: [] } : { reference: null }; + return multiple ? { references: [] } : { reference: undefined }; } return multiple ? { references: parsed.references } : { reference: parsed.reference }; }, diff --git a/app/components/Cell.tsx b/app/components/Cell.tsx index 3a9a3da..ce2f25e 100644 --- a/app/components/Cell.tsx +++ b/app/components/Cell.tsx @@ -14,7 +14,7 @@ type CellProps = { rowIndex?: number; }; -export default function Cell({ sx, cell, onClick, isSelected = false, rowIndex }: CellProps) { +function Cell({ sx, cell, onClick, isSelected = false, rowIndex }: CellProps) { const { deleteRow } = useGridContext(); const isPrimaryCell = rowIndex !== undefined; @@ -31,6 +31,12 @@ export default function Cell({ sx, cell, onClick, isSelected = false, rowIndex } backgroundColor: 'canvas.inset', }; + const handleDeleteRow = React.useCallback(() => { + if (rowIndex !== undefined) { + deleteRow(rowIndex); + } + }, [deleteRow, rowIndex]); + return ( - deleteRow(rowIndex)}> + @@ -88,7 +94,7 @@ export default function Cell({ sx, cell, onClick, isSelected = false, rowIndex } ); } -export function GridCellContent({ cell }: { cell: GridCell }) { +const GridCellContent = React.memo(function GridCellContent({ cell }: { cell: GridCell }) { if (cell.state === 'error') { return cell.errorMessage; } @@ -98,4 +104,8 @@ export function GridCellContent({ cell }: { cell: GridCell }) { const columnType = columnTypes[cell.columnType] as BaseColumnType; return columnType.renderCell(cell); -} +}); + +// Memoize Cell component to prevent unnecessary re-renders +export default React.memo(Cell); +export { GridCellContent }; diff --git a/app/components/ColumnTitle.tsx b/app/components/ColumnTitle.tsx index 33eb8ba..c2051bc 100644 --- a/app/components/ColumnTitle.tsx +++ b/app/components/ColumnTitle.tsx @@ -1,62 +1,74 @@ -import { IconButton, Box, ActionMenu, ActionList } from '@primer/react'; -import { KebabHorizontalIcon, PencilIcon, TrashIcon } from '@primer/octicons-react'; +import React, { useCallback } from 'react'; +import { Box, Text, IconButton, ActionMenu, ActionList } from '@primer/react'; +import { KebabHorizontalIcon, TrashIcon } from '@primer/octicons-react'; import { useGridContext } from './GridContext'; -export default function ColumnTitle({ title, index }: { title: string; index?: number }) { - const { deleteColumnByIndex } = useGridContext(); +type ColumnTitleProps = { + title: string; + index?: number; +}; + +function ColumnTitle({ title, index }: ColumnTitleProps) { + const { deleteColumnByIndex, setGroupBy, setFilterBy } = useGridContext(); + const hasIndex = index !== undefined; + + const handleDeleteColumn = useCallback(() => { + if (index !== undefined) { + deleteColumnByIndex(index); + } + }, [deleteColumnByIndex, index]); + + const handleGroupBy = useCallback(() => { + setGroupBy(title); + }, [setGroupBy, title]); + + const handleClearFilters = useCallback(() => { + setFilterBy(undefined, undefined); + }, [setFilterBy]); + return ( - {title} - - - - - - - - {index !== undefined && ( - deleteColumnByIndex(index)}> + {title} + {hasIndex && ( + + + + + + + Group by {title} + Clear filters + + - Delete + Delete column - )} - alert('Copy link clicked')}> - - - - Edit - - - - + + + + )} ); } + +export default React.memo(ColumnTitle); diff --git a/app/components/DebugDialog.tsx b/app/components/DebugDialog.tsx index 652a88e..d843193 100644 --- a/app/components/DebugDialog.tsx +++ b/app/components/DebugDialog.tsx @@ -1,9 +1,18 @@ -import { useState } from 'react'; +import React, { useState, useCallback } from 'react'; import { Box } from '@primer/react'; import { Dialog } from '@primer/react/experimental'; -export default function DebugDialog({ prompt, sources }: { prompt: string; sources: string[] }) { +interface DebugDialogProps { + prompt: string; + sources: string[]; +} + +const DebugDialog = React.memo(function DebugDialog({ prompt, sources }: DebugDialogProps) { const [open, setOpen] = useState(false); + + const handleOpen = useCallback(() => setOpen(true), []); + const handleClose = useCallback(() => setOpen(false), []); + return ( <> setOpen(true)} + onClick={handleOpen} > Debug {open && ( - setOpen(false)}> + {sources.length > 0 && ( <> Sources used: - {sources.map((source) => ( - + {sources.map((source, index) => ( + {source} ))} @@ -66,4 +75,6 @@ export default function DebugDialog({ prompt, sources }: { prompt: string; sourc )} ); -} +}); + +export default DebugDialog; diff --git a/app/components/Grid.css b/app/components/Grid.css index 462ac0b..a03caa9 100644 --- a/app/components/Grid.css +++ b/app/components/Grid.css @@ -6,10 +6,15 @@ .markdownContainer { font-size: 14px; + contain: content; + word-wrap: break-word; + overflow-wrap: break-word; } .markdownContainer img { max-width: 100%; + height: auto; + loading: lazy; } .markdownContainer > p { @@ -26,3 +31,46 @@ margin-bottom: 0; padding-bottom: 0; } + +/* Performance optimizations */ +.grid-app { + contain: layout style; +} + +.virtualized-table { + contain: strict; + will-change: transform; +} + +.grid-row { + contain: layout style; +} + +.grid-cell { + contain: layout style; +} + +/* Smooth scrolling optimizations */ +.scroll-container { + scrollbar-width: thin; + scrollbar-color: rgba(0, 0, 0, 0.2) transparent; + -webkit-overflow-scrolling: touch; +} + +.scroll-container::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +.scroll-container::-webkit-scrollbar-track { + background: transparent; +} + +.scroll-container::-webkit-scrollbar-thumb { + background-color: rgba(0, 0, 0, 0.2); + border-radius: 4px; +} + +.scroll-container::-webkit-scrollbar-thumb:hover { + background-color: rgba(0, 0, 0, 0.3); +} diff --git a/app/components/GridContext.tsx b/app/components/GridContext.tsx index 6e20b57..9ca5958 100644 --- a/app/components/GridContext.tsx +++ b/app/components/GridContext.tsx @@ -1,4 +1,4 @@ -import React, { createContext, useContext, ReactNode, useState, useCallback } from 'react'; +import React, { createContext, useContext, ReactNode, useState, useCallback, useMemo } from 'react'; import { SuccessfulPrimaryColumnResponse, ErrorResponse, GridState, GridCell } from '../actions'; import type { ColumnResponse, ColumnType, GridCol, Option } from '../actions'; import useLocalStorage from '../utils/local-storage'; @@ -13,23 +13,26 @@ export type Grid = { createdAt: Date; }; -type GridContextType = { +type GridStateContextType = { gridState: GridState | null; + selectedIndex: number | null; + currentGridId: string | null; + isSavingGist: boolean; +}; + +type GridActionsContextType = { setGridState: React.Dispatch>; selectRow: (index: number | null) => void; updateCellState: (columnTitle: string, cellIndex: number, newCellContents: GridCell) => void; addNewColumn: (props: NewColumnProps) => void; inititializeGrid: (s: string) => Promise; - selectedIndex: number | null; deleteColumnByIndex: (index: number) => void; setGroupBy: (columnTitle: string | undefined) => void; setFilterBy: (columnTitle: string | undefined, filterValue: string | undefined) => void; - currentGridId: string | null; setCurrentGridId: (id: string) => void; getAllGrids: () => Grid[]; deleteGrid: (id: string) => void; saveGridAsGist: () => Promise; - isSavingGist: boolean; // Add this new property deleteRow: (index: number) => void; }; @@ -41,29 +44,50 @@ type NewColumnProps = { multiple?: boolean; }; -const GridContext = createContext(undefined); +const GridStateContext = createContext(undefined); +const GridActionsContext = createContext(undefined); -export const useGridContext = () => { - const context = useContext(GridContext); +export const useGridState = () => { + const context = useContext(GridStateContext); + if (context === undefined) { + throw new Error('useGridState must be used within a GridProvider'); + } + return context; +}; + +export const useGridActions = () => { + const context = useContext(GridActionsContext); if (context === undefined) { - throw new Error('useGridContext must be used within a GridProvider'); + throw new Error('useGridActions must be used within a GridProvider'); } return context; }; +// Legacy hook for backward compatibility +export const useGridContext = () => { + const state = useGridState(); + const actions = useGridActions(); + return { ...state, ...actions }; +}; + type ProviderProps = { hydrateCell: (cell: GridCell) => Promise<{ promise: Promise }>; createPrimaryColumn: (s: string) => Promise; children: ReactNode; }; -export const GridProvider = ({ createPrimaryColumn, hydrateCell, children }: ProviderProps) => { +export const GridProvider = React.memo(function GridProvider({ createPrimaryColumn, hydrateCell, children }: ProviderProps) { const [grids, setGrids] = useLocalStorage>('grids', {}); const [currentGridId, setCurrentGridId] = useState(null); + const [selectedIndex, setSelectedIndex] = useState(null); + const [isSavingGist, setIsSavingGist] = useState(false); - const gridState = currentGridId ? grids[currentGridId] : null; + const gridState = useMemo(() => + currentGridId ? grids[currentGridId] : null, + [currentGridId, grids] + ); - const setGridState: React.Dispatch> = (newState) => { + const setGridState: React.Dispatch> = useCallback((newState) => { if (currentGridId) { setGrids((prevGrids) => ({ ...prevGrids, @@ -73,13 +97,13 @@ export const GridProvider = ({ createPrimaryColumn, hydrateCell, children }: Pro : (newState ?? prevGrids[currentGridId]), })); } - }; + }, [currentGridId, setGrids]); const getAllGrids = useCallback(() => { return Object.entries(grids).map(([id, grid]) => ({ id, title: grid.title, - rowCount: grid.primaryColumn.length, + rowCount: grid.primaryColumn.filter(cell => !cell.deleted).length, columnCount: grid.columns.length + 1, createdAt: new Date(), })); @@ -99,7 +123,7 @@ export const GridProvider = ({ createPrimaryColumn, hydrateCell, children }: Pro [setGrids, currentGridId] ); - async function inititializeGrid(title: string): Promise { + const inititializeGrid = useCallback(async (title: string): Promise => { const result = await createPrimaryColumn(title); if (!result.success) { throw new Error(result.message); @@ -112,19 +136,42 @@ export const GridProvider = ({ createPrimaryColumn, hydrateCell, children }: Pro })); setCurrentGridId(newGridId); return newGridId; - } - - const [selectedIndex, setSelectedIndex] = useState(null); + }, [createPrimaryColumn, setGrids]); - const selectRow = (index: number | null) => { + const selectRow = useCallback((index: number | null) => { if (!gridState) { console.warn("Can't select row without grid state!"); return; } setSelectedIndex(index); - }; + }, [gridState]); - function addNewColumn({ title, instructions, type, options, multiple }: NewColumnProps) { + const updateCellState = useCallback((columnTitle: string, cellIndex: number, newCellContents: GridCell) => { + setGridState((prevState) => { + if (prevState === null) { + return null; + } + return { + ...prevState, + columns: prevState.columns.map((column) => { + if (column.title === columnTitle) { + return { + ...column, + cells: column.cells.map((c, i) => { + if (i === cellIndex) { + return newCellContents; + } + return c; + }), + }; + } + return column; + }), + }; + }); + }, [setGridState]); + + const addNewColumn = useCallback(({ title, instructions, type, options, multiple }: NewColumnProps) => { if (!gridState) { alert("Can't add column without grid state!"); return; @@ -169,9 +216,9 @@ export const GridProvider = ({ createPrimaryColumn, hydrateCell, children }: Pro updateCellState(title, cellIndex, hydratedCell); }); }); - } + }, [gridState, setGridState, hydrateCell, updateCellState]); - const deleteColumnByIndex = (index: number) => { + const deleteColumnByIndex = useCallback((index: number) => { setGridState((prevState) => { if (prevState === null) { return null; @@ -181,9 +228,9 @@ export const GridProvider = ({ createPrimaryColumn, hydrateCell, children }: Pro columns: prevState.columns.filter((_, colIndex) => colIndex !== index), }; }); - }; + }, [setGridState]); - const setGroupBy = (columnTitle: string | undefined) => { + const setGroupBy = useCallback((columnTitle: string | undefined) => { setGridState((prevState) => { if (prevState === null) { return null; @@ -193,9 +240,9 @@ export const GridProvider = ({ createPrimaryColumn, hydrateCell, children }: Pro groupBy: columnTitle, }; }); - }; + }, [setGridState]); - const setFilterBy = (columnTitle: string | undefined, filterValue: string | undefined) => { + const setFilterBy = useCallback((columnTitle: string | undefined, filterValue: string | undefined) => { setGridState((prevState) => { if (prevState === null) { return null; @@ -208,67 +255,37 @@ export const GridProvider = ({ createPrimaryColumn, hydrateCell, children }: Pro }, }; }); - }; - - const updateCellState = (columnTitle: string, cellIndex: number, newCellContents: GridCell) => { - setGridState((prevState) => { - if (prevState === null) { - return null; - } - return { - ...prevState, - columns: prevState.columns.map((column) => { - if (column.title === columnTitle) { - return { - ...column, - cells: column.cells.map((c, i) => { - if (i === cellIndex) { - return newCellContents; - } - return c; - }), - }; - } - return column; - }), - }; - }); - }; - - const [isSavingGist, setIsSavingGist] = useState(false); + }, [setGridState]); - const saveGridAsGist = async (): Promise => { - if (!gridState) { - console.warn("Can't save grid without grid state!"); - return null; - } - - setIsSavingGist(true); - try { - const markdownTable = generateMarkdownTable(gridState); - const filename = `${gridState.title}.md`; - const gistUrl = await createGist(filename, markdownTable); - return gistUrl; - } catch (error) { - console.error('Failed to save grid as gist:', error); - return null; - } finally { - setIsSavingGist(false); - } - }; + const deleteRow = useCallback( + (index: number) => { + setGridState((prevState) => { + if (!prevState) return null; - const fileToMd = (file: { path: string; repository: string }) => { - const href = `https://github.com/${file.repository}/blob/${file.path}`; - return `[${file.path}](${href})`; - }; + return { + ...prevState, + primaryColumn: prevState.primaryColumn.map((cell, i) => + i === index ? { ...cell, deleted: true } : cell + ), + }; + }); + }, + [setGridState] + ); - function generateMarkdownTable(gridState: GridState): string { + // Memoize markdown generation to avoid expensive recalculations + const generateMarkdownTable = useCallback((gridState: GridState): string => { const headers = ['Primary Column', ...gridState.columns.map((col) => col.title)]; function escapeMarkdown(text: string): string { return text.replace(/\|/g, '\\|').replace(/\n/g, '
'); } + const fileToMd = (file: { path: string; repository: string }) => { + const href = `https://github.com/${file.repository}/blob/${file.path}`; + return `[${file.path}](${href})`; + }; + function formatCell(response: ColumnResponse[keyof ColumnResponse]): string { if (!response) return ''; if (typeof response === 'string') { @@ -292,62 +309,89 @@ export const GridProvider = ({ createPrimaryColumn, hydrateCell, children }: Pro } } - const rows = gridState.primaryColumn.map((primaryCell, index) => { - return [ - formatCell(primaryCell.response), - ...gridState.columns.map((col) => { - const cell = col.cells[index]; - return formatCell(cell.response as ColumnResponse[keyof ColumnResponse]); - }), - ]; - }); + const rows = gridState.primaryColumn + .filter(cell => !cell.deleted) + .map((primaryCell, index) => { + return [ + formatCell(primaryCell.response), + ...gridState.columns.map((col) => { + const cell = col.cells[index]; + return formatCell(cell.response as ColumnResponse[keyof ColumnResponse]); + }), + ]; + }); const headerRow = `| ${headers.map(escapeMarkdown).join(' | ')} |`; const separatorRow = `| ${headers.map(() => '---').join(' | ')} |`; const dataRows = rows.map((row) => `| ${row.join(' | ')} |`); return [headerRow, separatorRow, ...dataRows].join('\n'); - } + }, []); - const deleteRow = useCallback( - (index: number) => { - setGridState((prevState) => { - if (!prevState) return null; + const saveGridAsGist = useCallback(async (): Promise => { + if (!gridState) { + console.warn("Can't save grid without grid state!"); + return null; + } - return { - ...prevState, - primaryColumn: prevState.primaryColumn.map((cell, i) => - i === index ? { ...cell, deleted: true } : cell - ), - }; - }); - }, - [setGridState] - ); + setIsSavingGist(true); + try { + const markdownTable = generateMarkdownTable(gridState); + const filename = `${gridState.title}.md`; + const gistUrl = await createGist(filename, markdownTable); + return gistUrl; + } catch (error) { + console.error('Failed to save grid as gist:', error); + return null; + } finally { + setIsSavingGist(false); + } + }, [gridState, generateMarkdownTable]); + + // Memoize state context value + const stateValue = useMemo(() => ({ + gridState, + selectedIndex, + currentGridId, + isSavingGist, + }), [gridState, selectedIndex, currentGridId, isSavingGist]); + + // Memoize actions context value + const actionsValue = useMemo(() => ({ + setGridState, + selectRow, + updateCellState, + addNewColumn, + inititializeGrid, + deleteColumnByIndex, + setGroupBy, + setFilterBy, + setCurrentGridId, + getAllGrids, + deleteGrid, + saveGridAsGist, + deleteRow, + }), [ + setGridState, + selectRow, + updateCellState, + addNewColumn, + inititializeGrid, + deleteColumnByIndex, + setGroupBy, + setFilterBy, + setCurrentGridId, + getAllGrids, + deleteGrid, + saveGridAsGist, + deleteRow, + ]); return ( - - {children} - + + + {children} + + ); -}; +}); diff --git a/app/components/GridHeader.tsx b/app/components/GridHeader.tsx index 22387fa..785d85c 100644 --- a/app/components/GridHeader.tsx +++ b/app/components/GridHeader.tsx @@ -1,29 +1,79 @@ +import React, { useCallback } from 'react'; import { TextInput, Text, ActionMenu, ActionList, Box, Button, CounterLabel } from '@primer/react'; -import { ArrowLeftIcon } from '@primer/octicons-react'; +import { ArrowLeftIcon, XIcon } from '@primer/octicons-react'; import { SearchIcon } from '@primer/octicons-react'; import { useGridContext } from './GridContext'; +import { useSearch } from '../hooks/useSearch'; import NextLink from 'next/link'; -export function Search() { +const Search = React.memo(function Search() { + const { gridState } = useGridContext(); + const { searchTerm, handleSearchChange, clearSearch, isSearching } = useSearch(gridState); + + const handleInputChange = useCallback((e: React.ChangeEvent) => { + handleSearchChange(e.target.value); + }, [handleSearchChange]); + return ( - }*/ - placeholder="Search..." - /> + + + {isSearching && ( + + + + )} + ); -} +}); -export function GroupBy() { +const GroupBy = React.memo(function GroupBy() { const { gridState, setGroupBy } = useGridContext(); + + const handleGroupBySelect = useCallback((columnTitle: string) => { + setGroupBy(columnTitle); + }, [setGroupBy]); + + const handleClearGroupBy = useCallback(() => { + setGroupBy(undefined); + }, [setGroupBy]); + if (!gridState) { return null; } + const { groupBy } = gridState; const groupableColumnTypes = ['select', 'select-user']; const groupableColumns = gridState.columns.filter((column) => groupableColumnTypes.includes(column.type) ); + if (gridState && groupableColumns.length === 0) { return null; } @@ -46,74 +96,115 @@ export function GroupBy() { {groupableColumns.map((column, index) => ( setGroupBy(column.title)} + key={`${column.title}-${index}`} + onSelect={() => handleGroupBySelect(column.title)} > {column.title} ))} - setGroupBy(undefined)}> - Ungrouped - + {groupBy && ( + + Clear grouping + + )}
); -} +}); + +const FilterBy = React.memo(function FilterBy() { + const { gridState, setFilterBy } = useGridContext(); + + const handleFilterBySelect = useCallback((columnTitle: string, filterValue: string) => { + setFilterBy(columnTitle, filterValue); + }, [setFilterBy]); + + const handleClearFilter = useCallback(() => { + setFilterBy(undefined, undefined); + }, [setFilterBy]); -export function FilterBy() { - const { gridState } = useGridContext(); if (!gridState) { return null; } - - const filterableColumnTypes = ['select', 'select-user']; + + const { filterByKey, filterByValue: _filterByValue } = gridState; + const filterableColumnTypes = ['select', 'select-user', 'text']; const filterableColumns = gridState.columns.filter((column) => filterableColumnTypes.includes(column.type) ); + if (gridState && filterableColumns.length === 0) { return null; } + return ( - Filter + + {filterByKey ? ( + <> + Filter by: +   + {filterByKey} + + ) : ( + Filter by + )} + - + {filterableColumns.map((column, index) => ( - alert(`Group by ${column.title}`)}> + handleFilterBySelect(column.title, '')} + > {column.title} ))} + {filterByKey && ( + + Clear filter + + )} ); -} +}); -type GridHeaderProps = { +export function GridHeader({ + title, + setShowNewColumnForm, + count, +}: { title: string; + setShowNewColumnForm: (value: boolean) => void; count: number; - setShowNewColumnForm: (b: boolean) => void; -}; -export function GridHeader({ title, setShowNewColumnForm, count }: GridHeaderProps) { +}) { const { saveGridAsGist, isSavingGist } = useGridContext(); - const handleSaveGist = async () => { - const gistUrl = await saveGridAsGist(); - if (gistUrl) { - window.open(gistUrl, '_blank'); - } - }; + const handleSaveGist = useCallback(async () => { + await saveGridAsGist(); + }, [saveGridAsGist]); + + const handleAddColumn = useCallback(() => { + setShowNewColumnForm(true); + }, [setShowNewColumnForm]); return ( Save to gist - diff --git a/app/components/GridLoading.tsx b/app/components/GridLoading.tsx new file mode 100644 index 0000000..eef4a33 --- /dev/null +++ b/app/components/GridLoading.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import { Box, Spinner } from '@primer/react'; + +const GridLoading = React.memo(function GridLoading() { + return ( + + + + Starting grid... + + + + + + + ); +}); + +export default GridLoading; \ No newline at end of file diff --git a/app/components/GridTable.tsx b/app/components/GridTable.tsx index f42691a..3496a30 100644 --- a/app/components/GridTable.tsx +++ b/app/components/GridTable.tsx @@ -12,9 +12,10 @@ import ColumnTitle from './ColumnTitle'; import { pluralize } from '../utils/pluralize'; import { capitalize } from '../utils/capitalize'; import type { GridState, PrimaryDataType } from '../actions'; -import Row from './Row'; +import React from 'react'; +import VirtualizedTable from './VirtualizedTable'; -function Panel({ children, sx = {} }: { children: React.ReactNode; sx?: any }) { +const Panel = React.memo(function Panel({ children, sx = {} }: { children: React.ReactNode; sx?: any }) { return ( ); -} +}); -function GroupHeader({ groupName, count }: { groupName: string; count: number }) { +const GroupHeader = React.memo(function GroupHeader({ groupName, count }: { groupName: string; count: number }) { return ( {count} ); -} +}); function useColumnDialog() { const [showNewColumnForm, setShowNewColumnForm] = useState(); @@ -75,9 +76,15 @@ function useGroupedRows(gridState: GridState | null) { } const { columns, primaryColumn, groupBy } = gridState; + + // Filter out deleted rows early + const visiblePrimaryRows = primaryColumn + .map((cell, index) => ({ cell, index })) + .filter(({ cell }) => !cell.deleted); + const defaultGroup = { groupName: '', - rows: primaryColumn.map((cell, index) => ({ cell, index })), + rows: visiblePrimaryRows, }; if (!groupBy) { @@ -91,7 +98,7 @@ function useGroupedRows(gridState: GridState | null) { const groups: { [key: string]: { cell: GridCell; index: number }[] } = {}; - primaryColumn.forEach((cell, index) => { + visiblePrimaryRows.forEach(({ cell, index }) => { const groupCell = groupColumn.cells[index]; let groupValues: string[] = []; let optionRes; @@ -126,7 +133,7 @@ function useGroupedRows(gridState: GridState | null) { }, [gridState]); } -function TableHeaderRow({ +const TableHeaderRow = React.memo(function TableHeaderRow({ columns, primaryColumnType, }: { @@ -149,43 +156,60 @@ function TableHeaderRow({ > {columns.map((column: GridCol, index: number) => ( - + ))} ); -} +}); -function TableContent() { +const TableContent = React.memo(function TableContent() { const { gridState, selectRow, selectedIndex } = useGridContext(); + const groupedRows = useGroupedRows(gridState); + if (!gridState) return null; - const { columns, primaryColumn } = gridState; + const { columns } = gridState; - // Filter out deleted rows - const visibleRows = primaryColumn - .map((cell, index) => ({ cell, index })) - .filter(({ cell }) => !cell.deleted); + // If we have groups, render them separately + if (groupedRows.length > 1 || (groupedRows.length === 1 && groupedRows[0].groupName)) { + return ( + + {groupedRows.map((group, groupIndex) => ( + + {group.groupName && ( + + )} + + + ))} + + ); + } + // For simple case without grouping, use virtualization directly + const allRows = groupedRows[0]?.rows || []; + return ( - - {visibleRows.map(({ cell, index }) => ( - - ))} - + ); -} +}); export default function GridTable() { const { showNewColumnForm, setShowNewColumnForm, onDialogClose } = useColumnDialog(); const { gridState, addNewColumn, selectedIndex } = useGridContext(); - const groupedRows = useGroupedRows(gridState); + if (!gridState) { return null; } @@ -205,34 +229,30 @@ export default function GridTable() { !cell.deleted).length} /> - + - {groupedRows.map((group, groupIndex) => ( - - {group.groupName && ( - - )} - - ))} - + + + diff --git a/app/components/Home.tsx b/app/components/Home.tsx index fb43596..2e1669c 100644 --- a/app/components/Home.tsx +++ b/app/components/Home.tsx @@ -5,50 +5,15 @@ import { useGridContext } from './GridContext'; import { useRouter } from 'next/navigation'; import NextLink from 'next/link'; import type { Grid } from './GridContext'; +import { shuffleArray } from '../utils/shuffle'; -const shuffleArray = (array: string[]) => { - for (let i = array.length - 1; i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)); - [array[i], array[j]] = [array[j], array[i]]; - } - return array; -}; - -function GridLoading() { - return ( - - - - Starting grid... - - - - - - - ); -} +const GRID_SUGGESTIONS = [ + 'Merged PRs in primer/design', + 'Open issues in primer/design', + 'The action list component in primer/react', + 'Closed PRs in vercel/swr', + 'Files from last merged PR in primer/react', +]; const SuggestionItem = ({ children, onClick }: { children: string; onClick: () => void }) => ( @@ -131,10 +95,10 @@ function ExistingGrids({ grids }: { grids: Grid[]; deleteGrid: (id: string) => v } export default function Home() { + const { inititializeGrid, getAllGrids, setCurrentGridId, deleteGrid } = useGridContext(); const [state, setState] = useState<'empty' | 'loading' | 'done'>('empty'); const [inputValue, setInputValue] = useState(''); const [errorMessage, setErrorMessage] = useState(''); - const { inititializeGrid, getAllGrids, setCurrentGridId, deleteGrid } = useGridContext(); const router = useRouter(); const existingGrids = getAllGrids(); @@ -164,20 +128,12 @@ export default function Home() { } } - const suggestions = [ - 'Merged PRs in primer/design', - 'Open issues in primer/design', - 'The action list component in primer/react', - 'Closed PRs in vercel/swr', - 'Files from last merged PR in primer/react', - ]; - const selectedSuggestions = useMemo(() => { - return shuffleArray([...suggestions]).slice(0, 3); - }, [suggestions]); + return shuffleArray([...GRID_SUGGESTIONS]).slice(0, 3); + }, []); if (state === 'loading') { - return ; + return ; } return ( diff --git a/app/components/NewColumnForm.tsx b/app/components/NewColumnForm.tsx index 697b1e6..26cb28b 100644 --- a/app/components/NewColumnForm.tsx +++ b/app/components/NewColumnForm.tsx @@ -1,135 +1,135 @@ -import React, { useState } from 'react'; -import { Box, Button, TextInput, Textarea, FormControl, Select, Checkbox } from '@primer/react'; -import { columnTypes } from '../columns'; -import type { Option, ColumnType } from '../actions'; - -type Props = { - addNewColumn: ({ - title, - instructions, - type, - options, - multiple, - }: { +import React, { useState, useCallback } from 'react'; +import { + Box, + Button, + FormControl, + Select, + TextInput, + Textarea, + Checkbox, + Text, +} from '@primer/react'; +import type { ColumnType, Option } from '../actions'; + +interface NewColumnFormProps { + addNewColumn: (data: { title: string; instructions: string; type: ColumnType; options: Option[]; - multiple: boolean; + multiple?: boolean; }) => void; - errorMessage?: string; -}; - -export default function NewColumnForm({ addNewColumn, errorMessage }: Props) { - const [title, setTitle] = useState(''); - const [instructions, setInstructions] = useState(''); - const [type, setType] = useState('text'); - const [options, setOptions] = useState([]); - const [multiple, setMultiple] = useState(false); - const [message, setMessage] = useState(errorMessage || ''); - - const selectedColumnType = columnTypes[type]; - - function handleTypeChange(e: React.ChangeEvent) { - const newType = e.target.value as ColumnType; - setType((currentType) => { - if ( - currentType === 'text' && - (newType === 'select' || newType === 'select-user' || newType === 'file') - ) { - setOptions([{ title: '', description: '' }]); - } else if ( - (currentType === 'select' || currentType === 'select-user' || currentType === 'file') && - newType === 'text' - ) { - setOptions([]); - } - return newType; - }); - } +} - function addNewHandler(e: React.FormEvent) { - e.preventDefault(); - setMessage(''); +const NewColumnForm = React.memo(function NewColumnForm({ addNewColumn }: NewColumnFormProps) { + const [title, setTitle] = useState(''); + const [instructions, setInstructions] = useState(''); + const [type, setType] = useState('boolean'); + const [options, setOptions] = useState(''); + const [multiple, setMultiple] = useState(false); - if (title === '') { - setMessage('Enter a title'); - return; - } + const handleSubmit = useCallback((e: React.FormEvent) => { + e.preventDefault(); + + const optionsList: Option[] = options + .split('\n') + .filter((line) => line.trim()) + .map((line) => ({ title: line.trim(), description: '' })); - const filteredOptions = options.filter((option) => option.title !== ''); addNewColumn({ title, instructions, type, - options: filteredOptions, + options: optionsList, multiple, }); + + // Reset form setTitle(''); setInstructions(''); - setType('text'); - setOptions([]); + setType('boolean'); + setOptions(''); setMultiple(false); - } + }, [addNewColumn, title, instructions, type, options, multiple]); + + const handleTitleChange = useCallback((e: React.ChangeEvent) => { + setTitle(e.target.value); + }, []); + + const handleInstructionsChange = useCallback((e: React.ChangeEvent) => { + setInstructions(e.target.value); + }, []); + + const handleTypeChange = useCallback((e: React.ChangeEvent) => { + setType(e.target.value as ColumnType); + }, []); + + const handleOptionsChange = useCallback((e: React.ChangeEvent) => { + setOptions(e.target.value); + }, []); + + const handleMultipleChange = useCallback((e: React.ChangeEvent) => { + setMultiple(e.target.checked); + }, []); + + const showOptions = type === 'select' || type === 'select-user'; return ( - - {message && {message}} - - - Title - setTitle(e.target.value)} block /> + + + Column Title + - - Type + + Instructions +