diff --git a/frontend/common/constants.ts b/frontend/common/constants.ts index 8a8c91ba99f9..85dde7c4edc2 100644 --- a/frontend/common/constants.ts +++ b/frontend/common/constants.ts @@ -1,9 +1,17 @@ import { OAuthType } from './types/requests' import { SegmentCondition } from './types/responses' import Utils from './utils/utils' - import Project from './project' import { integrationCategories } from 'components/pages/IntegrationsPage' +import { + EnvironmentPermission, + EnvironmentPermissionDescriptions, + OrganisationPermission, + OrganisationPermissionDescriptions, + ProjectPermission, + ProjectPermissionDescriptions, +} from './types/permissions.types' + const keywords = { FEATURE_FUNCTION: 'myCoolFeature', FEATURE_NAME: 'my_cool_feature', @@ -252,8 +260,13 @@ const Constants = { value: '', } as SegmentCondition, defaultTagColor: '#3d4db6', - environmentPermissions: (perm: string) => - `To manage this feature you need the ${perm} permission for this environment.
Please contact a member of this environment who has administrator privileges.`, + environmentPermissions: (perm: EnvironmentPermission) => { + const description = + perm === 'ADMIN' + ? 'Administrator' + : EnvironmentPermissionDescriptions[perm] + return `To manage this feature you need the ${description} permission for this environment.
Please contact a member of this environment who has administrator privileges.` + }, events: { 'ACCEPT_INVITE': (org: any) => ({ 'category': 'Invite', @@ -623,8 +636,13 @@ const Constants = { modals: { 'PAYMENT': 'Payment Modal', }, - organisationPermissions: (perm: string) => - `To manage this feature you need the ${perm} permission for this organisastion.
Please contact a member of this organisation who has administrator privileges.`, + organisationPermissions: (perm: OrganisationPermission) => { + const description = + perm === 'ADMIN' + ? 'Administrator' + : OrganisationPermissionDescriptions[perm] + return `To manage this feature you need the ${description} permission for this organisation.
Please contact a member of this organisation who has administrator privileges.` + }, pages: { 'ACCOUNT': 'Account Page', 'AUDIT_LOG': 'Audit Log Page', @@ -656,8 +674,11 @@ const Constants = { '#FFBE71', '#F57C78', ], - projectPermissions: (perm: string) => - `To use this feature you need the ${perm} permission for this project.
Please contact a member of this project who has administrator privileges.`, + projectPermissions: (perm: ProjectPermission) => { + const description = + perm === 'ADMIN' ? 'Administrator' : ProjectPermissionDescriptions[perm] + return `To use this feature you need the ${description} permission for this project.
Please contact a member of this project who has administrator privileges.` + }, resourceTypes: { GITHUB_ISSUE: { id: 1, diff --git a/frontend/common/providers/Permission.tsx b/frontend/common/providers/Permission.tsx index e5f83db8b9ab..6e700d251658 100644 --- a/frontend/common/providers/Permission.tsx +++ b/frontend/common/providers/Permission.tsx @@ -1,29 +1,93 @@ import React, { FC, ReactNode, useMemo } from 'react' import { useGetPermissionQuery } from 'common/services/usePermission' -import { PermissionLevel } from 'common/types/requests' import AccountStore from 'common/stores/account-store' import intersection from 'lodash/intersection' import { cloneDeep } from 'lodash' +import Utils from 'common/utils/utils' +import Constants from 'common/constants' +import { + ADMIN_PERMISSION, + EnvironmentPermission, + OrganisationPermission, + ProjectPermission, +} from 'common/types/permissions.types' -type PermissionType = { - id: any - permission: string +/** + * Base props shared across all permission levels + */ +type BasePermissionProps = { + id: number | string tags?: number[] - level: PermissionLevel - children: (data: { permission: boolean; isLoading: boolean }) => ReactNode + children: + | ReactNode + | ((data: { permission: boolean; isLoading: boolean }) => ReactNode) + fallback?: ReactNode + permissionName?: string + showTooltip?: boolean } +/** + * Discriminated union types for each permission level + * This means we can detect a mismatch between level and permission + */ +type OrganisationLevelProps = BasePermissionProps & { + level: 'organisation' + permission: OrganisationPermission +} + +type ProjectLevelProps = BasePermissionProps & { + level: 'project' + permission: ProjectPermission +} + +type EnvironmentLevelProps = BasePermissionProps & { + level: 'environment' + permission: EnvironmentPermission +} + +type PermissionType = + | OrganisationLevelProps + | ProjectLevelProps + | EnvironmentLevelProps + +type UseHasPermissionParams = { + id: number | string + level: 'organisation' | 'project' | 'environment' + permission: OrganisationPermission | ProjectPermission | EnvironmentPermission + tags?: number[] +} + +/** + * Hook to check if the current user has a specific permission + * + * Fetches permission data and checks if the user has the requested permission. + * Supports tag-based permissions where additional permissions can be granted + * based on tag intersection. + * + * @param {Object} params - The permission check parameters + * @param {number | string} params.id - The resource ID to check permissions for + * @param {PermissionLevel} params.level - The permission level to check at + * @param {string} params.permission - The permission key to check + * @param {number[]} [params.tags] - Optional tag IDs for tag-based permission checking + * @returns {Object} Object containing permission status and loading state + * @returns {boolean} returns.isLoading - Whether the permission data is still loading + * @returns {boolean} returns.isSuccess - Whether the permission data was fetched successfully + * @returns {boolean} returns.permission - Whether the user has the requested permission + */ export const useHasPermission = ({ id, level, permission, tags, -}: Omit) => { +}: UseHasPermissionParams) => { const { data: permissionsData, isLoading, isSuccess, - } = useGetPermissionQuery({ id: `${id}`, level }, { skip: !id || !level }) + } = useGetPermissionQuery( + { id: id as number, level }, + { skip: !id || !level }, + ) const data = useMemo(() => { if (!tags?.length || !permissionsData?.tag_based_permissions) return permissionsData @@ -45,11 +109,68 @@ export const useHasPermission = ({ } } +/** + * Permission component for conditional rendering based on user permissions + * + * This component checks if the current user has a specific permission and conditionally + * renders its children. It supports multiple rendering patterns: + * + * @example + * // Basic usage with simple children + * + * + * + * + * @example + * // Using render function to access permission state + * + * {({ permission, isLoading }) => ( + * + * )} + * + * + * @example + * // With tooltip on permission denial + * + * + * + * + * @example + * // With fallback content + * You don't have permission to delete features} + * > + * + * + * + * @example + * // With tag-based permissions + * + * + * + */ const Permission: FC = ({ children, + fallback, id, level, permission, + permissionName, + showTooltip = false, tags, }) => { const { isLoading, permission: hasPermission } = useHasPermission({ @@ -58,14 +179,63 @@ const Permission: FC = ({ permission, tags, }) - return ( - <> - {children({ - isLoading, - permission: hasPermission || AccountStore.isAdmin(), - }) || null} - - ) + + const finalPermission = hasPermission || AccountStore.isAdmin() + + const getPermissionDescription = (): string => { + if (permission === ADMIN_PERMISSION) { + switch (level) { + case 'environment': + return Constants.environmentPermissions(ADMIN_PERMISSION) + case 'project': + return Constants.projectPermissions(ADMIN_PERMISSION) + default: + return Constants.organisationPermissions(ADMIN_PERMISSION) + } + } + + switch (level) { + case 'environment': + return Constants.environmentPermissions( + permission as EnvironmentPermission, + ) + case 'project': + return Constants.projectPermissions(permission as ProjectPermission) + default: + return Constants.organisationPermissions( + permission as OrganisationPermission, + ) + } + } + + const tooltipMessage = permissionName || getPermissionDescription() + + if (typeof children === 'function') { + const renderedChildren = children({ + isLoading, + permission: finalPermission, + }) + + if (finalPermission || !showTooltip) { + return <>{renderedChildren || null} + } + + return Utils.renderWithPermission( + finalPermission, + tooltipMessage, + renderedChildren, + ) + } + + if (finalPermission) { + return <>{children} + } + + if (showTooltip) { + return Utils.renderWithPermission(finalPermission, tooltipMessage, children) + } + + return <>{fallback || null} } export default Permission diff --git a/frontend/common/providers/withSegmentOverrides.js b/frontend/common/providers/withSegmentOverrides.js index a958e8a51e70..b3fbbbe95e62 100644 --- a/frontend/common/providers/withSegmentOverrides.js +++ b/frontend/common/providers/withSegmentOverrides.js @@ -1,6 +1,7 @@ import data from 'common/data/base/_data' import ProjectStore from 'common/stores/project-store' import FeatureListStore from 'common/stores/feature-list-store' +import { mergeChangeSets } from 'common/services/useChangeRequest' export default (WrappedComponent) => { class HOC extends React.Component { @@ -28,6 +29,7 @@ export default (WrappedComponent) => { getOverrides = () => { if (this.props.projectFlag) { //todo: migrate to useSegmentFeatureState + const projectId = this.props.projectFlag.project Promise.all([ data.get( `${ @@ -43,7 +45,12 @@ export default (WrappedComponent) => { this.props.environmentId, )}&feature=${this.props.projectFlag.id}`, ), - ]).then(([res, res2]) => { + this.props.changeRequest?.change_sets + ? data.get( + `${Project.api}projects/${projectId}/segments/?page_size=1000`, + ) + : Promise.resolve({ results: [] }), + ]).then(([res, res2, segmentsRes]) => { const results = res.results const featureStates = res2.results const environmentOverride = res2.results.find( @@ -72,18 +79,115 @@ export default (WrappedComponent) => { } } }) - const resResults = res.results || [] - const segmentOverrides = results - .concat( + + let segmentOverrides + if (this.props.changeRequest?.change_sets) { + // Add changesets to existing segment overrides + const mergedFeatureStates = mergeChangeSets( + this.props.changeRequest.change_sets, + featureStates, + this.props.changeRequest.conflicts, + ) + + // Get segment IDs marked for deletion + const segmentIdsToDelete = + this.props.changeRequest.change_sets?.flatMap( + (changeSet) => changeSet.segment_ids_to_delete_overrides || [], + ) || [] + + segmentOverrides = results.map((currentSegmentOverride) => { + const changedFeatureState = mergedFeatureStates.find( + (featureState) => + featureState.feature_segment?.segment === + currentSegmentOverride.segment, + ) + + // Any segment_ids_to_delete_overrides should be marked as toRemove + const toRemove = segmentIdsToDelete.includes( + currentSegmentOverride.segment, + ) + + if (changedFeatureState) { + return { + ...currentSegmentOverride, + ...changedFeatureState, + id: changedFeatureState.id || currentSegmentOverride.id, + is_feature_specific: + changedFeatureState.feature_segment?.is_feature_specific, + multivariate_options: + changedFeatureState.multivariate_feature_state_values || [], + priority: changedFeatureState.feature_segment?.priority || 0, + segment: changedFeatureState.feature_segment?.segment, + segment_name: + changedFeatureState.feature_segment?.segment_name || + currentSegmentOverride.segment_name, + toRemove, + uuid: + changedFeatureState.feature_segment?.uuid || + currentSegmentOverride.uuid, + value: Utils.featureStateToValue( + changedFeatureState.feature_state_value, + ), + } + } + + return toRemove + ? { ...currentSegmentOverride, toRemove } + : currentSegmentOverride + }) + + // Add any new segment overrides from the changesets + mergedFeatureStates + .filter( + (featureState) => + !!featureState.feature_segment?.segment && + !results.find( + (currentOverride) => + currentOverride.segment === + featureState.feature_segment.segment, + ), + ) + .forEach((newFeatureState) => { + // Look up segment metadata from segments API to get segment name + const segmentMetadata = segmentsRes.results?.find( + (segment) => + segment.id === newFeatureState.feature_segment?.segment, + ) + + segmentOverrides.push({ + enabled: newFeatureState.enabled, + environment: newFeatureState.environment, + feature: newFeatureState.feature, + id: newFeatureState.id, + is_feature_specific: + newFeatureState.feature_segment?.is_feature_specific, + multivariate_options: + newFeatureState.multivariate_feature_state_values || [], + priority: newFeatureState.feature_segment?.priority || 0, + segment: newFeatureState.feature_segment?.segment, + segment_name: + newFeatureState.feature_segment?.segment_name || + segmentMetadata?.name || + 'Unknown Segment', + uuid: newFeatureState.feature_segment?.uuid, + value: Utils.featureStateToValue( + newFeatureState.feature_state_value, + ), + }) + }) + } else { + segmentOverrides = results.concat( (this.props.newSegmentOverrides || []).map((v, i) => ({ ...v, })), ) - .map((v, i) => ({ - ...v, - originalPriority: i, - priority: i, - })) + } + segmentOverrides = segmentOverrides.map((v, i) => ({ + ...v, + originalPriority: i, + priority: i, + })) + const originalSegmentOverrides = _.cloneDeep(segmentOverrides) this.setState({ environmentVariations: diff --git a/frontend/common/types/permissions.types.ts b/frontend/common/types/permissions.types.ts new file mode 100644 index 000000000000..67be34c6d90b --- /dev/null +++ b/frontend/common/types/permissions.types.ts @@ -0,0 +1,103 @@ +export const ADMIN_PERMISSION = 'ADMIN' as const +export const ADMIN_PERMISSION_DESCRIPTION = 'Administrator' as const + +// Organization Permissions +enum OrganisationPermissionEnum { + CREATE_PROJECT = 'CREATE_PROJECT', + MANAGE_USERS = 'MANAGE_USERS', + MANAGE_USER_GROUPS = 'MANAGE_USER_GROUPS', +} +export const OrganisationPermissionDescriptions = { + [OrganisationPermissionEnum.CREATE_PROJECT]: 'Create project', + [OrganisationPermissionEnum.MANAGE_USERS]: 'Manage users', + [OrganisationPermissionEnum.MANAGE_USER_GROUPS]: 'Manage user groups', +} as const + +export type OrganisationPermission = + | OrganisationPermissionEnum + | typeof ADMIN_PERMISSION +export const OrganisationPermission = OrganisationPermissionEnum + +export type OrganisationPermissionDescription = + | (typeof OrganisationPermissionDescriptions)[keyof typeof OrganisationPermissionDescriptions] + | typeof ADMIN_PERMISSION_DESCRIPTION + +// Project Permissions +enum ProjectPermissionEnum { + VIEW_PROJECT = 'VIEW_PROJECT', + CREATE_ENVIRONMENT = 'CREATE_ENVIRONMENT', + DELETE_FEATURE = 'DELETE_FEATURE', + CREATE_FEATURE = 'CREATE_FEATURE', + MANAGE_SEGMENTS = 'MANAGE_SEGMENTS', + VIEW_AUDIT_LOG = 'VIEW_AUDIT_LOG', + MANAGE_TAGS = 'MANAGE_TAGS', + MANAGE_PROJECT_LEVEL_CHANGE_REQUESTS = 'MANAGE_PROJECT_LEVEL_CHANGE_REQUESTS', + APPROVE_PROJECT_LEVEL_CHANGE_REQUESTS = 'APPROVE_PROJECT_LEVEL_CHANGE_REQUESTS', + CREATE_PROJECT_LEVEL_CHANGE_REQUESTS = 'CREATE_PROJECT_LEVEL_CHANGE_REQUESTS', +} +export const ProjectPermissionDescriptions = { + [ProjectPermissionEnum.VIEW_PROJECT]: 'View project', + [ProjectPermissionEnum.CREATE_ENVIRONMENT]: 'Create environment', + [ProjectPermissionEnum.DELETE_FEATURE]: 'Delete feature', + [ProjectPermissionEnum.CREATE_FEATURE]: 'Create feature', + [ProjectPermissionEnum.MANAGE_SEGMENTS]: 'Manage segments', + [ProjectPermissionEnum.VIEW_AUDIT_LOG]: 'View audit log', + [ProjectPermissionEnum.MANAGE_TAGS]: 'Manage tags', + [ProjectPermissionEnum.MANAGE_PROJECT_LEVEL_CHANGE_REQUESTS]: + 'Manage project level change requests', + [ProjectPermissionEnum.APPROVE_PROJECT_LEVEL_CHANGE_REQUESTS]: + 'Approve project level change requests', + [ProjectPermissionEnum.CREATE_PROJECT_LEVEL_CHANGE_REQUESTS]: + 'Create project level change requests', +} as const + +export type ProjectPermission = ProjectPermissionEnum | typeof ADMIN_PERMISSION +export const ProjectPermission = ProjectPermissionEnum + +export type ProjectPermissionDescription = + | (typeof ProjectPermissionDescriptions)[keyof typeof ProjectPermissionDescriptions] + | typeof ADMIN_PERMISSION_DESCRIPTION + +// Environment Permissions +enum EnvironmentPermissionEnum { + VIEW_ENVIRONMENT = 'VIEW_ENVIRONMENT', + UPDATE_FEATURE_STATE = 'UPDATE_FEATURE_STATE', + MANAGE_IDENTITIES = 'MANAGE_IDENTITIES', + CREATE_CHANGE_REQUEST = 'CREATE_CHANGE_REQUEST', + APPROVE_CHANGE_REQUEST = 'APPROVE_CHANGE_REQUEST', + VIEW_IDENTITIES = 'VIEW_IDENTITIES', + MANAGE_SEGMENT_OVERRIDES = 'MANAGE_SEGMENT_OVERRIDES', +} +export const EnvironmentPermissionDescriptions = { + [EnvironmentPermissionEnum.VIEW_ENVIRONMENT]: 'View environment', + [EnvironmentPermissionEnum.UPDATE_FEATURE_STATE]: 'Update feature state', + [EnvironmentPermissionEnum.MANAGE_IDENTITIES]: 'Manage identities', + [EnvironmentPermissionEnum.CREATE_CHANGE_REQUEST]: 'Create change request', + [EnvironmentPermissionEnum.APPROVE_CHANGE_REQUEST]: 'Approve change request', + [EnvironmentPermissionEnum.VIEW_IDENTITIES]: 'View identities', + [EnvironmentPermissionEnum.MANAGE_SEGMENT_OVERRIDES]: + 'Manage segment overrides', +} as const + +export type EnvironmentPermission = + | EnvironmentPermissionEnum + | typeof ADMIN_PERMISSION +export const EnvironmentPermission = EnvironmentPermissionEnum + +export type EnvironmentPermissionDescription = + | (typeof EnvironmentPermissionDescriptions)[keyof typeof EnvironmentPermissionDescriptions] + | typeof ADMIN_PERMISSION_DESCRIPTION + +export type Permission = + | OrganisationPermission + | ProjectPermission + | EnvironmentPermission + | typeof ADMIN_PERMISSION + +// Combined permission descriptions record +export const PermissionDescriptions = { + ...OrganisationPermissionDescriptions, + ...ProjectPermissionDescriptions, + ...EnvironmentPermissionDescriptions, + [ADMIN_PERMISSION]: ADMIN_PERMISSION_DESCRIPTION, +} as const diff --git a/frontend/common/utils/utils.tsx b/frontend/common/utils/utils.tsx index 25434274a790..70d12d5d5f52 100644 --- a/frontend/common/utils/utils.tsx +++ b/frontend/common/utils/utils.tsx @@ -9,11 +9,11 @@ import { MultivariateFeatureStateValue, MultivariateOption, Organisation, + PConfidence, Project as ProjectType, ProjectFlag, SegmentCondition, Tag, - PConfidence, UserPermissions, } from 'common/types/responses' import flagsmith from 'flagsmith' @@ -28,6 +28,15 @@ import { selectBuildVersion } from 'common/services/useBuildVersion' import { getStore } from 'common/store' import { TRACKED_UTMS, UtmsType } from 'common/types/utms' import { TimeUnit } from 'components/release-pipelines/constants' +import { + ADMIN_PERMISSION, + ADMIN_PERMISSION_DESCRIPTION, + EnvironmentPermission, + EnvironmentPermissionDescription, + EnvironmentPermissionDescriptions, + OrganisationPermission, + OrganisationPermissionDescriptions, +} from 'common/types/permissions.types' const semver = require('semver') @@ -218,15 +227,15 @@ const Utils = Object.assign({}, require('./base/_utils'), { }, getCreateProjectPermission(organisation: Organisation) { if (organisation?.restrict_project_create_to_admin) { - return 'ADMIN' + return ADMIN_PERMISSION } - return 'CREATE_PROJECT' + return OrganisationPermission.CREATE_PROJECT }, getCreateProjectPermissionDescription(organisation: Organisation) { if (organisation?.restrict_project_create_to_admin) { - return 'Administrator' + return ADMIN_PERMISSION_DESCRIPTION } - return 'Create Project' + return OrganisationPermissionDescriptions.CREATE_PROJECT }, getExistingWaitForTime: ( waitFor: string | undefined, @@ -376,22 +385,15 @@ const Utils = Object.assign({}, require('./base/_utils'), { }, getManageFeaturePermission(isChangeRequest: boolean) { if (isChangeRequest) { - return 'CREATE_CHANGE_REQUEST' + return EnvironmentPermission.CREATE_CHANGE_REQUEST } - return 'UPDATE_FEATURE_STATE' + return EnvironmentPermission.UPDATE_FEATURE_STATE }, getManageFeaturePermissionDescription(isChangeRequest: boolean) { if (isChangeRequest) { - return 'Create Change Request' + return EnvironmentPermissionDescriptions.CREATE_CHANGE_REQUEST } - return 'Update Feature State' - }, - - getManageUserPermission() { - return 'MANAGE_IDENTITIES' - }, - getManageUserPermissionDescription() { - return 'Manage Identities' + return EnvironmentPermissionDescriptions.UPDATE_FEATURE_STATE }, getNextPlan: (skipFree?: boolean) => { @@ -423,7 +425,12 @@ const Utils = Object.assign({}, require('./base/_utils'), { const organisationId = match?.params?.organisationId return organisationId ? parseInt(organisationId) : null }, - getOverridePermission: (level: 'identity' | 'segment') => { + getOverridePermission: ( + level: 'identity' | 'segment', + ): { + permission: EnvironmentPermission + permissionDescription: EnvironmentPermissionDescription + } => { switch (level) { case 'identity': return { @@ -433,8 +440,9 @@ const Utils = Object.assign({}, require('./base/_utils'), { } default: return { - permission: 'MANAGE_SEGMENT_OVERRIDES', - permissionDescription: 'Manage Segment Overrides', + permission: EnvironmentPermission.MANAGE_SEGMENT_OVERRIDES, + permissionDescription: + EnvironmentPermissionDescriptions.MANAGE_SEGMENT_OVERRIDES, } } }, @@ -622,9 +630,6 @@ const Utils = Object.assign({}, require('./base/_utils'), { return utms }, {} as UtmsType) }, - getViewIdentitiesPermission() { - return 'VIEW_IDENTITIES' - }, hasEntityPermission(key: string, entityPermissions: UserPermissions) { if (entityPermissions?.admin) return true diff --git a/frontend/package-lock.json b/frontend/package-lock.json index cbcc39af0b39..11b242e30ee6 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -130,6 +130,7 @@ "@types/classnames": "^2.3.1", "@types/color": "^3.0.3", "@types/dompurify": "^3.0.2", + "@types/rc-switch": "^1.9.5", "@types/react-router": "^5.1.20", "@types/react-router-dom": "^5.3.3", "@types/react-select": "^2.0.3", @@ -4966,6 +4967,16 @@ "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", "license": "MIT" }, + "node_modules/@types/rc-switch": { + "version": "1.9.5", + "resolved": "https://registry.npmjs.org/@types/rc-switch/-/rc-switch-1.9.5.tgz", + "integrity": "sha512-pah8pI9LwjppjzD2rAd2p9AdWxCoQzKYff0zCIHAiVpAxUI60U9vmNVbosunpEmOvbzaChhRnWgeWwTRweLAgA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/react": { "version": "17.0.87", "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.87.tgz", diff --git a/frontend/package.json b/frontend/package.json index 8096c952e14c..a3be9c083697 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -155,6 +155,7 @@ "@types/classnames": "^2.3.1", "@types/color": "^3.0.3", "@types/dompurify": "^3.0.2", + "@types/rc-switch": "^1.9.5", "@types/react-router": "^5.1.20", "@types/react-router-dom": "^5.3.3", "@types/react-select": "^2.0.3", diff --git a/frontend/web/components/BreadcrumbSeparator.tsx b/frontend/web/components/BreadcrumbSeparator.tsx index 68fa9224ecd9..85d4e90ca1ef 100644 --- a/frontend/web/components/BreadcrumbSeparator.tsx +++ b/frontend/web/components/BreadcrumbSeparator.tsx @@ -414,7 +414,7 @@ const BreadcrumbSeparator: FC = ({ footer={Utils.renderWithPermission( canCreateProject, Constants.organisationPermissions( - Utils.getCreateProjectPermissionDescription( + Utils.getCreateProjectPermission( AccountStore.getOrganisation(), ), ), diff --git a/frontend/web/components/CompareIdentities.tsx b/frontend/web/components/CompareIdentities.tsx index e0aaf3630f0d..98c3ef1c9455 100644 --- a/frontend/web/components/CompareIdentities.tsx +++ b/frontend/web/components/CompareIdentities.tsx @@ -23,6 +23,7 @@ import IdentityOverridesIcon from './IdentityOverridesIcon' import Tooltip from './Tooltip' import PageTitle from './PageTitle' import { getDarkMode } from 'project/darkMode' +import { EnvironmentPermission } from 'common/types/permissions.types' type CompareIdentitiesType = { projectId: string @@ -69,7 +70,7 @@ const CompareIdentities: FC = ({ const { isLoading: permissionLoading, permission } = useHasPermission({ id: environmentId, level: 'environment', - permission: Utils.getViewIdentitiesPermission(), + permission: EnvironmentPermission.VIEW_IDENTITIES, }) const { data: leftUser } = useGetIdentityFeatureStatesAllQuery( @@ -118,12 +119,13 @@ const CompareIdentities: FC = ({ const isEdge = Utils.getIsEdge() const goUser = (user: IdentitySelectType['value'], feature: string) => { + if (!user) return window.open( `${ document.location.origin }/project/${projectId}/environment/${environmentId}/users/${encodeURIComponent( - user!.label, - )}/${user!.value}?flag=${encodeURIComponent(feature)}`, + user.label, + )}/${user.value}?flag=${encodeURIComponent(feature)}`, '_blank', ) } @@ -189,7 +191,9 @@ const CompareIdentities: FC = ({ {!permission && !permissionLoading ? (
) : ( diff --git a/frontend/web/components/Feature.js b/frontend/web/components/Feature.js deleted file mode 100644 index 73258aca6c1b..000000000000 --- a/frontend/web/components/Feature.js +++ /dev/null @@ -1,209 +0,0 @@ -// import propTypes from 'prop-types'; -import React, { PureComponent } from 'react' -import ValueEditor from './ValueEditor' -import Constants from 'common/constants' -import { VariationOptions } from './mv/VariationOptions' -import { AddVariationButton } from './mv/AddVariationButton' -import ErrorMessage from './ErrorMessage' -import Tooltip from './Tooltip' -import Icon from './Icon' -import InputGroup from './base/forms/InputGroup' -import WarningMessage from './WarningMessage' - -function isNegativeNumberString(str) { - if (typeof Utils.getTypedValue(str) !== 'number') { - return false - } - if (typeof str !== 'string') { - return false - } - const num = parseFloat(str) - return !isNaN(num) && num < 0 -} - -export default class Feature extends PureComponent { - static displayName = 'Feature' - - constructor(props) { - super(props) - this.state = { - isNegativeNumberString: isNegativeNumberString( - props.environmentFlag?.feature_state_value, - ), - } - } - removeVariation = (i) => { - const idToRemove = this.props.multivariate_options[i].id - - 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: () => { - this.props.removeVariation(i) - }, - title: 'Delete variation', - yesText: 'Confirm', - }) - } else { - this.props.removeVariation(i) - } - } - - render() { - const { - checked, - environmentFlag, - environmentVariations, - error, - identity, - isEdit, - multivariate_options, - onCheckedChange, - onValueChange, - projectFlag, - readOnly, - value, - } = this.props - - const enabledString = isEdit ? 'Enabled' : 'Enabled by default' - const controlPercentage = Utils.calculateControl(multivariate_options) - const valueString = identity - ? 'User override' - : !!multivariate_options && multivariate_options.length - ? `Control Value - ${controlPercentage}%` - : `Value` - - const showValue = !( - !!identity && - multivariate_options && - !!multivariate_options.length - ) - return ( -
- - - -
- {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 && ( - - - } - 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}`} - /> -
- )} - {this.state.isNegativeNumberString && ( - - This feature currently has the value of{' '} - "{environmentFlag?.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 && ( -
- -
- )} - {!!identity && ( -
- - {}} - weightTitle='Override Weight %' - projectFlag={projectFlag} - multivariateOptions={projectFlag.multivariate_options} - removeVariation={() => {}} - /> - -
- )} - {!identity && ( -
- - {(!!environmentVariations || !isEdit) && ( - - )} - - {!this.props.hideAddVariation && - Utils.renderWithPermission( - this.props.canCreateFeature, - Constants.projectPermissions('Create Feature'), - , - )} -
- )} - - ) - } -} diff --git a/frontend/web/components/IdentityTraits.tsx b/frontend/web/components/IdentityTraits.tsx index 2950c6ab5067..f25e4f835f5c 100644 --- a/frontend/web/components/IdentityTraits.tsx +++ b/frontend/web/components/IdentityTraits.tsx @@ -14,6 +14,7 @@ import { useGetIdentityTraitsQuery, } from 'common/services/useIdentityTrait' import { IdentityTrait } from 'common/types/responses' +import { EnvironmentPermission } from 'common/types/permissions.types' type IdentityTraitsType = { projectId: string | number @@ -33,7 +34,7 @@ const IdentityTraits: FC = ({ const { permission: manageUserPermission } = useHasPermission({ id: environmentId, level: 'environment', - permission: Utils.getManageUserPermission(), + permission: EnvironmentPermission.MANAGE_IDENTITIES, }) const { data: traits } = useGetIdentityTraitsQuery({ @@ -42,8 +43,7 @@ const IdentityTraits: FC = ({ use_edge_identities, }) - const [deleteTrait, { isLoading: deletingTrait }] = - useDeleteIdentityTraitMutation({}) + const [deleteTrait] = useDeleteIdentityTraitMutation({}) const onTraitSaved = () => { closeModal?.() @@ -126,7 +126,7 @@ const IdentityTraits: FC = ({ {Utils.renderWithPermission( manageUserPermission, Constants.environmentPermissions( - Utils.getManageUserPermissionDescription(), + EnvironmentPermission.MANAGE_IDENTITIES, ), - - ) - } - if (darkMode) { - return ( - - ) - } - return - } -} diff --git a/frontend/web/components/Switch.tsx b/frontend/web/components/Switch.tsx new file mode 100644 index 000000000000..7c54595d0eff --- /dev/null +++ b/frontend/web/components/Switch.tsx @@ -0,0 +1,68 @@ +import React, { FC } from 'react' +import RCSwitch, { Props as RCSwitchProps } from 'rc-switch' +import Icon from './Icon' + +export type SwitchProps = RCSwitchProps & { + checked?: boolean + darkMode?: boolean + offMarkup?: React.ReactNode + onMarkup?: React.ReactNode + onChange?: (checked: boolean) => void +} + +const Switch: FC = ({ + checked, + darkMode, + offMarkup, + onChange, + onMarkup, + ...rest +}) => { + if (E2E) { + return ( +
+ +
+ ) + } + + if (darkMode) { + return ( + + ) + } + + return +} + +Switch.displayName = 'Switch' + +export default Switch diff --git a/frontend/web/components/base/forms/Button.tsx b/frontend/web/components/base/forms/Button.tsx index 67cb7983ffb9..fc88958e88cd 100644 --- a/frontend/web/components/base/forms/Button.tsx +++ b/frontend/web/components/base/forms/Button.tsx @@ -23,6 +23,7 @@ export const sizeClassNames = { large: 'btn-lg', small: 'btn-sm', xSmall: 'btn-xsm', + xxSmall: 'btn-xxsm', } export type ButtonType = ButtonHTMLAttributes & { diff --git a/frontend/web/components/base/grid/FormGroup.js b/frontend/web/components/base/grid/FormGroup.js deleted file mode 100644 index 9f4bcdb77e89..000000000000 --- a/frontend/web/components/base/grid/FormGroup.js +++ /dev/null @@ -1,18 +0,0 @@ -import { PureComponent } from 'react' -const FormGroup = class extends PureComponent { - static displayName = 'FormGroup' - - render() { - return ( -
- {this.props.children} -
- ) - } -} - -FormGroup.displayName = 'FormGroup' -FormGroup.propTypes = { - children: OptionalNode, -} -module.exports = FormGroup diff --git a/frontend/web/components/base/grid/FormGroup.tsx b/frontend/web/components/base/grid/FormGroup.tsx new file mode 100644 index 000000000000..d41269cd96a9 --- /dev/null +++ b/frontend/web/components/base/grid/FormGroup.tsx @@ -0,0 +1,14 @@ +import React, { FC, ReactNode } from 'react' + +export type FormGroupProps = { + children?: ReactNode + className?: string +} + +const FormGroup: FC = ({ children, className = '' }) => { + return
{children}
+} + +FormGroup.displayName = 'FormGroup' + +export default FormGroup diff --git a/frontend/web/components/base/grid/Row.js b/frontend/web/components/base/grid/Row.js deleted file mode 100644 index 7a686afcbcb0..000000000000 --- a/frontend/web/components/base/grid/Row.js +++ /dev/null @@ -1,38 +0,0 @@ -/** - * Created by kylejohnson on 24/07/2016. - */ -import { PureComponent } from 'react' -import cn from 'classnames' - -class Row extends PureComponent { - static displayName = 'Row' - - static propTypes = { - children: OptionalNode, - className: OptionalString, - space: OptionalBool, - style: propTypes.any, - } - - render() { - const { noWrap, space, ...rest } = this.props - - return ( -
- {this.props.children} -
- ) - } -} - -module.exports = Row diff --git a/frontend/web/components/base/grid/Row.tsx b/frontend/web/components/base/grid/Row.tsx new file mode 100644 index 000000000000..58671d832389 --- /dev/null +++ b/frontend/web/components/base/grid/Row.tsx @@ -0,0 +1,31 @@ +import React, { FC, HTMLAttributes, ReactNode } from 'react' +import cn from 'classnames' + +export type RowProps = HTMLAttributes & { + children?: ReactNode + className?: string + space?: boolean + noWrap?: boolean +} + +const Row: FC = ({ children, className, noWrap, space, ...rest }) => { + return ( +
+ {children} +
+ ) +} + +Row.displayName = 'Row' + +export default Row diff --git a/frontend/web/components/diff/DiffSegmentOverrides.tsx b/frontend/web/components/diff/DiffSegmentOverrides.tsx index d73143562e3a..0d57f8d6378e 100644 --- a/frontend/web/components/diff/DiffSegmentOverrides.tsx +++ b/frontend/web/components/diff/DiffSegmentOverrides.tsx @@ -130,7 +130,7 @@ const DiffSegmentOverrides: FC = ({ +
Created
{created.length}
} diff --git a/frontend/web/components/feature-override/FeatureOverrideCTA.tsx b/frontend/web/components/feature-override/FeatureOverrideCTA.tsx index 15753d4e415d..e972299d8d2a 100644 --- a/frontend/web/components/feature-override/FeatureOverrideCTA.tsx +++ b/frontend/web/components/feature-override/FeatureOverrideCTA.tsx @@ -31,8 +31,7 @@ const FeatureOverrideCTA: FC = ({ overrideFeatureState, projectFlag, }) => { - const { permission, permissionDescription } = - Utils.getOverridePermission(level) + const { permission } = Utils.getOverridePermission(level) const { permission: hasPermission } = useHasPermission({ id: environmentId, level: 'environment', @@ -48,7 +47,7 @@ const FeatureOverrideCTA: FC = ({ <> {Utils.renderWithPermission( hasPermission, - Constants.environmentPermissions(permissionDescription), + Constants.environmentPermissions(permission), + + )} + + ) +} + +export default FeatureUpdateSummary diff --git a/frontend/web/components/modals/CreateFlag.js b/frontend/web/components/modals/create-feature/index.js similarity index 60% rename from frontend/web/components/modals/CreateFlag.js rename to frontend/web/components/modals/create-feature/index.js index 53d6030ee166..28deec1647f9 100644 --- a/frontend/web/components/modals/CreateFlag.js +++ b/frontend/web/components/modals/create-feature/index.js @@ -10,122 +10,138 @@ 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 AddEditTags from 'components/tags/AddEditTags' -import FlagOwners from 'components/FlagOwners' -import ChangeRequestModal from './ChangeRequestModal' -import Feature from 'components/Feature' +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 './base/ModalDefault' +import { + setInterceptClose, + setModalTitle, +} from 'components/modals/base/ModalDefault' import Icon from 'components/Icon' -import ModalHR from './ModalHR' +import ModalHR from 'components/modals/ModalHR' import FeatureValue from 'components/feature-summary/FeatureValue' import { getStore } from 'common/store' -import FlagOwnerGroups from 'components/FlagOwnerGroups' -import ExistingChangeRequestAlert from 'components/ExistingChangeRequestAlert' import Button from 'components/base/forms/Button' -import AddMetadataToEntity from 'components/metadata/AddMetadataToEntity' import { getSupportedContentType } from 'common/services/useSupportedContentType' import { getGithubIntegration } from 'common/services/useGithubIntegration' import { removeUserOverride } from 'components/RemoveUserOverride' import ExternalResourcesLinkTab from 'components/ExternalResourcesLinkTab' import { saveFeatureWithValidation } from 'components/saveFeatureWithValidation' -import PlanBasedBanner from 'components/PlanBasedAccess' 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 { IonIcon } from '@ionic/react' import { warning } from 'ionicons/icons' import FeaturePipelineStatus from 'components/release-pipelines/FeaturePipelineStatus' -import { FlagValueFooter } from './FlagValueFooter' import FeatureInPipelineGuard from 'components/release-pipelines/FeatureInPipelineGuard' import FeatureCodeReferencesContainer from 'components/feature-page/FeatureNavTab/CodeReferences/FeatureCodeReferencesContainer' import BetaFlag from 'components/BetaFlag' import ProjectProvider from 'common/providers/ProjectProvider' - -const CreateFlag = class extends Component { - static displayName = 'CreateFlag' +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, + EnvironmentPermissionDescriptions, + ProjectPermission, +} from 'common/types/permissions.types' + +const Index = class extends Component { + static displayName = 'create-feature' constructor(props, context) { super(props, context) - const { - description, - enabled, - feature_state_value, - is_archived, - is_server_key_only, - multivariate_options, - name, - tags, - } = this.props.projectFlag - ? Utils.getFlagValue( - this.props.projectFlag, - this.props.environmentFlag, - this.props.identityFlag, - ) + 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 { allowEditDescription } = this.props - const hideTags = this.props.hideTags || [] - if (this.props.projectFlag) { - this.userOverridesPage(1, true) - } + const sourceFlag = this.props.identityFlag || this.props.environmentFlag + const environmentFlagData = sourceFlag ? _.cloneDeep(sourceFlag) : {} + this.state = { - allowEditDescription, changeRequests: [], - default_enabled: enabled, - description, enabledIndentity: false, enabledSegment: false, + environmentFlag: environmentFlagData, externalResource: {}, externalResources: [], featureContentType: {}, + featureLimitAlert: { percentage: 0 }, githubId: '', hasIntegrationWithGithub: false, hasMetadataRequired: false, - identityVariations: - this.props.identityFlag && - this.props.identityFlag.multivariate_feature_state_values - ? _.cloneDeep( - this.props.identityFlag.multivariate_feature_state_values, - ) - : [], - initial_value: - typeof feature_state_value === 'undefined' - ? undefined - : Utils.getTypedValue(feature_state_value), isEdit: !!this.props.projectFlag, - is_archived, - is_server_key_only, - metadata: [], - multivariate_options: _.cloneDeep(multivariate_options), - name, period: 30, + projectFlag: projectFlagData, scheduledChangeRequests: [], + segmentsChanged: false, selectedIdentity: null, - tags: tags?.filter((tag) => !hideTags.includes(tag)) || [], + settingsChanged: false, userOverridesError: false, userOverridesNoPermission: false, + valueChanged: false, } } - getState = () => {} - 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 @@ -135,22 +151,25 @@ const CreateFlag = class extends Component { this.props.environmentVariations.length ) { this.setState({ - multivariate_options: - this.state.multivariate_options && - this.state.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, - } - }), + 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, + } + }), + }, }) } } @@ -159,10 +178,13 @@ const CreateFlag = class extends Component { 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 ( - this.state.valueChanged || - this.state.segmentsChanged || - this.state.settingsChanged + projectFlagChanged || + environmentFlagChanged || + segmentOverridesChanged ) { openConfirm({ body: 'Closing this will discard your unsaved changes.', @@ -182,12 +204,6 @@ const CreateFlag = class extends Component { componentDidMount = () => { setInterceptClose(this.onClosing) - if (!this.state.isEdit && !E2E) { - this.focusTimeout = setTimeout(() => { - this.input.focus() - this.focusTimeout = null - }, 500) - } if (Utils.getPlansPermission('METADATA')) { getSupportedContentType(getStore(), { organisation_id: AccountStore.getOrganisation().id, @@ -258,13 +274,13 @@ const CreateFlag = class extends Component { { id: this.props.environmentId, level: 'environment', - permissions: 'VIEW_IDENTITIES', + permissions: EnvironmentPermission.VIEW_IDENTITIES, }, { forceRefetch }, ) .then((permissions) => { const hasViewIdentitiesPermission = - permissions[Utils.getViewIdentitiesPermission()] || + permissions[EnvironmentPermission.VIEW_IDENTITIES] || permissions.ADMIN // Early return if user doesn't have permission if (!hasViewIdentitiesPermission) { @@ -364,42 +380,32 @@ const CreateFlag = class extends Component { save = (func, isSaving) => { const { - environmentFlag, + environmentFlag: propsEnvironmentFlag, environmentId, identity, identityFlag, projectFlag: _projectFlag, segmentOverrides, } = this.props - const { - default_enabled, - description, - initial_value, - is_archived, - is_server_key_only, - name, - } = this.state - const projectFlag = { - skipSaveProjectFeature: this.state.skipSaveProjectFeature, - ..._projectFlag, - } + const { environmentFlag: stateEnvironmentFlag, projectFlag } = this.state const hasMultivariate = - this.props.environmentFlag && - this.props.environmentFlag.multivariate_feature_state_values && - this.props.environmentFlag.multivariate_feature_state_values.length + propsEnvironmentFlag && + propsEnvironmentFlag.multivariate_feature_state_values && + propsEnvironmentFlag.multivariate_feature_state_values.length if (identity) { !isSaving && - name && + projectFlag.name && func({ - environmentFlag, + environmentFlag: propsEnvironmentFlag, environmentId, identity, identityFlag: Object.assign({}, identityFlag || {}, { - enabled: default_enabled, + enabled: stateEnvironmentFlag.enabled, feature_state_value: hasMultivariate - ? this.props.environmentFlag.feature_state_value - : this.cleanInputValue(initial_value), - multivariate_options: this.state.identityVariations, + ? propsEnvironmentFlag.feature_state_value + : this.cleanInputValue(stateEnvironmentFlag.feature_state_value), + multivariate_options: + stateEnvironmentFlag.multivariate_feature_state_values, }), projectFlag, }) @@ -407,28 +413,33 @@ const CreateFlag = class extends Component { FeatureListStore.isSaving = true FeatureListStore.trigger('change') !isSaving && - name && + projectFlag.name && func( this.props.projectId, this.props.environmentId, { - default_enabled, - description, - initial_value: this.cleanInputValue(initial_value), - is_archived, - is_server_key_only, + 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 !== this.state.metadata && - this.state.metadata.length) - ? this.state.metadata + (this.props.projectFlag.metadata !== projectFlag.metadata && + projectFlag.metadata.length) + ? projectFlag.metadata : this.props.projectFlag.metadata, - multivariate_options: this.state.multivariate_options, - name, - tags: this.state.tags, + multivariate_options: projectFlag.multivariate_options, + name: projectFlag.name, + tags: projectFlag.tags, }, - projectFlag, - environmentFlag, + { + skipSaveProjectFeature: this.state.skipSaveProjectFeature, + ..._projectFlag, + }, + propsEnvironmentFlag, segmentOverrides, ) } @@ -545,40 +556,6 @@ const CreateFlag = class extends Component { } } - addVariation = () => { - this.setState({ - multivariate_options: this.state.multivariate_options.concat([ - { - ...Utils.valueToFeatureState(''), - default_percentage_allocation: 0, - }, - ]), - valueChanged: true, - }) - } - - removeVariation = (i) => { - this.state.valueChanged = true - if (this.state.multivariate_options[i].id) { - const idToRemove = this.state.multivariate_options[i].id - if (idToRemove) { - this.props.removeMultivariateOption(idToRemove) - } - this.state.multivariate_options.splice(i, 1) - this.forceUpdate() - } else { - this.state.multivariate_options.splice(i, 1) - this.forceUpdate() - } - } - - updateVariation = (i, e, environmentVariations) => { - this.props.onEnvironmentVariationsChange(environmentVariations) - this.state.multivariate_options[i] = e - this.state.valueChanged = true - this.forceUpdate() - } - fetchChangeRequests = (forceRefetch) => { const { environmentId, projectFlag } = this.props if (!projectFlag?.id) return @@ -617,23 +594,17 @@ const CreateFlag = class extends Component { render() { const { - default_enabled, - description, enabledIndentity, enabledSegment, + environmentFlag, featureContentType, githubId, hasIntegrationWithGithub, - initial_value, isEdit, - multivariate_options, - name, + projectFlag, } = this.state - const FEATURE_ID_MAXLENGTH = Constants.forms.maxLength.FEATURE_ID - - const { identity, identityName, projectFlag } = this.props + const { identity, identityName } = this.props const Provider = identity ? IdentityProvider : FeatureListProvider - const environmentVariations = this.props.environmentVariations const environment = ProjectStore.getEnvironment(this.props.environmentId) const isVersioned = !!environment?.use_v2_feature_versioning const is4Eyes = @@ -642,319 +613,31 @@ const CreateFlag = class extends Component { const project = ProjectStore.model const caseSensitive = project?.only_allow_lower_case_feature_names const regex = project?.feature_name_regex - const controlValue = Utils.calculateControl(multivariate_options) + const controlValue = Utils.calculateControl( + projectFlag.multivariate_options, + ) const invalid = - !!multivariate_options && multivariate_options.length && controlValue < 0 + !!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 metadataEnable = Utils.getPlansPermission('METADATA') const isCodeReferencesEnabled = Utils.getFlagsmithHasFeature( 'git_code_references', ) try { - if (!isEdit && name && regex) { - regexValid = name.match(new RegExp(regex)) + if (!isEdit && projectFlag.name && regex) { + regexValid = projectFlag.name.match(new RegExp(regex)) } } catch (e) { regexValid = false } - const Settings = (projectAdmin, createFeature, featureContentType) => - !createFeature ? ( - -
- - ) : ( - <> - {!identity && this.state.tags && ( - - - this.setState({ settingsChanged: true, tags }) - } - /> - } - /> - - )} - {metadataEnable && featureContentType?.id && ( - <> - - { - this.setState({ - hasMetadataRequired: b, - }) - }} - onChange={(m) => { - this.setState({ - metadata: m, - }) - }} - /> - - )} - {!identity && projectFlag && ( - - {({ permission }) => - permission && ( - <> - - - - - - - - - ) - } - - )} - - - this.setState({ - description: Utils.safeParseEventValue(e), - settingsChanged: true, - }) - } - ds - type='text' - title={identity ? 'Description' : 'Description (optional)'} - placeholder="e.g. 'This determines what size the header is' " - /> - - - {!identity && ( - - - - this.setState({ is_server_key_only, settingsChanged: true }) - } - className='ml-0' - /> - - Server-side only - - } - > - Prevent this feature from being accessed with client-side - SDKs. - - - - )} - - {!identity && isEdit && ( - - - { - this.setState({ is_archived, settingsChanged: true }) - }} - className='ml-0' - /> - - Archived - - } - > - {`Archiving a flag allows you to filter out flags from the - Flagsmith dashboard that are no longer relevant. -
- An archived flag will still return as normal in all SDK - endpoints.`} -
-
-
- )} - - ) - const Value = (error, projectAdmin, createFeature, hideValue) => { - const { featureError, featureWarning } = this.parseError(error) - const { changeRequests, scheduledChangeRequests } = this.state - return ( - <> - {!!isEdit && !identity && ( - - )} - {!isEdit && ( - - (this.input = e)} - data-test='featureID' - inputProps={{ - className: 'full-width', - maxLength: FEATURE_ID_MAXLENGTH, - name: 'featureID', - readOnly: isEdit, - }} - value={name} - onChange={(e) => { - const newName = Utils.safeParseEventValue(e).replace( - / /g, - '_', - ) - this.setState({ - name: caseSensitive ? newName.toLowerCase() : newName, - }) - }} - isValid={!!name && regexValid} - type='text' - title={ - <> - - - {isEdit ? 'ID / Name' : 'ID / Name*'} - - - - } - > - The ID that will be used by SDKs to retrieve the feature - value and enabled state. This cannot be edited once the - feature has been created. - - {!!regex && !isEdit && ( -
- {' '} - - {' '} - This must conform to the regular expression{' '} -
{regex}
-
-
- )} - - } - placeholder='E.g. header_size' - /> -
- )} - - - {identity && description && ( - - - this.setState({ description: Utils.safeParseEventValue(e) }) - } - type='text' - title={identity ? 'Description' : 'Description (optional)'} - placeholder='No description' - /> - - )} - {!hideValue && ( -
- { - this.setState({ identityVariations, valueChanged: true }) - }} - environmentFlag={this.props.environmentFlag} - projectFlag={projectFlag} - onValueChange={(e) => { - const initial_value = Utils.getTypedValue( - Utils.safeParseEventValue(e), - ) - this.setState({ initial_value, valueChanged: true }) - }} - onCheckedChange={(default_enabled) => - this.setState({ default_enabled }) - } - /> -
- )} - {!isEdit && - !identity && - Settings(projectAdmin, createFeature, featureContentType)} - - ) - } return ( {({ project }) => ( @@ -972,6 +655,10 @@ const CreateFlag = class extends Component { this.fetchChangeRequests(true) this.fetchScheduledChangeRequests(true) } + + if (this.props.changeRequest) { + this.close() + } }} > {( @@ -1013,20 +700,27 @@ const CreateFlag = class extends Component { }) .concat([ Object.assign({}, this.props.environmentFlag, { - enabled: default_enabled, - feature_state_value: - Utils.valueToFeatureState(initial_value), + enabled: environmentFlag.enabled, + feature_state_value: Utils.valueToFeatureState( + environmentFlag.feature_state_value, + ), multivariate_feature_state_values: - this.state.identityVariations, + 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' + } + openModal2( - schedule - ? 'New Scheduled Flag Update' - : this.props.changeRequest - ? 'Update Change Request' - : 'New Change Request', + getModalTitle(), { const matching = - this.state.multivariate_options.find( + projectFlag.multivariate_options.find( (m) => m.id === v.multivariate_feature_option, @@ -1088,7 +783,7 @@ const CreateFlag = class extends Component { matching.default_percentage_allocation, } }) - : this.state.multivariate_options, + : projectFlag.multivariate_options, title, }, !is4Eyes, @@ -1126,20 +821,12 @@ const CreateFlag = class extends Component { }) const isLimitReached = false - const featureLimitAlert = - Utils.calculateRemainingLimitsPercentage( - project.total_features, - project.max_features_allowed, - ) const { featureError, featureWarning } = this.parseError(error) - const isReleasePipelineEnabled = - Utils.getFlagsmithHasFeature('release_pipelines') - return ( {({ permission: createFeature }) => ( @@ -1152,17 +839,16 @@ const CreateFlag = class extends Component { this.state.skipSaveProjectFeature = !createFeature const _hasMetadataRequired = this.state.hasMetadataRequired && - !this.state.metadata.length + !projectFlag.metadata?.length + return (
{isEdit && !identity ? ( <> - {isReleasePipelineEnabled && ( - - )} + this.forceUpdate()} urlParam='tab' @@ -1183,95 +869,68 @@ const CreateFlag = class extends Component { } > - - {featureLimitAlert.percentage && - Utils.displayLimitAlert( - 'features', - featureLimitAlert.percentage, - )} - - ( - <> -
Environment Value
- - This feature is in{' '} - - {matchingReleasePipeline?.name} - {' '} - release pipeline and its value - cannot be changed - - - )} - > - - Environment Value{' '} - - - } - place='top' - > - {Constants.strings.ENVIRONMENT_OVERRIDE_DESCRIPTION( - _.find(project.environments, { - api_key: this.props.environmentId, - }).name, - )} - - - {Value( - error, - projectAdmin, - createFeature, - )} - - {isEdit && ( - <> - - - - )} - -
-
+ { + this.setState({ + environmentFlag: { + ...this.state.environmentFlag, + ...changes, + }, + valueChanged: true, + }) + }} + onProjectFlagChange={(changes) => { + this.setState({ + projectFlag: { + ...this.state.projectFlag, + ...changes, + }, + }) + }} + onRemoveMultivariateOption={ + this.props.removeMultivariateOption + } + /> + + + - {!existingChangeRequest && ( + {(!existingChangeRequest || + isVersionedChangeRequest) && ( } > - {!identity && isEdit && ( - - ( - <> -
- Segment Overrides{' '} -
- + ( + <> +
+ Segment Overrides{' '} +
+ + This feature is in{' '} + + { + matchingReleasePipeline?.name + } + {' '} + release pipeline and no segment + overrides can be created + + + )} + > +
+ +
+ + Segment Overrides{' '} + + + } + place='top' > - This feature is in{' '} - - { - matchingReleasePipeline?.name - } - {' '} - release pipeline and no - segment overrides can be - created - - - )} - > -
- -
- - Segment Overrides{' '} - - + { + 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'}{' '} + { - Constants.strings - .SEGMENT_OVERRIDES_DESCRIPTION + _.find( + project.environments, + { + api_key: + this.props + .environmentId, + }, + ).name } - -

- - {({ - 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={ - multivariate_options - } - environmentId={ - this.props - .environmentId - } - value={ - this.props - .segmentOverrides - } - controlValue={ - initial_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 ${ - isVersioned - ? '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, - }) => { - if ( - isVersioned && - is4Eyes - ) { - return Utils.renderWithPermission( - savePermission, - Utils.getManageFeaturePermissionDescription( - is4Eyes, - identity, - ), - , - ) - } + : 'Create Change Request' + })()} + , + ) + } - return Utils.renderWithPermission( - manageSegmentsOverrides, - Constants.environmentPermissions( - 'Manage segment overrides', - ), - <> - {!is4Eyes && - isVersioned && ( - <> - - - )} - - , - ) - }} - - )} - -
+ : 'Schedule Update' + })()} + + + )} + + , + ) + }} + + )} +
- )} -
- - - )} +
+ )} +
+
+
)} {({ permission: viewIdentities }) => - !identity && - isEdit && !existingChangeRequest && !hideIdentityOverridesTab && ( @@ -1910,7 +1565,7 @@ const CreateFlag = class extends Component { dangerouslySetInnerHTML={{ __html: Constants.environmentPermissions( - 'View Identities', + EnvironmentPermission.VIEW_IDENTITIES, ), }} /> @@ -2033,11 +1688,38 @@ const CreateFlag = class extends Component { } > - {Settings( - projectAdmin, - createFeature, - featureContentType, - )} + { + 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, + }) + } + /> - {featureLimitAlert.percentage && - Utils.displayLimitAlert( - 'features', - featureLimitAlert.percentage, - )} - {Value( - error, - projectAdmin, - createFeature, - project.prevent_flag_defaults && !identity, - )} - + 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 && ( -
- {project.prevent_flag_defaults ? ( - - This will create the feature for{' '} - all environments, you - can edit this feature per environment - once the feature's enabled state and - environment once the feature is created. - - ) : ( - - This will create the feature for{' '} - all environments, you - can edit this feature per environment - once the feature is created. - - )} - - -
- )}
)} {identity && ( @@ -2171,18 +1873,39 @@ const CreateFlag = class extends Component {
{identity && ( -
- -
+ + {({ permission: savePermission }) => + Utils.renderWithPermission( + savePermission, + EnvironmentPermissionDescriptions.UPDATE_FEATURE_STATE, +
+ +
, + ) + } +
)}
@@ -2202,7 +1925,7 @@ const CreateFlag = class extends Component { } } -CreateFlag.propTypes = {} +Index.propTypes = {} //This will remount the modal when a feature is created const FeatureProvider = (WrappedComponent) => { @@ -2238,10 +1961,14 @@ const FeatureProvider = (WrappedComponent) => { 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 = `${operation} ${type}` + const toastText = isEditingChangeRequest + ? `Updated ${type}` + : `${operation} ${type}` const toastAction = changeRequest ? { buttonText: 'Open', @@ -2274,6 +2001,9 @@ const FeatureProvider = (WrappedComponent) => { ...(newEnvironmentFlag || {}), }, projectFlag, + segmentsChanged: false, + settingsChanged: false, + valueChanged: false, }) } else if (this.props.projectFlag) { //update the environmentFlag and projectFlag to the new values @@ -2288,6 +2018,9 @@ const FeatureProvider = (WrappedComponent) => { ...(newEnvironmentFlag || {}), }, projectFlag: newProjectFlag, + segmentsChanged: false, + settingsChanged: false, + valueChanged: false, }) } }, @@ -2306,6 +2039,6 @@ const FeatureProvider = (WrappedComponent) => { return HOC } -const WrappedCreateFlag = ConfigProvider(withSegmentOverrides(CreateFlag)) +const WrappedCreateFlag = ConfigProvider(withSegmentOverrides(Index)) export default FeatureProvider(WrappedCreateFlag) diff --git a/frontend/web/components/modals/create-feature/tabs/CreateFeature.tsx b/frontend/web/components/modals/create-feature/tabs/CreateFeature.tsx new file mode 100644 index 000000000000..b40e333bed1b --- /dev/null +++ b/frontend/web/components/modals/create-feature/tabs/CreateFeature.tsx @@ -0,0 +1,92 @@ +import React, { FC } from 'react' +import { FeatureState, ProjectFlag } from 'common/types/responses' +import FeatureValue from './FeatureValue' +import FeatureSettings from './FeatureSettings' +import ErrorMessage from 'components/ErrorMessage' +import WarningMessage from 'components/WarningMessage' +import { useHasPermission } from 'common/providers/Permission' +import { + ADMIN_PERMISSION, + ProjectPermission, +} from 'common/types/permissions.types' + +type CreateFeatureTabProps = { + projectId: number + error: any + featureState: FeatureState + projectFlag: ProjectFlag | null + featureContentType: any + identity?: string + onEnvironmentFlagChange: (changes: FeatureState) => void + onProjectFlagChange: (changes: ProjectFlag) => void + onRemoveMultivariateOption?: (id: number) => void + onHasMetadataRequiredChange: (hasMetadataRequired: boolean) => void + featureError?: string + featureWarning?: string +} + +const CreateFeature: FC = ({ + error, + featureContentType, + featureError, + featureState, + featureWarning, + identity, + onEnvironmentFlagChange, + onHasMetadataRequiredChange, + onProjectFlagChange, + onRemoveMultivariateOption, + projectFlag, + projectId, +}) => { + const { permission: createFeature } = useHasPermission({ + id: projectId, + level: 'project', + permission: ProjectPermission.CREATE_FEATURE, + }) + + const { permission: projectAdmin } = useHasPermission({ + id: projectId, + level: 'project', + permission: ADMIN_PERMISSION, + }) + + const noPermissions = !createFeature && !projectAdmin + + return ( + <> + + + {!!projectFlag && ( + <> + + + + )} + + ) +} + +export default CreateFeature diff --git a/frontend/web/components/modals/create-feature/tabs/FeatureSettings.tsx b/frontend/web/components/modals/create-feature/tabs/FeatureSettings.tsx new file mode 100644 index 000000000000..ab66d121c9f0 --- /dev/null +++ b/frontend/web/components/modals/create-feature/tabs/FeatureSettings.tsx @@ -0,0 +1,193 @@ +import React, { FC } 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 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 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' + +type FeatureSettingsTabProps = { + projectAdmin: boolean + createFeature: boolean + featureContentType: any + identity?: string + isEdit: boolean + projectId: number | string + projectFlag: ProjectFlag | null + onChange: (projectFlag: ProjectFlag) => void + onHasMetadataRequiredChange: (hasMetadataRequired: boolean) => void +} + +const FeatureSettings: FC = ({ + createFeature, + featureContentType, + identity, + isEdit, + onChange, + onHasMetadataRequiredChange, + projectFlag, + projectId, +}) => { + const metadataEnable = Utils.getPlansPermission('METADATA') + + if (!createFeature) { + return ( + +
+ + ) + } + + if (!projectFlag) { + return null + } + return ( +
+ {!identity && projectFlag?.tags && ( + + onChange({ ...projectFlag, tags })} + /> + } + /> + + )} + {metadataEnable && featureContentType?.id && !identity && ( + <> + + onChange({ ...projectFlag, metadata })} + /> + + )} + {!identity && projectFlag?.id && ( + + {({ permission }) => + permission && ( + <> + + + + + + + + + ) + } + + )} + + + onChange({ + ...projectFlag, + description: Utils.safeParseEventValue(e), + }) + } + type='text' + title={identity ? 'Description' : 'Description (optional)'} + placeholder="e.g. 'This determines what size the header is' " + /> + + + {!identity && ( + + + + onChange({ ...projectFlag, is_server_key_only }) + } + className='ml-0' + /> + + Server-side only + + } + > + Prevent this feature from being accessed with client-side SDKs. + + + + )} + + {!identity && isEdit && ( + + + + onChange({ ...projectFlag, is_archived }) + } + className='ml-0' + /> + + Archived + + } + > + {`Archiving a flag allows you to filter out flags from the + Flagsmith dashboard that are no longer relevant. +
+ An archived flag will still return as normal in all SDK + endpoints.`} +
+
+
+ )} +
+ ) +} + +export default FeatureSettings diff --git a/frontend/web/components/modals/create-feature/tabs/FeatureValue.tsx b/frontend/web/components/modals/create-feature/tabs/FeatureValue.tsx new file mode 100644 index 000000000000..de7611251aea --- /dev/null +++ b/frontend/web/components/modals/create-feature/tabs/FeatureValue.tsx @@ -0,0 +1,295 @@ +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/mv/VariationOptions.tsx b/frontend/web/components/mv/VariationOptions.tsx index 72b4a703c8d5..acf980fc78ab 100644 --- a/frontend/web/components/mv/VariationOptions.tsx +++ b/frontend/web/components/mv/VariationOptions.tsx @@ -10,10 +10,9 @@ interface VariationOptionsProps { controlValue: any disabled: boolean multivariateOptions: any[] - preventRemove: boolean - readOnlyValue: boolean + readOnly?: boolean removeVariation: (i: number) => void - select: boolean + select?: boolean setValue: (value: any) => void setVariations: (variations: any[]) => void updateVariation: ( @@ -31,8 +30,7 @@ export const VariationOptions: React.FC = ({ controlValue, disabled, multivariateOptions, - preventRemove, - readOnlyValue, + readOnly, removeVariation, select, setValue, @@ -56,7 +54,7 @@ export const VariationOptions: React.FC = ({ error='Your variation percentage splits total to over 100%' /> )} - {!preventRemove && ( + {!readOnly && (

Changing a Variation Value will affect{' '} @@ -152,7 +150,7 @@ export const VariationOptions: React.FC = ({ key={i} index={i} canCreateFeature={canCreateFeature} - readOnlyValue={readOnlyValue} + readOnly={readOnly} value={theValue} onChange={(e) => { updateVariation(i, e, variationOverrides) @@ -160,7 +158,7 @@ export const VariationOptions: React.FC = ({ weightTitle={weightTitle} disabled={disabled} onRemove={ - preventRemove || disabled ? undefined : () => removeVariation(i) + readOnly || disabled ? undefined : () => removeVariation(i) } /> ) diff --git a/frontend/web/components/mv/VariationValueInput.tsx b/frontend/web/components/mv/VariationValueInput.tsx index a10cfe1f4d62..945e714e4b70 100644 --- a/frontend/web/components/mv/VariationValueInput.tsx +++ b/frontend/web/components/mv/VariationValueInput.tsx @@ -5,6 +5,7 @@ import Icon from 'components/Icon' import InputGroup from 'components/base/forms/InputGroup' import Utils from 'common/utils/utils' import shallowEqual from 'fbjs/lib/shallowEqual' +import { ProjectPermission } from 'common/types/permissions.types' interface VariationValueProps { canCreateFeature: boolean @@ -12,7 +13,7 @@ interface VariationValueProps { index: number onChange: (value: any) => void onRemove?: () => void - readOnlyValue: boolean + readOnly: boolean value: any weightTitle: string } @@ -23,7 +24,7 @@ export const VariationValueInput: React.FC = ({ index, onChange, onRemove, - readOnlyValue, + readOnly, value, weightTitle, }) => { @@ -36,7 +37,7 @@ export const VariationValueInput: React.FC = ({ <> {Utils.renderWithPermission( canCreateFeature, - Constants.projectPermissions('Create Feature'), + Constants.projectPermissions(ProjectPermission.CREATE_FEATURE), = ({ name='featureValue' className='full-width code-medium' value={Utils.getTypedValue(Utils.featureStateToValue(value))} - disabled={!canCreateFeature || disabled || readOnlyValue} + disabled={!canCreateFeature || disabled || readOnly} onBlur={() => { const newValue = { ...value, @@ -98,7 +99,7 @@ export const VariationValueInput: React.FC = ({ title={weightTitle} /> - {!!onRemove && ( + {!!onRemove && !readOnly && (

) : ( -
{Constants.projectPermissions('Admin')}
+
+ {Constants.projectPermissions(ADMIN_PERMISSION)} +
)} diff --git a/frontend/web/components/pages/OrganisationIntegrationsPage.tsx b/frontend/web/components/pages/OrganisationIntegrationsPage.tsx index b7ac7961f1f8..bb13afbcc5a5 100644 --- a/frontend/web/components/pages/OrganisationIntegrationsPage.tsx +++ b/frontend/web/components/pages/OrganisationIntegrationsPage.tsx @@ -5,6 +5,7 @@ import PageTitle from 'components/PageTitle' import Utils from 'common/utils/utils' import InfoMessage from 'components/InfoMessage' import AccountStore from 'common/stores/account-store' +import { ADMIN_PERMISSION } from 'common/types/permissions.types' const OrganisationIntegrationsPage = ({ match }) => { useEffect(() => { @@ -19,7 +20,9 @@ const OrganisationIntegrationsPage = ({ match }) => { return (
diff --git a/frontend/web/components/pages/ProjectChangeRequestDetailPage.tsx b/frontend/web/components/pages/ProjectChangeRequestDetailPage.tsx index 24f427f8dad3..4c3b85349864 100644 --- a/frontend/web/components/pages/ProjectChangeRequestDetailPage.tsx +++ b/frontend/web/components/pages/ProjectChangeRequestDetailPage.tsx @@ -18,6 +18,7 @@ import { useGetProjectQuery } from 'common/services/useProject' import DiffSegment from 'components/diff/DiffSegment' import ConfigProvider from 'common/providers/ConfigProvider' import { useHistory } from 'react-router-dom' +import { ProjectPermission } from 'common/types/permissions.types' type ProjectChangeRequestPageType = { router: RouterChildContext['router'] @@ -37,12 +38,12 @@ const ProjectChangeRequestDetailPage: FC = ({ const approvePermission = useHasPermission({ id: projectId, level: 'project', - permission: 'MANAGE_SEGMENTS', + permission: ProjectPermission.APPROVE_PROJECT_LEVEL_CHANGE_REQUESTS, }) const publishPermission = useHasPermission({ id: projectId, level: 'project', - permission: 'MANAGE_SEGMENTS', + permission: ProjectPermission.MANAGE_SEGMENTS, }) const { data: project } = useGetProjectQuery({ id: projectId }) const [actionChangeRequest, { isLoading: isActioning }] = @@ -117,9 +118,10 @@ const ProjectChangeRequestDetailPage: FC = ({ ), destructive: true, onYes: () => { + if (!changeRequest) return deleteProjectChangeRequest({ - id: `${changeRequest!.id}`, - project_id: projectId, + id: `${changeRequest.id}`, + project_id: Number(projectId), }).then((res) => { // @ts-ignore if (!res.error) { @@ -136,10 +138,11 @@ const ProjectChangeRequestDetailPage: FC = ({ } const approveChangeRequest = () => { + if (!changeRequest) return actionChangeRequest({ actionType: 'approve', - id: `${changeRequest!.id}`, - project_id: projectId, + id: `${changeRequest.id}`, + project_id: Number(projectId), }) } @@ -165,10 +168,11 @@ const ProjectChangeRequestDetailPage: FC = ({ ), onYes: () => { + if(!changeRequest) return actionChangeRequest({ actionType: 'commit', - id: `${changeRequest!.id}`, - project_id: projectId, + id: `${changeRequest.id}`, + project_id: Number(projectId), }) }, title: `Publish Change Request`, @@ -231,7 +235,7 @@ const ProjectChangeRequestDetailPage: FC = ({ addOwner={addOwner} removeOwner={removeOwner} publishPermissionDescription={Constants.projectPermissions( - 'Update Segments', + ProjectPermission.MANAGE_SEGMENTS, )} deleteChangeRequest={deleteChangeRequest} minApprovals={minApprovals || 0} diff --git a/frontend/web/components/pages/SegmentPage.tsx b/frontend/web/components/pages/SegmentPage.tsx index 7fe3f2469b3d..a816f0f5b161 100644 --- a/frontend/web/components/pages/SegmentPage.tsx +++ b/frontend/web/components/pages/SegmentPage.tsx @@ -12,6 +12,7 @@ import Icon from 'components/Icon' import { handleRemoveSegment } from 'components/modals/ConfirmRemoveSegment' import SegmentSelect from 'components/SegmentSelect' import Utils from 'common/utils/utils' +import { ProjectPermission } from 'common/types/permissions.types' type SegmentPageType = {} @@ -27,7 +28,7 @@ const SegmentPage: FC = ({}) => { const { permission: manageSegmentsPermission } = useHasPermission({ id: projectId, level: 'project', - permission: 'MANAGE_SEGMENTS', + permission: ProjectPermission.MANAGE_SEGMENTS, }) const onRemoveSegment = () => { @@ -66,14 +67,19 @@ const SegmentPage: FC = ({}) => { projectId={projectId} /> - + {Utils.renderWithPermission( + manageSegmentsPermission, + 'Manage Segments', + , + )} } /> diff --git a/frontend/web/components/pages/SegmentsPage.tsx b/frontend/web/components/pages/SegmentsPage.tsx index 592d52dc8b78..34f15809ad41 100644 --- a/frontend/web/components/pages/SegmentsPage.tsx +++ b/frontend/web/components/pages/SegmentsPage.tsx @@ -25,6 +25,7 @@ import InfoMessage from 'components/InfoMessage' import CodeHelp from 'components/CodeHelp' import SegmentRow from 'components/segments/SegmentRow/SegmentRow' +import { ProjectPermission } from 'common/types/permissions.types' const SegmentsPage: FC = () => { const history = useHistory() @@ -48,13 +49,14 @@ const SegmentsPage: FC = () => { } else if (!id && typeof closeModal !== 'undefined') { closeModal() } + //eslint-disable-next-line }, [id]) - const { data, error, isLoading, refetch } = useGetSegmentsQuery({ + const { data, error, isLoading } = useGetSegmentsQuery({ include_feature_specific: showFeatureSpecific, page, page_size: 100, - projectId, + projectId: Number(projectId), q: search, }) const [_, { isLoading: isRemoving }] = useDeleteSegmentMutation() @@ -100,7 +102,7 @@ const SegmentsPage: FC = () => { const { permission: manageSegmentsPermission } = useHasPermission({ id: projectId, level: 'project', - permission: 'MANAGE_SEGMENTS', + permission: ProjectPermission.MANAGE_SEGMENTS, }) const renderWithPermission = ( @@ -112,7 +114,7 @@ const SegmentsPage: FC = () => { el ) : ( - {Constants.projectPermissions('Manage segments')} + {Constants.projectPermissions(ProjectPermission.MANAGE_SEGMENTS)} ) } diff --git a/frontend/web/components/pages/UsersAndPermissionsPage.tsx b/frontend/web/components/pages/UsersAndPermissionsPage.tsx index adb6837f60fa..dc940504a54b 100644 --- a/frontend/web/components/pages/UsersAndPermissionsPage.tsx +++ b/frontend/web/components/pages/UsersAndPermissionsPage.tsx @@ -42,6 +42,10 @@ import { } from 'common/services/useInvites' import OrganisationUsersTable from 'components/users-permissions/OrganisationUsersTable/OrganisationUsersTable' import getUserDisplayName from 'common/utils/getUserDisplayName' +import { + ADMIN_PERMISSION, + OrganisationPermission, +} from 'common/types/permissions.types' type UsersAndPermissionsPageType = { router: RouterChildContext['router'] @@ -90,19 +94,19 @@ const UsersAndPermissionsInner: FC = ({ const manageUsersPermission = useHasPermission({ id: AccountStore.getOrganisation()?.id, level: 'organisation', - permission: 'MANAGE_USERS', + permission: OrganisationPermission.MANAGE_USERS, }) const manageGroupsPermission = useHasPermission({ id: AccountStore.getOrganisation()?.id, level: 'organisation', - permission: 'MANAGE_USER_GROUPS', + permission: OrganisationPermission.MANAGE_USER_GROUPS, }) const hasInvitePermission = hasEmailProvider && manageUsersPermission.permission - const tooltTipText = !hasEmailProvider + const tooltipText = !hasEmailProvider ? noEmailProvider - : Constants.organisationPermissions('Admin') + : Constants.organisationPermissions(ADMIN_PERMISSION) const { data: roles } = useGetRolesQuery({ organisation_id: organisation.id }) @@ -140,9 +144,8 @@ const UsersAndPermissionsInner: FC = ({ .then(() => { toast('Invite deleted successfully') }) - .catch((error) => { + .catch(() => { toast('Error deleting invite', 'error') - console.error(error) }), title: 'Delete Invite', yesText: 'Confirm', @@ -195,7 +198,7 @@ const UsersAndPermissionsInner: FC = ({
Team Members
{Utils.renderWithPermission( hasInvitePermission, - tooltTipText, + tooltipText, {({ permission }) => Utils.renderWithPermission( permission, - Constants.projectPermissions( - permissionType === 'ADMIN' ? 'Admin' : 'Manage Tags', - ), + Constants.projectPermissions(ProjectPermission.MANAGE_TAGS),