diff --git a/frontend/common/services/useMetadataField.ts b/frontend/common/services/useMetadataField.ts index e33711b000bd..f169a5361df5 100644 --- a/frontend/common/services/useMetadataField.ts +++ b/frontend/common/services/useMetadataField.ts @@ -2,6 +2,47 @@ import { Res } from 'common/types/responses' import { Req } from 'common/types/requests' import { service } from 'common/service' import Utils from 'common/utils/utils' +import { CustomMetadataField } from 'common/types/metadata-field' +import { + Environment, + MetadataField, + MetadataModelField, + PagedResponse, + ProjectFlag, + Segment, +} from 'common/types/responses' +import { mergeMetadataFields } from 'common/utils/mergeMetadataFields' + +type EntityType = 'feature' | 'segment' | 'environment' + +type EntityMetadataParams = { + organisationId: number + projectId: number + entityContentType: number + entityType: EntityType + entityId?: number +} + +type EntityData = ProjectFlag | Segment | Environment + +function getEntityUrl(params: EntityMetadataParams): string | null { + const { entityId, entityType, projectId } = params + + if (!entityId) { + return null + } + + switch (entityType) { + case 'feature': + return `projects/${projectId}/features/${entityId}/` + case 'segment': + return `projects/${projectId}/segments/${entityId}/` + case 'environment': + return `environments/${entityId}/` + default: + return null + } +} export const metadataService = service .enhanceEndpoints({ addTagTypes: ['Metadata'] }) @@ -11,7 +52,7 @@ export const metadataService = service Res['metadataField'], Req['createMetadataField'] >({ - invalidatesTags: [{ id: 'LIST', type: 'Metadata' }], + invalidatesTags: [{ type: 'Metadata' }], query: (query: Req['createMetadataField']) => ({ body: query.body, method: 'POST', @@ -22,12 +63,73 @@ export const metadataService = service Res['metadataField'], Req['deleteMetadataField'] >({ - invalidatesTags: [{ id: 'LIST', type: 'Metadata' }], + invalidatesTags: [{ type: 'Metadata' }], query: (query: Req['deleteMetadataField']) => ({ method: 'DELETE', url: `metadata/fields/${query.id}/`, }), }), + getEntityMetadataFields: builder.query< + CustomMetadataField[], + EntityMetadataParams + >({ + providesTags: (_res, _err, arg) => [ + { + id: `${arg.entityType}-${arg.entityId ?? 'new'}-${ + arg.entityContentType + }`, + type: 'Metadata', + }, + ], + queryFn: async (arg, _api, _extraOptions, baseQuery) => { + const entityUrl = getEntityUrl(arg) + + // Build queries to run in parallel + const queries: Promise<{ data?: unknown; error?: unknown }>[] = [ + baseQuery({ + url: `metadata/fields/?${Utils.toParam({ + organisation: arg.organisationId, + })}`, + }), + baseQuery({ + url: `organisations/${arg.organisationId}/metadata-model-fields/`, + }), + ] + + // Only fetch entity data if we have an entityId + if (arg.entityId && entityUrl) { + queries.push(baseQuery({ url: entityUrl })) + } + + // Fetch all in parallel + const results = await Promise.all(queries) + + const [fieldsRes, modelFieldsRes, entityRes] = results + + // Handle errors + if (fieldsRes.error) { + return { error: fieldsRes.error as Res['metadataList'] } + } + if (modelFieldsRes.error) { + return { + error: modelFieldsRes.error as Res['metadataModelFieldList'], + } + } + if (entityRes?.error) { + return { error: entityRes.error as EntityData } + } + + // Merge and return + const mergedMetadata = mergeMetadataFields( + fieldsRes.data as PagedResponse, + modelFieldsRes.data as PagedResponse, + entityRes?.data as EntityData | null, + arg.entityContentType, + ) + + return { data: mergedMetadata } + }, + }), getMetadataField: builder.query< Res['metadataField'], Req['getMetadataField'] @@ -50,10 +152,7 @@ export const metadataService = service Res['metadataField'], Req['updateMetadataField'] >({ - invalidatesTags: (res) => [ - { id: 'LIST', type: 'Metadata' }, - { id: res?.id, type: 'Metadata' }, - ], + invalidatesTags: [{ type: 'Metadata' }], query: (query: Req['updateMetadataField']) => ({ body: query.body, method: 'PUT', @@ -119,11 +218,23 @@ export async function updateMetadata( metadataService.endpoints.updateMetadataField.initiate(data, options), ) } +export async function getEntityMetadataFields( + store: any, + data: EntityMetadataParams, + options?: Parameters< + typeof metadataService.endpoints.getEntityMetadataFields.initiate + >[1], +) { + return store.dispatch( + metadataService.endpoints.getEntityMetadataFields.initiate(data, options), + ) +} // END OF FUNCTION_EXPORTS export const { useCreateMetadataFieldMutation, useDeleteMetadataFieldMutation, + useGetEntityMetadataFieldsQuery, useGetMetadataFieldListQuery, useGetMetadataFieldQuery, useUpdateMetadataFieldMutation, diff --git a/frontend/common/types/metadata-field.ts b/frontend/common/types/metadata-field.ts new file mode 100644 index 000000000000..a71dbf84088c --- /dev/null +++ b/frontend/common/types/metadata-field.ts @@ -0,0 +1,9 @@ +import { MetadataField } from './responses' + +export type CustomMetadataField = MetadataField & { + metadataModelFieldId: number | string | null + isRequiredFor: boolean + model_field?: string | number + hasValue?: boolean + field_value?: string +} diff --git a/frontend/common/utils/__tests__/mergeMetadataFields.test.ts b/frontend/common/utils/__tests__/mergeMetadataFields.test.ts new file mode 100644 index 000000000000..1d23d15eab30 --- /dev/null +++ b/frontend/common/utils/__tests__/mergeMetadataFields.test.ts @@ -0,0 +1,103 @@ +import { mergeMetadataFields } from 'common/utils/mergeMetadataFields' +import { + MetadataField, + MetadataModelField, + PagedResponse, +} from 'common/types/responses' + +const createFieldList = ( + fields: Partial[], +): PagedResponse => ({ + results: fields.map((f, idx) => ({ + description: 'Test description', + id: idx + 1, + name: `Field ${idx + 1}`, + organisation: 1, + type: 'str', + ...f, + })) as MetadataField[], +}) + +const createModelFieldList = ( + modelFields: Partial[], +): PagedResponse => ({ + results: modelFields.map((mf, idx) => ({ + content_type: 100, + field: idx + 1, + id: `${idx + 10}`, + is_required_for: [], + ...mf, + })) as MetadataModelField[], +}) + +describe('mergeMetadataFields', () => { + it('merges field definitions with existing values', () => { + const fieldList = createFieldList([{ id: 1, name: 'Field 1' }]) + const modelFieldList = createModelFieldList([ + { content_type: 100, field: 1, id: '10', is_required_for: [] }, + ]) + const entityData = { + metadata: [{ field_value: 'existing value', model_field: '10' }], + } + + const result = mergeMetadataFields( + fieldList, + modelFieldList, + entityData, + 100, + ) + + expect(result).toHaveLength(1) + expect(result[0].field_value).toBe('existing value') + expect(result[0].hasValue).toBe(true) + }) + + it('sets empty field_value when no existing value or null entity', () => { + const fieldList = createFieldList([{ id: 1, name: 'Field 1' }]) + const modelFieldList = createModelFieldList([ + { content_type: 100, field: 1, id: '10', is_required_for: [] }, + ]) + + const result = mergeMetadataFields(fieldList, modelFieldList, null, 100) + + expect(result[0].field_value).toBe('') + expect(result[0].hasValue).toBe(false) + }) + + it('marks field as required when is_required_for has entries', () => { + const fieldList = createFieldList([{ id: 1, name: 'Required Field' }]) + const modelFieldList = createModelFieldList([ + { + content_type: 100, + field: 1, + id: '10', + is_required_for: [{ content_type: 50 }], + }, + ]) + + const result = mergeMetadataFields(fieldList, modelFieldList, null, 100) + + expect(result[0].isRequiredFor).toBe(true) + }) + + it('sorts required fields first', () => { + const fieldList = createFieldList([ + { id: 1, name: 'Optional' }, + { id: 2, name: 'Required' }, + ]) + const modelFieldList = createModelFieldList([ + { content_type: 100, field: 1, id: '10', is_required_for: [] }, + { + content_type: 100, + field: 2, + id: '11', + is_required_for: [{ content_type: 50 }], + }, + ]) + + const result = mergeMetadataFields(fieldList, modelFieldList, null, 100) + + expect(result[0].name).toBe('Required') + expect(result[1].name).toBe('Optional') + }) +}) diff --git a/frontend/common/utils/__tests__/metadataValidation.test.ts b/frontend/common/utils/__tests__/metadataValidation.test.ts new file mode 100644 index 000000000000..860bd86df82c --- /dev/null +++ b/frontend/common/utils/__tests__/metadataValidation.test.ts @@ -0,0 +1,90 @@ +import { getGlobalMetadataValidationState } from 'common/utils/metadataValidation' +import { CustomMetadataField } from 'common/types/metadata-field' + +const createField = ( + partialField: Partial = {}, +): CustomMetadataField => ({ + description: 'A test field', + field_value: '', + hasValue: false, + id: 1, + isRequiredFor: false, + metadataModelFieldId: 1, + name: 'Test Field', + organisation: 1, + type: 'str', + ...partialField, +}) + +describe('getGlobalMetadataValidationState', () => { + it('returns all zeros for empty fields array', () => { + const result = getGlobalMetadataValidationState([]) + + expect(result).toEqual({ + hasUnfilledRequired: false, + totalFilledRequired: 0, + totalRequired: 0, + }) + }) + + it('returns hasUnfilledRequired false when no required fields', () => { + const fields = [ + createField({ id: 1, isRequiredFor: false }), + createField({ id: 2, isRequiredFor: false }), + ] + + const result = getGlobalMetadataValidationState(fields) + + expect(result).toEqual({ + hasUnfilledRequired: false, + totalFilledRequired: 0, + totalRequired: 0, + }) + }) + + it('returns hasUnfilledRequired false when required field is filled', () => { + const fields = [ + createField({ field_value: 'some value', id: 1, isRequiredFor: true }), + ] + + const result = getGlobalMetadataValidationState(fields) + + expect(result).toEqual({ + hasUnfilledRequired: false, + totalFilledRequired: 1, + totalRequired: 1, + }) + }) + + it('returns hasUnfilledRequired true when some required fields are unfilled', () => { + const fields = [ + createField({ field_value: 'filled', id: 1, isRequiredFor: true }), + createField({ field_value: '', id: 2, isRequiredFor: true }), + createField({ field_value: '', id: 3, isRequiredFor: false }), + ] + + const result = getGlobalMetadataValidationState(fields) + + expect(result).toEqual({ + hasUnfilledRequired: true, + totalFilledRequired: 1, + totalRequired: 2, + }) + }) + + it('returns hasUnfilledRequired false when all required fields are filled', () => { + const fields = [ + createField({ field_value: 'filled', id: 1, isRequiredFor: true }), + createField({ field_value: 'also filled', id: 2, isRequiredFor: true }), + createField({ field_value: '', id: 3, isRequiredFor: false }), + ] + + const result = getGlobalMetadataValidationState(fields) + + expect(result).toEqual({ + hasUnfilledRequired: false, + totalFilledRequired: 2, + totalRequired: 2, + }) + }) +}) diff --git a/frontend/common/utils/mergeMetadataFields.ts b/frontend/common/utils/mergeMetadataFields.ts new file mode 100644 index 000000000000..fc9cf4ac5891 --- /dev/null +++ b/frontend/common/utils/mergeMetadataFields.ts @@ -0,0 +1,60 @@ +import { sortBy } from 'lodash' +import { + Metadata, + MetadataField, + MetadataModelField, + PagedResponse, +} from 'common/types/responses' +import { CustomMetadataField } from 'common/types/metadata-field' + +type EntityWithMetadata = { + metadata?: Metadata[] +} + +/** + * Merges metadata field definitions with model field mappings and existing entity values. + */ +export function mergeMetadataFields( + fieldList: PagedResponse, + modelFieldList: PagedResponse, + entityData: EntityWithMetadata | null, + entityContentType: number, +): CustomMetadataField[] { + // Filter fields that apply to this content type + const fieldsForContentType: CustomMetadataField[] = fieldList.results + .filter((meta) => + modelFieldList.results.some( + (item) => + item.field === meta.id && item.content_type === entityContentType, + ), + ) + .map((meta) => { + const matchingItem = modelFieldList.results.find( + (item) => + item.field === meta.id && item.content_type === entityContentType, + ) + return { + ...meta, + isRequiredFor: !!matchingItem?.is_required_for.length, + metadataModelFieldId: matchingItem ? matchingItem.id : null, + } + }) + + // Get existing values from the entity + const existingValues: Metadata[] = entityData?.metadata ?? [] + + // Merge field definitions with existing values + const mergedMetadata = fieldsForContentType.map((field) => { + const existingValue = existingValues.find( + (v) => v.model_field === field.metadataModelFieldId, + ) + return { + ...field, + field_value: existingValue?.field_value ?? '', + hasValue: !!existingValue, + } + }) + + // Sort required fields first + return sortBy(mergedMetadata, (m) => (m.isRequiredFor ? -1 : 1)) +} diff --git a/frontend/common/utils/metadataValidation.ts b/frontend/common/utils/metadataValidation.ts new file mode 100644 index 000000000000..d66e9c4be57c --- /dev/null +++ b/frontend/common/utils/metadataValidation.ts @@ -0,0 +1,29 @@ +import { useMemo } from 'react' +import { CustomMetadataField } from 'common/types/metadata-field' + +export type MetadataValidationState = { + hasUnfilledRequired: boolean + totalRequired: number + totalFilledRequired: number +} + +export function getGlobalMetadataValidationState( + fields: CustomMetadataField[], +): MetadataValidationState { + const totalRequired = fields.filter((f) => f.isRequiredFor).length + const totalFilledRequired = fields.filter( + (f) => f.isRequiredFor && f.field_value && f.field_value !== '', + ).length + + return { + hasUnfilledRequired: + totalRequired > 0 && totalFilledRequired < totalRequired, + totalFilledRequired, + totalRequired, + } +} +export function useGlobalMetadataValidation( + fields: CustomMetadataField[], +): MetadataValidationState { + return useMemo(() => getGlobalMetadataValidationState(fields), [fields]) +} diff --git a/frontend/web/components/metadata/AddMetadataToEntity.tsx b/frontend/web/components/metadata/AddMetadataToEntity.tsx index f460b07af5d4..b657dc81dfb9 100644 --- a/frontend/web/components/metadata/AddMetadataToEntity.tsx +++ b/frontend/web/components/metadata/AddMetadataToEntity.tsx @@ -1,43 +1,59 @@ -import React, { FC, useEffect, useState } from 'react' +import React, { FC, useCallback, useEffect, useState } from 'react' import PanelSearch from 'components/PanelSearch' import Button from 'components/base/forms/Button' -import { useGetMetadataModelFieldListQuery } from 'common/services/useMetadataModelField' -import { useGetMetadataFieldListQuery } from 'common/services/useMetadataField' -import { useGetSegmentQuery } from 'common/services/useSegment' -import { - useGetEnvironmentQuery, - useUpdateEnvironmentMutation, -} from 'common/services/useEnvironment' -import { MetadataField, Metadata } from 'common/types/responses' +import { useUpdateEnvironmentMutation } from 'common/services/useEnvironment' +import { Metadata } from 'common/types/responses' import Utils from 'common/utils/utils' -import { useGetProjectFlagQuery } from 'common/services/useProjectFlag' -import { sortBy } from 'lodash' import Switch from 'components/Switch' import InputGroup from 'components/base/forms/InputGroup' +import { + metadataService, + useGetEntityMetadataFieldsQuery, +} from 'common/services/useMetadataField' +import { getStore } from 'common/store' +import { CustomMetadataField } from 'common/types/metadata-field' +import { useGlobalMetadataValidation } from 'common/utils/metadataValidation' -export type CustomMetadataField = MetadataField & { - metadataModelFieldId: number | string | null - isRequiredFor: boolean - model_field?: string | number - metadataEntity?: boolean - field_value?: string -} - -type CustomMetadata = (Metadata & CustomMetadataField) | null +const EMPTY_FIELDS: CustomMetadataField[] = [] -type AddMetadataToEntityType = { +type AddMetadataToEntityProps = { isCloningEnvironment?: boolean - organisationId: string - projectId: string | number + organisationId: number + projectId: number entityContentType: number - entityId: string + entityId?: number entity: string envName?: string - onChange?: (m: CustomMetadataField[]) => void + onChange?: (metadata: Metadata[]) => void setHasMetadataRequired?: (b: boolean) => void } -const AddMetadataToEntity: FC = ({ +function formatMetadataToApi(fields: CustomMetadataField[]): Metadata[] { + return fields + .filter((f) => f.hasValue) + .map(({ field_value, metadataModelFieldId }) => ({ + field_value: field_value ?? '', + model_field: metadataModelFieldId as number, + })) +} + +type MetadataErrorResponse = { + data?: { + metadata?: Array<{ + non_field_errors?: string[] + [key: string]: unknown + }> + } +} + +function getMetadataErrors(error: MetadataErrorResponse): string { + const metadataErrors = error?.data?.metadata + if (!metadataErrors) return '' + + return metadataErrors.flatMap((m) => m.non_field_errors ?? []).join('\n') +} + +const AddMetadataToEntity: FC = ({ entity, entityContentType, entityId, @@ -48,348 +64,184 @@ const AddMetadataToEntity: FC = ({ projectId, setHasMetadataRequired, }) => { - const { data: metadataFieldList, isSuccess: metadataFieldListLoaded } = - useGetMetadataFieldListQuery({ - organisation: organisationId, - }) - const { - data: metadataModelFieldList, - isSuccess: metadataModelFieldListLoaded, - } = useGetMetadataModelFieldListQuery({ - organisation_id: organisationId, - }) - - const { data: projectFeatureData, isSuccess: projectFeatureDataLoaded } = - useGetProjectFlagQuery( + const { data: initialFields = EMPTY_FIELDS, isLoading } = + useGetEntityMetadataFieldsQuery( { - id: entityId, - project: projectId, + entityContentType, + entityId, + entityType: entity as 'feature' | 'segment' | 'environment', + organisationId, + projectId, }, - { skip: entity !== 'feature' || !entityId }, + { refetchOnMountOrArgChange: true }, ) - const { data: segmentData, isSuccess: segmentDataLoaded } = - useGetSegmentQuery( - { - id: `${entityId}`, - projectId: `${projectId}`, - }, - { skip: entity !== 'segment' || !entityId }, - ) - - const { data: envData, isSuccess: envDataLoaded } = useGetEnvironmentQuery( - { id: entityId }, - { skip: entity !== 'environment' || !entityId }, + const [metadataFields, setMetadataFields] = useState( + [], ) + const [hasChanges, setHasChanges] = useState(false) - const [updateEnvironment] = useUpdateEnvironmentMutation() + const { hasUnfilledRequired } = useGlobalMetadataValidation(metadataFields) - const [ - metadataFieldsAssociatedtoEntity, - setMetadataFieldsAssociatedtoEntity, - ] = useState() + useEffect(() => { + setMetadataFields(initialFields) + setHasChanges(false) + }, [initialFields]) useEffect(() => { - if (metadataFieldsAssociatedtoEntity?.length && metadataChanged) { - const metadataParsed = metadataFieldsAssociatedtoEntity - .filter((m) => m.metadataEntity) - .map((i) => { - const { metadataModelFieldId, ...rest } = i - return { model_field: metadataModelFieldId, ...rest } - }) - onChange?.(metadataParsed as CustomMetadataField[]) - } + setHasMetadataRequired?.(hasUnfilledRequired) // eslint-disable-next-line react-hooks/exhaustive-deps - }, [metadataFieldsAssociatedtoEntity]) - - const [metadataChanged, setMetadataChanged] = useState(false) + }, [hasUnfilledRequired]) + + const handleFieldChange = useCallback( + (fieldId: number, newValue: string) => { + setMetadataFields((prev) => { + const updatedMetadataFields = prev.map((field) => + field.id === fieldId + ? { ...field, field_value: newValue, hasValue: !!newValue } + : field, + ) - const mergeMetadataEntityWithMetadataField = ( - metadata: Metadata[], // Metadata array - metadataField: CustomMetadataField[], // Custom metadata field array - ) => { - // Create a map of metadata fields using metadataModelFieldId as key - const map = new Map( - metadataField.map((item) => [item.metadataModelFieldId, item]), - ) + // Propagate the change to upstream parents + if (entity !== 'environment' || isCloningEnvironment) { + const formattedMetadata = formatMetadataToApi(updatedMetadataFields) + onChange?.(formattedMetadata) + } + + return updatedMetadataFields + }) + setHasChanges(true) + }, + [entity, isCloningEnvironment, onChange], + ) - // Merge metadata fields with metadata entities - return metadataField.map((item) => { - const mergedItem = { - ...item, // Spread the properties of the metadata field - ...(map.get(item.model_field!) || {}), // Get the corresponding metadata field from the map - ...(metadata.find((m) => m.model_field === item.metadataModelFieldId) || - {}), // Find the corresponding metadata entity - } + const [updateEnvironment] = useUpdateEnvironmentMutation() - // Determine if metadata entity exists - mergedItem.metadataEntity = - mergedItem.metadataModelFieldId !== undefined && - mergedItem.model_field !== undefined + const handleEnvironmentSave = async () => { + if (!envName || !entityId) return - return mergedItem // Return the merged item + const result = await updateEnvironment({ + body: { + metadata: formatMetadataToApi(metadataFields), + name: envName, + project: projectId, + }, + id: entityId, }) - } - useEffect(() => { - if ( - metadataFieldList && - metadataFieldListLoaded && - metadataModelFieldList && - metadataModelFieldListLoaded - ) { - // Filter metadata fields based on the provided content type - const metadataForContentType = metadataFieldList.results - // Filter metadata fields that have corresponding entries in the metadata model field list - .filter((meta) => { - return metadataModelFieldList.results.some((item) => { - return ( - item.field === meta.id && item.content_type === entityContentType - ) - }) - }) - // Map each filtered metadata field to include additional information from the metadata model field list - .map((meta) => { - // Find the matching item in the metadata model field list - const matchingItem = metadataModelFieldList.results.find((item) => { - return ( - item.field === meta.id && item.content_type === entityContentType - ) - }) - // Determine if isRequiredFor should be true or false based on is_required_for array - const isRequiredFor = !!matchingItem?.is_required_for.length - setHasMetadataRequired?.(isRequiredFor) - // Return the metadata field with additional metadata model field information including isRequiredFor - return { - ...meta, - isRequiredFor: isRequiredFor || false, - metadataModelFieldId: matchingItem ? matchingItem.id : null, - } - }) - if (projectFeatureData?.metadata && projectFeatureDataLoaded) { - const mergedFeatureEntity = mergeMetadataEntityWithMetadataField( - projectFeatureData?.metadata, - metadataForContentType, - ) - const sortedArray = sortBy(mergedFeatureEntity, (m) => - m.isRequiredFor ? -1 : 1, - ) - setMetadataFieldsAssociatedtoEntity(sortedArray) - } else if (segmentData?.metadata && segmentDataLoaded) { - const mergedSegmentEntity = mergeMetadataEntityWithMetadataField( - segmentData?.metadata, - metadataForContentType, - ) - const sortedArray = sortBy(mergedSegmentEntity, (m) => - m.isRequiredFor ? -1 : 1, - ) - setMetadataFieldsAssociatedtoEntity(sortedArray) - } else if (envData?.metadata && envDataLoaded) { - const mergedEnvEntity = mergeMetadataEntityWithMetadataField( - envData?.metadata, - metadataForContentType, - ) - const sortedArray = sortBy(mergedEnvEntity, (m) => - m.isRequiredFor ? -1 : 1, - ) - setMetadataFieldsAssociatedtoEntity(sortedArray) - } else { - const sortedArray = sortBy(metadataForContentType, (m) => - m.isRequiredFor ? -1 : 1, - ) - setMetadataFieldsAssociatedtoEntity(sortedArray) - } + if ('error' in result) { + const errorMessage = getMetadataErrors( + result.error as MetadataErrorResponse, + ) + toast(errorMessage || 'Failed to update custom fields', 'danger') + } else { + toast('Environment Field Updated') + getStore().dispatch( + metadataService.util.invalidateTags([{ type: 'Metadata' }]), + ) } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ - metadataFieldList, - metadataFieldListLoaded, - metadataModelFieldList, - metadataModelFieldListLoaded, - projectFeatureDataLoaded, - projectFeatureData, - entityId, - envData, - envDataLoaded, - segmentData, - segmentDataLoaded, - ]) - - const getMetadataErrors = (error: any) => { - const nonFieldErrors = - error?.data?.metadata?.map( - (metadata: any) => metadata?.non_field_errors, - ) || [] - const fieldErrors = - error?.data?.metadata?.map((metadata: any) => metadata) || [] - - const allErrors = [...nonFieldErrors, ...fieldErrors] - - return allErrors.join('\n') } return ( - <> - - - Field - Value - - } - items={metadataFieldsAssociatedtoEntity} - renderRow={(m) => { - return ( - { - setMetadataFieldsAssociatedtoEntity((prevState) => - prevState?.map((metadata) => { - if (metadata.id === m?.id) { - return { - ...metadata, - field_value: m?.field_value, - metadataEntity: !!m?.field_value, - } - } - return metadata - }), - ) - setMetadataChanged(true) - }} - /> - ) - }} - renderNoResults={ - - No custom fields configured for {entity}s. Add custom fields in - your{' '} - - Organisation Settings - - . - - } - /> - {entity === 'environment' && !isCloningEnvironment && ( -
- -
+ + + Field + Value + + } + items={metadataFields} + renderRow={(field: CustomMetadataField) => ( + )} - - + renderNoResults={ + + No custom fields configured for {entity}s. Add custom fields in your{' '} + + Organisation Settings + + . + + } + /> + {entity === 'environment' && !isCloningEnvironment && ( +
+ +
+ )} +
) } -type MetadataRowType = { +type MetadataRowProps = { metadata: CustomMetadataField - getMetadataValue?: (metadata: CustomMetadata) => void - entity: string + onFieldChange: (fieldId: number, value: string) => void } -const MetadataRow: FC = ({ - entity, - getMetadataValue, - metadata, -}) => { - const [metadataValueChanged, setMetadataValueChanged] = - useState(false) - const metadataValue = - metadata?.type === 'bool' - ? metadata?.field_value === 'true' - : metadata?.field_value || '' - const handleChange = (newMetadataValue: string | boolean) => { - setMetadataValueChanged(false) - const updatedMetadataObject = { ...metadata } - updatedMetadataObject.field_value = - metadata?.type === 'bool' ? `${!newMetadataValue}` : `${newMetadataValue}` - getMetadataValue?.(updatedMetadataObject as CustomMetadata) - } +const MetadataRow: FC = ({ metadata, onFieldChange }) => { + const displayValue = + metadata.type === 'bool' + ? metadata.field_value === 'true' + : metadata.field_value || '' - const isRequiredForAndCorrectType = - metadata?.isRequiredFor && - Utils.validateMetadataType(metadata?.type, metadataValue) - const isNotRequiredAndCorrectType = - !!metadataValue && Utils.validateMetadataType(metadata?.type, metadataValue) - const isEmptyAuthorized = !metadataValue && !metadata?.isRequiredFor + const handleChange = (newValue: string | boolean) => { + onFieldChange(metadata.id, `${newValue}`) + } + const isEmpty = !displayValue || displayValue === '' + const isValidType = Utils.validateMetadataType(metadata.type, displayValue) + const isValid = isEmpty ? !metadata.isRequiredFor : isValidType return ( - {metadataValueChanged && entity !== 'segment' && ( -
{'*'}
- )} - {`${metadata?.name} ${ - metadata?.isRequiredFor ? '*' : '' - }`} - {metadata?.type === 'bool' ? ( + + {metadata.name} + {metadata.isRequiredFor && '*'} + + {metadata.type === 'bool' ? ( { - setMetadataValueChanged(true) - handleChange(!metadataValue) + const currentBool = + displayValue === true || displayValue === 'true' + handleChange(!currentBool) }} /> ) : ( { - setMetadataValueChanged(true) handleChange(Utils.safeParseEventValue(e)) }} type='text' diff --git a/frontend/web/components/modals/CreateSegment.tsx b/frontend/web/components/modals/CreateSegment.tsx index 642b1444a6ec..3022a9ff92c9 100644 --- a/frontend/web/components/modals/CreateSegment.tsx +++ b/frontend/web/components/modals/CreateSegment.tsx @@ -466,7 +466,7 @@ const CreateSegment: FC = ({ { diff --git a/frontend/web/components/modals/create-feature/index.js b/frontend/web/components/modals/create-feature/index.js index 2b40e5e02bd5..6dfd04e00fce 100644 --- a/frontend/web/components/modals/create-feature/index.js +++ b/frontend/web/components/modals/create-feature/index.js @@ -498,7 +498,11 @@ const Index = class extends Component { } parseError = (error) => { const { projectFlag } = this.props - let featureError = error?.message || error?.name?.[0] || error + let featureError = + error?.metadata?.flatMap((m) => m.non_field_errors ?? []).join('\n') || + error?.message || + error?.name?.[0] || + error let featureWarning = '' //Treat multivariate no changes as warnings if ( @@ -824,9 +828,6 @@ const Index = class extends Component { > {({ permission: projectAdmin }) => { this.state.skipSaveProjectFeature = !createFeature - const _hasMetadataRequired = - this.state.hasMetadataRequired && - !projectFlag.metadata?.length return (
@@ -1744,7 +1745,7 @@ const Index = class extends Component { isSaving || !projectFlag.name || invalid || - _hasMetadataRequired + this.state.hasMetadataRequired } > {isSaving @@ -1828,11 +1829,11 @@ const Index = class extends Component { } onHasMetadataRequiredChange={( hasMetadataRequired, - ) => + ) => { this.setState({ hasMetadataRequired, }) - } + }} featureError={ this.parseError(error).featureError } @@ -1850,7 +1851,9 @@ const Index = class extends Component { featureLimitPercentage={ this.state.featureLimitAlert.percentage } - hasMetadataRequired={_hasMetadataRequired} + hasMetadataRequired={ + this.state.hasMetadataRequired + } />
)} diff --git a/frontend/web/components/pages/CreateEnvironmentPage.tsx b/frontend/web/components/pages/CreateEnvironmentPage.tsx index 43ce829f02fd..efc5e6f2fde0 100644 --- a/frontend/web/components/pages/CreateEnvironmentPage.tsx +++ b/frontend/web/components/pages/CreateEnvironmentPage.tsx @@ -26,6 +26,7 @@ const CreateEnvironmentPage: React.FC = () => { const [name, setName] = useState('') const [description, setDescription] = useState() const [selectedEnv, setSelectedEnv] = useState() + const [hasMetadataRequired, setHasMetadataRequired] = useState(false) const inputRef = useRef(null) const history = useHistory() @@ -84,10 +85,37 @@ const CreateEnvironmentPage: React.FC = () => { permission='CREATE_ENVIRONMENT' id={projectId} > - {({ isLoading, permission }) => - isLoading ? ( - - ) : permission ? ( + {({ isLoading, permission }) => { + if (isLoading) { + return + } + if (!permission) { + return ( +
+

+ Check your project permissions +

+

+ Although you have been invited to this project, you are not + invited to any environments yet! +

+

+ Contact your project administrator asking them to either: +

    +
  • + Invite you to an environment (e.g. develop) by visiting{' '} + Environment settings +
  • +
  • + Grant permissions to create an environment under{' '} + Project settings. +
  • +
+

+
+ ) + } + return ( {({ createEnv, error, isSaving, project }) => (
{ /> )} - {error && ( - - - - )} {Utils.getPlansPermission('METADATA') && envContentType?.id && ( @@ -183,19 +206,25 @@ const CreateEnvironmentPage: React.FC = () => { entity={envContentType.model} isCloningEnvironment onChange={setMetadata} + setHasMetadataRequired={setHasMetadataRequired} /> } /> )} + {error && ( + + + + )}
@@ -209,31 +238,8 @@ const CreateEnvironmentPage: React.FC = () => { )} - ) : ( -
-

- Check your project permissions -

-

- Although you have been invited to this project, you are not - invited to any environments yet! -

-

- Contact your project administrator asking them to either: -

    -
  • - Invite you to an environment (e.g. develop) by visiting{' '} - Environment settings -
  • -
  • - Grant permissions to create an environment under{' '} - Project settings. -
  • -
-

-
) - } + }}
)