From 481abb4fb3d104a1aade2253a2a7eee4730595f0 Mon Sep 17 00:00:00 2001 From: tschug Date: Tue, 25 Aug 2026 23:50:12 -0400 Subject: [PATCH 01/10] Summer 26 Security updates: - Code Analyzer security findings: class is now with sharing; the getDurableId SOQL uses WITH SYSTEM_MODE. - Custom Permission + Permission Set gate: added Manage_Problem_Profiles custom permission and Problem_Profile_Editor_Access permission set (custom permission + Apex class access + tab visibility). Enforced server-side in both @AuraEnabled methods via FeatureManagement.checkPermission. --- .../classes/lwcProfileEditorController.cls | 102 ++++++++++++------ .../lwcProfileEditorControllerTest.cls | 102 ++++++++++++++---- ...Problem_Profiles.customPermission-meta.xml | 7 ++ ...ofile_Editor_Access.permissionset-meta.xml | 19 ++++ 4 files changed, 176 insertions(+), 54 deletions(-) create mode 100644 force-app/main/default/customPermissions/Manage_Problem_Profiles.customPermission-meta.xml create mode 100644 force-app/main/default/permissionsets/Problem_Profile_Editor_Access.permissionset-meta.xml diff --git a/force-app/main/default/classes/lwcProfileEditorController.cls b/force-app/main/default/classes/lwcProfileEditorController.cls index dbcace8..968f9d0 100644 --- a/force-app/main/default/classes/lwcProfileEditorController.cls +++ b/force-app/main/default/classes/lwcProfileEditorController.cls @@ -1,43 +1,77 @@ -public class lwcProfileEditorController { +public with sharing class lwcProfileEditorController { + @AuraEnabled(cacheable=true) + public static List> getSObjects() { + checkAccess(); - @AuraEnabled(cacheable=true) - public static List> getSObjects() { - List> results = new List>(); - Map allTypes = Schema.getGlobalDescribe(); + List> results = new List>(); + Map allTypes = Schema.getGlobalDescribe(); - for (String objType : new List(allTypes.keySet())) { + for (String objType : new List(allTypes.keySet())) { + Schema.DescribeSObjectResult describeObject = allTypes.get(objType) + .getDescribe(); + String objectType = describeObject.getName(); + String objectLabel = describeObject.getLabel(); + Boolean isCustom = describeObject.isCustom(); + Boolean isCustomSetting = describeObject.isCustomSetting(); + Boolean isCustomChangeEvent = objectType.endsWith('__ChangeEvent'); + Boolean isPlatformEvent = objectType.endsWith('__e'); + Boolean isCMDT = objectType.endsWith('__mdt'); + Boolean isStdFeed = !isCustom && objectType.endsWith('Feed'); + Boolean isStdHistory = !isCustom && objectType.endsWith('History'); + Boolean isStdShare = !isCustom && objectType.endsWith('Share'); + Boolean isStdChangeEvent = + !isCustom && objectType.endsWith('ChangeEvent'); - Schema.DescribeSObjectResult describeObject = allTypes.get(objType).getDescribe(); - String objectType = describeObject.getName(); - String objectLabel = describeObject.getLabel(); - Boolean isCustom = describeObject.isCustom(); - Boolean isCustomSetting = describeObject.isCustomSetting(); - Boolean isCustomChangeEvent = objectType.endsWith('__ChangeEvent'); - Boolean isPlatformEvent = objectType.endsWith('__e'); - Boolean isCMDT = objectType.endsWith('__mdt'); - Boolean isStdFeed = !isCustom && objectType.endsWith('Feed'); - Boolean isStdHistory = !isCustom && objectType.endsWith('History'); - Boolean isStdShare = !isCustom && objectType.endsWith('Share'); - Boolean isStdChangeEvent = !isCustom && objectType.endsWith('ChangeEvent'); - - if (!isCustomSetting && !isCustomChangeEvent && !isCMDT && !isPlatformEvent && !isStdFeed && !isStdChangeEvent && !isStdHistory && !isStdShare ) { - results.add(new Map{'label' => objectLabel + ' (' + objectType + ')', 'value' => objectType}); - } + if ( + !isCustomSetting && + !isCustomChangeEvent && + !isCMDT && + !isPlatformEvent && + !isStdFeed && + !isStdChangeEvent && + !isStdHistory && + !isStdShare + ) { + results.add( + new Map{ + 'label' => objectLabel + + ' (' + + objectType + + ')', + 'value' => objectType + } + ); + } + } - } + return results; + } - return results; + @AuraEnabled(cacheable=true) + public static string getDurableId(String qualifiedApiName) { + checkAccess(); + List eds = [ + SELECT DurableId + FROM EntityDefinition + WHERE QualifiedApiName = :qualifiedApiName + WITH SYSTEM_MODE + LIMIT 1 + ]; + if (eds.isEmpty()) { + return null; } - @AuraEnabled(cacheable=true) - public static string getDurableId(String qualifiedApiName){ - List eds = [SELECT DurableId FROM EntityDefinition WHERE QualifiedApiName = :qualifiedAPiName LIMIT 1]; - if(eds.isEmpty()){ - return null; - } - - return eds[0].DurableId; - } + return eds[0].DurableId; + } -} \ No newline at end of file + private static void checkAccess() { + if (!FeatureManagement.checkPermission('Manage_Problem_Profiles')) { + AuraHandledException ex = new AuraHandledException( + 'You do not have access to this feature.' + ); + ex.setMessage('You do not have access to this feature.'); + throw ex; + } + } +} diff --git a/force-app/main/default/classes/lwcProfileEditorControllerTest.cls b/force-app/main/default/classes/lwcProfileEditorControllerTest.cls index a23d89a..65fa399 100644 --- a/force-app/main/default/classes/lwcProfileEditorControllerTest.cls +++ b/force-app/main/default/classes/lwcProfileEditorControllerTest.cls @@ -1,35 +1,97 @@ @IsTest private class lwcProfileEditorControllerTest { + private static User createTestUser(Boolean withAccess) { + Profile standardProfile = [ + SELECT Id + FROM Profile + WHERE Name = 'Standard User' + LIMIT 1 + ]; + User testUser = new User( + FirstName = 'Test', + LastName = 'ProblemProfileUser', + Email = 'problem.profile.test.user@example.com.invalid', + Username = 'problem.profile.test.user' + + Crypto.getRandomInteger() + + '@example.com.invalid', + Alias = 'ppuser', + TimeZoneSidKey = 'America/Los_Angeles', + LocaleSidKey = 'en_US', + EmailEncodingKey = 'UTF-8', + LanguageLocaleKey = 'en_US', + ProfileId = standardProfile.Id + ); + insert testUser; - @IsTest - static void successfullyRetrieveObjectDescribe(){ - - List> objectValues = lwcProfileEditorController.getSObjects(); + if (withAccess) { + PermissionSet accessPermSet = [ + SELECT Id + FROM PermissionSet + WHERE Name = 'Problem_Profile_Editor_Access' + LIMIT 1 + ]; + insert new PermissionSetAssignment( + AssigneeId = testUser.Id, + PermissionSetId = accessPermSet.Id + ); + } + + return testUser; + } + + @IsTest + static void successfullyRetrieveObjectDescribe() { + User testUser = createTestUser(true); + + List> objectValues; + System.runAs(testUser) { + objectValues = lwcProfileEditorController.getSObjects(); + } + + Assert.isFalse(objectValues.isEmpty()); + + Map firstRecord = objectValues[0]; + Assert.isNotNull(firstRecord.get('label')); + Assert.isNotNull(firstRecord.get('value')); + } - Assert.isFalse(objectValues.isEmpty()); + @IsTest + static void successfullyRetrieveDurableId() { + User testUser = createTestUser(true); - Map firstRecord = objectValues[0]; - Assert.isNotNull(firstRecord.get('label')); - Assert.isNotNull(firstRecord.get('value')); - + String durableId; + System.runAs(testUser) { + durableId = lwcProfileEditorController.getDurableId('Account'); } - @IsTest - static void successfullyRetrieveDurableId(){ + Assert.areEqual('Account', durableId); + } - String durableId = lwcProfileEditorController.getDurableId('Account'); + @IsTest + static void successfullyReturnNullForNoMatch() { + User testUser = createTestUser(true); - Assert.areEqual('Account', durableId); - + String durableId; + System.runAs(testUser) { + durableId = lwcProfileEditorController.getDurableId('Account__test'); } - @IsTest - static void successfullyReturnNullForNoMatch(){ + Assert.isNull(durableId); + } - String durableId = lwcProfileEditorController.getDurableId('Account__test'); + @IsTest + static void throwsWhenUserLacksCustomPermission() { + User testUser = createTestUser(false); - Assert.isNull(durableId); - + Boolean exceptionThrown = false; + System.runAs(testUser) { + try { + lwcProfileEditorController.getSObjects(); + } catch (AuraHandledException e) { + exceptionThrown = true; + } } -} \ No newline at end of file + Assert.isTrue(exceptionThrown); + } +} diff --git a/force-app/main/default/customPermissions/Manage_Problem_Profiles.customPermission-meta.xml b/force-app/main/default/customPermissions/Manage_Problem_Profiles.customPermission-meta.xml new file mode 100644 index 0000000..bdee119 --- /dev/null +++ b/force-app/main/default/customPermissions/Manage_Problem_Profiles.customPermission-meta.xml @@ -0,0 +1,7 @@ + + + Grants access to the Problem Profile Editor tool, used to navigate to Setup pages for profiles that require Classic or URL-based access to edit. + false + + diff --git a/force-app/main/default/permissionsets/Problem_Profile_Editor_Access.permissionset-meta.xml b/force-app/main/default/permissionsets/Problem_Profile_Editor_Access.permissionset-meta.xml new file mode 100644 index 0000000..2b43a4e --- /dev/null +++ b/force-app/main/default/permissionsets/Problem_Profile_Editor_Access.permissionset-meta.xml @@ -0,0 +1,19 @@ + + + + lwcProfileEditorController + true + + + true + Manage_Problem_Profiles + + Grants access to the Problem Profile Editor tool for navigating to hard-to-reach Setup pages for a Profile. + false + + + Problem_Profile_Editor + Visible + + From ffa26277cb1994e1b94b7a40ca3cba6dc6ebe94c Mon Sep 17 00:00:00 2001 From: tschug Date: Wed, 26 Aug 2026 00:43:55 -0400 Subject: [PATCH 02/10] LWC Updates for security and button bug (Summer '26) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `window.open` → hidden-anchor fix (profileEditor.html/.js): added a hidden ` with rel="noopener noreferrer"`; `generateUrl()` now sets its href and calls `.click()` instead of `window.open()`. - Custom Permission gate: Enforced client-side via `@salesforce/customPermission/Manage_Problem_Profiles` gating the whole card behind an access-denied message. - Jest tests: added __tests__ suites for profileEditor (anchor-click navigation, button enable/disable, reset, access-denied) and lwcComboBoxSearch (filtering, selection, clear). --- .../__tests__/lwcComboBoxSearch.test.js | 85 ++++ .../lwcComboBoxSearch/lwcComboBoxSearch.js | 187 ++++---- .../__tests__/profileEditor.test.js | 162 +++++++ .../profileEditorAccessDenied.test.js | 66 +++ .../lwc/profileEditor/profileEditor.html | 166 ++++--- .../lwc/profileEditor/profileEditor.js | 441 +++++++++--------- 6 files changed, 734 insertions(+), 373 deletions(-) create mode 100644 force-app/main/default/lwc/lwcComboBoxSearch/__tests__/lwcComboBoxSearch.test.js create mode 100644 force-app/main/default/lwc/profileEditor/__tests__/profileEditor.test.js create mode 100644 force-app/main/default/lwc/profileEditor/__tests__/profileEditorAccessDenied.test.js diff --git a/force-app/main/default/lwc/lwcComboBoxSearch/__tests__/lwcComboBoxSearch.test.js b/force-app/main/default/lwc/lwcComboBoxSearch/__tests__/lwcComboBoxSearch.test.js new file mode 100644 index 0000000..92d41fb --- /dev/null +++ b/force-app/main/default/lwc/lwcComboBoxSearch/__tests__/lwcComboBoxSearch.test.js @@ -0,0 +1,85 @@ +import { createElement } from "lwc"; +import LwcComboBoxSearch from "c/lwcComboBoxSearch"; + +const OPTIONS = [ + { label: "Account", value: "Account" }, + { label: "Contact", value: "Contact" }, + { label: "Case", value: "Case" } +]; + +function createComboBox() { + const element = createElement("c-lwc-combo-box-search", { + is: LwcComboBoxSearch + }); + element.inputName = "objectSelector"; + element.inputLabel = "Object"; + element.inputOptions = OPTIONS; + document.body.appendChild(element); + return element; +} + +describe("c-lwc-combo-box-search", () => { + afterEach(() => { + while (document.body.firstChild) { + document.body.removeChild(document.body.firstChild); + } + }); + + it("filters options case-insensitively as the user types", async () => { + const element = createComboBox(); + const input = element.shadowRoot.querySelector("lightning-input"); + + input.dispatchEvent( + new CustomEvent("change", { detail: { value: "acc" } }) + ); + await Promise.resolve(); + + const items = element.shadowRoot.querySelectorAll("li[data-value]"); + expect(items.length).toBe(1); + expect(items[0].dataset.value).toBe("Account"); + }); + + it("dispatches a selected event with the chosen value and closes the dropdown", async () => { + const element = createComboBox(); + const handler = jest.fn(); + element.addEventListener("selected", handler); + + const input = element.shadowRoot.querySelector("lightning-input"); + input.dispatchEvent(new CustomEvent("focus")); + await Promise.resolve(); + + const item = Array.from( + element.shadowRoot.querySelectorAll("li[data-value]") + ).find((li) => li.dataset.value === "Contact"); + item.dispatchEvent(new CustomEvent("click")); + await Promise.resolve(); + + expect(handler).toHaveBeenCalledTimes(1); + expect(handler.mock.calls[0][0].detail.value).toBe("Contact"); + expect(element.shadowRoot.querySelector("ul")).toBeNull(); + }); + + it("clears the selected value via the exposed API method", async () => { + const element = createComboBox(); + const input = element.shadowRoot.querySelector("lightning-input"); + input.dispatchEvent(new CustomEvent("focus")); + await Promise.resolve(); + + const item = Array.from( + element.shadowRoot.querySelectorAll("li[data-value]") + ).find((li) => li.dataset.value === "Case"); + item.dispatchEvent(new CustomEvent("click")); + await Promise.resolve(); + + expect(element.shadowRoot.querySelector("lightning-input").value).toBe( + "Case" + ); + + element.clearSelectedValue(); + await Promise.resolve(); + + expect( + element.shadowRoot.querySelector("lightning-input").value + ).toBeNull(); + }); +}); diff --git a/force-app/main/default/lwc/lwcComboBoxSearch/lwcComboBoxSearch.js b/force-app/main/default/lwc/lwcComboBoxSearch/lwcComboBoxSearch.js index b1aef7c..4bce94d 100644 --- a/force-app/main/default/lwc/lwcComboBoxSearch/lwcComboBoxSearch.js +++ b/force-app/main/default/lwc/lwcComboBoxSearch/lwcComboBoxSearch.js @@ -2,107 +2,108 @@ // SOURCE 2: https://github.com/rahulgawale/Searchable-Combobox-Lwc // SOURCE 3: https://hugolemos.medium.com/lwc-lookup-component-for-custom-searches-4e58c9dd7e69 -import { LightningElement, api } from 'lwc'; +import { LightningElement, api } from "lwc"; export default class LwcComboBoxSearch extends LightningElement { - - @api inputName; - @api inputLabel; - @api inputPlaceholder; - @api inputHelp; - @api inputOptions; - - searchInput; - canBlur = true; - showOptions = false; - - searchResults; - selectedSearchResult; - - get selectedValue() { - return this.selectedSearchResult?.label ?? null; + @api inputName; + @api inputLabel; + @api inputPlaceholder; + @api inputHelp; + @api inputOptions; + + searchInput; + canBlur = true; + showOptions = false; + + searchResults; + selectedSearchResult; + + get selectedValue() { + return this.selectedSearchResult?.label ?? null; + } + + handleFocus() { + if (!this.selectedSearchResult) { + this.showOptions = true; + this.showPickListOptions(); } - - handleFocus() { - if(!this.selectedSearchResult){ - this.showOptions = true; - this.showPickListOptions(); + } + + allowBlur() { + this.canBlur = true; + } + cancelBlur() { + this.canBlur = false; + } + + handleBlur() { + if (this.canBlur && !this.selectedSearchResult) { + // eslint-disable-next-line @lwc/lwc/no-async-operation + setTimeout(() => { + if (!this.selectedSearchResult) { + this.clearSearchResults(); } + }, 300); } + } - allowBlur() { - this.canBlur = true; + handleCommit() { + if (!this.searchInput) { + this.showOptions = true; + this.searchResults = [...this.inputOptions]; } - cancelBlur() { - this.canBlur = false; - } - - handleBlur(){ - if(this.canBlur && !this.selectedSearchResult){ - setTimeout(() => { - if(!this.selectedSearchResult){ - this.clearSearchResults(); - } - }, 300); - } + } + + handleChange(event) { + this.cancelBlur(); + this.showOptions = true; + this.searchInput = event.detail.value.toLowerCase(); + if (this.searchInput) { + const result = this.inputOptions.filter((pickListOption) => + pickListOption.label.toLowerCase().includes(this.searchInput) + ); + this.searchResults = result; + } else { + this.selectedSearchResult = null; + this.allowBlur(); + this.returnValue(null); } - - handleCommit(){ - if(!this.searchInput){ - this.showOptions = true; - this.searchResults = [...this.inputOptions]; - } + } + + selectSearchResult(event) { + const selectedValue = event.currentTarget.dataset.value; + this.selectedSearchResult = this.inputOptions.find( + (pickListOption) => pickListOption.value === selectedValue + ); + this.clearSearchResults(); + this.searchInput = null; + this.allowBlur(); + this.returnValue(selectedValue); + } + + @api + clearSelectedValue() { + this.selectedSearchResult = null; + } + + clearSearchResults() { + this.showOptions = false; + this.searchResults = null; + } + + showPickListOptions() { + if (!this.searchResults && !this.selectedSearchResult) { + this.searchResults = [...this.inputOptions]; } + } - handleChange(event) { - this.cancelBlur(); - this.showOptions = true; - this.searchInput = event.detail.value.toLowerCase(); - if(this.searchInput){ - const result = this.inputOptions.filter((pickListOption) => - pickListOption.label.toLowerCase().includes(this.searchInput) - ); - this.searchResults = result; - } else { - this.selectedSearchResult = null; - this.allowBlur(); - this.returnValue(null); + returnValue(result) { + this.dispatchEvent( + new CustomEvent("selected", { + detail: { + value: result } - } - - selectSearchResult(event) { - const selectedValue = event.currentTarget.dataset.value; - this.selectedSearchResult = this.inputOptions.find( - (pickListOption) => pickListOption.value === selectedValue - ); - this.clearSearchResults(); - this.searchInput = null; - this.allowBlur(); - this.returnValue(selectedValue); - } - - @api - clearSelectedValue(){ - this.selectedSearchResult = null; - } - - clearSearchResults() { - this.showOptions = false; - this.searchResults = null; - } - - showPickListOptions() { - if (!this.searchResults && !this.selectedSearchResult) { - this.searchResults = [...this.inputOptions]; - } - } - - returnValue(result) { - this.dispatchEvent(new CustomEvent('selected', { - detail: { - value: result - } - })); - } - -} \ No newline at end of file + }) + ); + } +} diff --git a/force-app/main/default/lwc/profileEditor/__tests__/profileEditor.test.js b/force-app/main/default/lwc/profileEditor/__tests__/profileEditor.test.js new file mode 100644 index 0000000..e0283e5 --- /dev/null +++ b/force-app/main/default/lwc/profileEditor/__tests__/profileEditor.test.js @@ -0,0 +1,162 @@ +import { createElement } from "lwc"; +import ProfileEditor from "c/profileEditor"; +import getDurableId from "@salesforce/apex/lwcProfileEditorController.getDurableId"; + +jest.mock( + "@salesforce/apex/lwcProfileEditorController.getSObjects", + () => { + const { + createTestWireAdapter + } = require("@salesforce/wire-service-jest-util"); + return { default: createTestWireAdapter(jest.fn()) }; + }, + { virtual: true } +); + +jest.mock( + "@salesforce/apex/lwcProfileEditorController.getDurableId", + () => { + const { + createTestWireAdapter + } = require("@salesforce/wire-service-jest-util"); + return { default: createTestWireAdapter(jest.fn()) }; + }, + { virtual: true } +); + +jest.mock( + "lightning/uiGraphQLApi", + () => { + const { + createTestWireAdapter + } = require("@salesforce/wire-service-jest-util"); + return { + gql: (strings) => strings.join(""), + graphql: createTestWireAdapter(jest.fn()), + refreshGraphQL: jest.fn() + }; + }, + { virtual: true } +); + +jest.mock( + "@salesforce/customPermission/Manage_Problem_Profiles", + () => ({ default: true }), + { + virtual: true + } +); + +function createProfileEditor() { + const element = createElement("c-profile-editor", { is: ProfileEditor }); + document.body.appendChild(element); + return element; +} + +function getComboBoxes(element) { + const [profileSelector, objectSelector] = element.shadowRoot.querySelectorAll( + "c-lwc-combo-box-search" + ); + return { profileSelector, objectSelector }; +} + +describe("c-profile-editor (access granted)", () => { + afterEach(() => { + while (document.body.firstChild) { + document.body.removeChild(document.body.firstChild); + } + jest.clearAllMocks(); + }); + + it("disables action buttons until both a profile and an object are selected", async () => { + const element = createProfileEditor(); + await Promise.resolve(); + + let buttons = element.shadowRoot.querySelectorAll( + "lightning-button[data-id]" + ); + expect(buttons.length).toBeGreaterThan(0); + buttons.forEach((button) => expect(button.disabled).toBe(true)); + + const { profileSelector, objectSelector } = getComboBoxes(element); + profileSelector.dispatchEvent( + new CustomEvent("selected", { detail: { value: "00e000000000001" } }) + ); + objectSelector.dispatchEvent( + new CustomEvent("selected", { detail: { value: "Account" } }) + ); + await Promise.resolve(); + + buttons = element.shadowRoot.querySelectorAll("lightning-button[data-id]"); + buttons.forEach((button) => expect(button.disabled).toBe(false)); + }); + + it("sets the hidden anchor href and clicks it instead of calling window.open", async () => { + const clickSpy = jest + .spyOn(HTMLAnchorElement.prototype, "click") + .mockImplementation(() => {}); + const windowOpenSpy = jest + .spyOn(window, "open") + .mockImplementation(() => {}); + + const element = createProfileEditor(); + await Promise.resolve(); + + const { profileSelector, objectSelector } = getComboBoxes(element); + profileSelector.dispatchEvent( + new CustomEvent("selected", { detail: { value: "00e000000000001" } }) + ); + objectSelector.dispatchEvent( + new CustomEvent("selected", { detail: { value: "Account" } }) + ); + + getDurableId.emit({ data: "01I000000000001" }); + await Promise.resolve(); + + const classicButton = element.shadowRoot.querySelector( + 'lightning-button[data-id="classic"]' + ); + classicButton.dispatchEvent(new CustomEvent("click", { bubbles: true })); + await Promise.resolve(); + + expect(windowOpenSpy).not.toHaveBeenCalled(); + expect(clickSpy).toHaveBeenCalledTimes(1); + + const hiddenLink = element.shadowRoot.querySelector("a"); + expect(hiddenLink.href).toContain("/00e000000000001?isdtp=vw"); + + clickSpy.mockRestore(); + windowOpenSpy.mockRestore(); + }); + + it("clears selections and delegates to child combo boxes on reset", async () => { + const element = createProfileEditor(); + await Promise.resolve(); + + const { profileSelector, objectSelector } = getComboBoxes(element); + profileSelector.clearSelectedValue = jest.fn(); + objectSelector.clearSelectedValue = jest.fn(); + + profileSelector.dispatchEvent( + new CustomEvent("selected", { detail: { value: "00e000000000001" } }) + ); + objectSelector.dispatchEvent( + new CustomEvent("selected", { detail: { value: "Account" } }) + ); + await Promise.resolve(); + + const resetButton = Array.from( + element.shadowRoot.querySelectorAll("lightning-button") + ).find((button) => button.label === "Reset Selections"); + resetButton.dispatchEvent(new CustomEvent("click")); + await Promise.resolve(); + + expect(profileSelector.clearSelectedValue).toHaveBeenCalled(); + expect(objectSelector.clearSelectedValue).toHaveBeenCalled(); + + const buttons = element.shadowRoot.querySelectorAll( + "lightning-button[data-id]" + ); + buttons.forEach((button) => expect(button.disabled).toBe(true)); + }); +}); diff --git a/force-app/main/default/lwc/profileEditor/__tests__/profileEditorAccessDenied.test.js b/force-app/main/default/lwc/profileEditor/__tests__/profileEditorAccessDenied.test.js new file mode 100644 index 0000000..49a8b6c --- /dev/null +++ b/force-app/main/default/lwc/profileEditor/__tests__/profileEditorAccessDenied.test.js @@ -0,0 +1,66 @@ +import { createElement } from "lwc"; +import ProfileEditor from "c/profileEditor"; + +jest.mock( + "@salesforce/apex/lwcProfileEditorController.getSObjects", + () => { + const { + createTestWireAdapter + } = require("@salesforce/wire-service-jest-util"); + return { default: createTestWireAdapter(jest.fn()) }; + }, + { virtual: true } +); + +jest.mock( + "@salesforce/apex/lwcProfileEditorController.getDurableId", + () => { + const { + createTestWireAdapter + } = require("@salesforce/wire-service-jest-util"); + return { default: createTestWireAdapter(jest.fn()) }; + }, + { virtual: true } +); + +jest.mock( + "lightning/uiGraphQLApi", + () => { + const { + createTestWireAdapter + } = require("@salesforce/wire-service-jest-util"); + return { + gql: (strings) => strings.join(""), + graphql: createTestWireAdapter(jest.fn()), + refreshGraphQL: jest.fn() + }; + }, + { virtual: true } +); + +jest.mock( + "@salesforce/customPermission/Manage_Problem_Profiles", + () => ({ default: false }), + { + virtual: true + } +); + +describe("c-profile-editor (access denied)", () => { + afterEach(() => { + while (document.body.firstChild) { + document.body.removeChild(document.body.firstChild); + } + }); + + it("shows an access-denied message instead of the tool when the custom permission is missing", async () => { + const element = createElement("c-profile-editor", { is: ProfileEditor }); + document.body.appendChild(element); + await Promise.resolve(); + + expect(element.shadowRoot.querySelector("lightning-card")).toBeNull(); + expect(element.shadowRoot.textContent).toContain( + "You do not have access to this feature" + ); + }); +}); diff --git a/force-app/main/default/lwc/profileEditor/profileEditor.html b/force-app/main/default/lwc/profileEditor/profileEditor.html index 5917fa9..e966103 100644 --- a/force-app/main/default/lwc/profileEditor/profileEditor.html +++ b/force-app/main/default/lwc/profileEditor/profileEditor.html @@ -1,71 +1,107 @@ + + diff --git a/force-app/main/default/lwc/profileEditor/profileEditor.js b/force-app/main/default/lwc/profileEditor/profileEditor.js index f926236..f324647 100644 --- a/force-app/main/default/lwc/profileEditor/profileEditor.js +++ b/force-app/main/default/lwc/profileEditor/profileEditor.js @@ -1,246 +1,257 @@ -import { LightningElement, wire } from 'lwc'; -import getObjects from '@salesforce/apex/lwcProfileEditorController.getSObjects'; -import getDurableId from '@salesforce/apex/lwcProfileEditorController.getDurableId'; +import { LightningElement, wire } from "lwc"; +import getObjects from "@salesforce/apex/lwcProfileEditorController.getSObjects"; +import getDurableId from "@salesforce/apex/lwcProfileEditorController.getDurableId"; +import hasAccess from "@salesforce/customPermission/Manage_Problem_Profiles"; -import { CurrentPageReference, NavigationMixin } from 'lightning/navigation'; -import { gql, graphql, refreshGraphQL } from 'lightning/uiGraphQLApi'; +import { CurrentPageReference, NavigationMixin } from "lightning/navigation"; +import { gql, graphql } from "lightning/uiGraphQLApi"; const PAGE_URLS = new Map([ - ['tid','/setup/ui/profilerecordtypeedit.jsp'], - ['type', '/setup/layout/flsdetail.jsp'], - ['classic','/'] + ["tid", "/setup/ui/profilerecordtypeedit.jsp"], + ["type", "/setup/layout/flsdetail.jsp"], + ["classic", "/"] ]); const ACTIONS = [ - { label: 'Record Type Settings', value: 'tid', icon: 'utility:table_settings' }, - { label: 'Field-Level Security', value: 'type', icon: 'utility:deny_access_field' }, - { label: 'Classic Profile Editor', value: 'classic', icon: 'utility:classic_interface' } -] - -export default class ProfileEditor extends NavigationMixin( LightningElement ) { - - selectedProfileId; - selectedObjectApiName; - selectedAction; - returnPage; - baseURL; - pageURL; - navURL; - isLoading; - - profileOptions = []; - objectOptions; - - get retURL(){ - return this.baseURL + "/lightning/n/" + this.returnPage - } + { + label: "Record Type Settings", + value: "tid", + icon: "utility:table_settings" + }, + { + label: "Field-Level Security", + value: "type", + icon: "utility:deny_access_field" + }, + { + label: "Classic Profile Editor", + value: "classic", + icon: "utility:classic_interface" + } +]; - get actionOptions() { - return ACTIONS; - } +export default class ProfileEditor extends NavigationMixin(LightningElement) { + selectedProfileId; + selectedObjectApiName; + selectedAction; + returnPage; + baseURL; + pageURL; + isLoading; - get isDisabled(){ - if(this.selectedProfileId && this.selectedObjectApiName){ - return false; - } - return true; - } + profileOptions = []; + objectOptions; - connectedCallback(){ - this.isLoading = true; - this.baseURL = window.location.origin; - } + hasAccess = hasAccess; - @wire(CurrentPageReference) - wireCurrentPageReference(currentPageReference) { - this.returnPage = currentPageReference.attributes.apiName; - // console.log('retURL: ', this.returnPage); - } + get retURL() { + return this.baseURL + "/lightning/n/" + this.returnPage; + } - @wire(getDurableId, { qualifiedApiName: '$selectedObjectApiName' }) - durableId; - - @wire(getObjects, {} ) - _getObjects({error, data}) { - if(error){ - console.log('ERROR getting objects'); - console.log(error); - } else if (data) { - // console.log('Object Options Retrieved'); - let orderedObjects = [...data]; - this.objectOptions = orderedObjects.sort((a, b) => - a.label.localeCompare(b.label) - ); - } - this.isLoading = false; - } - - handleProfileSelection(event){ - console.log('HEARD: ' + event.detail.value); - this.selectedProfileId = event.detail.value; - } + get actionOptions() { + return ACTIONS; + } - handleObjectSelection(event){ - console.log('HEARD: ' + event.detail.value); - this.selectedObjectApiName = event.detail.value; + get isDisabled() { + if (this.selectedProfileId && this.selectedObjectApiName) { + return false; } + return true; + } - handleActionClick(evt){ - evt.preventDefault(); - evt.stopPropagation(); - this.selectedAction = evt.target.dataset.id; - this.pageURL = PAGE_URLS.get(this.selectedAction); - this.generateUrl(); - } + connectedCallback() { + this.isLoading = this.hasAccess; + this.baseURL = window.location.origin; + } - handleReset(){ - this.selectedObjectApiName = undefined; - this.selectedProfileId = undefined; + @wire(CurrentPageReference) + wireCurrentPageReference(currentPageReference) { + this.returnPage = currentPageReference.attributes.apiName; + } - this.refs.objectSelector.clearSelectedValue(); - this.refs.profileSelector.clearSelectedValue(); - } + @wire(getDurableId, { qualifiedApiName: "$selectedObjectApiName" }) + durableId; - generateUrl() { - let address; - if(this.selectedAction === 'classic') { - address = this.pageURL + this.selectedProfileId + "?isdtp=vw&retURL=/" + this.selectedProfileId+ "&isdtp=vw"; - } - else { - address = this.pageURL + "?id=" + this.selectedProfileId + "&" + this.selectedAction + "=" + this.durableId.data + "&isdtp=vw" + "&retURL=" + this.retURL; - } - // console.log('Address: ', address); - let url = this.baseURL + address; - // console.log('URL: ', url); - window.open(url); - } - - - @wire(graphql, { query: '$gqlQueryProfiles', variables: "$params" }) - graphqlQueryResultProfiles(result) { - this.gqlProfileData = result; - let data = result.data; - let errors = result.errors; - if (data) { - let result = data.uiapi.query.Profile; - let records = result.edges.map((edge) => { - let field = edge.node; - return { - "value": field.Id, - "label": field.Name.value, - } - }); - this.addOptions(records); - this.isLoaded = true; - } else if (errors) { - console.log('GQL WIRE FAILED'); - console.log(JSON.stringify(errors)); - } else { - this.profileOptions = []; - } + @wire(getObjects, {}) + _getObjects({ error, data }) { + if (error) { + console.log("ERROR getting objects"); + console.log(error); + } else if (data) { + let orderedObjects = [...data]; + this.objectOptions = orderedObjects.sort((a, b) => + a.label.localeCompare(b.label) + ); } + this.isLoading = false; + } - @wire(graphql, { query: '$gqlQueryUserProfile', variables: "$params" }) - graphqlQueryResultUserProfiles(result) { - this.gqlUserProfileData = result; - let data = result.data; - let errors = result.errors; - if (data) { - let result = data.uiapi.query.User; - console.log(JSON.stringify(result)); - let records = result.edges.map((edge) => { - let field = edge.node; - return { - "value": field.ProfileId.value, - "label": field.Name.value, - } - }); - this.addOptions(records); - this.isLoaded = true; - } else if (errors) { - console.log('GQL WIRE FAILED'); - console.log(JSON.stringify(errors)); - } else { - this.profileOptions = []; - } + handleProfileSelection(event) { + this.selectedProfileId = event.detail.value; + } + + handleObjectSelection(event) { + this.selectedObjectApiName = event.detail.value; + } + + handleActionClick(evt) { + evt.preventDefault(); + evt.stopPropagation(); + this.selectedAction = evt.target.dataset.id; + this.pageURL = PAGE_URLS.get(this.selectedAction); + this.generateUrl(); + } + + handleReset() { + this.selectedObjectApiName = undefined; + this.selectedProfileId = undefined; + + this.refs.objectSelector.clearSelectedValue(); + this.refs.profileSelector.clearSelectedValue(); + } + + generateUrl() { + let address; + if (this.selectedAction === "classic") { + address = + this.pageURL + + this.selectedProfileId + + "?isdtp=vw&retURL=/" + + this.selectedProfileId + + "&isdtp=vw"; + } else { + address = + this.pageURL + + "?id=" + + this.selectedProfileId + + "&" + + this.selectedAction + + "=" + + this.durableId.data + + "&isdtp=vw&retURL=" + + this.retURL; } + let url = this.baseURL + address; + const link = this.refs.hiddenLink; + link.href = url; + link.click(); + } - get params() { - return {}; + @wire(graphql, { query: "$gqlQueryProfiles", variables: "$params" }) + graphqlQueryResultProfiles(result) { + this.gqlProfileData = result; + let data = result.data; + let errors = result.errors; + if (data) { + let profileResult = data.uiapi.query.Profile; + let records = profileResult.edges.map((edge) => { + let field = edge.node; + return { + value: field.Id, + label: field.Name.value + }; + }); + this.addOptions(records); + this.isLoaded = true; + } else if (errors) { + console.log("GQL WIRE FAILED"); + console.log(JSON.stringify(errors)); + } else { + this.profileOptions = []; } + } - get gqlQueryProfiles() { - - return gql` - query profiles { - uiapi { - query { - Profile( - first: 2000 - orderBy: { - Name: { order: ASC } - } - ) { - edges { - node { - Id - Name { value } - } - } - } - } - } - } - `; + @wire(graphql, { query: "$gqlQueryUserProfile", variables: "$params" }) + graphqlQueryResultUserProfiles(result) { + this.gqlUserProfileData = result; + let data = result.data; + let errors = result.errors; + if (data) { + let userResult = data.uiapi.query.User; + let records = userResult.edges.map((edge) => { + let field = edge.node; + return { + value: field.ProfileId.value, + label: field.Name.value + }; + }); + this.addOptions(records); + this.isLoaded = true; + } else if (errors) { + console.log("GQL WIRE FAILED"); + console.log(JSON.stringify(errors)); + } else { + this.profileOptions = []; } + } + + get params() { + return {}; + } - get gqlQueryUserProfile() { - - return gql` - query userprofiles { - uiapi { - query { - User( - first: 100 - where: { - ProfileId: { ne: null } - Profile: { - Id: { eq: null } - } - } - orderBy: { - Name: { order: ASC } - } - ) { - edges { - node { - Id - Name { value } - ProfileId { value } - } - } - } - } + get gqlQueryProfiles() { + return gql` + query profiles { + uiapi { + query { + Profile(first: 2000, orderBy: { Name: { order: ASC } }) { + edges { + node { + Id + Name { + value + } } + } } - `; - } + } + } + } + `; + } - addOptions(options){ - let valueList = [...this.profileOptions, ...options]; - valueList.sort((a, b) => { - const stringA = a.label.toUpperCase(); // ignore upper and lowercase - const stringB = b.label.toUpperCase(); // ignore upper and lowercase - if (stringA < stringB) { - return -1; - } - if (stringA > stringB) { - return 1; + get gqlQueryUserProfile() { + return gql` + query userprofiles { + uiapi { + query { + User( + first: 100 + where: { ProfileId: { ne: null }, Profile: { Id: { eq: null } } } + orderBy: { Name: { order: ASC } } + ) { + edges { + node { + Id + Name { + value + } + ProfileId { + value + } + } + } } - return 0; // names are equal - }); + } + } + } + `; + } - this.profileOptions = [...valueList]; - } + addOptions(options) { + let valueList = [...this.profileOptions, ...options]; + valueList.sort((a, b) => { + const stringA = a.label.toUpperCase(); // ignore upper and lowercase + const stringB = b.label.toUpperCase(); // ignore upper and lowercase + if (stringA < stringB) { + return -1; + } + if (stringA > stringB) { + return 1; + } + return 0; // names are equal + }); -} \ No newline at end of file + this.profileOptions = [...valueList]; + } +} From 9d12dfcbab7ebd4dd0ccc60c8f324861c0cff728 Mon Sep 17 00:00:00 2001 From: tschug Date: Wed, 26 Aug 2026 00:46:26 -0400 Subject: [PATCH 03/10] API versions bumped to 67.0 + Ignore Code Analyzer --- .gitignore | 4 ++++ .../classes/lwcProfileEditorController.cls-meta.xml | 4 ++-- .../classes/lwcProfileEditorControllerTest.cls-meta.xml | 4 ++-- .../lwc/lwcComboBoxSearch/lwcComboBoxSearch.js-meta.xml | 6 +++--- .../default/lwc/profileEditor/profileEditor.js-meta.xml | 9 +++++---- sfdx-project.json | 4 ++-- 6 files changed, 18 insertions(+), 13 deletions(-) diff --git a/.gitignore b/.gitignore index f5f33eb..18fe36f 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,10 @@ deploy-options.json # LWC Jest coverage reports coverage/ +# Code Analyzer report output +CodeAnalyzerReport.* +CodeAnalyzer*.csv + # Logs logs *.log diff --git a/force-app/main/default/classes/lwcProfileEditorController.cls-meta.xml b/force-app/main/default/classes/lwcProfileEditorController.cls-meta.xml index 5f399c3..7d3a8b2 100644 --- a/force-app/main/default/classes/lwcProfileEditorController.cls-meta.xml +++ b/force-app/main/default/classes/lwcProfileEditorController.cls-meta.xml @@ -1,5 +1,5 @@ - + - 63.0 + 67.0 Active diff --git a/force-app/main/default/classes/lwcProfileEditorControllerTest.cls-meta.xml b/force-app/main/default/classes/lwcProfileEditorControllerTest.cls-meta.xml index 5f399c3..7d3a8b2 100644 --- a/force-app/main/default/classes/lwcProfileEditorControllerTest.cls-meta.xml +++ b/force-app/main/default/classes/lwcProfileEditorControllerTest.cls-meta.xml @@ -1,5 +1,5 @@ - + - 63.0 + 67.0 Active diff --git a/force-app/main/default/lwc/lwcComboBoxSearch/lwcComboBoxSearch.js-meta.xml b/force-app/main/default/lwc/lwcComboBoxSearch/lwcComboBoxSearch.js-meta.xml index 41719fe..03c824a 100644 --- a/force-app/main/default/lwc/lwcComboBoxSearch/lwcComboBoxSearch.js-meta.xml +++ b/force-app/main/default/lwc/lwcComboBoxSearch/lwcComboBoxSearch.js-meta.xml @@ -1,5 +1,5 @@ - + - 63.0 + 67.0 false - \ No newline at end of file + diff --git a/force-app/main/default/lwc/profileEditor/profileEditor.js-meta.xml b/force-app/main/default/lwc/profileEditor/profileEditor.js-meta.xml index e5c40e3..d28558a 100644 --- a/force-app/main/default/lwc/profileEditor/profileEditor.js-meta.xml +++ b/force-app/main/default/lwc/profileEditor/profileEditor.js-meta.xml @@ -1,11 +1,12 @@ - + - 62.0 + 67.0 true - Access Profile Settings for hard-to-reach Profiles + Access Profile Settings for hard-to-reach Profiles Problematic Profile Editor lightning__AppPage lightning__Tab - \ No newline at end of file + diff --git a/sfdx-project.json b/sfdx-project.json index 8a2b49f..1f46e53 100644 --- a/sfdx-project.json +++ b/sfdx-project.json @@ -16,9 +16,9 @@ "name": "ProblemProfiles", "namespace": "fydo", "sfdcLoginUrl": "https://login.salesforce.com", - "sourceApiVersion": "62.0", + "sourceApiVersion": "67.0", "packageAliases": { "Problem Profile Editor": "0Ho3x000000fxWDCAY", "Problem Profile Editor@0.1.0.2": "04t3x000001ZqhZAAS" } -} \ No newline at end of file +} From 8aa344e419103354d55e5d6900812627d5e67f25 Mon Sep 17 00:00:00 2001 From: tschug Date: Wed, 26 Aug 2026 03:24:31 -0400 Subject: [PATCH 04/10] Refactored the combobox to not use blur and better handle input/ and option selection clearing/editing. --- .../__tests__/lwcComboBoxSearch.test.js | 109 +++++++++++++++++- .../lwcComboBoxSearch/lwcComboBoxSearch.html | 100 ++++++++-------- .../lwcComboBoxSearch/lwcComboBoxSearch.js | 27 ++--- 3 files changed, 163 insertions(+), 73 deletions(-) diff --git a/force-app/main/default/lwc/lwcComboBoxSearch/__tests__/lwcComboBoxSearch.test.js b/force-app/main/default/lwc/lwcComboBoxSearch/__tests__/lwcComboBoxSearch.test.js index 92d41fb..49b2a58 100644 --- a/force-app/main/default/lwc/lwcComboBoxSearch/__tests__/lwcComboBoxSearch.test.js +++ b/force-app/main/default/lwc/lwcComboBoxSearch/__tests__/lwcComboBoxSearch.test.js @@ -78,8 +78,115 @@ describe("c-lwc-combo-box-search", () => { element.clearSelectedValue(); await Promise.resolve(); + expect(element.shadowRoot.querySelector("lightning-input").value).toBe(""); + }); + + it("re-selecting the same value after editing repopulates the label", async () => { + const element = createComboBox(); + const handler = jest.fn(); + element.addEventListener("selected", handler); + + const input = element.shadowRoot.querySelector("lightning-input"); + input.dispatchEvent(new CustomEvent("focus")); + await Promise.resolve(); + + let item = Array.from( + element.shadowRoot.querySelectorAll("li[data-value]") + ).find((li) => li.dataset.value === "Case"); + item.dispatchEvent(new CustomEvent("click")); + await Promise.resolve(); + + expect(input.value).toBe("Case"); + + // User clicks back in and edits, invalidating the prior selection. The + // input's own displayed text is left alone (uncontrolled) so it isn't + // clobbered mid-edit; only our internal selection state is cleared here. + input.dispatchEvent( + new CustomEvent("change", { detail: { value: "Cas" } }) + ); + await Promise.resolve(); + expect( - element.shadowRoot.querySelector("lightning-input").value + handler.mock.calls[handler.mock.calls.length - 1][0].detail.value ).toBeNull(); + + item = Array.from( + element.shadowRoot.querySelectorAll("li[data-value]") + ).find((li) => li.dataset.value === "Case"); + item.dispatchEvent(new CustomEvent("click")); + await Promise.resolve(); + + expect(input.value).toBe("Case"); + expect( + handler.mock.calls[handler.mock.calls.length - 1][0].detail.value + ).toBe("Case"); + }); + + it("closes the dropdown on blur even if the user never selects or clears", async () => { + jest.useFakeTimers(); + const element = createComboBox(); + const input = element.shadowRoot.querySelector("lightning-input"); + + input.dispatchEvent(new CustomEvent("focus")); + await Promise.resolve(); + input.dispatchEvent( + new CustomEvent("change", { detail: { value: "acc" } }) + ); + await Promise.resolve(); + + input.dispatchEvent(new CustomEvent("blur")); + jest.advanceTimersByTime(300); + await Promise.resolve(); + + expect(element.shadowRoot.querySelector("ul")).toBeNull(); + jest.useRealTimers(); + }); + + it("restores the full option list after clearing a partial search", async () => { + const element = createComboBox(); + const input = element.shadowRoot.querySelector("lightning-input"); + input.dispatchEvent(new CustomEvent("focus")); + await Promise.resolve(); + + input.dispatchEvent( + new CustomEvent("change", { detail: { value: "acc" } }) + ); + await Promise.resolve(); + expect(element.shadowRoot.querySelectorAll("li[data-value]").length).toBe( + 1 + ); + + // Native "x" clear on the search input dispatches change with an empty value. + input.dispatchEvent(new CustomEvent("change", { detail: { value: "" } })); + await Promise.resolve(); + + expect(element.shadowRoot.querySelectorAll("li[data-value]").length).toBe( + OPTIONS.length + ); + }); + + it("restores the full option list after clearing a made selection", async () => { + const element = createComboBox(); + const input = element.shadowRoot.querySelector("lightning-input"); + input.dispatchEvent(new CustomEvent("focus")); + await Promise.resolve(); + + const item = Array.from( + element.shadowRoot.querySelectorAll("li[data-value]") + ).find((li) => li.dataset.value === "Case"); + item.dispatchEvent(new CustomEvent("click")); + await Promise.resolve(); + + // Lose focus and come back, then clear via the native "x" (empty change). + input.dispatchEvent(new CustomEvent("blur")); + await Promise.resolve(); + input.dispatchEvent(new CustomEvent("focus")); + await Promise.resolve(); + input.dispatchEvent(new CustomEvent("change", { detail: { value: "" } })); + await Promise.resolve(); + + expect(element.shadowRoot.querySelectorAll("li[data-value]").length).toBe( + OPTIONS.length + ); }); }); diff --git a/force-app/main/default/lwc/lwcComboBoxSearch/lwcComboBoxSearch.html b/force-app/main/default/lwc/lwcComboBoxSearch/lwcComboBoxSearch.html index 20f2d02..9510707 100644 --- a/force-app/main/default/lwc/lwcComboBoxSearch/lwcComboBoxSearch.html +++ b/force-app/main/default/lwc/lwcComboBoxSearch/lwcComboBoxSearch.html @@ -1,58 +1,48 @@ \ No newline at end of file + + diff --git a/force-app/main/default/lwc/lwcComboBoxSearch/lwcComboBoxSearch.js b/force-app/main/default/lwc/lwcComboBoxSearch/lwcComboBoxSearch.js index 4bce94d..efd5930 100644 --- a/force-app/main/default/lwc/lwcComboBoxSearch/lwcComboBoxSearch.js +++ b/force-app/main/default/lwc/lwcComboBoxSearch/lwcComboBoxSearch.js @@ -12,16 +12,11 @@ export default class LwcComboBoxSearch extends LightningElement { @api inputOptions; searchInput; - canBlur = true; showOptions = false; searchResults; selectedSearchResult; - get selectedValue() { - return this.selectedSearchResult?.label ?? null; - } - handleFocus() { if (!this.selectedSearchResult) { this.showOptions = true; @@ -29,15 +24,8 @@ export default class LwcComboBoxSearch extends LightningElement { } } - allowBlur() { - this.canBlur = true; - } - cancelBlur() { - this.canBlur = false; - } - handleBlur() { - if (this.canBlur && !this.selectedSearchResult) { + if (!this.selectedSearchResult) { // eslint-disable-next-line @lwc/lwc/no-async-operation setTimeout(() => { if (!this.selectedSearchResult) { @@ -55,8 +43,9 @@ export default class LwcComboBoxSearch extends LightningElement { } handleChange(event) { - this.cancelBlur(); this.showOptions = true; + const hadSelection = Boolean(this.selectedSearchResult); + this.selectedSearchResult = null; this.searchInput = event.detail.value.toLowerCase(); if (this.searchInput) { const result = this.inputOptions.filter((pickListOption) => @@ -64,8 +53,9 @@ export default class LwcComboBoxSearch extends LightningElement { ); this.searchResults = result; } else { - this.selectedSearchResult = null; - this.allowBlur(); + this.searchResults = [...this.inputOptions]; + } + if (hadSelection || !this.searchInput) { this.returnValue(null); } } @@ -77,13 +67,16 @@ export default class LwcComboBoxSearch extends LightningElement { ); this.clearSearchResults(); this.searchInput = null; - this.allowBlur(); + this.refs.searchInput.value = this.selectedSearchResult.label; this.returnValue(selectedValue); } @api clearSelectedValue() { this.selectedSearchResult = null; + if (this.refs.searchInput) { + this.refs.searchInput.value = ""; + } } clearSearchResults() { From 95ab71017e871094b617ee732118eaa8a7feb3c4 Mon Sep 17 00:00:00 2001 From: tschug Date: Wed, 26 Aug 2026 04:15:45 -0400 Subject: [PATCH 05/10] Resolved tab/arrow key navigation --- .../__tests__/lwcComboBoxSearch.test.js | 142 ++++++++++++++++++ .../lwcComboBoxSearch/lwcComboBoxSearch.html | 7 +- .../lwcComboBoxSearch/lwcComboBoxSearch.js | 109 +++++++++++++- 3 files changed, 249 insertions(+), 9 deletions(-) diff --git a/force-app/main/default/lwc/lwcComboBoxSearch/__tests__/lwcComboBoxSearch.test.js b/force-app/main/default/lwc/lwcComboBoxSearch/__tests__/lwcComboBoxSearch.test.js index 49b2a58..5af5e25 100644 --- a/force-app/main/default/lwc/lwcComboBoxSearch/__tests__/lwcComboBoxSearch.test.js +++ b/force-app/main/default/lwc/lwcComboBoxSearch/__tests__/lwcComboBoxSearch.test.js @@ -189,4 +189,146 @@ describe("c-lwc-combo-box-search", () => { OPTIONS.length ); }); + + it("ArrowDown opens the dropdown and highlights the first option", async () => { + const element = createComboBox(); + const input = element.shadowRoot.querySelector("lightning-input"); + + input.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown" })); + await Promise.resolve(); + + const items = element.shadowRoot.querySelectorAll("li[data-value]"); + expect(items.length).toBe(OPTIONS.length); + expect(items[0].querySelector('[role="option"]').className).toContain( + "slds-has-focus" + ); + }); + + it("ArrowDown/ArrowUp move the highlight and wrap around", async () => { + const element = createComboBox(); + const input = element.shadowRoot.querySelector("lightning-input"); + const highlighted = () => + Array.from(element.shadowRoot.querySelectorAll("li[data-value]")).find( + (li) => + li + .querySelector('[role="option"]') + .className.includes("slds-has-focus") + ); + + input.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown" })); + await Promise.resolve(); + input.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown" })); + await Promise.resolve(); + expect(highlighted().dataset.value).toBe("Contact"); + + input.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowUp" })); + await Promise.resolve(); + expect(highlighted().dataset.value).toBe("Account"); + + input.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowUp" })); + await Promise.resolve(); + expect(highlighted().dataset.value).toBe("Case"); + }); + + it("Enter selects the highlighted option", async () => { + const element = createComboBox(); + const handler = jest.fn(); + element.addEventListener("selected", handler); + const input = element.shadowRoot.querySelector("lightning-input"); + + input.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown" })); + await Promise.resolve(); + input.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown" })); + await Promise.resolve(); + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter" })); + await Promise.resolve(); + + expect(handler).toHaveBeenCalledTimes(1); + expect(handler.mock.calls[0][0].detail.value).toBe("Contact"); + expect(element.shadowRoot.querySelector("ul")).toBeNull(); + }); + + it("Escape closes the dropdown without selecting", async () => { + const element = createComboBox(); + const handler = jest.fn(); + element.addEventListener("selected", handler); + const input = element.shadowRoot.querySelector("lightning-input"); + + input.dispatchEvent(new CustomEvent("focus")); + await Promise.resolve(); + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); + await Promise.resolve(); + + expect(element.shadowRoot.querySelector("ul")).toBeNull(); + expect(handler).not.toHaveBeenCalled(); + }); + + it("Tab closes the dropdown immediately", async () => { + const element = createComboBox(); + const input = element.shadowRoot.querySelector("lightning-input"); + + input.dispatchEvent(new CustomEvent("focus")); + await Promise.resolve(); + expect(element.shadowRoot.querySelector("ul")).not.toBeNull(); + + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab" })); + await Promise.resolve(); + + expect(element.shadowRoot.querySelector("ul")).toBeNull(); + }); + + it("refocusing after Tab shows the same filtered list, not every option", async () => { + const element = createComboBox(); + const input = element.shadowRoot.querySelector("lightning-input"); + + input.dispatchEvent(new CustomEvent("focus")); + await Promise.resolve(); + input.dispatchEvent( + new CustomEvent("change", { detail: { value: "acc" } }) + ); + await Promise.resolve(); + expect(element.shadowRoot.querySelectorAll("li[data-value]").length).toBe( + 1 + ); + + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab" })); + await Promise.resolve(); + input.dispatchEvent(new CustomEvent("focus")); + await Promise.resolve(); + + expect(element.shadowRoot.querySelectorAll("li[data-value]").length).toBe( + 1 + ); + }); + + it("commit firing after the field truly loses focus does not reopen the dropdown", async () => { + const element = createComboBox(); + const input = element.shadowRoot.querySelector("lightning-input"); + + input.dispatchEvent(new CustomEvent("focus")); + await Promise.resolve(); + input.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown" })); + await Promise.resolve(); + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter" })); + await Promise.resolve(); + expect(element.shadowRoot.querySelector("ul")).toBeNull(); + + // lightning-input dispatches "commit" when focus finally leaves the + // whole field (e.g. tabbing past its native clear button), well after + // our own blur/keydown handling already closed the dropdown. + input.dispatchEvent(new CustomEvent("commit")); + await Promise.resolve(); + + expect(element.shadowRoot.querySelector("ul")).toBeNull(); + }); + + it("commit on an untouched field does not open and strand the dropdown", async () => { + const element = createComboBox(); + const input = element.shadowRoot.querySelector("lightning-input"); + + input.dispatchEvent(new CustomEvent("commit")); + await Promise.resolve(); + + expect(element.shadowRoot.querySelector("ul")).toBeNull(); + }); }); diff --git a/force-app/main/default/lwc/lwcComboBoxSearch/lwcComboBoxSearch.html b/force-app/main/default/lwc/lwcComboBoxSearch/lwcComboBoxSearch.html index 9510707..0e1dfc4 100644 --- a/force-app/main/default/lwc/lwcComboBoxSearch/lwcComboBoxSearch.html +++ b/force-app/main/default/lwc/lwcComboBoxSearch/lwcComboBoxSearch.html @@ -8,6 +8,7 @@ onchange={handleChange} oncommit={handleCommit} onblur={handleBlur} + onkeydown={handleKeyDown} type="search" placeholder={inputPlaceholder} field-level-help={inputHelp} @@ -21,17 +22,19 @@ lwc:ref="searchResults" >