From 9e3f52576acc4196b2715327f077c2ff9bfc95d3 Mon Sep 17 00:00:00 2001 From: bhavesh-bhuckory-ag2r Date: Fri, 25 Jul 2025 15:24:28 +0400 Subject: [PATCH 1/4] [EVOL] Add Custom Data Type (Image) to display in Lightning-Datatable [EVOL] Add Custom Data Type (Image) remove alt text --- .../lwc/sfpegListCmp/sfpegListCmp.html | 3 +++ .../sfpegListCustomDataTypesProvider.html | 3 +++ .../sfpegListCustomDataTypesProvider.js | 21 +++++++++++++++++++ ...pegListCustomDataTypesProvider.js-meta.xml | 5 +++++ .../sfpegListCustomImageTemplate.html | 6 ++++++ 5 files changed, 38 insertions(+) create mode 100644 force-app/main/default/lwc/sfpegListCustomDataTypesProvider/sfpegListCustomDataTypesProvider.html create mode 100644 force-app/main/default/lwc/sfpegListCustomDataTypesProvider/sfpegListCustomDataTypesProvider.js create mode 100644 force-app/main/default/lwc/sfpegListCustomDataTypesProvider/sfpegListCustomDataTypesProvider.js-meta.xml create mode 100644 force-app/main/default/lwc/sfpegListCustomDataTypesProvider/sfpegListCustomImageTemplate.html diff --git a/force-app/main/default/lwc/sfpegListCmp/sfpegListCmp.html b/force-app/main/default/lwc/sfpegListCmp/sfpegListCmp.html index fc36d06..bea0ceb 100755 --- a/force-app/main/default/lwc/sfpegListCmp/sfpegListCmp.html +++ b/force-app/main/default/lwc/sfpegListCmp/sfpegListCmp.html @@ -181,6 +181,9 @@

