diff --git a/docs/docs/basic-features/managing-features.md b/docs/docs/basic-features/managing-features.md index 0cfafceef519..00cf7184ed91 100644 --- a/docs/docs/basic-features/managing-features.md +++ b/docs/docs/basic-features/managing-features.md @@ -98,3 +98,15 @@ other Environments within the Project. ### Multi-Variate Flag Use Cases The primary use case for using Multi-Variate flags is to drive [A/B tests](/advanced-use/ab-testing.md). + +### Use Metadata + +When creating or updating a feature, you can add Metadata if you was created Metadata Fields in Project Settings -> +Metadata. + +You can add the Metadata in the Feature Setting Tab. + +If you have metadata for features, a list of fields that can be filled, saved, and will be stored with the feature's +save flag will be displayed. + +![Image](/img/metadata/metadata-feature-1.png) diff --git a/docs/docs/basic-features/segments.md b/docs/docs/basic-features/segments.md index 3b92a69a7f99..0acc880b8e84 100644 --- a/docs/docs/basic-features/segments.md +++ b/docs/docs/basic-features/segments.md @@ -248,3 +248,17 @@ These are the default limits for segments and rules: - 1000 bytes per segment rule value See the [documentation on System Limits](system-administration/system-limits.md) for more details. + +## Use Metadata + +When creating or updating a feature, you can add Metadata if you was created Metadata Fields in Project Settings -> +Metadata. + +You can add the Metadata value in the Feature Setting Tab. + +When creating or updating a segment, you can add Metadata. You can add the Metadata value in the Settings Setting Tab. + +If you have metadata for features, it will create a list of fields that can be filled, saved, and will be stored with +the feature's save flag. + +![Image](/img/metadata/metadata-segment-1.png) diff --git a/docs/docs/system-administration/environment-settings.md b/docs/docs/system-administration/environment-settings.md index fd7b6a9cef70..9b7b60fed0d5 100644 --- a/docs/docs/system-administration/environment-settings.md +++ b/docs/docs/system-administration/environment-settings.md @@ -16,3 +16,13 @@ This will result in identities receiving different variations and being evaluate split operator) when evaluated against the remote API. Evaluations in local evaluation mode will not be affected. ::: + +## Use Metadata + +When creating or updating a segment, you can enhance its information by adding previously created and enabled metadata +fields. To create and enable this you can do it in the metadata tab on the project settings page. + +If you have metadata for features, it will create a list of fields that can be filled, saved, and will be stored with +the feature's save flag. + +![Image](/img/metadata/metadata-environment.png) diff --git a/docs/docs/system-administration/metadata/metadata.md b/docs/docs/system-administration/metadata/metadata.md new file mode 100644 index 000000000000..d0bdd1c9ce5d --- /dev/null +++ b/docs/docs/system-administration/metadata/metadata.md @@ -0,0 +1,42 @@ +--- +title: Metadata +sidebar_position: 110 +--- + +Flagsmith allows certain Entities within a Project to have Metadata of different types. + +## Core Entities that support Metadata + +- **[Features](/basic-features/managing-features#use-metadata)**. +- **[Environment](/system-administration/environment-settings#use-metadata)**. +- **[Segments](/basic-features/segments#use-metadata)**. + +## Metadata Fields + +To be able to add Metadata to your Entities, you first need to create Metadata fields within Project Settings -> +Metadata. + +Here you'll also need to define whether it's optional or required. + +- **Optional**: You may or may not add Metadata to your Entities. +- **Required**: You won't be able to update or create an Entity within your Project unless you include this Metadata. + +![Image](/img/metadata/metadata-fields.png) + +### Types of Metadata Field + +Metadata Field supports five primary types of metadata values, each serving distinct purposes: + +**String**: A basic data type representing text or alphanumeric characters. Strings are versatile and can describe a +wide range of attributes or characteristics. + +**URL**: A type specifically designed to store web addresses or Uniform Resource Locators. + +**Integer**: A numeric data type representing whole numbers without decimal points. Integers are useful for quantifiable +properties or attributes. + +**Multiline String**: Similar to a standard string but capable of storing multiline text. Multiline strings are +beneficial for longer descriptions or content blocks. + +**Boolean**: A data type with only two possible values: true or false. Booleans are ideal for representing binary +attributes or conditions. diff --git a/docs/static/img/metadata/metadata-environment.png b/docs/static/img/metadata/metadata-environment.png new file mode 100644 index 000000000000..5dc111311707 Binary files /dev/null and b/docs/static/img/metadata/metadata-environment.png differ diff --git a/docs/static/img/metadata/metadata-feature-1.png b/docs/static/img/metadata/metadata-feature-1.png new file mode 100644 index 000000000000..20983012a5cd Binary files /dev/null and b/docs/static/img/metadata/metadata-feature-1.png differ diff --git a/docs/static/img/metadata/metadata-fields.png b/docs/static/img/metadata/metadata-fields.png new file mode 100644 index 000000000000..f7a3a6080629 Binary files /dev/null and b/docs/static/img/metadata/metadata-fields.png differ diff --git a/docs/static/img/metadata/metadata-segment-1.png b/docs/static/img/metadata/metadata-segment-1.png new file mode 100644 index 000000000000..9ef0c6465a23 Binary files /dev/null and b/docs/static/img/metadata/metadata-segment-1.png differ diff --git a/frontend/common/constants.ts b/frontend/common/constants.ts index aac41d387a82..9b81a859db23 100644 --- a/frontend/common/constants.ts +++ b/frontend/common/constants.ts @@ -500,6 +500,7 @@ export default { 'Set different values for your feature based on what segments users are in. Identity overrides will take priority over any segment override.', TAGS_DESCRIPTION: 'Organise your flags with tags, tagging your features as "protected" will prevent them from accidentally being deleted.', + TOOLTIP_METADATA_DESCRIPTION: 'Add metadata in your', USER_PROPERTY_DESCRIPTION: 'The name of the user trait or custom property belonging to the user, e.g. firstName', WEBHOOKS_DESCRIPTION: diff --git a/frontend/common/services/useEnvironment.ts b/frontend/common/services/useEnvironment.ts index e40d7e747bd1..1a493dabaf8a 100644 --- a/frontend/common/services/useEnvironment.ts +++ b/frontend/common/services/useEnvironment.ts @@ -21,6 +21,20 @@ export const environmentService = service url: `environments/?project=${data.projectId}`, }), }), + updateEnvironment: builder.mutation< + Res['environment'], + Req['updateEnvironment'] + >({ + invalidatesTags: (res) => [ + { id: 'LIST', type: 'Environment' }, + { id: res?.id, type: 'Environment' }, + ], + query: (query: Req['updateEnvironment']) => ({ + body: query.body, + method: 'PUT', + url: `environments/${query.id}/`, + }), + }), // END OF ENDPOINTS }), }) @@ -47,11 +61,23 @@ export async function getEnvironment( environmentService.endpoints.getEnvironment.initiate(data, options), ) } +export async function updateEnvironment( + store: any, + data: Req['updateEnvironment'], + options?: Parameters< + typeof environmentService.endpoints.updateEnvironment.initiate + >[1], +) { + return store.dispatch( + environmentService.endpoints.updateEnvironment.initiate(data, options), + ) +} // END OF FUNCTION_EXPORTS export const { useGetEnvironmentQuery, useGetEnvironmentsQuery, + useUpdateEnvironmentMutation, // END OF EXPORTS } = environmentService diff --git a/frontend/common/services/useMetadataField.ts b/frontend/common/services/useMetadataField.ts new file mode 100644 index 000000000000..e33711b000bd --- /dev/null +++ b/frontend/common/services/useMetadataField.ts @@ -0,0 +1,137 @@ +import { Res } from 'common/types/responses' +import { Req } from 'common/types/requests' +import { service } from 'common/service' +import Utils from 'common/utils/utils' + +export const metadataService = service + .enhanceEndpoints({ addTagTypes: ['Metadata'] }) + .injectEndpoints({ + endpoints: (builder) => ({ + createMetadataField: builder.mutation< + Res['metadataField'], + Req['createMetadataField'] + >({ + invalidatesTags: [{ id: 'LIST', type: 'Metadata' }], + query: (query: Req['createMetadataField']) => ({ + body: query.body, + method: 'POST', + url: `metadata/fields/`, + }), + }), + deleteMetadataField: builder.mutation< + Res['metadataField'], + Req['deleteMetadataField'] + >({ + invalidatesTags: [{ id: 'LIST', type: 'Metadata' }], + query: (query: Req['deleteMetadataField']) => ({ + method: 'DELETE', + url: `metadata/fields/${query.id}/`, + }), + }), + getMetadataField: builder.query< + Res['metadataField'], + Req['getMetadataField'] + >({ + providesTags: (res) => [{ id: res?.id, type: 'Metadata' }], + query: (query: Req['getMetadataField']) => ({ + url: `metadata/fields/${query.organisation_id}/`, + }), + }), + getMetadataFieldList: builder.query< + Res['metadataList'], + Req['getMetadataList'] + >({ + providesTags: [{ id: 'LIST', type: 'Metadata' }], + query: (query: Req['getMetadataList']) => ({ + url: `metadata/fields/?${Utils.toParam(query)}`, + }), + }), + updateMetadataField: builder.mutation< + Res['metadataField'], + Req['updateMetadataField'] + >({ + invalidatesTags: (res) => [ + { id: 'LIST', type: 'Metadata' }, + { id: res?.id, type: 'Metadata' }, + ], + query: (query: Req['updateMetadataField']) => ({ + body: query.body, + method: 'PUT', + url: `metadata/fields/${query.id}/`, + }), + }), + // END OF ENDPOINTS + }), + }) + +export async function createMetadataField( + store: any, + data: Req['createMetadataField'], + options?: Parameters< + typeof metadataService.endpoints.createMetadataField.initiate + >[1], +) { + return store.dispatch( + metadataService.endpoints.createMetadataField.initiate(data, options), + ) +} +export async function deleteMetadataField( + store: any, + data: Req['deleteMetadataField'], + options?: Parameters< + typeof metadataService.endpoints.deleteMetadataField.initiate + >[1], +) { + return store.dispatch( + metadataService.endpoints.deleteMetadataField.initiate(data, options), + ) +} +export async function getMetadata( + store: any, + data: Req['getMetadataField'], + options?: Parameters< + typeof metadataService.endpoints.getMetadataField.initiate + >[1], +) { + return store.dispatch( + metadataService.endpoints.getMetadataField.initiate(data, options), + ) +} +export async function getMetadataList( + store: any, + data: Req['getMetadataList'], + options?: Parameters< + typeof metadataService.endpoints.getMetadataFieldList.initiate + >[1], +) { + return store.dispatch( + metadataService.endpoints.getMetadataFieldList.initiate(data, options), + ) +} +export async function updateMetadata( + store: any, + data: Req['updateMetadataField'], + options?: Parameters< + typeof metadataService.endpoints.updateMetadataField.initiate + >[1], +) { + return store.dispatch( + metadataService.endpoints.updateMetadataField.initiate(data, options), + ) +} +// END OF FUNCTION_EXPORTS + +export const { + useCreateMetadataFieldMutation, + useDeleteMetadataFieldMutation, + useGetMetadataFieldListQuery, + useGetMetadataFieldQuery, + useUpdateMetadataFieldMutation, + // END OF EXPORTS +} = metadataService + +/* Usage examples: +const { data, isLoading } = useGetMetadataFieldQuery({ id: 2 }, {}) //get hook +const [createMetadataField, { isLoading, data, isSuccess }] = useCreateMetadataFieldMutation() //create hook +metadataService.endpoints.getMetadataField.select({id: 2})(store.getState()) //access data from any function +*/ diff --git a/frontend/common/services/useMetadataModelField.ts b/frontend/common/services/useMetadataModelField.ts new file mode 100644 index 000000000000..f1051865975e --- /dev/null +++ b/frontend/common/services/useMetadataModelField.ts @@ -0,0 +1,151 @@ +import { Res } from 'common/types/responses' +import { Req } from 'common/types/requests' +import { service } from 'common/service' + +export const metadataModelFieldService = service + .enhanceEndpoints({ addTagTypes: ['MetadataModelField'] }) + .injectEndpoints({ + endpoints: (builder) => ({ + createMetadataModelField: builder.mutation< + Res['metadataModelField'], + Req['createMetadataModelField'] + >({ + invalidatesTags: [{ id: 'LIST', type: 'MetadataModelField' }], + query: (query: Req['createMetadataModelField']) => ({ + body: query.body, + method: 'POST', + url: `organisations/${query.organisation_id}/metadata-model-fields/`, + }), + }), + deleteMetadataModelField: builder.mutation< + Res['metadataModelField'], + Req['deleteMetadataModelField'] + >({ + invalidatesTags: [{ id: 'LIST', type: 'MetadataModelField' }], + query: (query: Req['deleteMetadataModelField']) => ({ + method: 'DELETE', + url: `organisations/${query.organisation_id}/metadata-model-fields/${query.id}/`, + }), + }), + getMetadataModelField: builder.query< + Res['metadataModelField'], + Req['getMetadataModelField'] + >({ + providesTags: (res) => [{ id: res?.id, type: 'MetadataModelField' }], + query: (query: Req['getMetadataModelField']) => ({ + url: `organisations/${query.organisation_id}/metadata-model-fields/${query.id}/`, + }), + }), + getMetadataModelFieldList: builder.query< + Res['metadataModelFieldList'], + Req['getMetadataModelFields'] + >({ + providesTags: [{ id: 'LIST', type: 'MetadataModelField' }], + query: (query: Req['getMetadataModelFields']) => ({ + url: `organisations/${query.organisation_id}/metadata-model-fields/`, + }), + }), + updateMetadataModelField: builder.mutation< + Res['metadataModelField'], + Req['updateMetadataModelField'] + >({ + invalidatesTags: (res) => [ + { id: 'LIST', type: 'MetadataModelField' }, + { id: res?.id, type: 'MetadataModelField' }, + ], + query: (query: Req['updateMetadataModelField']) => ({ + body: query.body, + method: 'PUT', + url: `organisations/${query.organisation_id}/metadata-model-fields/${query.id}/`, + }), + }), + // END OF ENDPOINTS + }), + }) + +export async function createMetadataModelField( + store: any, + data: Req['createMetadataModelField'], + options?: Parameters< + typeof metadataModelFieldService.endpoints.createMetadataModelField.initiate + >[1], +) { + return store.dispatch( + metadataModelFieldService.endpoints.createMetadataModelField.initiate( + data, + options, + ), + ) +} +export async function deleteMetadataModelField( + store: any, + data: Req['deleteMetadataModelField'], + options?: Parameters< + typeof metadataModelFieldService.endpoints.deleteMetadataModelField.initiate + >[1], +) { + return store.dispatch( + metadataModelFieldService.endpoints.deleteMetadataModelField.initiate( + data, + options, + ), + ) +} +export async function getMetadataModelField( + store: any, + data: Req['getMetadataModelField'], + options?: Parameters< + typeof metadataModelFieldService.endpoints.getMetadataModelField.initiate + >[1], +) { + return store.dispatch( + metadataModelFieldService.endpoints.getMetadataModelField.initiate( + data, + options, + ), + ) +} +export async function getMetadataModelFieldList( + store: any, + data: Req['getMetadataModelFields'], + options?: Parameters< + typeof metadataModelFieldService.endpoints.getMetadataModelFieldList.initiate + >[1], +) { + return store.dispatch( + metadataModelFieldService.endpoints.getMetadataModelFieldList.initiate( + data, + options, + ), + ) +} +export async function updateMetadataModelField( + store: any, + data: Req['updateMetadataModelField'], + options?: Parameters< + typeof metadataModelFieldService.endpoints.updateMetadataModelField.initiate + >[1], +) { + return store.dispatch( + metadataModelFieldService.endpoints.updateMetadataModelField.initiate( + data, + options, + ), + ) +} +// END OF FUNCTION_EXPORTS + +export const { + useCreateMetadataModelFieldMutation, + useDeleteMetadataModelFieldMutation, + useGetMetadataModelFieldListQuery, + useGetMetadataModelFieldQuery, + useUpdateMetadataModelFieldMutation, + // END OF EXPORTS +} = metadataModelFieldService + +/* Usage examples: +const { data, isLoading } = useGetMetadataModelFieldQuery({ id: 2 }, {}) //get hook +const [createMetadataModelField, { isLoading, data, isSuccess }] = useCreateMetadataModelFieldMutation() //create hook +metadataModelFieldService.endpoints.getMetadataModelField.select({id: 2})(store.getState()) //access data from any function +*/ diff --git a/frontend/common/services/useProjectFlag.ts b/frontend/common/services/useProjectFlag.ts index 41f7f4547e11..f5e6f6dc35ff 100644 --- a/frontend/common/services/useProjectFlag.ts +++ b/frontend/common/services/useProjectFlag.ts @@ -1,8 +1,6 @@ import { PagedResponse, ProjectFlag, Res } from 'common/types/responses' import { Req } from 'common/types/requests' import { service } from 'common/service' -import data from 'common/data/base/_data' -import { BaseQueryFn } from '@reduxjs/toolkit/query' import Utils from 'common/utils/utils' function recursivePageGet( @@ -33,6 +31,17 @@ export const projectFlagService = service .enhanceEndpoints({ addTagTypes: ['ProjectFlag'] }) .injectEndpoints({ endpoints: (builder) => ({ + createProjectFlag: builder.mutation< + Res['projectFlag'], + Req['createProjectFlag'] + >({ + invalidatesTags: [{ id: 'LIST', type: 'ProjectFlag' }], + query: (query: Req['createProjectFlag']) => ({ + body: query.body, + method: 'POST', + url: `projects/${query.project_id}/features/`, + }), + }), getProjectFlag: builder.query({ providesTags: (res) => [{ id: res?.id, type: 'ProjectFlag' }], query: (query: Req['getProjectFlag']) => ({ @@ -57,6 +66,20 @@ export const projectFlagService = service ) }, }), + updateProjectFlag: builder.mutation< + Res['projectFlag'], + Req['updateProjectFlag'] + >({ + invalidatesTags: (res) => [ + { id: 'LIST', type: 'ProjectFlag' }, + { id: res?.id, type: 'ProjectFlag' }, + ], + query: (query: Req['updateProjectFlag']) => ({ + body: query.body, + method: 'PUT', + url: `projects/${query.project_id}/features/${query.feature_id}/`, + }), + }), // END OF ENDPOINTS }), }) @@ -83,11 +106,35 @@ export async function getProjectFlag( projectFlagService.endpoints.getProjectFlag.initiate(data, options), ) } +export async function updateProjectFlag( + store: any, + data: Req['updateProjectFlag'], + options?: Parameters< + typeof projectFlagService.endpoints.updateProjectFlag.initiate + >[1], +) { + return store.dispatch( + projectFlagService.endpoints.updateProjectFlag.initiate(data, options), + ) +} +export async function createProjectFlag( + store: any, + data: Req['createProjectFlag'], + options?: Parameters< + typeof projectFlagService.endpoints.createProjectFlag.initiate + >[1], +) { + return store.dispatch( + projectFlagService.endpoints.createProjectFlag.initiate(data, options), + ) +} // END OF FUNCTION_EXPORTS export const { + useCreateProjectFlagMutation, useGetProjectFlagQuery, useGetProjectFlagsQuery, + useUpdateProjectFlagMutation, // END OF EXPORTS } = projectFlagService diff --git a/frontend/common/services/useSupportedContentType.ts b/frontend/common/services/useSupportedContentType.ts new file mode 100644 index 000000000000..00fe24a060b9 --- /dev/null +++ b/frontend/common/services/useSupportedContentType.ts @@ -0,0 +1,47 @@ +import { Res } from 'common/types/responses' +import { Req } from 'common/types/requests' +import { service } from 'common/service' + +export const supportedContentTypeService = service + .enhanceEndpoints({ addTagTypes: ['SupportedContentType'] }) + .injectEndpoints({ + endpoints: (builder) => ({ + getSupportedContentType: builder.query< + Res['supportedContentType'], + Req['getSupportedContentType'] + >({ + providesTags: [{ id: 'LIST', type: 'SupportedContentType' }], + query: (query: Req['getSupportedContentType']) => ({ + url: `organisations/${query.organisation_id}/metadata-model-fields/supported-content-types/`, + }), + }), + // END OF ENDPOINTS + }), + }) + +export async function getSupportedContentType( + store: any, + data: Req['getSupportedContentType'], + options?: Parameters< + typeof supportedContentTypeService.endpoints.getSupportedContentType.initiate + >[1], +) { + return store.dispatch( + supportedContentTypeService.endpoints.getSupportedContentType.initiate( + data, + options, + ), + ) +} +// END OF FUNCTION_EXPORTS + +export const { + useGetSupportedContentTypeQuery, + // END OF EXPORTS +} = supportedContentTypeService + +/* Usage examples: +const { data, isLoading } = useGetSupportedContentTypeQuery({ id: 2 }, {}) //get hook +const [createSupportedContentType, { isLoading, data, isSuccess }] = useCreateSupportedContentTypeMutation() //create hook +supportedContentTypeService.endpoints.getSupportedContentType.select({id: 2})(store.getState()) //access data from any function +*/ diff --git a/frontend/common/stores/feature-list-store.ts b/frontend/common/stores/feature-list-store.ts index 291f5be5a550..79d36b7b99de 100644 --- a/frontend/common/stores/feature-list-store.ts +++ b/frontend/common/stores/feature-list-store.ts @@ -3,6 +3,10 @@ import { getIsWidget } from 'components/pages/WidgetPage' import ProjectStore from './project-store' import { createAndSetFeatureVersion } from 'common/services/useFeatureVersion' import { updateSegmentPriorities } from 'common/services/useSegmentPriority' +import { + createProjectFlag, + updateProjectFlag, +} from 'common/services/useProjectFlag' import OrganisationStore from './organisation-store' import { Approval, @@ -76,41 +80,42 @@ const controller = { createdFirstFeature = true flagsmith.setTrait('first_feature', 'true') API.trackEvent(Constants.events.CREATE_FIRST_FEATURE) - window.lintrk?.('track', { conversion_id: 16798354 }); + window.lintrk?.('track', { conversion_id: 16798354 }) } - data - .post( - `${Project.api}projects/${projectId}/features/`, - Object.assign({}, flag, { - initial_value: - typeof flag.initial_value !== 'undefined' && - flag.initial_value !== null - ? `${flag.initial_value}` - : flag.initial_value, - multivariate_options: undefined, - project: projectId, - type: - flag.multivariate_options && flag.multivariate_options.length - ? 'MULTIVARIATE' - : 'STANDARD', - }), - ) + createProjectFlag(getStore(), { + body: Object.assign({}, flag, { + initial_value: + typeof flag.initial_value !== 'undefined' && + flag.initial_value !== null + ? `${flag.initial_value}` + : flag.initial_value, + multivariate_options: undefined, + project: projectId, + type: + flag.multivariate_options && flag.multivariate_options.length + ? 'MULTIVARIATE' + : 'STANDARD', + }), + project_id: projectId, + }) .then((res) => Promise.all( (flag.multivariate_options || []).map((v) => data .post( - `${Project.api}projects/${projectId}/features/${res.id}/mv-options/`, + `${Project.api}projects/${projectId}/features/${res.data.id}/mv-options/`, { ...v, - feature: res.id, + feature: res.data.id, }, ) - .then(() => res), + .then(() => res.data), ), ).then(() => - data.get(`${Project.api}projects/${projectId}/features/${res.id}/`), + data.get( + `${Project.api}projects/${projectId}/features/${res.data.id}/`, + ), ), ) .then(() => @@ -141,9 +146,11 @@ const controller = { }) return } - - data - .put(`${Project.api}projects/${projectId}/features/${flag.id}/`, flag) + updateProjectFlag(getStore(), { + body: flag, + feature_id: flag.id, + project_id: projectId, + }) .then((res) => { // onComplete calls back preserving the order of multivariate_options with their updated ids if (onComplete) { diff --git a/frontend/common/types/requests.ts b/frontend/common/types/requests.ts index 46162d98c1dd..7214ef8c037a 100644 --- a/frontend/common/types/requests.ts +++ b/frontend/common/types/requests.ts @@ -9,6 +9,8 @@ import { MultivariateOption, Segment, Tag, + ProjectFlag, + Environment, UserGroup, } from './responses' @@ -33,7 +35,10 @@ export type Req = { include_feature_specific?: boolean }> deleteSegment: { projectId: number | string; id: number } - updateSegment: { projectId: number | string; segment: Segment } + updateSegment: { + projectId: number | string + segment: Segment + } createSegment: { projectId: number | string segment: Omit @@ -172,7 +177,7 @@ export type Req = { tags?: string[] is_archived?: boolean } - getProjectFlag: { project: string; id: string } + getProjectFlag: { project: string | number; id: string } getRolesPermissionUsers: { organisation_id: number; role_id: number } deleteRolesPermissionUsers: { organisation_id: number @@ -207,6 +212,49 @@ export type Req = { getGetSubscriptionMetadata: { id: string } getEnvironment: { id: string } getSubscriptionMetadata: { id: string } + getMetadataModelFields: { organisation_id: string } + getMetadataModelField: { organisation_id: string; id: string } + updateMetadataModelField: { + organisation_id: string + id: string + body: { + content_type: number + field: number + is_required_for: { + content_type: number + object_id: number + }[] + } + } + deleteMetadataModelField: { organisation_id: string; id: string | number } + createMetadataModelField: { + organisation_id: string + body: { + content_type: number | string + field: string | number + } + } + getMetadataField: { organisation_id: string } + getMetadataList: { organisation: string } + updateMetadataField: { + id: string + body: { + name: string + type: string + description: string + organisation: string + } + } + deleteMetadataField: { id: string } + createMetadataField: { + body: { + description: string + name: string + organisation: string + type: string + } + } + getRoleMasterApiKey: { org_id: number; role_id: number; id: string } updateRoleMasterApiKey: { org_id: number; role_id: number; id: string } deleteRoleMasterApiKey: { org_id: number; role_id: number; id: string } @@ -310,6 +358,7 @@ export type Req = { getGroupSummaries: { orgId: string } + getSupportedContentType: { organisation_id: string } getExternalResources: { project_id: string; feature_id: string } deleteExternalResource: { project_id: string @@ -392,6 +441,16 @@ export type Req = { usersToAddAdmin: number[] | null } getUserGroupPermission: { project_id: string } + updateProjectFlag: { + project_id: string | number + feature_id: string | number + body: ProjectFlag + } + createProjectFlag: { + project_id: string | number + body: ProjectFlag + } + updateEnvironment: { id: string; body: Environment } createCloneIdentityFeatureStates: { environment_id: string identity_id: string diff --git a/frontend/common/types/responses.ts b/frontend/common/types/responses.ts index 6697303b3850..ab32cd6b99e4 100644 --- a/frontend/common/types/responses.ts +++ b/frontend/common/types/responses.ts @@ -68,6 +68,7 @@ export type Segment = { description: string project: string | number feature?: number + metadata: Metadata[] | [] } export type Environment = { id: number @@ -82,6 +83,7 @@ export type Environment = { hide_sensitive_data: boolean total_segment_overrides?: number use_v2_feature_versioning: boolean + metadata: Metadata[] | [] } export type Project = { id: number @@ -523,6 +525,7 @@ export type ProjectFlag = { num_segment_overrides: number | null owners: User[] owner_groups: UserGroupSummary[] + metadata: Metadata[] | [] project: number tags: number[] type: string @@ -639,6 +642,40 @@ export type FeatureVersion = { published_by: number | null created_by: number | null } + +export type Metadata = { + id?: number + model_field: number | string + field_value: string +} + +export type MetadataField = { + id: number + name: string + type: string + description: string + organisation: number +} + +export type ContentType = { + [key: string]: any + id: number + app_label: string + model: string +} + +export type isRequiredFor = { + content_type: number + object_id: number +} + +export type MetadataModelField = { + id: string + field: number + content_type: number | string + is_required_for: isRequiredFor[] +} + export type Res = { segments: PagedResponse segment: Segment @@ -713,6 +750,10 @@ export type Res = { rolePermissionGroup: PagedResponse getSubscriptionMetadata: { id: string } environment: Environment + metadataModelFieldList: PagedResponse + metadataModelField: MetadataModelField + metadataList: PagedResponse + metadataField: MetadataField launchDarklyProjectImport: LaunchDarklyProjectImport launchDarklyProjectsImport: LaunchDarklyProjectImport[] roleMasterApiKey: { id: number; master_api_key: string; role: number } @@ -725,6 +766,7 @@ export type Res = { groupWithRole: PagedResponse changeRequests: PagedResponse groupSummaries: UserGroupSummary[] + supportedContentType: ContentType[] externalResource: PagedResponse githubIntegrations: PagedResponse githubRepository: PagedResponse | { data: { id: string } } diff --git a/frontend/common/utils/utils.tsx b/frontend/common/utils/utils.tsx index c46f77915a39..c591bc043834 100644 --- a/frontend/common/utils/utils.tsx +++ b/frontend/common/utils/utils.tsx @@ -2,6 +2,7 @@ import AccountStore from 'common/stores/account-store' import ProjectStore from 'common/stores/project-store' import Project from 'common/project' import { + ContentType, FeatureState, FeatureStateValue, FlagsmithValue, @@ -146,6 +147,9 @@ const Utils = Object.assign({}, require('./base/_utils'), { getApproveChangeRequestPermission() { return 'APPROVE_CHANGE_REQUEST' }, + getContentType(contentTypes: ContentType[], model: string, type: string) { + return contentTypes.find((c: ContentType) => c[model] === type) || null + }, getCreateProjectPermission(organisation: Organisation) { if (organisation?.restrict_project_create_to_admin) { return 'ADMIN' @@ -521,6 +525,18 @@ const Utils = Object.assign({}, require('./base/_utils'), { isValidNumber(value: any) { return /^-?\d*\.?\d+$/.test(`${value}`) }, + isValidURL(value: any) { + const pattern = new RegExp( + '^(https?:\\/\\/)?' + // protocol + '((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|' + // domain name + '((\\d{1,3}\\.){3}\\d{1,3}))' + // OR ip (v4) address + '(\\:\\d+)?(\\/[-a-z\\d%_.~+]*)*' + // port and path + '(\\?[;&a-z\\d%_.~+=-]*)?' + // query string + '(\\#[-a-z\\d_]*)?$', + 'i', + ) + return !!pattern.test(value) + }, loadScriptPromise(url: string) { return new Promise((resolve) => { const cb = function () { @@ -572,13 +588,29 @@ const Utils = Object.assign({}, require('./base/_utils'), { } return `${value}` }, + tagDisabled: (tag: Tag | undefined) => { const hasStaleFlagsPermission = Utils.getPlansPermission('STALE_FLAGS') return tag?.type === 'STALE' && !hasStaleFlagsPermission }, + + validateMetadataType(type: string, value: any) { + switch (type) { + case 'int': { + return Utils.isValidNumber(value) + } + case 'url': { + return Utils.isValidURL(value) + } + case 'bool': { + return value === 'true' || value === 'false' + } + default: + return true + } + }, validateRule(rule: SegmentCondition) { if (!rule) return false - if (rule.delete) { return true } diff --git a/frontend/web/components/App.js b/frontend/web/components/App.js index 33c9940e52f1..de098df629ee 100644 --- a/frontend/web/components/App.js +++ b/frontend/web/components/App.js @@ -25,7 +25,7 @@ import OrganisationLimit from './OrganisationLimit' import GithubStar from './GithubStar' import Tooltip from './Tooltip' import classNames from 'classnames' -import { apps, gitBranch, gitCompare, home, statsChart } from 'ionicons/icons'; +import { apps, gitBranch, gitCompare, statsChart } from 'ionicons/icons' import NavSubLink from './NavSubLink' import SettingsIcon from './svg/SettingsIcon' import UsersIcon from './svg/UsersIcon' @@ -35,7 +35,7 @@ import SegmentsIcon from './svg/SegmentsIcon' import AuditLogIcon from './svg/AuditLogIcon' import Permission from 'common/providers/Permission' import HomeAside from './pages/HomeAside' -import ScrollToTop from './ScrollToTop'; +import ScrollToTop from './ScrollToTop' const App = class extends Component { static propTypes = { @@ -443,7 +443,6 @@ const App = class extends Component { src='/static/images/nav-logo.png' /> - {!( isOrganisationSelect || isCreateOrganisation ) && ( @@ -711,7 +710,7 @@ const App = class extends Component { ) }} - + ) } diff --git a/frontend/web/components/ErrorMessage.js b/frontend/web/components/ErrorMessage.js index cd3dd7d5454c..b1290f482a67 100644 --- a/frontend/web/components/ErrorMessage.js +++ b/frontend/web/components/ErrorMessage.js @@ -3,7 +3,7 @@ import React, { PureComponent } from 'react' import Icon from './Icon' import Button from './base/forms/Button' import Format from 'common/utils/format' -import Constants from 'common/constants'; +import Constants from 'common/constants' export default class ErrorMessage extends PureComponent { static displayName = 'ErrorMessage' diff --git a/frontend/web/components/Icon.tsx b/frontend/web/components/Icon.tsx index 246d30040eda..db7a2542fca2 100644 --- a/frontend/web/components/Icon.tsx +++ b/frontend/web/components/Icon.tsx @@ -52,6 +52,7 @@ export type IconName = | 'timer' | 'request' | 'people' + | 'required' | 'more-vertical' export type IconType = React.DetailedHTMLProps< @@ -1039,321 +1040,6 @@ const Icon: FC = ({ fill, fill2, height, name, width, ...rest }) => { ) } - case 'bell': { - return ( - - - - - - ) - } - case 'layout': { - return ( - - - - - - ) - } - case 'height': { - return ( - - - - ) - } - case 'chevron-up': { - return ( - - - - ) - } - case 'nav-logo': { - return ( - - - - ) - } - case 'options-2': { - return ( - - - - ) - } - case 'pie-chart': { - return ( - - - - ) - } - case 'bar-chart': { - return ( - - - - ) - } - case 'list': { - return ( - - - - ) - } - case 'layers': { - return ( - - - - ) - } - case 'flash': { - return ( - - - - ) - } - case 'checkmark-circle': { - return ( - - - - ) - } - case 'minus-circle': { - return ( - - - - ) - } - case 'email': { - return ( - - - - ) - } case 'arrow-right': { return ( = ({ fill, fill2, height, name, width, ...rest }) => { ) } + case 'required': { + return ( + + + + + + ) + } case 'more-vertical': { return ( void + setHasMetadataRequired?: (b: boolean) => void +} + +const AddMetadataToEntity: FC = ({ + entity, + entityContentType, + entityId, + envName, + onChange, + organisationId, + 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( + { + id: entityId, + project: projectId, + }, + { skip: entity !== 'feature' || !entityId }, + ) + + 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 [updateEnvironment] = useUpdateEnvironmentMutation() + + const [ + metadataFieldsAssociatedtoEntity, + setMetadataFieldsAssociatedtoEntity, + ] = useState() + + 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[]) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [metadataFieldsAssociatedtoEntity]) + + const [metadataChanged, setMetadataChanged] = useState(false) + useEffect(() => { + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [metadataFieldsAssociatedtoEntity]) + + 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]), + ) + + // 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 + } + + // Determine if metadata entity exists + mergedItem.metadataEntity = + mergedItem.metadataModelFieldId !== undefined && + mergedItem.model_field !== undefined + + return mergedItem // Return the merged item + }) + } + + 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 + if (isRequiredFor) { + setHasMetadataRequired?.() + } + // 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) + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + metadataFieldList, + metadataFieldListLoaded, + metadataModelFieldList, + metadataModelFieldListLoaded, + projectFeatureDataLoaded, + projectFeatureData, + ]) + return ( + <> + + + Metadata + Value + + } + items={metadataFieldsAssociatedtoEntity} + renderRow={(m: CustomMetadata) => { + 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) + }} + /> + ) + }} + /> + {entity === 'environment' && ( +
+ +
+ )} + + + ) +} + +type MetadataRowType = { + metadata: CustomMetadata + getMetadataValue?: (metadata: CustomMetadata) => void +} +const MetadataRow: FC = ({ getMetadataValue, metadata }) => { + const [metadataValue, setMetadataValue] = useState(() => { + if (metadata?.type === 'bool') { + return metadata?.field_value === 'true' ? true : false + } else { + return metadata?.field_value !== undefined ? metadata?.field_value : '' + } + }) + const saveMetadata = () => { + setMetadataValueChanged(false) + const updatedMetadataObject = { ...metadata } + updatedMetadataObject.field_value = + metadata?.type === 'bool' ? `${!metadataValue}` : `${metadataValue}` + getMetadataValue?.(updatedMetadataObject as CustomMetadata) + } + const [metadataValueChanged, setMetadataValueChanged] = + useState(false) + return ( + + {metadataValueChanged &&
{'*'}
} + {`${metadata?.name} ${ + metadata?.isRequiredFor ? '*' : '' + }`} + {metadata?.type !== 'bool' ? ( + + { + setMetadataValue(Utils.safeParseEventValue(e)) + setMetadataValueChanged(true) + }} + className='mr-2' + style={{ width: '250px' }} + placeholder='Metadata Value' + isValid={Utils.validateMetadataType( + metadata?.type, + metadataValue, + )} + /> + } + place='top' + > + {`This value has to be of type ${metadata?.type}`} + + + ) : ( + + { + setMetadataValue(!metadataValue) + setMetadataValueChanged(true) + saveMetadata() + }} + /> + + )} +
+ ) +} + +export default AddMetadataToEntity diff --git a/frontend/web/components/metadata/ContentTypesMetadataFieldTable.tsx b/frontend/web/components/metadata/ContentTypesMetadataFieldTable.tsx new file mode 100644 index 000000000000..537e2b3cf7ad --- /dev/null +++ b/frontend/web/components/metadata/ContentTypesMetadataFieldTable.tsx @@ -0,0 +1,118 @@ +import React, { FC, useState } from 'react' + +import PanelSearch from 'components/PanelSearch' +import Button from 'components/base/forms/Button' +import Switch from 'components/Switch' +import Icon from 'components/Icon' + +import { MetadataModelField } from 'common/types/responses' +type selectedContentType = { + label: string + value: string + isRequired?: boolean +} + +type ContentTypesMetadataFieldTableType = { + organisationId: string + selectedContentTypes: selectedContentType[] + onDelete: (removed: selectedContentType) => void + isEdit: boolean + changeMetadataRequired: (value: string, isRequired: boolean) => void + metadataModelFieldList: MetadataModelField[] +} + +type ContentTypesMetadataRowBase = Omit< + Omit, + 'selectedContentTypes' +> + +type ContentTypesMetadataRowType = ContentTypesMetadataRowBase & { + item: selectedContentType + isEnabled: boolean +} + +const ContentTypesMetadataRow: FC = ({ + changeMetadataRequired, + isEnabled, + item, + onDelete, +}) => { + const [isMetadataRequired, setIsMetadataRequired] = + useState(isEnabled) + + const deleteRequied = (removed: selectedContentType) => { + onDelete?.(removed) + } + const isRequired = (value: string, isRequired: boolean) => { + changeMetadataRequired(value, isRequired) + setIsMetadataRequired(!isMetadataRequired) + } + + return ( + + {item.label} +
+ { + isRequired(item.value, !isMetadataRequired) + }} + className='ml-0' + /> +
+
+ +
+
+ ) +} + +const ContentTypesMetadataFieldTable: FC< + ContentTypesMetadataFieldTableType +> = ({ + changeMetadataRequired, + isEdit, + onDelete, + organisationId, + selectedContentTypes, +}) => { + return ( + + + Entity + +
+ Requeried +
+
+ Remove +
+ + } + items={selectedContentTypes} + renderRow={(s: selectedContentType) => ( + + )} + /> + ) +} + +export default ContentTypesMetadataFieldTable diff --git a/frontend/web/components/metadata/ContentTypesValues.tsx b/frontend/web/components/metadata/ContentTypesValues.tsx new file mode 100644 index 000000000000..d05c08b186d0 --- /dev/null +++ b/frontend/web/components/metadata/ContentTypesValues.tsx @@ -0,0 +1,55 @@ +import React, { FC } from 'react' +import { useGetSupportedContentTypeQuery } from 'common/services/useSupportedContentType' +import { MetadataModelField } from 'common/types/responses' +import classNames from 'classnames' + +type ContentTypesValuesType = { + contentTypes: MetadataModelField[] + organisationId: string +} + +const ContentTypesValues: FC = ({ + contentTypes, + organisationId, +}) => { + const { data: supportedContentTypes } = useGetSupportedContentTypeQuery({ + organisation_id: `${organisationId}`, + }) + + const combinedData = contentTypes.map((contentType) => { + const match = supportedContentTypes?.find( + (item) => item.id === contentType.content_type, + ) + return { ...contentType, model: match ? match.model : null } + }) + + return ( + + {combinedData.map((contentType, index) => ( + + {`${contentType.model}${ + contentType.is_required_for.length ? '*' : '' + }`} + + } + place='right' + > + {contentType.is_required_for.length ? 'Required' : 'Optional'} + + ))} + + ) +} + +export default ContentTypesValues diff --git a/frontend/web/components/metadata/MetadataPage.tsx b/frontend/web/components/metadata/MetadataPage.tsx new file mode 100644 index 000000000000..5e3cad20f5c6 --- /dev/null +++ b/frontend/web/components/metadata/MetadataPage.tsx @@ -0,0 +1,193 @@ +import React, { FC, useMemo } 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 { + useGetMetadataFieldListQuery, + useDeleteMetadataFieldMutation, +} from 'common/services/useMetadataField' +import { useGetMetadataModelFieldListQuery } from 'common/services/useMetadataModelField' + +const metadataWidth = [200, 150, 150, 90] +type MetadataPageType = { + organisationId: string + projectId: string +} +type MergeMetadata = { + content_type_fields: MetadataModelField[] + id: number + name: string + type: string + description: string + organisation: number +} + +const MetadataPage: FC = ({ organisationId, projectId }) => { + const { data: metadataFieldList } = useGetMetadataFieldListQuery({ + organisation: organisationId, + }) + + const { data: MetadataModelFieldList } = useGetMetadataModelFieldListQuery({ + organisation_id: organisationId, + }) + + 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, + } + }) + } + return null + }, [metadataFieldList, MetadataModelFieldList]) + + const metadataCreatedToast = () => { + toast('Metadata Field Created') + closeModal() + } + const createMetadataField = () => { + openModal( + `Create Metadata Field`, + , + 'side-modal create-feature-modal', + ) + } + + const editMetadata = (id: string, contentTypeList: MetadataModelField[]) => { + openModal( + `Edit Metadata Field`, + { + toast('Metadata Field Updated') + }} + projectId={projectId} + organisationId={organisationId} + />, + 'side-modal create-feature-modal', + ) + } + + const _deleteMetadata = (id: string, name: string) => { + openConfirm({ + body: ( +
+ {'Are you sure you want to delete '} + {name} + {' metadata field?'} +
+ ), + destructive: true, + onYes: () => deleteMetadata({ id }), + title: 'Delete Metadata Field', + yesText: 'Confirm', + }) + } + + return ( +
+ + +
Metadata Fields
+
+ +
+
+ } + > + {'Create or Update the Metadata Fields Project'} + + + +

