From 7c192378d62dc59ed5c23e2540cc27a376ea0153 Mon Sep 17 00:00:00 2001 From: Mateusz Kwasniewski Date: Tue, 5 Nov 2024 15:14:28 +0100 Subject: [PATCH 1/9] chore: orval types (#8661) --- frontend/src/openapi/models/index.ts | 1 + frontend/src/openapi/models/profileSchema.ts | 2 ++ .../openapi/models/projectStatusSchemaResources.ts | 13 +++++++++++++ 3 files changed, 16 insertions(+) create mode 100644 frontend/src/openapi/models/projectStatusSchemaResources.ts diff --git a/frontend/src/openapi/models/index.ts b/frontend/src/openapi/models/index.ts index 0349b125a6b6..4dd6d3daa0c1 100644 --- a/frontend/src/openapi/models/index.ts +++ b/frontend/src/openapi/models/index.ts @@ -1022,6 +1022,7 @@ export * from './projectSettingsSchemaDefaultStickiness'; export * from './projectSettingsSchemaMode'; export * from './projectStatsSchema'; export * from './projectStatusSchema'; +export * from './projectStatusSchemaResources'; export * from './projectUsersSchema'; export * from './projectsSchema'; export * from './provideFeedbackSchema'; diff --git a/frontend/src/openapi/models/profileSchema.ts b/frontend/src/openapi/models/profileSchema.ts index a06de78c3a64..9d57d2ff8253 100644 --- a/frontend/src/openapi/models/profileSchema.ts +++ b/frontend/src/openapi/models/profileSchema.ts @@ -15,4 +15,6 @@ export interface ProfileSchema { /** Which projects this user is a member of */ projects: string[]; rootRole: RoleSchema; + /** Which email subscriptions this user is subscribed to */ + subscriptions: string[]; } diff --git a/frontend/src/openapi/models/projectStatusSchemaResources.ts b/frontend/src/openapi/models/projectStatusSchemaResources.ts new file mode 100644 index 000000000000..6483aff39ec4 --- /dev/null +++ b/frontend/src/openapi/models/projectStatusSchemaResources.ts @@ -0,0 +1,13 @@ +/** + * Generated by Orval + * Do not edit manually. + * See `gen:api` script in package.json + */ + +/** + * Key resources within the project + */ +export type ProjectStatusSchemaResources = { + /** The number of environments that have received SDK traffic in this project. */ + connectedEnvironments: number; +}; From 7aa74cccd381d6d9e990cd6c2818333722a3c0c8 Mon Sep 17 00:00:00 2001 From: Tymoteusz Czech <2625371+Tymek@users.noreply.github.com> Date: Tue, 5 Nov 2024 15:52:11 +0100 Subject: [PATCH 2/9] feat: add user preference change to event log (#8652) --- .../user-subscriptions-service.e2e.test.ts | 67 ++++++++++++++++--- .../user-subscriptions-service.ts | 34 +++++----- src/lib/types/events.ts | 18 +++++ .../productivity-report.plain.mustache | 2 +- 4 files changed, 97 insertions(+), 24 deletions(-) diff --git a/src/lib/features/user-subscriptions/user-subscriptions-service.e2e.test.ts b/src/lib/features/user-subscriptions/user-subscriptions-service.e2e.test.ts index 39e5744a2ccd..76db78344447 100644 --- a/src/lib/features/user-subscriptions/user-subscriptions-service.e2e.test.ts +++ b/src/lib/features/user-subscriptions/user-subscriptions-service.e2e.test.ts @@ -1,7 +1,8 @@ import { + type IEventStore, type IUnleashConfig, type IUnleashStores, - type IUser, + type IUserStore, TEST_AUDIT_USER, } from '../../types'; import type { UserSubscriptionsService } from './user-subscriptions-service'; @@ -13,25 +14,27 @@ import type { IUserSubscriptionsReadModel } from './user-subscriptions-read-mode let stores: IUnleashStores; let db: ITestDb; +let userStore: IUserStore; let userSubscriptionService: UserSubscriptionsService; let userSubscriptionsReadModel: IUserSubscriptionsReadModel; +let eventsStore: IEventStore; let config: IUnleashConfig; -let user: IUser; beforeAll(async () => { db = await dbInit('user_subscriptions', getLogger); stores = db.stores; config = createTestConfig({}); + userStore = stores.userStore; userSubscriptionService = createUserSubscriptionsService(config)( db.rawDatabase, ); userSubscriptionsReadModel = db.stores.userSubscriptionsReadModel; + eventsStore = db.stores.eventStore; +}); - user = await stores.userStore.insert({ - email: 'test@getunleash.io', - name: 'Sample Name', - }); +beforeEach(async () => { + await userStore.deleteAll(); }); afterAll(async () => { @@ -39,6 +42,11 @@ afterAll(async () => { }); test('Subscribe and unsubscribe', async () => { + const user = await userStore.insert({ + email: 'test@getunleash.io', + name: 'Sample Name', + }); + const subscribers = await userSubscriptionsReadModel.getSubscribedUsers( 'productivity-report', ); @@ -47,7 +55,7 @@ test('Subscribe and unsubscribe', async () => { ]); const userSubscriptions = - await userSubscriptionsReadModel.getUserSubscriptions(user.id); + await userSubscriptionService.getUserSubscriptions(user.id); expect(userSubscriptions).toMatchObject(['productivity-report']); await userSubscriptionService.unsubscribe( @@ -62,6 +70,49 @@ test('Subscribe and unsubscribe', async () => { expect(noSubscribers).toMatchObject([]); const noUserSubscriptions = - await userSubscriptionsReadModel.getUserSubscriptions(user.id); + await userSubscriptionService.getUserSubscriptions(user.id); expect(noUserSubscriptions).toMatchObject([]); }); + +test('Event log for subscription actions', async () => { + const user = await userStore.insert({ + email: 'test@getunleash.io', + name: 'Sample Name', + }); + + await userSubscriptionService.unsubscribe( + user.id, + 'productivity-report', + TEST_AUDIT_USER, + ); + + const unsubscribeEvent = (await eventsStore.getAll())[0]; + + expect(unsubscribeEvent).toEqual( + expect.objectContaining({ + type: 'user-preference-updated', + data: { + subscription: 'productivity-report', + action: 'unsubscribed', + }, + }), + ); + + await userSubscriptionService.subscribe( + user.id, + 'productivity-report', + TEST_AUDIT_USER, + ); + + const subscribeEvent = (await eventsStore.getAll())[0]; + + expect(subscribeEvent).toEqual( + expect.objectContaining({ + type: 'user-preference-updated', + data: { + subscription: 'productivity-report', + action: 'subscribed', + }, + }), + ); +}); diff --git a/src/lib/features/user-subscriptions/user-subscriptions-service.ts b/src/lib/features/user-subscriptions/user-subscriptions-service.ts index 9dbb8d9a535a..4ba7eb760cb4 100644 --- a/src/lib/features/user-subscriptions/user-subscriptions-service.ts +++ b/src/lib/features/user-subscriptions/user-subscriptions-service.ts @@ -1,4 +1,8 @@ -import type { IUnleashConfig, IUnleashStores } from '../../types'; +import { + UserPreferenceUpdatedEvent, + type IUnleashConfig, + type IUnleashStores, +} from '../../types'; import type { Logger } from '../../logger'; import type { IAuditUser } from '../../types/user'; import type { @@ -49,13 +53,13 @@ export class UserSubscriptionsService { }; await this.userUnsubscribeStore.delete(entry); - // TODO: log an event - // await this.eventService.storeEvent( - // new UserSubscriptionEvent({ - // data: { ...entry, action: 'subscribed' }, - // auditUser, - // }), - // ); + await this.eventService.storeEvent( + new UserPreferenceUpdatedEvent({ + userId, + data: { subscription, action: 'subscribed' }, + auditUser, + }), + ); } async unsubscribe( @@ -69,12 +73,12 @@ export class UserSubscriptionsService { }; await this.userUnsubscribeStore.insert(entry); - // TODO: log an event - // await this.eventService.storeEvent( - // new UserSubscriptionEvent({ - // data: { ...entry, action: 'unsubscribed' }, - // auditUser, - // }), - // ); + await this.eventService.storeEvent( + new UserPreferenceUpdatedEvent({ + userId, + data: { subscription, action: 'unsubscribed' }, + auditUser, + }), + ); } } diff --git a/src/lib/types/events.ts b/src/lib/types/events.ts index d71c28df5f91..865b97cf4f27 100644 --- a/src/lib/types/events.ts +++ b/src/lib/types/events.ts @@ -204,6 +204,8 @@ export const ACTIONS_CREATED = 'actions-created' as const; export const ACTIONS_UPDATED = 'actions-updated' as const; export const ACTIONS_DELETED = 'actions-deleted' as const; +export const USER_PREFERENCE_UPDATED = 'user-preference-updated' as const; + export const IEventTypes = [ APPLICATION_CREATED, FEATURE_CREATED, @@ -351,6 +353,7 @@ export const IEventTypes = [ ACTIONS_CREATED, ACTIONS_UPDATED, ACTIONS_DELETED, + USER_PREFERENCE_UPDATED, ] as const; export type IEventType = (typeof IEventTypes)[number]; @@ -2024,3 +2027,18 @@ function mapUserToData(user: IUserEventData): any { rootRole: user.rootRole, }; } + +export class UserPreferenceUpdatedEvent extends BaseEvent { + readonly userId; + readonly data: any; + + constructor(eventData: { + userId: number; + data: any; + auditUser: IAuditUser; + }) { + super(USER_PREFERENCE_UPDATED, eventData.auditUser); + this.userId = eventData.userId; + this.data = eventData.data; + } +} diff --git a/src/mailtemplates/productivity-report/productivity-report.plain.mustache b/src/mailtemplates/productivity-report/productivity-report.plain.mustache index 4c23236ce4b3..00c584100ee4 100644 --- a/src/mailtemplates/productivity-report/productivity-report.plain.mustache +++ b/src/mailtemplates/productivity-report/productivity-report.plain.mustache @@ -14,4 +14,4 @@ Production updates last month: {{productionUpdates}} Go to your Insights to learn more: {{unleashUrl}}/insights This email was sent to {{userEmail}}. You’ve received this as you are a user of Unleash. -If you wish to unsubscribe, go to you profile settings here. +If you wish to unsubscribe, go to you profile settings on {{unleashUrl}}/profile From b00d449c7207cb072d734a625c641ea4a469319e Mon Sep 17 00:00:00 2001 From: Mateusz Kwasniewski Date: Tue, 5 Nov 2024 16:14:19 +0100 Subject: [PATCH 3/9] feat: read productivity report from profile (#8662) --- .../ProductivityEmailSubscription.test.tsx | 105 ++++++++++++++++++ .../ProductivityEmailSubscription.tsx | 88 ++++++++------- .../user/Profile/ProfileTab/ProfileTab.tsx | 15 ++- .../api/getters/useProfile/useProfile.ts | 4 +- frontend/src/interfaces/profile.ts | 6 - 5 files changed, 165 insertions(+), 53 deletions(-) create mode 100644 frontend/src/component/user/Profile/ProfileTab/ProductivityEmailSubscription.test.tsx delete mode 100644 frontend/src/interfaces/profile.ts diff --git a/frontend/src/component/user/Profile/ProfileTab/ProductivityEmailSubscription.test.tsx b/frontend/src/component/user/Profile/ProfileTab/ProductivityEmailSubscription.test.tsx new file mode 100644 index 000000000000..a06615264e4e --- /dev/null +++ b/frontend/src/component/user/Profile/ProfileTab/ProductivityEmailSubscription.test.tsx @@ -0,0 +1,105 @@ +import { render } from 'utils/testRenderer'; +import { screen } from '@testing-library/react'; +import { testServerRoute, testServerSetup } from 'utils/testServer'; +import { ProductivityEmailSubscription } from './ProductivityEmailSubscription'; +import ToastRenderer from '../../../common/ToastRenderer/ToastRenderer'; + +const server = testServerSetup(); + +const setupSubscribeApi = () => { + testServerRoute( + server, + '/api/admin/email-subscription/productivity-report', + {}, + 'put', + 202, + ); +}; + +const setupUnsubscribeApi = () => { + testServerRoute( + server, + '/api/admin/email-subscription/productivity-report', + {}, + 'delete', + 202, + ); +}; + +const setupErrorApi = () => { + testServerRoute( + server, + '/api/admin/email-subscription/productivity-report', + { message: 'user error' }, + 'delete', + 400, + ); +}; + +test('unsubscribe', async () => { + setupUnsubscribeApi(); + let changed = false; + render( + <> + { + changed = true; + }} + /> + + , + ); + const checkbox = screen.getByLabelText('Productivity Email Subscription'); + expect(checkbox).toBeChecked(); + + checkbox.click(); + + await screen.findByText('Unsubscribed from productivity report'); + expect(changed).toBe(true); +}); + +test('subscribe', async () => { + setupSubscribeApi(); + let changed = false; + render( + <> + { + changed = true; + }} + /> + + , + ); + const checkbox = screen.getByLabelText('Productivity Email Subscription'); + expect(checkbox).not.toBeChecked(); + + checkbox.click(); + + await screen.findByText('Subscribed to productivity report'); + expect(changed).toBe(true); +}); + +test('handle error', async () => { + setupErrorApi(); + let changed = false; + render( + <> + { + changed = true; + }} + /> + + , + ); + const checkbox = screen.getByLabelText('Productivity Email Subscription'); + + checkbox.click(); + + await screen.findByText('user error'); + expect(changed).toBe(true); +}); diff --git a/frontend/src/component/user/Profile/ProfileTab/ProductivityEmailSubscription.tsx b/frontend/src/component/user/Profile/ProfileTab/ProductivityEmailSubscription.tsx index 7545d5f64949..e047fab2e769 100644 --- a/frontend/src/component/user/Profile/ProfileTab/ProductivityEmailSubscription.tsx +++ b/frontend/src/component/user/Profile/ProfileTab/ProductivityEmailSubscription.tsx @@ -1,14 +1,14 @@ -import { Box, Switch } from '@mui/material'; +import { Box, FormControlLabel, Switch } from '@mui/material'; import { formatUnknownError } from 'utils/formatUnknownError'; -import { useState } from 'react'; +import type { FC } from 'react'; import { useEmailSubscriptionApi } from 'hooks/api/actions/useEmailSubscriptionApi/useEmailSubscriptionApi'; import useToast from 'hooks/useToast'; import { usePlausibleTracker } from 'hooks/usePlausibleTracker'; -export const ProductivityEmailSubscription = () => { - // TODO: read data from user profile when available - const [receiveProductivityReportEmail, setReceiveProductivityReportEmail] = - useState(false); +export const ProductivityEmailSubscription: FC<{ + status: 'subscribed' | 'unsubscribed'; + onChange: () => void; +}> = ({ status, onChange }) => { const { subscribe, unsubscribe, @@ -19,44 +19,46 @@ export const ProductivityEmailSubscription = () => { return ( - Productivity Email Subscription - { - try { - if (receiveProductivityReportEmail) { - await unsubscribe('productivity-report'); - setToastData({ - title: 'Unsubscribed from productivity report', - type: 'success', - }); - trackEvent('productivity-report', { - props: { - eventType: 'subscribe', - }, - }); - } else { - await subscribe('productivity-report'); - setToastData({ - title: 'Subscribed to productivity report', - type: 'success', - }); - trackEvent('productivity-report', { - props: { - eventType: 'unsubscribe', - }, - }); - } - } catch (error) { - setToastApiError(formatUnknownError(error)); - } + { + try { + if (status === 'subscribed') { + await unsubscribe('productivity-report'); + setToastData({ + title: 'Unsubscribed from productivity report', + type: 'success', + }); + trackEvent('productivity-report', { + props: { + eventType: 'subscribe', + }, + }); + } else { + await subscribe('productivity-report'); + setToastData({ + title: 'Subscribed to productivity report', + type: 'success', + }); + trackEvent('productivity-report', { + props: { + eventType: 'unsubscribe', + }, + }); + } + } catch (error) { + setToastApiError(formatUnknownError(error)); + } - setReceiveProductivityReportEmail( - !receiveProductivityReportEmail, - ); - }} - name='productivity-email' - checked={receiveProductivityReportEmail} - disabled={changingSubscriptionStatus} + onChange(); + }} + name='productivity-email' + checked={status === 'subscribed'} + disabled={changingSubscriptionStatus} + /> + } /> ); diff --git a/frontend/src/component/user/Profile/ProfileTab/ProfileTab.tsx b/frontend/src/component/user/Profile/ProfileTab/ProfileTab.tsx index 5c06f175f13f..814b53bebcc7 100644 --- a/frontend/src/component/user/Profile/ProfileTab/ProfileTab.tsx +++ b/frontend/src/component/user/Profile/ProfileTab/ProfileTab.tsx @@ -87,7 +87,7 @@ interface IProfileTabProps { } export const ProfileTab = ({ user }: IProfileTabProps) => { - const { profile } = useProfile(); + const { profile, refetchProfile } = useProfile(); const navigate = useNavigate(); const { locationSettings, setLocationSettings } = useLocationSettings(); const [currentLocale, setCurrentLocale] = useState(); @@ -223,7 +223,18 @@ export const ProfileTab = ({ user }: IProfileTabProps) => { <> Email Settings - + {profile?.subscriptions && ( + + )} ) : null} diff --git a/frontend/src/hooks/api/getters/useProfile/useProfile.ts b/frontend/src/hooks/api/getters/useProfile/useProfile.ts index e236fb4f1bc6..bbf52fc11a6e 100644 --- a/frontend/src/hooks/api/getters/useProfile/useProfile.ts +++ b/frontend/src/hooks/api/getters/useProfile/useProfile.ts @@ -1,10 +1,10 @@ import useSWR from 'swr'; import { formatApiPath } from 'utils/formatPath'; import handleErrorResponses from '../httpErrorResponseHandler'; -import type { IProfile } from 'interfaces/profile'; +import type { ProfileSchema } from '../../../../openapi'; export interface IUseProfileOutput { - profile?: IProfile; + profile?: ProfileSchema; refetchProfile: () => void; loading: boolean; error?: Error; diff --git a/frontend/src/interfaces/profile.ts b/frontend/src/interfaces/profile.ts deleted file mode 100644 index 1997ee767523..000000000000 --- a/frontend/src/interfaces/profile.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { IRole } from './role'; - -export interface IProfile { - rootRole: IRole; - projects: string[]; -} From 16e136e42c13c9c9d56d2cfdc6bfa92c82ee077e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nuno=20G=C3=B3is?= Date: Tue, 5 Nov 2024 15:53:15 +0000 Subject: [PATCH 4/9] chore: add flagOverviewRedesign flag (#8653) https://linear.app/unleash/issue/2-2916/create-a-new-flag-for-the-new-feature-flag-overview-page-redesign Adds the `flagOverviewRedesign` feature flag, allowing us to toggle the new feature flag overview page redesign. --- frontend/src/interfaces/uiConfig.ts | 1 + src/lib/types/experimental.ts | 7 ++++++- src/server-dev.ts | 1 + 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/frontend/src/interfaces/uiConfig.ts b/frontend/src/interfaces/uiConfig.ts index 6e7b8c23191a..e66fba2891ac 100644 --- a/frontend/src/interfaces/uiConfig.ts +++ b/frontend/src/interfaces/uiConfig.ts @@ -94,6 +94,7 @@ export type UiFlags = { 'enterprise-payg'?: boolean; simplifyProjectOverview?: boolean; productivityReportEmail?: boolean; + flagOverviewRedesign?: boolean; }; export interface IVersionInfo { diff --git a/src/lib/types/experimental.ts b/src/lib/types/experimental.ts index 222897aef9b0..320efb6805d6 100644 --- a/src/lib/types/experimental.ts +++ b/src/lib/types/experimental.ts @@ -60,7 +60,8 @@ export type IFlagKey = | 'releasePlans' | 'productivityReportEmail' | 'enterprise-payg' - | 'simplifyProjectOverview'; + | 'simplifyProjectOverview' + | 'flagOverviewRedesign'; export type IFlags = Partial<{ [key in IFlagKey]: boolean | Variant }>; @@ -301,6 +302,10 @@ const flags: IFlags = { process.env.UNLEASH_EXPERIMENTAL_SIMPLIFY_PROJECT_OVERVIEW, false, ), + flagOverviewRedesign: parseEnvVarBoolean( + process.env.UNLEASH_EXPERIMENTAL_FLAG_OVERVIEW_REDESIGN, + false, + ), }; export const defaultExperimentalOptions: IExperimentalOptions = { diff --git a/src/server-dev.ts b/src/server-dev.ts index 81a95d9f16c9..7f1ea4b8d418 100644 --- a/src/server-dev.ts +++ b/src/server-dev.ts @@ -57,6 +57,7 @@ process.nextTick(async () => { webhookDomainLogging: true, releasePlans: false, simplifyProjectOverview: true, + flagOverviewRedesign: true, }, }, authentication: { From ba72be616934fef8d94ee6d150b305f34a9a6bc4 Mon Sep 17 00:00:00 2001 From: Mateusz Kwasniewski Date: Wed, 6 Nov 2024 09:34:38 +0100 Subject: [PATCH 5/9] chore: export user subscriptions read model (#8664) --- src/lib/types/stores.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/lib/types/stores.ts b/src/lib/types/stores.ts index 0379a0f78017..b4ab43c17eab 100644 --- a/src/lib/types/stores.ts +++ b/src/lib/types/stores.ts @@ -164,4 +164,5 @@ export { type IntegrationEventsStore, type IProjectReadModel, IOnboardingStore, + type IUserSubscriptionsReadModel, }; From d6e722b7b33465d6fbcd4fe4e61d7cf247c12384 Mon Sep 17 00:00:00 2001 From: Jaanus Sellin Date: Wed, 6 Nov 2024 12:00:42 +0200 Subject: [PATCH 6/9] feat: activity chart polish (#8665) ![image](https://github.com/user-attachments/assets/a97f5745-1300-473e-80af-54f0cfc985e1) --- .../Project/ProjectStatus/ProjectActivity.tsx | 37 ++++++++++--------- src/lib/features/events/event-store.ts | 7 +++- .../project-status/project-status-service.ts | 2 +- src/lib/types/stores/event-store.ts | 4 +- src/test/fixtures/fake-event-store.ts | 4 +- 5 files changed, 33 insertions(+), 21 deletions(-) diff --git a/frontend/src/component/project/Project/ProjectStatus/ProjectActivity.tsx b/frontend/src/component/project/Project/ProjectStatus/ProjectActivity.tsx index 5bbb6d5f476c..7bce045736bc 100644 --- a/frontend/src/component/project/Project/ProjectStatus/ProjectActivity.tsx +++ b/frontend/src/component/project/Project/ProjectStatus/ProjectActivity.tsx @@ -5,22 +5,25 @@ import type { ProjectActivitySchema } from '../../../../openapi'; import { styled, Tooltip } from '@mui/material'; const StyledContainer = styled('div')(({ theme }) => ({ - gap: theme.spacing(1), + display: 'flex', + flexDirection: 'column', + justifyContent: 'center', + alignItems: 'center', + gap: theme.spacing(2), })); +const TitleContainer = styled('h4')({ + margin: 0, + width: '100%', +}); + type Output = { date: string; count: number; level: number }; export function transformData(inputData: ProjectActivitySchema): Output[] { - const resultMap: Record = {}; - - // Step 1: Count the occurrences of each date - inputData.forEach((item) => { - const formattedDate = new Date(item.date).toISOString().split('T')[0]; - resultMap[formattedDate] = (resultMap[formattedDate] || 0) + 1; - }); + const countArray = inputData.map((item) => item.count); // Step 2: Get all counts, sort them, and find the cut-off values for percentiles - const counts = Object.values(resultMap).sort((a, b) => a - b); + const counts = Object.values(countArray).sort((a, b) => a - b); const percentile = (percent: number) => { const index = Math.floor((percent / 100) * counts.length); @@ -43,13 +46,13 @@ export function transformData(inputData: ProjectActivitySchema): Output[] { }; // Step 4: Convert the map back to an array and assign levels - return Object.entries(resultMap) - .map(([date, count]) => ({ + return inputData + .map(({ date, count }) => ({ date, count, level: calculateLevel(count), })) - .reverse(); // Optional: reverse the order if needed + .reverse(); } export const ProjectActivity = () => { @@ -64,10 +67,10 @@ export const ProjectActivity = () => { const levelledData = transformData(data.activityCountByDate); return ( - + <> {data.activityCountByDate.length > 0 ? ( - <> - Activity in project + + Activity in project { )} /> - + ) : ( No activity )} - + ); }; diff --git a/src/lib/features/events/event-store.ts b/src/lib/features/events/event-store.ts index 8b522f30bf8b..5be33de1bda5 100644 --- a/src/lib/features/events/event-store.ts +++ b/src/lib/features/events/event-store.ts @@ -409,7 +409,7 @@ class EventStore implements IEventStore { })); } - async getProjectEventActivity( + async getProjectRecentEventActivity( project: string, ): Promise { const result = await this.db('events') @@ -418,6 +418,11 @@ class EventStore implements IEventStore { ) .count('* AS count') .where('project', project) + .andWhere( + 'created_at', + '>=', + this.db.raw("NOW() - INTERVAL '1 year'"), + ) .groupBy(this.db.raw("TO_CHAR(created_at::date, 'YYYY-MM-DD')")) .orderBy('date', 'asc'); diff --git a/src/lib/features/project-status/project-status-service.ts b/src/lib/features/project-status/project-status-service.ts index 47352ca3e031..4603c6905f63 100644 --- a/src/lib/features/project-status/project-status-service.ts +++ b/src/lib/features/project-status/project-status-service.ts @@ -22,7 +22,7 @@ export class ProjectStatusService { ), }, activityCountByDate: - await this.eventStore.getProjectEventActivity(projectId), + await this.eventStore.getProjectRecentEventActivity(projectId), }; } } diff --git a/src/lib/types/stores/event-store.ts b/src/lib/types/stores/event-store.ts index aada71d1dea3..3cd13e255bfe 100644 --- a/src/lib/types/stores/event-store.ts +++ b/src/lib/types/stores/event-store.ts @@ -47,5 +47,7 @@ export interface IEventStore queryCount(operations: IQueryOperations[]): Promise; setCreatedByUserId(batchSize: number): Promise; getEventCreators(): Promise>; - getProjectEventActivity(project: string): Promise; + getProjectRecentEventActivity( + project: string, + ): Promise; } diff --git a/src/test/fixtures/fake-event-store.ts b/src/test/fixtures/fake-event-store.ts index ea4b63438bf4..db17fa148666 100644 --- a/src/test/fixtures/fake-event-store.ts +++ b/src/test/fixtures/fake-event-store.ts @@ -18,7 +18,9 @@ class FakeEventStore implements IEventStore { this.events = []; } - getProjectEventActivity(project: string): Promise { + getProjectRecentEventActivity( + project: string, + ): Promise { throw new Error('Method not implemented.'); } From 314a4d7113cc08c0baf9666840daebb844e4ba4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nuno=20G=C3=B3is?= Date: Wed, 6 Nov 2024 10:41:39 +0000 Subject: [PATCH 7/9] chore: new feature flag overview metadata panel (#8663) https://linear.app/unleash/issue/2-2920/update-the-flag-overview-metadata-properties-to-match-the-new-design Updates the feature flag overview metadata panel to match the new design. This redesign is behind a feature flag, so we opted to go with a duplicate file approach. We should remember to clean this up if we decide to remove the flag. ![image](https://github.com/user-attachments/assets/0eb8464c-8279-46a8-9f64-9d914f56db36) --- .../FeatureOverview/FeatureOverview.tsx | 15 +- .../DependencyActions.tsx | 22 +- .../FeatureOverviewMetaData/DependencyRow.tsx | 154 +++++----- .../FeatureOverviewMetaData.tsx | 271 +++++++---------- .../OldDependencyActions.tsx | 104 +++++++ .../OldDependencyRow.tsx | 219 ++++++++++++++ .../OldFeatureOverviewMetaData.tsx | 281 ++++++++++++++++++ .../FeatureOverviewMetaData/TagRow.tsx | 203 +++++++++++++ .../FeatureOverviewSidePanel.tsx | 12 +- .../OldFeatureOverviewSidePanel.tsx | 89 ++++++ .../ManageTagsDialog/ManageTagsDialog.tsx | 2 +- 11 files changed, 1114 insertions(+), 258 deletions(-) create mode 100644 frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewMetaData/OldDependencyActions.tsx create mode 100644 frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewMetaData/OldDependencyRow.tsx create mode 100644 frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewMetaData/OldFeatureOverviewMetaData.tsx create mode 100644 frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewMetaData/TagRow.tsx create mode 100644 frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewSidePanel/OldFeatureOverviewSidePanel.tsx diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverview.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverview.tsx index 3d6b7a0f1468..d1320c8ef19d 100644 --- a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverview.tsx +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverview.tsx @@ -1,4 +1,4 @@ -import FeatureOverviewMetaData from './FeatureOverviewMetaData/FeatureOverviewMetaData'; +import NewFeatureOverviewMetaData from './FeatureOverviewMetaData/FeatureOverviewMetaData'; import FeatureOverviewEnvironments from './FeatureOverviewEnvironments/FeatureOverviewEnvironments'; import { Route, Routes, useNavigate } from 'react-router-dom'; import { SidebarModal } from 'component/common/SidebarModal/SidebarModal'; @@ -8,12 +8,15 @@ import { } from 'component/feature/FeatureStrategy/FeatureStrategyEdit/FeatureStrategyEdit'; import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; import { usePageTitle } from 'hooks/usePageTitle'; -import { FeatureOverviewSidePanel } from 'component/feature/FeatureView/FeatureOverview/FeatureOverviewSidePanel/FeatureOverviewSidePanel'; +import { FeatureOverviewSidePanel as NewFeatureOverviewSidePanel } from 'component/feature/FeatureView/FeatureOverview/FeatureOverviewSidePanel/FeatureOverviewSidePanel'; import { useHiddenEnvironments } from 'hooks/useHiddenEnvironments'; import { styled } from '@mui/material'; import { FeatureStrategyCreate } from 'component/feature/FeatureStrategy/FeatureStrategyCreate/FeatureStrategyCreate'; import { useEffect } from 'react'; import { useLastViewedFlags } from 'hooks/useLastViewedFlags'; +import { useUiFlag } from 'hooks/useUiFlag'; +import OldFeatureOverviewMetaData from './FeatureOverviewMetaData/OldFeatureOverviewMetaData'; +import { OldFeatureOverviewSidePanel } from 'component/feature/FeatureView/FeatureOverview/FeatureOverviewSidePanel/OldFeatureOverviewSidePanel'; const StyledContainer = styled('div')(({ theme }) => ({ display: 'flex', @@ -46,6 +49,14 @@ const FeatureOverview = () => { setLastViewed({ featureId, projectId }); }, [featureId]); + const flagOverviewRedesign = useUiFlag('flagOverviewRedesign'); + const FeatureOverviewMetaData = flagOverviewRedesign + ? NewFeatureOverviewMetaData + : OldFeatureOverviewMetaData; + const FeatureOverviewSidePanel = flagOverviewRedesign + ? NewFeatureOverviewSidePanel + : OldFeatureOverviewSidePanel; + return (
diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewMetaData/DependencyActions.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewMetaData/DependencyActions.tsx index 375fa6c7a1f0..b3742cac024f 100644 --- a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewMetaData/DependencyActions.tsx +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewMetaData/DependencyActions.tsx @@ -1,5 +1,5 @@ import type React from 'react'; -import { type FC, useState } from 'react'; +import { useState } from 'react'; import { IconButton, ListItemIcon, @@ -16,16 +16,27 @@ import Delete from '@mui/icons-material/Delete'; import Edit from '@mui/icons-material/Edit'; import MoreVert from '@mui/icons-material/MoreVert'; +const StyledIconButton = styled(IconButton)(({ theme }) => ({ + height: theme.spacing(3.5), + width: theme.spacing(3.5), +})); + const StyledPopover = styled(Popover)(({ theme }) => ({ borderRadius: theme.shape.borderRadiusLarge, padding: theme.spacing(1, 1.5), })); -export const DependencyActions: FC<{ +interface IDependencyActionsProps { feature: string; onEdit: () => void; onDelete: () => void; -}> = ({ feature, onEdit, onDelete }) => { +} + +export const DependencyActions = ({ + feature, + onEdit, + onDelete, +}: IDependencyActionsProps) => { const id = `dependency-${feature}-actions`; const menuId = `${id}-menu`; @@ -42,8 +53,7 @@ export const DependencyActions: FC<{ return ( - - + ({ + '&&&': { + fontSize: theme.fontSizes.smallBody, + lineHeight: 1, + margin: 0, + }, +})); const useDeleteDependency = (project: string, featureId: string) => { const { trackEvent } = usePlausibleTracker(); @@ -83,7 +92,11 @@ const useDeleteDependency = (project: string, featureId: string) => { return deleteDependency; }; -export const DependencyRow: FC<{ feature: IFeatureToggle }> = ({ feature }) => { +interface IDependencyRowProps { + feature: IFeatureToggle; +} + +export const DependencyRow = ({ feature }: IDependencyRowProps) => { const [showDependencyDialogue, setShowDependencyDialogue] = useState(false); const canAddParentDependency = Boolean(feature.project) && @@ -103,55 +116,54 @@ export const DependencyRow: FC<{ feature: IFeatureToggle }> = ({ feature }) => { - - Dependency: - { - setShowDependencyDialogue(true); - }} - sx={(theme) => ({ - marginBottom: theme.spacing(0.4), - })} - > - Add parent feature - - - + + + Dependency: + + { + setShowDependencyDialogue(true); + }} + > + Add parent feature + + } /> - - Dependency: + + + Dependency: + + {feature.dependencies[0]?.feature} - - - setShowDependencyDialogue(true) - } - onDelete={deleteDependency} - /> - } - /> - + + setShowDependencyDialogue(true) + } + onDelete={deleteDependency} + /> + } + /> + + } /> = ({ feature }) => { hasParentDependency && !feature.dependencies[0]?.enabled } show={ - - - Dependency value: - disabled - - + + + Dependency value: + + disabled + } /> = ({ feature }) => { Boolean(feature.dependencies[0]?.variants?.length) } show={ - - - Dependency value: - - - + + + Dependency value: + + + } /> - - Children: - - - + + + Children: + + + } /> diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewMetaData/FeatureOverviewMetaData.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewMetaData/FeatureOverviewMetaData.tsx index fc3421697d38..ba8c1656391b 100644 --- a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewMetaData/FeatureOverviewMetaData.tsx +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewMetaData/FeatureOverviewMetaData.tsx @@ -1,262 +1,201 @@ -import { Box, capitalize, styled } from '@mui/material'; -import { Link, useNavigate } from 'react-router-dom'; +import { capitalize, styled } from '@mui/material'; +import { useNavigate } from 'react-router-dom'; import { useFeature } from 'hooks/api/getters/useFeature/useFeature'; import { getFeatureTypeIcons } from 'utils/getFeatureTypeIcons'; import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; -import Edit from '@mui/icons-material/Edit'; -import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton'; -import { UPDATE_FEATURE } from 'component/providers/AccessProvider/permissions'; import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; import { FeatureArchiveDialog } from 'component/common/FeatureArchiveDialog/FeatureArchiveDialog'; import { useState } from 'react'; import { FeatureArchiveNotAllowedDialog } from 'component/common/FeatureArchiveDialog/FeatureArchiveNotAllowedDialog'; -import { StyledDetail } from '../FeatureOverviewSidePanel/FeatureOverviewSidePanelDetails/StyledRow'; import { formatDateYMD } from 'utils/formatDate'; import { parseISO } from 'date-fns'; -import { FeatureEnvironmentSeen } from '../../FeatureEnvironmentSeen/FeatureEnvironmentSeen'; import { DependencyRow } from './DependencyRow'; import { useLocationSettings } from 'hooks/useLocationSettings'; import { useShowDependentFeatures } from './useShowDependentFeatures'; -import type { ILastSeenEnvironments } from 'interfaces/featureToggle'; import { FeatureLifecycle } from '../FeatureLifecycle/FeatureLifecycle'; import { MarkCompletedDialogue } from '../FeatureLifecycle/MarkCompletedDialogue'; import { UserAvatar } from 'component/common/UserAvatar/UserAvatar'; +import { TagRow } from './TagRow'; -const StyledContainer = styled('div')(({ theme }) => ({ +const StyledMetaDataContainer = styled('div')(({ theme }) => ({ + padding: theme.spacing(3), borderRadius: theme.shape.borderRadiusLarge, backgroundColor: theme.palette.background.paper, display: 'flex', flexDirection: 'column', - maxWidth: '350px', - minWidth: '350px', - marginRight: theme.spacing(2), + gap: theme.spacing(2), + width: '350px', [theme.breakpoints.down(1000)]: { width: '100%', - maxWidth: 'none', - minWidth: 'auto', }, })); -const StyledPaddingContainerTop = styled('div')({ - padding: '1.5rem 1.5rem 0 1.5rem', -}); - -const StyledMetaDataHeader = styled('div')({ +const StyledMetaDataHeader = styled('div')(({ theme }) => ({ display: 'flex', alignItems: 'center', -}); - -const StyledHeader = styled('h2')(({ theme }) => ({ - fontSize: theme.fontSizes.mainHeader, - fontWeight: 'normal', - margin: 0, + gap: theme.spacing(2), + '& > svg': { + height: theme.spacing(5), + width: theme.spacing(5), + padding: theme.spacing(0.5), + backgroundColor: theme.palette.background.alternative, + fill: theme.palette.primary.contrastText, + borderRadius: theme.shape.borderRadiusMedium, + }, + '& > h2': { + fontSize: theme.fontSizes.mainHeader, + fontWeight: 'normal', + }, })); -const StyledBody = styled('div')(({ theme }) => ({ - margin: theme.spacing(2, 0), +const StyledBody = styled('div')({ display: 'flex', flexDirection: 'column', - fontSize: theme.fontSizes.smallBody, -})); - -const BodyItemWithIcon = styled('div')(({ theme }) => ({})); - -const SpacedBodyItem = styled('div')(({ theme }) => ({ - display: 'flex', - justifyContent: 'space-between', - padding: theme.spacing(1, 0), -})); +}); -const StyledDescriptionContainer = styled('div')(({ theme }) => ({ +export const StyledMetaDataItem = styled('div')(({ theme }) => ({ display: 'flex', alignItems: 'center', justifyContent: 'space-between', + minHeight: theme.spacing(4.25), + fontSize: theme.fontSizes.smallBody, })); -const StyledDetailsContainer = styled('div')(({ theme }) => ({ - display: 'flex', - alignItems: 'center', - justifyContent: 'space-between', +export const StyledMetaDataItemLabel = styled('span')(({ theme }) => ({ + color: theme.palette.text.secondary, + marginRight: theme.spacing(1), })); -const StyledDescription = styled('p')({ - wordBreak: 'break-word', +const StyledMetaDataItemText = styled('span')({ + overflowWrap: 'anywhere', }); -const StyledUserAvatar = styled(UserAvatar)(({ theme }) => ({ - margin: theme.spacing(1), +export const StyledMetaDataItemValue = styled('div')(({ theme }) => ({ + display: 'flex', + alignItems: 'center', + gap: theme.spacing(1), })); -export const StyledLabel = styled('span')(({ theme }) => ({ - color: theme.palette.text.secondary, - marginRight: theme.spacing(1), +const StyledUserAvatar = styled(UserAvatar)(({ theme }) => ({ + height: theme.spacing(3.5), + width: theme.spacing(3.5), })); const FeatureOverviewMetaData = () => { const projectId = useRequiredPathParam('projectId'); const featureId = useRequiredPathParam('featureId'); const { feature, refetchFeature } = useFeature(projectId, featureId); - const { project, description, type } = feature; + + const { locationSettings } = useLocationSettings(); const navigate = useNavigate(); - const [showDelDialog, setShowDelDialog] = useState(false); - const [showMarkCompletedDialogue, setShowMarkCompletedDialogue] = + + const [archiveDialogOpen, setArchiveDialogOpen] = useState(false); + const [markCompletedDialogueOpen, setMarkCompletedDialogueOpen] = useState(false); - const { locationSettings } = useLocationSettings(); - const showDependentFeatures = useShowDependentFeatures(feature.project); + const { project, description, type } = feature; - const lastSeenEnvironments: ILastSeenEnvironments[] = - feature.environments?.map((env) => ({ - name: env.name, - lastSeenAt: env.lastSeenAt, - enabled: env.enabled, - yes: env.yes, - no: env.no, - })); + const showDependentFeatures = useShowDependentFeatures(project); - const IconComponent = getFeatureTypeIcons(type); + const FlagTypeIcon = getFeatureTypeIcons(type); return ( - - + <> + - ({ - marginRight: theme.spacing(2), - height: '40px', - width: '40px', - padding: theme.spacing(0.5), - backgroundColor: - theme.palette.background.alternative, - fill: theme.palette.primary.contrastText, - borderRadius: `${theme.shape.borderRadiusMedium}px`, - })} - />{' '} - {capitalize(type || '')} toggle + +

