From 2aaa93c4855ad3f717db6bdc198b7dbf1bedef76 Mon Sep 17 00:00:00 2001 From: kyle-ssg Date: Wed, 8 Jul 2026 10:19:30 +0100 Subject: [PATCH 1/4] feat: Redesign Compare Environments page - Convert CompareEnvironments from JS to TypeScript - Add expandable rows with full diff view (segments, variations) - Add standard table filters (search, tags, owners, groups, sort) - Split results into "Changed features" and "Unchanged features" tables - Add column headers for source/target environments and segment changes - Show environment names in diff view dropdown instead of "Old/New Value" - Add environment name legend with +/- indicators in diff colours - Style improvements: proper table header styling, badge counts Co-Authored-By: Claude Opus 4.5 --- .../web/components/CompareEnvironments.js | 500 ---------------- .../web/components/CompareEnvironments.tsx | 532 ++++++++++++++++++ frontend/web/components/diff/DiffFeature.tsx | 22 +- frontend/web/styles/3rdParty/_react-diff.scss | 24 + 4 files changed, 576 insertions(+), 502 deletions(-) delete mode 100644 frontend/web/components/CompareEnvironments.js create mode 100644 frontend/web/components/CompareEnvironments.tsx diff --git a/frontend/web/components/CompareEnvironments.js b/frontend/web/components/CompareEnvironments.js deleted file mode 100644 index 4ec721e67d8b..000000000000 --- a/frontend/web/components/CompareEnvironments.js +++ /dev/null @@ -1,500 +0,0 @@ -// import propTypes from 'prop-types'; -import React, { Component } from 'react' -import each from 'lodash/each' -import sortBy from 'lodash/sortBy' -import keyBy from 'lodash/keyBy' -import EnvironmentSelect from './EnvironmentSelect' -import data from 'common/data/base/_data' -import ProjectStore from 'common/stores/project-store' -import FeatureRow from './feature-summary/FeatureRow' -import FeatureListStore from 'common/stores/feature-list-store' -import ConfigProvider from 'common/providers/ConfigProvider' -import Permission from 'common/providers/Permission' -import Tag from './tags/Tag' -import Icon from './icons/Icon' -import Constants from 'common/constants' -import Button from './base/forms/Button' -import EmptyState from './EmptyState' -import Tooltip from './Tooltip' -import { withRouter } from 'react-router-dom' -import { getDarkMode } from 'project/darkMode' -import { getStore } from 'common/store' -import { removeProjectFlag } from 'common/services/useProjectFlag' -import { hasMultivariateChange } from 'common/utils/compareMultivariate' - -const featureNameWidth = 300 - -class CompareEnvironments extends Component { - static displayName = 'CompareEnvironments' - - static propTypes = {} - - constructor(props) { - super(props) - this.state = { - environmentLeft: props.environmentId, - environmentRight: '', - isLoading: true, - projectFlagsLeft: null, - projectFlagsRight: null, - showArchived: false, - } - } - - componentDidUpdate(prevProps, prevState) { - if ( - this.state.environmentLeft !== prevState.environmentLeft || - this.state.environmentRight !== prevState.environmentRight - ) { - this.fetch() - } - } - - fetch = () => { - if (!this.state.environmentLeft || !this.state.environmentRight) { - return - } - this.setState({ isLoading: true }) - return Promise.all([ - data.get( - `${Project.api}projects/${ - this.props.projectId - }/features/?page_size=999&environment=${ProjectStore.getEnvironmentIdFromKey( - this.state.environmentLeft, - )}`, - ), - data.get( - `${Project.api}projects/${ - this.props.projectId - }/features/?page_size=999&environment=${ProjectStore.getEnvironmentIdFromKey( - this.state.environmentRight, - )}`, - ), - data.get( - `${Project.api}environments/${this.state.environmentLeft}/featurestates/?page_size=999`, - ), - data.get( - `${Project.api}environments/${this.state.environmentRight}/featurestates/?page_size=999`, - ), - ]) - .then( - ([ - environmentLeftProjectFlags, - environmentRightProjectFlags, - environmentLeftFlags, - environmentRightFlags, - ]) => { - const changes = [] - const same = [] - each( - sortBy(environmentLeftProjectFlags.results, (p) => p.name), - (projectFlagLeft) => { - const projectFlagRight = - environmentRightProjectFlags.results?.find( - (projectFlagRight) => - projectFlagRight.id === projectFlagLeft.id, - ) - const leftSide = environmentLeftFlags.results.find( - (v) => v.feature === projectFlagLeft.id, - ) - const rightSide = environmentRightFlags.results.find( - (v) => v.feature === projectFlagLeft.id, - ) - const change = { - leftEnabled: leftSide.enabled, - leftEnvironmentFlag: leftSide, - leftValue: leftSide.feature_state_value, - projectFlagLeft, - projectFlagRight, - rightEnabled: rightSide.enabled, - rightEnvironmentFlag: rightSide, - rightValue: rightSide.feature_state_value, - } - change.enabledChanged = change.rightEnabled !== change.leftEnabled - change.valueChanged = change.rightValue !== change.leftValue - change.multivariateChanged = hasMultivariateChange( - leftSide, - rightSide, - ) - if ( - change.enabledChanged || - change.valueChanged || - change.multivariateChanged || - projectFlagLeft.num_identity_overrides || - projectFlagLeft.num_segment_overrides || - projectFlagRight.num_identity_overrides || - projectFlagRight.num_segment_overrides - ) { - changes.push(change) - } else { - same.push(change) - } - }, - ) - this.setState({ - changes, - environmentLeftFlags: keyBy( - environmentLeftFlags.results, - 'feature', - ), - environmentRightFlags: keyBy( - environmentRightFlags.results, - 'feature', - ), - isLoading: false, - projectFlagsLeft: environmentLeftProjectFlags.results, - projectFlagsRight: environmentLeftProjectFlags.results, - same, - }) - }, - ) - .catch(() => { - this.setState({ isLoading: false }) - }) - } - - onSave = () => this.fetch() - - filter = (items) => { - if (!items) return items - - return items.filter((v) => { - if (this.state.showArchived) { - return true - } - return !v.projectFlagLeft.is_archived - }) - } - - render() { - return ( -
-
-
Compare Environments
-

- Compare feature flag changes across environments. -

