fix: rating type issues (#3638)
* fix: rating type issues * fix: rebase --------- Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
@ -28,6 +28,7 @@ export const useMapFieldMetadataToGraphQLQuery = () => {
|
|||||||
'EMAIL',
|
'EMAIL',
|
||||||
'NUMBER',
|
'NUMBER',
|
||||||
'BOOLEAN',
|
'BOOLEAN',
|
||||||
|
'RATING',
|
||||||
'SELECT',
|
'SELECT',
|
||||||
] as FieldType[]
|
] as FieldType[]
|
||||||
).includes(fieldType);
|
).includes(fieldType);
|
||||||
|
|||||||
@ -23,7 +23,7 @@ export const useRatingField = () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const rating = +(fieldValue ?? 0);
|
const rating = fieldValue ?? 'RATING_1';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
fieldDefinition,
|
fieldDefinition,
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { FieldRatingValue } from '@/object-record/record-field/types/FieldMetadata';
|
||||||
import { RatingInput } from '@/ui/field/input/components/RatingInput';
|
import { RatingInput } from '@/ui/field/input/components/RatingInput';
|
||||||
|
|
||||||
import { usePersistField } from '../../../hooks/usePersistField';
|
import { usePersistField } from '../../../hooks/usePersistField';
|
||||||
@ -10,6 +11,14 @@ export type RatingFieldInputProps = {
|
|||||||
readonly?: boolean;
|
readonly?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const RATING_VALUES = [
|
||||||
|
'RATING_1',
|
||||||
|
'RATING_2',
|
||||||
|
'RATING_3',
|
||||||
|
'RATING_4',
|
||||||
|
'RATING_5',
|
||||||
|
] as const;
|
||||||
|
|
||||||
export const RatingFieldInput = ({
|
export const RatingFieldInput = ({
|
||||||
onSubmit,
|
onSubmit,
|
||||||
readonly,
|
readonly,
|
||||||
@ -18,8 +27,8 @@ export const RatingFieldInput = ({
|
|||||||
|
|
||||||
const persistField = usePersistField();
|
const persistField = usePersistField();
|
||||||
|
|
||||||
const handleChange = (newRating: number) => {
|
const handleChange = (newRating: FieldRatingValue) => {
|
||||||
onSubmit?.(() => persistField(`${newRating}`));
|
onSubmit?.(() => persistField(newRating));
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { RATING_VALUES } from '@/object-record/record-field/meta-types/input/components/RatingFieldInput';
|
||||||
import { EntityForSelect } from '@/object-record/relation-picker/types/EntityForSelect';
|
import { EntityForSelect } from '@/object-record/relation-picker/types/EntityForSelect';
|
||||||
import { ThemeColor } from '@/ui/theme/constants/colors';
|
import { ThemeColor } from '@/ui/theme/constants/colors';
|
||||||
|
|
||||||
@ -119,7 +120,7 @@ export type FieldCurrencyValue = {
|
|||||||
amountMicros: number | null;
|
amountMicros: number | null;
|
||||||
};
|
};
|
||||||
export type FieldFullNameValue = { firstName: string; lastName: string };
|
export type FieldFullNameValue = { firstName: string; lastName: string };
|
||||||
export type FieldRatingValue = '1' | '2' | '3' | '4' | '5';
|
export type FieldRatingValue = (typeof RATING_VALUES)[number];
|
||||||
export type FieldSelectValue = string | null;
|
export type FieldSelectValue = string | null;
|
||||||
|
|
||||||
export type FieldRelationValue = EntityForSelect | null;
|
export type FieldRelationValue = EntityForSelect | null;
|
||||||
|
|||||||
@ -1,11 +1,9 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { RATING_VALUES } from '../../meta-types/input/components/RatingFieldInput';
|
||||||
import { FieldRatingValue } from '../FieldMetadata';
|
import { FieldRatingValue } from '../FieldMetadata';
|
||||||
|
|
||||||
const ratingSchema = z
|
const ratingSchema = z.string().pipe(z.enum(RATING_VALUES));
|
||||||
.string()
|
|
||||||
.transform((value) => +value)
|
|
||||||
.pipe(z.number().int().min(1).max(5));
|
|
||||||
|
|
||||||
export const isFieldRatingValue = (
|
export const isFieldRatingValue = (
|
||||||
fieldValue: unknown,
|
fieldValue: unknown,
|
||||||
|
|||||||
@ -2,6 +2,8 @@ import { useState } from 'react';
|
|||||||
import { useTheme } from '@emotion/react';
|
import { useTheme } from '@emotion/react';
|
||||||
import styled from '@emotion/styled';
|
import styled from '@emotion/styled';
|
||||||
|
|
||||||
|
import { RATING_VALUES } from '@/object-record/record-field/meta-types/input/components/RatingFieldInput';
|
||||||
|
import { FieldRatingValue } from '@/object-record/record-field/types/FieldMetadata';
|
||||||
import { IconTwentyStarFilled } from '@/ui/display/icon/components/IconTwentyStarFilled';
|
import { IconTwentyStarFilled } from '@/ui/display/icon/components/IconTwentyStarFilled';
|
||||||
|
|
||||||
const StyledContainer = styled.div`
|
const StyledContainer = styled.div`
|
||||||
@ -16,40 +18,40 @@ const StyledRatingIconContainer = styled.div<{ isActive?: boolean }>`
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
type RatingInputProps = {
|
type RatingInputProps = {
|
||||||
onChange: (newValue: number) => void;
|
onChange: (newValue: FieldRatingValue) => void;
|
||||||
value: number;
|
value: FieldRatingValue;
|
||||||
readonly?: boolean;
|
readonly?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const RATING_LEVELS_NB = 5;
|
|
||||||
|
|
||||||
export const RatingInput = ({
|
export const RatingInput = ({
|
||||||
onChange,
|
onChange,
|
||||||
value,
|
value,
|
||||||
readonly,
|
readonly,
|
||||||
}: RatingInputProps) => {
|
}: RatingInputProps) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const [hoveredValue, setHoveredValue] = useState<number | null>(null);
|
const [hoveredValue, setHoveredValue] = useState<FieldRatingValue | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
const currentValue = hoveredValue ?? value;
|
const currentValue = hoveredValue ?? value;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledContainer
|
<StyledContainer
|
||||||
role="slider"
|
role="slider"
|
||||||
aria-label="Rating"
|
aria-label="Rating"
|
||||||
aria-valuemax={RATING_LEVELS_NB}
|
aria-valuemax={RATING_VALUES.length}
|
||||||
aria-valuemin={1}
|
aria-valuemin={1}
|
||||||
aria-valuenow={value}
|
aria-valuenow={RATING_VALUES.indexOf(currentValue) + 1}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
>
|
>
|
||||||
{Array.from({ length: RATING_LEVELS_NB }, (_, index) => {
|
{RATING_VALUES.map((value, index) => {
|
||||||
const rating = index + 1;
|
const currentIndex = RATING_VALUES.indexOf(currentValue);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledRatingIconContainer
|
<StyledRatingIconContainer
|
||||||
key={index}
|
key={index}
|
||||||
isActive={rating <= currentValue}
|
isActive={index <= currentIndex}
|
||||||
onClick={readonly ? undefined : () => onChange(rating)}
|
onClick={readonly ? undefined : () => onChange(value)}
|
||||||
onMouseEnter={readonly ? undefined : () => setHoveredValue(rating)}
|
onMouseEnter={readonly ? undefined : () => setHoveredValue(value)}
|
||||||
onMouseLeave={readonly ? undefined : () => setHoveredValue(null)}
|
onMouseLeave={readonly ? undefined : () => setHoveredValue(null)}
|
||||||
>
|
>
|
||||||
<IconTwentyStarFilled size={theme.icon.size.md} />
|
<IconTwentyStarFilled size={theme.icon.size.md} />
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
import { IsString, IsNumber, IsOptional, IsNotEmpty } from 'class-validator';
|
import { IsString, IsNumber, IsOptional, IsNotEmpty } from 'class-validator';
|
||||||
|
|
||||||
export class FieldMetadataDefaultOptions {
|
import { IsValidGraphQLEnumName } from 'src/metadata/field-metadata/validators/is-valid-graphql-enum-name.validator';
|
||||||
|
|
||||||
|
export class FieldMetadataDefaultOption {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
id?: string;
|
id?: string;
|
||||||
@ -13,11 +15,11 @@ export class FieldMetadataDefaultOptions {
|
|||||||
label: string;
|
label: string;
|
||||||
|
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@IsString()
|
@IsValidGraphQLEnumName()
|
||||||
value: string;
|
value: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class FieldMetadataComplexOptions extends FieldMetadataDefaultOptions {
|
export class FieldMetadataComplexOption extends FieldMetadataDefaultOption {
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@IsString()
|
@IsString()
|
||||||
color: string;
|
color: string;
|
||||||
|
|||||||
@ -25,7 +25,13 @@ import { UpdateFieldInput } from 'src/metadata/field-metadata/dtos/update-field.
|
|||||||
import { WorkspaceMigrationFactory } from 'src/metadata/workspace-migration/workspace-migration.factory';
|
import { WorkspaceMigrationFactory } from 'src/metadata/workspace-migration/workspace-migration.factory';
|
||||||
import { computeObjectTargetTable } from 'src/workspace/utils/compute-object-target-table.util';
|
import { computeObjectTargetTable } from 'src/workspace/utils/compute-object-target-table.util';
|
||||||
|
|
||||||
import { FieldMetadataEntity } from './field-metadata.entity';
|
import {
|
||||||
|
FieldMetadataEntity,
|
||||||
|
FieldMetadataType,
|
||||||
|
} from './field-metadata.entity';
|
||||||
|
|
||||||
|
import { isEnumFieldMetadataType } from './utils/is-enum-field-metadata-type.util';
|
||||||
|
import { generateRatingOptions } from './utils/generate-rating-optionts.util';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntity> {
|
export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntity> {
|
||||||
@ -60,6 +66,21 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
|
|||||||
throw new NotFoundException('Object does not exist');
|
throw new NotFoundException('Object does not exist');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Double check in case the service is directly called
|
||||||
|
if (isEnumFieldMetadataType(fieldMetadataInput.type)) {
|
||||||
|
if (
|
||||||
|
!fieldMetadataInput.options &&
|
||||||
|
fieldMetadataInput.type !== FieldMetadataType.RATING
|
||||||
|
) {
|
||||||
|
throw new BadRequestException('Options are required for enum fields');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate options for rating fields
|
||||||
|
if (fieldMetadataInput.type === FieldMetadataType.RATING) {
|
||||||
|
fieldMetadataInput.options = generateRatingOptions();
|
||||||
|
}
|
||||||
|
|
||||||
const fieldAlreadyExists = await this.fieldMetadataRepository.findOne({
|
const fieldAlreadyExists = await this.fieldMetadataRepository.findOne({
|
||||||
where: {
|
where: {
|
||||||
name: fieldMetadataInput.name,
|
name: fieldMetadataInput.name,
|
||||||
|
|||||||
@ -1,20 +1,20 @@
|
|||||||
import {
|
import {
|
||||||
FieldMetadataComplexOptions,
|
FieldMetadataComplexOption,
|
||||||
FieldMetadataDefaultOptions,
|
FieldMetadataDefaultOption,
|
||||||
} from 'src/metadata/field-metadata/dtos/options.input';
|
} from 'src/metadata/field-metadata/dtos/options.input';
|
||||||
import { FieldMetadataType } from 'src/metadata/field-metadata/field-metadata.entity';
|
import { FieldMetadataType } from 'src/metadata/field-metadata/field-metadata.entity';
|
||||||
|
|
||||||
type FieldMetadataOptionsMapping = {
|
type FieldMetadataOptionsMapping = {
|
||||||
[FieldMetadataType.RATING]: FieldMetadataDefaultOptions[];
|
[FieldMetadataType.RATING]: FieldMetadataDefaultOption[];
|
||||||
[FieldMetadataType.SELECT]: FieldMetadataComplexOptions[];
|
[FieldMetadataType.SELECT]: FieldMetadataComplexOption[];
|
||||||
[FieldMetadataType.MULTI_SELECT]: FieldMetadataComplexOptions[];
|
[FieldMetadataType.MULTI_SELECT]: FieldMetadataComplexOption[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type OptionsByFieldMetadata<T extends FieldMetadataType | 'default'> =
|
type OptionsByFieldMetadata<T extends FieldMetadataType | 'default'> =
|
||||||
T extends keyof FieldMetadataOptionsMapping
|
T extends keyof FieldMetadataOptionsMapping
|
||||||
? FieldMetadataOptionsMapping[T]
|
? FieldMetadataOptionsMapping[T]
|
||||||
: T extends 'default'
|
: T extends 'default'
|
||||||
? FieldMetadataDefaultOptions[] | FieldMetadataComplexOptions[]
|
? FieldMetadataDefaultOption[] | FieldMetadataComplexOption[]
|
||||||
: never;
|
: never;
|
||||||
|
|
||||||
export type FieldMetadataOptions<
|
export type FieldMetadataOptions<
|
||||||
|
|||||||
@ -0,0 +1,23 @@
|
|||||||
|
import { v4 as uuidV4 } from 'uuid';
|
||||||
|
|
||||||
|
import { FieldMetadataDefaultOption } from 'src/metadata/field-metadata/dtos/options.input';
|
||||||
|
|
||||||
|
const range = {
|
||||||
|
start: 1,
|
||||||
|
end: 5,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function generateRatingOptions(): FieldMetadataDefaultOption[] {
|
||||||
|
const options: FieldMetadataDefaultOption[] = [];
|
||||||
|
|
||||||
|
for (let i = range.start; i <= range.end; i++) {
|
||||||
|
options.push({
|
||||||
|
id: uuidV4(),
|
||||||
|
label: i.toString(),
|
||||||
|
value: `RATING_${i}`,
|
||||||
|
position: i - 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return options;
|
||||||
|
}
|
||||||
@ -5,14 +5,16 @@ import { FieldMetadataOptions } from 'src/metadata/field-metadata/interfaces/fie
|
|||||||
|
|
||||||
import { FieldMetadataType } from 'src/metadata/field-metadata/field-metadata.entity';
|
import { FieldMetadataType } from 'src/metadata/field-metadata/field-metadata.entity';
|
||||||
import {
|
import {
|
||||||
FieldMetadataComplexOptions,
|
FieldMetadataComplexOption,
|
||||||
FieldMetadataDefaultOptions,
|
FieldMetadataDefaultOption,
|
||||||
} from 'src/metadata/field-metadata/dtos/options.input';
|
} from 'src/metadata/field-metadata/dtos/options.input';
|
||||||
|
|
||||||
|
import { isEnumFieldMetadataType } from './is-enum-field-metadata-type.util';
|
||||||
|
|
||||||
export const optionsValidatorsMap = {
|
export const optionsValidatorsMap = {
|
||||||
[FieldMetadataType.RATING]: [FieldMetadataDefaultOptions],
|
// RATING doesn't need to be provided as it's the backend that will generate the options
|
||||||
[FieldMetadataType.SELECT]: [FieldMetadataComplexOptions],
|
[FieldMetadataType.SELECT]: [FieldMetadataComplexOption],
|
||||||
[FieldMetadataType.MULTI_SELECT]: [FieldMetadataComplexOptions],
|
[FieldMetadataType.MULTI_SELECT]: [FieldMetadataComplexOption],
|
||||||
};
|
};
|
||||||
|
|
||||||
export const validateOptionsForType = (
|
export const validateOptionsForType = (
|
||||||
@ -25,6 +27,14 @@ export const validateOptionsForType = (
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!isEnumFieldMetadataType(type)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === FieldMetadataType.RATING) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
const validators = optionsValidatorsMap[type];
|
const validators = optionsValidatorsMap[type];
|
||||||
|
|
||||||
if (!validators) return false;
|
if (!validators) return false;
|
||||||
@ -33,7 +43,7 @@ export const validateOptionsForType = (
|
|||||||
return validators.some((validator) => {
|
return validators.some((validator) => {
|
||||||
const optionsInstance = plainToInstance<
|
const optionsInstance = plainToInstance<
|
||||||
any,
|
any,
|
||||||
FieldMetadataDefaultOptions | FieldMetadataComplexOptions
|
FieldMetadataDefaultOption | FieldMetadataComplexOption
|
||||||
>(validator, option);
|
>(validator, option);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -0,0 +1,26 @@
|
|||||||
|
import {
|
||||||
|
registerDecorator,
|
||||||
|
ValidationOptions,
|
||||||
|
ValidationArguments,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
const graphQLEnumNameRegex = /^[_A-Za-z][_0-9A-Za-z]+$/;
|
||||||
|
|
||||||
|
export function IsValidGraphQLEnumName(validationOptions?: ValidationOptions) {
|
||||||
|
return function (object: object, propertyName: string) {
|
||||||
|
registerDecorator({
|
||||||
|
name: 'isValidGraphQLEnumName',
|
||||||
|
target: object.constructor,
|
||||||
|
propertyName: propertyName,
|
||||||
|
options: validationOptions,
|
||||||
|
validator: {
|
||||||
|
validate(value: any) {
|
||||||
|
return typeof value === 'string' && graphQLEnumNameRegex.test(value);
|
||||||
|
},
|
||||||
|
defaultMessage(args: ValidationArguments) {
|
||||||
|
return `${args.property} must match the ${graphQLEnumNameRegex} format`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -8,8 +8,8 @@ import { FieldMetadataInterface } from 'src/metadata/field-metadata/interfaces/f
|
|||||||
|
|
||||||
import { pascalCase } from 'src/utils/pascal-case';
|
import { pascalCase } from 'src/utils/pascal-case';
|
||||||
import {
|
import {
|
||||||
FieldMetadataComplexOptions,
|
FieldMetadataComplexOption,
|
||||||
FieldMetadataDefaultOptions,
|
FieldMetadataDefaultOption,
|
||||||
} from 'src/metadata/field-metadata/dtos/options.input';
|
} from 'src/metadata/field-metadata/dtos/options.input';
|
||||||
import { isEnumFieldMetadataType } from 'src/metadata/field-metadata/utils/is-enum-field-metadata-type.util';
|
import { isEnumFieldMetadataType } from 'src/metadata/field-metadata/utils/is-enum-field-metadata-type.util';
|
||||||
|
|
||||||
@ -54,7 +54,7 @@ export class EnumTypeDefinitionFactory {
|
|||||||
// FixMe: It's a hack until Typescript get fixed on union types for reduce function
|
// FixMe: It's a hack until Typescript get fixed on union types for reduce function
|
||||||
// https://github.com/microsoft/TypeScript/issues/36390
|
// https://github.com/microsoft/TypeScript/issues/36390
|
||||||
const enumOptions = fieldMetadata.options as Array<
|
const enumOptions = fieldMetadata.options as Array<
|
||||||
FieldMetadataDefaultOptions | FieldMetadataComplexOptions
|
FieldMetadataDefaultOption | FieldMetadataComplexOption
|
||||||
>;
|
>;
|
||||||
|
|
||||||
if (!enumOptions) {
|
if (!enumOptions) {
|
||||||
@ -74,6 +74,7 @@ export class EnumTypeDefinitionFactory {
|
|||||||
description: fieldMetadata.description,
|
description: fieldMetadata.description,
|
||||||
values: enumOptions.reduce(
|
values: enumOptions.reduce(
|
||||||
(acc, enumOption) => {
|
(acc, enumOption) => {
|
||||||
|
// Key must match this regex: /^[_A-Za-z][_0-9A-Za-z]+$/
|
||||||
acc[enumOption.value] = {
|
acc[enumOption.value] = {
|
||||||
value: enumOption.value,
|
value: enumOption.value,
|
||||||
description: enumOption.label,
|
description: enumOption.label,
|
||||||
|
|||||||
@ -39,7 +39,7 @@ import { WorkspaceMigrationRunnerService } from 'src/workspace/workspace-migrati
|
|||||||
import { ReflectiveMetadataFactory } from 'src/workspace/workspace-sync-metadata/reflective-metadata.factory';
|
import { ReflectiveMetadataFactory } from 'src/workspace/workspace-sync-metadata/reflective-metadata.factory';
|
||||||
import { FeatureFlagEntity } from 'src/core/feature-flag/feature-flag.entity';
|
import { FeatureFlagEntity } from 'src/core/feature-flag/feature-flag.entity';
|
||||||
import { computeObjectTargetTable } from 'src/workspace/utils/compute-object-target-table.util';
|
import { computeObjectTargetTable } from 'src/workspace/utils/compute-object-target-table.util';
|
||||||
import { FieldMetadataComplexOptions } from 'src/metadata/field-metadata/dtos/options.input';
|
import { FieldMetadataComplexOption } from 'src/metadata/field-metadata/dtos/options.input';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class WorkspaceSyncMetadataService {
|
export class WorkspaceSyncMetadataService {
|
||||||
@ -314,7 +314,7 @@ export class WorkspaceSyncMetadataService {
|
|||||||
convertedField.options
|
convertedField.options
|
||||||
? {
|
? {
|
||||||
options: this.generateUUIDForNewSelectFieldOptions(
|
options: this.generateUUIDForNewSelectFieldOptions(
|
||||||
convertedField.options as FieldMetadataComplexOptions[],
|
convertedField.options as FieldMetadataComplexOption[],
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
@ -323,8 +323,8 @@ export class WorkspaceSyncMetadataService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private generateUUIDForNewSelectFieldOptions(
|
private generateUUIDForNewSelectFieldOptions(
|
||||||
options: FieldMetadataComplexOptions[],
|
options: FieldMetadataComplexOption[],
|
||||||
): FieldMetadataComplexOptions[] {
|
): FieldMetadataComplexOption[] {
|
||||||
return options.map((option) => ({
|
return options.map((option) => ({
|
||||||
...option,
|
...option,
|
||||||
id: uuidV4(),
|
id: uuidV4(),
|
||||||
|
|||||||
Reference in New Issue
Block a user