Refactor UI folder (#2016)

* Added Overview page

* Revised Getting Started page

* Minor revision

* Edited readme, minor modifications to docs

* Removed sweep.yaml, .devcontainer, .ergomake

* Moved security.md to .github, added contributing.md

* changes as per code review

* updated contributing.md

* fixed broken links & added missing links in doc, improved structure

* fixed link in wsl setup

* fixed server link, added https cloning in yarn-setup

* removed package-lock.json

* added doc card, admonitions

* removed underline from nav buttons

* refactoring modules/ui

* refactoring modules/ui

* Change folder case

* Fix theme location

* Fix case 2

* Fix storybook

---------

Co-authored-by: Nimra Ahmed <nimra1408@gmail.com>
Co-authored-by: Nimra Ahmed <50912134+nimraahmed@users.noreply.github.com>
This commit is contained in:
Charles Bochet
2023-10-14 00:04:29 +02:00
committed by GitHub
parent a35ea5e8f9
commit 258685467b
732 changed files with 1106 additions and 1010 deletions

View File

@ -0,0 +1,24 @@
import { IconPlus } from '@/ui/display/icon';
import { LightButton } from '@/ui/input/button/components/LightButton';
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
import { FilterDropdownId } from '../constants/FilterDropdownId';
export const AddFilterFromDropdownButton = () => {
const { toggleDropdown } = useDropdown({
dropdownScopeId: FilterDropdownId,
});
const handleClick = () => {
toggleDropdown();
};
return (
<LightButton
onClick={handleClick}
icon={<IconPlus />}
title="Add filter"
accent="tertiary"
/>
);
};

View File

@ -0,0 +1,36 @@
import { HotkeyScope } from '@/ui/utilities/hotkey/types/HotkeyScope';
import { useRecoilScopedState } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedState';
import { useViewBarContext } from '../hooks/useViewBarContext';
import { availableFiltersScopedState } from '../states/availableFiltersScopedState';
import { MultipleFiltersDropdownButton } from './MultipleFiltersDropdownButton';
import { SingleEntityFilterDropdownButton } from './SingleEntityFilterDropdownButton';
type FilterDropdownButtonProps = {
hotkeyScope: HotkeyScope;
};
export const FilterDropdownButton = ({
hotkeyScope,
}: FilterDropdownButtonProps) => {
const { ViewBarRecoilScopeContext } = useViewBarContext();
const [availableFilters] = useRecoilScopedState(
availableFiltersScopedState,
ViewBarRecoilScopeContext,
);
const hasOnlyOneEntityFilter =
availableFilters.length === 1 && availableFilters[0].type === 'entity';
if (!availableFilters.length) {
return <></>;
}
return hasOnlyOneEntityFilter ? (
<SingleEntityFilterDropdownButton hotkeyScope={hotkeyScope} />
) : (
<MultipleFiltersDropdownButton hotkeyScope={hotkeyScope} />
);
};

View File

@ -0,0 +1,51 @@
import { useUpsertFilter } from '@/ui/data/view-bar/hooks/useUpsertFilter';
import { filterDefinitionUsedInDropdownScopedState } from '@/ui/data/view-bar/states/filterDefinitionUsedInDropdownScopedState';
import { selectedOperandInDropdownScopedState } from '@/ui/data/view-bar/states/selectedOperandInDropdownScopedState';
import { InternalDatePicker } from '@/ui/input/components/internal/date/components/InternalDatePicker';
import { useRecoilScopedState } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedState';
import { useViewBarContext } from '../hooks/useViewBarContext';
import { isFilterDropdownUnfoldedScopedState } from '../states/isFilterDropdownUnfoldedScopedState';
export const FilterDropdownDateSearchInput = () => {
const { ViewBarRecoilScopeContext } = useViewBarContext();
const [filterDefinitionUsedInDropdown] = useRecoilScopedState(
filterDefinitionUsedInDropdownScopedState,
ViewBarRecoilScopeContext,
);
const [selectedOperandInDropdown] = useRecoilScopedState(
selectedOperandInDropdownScopedState,
ViewBarRecoilScopeContext,
);
const [, setIsFilterDropdownUnfolded] = useRecoilScopedState(
isFilterDropdownUnfoldedScopedState,
ViewBarRecoilScopeContext,
);
const upsertFilter = useUpsertFilter();
const handleChange = (date: Date) => {
if (!filterDefinitionUsedInDropdown || !selectedOperandInDropdown) return;
upsertFilter({
key: filterDefinitionUsedInDropdown.key,
type: filterDefinitionUsedInDropdown.type,
value: date.toISOString(),
operand: selectedOperandInDropdown,
displayValue: date.toLocaleDateString(),
});
setIsFilterDropdownUnfolded(false);
};
return (
<InternalDatePicker
date={new Date()}
onChange={handleChange}
onMouseSelect={handleChange}
/>
);
};

View File

@ -0,0 +1,44 @@
import { ChangeEvent } from 'react';
import { filterDefinitionUsedInDropdownScopedState } from '@/ui/data/view-bar/states/filterDefinitionUsedInDropdownScopedState';
import { filterDropdownSearchInputScopedState } from '@/ui/data/view-bar/states/filterDropdownSearchInputScopedState';
import { selectedOperandInDropdownScopedState } from '@/ui/data/view-bar/states/selectedOperandInDropdownScopedState';
import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput';
import { useRecoilScopedState } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedState';
import { useViewBarContext } from '../hooks/useViewBarContext';
export const FilterDropdownEntitySearchInput = () => {
const { ViewBarRecoilScopeContext } = useViewBarContext();
const [filterDefinitionUsedInDropdown] = useRecoilScopedState(
filterDefinitionUsedInDropdownScopedState,
ViewBarRecoilScopeContext,
);
const [selectedOperandInDropdown] = useRecoilScopedState(
selectedOperandInDropdownScopedState,
ViewBarRecoilScopeContext,
);
const [filterDropdownSearchInput, setFilterDropdownSearchInput] =
useRecoilScopedState(
filterDropdownSearchInputScopedState,
ViewBarRecoilScopeContext,
);
return (
filterDefinitionUsedInDropdown &&
selectedOperandInDropdown && (
<DropdownMenuSearchInput
autoFocus
type="text"
value={filterDropdownSearchInput}
placeholder={filterDefinitionUsedInDropdown.label}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
setFilterDropdownSearchInput(event.target.value);
}}
/>
)
);
};

View File

@ -0,0 +1,153 @@
import { useEffect, useState } from 'react';
import { useFilterCurrentlyEdited } from '@/ui/data/view-bar/hooks/useFilterCurrentlyEdited';
import { useRemoveFilter } from '@/ui/data/view-bar/hooks/useRemoveFilter';
import { useUpsertFilter } from '@/ui/data/view-bar/hooks/useUpsertFilter';
import { filterDefinitionUsedInDropdownScopedState } from '@/ui/data/view-bar/states/filterDefinitionUsedInDropdownScopedState';
import { filterDropdownSelectedEntityIdScopedState } from '@/ui/data/view-bar/states/filterDropdownSelectedEntityIdScopedState';
import { selectedOperandInDropdownScopedState } from '@/ui/data/view-bar/states/selectedOperandInDropdownScopedState';
import { EntitiesForMultipleEntitySelect } from '@/ui/input/relation-picker/components/MultipleEntitySelect';
import { SingleEntitySelectBase } from '@/ui/input/relation-picker/components/SingleEntitySelectBase';
import { EntityForSelect } from '@/ui/input/relation-picker/types/EntityForSelect';
import { useRecoilScopedState } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedState';
import { ViewFilterOperand } from '~/generated/graphql';
import { useViewBarContext } from '../hooks/useViewBarContext';
import { filterDropdownSearchInputScopedState } from '../states/filterDropdownSearchInputScopedState';
export const FilterDropdownEntitySearchSelect = ({
entitiesForSelect,
}: {
entitiesForSelect: EntitiesForMultipleEntitySelect<EntityForSelect>;
}) => {
const { ViewBarRecoilScopeContext } = useViewBarContext();
const [isAllEntitySelected, setIsAllEntitySelected] = useState(false);
const [filterDropdownSelectedEntityId, setFilterDropdownSelectedEntityId] =
useRecoilScopedState(
filterDropdownSelectedEntityIdScopedState,
ViewBarRecoilScopeContext,
);
const [selectedOperandInDropdown] = useRecoilScopedState(
selectedOperandInDropdownScopedState,
ViewBarRecoilScopeContext,
);
const [filterDefinitionUsedInDropdown] = useRecoilScopedState(
filterDefinitionUsedInDropdownScopedState,
ViewBarRecoilScopeContext,
);
const upsertFilter = useUpsertFilter();
const removeFilter = useRemoveFilter();
const filterCurrentlyEdited = useFilterCurrentlyEdited();
const handleUserSelected = (
selectedEntity: EntityForSelect | null | undefined,
) => {
if (
!filterDefinitionUsedInDropdown ||
!selectedOperandInDropdown ||
!selectedEntity
) {
return;
}
if (isAllEntitySelected) {
setIsAllEntitySelected(false);
}
const clickedOnAlreadySelectedEntity =
selectedEntity.id === filterDropdownSelectedEntityId;
if (clickedOnAlreadySelectedEntity) {
removeFilter(filterDefinitionUsedInDropdown.key);
setFilterDropdownSelectedEntityId(null);
} else {
setFilterDropdownSelectedEntityId(selectedEntity.id);
upsertFilter({
displayValue: selectedEntity.name,
key: filterDefinitionUsedInDropdown.key,
operand: selectedOperandInDropdown,
type: filterDefinitionUsedInDropdown.type,
value: selectedEntity.id,
displayAvatarUrl: selectedEntity.avatarUrl,
});
}
};
const [filterDropdownSearchInput] = useRecoilScopedState(
filterDropdownSearchInputScopedState,
ViewBarRecoilScopeContext,
);
const isAllEntitySelectShown =
!!filterDefinitionUsedInDropdown?.selectAllLabel &&
!!filterDefinitionUsedInDropdown?.SelectAllIcon &&
(isAllEntitySelected ||
filterDefinitionUsedInDropdown?.selectAllLabel
.toLocaleLowerCase()
.includes(filterDropdownSearchInput.toLocaleLowerCase()));
const handleAllEntitySelectClick = () => {
if (
!filterDefinitionUsedInDropdown ||
!selectedOperandInDropdown ||
!filterDefinitionUsedInDropdown.selectAllLabel
) {
return;
}
if (isAllEntitySelected) {
setIsAllEntitySelected(false);
removeFilter(filterDefinitionUsedInDropdown.key);
} else {
setIsAllEntitySelected(true);
setFilterDropdownSelectedEntityId(null);
upsertFilter({
displayValue: filterDefinitionUsedInDropdown.selectAllLabel,
key: filterDefinitionUsedInDropdown.key,
operand: ViewFilterOperand.IsNotNull,
type: filterDefinitionUsedInDropdown.type,
value: '',
});
}
};
useEffect(() => {
if (!filterCurrentlyEdited) {
setFilterDropdownSelectedEntityId(null);
} else {
setFilterDropdownSelectedEntityId(filterCurrentlyEdited.value);
setIsAllEntitySelected(
filterCurrentlyEdited.operand === ViewFilterOperand.IsNotNull,
);
}
}, [
filterCurrentlyEdited,
setFilterDropdownSelectedEntityId,
entitiesForSelect.selectedEntities,
]);
return (
<>
<SingleEntitySelectBase
entitiesToSelect={entitiesForSelect.entitiesToSelect}
selectedEntity={entitiesForSelect.selectedEntities[0]}
loading={entitiesForSelect.loading}
onEntitySelected={handleUserSelected}
SelectAllIcon={filterDefinitionUsedInDropdown?.SelectAllIcon}
selectAllLabel={filterDefinitionUsedInDropdown?.selectAllLabel}
isAllEntitySelected={isAllEntitySelected}
isAllEntitySelectShown={isAllEntitySelectShown}
onAllEntitySelected={handleAllEntitySelectClick}
/>
</>
);
};