-
- - -
- - this.setState({ environmentLeft }) - } - value={this.state.environmentLeft} - /> -
- -
- -
- -
- - this.setState({ environmentRight }) - } - value={this.state.environmentRight} - /> -
-
-
- - {this.state.environmentLeft && this.state.environmentRight ? ( - - {({}, { toggleFlag }) => { - // Adapt old FeatureListProvider signatures to new FeatureRow signatures - const adaptedToggleFlag = - (environmentId) => (projectFlag, environmentFlag, onError) => { - toggleFlag( - this.props.projectId, - environmentId, - projectFlag, - environmentFlag, - onError, - ) - } - const adaptedRemoveFlag = (projectFlag) => { - removeProjectFlag(getStore(), { - flag_id: projectFlag.id, - project_id: this.props.projectId, - }) - } - const renderRow = (p, i, fadeEnabled, fadeValue) => { - const environmentLeft = ProjectStore.getEnvironment( - this.state.environmentLeft, - ) - const environmentRight = ProjectStore.getEnvironment( - this.state.environmentRight, - ) - return ( - -
- -
- {p.projectFlagLeft.name} -
- - - } - > - {p.projectFlagLeft.description} -
-
- - {({ permission }) => ( - - )} - - - {({ permission }) => ( - - )} - -
- ) - } - - return ( -
- {this.state.isLoading && ( -
- -
- )} - {!this.state.isLoading && - (!this.state.changes || !this.state.changes.length) && ( - - )} - {!this.state.isLoading && - this.state.changes && - !!this.state.changes.length && ( -
- - { - FeatureListStore.isLoading = true - this.setState({ - showArchived: !this.state.showArchived, - }) - }} - className='px-2 py-2 ml-2 mr-2' - tag={Constants.archivedTag} - /> - - } - header={ - -
- Name -
- -
- { - ProjectStore.getEnvironment( - this.state.environmentLeft, - ).name - } -
- -
- -
- { - ProjectStore.getEnvironment( - this.state.environmentRight, - ).name - } -
- -
-
- } - className='no-pad mt-2' - items={this.filter(this.state.changes)} - renderRow={(p, i) => - renderRow( - p, - i, - !p.enabledChanged, - !(p.valueChanged || p.multivariateChanged), - ) - } - /> -
- )} - {!this.state.isLoading && - this.state.same && - !!this.state.same.length && ( -
-
- -
- Name -
- -
- { - ProjectStore.getEnvironment( - this.state.environmentLeft, - ).name - } -
- Value -
- -
- { - ProjectStore.getEnvironment( - this.state.environmentRight, - ).name - } -
- Value -
- - } - className='no-pad mt-2' - items={this.filter(this.state.same)} - renderRow={(p, i) => - renderRow( - p, - i, - !p.enabledChanged, - !(p.valueChanged || p.multivariateChanged), - ) - } - /> -
-
- )} -
- ) - }} -
- ) : ( - '' - )} -
- ) - } -} - -export default withRouter(ConfigProvider(CompareEnvironments)) diff --git a/frontend/web/components/CompareEnvironments.tsx b/frontend/web/components/CompareEnvironments.tsx new file mode 100644 index 000000000000..27196e08e397 --- /dev/null +++ b/frontend/web/components/CompareEnvironments.tsx @@ -0,0 +1,532 @@ +import React, { FC, useCallback, useMemo, useState } from 'react' +import sortBy from 'lodash/sortBy' +import EnvironmentSelect from './EnvironmentSelect' +import data from 'common/data/base/_data' +import ProjectStore from 'common/stores/project-store' +import Icon from './icons/Icon' +import { hasMultivariateChange } from 'common/utils/compareMultivariate' +import Switch from './Switch' +import Panel from './base/grid/Panel' +import Project from 'common/project' +import { FeaturesTableFilters } from './pages/features/components' +import DiffFeature from './diff/DiffFeature' +import { useGetFeatureStatesQuery } from 'common/services/useFeatureState' +import FeatureName from './feature-summary/FeatureName' +import FeatureValue from './feature-summary/FeatureValue' +import SegmentsIcon from './icons/SegmentsIcon' +import { + Environment, + FeatureState, + FeatureStateWithConflict, + ProjectFlag, + TagStrategy, +} from 'common/types/responses' +import type { FilterState } from 'common/types/featureFilters' +import { SortOrder } from 'common/types/requests' +import { + hasActiveFilters, + getFiltersFromParams, +} from 'common/utils/featureFilterParams' + +type FeatureChange = { + leftEnabled: boolean + leftValue: string | number | boolean | null + leftEnvironmentFlag: FeatureState + rightEnabled: boolean + rightValue: string | number | boolean | null + rightEnvironmentFlag: FeatureState + projectFlagLeft: ProjectFlag + projectFlagRight: ProjectFlag + enabledChanged: boolean + valueChanged: boolean + multivariateChanged: boolean + hasDiff: boolean +} + +type CompareEnvironmentsProps = { + projectId: string + environmentId?: string +} + +type ExpandedRowProps = { + item: FeatureChange + projectId: string + environmentLeftId: number + environmentRightId: number + oldEnvName?: string + newEnvName?: string +} + +const ExpandedRow: FC = ({ + environmentLeftId, + environmentRightId, + item, + newEnvName, + oldEnvName, + projectId, +}) => { + const { data: leftStates, isLoading: leftLoading } = useGetFeatureStatesQuery( + { + environment: environmentLeftId, + feature: item.projectFlagLeft.id, + }, + ) + + const { data: rightStates, isLoading: rightLoading } = + useGetFeatureStatesQuery({ + environment: environmentRightId, + feature: item.projectFlagLeft.id, + }) + + if (leftLoading || rightLoading) { + return ( +
+ +
+ ) + } + + return ( +
+ +
+ ) +} + +const CompareEnvironments: FC = ({ + environmentId: initialEnvironmentId, + projectId, +}) => { + const [environmentLeft, setEnvironmentLeft] = useState( + initialEnvironmentId || '', + ) + const [environmentRight, setEnvironmentRight] = useState('') + const [isLoading, setIsLoading] = useState(false) + const [changes, setChanges] = useState(null) + const [expandedRows, setExpandedRows] = useState>(new Set()) + const [filters, setFilters] = useState(getFiltersFromParams({})) + + const projectIdNum = parseInt(projectId) + + const fetch = useCallback(async () => { + if (!environmentLeft || !environmentRight) { + return + } + setIsLoading(true) + try { + const [ + environmentLeftProjectFlags, + environmentRightProjectFlags, + environmentLeftFlags, + environmentRightFlags, + ] = await Promise.all([ + data.get( + `${ + Project.api + }projects/${projectId}/features/?page_size=999&environment=${ProjectStore.getEnvironmentIdFromKey( + environmentLeft, + )}`, + ), + data.get( + `${ + Project.api + }projects/${projectId}/features/?page_size=999&environment=${ProjectStore.getEnvironmentIdFromKey( + environmentRight, + )}`, + ), + data.get( + `${Project.api}environments/${environmentLeft}/featurestates/?page_size=999`, + ), + data.get( + `${Project.api}environments/${environmentRight}/featurestates/?page_size=999`, + ), + ]) + + const changesArr: FeatureChange[] = [] + + sortBy(environmentLeftProjectFlags.results, (p) => p.name).forEach( + (projectFlagLeft: ProjectFlag) => { + const projectFlagRight = environmentRightProjectFlags.results?.find( + (pf: ProjectFlag) => pf.id === projectFlagLeft.id, + ) + const leftSide = environmentLeftFlags.results.find( + (v: FeatureState) => v.feature === projectFlagLeft.id, + ) + const rightSide = environmentRightFlags.results.find( + (v: FeatureState) => v.feature === projectFlagLeft.id, + ) + + if (!leftSide || !rightSide) return + + const enabledChanged = rightSide.enabled !== leftSide.enabled + const valueChanged = + rightSide.feature_state_value !== leftSide.feature_state_value + const multivariateChanged = hasMultivariateChange(leftSide, rightSide) + + const hasDiff = + enabledChanged || + valueChanged || + multivariateChanged || + !!projectFlagLeft.num_segment_overrides || + !!projectFlagRight?.num_segment_overrides + + const change: FeatureChange = { + enabledChanged, + hasDiff, + leftEnabled: leftSide.enabled, + leftEnvironmentFlag: leftSide, + leftValue: leftSide.feature_state_value, + multivariateChanged, + projectFlagLeft, + projectFlagRight, + rightEnabled: rightSide.enabled, + rightEnvironmentFlag: rightSide, + rightValue: rightSide.feature_state_value, + valueChanged, + } + + changesArr.push(change) + }, + ) + + setChanges(changesArr) + setExpandedRows(new Set()) + } catch { + // Error handling + } finally { + setIsLoading(false) + } + }, [environmentLeft, environmentRight, projectId]) + + React.useEffect(() => { + fetch() + }, [fetch]) + + const filterItems = useCallback( + (items: FeatureChange[] | null): FeatureChange[] => { + if (!items) return [] + + let filtered = items.filter((item) => { + if (!filters.showArchived && item.projectFlagLeft.is_archived) { + return false + } + + if (filters.search) { + const searchLower = filters.search.toLowerCase() + if (!item.projectFlagLeft.name.toLowerCase().includes(searchLower)) { + return false + } + } + + if (filters.tags.length > 0) { + const featureTags = item.projectFlagLeft.tags || [] + + if (filters.tags.includes('')) { + if (featureTags.length > 0) { + return false + } + } else { + const tagIds = filters.tags.filter((t) => t !== '') as number[] + if (tagIds.length > 0) { + if (filters.tag_strategy === TagStrategy.INTERSECTION) { + if (!tagIds.every((tagId) => featureTags.includes(tagId))) { + return false + } + } else { + if (!tagIds.some((tagId) => featureTags.includes(tagId))) { + return false + } + } + } + } + } + + if (filters.owners.length > 0) { + const ownerIds = item.projectFlagLeft.owners?.map((o) => o.id) || [] + if (!filters.owners.some((id) => ownerIds.includes(id))) { + return false + } + } + + if (filters.group_owners.length > 0) { + const groupIds = + item.projectFlagLeft.group_owners?.map((g) => g.id) || [] + if (!filters.group_owners.some((id) => groupIds.includes(id))) { + return false + } + } + + if (filters.is_enabled !== null) { + if (item.leftEnabled !== filters.is_enabled) { + return false + } + } + + return true + }) + + if (filters.sort.sortBy === 'created_date') { + filtered = sortBy(filtered, (f) => f.projectFlagLeft.created_date) + if (filters.sort.sortOrder === SortOrder.DESC) { + filtered = filtered.reverse() + } + } else { + filtered = sortBy(filtered, (f) => f.projectFlagLeft.name.toLowerCase()) + if (filters.sort.sortOrder === SortOrder.DESC) { + filtered = filtered.reverse() + } + } + + return filtered + }, + [filters], + ) + + const filteredItems = useMemo( + () => filterItems(changes), + [changes, filterItems], + ) + + const differentItems = useMemo( + () => filteredItems.filter((item) => item.hasDiff), + [filteredItems], + ) + + const sameItems = useMemo( + () => filteredItems.filter((item) => !item.hasDiff), + [filteredItems], + ) + + const toggleExpand = (featureId: number) => { + setExpandedRows((prev) => { + const next = new Set(prev) + if (next.has(featureId)) { + next.delete(featureId) + } else { + next.add(featureId) + } + return next + }) + } + + const handleFilterChange = (updates: Partial) => { + setFilters((prev) => ({ ...prev, ...updates })) + } + + const clearFilters = () => { + setFilters(getFiltersFromParams({})) + } + + const envLeft = ProjectStore.getEnvironment(environmentLeft) as + | Environment + | undefined + | null + const envRight = ProjectStore.getEnvironment(environmentRight) as + | Environment + | undefined + | null + + const envLeftId = ProjectStore.getEnvironmentIdFromKey(environmentLeft) + const envRightId = ProjectStore.getEnvironmentIdFromKey(environmentRight) + + const hasFilters = hasActiveFilters(filters) + + const renderRow = (item: FeatureChange, index: number) => { + const isExpanded = expandedRows.has(item.projectFlagLeft.id) + const totalSegments = Math.max( + item.projectFlagLeft.num_segment_overrides || 0, + item.projectFlagRight?.num_segment_overrides || 0, + ) + + return ( +
+
toggleExpand(item.projectFlagLeft.id)} + > +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ {totalSegments > 0 && ( + + + {totalSegments} + + )} +
+
+ + {isExpanded && envLeftId && envRightId && ( + + )} +
+ ) + } + + const renderHeader = () => ( + + ) + + return ( +
+
+
Compare Environments
+

+ Compare feature flag configurations across environments. +

+
+ + +
+ setEnvironmentLeft(value as string)} + value={environmentLeft} + /> +
+ +
+ +
+ +
+ setEnvironmentRight(value as string)} + value={environmentRight} + /> +
+
+ + {environmentLeft && environmentRight && ( + <> + {isLoading ? ( +
+ +
+ ) : ( + <> + +
+ {renderHeader()} + +
+ Changed features + + {differentItems.length} + +
+
+ {envLeft?.name} +
+
+ {envRight?.name} +
+
+ Segment changes +
+
+ {differentItems.length > 0 && + differentItems.map((item, i) => renderRow(item, i))} + {!differentItems.length && ( +
+ No differences found +
+ )} +
+
+ + {sameItems.length > 0 && ( + +
+ +
+ Unchanged features + {sameItems.length} +
+
+ {envLeft?.name} +
+
+ {envRight?.name} +
+
+ Segment changes +
+
+ {sameItems.map((item, i) => renderRow(item, i))} +
+
+ )} + + )} + + )} +
+ ) +} + +export default CompareEnvironments diff --git a/frontend/web/components/diff/DiffFeature.tsx b/frontend/web/components/diff/DiffFeature.tsx index 9713a73e8eeb..6d265c08a234 100644 --- a/frontend/web/components/diff/DiffFeature.tsx +++ b/frontend/web/components/diff/DiffFeature.tsx @@ -30,6 +30,8 @@ type FeatureDiffType = { tabTheme?: string conflicts?: FeatureConflict[] | undefined disableSegments?: boolean + oldEnvName?: string + newEnvName?: string } type ViewMode = 'combined' | 'new' | 'old' const DiffFeature: FC = ({ @@ -37,8 +39,10 @@ const DiffFeature: FC = ({ disableSegments, environmentId, featureId, + newEnvName, newState, noChangesMessage, + oldEnvName, oldState, projectId, tabTheme, @@ -81,8 +85,8 @@ const DiffFeature: FC = ({ !totalChanges && (diff.newValue === null || diff.newValue === undefined) const viewOptions = [ { label: 'Combined Diff', value: 'combined' }, - { label: 'New Value', value: 'new' }, - { label: 'Old Value', value: 'old' }, + { label: newEnvName || 'New Value', value: 'new' }, + { label: oldEnvName || 'Old Value', value: 'old' }, ] const selectedOption = viewOptions.find((v) => v.value === viewMode) return ( @@ -97,6 +101,20 @@ const DiffFeature: FC = ({ !totalSegmentChanges && !totalVariationChanges && noChangesMessage && {noChangesMessage}} + {(oldEnvName || newEnvName) && ( +
+ {oldEnvName && ( + + {oldEnvName} + + )} + {newEnvName && ( + + + {newEnvName} + + )} +
+ )} Date: Wed, 8 Jul 2026 11:33:50 +0100 Subject: [PATCH 2/4] feat: add edit buttons to Compare Environments page Add Edit buttons to each environment column that open the CreateFlagModal for editing features directly from the comparison view. The modal handles change requests automatically when required. Co-Authored-By: Claude Opus 4.5 --- .../web/components/CompareEnvironments.tsx | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/frontend/web/components/CompareEnvironments.tsx b/frontend/web/components/CompareEnvironments.tsx index 27196e08e397..2803c46986b9 100644 --- a/frontend/web/components/CompareEnvironments.tsx +++ b/frontend/web/components/CompareEnvironments.tsx @@ -1,5 +1,6 @@ import React, { FC, useCallback, useMemo, useState } from 'react' import sortBy from 'lodash/sortBy' +import { useHistory } from 'react-router-dom' import EnvironmentSelect from './EnvironmentSelect' import data from 'common/data/base/_data' import ProjectStore from 'common/stores/project-store' @@ -14,6 +15,9 @@ import { useGetFeatureStatesQuery } from 'common/services/useFeatureState' import FeatureName from './feature-summary/FeatureName' import FeatureValue from './feature-summary/FeatureValue' import SegmentsIcon from './icons/SegmentsIcon' +import Button from './base/forms/Button' +import CreateFlagModal from './modals/create-feature' +import Utils from 'common/utils/utils' import { Environment, FeatureState, @@ -111,6 +115,7 @@ const CompareEnvironments: FC = ({ environmentId: initialEnvironmentId, projectId, }) => { + const history = useHistory() const [environmentLeft, setEnvironmentLeft] = useState( initialEnvironmentId || '', ) @@ -122,6 +127,44 @@ const CompareEnvironments: FC = ({ const projectIdNum = parseInt(projectId) + const editFeature = ( + projectFlag: ProjectFlag, + environmentFlag: FeatureState, + environmentId: string, + environmentName?: string, + ) => { + openModal( + + + Edit Feature: {projectFlag.name} + {environmentName && ( + ({environmentName}) + )} + + + , + , + 'side-modal create-feature-modal', + () => { + fetch() + }, + ) + } + const fetch = useCallback(async () => { if (!environmentLeft || !environmentRight) { return @@ -374,6 +417,22 @@ const CompareEnvironments: FC = ({ > +
+
= ({ > +
+
Date: Wed, 8 Jul 2026 16:40:51 +0100 Subject: [PATCH 3/4] refactor(compare-environments): split into folder, fix review findings Address PR review feedback on the Compare Environments redesign: - Move the single 607-line component into a CompareEnvironments/ folder with a barrel: types.ts, useEnvironmentComparison hook (fetch + diff derivation, now unit-testable), ExpandedRow, CompareFeatureRow, and the layout component. - Replace relative imports with components/ and common/ aliases. - Reuse the shared FlagsmithValue type instead of an inline union. - Surface a fetch error state with a retry action instead of silently swallowing failures and rendering "No differences found". - Guard against stale overlapping comparisons with a request-id check so a slow earlier response can't overwrite a newer one. - Make the row expand toggle keyboard accessible (role, tabIndex, aria-expanded, Enter/Space handling). - Pass the numeric projectId into CreateFlagModal. - Fall back to the left project flag for the right-side edit action so the modal edits the existing feature instead of switching to create mode. - Consolidate duplicated diff header styles into a shared base rule. Co-Authored-By: Claude Fable 5 --- .../web/components/CompareEnvironments.tsx | 607 ------------------ .../CompareEnvironments.tsx | 334 ++++++++++ .../CompareEnvironments/CompareFeatureRow.tsx | 157 +++++ .../CompareEnvironments/ExpandedRow.tsx | 66 ++ .../components/CompareEnvironments/index.ts | 2 + .../components/CompareEnvironments/types.ts | 20 + .../useEnvironmentComparison.ts | 152 +++++ frontend/web/styles/3rdParty/_react-diff.scss | 14 +- 8 files changed, 738 insertions(+), 614 deletions(-) delete mode 100644 frontend/web/components/CompareEnvironments.tsx create mode 100644 frontend/web/components/CompareEnvironments/CompareEnvironments.tsx create mode 100644 frontend/web/components/CompareEnvironments/CompareFeatureRow.tsx create mode 100644 frontend/web/components/CompareEnvironments/ExpandedRow.tsx create mode 100644 frontend/web/components/CompareEnvironments/index.ts create mode 100644 frontend/web/components/CompareEnvironments/types.ts create mode 100644 frontend/web/components/CompareEnvironments/useEnvironmentComparison.ts diff --git a/frontend/web/components/CompareEnvironments.tsx b/frontend/web/components/CompareEnvironments.tsx deleted file mode 100644 index 2803c46986b9..000000000000 --- a/frontend/web/components/CompareEnvironments.tsx +++ /dev/null @@ -1,607 +0,0 @@ -import React, { FC, useCallback, useMemo, useState } from 'react' -import sortBy from 'lodash/sortBy' -import { useHistory } from 'react-router-dom' -import EnvironmentSelect from './EnvironmentSelect' -import data from 'common/data/base/_data' -import ProjectStore from 'common/stores/project-store' -import Icon from './icons/Icon' -import { hasMultivariateChange } from 'common/utils/compareMultivariate' -import Switch from './Switch' -import Panel from './base/grid/Panel' -import Project from 'common/project' -import { FeaturesTableFilters } from './pages/features/components' -import DiffFeature from './diff/DiffFeature' -import { useGetFeatureStatesQuery } from 'common/services/useFeatureState' -import FeatureName from './feature-summary/FeatureName' -import FeatureValue from './feature-summary/FeatureValue' -import SegmentsIcon from './icons/SegmentsIcon' -import Button from './base/forms/Button' -import CreateFlagModal from './modals/create-feature' -import Utils from 'common/utils/utils' -import { - Environment, - FeatureState, - FeatureStateWithConflict, - ProjectFlag, - TagStrategy, -} from 'common/types/responses' -import type { FilterState } from 'common/types/featureFilters' -import { SortOrder } from 'common/types/requests' -import { - hasActiveFilters, - getFiltersFromParams, -} from 'common/utils/featureFilterParams' - -type FeatureChange = { - leftEnabled: boolean - leftValue: string | number | boolean | null - leftEnvironmentFlag: FeatureState - rightEnabled: boolean - rightValue: string | number | boolean | null - rightEnvironmentFlag: FeatureState - projectFlagLeft: ProjectFlag - projectFlagRight: ProjectFlag - enabledChanged: boolean - valueChanged: boolean - multivariateChanged: boolean - hasDiff: boolean -} - -type CompareEnvironmentsProps = { - projectId: string - environmentId?: string -} - -type ExpandedRowProps = { - item: FeatureChange - projectId: string - environmentLeftId: number - environmentRightId: number - oldEnvName?: string - newEnvName?: string -} - -const ExpandedRow: FC = ({ - environmentLeftId, - environmentRightId, - item, - newEnvName, - oldEnvName, - projectId, -}) => { - const { data: leftStates, isLoading: leftLoading } = useGetFeatureStatesQuery( - { - environment: environmentLeftId, - feature: item.projectFlagLeft.id, - }, - ) - - const { data: rightStates, isLoading: rightLoading } = - useGetFeatureStatesQuery({ - environment: environmentRightId, - feature: item.projectFlagLeft.id, - }) - - if (leftLoading || rightLoading) { - return ( -
- -
- ) - } - - return ( -
- -
- ) -} - -const CompareEnvironments: FC = ({ - environmentId: initialEnvironmentId, - projectId, -}) => { - const history = useHistory() - const [environmentLeft, setEnvironmentLeft] = useState( - initialEnvironmentId || '', - ) - const [environmentRight, setEnvironmentRight] = useState('') - const [isLoading, setIsLoading] = useState(false) - const [changes, setChanges] = useState(null) - const [expandedRows, setExpandedRows] = useState>(new Set()) - const [filters, setFilters] = useState(getFiltersFromParams({})) - - const projectIdNum = parseInt(projectId) - - const editFeature = ( - projectFlag: ProjectFlag, - environmentFlag: FeatureState, - environmentId: string, - environmentName?: string, - ) => { - openModal( - - - Edit Feature: {projectFlag.name} - {environmentName && ( - ({environmentName}) - )} - - - , - , - 'side-modal create-feature-modal', - () => { - fetch() - }, - ) - } - - const fetch = useCallback(async () => { - if (!environmentLeft || !environmentRight) { - return - } - setIsLoading(true) - try { - const [ - environmentLeftProjectFlags, - environmentRightProjectFlags, - environmentLeftFlags, - environmentRightFlags, - ] = await Promise.all([ - data.get( - `${ - Project.api - }projects/${projectId}/features/?page_size=999&environment=${ProjectStore.getEnvironmentIdFromKey( - environmentLeft, - )}`, - ), - data.get( - `${ - Project.api - }projects/${projectId}/features/?page_size=999&environment=${ProjectStore.getEnvironmentIdFromKey( - environmentRight, - )}`, - ), - data.get( - `${Project.api}environments/${environmentLeft}/featurestates/?page_size=999`, - ), - data.get( - `${Project.api}environments/${environmentRight}/featurestates/?page_size=999`, - ), - ]) - - const changesArr: FeatureChange[] = [] - - sortBy(environmentLeftProjectFlags.results, (p) => p.name).forEach( - (projectFlagLeft: ProjectFlag) => { - const projectFlagRight = environmentRightProjectFlags.results?.find( - (pf: ProjectFlag) => pf.id === projectFlagLeft.id, - ) - const leftSide = environmentLeftFlags.results.find( - (v: FeatureState) => v.feature === projectFlagLeft.id, - ) - const rightSide = environmentRightFlags.results.find( - (v: FeatureState) => v.feature === projectFlagLeft.id, - ) - - if (!leftSide || !rightSide) return - - const enabledChanged = rightSide.enabled !== leftSide.enabled - const valueChanged = - rightSide.feature_state_value !== leftSide.feature_state_value - const multivariateChanged = hasMultivariateChange(leftSide, rightSide) - - const hasDiff = - enabledChanged || - valueChanged || - multivariateChanged || - !!projectFlagLeft.num_segment_overrides || - !!projectFlagRight?.num_segment_overrides - - const change: FeatureChange = { - enabledChanged, - hasDiff, - leftEnabled: leftSide.enabled, - leftEnvironmentFlag: leftSide, - leftValue: leftSide.feature_state_value, - multivariateChanged, - projectFlagLeft, - projectFlagRight, - rightEnabled: rightSide.enabled, - rightEnvironmentFlag: rightSide, - rightValue: rightSide.feature_state_value, - valueChanged, - } - - changesArr.push(change) - }, - ) - - setChanges(changesArr) - setExpandedRows(new Set()) - } catch { - // Error handling - } finally { - setIsLoading(false) - } - }, [environmentLeft, environmentRight, projectId]) - - React.useEffect(() => { - fetch() - }, [fetch]) - - const filterItems = useCallback( - (items: FeatureChange[] | null): FeatureChange[] => { - if (!items) return [] - - let filtered = items.filter((item) => { - if (!filters.showArchived && item.projectFlagLeft.is_archived) { - return false - } - - if (filters.search) { - const searchLower = filters.search.toLowerCase() - if (!item.projectFlagLeft.name.toLowerCase().includes(searchLower)) { - return false - } - } - - if (filters.tags.length > 0) { - const featureTags = item.projectFlagLeft.tags || [] - - if (filters.tags.includes('')) { - if (featureTags.length > 0) { - return false - } - } else { - const tagIds = filters.tags.filter((t) => t !== '') as number[] - if (tagIds.length > 0) { - if (filters.tag_strategy === TagStrategy.INTERSECTION) { - if (!tagIds.every((tagId) => featureTags.includes(tagId))) { - return false - } - } else { - if (!tagIds.some((tagId) => featureTags.includes(tagId))) { - return false - } - } - } - } - } - - if (filters.owners.length > 0) { - const ownerIds = item.projectFlagLeft.owners?.map((o) => o.id) || [] - if (!filters.owners.some((id) => ownerIds.includes(id))) { - return false - } - } - - if (filters.group_owners.length > 0) { - const groupIds = - item.projectFlagLeft.group_owners?.map((g) => g.id) || [] - if (!filters.group_owners.some((id) => groupIds.includes(id))) { - return false - } - } - - if (filters.is_enabled !== null) { - if (item.leftEnabled !== filters.is_enabled) { - return false - } - } - - return true - }) - - if (filters.sort.sortBy === 'created_date') { - filtered = sortBy(filtered, (f) => f.projectFlagLeft.created_date) - if (filters.sort.sortOrder === SortOrder.DESC) { - filtered = filtered.reverse() - } - } else { - filtered = sortBy(filtered, (f) => f.projectFlagLeft.name.toLowerCase()) - if (filters.sort.sortOrder === SortOrder.DESC) { - filtered = filtered.reverse() - } - } - - return filtered - }, - [filters], - ) - - const filteredItems = useMemo( - () => filterItems(changes), - [changes, filterItems], - ) - - const differentItems = useMemo( - () => filteredItems.filter((item) => item.hasDiff), - [filteredItems], - ) - - const sameItems = useMemo( - () => filteredItems.filter((item) => !item.hasDiff), - [filteredItems], - ) - - const toggleExpand = (featureId: number) => { - setExpandedRows((prev) => { - const next = new Set(prev) - if (next.has(featureId)) { - next.delete(featureId) - } else { - next.add(featureId) - } - return next - }) - } - - const handleFilterChange = (updates: Partial) => { - setFilters((prev) => ({ ...prev, ...updates })) - } - - const clearFilters = () => { - setFilters(getFiltersFromParams({})) - } - - const envLeft = ProjectStore.getEnvironment(environmentLeft) as - | Environment - | undefined - | null - const envRight = ProjectStore.getEnvironment(environmentRight) as - | Environment - | undefined - | null - - const envLeftId = ProjectStore.getEnvironmentIdFromKey(environmentLeft) - const envRightId = ProjectStore.getEnvironmentIdFromKey(environmentRight) - - const hasFilters = hasActiveFilters(filters) - - const renderRow = (item: FeatureChange, index: number) => { - const isExpanded = expandedRows.has(item.projectFlagLeft.id) - const totalSegments = Math.max( - item.projectFlagLeft.num_segment_overrides || 0, - item.projectFlagRight?.num_segment_overrides || 0, - ) - - return ( -
-
toggleExpand(item.projectFlagLeft.id)} - > -
- - -
- -
- - -
- -
- -
- - -
- -
- -
- {totalSegments > 0 && ( - - - {totalSegments} - - )} -
-
- - {isExpanded && envLeftId && envRightId && ( - - )} -
- ) - } - - const renderHeader = () => ( - - ) - - return ( -
-
-
Compare Environments
-

- Compare feature flag configurations across environments. -

-
- - -
- setEnvironmentLeft(value as string)} - value={environmentLeft} - /> -
- -
- -
- -
- setEnvironmentRight(value as string)} - value={environmentRight} - /> -
-
- - {environmentLeft && environmentRight && ( - <> - {isLoading ? ( -
- -
- ) : ( - <> - -
- {renderHeader()} - -
- Changed features - - {differentItems.length} - -
-
- {envLeft?.name} -
-
- {envRight?.name} -
-
- Segment changes -
-
- {differentItems.length > 0 && - differentItems.map((item, i) => renderRow(item, i))} - {!differentItems.length && ( -
- No differences found -
- )} -
-
- - {sameItems.length > 0 && ( - -
- -
- Unchanged features - {sameItems.length} -
-
- {envLeft?.name} -
-
- {envRight?.name} -
-
- Segment changes -
-
- {sameItems.map((item, i) => renderRow(item, i))} -
-
- )} - - )} - - )} -
- ) -} - -export default CompareEnvironments diff --git a/frontend/web/components/CompareEnvironments/CompareEnvironments.tsx b/frontend/web/components/CompareEnvironments/CompareEnvironments.tsx new file mode 100644 index 000000000000..504ed3def0f2 --- /dev/null +++ b/frontend/web/components/CompareEnvironments/CompareEnvironments.tsx @@ -0,0 +1,334 @@ +import React, { FC, useCallback, useEffect, useMemo, useState } from 'react' +import sortBy from 'lodash/sortBy' +import { useHistory } from 'react-router-dom' +import EnvironmentSelect from 'components/EnvironmentSelect' +import Icon from 'components/icons/Icon' +import Panel from 'components/base/grid/Panel' +import Button from 'components/base/forms/Button' +import CreateFlagModal from 'components/modals/create-feature' +import { FeaturesTableFilters } from 'components/pages/features/components' +import Utils from 'common/utils/utils' +import { TagStrategy } from 'common/types/responses' +import type { FilterState } from 'common/types/featureFilters' +import { SortOrder } from 'common/types/requests' +import { + getFiltersFromParams, + hasActiveFilters, +} from 'common/utils/featureFilterParams' +import CompareFeatureRow, { EditFeatureHandler } from './CompareFeatureRow' +import { FeatureChange } from './types' +import { useEnvironmentComparison } from './useEnvironmentComparison' + +type CompareEnvironmentsProps = { + projectId: string + environmentId?: string +} + +const filterAndSortChanges = ( + items: FeatureChange[] | null, + filters: FilterState, +): FeatureChange[] => { + if (!items) return [] + + const filtered = items.filter((item) => { + if (!filters.showArchived && item.projectFlagLeft.is_archived) { + return false + } + + if (filters.search) { + const searchLower = filters.search.toLowerCase() + if (!item.projectFlagLeft.name.toLowerCase().includes(searchLower)) { + return false + } + } + + if (filters.tags.length > 0) { + const featureTags = item.projectFlagLeft.tags || [] + + if (filters.tags.includes('')) { + if (featureTags.length > 0) { + return false + } + } else { + const tagIds = filters.tags.filter((t) => t !== '') as number[] + if (tagIds.length > 0) { + if (filters.tag_strategy === TagStrategy.INTERSECTION) { + if (!tagIds.every((tagId) => featureTags.includes(tagId))) { + return false + } + } else if (!tagIds.some((tagId) => featureTags.includes(tagId))) { + return false + } + } + } + } + + if (filters.owners.length > 0) { + const ownerIds = item.projectFlagLeft.owners?.map((o) => o.id) || [] + if (!filters.owners.some((id) => ownerIds.includes(id))) { + return false + } + } + + if (filters.group_owners.length > 0) { + const groupIds = item.projectFlagLeft.group_owners?.map((g) => g.id) || [] + if (!filters.group_owners.some((id) => groupIds.includes(id))) { + return false + } + } + + if ( + filters.is_enabled !== null && + item.leftEnabled !== filters.is_enabled + ) { + return false + } + + return true + }) + + const sorted = + filters.sort.sortBy === 'created_date' + ? sortBy(filtered, (f) => f.projectFlagLeft.created_date) + : sortBy(filtered, (f) => f.projectFlagLeft.name.toLowerCase()) + + return filters.sort.sortOrder === SortOrder.DESC ? sorted.reverse() : sorted +} + +const CompareEnvironments: FC = ({ + environmentId: initialEnvironmentId, + projectId, +}) => { + const history = useHistory() + const [environmentLeft, setEnvironmentLeft] = useState( + initialEnvironmentId || '', + ) + const [environmentRight, setEnvironmentRight] = useState('') + const [expandedRows, setExpandedRows] = useState>(new Set()) + const [filters, setFilters] = useState(getFiltersFromParams({})) + + const projectIdNum = parseInt(projectId) + + const { + changes, + error, + isLoading, + leftEnvironment, + leftEnvironmentId, + refresh, + rightEnvironment, + rightEnvironmentId, + } = useEnvironmentComparison({ + leftEnvironmentKey: environmentLeft, + projectId, + rightEnvironmentKey: environmentRight, + }) + + // Collapse any expanded rows whenever a fresh comparison loads + useEffect(() => { + setExpandedRows(new Set()) + }, [changes]) + + const editFeature: EditFeatureHandler = useCallback( + (projectFlag, environmentFlag, environmentKey, environmentName) => { + openModal( + + + Edit Feature: {projectFlag.name} + {environmentName && ( + ({environmentName}) + )} + + + , + , + 'side-modal create-feature-modal', + () => { + refresh() + }, + ) + }, + [history, projectIdNum, refresh], + ) + + const filteredItems = useMemo( + () => filterAndSortChanges(changes, filters), + [changes, filters], + ) + + const differentItems = useMemo( + () => filteredItems.filter((item) => item.hasDiff), + [filteredItems], + ) + + const sameItems = useMemo( + () => filteredItems.filter((item) => !item.hasDiff), + [filteredItems], + ) + + const toggleExpand = (featureId: number) => { + setExpandedRows((prev) => { + const next = new Set(prev) + if (next.has(featureId)) { + next.delete(featureId) + } else { + next.add(featureId) + } + return next + }) + } + + const handleFilterChange = (updates: Partial) => { + setFilters((prev) => ({ ...prev, ...updates })) + } + + const clearFilters = () => { + setFilters(getFiltersFromParams({})) + } + + const hasFilters = hasActiveFilters(filters) + + const renderRow = (item: FeatureChange, index: number) => ( + + ) + + const renderResults = () => { + if (isLoading) { + return ( +
+ +
+ ) + } + + if (error) { + return ( +
+ Could not load the comparison.{' '} + +
+ ) + } + + return ( + <> + +
+ + {renderTableHeader('Changed features', differentItems.length)} + {differentItems.length > 0 ? ( + differentItems.map((item, i) => renderRow(item, i)) + ) : ( +
+ No differences found +
+ )} +
+
+ + {sameItems.length > 0 && ( + +
+ {renderTableHeader('Unchanged features', sameItems.length)} + {sameItems.map((item, i) => renderRow(item, i))} +
+
+ )} + + ) + } + + const renderTableHeader = (label: string, count: number) => ( + +
+ {label} + {count} +
+
+ {leftEnvironment?.name} +
+
+ {rightEnvironment?.name} +
+
+ Segment changes +
+
+ ) + + return ( +
+
+
Compare Environments
+

+ Compare feature flag configurations across environments. +

+
+ + +
+ setEnvironmentLeft(value as string)} + value={environmentLeft} + /> +
+ +
+ +
+ +
+ setEnvironmentRight(value as string)} + value={environmentRight} + /> +
+
+ + {environmentLeft && environmentRight && renderResults()} +
+ ) +} + +export default CompareEnvironments diff --git a/frontend/web/components/CompareEnvironments/CompareFeatureRow.tsx b/frontend/web/components/CompareEnvironments/CompareFeatureRow.tsx new file mode 100644 index 000000000000..0741e32636ab --- /dev/null +++ b/frontend/web/components/CompareEnvironments/CompareFeatureRow.tsx @@ -0,0 +1,157 @@ +import React, { FC } from 'react' +import { FeatureState, ProjectFlag } from 'common/types/responses' +import Icon from 'components/icons/Icon' +import Switch from 'components/Switch' +import FeatureName from 'components/feature-summary/FeatureName' +import FeatureValue from 'components/feature-summary/FeatureValue' +import SegmentsIcon from 'components/icons/SegmentsIcon' +import Button from 'components/base/forms/Button' +import ExpandedRow from './ExpandedRow' +import { FeatureChange } from './types' + +export type EditFeatureHandler = ( + projectFlag: ProjectFlag, + environmentFlag: FeatureState, + environmentKey: string, + environmentName?: string, +) => void + +type CompareFeatureRowProps = { + item: FeatureChange + index: number + projectId: string + isExpanded: boolean + onToggle: (featureId: number) => void + onEdit: EditFeatureHandler + leftEnvironmentKey: string + rightEnvironmentKey: string + leftEnvironmentId?: number + rightEnvironmentId?: number + leftEnvironmentName?: string + rightEnvironmentName?: string +} + +const CompareFeatureRow: FC = ({ + index, + isExpanded, + item, + leftEnvironmentId, + leftEnvironmentKey, + leftEnvironmentName, + onEdit, + onToggle, + projectId, + rightEnvironmentId, + rightEnvironmentKey, + rightEnvironmentName, +}) => { + const featureId = item.projectFlagLeft.id + const totalSegments = Math.max( + item.projectFlagLeft.num_segment_overrides || 0, + item.projectFlagRight?.num_segment_overrides || 0, + ) + const toggle = () => onToggle(featureId) + + return ( +
+
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + toggle() + } + }} + > +
+ + +
+ +
+ + +
+ +
+ +
+ + +
+ +
+ +
+ {totalSegments > 0 && ( + + + {totalSegments} + + )} +
+
+ + {isExpanded && leftEnvironmentId && rightEnvironmentId && ( + + )} +
+ ) +} + +export default CompareFeatureRow diff --git a/frontend/web/components/CompareEnvironments/ExpandedRow.tsx b/frontend/web/components/CompareEnvironments/ExpandedRow.tsx new file mode 100644 index 000000000000..3c63bd84bb24 --- /dev/null +++ b/frontend/web/components/CompareEnvironments/ExpandedRow.tsx @@ -0,0 +1,66 @@ +import React, { FC } from 'react' +import { useGetFeatureStatesQuery } from 'common/services/useFeatureState' +import { FeatureStateWithConflict } from 'common/types/responses' +import DiffFeature from 'components/diff/DiffFeature' +import { FeatureChange } from './types' + +type ExpandedRowProps = { + item: FeatureChange + projectId: string + environmentLeftId: number + environmentRightId: number + oldEnvName?: string + newEnvName?: string +} + +const ExpandedRow: FC = ({ + environmentLeftId, + environmentRightId, + item, + newEnvName, + oldEnvName, + projectId, +}) => { + const { data: leftStates, isLoading: leftLoading } = useGetFeatureStatesQuery( + { + environment: environmentLeftId, + feature: item.projectFlagLeft.id, + }, + ) + + const { data: rightStates, isLoading: rightLoading } = + useGetFeatureStatesQuery({ + environment: environmentRightId, + feature: item.projectFlagLeft.id, + }) + + if (leftLoading || rightLoading) { + return ( +
+ +
+ ) + } + + return ( +
+ +
+ ) +} + +export default ExpandedRow diff --git a/frontend/web/components/CompareEnvironments/index.ts b/frontend/web/components/CompareEnvironments/index.ts new file mode 100644 index 000000000000..fd2619d8f844 --- /dev/null +++ b/frontend/web/components/CompareEnvironments/index.ts @@ -0,0 +1,2 @@ +export { default } from './CompareEnvironments' +export type { FeatureChange } from './types' diff --git a/frontend/web/components/CompareEnvironments/types.ts b/frontend/web/components/CompareEnvironments/types.ts new file mode 100644 index 000000000000..9454302d7766 --- /dev/null +++ b/frontend/web/components/CompareEnvironments/types.ts @@ -0,0 +1,20 @@ +import { + FeatureState, + FlagsmithValue, + ProjectFlag, +} from 'common/types/responses' + +export type FeatureChange = { + leftEnabled: boolean + leftValue: FlagsmithValue + leftEnvironmentFlag: FeatureState + rightEnabled: boolean + rightValue: FlagsmithValue + rightEnvironmentFlag: FeatureState + projectFlagLeft: ProjectFlag + projectFlagRight?: ProjectFlag + enabledChanged: boolean + valueChanged: boolean + multivariateChanged: boolean + hasDiff: boolean +} diff --git a/frontend/web/components/CompareEnvironments/useEnvironmentComparison.ts b/frontend/web/components/CompareEnvironments/useEnvironmentComparison.ts new file mode 100644 index 000000000000..4497fda1a698 --- /dev/null +++ b/frontend/web/components/CompareEnvironments/useEnvironmentComparison.ts @@ -0,0 +1,152 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import sortBy from 'lodash/sortBy' +import data from 'common/data/base/_data' +import Project from 'common/project' +import ProjectStore from 'common/stores/project-store' +import { hasMultivariateChange } from 'common/utils/compareMultivariate' +import { Environment, FeatureState, ProjectFlag } from 'common/types/responses' +import { FeatureChange } from './types' + +type UseEnvironmentComparisonArgs = { + projectId: string + leftEnvironmentKey: string + rightEnvironmentKey: string +} + +const deriveChanges = ( + leftProjectFlags: ProjectFlag[], + rightProjectFlags: ProjectFlag[], + leftFlags: FeatureState[], + rightFlags: FeatureState[], +): FeatureChange[] => { + const changes: FeatureChange[] = [] + + sortBy(leftProjectFlags, (p) => p.name).forEach((projectFlagLeft) => { + const projectFlagRight = rightProjectFlags.find( + (pf) => pf.id === projectFlagLeft.id, + ) + const leftSide = leftFlags.find((v) => v.feature === projectFlagLeft.id) + const rightSide = rightFlags.find((v) => v.feature === projectFlagLeft.id) + + if (!leftSide || !rightSide) return + + const enabledChanged = rightSide.enabled !== leftSide.enabled + const valueChanged = + rightSide.feature_state_value !== leftSide.feature_state_value + const multivariateChanged = hasMultivariateChange(leftSide, rightSide) + + const hasDiff = + enabledChanged || + valueChanged || + multivariateChanged || + !!projectFlagLeft.num_segment_overrides || + !!projectFlagRight?.num_segment_overrides + + changes.push({ + enabledChanged, + hasDiff, + leftEnabled: leftSide.enabled, + leftEnvironmentFlag: leftSide, + leftValue: leftSide.feature_state_value, + multivariateChanged, + projectFlagLeft, + projectFlagRight, + rightEnabled: rightSide.enabled, + rightEnvironmentFlag: rightSide, + rightValue: rightSide.feature_state_value, + valueChanged, + }) + }) + + return changes +} + +export const useEnvironmentComparison = ({ + leftEnvironmentKey, + projectId, + rightEnvironmentKey, +}: UseEnvironmentComparisonArgs) => { + const [changes, setChanges] = useState(null) + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState(false) + + // Bumped on every run so a slow earlier response can't overwrite the + // result of a newer comparison (overlapping-fetch guard) + const requestId = useRef(0) + + const refresh = useCallback(async () => { + if (!leftEnvironmentKey || !rightEnvironmentKey) { + return + } + + const currentRequest = ++requestId.current + setIsLoading(true) + setError(false) + + const leftEnvironmentId = + ProjectStore.getEnvironmentIdFromKey(leftEnvironmentKey) + const rightEnvironmentId = + ProjectStore.getEnvironmentIdFromKey(rightEnvironmentKey) + + try { + const [leftProjectFlags, rightProjectFlags, leftFlags, rightFlags] = + await Promise.all([ + data.get( + `${Project.api}projects/${projectId}/features/?page_size=999&environment=${leftEnvironmentId}`, + ), + data.get( + `${Project.api}projects/${projectId}/features/?page_size=999&environment=${rightEnvironmentId}`, + ), + data.get( + `${Project.api}environments/${leftEnvironmentKey}/featurestates/?page_size=999`, + ), + data.get( + `${Project.api}environments/${rightEnvironmentKey}/featurestates/?page_size=999`, + ), + ]) + + if (currentRequest !== requestId.current) return + + setChanges( + deriveChanges( + leftProjectFlags.results || [], + rightProjectFlags.results || [], + leftFlags.results || [], + rightFlags.results || [], + ), + ) + } catch { + if (currentRequest !== requestId.current) return + setError(true) + } finally { + if (currentRequest === requestId.current) { + setIsLoading(false) + } + } + }, [leftEnvironmentKey, rightEnvironmentKey, projectId]) + + useEffect(() => { + refresh() + }, [refresh]) + + const leftEnvironment = ProjectStore.getEnvironment(leftEnvironmentKey) as + | Environment + | undefined + | null + const rightEnvironment = ProjectStore.getEnvironment(rightEnvironmentKey) as + | Environment + | undefined + | null + + return { + changes, + error, + isLoading, + leftEnvironment, + leftEnvironmentId: ProjectStore.getEnvironmentIdFromKey(leftEnvironmentKey), + refresh, + rightEnvironment, + rightEnvironmentId: + ProjectStore.getEnvironmentIdFromKey(rightEnvironmentKey), + } +} diff --git a/frontend/web/styles/3rdParty/_react-diff.scss b/frontend/web/styles/3rdParty/_react-diff.scss index 1371d21e4bbc..026d8847e008 100644 --- a/frontend/web/styles/3rdParty/_react-diff.scss +++ b/frontend/web/styles/3rdParty/_react-diff.scss @@ -97,22 +97,22 @@ } // Environment name legend for diff headers -.diff-removed-header { - background-color: $danger-alfa-16; - color: $danger; +.diff-removed-header, +.diff-added-header { font-weight: 500; font-size: $font-caption; padding: 4px 8px; border-radius: 4px; } +.diff-removed-header { + background-color: $danger-alfa-16; + color: $danger; +} + .diff-added-header { background-color: $success-alfa-16; color: $success; - font-weight: 500; - font-size: $font-caption; - padding: 4px 8px; - border-radius: 4px; } .diff-sign { From c6df7d30a4d775773cba9d9e59acd9b8fd1b3723 Mon Sep 17 00:00:00 2001 From: kyle-ssg Date: Wed, 8 Jul 2026 16:49:52 +0100 Subject: [PATCH 4/4] refactor(compare-environments): paginate fetches via shared recursivePageGet Export the existing recursivePageGet helper and reuse it in the comparison hook so every list is fetched across all pages, replacing the page_size=999 calls that silently capped results at 999. A small _data.get adapter bridges it to the baseQuery contract the helper expects. Co-Authored-By: Claude Fable 5 --- frontend/common/services/useProjectFlag.ts | 2 +- .../useEnvironmentComparison.ts | 31 +++++++++++++------ 2 files changed, 23 insertions(+), 10 deletions(-) diff --git a/frontend/common/services/useProjectFlag.ts b/frontend/common/services/useProjectFlag.ts index 40e93ad4c780..271833800370 100644 --- a/frontend/common/services/useProjectFlag.ts +++ b/frontend/common/services/useProjectFlag.ts @@ -9,7 +9,7 @@ import { sortMultivariateOptions } from 'common/utils/multivariate' */ export const FEATURES_PAGE_SIZE = 50 -function recursivePageGet( +export function recursivePageGet( url: string, parentRes: null | PagedResponse, baseQuery: (arg: unknown) => any, // matches rtk types, diff --git a/frontend/web/components/CompareEnvironments/useEnvironmentComparison.ts b/frontend/web/components/CompareEnvironments/useEnvironmentComparison.ts index 4497fda1a698..ca67d3f2a8bc 100644 --- a/frontend/web/components/CompareEnvironments/useEnvironmentComparison.ts +++ b/frontend/web/components/CompareEnvironments/useEnvironmentComparison.ts @@ -4,7 +4,13 @@ import data from 'common/data/base/_data' import Project from 'common/project' import ProjectStore from 'common/stores/project-store' import { hasMultivariateChange } from 'common/utils/compareMultivariate' -import { Environment, FeatureState, ProjectFlag } from 'common/types/responses' +import { recursivePageGet } from 'common/services/useProjectFlag' +import { + Environment, + FeatureState, + PagedResponse, + ProjectFlag, +} from 'common/types/responses' import { FeatureChange } from './types' type UseEnvironmentComparisonArgs = { @@ -13,6 +19,13 @@ type UseEnvironmentComparisonArgs = { rightEnvironmentKey: string } +// Adapt _data.get to the baseQuery contract recursivePageGet expects: it reads +// `results`/`next` straight off the resolved body, which is what _data.get returns +const pagedGet = (url: string) => + recursivePageGet(url, null, (arg) => + data.get((arg as { url: string }).url), + ) as Promise> + const deriveChanges = ( leftProjectFlags: ProjectFlag[], rightProjectFlags: ProjectFlag[], @@ -91,17 +104,17 @@ export const useEnvironmentComparison = ({ try { const [leftProjectFlags, rightProjectFlags, leftFlags, rightFlags] = await Promise.all([ - data.get( - `${Project.api}projects/${projectId}/features/?page_size=999&environment=${leftEnvironmentId}`, + pagedGet( + `${Project.api}projects/${projectId}/features/?environment=${leftEnvironmentId}`, ), - data.get( - `${Project.api}projects/${projectId}/features/?page_size=999&environment=${rightEnvironmentId}`, + pagedGet( + `${Project.api}projects/${projectId}/features/?environment=${rightEnvironmentId}`, ), - data.get( - `${Project.api}environments/${leftEnvironmentKey}/featurestates/?page_size=999`, + pagedGet( + `${Project.api}environments/${leftEnvironmentKey}/featurestates/`, ), - data.get( - `${Project.api}environments/${rightEnvironmentKey}/featurestates/?page_size=999`, + pagedGet( + `${Project.api}environments/${rightEnvironmentKey}/featurestates/`, ), ])