Refactoring shortcuts and commandbar (#412)

* Begin refactoring shortcuts and commandbar

* Continue refacto hotkeys

* Remove debug logs

* Add new story

* Simplify hotkeys

* Simplify hotkeys

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Félix Malfait
2023-06-25 22:25:31 -07:00
committed by GitHub
parent 9bd8f6df01
commit 827d6390e4
19 changed files with 387 additions and 414 deletions

View File

@ -2,6 +2,7 @@ import { Navigate, Route, Routes } from 'react-router-dom';
import { RequireAuth } from '@/auth/components/RequireAuth'; import { RequireAuth } from '@/auth/components/RequireAuth';
import { RequireNotAuth } from '@/auth/components/RequireNotAuth'; import { RequireNotAuth } from '@/auth/components/RequireNotAuth';
import { useGoToHotkeys } from '@/hotkeys/hooks/useGoToHotkeys';
import { DefaultLayout } from '@/ui/layout/DefaultLayout'; import { DefaultLayout } from '@/ui/layout/DefaultLayout';
import { Index } from '~/pages/auth/Index'; import { Index } from '~/pages/auth/Index';
import { PasswordLogin } from '~/pages/auth/PasswordLogin'; import { PasswordLogin } from '~/pages/auth/PasswordLogin';
@ -12,6 +13,11 @@ import { People } from '~/pages/people/People';
import { SettingsProfile } from '~/pages/settings/SettingsProfile'; import { SettingsProfile } from '~/pages/settings/SettingsProfile';
export function App() { export function App() {
useGoToHotkeys('p', '/people');
useGoToHotkeys('c', '/companies');
useGoToHotkeys('o', '/opportunities');
useGoToHotkeys('s', '/settings/profile');
return ( return (
<DefaultLayout> <DefaultLayout>
<Routes> <Routes>

View File

@ -0,0 +1,96 @@
import React from 'react';
import { useRecoilState } from 'recoil';
import { useDirectHotkeys } from '@/hotkeys/hooks/useDirectHotkeys';
import { isCommandMenuOpenedState } from '../states/isCommandMenuOpened';
import { CommandMenuItem } from './CommandMenuItem';
import {
StyledDialog,
StyledEmpty,
StyledGroup,
StyledInput,
StyledList,
// StyledSeparator,
} from './CommandMenuStyles';
export function CommandMenu() {
const [open, setOpen] = useRecoilState(isCommandMenuOpenedState);
useDirectHotkeys(
'ctrl+k,meta+k',
() => {
setOpen((prevOpen) => !prevOpen);
},
[setOpen],
);
/*
TODO: Allow performing actions on page through CommandBar
import { useMatch, useResolvedPath } from 'react-router-dom';
import { IconBuildingSkyscraper, IconUser } from '@/ui/icons';
const createSection = (
<StyledGroup heading="Create">
<CommandMenuItem
label="Create People"
onClick={createPeople}
icon={<IconUser />}
shortcuts={
!!useMatch({
path: useResolvedPath('/people').pathname,
end: true,
})
? ['C']
: []
}
/>
<CommandMenuItem
label="Create Company"
onClick={createCompany}
icon={<IconBuildingSkyscraper />}
shortcuts={
!!useMatch({
path: useResolvedPath('/companies').pathname,
end: true,
})
? ['C']
: []
}
/>
</StyledGroup>
);*/
return (
<StyledDialog
open={open}
onOpenChange={setOpen}
label="Global Command Menu"
>
<StyledInput placeholder="Search" />
<StyledList>
<StyledEmpty>No results found.</StyledEmpty>
<StyledGroup heading="Go to">
<CommandMenuItem to="/people" label="People" shortcuts={['G', 'P']} />
<CommandMenuItem
to="/companies"
label="Companies"
shortcuts={['G', 'C']}
/>
<CommandMenuItem
to="/opportunities"
label="Opportunities"
shortcuts={['G', 'O']}
/>
<CommandMenuItem
to="/settings/profile"
label="Settings"
shortcuts={['G', 'S']}
/>
</StyledGroup>
</StyledList>
</StyledDialog>
);
}

View File

@ -0,0 +1,73 @@
import React from 'react';
import { ReactNode } from 'react';
import { useNavigate } from 'react-router-dom';
import { useSetRecoilState } from 'recoil';
import { IconArrowUpRight } from '@/ui/icons';
import { isCommandMenuOpenedState } from '../states/isCommandMenuOpened';
import {
StyledIconAndLabelContainer,
StyledIconContainer,
StyledMenuItem,
StyledShortCut,
StyledShortcutsContainer,
} from './CommandMenuStyles';
export type OwnProps = {
label: string;
to?: string;
onClick?: () => void;
icon?: ReactNode;
shortcuts?: Array<string>;
};
export function CommandMenuItem({
label,
to,
onClick,
icon,
shortcuts,
}: OwnProps) {
const setOpen = useSetRecoilState(isCommandMenuOpenedState);
const navigate = useNavigate();
if (to) {
icon = <IconArrowUpRight />;
}
const onItemClick = () => {
setOpen(false);
if (onClick) {
onClick();
return;
}
if (to) {
navigate(to);
return;
}
};
return (
<StyledMenuItem onSelect={onItemClick}>
<StyledIconAndLabelContainer>
<StyledIconContainer>{icon}</StyledIconContainer>
{label}
</StyledIconAndLabelContainer>
<StyledShortcutsContainer>
{shortcuts &&
shortcuts.map((shortcut, index) => {
const prefix = index > 0 ? 'then' : '';
return (
<React.Fragment key={index}>
{prefix}
<StyledShortCut>{shortcut}</StyledShortCut>
</React.Fragment>
);
})}
</StyledShortcutsContainer>
</StyledMenuItem>
);
}

View File

@ -4,7 +4,7 @@ import { Command } from 'cmdk';
export const StyledDialog = styled(Command.Dialog)` export const StyledDialog = styled(Command.Dialog)`
background: ${(props) => props.theme.primaryBackground}; background: ${(props) => props.theme.primaryBackground};
border-radius: ${(props) => props.theme.borderRadius}; border-radius: ${(props) => props.theme.borderRadius};
box-shadow: ${(props) => props.theme.modalBoxShadow}; box-shadow: ${(props) => props.theme.heavyBoxShadow};
font-family: ${(props) => props.theme.fontFamily}; font-family: ${(props) => props.theme.fontFamily};
left: 50%; left: 50%;
max-width: 640px; max-width: 640px;
@ -21,7 +21,6 @@ export const StyledInput = styled(Command.Input)`
border: none; border: none;
border-bottom: 1px solid ${(props) => props.theme.primaryBorder}; border-bottom: 1px solid ${(props) => props.theme.primaryBorder};
border-radius: 0; border-radius: 0;
caret-color: ${(props) => props.theme.blue};
color: ${(props) => props.theme.text100}; color: ${(props) => props.theme.text100};
font-size: ${(props) => props.theme.fontSizeLarge}; font-size: ${(props) => props.theme.fontSizeLarge};
margin: 0; margin: 0;
@ -30,14 +29,15 @@ export const StyledInput = styled(Command.Input)`
width: 100%; width: 100%;
`; `;
export const StyledItem = styled(Command.Item)` export const StyledMenuItem = styled(Command.Item)`
align-items: center; align-items: center;
color: ${(props) => props.theme.text100}; color: ${(props) => props.theme.text80};
cursor: pointer; cursor: pointer;
display: flex; display: flex;
font-size: ${(props) => props.theme.fontSizeMedium}; font-size: ${(props) => props.theme.fontSizeMedium};
gap: ${(props) => props.theme.spacing(3)}; gap: ${(props) => props.theme.spacing(3)};
height: 48px; height: 40px;
justify-content: space-between;
padding: 0 ${(props) => props.theme.spacing(4)}; padding: 0 ${(props) => props.theme.spacing(4)};
position: relative; position: relative;
transition: all 150ms ease; transition: all 150ms ease;
@ -47,23 +47,24 @@ export const StyledItem = styled(Command.Item)`
background: ${(props) => props.theme.lightBackgroundTransparent}; background: ${(props) => props.theme.lightBackgroundTransparent};
} }
&[data-selected='true'] { &[data-selected='true'] {
background: ${(props) => props.theme.secondaryBackground}; background: ${(props) => props.theme.tertiaryBackground};
/* Could be nice to add a caret like this for better accessibility in the future
But it needs to be consistend with other picker dropdown (e.g. company)
&:after { &:after {
background: ${(props) => props.theme.blue}; background: ${(props) => props.theme.quaternaryBackground};
content: ''; content: '';
height: 100%; height: 100%;
left: 0; left: 0;
position: absolute; position: absolute;
width: 3px; width: 3px;
z-index: ${(props) => props.theme.lastLayerZIndex}; z-index: ${(props) => props.theme.lastLayerZIndex};
} } */
} }
&[data-disabled='true'] { &[data-disabled='true'] {
color: ${(props) => props.theme.text30}; color: ${(props) => props.theme.text30};
cursor: not-allowed; cursor: not-allowed;
} }
svg { svg {
color: ${(props) => props.theme.text80};
height: 16px; height: 16px;
width: 16px; width: 16px;
} }
@ -85,7 +86,12 @@ export const StyledGroup = styled(Command.Group)`
color: ${(props) => props.theme.text30}; color: ${(props) => props.theme.text30};
display: flex; display: flex;
font-size: ${(props) => props.theme.fontSizeExtraSmall}; font-size: ${(props) => props.theme.fontSizeExtraSmall};
padding: ${(props) => props.theme.spacing(2)}; font-weight: ${(props) => props.theme.fontWeightBold};
padding-bottom: ${(props) => props.theme.spacing(2)};
padding-left: ${(props) => props.theme.spacing(4)};
padding-right: ${(props) => props.theme.spacing(4)};
padding-top: ${(props) => props.theme.spacing(2)};
text-transform: uppercase;
user-select: none; user-select: none;
} }
`; `;
@ -101,3 +107,32 @@ export const StyledEmpty = styled(Command.Empty)`
`; `;
export const StyledSeparator = styled(Command.Separator)``; export const StyledSeparator = styled(Command.Separator)``;
export const StyledIconAndLabelContainer = styled.div`
align-items: center;
display: flex;
gap: ${(props) => props.theme.spacing(2)};
`;
export const StyledIconContainer = styled.div`
align-items: center;
background: ${(props) => props.theme.lightBackgroundTransparent};
border-radius: 4px;
color: ${(props) => props.theme.text60};
display: flex;
padding: ${(props) => props.theme.spacing(1)};
`;
export const StyledShortCut = styled.div`
background-color: ${(props) => props.theme.lightBackgroundTransparent};
border-radius: 4px;
color: ${(props) => props.theme.text30};
margin-left: ${(props) => props.theme.spacing(1)};
margin-right: ${(props) => props.theme.spacing(1)};
padding: ${(props) => props.theme.spacing(1)};
`;
export const StyledShortcutsContainer = styled.div`
align-items: center;
color: ${(props) => props.theme.text30};
display: flex;
font-size: ${(props) => props.theme.fontSizeSmall};
`;

View File

@ -1,12 +1,13 @@
import { MemoryRouter } from 'react-router-dom'; import { MemoryRouter } from 'react-router-dom';
import type { Meta, StoryObj } from '@storybook/react'; import type { Meta, StoryObj } from '@storybook/react';
import { fireEvent } from '@storybook/testing-library';
import { getRenderWrapperForComponent } from '~/testing/renderWrappers'; import { getRenderWrapperForComponent } from '~/testing/renderWrappers';
import { CommandMenu } from '../CommandMenu'; import { CommandMenu } from '../CommandMenu';
const meta: Meta<typeof CommandMenu> = { const meta: Meta<typeof CommandMenu> = {
title: 'Modules/Search/CommandMenu', title: 'Modules/CommandMenu/CommandMenu',
component: CommandMenu, component: CommandMenu,
}; };
@ -16,7 +17,22 @@ type Story = StoryObj<typeof CommandMenu>;
export const Default: Story = { export const Default: Story = {
render: getRenderWrapperForComponent( render: getRenderWrapperForComponent(
<MemoryRouter> <MemoryRouter>
<CommandMenu initiallyOpen={true} /> <CommandMenu />
</MemoryRouter>, </MemoryRouter>,
), ),
}; };
export const CmdK: Story = {
render: getRenderWrapperForComponent(
<MemoryRouter>
<CommandMenu />
</MemoryRouter>,
),
play: async ({ canvasElement }) => {
fireEvent.keyDown(canvasElement, {
key: 'k',
code: 'KeyK',
metaKey: true,
});
},
};

View File

@ -0,0 +1,6 @@
import { atom } from 'recoil';
export const isCommandMenuOpenedState = atom({
key: 'command-menu/isCommandMenuOpenedState',
default: false,
});

View File

@ -0,0 +1,30 @@
import { useHotkeys } from 'react-hotkeys-hook';
import {
Hotkey,
HotkeyCallback,
OptionsOrDependencyArray,
} from 'react-hotkeys-hook/dist/types';
import { useRecoilState } from 'recoil';
import { pendingHotkeyState } from '../states/pendingHotkeysState';
export function useDirectHotkeys(
keys: string,
callback: HotkeyCallback,
dependencies?: OptionsOrDependencyArray,
) {
const [pendingHotkey, setPendingHotkey] = useRecoilState(pendingHotkeyState);
const callbackIfDirectKey = function (
keyboardEvent: KeyboardEvent,
hotkeysEvent: Hotkey,
) {
if (!pendingHotkey) {
callback(keyboardEvent, hotkeysEvent);
return;
}
setPendingHotkey(null);
};
useHotkeys(keys, callbackIfDirectKey, dependencies);
}

View File

@ -0,0 +1,11 @@
import { useNavigate } from 'react-router-dom';
import { useSequenceHotkeys } from './useSequenceHotkeys';
export function useGoToHotkeys(key: string, location: string) {
const navigate = useNavigate();
useSequenceHotkeys('g', key, () => {
navigate(location);
});
}

View File

@ -0,0 +1,32 @@
import { useHotkeys } from 'react-hotkeys-hook';
import { useRecoilState } from 'recoil';
import { pendingHotkeyState } from '../states/pendingHotkeysState';
export function useSequenceHotkeys(
firstKey: string,
secondKey: string,
callback: () => void,
) {
const [pendingHotkey, setPendingHotkey] = useRecoilState(pendingHotkeyState);
useHotkeys(
firstKey,
() => {
setPendingHotkey(firstKey);
},
[pendingHotkey],
);
useHotkeys(
secondKey,
() => {
if (pendingHotkey !== firstKey) {
return;
}
setPendingHotkey(null);
callback();
},
[pendingHotkey, setPendingHotkey],
);
}

View File

@ -0,0 +1,31 @@
import { useHotkeys } from 'react-hotkeys-hook';
import { HotkeyCallback } from 'react-hotkeys-hook/dist/types';
import { OptionsOrDependencyArray } from 'react-hotkeys-hook/dist/types';
export function useUpDownHotkeys(
upArrowCallBack: HotkeyCallback,
downArrownCallback: HotkeyCallback,
dependencies?: OptionsOrDependencyArray,
) {
useHotkeys(
'up',
upArrowCallBack,
{
enableOnContentEditable: true,
enableOnFormTags: true,
preventDefault: true,
},
dependencies,
);
useHotkeys(
'down',
downArrownCallback,
{
enableOnContentEditable: true,
enableOnFormTags: true,
preventDefault: true,
},
dependencies,
);
}

View File

@ -0,0 +1,6 @@
import { atom } from 'recoil';
export const pendingHotkeyState = atom<string | null>({
key: 'command-menu/pendingHotkeyState',
default: null,
});

View File

@ -1,8 +1,8 @@
import { useState } from 'react'; import { useState } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { debounce } from 'lodash'; import { debounce } from 'lodash';
import scrollIntoView from 'scroll-into-view'; import scrollIntoView from 'scroll-into-view';
import { useUpDownHotkeys } from '@/hotkeys/hooks/useUpDownHotkeys';
import { useRecoilScopedState } from '@/ui/hooks/useRecoilScopedState'; import { useRecoilScopedState } from '@/ui/hooks/useRecoilScopedState';
import { relationPickerSearchFilterScopedState } from '../states/relationPickerSearchFilterScopedState'; import { relationPickerSearchFilterScopedState } from '../states/relationPickerSearchFilterScopedState';
@ -34,39 +34,7 @@ export function useEntitySelectLogic<
setHoveredIndex(0); setHoveredIndex(0);
} }
useHotkeys( useUpDownHotkeys(
'down',
() => {
setHoveredIndex((prevSelectedIndex) =>
Math.min(prevSelectedIndex + 1, (entities?.length ?? 0) - 1),
);
const currentHoveredRef = containerRef.current?.children[
hoveredIndex
] as HTMLElement;
if (currentHoveredRef) {
scrollIntoView(currentHoveredRef, {
align: {
top: 0.275,
},
isScrollable: (target) => {
return target === containerRef.current;
},
time: 0,
});
}
},
{
enableOnContentEditable: true,
enableOnFormTags: true,
preventDefault: true,
},
[setHoveredIndex, entities],
);
useHotkeys(
'up',
() => { () => {
setHoveredIndex((prevSelectedIndex) => setHoveredIndex((prevSelectedIndex) =>
Math.max(prevSelectedIndex - 1, 0), Math.max(prevSelectedIndex - 1, 0),
@ -88,10 +56,26 @@ export function useEntitySelectLogic<
}); });
} }
}, },
{ () => {
enableOnContentEditable: true, setHoveredIndex((prevSelectedIndex) =>
enableOnFormTags: true, Math.min(prevSelectedIndex + 1, (entities?.length ?? 0) - 1),
preventDefault: true, );
const currentHoveredRef = containerRef.current?.children[
hoveredIndex
] as HTMLElement;
if (currentHoveredRef) {
scrollIntoView(currentHoveredRef, {
align: {
top: 0.275,
},
isScrollable: (target) => {
return target === containerRef.current;
},
time: 0,
});
}
}, },
[setHoveredIndex, entities], [setHoveredIndex, entities],
); );

View File

@ -1,72 +0,0 @@
import React from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useNavigate } from 'react-router-dom';
import {
StyledDialog,
StyledEmpty,
StyledGroup,
StyledInput,
StyledItem,
StyledList,
// StyledSeparator,
} from './CommandMenuStyles';
export const CommandMenu = ({ initiallyOpen = false }) => {
const [open, setOpen] = React.useState(initiallyOpen);
useHotkeys(
'ctrl+k,meta+k',
() => {
setOpen((prevOpen) => !prevOpen);
},
{
preventDefault: true,
enableOnContentEditable: true,
enableOnFormTags: true,
},
[setOpen],
);
const navigate = useNavigate();
return (
<StyledDialog
open={open}
onOpenChange={setOpen}
label="Global Command Menu"
>
<StyledInput />
<StyledList>
<StyledEmpty>No results found.</StyledEmpty>
<StyledGroup heading="Go to">
<StyledItem
onSelect={() => {
setOpen(false);
navigate('/people');
}}
>
People
</StyledItem>
<StyledItem
onSelect={() => {
setOpen(false);
navigate('/companies');
}}
>
Companies
</StyledItem>
<StyledItem
onSelect={() => {
setOpen(false);
navigate('/settings/profile');
}}
>
Settings
</StyledItem>
</StyledGroup>
</StyledList>
</StyledDialog>
);
};

View File

@ -1,288 +0,0 @@
import { ChangeEvent, ComponentType, useEffect, useState } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import styled from '@emotion/styled';
import { useRecoilState } from 'recoil';
import { SearchConfigType } from '@/search/interfaces/interface';
import { useSearch } from '@/search/services/search';
import { IconPlus } from '@/ui/icons/index';
import { textInputStyle } from '@/ui/layout/styles/themes';
import { isSomeInputInEditModeState } from '@/ui/tables/states/isSomeInputInEditModeState';
import { isDefined } from '@/utils/type-guards/isDefined';
import { isNonEmptyString } from '@/utils/type-guards/isNonEmptyString';
import { EditableCell } from '../EditableCell';
import { HoverableMenuItem } from '../HoverableMenuItem';
import { EditableRelationCreateButton } from './EditableRelationCreateButton';
const StyledEditModeContainer = styled.div`
width: 200px;
`;
const StyledEditModeSelectedContainer = styled.div`
align-items: center;
display: flex;
height: 31px;
padding-left: ${(props) => props.theme.spacing(2)};
padding-right: ${(props) => props.theme.spacing(1)};
`;
const StyledEditModeSearchContainer = styled.div`
align-items: center;
border-top: 1px solid ${(props) => props.theme.primaryBorder};
display: flex;
height: 32px;
padding-left: ${(props) => props.theme.spacing(1)};
padding-right: ${(props) => props.theme.spacing(1)};
`;
const StyledEditModeCreateButtonContainer = styled.div`
align-items: center;
border-top: 1px solid ${(props) => props.theme.primaryBorder};
color: ${(props) => props.theme.text60};
display: flex;
height: 36px;
padding: ${(props) => props.theme.spacing(1)};
`;
const StyledEditModeSearchInput = styled.input`
width: 100%;
${textInputStyle}
`;
const StyledEditModeResults = styled.div`
border-top: 1px solid ${(props) => props.theme.primaryBorder};
padding-left: ${(props) => props.theme.spacing(1)};
padding-right: ${(props) => props.theme.spacing(1)};
`;
type StyledEditModeResultItemProps = {
isSelected: boolean;
};
const StyledEditModeResultItem = styled.div<StyledEditModeResultItemProps>`
align-items: center;
cursor: pointer;
display: flex;
height: 32px;
user-select: none;
${(props) =>
props.isSelected &&
`
background-color: ${props.theme.tertiaryBackground};
`}
`;
const StyledCreateButtonIcon = styled.div`
align-self: center;
color: ${(props) => props.theme.text100};
padding-top: 4px;
`;
const StyledCreateButtonText = styled.div`
color: ${(props) => props.theme.text60};
`;
export type EditableRelationProps<RelationType, ChipComponentPropsType> = {
relation?: any;
searchPlaceholder: string;
searchConfig: SearchConfigType;
onChange: (relation: RelationType) => void;
onChangeSearchInput?: (searchInput: string) => void;
editModeHorizontalAlign?: 'left' | 'right';
ChipComponent: ComponentType<ChipComponentPropsType>;
chipComponentPropsMapper: (
relation: RelationType,
) => ChipComponentPropsType & JSX.IntrinsicAttributes;
// TODO: refactor, newRelationName is too hard coded.
onCreate?: (newRelationName: string) => void;
};
// TODO: split this component
export function EditableRelation<RelationType, ChipComponentPropsType>({
relation,
searchPlaceholder,
searchConfig,
onChange,
onChangeSearchInput,
editModeHorizontalAlign,
ChipComponent,
chipComponentPropsMapper,
onCreate,
}: EditableRelationProps<RelationType, ChipComponentPropsType>) {
const [isEditMode, setIsEditMode] = useState(false);
const [, setIsSomeInputInEditMode] = useRecoilState(
isSomeInputInEditModeState,
);
// TODO: Tie this to a react context
const [filterSearchResults, setSearchInput, setFilterSearch, searchInput] =
useSearch<RelationType>();
useEffect(() => {
if (isDefined(onChangeSearchInput)) {
onChangeSearchInput(searchInput);
}
}, [onChangeSearchInput, searchInput]);
const canCreate = isDefined(onCreate);
const createButtonIsVisible =
canCreate && isEditMode && isNonEmptyString(searchInput);
function handleCreateNewRelationButtonClick() {
onCreate?.(searchInput);
closeEditMode();
}
function closeEditMode() {
setIsEditMode(false);
setIsSomeInputInEditMode(false);
}
const [selectedIndex, setSelectedIndex] = useState(0);
useHotkeys(
'down',
() => {
setSelectedIndex((prevSelectedIndex) =>
Math.min(
prevSelectedIndex + 1,
(filterSearchResults.results?.length ?? 0) - 1,
),
);
},
{
enableOnContentEditable: true,
enableOnFormTags: true,
preventDefault: true,
},
[setSelectedIndex, filterSearchResults.results],
);
useHotkeys(
'up',
() => {
setSelectedIndex((prevSelectedIndex) =>
Math.max(prevSelectedIndex - 1, 0),
);
},
{
enableOnContentEditable: true,
enableOnFormTags: true,
preventDefault: true,
},
[setSelectedIndex],
);
useHotkeys(
'enter',
() => {
if (isEditMode) {
if (
filterSearchResults.results &&
selectedIndex < filterSearchResults.results.length
) {
const selectedResult = filterSearchResults.results[selectedIndex];
onChange(selectedResult.value);
closeEditMode();
} else if (canCreate && isNonEmptyString(searchInput)) {
onCreate(searchInput);
closeEditMode();
}
}
},
{
enableOnContentEditable: true,
enableOnFormTags: true,
},
[
filterSearchResults.results,
selectedIndex,
onChange,
closeEditMode,
canCreate,
searchInput,
onCreate,
],
);
return (
<>
<EditableCell
editModeHorizontalAlign={editModeHorizontalAlign}
isEditMode={isEditMode}
onOutsideClick={() => setIsEditMode(false)}
onInsideClick={() => {
if (!isEditMode) {
setIsEditMode(true);
}
}}
editModeContent={
<StyledEditModeContainer>
<StyledEditModeSelectedContainer>
{relation ? (
<ChipComponent {...chipComponentPropsMapper(relation)} />
) : (
<></>
)}
</StyledEditModeSelectedContainer>
<StyledEditModeSearchContainer>
<StyledEditModeSearchInput
autoFocus
placeholder={searchPlaceholder}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
setFilterSearch(searchConfig);
setSearchInput(event.target.value);
}}
/>
</StyledEditModeSearchContainer>
{createButtonIsVisible && (
<StyledEditModeCreateButtonContainer>
<HoverableMenuItem>
<EditableRelationCreateButton
onClick={handleCreateNewRelationButtonClick}
>
<StyledCreateButtonIcon>
<IconPlus />
</StyledCreateButtonIcon>
<StyledCreateButtonText>Create new</StyledCreateButtonText>
</EditableRelationCreateButton>
</HoverableMenuItem>
</StyledEditModeCreateButtonContainer>
)}
<StyledEditModeResults>
{filterSearchResults.results &&
filterSearchResults.results.map((result, index) => (
<StyledEditModeResultItem
key={index}
isSelected={index === selectedIndex}
onClick={() => {
onChange(result.value);
closeEditMode();
}}
>
<HoverableMenuItem>
<ChipComponent
{...chipComponentPropsMapper(result.value)}
/>
</HoverableMenuItem>
</StyledEditModeResultItem>
))}
</StyledEditModeResults>
</StyledEditModeContainer>
}
nonEditModeContent={
<>
{relation ? (
<ChipComponent {...chipComponentPropsMapper(relation)} />
) : (
<></>
)}
</>
}
/>
</>
);
}

View File

@ -20,6 +20,7 @@ const StyledContainer = styled.div<StyledContainerProps>`
border: 1px solid ${(props) => props.theme.primaryBorder}; border: 1px solid ${(props) => props.theme.primaryBorder};
border-radius: 8px; border-radius: 8px;
bottom: ${(props) => (props.position.x ? 'auto' : '38px')}; bottom: ${(props) => (props.position.x ? 'auto' : '38px')};
box-shadow: ${(props) => props.theme.modalBoxShadow};
display: flex; display: flex;
height: 48px; height: 48px;

View File

@ -2,7 +2,7 @@ import styled from '@emotion/styled';
import { useRecoilState, useRecoilValue } from 'recoil'; import { useRecoilState, useRecoilValue } from 'recoil';
import { currentUserState } from '@/auth/states/currentUserState'; import { currentUserState } from '@/auth/states/currentUserState';
import { CommandMenu } from '@/search/components/CommandMenu'; import { CommandMenu } from '@/command-menu/components/CommandMenu';
import { AppNavbar } from '~/AppNavbar'; import { AppNavbar } from '~/AppNavbar';
import { NavbarContainer } from './navbar/NavbarContainer'; import { NavbarContainer } from './navbar/NavbarContainer';

View File

@ -31,9 +31,12 @@ const lightThemeSpecific = {
blueHighTransparency: 'rgba(25, 97, 237, 0.03)', blueHighTransparency: 'rgba(25, 97, 237, 0.03)',
blueLowTransparency: 'rgba(25, 97, 237, 0.32)', blueLowTransparency: 'rgba(25, 97, 237, 0.32)',
boxShadow: '0px 2px 4px 0px #0F0F0F0A', boxShadow: '0px 2px 4px 0px #0F0F0F0A',
modalBoxShadow: '0px 3px 12px rgba(0, 0, 0, 0.09)', modalBoxShadow:
'2px 4px 16px 0px rgba(0, 0, 0, 0.12), 0px 2px 4px 0px rgba(0, 0, 0, 0.04)',
lightBoxShadow: lightBoxShadow:
'0px 2px 4px 0px rgba(0, 0, 0, 0.04), 0px 0px 4px 0px rgba(0, 0, 0, 0.08)', '0px 2px 4px 0px rgba(0, 0, 0, 0.04), 0px 0px 4px 0px rgba(0, 0, 0, 0.08)',
heavyBoxShadow:
'0px 16px 40px 0px rgba(0, 0, 0, 0.24), 0px 0px 12px 0px rgba(0, 0, 0, 0.24)',
}; };
const darkThemeSpecific: typeof lightThemeSpecific = { const darkThemeSpecific: typeof lightThemeSpecific = {
@ -52,6 +55,8 @@ const darkThemeSpecific: typeof lightThemeSpecific = {
modalBoxShadow: '0px 3px 12px rgba(0, 0, 0, 0.09)', // TODO change color for dark theme modalBoxShadow: '0px 3px 12px rgba(0, 0, 0, 0.09)', // TODO change color for dark theme
lightBoxShadow: lightBoxShadow:
'0px 2px 4px 0px rgba(0, 0, 0, 0.04), 0px 0px 4px 0px rgba(0, 0, 0, 0.08)', '0px 2px 4px 0px rgba(0, 0, 0, 0.04), 0px 0px 4px 0px rgba(0, 0, 0, 0.08)',
heavyBoxShadow:
'box-shadow: 0px 16px 40px 0px rgba(0, 0, 0, 0.24), 0px 0px 12px 0px rgba(0, 0, 0, 0.24)',
}; };
export const lightTheme = { ...commonTheme, ...lightThemeSpecific }; export const lightTheme = { ...commonTheme, ...lightThemeSpecific };

View File

@ -1,6 +1,7 @@
import { ReactNode } from 'react'; import { ReactNode } from 'react';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { useDirectHotkeys } from '@/hotkeys/hooks/useDirectHotkeys';
import { IconPlus } from '@/ui/icons/index'; import { IconPlus } from '@/ui/icons/index';
import NavCollapseButton from '../navbar/NavCollapseButton'; import NavCollapseButton from '../navbar/NavCollapseButton';
@ -49,6 +50,8 @@ type OwnProps = {
}; };
export function TopBar({ title, icon, onAddButtonClick }: OwnProps) { export function TopBar({ title, icon, onAddButtonClick }: OwnProps) {
useDirectHotkeys('c', () => onAddButtonClick && onAddButtonClick());
return ( return (
<> <>
<TopBarContainer> <TopBarContainer>

View File

@ -40,8 +40,6 @@ export class PipelineProgressResolver {
async findManyPipelineProgress( async findManyPipelineProgress(
@Args() args: FindManyPipelineProgressArgs, @Args() args: FindManyPipelineProgressArgs,
@UserAbility() ability: AppAbility, @UserAbility() ability: AppAbility,
@PrismaSelector({ modelName: 'PipelineProgress' })
prismaSelect: PrismaSelect<'PipelineProgress'>,
): Promise<Partial<PipelineProgress>[]> { ): Promise<Partial<PipelineProgress>[]> {
return this.pipelineProgressService.findMany({ return this.pipelineProgressService.findMany({
...args, ...args,