View File

@ -0,0 +1,28 @@
import { StyledDropdownMenuSeparator } from '@/ui/layout/dropdown/components/StyledDropdownMenuSeparator';
import { RecoilScope } from '@/ui/utilities/recoil-scope/components/RecoilScope';
import { useRecoilScopedState } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedState';
import { useViewBarContext } from '../hooks/useViewBarContext';
import { filterDefinitionUsedInDropdownScopedState } from '../states/filterDefinitionUsedInDropdownScopedState';
export const FilterDropdownEntitySelect = () => {
const { ViewBarRecoilScopeContext } = useViewBarContext();
const [filterDefinitionUsedInDropdown] = useRecoilScopedState(
filterDefinitionUsedInDropdownScopedState,
ViewBarRecoilScopeContext,
);
if (filterDefinitionUsedInDropdown?.type !== 'entity') {
return null;
}
return (
<>
<StyledDropdownMenuSeparator />
<RecoilScope>
{filterDefinitionUsedInDropdown.entitySelectComponent}
</RecoilScope>
</>
);
};

View File

@ -0,0 +1,65 @@
import { RelationPickerHotkeyScope } from '@/ui/input/relation-picker/types/RelationPickerHotkeyScope';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem';
import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope';
import { useRecoilScopedState } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedState';
import { useRecoilScopedValue } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedValue';
import { useViewBarContext } from '../hooks/useViewBarContext';
import { availableFiltersScopedState } from '../states/availableFiltersScopedState';
import { filterDefinitionUsedInDropdownScopedState } from '../states/filterDefinitionUsedInDropdownScopedState';
import { filterDropdownSearchInputScopedState } from '../states/filterDropdownSearchInputScopedState';
import { selectedOperandInDropdownScopedState } from '../states/selectedOperandInDropdownScopedState';
import { getOperandsForFilterType } from '../utils/getOperandsForFilterType';
export const FilterDropdownFilterSelect = () => {
const { ViewBarRecoilScopeContext } = useViewBarContext();
const [, setFilterDefinitionUsedInDropdown] = useRecoilScopedState(
filterDefinitionUsedInDropdownScopedState,
ViewBarRecoilScopeContext,
);
const [, setSelectedOperandInDropdown] = useRecoilScopedState(
selectedOperandInDropdownScopedState,
ViewBarRecoilScopeContext,
);
const [, setFilterDropdownSearchInput] = useRecoilScopedState(
filterDropdownSearchInputScopedState,
ViewBarRecoilScopeContext,
);
const availableFilters = useRecoilScopedValue(
availableFiltersScopedState,
ViewBarRecoilScopeContext,
);
const setHotkeyScope = useSetHotkeyScope();
return (
<DropdownMenuItemsContainer>
{availableFilters.map((availableFilter, index) => (
<MenuItem
key={`select-filter-${index}`}
testId={`select-filter-${index}`}
onClick={() => {
setFilterDefinitionUsedInDropdown(availableFilter);
if (availableFilter.type === 'entity') {
setHotkeyScope(RelationPickerHotkeyScope.RelationPicker);
}
setSelectedOperandInDropdown(
getOperandsForFilterType(availableFilter.type)?.[0],
);
setFilterDropdownSearchInput('');
}}
LeftIcon={availableFilter.Icon}
text={availableFilter.label}
/>
))}
</DropdownMenuItemsContainer>
);
};

View File

@ -0,0 +1,51 @@
import { ChangeEvent } from 'react';
import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput';
import { useRecoilScopedState } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedState';
import { useRemoveFilter } from '../hooks/useRemoveFilter';
import { useUpsertFilter } from '../hooks/useUpsertFilter';
import { useViewBarContext } from '../hooks/useViewBarContext';
import { filterDefinitionUsedInDropdownScopedState } from '../states/filterDefinitionUsedInDropdownScopedState';
import { selectedOperandInDropdownScopedState } from '../states/selectedOperandInDropdownScopedState';
export const FilterDropdownNumberSearchInput = () => {
const { ViewBarRecoilScopeContext } = useViewBarContext();
const [filterDefinitionUsedInDropdown] = useRecoilScopedState(
filterDefinitionUsedInDropdownScopedState,
ViewBarRecoilScopeContext,
);
const [selectedOperandInDropdown] = useRecoilScopedState(
selectedOperandInDropdownScopedState,
ViewBarRecoilScopeContext,
);
const upsertFilter = useUpsertFilter();
const removeFilter = useRemoveFilter();
return (
filterDefinitionUsedInDropdown &&
selectedOperandInDropdown && (
<DropdownMenuSearchInput
autoFocus
type="number"
placeholder={filterDefinitionUsedInDropdown.label}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
if (event.target.value === '') {
removeFilter(filterDefinitionUsedInDropdown.key);
} else {
upsertFilter({
key: filterDefinitionUsedInDropdown.key,
type: filterDefinitionUsedInDropdown.type,
value: event.target.value,
operand: selectedOperandInDropdown,
displayValue: event.target.value,
});
}
}}
/>
)
);
};

View File

@ -0,0 +1,39 @@
import { IconChevronDown } from '@/ui/display/icon';
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader';
import { useRecoilScopedState } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedState';
import { useViewBarContext } from '../hooks/useViewBarContext';
import { isFilterDropdownOperandSelectUnfoldedScopedState } from '../states/isFilterDropdownOperandSelectUnfoldedScopedState';
import { selectedOperandInDropdownScopedState } from '../states/selectedOperandInDropdownScopedState';
import { getOperandLabel } from '../utils/getOperandLabel';
export const FilterDropdownOperandButton = () => {
const { ViewBarRecoilScopeContext } = useViewBarContext();
const [selectedOperandInDropdown] = useRecoilScopedState(
selectedOperandInDropdownScopedState,
ViewBarRecoilScopeContext,
);
const [
isFilterDropdownOperandSelectUnfolded,
setIsFilterDropdownOperandSelectUnfolded,
] = useRecoilScopedState(
isFilterDropdownOperandSelectUnfoldedScopedState,
ViewBarRecoilScopeContext,
);
if (isFilterDropdownOperandSelectUnfolded) {
return null;
}
return (
<DropdownMenuHeader
key={'selected-filter-operand'}
EndIcon={IconChevronDown}
onClick={() => setIsFilterDropdownOperandSelectUnfolded(true)}
>
{getOperandLabel(selectedOperandInDropdown)}
</DropdownMenuHeader>
);
};

View File

@ -0,0 +1,76 @@
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem';
import { useRecoilScopedState } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedState';
import { ViewFilterOperand } from '~/generated/graphql';
import { useFilterCurrentlyEdited } from '../hooks/useFilterCurrentlyEdited';
import { useUpsertFilter } from '../hooks/useUpsertFilter';
import { useViewBarContext } from '../hooks/useViewBarContext';
import { filterDefinitionUsedInDropdownScopedState } from '../states/filterDefinitionUsedInDropdownScopedState';
import { isFilterDropdownOperandSelectUnfoldedScopedState } from '../states/isFilterDropdownOperandSelectUnfoldedScopedState';
import { selectedOperandInDropdownScopedState } from '../states/selectedOperandInDropdownScopedState';
import { getOperandLabel } from '../utils/getOperandLabel';
import { getOperandsForFilterType } from '../utils/getOperandsForFilterType';
export const FilterDropdownOperandSelect = () => {
const { ViewBarRecoilScopeContext } = useViewBarContext();
const [filterDefinitionUsedInDropdown] = useRecoilScopedState(
filterDefinitionUsedInDropdownScopedState,
ViewBarRecoilScopeContext,
);
const [, setSelectedOperandInDropdown] = useRecoilScopedState(
selectedOperandInDropdownScopedState,
ViewBarRecoilScopeContext,
);
const operandsForFilterType = getOperandsForFilterType(
filterDefinitionUsedInDropdown?.type,
);
const [
isFilterDropdownOperandSelectUnfolded,
setIsFilterDropdownOperandSelectUnfolded,
] = useRecoilScopedState(
isFilterDropdownOperandSelectUnfoldedScopedState,
ViewBarRecoilScopeContext,
);
const filterCurrentlyEdited = useFilterCurrentlyEdited();
const upsertFilter = useUpsertFilter();
const handleOperangeChange = (newOperand: ViewFilterOperand) => {
setSelectedOperandInDropdown(newOperand);
setIsFilterDropdownOperandSelectUnfolded(false);
if (filterDefinitionUsedInDropdown && filterCurrentlyEdited) {
upsertFilter({
key: filterCurrentlyEdited.key,
displayValue: filterCurrentlyEdited.displayValue,
operand: newOperand,
type: filterCurrentlyEdited.type,
value: filterCurrentlyEdited.value,
});
}
};
if (!isFilterDropdownOperandSelectUnfolded) {
return <></>;
}
return (
<DropdownMenuItemsContainer>
{operandsForFilterType.map((filterOperand, index) => (
<MenuItem
key={`select-filter-operand-${index}`}
onClick={() => {
handleOperangeChange(filterOperand);
}}
text={getOperandLabel(filterOperand)}
/>
))}
</DropdownMenuItemsContainer>
);
};

View File

@ -0,0 +1,64 @@
import { ChangeEvent } from 'react';
import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput';
import { useRecoilScopedState } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedState';
import { useFilterCurrentlyEdited } from '../hooks/useFilterCurrentlyEdited';
import { useRemoveFilter } from '../hooks/useRemoveFilter';
import { useUpsertFilter } from '../hooks/useUpsertFilter';
import { useViewBarContext } from '../hooks/useViewBarContext';
import { filterDefinitionUsedInDropdownScopedState } from '../states/filterDefinitionUsedInDropdownScopedState';
import { filterDropdownSearchInputScopedState } from '../states/filterDropdownSearchInputScopedState';
import { selectedOperandInDropdownScopedState } from '../states/selectedOperandInDropdownScopedState';
export const FilterDropdownTextSearchInput = () => {
const { ViewBarRecoilScopeContext } = useViewBarContext();
const [filterDefinitionUsedInDropdown] = useRecoilScopedState(
filterDefinitionUsedInDropdownScopedState,
ViewBarRecoilScopeContext,
);
const [selectedOperandInDropdown] = useRecoilScopedState(
selectedOperandInDropdownScopedState,
ViewBarRecoilScopeContext,
);
const [filterDropdownSearchInput, setFilterDropdownSearchInput] =
useRecoilScopedState(
filterDropdownSearchInputScopedState,
ViewBarRecoilScopeContext,
);
const upsertFilter = useUpsertFilter();
const removeFilter = useRemoveFilter();
const filterCurrentlyEdited = useFilterCurrentlyEdited();
return (
filterDefinitionUsedInDropdown &&
selectedOperandInDropdown && (
<DropdownMenuSearchInput
autoFocus
type="text"
placeholder={filterDefinitionUsedInDropdown.label}
value={filterCurrentlyEdited?.value ?? filterDropdownSearchInput}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
setFilterDropdownSearchInput(event.target.value);
if (event.target.value === '') {
removeFilter(filterDefinitionUsedInDropdown.key);
} else {
upsertFilter({
key: filterDefinitionUsedInDropdown.key,
type: filterDefinitionUsedInDropdown.type,
value: event.target.value,
operand: selectedOperandInDropdown,
displayValue: event.target.value,
});
}
}}
/>
)
);
};

