From 7b53fd9cdae31dd6ad998e1bf705c63f6472bd73 Mon Sep 17 00:00:00 2001 From: Jbee Date: Wed, 2 Feb 2022 18:04:49 +0900 Subject: [PATCH] feat(@h6s/table): Initial commit (#165) * feat(@h6s/table): Initial commit * fix(@h6s/table): Rename tabler to table * fix(@h6s/table): Extract mock dataset * fix(@h6s/table): Fix build error * chore(@h6s/table): Add scope to test script * fix(@h6s/table): Remove useless return value - headerTree * fix(@h6s/table): Fix bug in buildFooters Sub last colSpan in colSpanQueue when exist tail footer * chore(@h6s/table): Supply mock dataset * chore(@h6s/table): Arrange storybook code * feat(@h6s/table): Expose headerIds * chore(@h6s/table): Add controls to stories * feat(@h6s/table): Add `extendsFooter` rules * fix(@h6s/table): Arrange code * chore(@h6s/table): Add buildFooters test * chore(@h6s/table): Add TableCore test code * chore(@h6s/table): Add unstable_rendererModel for testing * chore(@h6s/table): Add composeDataset use case * fix: replace new foramt * fix: remove unstable word * fix: replace to new interface (2) * fix: rebranding to TableModel * fix: footers -> tfoots * fix: rebranding to thead * fix: rebranding to headMeta * fix: Header to Head * fix: change RowData generic type to Row * fix: add type to get function * fix: use TableCore.compose * chore: update gitignore * chore: supply test code for coverage * fix: render interface * fix: header to head * fix: footer to foot --- .gitignore | 1 + .pnp.cjs | 24 + packages/table/README.md | 1 + packages/table/package.json | 48 ++ packages/table/src/core/TableCore.test.ts | 526 ++++++++++++++++++ packages/table/src/core/TableCore.ts | 110 ++++ .../src/core/renderer/buildRendererModel.ts | 23 + .../src/core/renderer/flattenRendererModel.ts | 20 + .../src/core/renderer/getChildrenCount.ts | 11 + .../src/core/renderer/getHeaderAccessorId.ts | 7 + .../src/core/renderer/getLargestDepth.ts | 7 + .../table/src/core/row/CellSpanManager.ts | 71 +++ packages/table/src/core/row/buildCells.ts | 53 ++ packages/table/src/core/row/buildRows.ts | 58 ++ .../table/src/core/tfoot/buildFooters.test.ts | 437 +++++++++++++++ packages/table/src/core/tfoot/buildTFoots.ts | 154 +++++ .../table/src/core/thead/buildHeadMeta.ts | 48 ++ .../table/src/core/thead/buildTHeadGroups.ts | 24 + packages/table/src/core/thead/buildTHeads.ts | 63 +++ packages/table/src/helpers/cellRenderer.tsx | 77 +++ .../table/src/helpers/composeDataset.test.ts | 119 ++++ packages/table/src/helpers/composeDataset.ts | 47 ++ .../table/src/helpers/transToRendererModel.ts | 20 + packages/table/src/index.ts | 4 + packages/table/src/mocks/payments.mock.ts | 157 ++++++ .../src/mocks/paymentsTableModel.mock.tsx | 91 +++ packages/table/src/react/index.ts | 1 + packages/table/src/react/useTable.stories.tsx | 114 ++++ packages/table/src/react/useTable.ts | 43 ++ packages/table/src/types/index.ts | 2 + packages/table/src/types/table.ts | 124 +++++ packages/table/src/types/utility.ts | 63 +++ packages/table/src/utils/array.ts | 23 + packages/table/src/utils/generateTableID.ts | 22 + packages/table/src/utils/get.ts | 12 + packages/table/src/utils/groupBy.ts | 12 + packages/table/src/utils/invariant.ts | 9 + packages/table/src/utils/mapValues.ts | 9 + packages/table/src/utils/object.ts | 15 + packages/table/src/utils/sum.ts | 3 + packages/table/tsconfig.json | 15 + yarn.lock | 24 + 42 files changed, 2692 insertions(+) create mode 100644 packages/table/README.md create mode 100644 packages/table/package.json create mode 100644 packages/table/src/core/TableCore.test.ts create mode 100644 packages/table/src/core/TableCore.ts create mode 100644 packages/table/src/core/renderer/buildRendererModel.ts create mode 100644 packages/table/src/core/renderer/flattenRendererModel.ts create mode 100644 packages/table/src/core/renderer/getChildrenCount.ts create mode 100644 packages/table/src/core/renderer/getHeaderAccessorId.ts create mode 100644 packages/table/src/core/renderer/getLargestDepth.ts create mode 100644 packages/table/src/core/row/CellSpanManager.ts create mode 100644 packages/table/src/core/row/buildCells.ts create mode 100644 packages/table/src/core/row/buildRows.ts create mode 100644 packages/table/src/core/tfoot/buildFooters.test.ts create mode 100644 packages/table/src/core/tfoot/buildTFoots.ts create mode 100644 packages/table/src/core/thead/buildHeadMeta.ts create mode 100644 packages/table/src/core/thead/buildTHeadGroups.ts create mode 100644 packages/table/src/core/thead/buildTHeads.ts create mode 100644 packages/table/src/helpers/cellRenderer.tsx create mode 100644 packages/table/src/helpers/composeDataset.test.ts create mode 100644 packages/table/src/helpers/composeDataset.ts create mode 100644 packages/table/src/helpers/transToRendererModel.ts create mode 100644 packages/table/src/index.ts create mode 100644 packages/table/src/mocks/payments.mock.ts create mode 100644 packages/table/src/mocks/paymentsTableModel.mock.tsx create mode 100644 packages/table/src/react/index.ts create mode 100644 packages/table/src/react/useTable.stories.tsx create mode 100644 packages/table/src/react/useTable.ts create mode 100644 packages/table/src/types/index.ts create mode 100644 packages/table/src/types/table.ts create mode 100644 packages/table/src/types/utility.ts create mode 100644 packages/table/src/utils/array.ts create mode 100644 packages/table/src/utils/generateTableID.ts create mode 100644 packages/table/src/utils/get.ts create mode 100644 packages/table/src/utils/groupBy.ts create mode 100644 packages/table/src/utils/invariant.ts create mode 100644 packages/table/src/utils/mapValues.ts create mode 100644 packages/table/src/utils/object.ts create mode 100644 packages/table/src/utils/sum.ts create mode 100644 packages/table/tsconfig.json diff --git a/.gitignore b/.gitignore index 329d69b0..8142d94a 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,4 @@ temp lerna-debug.log website/.yarn/.cache/**/* playwright-report +coverage \ No newline at end of file diff --git a/.pnp.cjs b/.pnp.cjs index ce0241af..802d6528 100755 --- a/.pnp.cjs +++ b/.pnp.cjs @@ -30,6 +30,10 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "name": "@h6s/calendar", "reference": "workspace:packages/calendar" }, + { + "name": "@h6s/table", + "reference": "workspace:packages/table" + }, { "name": "website", "reference": "workspace:website" @@ -40,6 +44,7 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "fallbackExclusionList": [ ["@h6s-examples/react", ["workspace:examples/react"]], ["@h6s/calendar", ["workspace:packages/calendar"]], + ["@h6s/table", ["workspace:packages/table"]], ["h6s", ["workspace:."]], ["website", ["workspace:website"]] ], @@ -9201,6 +9206,25 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "SOFT", }] ]], + ["@h6s/table", [ + ["workspace:packages/table", { + "packageLocation": "./packages/table/", + "packageDependencies": [ + ["@h6s/table", "workspace:packages/table"], + ["@playwright/test", "npm:1.17.1"], + ["@storybook/react", "virtual:3cce1c84b75c6752ab7b5395dbcd8e3ea3b1319115ab15fda228bf63fb81d197768dc0e6a4c4831843462d6a936c54a88b677afbdb62ac2b741e0b6101ed5c6d#npm:6.4.9"], + ["@testing-library/react-hooks", "virtual:abf59ffe433a9dd4bbaca062eb7bd9e362b80a73d9304e4d8e587a76736b2ea454dcce4a0bfbee94b505e09a8cc5901c05c4bb781ca089484664c4ca13a9e345#npm:7.0.2"], + ["@types/jest", "npm:27.4.0"], + ["@types/node", "npm:17.0.5"], + ["@types/react", "npm:17.0.38"], + ["playwright", "npm:1.17.1"], + ["react", "npm:17.0.2"], + ["react-dom", "virtual:abf59ffe433a9dd4bbaca062eb7bd9e362b80a73d9304e4d8e587a76736b2ea454dcce4a0bfbee94b505e09a8cc5901c05c4bb781ca089484664c4ca13a9e345#npm:17.0.2"], + ["react-test-renderer", "virtual:abf59ffe433a9dd4bbaca062eb7bd9e362b80a73d9304e4d8e587a76736b2ea454dcce4a0bfbee94b505e09a8cc5901c05c4bb781ca089484664c4ca13a9e345#npm:17.0.2"] + ], + "linkType": "SOFT", + }] + ]], ["@hapi/accept", [ ["npm:5.0.2", { "packageLocation": "./.yarn/cache/@hapi-accept-npm-5.0.2-cfe21ffd1e-8088cbc245.zip/node_modules/@hapi/accept/", diff --git a/packages/table/README.md b/packages/table/README.md new file mode 100644 index 00000000..840f2e7f --- /dev/null +++ b/packages/table/README.md @@ -0,0 +1 @@ +# [@h6s/table](https://h6s.dev/docs/table/get-started) diff --git a/packages/table/package.json b/packages/table/package.json new file mode 100644 index 00000000..80c18b3d --- /dev/null +++ b/packages/table/package.json @@ -0,0 +1,48 @@ +{ + "name": "@h6s/table", + "version": "0.0.1", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "module": "esm/index.js", + "sideEffects": false, + "publishConfig": { + "access": "public" + }, + "scripts": { + "prepack": "yarn build", + "prebuild": "rimraf dist cjs", + "build:type": "yarn run -T tsc --emitDeclarationOnly --declaration", + "build": "yarn build:type && node ../../scripts/build.js", + "lint": "yarn run -T eslint 'src/**/*.{js,jsx,ts,tsx}'", + "lint:fix": "yarn lint --fix", + "typecheck": "yarn run -T tsc", + "test": "yarn run -T jest --config ../../jest.config.js --roots './packages/table/src'", + "test:playwright": "playwright test", + "test:cov": "yarn test --coverage", + "test:watch": "yarn test --watch", + "semantic-release": "semantic-release" + }, + "devDependencies": { + "@playwright/test": "1.17.1", + "@storybook/react": "6.4.9", + "@testing-library/react-hooks": "7.0.2", + "@types/jest": "27.4.0", + "@types/node": "17.0.5", + "@types/react": "17.0.38", + "playwright": "1.17.1", + "react": "17.0.2", + "react-dom": "17.0.2", + "react-test-renderer": "17.0.2" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } +} diff --git a/packages/table/src/core/TableCore.test.ts b/packages/table/src/core/TableCore.test.ts new file mode 100644 index 00000000..67f2595a --- /dev/null +++ b/packages/table/src/core/TableCore.test.ts @@ -0,0 +1,526 @@ +import { TableCore } from '..' +import { paymentDataset, paymentDatasetWithSum } from '../mocks/payments.mock' +import { paymentsTableModel } from '../mocks/paymentsTableModel.mock' +import { sum } from '../utils/sum' + +describe('let instance = new TableCore(model, { source })', () => { + const instance = new TableCore(paymentsTableModel, { + source: paymentDataset, + }) + + describe('instance.generate()', () => { + const { theadGroups, rows, tfoots, headMeta } = instance.generate() + + describe('return theadGroups', () => { + + it('return theadGroups which have 3 length (3 is largest depth of generated table)', () => { + expect(theadGroups.length).toBe(3) + }) + + test('check first HeaderGroup', () => { + expect(theadGroups[0].theads.length).toBe(8) + + const [DATE, ID, SUB_ID, AMOUNT, BUYER, PAY_METHOD, TRANSACTION_ID, MESSAGE] = theadGroups[0].theads + + expect(DATE.label).toBe('Date') + expect(DATE.rowSpan).toBe(3) + expect(DATE.colSpan).toBe(1) + + expect(ID.label).toBe('Id') + expect(ID.rowSpan).toBe(3) + expect(ID.colSpan).toBe(1) + + expect(SUB_ID.label).toBe('Sub Id') + expect(SUB_ID.rowSpan).toBe(3) + expect(SUB_ID.colSpan).toBe(1) + + expect(AMOUNT.label).toBe('AMOUNT') + expect(AMOUNT.rowSpan).toBe(1) + expect(AMOUNT.colSpan).toBe(2) + + expect(BUYER.label).toBe('Buyer') + expect(BUYER.rowSpan).toBe(3) + expect(BUYER.colSpan).toBe(1) + + expect(PAY_METHOD.label).toBe('PAY METHOD') + expect(PAY_METHOD.rowSpan).toBe(1) + expect(PAY_METHOD.colSpan).toBe(3) + + expect(TRANSACTION_ID.label).toBe('Transaction Id') + expect(TRANSACTION_ID.rowSpan).toBe(3) + expect(TRANSACTION_ID.colSpan).toBe(1) + + expect(MESSAGE.label).toBe('Message') + expect(MESSAGE.rowSpan).toBe(3) + expect(MESSAGE.colSpan).toBe(1) + }) + + test('check second HeaderGroup', () => { + expect(theadGroups[1].theads.length).toBe(4) + + const [PAID, CANCELED, CARD, TRANSFER] = theadGroups[1].theads + + expect(PAID.label).toBe('Paid') + expect(PAID.rowSpan).toBe(2) + expect(PAID.colSpan).toBe(1) + + expect(CANCELED.label).toBe('Canceled') + expect(CANCELED.rowSpan).toBe(2) + expect(CANCELED.colSpan).toBe(1) + + expect(CARD.label).toBe('CARD') + expect(CARD.rowSpan).toBe(1) + expect(CARD.colSpan).toBe(2) + + expect(TRANSFER.label).toBe('Transfer') + expect(TRANSFER.rowSpan).toBe(2) + expect(TRANSFER.colSpan).toBe(1) + }) + + test('check third HeaderGroup', () => { + expect(theadGroups[2].theads.length).toBe(2) + + const [PLCC, DEBIT] = theadGroups[2].theads + + expect(PLCC.label).toBe('Plcc') + expect(PLCC.rowSpan).toBe(1) + expect(PLCC.colSpan).toBe(1) + + expect(DEBIT.label).toBe('Debit') + expect(DEBIT.rowSpan).toBe(1) + expect(DEBIT.colSpan).toBe(1) + }) + }) + + describe('return rows', () => { + it('rows have 7 length', () => { + expect(rows.length).toBe(7) + }) + + const [firstRow, secondRow, thirdRow, fourthRow, fifthRow] = rows + + test('first row have cells,', () => { + expect(firstRow.cells.length).toBe(11) + expect(firstRow.getRowProps().rowSpan).toBe(4) + + const [DATE, ID, SUB_ID, PAID, CANCELED, BUYER, PLCC, DEBIT, TRANSFER, TRANSACTION_ID, MESSAGE] = firstRow.cells + + expect(DATE.label).toBe('Date') + expect(DATE.rowSpan).toBe(4) + expect(DATE.colSpan).toBe(1) + + expect(ID.label).toBe('Id') + expect(ID.rowSpan).toBe(3) + expect(ID.colSpan).toBe(1) + + expect(SUB_ID.label).toBe('Sub Id') + expect(SUB_ID.rowSpan).toBe(2) + expect(SUB_ID.colSpan).toBe(1) + + ;[BUYER, PAID, CANCELED, PLCC, DEBIT, TRANSFER, TRANSACTION_ID].forEach(target => { + expect(target.rowSpan).toBe(1) + expect(target.colSpan).toBe(1) + }) + + expect(TRANSACTION_ID.label).toBe('Transaction Id') + expect(MESSAGE.label).toBe('Message') + }) + + test('second row have cells,', () => { + expect(secondRow.cells.length).toBe(8) + expect(secondRow.getRowProps().rowSpan).toBe(4) + + expect(secondRow.cells[0].label).toBe('Paid') + secondRow.cells.forEach(target => { + expect(target.rowSpan).toBe(1) + expect(target.colSpan).toBe(1) + }) + }) + + test('third row have cells,', () => { + expect(thirdRow.cells.length).toBe(9) + expect(thirdRow.getRowProps().rowSpan).toBe(4) + + expect(thirdRow.cells[0].label).toBe('Sub Id') + thirdRow.cells.forEach(target => { + expect(target.rowSpan).toBe(1) + expect(target.colSpan).toBe(1) + }) + }) + + test('fourth row have cells,', () => { + expect(fourthRow.cells.length).toBe(10) + expect(fourthRow.getRowProps().rowSpan).toBe(4) + + expect(fourthRow.cells[0].label).toBe('Id') + fourthRow.cells.forEach(target => { + expect(target.rowSpan).toBe(1) + expect(target.colSpan).toBe(1) + }) + }) + + test('fifth row have cells,', () => { + expect(fifthRow.cells.length).toBe(11) + expect(fifthRow.getRowProps().rowSpan).toBe(4) + + expect(fifthRow.cells[0].label).toBe('Date') + + const [DATE, ...restCells] = fifthRow.cells + + expect(DATE.label).toBe('Date') + expect(DATE.rowSpan).toBe(2) + expect(DATE.colSpan).toBe(1) + + restCells.forEach(target => { + expect(target.rowSpan).toBe(1) + expect(target.colSpan).toBe(1) + }) + }) + }) + + describe('return tfoots', () => { + expect(tfoots!.length).toBe(8) + + const [TOTAL, PAID, CANCELED, EMPTY, PLCC, DEBIT, TRANSFER, REST] = tfoots! + + expect(TOTAL.colSpan).toBe(3) + expect(PAID.colSpan).toBe(1) + expect(CANCELED.colSpan).toBe(1) + expect(EMPTY.colSpan).toBe(1) + expect(PLCC.colSpan).toBe(1) + expect(DEBIT.colSpan).toBe(1) + expect(TRANSFER.colSpan).toBe(1) + expect(REST.colSpan).toBe(2) + }) + + describe('return headMeta', () => { + test('check headMeta', () => { + expect(headMeta.date.label).toBe('Date') + expect(headMeta.date.show).toBe(true) + expect(headMeta.date.countOfChild).toBe(0) + expect(headMeta.date.countOfParent).toBe(0) + + expect(headMeta.id.label).toBe('Id') + expect(headMeta.id.show).toBe(true) + expect(headMeta.id.countOfChild).toBe(0) + expect(headMeta.id.countOfParent).toBe(0) + + expect(headMeta.subId.label).toBe('Sub Id') + expect(headMeta.subId.show).toBe(true) + expect(headMeta.subId.countOfChild).toBe(0) + expect(headMeta.subId.countOfParent).toBe(0) + + expect(headMeta['amount+cancelAmount'].label).toBe('AMOUNT') + expect(headMeta['amount+cancelAmount'].show).toBe(true) + expect(headMeta['amount+cancelAmount'].countOfChild).toBe(1) + expect(headMeta['amount+cancelAmount'].countOfParent).toBe(0) + + expect(headMeta.amount.label).toBe('Paid') + expect(headMeta.amount.show).toBe(true) + expect(headMeta.amount.countOfChild).toBe(0) + expect(headMeta.amount.countOfParent).toBe(1) + + expect(headMeta.cancelAmount.label).toBe('Canceled') + expect(headMeta.cancelAmount.show).toBe(true) + expect(headMeta.cancelAmount.countOfChild).toBe(0) + expect(headMeta.cancelAmount.countOfParent).toBe(1) + + expect(headMeta.buyer.label).toBe('Buyer') + expect(headMeta.buyer.show).toBe(true) + expect(headMeta.buyer.countOfChild).toBe(0) + expect(headMeta.buyer.countOfParent).toBe(0) + + expect(headMeta['plcc+debit+transfer'].label).toBe('PAY METHOD') + expect(headMeta['plcc+debit+transfer'].show).toBe(true) + expect(headMeta['plcc+debit+transfer'].countOfChild).toBe(2) + expect(headMeta['plcc+debit+transfer'].countOfParent).toBe(0) + + expect(headMeta['plcc+debit'].label).toBe('CARD') + expect(headMeta['plcc+debit'].show).toBe(true) + expect(headMeta['plcc+debit'].countOfChild).toBe(1) + expect(headMeta['plcc+debit'].countOfParent).toBe(1) + + expect(headMeta['plcc'].label).toBe('Plcc') + expect(headMeta['plcc'].show).toBe(true) + expect(headMeta['plcc'].countOfChild).toBe(0) + expect(headMeta['plcc'].countOfParent).toBe(2) + + expect(headMeta['debit'].label).toBe('Debit') + expect(headMeta['debit'].show).toBe(true) + expect(headMeta['debit'].countOfChild).toBe(0) + expect(headMeta['debit'].countOfParent).toBe(2) + + expect(headMeta['transfer'].label).toBe('Transfer') + expect(headMeta['transfer'].show).toBe(true) + expect(headMeta['transfer'].countOfChild).toBe(0) + expect(headMeta['transfer'].countOfParent).toBe(1) + + expect(headMeta['meta.transactionId'].label).toBe('Transaction Id') + expect(headMeta['meta.transactionId'].show).toBe(true) + expect(headMeta['meta.transactionId'].countOfChild).toBe(0) + expect(headMeta['meta.transactionId'].countOfParent).toBe(0) + + expect(headMeta['message'].label).toBe('Message') + expect(headMeta['message'].show).toBe(true) + expect(headMeta['message'].countOfChild).toBe(0) + expect(headMeta['message'].countOfParent).toBe(0) + }) + }) + }) + + describe('instance.updateHeads([\'date\']).generate()', () => { + const { theadGroups, visibleHeadIds, tfoots } = instance.updateHead(['date']).generate() + + it('return single column instance with only \'date\' header', () => { + expect(visibleHeadIds).toEqual(['date']) + expect(theadGroups.length).toBe(1) + expect(theadGroups[0].getRowProps().rowSpan).toBe(1) + expect(theadGroups[0].theads.length).toBe(1) + + const [DATE] = theadGroups[0].theads + + expect(DATE.label).toBe('Date') + expect(DATE.rowSpan).toBe(1) + expect(DATE.colSpan).toBe(1) + }) + + it('return single footer', () => { + expect(tfoots?.length).toBe(1) + expect(tfoots?.[0].rowSpan).toBe(1) + }) + }) + + describe('instance.updateHeads([\'id\', \'buyer\'\'transfer\']).generate()', () => { + const { theadGroups, visibleHeadIds, tfoots } = instance.updateHead(['id', 'buyer', 'transfer']).generate() + + it('return 2 depth column', () => { + expect(visibleHeadIds).toEqual(['id', 'buyer', 'transfer']) + expect(theadGroups.length).toBe(2) + + const [firstGroup, secondHeader] = theadGroups + + expect(firstGroup.getRowProps().rowSpan).toBe(2) + expect(firstGroup.theads.length).toBe(3) + expect(secondHeader.theads.length).toBe(1) + + const [ID, BUYER, PAY_METHOD] = firstGroup.theads + + expect(ID.label).toBe('Id') + expect(ID.rowSpan).toBe(2) + expect(ID.colSpan).toBe(1) + + expect(BUYER.label).toBe('Buyer') + expect(BUYER.rowSpan).toBe(2) + expect(BUYER.colSpan).toBe(1) + + expect(PAY_METHOD.label).toBe('PAY METHOD') + expect(PAY_METHOD.rowSpan).toBe(1) + expect(PAY_METHOD.colSpan).toBe(1) + + const [TRANSFER] = secondHeader.theads + + expect(TRANSFER.label).toBe('Transfer') + expect(TRANSFER.rowSpan).toBe(1) + expect(TRANSFER.colSpan).toBe(1) + }) + + it('return 2 length tfoots', () => { + expect(tfoots?.length).toBe(2) + + const [first, second] = tfoots ?? [] + + expect(first.accessor).toBeNull() + expect(first.colSpan).toBe(2) + expect(first.rowSpan).toBe(1) + + expect(second.accessor).toBe('transfer') + expect(second.colSpan).toBe(1) + expect(second.rowSpan).toBe(1) + }) + }) + + describe('instance.updateSource(filtered)', () => { + const filtered = paymentDataset.filter(x => x.buyer === 'Jbee') + const { rows } = instance.updateSource(filtered).generate() + + it('return one row', () => { + expect(rows.length).toBe(1) + }) + }) + + describe('instance.updateSource() // Flush source', () => { + const { rows } = instance.updateSource().generate() + + it('return empty row', () => { + expect(rows.length).toBe(0) + }) + }) +}) + +describe('let instance = new TableCore(model, { defaultHeadIds }) // with default Headers', () => { + describe('defaultHeadIds: [\'date\']', () => { + const instance = new TableCore(paymentsTableModel, { + defaultHeadIds: ['date'], + }) + const { theadGroups, tfoots } = instance.generate() + + it('have single column with only \'date\' header', () => { + + expect(theadGroups.length).toBe(1) + expect(theadGroups[0].getRowProps().rowSpan).toBe(1) + expect(theadGroups[0].theads.length).toBe(1) + + const [DATE] = theadGroups[0].theads + + expect(DATE.label).toBe('Date') + expect(DATE.rowSpan).toBe(1) + expect(DATE.colSpan).toBe(1) + }) + + it('return single footer', () => { + expect(tfoots!.length).toBe(1) + expect(tfoots![0].rowSpan).toBe(1) + }) + }) + + describe('defaultHeadIds: [\'id\', \'transfer\']', () => { + const instance = new TableCore(paymentsTableModel, { + defaultHeadIds: ['id', 'transfer'], + }) + const { theadGroups } = instance.generate() + + test('changed largest depth by headIds: 3 -> 2', () => { + expect(theadGroups.length).toBe(2) + + const [firstGroup, secondHeader] = theadGroups + + expect(firstGroup.getRowProps().rowSpan).toBe(2) + expect(firstGroup.theads.length).toBe(2) + expect(secondHeader.theads.length).toBe(1) + + const [ID, PAY_METHOD] = firstGroup.theads + + expect(ID.label).toBe('Id') + expect(ID.rowSpan).toBe(2) + expect(ID.colSpan).toBe(1) + + expect(PAY_METHOD.label).toBe('PAY METHOD') + expect(PAY_METHOD.rowSpan).toBe(1) + expect(PAY_METHOD.colSpan).toBe(1) + + const [TRANSFER] = secondHeader.theads + + expect(TRANSFER.label).toBe('Transfer') + expect(TRANSFER.rowSpan).toBe(1) + expect(TRANSFER.colSpan).toBe(1) + }) + }) +}) + +describe('let instance = new TableCore(model) // without source', () => { + const instance = new TableCore(paymentsTableModel, {}) + + describe('instance.generate()', () => { + const { rows } = instance.generate() + + it('return empty row', () => { + expect(rows.length).toBe(0) + }) + }) + + describe('instance.composeRows().generate()', () => { + const { rows } = instance.composeRows({ + groupBy: 'date', + compose: rows => { + return rows.concat([]) + }, + }).generate() + + it('return empty row', () => { + expect(rows.length).toBe(0) + }) + }) +}) + +describe('let instance = new TableCore(model, { source: composeRow(data) })', () => { + const instance = new TableCore(paymentsTableModel, { + source: paymentDatasetWithSum, + }) + + describe('instance.generate()', () => { + const { rows } = instance.generate() + + describe('return rows', () => { + const [, , , , , , seventhRow] = rows + + test('check seventhRow', () => { + const [TOTAL, SUB_ID] = seventhRow.cells + + expect(TOTAL.label).toBe('Id') + expect(TOTAL.colSpan).toBe(2) + expect(SUB_ID.label).toBe('Sub Id') + expect(SUB_ID.colSpan).toBe(0) + }) + }) + }) +}) + +describe('let instance = new TableCore(model, { source }).compose(...)', () => { + const instance = new TableCore(paymentsTableModel, { + source: paymentDataset, + }) + + describe('instance.generate()', () => { + const { rows } = instance.composeRows({ + groupBy: 'date', + compose: rows => { + const appended = TableCore.compose(rows, { + groupBy: 'id', + compose: rows => { + return rows.concat({ + subId: '#SUB_TOTAL', + amount: sum(rows.map(x => x.amount)), + cancelAmount: sum(rows.map(x => x.cancelAmount)), + buyer: 'N/A', + plcc: sum(rows.map(x => x.plcc)), + debit: sum(rows.map(x => x.debit)), + transfer: sum(rows.map(x => x.transfer)), + meta: { + transactionId: 'N/A', + }, + message: 'N/A', + }) + }, + }) + + return appended.concat({ + id: '#TOTAL', + subId: '', + amount: sum(rows.map(x => x.amount)), + cancelAmount: sum(rows.map(x => x.cancelAmount)), + buyer: 'N/A', + plcc: sum(rows.map(x => x.plcc)), + debit: sum(rows.map(x => x.debit)), + transfer: sum(rows.map(x => x.transfer)), + meta: { + transactionId: 'N/A', + }, + message: 'N/A', + }) + }, + }).generate() + + describe('return rows', () => { + const [, , , , , , seventhRow] = rows + + test('check seventhRow', () => { + const [TOTAL, SUB_ID] = seventhRow.cells + + expect(TOTAL.label).toBe('Id') + expect(TOTAL.colSpan).toBe(2) + expect(SUB_ID.label).toBe('Sub Id') + expect(SUB_ID.colSpan).toBe(0) + }) + }) + }) +}) diff --git a/packages/table/src/core/TableCore.ts b/packages/table/src/core/TableCore.ts new file mode 100644 index 00000000..7476e86c --- /dev/null +++ b/packages/table/src/core/TableCore.ts @@ -0,0 +1,110 @@ +import { composeDataset, ComposeDatasetOptions } from '../helpers/composeDataset' +import { transToRendererModel } from '../helpers/transToRendererModel' +import { + HeadIds, + HeadMeta, + RendererModel, + TableInstance, + TableModel, +} from '../types/table' +import { Path } from '../types/utility' +import { invariant } from '../utils/invariant' +import { objectEntries } from '../utils/object' +import { buildRendererModel } from './renderer/buildRendererModel' +import { buildCells } from './row/buildCells' +import { buildRows } from './row/buildRows' +import { buildTFoots } from './tfoot/buildTFoots' +import { buildHeadMeta } from './thead/buildHeadMeta' +import { buildTHeadGroups } from './thead/buildTHeadGroups' +import { buildTHeads } from './thead/buildTHeads' + +interface Options { + source?: Row[]; + cellRenderer?: CellRenderer; + defaultHeadIds?: Array>; +} + +export class TableCore { + static compose = composeDataset + + private rendererModel: RendererModel + private headMeta: HeadMeta + private options: Options + + constructor( + model: TableModel, + options: Options, + ) { + const rendererModel = transToRendererModel(model) + const { headMeta } = buildHeadMeta(rendererModel, { + visibleHeadIds: options.defaultHeadIds, + }) + + this.options = options + this.rendererModel = rendererModel + this.headMeta = headMeta + } + + updateHead(headIds?: Array>) { + invariant(headIds == null || headIds?.length > 0, 'headIds must be an array') + + const { headMeta } = buildHeadMeta(this.rendererModel, { + visibleHeadIds: headIds, + }) + + this.headMeta = headMeta + + return this + } + + updateSource(source?: Row[]) { + this.options.source = source + + return this + } + + generate(): TableInstance { + const { + rendererModel, + headMeta, + options: { source = [], cellRenderer }, + } = this + + const model = buildRendererModel(rendererModel, headMeta) + + const { theadGroups } = buildTHeadGroups({ + theads: buildTHeads(model, { cellRenderer }), + }) + + const { rows } = buildRows(source, { + cells: buildCells(model, { cellRenderer }), + }) + + const { tfoots } = buildTFoots(model, { cellRenderer }) + + // FIXME: infer type + const selectableHeadIds = objectEntries(headMeta) + .filter(([, x]) => x.countOfChild === 0) + .map(([id]) => id) as Array> + + const visibleHeadIds = objectEntries(headMeta) + .filter(([, x]) => x.show && x.countOfChild === 0) + .map(([id]) => id) as Array> + + return { + theadGroups, + rows, + tfoots, + + headMeta, + selectableHeadIds, + visibleHeadIds, + } + } + + composeRows>(composeOptions: ComposeDatasetOptions) { + this.options.source = TableCore.compose(this.options.source ?? [], composeOptions) + + return this + } +} diff --git a/packages/table/src/core/renderer/buildRendererModel.ts b/packages/table/src/core/renderer/buildRendererModel.ts new file mode 100644 index 00000000..e8fd5167 --- /dev/null +++ b/packages/table/src/core/renderer/buildRendererModel.ts @@ -0,0 +1,23 @@ +import { HeadMeta, RendererModel } from '../../types/table' +import { getHeaderAccessorId } from './getHeaderAccessorId' + +export function buildRendererModel( + rendererModel: RendererModel, + headMeta: HeadMeta, +): RendererModel { + const result = [] + + for (const model of rendererModel) { + const id = Array.isArray(model.accessor) ? getHeaderAccessorId(model) : model.accessor + + if (headMeta[id].show) { + const value = Array.isArray(model.accessor) + ? { ...model, accessor: buildRendererModel(model.accessor, headMeta) } + : model + + result.push(value) + } + } + + return result +} diff --git a/packages/table/src/core/renderer/flattenRendererModel.ts b/packages/table/src/core/renderer/flattenRendererModel.ts new file mode 100644 index 00000000..90f29fbd --- /dev/null +++ b/packages/table/src/core/renderer/flattenRendererModel.ts @@ -0,0 +1,20 @@ +import { RendererModel } from '../../types/table' + +export function flattenRendererModel( + rendererModel: RendererModel, +): RendererModel { + const models: RendererModel = [] + + for (const model of rendererModel) { + const { accessor } = model + const hasChild = Array.isArray(accessor) + + if (hasChild) { + models.push(...flattenRendererModel(accessor)) + } else { + models.push(model) + } + } + + return models +} diff --git a/packages/table/src/core/renderer/getChildrenCount.ts b/packages/table/src/core/renderer/getChildrenCount.ts new file mode 100644 index 00000000..aba5937b --- /dev/null +++ b/packages/table/src/core/renderer/getChildrenCount.ts @@ -0,0 +1,11 @@ +import { RendererModel } from '../../types/table' + +export function getChildrenCount(model: RendererModel[number]): number { + if (!Array.isArray(model.accessor)) { + return 1 + } + + return model.accessor.reduce((acc, result) => { + return acc + getChildrenCount(result) + }, 0) +} diff --git a/packages/table/src/core/renderer/getHeaderAccessorId.ts b/packages/table/src/core/renderer/getHeaderAccessorId.ts new file mode 100644 index 00000000..7ee2e2db --- /dev/null +++ b/packages/table/src/core/renderer/getHeaderAccessorId.ts @@ -0,0 +1,7 @@ +import { RendererModel } from '../../types/table' + +export function getHeaderAccessorId(model: RendererModel[number]): string { + return Array.isArray(model.accessor) + ? model.accessor.map(model => getHeaderAccessorId(model)).join('+') + : model.accessor +} diff --git a/packages/table/src/core/renderer/getLargestDepth.ts b/packages/table/src/core/renderer/getLargestDepth.ts new file mode 100644 index 00000000..cd83490b --- /dev/null +++ b/packages/table/src/core/renderer/getLargestDepth.ts @@ -0,0 +1,7 @@ +import { RendererModel } from '../../types/table' + +export function getLargestDepth(rendererModel: RendererModel): number { + return rendererModel.reduce((acc, { accessor }) => { + return Array.isArray(accessor) ? getLargestDepth(accessor) + 1 : acc + }, 1) +} diff --git a/packages/table/src/core/row/CellSpanManager.ts b/packages/table/src/core/row/CellSpanManager.ts new file mode 100644 index 00000000..0cfadec9 --- /dev/null +++ b/packages/table/src/core/row/CellSpanManager.ts @@ -0,0 +1,71 @@ +import { RendererRules } from '../../types/table' +import { get } from '../../utils/get' + +export class CellSpanManager { + private rowSpanMap: Map + + constructor() { + this.rowSpanMap = new Map() + } + + getColSpan(Row: Row, rules?: RendererRules) { + const { colSpanRule } = this.parseRules(rules) + + return typeof colSpanRule === 'function' ? colSpanRule(Row) : colSpanRule ?? 1 + } + + getRowSpan(Row: Row, rules?: RendererRules) { + const key = this.getRowSpanMapKey(Row, rules) + + if (key == null) { + return 1 + } + + return this.rowSpanMap.get(key) + } + + saveRowSpan(Row: Row, rules?: RendererRules) { + const key = this.getRowSpanMapKey(Row, rules) + + if (key != null) { + const savedRowSpan = this.rowSpanMap.get(key) + + if (savedRowSpan != null) { + this.rowSpanMap.set(key, savedRowSpan + 1) + + return true + } + this.rowSpanMap.set(key, 1) + } + + return false + } + + getMaxRowSpan() { + return Math.max(...this.rowSpanMap.values()) + } + + private parseRules(rules?: RendererRules) { + const { mergeRow, colSpanAs: colSpanRule } = rules ?? {} + + return { mergeRow, colSpanRule } + } + + private getRowSpanMapKey(row: Row, rules?: RendererRules) { + const { mergeRow } = this.parseRules(rules) + + if (mergeRow == null) { + return + } + + if (typeof mergeRow === 'function') { + return mergeRow(row) + } + + if (Array.isArray(mergeRow)) { + return mergeRow.map(accessor => get(row, accessor)).join('+') + } + + return JSON.stringify(get(row, mergeRow)) + } +} diff --git a/packages/table/src/core/row/buildCells.ts b/packages/table/src/core/row/buildCells.ts new file mode 100644 index 00000000..955cd682 --- /dev/null +++ b/packages/table/src/core/row/buildCells.ts @@ -0,0 +1,53 @@ +import { PrivateAggregatedCell, RendererModel } from '../../types/table' +import { generateTableID } from '../../utils/generateTableID' + +interface Options { + cellRenderer?: CellRenderer; +} + +export function buildCells( + rendererModel: RendererModel, + options?: Options, +) { + return _build(rendererModel, { ...options, labelSequence: [] }) +} + +interface BuildOptions extends Options { + labelSequence: string[]; +} + +function _build( + rendererModel: RendererModel, + { cellRenderer, labelSequence }: BuildOptions, +) { + const cells: Array> = [] + + for (const model of rendererModel) { + const { accessor, label, cell, rules } = model + const hasChild = Array.isArray(accessor) + const sequence = labelSequence.concat(label) + + if (hasChild) { + cells.push( + ..._build(accessor, { + labelSequence: sequence, + cellRenderer, + }), + ) + } else { + cells.push({ + id: generateTableID(), + accessor, + label, + labelSequence: sequence, + render: + typeof cellRenderer === 'function' + ? cellRenderer(cell) + : ({ cellProps }) => cellProps.value, + rules, + }) + } + } + + return cells +} diff --git a/packages/table/src/core/row/buildRows.ts b/packages/table/src/core/row/buildRows.ts new file mode 100644 index 00000000..858e012c --- /dev/null +++ b/packages/table/src/core/row/buildRows.ts @@ -0,0 +1,58 @@ +import { Cell, PrivateAggregatedCell } from '../../types/table' +import { Primitive } from '../../types/utility' +import { generateTableID } from '../../utils/generateTableID' +import { get } from '../../utils/get' +import { invariant } from '../../utils/invariant' +import { CellSpanManager } from './CellSpanManager' + +interface Options { + cells: Array>; +} + +export function buildRows(data: Row[], { cells }: Options) { + const manager = new CellSpanManager() + + const candidateRows = data.map(row => { + return cells + .map(cell => { + const value = get(row, cell.accessor) + + const dropCell = manager.saveRowSpan(row, cell.rules) + + if (dropCell) { + return null + } + + return { value, row, ...cell } + }) + .filter(x => x != null) + }) + + const rows = candidateRows.map(cells => { + return { + getRowProps () { + return { + id: generateTableID(), + rowSpan: manager.getMaxRowSpan(), + } + }, + cells: cells.map(aggregatedCell => { + invariant(aggregatedCell != null, 'invalid cell') + + const { row, rules, value, ...rest } = aggregatedCell + + const cell: Cell = { + rowSpan: manager.getRowSpan(row, rules) ?? 1, + colSpan: manager.getColSpan(row, rules), + rowValues: row, + value: value as Primitive, + ...rest, + } + + return cell + }), + } + }) + + return { rows } +} diff --git a/packages/table/src/core/tfoot/buildFooters.test.ts b/packages/table/src/core/tfoot/buildFooters.test.ts new file mode 100644 index 00000000..5db8621a --- /dev/null +++ b/packages/table/src/core/tfoot/buildFooters.test.ts @@ -0,0 +1,437 @@ +import { RendererModel } from '../..' +import { buildTFoots } from './buildTFoots' + +interface Model { + coding: number; + communication: number; + design: number; + impact: number; + lead: number; +} + +describe('buildTFoots', () => { + test('All Column has foot', () => { + const model: RendererModel = [ + { + accessor: 'coding', + label: 'Coding', + foot: () => 'coding', + }, + { + accessor: 'communication', + label: 'Communication', + foot: () => 'communication', + }, + { + accessor: 'design', + label: 'Design', + foot: () => 'design', + }, + { + accessor: 'impact', + label: 'Impact', + foot: () => 'impact', + }, + { + accessor: 'lead', + label: 'Lead', + foot: () => 'lead', + }, + ] + const { tfoots } = buildTFoots(model) + + expect(tfoots!.length).toBe(5) + tfoots!.forEach(foot => { + expect(foot.colSpan).toBe(1) + }) + + const [CODING, COMMUNICATION, DESIGN, IMPACT, LEAD] = tfoots! + + expect(CODING.value).toBe('Coding') + expect(COMMUNICATION.value).toBe('Communication') + expect(DESIGN.value).toBe('Design') + expect(IMPACT.value).toBe('Impact') + expect(LEAD.value).toBe('Lead') + }) + + test('First column have no foot, so we need to fill one head', () => { + const model: RendererModel = [ + { + accessor: 'coding', + label: 'Coding', + }, + { + accessor: 'communication', + label: 'Communication', + foot: () => 'communication', + }, + { + accessor: 'design', + label: 'Design', + foot: () => 'design', + }, + { + accessor: 'impact', + label: 'Impact', + foot: () => 'impact', + }, + { + accessor: 'lead', + label: 'Lead', + foot: () => 'lead', + }, + ] + const { tfoots } = buildTFoots(model) + + expect(tfoots!.length).toBe(5) + + const [first, ...rest] = tfoots! + + expect(first.colSpan).toBe(1) + rest!.forEach(foot => { + expect(foot.colSpan).toBe(1) + }) + + const [COMMUNICATION, DESIGN, IMPACT, LEAD] = rest + + expect(first.value).toBeNull() + expect(COMMUNICATION.value).toBe('Communication') + expect(DESIGN.value).toBe('Design') + expect(IMPACT.value).toBe('Impact') + expect(LEAD.value).toBe('Lead') + }) + + test('First and Second column have no foot, so we need to fill two head', () => { + const model: RendererModel = [ + { + accessor: 'coding', + label: 'Coding', + }, + { + accessor: 'communication', + label: 'Communication', + }, + { + accessor: 'design', + label: 'Design', + foot: () => 'design', + }, + { + accessor: 'impact', + label: 'Impact', + foot: () => 'impact', + }, + { + accessor: 'lead', + label: 'Lead', + foot: () => 'lead', + }, + ] + const { tfoots } = buildTFoots(model) + + expect(tfoots!.length).toBe(4) + + const [first, ...rest] = tfoots! + + expect(first.colSpan).toBe(2) + + rest.forEach(foot => { + expect(foot.colSpan).toBe(1) + }) + + const [DESIGN, IMPACT, LEAD] = rest + + expect(first.value).toBeNull() + expect(DESIGN.value).toBe('Design') + expect(IMPACT.value).toBe('Impact') + expect(LEAD.value).toBe('Lead') + }) + + test('Last column have no foot, so we need to fill one tail', () => { + const model: RendererModel = [ + { + accessor: 'coding', + label: 'Coding', + foot: () => 'coding', + }, + { + accessor: 'communication', + label: 'Communication', + foot: () => 'communication', + }, + { + accessor: 'design', + label: 'Design', + foot: () => 'design', + }, + { + accessor: 'impact', + label: 'Impact', + foot: () => 'impact', + }, + { + accessor: 'lead', + label: 'Lead', + }, + ] + const { tfoots } = buildTFoots(model) + + expect(tfoots!.length).toBe(5) + + const [CODING, COMMUNICATION, DESIGN, IMPACT, last] = tfoots! + + ;[CODING, COMMUNICATION, DESIGN, IMPACT].forEach(target => { + expect(target.colSpan).toBe(1) + }) + expect(last.colSpan).toBe(1) + + expect(CODING.value).toBe('Coding') + expect(COMMUNICATION.value).toBe('Communication') + expect(DESIGN.value).toBe('Design') + expect(IMPACT.value).toBe('Impact') + expect(last.value).toBeNull() + }) + + test('Last two column have no foot, so we need to fill two tail', () => { + const model: RendererModel = [ + { + accessor: 'coding', + label: 'Coding', + foot: () => 'coding', + }, + { + accessor: 'communication', + label: 'Communication', + foot: () => 'communication', + }, + { + accessor: 'design', + label: 'Design', + foot: () => 'design', + }, + { + accessor: 'impact', + label: 'Impact', + }, + { + accessor: 'lead', + label: 'Lead', + }, + ] + const { tfoots } = buildTFoots(model) + + expect(tfoots!.length).toBe(4) + + const [CODING, COMMUNICATION, DESIGN, last] = tfoots! + + ;[CODING, COMMUNICATION, DESIGN].forEach(target => { + expect(target.colSpan).toBe(1) + }) + expect(last.colSpan).toBe(2) + + expect(CODING.value).toBe('Coding') + expect(COMMUNICATION.value).toBe('Communication') + expect(DESIGN.value).toBe('Design') + expect(last.value).toBeNull() + }) + + test('First and Last column have no foot, so we need to fill one head, one tail', () => { + const model: RendererModel = [ + { + accessor: 'coding', + label: 'Coding', + }, + { + accessor: 'communication', + label: 'Communication', + foot: () => 'communication', + }, + { + accessor: 'design', + label: 'Design', + foot: () => 'design', + }, + { + accessor: 'impact', + label: 'Impact', + foot: () => 'impact', + }, + { + accessor: 'lead', + label: 'Lead', + }, + ] + const { tfoots } = buildTFoots(model) + + expect(tfoots!.length).toBe(5) + + const [first, COMMUNICATION, DESIGN, IMPACT, last] = tfoots! + + ;[COMMUNICATION, DESIGN, IMPACT].forEach(target => { + expect(target.colSpan).toBe(1) + }) + expect(first.colSpan).toBe(1) + expect(last.colSpan).toBe(1) + + expect(first.value).toBeNull() + expect(COMMUNICATION.value).toBe('Communication') + expect(DESIGN.value).toBe('Design') + expect(IMPACT.value).toBe('Impact') + expect(last.value).toBeNull() + }) + + test('The Middle Column have no foot, so check previous column config - extends', () => { + const model: RendererModel = [ + { + accessor: 'coding', + label: 'Coding', + foot: () => 'coding', + }, + { + accessor: 'communication', + label: 'Communication', + foot: () => 'communication', + rules: { + extendsFoot: true, + }, + }, + { + accessor: 'design', + label: 'Design', + }, + { + accessor: 'impact', + label: 'Impact', + foot: () => 'impact', + }, + { + accessor: 'lead', + label: 'Lead', + foot: () => 'lead', + }, + ] + const { tfoots } = buildTFoots(model) + + expect(tfoots!.length).toBe(4) + + const [CODING, COMMUNICATION, IMPACT, LEAD] = tfoots! + + ;[CODING, IMPACT, LEAD].forEach(target => { + expect(target.colSpan).toBe(1) + }) + expect(COMMUNICATION.colSpan).toBe(2) + + expect(CODING.value).toBe('Coding') + expect(COMMUNICATION.value).toBe('Communication') + expect(IMPACT.value).toBe('Impact') + expect(LEAD.value).toBe('Lead') + }) + + test('The Middle Column have no foot, so check previous column config - extends', () => { + const model: RendererModel = [ + { + accessor: 'coding', + label: 'Coding', + foot: () => 'coding', + }, + { + accessor: 'communication', + label: 'Communication', + foot: () => 'communication', + rules: { + extendsFoot: false, + }, + }, + { + accessor: 'design', + label: 'Design', + }, + { + accessor: 'impact', + label: 'Impact', + foot: () => 'impact', + }, + { + accessor: 'lead', + label: 'Lead', + foot: () => 'lead', + }, + ] + const { tfoots } = buildTFoots(model) + + expect(tfoots!.length).toBe(5) + + const [CODING, COMMUNICATION, empty, IMPACT, LEAD] = tfoots! + + ;[CODING, COMMUNICATION, IMPACT, LEAD].forEach(target => { + expect(target.colSpan).toBe(1) + }) + expect(empty.colSpan).toBe(1) + + expect(CODING.value).toBe('Coding') + expect(COMMUNICATION.value).toBe('Communication') + expect(empty.value).toBeNull() + expect(IMPACT.value).toBe('Impact') + expect(LEAD.value).toBe('Lead') + }) + + test('No column have foot', () => { + const model: RendererModel = [ + { + accessor: 'coding', + label: 'Coding', + }, + { + accessor: 'communication', + label: 'Communication', + }, + { + accessor: 'design', + label: 'Design', + }, + { + accessor: 'impact', + label: 'Impact', + }, + { + accessor: 'lead', + label: 'Lead', + }, + ] + const { tfoots } = buildTFoots(model) + + expect(tfoots).toBeNull() + }) + + test('All Column has foot', () => { + const model: RendererModel<{ web: string; mobile: string}> = [ + { + accessor: [ + { + accessor: 'web', + label: 'Web', + foot: () => 'Web', + }, + { + accessor: 'mobile', + label: 'Mobile', + foot: () => 'Mobile', + }, + ], + label: 'Coding', + foot: () => 'coding', + }, + ] + const { tfoots } = buildTFoots(model) + + expect(tfoots!.length).toBe(2) + tfoots!.forEach(foot => { + expect(foot.colSpan).toBe(1) + }) + + const [WEB, MOBILE] = tfoots! + + expect(WEB.value).toBe('Web') + expect(MOBILE.value).toBe('Mobile') + }) +}) diff --git a/packages/table/src/core/tfoot/buildTFoots.ts b/packages/table/src/core/tfoot/buildTFoots.ts new file mode 100644 index 00000000..b2d669ed --- /dev/null +++ b/packages/table/src/core/tfoot/buildTFoots.ts @@ -0,0 +1,154 @@ +import { RendererModel, TFoot } from '../../types/table' +import { popUntil, shiftUntil } from '../../utils/array' +import { generateTableID } from '../../utils/generateTableID' +import { flattenRendererModel } from '../renderer/flattenRendererModel' + +interface Options { + cellRenderer?: CellRenderer; +} + +export function buildTFoots( + rendererModel: RendererModel, + options?: Options, +) { + const parsed = prepare(rendererModel) + + if (parsed == null) { + return { tfoots: null } + } + const { + result: { head, middle, tail }, + colSpanQueue, + } = parsed + + const tfoots = _build(middle, { ...options, colSpanQueue }) + + if (head.length > 0) { + tfoots.unshift({ ...getDefaultTFootCell(), colSpan: head.length }) + } + if (tail.length > 0) { + tfoots.push({ ...getDefaultTFootCell(), colSpan: tail.length }) + } + + return { tfoots: tfoots.length === 0 ? null : tfoots } +} + +interface BuildOptions extends Options { + colSpanQueue: Array; +} + +function _build( + rendererModel: RendererModel, + { cellRenderer, colSpanQueue }: BuildOptions, +) { + const tfoots: Array> = [] + + for (const model of rendererModel) { + const { label, accessor, foot } = model + const hasChild = Array.isArray(accessor) + + if (hasChild) { + tfoots.push(..._build(accessor, { cellRenderer, colSpanQueue })) + } else { + if (foot != null) { + tfoots.push({ + ...getDefaultTFootCell(), + accessor, + colSpan: colSpanQueue.shift() ?? 1, + value: label, + render: + typeof cellRenderer === 'function' + ? cellRenderer(foot) + : ({ cellProps }) => cellProps.value, + }) + } else { + const colSpan = colSpanQueue.shift() + + if (colSpan == null) { + tfoots.push({ + ...getDefaultTFootCell(), + accessor, + colSpan: 1, + }) + } else { + colSpanQueue.unshift(colSpan) + } + } + } + } + + return tfoots +} + +function buildColSpanQueue(rendererModel: RendererModel): Array<{ value: number | null, extends: boolean } | null> { + const queue: Array<{ value: number | null, extends: boolean } | null> = [] + + for (const model of rendererModel) { + const { accessor, foot, rules } = model + const hasChild = Array.isArray(accessor) + + if (hasChild) { + queue.push(...buildColSpanQueue(accessor)) + } else { + if (foot != null) { + queue.push({ + value: 1, + extends: rules?.extendsFoot ?? true, + }) + } else { + const lastValue = queue.pop() + + if (lastValue != null) { + if (lastValue.extends) { + queue.push({ value: (lastValue.value ?? 1) + 1, extends: lastValue.extends }) + } else { + queue.push(lastValue) + queue.push({ value:null, extends: lastValue.extends }) + } + } else { + queue.push(null) + } + } + } + } + + return queue +} + +function prepare(rendererModel: RendererModel) { + const model = flattenRendererModel(rendererModel) + + if (model.every(x => x.foot == null)) { + return null + } + + const colSpanQueue = buildColSpanQueue(rendererModel).map(x => x?.value ?? null) + const head = shiftUntil(model, x => x.foot == null) + const tail = popUntil(model, x => x.foot == null) + const middle = model + + if (tail.length > 0) { + const lastColSpan = colSpanQueue.pop() ?? 1 + + colSpanQueue.push(lastColSpan - tail.length) + } + + return { + colSpanQueue, + result: { + head, + tail, + middle, + }, + } +} + +function getDefaultTFootCell() { + return { + id: generateTableID(), + accessor: null, + rowSpan: 1, + value: null, + render: () => null, + } +} diff --git a/packages/table/src/core/thead/buildHeadMeta.ts b/packages/table/src/core/thead/buildHeadMeta.ts new file mode 100644 index 00000000..fff23544 --- /dev/null +++ b/packages/table/src/core/thead/buildHeadMeta.ts @@ -0,0 +1,48 @@ +import { HeadIds, HeadMeta, RendererModel } from '../../types/table' +import { arrayIncludes } from '../../utils/array' +import { getHeaderAccessorId } from '../renderer/getHeaderAccessorId' +import { getLargestDepth } from '../renderer/getLargestDepth' + +interface Options { + visibleHeadIds?: Array>; +} + +export function buildHeadMeta( + rendererModel: RendererModel, + options?: Options, +) { + const headMeta = _build(rendererModel, { ...options, depth: 0 }) + + return { headMeta } +} + +interface BuildOptions extends Options { + depth: number; +} + +function _build(rendererModel: RendererModel, options: BuildOptions) { + const headMeta: HeadMeta = {} + const { visibleHeadIds, depth } = options + + for (const model of rendererModel) { + const { label, accessor } = model + const hasChild = Array.isArray(accessor) + + const base = hasChild ? accessor : [{ accessor }] + const show = + visibleHeadIds != null ? base.some(x => arrayIncludes(visibleHeadIds, x.accessor)) : true + + headMeta[getHeaderAccessorId(model)] = { + label, + show, + countOfChild: hasChild ? getLargestDepth(accessor) : 0, + countOfParent: depth, + } + + if (hasChild) { + Object.assign(headMeta, _build(accessor, { ...options, depth: depth + 1 })) + } + } + + return headMeta +} diff --git a/packages/table/src/core/thead/buildTHeadGroups.ts b/packages/table/src/core/thead/buildTHeadGroups.ts new file mode 100644 index 00000000..4027ebc4 --- /dev/null +++ b/packages/table/src/core/thead/buildTHeadGroups.ts @@ -0,0 +1,24 @@ +import { THead } from '../../types/table' +import { generateTableID } from '../../utils/generateTableID' +import { groupBy } from '../../utils/groupBy' + +interface Options { + theads: Array>; +} + +export function buildTHeadGroups({ theads }: Options) { + const groupByDepth = Object.values(groupBy(theads, x => String(x.depth))) + const theadGroups = groupByDepth.map(theads => { + return { + theads, + getRowProps () { + return { + id: generateTableID(), + rowSpan: groupByDepth.length, + } + }, + } + }) + + return { theadGroups } +} diff --git a/packages/table/src/core/thead/buildTHeads.ts b/packages/table/src/core/thead/buildTHeads.ts new file mode 100644 index 00000000..b43ea3ef --- /dev/null +++ b/packages/table/src/core/thead/buildTHeads.ts @@ -0,0 +1,63 @@ +import { RendererModel, THead } from '../../types/table' +import { generateTableID } from '../../utils/generateTableID' +import { getChildrenCount } from '../renderer/getChildrenCount' +import { getLargestDepth } from '../renderer/getLargestDepth' + +interface Options { + cellRenderer?: CellRenderer; +} + +export function buildTHeads( + rendererModel: RendererModel, + options?: Options, +) { + const largestDepth = getLargestDepth(rendererModel) + + return _build(rendererModel, { ...options, largestDepth, depth: 1, labelSequence: [] }) +} + +interface BuildOptions extends Options { + largestDepth: number; + depth: number; + labelSequence: string[]; +} + +function _build( + rendererModel: RendererModel, + { cellRenderer, largestDepth, depth, labelSequence }: BuildOptions, +) { + const heads: Array> = [] + + for (const model of rendererModel) { + const { label, accessor, head } = model + const hasChild = Array.isArray(accessor) + const sequence = labelSequence.concat(label) + + if (hasChild) { + heads.push( + ..._build(accessor, { + largestDepth, + depth: depth + 1, + labelSequence: sequence, + cellRenderer, + }), + ) + } + heads.push({ + id: generateTableID(), + accessor: hasChild ? null : accessor, + rowSpan: hasChild ? 1 : depth > 1 ? largestDepth - depth + 1 : largestDepth, + colSpan: getChildrenCount(model), + label, + value: label, + labelSequence: sequence, + render: + typeof cellRenderer === 'function' + ? cellRenderer(head) + : ({ cellProps }) => cellProps.value, + depth, + }) + } + + return heads +} diff --git a/packages/table/src/helpers/cellRenderer.tsx b/packages/table/src/helpers/cellRenderer.tsx new file mode 100644 index 00000000..e0e191e3 --- /dev/null +++ b/packages/table/src/helpers/cellRenderer.tsx @@ -0,0 +1,77 @@ +import { + CellComponent, + CellRecursiveRenderer, + CellRendererProps, + CommonCell, +} from '../types/table' + +export function cellRenderer( + renderers?: CellComponent | Array>, +) { + return ({ cellProps, ...props }: CellRendererProps) => { + if (cellProps.colSpan === 0) { + return null + } + + if (renderers == null || renderers.length === 0) { + return <>{cellProps.value} + } + + if (typeof renderers === 'string') { + return <>{renderers} + } + + if (typeof renderers === 'function') { + return <>{renderers({ cellProps, ...props })} + } + + return + } +} + +interface Props extends CellRendererProps { + renderers: Array>; + index?: number; +} + +function CombinedCell({ + renderers, + index = 0, + cellProps, +}: Props) { + const Renderer = renderers[index] + + if (index === renderers.length - 1) { + if (index === 0) { + const CellComponent = Renderer as any + + return ( + + {cellProps.value} + + ) + } + + return {cellProps.value} + } + + if (index === 0) { + const CellComponent = Renderer as any + + return ( + + + + ) + } + + return ( + + + + ) +} diff --git a/packages/table/src/helpers/composeDataset.test.ts b/packages/table/src/helpers/composeDataset.test.ts new file mode 100644 index 00000000..a80401e7 --- /dev/null +++ b/packages/table/src/helpers/composeDataset.test.ts @@ -0,0 +1,119 @@ +import { sum } from '../utils/sum' +import { composeDataset } from './composeDataset' + +const date = new Date('2021-12-27').toISOString() +const stats = { + 금액: 100, +} + +const 모든_넥스트상점_합계_ID = '#모든넥스트상점합계' +const 모든_리믹스상점_합계_ID = '#모든리믹스상점합계' +const 모든_상점아이디_합계_ID = '#모든상점아이디합계' + +describe('composeDataset', () => { + test('parse', () => { + // Given, When, Then + expect( + composeDataset(byDate, { + groupBy: 'date', + compose: rows => { + const appended = composeDataset(rows, { + groupBy: 'mid', + compose: (rows, mid) => { + return rows.concat({ + payMethod: mid === '넥스트상점' ? 모든_넥스트상점_합계_ID : 모든_리믹스상점_합계_ID, + stats: { 금액: sum(...rows.map(x => x.stats.금액)) }, + }) + }, + }) + + return appended.concat({ + mid: 모든_상점아이디_합계_ID as any, + payMethod: '', + stats: { + 금액: sum(...rows.map(x => x.stats.금액)), + }, + }) + }, + }), + ).toEqual(result) + }) +}) + +const byDate = [ + { + date, + mid: '넥스트상점' as const, + payMethod: 'CARD', + stats, + }, + { + date, + mid: '넥스트상점' as const, + payMethod: 'VIRTUAL_ACCOUNT', + stats, + }, + { + date, + mid: '리믹스상점' as const, + payMethod: 'CARD', + stats, + }, + { + date, + mid: '리믹스상점' as const, + payMethod: 'VIRTUAL_ACCOUNT', + stats, + }, +] + +const result = [ + { + date, + mid: '넥스트상점' as const, + payMethod: 'CARD', + stats, + }, + { + date, + mid: '넥스트상점' as const, + payMethod: 'VIRTUAL_ACCOUNT', + stats, + }, + { + date, + mid: '넥스트상점' as const, + payMethod: 모든_넥스트상점_합계_ID, + stats: { + 금액: 200, + }, + }, + { + date, + mid: '리믹스상점' as const, + payMethod: 'CARD', + stats, + }, + { + date, + mid: '리믹스상점' as const, + payMethod: 'VIRTUAL_ACCOUNT', + stats, + }, + { + date, + mid: '리믹스상점' as const, + payMethod: 모든_리믹스상점_합계_ID, + stats: { + 금액: 200, + }, + }, + { + date, + mid: 모든_상점아이디_합계_ID, + payMethod: '', + stats: { + 금액: 400, + }, + }, +] diff --git a/packages/table/src/helpers/composeDataset.ts b/packages/table/src/helpers/composeDataset.ts new file mode 100644 index 00000000..ef657b17 --- /dev/null +++ b/packages/table/src/helpers/composeDataset.ts @@ -0,0 +1,47 @@ + +import { Path, PathValue } from '../types/utility' +import { get } from '../utils/get' +import { groupBy } from '../utils/groupBy' +import { mapValues } from '../utils/mapValues' + +export interface ComposeDatasetOptions> { + groupBy: Key; + compose: (rows: Array>, key: PathValue) => Array>; +} + +export function composeDataset>( + rows: Row[], + { groupBy: key, compose }: ComposeDatasetOptions, +) { + const normalized = normalize(rows, key) + const result = insert(normalized, compose) + + return reverseNormalize(result, key) as unknown as Row[] +} + +function normalize>(rows: Row[], key: Key) { + return mapValues( + groupBy(rows, x => String(get(x, key))), + x => x.map(({ [key]: _, ...rest }) => rest), + ) +} + +function reverseNormalize>( + normalizedValue: Record>>, + key: Key, +) { + return Object.entries(normalizedValue).flatMap(([id, entries]) => + entries.map(entry => ({ [key]: id, ...entry })), + ) +} + +function insert>( + body: Record>>, + compose: (rows: Array>, key: PathValue) => Array>, +) { + return Object.fromEntries( + Object.entries(body).map(([key, entities]) => { + return [key, compose(entities, key as any)] + }), + ) +} diff --git a/packages/table/src/helpers/transToRendererModel.ts b/packages/table/src/helpers/transToRendererModel.ts new file mode 100644 index 00000000..7bcff94d --- /dev/null +++ b/packages/table/src/helpers/transToRendererModel.ts @@ -0,0 +1,20 @@ +import { isRenderer, RendererModel, TableModel } from '../types/table' + +export function transToRendererModel( + model: TableModel, +): RendererModel { + return model.map(x => { + return { + ...x, + accessor: Array.isArray(x.accessor) ? transToRendererModel(x.accessor) : x.accessor, + head: isRenderer(x.head) ? x.head: x.head?.render, + cell: isRenderer(x.cell) ? x.cell : x.cell?.render, + foot: isRenderer(x.foot) ? x.foot : x.foot?.render, + rules: { + mergeRow: isRenderer(x.cell) ? undefined : x.cell?.mergeRow, + colSpanAs: isRenderer(x.cell) ? undefined : x.cell?.colSpanAs, + extendsFoot: isRenderer(x.foot) ? undefined : x.foot?.extends, + }, + } + }) +} diff --git a/packages/table/src/index.ts b/packages/table/src/index.ts new file mode 100644 index 00000000..589124e7 --- /dev/null +++ b/packages/table/src/index.ts @@ -0,0 +1,4 @@ +export * from './core/TableCore' +export { composeDataset } from './helpers/composeDataset' +export * from './react' +export * from './types' diff --git a/packages/table/src/mocks/payments.mock.ts b/packages/table/src/mocks/payments.mock.ts new file mode 100644 index 00000000..b0d7cb34 --- /dev/null +++ b/packages/table/src/mocks/payments.mock.ts @@ -0,0 +1,157 @@ +import { composeDataset, TableCore } from '..' +import { sum } from '../utils/sum' + +export interface PaymentDatasetType { + date: string; + id: string; + subId: string; + amount: number; + cancelAmount: number; + buyer: string; + plcc: number; + debit: number; + transfer: number; + meta: { + transactionId: string; + }; + message: string; +} + +export const paymentDataset: PaymentDatasetType[] = [{ + date: new Date('2022-01-01').toISOString(), + id: '100', + subId: 'aaaaa', + amount: 2400, + cancelAmount: 0, + buyer: 'Dan', + plcc: 2400, + debit: 0, + transfer: 0, + meta: { + transactionId: 'transaction-123-aaaaa', + }, + message: 'success', +}, { + date: new Date('2022-01-01').toISOString(), + id: '100', + subId: 'aaaaa', + amount: 10000, + cancelAmount: 0, + buyer: 'Jbee', + plcc: 10000, + debit: 0, + transfer: 0, + meta: { + transactionId: 'transaction-123-aaaaa', + }, + message: 'success', +}, { + date: new Date('2022-01-01').toISOString(), + id: '100', + subId: 'bbbbb', + amount: 9800, + cancelAmount: 9800, + buyer: 'Mark', + plcc: 0, + debit: 9800, + transfer: 0, + meta: { + transactionId: 'transaction-123-aaaaa', + }, + message: 'success', +}, { + date: new Date('2022-01-01').toISOString(), + id: '101', + subId: 'ccccc', + amount: 100, + cancelAmount: 80, + buyer: 'John', + plcc: 100, + debit: 0, + transfer: 0, + meta: { + transactionId: 'transaction-123-aaaaa', + }, + message: 'success', +}, { + date: new Date('2022-01-02').toISOString(), + id: '200', + subId: 'aaaaa', + amount: 1200, + cancelAmount: 1180, + buyer: 'Kent', + plcc: 0, + debit: 1200, + transfer: 0, + meta: { + transactionId: 'transaction-123-aaaaa', + }, + message: 'success', +}, { + date: new Date('2022-01-02').toISOString(), + id: '201', + subId: 'bbbbb', + amount: 1200, + cancelAmount: 1180, + buyer: 'Bill', + plcc: 0, + debit: 1200, + transfer: 0, + meta: { + transactionId: 'transaction-123-aaaaa', + }, + message: 'success', +}, { + date: new Date('2022-01-03').toISOString(), + id: '300', + subId: 'ccccc', + amount: 20000, + cancelAmount: 0, + buyer: 'Musk', + plcc: 0, + debit: 0, + transfer: 20000, + meta: { + transactionId: 'transaction-123-aaaaa', + }, + message: 'success', +}] + +export const paymentDatasetWithSum = composeDataset(paymentDataset, { + groupBy: 'date', + compose: rows => { + const appended = TableCore.compose(rows, { + groupBy: 'id', + compose: rows => { + return rows.concat({ + subId: '#SUB_TOTAL', + amount: sum(rows.map(x => x.amount)), + cancelAmount: sum(rows.map(x => x.cancelAmount)), + buyer: 'N/A', + plcc: sum(rows.map(x => x.plcc)), + debit: sum(rows.map(x => x.debit)), + transfer: sum(rows.map(x => x.transfer)), + meta: { + transactionId: 'N/A', + }, + message: 'N/A', + }) + }, + }) + + return appended.concat({ + id: '#TOTAL', + subId: '', + amount: sum(rows.map(x => x.amount)), + cancelAmount: sum(rows.map(x => x.cancelAmount)), + buyer: 'N/A', + plcc: sum(rows.map(x => x.plcc)), + debit: sum(rows.map(x => x.debit)), + transfer: sum(rows.map(x => x.transfer)), + meta: { + transactionId: 'N/A', + }, + message: 'N/A', + }) + }, +}) diff --git a/packages/table/src/mocks/paymentsTableModel.mock.tsx b/packages/table/src/mocks/paymentsTableModel.mock.tsx new file mode 100644 index 00000000..fede59da --- /dev/null +++ b/packages/table/src/mocks/paymentsTableModel.mock.tsx @@ -0,0 +1,91 @@ +import { TableModel } from '../types/table' +import { sum } from '../utils/sum' +import { paymentDataset, PaymentDatasetType } from './payments.mock' + +export const paymentsTableModel: TableModel = [ + { + accessor: 'date', + label: 'Date', + cell: { + mergeRow: 'date', + }, + foot: [() => <>{'Total'}], + }, + { + accessor: 'id', + label: 'Id', + cell: { + mergeRow: ['date', 'id'], + colSpanAs: x => x.id === '#TOTAL' ? 2 : 1, + }, + }, + { + accessor: 'subId', + label: 'Sub Id', + cell: { + mergeRow: ({ date, id, subId }) => date + id + subId, + colSpanAs: x => x.id === '#TOTAL' ? 0 : 1, + }, + }, + { + accessor: [ + { + accessor: 'amount', + label: 'Paid', + foot: () => <>Paid: {sum(paymentDataset.map(x => x.amount))}, + }, + { + accessor: 'cancelAmount', + label: 'Canceled', + foot: { + render: [() => <>Canceled: {sum(paymentDataset.map(x => x.cancelAmount))}], + extends: false, + }, + }, + ], + label: 'AMOUNT', + }, + { + accessor: 'buyer', + label: 'Buyer', + }, + { + accessor: [ + { + accessor: [ + { + accessor: 'plcc', + label: 'Plcc', + foot: { + render: [() => <>Plcc: {sum(paymentDataset.map(x => x.plcc))}], + }, + }, + { + accessor: 'debit', + label: 'Debit', + foot: { + render: [() => <>Debit: {sum(paymentDataset.map(x => x.debit))}], + }, + }, + ], + label: 'CARD', + }, + { + accessor: 'transfer', + label: 'Transfer', + foot: { + render: [() => <>Transfer: {sum(paymentDataset.map(x => x.transfer))}], + }, + }, + ], + label: 'PAY METHOD', + }, + { + accessor: 'meta.transactionId', + label: 'Transaction Id', + }, + { + accessor: 'message', + label: 'Message', + }, +] diff --git a/packages/table/src/react/index.ts b/packages/table/src/react/index.ts new file mode 100644 index 00000000..c89d2c2b --- /dev/null +++ b/packages/table/src/react/index.ts @@ -0,0 +1 @@ +export * from './useTable' diff --git a/packages/table/src/react/useTable.stories.tsx b/packages/table/src/react/useTable.stories.tsx new file mode 100644 index 00000000..2d7aad9a --- /dev/null +++ b/packages/table/src/react/useTable.stories.tsx @@ -0,0 +1,114 @@ +import { useState } from 'react' + +import { paymentDataset, paymentDatasetWithSum } from '../mocks/payments.mock' +import { paymentsTableModel } from '../mocks/paymentsTableModel.mock' +import { TableInstance } from '../types/table' +import { objectEntries } from '../utils/object' +import { useTable } from './useTable' + +export default { + title: 'table/useTable', + component: useTable, +} + +export function Basic() { + const [instance, controls] = useTable({ + model: paymentsTableModel, + source: paymentDataset, + }) + const [headers, setHeaders] = useState(() => instance.visibleHeadIds) + + return ( + <> + +
    + {objectEntries(instance.headMeta).map(([id, { label, show, countOfChild }]) => { + return ( + + ) + })} +
+ + + + ) +} + +export function ComposeDataset() { + const [instance] = useTable({ + model: paymentsTableModel, + source: paymentDatasetWithSum, + }) + + return +} + +interface TableUIProps { + instance: TableInstance +} + +function TableUI({ instance }: TableUIProps) { + return ( + + + {instance.theadGroups.map(({ theads, getRowProps }) => { + const props = getRowProps() + + return ( + + {theads.map(header => { + return ( + + ) + })} + + ) + })} + + + {instance.rows.map(({ cells, getRowProps }) => { + const props = getRowProps() + + return ( + + {cells.map(cell => { + if (cell.colSpan === 0) { + return null + } + + return ( + + ) + })} + + ) + })} + + + + {instance.tfoots?.map(cell => { + return ( + + ) + })} + + +
+ {header.render({ cellProps: header })} +
+ {cell.render({ cellProps: cell })} +
+ {cell.render({ cellProps: cell })} +
+ ) +} diff --git a/packages/table/src/react/useTable.ts b/packages/table/src/react/useTable.ts new file mode 100644 index 00000000..3c974d6a --- /dev/null +++ b/packages/table/src/react/useTable.ts @@ -0,0 +1,43 @@ +import { useEffect, useMemo, useRef, useState } from 'react' + +import { TableCore } from '../core/TableCore' +import { cellRenderer } from '../helpers/cellRenderer' +import { HeadIds, TableModel } from '../types/table' + +interface Options { + model: TableModel; + source?: Row[]; + options?: { + defaultHeadIds?: Array>; + }; +} + +export function useTable({ + model, + source, + options: { defaultHeadIds } = {}, +}: Options) { + const tableCore = useRef( + new TableCore(model, { + source, + cellRenderer, + defaultHeadIds, + }), + ) + + const [instance, setInstance] = useState(() => tableCore.current.generate()) + const controls = useMemo( + () => ({ + updateHead(headIds?: Array>) { + setInstance(() => tableCore.current.updateHead(headIds).generate()) + }, + }), + [], + ) + + useEffect(() => { + setInstance(() => tableCore.current.updateSource(source).generate()) + }, [source]) + + return [instance, controls] as const +} diff --git a/packages/table/src/types/index.ts b/packages/table/src/types/index.ts new file mode 100644 index 00000000..c7d765c6 --- /dev/null +++ b/packages/table/src/types/index.ts @@ -0,0 +1,2 @@ +export * from './table' +export * from './utility' diff --git a/packages/table/src/types/table.ts b/packages/table/src/types/table.ts new file mode 100644 index 00000000..7d7884be --- /dev/null +++ b/packages/table/src/types/table.ts @@ -0,0 +1,124 @@ +import { PropsWithChildren, ReactNode } from 'react' + +import { Path, Primitive } from './utility' + +export interface CommonCell { + id: string; + rowSpan: number; + colSpan: number; + value: Primitive; +} + +export interface THead extends CommonCell { + accessor: Path | null; + depth: number; + render: CellRecursiveRenderer>; + labelSequence: string[]; + label: string; +} + +export interface Cell extends CommonCell { + accessor: Path; + rowValues: Row; + render: CellRecursiveRenderer>; + labelSequence: string[]; + label: string; +} + +export interface TFoot extends CommonCell { + accessor: Path | null; + render: CellRecursiveRenderer>; +} + +export interface PrivateAggregatedCell + extends Pick, 'rules'>, + Pick { + accessor: Path; + render: CellRecursiveRenderer>; + labelSequence: string[]; + label: string; +} + +// TODO: move react directory +export type CellRendererProps = PropsWithChildren<{ + cellProps: CellType; +}>; + +export type CellRecursiveRenderer = ( + props: PropsWithChildren<{ cellProps: CellType }> +) => JSX.Element | null; + +export type CellComponent = (props: { + cellProps: CellType; +}) => ReactNode | Primitive; + +export interface RendererRules { + mergeRow?: Path | Array> | ((rowValues: Row) => string); + colSpanAs?: number | ((rowValues: Row) => number); + extendsFoot?: boolean +} + +interface Renderer { + accessor: Path | Array>; + label: string; + head?: CellComponent> | Array>>; + cell?: CellComponent> | Array>>; + foot?: CellComponent> | Array>>; + rules?: RendererRules; +} + +export type RendererModel = Array>; + +export type HeadIds = Path; + +export type HeadMeta = Record< + string, + { label: string; show: boolean; countOfChild: number; countOfParent: number } +>; + +interface RowProps { + id: string; + rowSpan: number; +} + +export type TableInstance = { + theadGroups: Array<{ + getRowProps: () => RowProps; + theads: Array>; + }>; + rows: Array<{ + getRowProps: () => RowProps; + cells: Array>; + }>; + tfoots: Array> | null; + + headMeta: HeadMeta; + selectableHeadIds: HeadIds[] + visibleHeadIds: HeadIds[] +} + +type CommonRenderer = CellComponent | Array> +type CellConfig = CommonRenderer | Record + +interface TableColumn { + accessor: Path | Array> + label: string + head?: CommonRenderer> | { + render: CommonRenderer> + } + cell?: CommonRenderer> | { + render?: CommonRenderer> + mergeRow?: Path | Array> | ((rowValues: Row) => string) + colSpanAs?: number | ((rowValues: Row) => number) + } + foot?: CommonRenderer> | { + render: CommonRenderer> + extends?: boolean + } +} + +export type TableModel = Array> + +export function isRenderer(value?: CellConfig): value is CommonRenderer { + return typeof value === 'function' || Array.isArray(value) +} diff --git a/packages/table/src/types/utility.ts b/packages/table/src/types/utility.ts new file mode 100644 index 00000000..34f2fd9d --- /dev/null +++ b/packages/table/src/types/utility.ts @@ -0,0 +1,63 @@ +export type Primitive = null | undefined | string | number | boolean; + +type IsTuple = number extends T['length'] ? false : true; +type TupleKey = Exclude; +type ArrayKey = number; + +type PathImpl = V extends Primitive + ? `${K}` + : `${K}` | `${K}.${Path}`; + +export type Path = T extends ReadonlyArray + ? IsTuple extends true + ? { + [K in TupleKey]-?: PathImpl; + }[TupleKey] + : PathImpl + : { + [K in keyof T]-?: PathImpl; + }[keyof T]; + +type ArrayPathImpl = V extends Primitive + ? never + : V extends ReadonlyArray + ? U extends Primitive + ? never + : `${K}` | `${K}.${ArrayPath}` + : `${K}.${ArrayPath}`; + +export type ArrayPath = T extends ReadonlyArray + ? IsTuple extends true + ? { + [K in TupleKey]-?: ArrayPathImpl; + }[TupleKey] + : ArrayPathImpl + : { + [K in keyof T]-?: ArrayPathImpl; + }[keyof T]; + +export type PathValue | ArrayPath> = T extends any + ? P extends `${infer K}.${infer R}` + ? K extends keyof T + ? R extends Path + ? PathValue + : never + : K extends `${ArrayKey}` + ? T extends ReadonlyArray + ? PathValue> + : never + : never + : P extends keyof T + ? T[P] + : P extends `${ArrayKey}` + ? T extends ReadonlyArray + ? V + : never + : never + : never; + +type IteratorWithGeneric = T[]; + +export type InferGeneric = TargetType extends IteratorWithGeneric + ? InferType + : never; diff --git a/packages/table/src/utils/array.ts b/packages/table/src/utils/array.ts new file mode 100644 index 00000000..e1341f83 --- /dev/null +++ b/packages/table/src/utils/array.ts @@ -0,0 +1,23 @@ +export function arrayIncludes(array: Type[] | readonly Type[], item: unknown, fromIndex?: number): item is Type { + return array.includes(item as Type, fromIndex) +} + +export function shiftUntil(arr: Item[], predicate: (item: Item) => boolean) { + const result: Array = [] + + while (arr.length > 0 && predicate(arr[0])) { + result.push(arr.shift() ?? null) + } + + return result +} + +export function popUntil(arr: Item[], predicate: (item: Item) => boolean) { + const result: Array = [] + + while (arr.length > 0 && predicate(arr[arr.length - 1])) { + result.push(arr.pop() ?? null) + } + + return result +} diff --git a/packages/table/src/utils/generateTableID.ts b/packages/table/src/utils/generateTableID.ts new file mode 100644 index 00000000..ee8b11b0 --- /dev/null +++ b/packages/table/src/utils/generateTableID.ts @@ -0,0 +1,22 @@ +export function generateTableID() { + return generateID('table') +} + +let randomId = 0 + +const map = new Map() + +function generateID(prefix: string) { + if (map.has(prefix)) { + const id = map.get(prefix) + const newId = id! + 1 + map.set(prefix, newId) + randomId = newId + } else { + const id = 1 + map.set(prefix, id) + randomId = id + } + + return `${prefix}-${randomId}` +} diff --git a/packages/table/src/utils/get.ts b/packages/table/src/utils/get.ts new file mode 100644 index 00000000..15ee68e9 --- /dev/null +++ b/packages/table/src/utils/get.ts @@ -0,0 +1,12 @@ +import { Path } from '..' + +export function get>(obj: ObjectType, path: Path, defaultValue = undefined) { + const travel = (regexp: RegExp) => + String.prototype.split + .call(path, regexp) + .filter(Boolean) + .reduce((acc, key) => (acc !== null && acc !== undefined ? acc[key] : acc), obj) + const result = travel(/[,[\]]+?/) || travel(/[,[\].]+?/) + + return result === undefined || result === obj ? defaultValue : result +} diff --git a/packages/table/src/utils/groupBy.ts b/packages/table/src/utils/groupBy.ts new file mode 100644 index 00000000..7cc783f4 --- /dev/null +++ b/packages/table/src/utils/groupBy.ts @@ -0,0 +1,12 @@ +export function groupBy(data: T[], createKey: (item: T) => string) { + return data.reduce((result: Record, current) => { + const key = createKey(current) + if (result[key] == null) { + result[key] = [current] + } else { + result[key].push(current) + } + + return result + }, {}) +} diff --git a/packages/table/src/utils/invariant.ts b/packages/table/src/utils/invariant.ts new file mode 100644 index 00000000..e382ed3c --- /dev/null +++ b/packages/table/src/utils/invariant.ts @@ -0,0 +1,9 @@ +export function invariant(condition: unknown, error: Error | string = new Error()): asserts condition { + if (!condition) { + if (typeof error === 'string') { + throw new Error(error) + } else { + throw error + } + } +} diff --git a/packages/table/src/utils/mapValues.ts b/packages/table/src/utils/mapValues.ts new file mode 100644 index 00000000..3b3d432f --- /dev/null +++ b/packages/table/src/utils/mapValues.ts @@ -0,0 +1,9 @@ +export function mapValues(value: T, mapper: (value: T[keyof T]) => U): { [K in keyof T]: U } { + const entries = Object.entries(value) + + return Object.fromEntries( + entries.map(([k, v]) => { + return [k, mapper(v)] + }), + ) as any +} diff --git a/packages/table/src/utils/object.ts b/packages/table/src/utils/object.ts new file mode 100644 index 00000000..c15919b3 --- /dev/null +++ b/packages/table/src/utils/object.ts @@ -0,0 +1,15 @@ +export type ObjectKeys> = `${Exclude}`; + +export function objectKeys>(obj: Type): Array> { + return Object.keys(obj) as Array> +} + +export function objectEntries>( + obj: Type, +): Array<[ObjectKeys, Type[ObjectKeys]]> { + return Object.entries(obj) as Array<[ObjectKeys, Type[ObjectKeys]]> +} + +export function objectValues>(obj: Type): Array]> { + return Object.values(obj) as Array]> +} diff --git a/packages/table/src/utils/sum.ts b/packages/table/src/utils/sum.ts new file mode 100644 index 00000000..1217d3cf --- /dev/null +++ b/packages/table/src/utils/sum.ts @@ -0,0 +1,3 @@ +export function sum(...arr: number[] | number[][]) { + return arr.flat().reduce((a, b) => a + b, 0) +} diff --git a/packages/table/tsconfig.json b/packages/table/tsconfig.json new file mode 100644 index 00000000..9743bdc7 --- /dev/null +++ b/packages/table/tsconfig.json @@ -0,0 +1,15 @@ +{ + "$schema": "http://json.schemastore.org/tsconfig", + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist" + }, + "exclude": [ + "**/__mocks__/*", + "**/__tests__/*", + "**/*.stories.tsx", + "**/tests/**/*", + "**/*.test.ts" + ], + "include": ["src"] +} diff --git a/yarn.lock b/yarn.lock index 431f7214..7ba04e6a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4599,6 +4599,30 @@ __metadata: languageName: unknown linkType: soft +"@h6s/table@workspace:packages/table": + version: 0.0.0-use.local + resolution: "@h6s/table@workspace:packages/table" + dependencies: + "@playwright/test": 1.17.1 + "@storybook/react": 6.4.9 + "@testing-library/react-hooks": 7.0.2 + "@types/jest": 27.4.0 + "@types/node": 17.0.5 + "@types/react": 17.0.38 + playwright: 1.17.1 + react: 17.0.2 + react-dom: 17.0.2 + react-test-renderer: 17.0.2 + peerDependencies: + react: ^16.8.0 || ^17.0.0 + peerDependenciesMeta: + react-dom: + optional: true + react-native: + optional: true + languageName: unknown + linkType: soft + "@hapi/accept@npm:5.0.2": version: 5.0.2 resolution: "@hapi/accept@npm:5.0.2"