├── _config.yml ├── force-app └── main │ ├── default │ ├── lwc │ │ ├── datatable │ │ │ ├── datatable.css │ │ │ ├── datatable.js-meta.xml │ │ │ ├── datatable.html │ │ │ ├── __tests__ │ │ │ │ ├── data │ │ │ │ │ └── wireTableCache.json │ │ │ │ └── datatable.test.js │ │ │ ├── README.md │ │ │ ├── datatableUtils.js │ │ │ └── datatable.js │ │ ├── .eslintrc.json │ │ ├── modal │ │ │ ├── modal.js-meta.xml │ │ │ ├── modal.js │ │ │ └── modal.html │ │ ├── datatableBase │ │ │ ├── datatableBase.js-meta.xml │ │ │ ├── picklistField.html │ │ │ └── datatableBase.js │ │ ├── datatablePicklistField │ │ │ ├── datatablePicklistField.js-meta.xml │ │ │ ├── datatablePicklistField.css │ │ │ ├── datatablePicklistField.html │ │ │ └── datatablePicklistField.js │ │ ├── relatedList │ │ │ ├── relatedList.svg │ │ │ ├── relatedList.html │ │ │ ├── relatedList.js │ │ │ └── relatedList.js-meta.xml │ │ └── jsconfig.json │ ├── staticresources │ │ ├── datatablePicklist.resource-meta.xml │ │ └── datatablePicklist.css │ ├── tabs │ │ └── tst.tab-meta.xml │ └── flexipages │ │ ├── tst.flexipage-meta.xml │ │ ├── contact.flexipage-meta.xml │ │ └── test.flexipage-meta.xml │ └── lwc-utils │ ├── lwc │ ├── .eslintrc.json │ ├── lwcUtilsLicense │ │ ├── lwcUtilsLicense.js │ │ ├── lwcUtilsLicense.js-meta.xml │ │ └── lwcUtilsLicense.html │ ├── tableService │ │ ├── tableService.js-meta.xml │ │ └── tableService.js │ ├── tableServiceUtils │ │ ├── tableServiceUtils.js-meta.xml │ │ └── tableServiceUtils.js │ ├── datatableImperativeExample │ │ ├── datatableImperativeExample.js-meta.xml │ │ ├── datatableImperativeExample.html │ │ └── datatableImperativeExample.js │ └── jsconfig.json │ ├── classes │ ├── DataTableService.cls-meta.xml │ ├── DataTableServiceTests.cls-meta.xml │ ├── DataTableServiceTests.cls │ └── DataTableService.cls │ └── LICENSE ├── .eslintrc.json ├── resources └── datatable │ └── demo.gif ├── jsconfig.json ├── .gitignore ├── .forceignore ├── config └── project-scratch-def.json ├── .github └── workflows │ ├── js-tests.yml │ └── main.yml ├── jest.config.js ├── package.json ├── sfdx-project.json ├── LICENSE └── README.md /_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-slate -------------------------------------------------------------------------------- /force-app/main/default/lwc/datatable/datatable.css: -------------------------------------------------------------------------------- 1 | /* .advanced-filters { 2 | width: 20%; 3 | } */ -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@salesforce/eslint-config-lwc/recommended", 3 | "root": true 4 | } 5 | -------------------------------------------------------------------------------- /force-app/main/default/lwc/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@salesforce/eslint-config-lwc/recommended" 3 | } 4 | -------------------------------------------------------------------------------- /force-app/main/lwc-utils/lwc/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@salesforce/eslint-config-lwc/recommended" 3 | } 4 | -------------------------------------------------------------------------------- /resources/datatable/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shliachtx/lwc-listview/HEAD/resources/datatable/demo.gif -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "checkJs": false 4 | }, 5 | "exclude": [ 6 | "force-app/main/dev" 7 | ] 8 | } -------------------------------------------------------------------------------- /force-app/main/lwc-utils/lwc/lwcUtilsLicense/lwcUtilsLicense.js: -------------------------------------------------------------------------------- 1 | import { LightningElement } from 'lwc'; 2 | 3 | export default class LwcUtilsLicense extends LightningElement {} -------------------------------------------------------------------------------- /force-app/main/lwc-utils/classes/DataTableService.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 45.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /force-app/main/default/lwc/modal/modal.js-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 47.0 4 | false 5 | -------------------------------------------------------------------------------- /force-app/main/lwc-utils/classes/DataTableServiceTests.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 45.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /force-app/main/default/lwc/datatableBase/datatableBase.js-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 47.0 4 | false 5 | -------------------------------------------------------------------------------- /force-app/main/default/lwc/datatable/datatable.js-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 47.0 4 | false 5 | -------------------------------------------------------------------------------- /force-app/main/default/staticresources/datatablePicklist.resource-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Private 4 | text/css 5 | 6 | -------------------------------------------------------------------------------- /force-app/main/default/lwc/datatablePicklistField/datatablePicklistField.js-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 48.0 4 | false 5 | -------------------------------------------------------------------------------- /force-app/main/lwc-utils/lwc/tableService/tableService.js-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 45.0 4 | false 5 | -------------------------------------------------------------------------------- /force-app/main/lwc-utils/lwc/lwcUtilsLicense/lwcUtilsLicense.js-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 45.0 4 | false 5 | -------------------------------------------------------------------------------- /force-app/main/lwc-utils/lwc/tableServiceUtils/tableServiceUtils.js-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 45.0 4 | false 5 | -------------------------------------------------------------------------------- /force-app/main/default/tabs/tst.tab-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Created by Lightning App Builder 4 | tst 5 | 6 | Custom59: Can 7 | 8 | -------------------------------------------------------------------------------- /force-app/main/default/staticresources/datatablePicklist.css: -------------------------------------------------------------------------------- 1 | /** LWC loadStyle hack 2 | * https://salesforce.stackexchange.com/questions/246887/target-inner-elements-of-standard-lightning-web-components-with-css/252852#252852 3 | */ 4 | .slds-scrollable_x { 5 | overflow: visible !important; 6 | } 7 | .slds-scrollable_y { 8 | overflow: visible !important; 9 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/* 2 | *.iml 3 | IlluminatedCloud 4 | *.ipr 5 | *.iws 6 | node_modules 7 | 8 | node_modules/* 9 | /.idea/* 10 | /node_modules 11 | /IlluminatedCloud 12 | 13 | **/.sfdx/** 14 | **/.vscode 15 | **/.yarn 16 | **/.vscode/*.code-workspace 17 | **/.vscode/tasks.json 18 | **/.vscode/settings.json 19 | 20 | coverage 21 | junit.xml 22 | eslint-junit.xml 23 | /force-app/*/dev -------------------------------------------------------------------------------- /force-app/main/default/lwc/datatableBase/picklistField.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.forceignore: -------------------------------------------------------------------------------- 1 | # List files or directories below to ignore them when running force:source:push, force:source:pull, and force:source:status 2 | # More information: https://developer.salesforce.com/docs/atlas.en-us.sfdx_dev.meta/sfdx_dev/sfdx_dev_exclude_source.htm 3 | # 4 | 5 | package.xml 6 | 7 | # LWC configuration files 8 | **/jsconfig.json 9 | **/.eslintrc.json 10 | 11 | # LWC Jest 12 | **/__tests__/** 13 | **/README.md 14 | 15 | **/LICENSE -------------------------------------------------------------------------------- /force-app/main/default/lwc/modal/modal.js: -------------------------------------------------------------------------------- 1 | import { LightningElement,api,track } from 'lwc'; 2 | 3 | export default class Modal extends LightningElement { 4 | @api headerText; 5 | @api hideFooter=false; 6 | @track showModal = false; 7 | 8 | @api open(){ 9 | this.showModal = true; 10 | } 11 | 12 | @api close(){ 13 | this.showModal = false; 14 | this.dispatchEvent(new CustomEvent('close')); 15 | } 16 | } -------------------------------------------------------------------------------- /force-app/main/default/lwc/datatableBase/datatableBase.js: -------------------------------------------------------------------------------- 1 | import LightningDatatable from 'lightning/datatable'; 2 | import picklistField from './picklistField.html'; 3 | 4 | export default class DatatableBase extends LightningDatatable { 5 | static customTypes = { 6 | picklist: { 7 | template: picklistField, 8 | standardCellLayout: false, 9 | typeAttributes: ['options','editable'] 10 | } 11 | } 12 | } -------------------------------------------------------------------------------- /force-app/main/lwc-utils/lwc/datatableImperativeExample/datatableImperativeExample.js-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Datatable Example (LWC) 4 | 45.0 5 | true 6 | 7 | lightning__AppPage 8 | 9 | -------------------------------------------------------------------------------- /config/project-scratch-def.json: -------------------------------------------------------------------------------- 1 | { 2 | "orgName": "Test", 3 | "edition": "Developer", 4 | "hasSampleData": true, 5 | "features": [ 6 | "ForceComPlatform", 7 | "ProcessBuilder" 8 | ], 9 | "settings": { 10 | "lightningExperienceSettings": { 11 | "enableS1DesktopEnabled": true 12 | }, 13 | "emailAdministrationSettings": { 14 | "enableEnhancedEmailEnabled": true 15 | }, 16 | "mobileSettings": { 17 | "enableS1EncryptedStoragePref2": false 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /.github/workflows/js-tests.yml: -------------------------------------------------------------------------------- 1 | name: Jest 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | lint: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | - uses: bahmutov/npm-install@v1 14 | - name: Run ESLint 15 | run: | 16 | yarn 17 | yarn lint:ci 18 | 19 | jest: 20 | runs-on: ubuntu-latest 21 | steps: 22 | - uses: actions/checkout@v2 23 | - uses: bahmutov/npm-install@v1 24 | - name: Run Jest tests 25 | run: | 26 | yarn 27 | yarn test:ci 28 | - name: Upload code coverage 29 | uses: codecov/codecov-action@v1 30 | with: 31 | token: ${{ secrets.CODECOV_TOKEN }} #required 32 | name: js 33 | 34 | -------------------------------------------------------------------------------- /force-app/main/lwc-utils/lwc/tableService/tableService.js: -------------------------------------------------------------------------------- 1 | import getTableCache from '@salesforce/apex/DataTableService.getTableCache'; 2 | import * as tableUtils from 'c/tableServiceUtils'; 3 | 4 | const getTableRequest = (requestConfig) => { 5 | return new Promise (resolve => { 6 | getTableCache({tableRequest: requestConfig}) 7 | .then(data => { 8 | const flatData = tableUtils.flattenQueryResult(data.tableData); 9 | const flatDataWithLinks = tableUtils.applyLinks(flatData); 10 | const response = { 11 | tableData: flatDataWithLinks, 12 | tableColumns: data.tableColumns 13 | } 14 | resolve(response); 15 | }) 16 | .catch(error => { 17 | resolve(error); 18 | }); 19 | }) 20 | } 21 | 22 | export { 23 | getTableRequest 24 | } -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | const { jestConfig } = require('@salesforce/sfdx-lwc-jest/config'); 2 | // eslint-disable-next-line no-undef 3 | module.exports = { 4 | ...jestConfig, 5 | // add any custom configurations here 6 | reporters: [ 7 | "default", 8 | "jest-junit" 9 | ], 10 | collectCoverage: true, 11 | collectCoverageFrom: [ 12 | "force-app/**/*.{js,jsx}", 13 | "!**/aura/**", 14 | "!**/lwc-utils/**" 15 | ], 16 | coverageReporters: [ 17 | "text", 18 | "json", 19 | "cobertura", 20 | "html" 21 | ], 22 | // coverageThreshold: { 23 | // "global":{ 24 | // "branches": 80, 25 | // "functions": 80, 26 | // "lines": 80, 27 | // "statements": -10 28 | // } 29 | // } 30 | }; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "list-view", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "lint": "eslint force-app", 8 | "lint:ci": "eslint force-app --format junit --output-file eslint-junit.xml", 9 | "test": "yarn lint && yarn test:unit", 10 | "test:unit": "lwc-jest", 11 | "test:ci": "lwc-jest -- --ci", 12 | "test:unit:watch": "lwc-jest --watch", 13 | "test:unit:debug": "lwc-jest --debug" 14 | }, 15 | "author": "JackDough", 16 | "license": "BSD-3-Clause", 17 | "private": true, 18 | "devDependencies": { 19 | "@salesforce/eslint-config-lwc": "^0.3.0", 20 | "@salesforce/sfdx-lwc-jest": "^0.7.1", 21 | "eslint": "^6.0.1", 22 | "jest": "^25.1.0", 23 | "jest-junit": "^6.4.0" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /force-app/main/default/lwc/relatedList/relatedList.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /force-app/main/lwc-utils/lwc/datatableImperativeExample/datatableImperativeExample.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /force-app/main/default/lwc/datatablePicklistField/datatablePicklistField.css: -------------------------------------------------------------------------------- 1 | :host>div { 2 | display: block; 3 | padding: var(--lwc-spacingXxSmall); 4 | margin: 1px; 5 | z-index: 8001; /* To prevent the droplist from being hidden by the save menu */ 6 | } 7 | 8 | .editable:hover { 9 | background-color: var(--lwc-colorBackgroundAlt,white); 10 | } 11 | 12 | .edit-button { 13 | visibility: hidden; 14 | } 15 | 16 | :host:hover .edit-button { 17 | visibility: visible; 18 | opacity: .5; 19 | } 20 | 21 | :host:focus .editable .edit-button { 22 | visibility: visible; 23 | opacity: 1; 24 | } 25 | 26 | .editor { 27 | position: absolute !important; 28 | min-width: 100%; 29 | width: max-content; 30 | z-index: 1; 31 | top: -1px; 32 | left: -1px; 33 | display:block; 34 | background-color:white; 35 | border: 1px solid lightgray; 36 | padding: var(--lwc-spacingXxSmall); 37 | } 38 | 39 | lightning-combobox { 40 | min-width: 100%; 41 | } -------------------------------------------------------------------------------- /force-app/main/default/lwc/datatablePicklistField/datatablePicklistField.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /force-app/main/default/lwc/datatable/datatable.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /force-app/main/lwc-utils/lwc/datatableImperativeExample/datatableImperativeExample.js: -------------------------------------------------------------------------------- 1 | import { LightningElement, track } from 'lwc'; 2 | import { getTableRequest } from 'c/tableService'; 3 | 4 | const _defaultQueryString = 'SELECT Id, Name, UserName, Email FROM User'; 5 | const DELAY = 2000; 6 | 7 | export default class DatatableExample extends LightningElement { 8 | @track data; 9 | @track columns; 10 | @track query = _defaultQueryString; 11 | 12 | async connectedCallback() { 13 | await this.fetchTableService(this.query); 14 | } 15 | 16 | async fetchTableService(queryString) { 17 | try { 18 | let tableResults = await getTableRequest({queryString: queryString}); 19 | this.data = tableResults.tableData; 20 | this.columns = tableResults.tableColumns; 21 | } catch (err) { 22 | // eslint-disable-next-line no-console 23 | console.log(err); 24 | } 25 | } 26 | 27 | handleKeyChange(event) { 28 | window.clearTimeout(this.delayTimeout); 29 | this.query = event.target.value; 30 | // eslint-disable-next-line @lwc/lwc/no-async-operation 31 | this.delayTimeout = setTimeout(() => { 32 | this.fetchTableService(this.query); 33 | }, DELAY); 34 | } 35 | 36 | } -------------------------------------------------------------------------------- /force-app/main/default/lwc/jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "experimentalDecorators": true, 4 | "baseUrl": ".", 5 | "paths": { 6 | "c/datatable": [ 7 | "datatable/datatable.js" 8 | ], 9 | "c/datatableBase": [ 10 | "datatableBase/datatableBase.js" 11 | ], 12 | "c/datatablePicklistField": [ 13 | "datatablePicklistField/datatablePicklistField.js" 14 | ], 15 | "c/modal": [ 16 | "modal/modal.js" 17 | ], 18 | "c/relatedList": [ 19 | "relatedList/relatedList.js" 20 | ], 21 | "c/datatableImperativeExample": [ 22 | "../../lwc-utils/lwc/datatableImperativeExample/datatableImperativeExample.js" 23 | ], 24 | "c/lwcUtilsLicense": [ 25 | "../../lwc-utils/lwc/lwcUtilsLicense/lwcUtilsLicense.js" 26 | ], 27 | "c/tableService": [ 28 | "../../lwc-utils/lwc/tableService/tableService.js" 29 | ], 30 | "c/tableServiceUtils": [ 31 | "../../lwc-utils/lwc/tableServiceUtils/tableServiceUtils.js" 32 | ] 33 | } 34 | }, 35 | "include": [ 36 | "**/*", 37 | "../../../../.sfdx/typings/lwc/**/*.d.ts" 38 | ], 39 | "typeAcquisition": { 40 | "include": [ 41 | "jest" 42 | ] 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /force-app/main/lwc-utils/lwc/jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "paths": { 5 | "c/datatable": [ 6 | "../../default/lwc/datatable/datatable.js" 7 | ], 8 | "c/datatableBase": [ 9 | "../../default/lwc/datatableBase/datatableBase.js" 10 | ], 11 | "c/datatablePicklistField": [ 12 | "../../default/lwc/datatablePicklistField/datatablePicklistField.js" 13 | ], 14 | "c/modal": [ 15 | "../../default/lwc/modal/modal.js" 16 | ], 17 | "c/relatedList": [ 18 | "../../default/lwc/relatedList/relatedList.js" 19 | ], 20 | "c/datatableImperativeExample": [ 21 | "datatableImperativeExample/datatableImperativeExample.js" 22 | ], 23 | "c/lwcUtilsLicense": [ 24 | "lwcUtilsLicense/lwcUtilsLicense.js" 25 | ], 26 | "c/tableService": [ 27 | "tableService/tableService.js" 28 | ], 29 | "c/tableServiceUtils": [ 30 | "tableServiceUtils/tableServiceUtils.js" 31 | ] 32 | }, 33 | "experimentalDecorators": true 34 | }, 35 | "include": [ 36 | "**/*", 37 | "../../../../.sfdx/typings/lwc/**/*.d.ts" 38 | ], 39 | "typeAcquisition": { 40 | "include": [ 41 | "jest" 42 | ] 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /sfdx-project.json: -------------------------------------------------------------------------------- 1 | { 2 | "packageDirectories": [ 3 | { 4 | "path": "force-app", 5 | "default": true, 6 | "package": "lwc", 7 | "versionNumber": "0.9.0.NEXT" 8 | } 9 | ], 10 | "namespace": "easydt", 11 | "sfdcLoginUrl": "https://login.salesforce.com", 12 | "sourceApiVersion": "48.0", 13 | "packageAliases": { 14 | "lwc": "0Ho6g000000blMhCAI", 15 | "lwc@0.1.0-1": "04t6g000003flAwAAI", 16 | "lwc@0.1.0-2": "04t6g000003flJHAAY", 17 | "lwc@0.1.0-3": "04t6g000003fpyTAAQ", 18 | "lwc@0.2.0-0": "04t6g000003fpyYAAQ", 19 | "lwc@0.3.0-0": "04t6g000003hxs6AAA", 20 | "lwc@0.4.0-1": "04t6g000004Nw0mAAC", 21 | "lwc@0.4.0-2": "04t6g000004O0wPAAS", 22 | "lwc@0.5.0-0": "04t6g000004O0wUAAS", 23 | "lwc@0.5.0-1": "04t6g000004O28tAAC", 24 | "lwc@0.6.0-0": "04t6g000004OWs4AAG", 25 | "lwc@0.7.0-0": "04t6g000008fW6kAAE", 26 | "lwc@0.8.0-0": "04t6g000008SZyYAAW", 27 | "lwc@0.9.0-0": "04t6g000008SZzRAAW", 28 | "lwc@0.10.0-0": "04t6g000008SZzWAAW", 29 | "lwc@0.11.0-0": "04t6g000008Sa4IAAS", 30 | "lwc@0.12.0-0": "04t6g000008Sa5LAAS", 31 | "lwc@0.13.0-0": "04t6g000008aqdsAAA", 32 | "lwc@0.13.1-0": "04t6g000008aqe7AAA", 33 | "lwc@0.13.2-0": "04t6g000008aqeCAAQ", 34 | "lwc@0.13.3-0": "04t6g000008aqeHAAQ", 35 | "lwc@0.13.4-0": "04t6g000008aqelAAA", 36 | "lwc@0.13.5-0": "04t6g000008aqeqAAA" 37 | } 38 | } -------------------------------------------------------------------------------- /force-app/main/default/lwc/modal/modal.html: -------------------------------------------------------------------------------- 1 | 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2019, jackdough 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /force-app/main/lwc-utils/LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2019, james@sparkworks.io 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | * Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /force-app/main/lwc-utils/lwc/lwcUtilsLicense/lwcUtilsLicense.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /force-app/main/lwc-utils/lwc/tableServiceUtils/tableServiceUtils.js: -------------------------------------------------------------------------------- 1 | const flattenObject = (propName, obj) => { 2 | let flatObject = {}; 3 | for (let prop in obj) { 4 | if (prop) { 5 | //if this property is an object, we need to flatten again 6 | let propIsNumber = isNaN(propName); 7 | let preAppend = propIsNumber ? propName+'_' : ''; 8 | if (typeof obj[prop] == 'object') { 9 | flatObject[preAppend+prop] = {...flatObject, ...flattenObject(preAppend+prop,obj[prop])}; 10 | } else { 11 | flatObject[preAppend+prop] = obj[prop]; 12 | } 13 | } 14 | } 15 | return flatObject; 16 | } 17 | 18 | const flattenQueryResult = (listOfObjects) => { 19 | let finalArr = []; 20 | for (let i=0; i { 40 | let dataClone = JSON.parse(JSON.stringify(flatData)); 41 | for (let row of dataClone) { 42 | Object.keys(row).forEach(key => { 43 | if (key.endsWith('Id')) { 44 | row[key.replace('Id','Link')] = '/'+row[key]; 45 | } 46 | }); 47 | } 48 | return dataClone; 49 | } 50 | 51 | export { 52 | flattenQueryResult, 53 | applyLinks 54 | } -------------------------------------------------------------------------------- /force-app/main/default/flexipages/tst.flexipage-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | region1 5 | Region 6 | 7 | 8 | region2 9 | Region 10 | 11 | 12 | 13 | 14 | fields 15 | Name,Phone,Website 16 | 17 | 18 | filter 19 | 20 | 21 | hideCheckboxColumn 22 | false 23 | 24 | 25 | recordsPerBatch 26 | 50 27 | 28 | 29 | sObject 30 | Account 31 | 32 | 33 | sortedBy 34 | Name 35 | 36 | 37 | sortedDirection 38 | asc 39 | 40 | relatedList 41 | 42 | region3 43 | Region 44 | 45 | tst 46 | 49 | AppPage 50 | 51 | -------------------------------------------------------------------------------- /force-app/main/default/lwc/relatedList/relatedList.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /force-app/main/default/lwc/datatablePicklistField/datatablePicklistField.js: -------------------------------------------------------------------------------- 1 | import { LightningElement, api, track } from 'lwc'; 2 | 3 | // import displayTemplate from './display.html'; 4 | // import editTemplate from './edit.html' 5 | export default class DatatablePicklistField extends LightningElement { 6 | @api rowKeyValue; 7 | @api colKeyValue; 8 | @api 9 | get value() { 10 | return this._value; 11 | } 12 | set value(val) { 13 | this._value = val; 14 | this._editedValue = val; 15 | } 16 | @api 17 | get options() { 18 | return this._options; 19 | } 20 | set options(value) { 21 | if (!Array.isArray(value)) { 22 | throw new Error('Picklist options must be an array'); 23 | } 24 | this._options = value; 25 | this.valueToLabelMap = value.reduce((acc,opt) => { 26 | acc[opt.value] = opt.label; 27 | return acc; 28 | }, {}); 29 | } 30 | @api editable; 31 | 32 | @track editing; 33 | @track _options; 34 | @track _value; 35 | _editedValue; 36 | valueToLabelMap={}; 37 | editRendered; 38 | 39 | renderedCallback() { 40 | const combobox = this.template.querySelector('lightning-combobox'); 41 | if (!combobox) { 42 | this.editRendered = false; 43 | } else if (!this.editRendered ) { 44 | combobox.focus(); 45 | // combobox.click(); 46 | this.editRendered = true; 47 | } 48 | } 49 | handleFocusout() { 50 | if (this._editedValue !== this._value) { 51 | this.dispatchEvent(new CustomEvent('inlineedit', { 52 | detail: { 53 | value: this._editedValue, 54 | rowKeyValue: this.rowKeyValue, 55 | colKeyValue: this.colKeyValue 56 | }, 57 | bubbles: true, 58 | composed: true 59 | })); 60 | } 61 | this.editing=false; 62 | } 63 | 64 | handleEdit() { 65 | this.editing=true; 66 | 67 | } 68 | 69 | handleChange(event) { 70 | this._editedValue = event.detail.value; 71 | this.handleFocusout(); 72 | // this.template.focus(); 73 | } 74 | 75 | get editClass() { 76 | return this.editable ? 'editable ' : ''; 77 | } 78 | 79 | get displayValue() { 80 | return this.valueToLabelMap[this.value] || this.value; 81 | } 82 | 83 | } -------------------------------------------------------------------------------- /force-app/main/default/flexipages/contact.flexipage-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | header 5 | Region 6 | 7 | 8 | 9 | 10 | childRecordField 11 | AccountId 12 | 13 | 14 | fields 15 | Name 16 | 17 | 18 | filter 19 | 20 | 21 | hideCheckboxColumn 22 | false 23 | 24 | 25 | parentRecordField 26 | AccountId 27 | 28 | 29 | recordsPerBatch 30 | 50 31 | 32 | 33 | sObject 34 | Contact 35 | 36 | 37 | showSoql 38 | true 39 | 40 | 41 | sortedBy 42 | Name 43 | 44 | 45 | sortedDirection 46 | asc 47 | 48 | relatedList 49 | 50 | main 51 | Region 52 | 53 | contact 54 | Contact 55 | 58 | RecordPage 59 | 60 | -------------------------------------------------------------------------------- /force-app/main/default/flexipages/test.flexipage-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | header 5 | Region 6 | 7 | 8 | 9 | 10 | childRecordField 11 | AccountId 12 | 13 | 14 | fields 15 | Name,Phone,Website 16 | 17 | 18 | filter 19 | 20 | 21 | editable 22 | true 23 | 24 | 25 | hideCheckboxColumn 26 | false 27 | 28 | 29 | parentRecordField 30 | Id 31 | 32 | 33 | recordsPerBatch 34 | 50 35 | 36 | 37 | sObject 38 | Account 39 | 40 | 41 | sortedBy 42 | Name 43 | 44 | 45 | sortedDirection 46 | asc 47 | 48 | relatedList 49 | 50 | main 51 | Region 52 | 53 | test 54 | Account 55 | 58 | RecordPage 59 | 60 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | lint: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | - uses: bahmutov/npm-install@v1 14 | - name: Run ESLint 15 | run: | 16 | yarn 17 | yarn lint:ci 18 | 19 | jest: 20 | runs-on: ubuntu-latest 21 | steps: 22 | - uses: actions/checkout@v2 23 | - uses: bahmutov/npm-install@v1 24 | - name: Run Jest tests 25 | run: | 26 | yarn 27 | yarn test:ci 28 | - name: Upload code coverage 29 | uses: codecov/codecov-action@v1 30 | with: 31 | token: ${{ secrets.CODECOV_TOKEN }} #required 32 | name: js 33 | 34 | apex_tests: 35 | runs-on: ubuntu-latest 36 | # env: 37 | # scratch_username: ${{ secrets.SFDX_USERNAME }}.listview.ci 38 | 39 | steps: 40 | - uses: actions/checkout@v2 41 | - name: Install Salesforce CLI 42 | run: | 43 | wget https://developer.salesforce.com/media/salesforce-cli/sfdx-linux-amd64.tar.xz 44 | mkdir sfdx-cli 45 | tar xJf sfdx-linux-amd64.tar.xz -C sfdx-cli --strip-components 1 46 | ./sfdx-cli/install 47 | - name: Populate auth file with SFDX_URL secret 48 | run: echo "${{ secrets.SFDX_JWT_KEY }}" > ./server.key 49 | - name: Authenticate Dev Hub with JWT 50 | run: | 51 | sfdx force:auth:jwt:grant \ 52 | --clientid ${{ secrets.SFDX_CONSUMER_KEY }} \ 53 | --jwtkeyfile ./server.key \ 54 | --username ${{ secrets.SFDX_USERNAME }} \ 55 | --setdefaultdevhubusername 56 | - name: Create a new scratch org 57 | run: | 58 | sfdx force:org:create \ 59 | --definitionfile=config/project-scratch-def.json \ 60 | --setdefaultusername \ 61 | --setalias=scratch-org 62 | - name: Push source 63 | run: | 64 | sfdx force:source:push \ 65 | --targetusername=scratch-org \ 66 | --forceoverwrite 67 | - name: Run Apex tests 68 | run: | 69 | sfdx force:apex:test:run \ 70 | --targetusername=scratch-org \ 71 | --codecoverage \ 72 | --synchronous \ 73 | --resultformat=human \ 74 | --outputdir=coverage 75 | - name: Delete scratch org 76 | if: always() 77 | run: | 78 | sfdx force:org:delete \ 79 | --targetusername=scratch-org \ 80 | --noprompt 81 | - name: Upload code coverage 82 | uses: codecov/codecov-action@v1 83 | with: 84 | token: ${{ secrets.CODECOV_TOKEN }} #required 85 | name: apex 86 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # LWC Listview 2 | 3 | [![codecov](https://codecov.io/gh/shliachtx/lwc-listview/branch/master/graph/badge.svg)](https://codecov.io/gh/shliachtx/lwc-listview) 4 | 5 | 6 | _No warranty is provided, express or implied_ 7 | 8 | [Install unlocked package](https://login.salesforce.com/packaging/installPackage.apexp?p0=04t6g000008aqeqAAA) version 0.13.5 9 | 10 | ## Release Notes 11 | ### 0.13.5 12 | - Add the ability to show an icon in the header 13 | - Display errors on datatable 14 | - Block sorting on fields that are not sortable 15 | - Fix issue where newly inserted records would show duplicates if the table was refreshed immediately after creating the new record. 16 | - Fix issues with picklist field. (thanks to @tsalb for his help with this) 17 | ### 0.11.0 18 | - Add `editFieldName` option to datatable - allows for field fronting in edit mode 19 | ### 0.10.0 20 | - Change live data updates to use PushTopic 21 | ### 0.8.0 22 | - Add support for Change Data Capture. 23 | ### 0.7.0 24 | - Fix problem with infinite loading sometimes not working. 25 | ### 0.6.0 26 | - Picklist fields dropdown are auto populated, options will now override the default. Does not support RecordType dependent picklists. 27 | ### 0.4.0 28 | - Picklist fields! The options need to be manually set on the field JSON using the `options` property. Accepts an array of strings or `{label, value}` objects 29 | ### 0.3.0 30 | - Allow custom label on datatable columns 31 | - Fix issue in related list that prevented using a filter string if there was no parent-child relationship set. 32 | ### 0.2.0 33 | - Add option to create a record from a related list 34 | ### 0.1.0 35 | - Add option to edit related list inline 36 | 37 | 38 | ## Dev, Build and Test 39 | 40 | To setup, clone the repository locally, and from the home directory run `$ yarn`. 41 | 42 | To test lwc components locally run `$ yarn:test` with sfdx installed. 43 | 44 | To deploy authorize a dev hub in sfdx and run `$ sfdx force:org:create -f config/project-scratch-def.json -a MyScratchOrg` followed by `$ sfdx force:source:push -u MyScratchOrg` 45 | 46 | 47 | ## Resources 48 | 49 | ## Description of Files and Directories 50 | 51 | ### [datatable](force-app/main/default/lwc/datatable) 52 | Takes as input an sObject and an array of fields and populates a datatable with records from the database. 53 | 54 | Note: Streaming update support utilizes the PushTopic feature, which has a maximum of 50 PushTopic records per org. The datatable uses one for each object type that has live updates enabled. They can be deleted or deactivated if necessary - use `SELECT Id, IsActive FROM PushTopic WHERE Name LIKE 'easydt__%` to retrieve them via SOQL. 55 | 56 | ### [Custom Related List](force-app/main/default/lwc/relatedList) 57 | Related list for use on lightning app and record pages. Choose object, fields, etc. 58 | 59 | Fields accepts a comma separated list of fields, or a JSON list with field information. For more documentation see [datatable](force-app/main/default/lwc/datatable) 60 | 61 | ![](resources/datatable/demo.gif) 62 | 63 | ## Issues 64 | -------------------------------------------------------------------------------- /force-app/main/default/lwc/datatable/__tests__/data/wireTableCache.json: -------------------------------------------------------------------------------- 1 | { 2 | "tableData": [ 3 | { 4 | "Id": "0063F00000IupIYQAZ", 5 | "Name": "United Oil Emergency Generators", 6 | "StageName": "option 2", 7 | "CloseDate": "2019-10-22" 8 | }, 9 | { 10 | "Id": "0063F00000IupIQQAZ", 11 | "Name": "United Oil Installations", 12 | "StageName": "Closed Won", 13 | "CloseDate": "2019-10-28" 14 | }, 15 | { 16 | "Id": "0063F00000IupINQAZ", 17 | "Name": "United Oil Installations", 18 | "StageName": "Negotiation/Review", 19 | "CloseDate": "2019-10-29" 20 | }, 21 | { 22 | "Id": "0063F00000IupIAQAZ", 23 | "Name": "United Oil Office Portable Generators", 24 | "StageName": "Negotiation/Review", 25 | "CloseDate": "2019-11-02" 26 | }, 27 | { 28 | "Id": "0063F00000IupIXQAZ", 29 | "Name": "United Oil Installations", 30 | "StageName": "Closed Won", 31 | "CloseDate": "2019-11-09" 32 | }, 33 | { 34 | "Id": "0063F00000IupIcQAJ", 35 | "Name": "United Oil Plant Standby Generators", 36 | "StageName": "Needs Analysis", 37 | "CloseDate": "2019-11-26" 38 | }, 39 | { 40 | "Id": "0063F00000IupISQAZ", 41 | "Name": "United Oil Refinery Generators", 42 | "StageName": "Closed Won", 43 | "CloseDate": "2019-12-10" 44 | }, 45 | { 46 | "Id": "0063F00000IupIEQAZ", 47 | "Name": "United Oil Refinery Generators", 48 | "StageName": "Proposal/Price Quote", 49 | "CloseDate": "2019-12-17" 50 | }, 51 | { 52 | "Id": "0063F00000IupIFQAZ", 53 | "Name": "United Oil SLA", 54 | "StageName": "Closed Won", 55 | "CloseDate": "2019-12-24" 56 | }, 57 | { 58 | "Id": "0063F00000IupIaQAJ", 59 | "Name": "United Oil Standby Generators", 60 | "StageName": "Closed Won", 61 | "CloseDate": "2019-12-25" 62 | } 63 | ], 64 | "tableColumns": [ 65 | { 66 | "label": "Name", 67 | "type": "url", 68 | "fieldName": "Link", 69 | "typeAttributes": { 70 | "label": { "fieldName": "Name" }, 71 | "target": "_parent" 72 | } 73 | }, 74 | { 75 | "label": "Stage", 76 | "type": "picklist", 77 | "fieldName": "StageName", 78 | "options": [ 79 | { "label": "Prospecting", "value": "Prospecting" }, 80 | { "label": "Qualification", "value": "Qualification" }, 81 | { "label": "Needs Analysis", "value": "Needs Analysis" }, 82 | { "label": "Value Proposition", "value": "Value Proposition" }, 83 | { "label": "Id. Decision Makers", "value": "Id. Decision Makers" }, 84 | { "label": "Perception Analysis", "value": "Perception Analysis" }, 85 | { "label": "Proposal/Price Quote", "value": "Proposal/Price Quote" }, 86 | { "label": "Negotiation/Review", "value": "Negotiation/Review" }, 87 | { "label": "Closed Won", "value": "Closed Won" }, 88 | { "label": "Closed Lost", "value": "Closed Lost" } 89 | ] 90 | }, 91 | { "label": "Close Date", "type": "date-local", "fieldName": "CloseDate" } 92 | ], 93 | "recordCount": 10 94 | } 95 | -------------------------------------------------------------------------------- /force-app/main/default/lwc/relatedList/relatedList.js: -------------------------------------------------------------------------------- 1 | import { LightningElement, api, track, wire } from "lwc"; 2 | import { getRecord, getFieldValue } from "lightning/uiRecordApi"; 3 | import { getObjectInfo } from 'lightning/uiObjectInfoApi'; 4 | 5 | 6 | export default class RelatedList extends LightningElement { 7 | @api showAddButton; 8 | @api modalEdit; 9 | _title; 10 | @api 11 | get title() { 12 | return this._title || (this.objectInfo && this.objectInfo.labelPlural) || this.sObject; 13 | } 14 | set title(value) { 15 | this._title = value; 16 | } 17 | @api recordId; 18 | @api objectApiName 19 | @api sObject; 20 | @api fields; 21 | @api sortedBy; 22 | @api sortedDirection; 23 | _filter; 24 | @api 25 | get filter() { 26 | if (this._filter && this.parentRelationship) { 27 | return this.parentRelationship + ' AND ' + this._filter; 28 | } 29 | return this._filter || this.parentRelationship; 30 | } 31 | set filter(value) { 32 | this._filter = value; 33 | } 34 | @api hideCheckboxColumn; 35 | @api enableInfiniteLoading; 36 | @api recordsPerBatch=50; 37 | @api initialRecords; 38 | @api showSoql; 39 | @api parentRecordField; 40 | @api childRecordField; 41 | @api editable; 42 | @api height; 43 | @api enableLiveUpdates; 44 | @api iconName; 45 | 46 | _parentRecordField; 47 | _childRecordField; 48 | @track objectInfo; 49 | 50 | @wire(getObjectInfo, { objectApiName: '$sObject' }) 51 | wiredObjectInfo({ error, data }) { 52 | if (data) { 53 | this.objectInfo = data; 54 | } else if (error) { 55 | this.error(error.statusText + ': ' + error.body.message); 56 | } 57 | } 58 | 59 | 60 | @wire(getRecord, { recordId: "$recordId", fields: "$fullParentRecordField" }) 61 | parentRecord; 62 | 63 | get fullParentRecordField() { 64 | return this.objectApiName + "." + this.parentRecordField; 65 | } 66 | 67 | get parentRecordId() { 68 | if (!this.parentRecordField) { 69 | return undefined; 70 | } else if (this.parentRecord && this.parentRecord.data) { 71 | return getFieldValue(this.parentRecord.data, this.fullParentRecordField); 72 | } 73 | return ""; 74 | } 75 | 76 | get parentRelationship() { 77 | if (this.childRecordField && typeof this.parentRecordId !== undefined) { 78 | return `${this.childRecordField}='${this.parentRecordId}'`; 79 | } 80 | return ""; 81 | } 82 | 83 | // used to render datatable. Important because otherwise, initial query can be very inefficient and cause slow load times 84 | get ready() { 85 | return !!this.parentRecordId || !this.parentRecordField; 86 | } 87 | 88 | refresh() { 89 | this.template.querySelector('c-datatable').refresh(); 90 | } 91 | get customStyle() { 92 | if (this.height) { 93 | return 'height:'+this.height+'px'; 94 | } 95 | return ''; 96 | } 97 | 98 | get addRecordTitle() { 99 | return this.objectInfo ? 'New ' + this.objectInfo.label : 'Loading...'; 100 | } 101 | 102 | createNew() { 103 | this.template.querySelector('c-modal').open(); 104 | } 105 | handleCancel() { 106 | this.template.querySelector('c-modal').close(); 107 | } 108 | handleSuccess() { 109 | this.template.querySelector('c-datatable').refresh(); 110 | this.template.querySelector('c-modal').close(); 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /force-app/main/default/lwc/datatable/README.md: -------------------------------------------------------------------------------- 1 | # datatable 2 | ## Description 3 | Takes as input an sObject and an array of fields and populates a datatable with records from the database. 4 | 5 | ### Fields: 6 | 7 | { 8 | fieldName (required),, 9 | editFieldName (name of field to save when editing), 10 | label (defaults to field label), 11 | searchable (defaults to true on text fields), 12 | sortable (defaults to true), 13 | visible (defaults to true), 14 | editable (defaults to table setting), 15 | options (array of options for picklist - Array of strings or objects with `label` and `value`) 16 | } 17 | 18 | Notes one `editFieldName`: This is designed for use with a calculated (formula) field that fronts field in Salesforce. Use carefully! It may not be obvious to users that they are editing a different field. Additionally, all the metadata comes from the original field - not the edit field! So make sure that they are the same type. 19 | 20 | 21 | #### Example: 22 | 23 | [ 24 | { 25 | "fieldName": "Name" 26 | }, 27 | { 28 | "fieldName": "Phone", 29 | "sortable": false, 30 | "editable": true 31 | }, 32 | { 33 | "fieldName": "Account.Website", 34 | "label": "Website" 35 | } 36 | ] 37 | 38 | ### Row Actions: 39 | Contains a label and a callback that does something with the selected row. The callback should take an input of a row object and return a promise. If the return value is `false` the row will be deleted from the datatable, otherwise the row will be updated with return value of the promise (if there is one). 40 | 41 | { 42 | label, 43 | callback 44 | } 45 | 46 | 47 | ## Properties 48 | Name | Type |Read only | Required | Description | Default value 49 | ---|---|---|---|---|--- 50 | `s-object`|string||✔| name of Salesforce object 51 | `fields`|array||✔|fields to display. Optionally a comma separated list of fields 52 | `sorted-by`|string||✔|field to sort table by 53 | `sorted-direction`|string|||`asc` or `desc`|`asc` 54 | `editable`|boolean|||make the entire table editable (by default). this can also be set on the field level|`false` 55 | `filter`|string|||string to filter by - excluding the where clause. e.g. `Name='Bob' AND Total_Donations__c > 1000` 56 | `search`|string|||text to search in all searchable text fields 57 | `row-actions`|array or function|||array of row actions to display on each row. optionally a function which takes as input the rwo of the datable and returns an array of row actions 58 | `hide-checkbox-column`|boolean|||hide checkboxes from table (disable row selection) 59 | `enable-infinite-loading`|boolean|||automatically load more records when user reaches the end of the datatable|`false` 60 | `records-per-batch`|integer|||number of records to load when the end of the datable is reached|`50` 61 | `initial-records`|integer|||number of records to load initially|`this.recordsPerBatch` 62 | `enableLiveUpdates`|boolean|||update records using PushTopic|`false` 63 | `selected-rows`|array|✔||array of selected IDs from datatable 64 | `query`|string|✔||generated query string used to retrieve data 65 | `record-count`|integer|✔||total number of records returned by current query 66 | 67 | ## Methods 68 | Name | Parameters | Return | Description 69 | ---|---|---|--- 70 | `refresh`|||refresh the data in the datatable using the current fields and filters 71 | `clearSelection`|||clear all selected rows 72 | 73 | ## Events 74 | Name|Detail|Bubbles 75 | ---|---|--- 76 | `rowselection`| `{ selectedRows }` 77 | `loaddata`| `{ recordCount, sortedDirection, sortedBy }` -------------------------------------------------------------------------------- /force-app/main/default/lwc/datatable/datatableUtils.js: -------------------------------------------------------------------------------- 1 | const addFieldMetadata = (columns, fieldOptions) => { 2 | return JSON.parse(JSON.stringify(columns)) 3 | .map(col => { 4 | let fieldName = col.fieldName; 5 | if (fieldName.endsWith('Link')) { // special case for salesforce relationship fields (this will not work for custom relationships) 6 | fieldName = fieldName.replace('_Link', '.Name'); 7 | fieldName = fieldName.replace('Link', 'Name') 8 | } 9 | let field = fieldOptions.find(f => (f.fieldName === fieldName)); 10 | if (field) { // copy values from fields list to columns list 11 | // col.sortable = field.sortable; 12 | // col.visible = field.visible; 13 | // col.editable = field.editable; 14 | // col.label = field.label || col.label; 15 | Object.assign(col,field); 16 | col.typeAttributes = col.typeAttributes || {}; 17 | col.typeAttributes.editable = field.editable; 18 | col.typeAttributes.options = field.options || col.options || []; 19 | } 20 | return col; 21 | }) 22 | .filter(col => col.visible); 23 | }; 24 | 25 | const addObjectInfo = (columns, objectInfo) => { 26 | return columns.map(col => { 27 | let fieldName = col.fieldName; 28 | if (!fieldName) { 29 | return col; 30 | } 31 | if (fieldName.endsWith('Link')) { // special case for salesforce relationship fields (this will not work for custom relationships) 32 | fieldName = fieldName.replace('_Link', '.Name'); 33 | fieldName = fieldName.replace('Link', 'Name') 34 | } 35 | let fieldInfo = objectInfo && objectInfo.fields && objectInfo.fields[fieldName]; 36 | if (fieldInfo) { 37 | col.sortable = fieldInfo.sortable && col.sortable; // field is not sortable if 38 | } 39 | return col; 40 | }); 41 | } 42 | 43 | const addRowActions = (columns, rowActions) => { 44 | if (rowActions && rowActions.length || typeof rowActions === 'function') { 45 | columns.push({ 46 | type: 'action', 47 | typeAttributes: { 48 | rowActions: rowActions 49 | } 50 | }); 51 | } 52 | return columns; 53 | }; 54 | 55 | const getNumberOfRecordsToLoad = (numberOfLoadedRecords, recordsPerBatch, maxRecords) => { 56 | if ((recordsPerBatch + numberOfLoadedRecords) <= maxRecords) { 57 | return recordsPerBatch; 58 | } 59 | return maxRecords - numberOfLoadedRecords; 60 | } 61 | 62 | const createFieldArrayFromString = (value) => { 63 | let fields; 64 | if (value.substring(0,1)==='[') { 65 | fields = JSON.parse(value); 66 | } else { 67 | fields = value.split(',') 68 | .map(field => { 69 | return { 70 | fieldName: field.trim() 71 | }; 72 | }); 73 | } 74 | return fields; 75 | } 76 | 77 | const addDefaultFieldValues = (fields, editable) => { 78 | return fields.map(field => { 79 | if (!field.fieldName) throw new Error('Field must have a valid `fieldName` property'); 80 | 81 | if (typeof field.visible === 'undefined') 82 | field.visible = true; // default true 83 | else 84 | field.visible = !!field.visible; // convert to boolean 85 | 86 | if (typeof field.sortable === 'undefined') 87 | field.sortable = true; // default true 88 | else 89 | field.sortable = !!field.sortable; // convert to boolean 90 | 91 | if (typeof field.editable === 'undefined') { 92 | field.editable = ( 93 | !!field.editFieldName || // if editFieldName is set, assume field is editable 94 | field.fieldName === 'StageName' || 95 | !field.fieldName.endsWith('Name') // && 96 | // !field.fieldName.endsWith('Link') && 97 | // !field.fieldName.endsWith('Id') 98 | ) && editable; // default to global setting 99 | } else { 100 | field.editable = !!field.editable; // convert to boolean 101 | } 102 | 103 | if (field.options && 104 | Array.isArray(field.options) && 105 | field.options.every(opt=> typeof opt === 'string')) { 106 | 107 | field.options = field.options.map(opt => {return {label: opt, value: opt}}); 108 | 109 | } 110 | return field; 111 | }); 112 | } 113 | 114 | export { 115 | addFieldMetadata, 116 | addObjectInfo, 117 | addRowActions, 118 | getNumberOfRecordsToLoad, 119 | createFieldArrayFromString, 120 | addDefaultFieldValues 121 | }; -------------------------------------------------------------------------------- /force-app/main/default/lwc/relatedList/relatedList.js-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 47.0 4 | true 5 | Custom Related List 6 | 7 | lightning__AppPage 8 | lightning__HomePage 9 | lightning__RecordPage 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | -------------------------------------------------------------------------------- /force-app/main/lwc-utils/classes/DataTableServiceTests.cls: -------------------------------------------------------------------------------- 1 | @isTest 2 | private class DataTableServiceTests { 3 | 4 | @isTest 5 | static void test_missing_query() { 6 | Map tableServiceRequest = new Map(); 7 | Map tableServiceResponse = new Map(); 8 | String errorMessage; 9 | 10 | Test.startTest(); 11 | try { 12 | tableServiceResponse = DataTableService.getTableCache(tableServiceRequest); 13 | } catch (Exception e) { 14 | errorMessage = e.getMessage(); 15 | } 16 | Test.stopTest(); 17 | 18 | System.debug('test_missing_query errorMessage is: '+errorMessage); 19 | System.assert(tableServiceResponse.isEmpty()); 20 | System.assert(!tableServiceResponse.containsKey(DataTableService.TABLE_DATA_KEY)); 21 | System.assert(!tableServiceResponse.containsKey(DataTableService.TABLE_COLUMNS_KEY)); 22 | System.assert(String.isNotEmpty(errorMessage)); 23 | } 24 | 25 | @isTest 26 | static void test_query_no_where_filter() { 27 | Map tableServiceRequest = new Map(); 28 | Map tableServiceResponse = new Map(); 29 | String queryString = 'SELECT Id, Name, Email FROM User'; 30 | 31 | tableServiceRequest.put(DataTableService.QUERY_STRING_KEY, queryString); 32 | 33 | Test.startTest(); 34 | tableServiceResponse = DataTableService.getTableCache(tableServiceRequest); 35 | Test.stopTest(); 36 | 37 | System.assert(!tableServiceResponse.isEmpty()); 38 | System.assert(tableServiceResponse.containsKey(DataTableService.TABLE_DATA_KEY)); 39 | System.assert(tableServiceResponse.containsKey(DataTableService.TABLE_COLUMNS_KEY)); 40 | 41 | List users = (List) tableServiceResponse.get(DataTableService.TABLE_DATA_KEY); 42 | System.assert(!users.isEmpty()); 43 | 44 | List> columns = (List>) tableServiceResponse.get(DataTableService.TABLE_COLUMNS_KEY); 45 | System.assertEquals(2, columns.size()); // Id is parsed out 46 | } 47 | 48 | @isTest 49 | static void test_query_has_advanced_where_filter() { 50 | Map tableServiceRequest = new Map(); 51 | Map tableServiceResponse = new Map(); 52 | String queryString = 'SELECT Id, Name FROM User WHERE Name LIKE \'%BlahBlahBlah%\''; 53 | 54 | tableServiceRequest.put(DataTableService.QUERY_STRING_KEY, queryString); 55 | 56 | Test.startTest(); 57 | tableServiceResponse = DataTableService.getTableCache(tableServiceRequest); 58 | Test.stopTest(); 59 | 60 | List users = (List) tableServiceResponse.get(DataTableService.TABLE_DATA_KEY); 61 | System.assertEquals(0, users.size()); 62 | 63 | List> columns = (List>) tableServiceResponse.get(DataTableService.TABLE_COLUMNS_KEY); 64 | System.assertEquals(1, columns.size()); // Id is parsed out 65 | } 66 | 67 | @isTest 68 | static void test_query_has_where_bind_var() { 69 | Map bindVars = new Map(); // This is the datatype for a bind var 70 | Map tableServiceRequest = new Map(); 71 | Map tableServiceResponse = new Map(); 72 | 73 | // Build our binding var, assuming this is coming form somewhere in the UI or user interaction 74 | Id userId = UserInfo.getUserId(); 75 | Set idSet = new Set(); 76 | idSet.add(userId); 77 | bindVars.put(DataTableService.ID_SET_KEY, idSet); 78 | 79 | // This is the expected format for a soql using the bind var key 80 | String queryString = 'SELECT Id, Name, Email FROM User WHERE Id =: idSet'; 81 | 82 | tableServiceRequest.put(DataTableService.QUERY_STRING_KEY, queryString); 83 | tableServiceRequest.put(DataTableService.BIND_VAR_KEY, bindVars); 84 | 85 | Test.startTest(); 86 | tableServiceResponse = DataTableService.getTableCache(tableServiceRequest); 87 | Test.stopTest(); 88 | 89 | System.assert(!tableServiceResponse.isEmpty()); 90 | System.assert(tableServiceResponse.containsKey(DataTableService.TABLE_DATA_KEY)); 91 | System.assert(tableServiceResponse.containsKey(DataTableService.TABLE_COLUMNS_KEY)); 92 | 93 | List users = (List) tableServiceResponse.get(DataTableService.TABLE_DATA_KEY); 94 | System.assertEquals(1, users.size()); 95 | System.assertEquals(userId, users[0].Id); 96 | 97 | List> columns = (List>) tableServiceResponse.get(DataTableService.TABLE_COLUMNS_KEY); 98 | System.assertEquals(2, columns.size()); // Id is parsed out 99 | } 100 | 101 | @isTest 102 | static void test_query_has_where_bind_var_testing_soql_injection() { 103 | Map tableServiceRequest = new Map(); 104 | Map tableServiceResponse = new Map(); 105 | String userIdString = (String)UserInfo.getUserId(); 106 | String queryString = 'SELECT Id, Name FROM User WHERE Id = \''+userIdString+'\''; 107 | 108 | tableServiceRequest.put(DataTableService.QUERY_STRING_KEY, queryString); 109 | 110 | Test.startTest(); 111 | tableServiceResponse = DataTableService.getTableCache(tableServiceRequest); 112 | Test.stopTest(); 113 | 114 | List users = (List) tableServiceResponse.get(DataTableService.TABLE_DATA_KEY); 115 | System.assertEquals(1, users.size()); 116 | } 117 | 118 | @isTest 119 | static void test_query_with_typo_has_exception_msg() { 120 | Map tableServiceRequest = new Map(); 121 | Map tableServiceResponse = new Map(); 122 | String queryString = 'SELECT Id, Name, Email FROM Fake_SObject__c'; 123 | String errorMessage; 124 | 125 | tableServiceRequest.put(DataTableService.QUERY_STRING_KEY, queryString); 126 | 127 | Test.startTest(); 128 | try { 129 | tableServiceResponse = DataTableService.getTableCache(tableServiceRequest); 130 | } catch (Exception e) { 131 | errorMessage = e.getMessage(); 132 | } 133 | Test.stopTest(); 134 | 135 | System.debug('test_query_with_typo_has_exception_msg errorMessage is: '+errorMessage); 136 | System.assert(tableServiceResponse.isEmpty()); 137 | System.assert(!tableServiceResponse.containsKey(DataTableService.TABLE_DATA_KEY)); 138 | System.assert(!tableServiceResponse.containsKey(DataTableService.TABLE_COLUMNS_KEY)); 139 | System.assert(String.isNotEmpty(errorMessage)); 140 | } 141 | 142 | @isTest 143 | static void test_bind_var_set_up_incorrectly_has_exception_msg() { 144 | Map bindVars = new Map(); 145 | Map tableServiceRequest = new Map(); 146 | Map tableServiceResponse = new Map(); 147 | String errorMessage; 148 | 149 | // Build our binding var INCORRECTLY 150 | Id userId = UserInfo.getUserId(); 151 | String idSet = userId; 152 | bindVars.put(DataTableService.ID_SET_KEY, idSet); 153 | 154 | // This is the expected format for a soql using the bind var key 155 | String queryString = 'SELECT Id, Name FROM User WHERE Id =: idSet'; 156 | 157 | tableServiceRequest.put(DataTableService.QUERY_STRING_KEY, queryString); 158 | tableServiceRequest.put(DataTableService.BIND_VAR_KEY, bindVars); 159 | 160 | Test.startTest(); 161 | try { 162 | tableServiceResponse = DataTableService.getTableCache(tableServiceRequest); 163 | } catch (Exception e) { 164 | errorMessage = e.getMessage(); 165 | } 166 | Test.stopTest(); 167 | 168 | System.debug('test_bind_var_set_up_incorrectly_has_exception_msg errorMessage is: '+errorMessage); 169 | System.assert(tableServiceResponse.isEmpty()); 170 | System.assert(!tableServiceResponse.containsKey(DataTableService.TABLE_DATA_KEY)); 171 | System.assert(!tableServiceResponse.containsKey(DataTableService.TABLE_COLUMNS_KEY)); 172 | System.assert(String.isNotEmpty(errorMessage)); 173 | } 174 | 175 | } -------------------------------------------------------------------------------- /force-app/main/lwc-utils/classes/DataTableService.cls: -------------------------------------------------------------------------------- 1 | /************************************************************* 2 | * @author: James Hou, james@sparkworks.io 3 | **************************************************************/ 4 | 5 | public with sharing class DataTableService { 6 | 7 | // tableRequest 8 | public static final String QUERY_STRING_KEY = 'queryString'; 9 | public static final String BIND_VAR_KEY = 'bindVars'; 10 | public static final String ID_SET_KEY = 'idSet'; 11 | 12 | // tableCache 13 | public static final String TABLE_DATA_KEY = 'tableData'; 14 | public static final String TABLE_COLUMNS_KEY = 'tableColumns'; 15 | 16 | // lightning-datatable type translation map 17 | // https://developer.salesforce.com/docs/atlas.en-us.apexcode.meta/apexcode/apex_enum_Schema_DisplayType.htm 18 | // https://developer.salesforce.com/docs/component-library/bundle/lightning-datatable/documentation 19 | public static final Map DISPLAY_TYPE_TO_DATATABLE_TYPE_MAP = new Map{ 20 | Schema.DisplayType.address => 'text', 21 | Schema.DisplayType.anytype => 'text', 22 | Schema.DisplayType.base64 => 'text', 23 | Schema.DisplayType.Boolean => 'boolean', 24 | Schema.DisplayType.Combobox => 'text', 25 | Schema.DisplayType.Currency => 'currency', 26 | Schema.DisplayType.Date => 'date-local', // my preference 27 | Schema.DisplayType.DateTime => 'date-local', // my preference 28 | Schema.DisplayType.Double => 'number', 29 | Schema.DisplayType.Email => 'email', 30 | Schema.DisplayType.ID => 'text', 31 | Schema.DisplayType.Integer => 'number', 32 | Schema.DisplayType.MultiPicklist => 'text', 33 | Schema.DisplayType.Percent => 'percent', 34 | Schema.DisplayType.Phone => 'phone', 35 | Schema.DisplayType.Picklist => 'picklist', 36 | Schema.DisplayType.Reference => 'url', 37 | Schema.DisplayType.String => 'text', 38 | Schema.DisplayType.TextArea => 'text', 39 | Schema.DisplayType.Time => 'text', 40 | Schema.DisplayType.URL => 'url' 41 | }; 42 | 43 | /** 44 | * Experimental wire for creating tableCache 45 | * For now, new POJO passed through @wire aren't triggering change event unless as JSON. 46 | */ 47 | @AuraEnabled (cacheable=true) 48 | public static Map wireTableCache(String tableRequest) { 49 | Map request = (Map)JSON.deserializeUntyped(tableRequest); 50 | Map result = DataTableService.getTableCache(request); 51 | 52 | String filter = (String)request.get('filter'); 53 | String q = 'SELECT count() FROM ' + (String)request.get('sObject') + filter; 54 | result.put('recordCount', Database.countQuery(q)); 55 | 56 | return result; 57 | } 58 | 59 | 60 | /** 61 | * Creates a lightning-datatable ready object keys: 62 | * tableData and tableColumns can be used as attributes directly clientside. 63 | * @param tableRequest [Object with configs, see DataTableService.cmp] 64 | * @return [Object with tableCache.tableData, tableCache.tableColumns] 65 | */ 66 | @AuraEnabled 67 | public static Map getTableCache(Map tableRequest) { 68 | if (!tableRequest.containsKey(QUERY_STRING_KEY)) { 69 | throw new AuraHandledException('Missing Query.'); 70 | } 71 | // Configurations 72 | Map tableServiceResponse = new Map(); 73 | // Derived Data 74 | List tableData = DataTableService.getSObjectData(tableRequest); 75 | // Derived Columns 76 | String queryString = (String)tableRequest.get(QUERY_STRING_KEY); 77 | String sObjectName = queryString.substringAfterLast(' FROM ').split(' ').get(0); // don't depend on if there is a WHERE, also in case FROM is in a field name 78 | SObject queryObject = Schema.getGlobalDescribe().get(sObjectName).newSObject(); 79 | 80 | tableServiceResponse.put(TABLE_DATA_KEY, tableData); 81 | tableServiceResponse.put(TABLE_COLUMNS_KEY, DataTableService.getColumnData(queryString, queryObject)); 82 | return tableServiceResponse; 83 | } 84 | 85 | /** 86 | * Routing method to see if there are any Binding Variables (BIND_VAR_KEY) to scope the dynamic query 87 | * @param tableRequest [Object with configs] 88 | */ 89 | private static List getSObjectData(Map tableRequest) { 90 | if (tableRequest.get(BIND_VAR_KEY) == null) { 91 | return DataTableService.getSObjectDataFromQueryString((String)tableRequest.get(QUERY_STRING_KEY)); 92 | } else { 93 | return DataTableService.getSObjectDataFromQueryString((String)tableRequest.get(QUERY_STRING_KEY), tableRequest.get(BIND_VAR_KEY)); 94 | } 95 | } 96 | 97 | /** 98 | * No dynamic binding vars, returns everything specific directly from SOQL string 99 | * @param queryString [Dynamic SOQL string] 100 | * @return [List of dynamically queried SObjects] 101 | */ 102 | private static List getSObjectDataFromQueryString(String queryString) { 103 | String.escapeSingleQuotes(queryString); 104 | try { 105 | System.debug('getSObjectDataFromQueryString queryString is: '+queryString); 106 | return Database.query(queryString); 107 | } catch (Exception e) { 108 | throw new AuraHandledException(e.getMessage()); 109 | } 110 | } 111 | 112 | /** 113 | * Contains dynamic binding vars, returns everything bound to the dynamic variable 114 | * @param queryString [Dynamic SOQL string] 115 | * @param orderedBindVars [Currently only an ID_SET_KEY, containing a list of sObject Ids to scope the query] 116 | * @return [List of dynamically queried SObjects scoped by some BIND_VAR] 117 | */ 118 | private static List getSObjectDataFromQueryString(String queryString, Object orderedBindVars) { 119 | Set idSet = new Set(); 120 | System.debug('getSObjectDataFromQueryString orderedBindVars '+orderedBindVars); 121 | 122 | Map reconstructedBindVars = (Map)JSON.deserializeUntyped(JSON.serialize(orderedBindVars)); 123 | 124 | if (reconstructedBindVars.get(ID_SET_KEY) != null) { 125 | List idList = (List) JSON.deserialize( 126 | JSON.serialize( 127 | reconstructedBindVars.get(ID_SET_KEY) 128 | ), 129 | List.class 130 | ); 131 | for (String sObjectId : idList) { 132 | idSet.add(sObjectId.trim()); 133 | } 134 | } 135 | try { 136 | return Database.query(queryString); 137 | } catch (Exception e) { 138 | throw new AuraHandledException(e.getMessage()); 139 | } 140 | } 141 | 142 | /** 143 | * Creates lightning-datatable ready tableColumns using the queryString and the queried object's schema. 144 | * @param queryString [Dynamic SOQL String, to parse out fields] 145 | * @param queriedSObject [To grab full schema of fields, primarily for labels] 146 | * @return [List of individual tableColumn, i.e. tableColumns] 147 | */ 148 | private static List> getColumnData(String queryString, SObject queriedSObject) { 149 | String soqlFields = queryString.subString(queryString.indexOfIgnoreCase('select') + 6, queryString.indexOfIgnoreCase('from')).trim(); 150 | List soqlColumns = soqlFields.split('[,]{1}[\\s]?'); // sanitizes the spacing between commas 151 | List> tableColumns = new List>(); 152 | Map fieldMap = queriedSObject.getSObjectType().getDescribe().fields.getMap(); 153 | 154 | for (String fieldName : soqlColumns){ 155 | Schema.DescribeFieldResult field; 156 | Map fieldColumn = new Map(); 157 | 158 | // History tables have this field, ignore this one 159 | if (fieldname == 'created') { 160 | continue; 161 | } 162 | 163 | // Handles parent relationships, to a degree 164 | if (fieldName.contains('.')) { 165 | String parentReference = fieldName.contains('__r') 166 | ? fieldName.substringBeforeLast('__r.') + '__c' // custom objects 167 | : fieldName.substringBeforeLast('.') + 'Id'; // standard objects typical schema 168 | Schema.SObjectType referenceTo = fieldMap.get(parentReference).getDescribe().getReferenceTo().get(0); 169 | field = referenceTo.getDescribe().fields.getMap().get(fieldName.substringAfterLast('.')).getDescribe(); 170 | } else { 171 | field = fieldMap.get(fieldName).getDescribe(); 172 | } 173 | System.debug('getColumnData field info: '+fieldName+' : '+field.getType()); 174 | 175 | // Minor validations 176 | if ( 177 | field.getType() == Schema.DisplayType.ID // IDs are usually keyFields, so we skip display of this 178 | || field.getType() == Schema.DisplayType.Reference // References are lookups, need granular formatting so left to UI implementation 179 | || !field.isAccessible() // Respect FLS 180 | ) { 181 | continue; 182 | } 183 | 184 | // Defaults. Can be overridden as needed 185 | String flatFieldName = fieldName.contains('.') 186 | ? fieldName.replace('.', '_') // parent fields handled by clientside flattener 187 | : fieldName; 188 | fieldColumn.put('label', field.getLabel()); 189 | fieldColumn.put('type', DISPLAY_TYPE_TO_DATATABLE_TYPE_MAP.get(field.getType())); 190 | fieldColumn.put('fieldName', flatFieldName); 191 | 192 | if (field.getType() == Schema.DisplayType.PICKLIST) { 193 | Schema.PicklistEntry[] picklistEntries = field.getPicklistValues(); 194 | Object[] picklistValues = new Object[]{}; 195 | for (Schema.PicklistEntry pe : picklistEntries) { 196 | if (pe.isActive()) { 197 | Map pv = new Map(); 198 | pv.put('label',pe.getLabel()); 199 | pv.put('value',pe.getValue()); 200 | picklistValues.add(pv); 201 | } 202 | } 203 | fieldColumn.put('options', picklistValues); 204 | } 205 | 206 | // Fields with Name are typically hyperlinked 207 | if ( 208 | fieldName.equalsIgnoreCase('name') 209 | || fieldName.substringAfterLast('.').equalsIgnoreCase('name') 210 | ) { 211 | // Override previous key 212 | fieldColumn.put('type', DISPLAY_TYPE_TO_DATATABLE_TYPE_MAP.get(Schema.DisplayType.Reference)); 213 | 214 | // Override the fieldName with a url to the id 215 | fieldColumn.put('fieldName', flatFieldName.replace('Name', 'Link')); 216 | 217 | // For the field label, given by URL typeAttribute 218 | Map typeAttributes = new Map(); 219 | typeAttributes.put('label', new Map{'fieldName' => flatFieldName}); // neat trick to pass reference to an existing column 220 | typeAttributes.put('target', '_parent'); 221 | fieldColumn.put('typeAttributes', typeAttributes); 222 | } 223 | 224 | // Finally 225 | tableColumns.add(fieldColumn); 226 | } 227 | return tableColumns; 228 | } 229 | 230 | @AuraEnabled 231 | public static String getPushTopic(String sObjectApiName) { 232 | String name = 'easydt__'+sObjectApiName; 233 | String query = 'SELECT Id FROM ' + sObjectApiName; 234 | PushTopic pt; 235 | PushTopic[] pushTopics = [SELECT Id,Name,IsActive FROM PushTopic WHERE Name=:name LIMIT 1]; 236 | if (pushTopics.size() > 0) { 237 | pt = pushTopics[0]; 238 | 239 | if (!pt.IsActive) { 240 | pt.IsActive = true; 241 | update pt; 242 | } 243 | } else { 244 | pt = new PushTopic(); 245 | pt.Name=name; 246 | pt.ApiVersion=48; 247 | pt.NotifyForOperationCreate=true; 248 | pt.NotifyForOperationUpdate=true; 249 | pt.NotifyForOperationDelete=true; 250 | pt.NotifyForFields='All'; 251 | pt.Query=query; 252 | 253 | insert pt; 254 | } 255 | 256 | return '/topic/' + pt.Name; 257 | } 258 | 259 | } -------------------------------------------------------------------------------- /force-app/main/default/lwc/datatable/datatable.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | import { LightningElement, track, api, wire } from "lwc"; 3 | import { ShowToastEvent } from "lightning/platformShowToastEvent"; 4 | import { getObjectInfo } from "lightning/uiObjectInfoApi"; 5 | import { updateRecord } from "lightning/uiRecordApi"; 6 | import { refreshApex } from "@salesforce/apex"; 7 | import wireTableCache from "@salesforce/apex/DataTableService.wireTableCache"; 8 | import getTableCache from "@salesforce/apex/DataTableService.getTableCache"; 9 | import getPushTopic from "@salesforce/apex/DataTableService.getPushTopic"; 10 | import * as tableUtils from "c/tableServiceUtils"; 11 | import * as datatableUtils from "./datatableUtils"; 12 | import { loadStyle } from 'lightning/platformResourceLoader'; 13 | import datatablePicklistUrl from '@salesforce/resourceUrl/datatablePicklist' 14 | 15 | import { 16 | subscribe, 17 | unsubscribe, 18 | // onError, 19 | // setDebugFlag, 20 | // isEmpEnabled, 21 | } from "lightning/empApi"; 22 | 23 | // import getTableRequest from 'c/tableService'; 24 | 25 | // import * as filters from 'force/filterLogicLibrary'; 26 | 27 | // const _defaultQueryString = 'SELECT Id, Name, UserName, Email FROM User'; 28 | // const DELAY = 2000; 29 | 30 | export default class Datatable extends LightningElement { 31 | renderedCallback() { 32 | if (!this.rendered) { 33 | loadStyle(this, datatablePicklistUrl); 34 | this.rendered = true; 35 | } 36 | } 37 | rendered = false; 38 | 39 | /*** 40 | * See README.md 41 | ***/ 42 | // _wiredResults; 43 | lastEventId = -1; 44 | lastRefreshTime = -1; 45 | subscription; 46 | wiredResults; 47 | _sObject = ""; 48 | _filter = ""; 49 | _search = ""; 50 | _offset = 0; 51 | _maxRecords; 52 | _initialRecords; 53 | _recordsPerBatch; 54 | _recordCount; 55 | _tableRequest = ""; 56 | objectInfo; 57 | @track errors = { 58 | rows:{} 59 | }; 60 | @track _sortedDirection = "asc"; 61 | @track _sortedBy = ""; 62 | @track _enableInfiniteLoading; 63 | @track _selectedRows = []; 64 | @track _isLoading = true; 65 | @track isLoadingMore = false; 66 | @track draftValues = []; 67 | @track data; 68 | @track _columns; 69 | @track 70 | _fields = []; /*[ // default value - sample. either way we need to document the sample 71 | // id fields are ignored 72 | { fieldName: 'Name', sortable: true, sorted: true, searchable: true, visible: true, sortDirection: 'asc' }, 73 | { fieldName: 'Account.Name', searchable: true, sortable: true} 74 | ];*/ 75 | @api maxRecords = 2000; 76 | @api recordsPerBatch = 50; 77 | @api editable; 78 | @api showSoql; 79 | @api 80 | get enableLiveUpdates() { 81 | return this._enableLiveUpdates; 82 | } 83 | set enableLiveUpdates(value) { 84 | this._enableLiveUpdates = value; 85 | // eslint-disable-next-line no-self-assign 86 | this.sObject = this.sObject; 87 | } 88 | 89 | get sortedByFormatted() { 90 | let name = this._sortedBy; 91 | if (name.endsWith("Name")) { 92 | // special case for salesforce relationship fields (this will not work for custom relationships) 93 | name = name.replace(".Name", "_Id"); 94 | name = name.replace("Name", "Id"); 95 | } 96 | return name; 97 | } 98 | 99 | @api 100 | get sortedBy() { 101 | return this._sortedBy; 102 | } 103 | set sortedBy(value) { 104 | this._sortedBy = value; 105 | this.tableRequest = "reset"; 106 | } 107 | 108 | @api 109 | get sortedDirection() { 110 | return this._sortedDirection; 111 | } 112 | set sortedDirection(value) { 113 | this._sortedDirection = value; 114 | this.tableRequest = "reset"; 115 | } 116 | 117 | @api 118 | get initialRecords() { 119 | return this._initialRecords || this.recordsPerBatch; 120 | } 121 | set initialRecords(value) { 122 | this._initialRecords = value; 123 | } 124 | 125 | @api enableInfiniteLoading; 126 | 127 | @api hideCheckboxColumn = false; 128 | @api 129 | get recordCount() { 130 | return this._recordCount; 131 | } 132 | @api 133 | get search() { 134 | return this._search; 135 | } 136 | set search(value) { 137 | this._tableRequest = this.tableRequest; 138 | this._search = value; 139 | this.tableRequest = "reset"; 140 | } 141 | 142 | @api // sObject; 143 | get sObject() { 144 | return this._sObject; 145 | } 146 | set sObject(value) { 147 | this._sObject = value; 148 | if (this.enableLiveUpdates) { 149 | getPushTopic({ sObjectApiName: value }) 150 | .then( (channelName) => { 151 | this.channelName = channelName 152 | // if (isEmpEnabled) { 153 | return this.pushTopicSubscribe(channelName) 154 | // } 155 | // return undefined; 156 | }); 157 | } 158 | 159 | this.tableRequest = "reset"; 160 | } 161 | @api // filter; 162 | get filter() { 163 | return this._filter; 164 | } 165 | set filter(value) { 166 | this._filter = value; 167 | this.tableRequest = "reset"; 168 | } 169 | 170 | @api 171 | get fields() { 172 | return this._fields; 173 | } 174 | 175 | set fields(value) { 176 | if (value && typeof value == "string") { 177 | value = datatableUtils.createFieldArrayFromString(value); 178 | } else { 179 | value = JSON.parse(JSON.stringify(value)); // Deep copy the object because LWC does not allow modifying API attributes THIS WILL NOT WORK IF THERE ARE ANY METHODS ON THE OBJECT 180 | } 181 | 182 | if (Array.isArray(value)) { 183 | value = datatableUtils.addDefaultFieldValues(value, this.editable); 184 | } else { 185 | this.error("`fields` is required"); 186 | } 187 | 188 | // this._fields = value; 189 | this.tableRequest = value; // this may not actually be necessary. we might be able to just assign this._fields directly 190 | } 191 | 192 | /** 193 | * Sample: { label: 'Show Details', callback: (row)=>{do stuff}} 194 | * Valid input is an array of row actions, or a function (row, doneCallback) { return [rowActions]} 195 | */ 196 | @api rowActions; 197 | 198 | @api 199 | get isLoading() { 200 | return this._isLoading; 201 | } 202 | 203 | @api 204 | get selectedRows() { 205 | return this._selectedRows; //.map(row => { return row.charAt(0) === '/' ? row.slice(1) : row }); // remove prepended forward slash 206 | } 207 | 208 | @api 209 | refresh() { 210 | this._isLoading = true; 211 | refreshApex(this.wiredResults) 212 | .then(() => { 213 | this._isLoading = false; 214 | }) 215 | .catch((e) => { 216 | this.error(e.message); 217 | }); 218 | } 219 | 220 | @api 221 | clearSelection() { 222 | this._selectedRows = []; 223 | } 224 | 225 | 226 | @wire(getObjectInfo, { objectApiName: "$sObject" }) 227 | wiredObjectInfo({ error, data }) { 228 | if (data) { 229 | this.objectInfo = data; 230 | if (this._columns) { 231 | this._columns = datatableUtils.addObjectInfo(this._columns, this.objectInfo); 232 | } 233 | } else if (error) { 234 | this.error(error.statusText + ": " + error.body.message); 235 | } 236 | } 237 | 238 | @wire(wireTableCache, { tableRequest: "$tableRequest" }) 239 | wiredCache(result) { 240 | this.wiredResults = result; 241 | let error, data; 242 | ({ error, data } = result); 243 | if (data) { 244 | this.lastRefreshTime = new Date().getTime(); 245 | this.data = tableUtils.applyLinks( 246 | tableUtils.flattenQueryResult(data.tableData) 247 | ); 248 | this._offset = this.data.length; 249 | 250 | this._columns = datatableUtils.addFieldMetadata( 251 | data.tableColumns, 252 | this.fields 253 | ); 254 | if (this.objectInfo) { 255 | this._columns = datatableUtils.addObjectInfo(this._columns, this.objectInfo); 256 | } 257 | this._columns = datatableUtils.addRowActions( 258 | this._columns, 259 | this.rowActions 260 | ); 261 | 262 | if (this.datatable) this.datatable.selectedRows = this._selectedRows; 263 | this._enableInfiniteLoading = this.enableInfiniteLoading; 264 | this._isLoading = false; 265 | this._recordCount = data.recordCount; 266 | this.dispatchEvent( 267 | new CustomEvent("loaddata", { 268 | detail: { 269 | recordCount: this.recordCount, 270 | sortedDirection: this.sortedDirection, 271 | sortedBy: this.sortedBy, 272 | }, 273 | }) 274 | ); 275 | } else if (error) { 276 | this.error(error.statusText + ": " + error.body.message); 277 | } 278 | } 279 | 280 | loadMoreData() { 281 | this.isLoadingMore = true; 282 | const recordsToLoad = datatableUtils.getNumberOfRecordsToLoad( 283 | this._offset, 284 | this.recordsPerBatch, 285 | this.maxRecords 286 | ); 287 | return getTableCache({ 288 | tableRequest: { 289 | queryString: 290 | this.query + " LIMIT " + recordsToLoad + " OFFSET " + this._offset, 291 | }, 292 | }) 293 | .then((data) => { 294 | data = tableUtils.applyLinks( 295 | tableUtils.flattenQueryResult(data.tableData) 296 | ); 297 | this.data = this.data.concat(data); 298 | this.isLoadingMore = false; 299 | this.datatable.selectedRows = this._selectedRows; 300 | this._offset += data.length; 301 | if (this._offset >= this.maxRecords || data.length < recordsToLoad) { 302 | this._enableInfiniteLoading = false; 303 | } 304 | }) 305 | .catch((err) => { 306 | throw err; 307 | }); 308 | } 309 | 310 | get datatable() { 311 | return this.template.querySelector("c-datatable-base"); 312 | } 313 | 314 | get tableRequest() { 315 | return JSON.stringify({ 316 | sObject: this.sObject, 317 | filter: this.where, 318 | queryString: this.query + " LIMIT " + this.initialRecords, 319 | }); 320 | } 321 | 322 | set tableRequest(value) { 323 | // this._tableRequest = this.tableRequest; 324 | if (!Array.isArray(value)) this._fields = [...this._fields]; 325 | // hack to force wire to reload 326 | else this._fields = value; 327 | this._isLoading = true; 328 | } 329 | 330 | @api 331 | get query() { 332 | return this.buildQuery(this.fields, this.sObject, this.where, this.orderBy); 333 | } 334 | 335 | buildQuery(fields, sObject, where, orderBy) { 336 | let soql = 337 | "SELECT " + 338 | (fields.some((field) => field.fieldName === "Id") ? "" : "Id,") + // include Id in query if is not defined 339 | // (this.fields.some(field => field.fieldName === 'RecordTypeId') ? '' : 'RecordTypeId,') + // include record type Id in query if is not defined 340 | fields 341 | // .filter(field => field.visible) // exclude fields set to not be visible 342 | // .filter(field => field.fieldName.includes('.') || !this.objectInfo && this.objectInfo.fields[field.fieldName]) // exclude fields that are not existent (does not check related fields) 343 | .map((field) => field.fieldName) 344 | .join(",") + 345 | " FROM " + 346 | sObject + 347 | where + 348 | orderBy; 349 | return soql; 350 | } 351 | 352 | get where() { 353 | let filterItems = [this.filter, this.searchQuery].filter(f => f); 354 | 355 | if (filterItems.length) { 356 | return " WHERE " + filterItems.join(' AND '); 357 | } 358 | return ""; 359 | } 360 | 361 | get searchQuery() { 362 | let searchTerm = this.search.replace("'", "\\'"); 363 | let search = this.fields 364 | .filter((field) => { 365 | if (Object.prototype.hasOwnProperty.call(field, "searchable")) { 366 | return field.searchable; 367 | } 368 | if (!this.objectInfo || !this.objectInfo.fields[field.fieldName]) { 369 | return false; 370 | } 371 | let fieldType = this.objectInfo.fields[field.fieldName].dataType; 372 | return ( 373 | fieldType === "String" || 374 | fieldType === "Email" || 375 | fieldType === "Phone" 376 | ); 377 | }) 378 | .map((field) => { 379 | return field.fieldName + " LIKE '%" + searchTerm + "%'"; 380 | }) 381 | .join(" OR "); 382 | if (search) { 383 | search = "(" + search + ")"; 384 | } 385 | return search; 386 | } 387 | 388 | get orderBy() { 389 | if (!this.sortedBy) this.error("Sort field is required"); 390 | let sortedDirection = 391 | this.sortedDirection.toLowerCase() === "desc" 392 | ? "desc nulls last" 393 | : "asc nulls first"; 394 | return " ORDER BY " + this.sortedBy + " " + sortedDirection; 395 | } 396 | 397 | error(err) { 398 | if (typeof err == "string") err = new Error(err); 399 | const evt = new ShowToastEvent({ 400 | title: err.name + " - " + err.message, 401 | message: err.stack, 402 | variant: "error", 403 | mode: "sticky", 404 | }); 405 | this.dispatchEvent(evt); 406 | // console.error(err); 407 | throw err; 408 | } 409 | 410 | // getRowActions(row, renderActions) { 411 | // const actions = this.rowActions.filterRowActions(row, this.rowActions.availableActions); 412 | // renderActions(actions); 413 | // } 414 | 415 | updateSortField(event) { 416 | let fieldName = event.detail.fieldName; 417 | if (fieldName.endsWith("Link")) { 418 | // special case for salesforce relationship fields (this will not work for custom relationships) 419 | fieldName = fieldName.replace("_Link", ".Name"); 420 | fieldName = fieldName.replace("Link", "Name"); 421 | } 422 | this.sortedBy = fieldName; 423 | this.sortedDirection = event.detail.sortDirection; 424 | 425 | this.tableRequest = "reset"; 426 | } 427 | 428 | handleRowAction(event) { 429 | const action = event.detail.action; 430 | if (action && action.callback) { 431 | const row = JSON.parse(JSON.stringify(event.detail.row)); // deep copy so changes can be made that will not affect anything 432 | Promise.resolve(action.callback(row)).then((result) => { 433 | if (result) { 434 | this.replaceRow(row.Id, result); 435 | // eslint-disable-next-line no-empty 436 | } else if (typeof result === 'undefined') { 437 | } else { 438 | this.removeRow(row.Id); 439 | } 440 | }); 441 | } 442 | } 443 | 444 | handleRowSelection(event) { 445 | let availableRows = this.data.map((row) => row.Id); 446 | let newRows = event.detail.selectedRows.map((row) => row.Id); 447 | 448 | let selectedRows = this._selectedRows 449 | .filter((row) => !availableRows.includes(row)) // keep rows that arent in the current table 450 | .concat(newRows); // add currently selected rows 451 | 452 | this._selectedRows = selectedRows; 453 | 454 | this.dispatchEvent( 455 | new CustomEvent("rowselection", { 456 | detail: { 457 | selectedRows: selectedRows, 458 | }, 459 | }) 460 | ); 461 | } 462 | 463 | handleSave(event) { 464 | this._isLoading = true; 465 | let updatePromises = event.detail.draftValues.map((row) => { 466 | row = {...row}; 467 | for (let key of Object.keys(row)) { // Replace fieldName with editFieldName before performing the update 468 | let fieldInfo = this._fields.find(f => f.fieldName === key); 469 | if (fieldInfo && fieldInfo.editFieldName) { 470 | row[fieldInfo.editFieldName] = row[key]; 471 | delete row[key]; 472 | } 473 | } 474 | // let recordForUpdate = generateRecordInputForUpdate({id: row.Id, fields:row},this.objectInfo); 475 | return updateRecord({ fields: row }) 476 | .then(() => { 477 | delete this.errors.rows[row.Id]; 478 | if (this.datatable && this.datatable.draftValues) { 479 | const draftValues = this.datatable.draftValues; 480 | const draftIndex = draftValues.find(r => r.Id === row.Id); 481 | draftValues.splice(draftIndex, 1); 482 | this.datatable.draftValues = [...draftValues]; 483 | } 484 | return this.refreshRow(row.Id); 485 | }) 486 | .catch((error) => { 487 | if (!error || !error.body || !error.body.output) { 488 | return; 489 | } 490 | const formatError = err => err && err.errorCode + ': ' + err.message 491 | const rowErrorMessages = (error.body.output.errors && error.body.output.errors.map(formatError)) || []; 492 | const fieldErrors = Object.values(error.body.output.fieldErrors).reduce((acc,current) => acc.concat(current), []); 493 | const fieldErrorMessages = (fieldErrors && fieldErrors.map(formatError)) || []; 494 | const messages = [...rowErrorMessages, ...fieldErrorMessages]; 495 | const fieldNames = (fieldErrors && fieldErrors.map(err => { 496 | let fieldInfo = this._fields.find(f => f.editFieldName === err.field); // lookup the original field name 497 | return (fieldInfo && fieldInfo.fieldName) || err.field; 498 | })) || []; 499 | this.errors.rows[row.Id] = { 500 | title: `We found ${messages.length} error${messages.length > 1 ? 's':''}.`, 501 | messages, 502 | fieldNames 503 | } 504 | }); 505 | }); 506 | 507 | Promise.all(updatePromises) 508 | // .then(() => { 509 | // this.draftValues = []; 510 | // this.refresh(); 511 | // }) 512 | .catch((error) => { 513 | if (error && error.body) { 514 | this.errors.table = { 515 | title: "Error updating records", 516 | message: [error.body.message], 517 | } 518 | console.log(JSON.parse(JSON.stringify(error))); 519 | } 520 | }) 521 | .then(()=> { 522 | this.errors = {...this.errors} 523 | this._isLoading = false; 524 | }); 525 | console.log(event); 526 | } 527 | 528 | handleFieldEdit(event) { 529 | const { value, rowKeyValue, colKeyValue } = event.detail; 530 | const draftValues = this.template.querySelector("c-datatable-base") 531 | .draftValues; 532 | if (rowKeyValue) { 533 | let currentRow = 534 | draftValues && draftValues.find((row) => row.Id === rowKeyValue); 535 | if (!currentRow) { 536 | currentRow = { Id: rowKeyValue }; 537 | draftValues.push(currentRow); 538 | } 539 | currentRow[colKeyValue.split("-")[0]] = value; 540 | this.draftValues = [...draftValues]; 541 | } 542 | } 543 | 544 | getRowValue(recordId) { 545 | let filter = 546 | this.where + (this.where ? " AND " : " WHERE ") + `Id='${recordId}'`; 547 | let query = this.buildQuery( 548 | this.fields, 549 | this.sObject, 550 | filter, 551 | this.orderBy 552 | ); 553 | return getTableCache({ 554 | tableRequest: { 555 | queryString: query, 556 | }, 557 | }); 558 | } 559 | 560 | addRow(recordId) { 561 | if (this._offset < this.maxRecords) { 562 | this.getRowValue(recordId).then((data) => { 563 | const newData = tableUtils.applyLinks( 564 | tableUtils.flattenQueryResult(data.tableData) 565 | ); 566 | 567 | const rows = this.data; 568 | const rowIndex = rows.findIndex((r) => r.Id === recordId); 569 | 570 | if (rowIndex >= 0) { // Check if row already exists 571 | rows[rowIndex] = newData[0]; 572 | this.data = [...rows]; 573 | } else { 574 | this.data = newData.concat(this.data); 575 | } 576 | 577 | this.datatable.selectedRows = this._selectedRows; 578 | }); 579 | } 580 | } 581 | 582 | replaceRow(recordId, row) { 583 | const rows = this.data; 584 | const rowIndex = rows.findIndex((r) => r.Id === recordId); 585 | if (rowIndex >= 0) { 586 | rows[rowIndex] = row; 587 | this.data = [...rows]; 588 | } 589 | } 590 | 591 | refreshRow(recordId) { 592 | const rows = this.data; 593 | const rowIndex = rows.findIndex((r) => r.Id === recordId); 594 | 595 | if (rowIndex >= 0) { 596 | // if row exists 597 | this.getRowValue(recordId).then((data) => { 598 | const newData = tableUtils.applyLinks( 599 | tableUtils.flattenQueryResult(data.tableData) 600 | ); 601 | rows[rowIndex] = newData[0]; 602 | 603 | this.data = [...rows]; 604 | }); 605 | } 606 | } 607 | 608 | removeRow(recordId) { 609 | const rows = this.data; 610 | const rowIndex = rows.findIndex((r) => r.Id === recordId); 611 | 612 | if (rowIndex >= 0) { 613 | // if row exists 614 | rows.splice(rowIndex, 1); 615 | this._offset--; 616 | 617 | this.data = [...rows]; 618 | } 619 | } 620 | 621 | pushTopicSubscribe(channelName) { 622 | const messageCallback = (response) => { 623 | console.log(JSON.parse(JSON.stringify(response))); 624 | const event = response.data.event; 625 | const recordId = response.data.sobject.Id; 626 | if (this.lastEventId < event.replayId && this.lastRefreshTime < new Date(event.createdDate).getTime()) { 627 | switch (event.type) { 628 | case "created": 629 | this.addRow(recordId); 630 | break; 631 | case "updated": 632 | this.refreshRow(recordId); 633 | break; 634 | case "deleted": 635 | this.removeRow(recordId); 636 | break; 637 | default: 638 | break; 639 | } 640 | } 641 | }; 642 | 643 | return subscribe(channelName, -1, messageCallback).then((response) => { 644 | console.log( 645 | "Successfully subscribed to : ", 646 | JSON.stringify(response.channel) 647 | ); 648 | if (this.subscription) { 649 | unsubscribe(this.subscription, (resp) => { 650 | console.log(JSON.parse(JSON.stringify(resp))); 651 | }); 652 | } 653 | this.subscription = response; 654 | }); 655 | } 656 | /* 657 | // based on https://stackoverflow.com/a/31536517 658 | createCsv(columns, rows) { 659 | const replacer = (key, value) => value === null ? '' : value; // specify how you want to handle null values here 660 | const fields = columns.map(col=>col.fieldName); 661 | let csv = rows.map(row => fields.map(fieldName => JSON.stringify(row[fieldName], replacer)).join(',')); 662 | csv.unshift(columns.map(col=>JSON.stringify(col.label)).join(',')); 663 | csv = csv.join('\r\n'); 664 | } 665 | */ 666 | 667 | // getPicklistOptions(fieldName) { 668 | // getPicklistValues() 669 | // } 670 | 671 | // getAllRows() { 672 | // // return Promise.resolve(); 673 | // } 674 | } 675 | -------------------------------------------------------------------------------- /force-app/main/default/lwc/datatable/__tests__/datatable.test.js: -------------------------------------------------------------------------------- 1 | import { createElement } from 'lwc'; 2 | import Datatable from 'c/datatable'; 3 | import wireTableCache from '@salesforce/apex/DataTableService.wireTableCache'; 4 | import getTableCache from '@salesforce/apex/DataTableService.getTableCache'; 5 | import { registerLdsTestWireAdapter } from '@salesforce/sfdx-lwc-jest'; 6 | import { getObjectInfo } from 'lightning/uiObjectInfoApi'; 7 | // import { subscribe, unsubscribe } from 'lightning/empApi'; 8 | // import { getRecord } from 'lightning/uiRecordApi'; 9 | 10 | 11 | const mockTableData = require('./data/wireTableCache.json'); 12 | const mockObjectInfo = require('./data/getObjectInfo.json'); 13 | 14 | const wireTableCacheWireAdapter = registerLdsTestWireAdapter(wireTableCache); 15 | const getObjectInfoAdapter = registerLdsTestWireAdapter(getObjectInfo); 16 | // const getRecordAdapter = registerLdsTestWireAdapter(getRecord); 17 | 18 | jest.mock( 19 | '@salesforce/apex/DataTableService.getTableCache', 20 | () => { 21 | return { 22 | default: jest.fn() 23 | }; 24 | }, 25 | { virtual: true } 26 | ); 27 | 28 | describe('c-datatable', () => { 29 | 30 | const defaultDatatable = () => { 31 | const element = createElement('c-datatable', { 32 | is: Datatable 33 | }); 34 | 35 | element.sObject = 'Opportunity'; 36 | element.sortedBy = 'Name'; 37 | element.sortedDirection = 'asc'; 38 | element.fields = [ 39 | { fieldName: 'Name', sortable: true }, 40 | { fieldName: 'StageName', sortable: true }, 41 | { fieldName: 'CloseDate', sortable: true } 42 | ]; 43 | 44 | return element; 45 | } 46 | 47 | function flushPromises() { 48 | // eslint-disable-next-line no-undef 49 | return new Promise(resolve => setImmediate(resolve)); 50 | } 51 | 52 | afterEach(() => { 53 | // The jsdom instance is shared across test cases in a single file so reset the DOM 54 | while (document.body.firstChild) { 55 | document.body.removeChild(document.body.firstChild); 56 | } 57 | jest.clearAllMocks(); 58 | }); 59 | 60 | it('creates soql query from fields json', () => { 61 | // Create element 62 | const element = defaultDatatable(); 63 | element.fields = [ 64 | { fieldName: 'Id'}, 65 | { fieldName: 'Name', sortable: true }, 66 | { fieldName: 'Account.Name', sortable: true} 67 | ]; 68 | 69 | document.body.appendChild(element); 70 | const expectedQuery = 'SELECT Id,Name,Account.Name FROM Opportunity ORDER BY Name asc nulls first'; 71 | expect(element.query).toBe(expectedQuery); 72 | 73 | return flushPromises().then(() => { 74 | const request = wireTableCacheWireAdapter.getLastConfig().tableRequest; 75 | expect(JSON.parse(request).queryString).toMatch(expectedQuery); 76 | }); 77 | }); 78 | 79 | it('accepts json string as fields input', () => { 80 | // Create element 81 | const element = defaultDatatable(); 82 | element.fields = JSON.stringify([ 83 | { fieldName: 'Id'}, 84 | { fieldName: 'Name', sortable: true }, 85 | { fieldName: 'Account.Name', sortable: true} 86 | ]); 87 | 88 | document.body.appendChild(element); 89 | const expectedQuery = 'SELECT Id,Name,Account.Name FROM Opportunity ORDER BY Name asc nulls first'; 90 | expect(element.query).toBe(expectedQuery); 91 | 92 | return flushPromises().then(() => { 93 | const request = wireTableCacheWireAdapter.getLastConfig().tableRequest; 94 | expect(JSON.parse(request).queryString).toMatch(expectedQuery); 95 | }); 96 | }); 97 | 98 | it('adds id field if not included', () => { 99 | // Create element 100 | const element = defaultDatatable(); 101 | 102 | document.body.appendChild(element); 103 | const expectedQuery = 'SELECT Id,Name,StageName,CloseDate FROM Opportunity ORDER BY Name asc nulls first'; 104 | expect(element.query).toBe(expectedQuery); 105 | 106 | return flushPromises().then(() => { 107 | const request = wireTableCacheWireAdapter.getLastConfig().tableRequest; 108 | expect(JSON.parse(request).queryString).toMatch(expectedQuery); 109 | }); 110 | // expect('hi').toBe('hi'); 111 | }); 112 | 113 | // // test failing due to async tests. not sure how to solve this 114 | // it('throws an error if no field is sorted', (done) => { 115 | // expect.assertions(1); 116 | 117 | 118 | // // Create element 119 | // const element = createElement('c-datatable', { 120 | // is: Datatable 121 | // }); 122 | 123 | // element.sObject = 'Opportunity'; 124 | // element.sortedDirection = 'asc'; 125 | // element.fields = [ 126 | // { fieldName: 'Name', sortable: true }, 127 | // { fieldName: 'CloseDate', sortable: true } 128 | // ]; 129 | // document.body.appendChild(element); 130 | // return expect(() => element.query).toThrow('Sort field is required') 131 | // .then(()=> { 132 | // done(); 133 | // }); 134 | // }); 135 | 136 | it('throws an error if fieldName is not provided', () => { 137 | const element = defaultDatatable(); 138 | 139 | expect(() => { 140 | element.fields = [ 141 | { sortable: true }, 142 | { fieldName: 'Account.Name', sortable: true } 143 | ]; 144 | }).toThrow('Field must have a valid `fieldName` property'); 145 | }); 146 | 147 | it('adds sort information to datatable', () => { 148 | const element = defaultDatatable(); 149 | 150 | document.body.appendChild(element); 151 | wireTableCacheWireAdapter.emit(mockTableData); 152 | 153 | return flushPromises().then(() => { 154 | const lightningDatatable = element.shadowRoot.querySelector('c-datatable-base'); 155 | expect(lightningDatatable).not.toBeNull(); 156 | expect(lightningDatatable.sortedBy).toBe('Id'); 157 | expect(lightningDatatable.sortedDirection).toBe('asc'); 158 | }); 159 | 160 | }); 161 | 162 | it('adds sort information to column headers', () => { 163 | const element = defaultDatatable(); 164 | 165 | document.body.appendChild(element); 166 | wireTableCacheWireAdapter.emit(mockTableData); 167 | getObjectInfoAdapter.emit(mockObjectInfo); 168 | 169 | return flushPromises().then(() => { 170 | const lightningDatatable = element.shadowRoot.querySelector('c-datatable-base'); 171 | expect(lightningDatatable.columns[0].sortable).toBe(true); 172 | }); 173 | 174 | }); 175 | 176 | it('updates sort field when sort event is received from c-datatable-base', () => { 177 | const element = defaultDatatable(); 178 | 179 | document.body.appendChild(element); 180 | wireTableCacheWireAdapter.emit(mockTableData); 181 | 182 | return flushPromises() 183 | .then(() => { 184 | const lightningDatatable = element.shadowRoot.querySelector('c-datatable-base'); 185 | expect(lightningDatatable).not.toBeNull(); 186 | lightningDatatable.dispatchEvent(new CustomEvent('sort', { 187 | detail: { 188 | fieldName: "StageName", 189 | sortDirection: "desc" 190 | } 191 | })); 192 | }).then(() => { 193 | expect(element.sortedBy).toBe('StageName'); 194 | const request = wireTableCacheWireAdapter.getLastConfig().tableRequest; 195 | expect(JSON.parse(request).queryString) 196 | .toMatch('ORDER BY StageName desc nulls last'); 197 | // const accountField = element.fields.find(field=>field.fieldName==='Account.Name'); 198 | // expect(accountField.sorted).toBe(true); 199 | // expect(accountField.sortDirection).toBe('desc'); 200 | }); 201 | }); 202 | 203 | it('loads more data on load event', ()=> { 204 | const element = defaultDatatable(); 205 | element.enableInfiniteLoading = true; 206 | 207 | document.body.appendChild(element); 208 | wireTableCacheWireAdapter.emit(mockTableData); 209 | getTableCache.mockResolvedValue(mockTableData); 210 | 211 | return flushPromises().then(() => { 212 | const request = wireTableCacheWireAdapter.getLastConfig().tableRequest; 213 | expect(JSON.parse(request).queryString).toMatch(/LIMIT 50$/); 214 | 215 | const datatable = element.shadowRoot.querySelector('c-datatable-base'); 216 | datatable.dispatchEvent(new CustomEvent('loadmore')); 217 | 218 | const imperativeRequest = getTableCache.mock.calls[0][0].tableRequest; 219 | expect(imperativeRequest.queryString).toMatch(new RegExp('LIMIT 50 OFFSET '+mockTableData.tableData.length+'$')); 220 | }).then(() => { 221 | const datatable = element.shadowRoot.querySelector('c-datatable-base'); 222 | //IDK why this isnt working as expected: expect(datatable.enableInfiniteLoading).toBe(false); // if previous loadmore call returned less than requested, disable infinite loading 223 | datatable.dispatchEvent(new CustomEvent('loadmore')); 224 | const imperativeRequest = getTableCache.mock.calls[1][0].tableRequest; 225 | expect(imperativeRequest.queryString).toMatch(new RegExp('LIMIT 50 OFFSET '+(mockTableData.tableData.length*2)+'$')); 226 | }); 227 | }); 228 | 229 | it('loads specified number of records', () => { 230 | const element = defaultDatatable(); 231 | 232 | element.enableInfiniteLoading = true; 233 | 234 | const recordsPerBatch = 10; 235 | const initialRecords = 20; 236 | 237 | element.recordsPerBatch = recordsPerBatch; 238 | element.initialRecords = initialRecords; 239 | 240 | document.body.appendChild(element); 241 | wireTableCacheWireAdapter.emit(mockTableData); 242 | getTableCache.mockResolvedValue(mockTableData); 243 | 244 | return flushPromises().then(() => { 245 | const request = wireTableCacheWireAdapter.getLastConfig().tableRequest; 246 | expect(JSON.parse(request).queryString).toMatch(new RegExp('LIMIT '+ 20 + '$')); 247 | 248 | const datatable = element.shadowRoot.querySelector('c-datatable-base'); 249 | datatable.dispatchEvent(new CustomEvent('loadmore')); 250 | const imperativeRequest = getTableCache.mock.calls[0][0].tableRequest; 251 | expect(imperativeRequest.queryString).toMatch(new RegExp('LIMIT '+ recordsPerBatch+' OFFSET '+mockTableData.tableData.length+'$')); 252 | }); 253 | }); 254 | 255 | it('initial load falls back to recordsPerBatch', () => { 256 | const element = defaultDatatable(); 257 | 258 | const recordsPerBatch = 10; 259 | 260 | element.enableInfiniteLoading = true; 261 | element.recordsPerBatch = recordsPerBatch; 262 | 263 | document.body.appendChild(element); 264 | wireTableCacheWireAdapter.emit(mockTableData); 265 | getTableCache.mockResolvedValue(mockTableData); 266 | 267 | return flushPromises().then(() => { 268 | const request = wireTableCacheWireAdapter.getLastConfig().tableRequest; 269 | expect(JSON.parse(request).queryString).toMatch(new RegExp('LIMIT '+ recordsPerBatch + '$')); 270 | }); 271 | }); 272 | 273 | it('keeps selected rows when they are not present in the table', () => { 274 | const element = defaultDatatable(); 275 | document.body.appendChild(element); 276 | wireTableCacheWireAdapter.emit(mockTableData); 277 | 278 | const idToHide = '0031F00000JKhtVQAT'; 279 | return flushPromises() 280 | .then(() => { 281 | const lightningDatatable = element.shadowRoot.querySelector('c-datatable-base'); 282 | lightningDatatable.dispatchEvent(new CustomEvent('rowselection', { 283 | detail: { 284 | selectedRows: [ 285 | { Id:idToHide } 286 | ] 287 | } 288 | })); 289 | }).then(() => { 290 | const modifiedData = JSON.parse(JSON.stringify(mockTableData)); 291 | const lightningDatatable = element.shadowRoot.querySelector('c-datatable-base'); 292 | 293 | modifiedData.tableData.pop(); 294 | wireTableCacheWireAdapter.emit(modifiedData); 295 | lightningDatatable.dispatchEvent(new CustomEvent('rowselection', { 296 | detail: { 297 | selectedRows: [] 298 | } 299 | })); 300 | }).then(() => { 301 | const lightningDatatable = element.shadowRoot.querySelector('c-datatable-base'); 302 | expect(lightningDatatable.selectedRows).toContain(idToHide); 303 | expect(element.selectedRows).toContain(idToHide); 304 | }).then(() => { 305 | wireTableCacheWireAdapter.emit(mockTableData); 306 | }).then(() => { 307 | const lightningDatatable = element.shadowRoot.querySelector('c-datatable-base'); 308 | expect(lightningDatatable.selectedRows).toContain(idToHide); 309 | expect(element.selectedRows).toContain(idToHide); 310 | }); 311 | }); 312 | 313 | it('clear selected rows on clearSelection', () => { 314 | const element = defaultDatatable(); 315 | document.body.appendChild(element); 316 | wireTableCacheWireAdapter.emit(mockTableData); 317 | 318 | const idToHide = '0031F00000JKhtVQAT'; 319 | return flushPromises() 320 | .then(() => { 321 | const lightningDatatable = element.shadowRoot.querySelector('c-datatable-base'); 322 | lightningDatatable.dispatchEvent(new CustomEvent('rowselection', { 323 | detail: { 324 | selectedRows: [ 325 | { Id:idToHide } 326 | ] 327 | } 328 | })); 329 | }).then(() => { 330 | element.clearSelection(); 331 | }).then(() => { 332 | const lightningDatatable = element.shadowRoot.querySelector('c-datatable-base'); 333 | expect(lightningDatatable.selectedRows).toHaveLength(0); 334 | expect(element.selectedRows).toHaveLength(0); 335 | }); 336 | }); 337 | 338 | it('searches for search input', () => { 339 | const element = defaultDatatable(); 340 | 341 | const searchString = 'test' 342 | element.search = searchString; 343 | 344 | document.body.appendChild(element); 345 | getObjectInfoAdapter.emit(mockObjectInfo); 346 | return flushPromises().then(() => { 347 | const request = wireTableCacheWireAdapter.getLastConfig().tableRequest; 348 | expect(JSON.parse(request).queryString).toMatch('LIKE \'%'+searchString+'%\''); 349 | expect(element.search).toBe(searchString); 350 | }); 351 | 352 | }); 353 | 354 | it('does not search relationship fields',() => { 355 | const element = defaultDatatable(); 356 | 357 | const searchString = 'test' 358 | element.search = searchString; 359 | 360 | document.body.appendChild(element); 361 | getObjectInfoAdapter.emit(mockObjectInfo); 362 | 363 | return flushPromises().then(() => { 364 | const request = wireTableCacheWireAdapter.getLastConfig().tableRequest; 365 | expect(JSON.parse(request).queryString).toMatch('LIKE \'%'+searchString+'%\''); 366 | expect(JSON.parse(request).queryString).not.toMatch('Account.Name LIKE \'%'+searchString+'%\''); 367 | }); 368 | }) 369 | 370 | it('does not search non-text fields',() => { 371 | const element = defaultDatatable(); 372 | 373 | element.fields = [ 374 | { fieldName: 'Name', sortable: true }, 375 | { fieldName: 'Account.Name', sortable: true}, 376 | { fieldName: 'CreatedDate', sortable: true}, 377 | ]; 378 | const searchString = 'test' 379 | element.search = searchString; 380 | document.body.appendChild(element); 381 | getObjectInfoAdapter.emit(mockObjectInfo); 382 | 383 | return flushPromises().then(() => { 384 | const request = wireTableCacheWireAdapter.getLastConfig().tableRequest; 385 | expect(JSON.parse(request).queryString).toMatch('LIKE \'%'+searchString+'%\''); 386 | expect(JSON.parse(request).queryString).not.toMatch('CreatedDate LIKE \'%'+searchString+'%\''); 387 | }); 388 | }) 389 | 390 | it('always searches fields that are marked searchable=true',() => { 391 | const element = defaultDatatable(); 392 | 393 | element.fields = [ 394 | { fieldName: 'Name', sortable: true }, 395 | { fieldName: 'Account.Name', sortable: true, searchable: true} 396 | ]; 397 | const searchString = 'test' 398 | element.search = searchString; 399 | document.body.appendChild(element); 400 | return flushPromises().then(() => { 401 | const request = wireTableCacheWireAdapter.getLastConfig().tableRequest; 402 | expect(JSON.parse(request).queryString).toMatch('Account.Name LIKE \'%'+searchString+'%\''); 403 | }); 404 | }) 405 | 406 | it('never searches fields that are marked searchable=false',() => { 407 | const element = defaultDatatable(); 408 | 409 | element.fields = [ 410 | { fieldName: 'Name', sortable: true, searchable: false }, 411 | { fieldName: 'Phone', searchable: true}, 412 | { fieldName: 'Account.Name', sortable: true} 413 | ]; 414 | const searchString = 'test' 415 | element.search = searchString; 416 | document.body.appendChild(element); 417 | return flushPromises().then(() => { 418 | const request = wireTableCacheWireAdapter.getLastConfig().tableRequest; 419 | expect(JSON.parse(request).queryString).toMatch('LIKE \'%'+searchString+'%\''); 420 | expect(JSON.parse(request).queryString).not.toMatch(' Name LIKE \'%'+searchString+'%\''); 421 | }); 422 | }) 423 | 424 | it('filters on filter parameter', () => { 425 | const element = defaultDatatable(); 426 | 427 | const filterString = 'Name = \'asdf\'' 428 | element.filter = filterString; 429 | 430 | document.body.appendChild(element); 431 | return flushPromises().then(() => { 432 | const request = JSON.parse(wireTableCacheWireAdapter.getLastConfig().tableRequest); 433 | expect(request.queryString).toMatch('WHERE '+filterString); 434 | expect(element.filter).toBe(filterString); 435 | }); 436 | 437 | }); 438 | 439 | 440 | it('filters and searches on filter and search parameter', () => { 441 | const element = defaultDatatable(); 442 | 443 | element.fields = [ 444 | { fieldName: 'Name', sortable: true, searchable: true }, 445 | { fieldName: 'Account.Name', sortable: true } 446 | ]; 447 | 448 | 449 | const filterString = 'Name = \'asdf\'' 450 | const searchString = 'test'; 451 | element.filter = filterString; 452 | element.search = searchString; 453 | document.body.appendChild(element); 454 | return flushPromises().then(() => { 455 | const request = JSON.parse(wireTableCacheWireAdapter.getLastConfig().tableRequest); 456 | expect(request.queryString).toMatch('WHERE '+filterString); 457 | expect(request.queryString).toMatch(' LIKE \'%'+searchString+'%\''); 458 | expect(element.filter).toBe(filterString); 459 | }); 460 | 461 | }); 462 | 463 | 464 | it('does not display fields with visible set to false', () => { 465 | const element = defaultDatatable(); 466 | 467 | element.fields = [ 468 | { fieldName: 'Name', sortable: true }, 469 | { fieldName: 'CloseDate', sortable: true, visible: false} 470 | ]; 471 | 472 | document.body.appendChild(element); 473 | wireTableCacheWireAdapter.emit(mockTableData); 474 | 475 | return flushPromises().then(() => { 476 | const lightningDatatable = element.shadowRoot.querySelector('c-datatable-base'); 477 | const request = JSON.parse(wireTableCacheWireAdapter.getLastConfig().tableRequest); 478 | expect(request.queryString).toMatch('CloseDate'); 479 | expect(lightningDatatable.columns).not.toEqual(expect.arrayContaining([ 480 | expect.objectContaining({'fieldName': 'CloseDate'}) 481 | ])); 482 | element.fields = [ 483 | { fieldName: 'Name', sortable: true }, 484 | { fieldName: 'CloseDate', sortable: true } 485 | ]; 486 | wireTableCacheWireAdapter.emit(mockTableData); 487 | }).then(() => { 488 | const lightningDatatable = element.shadowRoot.querySelector('c-datatable-base'); 489 | const request = JSON.parse(wireTableCacheWireAdapter.getLastConfig().tableRequest); 490 | expect(lightningDatatable.columns).toEqual(expect.arrayContaining([ 491 | expect.objectContaining({'fieldName': 'CloseDate'}) 492 | ])); 493 | expect(request.queryString).toMatch('CloseDate'); 494 | }); 495 | }); 496 | 497 | it('adds static rowActions to datatable', () => { 498 | const element = defaultDatatable(); 499 | 500 | const callback = jest.fn(); 501 | element.rowActions = [ 502 | { label: 'Do something', callback: callback} 503 | ]; 504 | 505 | document.body.appendChild(element); 506 | wireTableCacheWireAdapter.emit(mockTableData); 507 | 508 | return flushPromises().then(() => { 509 | const lightningDatatable = element.shadowRoot.querySelector('c-datatable-base'); 510 | expect(lightningDatatable).not.toBeNull(); 511 | expect(lightningDatatable.columns).toStrictEqual( 512 | expect.arrayContaining([ 513 | expect.objectContaining({ 514 | type: 'action', 515 | typeAttributes: { 516 | rowActions: expect.arrayContaining([ 517 | expect.objectContaining({ label: 'Do something', callback: callback}) 518 | ]), 519 | } 520 | }) 521 | ]) 522 | ); 523 | }); 524 | }); 525 | 526 | it('adds dynamic rowActions to datatable', () => { 527 | const element = defaultDatatable(); 528 | 529 | const rowActions = jest.fn(); 530 | 531 | element.rowActions = rowActions; 532 | 533 | document.body.appendChild(element); 534 | wireTableCacheWireAdapter.emit(mockTableData); 535 | 536 | return flushPromises().then(() => { 537 | const lightningDatatable = element.shadowRoot.querySelector('c-datatable-base'); 538 | expect(lightningDatatable).not.toBeNull(); 539 | expect(lightningDatatable.columns).toStrictEqual( 540 | expect.arrayContaining([ 541 | expect.objectContaining({ 542 | type: 'action', 543 | typeAttributes: { 544 | rowActions: rowActions, 545 | } 546 | }) 547 | ]) 548 | ); 549 | }); 550 | }); 551 | 552 | it('runs callback on rowaction', () => { 553 | const element = defaultDatatable(); 554 | 555 | const callback = jest.fn(); 556 | 557 | document.body.appendChild(element); 558 | wireTableCacheWireAdapter.emit(mockTableData); 559 | 560 | return flushPromises().then(() => { 561 | const lightningDatatable = element.shadowRoot.querySelector('c-datatable-base'); 562 | expect(lightningDatatable).not.toBeNull(); 563 | lightningDatatable.dispatchEvent(new CustomEvent('rowaction', { 564 | detail: { 565 | row: lightningDatatable.data[0], 566 | action: { 567 | label:'testrowaction', 568 | callback: callback 569 | } 570 | } 571 | })); 572 | }).then(() => { 573 | expect(callback).toHaveBeenCalled(); 574 | }); 575 | 576 | }); 577 | 578 | it('updates table with value returned by row action callback', () => { 579 | const element = defaultDatatable(); 580 | 581 | const callback = jest.fn().mockImplementation((row)=>{ 582 | row.Name = 'testname'; 583 | return Promise.resolve(row); 584 | }); 585 | 586 | document.body.appendChild(element); 587 | wireTableCacheWireAdapter.emit(mockTableData); 588 | 589 | return flushPromises().then(() => { 590 | const lightningDatatable = element.shadowRoot.querySelector('c-datatable-base'); 591 | expect(lightningDatatable).not.toBeNull(); 592 | lightningDatatable.dispatchEvent(new CustomEvent('rowaction', { 593 | detail: { 594 | row: lightningDatatable.data[0], 595 | action: { 596 | label:'testrowaction', 597 | callback: callback 598 | } 599 | } 600 | })); 601 | }).then(() => { 602 | const lightningDatatable = element.shadowRoot.querySelector('c-datatable-base'); 603 | 604 | expect(callback).toHaveBeenCalled(); 605 | expect(callback.mock.calls[0][0]).toMatchObject(lightningDatatable.data[0]); 606 | expect(lightningDatatable.data[0]).toMatchObject({ 607 | Id: lightningDatatable.data[0].Id, 608 | Name: 'testname' 609 | }); 610 | }); 611 | 612 | }); 613 | 614 | it('deletes row from table when row action callback returns false', () => { 615 | const element = defaultDatatable(); 616 | 617 | const callback = jest.fn().mockImplementation(()=>false); 618 | 619 | document.body.appendChild(element); 620 | wireTableCacheWireAdapter.emit(mockTableData); 621 | 622 | 623 | return flushPromises().then(() => { 624 | const lightningDatatable = element.shadowRoot.querySelector('c-datatable-base'); 625 | const row = lightningDatatable.data[0]; 626 | expect(lightningDatatable).not.toBeNull(); 627 | lightningDatatable.dispatchEvent(new CustomEvent('rowaction', { 628 | detail: { 629 | row: row, 630 | action: { 631 | label:'testrowaction', 632 | callback: callback 633 | } 634 | } 635 | })); 636 | return row; 637 | }).then((row) => { 638 | const lightningDatatable = element.shadowRoot.querySelector('c-datatable-base'); 639 | 640 | expect(callback).toHaveBeenCalled(); 641 | expect(callback.mock.calls[0][0]).toMatchObject(row); 642 | expect(lightningDatatable.data).not.toContain(expect.objectContaining({ 643 | Id: row.Id 644 | })); 645 | }); 646 | 647 | }); 648 | 649 | it('fires loaddata event on inital load', () => { 650 | const element = defaultDatatable(); 651 | 652 | const loaddataListener = jest.fn(); 653 | element.addEventListener('loaddata',loaddataListener); 654 | 655 | document.body.appendChild(element); 656 | wireTableCacheWireAdapter.emit(mockTableData); 657 | 658 | 659 | return flushPromises().then(() => { 660 | expect(loaddataListener).toHaveBeenCalled(); 661 | expect(loaddataListener.mock.calls[0][0].detail).toMatchObject({ 662 | recordCount: mockTableData.recordCount, 663 | sortedDirection: 'asc', 664 | sortedBy: 'Name' 665 | }); 666 | }); 667 | 668 | }); 669 | 670 | it('fires rowselection event when row is selected', () => { 671 | const element = defaultDatatable(); 672 | const rowselectionListener = jest.fn(); 673 | element.addEventListener('rowselection',rowselectionListener); 674 | 675 | document.body.appendChild(element); 676 | wireTableCacheWireAdapter.emit(mockTableData); 677 | const rowId = 'testid'; 678 | 679 | return flushPromises().then(() => { 680 | element.shadowRoot.querySelector('c-datatable-base').dispatchEvent(new CustomEvent('rowselection', { 681 | detail: { 682 | selectedRows: [{Id:rowId}] 683 | } 684 | })); 685 | }).then(() => { 686 | expect(rowselectionListener).toHaveBeenCalled(); 687 | expect(rowselectionListener.mock.calls[0][0].detail).toMatchObject({ 688 | selectedRows: expect.arrayContaining([rowId]) 689 | }); 690 | }); 691 | 692 | }); 693 | }); --------------------------------------------------------------------------------