+ Manage metadata fields for selected core identities in your project{' '} + +

+ + + Name +
+ Remove +
+ + } + renderRow={(metadata: MergeMetadata) => ( + { + editMetadata(`${metadata.id}`, metadata.content_type_fields) + }} + > + +
{metadata.name}
+ +
+
+ +
+
+ )} + renderNoResults={ + +
+ + You currently have no metadata configured. + +
+
+ } + /> +
+
+ ) +} + +export default MetadataPage diff --git a/frontend/web/components/metadata/SupportedContentTypesSelect.tsx b/frontend/web/components/metadata/SupportedContentTypesSelect.tsx new file mode 100644 index 000000000000..f34e80502f65 --- /dev/null +++ b/frontend/web/components/metadata/SupportedContentTypesSelect.tsx @@ -0,0 +1,118 @@ +import React, { FC, useEffect, useState } from 'react' +import { useGetSupportedContentTypeQuery } from 'common/services/useSupportedContentType' +import { ContentType, MetadataModelField } from 'common/types/responses' +import InputGroup from 'components/base/forms/InputGroup' +import ContentTypesMetadataFieldTable from './ContentTypesMetadataFieldTable' + +type SupportedContentTypesSelectType = { + organisationId: string + isEdit: boolean + getMetadataContentTypes: (m: SelectContentTypesType[]) => void + metadataModelFieldList: MetadataModelField[] +} + +export type SelectContentTypesType = { + label: string + value: string + isRequired?: boolean +} +const SupportedContentTypesSelect: FC = ({ + getMetadataContentTypes, + isEdit, + metadataModelFieldList, + organisationId, +}) => { + const { data: supportedContentTypes } = useGetSupportedContentTypeQuery({ + organisation_id: organisationId, + }) + const [selectedContentTypes, setSelectedContentTypes] = useState< + SelectContentTypesType[] + >([]) + + useEffect(() => { + if (isEdit && !!supportedContentTypes?.length) { + const excludedModels = ['project', 'organisation'] + const newSelectedContentTypes = supportedContentTypes + .filter((item) => !excludedModels.includes(item.model)) + .filter((item) => + metadataModelFieldList.some( + (entry) => entry.content_type === item.id, + ), + ) + .map((item) => { + const match = metadataModelFieldList.find( + (entry) => entry.content_type === item.id, + ) + const isRequired = match && !!match.is_required_for.length + + return { + isRequired: isRequired, + label: item.model, + value: item.id.toString(), + } + }) + + setSelectedContentTypes(newSelectedContentTypes) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [supportedContentTypes]) + + useEffect(() => { + getMetadataContentTypes(selectedContentTypes) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedContentTypes]) + + return ( + <> + + v.model !== 'project' && + v.model !== 'organisation' && + !selectedContentTypes.some((x) => x.value === `${v.id}`), + ) + .map((v: ContentType) => ({ + label: v.model, + value: `${v.id}`, + }))} + onChange={(v: SelectContentTypesType) => { + setSelectedContentTypes((prevState) => [...prevState, v]) + }} + className='mb-4 react-select' + /> + } + /> + {!!selectedContentTypes.length && ( + { + setSelectedContentTypes((prevState) => + prevState.filter((item) => item.value !== v.value), + ) + }} + organisationId={organisationId} + isEdit={isEdit} + changeMetadataRequired={(v: string, r: boolean) => { + setSelectedContentTypes((prevState) => + prevState.map((item): SelectContentTypesType => { + const updatedItem: SelectContentTypesType = { + ...item, + isRequired: item.value === v ? r : item.isRequired, + } + return updatedItem + }), + ) + }} + /> + )} + + ) +} + +export default SupportedContentTypesSelect diff --git a/frontend/web/components/modals/CreateFlag.js b/frontend/web/components/modals/CreateFlag.js index dd588b18257e..3a56ec8f1448 100644 --- a/frontend/web/components/modals/CreateFlag.js +++ b/frontend/web/components/modals/CreateFlag.js @@ -31,12 +31,14 @@ import { setInterceptClose, setModalTitle } from './base/ModalDefault' import Icon from 'components/Icon' import ModalHR from './ModalHR' import FeatureValue from 'components/FeatureValue' +import { getStore } from 'common/store' import FlagOwnerGroups from 'components/FlagOwnerGroups' import ExistingChangeRequestAlert from 'components/ExistingChangeRequestAlert' import Button from 'components/base/forms/Button' +import AddMetadataToEntity from 'components/metadata/AddMetadataToEntity' +import { getSupportedContentType } from 'common/services/useSupportedContentType' import { getGithubIntegration } from 'common/services/useGithubIntegration' import { createExternalResource } from 'common/services/useExternalResource' -import { getStore } from 'common/store' import { removeUserOverride } from 'components/RemoveUserOverride' import MyIssueSelect from 'components/MyIssuesSelect' import MyPullRequestsSelect from 'components/MyPullRequestsSelect' @@ -54,6 +56,7 @@ const CreateFlag = class extends Component { feature_state_value, is_archived, is_server_key_only, + metadata, multivariate_options, name, tags, @@ -79,8 +82,10 @@ const CreateFlag = class extends Component { environmentFlag: this.props.environmentFlag, externalResource: {}, externalResources: [], + featureContentType: {}, githubId: '', hasIntegrationWithGithub: false, + hasMetadataRequired: false, identityVariations: this.props.identityFlag && this.props.identityFlag.multivariate_feature_state_values @@ -95,6 +100,7 @@ const CreateFlag = class extends Component { isEdit: !!this.props.projectFlag, is_archived, is_server_key_only, + metadata: [], multivariate_options: _.cloneDeep(multivariate_options), name, period: 30, @@ -182,6 +188,18 @@ const CreateFlag = class extends Component { ) { this.getFeatureUsage() } + if (Utils.getFlagsmithHasFeature('enable_metadata')) { + getSupportedContentType(getStore(), { + organisation_id: AccountStore.getOrganisation().id, + }).then((res) => { + const featureContentType = Utils.getContentType( + res.data, + 'model', + 'feature', + ) + this.setState({ featureContentType: featureContentType }) + }) + } if (Utils.getFlagsmithHasFeature('github_integration')) { getGithubIntegration(getStore(), { @@ -313,6 +331,12 @@ const CreateFlag = class extends Component { initial_value, is_archived, is_server_key_only, + metadata: + !this.props.projectFlag?.metadata || + (this.props.projectFlag.metadata !== this.state.metadata && + this.state.metadata.length) + ? this.state.metadata + : this.props.projectFlag.metadata, multivariate_options: this.state.multivariate_options, name, tags: this.state.tags, @@ -505,6 +529,7 @@ const CreateFlag = class extends Component { enabledIndentity, enabledSegment, externalResourceType, + featureContentType, featureExternalResource, githubId, hasIntegrationWithGithub, @@ -552,6 +577,7 @@ const CreateFlag = class extends Component { }) } let regexValid = true + const metadataEnable = Utils.getFlagsmithHasFeature('enable_metadata') try { if (!isEdit && name && regex) { regexValid = name.match(new RegExp(regex)) @@ -559,7 +585,7 @@ const CreateFlag = class extends Component { } catch (e) { regexValid = false } - const Settings = (projectAdmin, createFeature) => ( + const Settings = (projectAdmin, createFeature, featureContentType) => ( <> {!identity && this.state.tags && ( @@ -579,6 +605,34 @@ const CreateFlag = class extends Component { /> )} + {metadataEnable && featureContentType?.id && ( + + { + this.setState({ + hasMetadataRequired: true, + }) + }} + onChange={(m) => { + this.setState({ + metadata: m, + }) + }} + /> + } + /> + + )} {!identity && projectFlag && ( )} - {!isEdit && !identity && Settings(projectAdmin, createFeature)} + {!isEdit && + !identity && + Settings(projectAdmin, createFeature, featureContentType)} ) return ( @@ -1109,6 +1165,9 @@ const CreateFlag = class extends Component { > {({ permission: projectAdmin }) => { this.state.skipSaveProjectFeature = !createFeature + const _hasMetadataRequired = + this.state.hasMetadataRequired && + !this.state.metadata.length return (
{isEdit && !identity ? ( @@ -1814,7 +1873,11 @@ const CreateFlag = class extends Component { } > - {Settings(projectAdmin, createFeature)} + {Settings( + projectAdmin, + createFeature, + featureContentType, + )} {isSaving @@ -1908,7 +1974,8 @@ const CreateFlag = class extends Component { !name || invalid || !regexValid || - featureLimitAlert.percentage >= 100 + featureLimitAlert.percentage >= 100 || + _hasMetadataRequired } > {isSaving ? 'Creating' : 'Create Feature'} @@ -1993,6 +2060,7 @@ const FeatureProvider = (WrappedComponent) => { } componentDidMount() { + // toast update feature ES6Component(this) this.listenTo( FeatureListStore, diff --git a/frontend/web/components/modals/CreateMetadataField.tsx b/frontend/web/components/modals/CreateMetadataField.tsx new file mode 100644 index 000000000000..0c694ff42d24 --- /dev/null +++ b/frontend/web/components/modals/CreateMetadataField.tsx @@ -0,0 +1,330 @@ +import React, { FC, useEffect, useState } from 'react' +import Utils from 'common/utils/utils' +import InputGroup from 'components/base/forms/InputGroup' +import Button from 'components/base/forms/Button' +import SupportedContentTypesSelect, { + SelectContentTypesType, +} from 'components/metadata/SupportedContentTypesSelect' + +import { + useCreateMetadataFieldMutation, + useGetMetadataFieldQuery, + useUpdateMetadataFieldMutation, +} from 'common/services/useMetadataField' + +import { useGetSupportedContentTypeQuery } from 'common/services/useSupportedContentType' + +import { + useCreateMetadataModelFieldMutation, + useUpdateMetadataModelFieldMutation, + useDeleteMetadataModelFieldMutation, +} from 'common/services/useMetadataModelField' +import { + ContentType, + MetadataModelField, + isRequiredFor, +} from 'common/types/responses' + +type CreateMetadataFieldType = { + id?: string + isEdit: boolean + metadataModelFieldList?: MetadataModelField[] + onComplete?: () => void + organisationId: string + projectId: string +} + +type QueryBody = Omit + +type Query = { + body: QueryBody + id?: number + organisation_id: string +} + +type MetadataType = { + id: number + value: string + label: string +} + +type metadataFieldUpdatedSelectListType = MetadataModelField & { + removed: boolean + new: boolean +} + +const CreateMetadataField: FC = ({ + id, + isEdit, + metadataModelFieldList, + onComplete, + organisationId, + projectId, +}) => { + const metadataTypes: MetadataType[] = [ + { id: 1, label: 'int', value: 'int' }, + { id: 2, label: 'string', value: 'str' }, + { id: 3, label: 'boolean', value: 'bool' }, + { id: 4, label: 'url', value: 'url' }, + { id: 5, label: 'multiline string', value: 'multiline_str' }, + ] + const { data, isLoading } = useGetMetadataFieldQuery( + { organisation_id: id! }, + { skip: !id }, + ) + + const { data: supportedContentTypes } = useGetSupportedContentTypeQuery({ + organisation_id: `${organisationId}`, + }) + const [createMetadataField, { isLoading: creating, isSuccess: created }] = + useCreateMetadataFieldMutation() + const [updateMetadataField, { isLoading: updating, isSuccess: updated }] = + useUpdateMetadataFieldMutation() + + const [createMetadataModelField] = useCreateMetadataModelFieldMutation() + const [updateMetadataModelField] = useUpdateMetadataModelFieldMutation() + + const [deleteMetadataModelField] = useDeleteMetadataModelFieldMutation() + const projectContentType: ContentType = + supportedContentTypes && + Utils.getContentType(supportedContentTypes, 'model', 'project') + useEffect(() => { + if (data && !isLoading) { + setName(data.name) + setDescription(data.description) + const _metadataType = metadataTypes.find( + (m: MetadataType) => m.value === data.type, + ) + if (_metadataType) { + setTypeValue(_metadataType) + } + } + // 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('') + const [metadataFieldSelectList, setMetadataFieldSelectList] = useState< + SelectContentTypesType[] + >([]) + const [metadataUpdatedSelectList, setMetadataFieldUpdatedSelectList] = + useState([]) + + const generateDataQuery = ( + contentType: string | number, + field: number, + isRequiredFor: boolean, + id: number, + isNew = false, + ) => { + const query: Query = { + body: { + content_type: contentType, + field: field, + is_required_for: isRequiredFor + ? ([ + { + content_type: projectContentType.id, + object_id: parseInt(projectId), + } as isRequiredFor, + ] as isRequiredFor[]) + : [], + }, + id: id, + organisation_id: organisationId, + } + if (isNew) { + const newQuery = { ...query } + delete newQuery.id + return newQuery + } + return query + } + + const save = () => { + if (isEdit) { + updateMetadataField({ + body: { + description, + name, + organisation: organisationId, + type: `${typeValue?.value}`, + }, + id: id!, + }).then(() => { + Promise.all( + metadataUpdatedSelectList?.map( + async (m: metadataFieldUpdatedSelectListType) => { + const query = generateDataQuery( + m.content_type, + m.field, + m.is_required_for, + m.id, + m.new, + ) + if (!m.removed && !m.new) { + await updateMetadataModelField(query) + } else if (m.removed) { + await deleteMetadataModelField({ + id: m.id, + organisation_id: organisationId, + }) + } else if (m.new) { + const newQuery = { ...query } + delete newQuery.id + await createMetadataModelField(newQuery) + } + }, + ), + ) + 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) + }), + ) + }) + } + } + + return ( +
+ ) => { + setName(Utils.safeParseEventValue(event)) + }} + type='text' + id='metadata-name' + placeholder='e.g. JIRA Ticket Number' + /> + ) => { + setDescription(Utils.safeParseEventValue(event)) + }} + type='text' + title={'Description (optional)'} + placeholder={"e.g. 'The JIRA Ticket Number associated with this flag'"} + /> + { + setTypeValue(m) + }} + className='mb-4 react-select' + /> + } + /> + { + if (isEdit) { + const newMetadataFieldArray: metadataFieldUpdatedSelectListType[] = + [] + + metadataModelFieldList?.forEach((item1) => { + const match = m.find( + (item2) => item2.value === item1.content_type.toString(), + ) + + if (match) { + const isRequiredLength = !!item1.is_required_for.length + const isRequired = match.isRequired + if (isRequiredLength !== isRequired) { + newMetadataFieldArray.push({ + ...item1, + is_required_for: isRequired, + }) + } + } else { + newMetadataFieldArray.push({ + ...item1, + new: false, + removed: true, + }) + } + m.forEach((item) => { + const match = metadataModelFieldList.find( + (item2) => item2.content_type.toString() === item.value, + ) + if (!match) { + newMetadataFieldArray.push({ + ...item1, + content_type: item.value, + is_required_for: m?.isRequired, + new: true, + removed: false, + }) + } + }) + }) + setMetadataFieldUpdatedSelectList(newMetadataFieldArray) + } else { + setMetadataFieldSelectList(m) + } + }} + metadataModelFieldList={metadataModelFieldList!} + /> + +
+ ) +} + +export default CreateMetadataField diff --git a/frontend/web/components/modals/CreateSegment.tsx b/frontend/web/components/modals/CreateSegment.tsx index b164e1277b01..8551c6ee55f4 100644 --- a/frontend/web/components/modals/CreateSegment.tsx +++ b/frontend/web/components/modals/CreateSegment.tsx @@ -2,9 +2,11 @@ import React, { FC, FormEvent, useEffect, useMemo, useState } from 'react' import Constants from 'common/constants' import useSearchThrottle from 'common/useSearchThrottle' +import AccountStore from 'common/stores/account-store' import { EdgePagedResponse, Identity, + Metadata, Operator, Segment, SegmentRule, @@ -39,6 +41,10 @@ import ProjectStore from 'common/stores/project-store' import Icon from 'components/Icon' import Permission from 'common/providers/Permission' import classNames from 'classnames' +import AddMetadataToEntity, { + CustomMetadataField, +} from 'components/metadata/AddMetadataToEntity' +import { useGetSupportedContentTypeQuery } from 'common/services/useSupportedContentType' type PageType = { number: number @@ -131,17 +137,31 @@ const CreateSegment: FC = ({ const [name, setName] = useState(segment.name) const [rules, setRules] = useState(segment.rules) const [tab, setTab] = useState(0) + const [metadata, setMetadata] = useState( + segment.metadata, + ) + const metadataEnable = Utils.getFlagsmithHasFeature('enable_metadata') const error = createError || updateError - const isLimitReached = - ProjectStore.getTotalSegments() >= ProjectStore.getMaxSegmentsAllowed() + const totalSegments = ProjectStore.getTotalSegments() ?? 0 + const maxSegmentsAllowed = ProjectStore.getMaxSegmentsAllowed() ?? 0 + const isLimitReached = totalSegments >= maxSegmentsAllowed const THRESHOLD = 90 const segmentsLimitAlert = Utils.calculateRemainingLimitsPercentage( - ProjectStore.getTotalSegments(), - ProjectStore.getMaxSegmentsAllowed(), + totalSegments, + maxSegmentsAllowed, THRESHOLD, ) + const { data: supportedContentTypes } = useGetSupportedContentTypeQuery({ + organisation_id: AccountStore.getOrganisation().id, + }) + + const segmentContentType = useMemo(() => { + if (supportedContentTypes) { + return Utils.getContentType(supportedContentTypes, 'model', 'segment') + } + }, [supportedContentTypes]) const addRule = (type = 'ANY') => { const newRules = cloneDeep(rules) @@ -174,6 +194,7 @@ const CreateSegment: FC = ({ const segmentData: Omit = { description, feature: feature, + metadata: metadata as Metadata[], name, project: projectId, rules, @@ -402,6 +423,27 @@ const CreateSegment: FC = ({ : 'Show condition descriptions'} + {metadataEnable && segmentContentType?.id && ( + + { + setMetadata(m) + }} + /> + } + /> + + )}