refactor: add ViewBar and move view components to ui/view-bar (#1495)

Closes #1494
This commit is contained in:
Thaïs
2023-09-08 11:57:16 +02:00
committed by GitHub
parent ccb57c91a3
commit df17da80fc
22 changed files with 325 additions and 376 deletions

View File

@ -5,15 +5,15 @@ import { StyledDropdownMenuItemsContainer } from '@/ui/dropdown/components/Style
import { StyledDropdownMenuSeparator } from '@/ui/dropdown/components/StyledDropdownMenuSeparator';
import { IconChevronDown } from '@/ui/icon';
import { MenuItem } from '@/ui/menu-item/components/MenuItem';
import { useRecoilScopedState } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedState';
import { sortsScopedState } from '../states/sortsScopedState';
import { FiltersHotkeyScope } from '../types/FiltersHotkeyScope';
import { SelectedSortType, SortType } from '../types/interface';
import DropdownButton from './DropdownButton';
type OwnProps<SortField> = {
isSortSelected: boolean;
onSortSelect: (sort: SelectedSortType<SortField>) => void;
export type SortDropdownButtonProps<SortField> = {
availableSorts: SortType<SortField>[];
HotkeyScope: FiltersHotkeyScope;
context: Context<string | null>;
@ -23,21 +23,37 @@ type OwnProps<SortField> = {
const options: Array<SelectedSortType<any>['order']> = ['asc', 'desc'];
export function SortDropdownButton<SortField>({
isSortSelected,
context,
availableSorts,
onSortSelect,
HotkeyScope,
}: OwnProps<SortField>) {
}: SortDropdownButtonProps<SortField>) {
const [isUnfolded, setIsUnfolded] = useState(false);
const [isOptionUnfolded, setIsOptionUnfolded] = useState(false);
const [selectedSortDirection, setSelectedSortDirection] =
useState<SelectedSortType<SortField>['order']>('asc');
const [sorts, setSorts] = useRecoilScopedState<SelectedSortType<SortField>[]>(
sortsScopedState,
context,
);
const isSortSelected = sorts.length > 0;
const onSortItemSelect = useCallback(
(sort: SortType<SortField>) => {
onSortSelect({ ...sort, order: selectedSortDirection });
const newSort = { ...sort, order: selectedSortDirection };
const sortIndex = sorts.findIndex((sort) => sort.key === newSort.key);
const newSorts = [...sorts];
if (sortIndex !== -1) {
newSorts[sortIndex] = newSort;
} else {
newSorts.push(newSort);
}
setSorts(newSorts);
},
[onSortSelect, selectedSortDirection],
[selectedSortDirection, setSorts, sorts],
);
const resetState = useCallback(() => {
@ -46,12 +62,8 @@ export function SortDropdownButton<SortField>({
}, []);
function handleIsUnfoldedChange(newIsUnfolded: boolean) {
if (newIsUnfolded) {
setIsUnfolded(true);
} else {
setIsUnfolded(false);
resetState();
}
setIsUnfolded(newIsUnfolded);
if (!newIsUnfolded) resetState();
}
function handleAddSort(sort: SortType<SortField>) {

View File

@ -0,0 +1,129 @@
import { type Context, useCallback, useState } from 'react';
import styled from '@emotion/styled';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import { Key } from 'ts-key-enum';
import { Button } from '@/ui/button/components/Button';
import { ButtonGroup } from '@/ui/button/components/ButtonGroup';
import { StyledDropdownMenuItemsContainer } from '@/ui/dropdown/components/StyledDropdownMenuItemsContainer';
import { IconChevronDown, IconPlus } from '@/ui/icon';
import { MenuItem } from '@/ui/menu-item/components/MenuItem';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { useContextScopeId } from '@/ui/utilities/recoil-scope/hooks/useContextScopeId';
import { useRecoilScopedValue } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedValue';
import { DropdownMenuContainer } from '@/ui/view-bar/components/DropdownMenuContainer';
import { currentViewIdScopedState } from '@/ui/view-bar/states/currentViewIdScopedState';
import { filtersScopedState } from '@/ui/view-bar/states/filtersScopedState';
import { savedFiltersFamilyState } from '@/ui/view-bar/states/savedFiltersFamilyState';
import { savedSortsFamilyState } from '@/ui/view-bar/states/savedSortsFamilyState';
import { canPersistFiltersScopedFamilySelector } from '@/ui/view-bar/states/selectors/canPersistFiltersScopedFamilySelector';
import { canPersistSortsScopedFamilySelector } from '@/ui/view-bar/states/selectors/canPersistSortsScopedFamilySelector';
import { sortsScopedState } from '@/ui/view-bar/states/sortsScopedState';
import { viewEditModeState } from '@/ui/view-bar/states/viewEditModeState';
const StyledContainer = styled.div`
display: inline-flex;
margin-right: ${({ theme }) => theme.spacing(2)};
position: relative;
`;
export type UpdateViewButtonGroupProps = {
canPersistViewFields?: boolean;
HotkeyScope: string;
onViewEditModeChange?: () => void;
onViewSubmit?: () => void | Promise<void>;
scopeContext: Context<string | null>;
};
export const UpdateViewButtonGroup = ({
canPersistViewFields,
HotkeyScope,
onViewEditModeChange,
onViewSubmit,
scopeContext,
}: UpdateViewButtonGroupProps) => {
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const recoilScopeId = useContextScopeId(scopeContext);
const currentViewId = useRecoilScopedValue(
currentViewIdScopedState,
scopeContext,
);
const filters = useRecoilScopedValue(filtersScopedState, scopeContext);
const setSavedFilters = useSetRecoilState(
savedFiltersFamilyState(currentViewId),
);
const canPersistFilters = useRecoilValue(
canPersistFiltersScopedFamilySelector([recoilScopeId, currentViewId]),
);
const sorts = useRecoilScopedValue(sortsScopedState, scopeContext);
const setSavedSorts = useSetRecoilState(savedSortsFamilyState(currentViewId));
const canPersistSorts = useRecoilValue(
canPersistSortsScopedFamilySelector([recoilScopeId, currentViewId]),
);
const setViewEditMode = useSetRecoilState(viewEditModeState);
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 onViewSubmit?.();
};
useScopedHotkeys(
[Key.Enter, Key.Escape],
handleDropdownClose,
HotkeyScope,
[],
);
return (
<StyledContainer>
<ButtonGroup size="small" accent="blue">
<Button
title="Update view"
disabled={
!currentViewId ||
(!canPersistViewFields && !canPersistFilters && !canPersistSorts)
}
onClick={handleViewSubmit}
/>
<Button
size="small"
icon={<IconChevronDown />}
onClick={handleArrowDownButtonClick}
/>
</ButtonGroup>
{isDropdownOpen && (
<DropdownMenuContainer onClose={handleDropdownClose}>
<StyledDropdownMenuItemsContainer>
<MenuItem
onClick={handleCreateViewButtonClick}
LeftIcon={IconPlus}
text="Create view"
/>
</StyledDropdownMenuItemsContainer>
</DropdownMenuContainer>
)}
</StyledContainer>
);
};

View File

@ -0,0 +1,120 @@
import { ComponentProps, type ComponentType, type Context } from 'react';
import { useRecoilValue } from 'recoil';
import { useDropdownButton } from '@/ui/dropdown/hooks/useDropdownButton';
import { TopBar } from '@/ui/top-bar/TopBar';
import { useContextScopeId } from '@/ui/utilities/recoil-scope/hooks/useContextScopeId';
import { useRecoilScopedValue } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedValue';
import { currentViewIdScopedState } from '../states/currentViewIdScopedState';
import { canPersistFiltersScopedFamilySelector } from '../states/selectors/canPersistFiltersScopedFamilySelector';
import { canPersistSortsScopedFamilySelector } from '../states/selectors/canPersistSortsScopedFamilySelector';
import { FiltersHotkeyScope } from '../types/FiltersHotkeyScope';
import { ViewsHotkeyScope } from '../types/ViewsHotkeyScope';
import { FilterDropdownButton } from './FilterDropdownButton';
import {
SortDropdownButton,
SortDropdownButtonProps,
} from './SortDropdownButton';
import {
UpdateViewButtonGroup,
UpdateViewButtonGroupProps,
} from './UpdateViewButtonGroup';
import ViewBarDetails from './ViewBarDetails';
import {
ViewsDropdownButton,
ViewsDropdownButtonProps,
} from './ViewsDropdownButton';
export type ViewBarProps<SortField> = ComponentProps<'div'> & {
canPersistViewFields?: boolean;
OptionsDropdownButton: ComponentType;
optionsDropdownKey: string;
scopeContext: Context<string | null>;
} & Pick<
ViewsDropdownButtonProps,
'defaultViewName' | 'onViewsChange' | 'onViewSelect'
> &
Pick<SortDropdownButtonProps<SortField>, 'availableSorts'> &
Pick<UpdateViewButtonGroupProps, 'onViewSubmit'>;
export const ViewBar = <SortField,>({
availableSorts,
canPersistViewFields,
defaultViewName,
onViewsChange,
onViewSelect,
onViewSubmit,
OptionsDropdownButton,
optionsDropdownKey,
scopeContext,
...props
}: ViewBarProps<SortField>) => {
const recoilScopeId = useContextScopeId(scopeContext);
const currentViewId = useRecoilScopedValue(
currentViewIdScopedState,
scopeContext,
);
const canPersistFilters = useRecoilValue(
canPersistFiltersScopedFamilySelector([recoilScopeId, currentViewId]),
);
const canPersistSorts = useRecoilValue(
canPersistSortsScopedFamilySelector([recoilScopeId, currentViewId]),
);
const { openDropdownButton: openOptionsDropdownButton } = useDropdownButton({
key: optionsDropdownKey,
});
return (
<TopBar
{...props}
leftComponent={
<ViewsDropdownButton
defaultViewName={defaultViewName}
onViewEditModeChange={openOptionsDropdownButton}
onViewsChange={onViewsChange}
onViewSelect={onViewSelect}
HotkeyScope={ViewsHotkeyScope.ListDropdown}
scopeContext={scopeContext}
/>
}
displayBottomBorder={false}
rightComponent={
<>
<FilterDropdownButton
context={scopeContext}
HotkeyScope={FiltersHotkeyScope.FilterDropdownButton}
isPrimaryButton
/>
<SortDropdownButton<SortField>
context={scopeContext}
availableSorts={availableSorts}
HotkeyScope={FiltersHotkeyScope.FilterDropdownButton}
isPrimaryButton
/>
<OptionsDropdownButton />
</>
}
bottomComponent={
<ViewBarDetails
canPersistView={
canPersistViewFields || canPersistFilters || canPersistSorts
}
context={scopeContext}
hasFilterButton
rightComponent={
<UpdateViewButtonGroup
onViewEditModeChange={openOptionsDropdownButton}
onViewSubmit={onViewSubmit}
HotkeyScope={ViewsHotkeyScope.CreateDropdown}
scopeContext={scopeContext}
/>
}
/>
}
/>
);
};

View File

@ -13,6 +13,7 @@ import { useRemoveFilter } from '../hooks/useRemoveFilter';
import { availableFiltersScopedState } from '../states/availableFiltersScopedState';
import { filtersScopedState } from '../states/filtersScopedState';
import { isViewBarExpandedScopedState } from '../states/isViewBarExpandedScopedState';
import { sortsScopedState } from '../states/sortsScopedState';
import { FiltersHotkeyScope } from '../types/FiltersHotkeyScope';
import { SelectedSortType } from '../types/interface';
import { getOperandLabelShort } from '../utils/getOperandLabel';
@ -20,12 +21,9 @@ import { getOperandLabelShort } from '../utils/getOperandLabel';
import { FilterDropdownButton } from './FilterDropdownButton';
import SortOrFilterChip from './SortOrFilterChip';
type OwnProps<SortField> = {
type OwnProps = {
canPersistView?: boolean;
context: Context<string | null>;
sorts: Array<SelectedSortType<SortField>>;
onRemoveSort: (sortId: SelectedSortType<SortField>['key']) => void;
onCancelClick: () => void;
hasFilterButton?: boolean;
rightComponent?: ReactNode;
};
@ -101,24 +99,25 @@ const StyledAddFilterContainer = styled.div`
function ViewBarDetails<SortField>({
canPersistView,
context,
sorts,
onRemoveSort,
onCancelClick,
hasFilterButton = false,
rightComponent,
}: OwnProps<SortField>) {
}: OwnProps) {
const theme = useTheme();
const [filters, setFilters] = useRecoilScopedState(
filtersScopedState,
context,
);
const [availableFilters] = useRecoilScopedState(
availableFiltersScopedState,
context,
);
const [sorts, setSorts] = useRecoilScopedState<SelectedSortType<SortField>[]>(
sortsScopedState,
context,
);
const [isViewBarExpanded] = useRecoilScopedState(
isViewBarExpandedScopedState,
context,
@ -139,9 +138,14 @@ function ViewBarDetails<SortField>({
function handleCancelClick() {
setFilters([]);
onCancelClick();
setSorts([]);
}
const handleSortRemove = (sortKey: string) =>
setSorts((previousSorts) =>
previousSorts.filter((sort) => sort.key !== sortKey),
);
const shouldExpandViewBar =
canPersistView ||
((filtersWithDefinition.length || sorts.length) && isViewBarExpanded);
@ -166,7 +170,7 @@ function ViewBarDetails<SortField>({
: IconArrowNarrowUp
}
isSort
onRemove={() => onRemoveSort(sort.key)}
onRemove={() => handleSortRemove(sort.key)}
/>
);
})}

View File

@ -0,0 +1,229 @@
import {
type Context,
type MouseEvent,
useCallback,
useEffect,
useState,
} from 'react';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useRecoilCallback, useSetRecoilState } from 'recoil';
import { StyledDropdownMenuItemsContainer } from '@/ui/dropdown/components/StyledDropdownMenuItemsContainer';
import { StyledDropdownMenuSeparator } from '@/ui/dropdown/components/StyledDropdownMenuSeparator';
import {
IconChevronDown,
IconList,
IconPencil,
IconPlus,
IconTrash,
} from '@/ui/icon';
import { MenuItem } from '@/ui/menu-item/components/MenuItem';
import { MOBILE_VIEWPORT } from '@/ui/theme/constants/theme';
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
import { useContextScopeId } from '@/ui/utilities/recoil-scope/hooks/useContextScopeId';
import { useRecoilScopedState } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedState';
import { useRecoilScopedValue } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedValue';
import DropdownButton from '@/ui/view-bar/components/DropdownButton';
import { currentViewIdScopedState } from '@/ui/view-bar/states/currentViewIdScopedState';
import { filtersScopedState } from '@/ui/view-bar/states/filtersScopedState';
import { savedFiltersFamilyState } from '@/ui/view-bar/states/savedFiltersFamilyState';
import { savedSortsFamilyState } from '@/ui/view-bar/states/savedSortsFamilyState';
import { currentViewScopedSelector } from '@/ui/view-bar/states/selectors/currentViewScopedSelector';
import { sortsScopedState } from '@/ui/view-bar/states/sortsScopedState';
import { viewEditModeState } from '@/ui/view-bar/states/viewEditModeState';
import { viewsScopedState } from '@/ui/view-bar/states/viewsScopedState';
import type { View } from '@/ui/view-bar/types/View';
import { ViewsHotkeyScope } from '@/ui/view-bar/types/ViewsHotkeyScope';
import { assertNotNull } from '~/utils/assert';
const StyledBoldDropdownMenuItemsContainer = styled(
StyledDropdownMenuItemsContainer,
)`
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: 200px;
@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 = {
defaultViewName: string;
HotkeyScope: ViewsHotkeyScope;
onViewEditModeChange?: () => void;
onViewsChange?: (views: View[]) => void | Promise<void>;
onViewSelect?: (viewId: string) => void | Promise<void>;
scopeContext: Context<string | null>;
};
export const ViewsDropdownButton = ({
defaultViewName,
HotkeyScope,
onViewEditModeChange,
onViewsChange,
onViewSelect,
scopeContext,
}: ViewsDropdownButtonProps) => {
const theme = useTheme();
const [isUnfolded, setIsUnfolded] = useState(false);
const recoilScopeId = useContextScopeId(scopeContext);
const [, setCurrentViewId] = useRecoilScopedState(
currentViewIdScopedState,
scopeContext,
);
const currentView = useRecoilScopedValue(
currentViewScopedSelector,
scopeContext,
);
const [views, setViews] = useRecoilScopedState(
viewsScopedState,
scopeContext,
);
const setViewEditMode = useSetRecoilState(viewEditModeState);
const {
goBackToPreviousHotkeyScope,
setHotkeyScopeAndMemorizePreviousScope,
} = usePreviousHotkeyScope();
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);
setIsUnfolded(false);
},
[onViewSelect, recoilScopeId],
);
const handleAddViewButtonClick = useCallback(() => {
setViewEditMode({ mode: 'create', viewId: undefined });
onViewEditModeChange?.();
setIsUnfolded(false);
}, [setViewEditMode, onViewEditModeChange]);
const handleEditViewButtonClick = useCallback(
(event: MouseEvent<HTMLButtonElement>, viewId: string) => {
event.stopPropagation();
setViewEditMode({ mode: 'edit', viewId });
onViewEditModeChange?.();
setIsUnfolded(false);
},
[setViewEditMode, onViewEditModeChange],
);
const handleDeleteViewButtonClick = useCallback(
async (event: MouseEvent<HTMLButtonElement>, viewId: string) => {
event.stopPropagation();
if (currentView?.id === viewId) setCurrentViewId(undefined);
const nextViews = views.filter((view) => view.id !== viewId);
setViews(nextViews);
await onViewsChange?.(nextViews);
setIsUnfolded(false);
},
[currentView?.id, onViewsChange, setCurrentViewId, setViews, views],
);
useEffect(() => {
isUnfolded
? setHotkeyScopeAndMemorizePreviousScope(HotkeyScope)
: goBackToPreviousHotkeyScope();
}, [
HotkeyScope,
goBackToPreviousHotkeyScope,
isUnfolded,
setHotkeyScopeAndMemorizePreviousScope,
]);
return (
<DropdownButton
label={
<>
<StyledViewIcon size={theme.icon.size.md} />
<StyledViewName>
{currentView?.name || defaultViewName}
</StyledViewName>
<StyledDropdownLabelAdornments>
· {views.length} <IconChevronDown size={theme.icon.size.sm} />
</StyledDropdownLabelAdornments>
</>
}
isActive={false}
isUnfolded={isUnfolded}
onIsUnfoldedChange={setIsUnfolded}
anchor="left"
HotkeyScope={HotkeyScope}
menuWidth="auto"
>
<StyledDropdownMenuItemsContainer>
{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}
/>
))}
</StyledDropdownMenuItemsContainer>
<StyledDropdownMenuSeparator />
<StyledBoldDropdownMenuItemsContainer>
<MenuItem
onClick={handleAddViewButtonClick}
LeftIcon={IconPlus}
text="Add view"
/>
</StyledBoldDropdownMenuItemsContainer>
</DropdownButton>
);
};

View File

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