From 31340683215cf7e4e7b626c715ea418a2692c58c Mon Sep 17 00:00:00 2001 From: kyle-ssg Date: Tue, 5 Aug 2025 16:27:18 +0100 Subject: [PATCH 001/108] Add segment page --- frontend/e2e/helpers.cafe.ts | 1 - frontend/web/components/SegmentOverrides.js | 2 +- .../web/components/modals/CreateSegment.tsx | 2 +- frontend/web/components/pages/SegmentPage.tsx | 80 +++++++++++++++++++ .../web/components/pages/SegmentsPage.tsx | 40 +--------- .../segments/SegmentRow/SegmentRow.tsx | 75 ++++++++--------- frontend/web/routes.js | 3 + 7 files changed, 126 insertions(+), 77 deletions(-) create mode 100644 frontend/web/components/pages/SegmentPage.tsx diff --git a/frontend/e2e/helpers.cafe.ts b/frontend/e2e/helpers.cafe.ts index 1074bb54f3e0..097cc4a67983 100644 --- a/frontend/e2e/helpers.cafe.ts +++ b/frontend/e2e/helpers.cafe.ts @@ -564,7 +564,6 @@ export const createSegment = async ( await click(byId('create-segment')) await waitForElementVisible(byId(`segment-${index}-name`)) await assertTextContent(byId(`segment-${index}-name`), id) - await closeModal() } export const waitAndRefresh = async (waitFor = 3000) => { diff --git a/frontend/web/components/SegmentOverrides.js b/frontend/web/components/SegmentOverrides.js index 59e09ea9bdcc..5b6ff4c1edea 100644 --- a/frontend/web/components/SegmentOverrides.js +++ b/frontend/web/components/SegmentOverrides.js @@ -208,7 +208,7 @@ const SegmentOverrideInner = class Override extends React.Component { setSegmentEditId(v.segment) } else { window.open( - `${document.location.origin}/project/${this.props.projectId}/segments?id=${v.segment}`, + `${document.location.origin}/project/${this.props.projectId}/segments/${v.segment}`, '_blank', ) } diff --git a/frontend/web/components/modals/CreateSegment.tsx b/frontend/web/components/modals/CreateSegment.tsx index 54e9ccc03fd8..e5386bc7dc75 100644 --- a/frontend/web/components/modals/CreateSegment.tsx +++ b/frontend/web/components/modals/CreateSegment.tsx @@ -442,7 +442,7 @@ const CreateSegment: FC = ({ } > -
+
= ({}) => { + const route = useRouteMatch<{ id: string; projectId: string }>() + const { id, projectId } = route.params + const history = useHistory() + const environmentId = ( + ProjectStore.getEnvironment() as unknown as Environment | undefined + )?.api_key + const { data: segment } = useGetSegmentQuery({ id, projectId }) + + const { permission: manageSegmentsPermission } = useHasPermission({ + id: projectId, + level: 'project', + permission: 'MANAGE_SEGMENTS', + }) + + const [removeSegment] = useDeleteSegmentMutation() + const onRemoveSegment = () => { + handleRemoveSegment(projectId, segment!, removeSegment, () => { + history.replace(`/project/${projectId}/segments`) + }) + } + return ( +
+ + + + + ) + } + title={segment?.name} + > + {segment?.description} + + + +
+ ) +} + +export default SegmentPage diff --git a/frontend/web/components/pages/SegmentsPage.tsx b/frontend/web/components/pages/SegmentsPage.tsx index 19e9dbb40933..2e76e23fa8c5 100644 --- a/frontend/web/components/pages/SegmentsPage.tsx +++ b/frontend/web/components/pages/SegmentsPage.tsx @@ -45,7 +45,7 @@ const SegmentsPage: FC = () => { useEffect(() => { if (id) { - editSegment(id, !manageSegmentsPermission) + history.push(id, !manageSegmentsPermission) } else if (!id && typeof closeModal !== 'undefined') { closeModal() } @@ -88,10 +88,8 @@ const SegmentsPage: FC = () => { openModal( 'New Segment', { - //todo: remove when CreateSegment uses hooks - setModalTitle(`Edit Segment: ${segment.name}`) - toast('Created segment') + onComplete={() => { + closeModal() }} environmentId={environmentId!} projectId={projectId} @@ -106,38 +104,6 @@ const SegmentsPage: FC = () => { permission: 'MANAGE_SEGMENTS', }) - const editSegment = (id: number, readOnly?: boolean) => { - API.trackEvent(Constants.events.VIEW_SEGMENT) - - openModal( - `Edit Segment`, - { - setShowFeatureSpecific(!!segment?.feature) - setModalTitle(`Edit Segment: ${segment.name}`) - }} - readOnly={readOnly} - onComplete={() => { - refetch() - toast('Updated Segment') - }} - environmentId={environmentId!} - projectId={projectId} - />, - 'side-modal create-segment-modal', - () => { - history.push( - `${document.location.pathname}?${Utils.toParam({ - ...Utils.fromParam(), - id: undefined, - })}`, - ) - }, - ) - } - const renderWithPermission = ( permission: boolean, name: string, diff --git a/frontend/web/components/segments/SegmentRow/SegmentRow.tsx b/frontend/web/components/segments/SegmentRow/SegmentRow.tsx index 8d9111725158..8d11851a4dc3 100644 --- a/frontend/web/components/segments/SegmentRow/SegmentRow.tsx +++ b/frontend/web/components/segments/SegmentRow/SegmentRow.tsx @@ -24,11 +24,40 @@ interface SegmentRowProps { MutationDefinition > } - +export const handleRemoveSegment = ( + projectId: string, + segment: Segment, + removeSegmentQuery: SegmentRowProps['removeSegment'], + onComplete?: () => void, +) => { + const removeSegmentCallback = async () => { + try { + await removeSegmentQuery({ id: segment.id, projectId }) + toast( +
+ Removed Segment: {segment.name} +
, + ) + onComplete?.() + } catch (error) { + toast( +
+ Error removing segment: {segment.name} +
, + 'danger', + ) + } + } + openModal( + 'Remove Segment', + , + 'p-0', + ) +} export const SegmentRow: FC = ({ index, projectId, - removeSegment, + removeSegment: removeSegmentCallback, segment, }) => { const history = useHistory() @@ -40,28 +69,14 @@ export const SegmentRow: FC = ({ permission: 'MANAGE_SEGMENTS', }) + const onRemoveSegmentClick = () => { + handleRemoveSegment(projectId, segment, removeSegmentCallback) + } + const [cloneSegment, { isLoading: isCloning }] = useCloneSegmentMutation() const isCloningEnabled = Utils.getFlagsmithHasFeature('clone_segment') - const removeSegmentCallback = async () => { - try { - await removeSegment({ id, projectId }) - toast( -
- Removed Segment: {segment.name} -
, - ) - } catch (error) { - toast( -
- Error removing segment: {segment.name} -
, - 'danger', - ) - } - } - const cloneSegmentCallback = async (name: string) => { try { await cloneSegment({ name, projectId, segmentId: segment.id }).unwrap() @@ -81,14 +96,6 @@ export const SegmentRow: FC = ({ } } - const handleRemoveSegment = () => { - openModal( - 'Remove Segment', - , - 'p-0', - ) - } - const handleCloneSegment = () => { openModal( 'Clone Segment', @@ -107,13 +114,7 @@ export const SegmentRow: FC = ({ className='table-column px-3' onClick={ manageSegmentsPermission - ? () => - history.push( - `${document.location.pathname}?${Utils.toParam({ - ...Utils.fromParam(), - id, - })}`, - ) + ? () => history.push(`${document.location.pathname}/${id}`) : undefined } > @@ -133,14 +134,14 @@ export const SegmentRow: FC = ({ index={index} isRemoveDisabled={!manageSegmentsPermission} isCloneDisabled={!manageSegmentsPermission} - onRemove={handleRemoveSegment} + onRemove={onRemoveSegmentClick} onClone={handleCloneSegment} /> ) : ( + + + + {hasUserOverride ? ( +
+ + This feature is being overridden for this identity +
+ ) : flagEnabledDifferent ? ( +
+ + + {isMultiVariateOverride ? ( + + This flag is being overridden by a variation defined on + your feature, the control value is{' '} + {flagEnabled ? 'on' : 'off'} for this + user + + ) : ( + + + {`This flag is being overridden by a segment and would normally be`} +
+ {flagEnabled ? 'on' : 'off'} +
{' '} + for this user +
+ )} +
+
+
+ ) : flagValueDifferent ? ( + isMultiVariateOverride ? ( +
+ + This feature is being overridden by a % variation in the + environment, the control value of this feature is{' '} + + +
+ ) : ( + + + {`This feature is being + overridden by a segment + and would normally be`} + {' '} + for this user + + ) + ) : ( + getViewMode() === 'default' && ( +
+ Using environment defaults +
+ ) + )} + + + +
+ +
+
e.stopPropagation()} + > + {Utils.renderWithPermission( + editPermission, + editPermissionDescription, + , + )} +
+
e.stopPropagation()} + > + {cta} +
+
+ ) +} + +export default FeatureOverrideRow diff --git a/frontend/web/components/IdentityTraits.tsx b/frontend/web/components/IdentityTraits.tsx index f4307cd931c8..bcadd7461ee0 100644 --- a/frontend/web/components/IdentityTraits.tsx +++ b/frontend/web/components/IdentityTraits.tsx @@ -16,7 +16,7 @@ import { import { IdentityTrait } from 'common/types/responses' type IdentityTraitsType = { - projectId: string + projectId: string | number environmentId: string identityName: string identityId: string diff --git a/frontend/web/components/modals/CreateSegment.tsx b/frontend/web/components/modals/CreateSegment.tsx index e5386bc7dc75..8be429bad2ae 100644 --- a/frontend/web/components/modals/CreateSegment.tsx +++ b/frontend/web/components/modals/CreateSegment.tsx @@ -587,7 +587,7 @@ type LoadingCreateSegmentType = { readOnly?: boolean onSegmentRetrieved?: (segment: Segment) => void onComplete?: (segment: Segment) => void - projectId: string + projectId: string | number segment?: number } diff --git a/frontend/web/components/pages/UserPage.tsx b/frontend/web/components/pages/UserPage.tsx index af9292a325b6..23af77ba2489 100644 --- a/frontend/web/components/pages/UserPage.tsx +++ b/frontend/web/components/pages/UserPage.tsx @@ -27,7 +27,6 @@ import CreateFlagModal from 'components/modals/CreateFlag' import CreateSegmentModal from 'components/modals/CreateSegment' import EditIdentity from 'components/EditIdentity' import FeatureListStore from 'common/stores/feature-list-store' -import FeatureValue from 'components/FeatureValue' import Format from 'common/utils/format' import Icon from 'components/Icon' import IdentifierString from 'components/IdentifierString' @@ -40,7 +39,6 @@ import PanelSearch from 'components/PanelSearch' import Permission from 'common/providers/Permission' // @ts-ignore import Project from 'common/project' -import Switch from 'components/Switch' import TableFilterOptions from 'components/tables/TableFilterOptions' import TableGroupsFilter from 'components/tables/TableGroupsFilter' import TableOwnerFilter from 'components/tables/TableOwnerFilter' @@ -48,32 +46,19 @@ import TableSearchFilter from 'components/tables/TableSearchFilter' import TableSortFilter, { SortValue } from 'components/tables/TableSortFilter' import TableTagFilter from 'components/tables/TableTagFilter' import TableValueFilter from 'components/tables/TableValueFilter' -import TagValues from 'components/tags/TagValues' import TryIt from 'components/TryIt' import Utils from 'common/utils/utils' // @ts-ignore import _data from 'common/data/base/_data' -import classNames from 'classnames' import { removeIdentity } from './UsersPage' import { isEqual } from 'lodash' import ClearFilters from 'components/ClearFilters' -import SegmentsIcon from 'components/svg/SegmentsIcon' -import UsersIcon from 'components/svg/UsersIcon' import IdentityTraits from 'components/IdentityTraits' import { useGetIdentitySegmentsQuery } from 'common/services/useIdentitySegment' import useDebouncedSearch from 'common/useDebouncedSearch' +import FeatureOverrideRow from 'components/FeatureOverrideRow' +import featureValuesEqual from 'common/featureValuesEqual' -const width = [200, 48, 78] - -const valuesEqual = (actualValue: any, flagValue: any) => { - const nullFalseyA = - actualValue == null || - actualValue === '' || - typeof actualValue === 'undefined' - const nullFalseyB = - flagValue == null || flagValue === '' || typeof flagValue === 'undefined' - return nullFalseyA && nullFalseyB ? true : actualValue === flagValue -} interface RouteParams { environmentId: string projectId: string @@ -142,17 +127,17 @@ const UserPage: FC = () => { const [preselect, setPreselect] = useState(Utils.fromParam().flag) const [segmentsPage, setSegmentsPage] = useState(1) const { search, searchInput, setSearchInput } = useDebouncedSearch('') - const { - data: segments, - isFetching: isFetchingSegments, - refetch: refetchIdentitySegments, - } = useGetIdentitySegmentsQuery({ - identity: id, - page: segmentsPage, - page_size: 10, - projectId, - q: search, - }) + const { data: segments, isFetching: isFetchingSegments } = + useGetIdentitySegmentsQuery( + { + identity: id, + page: segmentsPage, + page_size: 10, + projectId: `${projectId}`, + q: search, + }, + { skip: !projectId }, + ) const getFilter = useCallback( (filter) => ({ ...filter, @@ -212,7 +197,7 @@ const UserPage: FC = () => { segment={segment.id} readOnly environmentId={environmentId} - projectId={projectId} + projectId={projectId!} />, 'side-modal create-segment-modal', ) @@ -439,7 +424,7 @@ const UserPage: FC = () => { )} { /> { @@ -553,10 +537,7 @@ const UserPage: FC = () => { } isLoading={FeatureListStore.isLoading} items={projectFlags} - renderRow={( - { description, id: featureId, name, tags }, - i, - ) => { + renderRow={({ id: featureId, name, tags }, i) => { return ( { : actualEnabled !== flagEnabled const flagValueDifferent = hasUserOverride ? false - : !valuesEqual(actualValue, flagValue) + : !featureValuesEqual( + actualValue, + flagValue, + ) const projectFlag = projectFlags?.find( (p: any) => p.id === environmentFlag.feature, @@ -624,230 +608,30 @@ const UserPage: FC = () => { } } - const isCompact = getViewMode() === 'compact' if (name === preselect && actualFlags) { setPreselect(null) onClick() } return ( -
- - - - - - - {description ? ( - {name} - } - > - {description} - - ) : ( - name - )} - - - - - - {hasUserOverride ? ( -
- - This feature is being overridden - for this identity -
- ) : flagEnabledDifferent ? ( -
- - - {isMultiVariateOverride ? ( - - This flag is being - overridden by a - variation defined on - your feature, the - control value is{' '} - - {flagEnabled - ? 'on' - : 'off'} - {' '} - for this user - - ) : ( - - - {`This flag is being overridden by a segment and would normally be`} -
- {flagEnabled - ? 'on' - : 'off'} -
{' '} - for this user -
- )} -
-
-
- ) : flagValueDifferent ? ( - isMultiVariateOverride ? ( -
- - This feature is being - overridden by a % variation - in the environment, the - control value of this - feature is{' '} - - -
- ) : ( - - - {`This feature is being - overridden by a segment - and would normally be`} - {' '} - for this user - - ) - ) : ( - getViewMode() === 'default' && ( -
- Using environment defaults -
- ) - )} -
-
-
-
- -
-
e.stopPropagation()} - > - {Utils.renderWithPermission( - permission, - Constants.environmentPermissions( - Utils.getManageFeaturePermissionDescription( - false, - true, - ), - ), - - confirmToggle( - projectFlag, - actualFlags![name], - () => - toggleFlag({ - environmentFlag: - actualFlags![name], - environmentId, - identity: id, - identityFlag, - projectFlag: { - id: featureId, - }, - }), - ) - } - />, - )} -
-
e.stopPropagation()} - > - {hasUserOverride && ( + dataTest={`user-feature-${i}`} + overrideFeatureState={actualFlags?.[name]} + cta={ + hasUserOverride && ( <> {Utils.renderWithPermission( permission, @@ -882,9 +666,31 @@ const UserPage: FC = () => { , )} - )} -
-
+ ) + } + environmentFeatureState={ + environmentFlags[featureId] + } + onToggle={() => + confirmToggle( + projectFlag, + actualFlags![name], + () => + toggleFlag({ + environmentFeatureState: + actualFlags![name], + environmentId, + identity: id, + identityFlag, + projectFlag: { + id: featureId, + }, + }), + ) + } + hasUserOverride={!!hasUserOverride} + hasSegmentOverride={hasSegmentOverride} + /> ) }}
@@ -931,7 +737,7 @@ const UserPage: FC = () => { {!preventAddTrait && ( @@ -949,9 +755,7 @@ const UserPage: FC = () => { isLoading={isFetchingSegments} search={searchInput} onChange={(e) => { - setSearchInput( - Utils.safeParseEventValue(e), - ) + setSearchInput(Utils.safeParseEventValue(e)) }} itemHeight={70} paging={segments} diff --git a/frontend/web/components/tables/TableGroupsFilter.tsx b/frontend/web/components/tables/TableGroupsFilter.tsx index 86b864b92f5f..40b466500b83 100644 --- a/frontend/web/components/tables/TableGroupsFilter.tsx +++ b/frontend/web/components/tables/TableGroupsFilter.tsx @@ -9,7 +9,6 @@ type TableFilterType = { onChange: (value: TableFilterType['value']) => void className?: string isLoading?: boolean - projectId: string orgId: string | undefined } @@ -18,7 +17,6 @@ const TableGroupsFilter: FC = ({ isLoading, onChange, orgId, - projectId, value, }) => { const { data } = useGetGroupSummariesQuery( diff --git a/frontend/web/components/tables/TableTagFilter.tsx b/frontend/web/components/tables/TableTagFilter.tsx index 21b9c9688dbc..db384039408c 100644 --- a/frontend/web/components/tables/TableTagFilter.tsx +++ b/frontend/web/components/tables/TableTagFilter.tsx @@ -10,7 +10,7 @@ import { TagStrategy } from 'common/types/responses' import TagContent from 'components/tags/TagContent' type TableFilterType = { - projectId: string + projectId: string | number value: (number | string)[] | undefined isLoading: boolean onChange: (value: (number | string)[], isAutomatedChange?: boolean) => void @@ -33,7 +33,10 @@ const TableTagFilter: FC = ({ value, }) => { const [filter, setFilter] = useState('') - const { data } = useGetTagsQuery({ projectId }) + const { data } = useGetTagsQuery( + { projectId: `${projectId}` }, + { skip: !projectId }, + ) const isFeatureHealthEnabled = Utils.getFlagsmithHasFeature('feature_health') const flagGatedTags = useMemo(() => { From c953bec954d2235bc94d8612f0c5e4700c55a122 Mon Sep 17 00:00:00 2001 From: Kyle Johnson Date: Tue, 26 Aug 2025 17:43:02 +0100 Subject: [PATCH 005/108] Update frontend/web/components/Breadcrumb.tsx Co-authored-by: Zaimwa9 --- frontend/web/components/Breadcrumb.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/web/components/Breadcrumb.tsx b/frontend/web/components/Breadcrumb.tsx index 4e88ec9e6788..cc4434e8b0ba 100644 --- a/frontend/web/components/Breadcrumb.tsx +++ b/frontend/web/components/Breadcrumb.tsx @@ -9,7 +9,7 @@ type BreadcrumbType = { const Breadcrumb: FC = ({ currentPage, items }) => { return (
) diff --git a/frontend/web/components/pages/UserPage.tsx b/frontend/web/components/pages/UserPage.tsx index fb0796409746..588ac5db1838 100644 --- a/frontend/web/components/pages/UserPage.tsx +++ b/frontend/web/components/pages/UserPage.tsx @@ -36,7 +36,7 @@ import JSONReference from 'components/JSONReference' import PageTitle from 'components/PageTitle' import Panel from 'components/base/grid/Panel' import PanelSearch from 'components/PanelSearch' -import Permission from 'common/providers/Permission' +import Permission, { useHasPermission } from 'common/providers/Permission' // @ts-ignore import Project from 'common/project' import TableFilterOptions from 'components/tables/TableFilterOptions' @@ -203,68 +203,9 @@ const UserPage: FC = () => { ) } - const confirmToggle = (projectFlag: any, environmentFlag: any, cb: any) => { - openModal( - 'Toggle Feature', - , - 'p-0', - ) - } - const editFeature = ( - projectFlag: ProjectFlag, - environmentFlag: FeatureState, - identityFlag: IdentityFeatureState, - multivariate_feature_state_values: IdentityFeatureState['multivariate_feature_state_values'], - ) => { - history.replace(`${document.location.pathname}?flag=${projectFlag.name}`) - API.trackEvent(Constants.events.VIEW_USER_FEATURE) - openModal( - - - Edit User Feature:{' '} - {projectFlag.name} - - - , - , - 'side-modal create-feature-modal overflow-y-auto', - () => { - history.replace(document.location.pathname) - }, - ) - } - const preventAddTrait = !AccountStore.getOrganisation().persist_trait_data const isEdge = Utils.getIsEdge() const showAliases = isEdge && Utils.getFlagsmithHasFeature('identity_aliases') - const clearFilters = () => { history.replace(`${document.location.pathname}`) setFilter(getFiltersFromParams({})) @@ -274,29 +215,28 @@ const UserPage: FC = () => {
- {( - { - environmentFlags, - identity, - identityFlags, - isLoading, - projectFlags, - traits, - }: { - environmentFlags: FeatureState[] - identity: { identity: Identity; identifier: string } - identityFlags: IdentityFeatureState[] - isLoading: boolean - projectFlags: ProjectFlag[] - traits: IdentityTrait[] - }, - { toggleFlag }: any, - ) => - isLoading && - !filter.tags.length && - !filter.is_archived && - typeof filter.search !== 'string' && - (!identityFlags || !actualFlags || !projectFlags) ? ( + {({ + environmentFlags, + identity, + identityFlags, + isLoading, + projectFlags, + }: { + environmentFlags: FeatureState[] + identity: { identity: Identity; identifier: string } + identityFlags: IdentityFeatureState[] + isLoading: boolean + projectFlags: ProjectFlag[] + traits: IdentityTrait[] + }) => { + const identityName = + (identity && identity.identity.identifier) || id + + return isLoading && + !filter.tags.length && + !filter.is_archived && + typeof filter.search !== 'string' && + (!identityFlags || !actualFlags || !projectFlags) ? (
@@ -306,11 +246,7 @@ const UserPage: FC = () => { title={
- + {showAliases && (
{ onClick={() => { removeIdentity( id, - (identity && identity.identity.identifier) || id, + identityName, environmentId, () => { history.replace( @@ -538,162 +474,38 @@ const UserPage: FC = () => { isLoading={FeatureListStore.isLoading} items={projectFlags} renderRow={({ id: featureId, name, tags }, i) => { + const identityFlag = identityFlags[featureId] || {} + const actualEnabled = + actualFlags && actualFlags[name]?.enabled + const environmentFlag = + (environmentFlags && + environmentFlags[featureId]) || + {} + const projectFlag = projectFlags?.find( + (p: any) => p.id === environmentFlag.feature, + ) return ( - - {({ permission }) => { - const identityFlag = - identityFlags[featureId] || {} - const environmentFlag = - (environmentFlags && - environmentFlags[featureId]) || - {} - const hasUserOverride = - identityFlag.identity || - identityFlag.identity_uuid - const flagEnabled = hasUserOverride - ? identityFlag.enabled - : environmentFlag.enabled - const flagValue = hasUserOverride - ? identityFlag.feature_state_value - : environmentFlag.feature_state_value - const actualEnabled = - actualFlags && actualFlags[name]?.enabled - const actualValue = - actualFlags && - actualFlags[name]?.feature_state_value - const flagEnabledDifferent = hasUserOverride - ? false - : actualEnabled !== flagEnabled - const flagValueDifferent = hasUserOverride - ? false - : !featureValuesEqual( - actualValue, - flagValue, - ) - const projectFlag = projectFlags?.find( - (p: any) => - p.id === environmentFlag.feature, - ) - const isMultiVariateOverride = - flagValueDifferent && - projectFlag?.multivariate_options?.find( - (v: any) => - Utils.featureStateToValue(v) === - actualValue, - ) - - const hasSegmentOverride = - (flagEnabledDifferent || - flagValueDifferent) && - !hasUserOverride && - !isMultiVariateOverride - - const onClick = () => { - if (permission) { - editFeature( - projectFlag!, - environmentFlags[featureId], - identityFlags[featureId] || - actualFlags![name], - identityFlags[featureId] - ?.multivariate_feature_state_values, - ) - } + !!projectFlag && ( + - {Utils.renderWithPermission( - permission, - Constants.environmentPermissions( - Utils.getManageFeaturePermissionDescription( - false, - true, - ), - ), - , - )} - - ) - } - environmentFeatureState={ - environmentFlags[featureId] - } - onToggle={() => - confirmToggle( - projectFlag, - actualFlags![name], - () => - toggleFlag({ - environmentFlag: - actualFlags![name], - environmentId, - identity: id, - identityFlag, - projectFlag: { - id: featureId, - }, - }), - ) - } - hasUserOverride={!!hasUserOverride} - hasSegmentOverride={hasSegmentOverride} - /> - ) - }} - + /> + ) ) }} renderSearchWithNoResults @@ -830,7 +642,7 @@ const UserPage: FC = () => { title='Managing user traits and segments' snippets={Constants.codeHelp.USER_TRAITS( environmentId, - identity?.identifier, + identityName, )} /> @@ -838,14 +650,14 @@ const UserPage: FC = () => {
) - } + }}
From 9d6f43af7e6b7d106357f42b2e17cade601c9268 Mon Sep 17 00:00:00 2001 From: kyle-ssg Date: Tue, 30 Sep 2025 18:35:52 +0100 Subject: [PATCH 024/108] Refactor --- frontend/common/utils/utils.tsx | 5 ++--- .../web/components/feature-override/FeatureOverrideRow.tsx | 1 - 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/frontend/common/utils/utils.tsx b/frontend/common/utils/utils.tsx index 524dd5dc48e0..e5092720fd72 100644 --- a/frontend/common/utils/utils.tsx +++ b/frontend/common/utils/utils.tsx @@ -10,15 +10,15 @@ import { MultivariateFeatureStateValue, MultivariateOption, Organisation, + PConfidence, Project as ProjectType, ProjectFlag, SegmentCondition, Tag, - PConfidence, UserPermissions, } from 'common/types/responses' import flagsmith from 'flagsmith' -import { ReactNode, useMemo } from 'react' +import { ReactNode } from 'react' import _ from 'lodash' import ErrorMessage from 'components/ErrorMessage' import WarningMessage from 'components/WarningMessage' @@ -30,7 +30,6 @@ import { getStore } from 'common/store' import { TRACKED_UTMS, UtmsType } from 'common/types/utms' import { TimeUnit } from 'components/release-pipelines/constants' import getUserDisplayName from './getUserDisplayName' -import { useHasPermission } from 'common/providers/Permission' const semver = require('semver') diff --git a/frontend/web/components/feature-override/FeatureOverrideRow.tsx b/frontend/web/components/feature-override/FeatureOverrideRow.tsx index b5d98f125c38..2c9b428841ec 100644 --- a/frontend/web/components/feature-override/FeatureOverrideRow.tsx +++ b/frontend/web/components/feature-override/FeatureOverrideRow.tsx @@ -20,7 +20,6 @@ import Utils from 'common/utils/utils' import featureValuesEqual from 'common/featureValuesEqual' import { getViewMode } from 'common/useViewMode' import { useHasPermission } from 'common/providers/Permission' -// import FeatureOverrideCTA from './FeatureOverrideCTA' import API from 'project/api' import Constants from 'common/constants' import Button from 'components/base/forms/Button' From 661872ff3cdb230f5856b9d81b033c52d6da09c9 Mon Sep 17 00:00:00 2001 From: kyle-ssg Date: Tue, 30 Sep 2025 18:39:00 +0100 Subject: [PATCH 025/108] Permission fix --- .../components/feature-override/FeatureOverrideCTA.tsx | 10 ++++++++-- .../components/feature-override/FeatureOverrideRow.tsx | 2 +- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/frontend/web/components/feature-override/FeatureOverrideCTA.tsx b/frontend/web/components/feature-override/FeatureOverrideCTA.tsx index 5930636c96ea..1e2a59171a7c 100644 --- a/frontend/web/components/feature-override/FeatureOverrideCTA.tsx +++ b/frontend/web/components/feature-override/FeatureOverrideCTA.tsx @@ -8,6 +8,7 @@ import { IdentityFeatureState, ProjectFlag, } from 'common/types/responses' +import { useHasPermission } from 'common/providers/Permission' type FeatureOverrideCTAType = { level: 'identity' | 'segment' @@ -31,6 +32,11 @@ const FeatureOverrideCTA: FC = ({ }) => { const { permission, permissionDescription } = Utils.getOverridePermission(level) + const { permission: hasPermission } = useHasPermission({ + id: environmentId, + level: 'environment', + permission, + }) switch (level) { case 'identity': { if (!hasUserOverride) { @@ -40,12 +46,12 @@ const FeatureOverrideCTA: FC = ({ hasUserOverride && ( <> {Utils.renderWithPermission( - permission, + hasPermission, permissionDescription, , - )} - - ) + <> + {Utils.renderWithPermission( + hasPermission, + Constants.environmentPermissions(permissionDescription), + , + )} + ) } default: diff --git a/frontend/web/components/feature-override/FeatureOverrideRow.tsx b/frontend/web/components/feature-override/FeatureOverrideRow.tsx index c40b908dff1f..7ff0cf0a9484 100644 --- a/frontend/web/components/feature-override/FeatureOverrideRow.tsx +++ b/frontend/web/components/feature-override/FeatureOverrideRow.tsx @@ -36,6 +36,7 @@ type FeatureOverrideRowProps = { shouldPreselect?: boolean level: 'identity' | 'segment' dataTest: string + environmentId: string identity?: string identifier?: string identityName?: string @@ -49,6 +50,7 @@ type FeatureOverrideRowProps = { const FeatureOverrideRow: FC = ({ dataTest, environmentFeatureState, + environmentId, identifier, identity, identityName, @@ -66,11 +68,7 @@ const FeatureOverrideRow: FC = ({ const viewMode = getViewMode() const isCompact = viewMode === 'compact' const history = useHistory() - const environmentId = ( - ProjectStore.getEnvironmentById( - environmentFeatureState.environment, - ) as unknown as Environment - )?.api_key + const { description, id: featureId, name, project: projectId } = projectFlag const flagEnabled = environmentFeatureState.enabled const flagValue = environmentFeatureState.feature_state_value diff --git a/frontend/web/components/pages/UserPage.tsx b/frontend/web/components/pages/UserPage.tsx index 43700cdadca1..8a25fa891c34 100644 --- a/frontend/web/components/pages/UserPage.tsx +++ b/frontend/web/components/pages/UserPage.tsx @@ -118,7 +118,6 @@ const UserPage: FC = () => { const environmentId = match?.params?.environmentId const id = match?.params?.id - const identity = match?.params?.identity const { projectId } = useRouteContext() const [filter, setFilter] = useState(defaultState) @@ -488,6 +487,7 @@ const UserPage: FC = () => { !!projectFlag && ( Date: Tue, 7 Oct 2025 13:10:44 +0100 Subject: [PATCH 034/108] Make filtering consistent across user and features page --- .../feature-page/FeatureFilters.tsx | 107 ++++- frontend/web/components/pages/FeaturesPage.js | 158 ++----- frontend/web/components/pages/UserPage.tsx | 403 +++++++----------- .../web/components/tables/TableSortFilter.tsx | 2 +- 4 files changed, 283 insertions(+), 387 deletions(-) diff --git a/frontend/web/components/feature-page/FeatureFilters.tsx b/frontend/web/components/feature-page/FeatureFilters.tsx index b5c57757369b..5e7b44bb2a10 100644 --- a/frontend/web/components/feature-page/FeatureFilters.tsx +++ b/frontend/web/components/feature-page/FeatureFilters.tsx @@ -6,28 +6,25 @@ import TableValueFilter from 'components/tables/TableValueFilter' import TableOwnerFilter from 'components/tables/TableOwnerFilter' import TableGroupsFilter from 'components/tables/TableGroupsFilter' import TableFilterOptions from 'components/tables/TableFilterOptions' -import TableSortFilter from 'components/tables/TableSortFilter' +import TableSortFilter, { SortValue } from 'components/tables/TableSortFilter' import { getViewMode, setViewMode, ViewMode } from 'common/useViewMode' import { isEqual } from 'lodash' import Utils from 'common/utils/utils' import { TagStrategy } from 'common/types/responses' +import Format from 'common/utils/format' -type SortState = { - label: string - sortBy: string - sortOrder: 'asc' | 'desc' | null -} - -type FiltersValue = { +export type FiltersValue = { search: string | null + releasePipelines: number[] + page: number tag_strategy: TagStrategy tags: (number | string)[] - showArchived: boolean + is_archived: boolean value_search: string | null is_enabled: boolean | null owners: number[] group_owners: number[] - sort: SortState + sort: SortValue } type Props = { @@ -40,21 +37,102 @@ type Props = { const DEFAULTS: FiltersValue = { group_owners: [], + is_archived: false, is_enabled: null, owners: [], + page: 1, + releasePipelines: [], search: '', - showArchived: false, sort: { label: 'Name', sortBy: 'name', sortOrder: 'asc' }, tag_strategy: 'INTERSECTION', tags: [], value_search: '', } +const sortToHeader = (s: any) => { + if (!s) return { label: 'Name', sortBy: 'name', sortOrder: 'asc' as const } + if ('sortBy' in s) return s + return { + label: s.label || 'Name', + sortBy: s.value || 'name', + sortOrder: (s.order as 'asc' | 'desc') || 'asc', + } +} + +// Converts filters to url params, excluding ones that are already default +export function getURLParamsFromFilters(f: FiltersValue) { + const existing = Utils.fromParam() as Record + + return { + ...existing, + group_owners: f.group_owners?.length ? f.group_owners.join(',') : undefined, + is_archived: f.is_archived ? 'true' : undefined, + is_enabled: + f.is_enabled === null ? undefined : f.is_enabled ? 'true' : 'false', + owners: f.owners?.length ? f.owners.join(',') : undefined, + page: f.page !== DEFAULTS.page ? String(f.page) : undefined, + search: f.search || undefined, + tag_strategy: + f.tag_strategy !== DEFAULTS.tag_strategy + ? String(f.tag_strategy) + : undefined, + tags: f.tags?.length ? f.tags.join(',') : undefined, + value_search: f.value_search || undefined, + } +} +// Gets expected filters from URL parameters +export const getFiltersFromURLParams = ( + params: Record, +) => { + return { + group_owners: + typeof params.group_owners === 'string' + ? params.group_owners.split(',').map((v) => parseInt(v)) + : [], + is_archived: params.is_archived === 'true', + is_enabled: + params.is_enabled === 'true' + ? true + : params.is_enabled === 'false' + ? false + : null, + owners: + typeof params.owners === 'string' + ? params.owners.split(',').map((v) => parseInt(v)) + : [], + page: params.page ? parseInt(params.page) - 1 : 1, + releasePipelines: [], + search: params.search || '', + sort: { + label: Format.camelCase(params.sortBy || 'Name'), + sortBy: params.sortBy || 'name', + sortOrder: params.sortOrder || 'asc', + }, + tag_strategy: params.tag_strategy || 'INTERSECTION', + tags: + typeof params.tags === 'string' + ? params.tags.split(',').map((v) => parseInt(v)) + : [], + value_search: + typeof params.value_search === 'string' ? params.value_search : '', + } as FiltersValue +} + +//Converts filter to api expected properties +export const getServerFilter = (f: FiltersValue) => ({ + ...f, + group_owners: f.group_owners?.length ? f.group_owners : undefined, + owners: f.owners.length ? f.owners : undefined, + search: (f.search || '').trim(), + sort: sortToHeader(f.sort), + tags: f.tags.length ? f.tags.join(',') : undefined, +}) + +//Detect if the filter is default const isDefault = (v: FiltersValue) => isEqual( { ...v, - search: v.search || '', }, DEFAULTS, ) @@ -69,7 +147,6 @@ const FeatureFilters: React.FC = ({ const set = (partial: Partial) => onChange({ ...value, ...partial }) const clearAll = () => onChange({ ...DEFAULTS }) - return (
@@ -86,8 +163,8 @@ const FeatureFilters: React.FC = ({ tagStrategy={value.tag_strategy} onChangeStrategy={(tag_strategy) => set({ tag_strategy })} value={value.tags} - onToggleArchived={(next) => set({ showArchived: next })} - showArchived={value.showArchived} + onToggleArchived={(next) => set({ is_archived: next })} + showArchived={value.is_archived} onChange={(tags) => { if (tags.includes('') && tags.length > 1) { if (!(value.tags || []).includes('')) set({ tags: [''] }) diff --git a/frontend/web/components/pages/FeaturesPage.js b/frontend/web/components/pages/FeaturesPage.js index 63ab534eb8e5..4a3ad9f75046 100644 --- a/frontend/web/components/pages/FeaturesPage.js +++ b/frontend/web/components/pages/FeaturesPage.js @@ -11,71 +11,39 @@ import Constants from 'common/constants' import PageTitle from 'components/PageTitle' import { rocket } from 'ionicons/icons' import { IonIcon } from '@ionic/react' -import Format from 'common/utils/format' import EnvironmentDocumentCodeHelp from 'components/EnvironmentDocumentCodeHelp' import classNames from 'classnames' import Button from 'components/base/forms/Button' -import { isEqual } from 'lodash' import EnvironmentMetricsList from 'components/metrics/EnvironmentMetricsList' import { withRouter } from 'react-router-dom' import { useRouteContext } from 'components/providers/RouteContext' -import FeatureFilters from 'components/feature-page/FeatureFilters' +import FeatureFilters, { + getFiltersFromURLParams, + getServerFilter, + getURLParamsFromFilters, +} from 'components/feature-page/FeatureFilters' const FeaturesPage = class extends Component { static displayName = 'FeaturesPage' - getFiltersFromParams = (params) => { - return { - group_owners: - typeof params.group_owners === 'string' - ? params.group_owners.split(',').map((v) => parseInt(v)) - : [], - is_enabled: - params.is_enabled === 'true' - ? true - : params.is_enabled === 'false' - ? false - : null, - loadedOnce: false, - owners: - typeof params.owners === 'string' - ? params.owners.split(',').map((v) => parseInt(v)) - : [], - page: params.page ? parseInt(params.page) - 1 : 1, - releasePipelines: [], - search: params.search || null, - showArchived: params.is_archived === 'true', - sort: { - label: Format.camelCase(params.sortBy || 'Name'), - sortBy: params.sortBy || 'name', - sortOrder: params.sortOrder || 'asc', - }, - tag_strategy: params.tag_strategy || 'INTERSECTION', - tags: - typeof params.tags === 'string' - ? params.tags.split(',').map((v) => parseInt(v)) - : [], - value_search: - typeof params.value_search === 'string' ? params.value_search : '', - } - } - constructor(props) { super(props) this.state = { - ...this.getFiltersFromParams(Utils.fromParam()), + filters: getFiltersFromURLParams(Utils.fromParam()), forceMetricsRefetch: false, + loadedOnce: false, } ES6Component(this) this.projectId = this.props.routeContext.projectId + const { filters } = this.state AppActions.getFeatures( this.projectId, this.props.match.params.environmentId, true, - this.state.search, - this.state.sort, - this.state.page, - this.getFilter(), + filters.search, + filters.sort, + filters.page, + getServerFilter(filters), ) } @@ -90,8 +58,7 @@ const FeaturesPage = class extends Component { params.environmentId !== oldParams.environmentId || params.projectId !== oldParams.projectId ) { - this.state.loadedOnce = false - this.filter() + this.setState({ loadedOnce: false }, () => this.filter()) } } @@ -128,33 +95,6 @@ const FeaturesPage = class extends Component { })) } - getURLParams = () => ({ - ...this.getFilter(), - group_owners: (this.state.group_owners || [])?.join(',') || undefined, - owners: (this.state.owners || [])?.join(',') || undefined, - page: this.state.page || 1, - search: this.state.search || '', - sortBy: this.state.sort.sortBy, - sortOrder: this.state.sort.sortOrder, - tags: (this.state.tags || [])?.join(',') || undefined, - }) - - getFilter = () => ({ - group_owners: this.state.group_owners?.length - ? this.state.group_owners - : undefined, - is_archived: this.state.showArchived, - is_enabled: - this.state.is_enabled === null ? undefined : this.state.is_enabled, - owners: this.state.owners?.length ? this.state.owners : undefined, - tag_strategy: this.state.tag_strategy, - tags: - !this.state.tags || !this.state.tags.length - ? undefined - : this.state.tags.join(','), - value_search: this.state.value_search ? this.state.value_search : undefined, - }) - onError = (error) => { if (!error?.name && !error?.initial_value) { toast( @@ -167,10 +107,17 @@ const FeaturesPage = class extends Component { filter = (page) => { const currentParams = Utils.fromParam() - this.setState({ page }, () => { + const nextFilters = + typeof page === 'number' + ? { ...this.state.filters, page } + : this.state.filters + this.setState({ filters: nextFilters }, () => { + const f = this.state.filters if (!currentParams.feature) { this.props.history.replace( - `${document.location.pathname}?${Utils.toParam(this.getURLParams())}`, + `${document.location.pathname}?${Utils.toParam( + getURLParamsFromFilters(this.state.filters), + )}`, ) } if (page) { @@ -178,19 +125,19 @@ const FeaturesPage = class extends Component { this.projectId, this.props.match.params.environmentId, true, - this.state.search, - this.state.sort, + f.search, + f.sort, page, - this.getFilter(), + getServerFilter(f), ) } else { AppActions.searchFeatures( this.projectId, this.props.match.params.environmentId, true, - this.state.search, - this.state.sort, - this.getFilter(), + f.search, + f.sort, + getServerFilter(f), ) } }) @@ -224,27 +171,6 @@ const FeaturesPage = class extends Component { ) const environment = ProjectStore.getEnvironment(environmentId) - const params = Utils.fromParam() - - const hasFilters = !isEqual( - this.getFiltersFromParams({ ...params, page: '1' }), - this.getFiltersFromParams({ page: '1' }), - ) - const clearFilters = () => { - this.props.history.replace(`${document.location.pathname}`) - const newState = this.getFiltersFromParams({}) - this.setState(newState, () => { - AppActions.getFeatures( - projectId, - this.props.match.params.environmentId, - true, - this.state.search, - this.state.sort, - 1, - this.getFilter(), - ) - }) - } return (
{(isLoading || !this.state.loadedOnce) && @@ -295,9 +217,9 @@ const FeaturesPage = class extends Component { {(!isLoading || this.state.loadedOnce) && (
{this.state.loadedOnce || - ((this.state.showArchived || - typeof this.state.search === 'string' || - !!this.state.tags.length) && + ((this.state.filters.is_archived || + typeof this.state.filters.search === 'string' || + !!this.state.filters.tags.length) && !isLoading) ? (
{featureLimitAlert.percentage && @@ -317,8 +239,8 @@ const FeaturesPage = class extends Component { cta={ <> {this.state.loadedOnce || - this.state.showArchived || - this.state.tags?.length + this.state.filters.is_archived || + this.state.filters.tags?.length ? this.createFeaturePermission((perm) => ( +
+
setHoveredSection('organisation')} + style={{ maxWidth: 'calc(50vw - 10px)', width: 260 }} + > + + navigateOrganisations(e, organisations) + } + onChange={(e: KeyboardEvent) => { + setOrganisationSearch(Utils.safeParseEventValue(e)) + }} + search + inputClassName='border-0 bg-transparent border-bottom-1' + size='xSmall' + className='full-width' + placeholder='Search Organisations...' + /> + { + setHoveredOrganisation(organisation) + setHoveredProject(undefined) + }} + onClick={goOrganisation} + footer={ + Utils.canCreateOrganisation() && ( +
-
setHoveredSection('project')} - style={{ maxWidth: 'calc(50vw)', width: 260 }} - className={classNames( - { - 'bg-faint rounded': hoveredSection === 'organisation', - }, - 'border-left-1', - )} - > - { - setProjectSearch(Utils.safeParseEventValue(e)) }} - autoFocus={focus === 'project'} - onKeyDown={(e: KeyboardEvent) => navigateProjects(e)} - search - className='full-width' - inputClassName='border-0 bg-transparent border-bottom-1' - size='xSmall' - placeholder='Search Projects...' - /> - setHoveredProject(v.id)} - onClick={goProject} - footer={Utils.renderWithPermission( - canCreateProject, - Constants.organisationPermissions( - Utils.getCreateProjectPermissionDescription( - AccountStore.getOrganisation(), - ), - ), - , - )} - /> -
-
- ) - }} - + > + Create Organisation + + ) + } + /> +
+
setHoveredSection('project')} + style={{ maxWidth: 'calc(50vw)', width: 260 }} + className={classNames( + { + 'bg-faint rounded': hoveredSection === 'organisation', + }, + 'border-left-1', + )} + > + { + setProjectSearch(Utils.safeParseEventValue(e)) + }} + autoFocus={focus === 'project'} + onKeyDown={(e: KeyboardEvent) => navigateProjects(e)} + search + className='full-width' + inputClassName='border-0 bg-transparent border-bottom-1' + size='xSmall' + placeholder='Search Projects...' + /> + setHoveredProject(v.id)} + onClick={goProject} + footer={Utils.renderWithPermission( + canCreateProject, + Constants.organisationPermissions( + Utils.getCreateProjectPermissionDescription( + AccountStore.getOrganisation(), + ), + ), + , + )} + /> +
+
)}
diff --git a/frontend/web/components/ChangeRequestConflictCheck.tsx b/frontend/web/components/ChangeRequestConflictCheck.tsx new file mode 100644 index 000000000000..23f4df2a887a --- /dev/null +++ b/frontend/web/components/ChangeRequestConflictCheck.tsx @@ -0,0 +1,105 @@ +import React, { FC, useEffect, useMemo } from 'react' +import { useHasFeatureStateChanges } from 'common/hooks/useHasFeatureStateChanges' +import Checkbox from './base/forms/Checkbox' +import { ChangeSet, FeatureState } from 'common/types/responses' +import WarningMessage from './WarningMessage' +import Format from 'common/utils/format' +import moment from 'moment' + +interface ChangeRequestConflictCheckProps { + action: 'create' | 'publish' + projectId: string | number + environmentId: number + featureId?: number + featureStates?: FeatureState[] + changeSets?: ChangeSet[] + liveFrom?: string | null + ignoreConflicts: boolean + onIgnoreConflictsChange: (value: boolean) => void + onHasChangesChange?: (hasChanges: boolean) => void + onHasWarningChange?: (hasWarning: boolean) => void +} + +/** + * ChangeRequestConflictCheck displays a warning when: + * - Creating a change request with no feature state changes + * - Publishing a change request that is scheduled in the future + */ +const ChangeRequestConflictCheck: FC = ({ + action, + changeSets, + environmentId, + featureId, + featureStates, + ignoreConflicts, + liveFrom, + onHasChangesChange, + onHasWarningChange, + onIgnoreConflictsChange, + projectId, +}) => { + const hasChanges = useHasFeatureStateChanges({ + changeSets, + environmentId, + featureId, + featureStates, + projectId, + }) + + useEffect(() => { + onHasChangesChange?.(hasChanges) + }, [hasChanges, onHasChangesChange]) + + const isScheduledInFuture = useMemo(() => { + return liveFrom ? moment(liveFrom).isAfter(moment()) : false + }, [liveFrom]) + + const hasWarning = useMemo(() => { + return ( + (!hasChanges && action === 'create') || + (isScheduledInFuture && action === 'publish') + ) + }, [hasChanges, action, isScheduledInFuture]) + + useEffect(() => { + onHasWarningChange?.(hasWarning) + }, [hasWarning, onHasWarningChange]) + + if (!hasChanges && action === 'create') { + return ( +
+ + +
+ ) + } + + if (isScheduledInFuture && action === 'publish') { + return ( +
+ + +
+ ) + } + + return null +} + +export default ChangeRequestConflictCheck 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/DateSelect.tsx b/frontend/web/components/DateSelect.tsx index b66e6eaca5fb..d7aa2648f8af 100644 --- a/frontend/web/components/DateSelect.tsx +++ b/frontend/web/components/DateSelect.tsx @@ -29,9 +29,7 @@ const DateSelect: FC = ({ return ( setTouched(true)} renderCustomHeader={({ @@ -134,7 +132,7 @@ const DateSelect: FC = ({ }} open={isOpen} /> - + diff --git a/frontend/web/components/PublishChangeRequestModal.tsx b/frontend/web/components/PublishChangeRequestModal.tsx new file mode 100644 index 000000000000..fc276389ec8e --- /dev/null +++ b/frontend/web/components/PublishChangeRequestModal.tsx @@ -0,0 +1,116 @@ +import React, { FC, ReactNode, useState } from 'react' +import { ChangeRequest, ProjectChangeRequest } from 'common/types/responses' +import ChangeRequestConflictCheck from './ChangeRequestConflictCheck' +import { getChangeRequestLiveDate } from 'common/utils/getChangeRequestLiveDate' + +interface PublishChangeRequestContentProps { + changeRequest: ChangeRequest + projectId: string + environmentId: number + isScheduled?: boolean + scheduledDate?: string + children?: ReactNode + onIgnoreConflictsChange?: (ignoreConflicts: boolean) => void + onHasWarningDetected?: (hasWarning: boolean) => void +} + +export const PublishChangeRequestContent: FC< + PublishChangeRequestContentProps +> = ({ + changeRequest, + children, + environmentId, + isScheduled, + onHasWarningDetected, + onIgnoreConflictsChange, + projectId, + scheduledDate, +}) => { + const [ignoreConflicts, setIgnoreConflicts] = useState(false) + + const featureStates = changeRequest.feature_states + const featureId = + featureStates.length > 0 ? featureStates[0].feature : undefined + + const handleIgnoreConflictsChange = (value: boolean) => { + setIgnoreConflicts(value) + onIgnoreConflictsChange?.(value) + } + + const handleHasWarningChange = (hasWarning: boolean) => { + onHasWarningDetected?.(hasWarning) + } + + return ( +
+
+ Are you sure you want to {isScheduled ? 'schedule' : 'publish'} this + change request + {isScheduled && scheduledDate ? ` for ${scheduledDate}` : ''}? This will + adjust the feature for your environment. +
+ + {children} + + +
+ ) +} + +interface OpenPublishChangeRequestConfirmParams { + changeRequest: ChangeRequest | ProjectChangeRequest + projectId: string + environmentId: number + isScheduled?: boolean + scheduledDate?: string + onYes: (ignoreConflicts: boolean | undefined) => void + children?: ReactNode +} + +export const openPublishChangeRequestConfirm = ({ + changeRequest, + children, + environmentId, + isScheduled, + onYes, + projectId, + scheduledDate, +}: OpenPublishChangeRequestConfirmParams) => { + let ignoreConflicts: boolean | undefined = undefined + let hasWarning = false + + openConfirm({ + body: ( + { + ignoreConflicts = value + }} + onHasWarningDetected={(value) => { + hasWarning = value + }} + > + {children} + + ), + onYes: () => { + onYes(hasWarning ? ignoreConflicts : undefined) + }, + title: 'Publish Change Request', + }) +} diff --git a/frontend/web/components/base/select/multi-select/CustomMultiValue.tsx b/frontend/web/components/base/select/multi-select/CustomMultiValue.tsx index 9b039c6ade51..5b746271f9d6 100644 --- a/frontend/web/components/base/select/multi-select/CustomMultiValue.tsx +++ b/frontend/web/components/base/select/multi-select/CustomMultiValue.tsx @@ -14,6 +14,7 @@ export const CustomMultiValue = ({ borderRadius: '4px', color: 'white', fontSize: '12px', + gap: '4px', maxHeight: '24px', maxWidth: '150px', overflow: 'hidden', diff --git a/frontend/web/components/base/select/multi-select/CustomOption.tsx b/frontend/web/components/base/select/multi-select/CustomOption.tsx index 5c4199f3c95e..ba13db3bcc3b 100644 --- a/frontend/web/components/base/select/multi-select/CustomOption.tsx +++ b/frontend/web/components/base/select/multi-select/CustomOption.tsx @@ -2,6 +2,20 @@ import { OptionProps } from 'react-select/lib/components/Option' import { MultiSelectOption } from './MultiSelect' import Icon from 'components/Icon' import { useEffect, useRef } from 'react' +import { getDarkMode } from 'project/darkMode' + +const getOptionColors = (isFocused: boolean) => { + const isDarkMode = getDarkMode() + + const focusedBgColor = isDarkMode ? '#202839' : '#f0f0f0' + const backgroundColor = isFocused ? focusedBgColor : 'transparent' + const textColor = isDarkMode ? 'white' : 'inherit' + + return { + backgroundColor, + textColor, + } +} export const CustomOption = ({ children, @@ -19,6 +33,8 @@ export const CustomOption = ({ } }, [props.isFocused]) + const { backgroundColor, textColor } = getOptionColors(props.isFocused) + return (
) => { + const isDarkMode = getDarkMode() const { data } = props const selectedOptions = props.getValue() as MultiSelectOption[] const currentIndex = selectedOptions.findIndex( @@ -28,6 +30,7 @@ export const InlineMultiValue = (props: MultiValueProps) => {
= ({ - environmentId, - projectId, - projectName, -}) => { +const ImportPage: FC = ({ projectId, projectName }) => { const history = useHistory() const [LDKey, setLDKey] = useState('') const [importId, setImportId] = useState() diff --git a/frontend/web/components/modals/ChangeRequestModal.tsx b/frontend/web/components/modals/ChangeRequestModal.tsx index ff6a15ccca36..bd9d55843d68 100644 --- a/frontend/web/components/modals/ChangeRequestModal.tsx +++ b/frontend/web/components/modals/ChangeRequestModal.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, FC, useMemo } from 'react' +import React, { FC, useEffect, useMemo, useState } from 'react' import UserSelect from 'components/UserSelect' import OrganisationProvider from 'common/providers/OrganisationProvider' import Button from 'components/base/forms/Button' @@ -12,24 +12,42 @@ import AccountStore from 'common/stores/account-store' import InputGroup from 'components/base/forms/InputGroup' import moment from 'moment' import Utils from 'common/utils/utils' -import { Approval, ChangeRequest, User } from 'common/types/responses' +import { + Approval, + ChangeRequest, + TypedFeatureState, + User, +} from 'common/types/responses' import { Req } from 'common/types/requests' import getUserDisplayName from 'common/utils/getUserDisplayName' +import ChangeRequestConflictCheck from 'components/ChangeRequestConflictCheck' interface ChangeRequestModalProps { changeRequest?: ChangeRequest onSave: ( - data: Omit, + data: Omit & { + ignore_conflicts?: boolean + }, ) => void isScheduledChange?: boolean + showIgnoreConflicts?: boolean showAssignees?: boolean + projectId?: string | number + environmentId?: string | number + featureId?: number + featureStates?: TypedFeatureState[] } const ChangeRequestModal: FC = ({ changeRequest, + environmentId, + featureId, + featureStates, isScheduledChange, onSave, + projectId, showAssignees, + showIgnoreConflicts, }) => { const [approvals, setApprovals] = useState([ ...(changeRequest?.approvals ?? []), @@ -46,6 +64,8 @@ const ChangeRequestModal: FC = ({ const [showUsers, setShowUsers] = useState(false) const [showGroups, setShowGroups] = useState(false) const [currDate, setCurrDate] = useState(new Date()) + const [ignoreConflicts, setIgnoreConflicts] = useState(false) + const [hasChanges, setHasChanges] = useState(true) const { data: groups } = useGetMyGroupsQuery({ orgId: AccountStore.getOrganisation().id, @@ -89,6 +109,7 @@ const ChangeRequestModal: FC = ({ onSave({ approvals, description, + ignore_conflicts: ignoreConflicts, live_from: liveFrom || undefined, title, }) @@ -105,8 +126,11 @@ const ChangeRequestModal: FC = ({ } const isValid = useMemo(() => { + if (!hasChanges && !ignoreConflicts) { + return false + } return !!title?.length && !!liveFrom - }, [title, liveFrom]) + }, [title, liveFrom, hasChanges, ignoreConflicts]) return ( @@ -159,7 +183,6 @@ const ChangeRequestModal: FC = ({ className='ml-2' onClick={handleClear} theme='secondary' - size='large' > Clear @@ -282,6 +305,19 @@ const ChangeRequestModal: FC = ({ size='-sm' /> )} + {!changeRequest && showIgnoreConflicts && ( + + )}
diff --git a/frontend/web/components/pages/ChangeRequestDetailPage.tsx b/frontend/web/components/pages/ChangeRequestDetailPage.tsx index 02fb38f46203..f253399b0125 100644 --- a/frontend/web/components/pages/ChangeRequestDetailPage.tsx +++ b/frontend/web/components/pages/ChangeRequestDetailPage.tsx @@ -15,17 +15,16 @@ import { User, UserGroupSummary, } from 'common/types/responses' -import { RouterChildContext } from 'react-router' import Utils from 'common/utils/utils' import moment from 'moment' import ProjectStore from 'common/stores/project-store' +import { useUpdateChangeRequestMutation } from 'common/services/useChangeRequest' import { useHasPermission } from 'common/providers/Permission' import { IonIcon } from '@ionic/react' import { close } from 'ionicons/icons' import Constants from 'common/constants' import Button from 'components/base/forms/Button' import NewVersionWarning from 'components/NewVersionWarning' -import WarningMessage from 'components/WarningMessage' import Breadcrumb from 'components/Breadcrumb' import PageTitle from 'components/PageTitle' import InfoMessage from 'components/InfoMessage' @@ -39,6 +38,8 @@ import JSONReference from 'components/JSONReference' import ErrorMessage from 'components/ErrorMessage' import ConfigProvider from 'common/providers/ConfigProvider' import { useHistory } from 'react-router-dom' +import { openPublishChangeRequestConfirm } from 'components/PublishChangeRequestModal' +import { getChangeRequestLiveDate } from 'common/utils/getChangeRequestLiveDate' type ChangeRequestPageType = { match: { @@ -54,6 +55,7 @@ const ChangeRequestDetailPage: FC = ({ match }) => { const history = useHistory() const { environmentId, id, projectId } = match.params const [_, setUpdate] = useState(Date.now()) + const [updateChangeRequest] = useUpdateChangeRequestMutation() const error = ChangeRequestStore.error const changeRequest = ( ChangeRequestStore.model as Record | undefined @@ -187,60 +189,49 @@ const ChangeRequestDetailPage: FC = ({ match }) => { AppActions.actionChangeRequest(id, 'approve') } - const getScheduledDate = () => { - if (!changeRequest) return null - return changeRequest.environment_feature_versions.length > 0 - ? moment(changeRequest.environment_feature_versions?.[0]?.live_from) - : changeRequest?.change_sets?.[0]?.live_from - ? moment(changeRequest?.change_sets?.[0]?.live_from) - : moment(changeRequest.feature_states?.[0]?.live_from) - } - const publishChangeRequest = () => { - const scheduledDate = getScheduledDate() + if (!changeRequest) return + + const scheduledDate = getChangeRequestLiveDate(changeRequest) const isScheduled = moment(scheduledDate).valueOf() > moment().valueOf() - const featureId = - changeRequest && - changeRequest.feature_states[0] && - changeRequest.feature_states[0].feature + const featureId = changeRequest.feature_states?.[0]?.feature const environment = ProjectStore.getEnvironment( environmentId, ) as unknown as Environment - openConfirm({ - body: ( -
- Are you sure you want to {isScheduled ? 'schedule' : 'publish'} this - change request - {isScheduled - ? ` for ${moment(scheduledDate).format('Do MMM YYYY hh:mma')}` - : ''} - ? This will adjust the feature for your environment. - - {!!changeRequest?.conflicts?.length && ( -
- - A change request was published since the creation of this - one that also modified this feature. Please review the - changes on this page to make sure they are correct. -
- } - /> -
- )} -
+ + openPublishChangeRequestConfirm({ + changeRequest, + children: ( + ), - onYes: () => { - AppActions.actionChangeRequest(id, 'commit', () => { - AppActions.refreshFeatures(projectId, environmentId) - }) + environmentId: environment.id, + isScheduled, + onYes: (ignore_conflicts) => { + const commitChangeRequest = () => { + AppActions.actionChangeRequest(id, 'commit', () => { + AppActions.refreshFeatures(projectId, environmentId) + }) + } + + if (ignore_conflicts) { + updateChangeRequest({ + ...changeRequest, + ignore_conflicts: true, + }).then(() => { + commitChangeRequest() + }) + } else { + commitChangeRequest() + } }, - title: `${isScheduled ? 'Schedule' : 'Publish'} Change Request`, + projectId, + scheduledDate: isScheduled + ? moment(scheduledDate).format('Do MMM YYYY hh:mma') + : undefined, }) } @@ -275,7 +266,7 @@ const ChangeRequestDetailPage: FC = ({ match }) => { changeRequest.feature_states[0] && changeRequest.feature_states[0].feature - const scheduledDate = getScheduledDate() + const scheduledDate = getChangeRequestLiveDate(changeRequest) const isScheduled = moment(scheduledDate).valueOf() > moment().valueOf() const environment = ProjectStore.getEnvironment( @@ -316,7 +307,7 @@ const ChangeRequestDetailPage: FC = ({ match }) => { publishPermissionDescription={Constants.environmentPermissions( 'Update Feature States', )} - scheduledDate={getScheduledDate()} + scheduledDate={getChangeRequestLiveDate(changeRequest)} deleteChangeRequest={deleteChangeRequest} editChangeRequest={ !isVersioned && !changeRequest?.committed_at diff --git a/frontend/web/components/pages/OrganisationSettingsPage.js b/frontend/web/components/pages/OrganisationSettingsPage.js deleted file mode 100644 index 95ca3e4196f6..000000000000 --- a/frontend/web/components/pages/OrganisationSettingsPage.js +++ /dev/null @@ -1,466 +0,0 @@ -import React, { Component } from 'react' -import ConfirmRemoveOrganisation from 'components/modals/ConfirmRemoveOrganisation' -import Payment from 'components/modals/Payment' -import Button from 'components/base/forms/Button' -import AdminAPIKeys from 'components/AdminAPIKeys' -import Tabs from 'components/navigation/TabMenu/Tabs' -import TabItem from 'components/navigation/TabMenu/TabItem' -import JSONReference from 'components/JSONReference' -import ConfigProvider from 'common/providers/ConfigProvider' -import Constants from 'common/constants' -import Icon from 'components/Icon' -import _data from 'common/data/base/_data' -import AccountStore from 'common/stores/account-store' -import PageTitle from 'components/PageTitle' -import SamlTab from 'components/SamlTab' -import Setting from 'components/Setting' -import AccountProvider from 'common/providers/AccountProvider' -import LicensingTabContent from 'components/LicensingTabContent' -import Utils from 'common/utils/utils' -import AuditLogWebhooks from 'components/modals/AuditLogWebhooks' -import MetadataPage from 'components/metadata/MetadataPage' -import { withRouter } from 'react-router-dom' -import SettingTitle from 'components/SettingTitle' -const SettingsTab = { - 'Billing': 'billing', - 'CustomFields': 'custom-fields', - 'General': 'general', - 'Keys': 'keys', - 'Licensing': 'licensing', - 'SAML': 'saml', - 'Usage': 'usage', - 'Webhooks': 'webhooks', -} - -const OrganisationSettingsPage = class extends Component { - static displayName = 'OrganisationSettingsPage' - - constructor(props) { - super(props) - this.state = { - manageSubscriptionLoaded: true, - permissions: [], - } - if (!AccountStore.getOrganisation()) { - return - } - AppActions.getOrganisation(AccountStore.getOrganisation().id) - - this.getOrganisationPermissions(AccountStore.getOrganisation().id) - } - - componentDidMount = () => { - if (!AccountStore.getOrganisation()) { - return - } - API.trackPage(Constants.pages.ORGANISATION_SETTINGS) - $('body').trigger('click') - } - - onSave = () => { - this.setState({ name: null }, () => { - toast('Saved organisation') - }) - } - - confirmRemove = (organisation, cb) => { - openModal( - 'Delete Organisation', - , - 'p-0', - ) - } - - onRemove = () => { - toast('Your organisation has been removed') - if (AccountStore.getOrganisation()) { - this.props.history.replace(Utils.getOrganisationHomePage()) - } else { - this.props.history.replace('/create') - } - } - - save = (e) => { - e && e.preventDefault() - const { - force_2fa, - name, - restrict_project_create_to_admin, - webhook_notification_email, - } = this.state - if (AccountStore.isSaving) { - return - } - - const org = AccountStore.getOrganisation() - AppActions.editOrganisation({ - force_2fa, - name: name || org.name, - restrict_project_create_to_admin: - typeof restrict_project_create_to_admin === 'boolean' - ? restrict_project_create_to_admin - : undefined, - webhook_notification_email: - webhook_notification_email !== undefined - ? webhook_notification_email - : org.webhook_notification_email, - }) - } - - save2FA = (force_2fa) => { - const { - name, - restrict_project_create_to_admin, - webhook_notification_email, - } = this.state - if (AccountStore.isSaving) { - return - } - - const org = AccountStore.getOrganisation() - AppActions.editOrganisation({ - force_2fa, - name: name || org.name, - restrict_project_create_to_admin: - typeof restrict_project_create_to_admin === 'boolean' - ? restrict_project_create_to_admin - : undefined, - webhook_notification_email: - webhook_notification_email !== undefined - ? webhook_notification_email - : org.webhook_notification_email, - }) - } - - setAdminCanCreateProject = (restrict_project_create_to_admin) => { - this.setState({ restrict_project_create_to_admin }, this.save) - } - - saveDisabled = () => { - const { name, webhook_notification_email } = this.state - if ( - AccountStore.isSaving || - (!name && webhook_notification_email === undefined) - ) { - return true - } - - // Must have name - if (name !== undefined && !name) { - return true - } - - // Must be valid email for webhook notification email - return ( - webhook_notification_email && - !Utils.isValidEmail(webhook_notification_email) - ) - } - - getOrganisationPermissions = (id) => { - if (this.state.permissions.length) return - - const url = `${Project.api}organisations/${id}/my-permissions/` - _data.get(url).then(({ permissions }) => { - this.setState({ permissions }) - }) - } - - render() { - const paymentsEnabled = Utils.getFlagsmithHasFeature('payments_enabled') - - return ( -
- - {({ isSaving, organisation }, { deleteOrganisation }) => - !!organisation && ( - - {({ name, subscriptionMeta }) => { - const isAWS = - AccountStore.getPaymentMethod() === 'AWS_MARKETPLACE' - const { chargebee_email } = subscriptionMeta || {} - - const displayedTabs = [] - //todo: replace with RTK when this is a functional component - const isEnterprise = Utils.isEnterpriseImage() - if ( - AccountStore.getUser() && - AccountStore.getOrganisationRole() === 'ADMIN' - ) { - displayedTabs.push( - ...[ - SettingsTab.General, - paymentsEnabled && !isAWS ? SettingsTab.Billing : null, - isEnterprise ? SettingsTab.Licensing : null, - SettingsTab.CustomFields, - SettingsTab.Keys, - SettingsTab.Webhooks, - SettingsTab.SAML, - ].filter((v) => !!v), - ) - } else { - return ( -
- You do not have permission to view this page -
- ) - } - return ( -
- - - {displayedTabs.includes(SettingsTab.General) && ( - -
- - Organisation Information - - -
-
- - - (this.input = e)} - data-test='organisation-name' - value={ - this.state.name || organisation.name - } - onChange={(e) => - this.setState({ - name: Utils.safeParseEventValue(e), - }) - } - isValid={name && name.length} - type='text' - inputClassName='input--wide' - placeholder='My Organisation' - title='Organisation Name' - inputProps={{ - className: 'full-width', - }} - /> - - - -
- Admin Settings - - {Utils.getFlagsmithHasFeature( - 'restrict_project_create_to_admin', - ) && ( - - - this.setAdminCanCreateProject( - !organisation.restrict_project_create_to_admin, - ) - } - description={ - 'Only allow organisation admins to create projects' - } - /> - - )} -
- - Delete Organisation - - -
-

- This organisation will be permanently - deleted, along with all projects and - features. -

-
- -
-
-
- )} - {displayedTabs.includes(SettingsTab.Billing) && ( - -
- -
- -
- -
- -
-
-

- Your plan -

-

- {Utils.getPlanName( - _.get( - organisation, - 'subscription.plan', - ), - ) - ? Utils.getPlanName( - _.get( - organisation, - 'subscription.plan', - ), - ) - : 'Free'} -

-
-
-
-
- -
-

- ID -

-
-
-

- Organisation ID -

-

- {organisation.id} -

-
-
-
- {!!chargebee_email && ( -
- -
- -
-
-

- Management Email -

-
- {chargebee_email} -
-
-
-
- )} -
-
-
- {organisation.subscription - ?.subscription_id && ( - - )} -
-
-
Manage Payment Plan
- -
-
- )} - - {displayedTabs.includes(SettingsTab.Licensing) && ( - - - - )} - {displayedTabs.includes(SettingsTab.CustomFields) && ( - - - - )} - {displayedTabs.includes(SettingsTab.Keys) && ( - - - - )} - - {displayedTabs.includes(SettingsTab.Webhooks) && ( - - - - - - )} - {displayedTabs.includes(SettingsTab.SAML) && ( - - - - )} -
-
- ) - }} -
- ) - } -
-
- ) - } -} - -OrganisationSettingsPage.propTypes = {} - -export default withRouter(ConfigProvider(OrganisationSettingsPage)) 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/organisation-settings/OrganisationSettingsPage.tsx b/frontend/web/components/pages/organisation-settings/OrganisationSettingsPage.tsx new file mode 100644 index 000000000000..1fc2f313a199 --- /dev/null +++ b/frontend/web/components/pages/organisation-settings/OrganisationSettingsPage.tsx @@ -0,0 +1,135 @@ +import { FC, ReactNode, useEffect } from 'react' +import { useGetOrganisationQuery } from 'common/services/useOrganisation' +import { useRouteContext } from 'components/providers/RouteContext' +import Utils from 'common/utils/utils' +import PageTitle from 'components/PageTitle' +import Tabs from 'components/navigation/TabMenu/Tabs' +import TabItem from 'components/navigation/TabMenu/TabItem' +import Constants from 'common/constants' +import { GeneralTab } from './tabs/general-tab' +import { BillingTab } from './tabs/BillingTab' +import { LicensingTab } from './tabs/LicensingTab' +import { CustomFieldsTab } from './tabs/CustomFieldsTab' +import { APIKeysTab } from './tabs/APIKeysTab' +import { WebhooksTab } from './tabs/WebhooksTab' +import { SAMLTab } from './tabs/SAMLTab' + +type OrganisationSettingsTab = { + component: ReactNode + isVisible: boolean + key: string + label: ReactNode +} + +const OrganisationSettingsPage: FC = () => { + const { organisationId } = useRouteContext() + const paymentsEnabled = Utils.getFlagsmithHasFeature('payments_enabled') + const isEnterprise = Utils.isEnterpriseImage() + + const { + data: organisation, + error, + isLoading, + } = useGetOrganisationQuery( + { id: organisationId || 0 }, + { skip: !organisationId }, + ) + + useEffect(() => { + API.trackPage(Constants.pages.ORGANISATION_SETTINGS) + }, []) + + if (isLoading) { + return ( +
+ +
+ +
+
+ ) + } + + if (error || !organisation) { + return ( +
+ +
+ Failed to load organisation settings. Please try again. +
+
+ ) + } + + const isAdmin = organisation.role === 'ADMIN' + const isAWS = organisation.subscription?.payment_method === 'AWS_MARKETPLACE' + + if (!isAdmin) { + return ( +
+ +
You do not have permission to view this page
+
+ ) + } + + const tabs: OrganisationSettingsTab[] = [ + { + component: , + isVisible: true, + key: 'general', + label: 'General', + }, + { + component: , + isVisible: paymentsEnabled && !isAWS, + key: 'billing', + label: 'Billing', + }, + { + component: , + isVisible: isEnterprise, + key: 'licensing', + label: 'Licensing', + }, + { + component: , + isVisible: true, + key: 'custom-fields', + label: 'Custom Fields', + }, + { + component: , + isVisible: true, + key: 'keys', + label: 'API Keys', + }, + { + component: , + isVisible: true, + key: 'webhooks', + label: 'Webhooks', + }, + { + component: , + isVisible: true, + key: 'saml', + label: 'SAML', + }, + ].filter(({ isVisible }) => isVisible) + + return ( +
+ + + {tabs.map(({ component, key, label }) => ( + + {component} + + ))} + +
+ ) +} + +export default OrganisationSettingsPage diff --git a/frontend/web/components/pages/organisation-settings/hooks/index.ts b/frontend/web/components/pages/organisation-settings/hooks/index.ts new file mode 100644 index 000000000000..95c1c3d25bc4 --- /dev/null +++ b/frontend/web/components/pages/organisation-settings/hooks/index.ts @@ -0,0 +1,2 @@ +export { useUpdateOrganisationWithToast } from './useUpdateOrganisationWithToast' +export { useDeleteOrganisationWithToast } from './useDeleteOrganisationWithToast' diff --git a/frontend/web/components/pages/organisation-settings/hooks/useDeleteOrganisationWithToast.ts b/frontend/web/components/pages/organisation-settings/hooks/useDeleteOrganisationWithToast.ts new file mode 100644 index 000000000000..973ddbbf0708 --- /dev/null +++ b/frontend/web/components/pages/organisation-settings/hooks/useDeleteOrganisationWithToast.ts @@ -0,0 +1,48 @@ +import { useCallback } from 'react' +import { + useDeleteOrganisationMutation, + useGetOrganisationsQuery, +} from 'common/services/useOrganisation' +import AppActions from 'common/dispatcher/app-actions' + +type DeleteOrganisationOptions = { + successMessage?: string + errorMessage?: string + onError?: (error: unknown) => void + onSuccess?: (nextOrganisationId?: number) => void +} + +export const useDeleteOrganisationWithToast = () => { + const [deleteOrganisation, state] = useDeleteOrganisationMutation() + const { data: organisations } = useGetOrganisationsQuery({}) + + const deleteWithToast = useCallback( + async (organisationId: number, options?: DeleteOrganisationOptions) => { + try { + await deleteOrganisation({ + id: organisationId, + }).unwrap() + + const nextOrgId = organisations?.results?.filter( + (org) => org.id !== organisationId, + )?.[0]?.id + + AppActions.selectOrganisation(nextOrgId) + AppActions.getOrganisation(nextOrgId) + + toast(options?.successMessage || 'Your organisation has been removed') + options?.onSuccess?.(nextOrgId) + } catch (error) { + toast( + options?.errorMessage || + 'Failed to delete organisation. Please try again.', + 'danger', + ) + options?.onError?.(error) + } + }, + [deleteOrganisation, organisations], + ) + + return [deleteWithToast, state] as const +} diff --git a/frontend/web/components/pages/organisation-settings/hooks/useUpdateOrganisationWithToast.ts b/frontend/web/components/pages/organisation-settings/hooks/useUpdateOrganisationWithToast.ts new file mode 100644 index 000000000000..561461ecf577 --- /dev/null +++ b/frontend/web/components/pages/organisation-settings/hooks/useUpdateOrganisationWithToast.ts @@ -0,0 +1,39 @@ +import { useCallback } from 'react' +import { useUpdateOrganisationMutation } from 'common/services/useOrganisation' +import { UpdateOrganisationBody } from 'common/types/requests' + +type UpdateOrganisationOptions = { + successMessage?: string + errorMessage?: string + onError?: (error: unknown) => void +} + +export const useUpdateOrganisationWithToast = () => { + const [updateOrganisation, state] = useUpdateOrganisationMutation() + + const updateWithToast = useCallback( + async ( + body: UpdateOrganisationBody, + organisationId: number, + options?: UpdateOrganisationOptions, + ) => { + try { + await updateOrganisation({ + body, + id: organisationId, + }).unwrap() + toast(options?.successMessage || 'Saved organisation') + } catch (error) { + toast( + options?.errorMessage || + 'Failed to update organisation. Please try again.', + 'danger', + ) + options?.onError?.(error) + } + }, + [updateOrganisation], + ) + + return [updateWithToast, state] as const +} diff --git a/frontend/web/components/pages/organisation-settings/index.ts b/frontend/web/components/pages/organisation-settings/index.ts new file mode 100644 index 000000000000..db528a98d25c --- /dev/null +++ b/frontend/web/components/pages/organisation-settings/index.ts @@ -0,0 +1 @@ +export { default } from './OrganisationSettingsPage' diff --git a/frontend/web/components/pages/organisation-settings/tabs/APIKeysTab.tsx b/frontend/web/components/pages/organisation-settings/tabs/APIKeysTab.tsx new file mode 100644 index 000000000000..7db70e515a2d --- /dev/null +++ b/frontend/web/components/pages/organisation-settings/tabs/APIKeysTab.tsx @@ -0,0 +1,10 @@ +import React from 'react' +import AdminAPIKeys from 'components/AdminAPIKeys' + +type APIKeysTabProps = { + organisationId: number +} + +export const APIKeysTab = ({ organisationId }: APIKeysTabProps) => { + return +} diff --git a/frontend/web/components/pages/organisation-settings/tabs/BillingTab.tsx b/frontend/web/components/pages/organisation-settings/tabs/BillingTab.tsx new file mode 100644 index 000000000000..63dcf9392bc0 --- /dev/null +++ b/frontend/web/components/pages/organisation-settings/tabs/BillingTab.tsx @@ -0,0 +1,81 @@ +import React from 'react' +import { Organisation } from 'common/types/responses' +import Icon from 'components/Icon' +import Utils from 'common/utils/utils' +import Payment from 'components/modals/Payment' +import { useGetSubscriptionMetadataQuery } from 'common/services/useSubscriptionMetadata' + +type BillingTabProps = { + organisation: Organisation +} + +export const BillingTab = ({ organisation }: BillingTabProps) => { + const { data: subscriptionMeta } = useGetSubscriptionMetadataQuery({ + id: String(organisation.id), + }) + + const { chargebee_email } = subscriptionMeta || {} + const planName = Utils.getPlanName(organisation.subscription?.plan) || 'Free' + + return ( +
+ +
+ +
+ +
+ +
+
+

Your plan

+

{planName}

+
+
+
+
+ +
+

+ ID +

+
+
+

Organisation ID

+

{organisation.id}

+
+
+
+ {!!chargebee_email && ( +
+ +
+ +
+
+

Management Email

+
{chargebee_email}
+
+
+
+ )} +
+
+
+ {organisation.subscription?.subscription_id && ( + + )} +
+
+
Manage Payment Plan
+ +
+ ) +} diff --git a/frontend/web/components/pages/organisation-settings/tabs/CustomFieldsTab.tsx b/frontend/web/components/pages/organisation-settings/tabs/CustomFieldsTab.tsx new file mode 100644 index 000000000000..71b967941ecd --- /dev/null +++ b/frontend/web/components/pages/organisation-settings/tabs/CustomFieldsTab.tsx @@ -0,0 +1,10 @@ +import React from 'react' +import MetadataPage from 'components/metadata/MetadataPage' + +type CustomFieldsTabProps = { + organisationId: number +} + +export const CustomFieldsTab = ({ organisationId }: CustomFieldsTabProps) => { + return +} diff --git a/frontend/web/components/pages/organisation-settings/tabs/LicensingTab.tsx b/frontend/web/components/pages/organisation-settings/tabs/LicensingTab.tsx new file mode 100644 index 000000000000..4c586af3413f --- /dev/null +++ b/frontend/web/components/pages/organisation-settings/tabs/LicensingTab.tsx @@ -0,0 +1,10 @@ +import React from 'react' +import LicensingTabContent from 'components/LicensingTabContent' + +type LicensingTabProps = { + organisationId: number +} + +export const LicensingTab = ({ organisationId }: LicensingTabProps) => { + return +} diff --git a/frontend/web/components/pages/organisation-settings/tabs/SAMLTab.tsx b/frontend/web/components/pages/organisation-settings/tabs/SAMLTab.tsx new file mode 100644 index 000000000000..ee55e5ac9e14 --- /dev/null +++ b/frontend/web/components/pages/organisation-settings/tabs/SAMLTab.tsx @@ -0,0 +1,10 @@ +import React from 'react' +import SamlTab from 'components/SamlTab' + +type SAMLTabProps = { + organisationId: number +} + +export const SAMLTab = ({ organisationId }: SAMLTabProps) => { + return +} diff --git a/frontend/web/components/pages/organisation-settings/tabs/WebhooksTab.tsx b/frontend/web/components/pages/organisation-settings/tabs/WebhooksTab.tsx new file mode 100644 index 000000000000..4257cfcfe771 --- /dev/null +++ b/frontend/web/components/pages/organisation-settings/tabs/WebhooksTab.tsx @@ -0,0 +1,14 @@ +import React from 'react' +import AuditLogWebhooks from 'components/modals/AuditLogWebhooks' + +type WebhooksTabProps = { + organisationId: number +} + +export const WebhooksTab = ({ organisationId }: WebhooksTabProps) => { + return ( + + + + ) +} diff --git a/frontend/web/components/pages/organisation-settings/tabs/general-tab/index.tsx b/frontend/web/components/pages/organisation-settings/tabs/general-tab/index.tsx new file mode 100644 index 000000000000..550009975226 --- /dev/null +++ b/frontend/web/components/pages/organisation-settings/tabs/general-tab/index.tsx @@ -0,0 +1,30 @@ +import React from 'react' +import { Organisation } from 'common/types/responses' +import JSONReference from 'components/JSONReference' +import SettingTitle from 'components/SettingTitle' +import { OrganisationInformation } from './sections/OrganisationInformation' +import { Force2FASetting } from './sections/admin-settings/Force2FASetting' +import { RestrictProjectCreationSetting } from './sections/admin-settings/RestrictProjectCreationSetting' +import { DeleteOrganisation } from './sections/DeleteOrganisation' + +type GeneralTabProps = { + organisation: Organisation +} + +export const GeneralTab = ({ organisation }: GeneralTabProps) => { + return ( +
+
+ Organisation Information + + +
+
+ Admin Settings + + +
+ +
+ ) +} diff --git a/frontend/web/components/pages/organisation-settings/tabs/general-tab/sections/DeleteOrganisation.tsx b/frontend/web/components/pages/organisation-settings/tabs/general-tab/sections/DeleteOrganisation.tsx new file mode 100644 index 000000000000..1d8f9fb8eae7 --- /dev/null +++ b/frontend/web/components/pages/organisation-settings/tabs/general-tab/sections/DeleteOrganisation.tsx @@ -0,0 +1,63 @@ +import React from 'react' +import { useHistory } from 'react-router-dom' +import { Organisation } from 'common/types/responses' +import { useDeleteOrganisationWithToast } from 'components/pages/organisation-settings/hooks' +import ConfirmRemoveOrganisation from 'components/modals/ConfirmRemoveOrganisation' +import SettingTitle from 'components/SettingTitle' + +type DeleteOrganisationProps = { + organisation: Organisation +} + +export const DeleteOrganisation = ({ + organisation, +}: DeleteOrganisationProps) => { + const history = useHistory() + const [deleteOrganisationWithToast, { isLoading }] = + useDeleteOrganisationWithToast() + + const handleDelete = () => { + openModal( + 'Delete Organisation', + { + deleteOrganisationWithToast(organisation.id, { + onSuccess: (nextOrgId) => { + if (nextOrgId) { + history.replace(`/organisation/${nextOrgId}/projects`) + } else { + // Redirect to /create when no organisations remain + history.replace('/create') + } + }, + }) + }} + />, + 'p-0', + ) + } + + return ( + <> + Delete Organisation + +
+

+ This organisation will be permanently deleted, along with all + projects and features. +

+
+ +
+ + ) +} diff --git a/frontend/web/components/pages/organisation-settings/tabs/general-tab/sections/OrganisationInformation.tsx b/frontend/web/components/pages/organisation-settings/tabs/general-tab/sections/OrganisationInformation.tsx new file mode 100644 index 000000000000..4ffbe6fe223c --- /dev/null +++ b/frontend/web/components/pages/organisation-settings/tabs/general-tab/sections/OrganisationInformation.tsx @@ -0,0 +1,63 @@ +import React, { FormEvent, useState } from 'react' +import { Organisation } from 'common/types/responses' +import { useUpdateOrganisationWithToast } from 'components/pages/organisation-settings/hooks' +import Utils from 'common/utils/utils' + +type OrganisationInformationProps = { + organisation: Organisation +} + +export const OrganisationInformation = ({ + organisation, +}: OrganisationInformationProps) => { + const [updateOrganisationWithToast, { isLoading: isSaving }] = + useUpdateOrganisationWithToast() + const [name, setName] = useState(organisation.name) + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault() + if (!name || isSaving) return + + await updateOrganisationWithToast({ name }, organisation.id, { + errorMessage: 'Failed to save organisation. Please try again.', + successMessage: 'Saved organisation', + }) + } + + return ( + +
+ + + ) => + setName(Utils.safeParseEventValue(e)) + } + isValid={!!name && name.length > 0} + type='text' + placeholder='My Organisation' + inputProps={{ + className: 'full-width', + }} + /> + + + + +
+
+ ) +} diff --git a/frontend/web/components/pages/organisation-settings/tabs/general-tab/sections/admin-settings/Force2FASetting.tsx b/frontend/web/components/pages/organisation-settings/tabs/general-tab/sections/admin-settings/Force2FASetting.tsx new file mode 100644 index 000000000000..435b4bd4132e --- /dev/null +++ b/frontend/web/components/pages/organisation-settings/tabs/general-tab/sections/admin-settings/Force2FASetting.tsx @@ -0,0 +1,33 @@ +import React from 'react' +import Setting from 'components/Setting' +import { Organisation } from 'common/types/responses' +import { useUpdateOrganisationWithToast } from 'components/pages/organisation-settings/hooks' + +type Force2FASettingProps = { + organisation: Organisation +} + +export const Force2FASetting = ({ organisation }: Force2FASettingProps) => { + const [updateOrganisationWithToast, { isLoading: isSaving }] = + useUpdateOrganisationWithToast() + + const handleToggle = async () => { + await updateOrganisationWithToast( + { + force_2fa: !organisation.force_2fa, + name: organisation.name, // name is required by API + }, + organisation.id, + ) + } + + return ( + + ) +} diff --git a/frontend/web/components/pages/organisation-settings/tabs/general-tab/sections/admin-settings/RestrictProjectCreationSetting.tsx b/frontend/web/components/pages/organisation-settings/tabs/general-tab/sections/admin-settings/RestrictProjectCreationSetting.tsx new file mode 100644 index 000000000000..6e497a297d23 --- /dev/null +++ b/frontend/web/components/pages/organisation-settings/tabs/general-tab/sections/admin-settings/RestrictProjectCreationSetting.tsx @@ -0,0 +1,48 @@ +import React from 'react' +import Setting from 'components/Setting' +import Utils from 'common/utils/utils' +import { Organisation } from 'common/types/responses' +import { useUpdateOrganisationWithToast } from 'components/pages/organisation-settings/hooks' + +type RestrictProjectCreationSettingProps = { + organisation: Organisation +} + +export const RestrictProjectCreationSetting = ({ + organisation, +}: RestrictProjectCreationSettingProps) => { + const [updateOrganisationWithToast, { isLoading: isSaving }] = + useUpdateOrganisationWithToast() + + const hasFeature = Utils.getFlagsmithHasFeature( + 'restrict_project_create_to_admin', + ) + + const handleToggle = async () => { + await updateOrganisationWithToast( + { + name: organisation.name, // name is required by API + restrict_project_create_to_admin: + !organisation.restrict_project_create_to_admin, + }, + organisation.id, + ) + } + + if (!hasFeature) { + return null + } + + return ( + + + + ) +} 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..d53629f77e91 --- /dev/null +++ b/frontend/web/components/pages/project-settings/ProjectSettingsPage.tsx @@ -0,0 +1,157 @@ +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' +import { useGetEnvironmentsQuery } from 'common/services/useEnvironment' + +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 }) + const { data: environments } = useGetEnvironmentsQuery( + { projectId: 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 = (environments?.results?.length || 0) > 0 + 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..543af8e1ccc5 --- /dev/null +++ b/frontend/web/components/pages/project-settings/tabs/ImportTab.tsx @@ -0,0 +1,11 @@ +import React from 'react' +import ImportPage from 'components/import-export/ImportPage' + +type ImportTabProps = { + projectName: string + projectId: string +} + +export const ImportTab = ({ projectId, projectName }: ImportTabProps) => { + 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..fc5147d4c653 100644 --- a/frontend/web/routes.js +++ b/frontend/web/routes.js @@ -12,10 +12,10 @@ import UserIdPage from './components/pages/UserIdPage' import IntegrationsPage from './components/pages/IntegrationsPage' import FlagsPage from './components/pages/FeaturesPage' import SegmentsPage from './components/pages/SegmentsPage' -import OrganisationSettingsPage from './components/pages/OrganisationSettingsPage' +import OrganisationSettingsPage from './components/pages/organisation-settings' 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' diff --git a/frontend/web/styles/3rdParty/_react-datepicker.scss b/frontend/web/styles/3rdParty/_react-datepicker.scss index 3ffa762bf840..ec902838abc3 100644 --- a/frontend/web/styles/3rdParty/_react-datepicker.scss +++ b/frontend/web/styles/3rdParty/_react-datepicker.scss @@ -1,9 +1,3 @@ -.calendar-icon { - position: absolute; - top: 16px; - right: 12px; -} - .react-datepicker-wrapper .react-datepicker__input-container { input[type=text].invalid, input[type=password].invalid { border: 1px solid $danger; @@ -207,11 +201,6 @@ } .dark { - .calendar-icon { - path { - fill: $bg-dark400; - } - } .react-datepicker { background-color: $bg-dark300; box-shadow: none; @@ -240,11 +229,3 @@ scrollbar-color: $bg-dark300; } } - -.dark { - .calendar-icon { - path { - fill: $text-icon-grey; - } - } -} diff --git a/frontend/web/styles/project/_modals.scss b/frontend/web/styles/project/_modals.scss index 1c6f84cff8f9..9896168eb604 100644 --- a/frontend/web/styles/project/_modals.scss +++ b/frontend/web/styles/project/_modals.scss @@ -317,7 +317,7 @@ $side-width: 750px; } .modal-open { - #crisp-chatbox { + #crisp-chatbox, #pylon-chat-bubble { opacity: 0; pointer-events: none; } diff --git a/infrastructure/aws/production/ecs-task-definition-web.json b/infrastructure/aws/production/ecs-task-definition-web.json index a5977d6c7236..c38135102720 100644 --- a/infrastructure/aws/production/ecs-task-definition-web.json +++ b/infrastructure/aws/production/ecs-task-definition-web.json @@ -286,6 +286,10 @@ { "name": "FLAGSMITH_ON_FLAGSMITH_SERVER_KEY", "valueFrom": "arn:aws:secretsmanager:eu-west-2:084060095745:secret:ECS-API-LxUiIQ:FLAGSMITH_ON_FLAGSMITH_SERVER_KEY::" + }, + { + "name": "PYLON_IDENTITY_VERIFICATION_SECRET", + "valueFrom": "arn:aws:secretsmanager:eu-west-2:084060095745:secret:ECS-API-LxUiIQ:PYLON_IDENTITY_VERIFICATION_SECRET::" } ], "logConfiguration": { diff --git a/version.txt b/version.txt index c53b3c5b697f..ac44a2625bbb 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -2.201.3 +2.202.0 From 142c32e3e3d06647df7118c0cf07f39a7420d559 Mon Sep 17 00:00:00 2001 From: kyle-ssg Date: Tue, 2 Dec 2025 10:57:07 +0000 Subject: [PATCH 089/108] Migrate tabs --- .../feature-override/FeatureOverrideRow.tsx | 2 +- .../components/feature-summary/FeatureRow.tsx | 2 +- .../modals/CreateEditFeature/index.tsx | 2136 ----------------- .../tabs/FeatureValueTab.tsx | 193 -- .../modals/CreateFlag/FeatureLimitAlert.tsx | 34 + .../web/components/modals/CreateFlag/index.js | 119 +- .../tabs/CreateFeature.tsx} | 57 +- .../CreateFlag/tabs/FeatureSettings.tsx | 193 ++ .../tabs/FeatureValue.tsx} | 4 +- .../CreateFlag/tabs/FeatureValueTab.tsx | 119 - .../pages/ChangeRequestDetailPage.tsx | 2 +- frontend/web/components/pages/FeaturesPage.js | 2 +- 12 files changed, 374 insertions(+), 2489 deletions(-) delete mode 100644 frontend/web/components/modals/CreateEditFeature/index.tsx delete mode 100644 frontend/web/components/modals/CreateEditFeature/tabs/FeatureValueTab.tsx create mode 100644 frontend/web/components/modals/CreateFlag/FeatureLimitAlert.tsx rename frontend/web/components/modals/{CreateEditFeature/tabs/CreateFeatureTab.tsx => CreateFlag/tabs/CreateFeature.tsx} (55%) create mode 100644 frontend/web/components/modals/CreateFlag/tabs/FeatureSettings.tsx rename frontend/web/components/modals/{CreateEditFeature/EditFeatureValue.tsx => CreateFlag/tabs/FeatureValue.tsx} (99%) delete mode 100644 frontend/web/components/modals/CreateFlag/tabs/FeatureValueTab.tsx diff --git a/frontend/web/components/feature-override/FeatureOverrideRow.tsx b/frontend/web/components/feature-override/FeatureOverrideRow.tsx index 13def2d83471..b7a3c51ae854 100644 --- a/frontend/web/components/feature-override/FeatureOverrideRow.tsx +++ b/frontend/web/components/feature-override/FeatureOverrideRow.tsx @@ -23,7 +23,7 @@ import API from 'project/api' import Constants from 'common/constants' import Button from 'components/base/forms/Button' import Icon from 'components/Icon' -import CreateFeatureModal from 'components/modals/CreateEditFeature' +import CreateFeatureModal from 'components/modals/CreateFlag' import { useHistory } from 'react-router-dom' import ConfirmToggleFeature from 'components/modals/ConfirmToggleFeature' import FeatureOverrideCTA from './FeatureOverrideCTA' diff --git a/frontend/web/components/feature-summary/FeatureRow.tsx b/frontend/web/components/feature-summary/FeatureRow.tsx index a76ea774b231..0b417f94da08 100644 --- a/frontend/web/components/feature-summary/FeatureRow.tsx +++ b/frontend/web/components/feature-summary/FeatureRow.tsx @@ -1,7 +1,7 @@ import React, { FC, useEffect, useMemo } from 'react' import ConfirmToggleFeature from 'components/modals/ConfirmToggleFeature' import ConfirmRemoveFeature from 'components/modals/ConfirmRemoveFeature' -import CreateFeatureModal from 'components/modals/CreateEditFeature' +import CreateFeatureModal from 'components/modals/CreateFlag' import ProjectStore from 'common/stores/project-store' import Constants from 'common/constants' import { useProtectedTags } from 'common/utils/useProtectedTags' diff --git a/frontend/web/components/modals/CreateEditFeature/index.tsx b/frontend/web/components/modals/CreateEditFeature/index.tsx deleted file mode 100644 index 2c1b9616f2ab..000000000000 --- a/frontend/web/components/modals/CreateEditFeature/index.tsx +++ /dev/null @@ -1,2136 +0,0 @@ -import React, { Component } from 'react' -import withSegmentOverrides from 'common/providers/withSegmentOverrides' -import moment from 'moment' -import Constants from 'common/constants' -import data from 'common/data/base/_data' -import ProjectStore from 'common/stores/project-store' -import ConfigProvider from 'common/providers/ConfigProvider' -import FeatureListStore from 'common/stores/feature-list-store' -import IdentityProvider from 'common/providers/IdentityProvider' -import Tabs from 'components/navigation/TabMenu/Tabs' -import TabItem from 'components/navigation/TabMenu/TabItem' -import SegmentOverrides from 'components/SegmentOverrides' -import AddEditTags from 'components/tags/AddEditTags' -import FlagOwners from 'components/FlagOwners' -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 InputGroup from 'components/base/forms/InputGroup' -import Tooltip from 'components/Tooltip' -import IdentitySelect from 'components/IdentitySelect' -import { - setInterceptClose, - setModalTitle, -} from 'components/modals/base/ModalDefault' -import Icon from 'components/Icon' -import ModalHR from 'components/modals/ModalHR' -import FeatureValue from 'components/feature-summary/FeatureValue' -import { getStore } from 'common/store' -import 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 { 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 FeatureInPipelineGuard from 'components/release-pipelines/FeatureInPipelineGuard' -import FeatureCodeReferencesContainer from 'components/feature-page/FeatureNavTab/CodeReferences/FeatureCodeReferencesContainer' -import BetaFlag from 'components/BetaFlag' -import EditFeatureValue from './EditFeatureValue' -import FeatureValueTab from './tabs/FeatureValueTab' -import CreateFeatureTab from './tabs/CreateFeatureTab' - -const CreateEditFeature = class extends Component { - static displayName = 'CreateEditFeature' - - 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, - ) - : { - multivariate_options: [], - } - const { allowEditDescription } = this.props - const hideTags = this.props.hideTags || [] - - if (this.props.projectFlag) { - this.userOverridesPage(1, true) - } - this.state = { - allowEditDescription, - changeRequests: [], - default_enabled: enabled, - description, - enabledIndentity: false, - enabledSegment: false, - externalResource: {}, - externalResources: [], - featureContentType: {}, - 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, - scheduledChangeRequests: [], - selectedIdentity: null, - tags: tags?.filter((tag) => !hideTags.includes(tag)) || [], - } - } - - getState = () => {} - - close() { - closeModal() - } - - componentDidUpdate(prevProps) { - ES6Component(this) - if ( - !this.props.identity && - this.props.environmentVariations !== prevProps.environmentVariations - ) { - if ( - this.props.environmentVariations && - 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, - } - }), - }) - } - } - } - - onClosing = () => { - if (this.state.isEdit) { - return new Promise((resolve) => { - if ( - this.state.valueChanged || - this.state.segmentsChanged || - this.state.settingsChanged - ) { - openConfirm({ - body: 'Closing this will discard your unsaved changes.', - noText: 'Cancel', - onNo: () => resolve(false), - onYes: () => resolve(true), - title: 'Discard changes', - yesText: 'Ok', - }) - } else { - resolve(true) - } - }) - } - return Promise.resolve(true) - } - - componentDidMount = () => { - setInterceptClose(this.onClosing) - if (!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, - }).then((res) => { - const featureContentType = Utils.getContentType( - res.data, - 'model', - 'feature', - ) - this.setState({ featureContentType: featureContentType }) - }) - } - - this.fetchChangeRequests() - this.fetchScheduledChangeRequests() - - getGithubIntegration(getStore(), { - organisation_id: AccountStore.getOrganisation().id, - }).then((res) => { - this.setState({ - githubId: res?.data?.results[0]?.id, - hasIntegrationWithGithub: !!res?.data?.results?.length, - }) - }) - } - - componentWillUnmount() { - if (this.focusTimeout) { - clearTimeout(this.focusTimeout) - } - } - - userOverridesPage = (page, forceRefetch) => { - if (Utils.getIsEdge()) { - if (!Utils.getShouldHideIdentityOverridesTab(ProjectStore.model)) { - getPermission( - getStore(), - { - id: this.props.environmentId, - level: 'environment', - permissions: 'VIEW_IDENTITIES', - }, - { forceRefetch }, - ).then((permissions) => { - const hasViewIdentitiesPermission = - permissions[Utils.getViewIdentitiesPermission()] - if (hasViewIdentitiesPermission || AccountStore.isAdmin()) { - data - .get( - `${Project.api}environments/${this.props.environmentId}/edge-identity-overrides?feature=${this.props.projectFlag.id}&page=${page}`, - ) - .then((userOverrides) => { - this.setState({ - userOverrides: userOverrides.results.map((v) => ({ - ...v.feature_state, - identity: { - id: v.identity_uuid, - identifier: v.identifier, - }, - })), - userOverridesPaging: { - count: userOverrides.count, - currentPage: page, - next: userOverrides.next, - }, - }) - }) - .catch(() => { - //eslint-disable-next-line no-console - console.error('Cannot retrieve user overrides') - }) - } - }) - } - - return - } - data - .get( - `${Project.api}environments/${ - this.props.environmentId - }/${Utils.getFeatureStatesEndpoint()}/?anyIdentity=1&feature=${ - this.props.projectFlag.id - }&page=${page}`, - ) - .then((userOverrides) => { - this.setState({ - userOverrides: userOverrides.results, - userOverridesPaging: { - count: userOverrides.count, - currentPage: page, - next: userOverrides.next, - }, - }) - }) - } - - save = (func, isSaving) => { - const { - environmentFlag, - 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 hasMultivariate = - this.props.environmentFlag && - this.props.environmentFlag.multivariate_feature_state_values && - this.props.environmentFlag.multivariate_feature_state_values.length - if (identity) { - !isSaving && - name && - func({ - environmentFlag, - environmentId, - identity, - identityFlag: Object.assign({}, identityFlag || {}, { - enabled: default_enabled, - feature_state_value: hasMultivariate - ? this.props.environmentFlag.feature_state_value - : this.cleanInputValue(initial_value), - multivariate_options: this.state.identityVariations, - }), - projectFlag, - }) - } else { - FeatureListStore.isSaving = true - FeatureListStore.trigger('change') - !isSaving && - name && - func( - this.props.projectId, - this.props.environmentId, - { - default_enabled, - description, - initial_value: this.cleanInputValue(initial_value), - is_archived, - is_server_key_only, - metadata: - !this.props.projectFlag?.metadata || - (this.props.projectFlag.metadata !== this.state.metadata && - this.state.metadata.length) - ? this.state.metadata - : this.props.projectFlag.metadata, - multivariate_options: this.state.multivariate_options, - name, - tags: this.state.tags, - }, - projectFlag, - environmentFlag, - segmentOverrides, - ) - } - } - - changeSegment = (items) => { - const { enabledSegment } = this.state - items.forEach((item) => { - item.enabled = enabledSegment - }) - this.props.updateSegments(items) - this.setState({ enabledSegment: !enabledSegment }) - } - - changeIdentity = (items) => { - const { environmentId } = this.props - const { enabledIndentity } = this.state - - Promise.all( - items.map( - (item) => - new Promise((resolve) => { - AppActions.changeUserFlag({ - environmentId, - identity: item.identity.id, - identityFlag: item.id, - onSuccess: resolve, - payload: { - enabled: enabledIndentity, - id: item.identity.id, - value: item.identity.identifier, - }, - }) - }), - ), - ).then(() => { - this.userOverridesPage(1) - }) - - this.setState({ enabledIndentity: !enabledIndentity }) - } - - toggleUserFlag = ({ enabled, id, identity }) => { - const { environmentId } = this.props - - AppActions.changeUserFlag({ - environmentId, - identity: identity.id, - identityFlag: id, - onSuccess: () => { - this.userOverridesPage(1) - }, - payload: { - enabled: !enabled, - id: identity.id, - value: identity.identifier, - }, - }) - } - parseError = (error) => { - const { projectFlag } = this.props - let featureError = error?.message || error?.name?.[0] || error - let featureWarning = '' - //Treat multivariate no changes as warnings - if ( - featureError?.includes?.('no changes') && - projectFlag?.multivariate_options?.length - ) { - featureWarning = `Your feature contains no changes to its value, enabled state or environment weights. If you have adjusted any variation values this will have been saved for all environments.` - featureError = '' - } - return { featureError, featureWarning } - } - cleanInputValue = (value) => { - if (value && typeof value === 'string') { - return value.trim() - } - return value - } - - addItem = () => { - const { environmentFlag, environmentId, identity, projectFlag } = this.props - this.setState({ isLoading: true }) - const selectedIdentity = this.state.selectedIdentity.value - const identities = identity ? identity.identifier : [] - - if (!_.find(identities, (v) => v.identifier === selectedIdentity)) { - data - .post( - `${ - Project.api - }environments/${environmentId}/${Utils.getIdentitiesEndpoint()}/${selectedIdentity}/${Utils.getFeatureStatesEndpoint()}/`, - { - enabled: !environmentFlag.enabled, - feature: projectFlag.id, - feature_state_value: environmentFlag.value || null, - }, - ) - .then(() => { - this.setState({ - isLoading: false, - selectedIdentity: null, - }) - this.userOverridesPage(1) - }) - .catch((e) => { - this.setState({ error: e, isLoading: false }) - }) - } else { - this.setState({ - isLoading: false, - selectedIdentity: null, - }) - } - } - - 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 - - getChangeRequests( - getStore(), - { - committed: false, - environmentId, - feature_id: projectFlag?.id, - }, - { forceRefetch }, - ).then((res) => { - this.setState({ changeRequests: res.data?.results }) - }) - } - - fetchScheduledChangeRequests = (forceRefetch) => { - const { environmentId, projectFlag } = this.props - if (!projectFlag?.id) return - - const date = moment().toISOString() - - getChangeRequests( - getStore(), - { - environmentId, - feature_id: projectFlag.id, - live_from_after: date, - }, - { forceRefetch }, - ).then((res) => { - this.setState({ scheduledChangeRequests: res.data?.results }) - }) - } - - render() { - const { - default_enabled, - description, - enabledIndentity, - enabledSegment, - featureContentType, - githubId, - hasIntegrationWithGithub, - initial_value, - isEdit, - multivariate_options, - name, - } = this.state - - const { identity, identityName, projectFlag } = 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 = - !!environment && - Utils.changeRequestsEnabled(environment.minimum_change_request_approvals) - const project = ProjectStore.model - const caseSensitive = project?.only_allow_lower_case_feature_names - const regex = project?.feature_name_regex - const controlValue = Utils.calculateControl(multivariate_options) - const invalid = - !!multivariate_options && multivariate_options.length && controlValue < 0 - const existingChangeRequest = this.props.changeRequest - 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)) - } - } 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 FEATURE_ID_MAXLENGTH = Constants.forms.maxLength.FEATURE_ID - 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' - /> -
- )} - - - { - const updates: any = {} - if (featureState.enabled !== undefined) { - updates.default_enabled = featureState.enabled - } - if (featureState.feature_state_value !== undefined) { - updates.initial_value = featureState.feature_state_value - updates.valueChanged = true - } - if (featureState.multivariate_feature_state_values !== undefined) { - updates.identityVariations = - featureState.multivariate_feature_state_values - updates.valueChanged = true - } - this.setState(updates) - }} - removeVariation={this.removeVariation} - updateVariation={this.updateVariation} - addVariation={this.addVariation} - /> - - ) - } - return ( - - {({ project }) => ( - { - if (identity) { - this.close() - } - AppActions.refreshFeatures( - this.props.projectId, - this.props.environmentId, - ) - - if (is4Eyes && !identity) { - this.fetchChangeRequests(true) - this.fetchScheduledChangeRequests(true) - } - }} - > - {( - { error, isSaving }, - { - createChangeRequest, - createFlag, - editFeatureSegments, - editFeatureSettings, - editFeatureValue, - }, - ) => { - const saveFeatureValue = saveFeatureWithValidation((schedule) => { - if ((is4Eyes || schedule) && !identity) { - this.setState({ segmentsChanged: false, valueChanged: false }) - - openModal2( - schedule - ? 'New Scheduled Flag Update' - : this.props.changeRequest - ? 'Update Change Request' - : 'New Change Request', - { - closeModal2() - this.save( - ( - projectId, - environmentId, - flag, - projectFlag, - environmentFlag, - segmentOverrides, - ) => { - createChangeRequest( - projectId, - environmentId, - flag, - projectFlag, - environmentFlag, - segmentOverrides, - { - approvals, - description, - featureStateId: - this.props.changeRequest && - this.props.changeRequest.feature_states[0].id, - id: - this.props.changeRequest && - this.props.changeRequest.id, - live_from, - multivariate_options: this.props - .multivariate_options - ? this.props.multivariate_options.map((v) => { - const matching = - this.state.multivariate_options.find( - (m) => - m.id === - v.multivariate_feature_option, - ) - return { - ...v, - percentage_allocation: - matching.default_percentage_allocation, - } - }) - : this.state.multivariate_options, - title, - }, - !is4Eyes, - ) - }, - ) - }} - />, - ) - } else { - this.setState({ valueChanged: false }) - this.save(editFeatureValue, isSaving) - } - }) - - const saveSettings = () => { - this.setState({ settingsChanged: false }) - this.save(editFeatureSettings, isSaving) - } - - const saveFeatureSegments = saveFeatureWithValidation( - (schedule) => { - this.setState({ segmentsChanged: false }) - - if ((is4Eyes || schedule) && isVersioned && !identity) { - return saveFeatureValue() - } else { - this.save(editFeatureSegments, isSaving) - } - }, - ) - - const onCreateFeature = saveFeatureWithValidation(() => { - this.save(createFlag, isSaving) - }) - const isLimitReached = false - - const featureLimitAlert = - Utils.calculateRemainingLimitsPercentage( - project.total_features, - project.max_features_allowed, - ) - const { featureError, featureWarning } = this.parseError(error) - - return ( - - {({ permission: createFeature }) => ( - - {({ permission: projectAdmin }) => { - this.state.skipSaveProjectFeature = !createFeature - const _hasMetadataRequired = - this.state.hasMetadataRequired && - !this.state.metadata.length - return ( -
- {isEdit && !identity ? ( - <> - - this.forceUpdate()} - urlParam='tab' - history={this.props.history} - overflowX - > - - Value{' '} - {this.state.valueChanged && ( -
- {'*'} -
- )} - - } - > - - this.setState({ - initial_value, - valueChanged: true, - }) - } - onCheckedChange={(default_enabled) => - this.setState({ - default_enabled, - valueChanged: true, - }) - } - onIdentityVariationsChange={( - identityVariations, - ) => - this.setState({ - identityVariations, - valueChanged: true, - }) - } - removeVariation={this.removeVariation} - updateVariation={this.updateVariation} - addVariation={this.addVariation} - saveFeatureValue={saveFeatureValue} - /> -
- {!existingChangeRequest && ( - - Segment Overrides{' '} - {this.state.segmentsChanged && ( -
- * -
- )} - - } - > - {!identity && isEdit && ( - - ( - <> -
- Segment Overrides{' '} -
- - This feature is in{' '} - - { - matchingReleasePipeline?.name - } - {' '} - release pipeline and no - segment overrides can be - created - - - )} - > -
- -
- - Segment Overrides{' '} - - - } - place='top' - > - { - Constants.strings - .SEGMENT_OVERRIDES_DESCRIPTION - } - -
- - {({ - permission: - manageSegmentOverrides, - }) => - !this.state - .showCreateSegment && - !!manageSegmentOverrides && - !this.props - .disableCreate && ( -
- -
- ) - } -
- {!this.state - .showCreateSegment && - !noPermissions && ( - - )} -
- {this.props.segmentOverrides ? ( - - {({ - permission: - manageSegmentOverrides, - }) => { - const isReadOnly = - !manageSegmentOverrides - return ( - <> - - - - this.setState({ - showCreateSegment, - }) - } - readOnly={isReadOnly} - is4Eyes={is4Eyes} - showEditSegment - showCreateSegment={ - this.state - .showCreateSegment - } - feature={ - projectFlag.id - } - projectId={ - this.props.projectId - } - multivariateOptions={ - multivariate_options - } - environmentId={ - this.props - .environmentId - } - value={ - this.props - .segmentOverrides - } - controlValue={ - initial_value - } - onChange={(v) => { - this.setState({ - segmentsChanged: true, - }) - this.props.updateSegments( - v, - ) - }} - /> - - ) - }} - - ) : ( -
- -
- )} - {!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, - ), - , - ) - } - - return Utils.renderWithPermission( - manageSegmentsOverrides, - Constants.environmentPermissions( - 'Manage segment overrides', - ), - <> - {!is4Eyes && - isVersioned && ( - <> - - - )} - - , - ) - }} - - )} - -
-
- )} -
-
-
- )} -
- )} - - {({ permission: viewIdentities }) => - !identity && - isEdit && - !existingChangeRequest && - !hideIdentityOverridesTab && ( - - {viewIdentities ? ( - <> - - - - Identity Overrides{' '} - - - } - place='top' - > - { - Constants.strings - .IDENTITY_OVERRIDES_DESCRIPTION - } - -
- - Identity overrides - override feature - values for individual - identities. The - overrides take - priority over an - segment overrides and - environment defaults. - Identity overrides - will only apply when - you identify via the - SDK.{' '} - - Check the Docs for - more details - - . - -
- - } - action={ - !Utils.getIsEdge() && ( - - ) - } - items={ - this.state.userOverrides - } - paging={ - this.state - .userOverridesPaging - } - renderSearchWithNoResults - nextPage={() => - this.userOverridesPage( - this.state - .userOverridesPaging - .currentPage + 1, - ) - } - prevPage={() => - this.userOverridesPage( - this.state - .userOverridesPaging - .currentPage - 1, - ) - } - goToPage={(page) => - this.userOverridesPage(page) - } - searchPanel={ - !Utils.getIsEdge() && ( -
- - - v.identity?.id, - )} - environmentId={ - this.props - .environmentId - } - data-test='select-identity' - placeholder='Create an Identity Override...' - value={ - this.state - .selectedIdentity - } - onChange={( - selectedIdentity, - ) => - this.setState( - { - selectedIdentity, - }, - this.addItem, - ) - } - /> - -
- ) - } - renderRow={(identityFlag) => { - const { - enabled, - feature_state_value, - id, - identity, - } = identityFlag - return ( - - -
- - this.toggleUserFlag( - { - enabled, - id, - identity, - }, - ) - } - disabled={Utils.getIsEdge()} - /> -
-
- { - identity.identifier - } -
-
- -
- {feature_state_value !== - null && ( - - )} -
-
- - -
-
-
- ) - }} - renderNoResults={ - -
- No identities are - overriding this feature. -
-
- } - isLoading={ - !this.state.userOverrides - } - /> -
- - ) : ( - -
- - )} - - ) - } - - {!Project.disableAnalytics && ( - -
- -
-
- )} - { - - - Feature Health - {this.props.hasUnhealthyEvents && ( - - )} - - - } - > - - - } - {isCodeReferencesEnabled && ( - - Code References - - } - > - - - )} - {hasIntegrationWithGithub && - projectFlag?.id && ( - - Links - - } - > - - - )} - {!existingChangeRequest && - this.props.flagId && - isVersioned && ( - - - - )} - {!existingChangeRequest && ( - - Settings{' '} - {this.state.settingsChanged && ( -
- {'*'} -
- )} - - } - > - {Settings( - projectAdmin, - createFeature, - featureContentType, - )} - - - {isEdit && ( -
- {!!createFeature && ( - <> -

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

- - - )} -
- )} -
- )} - - - ) : ( -
- {featureLimitAlert.percentage && - Utils.displayLimitAlert( - 'features', - featureLimitAlert.percentage, - )} - {Value( - error, - projectAdmin, - createFeature, - project.prevent_flag_defaults && !identity, - )} - - {!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 && ( -
- {identity ? ( -
-

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

-
- ) : ( - '' - )} - -
- {identity && ( -
- -
- )} -
-
- )} -
- ) - }} -
- )} - - ) - }} - - )} - - ) - } -} - -CreateEditFeature.propTypes = {} - -//This will remount the modal when a feature is created -const FeatureProvider = (WrappedComponent) => { - class HOC extends Component { - constructor(props) { - super(props) - this.state = { - ...props, - } - ES6Component(this) - } - - componentDidMount() { - // toast update feature - ES6Component(this) - this.listenTo( - FeatureListStore, - 'saved', - ({ - changeRequest, - createdFlag, - error, - isCreate, - updatedChangeRequest, - } = {}) => { - if (error?.data?.metadata) { - error.data.metadata?.forEach((m) => { - if (Object.keys(m).length > 0) { - toast(m.non_field_errors[0], 'danger') - } - }) - } else if (error?.data) { - toast('Error updating the Flag', 'danger') - return - } else { - const operation = createdFlag || isCreate ? 'Created' : 'Updated' - const type = changeRequest ? 'Change Request' : 'Feature' - - const toastText = `${operation} ${type}` - const toastAction = changeRequest - ? { - buttonText: 'Open', - onClick: () => { - closeModal() - this.props.history.push( - `/project/${this.props.projectId}/environment/${this.props.environmentId}/change-requests/${updatedChangeRequest?.id}`, - ) - }, - } - : undefined - - toast(toastText, 'success', undefined, toastAction) - } - const envFlags = FeatureListStore.getEnvironmentFlags() - - if (createdFlag) { - const projectFlag = FeatureListStore.getProjectFlags()?.find?.( - (flag) => flag.name === createdFlag, - ) - window.history.replaceState( - {}, - `${document.location.pathname}?feature=${projectFlag.id}`, - ) - const newEnvironmentFlag = envFlags?.[projectFlag.id] || {} - setModalTitle(`Edit Feature ${projectFlag.name}`) - this.setState({ - environmentFlag: { - ...this.state.environmentFlag, - ...(newEnvironmentFlag || {}), - }, - projectFlag, - }) - } else if (this.props.projectFlag) { - //update the environmentFlag and projectFlag to the new values - const newEnvironmentFlag = - envFlags?.[this.props.projectFlag.id] || {} - const newProjectFlag = FeatureListStore.getProjectFlags()?.find?.( - (flag) => flag.id === this.props.projectFlag.id, - ) - this.setState({ - environmentFlag: { - ...this.state.environmentFlag, - ...(newEnvironmentFlag || {}), - }, - projectFlag: newProjectFlag, - }) - } - }, - ) - } - - render() { - return ( - - ) - } - } - return HOC -} - -const WrappedCreateFlag = ConfigProvider(withSegmentOverrides(CreateEditFeature)) - -export default FeatureProvider(WrappedCreateFlag) diff --git a/frontend/web/components/modals/CreateEditFeature/tabs/FeatureValueTab.tsx b/frontend/web/components/modals/CreateEditFeature/tabs/FeatureValueTab.tsx deleted file mode 100644 index 7411261595c9..000000000000 --- a/frontend/web/components/modals/CreateEditFeature/tabs/FeatureValueTab.tsx +++ /dev/null @@ -1,193 +0,0 @@ -import React, { FC } from 'react' -import { ChangeRequest, ProjectFlag } from 'common/types/responses' -import { useGetProjectQuery } from 'common/services/useProject' -import Utils from 'common/utils/utils' -import FeatureInPipelineGuard from 'components/release-pipelines/FeatureInPipelineGuard' -import InfoMessage from 'components/InfoMessage' -import Constants from 'common/constants' -import Icon from 'components/Icon' -import JSONReference from 'components/JSONReference' -import { FlagValueFooter } from 'components/modals/FlagValueFooter' -import Tooltip from 'components/Tooltip' -import EditFeatureValue from 'components/modals/CreateEditFeature/EditFeatureValue' -import { useHasPermission } from 'common/providers/Permission' - -type FeatureValueTabType = { - projectId: number - environmentId: string - environmentFlag: any - projectFlag: ProjectFlag | null | undefined - isSaving: boolean - invalid: boolean - existingChangeRequest?: ChangeRequest - error: any - identity?: string - identityName?: string - description: string - multivariate_options: any[] - environmentVariations: any[] - default_enabled: boolean - initial_value: any - identityVariations: any[] - featureName: string - onValueChange: (initial_value: any) => void - onCheckedChange: (default_enabled: boolean) => void - onIdentityVariationsChange: (identityVariations: any[]) => void - removeVariation: (i: number) => void - updateVariation: (i: number, e: any, environmentVariations: any[]) => void - addVariation: () => void - saveFeatureValue: () => void -} - -const FeatureValueTab: FC = ({ - addVariation, - default_enabled, - description, - environmentFlag, - environmentId, - environmentVariations, - error, - existingChangeRequest, - featureName, - identity, - identityName, - identityVariations, - initial_value, - invalid, - isSaving, - multivariate_options, - onCheckedChange, - onIdentityVariationsChange, - onValueChange, - projectFlag, - projectId, - removeVariation, - saveFeatureValue, - updateVariation, -}) => { - const { data: project } = useGetProjectQuery({ - id: `${projectId}`, - }) - const environment = project?.environments.find( - (v) => `${v.id}` === `${environmentId}`, - ) - const isVersioned = !!environment?.use_v2_feature_versioning - const is4Eyes = - !!environment && - Utils.changeRequestsEnabled(environment.minimum_change_request_approvals) - - const manageFeaturePermission = Utils.getManageFeaturePermission(is4Eyes) - - const { permission: hasManageFeaturePermission } = useHasPermission({ - id: environmentId, - level: 'environment', - permission: manageFeaturePermission, - tags: projectFlag?.tags, - }) - - const noPermissions = !hasManageFeaturePermission - - const featureLimitAlert = Utils.calculateRemainingLimitsPercentage( - project?.total_features, - project?.max_features_allowed, - ) - if (!projectFlag || !environment) { - return ( -
- -
- ) - } - return ( - <> - - {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( - environment.name, - )} - - {identity && description && ( - {description} - )} - { - if (featureState.enabled !== undefined) { - onCheckedChange(featureState.enabled) - } - if (featureState.feature_state_value !== undefined) { - onValueChange(featureState.feature_state_value) - } - if (featureState.multivariate_feature_state_values !== undefined) { - onIdentityVariationsChange( - featureState.multivariate_feature_state_values, - ) - } - }} - removeVariation={removeVariation} - updateVariation={updateVariation} - addVariation={addVariation} - /> - - - -
-
- - ) -} - -export default FeatureValueTab diff --git a/frontend/web/components/modals/CreateFlag/FeatureLimitAlert.tsx b/frontend/web/components/modals/CreateFlag/FeatureLimitAlert.tsx new file mode 100644 index 000000000000..8bf1886838d9 --- /dev/null +++ b/frontend/web/components/modals/CreateFlag/FeatureLimitAlert.tsx @@ -0,0 +1,34 @@ +import { FC, useEffect } from 'react' +import Utils from 'common/utils/utils' +import { useGetProjectQuery } from 'common/services/useProject' + +type FeatureLimitAlertType = { + projectId: string | number + onChange?: (limitAlert: { percentage: number; limit: number }) => void +} + +const FeatureLimitAlert: FC = ({ + onChange, + projectId, +}) => { + const { data: project } = useGetProjectQuery({ id: `${projectId}` }) + + const featureLimitAlert = Utils.calculateRemainingLimitsPercentage( + project?.total_features, + project?.max_features_allowed, + ) + + useEffect(() => { + if (onChange && featureLimitAlert) { + onChange(featureLimitAlert) + } + }, [onChange, featureLimitAlert]) + + if (!featureLimitAlert.percentage) { + return null + } + + return <>{Utils.displayLimitAlert('features', featureLimitAlert.percentage)} +} + +export default FeatureLimitAlert diff --git a/frontend/web/components/modals/CreateFlag/index.js b/frontend/web/components/modals/CreateFlag/index.js index e9c3809d83e9..c7f98686a807 100644 --- a/frontend/web/components/modals/CreateFlag/index.js +++ b/frontend/web/components/modals/CreateFlag/index.js @@ -47,11 +47,12 @@ import FeatureHealthTabContent from 'components/feature-health/FeatureHealthTabC import { IonIcon } from '@ionic/react' import { warning } from 'ionicons/icons' import FeaturePipelineStatus from 'components/release-pipelines/FeaturePipelineStatus' -import { FlagValueFooter } from 'components/modals/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' +import CreateFeature from './tabs/CreateFeature' +import FeatureLimitAlert from './FeatureLimitAlert' const Index = class extends Component { static displayName = 'CreateFlag' @@ -92,6 +93,7 @@ const Index = class extends Component { externalResource: {}, externalResources: [], featureContentType: {}, + featureLimitAlert: { percentage: 0 }, githubId: '', hasIntegrationWithGithub: false, hasMetadataRequired: false, @@ -183,12 +185,6 @@ const Index = 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, @@ -1050,11 +1046,6 @@ const Index = class extends Component { }) const isLimitReached = false - const featureLimitAlert = - Utils.calculateRemainingLimitsPercentage( - project.total_features, - project.max_features_allowed, - ) const { featureError, featureWarning } = this.parseError(error) return ( @@ -1924,17 +1915,96 @@ const Index = class extends Component { !isEdit ? 'create-feature-tab px-3' : '', )} > - {featureLimitAlert.percentage && - Utils.displayLimitAlert( - 'features', - featureLimitAlert.percentage, - )} - {Value( - error, - projectAdmin, - createFeature, - project.prevent_flag_defaults && !identity, - )} + + this.setState({ featureLimitAlert }) + } + /> + { + const updates = {} + if (featureState.enabled !== undefined) { + updates.default_enabled = + featureState.enabled + } + if ( + featureState.feature_state_value !== + undefined + ) { + updates.initial_value = + featureState.feature_state_value + updates.valueChanged = true + } + if ( + featureState.multivariate_feature_state_values !== + undefined + ) { + updates.identityVariations = + featureState.multivariate_feature_state_values + updates.valueChanged = true + } + this.setState(updates) + }} + removeVariation={this.removeVariation} + updateVariation={this.updateVariation} + addVariation={this.addVariation} + onTagsChange={(tags) => + this.setState({ tags, settingsChanged: true }) + } + onMetadataChange={(metadata) => + this.setState({ metadata }) + } + onDescriptionChange={(description) => + this.setState({ + description, + settingsChanged: true, + }) + } + onServerKeyOnlyChange={(is_server_key_only) => + this.setState({ + is_server_key_only, + settingsChanged: true, + }) + } + onArchivedChange={(is_archived) => + this.setState({ + is_archived, + settingsChanged: true, + }) + } + onHasMetadataRequiredChange={( + hasMetadataRequired, + ) => + this.setState({ + hasMetadataRequired, + }) + } + featureError={ + this.parseError(error).featureError + } + featureWarning={ + this.parseError(error).featureWarning + } + /> @@ -1972,7 +2042,8 @@ const Index = class extends Component { !name || invalid || !regexValid || - featureLimitAlert.percentage >= 100 || + this.state.featureLimitAlert.percentage >= + 100 || _hasMetadataRequired } > diff --git a/frontend/web/components/modals/CreateEditFeature/tabs/CreateFeatureTab.tsx b/frontend/web/components/modals/CreateFlag/tabs/CreateFeature.tsx similarity index 55% rename from frontend/web/components/modals/CreateEditFeature/tabs/CreateFeatureTab.tsx rename to frontend/web/components/modals/CreateFlag/tabs/CreateFeature.tsx index 6c77c0aec554..83c6b320454b 100644 --- a/frontend/web/components/modals/CreateEditFeature/tabs/CreateFeatureTab.tsx +++ b/frontend/web/components/modals/CreateFlag/tabs/CreateFeature.tsx @@ -1,6 +1,7 @@ import React, { FC } from 'react' import { FeatureState, ProjectFlag } from 'common/types/responses' -import EditFeatureValue from 'components/modals/CreateEditFeature/EditFeatureValue' +import FeatureValue from 'components/modals/CreateFlag/tabs/FeatureValue' +import FeatureSettings from 'components/modals/CreateFlag/tabs/FeatureSettings' import ErrorMessage from 'components/ErrorMessage' import WarningMessage from 'components/WarningMessage' import { useHasPermission } from 'common/providers/Permission' @@ -14,22 +15,28 @@ type CreateFeatureTabProps = { environmentFlag: any projectFlag: ProjectFlag | null | undefined featureContentType: any + identity?: string + tags: number[] + description: string + is_server_key_only: boolean + is_archived: boolean onChange: (featureState: Partial) => void removeVariation: (i: number) => void updateVariation: (i: number, e: any, environmentVariations: any[]) => void addVariation: () => void - Settings: ( - projectAdmin: boolean, - createFeature: boolean, - featureContentType: any, - ) => JSX.Element + onTagsChange: (tags: number[]) => void + onMetadataChange: (metadata: any[]) => void + onDescriptionChange: (description: string) => void + onServerKeyOnlyChange: (is_server_key_only: boolean) => void + onArchivedChange: (is_archived: boolean) => void + onHasMetadataRequiredChange: (hasMetadataRequired: boolean) => void featureError?: string featureWarning?: string } -const CreateFeatureTab: FC = ({ - Settings, +const CreateFeature: FC = ({ addVariation, + description, environmentFlag, environmentVariations, error, @@ -37,11 +44,21 @@ const CreateFeatureTab: FC = ({ featureError, featureState, featureWarning, + identity, + is_archived, + is_server_key_only, multivariate_options, + onArchivedChange, onChange, + onDescriptionChange, + onHasMetadataRequiredChange, + onMetadataChange, + onServerKeyOnlyChange, + onTagsChange, projectFlag, projectId, removeVariation, + tags, updateVariation, }) => { const { permission: createFeature } = useHasPermission({ @@ -61,7 +78,7 @@ const CreateFeatureTab: FC = ({ <> - = ({ updateVariation={updateVariation} addVariation={addVariation} /> - {Settings(projectAdmin, createFeature, featureContentType)} + ) } -export default CreateFeatureTab +export default CreateFeature diff --git a/frontend/web/components/modals/CreateFlag/tabs/FeatureSettings.tsx b/frontend/web/components/modals/CreateFlag/tabs/FeatureSettings.tsx new file mode 100644 index 000000000000..929e2d22c6ee --- /dev/null +++ b/frontend/web/components/modals/CreateFlag/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 FormGroup from 'components/base/forms/FormGroup' +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 Row from 'components/base/Row' +import Switch from 'components/Switch' +import Tooltip from 'components/Tooltip' +import Icon from 'components/Icon' +import Utils from 'common/utils/utils' + +type FeatureSettingsTabProps = { + projectAdmin: boolean + createFeature: boolean + featureContentType: any + identity?: string + isEdit: boolean + projectId: number | string + projectFlag: ProjectFlag | null | undefined + tags: number[] + description: string + is_server_key_only: boolean + is_archived: boolean + onTagsChange: (tags: number[]) => void + onMetadataChange: (metadata: any[]) => void + onDescriptionChange: (description: string) => void + onServerKeyOnlyChange: (is_server_key_only: boolean) => void + onArchivedChange: (is_archived: boolean) => void + onHasMetadataRequiredChange: (hasMetadataRequired: boolean) => void +} + +const FeatureSettings: FC = ({ + createFeature, + description, + featureContentType, + identity, + isEdit, + is_archived, + is_server_key_only, + onArchivedChange, + onDescriptionChange, + onHasMetadataRequiredChange, + onMetadataChange, + onServerKeyOnlyChange, + onTagsChange, + projectFlag, + projectId, + tags, +}) => { + const metadataEnable = Utils.getPlansPermission('METADATA') + + if (!createFeature) { + return ( + +
+ + ) + } + + return ( + <> + {!identity && tags && ( + + + } + /> + + )} + {metadataEnable && featureContentType?.id && ( + <> + + + + )} + {!identity && projectFlag && ( + + {({ permission }) => + permission && ( + <> + + + + + + + + + ) + } + + )} + + onDescriptionChange(Utils.safeParseEventValue(e))} + ds + type='text' + title={identity ? 'Description' : 'Description (optional)'} + placeholder="e.g. 'This determines what size the header is' " + /> + + + {!identity && ( + + + + + Server-side only + + } + > + Prevent this feature from being accessed with client-side SDKs. + + + + )} + + {!identity && isEdit && ( + + + + + 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/CreateEditFeature/EditFeatureValue.tsx b/frontend/web/components/modals/CreateFlag/tabs/FeatureValue.tsx similarity index 99% rename from frontend/web/components/modals/CreateEditFeature/EditFeatureValue.tsx rename to frontend/web/components/modals/CreateFlag/tabs/FeatureValue.tsx index 0f89f819d5da..aceae4942c58 100644 --- a/frontend/web/components/modals/CreateEditFeature/EditFeatureValue.tsx +++ b/frontend/web/components/modals/CreateFlag/tabs/FeatureValue.tsx @@ -43,7 +43,7 @@ type EditFeatureValueProps = { } /* eslint-disable sort-destructure-keys/sort-destructure-keys */ -const EditFeatureValue: FC = ({ +const FeatureValue: FC = ({ addVariation, createFeature, environmentFlag, @@ -258,4 +258,4 @@ const EditFeatureValue: FC = ({ ) } -export default EditFeatureValue +export default FeatureValue diff --git a/frontend/web/components/modals/CreateFlag/tabs/FeatureValueTab.tsx b/frontend/web/components/modals/CreateFlag/tabs/FeatureValueTab.tsx deleted file mode 100644 index c955e7e024ed..000000000000 --- a/frontend/web/components/modals/CreateFlag/tabs/FeatureValueTab.tsx +++ /dev/null @@ -1,119 +0,0 @@ -import { FC } from 'react' -import { ChangeRequest, ProjectFlag } from 'common/types/responses' -import { useGetProjectQuery } from 'common/services/useProject' -import Utils from 'common/utils/utils' -import FeatureInPipelineGuard from 'components/release-pipelines/FeatureInPipelineGuard' -import InfoMessage from 'components/InfoMessage' -import Constants from 'common/constants' -import Icon from 'components/Icon' -import JSONReference from 'components/JSONReference' -import { FlagValueFooter } from 'components/modals/FlagValueFooter' - -type FeatureValueTabType = { - projectId: number - environmentId: string - environmentFlag: any - projectFlag: ProjectFlag | null | undefined - isSaving: boolean - isEdit: boolean - isVersioned: boolean - invalid: boolean - existingChangeRequest?: ChangeRequest -} - -const FeatureValueTab: FC = ({ - environmentId, - isEdit, - projectFlag, - projectId, -}) => { - const { data: project } = useGetProjectQuery({ - id: `${projectId}`, - }) - const environment = project?.environments.find((v) => v.id === environmentId) - const is4Eyes = - !!environment && - Utils.changeRequestsEnabled(environment.minimum_change_request_approvals) - - const featureLimitAlert = Utils.calculateRemainingLimitsPercentage( - project?.total_features, - project?.max_features_allowed, - ) - if (!projectFlag || !environment) { - return ( -
- -
- ) - } - return ( - <> - - {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( - environment.name, - )} - - - {Value(error, projectAdmin, createFeature)} - - {isEdit && ( - <> - - - - )} - -
-
- - ) -} - -export default FeatureValueTab diff --git a/frontend/web/components/pages/ChangeRequestDetailPage.tsx b/frontend/web/components/pages/ChangeRequestDetailPage.tsx index 2939f7324ade..abb74509bb58 100644 --- a/frontend/web/components/pages/ChangeRequestDetailPage.tsx +++ b/frontend/web/components/pages/ChangeRequestDetailPage.tsx @@ -3,7 +3,7 @@ import OrganisationStore from 'common/stores/organisation-store' import ChangeRequestStore from 'common/stores/change-requests-store' import FeatureListStore from 'common/stores/feature-list-store' import { useGetMyGroupsQuery } from 'common/services/useMyGroup' -import CreateFeatureModal from 'components/modals/CreateEditFeature' +import CreateFeatureModal from 'components/modals/CreateFlag' import AccountStore from 'common/stores/account-store' import AppActions from 'common/dispatcher/app-actions' import { diff --git a/frontend/web/components/pages/FeaturesPage.js b/frontend/web/components/pages/FeaturesPage.js index c123adf2929b..3b3594a0c61d 100644 --- a/frontend/web/components/pages/FeaturesPage.js +++ b/frontend/web/components/pages/FeaturesPage.js @@ -1,5 +1,5 @@ import React, { Component } from 'react' -import CreateFeatureModal from 'components/modals/CreateEditFeature' +import CreateFeatureModal from 'components/modals/CreateFlag' import TryIt from 'components/TryIt' import FeatureRow from 'components/feature-summary/FeatureRow' import FeatureListStore from 'common/stores/feature-list-store' From 752ce0657e14b493e8737b4b33c1f90f5e2ef0e8 Mon Sep 17 00:00:00 2001 From: kyle-ssg Date: Tue, 2 Dec 2025 12:43:29 +0000 Subject: [PATCH 090/108] Add settings and value tabs --- frontend/web/components/Switch.js | 55 -- frontend/web/components/Switch.tsx | 68 ++ frontend/web/components/base/forms/Button.tsx | 1 + .../modals/CreateFlag/FeatureNameInput.tsx | 79 +++ .../CreateFlag/FeatureUpdateSummary.tsx | 62 ++ .../web/components/modals/CreateFlag/index.js | 666 +++++++----------- .../modals/CreateFlag/tabs/CreateFeature.tsx | 96 +-- .../CreateFlag/tabs/FeatureSettings.tsx | 68 +- frontend/web/components/tags/AddEditTags.tsx | 8 +- frontend/web/components/tags/TagValues.tsx | 2 +- frontend/web/styles/_variables.scss | 1 + frontend/web/styles/project/_buttons.scss | 7 + 12 files changed, 544 insertions(+), 569 deletions(-) delete mode 100644 frontend/web/components/Switch.js create mode 100644 frontend/web/components/Switch.tsx create mode 100644 frontend/web/components/modals/CreateFlag/FeatureNameInput.tsx create mode 100644 frontend/web/components/modals/CreateFlag/FeatureUpdateSummary.tsx diff --git a/frontend/web/components/Switch.js b/frontend/web/components/Switch.js deleted file mode 100644 index 7c10021da094..000000000000 --- a/frontend/web/components/Switch.js +++ /dev/null @@ -1,55 +0,0 @@ -// import propTypes from 'prop-types'; -import React, { PureComponent } from 'react' -import RCSwitch from 'rc-switch' -import Icon from './Icon' - -export default class Switch extends PureComponent { - static displayName = 'Switch' - - static propTypes = {} - - render() { - const { checked, darkMode, offMarkup, onChange, onMarkup } = this.props - if (E2E) { - return ( -
- -
- ) - } - if (darkMode) { - return ( - - ) - } - return - } -} diff --git a/frontend/web/components/Switch.tsx b/frontend/web/components/Switch.tsx new file mode 100644 index 000000000000..ef4ce826dc29 --- /dev/null +++ b/frontend/web/components/Switch.tsx @@ -0,0 +1,68 @@ +import React, { FC } from 'react' +import RCSwitch, { SwitchProps 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/modals/CreateFlag/FeatureNameInput.tsx b/frontend/web/components/modals/CreateFlag/FeatureNameInput.tsx new file mode 100644 index 000000000000..7927f44b786f --- /dev/null +++ b/frontend/web/components/modals/CreateFlag/FeatureNameInput.tsx @@ -0,0 +1,79 @@ +import React, { FC } from 'react' +import InputGroup from 'components/base/forms/InputGroup' +import Tooltip from 'components/Tooltip' +import Icon from 'components/Icon' +import InfoMessage from 'components/InfoMessage' +import Constants from 'common/constants' +import Utils from 'common/utils/utils' +import FormGroup from 'components/base/grid/FormGroup' +import Row from 'components/base/grid/Row' + +type FeatureNameInputProps = { + value: string + onChange: (name: string) => void + caseSensitive: boolean + regex?: string + regexValid: boolean + autoFocus?: boolean +} + +const FeatureNameInput: FC = ({ + autoFocus, + caseSensitive, + onChange, + regex, + regexValid, + value, +}) => { + const FEATURE_ID_MAXLENGTH = Constants.forms.maxLength.FEATURE_ID + + return ( + + { + const newName = Utils.safeParseEventValue(e).replace(/ /g, '_') + onChange(caseSensitive ? newName.toLowerCase() : newName) + }} + isValid={!!value && regexValid} + type='text' + title={ + <> + + 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 && ( +
+ {' '} + + {' '} + This must conform to the regular expression
{regex}
+
+
+ )} + + } + placeholder='E.g. header_size' + /> +
+ ) +} + +export default FeatureNameInput diff --git a/frontend/web/components/modals/CreateFlag/FeatureUpdateSummary.tsx b/frontend/web/components/modals/CreateFlag/FeatureUpdateSummary.tsx new file mode 100644 index 000000000000..2bf12fdbf593 --- /dev/null +++ b/frontend/web/components/modals/CreateFlag/FeatureUpdateSummary.tsx @@ -0,0 +1,62 @@ +import React, { FC } from 'react' +import InfoMessage from 'components/InfoMessage' +import Button from 'components/base/forms/Button' +import ModalHR from 'components/modals/ModalHR' + +type FeatureUpdateSummaryProps = { + identity?: string + onCreateFeature: () => void + isSaving: boolean + name: string + invalid: boolean + regexValid: boolean + featureLimitPercentage: number + hasMetadataRequired: boolean +} + +const FeatureUpdateSummary: FC = ({ + featureLimitPercentage, + hasMetadataRequired, + identity, + invalid, + isSaving, + name, + onCreateFeature, + regexValid, +}) => { + return ( + <> + + {!identity && ( +
+ + This will create the feature for all environments, + you can edit this feature per environment once the feature is + created. + + + +
+ )} + + ) +} + +export default FeatureUpdateSummary diff --git a/frontend/web/components/modals/CreateFlag/index.js b/frontend/web/components/modals/CreateFlag/index.js index c7f98686a807..145e5b105adc 100644 --- a/frontend/web/components/modals/CreateFlag/index.js +++ b/frontend/web/components/modals/CreateFlag/index.js @@ -10,10 +10,7 @@ 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 'components/modals/ChangeRequestModal' -import Feature from 'components/Feature' import classNames from 'classnames' import InfoMessage from 'components/InfoMessage' import JSONReference from 'components/JSONReference' @@ -28,19 +25,16 @@ import Icon from 'components/Icon' 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' @@ -52,7 +46,11 @@ import FeatureCodeReferencesContainer from 'components/feature-page/FeatureNavTa import BetaFlag from 'components/BetaFlag' import ProjectProvider from 'common/providers/ProjectProvider' import CreateFeature from './tabs/CreateFeature' +import FeatureSettings from './tabs/FeatureSettings' +import FeatureValueTab from './tabs/FeatureValue' import FeatureLimitAlert from './FeatureLimitAlert' +import FeatureUpdateSummary from './FeatureUpdateSummary' +import FeatureNameInput from './FeatureNameInput' const Index = class extends Component { static displayName = 'CreateFlag' @@ -538,7 +536,6 @@ const Index = class extends Component { render() { const { default_enabled, - description, enabledIndentity, enabledSegment, featureContentType, @@ -549,8 +546,6 @@ const Index = class extends Component { multivariate_options, name, } = this.state - const FEATURE_ID_MAXLENGTH = Constants.forms.maxLength.FEATURE_ID - const { identity, identityName, projectFlag } = this.props const Provider = identity ? IdentityProvider : FeatureListProvider const environmentVariations = this.props.environmentVariations @@ -569,7 +564,6 @@ const Index = class extends Component { const hideIdentityOverridesTab = Utils.getShouldHideIdentityOverridesTab() const noPermissions = this.props.noPermissions let regexValid = true - const metadataEnable = Utils.getPlansPermission('METADATA') const isCodeReferencesEnabled = Utils.getFlagsmithHasFeature( 'git_code_references', @@ -582,299 +576,7 @@ const Index = class extends Component { } 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 }) => ( @@ -941,12 +643,18 @@ const Index = class extends Component { }), ]) + 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(), {isEdit && !identity ? ( @@ -1092,7 +828,90 @@ const Index = class extends Component { )} } - > + > + { + const updates = {} + if ( + featureState.enabled !== undefined + ) { + updates.default_enabled = + featureState.enabled + } + if ( + featureState.feature_state_value !== + undefined + ) { + updates.initial_value = + featureState.feature_state_value + updates.valueChanged = true + } + if ( + featureState.multivariate_feature_state_values !== + undefined + ) { + updates.identityVariations = + featureState.multivariate_feature_state_values + updates.valueChanged = true + } + this.setState(updates) + }} + removeVariation={this.removeVariation} + updateVariation={this.updateVariation} + addVariation={this.addVariation} + /> + + + + {!existingChangeRequest && (

{is4Eyes && isVersioned - ? `This will create a change request ${ - isVersioned - ? 'with any value and segment override changes ' - : '' - }for the environment` + ? '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'}{' '} { @@ -1382,13 +1197,18 @@ const Index = class extends Component { !savePermission } > - {isSaving - ? existingChangeRequest - ? 'Updating Change Request' - : 'Creating Change Request' - : existingChangeRequest - ? 'Update Change Request' - : 'Create Change Request'} + {(() => { + if ( + isSaving + ) { + return existingChangeRequest + ? 'Updating Change Request' + : 'Creating Change Request' + } + return existingChangeRequest + ? 'Update Change Request' + : 'Create Change Request' + })()} , ) } @@ -1421,13 +1241,18 @@ const Index = class extends Component { !savePermission } > - {isSaving - ? existingChangeRequest - ? 'Updating Change Request' - : 'Scheduling Update' - : existingChangeRequest - ? 'Update Change Request' - : 'Schedule Update'} + {(() => { + if ( + isSaving + ) { + return existingChangeRequest + ? 'Updating Change Request' + : 'Scheduling Update' + } + return existingChangeRequest + ? 'Update Change Request' + : 'Schedule Update' + })()} )} @@ -1862,11 +1687,59 @@ const Index = class extends Component { } > - {Settings( - projectAdmin, - createFeature, - featureContentType, - )} + { + const updates = { + settingsChanged: true, + } + if (projectFlag.tags !== undefined) { + updates.tags = projectFlag.tags + } + if ( + projectFlag.description !== + undefined + ) { + updates.description = + projectFlag.description + } + if ( + projectFlag.is_server_key_only !== + undefined + ) { + updates.is_server_key_only = + projectFlag.is_server_key_only + } + if ( + projectFlag.is_archived !== + undefined + ) { + updates.is_archived = + projectFlag.is_archived + } + if ( + projectFlag.metadata !== undefined + ) { + updates.metadata = + projectFlag.metadata + delete updates.settingsChanged + } + this.setState(updates) + }} + onHasMetadataRequiredChange={( + hasMetadataRequired, + ) => + this.setState({ + hasMetadataRequired, + }) + } + /> + this.setState({ name })} + caseSensitive={caseSensitive} + regex={regex} + regexValid={regexValid} + autoFocus + /> { const updates = {} if (featureState.enabled !== undefined) { @@ -1964,33 +1836,35 @@ const Index = class extends Component { } this.setState(updates) }} + onProjectFlagChange={(projectFlag) => { + const updates = { settingsChanged: true } + if (projectFlag.tags !== undefined) { + updates.tags = projectFlag.tags + } + if (projectFlag.description !== undefined) { + updates.description = + projectFlag.description + } + if ( + projectFlag.is_server_key_only !== + undefined + ) { + updates.is_server_key_only = + projectFlag.is_server_key_only + } + if (projectFlag.is_archived !== undefined) { + updates.is_archived = + projectFlag.is_archived + } + if (projectFlag.metadata !== undefined) { + updates.metadata = projectFlag.metadata + delete updates.settingsChanged + } + this.setState(updates) + }} removeVariation={this.removeVariation} updateVariation={this.updateVariation} addVariation={this.addVariation} - onTagsChange={(tags) => - this.setState({ tags, settingsChanged: true }) - } - onMetadataChange={(metadata) => - this.setState({ metadata }) - } - onDescriptionChange={(description) => - this.setState({ - description, - settingsChanged: true, - }) - } - onServerKeyOnlyChange={(is_server_key_only) => - this.setState({ - is_server_key_only, - settingsChanged: true, - }) - } - onArchivedChange={(is_archived) => - this.setState({ - is_archived, - settingsChanged: true, - }) - } onHasMetadataRequiredChange={( hasMetadataRequired, ) => @@ -2005,52 +1879,18 @@ const Index = class extends Component { 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 && ( diff --git a/frontend/web/components/modals/CreateFlag/tabs/CreateFeature.tsx b/frontend/web/components/modals/CreateFlag/tabs/CreateFeature.tsx index 83c6b320454b..db881a8b9527 100644 --- a/frontend/web/components/modals/CreateFlag/tabs/CreateFeature.tsx +++ b/frontend/web/components/modals/CreateFlag/tabs/CreateFeature.tsx @@ -1,7 +1,7 @@ import React, { FC } from 'react' import { FeatureState, ProjectFlag } from 'common/types/responses' -import FeatureValue from 'components/modals/CreateFlag/tabs/FeatureValue' -import FeatureSettings from 'components/modals/CreateFlag/tabs/FeatureSettings' +import FeatureValue from './FeatureValue' +import FeatureSettings from './FeatureSettings' import ErrorMessage from 'components/ErrorMessage' import WarningMessage from 'components/WarningMessage' import { useHasPermission } from 'common/providers/Permission' @@ -11,24 +11,16 @@ type CreateFeatureTabProps = { error: any multivariate_options: any[] environmentVariations: any[] - featureState: Partial + featureState: FeatureState environmentFlag: any - projectFlag: ProjectFlag | null | undefined + projectFlag: ProjectFlag | null featureContentType: any identity?: string - tags: number[] - description: string - is_server_key_only: boolean - is_archived: boolean - onChange: (featureState: Partial) => void + onChange: (featureState: FeatureState) => void + onProjectFlagChange: (projectFlag: ProjectFlag) => void removeVariation: (i: number) => void updateVariation: (i: number, e: any, environmentVariations: any[]) => void addVariation: () => void - onTagsChange: (tags: number[]) => void - onMetadataChange: (metadata: any[]) => void - onDescriptionChange: (description: string) => void - onServerKeyOnlyChange: (is_server_key_only: boolean) => void - onArchivedChange: (is_archived: boolean) => void onHasMetadataRequiredChange: (hasMetadataRequired: boolean) => void featureError?: string featureWarning?: string @@ -36,7 +28,6 @@ type CreateFeatureTabProps = { const CreateFeature: FC = ({ addVariation, - description, environmentFlag, environmentVariations, error, @@ -45,20 +36,13 @@ const CreateFeature: FC = ({ featureState, featureWarning, identity, - is_archived, - is_server_key_only, multivariate_options, - onArchivedChange, onChange, - onDescriptionChange, onHasMetadataRequiredChange, - onMetadataChange, - onServerKeyOnlyChange, - onTagsChange, + onProjectFlagChange, projectFlag, projectId, removeVariation, - tags, updateVariation, }) => { const { permission: createFeature } = useHasPermission({ @@ -78,41 +62,37 @@ const CreateFeature: FC = ({ <> - - + {!!projectFlag && ( + <> + + + + )} ) } diff --git a/frontend/web/components/modals/CreateFlag/tabs/FeatureSettings.tsx b/frontend/web/components/modals/CreateFlag/tabs/FeatureSettings.tsx index 929e2d22c6ee..433e8e3c33d8 100644 --- a/frontend/web/components/modals/CreateFlag/tabs/FeatureSettings.tsx +++ b/frontend/web/components/modals/CreateFlag/tabs/FeatureSettings.tsx @@ -2,7 +2,6 @@ import React, { FC } from 'react' import { ProjectFlag } from 'common/types/responses' import Constants from 'common/constants' import InfoMessage from 'components/InfoMessage' -import FormGroup from 'components/base/forms/FormGroup' import InputGroup from 'components/base/forms/InputGroup' import AddEditTags from 'components/tags/AddEditTags' import AddMetadataToEntity from 'components/metadata/AddMetadataToEntity' @@ -10,12 +9,13 @@ import Permission from 'common/providers/Permission' import FlagOwners from 'components/FlagOwners' import FlagOwnerGroups from 'components/FlagOwnerGroups' import PlanBasedBanner from 'components/PlanBasedAccess' -import Row from 'components/base/Row' 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' type FeatureSettingsTabProps = { projectAdmin: boolean createFeature: boolean @@ -23,36 +23,20 @@ type FeatureSettingsTabProps = { identity?: string isEdit: boolean projectId: number | string - projectFlag: ProjectFlag | null | undefined - tags: number[] - description: string - is_server_key_only: boolean - is_archived: boolean - onTagsChange: (tags: number[]) => void - onMetadataChange: (metadata: any[]) => void - onDescriptionChange: (description: string) => void - onServerKeyOnlyChange: (is_server_key_only: boolean) => void - onArchivedChange: (is_archived: boolean) => void + projectFlag: ProjectFlag | null + onChange: (projectFlag: ProjectFlag) => void onHasMetadataRequiredChange: (hasMetadataRequired: boolean) => void } const FeatureSettings: FC = ({ createFeature, - description, featureContentType, identity, isEdit, - is_archived, - is_server_key_only, - onArchivedChange, - onDescriptionChange, + onChange, onHasMetadataRequiredChange, - onMetadataChange, - onServerKeyOnlyChange, - onTagsChange, projectFlag, projectId, - tags, }) => { const metadataEnable = Utils.getPlansPermission('METADATA') @@ -68,9 +52,12 @@ const FeatureSettings: FC = ({ ) } + if (!projectFlag) { + return null + } return ( <> - {!identity && tags && ( + {!identity && projectFlag?.tags && ( = ({ onChange({ ...projectFlag, tags })} /> } /> @@ -96,16 +83,12 @@ const FeatureSettings: FC = ({ entityContentType={featureContentType?.id} entity={featureContentType?.model} setHasMetadataRequired={onHasMetadataRequiredChange} - onChange={onMetadataChange} + onChange={(metadata) => onChange({ ...projectFlag, metadata })} /> )} - {!identity && projectFlag && ( - + {!identity && projectFlag?.id && ( + {({ permission }) => permission && ( <> @@ -127,13 +110,18 @@ const FeatureSettings: FC = ({ )} onDescriptionChange(Utils.safeParseEventValue(e))} + onChange={(e: InputEvent) => + onChange({ + ...projectFlag, + description: Utils.safeParseEventValue(e), + }) + } ds type='text' title={identity ? 'Description' : 'Description (optional)'} @@ -145,8 +133,10 @@ const FeatureSettings: FC = ({ + onChange({ ...projectFlag, is_server_key_only }) + } className='ml-0' /> = ({ + onChange({ ...projectFlag, is_archived }) + } className='ml-0' /> = ({ ),
)} -
+
{filteredTags && filteredTags.map((tag) => (
= ({ : 'opacity-0 pointer-events-none' } > - +
confirmDeleteTag(tag)} className='ml-3 clickable' > - +
)} diff --git a/frontend/web/components/tags/TagValues.tsx b/frontend/web/components/tags/TagValues.tsx index 6e8273a21c1f..74d697730b68 100644 --- a/frontend/web/components/tags/TagValues.tsx +++ b/frontend/web/components/tags/TagValues.tsx @@ -64,7 +64,7 @@ const TagValues: FC = ({ ), } - -) -``` - -## Managing Feature Flags via MCP - -This project uses the **flagsmith-admin-api MCP** for feature flag management. All operations are performed through MCP tools instead of manual API calls or web console. - -### ⚠️ CRITICAL: Project Verification Before Creating Flags - -**STOP! Before creating ANY feature flag, follow this mandatory checklist:** - -1. ✅ **Verify the correct project**: This frontend ONLY uses Project ID 12 (Flagsmith Website) -2. ✅ **Read configuration**: Check `common/project.js` to see the Flagsmith API key (`ENktaJnfLVbLifybz34JmX` for staging) -3. ✅ **Confirm with MCP**: Run `mcp__flagsmith__list_project_environments` with `project_id: 12` to verify -4. ✅ **Create ONCE**: Only call `mcp__flagsmith__create_feature` ONCE with `project_id: 12` - -**NEVER:** -- ❌ Create flags in multiple projects to "try" which one is correct -- ❌ Create flags in Project ID 11737 (Flagsmith API) - that's a different project -- ❌ Guess the project ID without verification -- ❌ Create duplicate flags - -**Why this matters:** Creating flags in the wrong project pollutes other Flagsmith projects with incorrect flags that don't belong there. This is a critical error. - -### Known Limitations - -**IMPORTANT:** Published feature versions cannot be modified via the Flagsmith API. - -- After creating a flag with MCP (`mcp__flagsmith__create_feature`), the flag is created but disabled by default -- To enable/disable the flag in specific environments, you must use the Flagsmith web UI at https://app.flagsmith.com -- This is a Flagsmith API limitation, not a tooling issue -- The MCP can create flags, but enabling/disabling must be done manually via the UI - -**Workflow:** -1. Create flag via MCP → ✅ Automated -2. Implement code with `useFlags()` → ✅ Automated -3. Enable flag in staging/production → ❌ Manual (via Flagsmith UI) - -When documenting completion, always inform the user that step 3 requires manual action via the web UI. - -### CRITICAL: When User Says "Create a Feature Flag" - -**When the user requests to create a feature flag, you MUST:** - -1. ✅ **Actually create the flag in Flagsmith** using `mcp__flagsmith__create_feature` -2. ✅ **Implement the frontend code** that uses the flag with `useFlags()` -3. ✅ **Return the flag details** (ID, name, project) to confirm creation - -**DO NOT:** -- ❌ Only implement the code without creating the flag -- ❌ Assume the flag already exists -- ❌ Assume the user will create it manually - -**This is a two-part task:** -- **Backend (Flagsmith)**: Create the flag entity in Flagsmith -- **Frontend (Code)**: Write code that checks the flag with `useFlags()` - -Both parts are required when "create a feature flag" is requested. - -**Standard Workflow Example:** -``` -User: "Add a download button, create this under a feature flag download_invoices" - -Step 1: Create flag in Flagsmith - - Use mcp__flagsmith__list_organizations (if needed) - - Use mcp__flagsmith__list_projects_in_organization (find "portal" project) - - Use mcp__flagsmith__create_feature with name "download_invoices" - - Confirm flag ID and status to user - -Step 2: Implement code - - Add useFlags(['download_invoices']) to component - - Wrap button with flag check: {flags.download_invoices?.enabled && } - - Test that code compiles - -Step 3: Report completion - - Confirm flag created in Flagsmith (with ID) - - Confirm code implementation complete -``` - -### Available MCP Tools - -The MCP provides tools prefixed with `mcp__flagsmith-admin-api__` for managing feature flags. Key operations: - -#### Discovery & Listing -- **`list_organizations`** - List all organizations accessible with your API key -- **`list_projects_in_organization`** - List all projects in an organization -- **`list_project_features`** - List all feature flags in a project -- **`list_project_environments`** - List all environments (staging, production, etc.) -- **`list_project_segments`** - List user segments for targeting - -#### Feature Flag Operations -- **`create_feature`** - Create a new feature flag (defaults to disabled) -- **`get_feature`** - Get detailed information about a specific flag -- **`update_feature`** - Update flag name or description -- **`get_feature_evaluation_data`** - Get analytics/metrics for a flag -- **`get_feature_external_resources`** - Get linked resources (Jira, GitHub, etc.) -- **`get_feature_code_references`** - Get code usage information - -#### Feature State Management -- **`get_environment_feature_versions`** - Get version info for a flag in an environment -- **`get_environment_feature_version_states`** - Get state info for a specific version -- **`create_environment_feature_version_state`** - Create new state (enable/disable/set value) -- **`update_environment_feature_version_state`** - Update existing state -- **`patch_environment_feature_version_state`** - Partially update state - -#### Advanced Features -- **`create_multivariate_option`** - Create A/B test variants -- **`list_multivariate_options`** - List all variants for a flag -- **`update_multivariate_option`** / **`delete_multivariate_option`** - Manage variants -- **`create_project_segment`** - Create user targeting rules -- **`update_project_segment`** / **`get_project_segment`** - Manage segments -- **`list_project_change_requests`** - List change requests for approval workflows -- **`create_environment_change_reques...`** - Create controlled deployment requests -- **`list_project_release_pipelines`** - List automated deployment pipelines - -### Common Workflows - -#### 1. Find Your Project -``` -Step 1: List organizations -Tool: mcp__flagsmith-admin-api__flagsmith_admin_api_list_organizations - -Step 2: List projects in your organization -Tool: mcp__flagsmith-admin-api__flagsmith_admin_api_list_projects_in_organization -Parameters: {"org_id": } - -Step 3: Find project by matching repository name to project name -``` - -#### 2. List Existing Feature Flags -``` -Tool: mcp__flagsmith-admin-api__flagsmith_admin_api_list_project_features -Parameters: {"project_id": } -Optional: Add query params for pagination: {"page": 1, "page_size": 50} -``` - -#### 3. Create a New Feature Flag -``` -Step 1: Create the flag (disabled by default) -Tool: mcp__flagsmith-admin-api__flagsmith_admin_api_create_feature -Parameters: - pathParameters: {"project_id": } - body: {"name": "flag_name", "description": "Description"} - -Step 2: Get environment IDs -Tool: mcp__flagsmith-admin-api__flagsmith_admin_api_list_project_environments -Parameters: {"project_id": } - -Step 3: Enable for staging/development -Tool: mcp__flagsmith-admin-api__flagsmith_admin_api_get_environment_feature_versions -Then use create/update_environment_feature_version_state to enable -``` - -#### 4. Enable/Disable a Flag in an Environment -``` -Step 1: Get feature versions -Tool: mcp__flagsmith-admin-api__flagsmith_admin_api_get_environment_feature_versions -Parameters: {"environment_id": , "feature_id": } - -Step 2: Update feature state -Tool: mcp__flagsmith-admin-api__flagsmith_admin_api_patch_environment_feature_version_state -Parameters: - pathParameters: {"environment_id": , "feature_id": , "version_id": } - body: {"enabled": true} -``` - -### Best Practices - -1. **Always look up IDs dynamically** - Don't hardcode organization, project, or feature IDs -2. **Match repository to project** - Project names typically correspond to repository names -3. **Start disabled** - New flags are created disabled by default -4. **Enable in staging first** - Test in non-production environments before enabling in production -5. **Use descriptive names** - Follow snake_case naming: `download_invoices`, `new_dashboard` -6. **Document usage** - Note which components use each flag - -### Environment-Specific Configuration - -When creating a new feature flag: -1. **Create the flag** (disabled globally by default) -2. **Enable for staging/development** to allow testing -3. **Keep production disabled** until ready for release -4. **Use change requests** for production changes if approval workflows are configured - -### Trait Example (User Preferences) -```typescript -// Traits are user-specific preferences, not feature toggles -const flags = useFlags([], ['dark_mode']) -const isDarkMode = flags.dark_mode // Returns boolean/string/number directly - -// Setting a trait -const flagsmith = useFlagsmith() -flagsmith.setTrait('dark_mode', true) -``` - -## Reference Implementation - -See `pages/dashboard.tsx` for a complete example of: -- Feature flag setup with `useFlags(['flag_name'])` -- Conditional component rendering -- Checking `.enabled` property -- Wrapping entire components with feature flags - -See `components/DarkModeHandler.tsx` for an example of trait usage. diff --git a/frontend/.claude/context/forms.md b/frontend/.claude/context/forms.md deleted file mode 100644 index 416d229cb983..000000000000 --- a/frontend/.claude/context/forms.md +++ /dev/null @@ -1,130 +0,0 @@ -# Form Patterns (Yup + Formik) - -## Standard Pattern for ALL Forms - -```typescript -import { useFormik } from 'formik' -import * as yup from 'yup' -import { validateForm } from 'project/utils/forms/validateForm' - -const schema = yup.object().shape({ - name: yup.string().required('Name is required'), -}) - -const MyForm = () => { - const { errors, touched, values, handleChange, handleBlur, handleSubmit, setTouched } = useFormik({ - initialValues: { name: '' }, - onSubmit: async (values) => { /* API call */ }, - validationSchema: schema, - validateOnMount: true, - }) - - const onSubmit = async (e) => { - e.preventDefault() - const isValid = await validateForm(errors, setTouched) - if (isValid) handleSubmit() - } - - return ( -
- - - - ) -} -``` - -## Form Components (in `components/base/forms/`) - -- **InputGroup**: Standard wrapper - pass `touched` and `error` props -- **DatePicker**, **PhoneInput**, **Select**: Use with `component` prop on InputGroup -- **Radio**, **Checkbox**, **Switch**: Boolean/choice inputs - -**Reference**: See `/examples/forms/ComprehensiveFormExample.tsx` - -## Form Spacing & Layout - -### Standard Form Layout Structure - -```tsx -
-
-

Form Title

- -
- - - -
-
-
-``` - -### Spacing Classes - -Use Bootstrap's spacing scale consistently: - -- **Between InputGroups**: `d-flex flex-column gap-4` (24px vertical gap) -- **Section header margin**: `h3 className='mb-4 pb-3 border-bottom'` (24px bottom) -- **Two-column rows**: `d-flex gap-4 mb-4` with `flex-1` per column -- **Button rows**: `d-flex justify-content-end gap-2 mt-3` (8px between buttons, 16px top margin) -- **Error messages**: `mb-5` when standalone (48px) - -### Multi-Section Forms - -For forms with multiple sections: - -```tsx -
-
{/* 48px between sections */} - - {/* Section 1 */} -
-

Section Title

-
- - -
-
- - {/* Section 2 */} -
-

Another Section

-
- -
-
- - {/* Actions */} -
- - -
-
-
-``` - -### Bootstrap Gap/Margin Scale - -- `gap-2` = 0.5rem (8px) -- `gap-3` = 1rem (16px) -- `gap-4` = 1.5rem (24px) ← Use for InputGroup spacing -- `gap-5` = 3rem (48px) ← Use for section separation - -- `mb-3` = 1rem (16px) -- `mb-4` = 1.5rem (24px) ← Use for section headers -- `mb-5` = 3rem (48px) ← Use for standalone errors/content - -### Key Files with Examples - -- `/components/examples/forms/ComprehensiveFormExample.tsx` - Full pattern with sections -- `/components/ChangeAccountInformation.tsx` - Account form spacing -- `/components/ChangeContact.tsx` - Contact form spacing -- `/components/whatsapp/CreateEditNumber.tsx` - Modal form pattern diff --git a/frontend/.claude/context/git-workflow.md b/frontend/.claude/context/git-workflow.md deleted file mode 100644 index 7d26427c6e23..000000000000 --- a/frontend/.claude/context/git-workflow.md +++ /dev/null @@ -1,29 +0,0 @@ -# Git Workflow - -## Pre-Commit Checking Strategy - -Before creating commits, always check and lint staged files to catch errors early: - -```bash -npm run check:staged -``` - -Or use the slash command: -``` -/check-staged -``` - -This runs both typechecking and linting on staged files only, mimicking pre-commit hooks. - -## Available Scripts - -- `npm run check:staged` - Typecheck + lint staged files (use this!) -- `npm run typecheck:staged` - Typecheck staged files only -- `npm run lint:staged` - Lint staged files only (with --fix) - -## Important Notes - -- Never run `npm run typecheck` (full project) or `npm run lint` on all files unless explicitly requested -- Always focus on staged files only to keep checks fast and relevant -- The lint:staged script auto-fixes issues where possible -- Fix any remaining type errors or lint issues before committing diff --git a/frontend/.claude/context/mobile.md b/frontend/.claude/context/mobile.md deleted file mode 100644 index e8d62ac2d4be..000000000000 --- a/frontend/.claude/context/mobile.md +++ /dev/null @@ -1,748 +0,0 @@ -# Mobile App Development Guide - -## Directory Structure - -``` -mobile/app/ -├── components/ # Reusable mobile components -├── screens/ # Screen components -│ └── tabs/ # Tab screen components (Dashboard, Mail, etc.) -├── navigation/ # React Navigation setup -├── styles/ # Mobile-specific styles -└── project/ # Mobile-specific utilities -``` - -## Key Differences from Web - -### 1. Toast Notifications - -Mobile uses `react-native-toast-message`, NOT the web toast component. - -```typescript -// ✅ Correct for mobile -import Toast from 'react-native-toast-message' - -Toast.show({ text1: 'Success message' }) -Toast.show({ text1: 'Error message', type: 'error' }) - -// ❌ Wrong - web only -import { toast } from 'components/base/Toast' -``` - -### 2. Confirmation Dialogs - -Mobile uses native Alert or the `openConfirm` utility (NOT JSX-based modals). - -```typescript -// ✅ Correct for mobile -import openConfirm from 'components/utility-components/openConfirm' - -openConfirm( - 'Delete Item', - 'Are you sure you want to delete this item?', - () => handleDelete(), - undefined, // optional onNo callback - 'Delete', // optional yes text - 'Cancel' // optional no text -) - -// ❌ Wrong - web only -import { openConfirm } from 'components/base/Modal' -openConfirm('Title', , callback) -``` - -### 3. Modals - -Mobile uses `CustomModal` component from `components/CustomModal`. - -```typescript -// ✅ Correct for mobile -import CustomModal from 'components/CustomModal' - - - {/* content */} - - -// ❌ Wrong - web only -import ModalDefault from 'components/base/ModalDefault' -``` - -### 4. Icons - -Mobile uses the shared `Icon` component from `project/Icon`. - -```typescript -// ✅ Correct for mobile -import Icon from 'project/Icon' - - - -``` - -### 5. Forms & Inputs - -Mobile uses React Native specific form components. - -```typescript -// ✅ Correct for mobile -import TextInput from 'components/base/forms/TextInput' -import Button from 'components/base/forms/Button' - - - - -``` - -## Component Naming Conventions - -Follow these established patterns in `mobile/app/components/`: - -### Table Components -- `MailTable.tsx` - Displays mail items -- `TeamTable.tsx` - Displays team members -- `WhatsAppTable.tsx` - Displays WhatsApp numbers with inline edit/delete actions - -**WhatsApp Table Pattern:** -The WhatsAppTable component demonstrates the canonical pattern for table/list components with CRUD operations: -- Modal-based create/edit instead of full-screen navigation -- Inline action buttons (Edit/Delete) per row -- Actions hidden for cancelled/inactive items -- Uses `CreateEditNumber` modal for both create and edit modes -- Delete actions use confirmation dialog via `openConfirm` -- State management with `modalOpen` and `editing` for modal control - -```typescript -// State management -const [modalOpen, setModalOpen] = useState(false) -const [editing, setEditing] = useState(null) - -// Edit handler - opens modal with data -const handleEdit = (num: NumberDetails) => { - setEditing(num) - setModalOpen(true) -} - -// Add handler - opens modal without data -const handleAddNew = () => { - setEditing(null) - setModalOpen(true) -} - -// Modal integration - setModalOpen(false)} - initial={editing} // null for create, data for edit -/> -``` - -### Modal Components -- `CreateEditNumber.tsx` - Create/edit number modal (supports both modes via `initial` prop) -- `RequestDocumentModal.tsx` - Request document modal -- `VerifyAddressChangeModal.tsx` - Address verification modal -- Use `CustomModal` as the base - -### Card Components -- `AddressCard.tsx` -- `StatementsCard.tsx` -- `SubscriptionInfoCard.tsx` -- `FeaturedOfferCard.tsx` - -### Screen Components -- Located in `mobile/app/screens/tabs/` -- Named with `TabScreen` suffix (e.g., `MailTabScreen.tsx`) -- Export component WITHOUT "Tab" (e.g., `const MailScreen`) -- Export type WITH "Tab" (e.g., `export type MailTabScreen = {}`) - -```typescript -// ✅ Correct pattern -export type WhatsAppTabScreen = {} - -const WhatsAppScreen: FC> = () => { - // ... -} - -export default withScreen(WhatsAppScreen) -``` - -## Styling - -### Global Styles Pattern (Preferred) - -The mobile app uses a **centralized global styles system** via the `Styles` object, which is automatically available globally (no import needed). - -#### When to Use Global Styles (Styles object) - -Use global styles for: -- **Spacing** - All margins and padding (mt4, mb3, ph5, p6) -- **Typography** - Text sizes, weights, colors (h1, h4, textBold, textMuted) -- **Colors** - Text and background colors (textPrimary, bgWhite, textDanger) -- **Layout** - Flexbox helpers (flex, row, gap, alignCenter) -- **Common utilities** - list, borderRadius, textCenter - -```typescript -// ✅ Correct: Use global styles for common patterns - - Title - Description - - - - - - - - } /> - } /> - -``` - -#### Available Global Style Utilities - -**Spacing (based on paddingBase = 4):** -- Margin: m0-m10, mb0-mb10, mt0-mt10, ml0-ml10, mr0-mr10, mh0-mh10, mv0-mv10 -- Padding: p0-p10, pb0-pb10, pt0-pt10, pl0-pl10, pr0-pr10, ph0-ph10, pv0-pv10 - -**Typography:** -- Headings: display, h1, h2, h3, h4, h5 -- Weights: textBold, textExtraBold, textSemibold, textLight -- Sizes: textSmall, textExtraSmall -- Alignment: textCenter, textLeft, textRight -- Colors: textMuted, textMutedLight, textPrimary, textDanger, textWhite - -**Layout:** -- Flexbox: flex, flexGrow, row, column, gap -- Alignment: alignCenter, alignStart, alignEnd, justifyCenter, justifyStart, justifyEnd -- Containers: container, centeredContainer -- Borders: borderRadius, borderRadiusSm, borderRadiusXs -- Lists: list - -**Backgrounds:** -- bgWhite, bgBody, bgPrimary, bgDark -- primary20, primary40, primary60, primary80 - -### When to Use Local StyleSheet.create - -Use local `StyleSheet.create()` **only** for component-specific complex styles: - -```typescript -// ✅ Correct: Local styles for component-specific styling -const styles = StyleSheet.create({ - numberCard: { - backgroundColor: palette.white, - borderBottomColor: palette.border, - borderBottomWidth: 1, - marginBottom: paddingBase, - padding: paddingBase * 4, - }, -}) -``` - -Use local styles for: -- Complex component-specific styles (shadows, specific borders) -- Detailed layout that won't be reused -- Platform-specific styles (Platform.select()) -- Performance-critical styles that shouldn't be recreated - -### Common Style Imports (for local styles only) - -```typescript -import { StyleSheet } from 'react-native' -import { paddingBase } from 'styles/style_grid' -import { palette } from 'styles/style_variables' - -// Note: Styles object is global, no import needed -``` - -### Hybrid Pattern: Global + Local Styles - -```typescript -// ✅ Best practice: Combine global utilities with local component styles - - - Title - Description - - - -const styles = StyleSheet.create({ - card: { - backgroundColor: palette.white, - borderRadius: 8, - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.1, - }, -}) -``` - -## Navigation - -### Navigation Architecture - -Mobile uses bottom tab navigation with 5 tabs: -1. **Home/Dashboard** (Tab1Container) -2. **Mail** (Tab2Container) -3. **Marketplace** (Tab3Container) -4. **WhatsApp** (Tab4Container) -5. **Settings** (Tab5Container) - -**Tab Screens** live in `mobile/app/screens/tabs/` and use: -- `AccountNav` (shows account selector at top) -- `ScreenContainer` wrapper -- Named with `TabScreen` suffix - -**Regular Screens** live in `mobile/app/screens/` and use: -- `CustomNavbar` (shows back button) -- No `AccountNav` or `ScreenContainer` -- Accessible from other screens (like Settings menu) - -### Tab Screen Pattern - -For screens in the bottom tab navigation: - -```typescript -// mobile/app/screens/tabs/MailTabScreen.tsx -import { RootStackParamList } from 'navigation/types' -import withScreen, { Screen } from 'screens/withScreen' -import ScreenContainer from 'components/ScreenContainer' -import AccountNav from 'components/AccountNav' - -export type MailTabScreen = {} - -const MailScreen: FC> = () => { - return ( - - - - {/* content */} - - - ) -} - -export default withScreen(MailScreen) -``` - -### Regular Screen Pattern - -For screens navigable from other screens (not in tabs): - -```typescript -// mobile/app/screens/TeamScreen.tsx -import { RootStackParamList } from 'navigation/types' -import withScreen, { Screen } from 'screens/withScreen' -import CustomNavbar from 'components/CustomNavbar' - -export type TeamScreen = {} - -const TeamScreen: FC> = () => { - return ( - - - - {/* content */} - - - - ) -} - -export default withScreen(TeamScreen) -``` - -### Adding a New Screen to Navigation - -When adding a new screen, you must update multiple files: - -1. **Add route to `route-urls.ts`**: -```typescript -export enum RouteUrls { - // ... - 'MyNewScreen' = '/my-new-screen', - // END OF SCREENS -} -``` - -2. **Add screen import and route to `routes.tsx`**: -```typescript -import MyNewScreen from './screens/MyNewScreen' - -export const routes: Record = { - // ... - [RouteUrls.MyNewScreen]: { - component: MyNewScreen, - options: { - headerShown: false, - }, - }, - // END OF SCREENS -} -``` - -3. **Add type to `navigation/types.ts`**: -```typescript -import { MyNewScreen } from 'screens/MyNewScreen' - -export type RootStackParamList = { - // ... - [RouteUrls.MyNewScreen]: MyNewScreen - // END OF STACKS -} -``` - -4. **Register in `AppNavigator.tsx`**: -```typescript - -{/* END OF ROUTES*/} -``` - -### Modifying Tab Navigation - -To add or remove bottom tabs, update these files: - -1. **`BottomNav.tsx`** - The tab bar UI component: - - Add/remove `` components - - Update index numbers for each tab - - Import necessary icons - -2. **`BottomTabsNavigator.tsx`** - Tab navigation logic: - - Add/remove Stack components (Stack1, Stack2, etc.) - - Update `` components - - Update type imports (Tab1StackParamList, etc.) - -3. **`route-urls.ts`** - Route definitions: - - Update TabXContainer routes - -4. **`navigation/types.ts`** - Type definitions: - - Update MainTabParamList - - Update TabXStackParamList types - -**Example: Moving a tab to a regular screen** -```typescript -// 1. Remove from BottomNav.tsx TabItem -// 2. Remove Stack from BottomTabsNavigator.tsx -// 3. Update route-urls.ts to renumber tabs -// 4. Move screen file from screens/tabs/ to screens/ -// 5. Change from AccountNav to CustomNavbar in screen -// 6. Add to AppNavigator.tsx Stack.Screen -// 7. Add menu item in Settings or parent screen -``` - -### Navigation Hooks - -```typescript -import { useNavigation } from '@react-navigation/native' -import { NativeStackNavigationProp } from '@react-navigation/native-stack' -import { RootStackParamList } from 'navigation/types' - -const navigation = useNavigation>() - -// Navigate to a screen -navigation.push(RouteUrls.MailDetailScreen, { id: '123' }) -navigation.navigate(RouteUrls.DashboardScreen) -navigation.goBack() - -// From Settings screen, navigate to detail screen -push(RouteUrls.BusinessScreen, {}) -``` - -## Lists & Scrolling - -### Use VerticalList for Paginated Data - -```typescript -import VerticalList from 'components/VerticalList' -import { useGetMailQuery } from 'common/services/useMail' -import { Req, Res } from 'common/types/requests' - - - query={useGetMailQuery} - queryParams={{ - subscription_id: subscriptionId, - page_size: 20, - }} - renderItem={({ item }) => } - ListHeaderComponent={
} - ListEmptyComponent={} - listProps={{ - refreshControl: , - }} -/> -``` - -### Use FlatList for Simple Lists - -```typescript -import { FlatList } from 'react-native' - - } - keyExtractor={(item) => item.id} - ListEmptyComponent={No items} -/> -``` - -## Forms & Screens - -### Form Screen Pattern - -Use `InviteScreen` as the canonical example for form screens. Key characteristics: - -```typescript -import { KeyboardAwareScrollView } from 'react-native-keyboard-controller' -import CustomNavbar from 'components/CustomNavbar' -import Container from 'components/base/grid/Container' -import TextInput from 'components/base/forms/TextInput' -import Button from 'components/base/forms/Button' -import { ErrorMessage } from 'components/Messages' -import withScreen, { Screen } from 'screens/withScreen' - -const MyFormScreen: React.FC> = () => { - const [field, setField] = useState('') - const isValid = !!field - - const handleSubmit = async () => { - // Submit logic - rootPop() // Go back after success - Toast.show({ text1: 'Success message' }) - } - - return ( - - - - - - Section Title - - - {error} - - - - - - ) -} -``` - -**Key Points:** -- Use `KeyboardAwareScrollView` for forms -- Use `CustomNavbar` with back button -- Use `Container` for content padding -- TextInput has `title` prop for labels (NOT separate Text components) -- Add `required` prop to required TextInputs -- Use `rootPop()` to navigate back after success -- Use global `Styles` (no imports needed) - -### TextInput Patterns - -```typescript -// Basic text input - - -// Email input with validation - - -// With placeholder - -``` - -### Loading States - -```typescript -import Loader from 'components/Loader' - -{isLoading && ( - - - -)} -``` - -### Empty States - -```typescript -{!isLoading && items?.length === 0 && ( - - No items found - -)} -``` - -### Error Handling - -```typescript -import { ErrorMessage } from 'components/Messages' - -{error && {error}} -``` - -### Pull to Refresh - -```typescript -const [refreshKey, setRefreshKey] = useState() - -const refetch = () => { - setRefreshKey(Date.now()) -} - -// Pass refreshKey to VerticalList or use RefreshControl -``` - -## API Integration - -Mobile shares the same API services as web from `common/services/`. Follow the API integration guide but use mobile-specific UI patterns for feedback. - -### Query Example - -```typescript -import { useGetNumbersQuery } from 'common/services/useNumber' - -const { data: numbers, isLoading } = useGetNumbersQuery( - { company_id }, - { - pollingInterval: 5000, // Poll every 5 seconds - skip: !company_id, // Skip if no company_id - } -) -``` - -### Mutation Example - -```typescript -import { useCreateNumberMutation } from 'common/services/useNumber' -import Toast from 'react-native-toast-message' - -const [createNumber, { isLoading }] = useCreateNumberMutation() - -const handleSubmit = async () => { - const result: any = await createNumber(payload) - - if (result.error) { - Toast.show({ text1: 'Error creating number' }) - return - } - - Toast.show({ text1: 'Number created successfully' }) -} -``` - -## Testing Mobile Changes - -### Run TypeCheck - -```bash -npx tsc --noEmit --project mobile/tsconfig.json -``` - -### Run Linter - -```bash -npx eslint mobile/app --fix -``` - -### Check Staged Files - -```bash -npm run check:staged -``` - -## Handling Cancelled/Inactive Items - -When displaying lists that include cancelled or inactive items, follow this pattern: - -```typescript -// Visual indicator - reduce opacity for cancelled items -style={[ - styles.numberCard, - { opacity: num.cancelled_on ? 0.5 : 1 }, -]} - -// Hide actions for cancelled items -{!num.cancelled_on && ( - - handleEdit(num)} icon={} /> - handleDelete(num)} icon={} /> - -)} - -// Show cancellation status -{num.cancelled_on && ( - - Cancelled on {dateAndTime(num.cancelled_on)} - -)} -``` - -**Key principles:** -- Reduce opacity to 0.5 for visual differentiation -- Hide action buttons completely (not just disable them) -- Show cancellation date/reason if available -- Keep cancelled items visible for historical reference -- Sort cancelled items to the end of the list (handle in API query or transform) - -## Common Pitfalls - -1. **Don't use web-only imports** in mobile code (like `components/base/Toast`) -2. **Don't use JSX in Alert messages** - use strings only -3. **Always import from path aliases** - never use relative imports -4. **Use `react-native-toast-message`** for toasts, not web toast -5. **Use `openConfirm` utility** for confirms, not web Modal component -6. **Check existing components** before creating new ones - maintain consistency -7. **Don't disable action buttons for cancelled items** - hide them completely instead - -## Related Documentation - -- See `patterns.md` for general code patterns -- See `api-integration.md` for API service patterns -- See `forms.md` for form validation patterns diff --git a/frontend/.claude/context/patterns.md b/frontend/.claude/context/patterns.md deleted file mode 100644 index 58fa9fff7325..000000000000 --- a/frontend/.claude/context/patterns.md +++ /dev/null @@ -1,543 +0,0 @@ -# Common Code Patterns - -## Complete Feature Implementation Example - -This end-to-end example shows how to add tabs with a new API endpoint (real implementation from the codebase). - -**Requirements:** Add a "Top-Up" invoices tab to the account-billing page, pulling from a new backend endpoint. - -### Step 1: Check Backend API - -```bash -cd ../api -git fetch -git show COMMIT_HASH:apps/customers/urls.py | grep "invoice" -# Found: path("companies//invoices", get_company_invoices) -``` - -### Step 2: Add Request Type - -**File:** `common/types/requests.ts` - -```typescript -export type Req = { - // ... existing types - getCompanyInvoices: { - company_id: string - } -} -``` - -### Step 3: Extend RTK Query Service - -**File:** `common/services/useInvoice.ts` - -```typescript -export const invoiceService = service - .enhanceEndpoints({ addTagTypes: ['Invoice'] }) - .injectEndpoints({ - endpoints: (builder) => ({ - getCompanyInvoices: builder.query< - Res['invoices'], - Req['getCompanyInvoices'] - >({ - providesTags: [{ id: 'LIST', type: 'Invoice' }], - query: (req) => ({ - url: `customers/companies/${req.company_id}/invoices`, - }), - transformResponse(res: InvoiceSummary[]) { - return res?.map((v) => ({ ...v, date: v.date * 1000 })) - }, - }), - }), - }) - -export const { - useGetCompanyInvoicesQuery, - // END OF EXPORTS -} = invoiceService -``` - -### Step 4: Create Table Component - -**File:** `components/project/tables/CompanyInvoiceTable.tsx` - -```typescript -import { useGetCompanyInvoicesQuery } from 'common/services/useInvoice' -import { useDefaultSubscription } from 'common/services/useDefaultSubscription' - -const CompanyInvoiceTable: FC = () => { - const { subscriptionDetail } = useDefaultSubscription() - const companyId = subscriptionDetail?.company_id - - const { data: invoices, error, isLoading } = useGetCompanyInvoicesQuery( - { company_id: `${companyId}` }, - { skip: !companyId } - ) - - if (isLoading) return - if (error) return {error} - - return ( - - - {/* table structure */} -
-
- ) -} -``` - -### Step 5: Add Tabs to Page - -**File:** `pages/account-billing.tsx` - -```typescript -import { useState } from 'react' -import { Tabs } from 'components/base/forms/Tabs' -import InvoiceTable from 'components/project/tables/InvoiceTable' -import CompanyInvoiceTable from 'components/project/tables/CompanyInvoiceTable' - -const AccountAndBilling = () => { - const [activeTab, setActiveTab] = useState(0) - - return ( -
-

Invoices

- -
-
-
-
- ) -} -``` - -### Step 6: Run Linter - -```bash -npx eslint --fix common/types/requests.ts common/services/useInvoice.ts \ - components/project/tables/CompanyInvoiceTable.tsx pages/account-billing.tsx -``` - -**Done!** The feature is now live with tabs and proper error handling. - -### Optional: Add Feature Flag - -If you need to gate this feature behind a feature flag (only when explicitly requested), see `feature-flags.md` for the pattern. - -## Import Rules - -**ALWAYS use path aliases - NEVER use relative imports** - -```typescript -// ✅ Correct -import { service } from 'common/service' -import { Button } from 'components/base/forms/Button' -import { validateForm } from 'project/utils/forms/validateForm' - -// ❌ Wrong -import { service } from '../service' -import { Button } from '../../base/forms/Button' -import { validateForm } from '../../../utils/forms/validateForm' -``` - -## Mobile-Specific Patterns - -### Toast Notifications (Mobile) - -Mobile uses `react-native-toast-message`: - -```typescript -// ✅ Correct for mobile -import Toast from 'react-native-toast-message' - -Toast.show({ text1: 'Success message' }) -Toast.show({ text1: 'Error message', type: 'error' }) - -// ❌ Wrong - this is web only -import { toast } from 'components/base/Toast' -toast('Success', 'Message') -``` - -### Icons (Mobile) - -Mobile uses specific SVG component imports, not a generic Icon component: - -```typescript -// ✅ Correct for mobile -import EditIcon from 'components/svgs/EditIcon' -import RemoveIcon from 'components/svgs/RemoveIcon' -import IconButton from 'components/IconButton' - -} onPress={handleEdit} /> -} onPress={handleDelete} /> - -// ❌ Wrong - this is web only -import Icon from 'project/Icon' - -``` - -### Confirm Dialogs (Mobile) - -Mobile uses Alert or the utility function: - -```typescript -// ✅ Correct for mobile -import openConfirm from 'components/utility-components/openConfirm' - -openConfirm( - 'Delete Item', - 'Are you sure you want to delete this item?', - () => handleDelete(), -) - -// ❌ Wrong - this is web only -import { openConfirm } from 'components/base/Modal' -openConfirm('Title', , callback) -``` - -### Component Naming (Mobile) - -Follow existing mobile patterns: - -```typescript -// Mobile components in mobile/app/components/ -- MailTable.tsx (not MailList) -- TeamTable.tsx -- WhatsAppTable.tsx -- CreateEditNumber.tsx (modal for creating/editing) -- Custom modals use CustomModal component -``` - -### Modal-Based CRUD Pattern - -For list components with create/edit capabilities, use a single modal for both operations: - -```typescript -import { useState } from 'react' -import CreateEditModal from './CreateEditModal' - -const MyTable: FC = () => { - const [modalOpen, setModalOpen] = useState(false) - const [editing, setEditing] = useState(null) - - const handleEdit = (item: ItemType) => { - setEditing(item) - setModalOpen(true) - } - - const handleCreate = () => { - setEditing(null) // null = create mode - setModalOpen(true) - } - - return ( - <> - - - {items?.map((item) => ( - - {!item.cancelled_on && ( - handleEdit(item)} - icon={} - /> - )} - - ))} - - setModalOpen(false)} - initial={editing} // null for create, data for edit - /> - - ) -} -``` - -**Modal component pattern:** -```typescript -type Props = { - isOpen: boolean - onClose: () => void - initial?: ItemType | null // null/undefined = create, data = edit - onSuccess?: () => void -} - -const CreateEditModal: FC = ({ isOpen, onClose, initial }) => { - const [createItem] = useCreateItemMutation() - const [updateItem] = useUpdateItemMutation() - - const [form, setForm] = useState({ - field1: initial?.field1 || '', - field2: initial?.field2 || '', - }) - - // Reset form when modal opens - useEffect(() => { - if (isOpen && initial) { - setForm({ field1: initial.field1, field2: initial.field2 }) - } else if (isOpen && !initial) { - setForm({ field1: '', field2: '' }) - } - }, [isOpen, initial]) - - const handleSubmit = async () => { - if (initial) { - await updateItem({ id: initial.id, ...form }) - } else { - await createItem(form) - } - onClose() - } - - return ( - - {/* form fields */} - - ) -} -``` - -**Key principles:** -- Single modal handles both create and edit -- `initial` prop determines mode (null = create, data = edit) -- Reset form state when modal opens -- Different mutation based on mode -- Button text changes based on mode - -## API Service Patterns - -### Query vs Mutation Rule - -- **GET requests** → `builder.query` -- **POST/PUT/PATCH/DELETE requests** → `builder.mutation` - -```typescript -// ✅ Correct: GET endpoint -getMailItem: builder.query({ - providesTags: (res, _, req) => [{ id: req?.id, type: 'MailItem' }], - query: (query: Req['getMailItem']) => ({ - url: `mailbox/mails/${query.id}`, - }), -}), - -// ✅ Correct: POST endpoint -createScanMail: builder.mutation({ - invalidatesTags: [{ id: 'LIST', type: 'ScanMail' }], - query: (query: Req['createScanMail']) => ({ - body: query, - method: 'POST', - url: `mailbox/mails/${query.id}/actions/scan`, - }), -}), -``` - -### File Download Pattern - -Use the reusable `handleFileDownload` utility for endpoints that return files: - -```typescript -import { handleFileDownload } from 'common/utils/fileDownload' - -getInvoiceDownload: builder.query({ - query: (query: Req['getInvoiceDownload']) => ({ - url: `customers/invoices/${query.id}/download`, - responseHandler: (response) => handleFileDownload(response, 'invoice.pdf'), - }), -}), -``` - -## Pagination Pattern - -Use `useInfiniteScroll` hook for paginated lists: - -```typescript -import useInfiniteScroll from 'common/hooks/useInfiniteScroll' -import { useGetMailQuery } from 'common/services/useMail' - -const MailList = ({ subscription_id }: Props) => { - const { - data, - isLoading, - isFetching, - loadMore, - refresh, - searchItems, - } = useInfiniteScroll( - useGetMailQuery, - { subscription_id, page_size: 20 }, - ) - - return ( - } - refreshFunction={refresh} - pullDownToRefresh - > - {data?.results.map(item => )} - - ) -} -``` - -## Error Handling - -### RTK Query Error Pattern - -```typescript -const [createMail, { isLoading, error }] = useCreateMailMutation() - -const handleSubmit = async () => { - try { - const result = await createMail(data).unwrap() - // Success - result contains the response - toast.success('Mail created successfully') - } catch (err) { - // Error handling - if ('status' in err) { - // FetchBaseQueryError - const errMsg = 'error' in err ? err.error : JSON.stringify(err.data) - toast.error(errMsg) - } else { - // SerializedError - toast.error(err.message || 'An error occurred') - } - } -} -``` - -### Query Refetching - -```typescript -const { data, refetch } = useGetMailQuery({ id: '123' }) - -// Refetch on demand -const handleRefresh = () => { - refetch() -} - -// Automatic refetch on focus/reconnect is enabled by default in common/service.ts -``` - -## Cache Invalidation - -### Manual Cache Clearing - -```typescript -import { getStore } from 'common/store' -import { mailItemService } from 'common/services/useMailItem' - -export const clearMailCache = () => { - getStore().dispatch( - mailItemService.util.invalidateTags([{ type: 'MailItem', id: 'LIST' }]) - ) -} -``` - -### Automatic Invalidation - -Cache invalidation is handled automatically through RTK Query tags: - -```typescript -// Mutation invalidates the list -createMail: builder.mutation({ - invalidatesTags: [{ type: 'Mail', id: 'LIST' }], - // This will automatically refetch any active queries with matching tags -}), -``` - -## Type Organization - -### Request and Response Types - -All API types go in `common/types/`: - -```typescript -// common/types/requests.ts -export type Req = { - getMail: PagedRequest<{ - subscription_id: string - q?: string - }> - createMail: { - id: string - content: string - } - // END OF TYPES -} - -// common/types/responses.ts -export type Res = { - mail: PagedResponse - mailItem: MailItem - // END OF TYPES -} -``` - -### Shared Types - -For types used across requests AND responses, keep them in their respective files but document the shared usage: - -```typescript -// common/types/requests.ts -export type Address = { - address_line_1: string - address_line_2: string | null - postal_code: string - city: string - country: string -} -``` - -## SSG CLI Usage - -Always use `npx ssg` to generate new API services: - -```bash -# Interactive mode -npx ssg - -# Follow prompts to: -# 1. Choose action type (get/create/update/delete) -# 2. Enter resource name -# 3. Enter API endpoint URL -# 4. Configure cache invalidation -``` - -The CLI will: -- Create/update service file in `common/services/` -- Add types to `common/types/requests.ts` and `responses.ts` -- Generate appropriate hooks (Query or Mutation) -- Use correct import paths (no relative imports) - -## Pre-commit Checks - -Before committing, run: - -```bash -npm run check:staged -``` - -This runs: -1. TypeScript type checking on staged files -2. ESLint with auto-fix on staged files - -Or use the slash command: - -``` -/check-staged -``` diff --git a/frontend/.claude/context/quick-reference.md b/frontend/.claude/context/quick-reference.md deleted file mode 100644 index 58b8d0615cd8..000000000000 --- a/frontend/.claude/context/quick-reference.md +++ /dev/null @@ -1,293 +0,0 @@ -# Quick Reference Guide - -## Common Tasks Checklist - -### Finding a Page Component - -**All page components are in `web/components/pages/`** - no need to search extensively. - -Examples: -- Features page: `web/components/pages/FeaturesPage.js` -- Project settings: `web/components/pages/ProjectSettingsPage.js` -- Environment settings: `web/components/pages/EnvironmentSettingsPage.tsx` -- Users: `web/components/pages/UsersPage.tsx` - -To find a page: -```bash -ls web/components/pages/ | grep -i -``` - -### Adding a New API Endpoint - -- [ ] Check backend for endpoint (see `backend-integration.md`) -- [ ] Add request type to `common/types/requests.ts` -- [ ] Add/extend RTK Query service in `common/services/use*.ts` -- [ ] Export hook from service -- [ ] Use hook in component with proper loading/error handling -- [ ] Run linter: `npx eslint --fix ` - -### Creating a New Table Component - -- [ ] Create in `components/project/tables/` -- [ ] Fetch data with RTK Query hook -- [ ] Handle loading state with `` -- [ ] Handle error state with `` -- [ ] Wrap table in `` -- [ ] Use responsive classes: `d-none d-md-table-cell` - -### Adding Tabs to a Page - -- [ ] Import `{ Tabs }` from `components/base/forms/Tabs` -- [ ] Add `useState` for active tab -- [ ] Pass `value`, `onChange`, and `tabLabels` props -- [ ] Wrap each tab content in `
` - -### Implementing Feature Flags (When Requested) - -Only use feature flags when explicitly requested by the user. - -- [ ] Create flag in Flagsmith (use `/feature-flag` command or MCP tools) -- [ ] In render method or component, call `Utils.getFlagsmithHasFeature('flag_name')` -- [ ] Store result in a variable (e.g., `const isEnabled = Utils.getFlagsmithHasFeature('flag_name')`) -- [ ] Use conditional rendering: `{isEnabled && }` -- [ ] Provide fallback when flag is disabled -- [ ] Enable flag manually via Flagsmith UI at https://app.flagsmith.com - -## File Locations - -| Purpose | Location | Example | -|---------|----------|---------| -| API Services | `common/services/use*.ts` | `useEnvironment.ts`, `useFeature.ts` | -| Request Types | `common/types/requests.ts` | API request interfaces | -| Response Types | `common/types/responses.ts` | API response interfaces | -| Table Components | `components/project/tables/` | User tables, data grids | -| **Page Components** | **`web/components/pages/`** | **`FeaturesPage.js`, `ProjectSettingsPage.js`** | -| Card Components | `components/project/cards/` | Summary cards, info cards | -| Base UI Components | `components/base/` | Buttons, forms, inputs | -| Feature Flags Context | `.claude/context/feature-flags.md` | Flagsmith integration guide | -| Backend API | `../api/` | Flagsmith backend API | - -## Common Components for Messages - -| Component | Location | Usage | -|-----------|----------|-------| -| InfoMessage | `components/InfoMessage` | Info alerts/messages | -| ErrorMessage | `components/base/Messages` | Error states | -| SuccessMessage | `components/base/Messages` | Success notifications | -| Loader | `components/base/Loader` | Loading states | -| Tooltip | `components/Tooltip` | Hover tooltips | -| Button | `components/base/forms/Button` | Standard buttons | - -## Common Imports - -### RTK Query -```typescript -import { service } from 'common/service' -import { Req } from 'common/types/requests' -import { Res } from 'common/types/responses' -``` - -### UI Components -```typescript -import { Tabs } from 'components/base/forms/Tabs' -import Loader from 'components/base/Loader' -import { ErrorMessage } from 'components/base/Messages' -``` - -### Hooks -```typescript -import { useDefaultSubscription } from 'common/services/useDefaultSubscription' -import { useState } from 'react' -``` - -### Utils -```typescript -import { Format } from 'common/utils/format' -import dayjs from 'dayjs' -``` - -## Backend API Structure - -``` -../api/apps/ -├── projects/ # Projects and features -├── environments/ # Environment management -├── features/ # Feature flags -├── segments/ # User segments -├── users/ # User management -├── organisations/ # Organization management -├── permissions/ # Access control -└── audit/ # Audit logs -``` - -## Common Backend Endpoints - -See `/backend` slash command to search the backend codebase for specific endpoints. - -## RTK Query Patterns - -### Query (GET) -```typescript -builder.query({ - providesTags: [{ id: 'LIST', type: 'Entity' }], - query: (req) => ({ - url: `endpoint/${req.id}`, - }), -}) -``` - -### Mutation (POST/PUT/DELETE) -```typescript -builder.mutation({ - invalidatesTags: [{ id: 'LIST', type: 'Entity' }], - query: (req) => ({ - body: req, - method: 'PUT', - url: `endpoint/${req.id}`, - }), -}) -``` - -### Skip Query -```typescript -useGetEntityQuery( - { id: entityId }, - { skip: !entityId } // Don't run query if no ID -) -``` - -## Component Patterns - -### Loading State -```typescript -if (isLoading) { - return ( -
- -
- ) -} -``` - -### Error State -```typescript -if (error) return {error} -``` - -### Feature Flag Check -```typescript -const isFeatureEnabled = Utils.getFlagsmithHasFeature('flag_name') -if (isFeatureEnabled) { - // Show new feature -} -``` - -## Slash Commands - -| Command | Purpose | -|---------|---------| -| `/api-types-sync` | Sync types with backend | -| `/api` | Generate new API service | -| `/backend ` | Search backend codebase | -| `/feature-flag` | Create feature flag | -| `/form` | Generate form with Yup + Formik | -| `/check` | Run type checking and linting | -| `/context` | Load specific context files | - -## Common Utilities - -### Date Formatting -```typescript -import dayjs from 'dayjs' -dayjs(timestamp).format('DD MMM YY') -``` - -### Money Formatting -```typescript -import { Format } from 'common/utils/format' -Format.money(amountInCents) // e.g., "$12.34" -Format.camelCase('pending_payment') // e.g., "Pending Payment" -``` - -### Subscription Info -```typescript -const { defaultSubscriptionId, subscriptionDetail, hasPermission } = - useDefaultSubscription() - -const canManageBilling = hasPermission('MANAGE_BILLING') -const companyId = subscriptionDetail?.company_id -``` - -## Bootstrap Classes Reference - -### Responsive Display -- `d-none d-md-block` - Hide on mobile, show on desktop -- `d-block d-md-none` - Show on mobile, hide on desktop -- `d-none d-md-table-cell` - For table cells - -### Spacing -- `mt-4` - Margin top -- `mb-4` - Margin bottom -- `pb-4` - Padding bottom -- `mb-24` - Large margin bottom - -### Layout -- `container-fluid` - Full-width container -- `row` - Bootstrap row -- `col-lg-6 col-md-12` - Responsive columns - -### Flexbox -- `d-flex` - Display flex -- `justify-content-center` - Center horizontally -- `align-items-center` - Center vertically - -## Linting - -Always run linter after changes: -```bash -npx eslint --fix common/types/requests.ts -npx eslint --fix common/services/useInvoice.ts -npx eslint --fix components/project/tables/MyTable.tsx -npx eslint --fix pages/my-page.tsx -``` - -Or use the check command: -```bash -/check -``` - -## Git Workflow - -Check backend branches: -```bash -cd ../api -git fetch -git log --oneline origin/feat/branch-name -n 10 -git show COMMIT_HASH:path/to/file.py -``` - -## Debugging Tips - -### Check if query is running -```typescript -const { data, error, isLoading, isFetching } = useGetEntityQuery(...) -console.log({ data, error, isLoading, isFetching }) -``` - -### Check feature flag value -```typescript -const isEnabled = Utils.getFlagsmithHasFeature('my_flag') -console.log('Flag enabled:', isEnabled) -``` - -### Inspect Redux state -```typescript -import { getStore } from 'common/store' -console.log(getStore().getState()) -``` - -### Force refetch -```typescript -const { refetch } = useGetEntityQuery(...) -refetch() -``` diff --git a/frontend/.claude/context/ui-patterns.md b/frontend/.claude/context/ui-patterns.md deleted file mode 100644 index 221a23976dd0..000000000000 --- a/frontend/.claude/context/ui-patterns.md +++ /dev/null @@ -1,171 +0,0 @@ -# UI Patterns & Best Practices - -## Table Components - -### Pattern: Reusable Table Components - -**Location:** `components/project/tables/` - -Tables should be self-contained components that fetch their own data and handle loading/error states. - -**Example:** `InvoiceTable.tsx` - -```typescript -import { useGetInvoicesQuery } from 'common/services/useInvoice' -import { useDefaultSubscription } from 'common/services/useDefaultSubscription' -import Loader from 'components/base/Loader' -import { ErrorMessage } from 'components/base/Messages' -import ContentContainer from './ContentContainer' - -const InvoiceTable: FC = () => { - const { defaultSubscriptionId } = useDefaultSubscription() - const { data: invoices, error, isLoading } = useGetInvoicesQuery({ - subscription_id: `${defaultSubscriptionId}`, - }) - - if (isLoading) { - return ( -
- -
- ) - } - - if (error) return {error} - - return ( - - - - - - - - - - - {invoices?.map((invoice) => ( - - - - - - ))} - -
Invoice No.DescriptionTotal
{invoice.id}{invoice.description}{invoice.total}
-
- ) -} -``` - -### Responsive Tables - -Use Bootstrap classes for responsive behavior: -- `d-none d-md-table-cell` - Hide column on mobile -- `d-block d-md-none` - Show on mobile only - -## Tabs Component - -**Location:** `components/base/forms/Tabs.tsx` - -### Basic Usage - -```typescript -import { useState } from 'react' -import { Tabs } from 'components/base/forms/Tabs' - -const MyPage = () => { - const [activeTab, setActiveTab] = useState(0) - - return ( - -
Tab 1 content
-
Tab 2 content
-
Tab 3 content
-
- ) -} -``` - -### Tabs with Feature Flag (Optional) - -**Note:** Only use feature flags when explicitly requested. By default, implement features directly without flags. - -When specifically requested, this pattern shows tabs only when feature flag is enabled: - -```typescript -import { useFlags } from 'flagsmith/react' -import { Tabs } from 'components/base/forms/Tabs' -import Utils from 'common/utils/utils' -const MyPage = () => { - const my_feature_flag = Utils.getFlagsmithHasFeature('my_feature_flag') - const [activeTab, setActiveTab] = useState(0) - - return ( -
-

My Section

- {my_feature_flag? ( - -
-
-
- ) : ( - - )} -
- ) -} -``` - -See `feature-flags.md` for more details on when and how to use feature flags. - -### Uncontrolled Tabs - -For simple cases without parent state management: - -```typescript - -
Tab 1 content
-
Tab 2 content
-
-``` - -## Confirmation Dialogs - -**NEVER use `window.confirm`** - Always use the `openConfirm` function from `components/base/Modal`. - -### Correct Usage - -```typescript -import { openConfirm } from 'components/base/Modal' - -// Signature: openConfirm(title, body, onYes, onNo?, challenge?) -openConfirm({ - body: 'Closing this will discard your unsaved changes.', - noText: 'Cancel', - onNo: () => resolve(false), - onYes: () => resolve(true), - title: 'Discard changes', - yesText: 'Ok', -}) -``` - -### Parameters -- `title: string` - Dialog title -- `body: ReactNode` - Dialog content (can be JSX) -- `onYes: (closeModal: () => void) => void` - Callback when user confirms -- `onNo?: () => void` - Optional callback when user cancels -- `challenge?: string` - Optional challenge text user must type to confirm - -### Key Points -- The `onYes` callback receives a `closeModal` function -- Always call `closeModal()` when the action completes successfully -- Can be async - use `async (closeModal) => { ... }` diff --git a/frontend/.claude/scripts/sync-types-helper.py b/frontend/.claude/scripts/sync-types-helper.py deleted file mode 100755 index a306e1d1a5b7..000000000000 --- a/frontend/.claude/scripts/sync-types-helper.py +++ /dev/null @@ -1,215 +0,0 @@ -#!/usr/bin/env python3 -""" -Helper script for API type syncing operations. -Minimizes token usage by batching cache operations. -""" - -import json -import sys -from pathlib import Path -from typing import Dict, List - -CACHE_FILE = Path(__file__).parent.parent / "api-type-map.json" - - -def load_cache() -> Dict: - """Load the type cache from JSON file.""" - if not CACHE_FILE.exists(): - return {"_metadata": {}, "response_types": {}, "request_types": {}} - with open(CACHE_FILE, "r") as f: - cache = json.load(f) - # Migrate old format to new format if needed - if "types" in cache and "response_types" not in cache: - cache["response_types"] = cache.pop("types") - cache["request_types"] = {} - return cache - - -def save_cache(cache: Dict) -> None: - """Save the type cache to JSON file.""" - with open(CACHE_FILE, "w") as f: - json.dump(cache, f, indent=2) - f.write("\n") - - - - -def get_changed_serializers(old_commit: str, new_commit: str, api_path: str) -> List[str]: - """Get list of serializer files changed between commits.""" - import subprocess - - result = subprocess.run( - ["git", "diff", f"{old_commit}..{new_commit}", "--name-only"], - cwd=api_path, - capture_output=True, - text=True, - ) - - if result.returncode != 0: - return [] - - files = result.stdout.strip().split("\n") - return [f for f in files if "serializers.py" in f] - - -def find_types_using_serializer(cache: Dict, serializer_path: str, serializer_name: str) -> List[str]: - """Find all type keys that use a specific serializer.""" - search_string = f"{serializer_path}:{serializer_name}" - types = [] - - for key, value in cache.items(): - if key == "_metadata": - continue - if value.get("serializer", "").startswith(search_string.split(":")[0]): - if serializer_name in value.get("serializer", ""): - types.append(key) - - return types - - - - -def update_metadata(stats: Dict) -> None: - """Update cache metadata with sync statistics.""" - cache = load_cache() - - if "_metadata" not in cache: - cache["_metadata"] = {} - - cache["_metadata"].update(stats) - save_cache(cache) - - -def get_types_needing_sync(serializer_files: List[str], api_path: str, type_category: str = "response") -> List[Dict]: - """ - Get list of types that need syncing based on changed serializer files. - - Args: - serializer_files: List of changed serializer file paths - api_path: Path to backend API repository - type_category: Either "response" or "request" - - Returns: - List of dicts with type info: {key, serializer_file, serializer_class, type_name} - """ - cache = load_cache() - types_to_check = [] - - # Select the appropriate cache section - cache_key = f"{type_category}_types" - type_cache = cache.get(cache_key, {}) - - for file_path in serializer_files: - # Extract serializer classes from the file path in cache - for type_key, type_data in type_cache.items(): - if type_key == "_metadata": - continue - - serializer = type_data.get("serializer", "") - if file_path in serializer and ":" in serializer: - serializer_class = serializer.split(":")[-1].strip() - types_to_check.append({ - "key": type_key, - "serializer_file": file_path, - "serializer_class": serializer_class, - "type_name": type_data.get("type", ""), - }) - - return types_to_check - - -def filter_syncable_types(cache: Dict, type_category: str = "response") -> List[Dict]: - """ - Filter cache to only include types with Django serializers (exclude custom/ChargeBee/empty). - - Args: - cache: Full cache dict - type_category: Either "response" or "request" - - Returns: - List of type info dicts - """ - syncable = [] - cache_key = f"{type_category}_types" - type_cache = cache.get(cache_key, {}) - - for type_key, type_data in type_cache.items(): - if type_key == "_metadata": - continue - - serializer = type_data.get("serializer", "") - note = type_data.get("note", "") - - # Skip custom responses, ChargeBee, NOT_IMPLEMENTED, and view methods - if any(x in note.lower() for x in ["custom", "chargebee", "empty"]): - continue - if "NOT_IMPLEMENTED" in serializer: - continue - if "views.py:" in serializer and "(" in serializer: - continue - - # Only include Django serializers - if "serializers.py:" in serializer and ":" in serializer: - parts = serializer.split(":") - if len(parts) == 2: - syncable.append({ - "key": type_key, - "serializer_file": parts[0], - "serializer_class": parts[1].strip(), - "type_name": type_data.get("type", ""), - }) - - return syncable - - -def get_last_commit() -> str: - """Get the last backend commit hash from cache metadata.""" - cache = load_cache() - return cache.get("_metadata", {}).get("lastBackendCommit", "") - - -if __name__ == "__main__": - # Command-line interface - command = sys.argv[1] if len(sys.argv) > 1 else "help" - - if command == "changed-serializers": - # Usage: python sync-types-helper.py changed-serializers OLD_COMMIT NEW_COMMIT API_PATH - old_commit = sys.argv[2] - new_commit = sys.argv[3] - api_path = sys.argv[4] - changed = get_changed_serializers(old_commit, new_commit, api_path) - print("\n".join(changed)) - - elif command == "types-to-sync": - # Usage: python sync-types-helper.py types-to-sync [response|request] FILE1 FILE2 ... API_PATH - type_category = sys.argv[2] if len(sys.argv) > 2 else "response" - files = sys.argv[3:] - api_path = sys.argv[-1] if files else "" - types = get_types_needing_sync(files[:-1], api_path, type_category) - print(json.dumps(types, indent=2)) - - elif command == "update-metadata": - # Usage: echo '{"lastSync": "..."}' | python sync-types-helper.py update-metadata - stats = json.load(sys.stdin) - update_metadata(stats) - print("Metadata updated") - - elif command == "syncable-types": - # Usage: python sync-types-helper.py syncable-types [response|request] - type_category = sys.argv[2] if len(sys.argv) > 2 else "response" - cache = load_cache() - types = filter_syncable_types(cache, type_category) - print(json.dumps(types, indent=2)) - - elif command == "get-last-commit": - # Usage: python sync-types-helper.py get-last-commit - commit = get_last_commit() - print(commit) - - else: - print("Usage:") - print(" changed-serializers OLD NEW PATH - Get changed serializer files") - print(" types-to-sync [response|request] FILE... PATH - Get types needing sync") - print(" update-metadata - Update metadata (JSON via stdin)") - print(" syncable-types [response|request] - Get all syncable type info") - print(" get-last-commit - Get last backend commit from cache") diff --git a/frontend/.claude/settings.json b/frontend/.claude/settings.json deleted file mode 100644 index 9367039aa0cc..000000000000 --- a/frontend/.claude/settings.json +++ /dev/null @@ -1,71 +0,0 @@ -{ - "autoApprovalSettings": { - "enabled": true, - "rules": [ - { - "tool": "Read", - "pattern": "**/*" - }, - { - "tool": "Bash", - "pattern": "npm run typecheck:*" - }, - { - "tool": "Bash", - "pattern": "npm run typecheck:staged" - }, - { - "tool": "Bash", - "pattern": "node:*" - }, - { - "tool": "Bash", - "pattern": "npm run lint:fix:*" - }, - { - "tool": "Bash", - "pattern": "npm run build:*" - }, - { - "tool": "Bash", - "pattern": "npm run lint*" - }, - { - "tool": "Bash", - "pattern": "npm run check:staged" - }, - { - "tool": "Bash", - "pattern": "npm run test*" - }, - { - "tool": "Bash", - "pattern": "git diff*" - }, - { - "tool": "Bash", - "pattern": "git log*" - }, - { - "tool": "Bash", - "pattern": "git status*" - }, - { - "tool": "Bash", - "pattern": "npx ssg*" - }, - { - "tool": "WebSearch", - "pattern": "*" - }, - { - "tool": "Glob", - "pattern": "*" - }, - { - "tool": "Grep", - "pattern": "*" - } - ] - } -} diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md deleted file mode 100644 index c6f03a0e98ea..000000000000 --- a/frontend/CLAUDE.md +++ /dev/null @@ -1,42 +0,0 @@ -# CLAUDE.md - -## Commands -- `npm run dev` - Start dev server -- `npm run typecheck` - Type checking - -## Structure -- `/common` - Shared Redux/API (no web/mobile code) -- `/web/components` - React components (includes `/web/components/pages/` for all page components) -- `/web/components/pages/` - **All page components** (e.g., `FeaturesPage.js`, `ProjectSettingsPage.js`) -- `/common/types/` - `requests.ts` and `responses.ts` for API types -- Ignore: `ios/`, `android/`, `.net/` - -## Rules -1. **API Integration**: Use `npx ssg` CLI + check `../api` backend -2. **Forms**: Yup + Formik + `validateForm` utility (see `/examples/forms/`) -3. **Imports**: Use `common/`, `components/`, `project/` (NO relative imports) -4. **State**: Redux Toolkit + RTK Query, store in `common/store.ts` -5. **Feature Flags**: When user says "create a feature flag", you MUST: (1) Create it in Flagsmith using MCP tools (`mcp__flagsmith__create_feature`), (2) Implement code with `useFlags` hook. See `.claude/context/feature-flags.md` for details -6. **Linting**: ALWAYS run `npx eslint --fix ` on any files you modify -7. **Type Enums**: Extract inline union types to named types (e.g., `type Status = 'A' | 'B'` instead of inline) -8. **NO FETCH**: NEVER use `fetch()` directly - ALWAYS use RTK Query mutations/queries (inject endpoints into services in `common/services/`), see api-integration context - -## Key Files -- Store: `common/store.ts` -- Base service: `common/service.ts` -- Forms example: `/examples/forms/ComprehensiveFormExample.tsx` - -## Context Files - -The `.claude/context/` directory contains **required patterns and standards** for this codebase. These are not optional suggestions - they document how things must be done in this project. - -For detailed guidance on specific topics: -- **Quick Start**: `.claude/context/quick-reference.md` - Common tasks, commands, patterns -- **API Integration**: `.claude/context/api-integration.md` - Adding endpoints, RTK Query (required reading for API work) -- **Backend**: `.claude/context/backend-integration.md` - Finding endpoints, backend structure -- **UI Patterns**: `.claude/context/ui-patterns.md` - Tables, tabs, modals, confirmations (required reading for UI work) -- **Feature Flags**: `.claude/context/feature-flags.md` - Using Flagsmith flags (optional, only when requested) -- **Code Patterns**: `.claude/context/patterns.md` - Complete examples, best practices -- **Forms**: `.claude/context/forms.md` - Yup + Formik patterns - -**Tip:** Start with `quick-reference.md` for common tasks and checklists. From fd08db6a5393095ff28266c5d0b34af328d3bb67 Mon Sep 17 00:00:00 2001 From: kyle-ssg Date: Tue, 2 Dec 2025 13:58:34 +0000 Subject: [PATCH 093/108] Migrate components --- frontend/package-lock.json | 11 + frontend/package.json | 1 + frontend/web/components/Feature.js | 209 ------------------ frontend/web/components/Switch.tsx | 2 +- .../web/components/base/grid/FormGroup.js | 18 -- .../web/components/base/grid/FormGroup.tsx | 14 ++ frontend/web/components/base/grid/Row.js | 38 ---- frontend/web/components/base/grid/Row.tsx | 31 +++ .../modals/CreateFlag/EditFeatureValue.tsx | 0 .../modals/CreateFlag/FeatureNameInput.tsx | 2 +- .../CreateFlag/FeatureUpdateSummary.tsx | 5 +- .../modals/CreateFlag/tabs/CreateFeature.tsx | 1 + .../modals/CreateFlag/tabs/FeatureValue.tsx | 27 ++- frontend/web/project/project-components.js | 8 +- 14 files changed, 82 insertions(+), 285 deletions(-) delete mode 100644 frontend/web/components/Feature.js delete mode 100644 frontend/web/components/base/grid/FormGroup.js create mode 100644 frontend/web/components/base/grid/FormGroup.tsx delete mode 100644 frontend/web/components/base/grid/Row.js create mode 100644 frontend/web/components/base/grid/Row.tsx delete mode 100644 frontend/web/components/modals/CreateFlag/EditFeatureValue.tsx diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 1923e228197e..a75f7e75fad7 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", @@ -4922,6 +4923,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 3a842b4dd290..7918e2e0db90 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/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/Switch.tsx b/frontend/web/components/Switch.tsx index ef4ce826dc29..7c54595d0eff 100644 --- a/frontend/web/components/Switch.tsx +++ b/frontend/web/components/Switch.tsx @@ -1,5 +1,5 @@ import React, { FC } from 'react' -import RCSwitch, { SwitchProps as RCSwitchProps } from 'rc-switch' +import RCSwitch, { Props as RCSwitchProps } from 'rc-switch' import Icon from './Icon' export type SwitchProps = RCSwitchProps & { 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/modals/CreateFlag/EditFeatureValue.tsx b/frontend/web/components/modals/CreateFlag/EditFeatureValue.tsx deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/frontend/web/components/modals/CreateFlag/FeatureNameInput.tsx b/frontend/web/components/modals/CreateFlag/FeatureNameInput.tsx index 7927f44b786f..2c5e38dccb0d 100644 --- a/frontend/web/components/modals/CreateFlag/FeatureNameInput.tsx +++ b/frontend/web/components/modals/CreateFlag/FeatureNameInput.tsx @@ -39,7 +39,7 @@ const FeatureNameInput: FC = ({ readOnly: false, }} value={value} - onChange={(e) => { + onChange={(e: InputEvent) => { const newName = Utils.safeParseEventValue(e).replace(/ /g, '_') onChange(caseSensitive ? newName.toLowerCase() : newName) }} diff --git a/frontend/web/components/modals/CreateFlag/FeatureUpdateSummary.tsx b/frontend/web/components/modals/CreateFlag/FeatureUpdateSummary.tsx index 2bf12fdbf593..891f55e62456 100644 --- a/frontend/web/components/modals/CreateFlag/FeatureUpdateSummary.tsx +++ b/frontend/web/components/modals/CreateFlag/FeatureUpdateSummary.tsx @@ -29,10 +29,7 @@ const FeatureUpdateSummary: FC = ({ {!identity && (
- + This will create the feature for all environments, you can edit this feature per environment once the feature is created. diff --git a/frontend/web/components/modals/CreateFlag/tabs/CreateFeature.tsx b/frontend/web/components/modals/CreateFlag/tabs/CreateFeature.tsx index db881a8b9527..16c37e859385 100644 --- a/frontend/web/components/modals/CreateFlag/tabs/CreateFeature.tsx +++ b/frontend/web/components/modals/CreateFlag/tabs/CreateFeature.tsx @@ -69,6 +69,7 @@ const CreateFeature: FC = ({ createFeature={createFeature} hideValue={false} isEdit={false} + identity={identity} noPermissions={noPermissions} multivariate_options={multivariate_options} environmentVariations={environmentVariations} diff --git a/frontend/web/components/modals/CreateFlag/tabs/FeatureValue.tsx b/frontend/web/components/modals/CreateFlag/tabs/FeatureValue.tsx index aceae4942c58..3ff399a67dea 100644 --- a/frontend/web/components/modals/CreateFlag/tabs/FeatureValue.tsx +++ b/frontend/web/components/modals/CreateFlag/tabs/FeatureValue.tsx @@ -10,7 +10,7 @@ import Tooltip from 'components/Tooltip' import Icon from 'components/Icon' import Switch from 'components/Switch' import Utils from 'common/utils/utils' -import { FlagsmithValue, FeatureState, ProjectFlag } from 'common/types/responses' +import { FeatureState, ProjectFlag } from 'common/types/responses' function isNegativeNumberString(str: any) { if (typeof Utils.getTypedValue(str) !== 'number') { @@ -33,10 +33,10 @@ type EditFeatureValueProps = { noPermissions: boolean multivariate_options: any[] environmentVariations: any[] - featureState: Partial + featureState: FeatureState environmentFlag: any projectFlag: ProjectFlag - onChange: (featureState: Partial) => void + onChange: (featureState: FeatureState) => void removeVariation: (i: number) => void updateVariation: (i: number, e: any, environmentVariations: any[]) => void addVariation: () => void @@ -97,11 +97,17 @@ const FeatureValue: FC = ({ 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 getValueString = () => { + if (identity) { + return 'User override' + } + if (multivariate_options && multivariate_options.length) { + return `Control Value - ${controlPercentage}%` + } + return 'Value' + } + const valueString = getValueString() const showValue = !( !!identity && @@ -134,8 +140,9 @@ const FeatureValue: FC = ({
} > - {!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.'} + {!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.' + : ''} diff --git a/frontend/web/project/project-components.js b/frontend/web/project/project-components.js index 29d1a696d8e2..838b436334dd 100644 --- a/frontend/web/project/project-components.js +++ b/frontend/web/project/project-components.js @@ -15,7 +15,8 @@ import OrganisationProvider from 'common/providers/OrganisationProvider' import Panel from 'components/base/grid/Panel' import { checkmarkCircle } from 'ionicons/icons' import { IonIcon } from '@ionic/react' - +import FormGroup from 'components/base/grid/FormGroup' +import Row from 'components/base/grid/Row' window.AppActions = require('../../common/dispatcher/app-actions') window.Actions = require('../../common/dispatcher/action-constants') window.ES6Component = require('../../common/ES6Component') @@ -29,15 +30,14 @@ window.ProjectProvider = ProjectProvider window.Paging = Paging // Useful components -window.Row = require('../components/base/grid/Row') +window.Row = Row window.Flex = require('../components/base/grid/Flex') window.Column = require('../components/base/grid/Column') window.InputGroup = InputGroup window.Input = Input window.Button = Button -window.FormGroup = require('../components/base/grid/FormGroup') +window.FormGroup = FormGroup window.Panel = Panel -window.FormGroup = require('../components/base/grid/FormGroup') window.PanelSearch = PanelSearch window.CodeHelp = CodeHelp From 2732306251f81b7c8d1d074056260d5b202266e2 Mon Sep 17 00:00:00 2001 From: kyle-ssg Date: Tue, 2 Dec 2025 14:20:58 +0000 Subject: [PATCH 094/108] Revert changes --- frontend/.eslintrc.js | 2 +- frontend/Makefile | 5 +---- frontend/docker-compose-e2e-tests.yml | 2 -- 3 files changed, 2 insertions(+), 7 deletions(-) diff --git a/frontend/.eslintrc.js b/frontend/.eslintrc.js index bb4e2b20c230..7cb3d28f9bcb 100644 --- a/frontend/.eslintrc.js +++ b/frontend/.eslintrc.js @@ -95,7 +95,7 @@ module.exports = { 'window': true, 'zE': true, }, - 'ignorePatterns': ['server/index.tsx', 'next.config.js', 'babel.config.js'], + 'ignorePatterns': ['server/index.js', 'next.config.js', 'babel.config.js'], 'parser': '@typescript-eslint/parser', 'parserOptions': { 'ecmaFeatures': { diff --git a/frontend/Makefile b/frontend/Makefile index 366d737b2319..f326515322d6 100644 --- a/frontend/Makefile +++ b/frontend/Makefile @@ -22,10 +22,6 @@ lint: build: npm run build -.PHONY: build-docker -build-docker: - docker compose build frontend - .PHONY: serve serve: npm run dev @@ -34,3 +30,4 @@ serve: test: docker compose run frontend \ npx cross-env E2E_CONCURRENCY=${E2E_CONCURRENCY} npm run test -- $(opts) \ + || (docker compose logs flagsmith-api; exit 1) diff --git a/frontend/docker-compose-e2e-tests.yml b/frontend/docker-compose-e2e-tests.yml index 1be1d40ed3a0..4dd26cc6ff63 100644 --- a/frontend/docker-compose-e2e-tests.yml +++ b/frontend/docker-compose-e2e-tests.yml @@ -44,13 +44,11 @@ services: context: ../ dockerfile: frontend/Dockerfile.e2e environment: - E2E: 'true' E2E_TEST_TOKEN_DEV: some-token DISABLE_ANALYTICS_FEATURES: 'true' FLAGSMITH_API: flagsmith-api:8000/api/v1/ SLACK_TOKEN: ${SLACK_TOKEN} GITHUB_ACTION_URL: ${GITHUB_ACTION_URL} - FIREFOX_BIN: /usr/bin/firefox ports: - 3000:3000 depends_on: From 1c117d78c26f5220530e62571239cc3c63867742 Mon Sep 17 00:00:00 2001 From: Kyle Johnson Date: Tue, 2 Dec 2025 16:28:33 +0000 Subject: [PATCH 095/108] feat: segment feature state view (#6137) Co-authored-by: Zaimwa9 --- .../common/providers/withSegmentOverrides.js | 1 + frontend/common/services/useProjectFlag.ts | 5 +- .../common/services/useSegmentOverride.ts | 8 + frontend/common/stores/feature-list-store.ts | 18 +- frontend/common/types/requests.ts | 23 +- frontend/common/types/responses.ts | 2 + frontend/web/components/Breadcrumb.tsx | 42 +- frontend/web/components/EnvironmentSelect.tsx | 14 +- frontend/web/components/PanelSearch.tsx | 20 +- .../web/components/ProjectManageWidget.tsx | 3 +- frontend/web/components/SegmentOverrides.js | 85 ++- frontend/web/components/SegmentSelect.tsx | 5 +- .../feature-override/FeatureOverrideRow.tsx | 130 +++-- .../SegmentOverrideDescription.tsx | 14 +- .../feature-page/FeatureFilters.tsx | 23 +- .../components/feature-summary/FeatureRow.tsx | 2 +- .../import-export/FeatureImport.tsx | 3 +- .../modals/AssociatedSegmentOverrides.js | 520 ------------------ frontend/web/components/modals/CreateFlag.js | 4 + .../web/components/modals/CreateSegment.tsx | 26 +- .../components/pages/OrganisationsPage.tsx | 3 +- frontend/web/components/pages/SegmentPage.tsx | 69 +-- frontend/web/components/pages/WidgetPage.tsx | 7 +- .../segments/AssociatedSegmentOverrides.tsx | 192 +++++++ .../web/components/tables/TableSortFilter.tsx | 17 +- frontend/web/styles/project/_utils.scss | 3 + 26 files changed, 544 insertions(+), 695 deletions(-) delete mode 100644 frontend/web/components/modals/AssociatedSegmentOverrides.js create mode 100644 frontend/web/components/segments/AssociatedSegmentOverrides.tsx diff --git a/frontend/common/providers/withSegmentOverrides.js b/frontend/common/providers/withSegmentOverrides.js index 497624f4c17c..a958e8a51e70 100644 --- a/frontend/common/providers/withSegmentOverrides.js +++ b/frontend/common/providers/withSegmentOverrides.js @@ -27,6 +27,7 @@ export default (WrappedComponent) => { getOverrides = () => { if (this.props.projectFlag) { + //todo: migrate to useSegmentFeatureState Promise.all([ data.get( `${ diff --git a/frontend/common/services/useProjectFlag.ts b/frontend/common/services/useProjectFlag.ts index f5e6f6dc35ff..bcce029b7a97 100644 --- a/frontend/common/services/useProjectFlag.ts +++ b/frontend/common/services/useProjectFlag.ts @@ -53,7 +53,10 @@ export const projectFlagService = service Req['getProjectFlags'] >({ providesTags: (res, _, req) => [ - { id: req?.project, type: 'ProjectFlag' }, + { + id: `${req?.project}-${req?.environmentId}-${req?.segmentId}`, + type: 'ProjectFlag', + }, ], queryFn: async (args, _, _2, baseQuery) => { return await recursivePageGet( diff --git a/frontend/common/services/useSegmentOverride.ts b/frontend/common/services/useSegmentOverride.ts index 5941581a068e..4ba22f8aad26 100644 --- a/frontend/common/services/useSegmentOverride.ts +++ b/frontend/common/services/useSegmentOverride.ts @@ -1,6 +1,8 @@ import { Res } from 'common/types/responses' import { Req } from 'common/types/requests' import { service } from 'common/service' +import { projectFlagService } from './useProjectFlag' +import { getStore } from 'common/store' export const segmentOverrideService = service .enhanceEndpoints({ addTagTypes: ['SegmentOverride'] }) @@ -16,6 +18,12 @@ export const segmentOverrideService = service method: 'POST', url: `environments/${query.environmentId}/features/${query.featureId}/create-segment-override/`, }), + transformResponse: (res) => { + getStore().dispatch( + projectFlagService.util.invalidateTags(['ProjectFlag']), + ) + return res + }, }), // END OF ENDPOINTS }), diff --git a/frontend/common/stores/feature-list-store.ts b/frontend/common/stores/feature-list-store.ts index ad3c747bc0cb..1b44eaa9fdc8 100644 --- a/frontend/common/stores/feature-list-store.ts +++ b/frontend/common/stores/feature-list-store.ts @@ -12,6 +12,7 @@ import { updateProjectFlag, } from 'common/services/useProjectFlag' import OrganisationStore from './organisation-store' +import { SortOrder } from 'common/types/requests' import { ChangeRequest, Environment, @@ -170,7 +171,7 @@ const controller = { if (onComplete) { onComplete(res) } - if (store.model) { + if (store.model?.features) { const index = _.findIndex(store.model.features, { id: flag.id }) store.model.features[index] = controller.parseFlag(flag) store.model.lastSaved = new Date().valueOf() @@ -438,7 +439,7 @@ const controller = { Promise.all([prom, segmentOverridesRequest]) .then(([res, segmentRes]) => { - if (store.model) { + if (store.model?.keyedEnvironmentFeatures) { store.model.keyedEnvironmentFeatures[projectFlag.id] = res if (segmentRes) { const feature = _.find( @@ -729,6 +730,12 @@ const controller = { if (version.error) { throw version.error } + getStore().dispatch( + projectFlagService.util.invalidateTags(['ProjectFlag']), + ) + if(!store.model) { + return + } // Fetch and update the latest environment feature state return getVersionFeatureState(getStore(), { environmentId: ProjectStore.getEnvironmentIdFromKey(environmentId), @@ -977,7 +984,12 @@ const store = Object.assign({}, BaseStore, { }, id: 'features', paging: {}, - sort: { default: true, label: 'Name', sortBy: 'name', sortOrder: 'asc' }, + sort: { + default: true, + label: 'Name', + sortBy: 'name', + sortOrder: SortOrder.ASC, + }, }) store.dispatcherIndex = Dispatcher.register(store, (payload) => { diff --git a/frontend/common/types/requests.ts b/frontend/common/types/requests.ts index c7f59b6b9a29..c2dbbeddfa7a 100644 --- a/frontend/common/types/requests.ts +++ b/frontend/common/types/requests.ts @@ -23,6 +23,7 @@ import { StageTrigger, StageActionType, StageActionBody, + TagStrategy, } from './responses' import { UtmsType } from './utms' @@ -102,7 +103,10 @@ export type RegisterRequest = { marketing_consent_given?: boolean utm_data?: UtmsType } - +export enum SortOrder { + ASC = 'ASC', + DESC = 'DESC', +} export interface StageActionRequest { action_type: StageActionType | '' action_body: StageActionBody @@ -240,7 +244,7 @@ export type Req = { projectId: string } createTag: { projectId: string; tag: Omit } - getSegment: { projectId: string; id: string } + getSegment: { projectId: number; id: string } updateAccount: Account deleteAccount: { current_password: string @@ -341,9 +345,20 @@ export type Req = { } getProjectFlags: { project: string - environmentId?: string - tags?: string[] + environment?: number + segment?: number + search?: string | null + releasePipelines?: number[] + page?: number + tag_strategy?: TagStrategy + tags?: string is_archived?: boolean + value_search?: string | null + is_enabled?: boolean | null + owners?: number[] + group_owners?: number[] + sort_field?: string + sort_direction?: SortOrder } getProjectFlag: { project: string | number; id: string } getRolesPermissionUsers: { organisation_id: number; role_id: number } diff --git a/frontend/common/types/responses.ts b/frontend/common/types/responses.ts index 4fde1a121f0c..22b375c3179b 100644 --- a/frontend/common/types/responses.ts +++ b/frontend/common/types/responses.ts @@ -506,6 +506,8 @@ export type ProjectFlag = { created_date: string default_enabled: boolean description?: string + environment_feature_state?: FeatureState + segment_feature_state?: FeatureState id: number initial_value: FlagsmithValue is_archived: boolean diff --git a/frontend/web/components/Breadcrumb.tsx b/frontend/web/components/Breadcrumb.tsx index cc4434e8b0ba..ffc230626f30 100644 --- a/frontend/web/components/Breadcrumb.tsx +++ b/frontend/web/components/Breadcrumb.tsx @@ -1,31 +1,39 @@ -import React, { FC } from 'react' +import React, { FC, ReactNode } from 'react' import { Link } from 'react-router-dom' type BreadcrumbType = { items: { title: string; url: string }[] - currentPage: string + currentPage: ReactNode + isCurrentPageMuted?: boolean } -const Breadcrumb: FC = ({ currentPage, items }) => { +const Breadcrumb: FC = ({ + currentPage, + isCurrentPageMuted = true, + items, +}) => { return ( - +
+ ) : ( + currentPage + )} +
) } diff --git a/frontend/web/components/EnvironmentSelect.tsx b/frontend/web/components/EnvironmentSelect.tsx index 7de2f6cbf558..6b3800d47b7a 100644 --- a/frontend/web/components/EnvironmentSelect.tsx +++ b/frontend/web/components/EnvironmentSelect.tsx @@ -1,12 +1,13 @@ import React, { FC, useMemo } from 'react' import { useGetEnvironmentsQuery } from 'common/services/useEnvironment' import { Props } from 'react-select/lib/Select' +import { Environment } from 'common/types/responses' export type EnvironmentSelectType = Partial> & { projectId: number value?: string label?: string - onChange: (value: string) => void + onChange: (value: string, environment: Environment | null) => void showAll?: boolean readOnly?: boolean idField?: 'id' | 'api_key' @@ -30,6 +31,7 @@ const EnvironmentSelect: FC = ({ const environments = useMemo(() => { return (data?.results || []) ?.map((v) => ({ + environment: v, label: v.name, value: `${v[idField]}`, })) @@ -66,12 +68,14 @@ const EnvironmentSelect: FC = ({ } } options={(showAll - ? [{ label: 'All Environments', value: '' }] + ? [{ environment: null, label: 'All Environments', value: '' }] : [] ).concat(environments)} - onChange={(value: { value: string; label: string }) => - onChange(value?.value || '') - } + onChange={(value: { + value: string + label: string + environment: Environment + }) => onChange(value?.value || '', value?.environment)} />
) diff --git a/frontend/web/components/PanelSearch.tsx b/frontend/web/components/PanelSearch.tsx index fb75c398a0cd..fecbf6aaf8e6 100644 --- a/frontend/web/components/PanelSearch.tsx +++ b/frontend/web/components/PanelSearch.tsx @@ -20,10 +20,11 @@ import Paging from './Paging' import _ from 'lodash' import Panel from './base/grid/Panel' import Utils from 'common/utils/utils' +import { SortOrder } from 'common/types/requests' export type SortOption = { value: string - order: 'asc' | 'desc' + order: SortOrder default?: boolean label: string } @@ -56,7 +57,7 @@ export interface PanelSearchProps { className?: string onSortChange?: (args: { sortBy: string | null - sortOrder: 'asc' | 'desc' | null + sortOrder: SortOrder | null }) => void itemHeight?: number action?: ReactNode @@ -90,7 +91,7 @@ const PanelSearch = (props: PanelSearchProps): ReactElement => { const [sortBy, setSortBy] = useState( defaultSortingOption ? defaultSortingOption.value : null, ) - const [sortOrder, setSortOrder] = useState<'asc' | 'desc' | null>( + const [sortOrder, setSortOrder] = useState( defaultSortingOption ? defaultSortingOption.order : null, ) const [internalSearch, setInternalSearch] = useState('') @@ -102,7 +103,11 @@ const PanelSearch = (props: PanelSearchProps): ReactElement => { const sortItems = useCallback( (itemsToSort: T[]): T[] => { if (sortBy) { - return _.orderBy(itemsToSort, [sortBy], [sortOrder || 'asc']) + return _.orderBy( + itemsToSort, + [sortBy], + [(sortOrder?.toLowerCase() || 'asc') as 'asc' | 'desc'], + ) } return itemsToSort }, @@ -127,7 +132,8 @@ const PanelSearch = (props: PanelSearchProps): ReactElement => { (e: React.MouseEvent, sortOption: SortOption) => { e.preventDefault() if (sortOption.value === sortBy) { - const newSortOrder = sortOrder === 'asc' ? 'desc' : 'asc' + const newSortOrder = + sortOrder === SortOrder.ASC ? SortOrder.DESC : SortOrder.ASC setSortOrder(newSortOrder) onSortChange && onSortChange({ sortBy, sortOrder: newSortOrder }) } else { @@ -247,7 +253,9 @@ const PanelSearch = (props: PanelSearchProps): ReactElement => { {currentSort?.value === sortOption.value && ( )} diff --git a/frontend/web/components/ProjectManageWidget.tsx b/frontend/web/components/ProjectManageWidget.tsx index 7cd29e5e617f..69e4a0f62c09 100644 --- a/frontend/web/components/ProjectManageWidget.tsx +++ b/frontend/web/components/ProjectManageWidget.tsx @@ -10,6 +10,7 @@ import ConfigProvider from 'common/providers/ConfigProvider' import { useGetOrganisationsQuery } from 'common/services/useOrganisation' import OrganisationProvider from 'common/providers/OrganisationProvider' import { Project } from 'common/types/responses' +import { SortOrder } from 'common/types/requests' import Button from './base/forms/Button' import PanelSearch from './PanelSearch' import Icon from './Icon' @@ -229,7 +230,7 @@ const ProjectManageWidget: FC = ({ organisationId }) => { { default: true, label: 'Name', - order: 'asc', + order: SortOrder.ASC, value: 'name', }, ]} diff --git a/frontend/web/components/SegmentOverrides.js b/frontend/web/components/SegmentOverrides.js index 81f62416e61e..ff6e896bba21 100644 --- a/frontend/web/components/SegmentOverrides.js +++ b/frontend/web/components/SegmentOverrides.js @@ -16,9 +16,11 @@ import Icon from './Icon' import SegmentOverrideLimit from './SegmentOverrideLimit' import { getStore } from 'common/store' import { getEnvironment } from 'common/services/useEnvironment' +import { getSegment } from 'common/services/useSegment' import Tooltip from './Tooltip' import SegmentsIcon from './svg/SegmentsIcon' import SegmentOverrideActions from './SegmentOverrideActions' +import Button from './base/forms/Button' const arrayMoveMutate = (array, from, to) => { array.splice(to < 0 ? array.length + to : to, 0, array.splice(from, 1)[0]) @@ -49,6 +51,7 @@ const SegmentOverrideInner = class Override extends React.Component { disabled, environmentId, hideViewSegment, + highlightSegmentId, index, multivariateOptions, name, @@ -82,6 +85,7 @@ const SegmentOverrideInner = class Override extends React.Component { const changed = !v.id || this.state.changed const showValue = !(multivariateOptions && multivariateOptions.length) const controlPercent = Utils.calculateControl(mvOptions) + const isHighlighted = highlightSegmentId && v.segment === highlightSegmentId if (!v || v.toRemove) { if (this.props.id) { return ( @@ -101,7 +105,7 @@ const SegmentOverrideInner = class Override extends React.Component { this.props.id ? '' : ' panel user-select-none panel-without-heading panel--draggable pb-0' - }`} + }${isHighlighted ? ' border-2 border-primary' : ''}`} >
@@ -349,6 +353,7 @@ const SegmentOverrideListInner = ({ disabled, environmentId, hideViewSegment, + highlightSegmentId, id, items, multivariateOptions, @@ -374,6 +379,7 @@ const SegmentOverrideListInner = ({ name={name} segment={value.segment} hideViewSegment={hideViewSegment} + highlightSegmentId={highlightSegmentId} onSortEnd={onSortEnd} disabled={disabled} showEditSegment={showEditSegment} @@ -433,6 +439,38 @@ class TheComponent extends Component { totalSegmentOverrides: res.data.total_segment_overrides, }) }) + this.checkPreselectedSegment() + } + + componentDidUpdate(prevProps) { + if ( + this.props.highlightSegmentId && + this.props.highlightSegmentId !== prevProps.highlightSegmentId + ) { + this.checkPreselectedSegment() + } + } + + checkPreselectedSegment = () => { + const { highlightSegmentId, projectId, value } = this.props + if (!highlightSegmentId) return + + const existingOverride = value?.find((v) => v.segment === highlightSegmentId) + if (existingOverride) { + return + } + + getSegment(getStore(), { + id: highlightSegmentId, + projectId: projectId, + }).then((res) => { + this.setState({ + selectedSegment: { + label: res.data.name, + value: res.data.id, + }, + }) + }) } addItem = () => { @@ -573,19 +611,37 @@ class TheComponent extends Component { !this.props.disableCreate && !this.props.showCreateSegment && !this.props.readOnly && ( - - - this.setState({ selectedSegment }, this.addItem) - } - /> - + +
+ { + if (this.props.highlightSegmentId) { + this.setState({ selectedSegment }) + } else { + this.setState({ selectedSegment }, this.addItem) + } + }} + /> +
+ {this.props.highlightSegmentId && + this.state.selectedSegment && ( + + )} +
)} {this.props.showCreateSegment && !this.state.segmentEditId && (
@@ -710,6 +766,7 @@ class TheComponent extends Component { onSortEnd={this.onSortEnd} projectFlag={this.props.projectFlag} hideViewSegment={this.props.hideViewSegment} + highlightSegmentId={this.props.highlightSegmentId} />
Segment[] } const SegmentSelect: FC = ({ + className, filter, projectId, ...rest @@ -41,12 +43,13 @@ const SegmentSelect: FC = ({