@trackunit/filters-filter-bar 1.3.89 → 1.3.92

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.cjs.js CHANGED
@@ -11,6 +11,7 @@ var reactCoreContextsApi = require('@trackunit/react-core-contexts-api');
11
11
  var reactFormComponents = require('@trackunit/react-form-components');
12
12
  var reactDateAndTimeComponents = require('@trackunit/react-date-and-time-components');
13
13
  var sharedUtils = require('@trackunit/shared-utils');
14
+ var cssClassVarianceUtilities = require('@trackunit/css-class-variance-utilities');
14
15
  var tailwindMerge = require('tailwind-merge');
15
16
  var dequal = require('dequal');
16
17
  var isEqual = require('lodash/isEqual');
@@ -628,6 +629,231 @@ const DefaultRadioFilter = ({ filterDefinition, filterBarActions, options, loadi
628
629
  }, value: selectedRadioId?.key || "", children: jsxRuntime.jsx(DynamicFilterList, { checked: index => filterBarActions.objectIncludesValue(filterDefinition.filterKey, res[index]?.key || ""), count: index => res[index]?.count, keyMapper: index => res[index]?.key || "", labelMapper: index => res[index]?.label || "", rowCount: res.length, showRequestMoreUseSearch: showRequestMoreUseSearch, type: "Radio" }) })) })] }));
629
630
  };
630
631
 
