feat: add views dropdown (list, add & edit views) (#1220)

Closes #1218
This commit is contained in:
Thaïs
2023-08-15 21:08:02 +02:00
committed by GitHub
parent 7a330b4a02
commit 4e654654da
36 changed files with 1037 additions and 212 deletions

View File

@ -0,0 +1,34 @@
import { forwardRef, InputHTMLAttributes } from 'react';
import styled from '@emotion/styled';
import { textInputStyle } from '@/ui/theme/constants/effects';
export const DropdownMenuInputContainer = styled.div`
--vertical-padding: ${({ theme }) => theme.spacing(1)};
align-items: center;
display: flex;
flex-direction: row;
height: calc(36px - 2 * var(--vertical-padding));
padding: var(--vertical-padding) 0;
width: calc(100%);
`;
const StyledInput = styled.input`
font-size: ${({ theme }) => theme.font.size.sm};
${textInputStyle}
width: 100%;
`;
export const DropdownMenuInput = forwardRef<
HTMLInputElement,
InputHTMLAttributes<HTMLInputElement>
>((props, ref) => (
<DropdownMenuInputContainer>
<StyledInput autoComplete="off" placeholder="Search" {...props} ref={ref} />
</DropdownMenuInputContainer>
));

View File

@ -1,39 +0,0 @@
import { InputHTMLAttributes } from 'react';
import styled from '@emotion/styled';
import { textInputStyle } from '@/ui/theme/constants/effects';
export const DropdownMenuSearchContainer = styled.div`
--vertical-padding: ${({ theme }) => theme.spacing(1)};
align-items: center;
display: flex;
flex-direction: row;
height: calc(36px - 2 * var(--vertical-padding));
padding: var(--vertical-padding) 0;
width: calc(100%);
`;
const StyledEditModeSearchInput = styled.input`
font-size: ${({ theme }) => theme.font.size.sm};
${textInputStyle}
width: 100%;
`;
export function DropdownMenuSearch(
props: InputHTMLAttributes<HTMLInputElement>,
) {
return (
<DropdownMenuSearchContainer>
<StyledEditModeSearchInput
autoComplete="off"
{...props}
placeholder={props.placeholder ?? 'Search'}
/>
</DropdownMenuSearchContainer>
);
}

View File

@ -11,9 +11,9 @@ import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { DropdownMenu } from '../DropdownMenu';
import { DropdownMenuCheckableItem } from '../DropdownMenuCheckableItem';
import { DropdownMenuHeader } from '../DropdownMenuHeader';
import { DropdownMenuInput } from '../DropdownMenuInput';
import { DropdownMenuItem } from '../DropdownMenuItem';
import { DropdownMenuItemsContainer } from '../DropdownMenuItemsContainer';
import { DropdownMenuSearch } from '../DropdownMenuSearch';
import { DropdownMenuSelectableItem } from '../DropdownMenuSelectableItem';
import { DropdownMenuSeparator } from '../DropdownMenuSeparator';
import { DropdownMenuSubheader } from '../DropdownMenuSubheader';
@ -256,7 +256,7 @@ export const LoadingMenu: Story = {
...WithContentBelow,
render: () => (
<DropdownMenu>
<DropdownMenuSearch value={'query'} autoFocus />
<DropdownMenuInput value={'query'} autoFocus />
<DropdownMenuSeparator />
<DropdownMenuItemsContainer hasMaxHeight>
<DropdownMenuSkeletonItem />
@ -269,7 +269,7 @@ export const Search: Story = {
...WithContentBelow,
render: (args) => (
<DropdownMenu {...args}>
<DropdownMenuSearch />
<DropdownMenuInput />
<DropdownMenuSeparator />
<DropdownMenuItemsContainer hasMaxHeight>
{mockSelectArray.map(({ name }) => (

View File

@ -4,19 +4,18 @@ import { Key } from 'ts-key-enum';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { FiltersHotkeyScope } from '../types/FiltersHotkeyScope';
import { DropdownMenuContainer } from './DropdownMenuContainer';
type OwnProps = {
label: string;
anchor?: 'left' | 'right';
label: ReactNode;
isActive: boolean;
children?: ReactNode;
isUnfolded?: boolean;
icon?: ReactNode;
onIsUnfoldedChange?: (newIsUnfolded: boolean) => void;
resetState?: () => void;
HotkeyScope: FiltersHotkeyScope;
HotkeyScope: string;
color?: string;
};
@ -59,7 +58,12 @@ const StyledDropdownButton = styled.div<StyledDropdownButtonProps>`
}
`;
const StyledDropdownMenuContainer = styled(DropdownMenuContainer)`
z-index: 2;
`;
function DropdownButton({
anchor,
label,
isActive,
children,
@ -99,9 +103,9 @@ function DropdownButton({
{label}
</StyledDropdownButton>
{isUnfolded && (
<DropdownMenuContainer onClose={onOutsideClick}>
<StyledDropdownMenuContainer anchor={anchor} onClose={onOutsideClick}>
{children}
</DropdownMenuContainer>
</StyledDropdownMenuContainer>
)}
</StyledDropdownButtonContainer>
);

View File

@ -1,23 +1,32 @@
import { useRef } from 'react';
import { type HTMLAttributes, useRef } from 'react';
import styled from '@emotion/styled';
import { DropdownMenu } from '@/ui/dropdown/components/DropdownMenu';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { DropdownMenu } from '../../dropdown/components/DropdownMenu';
export const StyledDropdownMenuContainer = styled.ul`
export const StyledDropdownMenuContainer = styled.ul<{
anchor: 'left' | 'right';
}>`
padding: 0;
position: absolute;
right: 0;
${({ anchor }) => {
if (anchor === 'right') return 'right: 0';
}};
top: 14px;
`;
export function DropdownMenuContainer({
children,
onClose,
}: {
type DropdownMenuContainerProps = {
anchor?: 'left' | 'right';
children: React.ReactNode;
onClose?: () => void;
}) {
} & HTMLAttributes<HTMLUListElement>;
export function DropdownMenuContainer({
anchor = 'right',
children,
onClose,
...props
}: DropdownMenuContainerProps) {
const dropdownRef = useRef(null);
useListenClickOutside({
@ -28,7 +37,7 @@ export function DropdownMenuContainer({
});
return (
<StyledDropdownMenuContainer data-select-disable>
<StyledDropdownMenuContainer data-select-disable {...props} anchor={anchor}>
<DropdownMenu ref={dropdownRef}>{children}</DropdownMenu>
</StyledDropdownMenuContainer>
);

View File

@ -1,6 +1,6 @@
import { ChangeEvent, Context } from 'react';
import { DropdownMenuSearch } from '@/ui/dropdown/components/DropdownMenuSearch';
import { DropdownMenuInput } from '@/ui/dropdown/components/DropdownMenuInput';
import { filterDefinitionUsedInDropdownScopedState } from '@/ui/filter-n-sort/states/filterDefinitionUsedInDropdownScopedState';
import { filterDropdownSearchInputScopedState } from '@/ui/filter-n-sort/states/filterDropdownSearchInputScopedState';
import { selectedOperandInDropdownScopedState } from '@/ui/filter-n-sort/states/selectedOperandInDropdownScopedState';
@ -27,7 +27,7 @@ export function FilterDropdownEntitySearchInput({
return (
filterDefinitionUsedInDropdown &&
selectedOperandInDropdown && (
<DropdownMenuSearch
<DropdownMenuInput
type="text"
value={filterDropdownSearchInput}
placeholder={filterDefinitionUsedInDropdown.label}

View File

@ -1,6 +1,6 @@
import { ChangeEvent, Context } from 'react';
import { DropdownMenuSearch } from '@/ui/dropdown/components/DropdownMenuSearch';
import { DropdownMenuInput } from '@/ui/dropdown/components/DropdownMenuInput';
import { useRecoilScopedState } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedState';
import { useRemoveFilter } from '../hooks/useRemoveFilter';
@ -29,7 +29,7 @@ export function FilterDropdownNumberSearchInput({
return (
filterDefinitionUsedInDropdown &&
selectedOperandInDropdown && (
<DropdownMenuSearch
<DropdownMenuInput
type="number"
placeholder={filterDefinitionUsedInDropdown.label}
onChange={(event: ChangeEvent<HTMLInputElement>) => {

View File

@ -1,6 +1,6 @@
import { ChangeEvent, Context } from 'react';
import { DropdownMenuSearch } from '@/ui/dropdown/components/DropdownMenuSearch';
import { DropdownMenuInput } from '@/ui/dropdown/components/DropdownMenuInput';
import { useRecoilScopedState } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedState';
import { useFilterCurrentlyEdited } from '../hooks/useFilterCurrentlyEdited';
@ -36,7 +36,7 @@ export function FilterDropdownTextSearchInput({
return (
filterDefinitionUsedInDropdown &&
selectedOperandInDropdown && (
<DropdownMenuSearch
<DropdownMenuInput
type="text"
placeholder={filterDefinitionUsedInDropdown.label}
value={filterCurrentlyEdited?.value ?? filterDropdownSearchInput}

View File

@ -1,58 +1,60 @@
export { IconAddressBook } from './components/IconAddressBook';
export { IconBuildingSkyscraper } from '@tabler/icons-react';
export { IconMessageCircle as IconComment } from '@tabler/icons-react';
export { IconCheck } from '@tabler/icons-react';
export { IconTrash } from '@tabler/icons-react';
export { IconLayoutSidebarRightCollapse } from '@tabler/icons-react';
export { IconLayoutSidebarLeftCollapse } from '@tabler/icons-react';
export { IconUser } from '@tabler/icons-react';
export { IconBell } from '@tabler/icons-react';
export { IconList } from '@tabler/icons-react';
export { IconInbox } from '@tabler/icons-react';
export { IconSearch } from '@tabler/icons-react';
export { IconArchive } from '@tabler/icons-react';
export { IconSettings } from '@tabler/icons-react';
export { IconLogout } from '@tabler/icons-react';
export { IconColorSwatch } from '@tabler/icons-react';
export { IconProgressCheck } from '@tabler/icons-react';
export { IconX } from '@tabler/icons-react';
export { IconChevronLeft } from '@tabler/icons-react';
export { IconBriefcase } from '@tabler/icons-react';
export { IconPlus } from '@tabler/icons-react';
export { IconMinus } from '@tabler/icons-react';
export { IconLink } from '@tabler/icons-react';
export { IconBrandLinkedin } from '@tabler/icons-react';
export { IconUsers } from '@tabler/icons-react';
export { IconCalendarEvent } from '@tabler/icons-react';
export { IconMap } from '@tabler/icons-react';
export { IconMail } from '@tabler/icons-react';
export { IconPhone } from '@tabler/icons-react';
export { IconTargetArrow } from '@tabler/icons-react';
export { IconChevronDown } from '@tabler/icons-react';
export { IconArrowNarrowDown } from '@tabler/icons-react';
export { IconArrowNarrowUp } from '@tabler/icons-react';
export { IconArrowRight } from '@tabler/icons-react';
export { IconArrowUpRight } from '@tabler/icons-react';
export { IconBrandGoogle } from '@tabler/icons-react';
export { IconUpload } from '@tabler/icons-react';
export { IconFileUpload } from '@tabler/icons-react';
export { IconChevronsRight } from '@tabler/icons-react';
export { IconNotes } from '@tabler/icons-react';
export { IconCirclePlus } from '@tabler/icons-react';
export { IconCheckbox } from '@tabler/icons-react';
export { IconTimelineEvent } from '@tabler/icons-react';
export { IconAlertCircle } from '@tabler/icons-react';
export { IconEye } from '@tabler/icons-react';
export { IconEyeOff } from '@tabler/icons-react';
export { IconAlertTriangle } from '@tabler/icons-react';
export { IconCopy } from '@tabler/icons-react';
export { IconCurrencyDollar } from '@tabler/icons-react';
export { IconUserCircle } from '@tabler/icons-react';
export { IconCalendar } from '@tabler/icons-react';
export { IconPencil } from '@tabler/icons-react';
export { IconCircleDot } from '@tabler/icons-react';
export { IconHeart } from '@tabler/icons-react';
export { IconBrandX } from '@tabler/icons-react';
export { IconTag } from '@tabler/icons-react';
export { IconHelpCircle } from '@tabler/icons-react';
export { IconBrandGithub } from '@tabler/icons-react';
export {
IconAlertCircle,
IconAlertTriangle,
IconArchive,
IconArrowNarrowDown,
IconArrowNarrowUp,
IconArrowRight,
IconArrowUpRight,
IconBell,
IconBrandGithub,
IconBrandGoogle,
IconBrandLinkedin,
IconBrandX,
IconBriefcase,
IconBuildingSkyscraper,
IconCalendar,
IconCalendarEvent,
IconCheck,
IconCheckbox,
IconChevronDown,
IconChevronLeft,
IconChevronsRight,
IconCircleDot,
IconCirclePlus,
IconColorSwatch,
IconMessageCircle as IconComment,
IconCopy,
IconCurrencyDollar,
IconEye,
IconEyeOff,
IconFileUpload,
IconHeart,
IconHelpCircle,
IconInbox,
IconLayoutSidebarLeftCollapse,
IconLayoutSidebarRightCollapse,
IconLink,
IconList,
IconLogout,
IconMail,
IconMap,
IconMinus,
IconNotes,
IconPencil,
IconPhone,
IconPlus,
IconProgressCheck,
IconSearch,
IconSettings,
IconTag,
IconTargetArrow,
IconTimelineEvent,
IconTrash,
IconUpload,
IconUser,
IconUserCircle,
IconUsers,
IconX,
} from '@tabler/icons-react';

View File

@ -3,9 +3,9 @@ import debounce from 'lodash.debounce';
import { DropdownMenu } from '@/ui/dropdown/components/DropdownMenu';
import { DropdownMenuCheckableItem } from '@/ui/dropdown/components/DropdownMenuCheckableItem';
import { DropdownMenuInput } from '@/ui/dropdown/components/DropdownMenuInput';
import { DropdownMenuItem } from '@/ui/dropdown/components/DropdownMenuItem';
import { DropdownMenuItemsContainer } from '@/ui/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSearch } from '@/ui/dropdown/components/DropdownMenuSearch';
import { DropdownMenuSeparator } from '@/ui/dropdown/components/DropdownMenuSeparator';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { Avatar } from '@/users/components/Avatar';
@ -73,7 +73,7 @@ export function MultipleEntitySelect<
return (
<DropdownMenu ref={containerRef}>
<DropdownMenuSearch
<DropdownMenuInput
value={searchFilter}
onChange={handleFilterChange}
autoFocus

View File

@ -2,9 +2,9 @@ import { useRef } from 'react';
import { useTheme } from '@emotion/react';
import { DropdownMenu } from '@/ui/dropdown/components/DropdownMenu';
import { DropdownMenuInput } from '@/ui/dropdown/components/DropdownMenuInput';
import { DropdownMenuItem } from '@/ui/dropdown/components/DropdownMenuItem';
import { DropdownMenuItemsContainer } from '@/ui/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSearch } from '@/ui/dropdown/components/DropdownMenuSearch';
import { DropdownMenuSeparator } from '@/ui/dropdown/components/DropdownMenuSeparator';
import { IconPlus } from '@/ui/icon';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
@ -65,7 +65,7 @@ export function SingleEntitySelect<
ref={containerRef}
width={width}
>
<DropdownMenuSearch
<DropdownMenuInput
value={searchFilter}
onChange={handleSearchFilterChange}
autoFocus

View File

@ -14,6 +14,7 @@ import { useLeaveTableFocus } from '../hooks/useLeaveTableFocus';
import { useMapKeyboardToSoftFocus } from '../hooks/useMapKeyboardToSoftFocus';
import { useResetTableRowSelection } from '../hooks/useResetTableRowSelection';
import { useSetRowSelectedState } from '../hooks/useSetRowSelectedState';
import type { TableView } from '../states/tableViewsState';
import { TableHeader } from '../table-header/components/TableHeader';
import { EntityTableBody } from './EntityTableBody';
@ -97,16 +98,16 @@ type OwnProps<SortField> = {
availableSorts?: Array<SortType<SortField>>;
onColumnsChange?: (columns: ViewFieldDefinition<ViewFieldMetadata>[]) => void;
onSortsUpdate?: (sorts: Array<SelectedSortType<SortField>>) => void;
onRowSelectionChange?: (rowSelection: string[]) => void;
onViewsChange?: (views: TableView[]) => void;
updateEntityMutation: any;
};
export function EntityTable<SortField>({
viewName,
viewIcon,
availableSorts,
onColumnsChange,
onSortsUpdate,
onViewsChange,
updateEntityMutation,
}: OwnProps<SortField>) {
const tableBodyRef = useRef<HTMLDivElement>(null);
@ -131,10 +132,10 @@ export function EntityTable<SortField>({
<StyledTableContainer ref={tableBodyRef}>
<TableHeader
viewName={viewName}
viewIcon={viewIcon}
availableSorts={availableSorts}
onColumnsChange={onColumnsChange}
onSortsUpdate={onSortsUpdate}
onViewsChange={onViewsChange}
/>
<StyledTableWrapper>
<StyledTable>

View File

@ -1,30 +1,50 @@
import { useCallback, useState } from 'react';
import {
type FormEvent,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import { useTheme } from '@emotion/react';
import { useRecoilState, useRecoilValue } from 'recoil';
import { v4 } from 'uuid';
import { IconButton } from '@/ui/button/components/IconButton';
import { DropdownMenuHeader } from '@/ui/dropdown/components/DropdownMenuHeader';
import { DropdownMenuInput } from '@/ui/dropdown/components/DropdownMenuInput';
import { DropdownMenuItem } from '@/ui/dropdown/components/DropdownMenuItem';
import { DropdownMenuItemsContainer } from '@/ui/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSeparator } from '@/ui/dropdown/components/DropdownMenuSeparator';
import {
import type {
ViewFieldDefinition,
ViewFieldMetadata,
} from '@/ui/editable-field/types/ViewField';
import DropdownButton from '@/ui/filter-n-sort/components/DropdownButton';
import { FiltersHotkeyScope } from '@/ui/filter-n-sort/types/FiltersHotkeyScope';
import { IconChevronLeft, IconMinus, IconPlus, IconTag } from '@/ui/icon';
import {
hiddenTableColumnsState,
tableColumnsState,
visibleTableColumnsState,
} from '@/ui/table/states/tableColumnsState';
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
import { useRecoilScopedState } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedState';
import { useRecoilScopedValue } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedValue';
import { TableRecoilScopeContext } from '../../states/recoil-scope-contexts/TableRecoilScopeContext';
import {
type TableView,
tableViewEditModeState,
tableViewsByIdState,
tableViewsState,
} from '../../states/tableViewsState';
import { TableOptionsHotkeyScope } from '../../types/TableOptionsHotkeyScope';
import { TableOptionsDropdownSection } from './TableOptionsDropdownSection';
type TableOptionsDropdownButtonProps = {
onColumnsChange?: (columns: ViewFieldDefinition<ViewFieldMetadata>[]) => void;
HotkeyScope: FiltersHotkeyScope;
onViewsChange?: (views: TableView[]) => void;
HotkeyScope: TableOptionsHotkeyScope;
};
enum Option {
@ -33,17 +53,37 @@ enum Option {
export const TableOptionsDropdownButton = ({
onColumnsChange,
onViewsChange,
HotkeyScope,
}: TableOptionsDropdownButtonProps) => {
const theme = useTheme();
const [isUnfolded, setIsUnfolded] = useState(false);
const [selectedOption, setSelectedOption] = useState<Option | undefined>(
undefined,
);
const viewEditInputRef = useRef<HTMLInputElement>(null);
const [columns, setColumns] = useRecoilState(tableColumnsState);
const [viewEditMode, setViewEditMode] = useRecoilState(
tableViewEditModeState,
);
const [views, setViews] = useRecoilScopedState(
tableViewsState,
TableRecoilScopeContext,
);
const visibleColumns = useRecoilValue(visibleTableColumnsState);
const hiddenColumns = useRecoilValue(hiddenTableColumnsState);
const viewsById = useRecoilScopedValue(
tableViewsByIdState,
TableRecoilScopeContext,
);
const {
goBackToPreviousHotkeyScope,
setHotkeyScopeAndMemorizePreviousScope,
} = usePreviousHotkeyScope();
const handleColumnVisibilityChange = useCallback(
(columnId: string, nextIsVisible: boolean) => {
@ -79,25 +119,109 @@ export const TableOptionsDropdownButton = ({
[handleColumnVisibilityChange, theme.icon.size.sm, visibleColumns.length],
);
const resetViewEditMode = useCallback(() => {
setViewEditMode({ mode: undefined, viewId: undefined });
if (viewEditInputRef.current) {
viewEditInputRef.current.value = '';
}
}, [setViewEditMode]);
const handleViewNameSubmit = useCallback(
(event?: FormEvent) => {
event?.preventDefault();
if (viewEditMode.mode && viewEditInputRef.current?.value) {
const name = viewEditInputRef.current.value;
const nextViews =
viewEditMode.mode === 'create'
? [...views, { id: v4(), name }]
: views.map((view) =>
view.id === viewEditMode.viewId ? { ...view, name } : view,
);
(onViewsChange ?? setViews)(nextViews);
}
resetViewEditMode();
},
[
onViewsChange,
resetViewEditMode,
setViews,
viewEditMode.mode,
viewEditMode.viewId,
views,
],
);
const handleSelectOption = useCallback(
(option: Option) => {
handleViewNameSubmit();
setIsUnfolded(true);
setSelectedOption(option);
},
[handleViewNameSubmit],
);
const resetSelectedOption = useCallback(() => {
setSelectedOption(undefined);
}, []);
const handleUnfoldedChange = useCallback(
(nextIsUnfolded: boolean) => {
setIsUnfolded(nextIsUnfolded);
if (!nextIsUnfolded) {
handleViewNameSubmit();
resetSelectedOption();
}
},
[handleViewNameSubmit, resetSelectedOption],
);
useEffect(() => {
isUnfolded || viewEditMode.mode
? setHotkeyScopeAndMemorizePreviousScope(HotkeyScope)
: goBackToPreviousHotkeyScope();
}, [
HotkeyScope,
goBackToPreviousHotkeyScope,
isUnfolded,
setHotkeyScopeAndMemorizePreviousScope,
viewEditMode.mode,
]);
return (
<DropdownButton
label="Options"
isActive={false}
isUnfolded={isUnfolded}
onIsUnfoldedChange={setIsUnfolded}
isUnfolded={isUnfolded || !!viewEditMode.mode}
onIsUnfoldedChange={handleUnfoldedChange}
HotkeyScope={HotkeyScope}
>
{!selectedOption && (
<>
<DropdownMenuHeader>View settings</DropdownMenuHeader>
{!!viewEditMode.mode ? (
<DropdownMenuInput
ref={viewEditInputRef}
autoFocus
placeholder={
viewEditMode.mode === 'create' ? 'New view' : 'View name'
}
defaultValue={
viewEditMode.viewId
? viewsById[viewEditMode.viewId]?.name
: undefined
}
/>
) : (
<DropdownMenuHeader>View settings</DropdownMenuHeader>
)}
<DropdownMenuSeparator />
<DropdownMenuItemsContainer>
<DropdownMenuItem
onClick={() => setSelectedOption(Option.Properties)}
onClick={() => handleSelectOption(Option.Properties)}
>
<IconTag size={theme.icon.size.md} />
Properties

View File

@ -0,0 +1,149 @@
import { type MouseEvent, useCallback, useEffect, useState } from 'react';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useSetRecoilState } from 'recoil';
import { IconButton } from '@/ui/button/components/IconButton';
import { DropdownMenuItem } from '@/ui/dropdown/components/DropdownMenuItem';
import { DropdownMenuItemsContainer } from '@/ui/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSeparator } from '@/ui/dropdown/components/DropdownMenuSeparator';
import DropdownButton from '@/ui/filter-n-sort/components/DropdownButton';
import { IconChevronDown, IconList, IconPencil, IconPlus } from '@/ui/icon';
import {
currentTableViewIdState,
currentTableViewState,
tableViewEditModeState,
tableViewsState,
} from '@/ui/table/states/tableViewsState';
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
import { useRecoilScopedState } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedState';
import { useRecoilScopedValue } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedValue';
import { TableRecoilScopeContext } from '../../states/recoil-scope-contexts/TableRecoilScopeContext';
import { TableViewsHotkeyScope } from '../../types/TableViewsHotkeyScope';
const StyledDropdownMenuItemsContainer = 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)};
`;
type TableViewsDropdownButtonProps = {
defaultViewName: string;
HotkeyScope: TableViewsHotkeyScope;
};
export const TableViewsDropdownButton = ({
defaultViewName,
HotkeyScope,
}: TableViewsDropdownButtonProps) => {
const theme = useTheme();
const [isUnfolded, setIsUnfolded] = useState(false);
const currentView = useRecoilScopedValue(
currentTableViewState,
TableRecoilScopeContext,
);
const views = useRecoilScopedValue(tableViewsState, TableRecoilScopeContext);
const [, setCurrentViewId] = useRecoilScopedState(
currentTableViewIdState,
TableRecoilScopeContext,
);
const setViewEditMode = useSetRecoilState(tableViewEditModeState);
const {
goBackToPreviousHotkeyScope,
setHotkeyScopeAndMemorizePreviousScope,
} = usePreviousHotkeyScope();
const handleViewSelect = useCallback(
(viewId?: string) => {
setCurrentViewId(viewId);
setIsUnfolded(false);
},
[setCurrentViewId],
);
const handleAddViewButtonClick = useCallback(() => {
setViewEditMode({ mode: 'create', viewId: undefined });
setIsUnfolded(false);
}, [setViewEditMode]);
const handleEditViewButtonClick = useCallback(
(event: MouseEvent<HTMLButtonElement>, viewId: string) => {
event.stopPropagation();
setViewEditMode({ mode: 'edit', viewId });
setIsUnfolded(false);
},
[setViewEditMode],
);
useEffect(() => {
isUnfolded
? setHotkeyScopeAndMemorizePreviousScope(HotkeyScope)
: goBackToPreviousHotkeyScope();
}, [
HotkeyScope,
goBackToPreviousHotkeyScope,
isUnfolded,
setHotkeyScopeAndMemorizePreviousScope,
]);
return (
<DropdownButton
label={
<>
<StyledViewIcon size={theme.icon.size.md} />
{currentView?.name || defaultViewName}{' '}
<StyledDropdownLabelAdornments>
· {views.length + 1} <IconChevronDown size={theme.icon.size.sm} />
</StyledDropdownLabelAdornments>
</>
}
isActive={false}
isUnfolded={isUnfolded}
onIsUnfoldedChange={setIsUnfolded}
anchor="left"
HotkeyScope={HotkeyScope}
>
<StyledDropdownMenuItemsContainer>
<DropdownMenuItem onClick={() => handleViewSelect(undefined)}>
<IconList size={theme.icon.size.md} />
{defaultViewName}
</DropdownMenuItem>
{views.map((view) => (
<DropdownMenuItem
key={view.id}
actions={
<IconButton
onClick={(event) => handleEditViewButtonClick(event, view.id)}
icon={<IconPencil size={theme.icon.size.sm} />}
/>
}
onClick={() => handleViewSelect(view.id)}
>
<IconList size={theme.icon.size.md} />
{view.name}
</DropdownMenuItem>
))}
</StyledDropdownMenuItemsContainer>
<DropdownMenuSeparator />
<StyledDropdownMenuItemsContainer>
<DropdownMenuItem onClick={handleAddViewButtonClick}>
<IconPlus size={theme.icon.size.md} />
Add view
</DropdownMenuItem>
</StyledDropdownMenuItemsContainer>
</DropdownButton>
);
};

View File

@ -0,0 +1,50 @@
import { atom, atomFamily, selectorFamily } from 'recoil';
export type TableView = { id: string; name: string };
export const tableViewsState = atomFamily<TableView[], string>({
key: 'tableViewsState',
default: [],
});
export const tableViewsByIdState = selectorFamily<
Record<string, TableView>,
string
>({
key: 'tableViewsByIdState',
get:
(scopeId) =>
({ get }) =>
get(tableViewsState(scopeId)).reduce<Record<string, TableView>>(
(result, view) => ({ ...result, [view.id]: view }),
{},
),
});
export const currentTableViewIdState = atomFamily<string | undefined, string>({
key: 'currentTableViewIdState',
default: undefined,
});
export const currentTableViewState = selectorFamily<
TableView | undefined,
string
>({
key: 'currentTableViewState',
get:
(scopeId) =>
({ get }) => {
const currentViewId = get(currentTableViewIdState(scopeId));
return currentViewId
? get(tableViewsByIdState(scopeId))[currentViewId]
: undefined;
},
});
export const tableViewEditModeState = atom<{
mode: 'create' | 'edit' | undefined;
viewId: string | undefined;
}>({
key: 'tableViewEditModeState',
default: { mode: undefined, viewId: undefined },
});

View File

@ -1,5 +1,4 @@
import { ReactNode, useCallback } from 'react';
import styled from '@emotion/styled';
import { useCallback } from 'react';
import type {
ViewFieldDefinition,
@ -15,32 +14,26 @@ import { TableOptionsDropdownButton } from '@/ui/table/options/components/TableO
import { TopBar } from '@/ui/top-bar/TopBar';
import { useRecoilScopedState } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedState';
import { TableViewsDropdownButton } from '../../options/components/TableViewsDropdownButton';
import { TableRecoilScopeContext } from '../../states/recoil-scope-contexts/TableRecoilScopeContext';
import type { TableView } from '../../states/tableViewsState';
import { TableOptionsHotkeyScope } from '../../types/TableOptionsHotkeyScope';
import { TableViewsHotkeyScope } from '../../types/TableViewsHotkeyScope';
type OwnProps<SortField> = {
viewName: string;
viewIcon?: ReactNode;
availableSorts?: Array<SortType<SortField>>;
onColumnsChange?: (columns: ViewFieldDefinition<ViewFieldMetadata>[]) => void;
onSortsUpdate?: (sorts: Array<SelectedSortType<SortField>>) => void;
onViewsChange?: (views: TableView[]) => void;
};
const StyledIcon = styled.div`
display: flex;
margin-left: ${({ theme }) => theme.spacing(1)};
margin-right: ${({ theme }) => theme.spacing(2)};
& > svg {
font-size: ${({ theme }) => theme.icon.size.sm};
}
`;
export function TableHeader<SortField>({
viewName,
viewIcon,
availableSorts,
onColumnsChange,
onSortsUpdate,
onViewsChange,
}: OwnProps<SortField>) {
const [sorts, setSorts] = useRecoilScopedState<SelectedSortType<SortField>[]>(
sortScopedState,
@ -67,10 +60,10 @@ export function TableHeader<SortField>({
return (
<TopBar
leftComponent={
<>
<StyledIcon>{viewIcon}</StyledIcon>
{viewName}
</>
<TableViewsDropdownButton
defaultViewName={viewName}
HotkeyScope={TableViewsHotkeyScope.Dropdown}
/>
}
displayBottomBorder={false}
rightComponent={
@ -90,7 +83,8 @@ export function TableHeader<SortField>({
/>
<TableOptionsDropdownButton
onColumnsChange={onColumnsChange}
HotkeyScope={FiltersHotkeyScope.FilterDropdownButton}
onViewsChange={onViewsChange}
HotkeyScope={TableOptionsHotkeyScope.Dropdown}
/>
</>
}

View File

@ -0,0 +1,3 @@
export enum TableOptionsHotkeyScope {
Dropdown = 'table-options-dropdown',
}

View File

@ -0,0 +1,3 @@
export enum TableViewsHotkeyScope {
Dropdown = 'table-views-dropdown',
}

View File

@ -4,13 +4,15 @@ import { v4 } from 'uuid';
import { RecoilScopeContext } from '../states/RecoilScopeContext';
export function RecoilScope({
SpecificContext,
children,
scopeId,
SpecificContext,
}: {
SpecificContext?: Context<string | null>;
children: React.ReactNode;
scopeId?: string;
SpecificContext?: Context<string | null>;
}) {
const currentScopeId = useRef(v4());
const currentScopeId = useRef(scopeId || v4());
return SpecificContext ? (
<SpecificContext.Provider value={currentScopeId.current}>