Select Field Input Menu scrollable and add Select Field in Filter and Sort (#3656)

* - fix Select Option Menu scrollable and added search

- add select field in filter and sort operation

* Fix lint

* Fix post merge

* Fix select filter

* Fix

* Remove duplicated search input

* fix turn object into query

* Rename search inputs

* Remove debounced for options

* Simplify option filter

* Rename option to MenuItemSelectTag

* Fix test

* Infer type from field metadata item

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
Co-authored-by: Thomas Trompette <thomast@twenty.com>
This commit is contained in:
Arshil Vahora
2024-03-05 22:11:41 +05:30
committed by GitHub
parent 0b889ef089
commit 6bb7042a68
22 changed files with 367 additions and 27 deletions

View File

@ -1,6 +1,14 @@
import { ThemeColor } from '@/ui/theme/constants/MainColorNames';
import { Field, Relation } from '~/generated-metadata/graphql';
export type FieldMetadataItemOption = {
color: ThemeColor;
id: string;
label: string;
position: number;
value: string;
};
export type FieldMetadataItem = Omit<
Field,
| '__typename'
@ -27,11 +35,5 @@ export type FieldMetadataItem = Omit<
})
| null;
defaultValue?: any;
options?: {
color: ThemeColor;
id: string;
label: string;
position: number;
value: string;
}[];
options?: FieldMetadataItemOption[];
};

View File

@ -18,6 +18,7 @@ export const formatFieldMetadataItemsAsFilterDefinitions = ({
FieldMetadataType.Link,
FieldMetadataType.FullName,
FieldMetadataType.Relation,
FieldMetadataType.Select,
FieldMetadataType.Currency,
].includes(field.type)
) {
@ -67,5 +68,7 @@ export const formatFieldMetadataItemAsFilterDefinition = ({
? 'TEXT'
: field.type === FieldMetadataType.Relation
? 'RELATION'
: 'TEXT',
: field.type === FieldMetadataType.Select
? 'SELECT'
: 'TEXT',
});

View File

@ -15,6 +15,7 @@ export const formatFieldMetadataItemsAsSortDefinitions = ({
FieldMetadataType.Number,
FieldMetadataType.Text,
FieldMetadataType.Boolean,
FieldMetadataType.Select,
].includes(field.type)
) {
return acc;

View File

@ -1,13 +1,14 @@
import { ObjectFilterDropdownRecordSearchInput } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownEntitySearchInput';
import { ObjectFilterDropdownSearchInput } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownSearchInput';
import { useFilterDropdown } from '@/object-record/object-filter-dropdown/hooks/useFilterDropdown';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { MultipleFiltersDropdownFilterOnFilterChangedEffect } from './MultipleFiltersDropdownFilterOnFilterChangedEffect';
import { ObjectFilterDropdownDateSearchInput } from './ObjectFilterDropdownDateSearchInput';
import { ObjectFilterDropdownDateInput } from './ObjectFilterDropdownDateInput';
import { ObjectFilterDropdownFilterSelect } from './ObjectFilterDropdownFilterSelect';
import { ObjectFilterDropdownNumberSearchInput } from './ObjectFilterDropdownNumberSearchInput';
import { ObjectFilterDropdownNumberInput } from './ObjectFilterDropdownNumberInput';
import { ObjectFilterDropdownOperandButton } from './ObjectFilterDropdownOperandButton';
import { ObjectFilterDropdownOperandSelect } from './ObjectFilterDropdownOperandSelect';
import { ObjectFilterDropdownOptionSelect } from './ObjectFilterDropdownOptionSelect';
import { ObjectFilterDropdownRecordSelect } from './ObjectFilterDropdownRecordSelect';
import { ObjectFilterDropdownTextSearchInput } from './ObjectFilterDropdownTextSearchInput';
@ -40,17 +41,24 @@ export const MultipleFiltersDropdownContent = ({
) && <ObjectFilterDropdownTextSearchInput />}
{['NUMBER', 'CURRENCY'].includes(
filterDefinitionUsedInDropdown.type,
) && <ObjectFilterDropdownNumberSearchInput />}
) && <ObjectFilterDropdownNumberInput />}
{filterDefinitionUsedInDropdown.type === 'DATE_TIME' && (
<ObjectFilterDropdownDateSearchInput />
<ObjectFilterDropdownDateInput />
)}
{filterDefinitionUsedInDropdown.type === 'RELATION' && (
<>
<ObjectFilterDropdownRecordSearchInput />
<ObjectFilterDropdownSearchInput />
<DropdownMenuSeparator />
<ObjectFilterDropdownRecordSelect />
</>
)}
{filterDefinitionUsedInDropdown.type === 'SELECT' && (
<>
<ObjectFilterDropdownSearchInput />
<DropdownMenuSeparator />
<ObjectFilterDropdownOptionSelect />
</>
)}
</>
)
)}