632
+ /**
633
+ * Recursively gets all descendant keys for a given hierarchical option, including the option itself.
634
+ *
635
+ * @param option The starting hierarchical filter option.
636
+ * @returns An array of string keys representing the option and all its descendants.
637
+ */
638
+ const getAllDescendantKeys = (option) => {
639
+ let keys = [option.key];
640
+ if (option.children) {
641
+ option.children.forEach(child => {
642
+ keys = keys.concat(getAllDescendantKeys(child));
643
+ });
644
+ }
645
+ return keys;
646
+ };
647
+ /**
648
+ * Recursively filters hierarchical options based on a search term.
649
+ * Keeps an option if its label matches or if any descendant matches.
650
+ */
651
+ const filterHierarchicalOptions = (options, searchTerm, parentMatches = false // Track if the parent already matched
652
+ ) => {
653
+ if (!searchTerm) {
654
+ return options; // Return all options if search term is empty
655
+ }
656
+ const lowerCaseSearchTerm = searchTerm.toLowerCase();
657
+ return options.reduce((acc, option) => {
658
+ const labelMatches = option.label.toLowerCase().includes(lowerCaseSearchTerm);
659
+ const matches = labelMatches || parentMatches;
660
+ let filteredChildren = [];
661
+ if (option.children && option.children.length > 0) {
662
+ // Pass `matches` as `parentMatches` for children
663
+ filteredChildren = filterHierarchicalOptions(option.children, searchTerm, matches);
664
+ }
665
+ // Include the option if:
666
+ // 1. Its label matches OR
667
+ // 2. Its parent matched (meaning it's part of a matched branch) OR
668
+ // 3. It has children that matched
669
+ if (matches || filteredChildren.length > 0) {
670
+ acc.push({
671
+ ...option,
672
+ // Only include children if they (or their descendants) matched
673
+ children: filteredChildren.length > 0 ? filteredChildren : undefined,
674
+ });
675
+ }
676
+ return acc;
677
+ }, []);
678
+ };
679
+
680
+ /**
681
+ * Custom hook to manage the state and interactions of a single hierarchical option.
682
+ * Calculates checked/indeterminate state and provides a handler for value changes,
683
+ * including cascading logic.
684
+ */
685
+ const useHierarchicalOptionState = ({ options, option, selectedValues, cascadeSelection, optionsMap, setValue, filterDefinition, logEvent, }) => {
686
+ const allDescendantKeys = react.useMemo(() => (cascadeSelection ? getAllDescendantKeys(option) : [option.key]), [option, cascadeSelection]);
687
+ const hasChildren = react.useMemo(() => (option.children ?? []).length > 0, [option.children]);
688
+ const parentMap = react.useMemo(() => {
689
+ const map = new Map();
690
+ const traverse = (opts, parentKey) => {
691
+ opts.forEach(opt => {
692
+ if (parentKey) {
693
+ map.set(opt.key, parentKey);
694
+ }
695
+ if (opt.children && Array.isArray(opt.children) && opt.children.length > 0) {
696
+ traverse(opt.children, opt.key);
697
+ }
698
+ });
699
+ };
700
+ traverse(options);
701
+ return map;
702
+ }, [options]);
703
+ const { isChecked, isIndeterminate } = react.useMemo(() => {
704
+ if (!cascadeSelection || !hasChildren) {
705
+ const selfChecked = selectedValues.some(v => v.value === option.key);
706
+ return { isChecked: selfChecked, isIndeterminate: false };
707
+ }
708
+ const selectedKeySet = new Set(selectedValues.map(v => v.value));
709
+ let allChildrenChecked = true;
710
+ let someChildrenChecked = false;
711
+ const descendantKeysOnly = allDescendantKeys.filter(k => k !== option.key);
712
+ if (descendantKeysOnly.length === 0) {
713
+ return { isChecked: selectedKeySet.has(option.key), isIndeterminate: false };
714
+ }
715
+ for (const key of descendantKeysOnly) {
716
+ if (selectedKeySet.has(key)) {
717
+ someChildrenChecked = true;
718
+ }
719
+ else {
720
+ allChildrenChecked = false;
721
+ }
722
+ }
723
+ const checked = allChildrenChecked;
724
+ const indeterminate = someChildrenChecked && !allChildrenChecked;
725
+ return { isChecked: checked, isIndeterminate: indeterminate };
726
+ }, [selectedValues, option.key, allDescendantKeys, cascadeSelection, hasChildren]);
727
+ const handleSelect = react.useCallback(() => {
728
+ logEvent("Filters Applied - V2", {
729
+ type: `${stringTs.capitalize(filterDefinition.filterKey)}Filter`,
730
+ value: option.key,
731
+ });
732
+ setValue(prev => {
733
+ let currentSelectedValues = prev ? [...prev] : [];
734
+ const selectedKeySet = new Set(currentSelectedValues.map(v => v.value));
735
+ if (!cascadeSelection) {
736
+ // Non-cascading: Toggle only the current option
737
+ if (selectedKeySet.has(option.key)) {
738
+ currentSelectedValues = currentSelectedValues.filter(f => f.value !== option.key);
739
+ }
740
+ else {
741
+ currentSelectedValues.push({ name: option.label, value: option.key });
742
+ }
743
+ }
744
+ else {
745
+ const keysToToggle = getAllDescendantKeys(option);
746
+ const shouldBeChecked = !isChecked;
747
+ if (!shouldBeChecked) {
748
+ const keysToRemove = new Set(getAllDescendantKeys(option));
749
+ let currentParentKey = parentMap.get(option.key);
750
+ while (currentParentKey) {
751
+ keysToRemove.add(currentParentKey);
752
+ currentParentKey = parentMap.get(currentParentKey);
753
+ }
754
+ currentSelectedValues = currentSelectedValues.filter(f => !keysToRemove.has(f.value));
755
+ }
756
+ else {
757
+ // Add the option and all descendants if they are not already selected
758
+ const itemsToAdd = [];
759
+ keysToToggle.forEach(key => {
760
+ if (!selectedKeySet.has(key)) {
761
+ const optionDetails = optionsMap.get(key);
762
+ // Ensure we have details before adding, fallback to key if not found (shouldn't happen ideally)
763
+ itemsToAdd.push({ name: optionDetails?.label ?? key, value: key });
764
+ }
765
+ });
766
+ currentSelectedValues.push(...itemsToAdd);
767
+ }
768
+ }
769
+ return currentSelectedValues;
770
+ });
771
+ }, [cascadeSelection, filterDefinition.filterKey, logEvent, setValue, isChecked, option, optionsMap, parentMap]);
772
+ return {
773
+ isChecked,
774
+ isIndeterminate,
775
+ handleSelect,
776
+ };
777
+ };
778
+
779
+ /**
780
+ * Renders a single option and its children recursively.
781
+ * Uses the `useHierarchicalOptionState` hook for state management and interaction logic.
782
+ */
783
+ const RenderHierarchicalOption = ({ option, filterDefinition, filterBarActions, setValue, logEvent, selectedValues, cascadeSelection, optionsMap, }) => {
784
+ const level = option.level ?? 0;
785
+ const { isChecked, isIndeterminate, handleSelect } = useHierarchicalOptionState({
786
+ option,
787
+ selectedValues,
788
+ cascadeSelection,
789
+ optionsMap,
790
+ setValue,
791
+ options: Array.from(optionsMap.values()),
792
+ filterDefinition,
793
+ logEvent,
794
+ });
795
+ return (jsxRuntime.jsxs(react.Fragment, { children: [jsxRuntime.jsx("div", { className: cvaOptionItem({ indentationLevel: level > 6 ? 6 : level }), children: jsxRuntime.jsx(reactFilterComponents.CheckBoxFilterItem, { checked: isChecked, className: "rounded-none", dataTestId: `hierarchical-filter-check-box-${option.key}`, indeterminate: isIndeterminate, itemCount: option.count, label: option.label, name: `hierarchical-filter-check-box-${option.key}`, onChange: handleSelect }) }), option.children?.map(child => (jsxRuntime.jsx(RenderHierarchicalOption, { cascadeSelection: cascadeSelection, filterBarActions: filterBarActions, filterDefinition: filterDefinition, logEvent: logEvent, option: child, optionsMap: optionsMap, selectedValues: selectedValues, setValue: setValue }, child.key)))] }, option.key));
796
+ };
797
+ const cvaOptionItem = cssClassVarianceUtilities.cvaMerge(["m-1"], {
798
+ variants: {
799
+ indentationLevel: {
800
+ 0: ["pl-0"],
801
+ 1: ["pl-6"],
802
+ 2: ["pl-12"],
803
+ 3: ["pl-18"],
804
+ 4: ["pl-24"],
805
+ 5: ["pl-30"],
806
+ 6: ["pl-36"],
807
+ },
808
+ },
809
+ defaultVariants: {
810
+ indentationLevel: 0,
811
+ },
812
+ });
813
+
814
+ /**
815
+ * `HierarchicalCheckboxFilter` is a React component for handling hierarchical checkbox filters.
816
+ * It expects options to be pre-processed into a hierarchical structure.
817
+ *
818
+ * Supports optional cascading selection behavior via the `cascadeSelection` prop.
819
+ */
820
+ const HierarchicalCheckboxFilter = ({ filterDefinition, filterBarActions, options, loading, setValue, value, cascadeSelection = false, customSearch, }) => {
821
+ const { logEvent } = reactCoreHooks.useAnalytics(FilterEvents);
822
+ const selectedValues = value ?? [];
823
+ // Local state for client-side search (used if customSearch is not provided)
824
+ const [localSearchText, setLocalSearchText] = react.useState("");
825
+ // Determine if we are using server-side search (customSearch) or client-side search
826
+ const isServerSearch = !!customSearch;
827
+ const currentSearchText = isServerSearch ? customSearch.value : localSearchText;
828
+ const currentSetSearchText = isServerSearch ? customSearch.onChange : setLocalSearchText;
829
+ const searchHeaderProps = {
830
+ searchEnabled: true,
831
+ searchProps: { value: currentSearchText, onChange: currentSetSearchText },
832
+ };
833
+ const optionsMap = react.useMemo(() => {
834
+ const map = new Map();
835
+ const traverse = (opts) => {
836
+ opts.forEach(opt => {
837
+ map.set(opt.key, opt);
838
+ if (opt.children) {
839
+ traverse(opt.children);
840
+ }
841
+ });
842
+ };
843
+ traverse(options);
844
+ return map;
845
+ }, [options]);
846
+ // Filter options based on search term, preserving hierarchy
847
+ const filteredOptions = react.useMemo(() => {
848
+ // Only apply client-side filtering if not doing server-side search
849
+ if (isServerSearch) {
850
+ return options; // Assume server provides filtered options
851
+ }
852
+ return filterHierarchicalOptions(options, localSearchText);
853
+ }, [options, isServerSearch, localSearchText]);
854
+ return (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx(FilterHeader, { ...filterDefinition, ...filterBarActions, filterHasChanges: filterBarActions.appliedFilterKeys().includes(filterDefinition.filterKey), loading: loading, ...searchHeaderProps }), jsxRuntime.jsx("div", { className: "max-h-60 overflow-y-auto", children: jsxRuntime.jsx(FilterResults, { loading: loading, results: filteredOptions, children: results => (jsxRuntime.jsx(jsxRuntime.Fragment, { children: results.map(option => (jsxRuntime.jsx(RenderHierarchicalOption, { cascadeSelection: cascadeSelection, filterBarActions: filterBarActions, filterDefinition: filterDefinition, logEvent: logEvent, option: option, optionsMap: optionsMap, selectedValues: selectedValues, setValue: setValue }, option.key))) })) }) })] }));
855
+ };
856
+
631
857
  /**
632
858
  * Custom React hook for grouping and filtering a list of filters.
633
859
  *
@@ -1709,6 +1935,7 @@ exports.FilterBar = FilterBar;
1709
1935
  exports.FilterEvents = FilterEvents;
1710
1936
  exports.FilterHeader = FilterHeader;
1711
1937
  exports.FilterResults = FilterResults;
1938
+ exports.HierarchicalCheckboxFilter = HierarchicalCheckboxFilter;
1712
1939
  exports.StarredFilters = StarredFilters;
1713
1940
  exports.areaFilterGeoJsonGeometrySchema = areaFilterGeoJsonGeometrySchema;
1714
1941
  exports.isAreaFilterValue = isAreaFilterValue;
package/index.esm.js CHANGED
@@ -3,12 +3,13 @@ import { registerTranslations, useNamespaceTranslation } from '@trackunit/i18n-l
3
3
  import { VirtualizedList, Text, Button, Popover, PopoverTrigger, PopoverContent, MenuList, cvaInteractableItem, useViewportBreakpoints, Tooltip, Icon, IconButton, Card, CardBody, CardFooter } from '@trackunit/react-components';
4
4
  import { useAnalytics, useTextSearch, useCurrentUser } from '@trackunit/react-core-hooks';
5
5
  import { FilterBody, RadioFilterItem, CheckBoxFilterItem, FilterHeader as FilterHeader$1, FilterFooter, Filter as Filter$1 } from '@trackunit/react-filter-components';
6
- import { useRef, useMemo, useState, useEffect, useCallback } from 'react';
6
+ import { useRef, useMemo, useState, useEffect, useCallback, Fragment as Fragment$1 } from 'react';
7
7
  import { capitalize } from 'string-ts';
8
8
  import { createEvent } from '@trackunit/react-core-contexts-api';
9
9
  import { Search, NumberField, RadioGroup, ToggleSwitchOption } from '@trackunit/react-form-components';
10
10
  import { DayRangePicker } from '@trackunit/react-date-and-time-components';
11
11
  import { nonNullable, capitalize as capitalize$1, objectValues, truthy, objectKeys } from '@trackunit/shared-utils';
12
+ import { cvaMerge } from '@trackunit/css-class-variance-utilities';
12
13
  import { twMerge } from 'tailwind-merge';
13
14
  import { dequal } from 'dequal';
14
15
  import isEqual from 'lodash/isEqual';
@@ -626,6 +627,231 @@ const DefaultRadioFilter = ({ filterDefinition, filterBarActions, options, loadi
626
627
  }, value: selectedRadioId?.key || "", children: jsx(DynamicFilterList, { checked: index => filterBarActions.objectIncludesValue(filterDefinition.filterKey, res[index]?.key || ""), count: index => res[index]?.count, keyMapper: index => res[index]?.key || "", labelMapper: index => res[index]?.label || "", rowCount: res.length, showRequestMoreUseSearch: showRequestMoreUseSearch, type: "Radio" }) })) })] }));
627
628
  };
628
629
 
630
+ /**
631
+ * Recursively gets all descendant keys for a given hierarchical option, including the option itself.
632
+ *
633
+ * @param option The starting hierarchical filter option.
634
+ * @returns An array of string keys representing the option and all its descendants.
635
+ */
636
+ const getAllDescendantKeys = (option) => {
637
+ let keys = [option.key];
638
+ if (option.children) {
639
+ option.children.forEach(child => {
640
+ keys = keys.concat(getAllDescendantKeys(child));
641
+ });
642
+ }
643
+ return keys;
644
+ };
645
+ /**
646
+ * Recursively filters hierarchical options based on a search term.
647
+ * Keeps an option if its label matches or if any descendant matches.
648
+ */
649
+ const filterHierarchicalOptions = (options, searchTerm, parentMatches = false // Track if the parent already matched
650
+ ) => {
651
+ if (!searchTerm) {
652
+ return options; // Return all options if search term is empty
653
+ }
654
+ const lowerCaseSearchTerm = searchTerm.toLowerCase();
655
+ return options.reduce((acc, option) => {
656
+ const labelMatches = option.label.toLowerCase().includes(lowerCaseSearchTerm);
657
+ const matches = labelMatches || parentMatches;
658
+ let filteredChildren = [];
659
+ if (option.children && option.children.length > 0) {
660
+ // Pass `matches` as `parentMatches` for children
661
+ filteredChildren = filterHierarchicalOptions(option.children, searchTerm, matches);
662
+ }
663
+ // Include the option if:
664
+ // 1. Its label matches OR
665
+ // 2. Its parent matched (meaning it's part of a matched branch) OR
666
+ // 3. It has children that matched
667
+ if (matches || filteredChildren.length > 0) {
668
+ acc.push({
669
+ ...option,
670
+ // Only include children if they (or their descendants) matched
671
+ children: filteredChildren.length > 0 ? filteredChildren : undefined,
672
+ });
673
+ }
674
+ return acc;
675
+ }, []);
676
+ };
677
+
678
+ /**
679
+ * Custom hook to manage the state and interactions of a single hierarchical option.
680
+ * Calculates checked/indeterminate state and provides a handler for value changes,
681
+ * including cascading logic.
682
+ */
683
+ const useHierarchicalOptionState = ({ options, option, selectedValues, cascadeSelection, optionsMap, setValue, filterDefinition, logEvent, }) => {
684
+ const allDescendantKeys = useMemo(() => (cascadeSelection ? getAllDescendantKeys(option) : [option.key]), [option, cascadeSelection]);
685
+ const hasChildren = useMemo(() => (option.children ?? []).length > 0, [option.children]);
686
+ const parentMap = useMemo(() => {
687
+ const map = new Map();
688
+ const traverse = (opts, parentKey) => {
689
+ opts.forEach(opt => {
690
+ if (parentKey) {
691
+ map.set(opt.key, parentKey);
692
+ }
693
+ if (opt.children && Array.isArray(opt.children) && opt.children.length > 0) {
694
+ traverse(opt.children, opt.key);
695
+ }
696
+ });
697
+ };
698
+ traverse(options);
699
+ return map;
700
+ }, [options]);
701
+ const { isChecked, isIndeterminate } = useMemo(() => {
702
+ if (!cascadeSelection || !hasChildren) {
703
+ const selfChecked = selectedValues.some(v => v.value === option.key);
704
+ return { isChecked: selfChecked, isIndeterminate: false };
705
+ }
706
+ const selectedKeySet = new Set(selectedValues.map(v => v.value));
707
+ let allChildrenChecked = true;
708
+ let someChildrenChecked = false;
709
+ const descendantKeysOnly = allDescendantKeys.filter(k => k !== option.key);
710
+ if (descendantKeysOnly.length === 0) {
711
+ return { isChecked: selectedKeySet.has(option.key), isIndeterminate: false };
712
+ }
713
+ for (const key of descendantKeysOnly) {
714
+ if (selectedKeySet.has(key)) {
715
+ someChildrenChecked = true;
716
+ }
717
+ else {
718
+ allChildrenChecked = false;
719
+ }
720
+ }
721
+ const checked = allChildrenChecked;
722
+ const indeterminate = someChildrenChecked && !allChildrenChecked;
723
+ return { isChecked: checked, isIndeterminate: indeterminate };
724
+ }, [selectedValues, option.key, allDescendantKeys, cascadeSelection, hasChildren]);
725
+ const handleSelect = useCallback(() => {
726
+ logEvent("Filters Applied - V2", {
727
+ type: `${capitalize(filterDefinition.filterKey)}Filter`,
728
+ value: option.key,
729
+ });
730
+ setValue(prev => {
731
+ let currentSelectedValues = prev ? [...prev] : [];
732
+ const selectedKeySet = new Set(currentSelectedValues.map(v => v.value));
733
+ if (!cascadeSelection) {
734
+ // Non-cascading: Toggle only the current option
735
+ if (selectedKeySet.has(option.key)) {
736
+ currentSelectedValues = currentSelectedValues.filter(f => f.value !== option.key);
737
+ }
738
+ else {
739
+ currentSelectedValues.push({ name: option.label, value: option.key });
740
+ }
741
+ }
742
+ else {
743
+ const keysToToggle = getAllDescendantKeys(option);
744
+ const shouldBeChecked = !isChecked;
745
+ if (!shouldBeChecked) {
746
+ const keysToRemove = new Set(getAllDescendantKeys(option));
747
+ let currentParentKey = parentMap.get(option.key);
748
+ while (currentParentKey) {
749
+ keysToRemove.add(currentParentKey);
750
+ currentParentKey = parentMap.get(currentParentKey);
751
+ }
752
+ currentSelectedValues = currentSelectedValues.filter(f => !keysToRemove.has(f.value));
753
+ }
754
+ else {
755
+ // Add the option and all descendants if they are not already selected
756
+ const itemsToAdd = [];
757
+ keysToToggle.forEach(key => {
758
+ if (!selectedKeySet.has(key)) {
759
+ const optionDetails = optionsMap.get(key);
760
+ // Ensure we have details before adding, fallback to key if not found (shouldn't happen ideally)
761
+ itemsToAdd.push({ name: optionDetails?.label ?? key, value: key });
762
+ }
763
+ });
764
+ currentSelectedValues.push(...itemsToAdd);
765
+ }
766
+ }
767
+ return currentSelectedValues;
768
+ });
769
+ }, [cascadeSelection, filterDefinition.filterKey, logEvent, setValue, isChecked, option, optionsMap, parentMap]);
770
+ return {
771
+ isChecked,
772
+ isIndeterminate,
773
+ handleSelect,
774
+ };
775
+ };
776
+
777
+ /**
778
+ * Renders a single option and its children recursively.
779
+ * Uses the `useHierarchicalOptionState` hook for state management and interaction logic.
780
+ */
781
+ const RenderHierarchicalOption = ({ option, filterDefinition, filterBarActions, setValue, logEvent, selectedValues, cascadeSelection, optionsMap, }) => {
782
+ const level = option.level ?? 0;
783
+ const { isChecked, isIndeterminate, handleSelect } = useHierarchicalOptionState({
784
+ option,
785
+ selectedValues,
786
+ cascadeSelection,
787
+ optionsMap,
788
+ setValue,
789
+ options: Array.from(optionsMap.values()),
790
+ filterDefinition,
791
+ logEvent,
792
+ });
793
+ return (jsxs(Fragment$1, { children: [jsx("div", { className: cvaOptionItem({ indentationLevel: level > 6 ? 6 : level }), children: jsx(CheckBoxFilterItem, { checked: isChecked, className: "rounded-none", dataTestId: `hierarchical-filter-check-box-${option.key}`, indeterminate: isIndeterminate, itemCount: option.count, label: option.label, name: `hierarchical-filter-check-box-${option.key}`, onChange: handleSelect }) }), option.children?.map(child => (jsx(RenderHierarchicalOption, { cascadeSelection: cascadeSelection, filterBarActions: filterBarActions, filterDefinition: filterDefinition, logEvent: logEvent, option: child, optionsMap: optionsMap, selectedValues: selectedValues, setValue: setValue }, child.key)))] }, option.key));
794
+ };
795
+ const cvaOptionItem = cvaMerge(["m-1"], {
796
+ variants: {
797
+ indentationLevel: {
798
+ 0: ["pl-0"],
799
+ 1: ["pl-6"],
800
+ 2: ["pl-12"],
801
+ 3: ["pl-18"],
802
+ 4: ["pl-24"],
803
+ 5: ["pl-30"],
804
+ 6: ["pl-36"],
805
+ },
806
+ },
807
+ defaultVariants: {
808
+ indentationLevel: 0,
809
+ },
810
+ });
811
+
812
+ /**
813
+ * `HierarchicalCheckboxFilter` is a React component for handling hierarchical checkbox filters.
814
+ * It expects options to be pre-processed into a hierarchical structure.
815
+ *
816
+ * Supports optional cascading selection behavior via the `cascadeSelection` prop.
817
+ */
818
+ const HierarchicalCheckboxFilter = ({ filterDefinition, filterBarActions, options, loading, setValue, value, cascadeSelection = false, customSearch, }) => {
819
+ const { logEvent } = useAnalytics(FilterEvents);
820
+ const selectedValues = value ?? [];
821
+ // Local state for client-side search (used if customSearch is not provided)
822
+ const [localSearchText, setLocalSearchText] = useState("");
823
+ // Determine if we are using server-side search (customSearch) or client-side search
824
+ const isServerSearch = !!customSearch;
825
+ const currentSearchText = isServerSearch ? customSearch.value : localSearchText;
826
+ const currentSetSearchText = isServerSearch ? customSearch.onChange : setLocalSearchText;
827
+ const searchHeaderProps = {
828
+ searchEnabled: true,
829
+ searchProps: { value: currentSearchText, onChange: currentSetSearchText },
830
+ };
831
+ const optionsMap = useMemo(() => {
832
+ const map = new Map();
833
+ const traverse = (opts) => {
834
+ opts.forEach(opt => {
835
+ map.set(opt.key, opt);
836
+ if (opt.children) {
837
+ traverse(opt.children);
838
+ }
839
+ });
840
+ };
841
+ traverse(options);
842
+ return map;
843
+ }, [options]);
844
+ // Filter options based on search term, preserving hierarchy
845
+ const filteredOptions = useMemo(() => {
846
+ // Only apply client-side filtering if not doing server-side search
847
+ if (isServerSearch) {
848
+ return options; // Assume server provides filtered options
849
+ }
850
+ return filterHierarchicalOptions(options, localSearchText);
851
+ }, [options, isServerSearch, localSearchText]);
852
+ return (jsxs(Fragment, { children: [jsx(FilterHeader, { ...filterDefinition, ...filterBarActions, filterHasChanges: filterBarActions.appliedFilterKeys().includes(filterDefinition.filterKey), loading: loading, ...searchHeaderProps }), jsx("div", { className: "max-h-60 overflow-y-auto", children: jsx(FilterResults, { loading: loading, results: filteredOptions, children: results => (jsx(Fragment, { children: results.map(option => (jsx(RenderHierarchicalOption, { cascadeSelection: cascadeSelection, filterBarActions: filterBarActions, filterDefinition: filterDefinition, logEvent: logEvent, option: option, optionsMap: optionsMap, selectedValues: selectedValues, setValue: setValue }, option.key))) })) }) })] }));
853
+ };
854
+
629
855
  /**
630
856
  * Custom React hook for grouping and filtering a list of filters.
631
857
  *
@@ -1698,4 +1924,4 @@ const mergeFilters = (filterBarDefinition, extraFilters) => {
1698
1924
  */
