0
0
mirror of https://github.com/PostHog/posthog.git synced 2024-11-21 21:49:51 +01:00

feat(activity-log): adds the activity log to the feature flags page (#9119)

* first pass at adding the log to the feature flags page behind a flag

* more removal of the word history

* Add loading state to activity log

* why return early

* no need to start loader on mount

* separate generation of rows from page scaffold

* simplify activity log loading and describing

* inline rows

* remove lemon table style loader

* fix tests

* use activity scope enum

* need less punctuation

* false not fragment
This commit is contained in:
Paul D'Ambra 2022-03-21 13:18:47 +00:00 committed by GitHub
parent 602378ba7c
commit 8fd8efd87e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 965 additions and 375 deletions

View File

@ -1,7 +1,7 @@
@import '~/vars.scss';
.history-list {
.history-list-row {
.activity-log {
.activity-log-row {
display: flex;
margin-top: $default_spacing;

View File

@ -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<typeof ActivityLog>
export function WithData(): JSX.Element {
return <ActivityLog scope={'FeatureFlag'} id={7} describer={flagActivityDescriber} />
}
export function WithNoData(): JSX.Element {
return <ActivityLog scope={'FeatureFlag'} id={6} describer={flagActivityDescriber} />
}
export function Timeout(): JSX.Element {
return <ActivityLog scope={'FeatureFlag'} id={5} describer={flagActivityDescriber} />
}

View File

@ -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 => <div className="text-muted">There is no history for this item</div>
const SkeletonLog = (): JSX.Element => {
return (
<div className="activity-log-row">
<Skeleton.Avatar active={true} size={40} />
<div className="details">
<Skeleton paragraph={{ rows: 1 }} />
</div>
</div>
)
}
const Loading = (): JSX.Element => {
return (
<>
<SkeletonLog />
<SkeletonLog />
<SkeletonLog />
<SkeletonLog />
</>
)
}
export const ActivityLog = ({ scope, id, describer }: ActivityLogProps): JSX.Element | null => {
const logic = activityLogLogic({ scope, id, describer })
const { activity, activityLoading } = useValues(logic)
return (
<div className="activity-log">
{activityLoading ? (
<Loading />
) : activity.length > 0 ? (
activity.map((logItem, index) => {
return (
<div className={'activity-log-row'} key={index}>
<ProfilePicture showName={false} email={logItem.email} size={'xl'} />
<div className="details">
<div>
<strong>{logItem.name ?? 'unknown user'}</strong> {logItem.description}
</div>
<div className={'text-muted'}>
<TZLabel time={logItem.created_at} />
</div>
</div>
</div>
)
})
) : (
<Empty />
)}
</div>
)
}

View File

@ -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',
},
]

View File

@ -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: <Link to={urls.featureFlag('7')}>test flag</Link>
</>
),
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<typeof activityLogLogic.build>
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,
})
})
})
})

View File

@ -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<activityLogLogicType>({
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<ActivityLogItem> = await api.get(url)
return humanize(apiResponse?.results, props.describer)
},
},
],
}),
events: ({ actions }) => ({
afterMount: () => {
actions.fetchActivity()
},
}),
})

View File

@ -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'
)
})
})
})

View File

@ -0,0 +1,64 @@
import { dayjs } from 'lib/dayjs'
export interface ActivityChange {
type: 'FeatureFlag'
action: 'changed' | 'created' | 'deleted'
field?: string
before?: string | Record<string, any> | boolean
after?: string | Record<string, any> | 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
}

View File

@ -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<typeof HistoryList>
export function WithData(): JSX.Element {
return <HistoryList type={'FeatureFlag'} id={7} />
}
export function WithNoData(): JSX.Element {
return <HistoryList type={'FeatureFlag'} id={6} />
}

View File

