diff --git a/frontend/src/lib/components/HistoryList/HistoryList.scss b/frontend/src/lib/components/ActivityLog/ActivityLog.scss similarity index 89% rename from frontend/src/lib/components/HistoryList/HistoryList.scss rename to frontend/src/lib/components/ActivityLog/ActivityLog.scss index f84da52952e..78ae551da3b 100644 --- a/frontend/src/lib/components/HistoryList/HistoryList.scss +++ b/frontend/src/lib/components/ActivityLog/ActivityLog.scss @@ -1,7 +1,7 @@ @import '~/vars.scss'; -.history-list { - .history-list-row { +.activity-log { + .activity-log-row { display: flex; margin-top: $default_spacing; diff --git a/frontend/src/lib/components/ActivityLog/ActivityLog.stories.tsx b/frontend/src/lib/components/ActivityLog/ActivityLog.stories.tsx new file mode 100644 index 00000000000..3566a532204 --- /dev/null +++ b/frontend/src/lib/components/ActivityLog/ActivityLog.stories.tsx @@ -0,0 +1,44 @@ +import React from 'react' +import { featureFlagsActivityResponseJson } from 'lib/components/ActivityLog/__mocks__/activityLogMocks' +import { mswDecorator } from '~/mocks/browser' +import { ComponentMeta } from '@storybook/react' +import { ActivityLog } from 'lib/components/ActivityLog/ActivityLog' +import { flagActivityDescriber } from 'scenes/feature-flags/activityDescriptions' + +export default { + title: 'Components/ActivityLog', + component: ActivityLog, + decorators: [ + mswDecorator({ + get: { + '/api/projects/@current/feature_flags/5/activity': (_, __, ctx) => [ + ctx.delay(86400000), + ctx.status(200), + ctx.json({ results: [] }), + ], + '/api/projects/@current/feature_flags/6/activity': (_, __, ctx) => [ + ctx.delay(1000), + ctx.status(200), + ctx.json({ results: [] }), + ], + '/api/projects/@current/feature_flags/7/activity': (_, __, ctx) => [ + ctx.delay(1000), + ctx.status(200), + ctx.json({ results: featureFlagsActivityResponseJson }), + ], + }, + }), + ], +} as ComponentMeta + +export function WithData(): JSX.Element { + return +} + +export function WithNoData(): JSX.Element { + return +} + +export function Timeout(): JSX.Element { + return +} diff --git a/frontend/src/lib/components/ActivityLog/ActivityLog.tsx b/frontend/src/lib/components/ActivityLog/ActivityLog.tsx new file mode 100644 index 00000000000..4c761b6a350 --- /dev/null +++ b/frontend/src/lib/components/ActivityLog/ActivityLog.tsx @@ -0,0 +1,69 @@ +import React from 'react' +import { ProfilePicture } from 'lib/components/ProfilePicture' +import { TZLabel } from 'lib/components/TimezoneAware' +import { useValues } from 'kea' +import './ActivityLog.scss' +import { activityLogLogic } from 'lib/components/ActivityLog/activityLogLogic' +import { Skeleton } from 'antd' +import { Describer } from 'lib/components/ActivityLog/humanizeActivity' + +export interface ActivityLogProps { + scope: 'FeatureFlag' + // if no id is provided, the list is not scoped by id and shows all activity ordered by time + id?: number + describer?: Describer +} + +const Empty = (): JSX.Element =>
There is no history for this item
+ +const SkeletonLog = (): JSX.Element => { + return ( +
+ +
+ +
+
+ ) +} + +const Loading = (): JSX.Element => { + return ( + <> + + + + + + ) +} + +export const ActivityLog = ({ scope, id, describer }: ActivityLogProps): JSX.Element | null => { + const logic = activityLogLogic({ scope, id, describer }) + const { activity, activityLoading } = useValues(logic) + return ( +
+ {activityLoading ? ( + + ) : activity.length > 0 ? ( + activity.map((logItem, index) => { + return ( +
+ +
+
+ {logItem.name ?? 'unknown user'} {logItem.description} +
+
+ +
+
+
+ ) + }) + ) : ( + + )} +
+ ) +} diff --git a/frontend/src/lib/components/ActivityLog/__mocks__/activityLogMocks.ts b/frontend/src/lib/components/ActivityLog/__mocks__/activityLogMocks.ts new file mode 100644 index 00000000000..72f7ab17624 --- /dev/null +++ b/frontend/src/lib/components/ActivityLog/__mocks__/activityLogMocks.ts @@ -0,0 +1,51 @@ +import { ActivityScope, ActivityLogItem } from 'lib/components/ActivityLog/humanizeActivity' + +export const featureFlagsActivityResponseJson: ActivityLogItem[] = [ + { + user: { first_name: 'kunal', email: 'kunal@posthog.com' }, + activity: 'created', + scope: ActivityScope.FEATURE_FLAG, + item_id: '7', + detail: { + changes: null, + name: 'test flag', + }, + created_at: '2022-02-05T16:28:39.594Z', + }, + { + user: { first_name: 'eli', email: 'eli@posthog.com' }, + activity: 'updated', + scope: ActivityScope.FEATURE_FLAG, + item_id: '7', + detail: { + changes: [ + { + type: 'FeatureFlag', + action: 'changed', + field: 'name', + after: 'this is what was added', + }, + ], + name: 'test flag', + }, + created_at: '2022-02-06T16:28:39.594Z', + }, + { + user: { first_name: 'guido', email: 'guido@posthog.com' }, + activity: 'updated', + scope: ActivityScope.FEATURE_FLAG, + item_id: '7', + detail: { + changes: [ + { + type: 'FeatureFlag', + action: 'changed', + field: 'filters', + after: { filter: 'info' }, + }, + ], + name: 'test flag', + }, + created_at: '2022-02-08T16:28:39.594Z', + }, +] diff --git a/frontend/src/lib/components/ActivityLog/activityLogLogic.test.tsx b/frontend/src/lib/components/ActivityLog/activityLogLogic.test.tsx new file mode 100644 index 00000000000..65818a851c3 --- /dev/null +++ b/frontend/src/lib/components/ActivityLog/activityLogLogic.test.tsx @@ -0,0 +1,95 @@ +import { initKeaTests } from '~/test/init' +import { expectLogic } from 'kea-test-utils' +import { dayjs } from 'lib/dayjs' +import React from 'react' +import { useMocks } from '~/mocks/jest' +import { urls } from 'scenes/urls' +import { Link } from 'lib/components/Link' +import { ActivityScope, HumanizedActivityLogItem } from 'lib/components/ActivityLog/humanizeActivity' +import { activityLogLogic } from 'lib/components/ActivityLog/activityLogLogic' +import { featureFlagsActivityResponseJson } from 'lib/components/ActivityLog/__mocks__/activityLogMocks' +import { flagActivityDescriber } from 'scenes/feature-flags/activityDescriptions' + +const aHumanizedPageOfHistory: HumanizedActivityLogItem[] = [ + { + email: 'kunal@posthog.com', + name: 'kunal', + description: ( + <> + created the flag: test flag + + ), + created_at: dayjs('2022-02-05T16:28:39.594Z'), + }, + { + email: 'eli@posthog.com', + name: 'eli', + description: expect.anything(), // react fragment equality is odd here. tested in humanizeActivity.test.tsx + created_at: dayjs('2022-02-06T16:28:39.594Z'), + }, + { + email: 'guido@posthog.com', + name: 'guido', + description: expect.anything(), // react fragment equality is odd here. tested in humanizeActivity.test.tsx + created_at: dayjs('2022-02-08T16:28:39.594Z'), + }, +] + +describe('the activity log logic', () => { + let logic: ReturnType + + describe('when not scoped by ID', () => { + beforeEach(() => { + useMocks({ + get: { + '/api/projects/@current/feature_flags/activity/': { results: featureFlagsActivityResponseJson }, + }, + }) + initKeaTests() + logic = activityLogLogic({ scope: ActivityScope.FEATURE_FLAG, describer: flagActivityDescriber }) + logic.mount() + }) + + it('sets a key', () => { + expect(logic.key).toEqual('activity/FeatureFlag/all') + }) + + it('loads on mount', async () => { + await expectLogic(logic).toDispatchActions([logic.actionCreators.fetchActivity()]) + }) + + it('can load a page of activity', async () => { + await expectLogic(logic).toFinishAllListeners().toMatchValues({ + activityLoading: false, + activity: aHumanizedPageOfHistory, + }) + }) + }) + describe('when scoped by ID', () => { + beforeEach(() => { + useMocks({ + get: { + '/api/projects/@current/feature_flags/7/activity/': { results: featureFlagsActivityResponseJson }, + }, + }) + initKeaTests() + logic = activityLogLogic({ scope: ActivityScope.FEATURE_FLAG, id: 7, describer: flagActivityDescriber }) + logic.mount() + }) + + it('sets a key', () => { + expect(logic.key).toEqual('activity/FeatureFlag/7') + }) + + it('loads on mount', async () => { + await expectLogic(logic).toDispatchActions([logic.actionCreators.fetchActivity()]) + }) + + it('can load a page of activity', async () => { + await expectLogic(logic).toFinishAllListeners().toMatchValues({ + activityLoading: false, + activity: aHumanizedPageOfHistory, + }) + }) + }) +}) diff --git a/frontend/src/lib/components/ActivityLog/activityLogLogic.tsx b/frontend/src/lib/components/ActivityLog/activityLogLogic.tsx new file mode 100644 index 00000000000..5a461762f6c --- /dev/null +++ b/frontend/src/lib/components/ActivityLog/activityLogLogic.tsx @@ -0,0 +1,30 @@ +import { kea } from 'kea' +import api, { PaginatedResponse } from 'lib/api' +import { ActivityLogItem, humanize, HumanizedActivityLogItem } from 'lib/components/ActivityLog/humanizeActivity' +import { activityLogLogicType } from './activityLogLogicType' +import { ActivityLogProps } from 'lib/components/ActivityLog/ActivityLog' + +export const activityLogLogic = kea({ + path: (key) => ['lib', 'components', 'ActivityLog', 'activitylog', 'logic', key], + props: {} as ActivityLogProps, + key: ({ scope, id }) => `activity/${scope}/${id || 'all'}`, + loaders: ({ props }) => ({ + activity: [ + [] as HumanizedActivityLogItem[], + { + fetchActivity: async () => { + const url = props.id + ? `/api/projects/@current/feature_flags/${props.id}/activity` + : `/api/projects/@current/feature_flags/activity` + const apiResponse: PaginatedResponse = await api.get(url) + return humanize(apiResponse?.results, props.describer) + }, + }, + ], + }), + events: ({ actions }) => ({ + afterMount: () => { + actions.fetchActivity() + }, + }), +}) diff --git a/frontend/src/lib/components/ActivityLog/humanizeActivity.test.tsx b/frontend/src/lib/components/ActivityLog/humanizeActivity.test.tsx new file mode 100644 index 00000000000..0ed8fd862bb --- /dev/null +++ b/frontend/src/lib/components/ActivityLog/humanizeActivity.test.tsx @@ -0,0 +1,185 @@ +import { ActivityChange, ActivityLogItem, ActivityScope, humanize } from 'lib/components/ActivityLog/humanizeActivity' +import { render } from '@testing-library/react' +import { dayjs } from 'lib/dayjs' +import React from 'react' +import '@testing-library/jest-dom' +import { flagActivityDescriber } from 'scenes/feature-flags/activityDescriptions' + +const makeAPIItem = (name: string, activity: string, changes: ActivityChange[] | null = null): ActivityLogItem => ({ + user: { first_name: 'kunal', email: 'kunal@posthog.com' }, + activity, + scope: ActivityScope.FEATURE_FLAG, + item_id: '7', + detail: { + changes: changes, + name, + }, + created_at: '2022-02-05T16:28:39.594Z', +}) + +describe('humanizing the activity log', () => { + describe('humanizing feature flags', () => { + it('can handle creation', () => { + const apiItem = makeAPIItem('test created flag', 'created') + const actual = humanize([apiItem], flagActivityDescriber) + expect(actual).toEqual([ + { + email: 'kunal@posthog.com', + name: 'kunal', + description: expect.anything(), + created_at: dayjs('2022-02-05T16:28:39.594Z'), + }, + ]) + + expect(render(<>{actual[0].description}).container).toHaveTextContent( + 'created the flag: test created flag' + ) + }) + it('can handle deletion', () => { + const apiItem = makeAPIItem('test del flag', 'deleted') + const actual = humanize([apiItem], flagActivityDescriber) + expect(actual).toEqual([ + { + email: 'kunal@posthog.com', + name: 'kunal', + description: expect.anything(), + created_at: dayjs('2022-02-05T16:28:39.594Z'), + }, + ]) + + expect(render(<>{actual[0].description}).container).toHaveTextContent('deleted the flag: test del flag') + }) + it('can handle soft deletion', () => { + const apiItem = makeAPIItem('test flag', 'updated', [ + { + type: 'FeatureFlag', + action: 'changed', + field: 'deleted', + after: true, + }, + ]) + const actual = humanize([apiItem], flagActivityDescriber) + expect(actual).toEqual([ + { + email: 'kunal@posthog.com', + name: 'kunal', + description: expect.anything(), + created_at: dayjs('2022-02-05T16:28:39.594Z'), + }, + ]) + + expect(render(<>{actual[0].description}).container).toHaveTextContent('deleted the flag: test flag') + }) + it('can handle name change', () => { + const apiItem = makeAPIItem('test flag', 'updated', [ + { + type: 'FeatureFlag', + action: 'changed', + field: 'name', + before: 'potato', + after: 'tomato', + }, + ]) + const actual = humanize([apiItem], flagActivityDescriber) + expect(actual).toEqual([ + { + email: 'kunal@posthog.com', + name: 'kunal', + description: expect.anything(), + created_at: dayjs('2022-02-05T16:28:39.594Z'), + }, + ]) + + expect(render(<>{actual[0].description}).container).toHaveTextContent( + 'changed the description to "tomato" on test flag' + ) + }) + + it('can handle rollout percentage change', () => { + const apiItem = makeAPIItem('test flag', 'updated', [ + { + type: 'FeatureFlag', + action: 'changed', + field: 'rollout_percentage', + after: '36', + }, + ]) + const actual = humanize([apiItem], flagActivityDescriber) + expect(actual).toEqual([ + { + email: 'kunal@posthog.com', + name: 'kunal', + description: expect.anything(), + created_at: dayjs('2022-02-05T16:28:39.594Z'), + }, + ]) + + expect(render(<>{actual[0].description}).container).toHaveTextContent( + 'changed rollout percentage to 36% on test flag' + ) + }) + + it('can humanize more than one change', () => { + const apiItem = makeAPIItem('test flag', 'updated', [ + { + type: 'FeatureFlag', + action: 'changed', + field: 'rollout_percentage', + after: '36', + }, + { + type: 'FeatureFlag', + action: 'changed', + field: 'name', + after: 'strawberry', + }, + ]) + const actual = humanize([apiItem], flagActivityDescriber) + expect(actual).toEqual([ + { + email: 'kunal@posthog.com', + name: 'kunal', + description: expect.anything(), + created_at: dayjs('2022-02-05T16:28:39.594Z'), + }, + { + email: 'kunal@posthog.com', + name: 'kunal', + description: expect.anything(), + created_at: dayjs('2022-02-05T16:28:39.594Z'), + }, + ]) + + expect(render(<>{actual[0].description}).container).toHaveTextContent( + 'changed rollout percentage to 36% on test flag' + ) + expect(render(<>{actual[1].description}).container).toHaveTextContent( + 'changed the description to "strawberry" on test flag' + ) + }) + + it('can handle filter change - boolean value, no conditions', () => { + const apiItem = makeAPIItem('test flag', 'updated', [ + { + type: 'FeatureFlag', + action: 'changed', + field: 'filters', + after: { groups: [{ properties: [], rollout_percentage: 99 }], multivariate: null }, + }, + ]) + const actual = humanize([apiItem], flagActivityDescriber) + expect(actual).toEqual([ + { + email: 'kunal@posthog.com', + name: 'kunal', + description: expect.anything(), + created_at: dayjs('2022-02-05T16:28:39.594Z'), + }, + ]) + + expect(render(<>{actual[0].description}).container).toHaveTextContent( + 'changed the rollout percentage to 99% of all users on test flag' + ) + }) + }) +}) diff --git a/frontend/src/lib/components/ActivityLog/humanizeActivity.tsx b/frontend/src/lib/components/ActivityLog/humanizeActivity.tsx new file mode 100644 index 00000000000..8566e1f1aae --- /dev/null +++ b/frontend/src/lib/components/ActivityLog/humanizeActivity.tsx @@ -0,0 +1,64 @@ +import { dayjs } from 'lib/dayjs' + +export interface ActivityChange { + type: 'FeatureFlag' + action: 'changed' | 'created' | 'deleted' + field?: string + before?: string | Record | boolean + after?: string | Record | boolean +} + +export interface ActivityLogDetail { + changes: ActivityChange[] | null + name: string +} + +export interface ActivityUser { + email: string + first_name: string +} + +export enum ActivityScope { + FEATURE_FLAG = 'FeatureFlag', +} + +export interface ActivityLogItem { + user: ActivityUser + activity: string + created_at: string + scope: ActivityScope + item_id?: string + detail: ActivityLogDetail +} + +export interface HumanizedActivityLogItem { + email?: string + name?: string + description: string | JSX.Element + created_at: dayjs.Dayjs +} + +export type Describer = (logItem: ActivityLogItem) => (string | JSX.Element | null)[] + +export function humanize(results: ActivityLogItem[], describer?: Describer): HumanizedActivityLogItem[] { + if (!describer) { + // TODO make a default describer + return [] + } + + const logLines: HumanizedActivityLogItem[] = [] + + for (const logItem of results) { + for (const description of describer(logItem)) { + if (description !== null) { + logLines.push({ + email: logItem.user.email, + name: logItem.user.first_name, + description: description, + created_at: dayjs(logItem.created_at), + }) + } + } + } + return logLines +} diff --git a/frontend/src/lib/components/HistoryList/HistoryList.stories.tsx b/frontend/src/lib/components/HistoryList/HistoryList.stories.tsx deleted file mode 100644 index 16638d91e5a..00000000000 --- a/frontend/src/lib/components/HistoryList/HistoryList.stories.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import React from 'react' -import { HistoryList } from 'lib/components/HistoryList/HistoryList' -import { featureFlagsHistoryResponseJson } from 'lib/components/HistoryList/__mocks__/historyListMocks' -import { mswDecorator } from '~/mocks/browser' -import { ComponentMeta } from '@storybook/react' - -export default { - title: 'Components/History List', - component: HistoryList, - decorators: [ - mswDecorator({ - get: { - '/api/projects/@current/feature_flags/6/history': (_, __, ctx) => [ - ctx.delay(1000), - ctx.status(200), - ctx.json({ results: [] }), - ], - '/api/projects/@current/feature_flags/7/history': (_, __, ctx) => [ - ctx.delay(1000), - ctx.status(200), - ctx.json({ results: featureFlagsHistoryResponseJson }), - ], - }, - }), - ], -} as ComponentMeta - -export function WithData(): JSX.Element { - return -} - -export function WithNoData(): JSX.Element { - return -} diff --git a/frontend/src/lib/components/HistoryList/HistoryList.tsx b/frontend/src/lib/components/HistoryList/HistoryList.tsx deleted file mode 100644 index a8aec3aca16..00000000000 --- a/frontend/src/lib/components/HistoryList/HistoryList.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import React from 'react' -import { ProfilePicture } from 'lib/components/ProfilePicture' -import { TZLabel } from 'lib/components/TimezoneAware' -import { historyListLogic } from 'lib/components/HistoryList/historyListLogic' -import { useValues } from 'kea' -import './HistoryList.scss' -import { Spinner } from 'lib/components/Spinner/Spinner' - -export interface HistoryListProps { - type: 'FeatureFlag' - id: number -} - -const Empty = (): JSX.Element => ( -
-
There is no history for this item
-
-) - -const Loading = (): JSX.Element => ( -
- Loading history for this item -
-) - -export const HistoryList = ({ type, id }: HistoryListProps): JSX.Element | null => { - const logic = historyListLogic({ type, id }) - const { history, historyLoading } = useValues(logic) - - const rows = history.map((historyItem, index) => { - return ( -
- -
-
- {historyItem.name ?? 'unknown user'} {historyItem.description} -
-
- -
-
-
- ) - }) - - return
{rows && rows.length ? rows : historyLoading ? : }
-} diff --git a/frontend/src/lib/components/HistoryList/__mocks__/historyListMocks.ts b/frontend/src/lib/components/HistoryList/__mocks__/historyListMocks.ts deleted file mode 100644 index 8d6e81c2eac..00000000000 --- a/frontend/src/lib/components/HistoryList/__mocks__/historyListMocks.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { HistoryActions, HistoryListItem } from 'lib/components/HistoryList/historyListLogic' - -export const featureFlagsHistoryResponseJson: HistoryListItem[] = [ - { - email: 'kunal@posthog.com', - name: 'kunal', - action: HistoryActions.FEATURE_FLAG_CREATED, - detail: { - id: 7, - name: 'test flag', - }, - created_at: '2022-02-05T16:28:39.594Z', - }, - { - email: 'eli@posthog.com', - name: 'eli', - action: HistoryActions.FEATURE_FLAG_DESCRIPTION_CHANGED, - detail: { - id: 7, - to: 'this is what was added', - }, - created_at: '2022-02-06T16:28:39.594Z', - }, - { - email: 'guido@posthog.com', - name: 'guido', - action: HistoryActions.FEATURE_FLAG_FILTERS_CHANGED, - detail: { - id: 7, - to: "{ 'filter': 'info' }", - }, - created_at: '2022-02-08T16:28:39.594Z', - }, - { - email: 'paul@posthog.com', - name: 'paul', - action: HistoryActions.FEATURE_FLAG_ACTIVE_CHANGED, - detail: { - id: 7, - to: false, - }, - created_at: '2022-02-08T16:45:39.594Z', - }, -] diff --git a/frontend/src/lib/components/HistoryList/historyListLogic.test.tsx b/frontend/src/lib/components/HistoryList/historyListLogic.test.tsx deleted file mode 100644 index bea3ce6d793..00000000000 --- a/frontend/src/lib/components/HistoryList/historyListLogic.test.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import { - HistoryActions, - HistoryListItem, - historyListLogic, - HumanizedHistoryListItem, -} from 'lib/components/HistoryList/historyListLogic' -import { initKeaTests } from '~/test/init' -import { expectLogic } from 'kea-test-utils' -import { dayjs } from 'lib/dayjs' -import React from 'react' -import { useMocks } from '~/mocks/jest' - -const aHumanizedPageOfHistory: HumanizedHistoryListItem[] = [ - { - email: 'kunal@posthog.com', - name: 'kunal', - description: 'created the flag', - created_at: dayjs('2022-02-05T16:28:39.594Z'), - }, - { - email: 'eli@posthog.com', - name: 'eli', - description: 'changed the description of the flag to: this is what was added', - created_at: dayjs('2022-02-06T16:28:39.594Z'), - }, - { - email: 'guido@posthog.com', - name: 'guido', - description: ( - <> - changed the filters to {JSON.stringify({ filter: 'info' })} - - ), - created_at: dayjs('2022-02-08T16:28:39.594Z'), - }, -] - -const aPageOfHistory: HistoryListItem[] = [ - { - email: 'kunal@posthog.com', - name: 'kunal', - action: HistoryActions.FEATURE_FLAG_CREATED, - detail: { - id: 7, - name: 'test flag', - }, - created_at: '2022-02-05T16:28:39.594Z', - }, - { - email: 'eli@posthog.com', - name: 'eli', - action: HistoryActions.FEATURE_FLAG_DESCRIPTION_CHANGED, - detail: { - id: 7, - to: 'this is what was added', - }, - created_at: '2022-02-06T16:28:39.594Z', - }, - { - email: 'guido@posthog.com', - name: 'guido', - action: HistoryActions.FEATURE_FLAG_FILTERS_CHANGED, - detail: { - id: 7, - to: { filter: 'info' }, - }, - created_at: '2022-02-08T16:28:39.594Z', - }, -] - -describe('the history list logic', () => { - let logic: ReturnType - - beforeEach(() => { - useMocks({ - get: { - '/api/projects/@current/feature_flags/7/history/': { results: aPageOfHistory }, - }, - }) - initKeaTests() - logic = historyListLogic({ type: 'FeatureFlag', id: 7 }) - logic.mount() - }) - - it('sets a key', () => { - expect(logic.key).toEqual('history/FeatureFlag/7') - }) - - it.only('can load a page of history', async () => { - await expectLogic(logic).toFinishAllListeners().toMatchValues({ - historyLoading: false, - history: aHumanizedPageOfHistory, - }) - }) -}) diff --git a/frontend/src/lib/components/HistoryList/historyListLogic.tsx b/frontend/src/lib/components/HistoryList/historyListLogic.tsx deleted file mode 100644 index 874cdaba622..00000000000 --- a/frontend/src/lib/components/HistoryList/historyListLogic.tsx +++ /dev/null @@ -1,108 +0,0 @@ -import { kea } from 'kea' -import api, { PaginatedResponse } from 'lib/api' - -import { historyListLogicType } from './historyListLogicType' -import { dayjs } from 'lib/dayjs' -import React from 'react' -interface HistoryListLogicProps { - type: 'FeatureFlag' - id: number -} - -export enum HistoryActions { - FEATURE_FLAG_CREATED = 'FeatureFlag_created', - FEATURE_FLAG_DESCRIPTION_CHANGED = 'FeatureFlag_name_changed', - FEATURE_FLAG_IMPORTED = 'FeatureFlag_imported', - FEATURE_FLAG_FILTERS_CHANGED = 'FeatureFlag_filters_changed', - FEATURE_FLAG_SOFT_DELETED = 'FeatureFlag_deleted_added', - FEATURE_FLAG_ROLLOUT_PERCENTAGE_CHANGED = 'FeatureFlag_rollout_percentage_changed', - FEATURE_FLAG_ACTIVE_CHANGED = 'FeatureFlag_active_changed', - FEATURE_FLAG_KEY_CHANGED = 'FeatureFlag_key_changed', -} - -export interface HistoryDetail { - id?: string | number - key?: string - name?: string - filter?: string - to?: string | Record | boolean -} - -export interface HistoryListItem { - email?: string - name?: string - action: HistoryActions - detail: HistoryDetail - created_at: string -} - -export interface HumanizedHistoryListItem { - email?: string - name?: string - description: string | JSX.Element - created_at: dayjs.Dayjs -} - -const actionsMapping: { - [key in HistoryActions]: (detail: HistoryDetail) => string | JSX.Element -} = { - [HistoryActions.FEATURE_FLAG_CREATED]: () => `created the flag`, - [HistoryActions.FEATURE_FLAG_DESCRIPTION_CHANGED]: (detail) => - `changed the description of the flag to: ${detail.to}`, - [HistoryActions.FEATURE_FLAG_ACTIVE_CHANGED]: (detail) => (detail.to ? 'enabled the flag' : 'disabled the flag'), - [HistoryActions.FEATURE_FLAG_IMPORTED]: () => `imported the flag`, - [HistoryActions.FEATURE_FLAG_FILTERS_CHANGED]: function onChangedFilter(detail) { - return ( - <> - changed the filters to {JSON.stringify(detail.to)} - - ) - }, - [HistoryActions.FEATURE_FLAG_SOFT_DELETED]: () => `deleted the flag`, - [HistoryActions.FEATURE_FLAG_ROLLOUT_PERCENTAGE_CHANGED]: (detail) => `changed rollout percentage to ${detail.to}`, - [HistoryActions.FEATURE_FLAG_KEY_CHANGED]: (detail) => `changed the flag key to ${detail.to}`, -} - -function descriptionFrom(historyListItem: HistoryListItem): string | JSX.Element | null { - const mapping = actionsMapping[historyListItem.action] - return (mapping && mapping(historyListItem.detail)) || null -} - -function humanize(results: HistoryListItem[]): HumanizedHistoryListItem[] { - return (results || []).reduce((acc, historyListItem) => { - const humanized = descriptionFrom(historyListItem) - if (humanized !== null) { - acc.push({ - email: historyListItem.email, - name: historyListItem.name, - description: humanized, - created_at: dayjs(historyListItem.created_at), - }) - } - return acc - }, [] as HumanizedHistoryListItem[]) -} - -export const historyListLogic = kea>({ - path: (key) => ['lib', 'components', 'HistoryList', 'historyList', 'logic', key], - props: {} as HistoryListLogicProps, - key: ({ id, type }) => `history/${type}/${id}`, - loaders: ({ props }) => ({ - history: [ - [] as HumanizedHistoryListItem[], - { - fetchHistory: async () => { - const apiResponse: PaginatedResponse = await api.get( - `/api/projects/@current/feature_flags/${props.id}/history` - ) - return humanize(apiResponse?.results) - }, - }, - ], - }), - events: ({ actions }) => ({ - afterMount: () => { - actions.fetchHistory() - }, - }), -}) diff --git a/frontend/src/lib/constants.tsx b/frontend/src/lib/constants.tsx index 2cc01903113..1a5512d375a 100644 --- a/frontend/src/lib/constants.tsx +++ b/frontend/src/lib/constants.tsx @@ -115,6 +115,7 @@ export const FEATURE_FLAGS = { SESSION_CONSOLE: 'session-recording-console', // owner: @timgl AND_OR_FILTERING: 'and-or-filtering', // owner: @edscode PROJECT_HOMEPAGE: 'project-homepage', // owner: @rcmarron + FEATURE_FLAGS_ACTIVITY_LOG: '8545-ff-activity-log', // owner: @pauldambra } /** Which self-hosted plan's features are available with Cloud's "Standard" plan (aka card attached). */ diff --git a/frontend/src/scenes/feature-flags/FeatureFlags.tsx b/frontend/src/scenes/feature-flags/FeatureFlags.tsx index 7e9e557bcc4..82f7d400c8f 100644 --- a/frontend/src/scenes/feature-flags/FeatureFlags.tsx +++ b/frontend/src/scenes/feature-flags/FeatureFlags.tsx @@ -1,7 +1,7 @@ import React from 'react' -import { useValues, useActions } from 'kea' +import { useActions, useValues } from 'kea' import { featureFlagsLogic } from './featureFlagsLogic' -import { Input } from 'antd' +import { Input, Tabs } from 'antd' import { Link } from 'lib/components/Link' import { copyToClipboard, deleteWithUndo } from 'lib/utils' import { PlusOutlined } from '@ant-design/icons' @@ -13,24 +13,32 @@ import { urls } from 'scenes/urls' import stringWithWBR from 'lib/utils/stringWithWBR' import { teamLogic } from '../teamLogic' import { SceneExport } from 'scenes/sceneTypes' -import { LemonButton } from '../../lib/components/LemonButton' -import { LemonSpacer } from '../../lib/components/LemonRow' -import { LemonSwitch } from '../../lib/components/LemonSwitch/LemonSwitch' -import { LemonTable, LemonTableColumn, LemonTableColumns } from '../../lib/components/LemonTable' -import { More } from '../../lib/components/LemonButton/More' -import { createdAtColumn, createdByColumn } from '../../lib/components/LemonTable/columnUtils' +import { LemonButton } from 'lib/components/LemonButton' +import { LemonSpacer } from 'lib/components/LemonRow' +import { LemonSwitch } from 'lib/components/LemonSwitch/LemonSwitch' +import { LemonTable, LemonTableColumn, LemonTableColumns } from 'lib/components/LemonTable' +import { More } from 'lib/components/LemonButton/More' +import { createdAtColumn, createdByColumn } from 'lib/components/LemonTable/columnUtils' import PropertyFiltersDisplay from 'lib/components/PropertyFilters/components/PropertyFiltersDisplay' +import { featureFlagLogic } from 'lib/logic/featureFlagLogic' +import { FEATURE_FLAGS } from 'lib/constants' +import { ActivityLog } from 'lib/components/ActivityLog/ActivityLog' +import { flagActivityDescriber } from 'scenes/feature-flags/activityDescriptions' +import { ActivityScope } from 'lib/components/ActivityLog/humanizeActivity' export const scene: SceneExport = { component: FeatureFlags, logic: featureFlagsLogic, } -export function FeatureFlags(): JSX.Element { +function OverViewTab(): JSX.Element { const { currentTeamId } = useValues(teamLogic) const { featureFlagsLoading, searchedFeatureFlags, searchTerm } = useValues(featureFlagsLogic) const { updateFeatureFlag, loadFeatureFlags, setSearchTerm } = useActions(featureFlagsLogic) + const { featureFlags } = useValues(featureFlagLogic) + const showActivityLog = featureFlags[FEATURE_FLAGS.FEATURE_FLAGS_ACTIVITY_LOG] + const columns: LemonTableColumns = [ { title: normalizeColumnTitle('Key'), @@ -140,32 +148,30 @@ export function FeatureFlags(): JSX.Element { ] return ( -
- + <>
{ setSearchTerm(e.target.value) }} /> -
- } - > - New Feature Flag - -
+ {!showActivityLog && ( +
+ } + > + New Feature Flag + +
+ )}
+ + ) +} + +export function FeatureFlags(): JSX.Element { + const { featureFlags } = useValues(featureFlagLogic) + const showActivityLog = featureFlags[FEATURE_FLAGS.FEATURE_FLAGS_ACTIVITY_LOG] + return ( +
+ } + > + New Feature Flag + + ) : ( + false + ) + } + /> + {showActivityLog ? ( + + + + + + + + + ) : ( + + )}
) } -function groupFilters(groups: FeatureFlagGroupType[]): JSX.Element | string { +export function groupFilters(groups: FeatureFlagGroupType[]): JSX.Element | string { if (groups.length === 0 || !groups.some((group) => group.rollout_percentage !== 0)) { // There are no rollout groups or all are at 0% return 'No users' diff --git a/frontend/src/scenes/feature-flags/activityDescriptions.tsx b/frontend/src/scenes/feature-flags/activityDescriptions.tsx new file mode 100644 index 00000000000..dd3adac5271 --- /dev/null +++ b/frontend/src/scenes/feature-flags/activityDescriptions.tsx @@ -0,0 +1,104 @@ +import { ActivityChange, ActivityLogItem } from 'lib/components/ActivityLog/humanizeActivity' +import { Link } from 'lib/components/Link' +import { urls } from 'scenes/urls' +import { FeatureFlagFilters, FeatureFlagType } from '~/types' +import React from 'react' +import { groupFilters } from 'scenes/feature-flags/FeatureFlags' + +const nameOrLinkToFlag = (item: ActivityLogItem): string | JSX.Element => { + const name = item.detail.name || '(empty string)' + return item.item_id ? {name} : name +} + +type flagFields = keyof FeatureFlagType + +const featureFlagActionsMapping: { + [field in flagFields]: (item: ActivityLogItem, change?: ActivityChange) => string | JSX.Element | null +} = { + name: function onName(item, change) { + return ( + <> + changed the description to "{change?.after}" on {nameOrLinkToFlag(item)} + + ) + }, + active: function onActive(item, change) { + const describeChange = change?.after ? 'enabled' : 'disabled' + return ( + <> + {describeChange} the flag: {nameOrLinkToFlag(item)} + + ) + }, + filters: function onChangedFilter(item, change) { + // "string value (multivariate test)" looks like {"variants": [{"key": "control", "rollout_percentage": 50}, {"key": "test_sticky", "rollout_percentage": 50}]} + // "boolean value" with condition looks like {"groups":[{"properties":[{"key":"$initial_browser_version","type":"person","value":["100"],"operator":"exact"}],"rollout_percentage":35}],"multivariate":null} + // "boolean value" with no condition looks like {"groups":[{"properties":[],"rollout_percentage":99}],"multivariate":null} + + const filters = change?.after as FeatureFlagFilters + + const isBooleanValueFlag = Array.isArray(filters.groups) && filters.groups.length >= 1 + + if (isBooleanValueFlag) { + //simple flag with no condition + return ( + <> + changed the rollout percentage to {groupFilters(filters.groups)} on {nameOrLinkToFlag(item)} + + ) + } + // TODO is it true that it must be multivariate now + return ( + <> + changed the rollout percentage for the variants to{' '} + {filters.multivariate?.variants.map((v) => `${v.key}: ${v.rollout_percentage}`).join(', ')} on{' '} + {nameOrLinkToFlag(item)} + + ) + }, + deleted: function onSoftDelete(item) { + return <>deleted the flag: {item.detail.name} + }, + rollout_percentage: function onRolloutPercentage(item, change) { + return ( + <> + changed rollout percentage to {change?.after}% on {nameOrLinkToFlag(item)} + + ) + }, + key: function onKey(item, change) { + return ( + <> + changed flag key from ${change?.before} to {nameOrLinkToFlag(item)} + + ) + }, + // fields that shouldn't show in the log if they change + id: () => null, + created_at: () => null, + created_by: () => null, + is_simple_flag: () => null, +} + +export function flagActivityDescriber(logItem: ActivityLogItem): (string | JSX.Element | null)[] { + if (logItem.scope != 'FeatureFlag') { + return [] // currently, only humanizes the feature flag scope + } + const descriptions = [] + if (logItem.activity == 'created') { + descriptions.push(<>created the flag: {nameOrLinkToFlag(logItem)}) + } + if (logItem.activity == 'deleted') { + descriptions.push(<>deleted the flag: {logItem.detail.name}) + } + if (logItem.activity == 'updated') { + for (const change of logItem.detail.changes || []) { + if (!change?.field) { + continue // model changes have to have a "field" to be described + } + + descriptions.push(featureFlagActionsMapping[change.field](logItem, change)) + } + } + return descriptions +} diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 4599e621166..285e11c4869 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -1224,7 +1224,7 @@ export interface MultivariateFlagOptions { variants: MultivariateFlagVariant[] } -interface FeatureFlagFilters { +export interface FeatureFlagFilters { groups: FeatureFlagGroupType[] multivariate: MultivariateFlagOptions | null aggregation_group_type_index?: number | null diff --git a/package.json b/package.json index 351137f7beb..a0ca08a1ea8 100644 --- a/package.json +++ b/package.json @@ -142,6 +142,7 @@ "@storybook/addon-links": "^6.4.19", "@storybook/addon-storysource": "^6.4.19", "@storybook/react": "^6.4.19", + "@testing-library/jest-dom": "^5.16.2", "@testing-library/react": "^12.1.2", "@testing-library/user-event": "^13.5.0", "@types/chart.js": "^2.9.34", diff --git a/posthog/api/feature_flag.py b/posthog/api/feature_flag.py index 5a9d27c5ce4..26c525058ba 100644 --- a/posthog/api/feature_flag.py +++ b/posthog/api/feature_flag.py @@ -199,6 +199,14 @@ class FeatureFlagViewSet(StructuredViewSetMixin, viewsets.ModelViewSet): ) return Response(flags) + @action(methods=["GET"], url_path="activity", detail=False) + def all_activity(self, request: request.Request, **kwargs): + activity = load_activity(scope="FeatureFlag", team_id=self.team_id) + return Response( + {"results": ActivityLogSerializer(activity, many=True,).data, "next": None, "previous": None,}, + status=status.HTTP_200_OK, + ) + @action(methods=["GET"], detail=True) def activity(self, request: request.Request, **kwargs): item_id = kwargs["pk"] diff --git a/posthog/api/test/test_feature_flag.py b/posthog/api/test/test_feature_flag.py index ab7b40fd2fd..11dc2884575 100644 --- a/posthog/api/test/test_feature_flag.py +++ b/posthog/api/test/test_feature_flag.py @@ -8,7 +8,7 @@ from django.test.utils import CaptureQueriesContext from freezegun.api import freeze_time from rest_framework import status -from posthog.models import ActivityLog, FeatureFlag, GroupTypeMapping, User +from posthog.models import FeatureFlag, GroupTypeMapping, User from posthog.models.cohort import Cohort from posthog.models.feature_flag import FeatureFlagOverride from posthog.test.base import APIBaseTest @@ -165,6 +165,7 @@ class TestFeatureFlag(APIBaseTest): { "user": {"first_name": "", "email": "user1@posthog.com",}, "activity": "created", + "created_at": "2021-08-25T22:09:14.252000Z", "scope": "FeatureFlag", "item_id": str(flag_id), "detail": {"changes": None, "name": "alpha-feature"}, @@ -365,6 +366,7 @@ class TestFeatureFlag(APIBaseTest): { "user": {"first_name": self.user.first_name, "email": self.user.email}, "activity": "updated", + "created_at": "2021-08-25T22:19:14.252000Z", "scope": "FeatureFlag", "item_id": str(flag_id), "detail": { @@ -404,6 +406,7 @@ class TestFeatureFlag(APIBaseTest): { "user": {"first_name": self.user.first_name, "email": self.user.email}, "activity": "created", + "created_at": "2021-08-25T22:09:14.252000Z", "scope": "FeatureFlag", "item_id": str(flag_id), "detail": {"changes": None, "name": "a-feature-flag-that-is-updated"}, @@ -440,16 +443,21 @@ class TestFeatureFlag(APIBaseTest): }, ) - self._get_feature_flag_activity(instance.pk, expected_status=status.HTTP_404_NOT_FOUND) + flag_activity = self._get_feature_flag_activity()["results"] - # can't get the flag delete from the activity API but it should have been logged - delete_activity = ActivityLog.objects.filter( - team_id=self.team.id, scope="FeatureFlag", item_id=instance.pk, activity="deleted" - ).first() - if isinstance(delete_activity, ActivityLog): - self.assertEqual(delete_activity.detail["name"], "potato") - else: - raise AssertionError("must be able to load this activity log") + self.assert_feature_flag_activity( + flag_id=None, + expected=[ + { + "user": {"first_name": "", "email": "new_annotations@posthog.com"}, + "activity": "deleted", + "scope": "FeatureFlag", + "item_id": str(instance.pk), + "detail": {"changes": None, "name": "potato"}, + "created_at": "2021-08-25T22:09:14.252000Z", + } + ], + ) def test_get_feature_flag_activity(self): new_user = User.objects.create_and_join( @@ -488,6 +496,7 @@ class TestFeatureFlag(APIBaseTest): { "user": {"first_name": new_user.first_name, "email": new_user.email}, "activity": "updated", + "created_at": "2021-08-25T22:19:14.252000Z", "scope": "FeatureFlag", "item_id": str(flag_id), "detail": { @@ -506,6 +515,87 @@ class TestFeatureFlag(APIBaseTest): { "user": {"first_name": new_user.first_name, "email": new_user.email}, "activity": "created", + "created_at": "2021-08-25T22:09:14.252000Z", + "scope": "FeatureFlag", + "item_id": str(flag_id), + "detail": {"changes": None, "name": "feature_with_activity"}, + }, + ], + ) + + def test_get_feature_flag_activity_for_all_flags(self): + new_user = User.objects.create_and_join( + organization=self.organization, + email="person_acting_and_then_viewing_activity@posthog.com", + password=None, + first_name="Potato", + ) + self.client.force_login(new_user) + + with freeze_time("2021-08-25T22:09:14.252Z") as frozen_datetime: + create_response = self.client.post( + f"/api/projects/{self.team.id}/feature_flags/", + {"name": "feature flag with activity", "key": "feature_with_activity"}, + ) + + self.assertEqual(create_response.status_code, status.HTTP_201_CREATED) + flag_id = create_response.json()["id"] + + frozen_datetime.tick(delta=datetime.timedelta(minutes=10)) + + update_response = self.client.patch( + f"/api/projects/{self.team.id}/feature_flags/{flag_id}", + { + "name": "feature flag with activity", + "filters": {"groups": [{"properties": [], "rollout_percentage": 74}]}, + }, + format="json", + ) + self.assertEqual(update_response.status_code, status.HTTP_200_OK) + + frozen_datetime.tick(delta=datetime.timedelta(minutes=10)) + + second_create_response = self.client.post( + f"/api/projects/{self.team.id}/feature_flags/", {"name": "a second feature flag", "key": "flag-two"}, + ) + + self.assertEqual(second_create_response.status_code, status.HTTP_201_CREATED) + second_flag_id = second_create_response.json()["id"] + + self.assert_feature_flag_activity( + flag_id=None, + expected=[ + { + "user": {"first_name": new_user.first_name, "email": new_user.email}, + "activity": "created", + "created_at": "2021-08-25T22:29:14.252000Z", + "scope": "FeatureFlag", + "item_id": str(second_flag_id), + "detail": {"changes": None, "name": "flag-two"}, + }, + { + "user": {"first_name": new_user.first_name, "email": new_user.email}, + "activity": "updated", + "created_at": "2021-08-25T22:19:14.252000Z", + "scope": "FeatureFlag", + "item_id": str(flag_id), + "detail": { + "changes": [ + { + "type": "FeatureFlag", + "action": "changed", + "field": "filters", + "before": {}, + "after": {"groups": [{"properties": [], "rollout_percentage": 74}]}, + } + ], + "name": "feature_with_activity", + }, + }, + { + "user": {"first_name": new_user.first_name, "email": new_user.email}, + "activity": "created", + "created_at": "2021-08-25T22:09:14.252000Z", "scope": "FeatureFlag", "item_id": str(flag_id), "detail": {"changes": None, "name": "feature_with_activity"}, @@ -1029,16 +1119,21 @@ class TestFeatureFlag(APIBaseTest): return create_response def _get_feature_flag_activity( - self, flag_id: int, team_id: Optional[int] = None, expected_status: int = status.HTTP_200_OK + self, flag_id: Optional[int] = None, team_id: Optional[int] = None, expected_status: int = status.HTTP_200_OK ): if team_id is None: team_id = self.team.id - activity = self.client.get(f"/api/projects/{team_id}/feature_flags/{flag_id}/activity") + if flag_id: + url = f"/api/projects/{team_id}/feature_flags/{flag_id}/activity" + else: + url = f"/api/projects/{team_id}/feature_flags/activity" + + activity = self.client.get(url) self.assertEqual(activity.status_code, expected_status) return activity.json() - def assert_feature_flag_activity(self, flag_id: int, expected: List[Dict]): + def assert_feature_flag_activity(self, flag_id: Optional[int], expected: List[Dict]): activity_response = self._get_feature_flag_activity(flag_id) activity: List[Dict] = activity_response["results"] diff --git a/posthog/models/activity_logging/activity_log.py b/posthog/models/activity_logging/activity_log.py index ab6780a4038..7786c9f6706 100644 --- a/posthog/models/activity_logging/activity_log.py +++ b/posthog/models/activity_logging/activity_log.py @@ -112,12 +112,14 @@ def log_activity( ) -def load_activity(scope: Literal["FeatureFlag"], team_id: int, item_id: int): +def load_activity(scope: Literal["FeatureFlag"], team_id: int, item_id: Optional[int] = None): # TODO in follow-up to posthog#8931 paging and selecting specific fields into a return type from this query - activities = list( - ActivityLog.objects.select_related("user") - .filter(team_id=team_id, scope=scope, item_id=item_id) - .order_by("-created_at")[:10] + activity_query = ( + ActivityLog.objects.select_related("user").filter(team_id=team_id, scope=scope).order_by("-created_at") ) + if item_id is not None: + activity_query.filter(item_id=item_id) + activities = list(activity_query[:10]) + return activities diff --git a/posthog/models/activity_logging/serializers.py b/posthog/models/activity_logging/serializers.py index 4a79a2d1250..3c9e87135d7 100644 --- a/posthog/models/activity_logging/serializers.py +++ b/posthog/models/activity_logging/serializers.py @@ -32,3 +32,4 @@ class ActivityLogSerializer(serializers.Serializer): scope = serializers.CharField(read_only=True) item_id = serializers.CharField(read_only=True) detail = DetailSerializer() + created_at = serializers.DateTimeField(read_only=True) diff --git a/yarn.lock b/yarn.lock index e03af4edbc8..e69c068d8da 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1821,6 +1821,13 @@ dependencies: regenerator-runtime "^0.13.4" +"@babel/runtime@^7.9.2": + version "7.17.7" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.17.7.tgz#a5f3328dc41ff39d803f311cfe17703418cf9825" + integrity sha512-L6rvG9GDxaLgFjg41K+5Yv9OMrU98sWe+Ykmc6FDJW/+vYZMhdOMKkISgzptMaERHvS2Y2lw9MDRm2gHhlQQoA== + dependencies: + regenerator-runtime "^0.13.4" + "@babel/template@^7.10.4", "@babel/template@^7.12.7", "@babel/template@^7.3.3": version "7.12.7" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.12.7.tgz#c817233696018e39fbb6c491d2fb684e05ed43bc" @@ -3461,6 +3468,21 @@ lz-string "^1.4.4" pretty-format "^27.0.2" +"@testing-library/jest-dom@^5.16.2": + version "5.16.2" + resolved "https://registry.yarnpkg.com/@testing-library/jest-dom/-/jest-dom-5.16.2.tgz#f329b36b44aa6149cd6ced9adf567f8b6aa1c959" + integrity sha512-6ewxs1MXWwsBFZXIk4nKKskWANelkdUehchEOokHsN8X7c2eKXGw+77aRV63UU8f/DTSVUPLaGxdrj4lN7D/ug== + dependencies: + "@babel/runtime" "^7.9.2" + "@types/testing-library__jest-dom" "^5.9.1" + aria-query "^5.0.0" + chalk "^3.0.0" + css "^3.0.0" + css.escape "^1.5.1" + dom-accessibility-api "^0.5.6" + lodash "^4.17.15" + redent "^3.0.0" + "@testing-library/react@^12.1.2": version "12.1.2" resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-12.1.2.tgz#f1bc9a45943461fa2a598bb4597df1ae044cfc76" @@ -3890,6 +3912,14 @@ dependencies: "@types/istanbul-lib-report" "*" +"@types/jest@*": + version "27.4.1" + resolved "https://registry.yarnpkg.com/@types/jest/-/jest-27.4.1.tgz#185cbe2926eaaf9662d340cc02e548ce9e11ab6d" + integrity sha512-23iPJADSmicDVrWk+HT58LMJtzLAnB2AgIzplQuq/bSrGaxCrlvRFjGbXmamnnk/mAmCdLStiGqggu28ocUyiw== + dependencies: + jest-matcher-utils "^27.0.0" + pretty-format "^27.0.0" + "@types/jest@^26.0.15": version "26.0.15" resolved "https://registry.yarnpkg.com/@types/jest/-/jest-26.0.15.tgz#12e02c0372ad0548e07b9f4e19132b834cb1effe" @@ -4130,6 +4160,13 @@ resolved "https://registry.yarnpkg.com/@types/tapable/-/tapable-1.0.8.tgz#b94a4391c85666c7b73299fd3ad79d4faa435310" integrity sha512-ipixuVrh2OdNmauvtT51o3d8z12p6LtFW9in7U79der/kwejjdNchQC5UMn5u/KxNoM7VHHOs/l8KS8uHxhODQ== +"@types/testing-library__jest-dom@^5.9.1": + version "5.14.3" + resolved "https://registry.yarnpkg.com/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.14.3.tgz#ee6c7ffe9f8595882ee7bda8af33ae7b8789ef17" + integrity sha512-oKZe+Mf4ioWlMuzVBaXQ9WDnEm1+umLx0InILg+yvZVBBDmzV5KfZyLrCvadtWcx8+916jLmHafcmqqffl+iIw== + dependencies: + "@types/jest" "*" + "@types/through@*": version "0.0.30" resolved "https://registry.yarnpkg.com/@types/through/-/through-0.0.30.tgz#e0e42ce77e897bd6aead6f6ea62aeb135b8a3895" @@ -5829,6 +5866,14 @@ chalk@^2.0.0, chalk@^2.4.1, chalk@^2.4.2: escape-string-regexp "^1.0.5" supports-color "^5.3.0" +chalk@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-3.0.0.tgz#3f73c2bf526591f574cc492c51e2456349f844e4" + integrity sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg== + dependencies: + ansi-styles "^4.1.0" + supports-color "^7.1.0" + chalk@^4.0.0, chalk@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.0.tgz#4e14870a618d9e2edd97dd8345fd9d9dc315646a" @@ -6691,6 +6736,20 @@ css-what@^3.2.1: resolved "https://registry.yarnpkg.com/css-what/-/css-what-3.4.2.tgz#ea7026fcb01777edbde52124e21f327e7ae950e4" integrity sha512-ACUm3L0/jiZTqfzRM3Hi9Q8eZqd6IK37mMWPLz9PJxkLWllYeRf+EHUSHYEtFop2Eqytaq1FizFVh7XfBnXCDQ== +css.escape@^1.5.1: + version "1.5.1" + resolved "https://registry.yarnpkg.com/css.escape/-/css.escape-1.5.1.tgz#42e27d4fa04ae32f931a4b4d4191fa9cddee97cb" + integrity sha1-QuJ9T6BK4y+TGktNQZH6nN3ul8s= + +css@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/css/-/css-3.0.0.tgz#4447a4d58fdd03367c516ca9f64ae365cee4aa5d" + integrity sha512-DG9pFfwOrzc+hawpmqX/dHYHJG+Bsdb0klhyi1sDneOgGOXy9wQIC8hzyVp1e4NRYDBdxcylvywPkkXCHAzTyQ== + dependencies: + inherits "^2.0.4" + source-map "^0.6.1" + source-map-resolve "^0.6.0" + cssesc@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-3.0.0.tgz#37741919903b868565e1c09ea747445cd18983ee" @@ -7290,6 +7349,11 @@ diff-sequences@^26.6.2: resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-26.6.2.tgz#48ba99157de1923412eed41db6b6d4aa9ca7c0b1" integrity sha512-Mv/TDa3nZ9sbc5soK+OoA74BsS3mL37yixCvUAQkiuA4Wz6YtwP/K47n2rv2ovzHZvoiQeA5FTQOschKkEwB0Q== +diff-sequences@^27.5.1: + version "27.5.1" + resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-27.5.1.tgz#eaecc0d327fd68c8d9672a1e64ab8dccb2ef5327" + integrity sha512-k1gCAXAsNgLwEL+Y8Wvl+M6oEFj5bgazfZULpS5CneoPPXRaCCW7dm+q21Ky2VEE5X+VeRDBVg1Pcvvsr4TtNQ== + diff@^4.0.1: version "4.0.2" resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d" @@ -7357,6 +7421,11 @@ doctrine@^3.0.0: dependencies: esutils "^2.0.2" +dom-accessibility-api@^0.5.6: + version "0.5.13" + resolved "https://registry.yarnpkg.com/dom-accessibility-api/-/dom-accessibility-api-0.5.13.tgz#102ee5f25eacce09bdf1cfa5a298f86da473be4b" + integrity sha512-R305kwb5CcMDIpSHUnLyIAp7SrSPBx6F0VfQFB3M75xVMHhXJJIdePYgbPPh1o57vCHNu5QztokWUPsLjWzFqw== + dom-accessibility-api@^0.5.9: version "0.5.9" resolved "https://registry.yarnpkg.com/dom-accessibility-api/-/dom-accessibility-api-0.5.9.tgz#915f8531ba29a50e5c29389dbfb87a9642fef0d6" @@ -10394,6 +10463,16 @@ jest-diff@^26.0.0, jest-diff@^26.6.2: jest-get-type "^26.3.0" pretty-format "^26.6.2" +jest-diff@^27.5.1: + version "27.5.1" + resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-27.5.1.tgz#a07f5011ac9e6643cf8a95a462b7b1ecf6680def" + integrity sha512-m0NvkX55LDt9T4mctTEgnZk3fmEg3NRYutvMPWM/0iPnkFj2wIeF45O1718cMSOFO1vINkqmxqD8vE37uTEbqw== + dependencies: + chalk "^4.0.0" + diff-sequences "^27.5.1" + jest-get-type "^27.5.1" + pretty-format "^27.5.1" + jest-docblock@^26.0.0: version "26.0.0" resolved "https://registry.yarnpkg.com/jest-docblock/-/jest-docblock-26.0.0.tgz#3e2fa20899fc928cb13bd0ff68bd3711a36889b5" @@ -10442,6 +10521,11 @@ jest-get-type@^26.3.0: resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-26.3.0.tgz#e97dc3c3f53c2b406ca7afaed4493b1d099199e0" integrity sha512-TpfaviN1R2pQWkIihlfEanwOXK0zcxrKEE4MlU6Tn7keoXdN6/3gK/xl0yEh8DOunn5pOVGKf8hB4R9gVh04ig== +jest-get-type@^27.5.1: + version "27.5.1" + resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-27.5.1.tgz#3cd613c507b0f7ace013df407a1c1cd578bcb4f1" + integrity sha512-2KY95ksYSaK7DMBWQn6dQz3kqAf3BB64y2udeG+hv4KfSOb9qwcYQstTJc1KCbsix+wLZWZYN8t7nwX3GOBLRw== + jest-haste-map@^26.6.2: version "26.6.2" resolved "https://registry.yarnpkg.com/jest-haste-map/-/jest-haste-map-26.6.2.tgz#dd7e60fe7dc0e9f911a23d79c5ff7fb5c2cafeaa" @@ -10525,6 +10609,16 @@ jest-matcher-utils@^26.6.2: jest-get-type "^26.3.0" pretty-format "^26.6.2" +jest-matcher-utils@^27.0.0: + version "27.5.1" + resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-27.5.1.tgz#9c0cdbda8245bc22d2331729d1091308b40cf8ab" + integrity sha512-z2uTx/T6LBaCoNWNFWwChLBKYxTMcGBRjAt+2SbP929/Fflb9aa5LGma654Rz8z9HLxsrUaYzxE9T/EFIL/PAw== + dependencies: + chalk "^4.0.0" + jest-diff "^27.5.1" + jest-get-type "^27.5.1" + pretty-format "^27.5.1" + jest-message-util@^26.6.2: version "26.6.2" resolved "https://registry.yarnpkg.com/jest-message-util/-/jest-message-util-26.6.2.tgz#58173744ad6fc0506b5d21150b9be56ef001ca07" @@ -13110,6 +13204,15 @@ pretty-format@^26.0.0, pretty-format@^26.6.2: ansi-styles "^4.0.0" react-is "^17.0.1" +pretty-format@^27.0.0, pretty-format@^27.5.1: + version "27.5.1" + resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-27.5.1.tgz#2181879fdea51a7a5851fb39d920faa63f01d88e" + integrity sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ== + dependencies: + ansi-regex "^5.0.1" + ansi-styles "^5.0.0" + react-is "^17.0.1" + pretty-format@^27.0.2: version "27.3.1" resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-27.3.1.tgz#7e9486365ccdd4a502061fa761d3ab9ca1b78df5" @@ -14196,6 +14299,14 @@ rechoir@^0.7.0: dependencies: resolve "^1.9.0" +redent@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/redent/-/redent-3.0.0.tgz#e557b7998316bb53c9f1f56fa626352c6963059f" + integrity sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg== + dependencies: + indent-string "^4.0.0" + strip-indent "^3.0.0" + redux@^4.0.0, redux@^4.0.5: version "4.0.5" resolved "https://registry.yarnpkg.com/redux/-/redux-4.0.5.tgz#4db5de5816e17891de8a80c424232d06f051d93f" @@ -15167,6 +15278,14 @@ source-map-resolve@^0.5.0: source-map-url "^0.4.0" urix "^0.1.0" +source-map-resolve@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/source-map-resolve/-/source-map-resolve-0.6.0.tgz#3d9df87e236b53f16d01e58150fc7711138e5ed2" + integrity sha512-KXBr9d/fO/bWo97NXsPIAW1bFSBOuCnjbNTBMO7N59hsv5i9yzRDfcYwwt0l04+VqnKC+EwzvJZIP/qkuMgR/w== + dependencies: + atob "^2.1.2" + decode-uri-component "^0.2.0" + source-map-support@^0.5.16, source-map-support@^0.5.17, source-map-support@^0.5.6, source-map-support@~0.5.12, source-map-support@~0.5.19: version "0.5.19" resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.19.tgz#a98b62f86dcaf4f67399648c085291ab9e8fed61"