1699
1925
  setupLibraryTranslations();
1700
1926
 
1701
- export { DefaultCheckboxFilter, DefaultDateRangeFilter, DefaultMinMaxFilter, DefaultRadioFilter, DynamicFilterList, FilterBar, FilterEvents, FilterHeader, FilterResults, StarredFilters, areaFilterGeoJsonGeometrySchema, isAreaFilterValue, isArrayFilterValue, isBooleanValue, isDateRangeValue, isMinMaxFilterValue, isStringArrayFilterValue, isValueName, isValueNameArray, mergeFilters, mockFilterBar, toggleFilterValue, useFilterBar, useFilterBarAsync, useSearchParamAsFilter, validateFilter };
1927
+ export { DefaultCheckboxFilter, DefaultDateRangeFilter, DefaultMinMaxFilter, DefaultRadioFilter, DynamicFilterList, FilterBar, FilterEvents, FilterHeader, FilterResults, HierarchicalCheckboxFilter, StarredFilters, areaFilterGeoJsonGeometrySchema, isAreaFilterValue, isArrayFilterValue, isBooleanValue, isDateRangeValue, isMinMaxFilterValue, isStringArrayFilterValue, isValueName, isValueNameArray, mergeFilters, mockFilterBar, toggleFilterValue, useFilterBar, useFilterBarAsync, useSearchParamAsFilter, validateFilter };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trackunit/filters-filter-bar",
3
- "version": "1.3.89",
3
+ "version": "1.3.92",
4
4
  "repository": "https://github.com/Trackunit/manager",
