From 4f570516c582c114753383377151114fb187e369 Mon Sep 17 00:00:00 2001 From: Antoine Rosenbach Date: Fri, 12 Jan 2024 10:24:48 +0100 Subject: [PATCH] Lightning record picker multi value (#893) * feat: creating record-picker-multi-value scenario + using record-picker component * feat: adding pill-container to displayed selected records * feat: handling pill-container itemremove event * feat: adding logic to filters fetched records based on the one that already has been selected * feat: creating test for record-picker-multi-value * test: should clear the input when a selection is made * feat: adding test for records filtering * refacto: clean-up tests and comments * refacto: add state * refacto: simplify code (remove state) * chore: add comments + error and view source panel * feat: adding lightning-card to the recordPickerMultiValue cmp * feat: adding recordPickerMultiValue cmp to recordPicker flexipage * fix: display icon in pill items * fix: Reset recordId to ensure the wire can be called a second time the same recordId * change to gql wire * fix gql * chore: fix typo * pr: adjust view source footer --------- Co-authored-by: skempf --- .../Record_Picker.flexipage-meta.xml | 6 + .../__tests__/recordPickerHello.test.js | 16 +- .../__tests__/data/graphqlContactResult.json | 30 +++ .../__tests__/recordPickerMultiValue.test.js | 202 ++++++++++++++++++ .../recordPickerMultiValue.html | 32 +++ .../recordPickerMultiValue.js | 132 ++++++++++++ .../recordPickerMultiValue.js-meta.xml | 25 +++ 7 files changed, 432 insertions(+), 11 deletions(-) create mode 100644 force-app/main/default/lwc/recordPickerMultiValue/__tests__/data/graphqlContactResult.json create mode 100644 force-app/main/default/lwc/recordPickerMultiValue/__tests__/recordPickerMultiValue.test.js create mode 100644 force-app/main/default/lwc/recordPickerMultiValue/recordPickerMultiValue.html create mode 100644 force-app/main/default/lwc/recordPickerMultiValue/recordPickerMultiValue.js create mode 100644 force-app/main/default/lwc/recordPickerMultiValue/recordPickerMultiValue.js-meta.xml diff --git a/force-app/main/default/flexipages/Record_Picker.flexipage-meta.xml b/force-app/main/default/flexipages/Record_Picker.flexipage-meta.xml index 94036471d..7f5031595 100644 --- a/force-app/main/default/flexipages/Record_Picker.flexipage-meta.xml +++ b/force-app/main/default/flexipages/Record_Picker.flexipage-meta.xml @@ -25,6 +25,12 @@ Region + + + recordPickerMultiValue + c_recordPickerMultiValue + + region4 Region diff --git a/force-app/main/default/lwc/recordPickerHello/__tests__/recordPickerHello.test.js b/force-app/main/default/lwc/recordPickerHello/__tests__/recordPickerHello.test.js index 90f529d48..fa8d2a2bb 100644 --- a/force-app/main/default/lwc/recordPickerHello/__tests__/recordPickerHello.test.js +++ b/force-app/main/default/lwc/recordPickerHello/__tests__/recordPickerHello.test.js @@ -62,15 +62,13 @@ describe('recordPickerHello', () => { detail: { recordId: '003Z70000016iOUIAY' } }) ); - // Emit data from @wire + // Emit data from @wire and wait for any asynchronous DOM updates graphql.emit(mockGraphQL); - - // Wait for any asynchronous DOM updates await flushPromises(); + const selectedRecordDetails = element.shadowRoot.querySelector( '.selectedRecordDetails' ); - await flushPromises(); expect(selectedRecordDetails).toBeTruthy(); }); @@ -96,25 +94,21 @@ describe('recordPickerHello', () => { detail: { recordId: null } }) ); - // Emit data from @wire + // Emit data from @wire and wait for any asynchronous DOM updates graphql.emit(mockGraphQLEmptyResults); - - // Wait for any asynchronous DOM updates await flushPromises(); + const selectedRecordDetails = element.shadowRoot.querySelector( '.selectedRecordDetails' ); - await flushPromises(); expect(selectedRecordDetails).toBeFalsy(); }); describe('graphql @wire error', () => { it('shows error panel element', async () => { - // Emit error from @wire + // Emit error from @wire and wait for any asynchronous DOM updates graphql.emitErrors(['an error']); - - // Wait for any asynchronous DOM updates await flushPromises(); // Check for error panel diff --git a/force-app/main/default/lwc/recordPickerMultiValue/__tests__/data/graphqlContactResult.json b/force-app/main/default/lwc/recordPickerMultiValue/__tests__/data/graphqlContactResult.json new file mode 100644 index 000000000..6c2b44e3e --- /dev/null +++ b/force-app/main/default/lwc/recordPickerMultiValue/__tests__/data/graphqlContactResult.json @@ -0,0 +1,30 @@ +{ + "uiapi": { + "query": { + "Contact": { + "edges": [ + { + "node": { + "Id": "005xx000016QpSqAAK", + "Name": { + "value": "Amy Taylor" + }, + "Title": { + "value": "VP of Engineering" + }, + "Phone": { + "value": "4152568563" + }, + "Email": { + "value": "amy@demo.net" + }, + "Picture__c": { + "value": "https://s3-us-west-2.amazonaws.com/dev-or-devrl-s3-bucket/sample-apps/people/amy_taylor.jpg" + } + } + } + ] + } + } + } +} diff --git a/force-app/main/default/lwc/recordPickerMultiValue/__tests__/recordPickerMultiValue.test.js b/force-app/main/default/lwc/recordPickerMultiValue/__tests__/recordPickerMultiValue.test.js new file mode 100644 index 000000000..cdb436b80 --- /dev/null +++ b/force-app/main/default/lwc/recordPickerMultiValue/__tests__/recordPickerMultiValue.test.js @@ -0,0 +1,202 @@ +import { createElement } from 'lwc'; +import { graphql } from 'lightning/uiGraphQLApi'; +import RecordPickerMultiValue from 'c/recordPickerMultiValue'; + +// Mock realistic data +const mockGraphQL = require('./data/graphqlContactResult.json'); + +// Helper function to wait until the microtask queue is empty. This is needed for promise +// timing when calling imperative Apex. +async function flushPromises() { + return Promise.resolve(); +} + +describe('recordPickerMultiValue', () => { + let element; + beforeEach(() => { + // Create component + element = createElement('c-record-picker-multi-value', { + is: RecordPickerMultiValue + }); + element.objectApiName = 'Contact'; + element.label = 'Contact'; + document.body.appendChild(element); + }); + + afterEach(() => { + // The jsdom instance is shared across test cases in a single file so reset the DOM + while (document.body.firstChild) { + document.body.removeChild(document.body.firstChild); + } + // Prevent data saved on mocks from leaking between tests + jest.clearAllMocks(); + }); + + it('is accessible', async () => { + expect(element).toBeAccessible(); + }); + + it('should display the selected record in the pill container', async () => { + // Set selected record + const recordPickerElement = element.shadowRoot.querySelector( + 'lightning-record-picker' + ); + recordPickerElement.value = '005xx000001X83aAAC'; + recordPickerElement.dispatchEvent( + new CustomEvent('change', { + detail: { recordId: '005xx000001X83aAAC' } + }) + ); + await flushPromises(); + + // Emit data from @wire + graphql.emit(mockGraphQL); + await flushPromises(); + + const pillContainer = element.shadowRoot.querySelector( + 'lightning-pill-container' + ); + + expect(pillContainer.items).toEqual([ + { + name: '005xx000016QpSqAAK', + label: 'Amy Taylor', + iconName: 'standard:contact', + type: 'icon' + } + ]); + }); + + it('should clear the input when a selection is made', async () => { + const recordPickerElement = element.shadowRoot.querySelector( + 'lightning-record-picker' + ); + + // Spy on recordPickerElement.clearSelection() + const clearSelection = jest.spyOn( + recordPickerElement, + 'clearSelection' + ); + + // Simulate a record selection in the record picker + recordPickerElement.value = '005xx000001X83aAAC'; + recordPickerElement.dispatchEvent( + new CustomEvent('change', { + detail: { recordId: '005xx000001X83aAAC' } + }) + ); + await flushPromises(); + + // Emit data from @wire + graphql.emit(mockGraphQL); + await flushPromises(); + + expect(clearSelection).toHaveBeenCalled(); + }); + + it('should filter out a record from the suggestions when it has already been selected', async () => { + // Set selected record + const recordPickerElement = element.shadowRoot.querySelector( + 'lightning-record-picker' + ); + recordPickerElement.value = '005xx000016QpSqAAK'; + recordPickerElement.dispatchEvent( + new CustomEvent('change', { + detail: { recordId: '005xx000016QpSqAAK' } + }) + ); + await flushPromises(); + + // Emit data from @wire + graphql.emit(mockGraphQL); + await flushPromises(); + + expect(recordPickerElement.filter.criteria).toEqual( + expect.arrayContaining([ + { + fieldPath: 'Id', + operator: 'nin', + value: ['005xx000016QpSqAAK'] + } + ]) + ); + }); + + it('should remove the corresponding pill when selected record is removed', async () => { + // Set selected record + const recordPickerElement = element.shadowRoot.querySelector( + 'lightning-record-picker' + ); + recordPickerElement.value = '005xx000001X83aAAC'; + recordPickerElement.dispatchEvent( + new CustomEvent('change', { + detail: { recordId: '005xx000001X83aAAC' } + }) + ); + // Emit data from @wire + graphql.emit(mockGraphQL); + await flushPromises(); + + // Simulate a selection removal + const pillContainer = element.shadowRoot.querySelector( + 'lightning-pill-container' + ); + pillContainer.dispatchEvent( + new CustomEvent('itemremove', { + detail: { + item: { + name: '005xx000016QpSqAAK', + label: 'Amy Taylor', + iconName: 'standard:contact' + } + } + }) + ); + await flushPromises(); + + // The pill item has been removed + expect(pillContainer.items).toEqual([]); + }); + + it('should remove a record from filter when it has been removed from selection', async () => { + // Set selected record + const recordPickerElement = element.shadowRoot.querySelector( + 'lightning-record-picker' + ); + recordPickerElement.value = '005xx000001X83aAAC'; + recordPickerElement.dispatchEvent( + new CustomEvent('change', { + detail: { recordId: '005xx000001X83aAAC' } + }) + ); + await flushPromises(); + + // Emit data from @wire + graphql.emit(mockGraphQL); + await flushPromises(); + + // Simulate a selection removal + const pillContainer = element.shadowRoot.querySelector( + 'lightning-pill-container' + ); + pillContainer.dispatchEvent( + new CustomEvent('itemremove', { + detail: { + item: { + name: '005xx000016QpSqAAK', + label: 'Amy Taylor', + iconName: 'standard:contact' + } + } + }) + ); + await flushPromises(); + + // no more filter with the selected record id + expect(recordPickerElement.filter.criteria).toEqual( + expect.arrayContaining([ + { fieldPath: 'Id', operator: 'nin', value: [] } + ]) + ); + }); +}); diff --git a/force-app/main/default/lwc/recordPickerMultiValue/recordPickerMultiValue.html b/force-app/main/default/lwc/recordPickerMultiValue/recordPickerMultiValue.html new file mode 100644 index 000000000..884cea628 --- /dev/null +++ b/force-app/main/default/lwc/recordPickerMultiValue/recordPickerMultiValue.html @@ -0,0 +1,32 @@ + diff --git a/force-app/main/default/lwc/recordPickerMultiValue/recordPickerMultiValue.js b/force-app/main/default/lwc/recordPickerMultiValue/recordPickerMultiValue.js new file mode 100644 index 000000000..4cca3bc95 --- /dev/null +++ b/force-app/main/default/lwc/recordPickerMultiValue/recordPickerMultiValue.js @@ -0,0 +1,132 @@ +import { LightningElement, wire } from 'lwc'; +import { gql, graphql } from 'lightning/uiGraphQLApi'; + +// As of Winter '24, `lightning-record-picker` only supports a single selection. +// This sample component shows how you can turn `lightning-record-picker` into +// a multi-selection record picker. + +// Converts a record to a lightning-pill element +const toContactPill = (record) => ({ + name: record.id, + label: record.name, + iconName: 'standard:contact', + type: 'icon' +}); + +// Converts a list a IDs to lightning-record-picker filter +const toRecordPickerFilter = (ids) => ({ + criteria: [ + { + fieldPath: 'Id', + operator: 'nin', // "not in" operator + value: ids + } + ] +}); + +export default class RecordPickerMultiValue extends LightningElement { + /** + * The id of the last record the user selected using the record picker. + * Used to trigger graphql @wire calls. + */ + selectedRecordId; + + /** + * The list of selected records (id and name). + * Used to compute and update the list of pill items + * and to update the record picker filter + * as we want to filter out already selected records from the record picker suggestions + */ + selectedRecords = []; + + /** + * Builds lightning-pill items from `selectedRecords` + */ + get pillItems() { + return this.selectedRecords.map(toContactPill); + } + + /** + * Builds lightning-record-picker filter from `selectedRecords` + */ + get recordPickerFilter() { + // Convert selectedRecords to a list of IDs + const selectedRecordIds = this.selectedRecords.map( + (record) => record.id + ); + return toRecordPickerFilter(selectedRecordIds); + } + + // Variables for the GraphQL query + get variables() { + return this.selectedRecordId + ? { + selectedRecordId: this.selectedRecordId + } + : undefined; + } + + // A GraphQL query is sent after the record picker change event has been dispatched + // to get the name of the records that are selected in the record picker. + @wire(graphql, { + query: gql` + query searchContacts($selectedRecordId: ID) { + uiapi { + query { + Contact( + where: { Id: { eq: $selectedRecordId } } + first: 1 + ) { + edges { + node { + Id + Name { + value + } + } + } + } + } + } + } + `, + variables: '$variables' + }) + wiredGraphQL({ data, errors }) { + this.wireError = errors; + if (errors || !data) { + return; + } + + const graphqlResults = data.uiapi.query.Contact.edges.map((edge) => ({ + id: edge.node.Id, + name: edge.node.Name.value + })); + + // Add to selectedRecords + const selectedRecord = graphqlResults[0]; + this.selectedRecords = [...this.selectedRecords, selectedRecord]; + + // We want the record picker input to be cleared + // each time the user selects a record suggestion + this._clearRecordPickerSelection(); + } + + _clearRecordPickerSelection() { + this.refs.recordPicker.clearSelection(); + this.selectedRecordId = undefined; + } + + handlePillRemove(event) { + const recordId = event.detail.item.name; + + // Remove `recordId` from `selectedRecords` + this.selectedRecords = this.selectedRecords.filter( + (record) => record.id !== recordId + ); + } + + handleRecordPickerChange(event) { + this.selectedRecordId = event.detail.recordId; + } +} diff --git a/force-app/main/default/lwc/recordPickerMultiValue/recordPickerMultiValue.js-meta.xml b/force-app/main/default/lwc/recordPickerMultiValue/recordPickerMultiValue.js-meta.xml new file mode 100644 index 000000000..4207d84b5 --- /dev/null +++ b/force-app/main/default/lwc/recordPickerMultiValue/recordPickerMultiValue.js-meta.xml @@ -0,0 +1,25 @@ + + + 59.0 + true + + lightning__RecordAction + lightning__AppPage + lightning__RecordPage + lightning__HomePage + lightning__Tab + lightningCommunity__Page + lightningCommunity__Default + + + + ScreenAction + + + + + + + + +