diff --git a/frontend/common/services/useMetadataField.ts b/frontend/common/services/useMetadataField.ts index f169a5361df5..9b8f1d8bae52 100644 --- a/frontend/common/services/useMetadataField.ts +++ b/frontend/common/services/useMetadataField.ts @@ -1,17 +1,19 @@ +import { sortBy } from 'lodash' + import { Res } from 'common/types/responses' import { Req } from 'common/types/requests' import { service } from 'common/service' +import transformCorePaging from 'common/transformCorePaging' import Utils from 'common/utils/utils' import { CustomMetadataField } from 'common/types/metadata-field' import { Environment, + Metadata, MetadataField, - MetadataModelField, PagedResponse, ProjectFlag, Segment, } from 'common/types/responses' -import { mergeMetadataFields } from 'common/utils/mergeMetadataFields' type EntityType = 'feature' | 'segment' | 'environment' @@ -25,13 +27,13 @@ type EntityMetadataParams = { type EntityData = ProjectFlag | Segment | Environment +type EntityWithMetadata = { + metadata?: Metadata[] +} + function getEntityUrl(params: EntityMetadataParams): string | null { const { entityId, entityType, projectId } = params - if (!entityId) { - return null - } - switch (entityType) { case 'feature': return `projects/${projectId}/features/${entityId}/` @@ -87,13 +89,12 @@ export const metadataService = service // Build queries to run in parallel const queries: Promise<{ data?: unknown; error?: unknown }>[] = [ baseQuery({ - url: `metadata/fields/?${Utils.toParam({ - organisation: arg.organisationId, + url: `projects/${arg.projectId}/metadata/fields/?${Utils.toParam({ + entity: arg.entityType, + include_organisation: true, + page_size: 100, })}`, }), - baseQuery({ - url: `organisations/${arg.organisationId}/metadata-model-fields/`, - }), ] // Only fetch entity data if we have an entityId @@ -104,30 +105,54 @@ export const metadataService = service // Fetch all in parallel const results = await Promise.all(queries) - const [fieldsRes, modelFieldsRes, entityRes] = results + const [fieldsRes, 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, - ) + const fieldList = fieldsRes.data as PagedResponse + const entityData = (entityRes?.data ?? + null) as EntityWithMetadata | null + + // Map fields to custom metadata fields with required status + const fieldsForContentType: CustomMetadataField[] = + fieldList.results.map((meta) => { + const matchingModelField = meta.model_fields.find( + (mf) => mf.content_type === arg.entityContentType, + ) + return { + ...meta, + isRequiredFor: !!matchingModelField?.is_required_for.length, + metadataModelFieldId: matchingModelField + ? matchingModelField.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, + } + }) - return { data: mergedMetadata } + return { + data: sortBy(mergedMetadata, (m) => (m.isRequiredFor ? -1 : 1)), + } }, }), getMetadataField: builder.query< @@ -147,6 +172,25 @@ export const metadataService = service query: (query: Req['getMetadataList']) => ({ url: `metadata/fields/?${Utils.toParam(query)}`, }), + transformResponse: (res: Res['metadataList'], _, req) => + transformCorePaging(req, res), + }), + getProjectMetadataFieldList: builder.query< + Res['projectMetadataFieldList'], + Req['getProjectMetadataFieldList'] + >({ + providesTags: [{ id: 'LIST', type: 'Metadata' }], + query: (query: Req['getProjectMetadataFieldList']) => ({ + url: `projects/${query.project_id}/metadata/fields/?${Utils.toParam({ + ...(query.include_organisation + ? { include_organisation: true } + : {}), + page: query.page, + page_size: query.page_size, + })}`, + }), + transformResponse: (res: Res['projectMetadataFieldList'], _, req) => + transformCorePaging(req, res), }), updateMetadataField: builder.mutation< Res['metadataField'], @@ -229,6 +273,20 @@ export async function getEntityMetadataFields( metadataService.endpoints.getEntityMetadataFields.initiate(data, options), ) } +export async function getProjectMetadataFieldList( + store: any, + data: Req['getProjectMetadataFieldList'], + options?: Parameters< + typeof metadataService.endpoints.getProjectMetadataFieldList.initiate + >[1], +) { + return store.dispatch( + metadataService.endpoints.getProjectMetadataFieldList.initiate( + data, + options, + ), + ) +} // END OF FUNCTION_EXPORTS export const { @@ -237,6 +295,7 @@ export const { useGetEntityMetadataFieldsQuery, useGetMetadataFieldListQuery, useGetMetadataFieldQuery, + useGetProjectMetadataFieldListQuery, useUpdateMetadataFieldMutation, // END OF EXPORTS } = metadataService diff --git a/frontend/common/stores/project-store.js b/frontend/common/stores/project-store.js index af7150b57fc7..8e1ef28c380e 100644 --- a/frontend/common/stores/project-store.js +++ b/frontend/common/stores/project-store.js @@ -27,10 +27,12 @@ const controller = { ? data.post(`${Project.api}environments/${cloneId}/clone/`, { clone_feature_states_async: cloneFeatureStatesAsync, description, + metadata: metadata || [], name, }) : data.post(`${Project.api}environments/`, { description, + metadata: metadata || [], name, project: projectId, }) diff --git a/frontend/common/types/requests.ts b/frontend/common/types/requests.ts index 9a7329328831..fa1569a779fb 100644 --- a/frontend/common/types/requests.ts +++ b/frontend/common/types/requests.ts @@ -425,7 +425,11 @@ export type Req = { } } getMetadataField: { organisation_id: number } - getMetadataList: { organisation: number } + getMetadataList: PagedRequest<{ organisation: number }> + getProjectMetadataFieldList: PagedRequest<{ + project_id: number + include_organisation?: boolean + }> updateMetadataField: { id: number body: { @@ -433,6 +437,7 @@ export type Req = { type: string description: string organisation: number + project?: number | null } } deleteMetadataField: { id: number } @@ -442,6 +447,7 @@ export type Req = { name: string organisation: number type: string + project?: number | null } } diff --git a/frontend/common/types/responses.ts b/frontend/common/types/responses.ts index 951c0fdfd217..d73a95353bda 100644 --- a/frontend/common/types/responses.ts +++ b/frontend/common/types/responses.ts @@ -772,12 +772,20 @@ export type Metadata = { field_value: string } +export type MetadataFieldModelField = { + id: number + content_type: number + is_required_for: isRequiredFor[] +} + export type MetadataField = { id: number name: string type: string description: string organisation: number + project: number | null + model_fields: MetadataFieldModelField[] } export type ContentType = { @@ -789,6 +797,7 @@ export type ContentType = { export type isRequiredFor = { content_type: number + object_id: number } export type MetadataModelField = { @@ -1120,6 +1129,7 @@ export type Res = { metadataModelFieldList: PagedResponse metadataModelField: MetadataModelField metadataList: PagedResponse + projectMetadataFieldList: PagedResponse metadataField: MetadataField launchDarklyProjectImport: LaunchDarklyProjectImport launchDarklyProjectsImport: LaunchDarklyProjectImport[] diff --git a/frontend/common/utils/__tests__/mergeMetadataFields.test.ts b/frontend/common/utils/__tests__/mergeMetadataFields.test.ts deleted file mode 100644 index 1d23d15eab30..000000000000 --- a/frontend/common/utils/__tests__/mergeMetadataFields.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -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/mergeMetadataFields.ts b/frontend/common/utils/mergeMetadataFields.ts deleted file mode 100644 index fc9cf4ac5891..000000000000 --- a/frontend/common/utils/mergeMetadataFields.ts +++ /dev/null @@ -1,60 +0,0 @@ -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/web/components/metadata/AddMetadataToEntity.tsx b/frontend/web/components/metadata/AddMetadataToEntity.tsx index b657dc81dfb9..7b72911d7505 100644 --- a/frontend/web/components/metadata/AddMetadataToEntity.tsx +++ b/frontend/web/components/metadata/AddMetadataToEntity.tsx @@ -13,6 +13,7 @@ import { import { getStore } from 'common/store' import { CustomMetadataField } from 'common/types/metadata-field' import { useGlobalMetadataValidation } from 'common/utils/metadataValidation' +import RedirectCreateCustomFields from './RedirectCreateCustomFields' const EMPTY_FIELDS: CustomMetadataField[] = [] @@ -163,15 +164,11 @@ const AddMetadataToEntity: FC = ({ )} renderNoResults={ - No custom fields configured for {entity}s. Add custom fields in your{' '} - - Organisation Settings - - . + } /> diff --git a/frontend/web/components/metadata/ContentTypesMetadataFieldTable.tsx b/frontend/web/components/metadata/ContentTypesMetadataFieldTable.tsx index 8a652b0f816a..6c1a9d0debe8 100644 --- a/frontend/web/components/metadata/ContentTypesMetadataFieldTable.tsx +++ b/frontend/web/components/metadata/ContentTypesMetadataFieldTable.tsx @@ -5,7 +5,7 @@ import Button from 'components/base/forms/Button' import Switch from 'components/Switch' import Icon from 'components/Icon' -import { MetadataModelField } from 'common/types/responses' +import { MetadataFieldModelField } from 'common/types/responses' type selectedContentType = { label: string value: string @@ -13,12 +13,12 @@ type selectedContentType = { } type ContentTypesMetadataFieldTableType = { - organisationId: string + organisationId: number selectedContentTypes: selectedContentType[] onDelete: (removed: selectedContentType) => void isEdit: boolean changeMetadataRequired: (value: string, isRequired: boolean) => void - metadataModelFieldList: MetadataModelField[] + metadataModelFieldList: MetadataFieldModelField[] } type ContentTypesMetadataRowBase = Omit< diff --git a/frontend/web/components/metadata/ContentTypesValues.tsx b/frontend/web/components/metadata/ContentTypesValues.tsx index d05c08b186d0..65ad0111e2d9 100644 --- a/frontend/web/components/metadata/ContentTypesValues.tsx +++ b/frontend/web/components/metadata/ContentTypesValues.tsx @@ -1,11 +1,11 @@ import React, { FC } from 'react' import { useGetSupportedContentTypeQuery } from 'common/services/useSupportedContentType' -import { MetadataModelField } from 'common/types/responses' +import { MetadataFieldModelField } from 'common/types/responses' import classNames from 'classnames' type ContentTypesValuesType = { - contentTypes: MetadataModelField[] - organisationId: string + contentTypes: MetadataFieldModelField[] + organisationId: number } const ContentTypesValues: FC = ({ @@ -13,7 +13,7 @@ const ContentTypesValues: FC = ({ organisationId, }) => { const { data: supportedContentTypes } = useGetSupportedContentTypeQuery({ - organisation_id: `${organisationId}`, + organisation_id: organisationId, }) const combinedData = contentTypes.map((contentType) => { diff --git a/frontend/web/components/metadata/MetadataPage.tsx b/frontend/web/components/metadata/MetadataPage.tsx index 5c4522bfc135..afd607db1441 100644 --- a/frontend/web/components/metadata/MetadataPage.tsx +++ b/frontend/web/components/metadata/MetadataPage.tsx @@ -1,77 +1,82 @@ -import React, { FC, useMemo } from 'react' +import React, { FC, useState } from 'react' import Button from 'components/base/forms/Button' import PanelSearch from 'components/PanelSearch' import Icon from 'components/Icon' import Panel from 'components/base/grid/Panel' import CreateMetadataField from 'components/modals/CreateMetadataField' import ContentTypesValues from './ContentTypesValues' -import { MetadataModelField } from 'common/types/responses' +import { MetadataFieldModelField } from 'common/types/responses' import { useGetMetadataFieldListQuery, + useGetProjectMetadataFieldListQuery, useDeleteMetadataFieldMutation, } from 'common/services/useMetadataField' -import { useGetMetadataModelFieldListQuery } from 'common/services/useMetadataModelField' import PlanBasedBanner from 'components/PlanBasedAccess' +import RedirectCreateCustomFields from './RedirectCreateCustomFields' +const PAGE_SIZE = 20 const metadataWidth = [200, 150, 150, 90] type MetadataPageType = { - organisationId: string + organisationId: number + projectId?: number } type MergeMetadata = { - content_type_fields: MetadataModelField[] + model_fields: MetadataFieldModelField[] id: number name: string type: string description: string organisation: number + project: number | null } -const MetadataPage: FC = ({ organisationId }) => { - const { data: metadataFieldList } = useGetMetadataFieldListQuery({ +const MetadataPage: FC = ({ organisationId, projectId }) => { + const [orgPage, setOrgPage] = useState(1) + const [projectPage, setProjectPage] = useState(1) + + const { data: orgMetadataFieldList } = useGetMetadataFieldListQuery({ organisation: organisationId, + page: orgPage, + page_size: PAGE_SIZE, }) - const { data: MetadataModelFieldList } = useGetMetadataModelFieldListQuery({ - organisation_id: organisationId, - }) + const { data: projectMetadataFieldList } = + useGetProjectMetadataFieldListQuery( + { + page: projectPage, + page_size: PAGE_SIZE, + project_id: projectId!, + }, + { skip: !projectId }, + ) const [deleteMetadata] = useDeleteMetadataFieldMutation() - const mergeMetadata = useMemo(() => { - if (metadataFieldList && MetadataModelFieldList) { - return metadataFieldList.results - .map((item1) => { - const matchingItems2 = MetadataModelFieldList.results.filter( - (item2) => item2.field === item1.id, - ) - return { - ...item1, - content_type_fields: matchingItems2, - } - }) - ?.sort((a, b) => a.id - b.id) - } - return null - }, [metadataFieldList, MetadataModelFieldList]) + const orgFields = orgMetadataFieldList?.results ?? undefined + const projectFields = projectMetadataFieldList?.results ?? undefined const metadataCreatedToast = () => { toast('Custom Field Created') closeModal() } - const createMetadataField = () => { + const openCreateMetadataField = () => { openModal( `Create Custom Field`, , 'side-modal create-feature-modal', ) } - const editMetadata = (id: string, contentTypeList: MetadataModelField[]) => { + const editMetadata = ( + id: string, + contentTypeList: MetadataFieldModelField[], + ) => { openModal( `Edit Custom Field`, = ({ organisationId }) => { toast('Custom Field Updated') }} organisationId={organisationId} + projectId={projectId} />, 'side-modal create-feature-modal', ) @@ -104,13 +110,143 @@ const MetadataPage: FC = ({ organisationId }) => { }) } + const renderFieldRow = ( + metadata: MergeMetadata, + { readOnly }: { readOnly: boolean }, + ) => { + const handleEdit = () => + editMetadata(`${metadata.id}`, metadata.model_fields) + + return ( + + +
+ {metadata.name} + {readOnly && Inherited} +
+ +
+ {!readOnly && ( +
+ +
+ )} +
+ ) + } + + const renderTableHeader = ({ showRemove }: { showRemove: boolean }) => ( + + Name + {showRemove && ( +
+ Remove +
+ )} +
+ ) + + if (projectId) { + return ( + + + +
Custom Fields
+
+ +
+

+ Manage project-level custom fields and view inherited organisation + fields.{' '} + +

+ + +
Organisation Fields
+ + renderFieldRow(metadata, { readOnly: true }) + } + paging={orgMetadataFieldList} + nextPage={() => setOrgPage(orgPage + 1)} + prevPage={() => setOrgPage(orgPage - 1)} + goToPage={(p: number) => setOrgPage(p)} + renderNoResults={ +
+ + + +
+ } + /> +
+ + +
Project Fields
+ + renderFieldRow(metadata, { readOnly: false }) + } + paging={projectMetadataFieldList} + nextPage={() => setProjectPage(projectPage + 1)} + prevPage={() => setProjectPage(projectPage - 1)} + goToPage={(p: number) => setProjectPage(p)} + renderNoResults={ +
+ + No project-level custom fields configured. + +
+ } + /> +
+
+ ) + } + return (
Custom Fields
-
@@ -128,46 +264,15 @@ const MetadataPage: FC = ({ organisationId }) => { - Name -
- Remove -
- + items={orgFields} + header={renderTableHeader({ showRemove: true })} + renderRow={(metadata: MergeMetadata) => + renderFieldRow(metadata, { readOnly: false }) } - renderRow={(metadata) => ( - { - editMetadata(`${metadata.id}`, metadata.content_type_fields) - }} - > - -
{metadata.name}
- -
-
- -
-
- )} + paging={orgMetadataFieldList} + nextPage={() => setOrgPage(orgPage + 1)} + prevPage={() => setOrgPage(orgPage - 1)} + goToPage={(p: number) => setOrgPage(p)} renderNoResults={
diff --git a/frontend/web/components/metadata/RedirectCreateCustomFields.tsx b/frontend/web/components/metadata/RedirectCreateCustomFields.tsx new file mode 100644 index 000000000000..d5ddd38fc6d2 --- /dev/null +++ b/frontend/web/components/metadata/RedirectCreateCustomFields.tsx @@ -0,0 +1,44 @@ +import React, { FC } from 'react' + +type RedirectCreateCustomFieldsProps = { + organisationId: number + projectId?: number + organisationOnly: boolean +} + +const RedirectCreateCustomFields: FC = ({ + organisationId, + organisationOnly, + projectId, +}) => { + const orgLink = `/organisation/${organisationId}/settings?tab=custom-fields` + const projectLink = `/project/${projectId}/settings?tab=custom-fields` + + if (organisationOnly) { + return ( + + You can create Organisation Custom Fields in your{' '} + + Organisation Settings + + . + + ) + } + + return ( + + You can create Custom Fields in your{' '} + + Organisation Settings + {' '} + or{' '} + + Project Settings + + . + + ) +} + +export default RedirectCreateCustomFields diff --git a/frontend/web/components/metadata/SupportedContentTypesSelect.tsx b/frontend/web/components/metadata/SupportedContentTypesSelect.tsx index f34e80502f65..e89561814f1c 100644 --- a/frontend/web/components/metadata/SupportedContentTypesSelect.tsx +++ b/frontend/web/components/metadata/SupportedContentTypesSelect.tsx @@ -1,14 +1,14 @@ import React, { FC, useEffect, useState } from 'react' import { useGetSupportedContentTypeQuery } from 'common/services/useSupportedContentType' -import { ContentType, MetadataModelField } from 'common/types/responses' +import { ContentType, MetadataFieldModelField } from 'common/types/responses' import InputGroup from 'components/base/forms/InputGroup' import ContentTypesMetadataFieldTable from './ContentTypesMetadataFieldTable' type SupportedContentTypesSelectType = { - organisationId: string + organisationId: number isEdit: boolean getMetadataContentTypes: (m: SelectContentTypesType[]) => void - metadataModelFieldList: MetadataModelField[] + metadataModelFieldList: MetadataFieldModelField[] } export type SelectContentTypesType = { diff --git a/frontend/web/components/modals/CreateMetadataField.tsx b/frontend/web/components/modals/CreateMetadataField.tsx index d23faaaebb48..44c24fd6ea43 100644 --- a/frontend/web/components/modals/CreateMetadataField.tsx +++ b/frontend/web/components/modals/CreateMetadataField.tsx @@ -7,10 +7,12 @@ import SupportedContentTypesSelect, { } from 'components/metadata/SupportedContentTypesSelect' import { + metadataService, useCreateMetadataFieldMutation, useGetMetadataFieldQuery, useUpdateMetadataFieldMutation, } from 'common/services/useMetadataField' +import { getStore } from 'common/store' import { useGetSupportedContentTypeQuery } from 'common/services/useSupportedContentType' @@ -21,7 +23,7 @@ import { } from 'common/services/useMetadataModelField' import { ContentType, - MetadataModelField, + MetadataFieldModelField, isRequiredFor, } from 'common/types/responses' import ErrorMessage from 'components/ErrorMessage' @@ -29,17 +31,22 @@ import ErrorMessage from 'components/ErrorMessage' type CreateMetadataFieldType = { id?: string isEdit: boolean - metadataModelFieldList?: MetadataModelField[] + metadataModelFieldList?: MetadataFieldModelField[] onComplete?: () => void - organisationId: string + organisationId: number + projectId?: number } -type QueryBody = Omit +type QueryBody = { + content_type: number + field: number + is_required_for: isRequiredFor[] +} type Query = { body: QueryBody id?: number - organisation_id: string + organisation_id: number } type MetadataType = { @@ -48,7 +55,8 @@ type MetadataType = { label: string } -type metadataFieldUpdatedSelectListType = MetadataModelField & { +type metadataFieldUpdatedSelectListType = MetadataFieldModelField & { + field: number removed: boolean new: boolean } @@ -64,6 +72,7 @@ const CreateMetadataField: FC = ({ metadataModelFieldList, onComplete, organisationId, + projectId, }) => { const metadataTypes: MetadataType[] = [ { id: 1, label: 'int', value: 'int' }, @@ -78,14 +87,11 @@ const CreateMetadataField: FC = ({ ) const { data: supportedContentTypes } = useGetSupportedContentTypeQuery({ - organisation_id: `${organisationId}`, + organisation_id: organisationId, }) - const [ - createMetadataField, - { error: errorCreating, isLoading: creating, isSuccess: created }, - ] = useCreateMetadataFieldMutation() - const [updateMetadataField, { isLoading: updating, isSuccess: updated }] = - useUpdateMetadataFieldMutation() + const [createMetadataField, { error: errorCreating }] = + useCreateMetadataFieldMutation() + const [updateMetadataField] = useUpdateMetadataFieldMutation() const [createMetadataModelField] = useCreateMetadataModelFieldMutation() const [updateMetadataModelField] = useUpdateMetadataModelFieldMutation() @@ -96,7 +102,9 @@ const CreateMetadataField: FC = ({ Utils.getContentType( supportedContentTypes, 'model', - MetadataContentType.ORGANISATION, + projectId + ? MetadataContentType.PROJECT + : MetadataContentType.ORGANISATION, ) useEffect(() => { @@ -113,20 +121,6 @@ const CreateMetadataField: FC = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [data, isLoading]) - useEffect(() => { - if (!updating && updated) { - onComplete?.() - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [updating, updated]) - - useEffect(() => { - if (created && !creating) { - onComplete?.() - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [creating, created]) - const [typeValue, setTypeValue] = useState() const [name, setName] = useState('') const [description, setDescription] = useState('') @@ -137,7 +131,7 @@ const CreateMetadataField: FC = ({ useState([]) const generateDataQuery = ( - contentType: string | number, + contentType: number, field: number, isRequiredFor: boolean, id: number, @@ -151,7 +145,7 @@ const CreateMetadataField: FC = ({ ? ([ { content_type: metadataContentType.id, - object_id: parseInt(organisationId), + object_id: projectId ?? organisationId, } as isRequiredFor, ] as isRequiredFor[]) : [], @@ -167,82 +161,92 @@ const CreateMetadataField: FC = ({ return query } - const save = () => { - if (isEdit) { - updateMetadataField({ - body: { - description, - name, - organisation: organisationId, - type: `${typeValue?.value}`, - }, - id: id!, - }).then(() => { + const save = async () => { + try { + if (isEdit) { + await updateMetadataField({ + body: { + description, + name, + organisation: organisationId, + type: `${typeValue?.value}`, + ...(projectId ? { project: projectId } : {}), + }, + id: id!, + }).unwrap() if (metadataFieldSelectList.length) { - Promise.all( + await Promise.all( metadataFieldSelectList.map(async (m) => { const query = generateDataQuery( - m.value, + Number(m.value), parseInt(id!), !!m?.isRequired, 0, true, ) - await createMetadataModelField(query) + await createMetadataModelField(query).unwrap() }), ) } if (metadataUpdatedSelectList.length) { - Promise.all( + await Promise.all( metadataUpdatedSelectList?.map( async (m: metadataFieldUpdatedSelectListType) => { const query = generateDataQuery( m.content_type, m.field, !!m.is_required_for, - parseInt(m.id), + m.id, m.new, ) if (!m.removed && !m.new) { - await updateMetadataModelField(query) + await updateMetadataModelField(query).unwrap() } else if (m.removed) { await deleteMetadataModelField({ id: m.id, organisation_id: organisationId, - }) + }).unwrap() } else if (m.new) { const newQuery = { ...query } delete newQuery.id - await createMetadataModelField(newQuery) + await createMetadataModelField(newQuery).unwrap() } }, ), ) } - closeModal() - }) - } else { - createMetadataField({ - body: { - description, - name, - organisation: organisationId, - type: `${typeValue?.value}`, - }, - }).then((res) => { - Promise.all( - metadataFieldSelectList.map(async (m) => { - const query = generateDataQuery( - m.value, - res?.data.id, - !!m?.isRequired, - 0, - true, - ) - await createMetadataModelField(query) - }), - ) - }) + } else { + const res = await createMetadataField({ + body: { + description, + name, + organisation: organisationId, + type: `${typeValue?.value}`, + ...(projectId ? { project: projectId } : {}), + }, + }).unwrap() + if (res?.id) { + await Promise.all( + metadataFieldSelectList.map(async (m) => { + const query = generateDataQuery( + Number(m.value), + res.id, + !!m?.isRequired, + 0, + true, + ) + await createMetadataModelField(query).unwrap() + }), + ) + } + } + getStore().dispatch( + metadataService.util.invalidateTags([{ type: 'Metadata' }]), + ) + onComplete?.() + closeModal() + } catch (e) { + toast('Failed to save custom field', 'danger') } } @@ -313,12 +317,14 @@ const CreateMetadataField: FC = ({ if (isRequiredLength !== isRequired) { newMetadataFieldArray.push({ ...item1, + field: parseInt(id!), is_required_for: isRequired, }) } } else { newMetadataFieldArray.push({ ...item1, + field: parseInt(id!), new: false, removed: true, }) @@ -331,6 +337,7 @@ const CreateMetadataField: FC = ({ newMetadataFieldArray.push({ ...item1, content_type: item.value, + field: parseInt(id!), is_required_for: m?.isRequired, new: true, removed: false, diff --git a/frontend/web/components/pages/project-settings/ProjectSettingsPage.tsx b/frontend/web/components/pages/project-settings/ProjectSettingsPage.tsx index 0c1833265591..5e0c453e904f 100644 --- a/frontend/web/components/pages/project-settings/ProjectSettingsPage.tsx +++ b/frontend/web/components/pages/project-settings/ProjectSettingsPage.tsx @@ -111,7 +111,12 @@ const ProjectSettingsPage = () => { label: 'Permissions', }, { - component: , + component: ( + + ), isVisible: true, key: 'custom-fields', label: 'Custom Fields', diff --git a/frontend/web/components/pages/project-settings/tabs/CustomFieldsTab.tsx b/frontend/web/components/pages/project-settings/tabs/CustomFieldsTab.tsx index 864d87769c21..0b7b97203b75 100644 --- a/frontend/web/components/pages/project-settings/tabs/CustomFieldsTab.tsx +++ b/frontend/web/components/pages/project-settings/tabs/CustomFieldsTab.tsx @@ -1,12 +1,16 @@ import InfoMessage from 'components/InfoMessage' -import WarningMessage from 'components/WarningMessage' +import MetadataPage from 'components/metadata/MetadataPage' import React from 'react' type CustomFieldsTabProps = { organisationId: number + projectId: number } -export const CustomFieldsTab = ({ organisationId }: CustomFieldsTabProps) => { +export const CustomFieldsTab = ({ + organisationId, + projectId, +}: CustomFieldsTabProps) => { if (!organisationId) { return (
@@ -17,22 +21,7 @@ export const CustomFieldsTab = ({ organisationId }: CustomFieldsTabProps) => { return (
-
Custom Fields
- - - Custom fields have been moved to{' '} - - Organisation Settings - - . - - } - /> +
) }