diff --git a/ui/src/components/CheckboxTree/CheckboxSubTree.tsx b/ui/src/components/CheckboxTree/CheckboxSubTree.tsx new file mode 100644 index 000000000..5cb8db0e2 --- /dev/null +++ b/ui/src/components/CheckboxTree/CheckboxSubTree.tsx @@ -0,0 +1,109 @@ +import React, { useMemo, useState } from 'react'; +import CheckboxRowWrapper from './CheckboxTreeRowWrapper'; +import { getCheckedCheckboxesCount, GroupWithRows, ValueByField } from './CheckboxTree.utils'; +import { + CheckboxContainer, + CheckboxWrapper, + Description, + GroupLabel, + RowContainer, + StyledCollapsiblePanel, +} from './StyledComponent'; + +interface CheckboxSubTreeProps { + group: GroupWithRows; + values: ValueByField; + handleRowChange: (newValue: { field: string; checkbox: boolean; text?: string }) => void; + disabled?: boolean; + handleParentCheckboxTree: (groupLabel: string, newCheckboxValue: boolean) => void; +} + +const CheckboxSubTree: React.FC = ({ + group, + values, + handleRowChange, + disabled, + handleParentCheckboxTree, +}) => { + const [isExpanded, setIsExpanded] = useState(true); + + const isParentChecked = useMemo( + () => group.rows.every((row) => values.get(row.field)?.checkbox), + [group.rows, values] + ); + + const isIndeterminate = useMemo( + () => group.rows.some((row) => values.get(row.field)?.checkbox) && !isParentChecked, + [group.rows, values, isParentChecked] + ); + + const checkedCheckboxesCount = useMemo( + () => getCheckedCheckboxesCount(group, values), + [group, values] + ); + + const toggleCollapse = () => setIsExpanded((prev) => !prev); + + const ParentCheckbox = ( + + { + const inputElement = el as HTMLInputElement | null; + if (inputElement) { + inputElement.indeterminate = isIndeterminate; + } + }} + onChange={() => handleParentCheckboxTree(group.label, !isParentChecked)} + disabled={disabled} + /> + {group.label} + + ); + + const childRows = ( + + {group.rows.map((row) => ( + + ))} + + ); + + const description = ( + + {checkedCheckboxesCount} of {group.fields.length} + + ); + + return ( + + {group.options?.isExpandable ? ( + + {childRows} + + ) : ( + <> + + {ParentCheckbox} + {description} + + {childRows} + + )} + + ); +}; + +export default CheckboxSubTree; diff --git a/ui/src/components/CheckboxTree/CheckboxTree.tsx b/ui/src/components/CheckboxTree/CheckboxTree.tsx new file mode 100644 index 000000000..9c4cfc9f1 --- /dev/null +++ b/ui/src/components/CheckboxTree/CheckboxTree.tsx @@ -0,0 +1,173 @@ +import React, { useEffect, useState, useCallback } from 'react'; +import ColumnLayout from '@splunk/react-ui/ColumnLayout'; +import Button from '@splunk/react-ui/Button'; +import Search from '@splunk/react-ui/Search'; +import { StyledColumnLayout } from './StyledComponent'; +import { + getDefaultValues, + getFlattenRowsWithGroups, + getNewCheckboxValues, + isGroupWithRows, +} from './CheckboxTree.utils'; +import CheckboxSubTree from './CheckboxSubTree'; +import CheckboxRowWrapper from './CheckboxTreeRowWrapper'; +import { MODE_CREATE } from '../../constants/modes'; +import { CheckboxTreeProps, ValueByField } from './types'; +import { packValue, parseValue } from './utils'; + +type SearchChangeData = { + value: string; +}; + +function CheckboxTree(props: CheckboxTreeProps) { + const { field, handleChange, controlOptions, disabled } = props; + const flattenedRowsWithGroups = getFlattenRowsWithGroups(controlOptions); + const shouldUseDefaultValue = + props.mode === MODE_CREATE && (props.value === null || props.value === undefined); + const initialValues = shouldUseDefaultValue + ? getDefaultValues(controlOptions.rows) + : parseValue(props.value); + + const [values, setValues] = useState(initialValues); + const [searchForCheckBoxValue, setSearchForCheckBoxValue] = useState(''); + + // Propagate default values on mount if applicable + useEffect(() => { + if (shouldUseDefaultValue) { + handleChange(field, packValue(initialValues), 'CheckboxTree'); + } + }, [field, handleChange, shouldUseDefaultValue, initialValues]); + + const handleRowChange = useCallback( + (newValue: { field: string; checkbox: boolean; text?: string }) => { + setValues((prevValues: ValueByField) => { + const updatedValues = getNewCheckboxValues(prevValues, newValue); + handleChange(field, packValue(updatedValues), 'CheckboxTree'); + return updatedValues; + }); + }, + [field, handleChange] + ); + + const handleParentCheckboxTree = useCallback( + (groupLabel: string, newCheckboxValue: boolean) => { + if (!controlOptions?.groups) { + return; + } + + const group = controlOptions.groups.find((g) => g.label === groupLabel); + if (!group) { + return; + } + + setValues((prevValues) => { + const updatedValues = new Map(prevValues); + group.fields.forEach((item) => { + updatedValues.set(item, { checkbox: newCheckboxValue }); + }); + handleChange(field, packValue(updatedValues), 'CheckboxTree'); + return updatedValues; + }); + }, + [controlOptions, field, handleChange] + ); + + const handleCheckboxToggleAll = useCallback( + (newCheckboxValue: boolean) => { + setValues((prevValues) => { + const updatedValues = new Map(prevValues); + controlOptions.rows.forEach((row) => { + updatedValues.set(row.field, { checkbox: newCheckboxValue }); + }); + handleChange(field, packValue(updatedValues), 'CheckboxTree'); + return updatedValues; + }); + }, + [controlOptions.rows, field, handleChange] + ); + + const handleSearchChange = useCallback( + (e: React.SyntheticEvent, { value: searchValue }: SearchChangeData) => { + setSearchForCheckBoxValue(searchValue); + }, + [] + ); + + const filterRows = useCallback(() => { + const searchValueLower = searchForCheckBoxValue.toLowerCase(); + + return flattenedRowsWithGroups + .flatMap((row) => { + if (isGroupWithRows(row)) { + const groupMatches = row.label.toLowerCase().includes(searchValueLower); + const filteredRows = groupMatches + ? row.rows + : row.rows.filter((childRow) => + childRow.checkbox?.label?.toLowerCase().includes(searchValueLower) + ); + + return groupMatches || filteredRows.length > 0 + ? { ...row, rows: filteredRows } + : []; + } + + const rowMatches = row.checkbox?.label?.toLowerCase().includes(searchValueLower); + return rowMatches ? row : null; + }) + .filter(Boolean); + }, [flattenedRowsWithGroups, searchForCheckBoxValue]); + + const filteredRows = filterRows(); + + return ( + <> + + + {filteredRows.map((row) => + row && isGroupWithRows(row) ? ( + + + + ) : ( + row && ( + + + + ) + ) + )} + + +
+
+ + ); +} + +export default CheckboxTree; diff --git a/ui/src/components/CheckboxTree/CheckboxTree.utils.ts b/ui/src/components/CheckboxTree/CheckboxTree.utils.ts new file mode 100644 index 000000000..f9d066266 --- /dev/null +++ b/ui/src/components/CheckboxTree/CheckboxTree.utils.ts @@ -0,0 +1,73 @@ +import { CheckboxTreeProps, Field, GroupWithRows, Row, Value, ValueByField } from './types'; + +export function isGroupWithRows(item: GroupWithRows | Row): item is GroupWithRows { + return 'label' in item; +} + +export function getFlattenRowsWithGroups({ groups, rows }: CheckboxTreeProps['controlOptions']) { + const flattenRowsMixedWithGroups: Array = []; + + rows.forEach((row) => { + const groupForThisRow = groups?.find((group) => group.fields.includes(row.field)); + if (groupForThisRow) { + const addedGroup = flattenRowsMixedWithGroups.find( + (item): item is GroupWithRows => + isGroupWithRows(item) && item.label === groupForThisRow.label + ); + const groupToAdd = addedGroup || { + ...groupForThisRow, + rows: [], + }; + groupToAdd.rows.push(row); + if (!addedGroup) { + flattenRowsMixedWithGroups.push(groupToAdd); + } + return; + } + flattenRowsMixedWithGroups.push(row); + }); + + return flattenRowsMixedWithGroups; +} + +export function getNewCheckboxValues( + values: ValueByField, + newValue: { + field: string; + checkbox: boolean; + } +) { + const newValues = new Map(values); + newValues.set(newValue.field, { + checkbox: newValue.checkbox, + }); + + return newValues; +} + +export function getCheckedCheckboxesCount(group: GroupWithRows, values: ValueByField) { + let checkedCheckboxesCount = 0; + group.rows.forEach((row) => { + if (values.get(row.field)?.checkbox) { + checkedCheckboxesCount += 1; + } + }); + return checkedCheckboxesCount; +} + +export function getDefaultValues(rows: Row[]): ValueByField { + const resultMap = new Map(); + + rows.forEach((row) => { + if (!isGroupWithRows(row)) { + const checkboxDefaultValue = row.checkbox?.defaultValue; + if (typeof checkboxDefaultValue === 'boolean') { + resultMap.set(row.field, { + checkbox: checkboxDefaultValue, + }); + } + } + }); + + return resultMap; +} diff --git a/ui/src/components/CheckboxTree/CheckboxTreeRow.tsx b/ui/src/components/CheckboxTree/CheckboxTreeRow.tsx new file mode 100644 index 000000000..28ad92628 --- /dev/null +++ b/ui/src/components/CheckboxTree/CheckboxTreeRow.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import { StyledRow, StyledSwitch } from './StyledComponent'; + +interface CheckboxRowProps { + field: string; + label: string; + checkbox: boolean; + disabled?: boolean; + handleChange: (value: { field: string; checkbox: boolean }) => void; +} + +function CheckboxRow(props: CheckboxRowProps) { + const { field, label, checkbox, disabled, handleChange } = props; + const handleChangeCheckbox = ( + event: React.MouseEvent, + data: { selected: boolean } + ) => { + handleChange({ field, checkbox: !data.selected }); + }; + + return ( + + + {label} + + + ); +} + +export default CheckboxRow; diff --git a/ui/src/components/CheckboxTree/CheckboxTreeRowWrapper.tsx b/ui/src/components/CheckboxTree/CheckboxTreeRowWrapper.tsx new file mode 100644 index 000000000..7e604a2fb --- /dev/null +++ b/ui/src/components/CheckboxTree/CheckboxTreeRowWrapper.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import CheckboxRow from './CheckboxTreeRow'; +import { Row, ValueByField } from './types'; + +function CheckboxRowWrapper({ + row, + values, + handleRowChange, + disabled, +}: { + row: Row; + values: ValueByField; + handleRowChange: (newValue: { field: string; checkbox: boolean; text?: string }) => void; + disabled?: boolean; +}) { + const valueForField = values.get(row.field); + return ( + + ); +} +export default CheckboxRowWrapper; diff --git a/ui/src/components/CheckboxTree/StyledComponent.tsx b/ui/src/components/CheckboxTree/StyledComponent.tsx new file mode 100644 index 000000000..546972d83 --- /dev/null +++ b/ui/src/components/CheckboxTree/StyledComponent.tsx @@ -0,0 +1,72 @@ +import styled, { css } from 'styled-components'; +import ColumnLayout from '@splunk/react-ui/ColumnLayout'; +import CollapsiblePanel from '@splunk/react-ui/CollapsiblePanel'; +import { variables } from '@splunk/themes'; +import Switch from '@splunk/react-ui/Switch'; + +export const FixedCheckboxRowWidth = css` + width: 320px; +`; + +export const StyledColumnLayout = styled(ColumnLayout)` + ${FixedCheckboxRowWidth} +`; + +export const CheckboxContainer = styled.div` + display: flex; + flex-direction: column; + flex: 1; +`; + +export const StyledCollapsiblePanel = styled(CollapsiblePanel)` + & > *:not(:last-child) { + background-color: ${variables.neutral300}; + font-size: 14px; + margin-bottom: ${variables.spacingSmall}; + } +`; + +export const RowContainer = styled.div` + & > *:not(:last-child) { + margin-bottom: ${variables.spacingSmall}; + } + margin: 0 0 ${variables.spacingSmall} 28px; +`; + +export const GroupLabel = styled.div` + display: flex; + justify-content: space-between; + padding: 6px ${variables.spacingSmall}; + background-color: ${variables.neutral300}; + font-size: 14px; + margin: ${variables.spacingSmall} 0; +`; + +export const Description = styled.span` + padding-right: ${variables.spacingLarge}; + margin-left: ${variables.spacingSmall}; + font-size: 12px; + display: flex; + justify-content: end; +`; + +export const CheckboxWrapper = styled.div` + display: flex; + align-items: center; + input { + margin-right: ${variables.spacingSmall}; + } +`; + +export const StyledSwitch = styled(Switch)` + padding: 0 3px; + flex: min-content; + align-items: center; +`; + +export const StyledRow = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + padding-top: 2px; +`; diff --git a/ui/src/components/CheckboxTree/types.ts b/ui/src/components/CheckboxTree/types.ts new file mode 100644 index 000000000..bbb6b151d --- /dev/null +++ b/ui/src/components/CheckboxTree/types.ts @@ -0,0 +1,45 @@ +import { Mode } from '../../constants/modes'; + +export type Field = string; +export type Value = { + checkbox: boolean; + inputValue?: number; + error?: string; +}; + +export type ValueByField = Map; + +export interface Group { + label: string; + fields: string[]; + options?: { + isExpandable?: boolean; + expand?: boolean; + }; +} + +export interface Row { + field: string; + checkbox?: { + label?: string; + defaultValue?: boolean; + }; +} + +export type GroupWithRows = Group & { rows: Row[] }; + +export interface CheckboxTreeProps { + field: string; + value?: string; + controlOptions: { + groups?: Group[]; + rows: Row[]; + }; + mode: Mode; + addCustomValidator?: ( + field: string, + validator: (submittedField: string, submittedValue: string) => void + ) => void; + handleChange: (field: string, value: string, componentType?: 'CheckboxTree') => void; + disabled?: boolean; +} diff --git a/ui/src/components/CheckboxTree/utils.ts b/ui/src/components/CheckboxTree/utils.ts new file mode 100644 index 000000000..90d41d279 --- /dev/null +++ b/ui/src/components/CheckboxTree/utils.ts @@ -0,0 +1,32 @@ +import { ValueByField, Field, Value } from './types'; + +export function parseValue(collection?: string): ValueByField { + const resultMap = new Map(); + + if (!collection) { + return resultMap; + } + + const splitValues = collection.split(','); + splitValues.forEach((rawValue) => { + const [field, inputValue] = rawValue.trim().split('/'); + const parsedInputValue = inputValue === '' ? undefined : Number(inputValue); + if (!field || Number.isNaN(parsedInputValue)) { + throw new Error(`Value is not parsable: ${collection}`); + } + + resultMap.set(field, { + checkbox: true, + inputValue: parsedInputValue, + }); + }); + + return resultMap; +} + +export function packValue(map: ValueByField): string { + return Array.from(map.entries()) + .filter(([, value]) => value.checkbox) + .map(([field]) => `${field}`) + .join(','); +}