View File

@ -2,7 +2,7 @@ import { useFilterDropdown } from '@/object-record/object-filter-dropdown/hooks/
import { InternalDatePicker } from '@/ui/input/components/internal/date/components/InternalDatePicker';
import { isNonNullable } from '~/utils/isNonNullable';
export const ObjectFilterDropdownDateSearchInput = () => {
export const ObjectFilterDropdownDateInput = () => {
const {
filterDefinitionUsedInDropdown,
selectedOperandInDropdown,

View File

@ -1,9 +1,9 @@
import { ChangeEvent } from 'react';
import { useFilterDropdown } from '@/object-record/object-filter-dropdown/hooks/useFilterDropdown';
import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput';
import { DropdownMenuInput } from '@/ui/layout/dropdown/components/DropdownMenuInput';
export const ObjectFilterDropdownNumberSearchInput = () => {
export const ObjectFilterDropdownNumberInput = () => {
const {
selectedOperandInDropdown,
filterDefinitionUsedInDropdown,
@ -13,7 +13,7 @@ export const ObjectFilterDropdownNumberSearchInput = () => {
return (
filterDefinitionUsedInDropdown &&
selectedOperandInDropdown && (
<DropdownMenuSearchInput
<DropdownMenuInput
autoFocus
type="number"
placeholder={filterDefinitionUsedInDropdown.label}

View File

@ -0,0 +1,111 @@
import { useEffect, useState } from 'react';
import { MenuItem, MenuItemMultiSelect } from 'tsup.ui.index';
import { FieldMetadataItemOption } from '@/object-metadata/types/FieldMetadataItem';
import { useFilterDropdown } from '@/object-record/object-filter-dropdown/hooks/useFilterDropdown';
import { useOptionsForSelect } from '@/object-record/object-filter-dropdown/hooks/useOptionsForSelect';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
export const EMPTY_FILTER_VALUE = '';
export const MAX_OPTIONS_TO_DISPLAY = 3;
type SelectOptionForFilter = FieldMetadataItemOption & {
isSelected: boolean;
};
export const ObjectFilterDropdownOptionSelect = () => {
const {
filterDefinitionUsedInDropdown,
objectFilterDropdownSearchInput,
selectedOperandInDropdown,
objectFilterDropdownSelectedOptionValues,
selectFilter,
} = useFilterDropdown();
const fieldMetaDataId = filterDefinitionUsedInDropdown?.fieldMetadataId ?? '';
const { selectOptions } = useOptionsForSelect(fieldMetaDataId);
const [selectableOptions, setSelectableOptions] = useState<
SelectOptionForFilter[]
>([]);
useEffect(() => {
if (selectOptions) {
const options = selectOptions.map((option) => {
const isSelected =
objectFilterDropdownSelectedOptionValues?.includes(option.value) ??
false;
return {
...option,
isSelected,
};
});
setSelectableOptions(options);
}
}, [objectFilterDropdownSelectedOptionValues, selectOptions]);
const handleMultipleOptionSelectChange = (
optionChanged: SelectOptionForFilter,
isSelected: boolean,
) => {
if (!selectOptions) {
return;
}
const newSelectableOptions = selectableOptions.map((option) =>
option.id === optionChanged.id ? { ...option, isSelected } : option,
);
setSelectableOptions(newSelectableOptions);
const selectedOptions = newSelectableOptions.filter(
(option) => option.isSelected,
);
const filterDisplayValue =
selectedOptions.length > MAX_OPTIONS_TO_DISPLAY
? `${selectedOptions.length} options`
: selectedOptions.map((option) => option.label).join(', ');
if (filterDefinitionUsedInDropdown && selectedOperandInDropdown) {
const newFilterValue =
selectedOptions.length > 0
? JSON.stringify(selectedOptions.map((option) => option.value))
: EMPTY_FILTER_VALUE;
selectFilter({
definition: filterDefinitionUsedInDropdown,
operand: selectedOperandInDropdown,
displayValue: filterDisplayValue,
fieldMetadataId: filterDefinitionUsedInDropdown.fieldMetadataId,
value: newFilterValue,
});
}
};
const optionsInDropdown = selectableOptions?.filter((option) =>
option.label.toLowerCase().includes(objectFilterDropdownSearchInput),
);
const showNoResult = optionsInDropdown?.length === 0;
return (
<DropdownMenuItemsContainer hasMaxHeight>
{optionsInDropdown?.map((option) => (
<MenuItemMultiSelect
key={option.id}
selected={option.isSelected}
onSelectChange={(selected) =>
handleMultipleOptionSelectChange(option, selected)
}
text={option.label}
className=""
/>
))}
{showNoResult && <MenuItem text="No result" />}
</DropdownMenuItemsContainer>
);
};

View File

@ -3,7 +3,7 @@ import { ChangeEvent } from 'react';
import { useFilterDropdown } from '@/object-record/object-filter-dropdown/hooks/useFilterDropdown';
import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput';
export const ObjectFilterDropdownRecordSearchInput = () => {
export const ObjectFilterDropdownSearchInput = () => {
const {
filterDefinitionUsedInDropdown,
selectedOperandInDropdown,

View File

@ -13,8 +13,8 @@ import { ViewFilterOperand } from '@/views/types/ViewFilterOperand';
import { getOperandsForFilterType } from '../utils/getOperandsForFilterType';
import { GenericEntityFilterChip } from './GenericEntityFilterChip';
import { ObjectFilterDropdownRecordSearchInput } from './ObjectFilterDropdownEntitySearchInput';
import { ObjectFilterDropdownRecordSelect } from './ObjectFilterDropdownRecordSelect';
import { ObjectFilterDropdownSearchInput } from './ObjectFilterDropdownSearchInput';
export const SingleEntityObjectFilterDropdownButton = ({
hotkeyScope,
@ -66,7 +66,7 @@ export const SingleEntityObjectFilterDropdownButton = ({
}
dropdownComponents={
<>
<ObjectFilterDropdownRecordSearchInput />
<ObjectFilterDropdownSearchInput />
<DropdownMenuSeparator />
<ObjectFilterDropdownRecordRemoveFilterMenuItem />
<ObjectFilterDropdownRecordSelect />

View File

@ -27,6 +27,8 @@ export const useFilterDropdown = (props?: UseFilterDropdownProps) => {
setObjectFilterDropdownSelectedEntityId,
objectFilterDropdownSelectedRecordIds,
setObjectFilterDropdownSelectedRecordIds,
objectFilterDropdownSelectedOptionValues,
setObjectFilterDropdownSelectedOptionValues,
isObjectFilterDropdownOperandSelectUnfolded,
setIsObjectFilterDropdownOperandSelectUnfolded,
isObjectFilterDropdownUnfolded,
@ -87,6 +89,8 @@ export const useFilterDropdown = (props?: UseFilterDropdownProps) => {
setObjectFilterDropdownSelectedEntityId,
objectFilterDropdownSelectedRecordIds,
setObjectFilterDropdownSelectedRecordIds,
objectFilterDropdownSelectedOptionValues,
setObjectFilterDropdownSelectedOptionValues,
isObjectFilterDropdownOperandSelectUnfolded,
setIsObjectFilterDropdownOperandSelectUnfolded,
isObjectFilterDropdownUnfolded,

View File

@ -8,6 +8,7 @@ import { isObjectFilterDropdownOperandSelectUnfoldedScopedState } from '../state
import { isObjectFilterDropdownUnfoldedScopedState } from '../states/isObjectFilterDropdownUnfoldedScopedState';
import { objectFilterDropdownSearchInputScopedState } from '../states/objectFilterDropdownSearchInputScopedState';
import { objectFilterDropdownSelectedEntityIdScopedState } from '../states/objectFilterDropdownSelectedEntityIdScopedState';
import { objectFilterDropdownSelectedOptionValuesScopedState } from '../states/objectFilterDropdownSelectedOptionValuesScopedState';
import { selectedFilterScopedState } from '../states/selectedFilterScopedState';
import { selectedOperandInDropdownScopedState } from '../states/selectedOperandInDropdownScopedState';
@ -37,6 +38,14 @@ export const useFilterDropdownStates = (scopeId: string) => {
scopeId,
);
const [
objectFilterDropdownSelectedOptionValues,
setObjectFilterDropdownSelectedOptionValues,
] = useRecoilScopedStateV2(
objectFilterDropdownSelectedOptionValuesScopedState,
scopeId,
);
const [
isObjectFilterDropdownOperandSelectUnfolded,
setIsObjectFilterDropdownOperandSelectUnfolded,
@ -71,6 +80,8 @@ export const useFilterDropdownStates = (scopeId: string) => {
objectFilterDropdownSelectedEntityId,
setObjectFilterDropdownSelectedEntityId,
objectFilterDropdownSelectedRecordIds,
objectFilterDropdownSelectedOptionValues,
setObjectFilterDropdownSelectedOptionValues,
setObjectFilterDropdownSelectedRecordIds,
isObjectFilterDropdownOperandSelectUnfolded,
setIsObjectFilterDropdownOperandSelectUnfolded,

View File

@ -0,0 +1,26 @@
import { useParams } from 'react-router-dom';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useObjectNameSingularFromPlural } from '@/object-metadata/hooks/useObjectNameSingularFromPlural';
export const DEFAULT_SEARCH_REQUEST_LIMIT = 60;
export const useOptionsForSelect = (fieldMetadataId: string) => {
const objectNamePlural = useParams().objectNamePlural ?? '';
const { objectNameSingular } = useObjectNameSingularFromPlural({
objectNamePlural,
});
const { objectMetadataItem } = useObjectMetadataItem({ objectNameSingular });
const fieldMetadataItem = objectMetadataItem.fields.find(
(field) => field.id === fieldMetadataId,
);
const selectOptions = fieldMetadataItem?.options;
return {
selectOptions,
};
};

View File

@ -0,0 +1,7 @@
import { createStateScopeMap } from '@/ui/utilities/recoil-scope/utils/createStateScopeMap';
export const objectFilterDropdownSelectedOptionValuesScopedState =
createStateScopeMap<string[]>({
key: 'objectFilterDropdownSelectedOptionValuesScopedState',
defaultValue: [],
});

View File

@ -7,4 +7,5 @@ export type FilterType =
| 'CURRENCY'
| 'FULL_NAME'
| 'LINK'
| 'RELATION';
| 'RELATION'
| 'SELECT';

View File

@ -16,6 +16,7 @@ export const getOperandsForFilterType = (
case 'DATE_TIME':
return [ViewFilterOperand.GreaterThan, ViewFilterOperand.LessThan];
case 'RELATION':
case 'SELECT':
return [ViewFilterOperand.Is, ViewFilterOperand.IsNot];
default:
return [];

View File

@ -1,10 +1,13 @@
import { useState } from 'react';
import styled from '@emotion/styled';
import { MenuItem } from 'tsup.ui.index';
import { useSelectField } from '@/object-record/record-field/meta-types/hooks/useSelectField';
import { FieldInputEvent } from '@/object-record/record-field/types/FieldInputEvent';
import { DropdownMenu } from '@/ui/layout/dropdown/components/DropdownMenu';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { MenuItemSelectTag } from '@/ui/navigation/menu-item/components/MenuItemSelectTag';
const StyledRelationPickerContainer = styled.div`
left: -1px;
@ -17,16 +20,36 @@ export type SelectFieldInputProps = {
};
export const SelectFieldInput = ({ onSubmit }: SelectFieldInputProps) => {
const { persistField, fieldDefinition } = useSelectField();
const { persistField, fieldDefinition, fieldValue } = useSelectField();
const [searchFilter, setSearchFilter] = useState('');
const selectedOption = fieldDefinition.metadata.options.find(
(option) => option.value === fieldValue,
);
const optionsToSelect =
fieldDefinition.metadata.options.filter((option) => {
return option.value !== fieldValue && option.label.includes(searchFilter);
}) || [];
const optionsInDropDown = selectedOption
? [selectedOption, ...optionsToSelect]
: optionsToSelect;
return (
<StyledRelationPickerContainer>
<DropdownMenu data-select-disable>
<DropdownMenuItemsContainer>
{fieldDefinition.metadata.options.map((option) => {
<DropdownMenuSearchInput
value={searchFilter}
onChange={(event) => setSearchFilter(event.currentTarget.value)}
autoFocus
/>
<DropdownMenuSeparator />
<DropdownMenuItemsContainer hasMaxHeight>
{optionsInDropDown.map((option) => {
return (
<MenuItem
<MenuItemSelectTag
selected={option.value === fieldValue}
text={option.label}
color={option.color}
onClick={() => onSubmit?.(() => persistField(option.value))}
/>
);

View File

@ -141,6 +141,7 @@ export const isRecordMatchingFilter = ({
switch (objectMetadataField.type) {
case FieldMetadataType.Email:
case FieldMetadataType.Phone:
case FieldMetadataType.Select:
case FieldMetadataType.Text: {
return isMatchingStringFilter({
stringFilter: filterValue as StringFilter,

View File

@ -1,3 +1,5 @@
import { isNonEmptyString } from '@sniptt/guards';
import {
CurrencyFilter,
DateFilter,
@ -254,6 +256,48 @@ export const turnObjectDropdownFilterIntoQueryFilter = (
);
}
break;
case 'SELECT': {
const stringifiedSelectValues = rawUIFilter.value;
let parsedOptionValues: string[] = [];
if (!isNonEmptyString(stringifiedSelectValues)) {
break;
}
try {
parsedOptionValues = JSON.parse(stringifiedSelectValues);
} catch (e) {
throw new Error(
`Cannot parse filter value for SELECT filter : "${stringifiedSelectValues}"`,
);
}
if (parsedOptionValues.length > 0) {
switch (rawUIFilter.operand) {
case ViewFilterOperand.Is:
objectRecordFilters.push({
[correspondingField.name]: {
in: parsedOptionValues,
} as UUIDFilter,
});
break;
case ViewFilterOperand.IsNot:
objectRecordFilters.push({
not: {
[correspondingField.name]: {
in: parsedOptionValues,
} as UUIDFilter,
},
});
break;
default:
throw new Error(
`Unknown operand ${rawUIFilter.operand} for ${rawUIFilter.definition.type} filter`,
);
}
}
break;
}
default:
throw new Error('Unknown filter type');
}

View File

@ -0,0 +1,40 @@
import { useTheme } from '@emotion/react';
import { Tag } from 'tsup.ui.index';
import { IconCheck } from '@/ui/display/icon';
import { ThemeColor } from '@/ui/theme/constants/MainColorNames';
import { StyledMenuItemLeftContent } from '../internals/components/StyledMenuItemBase';
import { StyledMenuItemSelect } from './MenuItemSelect';
type MenuItemSelectTagProps = {
selected: boolean;
className?: string;
onClick?: () => void;
color: ThemeColor;
text: string;
};
export const MenuItemSelectTag = ({
color,
selected,
className,
onClick,
text,
}: MenuItemSelectTagProps) => {
const theme = useTheme();
return (
<StyledMenuItemSelect
onClick={onClick}
className={className}
selected={selected}
>
<StyledMenuItemLeftContent>
<Tag color={color} text={text} />
</StyledMenuItemLeftContent>
{selected && <IconCheck size={theme.icon.size.sm} />}
</StyledMenuItemSelect>
);
};

View File

@ -26,6 +26,7 @@ export const ViewBarFilterEffect = ({
setOnFilterSelect,
filterDefinitionUsedInDropdown,
setObjectFilterDropdownSelectedRecordIds,
setObjectFilterDropdownSelectedOptionValues,
isObjectFilterDropdownUnfolded,
} = useFilterDropdown({ filterDropdownId });
@ -61,12 +62,29 @@ export const ViewBarFilterEffect = ({
: [];
setObjectFilterDropdownSelectedRecordIds(viewFilterSelectedRecordIds);
} else if (filterDefinitionUsedInDropdown?.type === 'SELECT') {
const viewFilterUsedInDropdown = currentViewFilters.find(
(filter) =>
filter.fieldMetadataId ===
filterDefinitionUsedInDropdown.fieldMetadataId,
);
const viewFilterSelectedOptionValues = isNonEmptyString(
viewFilterUsedInDropdown?.value,
)
? JSON.parse(viewFilterUsedInDropdown.value)
: [];
setObjectFilterDropdownSelectedOptionValues(
viewFilterSelectedOptionValues,
);
}
}, [
filterDefinitionUsedInDropdown,
currentViewFilters,
setObjectFilterDropdownSelectedRecordIds,
isObjectFilterDropdownUnfolded,
setObjectFilterDropdownSelectedOptionValues,
]);
return <></>;

View File

@ -82,5 +82,41 @@ describe('ArgsStringFactory', () => {
'orderBy: [{id: AscNullsFirst}, {name: AscNullsFirst}]',
);
});
it('when orderBy is present with position criteria, should return position at the end of the list', () => {
const args = {
orderBy: {
position: 'AscNullsFirst',
id: 'AscNullsFirst',
name: 'AscNullsFirst',
},
};
argsAliasCreate.mockReturnValue(args);
const result = service.create(args, []);
expect(result).toEqual(
'orderBy: [{id: AscNullsFirst}, {name: AscNullsFirst}, {position: AscNullsFirst}]',
);
});
it('when orderBy is present with position in the middle, should return position at the end of the list', () => {
const args = {
orderBy: {
id: 'AscNullsFirst',
position: 'AscNullsFirst',
name: 'AscNullsFirst',
},
};
argsAliasCreate.mockReturnValue(args);
const result = service.create(args, []);
expect(result).toEqual(
'orderBy: [{id: AscNullsFirst}, {name: AscNullsFirst}, {position: AscNullsFirst}]',
);
});
});
});

View File

@ -62,6 +62,9 @@ export class ArgsStringFactory {
// PgGraphql is expecting the orderBy argument to be an array of objects
if (key === 'orderBy') {
const orderByString = Object.keys(obj)
.sort((_, b) => {
return b === 'position' ? -1 : 0;
})
.map((orderByKey) => `{${orderByKey}: ${obj[orderByKey]}}`)
.join(', ');