View File

@ -0,0 +1,22 @@
import { EntityChip } from '@/ui/display/chip/components/EntityChip';
import { IconComponent } from '@/ui/display/icon/types/IconComponent';
import { Filter } from '../types/Filter';
type GenericEntityFilterChipProps = {
filter: Filter;
Icon?: IconComponent;
};
export const GenericEntityFilterChip = ({
filter,
Icon,
}: GenericEntityFilterChipProps) => (
<EntityChip
entityId={filter.value}
name={filter.displayValue}
avatarType="rounded"
pictureUrl={filter.displayAvatarUrl}
LeftIcon={Icon}
/>
);

View File

@ -0,0 +1,59 @@
import { StyledHeaderDropdownButton } from '@/ui/layout/dropdown/components/StyledHeaderDropdownButton';
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
import { useRecoilScopedState } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedState';
import { FilterDropdownId } from '../constants/FilterDropdownId';
import { useViewBarContext } from '../hooks/useViewBarContext';
import { filterDefinitionUsedInDropdownScopedState } from '../states/filterDefinitionUsedInDropdownScopedState';
import { filterDropdownSearchInputScopedState } from '../states/filterDropdownSearchInputScopedState';
import { isFilterDropdownOperandSelectUnfoldedScopedState } from '../states/isFilterDropdownOperandSelectUnfoldedScopedState';
import { selectedOperandInDropdownScopedState } from '../states/selectedOperandInDropdownScopedState';
export const MultipleFiltersButton = () => {
const { ViewBarRecoilScopeContext } = useViewBarContext();
const { isDropdownOpen, toggleDropdown } = useDropdown({
dropdownScopeId: FilterDropdownId,
});
const [, setIsFilterDropdownOperandSelectUnfolded] = useRecoilScopedState(
isFilterDropdownOperandSelectUnfoldedScopedState,
ViewBarRecoilScopeContext,
);
const [, setFilterDefinitionUsedInDropdown] = useRecoilScopedState(
filterDefinitionUsedInDropdownScopedState,
ViewBarRecoilScopeContext,
);
const [, setFilterDropdownSearchInput] = useRecoilScopedState(
filterDropdownSearchInputScopedState,
ViewBarRecoilScopeContext,
);
const [, setSelectedOperandInDropdown] = useRecoilScopedState(
selectedOperandInDropdownScopedState,
ViewBarRecoilScopeContext,
);
const resetState = () => {
setIsFilterDropdownOperandSelectUnfolded(false);
setFilterDefinitionUsedInDropdown(null);
setSelectedOperandInDropdown(null);
setFilterDropdownSearchInput('');
};
const handleClick = () => {
toggleDropdown();
resetState();
};
return (
<StyledHeaderDropdownButton
isUnfolded={isDropdownOpen}
onClick={handleClick}
>
Filter
</StyledHeaderDropdownButton>
);
};

View File

@ -0,0 +1,24 @@
import { HotkeyScope } from '@/ui/utilities/hotkey/types/HotkeyScope';
import { FilterDropdownId } from '../constants/FilterDropdownId';
import { MultipleFiltersButton } from './MultipleFiltersButton';
import { MultipleFiltersDropdownContent } from './MultipleFiltersDropdownContent';
import { ViewBarDropdownButton } from './ViewBarDropdownButton';
type MultipleFiltersDropdownButtonProps = {
hotkeyScope: HotkeyScope;
};
export const MultipleFiltersDropdownButton = ({
hotkeyScope,
}: MultipleFiltersDropdownButtonProps) => {
return (
<ViewBarDropdownButton
dropdownId={FilterDropdownId}
buttonComponent={<MultipleFiltersButton />}
dropdownComponents={<MultipleFiltersDropdownContent />}
dropdownHotkeyScope={hotkeyScope}
/>
);
};

View File

@ -0,0 +1,70 @@
import { StyledDropdownMenu } from '@/ui/layout/dropdown/components/StyledDropdownMenu';
import { StyledDropdownMenuSeparator } from '@/ui/layout/dropdown/components/StyledDropdownMenuSeparator';
import { useRecoilScopedState } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedState';
import { useViewBarContext } from '../hooks/useViewBarContext';
import { filterDefinitionUsedInDropdownScopedState } from '../states/filterDefinitionUsedInDropdownScopedState';
import { isFilterDropdownOperandSelectUnfoldedScopedState } from '../states/isFilterDropdownOperandSelectUnfoldedScopedState';
import { selectedOperandInDropdownScopedState } from '../states/selectedOperandInDropdownScopedState';
import { FilterDropdownDateSearchInput } from './FilterDropdownDateSearchInput';
import { FilterDropdownEntitySearchInput } from './FilterDropdownEntitySearchInput';
import { FilterDropdownEntitySelect } from './FilterDropdownEntitySelect';
import { FilterDropdownFilterSelect } from './FilterDropdownFilterSelect';
import { FilterDropdownNumberSearchInput } from './FilterDropdownNumberSearchInput';
import { FilterDropdownOperandButton } from './FilterDropdownOperandButton';
import { FilterDropdownOperandSelect } from './FilterDropdownOperandSelect';
import { FilterDropdownTextSearchInput } from './FilterDropdownTextSearchInput';
export const MultipleFiltersDropdownContent = () => {
const { ViewBarRecoilScopeContext } = useViewBarContext();
const [isFilterDropdownOperandSelectUnfolded] = useRecoilScopedState(
isFilterDropdownOperandSelectUnfoldedScopedState,
ViewBarRecoilScopeContext,
);
const [filterDefinitionUsedInDropdown] = useRecoilScopedState(
filterDefinitionUsedInDropdownScopedState,
ViewBarRecoilScopeContext,
);
const [selectedOperandInDropdown] = useRecoilScopedState(
selectedOperandInDropdownScopedState,
ViewBarRecoilScopeContext,
);
return (
<StyledDropdownMenu>
<>
{!filterDefinitionUsedInDropdown ? (
<FilterDropdownFilterSelect />
) : isFilterDropdownOperandSelectUnfolded ? (
<FilterDropdownOperandSelect />
) : (
selectedOperandInDropdown && (
<>
<FilterDropdownOperandButton />
<StyledDropdownMenuSeparator />
{filterDefinitionUsedInDropdown.type === 'text' && (
<FilterDropdownTextSearchInput />
)}
{filterDefinitionUsedInDropdown.type === 'number' && (
<FilterDropdownNumberSearchInput />
)}
{filterDefinitionUsedInDropdown.type === 'date' && (
<FilterDropdownDateSearchInput />
)}
{filterDefinitionUsedInDropdown.type === 'entity' && (
<FilterDropdownEntitySearchInput />
)}
{filterDefinitionUsedInDropdown.type === 'entity' && (
<FilterDropdownEntitySelect />
)}
</>
)
)}
</>
</StyledDropdownMenu>
);
};

View File

@ -0,0 +1,95 @@
import React from 'react';
import { useTheme } from '@emotion/react';
import { IconChevronDown } from '@/ui/display/icon/index';
import { DropdownMenu } from '@/ui/layout/dropdown/components/DropdownMenu';
import { DropdownMenuContainer } from '@/ui/layout/dropdown/components/DropdownMenuContainer';
import { StyledHeaderDropdownButton } from '@/ui/layout/dropdown/components/StyledHeaderDropdownButton';
import { DropdownScope } from '@/ui/layout/dropdown/scopes/DropdownScope';
import { HotkeyScope } from '@/ui/utilities/hotkey/types/HotkeyScope';
import { useRecoilScopedState } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedState';
import { ViewFilterOperand } from '~/generated/graphql';
import { useViewBarContext } from '../hooks/useViewBarContext';
import { availableFiltersScopedState } from '../states/availableFiltersScopedState';
import { filterDefinitionUsedInDropdownScopedState } from '../states/filterDefinitionUsedInDropdownScopedState';
import { filtersScopedState } from '../states/filtersScopedState';
import { selectedOperandInDropdownScopedState } from '../states/selectedOperandInDropdownScopedState';
import { getOperandsForFilterType } from '../utils/getOperandsForFilterType';
import { FilterDropdownEntitySearchInput } from './FilterDropdownEntitySearchInput';
import { FilterDropdownEntitySelect } from './FilterDropdownEntitySelect';
import { GenericEntityFilterChip } from './GenericEntityFilterChip';
export const SingleEntityFilterDropdownButton = ({
hotkeyScope,
}: {
hotkeyScope: HotkeyScope;
}) => {
const { ViewBarRecoilScopeContext } = useViewBarContext();
const [availableFilters] = useRecoilScopedState(
availableFiltersScopedState,
ViewBarRecoilScopeContext,
);
const availableFilter = availableFilters[0];
const [filters] = useRecoilScopedState(
filtersScopedState,
ViewBarRecoilScopeContext,
);
const [, setFilterDefinitionUsedInDropdown] = useRecoilScopedState(
filterDefinitionUsedInDropdownScopedState,
ViewBarRecoilScopeContext,
);
const [, setSelectedOperandInDropdown] = useRecoilScopedState(
selectedOperandInDropdownScopedState,
ViewBarRecoilScopeContext,
);
React.useEffect(() => {
setFilterDefinitionUsedInDropdown(availableFilter);
const defaultOperand = getOperandsForFilterType(availableFilter?.type)[0];
setSelectedOperandInDropdown(defaultOperand);
}, [
availableFilter,
setFilterDefinitionUsedInDropdown,
setSelectedOperandInDropdown,
]);
const theme = useTheme();
return (
<DropdownScope dropdownScopeId="single-entity-filter-dropdown">
<DropdownMenu
dropdownHotkeyScope={hotkeyScope}
dropdownOffset={{ x: 0, y: -28 }}
clickableComponent={
<StyledHeaderDropdownButton>
{filters[0] ? (
<GenericEntityFilterChip
filter={filters[0]}
Icon={
filters[0].operand === ViewFilterOperand.IsNotNull
? availableFilter.SelectAllIcon
: undefined
}
/>
) : (
'Filter'
)}
<IconChevronDown size={theme.icon.size.md} />
</StyledHeaderDropdownButton>
}
dropdownComponents={
<DropdownMenuContainer>
<FilterDropdownEntitySearchInput />
<FilterDropdownEntitySelect />
</DropdownMenuContainer>
}
/>
</DropdownScope>
);
};

View File

