diff --git a/force-app/main/default/flexipages/GraphQL.flexipage-meta.xml b/force-app/main/default/flexipages/GraphQL.flexipage-meta.xml index 60be5bbde..b163b86c0 100644 --- a/force-app/main/default/flexipages/GraphQL.flexipage-meta.xml +++ b/force-app/main/default/flexipages/GraphQL.flexipage-meta.xml @@ -11,6 +11,12 @@ graphqlContacts + + + graphqlMutations + graphqlMutations + + region2 Region diff --git a/force-app/main/default/lwc/graphqlMutations/__tests__/data/graphqlContactResponse.json b/force-app/main/default/lwc/graphqlMutations/__tests__/data/graphqlContactResponse.json new file mode 100644 index 000000000..a598f38f0 --- /dev/null +++ b/force-app/main/default/lwc/graphqlMutations/__tests__/data/graphqlContactResponse.json @@ -0,0 +1,71 @@ +{ + "uiapi": { + "query": { + "Contact": { + "edges": [ + { + "node": { + "Id": "0031700000pJRRSAA4", + "FirstName": { + "value": "Amy" + }, + "LastName": { + "value": "Taylor" + }, + "Title": { + "value": "VP of Engineering" + }, + "Phone": { + "value": "4152568563" + }, + "Email": { + "value": "amy@demo.net" + } + } + }, + { + "node": { + "Id": "0031700000pJRRTAA4", + "FirstName": { + "value": "Michael" + }, + "LastName": { + "value": "Jones" + }, + "Title": { + "value": "VP of Sales" + }, + "Phone": { + "value": "4158526633" + }, + "Email": { + "value": "michael@demo.net" + } + } + }, + { + "node": { + "Id": "0031700000pJRRUAA4", + "FirstName": { + "value": "Jennifer" + }, + "LastName": { + "value": "Wu" + }, + "Title": { + "value": "CEO" + }, + "Phone": { + "value": "4158521463" + }, + "Email": { + "value": "jennifer@demo.net" + } + } + } + ] + } + } + } +} + diff --git a/force-app/main/default/lwc/graphqlMutations/__tests__/graphqlMutations.test.js b/force-app/main/default/lwc/graphqlMutations/__tests__/graphqlMutations.test.js new file mode 100644 index 000000000..44322073e --- /dev/null +++ b/force-app/main/default/lwc/graphqlMutations/__tests__/graphqlMutations.test.js @@ -0,0 +1,278 @@ +import { createElement } from '@lwc/engine-dom'; +import GraphqlMutations from 'c/graphqlMutations'; +import { graphql, executeMutation } from 'lightning/graphql'; +import { ShowToastEventName } from 'lightning/platformShowToastEvent'; + +// Mock realistic GraphQL contact data +const mockGraphQLContactResponse = require('./data/graphqlContactResponse.json'); + +// Mock lightning/graphql with wire adapter and executeMutation +jest.mock('lightning/graphql', () => { + const actual = jest.requireActual('lightning/graphql'); + return { + ...actual, + executeMutation: jest.fn(), + gql: jest.fn((strings, ...values) => + strings.reduce( + (acc, str, i) => acc + str + (i < values.length ? values[i] : ''), + '' + ) + ) + }; +}); + +const DRAFT_VALUES = [ + { + Id: '0031700000pJRRSAA4', + FirstName: 'Amy', + LastName: 'Taylor', + Title: 'VP of Engineering', + Phone: '4152568563', + Email: 'amy@new_demo.net' + }, + { + Id: '0031700000pJRRTAA4', + FirstName: 'Michael', + LastName: 'Jones', + Title: 'VP of Sales', + Phone: '4158526633', + Email: 'michael@new_demo.net' + } +]; + +describe('c-graphql-mutations', () => { + 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(); + }); + + // Helper function to wait until the microtask queue is empty + async function flushPromises() { + // eslint-disable-next-line @lwc/lwc/no-async-operation + return new Promise((resolve) => setTimeout(resolve, 0)); + } + + describe('graphql @wire data', () => { + it('renders three rows in the lightning datatable', async () => { + const element = createElement('c-graphql-mutations', { + is: GraphqlMutations + }); + document.body.appendChild(element); + + // Emit data from @wire + graphql.emit(mockGraphQLContactResponse); + + // Wait for any asynchronous DOM updates + await flushPromises(); + + const tableEl = element.shadowRoot.querySelector('lightning-datatable'); + + // Validate the datatable is populated with correct number of records + expect(tableEl.data.length).toBe(3); + + // Validate the record to have rendered with correct data + expect(tableEl.data[0].FirstName).toBe('Amy'); + expect(tableEl.data[0].LastName).toBe('Taylor'); + expect(tableEl.data[0].Email).toBe('amy@demo.net'); + }); + + it('is accessible when data is returned', async () => { + const element = createElement('c-graphql-mutations', { + is: GraphqlMutations + }); + document.body.appendChild(element); + + // Emit data from @wire + graphql.emit(mockGraphQLContactResponse); + + // Wait for any asynchronous DOM updates + await flushPromises(); + + // Check accessibility + await expect(element).toBeAccessible(); + }); + }); + + + describe('save handler with executeMutation', () => { + it('calls executeMutation when save is triggered', async () => { + // Mock executeMutation to return success + executeMutation.mockResolvedValue({ + errors: null + }); + + const element = createElement('c-graphql-mutations', { + is: GraphqlMutations + }); + document.body.appendChild(element); + + const refreshGraphQL = jest.fn().mockResolvedValue(); + // Emit data from @wire + graphql.emit(mockGraphQLContactResponse, () => true, refreshGraphQL); + + // Wait for any asynchronous DOM updates + await flushPromises(); + + // Simulate save event on datatable + const tableEl = element.shadowRoot.querySelector('lightning-datatable'); + tableEl.dispatchEvent( + new CustomEvent('save', { + detail: { + draftValues: DRAFT_VALUES + } + }) + ); + + // Wait for any asynchronous DOM updates - need extra time for async operations + await flushPromises(); + + const calledQuery = executeMutation.mock.calls[0][0].query; + expect(calledQuery).toBe(`mutation ContactUpdateExample($input0: ContactUpdateInput!, $input1: ContactUpdateInput!){uiapi (input: { allOrNone: false }) { query0: ContactUpdate(input: $input0) + { + success + } query1: ContactUpdate(input: $input1) + { + success + } } }`); + }); + + it('displays a success toast after records are updated', async () => { + // Mock executeMutation to return success + executeMutation.mockResolvedValue({ + errors: null + }); + + const element = createElement('c-graphql-mutations', { + is: GraphqlMutations + }); + document.body.appendChild(element); + + // Mock handler for toast event + const toastHandler = jest.fn(); + element.addEventListener(ShowToastEventName, toastHandler); + + const refreshGraphQL = jest.fn().mockResolvedValue(); + // Emit data from @wire + graphql.emit(mockGraphQLContactResponse, () => true, refreshGraphQL); + + // Wait for any asynchronous DOM updates + await flushPromises(); + + // Simulate save event on datatable + const tableEl = element.shadowRoot.querySelector('lightning-datatable'); + tableEl.dispatchEvent( + new CustomEvent('save', { + detail: { + draftValues: DRAFT_VALUES + } + }) + ); + + // Wait for any asynchronous DOM updates - need extra time for async operations + await flushPromises(); + + // Validate the toast event is called with success + expect(toastHandler).toHaveBeenCalled(); + expect(toastHandler.mock.calls[0][0].detail.variant).toBe('success'); + expect(toastHandler.mock.calls[0][0].detail.title).toBe('Success'); + + // Validate refreshGraphQL is called and the draft values are reset + expect(refreshGraphQL).toHaveBeenCalled(); + expect(tableEl.draftValues).toEqual([]); + }); + + it('displays an error toast when graphql mutation returns errors', async () => { + // Mock executeMutation to return errors + executeMutation.mockResolvedValue({ + errors: ['GraphQL error occurred'] + }); + + const element = createElement('c-graphql-mutations', { + is: GraphqlMutations + }); + document.body.appendChild(element); + + // Mock handler for toast event + const toastHandler = jest.fn(); + element.addEventListener(ShowToastEventName, toastHandler); + + const refreshGraphQL = jest.fn().mockResolvedValue(); + // Emit data from @wire + graphql.emit(mockGraphQLContactResponse, () => true, refreshGraphQL); + + // Wait for any asynchronous DOM updates + await flushPromises(); + + // Simulate save event on datatable + const tableEl = element.shadowRoot.querySelector('lightning-datatable'); + tableEl.dispatchEvent( + new CustomEvent('save', { + detail: { + draftValues: DRAFT_VALUES + } + }) + ); + + // Wait for any asynchronous DOM updates - need extra time for async operations + await flushPromises(); + + // Validate the toast event is called with error + expect(toastHandler).toHaveBeenCalled(); + expect(toastHandler.mock.calls[0][0].detail.variant).toBe('error'); + expect(toastHandler.mock.calls[0][0].detail.title).toBe('GraphQl Error'); + + // Validate refreshGraphQL is NOT called when there's an error + expect(refreshGraphQL).not.toHaveBeenCalled(); + }); + + it('displays an error toast on executeMutation exception', async () => { + // Mock executeMutation to reject + executeMutation.mockRejectedValue({ + body: { + message: 'Error executing mutation' + } + }); + + const element = createElement('c-graphql-mutations', { + is: GraphqlMutations + }); + document.body.appendChild(element); + + // Mock handler for toast event + const toastHandler = jest.fn(); + element.addEventListener(ShowToastEventName, toastHandler); + + const refreshGraphQL = jest.fn().mockResolvedValue(); + // Emit data from @wire + graphql.emit(mockGraphQLContactResponse, () => true, refreshGraphQL); + + // Wait for any asynchronous DOM updates + await flushPromises(); + + // Simulate save event on datatable + const tableEl = element.shadowRoot.querySelector('lightning-datatable'); + tableEl.dispatchEvent( + new CustomEvent('save', { + detail: { + draftValues: DRAFT_VALUES + } + }) + ); + + // Wait for any asynchronous DOM updates - need extra time for async operations + await flushPromises(); + + // Validate the toast event is called with error + expect(toastHandler).toHaveBeenCalled(); + expect(toastHandler.mock.calls[0][0].detail.variant).toBe('error'); + expect(toastHandler.mock.calls[0][0].detail.title).toBe( + 'Error while updating or refreshing records' + ); + }); + }); +}); + diff --git a/force-app/main/default/lwc/graphqlMutations/graphqlMutations.html b/force-app/main/default/lwc/graphqlMutations/graphqlMutations.html new file mode 100644 index 000000000..f1ddd6dcc --- /dev/null +++ b/force-app/main/default/lwc/graphqlMutations/graphqlMutations.html @@ -0,0 +1,27 @@ + + diff --git a/force-app/main/default/lwc/graphqlMutations/graphqlMutations.js b/force-app/main/default/lwc/graphqlMutations/graphqlMutations.js new file mode 100644 index 000000000..fcc8b3e7c --- /dev/null +++ b/force-app/main/default/lwc/graphqlMutations/graphqlMutations.js @@ -0,0 +1,178 @@ +import { LightningElement, wire } from 'lwc'; +import { gql, graphql, executeMutation } from 'lightning/graphql'; + +import { ShowToastEvent } from 'lightning/platformShowToastEvent'; +import FIRSTNAME_FIELD from '@salesforce/schema/Contact.FirstName'; +import LASTNAME_FIELD from '@salesforce/schema/Contact.LastName'; +import TITLE_FIELD from '@salesforce/schema/Contact.Title'; +import PHONE_FIELD from '@salesforce/schema/Contact.Phone'; +import EMAIL_FIELD from '@salesforce/schema/Contact.Email'; + +const COLS = [ + { + label: 'Name', + fieldName: FIRSTNAME_FIELD.fieldApiName, + editable: true + }, + { + label: 'Last Name', + fieldName: LASTNAME_FIELD.fieldApiName, + editable: true + }, + { label: 'Title', fieldName: TITLE_FIELD.fieldApiName, editable: true }, + { + label: 'Phone', + fieldName: PHONE_FIELD.fieldApiName, + type: 'phone', + editable: true + }, + { + label: 'Email', + fieldName: EMAIL_FIELD.fieldApiName, + type: 'email', + editable: true + } +]; +export default class GraphqlMutations extends LightningElement { + columns = COLS; + draftValues = []; + refreshGraphQL; + contacts; + errors; + + // Using GraphQL to get contacts + @wire(graphql, { + query: gql` + query getContacts { + uiapi { + query { + Contact{ + edges { + node { + Id + FirstName { + value + } + LastName { + value + } + Phone { + value + } + Title { + value + } + Email { + value + } + } + } + } + } + } + } + ` + }) + wiredValues(result) { + this.isLoading = false; + this.account = undefined; + this.errors = undefined; + + const { errors, data, refresh } = result; + // We hold a reference to the refresh function on the graphQL query result so we can call it later. + if (refresh) { + this.refreshGraphQL = refresh; + } + if (data) { + this.contacts = data.uiapi.query.Contact.edges.map((edge) => ({ + Id: edge.node.Id, + FirstName: edge.node.FirstName.value, + LastName: edge.node.LastName.value, + Phone: edge.node.Phone.value, + Title: edge.node.Title.value, + Email: edge.node.Email.value + })); + } + if (errors) { + this.errors = errors; + } + } + + async handleSave(event) { + // Convert datatable draft values into record objects + const params = event.detail.draftValues.slice().map((draftValue) => { + const fields = Object.assign({}, draftValue); + return { fields }; + }); + + // Clear all datatable draft values + this.draftValues = []; + + const variables = this.buildVariables(params); + const query = this.buildQuery(params); + try { + const result = await executeMutation({ query, variables }); + if (result.errors) { + this.dispatchEvent( + new ShowToastEvent({ + title: 'GraphQl Error', + message: 'Error in graphql mutation', + variant: 'error' + }) + ); + } else { + this.dispatchEvent( + new ShowToastEvent({ + title: 'Success', + message: 'Contacts updated', + variant: 'success' + }) + ); + // Refresh data in the datatable + await this.refreshGraphQL?.(); + } + } catch (error) { + this.dispatchEvent( + new ShowToastEvent({ + title: 'Error while updating or refreshing records', + message: error.body.message, + variant: 'error' + }) + ); + } + } + buildVariables(params){ + const variables = {} + // eslint-disable-next-line guard-for-in + for(let index in params){ + const fields = params[index].fields; + let input = {Contact: {}}; + for(let field of Object.keys(fields)){ + if(field === 'Id'){ + input.Id = fields[field]; + } else { + input.Contact[field] = fields[field]; + } + } + variables[`input${index}`] = input; + } + return variables; + } + + buildQuery(params){ + let header = ''; + let body = ''; + let query = 'mutation ContactUpdateExample('; + // eslint-disable-next-line guard-for-in + for(let index in params){ + header+=`$input${index}: ContactUpdateInput!, `; + let queryBlock = ` query${index}: ContactUpdate(input: $input${index}) + { + success + }`; + body += queryBlock; + } + query+=`${header.slice(0, -2)}){uiapi (input: { allOrNone: false }) {${body} } }` + return gql`${query.trim()}`; + } +} diff --git a/force-app/main/default/lwc/graphqlMutations/graphqlMutations.js-meta.xml b/force-app/main/default/lwc/graphqlMutations/graphqlMutations.js-meta.xml new file mode 100644 index 000000000..6cc1e7780 --- /dev/null +++ b/force-app/main/default/lwc/graphqlMutations/graphqlMutations.js-meta.xml @@ -0,0 +1,10 @@ + + + 58.0 + true + + lightning__AppPage + lightning__RecordPage + lightning__HomePage + +