From dddb57a6abd15f783eb184dff1086fcb2c5d316b Mon Sep 17 00:00:00 2001 From: Ajai John Chemmanam Date: Sat, 29 May 2021 15:13:03 +0530 Subject: [PATCH] Added basic table with sort, crud abilities --- .eslintrc.js | 2 + package.json | 4 + src/components/Icons/Icons.jsx | 174 ++++++++++ src/components/MainScreen.tsx | 15 +- src/components/Table/Cell.jsx | 227 +++++++++++++ src/components/Table/Header.jsx | 372 +++++++++++++++++++++ src/components/Table/Table.tsx | 133 ++++++++ src/components/TableScreen/TableScreen.tsx | 326 ++++++++++++++++++ src/components/utils/ui_utils.js | 7 + tailwind.config.js | 6 +- 10 files changed, 1262 insertions(+), 4 deletions(-) create mode 100644 src/components/Icons/Icons.jsx create mode 100644 src/components/Table/Cell.jsx create mode 100644 src/components/Table/Header.jsx create mode 100644 src/components/Table/Table.tsx create mode 100644 src/components/TableScreen/TableScreen.tsx create mode 100644 src/components/utils/ui_utils.js diff --git a/.eslintrc.js b/.eslintrc.js index fee6d71..53a9685 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -3,6 +3,8 @@ module.exports = { rules: { // A temporary hack related to IDE not resolving correct package.json 'import/no-extraneous-dependencies': 'off', + 'react/jsx-props-no-spreading': 'off', + 'react/prop-types': 'off', }, parserOptions: { ecmaVersion: 2020, diff --git a/package.json b/package.json index bdbc00a..cb5c328 100644 --- a/package.json +++ b/package.json @@ -173,6 +173,7 @@ "@babel/preset-typescript": "^7.12.7", "@babel/register": "^7.12.1", "@pmmmwh/react-refresh-webpack-plugin": "^0.4.3", + "@popperjs/core": "^2.9.2", "@teamsupercell/typings-for-css-modules-loader": "^2.4.0", "@testing-library/jest-dom": "^5.11.10", "@testing-library/react": "^11.2.7", @@ -238,7 +239,10 @@ "postcss": "^8.3.0", "postcss-loader": "^5.3.0", "prettier": "^2.3.0", + "react-contenteditable": "^3.3.5", + "react-popper": "^2.2.5", "react-refresh": "^0.9.0", + "react-table": "^7.7.0", "react-test-renderer": "^17.0.1", "rimraf": "^3.0.0", "sass-loader": "^11.1.1", diff --git a/src/components/Icons/Icons.jsx b/src/components/Icons/Icons.jsx new file mode 100644 index 0000000..ab3f4d9 --- /dev/null +++ b/src/components/Icons/Icons.jsx @@ -0,0 +1,174 @@ +import React from 'react'; + +export default function getIcons(icon) { + switch (icon) { + case 'plus': + return ( + + + + + + ); + + case 'arrowdown': + return ( + + + + + + + ); + + case 'arrowup': + return ( + + + + + + + ); + case 'arrowleft': + return ( + + + + + + + ); + case 'arrowright': + return ( + + + + + + + ); + case 'hash': + return ( + + + + + + + + ); + case 'multi': + return ( + + + + + + ); + case 'text': + return ( + + + + + + + ); + case 'trash': + return ( + + + + + + + + + ); + default: + return
noIcon
; + } +} diff --git a/src/components/MainScreen.tsx b/src/components/MainScreen.tsx index 929fa27..3986969 100644 --- a/src/components/MainScreen.tsx +++ b/src/components/MainScreen.tsx @@ -1,6 +1,7 @@ import React, { useState } from 'react'; import DbSession from '../sessions/DbSession'; import SqlExecuter from './SqlExecuter'; +import TableScreen from './TableScreen/TableScreen'; interface MainScreenProps { session: DbSession; @@ -23,22 +24,30 @@ const MainScreen: React.FC = ({ +
{selectedTab === 'SQL' && } - {selectedTab === 'TABLE' &&
Test
} + {selectedTab === 'TABLE' && } + {selectedTab === 'TEST' &&
Test
}
); diff --git a/src/components/Table/Cell.jsx b/src/components/Table/Cell.jsx new file mode 100644 index 0000000..8d3b003 --- /dev/null +++ b/src/components/Table/Cell.jsx @@ -0,0 +1,227 @@ +import PropTypes from 'prop-types'; +import React, { useEffect, useState } from 'react'; +import ContentEditable from 'react-contenteditable'; +import { usePopper } from 'react-popper'; +import getIcon from '../Icons/Icons'; +import { randomColor, shortId } from '../utils/ui_utils'; + +function Relationship({ value, backgroundColor }) { + return ( + + {value} + + ); +} + +Relationship.propTypes = { + backgroundColor: PropTypes.string.isRequired, + value: PropTypes.element.isRequired, +}; + +export default function Cell({ + value: initialValue, + row: { index }, + column: { id, dataType, options }, + dataDispatch, +}) { + const [value, setValue] = useState({ value: initialValue, update: false }); + const [selectRef, setSelectRef] = useState(null); + const [selectPop, setSelectPop] = useState(null); + const [showSelect, setShowSelect] = useState(false); + const onChange = (e) => { + setValue({ value: e.target.value, update: false }); + }; + const [showAdd, setShowAdd] = useState(false); + const [addSelectRef, setAddSelectRef] = useState(null); + + useEffect(() => { + setValue({ value: initialValue, update: false }); + }, [initialValue]); + + useEffect(() => { + if (value.update) { + dataDispatch({ + type: 'update_cell', + columnId: id, + rowIndex: index, + value: value.value, + }); + } + }, [value, dataDispatch, id, index]); + + function handleOptionKeyDown(e) { + if (e.key === 'Enter') { + if (e.target.value !== '') { + dataDispatch({ + type: 'add_option_to_column', + option: e.target.value, + backgroundColor: randomColor(), + columnId: id, + }); + } + setShowAdd(false); + } + } + + function handleAddOption() { + setShowAdd(true); + } + + function handleOptionBlur(e) { + if (e.target.value !== '') { + dataDispatch({ + type: 'add_option_to_column', + option: e.target.value, + backgroundColor: randomColor(), + columnId: id, + }); + } + setShowAdd(false); + } + + const { styles, attributes } = usePopper(selectRef, selectPop, { + placement: 'bottom-start', + strategy: 'fixed', + }); + + function getColor() { + const match = options.find((option) => option.label === value.value); + return (match && match.backgroundColor) || '#e0e0e0'; + } + + useEffect(() => { + if (addSelectRef && showAdd) { + addSelectRef.focus(); + } + }, [addSelectRef, showAdd]); + + let element; + switch (dataType) { + case 'text': + element = ( + setValue((old) => ({ value: old.value, update: true }))} + className="whitespace-pre-wrap border-0 p-2 text-gray-600 text-base rounded resize-none box-border flex-auto focus:outline-none focus:ring-2 focus:ring-blue-400" + /> + ); + break; + case 'number': + element = ( + setValue((old) => ({ value: old.value, update: true }))} + className="whitespace-pre-wrap border-0 p-2 text-gray-600 text-base rounded resize-none box-border flex-auto focus:outline-none focus:ring-2 focus:ring-blue-400 text-right" + /> + ); + break; + case 'select': + element = ( + <> +
setShowSelect(true)} + onKeyPress={() => setShowSelect(true)} + > + {value.value && ( + + )} +
+ {showSelect && ( +
setShowSelect(false)} + onKeyPress={() => setShowSelect(false)} + /> + )} + {showSelect && ( +
+
+ {options.map((option) => ( +
{ + setValue({ value: option.label, update: true }); + setShowSelect(false); + }} + onKeyPress={() => { + setValue({ value: option.label, update: true }); + setShowSelect(false); + }} + > + +
+ ))} + {showAdd && ( +
+ +
+ )} +
+ + {getIcon('plus')} + + } + backgroundColor="#eeeeee" + /> +
+
+
+ )} + + ); + break; + default: + element = ; + break; + } + + return element; +} diff --git a/src/components/Table/Header.jsx b/src/components/Table/Header.jsx new file mode 100644 index 0000000..19f4932 --- /dev/null +++ b/src/components/Table/Header.jsx @@ -0,0 +1,372 @@ +import React, { useState, useEffect } from 'react'; +import { usePopper } from 'react-popper'; +import getIcon from '../Icons/Icons'; +import { shortId } from '../utils/ui_utils'; + +export default function Header({ + column: { id, created, label, dataType, getResizerProps, getHeaderProps }, + setSortBy, + dataDispatch, +}) { + const [expanded, setExpanded] = useState(created || false); + const [referenceElement, setReferenceElement] = useState(null); + const [popperElement, setPopperElement] = useState(null); + const [inputRef, setInputRef] = useState(null); + const { styles, attributes } = usePopper(referenceElement, popperElement, { + placement: 'bottom', + strategy: 'absolute', + }); + const [header, setHeader] = useState(label); + const [typeReferenceElement, setTypeReferenceElement] = useState(null); + const [typePopperElement, setTypePopperElement] = useState(null); + const [showType, setShowType] = useState(false); + const buttons = [ + { + onClick: () => { + dataDispatch({ + type: 'update_column_header', + columnId: id, + label: header, + }); + setSortBy([{ id, desc: false }]); + setExpanded(false); + }, + icon: getIcon('arrowup'), + label: 'Sort ascending', + }, + { + onClick: () => { + dataDispatch({ + type: 'update_column_header', + columnId: id, + label: header, + }); + setSortBy([{ id, desc: true }]); + setExpanded(false); + }, + icon: getIcon('arrowdown'), + label: 'Sort descending', + }, + { + onClick: () => { + dataDispatch({ + type: 'update_column_header', + columnId: id, + label: header, + }); + dataDispatch({ + type: 'add_column_to_left', + columnId: id, + focus: false, + }); + setExpanded(false); + }, + icon: getIcon('arrowleft'), + label: 'Insert left', + }, + { + onClick: () => { + dataDispatch({ + type: 'update_column_header', + columnId: id, + label: header, + }); + dataDispatch({ + type: 'add_column_to_right', + columnId: id, + focus: false, + }); + setExpanded(false); + }, + icon: getIcon('arrowright'), + label: 'Insert right', + }, + { + onClick: () => { + dataDispatch({ + type: 'update_column_header', + columnId: id, + label: header, + }); + dataDispatch({ type: 'delete_column', columnId: id }); + setExpanded(false); + }, + icon: getIcon('trash'), + label: 'Delete', + }, + ]; + + const types = [ + { + onClick: () => { + dataDispatch({ + type: 'update_column_type', + columnId: id, + dataType: 'select', + }); + setShowType(false); + setExpanded(false); + }, + icon: getIcon('multi'), + label: 'Select', + }, + { + onClick: () => { + dataDispatch({ + type: 'update_column_type', + columnId: id, + dataType: 'text', + }); + setShowType(false); + setExpanded(false); + }, + icon: getIcon('text'), + label: 'Text', + }, + { + onClick: () => { + dataDispatch({ + type: 'update_column_type', + columnId: id, + dataType: 'number', + }); + setShowType(false); + setExpanded(false); + }, + icon: getIcon('hash'), + label: 'Number', + }, + ]; + + let propertyIcon; + switch (dataType) { + case 'number': + propertyIcon = getIcon('hash'); + break; + case 'text': + propertyIcon = getIcon('text'); + break; + case 'select': + propertyIcon = getIcon('multi'); + break; + default: + break; + } + + useEffect(() => { + if (created) { + setExpanded(true); + } + }, [created]); + + useEffect(() => { + setHeader(label); + }, [label]); + + useEffect(() => { + if (inputRef) { + inputRef.focus(); + inputRef.select(); + } + }, [inputRef]); + + const typePopper = usePopper(typeReferenceElement, typePopperElement, { + placement: 'right', + strategy: 'fixed', + }); + + function handleKeyDown(e) { + if (e.key === 'Enter') { + dataDispatch({ + type: 'update_column_header', + columnId: id, + label: header, + }); + setExpanded(false); + } + } + + function handleChange(e) { + setHeader(e.target.value); + } + + function handleBlur(e) { + e.preventDefault(); + dataDispatch({ type: 'update_column_header', columnId: id, label: header }); + } + + return id !== 999999 ? ( + <> +
+
setExpanded(true)} + onKeyPress={() => setExpanded(true)} + ref={setReferenceElement} + > + + {propertyIcon}{' '} + + {label} +
+
+
+ {expanded && ( +
setExpanded(false)} + onKeyPress={() => setExpanded(false)} + /> + )} + {expanded && ( + // Menu +
+ {/* Menu Background */} +
+ {/* Column Name Input */} +
+
+ +
+ {/* Property Type - Icon and Label */} + + Data Type + +
+ {/* Property Type Select Menu */} +
+ + {showType && ( +
setShowType(true)} + onMouseLeave={() => setShowType(false)} + {...typePopper.attributes.popper} + style={{ + ...typePopper.styles.popper, + width: 200, + zIndex: 4, + padding: '4px 0px', + }} + > + {types.map((type) => ( + + ))} +
+ )} +
+ {/* Sort/Add/Delete */} +
+
+ + Functions + +
+ + {buttons.map((button) => ( + + ))} +
+
+
+ )} + + ) : ( + // Add Column +
+
+ dataDispatch({ + type: 'add_column_to_left', + columnId: 999999, + focus: true, + }) + } + onKeyPress={() => + dataDispatch({ + type: 'add_column_to_left', + columnId: 999999, + focus: true, + }) + } + > + {/* Column Plus Icon */} + {getIcon('plus')} +
+
+ ); +} diff --git a/src/components/Table/Table.tsx b/src/components/Table/Table.tsx new file mode 100644 index 0000000..8b748ad --- /dev/null +++ b/src/components/Table/Table.tsx @@ -0,0 +1,133 @@ +import React, { useMemo } from 'react'; +import { + useTable, + useFlexLayout, + useResizeColumns, + useSortBy, +} from 'react-table'; +import Cell from './Cell'; +import Header from './Header'; +import getIcon from '../Icons/Icons'; +import { shortId } from '../utils/ui_utils'; + +const defaultColumn = { + minWidth: 50, + width: 150, + maxWidth: 400, + Cell, + Header, + sortType: 'alphanumericFalsyLast', +}; + +export default function Table({ + columns, + data, + dispatch: dataDispatch, + skipReset, +}) { + const sortTypes = useMemo( + () => ({ + alphanumericFalsyLast(rowA, rowB, columnId, desc) { + if (!rowA.values[columnId] && !rowB.values[columnId]) { + return 0; + } + + if (!rowA.values[columnId]) { + return desc ? -1 : 1; + } + + if (!rowB.values[columnId]) { + return desc ? 1 : -1; + } + + return Number.isNaN(rowA.values[columnId]) + ? rowA.values[columnId].localeCompare(rowB.values[columnId]) + : rowA.values[columnId] - rowB.values[columnId]; + }, + }), + [] + ); + + const { getTableProps, getTableBodyProps, headerGroups, rows, prepareRow } = + useTable( + { + columns, + data, + defaultColumn, + dataDispatch, + autoResetSortBy: !skipReset, + autoResetFilters: !skipReset, + autoResetRowState: !skipReset, + sortTypes, + }, + useFlexLayout, + useResizeColumns, + useSortBy + ); + + // function isTableResizing() { + // for (const headerGroup of headerGroups) { + // for (const column of headerGroup.headers) { + // if (column.isResizing) { + // return true; + // } + // } + // } + + // return false; + // } + + return ( + <> +
+
+ {headerGroups.map((headerGroup) => ( +
+ {headerGroup.headers.map((column) => column.render('Header'))} +
+ ))} +
+
+ {rows.map((row) => { + prepareRow(row); + return ( +
+ {row.cells.map((cell) => ( +
+ {cell.render('Cell')} +
+ ))} +
+ ); + })} +
dataDispatch({ type: 'add_row' })} + onKeyPress={() => dataDispatch({ type: 'add_row' })} + > + + {getIcon('plus')} + + New +
+
+
+ + ); +} diff --git a/src/components/TableScreen/TableScreen.tsx b/src/components/TableScreen/TableScreen.tsx new file mode 100644 index 0000000..22f8287 --- /dev/null +++ b/src/components/TableScreen/TableScreen.tsx @@ -0,0 +1,326 @@ +import React, { useEffect, useReducer } from 'react'; +import Table from '../Table/Table'; +import { shortId, randomColor } from '../utils/ui_utils'; + +function fakeData() { + const data = []; + const options = []; + for (let i = 0; i < 10; i += 1) { + const row = { + ID: 1, + firstName: 'temp_firstname', + lastName: 'temp_lastname', + email: 'temp_email@example.com', + age: 1 + i * i + i, + district: 'example_address', + }; + options.push({ label: row.district, backgroundColor: randomColor() }); + + data.push(row); + } + + const columns = [ + { + id: 'firstName', + label: 'First Name', + accessor: 'firstName', + minWidth: 100, + dataType: 'text', + options: [], + }, + { + id: 'lastName', + label: 'Last Name', + accessor: 'lastName', + minWidth: 100, + dataType: 'text', + options: [], + }, + { + id: 'age', + label: 'Age', + accessor: 'age', + width: 80, + dataType: 'number', + options: [], + }, + { + id: 'email', + label: 'E-Mail', + accessor: 'email', + width: 300, + dataType: 'text', + options: [], + }, + { + id: 'district', + label: 'District', + accessor: 'district', + dataType: 'select', + width: 200, + options, + }, + { + id: 999999, + width: 20, + label: '+', + disableResizing: true, + dataType: 'null', + }, + ]; + + return { columns, data, skipReset: false }; +} + +function reducer(state, action) { + switch (action.type) { + case 'add_option_to_column': { + const optionIndex = state.columns.findIndex( + (column) => column.id === action.columnId + ); + return { + ...state, + skipReset: true, + columns: [ + ...state.columns.slice(0, optionIndex), + { + ...state.columns[optionIndex], + options: [ + ...state.columns[optionIndex].options, + { label: action.option, backgroundColor: action.backgroundColor }, + ], + }, + ...state.columns.slice(optionIndex + 1, state.columns.length), + ], + }; + } + case 'add_row': + return { + ...state, + skipReset: true, + data: [...state.data, {}], + }; + case 'update_column_type': { + const typeIndex = state.columns.findIndex( + (column) => column.id === action.columnId + ); + switch (action.dataType) { + case 'number': + if (state.columns[typeIndex].dataType === 'number') { + return state; + } + return { + ...state, + columns: [ + ...state.columns.slice(0, typeIndex), + { ...state.columns[typeIndex], dataType: action.dataType }, + ...state.columns.slice(typeIndex + 1, state.columns.length), + ], + data: state.data.map((row) => ({ + ...row, + [action.columnId]: Number.isNaN(row[action.columnId]) + ? '' + : Number.parseInt(row[action.columnId], 10), + })), + }; + + case 'select': { + if (state.columns[typeIndex].dataType === 'select') { + return { + ...state, + columns: [ + ...state.columns.slice(0, typeIndex), + { ...state.columns[typeIndex], dataType: action.dataType }, + ...state.columns.slice(typeIndex + 1, state.columns.length), + ], + skipReset: true, + }; + } + const options = []; + state.data.forEach((row) => { + if (row[action.columnId]) { + options.push({ + label: row[action.columnId], + backgroundColor: randomColor(), + }); + } + }); + return { + ...state, + columns: [ + ...state.columns.slice(0, typeIndex), + { + ...state.columns[typeIndex], + dataType: action.dataType, + options: [...state.columns[typeIndex].options, ...options], + }, + ...state.columns.slice(typeIndex + 1, state.columns.length), + ], + skipReset: true, + }; + } + case 'text': + if (state.columns[typeIndex].dataType === 'text') { + return state; + } + if (state.columns[typeIndex].dataType === 'select') { + return { + ...state, + skipReset: true, + columns: [ + ...state.columns.slice(0, typeIndex), + { ...state.columns[typeIndex], dataType: action.dataType }, + ...state.columns.slice(typeIndex + 1, state.columns.length), + ], + }; + } + return { + ...state, + skipReset: true, + columns: [ + ...state.columns.slice(0, typeIndex), + { ...state.columns[typeIndex], dataType: action.dataType }, + ...state.columns.slice(typeIndex + 1, state.columns.length), + ], + data: state.data.map((row) => ({ + ...row, + [action.columnId]: `${row[action.columnId]}`, + })), + }; + + default: + return state; + } + } + case 'update_column_header': { + const index = state.columns.findIndex( + (column) => column.id === action.columnId + ); + return { + ...state, + skipReset: true, + columns: [ + ...state.columns.slice(0, index), + { ...state.columns[index], label: action.label }, + ...state.columns.slice(index + 1, state.columns.length), + ], + }; + } + case 'update_cell': + return { + ...state, + skipReset: true, + data: state.data.map((row, ridx) => { + if (ridx === action.rowIndex) { + return { + ...state.data[action.rowIndex], + [action.columnId]: action.value, + }; + } + return row; + }), + }; + case 'add_column_to_left': { + const leftIndex = state.columns.findIndex( + (column) => column.id === action.columnId + ); + const leftId = shortId(); + return { + ...state, + skipReset: true, + columns: [ + ...state.columns.slice(0, leftIndex), + { + id: leftId, + label: 'Column', + accessor: leftId, + dataType: 'text', + created: action.focus && true, + options: [], + }, + ...state.columns.slice(leftIndex, state.columns.length), + ], + }; + } + case 'add_column_to_right': { + const rightIndex = state.columns.findIndex( + (column) => column.id === action.columnId + ); + const rightId = shortId(); + return { + ...state, + skipReset: true, + columns: [ + ...state.columns.slice(0, rightIndex + 1), + { + id: rightId, + label: 'Column', + accessor: rightId, + dataType: 'text', + created: action.focus && true, + options: [], + }, + ...state.columns.slice(rightIndex + 1, state.columns.length), + ], + }; + } + case 'delete_column': { + const deleteIndex = state.columns.findIndex( + (column) => column.id === action.columnId + ); + return { + ...state, + skipReset: true, + columns: [ + ...state.columns.slice(0, deleteIndex), + ...state.columns.slice(deleteIndex + 1, state.columns.length), + ], + }; + } + case 'enable_reset': + return { + ...state, + skipReset: false, + }; + default: + return state; + } +} + +function TableScreen() { + const [state, dispatch] = useReducer(reducer, fakeData()); + + useEffect(() => { + dispatch({ type: 'enable_reset' }); + }, [state.data, state.columns]); + + return ( +
+
+

Table View

+
+
+
+ + + + + ); +} + +export default TableScreen; diff --git a/src/components/utils/ui_utils.js b/src/components/utils/ui_utils.js new file mode 100644 index 0000000..0777d3c --- /dev/null +++ b/src/components/utils/ui_utils.js @@ -0,0 +1,7 @@ +export function randomColor() { + return `hsl(${Math.floor(Math.random() * 360)}, 95%, 90%)`; +} + +export function shortId() { + return `_ ${Math.random().toString(36).substr(2, 9)}`; +} diff --git a/tailwind.config.js b/tailwind.config.js index 8331ec1..239d2ee 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -1,5 +1,9 @@ module.exports = { theme: {}, - variants: {}, + variants: { + extend: { + backgroundColor: ['even'], + }, + }, plugins: [], };