Improve viewbar api (#2233)
* create scopes * fix import bug * add useView hook * wip * wip * currentViewId is now retrieved via useView * working on sorts with useView * refactor in progress * refactor in progress * refactor in progress * refactor in progress * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * fix code * fix code * wip * push * Fix issue dependencies * Fix resize --------- Co-authored-by: bosiraphael <raphael.bosi@gmail.com>
This commit is contained in:
82
front/src/modules/views/components/SortOrFilterChip.tsx
Normal file
82
front/src/modules/views/components/SortOrFilterChip.tsx
Normal 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;
|
||||
84
front/src/modules/views/components/UpdateViewButtonGroup.tsx
Normal file
84
front/src/modules/views/components/UpdateViewButtonGroup.tsx
Normal file
@ -0,0 +1,84 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
import { Key } from 'ts-key-enum';
|
||||
|
||||
import { IconChevronDown, IconPlus } from '@/ui/display/icon';
|
||||
import { Button } from '@/ui/input/button/components/Button';
|
||||
import { ButtonGroup } from '@/ui/input/button/components/ButtonGroup';
|
||||
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 { useView } from '@/views/hooks/useView';
|
||||
|
||||
import { useViewInternalStates } from '../hooks/useViewInternalStates';
|
||||
|
||||
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 { updateCurrentView, setViewEditMode } = useView();
|
||||
const { canPersistFilters, canPersistSorts } = useViewInternalStates();
|
||||
|
||||
const canPersistView = canPersistFilters || canPersistSorts;
|
||||
|
||||
const handleArrowDownButtonClick = useCallback(() => {
|
||||
setIsDropdownOpen((previousIsOpen) => !previousIsOpen);
|
||||
}, []);
|
||||
|
||||
const handleCreateViewButtonClick = useCallback(() => {
|
||||
setViewEditMode('create');
|
||||
onViewEditModeChange?.();
|
||||
setIsDropdownOpen(false);
|
||||
}, [setViewEditMode, onViewEditModeChange]);
|
||||
|
||||
const handleDropdownClose = useCallback(() => {
|
||||
setIsDropdownOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleViewSubmit = async () => {
|
||||
await updateCurrentView?.();
|
||||
};
|
||||
|
||||
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 && (
|
||||
<DropdownMenuItemsContainer>
|
||||
<MenuItem
|
||||
onClick={handleCreateViewButtonClick}
|
||||
LeftIcon={IconPlus}
|
||||
text="Create view"
|
||||
/>
|
||||
</DropdownMenuItemsContainer>
|
||||
)}
|
||||
</StyledContainer>
|
||||
);
|
||||
};
|
||||
82
front/src/modules/views/components/ViewBar.tsx
Normal file
82
front/src/modules/views/components/ViewBar.tsx
Normal file
@ -0,0 +1,82 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
import { FilterDropdownButton } from '@/ui/data/filter/components/FilterDropdownButton';
|
||||
import { FilterScope } from '@/ui/data/filter/scopes/FilterScope';
|
||||
import { FiltersHotkeyScope } from '@/ui/data/filter/types/FiltersHotkeyScope';
|
||||
import { SortDropdownButton } from '@/ui/data/sort/components/SortDropdownButton';
|
||||
import { SortScope } from '@/ui/data/sort/scopes/SortScope';
|
||||
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
|
||||
import { TopBar } from '@/ui/layout/top-bar/TopBar';
|
||||
|
||||
import { useView } from '../hooks/useView';
|
||||
import { useViewInternalStates } from '../hooks/useViewInternalStates';
|
||||
import { ViewsHotkeyScope } from '../types/ViewsHotkeyScope';
|
||||
|
||||
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,
|
||||
});
|
||||
const { upsertViewSort } = useView();
|
||||
const { availableFilters, availableSorts } = useViewInternalStates();
|
||||
|
||||
return (
|
||||
<FilterScope
|
||||
filterScopeId="view-filter"
|
||||
availableFilters={availableFilters}
|
||||
>
|
||||
<SortScope
|
||||
sortScopeId="view-sort"
|
||||
availableSorts={availableSorts}
|
||||
onSortAdd={upsertViewSort}
|
||||
>
|
||||
<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}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</SortScope>
|
||||
</FilterScope>
|
||||
);
|
||||
};
|
||||
191
front/src/modules/views/components/ViewBarDetails.tsx
Normal file
191
front/src/modules/views/components/ViewBarDetails.tsx
Normal file
@ -0,0 +1,191 @@
|
||||
import { ReactNode } from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { AddFilterFromDropdownButton } from '@/ui/data/filter/components/AddFilterFromDetailsButton';
|
||||
import { getOperandLabelShort } from '@/ui/data/filter/utils/getOperandLabel';
|
||||
import { IconArrowDown, IconArrowUp } from '@/ui/display/icon/index';
|
||||
|
||||
import { useRemoveFilter } from '../hooks/useRemoveFilter';
|
||||
import { useView } from '../hooks/useView';
|
||||
import { useViewInternalStates } from '../hooks/useViewInternalStates';
|
||||
|
||||
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 {
|
||||
currentViewSorts,
|
||||
setCurrentViewSorts,
|
||||
availableFilters,
|
||||
currentViewFilters,
|
||||
canPersistFilters,
|
||||
canPersistSorts,
|
||||
isViewBarExpanded,
|
||||
} = useViewInternalStates();
|
||||
|
||||
const { resetViewBar } = useView();
|
||||
|
||||
const canPersistView = canPersistFilters || canPersistSorts;
|
||||
|
||||
const filtersWithDefinition = currentViewFilters?.map((filter) => {
|
||||
const filterDefinition = availableFilters.find((availableFilter) => {
|
||||
return availableFilter.key === filter.key;
|
||||
});
|
||||
|
||||
return {
|
||||
...filter,
|
||||
...filterDefinition,
|
||||
};
|
||||
});
|
||||
|
||||
const removeFilter = useRemoveFilter();
|
||||
|
||||
const handleCancelClick = () => {
|
||||
resetViewBar();
|
||||
};
|
||||
|
||||
const handleSortRemove = (sortKey: string) =>
|
||||
setCurrentViewSorts?.((previousSorts) =>
|
||||
previousSorts.filter((sort) => sort.key !== sortKey),
|
||||
);
|
||||
|
||||
const shouldExpandViewBar =
|
||||
canPersistView ||
|
||||
((filtersWithDefinition?.length || currentViewSorts?.length) &&
|
||||
isViewBarExpanded);
|
||||
|
||||
if (!shouldExpandViewBar) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledBar>
|
||||
<StyledFilterContainer>
|
||||
<StyledChipcontainer>
|
||||
{currentViewSorts?.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)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{!!currentViewSorts?.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>
|
||||
);
|
||||
};
|
||||
218
front/src/modules/views/components/ViewBarEffect.tsx
Normal file
218
front/src/modules/views/components/ViewBarEffect.tsx
Normal file
@ -0,0 +1,218 @@
|
||||
import { useRecoilCallback } from 'recoil';
|
||||
|
||||
import { ColumnDefinition } from '@/ui/data/data-table/types/ColumnDefinition';
|
||||
import { FieldMetadata } from '@/ui/data/field/types/FieldMetadata';
|
||||
import { Filter } from '@/ui/data/filter/types/Filter';
|
||||
import { Sort } from '@/ui/data/sort/types/Sort';
|
||||
import {
|
||||
SortOrder,
|
||||
useGetViewFieldsQuery,
|
||||
useGetViewFiltersQuery,
|
||||
useGetViewSortsQuery,
|
||||
useGetViewsQuery,
|
||||
} from '~/generated/graphql';
|
||||
import { assertNotNull } from '~/utils/assert';
|
||||
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
|
||||
|
||||
import { useView } from '../hooks/useView';
|
||||
import { useViewInternalStates } from '../hooks/useViewInternalStates';
|
||||
import { availableFieldsScopedState } from '../states/availableFieldsScopedState';
|
||||
import { availableFiltersScopedState } from '../states/availableFiltersScopedState';
|
||||
import { availableSortsScopedState } from '../states/availableSortsScopedState';
|
||||
import { savedViewFieldsScopedFamilyState } from '../states/savedViewFieldsScopedFamilyState';
|
||||
import { savedViewFiltersScopedFamilyState } from '../states/savedViewFiltersScopedFamilyState';
|
||||
import { savedViewSortsScopedFamilyState } from '../states/savedViewSortsScopedFamilyState';
|
||||
import { viewsScopedState } from '../states/viewsScopedState';
|
||||
|
||||
export const ViewBarEffect = () => {
|
||||
const {
|
||||
scopeId: viewScopeId,
|
||||
setCurrentViewFields,
|
||||
setSavedViewFields,
|
||||
setCurrentViewSorts,
|
||||
setSavedViewSorts,
|
||||
setCurrentViewFilters,
|
||||
setSavedViewFilters,
|
||||
currentViewId,
|
||||
setViews,
|
||||
setCurrentViewId,
|
||||
} = useView();
|
||||
|
||||
const { viewType, viewObjectId } = useViewInternalStates(viewScopeId);
|
||||
|
||||
useGetViewFieldsQuery({
|
||||
skip: !currentViewId,
|
||||
variables: {
|
||||
orderBy: { index: SortOrder.Asc },
|
||||
where: {
|
||||
viewId: { equals: currentViewId },
|
||||
},
|
||||
},
|
||||
onCompleted: useRecoilCallback(({ snapshot }) => async (data) => {
|
||||
const availableFields = snapshot
|
||||
.getLoadable(availableFieldsScopedState({ scopeId: viewScopeId }))
|
||||
.getValue();
|
||||
|
||||
if (!availableFields || !currentViewId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const savedViewFields = snapshot
|
||||
.getLoadable(
|
||||
savedViewFieldsScopedFamilyState({
|
||||
scopeId: viewScopeId,
|
||||
familyKey: currentViewId,
|
||||
}),
|
||||
)
|
||||
.getValue();
|
||||
|
||||
const queriedViewFields = data.viewFields
|
||||
.map<ColumnDefinition<FieldMetadata> | null>((viewField) => {
|
||||
const columnDefinition = availableFields.find(
|
||||
({ key }) => viewField.key === key,
|
||||
);
|
||||
|
||||
return columnDefinition
|
||||
? {
|
||||
...columnDefinition,
|
||||
key: viewField.key,
|
||||
name: viewField.name,
|
||||
index: viewField.index,
|
||||
size: viewField.size ?? columnDefinition.size,
|
||||
isVisible: viewField.isVisible,
|
||||
}
|
||||
: null;
|
||||
})
|
||||
.filter<ColumnDefinition<FieldMetadata>>(assertNotNull);
|
||||
|
||||
if (!isDeeplyEqual(savedViewFields, queriedViewFields)) {
|
||||
setCurrentViewFields?.(queriedViewFields);
|
||||
setSavedViewFields?.(queriedViewFields);
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
useGetViewsQuery({
|
||||
variables: {
|
||||
where: {
|
||||
objectId: { equals: viewObjectId },
|
||||
type: { equals: viewType },
|
||||
},
|
||||
},
|
||||
onCompleted: useRecoilCallback(({ snapshot }) => async (data) => {
|
||||
const nextViews = data.views.map((view) => ({
|
||||
id: view.id,
|
||||
name: view.name,
|
||||
}));
|
||||
const views = snapshot
|
||||
.getLoadable(viewsScopedState({ scopeId: viewScopeId }))
|
||||
.getValue();
|
||||
|
||||
if (!isDeeplyEqual(views, nextViews)) setViews(nextViews);
|
||||
|
||||
if (!nextViews.length) return;
|
||||
|
||||
if (!currentViewId) return setCurrentViewId(nextViews[0].id);
|
||||
}),
|
||||
});
|
||||
|
||||
useGetViewSortsQuery({
|
||||
skip: !currentViewId,
|
||||
variables: {
|
||||
where: {
|
||||
viewId: { equals: currentViewId },
|
||||
},
|
||||
},
|
||||
onCompleted: useRecoilCallback(({ snapshot }) => async (data) => {
|
||||
const availableSorts = snapshot
|
||||
.getLoadable(availableSortsScopedState({ scopeId: viewScopeId }))
|
||||
.getValue();
|
||||
|
||||
if (!availableSorts || !currentViewId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const savedViewSorts = snapshot
|
||||
.getLoadable(
|
||||
savedViewSortsScopedFamilyState({
|
||||
scopeId: viewScopeId,
|
||||
familyKey: currentViewId,
|
||||
}),
|
||||
)
|
||||
.getValue();
|
||||
|
||||
const queriedViewSorts = data.viewSorts
|
||||
.map((viewSort) => {
|
||||
const foundCorrespondingSortDefinition = availableSorts.find(
|
||||
(sort) => sort.key === viewSort.key,
|
||||
);
|
||||
|
||||
if (foundCorrespondingSortDefinition) {
|
||||
return {
|
||||
key: viewSort.key,
|
||||
definition: foundCorrespondingSortDefinition,
|
||||
direction: viewSort.direction.toLowerCase(),
|
||||
} as Sort;
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
})
|
||||
.filter((sort): sort is Sort => !!sort);
|
||||
|
||||
if (!isDeeplyEqual(savedViewSorts, queriedViewSorts)) {
|
||||
setSavedViewSorts?.(queriedViewSorts);
|
||||
setCurrentViewSorts?.(queriedViewSorts);
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
useGetViewFiltersQuery({
|
||||
skip: !currentViewId,
|
||||
variables: {
|
||||
where: {
|
||||
viewId: { equals: currentViewId },
|
||||
},
|
||||
},
|
||||
onCompleted: useRecoilCallback(({ snapshot }) => (data) => {
|
||||
const availableFilters = snapshot
|
||||
.getLoadable(availableFiltersScopedState({ scopeId: viewScopeId }))
|
||||
.getValue();
|
||||
|
||||
if (!availableFilters || !currentViewId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const savedViewFilters = snapshot
|
||||
.getLoadable(
|
||||
savedViewFiltersScopedFamilyState({
|
||||
scopeId: viewScopeId,
|
||||
familyKey: currentViewId,
|
||||
}),
|
||||
)
|
||||
.getValue();
|
||||
|
||||
const queriedViewFilters = data.viewFilters
|
||||
.map(({ __typename, name: _name, ...viewFilter }) => {
|
||||
const availableFilter = availableFilters.find(
|
||||
(filter) => filter.key === viewFilter.key,
|
||||
);
|
||||
|
||||
return availableFilter
|
||||
? {
|
||||
...viewFilter,
|
||||
displayValue: viewFilter.displayValue ?? viewFilter.value,
|
||||
type: availableFilter.type,
|
||||
}
|
||||
: undefined;
|
||||
})
|
||||
.filter((filter): filter is Filter => !!filter);
|
||||
|
||||
if (!isDeeplyEqual(savedViewFilters, queriedViewFilters)) {
|
||||
setSavedViewFilters?.(queriedViewFilters);
|
||||
setCurrentViewFilters?.(queriedViewFilters);
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
return <></>;
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
172
front/src/modules/views/components/ViewsDropdownButton.tsx
Normal file
172
front/src/modules/views/components/ViewsDropdownButton.tsx
Normal file
@ -0,0 +1,172 @@
|
||||
import { MouseEvent } from 'react';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import { useRecoilCallback } from 'recoil';
|
||||
|
||||
import {
|
||||
IconChevronDown,
|
||||
IconList,
|
||||
IconPencil,
|
||||
IconPlus,
|
||||
IconTrash,
|
||||
} from '@/ui/display/icon';
|
||||
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
|
||||
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
|
||||
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
|
||||
import { StyledDropdownButtonContainer } from '@/ui/layout/dropdown/components/StyledDropdownButtonContainer';
|
||||
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
|
||||
import { DropdownScope } from '@/ui/layout/dropdown/scopes/DropdownScope';
|
||||
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 { assertNotNull } from '~/utils/assert';
|
||||
|
||||
import { ViewsDropdownId } from '../constants/ViewsDropdownId';
|
||||
import { useView } from '../hooks/useView';
|
||||
import { useViewInternalStates } from '../hooks/useViewInternalStates';
|
||||
|
||||
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 { scopeId, removeView, currentViewId } = useView();
|
||||
|
||||
const {
|
||||
views,
|
||||
currentView,
|
||||
setViewEditMode,
|
||||
setCurrentViewId,
|
||||
entityCountInCurrentView,
|
||||
} = useViewInternalStates(scopeId, currentViewId);
|
||||
|
||||
const { isDropdownOpen, closeDropdown } = useDropdown({
|
||||
dropdownScopeId: ViewsDropdownId,
|
||||
});
|
||||
|
||||
const handleViewSelect = useRecoilCallback(
|
||||
() => async (viewId: string) => {
|
||||
setCurrentViewId(viewId);
|
||||
|
||||
closeDropdown();
|
||||
},
|
||||
[setCurrentViewId, closeDropdown],
|
||||
);
|
||||
|
||||
const handleAddViewButtonClick = () => {
|
||||
setViewEditMode('create');
|
||||
onViewEditModeChange?.();
|
||||
closeDropdown();
|
||||
};
|
||||
|
||||
const handleEditViewButtonClick = (
|
||||
event: MouseEvent<HTMLButtonElement>,
|
||||
viewId: string,
|
||||
) => {
|
||||
event.stopPropagation();
|
||||
setCurrentViewId(viewId);
|
||||
setViewEditMode('edit');
|
||||
onViewEditModeChange?.();
|
||||
closeDropdown();
|
||||
};
|
||||
|
||||
const handleDeleteViewButtonClick = async (
|
||||
event: MouseEvent<HTMLButtonElement>,
|
||||
viewId: string,
|
||||
) => {
|
||||
event.stopPropagation();
|
||||
|
||||
await removeView(viewId);
|
||||
closeDropdown();
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownScope dropdownScopeId={ViewsDropdownId}>
|
||||
<Dropdown
|
||||
dropdownHotkeyScope={hotkeyScope}
|
||||
clickableComponent={
|
||||
<StyledDropdownButtonContainer isUnfolded={isDropdownOpen}>
|
||||
<StyledViewIcon size={theme.icon.size.md} />
|
||||
<StyledViewName>{currentView?.name}</StyledViewName>
|
||||
<StyledDropdownLabelAdornments>
|
||||
· {entityCountInCurrentView}{' '}
|
||||
<IconChevronDown size={theme.icon.size.sm} />
|
||||
</StyledDropdownLabelAdornments>
|
||||
</StyledDropdownButtonContainer>
|
||||
}
|
||||
dropdownComponents={
|
||||
<>
|
||||
<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>
|
||||
<DropdownMenuSeparator />
|
||||
<StyledBoldDropdownMenuItemsContainer>
|
||||
<MenuItem
|
||||
onClick={handleAddViewButtonClick}
|
||||
LeftIcon={IconPlus}
|
||||
text="Add view"
|
||||
/>
|
||||
</StyledBoldDropdownMenuItemsContainer>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</DropdownScope>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user