fix: rating type issues (#3638)

* fix: rating type issues

* fix: rebase

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Jérémy M
2024-01-30 09:57:30 +01:00
committed by GitHub
parent e7f2af6f0b
commit da8dd671d1
14 changed files with 137 additions and 43 deletions

View File

@ -28,6 +28,7 @@ export const useMapFieldMetadataToGraphQLQuery = () => {
'EMAIL', 'EMAIL',
'NUMBER', 'NUMBER',
'BOOLEAN', 'BOOLEAN',
'RATING',
'SELECT', 'SELECT',
] as FieldType[] ] as FieldType[]
).includes(fieldType); ).includes(fieldType);

View File

@ -23,7 +23,7 @@ export const useRatingField = () => {
}), }),
); );
const rating = +(fieldValue ?? 0); const rating = fieldValue ?? 'RATING_1';
return { return {
fieldDefinition, fieldDefinition,

View File

@ -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 (

View File

@ -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;

View File

@ -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,

View File

@ -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} />

View File

@ -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;

View File

@ -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,

View File

@ -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<

View File

@ -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;
}

View File

@ -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 (

View File

@ -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`;
},
},
});
};
}

View File

@ -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,

View File

@ -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(),