diff --git a/frontend/.claude/commands/e2e-ee.md b/frontend/.claude/commands/e2e-ee.md index df25a3deaff8..20f17ccab8d7 100644 --- a/frontend/.claude/commands/e2e-ee.md +++ b/frontend/.claude/commands/e2e-ee.md @@ -14,7 +14,7 @@ Run enterprise E2E tests (tagged with @enterprise) and report results. ## Run Command ```bash -E2E_RETRIES=0 SKIP_BUNDLE=1 E2E_CONCURRENCY=20 npm run test -- --grep @enterprise --quiet +E2E_RETRIES=0 SKIP_BUNDLE=1 E2E_CONCURRENCY=5 npm run test -- --grep @enterprise --quiet ``` ## Re-running Failed Enterprise Tests diff --git a/frontend/.claude/commands/e2e-oss.md b/frontend/.claude/commands/e2e-oss.md index 837d99b3c6df..b3c4f1a25cf6 100644 --- a/frontend/.claude/commands/e2e-oss.md +++ b/frontend/.claude/commands/e2e-oss.md @@ -14,7 +14,7 @@ Run OSS (non-enterprise) E2E tests and report results. ## Run Command ```bash -E2E_RETRIES=0 SKIP_BUNDLE=1 E2E_CONCURRENCY=20 npm run test -- --grep @oss --quiet +E2E_RETRIES=0 SKIP_BUNDLE=1 E2E_CONCURRENCY=5 npm run test -- --grep @oss --quiet ``` ## Workflow diff --git a/frontend/.claude/commands/e2e.md b/frontend/.claude/commands/e2e.md index 6f9e6ccb5cec..78a97c8374c4 100644 --- a/frontend/.claude/commands/e2e.md +++ b/frontend/.claude/commands/e2e.md @@ -14,7 +14,7 @@ Run all E2E tests (both OSS and enterprise) and report results. ## Run Command ```bash -E2E_RETRIES=0 SKIP_BUNDLE=1 E2E_CONCURRENCY=20 npm run test -- --grep "@oss|@enterprise" --quiet +E2E_RETRIES=0 SKIP_BUNDLE=1 E2E_CONCURRENCY=5 npm run test -- --grep "@oss|@enterprise" --quiet ``` ## Workflow diff --git a/frontend/.claude/context/e2e.md b/frontend/.claude/context/e2e.md index 0d1d01899a4f..84b7f07f163a 100644 --- a/frontend/.claude/context/e2e.md +++ b/frontend/.claude/context/e2e.md @@ -49,7 +49,7 @@ E2E_TEST_TOKEN_PROD= ## Environment Variables - `SKIP_BUNDLE=1` - Skip webpack bundle build for faster iteration -- `E2E_CONCURRENCY=20` - Number of parallel test workers (reduce to 1 for debugging) +- `E2E_CONCURRENCY=5` - Number of parallel test workers (reduce to 1 for debugging) - `E2E_RETRIES=0` - Disable retries and enable fail-fast mode (stop on first failure) - `--quiet` - Minimal output - `--grep @enterprise` - Run only enterprise tests diff --git a/frontend/common/ES6Component.js b/frontend/common/ES6Component.js index d118eed4f30c..08c09392f739 100644 --- a/frontend/common/ES6Component.js +++ b/frontend/common/ES6Component.js @@ -1,4 +1,4 @@ -module.exports = function es6Component(context, onUnmount) { +function es6Component(context, onUnmount) { context._listeners = [] context.listenTo = function listenTo(store, event, callback) { @@ -55,3 +55,5 @@ module.exports = function es6Component(context, onUnmount) { } } } + +export default es6Component diff --git a/frontend/common/dispatcher/action-constants.js b/frontend/common/dispatcher/action-constants.js index 6226d6e78faa..2d7515bc6c52 100644 --- a/frontend/common/dispatcher/action-constants.js +++ b/frontend/common/dispatcher/action-constants.js @@ -48,4 +48,4 @@ const Actions = Object.assign({}, require('./base/_action-constants'), { }) window.Actions = Actions -module.exports = Actions +export default Actions diff --git a/frontend/common/dispatcher/app-actions.js b/frontend/common/dispatcher/app-actions.js index cfabc76b3fd6..ed10278ba5e6 100644 --- a/frontend/common/dispatcher/app-actions.js +++ b/frontend/common/dispatcher/app-actions.js @@ -376,5 +376,4 @@ const AppActions = Object.assign({}, require('./base/_app-actions'), { }, }) -module.exports = AppActions -window.AppActions = AppActions +export default AppActions diff --git a/frontend/common/hooks/useHasGithubIntegration.ts b/frontend/common/hooks/useHasGithubIntegration.ts new file mode 100644 index 000000000000..c75cfdafb631 --- /dev/null +++ b/frontend/common/hooks/useHasGithubIntegration.ts @@ -0,0 +1,16 @@ +import AccountStore from 'common/stores/account-store' +import { useGetGithubIntegrationQuery } from 'common/services/useGithubIntegration' + +export function useHasGithubIntegration() { + const organisationId = AccountStore.getOrganisation()?.id + const { data } = useGetGithubIntegrationQuery( + { organisation_id: organisationId }, + { skip: !organisationId }, + ) + + return { + githubId: data?.results?.[0]?.id ?? '', + hasIntegration: !!data?.results?.length, + organisationId, + } +} diff --git a/frontend/common/providers/FeatureListProvider.js b/frontend/common/providers/FeatureListProvider.js index 8460bfdb4319..598b930aafb1 100644 --- a/frontend/common/providers/FeatureListProvider.js +++ b/frontend/common/providers/FeatureListProvider.js @@ -264,4 +264,4 @@ FeatureListProvider.propTypes = { onSave: OptionalFunc, } -module.exports = FeatureListProvider +export default FeatureListProvider diff --git a/frontend/common/providers/Permission.tsx b/frontend/common/providers/Permission.tsx index 41ff775b57fe..e47323c8a710 100644 --- a/frontend/common/providers/Permission.tsx +++ b/frontend/common/providers/Permission.tsx @@ -1,8 +1,7 @@ import React, { FC, ReactNode, useMemo } from 'react' import { useGetPermissionQuery } from 'common/services/usePermission' import AccountStore from 'common/stores/account-store' -import intersection from 'lodash/intersection' -import { cloneDeep } from 'lodash' +import { cloneDeep, intersection } from 'lodash' import Utils from 'common/utils/utils' import Constants from 'common/constants' import { @@ -50,7 +49,7 @@ type PermissionType = | EnvironmentLevelProps type UseHasPermissionParams = { - id: number | string + id: number | string | undefined | null level: 'organisation' | 'project' | 'environment' permission: OrganisationPermission | ProjectPermission | EnvironmentPermission tags?: number[] diff --git a/frontend/common/services/useIdentityOverride.ts b/frontend/common/services/useIdentityOverride.ts new file mode 100644 index 000000000000..34f255d64155 --- /dev/null +++ b/frontend/common/services/useIdentityOverride.ts @@ -0,0 +1,78 @@ +import { service } from 'common/service' +import { Req } from 'common/types/requests' +import { + EdgeIdentityOverrideItem, + FeatureState, + IdentityOverride, + PagedResponse, + Res, +} from 'common/types/responses' +import Utils from 'common/utils/utils' + +export const identityOverrideService = service + .enhanceEndpoints({ addTagTypes: ['IdentityOverride'] }) + .injectEndpoints({ + endpoints: (builder) => ({ + createIdentityOverride: builder.mutation< + FeatureState, + Req['createIdentityOverride'] + >({ + invalidatesTags: [{ id: 'LIST', type: 'IdentityOverride' }], + query: ({ + enabled, + environmentId, + feature_state_value, + featureId, + identityId, + }) => ({ + body: { + enabled, + feature: featureId, + feature_state_value: feature_state_value ?? null, + }, + method: 'POST', + url: `environments/${environmentId}/${Utils.getIdentitiesEndpoint()}/${identityId}/${Utils.getFeatureStatesEndpoint()}/`, + }), + }), + getIdentityOverrides: builder.query< + Res['identityOverrides'], + Req['getIdentityOverrides'] + >({ + providesTags: [{ id: 'LIST', type: 'IdentityOverride' }], + query: ({ environmentId, featureId, isEdge, page = 1 }) => ({ + url: isEdge + ? `environments/${environmentId}/edge-identity-overrides?feature=${featureId}&page=${page}` + : `environments/${environmentId}/${Utils.getFeatureStatesEndpoint()}/?anyIdentity=1&feature=${featureId}&page=${page}`, + }), + transformResponse( + res: + | PagedResponse + | PagedResponse, + _, + { isEdge }, + ): Res['identityOverrides'] { + if (isEdge) { + const edgeRes = res as PagedResponse + return { + ...edgeRes, + results: edgeRes.results.map((v) => ({ + ...v.feature_state, + identity: { + id: v.identity_uuid, + identifier: v.identifier, + }, + })) as IdentityOverride[], + } + } + return res as PagedResponse + }, + }), + // END OF ENDPOINTS + }), + }) + +export const { + useCreateIdentityOverrideMutation, + useGetIdentityOverridesQuery, + // END OF EXPORTS +} = identityOverrideService diff --git a/frontend/common/types/requests.ts b/frontend/common/types/requests.ts index acdae2039e27..fc48e4e17841 100644 --- a/frontend/common/types/requests.ts +++ b/frontend/common/types/requests.ts @@ -24,6 +24,7 @@ import { StageActionType, StageActionBody, ChangeRequest, + FlagsmithValue, TagStrategy, } from './responses' import { UtmsType } from './utms' @@ -234,7 +235,7 @@ export type Req = { pages?: (string | undefined)[] // this is needed for edge since it returns no paging info other than a key isEdge: boolean }> - getPermission: { id: number; level: PermissionLevel } + getPermission: { id: number | string; level: PermissionLevel } getAvailablePermissions: { level: PermissionLevel } getTag: { id: number } getHealthEvents: { projectId: number } @@ -250,7 +251,10 @@ export type Req = { getTags: { projectId: number } - createTag: { projectId: number; tag: Omit } + createTag: { + projectId: number + tag: Omit + } getSegment: { projectId: number; id: number } updateAccount: Account deleteAccount: { @@ -883,5 +887,18 @@ export type Req = { } } } + getIdentityOverrides: { + environmentId: string + featureId: number + page?: number + isEdge: boolean + } + createIdentityOverride: { + environmentId: string + identityId: string + featureId: number + enabled: boolean + feature_state_value: FlagsmithValue | null + } // END OF TYPES } diff --git a/frontend/common/types/responses.ts b/frontend/common/types/responses.ts index d73a95353bda..2265891e198c 100644 --- a/frontend/common/types/responses.ts +++ b/frontend/common/types/responses.ts @@ -566,6 +566,18 @@ export type IdentityFeatureState = { }[] } +export type EdgeIdentityOverrideItem = { + feature_state: FeatureState + identity_uuid: string + identifier: string +} + +export type IdentityOverride = FeatureState & { + identity: { id: string; identifier: string } + segment?: null + overridden_by?: string | null +} + export type FeatureState = { change_request?: number created_at: string @@ -1165,6 +1177,7 @@ export type Res = { identityFeatureStates: IdentityFeatureState[] cloneidentityFeatureStates: IdentityFeatureState featureStates: PagedResponse + identityOverrides: PagedResponse samlConfiguration: SAMLConfiguration samlConfigurations: PagedResponse samlMetadata: { diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 1616c5760533..b953045d740a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -130,6 +130,7 @@ "@types/color": "^3.0.3", "@types/dompurify": "^3.2.0", "@types/jest": "^30.0.0", + "@types/lodash": "^4.17.24", "@types/rc-switch": "^1.9.5", "@types/react-router": "^5.1.20", "@types/react-router-dom": "^5.3.3", @@ -5597,6 +5598,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/lodash": { + "version": "4.17.24", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz", + "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/mdast": { "version": "3.0.15", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-3.0.15.tgz", diff --git a/frontend/package.json b/frontend/package.json index 65524c4356de..7a1e53625582 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -158,6 +158,7 @@ "@types/color": "^3.0.3", "@types/dompurify": "^3.2.0", "@types/jest": "^30.0.0", + "@types/lodash": "^4.17.24", "@types/rc-switch": "^1.9.5", "@types/react-router": "^5.1.20", "@types/react-router-dom": "^5.3.3", diff --git a/frontend/web/components/RemoveUserOverride.tsx b/frontend/web/components/RemoveUserOverride.tsx index 808bd3c4a32b..0fb72cce3ab5 100644 --- a/frontend/web/components/RemoveUserOverride.tsx +++ b/frontend/web/components/RemoveUserOverride.tsx @@ -1,11 +1,7 @@ import ConfirmRemoveFeature from './modals/ConfirmRemoveFeature' import React from 'react' import AppActions from 'common/dispatcher/app-actions' -import { - FeatureState, - IdentityFeatureState, - ProjectFlag, -} from 'common/types/responses' +import { IdentityFeatureState, ProjectFlag } from 'common/types/responses' export const removeUserOverride = ({ cb, environmentId, diff --git a/frontend/web/components/base/grid/Column.js b/frontend/web/components/base/grid/Column.js index 50afab89aa38..adbb5512faf3 100644 --- a/frontend/web/components/base/grid/Column.js +++ b/frontend/web/components/base/grid/Column.js @@ -24,4 +24,4 @@ Column.propTypes = { value: OptionalNumber, } -module.exports = Column +export default Column diff --git a/frontend/web/components/base/grid/Flex.js b/frontend/web/components/base/grid/Flex.js index 4a428247ceca..990d4ff4b02e 100644 --- a/frontend/web/components/base/grid/Flex.js +++ b/frontend/web/components/base/grid/Flex.js @@ -24,4 +24,4 @@ Flex.propTypes = { value: OptionalNumber, } -module.exports = Flex +export default Flex diff --git a/frontend/web/components/feature-summary/FeatureRow.tsx b/frontend/web/components/feature-summary/FeatureRow.tsx index c5e1df40b1d7..23bf1c8bf1ef 100644 --- a/frontend/web/components/feature-summary/FeatureRow.tsx +++ b/frontend/web/components/feature-summary/FeatureRow.tsx @@ -14,6 +14,7 @@ import Button from 'components/base/forms/Button' import { Environment, FeatureListProviderData, + FeatureState, ProjectFlag, ReleasePipeline, } from 'common/types/responses' @@ -39,9 +40,7 @@ export interface FeatureRowProps { removeFlag?: (projectFlag: ProjectFlag) => void | Promise toggleFlag?: ( projectFlag: ProjectFlag, - environmentFlag: - | FeatureListProviderData['environmentFlags'][number] - | undefined, + environmentFlag: FeatureState | undefined, onError?: () => void, ) => void | Promise index: number @@ -84,7 +83,7 @@ const FeatureRow: FC = (props) => { style, toggleFlag, } = props - const protectedTags = useProtectedTags(projectFlag, projectId) + const protectedTags = useProtectedTags(projectFlag, String(projectId)) const history = useHistory() const { id } = projectFlag @@ -99,7 +98,7 @@ const FeatureRow: FC = (props) => { } = useFeatureRowState(actualEnabled) const { data: healthEvents } = useGetHealthEventsQuery( - { projectId: String(projectFlag.project) }, + { projectId: projectFlag.project }, { skip: !projectFlag?.project }, ) @@ -206,7 +205,6 @@ const FeatureRow: FC = (props) => { ? { environmentFlag, environmentId, - flagId: environmentFlag?.id, history, noPermissions: !permission, projectFlag, @@ -216,7 +214,6 @@ const FeatureRow: FC = (props) => { : { environmentFlag, environmentId, - flagId: environmentFlag?.id, hasUnhealthyEvents: isFeatureHealthEnabled && !!featureUnhealthyEvents?.length, hideTagsByType: ['UNHEALTHY'], @@ -313,7 +310,7 @@ const FeatureRow: FC = (props) => { if (disableControls) return editFeature(Constants.featurePanelTabs.HISTORY) }, - projectId, + projectId: String(projectId), protectedTags, readOnly: isReadOnly, tags: projectFlag.tags, diff --git a/frontend/web/components/modals/AuditLogWebhooks.tsx b/frontend/web/components/modals/AuditLogWebhooks.tsx index 673077e4b1c3..e6d2c23db93a 100644 --- a/frontend/web/components/modals/AuditLogWebhooks.tsx +++ b/frontend/web/components/modals/AuditLogWebhooks.tsx @@ -23,7 +23,10 @@ type AuditLogWebhooksType = { const AuditLogWebhooks: FC = ({ organisationId }) => { const { data: webhooks, isLoading: webhooksLoading } = - useGetAuditLogWebhooksQuery({ organisationId }, { skip: !organisationId }) + useGetAuditLogWebhooksQuery( + { organisationId: parseInt(organisationId) }, + { skip: !organisationId }, + ) const createWebhook = () => { openModal( 'New Webhook', diff --git a/frontend/web/components/modals/create-experiment/index.js b/frontend/web/components/modals/create-experiment/index.js index 283c5eb05d9d..808c3f545a2a 100644 --- a/frontend/web/components/modals/create-experiment/index.js +++ b/frontend/web/components/modals/create-experiment/index.js @@ -19,11 +19,11 @@ import { saveFeatureWithValidation } from 'components/saveFeatureWithValidation' import { FlagValueFooter } from 'components/modals/FlagValueFooter' import { getChangeRequests } from 'common/services/useChangeRequest' import ProjectProvider from 'common/providers/ProjectProvider' -import CreateFeature from 'components/modals/create-feature/tabs/CreateFeature' -import FeatureValueTab from 'components/modals/create-feature/tabs/FeatureValue' -import FeatureLimitAlert from 'components/modals/create-feature/FeatureLimitAlert' -import FeatureUpdateSummary from 'components/modals/create-feature/FeatureUpdateSummary' -import FeatureNameInput from 'components/modals/create-feature/FeatureNameInput' +import CreateFeature from 'components/modals/create-feature/tabs/CreateFeatureTab' +import FeatureValueTab from 'components/modals/create-feature/tabs/FeatureValueTab' +import FeatureLimitAlert from 'components/modals/create-feature/components/FeatureLimitAlert' +import FeatureUpdateSummary from 'components/modals/create-feature/components/FeatureUpdateSummary' +import FeatureNameInput from 'components/modals/create-feature/components/FeatureNameInput' import ExperimentResultsTab from './ExperimentResultsTab' import moment from 'moment' diff --git a/frontend/web/components/modals/create-feature/FeatureLimitAlert.tsx b/frontend/web/components/modals/create-feature/components/FeatureLimitAlert.tsx similarity index 87% rename from frontend/web/components/modals/create-feature/FeatureLimitAlert.tsx rename to frontend/web/components/modals/create-feature/components/FeatureLimitAlert.tsx index 5c25e49d6c74..890b754a0758 100644 --- a/frontend/web/components/modals/create-feature/FeatureLimitAlert.tsx +++ b/frontend/web/components/modals/create-feature/components/FeatureLimitAlert.tsx @@ -11,7 +11,9 @@ const FeatureLimitAlert: FC = ({ onChange, projectId, }) => { - const { data: project } = useGetProjectQuery({ id: `${projectId}` }) + const { data: project } = useGetProjectQuery({ + id: typeof projectId === 'string' ? parseInt(projectId, 10) : projectId, + }) const featureLimitAlert = Utils.calculateRemainingLimitsPercentage( project?.total_features, diff --git a/frontend/web/components/modals/create-feature/FeatureNameInput.tsx b/frontend/web/components/modals/create-feature/components/FeatureNameInput.tsx similarity index 98% rename from frontend/web/components/modals/create-feature/FeatureNameInput.tsx rename to frontend/web/components/modals/create-feature/components/FeatureNameInput.tsx index 2c5e38dccb0d..ab329ca0b996 100644 --- a/frontend/web/components/modals/create-feature/FeatureNameInput.tsx +++ b/frontend/web/components/modals/create-feature/components/FeatureNameInput.tsx @@ -12,7 +12,7 @@ type FeatureNameInputProps = { value: string onChange: (name: string) => void caseSensitive: boolean - regex?: string + regex?: string | null regexValid: boolean autoFocus?: boolean } diff --git a/frontend/web/components/modals/create-feature/FeatureUpdateSummary.tsx b/frontend/web/components/modals/create-feature/components/FeatureUpdateSummary.tsx similarity index 93% rename from frontend/web/components/modals/create-feature/FeatureUpdateSummary.tsx rename to frontend/web/components/modals/create-feature/components/FeatureUpdateSummary.tsx index a3dc3fd08e4c..72e5f759fa73 100644 --- a/frontend/web/components/modals/create-feature/FeatureUpdateSummary.tsx +++ b/frontend/web/components/modals/create-feature/components/FeatureUpdateSummary.tsx @@ -27,7 +27,10 @@ const FeatureUpdateSummary: FC = ({ projectId, regexValid, }) => { - const { data: project } = useGetProjectQuery({ id: projectId }) + const { data: project } = useGetProjectQuery( + { id: parseInt(projectId as string, 10) }, + { skip: !projectId }, + ) const preventFlagDefaults = !!project?.prevent_flag_defaults return ( diff --git a/frontend/web/components/modals/create-feature/components/IdentitySaveFooter.tsx b/frontend/web/components/modals/create-feature/components/IdentitySaveFooter.tsx new file mode 100644 index 000000000000..4ecd4f7d3a53 --- /dev/null +++ b/frontend/web/components/modals/create-feature/components/IdentitySaveFooter.tsx @@ -0,0 +1,58 @@ +import React, { FC } from 'react' +import Utils from 'common/utils/utils' +import Button from 'components/base/forms/Button' +import { EnvironmentPermission } from 'common/types/permissions.types' + +type IdentitySaveFooterProps = { + identityName?: string + environmentName: string + savePermission: boolean + isSaving: boolean + projectFlagName: string + invalid: boolean | number + onSave: () => void +} + +const IdentitySaveFooter: FC = ({ + environmentName, + identityName, + invalid, + isSaving, + onSave, + projectFlagName, + savePermission, +}) => { + return ( +
+
+

