4778 multi select field front implement multi select type (#4887)

This commit is contained in:
martmull
2024-04-11 12:57:08 +02:00
committed by GitHub
parent aecf8783a0
commit a7fcc5d47e
42 changed files with 698 additions and 254 deletions

View File

@ -15,18 +15,20 @@ export const getRecordFromRecordNode = <T extends ObjectRecord>({
return [fieldName, value];
}
if (typeof value === 'object' && isDefined(value.edges)) {
return [
fieldName,
getRecordsFromRecordConnection({ recordConnection: value }),
];
if (Array.isArray(value)) {
return [fieldName, value];
}
if (typeof value === 'object' && !isDefined(value.edges)) {
return [fieldName, getRecordFromRecordNode<T>({ recordNode: value })];
if (typeof value !== 'object') {
return [fieldName, value];
}
return [fieldName, value];
return isDefined(value.edges)
? [
fieldName,
getRecordsFromRecordConnection({ recordConnection: value }),
]
: [fieldName, getRecordFromRecordNode<T>({ recordNode: value })];
}),
),
id: recordNode.id,

View File

@ -6,7 +6,10 @@ import { getNodeTypename } from '@/object-record/cache/utils/getNodeTypename';
import { getObjectTypename } from '@/object-record/cache/utils/getObjectTypename';
import { getRecordConnectionFromRecords } from '@/object-record/cache/utils/getRecordConnectionFromRecords';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import {
FieldMetadataType,
RelationDefinitionType,
} from '~/generated-metadata/graphql';
import { isDefined } from '~/utils/isDefined';
import { lowerAndCapitalize } from '~/utils/string/lowerAndCapitalize';
@ -65,12 +68,16 @@ export const getRecordNodeFromRecord = <T extends ObjectRecord>({
return undefined;
}
if (Array.isArray(value)) {
const objectMetadataItem = objectMetadataItems.find(
(objectMetadataItem) => objectMetadataItem.namePlural === fieldName,
if (
field.type === FieldMetadataType.Relation &&
field.relationDefinition?.direction ===
RelationDefinitionType.OneToMany
) {
const oneToManyObjectMetadataItem = objectMetadataItems.find(
(item) => item.namePlural === fieldName,
);
if (!objectMetadataItem) {
if (!oneToManyObjectMetadataItem) {
return undefined;
}
@ -78,7 +85,7 @@ export const getRecordNodeFromRecord = <T extends ObjectRecord>({
fieldName,
getRecordConnectionFromRecords({
objectMetadataItems,
objectMetadataItem: objectMetadataItem,
objectMetadataItem: oneToManyObjectMetadataItem,
records: value as ObjectRecord[],
queryFields:
queryFields?.[fieldName] === true ||

View File

@ -9,4 +9,5 @@ export type FilterType =
| 'LINK'
| 'RELATION'
| 'ADDRESS'
| 'SELECT';
| 'SELECT'
| 'MULTI_SELECT';

View File

@ -1,8 +1,5 @@
import { useContext } from 'react';
import { JsonFieldDisplay } from '@/object-record/record-field/meta-types/display/components/JsonFieldDisplay';
import { isFieldRawJson } from '@/object-record/record-field/types/guards/isFieldRawJson';
import { FieldContext } from '../contexts/FieldContext';
import { AddressFieldDisplay } from '../meta-types/display/components/AddressFieldDisplay';
import { ChipFieldDisplay } from '../meta-types/display/components/ChipFieldDisplay';
@ -10,7 +7,9 @@ import { CurrencyFieldDisplay } from '../meta-types/display/components/CurrencyF
import { DateFieldDisplay } from '../meta-types/display/components/DateFieldDisplay';
import { EmailFieldDisplay } from '../meta-types/display/components/EmailFieldDisplay';
import { FullNameFieldDisplay } from '../meta-types/display/components/FullNameFieldDisplay';
import { JsonFieldDisplay } from '../meta-types/display/components/JsonFieldDisplay';
import { LinkFieldDisplay } from '../meta-types/display/components/LinkFieldDisplay';
import { MultiSelectFieldDisplay } from '../meta-types/display/components/MultiSelectFieldDisplay.tsx';
import { NumberFieldDisplay } from '../meta-types/display/components/NumberFieldDisplay';
import { PhoneFieldDisplay } from '../meta-types/display/components/PhoneFieldDisplay';
import { RelationFieldDisplay } from '../meta-types/display/components/RelationFieldDisplay';
@ -23,8 +22,10 @@ import { isFieldDateTime } from '../types/guards/isFieldDateTime';
import { isFieldEmail } from '../types/guards/isFieldEmail';
import { isFieldFullName } from '../types/guards/isFieldFullName';
import { isFieldLink } from '../types/guards/isFieldLink';
import { isFieldMultiSelect } from '../types/guards/isFieldMultiSelect.ts';
import { isFieldNumber } from '../types/guards/isFieldNumber';
import { isFieldPhone } from '../types/guards/isFieldPhone';
import { isFieldRawJson } from '../types/guards/isFieldRawJson';
import { isFieldRelation } from '../types/guards/isFieldRelation';
import { isFieldSelect } from '../types/guards/isFieldSelect';
import { isFieldText } from '../types/guards/isFieldText';
@ -60,6 +61,8 @@ export const FieldDisplay = () => {
<PhoneFieldDisplay />
) : isFieldSelect(fieldDefinition) ? (
<SelectFieldDisplay />
) : isFieldMultiSelect(fieldDefinition) ? (
<MultiSelectFieldDisplay />
) : isFieldAddress(fieldDefinition) ? (
<AddressFieldDisplay />
) : isFieldRawJson(fieldDefinition) ? (

View File

@ -2,10 +2,12 @@ import { useContext } from 'react';
import { AddressFieldInput } from '@/object-record/record-field/meta-types/input/components/AddressFieldInput';
import { FullNameFieldInput } from '@/object-record/record-field/meta-types/input/components/FullNameFieldInput';
import { MultiSelectFieldInput } from '@/object-record/record-field/meta-types/input/components/MultiSelectFieldInput.tsx';
import { RawJsonFieldInput } from '@/object-record/record-field/meta-types/input/components/RawJsonFieldInput';
import { SelectFieldInput } from '@/object-record/record-field/meta-types/input/components/SelectFieldInput';
import { RecordFieldInputScope } from '@/object-record/record-field/scopes/RecordFieldInputScope';
import { isFieldFullName } from '@/object-record/record-field/types/guards/isFieldFullName';
import { isFieldMultiSelect } from '@/object-record/record-field/types/guards/isFieldMultiSelect.ts';
import { isFieldRawJson } from '@/object-record/record-field/types/guards/isFieldRawJson';
import { isFieldSelect } from '@/object-record/record-field/types/guards/isFieldSelect';
import { getScopeIdFromComponentId } from '@/ui/utilities/recoil-scope/utils/getScopeIdFromComponentId';
@ -131,6 +133,8 @@ export const FieldInput = ({
<RatingFieldInput onSubmit={onSubmit} />
) : isFieldSelect(fieldDefinition) ? (
<SelectFieldInput onSubmit={onSubmit} onCancel={onCancel} />
) : isFieldMultiSelect(fieldDefinition) ? (
<MultiSelectFieldInput onCancel={onCancel} />
) : isFieldAddress(fieldDefinition) ? (
<AddressFieldInput
onEnter={onEnter}

View File

@ -5,6 +5,8 @@ import { isFieldAddress } from '@/object-record/record-field/types/guards/isFiel
import { isFieldAddressValue } from '@/object-record/record-field/types/guards/isFieldAddressValue';
import { isFieldFullName } from '@/object-record/record-field/types/guards/isFieldFullName';
import { isFieldFullNameValue } from '@/object-record/record-field/types/guards/isFieldFullNameValue';
import { isFieldMultiSelect } from '@/object-record/record-field/types/guards/isFieldMultiSelect.ts';
import { isFieldMultiSelectValue } from '@/object-record/record-field/types/guards/isFieldMultiSelectValue.ts';
import { isFieldRawJson } from '@/object-record/record-field/types/guards/isFieldRawJson';
import { isFieldRawJsonValue } from '@/object-record/record-field/types/guards/isFieldRawJsonValue';
import { isFieldSelect } from '@/object-record/record-field/types/guards/isFieldSelect';
@ -86,6 +88,10 @@ export const usePersistField = () => {
const fieldIsSelect =
isFieldSelect(fieldDefinition) && isFieldSelectValue(valueToPersist);
const fieldIsMultiSelect =
isFieldMultiSelect(fieldDefinition) &&
isFieldMultiSelectValue(valueToPersist);
const fieldIsAddress =
isFieldAddress(fieldDefinition) &&
isFieldAddressValue(valueToPersist);
@ -94,7 +100,7 @@ export const usePersistField = () => {
isFieldRawJson(fieldDefinition) &&
isFieldRawJsonValue(valueToPersist);
if (
const isValuePersistable =
fieldIsRelation ||
fieldIsText ||
fieldIsBoolean ||
@ -107,9 +113,11 @@ export const usePersistField = () => {
fieldIsCurrency ||
fieldIsFullName ||
fieldIsSelect ||
fieldIsMultiSelect ||
fieldIsAddress ||
fieldIsRawJson
) {
fieldIsRawJson;
if (isValuePersistable === true) {
const fieldName = fieldDefinition.metadata.fieldName;
set(
recordStoreFamilySelector({ recordId: entityId, fieldName }),

View File

@ -0,0 +1,32 @@
import styled from '@emotion/styled';
import { useMultiSelectField } from '@/object-record/record-field/meta-types/hooks/useMultiSelectField.ts';
import { Tag } from '@/ui/display/tag/components/Tag';
const StyledTagContainer = styled.div`
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
`;
export const MultiSelectFieldDisplay = () => {
const { fieldValues, fieldDefinition } = useMultiSelectField();
const selectedOptions = fieldValues
? fieldDefinition.metadata.options.filter((option) =>
fieldValues.includes(option.value),
)
: [];
return selectedOptions ? (
<StyledTagContainer>
{selectedOptions.map((selectedOption, index) => (
<Tag
key={index}
color={selectedOption.color}
text={selectedOption.label}
/>
))}
</StyledTagContainer>
) : (
<></>
);
};

View File

@ -0,0 +1,50 @@
import { useContext } from 'react';
import { useRecoilState, useRecoilValue } from 'recoil';
import { FieldContext } from '@/object-record/record-field/contexts/FieldContext.ts';
import { usePersistField } from '@/object-record/record-field/hooks/usePersistField.ts';
import { useRecordFieldInput } from '@/object-record/record-field/hooks/useRecordFieldInput.ts';
import { FieldMultiSelectValue } from '@/object-record/record-field/types/FieldMetadata.ts';
import { assertFieldMetadata } from '@/object-record/record-field/types/guards/assertFieldMetadata.ts';
import { isFieldMultiSelect } from '@/object-record/record-field/types/guards/isFieldMultiSelect.ts';
import { isFieldMultiSelectValue } from '@/object-record/record-field/types/guards/isFieldMultiSelectValue.ts';
import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector.ts';
import { FieldMetadataType } from '~/generated/graphql.tsx';
export const useMultiSelectField = () => {
const { entityId, fieldDefinition, hotkeyScope } = useContext(FieldContext);
assertFieldMetadata(
FieldMetadataType.MultiSelect,
isFieldMultiSelect,
fieldDefinition,
);
const { fieldName } = fieldDefinition.metadata;
const [fieldValues, setFieldValue] = useRecoilState<FieldMultiSelectValue>(
recordStoreFamilySelector({
recordId: entityId,
fieldName: fieldName,
}),
);
const fieldMultiSelectValues = isFieldMultiSelectValue(fieldValues)
? fieldValues
: null;
const persistField = usePersistField();
const { setDraftValue, getDraftValueSelector } =
useRecordFieldInput<FieldMultiSelectValue>(`${entityId}-${fieldName}`);
const draftValue = useRecoilValue(getDraftValueSelector());
return {
fieldDefinition,
persistField,
fieldValues: fieldMultiSelectValues,
draftValue,
setDraftValue,
setFieldValue,
hotkeyScope,
};
};

View File

@ -0,0 +1,93 @@
import { useRef, useState } from 'react';
import styled from '@emotion/styled';
import { useMultiSelectField } from '@/object-record/record-field/meta-types/hooks/useMultiSelectField.ts';
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 { MenuItemMultiSelectTag } from '@/ui/navigation/menu-item/components/MenuItemMultiSelectTag.tsx';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { isDefined } from '~/utils/isDefined';
const StyledRelationPickerContainer = styled.div`
left: -1px;
position: absolute;
top: -1px;
`;
export type MultiSelectFieldInputProps = {
onSubmit?: FieldInputEvent;
onCancel?: () => void;
};
export const MultiSelectFieldInput = ({
onCancel,
}: MultiSelectFieldInputProps) => {
const { persistField, fieldDefinition, fieldValues } = useMultiSelectField();
const [searchFilter, setSearchFilter] = useState('');
const containerRef = useRef<HTMLDivElement>(null);
const selectedOptions = fieldDefinition.metadata.options.filter(
(option) => fieldValues?.includes(option.value),
);
const optionsInDropDown = fieldDefinition.metadata.options;
const formatNewSelectedOptions = (value: string) => {
const selectedOptionsValues = selectedOptions.map(
(selectedOption) => selectedOption.value,
);
if (!selectedOptionsValues.includes(value)) {
return [value, ...selectedOptionsValues];
} else {
return selectedOptionsValues.filter(
(selectedOptionsValue) => selectedOptionsValue !== value,
);
}
};
useListenClickOutside({
refs: [containerRef],
callback: (event) => {
event.stopImmediatePropagation();
const weAreNotInAnHTMLInput = !(
event.target instanceof HTMLInputElement &&
event.target.tagName === 'INPUT'
);
if (weAreNotInAnHTMLInput && isDefined(onCancel)) {
onCancel();
}
},
});
return (
<StyledRelationPickerContainer ref={containerRef}>
<DropdownMenu data-select-disable>
<DropdownMenuSearchInput
value={searchFilter}
onChange={(event) => setSearchFilter(event.currentTarget.value)}
autoFocus
/>
<DropdownMenuSeparator />
<DropdownMenuItemsContainer hasMaxHeight>
{optionsInDropDown.map((option) => {
return (
<MenuItemMultiSelectTag
key={option.value}
selected={fieldValues?.includes(option.value) || false}
text={option.label}
color={option.color}
onClick={() =>
persistField(formatNewSelectedOptions(option.value))
}
/>
);
})}
</DropdownMenuItemsContainer>
</DropdownMenu>
</StyledRelationPickerContainer>
);
};

View File

@ -7,6 +7,7 @@ import {
FieldEmailValue,
FieldFullNameValue,
FieldLinkValue,
FieldMultiSelectValue,
FieldNumberValue,
FieldPhoneValue,
FieldRatingValue,
@ -22,6 +23,7 @@ export type FieldDateTimeDraftValue = string;
export type FieldPhoneDraftValue = string;
export type FieldEmailDraftValue = string;
export type FieldSelectDraftValue = string;
export type FieldMultiSelectDraftValue = string[];
export type FieldRelationDraftValue = string;
export type FieldLinkDraftValue = { url: string; label: string };
export type FieldCurrencyDraftValue = {
@ -64,8 +66,10 @@ export type FieldInputDraftValue<FieldValue> = FieldValue extends FieldTextValue
? FieldRatingValue
: FieldValue extends FieldSelectValue
? FieldSelectDraftValue
: FieldValue extends FieldRelationValue
? FieldRelationDraftValue
: FieldValue extends FieldAddressValue
? FieldAddressDraftValue
: never;
: FieldValue extends FieldMultiSelectValue
? FieldMultiSelectDraftValue
: FieldValue extends FieldRelationValue
? FieldRelationDraftValue
: FieldValue extends FieldAddressValue
? FieldAddressDraftValue
: never;

View File

@ -103,6 +103,12 @@ export type FieldSelectMetadata = {
options: { label: string; color: ThemeColor; value: string }[];
};
export type FieldMultiSelectMetadata = {
objectMetadataNameSingular?: string;
fieldName: string;
options: { label: string; color: ThemeColor; value: string }[];
};
export type FieldMetadata =
| FieldBooleanMetadata
| FieldCurrencyMetadata
@ -115,6 +121,7 @@ export type FieldMetadata =
| FieldRatingMetadata
| FieldRelationMetadata
| FieldSelectMetadata
| FieldMultiSelectMetadata
| FieldTextMetadata
| FieldUuidMetadata
| FieldAddressMetadata;
@ -145,6 +152,7 @@ export type FieldAddressValue = {
};
export type FieldRatingValue = (typeof RATING_VALUES)[number];
export type FieldSelectValue = string | null;
export type FieldMultiSelectValue = string[] | null;
export type FieldRelationValue = EntityForSelect | null;
export type FieldJsonValue = string;

View File

@ -10,6 +10,7 @@ import {
FieldFullNameMetadata,
FieldLinkMetadata,
FieldMetadata,
FieldMultiSelectMetadata,
FieldNumberMetadata,
FieldPhoneMetadata,
FieldRatingMetadata,
@ -34,27 +35,29 @@ type AssertFieldMetadataFunction = <
? FieldEmailMetadata
: E extends 'SELECT'
? FieldSelectMetadata
: E extends 'RATING'
? FieldRatingMetadata
: E extends 'LINK'
? FieldLinkMetadata
: E extends 'NUMBER'
? FieldNumberMetadata
: E extends 'PHONE'
? FieldPhoneMetadata
: E extends 'PROBABILITY'
? FieldRatingMetadata
: E extends 'RELATION'
? FieldRelationMetadata
: E extends 'TEXT'
? FieldTextMetadata
: E extends 'UUID'
? FieldUuidMetadata
: E extends 'ADDRESS'
? FieldAddressMetadata
: E extends 'RAW_JSON'
? FieldRawJsonMetadata
: never,
: E extends 'MULTI_SELECT'
? FieldMultiSelectMetadata
: E extends 'RATING'
? FieldRatingMetadata
: E extends 'LINK'
? FieldLinkMetadata
: E extends 'NUMBER'
? FieldNumberMetadata
: E extends 'PHONE'
? FieldPhoneMetadata
: E extends 'PROBABILITY'
? FieldRatingMetadata
: E extends 'RELATION'
? FieldRelationMetadata
: E extends 'TEXT'
? FieldTextMetadata
: E extends 'UUID'
? FieldUuidMetadata
: E extends 'ADDRESS'
? FieldAddressMetadata
: E extends 'RAW_JSON'
? FieldRawJsonMetadata
: never,
>(
fieldType: E,
fieldTypeGuard: (

View File

@ -0,0 +1,11 @@
import { FieldDefinition } from '@/object-record/record-field/types/FieldDefinition.ts';
import {
FieldMetadata,
FieldMultiSelectMetadata,
} from '@/object-record/record-field/types/FieldMetadata.ts';
import { FieldMetadataType } from '~/generated-metadata/graphql.ts';
export const isFieldMultiSelect = (
field: Pick<FieldDefinition<FieldMetadata>, 'type'>,
): field is FieldDefinition<FieldMultiSelectMetadata> =>
field.type === FieldMetadataType.MultiSelect;

View File

@ -0,0 +1,9 @@
import { FieldMultiSelectValue } from '@/object-record/record-field/types/FieldMetadata.ts';
import { multiSelectFieldValueSchema } from '@/object-record/record-field/validation-schemas/multiSelectFieldValueSchema.ts';
export const isFieldMultiSelectValue = (
fieldValue: unknown,
options?: string[],
): fieldValue is FieldMultiSelectValue => {
return multiSelectFieldValueSchema(options).safeParse(fieldValue).success;
};

View File

@ -11,6 +11,8 @@ import { isFieldFullName } from '@/object-record/record-field/types/guards/isFie
import { isFieldFullNameValue } from '@/object-record/record-field/types/guards/isFieldFullNameValue';
import { isFieldLink } from '@/object-record/record-field/types/guards/isFieldLink';
import { isFieldLinkValue } from '@/object-record/record-field/types/guards/isFieldLinkValue';
import { isFieldMultiSelect } from '@/object-record/record-field/types/guards/isFieldMultiSelect.ts';
import { isFieldMultiSelectValue } from '@/object-record/record-field/types/guards/isFieldMultiSelectValue.ts';
import { isFieldNumber } from '@/object-record/record-field/types/guards/isFieldNumber';
import { isFieldRating } from '@/object-record/record-field/types/guards/isFieldRating';
import { isFieldRawJson } from '@/object-record/record-field/types/guards/isFieldRawJson';
@ -54,6 +56,13 @@ export const isFieldValueEmpty = ({
);
}
if (isFieldMultiSelect(fieldDefinition)) {
return (
!isFieldMultiSelectValue(fieldValue, selectOptionValues) ||
!isDefined(fieldValue)
);
}
if (isFieldCurrency(fieldDefinition)) {
return (
!isFieldCurrencyValue(fieldValue) ||

View File

@ -0,0 +1,10 @@
import { z } from 'zod';
import { FieldMultiSelectValue } from '@/object-record/record-field/types/FieldMetadata';
export const multiSelectFieldValueSchema = (
options?: string[],
): z.ZodType<FieldMultiSelectValue> =>
options?.length
? z.array(z.enum(options as [string, ...string[]])).nullable()
: z.array(z.string()).nullable();

View File

@ -143,6 +143,7 @@ export const isRecordMatchingFilter = ({
case FieldMetadataType.Email:
case FieldMetadataType.Phone:
case FieldMetadataType.Select:
case FieldMetadataType.MultiSelect:
case FieldMetadataType.Text: {
return isMatchingStringFilter({
stringFilter: filterValue as StringFilter,

View File

@ -73,7 +73,7 @@ export const generateEmptyFieldValue = (
return null;
}
case FieldMetadataType.MultiSelect: {
throw new Error('Not implemented yet');
return null;
}
case FieldMetadataType.RawJson: {
return null;