diff --git a/frontend/common/services/useProject.ts b/frontend/common/services/useProject.ts index b88606b745ee..73e4a65c0817 100644 --- a/frontend/common/services/useProject.ts +++ b/frontend/common/services/useProject.ts @@ -7,12 +7,27 @@ export const projectService = service .enhanceEndpoints({ addTagTypes: ['Project'] }) .injectEndpoints({ endpoints: (builder) => ({ + deleteProject: builder.mutation({ + invalidatesTags: [{ id: 'LIST', type: 'Project' }], + query: ({ id }: Req['deleteProject']) => ({ + method: 'DELETE', + url: `projects/${id}/`, + }), + }), getProject: builder.query({ providesTags: (res) => [{ id: res?.id, type: 'Project' }], query: (query: Req['getProject']) => ({ url: `projects/${query.id}/`, }), }), + getProjectPermissions: builder.query< + Res['userPermissions'], + Req['getProjectPermissions'] + >({ + query: ({ projectId }: Req['getProjectPermissions']) => ({ + url: `projects/${projectId}/user-permissions/`, + }), + }), getProjects: builder.query({ providesTags: [{ id: 'LIST', type: 'Project' }], query: (data) => ({ @@ -20,6 +35,43 @@ export const projectService = service }), transformResponse: (res) => sortBy(res, (v) => v.name.toLowerCase()), }), + migrateProject: builder.mutation({ + invalidatesTags: (res, error, { id }) => [{ id, type: 'Project' }], + query: ({ id }: Req['migrateProject']) => ({ + method: 'POST', + url: `projects/${id}/migrate-to-edge/`, + }), + }), + updateProject: builder.mutation({ + invalidatesTags: (res) => [ + { id: res?.id, type: 'Project' }, + { id: 'LIST', type: 'Project' }, + ], + async onQueryStarted({ body, id }, { dispatch, queryFulfilled }) { + // Optimistically update the cache before server responds + const patchResult = dispatch( + projectService.util.updateQueryData( + 'getProject', + { id }, + (draft) => { + Object.assign(draft, body) + }, + ), + ) + + try { + await queryFulfilled + } catch { + // Automatically rollback on error + patchResult.undo() + } + }, + query: ({ body, id }: Req['updateProject']) => ({ + body, + method: 'PUT', + url: `projects/${id}/`, + }), + }), // END OF ENDPOINTS }), }) @@ -47,8 +99,12 @@ export async function getProject( // END OF FUNCTION_EXPORTS export const { + useDeleteProjectMutation, + useGetProjectPermissionsQuery, useGetProjectQuery, useGetProjectsQuery, + useMigrateProjectMutation, + useUpdateProjectMutation, // END OF EXPORTS } = projectService diff --git a/frontend/common/types/requests.ts b/frontend/common/types/requests.ts index a3d279bca967..d318d627246f 100644 --- a/frontend/common/types/requests.ts +++ b/frontend/common/types/requests.ts @@ -14,7 +14,6 @@ import { UserGroup, AttributeName, Identity, - ChangeRequest, ProjectChangeRequest, Role, RolePermission, @@ -27,6 +26,17 @@ import { } from './responses' import { UtmsType } from './utms' +export type UpdateProjectBody = { + name: string + hide_disabled_flags?: boolean + prevent_flag_defaults?: boolean + enable_realtime_updates?: boolean + minimum_change_request_approvals?: number | null + stale_flags_limit_days?: number | null + only_allow_lower_case_feature_names?: boolean + feature_name_regex?: string | null +} + export type PagedRequest = T & { page?: number page_size?: number @@ -580,6 +590,10 @@ export type Req = { id: string } getProject: { id: string } + updateProject: { id: string; body: UpdateProjectBody } + deleteProject: { id: string } + migrateProject: { id: string } + getProjectPermissions: { projectId: string } createGroup: { orgId: string data: Omit diff --git a/frontend/common/types/responses.ts b/frontend/common/types/responses.ts index 197691b3a31c..c2a9ffd8e572 100644 --- a/frontend/common/types/responses.ts +++ b/frontend/common/types/responses.ts @@ -136,6 +136,8 @@ export type Project = { total_features?: number stale_flags_limit_days?: number total_segments?: number + only_allow_lower_case_feature_names?: boolean + feature_name_regex?: string | null environments: Environment[] } export type ImportStrategy = 'SKIP' | 'OVERWRITE_DESTRUCTIVE' @@ -1104,7 +1106,7 @@ export type Res = { } profile: User onboarding: {} - userPermissions: UserPermissions + userPermissions: UserPermission[] releasePipelines: PagedResponse releasePipeline: SingleReleasePipeline pipelineStages: PagedResponse diff --git a/frontend/e2e/tests/project-test.ts b/frontend/e2e/tests/project-test.ts index 48252d2c712b..eeda1c0f01c9 100644 --- a/frontend/e2e/tests/project-test.ts +++ b/frontend/e2e/tests/project-test.ts @@ -1,15 +1,21 @@ import { + assertInputValue, assertTextContent, byId, click, + getFlagsmith, log, login, setText, + waitForElementNotExist, waitForElementVisible, } from '../helpers.cafe'; import { E2E_USER, PASSWORD } from '../config' export default async function () { + const flagsmith = await getFlagsmith() + const hasSegmentChangeRequests = flagsmith.hasFeature('segment_change_requests') + log('Login') await login(E2E_USER, PASSWORD) await click('#project-select-0') @@ -20,4 +26,43 @@ export default async function () { await click('#save-proj-btn') await assertTextContent(`#project-link`, 'Test Project') + if (hasSegmentChangeRequests) { + log('Test Change Requests Approvals Setting') + + log('Test 1: Enable change requests (auto-save on toggle)') + await click('[data-test="js-change-request-approvals"]') + await waitForElementVisible('[name="env-name"]') + log('Verify auto-save persisted after navigation') + await click('#features-link') + await click('#project-settings-link') + await waitForElementVisible('[name="env-name"]') + + log('Test 2: Change minimum approvals to 3 (manual save)') + await setText('[name="env-name"]', '3') + await click('#save-env-btn') + log('Verify value 3 persisted after navigation') + await click('#features-link') + await click('#project-settings-link') + await waitForElementVisible('[name="env-name"]') + await assertInputValue('[name="env-name"]', '3') + + log('Test 3: Disable change requests (auto-save on toggle)') + await click('[data-test="js-change-request-approvals"]') + log('Verify disabled state persisted after navigation') + await click('#features-link') + await click('#project-settings-link') + await waitForElementNotExist('[name="env-name"]') + + log('Test 4: Re-enable and change to 5 (manual save)') + await click('[data-test="js-change-request-approvals"]') + await waitForElementVisible('[name="env-name"]') + await setText('[name="env-name"]', '5') + await click('#save-env-btn') + log('Verify value 5 persisted after navigation') + await click('#features-link') + await click('#project-settings-link') + await waitForElementVisible('[name="env-name"]') + await assertInputValue('[name="env-name"]', '5') + } + } diff --git a/frontend/global.d.ts b/frontend/global.d.ts index 2ea841e7a7a5..54de827ed234 100644 --- a/frontend/global.d.ts +++ b/frontend/global.d.ts @@ -65,11 +65,24 @@ declare global { const Select: typeof _Select const Column: typeof Component const Loader: typeof Component + const Input: typeof Component + const Button: typeof Component const E2E: boolean const closeModal: () => void const closeModal2: () => void const toast: (message: string) => void const Tooltip: FC + const API: { + trackPage: (title: string) => void + trackEvent: (data: { + category: string + event: string + label?: string + extra?: Record + }) => void + trackTraits: (traits: Record) => void + [key: string]: any + } interface Window { $crisp: Crisp engagement: { diff --git a/frontend/web/components/ChangeRequestsSetting.tsx b/frontend/web/components/ChangeRequestsSetting.tsx index a4122665e963..fe886247c38e 100644 --- a/frontend/web/components/ChangeRequestsSetting.tsx +++ b/frontend/web/components/ChangeRequestsSetting.tsx @@ -11,9 +11,11 @@ type ChangeRequestsSettingType = { onChange: (value: number | null) => void isLoading: boolean feature: '4_EYES' | '4_EYES_PROJECT' + 'data-test'?: string } const ChangeRequestsSetting: FC = ({ + 'data-test': dataTest, feature, isLoading, onChange, @@ -26,6 +28,7 @@ const ChangeRequestsSetting: FC = ({ return ( onToggle(v ? 0 : null)} diff --git a/frontend/web/components/pages/ProjectSettingsPage.js b/frontend/web/components/pages/ProjectSettingsPage.js deleted file mode 100644 index 91531d4bfba0..000000000000 --- a/frontend/web/components/pages/ProjectSettingsPage.js +++ /dev/null @@ -1,673 +0,0 @@ -import React, { Component } from 'react' -import ConfirmRemoveProject from 'components/modals/ConfirmRemoveProject' -import ConfirmHideFlags from 'components/modals/ConfirmHideFlags' -import EditPermissions from 'components/EditPermissions' -import Switch from 'components/Switch' -import _data from 'common/data/base/_data' -import Tabs from 'components/navigation/TabMenu/Tabs' -import TabItem from 'components/navigation/TabMenu/TabItem' -import RegexTester from 'components/RegexTester' -import ConfigProvider from 'common/providers/ConfigProvider' -import Constants from 'common/constants' -import JSONReference from 'components/JSONReference' -import PageTitle from 'components/PageTitle' -import Icon from 'components/Icon' -import { getStore } from 'common/store' -import { getRoles } from 'common/services/useRole' -import AccountStore from 'common/stores/account-store' -import ImportPage from 'components/import-export/ImportPage' -import FeatureExport from 'components/import-export/FeatureExport' -import ProjectUsage from 'components/ProjectUsage' -import ProjectStore from 'common/stores/project-store' -import Tooltip from 'components/Tooltip' -import Setting from 'components/Setting' -import PlanBasedBanner from 'components/PlanBasedAccess' -import classNames from 'classnames' -import ProjectProvider from 'common/providers/ProjectProvider' -import ChangeRequestsSetting from 'components/ChangeRequestsSetting' -import EditHealthProvider from 'components/EditHealthProvider' -import WarningMessage from 'components/WarningMessage' -import { withRouter } from 'react-router-dom' -import Utils from 'common/utils/utils' -import { useRouteContext } from 'components/providers/RouteContext' -import SettingTitle from 'components/SettingTitle' -import BetaFlag from 'components/BetaFlag' - -const ProjectSettingsPage = class extends Component { - static displayName = 'ProjectSettingsPage' - - constructor(props) { - super(props) - this.projectId = this.props.routeContext.projectId - this.state = { - roles: [], - } - AppActions.getProject(this.projectId) - this.getPermissions() - } - - getPermissions = () => { - _data - .get(`${Project.api}projects/${this.projectId}/user-permissions/`) - .then((permissions) => { - this.setState({ permissions }) - }) - } - - componentDidMount = () => { - API.trackPage(Constants.pages.PROJECT_SETTINGS) - getRoles( - getStore(), - { organisation_id: AccountStore.getOrganisation().id }, - { forceRefetch: true }, - ).then((roles) => { - if (!roles?.data?.results?.length) return - getRoles(getStore(), { - organisation_id: AccountStore.getOrganisation().id, - }).then((res) => { - this.setState({ roles: res.data.results }) - }) - }) - } - - onSave = () => { - toast('Project Saved') - } - componentDidUpdate(prevProps) { - if (this.props.projectId !== prevProps.projectId) { - AppActions.getProject(this.projectId) - } - } - confirmRemove = (project, cb) => { - openModal( - 'Delete Project', - , - 'p-0', - ) - } - - toggleHideDisabledFlags = (project, editProject) => { - openModal( - 'Hide Disabled Flags', - { - editProject({ - ...project, - hide_disabled_flags: !project.hide_disabled_flags, - }) - }} - />, - 'p-0 modal-sm', - ) - } - - togglePreventDefaults = (project, editProject) => { - editProject({ - ...project, - prevent_flag_defaults: !project.prevent_flag_defaults, - }) - } - - toggleRealtimeUpdates = (project, editProject) => { - editProject({ - ...project, - enable_realtime_updates: !project.enable_realtime_updates, - }) - } - - toggleFeatureValidation = (project, editProject) => { - if (this.state.feature_name_regex) { - editProject({ - ...project, - feature_name_regex: null, - }) - this.setState({ feature_name_regex: null }) - } else { - this.setState({ feature_name_regex: '^.+$' }) - } - } - - updateFeatureNameRegex = (project, editProject) => { - editProject({ - ...project, - feature_name_regex: this.state.feature_name_regex, - }) - } - - toggleCaseSensitivity = (project, editProject) => { - editProject({ - ...project, - only_allow_lower_case_feature_names: - !project.only_allow_lower_case_feature_names, - }) - } - - migrate = () => { - AppActions.migrateProject(this.projectId) - } - - forceSelectionRange = (e) => { - const input = e.currentTarget - setTimeout(() => { - const range = input.selectionStart - if (range === input.value.length) { - input.setSelectionRange(input.value.length - 1, input.value.length - 1) - } - }, 0) - } - - render() { - const { minimum_change_request_approvals, name, stale_flags_limit_days } = - this.state - const hasStaleFlagsPermission = Utils.getPlansPermission('STALE_FLAGS') - const changeRequestsFeature = Utils.getFlagsmithHasFeature( - 'segment_change_requests', - ) - return ( -
- - {({ deleteProject, editProject, isLoading, isSaving, project }) => { - if (project && this.state.populatedProjectState !== project?.id) { - this.state.populatedProjectState = project.id - this.state.stale_flags_limit_days = project.stale_flags_limit_days - this.state.name = project.name - this.state.feature_name_regex = project?.feature_name_regex - this.state.minimum_change_request_approvals = - project?.minimum_change_request_approvals - } - - let regexValid = true - if (this.state.feature_name_regex) - try { - new RegExp(this.state.feature_name_regex) - } catch (e) { - regexValid = false - } - const saveProject = (e) => { - e?.preventDefault?.() - const { - minimum_change_request_approvals, - name, - stale_flags_limit_days, - } = this.state - !isSaving && - name && - editProject( - Object.assign({}, project, { - minimum_change_request_approvals, - name, - stale_flags_limit_days, - }), - ) - } - - const featureRegexEnabled = - typeof this.state.feature_name_regex === 'string' - - const hasVersioning = - Utils.getFlagsmithHasFeature('feature_versioning') - return ( -
- - { - - -
- - Project Information - - -
- - - (this.input = e)} - value={this.state.name} - inputClassName='full-width' - name='proj-name' - onChange={(e) => - this.setState({ - name: Utils.safeParseEventValue(e), - }) - } - isValid={name && name.length} - type='text' - title={} - placeholder='My Project Name' - /> - - - {!!hasVersioning && ( - <> -
- - - - - } - > - {`If no changes have been made to a feature in any environment within this threshold the feature will be tagged as stale. You will need to enable feature versioning in your environments for stale features to be detected.`} - - -
-
- -
- (this.input = e)} - value={this.state.stale_flags_limit_days} - onChange={(e) => - this.setState({ - stale_flags_limit_days: parseInt( - Utils.safeParseEventValue(e), - ), - }) - } - isValid={!!stale_flags_limit_days} - type='number' - placeholder='Number of Days' - /> -
- -
- {!hasStaleFlagsPermission && ( - - )} - - )} -
- -
- -
- Additional Settings - - {!!changeRequestsFeature && ( - - this.setState( - { - minimum_change_request_approvals: v, - }, - saveProject, - ) - } - onSave={saveProject} - onChange={(v) => { - this.setState({ - minimum_change_request_approvals: v, - }) - }} - isLoading={isSaving} - /> - )} - - this.togglePreventDefaults(project, editProject) - } - checked={project.prevent_flag_defaults} - description={`By default, when you create a feature with a value and - enabled state it acts as a default for your other - environments. Enabling this setting forces the user to - create a feature before setting its values per - environment.`} - /> - - - - this.toggleCaseSensitivity(project, editProject) - } - checked={ - !project.only_allow_lower_case_feature_names - } - title='Case sensitive features' - description={`By default, features are lower case in order to - prevent human error. Enabling this will allow you to - use upper case characters when creating features.`} - /> - - - - this.toggleFeatureValidation(project, editProject) - } - checked={featureRegexEnabled} - /> - {featureRegexEnabled && ( - { - e.preventDefault() - if (regexValid) { - this.updateFeatureNameRegex( - project, - editProject, - ) - } - }} - > - - - (this.input = e)} - value={this.state.feature_name_regex} - inputClassName='input input--wide' - name='feature-name-regex' - onClick={this.forceSelectionRange} - onKeyUp={this.forceSelectionRange} - showSuccess - onChange={(e) => { - let newRegex = - Utils.safeParseEventValue( - e, - ).replace('$', '') - if (!newRegex.startsWith('^')) { - newRegex = `^${newRegex}` - } - if (!newRegex.endsWith('$')) { - newRegex = `${newRegex}$` - } - this.setState({ - feature_name_regex: newRegex, - }) - }} - isValid={regexValid} - type='text' - placeholder='Regular Expression' - /> - - - - - - } - /> - )} - - {!Utils.getIsEdge() && !!Utils.isSaas() && ( - - -
- Global Edge API Opt in -
- -
-

- Migrate your project onto our Global Edge API. - Existing Core API endpoints will continue to work - whilst the migration takes place. Find out more{' '} - - here - - . -

-
- )} - - Delete Project - -
-

- This project will be permanently deleted. -

-
- -
-
-
-
- - - - this.toggleRealtimeUpdates(project, editProject) - } - checked={project.enable_realtime_updates} - /> - -
-
- - - - this.toggleHideDisabledFlags( - project, - editProject, - ) - } - checked={project.hide_disabled_flags} - /> -
- Hide disabled flags from SDKs -
-
-

