Skip to content

Commit

Permalink
Add ability to add columns/properties to the table
Browse files Browse the repository at this point in the history
- Refactored the logic within AddPropertyHeader to update the store when
the add button is clicked. No more debounce.
- Adjustments made the the people store and now using 'immer' to handle
mutations to the state in a safe manner.
- Moved selectable cells/header components to their own files.
  • Loading branch information
tomtitherington committed Nov 8, 2023
1 parent 9e6cab2 commit 1e3a939
Show file tree
Hide file tree
Showing 8 changed files with 111 additions and 76 deletions.
1 change: 1 addition & 0 deletions .dictionary/custom.txt
Original file line number Diff line number Diff line change
Expand Up @@ -88,3 +88,4 @@ Customise
popperjs
tanstack
upsert
immer
1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"babel-jest": "^29.4.3",
"formik": "^2.2.9",
"history": "^5.3.0",
"immer": "^10.0.3",
"lodash": "^4.17.21",
"nanoid": "^5.0.2",
"openapi-typescript": "^6.1.0",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { Row } from '@tanstack/react-table';
import { IndeterminateCheckbox } from 'components';

const SelectableHeader = <TData extends object | unknown>({ row }: { row: Row<TData> }) => {
return (
<div className="flex items-center justify-center p-2 border-r border-crumpet-light-200">
<IndeterminateCheckbox
{...{
checked: row.getIsSelected(),
disabled: !row.getCanSelect(),
indeterminate: row.getIsSomeSelected(),
onChange: row.getToggleSelectedHandler(),
}}
/>
</div>
);
};

export default SelectableHeader;
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { Popover, Transition } from '@headlessui/react';
import { SlimTextInput } from 'components/inputs';
import { usePeopleStore, PropertyDefinition } from 'features/people/stores/usePeopleStore';
import { Fragment, useCallback, useEffect, useRef, useState } from 'react';
import { usePeopleStore } from 'features/people/stores/usePeopleStore';
import { Fragment, useCallback, useState } from 'react';
import { MdAdd } from 'react-icons/md';
import { usePopper } from 'react-popper';
import _ from 'lodash';
import { nanoid } from 'nanoid';
import { MainButton } from 'components/buttons';

const AddPropertyHeader = () => {
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
Expand All @@ -15,30 +15,13 @@ const AddPropertyHeader = () => {
});

const [id] = useState(() => nanoid()); // Initialize with a new GUID
const [property, setProperty] = useState<Partial<Omit<PropertyDefinition, 'id'>>>({});
const [displayName, setDisplayName] = useState('');
const [identifier, setIdentifier] = useState('');

const debounce = useCallback(
// Delay saving state until user activity stops
_.debounce((definition: Partial<Omit<PropertyDefinition, 'id'>>) => {
usePeopleStore.getState().upsertDefinition(id, definition);
// API Calls go here
const val = usePeopleStore.getState().propertyDefinitions;
console.log(val);
}, 750), // Delay (ms)
[usePeopleStore.getState().propertyDefinitions],
);

const handleChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = event.target;
setProperty(prev => {
const updatedProperty = { ...prev, [name]: value };
debounce(updatedProperty);
return updatedProperty;
});
},
[debounce], // Only recreate this function if debounceSave changes
);
//TODO: Do I need to use useCallback here?
const handleAddColumn = useCallback(() => {
usePeopleStore.getState().upsertDefinition(id, { header: displayName, accessor: identifier });
}, [usePeopleStore.getState().propertyDefinitions, id, displayName, identifier]);

