Uniformize folder structure (#693)
* Uniformize folder structure * Fix icons * Fix icons * Fix tests * Fix tests
This commit is contained in:
@ -2,8 +2,9 @@ import React, { useEffect } from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
import { useRecoilValue, useSetRecoilState } from 'recoil';
|
||||
|
||||
import { contextMenuPositionState } from '@/ui/tables/states/contextMenuPositionState';
|
||||
import { PositionType } from '@/ui/types/PositionType';
|
||||
import { contextMenuPositionState } from '@/ui/table/states/contextMenuPositionState';
|
||||
|
||||
import { PositionType } from '../types/PositionType';
|
||||
|
||||
type OwnProps = {
|
||||
children: React.ReactNode | React.ReactNode[];
|
||||
@ -1,7 +1,7 @@
|
||||
import { ReactElement } from 'react';
|
||||
|
||||
import { HotkeyScope } from '@/lib/hotkeys/types/HotkeyScope';
|
||||
import { RecoilScope } from '@/recoil-scope/components/RecoilScope';
|
||||
import { HotkeyScope } from '@/ui/hotkey/types/HotkeyScope';
|
||||
import { RecoilScope } from '@/ui/recoil-scope/components/RecoilScope';
|
||||
|
||||
import { BoardCardFieldContext } from '../states/BoardCardFieldContext';
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { BoardCardEditableField } from '@/ui/board-card-field/components/BoardCardEditableField';
|
||||
import { InplaceInputDateDisplayMode } from '@/ui/inplace-inputs/components/InplaceInputDateDisplayMode';
|
||||
import { debounce } from '@/utils/debounce';
|
||||
import { InplaceInputDateDisplayMode } from '@/ui/display/component/InplaceInputDateDisplayMode';
|
||||
import { debounce } from '~/utils/debounce';
|
||||
|
||||
import { BoardCardEditableField } from './BoardCardEditableField';
|
||||
import { BoardCardEditableFieldDateEditMode } from './BoardCardEditableFieldDateEditMode';
|
||||
|
||||
type OwnProps = {
|
||||
@ -1,4 +1,4 @@
|
||||
import { InplaceInputDate } from '@/ui/inplace-inputs/components/InplaceInputDate';
|
||||
import { InplaceInputDate } from '@/ui/inplace-input/components/InplaceInputDate';
|
||||
|
||||
type OwnProps = {
|
||||
value: Date;
|
||||
@ -1,8 +1,8 @@
|
||||
import { ReactElement, useRef } from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { useScopedHotkeys } from '@/lib/hotkeys/hooks/useScopedHotkeys';
|
||||
import { useListenClickOutsideArrayOfRef } from '@/ui/hooks/useListenClickOutsideArrayOfRef';
|
||||
import { useScopedHotkeys } from '@/ui/hotkey/hooks/useScopedHotkeys';
|
||||
import { overlayBackground } from '@/ui/themes/effects';
|
||||
|
||||
import { BoardCardFieldHotkeyScope } from '../types/BoardCardFieldHotkeyScope';
|
||||
@ -1,8 +1,8 @@
|
||||
import { ReactElement } from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { usePreviousHotkeyScope } from '@/lib/hotkeys/hooks/usePreviousHotkeyScope';
|
||||
import { HotkeyScope } from '@/lib/hotkeys/types/HotkeyScope';
|
||||
import { usePreviousHotkeyScope } from '@/ui/hotkey/hooks/usePreviousHotkeyScope';
|
||||
import { HotkeyScope } from '@/ui/hotkey/types/HotkeyScope';
|
||||
|
||||
import { useBoardCardField } from '../hooks/useBoardCardField';
|
||||
import { BoardCardFieldHotkeyScope } from '../types/BoardCardFieldHotkeyScope';
|
||||
@ -1,9 +1,10 @@
|
||||
import { ChangeEvent, useMemo, useState } from 'react';
|
||||
|
||||
import { BoardCardEditableField } from '@/ui/board-card-field/components/BoardCardEditableField';
|
||||
import { InplaceInputTextDisplayMode } from '@/ui/inplace-inputs/components/InplaceInputTextDisplayMode';
|
||||
import { InplaceInputTextEditMode } from '@/ui/inplace-inputs/components/InplaceInputTextEditMode';
|
||||
import { debounce } from '@/utils/debounce';
|
||||
import { InplaceInputTextDisplayMode } from '@/ui/display/component/InplaceInputTextDisplayMode';
|
||||
import { InplaceInputTextEditMode } from '@/ui/inplace-input/components/InplaceInputTextEditMode';
|
||||
import { debounce } from '~/utils/debounce';
|
||||
|
||||
import { BoardCardEditableField } from './BoardCardEditableField';
|
||||
|
||||
type OwnProps = {
|
||||
placeholder?: string;
|
||||
@ -1,4 +1,4 @@
|
||||
import { useRecoilScopedState } from '@/recoil-scope/hooks/useRecoilScopedState';
|
||||
import { useRecoilScopedState } from '@/ui/recoil-scope/hooks/useRecoilScopedState';
|
||||
|
||||
import { BoardCardFieldContext } from '../states/BoardCardFieldContext';
|
||||
import { isBoardCardFieldInEditModeScopedState } from '../states/isBoardCardFieldInEditModeScopedState';
|
||||
@ -1,7 +1,7 @@
|
||||
import React, { ChangeEvent } from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { debounce } from '@/utils/debounce';
|
||||
import { debounce } from '~/utils/debounce';
|
||||
|
||||
import { EditColumnTitleInput } from './EditColumnTitleInput';
|
||||
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import React from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { useScopedHotkeys } from '@/lib/hotkeys/hooks/useScopedHotkeys';
|
||||
import { useSetHotkeyScope } from '@/lib/hotkeys/hooks/useSetHotkeyScope';
|
||||
import { useListenClickOutsideArrayOfRef } from '@/ui/hooks/useListenClickOutsideArrayOfRef';
|
||||
import { useScopedHotkeys } from '@/ui/hotkey/hooks/useScopedHotkeys';
|
||||
import { useSetHotkeyScope } from '@/ui/hotkey/hooks/useSetHotkeyScope';
|
||||
|
||||
import { ColumnHotkeyScope } from './ColumnHotkeyScope';
|
||||
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { IconPlus } from '@/ui/icons/index';
|
||||
import { IconPlus } from '@/ui/icon/index';
|
||||
|
||||
const StyledButton = styled.button`
|
||||
align-items: center;
|
||||
|
||||
@ -1,10 +1,9 @@
|
||||
import React from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { SoonPill } from '@/ui/pill/components/SoonPill';
|
||||
import { rgba } from '@/ui/themes/colors';
|
||||
|
||||
import { SoonPill } from '../accessories/SoonPill';
|
||||
|
||||
export type ButtonVariant =
|
||||
| 'primary'
|
||||
| 'secondary'
|
||||
@ -1,7 +1,7 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { IconChevronDown } from '@/ui/icons/index';
|
||||
import { IconChevronDown } from '@/ui/icon/index';
|
||||
|
||||
type ButtonProps = React.ComponentProps<'button'>;
|
||||
|
||||
@ -5,7 +5,7 @@ import { expect, jest } from '@storybook/jest';
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import { userEvent, within } from '@storybook/testing-library';
|
||||
|
||||
import { IconSearch } from '@/ui/icons';
|
||||
import { IconSearch } from '@/ui/icon';
|
||||
import { getRenderWrapperForComponent } from '~/testing/renderWrappers';
|
||||
|
||||
import { Button } from '../Button';
|
||||
@ -5,7 +5,7 @@ import { expect, jest } from '@storybook/jest';
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import { userEvent, within } from '@storybook/testing-library';
|
||||
|
||||
import { IconUser } from '@/ui/icons';
|
||||
import { IconUser } from '@/ui/icon';
|
||||
import { getRenderWrapperForComponent } from '~/testing/renderWrappers';
|
||||
|
||||
import { IconButton } from '../IconButton';
|
||||
@ -2,7 +2,7 @@ import { expect, jest } from '@storybook/jest';
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import { userEvent, within } from '@storybook/testing-library';
|
||||
|
||||
import { IconBrandGoogle } from '@/ui/icons';
|
||||
import { IconBrandGoogle } from '@/ui/icon';
|
||||
import { getRenderWrapperForComponent } from '~/testing/renderWrappers';
|
||||
|
||||
import { MainButton } from '../MainButton';
|
||||
@ -2,7 +2,7 @@ import { expect, jest } from '@storybook/jest';
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import { userEvent, within } from '@storybook/testing-library';
|
||||
|
||||
import { IconArrowRight } from '@/ui/icons';
|
||||
import { IconArrowRight } from '@/ui/icon';
|
||||
import { getRenderWrapperForComponent } from '~/testing/renderWrappers';
|
||||
|
||||
import { RoundedIconButton } from '../RoundedIconButton';
|
||||
@ -1,24 +0,0 @@
|
||||
import { FilterDefinition } from '@/lib/filters-and-sorts/types/FilterDefinition';
|
||||
import { useInitializeEntityTable } from '@/ui/tables/hooks/useInitializeEntityTable';
|
||||
import { useInitializeEntityTableFilters } from '@/ui/tables/hooks/useInitializeEntityTableFilters';
|
||||
import { useMapKeyboardToSoftFocus } from '@/ui/tables/hooks/useMapKeyboardToSoftFocus';
|
||||
|
||||
export function HooksEntityTable({
|
||||
numberOfColumns,
|
||||
availableFilters,
|
||||
}: {
|
||||
numberOfColumns: number;
|
||||
availableFilters: FilterDefinition[];
|
||||
}) {
|
||||
useMapKeyboardToSoftFocus();
|
||||
|
||||
useInitializeEntityTable({
|
||||
numberOfColumns,
|
||||
});
|
||||
|
||||
useInitializeEntityTableFilters({
|
||||
availableFilters,
|
||||
});
|
||||
|
||||
return <></>;
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
import { formatToHumanReadableDate } from '@/utils/utils';
|
||||
import { formatToHumanReadableDate } from '~/utils';
|
||||
|
||||
type OwnProps = {
|
||||
value: Date;
|
||||
@ -2,7 +2,7 @@ import { MouseEvent } from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
import { isValidPhoneNumber, parsePhoneNumber } from 'libphonenumber-js';
|
||||
|
||||
import { RawLink } from '@/ui/components/links/RawLink';
|
||||
import { RawLink } from '@/ui/link/components/RawLink';
|
||||
|
||||
const StyledRawLink = styled(RawLink)`
|
||||
overflow: hidden;
|
||||
@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { Checkbox } from '../form/Checkbox';
|
||||
import { Checkbox } from '@/ui/input/components/Checkbox';
|
||||
|
||||
import { DropdownMenuButton } from './DropdownMenuButton';
|
||||
|
||||
@ -2,7 +2,7 @@ import React, { useEffect } from 'react';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { IconCheck } from '@/ui/icons/index';
|
||||
import { IconCheck } from '@/ui/icon/index';
|
||||
import { hoverBackground } from '@/ui/themes/effects';
|
||||
|
||||
import { DropdownMenuButton } from './DropdownMenuButton';
|
||||
@ -2,7 +2,7 @@ import React, { useState } from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
|
||||
import { IconPlus } from '@/ui/icons/index';
|
||||
import { IconPlus } from '@/ui/icon/index';
|
||||
import { Avatar } from '@/users/components/Avatar';
|
||||
import { getRenderWrapperForComponent } from '~/testing/renderWrappers';
|
||||
|
||||
@ -2,7 +2,7 @@ import { useState } from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
import { HotkeyScope } from '@/lib/hotkeys/types/HotkeyScope';
|
||||
import { HotkeyScope } from '@/ui/hotkey/types/HotkeyScope';
|
||||
|
||||
import { useEditableField } from '../hooks/useEditableField';
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import styled from '@emotion/styled';
|
||||
import { IconPencil } from '@tabler/icons-react';
|
||||
|
||||
import { HotkeyScope } from '@/lib/hotkeys/types/HotkeyScope';
|
||||
import { IconButton } from '@/ui/components/buttons/IconButton';
|
||||
import { IconButton } from '@/ui/button/components/IconButton';
|
||||
import { HotkeyScope } from '@/ui/hotkey/types/HotkeyScope';
|
||||
import { IconPencil } from '@/ui/icon';
|
||||
import { overlayBackground } from '@/ui/themes/effects';
|
||||
|
||||
import { useEditableField } from '../hooks/useEditableField';
|
||||
@ -1,10 +1,10 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { IconMap } from '@tabler/icons-react';
|
||||
|
||||
import { RecoilScope } from '@/recoil-scope/components/RecoilScope';
|
||||
import { EditableField } from '@/ui/editable-fields/components/EditableField';
|
||||
import { FieldContext } from '@/ui/editable-fields/states/FieldContext';
|
||||
import { InplaceInputText } from '@/ui/inplace-inputs/components/InplaceInputText';
|
||||
import { EditableField } from '@/ui/editable-field/components/EditableField';
|
||||
import { FieldContext } from '@/ui/editable-field/states/FieldContext';
|
||||
import { IconMap } from '@/ui/icon';
|
||||
import { InplaceInputText } from '@/ui/inplace-input/components/InplaceInputText';
|
||||
import { RecoilScope } from '@/ui/recoil-scope/components/RecoilScope';
|
||||
import { Company, useUpdateCompanyMutation } from '~/generated/graphql';
|
||||
|
||||
type OwnProps = {
|
||||
@ -1,4 +1,4 @@
|
||||
import { RawLink } from '@/ui/components/links/RawLink';
|
||||
import { RawLink } from '@/ui/link/components/RawLink';
|
||||
|
||||
export function FieldDisplayURL({ URL }: { URL: string | undefined }) {
|
||||
return <RawLink href={URL ? 'https://' + URL : ''}>{URL}</RawLink>;
|
||||
@ -1,6 +1,6 @@
|
||||
import { useSetHotkeyScope } from '@/lib/hotkeys/hooks/useSetHotkeyScope';
|
||||
import { HotkeyScope } from '@/lib/hotkeys/types/HotkeyScope';
|
||||
import { useRecoilScopedState } from '@/recoil-scope/hooks/useRecoilScopedState';
|
||||
import { useSetHotkeyScope } from '@/ui/hotkey/hooks/useSetHotkeyScope';
|
||||
import { HotkeyScope } from '@/ui/hotkey/types/HotkeyScope';
|
||||
import { useRecoilScopedState } from '@/ui/recoil-scope/hooks/useRecoilScopedState';
|
||||
|
||||
import { FieldContext } from '../states/FieldContext';
|
||||
import { isFieldInEditModeScopedState } from '../states/isFieldInEditModeScopedState';
|
||||
@ -1,5 +1,5 @@
|
||||
import { useScopedHotkeys } from '@/lib/hotkeys/hooks/useScopedHotkeys';
|
||||
import { useListenClickOutsideArrayOfRef } from '@/ui/hooks/useListenClickOutsideArrayOfRef';
|
||||
import { useScopedHotkeys } from '@/ui/hotkey/hooks/useScopedHotkeys';
|
||||
|
||||
import { EditableFieldHotkeyScope } from '../types/EditableFieldHotkeyScope';
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { HotkeyScope } from '@/lib/hotkeys/types/HotkeyScope';
|
||||
import { InplaceInputDate } from '@/ui/inplace-inputs/components/InplaceInputDate';
|
||||
import { parseDate } from '@/utils/datetime/date-utils';
|
||||
import { HotkeyScope } from '@/ui/hotkey/types/HotkeyScope';
|
||||
import { InplaceInputDate } from '@/ui/inplace-input/components/InplaceInputDate';
|
||||
import { parseDate } from '~/utils/date-utils';
|
||||
|
||||
import { useEditableField } from '../../hooks/useEditableField';
|
||||
|
||||
225
front/src/modules/ui/filter-n-sort/components/DropdownButton.tsx
Normal file
225
front/src/modules/ui/filter-n-sort/components/DropdownButton.tsx
Normal file
@ -0,0 +1,225 @@
|
||||
import { ReactNode, useRef } from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
import { Key } from 'ts-key-enum';
|
||||
|
||||
import { useOutsideAlerter } from '@/ui/hooks/useOutsideAlerter';
|
||||
import { useScopedHotkeys } from '@/ui/hotkey/hooks/useScopedHotkeys';
|
||||
import { IconChevronDown } from '@/ui/icon/index';
|
||||
import { overlayBackground, textInputStyle } from '@/ui/themes/effects';
|
||||
|
||||
import { FiltersHotkeyScope } from '../types/FiltersHotkeyScope';
|
||||
|
||||
type OwnProps = {
|
||||
label: string;
|
||||
isActive: boolean;
|
||||
children?: ReactNode;
|
||||
isUnfolded?: boolean;
|
||||
onIsUnfoldedChange?: (newIsUnfolded: boolean) => void;
|
||||
resetState?: () => void;
|
||||
HotkeyScope: FiltersHotkeyScope;
|
||||
};
|
||||
|
||||
const StyledDropdownButtonContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
`;
|
||||
|
||||
type StyledDropdownButtonProps = {
|
||||
isUnfolded: boolean;
|
||||
isActive: boolean;
|
||||
};
|
||||
|
||||
const StyledDropdownButton = styled.div<StyledDropdownButtonProps>`
|
||||
background: ${({ theme }) => theme.background.primary};
|
||||
border-radius: ${({ theme }) => theme.border.radius.sm};
|
||||
color: ${(props) => (props.isActive ? props.theme.color.blue : 'none')};
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
filter: ${(props) => (props.isUnfolded ? 'brightness(0.95)' : 'none')};
|
||||
|
||||
padding: ${({ theme }) => theme.spacing(1)};
|
||||
padding-left: ${({ theme }) => theme.spacing(2)};
|
||||
|
||||
padding-right: ${({ theme }) => theme.spacing(2)};
|
||||
user-select: none;
|
||||
|
||||
&:hover {
|
||||
filter: brightness(0.95);
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledDropdown = styled.ul`
|
||||
--outer-border-radius: calc(var(--wraper-border-radius) - 2px);
|
||||
--wraper-border: 1px;
|
||||
--wraper-border-radius: ${({ theme }) => theme.border.radius.md};
|
||||
|
||||
border: var(--wraper-border) solid ${({ theme }) => theme.border.color.light};
|
||||
border-radius: var(--wraper-border-radius);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 160px;
|
||||
padding: 0px;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 14px;
|
||||
${overlayBackground}
|
||||
li {
|
||||
&:first-of-type {
|
||||
border-top-left-radius: var(--outer-border-radius);
|
||||
border-top-right-radius: var(--outer-border-radius);
|
||||
}
|
||||
&:last-of-type {
|
||||
border-bottom: 0;
|
||||
border-bottom-left-radius: var(--outer-border-radius);
|
||||
border-bottom-right-radius: var(--outer-border-radius);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledDropdownItem = styled.li`
|
||||
align-items: center;
|
||||
border-radius: ${({ theme }) => theme.border.radius.xs};
|
||||
color: ${({ theme }) => theme.font.color.secondary};
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
margin: 2px;
|
||||
padding: ${({ theme }) => theme.spacing(2)}
|
||||
calc(${({ theme }) => theme.spacing(2)} - 2px);
|
||||
user-select: none;
|
||||
width: calc(160px - ${({ theme }) => theme.spacing(4)});
|
||||
|
||||
&:hover {
|
||||
background: ${({ theme }) => theme.background.transparent.light};
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledDropdownItemClipped = styled.span`
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
const StyledDropdownTopOption = styled.li`
|
||||
align-items: center;
|
||||
border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
|
||||
color: ${({ theme }) => theme.font.color.primary};
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
font-size: ${({ theme }) => theme.font.size.sm};
|
||||
font-weight: ${({ theme }) => theme.font.weight.medium};
|
||||
justify-content: space-between;
|
||||
padding: calc(${({ theme }) => theme.spacing(2)})
|
||||
calc(${({ theme }) => theme.spacing(2)});
|
||||
|
||||
&:hover {
|
||||
background: ${({ theme }) => theme.background.transparent.light};
|
||||
}
|
||||
user-select: none;
|
||||
`;
|
||||
|
||||
const StyledIcon = styled.div`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-right: ${({ theme }) => theme.spacing(1)};
|
||||
min-width: ${({ theme }) => theme.spacing(4)};
|
||||
`;
|
||||
|
||||
const StyledSearchField = styled.li`
|
||||
align-items: center;
|
||||
border-bottom: var(--wraper-border) solid
|
||||
${({ theme }) => theme.border.color.light};
|
||||
color: ${({ theme }) => theme.font.color.secondary};
|
||||
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
font-weight: ${({ theme }) => theme.font.weight.medium};
|
||||
justify-content: space-between;
|
||||
overflow: hidden;
|
||||
|
||||
user-select: none;
|
||||
input {
|
||||
border-radius: ${({ theme }) => theme.border.radius.md};
|
||||
box-sizing: border-box;
|
||||
font-family: ${({ theme }) => theme.font.family};
|
||||
height: 36px;
|
||||
padding: 8px;
|
||||
width: 100%;
|
||||
|
||||
${textInputStyle}
|
||||
|
||||
&:focus {
|
||||
outline: 0 none;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
function DropdownButton({
|
||||
label,
|
||||
isActive,
|
||||
children,
|
||||
isUnfolded = false,
|
||||
onIsUnfoldedChange,
|
||||
HotkeyScope,
|
||||
}: OwnProps) {
|
||||
useScopedHotkeys(
|
||||
[Key.Enter, Key.Escape],
|
||||
() => {
|
||||
onIsUnfoldedChange?.(false);
|
||||
},
|
||||
HotkeyScope,
|
||||
[onIsUnfoldedChange],
|
||||
);
|
||||
|
||||
const onButtonClick = () => {
|
||||
onIsUnfoldedChange?.(!isUnfolded);
|
||||
};
|
||||
|
||||
const onOutsideClick = () => {
|
||||
onIsUnfoldedChange?.(false);
|
||||
};
|
||||
|
||||
const dropdownRef = useRef(null);
|
||||
useOutsideAlerter({ ref: dropdownRef, callback: onOutsideClick });
|
||||
|
||||
return (
|
||||
<StyledDropdownButtonContainer>
|
||||
<StyledDropdownButton
|
||||
isUnfolded={isUnfolded}
|
||||
onClick={onButtonClick}
|
||||
isActive={isActive}
|
||||
aria-selected={isActive}
|
||||
>
|
||||
{label}
|
||||
</StyledDropdownButton>
|
||||
{isUnfolded && (
|
||||
<StyledDropdown ref={dropdownRef}>{children}</StyledDropdown>
|
||||
)}
|
||||
</StyledDropdownButtonContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const StyleAngleDownContainer = styled.div`
|
||||
color: ${({ theme }) => theme.font.color.tertiary};
|
||||
display: flex;
|
||||
height: 100%;
|
||||
justify-content: center;
|
||||
margin-left: auto;
|
||||
`;
|
||||
|
||||
function DropdownTopOptionAngleDown() {
|
||||
return (
|
||||
<StyleAngleDownContainer>
|
||||
<IconChevronDown size={16} />
|
||||
</StyleAngleDownContainer>
|
||||
);
|
||||
}
|
||||
DropdownButton.StyledDropdownItem = StyledDropdownItem;
|
||||
DropdownButton.StyledDropdownItemClipped = StyledDropdownItemClipped;
|
||||
DropdownButton.StyledSearchField = StyledSearchField;
|
||||
DropdownButton.StyledDropdownTopOption = StyledDropdownTopOption;
|
||||
DropdownButton.StyledDropdownTopOptionAngleDown = DropdownTopOptionAngleDown;
|
||||
DropdownButton.StyledIcon = StyledIcon;
|
||||
|
||||
export default DropdownButton;
|
||||
@ -0,0 +1,132 @@
|
||||
import { Context, useCallback, useState } from 'react';
|
||||
import { Key } from 'ts-key-enum';
|
||||
|
||||
import { filterDefinitionUsedInDropdownScopedState } from '@/ui/filter-n-sort/states/filterDefinitionUsedInDropdownScopedState';
|
||||
import { filterDropdownSearchInputScopedState } from '@/ui/filter-n-sort/states/filterDropdownSearchInputScopedState';
|
||||
import { filtersScopedState } from '@/ui/filter-n-sort/states/filtersScopedState';
|
||||
import { isFilterDropdownOperandSelectUnfoldedScopedState } from '@/ui/filter-n-sort/states/isFilterDropdownOperandSelectUnfoldedScopedState';
|
||||
import { selectedOperandInDropdownScopedState } from '@/ui/filter-n-sort/states/selectedOperandInDropdownScopedState';
|
||||
import { useScopedHotkeys } from '@/ui/hotkey/hooks/useScopedHotkeys';
|
||||
import { useSetHotkeyScope } from '@/ui/hotkey/hooks/useSetHotkeyScope';
|
||||
import { useRecoilScopedState } from '@/ui/recoil-scope/hooks/useRecoilScopedState';
|
||||
import { RelationPickerHotkeyScope } from '@/ui/relation-picker/types/RelationPickerHotkeyScope';
|
||||
|
||||
import { FiltersHotkeyScope } from '../types/FiltersHotkeyScope';
|
||||
|
||||
import DropdownButton from './DropdownButton';
|
||||
import { FilterDropdownDateSearchInput } from './FilterDropdownDateSearchInput';
|
||||
import { FilterDropdownEntitySearchInput } from './FilterDropdownEntitySearchInput';
|
||||
import { FilterDropdownEntitySelect } from './FilterDropdownEntitySelect';
|
||||
import { FilterDropdownFilterSelect } from './FilterDropdownFilterSelect';
|
||||
import { FilterDropdownNumberSearchInput } from './FilterDropdownNumberSearchInput';
|
||||
import { FilterDropdownOperandButton } from './FilterDropdownOperandButton';
|
||||
import { FilterDropdownOperandSelect } from './FilterDropdownOperandSelect';
|
||||
import { FilterDropdownTextSearchInput } from './FilterDropdownTextSearchInput';
|
||||
|
||||
export function FilterDropdownButton({
|
||||
context,
|
||||
HotkeyScope,
|
||||
}: {
|
||||
context: Context<string | null>;
|
||||
HotkeyScope: FiltersHotkeyScope;
|
||||
}) {
|
||||
const [isUnfolded, setIsUnfolded] = useState(false);
|
||||
|
||||
const [
|
||||
isFilterDropdownOperandSelectUnfolded,
|
||||
setIsFilterDropdownOperandSelectUnfolded,
|
||||
] = useRecoilScopedState(
|
||||
isFilterDropdownOperandSelectUnfoldedScopedState,
|
||||
context,
|
||||
);
|
||||
|
||||
const [filterDefinitionUsedInDropdown, setFilterDefinitionUsedInDropdown] =
|
||||
useRecoilScopedState(filterDefinitionUsedInDropdownScopedState, context);
|
||||
|
||||
const [, setFilterDropdownSearchInput] = useRecoilScopedState(
|
||||
filterDropdownSearchInputScopedState,
|
||||
context,
|
||||
);
|
||||
|
||||
const [filters] = useRecoilScopedState(filtersScopedState, context);
|
||||
|
||||
const [selectedOperandInDropdown, setSelectedOperandInDropdown] =
|
||||
useRecoilScopedState(selectedOperandInDropdownScopedState, context);
|
||||
|
||||
const resetState = useCallback(() => {
|
||||
setIsFilterDropdownOperandSelectUnfolded(false);
|
||||
setFilterDefinitionUsedInDropdown(null);
|
||||
setSelectedOperandInDropdown(null);
|
||||
setFilterDropdownSearchInput('');
|
||||
}, [
|
||||
setFilterDefinitionUsedInDropdown,
|
||||
setSelectedOperandInDropdown,
|
||||
setFilterDropdownSearchInput,
|
||||
setIsFilterDropdownOperandSelectUnfolded,
|
||||
]);
|
||||
|
||||
const isFilterSelected = (filters?.length ?? 0) > 0;
|
||||
|
||||
const setHotkeyScope = useSetHotkeyScope();
|
||||
|
||||
function handleIsUnfoldedChange(newIsUnfolded: boolean) {
|
||||
if (newIsUnfolded) {
|
||||
setHotkeyScope(HotkeyScope);
|
||||
setIsUnfolded(true);
|
||||
} else {
|
||||
if (filterDefinitionUsedInDropdown?.type === 'entity') {
|
||||
setHotkeyScope(HotkeyScope);
|
||||
}
|
||||
setIsUnfolded(false);
|
||||
resetState();
|
||||
}
|
||||
}
|
||||
|
||||
useScopedHotkeys(
|
||||
[Key.Escape],
|
||||
() => {
|
||||
handleIsUnfoldedChange(false);
|
||||
},
|
||||
RelationPickerHotkeyScope.RelationPicker,
|
||||
[handleIsUnfoldedChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<DropdownButton
|
||||
label="Filter"
|
||||
isActive={isFilterSelected}
|
||||
isUnfolded={isUnfolded}
|
||||
onIsUnfoldedChange={handleIsUnfoldedChange}
|
||||
HotkeyScope={HotkeyScope}
|
||||
>
|
||||
{!filterDefinitionUsedInDropdown ? (
|
||||
<FilterDropdownFilterSelect context={context} />
|
||||
) : isFilterDropdownOperandSelectUnfolded ? (
|
||||
<FilterDropdownOperandSelect context={context} />
|
||||
) : (
|
||||
selectedOperandInDropdown && (
|
||||
<>
|
||||
<FilterDropdownOperandButton context={context} />
|
||||
<DropdownButton.StyledSearchField autoFocus key={'search-filter'}>
|
||||
{filterDefinitionUsedInDropdown.type === 'text' && (
|
||||
<FilterDropdownTextSearchInput context={context} />
|
||||
)}
|
||||
{filterDefinitionUsedInDropdown.type === 'number' && (
|
||||
<FilterDropdownNumberSearchInput context={context} />
|
||||
)}
|
||||
{filterDefinitionUsedInDropdown.type === 'date' && (
|
||||
<FilterDropdownDateSearchInput context={context} />
|
||||
)}
|
||||
{filterDefinitionUsedInDropdown.type === 'entity' && (
|
||||
<FilterDropdownEntitySearchInput context={context} />
|
||||
)}
|
||||
</DropdownButton.StyledSearchField>
|
||||
{filterDefinitionUsedInDropdown.type === 'entity' && (
|
||||
<FilterDropdownEntitySelect context={context} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</DropdownButton>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,49 @@
|
||||
import { Context } from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { useUpsertFilter } from '@/ui/filter-n-sort/hooks/useUpsertFilter';
|
||||
import { filterDefinitionUsedInDropdownScopedState } from '@/ui/filter-n-sort/states/filterDefinitionUsedInDropdownScopedState';
|
||||
import { selectedOperandInDropdownScopedState } from '@/ui/filter-n-sort/states/selectedOperandInDropdownScopedState';
|
||||
import DatePicker from '@/ui/input/components/DatePicker';
|
||||
import { useRecoilScopedState } from '@/ui/recoil-scope/hooks/useRecoilScopedState';
|
||||
|
||||
export function FilterDropdownDateSearchInput({
|
||||
context,
|
||||
}: {
|
||||
context: Context<string | null>;
|
||||
}) {
|
||||
const [filterDefinitionUsedInDropdown] = useRecoilScopedState(
|
||||
filterDefinitionUsedInDropdownScopedState,
|
||||
context,
|
||||
);
|
||||
|
||||
const [selectedOperandInDropdown] = useRecoilScopedState(
|
||||
selectedOperandInDropdownScopedState,
|
||||
context,
|
||||
);
|
||||
|
||||
const upsertFilter = useUpsertFilter(context);
|
||||
|
||||
function handleChange(date: Date) {
|
||||
if (!filterDefinitionUsedInDropdown || !selectedOperandInDropdown) return;
|
||||
|
||||
upsertFilter({
|
||||
field: filterDefinitionUsedInDropdown.field,
|
||||
type: filterDefinitionUsedInDropdown.type,
|
||||
value: date.toISOString(),
|
||||
operand: selectedOperandInDropdown,
|
||||
displayValue: date.toLocaleDateString(),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<DatePicker
|
||||
date={new Date()}
|
||||
onChangeHandler={handleChange}
|
||||
customInput={<></>}
|
||||
customCalendarContainer={styled.div`
|
||||
top: -10px;
|
||||
`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,39 @@
|
||||
import { ChangeEvent, Context } from 'react';
|
||||
|
||||
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';
|
||||
import { useRecoilScopedState } from '@/ui/recoil-scope/hooks/useRecoilScopedState';
|
||||
|
||||
export function FilterDropdownEntitySearchInput({
|
||||
context,
|
||||
}: {
|
||||
context: Context<string | null>;
|
||||
}) {
|
||||
const [filterDefinitionUsedInDropdown] = useRecoilScopedState(
|
||||
filterDefinitionUsedInDropdownScopedState,
|
||||
context,
|
||||
);
|
||||
|
||||
const [selectedOperandInDropdown] = useRecoilScopedState(
|
||||
selectedOperandInDropdownScopedState,
|
||||
context,
|
||||
);
|
||||
|
||||
const [filterDropdownSearchInput, setFilterDropdownSearchInput] =
|
||||
useRecoilScopedState(filterDropdownSearchInputScopedState, context);
|
||||
|
||||
return (
|
||||
filterDefinitionUsedInDropdown &&
|
||||
selectedOperandInDropdown && (
|
||||
<input
|
||||
type="text"
|
||||
value={filterDropdownSearchInput}
|
||||
placeholder={filterDefinitionUsedInDropdown.label}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) => {
|
||||
setFilterDropdownSearchInput(event.target.value);
|
||||
}}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,81 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { useFilterCurrentlyEdited } from '@/ui/filter-n-sort/hooks/useFilterCurrentlyEdited';
|
||||
import { useRemoveFilter } from '@/ui/filter-n-sort/hooks/useRemoveFilter';
|
||||
import { useUpsertFilter } from '@/ui/filter-n-sort/hooks/useUpsertFilter';
|
||||
import { filterDefinitionUsedInDropdownScopedState } from '@/ui/filter-n-sort/states/filterDefinitionUsedInDropdownScopedState';
|
||||
import { filterDropdownSelectedEntityIdScopedState } from '@/ui/filter-n-sort/states/filterDropdownSelectedEntityIdScopedState';
|
||||
import { selectedOperandInDropdownScopedState } from '@/ui/filter-n-sort/states/selectedOperandInDropdownScopedState';
|
||||
import { useRecoilScopedState } from '@/ui/recoil-scope/hooks/useRecoilScopedState';
|
||||
import { EntitiesForMultipleEntitySelect } from '@/ui/relation-picker/components/MultipleEntitySelect';
|
||||
import { SingleEntitySelectBase } from '@/ui/relation-picker/components/SingleEntitySelectBase';
|
||||
import { EntityForSelect } from '@/ui/relation-picker/types/EntityForSelect';
|
||||
|
||||
export function FilterDropdownEntitySearchSelect({
|
||||
entitiesForSelect,
|
||||
context,
|
||||
}: {
|
||||
entitiesForSelect: EntitiesForMultipleEntitySelect<EntityForSelect>;
|
||||
context: React.Context<string | null>;
|
||||
}) {
|
||||
const [filterDropdownSelectedEntityId, setFilterDropdownSelectedEntityId] =
|
||||
useRecoilScopedState(filterDropdownSelectedEntityIdScopedState, context);
|
||||
|
||||
const [selectedOperandInDropdown] = useRecoilScopedState(
|
||||
selectedOperandInDropdownScopedState,
|
||||
context,
|
||||
);
|
||||
|
||||
const [filterDefinitionUsedInDropdown] = useRecoilScopedState(
|
||||
filterDefinitionUsedInDropdownScopedState,
|
||||
context,
|
||||
);
|
||||
|
||||
const upsertFilter = useUpsertFilter(context);
|
||||
const removeFilter = useRemoveFilter(context);
|
||||
|
||||
const filterCurrentlyEdited = useFilterCurrentlyEdited(context);
|
||||
|
||||
function handleUserSelected(selectedEntity: EntityForSelect) {
|
||||
if (!filterDefinitionUsedInDropdown || !selectedOperandInDropdown) {
|
||||
return;
|
||||
}
|
||||
|
||||
const clickedOnAlreadySelectedEntity =
|
||||
selectedEntity.id === filterDropdownSelectedEntityId;
|
||||
|
||||
if (clickedOnAlreadySelectedEntity) {
|
||||
removeFilter(filterDefinitionUsedInDropdown.field);
|
||||
setFilterDropdownSelectedEntityId(null);
|
||||
} else {
|
||||
setFilterDropdownSelectedEntityId(selectedEntity.id);
|
||||
|
||||
upsertFilter({
|
||||
displayValue: selectedEntity.name,
|
||||
field: filterDefinitionUsedInDropdown.field,
|
||||
operand: selectedOperandInDropdown,
|
||||
type: filterDefinitionUsedInDropdown.type,
|
||||
value: selectedEntity.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!filterCurrentlyEdited) {
|
||||
setFilterDropdownSelectedEntityId(null);
|
||||
}
|
||||
}, [filterCurrentlyEdited, setFilterDropdownSelectedEntityId]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SingleEntitySelectBase
|
||||
entities={{
|
||||
entitiesToSelect: entitiesForSelect.entitiesToSelect,
|
||||
selectedEntity: entitiesForSelect.selectedEntities[0],
|
||||
loading: entitiesForSelect.loading,
|
||||
}}
|
||||
onEntitySelected={handleUserSelected}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,31 @@
|
||||
import { Context } from 'react';
|
||||
|
||||
import { DropdownMenuSeparator } from '@/ui/dropdown/components/DropdownMenuSeparator';
|
||||
import { RecoilScope } from '@/ui/recoil-scope/components/RecoilScope';
|
||||
import { useRecoilScopedState } from '@/ui/recoil-scope/hooks/useRecoilScopedState';
|
||||
|
||||
import { filterDefinitionUsedInDropdownScopedState } from '../states/filterDefinitionUsedInDropdownScopedState';
|
||||
|
||||
export function FilterDropdownEntitySelect({
|
||||
context,
|
||||
}: {
|
||||
context: Context<string | null>;
|
||||
}) {
|
||||
const [filterDefinitionUsedInDropdown] = useRecoilScopedState(
|
||||
filterDefinitionUsedInDropdownScopedState,
|
||||
context,
|
||||
);
|
||||
|
||||
if (filterDefinitionUsedInDropdown?.type !== 'entity') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<RecoilScope>
|
||||
{filterDefinitionUsedInDropdown.entitySelectComponent}
|
||||
</RecoilScope>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,72 @@
|
||||
import { Context } from 'react';
|
||||
|
||||
import { DropdownMenuItemContainer } from '@/ui/dropdown/components/DropdownMenuItemContainer';
|
||||
import { DropdownMenuSelectableItem } from '@/ui/dropdown/components/DropdownMenuSelectableItem';
|
||||
import { useSetHotkeyScope } from '@/ui/hotkey/hooks/useSetHotkeyScope';
|
||||
import { useRecoilScopedState } from '@/ui/recoil-scope/hooks/useRecoilScopedState';
|
||||
import { useRecoilScopedValue } from '@/ui/recoil-scope/hooks/useRecoilScopedValue';
|
||||
import { RelationPickerHotkeyScope } from '@/ui/relation-picker/types/RelationPickerHotkeyScope';
|
||||
|
||||
import { availableFiltersScopedState } from '../states/availableFiltersScopedState';
|
||||
import { filterDefinitionUsedInDropdownScopedState } from '../states/filterDefinitionUsedInDropdownScopedState';
|
||||
import { filterDropdownSearchInputScopedState } from '../states/filterDropdownSearchInputScopedState';
|
||||
import { selectedOperandInDropdownScopedState } from '../states/selectedOperandInDropdownScopedState';
|
||||
import { getOperandsForFilterType } from '../utils/getOperandsForFilterType';
|
||||
|
||||
import DropdownButton from './DropdownButton';
|
||||
|
||||
export function FilterDropdownFilterSelect({
|
||||
context,
|
||||
}: {
|
||||
context: Context<string | null>;
|
||||
}) {
|
||||
const [, setFilterDefinitionUsedInDropdown] = useRecoilScopedState(
|
||||
filterDefinitionUsedInDropdownScopedState,
|
||||
context,
|
||||
);
|
||||
|
||||
const [, setSelectedOperandInDropdown] = useRecoilScopedState(
|
||||
selectedOperandInDropdownScopedState,
|
||||
context,
|
||||
);
|
||||
|
||||
const [, setFilterDropdownSearchInput] = useRecoilScopedState(
|
||||
filterDropdownSearchInputScopedState,
|
||||
context,
|
||||
);
|
||||
|
||||
const availableFilters = useRecoilScopedValue(
|
||||
availableFiltersScopedState,
|
||||
context,
|
||||
);
|
||||
|
||||
const setHotkeyScope = useSetHotkeyScope();
|
||||
|
||||
return (
|
||||
<DropdownMenuItemContainer style={{ maxHeight: '300px' }}>
|
||||
{availableFilters.map((availableFilter, index) => (
|
||||
<DropdownMenuSelectableItem
|
||||
key={`select-filter-${index}`}
|
||||
onClick={() => {
|
||||
setFilterDefinitionUsedInDropdown(availableFilter);
|
||||
|
||||
if (availableFilter.type === 'entity') {
|
||||
setHotkeyScope(RelationPickerHotkeyScope.RelationPicker);
|
||||
}
|
||||
|
||||
setSelectedOperandInDropdown(
|
||||
getOperandsForFilterType(availableFilter.type)?.[0],
|
||||
);
|
||||
|
||||
setFilterDropdownSearchInput('');
|
||||
}}
|
||||
>
|
||||
<DropdownButton.StyledIcon>
|
||||
{availableFilter.icon}
|
||||
</DropdownButton.StyledIcon>
|
||||
{availableFilter.label}
|
||||
</DropdownMenuSelectableItem>
|
||||
))}
|
||||
</DropdownMenuItemContainer>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,50 @@
|
||||
import { ChangeEvent, Context } from 'react';
|
||||
|
||||
import { useRecoilScopedState } from '@/ui/recoil-scope/hooks/useRecoilScopedState';
|
||||
|
||||
import { useRemoveFilter } from '../hooks/useRemoveFilter';
|
||||
import { useUpsertFilter } from '../hooks/useUpsertFilter';
|
||||
import { filterDefinitionUsedInDropdownScopedState } from '../states/filterDefinitionUsedInDropdownScopedState';
|
||||
import { selectedOperandInDropdownScopedState } from '../states/selectedOperandInDropdownScopedState';
|
||||
|
||||
export function FilterDropdownNumberSearchInput({
|
||||
context,
|
||||
}: {
|
||||
context: Context<string | null>;
|
||||
}) {
|
||||
const [filterDefinitionUsedInDropdown] = useRecoilScopedState(
|
||||
filterDefinitionUsedInDropdownScopedState,
|
||||
context,
|
||||
);
|
||||
|
||||
const [selectedOperandInDropdown] = useRecoilScopedState(
|
||||
selectedOperandInDropdownScopedState,
|
||||
context,
|
||||
);
|
||||
|
||||
const upsertFilter = useUpsertFilter(context);
|
||||
const removeFilter = useRemoveFilter(context);
|
||||
|
||||
return (
|
||||
filterDefinitionUsedInDropdown &&
|
||||
selectedOperandInDropdown && (
|
||||
<input
|
||||
type="number"
|
||||
placeholder={filterDefinitionUsedInDropdown.label}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) => {
|
||||
if (event.target.value === '') {
|
||||
removeFilter(filterDefinitionUsedInDropdown.field);
|
||||
} else {
|
||||
upsertFilter({
|
||||
field: filterDefinitionUsedInDropdown.field,
|
||||
type: filterDefinitionUsedInDropdown.type,
|
||||
value: event.target.value,
|
||||
operand: selectedOperandInDropdown,
|
||||
displayValue: event.target.value,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,40 @@
|
||||
import { Context } from 'react';
|
||||
|
||||
import { useRecoilScopedState } from '@/ui/recoil-scope/hooks/useRecoilScopedState';
|
||||
|
||||
import { isFilterDropdownOperandSelectUnfoldedScopedState } from '../states/isFilterDropdownOperandSelectUnfoldedScopedState';
|
||||
import { selectedOperandInDropdownScopedState } from '../states/selectedOperandInDropdownScopedState';
|
||||
import { getOperandLabel } from '../utils/getOperandLabel';
|
||||
|
||||
import DropdownButton from './DropdownButton';
|
||||
|
||||
export function FilterDropdownOperandButton({
|
||||
context,
|
||||
}: {
|
||||
context: Context<string | null>;
|
||||
}) {
|
||||
const [selectedOperandInDropdown] = useRecoilScopedState(
|
||||
selectedOperandInDropdownScopedState,
|
||||
context,
|
||||
);
|
||||
|
||||
const [isOperandSelectionUnfolded, setIsOperandSelectionUnfolded] =
|
||||
useRecoilScopedState(
|
||||
isFilterDropdownOperandSelectUnfoldedScopedState,
|
||||
context,
|
||||
);
|
||||
|
||||
if (isOperandSelectionUnfolded) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownButton.StyledDropdownTopOption
|
||||
key={'selected-filter-operand'}
|
||||
onClick={() => setIsOperandSelectionUnfolded(true)}
|
||||
>
|
||||
{getOperandLabel(selectedOperandInDropdown)}
|
||||
<DropdownButton.StyledDropdownTopOptionAngleDown />
|
||||
</DropdownButton.StyledDropdownTopOption>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,79 @@
|
||||
import { Context } from 'react';
|
||||
|
||||
import { DropdownMenuItemContainer } from '@/ui/dropdown/components/DropdownMenuItemContainer';
|
||||
import { useRecoilScopedState } from '@/ui/recoil-scope/hooks/useRecoilScopedState';
|
||||
|
||||
import { useFilterCurrentlyEdited } from '../hooks/useFilterCurrentlyEdited';
|
||||
import { useUpsertFilter } from '../hooks/useUpsertFilter';
|
||||
import { filterDefinitionUsedInDropdownScopedState } from '../states/filterDefinitionUsedInDropdownScopedState';
|
||||
import { isFilterDropdownOperandSelectUnfoldedScopedState } from '../states/isFilterDropdownOperandSelectUnfoldedScopedState';
|
||||
import { selectedOperandInDropdownScopedState } from '../states/selectedOperandInDropdownScopedState';
|
||||
import { FilterOperand } from '../types/FilterOperand';
|
||||
import { getOperandLabel } from '../utils/getOperandLabel';
|
||||
import { getOperandsForFilterType } from '../utils/getOperandsForFilterType';
|
||||
|
||||
import DropdownButton from './DropdownButton';
|
||||
|
||||
export function FilterDropdownOperandSelect({
|
||||
context,
|
||||
}: {
|
||||
context: Context<string | null>;
|
||||
}) {
|
||||
const [filterDefinitionUsedInDropdown] = useRecoilScopedState(
|
||||
filterDefinitionUsedInDropdownScopedState,
|
||||
context,
|
||||
);
|
||||
|
||||
const [, setSelectedOperandInDropdown] = useRecoilScopedState(
|
||||
selectedOperandInDropdownScopedState,
|
||||
context,
|
||||
);
|
||||
|
||||
const operandsForFilterType = getOperandsForFilterType(
|
||||
filterDefinitionUsedInDropdown?.type,
|
||||
);
|
||||
|
||||
const [isOperandSelectionUnfolded, setIsOperandSelectionUnfolded] =
|
||||
useRecoilScopedState(
|
||||
isFilterDropdownOperandSelectUnfoldedScopedState,
|
||||
context,
|
||||
);
|
||||
|
||||
const filterCurrentlyEdited = useFilterCurrentlyEdited(context);
|
||||
|
||||
const upsertFilter = useUpsertFilter(context);
|
||||
|
||||
function handleOperangeChange(newOperand: FilterOperand) {
|
||||
setSelectedOperandInDropdown(newOperand);
|
||||
setIsOperandSelectionUnfolded(false);
|
||||
|
||||
if (filterDefinitionUsedInDropdown && filterCurrentlyEdited) {
|
||||
upsertFilter({
|
||||
field: filterCurrentlyEdited.field,
|
||||
displayValue: filterCurrentlyEdited.displayValue,
|
||||
operand: newOperand,
|
||||
type: filterCurrentlyEdited.type,
|
||||
value: filterCurrentlyEdited.value,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!isOperandSelectionUnfolded) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenuItemContainer>
|
||||
{operandsForFilterType.map((filterOperand, index) => (
|
||||
<DropdownButton.StyledDropdownItem
|
||||
key={`select-filter-operand-${index}`}
|
||||
onClick={() => {
|
||||
handleOperangeChange(filterOperand);
|
||||
}}
|
||||
>
|
||||
{getOperandLabel(filterOperand)}
|
||||
</DropdownButton.StyledDropdownItem>
|
||||
))}
|
||||
</DropdownMenuItemContainer>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,60 @@
|
||||
import { ChangeEvent, Context } from 'react';
|
||||
|
||||
import { useRecoilScopedState } from '@/ui/recoil-scope/hooks/useRecoilScopedState';
|
||||
|
||||
import { useFilterCurrentlyEdited } from '../hooks/useFilterCurrentlyEdited';
|
||||
import { useRemoveFilter } from '../hooks/useRemoveFilter';
|
||||
import { useUpsertFilter } from '../hooks/useUpsertFilter';
|
||||
import { filterDefinitionUsedInDropdownScopedState } from '../states/filterDefinitionUsedInDropdownScopedState';
|
||||
import { filterDropdownSearchInputScopedState } from '../states/filterDropdownSearchInputScopedState';
|
||||
import { selectedOperandInDropdownScopedState } from '../states/selectedOperandInDropdownScopedState';
|
||||
|
||||
export function FilterDropdownTextSearchInput({
|
||||
context,
|
||||
}: {
|
||||
context: Context<string | null>;
|
||||
}) {
|
||||
const [filterDefinitionUsedInDropdown] = useRecoilScopedState(
|
||||
filterDefinitionUsedInDropdownScopedState,
|
||||
context,
|
||||
);
|
||||
|
||||
const [selectedOperandInDropdown] = useRecoilScopedState(
|
||||
selectedOperandInDropdownScopedState,
|
||||
context,
|
||||
);
|
||||
|
||||
const [filterDropdownSearchInput, setFilterDropdownSearchInput] =
|
||||
useRecoilScopedState(filterDropdownSearchInputScopedState, context);
|
||||
|
||||
const upsertFilter = useUpsertFilter(context);
|
||||
const removeFilter = useRemoveFilter(context);
|
||||
|
||||
const filterCurrentlyEdited = useFilterCurrentlyEdited(context);
|
||||
|
||||
return (
|
||||
filterDefinitionUsedInDropdown &&
|
||||
selectedOperandInDropdown && (
|
||||
<input
|
||||
type="text"
|
||||
placeholder={filterDefinitionUsedInDropdown.label}
|
||||
value={filterCurrentlyEdited?.value ?? filterDropdownSearchInput}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) => {
|
||||
setFilterDropdownSearchInput(event.target.value);
|
||||
|
||||
if (event.target.value === '') {
|
||||
removeFilter(filterDefinitionUsedInDropdown.field);
|
||||
} else {
|
||||
upsertFilter({
|
||||
field: filterDefinitionUsedInDropdown.field,
|
||||
type: filterDefinitionUsedInDropdown.type,
|
||||
value: event.target.value,
|
||||
operand: selectedOperandInDropdown,
|
||||
displayValue: event.target.value,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,153 @@
|
||||
import { Context } from 'react';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { IconArrowNarrowDown, IconArrowNarrowUp } from '@/ui/icon/index';
|
||||
import { useRecoilScopedState } from '@/ui/recoil-scope/hooks/useRecoilScopedState';
|
||||
|
||||
import { useRemoveFilter } from '../hooks/useRemoveFilter';
|
||||
import { availableFiltersScopedState } from '../states/availableFiltersScopedState';
|
||||
import { filtersScopedState } from '../states/filtersScopedState';
|
||||
import { SelectedSortType } from '../types/interface';
|
||||
import { getOperandLabel } from '../utils/getOperandLabel';
|
||||
|
||||
import SortOrFilterChip from './SortOrFilterChip';
|
||||
|
||||
type OwnProps<SortField> = {
|
||||
context: Context<string | null>;
|
||||
sorts: Array<SelectedSortType<SortField>>;
|
||||
onRemoveSort: (sortId: SelectedSortType<SortField>['key']) => void;
|
||||
onCancelClick: () => void;
|
||||
};
|
||||
|
||||
const StyledBar = styled.div`
|
||||
align-items: center;
|
||||
border-top: 1px solid ${({ theme }) => theme.border.color.light};
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
height: 40px;
|
||||
justify-content: space-between;
|
||||
`;
|
||||
|
||||
const StyledChipcontainer = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: ${({ theme }) => theme.spacing(1)};
|
||||
height: 40px;
|
||||
justify-content: space-between;
|
||||
margin-left: ${({ theme }) => theme.spacing(2)};
|
||||
overflow-x: auto;
|
||||
`;
|
||||
|
||||
const StyledCancelButton = styled.button`
|
||||
background-color: inherit;
|
||||
border: none;
|
||||
color: ${({ theme }) => theme.font.color.secondary};
|
||||
cursor: pointer;
|
||||
font-weight: ${({ theme }) => theme.font.weight.medium};
|
||||
margin-left: auto;
|
||||
margin-right: ${({ theme }) => theme.spacing(2)};
|
||||
padding: ${(props) => {
|
||||
const horiz = props.theme.spacing(2);
|
||||
const vert = props.theme.spacing(1);
|
||||
return `${vert} ${horiz} ${vert} ${horiz}`;
|
||||
}};
|
||||
user-select: none;
|
||||
|
||||
&:hover {
|
||||
background-color: ${({ theme }) => theme.background.tertiary};
|
||||
border-radius: ${({ theme }) => theme.spacing(1)};
|
||||
}
|
||||
`;
|
||||
|
||||
function SortAndFilterBar<SortField>({
|
||||
context,
|
||||
sorts,
|
||||
onRemoveSort,
|
||||
onCancelClick,
|
||||
}: OwnProps<SortField>) {
|
||||
const theme = useTheme();
|
||||
|
||||
const [filters, setFilters] = useRecoilScopedState(
|
||||
filtersScopedState,
|
||||
context,
|
||||
);
|
||||
|
||||
const [availableFilters] = useRecoilScopedState(
|
||||
availableFiltersScopedState,
|
||||
context,
|
||||
);
|
||||
|
||||
const filtersWithDefinition = filters.map((filter) => {
|
||||
const filterDefinition = availableFilters.find((availableFilter) => {
|
||||
return availableFilter.field === filter.field;
|
||||
});
|
||||
|
||||
return {
|
||||
...filter,
|
||||
...filterDefinition,
|
||||
};
|
||||
});
|
||||
|
||||
const removeFilter = useRemoveFilter(context);
|
||||
|
||||
function handleCancelClick() {
|
||||
setFilters([]);
|
||||
onCancelClick();
|
||||
}
|
||||
|
||||
if (!filtersWithDefinition.length && !sorts.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledBar>
|
||||
<StyledChipcontainer>
|
||||
{sorts.map((sort) => {
|
||||
return (
|
||||
<SortOrFilterChip
|
||||
key={sort.key}
|
||||
labelValue={sort.label}
|
||||
id={sort.key}
|
||||
icon={
|
||||
sort.order === 'desc' ? (
|
||||
<IconArrowNarrowDown size={theme.icon.size.md} />
|
||||
) : (
|
||||
<IconArrowNarrowUp size={theme.icon.size.md} />
|
||||
)
|
||||
}
|
||||
onRemove={() => onRemoveSort(sort.key)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{filtersWithDefinition.map((filter) => {
|
||||
return (
|
||||
<SortOrFilterChip
|
||||
key={filter.field}
|
||||
labelKey={filter.label}
|
||||
labelValue={`${getOperandLabel(filter.operand)} ${
|
||||
filter.displayValue
|
||||
}`}
|
||||
id={filter.field}
|
||||
icon={filter.icon}
|
||||
onRemove={() => {
|
||||
removeFilter(filter.field);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</StyledChipcontainer>
|
||||
{filters.length + sorts.length > 0 && (
|
||||
<StyledCancelButton
|
||||
data-testid={'cancel-button'}
|
||||
onClick={handleCancelClick}
|
||||
>
|
||||
Cancel
|
||||
</StyledCancelButton>
|
||||
)}
|
||||
</StyledBar>
|
||||
);
|
||||
}
|
||||
|
||||
export default SortAndFilterBar;
|
||||
@ -0,0 +1,97 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { FiltersHotkeyScope } from '../types/FiltersHotkeyScope';
|
||||
import { SelectedSortType, SortType } from '../types/interface';
|
||||
|
||||
import DropdownButton from './DropdownButton';
|
||||
|
||||
type OwnProps<SortField> = {
|
||||
isSortSelected: boolean;
|
||||
onSortSelect: (sort: SelectedSortType<SortField>) => void;
|
||||
availableSorts: SortType<SortField>[];
|
||||
HotkeyScope: FiltersHotkeyScope;
|
||||
};
|
||||
|
||||
const options: Array<SelectedSortType<any>['order']> = ['asc', 'desc'];
|
||||
|
||||
export function SortDropdownButton<SortField>({
|
||||
isSortSelected,
|
||||
availableSorts,
|
||||
onSortSelect,
|
||||
HotkeyScope,
|
||||
}: OwnProps<SortField>) {
|
||||
const [isUnfolded, setIsUnfolded] = useState(false);
|
||||
|
||||
const [isOptionUnfolded, setIsOptionUnfolded] = useState(false);
|
||||
|
||||
const [selectedSortDirection, setSelectedSortDirection] =
|
||||
useState<SelectedSortType<SortField>['order']>('asc');
|
||||
|
||||
const onSortItemSelect = useCallback(
|
||||
(sort: SortType<SortField>) => {
|
||||
onSortSelect({ ...sort, order: selectedSortDirection });
|
||||
},
|
||||
[onSortSelect, selectedSortDirection],
|
||||
);
|
||||
|
||||
const resetState = useCallback(() => {
|
||||
setIsOptionUnfolded(false);
|
||||
setSelectedSortDirection('asc');
|
||||
}, []);
|
||||
|
||||
function handleIsUnfoldedChange(newIsUnfolded: boolean) {
|
||||
if (newIsUnfolded) {
|
||||
setIsUnfolded(true);
|
||||
} else {
|
||||
setIsUnfolded(false);
|
||||
resetState();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownButton
|
||||
label="Sort"
|
||||
isActive={isSortSelected}
|
||||
isUnfolded={isUnfolded}
|
||||
onIsUnfoldedChange={handleIsUnfoldedChange}
|
||||
HotkeyScope={HotkeyScope}
|
||||
>
|
||||
{isOptionUnfolded
|
||||
? options.map((option, index) => (
|
||||
<DropdownButton.StyledDropdownItem
|
||||
key={index}
|
||||
onClick={() => {
|
||||
setSelectedSortDirection(option);
|
||||
setIsOptionUnfolded(false);
|
||||
}}
|
||||
>
|
||||
{option === 'asc' ? 'Ascending' : 'Descending'}
|
||||
</DropdownButton.StyledDropdownItem>
|
||||
))
|
||||
: [
|
||||
<DropdownButton.StyledDropdownTopOption
|
||||
key={0}
|
||||
onClick={() => setIsOptionUnfolded(true)}
|
||||
>
|
||||
{selectedSortDirection === 'asc' ? 'Ascending' : 'Descending'}
|
||||
|
||||
<DropdownButton.StyledDropdownTopOptionAngleDown />
|
||||
</DropdownButton.StyledDropdownTopOption>,
|
||||
...availableSorts.map((sort, index) => (
|
||||
<DropdownButton.StyledDropdownItem
|
||||
key={index + 1}
|
||||
onClick={() => {
|
||||
setIsUnfolded(false);
|
||||
onSortItemSelect(sort);
|
||||
}}
|
||||
>
|
||||
<DropdownButton.StyledIcon>
|
||||
{sort.icon}
|
||||
</DropdownButton.StyledIcon>
|
||||
{sort.label}
|
||||
</DropdownButton.StyledDropdownItem>
|
||||
)),
|
||||
]}
|
||||
</DropdownButton>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,71 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { IconX } from '@/ui/icon/index';
|
||||
|
||||
type OwnProps = {
|
||||
id: string;
|
||||
labelKey?: string;
|
||||
labelValue: string;
|
||||
icon: ReactNode;
|
||||
onRemove: () => void;
|
||||
};
|
||||
|
||||
const StyledChip = styled.div`
|
||||
align-items: center;
|
||||
background-color: ${({ theme }) => theme.background.secondary};
|
||||
border: 1px solid ${({ theme }) => theme.border.color.medium};
|
||||
border-radius: 50px;
|
||||
color: ${({ theme }) => theme.color.blue};
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-shrink: 0;
|
||||
font-size: ${({ theme }) => theme.font.size.sm};
|
||||
padding: ${({ theme }) => theme.spacing(1) + ' ' + theme.spacing(2)};
|
||||
`;
|
||||
const StyledIcon = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
margin-right: ${({ theme }) => theme.spacing(1)};
|
||||
`;
|
||||
|
||||
const StyledDelete = styled.div`
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
font-size: ${({ theme }) => theme.font.size.sm};
|
||||
margin-left: ${({ theme }) => theme.spacing(2)};
|
||||
margin-top: 1px;
|
||||
user-select: none;
|
||||
&:hover {
|
||||
background-color: ${({ theme }) => theme.background.tertiary};
|
||||
border-radius: ${({ theme }) => theme.border.radius.sm};
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledLabelKey = styled.div`
|
||||
font-weight: ${({ theme }) => theme.font.weight.medium};
|
||||
`;
|
||||
|
||||
function SortOrFilterChip({
|
||||
id,
|
||||
labelKey,
|
||||
labelValue,
|
||||
icon,
|
||||
onRemove,
|
||||
}: OwnProps) {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<StyledChip>
|
||||
<StyledIcon>{icon}</StyledIcon>
|
||||
{labelKey && <StyledLabelKey>{labelKey}: </StyledLabelKey>}
|
||||
{labelValue}
|
||||
<StyledDelete onClick={onRemove} data-testid={'remove-icon-' + id}>
|
||||
<IconX size={theme.icon.size.sm} stroke={theme.icon.stroke.sm} />
|
||||
</StyledDelete>
|
||||
</StyledChip>
|
||||
);
|
||||
}
|
||||
|
||||
export default SortOrFilterChip;
|
||||
28
front/src/modules/ui/filter-n-sort/helpers.ts
Normal file
28
front/src/modules/ui/filter-n-sort/helpers.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { SortOrder as Order_By } from '~/generated/graphql';
|
||||
|
||||
import { SelectedSortType } from './types/interface';
|
||||
|
||||
const mapOrderToOrder_By = (order: string) => {
|
||||
if (order === 'asc') return Order_By.Asc;
|
||||
return Order_By.Desc;
|
||||
};
|
||||
|
||||
export const defaultOrderByTemplateFactory =
|
||||
(key: string) => (order: string) => ({
|
||||
[key]: order,
|
||||
});
|
||||
|
||||
export const reduceSortsToOrderBy = <OrderByTemplate>(
|
||||
sorts: Array<SelectedSortType<OrderByTemplate>>,
|
||||
): OrderByTemplate[] => {
|
||||
const mappedSorts = sorts.map((sort) => {
|
||||
if (sort.orderByTemplates) {
|
||||
return sort.orderByTemplates?.map((orderByTemplate) =>
|
||||
orderByTemplate(mapOrderToOrder_By(sort.order)),
|
||||
);
|
||||
}
|
||||
|
||||
return defaultOrderByTemplateFactory(sort.key as string)(sort.order);
|
||||
});
|
||||
return mappedSorts.flat() as OrderByTemplate[];
|
||||
};
|
||||
@ -0,0 +1,21 @@
|
||||
import { Context, useMemo } from 'react';
|
||||
|
||||
import { useRecoilScopedState } from '@/ui/recoil-scope/hooks/useRecoilScopedState';
|
||||
|
||||
import { filterDefinitionUsedInDropdownScopedState } from '../states/filterDefinitionUsedInDropdownScopedState';
|
||||
import { filtersScopedState } from '../states/filtersScopedState';
|
||||
|
||||
export function useFilterCurrentlyEdited(context: Context<string | null>) {
|
||||
const [filters] = useRecoilScopedState(filtersScopedState, context);
|
||||
|
||||
const [filterDefinitionUsedInDropdown] = useRecoilScopedState(
|
||||
filterDefinitionUsedInDropdownScopedState,
|
||||
context,
|
||||
);
|
||||
|
||||
return useMemo(() => {
|
||||
return filters.find(
|
||||
(filter) => filter.field === filterDefinitionUsedInDropdown?.field,
|
||||
);
|
||||
}, [filterDefinitionUsedInDropdown, filters]);
|
||||
}
|
||||
17
front/src/modules/ui/filter-n-sort/hooks/useRemoveFilter.ts
Normal file
17
front/src/modules/ui/filter-n-sort/hooks/useRemoveFilter.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { Context } from 'react';
|
||||
|
||||
import { useRecoilScopedState } from '@/ui/recoil-scope/hooks/useRecoilScopedState';
|
||||
|
||||
import { filtersScopedState } from '../states/filtersScopedState';
|
||||
|
||||
export function useRemoveFilter(context: Context<string | null>) {
|
||||
const [, setFilters] = useRecoilScopedState(filtersScopedState, context);
|
||||
|
||||
return function removeFilter(filterField: string) {
|
||||
setFilters((filters) => {
|
||||
return filters.filter((filter) => {
|
||||
return filter.field !== filterField;
|
||||
});
|
||||
});
|
||||
};
|
||||
}
|
||||
27
front/src/modules/ui/filter-n-sort/hooks/useUpsertFilter.ts
Normal file
27
front/src/modules/ui/filter-n-sort/hooks/useUpsertFilter.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { Context } from 'react';
|
||||
import { produce } from 'immer';
|
||||
|
||||
import { useRecoilScopedState } from '@/ui/recoil-scope/hooks/useRecoilScopedState';
|
||||
|
||||
import { filtersScopedState } from '../states/filtersScopedState';
|
||||
import { Filter } from '../types/Filter';
|
||||
|
||||
export function useUpsertFilter(context: Context<string | null>) {
|
||||
const [, setFilters] = useRecoilScopedState(filtersScopedState, context);
|
||||
|
||||
return function upsertFilter(filterToUpsert: Filter) {
|
||||
setFilters((filters) => {
|
||||
return produce(filters, (filtersDraft) => {
|
||||
const index = filtersDraft.findIndex(
|
||||
(filter) => filter.field === filterToUpsert.field,
|
||||
);
|
||||
|
||||
if (index === -1) {
|
||||
filtersDraft.push(filterToUpsert);
|
||||
} else {
|
||||
filtersDraft[index] = filterToUpsert;
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
import { atomFamily } from 'recoil';
|
||||
|
||||
import { FilterDefinition } from '../types/FilterDefinition';
|
||||
|
||||
export const availableFiltersScopedState = atomFamily<
|
||||
FilterDefinition[],
|
||||
string
|
||||
>({
|
||||
key: 'availableFiltersScopedState',
|
||||
default: [],
|
||||
});
|
||||
@ -0,0 +1,11 @@
|
||||
import { atomFamily } from 'recoil';
|
||||
|
||||
import { FilterDefinition } from '../types/FilterDefinition';
|
||||
|
||||
export const filterDefinitionUsedInDropdownScopedState = atomFamily<
|
||||
FilterDefinition | null,
|
||||
string
|
||||
>({
|
||||
key: 'filterDefinitionUsedInDropdownScopedState',
|
||||
default: null,
|
||||
});
|
||||
@ -0,0 +1,6 @@
|
||||
import { atomFamily } from 'recoil';
|
||||
|
||||
export const filterDropdownSearchInputScopedState = atomFamily<string, string>({
|
||||
key: 'filterDropdownSearchInputScopedState',
|
||||
default: '',
|
||||
});
|
||||
@ -0,0 +1,9 @@
|
||||
import { atomFamily } from 'recoil';
|
||||
|
||||
export const filterDropdownSelectedEntityIdScopedState = atomFamily<
|
||||
string | null,
|
||||
string
|
||||
>({
|
||||
key: 'filterDropdownSelectedEntityIdScopedState',
|
||||
default: null,
|
||||
});
|
||||
@ -0,0 +1,8 @@
|
||||
import { atomFamily } from 'recoil';
|
||||
|
||||
import { Filter } from '../types/Filter';
|
||||
|
||||
export const filtersScopedState = atomFamily<Filter[], string>({
|
||||
key: 'filtersScopedState',
|
||||
default: [],
|
||||
});
|
||||
@ -0,0 +1,9 @@
|
||||
import { atomFamily } from 'recoil';
|
||||
|
||||
export const isFilterDropdownOperandSelectUnfoldedScopedState = atomFamily<
|
||||
boolean,
|
||||
string
|
||||
>({
|
||||
key: 'isFilterDropdownOperandSelectUnfoldedScopedState',
|
||||
default: false,
|
||||
});
|
||||
@ -0,0 +1,11 @@
|
||||
import { atomFamily } from 'recoil';
|
||||
|
||||
import { FilterOperand } from '../types/FilterOperand';
|
||||
|
||||
export const selectedOperandInDropdownScopedState = atomFamily<
|
||||
FilterOperand | null,
|
||||
string
|
||||
>({
|
||||
key: 'selectedOperandInDropdownScopedState',
|
||||
default: null,
|
||||
});
|
||||
10
front/src/modules/ui/filter-n-sort/types/Filter.ts
Normal file
10
front/src/modules/ui/filter-n-sort/types/Filter.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { FilterOperand } from './FilterOperand';
|
||||
import { FilterType } from './FilterType';
|
||||
|
||||
export type Filter = {
|
||||
field: string;
|
||||
type: FilterType;
|
||||
value: string;
|
||||
displayValue: string;
|
||||
operand: FilterOperand;
|
||||
};
|
||||
@ -0,0 +1,9 @@
|
||||
import { FilterType } from './FilterType';
|
||||
|
||||
export type FilterDefinition = {
|
||||
field: string;
|
||||
label: string;
|
||||
icon: JSX.Element;
|
||||
type: FilterType;
|
||||
entitySelectComponent?: JSX.Element;
|
||||
};
|
||||
@ -0,0 +1,5 @@
|
||||
import { FilterDefinition } from './FilterDefinition';
|
||||
|
||||
export type FilterDefinitionByEntity<T> = FilterDefinition & {
|
||||
field: keyof T;
|
||||
};
|
||||
@ -0,0 +1,7 @@
|
||||
export type FilterOperand =
|
||||
| 'contains'
|
||||
| 'does-not-contain'
|
||||
| 'greater-than'
|
||||
| 'less-than'
|
||||
| 'is'
|
||||
| 'is-not';
|
||||
@ -0,0 +1,4 @@
|
||||
export type FilterSearchResult = {
|
||||
id: string;
|
||||
label: string;
|
||||
};
|
||||
1
front/src/modules/ui/filter-n-sort/types/FilterType.ts
Normal file
1
front/src/modules/ui/filter-n-sort/types/FilterType.ts
Normal file
@ -0,0 +1 @@
|
||||
export type FilterType = 'text' | 'date' | 'entity' | 'number';
|
||||
@ -0,0 +1,3 @@
|
||||
export enum FiltersHotkeyScope {
|
||||
FilterDropdownButton = 'filter-dropdown-button',
|
||||
}
|
||||
14
front/src/modules/ui/filter-n-sort/types/interface.ts
Normal file
14
front/src/modules/ui/filter-n-sort/types/interface.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
import { SortOrder as Order_By } from '~/generated/graphql';
|
||||
|
||||
export type SortType<OrderByTemplate> = {
|
||||
label: string;
|
||||
key: string;
|
||||
icon?: ReactNode;
|
||||
orderByTemplates?: Array<(order: Order_By) => OrderByTemplate>;
|
||||
};
|
||||
|
||||
export type SelectedSortType<OrderByTemplate> = SortType<OrderByTemplate> & {
|
||||
order: 'asc' | 'desc';
|
||||
};
|
||||
20
front/src/modules/ui/filter-n-sort/utils/getOperandLabel.ts
Normal file
20
front/src/modules/ui/filter-n-sort/utils/getOperandLabel.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { FilterOperand } from '../types/FilterOperand';
|
||||
|
||||
export function getOperandLabel(operand: FilterOperand | null | undefined) {
|
||||
switch (operand) {
|
||||
case 'contains':
|
||||
return 'Contains';
|
||||
case 'does-not-contain':
|
||||
return "Does'nt contain";
|
||||
case 'greater-than':
|
||||
return 'Greater than';
|
||||
case 'less-than':
|
||||
return 'Less than';
|
||||
case 'is':
|
||||
return 'Is';
|
||||
case 'is-not':
|
||||
return 'Is not';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,18 @@
|
||||
import { FilterOperand } from '../types/FilterOperand';
|
||||
import { FilterType } from '../types/FilterType';
|
||||
|
||||
export function getOperandsForFilterType(
|
||||
filterType: FilterType | null | undefined,
|
||||
): FilterOperand[] {
|
||||
switch (filterType) {
|
||||
case 'text':
|
||||
return ['contains', 'does-not-contain'];
|
||||
case 'number':
|
||||
case 'date':
|
||||
return ['greater-than', 'less-than'];
|
||||
case 'entity':
|
||||
return ['is', 'is-not'];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,90 @@
|
||||
import { QueryMode } from '~/generated/graphql';
|
||||
|
||||
import { Filter } from '../types/Filter';
|
||||
|
||||
export function turnFilterIntoWhereClause(filter: Filter) {
|
||||
switch (filter.type) {
|
||||
case 'text':
|
||||
switch (filter.operand) {
|
||||
case 'contains':
|
||||
return {
|
||||
[filter.field]: {
|
||||
contains: filter.value,
|
||||
mode: QueryMode.Insensitive,
|
||||
},
|
||||
};
|
||||
case 'does-not-contain':
|
||||
return {
|
||||
[filter.field]: {
|
||||
not: {
|
||||
contains: filter.value,
|
||||
mode: QueryMode.Insensitive,
|
||||
},
|
||||
},
|
||||
};
|
||||
default:
|
||||
throw new Error(
|
||||
`Unknown operand ${filter.operand} for ${filter.type} filter`,
|
||||
);
|
||||
}
|
||||
case 'number':
|
||||
switch (filter.operand) {
|
||||
case 'greater-than':
|
||||
return {
|
||||
[filter.field]: {
|
||||
gte: parseFloat(filter.value),
|
||||
},
|
||||
};
|
||||
case 'less-than':
|
||||
return {
|
||||
[filter.field]: {
|
||||
lte: parseFloat(filter.value),
|
||||
},
|
||||
};
|
||||
default:
|
||||
throw new Error(
|
||||
`Unknown operand ${filter.operand} for ${filter.type} filter`,
|
||||
);
|
||||
}
|
||||
case 'date':
|
||||
switch (filter.operand) {
|
||||
case 'greater-than':
|
||||
return {
|
||||
[filter.field]: {
|
||||
gte: filter.value,
|
||||
},
|
||||
};
|
||||
case 'less-than':
|
||||
return {
|
||||
[filter.field]: {
|
||||
lte: filter.value,
|
||||
},
|
||||
};
|
||||
default:
|
||||
throw new Error(
|
||||
`Unknown operand ${filter.operand} for ${filter.type} filter`,
|
||||
);
|
||||
}
|
||||
case 'entity':
|
||||
switch (filter.operand) {
|
||||
case 'is':
|
||||
return {
|
||||
[filter.field]: {
|
||||
equals: filter.value,
|
||||
},
|
||||
};
|
||||
case 'is-not':
|
||||
return {
|
||||
[filter.field]: {
|
||||
not: { equals: filter.value },
|
||||
},
|
||||
};
|
||||
default:
|
||||
throw new Error(
|
||||
`Unknown operand ${filter.operand} for ${filter.type} filter`,
|
||||
);
|
||||
}
|
||||
default:
|
||||
throw new Error('Unknown filter type');
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
import React, { useEffect } from 'react';
|
||||
|
||||
import { isDefined } from '@/utils/type-guards/isDefined';
|
||||
import { isDefined } from '~/utils/isDefined';
|
||||
|
||||
export function useListenClickOutsideArrayOfRef<T extends Element>(
|
||||
arrayOfRef: Array<React.RefObject<T>>,
|
||||
|
||||
23
front/src/modules/ui/hotkey/constants/index.ts
Normal file
23
front/src/modules/ui/hotkey/constants/index.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { AppHotkeyScope } from '../types/AppHotkeyScope';
|
||||
import { CustomHotkeyScopes } from '../types/CustomHotkeyScope';
|
||||
import { HotkeyScope } from '../types/HotkeyScope';
|
||||
|
||||
export const INITIAL_HOTKEYS_SCOPES: string[] = [AppHotkeyScope.App];
|
||||
|
||||
export const ALWAYS_ON_HOTKEYS_SCOPES: string[] = [
|
||||
AppHotkeyScope.CommandMenu,
|
||||
AppHotkeyScope.App,
|
||||
];
|
||||
|
||||
export const DEFAULT_HOTKEYS_SCOPE_CUSTOM_SCOPES: CustomHotkeyScopes = {
|
||||
commandMenu: true,
|
||||
goto: false,
|
||||
};
|
||||
|
||||
export const INITIAL_HOTKEYS_SCOPE: HotkeyScope = {
|
||||
scope: AppHotkeyScope.App,
|
||||
customScopes: {
|
||||
commandMenu: true,
|
||||
goto: true,
|
||||
},
|
||||
};
|
||||
@ -0,0 +1,30 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
|
||||
import { currentHotkeyScopeState } from '@/ui/hotkey/states/internal/currentHotkeyScopeState';
|
||||
|
||||
import { AppHotkeyScope } from '../../types/AppHotkeyScope';
|
||||
|
||||
import { useHotkeyScopes } from './useHotkeyScopes';
|
||||
|
||||
export function useHotkeyScopeAutoSync() {
|
||||
const { setHotkeyScopes } = useHotkeyScopes();
|
||||
|
||||
const currentHotkeyScope = useRecoilValue(currentHotkeyScopeState);
|
||||
|
||||
useEffect(() => {
|
||||
const scopesToSet: string[] = [];
|
||||
|
||||
if (currentHotkeyScope.customScopes?.commandMenu) {
|
||||
scopesToSet.push(AppHotkeyScope.CommandMenu);
|
||||
}
|
||||
|
||||
if (currentHotkeyScope?.customScopes?.goto) {
|
||||
scopesToSet.push(AppHotkeyScope.Goto);
|
||||
}
|
||||
|
||||
scopesToSet.push(currentHotkeyScope.scope);
|
||||
|
||||
setHotkeyScopes(scopesToSet);
|
||||
}, [setHotkeyScopes, currentHotkeyScope]);
|
||||
}
|
||||
103
front/src/modules/ui/hotkey/hooks/internal/useHotkeyScopes.ts
Normal file
103
front/src/modules/ui/hotkey/hooks/internal/useHotkeyScopes.ts
Normal file
@ -0,0 +1,103 @@
|
||||
import { useHotkeysContext } from 'react-hotkeys-hook';
|
||||
import { useRecoilCallback } from 'recoil';
|
||||
|
||||
import { internalHotkeysEnabledScopesState } from '../../states/internal/internalHotkeysEnabledScopesState';
|
||||
|
||||
export function useHotkeyScopes() {
|
||||
const { disableScope, enableScope } = useHotkeysContext();
|
||||
|
||||
const disableAllHotkeyScopes = useRecoilCallback(
|
||||
({ set, snapshot }) => {
|
||||
return async () => {
|
||||
const enabledScopes = await snapshot.getPromise(
|
||||
internalHotkeysEnabledScopesState,
|
||||
);
|
||||
|
||||
for (const enabledScope of enabledScopes) {
|
||||
disableScope(enabledScope);
|
||||
}
|
||||
|
||||
set(internalHotkeysEnabledScopesState, []);
|
||||
};
|
||||
},
|
||||
[disableScope],
|
||||
);
|
||||
|
||||
const enableHotkeyScope = useRecoilCallback(
|
||||
({ set, snapshot }) => {
|
||||
return async (scopeToEnable: string) => {
|
||||
const enabledScopes = await snapshot.getPromise(
|
||||
internalHotkeysEnabledScopesState,
|
||||
);
|
||||
|
||||
if (!enabledScopes.includes(scopeToEnable)) {
|
||||
enableScope(scopeToEnable);
|
||||
set(internalHotkeysEnabledScopesState, [
|
||||
...enabledScopes,
|
||||
scopeToEnable,
|
||||
]);
|
||||
}
|
||||
};
|
||||
},
|
||||
[enableScope],
|
||||
);
|
||||
|
||||
const disableHotkeyScope = useRecoilCallback(
|
||||
({ set, snapshot }) => {
|
||||
return async (scopeToDisable: string) => {
|
||||
const enabledScopes = await snapshot.getPromise(
|
||||
internalHotkeysEnabledScopesState,
|
||||
);
|
||||
|
||||
const scopeToRemoveIndex = enabledScopes.findIndex(
|
||||
(scope) => scope === scopeToDisable,
|
||||
);
|
||||
|
||||
if (scopeToRemoveIndex > -1) {
|
||||
disableScope(scopeToDisable);
|
||||
|
||||
enabledScopes.splice(scopeToRemoveIndex);
|
||||
|
||||
set(internalHotkeysEnabledScopesState, enabledScopes);
|
||||
}
|
||||
};
|
||||
},
|
||||
[disableScope],
|
||||
);
|
||||
|
||||
const setHotkeyScopes = useRecoilCallback(
|
||||
({ set, snapshot }) => {
|
||||
return async (scopesToSet: string[]) => {
|
||||
const enabledScopes = await snapshot.getPromise(
|
||||
internalHotkeysEnabledScopesState,
|
||||
);
|
||||
|
||||
const scopesToDisable = enabledScopes.filter(
|
||||
(enabledScope) => !scopesToSet.includes(enabledScope),
|
||||
);
|
||||
|
||||
const scopesToEnable = scopesToSet.filter(
|
||||
(scopeToSet) => !enabledScopes.includes(scopeToSet),
|
||||
);
|
||||
|
||||
for (const scopeToDisable of scopesToDisable) {
|
||||
disableScope(scopeToDisable);
|
||||
}
|
||||
|
||||
for (const scopeToEnable of scopesToEnable) {
|
||||
enableScope(scopeToEnable);
|
||||
}
|
||||
|
||||
set(internalHotkeysEnabledScopesState, scopesToSet);
|
||||
};
|
||||
},
|
||||
[disableScope, enableScope],
|
||||
);
|
||||
|
||||
return {
|
||||
disableAllHotkeyScopes,
|
||||
enableHotkeyScope,
|
||||
disableHotkeyScope,
|
||||
setHotkeyScopes,
|
||||
};
|
||||
}
|
||||
25
front/src/modules/ui/hotkey/hooks/useGoToHotkeys.ts
Normal file
25
front/src/modules/ui/hotkey/hooks/useGoToHotkeys.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { Keys } from 'react-hotkeys-hook/dist/types';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { AppHotkeyScope } from '../types/AppHotkeyScope';
|
||||
|
||||
import { useSequenceHotkeys } from './useSequenceScopedHotkeys';
|
||||
|
||||
export function useGoToHotkeys(key: Keys, location: string) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
useSequenceHotkeys(
|
||||
'g',
|
||||
key,
|
||||
() => {
|
||||
navigate(location);
|
||||
},
|
||||
AppHotkeyScope.Goto,
|
||||
{
|
||||
enableOnContentEditable: true,
|
||||
enableOnFormTags: true,
|
||||
preventDefault: true,
|
||||
},
|
||||
[navigate],
|
||||
);
|
||||
}
|
||||
39
front/src/modules/ui/hotkey/hooks/usePreviousHotkeyScope.ts
Normal file
39
front/src/modules/ui/hotkey/hooks/usePreviousHotkeyScope.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { useState } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
|
||||
import { currentHotkeyScopeState } from '../states/internal/currentHotkeyScopeState';
|
||||
import { CustomHotkeyScopes } from '../types/CustomHotkeyScope';
|
||||
import { HotkeyScope } from '../types/HotkeyScope';
|
||||
|
||||
import { useSetHotkeyScope } from './useSetHotkeyScope';
|
||||
|
||||
export function usePreviousHotkeyScope() {
|
||||
const [previousHotkeyScope, setPreviousHotkeyScope] =
|
||||
useState<HotkeyScope | null>();
|
||||
|
||||
const setHotkeyScope = useSetHotkeyScope();
|
||||
|
||||
const currentHotkeyScope = useRecoilValue(currentHotkeyScopeState);
|
||||
|
||||
function goBackToPreviousHotkeyScope() {
|
||||
if (previousHotkeyScope) {
|
||||
setHotkeyScope(
|
||||
previousHotkeyScope.scope,
|
||||
previousHotkeyScope.customScopes,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function setHotkeyScopeAndMemorizePreviousScope(
|
||||
scope: string,
|
||||
customScopes?: CustomHotkeyScopes,
|
||||
) {
|
||||
setPreviousHotkeyScope(currentHotkeyScope);
|
||||
setHotkeyScope(scope, customScopes);
|
||||
}
|
||||
|
||||
return {
|
||||
setHotkeyScopeAndMemorizePreviousScope,
|
||||
goBackToPreviousHotkeyScope,
|
||||
};
|
||||
}
|
||||
43
front/src/modules/ui/hotkey/hooks/useScopedHotkeys.ts
Normal file
43
front/src/modules/ui/hotkey/hooks/useScopedHotkeys.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import {
|
||||
Hotkey,
|
||||
HotkeyCallback,
|
||||
Keys,
|
||||
Options,
|
||||
OptionsOrDependencyArray,
|
||||
} from 'react-hotkeys-hook/dist/types';
|
||||
import { useRecoilState } from 'recoil';
|
||||
|
||||
import { pendingHotkeyState } from '../states/internal/pendingHotkeysState';
|
||||
|
||||
export function useScopedHotkeys(
|
||||
keys: Keys,
|
||||
callback: HotkeyCallback,
|
||||
scope: string,
|
||||
dependencies?: OptionsOrDependencyArray,
|
||||
options: Options = {
|
||||
enableOnContentEditable: true,
|
||||
enableOnFormTags: true,
|
||||
preventDefault: true,
|
||||
},
|
||||
) {
|
||||
const [pendingHotkey, setPendingHotkey] = useRecoilState(pendingHotkeyState);
|
||||
|
||||
function callbackIfDirectKey(
|
||||
keyboardEvent: KeyboardEvent,
|
||||
hotkeysEvent: Hotkey,
|
||||
) {
|
||||
if (!pendingHotkey) {
|
||||
callback(keyboardEvent, hotkeysEvent);
|
||||
return;
|
||||
}
|
||||
setPendingHotkey(null);
|
||||
}
|
||||
|
||||
return useHotkeys(
|
||||
keys,
|
||||
callbackIfDirectKey,
|
||||
{ ...options, scopes: [scope] },
|
||||
dependencies,
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user