@ -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 => (
<div className="history-list">
<div className="text-muted">There is no history for this item</div>
</div>
)
const Loading = (): JSX.Element => (
<div className="text-muted">
<Spinner size="sm" style={{ verticalAlign: 'sub' }} /> Loading history for this item
</div>
)
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 (
<div className={'history-list-row'} key={index}>
<ProfilePicture showName={false} email={historyItem.email} size={'xl'} />
<div className="details">
<div>
<strong>{historyItem.name ?? 'unknown user'}</strong> {historyItem.description}
</div>
<div className={'text-muted'}>
<TZLabel time={historyItem.created_at} />
</div>
</div>
</div>
)
})
return <div className="history-list">{rows && rows.length ? rows : historyLoading ? <Loading /> : <Empty />}</div>
}

View File

@ -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',
},
]

View File

@ -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 <code>{JSON.stringify({ filter: 'info' })}</code>
</>
),
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<typeof historyListLogic.build>
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,
})
})
})

View File

@ -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<string, any> | 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 <code>{JSON.stringify(detail.to)}</code>
</>
)
},
[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<historyListLogicType<HistoryListLogicProps, HumanizedHistoryListItem>>({
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<HistoryListItem> = await api.get(
`/api/projects/@current/feature_flags/${props.id}/history`
)
return humanize(apiResponse?.results)
},
},
],
}),
events: ({ actions }) => ({
afterMount: () => {
actions.fetchHistory()
},
}),
})

View File

@ -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). */

View File

@ -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<FeatureFlagType> = [
{
title: normalizeColumnTitle('Key'),
@ -140,32 +148,30 @@ export function FeatureFlags(): JSX.Element {
]
return (
<div className="feature_flags">
<PageHeader
title="Feature Flags"
caption="Feature Flags are a way of turning functionality in your app on or off, based on user properties."
/>
<>
<div>
<Input.Search
placeholder="Search for feature flags"
allowClear
enterButton
style={{ maxWidth: 400, width: 'initial', flexGrow: 1 }}
style={{ maxWidth: 400, width: 'initial', flexGrow: 1, marginBottom: '1rem' }}
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value)
}}
/>
<div className="mb float-right">
<LinkButton
type="primary"
to={urls.featureFlag('new')}
data-attr="new-feature-flag"
icon={<PlusOutlined />}
>
New Feature Flag
</LinkButton>
</div>
{!showActivityLog && (
<div className="mb float-right">
<LinkButton
type="primary"
to={urls.featureFlag('new')}
data-attr="new-feature-flag"
icon={<PlusOutlined />}
>
New Feature Flag
</LinkButton>
</div>
)}
</div>
<LemonTable
dataSource={searchedFeatureFlags}
@ -177,11 +183,54 @@ export function FeatureFlags(): JSX.Element {
nouns={['feature flag', 'feature flags']}
data-attr="feature-flag-table"
/>
</>
)
}
export function FeatureFlags(): JSX.Element {
const { featureFlags } = useValues(featureFlagLogic)
const showActivityLog = featureFlags[FEATURE_FLAGS.FEATURE_FLAGS_ACTIVITY_LOG]
return (
<div className="feature_flags">
<PageHeader
title="Feature Flags"
caption={
showActivityLog
? ''
: 'Feature Flags are a way of turning functionality in your app on or off, based on user properties.'
}
buttons={
showActivityLog ? (
<LinkButton
type="primary"
to={urls.featureFlag('new')}
data-attr="new-feature-flag"
icon={<PlusOutlined />}
>
New Feature Flag
</LinkButton>
) : (
false
)
}
/>
{showActivityLog ? (
<Tabs defaultActiveKey="overview" destroyInactiveTabPane>
<Tabs.TabPane tab="Overview" key="overview">
<OverViewTab />
</Tabs.TabPane>
<Tabs.TabPane tab="Activity" key="activity">
<ActivityLog scope={ActivityScope.FEATURE_FLAG} describer={flagActivityDescriber} />
</Tabs.TabPane>
</Tabs>
) : (
<OverViewTab />
)}
</div>
)
}
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'

View File

@ -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 ? <Link to={urls.featureFlag(item.item_id)}>{name}</Link> : 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
}

View File

@ -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

View File

@ -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",

View File

@ -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"]

View File

@ -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"]

View File

@ -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

View File

@ -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)

119
yarn.lock
View File

@ -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"