From 2aaa93c4855ad3f717db6bdc198b7dbf1bedef76 Mon Sep 17 00:00:00 2001 From: kyle-ssg Date: Wed, 8 Jul 2026 10:19:30 +0100 Subject: [PATCH 1/7] 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/7] 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/7] 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/7] 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/`, ), ]) From bae288befe525418193f870ac6fa1ca06b7d3683 Mon Sep 17 00:00:00 2001 From: kyle-ssg Date: Wed, 15 Jul 2026 11:32:03 +0100 Subject: [PATCH 5/7] refactor(compare-environments): replace _data fetches with RTK Query - Add getAllEnvironmentFeatureStates endpoint for bulk environments/{key}/featurestates/ (default states only, all pages) - Rewrite useEnvironmentComparison to use RTK Query hooks, dropping the manual race guard in favour of currentData semantics - Fix recursivePageGet: it read results/next off the RTK baseQuery {data} wrapper, so pagination silently stopped after the first page - Move recursivePageGet to common/utils as it is now shared by the project flag and feature state services - Surface feature state fetch errors in ExpandedRow instead of showing "No differences between environments" Co-Authored-By: Claude Fable 5 --- frontend/common/services/useFeatureState.ts | 20 +- frontend/common/services/useProjectFlag.ts | 30 +-- frontend/common/types/requests.ts | 3 + frontend/common/utils/recursivePageGet.ts | 26 +++ .../CompareEnvironments/ExpandedRow.tsx | 35 ++-- .../useEnvironmentComparison.ts | 184 +++++++++--------- 6 files changed, 167 insertions(+), 131 deletions(-) create mode 100644 frontend/common/utils/recursivePageGet.ts diff --git a/frontend/common/services/useFeatureState.ts b/frontend/common/services/useFeatureState.ts index e9f5d9bf6adb..0f0ed75228c4 100644 --- a/frontend/common/services/useFeatureState.ts +++ b/frontend/common/services/useFeatureState.ts @@ -3,6 +3,7 @@ import { Req } from 'common/types/requests' import { service } from 'common/service' import Utils from 'common/utils/utils' import { getFeatureSegment } from './useFeatureSegment' +import { recursivePageGet } from 'common/utils/recursivePageGet' import { getStore } from 'common/store' export const addFeatureSegmentsToFeatureStates = async (v) => { if (typeof v.feature_segment !== 'number') { @@ -22,6 +23,18 @@ export const featureStateService = service }) .injectEndpoints({ endpoints: (builder) => ({ + getAllEnvironmentFeatureStates: builder.query< + Res['featureStates'], + Req['getAllEnvironmentFeatureStates'] + >({ + providesTags: [{ id: 'LIST', type: 'FeatureState' }], + queryFn: async (query, _baseQueryApi, _extraOptions, baseQuery) => + recursivePageGet( + `environments/${query.environmentKey}/featurestates/?page_size=999`, + null, + baseQuery, + ), + }), getFeatureStates: builder.query< Res['featureStates'], Req['getFeatureStates'] @@ -92,5 +105,8 @@ export async function updateFeatureState( ) } -export const { useGetFeatureStatesQuery, useUpdateFeatureStateMutation } = - featureStateService +export const { + useGetAllEnvironmentFeatureStatesQuery, + useGetFeatureStatesQuery, + useUpdateFeatureStateMutation, +} = featureStateService diff --git a/frontend/common/services/useProjectFlag.ts b/frontend/common/services/useProjectFlag.ts index 271833800370..483c36961771 100644 --- a/frontend/common/services/useProjectFlag.ts +++ b/frontend/common/services/useProjectFlag.ts @@ -1,38 +1,14 @@ -import { PagedResponse, ProjectFlag, Res } from 'common/types/responses' +import { ProjectFlag, Res } from 'common/types/responses' import { Req } from 'common/types/requests' import { service } from 'common/service' import Utils from 'common/utils/utils' import { sortMultivariateOptions } from 'common/utils/multivariate' +import { recursivePageGet } from 'common/utils/recursivePageGet' /** * Number of features to display per page in the features list. */ export const FEATURES_PAGE_SIZE = 50 - -export function recursivePageGet( - url: string, - parentRes: null | PagedResponse, - baseQuery: (arg: unknown) => any, // matches rtk types, -) { - return baseQuery({ - method: 'GET', - url, - }).then((res: Res['projectFlags']) => { - let response - if (parentRes) { - response = { - ...parentRes, - results: parentRes.results.concat(res.results), - } - } else { - response = res - } - if (res.next) { - return recursivePageGet(res.next, response, baseQuery) - } - return Promise.resolve(response) - }) -} export const projectFlagService = service .enhanceEndpoints({ addTagTypes: ['ProjectFlag', 'FeatureList', 'FeatureState', 'Environment'], @@ -158,7 +134,7 @@ export const projectFlagService = service }, ], queryFn: async (args, _, _2, baseQuery) => { - return await recursivePageGet( + return await recursivePageGet( `projects/${args.project}/features/?${Utils.toParam({ ...args, page_size: 999, diff --git a/frontend/common/types/requests.ts b/frontend/common/types/requests.ts index bde43452a006..c5169f8718ec 100644 --- a/frontend/common/types/requests.ts +++ b/frontend/common/types/requests.ts @@ -758,6 +758,9 @@ export type Req = { environment?: number feature?: number } + getAllEnvironmentFeatureStates: { + environmentKey: string + } getFeatureSegment: { id: number } getSamlConfiguration: { name: string } getSamlConfigurations: { organisation_id: number } diff --git a/frontend/common/utils/recursivePageGet.ts b/frontend/common/utils/recursivePageGet.ts new file mode 100644 index 000000000000..2ae3b1c4ab70 --- /dev/null +++ b/frontend/common/utils/recursivePageGet.ts @@ -0,0 +1,26 @@ +import { PagedResponse } from 'common/types/responses' + +// Fetches every page of a paginated endpoint through an RTK Query baseQuery, +// following `next` links and concatenating `results`. Returns the queryFn +// contract: `{ data }` with the merged response, or `{ error }` on failure. +export function recursivePageGet( + url: string, + parentRes: null | PagedResponse, + baseQuery: (arg: unknown) => any, // matches rtk types, +): Promise<{ data: PagedResponse } | { error: any }> { + return baseQuery({ + method: 'GET', + url, + }).then((res: { data?: PagedResponse; error?: any }) => { + if (res.error || !res.data) { + return res + } + const response = parentRes + ? { ...res.data, results: parentRes.results.concat(res.data.results) } + : res.data + if (response.next) { + return recursivePageGet(response.next, response, baseQuery) + } + return { data: response } + }) +} diff --git a/frontend/web/components/CompareEnvironments/ExpandedRow.tsx b/frontend/web/components/CompareEnvironments/ExpandedRow.tsx index 3c63bd84bb24..694aa5d082cb 100644 --- a/frontend/web/components/CompareEnvironments/ExpandedRow.tsx +++ b/frontend/web/components/CompareEnvironments/ExpandedRow.tsx @@ -21,18 +21,23 @@ const ExpandedRow: FC = ({ oldEnvName, projectId, }) => { - const { data: leftStates, isLoading: leftLoading } = useGetFeatureStatesQuery( - { - environment: environmentLeftId, - feature: item.projectFlagLeft.id, - }, - ) + const { + data: leftStates, + isError: leftError, + isLoading: leftLoading, + } = useGetFeatureStatesQuery({ + environment: environmentLeftId, + feature: item.projectFlagLeft.id, + }) - const { data: rightStates, isLoading: rightLoading } = - useGetFeatureStatesQuery({ - environment: environmentRightId, - feature: item.projectFlagLeft.id, - }) + const { + data: rightStates, + isError: rightError, + isLoading: rightLoading, + } = useGetFeatureStatesQuery({ + environment: environmentRightId, + feature: item.projectFlagLeft.id, + }) if (leftLoading || rightLoading) { return ( @@ -42,6 +47,14 @@ const ExpandedRow: FC = ({ ) } + if (leftError || rightError) { + return ( +
+ Could not load the comparison for this feature. +
+ ) + } + return (
(url: string) => - recursivePageGet(url, null, (arg) => - data.get((arg as { url: string }).url), - ) as Promise> +// ProjectStore.getEnvironment returns null while the project is loading and +// undefined for unknown keys +type StoredEnvironment = Environment | undefined | null const deriveChanges = ( leftProjectFlags: ProjectFlag[], @@ -79,87 +70,98 @@ export const useEnvironmentComparison = ({ 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([ - pagedGet( - `${Project.api}projects/${projectId}/features/?environment=${leftEnvironmentId}`, - ), - pagedGet( - `${Project.api}projects/${projectId}/features/?environment=${rightEnvironmentId}`, - ), - pagedGet( - `${Project.api}environments/${leftEnvironmentKey}/featurestates/`, - ), - pagedGet( - `${Project.api}environments/${rightEnvironmentKey}/featurestates/`, - ), - ]) - - 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) - } + const leftEnvironmentId = + ProjectStore.getEnvironmentIdFromKey(leftEnvironmentKey) + const rightEnvironmentId = + ProjectStore.getEnvironmentIdFromKey(rightEnvironmentKey) + + const { + currentData: leftProjectFlags, + isError: leftProjectFlagsError, + isFetching: leftProjectFlagsFetching, + refetch: refetchLeftProjectFlags, + } = useGetProjectFlagsQuery( + { environment: leftEnvironmentId, project: projectId }, + { skip: !leftEnvironmentId }, + ) + + const { + currentData: rightProjectFlags, + isError: rightProjectFlagsError, + isFetching: rightProjectFlagsFetching, + refetch: refetchRightProjectFlags, + } = useGetProjectFlagsQuery( + { environment: rightEnvironmentId, project: projectId }, + { skip: !rightEnvironmentId }, + ) + + const { + currentData: leftFlags, + isError: leftFlagsError, + isFetching: leftFlagsFetching, + refetch: refetchLeftFlags, + } = useGetAllEnvironmentFeatureStatesQuery( + { environmentKey: leftEnvironmentKey }, + { skip: !leftEnvironmentKey }, + ) + + const { + currentData: rightFlags, + isError: rightFlagsError, + isFetching: rightFlagsFetching, + refetch: refetchRightFlags, + } = useGetAllEnvironmentFeatureStatesQuery( + { environmentKey: rightEnvironmentKey }, + { skip: !rightEnvironmentKey }, + ) + + const changes = useMemo(() => { + if (!leftProjectFlags || !rightProjectFlags || !leftFlags || !rightFlags) { + return null } - }, [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 deriveChanges( + leftProjectFlags.results || [], + rightProjectFlags.results || [], + leftFlags.results || [], + rightFlags.results || [], + ) + }, [leftProjectFlags, rightProjectFlags, leftFlags, rightFlags]) + + const refresh = useCallback(() => { + refetchLeftProjectFlags() + refetchRightProjectFlags() + refetchLeftFlags() + refetchRightFlags() + }, [ + refetchLeftProjectFlags, + refetchRightProjectFlags, + refetchLeftFlags, + refetchRightFlags, + ]) + + const leftEnvironment = ProjectStore.getEnvironment( + leftEnvironmentKey, + ) as StoredEnvironment + const rightEnvironment = ProjectStore.getEnvironment( + rightEnvironmentKey, + ) as StoredEnvironment return { changes, - error, - isLoading, + error: + leftProjectFlagsError || + rightProjectFlagsError || + leftFlagsError || + rightFlagsError, + isLoading: + leftProjectFlagsFetching || + rightProjectFlagsFetching || + leftFlagsFetching || + rightFlagsFetching, leftEnvironment, - leftEnvironmentId: ProjectStore.getEnvironmentIdFromKey(leftEnvironmentKey), + leftEnvironmentId, refresh, rightEnvironment, - rightEnvironmentId: - ProjectStore.getEnvironmentIdFromKey(rightEnvironmentKey), + rightEnvironmentId, } } From 7fbd8b30b740ff6baf0ecedcb65e76921bdf87fb Mon Sep 17 00:00:00 2001 From: kyle-ssg Date: Wed, 15 Jul 2026 11:32:14 +0100 Subject: [PATCH 6/7] feat(compare-environments): improve row interaction, alignment and loading UX - Remove Edit buttons; clicking the switch or value chip opens the edit modal (keyboard accessible via Enter/Space on the cell) - Share column width constants between header and rows, and clamp overflowing value chips so wide values no longer break alignment - Keep the table visible and dimmed during refreshes instead of replacing it with a loader - Refetch when the edit modal saves (FeatureListStore events) rather than on every modal close Co-Authored-By: Claude Fable 5 --- .../CompareEnvironments.tsx | 35 ++++-- .../CompareEnvironments/CompareFeatureRow.tsx | 115 +++++++++++------- .../CompareEnvironments/constants.ts | 5 + 3 files changed, 101 insertions(+), 54 deletions(-) create mode 100644 frontend/web/components/CompareEnvironments/constants.ts diff --git a/frontend/web/components/CompareEnvironments/CompareEnvironments.tsx b/frontend/web/components/CompareEnvironments/CompareEnvironments.tsx index 504ed3def0f2..20df8ce46f97 100644 --- a/frontend/web/components/CompareEnvironments/CompareEnvironments.tsx +++ b/frontend/web/components/CompareEnvironments/CompareEnvironments.tsx @@ -6,6 +6,7 @@ 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 FeatureListStore from 'common/stores/feature-list-store' import { FeaturesTableFilters } from 'components/pages/features/components' import Utils from 'common/utils/utils' import { TagStrategy } from 'common/types/responses' @@ -16,6 +17,7 @@ import { hasActiveFilters, } from 'common/utils/featureFilterParams' import CompareFeatureRow, { EditFeatureHandler } from './CompareFeatureRow' +import { ENV_COLUMN_WIDTH, SEGMENTS_COLUMN_WIDTH } from './constants' import { FeatureChange } from './types' import { useEnvironmentComparison } from './useEnvironmentComparison' @@ -129,6 +131,17 @@ const CompareEnvironments: FC = ({ setExpandedRows(new Set()) }, [changes]) + // The edit modal saves through the legacy Flux store, which RTK Query + // can't observe — refetch when it reports a change + useEffect(() => { + FeatureListStore.on('saved', refresh) + FeatureListStore.on('removed', refresh) + return () => { + FeatureListStore.off('saved', refresh) + FeatureListStore.off('removed', refresh) + } + }, [refresh]) + const editFeature: EditFeatureHandler = useCallback( (projectFlag, environmentFlag, environmentKey, environmentName) => { openModal( @@ -157,12 +170,9 @@ const CompareEnvironments: FC = ({ history={history} />, 'side-modal create-feature-modal', - () => { - refresh() - }, ) }, - [history, projectIdNum, refresh], + [history, projectIdNum], ) const filteredItems = useMemo( @@ -221,7 +231,9 @@ const CompareEnvironments: FC = ({ ) const renderResults = () => { - if (isLoading) { + // Only show the full loader when there is nothing to display yet; + // refreshes keep the table visible and dim it instead + if (isLoading && !changes) { return (
@@ -241,7 +253,7 @@ const CompareEnvironments: FC = ({ } return ( - <> +
= ({
)} - +
) } @@ -281,13 +293,16 @@ const CompareEnvironments: FC = ({ {label} {count}
-
+
{leftEnvironment?.name}
-
+
{rightEnvironment?.name}
-
+
Segment changes
diff --git a/frontend/web/components/CompareEnvironments/CompareFeatureRow.tsx b/frontend/web/components/CompareEnvironments/CompareFeatureRow.tsx index 0741e32636ab..1b91b5757a88 100644 --- a/frontend/web/components/CompareEnvironments/CompareFeatureRow.tsx +++ b/frontend/web/components/CompareEnvironments/CompareFeatureRow.tsx @@ -5,8 +5,8 @@ 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 { ENV_COLUMN_WIDTH, SEGMENTS_COLUMN_WIDTH } from './constants' import { FeatureChange } from './types' export type EditFeatureHandler = ( @@ -31,6 +31,40 @@ type CompareFeatureRowProps = { rightEnvironmentName?: string } +type EnvironmentStateCellProps = { + enabled: boolean + value: FeatureChange['leftValue'] + onEdit: () => void +} + +// The switch and value are read-only representations — clicking either opens +// the edit modal rather than toggling in place +const EnvironmentStateCell: FC = ({ + enabled, + onEdit, + value, +}) => ( +
{ + e.stopPropagation() + onEdit() + }} + onKeyDown={(e: React.KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + e.stopPropagation() + onEdit() + } + }} + > + + +
+) + const CompareFeatureRow: FC = ({ index, isExpanded, @@ -52,6 +86,24 @@ const CompareFeatureRow: FC = ({ ) const toggle = () => onToggle(featureId) + const editLeft = () => + onEdit( + item.projectFlagLeft, + item.leftEnvironmentFlag, + leftEnvironmentKey, + leftEnvironmentName, + ) + + const editRight = () => + onEdit( + // Fall back to the left project flag so the modal edits the + // existing feature instead of switching to create mode + item.projectFlagRight || item.projectFlagLeft, + item.rightEnvironmentFlag, + rightEnvironmentKey, + rightEnvironmentName, + ) + return (
= ({ aria-expanded={isExpanded} onClick={toggle} onKeyDown={(e: React.KeyboardEvent) => { + // Ignore keydowns bubbling from the environment state cells, + // otherwise activating them via keyboard would also toggle the row + if (e.target !== e.currentTarget) return if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() toggle() @@ -78,58 +133,30 @@ const CompareFeatureRow: FC = ({
- - -
- +
- - -
- +
{totalSegments > 0 && ( diff --git a/frontend/web/components/CompareEnvironments/constants.ts b/frontend/web/components/CompareEnvironments/constants.ts new file mode 100644 index 000000000000..4695f12ce398 --- /dev/null +++ b/frontend/web/components/CompareEnvironments/constants.ts @@ -0,0 +1,5 @@ +// Shared column widths so the header and rows stay aligned. The environment +// column must fit a Switch, the widest possible FeatureValue chip (20 +// truncated characters plus quotes) and an Edit button. +export const ENV_COLUMN_WIDTH = 320 +export const SEGMENTS_COLUMN_WIDTH = 140 From 82764e1a4ec102758243ce33ab5a3949ca759e23 Mon Sep 17 00:00:00 2001 From: Kyle Date: Wed, 15 Jul 2026 14:04:19 +0100 Subject: [PATCH 7/7] Update frontend/common/utils/recursivePageGet.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- frontend/common/utils/recursivePageGet.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/frontend/common/utils/recursivePageGet.ts b/frontend/common/utils/recursivePageGet.ts index 2ae3b1c4ab70..f1b0ef2f34aa 100644 --- a/frontend/common/utils/recursivePageGet.ts +++ b/frontend/common/utils/recursivePageGet.ts @@ -6,8 +6,10 @@ import { PagedResponse } from 'common/types/responses' export function recursivePageGet( url: string, parentRes: null | PagedResponse, - baseQuery: (arg: unknown) => any, // matches rtk types, -): Promise<{ data: PagedResponse } | { error: any }> { + baseQuery: ( + arg: unknown, + ) => MaybePromise, unknown>>, +): Promise, unknown>> { return baseQuery({ method: 'GET', url,