@ -0,0 +1,146 @@
import { useCallback, useState } from 'react';
import { produce } from 'immer';
import { IconChevronDown } from '@/ui/display/icon';
import { LightButton } from '@/ui/input/button/components/LightButton';
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { StyledDropdownMenu } from '@/ui/layout/dropdown/components/StyledDropdownMenu';
import { StyledDropdownMenuSeparator } from '@/ui/layout/dropdown/components/StyledDropdownMenuSeparator';
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem';
import { HotkeyScope } from '@/ui/utilities/hotkey/types/HotkeyScope';
import { useRecoilScopedState } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedState';
import { SortDropdownId } from '../constants/SortDropdownId';
import { useViewBarContext } from '../hooks/useViewBarContext';
import { availableSortsScopedState } from '../states/availableSortsScopedState';
import { sortsScopedState } from '../states/sortsScopedState';
import { SortDefinition } from '../types/SortDefinition';
import { SORT_DIRECTIONS, SortDirection } from '../types/SortDirection';
import { ViewBarDropdownButton } from './ViewBarDropdownButton';
export type SortDropdownButtonProps = {
hotkeyScope: HotkeyScope;
isPrimaryButton?: boolean;
};
export const SortDropdownButton = ({
hotkeyScope,
}: SortDropdownButtonProps) => {
const { ViewBarRecoilScopeContext } = useViewBarContext();
const [isSortDirectionMenuUnfolded, setIsSortDirectionMenuUnfolded] =
useState(false);
const [selectedSortDirection, setSelectedSortDirection] =
useState<SortDirection>('asc');
const resetState = useCallback(() => {
setIsSortDirectionMenuUnfolded(false);
setSelectedSortDirection('asc');
}, []);
const [availableSorts] = useRecoilScopedState(
availableSortsScopedState,
ViewBarRecoilScopeContext,
);
const [sorts, setSorts] = useRecoilScopedState(
sortsScopedState,
ViewBarRecoilScopeContext,
);
const isSortSelected = sorts.length > 0;
const { toggleDropdown } = useDropdown({
dropdownScopeId: SortDropdownId,
});
const handleButtonClick = () => {
toggleDropdown();
resetState();
};
const handleAddSort = (selectedSortDefinition: SortDefinition) => {
toggleDropdown();
setSorts(
produce(sorts, (existingSortsDraft) => {
const foundExistingSortIndex = existingSortsDraft.findIndex(
(existingSort) => existingSort.key === selectedSortDefinition.key,
);
if (foundExistingSortIndex !== -1) {
existingSortsDraft[foundExistingSortIndex].direction =
selectedSortDirection;
} else {
existingSortsDraft.push({
key: selectedSortDefinition.key,
direction: selectedSortDirection,
definition: selectedSortDefinition,
});
}
}),
);
};
const handleDropdownButtonClose = () => {
resetState();
};
return (
<ViewBarDropdownButton
dropdownId={SortDropdownId}
dropdownHotkeyScope={hotkeyScope}
buttonComponent={
<LightButton
title="Sort"
active={isSortSelected}
onClick={handleButtonClick}
/>
}
dropdownComponents={
<StyledDropdownMenu>
{isSortDirectionMenuUnfolded ? (
<DropdownMenuItemsContainer>
{SORT_DIRECTIONS.map((sortOrder, index) => (
<MenuItem
key={index}
onClick={() => {
setSelectedSortDirection(sortOrder);
setIsSortDirectionMenuUnfolded(false);
}}
text={sortOrder === 'asc' ? 'Ascending' : 'Descending'}
/>
))}
</DropdownMenuItemsContainer>
) : (
<>
<DropdownMenuHeader
EndIcon={IconChevronDown}
onClick={() => setIsSortDirectionMenuUnfolded(true)}
>
{selectedSortDirection === 'asc' ? 'Ascending' : 'Descending'}
</DropdownMenuHeader>
<StyledDropdownMenuSeparator />
<DropdownMenuItemsContainer>
{availableSorts.map((availableSort, index) => (
<MenuItem
testId={`select-sort-${index}`}
key={index}
onClick={() => handleAddSort(availableSort)}
LeftIcon={availableSort.Icon}
text={availableSort.label}
/>
))}
</DropdownMenuItemsContainer>
</>
)}
</StyledDropdownMenu>
}
onClose={handleDropdownButtonClose}
></ViewBarDropdownButton>
);
};

View File

@ -0,0 +1,82 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { IconX } from '@/ui/display/icon/index';
import { IconComponent } from '@/ui/display/icon/types/IconComponent';
type SortOrFilterChipProps = {
labelKey?: string;
labelValue: string;
Icon?: IconComponent;
onRemove: () => void;
isSort?: boolean;
testId?: string;
};
type StyledChipProps = {
isSort?: boolean;
};
const StyledChip = styled.div<StyledChipProps>`
align-items: center;
background-color: ${({ theme }) => theme.accent.quaternary};
border: 1px solid ${({ theme }) => theme.accent.tertiary};
border-radius: 4px;
color: ${({ theme }) => theme.color.blue};
display: flex;
flex-direction: row;
flex-shrink: 0;
font-size: ${({ theme }) => theme.font.size.sm};
font-weight: ${({ isSort }) => (isSort ? 'bold' : 'normal')};
padding: ${({ theme }) => theme.spacing(1) + ' ' + theme.spacing(2)};
`;
const StyledIcon = styled.div`
align-items: center;
display: flex;
margin-right: ${({ theme }) => theme.spacing(1)};
`;
const StyledDelete = styled.div`
align-items: center;
cursor: pointer;
display: flex;
font-size: ${({ theme }) => theme.font.size.sm};
margin-left: ${({ theme }) => theme.spacing(2)};
margin-top: 1px;
user-select: none;
&:hover {
background-color: ${({ theme }) => theme.accent.secondary};
border-radius: ${({ theme }) => theme.border.radius.sm};
}
`;
const StyledLabelKey = styled.div`
font-weight: ${({ theme }) => theme.font.weight.medium};
`;
const SortOrFilterChip = ({
labelKey,
labelValue,
Icon,
onRemove,
isSort,
testId,
}: SortOrFilterChipProps) => {
const theme = useTheme();
return (
<StyledChip isSort={isSort}>
{Icon && (
<StyledIcon>
<Icon size={theme.icon.size.sm} />
</StyledIcon>
)}
{labelKey && <StyledLabelKey>{labelKey}</StyledLabelKey>}
{labelValue}
<StyledDelete onClick={onRemove} data-testid={'remove-icon-' + testId}>
<IconX size={theme.icon.size.sm} stroke={theme.icon.stroke.sm} />
</StyledDelete>
</StyledChip>
);
};
export default SortOrFilterChip;

View File

@ -0,0 +1,141 @@
import { useCallback, useContext, useState } from 'react';
import styled from '@emotion/styled';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import { Key } from 'ts-key-enum';
import { currentViewIdScopedState } from '@/ui/data/view-bar/states/currentViewIdScopedState';
import { filtersScopedState } from '@/ui/data/view-bar/states/filtersScopedState';
import { savedFiltersFamilyState } from '@/ui/data/view-bar/states/savedFiltersFamilyState';
import { savedSortsFamilyState } from '@/ui/data/view-bar/states/savedSortsFamilyState';
import { canPersistFiltersScopedFamilySelector } from '@/ui/data/view-bar/states/selectors/canPersistFiltersScopedFamilySelector';
import { canPersistSortsScopedFamilySelector } from '@/ui/data/view-bar/states/selectors/canPersistSortsScopedFamilySelector';
import { sortsScopedState } from '@/ui/data/view-bar/states/sortsScopedState';
import { viewEditModeState } from '@/ui/data/view-bar/states/viewEditModeState';
import { IconChevronDown, IconPlus } from '@/ui/display/icon';
import { Button } from '@/ui/input/button/components/Button';
import { ButtonGroup } from '@/ui/input/button/components/ButtonGroup';
import { DropdownMenuContainer } from '@/ui/layout/dropdown/components/DropdownMenuContainer';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { useRecoilScopedValue } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedValue';
import { useRecoilScopeId } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopeId';
import { ViewBarContext } from '../contexts/ViewBarContext';
const StyledContainer = styled.div`
display: inline-flex;
margin-right: ${({ theme }) => theme.spacing(2)};
position: relative;
`;
export type UpdateViewButtonGroupProps = {
hotkeyScope: string;
onViewEditModeChange?: () => void;
};
export const UpdateViewButtonGroup = ({
hotkeyScope,
onViewEditModeChange,
}: UpdateViewButtonGroupProps) => {
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const {
canPersistViewFields,
onCurrentViewSubmit,
ViewBarRecoilScopeContext,
} = useContext(ViewBarContext);
const recoilScopeId = useRecoilScopeId(ViewBarRecoilScopeContext);
const currentViewId = useRecoilScopedValue(
currentViewIdScopedState,
ViewBarRecoilScopeContext,
);
const filters = useRecoilScopedValue(
filtersScopedState,
ViewBarRecoilScopeContext,
);
const setSavedFilters = useSetRecoilState(
savedFiltersFamilyState(currentViewId),
);
const canPersistFilters = useRecoilValue(
canPersistFiltersScopedFamilySelector({
recoilScopeId,
viewId: currentViewId,
}),
);
const sorts = useRecoilScopedValue(
sortsScopedState,
ViewBarRecoilScopeContext,
);
const setSavedSorts = useSetRecoilState(savedSortsFamilyState(currentViewId));
const canPersistSorts = useRecoilValue(
canPersistSortsScopedFamilySelector({
recoilScopeId,
viewId: currentViewId,
}),
);
const setViewEditMode = useSetRecoilState(viewEditModeState);
const canPersistView =
currentViewId &&
(canPersistViewFields || canPersistFilters || canPersistSorts);
const handleArrowDownButtonClick = useCallback(() => {
setIsDropdownOpen((previousIsOpen) => !previousIsOpen);
}, []);
const handleCreateViewButtonClick = useCallback(() => {
setViewEditMode({ mode: 'create', viewId: undefined });
onViewEditModeChange?.();
setIsDropdownOpen(false);
}, [setViewEditMode, onViewEditModeChange]);
const handleDropdownClose = useCallback(() => {
setIsDropdownOpen(false);
}, []);
const handleViewSubmit = async () => {
if (canPersistFilters) setSavedFilters(filters);
if (canPersistSorts) setSavedSorts(sorts);
await onCurrentViewSubmit?.();
};
useScopedHotkeys(
[Key.Enter, Key.Escape],
handleDropdownClose,
hotkeyScope,
[],
);
if (!canPersistView) return null;
return (
<StyledContainer>
<ButtonGroup size="small" accent="blue">
<Button title="Update view" onClick={handleViewSubmit} />
<Button
size="small"
Icon={IconChevronDown}
onClick={handleArrowDownButtonClick}
/>
</ButtonGroup>
{isDropdownOpen && (
<DropdownMenuContainer onClose={handleDropdownClose}>
<DropdownMenuItemsContainer>
<MenuItem
onClick={handleCreateViewButtonClick}
LeftIcon={IconPlus}
text="Create view"
/>
</DropdownMenuItemsContainer>
</DropdownMenuContainer>
)}
</StyledContainer>
);
};

View File