onrowaction={handleRowAction} onrowselection={handleRowSelection} render-config={renderConfig}> + + + diff --git a/force-app/main/default/lwc/sfpegListCustomDataTypesProvider/sfpegListCustomDataTypesProvider.html b/force-app/main/default/lwc/sfpegListCustomDataTypesProvider/sfpegListCustomDataTypesProvider.html new file mode 100644 index 0000000..27e0f69 --- /dev/null +++ b/force-app/main/default/lwc/sfpegListCustomDataTypesProvider/sfpegListCustomDataTypesProvider.html @@ -0,0 +1,3 @@ + \ No newline at end of file diff --git a/force-app/main/default/lwc/sfpegListCustomDataTypesProvider/sfpegListCustomDataTypesProvider.js b/force-app/main/default/lwc/sfpegListCustomDataTypesProvider/sfpegListCustomDataTypesProvider.js new file mode 100644 index 0000000..4151ce9 --- /dev/null +++ b/force-app/main/default/lwc/sfpegListCustomDataTypesProvider/sfpegListCustomDataTypesProvider.js @@ -0,0 +1,21 @@ +import { LightningElement,api } from 'lwc'; +import customImageTemplate from './sfpegListCustomImageTemplate'; + +export default class SfpegListCustomDataTypesProvider extends LightningElement { + + //---------------------------------------------------------------- + // Return Custom Data Types + //---------------------------------------------------------------- + @api + getDataTypes() { + return { + //A custom type 'customImage' to be able to display images in any column of the lightning Datatable + customImage: { + template: customImageTemplate, //imported template + typeAttributes: ["imageUrl"], //attributes needed : imageUrl to be used in template + standardCellLayout: true + }, + //other custom types can be added here + }; + } +} \ No newline at end of file diff --git a/force-app/main/default/lwc/sfpegListCustomDataTypesProvider/sfpegListCustomDataTypesProvider.js-meta.xml b/force-app/main/default/lwc/sfpegListCustomDataTypesProvider/sfpegListCustomDataTypesProvider.js-meta.xml new file mode 100644 index 0000000..f37569d --- /dev/null +++ b/force-app/main/default/lwc/sfpegListCustomDataTypesProvider/sfpegListCustomDataTypesProvider.js-meta.xml @@ -0,0 +1,5 @@ + + + 64.0 + false + \ No newline at end of file diff --git a/force-app/main/default/lwc/sfpegListCustomDataTypesProvider/sfpegListCustomImageTemplate.html b/force-app/main/default/lwc/sfpegListCustomDataTypesProvider/sfpegListCustomImageTemplate.html new file mode 100644 index 0000000..cc34f47 --- /dev/null +++ b/force-app/main/default/lwc/sfpegListCustomDataTypesProvider/sfpegListCustomImageTemplate.html @@ -0,0 +1,6 @@ + \ No newline at end of file From b6779a6a95e49ef346be3c57c89f699b23cb2a44 Mon Sep 17 00:00:00 2001 From: bhavesh-bhuckory-ag2r Date: Wed, 30 Jul 2025 15:46:20 +0400 Subject: [PATCH 2/4] [Modified Doc SfpegListCMP] --- help/sfpegListCmp.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/help/sfpegListCmp.md b/help/sfpegListCmp.md index c18b12a..8b7db89 100755 --- a/help/sfpegListCmp.md +++ b/help/sfpegListCmp.md @@ -442,6 +442,35 @@ a generic button icon menu being displayed if more than one option is configured As a workaround for record previews, the `showPreview` action type may be used instead of the `navigation` one (see **[sfpegActionBarCmp](/help/sfpegActionBarCmp.md)**) to display a summary of the record. +### Display Image In Datatable Column + +For the `Datatable` mode, The `customImage` type can be used to display images directly within the Lightning datatable cells. Instead of showing plain text or links, custom images(e.g png,svg,jpg,etc.) can be used. + +To add an image column to the datatable, the 'customImage' type should be set within `Display Configuration` JSON : + +``` +{ + "label" : "Profile Picture", + "type" : "customImage", + "fieldName" : "profilePicture", + "sortable" : "true", + "typeAttributes" : { + "imageUrl" : { + "fieldName" : "profilePicture" + } + }, + "cellAttributes" : { + "alignment" : "center" + } +} +``` + +_Notes_: + +* The property `imageUrl` is always mandatory as it indicates from which field it is going to read the image url. Hence, the field value should be in the form of `"/resource/example/image.png"`. + +* in the example above the `fieldName` field displayed is specified twice in the configuration, once as root +to be used for sorting and filtering, secondly within the `typeAttributes` property of the `imageUrl` attribute. ### Timeline Configuration From 1a4d24f83f54f84a5a90980b5a0b5c9fc32a7f79 Mon Sep 17 00:00:00 2001 From: bhavesh-bhuckory-ag2r Date: Wed, 6 Aug 2025 10:36:06 +0400 Subject: [PATCH 3/4] Add Custom type to lightning-tree-grid --- force-app/main/default/lwc/sfpegListCmp/sfpegListCmp.html | 3 +++ 1 file changed, 3 insertions(+) diff --git a/force-app/main/default/lwc/sfpegListCmp/sfpegListCmp.html b/force-app/main/default/lwc/sfpegListCmp/sfpegListCmp.html index bea0ceb..8a298dc 100755 --- a/force-app/main/default/lwc/sfpegListCmp/sfpegListCmp.html +++ b/force-app/main/default/lwc/sfpegListCmp/sfpegListCmp.html @@ -204,6 +204,9 @@

onrowaction={handleRowAction} onrowselection={handleRowSelection} render-config={renderConfig} > + + + From b6a9a41bc513ee4f6e5c273e673d6d2f94605cae Mon Sep 17 00:00:00 2001 From: bhavesh-bhuckory-ag2r Date: Mon, 1 Dec 2025 11:44:48 +0400 Subject: [PATCH 4/4] [EVOL] Add ability to filter on multiple filters at the same time on the Lightning-Datatable - Created a new component sfpegMultipleFilters which handles different types of filters based on datatypes (date, text, picklists) - Created a new component sfpegMultiSelectCombobox which is a searchable combobox (enter text and picklist values generated) --- .../lwc/sfpegListCmp/sfpegListCmp.html | 15 +- .../default/lwc/sfpegListCmp/sfpegListCmp.js | 121 ++++ .../lwc/sfpegListCmp/sfpegListCmp.js-meta.xml | 10 + .../sfpegMultiSelectCombobox.css | 149 ++++ .../sfpegMultiSelectCombobox.html | 62 ++ .../sfpegMultiSelectCombobox.js | 260 +++++++ .../sfpegMultiSelectCombobox.js-meta.xml | 10 + .../sfpegMultipleFilters.css | 221 ++++++ .../sfpegMultipleFilters.html | 203 ++++++ .../sfpegMultipleFilters.js | 677 ++++++++++++++++++ .../sfpegMultipleFilters.js-meta.xml | 10 + 11 files changed, 1731 insertions(+), 7 deletions(-) create mode 100644 force-app/main/default/lwc/sfpegMultiSelectCombobox/sfpegMultiSelectCombobox.css create mode 100644 force-app/main/default/lwc/sfpegMultiSelectCombobox/sfpegMultiSelectCombobox.html create mode 100644 force-app/main/default/lwc/sfpegMultiSelectCombobox/sfpegMultiSelectCombobox.js create mode 100644 force-app/main/default/lwc/sfpegMultiSelectCombobox/sfpegMultiSelectCombobox.js-meta.xml create mode 100644 force-app/main/default/lwc/sfpegMultipleFilters/sfpegMultipleFilters.css create mode 100644 force-app/main/default/lwc/sfpegMultipleFilters/sfpegMultipleFilters.html create mode 100644 force-app/main/default/lwc/sfpegMultipleFilters/sfpegMultipleFilters.js create mode 100644 force-app/main/default/lwc/sfpegMultipleFilters/sfpegMultipleFilters.js-meta.xml diff --git a/force-app/main/default/lwc/sfpegListCmp/sfpegListCmp.html b/force-app/main/default/lwc/sfpegListCmp/sfpegListCmp.html index 8a298dc..7573159 100755 --- a/force-app/main/default/lwc/sfpegListCmp/sfpegListCmp.html +++ b/force-app/main/default/lwc/sfpegListCmp/sfpegListCmp.html @@ -149,7 +149,14 @@

diff --git a/force-app/main/default/lwc/sfpegListCmp/sfpegListCmp.js b/force-app/main/default/lwc/sfpegListCmp/sfpegListCmp.js index 175e684..ab9b673 100755 --- a/force-app/main/default/lwc/sfpegListCmp/sfpegListCmp.js +++ b/force-app/main/default/lwc/sfpegListCmp/sfpegListCmp.js @@ -77,6 +77,7 @@ export default class SfpegListCmp extends LightningElement { @api showCount = 'right'; // Flag to display the items count. @api showSearch = false; // Flag to show Filter action in header. @api showExport = false; // Flag to show Export action in header. + @api multipleFilters = false; // Flag to show multiple column filters component @api displayHeight = 0; // Max-height of the content Div (0 meaning no limit) @api maxSize = 100; // Header Action list overflow limit @api isCollapsible = false; // Flag to set the list details as collapsible @@ -1241,6 +1242,126 @@ export default class SfpegListCmp extends LightningElement { } + // Multiple Filters handling + handleMultipleFiltersChange(event) { + if (this.isDebug) console.log('handleMultipleFiltersChange: START', JSON.stringify(event.detail)); + + this.isFiltering = true; + + // Child component already validates and normalizes filters, but add minimal safety check + const rawFilters = event.detail || {}; + this.multipleFilterValues = {}; + + // Lightweight validation: only include valid keys and non-null values (child does full validation) + for (const key in rawFilters) { + if (key && rawFilters[key] != null) { + this.multipleFilterValues[key] = rawFilters[key]; + } + } + + const filterKeys = Object.keys(this.multipleFilterValues); + const hasFilters = filterKeys.length > 0; + + // Store original list if not already stored + if (!this.resultListOrig) { + if (this.isDebug) console.log('handleMultipleFiltersChange: storing original list'); + this.resultListOrig = [...this.resultList]; + } + + // Early exit: no filters - restore original list + if (!hasFilters) { + if (this.isDebug) console.log('handleMultipleFiltersChange: no filters - restoring original list'); + this.isFiltered = false; + setTimeout(() => { + this.resultList = [...this.resultListOrig]; + if (this.hideCheckbox) this.selectedRecords = this.resultList; + this.isFiltering = false; + if (this.isDebug) console.log('handleMultipleFiltersChange: END (no filters)'); + }, 0); + return; + } + + // Extract filter criteria + const generalSearchKeywords = this.multipleFilterValues._generalSearch; + const columnFilterKeys = filterKeys.filter(key => key !== '_generalSearch'); + const hasColumnFilters = columnFilterKeys.length > 0; + const hasGeneralSearch = generalSearchKeywords && generalSearchKeywords.length > 0; + + if (this.isDebug) console.log('handleMultipleFiltersChange: applying filters', JSON.stringify(this.multipleFilterValues)); + this.isFiltered = true; + + // Apply filters - all logic inline + const filteredList = this.resultListOrig.filter(record => { + if (!record) return false; + + // Check column-specific filters + if (hasColumnFilters) { + for (const fieldName of columnFilterKeys) { + const filterValue = this.multipleFilterValues[fieldName]; + if (!filterValue) continue; + + const fieldValue = record[fieldName]; + + // Date range filter + if (typeof filterValue === 'object' && filterValue.mode === 'range') { + let recordDateStr = String(fieldValue || '').trim(); + if (recordDateStr.includes('T')) recordDateStr = recordDateStr.split('T')[0]; + if (!recordDateStr) return false; + if (filterValue.startDate && recordDateStr < filterValue.startDate.trim()) return false; + if (filterValue.endDate && recordDateStr > filterValue.endDate.trim()) return false; + } + // Date specific filter + else if (typeof filterValue === 'object' && filterValue.mode === 'specific') { + let recordDateStr = String(fieldValue || '').trim(); + if (recordDateStr.includes('T')) recordDateStr = recordDateStr.split('T')[0]; + let filterDateStr = String(filterValue.value || '').trim(); + if (filterDateStr.includes('T')) filterDateStr = filterDateStr.split('T')[0]; + if (recordDateStr !== filterDateStr) return false; + } + // Multi-select filter (OR logic) - values already normalized to lowercase by child + else if (Array.isArray(filterValue)) { + const fieldValueStr = String(fieldValue || '').toLowerCase(); + const matches = filterValue.some(selectedValue => { + const selectedValueLower = String(selectedValue || '').toLowerCase(); + return fieldValueStr === selectedValueLower || fieldValueStr.includes(selectedValueLower); + }); + if (!matches) return false; + } + // String filter - value already normalized to lowercase by child + else { + const fieldValueLower = String(fieldValue || '').toLowerCase(); + const filterValueLower = String(filterValue || '').toLowerCase(); + if (!fieldValueLower.includes(filterValueLower)) return false; + } + } + } + + // Check general search if present (OR logic - any keyword match) + if (hasGeneralSearch) { + let searchableText = ''; + for (const value of Object.values(record)) { + if (value != null) searchableText += String(value).toLowerCase() + ' '; + } + const hasMatch = generalSearchKeywords.some(keyword => searchableText.includes(keyword)); + if (!hasMatch) return false; + } + + return true; + }); + + // Use setTimeout to defer assignment (same pattern as filterRecords) + setTimeout(() => { + this.resultList = filteredList; + if (this.hideCheckbox) this.selectedRecords = this.resultList; + this.isFiltering = false; + + if (this.isDebug) { + console.log('handleMultipleFiltersChange: filtered', filteredList.length, 'of', this.resultListOrig.length, 'records'); + console.log('handleMultipleFiltersChange: END'); + } + }, 0); + } + // Expand / Collapse management handleExpandCollapse(event) { if (this.isDebug) console.log('handleExpandCollapse: START with isCollapsed ',this.isCollapsed); diff --git a/force-app/main/default/lwc/sfpegListCmp/sfpegListCmp.js-meta.xml b/force-app/main/default/lwc/sfpegListCmp/sfpegListCmp.js-meta.xml index 000b74d..d6e3634 100755 --- a/force-app/main/default/lwc/sfpegListCmp/sfpegListCmp.js-meta.xml +++ b/force-app/main/default/lwc/sfpegListCmp/sfpegListCmp.js-meta.xml @@ -62,6 +62,11 @@ type="Boolean" default="false" description="Flag to show Filter action in header."/> + + +
+
+ +
+ +
+ + +
+ + + + + +
+ + +
+
+
+
+ \ No newline at end of file diff --git a/force-app/main/default/lwc/sfpegMultiSelectCombobox/sfpegMultiSelectCombobox.js b/force-app/main/default/lwc/sfpegMultiSelectCombobox/sfpegMultiSelectCombobox.js new file mode 100644 index 0000000..c8fea01 --- /dev/null +++ b/force-app/main/default/lwc/sfpegMultiSelectCombobox/sfpegMultiSelectCombobox.js @@ -0,0 +1,260 @@ +/*** +* @description LWC Component for searchable multi-select combobox with checkboxes +***/ + +import { LightningElement, api, track } from 'lwc'; + +export default class SfpegMultiSelectCombobox extends LightningElement { + isListening = false; + _pickList = []; + @track searchResults; + @track selectedValues = []; + searchInputValue = ''; + hideTimeout; + isFocusing = false; // Flag to prevent hiding when focusing + + _labelText; + _picklistValue; + + @api + get labelText() { + if(!this._labelText) { return "No name specified" }; + return this._labelText; + } + + set labelText(value) { + this._labelText = value; + } + + @api + get pickList() { + return this._pickList; + } + + set pickList(value) { + this._pickList = value || []; + } + + @api + get picklistValue() { + // Return array of selected values + return this.selectedValues; + } + + set picklistValue(value) { + // If value is null or undefined, preserve existing values (don't clear) + if (value === null || value === undefined) { + return; + } + + // Convert input to array format + const newValues = Array.isArray(value) + ? [...value] + : (typeof value === 'string' && value.trim() + ? value.split(',').map(v => v.trim()).filter(v => v) + : []); + + // CRITICAL: If new value is empty but we have existing selections, ALWAYS preserve them + // This prevents parent re-renders or reactive updates from clearing user selections + // The only way to clear is through handleRemovePill or explicit user action + if (newValues.length === 0 && this.selectedValues.length > 0) { + // Preserve existing values - don't clear on empty updates + return; + } + + // Compare arrays to avoid unnecessary updates + const currentValuesStr = JSON.stringify([...this.selectedValues].sort()); + const newValuesStr = JSON.stringify([...newValues].sort()); + + // Only update if values actually changed + if (currentValuesStr !== newValuesStr) { + this.selectedValues = newValues; + this._picklistValue = this.selectedValues; + } + } + + get selectedValue() { + // Return empty string for search input (not showing selected value in input) + return this.searchInputValue; + } + + get selectedPills() { + return this.selectedValues.map(val => { + const option = this._pickList.find(opt => opt.value === val); + return { + value: val, + label: option ? option.label : val + }; + }); + } + + get availableOptions() { + // Filter out already selected options and filter by search text + return this._pickList.filter(opt => { + // Skip already selected + if (this.selectedValues.includes(opt.value)) return false; + // Filter by search text - support multiple keywords (OR logic) + if (this.searchInputValue) { + // Split search input into keywords (similar to general search) + const keywords = this.searchInputValue.trim().toLowerCase().split(/\s+/).filter(k => k.length > 0); + if (keywords.length > 0) { + const labelLower = opt.label.toLowerCase(); + const valueLower = String(opt.value).toLowerCase(); + // Check if any keyword matches (OR logic) + return keywords.some(keyword => + labelLower.includes(keyword) || valueLower.includes(keyword) + ); + } + } + return true; + }); + } + + /** + * + * @returns nothing + */ + renderedCallback() { + if (this.isListening) return; + + // Listen for clicks on the document to detect clicks outside + document.addEventListener("click", (event) => { + this.handleOutsideClick(event); + }); + this.isListening = true; + } + + /** + * Handle clicks outside the component - hide dropdown if click is outside + */ + handleOutsideClick(event) { + // Don't hide if we're currently focusing (focus event is firing) + console.log('handleOutsideClick '+this.isFocusing); + + if (this.isFocusing) { + return; + } + else{ + this.clearSearchResults(); + } + + } + + /** + * Filter the values to whatever text was entered - case insensitive + * This method is called when typing in the search input. + * It does NOT fire a change event - only updates the search results. + * The change event is ONLY fired when a value is selected or a pill is removed. + * */ + search(event) { + const input = event.detail.value || ''; + this.searchInputValue = input; + // Always update search results based on current input + // This does NOT trigger a change event - only selection/removal does + this.searchResults = this.availableOptions; + // Note: We do NOT call fireChange() here - that only happens on selection/removal + } + + /** + * Set the selected value (add to array for multi-select) + * This should ONLY be called when user clicks on a dropdown item, NOT while typing + * */ + selectSearchResult(event) { + console.log('search result'+event.type); + + // Safety check: ensure this is a click event, not triggered by typing or keyboard + if (!event || event.type !== 'click') { + // Silently return - this might be called programmatically, we don't want that + return; + } + + + // Prevent the click from bubbling to document and triggering hideDropdown + event.stopPropagation(); + event.preventDefault(); + + // Clear any pending blur timeout since user is interacting with dropdown + if (this.hideTimeout) { + clearTimeout(this.hideTimeout); + this.hideTimeout = null; + } + + const selectedValue = event.currentTarget?.dataset?.value; + console.log('selected value '+selectedValue); + + if (!selectedValue) { + return; + } + + // Add to selected values if not already selected + if (!this.selectedValues.includes(selectedValue)) { + this.selectedValues = [...this.selectedValues, selectedValue]; + this._picklistValue = this.selectedValues; + + // Fire change event - this is the ONLY place where selection triggers a change + this.fireChange(); + } + + // Keep dropdown open for multi-select + this.searchResults = this.availableOptions; + } + + handleRemovePill(event) { + const valueToRemove = event.currentTarget.dataset.value; + + this.selectedValues = this.selectedValues.filter(v => v !== valueToRemove); + this._picklistValue = this.selectedValues; + + // Update search results to include the removed option + this.searchResults = this.availableOptions; + + // Fire change event + this.fireChange(); + } + + fireChange() { + + // Fire change event when selectedValues changes + // This is ONLY called from selectSearchResult() and handleRemovePill() + // It is NEVER called from search() (typing) + if (Array.isArray(this.selectedValues) && this.selectedValues.length >= 0) { + this.dispatchEvent(new CustomEvent("valuechange", { + detail: { + value: this.selectedValues, + valueString: this.selectedValues.join(',') + }, + bubbles: true, + composed: true + })); + } + } + + /** + * Clear the results + * */ + clearSearchResults() { + this.searchInputValue = ''; + this.searchResults = null; + } + + /** + * Invoked when inputbox is focused + */ + showPickListOptions() { + console.log('showPickListOptions on focus'); + + // Set flag to prevent hideDropdown from firing during focus + this.isFocusing = true; + + // Clear search input to show all available options on first focus + // this.searchInputValue = ''; + + // Always show all available options on focus (not filtered by search) + this.searchResults = this.availableOptions; + + // Clear the flag after a short delay to allow click event to complete + setTimeout(() => { + this.isFocusing = false; + }, 1000); + } +} \ No newline at end of file diff --git a/force-app/main/default/lwc/sfpegMultiSelectCombobox/sfpegMultiSelectCombobox.js-meta.xml b/force-app/main/default/lwc/sfpegMultiSelectCombobox/sfpegMultiSelectCombobox.js-meta.xml new file mode 100644 index 0000000..93b80f3 --- /dev/null +++ b/force-app/main/default/lwc/sfpegMultiSelectCombobox/sfpegMultiSelectCombobox.js-meta.xml @@ -0,0 +1,10 @@ + + + 60.0 + false + + lightning__AppPage + lightning__RecordPage + lightning__HomePage + + \ No newline at end of file diff --git a/force-app/main/default/lwc/sfpegMultipleFilters/sfpegMultipleFilters.css b/force-app/main/default/lwc/sfpegMultipleFilters/sfpegMultipleFilters.css new file mode 100644 index 0000000..73c279d --- /dev/null +++ b/force-app/main/default/lwc/sfpegMultipleFilters/sfpegMultipleFilters.css @@ -0,0 +1,221 @@ +.filter-container { + background-color: var(--lwc-colorBackgroundAlt, #f3f3f3); + border-bottom: var(--lwc-borderWidthThin, 1px) solid var(--lwc-colorBorder, #dddbda); + border-top: var(--lwc-borderWidthThin, 1px) solid var(--lwc-colorBorder, #dddbda); + width: 100%; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); + overflow: visible; + position: relative; +} + +.active-filters-container { + width: 100%; + overflow: visible; /* Allow dropdowns to extend outside */ +} + +.filter-item-wrapper { + min-width: 0; /* Allow flex items to shrink */ + overflow: visible; /* Allow dropdowns to extend outside */ +} + +.filter-item { + background-color: var(--lwc-colorBackground, #ffffff); + border: var(--lwc-borderWidthThin, 1px) solid var(--lwc-colorBorder, #dddbda); + border-radius: var(--lwc-borderRadiusMedium, 0.25rem); + padding: var(--lwc-spacingSmall, 0.75rem); + box-shadow: 0 1px 2px rgba(0,0,0,0.05); + height: 100%; + min-width: 0; /* Allow content to shrink */ + overflow: visible; /* Allow dropdowns to extend outside */ +} + +.filter-input { + --sds-c-input-radius-border: 0.25rem; + width: 100%; + max-width: 100%; + box-sizing: border-box; + min-width: 0; /* Allow inputs to shrink */ +} + + +.general-search-input { + --sds-c-input-radius-border: 0.25rem; +} + +.clear-filters-button { + --sds-c-button-neutral-color-background: var(--lwc-colorBackground, #ffffff); + --sds-c-button-neutral-color-border: var(--lwc-colorBorder, #dddbda); +} + +.filter-mode-checkbox { + --sds-c-input-checkbox-label-color: var(--lwc-colorTextDefault, #080707); + margin-right: var(--lwc-spacingSmall, 0.75rem); +} + +.remove-filter-button { + margin-top: var(--lwc-spacingXxLarge, 1.5rem); +} + +.combobox-with-pills { + width: 100%; + max-width: 100%; + min-width: 0; /* Allow combobox to shrink */ + box-sizing: border-box; +} + +.pills-container { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + min-height: 1.5rem; + gap: 0.25rem; + width: 100%; + max-width: 100%; + box-sizing: border-box; +} + +/* Wrapper to allow pill truncation */ +.pill-wrapper { + flex: 0 1 auto; + min-width: 0; +} + +/* Constrain individual pill width to prevent taking full line */ +.pill-wrapper lightning-pill { + display: inline-block; + max-width: 300px; /* Truncate pills that exceed this width */ + box-sizing: border-box; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Also apply to pills in searchable combobox wrapper */ +.searchable-combobox-wrapper .pill-wrapper { + flex: 1 1 100%; /* Take full width of parent */ + min-width: 0; +} + +.searchable-combobox-wrapper .pill-wrapper lightning-pill { + display: inline-block; + max-width: 300px; /* Default - will be overridden by media queries */ + box-sizing: border-box; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + width: auto; /* Allow responsive sizing */ +} + +.date-filter-container { + width: 100%; + min-width: 0; /* Allow date filters to shrink */ +} + +.date-filter-label { + display: block; + margin-bottom: var(--lwc-spacingXxSmall, 0.25rem); + font-weight: var(--lwc-fontWeightBold, 700); + font-size: 0.75rem; /* Much smaller font for compact display */ + line-height: 1.2; +} + +.date-inputs-row { + margin-top: 0; +} + +/* Wrapper for searchable combobox to prevent parent flex/grid from affecting dropdown positioning */ +.searchable-combobox-wrapper { + width: 100%; + max-width: 100%; + position: relative; + /* Override parent flex properties that might affect positioning */ + flex: none; + align-self: auto; + min-width: 0; /* Allow searchable combobox to shrink */ + box-sizing: border-box; +} + +/* Ensure the searchable combobox component scales with parent */ +.searchable-combobox-wrapper c-sfpeg-multi-select-combobox { + width: 100%; + max-width: 100%; + min-width: 0; + display: block; + box-sizing: border-box; +} + +/* Responsive styles for mobile and tablet */ +@media (max-width: 1000px) { + /* Stack header elements vertically on mobile */ + .slds-grid.slds-grid_align-spread { + flex-direction: column; + align-items: flex-start; + } + + .slds-grid.slds-grid_align-spread .slds-col.slds-no-flex { + width: 100%; + margin-top: var(--lwc-spacingXSmall, 0.5rem); + } + + /* Stack general search and field selector on mobile */ + .slds-grid.slds-gutters { + flex-direction: column; + } + + .slds-grid.slds-gutters > .slds-col { + width: 100%; + margin-bottom: var(--lwc-spacingXSmall, 0.5rem); + } + + /* Reduce pill max-width on mobile - responsive to screen dimension */ + .pills-container .pill-wrapper lightning-pill { + max-width: 200px !important; /* Smaller on mobile screens */ + } + + .searchable-combobox-wrapper .pill-wrapper lightning-pill { + max-width: 200px !important; /* Responsive to screen dimension - same as regular pills */ + } + + /* Make filter items stack on mobile */ + .active-filters-container { + display: flex; + flex-direction: column; + } + + .filter-item-wrapper { + width: 100%; + margin-bottom: var(--lwc-spacingXSmall, 0.5rem); + } +} + +/* Tablet-specific adjustments - responsive to screen dimension */ +@media (min-width: 1001px) and (max-width: 2089px) { + /* Medium max-width on tablet screens */ + .pills-container .pill-wrapper lightning-pill { + max-width: 100px !important; /* Medium size for tablet (3 per row layout) */ + } + + .searchable-combobox-wrapper .pill-wrapper lightning-pill { + max-width: 100px !important; /* Responsive to screen dimension - same as regular pills for tablet */ + } +} + +/* Large screen adjustments - responsive to screen dimension */ +/* @media (min-width: 1025px) and (max-width: 1440px) { + + .pill-wrapper lightning-pill { + max-width: 250px; + } +} * + +/* Extra large screen adjustments */ +@media (min-width: 2090px) { + /* Larger max-width on extra large screens */ + .pills-container .pill-wrapper lightning-pill { + max-width: 300px !important; /* Larger size for extra large screens (6 per row layout) */ + } + + .searchable-combobox-wrapper .pill-wrapper lightning-pill { + max-width: 300px !important; /* Responsive to screen dimension - same as regular pills for extra large */ + } +} diff --git a/force-app/main/default/lwc/sfpegMultipleFilters/sfpegMultipleFilters.html b/force-app/main/default/lwc/sfpegMultipleFilters/sfpegMultipleFilters.html new file mode 100644 index 0000000..94e819f --- /dev/null +++ b/force-app/main/default/lwc/sfpegMultipleFilters/sfpegMultipleFilters.html @@ -0,0 +1,203 @@ + \ No newline at end of file diff --git a/force-app/main/default/lwc/sfpegMultipleFilters/sfpegMultipleFilters.js b/force-app/main/default/lwc/sfpegMultipleFilters/sfpegMultipleFilters.js new file mode 100644 index 0000000..552e372 --- /dev/null +++ b/force-app/main/default/lwc/sfpegMultipleFilters/sfpegMultipleFilters.js @@ -0,0 +1,677 @@ +/*** +* @description LWC Component to display multiple column filters for sfpegListCmp +* Provides dynamic filter selection with searchable field picker +***/ + +import { LightningElement, api, track } from 'lwc'; + +export default class SfpegMultipleFilters extends LightningElement { + @api columns = []; + @api isDebug = false; + @track useSearchableCombobox = false; // If true, use sfpegMultiSelectCombobox; if false, use lightning-combobox + + _records = []; + _initialRecordsReceived = false; + + /** + * Gets the records array + * @returns {Array} Array of record objects + */ + @api + get records() { + return this._records; + } + + /** + * Sets the records array + * Triggers distinct options computation on first receipt of records + * Only computes once to avoid performance issues with large datasets + * @param {Array} value - Array of record objects + */ + set records(value) { + const newRecords = value || []; + const recordsChanged = this._records.length !== newRecords.length || + this._records !== newRecords; + + this._records = newRecords; + this.isFiltering = false; + + // Only compute distinct options when records are first received (not when filtered) + if (newRecords.length > 0 && + this.columns.length > 0 && + !this._distinctOptionsComputed && + !this._initialRecordsReceived) { + + this._initialRecordsReceived = true; + + // Use setTimeout to ensure component is fully initialized + setTimeout(() => { + if (!this._distinctOptionsComputed) { + this.recomputeDistinctOptions(true); + } + }, 0); + } + } + + @track activeFilters = []; + @track generalSearchValue = ''; + @track distinctOptionsByField = {}; + @track isFiltering = false; // Controlled by parent - shows spinner and prevents duplicate filtering + @track useButtonFilter = false; // If true, filter only on button click; if false, filter automatically + + recomputeTimeout; + filterTimeout; + generalSearchTimeout; + _lastRecordsRef = null; + _distinctOptionsComputed = false; // Flag to ensure we only compute once + _originalRecords = null; // Store original records for distinct value computation + + /** + * Checks if columns are available + * @returns {boolean} True if columns exist and have length > 0 + */ + get hasColumns() { + return this.columns && this.columns.length > 0; + } + + /** + * Filters columns to only include filterable ones (excludes action columns) + * @returns {Array} Array of filterable column objects + */ + get filterableColumns() { + if (!this.columns) return []; + return this.columns.filter(col => + col.fieldName && col.type !== 'action' && !col.typeAttributes?.rowActions + ); + } + + /** + * Gets options for the field selector dropdown (only shows fields not already added as filters) + * @returns {Array} Array of {label, value} objects for available fields + */ + get fieldSelectorOptions() { + const selectedFieldNames = this.activeFilters.map(f => f.fieldName); + return this.filterableColumns + .filter(col => !selectedFieldNames.includes(col.fieldName)) + .map(col => ({ + label: col.label || col.fieldName, + value: col.fieldName + })); + } + + /** + * Checks if a column is a date type + * @param {Object} col - Column object + * @returns {boolean} True if column type is date, date-local, datetime, or time + */ + isDateType(col) { + const t = (col.type || '').toLowerCase(); + return t === 'date' || t === 'date-local' || t === 'datetime' || t === 'time'; + } + + /** + * Checks if a column is a text type (text, string, or empty/undefined) + * @param {Object} col - Column object + * @returns {boolean} True if column is text type and not a date type + */ + isTextType(col) { + const t = (col.type || '').toLowerCase(); + if (this.isDateType(col)) return false; + return !t || t === 'text' || t === 'string'; + } + + /** + * Lifecycle hook: Called when component is inserted into the DOM + * Initializes searchable combobox and computes distinct options if records/columns are available + */ + connectedCallback() { + if (this._records.length > 0 && + this.columns.length > 0 && + !this._distinctOptionsComputed && + !this._initialRecordsReceived) { + this._initialRecordsReceived = true; + this.recomputeDistinctOptions(true); + } + } + + /** + * Lifecycle hook: Called after every render + * Fallback to compute distinct options if records were set after connectedCallback + */ + renderedCallback() { + const currentRecords = this._records || []; + if (currentRecords.length > 0 && + this.columns.length > 0 && + !this._distinctOptionsComputed && + !this._initialRecordsReceived && + Object.keys(this.distinctOptionsByField).length === 0) { + this._initialRecordsReceived = true; + this._lastRecordsRef = currentRecords; + this.recomputeDistinctOptions(true); + } + } + + /** + * Gets a field value from a record, supporting nested field paths (e.g., "Account.Name") + * @param {Object} record - The record object + * @param {string} fieldName - Field name, can be nested (e.g., "Account.Name") + * @returns {*} The field value or null if not found + */ + getFieldValue(record, fieldName) { + if (!record || !fieldName) return null; + const fieldParts = fieldName.split('.'); + let value = record; + for (const part of fieldParts) { + if (value === null || value === undefined) return null; + value = value[part]; + } + return value; + } + + /** + * Finds filter index by fieldName + * @param {string} fieldName - Field name to search for + * @returns {number} Filter index or -1 if not found + */ + findFilterIndex(fieldName) { + return this.activeFilters.findIndex(f => f?.fieldName === fieldName); + } + + /** + * Updates a filter's property and triggers reactivity + * @param {number} filterIndex - Index of filter to update + * @param {string} property - Property name to update + * @param {*} value - Value to set + */ + updateFilter(filterIndex, property, value) { + if (filterIndex >= 0) { + this.activeFilters[filterIndex][property] = value; + this.activeFilters = [...this.activeFilters]; + } + } + + /** + * Conditionally applies filters based on useButtonFilter setting + * @param {number} delay - Delay in milliseconds (default: 100) + * @param {string} timeoutName - Name of timeout variable ('filterTimeout' or 'generalSearchTimeout') + */ + applyFiltersIfAuto(delay = 100, timeoutName = 'filterTimeout') { + if (!this.useButtonFilter) { + clearTimeout(this[timeoutName]); + this[timeoutName] = setTimeout(() => this.applyFilters(), delay); + } + } + + /** + * Normalizes date value by removing time component + * @param {string} dateValue - Date string (may include time) + * @returns {string} Date string in YYYY-MM-DD format + */ + normalizeDate(dateValue) { + return String(dateValue || '').trim().split('T')[0]; + } + + /** + * Normalizes combobox values to array of lowercase strings + * @param {*} filterValue - Filter value (array or single value) + * @returns {Array} Array of normalized lowercase strings + */ + normalizeComboboxValues(filterValue) { + const values = Array.isArray(filterValue) + ? filterValue + : (filterValue != null ? [String(filterValue).trim()] : []); + return values + .filter(v => v != null) + .map(v => String(v).trim().toLowerCase()) + .filter(v => v); + } + + /** + * Normalizes text value to array of keywords (split by space) for OR search + * @param {*} filterValue - Filter value + * @returns {Array} Array of lowercase keywords for OR search + */ + normalizeTextValue(filterValue) { + const textValue = String(filterValue || '').trim(); + if (!textValue) return []; + // Split by spaces and filter out empty strings + return textValue.toLowerCase().split(/\s+/).filter(keyword => keyword.length > 0); + } + + /** + * Normalizes general search value to array of keywords + * @param {string} searchValue - General search input value + * @returns {Array} Array of lowercase keywords + */ + normalizeSearchKeywords(searchValue) { + const trimmed = searchValue?.trim(); + if (!trimmed) return []; + return trimmed.toLowerCase().split(/\s+/).filter(k => k); + } + + /** + * Dispatches filterchange event with given filter data + * @param {Object} filterData - Filter data object to dispatch + */ + dispatchFilterChange(filterData) { + this.dispatchEvent(new CustomEvent('filterchange', { + detail: filterData, + bubbles: true, + composed: true + })); + } + + /** + * Computes distinct values for each filterable column (used for combobox options) + * Only runs once per component lifecycle to avoid performance issues + * Scans up to 5000 records to find unique values + * @param {boolean} immediate - If true, runs immediately; otherwise uses setTimeout + */ + recomputeDistinctOptions(immediate = false) { + if (this._distinctOptionsComputed) return; + + const cols = this.filterableColumns || []; + const data = this._records || []; + + if (!this._originalRecords && data.length > 0) { + this._originalRecords = [...data]; + } + + const recordsToUse = this._originalRecords || data; + + if (cols.length === 0 || recordsToUse.length === 0) { + this.distinctOptionsByField = {}; + return; + } + + const run = () => { + if (this._distinctOptionsComputed) return; + + const optionsByField = {}; + const SCAN_LEN = Math.min(recordsToUse.length, 5000); + + cols.forEach(col => { + const field = col.fieldName; + if (!field || this.isDateType(col)) { + optionsByField[field] = []; + return; + } + + const valuesSet = new Set(); + + for (let i = 0; i < SCAN_LEN; i++) { + const rec = recordsToUse[i]; + if (!rec) continue; + + const raw = this.getFieldValue(rec, field); + if (raw == null) continue; + + const s = String(raw).trim(); + if (s) valuesSet.add(s); + } + + optionsByField[field] = valuesSet.size > 0 + ? Array.from(valuesSet).sort((a, b) => a.localeCompare(b)).map(v => ({ label: v, value: v })) + : []; + }); + + this.distinctOptionsByField = { ...optionsByField }; + this._distinctOptionsComputed = true; + }; + + clearTimeout(this.recomputeTimeout); + immediate ? run() : (this.recomputeTimeout = setTimeout(run, 150)); + } + + /** + * Handles field selection from the field selector dropdown + * Creates a new filter based on column type and distinct values count + * - Date columns → date filter + * - Text columns with < 3000 distinct values → combobox filter + * - Other columns → text filter + * @param {Event} event - Change event from field selector combobox + */ + handleFieldSelectorChange(event) { + const fieldName = event.detail.value; + if (!fieldName) return; + + const col = this.filterableColumns.find(c => c.fieldName === fieldName); + if (!col) return; + + const options = this.distinctOptionsByField[fieldName] || []; + const optionsCount = options.length; + + if (this.isDebug) { + console.log('handleFieldSelectorChange', { + fieldName: fieldName, + columnType: col.type, + columnLabel: col.label, + isTextType: this.isTextType(col), + isDateType: this.isDateType(col), + optionsCount: optionsCount, + options: options, + distinctOptionsKeys: Object.keys(this.distinctOptionsByField), + hasOptionsForField: !!this.distinctOptionsByField[fieldName] + }); + } + + // Determine filter type based on column type and distinct values count + const filterType = this.isDateType(col) ? 'date' + : (this.isTextType(col) && optionsCount > 0 && optionsCount < 3000) ? 'combobox' + : 'text'; + + const newFilter = { + fieldName, + label: col.label || fieldName, + type: filterType, + filterValue: filterType === 'combobox' ? [] : '', + options, + isDateRange: filterType === 'date', + startDate: filterType === 'date' ? '' : null, + endDate: filterType === 'date' ? '' : null + }; + + this.activeFilters = [...this.activeFilters, newFilter]; + + const fieldSelector = this.template.querySelector('[data-field-selector]'); + if (fieldSelector) { + fieldSelector.value = null; + } + } + + /** + * Removes a filter from activeFilters and applies filters + * @param {Event} event - Click event from remove filter button + */ + handleRemoveFilter(event) { + const fieldName = event.currentTarget.dataset.fieldname; + if (!fieldName) return; + + this.activeFilters = this.activeFilters.filter(f => f.fieldName !== fieldName); + this.applyFilters(); + } + + /** + * Handles date filter changes (start or end date) + * Updates the filter value and applies filters if auto-filter mode is enabled + * @param {Event} event - Change event from date input + */ + handleDateFilterChange(event) { + const fieldName = event.target.dataset.fieldname; + const dateType = event.target.dataset.dateType; + const value = event.target.value || ''; + + const filterIndex = this.findFilterIndex(fieldName); + this.updateFilter(filterIndex, dateType === 'end' ? 'endDate' : 'startDate', value); + this.applyFiltersIfAuto(500); + } + + /** + * Handles filter mode checkbox change (auto-filter vs button-filter) + * If switching to auto-filter mode, applies current filters immediately + * @param {Event} event - Change event from checkbox + */ + handleFilterModeChange(event) { + this.useButtonFilter = event.target.checked; + if (!this.useButtonFilter) { + this.applyFilters(); + } + } + + /** + * Handles searchable combobox mode checkbox change + * @param {Event} event - Change event from checkbox + */ + handleSearchableComboboxModeChange(event) { + this.useSearchableCombobox = event.target.checked; + } + + /** + * Handles text filter input changes + * Updates the filter value and applies filters if auto-filter mode is enabled + * @param {Event} event - Change event from text input + */ + handleTextFilterChange(event) { + const fieldName = event.target.dataset.fieldname; + const value = String(event.target.value || '').trim(); + + if (!fieldName) return; + + const filterIndex = this.findFilterIndex(fieldName); + this.updateFilter(filterIndex, 'filterValue', value); + this.applyFiltersIfAuto(500); + } + + /** + * Handles Enter key press in text filter input + * Applies filters immediately when Enter is pressed + * @param {Event} event - Keypress event + */ + handleTextFilterKeyPress(event) { + if (event.keyCode === 13) { + event.preventDefault(); + this.applyFilters(); + } + } + + /** + * Handles search button click + * Applies all current filters + */ + handleSearchClick() { + this.applyFilters(); + } + + /** + * Handles standard lightning-combobox change (non-searchable) + * Adds selected value to filter's array of values (multi-select) + * Clears combobox after selection to allow selecting another value + * @param {Event} event - Change event from lightning-combobox + */ + handleComboboxChange(event) { + const fieldName = event.currentTarget.dataset.fieldname; + const selectedValue = String(event.detail.value || '').trim(); + + if (!fieldName || !selectedValue) return; + + const filterIndex = this.findFilterIndex(fieldName); + if (filterIndex >= 0) { + const currentValues = Array.isArray(this.activeFilters[filterIndex].filterValue) + ? this.activeFilters[filterIndex].filterValue.filter(v => v != null) + : []; + + if (!currentValues.includes(selectedValue)) { + this.updateFilter(filterIndex, 'filterValue', [...currentValues, selectedValue]); + + setTimeout(() => { + const combobox = this.template.querySelector(`lightning-combobox[data-fieldname="${fieldName}"]`); + if (combobox) combobox.value = null; + }, 0); + } + } + + this.applyFiltersIfAuto(100); + } + + /** + * Handles searchable multi-select combobox change + * Updates filter with array of selected values + * Note: Change event only fires on selection/removal, not while typing + * @param {Event} event - Change event from sfpegMultiSelectCombobox + */ + handleSearchableComboboxChange(event) { + const fieldNameElement = event.target.closest('[data-fieldname]'); + const fieldName = fieldNameElement?.dataset.fieldname; + + if (!fieldName) return; + + const selectedValues = Array.isArray(event.detail.value) + ? event.detail.value.filter(v => v != null) + : []; + + const filterIndex = this.findFilterIndex(fieldName); + this.updateFilter(filterIndex, 'filterValue', selectedValues); + this.applyFiltersIfAuto(100); + } + + /** + * Handles pill removal from combobox filters + * Removes the value from filter's array and applies filters if auto-filter mode is enabled + * @param {Event} event - Remove event from lightning-pill + */ + handleRemovePill(event) { + const fieldName = event.currentTarget.dataset.fieldname; + const valueToRemove = event.currentTarget.dataset.value; + + if (!fieldName || valueToRemove == null) return; + + const filterIndex = this.findFilterIndex(fieldName); + if (filterIndex >= 0) { + const currentValues = Array.isArray(this.activeFilters[filterIndex].filterValue) + ? this.activeFilters[filterIndex].filterValue.filter(v => v != null && v !== valueToRemove) + : []; + this.updateFilter(filterIndex, 'filterValue', currentValues); + } + + this.applyFiltersIfAuto(100); + } + + /** + * Handles general search input changes + * Updates generalSearchValue and applies filters if auto-filter mode is enabled + * @param {Event} event - Change event from general search input + */ + handleGeneralSearchChange(event) { + this.generalSearchValue = event.target.value; + this.applyFiltersIfAuto(500, 'generalSearchTimeout'); + } + + /** + * Handles Enter key press in general search input + * Applies filters immediately when Enter is pressed + * @param {Event} event - Keypress event + */ + handleGeneralSearchKeyPress(event) { + if (event.keyCode === 13) { + event.preventDefault(); + this.applyFilters(); + } + } + + /** + * Main method that prepares and dispatches filter data to parent component + * Normalizes all filter values (dates, text, combobox) and general search keywords + * Dispatches 'filterchange' event with normalized filter object + * Parent component handles the actual filtering logic + */ + applyFilters() { + this.isFiltering = true; + const activeFilters = {}; + + this.activeFilters.forEach(filter => { + if (!filter?.fieldName) return; + + const { fieldName, filterValue, type, startDate, endDate } = filter; + const validFieldName = String(fieldName).trim(); + if (!validFieldName) return; + + if (type === 'date') { + const start = this.normalizeDate(startDate); + const end = this.normalizeDate(endDate); + if (start || end) { + activeFilters[validFieldName] = { mode: 'range', startDate: start, endDate: end }; + } + } else if (filterValue != null) { + if (type === 'combobox') { + const normalized = this.normalizeComboboxValues(filterValue); + if (normalized.length > 0) { + activeFilters[validFieldName] = normalized; + } + } else { + const textKeywords = this.normalizeTextValue(filterValue); + if (textKeywords.length > 0) { + activeFilters[validFieldName] = textKeywords; // Array of keywords for OR search + } + } + } + }); + + const keywords = this.normalizeSearchKeywords(this.generalSearchValue); + if (keywords.length > 0) { + activeFilters._generalSearch = keywords; + } + + this.dispatchFilterChange(activeFilters); + } + + /** + * Clears all filters and resets all input fields + * Dispatches empty filterchange event to restore original list in parent + */ + handleClearFilters() { + this.isFiltering = true; + this.activeFilters = []; + this.generalSearchValue = ''; + + const inputs = this.template.querySelectorAll('lightning-input'); + inputs.forEach(input => input.value = ''); + + const combos = this.template.querySelectorAll('lightning-combobox'); + combos.forEach(combo => combo.value = null); + + const fieldSelector = this.template.querySelector('[data-field-selector]'); + if (fieldSelector) fieldSelector.value = null; + + clearTimeout(this.filterTimeout); + clearTimeout(this.generalSearchTimeout); + + this.dispatchFilterChange({}); + } + + /** + * Computed getter that enriches activeFilters with computed properties for template rendering + * - Adds isDate, isCombobox, isText flags + * - Creates selectedPills for combobox filters + * - Filters availableOptions to exclude already selected values + * - Adds date labels for date filters + * @returns {Array} Array of filter objects with computed properties + */ + get filterItemsWithComputed() { + return this.activeFilters.map(filter => { + const allOptions = (this.distinctOptionsByField[filter.fieldName] || []).filter(opt => opt?.value != null); + const isCombobox = filter.type === 'combobox'; + const isDate = filter.type === 'date'; + const selectedValues = isCombobox && Array.isArray(filter.filterValue) + ? filter.filterValue.filter(v => v != null) + : []; + + let selectedPills = []; + let availableOptions = allOptions; + + if (isCombobox) { + selectedPills = selectedValues + .map(val => { + const option = allOptions.find(opt => opt.value === val); + return { value: String(val), label: option?.label || String(val) }; + }) + .filter(pill => pill); + + const selectedSet = new Set(selectedValues); + availableOptions = allOptions.filter(opt => !selectedSet.has(opt.value)); + } + + return { + ...filter, + isDate, + isCombobox, + isText: filter.type === 'text', + options: allOptions, + availableOptions, + selectedPills, + isDateRange: isDate, + startDateLabel: isDate ? `${filter.label} (Début)` : '', + endDateLabel: isDate ? `${filter.label} (Fin)` : '' + }; + }); + } +} \ No newline at end of file diff --git a/force-app/main/default/lwc/sfpegMultipleFilters/sfpegMultipleFilters.js-meta.xml b/force-app/main/default/lwc/sfpegMultipleFilters/sfpegMultipleFilters.js-meta.xml new file mode 100644 index 0000000..93b80f3 --- /dev/null +++ b/force-app/main/default/lwc/sfpegMultipleFilters/sfpegMultipleFilters.js-meta.xml @@ -0,0 +1,10 @@ + + + 60.0 + false + + lightning__AppPage + lightning__RecordPage + lightning__HomePage + + \ No newline at end of file