return (
<Popover>
Expand All @@ -62,19 +45,27 @@ const AddPropertyHeader = () => {
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
className="w-64 overflow-hidden p-4 bg-white rounded-md shadow-lg ring-1 ring-black ring-opacity-5">
className="flex flex-col w-64 overflow-hidden p-4 gap-2 bg-white rounded-md shadow-lg ring-1 ring-black
ring-opacity-5">
<SlimTextInput
label="Display Name"
placeholder="Property name"
onChange={event => handleChange(event)}
onChange={e => setDisplayName(e.target.value)}
inputProps={{ name: 'header' }}
/>
<SlimTextInput
label="Identifier"
placeholder="property_name"
onChange={event => handleChange(event)}
onChange={e => setIdentifier(e.target.value)}
inputProps={{ name: 'accessor' }}
/>
<div className="w-full h-[1px] bg-crumpet-light-200"></div>
<MainButton
className="justify-center"
label="Add property"
icon={<MdAdd />}
onClick={handleAddColumn}
/>
</Popover.Panel>
</Transition>
</Popover.Panel>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { Table } from '@tanstack/react-table';
import { IndeterminateCheckbox } from 'components';

const SelectableHeader = <TData extends object | unknown>({ table }: { table: Table<TData> }) => {
return (
<div className="flex items-center justify-center p-2 border-r border-crumpet-light-200">
<IndeterminateCheckbox
{...{
checked: table.getIsAllRowsSelected(),
indeterminate: table.getIsSomeRowsSelected(),
onChange: table.getToggleAllRowsSelectedHandler(),
}}
/>
</div>
);
};

export default SelectableHeader;
62 changes: 26 additions & 36 deletions frontend/src/features/people/components/table/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,58 +11,48 @@ import { MdAdd } from 'react-icons/md';
import EditableCell from './cells/EditableCell';
import { IndeterminateCheckbox } from 'components';
import AddPropertyHeader from './headers/AddPropertyHeader';
import { usePeopleStore } from 'features/people/stores/usePeopleStore';
import SelectableHeader from './headers/SelectableHeader';
import SelectableCell from './cells/SelectableCell';

const Table = ({ data, columnJson }: { data: any[]; columnJson: any[] }) => {
// Convert the structure to column definitions for TanStack Table
const createColumns = (columnStructure: any[]): ColumnDef<unknown>[] => {
interface TableProps {
data: any[];
columnJson: any[];
className?: string;
}

const Table = ({ data, columnJson, className }: TableProps) => {
const propertyDefs = usePeopleStore(state => state.propertyDefinitions);

const columns = useMemo(() => {
const columnHelper = createColumnHelper<unknown>();
const userColumns = columnStructure.map(column => {
return columnHelper.accessor(column.accessor, {
header: ({ header }) => <PropertyHeader header={header} value={column.header} />,

const cols = Object.entries(propertyDefs).map(([key, value]) => {
return columnHelper.accessor(value.accessor, {
header: ({ header }) => <PropertyHeader header={header} value={value.header} />,
cell: info => <EditableCell cell={info.cell} value={info.getValue()} />,
size: column.size,
size: 180,
});
});

return [
{
id: 'select',
header: ({ table }) => (
<div className="flex items-center justify-center p-2 border-r border-crumpet-light-200">
<IndeterminateCheckbox
{...{
checked: table.getIsAllRowsSelected(),
indeterminate: table.getIsSomeRowsSelected(),
onChange: table.getToggleAllRowsSelectedHandler(),
}}
/>
</div>
),
cell: ({ row }) => (
<div className="flex items-center justify-center p-2 border-r border-crumpet-light-200">
<IndeterminateCheckbox
{...{
checked: row.getIsSelected(),
disabled: !row.getCanSelect(),
indeterminate: row.getIsSomeSelected(),
onChange: row.getToggleSelectedHandler(),
}}
/>
</div>
),
header: ({ table }) => <SelectableHeader table={table} />,
cell: ({ row }) => <SelectableCell row={row} />,
size: 48,
maxSize: 48,
minSize: 48,
},
...userColumns,
...cols,
{
id: 'add_property',
header: ({table}) => <AddPropertyHeader />,
header: ({ table }) => <AddPropertyHeader />,
cell: ({ row }) => <div></div>,
},
];
};
] as ColumnDef<unknown>[];
}, [propertyDefs]);

const columns = useMemo(() => createColumns(columnJson), [columnJson]);
const [rowSelection, setRowSelection] = useState({});

const tableInstance = useReactTable({
Expand All @@ -78,7 +68,7 @@ const Table = ({ data, columnJson }: { data: any[]; columnJson: any[] }) => {
});

return (
<div className="relative overflow-x-auto">
<div className={`relative overflow-x-auto ${className}`}>
<div className="thead border-b border-t border-crumpet-light-200">
{tableInstance.getHeaderGroups().map(headerGroup => (
<div className="tr flex" key={headerGroup.id}>
Expand Down
32 changes: 21 additions & 11 deletions frontend/src/features/people/stores/usePeopleStore.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import { produce } from 'immer';
import { create } from 'zustand';

export interface PropertyDefinition {
id: string;
accessor?: string;
header?: string;
accessor: string;
header: string;
type?: string;
}

interface State {
propertyDefinitions: Record<string, PropertyDefinition>;
propertyDefinitions: Record<string, Omit<PropertyDefinition, 'id'>>;
}

//TODO: Maybe rename to upsertProperty ?
Expand All @@ -17,12 +18,21 @@ interface Actions {
}

export const usePeopleStore = create<State & Actions>(set => ({
propertyDefinitions: {},
upsertDefinition: (id, definition) =>
set(state => ({
propertyDefinitions: {
...state.propertyDefinitions,
[id]: { ...definition, id }, // Merge with the existing data
},
})),
propertyDefinitions: {
id_1: { accessor: 'name', header: 'Name' },
id_2: { accessor: 'age', header: 'Age' },
},
upsertDefinition: (id, newDefinition) =>
set(
produce<State>(draft => {
if (draft.propertyDefinitions[id]) {
// If the definition exists, merge it
Object.assign(draft.propertyDefinitions[id], newDefinition);
} else {
// If the definition does not exist, create a new one
// (you should validate the full object structure here)
draft.propertyDefinitions[id] = newDefinition as Omit<PropertyDefinition, 'id'>;
}
}),
),
}));
5 changes: 5 additions & 0 deletions frontend/yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -3463,6 +3463,11 @@ ignore@^5.2.0:
resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.2.4.tgz#a291c0c6178ff1b960befe47fcdec301674a6324"
integrity sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==

immer@^10.0.3:
version "10.0.3"
resolved "https://registry.yarnpkg.com/immer/-/immer-10.0.3.tgz#a8de42065e964aa3edf6afc282dfc7f7f34ae3c9"
integrity sha512-pwupu3eWfouuaowscykeckFmVTpqbzW+rXFCX8rQLkZzM9ftBmU/++Ra+o+L27mz03zJTlyV4UUr+fdKNffo4A==

import-fresh@^3.0.0, import-fresh@^3.2.1:
version "3.3.0"
resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.0.tgz#37162c25fcb9ebaa2e6e53d5b4d88ce17d9e0c2b"
Expand Down

0 comments on commit 1e3a939

Please sign in to comment.