diff --git a/frontend/common/providers/ProjectProvider.tsx b/frontend/common/providers/ProjectProvider.tsx index 42a8da1bafc2..a87f616dbe8f 100644 --- a/frontend/common/providers/ProjectProvider.tsx +++ b/frontend/common/providers/ProjectProvider.tsx @@ -11,6 +11,7 @@ export type CreateEnvType = (data: { cloneFeatureStatesAsync?: boolean metadata: Environment['metadata'] }) => void + export type ProjectProviderType = { children: (props: { createEnv: CreateEnvType diff --git a/frontend/common/types/responses.ts b/frontend/common/types/responses.ts index ea8c3d03c246..1d59e7455b72 100644 --- a/frontend/common/types/responses.ts +++ b/frontend/common/types/responses.ts @@ -79,16 +79,23 @@ export type Environment = { is_creating: boolean api_key: string description?: string - banner_text?: string + banner_text?: string | null banner_colour?: string project: number - minimum_change_request_approvals?: number + minimum_change_request_approvals?: number | null allow_client_traits: boolean hide_sensitive_data: boolean total_segment_overrides?: number use_v2_feature_versioning: boolean metadata: Metadata[] | [] + use_identity_overrides_in_local_eval: boolean + use_identity_composite_key_for_hashing: boolean + hide_disabled_flags: boolean | null + use_mv_v2_evaluation: boolean + show_disabled_flags: boolean + enabledFeatureVersioning?: boolean } + export type Project = { id: number uuid: string diff --git a/frontend/common/types/webhooks.ts b/frontend/common/types/webhooks.ts new file mode 100644 index 000000000000..354b20e3622d --- /dev/null +++ b/frontend/common/types/webhooks.ts @@ -0,0 +1,7 @@ +export type Webhook = { + id: string + url: string + secret: string + enabled: boolean + created_at: string +} diff --git a/frontend/web/components/IntegrationList.tsx b/frontend/web/components/IntegrationList.tsx index 08b49cbf1676..ce436694c6b7 100644 --- a/frontend/web/components/IntegrationList.tsx +++ b/frontend/web/components/IntegrationList.tsx @@ -70,7 +70,7 @@ const Integration: FC = (props) => { props.addIntegration(props.integration, props.id) } } - + const openChildWin = () => { const childWindow = window.open( `${Project.githubAppURL}`, diff --git a/frontend/web/components/base/forms/Tabs.js b/frontend/web/components/base/forms/Tabs.js index 9644607baf75..cfbb87b63c37 100644 --- a/frontend/web/components/base/forms/Tabs.js +++ b/frontend/web/components/base/forms/Tabs.js @@ -79,14 +79,14 @@ const Tabs = class extends React.Component { } this.props.onChange?.(i) }} - className={`btn-tab ${isSelected ? ' tab-active' : ''}`} + className={`btn-tab ${this.props.noFocus ? 'btn-no-focus' : ''} ${isSelected ? ' tab-active' : ''}`} > {child.props.tabLabel} ) })} - {this.props.theme === 'tab' && !hideNav && ( + {this.props.theme === 'tab' && !hideNav && ( )}
diff --git a/frontend/web/components/hooks/useFormNotSavedModal.tsx b/frontend/web/components/hooks/useFormNotSavedModal.tsx new file mode 100644 index 000000000000..5d3d4526d5bc --- /dev/null +++ b/frontend/web/components/hooks/useFormNotSavedModal.tsx @@ -0,0 +1,104 @@ +import { useState, useEffect, useRef, useCallback } from "react" +import { RouterChildContext } from "react-router-dom" +import { Modal, ModalHeader, ModalBody } from "reactstrap" + +/** + * useFormNotSavedModal + * @param {history: RouterChildContext['router']['history']} history - The history object + * @param {string} warningMessage - The message to show when user attempts to leave + * @returns {[React.FC, Function, boolean]} + */ + +type UseFormNotSavedModalReturn = [React.FC, React.Dispatch>, boolean] + +interface UseFormNotSavedModalOptions { + warningMessage?: string + onDiscard?: () => void +} + +const useFormNotSavedModal = ( + history: RouterChildContext['router']['history'], + options: UseFormNotSavedModalOptions = {} +): UseFormNotSavedModalReturn => { + const { onDiscard, warningMessage = "You have unsaved changes, are you sure you want to leave?" } = options + + const [isDirty, setIsDirty] = useState(false) + const [isNavigating, setIsNavigating] = useState(false); + const [nextLocation, setNextLocation] = useState(null); + + const unblockRef = useRef<(() => void) | null>(null); + useEffect(() => { + if (!isDirty) return; + + const unblock = history.block((transition: Location) => { + setNextLocation(transition); + setIsNavigating(true); + return false; + }); + + unblockRef.current = unblock; + return () => { + if (unblockRef.current) { + unblockRef.current(); + } + unblockRef.current = null; + }; + }, [isDirty, history]); + + const discardAndConfirmNavigation = useCallback(() => { + // allow the route change to happen + if (unblockRef.current) { + unblockRef.current(); // unblocks + unblockRef.current = null; + } + // navigate + if (nextLocation) { + history.push(`${nextLocation.pathname}${nextLocation.search}`); + } + if (onDiscard) { + onDiscard() + } + setIsDirty(false) + setIsNavigating(false); + setNextLocation(null); + }, [nextLocation, history, onDiscard]); + + const cancelNavigation = useCallback(() => { + history.push(`${history.location.pathname}${history.location.search}`); + setIsNavigating(false); + setNextLocation(null); + }, [history]); + + // Listen for browser/tab close (the 'beforeunload' event) + useEffect(() => { + const handleBeforeUnload = (event: BeforeUnloadEvent) => { + if (!isDirty) return + event.preventDefault() + event.returnValue = warningMessage + } + + window.addEventListener("beforeunload", handleBeforeUnload) + return () => { + window.removeEventListener("beforeunload", handleBeforeUnload) + } + }, [isDirty, warningMessage]) + + const DirtyFormModal = () => ( + + +
Unsaved Changes
+
+ +

{warningMessage}

+
+
+ + +
+
+ ) + + return [DirtyFormModal, setIsDirty, isDirty] +} + +export default useFormNotSavedModal diff --git a/frontend/web/components/modals/ConfirmRemoveEnvironment.tsx b/frontend/web/components/modals/ConfirmRemoveEnvironment.tsx index 58f5a20af222..d0cb73615bd3 100644 --- a/frontend/web/components/modals/ConfirmRemoveEnvironment.tsx +++ b/frontend/web/components/modals/ConfirmRemoveEnvironment.tsx @@ -23,6 +23,7 @@ const ConfirmRemoveEnvironment: FC = ({ cb() } } + return ( {() => ( diff --git a/frontend/web/components/pages/EnvironmentSettingsPage.tsx b/frontend/web/components/pages/EnvironmentSettingsPage.tsx new file mode 100644 index 000000000000..2c475e3b7911 --- /dev/null +++ b/frontend/web/components/pages/EnvironmentSettingsPage.tsx @@ -0,0 +1,886 @@ +import React, { useCallback, useEffect, useState } from 'react' +import ConfirmRemoveEnvironment from 'components/modals/ConfirmRemoveEnvironment' +import ProjectStore from 'common/stores/project-store' +import ConfigProvider from 'common/providers/ConfigProvider' +import withWebhooks from 'common/providers/withWebhooks' +import CreateWebhookModal from 'components/modals/CreateWebhook' +import ConfirmRemoveWebhook from 'components/modals/ConfirmRemoveWebhook' +import ConfirmToggleEnvFeature from 'components/modals/ConfirmToggleEnvFeature' +import EditPermissions from 'components/EditPermissions' +import Tabs from 'components/base/forms/Tabs' +import TabItem from 'components/base/forms/TabItem' +import JSONReference from 'components/JSONReference' +import ColourSelect from 'components/tags/ColourSelect' +import Constants from 'common/constants' +import Switch from 'components/Switch' +import Icon from 'components/Icon' +import _ from 'lodash' +import PageTitle from 'components/PageTitle' +import { getStore } from 'common/store' +import { getRoles } from 'common/services/useRole' +import { getRoleEnvironmentPermissions } from 'common/services/useRolePermission' +import AccountStore from 'common/stores/account-store' +import { Link, RouterChildContext } from 'react-router-dom' +import { enableFeatureVersioning } from 'common/services/useEnableFeatureVersioning' +import AddMetadataToEntity from 'components/metadata/AddMetadataToEntity' +import { getSupportedContentType } from 'common/services/useSupportedContentType' +import EnvironmentVersioningListener from 'components/EnvironmentVersioningListener' +import Format from 'common/utils/format' +import Setting from 'components/Setting' +import API from 'project/api' +import AppActions from 'common/dispatcher/app-actions' +import { Environment } from 'common/types/responses' +import PanelSearch from 'components/PanelSearch' +import moment from 'moment' +import Panel from 'components/base/grid/Panel' +import ProjectProvider from 'common/providers/ProjectProvider' +import InputGroup from 'components/base/forms/InputGroup' +import Utils from 'common/utils/utils' +import { Webhook } from 'common/types/webhooks' +import useFormNotSavedModal from 'components/hooks/useFormNotSavedModal' + +const showDisabledFlagOptions: { label: string, value: boolean | null }[] = [ + { label: 'Inherit from Project', value: null }, + { label: 'Disabled', value: false }, + { label: 'Enabled', value: true }, +] + +interface EnvironmentSettingsPageProps { + // Router props + match: { + params: { + projectId: string + environmentId: string + } + } + router: RouterChildContext['router'] + // Webhook props from HOC + webhooks: Webhook[] + webhooksLoading: boolean + getWebhooks: () => void + createWebhook: (webhook: Partial) => Promise + saveWebhook: (webhook: Webhook) => Promise + deleteWebhook: (webhook: Webhook) => Promise +} + +const EnvironmentSettingsPage: React.FC = ({ createWebhook, deleteWebhook, getWebhooks, match, router, saveWebhook, webhooks, webhooksLoading }) => { + const store = getStore() + const [currentEnv, setCurrentEnv] = useState(null) + const [roles, setRoles] = useState([]) + const [environmentContentType, setEnvironmentContentType] = useState(null) + + const has4EyesPermission = Utils.getPlansPermission('4_EYES') + const metadataEnable = Utils.getPlansPermission('METADATA') + + const onDiscard = () => { + const env = ProjectStore?.getEnvs()?.find( + (env: Environment) => env.api_key === match.params.environmentId, + ) + if (env && currentEnv) { + setCurrentEnv({ ...currentEnv, description: env.description, name: env.name }) + } + } + + const [DirtyFormModal, setIsDirty, isDirty] = useFormNotSavedModal(router.history, { onDiscard: onDiscard }) + + const getEnvironment = useCallback(async () => { + const env = ProjectStore?.getEnvs()?.find( + (env: Environment) => env.api_key === match.params.environmentId, + ) + setCurrentEnv(env) + + const roles = await getRoles( + store, + { organisation_id: AccountStore.getOrganisation().id }, + { forceRefetch: true }, + ) + if (!roles?.data?.results?.length) return + + const roleEnvironmentPermissions = await getRoleEnvironmentPermissions( + store, + { + env_id: env.id, + organisation_id: AccountStore.getOrganisation().id, + role_id: roles.data.results[0].id, + }, + { forceRefetch: true }, + ) + + const matchingItems = roles.data.results.filter((item1) => + roleEnvironmentPermissions.data.results.some((item2) => item2.role === item1.id), + ) + setRoles(matchingItems) + + + if (Utils.getPlansPermission('METADATA')) { + const supportedContentType = await getSupportedContentType(getStore(), { + organisation_id: AccountStore.getOrganisation().id, + }) + const environmentContentType = Utils.getContentType( + supportedContentType.data, + 'model', + 'environment', + ) + setEnvironmentContentType(environmentContentType) + } + + await getWebhooks() + }, [match.params.environmentId, getWebhooks, store]) + + useEffect(() => { + AppActions.getProject(match.params.projectId) + }, [match.params.projectId]) + + useEffect(() => { + getEnvironment() + }, [match.params.environmentId, getEnvironment]) + + useEffect(() => { + API.trackPage(Constants.pages.ENVIRONMENT_SETTINGS) + getEnvironment() + getWebhooks() + }, [getEnvironment, getWebhooks]) + + const onSave = () => { + toast('Environment Saved') + } + + const onRemove = () => { + toast('Your project has been removed') + router.history.replace(Utils.getOrganisationHomePage()) + } + + const confirmRemove = (environment: Environment, callback: () => void) => { + openModal( + 'Remove Environment', + , + 'p-0', + ) + } + + const onRemoveEnvironment = (environment: Environment) => { + const envs = ProjectStore.getEnvs() as Environment[] | null + if (envs && envs?.length > 0) { + router.history.replace( + `/project/${match.params.projectId}/environment` + + `/${envs[0].api_key}/features`, + ) + } else { + router.history.replace( + `/project/${match.params.projectId}/environment/create`, + ) + } + toast( +
+ Removed Environment: {environment.name} +
, + ) + } + + const updateCurrentEnv = (newEnv: Partial = {}, shouldSaveUpdate?: boolean, isDirtyDisabled?: boolean) => { + if (!isDirtyDisabled) { + setIsDirty(true) + } + setCurrentEnv((currentEnvState) => { + if (!currentEnvState) return null + const newEnvState = { + ...currentEnvState, + ...newEnv, + } + if (shouldSaveUpdate) { + saveEnv(newEnvState) + } + return newEnvState + }) + } + + const saveEnv = (newEnv: Partial = {}) => { + if (ProjectStore.isSaving || !currentEnv?.name) { + return + } + const editedEnv = { ...currentEnv, ...newEnv } + + AppActions.editEnv( + Object.assign({}, currentEnv, { + allow_client_traits: !!editedEnv?.allow_client_traits, + banner_colour: editedEnv?.banner_colour, + banner_text: editedEnv?.banner_text, + description: editedEnv?.description, + hide_disabled_flags: editedEnv?.hide_disabled_flags, + hide_sensitive_data: !!editedEnv?.hide_sensitive_data, + minimum_change_request_approvals: has4EyesPermission + ? editedEnv?.minimum_change_request_approvals + : null, + name: editedEnv.name || currentEnv.name, + use_identity_composite_key_for_hashing: + !!editedEnv?.use_identity_composite_key_for_hashing, + use_identity_overrides_in_local_eval: + !!editedEnv?.use_identity_overrides_in_local_eval, + use_mv_v2_evaluation: !!editedEnv?.use_mv_v2_evaluation, + }), + ) + setIsDirty(false) + } + + const handleCreateWebhook = () => { + openModal( + 'New Webhook', + , + 'side-modal', + ) + } + + const handleEditWebhook = (webhook: Webhook) => { + openModal( + 'Edit Webhook', + , + 'side-modal', + ) + } + + const handleDeleteWebhook = (webhook: Webhook) => { + openModal( + 'Remove Webhook', + deleteWebhook(webhook)} + />, + 'p-0', + ) + } + + const saveDisabled = ProjectStore.isSaving || !currentEnv?.name + + const confirmToggle = (title: string, environmentProperty: string, environmentPropertyValue: boolean) => { + openModal( + title, + { + updateCurrentEnv({ [environmentProperty]: environmentPropertyValue }, true) + closeModal() + }} + />, + 'p-0 modal-sm', + ) + } + + const onEnableVersioning = () => { + if (!currentEnv?.api_key) return + openConfirm({ + body: 'This will allow you to attach versions to updating feature values and segment overrides. Note: this may take several minutes to process', + onYes: () => { + enableFeatureVersioning(store, { + environmentId: currentEnv?.api_key, + }).then(() => { + toast( + 'Feature Versioning Enabled, this may take several minutes to process.', + ) + updateCurrentEnv({ + enabledFeatureVersioning: true, + }, false, true) + }) + }, + title: 'Enable "Feature Versioning"', + }) + } + + return ( +
+ + {({ deleteEnv, isLoading, isSaving, project }) => { + const env = _.find(project?.environments, { + api_key: match.params.environmentId, + }) + if ( + (env && + typeof env?.minimum_change_request_approvals === + 'undefined') || + env?.api_key !== match.params.environmentId + ) { + setTimeout(() => { + const minimumChangeRequestApprovals = Utils.changeRequestsEnabled(env?.minimum_change_request_approvals) + updateCurrentEnv({ + allow_client_traits: !!env?.allow_client_traits, + banner_colour: env?.banner_colour || Constants.tagColors[0], + banner_text: env?.banner_text, + hide_disabled_flags: env?.hide_disabled_flags || false, + hide_sensitive_data: !!env?.hide_sensitive_data, + minimum_change_request_approvals: minimumChangeRequestApprovals + ? env?.minimum_change_request_approvals + : null, + name: env?.name || "", + use_identity_composite_key_for_hashing: + !!env?.use_identity_composite_key_for_hashing, + use_identity_overrides_in_local_eval: + !!env?.use_identity_overrides_in_local_eval, + use_v2_feature_versioning: !!env?.use_v2_feature_versioning, + }, false, true) + }, 10) + } + + return ( + <> + + + {isLoading && ( +
+ +
+ )} + {!isLoading && ( + + +
+
General Settings
+ +
+
saveEnv()}> + ) => { + const value = Utils.safeParseEventValue(e) + updateCurrentEnv({ name: value }, false) + }} + isValid={currentEnv?.name && currentEnv?.name.length} + type='text' + title='Environment Name' + placeholder='Environment Name' + /> + (this.input = e)} + value={currentEnv?.description ?? ''} + inputProps={{ + className: 'input--wide textarea-lg', + }} + onChange={(e: React.ChangeEvent) => { + const value = Utils.safeParseEventValue(e) + updateCurrentEnv({ description: value }) + } + } + isValid={currentEnv?.description && currentEnv?.description.length} + type='text' + title='Environment Description' + placeholder='Environment Description' + /> +
+ +
+ +
+
+
+ updateCurrentEnv({ + banner_text: value ? `${currentEnv?.name} Environment` : null + }, true)} + checked={typeof currentEnv?.banner_text === 'string'} + title={'Environment Banner'} + description={ +
+ This will show a banner whenever you view its + pages. +
+ This is generally used to warn people that they + are viewing and editing a sensitive environment. +
+ } + /> + {typeof currentEnv?.banner_text === 'string' && ( + + ) => { + const bannerText = Utils.safeParseEventValue(e) + updateCurrentEnv({ banner_text: bannerText }, false) + }} + className='full-width' + /> +
+ + updateCurrentEnv({ banner_colour }, false) + } + /> +
+ +
+ )} +
+ {Utils.getFlagsmithHasFeature('feature_versioning') && ( +
+
+ {currentEnv?.use_v2_feature_versioning === false && ( + { + updateCurrentEnv({ use_v2_feature_versioning: true }, false, true) + }} + /> + )} + + Allows you to attach versions to updating + feature values and segment overrides. +
+ This setting may take up to a minute to take + affect. +
+
+ Enabling this is irreversible. +
+
+ } + disabled={ + currentEnv?.use_v2_feature_versioning || + currentEnv?.enabledFeatureVersioning + } + data-test={ + currentEnv?.use_v2_feature_versioning + ? 'feature-versioning-enabled' + : 'enable-versioning' + } + checked={currentEnv?.use_v2_feature_versioning} + onChange={onEnableVersioning} + /> +
+
+ )} +
+ { + confirmToggle( + 'Confirm Environment Setting', + 'hide_sensitive_data', + value, + ) + }} + description={ +
+ Exclude sensitive data from endpoints returning + flags and identity information to the SDKs or + via our REST API. +
+ For full information on the excluded fields see + documentation{' '} + +
+ Enabling this feature will change the response + from the API and could break your existing + code. +
+
+ } + /> +
+ + + updateCurrentEnv({ minimum_change_request_approvals: value ? 0 : undefined }, true) + } + /> + {Utils.changeRequestsEnabled( + currentEnv?.minimum_change_request_approvals, + ) && + has4EyesPermission && ( +
+
+ Minimum number of approvals +
+ + + { + const value = Utils.safeParseEventValue(e) + updateCurrentEnv({ minimum_change_request_approvals: value ? parseInt(value) : undefined }, false) + }} + isValid={currentEnv?.minimum_change_request_approvals && currentEnv?.minimum_change_request_approvals.length} + type='number' + placeholder='Minimum number of approvals' + /> + + + +
+ )} +
+
+ + +
+
Delete Environment
+

+ This environment will be permanently deleted. +

+
+ +
+
+
+ + +
+ +
+
saveEnv()}> +
+
+ Hide disabled flags from SDKs +
+