@ -0,0 +1,65 @@
import { ReactNode } from 'react';
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
import { TopBar } from '@/ui/layout/top-bar/TopBar';
import { FiltersHotkeyScope } from '../types/FiltersHotkeyScope';
import { ViewsHotkeyScope } from '../types/ViewsHotkeyScope';
import { FilterDropdownButton } from './FilterDropdownButton';
import { SortDropdownButton } from './SortDropdownButton';
import { UpdateViewButtonGroup } from './UpdateViewButtonGroup';
import { ViewBarDetails } from './ViewBarDetails';
import { ViewsDropdownButton } from './ViewsDropdownButton';
export type ViewBarProps = {
className?: string;
optionsDropdownButton: ReactNode;
optionsDropdownScopeId: string;
};
export const ViewBar = ({
className,
optionsDropdownButton,
optionsDropdownScopeId,
}: ViewBarProps) => {
const { openDropdown: openOptionsDropdownButton } = useDropdown({
dropdownScopeId: optionsDropdownScopeId,
});
return (
<TopBar
className={className}
leftComponent={
<ViewsDropdownButton
onViewEditModeChange={openOptionsDropdownButton}
hotkeyScope={{ scope: ViewsHotkeyScope.ListDropdown }}
/>
}
displayBottomBorder={false}
rightComponent={
<>
<FilterDropdownButton
hotkeyScope={{ scope: FiltersHotkeyScope.FilterDropdownButton }}
/>
<SortDropdownButton
hotkeyScope={{ scope: FiltersHotkeyScope.SortDropdownButton }}
isPrimaryButton
/>
{optionsDropdownButton}
</>
}
bottomComponent={
<ViewBarDetails
hasFilterButton
rightComponent={
<UpdateViewButtonGroup
onViewEditModeChange={openOptionsDropdownButton}
hotkeyScope={ViewsHotkeyScope.CreateDropdown}
/>
}
/>
}
/>
);
};

View File

@ -0,0 +1,242 @@
import { ReactNode, useContext } from 'react';
import styled from '@emotion/styled';
import { useRecoilValue } from 'recoil';
import { IconArrowDown, IconArrowUp } from '@/ui/display/icon/index';
import { useRecoilScopedState } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedState';
import { useRecoilScopedValue } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedValue';
import { useRecoilScopeId } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopeId';
import { ViewBarContext } from '../contexts/ViewBarContext';
import { useRemoveFilter } from '../hooks/useRemoveFilter';
import { availableFiltersScopedState } from '../states/availableFiltersScopedState';
import { currentViewIdScopedState } from '../states/currentViewIdScopedState';
import { filtersScopedState } from '../states/filtersScopedState';
import { isViewBarExpandedScopedState } from '../states/isViewBarExpandedScopedState';
import { canPersistFiltersScopedFamilySelector } from '../states/selectors/canPersistFiltersScopedFamilySelector';
import { canPersistSortsScopedFamilySelector } from '../states/selectors/canPersistSortsScopedFamilySelector';
import { savedFiltersFamilySelector } from '../states/selectors/savedFiltersFamilySelector';
import { savedSortsFamilySelector } from '../states/selectors/savedSortsFamilySelector';
import { sortsScopedState } from '../states/sortsScopedState';
import { getOperandLabelShort } from '../utils/getOperandLabel';
import { AddFilterFromDropdownButton } from './AddFilterFromDetailsButton';
import SortOrFilterChip from './SortOrFilterChip';
export type ViewBarDetailsProps = {
hasFilterButton?: boolean;
rightComponent?: ReactNode;
};
const StyledBar = styled.div`
align-items: center;
border-top: 1px solid ${({ theme }) => theme.border.color.light};
display: flex;
flex-direction: row;
height: 40px;
justify-content: space-between;
z-index: 4;
`;
const StyledChipcontainer = styled.div`
align-items: center;
display: flex;
flex-direction: row;
gap: ${({ theme }) => theme.spacing(1)};
height: 40px;
justify-content: space-between;
margin-left: ${({ theme }) => theme.spacing(2)};
overflow-x: auto;
`;
const StyledCancelButton = styled.button`
background-color: inherit;
border: none;
color: ${({ theme }) => theme.font.color.tertiary};
cursor: pointer;
font-weight: ${({ theme }) => theme.font.weight.medium};
margin-left: auto;
margin-right: ${({ theme }) => theme.spacing(2)};
padding: ${(props) => {
const horiz = props.theme.spacing(2);
const vert = props.theme.spacing(1);
return `${vert} ${horiz} ${vert} ${horiz}`;
}};
user-select: none;
&:hover {
background-color: ${({ theme }) => theme.background.tertiary};
border-radius: ${({ theme }) => theme.spacing(1)};
}
`;
const StyledFilterContainer = styled.div`
align-items: center;
display: flex;
`;
const StyledSeperatorContainer = styled.div`
align-items: flex-start;
align-self: stretch;
display: flex;
padding-bottom: ${({ theme }) => theme.spacing(2)};
padding-left: ${({ theme }) => theme.spacing(1)};
padding-right: ${({ theme }) => theme.spacing(1)};
padding-top: ${({ theme }) => theme.spacing(2)};
`;
const StyledSeperator = styled.div`
align-self: stretch;
background: ${({ theme }) => theme.background.quaternary};
width: 1px;
`;
const StyledAddFilterContainer = styled.div`
margin-left: ${({ theme }) => theme.spacing(1)};
z-index: 5;
`;
export const ViewBarDetails = ({
hasFilterButton = false,
rightComponent,
}: ViewBarDetailsProps) => {
const { canPersistViewFields, onViewBarReset, ViewBarRecoilScopeContext } =
useContext(ViewBarContext);
const recoilScopeId = useRecoilScopeId(ViewBarRecoilScopeContext);
const currentViewId = useRecoilScopedValue(
currentViewIdScopedState,
ViewBarRecoilScopeContext,
);
const [filters, setFilters] = useRecoilScopedState(
filtersScopedState,
ViewBarRecoilScopeContext,
);
const savedFilters = useRecoilValue(
savedFiltersFamilySelector(currentViewId),
);
const savedSorts = useRecoilValue(savedSortsFamilySelector(currentViewId));
const [availableFilters] = useRecoilScopedState(
availableFiltersScopedState,
ViewBarRecoilScopeContext,
);
const canPersistFilters = useRecoilValue(
canPersistFiltersScopedFamilySelector({
recoilScopeId,
viewId: currentViewId,
}),
);
const [sorts, setSorts] = useRecoilScopedState(
sortsScopedState,
ViewBarRecoilScopeContext,
);
const canPersistSorts = useRecoilValue(
canPersistSortsScopedFamilySelector({
recoilScopeId,
viewId: currentViewId,
}),
);
const canPersistView =
canPersistViewFields || canPersistFilters || canPersistSorts;
const [isViewBarExpanded] = useRecoilScopedState(
isViewBarExpandedScopedState,
ViewBarRecoilScopeContext,
);
const filtersWithDefinition = filters.map((filter) => {
const filterDefinition = availableFilters.find((availableFilter) => {
return availableFilter.key === filter.key;
});
return {
...filter,
...filterDefinition,
};
});
const removeFilter = useRemoveFilter();
const handleCancelClick = () => {
onViewBarReset?.();
setFilters(savedFilters);
setSorts(savedSorts);
};
const handleSortRemove = (sortKey: string) =>
setSorts((previousSorts) =>
previousSorts.filter((sort) => sort.key !== sortKey),
);
const shouldExpandViewBar =
canPersistView ||
((filtersWithDefinition.length || sorts.length) && isViewBarExpanded);
if (!shouldExpandViewBar) {
return null;
}
return (
<StyledBar>
<StyledFilterContainer>
<StyledChipcontainer>
{sorts.map((sort) => {
return (
<SortOrFilterChip
key={sort.key}
testId={sort.key}
labelValue={sort.definition.label}
Icon={sort.direction === 'desc' ? IconArrowDown : IconArrowUp}
isSort
onRemove={() => handleSortRemove(sort.key)}
/>
);
})}
{!!sorts.length && !!filtersWithDefinition.length && (
<StyledSeperatorContainer>
<StyledSeperator />
</StyledSeperatorContainer>
)}
{filtersWithDefinition.map((filter) => {
return (
<SortOrFilterChip
key={filter.key}
testId={filter.key}
labelKey={filter.label}
labelValue={`${getOperandLabelShort(filter.operand)} ${
filter.displayValue
}`}
Icon={filter.Icon}
onRemove={() => {
removeFilter(filter.key);
}}
/>
);
})}
</StyledChipcontainer>
{hasFilterButton && (
<StyledAddFilterContainer>
<AddFilterFromDropdownButton />
</StyledAddFilterContainer>
)}
</StyledFilterContainer>
{canPersistView && (
<StyledCancelButton
data-testid="cancel-button"
onClick={handleCancelClick}
>
Reset
</StyledCancelButton>
)}
{rightComponent}
</StyledBar>
);
};

View File

@ -0,0 +1,49 @@
import { Keys } from 'react-hotkeys-hook';
import { Placement } from '@floating-ui/react';
import { DropdownMenu } from '@/ui/layout/dropdown/components/DropdownMenu';
import { DropdownScope } from '@/ui/layout/dropdown/scopes/DropdownScope';
import { HotkeyScope } from '@/ui/utilities/hotkey/types/HotkeyScope';
type ViewBarDropdownButtonProps = {
buttonComponent: JSX.Element | JSX.Element[];
dropdownComponents: JSX.Element | JSX.Element[];
dropdownId: string;
hotkey?: {
key: Keys;
scope: string;
};
dropdownHotkeyScope: HotkeyScope;
dropdownPlacement?: Placement;
onClickOutside?: () => void;
onClose?: () => void;
onOpen?: () => void;
};
export const ViewBarDropdownButton = ({
buttonComponent,
dropdownComponents,
dropdownId,
hotkey,
dropdownHotkeyScope,
dropdownPlacement = 'bottom-end',
onClickOutside,
onClose,
onOpen,
}: ViewBarDropdownButtonProps) => {
return (
<DropdownScope dropdownScopeId={dropdownId}>
<DropdownMenu
clickableComponent={buttonComponent}
dropdownComponents={dropdownComponents}
hotkey={hotkey}
dropdownHotkeyScope={dropdownHotkeyScope}
dropdownOffset={{ x: 0, y: 8 }}
dropdownPlacement={dropdownPlacement}
onClickOutside={onClickOutside}
onClose={onClose}
onOpen={onOpen}
/>
</DropdownScope>
);
};

View File

