Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: introduce the new checkboxtree component #1495

Draft
wants to merge 4 commits into
base: develop
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 109 additions & 0 deletions ui/src/components/CheckboxTree/CheckboxSubTree.tsx
Original file line number Diff line number Diff line change
@@ -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<CheckboxSubTreeProps> = ({
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 = (
<CheckboxWrapper>
<input
type="checkbox"
checked={isParentChecked}
ref={(el) => {
const inputElement = el as HTMLInputElement | null;
if (inputElement) {
inputElement.indeterminate = isIndeterminate;
}
}}
onChange={() => handleParentCheckboxTree(group.label, !isParentChecked)}
disabled={disabled}
/>
<span>{group.label}</span>
</CheckboxWrapper>
);

const childRows = (
<RowContainer>
{group.rows.map((row) => (
<CheckboxRowWrapper
key={`row_${row.field}`}
disabled={disabled}
row={row}
values={values}
handleRowChange={handleRowChange}
/>
))}
</RowContainer>
);

const description = (
<Description>
{checkedCheckboxesCount} of {group.fields.length}
</Description>
);

return (
<CheckboxContainer>
{group.options?.isExpandable ? (
<StyledCollapsiblePanel
open={isExpanded}
onChange={toggleCollapse}
title={ParentCheckbox}
actions={description}
>
{childRows}
</StyledCollapsiblePanel>
) : (
<>
<GroupLabel>
{ParentCheckbox}
{description}
</GroupLabel>
{childRows}
</>
)}
</CheckboxContainer>
);
};

export default CheckboxSubTree;
173 changes: 173 additions & 0 deletions ui/src/components/CheckboxTree/CheckboxTree.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<Search
style={{ width: '320px', marginBottom: '10px' }}
inline
onChange={handleSearchChange}
value={searchForCheckBoxValue}
/>
<StyledColumnLayout gutter={5}>
{filteredRows.map((row) =>
row && isGroupWithRows(row) ? (
<ColumnLayout.Row key={`group_${row.label}`}>
<CheckboxSubTree
group={row}
values={values}
handleRowChange={handleRowChange}
disabled={disabled}
handleParentCheckboxTree={handleParentCheckboxTree}
/>
</ColumnLayout.Row>
) : (
row && (
<ColumnLayout.Row key={`row_${row.field}`}>
<CheckboxRowWrapper
row={row}
values={values}
handleRowChange={handleRowChange}
disabled={disabled}
/>
</ColumnLayout.Row>
)
)
)}
<ColumnLayout.Row />
</StyledColumnLayout>
<div>
<Button
label="Select All"
appearance="pill"
onClick={() => handleCheckboxToggleAll(true)}
/>
<Button
label="Clear All"
appearance="pill"
onClick={() => handleCheckboxToggleAll(false)}
/>
</div>
</>
);
}

export default CheckboxTree;
73 changes: 73 additions & 0 deletions ui/src/components/CheckboxTree/CheckboxTree.utils.ts
Original file line number Diff line number Diff line change
@@ -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<GroupWithRows | Row> = [];

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<Field, Value>();

rows.forEach((row) => {
if (!isGroupWithRows(row)) {
const checkboxDefaultValue = row.checkbox?.defaultValue;
if (typeof checkboxDefaultValue === 'boolean') {
resultMap.set(row.field, {
checkbox: checkboxDefaultValue,
});
}
}
});

return resultMap;
}
37 changes: 37 additions & 0 deletions ui/src/components/CheckboxTree/CheckboxTreeRow.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLElement>,
data: { selected: boolean }
) => {
handleChange({ field, checkbox: !data.selected });
};

return (
<StyledRow>
<StyledSwitch
aria-label={`${label} checkbox`}
data-test-field={field}
selected={checkbox}
onClick={handleChangeCheckbox}
appearance="checkbox"
disabled={disabled}
>
{label}
</StyledSwitch>
</StyledRow>
);
}

export default CheckboxRow;
Loading
Loading