5
5
  "license": "SEE LICENSE IN LICENSE.txt",
6
6
  "engines": {
@@ -14,15 +14,16 @@
14
14
  "tailwind-merge": "^2.0.0",
15
15
  "string-ts": "^2.0.0",
16
16
  "zod": "3.22.4",
17
- "@trackunit/react-components": "1.4.64",
18
- "@trackunit/react-core-hooks": "1.3.54",
19
- "@trackunit/react-filter-components": "1.3.74",
20
- "@trackunit/react-date-and-time-components": "1.3.72",
21
- "@trackunit/shared-utils": "1.5.51",
22
- "@trackunit/react-form-components": "1.3.74",
23
- "@trackunit/react-core-contexts-api": "1.4.52",
24
- "@trackunit/geo-json-utils": "1.3.52",
25
- "@trackunit/i18n-library-translation": "1.3.56"
17
+ "@trackunit/react-components": "1.4.66",
18
+ "@trackunit/react-core-hooks": "1.3.56",
19
+ "@trackunit/react-filter-components": "1.3.76",
20
+ "@trackunit/react-date-and-time-components": "1.3.74",
21
+ "@trackunit/shared-utils": "1.5.53",
22
+ "@trackunit/react-form-components": "1.3.76",
23
+ "@trackunit/react-core-contexts-api": "1.4.54",
24
+ "@trackunit/geo-json-utils": "1.3.54",
25
+ "@trackunit/i18n-library-translation": "1.3.58",
26
+ "@trackunit/css-class-variance-utilities": "1.3.53"
26
27
  },