- To prevent letting your users know about your - upcoming features and to cut down on payload, - enabling this will prevent the API from returning - features that are disabled. -

-
-
-
-
- - - - {Utils.getFlagsmithHasFeature('feature_health') && ( - - Feature Health - - } - tabLabelString='Feature Health' - > - - - )} - - { - this.getPermissions() - }} - permissions={this.state.permissions} - tabClassName='flat-panel' - id={this.projectId} - level='project' - roleTabTitle='Project Permissions' - role - roles={this.state.roles} - /> - - - - -
Custom Fields
-
-
- - - Custom fields have been moved to{' '} - - Organisation Settings - - . - - } - /> -
- {!!ProjectStore.getEnvs()?.length && ( - - - - )} - {!!ProjectStore.getEnvs()?.length && ( - - - - )} -
- } -
- ) - }} -
-
- ) - } -} - -ProjectSettingsPage.propTypes = {} - -const ProjectSettingsPageWithContext = (props) => { - const context = useRouteContext() - return -} - -export default withRouter(ConfigProvider(ProjectSettingsPageWithContext)) diff --git a/frontend/web/components/pages/project-settings/ProjectSettingsPage.tsx b/frontend/web/components/pages/project-settings/ProjectSettingsPage.tsx new file mode 100644 index 000000000000..8943b793507b --- /dev/null +++ b/frontend/web/components/pages/project-settings/ProjectSettingsPage.tsx @@ -0,0 +1,153 @@ +import React, { ReactNode, useEffect } from 'react' +import PageTitle from 'components/PageTitle' +import Tabs from 'components/navigation/TabMenu/Tabs' +import TabItem from 'components/navigation/TabMenu/TabItem' +import BetaFlag from 'components/BetaFlag' +import { useGetProjectQuery } from 'common/services/useProject' +import { useRouteContext } from 'components/providers/RouteContext' +import Constants from 'common/constants' +import Utils from 'common/utils/utils' +import ProjectUsage from 'components/ProjectUsage' +import EditHealthProvider from 'components/EditHealthProvider' +import FeatureExport from 'components/import-export/FeatureExport' +import { GeneralTab } from './tabs/general-tab' +import { SDKSettingsTab } from './tabs/SDKSettingsTab' +import { PermissionsTab } from './tabs/PermissionsTab' +import { CustomFieldsTab } from './tabs/CustomFieldsTab' +import { ImportTab } from './tabs/ImportTab' + +type ProjectSettingsTab = { + component: ReactNode + isVisible: boolean + key: string + label: ReactNode + labelString?: string +} + +const ProjectSettingsPage = () => { + const { environmentId, projectId } = useRouteContext() + const { + data: project, + error, + isLoading, + isUninitialized, + } = useGetProjectQuery({ id: String(projectId) }, { skip: !projectId }) + + useEffect(() => { + API.trackPage(Constants.pages.PROJECT_SETTINGS) + }, []) + + const isInitialLoading = isUninitialized || (isLoading && !project) + + if (isInitialLoading) { + return ( +
+ +
+ +
+
+ ) + } + + if (error || !project || !projectId || !project?.organisation) { + return ( +
+ +
+ Failed to load project settings. Please try again. +
+
+ ) + } + + // Derive data from project after all early returns + const hasEnvironments = !!project.environments?.length + const hasFeatureHealth = Utils.getFlagsmithHasFeature('feature_health') + const organisationId = project.organisation + + const tabs: ProjectSettingsTab[] = [ + { + component: , + isVisible: true, + key: 'general', + label: 'General', + }, + { + component: , + isVisible: true, + key: 'js-sdk-settings', + label: 'SDK Settings', + }, + { + component: , + isVisible: true, + key: 'usage', + label: 'Usage', + }, + { + component: ( + + ), + isVisible: hasFeatureHealth, + key: 'feature-health-settings', + label: Feature Health, + labelString: 'Feature Health', + }, + { + component: ( + + ), + isVisible: true, + key: 'permissions', + label: 'Permissions', + }, + { + component: , + isVisible: true, + key: 'custom-fields', + label: 'Custom Fields', + }, + { + component: ( + + ), + isVisible: hasEnvironments, + key: 'js-import-page', + label: 'Import', + }, + { + component: , + isVisible: hasEnvironments, + key: 'export', + label: 'Export', + }, + ].filter(({ isVisible }) => isVisible) + + return ( +
+ + + {tabs.map(({ component, key, label, labelString }) => ( + + {component} + + ))} + +
+ ) +} + +export default ProjectSettingsPage diff --git a/frontend/web/components/pages/project-settings/hooks/index.ts b/frontend/web/components/pages/project-settings/hooks/index.ts new file mode 100644 index 000000000000..dc1a937daf3e --- /dev/null +++ b/frontend/web/components/pages/project-settings/hooks/index.ts @@ -0,0 +1 @@ +export { useUpdateProjectWithToast } from './useUpdateProjectWithToast' diff --git a/frontend/web/components/pages/project-settings/hooks/useUpdateProjectWithToast.ts b/frontend/web/components/pages/project-settings/hooks/useUpdateProjectWithToast.ts new file mode 100644 index 000000000000..5a33c27eda6c --- /dev/null +++ b/frontend/web/components/pages/project-settings/hooks/useUpdateProjectWithToast.ts @@ -0,0 +1,42 @@ +import { useCallback } from 'react' +import { useUpdateProjectMutation } from 'common/services/useProject' +import { UpdateProjectBody } from 'common/types/requests' + +type UpdateProjectOptions = { + successMessage?: string + errorMessage?: string + onError?: (error: unknown) => void +} + +export const useUpdateProjectWithToast = () => { + const [updateProject, state] = useUpdateProjectMutation() + + const updateWithToast = useCallback( + async ( + body: UpdateProjectBody, + projectId: string | number, + options?: UpdateProjectOptions, + ) => { + try { + await updateProject({ + body, + id: String(projectId), + }).unwrap() + toast(options?.successMessage || 'Project Saved') + // Refresh OrganisationStore to update navbar and other components + // that rely on the legacy store + AppActions.refreshOrganisation() + } catch (error) { + toast( + options?.errorMessage || + 'Failed to update setting. Please try again.', + 'danger', + ) + options?.onError?.(error) + } + }, + [updateProject], + ) + + return [updateWithToast, state] as const +} diff --git a/frontend/web/components/pages/project-settings/index.ts b/frontend/web/components/pages/project-settings/index.ts new file mode 100644 index 000000000000..6f790a7311b6 --- /dev/null +++ b/frontend/web/components/pages/project-settings/index.ts @@ -0,0 +1 @@ +export { default } from './ProjectSettingsPage' diff --git a/frontend/web/components/pages/project-settings/tabs/CustomFieldsTab.tsx b/frontend/web/components/pages/project-settings/tabs/CustomFieldsTab.tsx new file mode 100644 index 000000000000..864d87769c21 --- /dev/null +++ b/frontend/web/components/pages/project-settings/tabs/CustomFieldsTab.tsx @@ -0,0 +1,38 @@ +import InfoMessage from 'components/InfoMessage' +import WarningMessage from 'components/WarningMessage' +import React from 'react' + +type CustomFieldsTabProps = { + organisationId: number +} + +export const CustomFieldsTab = ({ organisationId }: CustomFieldsTabProps) => { + if (!organisationId) { + return ( +
+ Unable to load organisation settings +
+ ) + } + + return ( +
+
Custom Fields
+ + + Custom fields have been moved to{' '} + + Organisation Settings + + . + + } + /> +
+ ) +} diff --git a/frontend/web/components/pages/project-settings/tabs/ImportTab.tsx b/frontend/web/components/pages/project-settings/tabs/ImportTab.tsx new file mode 100644 index 000000000000..41ebf7310a5f --- /dev/null +++ b/frontend/web/components/pages/project-settings/tabs/ImportTab.tsx @@ -0,0 +1,33 @@ +import ImportPage from 'components/import-export/ImportPage' +import InfoMessage from 'components/InfoMessage' +import React from 'react' + +type ImportTabProps = { + projectName: string + projectId: string + environmentId?: string +} + +export const ImportTab = ({ + environmentId, + projectId, + projectName, +}: ImportTabProps) => { + if (!environmentId) { + return ( +
+ + Please select an environment to import features + +
+ ) + } + + return ( + + ) +} diff --git a/frontend/web/components/pages/project-settings/tabs/PermissionsTab.tsx b/frontend/web/components/pages/project-settings/tabs/PermissionsTab.tsx new file mode 100644 index 000000000000..fa9d7330a164 --- /dev/null +++ b/frontend/web/components/pages/project-settings/tabs/PermissionsTab.tsx @@ -0,0 +1,60 @@ +import EditPermissions from 'components/EditPermissions' +import InfoMessage from 'components/InfoMessage' +import React from 'react' +import { useGetRolesQuery } from 'common/services/useRole' +import { useGetProjectPermissionsQuery } from 'common/services/useProject' + +type PermissionsTabProps = { + projectId: number + organisationId: number +} + +export const PermissionsTab = ({ + organisationId, + projectId, +}: PermissionsTabProps) => { + const { + data: rolesData, + error: rolesError, + isLoading: rolesLoading, + } = useGetRolesQuery({ organisation_id: organisationId }) + + const { + data: permissionsData, + error: permissionsError, + isLoading: permissionsLoading, + refetch: refetchPermissions, + } = useGetProjectPermissionsQuery({ projectId: String(projectId) }) + + const handleSaveUser = () => { + refetchPermissions() + } + + if (rolesLoading || permissionsLoading) { + return ( +
+ +
+ ) + } + + if (rolesError || permissionsError) { + return ( +
+ Error loading permissions data +
+ ) + } + + return ( + + ) +} diff --git a/frontend/web/components/pages/project-settings/tabs/SDKSettingsTab.tsx b/frontend/web/components/pages/project-settings/tabs/SDKSettingsTab.tsx new file mode 100644 index 000000000000..6f68395ca2f7 --- /dev/null +++ b/frontend/web/components/pages/project-settings/tabs/SDKSettingsTab.tsx @@ -0,0 +1,75 @@ +import React from 'react' +import Setting from 'components/Setting' +import ConfirmHideFlags from 'components/modals/ConfirmHideFlags' +import { Project } from 'common/types/responses' +import { useUpdateProjectWithToast } from 'components/pages/project-settings/hooks' + +type SDKSettingsTabProps = { + project: Project +} + +export const SDKSettingsTab = ({ project }: SDKSettingsTabProps) => { + const [updateProjectWithToast, { isLoading: isSaving }] = + useUpdateProjectWithToast() + + const handleRealtimeToggle = async () => { + await updateProjectWithToast( + { + enable_realtime_updates: !project.enable_realtime_updates, + name: project.name, + }, + project.id, + { + errorMessage: 'Failed to update realtime settings. Please try again.', + }, + ) + } + + const handleHideDisabledFlagsToggle = async () => { + await updateProjectWithToast( + { + hide_disabled_flags: !project.hide_disabled_flags, + name: project.name, + }, + project.id, + { + errorMessage: 'Failed to update hide disabled flags. Please try again.', + }, + ) + } + + const toggleHideDisabledFlags = () => { + openModal( + 'Hide Disabled Flags', + , + 'p-0 modal-sm', + ) + } + + return ( +
+
+ +
+
+ +
+
+ ) +} diff --git a/frontend/web/components/pages/project-settings/tabs/general-tab/index.tsx b/frontend/web/components/pages/project-settings/tabs/general-tab/index.tsx new file mode 100644 index 000000000000..394c82b47be4 --- /dev/null +++ b/frontend/web/components/pages/project-settings/tabs/general-tab/index.tsx @@ -0,0 +1,34 @@ +import React from 'react' +import JSONReference from 'components/JSONReference' +import SettingTitle from 'components/SettingTitle' +import { Project } from 'common/types/responses' +import { ProjectInformation } from './sections/ProjectInformation' +import { AdditionalSettings } from './sections/additional-settings' +import { EdgeAPIMigration } from './sections/EdgeAPIMigration' +import { DeleteProject } from './sections/DeleteProject' + +type GeneralTabProps = { + project: Project + environmentId?: string +} + +export const GeneralTab = ({ project }: GeneralTabProps) => { + return ( +
+ + + Project Information + + + + + Additional Settings + + + + + + +
+ ) +} diff --git a/frontend/web/components/pages/project-settings/tabs/general-tab/sections/DeleteProject.tsx b/frontend/web/components/pages/project-settings/tabs/general-tab/sections/DeleteProject.tsx new file mode 100644 index 000000000000..98f9eeee2480 --- /dev/null +++ b/frontend/web/components/pages/project-settings/tabs/general-tab/sections/DeleteProject.tsx @@ -0,0 +1,48 @@ +import React from 'react' +import { useHistory } from 'react-router-dom' +import ConfirmRemoveProject from 'components/modals/ConfirmRemoveProject' +import { Project } from 'common/types/responses' +import { useDeleteProjectMutation } from 'common/services/useProject' +import SettingTitle from 'components/SettingTitle' +import Utils from 'common/utils/utils' + +type DeleteProjectProps = { + project: Project +} + +export const DeleteProject = ({ project }: DeleteProjectProps) => { + const history = useHistory() + const [deleteProject, { isLoading }] = useDeleteProjectMutation() + + const handleDelete = () => { + history.replace(Utils.getOrganisationHomePage()) + } + + const confirmRemove = () => { + openModal( + 'Delete Project', + { + await deleteProject({ id: String(project.id) }) + handleDelete() + }} + />, + 'p-0', + ) + } + + return ( + + Delete Project + +

+ This project will be permanently deleted. +

+ +
+
+ ) +} diff --git a/frontend/web/components/pages/project-settings/tabs/general-tab/sections/EdgeAPIMigration.tsx b/frontend/web/components/pages/project-settings/tabs/general-tab/sections/EdgeAPIMigration.tsx new file mode 100644 index 000000000000..6fe288573a45 --- /dev/null +++ b/frontend/web/components/pages/project-settings/tabs/general-tab/sections/EdgeAPIMigration.tsx @@ -0,0 +1,61 @@ +import React from 'react' +import Icon from 'components/Icon' +import { Project } from 'common/types/responses' +import { useMigrateProjectMutation } from 'common/services/useProject' +import Utils from 'common/utils/utils' + +type EdgeAPIMigrationProps = { + project: Project +} + +export const EdgeAPIMigration = ({ project }: EdgeAPIMigrationProps) => { + const [migrateProject, { isLoading: isMigrating }] = + useMigrateProjectMutation() + + const handleMigrate = () => { + openConfirm({ + body: 'This will migrate your project to the Global Edge API.', + onYes: async () => { + await migrateProject({ id: String(project.id) }) + }, + title: 'Migrate to Global Edge API', + }) + } + + if (Utils.getIsEdge() || !Utils.isSaas()) { + return null + } + + return ( + + +
Global Edge API Opt in
+ +
+

+ Migrate your project onto our Global Edge API. Existing Core API + endpoints will continue to work whilst the migration takes place. Find + out more{' '} + + here + + . +

+
+ ) +} diff --git a/frontend/web/components/pages/project-settings/tabs/general-tab/sections/ProjectInformation.tsx b/frontend/web/components/pages/project-settings/tabs/general-tab/sections/ProjectInformation.tsx new file mode 100644 index 000000000000..4b89559032de --- /dev/null +++ b/frontend/web/components/pages/project-settings/tabs/general-tab/sections/ProjectInformation.tsx @@ -0,0 +1,129 @@ +import React, { FormEvent, useState } from 'react' +import classNames from 'classnames' +import Icon from 'components/Icon' +import Tooltip from 'components/Tooltip' +import PlanBasedBanner from 'components/PlanBasedAccess' +import Utils from 'common/utils/utils' +import { Project } from 'common/types/responses' +import { useUpdateProjectWithToast } from 'components/pages/project-settings/hooks' + +type ProjectInformationProps = { + project: Project +} + +export const ProjectInformation = ({ project }: ProjectInformationProps) => { + const [updateProjectWithToast, { isLoading: isSaving }] = + useUpdateProjectWithToast() + const [name, setName] = useState(project.name) + const [staleFlagsLimitDays, setStaleFlagsLimitDays] = useState( + project.stale_flags_limit_days, + ) + + const hasStaleFlagsPermission = Utils.getPlansPermission('STALE_FLAGS') + const hasVersioning = Utils.getFlagsmithHasFeature('feature_versioning') + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault() + if (!name || isSaving) return + + await updateProjectWithToast( + { + name, + stale_flags_limit_days: staleFlagsLimitDays, + }, + project.id, + { + errorMessage: 'Failed to save project. Please try again.', + successMessage: 'Project Saved', + }, + ) + } + + return ( + +
+ + + ) => + setName(Utils.safeParseEventValue(e)) + } + isValid={!!name && name.length > 0} + type='text' + placeholder='My Project Name' + /> + + + + {hasVersioning && ( +
+
+ + + +
+ } + > + {`If no changes have been made to a feature in any environment within this threshold the feature will be tagged as stale. You will need to enable feature versioning in your environments for stale features to be detected.`} + + +
+
+ +
+ ) => + setStaleFlagsLimitDays( + parseInt(Utils.safeParseEventValue(e)) || 0, + ) + } + isValid={!!staleFlagsLimitDays} + type='number' + placeholder='Number of Days' + /> +
+ +
+ {!hasStaleFlagsPermission && ( + + )} + + )} + +
+ +
+ +
+ ) +} diff --git a/frontend/web/components/pages/project-settings/tabs/general-tab/sections/additional-settings/CaseSensitivitySetting.tsx b/frontend/web/components/pages/project-settings/tabs/general-tab/sections/additional-settings/CaseSensitivitySetting.tsx new file mode 100644 index 000000000000..6159a49cd6b2 --- /dev/null +++ b/frontend/web/components/pages/project-settings/tabs/general-tab/sections/additional-settings/CaseSensitivitySetting.tsx @@ -0,0 +1,37 @@ +import React from 'react' +import Setting from 'components/Setting' +import { Project } from 'common/types/responses' +import { useUpdateProjectWithToast } from 'components/pages/project-settings/hooks' + +type CaseSensitivitySettingProps = { + project: Project +} + +export const CaseSensitivitySetting = ({ + project, +}: CaseSensitivitySettingProps) => { + const [updateProjectWithToast, { isLoading: isSaving }] = + useUpdateProjectWithToast() + + const handleToggle = async () => { + await updateProjectWithToast( + { + name: project.name, + only_allow_lower_case_feature_names: + !project.only_allow_lower_case_feature_names, + }, + project.id, + ) + } + + return ( + + ) +} diff --git a/frontend/web/components/pages/project-settings/tabs/general-tab/sections/additional-settings/ChangeRequestsApprovalsSetting.tsx b/frontend/web/components/pages/project-settings/tabs/general-tab/sections/additional-settings/ChangeRequestsApprovalsSetting.tsx new file mode 100644 index 000000000000..2730c1ded43a --- /dev/null +++ b/frontend/web/components/pages/project-settings/tabs/general-tab/sections/additional-settings/ChangeRequestsApprovalsSetting.tsx @@ -0,0 +1,59 @@ +import React, { useState } from 'react' +import ChangeRequestsSetting from 'components/ChangeRequestsSetting' +import Utils from 'common/utils/utils' +import { Project } from 'common/types/responses' +import { useUpdateProjectWithToast } from 'components/pages/project-settings/hooks' + +type ChangeRequestsApprovalsSettingProps = { + project: Project +} + +export const ChangeRequestsApprovalsSetting = ({ + project, +}: ChangeRequestsApprovalsSettingProps) => { + const [updateProjectWithToast, { isLoading: isSaving }] = + useUpdateProjectWithToast() + const [minimumChangeRequestApprovals, setMinimumChangeRequestApprovals] = + useState(project.minimum_change_request_approvals ?? null) + + const changeRequestsFeature = Utils.getFlagsmithHasFeature( + 'segment_change_requests', + ) + + const saveChangeRequests = async (value: number | null) => { + if (isSaving) return + + await updateProjectWithToast( + { + minimum_change_request_approvals: value, + name: project.name, + }, + project.id, + { + errorMessage: 'Failed to save project. Please try again.', + successMessage: 'Project Saved', + }, + ) + } + + const handleChangeRequestsToggle = (value: number | null) => { + setMinimumChangeRequestApprovals(value) + saveChangeRequests(value) + } + + if (!changeRequestsFeature) { + return null + } + + return ( + saveChangeRequests(minimumChangeRequestApprovals)} + onChange={setMinimumChangeRequestApprovals} + isLoading={isSaving} + /> + ) +} diff --git a/frontend/web/components/pages/project-settings/tabs/general-tab/sections/additional-settings/FeatureNameValidation.tsx b/frontend/web/components/pages/project-settings/tabs/general-tab/sections/additional-settings/FeatureNameValidation.tsx new file mode 100644 index 000000000000..bedd7fbd96ac --- /dev/null +++ b/frontend/web/components/pages/project-settings/tabs/general-tab/sections/additional-settings/FeatureNameValidation.tsx @@ -0,0 +1,166 @@ +import React, { useMemo, useRef, useState } from 'react' +import Setting from 'components/Setting' +import RegexTester from 'components/RegexTester' +import Utils from 'common/utils/utils' +import { Project } from 'common/types/responses' +import { useUpdateProjectWithToast } from 'components/pages/project-settings/hooks' + +type FeatureNameValidationProps = { + project: Project +} + +export const FeatureNameValidation = ({ + project, +}: FeatureNameValidationProps) => { + const [updateProjectWithToast, { isLoading: isSaving }] = + useUpdateProjectWithToast() + const [featureNameRegex, setFeatureNameRegex] = useState( + project.feature_name_regex || null, + ) + + const inputRef = useRef(null) + const featureRegexEnabled = typeof featureNameRegex === 'string' + + const handleToggle = async () => { + if (featureNameRegex) { + setFeatureNameRegex(null) + await updateProjectWithToast( + { + feature_name_regex: null, + name: project.name, + }, + project.id, + { + errorMessage: + 'Failed to update feature validation. Please try again.', + }, + ) + } else { + setFeatureNameRegex('^.+$') + } + } + + const handleSave = async () => { + await updateProjectWithToast( + { + feature_name_regex: featureNameRegex, + name: project.name, + }, + project.id, + { + errorMessage: 'Failed to save regex. Please try again.', + successMessage: 'Project Saved', + }, + ) + } + + const regexValid = useMemo(() => { + if (!featureNameRegex) return true + try { + new RegExp(featureNameRegex) + return true + } catch (e) { + return false + } + }, [featureNameRegex]) + + const forceSelectionRange = (e: React.MouseEvent | React.KeyboardEvent) => { + const input = e.currentTarget as HTMLInputElement + setTimeout(() => { + const range = input.selectionStart || 0 + if (range === input.value.length) { + input.setSelectionRange(input.value.length - 1, input.value.length - 1) + } + }, 0) + } + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + if (regexValid) { + handleSave() + } + } + + const handleRegexChange = (e: React.ChangeEvent) => { + let newRegex = Utils.safeParseEventValue(e).replace('$', '') + if (!newRegex.startsWith('^')) { + newRegex = `^${newRegex}` + } + if (!newRegex.endsWith('$')) { + newRegex = `${newRegex}$` + } + setFeatureNameRegex(newRegex) + } + + const openRegexTester = () => { + openModal( + RegEx Tester, + setFeatureNameRegex(newRegex)} + />, + ) + } + + return ( + + +
+ + + + + + + + + + } + /> +
+
+ ) +} diff --git a/frontend/web/components/pages/project-settings/tabs/general-tab/sections/additional-settings/PreventFlagDefaultsSetting.tsx b/frontend/web/components/pages/project-settings/tabs/general-tab/sections/additional-settings/PreventFlagDefaultsSetting.tsx new file mode 100644 index 000000000000..b444c836ab4e --- /dev/null +++ b/frontend/web/components/pages/project-settings/tabs/general-tab/sections/additional-settings/PreventFlagDefaultsSetting.tsx @@ -0,0 +1,40 @@ +import React from 'react' +import Setting from 'components/Setting' +import { Project } from 'common/types/responses' +import { useUpdateProjectWithToast } from 'components/pages/project-settings/hooks' + +type PreventFlagDefaultsSettingProps = { + project: Project +} + +export const PreventFlagDefaultsSetting = ({ + project, +}: PreventFlagDefaultsSettingProps) => { + const [updateProjectWithToast, { isLoading: isSaving }] = + useUpdateProjectWithToast() + + const handleToggle = async () => { + await updateProjectWithToast( + { + name: project.name, + prevent_flag_defaults: !project.prevent_flag_defaults, + }, + project.id, + ) + } + + return ( + + ) +} diff --git a/frontend/web/components/pages/project-settings/tabs/general-tab/sections/additional-settings/index.tsx b/frontend/web/components/pages/project-settings/tabs/general-tab/sections/additional-settings/index.tsx new file mode 100644 index 000000000000..8b8bf15865f0 --- /dev/null +++ b/frontend/web/components/pages/project-settings/tabs/general-tab/sections/additional-settings/index.tsx @@ -0,0 +1,24 @@ +import React from 'react' +import { Project } from 'common/types/responses' +import { ChangeRequestsApprovalsSetting } from './ChangeRequestsApprovalsSetting' +import { PreventFlagDefaultsSetting } from './PreventFlagDefaultsSetting' +import { CaseSensitivitySetting } from './CaseSensitivitySetting' +import { FeatureNameValidation } from './FeatureNameValidation' + +type AdditionalSettingsProps = { + project: Project +} + +export const AdditionalSettings = ({ project }: AdditionalSettingsProps) => { + return ( +
+ + + + + + + +
+ ) +} diff --git a/frontend/web/routes.js b/frontend/web/routes.js index 05ab7c8fa533..2753859e8f40 100644 --- a/frontend/web/routes.js +++ b/frontend/web/routes.js @@ -15,7 +15,7 @@ import SegmentsPage from './components/pages/SegmentsPage' import OrganisationSettingsPage from './components/pages/OrganisationSettingsPage' import AccountSettingsPage from './components/pages/AccountSettingsPage' import NotFoundErrorPage from './components/pages/NotFoundErrorPage' -import ProjectSettingsPage from './components/pages/ProjectSettingsPage' +import ProjectSettingsPage from './components/pages/project-settings' import PasswordResetPage from './components/pages/PasswordResetPage' import EnvironmentSettingsPage from './components/pages/EnvironmentSettingsPage' import InvitePage from './components/pages/InvitePage'