@ -0,0 +1,142 @@
import { useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import {
DropResult,
OnDragEndResponder,
ResponderProvided,
} from '@hello-pangea/dnd';
import { IconMinus, IconPlus } from '@/ui/display/icon';
import { AppTooltip } from '@/ui/display/tooltip/AppTooltip';
import { IconInfoCircle } from '@/ui/input/constants/icons';
import { DraggableItem } from '@/ui/layout/draggable-list/components/DraggableItem';
import { DraggableList } from '@/ui/layout/draggable-list/components/DraggableList';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { StyledDropdownMenuSubheader } from '@/ui/layout/dropdown/components/StyledDropdownMenuSubheader';
import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem';
import { MenuItemDraggable } from '@/ui/navigation/menu-item/components/MenuItemDraggable';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { isDefined } from '~/utils/isDefined';
import { ViewFieldForVisibility } from '../types/ViewFieldForVisibility';
type ViewFieldsVisibilityDropdownSectionProps = {
fields: ViewFieldForVisibility[];
onVisibilityChange: (field: ViewFieldForVisibility) => void;
title: string;
isDraggable: boolean;
onDragEnd?: OnDragEndResponder;
};
export const ViewFieldsVisibilityDropdownSection = ({
fields,
onVisibilityChange,
title,
isDraggable,
onDragEnd,
}: ViewFieldsVisibilityDropdownSectionProps) => {
const handleOnDrag = (result: DropResult, provided: ResponderProvided) => {
onDragEnd?.(result, provided);
};
const [openToolTipIndex, setOpenToolTipIndex] = useState<number>();
const handleInfoButtonClick = (index: number) => {
if (index === openToolTipIndex) setOpenToolTipIndex(undefined);
else setOpenToolTipIndex(index);
};
const getIconButtons = (index: number, field: ViewFieldForVisibility) => {
if (field.infoTooltipContent) {
return [
{
Icon: IconInfoCircle,
onClick: () => handleInfoButtonClick(index),
isActive: openToolTipIndex === index,
},
{
Icon: field.isVisible ? IconMinus : IconPlus,
onClick: () => onVisibilityChange(field),
},
];
}
if (!field.infoTooltipContent) {
return [
{
Icon: field.isVisible ? IconMinus : IconPlus,
onClick: () => onVisibilityChange(field),
},
];
}
};
const ref = useRef<HTMLDivElement>(null);
useListenClickOutside({
refs: [ref],
callback: () => {
setOpenToolTipIndex(undefined);
},
});
return (
<div ref={ref}>
<StyledDropdownMenuSubheader>{title}</StyledDropdownMenuSubheader>
<DropdownMenuItemsContainer>
{isDraggable ? (
<DraggableList
onDragEnd={handleOnDrag}
draggableItems={
<>
{fields
.filter(({ index, size }) => index !== 0 || !size)
.map((field, index) => (
<DraggableItem
key={field.key}
draggableId={field.key}
index={index + 1}
itemComponent={
<MenuItemDraggable
key={field.key}
LeftIcon={field.Icon}
iconButtons={getIconButtons(index + 1, field)}
isTooltipOpen={openToolTipIndex === index + 1}
text={field.name}
className={`${title}-draggable-item-tooltip-anchor-${
index + 1
}`}
/>
}
/>
))}
</>
}
/>
) : (
fields.map((field, index) => (
<MenuItem
key={field.key}
LeftIcon={field.Icon}
iconButtons={getIconButtons(index, field)}
isTooltipOpen={openToolTipIndex === index}
text={field.name}
className={`${title}-fixed-item-tooltip-anchor-${index}`}
/>
))
)}
</DropdownMenuItemsContainer>
{isDefined(openToolTipIndex) &&
createPortal(
<AppTooltip
anchorSelect={`.${title}-${
isDraggable ? 'draggable' : 'fixed'
}-item-tooltip-anchor-${openToolTipIndex}`}
place="left"
content={fields[openToolTipIndex].infoTooltipContent}
isOpen={true}
/>,
document.body,
)}
</div>
);
};

View File

@ -0,0 +1,214 @@
import { MouseEvent, useContext } from 'react';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import {
RecoilValueReadOnly,
useRecoilCallback,
useRecoilValue,
useSetRecoilState,
} from 'recoil';
import { currentViewIdScopedState } from '@/ui/data/view-bar/states/currentViewIdScopedState';
import { entityCountInCurrentViewState } from '@/ui/data/view-bar/states/entityCountInCurrentViewState';
import { filtersScopedState } from '@/ui/data/view-bar/states/filtersScopedState';
import { savedFiltersFamilyState } from '@/ui/data/view-bar/states/savedFiltersFamilyState';
import { savedSortsFamilyState } from '@/ui/data/view-bar/states/savedSortsFamilyState';
import { currentViewScopedSelector } from '@/ui/data/view-bar/states/selectors/currentViewScopedSelector';
import { sortsScopedState } from '@/ui/data/view-bar/states/sortsScopedState';
import { viewEditModeState } from '@/ui/data/view-bar/states/viewEditModeState';
import { viewsScopedState } from '@/ui/data/view-bar/states/viewsScopedState';
import {
IconChevronDown,
IconList,
IconPencil,
IconPlus,
IconTrash,
} from '@/ui/display/icon';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { StyledDropdownButtonContainer } from '@/ui/layout/dropdown/components/StyledDropdownButtonContainer';
import { StyledDropdownMenu } from '@/ui/layout/dropdown/components/StyledDropdownMenu';
import { StyledDropdownMenuSeparator } from '@/ui/layout/dropdown/components/StyledDropdownMenuSeparator';
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem';
import { MOBILE_VIEWPORT } from '@/ui/theme/constants/theme';
import { HotkeyScope } from '@/ui/utilities/hotkey/types/HotkeyScope';
import { useRecoilScopedState } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedState';
import { useRecoilScopedValue } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedValue';
import { useRecoilScopeId } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopeId';
import { assertNotNull } from '~/utils/assert';
import { ViewsDropdownId } from '../constants/ViewsDropdownId';
import { ViewBarContext } from '../contexts/ViewBarContext';
import { useRemoveView } from '../hooks/useRemoveView';
import { ViewBarDropdownButton } from './ViewBarDropdownButton';
const StyledBoldDropdownMenuItemsContainer = styled(DropdownMenuItemsContainer)`
font-weight: ${({ theme }) => theme.font.weight.regular};
`;
const StyledDropdownLabelAdornments = styled.span`
align-items: center;
color: ${({ theme }) => theme.grayScale.gray35};
display: inline-flex;
gap: ${({ theme }) => theme.spacing(1)};
margin-left: ${({ theme }) => theme.spacing(1)};
`;
const StyledViewIcon = styled(IconList)`
margin-right: ${({ theme }) => theme.spacing(1)};
`;
const StyledViewName = styled.span`
display: inline-block;
max-width: 130px;
@media (max-width: 375px) {
max-width: 90px;
}
@media (min-width: 376px) and (max-width: ${MOBILE_VIEWPORT}px) {
max-width: 110px;
}
overflow: hidden;
text-overflow: ellipsis;
vertical-align: middle;
white-space: nowrap;
`;
export type ViewsDropdownButtonProps = {
hotkeyScope: HotkeyScope;
onViewEditModeChange?: () => void;
};
export const ViewsDropdownButton = ({
hotkeyScope,
onViewEditModeChange,
}: ViewsDropdownButtonProps) => {
const theme = useTheme();
const { defaultViewName, onViewSelect, ViewBarRecoilScopeContext } =
useContext(ViewBarContext);
const recoilScopeId = useRecoilScopeId(ViewBarRecoilScopeContext);
const currentView = useRecoilScopedValue(
currentViewScopedSelector,
ViewBarRecoilScopeContext,
);
const [views] = useRecoilScopedState(
viewsScopedState,
ViewBarRecoilScopeContext,
);
const entityCount = useRecoilValue(
entityCountInCurrentViewState as RecoilValueReadOnly<number>,
);
const { isDropdownOpen, closeDropdown } = useDropdown({
dropdownScopeId: ViewsDropdownId,
});
const setViewEditMode = useSetRecoilState(viewEditModeState);
const handleViewSelect = useRecoilCallback(
({ set, snapshot }) =>
async (viewId: string) => {
await onViewSelect?.(viewId);
const savedFilters = await snapshot.getPromise(
savedFiltersFamilyState(viewId),
);
const savedSorts = await snapshot.getPromise(
savedSortsFamilyState(viewId),
);
set(filtersScopedState(recoilScopeId), savedFilters);
set(sortsScopedState(recoilScopeId), savedSorts);
set(currentViewIdScopedState(recoilScopeId), viewId);
closeDropdown();
},
[onViewSelect, recoilScopeId, closeDropdown],
);
const handleAddViewButtonClick = () => {
setViewEditMode({ mode: 'create', viewId: undefined });
onViewEditModeChange?.();
closeDropdown();
};
const handleEditViewButtonClick = (
event: MouseEvent<HTMLButtonElement>,
viewId: string,
) => {
event.stopPropagation();
setViewEditMode({ mode: 'edit', viewId });
onViewEditModeChange?.();
closeDropdown();
};
const { removeView } = useRemoveView();
const handleDeleteViewButtonClick = async (
event: MouseEvent<HTMLButtonElement>,
viewId: string,
) => {
event.stopPropagation();
await removeView(viewId);
closeDropdown();
};
return (
<ViewBarDropdownButton
dropdownId={ViewsDropdownId}
dropdownHotkeyScope={hotkeyScope}
buttonComponent={
<StyledDropdownButtonContainer isUnfolded={isDropdownOpen}>
<StyledViewIcon size={theme.icon.size.md} />
<StyledViewName>
{currentView?.name || defaultViewName}
</StyledViewName>
<StyledDropdownLabelAdornments>
· {entityCount} <IconChevronDown size={theme.icon.size.sm} />
</StyledDropdownLabelAdornments>
</StyledDropdownButtonContainer>
}
dropdownComponents={
<StyledDropdownMenu width={200}>
<DropdownMenuItemsContainer>
{views.map((view) => (
<MenuItem
key={view.id}
iconButtons={[
{
Icon: IconPencil,
onClick: (event: MouseEvent<HTMLButtonElement>) =>
handleEditViewButtonClick(event, view.id),
},
views.length > 1
? {
Icon: IconTrash,
onClick: (event: MouseEvent<HTMLButtonElement>) =>
handleDeleteViewButtonClick(event, view.id),
}
: null,
].filter(assertNotNull)}
onClick={() => handleViewSelect(view.id)}
LeftIcon={IconList}
text={view.name}
/>
))}
</DropdownMenuItemsContainer>
<StyledDropdownMenuSeparator />
<StyledBoldDropdownMenuItemsContainer>
<MenuItem
onClick={handleAddViewButtonClick}
LeftIcon={IconPlus}
text="Add view"
/>
</StyledBoldDropdownMenuItemsContainer>
</StyledDropdownMenu>
}
/>
);
};

View File

@ -0,0 +1 @@
export const FilterDropdownId = 'filter';

View File

@ -0,0 +1 @@
export const SortDropdownId = 'sort-dropdown';

View File

@ -0,0 +1 @@
export const ViewsDropdownId = 'views';

View File

@ -0,0 +1,20 @@
import { createContext } from 'react';
import { RecoilScopeContext } from '@/types/RecoilScopeContext';
import { View } from '../types/View';
export const ViewBarContext = createContext<{
canPersistViewFields?: boolean;
defaultViewName?: string;
onCurrentViewSubmit?: () => void | Promise<void>;
onViewBarReset?: () => void;
onViewCreate?: (view: View) => void | Promise<void>;
onViewEdit?: (view: View) => void | Promise<void>;
onViewRemove?: (viewId: string) => void | Promise<void>;
onViewSelect?: (viewId: string) => void | Promise<void>;
onImport?: () => void | Promise<void>;
ViewBarRecoilScopeContext: RecoilScopeContext;
}>({
ViewBarRecoilScopeContext: createContext<string | null>(null),
});

View File

@ -0,0 +1,28 @@
import { useMemo } from 'react';
import { useRecoilScopedState } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedState';
import { filterDefinitionUsedInDropdownScopedState } from '../states/filterDefinitionUsedInDropdownScopedState';
import { filtersScopedState } from '../states/filtersScopedState';
import { useViewBarContext } from './useViewBarContext';
export const useFilterCurrentlyEdited = () => {
const { ViewBarRecoilScopeContext } = useViewBarContext();
const [filters] = useRecoilScopedState(
filtersScopedState,
ViewBarRecoilScopeContext,
);
const [filterDefinitionUsedInDropdown] = useRecoilScopedState(
filterDefinitionUsedInDropdownScopedState,
ViewBarRecoilScopeContext,
);
return useMemo(() => {
return filters.find(
(filter) => filter.key === filterDefinitionUsedInDropdown?.key,
);
}, [filterDefinitionUsedInDropdown, filters]);
};

View File

@ -0,0 +1,25 @@
import { useContext } from 'react';
import { useRecoilScopedState } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedState';
import { ViewBarContext } from '../contexts/ViewBarContext';
import { filtersScopedState } from '../states/filtersScopedState';
export const useRemoveFilter = () => {
const { ViewBarRecoilScopeContext } = useContext(ViewBarContext);
const [, setFilters] = useRecoilScopedState(
filtersScopedState,
ViewBarRecoilScopeContext,
);
const removeFilter = (filterKey: string) => {
setFilters((filters) => {
return filters.filter((filter) => {
return filter.key !== filterKey;
});
});
};
return removeFilter;
};

View File

@ -0,0 +1,35 @@
import { useContext } from 'react';
import { useRecoilCallback } from 'recoil';
import { useRecoilScopeId } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopeId';
import { ViewBarContext } from '../contexts/ViewBarContext';
import { currentViewIdScopedState } from '../states/currentViewIdScopedState';
import { viewsScopedState } from '../states/viewsScopedState';
export const useRemoveView = () => {
const { onViewRemove, ViewBarRecoilScopeContext } =
useContext(ViewBarContext);
const recoilScopeId = useRecoilScopeId(ViewBarRecoilScopeContext);
const removeView = useRecoilCallback(
({ set, snapshot }) =>
async (viewId: string) => {
const currentViewId = await snapshot.getPromise(
currentViewIdScopedState(recoilScopeId),
);
if (currentViewId === viewId)
set(currentViewIdScopedState(recoilScopeId), undefined);
set(viewsScopedState(recoilScopeId), (previousViews) =>
previousViews.filter((view) => view.id !== viewId),
);
await onViewRemove?.(viewId);
},
[onViewRemove, recoilScopeId],
);
return { removeView };
};

View File

@ -0,0 +1,35 @@
import { produce } from 'immer';
import { useRecoilScopedState } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedState';
import { filtersScopedState } from '../states/filtersScopedState';
import { Filter } from '../types/Filter';
import { useViewBarContext } from './useViewBarContext';
export const useUpsertFilter = () => {
const { ViewBarRecoilScopeContext } = useViewBarContext();
const [, setFilters] = useRecoilScopedState(
filtersScopedState,
ViewBarRecoilScopeContext,
);
const upsertFilter = (filterToUpsert: Filter) => {
setFilters((filters) => {
return produce(filters, (filtersDraft) => {
const index = filtersDraft.findIndex(
(filter) => filter.key === filterToUpsert.key,
);
if (index === -1) {
filtersDraft.push(filterToUpsert);
} else {
filtersDraft[index] = filterToUpsert;
}
});
});
};
return upsertFilter;
};

View File

@ -0,0 +1,109 @@
import { useContext } from 'react';
import { useRecoilCallback, useRecoilValue, useResetRecoilState } from 'recoil';
import { v4 } from 'uuid';
import { useRecoilScopedValue } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedValue';
import { useRecoilScopeId } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopeId';
import { ViewBarContext } from '../contexts/ViewBarContext';
import { currentViewIdScopedState } from '../states/currentViewIdScopedState';
import { filtersScopedState } from '../states/filtersScopedState';
import { savedFiltersFamilyState } from '../states/savedFiltersFamilyState';
import { savedSortsFamilyState } from '../states/savedSortsFamilyState';
import { currentViewScopedSelector } from '../states/selectors/currentViewScopedSelector';
import { viewsByIdScopedSelector } from '../states/selectors/viewsByIdScopedSelector';
import { sortsScopedState } from '../states/sortsScopedState';
import { viewEditModeState } from '../states/viewEditModeState';
import { viewsScopedState } from '../states/viewsScopedState';
export const useUpsertView = () => {
const { onViewCreate, onViewEdit, ViewBarRecoilScopeContext } =
useContext(ViewBarContext);
const recoilScopeId = useRecoilScopeId(ViewBarRecoilScopeContext);
const filters = useRecoilScopedValue(
filtersScopedState,
ViewBarRecoilScopeContext,
);
const sorts = useRecoilScopedValue(
sortsScopedState,
ViewBarRecoilScopeContext,
);
const viewEditMode = useRecoilValue(viewEditModeState);
const resetViewEditMode = useResetRecoilState(viewEditModeState);
const upsertView = useRecoilCallback(
({ set, snapshot }) =>
async (name?: string) => {
if (!viewEditMode.mode || !name) {
resetViewEditMode();
return;
}
if (viewEditMode.mode === 'create') {
const createdView = { id: v4(), name };
set(savedFiltersFamilyState(createdView.id), filters);
set(savedSortsFamilyState(createdView.id), sorts);
set(viewsScopedState(recoilScopeId), (previousViews) => [
...previousViews,
createdView,
]);
await onViewCreate?.(createdView);
resetViewEditMode();
set(currentViewIdScopedState(recoilScopeId), createdView.id);
return createdView;
}
const viewsById = await snapshot.getPromise(
viewsByIdScopedSelector(recoilScopeId),
);
const currentView = await snapshot.getPromise(
currentViewScopedSelector(recoilScopeId),
);
const viewToEdit = viewEditMode.viewId
? viewsById[viewEditMode.viewId]
: currentView;
if (!viewToEdit) {
resetViewEditMode();
return;
}
const editedView = {
...viewToEdit,
name,
};
set(viewsScopedState(recoilScopeId), (previousViews) =>
previousViews.map((previousView) =>
previousView.id === editedView.id ? editedView : previousView,
),
);
await onViewEdit?.(editedView);
resetViewEditMode();
return editedView;
},
[
filters,
onViewCreate,
onViewEdit,
recoilScopeId,
resetViewEditMode,
sorts,
viewEditMode.mode,
viewEditMode.viewId,
],
);
return { upsertView };
};

View File

@ -0,0 +1,7 @@
import { useContext } from 'react';
import { ViewBarContext } from '../contexts/ViewBarContext';
export const useViewBarContext = () => {
return useContext(ViewBarContext);
};

View File

@ -0,0 +1,11 @@
import { atomFamily } from 'recoil';
import { FilterDefinition } from '../types/FilterDefinition';
export const availableFiltersScopedState = atomFamily<
FilterDefinition[],
string
>({
key: 'availableFiltersScopedState',
default: [],
});

View File

@ -0,0 +1,8 @@
import { atomFamily } from 'recoil';
import { SortDefinition } from '../types/SortDefinition';
export const availableSortsScopedState = atomFamily<SortDefinition[], string>({
key: 'availableSortsScopedState',
default: [],
});

View File

@ -0,0 +1,6 @@
import { atomFamily } from 'recoil';
export const currentViewIdScopedState = atomFamily<string | undefined, string>({
key: 'currentViewIdScopedState',
default: undefined,
});

View File

@ -0,0 +1,6 @@
import { atom } from 'recoil';
export const entityCountInCurrentViewState = atom<number>({
key: 'entityCountInCurrentViewState',
default: 0,
});

View File

@ -0,0 +1,11 @@
import { atomFamily } from 'recoil';
import { FilterDefinition } from '../types/FilterDefinition';
export const filterDefinitionUsedInDropdownScopedState = atomFamily<
FilterDefinition | null,
string
>({
key: 'filterDefinitionUsedInDropdownScopedState',
default: null,
});

View File

@ -0,0 +1,6 @@
import { atomFamily } from 'recoil';
export const filterDropdownSearchInputScopedState = atomFamily<string, string>({
key: 'filterDropdownSearchInputScopedState',
default: '',
});

View File

@ -0,0 +1,9 @@
import { atomFamily } from 'recoil';
export const filterDropdownSelectedEntityIdScopedState = atomFamily<
string | null,
string
>({
key: 'filterDropdownSelectedEntityIdScopedState',
default: null,
});

View File

@ -0,0 +1,8 @@
import { atomFamily } from 'recoil';
import { Filter } from '../types/Filter';
export const filtersScopedState = atomFamily<Filter[], string>({
key: 'filtersScopedState',
default: [],
});

View File

@ -0,0 +1,9 @@
import { atomFamily } from 'recoil';
export const isFilterDropdownOperandSelectUnfoldedScopedState = atomFamily<
boolean,
string
>({
key: 'isFilterDropdownOperandSelectUnfoldedScopedState',
default: false,
});

View File

@ -0,0 +1,6 @@
import { atomFamily } from 'recoil';
export const isFilterDropdownUnfoldedScopedState = atomFamily<boolean, string>({
key: 'isFilterDropdownUnfoldedScopedState',
default: false,
});

View File

@ -0,0 +1,6 @@
import { atomFamily } from 'recoil';
export const isViewBarExpandedScopedState = atomFamily<boolean, string>({
key: 'isViewBarExpandedScopedState',
default: true,
});

View File

@ -0,0 +1,10 @@
import { atomFamily } from 'recoil';
import { Filter } from '../types/Filter';
export const savedFiltersFamilyState = atomFamily<Filter[], string | undefined>(
{
key: 'savedFiltersFamilyState',
default: [],
},
);

View File

@ -0,0 +1,8 @@
import { atomFamily } from 'recoil';
import { Sort } from '../types/Sort';
export const savedSortsFamilyState = atomFamily<Sort[], string | undefined>({
key: 'savedSortsFamilyState',
default: [],
});

View File

@ -0,0 +1,11 @@
import { atomFamily } from 'recoil';
import { ViewFilterOperand } from '~/generated/graphql';
export const selectedOperandInDropdownScopedState = atomFamily<
ViewFilterOperand | null,
string
>({
key: 'selectedOperandInDropdownScopedState',
default: null,
});

View File

@ -0,0 +1,23 @@
import { selectorFamily } from 'recoil';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
import { filtersScopedState } from '../filtersScopedState';
import { savedFiltersFamilyState } from '../savedFiltersFamilyState';
export const canPersistFiltersScopedFamilySelector = selectorFamily({
key: 'canPersistFiltersScopedFamilySelector',
get:
({
recoilScopeId,
viewId,
}: {
recoilScopeId: string;
viewId: string | undefined;
}) =>
({ get }) =>
!isDeeplyEqual(
get(savedFiltersFamilyState(viewId)),
get(filtersScopedState(recoilScopeId)),
),
});

View File

@ -0,0 +1,23 @@
import { selectorFamily } from 'recoil';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
import { savedSortsFamilyState } from '../savedSortsFamilyState';
import { sortsScopedState } from '../sortsScopedState';
export const canPersistSortsScopedFamilySelector = selectorFamily({
key: 'canPersistSortsScopedFamilySelector',
get:
({
recoilScopeId,
viewId,
}: {
recoilScopeId: string;
viewId: string | undefined;
}) =>
({ get }) =>
!isDeeplyEqual(
get(savedSortsFamilyState(viewId)),
get(sortsScopedState(recoilScopeId)),
),
});

View File

@ -0,0 +1,21 @@
import { selectorFamily } from 'recoil';
import { View } from '../../types/View';
import { currentViewIdScopedState } from '../currentViewIdScopedState';
import { viewsByIdScopedSelector } from './viewsByIdScopedSelector';
export const currentViewScopedSelector = selectorFamily<
View | undefined,
string
>({
key: 'currentViewScopedSelector',
get:
(scopeId) =>
({ get }) => {
const currentViewId = get(currentViewIdScopedState(scopeId));
return currentViewId
? get(viewsByIdScopedSelector(scopeId))[currentViewId]
: undefined;
},
});

View File

@ -0,0 +1,13 @@
import { selectorFamily } from 'recoil';
import { turnFilterIntoWhereClause } from '../../utils/turnFilterIntoWhereClause';
import { filtersScopedState } from '../filtersScopedState';
export const filtersWhereScopedSelector = selectorFamily({
key: 'filtersWhereScopedSelector',
get:
(param: string) =>
({ get }) => ({
AND: get(filtersScopedState(param)).map(turnFilterIntoWhereClause),
}),
});

View File

@ -0,0 +1,15 @@
import { selectorFamily } from 'recoil';
import { Filter } from '../../types/Filter';
import { savedFiltersFamilyState } from '../savedFiltersFamilyState';
export const savedFiltersByKeyFamilySelector = selectorFamily({
key: 'savedFiltersByKeyFamilySelector',
get:
(viewId: string | undefined) =>
({ get }) =>
get(savedFiltersFamilyState(viewId)).reduce<Record<string, Filter>>(
(result, filter) => ({ ...result, [filter.key]: filter }),
{},
),
});

View File

@ -0,0 +1,11 @@
import { selectorFamily } from 'recoil';
import { savedFiltersFamilyState } from '../savedFiltersFamilyState';
export const savedFiltersFamilySelector = selectorFamily({
key: 'savedFiltersFamilySelector',
get:
(viewId: string | undefined) =>
({ get }) =>
get(savedFiltersFamilyState(viewId)),
});

View File

@ -0,0 +1,15 @@
import { selectorFamily } from 'recoil';
import { Sort } from '../../types/Sort';
import { savedSortsFamilyState } from '../savedSortsFamilyState';
export const savedSortsByKeyFamilySelector = selectorFamily({
key: 'savedSortsByKeyFamilySelector',
get:
(viewId: string | undefined) =>
({ get }) =>
get(savedSortsFamilyState(viewId)).reduce<Record<string, Sort>>(
(result, sort) => ({ ...result, [sort.key]: sort }),
{},
),
});

View File

@ -0,0 +1,11 @@
import { selectorFamily } from 'recoil';
import { savedSortsFamilyState } from '../savedSortsFamilyState';
export const savedSortsFamilySelector = selectorFamily({
key: 'savedSortsFamilySelector',
get:
(viewId: string | undefined) =>
({ get }) =>
get(savedSortsFamilyState(viewId)),
});

View File

@ -0,0 +1,16 @@
import { selectorFamily } from 'recoil';
import { SortOrder } from '~/generated/graphql';
import { reduceSortsToOrderBy } from '../../utils/helpers';
import { sortsScopedState } from '../sortsScopedState';
export const sortsOrderByScopedSelector = selectorFamily({
key: 'sortsOrderByScopedSelector',
get:
(scopeId: string) =>
({ get }) => {
const orderBy = reduceSortsToOrderBy(get(sortsScopedState(scopeId)));
return orderBy.length ? orderBy : [{ createdAt: SortOrder.Desc }];
},
});

View File

@ -0,0 +1,18 @@
import { selectorFamily } from 'recoil';
import { View } from '../../types/View';
import { viewsScopedState } from '../viewsScopedState';
export const viewsByIdScopedSelector = selectorFamily<
Record<string, View>,
string
>({
key: 'viewsByIdScopedSelector',
get:
(scopeId) =>
({ get }) =>
get(viewsScopedState(scopeId)).reduce<Record<string, View>>(
(result, view) => ({ ...result, [view.id]: view }),
{},
),
});

View File

@ -0,0 +1,8 @@
import { atomFamily } from 'recoil';
import { Sort } from '../types/Sort';
export const sortsScopedState = atomFamily<Sort[], string>({
key: 'sortsScopedState',
default: [],
});

View File

@ -0,0 +1,9 @@
import { atom } from 'recoil';
export const viewEditModeState = atom<{
mode: 'create' | 'edit';
viewId: string | undefined;
}>({
key: 'viewEditModeState',
default: { mode: 'edit', viewId: undefined },
});

View File

@ -0,0 +1,8 @@
import { atomFamily } from 'recoil';
import { View } from '../types/View';
export const viewsScopedState = atomFamily<View[], string>({
key: 'viewsScopedState',
default: [],
});

View File

@ -0,0 +1,12 @@
import { ViewFilterOperand } from '~/generated/graphql';
import { FilterType } from './FilterType';
export type Filter = {
key: string;
type: FilterType;
value: string;
displayValue: string;
displayAvatarUrl?: string;
operand: ViewFilterOperand;
};

View File

@ -0,0 +1,13 @@
import { IconComponent } from '@/ui/display/icon/types/IconComponent';
import { FilterType } from './FilterType';
export type FilterDefinition = {
key: string;
label: string;
Icon: IconComponent;
type: FilterType;
entitySelectComponent?: JSX.Element;
selectAllLabel?: string;
SelectAllIcon?: IconComponent;
};

View File

@ -0,0 +1,5 @@
import { FilterDefinition } from './FilterDefinition';
export type FilterDefinitionByEntity<T> = FilterDefinition & {
key: keyof T;
};

View File

@ -0,0 +1 @@
export { ViewFilterOperand as FilterOperand } from '~/generated/graphql';

View File

@ -0,0 +1 @@
export type FilterType = 'text' | 'date' | 'entity' | 'number';

View File

@ -0,0 +1,4 @@
export enum FiltersHotkeyScope {
FilterDropdownButton = 'filter-dropdown-button',
SortDropdownButton = 'sort-dropdown-button',
}

View File

@ -0,0 +1,8 @@
import { SortDefinition } from './SortDefinition';
import { SortDirection } from './SortDirection';
export type Sort = {
key: string;
direction: SortDirection;
definition: SortDefinition;
};

View File

@ -0,0 +1,10 @@
import { IconComponent } from '@/ui/display/icon/types/IconComponent';
import { SortDirection } from './SortDirection';
export type SortDefinition = {
key: string;
label: string;
Icon?: IconComponent;
getOrderByTemplate?: (direction: SortDirection) => any[];
};

View File

@ -0,0 +1,3 @@
export const SORT_DIRECTIONS = ['asc', 'desc'] as const;
export type SortDirection = (typeof SORT_DIRECTIONS)[number];

View File

@ -0,0 +1 @@
export type View = { id: string; name: string };

View File

@ -0,0 +1,11 @@
import { FieldDefinition } from '@/ui/data/field/types/FieldDefinition';
import { FieldMetadata } from '@/ui/data/field/types/FieldMetadata';
export type ViewFieldForVisibility = Pick<
FieldDefinition<FieldMetadata>,
'key' | 'name' | 'Icon' | 'infoTooltipContent'
> & {
isVisible?: boolean;
index: number;
size?: number | undefined;
};

View File

@ -0,0 +1,4 @@
export enum ViewsHotkeyScope {
ListDropdown = 'views-list-dropdown',
CreateDropdown = 'views-create-dropdown',
}

View File

@ -0,0 +1,13 @@
import { IconComponent } from '@/ui/display/icon/types/IconComponent';
import { SortOrder as Order_By } from '~/generated/graphql';
export type SortType<OrderByTemplate> = {
label: string;
key: string;
Icon?: IconComponent;
orderByTemplate?: (order: Order_By) => OrderByTemplate[];
};
export type SelectedSortType<OrderByTemplate> = SortType<OrderByTemplate> & {
order: 'asc' | 'desc';
};

View File

@ -0,0 +1,45 @@
import { ViewFilterOperand } from '~/generated/graphql';
export const getOperandLabel = (
operand: ViewFilterOperand | null | undefined,
) => {
switch (operand) {
case ViewFilterOperand.Contains:
return 'Contains';
case ViewFilterOperand.DoesNotContain:
return "Doesn't contain";
case ViewFilterOperand.GreaterThan:
return 'Greater than';
case ViewFilterOperand.LessThan:
return 'Less than';
case ViewFilterOperand.Is:
return 'Is';
case ViewFilterOperand.IsNot:
return 'Is not';
case ViewFilterOperand.IsNotNull:
return 'Is not null';
default:
return '';
}
};
export const getOperandLabelShort = (
operand: ViewFilterOperand | null | undefined,
) => {
switch (operand) {
case ViewFilterOperand.Is:
case ViewFilterOperand.Contains:
return ': ';
case ViewFilterOperand.IsNot:
case ViewFilterOperand.DoesNotContain:
return ': Not';
case ViewFilterOperand.IsNotNull:
return ': NotNull';
case ViewFilterOperand.GreaterThan:
return '\u00A0> ';
case ViewFilterOperand.LessThan:
return '\u00A0< ';
default:
return ': ';
}
};

View File

@ -0,0 +1,19 @@
import { ViewFilterOperand } from '~/generated/graphql';
import { FilterType } from '../types/FilterType';
export const getOperandsForFilterType = (
filterType: FilterType | null | undefined,
): ViewFilterOperand[] => {
switch (filterType) {
case 'text':
return [ViewFilterOperand.Contains, ViewFilterOperand.DoesNotContain];
case 'number':
case 'date':
return [ViewFilterOperand.GreaterThan, ViewFilterOperand.LessThan];
case 'entity':
return [ViewFilterOperand.Is, ViewFilterOperand.IsNot];
default:
return [];
}
};

View File

@ -0,0 +1,16 @@
import { SortOrder as Order_By } from '~/generated/graphql';
import { Sort } from '../types/Sort';
export const reduceSortsToOrderBy = (sorts: Sort[]): any[] =>
sorts
.map((sort) => {
const direction = sort.direction === 'asc' ? Order_By.Asc : Order_By.Desc;
if (sort.definition.getOrderByTemplate) {
return sort.definition.getOrderByTemplate(direction);
} else {
return [{ [sort.definition.key]: direction }];
}
})
.flat();

View File

@ -0,0 +1,99 @@
import { QueryMode, ViewFilterOperand } from '~/generated/graphql';
import { Filter } from '../types/Filter';
export const turnFilterIntoWhereClause = (filter: Filter) => {
switch (filter.operand) {
case ViewFilterOperand.IsNotNull:
return {
[filter.key]: {
not: null,
},
};
default:
switch (filter.type) {
case 'text':
switch (filter.operand) {
case ViewFilterOperand.Contains:
return {
[filter.key]: {
contains: filter.value,
mode: QueryMode.Insensitive,
},
};
case ViewFilterOperand.DoesNotContain:
return {
[filter.key]: {
not: {
contains: filter.value,
mode: QueryMode.Insensitive,
},
},
};
default:
throw new Error(
`Unknown operand ${filter.operand} for ${filter.type} filter`,
);
}
case 'number':
switch (filter.operand) {
case ViewFilterOperand.GreaterThan:
return {
[filter.key]: {
gte: parseFloat(filter.value),
},
};
case ViewFilterOperand.LessThan:
return {
[filter.key]: {
lte: parseFloat(filter.value),
},
};
default:
throw new Error(
`Unknown operand ${filter.operand} for ${filter.type} filter`,
);
}
case 'date':
switch (filter.operand) {
case ViewFilterOperand.GreaterThan:
return {
[filter.key]: {
gte: filter.value,
},
};
case ViewFilterOperand.LessThan:
return {
[filter.key]: {
lte: filter.value,
},
};
default:
throw new Error(
`Unknown operand ${filter.operand} for ${filter.type} filter`,
);
}
case 'entity':
switch (filter.operand) {
case ViewFilterOperand.Is:
return {
[filter.key]: {
equals: filter.value,
},
};
case ViewFilterOperand.IsNot:
return {
[filter.key]: {
not: { equals: filter.value },
},
};
default:
throw new Error(
`Unknown operand ${filter.operand} for ${filter.type} filter`,
);
}
default:
throw new Error('Unknown filter type');
}
}
};