diff --git a/frontend/common/dispatcher/action-constants.js b/frontend/common/dispatcher/action-constants.js index c1c68ca7f9ed..6226d6e78faa 100644 --- a/frontend/common/dispatcher/action-constants.js +++ b/frontend/common/dispatcher/action-constants.js @@ -26,7 +26,6 @@ const Actions = Object.assign({}, require('./base/_action-constants'), { 'ENABLE_TWO_FACTOR': 'ENABLE_TWO_FACTOR', 'GET_CHANGE_REQUEST': 'GET_CHANGE_REQUEST', 'GET_ENVIRONMENT': 'GET_ENVIRONMENT', - 'GET_FEATURE_USAGE': 'GET_FEATURE_USAGE', 'GET_FLAGS': 'GET_FLAGS', 'GET_IDENTITY': 'GET_IDENTITY', 'GET_ORGANISATION': 'GET_ORGANISATION', diff --git a/frontend/common/dispatcher/app-actions.js b/frontend/common/dispatcher/app-actions.js index 20513ea9ad7b..cfabc76b3fd6 100644 --- a/frontend/common/dispatcher/app-actions.js +++ b/frontend/common/dispatcher/app-actions.js @@ -199,15 +199,6 @@ const AppActions = Object.assign({}, require('./base/_app-actions'), { projectId, }) }, - getFeatureUsage(projectId, environmentId, flag, period) { - Dispatcher.handleViewAction({ - actionType: Actions.GET_FEATURE_USAGE, - environmentId, - flag, - period, - projectId, - }) - }, getFeatures( projectId, environmentId, diff --git a/frontend/common/providers/FeatureListProvider.js b/frontend/common/providers/FeatureListProvider.js index 3b8738bcf141..a655c37ba610 100644 --- a/frontend/common/providers/FeatureListProvider.js +++ b/frontend/common/providers/FeatureListProvider.js @@ -27,7 +27,6 @@ const FeatureListProvider = class extends React.Component { maxFeaturesAllowed: ProjectStore.getMaxFeaturesAllowed(), projectFlags: FeatureListStore.getProjectFlags(), totalFeatures: ProjectStore.getTotalFeatures(), - usageData: FeatureListStore.getFeatureUsage(), }) }) this.listenTo(FeatureListStore, 'removed', (data) => { @@ -44,7 +43,6 @@ const FeatureListProvider = class extends React.Component { isLoading: FeatureListStore.isLoading, isSaving: FeatureListStore.isSaving, lastSaved: FeatureListStore.getLastSaved(), - usageData: FeatureListStore.getFeatureUsage(), }) this.props.onError && this.props.onError(FeatureListStore.error) }) diff --git a/frontend/common/services/useFeatureAnalytics.ts b/frontend/common/services/useFeatureAnalytics.ts new file mode 100644 index 000000000000..525133d3e1e7 --- /dev/null +++ b/frontend/common/services/useFeatureAnalytics.ts @@ -0,0 +1,90 @@ +import { Res } from 'common/types/responses' +import { Req } from 'common/types/requests' +import { service } from 'common/service' +import { sortBy } from 'lodash' +import moment from 'moment' +import range from 'lodash/range' + +export const featureAnalyticsService = service + .enhanceEndpoints({ addTagTypes: ['FeatureAnalytics'] }) + .injectEndpoints({ + endpoints: (builder) => ({ + getFeatureAnalytics: builder.query< + Res['featureAnalytics'], + Req['getFeatureAnalytics'] + >({ + providesTags: [{ id: 'LIST', type: 'FeatureAnalytics' }], + queryFn: async (query, baseQueryApi, extraOptions, baseQuery) => { + const responses = await Promise.all( + query.environment_ids.map((environment_id) => { + return baseQuery({ + url: `projects/${query.project_id}/features/${query.feature_id}/evaluation-data/?period=${query.period}&environment_id=${environment_id}`, + }) + }), + ) + + const error = responses.find((v) => !!v.error)?.error + const today = moment().startOf('day') + const startDate = moment(today).subtract(query.period - 1, 'days') + const preBuiltData: Res['featureAnalytics'] = [] + for ( + let date = startDate.clone(); + date.isSameOrBefore(today); + date.add(1, 'days') + ) { + const dayObj: Res['featureAnalytics'][number] = { + day: date.format('Do MMM'), + } + query.environment_ids.forEach((envId) => { + dayObj[envId] = 0 + }) + preBuiltData.push(dayObj) + } + + responses.forEach((response, i) => { + const environment_id = query.environment_ids[i] + + response.data.forEach((entry: Res['featureAnalytics'][number]) => { + const date = moment(entry.day).format('Do MMM') + const dayEntry = preBuiltData.find((d) => d.day === date) + if (dayEntry) { + dayEntry[environment_id] = entry.count // Set count for specific environment ID + } + }) + }) + return { + data: error ? [] : preBuiltData, + error, + } + }, + }), + // END OF ENDPOINTS + }), + }) + +export async function getFeatureAnalytics( + store: any, + data: Req['getFeatureAnalytics'], + options?: Parameters< + typeof featureAnalyticsService.endpoints.getFeatureAnalytics.initiate + >[1], +) { + return store.dispatch( + featureAnalyticsService.endpoints.getFeatureAnalytics.initiate( + data, + options, + ), + ) +} +// END OF FUNCTION_EXPORTS + +export const { + useGetFeatureAnalyticsQuery, + // END OF EXPORTS +} = featureAnalyticsService + +/* Usage examples: +const { data, isLoading } = useGetFeatureAnalyticsQuery({ id: 2 }, {}) //get hook +const [createFeatureAnalytics, { isLoading, data, isSuccess }] = useCreateFeatureAnalyticsMutation() //create hook +featureAnalyticsService.endpoints.getFeatureAnalytics.select({id: 2})(store.getState()) //access data from any function +*/ diff --git a/frontend/common/types/requests.ts b/frontend/common/types/requests.ts index e37a97b4b556..006f1ae7d5c1 100644 --- a/frontend/common/types/requests.ts +++ b/frontend/common/types/requests.ts @@ -783,5 +783,11 @@ export type Req = { pipelineId: number name: string } + getFeatureAnalytics: { + project_id: string + feature_id: string + period: number + environment_ids: string[] + } // END OF TYPES } diff --git a/frontend/common/types/responses.ts b/frontend/common/types/responses.ts index fc7a46f390c0..b7a4e87e8580 100644 --- a/frontend/common/types/responses.ts +++ b/frontend/common/types/responses.ts @@ -1107,5 +1107,9 @@ export type Res = { releasePipeline: SingleReleasePipeline pipelineStages: PagedResponse featureCodeReferences: FeatureCodeReferences[] + featureAnalytics: { + day: string + [environmentId: string]: string | number + }[] // END OF TYPES } diff --git a/frontend/web/components/AuditLog.tsx b/frontend/web/components/AuditLog.tsx index 2a611adbe27e..afa870fe8b25 100644 --- a/frontend/web/components/AuditLog.tsx +++ b/frontend/web/components/AuditLog.tsx @@ -171,7 +171,6 @@ const AuditLog: FC = (props) => { color: Utils.getTagColour(colour), label: environment?.name, }} - className='chip--sm' /> ) : ( diff --git a/frontend/web/components/EnvironmentTagSelect.tsx b/frontend/web/components/EnvironmentTagSelect.tsx new file mode 100644 index 000000000000..d64ce5669378 --- /dev/null +++ b/frontend/web/components/EnvironmentTagSelect.tsx @@ -0,0 +1,123 @@ +import React, { FC, useMemo } from 'react' +import { useGetEnvironmentsQuery } from 'common/services/useEnvironment' +import { Props } from 'react-select/lib/Select' +import Tag from './tags/Tag' +import Utils from 'common/utils/utils' +import Button from './base/forms/Button' + +export type EnvironmentSelectType = Omit< + Partial, + 'value' | 'onChange' +> & { + projectId: string | number | undefined + value?: string[] | string | null + onChange: (value: string[] | string | undefined) => void + idField?: 'id' | 'api_key' + dataTest?: (value: { label: string }) => string + multiple?: boolean + allowEmpty?: boolean +} + +const EnvironmentSelect: FC = ({ + allowEmpty = false, + idField = 'api_key', + ignore, + multiple = false, + onChange, + projectId, + value, +}) => { + const { data } = useGetEnvironmentsQuery({ projectId: `${projectId}` }) + const environments = useMemo(() => { + return (data?.results || []) + ?.map((v) => ({ + label: v.name, + value: `${v[idField]}`, + })) + .filter((v) => { + if (ignore) { + return !ignore.includes(v.value) + } + return true + }) + }, [data?.results, ignore, idField]) + + const handleSelectAll = () => { + if (multiple) { + onChange(environments.map((env) => env.value)) + } + } + + const handleClearAll = () => { + if (multiple) { + onChange(allowEmpty ? [] : undefined) + } + } + + const selectedCount = Array.isArray(value) ? value.length : 0 + const allSelected = selectedCount === environments.length + + return ( +
+ + {environments.map((env, i) => ( + { + if (multiple) { + if (Array.isArray(value) && value.includes(env.value)) { + const newValue = value.filter((v) => v !== env.value) + onChange(allowEmpty && newValue.length === 0 ? [] : newValue) + } else { + onChange((value || []).concat([env.value])) + } + } else { + onChange( + value === env.value + ? allowEmpty + ? undefined + : value + : env.value, + ) + } + }} + className='mb-2 chip--xs' + /> + ))} + + {multiple && environments.length > 0 && ( +
+ {!allSelected && ( + + )} + {selectedCount > 1 && ( + + )} +
+ )} +
+ ) +} + +export default EnvironmentSelect diff --git a/frontend/web/components/ToggleChip.js b/frontend/web/components/ToggleChip.js deleted file mode 100644 index 96a81b75ce7c..000000000000 --- a/frontend/web/components/ToggleChip.js +++ /dev/null @@ -1,40 +0,0 @@ -import React from 'react' -import cx from 'classnames' -import Icon from './Icon' -import Utils from 'common/utils/utils' - -export default function (props) { - const colour = Utils.colour(props.color) - return ( - - - {props.active && } - - {props.children} - - ) -} diff --git a/frontend/web/components/ToggleChip.tsx b/frontend/web/components/ToggleChip.tsx new file mode 100644 index 000000000000..3098e69c9055 --- /dev/null +++ b/frontend/web/components/ToggleChip.tsx @@ -0,0 +1,51 @@ +import React, { FC, ReactNode } from 'react' +import cx from 'classnames' +import Icon from './Icon' +import Utils from 'common/utils/utils' + +export type ToggleChipProps = { + color?: string + active?: boolean + onClick?: () => void + className?: string + children?: ReactNode +} + +const ToggleChip: FC = ({ + active, + children, + className, + color, + onClick, +}) => { + const colour = Utils.colour(color) + return ( + + + {active && } + + {children} + + ) +} + +export default ToggleChip diff --git a/frontend/web/components/feature-page/FeatureNavTab/FeatureAnalytics.tsx b/frontend/web/components/feature-page/FeatureNavTab/FeatureAnalytics.tsx new file mode 100644 index 000000000000..6bac14571cfb --- /dev/null +++ b/frontend/web/components/feature-page/FeatureNavTab/FeatureAnalytics.tsx @@ -0,0 +1,128 @@ +import React, { FC, useState } from 'react' +import { sortBy } from 'lodash' +import Color from 'color' +import { + Bar, + BarChart, + CartesianGrid, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts' +import InfoMessage from 'components/InfoMessage' +import EnvironmentTagSelect from 'components/EnvironmentTagSelect' +import { useGetFeatureAnalyticsQuery } from 'common/services/useFeatureAnalytics' +import { useGetEnvironmentsQuery } from 'common/services/useEnvironment' +import Utils from 'common/utils/utils' + +type FlagAnalyticsType = { + projectId: string + featureId: string + defaultEnvironmentIds: string[] +} + +const FlagAnalytics: FC = ({ + defaultEnvironmentIds, + featureId, + projectId, +}) => { + const [environmentIds, setEnvironmentIds] = useState(defaultEnvironmentIds) + const { data, isLoading } = useGetFeatureAnalyticsQuery( + { + environment_ids: environmentIds, + feature_id: featureId, + period: 30, + project_id: projectId, + }, + { + skip: !environmentIds?.length || !featureId || !projectId, + }, + ) + const { data: environments } = useGetEnvironmentsQuery({ + projectId: `${projectId}`, + }) + + const handleEnvironmentChange = (value: string[] | string | undefined) => { + // If cleared (empty array or undefined), revert to default environment IDs + if (!value || (Array.isArray(value) && value.length === 0)) { + setEnvironmentIds(defaultEnvironmentIds) + } else { + setEnvironmentIds(value as string[]) + } + } + + return ( + <> + +
Flag events for last 30 days
+ + {isLoading && ( +
+ +
+ )} + {data && Array.isArray(data) && data.length > 0 && ( +
+ + + + + + + {sortBy(environmentIds, (id) => + environments?.results?.findIndex((env) => `${env.id}` === id), + ).map((id) => { + let index = environments?.results.findIndex( + (env) => `${env.id}` === id, + ) + if (index === -1) index = 0 + return ( + + ) + })} + + +
+ )} +
+ + The Flag Analytics data will be visible in the Dashboard between 30 + minutes and 1 hour after it has been collected.{' '} + + View docs + + + + ) +} + +export default FlagAnalytics diff --git a/frontend/web/components/feature-page/FeatureNavTab/FeatureAnalytics/FeatureAnalytics.container.tsx b/frontend/web/components/feature-page/FeatureNavTab/FeatureAnalytics/FeatureAnalytics.container.tsx deleted file mode 100644 index d013121cc2dd..000000000000 --- a/frontend/web/components/feature-page/FeatureNavTab/FeatureAnalytics/FeatureAnalytics.container.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import InfoMessage from 'components/InfoMessage' -import React from 'react' -import FeatureAnalyticsChart from './components/FeatureAnalyticsChart' - -export interface FlagAnalyticsData { - day: string - count: number -} - -interface FeatureAnalyticsProps { - usageData?: FlagAnalyticsData[] -} - -const FeatureAnalytics: React.FC = ({ usageData }) => { - return ( - <> - - {!!usageData &&
Flag events for last 30 days
} - {!usageData && ( -
- -
- )} - -
- - The Flag Analytics data will be visible in the Dashboard between 30 - minutes and 1 hour after it has been collected.{' '} - - View docs - - - - ) -} - -export default FeatureAnalytics diff --git a/frontend/web/components/feature-page/FeatureNavTab/FeatureAnalytics/components/FeatureAnalyticsChart.tsx b/frontend/web/components/feature-page/FeatureNavTab/FeatureAnalytics/components/FeatureAnalyticsChart.tsx deleted file mode 100644 index f56e888a5ede..000000000000 --- a/frontend/web/components/feature-page/FeatureNavTab/FeatureAnalytics/components/FeatureAnalyticsChart.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import React, { useMemo } from 'react' -import { - Bar, - BarChart, - CartesianGrid, - ResponsiveContainer, - Tooltip as RechartsTooltip, - XAxis, - YAxis, -} from 'recharts' -import Button from 'components/base/forms/Button' -import { FlagAnalyticsData } from 'components/feature-page/FeatureNavTab/FeatureAnalytics/FeatureAnalytics.container' - -interface FeatureAnalyticsChartProps { - usageData?: FlagAnalyticsData[] -} - -const FeatureAnalyticsChart: React.FC = ({ - usageData, -}) => { - const aggregatedData = useMemo(() => { - return usageData?.length - ? Object.values( - usageData.reduce( - (acc: Record, item) => { - const day = item.day - if (!acc[day]) { - acc[day] = { - count: 0, - day: day, - } - } - acc[day].count += item.count || 0 - return acc - }, - {}, - ), - ) - : [] - }, [usageData]) - - return aggregatedData?.length ? ( - - - - - - - - - - ) : ( -
- There has been no activity for this flag within the past month. Find out - about Flag Analytics{' '} - - . -
- ) -} - -FeatureAnalyticsChart.displayName = 'FeatureAnalyticsChart' - -export default React.memo(FeatureAnalyticsChart) diff --git a/frontend/web/components/modals/CreateFlag.js b/frontend/web/components/modals/CreateFlag.js index 4ed23fecbcd5..a39dae74d814 100644 --- a/frontend/web/components/modals/CreateFlag.js +++ b/frontend/web/components/modals/CreateFlag.js @@ -37,6 +37,7 @@ import { saveFeatureWithValidation } from 'components/saveFeatureWithValidation' import PlanBasedBanner from 'components/PlanBasedAccess' import FeatureHistory from 'components/FeatureHistory' import WarningMessage from 'components/WarningMessage' +import FeatureAnalytics from 'components/feature-page/FeatureNavTab/FeatureAnalytics' import { getPermission } from 'common/services/usePermission' import { getChangeRequests } from 'common/services/useChangeRequest' import FeatureHealthTabContent from 'components/feature-health/FeatureHealthTabContent' @@ -46,7 +47,6 @@ import FeaturePipelineStatus from 'components/release-pipelines/FeaturePipelineS import { FlagValueFooter } from './FlagValueFooter' import FeatureInPipelineGuard from 'components/release-pipelines/FeatureInPipelineGuard' import FeatureCodeReferencesContainer from 'components/feature-page/FeatureNavTab/CodeReferences/FeatureCodeReferencesContainer' -import FeatureAnalytics from 'components/feature-page/FeatureNavTab/FeatureAnalytics/FeatureAnalytics.container' import BetaFlag from 'components/BetaFlag' const CreateFlag = class extends Component { @@ -185,13 +185,6 @@ const CreateFlag = class extends Component { this.focusTimeout = null }, 500) } - if ( - !Project.disableAnalytics && - this.props.projectFlag && - this.props.environmentFlag - ) { - this.getFeatureUsage() - } if (Utils.getPlansPermission('METADATA')) { getSupportedContentType(getStore(), { organisation_id: AccountStore.getOrganisation().id, @@ -288,16 +281,6 @@ const CreateFlag = class extends Component { }) } - getFeatureUsage = () => { - if (this.props.environmentFlag) { - AppActions.getFeatureUsage( - this.props.projectId, - this.props.environmentFlag.environment, - this.props.projectFlag.id, - this.state.period, - ) - } - } save = (func, isSaving) => { const { environmentFlag, @@ -911,7 +894,7 @@ const CreateFlag = class extends Component { }} > {( - { error, isSaving, usageData }, + { error, isSaving }, { createChangeRequest, createFlag, @@ -1820,7 +1803,11 @@ const CreateFlag = class extends Component {
diff --git a/frontend/web/components/pages/AuditLogPage.tsx b/frontend/web/components/pages/AuditLogPage.tsx index 0bef319be815..1bcca10e2780 100644 --- a/frontend/web/components/pages/AuditLogPage.tsx +++ b/frontend/web/components/pages/AuditLogPage.tsx @@ -9,11 +9,7 @@ import PageTitle from 'components/PageTitle' import Tag from 'components/tags/Tag' import { featureDescriptions } from 'components/PlanBasedAccess' import { useRouteContext } from 'components/providers/RouteContext' - -interface RouteParams { - environmentId: string - projectId: string -} +import EnvironmentTagSelect from 'components/EnvironmentTagSelect' const AuditLogPage: FC = () => { const history = useHistory() @@ -68,32 +64,13 @@ const AuditLogPage: FC = () => { environmentId={environment} projectId={projectId} searchPanel={ - - {({ project }: { project: Project }) => ( - - {project && - project.environments && - project.environments.map((env, i) => ( - { - setEnvironment( - `${environment}` === `${env.id}` - ? undefined - : env.id, - ) - }} - className='mr-2 mb-2' - /> - ))} - - )} - + } /> diff --git a/frontend/web/components/tags/Tag.tsx b/frontend/web/components/tags/Tag.tsx index d740ca51248b..8051480d31ac 100644 --- a/frontend/web/components/tags/Tag.tsx +++ b/frontend/web/components/tags/Tag.tsx @@ -82,6 +82,7 @@ const Tag: FC = ({ if (!hideNames && !!onClick) { return ( { diff --git a/frontend/web/styles/components/_chip.scss b/frontend/web/styles/components/_chip.scss index 6369cc9c89f0..2fe1bc1736d8 100644 --- a/frontend/web/styles/components/_chip.scss +++ b/frontend/web/styles/components/_chip.scss @@ -96,9 +96,17 @@ border-radius: $border-radius; font-size: $font-caption-sm; line-height: $line-height-xsm; - height: 20px; + height: 24px; padding: 0px 6px; margin-right: 5px; + svg { + width: 14px; + height: 14px; + } + .icon-check { + width: 14px; + height: 14px; + } .chip-svg-icon { display: block; width: 12px;