Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 117 additions & 6 deletions frontend/common/services/useMetadataField.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Comment thread
Zaimwa9 marked this conversation as resolved.
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
Comment thread
Zaimwa9 marked this conversation as resolved.
}
}

export const metadataService = service
.enhanceEndpoints({ addTagTypes: ['Metadata'] })
Expand All @@ -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',
Expand All @@ -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',
},
Comment thread
cursor[bot] marked this conversation as resolved.
],
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 }))
Comment thread
cursor[bot] marked this conversation as resolved.
}

// 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<MetadataField>,
modelFieldsRes.data as PagedResponse<MetadataModelField>,
entityRes?.data as EntityData | null,
arg.entityContentType,
)

return { data: mergedMetadata }
},
}),
getMetadataField: builder.query<
Res['metadataField'],
Req['getMetadataField']
Expand All @@ -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',
Expand Down Expand Up @@ -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),
)
}
Comment thread
Zaimwa9 marked this conversation as resolved.
// END OF FUNCTION_EXPORTS

export const {
useCreateMetadataFieldMutation,
useDeleteMetadataFieldMutation,
useGetEntityMetadataFieldsQuery,
useGetMetadataFieldListQuery,
useGetMetadataFieldQuery,
useUpdateMetadataFieldMutation,
Expand Down
9 changes: 9 additions & 0 deletions frontend/common/types/metadata-field.ts
Original file line number Diff line number Diff line change
@@ -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
}
103 changes: 103 additions & 0 deletions frontend/common/utils/__tests__/mergeMetadataFields.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { mergeMetadataFields } from 'common/utils/mergeMetadataFields'
import {
MetadataField,
MetadataModelField,
PagedResponse,
} from 'common/types/responses'

const createFieldList = (
fields: Partial<MetadataField>[],
): PagedResponse<MetadataField> => ({
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<MetadataModelField>[],
): PagedResponse<MetadataModelField> => ({
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')
})
})
90 changes: 90 additions & 0 deletions frontend/common/utils/__tests__/metadataValidation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { getGlobalMetadataValidationState } from 'common/utils/metadataValidation'
import { CustomMetadataField } from 'common/types/metadata-field'

const createField = (
partialField: Partial<CustomMetadataField> = {},
): 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,
})
})
})
Loading