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

Closes #1218
This commit is contained in:
Thaïs
2023-08-15 12:08:02 -07:00
committed by GitHub
parent 7a330b4a02
commit 4e654654da
36 changed files with 1037 additions and 212 deletions
@@ -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>
));
@@ -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>
);
}
@@ -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 }) => (
@@ -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>
);
@@ -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>
);
@@ -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}
@@ -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>) => {
@@ -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}
+59 -57
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';
@@ -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
@@ -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
@@ -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>
@@ -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
@@ -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>
);
};
@@ -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 },
});
@@ -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}
/>
</>
}
@@ -0,0 +1,3 @@
export enum TableOptionsHotkeyScope {
Dropdown = 'table-options-dropdown',
}
@@ -0,0 +1,3 @@
export enum TableViewsHotkeyScope {
Dropdown = 'table-views-dropdown',
}
@@ -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}>