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:
@ -2,6 +2,7 @@ import { Navigate, Route, Routes } from 'react-router-dom';
|
||||
|
||||
import { RequireAuth } from '@/auth/components/RequireAuth';
|
||||
import { RequireNotAuth } from '@/auth/components/RequireNotAuth';
|
||||
import { useGoToHotkeys } from '@/hotkeys/hooks/useGoToHotkeys';
|
||||
import { DefaultLayout } from '@/ui/layout/DefaultLayout';
|
||||
import { Index } from '~/pages/auth/Index';
|
||||
import { PasswordLogin } from '~/pages/auth/PasswordLogin';
|
||||
@ -12,6 +13,11 @@ import { People } from '~/pages/people/People';
|
||||
import { SettingsProfile } from '~/pages/settings/SettingsProfile';
|
||||
|
||||
export function App() {
|
||||
useGoToHotkeys('p', '/people');
|
||||
useGoToHotkeys('c', '/companies');
|
||||
useGoToHotkeys('o', '/opportunities');
|
||||
useGoToHotkeys('s', '/settings/profile');
|
||||
|
||||
return (
|
||||
<DefaultLayout>
|
||||
<Routes>
|
||||
|
||||
96
front/src/modules/command-menu/components/CommandMenu.tsx
Normal file
96
front/src/modules/command-menu/components/CommandMenu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -4,7 +4,7 @@ import { Command } from 'cmdk';
|
||||
export const StyledDialog = styled(Command.Dialog)`
|
||||
background: ${(props) => props.theme.primaryBackground};
|
||||
border-radius: ${(props) => props.theme.borderRadius};
|
||||
box-shadow: ${(props) => props.theme.modalBoxShadow};
|
||||
box-shadow: ${(props) => props.theme.heavyBoxShadow};
|
||||
font-family: ${(props) => props.theme.fontFamily};
|
||||
left: 50%;
|
||||
max-width: 640px;
|
||||
@ -21,7 +21,6 @@ export const StyledInput = styled(Command.Input)`
|
||||
border: none;
|
||||
border-bottom: 1px solid ${(props) => props.theme.primaryBorder};
|
||||
border-radius: 0;
|
||||
caret-color: ${(props) => props.theme.blue};
|
||||
color: ${(props) => props.theme.text100};
|
||||
font-size: ${(props) => props.theme.fontSizeLarge};
|
||||
margin: 0;
|
||||
@ -30,14 +29,15 @@ export const StyledInput = styled(Command.Input)`
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
export const StyledItem = styled(Command.Item)`
|
||||
export const StyledMenuItem = styled(Command.Item)`
|
||||
align-items: center;
|
||||
color: ${(props) => props.theme.text100};
|
||||
color: ${(props) => props.theme.text80};
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
font-size: ${(props) => props.theme.fontSizeMedium};
|
||||
gap: ${(props) => props.theme.spacing(3)};
|
||||
height: 48px;
|
||||
height: 40px;
|
||||
justify-content: space-between;
|
||||
padding: 0 ${(props) => props.theme.spacing(4)};
|
||||
position: relative;
|
||||
transition: all 150ms ease;
|
||||
@ -47,23 +47,24 @@ export const StyledItem = styled(Command.Item)`
|
||||
background: ${(props) => props.theme.lightBackgroundTransparent};
|
||||
}
|
||||
&[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 {
|
||||
background: ${(props) => props.theme.blue};
|
||||
background: ${(props) => props.theme.quaternaryBackground};
|
||||
content: '';
|
||||
height: 100%;
|
||||
left: 0;
|
||||
position: absolute;
|
||||
width: 3px;
|
||||
z-index: ${(props) => props.theme.lastLayerZIndex};
|
||||
}
|
||||
} */
|
||||
}
|
||||
&[data-disabled='true'] {
|
||||
color: ${(props) => props.theme.text30};
|
||||
cursor: not-allowed;
|
||||
}
|
||||
svg {
|
||||
color: ${(props) => props.theme.text80};
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
}
|
||||
@ -85,7 +86,12 @@ export const StyledGroup = styled(Command.Group)`
|
||||
color: ${(props) => props.theme.text30};
|
||||
display: flex;
|
||||
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;
|
||||
}
|
||||
`;
|
||||
@ -101,3 +107,32 @@ export const StyledEmpty = styled(Command.Empty)`
|
||||
`;
|
||||
|
||||
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};
|
||||
`;
|
||||
@ -1,12 +1,13 @@
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import { fireEvent } from '@storybook/testing-library';
|
||||
|
||||
import { getRenderWrapperForComponent } from '~/testing/renderWrappers';
|
||||
|
||||
import { CommandMenu } from '../CommandMenu';
|
||||
|
||||
const meta: Meta<typeof CommandMenu> = {
|
||||
title: 'Modules/Search/CommandMenu',
|
||||
title: 'Modules/CommandMenu/CommandMenu',
|
||||
component: CommandMenu,
|
||||
};
|
||||
|
||||
@ -16,7 +17,22 @@ type Story = StoryObj<typeof CommandMenu>;
|
||||
export const Default: Story = {
|
||||
render: getRenderWrapperForComponent(
|
||||
<MemoryRouter>
|
||||
<CommandMenu initiallyOpen={true} />
|
||||
<CommandMenu />
|
||||
</MemoryRouter>,
|
||||
),
|
||||
};
|
||||
|
||||
export const CmdK: Story = {
|
||||
render: getRenderWrapperForComponent(
|
||||
<MemoryRouter>
|
||||
<CommandMenu />
|
||||
</MemoryRouter>,
|
||||
),
|
||||
play: async ({ canvasElement }) => {
|
||||
fireEvent.keyDown(canvasElement, {
|
||||
key: 'k',
|
||||
code: 'KeyK',
|
||||
metaKey: true,
|
||||
});
|
||||
},
|
||||
};
|
||||
@ -0,0 +1,6 @@
|
||||
import { atom } from 'recoil';
|
||||
|
||||
export const isCommandMenuOpenedState = atom({
|
||||
key: 'command-menu/isCommandMenuOpenedState',
|
||||
default: false,
|
||||
});
|
||||
30
front/src/modules/hotkeys/hooks/useDirectHotkeys.ts
Normal file
30
front/src/modules/hotkeys/hooks/useDirectHotkeys.ts
Normal 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);
|
||||
}
|
||||
11
front/src/modules/hotkeys/hooks/useGoToHotkeys.ts
Normal file
11
front/src/modules/hotkeys/hooks/useGoToHotkeys.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
32
front/src/modules/hotkeys/hooks/useSequenceHotkeys.ts
Normal file
32
front/src/modules/hotkeys/hooks/useSequenceHotkeys.ts
Normal 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],
|
||||
);
|
||||
}
|
||||
31
front/src/modules/hotkeys/hooks/useUpDownHotkeys.ts
Normal file
31
front/src/modules/hotkeys/hooks/useUpDownHotkeys.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
6
front/src/modules/hotkeys/states/pendingHotkeysState.ts
Normal file
6
front/src/modules/hotkeys/states/pendingHotkeysState.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { atom } from 'recoil';
|
||||
|
||||
export const pendingHotkeyState = atom<string | null>({
|
||||
key: 'command-menu/pendingHotkeyState',
|
||||
default: null,
|
||||
});
|
||||
@ -1,8 +1,8 @@
|
||||
import { useState } from 'react';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import { debounce } from 'lodash';
|
||||
import scrollIntoView from 'scroll-into-view';
|
||||
|
||||
import { useUpDownHotkeys } from '@/hotkeys/hooks/useUpDownHotkeys';
|
||||
import { useRecoilScopedState } from '@/ui/hooks/useRecoilScopedState';
|
||||
|
||||
import { relationPickerSearchFilterScopedState } from '../states/relationPickerSearchFilterScopedState';
|
||||
@ -34,39 +34,7 @@ export function useEntitySelectLogic<
|
||||
setHoveredIndex(0);
|
||||
}
|
||||
|
||||
useHotkeys(
|
||||
'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',
|
||||
useUpDownHotkeys(
|
||||
() => {
|
||||
setHoveredIndex((prevSelectedIndex) =>
|
||||
Math.max(prevSelectedIndex - 1, 0),
|
||||
@ -88,10 +56,26 @@ export function useEntitySelectLogic<
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
enableOnContentEditable: true,
|
||||
enableOnFormTags: true,
|
||||
preventDefault: true,
|
||||
() => {
|
||||
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,
|
||||
});
|
||||
}
|
||||
},
|
||||
[setHoveredIndex, entities],
|
||||
);
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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)} />
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -20,6 +20,7 @@ const StyledContainer = styled.div<StyledContainerProps>`
|
||||
border: 1px solid ${(props) => props.theme.primaryBorder};
|
||||
border-radius: 8px;
|
||||
bottom: ${(props) => (props.position.x ? 'auto' : '38px')};
|
||||
box-shadow: ${(props) => props.theme.modalBoxShadow};
|
||||
display: flex;
|
||||
height: 48px;
|
||||
|
||||
|
||||
@ -2,7 +2,7 @@ import styled from '@emotion/styled';
|
||||
import { useRecoilState, useRecoilValue } from 'recoil';
|
||||
|
||||
import { currentUserState } from '@/auth/states/currentUserState';
|
||||
import { CommandMenu } from '@/search/components/CommandMenu';
|
||||
import { CommandMenu } from '@/command-menu/components/CommandMenu';
|
||||
import { AppNavbar } from '~/AppNavbar';
|
||||
|
||||
import { NavbarContainer } from './navbar/NavbarContainer';
|
||||
|
||||
@ -31,9 +31,12 @@ const lightThemeSpecific = {
|
||||
blueHighTransparency: 'rgba(25, 97, 237, 0.03)',
|
||||
blueLowTransparency: 'rgba(25, 97, 237, 0.32)',
|
||||
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:
|
||||
'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 = {
|
||||
@ -52,6 +55,8 @@ const darkThemeSpecific: typeof lightThemeSpecific = {
|
||||
modalBoxShadow: '0px 3px 12px rgba(0, 0, 0, 0.09)', // TODO change color for dark theme
|
||||
lightBoxShadow:
|
||||
'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 };
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { ReactNode } from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { useDirectHotkeys } from '@/hotkeys/hooks/useDirectHotkeys';
|
||||
import { IconPlus } from '@/ui/icons/index';
|
||||
|
||||
import NavCollapseButton from '../navbar/NavCollapseButton';
|
||||
@ -49,6 +50,8 @@ type OwnProps = {
|
||||
};
|
||||
|
||||
export function TopBar({ title, icon, onAddButtonClick }: OwnProps) {
|
||||
useDirectHotkeys('c', () => onAddButtonClick && onAddButtonClick());
|
||||
|
||||
return (
|
||||
<>
|
||||
<TopBarContainer>
|
||||
|
||||
@ -40,8 +40,6 @@ export class PipelineProgressResolver {
|
||||
async findManyPipelineProgress(
|
||||
@Args() args: FindManyPipelineProgressArgs,
|
||||
@UserAbility() ability: AppAbility,
|
||||
@PrismaSelector({ modelName: 'PipelineProgress' })
|
||||
prismaSelect: PrismaSelect<'PipelineProgress'>,
|
||||
): Promise<Partial<PipelineProgress>[]> {
|
||||
return this.pipelineProgressService.findMany({
|
||||
...args,
|
||||
|
||||
Reference in New Issue
Block a user