{capitalize(type || '')} flag

+ + + {description} + + + } + /> - - Project: - {project} - + + + Project: + + + {project} + + - Lifecycle: + + + Lifecycle: + setShowDelDialog(true)} + onArchive={() => setArchiveDialogOpen(true)} onComplete={() => - setShowMarkCompletedDialogue(true) + setMarkCompletedDialogueOpen(true) } onUncomplete={refetchFeature} /> - + } /> - - - Description: - - - {description} - - - - - - - } - elseShow={ -
- - No description.{' '} - - - - -
- } - /> - - - - Created at: - - {formatDateYMD( - parseISO(feature.createdAt), - locationSettings.locale, - )} - - - - - - + + + Created at: + + + {formatDateYMD( + parseISO(feature.createdAt), + locationSettings.locale, + )} + + ( - - - - Created by: - {feature.createdBy?.name} - + + + Created by: + + + + {feature.createdBy?.name} + - - + + )} /> } /> +
-
+ 0} show={ setShowDelDialog(false)} + isOpen={archiveDialogOpen} + onClose={() => setArchiveDialogOpen(false)} /> } elseShow={ { navigate(`/projects/${projectId}`); }} - onClose={() => setShowDelDialog(false)} + onClose={() => setArchiveDialogOpen(false)} projectId={projectId} featureIds={[featureId]} /> @@ -266,15 +205,15 @@ const FeatureOverviewMetaData = () => { condition={Boolean(feature.project)} show={ } /> -
+ ); }; diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewMetaData/OldDependencyActions.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewMetaData/OldDependencyActions.tsx new file mode 100644 index 000000000000..0e294a04c5ca --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewMetaData/OldDependencyActions.tsx @@ -0,0 +1,104 @@ +import type React from 'react'; +import { type FC, useState } from 'react'; +import { + IconButton, + ListItemIcon, + ListItemText, + MenuItem, + MenuList, + Popover, + styled, + Tooltip, + Typography, + Box, +} from '@mui/material'; +import Delete from '@mui/icons-material/Delete'; +import Edit from '@mui/icons-material/Edit'; +import MoreVert from '@mui/icons-material/MoreVert'; + +const StyledPopover = styled(Popover)(({ theme }) => ({ + borderRadius: theme.shape.borderRadiusLarge, + padding: theme.spacing(1, 1.5), +})); + +export const OldDependencyActions: FC<{ + feature: string; + onEdit: () => void; + onDelete: () => void; +}> = ({ feature, onEdit, onDelete }) => { + const id = `dependency-${feature}-actions`; + const menuId = `${id}-menu`; + + const [anchorEl, setAnchorEl] = useState(null); + + const open = Boolean(anchorEl); + const openActions = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + const closeActions = () => { + setAnchorEl(null); + }; + + return ( + + + + + + + + + { + onEdit(); + closeActions(); + }} + > + + + + + Edit + + + + { + onDelete(); + closeActions(); + }} + > + + + + + Delete + + + + + + ); +}; diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewMetaData/OldDependencyRow.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewMetaData/OldDependencyRow.tsx new file mode 100644 index 000000000000..b27acdf87907 --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewMetaData/OldDependencyRow.tsx @@ -0,0 +1,219 @@ +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { AddDependencyDialogue } from 'component/feature/Dependencies/AddDependencyDialogue'; +import type { IFeatureToggle } from 'interfaces/featureToggle'; +import { type FC, useState } from 'react'; +import { + FlexRow, + StyledDetail, + StyledLabel, + StyledLink, +} from '../FeatureOverviewSidePanel/FeatureOverviewSidePanelDetails/StyledRow'; +import { OldDependencyActions } from './OldDependencyActions'; +import { useDependentFeaturesApi } from 'hooks/api/actions/useDependentFeaturesApi/useDependentFeaturesApi'; +import { useFeature } from 'hooks/api/getters/useFeature/useFeature'; +import { ChildrenTooltip } from './ChildrenTooltip'; +import PermissionButton from 'component/common/PermissionButton/PermissionButton'; +import { UPDATE_FEATURE_DEPENDENCY } from 'component/providers/AccessProvider/permissions'; +import { useCheckProjectAccess } from 'hooks/useHasAccess'; +import { useChangeRequestApi } from 'hooks/api/actions/useChangeRequestApi/useChangeRequestApi'; +import { usePendingChangeRequests } from 'hooks/api/getters/usePendingChangeRequests/usePendingChangeRequests'; +import useToast from 'hooks/useToast'; +import { useHighestPermissionChangeRequestEnvironment } from 'hooks/useHighestPermissionChangeRequestEnvironment'; +import { useChangeRequestsEnabled } from 'hooks/useChangeRequestsEnabled'; +import { formatUnknownError } from 'utils/formatUnknownError'; +import { usePlausibleTracker } from 'hooks/usePlausibleTracker'; +import { VariantsTooltip } from './VariantsTooltip'; + +const useDeleteDependency = (project: string, featureId: string) => { + const { trackEvent } = usePlausibleTracker(); + const { addChange } = useChangeRequestApi(); + const { refetch: refetchChangeRequests } = + usePendingChangeRequests(project); + const { setToastData, setToastApiError } = useToast(); + const { refetchFeature } = useFeature(project, featureId); + const environment = useHighestPermissionChangeRequestEnvironment(project)(); + const { isChangeRequestConfiguredInAnyEnv } = + useChangeRequestsEnabled(project); + const { removeDependencies } = useDependentFeaturesApi(project); + + const handleAddChange = async () => { + if (!environment) { + console.error('No change request environment'); + return; + } + await addChange(project, environment, [ + { + action: 'deleteDependency', + feature: featureId, + payload: undefined, + }, + ]); + }; + + const deleteDependency = async () => { + try { + if (isChangeRequestConfiguredInAnyEnv()) { + await handleAddChange(); + trackEvent('dependent_features', { + props: { + eventType: 'delete dependency added to change request', + }, + }); + setToastData({ + text: `${featureId} dependency will be removed`, + type: 'success', + title: 'Change added to a draft', + }); + await refetchChangeRequests(); + } else { + await removeDependencies(featureId); + trackEvent('dependent_features', { + props: { + eventType: 'dependency removed', + }, + }); + setToastData({ title: 'Dependency removed', type: 'success' }); + await refetchFeature(); + } + } catch (error) { + setToastApiError(formatUnknownError(error)); + } + }; + + return deleteDependency; +}; + +export const OldDependencyRow: FC<{ feature: IFeatureToggle }> = ({ + feature, +}) => { + const [showDependencyDialogue, setShowDependencyDialogue] = useState(false); + const canAddParentDependency = + Boolean(feature.project) && + feature.dependencies.length === 0 && + feature.children.length === 0; + const hasParentDependency = + Boolean(feature.project) && Boolean(feature.dependencies.length > 0); + const hasChildren = Boolean(feature.project) && feature.children.length > 0; + const environment = useHighestPermissionChangeRequestEnvironment( + feature.project, + )(); + const checkAccess = useCheckProjectAccess(feature.project); + const deleteDependency = useDeleteDependency(feature.project, feature.name); + + return ( + <> + + + Dependency: + { + setShowDependencyDialogue(true); + }} + sx={(theme) => ({ + marginBottom: theme.spacing(0.4), + })} + > + Add parent feature + + + + } + /> + + + Dependency: + + {feature.dependencies[0]?.feature} + + + + setShowDependencyDialogue(true) + } + onDelete={deleteDependency} + /> + } + /> + + } + /> + + + Dependency value: + disabled + + + } + /> + + + Dependency value: + + + + } + /> + + + Children: + + + + } + /> + + setShowDependencyDialogue(false)} + showDependencyDialogue={showDependencyDialogue} + /> + } + /> + + ); +}; diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewMetaData/OldFeatureOverviewMetaData.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewMetaData/OldFeatureOverviewMetaData.tsx new file mode 100644 index 000000000000..a6e86dedbce4 --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewMetaData/OldFeatureOverviewMetaData.tsx @@ -0,0 +1,281 @@ +import { Box, capitalize, styled } from '@mui/material'; +import { Link, useNavigate } from 'react-router-dom'; +import { useFeature } from 'hooks/api/getters/useFeature/useFeature'; +import { getFeatureTypeIcons } from 'utils/getFeatureTypeIcons'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import Edit from '@mui/icons-material/Edit'; +import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton'; +import { UPDATE_FEATURE } from 'component/providers/AccessProvider/permissions'; +import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; +import { FeatureArchiveDialog } from 'component/common/FeatureArchiveDialog/FeatureArchiveDialog'; +import { useState } from 'react'; +import { FeatureArchiveNotAllowedDialog } from 'component/common/FeatureArchiveDialog/FeatureArchiveNotAllowedDialog'; +import { StyledDetail } from '../FeatureOverviewSidePanel/FeatureOverviewSidePanelDetails/StyledRow'; +import { formatDateYMD } from 'utils/formatDate'; +import { parseISO } from 'date-fns'; +import { FeatureEnvironmentSeen } from '../../FeatureEnvironmentSeen/FeatureEnvironmentSeen'; +import { OldDependencyRow } from './OldDependencyRow'; +import { useLocationSettings } from 'hooks/useLocationSettings'; +import { useShowDependentFeatures } from './useShowDependentFeatures'; +import type { ILastSeenEnvironments } from 'interfaces/featureToggle'; +import { FeatureLifecycle } from '../FeatureLifecycle/FeatureLifecycle'; +import { MarkCompletedDialogue } from '../FeatureLifecycle/MarkCompletedDialogue'; +import { UserAvatar } from 'component/common/UserAvatar/UserAvatar'; + +const StyledContainer = styled('div')(({ theme }) => ({ + borderRadius: theme.shape.borderRadiusLarge, + backgroundColor: theme.palette.background.paper, + display: 'flex', + flexDirection: 'column', + maxWidth: '350px', + minWidth: '350px', + marginRight: theme.spacing(2), + [theme.breakpoints.down(1000)]: { + width: '100%', + maxWidth: 'none', + minWidth: 'auto', + }, +})); + +const StyledPaddingContainerTop = styled('div')({ + padding: '1.5rem 1.5rem 0 1.5rem', +}); + +const StyledMetaDataHeader = styled('div')({ + display: 'flex', + alignItems: 'center', +}); + +const StyledHeader = styled('h2')(({ theme }) => ({ + fontSize: theme.fontSizes.mainHeader, + fontWeight: 'normal', + margin: 0, +})); + +const StyledBody = styled('div')(({ theme }) => ({ + margin: theme.spacing(2, 0), + display: 'flex', + flexDirection: 'column', + fontSize: theme.fontSizes.smallBody, +})); + +const BodyItemWithIcon = styled('div')(({ theme }) => ({})); + +const SpacedBodyItem = styled('div')(({ theme }) => ({ + display: 'flex', + justifyContent: 'space-between', + padding: theme.spacing(1, 0), +})); + +const StyledDescriptionContainer = styled('div')(({ theme }) => ({ + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', +})); + +const StyledDetailsContainer = styled('div')(({ theme }) => ({ + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', +})); + +const StyledDescription = styled('p')({ + wordBreak: 'break-word', +}); + +const StyledUserAvatar = styled(UserAvatar)(({ theme }) => ({ + margin: theme.spacing(1), +})); + +export const StyledLabel = styled('span')(({ theme }) => ({ + color: theme.palette.text.secondary, + marginRight: theme.spacing(1), +})); + +const OldFeatureOverviewMetaData = () => { + const projectId = useRequiredPathParam('projectId'); + const featureId = useRequiredPathParam('featureId'); + const { feature, refetchFeature } = useFeature(projectId, featureId); + const { project, description, type } = feature; + const navigate = useNavigate(); + const [showDelDialog, setShowDelDialog] = useState(false); + const [showMarkCompletedDialogue, setShowMarkCompletedDialogue] = + useState(false); + + const { locationSettings } = useLocationSettings(); + const showDependentFeatures = useShowDependentFeatures(feature.project); + + const lastSeenEnvironments: ILastSeenEnvironments[] = + feature.environments?.map((env) => ({ + name: env.name, + lastSeenAt: env.lastSeenAt, + enabled: env.enabled, + yes: env.yes, + no: env.no, + })); + + const IconComponent = getFeatureTypeIcons(type); + + return ( + + + + ({ + marginRight: theme.spacing(2), + height: '40px', + width: '40px', + padding: theme.spacing(0.5), + backgroundColor: + theme.palette.background.alternative, + fill: theme.palette.primary.contrastText, + borderRadius: `${theme.shape.borderRadiusMedium}px`, + })} + />{' '} + {capitalize(type || '')} toggle + + + + Project: + {project} + + + Lifecycle: + setShowDelDialog(true)} + onComplete={() => + setShowMarkCompletedDialogue(true) + } + onUncomplete={refetchFeature} + /> + + } + /> + + + Description: + + + {description} + + + + + + + } + elseShow={ +
+ + No description.{' '} + + + + +
+ } + /> + + + + Created at: + + {formatDateYMD( + parseISO(feature.createdAt), + locationSettings.locale, + )} + + + + + + + ( + + + + Created by: + {feature.createdBy?.name} + + + + + )} + /> + } + /> +
+
+ 0} + show={ + setShowDelDialog(false)} + /> + } + elseShow={ + { + navigate(`/projects/${projectId}`); + }} + onClose={() => setShowDelDialog(false)} + projectId={projectId} + featureIds={[featureId]} + /> + } + /> + + } + /> +
+ ); +}; + +export default OldFeatureOverviewMetaData; diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewMetaData/TagRow.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewMetaData/TagRow.tsx new file mode 100644 index 000000000000..1ec11675a0bc --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewMetaData/TagRow.tsx @@ -0,0 +1,203 @@ +import type { IFeatureToggle } from 'interfaces/featureToggle'; +import { useContext, useState } from 'react'; +import { Chip, styled, Tooltip } from '@mui/material'; +import useFeatureTags from 'hooks/api/getters/useFeatureTags/useFeatureTags'; +import Add from '@mui/icons-material/Add'; +import ClearIcon from '@mui/icons-material/Clear'; +import { ManageTagsDialog } from 'component/feature/FeatureView/FeatureOverview/ManageTagsDialog/ManageTagsDialog'; +import { UPDATE_FEATURE } from 'component/providers/AccessProvider/permissions'; +import AccessContext from 'contexts/AccessContext'; +import { Dialogue } from 'component/common/Dialogue/Dialogue'; +import type { ITag } from 'interfaces/tags'; +import useFeatureApi from 'hooks/api/actions/useFeatureApi/useFeatureApi'; +import useToast from 'hooks/useToast'; +import { formatUnknownError } from 'utils/formatUnknownError'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { + StyledMetaDataItem, + StyledMetaDataItemLabel, +} from './FeatureOverviewMetaData'; +import PermissionButton from 'component/common/PermissionButton/PermissionButton'; + +const StyledPermissionButton = styled(PermissionButton)(({ theme }) => ({ + '&&&': { + fontSize: theme.fontSizes.smallBody, + lineHeight: 1, + margin: 0, + }, +})); + +const StyledTagRow = styled('div')(({ theme }) => ({ + display: 'flex', + alignItems: 'start', + minHeight: theme.spacing(4.25), + lineHeight: theme.spacing(4.25), + fontSize: theme.fontSizes.smallBody, + justifyContent: 'start', +})); + +const StyledTagContainer = styled('div')(({ theme }) => ({ + display: 'flex', + flex: 1, + overflow: 'hidden', + gap: theme.spacing(1), + flexWrap: 'wrap', + marginTop: theme.spacing(0.75), +})); + +const StyledChip = styled(Chip)(({ theme }) => ({ + fontSize: theme.fontSizes.smallerBody, + overflowWrap: 'anywhere', + backgroundColor: theme.palette.neutral.light, + color: theme.palette.neutral.dark, + '&&& > svg': { + color: theme.palette.neutral.dark, + fontSize: theme.fontSizes.smallBody, + }, +})); + +const StyledAddedTag = styled(StyledChip)(({ theme }) => ({ + backgroundColor: theme.palette.secondary.light, + color: theme.palette.secondary.dark, + '&&& > svg': { + color: theme.palette.secondary.dark, + fontSize: theme.fontSizes.smallBody, + }, +})); + +interface IFeatureOverviewSidePanelTagsProps { + feature: IFeatureToggle; +} + +export const TagRow = ({ feature }: IFeatureOverviewSidePanelTagsProps) => { + const { tags, refetch } = useFeatureTags(feature.name); + const { deleteTagFromFeature } = useFeatureApi(); + + const [manageTagsOpen, setManageTagsOpen] = useState(false); + const [removeTagOpen, setRemoveTagOpen] = useState(false); + const [selectedTag, setSelectedTag] = useState(); + + const { setToastData, setToastApiError } = useToast(); + const { hasAccess } = useContext(AccessContext); + const canUpdateTags = hasAccess(UPDATE_FEATURE, feature.project); + + const handleRemove = async () => { + if (!selectedTag) return; + try { + await deleteTagFromFeature( + feature.name, + selectedTag.type, + selectedTag.value, + ); + refetch(); + setToastData({ + type: 'success', + title: 'Tag removed', + text: 'Successfully removed tag', + }); + } catch (error: unknown) { + setToastApiError(formatUnknownError(error)); + } + }; + + return ( + <> + + Tags: + { + setManageTagsOpen(true); + }} + > + Add tag + + + } + elseShow={ + + Tags: + + {tags.map((tag) => { + const tagLabel = `${tag.type}:${tag.value}`; + return ( + 35 ? tagLabel : '' + } + arrow + > + + + + } + onDelete={ + canUpdateTags + ? () => { + setRemoveTagOpen( + true, + ); + setSelectedTag(tag); + } + : undefined + } + /> + + ); + })} + } + label='Add tag' + size='small' + onClick={() => setManageTagsOpen(true)} + /> + } + /> + + + } + /> + + { + setRemoveTagOpen(false); + setSelectedTag(undefined); + }} + onClick={() => { + setRemoveTagOpen(false); + handleRemove(); + setSelectedTag(undefined); + }} + title='Remove tag' + > + You are about to remove tag:{' '} + + {selectedTag?.type}:{selectedTag?.value} + + + + ); +}; diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewSidePanel/FeatureOverviewSidePanel.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewSidePanel/FeatureOverviewSidePanel.tsx index adfdab56780d..623aa34d3d75 100644 --- a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewSidePanel/FeatureOverviewSidePanel.tsx +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewSidePanel/FeatureOverviewSidePanel.tsx @@ -1,9 +1,8 @@ -import { Box, Divider, styled } from '@mui/material'; +import { Box, styled } from '@mui/material'; import { HelpIcon } from 'component/common/HelpIcon/HelpIcon'; import { useFeature } from 'hooks/api/getters/useFeature/useFeature'; import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; import { FeatureOverviewSidePanelEnvironmentSwitches } from './FeatureOverviewSidePanelEnvironmentSwitches/FeatureOverviewSidePanelEnvironmentSwitches'; -import { FeatureOverviewSidePanelTags } from './FeatureOverviewSidePanelTags/FeatureOverviewSidePanelTags'; import { Sticky } from 'component/common/Sticky/Sticky'; const StyledContainer = styled(Box)(({ theme }) => ({ @@ -75,15 +74,6 @@ export const FeatureOverviewSidePanel = ({ hiddenEnvironments={hiddenEnvironments} setHiddenEnvironments={setHiddenEnvironments} /> - - - Tags for this feature flag - - } - feature={feature} - /> ); }; diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewSidePanel/OldFeatureOverviewSidePanel.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewSidePanel/OldFeatureOverviewSidePanel.tsx new file mode 100644 index 000000000000..f26d7811a65c --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewSidePanel/OldFeatureOverviewSidePanel.tsx @@ -0,0 +1,89 @@ +import { Box, Divider, styled } from '@mui/material'; +import { HelpIcon } from 'component/common/HelpIcon/HelpIcon'; +import { useFeature } from 'hooks/api/getters/useFeature/useFeature'; +import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; +import { FeatureOverviewSidePanelEnvironmentSwitches } from './FeatureOverviewSidePanelEnvironmentSwitches/FeatureOverviewSidePanelEnvironmentSwitches'; +import { FeatureOverviewSidePanelTags } from './FeatureOverviewSidePanelTags/FeatureOverviewSidePanelTags'; +import { Sticky } from 'component/common/Sticky/Sticky'; + +const StyledContainer = styled(Box)(({ theme }) => ({ + top: theme.spacing(2), + borderRadius: theme.shape.borderRadiusLarge, + backgroundColor: theme.palette.background.paper, + display: 'flex', + flexDirection: 'column', + maxWidth: '350px', + minWidth: '350px', + marginRight: '1rem', + marginTop: '1rem', + [theme.breakpoints.down(1000)]: { + marginBottom: '1rem', + width: '100%', + maxWidth: 'none', + minWidth: 'auto', + }, +})); + +const StyledHeader = styled('h3')(({ theme }) => ({ + display: 'flex', + gap: theme.spacing(1), + alignItems: 'center', + fontSize: theme.fontSizes.bodySize, + margin: 0, + marginBottom: theme.spacing(3), + + // Make the help icon align with the text. + '& > :last-child': { + position: 'relative', + top: 1, + }, +})); + +interface IFeatureOverviewSidePanelProps { + hiddenEnvironments: Set; + setHiddenEnvironments: (environment: string) => void; +} + +export const OldFeatureOverviewSidePanel = ({ + hiddenEnvironments, + setHiddenEnvironments, +}: IFeatureOverviewSidePanelProps) => { + const projectId = useRequiredPathParam('projectId'); + const featureId = useRequiredPathParam('featureId'); + const { feature } = useFeature(projectId, featureId); + const isSticky = feature.environments?.length <= 3; + + return ( + + + Enabled in environments ( + { + feature.environments.filter( + ({ enabled }) => enabled, + ).length + } + ) + + + } + feature={feature} + hiddenEnvironments={hiddenEnvironments} + setHiddenEnvironments={setHiddenEnvironments} + /> + + + Tags for this feature flag + + } + feature={feature} + /> + + ); +}; diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/ManageTagsDialog/ManageTagsDialog.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/ManageTagsDialog/ManageTagsDialog.tsx index ed516a54152e..df0a742e5105 100644 --- a/frontend/src/component/feature/FeatureView/FeatureOverview/ManageTagsDialog/ManageTagsDialog.tsx +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/ManageTagsDialog/ManageTagsDialog.tsx @@ -88,7 +88,7 @@ export const ManageTagsDialog = ({ open, setOpen }: IManageTagsProps) => { tagsToOptions(tags.filter((tag) => tag.type === tagType.name)), ); } - }, [JSON.stringify(tags), tagType]); + }, [JSON.stringify(tags), tagType, open]); const onCancel = () => { setOpen(false); From 328fac39a26281bd3e9c344850fd957829f6809b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nuno=20G=C3=B3is?= Date: Wed, 6 Nov 2024 11:09:33 +0000 Subject: [PATCH 8/9] fix: console errors from highlight component and tag key prop placement (#8669) Addressing some oversights that led to browser console errors. This PR fixes console errors related to the recently introduced highlight component (#8643) and tag row component in the new flag metadata panel (#8663). --- .../component/common/Highlight/Highlight.tsx | 26 ++++++++++++------- .../FeatureOverviewMetaData/TagRow.tsx | 2 +- 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/frontend/src/component/common/Highlight/Highlight.tsx b/frontend/src/component/common/Highlight/Highlight.tsx index b4e66197f977..e977fc3e7bf2 100644 --- a/frontend/src/component/common/Highlight/Highlight.tsx +++ b/frontend/src/component/common/Highlight/Highlight.tsx @@ -1,5 +1,5 @@ import { alpha, styled } from '@mui/material'; -import type { ReactNode } from 'react'; +import { forwardRef, type HTMLAttributes, type ReactNode } from 'react'; import { useHighlightContext } from './HighlightContext'; import type { HighlightKey } from './HighlightProvider'; @@ -27,17 +27,23 @@ const StyledHighlight = styled('div', { }, })); -interface IHighlightProps { +interface IHighlightProps extends HTMLAttributes { highlightKey: HighlightKey; children: ReactNode; } -export const Highlight = ({ highlightKey, children }: IHighlightProps) => { - const { isHighlighted } = useHighlightContext(); +export const Highlight = forwardRef( + ({ highlightKey, children, ...props }, ref) => { + const { isHighlighted } = useHighlightContext(); - return ( - - {children} - - ); -}; + return ( + + {children} + + ); + }, +); diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewMetaData/TagRow.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewMetaData/TagRow.tsx index 1ec11675a0bc..583b771110e9 100644 --- a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewMetaData/TagRow.tsx +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewMetaData/TagRow.tsx @@ -128,13 +128,13 @@ export const TagRow = ({ feature }: IFeatureOverviewSidePanelTagsProps) => { const tagLabel = `${tag.type}:${tag.value}`; return ( 35 ? tagLabel : '' } arrow > Date: Wed, 6 Nov 2024 12:15:01 +0100 Subject: [PATCH 9/9] task: Added Release Plan Template events (#8668) As part of the release plan template work. This PR adds the three events for actions with the templates. Actually activating milestones should probably trigger existing FeatureStrategyAdd events when adding and FeatureStrategyRemove when changing milestones. --- src/lib/types/events.ts | 45 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/src/lib/types/events.ts b/src/lib/types/events.ts index 865b97cf4f27..3ddee00068c2 100644 --- a/src/lib/types/events.ts +++ b/src/lib/types/events.ts @@ -204,6 +204,12 @@ export const ACTIONS_CREATED = 'actions-created' as const; export const ACTIONS_UPDATED = 'actions-updated' as const; export const ACTIONS_DELETED = 'actions-deleted' as const; +export const RELEASE_PLAN_TEMPLATE_CREATED = + 'release-plan-template-created' as const; +export const RELEASE_PLAN_TEMPLATE_UPDATED = + 'release-plan-template-updated' as const; +export const RELEASE_PLAN_TEMPLATE_DELETED = + 'release-plan-template-deleted' as const; export const USER_PREFERENCE_UPDATED = 'user-preference-updated' as const; export const IEventTypes = [ @@ -353,6 +359,9 @@ export const IEventTypes = [ ACTIONS_CREATED, ACTIONS_UPDATED, ACTIONS_DELETED, + RELEASE_PLAN_TEMPLATE_CREATED, + RELEASE_PLAN_TEMPLATE_UPDATED, + RELEASE_PLAN_TEMPLATE_DELETED, USER_PREFERENCE_UPDATED, ] as const; export type IEventType = (typeof IEventTypes)[number]; @@ -2012,6 +2021,42 @@ export class GroupDeletedEvent extends BaseEvent { } } +export class ReleasePlanTemplateCreatedEvent extends BaseEvent { + readonly data: any; + constructor(eventData: { + data: any; + auditUser: IAuditUser; + }) { + super(RELEASE_PLAN_TEMPLATE_CREATED, eventData.auditUser); + this.data = eventData.data; + } +} + +export class ReleasePlanTemplateUpdatedEvent extends BaseEvent { + readonly preData: any; + readonly data: any; + constructor(eventData: { + data: any; + preData: any; + auditUser: IAuditUser; + }) { + super(RELEASE_PLAN_TEMPLATE_UPDATED, eventData.auditUser); + this.data = eventData.data; + this.preData = eventData.preData; + } +} + +export class ReleasePlanTemplateDeletedEvent extends BaseEvent { + readonly preData: any; + constructor(eventData: { + preData: any; + auditUser: IAuditUser; + }) { + super(RELEASE_PLAN_TEMPLATE_DELETED, eventData.auditUser); + this.preData = eventData.preData; + } +} + interface IUserEventData extends Pick< IUserWithRootRole,