27
28
  "module": "./index.esm.js",
28
29
  "main": "./index.cjs.js",
@@ -18,6 +18,20 @@ export interface FilterOption {
18
18
  */
19
19
  prefix?: ReactNode;
20
20
  }
21
+ /**
22
+ * Represents a filter option within a hierarchical structure.
23
+ */
24
+ export interface HierarchicalFilterOption extends FilterOption {
25
+ /**
26
+ * Child options for this node in the hierarchy.
27
+ */
28
+ children?: HierarchicalFilterOption[];
29
+ /**
30
+ * The nesting level of this option (0 for root).
31
+ * Added during hierarchy processing.
32
+ */
33
+ level?: number;
34
+ }
21
35
  export interface DefaultFilterProps<TReturnType> extends FilterViewProps<TReturnType> {
22
36
  /**
23
37
  * An array of filter options to be displayed.
@@ -0,0 +1,58 @@
1
+ import { FilterDefinition, FilterStateSetterCallback, ValueName } from "../../types/FilterTypes";
2
+ import { DefaultFilterProps, HierarchicalFilterOption } from "../DefaultFilterTypes";
3
+ /**
4
+ * Props for the HierarchicalCheckboxFilter component.
5
+ * Extends base filter props but requires hierarchical options and ValueName[] type.
6
+ */
7
+ export interface HierarchicalFilterProps extends Omit<DefaultFilterProps<ValueName[]>, "options" | "customSearch" | "showRequestMoreUseSearch" | "showUndefinedOptionWithCountAtBottom" | "totalRecords"> {
8
+ /**
9
+ * An array of hierarchical filter options to be displayed.
10
+ * The structure (parent-child relationships and levels) should be pre-processed.
11
+ */
12
+ options: HierarchicalFilterOption[];
13
+ /**
14
+ * The filter definition, constrained to the 'valueNameArray' type for this component.
15
+ */
16
+ filterDefinition: FilterDefinition & {
17
+ type: "valueNameArray";
18
+ };
19
+ /**
20
+ * Callback function to update the filter's value state.
21
+ */
22
+ setValue: FilterStateSetterCallback<ValueName[]>;
23
+ /**
24
+ * Enable cascading selection behavior.
25
+ * - Checking/unchecking a parent affects all descendants.
26
+ * - Parent shows indeterminate state if only some children are checked.
27
+ *
28
+ * @default false
29
+ */
30
+ cascadeSelection?: boolean;
31
+ /**
32
+ * Optional prop to enable server-side searching.
33
+ * - If provided, the component assumes filtering is handled externally (server-side).
34
+ * It will use the provided `value` for the search input and call `onChange` when it changes.
35
+ * Client-side filtering using `filterHierarchicalOptions` will be skipped.
36
+ * - If not provided, the component will handle filtering client-side using internal state
37
+ * and the `filterHierarchicalOptions` function.
38
+ */
39
+ customSearch?: {
40
+ /**
41
+ * Current search string
42
+ */
43
+ value: string;
44
+ /**
45
+ * Callback when search string changed
46
+ *
47
+ * @param value Updated search string
48
+ */
49
+ onChange: (value: string) => void;
50
+ };
51
+ }
52
+ /**
53
+ * `HierarchicalCheckboxFilter` is a React component for handling hierarchical checkbox filters.
54
+ * It expects options to be pre-processed into a hierarchical structure.
55
+ *
56
+ * Supports optional cascading selection behavior via the `cascadeSelection` prop.
57
+ */
58
+ export declare const HierarchicalCheckboxFilter: ({ filterDefinition, filterBarActions, options, loading, setValue, value, cascadeSelection, customSearch, }: HierarchicalFilterProps) => import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,13 @@
1
+ import { HierarchicalFilterOption } from "../DefaultFilterTypes";
2
+ /**
3
+ * Recursively gets all descendant keys for a given hierarchical option, including the option itself.
4
+ *
5
+ * @param option The starting hierarchical filter option.
6
+ * @returns An array of string keys representing the option and all its descendants.
7
+ */
8
+ export declare const getAllDescendantKeys: (option: HierarchicalFilterOption) => string[];
9
+ /**
10
+ * Recursively filters hierarchical options based on a search term.
11
+ * Keeps an option if its label matches or if any descendant matches.
12
+ */
13
+ export declare const filterHierarchicalOptions: (options: HierarchicalFilterOption[], searchTerm: string, parentMatches?: boolean) => HierarchicalFilterOption[];
@@ -0,0 +1,20 @@
1
+ import { useAnalytics } from "@trackunit/react-core-hooks";
2
+ import { ValueName } from "../../types/FilterTypes";
3
+ import { HierarchicalFilterOption } from "../DefaultFilterTypes";
4
+ import { HierarchicalFilterProps } from "./HierarchicalCheckboxFilter";
5
+ interface RenderHierarchicalOptionProps {
6
+ option: HierarchicalFilterOption;
7
+ filterDefinition: HierarchicalFilterProps["filterDefinition"];
8
+ filterBarActions: HierarchicalFilterProps["filterBarActions"];
9
+ setValue: HierarchicalFilterProps["setValue"];
10
+ logEvent: ReturnType<typeof useAnalytics>["logEvent"];
11
+ selectedValues: ValueName[];
12
+ cascadeSelection: boolean;
13
+ optionsMap: Map<string, HierarchicalFilterOption>;
14
+ }
15
+ /**
16
+ * Renders a single option and its children recursively.
17
+ * Uses the `useHierarchicalOptionState` hook for state management and interaction logic.
18
+ */
19
+ export declare const RenderHierarchicalOption: ({ option, filterDefinition, filterBarActions, setValue, logEvent, selectedValues, cascadeSelection, optionsMap, }: RenderHierarchicalOptionProps) => import("react/jsx-runtime").JSX.Element;
20
+ export {};
@@ -0,0 +1,25 @@
1
+ import { useAnalytics } from "@trackunit/react-core-hooks";
2
+ import { ValueName } from "../../types/FilterTypes";
3
+ import { HierarchicalFilterOption } from "../DefaultFilterTypes";
4
+ import { HierarchicalFilterProps } from "./HierarchicalCheckboxFilter";
5
+ interface UseHierarchicalOptionStateProps {
6
+ options: HierarchicalFilterOption[];
7
+ option: HierarchicalFilterOption;
8
+ selectedValues: ValueName[];
9
+ cascadeSelection: boolean;
10
+ optionsMap: Map<string, HierarchicalFilterOption>;
11
+ setValue: HierarchicalFilterProps["setValue"];
12
+ filterDefinition: HierarchicalFilterProps["filterDefinition"];
13
+ logEvent: ReturnType<typeof useAnalytics>["logEvent"];
14
+ }
15
+ /**
16
+ * Custom hook to manage the state and interactions of a single hierarchical option.
17
+ * Calculates checked/indeterminate state and provides a handler for value changes,
18
+ * including cascading logic.
19
+ */
20
+ export declare const useHierarchicalOptionState: ({ options, option, selectedValues, cascadeSelection, optionsMap, setValue, filterDefinition, logEvent, }: UseHierarchicalOptionStateProps) => {
21
+ isChecked: boolean;
22
+ isIndeterminate: boolean;
23
+ handleSelect: () => void;
24
+ };
25
+ export {};
@@ -6,4 +6,5 @@ export * from "./DefaultRadioFilter";
6
6
  export * from "./DynamicFilterList";
7
7
  export * from "./FilterHeader";
8
8
  export * from "./FilterResults";
9
+ export * from "./HierarchicalCheckboxFilter/HierarchicalCheckboxFilter";
9
10
  export * from "./StarredFilters";