diff --git a/frontend/.claude/context/testing-against-pr-backend.md b/frontend/.claude/context/testing-against-pr-backend.md new file mode 100644 index 000000000000..36b08ece2d71 --- /dev/null +++ b/frontend/.claude/context/testing-against-pr-backend.md @@ -0,0 +1,72 @@ +# Testing the Frontend Against an Unmerged Backend PR + +When a backend PR adds or changes API behaviour, you can run that exact backend +locally — without checking out or building the API — and point your local +frontend dev server at it. CI publishes a Docker image for every PR. + +## Where the images live + +Every PR's CI builds and **pushes** multi-arch (amd64 + arm64) images to GHCR, +tagged `pr-`. They are **public** — no `docker login` needed. + +| Image | Tag | Contents | +| --- | --- | --- | +| `ghcr.io/flagsmith/flagsmith` | `pr-` | Unified image — API + bundled frontend, serves on `:8000` | +| `ghcr.io/flagsmith/flagsmith-api` | `pr-` | API only | + +Use the **unified** image with the repo's root `docker-compose.yml`, which is +already wired for it (Postgres, migrations, API, task processor). + +Confirm an image exists before relying on it: + +```bash +docker manifest inspect ghcr.io/flagsmith/flagsmith:pr- >/dev/null && echo pullable +``` + +## Run a PR backend + local frontend + +From the repo root, create a one-off compose override that swaps the three +`flagsmith` services onto the PR image (the root compose hardcodes the image, so +an override file is the clean way to repoint it): + +```bash +cat > docker-compose.pr.yml <<'EOF' +services: + migrate-db: + image: ghcr.io/flagsmith/flagsmith:pr- + flagsmith: + image: ghcr.io/flagsmith/flagsmith:pr- + flagsmith-task-processor: + image: ghcr.io/flagsmith/flagsmith:pr- +EOF + +docker compose -f docker-compose.yml -f docker-compose.pr.yml pull +docker compose -f docker-compose.yml -f docker-compose.pr.yml up +``` + +The API comes up on `localhost:8000`. Then run the frontend against it: + +```bash +cd frontend +ENV=local npm run dev # dev server on :3000, talks to the API on :8000 +``` + +`docker-compose.pr.yml` is throwaway — delete it (or keep it git-ignored) when +done. To switch PRs, change the tag and re-run `pull` + `up`. + +## Notes / gotchas + +- **Flagsmith-on-Flagsmith gates.** Backend features are often gated by an + OpenFeature flag (e.g. `feature_lifecycle`). The flag's default for a + self-hosted/local run comes from the baked-in + `api/integrations/flagsmith/data/environment.json`. Check it's enabled there; + if not, the gated endpoints/fields won't appear. +- **Data, not just code.** Endpoints may need seeded data to return anything + meaningful (e.g. code references, stale tags, usage). The image gives you the + behaviour; you still have to create the data through the UI/API. +- **Inspect what the PR actually built.** Job logs show the pushed tag: + `gh run view --job --log | grep -i 'tags:'`. The `pr-` convention + is stable, but the logs are the source of truth. +- **Contract-checking without running.** To verify the frontend matches a + backend PR's contract, read its diff directly instead of (or before) running: + `gh api repos/Flagsmith/flagsmith/contents/?ref= --jq .content | base64 -d`. diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index 49d43e6ea0d7..b87a96b225b3 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -32,6 +32,7 @@ For detailed guidance on specific topics: - **Quick Start**: `.claude/context/quick-reference.md` - Common tasks, commands, patterns - **API Integration**: `.claude/context/api-integration.md` - Adding endpoints, RTK Query (required reading for API work) - **Backend**: `.claude/context/backend-integration.md` - Finding endpoints, backend structure +- **Testing vs PR backend**: `.claude/context/testing-against-pr-backend.md` - Run an unmerged backend PR's Docker image locally and point the dev server at it - **UI Patterns**: `.claude/context/ui-patterns.md` - Tables, tabs, modals, confirmations (required reading for UI work) - **Feature Flags**: `.claude/context/feature-flags/` - Using Flagsmith flags (optional, only when requested) - **Code Patterns**: `.claude/context/patterns/` - Complete examples, best practices diff --git a/frontend/common/lifecycleEnvironmentSlice.ts b/frontend/common/lifecycleEnvironmentSlice.ts new file mode 100644 index 000000000000..0df194402092 --- /dev/null +++ b/frontend/common/lifecycleEnvironmentSlice.ts @@ -0,0 +1,26 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit' + +type LifecycleEnvironmentState = { + // Maps a project id to the environment id selected for lifecycle analysis. + byProject: Record +} + +const initialState: LifecycleEnvironmentState = { + byProject: {}, +} + +const lifecycleEnvironmentSlice = createSlice({ + initialState, + name: 'lifecycleEnvironment', + reducers: { + setLifecycleEnvironment( + state, + action: PayloadAction<{ projectId: number; environmentId: number }>, + ) { + state.byProject[action.payload.projectId] = action.payload.environmentId + }, + }, +}) + +export const { setLifecycleEnvironment } = lifecycleEnvironmentSlice.actions +export default lifecycleEnvironmentSlice.reducer diff --git a/frontend/common/services/useProjectFlag.ts b/frontend/common/services/useProjectFlag.ts index 40e93ad4c780..d6a252a34d9d 100644 --- a/frontend/common/services/useProjectFlag.ts +++ b/frontend/common/services/useProjectFlag.ts @@ -132,6 +132,18 @@ export const projectFlagService = service }), }), + getLifecycleStatusCounts: builder.query< + Res['lifecycleStatusCounts'], + Req['getLifecycleStatusCounts'] + >({ + providesTags: (_res, _meta, req) => [ + { id: req?.environment, type: 'ProjectFlag' }, + ], + query: ({ environment }) => ({ + url: `environments/${environment}/feature-lifecycle-counts/`, + }), + }), + getProjectFlag: builder.query({ providesTags: (res) => [{ id: res?.id, type: 'ProjectFlag' }], query: (query: Req['getProjectFlag']) => ({ @@ -284,6 +296,7 @@ export const { useAddFlagOwnersMutation, useCreateProjectFlagMutation, useGetFeatureListQuery, + useGetLifecycleStatusCountsQuery, useGetProjectFlagQuery, useGetProjectFlagsQuery, useRemoveFlagGroupOwnersMutation, diff --git a/frontend/common/store.ts b/frontend/common/store.ts index 80045546fb5a..025632a08eb4 100644 --- a/frontend/common/store.ts +++ b/frontend/common/store.ts @@ -14,10 +14,12 @@ import storage from 'redux-persist/lib/storage' import { Persistor } from 'redux-persist/es/types' import { service } from './service' import selectedOrganisationReducer from './selectedOrganisationSlice' +import lifecycleEnvironmentReducer from './lifecycleEnvironmentSlice' // END OF IMPORTS const createStore = () => { const reducer = combineReducers({ [service.reducerPath]: service.reducer, + lifecycleEnvironment: lifecycleEnvironmentReducer, selectedOrganisation: selectedOrganisationReducer, // END OF REDUCERS }) diff --git a/frontend/common/types/requests.ts b/frontend/common/types/requests.ts index 8617803fbcfb..23ba64cf82b3 100644 --- a/frontend/common/types/requests.ts +++ b/frontend/common/types/requests.ts @@ -32,6 +32,7 @@ import { FlagsmithValue, TagStrategy, FeatureType, + LifecycleStage, } from './responses' import { UtmsType } from './utms' @@ -395,6 +396,10 @@ export type Req = { group_owners?: number[] sort_field?: string sort_direction?: SortOrder + lifecycle_stage?: LifecycleStage + } + getLifecycleStatusCounts: { + environment: number } getProjectFlag: { project: number; id: number } getRolesPermissionUsers: { organisation_id: number; role_id: number } diff --git a/frontend/common/types/responses.ts b/frontend/common/types/responses.ts index cd2815e0a1f8..d7149d5a3eee 100644 --- a/frontend/common/types/responses.ts +++ b/frontend/common/types/responses.ts @@ -741,8 +741,19 @@ export type ProjectFlag = { last_successful_repository_scanned_at: string last_feature_found_at: string }[] + lifecycle_stage?: LifecycleStage | null } +export type LifecycleStage = + | 'new' + | 'live' + | 'permanent' + | 'stale' + | 'needs_monitoring' + | 'to_remove' + +export type LifecycleStatusCounts = Record + export type FeatureListProviderData = { projectFlags: ProjectFlag[] | null environmentFlags: Record | undefined @@ -1269,6 +1280,7 @@ export type Res = { rolePermission: PagedResponse projectFlags: PagedResponse projectFlag: ProjectFlag + lifecycleStatusCounts: LifecycleStatusCounts identityFeatureStatesAll: IdentityFeatureState[] createRolesPermissionUsers: RolePermissionUser rolesPermissionUsers: PagedResponse diff --git a/frontend/web/components/pages/feature-lifecycle/FeatureLifecyclePage.tsx b/frontend/web/components/pages/feature-lifecycle/FeatureLifecyclePage.tsx index 816dfd168b31..bba95a7f8be5 100644 --- a/frontend/web/components/pages/feature-lifecycle/FeatureLifecyclePage.tsx +++ b/frontend/web/components/pages/feature-lifecycle/FeatureLifecyclePage.tsx @@ -1,30 +1,29 @@ -import React, { FC, useCallback, useEffect, useMemo, useState } from 'react' +import React, { FC, useCallback, useMemo, useState } from 'react' import { useParams } from 'react-router-dom' import { useRouteContext } from 'components/providers/RouteContext' import { usePageTracking } from 'common/hooks/usePageTracking' -import { useProjectEnvironments } from 'common/hooks/useProjectEnvironments' import { hasActiveFilters } from 'common/utils/featureFilterParams' import PageTitle from 'components/PageTitle' import Icon from 'components/icons/Icon' import Button from 'components/base/forms/Button' -import EnvironmentSelect from 'components/EnvironmentTagSelect' import CreateFlagModal from 'components/modals/create-feature' import LifecycleSidebar from './components/LifecycleSidebar' -import EvaluationChecker from './components/EvaluationChecker' import NewSection from './components/NewSection' import LiveSection from './components/LiveSection' import PermanentSection from './components/PermanentSection' import StaleSection from './components/StaleSection' import MonitorSection from './components/MonitorSection' import RemoveSection from './components/RemoveSection' -import { useLifecycleData } from './hooks/useLifecycleData' -import { useEvaluationCounts } from './hooks/useEvaluationCounts' +import { useLifecycleEnvironment } from './hooks/useLifecycleEnvironment' +import { + useLifecycleCounts, + useLifecycleSectionFlags, +} from './hooks/useLifecycleData' import { DEFAULT_FILTER_STATE, MONITOR_TOOLTIP, SECTIONS, STALE_TOOLTIP, - buildPeriodOptions, } from './constants' import type { Section } from './types' import type { FilterState } from 'common/types/featureFilters' @@ -46,21 +45,11 @@ function useSectionParam(): Section { const FeatureLifecyclePage: FC = () => { const routeContext = useRouteContext() - const projectId = routeContext.projectId as string - const { environments } = useProjectEnvironments(projectId) - const defaultEnvironmentApiKey = environments[0]?.api_key - - const allEnvironmentIds = useMemo( - () => environments.map((e) => `${e.id}`), - [environments], - ) - const [selectedEnvironments, setSelectedEnvironments] = useState([]) + const projectId = String(routeContext.projectId) + const projectIdNum = Number(projectId) - useEffect(() => { - if (allEnvironmentIds.length > 0 && selectedEnvironments.length === 0) { - setSelectedEnvironments(allEnvironmentIds) - } - }, [allEnvironmentIds, selectedEnvironments.length]) + const { environmentId, setEnvironmentId } = + useLifecycleEnvironment(projectIdNum) const [filters, setFilters] = useState(DEFAULT_FILTER_STATE) const handleFilterChange = useCallback( @@ -76,59 +65,31 @@ const FeatureLifecyclePage: FC = () => { (s) => s.key === section, ) as (typeof SECTIONS)[number] - const [monitorPeriod, setMonitorPeriod] = useState(1) - const [removePeriod, setRemovePeriod] = useState(7) + const { counts, isLoading: isLoadingCounts } = useLifecycleCounts({ + environmentId, + }) - // Central data hook — 2 API calls, all filtering done here const { - counts, error, - isLoading, - liveFlags, - newFlags, - permanentFlags, - staleFlags, - staleNoCodeFlags, - } = useLifecycleData({ - environmentApiKey: defaultEnvironmentApiKey, + flags, + isLoading: isLoadingFlags, + } = useLifecycleSectionFlags({ + environmentId, filters, - projectId, - }) - - // Monitor evaluation counts (short period — "has evaluation within") - const { - handleEvaluationResult: handleMonitorResult, - isCheckingEvaluations: isCheckingMonitor, - monitorCount, - monitorFlags, - } = useEvaluationCounts({ - period: monitorPeriod, - selectedEnvironments, - staleNoCodeFlags, - }) - - // Remove evaluation counts (longer period — "no evaluations in") - const { - handleEvaluationResult: handleRemoveResult, - isCheckingEvaluations: isCheckingRemove, - removeCount, - removeFlags, - } = useEvaluationCounts({ - period: removePeriod, - selectedEnvironments, - staleNoCodeFlags, + projectId: projectIdNum, + section, }) usePageTracking({ context: { organisationId: routeContext.organisationId, - projectId, + projectId: projectIdNum, }, pageName: 'CLEANUP', saveToStorage: false, }) - if (!defaultEnvironmentApiKey) { + if (!environmentId) { return (
@@ -137,125 +98,45 @@ const FeatureLifecyclePage: FC = () => { } const filterProps = { + error, filters, + flags, hasFilters, + isLoading: isLoadingFlags, onClearFilters: clearFilters, onFilterChange: handleFilterChange, - projectId, + projectId: projectIdNum, } const renderSection = () => { switch (section) { case 'new': - return ( - - ) + return case 'live': - return ( - - ) + return case 'permanent': - return ( - - ) + return case 'stale': - return ( - - ) + return case 'monitor': - return ( - - ) + return case 'remove': - return ( - - ) + return default: return null } } - const activePeriod = section === 'monitor' ? monitorPeriod : removePeriod - const setActivePeriod = - section === 'monitor' ? setMonitorPeriod : setRemovePeriod - const periodPrefix = - section === 'monitor' ? 'Evaluated within' : 'No evaluations in' - const periodOptions = buildPeriodOptions(periodPrefix) - return (
- {/* Hidden evaluators — monitor period */} - {staleNoCodeFlags.map((flag) => - selectedEnvironments.map((envId) => ( - - )), - )} - {/* Hidden evaluators — remove period */} - {staleNoCodeFlags.map((flag) => - selectedEnvironments.map((envId) => ( - - )), - )}
@@ -269,7 +150,7 @@ const FeatureLifecyclePage: FC = () => { openModal( 'New Feature', , 'side-modal create-feature-modal', @@ -311,35 +192,6 @@ const FeatureLifecyclePage: FC = () => { )}
- {(section === 'monitor' || section === 'remove') && ( - <> -
-