feat: improve table options dropdown view name input (#1604)
- Always show view name input in dropdown - Edit current view name by default - Add focus style - Reset view edit mode on dropdown close Closes #1540
This commit is contained in:
@ -2,10 +2,10 @@ import { useRef } from 'react';
|
||||
import { Keys } from 'react-hotkeys-hook';
|
||||
import { flip, offset, Placement, useFloating } from '@floating-ui/react';
|
||||
|
||||
import { HotkeyEffect } from '@/ui/utilities/hotkey/components/HotkeyEffect';
|
||||
import { HotkeyScope } from '@/ui/utilities/hotkey/types/HotkeyScope';
|
||||
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
|
||||
|
||||
import { HotkeyEffect } from '../../utilities/hotkey/components/HotkeyEffect';
|
||||
import { useDropdownButton } from '../hooks/useDropdownButton';
|
||||
import { useInternalHotkeyScopeManagement } from '../hooks/useInternalHotkeyScopeManagement';
|
||||
|
||||
@ -19,6 +19,7 @@ type OwnProps = {
|
||||
};
|
||||
dropdownHotkeyScope?: HotkeyScope;
|
||||
dropdownPlacement?: Placement;
|
||||
onClickOutside?: () => void;
|
||||
};
|
||||
|
||||
export function DropdownButton({
|
||||
@ -28,6 +29,7 @@ export function DropdownButton({
|
||||
hotkey,
|
||||
dropdownHotkeyScope,
|
||||
dropdownPlacement = 'bottom-end',
|
||||
onClickOutside,
|
||||
}: OwnProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@ -48,6 +50,8 @@ export function DropdownButton({
|
||||
useListenClickOutside({
|
||||
refs: [containerRef],
|
||||
callback: () => {
|
||||
onClickOutside?.();
|
||||
|
||||
if (isDropdownButtonOpen) {
|
||||
closeDropdownButton();
|
||||
}
|
||||
|
||||
@ -11,8 +11,7 @@ const StyledHeader = styled.li`
|
||||
font-size: ${({ theme }) => theme.font.size.sm};
|
||||
font-weight: ${({ theme }) => theme.font.weight.medium};
|
||||
|
||||
padding: calc(${({ theme }) => theme.spacing(2)})
|
||||
calc(${({ theme }) => theme.spacing(2)});
|
||||
padding: ${({ theme }) => theme.spacing(2)};
|
||||
|
||||
user-select: none;
|
||||
|
||||
|
||||
@ -13,7 +13,7 @@ const StyledDropdownMenuInputContainer = styled.div`
|
||||
height: calc(36px - 2 * var(--vertical-padding));
|
||||
padding: var(--vertical-padding) 0;
|
||||
|
||||
width: calc(100%);
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledInput = styled.input`
|
||||
|
||||
@ -1,5 +1,8 @@
|
||||
import { useResetRecoilState } from 'recoil';
|
||||
|
||||
import { DropdownButton } from '@/ui/dropdown/components/DropdownButton';
|
||||
import { HotkeyScope } from '@/ui/utilities/hotkey/types/HotkeyScope';
|
||||
import { viewEditModeState } from '@/ui/view-bar/states/viewEditModeState';
|
||||
|
||||
import { TableOptionsDropdownId } from '../../constants/TableOptionsDropdownId';
|
||||
|
||||
@ -15,12 +18,15 @@ export function TableOptionsDropdown({
|
||||
onImport,
|
||||
customHotkeyScope,
|
||||
}: TableOptionsDropdownProps) {
|
||||
const resetViewEditMode = useResetRecoilState(viewEditModeState);
|
||||
|
||||
return (
|
||||
<DropdownButton
|
||||
buttonComponents={<TableOptionsDropdownButton />}
|
||||
dropdownHotkeyScope={customHotkeyScope}
|
||||
dropdownId={TableOptionsDropdownId}
|
||||
dropdownComponents={<TableOptionsDropdownContent onImport={onImport} />}
|
||||
onClickOutside={resetViewEditMode}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,20 +1,23 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { useRecoilCallback, useRecoilValue } from 'recoil';
|
||||
import styled from '@emotion/styled';
|
||||
import { useRecoilCallback, useRecoilValue, useResetRecoilState } from 'recoil';
|
||||
import { Key } from 'ts-key-enum';
|
||||
|
||||
import { DropdownMenuHeader } from '@/ui/dropdown/components/DropdownMenuHeader';
|
||||
import { DropdownMenuInput } from '@/ui/dropdown/components/DropdownMenuInput';
|
||||
import { StyledDropdownMenu } from '@/ui/dropdown/components/StyledDropdownMenu';
|
||||
import { StyledDropdownMenuItemsContainer } from '@/ui/dropdown/components/StyledDropdownMenuItemsContainer';
|
||||
import { StyledDropdownMenuSeparator } from '@/ui/dropdown/components/StyledDropdownMenuSeparator';
|
||||
import { useDropdownButton } from '@/ui/dropdown/hooks/useDropdownButton';
|
||||
import { IconChevronLeft, IconFileImport, IconTag } from '@/ui/icon';
|
||||
import { MenuItem } from '@/ui/menu-item/components/MenuItem';
|
||||
import { rgba } from '@/ui/theme/constants/colors';
|
||||
import { textInputStyle } from '@/ui/theme/constants/effects';
|
||||
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 { ViewFieldsVisibilityDropdownSection } from '@/ui/view-bar/components/ViewFieldsVisibilityDropdownSection';
|
||||
import { useUpsertView } from '@/ui/view-bar/hooks/useUpsertView';
|
||||
import { currentViewScopedSelector } from '@/ui/view-bar/states/selectors/currentViewScopedSelector';
|
||||
import { viewsByIdScopedSelector } from '@/ui/view-bar/states/selectors/viewsByIdScopedSelector';
|
||||
import { viewEditModeState } from '@/ui/view-bar/states/viewEditModeState';
|
||||
|
||||
@ -33,6 +36,29 @@ type TableOptionsDropdownButtonProps = {
|
||||
|
||||
type TableOptionsMenu = 'fields';
|
||||
|
||||
const StyledInputContainer = styled.div`
|
||||
box-sizing: border-box;
|
||||
padding: ${({ theme }) => theme.spacing(1)};
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledViewNameInput = styled.input`
|
||||
${textInputStyle}
|
||||
|
||||
border: 1px solid ${({ theme }) => theme.border.color.medium};
|
||||
border-radius: ${({ theme }) => theme.border.radius.sm};
|
||||
box-sizing: border-box;
|
||||
font-weight: ${({ theme }) => theme.font.weight.medium};
|
||||
height: 32px;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
&:focus {
|
||||
border-color: ${({ theme }) => theme.color.blue};
|
||||
box-shadow: 0px 0px 0px 3px ${({ theme }) => rgba(theme.color.blue, 0.1)};
|
||||
}
|
||||
`;
|
||||
|
||||
export function TableOptionsDropdownContent({
|
||||
onImport,
|
||||
}: TableOptionsDropdownButtonProps) {
|
||||
@ -48,7 +74,12 @@ export function TableOptionsDropdownContent({
|
||||
|
||||
const viewEditInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const currentView = useRecoilScopedValue(
|
||||
currentViewScopedSelector,
|
||||
TableRecoilScopeContext,
|
||||
);
|
||||
const viewEditMode = useRecoilValue(viewEditModeState);
|
||||
const resetViewEditMode = useResetRecoilState(viewEditModeState);
|
||||
const visibleTableColumns = useRecoilScopedValue(
|
||||
visibleTableColumnsScopedSelector,
|
||||
TableRecoilScopeContext,
|
||||
@ -95,6 +126,7 @@ export function TableOptionsDropdownContent({
|
||||
useScopedHotkeys(
|
||||
Key.Escape,
|
||||
() => {
|
||||
resetViewEditMode();
|
||||
closeDropdownButton();
|
||||
},
|
||||
TableOptionsHotkeyScope.Dropdown,
|
||||
@ -105,6 +137,7 @@ export function TableOptionsDropdownContent({
|
||||
() => {
|
||||
handleViewNameSubmit();
|
||||
resetMenu();
|
||||
resetViewEditMode();
|
||||
closeDropdownButton();
|
||||
},
|
||||
TableOptionsHotkeyScope.Dropdown,
|
||||
@ -114,22 +147,24 @@ export function TableOptionsDropdownContent({
|
||||
<StyledDropdownMenu>
|
||||
{!currentMenu && (
|
||||
<>
|
||||
{!!viewEditMode.mode ? (
|
||||
<DropdownMenuInput
|
||||
<StyledInputContainer>
|
||||
<StyledViewNameInput
|
||||
ref={viewEditInputRef}
|
||||
autoFocus
|
||||
autoFocus={
|
||||
viewEditMode.mode === 'create' || !!viewEditMode.viewId
|
||||
}
|
||||
placeholder={
|
||||
viewEditMode.mode === 'create' ? 'New view' : 'View name'
|
||||
}
|
||||
defaultValue={
|
||||
viewEditMode.viewId
|
||||
viewEditMode.mode === 'create'
|
||||
? ''
|
||||
: viewEditMode.viewId
|
||||
? viewsById[viewEditMode.viewId]?.name
|
||||
: undefined
|
||||
: currentView?.name
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<DropdownMenuHeader>View settings</DropdownMenuHeader>
|
||||
)}
|
||||
</StyledInputContainer>
|
||||
<StyledDropdownMenuSeparator />
|
||||
<StyledDropdownMenuItemsContainer>
|
||||
<MenuItem
|
||||
|
||||
@ -0,0 +1,37 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import { userEvent, within } from '@storybook/testing-library';
|
||||
|
||||
import { DropdownRecoilScopeContext } from '@/ui/dropdown/states/recoil-scope-contexts/DropdownRecoilScopeContext';
|
||||
import { RecoilScope } from '@/ui/utilities/recoil-scope/components/RecoilScope';
|
||||
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
|
||||
|
||||
import { TableRecoilScopeContext } from '../../../states/recoil-scope-contexts/TableRecoilScopeContext';
|
||||
import { TableOptionsDropdown } from '../TableOptionsDropdown';
|
||||
|
||||
const meta: Meta<typeof TableOptionsDropdown> = {
|
||||
title: 'UI/Table/Options/TableOptionsDropdown',
|
||||
component: TableOptionsDropdown,
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<RecoilScope SpecificContext={TableRecoilScopeContext}>
|
||||
<RecoilScope SpecificContext={DropdownRecoilScopeContext}>
|
||||
<Story />
|
||||
</RecoilScope>
|
||||
</RecoilScope>
|
||||
),
|
||||
ComponentDecorator,
|
||||
],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof TableOptionsDropdown>;
|
||||
|
||||
export const Default: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
const dropdownButton = canvas.getByText('Options');
|
||||
|
||||
await userEvent.click(dropdownButton);
|
||||
},
|
||||
};
|
||||
@ -1,5 +1,5 @@
|
||||
import { type Context, useCallback, useContext } from 'react';
|
||||
import { useRecoilCallback, useRecoilState } from 'recoil';
|
||||
import { type Context, useContext } from 'react';
|
||||
import { useRecoilCallback, useRecoilValue, useResetRecoilState } from 'recoil';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import { useContextScopeId } from '@/ui/utilities/recoil-scope/hooks/useContextScopeId';
|
||||
@ -10,6 +10,7 @@ import { currentViewIdScopedState } from '../states/currentViewIdScopedState';
|
||||
import { filtersScopedState } from '../states/filtersScopedState';
|
||||
import { savedFiltersFamilyState } from '../states/savedFiltersFamilyState';
|
||||
import { savedSortsFamilyState } from '../states/savedSortsFamilyState';
|
||||
import { currentViewScopedSelector } from '../states/selectors/currentViewScopedSelector';
|
||||
import { viewsByIdScopedSelector } from '../states/selectors/viewsByIdScopedSelector';
|
||||
import { sortsScopedState } from '../states/sortsScopedState';
|
||||
import { viewEditModeState } from '../states/viewEditModeState';
|
||||
@ -25,12 +26,8 @@ export const useUpsertView = ({
|
||||
|
||||
const filters = useRecoilScopedValue(filtersScopedState, scopeContext);
|
||||
const sorts = useRecoilScopedValue(sortsScopedState, scopeContext);
|
||||
const [viewEditMode, setViewEditMode] = useRecoilState(viewEditModeState);
|
||||
|
||||
const resetViewEditMode = useCallback(
|
||||
() => setViewEditMode({ mode: undefined, viewId: undefined }),
|
||||
[setViewEditMode],
|
||||
);
|
||||
const viewEditMode = useRecoilValue(viewEditModeState);
|
||||
const resetViewEditMode = useResetRecoilState(viewEditModeState);
|
||||
|
||||
const upsertView = useRecoilCallback(
|
||||
({ set, snapshot }) =>
|
||||
@ -60,26 +57,38 @@ export const useUpsertView = ({
|
||||
return createdView;
|
||||
}
|
||||
|
||||
if (viewEditMode.mode === 'edit' && viewEditMode.viewId) {
|
||||
const viewsById = await snapshot.getPromise(
|
||||
viewsByIdScopedSelector(recoilScopeId),
|
||||
);
|
||||
const editedView = { ...viewsById[viewEditMode.viewId], name };
|
||||
const viewsById = await snapshot.getPromise(
|
||||
viewsByIdScopedSelector(recoilScopeId),
|
||||
);
|
||||
const currentView = await snapshot.getPromise(
|
||||
currentViewScopedSelector(recoilScopeId),
|
||||
);
|
||||
|
||||
set(viewsScopedState(recoilScopeId), (previousViews) =>
|
||||
previousViews.map((previousView) =>
|
||||
previousView.id === viewEditMode.viewId
|
||||
? editedView
|
||||
: previousView,
|
||||
),
|
||||
);
|
||||
|
||||
await onViewEdit?.(editedView);
|
||||
const viewToEdit = viewEditMode.viewId
|
||||
? viewsById[viewEditMode.viewId]
|
||||
: currentView;
|
||||
|
||||
if (!viewToEdit) {
|
||||
resetViewEditMode();
|
||||
|
||||
return editedView;
|
||||
return;
|
||||
}
|
||||
|
||||
const editedView = {
|
||||
...viewToEdit,
|
||||
name,
|
||||
};
|
||||
|
||||
set(viewsScopedState(recoilScopeId), (previousViews) =>
|
||||
previousViews.map((previousView) =>
|
||||
previousView.id === editedView.id ? editedView : previousView,
|
||||
),
|
||||
);
|
||||
|
||||
await onViewEdit?.(editedView);
|
||||
|
||||
resetViewEditMode();
|
||||
|
||||
return editedView;
|
||||
},
|
||||
[
|
||||
filters,
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import { atom } from 'recoil';
|
||||
|
||||
export const viewEditModeState = atom<{
|
||||
mode: 'create' | 'edit' | undefined;
|
||||
mode: 'create' | 'edit';
|
||||
viewId: string | undefined;
|
||||
}>({
|
||||
key: 'viewEditModeState',
|
||||
default: { mode: undefined, viewId: undefined },
|
||||
default: { mode: 'edit', viewId: undefined },
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user