+ This will update the feature value for the user{' '} + {identityName} in + {environmentName}. + {' Any segment overrides for this feature will now be ignored.'} +

+
+ +
+ {Utils.renderWithPermission( + savePermission, + EnvironmentPermission.UPDATE_FEATURE_STATE, +
+ +
, + )} +
+
+ ) +} + +export default IdentitySaveFooter diff --git a/frontend/web/components/modals/create-feature/hoc/FeatureProvider.tsx b/frontend/web/components/modals/create-feature/hoc/FeatureProvider.tsx new file mode 100644 index 000000000000..254fec52a67f --- /dev/null +++ b/frontend/web/components/modals/create-feature/hoc/FeatureProvider.tsx @@ -0,0 +1,121 @@ +import React, { Component } from 'react' +import FeatureListStore from 'common/stores/feature-list-store' +import ES6Component from 'common/ES6Component' +import { setModalTitle } from 'components/modals/base/ModalDefault' + +// TODO: Migrate to a custom hook once we move away from Flux stores. +// This class component is necessary because it uses ES6Component/listenTo +// to subscribe to FeatureListStore events, which requires class lifecycle methods. +export default function withFeatureProvider( + WrappedComponent: React.ComponentType, +) { + class FeatureProvider extends Component { + constructor(props: any) { + super(props) + this.state = { + ...props, + } + ES6Component(this) + } + + componentDidMount() { + ES6Component(this) + this.listenTo( + FeatureListStore, + 'saved', + ({ + changeRequest, + createdFlag, + error, + isCreate, + updatedChangeRequest, + }: any = {}) => { + if (error?.data?.metadata) { + error.data.metadata?.forEach((m: any) => { + if (Object.keys(m).length > 0) { + toast(m.non_field_errors[0], 'danger') + } + }) + } else if (error?.data) { + toast('Error updating the Flag', 'danger') + return + } else { + const isEditingChangeRequest = + this.props.changeRequest && changeRequest + const operation = createdFlag || isCreate ? 'Created' : 'Updated' + const type = changeRequest ? 'Change Request' : 'Feature' + + const toastText = isEditingChangeRequest + ? `Updated ${type}` + : `${operation} ${type}` + const toastAction = changeRequest + ? { + buttonText: 'Open', + onClick: () => { + closeModal() + this.props.history.push( + `/project/${this.props.projectId}/environment/${this.props.environmentId}/change-requests/${updatedChangeRequest?.id}`, + ) + }, + } + : undefined + + toast(toastText, 'success', undefined, toastAction) + } + const envFlags = FeatureListStore.getEnvironmentFlags() + + if (createdFlag) { + const projectFlag = FeatureListStore.getProjectFlags()?.find?.( + (flag: any) => flag.name === createdFlag, + ) + window.history.replaceState( + {}, + `${document.location.pathname}?feature=${projectFlag.id}`, + ) + const newEnvironmentFlag = envFlags?.[projectFlag.id] || {} + setModalTitle(`Edit Feature ${projectFlag.name}`) + this.setState({ + environmentFlag: { + ...this.state.environmentFlag, + ...(newEnvironmentFlag || {}), + }, + projectFlag, + segmentsChanged: false, + settingsChanged: false, + valueChanged: false, + }) + } else if (this.props.projectFlag) { + const newEnvironmentFlag = + envFlags?.[this.props.projectFlag.id] || {} + const newProjectFlag = FeatureListStore.getProjectFlags()?.find?.( + (flag: any) => flag.id === this.props.projectFlag.id, + ) + this.setState({ + environmentFlag: { + ...this.state.environmentFlag, + ...(newEnvironmentFlag || {}), + }, + projectFlag: newProjectFlag, + segmentsChanged: false, + settingsChanged: false, + valueChanged: false, + }) + } + }, + ) + } + + listenTo: any + + render() { + return ( + + ) + } + } + + return FeatureProvider +} diff --git a/frontend/web/components/modals/create-feature/index.js b/frontend/web/components/modals/create-feature/index.js deleted file mode 100644 index efdc235dda61..000000000000 --- a/frontend/web/components/modals/create-feature/index.js +++ /dev/null @@ -1,2061 +0,0 @@ -import React, { Component } from 'react' -import withSegmentOverrides from 'common/providers/withSegmentOverrides' -import moment from 'moment' -import Constants from 'common/constants' -import data from 'common/data/base/_data' -import ProjectStore from 'common/stores/project-store' -import ConfigProvider from 'common/providers/ConfigProvider' -import FeatureListStore from 'common/stores/feature-list-store' -import IdentityProvider from 'common/providers/IdentityProvider' -import Tabs from 'components/navigation/TabMenu/Tabs' -import TabItem from 'components/navigation/TabMenu/TabItem' -import SegmentOverrides from 'components/SegmentOverrides' -import ChangeRequestModal from 'components/modals/ChangeRequestModal' -import classNames from 'classnames' -import InfoMessage from 'components/InfoMessage' -import JSONReference from 'components/JSONReference' -import ErrorMessage from 'components/ErrorMessage' -import Permission from 'common/providers/Permission' -import IdentitySelect from 'components/IdentitySelect' -import { - setInterceptClose, - setModalTitle, -} from 'components/modals/base/ModalDefault' -import Icon from 'components/Icon' -import ModalHR from 'components/modals/ModalHR' -import FeatureValue from 'components/feature-summary/FeatureValue' -import { getStore } from 'common/store' -import Button from 'components/base/forms/Button' -import { getSupportedContentType } from 'common/services/useSupportedContentType' -import { getGithubIntegration } from 'common/services/useGithubIntegration' -import { removeUserOverride } from 'components/RemoveUserOverride' -import ExternalResourcesLinkTab from 'components/ExternalResourcesLinkTab' -import { saveFeatureWithValidation } from 'components/saveFeatureWithValidation' -import FeatureHistory from 'components/FeatureHistory' -import WarningMessage from 'components/WarningMessage' -import FeatureAnalytics from 'components/feature-page/FeatureNavTab/FeatureAnalytics' -import { FlagValueFooter } from 'components/modals/FlagValueFooter' -import { getPermission } from 'common/services/usePermission' -import { getChangeRequests } from 'common/services/useChangeRequest' -import FeatureHealthTabContent from 'components/feature-health/FeatureHealthTabContent' -import FeaturePipelineStatus from 'components/release-pipelines/FeaturePipelineStatus' -import FeatureInPipelineGuard from 'components/release-pipelines/FeatureInPipelineGuard' -import FeatureCodeReferencesContainer from 'components/feature-page/FeatureNavTab/CodeReferences/FeatureCodeReferencesContainer' -import ProjectProvider from 'common/providers/ProjectProvider' -import CreateFeature from './tabs/CreateFeature' -import FeatureSettings from './tabs/FeatureSettings' -import FeatureValueTab from './tabs/FeatureValue' -import FeatureLimitAlert from './FeatureLimitAlert' -import FeatureUpdateSummary from './FeatureUpdateSummary' -import FeatureNameInput from './FeatureNameInput' -import { - EnvironmentPermission, - ProjectPermission, -} from 'common/types/permissions.types' - -const Index = class extends Component { - static displayName = 'create-feature' - - constructor(props, context) { - super(props, context) - if (this.props.projectFlag) { - this.userOverridesPage(1, true) - } - - const projectFlagData = this.props.projectFlag - ? _.cloneDeep(this.props.projectFlag) - : { - description: undefined, - is_archived: undefined, - is_server_key_only: undefined, - metadata: [], - multivariate_options: [], - name: undefined, - tags: [], - } - - const sourceFlag = this.props.identityFlag || this.props.environmentFlag - const environmentFlagData = sourceFlag ? _.cloneDeep(sourceFlag) : {} - - this.state = { - changeRequests: [], - enabledIndentity: false, - enabledSegment: false, - environmentFlag: environmentFlagData, - externalResource: {}, - externalResources: [], - featureContentType: {}, - featureLimitAlert: { percentage: 0 }, - githubId: '', - hasIntegrationWithGithub: false, - hasMetadataRequired: false, - isEdit: !!this.props.projectFlag, - period: 30, - projectFlag: projectFlagData, - scheduledChangeRequests: [], - segmentsChanged: false, - selectedIdentity: null, - settingsChanged: false, - userOverridesError: false, - userOverridesNoPermission: false, - valueChanged: false, - } - } - - close() { - closeModal() - } - - componentDidUpdate(prevProps) { - ES6Component(this) - - const environmentFlagSource = - this.props.identityFlag || this.props.environmentFlag - const prevEnvironmentFlagSource = - prevProps.identityFlag || prevProps.environmentFlag - - if ( - environmentFlagSource && - prevEnvironmentFlagSource && - environmentFlagSource.updated_at && - prevEnvironmentFlagSource.updated_at && - environmentFlagSource.updated_at !== prevEnvironmentFlagSource.updated_at - ) { - this.setState({ - environmentFlag: _.cloneDeep(environmentFlagSource), - }) - } - - if ( - this.props.projectFlag && - prevProps.projectFlag && - this.props.projectFlag.updated_at && - prevProps.projectFlag.updated_at && - this.props.projectFlag.updated_at !== prevProps.projectFlag.updated_at - ) { - this.setState({ - projectFlag: _.cloneDeep(this.props.projectFlag), - }) - } - - if ( - !this.props.identity && - this.props.environmentVariations !== prevProps.environmentVariations - ) { - if ( - this.props.environmentVariations && - this.props.environmentVariations.length - ) { - this.setState({ - projectFlag: { - ...this.state.projectFlag, - multivariate_options: - this.state.projectFlag.multivariate_options && - this.state.projectFlag.multivariate_options.map((v) => { - const matchingVariation = ( - this.props.multivariate_options || - this.props.environmentVariations - ).find((e) => e.multivariate_feature_option === v.id) - return { - ...v, - default_percentage_allocation: - (matchingVariation && - matchingVariation.percentage_allocation) || - v.default_percentage_allocation || - 0, - } - }), - }, - }) - } - } - } - - onClosing = () => { - if (this.state.isEdit) { - return new Promise((resolve) => { - const projectFlagChanged = this.state.settingsChanged - const environmentFlagChanged = this.state.valueChanged - const segmentOverridesChanged = this.state.segmentsChanged - if ( - projectFlagChanged || - environmentFlagChanged || - segmentOverridesChanged - ) { - openConfirm({ - body: 'Closing this will discard your unsaved changes.', - noText: 'Cancel', - onNo: () => resolve(false), - onYes: () => resolve(true), - title: 'Discard changes', - yesText: 'Ok', - }) - } else { - resolve(true) - } - }) - } - return Promise.resolve(true) - } - - componentDidMount = () => { - setInterceptClose(this.onClosing) - if (Utils.getPlansPermission('METADATA')) { - getSupportedContentType(getStore(), { - organisation_id: AccountStore.getOrganisation().id, - }).then((res) => { - const featureContentType = Utils.getContentType( - res.data, - 'model', - 'feature', - ) - this.setState({ featureContentType: featureContentType }) - }) - } - - this.fetchChangeRequests() - this.fetchScheduledChangeRequests() - - getGithubIntegration(getStore(), { - organisation_id: AccountStore.getOrganisation().id, - }).then((res) => { - this.setState({ - githubId: res?.data?.results[0]?.id, - hasIntegrationWithGithub: !!res?.data?.results?.length, - }) - }) - } - - componentWillUnmount() { - if (this.focusTimeout) { - clearTimeout(this.focusTimeout) - } - } - - setUserOverridesError = () => { - this.setState({ - userOverrides: [], - userOverridesError: true, - userOverridesNoPermission: false, - userOverridesPaging: { count: 0, currentPage: 1, next: null }, - }) - } - - setUserOverridesNoPermission = () => { - this.setState({ - userOverrides: [], - userOverridesError: false, - userOverridesNoPermission: true, - userOverridesPaging: { count: 0, currentPage: 1, next: null }, - }) - } - - userOverridesPage = (page, forceRefetch) => { - if (Utils.getIsEdge()) { - // Early return if tab should be hidden - if (Utils.getShouldHideIdentityOverridesTab(ProjectStore.model)) { - this.setState({ - userOverrides: [], - userOverridesPaging: { - count: 0, - currentPage: 1, - next: null, - }, - }) - return - } - - getPermission( - getStore(), - { - id: this.props.environmentId, - level: 'environment', - permissions: EnvironmentPermission.VIEW_IDENTITIES, - }, - { forceRefetch }, - ) - .then((permissions) => { - const hasViewIdentitiesPermission = - permissions[EnvironmentPermission.VIEW_IDENTITIES] || - permissions.ADMIN - // Early return if user doesn't have permission - if (!hasViewIdentitiesPermission) { - this.setUserOverridesNoPermission() - return - } - - data - .get( - `${Project.api}environments/${this.props.environmentId}/edge-identity-overrides?feature=${this.props.projectFlag.id}&page=${page}`, - ) - .then((userOverrides) => { - this.setState({ - userOverrides: userOverrides.results.map((v) => ({ - ...v.feature_state, - identity: { - id: v.identity_uuid, - identifier: v.identifier, - }, - })), - userOverridesError: false, - userOverridesNoPermission: false, - userOverridesPaging: { - count: userOverrides.count, - currentPage: page, - next: userOverrides.next, - }, - }) - }) - .catch((response) => { - if (response?.status === 403) { - this.setUserOverridesNoPermission() - } else { - this.setUserOverridesError() - } - }) - }) - .catch(() => { - this.setUserOverridesError() - }) - - return - } - - data - .get( - `${Project.api}environments/${ - this.props.environmentId - }/${Utils.getFeatureStatesEndpoint()}/?anyIdentity=1&feature=${ - this.props.projectFlag.id - }&page=${page}`, - ) - .then((userOverrides) => { - this.setState({ - userOverrides: userOverrides.results, - userOverridesError: false, - userOverridesNoPermission: false, - userOverridesPaging: { - count: userOverrides.count, - currentPage: page, - next: userOverrides.next, - }, - }) - }) - .catch((response) => { - if (response?.status === 403) { - this.setUserOverridesNoPermission() - } else { - this.setUserOverridesError() - } - }) - } - - renderUserOverridesNoResults = () => { - if (this.state.userOverridesError) { - return ( -
- Failed to load identity overrides. -
- ) - } - if (this.state.userOverridesNoPermission) { - return ( -
- You do not have permission to view identity overrides. -
- ) - } - return ( - -
- No identities are overriding this feature. -
-
- ) - } - - save = (func, isSaving) => { - const { - environmentFlag, - environmentId, - identity, - identityFlag, - projectFlag: _projectFlag, - segmentOverrides, - } = this.props - const { environmentFlag: stateEnvironmentFlag, projectFlag } = this.state - const hasMultivariate = - environmentFlag && - environmentFlag.multivariate_feature_state_values && - environmentFlag.multivariate_feature_state_values.length - if (identity) { - !isSaving && - projectFlag.name && - func({ - environmentFlag, - environmentId, - identity, - identityFlag: Object.assign({}, identityFlag || {}, { - enabled: stateEnvironmentFlag.enabled, - feature_state_value: hasMultivariate - ? environmentFlag.feature_state_value - : this.cleanInputValue(stateEnvironmentFlag.feature_state_value), - multivariate_options: - stateEnvironmentFlag.multivariate_feature_state_values, - }), - projectFlag, - }) - } else { - FeatureListStore.isSaving = true - FeatureListStore.trigger('change') - !isSaving && - projectFlag.name && - func( - this.props.projectId, - this.props.environmentId, - { - default_enabled: stateEnvironmentFlag.enabled, - description: projectFlag.description, - initial_value: this.cleanInputValue( - stateEnvironmentFlag.feature_state_value, - ), - is_archived: projectFlag.is_archived, - is_server_key_only: projectFlag.is_server_key_only, - metadata: - !this.props.projectFlag?.metadata || - (this.props.projectFlag.metadata !== projectFlag.metadata && - projectFlag.metadata.length) - ? projectFlag.metadata - : this.props.projectFlag.metadata, - multivariate_options: projectFlag.multivariate_options, - name: projectFlag.name, - tags: projectFlag.tags, - }, - { - skipSaveProjectFeature: this.state.skipSaveProjectFeature, - ..._projectFlag, - }, - { - ...environmentFlag, - multivariate_feature_state_values: - this.props.environmentVariations || - environmentFlag?.multivariate_feature_state_values, - }, - segmentOverrides, - ) - } - } - - changeSegment = (items) => { - const { enabledSegment } = this.state - items.forEach((item) => { - item.enabled = enabledSegment - }) - this.props.updateSegments(items) - this.setState({ enabledSegment: !enabledSegment }) - } - - changeIdentity = (items) => { - const { environmentId } = this.props - const { enabledIndentity } = this.state - - Promise.all( - items.map( - (item) => - new Promise((resolve) => { - AppActions.changeUserFlag({ - environmentId, - identity: item.identity.id, - identityFlag: item.id, - onSuccess: resolve, - payload: { - enabled: enabledIndentity, - id: item.identity.id, - value: item.identity.identifier, - }, - }) - }), - ), - ).then(() => { - this.userOverridesPage(1) - }) - - this.setState({ enabledIndentity: !enabledIndentity }) - } - - toggleUserFlag = ({ enabled, id, identity }) => { - const { environmentId } = this.props - - AppActions.changeUserFlag({ - environmentId, - identity: identity.id, - identityFlag: id, - onSuccess: () => { - this.userOverridesPage(1) - }, - payload: { - enabled: !enabled, - id: identity.id, - value: identity.identifier, - }, - }) - } - parseError = (error) => { - const { projectFlag } = this.props - let featureError = - error?.metadata?.flatMap((m) => m.non_field_errors ?? []).join('\n') || - error?.message || - error?.name?.[0] || - error - let featureWarning = '' - //Treat multivariate no changes as warnings - if ( - featureError?.includes?.('no changes') && - projectFlag?.multivariate_options?.length - ) { - featureWarning = `Your feature contains no changes to its value, enabled state or environment weights. If you have adjusted any variation values this will have been saved for all environments.` - featureError = '' - } - return { featureError, featureWarning } - } - cleanInputValue = (value) => { - if (value && typeof value === 'string') { - return value.trim() - } - return value - } - - addItem = () => { - const { environmentFlag, environmentId, identity, projectFlag } = this.props - this.setState({ isLoading: true }) - const selectedIdentity = this.state.selectedIdentity.value - const identities = identity ? identity.identifier : [] - - if (!_.find(identities, (v) => v.identifier === selectedIdentity)) { - data - .post( - `${ - Project.api - }environments/${environmentId}/${Utils.getIdentitiesEndpoint()}/${selectedIdentity}/${Utils.getFeatureStatesEndpoint()}/`, - { - enabled: !environmentFlag.enabled, - feature: projectFlag.id, - feature_state_value: environmentFlag.value || null, - }, - ) - .then(() => { - this.setState({ - isLoading: false, - selectedIdentity: null, - }) - this.userOverridesPage(1) - }) - .catch((e) => { - this.setState({ error: e, isLoading: false }) - }) - } else { - this.setState({ - isLoading: false, - selectedIdentity: null, - }) - } - } - - fetchChangeRequests = (forceRefetch) => { - const { environmentId, projectFlag } = this.props - if (!projectFlag?.id) return - - getChangeRequests( - getStore(), - { - committed: false, - environmentId, - feature_id: projectFlag?.id, - }, - { forceRefetch }, - ).then((res) => { - this.setState({ changeRequests: res.data?.results }) - }) - } - - fetchScheduledChangeRequests = (forceRefetch) => { - const { environmentId, projectFlag } = this.props - if (!projectFlag?.id) return - - const date = moment().toISOString() - - getChangeRequests( - getStore(), - { - environmentId, - feature_id: projectFlag.id, - live_from_after: date, - }, - { forceRefetch }, - ).then((res) => { - this.setState({ scheduledChangeRequests: res.data?.results }) - }) - } - - render() { - const { - enabledIndentity, - enabledSegment, - environmentFlag, - featureContentType, - githubId, - hasIntegrationWithGithub, - isEdit, - projectFlag, - } = this.state - const { identity, identityName } = this.props - const Provider = identity ? IdentityProvider : FeatureListProvider - const environment = ProjectStore.getEnvironment(this.props.environmentId) - const isVersioned = !!environment?.use_v2_feature_versioning - const is4Eyes = - !!environment && - Utils.changeRequestsEnabled(environment.minimum_change_request_approvals) - const project = ProjectStore.model - const caseSensitive = project?.only_allow_lower_case_feature_names - const regex = project?.feature_name_regex - const controlValue = Utils.calculateControl( - projectFlag.multivariate_options, - ) - const invalid = - !!projectFlag.multivariate_options && - projectFlag.multivariate_options.length && - controlValue < 0 - const existingChangeRequest = this.props.changeRequest - const isVersionedChangeRequest = existingChangeRequest && isVersioned - const hideIdentityOverridesTab = Utils.getShouldHideIdentityOverridesTab() - const noPermissions = this.props.noPermissions - let regexValid = true - - const hasCodeReferences = projectFlag?.code_references_counts?.length > 0 - - try { - if (!isEdit && projectFlag.name && regex) { - regexValid = projectFlag.name.match(new RegExp(regex)) - } - } catch (e) { - regexValid = false - } - - return ( - - {({ project }) => ( - { - if (identity) { - this.close() - } - AppActions.refreshFeatures( - this.props.projectId, - this.props.environmentId, - ) - - if (is4Eyes && !identity) { - this.fetchChangeRequests(true) - this.fetchScheduledChangeRequests(true) - } - - if (this.props.changeRequest) { - this.close() - } - }} - > - {( - { error, isSaving }, - { - createChangeRequest, - createFlag, - editFeatureSegments, - editFeatureSettings, - editFeatureValue, - }, - ) => { - const saveFeatureValue = saveFeatureWithValidation((schedule) => { - if ((is4Eyes || schedule) && !identity) { - this.setState({ segmentsChanged: false, valueChanged: false }) - // Until this page and feature-list-store are refactored, this is the best way of parsing feature states - const featureStates = (this.props.segmentOverrides || []) - .filter((override) => !override.toRemove) - .map((override) => { - return { - enabled: override.enabled, - feature: override.feature, - feature_segment: { - environment: override.environment, - id: override.id, - is_feature_specific: override.is_feature_specific, - priority: override.priority, - segment: override.segment, - segment_name: override.segment_name, - uuid: override.uuid, - }, - feature_state_value: Utils.valueToFeatureState( - override.value, - ), - id: override.id, - multivariate_feature_state_values: - override.multivariate_options, - } - }) - .concat([ - Object.assign({}, this.props.environmentFlag, { - enabled: environmentFlag.enabled, - feature_state_value: Utils.valueToFeatureState( - environmentFlag.feature_state_value, - ), - multivariate_feature_state_values: - environmentFlag.multivariate_feature_state_values, - }), - ]) - - const getModalTitle = () => { - if (schedule) { - return 'New Scheduled Flag Update' - } - if (this.props.changeRequest) { - return 'Update Change Request' - } - return 'New Change Request' - } - - let modalTitle = 'New Change Request' - if (schedule) { - modalTitle = 'New Scheduled Flag Update' - } else if (this.props.changeRequest) { - modalTitle = 'Update Change Request' - } - - openModal2( - getModalTitle(), - { - closeModal2() - this.save( - ( - projectId, - environmentId, - flag, - projectFlag, - environmentFlag, - segmentOverrides, - ) => { - createChangeRequest( - projectId, - environmentId, - flag, - projectFlag, - environmentFlag, - segmentOverrides, - { - approvals, - description, - featureStateId: - this.props.changeRequest && - this.props.changeRequest.feature_states?.[0] - ?.id, - id: - this.props.changeRequest && - this.props.changeRequest.id, - ignore_conflicts, - live_from, - multivariate_options: flag.multivariate_options, - title, - }, - !is4Eyes, - ) - }, - ) - }} - />, - ) - } else { - this.setState({ valueChanged: false }) - this.save(editFeatureValue, isSaving) - } - }) - - const saveSettings = () => { - this.setState({ settingsChanged: false }) - this.save(editFeatureSettings, isSaving) - } - - const saveFeatureSegments = saveFeatureWithValidation( - (schedule) => { - this.setState({ segmentsChanged: false }) - - if ((is4Eyes || schedule) && isVersioned && !identity) { - return saveFeatureValue(schedule) - } else { - this.save(editFeatureSegments, isSaving) - } - }, - ) - - const onCreateFeature = saveFeatureWithValidation(() => { - this.save(createFlag, isSaving) - }) - const isLimitReached = false - - const { featureError, featureWarning } = this.parseError(error) - - return ( - - {({ permission: createFeature }) => ( - - {({ permission: projectAdmin }) => { - this.state.skipSaveProjectFeature = !createFeature - - return ( -
- {isEdit && !identity ? ( - <> - - this.forceUpdate()} - urlParam='tab' - history={this.props.history} - overflowX - > - - Value{' '} - {this.state.valueChanged && ( -
- {'*'} -
- )} - - } - > - { - this.setState({ - environmentFlag: { - ...this.state.environmentFlag, - ...changes, - }, - valueChanged: true, - }) - }} - onProjectFlagChange={(changes) => { - this.setState({ - projectFlag: { - ...this.state.projectFlag, - ...changes, - }, - }) - }} - onRemoveMultivariateOption={ - this.props.removeMultivariateOption - } - /> - - - -
- {(!existingChangeRequest || - isVersionedChangeRequest) && ( - - Segment Overrides{' '} - {this.state.segmentsChanged && ( -
- * -
- )} - - } - > - - ( - <> -
- Segment Overrides{' '} -
- - This feature is in{' '} - - { - matchingReleasePipeline?.name - } - {' '} - release pipeline and no segment - overrides can be created - - - )} - > -
- -
- - Segment Overrides{' '} - - - } - place='top' - > - { - Constants.strings - .SEGMENT_OVERRIDES_DESCRIPTION - } - -
- - {({ - permission: - manageSegmentOverrides, - }) => - !this.state - .showCreateSegment && - !!manageSegmentOverrides && - !this.props.disableCreate && ( -
- -
- ) - } -
- {!this.state.showCreateSegment && - !noPermissions && ( - - )} -
- {this.props.segmentOverrides ? ( - - {({ - permission: - manageSegmentOverrides, - }) => { - const isReadOnly = - !manageSegmentOverrides - return ( - <> - - - - this.setState({ - showCreateSegment, - }) - } - readOnly={isReadOnly} - is4Eyes={is4Eyes} - showEditSegment - showCreateSegment={ - this.state - .showCreateSegment - } - feature={projectFlag.id} - projectId={ - this.props.projectId - } - multivariateOptions={ - projectFlag.multivariate_options - } - environmentId={ - this.props - .environmentId - } - value={ - this.props - .segmentOverrides - } - controlValue={ - environmentFlag.feature_state_value - } - onChange={(v) => { - this.setState({ - segmentsChanged: true, - }) - this.props.updateSegments( - v, - ) - }} - highlightSegmentId={ - this.props - .highlightSegmentId - } - /> - - ) - }} - - ) : ( -
- -
- )} - {!this.state.showCreateSegment && ( - - )} - {!this.state.showCreateSegment && ( -
-

- {is4Eyes && isVersioned - ? 'This will create a change request with any value and segment override changes for the environment' - : 'This will update the segment overrides for the environment'}{' '} - - { - _.find( - project.environments, - { - api_key: - this.props - .environmentId, - }, - ).name - } - -

-
- - {({ - permission: - savePermission, - }) => ( - - {({ - permission: - manageSegmentsOverrides, - }) => { - const getButtonText = - () => { - if (isSaving) { - return existingChangeRequest - ? 'Updating Change Request' - : 'Creating Change Request' - } - return existingChangeRequest - ? 'Update Change Request' - : 'Create Change Request' - } - - const getScheduleButtonText = - () => { - if (isSaving) { - return existingChangeRequest - ? 'Updating Change Request' - : 'Scheduling Update' - } - return existingChangeRequest - ? 'Update Change Request' - : 'Schedule Update' - } - - if ( - isVersioned && - is4Eyes - ) { - return Utils.renderWithPermission( - savePermission, - Utils.getManageFeaturePermissionDescription( - is4Eyes, - identity, - ), - , - ) - } - - return Utils.renderWithPermission( - manageSegmentsOverrides, - Constants.environmentPermissions( - EnvironmentPermission.MANAGE_SEGMENT_OVERRIDES, - ), - <> - {!is4Eyes && - isVersioned && ( - <> - - - )} - - , - ) - }} - - )} - -
-
- )} -
-
-
-
- )} - - {({ permission: viewIdentities }) => - !existingChangeRequest && - !hideIdentityOverridesTab && ( - - {viewIdentities ? ( - <> - - - - Identity Overrides{' '} - - - } - place='top' - > - { - Constants.strings - .IDENTITY_OVERRIDES_DESCRIPTION - } - -
- - Identity overrides - override feature - values for individual - identities. The - overrides take - priority over an - segment overrides and - environment defaults. - Identity overrides - will only apply when - you identify via the - SDK.{' '} - - Check the Docs for - more details - - . - -
- - } - action={ - !Utils.getIsEdge() && ( - - ) - } - items={ - this.state.userOverrides - } - paging={ - this.state - .userOverridesPaging - } - renderSearchWithNoResults - nextPage={() => - this.userOverridesPage( - this.state - .userOverridesPaging - .currentPage + 1, - ) - } - prevPage={() => - this.userOverridesPage( - this.state - .userOverridesPaging - .currentPage - 1, - ) - } - goToPage={(page) => - this.userOverridesPage(page) - } - searchPanel={ - !Utils.getIsEdge() && ( -
- - - v.identity?.id, - )} - environmentId={ - this.props - .environmentId - } - data-test='select-identity' - placeholder='Create an Identity Override...' - value={ - this.state - .selectedIdentity - } - onChange={( - selectedIdentity, - ) => - this.setState( - { - selectedIdentity, - }, - this.addItem, - ) - } - /> - -
- ) - } - renderRow={(identityFlag) => { - const { - enabled, - feature_state_value, - id, - identity, - } = identityFlag - return ( - - -
- - this.toggleUserFlag( - { - enabled, - id, - identity, - }, - ) - } - disabled={Utils.getIsEdge()} - /> -
-
- { - identity.identifier - } -
-
- -
- {feature_state_value !== - null && ( - - )} -
-
- - -
-
-
- ) - }} - renderNoResults={this.renderUserOverridesNoResults()} - isLoading={ - !this.state.userOverrides - } - /> -
- - ) : ( - -
- - )} - - ) - } - - {(!Project.disableAnalytics || - hasCodeReferences) && ( - - Usage - - } - > - {!Project.disableAnalytics && ( -
- -
- )} - {hasCodeReferences && ( - -
-
- Code references -
- - New - -
-
- Code references allow you to track - where feature flags are being used - within your code.{' '} - - Learn more - -
- -
- )} -
- )} - { - - - - } - {hasIntegrationWithGithub && - projectFlag?.id && ( - - Links - - } - > - - - )} - {!existingChangeRequest && - this.props.flagId && - isVersioned && ( - - - - )} - {!existingChangeRequest && ( - - Settings{' '} - {this.state.settingsChanged && ( -
- {'*'} -
- )} - - } - > - { - const updates = {} - - // Update projectFlag with changes - updates.projectFlag = { - ...this.state.projectFlag, - ...changes, - } - - // Set settingsChanged flag unless it's only metadata changing - if (changes.metadata === undefined) { - updates.settingsChanged = true - } - - this.setState(updates) - }} - onHasMetadataRequiredChange={( - hasMetadataRequired, - ) => - this.setState({ - hasMetadataRequired, - }) - } - /> - - - {isEdit && ( -
- {!!createFeature && ( - <> -

- This will save the above - settings{' '} - - all environments - - . -

- - - )} -
- )} -
- )} - - - ) : ( -
- - this.setState({ featureLimitAlert }) - } - /> -
- - this.setState({ - projectFlag: { - ...this.state.projectFlag, - name, - }, - }) - } - caseSensitive={caseSensitive} - regex={regex} - regexValid={regexValid} - autoFocus - /> -
- { - this.setState({ - environmentFlag: { - ...this.state.environmentFlag, - ...changes, - }, - valueChanged: true, - }) - }} - onProjectFlagChange={(changes) => { - this.setState({ - projectFlag: { - ...this.state.projectFlag, - ...changes, - }, - }) - }} - onRemoveMultivariateOption={ - this.props.removeMultivariateOption - } - onHasMetadataRequiredChange={( - hasMetadataRequired, - ) => { - this.setState({ - hasMetadataRequired, - }) - }} - featureError={ - this.parseError(error).featureError - } - featureWarning={ - this.parseError(error).featureWarning - } - /> - -
- )} - {identity && ( -
- {identity ? ( -
-

- This will update the feature value for the - user {identityName} in - - {' '} - { - _.find(project.environments, { - api_key: this.props.environmentId, - }).name - } - . - - { - ' Any segment overrides for this feature will now be ignored.' - } -

-
- ) : ( - '' - )} - -
- {identity && ( - - {({ permission: savePermission }) => - Utils.renderWithPermission( - savePermission, - EnvironmentPermission.UPDATE_FEATURE_STATE, -
- -
, - ) - } -
- )} -
-
- )} -
- ) - }} -
- )} - - ) - }} - - )} - - ) - } -} - -Index.propTypes = {} - -//This will remount the modal when a feature is created -const FeatureProvider = (WrappedComponent) => { - class HOC extends Component { - constructor(props) { - super(props) - this.state = { - ...props, - } - ES6Component(this) - } - - componentDidMount() { - // toast update feature - ES6Component(this) - this.listenTo( - FeatureListStore, - 'saved', - ({ - changeRequest, - createdFlag, - error, - isCreate, - updatedChangeRequest, - } = {}) => { - if (error?.data?.metadata) { - error.data.metadata?.forEach((m) => { - if (Object.keys(m).length > 0) { - toast(m.non_field_errors[0], 'danger') - } - }) - } else if (error?.data) { - toast('Error updating the Flag', 'danger') - return - } else { - const isEditingChangeRequest = - this.props.changeRequest && changeRequest - const operation = createdFlag || isCreate ? 'Created' : 'Updated' - const type = changeRequest ? 'Change Request' : 'Feature' - - const toastText = isEditingChangeRequest - ? `Updated ${type}` - : `${operation} ${type}` - const toastAction = changeRequest - ? { - buttonText: 'Open', - onClick: () => { - closeModal() - this.props.history.push( - `/project/${this.props.projectId}/environment/${this.props.environmentId}/change-requests/${updatedChangeRequest?.id}`, - ) - }, - } - : undefined - - toast(toastText, 'success', undefined, toastAction) - } - const envFlags = FeatureListStore.getEnvironmentFlags() - - if (createdFlag) { - const projectFlag = FeatureListStore.getProjectFlags()?.find?.( - (flag) => flag.name === createdFlag, - ) - window.history.replaceState( - {}, - `${document.location.pathname}?feature=${projectFlag.id}`, - ) - const newEnvironmentFlag = envFlags?.[projectFlag.id] || {} - setModalTitle(`Edit Feature ${projectFlag.name}`) - this.setState({ - environmentFlag: { - ...this.state.environmentFlag, - ...(newEnvironmentFlag || {}), - }, - projectFlag, - segmentsChanged: false, - settingsChanged: false, - valueChanged: false, - }) - } else if (this.props.projectFlag) { - //update the environmentFlag and projectFlag to the new values - const newEnvironmentFlag = - envFlags?.[this.props.projectFlag.id] || {} - const newProjectFlag = FeatureListStore.getProjectFlags()?.find?.( - (flag) => flag.id === this.props.projectFlag.id, - ) - this.setState({ - environmentFlag: { - ...this.state.environmentFlag, - ...(newEnvironmentFlag || {}), - }, - projectFlag: newProjectFlag, - segmentsChanged: false, - settingsChanged: false, - valueChanged: false, - }) - } - }, - ) - } - - render() { - return ( - - ) - } - } - return HOC -} - -const WrappedCreateFlag = ConfigProvider(withSegmentOverrides(Index)) - -export default FeatureProvider(WrappedCreateFlag) diff --git a/frontend/web/components/modals/create-feature/index.tsx b/frontend/web/components/modals/create-feature/index.tsx new file mode 100644 index 000000000000..cef28d85c1d1 --- /dev/null +++ b/frontend/web/components/modals/create-feature/index.tsx @@ -0,0 +1,841 @@ +import React, { FC, useCallback, useEffect, useRef, useState } from 'react' +// @ts-ignore untyped module +import _ from 'lodash' +import moment from 'moment' +import { useProjectEnvironments } from 'common/hooks/useProjectEnvironments' +import { useHasGithubIntegration } from 'common/hooks/useHasGithubIntegration' +import FeatureListStore from 'common/stores/feature-list-store' +import IdentityProvider from 'common/providers/IdentityProvider' +import FeatureListProvider from 'common/providers/FeatureListProvider' +import AppActions from 'common/dispatcher/app-actions' +import Project from 'common/project' +import Tabs from 'components/navigation/TabMenu/Tabs' +import TabItem from 'components/navigation/TabMenu/TabItem' +import ChangeRequestModal from 'components/modals/ChangeRequestModal' +import classNames from 'classnames' +import { useHasPermission } from 'common/providers/Permission' +import { setInterceptClose } from 'components/modals/base/ModalDefault' +import { getStore } from 'common/store' +import ExternalResourcesLinkTab from 'components/ExternalResourcesLinkTab' +import { saveFeatureWithValidation } from 'components/saveFeatureWithValidation' +import FeatureHistory from 'components/FeatureHistory' +import { getChangeRequests } from 'common/services/useChangeRequest' +import FeatureHealthTabContent from 'components/feature-health/FeatureHealthTabContent' +import FeaturePipelineStatus from 'components/release-pipelines/FeaturePipelineStatus' +import { History } from 'history' +import CreateFeature from './tabs/CreateFeatureTab' +import FeatureSettings from './tabs/FeatureSettingsTab' +import FeatureValueTab from './tabs/FeatureValueTab' +import IdentityOverridesTab from './tabs/IdentityOverridesTab' +import SegmentOverridesTab, { + SegmentOverrideValue, +} from './tabs/SegmentOverridesTab' +import UsageTab from './tabs/UsageTab' +import FeatureLimitAlert from './components/FeatureLimitAlert' +import FeatureUpdateSummary from './components/FeatureUpdateSummary' +import FeatureNameInput from './components/FeatureNameInput' +import IdentitySaveFooter from './components/IdentitySaveFooter' +import { ProjectPermission } from 'common/types/permissions.types' +import type { + ChangeRequest, + FeatureState, + MultivariateFeatureStateValue, + ProjectFlag, +} from 'common/types/responses' + +type CreateFeatureModalProps = { + projectFlag?: ProjectFlag + environmentFlag?: FeatureState + identityFlag?: FeatureState + identity?: string + identityName?: string + environmentId: string + projectId: number + changeRequest?: ChangeRequest + noPermissions?: boolean + disableCreate?: boolean + highlightSegmentId?: number + defaultExperiment?: boolean + history?: History + multivariate_options?: MultivariateFeatureStateValue[] +} & Partial + +type InjectedSegmentOverrideProps = { + segmentOverrides: SegmentOverrideValue[] + environmentVariations: MultivariateFeatureStateValue[] + updateSegments: (segments: SegmentOverrideValue[]) => void + removeMultivariateOption: (id: number) => void +} + +const CreateFeatureModal: FC = (props) => { + const { + changeRequest: existingChangeRequest, + defaultExperiment, + disableCreate, + environmentId, + environmentVariations, + highlightSegmentId, + identity, + identityName, + noPermissions, + projectId, + removeMultivariateOption, + segmentOverrides, + updateSegments, + } = props + const flagId = props.environmentFlag?.id + + const [projectFlag, setProjectFlag] = useState(() => + props.projectFlag + ? _.cloneDeep(props.projectFlag) + : { + description: undefined, + is_archived: undefined, + is_server_key_only: undefined, + metadata: [], + multivariate_options: [], + name: undefined, + tags: [], + }, + ) + + const [environmentFlag, setEnvironmentFlag] = useState(() => { + const sourceFlag = props.identityFlag || props.environmentFlag + return sourceFlag ? _.cloneDeep(sourceFlag) : {} + }) + + const [_changeRequests, setChangeRequests] = useState([]) + const [_scheduledChangeRequests, setScheduledChangeRequests] = useState< + any[] + >([]) + const [valueChanged, setValueChanged] = useState(false) + const [settingsChanged, setSettingsChanged] = useState(false) + const [segmentsChanged, setSegmentsChanged] = useState(false) + const [hasMetadataRequired, setHasMetadataRequired] = useState(false) + const [featureLimitAlert, setFeatureLimitAlert] = useState({ + percentage: 0, + }) + const [skipSaveProjectFeature, setSkipSaveProjectFeature] = useState(false) + const [, setTabKey] = useState(0) + + const isEdit = !!props.projectFlag + const focusTimeoutRef = useRef | null>(null) + + const close = useCallback(() => { + closeModal() + }, []) + + const onClosing = useCallback(() => { + if (isEdit) { + return new Promise((resolve) => { + if (settingsChanged || valueChanged || segmentsChanged) { + openConfirm({ + body: 'Closing this will discard your unsaved changes.', + noText: 'Cancel', + onNo: () => resolve(false), + onYes: () => resolve(true), + title: 'Discard changes', + yesText: 'Ok', + }) + } else { + resolve(true) + } + }) + } + return Promise.resolve(true) + }, [isEdit, settingsChanged, valueChanged, segmentsChanged]) + + const fetchChangeRequests = useCallback( + (forceRefetch?: boolean) => { + if (!props.projectFlag?.id) return + + getChangeRequests( + getStore(), + { + committed: false, + environmentId, + feature_id: props.projectFlag?.id, + }, + { forceRefetch }, + ).then((res: any) => { + setChangeRequests(res.data?.results) + }) + }, + [environmentId, props.projectFlag?.id], + ) + + const fetchScheduledChangeRequests = useCallback( + (forceRefetch?: boolean) => { + if (!props.projectFlag?.id) return + + const date = moment().toISOString() + + getChangeRequests( + getStore(), + { + environmentId, + feature_id: props.projectFlag.id, + live_from_after: date, + }, + { forceRefetch }, + ).then((res: any) => { + setScheduledChangeRequests(res.data?.results) + }) + }, + [environmentId, props.projectFlag?.id], + ) + + // Mount effects + useEffect(() => { + setInterceptClose(onClosing) + }, [onClosing]) + + useEffect(() => { + fetchChangeRequests() + fetchScheduledChangeRequests() + + const timeout = focusTimeoutRef.current + return () => { + if (timeout) { + clearTimeout(timeout) + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + // Sync environmentFlag from props when updated_at changes + useEffect(() => { + const source = props.identityFlag || props.environmentFlag + if (source?.updated_at) { + setEnvironmentFlag(_.cloneDeep(source)) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [(props.identityFlag || props.environmentFlag)?.updated_at]) + + // Sync projectFlag from props only when the flag ID changes (e.g. navigating + // to a different feature). We intentionally avoid syncing on every reference + // change, as the parent Provider re-creates the object on saves, which would + // overwrite the user's unsaved edits to settings/tags/description. + useEffect(() => { + if (props.projectFlag) { + setProjectFlag(_.cloneDeep(props.projectFlag)) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [props.projectFlag?.id]) + + // Sync multivariate options from environment variations + useEffect(() => { + if (!identity && environmentVariations?.length) { + setProjectFlag((prev: any) => ({ + ...prev, + multivariate_options: prev.multivariate_options?.map((v: any) => { + const matchingVariation = ( + props.multivariate_options || environmentVariations + ).find((e: any) => e.multivariate_feature_option === v.id) + return { + ...v, + default_percentage_allocation: + (matchingVariation && matchingVariation.percentage_allocation) || + v.default_percentage_allocation || + 0, + } + }), + })) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [environmentVariations]) + + const cleanInputValue = (value: any) => { + if (value && typeof value === 'string') { + return value.trim() + } + return value + } + + const parseError = (error: any) => { + let featureError = + error?.metadata + ?.flatMap((m: any) => m.non_field_errors ?? []) + .join('\n') || + error?.message || + error?.name?.[0] || + error + let featureWarning = '' + if ( + featureError?.includes?.('no changes') && + props.projectFlag?.multivariate_options?.length + ) { + featureWarning = + 'Your feature contains no changes to its value, enabled state or environment weights. If you have adjusted any variation values this will have been saved for all environments.' + featureError = '' + } + return { featureError, featureWarning } + } + + const save = (func: (...args: any[]) => void, isSaving: boolean) => { + const hasMultivariate = + props.environmentFlag?.multivariate_feature_state_values?.length + + if (identity) { + !isSaving && + projectFlag.name && + func({ + environmentFlag: props.environmentFlag, + environmentId, + identity, + identityFlag: Object.assign({}, props.identityFlag || {}, { + enabled: environmentFlag.enabled, + feature_state_value: hasMultivariate + ? props.environmentFlag?.feature_state_value + : cleanInputValue(environmentFlag.feature_state_value), + multivariate_options: + environmentFlag.multivariate_feature_state_values, + }), + projectFlag, + }) + } else { + FeatureListStore.isSaving = true + FeatureListStore.trigger('change') + !isSaving && + projectFlag.name && + func( + projectId, + environmentId, + { + default_enabled: environmentFlag.enabled, + description: projectFlag.description, + initial_value: cleanInputValue(environmentFlag.feature_state_value), + is_archived: projectFlag.is_archived, + is_server_key_only: projectFlag.is_server_key_only, + metadata: + !props.projectFlag?.metadata || + (props.projectFlag.metadata !== projectFlag.metadata && + projectFlag.metadata.length) + ? projectFlag.metadata + : props.projectFlag.metadata, + multivariate_options: projectFlag.multivariate_options, + name: projectFlag.name, + tags: projectFlag.tags, + }, + { + skipSaveProjectFeature, + ...props.projectFlag, + }, + { + ...props.environmentFlag, + multivariate_feature_state_values: + environmentVariations || + props.environmentFlag?.multivariate_feature_state_values, + }, + segmentOverrides, + ) + } + } + + const { getEnvironment, project } = useProjectEnvironments(projectId) + const environment = getEnvironment(environmentId) + + const isVersioned = !!environment?.use_v2_feature_versioning + const is4Eyes = Utils.changeRequestsEnabled( + environment?.minimum_change_request_approvals, + ) + + const { + githubId, + hasIntegration: hasIntegrationWithGithub, + organisationId, + } = useHasGithubIntegration() + + const { permission: createFeaturePermission } = useHasPermission({ + id: projectId, + level: 'project', + permission: ProjectPermission.CREATE_FEATURE, + }) + + const { permission: savePermission } = useHasPermission({ + id: environmentId, + level: 'environment', + permission: Utils.getManageFeaturePermission(is4Eyes, identity), + tags: projectFlag.tags, + }) + + useEffect(() => { + setSkipSaveProjectFeature(!createFeaturePermission) + }, [createFeaturePermission]) + + if (!environment || !project) { + return ( +
+ +
+ ) + } + + const caseSensitive = !!project.only_allow_lower_case_feature_names + const regex = project.feature_name_regex ?? undefined + const controlValue = Utils.calculateControl(projectFlag.multivariate_options) + const invalid = + !!projectFlag.multivariate_options && + projectFlag.multivariate_options.length && + controlValue < 0 + const isVersionedChangeRequest = existingChangeRequest && isVersioned + const hideIdentityOverridesTab = Utils.getShouldHideIdentityOverridesTab() + const hasCodeReferences = projectFlag?.code_references_counts?.length > 0 + + let regexValid = true + try { + if (!isEdit && projectFlag.name && regex) { + regexValid = !!projectFlag.name.match(new RegExp(regex)) + } + } catch (e) { + regexValid = false + } + + const Provider = identity ? IdentityProvider : FeatureListProvider + const environmentName = environment.name + + return ( + { + if (identity) { + close() + } + AppActions.refreshFeatures(projectId, environmentId) + + if (is4Eyes && !identity) { + fetchChangeRequests(true) + fetchScheduledChangeRequests(true) + } + + if (existingChangeRequest) { + close() + } + }} + > + {( + { error, isSaving }: { error: any; isSaving: boolean }, + { + createChangeRequest, + createFlag, + editFeatureSegments, + editFeatureSettings, + editFeatureValue, + }: any, + ) => { + const saveFeatureValue = saveFeatureWithValidation( + (schedule?: boolean) => { + if ((is4Eyes || schedule) && !identity) { + setSegmentsChanged(false) + setValueChanged(false) + const segmentFeatureStates = (segmentOverrides || []) + .filter((override: any) => !override.toRemove) + .map((override: any) => ({ + enabled: override.enabled, + feature: override.feature, + feature_segment: { + environment: override.environment, + id: override.id, + is_feature_specific: override.is_feature_specific, + priority: override.priority, + segment: override.segment, + segment_name: override.segment_name, + uuid: override.uuid, + }, + feature_state_value: Utils.valueToFeatureState( + override.value, + ), + id: override.id, + multivariate_feature_state_values: + override.multivariate_options, + })) + const featureStates = [ + ...segmentFeatureStates, + { + ...props.environmentFlag, + enabled: environmentFlag.enabled, + feature_state_value: Utils.valueToFeatureState( + environmentFlag.feature_state_value, + ), + multivariate_feature_state_values: + environmentFlag.multivariate_feature_state_values, + }, + ] + + const getModalTitle = () => { + if (schedule) { + return 'New Scheduled Flag Update' + } + if (existingChangeRequest) { + return 'Update Change Request' + } + return 'New Change Request' + } + + openModal2( + getModalTitle(), + { + closeModal2() + save( + ( + _projectId: any, + _environmentId: any, + flag: any, + _projectFlag: any, + _environmentFlag: any, + _segmentOverrides: any, + ) => { + createChangeRequest( + _projectId, + _environmentId, + flag, + _projectFlag, + _environmentFlag, + _segmentOverrides, + { + approvals, + description, + featureStateId: + existingChangeRequest?.feature_states?.[0]?.id, + id: existingChangeRequest?.id, + ignore_conflicts, + live_from, + multivariate_options: flag.multivariate_options, + title, + }, + !is4Eyes, + ) + }, + isSaving, + ) + }} + />, + ) + } else { + setValueChanged(false) + save(editFeatureValue, isSaving) + } + }, + ) + + const saveSettings = () => { + setSettingsChanged(false) + save(editFeatureSettings, isSaving) + } + + const saveFeatureSegments = saveFeatureWithValidation( + (schedule?: boolean) => { + setSegmentsChanged(false) + + if ((is4Eyes || schedule) && isVersioned && !identity) { + return saveFeatureValue(schedule) + } else { + save(editFeatureSegments, isSaving) + } + }, + ) + + const onCreateFeature = saveFeatureWithValidation(() => { + save(createFlag, isSaving) + }) + + return ( +
+ {isEdit && !identity ? ( + <> + + setTabKey((k) => k + 1)} + overflowX + > + + Value{' '} + {valueChanged && ( +
{'*'}
+ )} + + } + > + { + setEnvironmentFlag((prev: any) => ({ + ...prev, + ...changes, + })) + setValueChanged(true) + }} + onProjectFlagChange={(changes: any) => { + setProjectFlag((prev: any) => ({ + ...prev, + ...changes, + })) + }} + onRemoveMultivariateOption={removeMultivariateOption} + /> +
+ {(!existingChangeRequest || isVersionedChangeRequest) && + updateSegments && ( + + Segment Overrides{' '} + {segmentsChanged && ( +
*
+ )} + + } + > + setSegmentsChanged(true)} + saveFeatureSegments={saveFeatureSegments} + isSaving={isSaving} + invalid={invalid} + error={error} + existingChangeRequest={existingChangeRequest} + noPermissions={!!noPermissions} + disableCreate={disableCreate} + highlightSegmentId={highlightSegmentId} + /> +
+ )} + {!existingChangeRequest && !hideIdentityOverridesTab && ( + + + + )} + {(!Project.disableAnalytics || hasCodeReferences) && ( + Usage + } + > + + + )} + { + + + + } + {hasIntegrationWithGithub && projectFlag?.id && ( + Links + } + > + + + )} + {!existingChangeRequest && flagId && isVersioned && ( + + + + )} + {!existingChangeRequest && ( + + Settings{' '} + {settingsChanged && ( +
{'*'}
+ )} + + } + > + { + setProjectFlag((prev: any) => ({ + ...prev, + ...changes, + })) + if (changes.metadata === undefined) { + setSettingsChanged(true) + } + }} + onHasMetadataRequiredChange={setHasMetadataRequired} + onSaveSettings={saveSettings} + /> +
+ )} +
+ + ) : ( +
+ +
+ + setProjectFlag((prev: any) => ({ + ...prev, + name, + })) + } + caseSensitive={caseSensitive} + regex={regex} + regexValid={regexValid} + autoFocus + /> +
+ { + setEnvironmentFlag((prev: any) => ({ + ...prev, + ...changes, + })) + setValueChanged(true) + }} + onProjectFlagChange={(changes: any) => { + setProjectFlag((prev: any) => ({ + ...prev, + ...changes, + })) + }} + onRemoveMultivariateOption={removeMultivariateOption} + onHasMetadataRequiredChange={setHasMetadataRequired} + featureError={parseError(error).featureError} + featureWarning={parseError(error).featureWarning} + /> + +
+ )} + {identity && ( + saveFeatureValue()} + /> + )} +
+ ) + }} +
+ ) +} + +import ConfigProvider from 'common/providers/ConfigProvider' +import withSegmentOverrides from 'common/providers/withSegmentOverrides' +import withFeatureProvider from './hoc/FeatureProvider' + +export default withFeatureProvider( + ConfigProvider(withSegmentOverrides(CreateFeatureModal)), +) diff --git a/frontend/web/components/modals/create-feature/tabs/CreateFeature.tsx b/frontend/web/components/modals/create-feature/tabs/CreateFeatureTab.tsx similarity index 88% rename from frontend/web/components/modals/create-feature/tabs/CreateFeature.tsx rename to frontend/web/components/modals/create-feature/tabs/CreateFeatureTab.tsx index bb5f0c312c05..6ea6203cf559 100644 --- a/frontend/web/components/modals/create-feature/tabs/CreateFeature.tsx +++ b/frontend/web/components/modals/create-feature/tabs/CreateFeatureTab.tsx @@ -1,7 +1,7 @@ import React, { FC, useCallback, useEffect, useState } from 'react' import { FeatureState, ProjectFlag } from 'common/types/responses' -import FeatureValue from './FeatureValue' -import FeatureSettings from './FeatureSettings' +import FeatureValueTab from './FeatureValueTab' +import FeatureSettingsTab from './FeatureSettingsTab' import ErrorMessage from 'components/ErrorMessage' import WarningMessage from 'components/WarningMessage' import { useHasPermission } from 'common/providers/Permission' @@ -19,21 +19,19 @@ type CreateFeatureTabProps = { featureState: FeatureState overrideFeatureState?: FeatureState projectFlag: ProjectFlag | null - featureContentType: any identity?: string defaultExperiment?: boolean - onEnvironmentFlagChange: (changes: FeatureState) => void - onProjectFlagChange: (changes: ProjectFlag) => void + onEnvironmentFlagChange: (changes: Partial) => void + onProjectFlagChange: (changes: Partial) => void onRemoveMultivariateOption?: (id: number) => void onHasMetadataRequiredChange: (hasMetadataRequired: boolean) => void featureError?: string featureWarning?: string } -const CreateFeature: FC = ({ +const CreateFeatureTab: FC = ({ defaultExperiment, error, - featureContentType, featureError, featureState, featureWarning, @@ -106,7 +104,7 @@ const CreateFeature: FC = ({ if (checked) { if (!experimentTag) { - const result = await createTag({ + experimentTag = await createTag({ projectId, tag: { color: '#6A52CF', @@ -114,7 +112,6 @@ const CreateFeature: FC = ({ label: 'experiment', }, }).unwrap() - experimentTag = result } if (experimentTag && !projectFlag.tags.includes(experimentTag.id)) { onProjectFlagChange({ @@ -126,7 +123,7 @@ const CreateFeature: FC = ({ if (experimentTag) { onProjectFlagChange({ ...projectFlag, - tags: projectFlag.tags.filter((id) => id !== experimentTag!.id), + tags: projectFlag.tags.filter((id) => id !== experimentTag?.id), }) } } @@ -147,12 +144,10 @@ const CreateFeature: FC = ({ environment once the feature is created. )} - = ({ )} - = ({ ) } -export default CreateFeature +export default CreateFeatureTab diff --git a/frontend/web/components/modals/create-feature/tabs/FeatureSettings.tsx b/frontend/web/components/modals/create-feature/tabs/FeatureSettingsTab.tsx similarity index 62% rename from frontend/web/components/modals/create-feature/tabs/FeatureSettings.tsx rename to frontend/web/components/modals/create-feature/tabs/FeatureSettingsTab.tsx index ab66d121c9f0..e9fae1ea07ff 100644 --- a/frontend/web/components/modals/create-feature/tabs/FeatureSettings.tsx +++ b/frontend/web/components/modals/create-feature/tabs/FeatureSettingsTab.tsx @@ -1,46 +1,72 @@ -import React, { FC } from 'react' +import React, { FC, useEffect, useState } from 'react' import { ProjectFlag } from 'common/types/responses' import Constants from 'common/constants' import InfoMessage from 'components/InfoMessage' import InputGroup from 'components/base/forms/InputGroup' import AddEditTags from 'components/tags/AddEditTags' import AddMetadataToEntity from 'components/metadata/AddMetadataToEntity' -import Permission from 'common/providers/Permission' +import { useHasPermission } from 'common/providers/Permission' import FlagOwners from 'components/FlagOwners' import FlagOwnerGroups from 'components/FlagOwnerGroups' import PlanBasedBanner from 'components/PlanBasedAccess' import Switch from 'components/Switch' import Tooltip from 'components/Tooltip' import Icon from 'components/Icon' +import JSONReference from 'components/JSONReference' +import ModalHR from 'components/modals/ModalHR' +import Button from 'components/base/forms/Button' import Utils from 'common/utils/utils' import FormGroup from 'components/base/grid/FormGroup' import Row from 'components/base/grid/Row' import AccountStore from 'common/stores/account-store' import { ProjectPermission } from 'common/types/permissions.types' +import { getStore } from 'common/store' +import { getSupportedContentType } from 'common/services/useSupportedContentType' type FeatureSettingsTabProps = { - projectAdmin: boolean - createFeature: boolean - featureContentType: any identity?: string - isEdit: boolean projectId: number | string projectFlag: ProjectFlag | null + isSaving?: boolean + invalid?: boolean + hasMetadataRequired?: boolean onChange: (projectFlag: ProjectFlag) => void onHasMetadataRequiredChange: (hasMetadataRequired: boolean) => void + onSaveSettings?: () => void } -const FeatureSettings: FC = ({ - createFeature, - featureContentType, +const FeatureSettingsTab: FC = ({ + hasMetadataRequired, identity, - isEdit, + invalid, + isSaving, onChange, onHasMetadataRequiredChange, + onSaveSettings, projectFlag, projectId, }) => { + const [featureContentType, setFeatureContentType] = useState({}) + const metadataEnable = Utils.getPlansPermission('METADATA') + const isEdit = !!projectFlag?.id + + useEffect(() => { + if (metadataEnable) { + getSupportedContentType(getStore(), { + organisation_id: AccountStore.getOrganisation().id, + }).then((res) => { + const contentType = Utils.getContentType(res.data, 'model', 'feature') + setFeatureContentType(contentType) + }) + } + }, [metadataEnable]) + + const { permission: createFeature } = useHasPermission({ + id: projectId, + level: 'project', + permission: ProjectPermission.CREATE_FEATURE, + }) if (!createFeature) { return ( @@ -59,6 +85,7 @@ const FeatureSettings: FC = ({ if (!projectFlag) { return null } + return (
{!identity && projectFlag?.tags && ( @@ -82,7 +109,11 @@ const FeatureSettings: FC = ({ = ({ /> )} - {!identity && projectFlag?.id && ( - - {({ permission }) => - permission && ( - <> - - - - - - - - - ) - } - + {!identity && projectFlag?.id && createFeature && ( + <> + + + + + + + + )} = ({ )} + + {onSaveSettings && ( + <> + + + {isEdit && ( +
+ {createFeature && ( + <> +

+ This will save the above settings{' '} + all environments. +

+ + + )} +
+ )} + + )}
) } -export default FeatureSettings +export default FeatureSettingsTab diff --git a/frontend/web/components/modals/create-feature/tabs/FeatureValue.tsx b/frontend/web/components/modals/create-feature/tabs/FeatureValue.tsx deleted file mode 100644 index 850325ff7823..000000000000 --- a/frontend/web/components/modals/create-feature/tabs/FeatureValue.tsx +++ /dev/null @@ -1,298 +0,0 @@ -import React, { FC, useEffect, useState } from 'react' -import InputGroup from 'components/base/forms/InputGroup' -import ValueEditor from 'components/ValueEditor' -import Constants from 'common/constants' -import { VariationOptions } from 'components/mv/VariationOptions' -import { AddVariationButton } from 'components/mv/AddVariationButton' -import ErrorMessage from 'components/ErrorMessage' -import WarningMessage from 'components/WarningMessage' -import Tooltip from 'components/Tooltip' -import Icon from 'components/Icon' -import Switch from 'components/Switch' -import Utils from 'common/utils/utils' -import { FeatureState, ProjectFlag } from 'common/types/responses' -import { ProjectPermission } from 'common/types/permissions.types' - -function isNegativeNumberString(str: any) { - if (typeof Utils.getTypedValue(str) !== 'number') { - return false - } - if (typeof str !== 'string') { - return false - } - const num = parseFloat(str) - return !isNaN(num) && num < 0 -} - -type EditFeatureValueProps = { - error: any - createFeature: boolean - hideValue: boolean - isEdit: boolean - identity?: string - identityName?: string - noPermissions: boolean - featureState: FeatureState - projectFlag: ProjectFlag - onEnvironmentFlagChange: (changes: FeatureState) => void - onProjectFlagChange: (changes: ProjectFlag) => void - onRemoveMultivariateOption?: (id: number) => void -} - -/* eslint-disable sort-destructure-keys/sort-destructure-keys */ -const FeatureValue: FC = ({ - createFeature, - error, - featureState, - hideValue, - identity, - isEdit, - noPermissions, - onEnvironmentFlagChange, - onProjectFlagChange, - onRemoveMultivariateOption, - projectFlag, -}) => { - /* eslint-enable sort-destructure-keys/sort-destructure-keys */ - const default_enabled = featureState.enabled ?? false - const initial_value = featureState.feature_state_value - const multivariate_options = projectFlag.multivariate_options || [] - const environmentVariations = - featureState.multivariate_feature_state_values ?? [] - const identityVariations = - featureState.multivariate_feature_state_values ?? [] - - const addVariation = () => { - const newVariation = { - ...Utils.valueToFeatureState(''), - default_percentage_allocation: 0, - } - onProjectFlagChange({ - multivariate_options: [...multivariate_options, newVariation], - }) - } - - const [isNegativeNumber, setIsNegativeNumber] = useState( - isNegativeNumberString(featureState?.feature_state_value), - ) - - useEffect(() => { - setIsNegativeNumber( - isNegativeNumberString(featureState?.feature_state_value), - ) - }, [featureState?.feature_state_value]) - - const handleRemoveVariation = (i: number) => { - const idToRemove = multivariate_options[i].id - - const doRemove = () => { - if (idToRemove && onRemoveMultivariateOption) { - onRemoveMultivariateOption(idToRemove) - } - const newMultivariateOptions = multivariate_options.filter( - (_, index) => index !== i, - ) - onProjectFlagChange({ - multivariate_options: newMultivariateOptions, - }) - } - - if (idToRemove) { - openConfirm({ - body: 'This will remove the variation on your feature for all environments, if you wish to turn it off just for this environment you can set the % value to 0.', - destructive: true, - onYes: doRemove, - title: 'Delete variation', - yesText: 'Confirm', - }) - } else { - doRemove() - } - } - - const handleUpdateVariation = ( - i: number, - updatedVariation: any, - updatedEnvironmentVariations: any[], - ) => { - // Update the environment variations (weights) - onEnvironmentFlagChange({ - multivariate_feature_state_values: updatedEnvironmentVariations, - }) - - // Update the multivariate option itself - const newMultivariateOptions = [...multivariate_options] - newMultivariateOptions[i] = updatedVariation - onProjectFlagChange({ - multivariate_options: newMultivariateOptions, - }) - } - - const enabledString = isEdit ? 'Enabled' : 'Enabled by default' - const controlPercentage = Utils.calculateControl(multivariate_options) - - const getValueString = () => { - if (multivariate_options && multivariate_options.length) { - return `Control Value - ${controlPercentage}%` - } - return 'Value' - } - const valueString = getValueString() - - const showValue = !( - !!identity && - multivariate_options && - !!multivariate_options.length - ) - - return ( - <> - {!hideValue && ( -
- - - onEnvironmentFlagChange({ enabled })} - className='ml-0' - /> -
- {enabledString || 'Enabled'} -
- {!isEdit && } -
- } - > - {!isEdit - ? 'This will determine the initial enabled state for all environments. You can edit the this individually for each environment once the feature is created.' - : ''} - - - - {showValue && ( - - { - const feature_state_value = Utils.getTypedValue( - Utils.safeParseEventValue(e), - ) - onEnvironmentFlagChange({ feature_state_value }) - }} - disabled={noPermissions} - placeholder="e.g. 'big' " - /> - } - tooltip={`${Constants.strings.REMOTE_CONFIG_DESCRIPTION}${ - !isEdit - ? '
Setting this when creating a feature will set the value for all environments. You can edit this individually for each environment once the feature is created.' - : '' - }`} - title={`${valueString}`} - /> -
- )} - - {isNegativeNumber && ( - - This feature currently has the value of{' '} - "{featureState.feature_state_value}". Saving - this feature will convert its value from a string to a number. - If you wish to preserve this value as a string, please save it - using the{' '} - - API - - . -
- } - /> - )} - - {!!error?.initial_value?.[0] && ( -
- -
- )} - - {!!identity && ( -
- - - onEnvironmentFlagChange({ - multivariate_feature_state_values, - }) - } - updateVariation={() => {}} - weightTitle='Override Weight %' - projectFlag={projectFlag} - multivariateOptions={projectFlag.multivariate_options} - removeVariation={() => {}} - /> - -
- )} - - {!identity && ( -
- - {(!!environmentVariations || !isEdit) && ( - - )} - - {Utils.renderWithPermission( - createFeature, - Constants.projectPermissions(ProjectPermission.CREATE_FEATURE), - , - )} -
- )} - - )} - - ) -} - -export default FeatureValue diff --git a/frontend/web/components/modals/create-feature/tabs/FeatureValueTab.tsx b/frontend/web/components/modals/create-feature/tabs/FeatureValueTab.tsx new file mode 100644 index 000000000000..81495326dd34 --- /dev/null +++ b/frontend/web/components/modals/create-feature/tabs/FeatureValueTab.tsx @@ -0,0 +1,360 @@ +import React, { FC, useEffect, useState } from 'react' +import InputGroup from 'components/base/forms/InputGroup' +import ValueEditor from 'components/ValueEditor' +import Constants from 'common/constants' +import { VariationOptions } from 'components/mv/VariationOptions' +import { AddVariationButton } from 'components/mv/AddVariationButton' +import ErrorMessage from 'components/ErrorMessage' +import WarningMessage from 'components/WarningMessage' +import Tooltip from 'components/Tooltip' +import Icon from 'components/Icon' +import Switch from 'components/Switch' +import JSONReference from 'components/JSONReference' +import { FlagValueFooter } from 'components/modals/FlagValueFooter' +import Utils from 'common/utils/utils' +import { FeatureState, ProjectFlag } from 'common/types/responses' +import { useHasPermission } from 'common/providers/Permission' +import { ProjectPermission } from 'common/types/permissions.types' + +function isNegativeNumberString(str: any) { + if (typeof Utils.getTypedValue(str) !== 'number') { + return false + } + if (typeof str !== 'string') { + return false + } + const num = parseFloat(str) + return !isNaN(num) && num < 0 +} + +type FeatureValueTabProps = { + error: any + projectId: number | string + identity?: string + noPermissions: boolean + featureState: FeatureState + projectFlag: ProjectFlag + environmentFlag?: FeatureState + environmentId?: string + environmentName?: string + is4Eyes?: boolean + isVersioned?: boolean + isSaving?: boolean + existingChangeRequest?: boolean + onSaveFeatureValue?: (schedule?: boolean) => void + onEnvironmentFlagChange: (changes: Partial) => void + onProjectFlagChange: (changes: Partial) => void + onRemoveMultivariateOption?: (id: number) => void +} + +const FeatureValueTab: FC = ({ + environmentFlag, + environmentId, + environmentName, + error, + existingChangeRequest, + featureState, + identity, + is4Eyes, + isSaving, + isVersioned, + noPermissions, + onEnvironmentFlagChange, + onProjectFlagChange, + onRemoveMultivariateOption, + onSaveFeatureValue, + projectFlag, + projectId, +}) => { + const isEdit = !!projectFlag?.id + + const { permission: createFeature } = useHasPermission({ + id: projectId, + level: 'project', + permission: ProjectPermission.CREATE_FEATURE, + }) + + const default_enabled = featureState.enabled ?? false + const initial_value = featureState.feature_state_value + const multivariate_options = projectFlag.multivariate_options || [] + const environmentVariations = + featureState.multivariate_feature_state_values ?? [] + const identityVariations = + featureState.multivariate_feature_state_values ?? [] + const controlPercentage = Utils.calculateControl(multivariate_options) + const invalid = + !!multivariate_options && + multivariate_options.length && + controlPercentage < 0 + + const addVariation = () => { + const newVariation = { + ...Utils.valueToFeatureState(''), + default_percentage_allocation: 0, + } + onProjectFlagChange({ + multivariate_options: [...multivariate_options, newVariation], + }) + } + + const [isNegativeNumber, setIsNegativeNumber] = useState( + isNegativeNumberString(featureState?.feature_state_value), + ) + + useEffect(() => { + setIsNegativeNumber( + isNegativeNumberString(featureState?.feature_state_value), + ) + }, [featureState?.feature_state_value]) + + const handleRemoveVariation = (i: number) => { + const idToRemove = multivariate_options[i].id + + const doRemove = () => { + if (idToRemove && onRemoveMultivariateOption) { + onRemoveMultivariateOption(idToRemove) + } + const newMultivariateOptions = multivariate_options.filter( + (_, index) => index !== i, + ) + onProjectFlagChange({ + multivariate_options: newMultivariateOptions, + }) + } + + if (idToRemove) { + openConfirm({ + body: 'This will remove the variation on your feature for all environments, if you wish to turn it off just for this environment you can set the % value to 0.', + destructive: true, + onYes: doRemove, + title: 'Delete variation', + yesText: 'Confirm', + }) + } else { + doRemove() + } + } + + const handleUpdateVariation = ( + i: number, + updatedVariation: any, + updatedEnvironmentVariations: any[], + ) => { + // Update the environment variations (weights) + onEnvironmentFlagChange({ + multivariate_feature_state_values: updatedEnvironmentVariations, + }) + + // Update the multivariate option itself + const newMultivariateOptions = [...multivariate_options] + newMultivariateOptions[i] = updatedVariation + onProjectFlagChange({ + multivariate_options: newMultivariateOptions, + }) + } + + const enabledString = isEdit ? 'Enabled' : 'Enabled by default' + + const getValueString = () => { + if (multivariate_options && multivariate_options.length) { + return `Control Value - ${controlPercentage}%` + } + return 'Value' + } + const valueString = getValueString() + + const showValue = !( + !!identity && + multivariate_options && + !!multivariate_options.length + ) + + return ( +
+ + + onEnvironmentFlagChange({ enabled })} + className='ml-0' + /> +
+ {enabledString || 'Enabled'} +
+ {!isEdit && } +
+ } + > + {!isEdit + ? 'This will determine the initial enabled state for all environments. You can edit the this individually for each environment once the feature is created.' + : ''} + + + + {showValue && ( + + { + const feature_state_value = Utils.getTypedValue( + Utils.safeParseEventValue(e), + ) + onEnvironmentFlagChange({ feature_state_value }) + }} + disabled={noPermissions} + placeholder="e.g. 'big' " + /> + } + tooltip={`${Constants.strings.REMOTE_CONFIG_DESCRIPTION}${ + !isEdit + ? '
Setting this when creating a feature will set the value for all environments. You can edit this individually for each environment once the feature is created.' + : '' + }`} + title={`${valueString}`} + /> +
+ )} + + {isNegativeNumber && ( + + This feature currently has the value of{' '} + "{featureState.feature_state_value}". Saving this + feature will convert its value from a string to a number. If you + wish to preserve this value as a string, please save it using the{' '} + + API + + . + + } + /> + )} + + {!!error?.initial_value?.[0] && ( +
+ +
+ )} + + {!!identity && ( +
+ + + onEnvironmentFlagChange({ feature_state_value: value }) + } + setVariations={(variations) => + onEnvironmentFlagChange({ + multivariate_feature_state_values: variations as any, + }) + } + updateVariation={() => {}} + weightTitle='Override Weight %' + multivariateOptions={projectFlag.multivariate_options || []} + removeVariation={() => {}} + /> + +
+ )} + + {!identity && ( +
+ + {(!!environmentVariations || !isEdit) && ( + + onEnvironmentFlagChange({ feature_state_value: value }) + } + setVariations={(variations) => + onEnvironmentFlagChange({ + multivariate_feature_state_values: variations as any, + }) + } + updateVariation={handleUpdateVariation} + weightTitle={ + isEdit ? 'Environment Weight %' : 'Default Weight %' + } + multivariateOptions={multivariate_options} + removeVariation={handleRemoveVariation} + /> + )} + + {Utils.renderWithPermission( + createFeature, + Constants.projectPermissions(ProjectPermission.CREATE_FEATURE), + , + )} +
+ )} + + {environmentId && onSaveFeatureValue && ( + <> + + + + + )} + + ) +} + +export default FeatureValueTab diff --git a/frontend/web/components/modals/create-feature/tabs/IdentityOverridesTab.tsx b/frontend/web/components/modals/create-feature/tabs/IdentityOverridesTab.tsx new file mode 100644 index 000000000000..58b24a1dfc29 --- /dev/null +++ b/frontend/web/components/modals/create-feature/tabs/IdentityOverridesTab.tsx @@ -0,0 +1,296 @@ +import React, { FC, useState } from 'react' +import Constants from 'common/constants' +import AppActions from 'common/dispatcher/app-actions' +import FeatureListStore from 'common/stores/feature-list-store' +import { useGetProjectQuery } from 'common/services/useProject' +import Icon from 'components/Icon' +import Button from 'components/base/forms/Button' +import InfoMessage from 'components/InfoMessage' +import IdentitySelect from 'components/IdentitySelect' +import FeatureValue from 'components/feature-summary/FeatureValue' +import { removeUserOverride } from 'components/RemoveUserOverride' +import { useHasPermission } from 'common/providers/Permission' +import Utils from 'common/utils/utils' +import { + FeatureState, + IdentityFeatureState, + IdentityOverride, + ProjectFlag, +} from 'common/types/responses' +import Switch from 'components/Switch' +import { EnvironmentPermission } from 'common/types/permissions.types' +import { + useCreateIdentityOverrideMutation, + useGetIdentityOverridesQuery, +} from 'common/services/useIdentityOverride' + +type IdentityOverridesTabProps = { + environmentId: string + projectId: number + projectFlag: ProjectFlag + environmentFlag?: FeatureState +} + +const IdentityOverridesTab: FC = ({ + environmentFlag, + environmentId, + projectFlag, + projectId, +}) => { + const { data: project } = useGetProjectQuery({ id: projectId }) + const [page, setPage] = useState(1) + const [selectedIdentity, setSelectedIdentity] = useState<{ + value: string + label: string + } | null>(null) + const [enabledIdentity, setEnabledIdentity] = useState(false) + + const isEdge = Utils.getIsEdge() + const shouldHideTab = Utils.getShouldHideIdentityOverridesTab(project) + + const { + isLoading: isPermissionLoading, + permission: hasViewIdentitiesPermission, + } = useHasPermission({ + id: environmentId, + level: 'environment', + permission: EnvironmentPermission.VIEW_IDENTITIES, + }) + + const skipFetch = + shouldHideTab || + (isEdge && (isPermissionLoading || !hasViewIdentitiesPermission)) + + const { data, isError, isLoading, refetch } = useGetIdentityOverridesQuery( + { environmentId, featureId: projectFlag.id, isEdge, page }, + { skip: skipFetch }, + ) + + const [createIdentityOverride] = useCreateIdentityOverrideMutation() + + const addItem = (identity: { value: string; label: string }) => { + if (!identity?.value) return + createIdentityOverride({ + enabled: !environmentFlag?.enabled, + environmentId, + featureId: projectFlag.id, + feature_state_value: environmentFlag?.feature_state_value ?? null, + identityId: identity.value, + }).then(() => { + setSelectedIdentity(null) + refetch() + FeatureListStore.trigger('saved', {}) + }) + } + + const changeIdentity = (items: IdentityOverride[]) => { + Promise.all( + items.map( + (item) => + new Promise((resolve) => { + AppActions.changeUserFlag({ + environmentId, + identity: item.identity.id, + identityFlag: item.id, + onSuccess: resolve, + payload: { + enabled: enabledIdentity, + id: item.identity.id, + value: item.identity.identifier, + }, + }) + }), + ), + ).then(() => { + refetch() + }) + setEnabledIdentity(!enabledIdentity) + } + + const toggleUserFlag = ({ + enabled, + id, + identity, + }: { + enabled: boolean + id: number + identity: { id: string; identifier: string } + }) => { + AppActions.changeUserFlag({ + environmentId, + identity: identity.id, + identityFlag: id, + onSuccess: () => { + refetch() + }, + payload: { + enabled: !enabled, + id: identity.id, + value: identity.identifier, + }, + }) + } + + const renderNoResults = () => { + if (isEdge && !isPermissionLoading && !hasViewIdentitiesPermission) { + return ( +
+ You do not have permission to view identity overrides. +
+ ) + } + if (isError) { + return ( +
+ Failed to load identity overrides. +
+ ) + } + return ( + +
+ No identities are overriding this feature. +
+
+ ) + } + + return ( + <> + + + + Identity Overrides{' '} + + + } + place='top' + > + {Constants.strings.IDENTITY_OVERRIDES_DESCRIPTION} + +
+ + Identity overrides override feature values for individual + identities. The overrides take priority over an segment + overrides and environment defaults. Identity overrides will + only apply when you identify via the SDK.{' '} + + Check the Docs for more details + + . + +
+ + } + action={ + !isEdge && ( + + ) + } + items={data?.results} + paging={{ ...data, currentPage: page }} + renderSearchWithNoResults + nextPage={() => setPage((p) => p + 1)} + prevPage={() => setPage((p) => p - 1)} + goToPage={setPage} + searchPanel={ + !isEdge && ( +
+ + v.identity?.id)} + environmentId={environmentId} + data-test='select-identity' + placeholder='Create an Identity Override...' + value={selectedIdentity} + onChange={(identity: { value: string; label: string }) => { + setSelectedIdentity(identity) + addItem(identity) + }} + /> + +
+ ) + } + renderRow={(identityFlag: IdentityOverride) => { + const { enabled, feature_state_value, id, identity } = identityFlag + return ( + + +
+ {identity.identifier} +
+
+ +
+ {feature_state_value !== null && ( + + )} +
+
+ toggleUserFlag({ enabled, id, identity })} + disabled={isEdge} + /> +
+
+ + +
+
+
+ ) + }} + renderNoResults={renderNoResults()} + isLoading={isLoading || isPermissionLoading} + /> +
+ + ) +} + +export default IdentityOverridesTab diff --git a/frontend/web/components/modals/create-feature/tabs/SegmentOverridesTab.tsx b/frontend/web/components/modals/create-feature/tabs/SegmentOverridesTab.tsx new file mode 100644 index 000000000000..7534eb0c328b --- /dev/null +++ b/frontend/web/components/modals/create-feature/tabs/SegmentOverridesTab.tsx @@ -0,0 +1,289 @@ +import React, { FC, useState } from 'react' +import Constants from 'common/constants' +import { useProjectEnvironments } from 'common/hooks/useProjectEnvironments' +import { useHasPermission } from 'common/providers/Permission' +import SegmentOverrides from 'components/SegmentOverrides' +import Button from 'components/base/forms/Button' +import Icon from 'components/Icon' +import InfoMessage from 'components/InfoMessage' +import ErrorMessage from 'components/ErrorMessage' +import WarningMessage from 'components/WarningMessage' +import ModalHR from 'components/modals/ModalHR' +import FeatureInPipelineGuard from 'components/release-pipelines/FeatureInPipelineGuard' +import Utils from 'common/utils/utils' +import { ProjectFlag } from 'common/types/responses' +import { EnvironmentPermission } from 'common/types/permissions.types' + +export type SegmentOverrideValue = { + enabled?: boolean + [key: string]: unknown +} + +type SegmentOverridesTabProps = { + projectId: number + environmentId: string + projectFlag: ProjectFlag + segmentOverrides?: SegmentOverrideValue[] + updateSegments: (segments: SegmentOverrideValue[]) => void + controlValue: string | number | boolean | null + onSegmentsChange: () => void + saveFeatureSegments: (schedule: boolean) => void + isSaving: boolean + invalid: boolean + error: any + existingChangeRequest?: { id: number } + noPermissions?: boolean + disableCreate?: boolean + highlightSegmentId?: number +} + +const SegmentOverridesTab: FC = ({ + controlValue, + disableCreate, + environmentId, + error, + existingChangeRequest, + highlightSegmentId, + invalid, + isSaving, + noPermissions, + onSegmentsChange, + projectFlag, + projectId, + saveFeatureSegments, + segmentOverrides, + updateSegments, +}) => { + const [showCreateSegment, setShowCreateSegment] = useState(false) + const [enabledSegment, setEnabledSegment] = useState(false) + + const { getEnvironment } = useProjectEnvironments(projectId) + const environment = getEnvironment(environmentId) + const isVersioned = !!environment?.use_v2_feature_versioning + const is4Eyes = + !!environment && + Utils.changeRequestsEnabled(environment.minimum_change_request_approvals) + + const { permission: manageSegmentOverrides } = useHasPermission({ + id: environmentId, + level: 'environment', + permission: EnvironmentPermission.MANAGE_SEGMENT_OVERRIDES, + }) + + const { permission: savePermission } = useHasPermission({ + id: environmentId, + level: 'environment', + permission: Utils.getManageFeaturePermission(is4Eyes, false), + tags: projectFlag.tags, + }) + + const changeSegment = (items: SegmentOverrideValue[]) => { + items.forEach((item) => { + item.enabled = enabledSegment + }) + updateSegments(items) + setEnabledSegment(!enabledSegment) + } + + const getButtonText = () => { + if (isSaving) { + return existingChangeRequest + ? 'Updating Change Request' + : 'Creating Change Request' + } + return existingChangeRequest + ? 'Update Change Request' + : 'Create Change Request' + } + + const getScheduleButtonText = () => { + if (isSaving) { + return existingChangeRequest + ? 'Updating Change Request' + : 'Scheduling Update' + } + return existingChangeRequest ? 'Update Change Request' : 'Schedule Update' + } + + const environmentName = environment?.name || '' + + let featureError = + error?.metadata?.flatMap((m: any) => m.non_field_errors ?? []).join('\n') || + error?.message || + error?.name?.[0] || + error + let featureWarning = '' + if ( + featureError?.includes?.('no changes') && + projectFlag.multivariate_options?.length + ) { + featureWarning = + 'Your feature contains no changes to its value, enabled state or environment weights. If you have adjusted any variation values this will have been saved for all environments.' + featureError = '' + } + + return ( + + ( + <> +
Segment Overrides
+ + This feature is in {matchingReleasePipeline?.name} release + pipeline and no segment overrides can be created + + + )} + > +
+ +
+ + Segment Overrides + + } + place='top' + > + {Constants.strings.SEGMENT_OVERRIDES_DESCRIPTION} + +
+ {!showCreateSegment && manageSegmentOverrides && !disableCreate && ( +
+ +
+ )} + {!showCreateSegment && !noPermissions && ( + + )} +
+ {segmentOverrides ? ( + <> + + + { + onSegmentsChange() + updateSegments(v) + }} + highlightSegmentId={highlightSegmentId} + /> + + ) : ( +
+ +
+ )} + {!showCreateSegment && } + {!showCreateSegment && ( +
+

+ {is4Eyes && isVersioned + ? 'This will create a change request with any value and segment override changes for the environment' + : 'This will update the segment overrides for the environment'}{' '} + {environmentName} +

+
+ {isVersioned && is4Eyes + ? Utils.renderWithPermission( + savePermission, + Utils.getManageFeaturePermissionDescription( + is4Eyes, + false, + ), + , + ) + : Utils.renderWithPermission( + manageSegmentOverrides, + Constants.environmentPermissions( + EnvironmentPermission.MANAGE_SEGMENT_OVERRIDES, + ), + <> + {!is4Eyes && isVersioned && ( + <> + + + )} + + , + )} +
+
+ )} +
+
+
+ ) +} + +export default SegmentOverridesTab diff --git a/frontend/web/components/modals/create-feature/tabs/UsageTab.tsx b/frontend/web/components/modals/create-feature/tabs/UsageTab.tsx new file mode 100644 index 000000000000..c59793c75307 --- /dev/null +++ b/frontend/web/components/modals/create-feature/tabs/UsageTab.tsx @@ -0,0 +1,65 @@ +import React, { FC } from 'react' +import Project from 'common/project' +import FeatureAnalytics from 'components/feature-page/FeatureNavTab/FeatureAnalytics' +import FeatureCodeReferencesContainer from 'components/feature-page/FeatureNavTab/CodeReferences/FeatureCodeReferencesContainer' + +type UsageTabProps = { + projectId: number | string + featureId: number + environmentId: number + hasCodeReferences: boolean +} + +const UsageTab: FC = ({ + environmentId, + featureId, + hasCodeReferences, + projectId, +}) => { + if (!projectId) { + return null + } + return ( + <> + {!Project.disableAnalytics && ( +
+ +
+ )} + {hasCodeReferences && ( + +
+
Code references
+ + New + +
+
+ Code references allow you to track where feature flags are being + used within your code.{' '} + + Learn more + +
+ +
+ )} + + ) +} + +export default UsageTab diff --git a/frontend/web/components/navigation/navbars/ProjectNavbar.tsx b/frontend/web/components/navigation/navbars/ProjectNavbar.tsx index e2666ff3355a..19a96788b2c1 100644 --- a/frontend/web/components/navigation/navbars/ProjectNavbar.tsx +++ b/frontend/web/components/navigation/navbars/ProjectNavbar.tsx @@ -51,18 +51,18 @@ const ProjectNavbar: FC = ({ environmentId, projectId }) => { > Segments - {Utils.getFlagsmithHasFeature('feature_lifecycle') && ( - } - id='lifecycle-link' - to={`/project/${projectId}/lifecycle`} - isActive={(_, location) => - location.pathname.startsWith(`/project/${projectId}/lifecycle`) - } - > - Feature Lifecycle - - )} + {Utils.getFlagsmithHasFeature('feature_lifecycle') && ( + } + id='lifecycle-link' + to={`/project/${projectId}/lifecycle`} + isActive={(_, location) => + location.pathname.startsWith(`/project/${projectId}/lifecycle`) + } + > + Feature Lifecycle + + )} = ({ match }) => { ?.multivariate_feature_state_values : undefined } - flagId={environmentFlag.id} />, 'side-modal create-feature-modal', ) diff --git a/frontend/web/components/pages/WidgetPage.tsx b/frontend/web/components/pages/WidgetPage.tsx index 19fb7cd4e0c1..6c9ed2938883 100644 --- a/frontend/web/components/pages/WidgetPage.tsx +++ b/frontend/web/components/pages/WidgetPage.tsx @@ -40,9 +40,9 @@ import AuditLog from 'components/AuditLog' import OrgEnvironmentSelect from 'components/OrgEnvironmentSelect' import AccountStore from 'common/stores/account-store' -const FeatureListProvider = require('common/providers/FeatureListProvider') -const AppActions = require('common/dispatcher/app-actions') -const ES6Component = require('common/ES6Component') +import FeatureListProvider from 'common/providers/FeatureListProvider' +import AppActions from 'common/dispatcher/app-actions' +import ES6Component from 'common/ES6Component' let isWidget = false export const getIsWidget = () => { return isWidget diff --git a/frontend/web/components/pages/features/components/FeaturesPageHeader.tsx b/frontend/web/components/pages/features/components/FeaturesPageHeader.tsx index c563e4f57200..2585df21b41c 100644 --- a/frontend/web/components/pages/features/components/FeaturesPageHeader.tsx +++ b/frontend/web/components/pages/features/components/FeaturesPageHeader.tsx @@ -3,7 +3,7 @@ import PageTitle from 'components/PageTitle' import Button from 'components/base/forms/Button' import Constants from 'common/constants' import Permission from 'common/providers/Permission' -import FeatureLimitAlert from 'components/modals/create-feature/FeatureLimitAlert' +import FeatureLimitAlert from 'components/modals/create-feature/components/FeatureLimitAlert' import { ProjectPermission } from 'common/types/permissions.types' type FeaturesPageHeaderProps = { diff --git a/frontend/web/components/saveFeatureWithValidation.ts b/frontend/web/components/saveFeatureWithValidation.ts index 816b7791269a..cd3e756b920f 100644 --- a/frontend/web/components/saveFeatureWithValidation.ts +++ b/frontend/web/components/saveFeatureWithValidation.ts @@ -1,5 +1,5 @@ export const saveFeatureWithValidation = (cb: (schedule?: boolean) => void) => { - return (schedule: boolean) => { + return (schedule?: boolean) => { if (document.getElementById('language-validation-error')) { openConfirm({ body: 'Your remote config value does not pass validation for the language you have selected. Are you sure you wish to save?', diff --git a/frontend/web/project/project-components.js b/frontend/web/project/project-components.js index 838b436334dd..696c7e0e0a9d 100644 --- a/frontend/web/project/project-components.js +++ b/frontend/web/project/project-components.js @@ -17,13 +17,20 @@ import { checkmarkCircle } from 'ionicons/icons' import { IonIcon } from '@ionic/react' import FormGroup from 'components/base/grid/FormGroup' import Row from 'components/base/grid/Row' -window.AppActions = require('../../common/dispatcher/app-actions') -window.Actions = require('../../common/dispatcher/action-constants') -window.ES6Component = require('../../common/ES6Component') +import AppActions from 'common/dispatcher/app-actions' +import Actions from 'common/dispatcher/action-constants' +import ES6Component from 'common/ES6Component' +import FeatureListProvider from 'common/providers/FeatureListProvider' +import Flex from 'components/base/grid/Flex' +import Column from 'components/base/grid/Column' + +window.AppActions = AppActions +window.Actions = Actions +window.ES6Component = ES6Component window.AccountProvider = AccountProvider window.AccountStore = AccountStore -window.FeatureListProvider = require('../../common/providers/FeatureListProvider') +window.FeatureListProvider = FeatureListProvider window.OrganisationProvider = OrganisationProvider window.ProjectProvider = ProjectProvider @@ -31,8 +38,8 @@ window.Paging = Paging // Useful components window.Row = Row -window.Flex = require('../components/base/grid/Flex') -window.Column = require('../components/base/grid/Column') +window.Flex = Flex +window.Column = Column window.InputGroup = InputGroup window.Input = Input window.Button = Button