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 40e93ad4c780..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 - -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..f1b0ef2f34aa --- /dev/null +++ b/frontend/common/utils/recursivePageGet.ts @@ -0,0 +1,28 @@ +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, + ) => MaybePromise, unknown>>, +): Promise, unknown>> { + 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.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/CompareEnvironments.tsx b/frontend/web/components/CompareEnvironments/CompareEnvironments.tsx new file mode 100644 index 000000000000..20df8ce46f97 --- /dev/null +++ b/frontend/web/components/CompareEnvironments/CompareEnvironments.tsx @@ -0,0 +1,349 @@ +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 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' +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 { ENV_COLUMN_WIDTH, SEGMENTS_COLUMN_WIDTH } from './constants' +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]) + + // 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( + + + Edit Feature: {projectFlag.name} + {environmentName && ( + ({environmentName}) + )} + + + , + , + 'side-modal create-feature-modal', + ) + }, + [history, projectIdNum], + ) + + 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 = () => { + // 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 ( +
+ +
+ ) + } + + 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..1b91b5757a88 --- /dev/null +++ b/frontend/web/components/CompareEnvironments/CompareFeatureRow.tsx @@ -0,0 +1,184 @@ +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 ExpandedRow from './ExpandedRow' +import { ENV_COLUMN_WIDTH, SEGMENTS_COLUMN_WIDTH } from './constants' +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 +} + +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, + 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) + + 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 ( +
+
{ + // 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() + } + }} + > +
+ + +
+ +
+ +
+ +
+ +
+ +
+ {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..694aa5d082cb --- /dev/null +++ b/frontend/web/components/CompareEnvironments/ExpandedRow.tsx @@ -0,0 +1,79 @@ +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, + isError: leftError, + isLoading: leftLoading, + } = useGetFeatureStatesQuery({ + environment: environmentLeftId, + feature: item.projectFlagLeft.id, + }) + + const { + data: rightStates, + isError: rightError, + isLoading: rightLoading, + } = useGetFeatureStatesQuery({ + environment: environmentRightId, + feature: item.projectFlagLeft.id, + }) + + if (leftLoading || rightLoading) { + return ( +
+ +
+ ) + } + + if (leftError || rightError) { + return ( +
+ Could not load the comparison for this feature. +
+ ) + } + + return ( +
+ +
+ ) +} + +export default ExpandedRow 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 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..8bfa030ed170 --- /dev/null +++ b/frontend/web/components/CompareEnvironments/useEnvironmentComparison.ts @@ -0,0 +1,167 @@ +import { useCallback, useMemo } from 'react' +import sortBy from 'lodash/sortBy' +import ProjectStore from 'common/stores/project-store' +import { hasMultivariateChange } from 'common/utils/compareMultivariate' +import { useGetProjectFlagsQuery } from 'common/services/useProjectFlag' +import { useGetAllEnvironmentFeatureStatesQuery } from 'common/services/useFeatureState' +import { Environment, FeatureState, ProjectFlag } from 'common/types/responses' +import { FeatureChange } from './types' + +type UseEnvironmentComparisonArgs = { + projectId: string + leftEnvironmentKey: string + rightEnvironmentKey: string +} + +// ProjectStore.getEnvironment returns null while the project is loading and +// undefined for unknown keys +type StoredEnvironment = Environment | undefined | null + +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 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 + } + 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: + leftProjectFlagsError || + rightProjectFlagsError || + leftFlagsError || + rightFlagsError, + isLoading: + leftProjectFlagsFetching || + rightProjectFlagsFetching || + leftFlagsFetching || + rightFlagsFetching, + leftEnvironment, + leftEnvironmentId, + refresh, + rightEnvironment, + rightEnvironmentId, + } +} 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} + + )} +
+ )}