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

Feature/filter component #313

Merged
merged 1 commit into from
Mar 19, 2024
Merged
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { Button } from '@digdir/designsystemet-react';
import { PlusIcon } from '@navikt/aksel-icons';
import { useTranslation } from 'react-i18next';
import { Filter, FilterBarField } from '../FilterBar.tsx';
import { FilterList, FilterListItem } from '../FilterList';

import styles from './addFilterButton.module.css';

type AddFilterButtonProps = {
filterOptions: FilterBarField[];
filters: Filter[];
onListItemClick: (option: FilterBarField) => void;
onBtnClick: () => void;
isOpen: boolean;
disabled?: boolean;
};
export const AddFilterButton = ({
filterOptions,
onListItemClick,
disabled,
filters,
onBtnClick,
isOpen,
}: AddFilterButtonProps) => {
const { t } = useTranslation();
return (
<div>
<Button size="small" onClick={onBtnClick} disabled={disabled} variant="secondary" color="first">
<PlusIcon /> {t('filter_bar.add_filter')}
</Button>
{isOpen && (
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potentially refactor this into a <ButtonWithDropdown />-component? To extract the button, isOpen and dropdown functionality.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Vi må se hvor gjenbrukbart det kan bli på sikt, og behovene. Ser ingen verdi i å skille ut dette som egen komponent foreløpig.

<FilterList>
{filterOptions.map((option: FilterBarField) => {
const isUsed = !!filters.find((filter) => filter.fieldName === option.id);
return (
<FilterListItem
key={option.id}
disabled={isUsed}
onClick={() => {
onListItemClick(option);
}}
>
<div className={styles.addFilterButtonContent}>
{option.leftIcon}
<span className={styles.addFilterItemLabel}>{option.label}</span>
<span className={styles.addFilterItemCount}>{option.options.length}</span>
</div>
</FilterListItem>
);
})}
</FilterList>
)}
</div>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
.addFilterButtonContent {
display: flex;
align-items: center;
justify-content: space-between;
column-gap: 1em;
}

.addFilterItemLabel {
font-size: 1rem;
}

.addFilterItemCount {
padding: 0.5em;
font-size: 0.75rem;
font-weight: 600;
line-height: 1em;
border-radius: 50%;
height: 1em;
width: 1em;
display: inline-flex;
align-items: center;
justify-content: center;
background-color: rgb(243, 244, 246);
color: rgba(0,0,0);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

flisespikk, men hvorfor ikke bare skrive "black" og spare å kjøre en funksjon? :)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

det skal byttes uansett, koster ingenting

}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { AddFilterButton } from './AddFilterButton.tsx';
193 changes: 193 additions & 0 deletions packages/frontend-design-poc/src/components/FilterBar/FilterBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
import { useCallback, useState } from 'react';
import { AddFilterButton } from './AddFilterButton';
import { FilterButton } from './FilterButton';
import styles from './filterBar.module.css';

export type FieldOptionOperation = 'equals' | 'includes';

interface ValueFilter {
fieldName: string | ((item: Record<string, string | number | boolean>) => string);
operation: FieldOptionOperation;
value: string;
label: string;
}

export interface UnsetValueFilter {
fieldName: string;
operation: 'unset';
label: string;
value?: string | string[];
}

export type Filter = ValueFilter | UnsetValueFilter;

export interface FilterBarFieldOption {
id: string;
label: string;
operation: FieldOptionOperation;
value: string;
count: number;
}

export interface FilterBarField {
label: string;
unSelectedLabel: string;
id: string;
options: FilterBarFieldOption[];
chosenOptionId?: string | string[];
leftIcon?: React.ReactNode;
}

interface FilterBarProps {
fields: FilterBarField[];
onFilterChange: (filters: Filter[]) => void;
initialFilters?: Filter[];
}

type ListOpenTarget = 'none' | string | 'add_filter';

/**
* `FilterBar` is a component that renders a dynamic filter UI, allowing users to add, remove, and modify filters based on predefined field options. It supports both value-based filters and unset filters, where the former applies a specific condition (e.g., equals, includes) to a field, and the latter signifies the absence of a filter on that field.
*
* The component is designed to be flexible, accommodating a variety of filter types through its configuration props. It manages its own state for active filters and the visibility of filter option lists, providing a callback for when the active filters change, enabling parent components to react to updates in the filter state.
*
* Props:
* - `fields`: Array of `FilterBarField` objects that define the available filters and their options. Each field includes a label, an ID, options (each with a label, value, and count), and optionally a chosen option ID and a left icon.
* - `onFilterChange`: Function called with the current array of active `Filter` objects whenever the active filters change, allowing parent components to respond to filter changes.
* - `initialFilters`: (Optional) Array of `Filter` objects representing the initial state of active filters when the component mounts.
*
* The component renders a list of `FilterButton` components for each active filter, allowing users to remove filters or change their values. An `AddFilterButton` is also rendered, enabling the addition of new filters from the available `fields`.
*
* Usage:
* The `FilterBar` is intended to be used in applications requiring dynamic filtering capabilities, such as data tables or lists that need to be filtered based on various criteria. It is designed to be integrated into a larger UI, where it can be positioned as needed to provide filtering functionality.
*
* Example:
* ```
* <FilterBar
* fields={[
* {
* label: 'Country',
* unSelectedLabel: 'All Countries',
* id: 'country',
* options: [
* { id: 'us', label: 'United States', value: 'United States', count: 10, operation: 'equals' },
* { id: 'ca', label: 'Canada', value: 'Canada', count: 5, operation: 'equals' }
* ]
* },
* {
* label: 'Gender',
* unSelectedLabel: 'All Genders',
* id: 'gender',
* options: [
* { id: 'male', label: 'Male', value: 'Male', count: 15, operation: 'equals' },
* { id: 'female', label: 'Female', value: 'Female', count: 20, operation: 'equals' }
* ]
* }
* ]}
* onFilterChange={(filters) => console.log('Active filters:', filters)}
* />
* ```
*/
export const FilterBar = ({ onFilterChange, fields, initialFilters = [] }: FilterBarProps) => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we could write a unit test for this component?

const [activeFilters, setActiveFilters] = useState<Filter[]>(initialFilters);
const [listOpenTarget, setListOpenTarget] = useState<ListOpenTarget>('none');

const handleOnRemove = useCallback(
(fieldName: string) => {
const updatedFilters = activeFilters.filter((filter) => filter.fieldName !== fieldName);
setActiveFilters(updatedFilters);
onFilterChange(updatedFilters);
},
[activeFilters, onFilterChange],
);

const handleFilterUpdate = useCallback(
(fieldName: string, option?: FilterBarFieldOption) => {
const filterFound = !!activeFilters.find((filter) => filter.fieldName === fieldName);
let newFilters = activeFilters.slice();
if (!filterFound && !option) {
newFilters = [
...activeFilters,
{
fieldName,
operation: 'unset',
label: '',
},
];
} else if (filterFound && option) {
newFilters = activeFilters.map((filter) => {
if (filter.fieldName === fieldName) {
return {
fieldName,
value: option.value,
label: option.label,
operation: option.operation,
};
}
return filter;
});
}

setActiveFilters(newFilters);
onFilterChange(newFilters);
},
[activeFilters, onFilterChange],
);

return (
<section className={styles.filterBar}>
{activeFilters.map((filter) => {
const filterOption = fields.find((field) => field.id === filter.fieldName);
if (!filterOption) {
return null;
}

const displayLabel = Array.isArray(filter.value) ? `${filter.value.length} selected` : filter.label;

return (
<FilterButton
onRemove={() => handleOnRemove(filterOption.id)}
isOpen={listOpenTarget === filterOption.id}
onBtnClick={() => {
setListOpenTarget(listOpenTarget === filterOption.id ? 'none' : filterOption.id);
}}
key={filterOption.id}
filterOption={filterOption}
onListItemClick={(id, option) => {
handleFilterUpdate(id, option);
setListOpenTarget('none');
}}
displayLabel={displayLabel}
/>
);
})}
<AddFilterButton
isOpen={listOpenTarget === 'add_filter'}
onBtnClick={() => {
setListOpenTarget(listOpenTarget === 'add_filter' ? 'none' : 'add_filter');
}}
filterOptions={fields}
filters={activeFilters}
onListItemClick={(filterOpt) => {
const chosenOption =
typeof filterOpt?.chosenOptionId !== 'undefined'
? filterOpt.options.find((option: FilterBarFieldOption) => option.id === filterOpt?.chosenOptionId)
: undefined;
handleFilterUpdate(filterOpt.id, chosenOption);
setListOpenTarget(filterOpt.id);
}}
/>
{listOpenTarget !== 'none' && (
<div
className={styles.background}
onKeyUp={(e) => {
if (e.key === 'Enter') {
setListOpenTarget('none');
}
}}
onClick={() => setListOpenTarget('none')}
/>
)}
</section>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { Button } from '@digdir/designsystemet-react';
import { TrashIcon } from '@navikt/aksel-icons';
import { FilterBarField, FilterBarFieldOption } from '../FilterBar.tsx';
import { FilterList, FilterListItem } from '../FilterList';

import cx from 'classnames';
import { useState } from 'react';
import styles from './filterButton.module.css';

export interface BaseFilterButtonProps {
filterOption: FilterBarField;
onListItemClick: (id: string, option: FilterBarFieldOption) => void;
isOpen: boolean;
displayLabel?: string;
onBtnClick: () => void;
onRemove: (fieldName: string) => void;
}

export const FilterButton = ({
filterOption,
onListItemClick,
onBtnClick,
isOpen,
onRemove,
displayLabel,
}: BaseFilterButtonProps) => {
const [hoveringDeleteBtn, setHoveringDeleteBtn] = useState(false);
const { id, unSelectedLabel, options } = filterOption;
const chosenDisplayLabel = displayLabel || unSelectedLabel;

return (
<div className={styles.filterButton}>
<div className={styles.buttons}>
<Button onClick={onBtnClick} className={cx({ [styles.xed]: hoveringDeleteBtn })} size="small">
{chosenDisplayLabel}
</Button>
<Button
size="small"
onClick={() => {
onRemove(id);
}}
onMouseEnter={() => {
if (!hoveringDeleteBtn) {
setHoveringDeleteBtn(true);
}
}}
onMouseLeave={() => {
if (hoveringDeleteBtn) {
setHoveringDeleteBtn(false);
}
}}
>
<TrashIcon />
</Button>
</div>
{isOpen ? (
<FilterList>
{options.map((option) => {
return (
<FilterListItem
key={option.label}
onClick={() => {
onListItemClick(id, option);
}}
>
<div className={styles.filterListContent}>
<span className={styles.filterListLabel}>{option.label}</span>
<span className={styles.filterListCount}>{option.count}</span>
</div>
</FilterListItem>
);
})}
</FilterList>
) : null}
</div>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
.filterButton {
button {
border-radius: 0;
}
}

.buttons {
display: flex;
align-items: center;
}

.xed {
text-decoration: line-through;
}

.filterListContent {
display: flex;
align-items: center;
justify-content: space-between;
column-gap: 1em;
}

.filterListLabel {
font-size: 1rem;
}

.filterListCount {
padding: 0.5em;
font-size: 0.75rem;
font-weight: 600;
line-height: 1em;
border-radius: 50%;
height: 1em;
width: 1em;
display: inline-flex;
align-items: center;
justify-content: center;
background-color: rgb(243, 244, 246);
color: rgba(0,0,0);
}
Loading
Loading