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:
parent
602378ba7c
commit
8fd8efd87e
@ -1,7 +1,7 @@
|
||||
@import '~/vars.scss';
|
||||
|
||||
.history-list {
|
||||
.history-list-row {
|
||||
.activity-log {
|
||||
.activity-log-row {
|
||||
display: flex;
|
||||
margin-top: $default_spacing;
|
||||
|
@ -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} />
|
||||
}
|
69
frontend/src/lib/components/ActivityLog/ActivityLog.tsx
Normal file
69
frontend/src/lib/components/ActivityLog/ActivityLog.tsx
Normal 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>
|
||||
)
|
||||
}
|
@ -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',
|
||||
},
|
||||
]
|
@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
30
frontend/src/lib/components/ActivityLog/activityLogLogic.tsx
Normal file
30
frontend/src/lib/components/ActivityLog/activityLogLogic.tsx
Normal 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()
|
||||
},
|
||||
}),
|
||||
})
|
@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
64
frontend/src/lib/components/ActivityLog/humanizeActivity.tsx
Normal file
64
frontend/src/lib/components/ActivityLog/humanizeActivity.tsx
Normal 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
|
||||
}
|
@ -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} />
|
||||
}
|
@ -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>
|
||||
}
|
@ -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',
|
||||
},
|
||||
]
|
@ -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,
|
||||
})
|
||||
})
|
||||
})
|
@ -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()
|
||||
},
|
||||
}),
|
||||
})
|
@ -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). */
|
||||
|
@ -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'
|
||||
|
104
frontend/src/scenes/feature-flags/activityDescriptions.tsx
Normal file
104
frontend/src/scenes/feature-flags/activityDescriptions.tsx
Normal 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
|
||||
}
|
@ -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
|
||||
|
@ -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",
|
||||
|
@ -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"]
|
||||
|
@ -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"]
|
||||
|
@ -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
|
||||
|
@ -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
119
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"
|
||||
|
Loading…
Reference in New Issue
Block a user