From da8dd671d1957791e5da10ba3933b9898c1d5559 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9my=20M?= Date: Tue, 30 Jan 2024 09:57:30 +0100 Subject: [PATCH] fix: rating type issues (#3638) * fix: rating type issues * fix: rebase --------- Co-authored-by: Charles Bochet --- .../useMapFieldMetadataToGraphQLQuery.ts | 1 + .../meta-types/hooks/useRatingField.ts | 2 +- .../input/components/RatingFieldInput.tsx | 13 ++++++++-- .../record-field/types/FieldMetadata.ts | 3 ++- .../types/guards/isFieldRatingValue.ts | 6 ++--- .../ui/field/input/components/RatingInput.tsx | 26 ++++++++++--------- .../field-metadata/dtos/options.input.ts | 8 +++--- .../field-metadata/field-metadata.service.ts | 23 +++++++++++++++- .../field-metadata-options.interface.ts | 12 ++++----- .../utils/generate-rating-optionts.util.ts | 23 ++++++++++++++++ .../utils/validate-options-for-type.util.ts | 22 +++++++++++----- .../is-valid-graphql-enum-name.validator.ts | 26 +++++++++++++++++++ .../factories/enum-type-definition.factory.ts | 7 ++--- .../workspace-sync.metadata.service.ts | 8 +++--- 14 files changed, 137 insertions(+), 43 deletions(-) create mode 100644 packages/twenty-server/src/metadata/field-metadata/utils/generate-rating-optionts.util.ts create mode 100644 packages/twenty-server/src/metadata/field-metadata/validators/is-valid-graphql-enum-name.validator.ts diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/useMapFieldMetadataToGraphQLQuery.ts b/packages/twenty-front/src/modules/object-metadata/hooks/useMapFieldMetadataToGraphQLQuery.ts index c1a30aad4..faa838d26 100644 --- a/packages/twenty-front/src/modules/object-metadata/hooks/useMapFieldMetadataToGraphQLQuery.ts +++ b/packages/twenty-front/src/modules/object-metadata/hooks/useMapFieldMetadataToGraphQLQuery.ts @@ -28,6 +28,7 @@ export const useMapFieldMetadataToGraphQLQuery = () => { 'EMAIL', 'NUMBER', 'BOOLEAN', + 'RATING', 'SELECT', ] as FieldType[] ).includes(fieldType); diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useRatingField.ts b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useRatingField.ts index 48663546f..b28133df4 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useRatingField.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useRatingField.ts @@ -23,7 +23,7 @@ export const useRatingField = () => { }), ); - const rating = +(fieldValue ?? 0); + const rating = fieldValue ?? 'RATING_1'; return { fieldDefinition, diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/RatingFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/RatingFieldInput.tsx index e50e01f60..300276b73 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/RatingFieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/RatingFieldInput.tsx @@ -1,3 +1,4 @@ +import { FieldRatingValue } from '@/object-record/record-field/types/FieldMetadata'; import { RatingInput } from '@/ui/field/input/components/RatingInput'; import { usePersistField } from '../../../hooks/usePersistField'; @@ -10,6 +11,14 @@ export type RatingFieldInputProps = { readonly?: boolean; }; +export const RATING_VALUES = [ + 'RATING_1', + 'RATING_2', + 'RATING_3', + 'RATING_4', + 'RATING_5', +] as const; + export const RatingFieldInput = ({ onSubmit, readonly, @@ -18,8 +27,8 @@ export const RatingFieldInput = ({ const persistField = usePersistField(); - const handleChange = (newRating: number) => { - onSubmit?.(() => persistField(`${newRating}`)); + const handleChange = (newRating: FieldRatingValue) => { + onSubmit?.(() => persistField(newRating)); }; return ( diff --git a/packages/twenty-front/src/modules/object-record/record-field/types/FieldMetadata.ts b/packages/twenty-front/src/modules/object-record/record-field/types/FieldMetadata.ts index e02c9df83..634df5dfe 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/types/FieldMetadata.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/types/FieldMetadata.ts @@ -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 { ThemeColor } from '@/ui/theme/constants/colors'; @@ -119,7 +120,7 @@ export type FieldCurrencyValue = { amountMicros: number | null; }; 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 FieldRelationValue = EntityForSelect | null; diff --git a/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldRatingValue.ts b/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldRatingValue.ts index 645c5a00f..484ecb3fb 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldRatingValue.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldRatingValue.ts @@ -1,11 +1,9 @@ import { z } from 'zod'; +import { RATING_VALUES } from '../../meta-types/input/components/RatingFieldInput'; import { FieldRatingValue } from '../FieldMetadata'; -const ratingSchema = z - .string() - .transform((value) => +value) - .pipe(z.number().int().min(1).max(5)); +const ratingSchema = z.string().pipe(z.enum(RATING_VALUES)); export const isFieldRatingValue = ( fieldValue: unknown, diff --git a/packages/twenty-front/src/modules/ui/field/input/components/RatingInput.tsx b/packages/twenty-front/src/modules/ui/field/input/components/RatingInput.tsx index 549c10841..2da9519e8 100644 --- a/packages/twenty-front/src/modules/ui/field/input/components/RatingInput.tsx +++ b/packages/twenty-front/src/modules/ui/field/input/components/RatingInput.tsx @@ -2,6 +2,8 @@ import { useState } from 'react'; import { useTheme } from '@emotion/react'; 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'; const StyledContainer = styled.div` @@ -16,40 +18,40 @@ const StyledRatingIconContainer = styled.div<{ isActive?: boolean }>` `; type RatingInputProps = { - onChange: (newValue: number) => void; - value: number; + onChange: (newValue: FieldRatingValue) => void; + value: FieldRatingValue; readonly?: boolean; }; -const RATING_LEVELS_NB = 5; - export const RatingInput = ({ onChange, value, readonly, }: RatingInputProps) => { const theme = useTheme(); - const [hoveredValue, setHoveredValue] = useState(null); + const [hoveredValue, setHoveredValue] = useState( + null, + ); const currentValue = hoveredValue ?? value; return ( - {Array.from({ length: RATING_LEVELS_NB }, (_, index) => { - const rating = index + 1; + {RATING_VALUES.map((value, index) => { + const currentIndex = RATING_VALUES.indexOf(currentValue); return ( onChange(rating)} - onMouseEnter={readonly ? undefined : () => setHoveredValue(rating)} + isActive={index <= currentIndex} + onClick={readonly ? undefined : () => onChange(value)} + onMouseEnter={readonly ? undefined : () => setHoveredValue(value)} onMouseLeave={readonly ? undefined : () => setHoveredValue(null)} > diff --git a/packages/twenty-server/src/metadata/field-metadata/dtos/options.input.ts b/packages/twenty-server/src/metadata/field-metadata/dtos/options.input.ts index 571217239..4ef3d036d 100644 --- a/packages/twenty-server/src/metadata/field-metadata/dtos/options.input.ts +++ b/packages/twenty-server/src/metadata/field-metadata/dtos/options.input.ts @@ -1,6 +1,8 @@ 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() @IsString() id?: string; @@ -13,11 +15,11 @@ export class FieldMetadataDefaultOptions { label: string; @IsNotEmpty() - @IsString() + @IsValidGraphQLEnumName() value: string; } -export class FieldMetadataComplexOptions extends FieldMetadataDefaultOptions { +export class FieldMetadataComplexOption extends FieldMetadataDefaultOption { @IsNotEmpty() @IsString() color: string; diff --git a/packages/twenty-server/src/metadata/field-metadata/field-metadata.service.ts b/packages/twenty-server/src/metadata/field-metadata/field-metadata.service.ts index 63aec135a..7b5bba5d7 100644 --- a/packages/twenty-server/src/metadata/field-metadata/field-metadata.service.ts +++ b/packages/twenty-server/src/metadata/field-metadata/field-metadata.service.ts @@ -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 { 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() export class FieldMetadataService extends TypeOrmQueryService { @@ -60,6 +66,21 @@ export class FieldMetadataService extends TypeOrmQueryService = T extends keyof FieldMetadataOptionsMapping ? FieldMetadataOptionsMapping[T] : T extends 'default' - ? FieldMetadataDefaultOptions[] | FieldMetadataComplexOptions[] + ? FieldMetadataDefaultOption[] | FieldMetadataComplexOption[] : never; export type FieldMetadataOptions< diff --git a/packages/twenty-server/src/metadata/field-metadata/utils/generate-rating-optionts.util.ts b/packages/twenty-server/src/metadata/field-metadata/utils/generate-rating-optionts.util.ts new file mode 100644 index 000000000..f4cc90dc2 --- /dev/null +++ b/packages/twenty-server/src/metadata/field-metadata/utils/generate-rating-optionts.util.ts @@ -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; +} diff --git a/packages/twenty-server/src/metadata/field-metadata/utils/validate-options-for-type.util.ts b/packages/twenty-server/src/metadata/field-metadata/utils/validate-options-for-type.util.ts index 0a3ec8740..5cd778c82 100644 --- a/packages/twenty-server/src/metadata/field-metadata/utils/validate-options-for-type.util.ts +++ b/packages/twenty-server/src/metadata/field-metadata/utils/validate-options-for-type.util.ts @@ -5,14 +5,16 @@ import { FieldMetadataOptions } from 'src/metadata/field-metadata/interfaces/fie import { FieldMetadataType } from 'src/metadata/field-metadata/field-metadata.entity'; import { - FieldMetadataComplexOptions, - FieldMetadataDefaultOptions, + FieldMetadataComplexOption, + FieldMetadataDefaultOption, } from 'src/metadata/field-metadata/dtos/options.input'; +import { isEnumFieldMetadataType } from './is-enum-field-metadata-type.util'; + export const optionsValidatorsMap = { - [FieldMetadataType.RATING]: [FieldMetadataDefaultOptions], - [FieldMetadataType.SELECT]: [FieldMetadataComplexOptions], - [FieldMetadataType.MULTI_SELECT]: [FieldMetadataComplexOptions], + // RATING doesn't need to be provided as it's the backend that will generate the options + [FieldMetadataType.SELECT]: [FieldMetadataComplexOption], + [FieldMetadataType.MULTI_SELECT]: [FieldMetadataComplexOption], }; export const validateOptionsForType = ( @@ -25,6 +27,14 @@ export const validateOptionsForType = ( return false; } + if (!isEnumFieldMetadataType(type)) { + return true; + } + + if (type === FieldMetadataType.RATING) { + return true; + } + const validators = optionsValidatorsMap[type]; if (!validators) return false; @@ -33,7 +43,7 @@ export const validateOptionsForType = ( return validators.some((validator) => { const optionsInstance = plainToInstance< any, - FieldMetadataDefaultOptions | FieldMetadataComplexOptions + FieldMetadataDefaultOption | FieldMetadataComplexOption >(validator, option); return ( diff --git a/packages/twenty-server/src/metadata/field-metadata/validators/is-valid-graphql-enum-name.validator.ts b/packages/twenty-server/src/metadata/field-metadata/validators/is-valid-graphql-enum-name.validator.ts new file mode 100644 index 000000000..44b60cf16 --- /dev/null +++ b/packages/twenty-server/src/metadata/field-metadata/validators/is-valid-graphql-enum-name.validator.ts @@ -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`; + }, + }, + }); + }; +} diff --git a/packages/twenty-server/src/workspace/workspace-schema-builder/factories/enum-type-definition.factory.ts b/packages/twenty-server/src/workspace/workspace-schema-builder/factories/enum-type-definition.factory.ts index 7d629af3c..778a5f74e 100644 --- a/packages/twenty-server/src/workspace/workspace-schema-builder/factories/enum-type-definition.factory.ts +++ b/packages/twenty-server/src/workspace/workspace-schema-builder/factories/enum-type-definition.factory.ts @@ -8,8 +8,8 @@ import { FieldMetadataInterface } from 'src/metadata/field-metadata/interfaces/f import { pascalCase } from 'src/utils/pascal-case'; import { - FieldMetadataComplexOptions, - FieldMetadataDefaultOptions, + FieldMetadataComplexOption, + FieldMetadataDefaultOption, } from 'src/metadata/field-metadata/dtos/options.input'; 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 // https://github.com/microsoft/TypeScript/issues/36390 const enumOptions = fieldMetadata.options as Array< - FieldMetadataDefaultOptions | FieldMetadataComplexOptions + FieldMetadataDefaultOption | FieldMetadataComplexOption >; if (!enumOptions) { @@ -74,6 +74,7 @@ export class EnumTypeDefinitionFactory { description: fieldMetadata.description, values: enumOptions.reduce( (acc, enumOption) => { + // Key must match this regex: /^[_A-Za-z][_0-9A-Za-z]+$/ acc[enumOption.value] = { value: enumOption.value, description: enumOption.label, diff --git a/packages/twenty-server/src/workspace/workspace-sync-metadata/workspace-sync.metadata.service.ts b/packages/twenty-server/src/workspace/workspace-sync-metadata/workspace-sync.metadata.service.ts index 14c96d38b..3826c15ab 100644 --- a/packages/twenty-server/src/workspace/workspace-sync-metadata/workspace-sync.metadata.service.ts +++ b/packages/twenty-server/src/workspace/workspace-sync-metadata/workspace-sync.metadata.service.ts @@ -39,7 +39,7 @@ import { WorkspaceMigrationRunnerService } from 'src/workspace/workspace-migrati import { ReflectiveMetadataFactory } from 'src/workspace/workspace-sync-metadata/reflective-metadata.factory'; import { FeatureFlagEntity } from 'src/core/feature-flag/feature-flag.entity'; 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() export class WorkspaceSyncMetadataService { @@ -314,7 +314,7 @@ export class WorkspaceSyncMetadataService { convertedField.options ? { options: this.generateUUIDForNewSelectFieldOptions( - convertedField.options as FieldMetadataComplexOptions[], + convertedField.options as FieldMetadataComplexOption[], ), } : {}), @@ -323,8 +323,8 @@ export class WorkspaceSyncMetadataService { } private generateUUIDForNewSelectFieldOptions( - options: FieldMetadataComplexOptions[], - ): FieldMetadataComplexOptions[] { + options: FieldMetadataComplexOption[], + ): FieldMetadataComplexOption[] { return options.map((option) => ({ ...option, id: uuidV4(),