diff --git a/frontend/common/hooks/useFeatureListWithApiKey.ts b/frontend/common/hooks/useFeatureListWithApiKey.ts new file mode 100644 index 000000000000..e28b1176a5e3 --- /dev/null +++ b/frontend/common/hooks/useFeatureListWithApiKey.ts @@ -0,0 +1,39 @@ +import { useMemo } from 'react' +import { skipToken } from '@reduxjs/toolkit/query' +import { useGetFeatureListQuery } from 'common/services/useProjectFlag' +import { useProjectEnvironments } from './useProjectEnvironments' +import { buildApiFilterParams } from 'common/utils/featureFilterParams' +import type { FilterState } from 'common/types/featureFilters' + +/** + * Fetches filtered feature list, accepting environment API key instead of numeric ID. + * + * TODO: This wrapper will be removed once we standardize environmentId and environmentApiKey on RouteContext. + */ +export function useFeatureListWithApiKey( + filters: FilterState, + page: number, + environmentApiKey: string | undefined, + projectId: number | undefined, +): ReturnType { + const { getEnvironmentIdFromKey, isLoading: isLoadingEnvironments } = + useProjectEnvironments(projectId!) + + const apiParams = useMemo(() => { + if (!environmentApiKey || !projectId || !getEnvironmentIdFromKey) { + return null + } + return buildApiFilterParams( + filters, + page, + environmentApiKey, + projectId, + getEnvironmentIdFromKey, + ) + }, [filters, page, environmentApiKey, projectId, getEnvironmentIdFromKey]) + + return useGetFeatureListQuery(apiParams ?? skipToken, { + refetchOnMountOrArgChange: true, + skip: isLoadingEnvironments, + }) +} diff --git a/frontend/common/hooks/usePageTracking.ts b/frontend/common/hooks/usePageTracking.ts new file mode 100644 index 000000000000..bec4cd93dd89 --- /dev/null +++ b/frontend/common/hooks/usePageTracking.ts @@ -0,0 +1,84 @@ +import { useEffect } from 'react' + +/** + * Options for configuring page tracking behavior. + */ +export type PageTrackingOptions = { + /** The page constant name from Constants.pages */ + pageName: string + /** Context data for tracking and storage persistence */ + context?: { + environmentId?: string + projectId?: number + organisationId?: number + } + /** Whether to save context to AsyncStorage (default: false) */ + saveToStorage?: boolean + /** Custom dependencies for re-tracking on changes */ + deps?: React.DependencyList +} + +/** + * Unified hook for tracking page views with optional context persistence. + * + * Consolidates both page tracking and environment context storage into a single, + * flexible hook. Automatically calls API.trackPage and optionally persists + * environment context to AsyncStorage. + * + * @param options - Configuration object for page tracking + * + * @example + * ```tsx + * // Basic page tracking only + * usePageTracking({ pageName: Constants.pages.FEATURES }) + * + * // With context and storage persistence + * usePageTracking({ + * pageName: Constants.pages.FEATURES, + * context: { environmentId, projectId, organisationId }, + * saveToStorage: true, + * }) + * + * // With custom dependencies + * usePageTracking({ + * pageName: Constants.pages.FEATURES, + * context: { projectId }, + * deps: [projectId, someOtherDep], + * }) + * ``` + */ +export function usePageTracking(options: PageTrackingOptions): void { + const { context, deps = [], pageName, saveToStorage = false } = options + + // Track page view + useEffect(() => { + if (typeof API !== 'undefined' && API.trackPage) { + API.trackPage(pageName) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, deps) + + // Persist environment context to storage if enabled + useEffect(() => { + if (saveToStorage && context) { + if (typeof AsyncStorage !== 'undefined' && AsyncStorage.setItem) { + AsyncStorage.setItem( + 'lastEnv', + JSON.stringify({ + environmentId: context.environmentId, + orgId: context.organisationId, + projectId: context.projectId, + }), + ) + } + } + // We intentionally use individual properties instead of context object + // to prevent re-runs when object reference changes but values don't + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + saveToStorage, + context?.environmentId, + context?.organisationId, + context?.projectId, + ]) +} diff --git a/frontend/common/hooks/useProjectEnvironments.ts b/frontend/common/hooks/useProjectEnvironments.ts new file mode 100644 index 000000000000..f97a9ac953d0 --- /dev/null +++ b/frontend/common/hooks/useProjectEnvironments.ts @@ -0,0 +1,58 @@ +import { useCallback, useMemo } from 'react' +import { useGetEnvironmentsQuery } from 'common/services/useEnvironment' +import { useGetProjectQuery } from 'common/services/useProject' +import type { Environment, Project } from 'common/types/responses' + +interface UseProjectEnvironmentsResult { + project: Project | undefined + environments: Environment[] + getEnvironmentIdFromKey: (apiKey: string) => number | undefined + getEnvironment: (apiKey: string) => Environment | undefined + isLoading: boolean + error: Error | undefined +} + +/** Fetches project and environment data with accessor functions for API key lookups. */ +export function useProjectEnvironments( + projectId: number, +): UseProjectEnvironmentsResult { + const { + data: project, + error: projectError, + isLoading: isLoadingProject, + } = useGetProjectQuery({ id: projectId }, { skip: !projectId }) + + const { + data: environmentsData, + error: environmentsError, + isLoading: isLoadingEnvironments, + } = useGetEnvironmentsQuery({ projectId }, { skip: !projectId }) + + const environments = useMemo( + () => environmentsData?.results ?? [], + [environmentsData?.results], + ) + + const getEnvironmentIdFromKey = useCallback( + (apiKey: string): number | undefined => { + return environments.find((env) => env.api_key === apiKey)?.id + }, + [environments], + ) + + const getEnvironment = useCallback( + (apiKey: string): Environment | undefined => { + return environments.find((env) => env.api_key === apiKey) + }, + [environments], + ) + + return { + environments, + error: projectError || environmentsError, + getEnvironment, + getEnvironmentIdFromKey, + isLoading: isLoadingProject || isLoadingEnvironments, + project, + } +} diff --git a/frontend/common/providers/Permission.tsx b/frontend/common/providers/Permission.tsx index e5f83db8b9ab..dbcec762209c 100644 --- a/frontend/common/providers/Permission.tsx +++ b/frontend/common/providers/Permission.tsx @@ -4,15 +4,51 @@ import { PermissionLevel } from 'common/types/requests' import AccountStore from 'common/stores/account-store' import intersection from 'lodash/intersection' import { cloneDeep } from 'lodash' +import Utils from 'common/utils/utils' +import Constants from 'common/constants' +/** + * Props for the Permission component + * + * @property {number | string} id - The ID of the resource (projectId, organisationId, environmentId, etc.) + * @property {string} permission - The permission key to check (e.g., 'CREATE_FEATURE', 'UPDATE_FEATURE') + * @property {PermissionLevel} level - The permission level ('project', 'organisation', 'environment') + * @property {number[]} [tags] - Optional tag IDs for tag-based permission checking + * @property {ReactNode | ((data: { permission: boolean; isLoading: boolean }) => ReactNode)} children - Content to render or render function + * @property {ReactNode} [fallback] - Optional content to render when permission is denied + * @property {string} [permissionName] - Optional custom permission name for tooltip display + * @property {boolean} [showTooltip=false] - Whether to show a tooltip when permission is denied + */ type PermissionType = { - id: any + id: number | string permission: string tags?: number[] level: PermissionLevel - children: (data: { permission: boolean; isLoading: boolean }) => ReactNode + children: + | ReactNode + | ((data: { permission: boolean; isLoading: boolean }) => ReactNode) + fallback?: ReactNode + permissionName?: string + showTooltip?: boolean } +/** + * Hook to check if the current user has a specific permission + * + * Fetches permission data and checks if the user has the requested permission. + * Supports tag-based permissions where additional permissions can be granted + * based on tag intersection. + * + * @param {Object} params - The permission check parameters + * @param {number | string} params.id - The resource ID to check permissions for + * @param {PermissionLevel} params.level - The permission level to check at + * @param {string} params.permission - The permission key to check + * @param {number[]} [params.tags] - Optional tag IDs for tag-based permission checking + * @returns {Object} Object containing permission status and loading state + * @returns {boolean} returns.isLoading - Whether the permission data is still loading + * @returns {boolean} returns.isSuccess - Whether the permission data was fetched successfully + * @returns {boolean} returns.permission - Whether the user has the requested permission + */ export const useHasPermission = ({ id, level, @@ -45,11 +81,68 @@ export const useHasPermission = ({ } } +/** + * Permission component for conditional rendering based on user permissions + * + * This component checks if the current user has a specific permission and conditionally + * renders its children. It supports multiple rendering patterns: + * + * @example + * // Basic usage with simple children + * + * + * + * + * @example + * // Using render function to access permission state + * + * {({ permission, isLoading }) => ( + * + * )} + * + * + * @example + * // With tooltip on permission denial + * + * + * + * + * @example + * // With fallback content + * You don't have permission to delete features} + * > + * + * + * + * @example + * // With tag-based permissions + * + * + * + */ const Permission: FC = ({ children, + fallback, id, level, permission, + permissionName, + showTooltip = false, tags, }) => { const { isLoading, permission: hasPermission } = useHasPermission({ @@ -58,14 +151,39 @@ const Permission: FC = ({ permission, tags, }) - return ( - <> - {children({ - isLoading, - permission: hasPermission || AccountStore.isAdmin(), - }) || null} - - ) + + const finalPermission = hasPermission || AccountStore.isAdmin() + + if (typeof children === 'function') { + const renderedChildren = children({ + isLoading, + permission: finalPermission, + }) + + if (finalPermission || !showTooltip) { + return <>{renderedChildren || null} + } + + return Utils.renderWithPermission( + finalPermission, + permissionName || Constants.projectPermissions(permission), + renderedChildren, + ) + } + + if (finalPermission) { + return <>{children} + } + + if (showTooltip) { + return Utils.renderWithPermission( + finalPermission, + permissionName || Constants.projectPermissions(permission), + children, + ) + } + + return <>{fallback || null} } export default Permission diff --git a/frontend/common/services/useFeatureState.ts b/frontend/common/services/useFeatureState.ts index 1b52753a373f..e9f5d9bf6adb 100644 --- a/frontend/common/services/useFeatureState.ts +++ b/frontend/common/services/useFeatureState.ts @@ -17,7 +17,9 @@ export const addFeatureSegmentsToFeatureStates = async (v) => { } } export const featureStateService = service - .enhanceEndpoints({ addTagTypes: ['FeatureState'] }) + .enhanceEndpoints({ + addTagTypes: ['FeatureState', 'FeatureList', 'Environment'], + }) .injectEndpoints({ endpoints: (builder) => ({ getFeatureStates: builder.query< @@ -26,7 +28,6 @@ export const featureStateService = service >({ providesTags: [{ id: 'LIST', type: 'FeatureState' }], queryFn: async (query, baseQueryApi, extraOptions, baseQuery) => { - //This endpoint returns feature_segments as a number, so it fetches the feature segments and appends const { data, }: { @@ -49,7 +50,21 @@ export const featureStateService = service } }, }), - // END OF ENDPOINTS + updateFeatureState: builder.mutation< + Res['featureState'], + Req['updateFeatureState'] + >({ + invalidatesTags: (_res, _meta, _req) => [ + { id: 'LIST', type: 'FeatureList' }, + { id: 'LIST', type: 'FeatureState' }, + { id: 'METRICS', type: 'Environment' }, + ], + query: (query: Req['updateFeatureState']) => ({ + body: query.body, + method: 'PUT', + url: `environments/${query.environmentId}/featurestates/${query.environmentFlagId}/`, + }), + }), }), }) @@ -64,15 +79,18 @@ export async function getFeatureStates( featureStateService.endpoints.getFeatureStates.initiate(data, options), ) } -// END OF FUNCTION_EXPORTS -export const { - useGetFeatureStatesQuery, - // END OF EXPORTS -} = featureStateService +export async function updateFeatureState( + store: any, + data: Req['updateFeatureState'], + options?: Parameters< + typeof featureStateService.endpoints.updateFeatureState.initiate + >[1], +) { + return store.dispatch( + featureStateService.endpoints.updateFeatureState.initiate(data, options), + ) +} -/* Usage examples: -const { data, isLoading } = useGetFeatureStatesQuery({ id: 2 }, {}) //get hook -const [createFeatureStates, { isLoading, data, isSuccess }] = useCreateFeatureStatesMutation() //create hook -featureStateService.endpoints.getFeatureStates.select({id: 2})(store.getState()) //access data from any function -*/ +export const { useGetFeatureStatesQuery, useUpdateFeatureStateMutation } = + featureStateService diff --git a/frontend/common/services/useFeatureVersion.ts b/frontend/common/services/useFeatureVersion.ts index 61346ca45995..140236e95d79 100644 --- a/frontend/common/services/useFeatureVersion.ts +++ b/frontend/common/services/useFeatureVersion.ts @@ -56,9 +56,11 @@ export const getFeatureStateCrud = ( ) return !!diff?.totalChanges }) - const newValueFeatureState = featureStates.find((v) => !v.feature_segment)! + const newValueFeatureState = featureStates.find( + (v) => !v.feature_segment?.segment, + )! const oldValueFeatureState = oldFeatureStates.find( - (v) => !v.feature_segment, + (v) => !v.feature_segment?.segment, )! // return nothing if feature state isn't different const valueDiff = getFeatureStateDiff( @@ -103,15 +105,21 @@ export const getFeatureStateCrud = ( } export const featureVersionService = service - .enhanceEndpoints({ addTagTypes: ['FeatureVersion', 'Environment'] }) + .enhanceEndpoints({ + addTagTypes: ['FeatureVersion', 'Environment', 'FeatureList'], + }) .injectEndpoints({ endpoints: (builder) => ({ createAndSetFeatureVersion: builder.mutation< Res['featureVersion'], Req['createAndSetFeatureVersion'] >({ - invalidatesTags: [ + invalidatesTags: (_result, _error, arg) => [ { id: 'LIST', type: 'FeatureVersion' }, + { + id: `${arg.projectId}-${arg.environmentId}`, + type: 'FeatureList', + }, { id: 'METRICS', type: 'Environment' }, ], queryFn: async (query: Req['createAndSetFeatureVersion']) => { @@ -143,24 +151,29 @@ export const featureVersionService = service }) ).data.results - const { - feature_states_to_create, - feature_states_to_update, - segment_ids_to_delete_overrides, - } = getFeatureStateCrud( - query.featureStates.map((v) => ({ - ...v, - feature_state_value: Utils.valueToFeatureState( - v.feature_state_value, - ), - })), - oldFeatureStates.data.results.filter((v) => { + const newFeatureStates = query.featureStates.map((v) => ({ + ...v, + feature_state_value: Utils.valueToFeatureState( + v.feature_state_value, + ), + })) + const oldFeatureStatesFiltered = oldFeatureStates.data.results.filter( + (v) => { if (mode === 'VALUE') { return !v.feature_segment?.segment } else { return !!v.feature_segment?.segment } - }), + }, + ) + + const { + feature_states_to_create, + feature_states_to_update, + segment_ids_to_delete_overrides, + } = getFeatureStateCrud( + newFeatureStates, + oldFeatureStatesFiltered, segments, ) @@ -302,7 +315,6 @@ export async function createAndSetFeatureVersion( ), ) } - export async function getFeatureVersions( store: any, data: Req['getFeatureVersions'], diff --git a/frontend/common/services/useProjectFlag.ts b/frontend/common/services/useProjectFlag.ts index 4e802bb9209d..efc34282f70a 100644 --- a/frontend/common/services/useProjectFlag.ts +++ b/frontend/common/services/useProjectFlag.ts @@ -3,6 +3,11 @@ import { Req } from 'common/types/requests' import { service } from 'common/service' import Utils from 'common/utils/utils' +/** + * Number of features to display per page in the features list. + */ +export const FEATURES_PAGE_SIZE = 50 + function recursivePageGet( url: string, parentRes: null | PagedResponse, @@ -28,26 +33,82 @@ function recursivePageGet( }) } export const projectFlagService = service - .enhanceEndpoints({ addTagTypes: ['ProjectFlag'] }) + .enhanceEndpoints({ + addTagTypes: ['ProjectFlag', 'FeatureList', 'FeatureState', 'Environment'], + }) .injectEndpoints({ endpoints: (builder) => ({ createProjectFlag: builder.mutation< Res['projectFlag'], Req['createProjectFlag'] >({ - invalidatesTags: [{ id: 'LIST', type: 'ProjectFlag' }], + invalidatesTags: [ + { id: 'LIST', type: 'ProjectFlag' }, + { id: 'LIST', type: 'FeatureList' }, + ], query: (query: Req['createProjectFlag']) => ({ body: query.body, method: 'POST', url: `projects/${query.project_id}/features/`, }), }), + getFeatureList: builder.query({ + providesTags: (_res, _meta, req) => [ + { + id: `${req?.projectId}-${req?.environmentId}`, + type: 'FeatureList', + }, + { id: 'LIST', type: 'FeatureList' }, + ], + query: (query: Req['getFeatureList']) => { + const { environmentId, projectId, ...params } = query + return { + params: { + ...params, + environment: parseInt(environmentId), + page: params.page || 1, + page_size: params.page_size || FEATURES_PAGE_SIZE, + }, + url: `projects/${projectId}/features/`, + } + }, + transformResponse: ( + response: { + results: Res['featureList']['results'] + count: number + next: string | null + previous: string | null + }, + _, + arg, + ) => ({ + ...response, + environmentStates: response.results.reduce((acc, feature) => { + if (feature.environment_feature_state) { + acc[feature.id] = { + ...feature.environment_feature_state, + feature: feature.id, + } + } + return acc + }, {} as Res['featureList']['environmentStates']), + pagination: { + count: response.count, + currentPage: arg.page || 1, + next: response.next, + pageSize: arg.page_size || FEATURES_PAGE_SIZE, + previous: response.previous, + }, + }), + }), + getProjectFlag: builder.query({ providesTags: (res) => [{ id: res?.id, type: 'ProjectFlag' }], query: (query: Req['getProjectFlag']) => ({ url: `projects/${query.project}/features/${query.id}/`, }), }), + getProjectFlags: builder.query< Res['projectFlags'], Req['getProjectFlags'] @@ -71,6 +132,19 @@ export const projectFlagService = service ) }, }), + + removeProjectFlag: builder.mutation({ + invalidatesTags: [ + { id: 'LIST', type: 'ProjectFlag' }, + { id: 'LIST', type: 'FeatureList' }, + { id: 'METRICS', type: 'Environment' }, + ], + query: ({ flag_id, project_id }) => ({ + method: 'DELETE', + url: `projects/${project_id}/features/${flag_id}/`, + }), + }), + updateProjectFlag: builder.mutation< Res['projectFlag'], Req['updateProjectFlag'] @@ -85,7 +159,6 @@ export const projectFlagService = service url: `projects/${query.project_id}/features/${query.feature_id}/`, }), }), - // END OF ENDPOINTS }), }) @@ -133,18 +206,12 @@ export async function createProjectFlag( projectFlagService.endpoints.createProjectFlag.initiate(data, options), ) } -// END OF FUNCTION_EXPORTS export const { useCreateProjectFlagMutation, + useGetFeatureListQuery, useGetProjectFlagQuery, useGetProjectFlagsQuery, + useRemoveProjectFlagMutation, useUpdateProjectFlagMutation, - // END OF EXPORTS } = projectFlagService - -/* Usage examples: -const { data, isLoading } = useGetProjectFlagsQuery({ id: 2 }, {}) //get hook -const [createProjectFlags, { isLoading, data, isSuccess }] = useCreateProjectFlagsMutation() //create hook -projectFlagService.endpoints.getProjectFlags.select({id: 2})(store.getState()) //access data from any function -*/ diff --git a/frontend/common/store.ts b/frontend/common/store.ts index 53828873973f..7d953b45ef80 100644 --- a/frontend/common/store.ts +++ b/frontend/common/store.ts @@ -1,4 +1,5 @@ import { combineReducers, configureStore } from '@reduxjs/toolkit' +import { setupListeners } from '@reduxjs/toolkit/query' import { FLUSH, PAUSE, @@ -49,6 +50,7 @@ export const getStore = function (): StoreType { if (_store) return _store _store = createStore() _persistor = persistStore(_store) + setupListeners(_store.dispatch) return _store } diff --git a/frontend/common/stores/feature-list-store.ts b/frontend/common/stores/feature-list-store.ts index f43550c575bc..e36be5778a99 100644 --- a/frontend/common/stores/feature-list-store.ts +++ b/frontend/common/stores/feature-list-store.ts @@ -34,6 +34,7 @@ import { changeRequestService, updateChangeRequest, } from 'common/services/useChangeRequest' +import { FEATURES_PAGE_SIZE } from 'common/services/useProjectFlag' const Dispatcher = require('common/dispatcher/dispatcher') const BaseStore = require('./base/_store') @@ -41,7 +42,6 @@ const data = require('../data/base/_data') const { createSegmentOverride } = require('../services/useSegmentOverride') const { getStore } = require('../store') let createdFirstFeature = false -const PAGE_SIZE = 50 const convertSegmentOverrideToFeatureState = ( override, @@ -82,8 +82,11 @@ const controller = { if ( !createdFirstFeature && !flagsmith.getTrait('first_feature') && + AccountStore.model && AccountStore.model.organisations.length === 1 && + OrganisationStore.model && OrganisationStore.model.projects.length === 1 && + store.model && (!store.model.features || !store.model.features.length) ) { createdFirstFeature = true @@ -758,9 +761,11 @@ const controller = { const environmentFeatureState = res.data.find( (v) => !v.feature_segment, ) - store.model.keyedEnvironmentFeatures[projectFlag.id] = { - ...store.model.keyedEnvironmentFeatures[projectFlag.id], - ...environmentFeatureState, + if (store.model?.keyedEnvironmentFeatures) { + store.model.keyedEnvironmentFeatures[projectFlag.id] = { + ...store.model.keyedEnvironmentFeatures[projectFlag.id], + ...environmentFeatureState, + } } }) }) @@ -808,11 +813,13 @@ const controller = { throw version.error } const featureState = version.data.feature_states[0].data - store.model.keyedEnvironmentFeatures[projectFlag.id] = { - ...featureState, - feature_state_value: Utils.featureStateToValue( - featureState.feature_state_value, - ), + if (store.model?.keyedEnvironmentFeatures) { + store.model.keyedEnvironmentFeatures[projectFlag.id] = { + ...featureState, + feature_state_value: Utils.featureStateToValue( + featureState.feature_state_value, + ), + } } }) }) @@ -861,7 +868,7 @@ const controller = { : `${Project.api}projects/${projectId}/features/?page=${ page || 1 }&environment=${environment}&page_size=${ - pageSize || PAGE_SIZE + pageSize || FEATURES_PAGE_SIZE }${filterUrl}` if (store.search) { featuresEndpoint += `&search=${store.search}` @@ -890,7 +897,7 @@ const controller = { return } store.paging.next = features.next - store.paging.pageSize = PAGE_SIZE + store.paging.pageSize = FEATURES_PAGE_SIZE store.paging.count = features.count store.paging.previous = features.previous store.paging.currentPage = diff --git a/frontend/common/types/featureFilters.ts b/frontend/common/types/featureFilters.ts new file mode 100644 index 000000000000..80714f6cd3c7 --- /dev/null +++ b/frontend/common/types/featureFilters.ts @@ -0,0 +1,18 @@ +import type { TagStrategy } from './responses' +import type { SortValue } from 'components/tables/TableSortFilter' + +/** Raw URL parameters for feature list filtering. */ +export type UrlParams = Record + +/** Structured filter configuration for querying feature lists. */ +export type FilterState = { + search: string | null + tags: (number | string)[] + tag_strategy: TagStrategy + showArchived: boolean + is_enabled: boolean | null + value_search: string + owners: number[] + group_owners: number[] + sort: SortValue +} diff --git a/frontend/common/types/requests.ts b/frontend/common/types/requests.ts index 4b796469010a..7b1220ccc974 100644 --- a/frontend/common/types/requests.ts +++ b/frontend/common/types/requests.ts @@ -23,6 +23,7 @@ import { StageTrigger, StageActionType, StageActionBody, + ChangeRequest, TagStrategy, } from './responses' import { UtmsType } from './utms' @@ -45,6 +46,12 @@ export type UpdateOrganisationBody = { webhook_notification_email?: string | null } +export type UpdateFeatureStateBody = { + enabled?: boolean + feature_state_value?: FeatureStateValue + multivariate_feature_state_values?: MultivariateOption[] | null +} + export type PagedRequest = T & { page?: number page_size?: number @@ -482,14 +489,15 @@ export type Req = { deleteGroupWithRole: { org_id: number; group_id: number; role_id: number } createAndSetFeatureVersion: { projectId: number - environmentId: string + environmentId: number // Numeric ID for getFeatureStates query + environmentApiKey?: string // API key for URL endpoints (optional for legacy store) featureId: number skipPublish?: boolean featureStates: FeatureState[] liveFrom?: string } createFeatureVersion: { - environmentId: string + environmentId: number // Numeric ID for URL featureId: number live_from?: string feature_states_to_create: Omit[] @@ -510,7 +518,7 @@ export type Req = { } getVersionFeatureState: { sha: string - environmentId: string + environmentId: number featureId: number } updateSegmentPriorities: { id: number; priority: number }[] @@ -636,6 +644,10 @@ export type Req = { project_id: number body: ProjectFlag } + removeProjectFlag: { + project_id: number + flag_id: number + } updateEnvironment: { id: number; body: Environment } createCloneIdentityFeatureStates: { environment_id: string @@ -835,5 +847,26 @@ export type Req = { period: number environment_id: string } + getFeatureList: { + projectId: number + environmentId: string + page?: number + page_size?: number + search?: string | null + tags?: string + is_archived?: boolean + is_enabled?: boolean | null + owners?: string + group_owners?: string + value_search?: string + tag_strategy?: TagStrategy + sort_field?: string + sort_direction?: 'ASC' | 'DESC' + } + updateFeatureState: { + environmentId: string + environmentFlagId: number + body: UpdateFeatureStateBody + } // END OF TYPES } diff --git a/frontend/common/types/responses.ts b/frontend/common/types/responses.ts index 5db1b166226f..98c114653e6d 100644 --- a/frontend/common/types/responses.ts +++ b/frontend/common/types/responses.ts @@ -451,7 +451,11 @@ export type MultivariateOption = { } export type FeatureType = 'STANDARD' | 'MULTIVARIATE' -export type TagStrategy = 'INTERSECTION' | 'UNION' + +export enum TagStrategy { + INTERSECTION = 'INTERSECTION', + UNION = 'UNION', +} export type IdentityFeatureState = { feature: { @@ -530,6 +534,7 @@ export type ProjectFlag = { last_successful_repository_scanned_at: string last_feature_found_at: string }[] + environment_feature_state?: FeatureState } export type FeatureListProviderData = { @@ -541,12 +546,12 @@ export type FeatureListProviderData = { export type FeatureListProviderActions = { toggleFlag: ( - projectId: string, + projectId: number, environmentId: string, projectFlag: ProjectFlag, environmentFlags: FeatureState | undefined, ) => void - removeFlag: (projectId: string, projectFlag: ProjectFlag) => void + removeFlag: (projectId: number, projectFlag: ProjectFlag) => void } export type AuthType = 'EMAIL' | 'GITHUB' | 'GOOGLE' @@ -1126,5 +1131,20 @@ export type Res = { day: string count: number }[] + featureList: { + results: ProjectFlag[] + count: number + next: string | null + previous: string | null + environmentStates: Record + pagination: { + count: number + next: string | null + previous: string | null + currentPage: number + pageSize: number + } + } + featureState: FeatureState // END OF TYPES } diff --git a/frontend/common/useViewMode.ts b/frontend/common/useViewMode.ts index 2586020e56a2..3d50596cffbe 100644 --- a/frontend/common/useViewMode.ts +++ b/frontend/common/useViewMode.ts @@ -1,13 +1,35 @@ import flagsmith from 'flagsmith' +import { useState, useCallback } from 'react' export type ViewMode = 'compact' | 'default' -export function getViewMode() { + +export function getViewMode(): ViewMode { const viewMode = flagsmith.getTrait('view_mode') if (viewMode === 'compact') { - return 'compact' as ViewMode + return 'compact' } - return 'default' as ViewMode + return 'default' } + export function setViewMode(viewMode: ViewMode) { return flagsmith.setTrait('view_mode', viewMode) } + +/** + * Hook for managing view mode with optimistic UI updates. + * Updates state immediately for instant feedback, then persists to Flagsmith in background. + */ +export function useViewMode() { + const [viewMode, setViewModeState] = useState(getViewMode) + + const updateViewMode = useCallback((value: ViewMode) => { + setViewModeState(value) // Optimistic update - instant UI change + setViewMode(value) // Persist to Flagsmith trait in background + }, []) + + return { + isCompact: viewMode === 'compact', + setViewMode: updateViewMode, + viewMode, + } +} diff --git a/frontend/common/utils/featureFilterParams.ts b/frontend/common/utils/featureFilterParams.ts new file mode 100644 index 000000000000..81a980194fc0 --- /dev/null +++ b/frontend/common/utils/featureFilterParams.ts @@ -0,0 +1,174 @@ +import { FEATURES_PAGE_SIZE } from 'common/services/useProjectFlag' +import Format from './format' +import type { FilterState, UrlParams } from 'common/types/featureFilters' +import { SortOrder } from 'common/types/requests' +import { TagStrategy } from 'common/types/responses' + +/** + * Function type for resolving environment API keys to numeric IDs. + * Used to bridge routing layer (API keys) with API layer (numeric IDs). + */ +export type EnvironmentIdResolver = (apiKey: string) => number | undefined + +/** Converts array to comma-separated string, or undefined if empty */ +function joinArrayOrUndefined( + arr: (string | number)[] | undefined, +): string | undefined { + if (!arr || arr.length === 0) return undefined + return arr.join(',') +} + +/** Parses comma-separated string into number array */ +function parseIntArray(value: string | string[] | undefined): number[] { + if (!value || typeof value !== 'string') return [] + return value + .split(',') + .filter((v) => v) + .map((v) => parseInt(v, 10)) +} + +/** Normalizes UI sort order to API format */ +function normalizeSortDirection(sortOrder: SortOrder | null): SortOrder { + return sortOrder === SortOrder.ASC ? SortOrder.ASC : SortOrder.DESC +} + +/** Parses boolean from URL param */ +function parseBooleanParam( + value: string | string[] | undefined, +): boolean | null { + if (value === 'true') return true + if (value === 'false') return false + return null +} + +/** Parses sort order from URL param, defaulting to ASC */ +function parseSortOrder(value: string | string[] | undefined): SortOrder { + if (typeof value === 'string' && value.toLowerCase() === 'desc') { + return SortOrder.DESC + } + return SortOrder.ASC +} + +/** Parses string from URL param, returns default if invalid */ +function parseStringParam( + value: string | string[] | undefined, + defaultValue: string, +): string { + if (typeof value === 'string') return value + return defaultValue +} + +/** Parses page number from URL param, defaulting to 1 */ +function parsePageNumber(value: string | string[] | undefined): number { + if (typeof value === 'string') { + const parsed = parseInt(value, 10) + return isNaN(parsed) ? 1 : parsed + } + return 1 +} + +/** Check if any filters are currently active. */ +export function hasActiveFilters(filters: FilterState): boolean { + return !!( + filters.tags?.length || + filters.showArchived || + filters.search?.length || + filters.is_enabled !== null || + filters.value_search?.length || + filters.owners?.length || + filters.group_owners?.length + ) +} + +/** Converts filter state to URL query parameters. */ +export function buildUrlParams( + filters: FilterState, + page: number, +): Record { + return { + group_owners: joinArrayOrUndefined(filters.group_owners), + is_archived: filters.showArchived ? 'true' : 'false', + is_enabled: filters.is_enabled === null ? undefined : filters.is_enabled, + owners: joinArrayOrUndefined(filters.owners), + page: page ?? 1, + search: filters.search || undefined, + sortBy: filters.sort.sortBy, + sortOrder: filters.sort.sortOrder === SortOrder.DESC ? 'desc' : 'asc', + tag_strategy: filters.tag_strategy, + tags: joinArrayOrUndefined(filters.tags), + value_search: filters.value_search || undefined, + } +} + +/** + * Converts filter state to RTK Query API parameters. + * + * TODO: getEnvironmentIdFromKey callback is temporary + * Once RouteContext provides environmentID and environmentApiKey, this can accept the numeric environmentID directly. + */ +export function buildApiFilterParams( + filters: FilterState, + page: number, + environmentApiKey: string, + projectId: number, + getEnvironmentIdFromKey: EnvironmentIdResolver, +) { + const environmentId = getEnvironmentIdFromKey(environmentApiKey) + if (!environmentId) { + return null + } + + const groupOwners = joinArrayOrUndefined(filters.group_owners) + const owners = joinArrayOrUndefined(filters.owners) + const tags = joinArrayOrUndefined(filters.tags) + const sortDirection = normalizeSortDirection(filters.sort.sortOrder) + + const params: Record = { + environmentId: String(environmentId), + page, + page_size: FEATURES_PAGE_SIZE, + projectId, + sort_direction: sortDirection, + sort_field: filters.sort.sortBy, + tag_strategy: filters.tag_strategy, + } + + params.is_archived = filters.showArchived ? 'true' : 'false' + if (groupOwners) params.group_owners = groupOwners + if (filters.is_enabled !== null) params.is_enabled = filters.is_enabled + if (owners) params.owners = owners + if (filters.search) params.search = filters.search + if (tags) params.tags = tags + if (filters.value_search) params.value_search = filters.value_search + + return params +} + +/** Parses URL query parameters into FilterState with page number. */ +export function getFiltersFromParams( + params: UrlParams, +): FilterState & { page: number } { + const sortBy = parseStringParam(params.sortBy, 'name') + const sortOrder = parseSortOrder(params.sortOrder) + const search = parseStringParam(params.search, '') + + return { + group_owners: parseIntArray(params.group_owners), + is_enabled: parseBooleanParam(params.is_enabled), + owners: parseIntArray(params.owners), + page: parsePageNumber(params.page), + search: search || null, + showArchived: params.is_archived === 'true', + sort: { + label: Format.camelCase(sortBy), + sortBy, + sortOrder, + }, + tag_strategy: + params.tag_strategy === TagStrategy.UNION + ? TagStrategy.UNION + : TagStrategy.INTERSECTION, + tags: parseIntArray(params.tags), + value_search: parseStringParam(params.value_search, '') || null, + } +} diff --git a/frontend/e2e/helpers.cafe.ts b/frontend/e2e/helpers.cafe.ts index af2e39cb20ca..2631bb0f4632 100644 --- a/frontend/e2e/helpers.cafe.ts +++ b/frontend/e2e/helpers.cafe.ts @@ -1,9 +1,8 @@ import { RequestLogger, Selector, t } from 'testcafe' -import Project from '../common/project'; -import fetch from 'node-fetch'; -import flagsmith from 'flagsmith/isomorphic'; -import { IFlagsmith, FlagsmithValue } from 'flagsmith/types'; -import { delay } from 'lodash'; +import Project from '../common/project' +import fetch from 'node-fetch' +import flagsmith from 'flagsmith/isomorphic' +import { IFlagsmith, FlagsmithValue } from 'flagsmith/types' export const LONG_TIMEOUT = 40000 @@ -23,8 +22,12 @@ export const isElementExists = async (selector: string) => { return Selector(byId(selector)).exists } -const initProm = flagsmith.init({fetch,environmentID:Project.flagsmith,api:Project.flagsmithClientAPI}) -export const getFlagsmith = async function() { +const initProm = flagsmith.init({ + api: Project.flagsmithClientAPI, + environmentID: Project.flagsmith, + fetch, +}) +export const getFlagsmith = async function () { await initProm return flagsmith as IFlagsmith } @@ -67,13 +70,15 @@ export const waitForElementClickable = async (selector: string) => { } export const clickSegmentByName = async (name: string) => { - const el = Selector('[data-test^="segment-"][data-test$="-name"]').withText( - name, - ) - await t.scrollIntoView(el) - await t.expect(el.visible).ok(`segment "${name}" not visible`, { timeout: LONG_TIMEOUT }) - await t.click(el) - } + const el = Selector('[data-test^="segment-"][data-test$="-name"]').withText( + name, + ) + await t.scrollIntoView(el) + await t + .expect(el.visible) + .ok(`segment "${name}" not visible`, { timeout: LONG_TIMEOUT }) + await t.click(el) +} export const logResults = async (requests: LoggedRequest[], t) => { if (!t.testRun?.errs?.length) { @@ -155,6 +160,18 @@ export const getLogger = () => stringifyResponseBody: true, }) +export const checkApiRequest = ( + urlPattern: RegExp, + method: 'get' | 'post' | 'put' | 'patch' | 'delete', +) => + RequestLogger( + (req) => req.url.match(urlPattern) && req.method === method, + { + logRequestBody: true, + logRequestHeaders: true, + }, + ) + export const createRole = async ( roleName: string, index: number, @@ -332,6 +349,15 @@ export const assertTextContentContains = (selector: string, v: string) => t.expect(Selector(selector).textContent).contains(v) export const getText = (selector: string) => Selector(selector).innerText +export const parseTryItResults = async (): Promise> => { + const text = await getText('#try-it-results') + try { + return JSON.parse(text) + } catch (e) { + throw new Error('Try it results are not valid JSON') + } +} + export const cloneSegment = async (index: number, name: string) => { await click(byId(`segment-action-${index}`)) await click(byId(`segment-clone-${index}`)) @@ -340,16 +366,13 @@ export const cloneSegment = async (index: number, name: string) => { await waitForElementVisible(byId(`segment-${index + 1}-name`)) } -export const deleteSegmentFromPage = async (name:string) => { +export const deleteSegmentFromPage = async (name: string) => { await click(byId(`remove-segment-btn`)) await setText('[name="confirm-segment-name"]', name) await click('#confirm-remove-segment-btn') await waitForElementVisible(byId('show-create-segment-btn')) } -export const deleteSegment = async ( - index: number, - name: string, -) => { +export const deleteSegment = async (index: number, name: string) => { await click(byId(`segment-action-${index}`)) await click(byId(`segment-remove-${index}`)) await setText('[name="confirm-segment-name"]', name) @@ -467,7 +490,7 @@ export const createOrganisationAndProject = async ( export const editRemoteConfig = async ( index: number, value: string | number | boolean, - toggleFeature: boolean = false, + toggleFeature = false, mvs: MultiVariate[] = [], ) => { const expectedValue = typeof value === 'string' ? `"${value}"` : `${value}` @@ -525,10 +548,10 @@ export const deleteFeature = async (index: number, name: string) => { } export const toggleFeature = async (index: number, toValue: boolean) => { - await click(byId(`feature-switch-${index}${toValue ? '-off' : 'on'}`)) + await click(byId(`feature-switch-${index}-${toValue ? 'off' : 'on'}`)) await click('#confirm-toggle-feature-btn') await waitForElementVisible( - byId(`feature-switch-${index}${toValue ? '-on' : 'off'}`), + byId(`feature-switch-${index}-${toValue ? 'on' : 'off'}`), ) } @@ -609,23 +632,23 @@ export const refreshUntilElementVisible = async ( } const permissionsMap = { - 'CREATE_PROJECT': 'organisation', - 'MANAGE_USERS': 'organisation', - 'MANAGE_USER_GROUPS': 'organisation', - 'VIEW_PROJECT': 'project', + 'APPROVE_CHANGE_REQUEST': 'environment', + 'CREATE_CHANGE_REQUEST': 'environment', 'CREATE_ENVIRONMENT': 'project', - 'DELETE_FEATURE': 'project', 'CREATE_FEATURE': 'project', + 'CREATE_PROJECT': 'organisation', + 'DELETE_FEATURE': 'project', + 'MANAGE_IDENTITIES': 'environment', 'MANAGE_SEGMENTS': 'project', + 'MANAGE_SEGMENT_OVERRIDES': 'environment', + 'MANAGE_TAGS': 'project', + 'MANAGE_USERS': 'organisation', + 'MANAGE_USER_GROUPS': 'organisation', + 'UPDATE_FEATURE_STATE': 'environment', 'VIEW_AUDIT_LOG': 'project', 'VIEW_ENVIRONMENT': 'environment', - 'UPDATE_FEATURE_STATE': 'environment', - 'MANAGE_IDENTITIES': 'environment', - 'CREATE_CHANGE_REQUEST': 'environment', - 'APPROVE_CHANGE_REQUEST': 'environment', 'VIEW_IDENTITIES': 'environment', - 'MANAGE_SEGMENT_OVERRIDES': 'environment', - 'MANAGE_TAGS': 'project', + 'VIEW_PROJECT': 'project', } as const export const setUserPermission = async ( diff --git a/frontend/e2e/init.cafe.js b/frontend/e2e/init.cafe.js index 0f55378834f9..24bab3e9b131 100644 --- a/frontend/e2e/init.cafe.js +++ b/frontend/e2e/init.cafe.js @@ -14,14 +14,16 @@ import versioningTests from './tests/versioning-tests' import organisationPermissionTest from './tests/organisation-permission-test' import projectPermissionTest from './tests/project-permission-test' import environmentPermissionTest from './tests/environment-permission-test' -import flagsmith from 'flagsmith/isomorphic'; +import flagsmith from 'flagsmith/isomorphic' import rolesTest from './tests/roles-test' import organisationTest from './tests/organisation-test' require('dotenv').config() const url = `http://localhost:${process.env.PORT || 8080}/` -const e2eTestApi = `${process.env.FLAGSMITH_API_URL || Project.api}e2etests/teardown/` +const e2eTestApi = `${ + process.env.FLAGSMITH_API_URL || Project.api +}e2etests/teardown/` const logger = getLogger() console.log( @@ -32,26 +34,25 @@ console.log( '\n', ) - fixture`E2E Tests`.requestHooks(logger).before(async () => { const token = process.env.E2E_TEST_TOKEN ? process.env.E2E_TEST_TOKEN : process.env[`E2E_TEST_TOKEN_${Project.env.toUpperCase()}`] - await flagsmith.init({ - api:Project.flagsmithClientAPI, - environmentID:Project.flagsmith, - fetch, - }) + await flagsmith.init({ + api: Project.flagsmithClientAPI, + environmentID: Project.flagsmith, + fetch, + }) if (token) { await fetch(e2eTestApi, { - method: 'POST', + body: JSON.stringify({}), headers: { 'Accept': 'application/json', 'Content-Type': 'application/json', 'X-E2E-Test-Auth-Token': token.trim(), }, - body: JSON.stringify({}), + method: 'POST', }).then((res) => { if (res.ok) { // eslint-disable-next-line no-console @@ -97,7 +98,9 @@ fixture`E2E Tests`.requestHooks(logger).before(async () => { await logResults(logger.requests, t) }) -test('Segment-part-1', async () => await testSegment1(flagsmith)).meta({ category: 'oss' }) +test('Segment-part-1', async () => await testSegment1(flagsmith)).meta({ + category: 'oss', +}) test('Segment-part-2', testSegment2).meta({ autoLogout: true, category: 'oss' }) @@ -113,14 +116,26 @@ test('Environment', environmentTest).meta({ autoLogout: true, category: 'oss' }) test('Project', projectTest).meta({ autoLogout: true, category: 'oss' }) -test('Organization', organisationTest).meta({ autoLogout: true, category: 'oss' }) +test('Organization', organisationTest).meta({ + autoLogout: true, + category: 'oss', +}) test('Versioning', versioningTests).meta({ autoLogout: true, category: 'oss' }) -test('Organisation-permission', organisationPermissionTest).meta({ autoLogout: true, category: 'enterprise' }) +test('Organisation-permission', organisationPermissionTest).meta({ + autoLogout: true, + category: 'enterprise', +}) -test('Project-permission', projectPermissionTest).meta({ autoLogout: true, category: 'enterprise' }) +test('Project-permission', projectPermissionTest).meta({ + autoLogout: true, + category: 'enterprise', +}) -test('Environment-permission', environmentPermissionTest).meta({ autoLogout: true, category: 'enterprise' }) +test('Environment-permission', environmentPermissionTest).meta({ + autoLogout: true, + category: 'enterprise', +}) test('Roles', rolesTest).meta({ autoLogout: true, category: 'enterprise' }) diff --git a/frontend/e2e/tests/flag-tests.ts b/frontend/e2e/tests/flag-tests.ts index 754106aab30e..09c61eec818e 100644 --- a/frontend/e2e/tests/flag-tests.ts +++ b/frontend/e2e/tests/flag-tests.ts @@ -4,10 +4,11 @@ import { closeModal, createFeature, createRemoteConfig, - deleteFeature, editRemoteConfig, - getText, + deleteFeature, + editRemoteConfig, log, login, + parseTryItResults, toggleFeature, waitForElementVisible, } from '../helpers.cafe'; @@ -47,14 +48,8 @@ export default async function () { log('Try it') await t.wait(2000) await click('#try-it-btn') - await t.wait(1500) - let text = await getText('#try-it-results') - let json - try { - json = JSON.parse(text) - } catch (e) { - throw new Error('Try it results are not valid JSON') - } + await t.wait(500) + let json = await parseTryItResults() await t.expect(json.header_size.value).eql('big') await t.expect(json.mv_flag.value).eql('big') await t.expect(json.header_enabled.enabled).eql(true) @@ -63,30 +58,20 @@ export default async function () { await editRemoteConfig(1,12) log('Try it again') - await t.wait(2000) + await t.wait(500) await click('#try-it-btn') - await t.wait(1500) - text = await getText('#try-it-results') - try { - json = JSON.parse(text) - } catch (e) { - throw new Error('Try it results are not valid JSON') - } + await t.wait(500) + json = await parseTryItResults() await t.expect(json.header_size.value).eql(12) log('Change feature value to boolean') await editRemoteConfig(1,false) log('Try it again 2') - await t.wait(2000) + await t.wait(500) await click('#try-it-btn') - await t.wait(1500) - text = await getText('#try-it-results') - try { - json = JSON.parse(text) - } catch (e) { - throw new Error('Try it results are not valid JSON') - } + await t.wait(500) + json = await parseTryItResults() await t.expect(json.header_size.value).eql(false) log('Switch environment') diff --git a/frontend/e2e/tests/versioning-tests.ts b/frontend/e2e/tests/versioning-tests.ts index 2f2d8c05fe4c..03ce7f8cf14f 100644 --- a/frontend/e2e/tests/versioning-tests.ts +++ b/frontend/e2e/tests/versioning-tests.ts @@ -1,18 +1,27 @@ import { assertNumberOfVersions, byId, + checkApiRequest, click, compareVersion, createFeature, createOrganisationAndProject, createRemoteConfig, - editRemoteConfig, getFlagsmith, + editRemoteConfig, + getFlagsmith, log, login, + parseTryItResults, + toggleFeature, waitForElementVisible, } from '../helpers.cafe'; +import { t } from 'testcafe'; import { E2E_USER, PASSWORD } from '../config'; +// Request logger to verify versioned toggle uses the versions API endpoint +// Versioned: POST /environments/{envId}/features/{featureId}/versions/ +const versionApiLogger = checkApiRequest(/\/features\/\d+\/versions\/$/, 'post') + export default async () => { const flagsmith = await getFlagsmith() const hasFeature = flagsmith.hasFeature("feature_versioning") @@ -51,11 +60,79 @@ export default async () => { log('Edit feature 3') await editRemoteConfig(2,'',true) - log('Edit feature 3') + log('Assert version counts') await assertNumberOfVersions(0, 2) await assertNumberOfVersions(1, 2) await assertNumberOfVersions(2, 2) await compareVersion(0,0,null,true,true, 'small','medium') await compareVersion(1,0,null,true,true, 'small','small') await compareVersion(2,0,null,false,true, null,null) + + // =================================================================================== + // Test: Row toggle in versioned environment + // This tests that toggling a feature via the row switch works when Feature Versioning + // is enabled. The toggle must use the versioning API instead of the regular PUT. + // We reuse the existing versioned environment from the tests above. + // Note: Feature 'c' is currently ON after editRemoteConfig(2,'',true) above. + // =================================================================================== + log('Test row toggle in versioned environment') + + // Clear any previous requests from the logger + await t.addRequestHooks(versionApiLogger) + versionApiLogger.clear() + + // Feature 'c' (index 2) is currently ON - toggle it OFF + log('Toggle feature OFF via row switch (versioned env)') + await toggleFeature(2, false) + + // Verify: Versioned API endpoint was called (POST /features/{id}/versions/) + log('Verify versioned API endpoint was called') + await t.expect(versionApiLogger.requests.length).gte(1, 'Expected versioned API to be called') + + // Verify: Switch shows OFF state on features list + await waitForElementVisible(byId('feature-switch-2-off')) + + // Verify: API returns correct state (feature disabled) + log('Verify API returns disabled state') + await t.wait(500) + await click('#try-it-btn') + await t.wait(500) + let json = await parseTryItResults() + await t.expect(json.c.enabled).eql(false) + + // Refresh page to verify state was persisted to backend + log('Refresh page to verify toggle OFF persisted') + await t.eval(() => location.reload()) + await waitForElementVisible(byId('features-page')) + await waitForElementVisible(byId('feature-switch-2-off')) + + // Clear logger before second toggle + versionApiLogger.clear() + + // Toggle feature 'c' back ON using row switch + log('Toggle feature ON via row switch (versioned env)') + await toggleFeature(2, true) + + // Verify: Versioned API endpoint was called again + log('Verify versioned API endpoint was called for toggle ON') + await t.expect(versionApiLogger.requests.length).gte(1, 'Expected versioned API to be called for toggle ON') + + // Verify: Switch shows ON state on features list + await waitForElementVisible(byId('feature-switch-2-on')) + + // Verify: API returns correct state (feature enabled) + log('Verify API returns enabled state') + await t.wait(500) + await click('#try-it-btn') + await t.wait(500) + json = await parseTryItResults() + await t.expect(json.c.enabled).eql(true) + + // Refresh page to verify state was persisted to backend + log('Refresh page to verify toggle ON persisted') + await t.eval(() => location.reload()) + await waitForElementVisible(byId('features-page')) + await waitForElementVisible(byId('feature-switch-2-on')) + + log('Versioned toggle test passed') } diff --git a/frontend/global.d.ts b/frontend/global.d.ts index 54de827ed234..192be66a514e 100644 --- a/frontend/global.d.ts +++ b/frontend/global.d.ts @@ -83,6 +83,15 @@ declare global { trackTraits: (traits: Record) => void [key: string]: any } + const Utils: typeof Utils + const AsyncStorage: { + setItem: (key: string, value: string) => void + getItem: (key: string) => string | null + removeItem: (key: string) => void + [key: string]: any + } + const PanelSearch: typeof Component + const CodeHelp: typeof Component interface Window { $crisp: Crisp engagement: { diff --git a/frontend/web/components/CompareEnvironments.js b/frontend/web/components/CompareEnvironments.js index e77941cd94f4..73f26916e178 100644 --- a/frontend/web/components/CompareEnvironments.js +++ b/frontend/web/components/CompareEnvironments.js @@ -199,6 +199,20 @@ class CompareEnvironments extends Component { {this.state.environmentLeft && this.state.environmentRight ? ( {({}, { removeFlag, 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) => { + removeFlag(this.props.projectId, projectFlag) + } const renderRow = (p, i, fadeEnabled, fadeValue) => { const environmentLeft = ProjectStore.getEnvironment( this.state.environmentLeft, @@ -260,8 +274,10 @@ class CompareEnvironments extends Component { projectId={this.props.projectId} index={i} canDelete={permission} - toggleFlag={toggleFlag} - removeFlag={removeFlag} + toggleFlag={adaptedToggleFlag( + this.state.environmentLeft, + )} + removeFlag={adaptedRemoveFlag} projectFlag={p.projectFlagLeft} /> )} @@ -290,8 +306,10 @@ class CompareEnvironments extends Component { projectId={this.props.projectId} index={i} canDelete={permission} - toggleFlag={toggleFlag} - removeFlag={removeFlag} + toggleFlag={adaptedToggleFlag( + this.state.environmentRight, + )} + removeFlag={adaptedRemoveFlag} projectFlag={p.projectFlagRight} /> )} diff --git a/frontend/web/components/CompareFeatures.js b/frontend/web/components/CompareFeatures.js index 40bfad98646e..50b8df470cd6 100644 --- a/frontend/web/components/CompareFeatures.js +++ b/frontend/web/components/CompareFeatures.js @@ -90,6 +90,21 @@ class CompareFeatures extends Component {
{({}, { removeFlag, 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) => { + removeFlag(this.props.projectId, projectFlag) + } const renderRow = (data, i) => { const flagValues = this.state.environmentResults[i] const compare = @@ -141,8 +156,8 @@ class CompareFeatures extends Component { history={this.props.history} index={i} canDelete={permission} - toggleFlag={toggleFlag} - removeFlag={removeFlag} + toggleFlag={adaptedToggleFlag(data.api_key)} + removeFlag={adaptedRemoveFlag} projectFlag={this.state.flag} onCloseEditModal={() => { this.props.history.replace({ diff --git a/frontend/web/components/EnvironmentDocumentCodeHelp.tsx b/frontend/web/components/EnvironmentDocumentCodeHelp.tsx index d8d0e013c636..afa4b9b4ea3c 100644 --- a/frontend/web/components/EnvironmentDocumentCodeHelp.tsx +++ b/frontend/web/components/EnvironmentDocumentCodeHelp.tsx @@ -10,7 +10,7 @@ import { useHasPermission } from 'common/providers/Permission' import { Link } from 'react-router-dom' type EnvironmentDocumentCodeHelpType = { environmentId: string - projectId: string + projectId: number title: string } diff --git a/frontend/web/components/feature-summary/FeatureRow.tsx b/frontend/web/components/feature-summary/FeatureRow.tsx index 1ca6dbdbf3ab..8e7b33964774 100644 --- a/frontend/web/components/feature-summary/FeatureRow.tsx +++ b/frontend/web/components/feature-summary/FeatureRow.tsx @@ -1,4 +1,4 @@ -import React, { FC, useEffect, useMemo } from 'react' +import React, { FC, useCallback, useEffect, useMemo } from 'react' import ConfirmToggleFeature from 'components/modals/ConfirmToggleFeature' import ConfirmRemoveFeature from 'components/modals/ConfirmRemoveFeature' import CreateFlagModal from 'components/modals/CreateFlag' @@ -8,12 +8,10 @@ import { useProtectedTags } from 'common/utils/useProtectedTags' import Icon from 'components/Icon' import FeatureValue from './FeatureValue' import FeatureAction, { FeatureActionProps } from './FeatureAction' -import { getViewMode } from 'common/useViewMode' import classNames from 'classnames' import Button from 'components/base/forms/Button' import { Environment, - FeatureListProviderActions, FeatureListProviderData, ProjectFlag, ReleasePipeline, @@ -28,6 +26,7 @@ import { useGetHealthEventsQuery } from 'common/services/useHealthEvents' import FeatureName from './FeatureName' import FeatureDescription from './FeatureDescription' import FeatureTags from './FeatureTags' +import { useFeatureRowState } from 'components/pages/features/hooks/useFeatureRowState' export interface FeatureRowProps { disableControls?: boolean @@ -35,9 +34,15 @@ export interface FeatureRowProps { environmentId: string permission?: boolean projectFlag: ProjectFlag - projectId: string - removeFlag?: FeatureListProviderActions['removeFlag'] - toggleFlag?: FeatureListProviderActions['toggleFlag'] + projectId: number + removeFlag?: (projectFlag: ProjectFlag) => void | Promise + toggleFlag?: ( + projectFlag: ProjectFlag, + environmentFlag: + | FeatureListProviderData['environmentFlags'][number] + | undefined, + onError?: () => void, + ) => void | Promise index: number readOnly?: boolean condensed?: boolean @@ -49,6 +54,7 @@ export interface FeatureRowProps { hideRemove?: boolean releasePipelines?: ReleasePipeline[] onCloseEditModal?: () => void + isCompact?: boolean } const width = [220, 50, 55, 70, 450] @@ -65,6 +71,7 @@ const FeatureRow: FC = (props) => { hideAudit = false, hideRemove = false, index, + isCompact = false, onCloseEditModal, permission, projectFlag, @@ -76,6 +83,17 @@ const FeatureRow: FC = (props) => { } = props const protectedTags = useProtectedTags(projectFlag, projectId) const history = useHistory() + const { id } = projectFlag + + const actualEnabled = environmentFlags?.[id]?.enabled + const { + displayEnabled, + isLoading, + revertToggle, + startRemoving, + startToggle, + stopRemoving, + } = useFeatureRowState(actualEnabled) const { data: healthEvents } = useGetHealthEventsQuery( { projectId: String(projectFlag.project) }, @@ -117,7 +135,6 @@ const FeatureRow: FC = (props) => { } const confirmToggle = () => { - const { id } = projectFlag openModal( 'Toggle Feature', = (props) => { projectFlag={projectFlag} environmentFlag={environmentFlags?.[id]} cb={() => { - toggleFlag?.( - projectId, - environmentId, - projectFlag, - environmentFlags?.[id], - ) + handleToggle() }} />, 'p-0', ) } + const handleToggle = useCallback(() => { + const canToggle = startToggle(!actualEnabled) + if (!canToggle) return // Prevent rapid toggling + toggleFlag?.(projectFlag, environmentFlags?.[id], revertToggle) + }, [ + actualEnabled, + environmentFlags, + id, + projectFlag, + revertToggle, + startToggle, + toggleFlag, + ]) + const onChange = () => { if (disableControls) { return @@ -210,13 +236,11 @@ const FeatureRow: FC = (props) => { const isReadOnly = readOnly || Utils.getFlagsmithHasFeature('read_only_mode') const isFeatureHealthEnabled = Utils.getFlagsmithHasFeature('feature_health') - const { description, id } = projectFlag + const { description } = projectFlag const environment = ProjectStore.getEnvironment( environmentId, ) as Environment | null - const isCompact = getViewMode() === 'compact' - if (condensed) { return ( = (props) => { onCopyName: copyFeature, onRemove: () => { if (disableControls) return - confirmRemove(projectFlag, () => { - removeFlag?.(projectId, projectFlag) + confirmRemove(projectFlag, async () => { + startRemoving() + try { + await removeFlag?.(projectFlag) + } catch { + stopRemoving() + } }) }, onShowAudit: () => { @@ -275,6 +304,7 @@ const FeatureRow: FC = (props) => { isReadOnly ? '' : 'clickable' }`, className, + { 'list-item--toggling': isLoading }, )} key={id} data-test={`feature-item-${index}`} @@ -310,11 +340,11 @@ const FeatureRow: FC = (props) => { }} >
@@ -332,7 +362,10 @@ const FeatureRow: FC = (props) => {
!isReadOnly && editFeature()} - className='d-flex cursor-pointer flex-column justify-content-center px-2 list-item py-1 d-lg-none' + className={classNames( + 'd-flex cursor-pointer flex-column justify-content-center px-2 list-item py-1 d-lg-none', + { 'list-item--toggling': isLoading }, + )} >
@@ -340,11 +373,11 @@ const FeatureRow: FC = (props) => {
diff --git a/frontend/web/components/feature-summary/FeatureRowSkeleton.tsx b/frontend/web/components/feature-summary/FeatureRowSkeleton.tsx new file mode 100644 index 000000000000..6244da250053 --- /dev/null +++ b/frontend/web/components/feature-summary/FeatureRowSkeleton.tsx @@ -0,0 +1,49 @@ +import React, { FC } from 'react' +import type { CSSProperties } from 'react' + +interface FeatureRowSkeletonProps { + style?: CSSProperties +} + +export const FeatureRowSkeleton: FC = ({ style }) => { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ) +} + +export default FeatureRowSkeleton diff --git a/frontend/web/components/metrics/EnvironmentMetricsList.tsx b/frontend/web/components/metrics/EnvironmentMetricsList.tsx index 82c85365faaa..2331d304335d 100644 --- a/frontend/web/components/metrics/EnvironmentMetricsList.tsx +++ b/frontend/web/components/metrics/EnvironmentMetricsList.tsx @@ -5,24 +5,29 @@ import EnvironmentMetric from './EnvironmentMetric' import { getExtraMetricsData } from './constants' interface EnvironmentMetricsListProps { - environmentApiKey: string + environmentId: string projectId: number forceRefetch?: boolean } const EnvironmentMetricsList: FC = ({ - environmentApiKey, + environmentId, forceRefetch, projectId, }) => { - const { data, isLoading, refetch } = useGetEnvironmentMetricsQuery({ - id: environmentApiKey, - }) + const { data, isLoading, refetch } = useGetEnvironmentMetricsQuery( + { + id: environmentId, + }, + { + skip: !environmentId, + }, + ) const MAX_COLUMNS = 6 const columns = Math.min(data?.metrics?.length || 0, MAX_COLUMNS) || 1 - const extraMetricsData = getExtraMetricsData(projectId, environmentApiKey) + const extraMetricsData = getExtraMetricsData(projectId, environmentId) useEffect(() => { if (forceRefetch) { diff --git a/frontend/web/components/metrics/constants.ts b/frontend/web/components/metrics/constants.ts index 7cfb48c67356..1e85728ae2cb 100644 --- a/frontend/web/components/metrics/constants.ts +++ b/frontend/web/components/metrics/constants.ts @@ -2,22 +2,22 @@ type ExtraMetrics = Record type GetExtraMetricsFunc = ( projectId: string, - environmentApiKey: string, + environmentId: string, ) => ExtraMetrics export const getExtraMetricsData: GetExtraMetricsFunc = ( projectId, - environmentApiKey, + environmentId, ) => { return { enabled_features: { tooltip: 'The number of features enabled for this environment', }, identity_overrides: { - link: `/project/${projectId}/environment/${environmentApiKey}/identities`, + link: `/project/${projectId}/environment/${environmentId}/identities`, }, open_change_requests: { - link: `/project/${projectId}/environment/${environmentApiKey}/change-requests`, + link: `/project/${projectId}/environment/${environmentId}/change-requests`, }, segment_overrides: { link: `/project/${projectId}/segments`, diff --git a/frontend/web/components/pages/FeaturesPage.js b/frontend/web/components/pages/FeaturesPage.js deleted file mode 100644 index aa5f8b668e39..000000000000 --- a/frontend/web/components/pages/FeaturesPage.js +++ /dev/null @@ -1,429 +0,0 @@ -import React, { Component } from 'react' -import CreateFlagModal from 'components/modals/CreateFlag' -import TryIt from 'components/TryIt' -import FeatureRow from 'components/feature-summary/FeatureRow' -import FeatureListStore from 'common/stores/feature-list-store' -import ProjectStore from 'common/stores/project-store' -import Permission from 'common/providers/Permission' -import JSONReference from 'components/JSONReference' -import ConfigProvider from 'common/providers/ConfigProvider' -import Constants from 'common/constants' -import PageTitle from 'components/PageTitle' -import EnvironmentDocumentCodeHelp from 'components/EnvironmentDocumentCodeHelp' -import classNames from 'classnames' -import Button from 'components/base/forms/Button' -import { withRouter } from 'react-router-dom' -import { useRouteContext } from 'components/providers/RouteContext' -import FeatureFilters, { - parseFiltersFromUrlParams, - getServerFilter, - getURLParamsFromFilters, -} from 'components/feature-page/FeatureFilters' -import { FeatureMetricsSection, FeaturesEmptyState } from './features' - -const FeaturesPage = class extends Component { - static displayName = 'FeaturesPage' - - constructor(props) { - super(props) - this.state = { - filters: parseFiltersFromUrlParams(Utils.fromParam()), - forceMetricsRefetch: false, - loadedOnce: false, - } - ES6Component(this) - this.projectId = this.props.routeContext.projectId - const { filters } = this.state - this.fetchFeatures(filters) - } - - fetchFeatures = (filters = this.state.filters, page = filters.page) => { - AppActions.getFeatures( - this.projectId, - this.props.match.params.environmentId, - true, - filters.search, - filters.sort, - page, - getServerFilter(filters), - ) - } - - componentDidUpdate(prevProps) { - const { - match: { params }, - } = this.props - const { - match: { params: oldParams }, - } = prevProps - if ( - params.environmentId !== oldParams.environmentId || - params.projectId !== oldParams.projectId - ) { - this.setState({ loadedOnce: false }, () => this.filter()) - } - } - - componentDidMount = () => { - API.trackPage(Constants.pages.FEATURES) - const { - match: { params }, - } = this.props - AsyncStorage.setItem( - 'lastEnv', - JSON.stringify({ - environmentId: params.environmentId, - orgId: AccountStore.getOrganisation()?.id, - projectId: params.projectId, - }), - ) - - // Add window focus listener to refetch features - this.handleWindowFocus = () => { - this.fetchFeatures() - } - window.addEventListener('focus', this.handleWindowFocus) - } - - componentWillUnmount = () => { - // Clean up the window focus listener - window.removeEventListener('focus', this.handleWindowFocus) - } - - newFlag = () => { - openModal( - 'New Feature', - , - 'side-modal create-feature-modal', - ) - } - - toggleForceMetricsRefetch = () => { - this.setState((prevState) => ({ - forceMetricsRefetch: !prevState.forceMetricsRefetch, - })) - } - - onError = (error) => { - if (!error?.name && !error?.initial_value) { - toast( - error.project || - 'We could not create this feature, please check the name is not in use.', - 'danger', - ) - } - } - - filter = (page) => { - const currentParams = Utils.fromParam() - const nextFilters = - typeof page === 'number' - ? { ...this.state.filters, page } - : this.state.filters - this.setState({ filters: nextFilters }, () => { - const f = this.state.filters - if (!currentParams.feature) { - this.props.history.replace( - `${document.location.pathname}?${Utils.toParam( - getURLParamsFromFilters(this.state.filters), - )}`, - ) - } - if (page) { - this.fetchFeatures(f, page) - } else { - AppActions.searchFeatures( - this.projectId, - this.props.match.params.environmentId, - true, - f.search, - f.sort, - getServerFilter(f), - ) - } - }) - } - - createFeaturePermission(el) { - return ( - - {({ permission }) => - permission - ? el(permission) - : Utils.renderWithPermission( - permission, - Constants.projectPermissions('Create Feature'), - el(permission), - ) - } - - ) - } - - render() { - const { environmentId, projectId } = this.props.match.params - const readOnly = Utils.getFlagsmithHasFeature('read_only_mode') - - const environment = ProjectStore.getEnvironment(environmentId) - - return ( -
- { - this.toggleForceMetricsRefetch() - return toast( -
- Removed feature: {feature.name} -
, - ) - }} - onSave={this.toggleForceMetricsRefetch} - onError={this.onError} - > - {( - { - environmentFlags, - maxFeaturesAllowed, - projectFlags, - totalFeatures, - }, - { removeFlag, toggleFlag }, - ) => { - const isLoading = !FeatureListStore.hasLoaded - const isSaving = FeatureListStore.isSaving - const featureLimitAlert = Utils.calculateRemainingLimitsPercentage( - totalFeatures, - maxFeaturesAllowed, - ) - - if (FeatureListStore.hasLoaded && !this.state.loadedOnce) { - this.state.loadedOnce = true - } - - return ( -
- {!this.state.loadedOnce && - (!projectFlags || !projectFlags.length) && ( -
- -
- )} - {this.state.loadedOnce && ( -
- {this.state.loadedOnce || - ((this.state.filters.is_archived || - typeof this.state.filters.search === 'string' || - !!this.state.filters.tags.length) && - !isLoading) ? ( -
- {featureLimitAlert.percentage && - Utils.displayLimitAlert( - 'features', - featureLimitAlert.percentage, - )} - - - {this.state.loadedOnce || - this.state.filters.is_archived || - this.state.filters.tags?.length - ? this.createFeaturePermission((perm) => ( - - )) - : null} - - } - > - View and manage{' '} - - feature flags - - } - place='right' - > - {Constants.strings.FEATURE_FLAG_DESCRIPTION} - {' '} - and{' '} - - remote config - - } - place='right' - > - {Constants.strings.REMOTE_CONFIG_DESCRIPTION} - {' '} - for your selected environment. - - - { - FeatureListStore.isLoading = true - this.setState({ filters: next }, this.filter) - }} - /> - } - nextPage={() => - this.filter(FeatureListStore.paging.next) - } - prevPage={() => - this.filter(FeatureListStore.paging.previous) - } - goToPage={(page) => this.filter(page)} - items={projectFlags?.filter((v) => !v.ignore)} - renderFooter={() => ( - <> - - - - )} - renderRow={(projectFlag, i) => ( - - {({ permission }) => ( - - )} - - )} - /> - - - - - - - - - -
- ) : ( - !isLoading && - this.state.loadedOnce && - this.createFeaturePermission((perm) => ( - - )) - )} -
- )} -
- ) - }} -
-
- ) - } -} - -FeaturesPage.propTypes = {} -const FeaturesPageWithContext = (props) => { - const context = useRouteContext() - return -} - -export default withRouter(ConfigProvider(FeaturesPageWithContext)) diff --git a/frontend/web/components/pages/features/FeaturesPage.tsx b/frontend/web/components/pages/features/FeaturesPage.tsx new file mode 100644 index 000000000000..2c7a65bf0d6d --- /dev/null +++ b/frontend/web/components/pages/features/FeaturesPage.tsx @@ -0,0 +1,380 @@ +import React, { FC, useCallback, useEffect, useMemo } from 'react' +import { useHistory } from 'react-router-dom' +import CreateFlagModal from 'components/modals/CreateFlag' +import Constants from 'common/constants' +import Utils from 'common/utils/utils' +import AppActions from 'common/dispatcher/app-actions' +import FeatureListStore from 'common/stores/feature-list-store' +import { FEATURES_PAGE_SIZE } from 'common/services/useProjectFlag' +import { useRouteContext } from 'components/providers/RouteContext' +import { usePageTracking } from 'common/hooks/usePageTracking' +import FeatureRow from 'components/feature-summary/FeatureRow' +import FeatureRowSkeleton from 'components/feature-summary/FeatureRowSkeleton' +import JSONReference from 'components/JSONReference' +import Permission from 'common/providers/Permission' +import { + FeaturesEmptyState, + FeatureMetricsSection, + FeaturesPageHeader, + FeaturesTableFilters, + FeaturesSDKIntegration, +} from './components' +import { useFeatureFilters } from './hooks/useFeatureFilters' +import { useRemoveFeatureWithToast } from './hooks/useRemoveFeatureWithToast' +import { useToggleFeatureWithToast } from './hooks/useToggleFeatureWithToast' +import { useProjectEnvironments } from 'common/hooks/useProjectEnvironments' +import { useFeatureListWithApiKey } from 'common/hooks/useFeatureListWithApiKey' +import { useViewMode } from 'common/useViewMode' +import type { Pagination } from './types' +import type { ProjectFlag, FeatureState } from 'common/types/responses' + +const DEFAULT_PAGINATION: Pagination = { + count: 0, + currentPage: 1, + next: null, + pageSize: FEATURES_PAGE_SIZE, + previous: null, +} + +const SKELETON_ITEMS = Array(10).fill({ isSkeleton: true }) + +type SkeletonItem = { isSkeleton: boolean } + +function isSkeletonItem( + item: ProjectFlag | SkeletonItem, +): item is SkeletonItem { + return 'isSkeleton' in item && item.isSkeleton === true +} + +const FeaturesPage: FC = () => { + const history = useHistory() + const routeContext = useRouteContext() + const projectId = routeContext.projectId! + const environmentId = routeContext.environmentId! + const { + clearFilters, + filters, + goToPage, + handleFilterChange, + hasFilters, + page, + } = useFeatureFilters(history) + + const { + isCompact, + setViewMode: handleViewModeChange, + viewMode, + } = useViewMode() + + const { + error: projectEnvError, + getEnvironment, + project, + } = useProjectEnvironments(projectId) + const { data, error, isFetching, isLoading, refetch } = + useFeatureListWithApiKey(filters, page, environmentId, projectId) + + // Backward compatibility: Populate ProjectStore for legacy components (CreateFlag) + // TODO: Remove this when CreateFlag is migrated to RTK Query + useEffect(() => { + if (projectId) { + AppActions.getProject(projectId) + } + }, [projectId]) + + // Backward compatibility: Populate FeatureListStore for legacy components (CreateFlag modal) + // Must pass current filters/search/page so FeatureListStore contains the same features + // that RTK Query displays. Otherwise editing features will crash because they're not in the store. + // TODO: Remove this when CreateFlag is migrated to RTK Query + useEffect(() => { + if (projectId && environmentId) { + AppActions.getFeatures( + projectId, + environmentId, + true, + filters.search, + filters.sort, + page, + { + group_owners: filters.group_owners?.join(',') || undefined, + is_archived: filters.showArchived, + is_enabled: filters.is_enabled, + owners: filters.owners?.join(',') || undefined, + tag_strategy: filters.tag_strategy, + tags: filters.tags?.join(',') || undefined, + value_search: filters.value_search, + }, + ) + } + }, [projectId, environmentId, page, filters]) + + // Force re-fetch when legacy Flux store updates features + // TODO: Remove when all feature mutations use RTK Query + useEffect(() => { + const onFeatureListChange = () => { + // Refetch RTK Query data when Flux store changes + refetch() + } + FeatureListStore.on('saved', onFeatureListChange) + FeatureListStore.on('removed', onFeatureListChange) + return () => { + FeatureListStore.off('saved', onFeatureListChange) + FeatureListStore.off('removed', onFeatureListChange) + } + }, [refetch]) + + const maxFeaturesAllowed = project?.max_features_allowed ?? null + const currentEnvironment = getEnvironment(environmentId) + const minimumChangeRequestApprovals = + currentEnvironment?.minimum_change_request_approvals + + const [removeFeature] = useRemoveFeatureWithToast() + const [toggleFeature] = useToggleFeatureWithToast() + + const removeFlag = useCallback( + async (projectFlag: ProjectFlag) => { + await removeFeature(projectFlag, projectId) + }, + [removeFeature, projectId], + ) + + const toggleFlag = useCallback( + async ( + flag: ProjectFlag, + environmentFlag: FeatureState | undefined, + onError?: () => void, + ) => { + if (!currentEnvironment) { + onError?.() + return + } + await toggleFeature(flag, environmentFlag, currentEnvironment, { + onError, + }) + }, + [toggleFeature, currentEnvironment], + ) + + const projectFlags = useMemo(() => data?.results ?? [], [data?.results]) + const environmentFlags = useMemo( + () => data?.environmentStates ?? {}, + [data?.environmentStates], + ) + const paging = useMemo( + () => data?.pagination ?? DEFAULT_PAGINATION, + [data?.pagination], + ) + const totalFeatures = useMemo(() => data?.count ?? 0, [data?.count]) + + usePageTracking({ + context: { + environmentId, + organisationId: routeContext.organisationId, + projectId, + }, + pageName: Constants.pages.FEATURES, + saveToStorage: true, + }) + + const openNewFlagModal = () => { + openModal( + 'New Feature', + , + 'side-modal create-feature-modal', + ) + } + + const renderHeader = useCallback( + () => ( + + ), + [ + projectId, + filters, + hasFilters, + isLoading, + project?.organisation, + handleFilterChange, + clearFilters, + viewMode, + handleViewModeChange, + ], + ) + + const renderFooter = useCallback( + () => ( + <> + + + + ), + [projectFlags, environmentFlags], + ) + + const renderFeatureRow = useCallback( + (projectFlag: ProjectFlag | SkeletonItem, i: number) => { + if (isSkeletonItem(projectFlag)) { + return + } + + return ( + + {({ permission }) => ( + + )} + + ) + }, + [ + environmentFlags, + environmentId, + projectId, + minimumChangeRequestApprovals, + toggleFlag, + removeFlag, + isCompact, + ], + ) + + const handleNextPage = () => { + if (paging?.next) { + goToPage(page + 1) + } + } + + const handlePrevPage = () => { + if (paging?.previous) { + goToPage(page - 1) + } + } + + const renderFeaturesList = () => { + const shouldShowEmptyState = + data && projectFlags.length === 0 && !hasFilters && !isFetching + + if (!shouldShowEmptyState) { + return ( + + ) + } + + return ( + + {({ permission: perm }) => ( + + )} + + ) + } + + const readOnly = Utils.getFlagsmithHasFeature('read_only_mode') + return ( +
+
+ {error || projectEnvError ? ( +
+

Unable to Load Features

+

+ We couldn't load your feature flags. This might be due to a + network issue or a temporary server problem. +

+
+ ) : ( + <> + + + + + {renderFeaturesList()} + + + + )} +
+
+ ) +} + +export default FeaturesPage diff --git a/frontend/web/components/pages/features/FeatureMetricsSection.tsx b/frontend/web/components/pages/features/components/FeatureMetricsSection.tsx similarity index 68% rename from frontend/web/components/pages/features/FeatureMetricsSection.tsx rename to frontend/web/components/pages/features/components/FeatureMetricsSection.tsx index f2008cbbbeef..e23741414cd0 100644 --- a/frontend/web/components/pages/features/FeatureMetricsSection.tsx +++ b/frontend/web/components/pages/features/components/FeatureMetricsSection.tsx @@ -3,28 +3,25 @@ import EnvironmentMetricsList from 'components/metrics/EnvironmentMetricsList' import Utils from 'common/utils/utils' type FeatureMetricsSectionProps = { - environmentApiKey?: string - forceRefetch: boolean - projectId: string + environmentId?: string + projectId: number } export const FeatureMetricsSection: FC = ({ - environmentApiKey, - forceRefetch, + environmentId, projectId, }) => { const environmentMetricsEnabled = Utils.getFlagsmithHasFeature( 'environment_metrics', ) - if (!environmentMetricsEnabled) { + if (!environmentMetricsEnabled || !environmentId) { return null } return ( ) diff --git a/frontend/web/components/pages/features/FeaturesEmptyState.tsx b/frontend/web/components/pages/features/components/FeaturesEmptyState.tsx similarity index 99% rename from frontend/web/components/pages/features/FeaturesEmptyState.tsx rename to frontend/web/components/pages/features/components/FeaturesEmptyState.tsx index c419016e6fba..f61a18095ade 100644 --- a/frontend/web/components/pages/features/FeaturesEmptyState.tsx +++ b/frontend/web/components/pages/features/components/FeaturesEmptyState.tsx @@ -8,7 +8,7 @@ import { Link } from 'react-router-dom' type FeaturesEmptyStateProps = { environmentId: string onCreateFeature: () => void - projectId: string + projectId: number canCreateFeature: boolean } diff --git a/frontend/web/components/pages/features/components/FeaturesPageHeader.tsx b/frontend/web/components/pages/features/components/FeaturesPageHeader.tsx new file mode 100644 index 000000000000..cf90e082f8ff --- /dev/null +++ b/frontend/web/components/pages/features/components/FeaturesPageHeader.tsx @@ -0,0 +1,87 @@ +import React, { FC } from 'react' +import PageTitle from 'components/PageTitle' +import Button from 'components/base/forms/Button' +import Utils from 'common/utils/utils' +import Constants from 'common/constants' +import Permission from 'common/providers/Permission' + +type FeaturesPageHeaderProps = { + totalFeatures: number + maxFeaturesAllowed: number | null + onCreateFeature: () => void + readOnly: boolean + projectId: number +} + +export const FeaturesPageHeader: FC = ({ + maxFeaturesAllowed, + onCreateFeature, + projectId, + readOnly, + totalFeatures, +}) => { + const featureLimitAlert = Utils.calculateRemainingLimitsPercentage( + totalFeatures, + maxFeaturesAllowed, + ) + + const featureLimitWarningBanner = featureLimitAlert.percentage + ? Utils.displayLimitAlert('features', featureLimitAlert.percentage) + : null + + return ( + <> + {featureLimitWarningBanner} + + {({ permission: perm }) => ( + + )} + + } + > + View and manage{' '} + + feature flags + + } + place='right' + > + {Constants.strings.FEATURE_FLAG_DESCRIPTION} + {' '} + and{' '} + + remote config + + } + place='right' + > + {Constants.strings.REMOTE_CONFIG_DESCRIPTION} + {' '} + for your selected environment. + + + ) +} diff --git a/frontend/web/components/pages/features/components/FeaturesSDKIntegration.tsx b/frontend/web/components/pages/features/components/FeaturesSDKIntegration.tsx new file mode 100644 index 000000000000..2938d4421b04 --- /dev/null +++ b/frontend/web/components/pages/features/components/FeaturesSDKIntegration.tsx @@ -0,0 +1,40 @@ +import React, { FC } from 'react' +import TryIt from 'components/TryIt' +import EnvironmentDocumentCodeHelp from 'components/EnvironmentDocumentCodeHelp' +import Constants from 'common/constants' + +type FeaturesSDKIntegrationProps = { + projectId: number + environmentId: string +} + +export const FeaturesSDKIntegration: FC = ({ + environmentId, + projectId, +}) => { + return ( + <> + + + + + + + + + + ) +} diff --git a/frontend/web/components/pages/features/components/FeaturesTableFilters.tsx b/frontend/web/components/pages/features/components/FeaturesTableFilters.tsx index 61a7b572f976..ae9f2aa5ba30 100644 --- a/frontend/web/components/pages/features/components/FeaturesTableFilters.tsx +++ b/frontend/web/components/pages/features/components/FeaturesTableFilters.tsx @@ -5,10 +5,10 @@ import TableValueFilter from 'components/tables/TableValueFilter' import TableOwnerFilter from 'components/tables/TableOwnerFilter' import TableGroupsFilter from 'components/tables/TableGroupsFilter' import TableFilterOptions from 'components/tables/TableFilterOptions' -import TableSortFilter, { SortValue } from 'components/tables/TableSortFilter' +import TableSortFilter from 'components/tables/TableSortFilter' import ClearFilters from 'components/ClearFilters' -import { getViewMode, setViewMode } from 'common/useViewMode' -import { TagStrategy } from 'common/types/responses' +import type { ViewMode } from 'common/useViewMode' +import type { FilterState } from 'common/types/featureFilters' const VIEW_MODE_OPTIONS = [ { @@ -32,26 +32,16 @@ const SORT_OPTIONS = [ }, ] -export type FilterState = { - search: string | null - tags: (number | string)[] - tag_strategy: TagStrategy - showArchived: boolean - is_enabled: boolean | null - value_search: string - owners: number[] - group_owners: number[] - sort: SortValue -} - type FeaturesTableFiltersProps = { - projectId: string + projectId: number filters: FilterState hasFilters: boolean isLoading?: boolean orgId?: number onFilterChange: (updates: Partial) => void onClearFilters: () => void + viewMode: ViewMode + onViewModeChange: (value: ViewMode) => void } export const FeaturesTableFilters: FC = ({ @@ -60,8 +50,10 @@ export const FeaturesTableFilters: FC = ({ isLoading, onClearFilters, onFilterChange, + onViewModeChange, orgId, projectId, + viewMode, }) => { const { group_owners: groupOwners, @@ -80,7 +72,7 @@ export const FeaturesTableFilters: FC = ({ if (newTags.includes('') && newTags.length > 1) { if (!tags.includes('')) { // User just selected empty tag - make it exclusive - onFilterChange({ tags: [''] as (number | string)[] }) + onFilterChange({ tags: [''] }) } else { // Empty tag was already selected - remove it to allow other tags onFilterChange({ tags: newTags.filter((v) => !!v) }) @@ -113,7 +105,7 @@ export const FeaturesTableFilters: FC = ({
onFilterChange({ search: v })} + onChange={(v) => onFilterChange({ search: v || null })} value={search} /> @@ -146,7 +138,6 @@ export const FeaturesTableFilters: FC = ({ /> onFilterChange({ group_owners })} @@ -154,14 +145,12 @@ export const FeaturesTableFilters: FC = ({ { - setViewMode(value as 'default' | 'compact') - }} + value={viewMode} + onChange={(value) => onViewModeChange(value as ViewMode)} options={VIEW_MODE_OPTIONS} /> onFilterChange({ sort })} diff --git a/frontend/web/components/pages/features/components/index.ts b/frontend/web/components/pages/features/components/index.ts new file mode 100644 index 000000000000..deceb0bbe1e3 --- /dev/null +++ b/frontend/web/components/pages/features/components/index.ts @@ -0,0 +1,5 @@ +export { FeaturesEmptyState } from './FeaturesEmptyState' +export { FeatureMetricsSection } from './FeatureMetricsSection' +export { FeaturesPageHeader } from './FeaturesPageHeader' +export { FeaturesSDKIntegration } from './FeaturesSDKIntegration' +export { FeaturesTableFilters } from './FeaturesTableFilters' diff --git a/frontend/web/components/pages/features/hooks/useFeatureFilters.ts b/frontend/web/components/pages/features/hooks/useFeatureFilters.ts new file mode 100644 index 000000000000..3f792f3d706e --- /dev/null +++ b/frontend/web/components/pages/features/hooks/useFeatureFilters.ts @@ -0,0 +1,69 @@ +import { useState, useMemo, useCallback, useEffect } from 'react' +import type { History } from 'history' +import type { FilterState } from 'common/types/featureFilters' +import { + hasActiveFilters, + buildUrlParams, + getFiltersFromParams, +} from 'common/utils/featureFilterParams' +import Utils from 'common/utils/utils' + +/** + * Manages feature filters with bidirectional URL synchronization. + * FeaturesPage-specific implementation. + */ +export function useFeatureFilters(history: History): { + filters: FilterState + page: number + hasFilters: boolean + handleFilterChange: (updates: Partial) => void + clearFilters: () => void + goToPage: (newPage: number) => void +} { + const initialFilters = useMemo( + () => getFiltersFromParams(Utils.fromParam()), + [], + ) + + const [filters, setFilters] = useState(initialFilters) + const [page, setPage] = useState(initialFilters.page) + + const updateURLParams = useCallback(() => { + const currentParams = Utils.fromParam() + if (!currentParams.feature) { + const urlParams = buildUrlParams(filters, page) + history.replace( + `${document.location.pathname}?${Utils.toParam(urlParams)}`, + ) + } + }, [filters, page, history]) + + useEffect(() => { + updateURLParams() + }, [updateURLParams]) + + const handleFilterChange = (updates: Partial) => { + setFilters((prev) => ({ ...prev, ...updates })) + setPage(1) + } + + const clearFilters = useCallback(() => { + history.replace(document.location.pathname) + const newFilters = getFiltersFromParams({}) + setFilters(newFilters) + setPage(1) + }, [history]) + + const goToPage = (newPage: number) => { + setPage(newPage) + } + + return { + clearFilters, + filters, + goToPage, + handleFilterChange, + hasFilters: hasActiveFilters(filters), + page, + } +} diff --git a/frontend/web/components/pages/features/hooks/useFeatureRowState.ts b/frontend/web/components/pages/features/hooks/useFeatureRowState.ts new file mode 100644 index 000000000000..747a158d7028 --- /dev/null +++ b/frontend/web/components/pages/features/hooks/useFeatureRowState.ts @@ -0,0 +1,55 @@ +import { useState, useEffect, useCallback, useRef } from 'react' + +/** + * Manages feature row state including optimistic toggle updates and loading states. + * Consolidates all row-level state management into a single hook. + */ +export function useFeatureRowState(actualEnabled: boolean | undefined) { + const [optimisticValue, setOptimisticValue] = useState(null) + const [isToggling, setIsToggling] = useState(false) + const [isRemoving, setIsRemoving] = useState(false) + const isTogglingRef = useRef(false) + + // Reset optimistic state when actual value changes (after API response) + useEffect(() => { + setOptimisticValue(null) + setIsToggling(false) + isTogglingRef.current = false + }, [actualEnabled]) + + const displayEnabled = optimisticValue ?? actualEnabled + const isLoading = isToggling || isRemoving + + const startToggle = useCallback((value: boolean) => { + if (isTogglingRef.current) return false + isTogglingRef.current = true + setIsToggling(true) + setOptimisticValue(value) + return true + }, []) + + const revertToggle = useCallback(() => { + setOptimisticValue(null) + setIsToggling(false) + isTogglingRef.current = false + }, []) + + const startRemoving = useCallback(() => { + setIsRemoving(true) + }, []) + + const stopRemoving = useCallback(() => { + setIsRemoving(false) + }, []) + + return { + displayEnabled, + isLoading, + isRemoving, + isToggling, + revertToggle, + startRemoving, + startToggle, + stopRemoving, + } +} diff --git a/frontend/web/components/pages/features/hooks/useRemoveFeatureWithToast.ts b/frontend/web/components/pages/features/hooks/useRemoveFeatureWithToast.ts new file mode 100644 index 000000000000..777fc72eb59a --- /dev/null +++ b/frontend/web/components/pages/features/hooks/useRemoveFeatureWithToast.ts @@ -0,0 +1,45 @@ +import { useCallback } from 'react' +import { useRemoveProjectFlagMutation } from 'common/services/useProjectFlag' +import type { ProjectFlag } from 'common/types/responses' + +type RemoveFeatureOptions = { + successMessage?: string + errorMessage?: string + onError?: (error: unknown) => void + onSuccess?: () => void +} + +/** Removes a feature flag with toast notifications. */ +export const useRemoveFeatureWithToast = () => { + const [removeProjectFlag, state] = useRemoveProjectFlagMutation() + + const removeWithToast = useCallback( + async ( + projectFlag: ProjectFlag, + projectId: number, + options?: RemoveFeatureOptions, + ) => { + try { + await removeProjectFlag({ + flag_id: projectFlag.id, + project_id: projectId, + }).unwrap() + + toast(options?.successMessage || `Removed feature: ${projectFlag.name}`) + options?.onSuccess?.() + } catch (error) { + console.error('Failed to remove feature:', error) + toast( + options?.errorMessage || + 'Failed to remove feature. Please try again.', + 'danger', + ) + options?.onError?.(error) + throw error + } + }, + [removeProjectFlag], + ) + + return [removeWithToast, state] as const +} diff --git a/frontend/web/components/pages/features/hooks/useToggleFeatureWithToast.ts b/frontend/web/components/pages/features/hooks/useToggleFeatureWithToast.ts new file mode 100644 index 000000000000..5a08df4cb131 --- /dev/null +++ b/frontend/web/components/pages/features/hooks/useToggleFeatureWithToast.ts @@ -0,0 +1,81 @@ +import { useCallback } from 'react' +import { useUpdateFeatureStateMutation } from 'common/services/useFeatureState' +import { useCreateAndSetFeatureVersionMutation } from 'common/services/useFeatureVersion' +import type { + Environment, + FeatureState, + ProjectFlag, +} from 'common/types/responses' + +type ToggleFeatureOptions = { + successMessage?: string + errorMessage?: string + onError?: (error: unknown) => void + onSuccess?: () => void +} + +/** Toggles a feature flag's enabled state with toast notifications. */ +export const useToggleFeatureWithToast = () => { + const [updateFeatureState, updateState] = useUpdateFeatureStateMutation() + const [createAndSetFeatureVersion, versionState] = + useCreateAndSetFeatureVersionMutation() + + const toggleWithToast = useCallback( + async ( + flag: ProjectFlag, + environmentFlag: FeatureState | undefined, + environment: Environment, + options?: ToggleFeatureOptions, + ) => { + if (!environmentFlag) { + console.warn('Cannot toggle feature: environmentFlag is undefined') + options?.onError?.(new Error('environmentFlag is undefined')) + return + } + try { + if (environment.use_v2_feature_versioning) { + // Versioned environment: use versioning API + await createAndSetFeatureVersion({ + environmentApiKey: environment.api_key, + environmentId: environment.id, + featureId: flag.id, + featureStates: [ + { + ...environmentFlag, + enabled: !environmentFlag.enabled, + }, + ], + projectId: flag.project, + }).unwrap() + } else { + // Non-versioned environment: use simple PUT + await updateFeatureState({ + body: { + enabled: !environmentFlag.enabled, + }, + environmentFlagId: environmentFlag.id, + environmentId: environment.api_key, + }).unwrap() + } + + if (options?.successMessage) { + toast(options.successMessage) + } + options?.onSuccess?.() + } catch (error) { + console.error('Failed to toggle feature:', error) + toast( + options?.errorMessage || + 'Failed to toggle feature. Please try again.', + 'danger', + ) + options?.onError?.(error) + } + }, + [updateFeatureState, createAndSetFeatureVersion], + ) + + const isLoading = updateState.isLoading || versionState.isLoading + + return [toggleWithToast, { isLoading }] as const +} diff --git a/frontend/web/components/pages/features/index.ts b/frontend/web/components/pages/features/index.ts index 8a379dfabea5..e8646d7232d8 100644 --- a/frontend/web/components/pages/features/index.ts +++ b/frontend/web/components/pages/features/index.ts @@ -1,3 +1 @@ -export { FeaturesEmptyState } from './FeaturesEmptyState' -export { FeatureMetricsSection } from './FeatureMetricsSection' -export { FeaturesTableFilters } from './components/FeaturesTableFilters' +export { default } from './FeaturesPage' diff --git a/frontend/web/components/pages/features/types.ts b/frontend/web/components/pages/features/types.ts new file mode 100644 index 000000000000..50dbd4554a9a --- /dev/null +++ b/frontend/web/components/pages/features/types.ts @@ -0,0 +1,11 @@ +import { FeatureState } from 'common/types/responses' + +export type Pagination = { + count: number + currentPage: number + next: string | null + pageSize: number + previous: string | null +} + +export type EnvironmentFlagsMap = Record diff --git a/frontend/web/components/tables/TableGroupsFilter.tsx b/frontend/web/components/tables/TableGroupsFilter.tsx index e6d3aad7e4a9..b0c6f40dc57e 100644 --- a/frontend/web/components/tables/TableGroupsFilter.tsx +++ b/frontend/web/components/tables/TableGroupsFilter.tsx @@ -1,5 +1,4 @@ -import React, { FC, useEffect, useMemo, useRef } from 'react' -import { AsyncStorage } from 'polyfill-react-native' +import React, { FC, useMemo } from 'react' import TableFilterOptions from './TableFilterOptions' import { sortBy } from 'lodash' import { useGetGroupSummariesQuery } from 'common/services/useGroupSummary' @@ -9,7 +8,7 @@ type TableFilterType = { onChange: (value: TableFilterType['value']) => void className?: string isLoading?: boolean - orgId: number | undefined + orgId: string | undefined } const TableGroupsFilter: FC = ({ @@ -20,7 +19,7 @@ const TableGroupsFilter: FC = ({ value, }) => { const { data } = useGetGroupSummariesQuery( - { orgId: orgId! }, + { orgId: orgId || '' }, { skip: !orgId }, ) const groups = useMemo(() => { @@ -31,7 +30,7 @@ const TableGroupsFilter: FC = ({ })), 'label', ) - }, [data, value]) + }, [data]) return ( void @@ -68,7 +68,7 @@ const TableTagFilter: FC = ({ }), }} onChange={(v) => { - onChangeStrategy(v!.value) + if (v) onChangeStrategy(v.value) }} value={{ label: diff --git a/frontend/web/routes.js b/frontend/web/routes.js index fc5147d4c653..b987dc31a50c 100644 --- a/frontend/web/routes.js +++ b/frontend/web/routes.js @@ -10,7 +10,7 @@ import UsersPage from './components/pages/UsersPage' import UserPage from './components/pages/UserPage' import UserIdPage from './components/pages/UserIdPage' import IntegrationsPage from './components/pages/IntegrationsPage' -import FlagsPage from './components/pages/FeaturesPage' +import FlagsPage from './components/pages/features' import SegmentsPage from './components/pages/SegmentsPage' import OrganisationSettingsPage from './components/pages/organisation-settings' import AccountSettingsPage from './components/pages/AccountSettingsPage' diff --git a/frontend/web/styles/components/_feature-row-skeleton.scss b/frontend/web/styles/components/_feature-row-skeleton.scss new file mode 100644 index 000000000000..fbb660fde2c3 --- /dev/null +++ b/frontend/web/styles/components/_feature-row-skeleton.scss @@ -0,0 +1,64 @@ +.skeleton-row { + padding: 0 16px; +} + +// Match parent specificity: .panel .panel-content .search-list +.panel .panel-content .search-list--skeleton { + border-top-left-radius: 0; + border-top-right-radius: 0; +} + +// Override first-child border removal for skeletons +.panel .panel-content .search-list--skeleton .skeleton-row:first-child { + border-top: 1px solid $panel-border-color; + border-top-left-radius: 0; + border-top-right-radius: 0; +} + +// Dark mode support +.dark .panel .panel-content .search-list--skeleton .skeleton-row:first-child { + border-top: 1px solid $panel-border-color-dark; +} + +.skeleton { + background: linear-gradient(90deg, $bg-light300 25%, $bg-light500 50%, $bg-light300 75%); + background-size: 200% 100%; + animation: shimmer 1.5s infinite; + border-radius: 4px; + + .dark & { + background: linear-gradient( + 90deg, + $white-alpha-8 25%, + $white-alpha-16 50%, + $white-alpha-8 75% + ); + } + + &-text { + height: 16px; + } + + &-badge { + border-radius: 12px; + } + + &-circle { + border-radius: 50%; + } +} + +@keyframes shimmer { + 0% { + background-position: 200% 0; + } + 100% { + background-position: -200% 0; + } +} + +@media (prefers-reduced-motion: reduce) { + .skeleton { + animation: none; + } +} diff --git a/frontend/web/styles/components/_index.scss b/frontend/web/styles/components/_index.scss index f2793a9710da..d7ab0982b899 100644 --- a/frontend/web/styles/components/_index.scss +++ b/frontend/web/styles/components/_index.scss @@ -15,3 +15,4 @@ @import 'release-pipelines'; @import 'feature-pipeline-status'; @import 'button-dropdown'; +@import 'feature-row-skeleton'; diff --git a/frontend/web/styles/components/_list-item.scss b/frontend/web/styles/components/_list-item.scss index c27604dca387..cc024b25104b 100644 --- a/frontend/web/styles/components/_list-item.scss +++ b/frontend/web/styles/components/_list-item.scss @@ -3,6 +3,11 @@ min-height: $panel-list-item-min-height; transition: all 0.2s ease-in; color: $panel-list-item-color; + + &--toggling { + opacity: 0.6; + pointer-events: none; + } .btn-link { font-weight: bold; svg {