add dynamic dates for webhookGraphDataUsage (#7720)
**Before:** Only last 5 days where displayed on Developers Settings Webhook Usage Graph.  **Now** Added component where you can select the time range where you want to view the webhook usage. To do better the styling and content depassing . <img width="652" alt="Screenshot 2024-10-15 at 16 56 45" src="https://github.com/user-attachments/assets/d06e7f4c-a689-49a0-8839-f015ce36bab9"> **In order to test** 1. Set ANALYTICS_ENABLED to true 2. Set TINYBIRD_TOKEN to your token from the workspace twenty_analytics_playground 3. Write your client tinybird token in SettingsDeveloppersWebhookDetail.tsx in line 93 4. Create a Webhook in twenty and set wich events it needs to track 5. Run twenty-worker in order to make the webhooks work. 6. Do your tasks in order to populate the data 7. Enter to settings> webhook>your webhook and the statistics section should be displayed. 8. Select the desired time range in the dropdown **To do list** - Tooltip is truncated when accessing values at the right end of the graph - DateTicks needs to follow a more clear standard - Update this PR with more representative images
This commit is contained in:
committed by
GitHub
parent
0c24001e23
commit
8cadcdf577
@ -141,6 +141,7 @@ export enum CaptchaDriverType {
|
|||||||
|
|
||||||
export type ClientConfig = {
|
export type ClientConfig = {
|
||||||
__typename?: 'ClientConfig';
|
__typename?: 'ClientConfig';
|
||||||
|
analyticsEnabled: Scalars['Boolean'];
|
||||||
api: ApiConfig;
|
api: ApiConfig;
|
||||||
authProviders: AuthProviders;
|
authProviders: AuthProviders;
|
||||||
billing: Billing;
|
billing: Billing;
|
||||||
@ -1599,7 +1600,7 @@ export type UpdateBillingSubscriptionMutation = { __typename?: 'Mutation', updat
|
|||||||
export type GetClientConfigQueryVariables = Exact<{ [key: string]: never; }>;
|
export type GetClientConfigQueryVariables = Exact<{ [key: string]: never; }>;
|
||||||
|
|
||||||
|
|
||||||
export type GetClientConfigQuery = { __typename?: 'Query', clientConfig: { __typename?: 'ClientConfig', signInPrefilled: boolean, signUpDisabled: boolean, debugMode: boolean, chromeExtensionId?: string | null, authProviders: { __typename?: 'AuthProviders', google: boolean, password: boolean, microsoft: boolean }, billing: { __typename?: 'Billing', isBillingEnabled: boolean, billingUrl?: string | null, billingFreeTrialDurationInDays?: number | null }, support: { __typename?: 'Support', supportDriver: string, supportFrontChatId?: string | null }, sentry: { __typename?: 'Sentry', dsn?: string | null, environment?: string | null, release?: string | null }, captcha: { __typename?: 'Captcha', provider?: CaptchaDriverType | null, siteKey?: string | null }, api: { __typename?: 'ApiConfig', mutationMaximumAffectedRecords: number } } };
|
export type GetClientConfigQuery = { __typename?: 'Query', clientConfig: { __typename?: 'ClientConfig', signInPrefilled: boolean, signUpDisabled: boolean, debugMode: boolean, analyticsEnabled: boolean, chromeExtensionId?: string | null, authProviders: { __typename?: 'AuthProviders', google: boolean, password: boolean, microsoft: boolean }, billing: { __typename?: 'Billing', isBillingEnabled: boolean, billingUrl?: string | null, billingFreeTrialDurationInDays?: number | null }, support: { __typename?: 'Support', supportDriver: string, supportFrontChatId?: string | null }, sentry: { __typename?: 'Sentry', dsn?: string | null, environment?: string | null, release?: string | null }, captcha: { __typename?: 'Captcha', provider?: CaptchaDriverType | null, siteKey?: string | null }, api: { __typename?: 'ApiConfig', mutationMaximumAffectedRecords: number } } };
|
||||||
|
|
||||||
export type SkipSyncEmailOnboardingStepMutationVariables = Exact<{ [key: string]: never; }>;
|
export type SkipSyncEmailOnboardingStepMutationVariables = Exact<{ [key: string]: never; }>;
|
||||||
|
|
||||||
@ -2765,6 +2766,7 @@ export const GetClientConfigDocument = gql`
|
|||||||
signInPrefilled
|
signInPrefilled
|
||||||
signUpDisabled
|
signUpDisabled
|
||||||
debugMode
|
debugMode
|
||||||
|
analyticsEnabled
|
||||||
support {
|
support {
|
||||||
supportDriver
|
supportDriver
|
||||||
supportFrontChatId
|
supportFrontChatId
|
||||||
|
|||||||
@ -32,6 +32,8 @@ import {
|
|||||||
import { isDefined } from '~/utils/isDefined';
|
import { isDefined } from '~/utils/isDefined';
|
||||||
|
|
||||||
import { currentWorkspaceMembersState } from '@/auth/states/currentWorkspaceMembersStates';
|
import { currentWorkspaceMembersState } from '@/auth/states/currentWorkspaceMembersStates';
|
||||||
|
import { DateFormat } from '@/localization/constants/DateFormat';
|
||||||
|
import { TimeFormat } from '@/localization/constants/TimeFormat';
|
||||||
import { dateTimeFormatState } from '@/localization/states/dateTimeFormatState';
|
import { dateTimeFormatState } from '@/localization/states/dateTimeFormatState';
|
||||||
import { detectDateFormat } from '@/localization/utils/detectDateFormat';
|
import { detectDateFormat } from '@/localization/utils/detectDateFormat';
|
||||||
import { detectTimeFormat } from '@/localization/utils/detectTimeFormat';
|
import { detectTimeFormat } from '@/localization/utils/detectTimeFormat';
|
||||||
@ -143,12 +145,12 @@ export const useAuth = () => {
|
|||||||
? getDateFormatFromWorkspaceDateFormat(
|
? getDateFormatFromWorkspaceDateFormat(
|
||||||
user.workspaceMember.dateFormat,
|
user.workspaceMember.dateFormat,
|
||||||
)
|
)
|
||||||
: detectDateFormat(),
|
: DateFormat[detectDateFormat()],
|
||||||
timeFormat: isDefined(user.workspaceMember.timeFormat)
|
timeFormat: isDefined(user.workspaceMember.timeFormat)
|
||||||
? getTimeFormatFromWorkspaceTimeFormat(
|
? getTimeFormatFromWorkspaceTimeFormat(
|
||||||
user.workspaceMember.timeFormat,
|
user.workspaceMember.timeFormat,
|
||||||
)
|
)
|
||||||
: detectTimeFormat(),
|
: TimeFormat[detectTimeFormat()],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,23 +1,24 @@
|
|||||||
import { useEffect } from 'react';
|
|
||||||
import { useRecoilState, useSetRecoilState } from 'recoil';
|
|
||||||
|
|
||||||
import { apiConfigState } from '@/client-config/states/apiConfigState';
|
import { apiConfigState } from '@/client-config/states/apiConfigState';
|
||||||
import { authProvidersState } from '@/client-config/states/authProvidersState';
|
import { authProvidersState } from '@/client-config/states/authProvidersState';
|
||||||
import { billingState } from '@/client-config/states/billingState';
|
import { billingState } from '@/client-config/states/billingState';
|
||||||
import { captchaProviderState } from '@/client-config/states/captchaProviderState';
|
import { captchaProviderState } from '@/client-config/states/captchaProviderState';
|
||||||
import { chromeExtensionIdState } from '@/client-config/states/chromeExtensionIdState';
|
import { chromeExtensionIdState } from '@/client-config/states/chromeExtensionIdState';
|
||||||
|
import { isAnalyticsEnabledState } from '@/client-config/states/isAnalyticsEnabledState';
|
||||||
import { isClientConfigLoadedState } from '@/client-config/states/isClientConfigLoadedState';
|
import { isClientConfigLoadedState } from '@/client-config/states/isClientConfigLoadedState';
|
||||||
import { isDebugModeState } from '@/client-config/states/isDebugModeState';
|
import { isDebugModeState } from '@/client-config/states/isDebugModeState';
|
||||||
import { isSignInPrefilledState } from '@/client-config/states/isSignInPrefilledState';
|
import { isSignInPrefilledState } from '@/client-config/states/isSignInPrefilledState';
|
||||||
import { isSignUpDisabledState } from '@/client-config/states/isSignUpDisabledState';
|
import { isSignUpDisabledState } from '@/client-config/states/isSignUpDisabledState';
|
||||||
import { sentryConfigState } from '@/client-config/states/sentryConfigState';
|
import { sentryConfigState } from '@/client-config/states/sentryConfigState';
|
||||||
import { supportChatState } from '@/client-config/states/supportChatState';
|
import { supportChatState } from '@/client-config/states/supportChatState';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useRecoilState, useSetRecoilState } from 'recoil';
|
||||||
import { useGetClientConfigQuery } from '~/generated/graphql';
|
import { useGetClientConfigQuery } from '~/generated/graphql';
|
||||||
import { isDefined } from '~/utils/isDefined';
|
import { isDefined } from '~/utils/isDefined';
|
||||||
|
|
||||||
export const ClientConfigProviderEffect = () => {
|
export const ClientConfigProviderEffect = () => {
|
||||||
const setAuthProviders = useSetRecoilState(authProvidersState);
|
const setAuthProviders = useSetRecoilState(authProvidersState);
|
||||||
const setIsDebugMode = useSetRecoilState(isDebugModeState);
|
const setIsDebugMode = useSetRecoilState(isDebugModeState);
|
||||||
|
const setIsAnalyticsEnabled = useSetRecoilState(isAnalyticsEnabledState);
|
||||||
|
|
||||||
const setIsSignInPrefilled = useSetRecoilState(isSignInPrefilledState);
|
const setIsSignInPrefilled = useSetRecoilState(isSignInPrefilledState);
|
||||||
const setIsSignUpDisabled = useSetRecoilState(isSignUpDisabledState);
|
const setIsSignUpDisabled = useSetRecoilState(isSignUpDisabledState);
|
||||||
@ -50,6 +51,7 @@ export const ClientConfigProviderEffect = () => {
|
|||||||
magicLink: false,
|
magicLink: false,
|
||||||
});
|
});
|
||||||
setIsDebugMode(data?.clientConfig.debugMode);
|
setIsDebugMode(data?.clientConfig.debugMode);
|
||||||
|
setIsAnalyticsEnabled(data?.clientConfig.analyticsEnabled);
|
||||||
setIsSignInPrefilled(data?.clientConfig.signInPrefilled);
|
setIsSignInPrefilled(data?.clientConfig.signInPrefilled);
|
||||||
setIsSignUpDisabled(data?.clientConfig.signUpDisabled);
|
setIsSignUpDisabled(data?.clientConfig.signUpDisabled);
|
||||||
|
|
||||||
@ -84,6 +86,7 @@ export const ClientConfigProviderEffect = () => {
|
|||||||
setCaptchaProvider,
|
setCaptchaProvider,
|
||||||
setChromeExtensionId,
|
setChromeExtensionId,
|
||||||
setApiConfig,
|
setApiConfig,
|
||||||
|
setIsAnalyticsEnabled,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return <></>;
|
return <></>;
|
||||||
|
|||||||
@ -16,6 +16,7 @@ export const GET_CLIENT_CONFIG = gql`
|
|||||||
signInPrefilled
|
signInPrefilled
|
||||||
signUpDisabled
|
signUpDisabled
|
||||||
debugMode
|
debugMode
|
||||||
|
analyticsEnabled
|
||||||
support {
|
support {
|
||||||
supportDriver
|
supportDriver
|
||||||
supportFrontChatId
|
supportFrontChatId
|
||||||
|
|||||||
@ -0,0 +1,6 @@
|
|||||||
|
import { createState } from 'twenty-ui';
|
||||||
|
|
||||||
|
export const isAnalyticsEnabledState = createState<boolean>({
|
||||||
|
key: 'isAnalyticsEnabled',
|
||||||
|
defaultValue: false,
|
||||||
|
});
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
import { DateFormat } from '@/localization/constants/DateFormat';
|
||||||
|
|
||||||
|
type DateFormatWithoutYear = {
|
||||||
|
[K in keyof typeof DateFormat]: string;
|
||||||
|
};
|
||||||
|
export const DATE_FORMAT_WITHOUT_YEAR: DateFormatWithoutYear = {
|
||||||
|
SYSTEM: 'SYSTEM',
|
||||||
|
MONTH_FIRST: 'MMM d',
|
||||||
|
DAY_FIRST: 'd MMM',
|
||||||
|
YEAR_FIRST: 'MMM d',
|
||||||
|
};
|
||||||
@ -1,8 +1,7 @@
|
|||||||
import { DateFormat } from '@/localization/constants/DateFormat';
|
|
||||||
import { detectDateFormat } from '@/localization/utils/detectDateFormat';
|
import { detectDateFormat } from '@/localization/utils/detectDateFormat';
|
||||||
|
|
||||||
describe('detectDateFormat', () => {
|
describe('detectDateFormat', () => {
|
||||||
it('should return DateFormat.MONTH_FIRST if the detected format starts with month', () => {
|
it('should return MONTH_FIRST if the detected format starts with month', () => {
|
||||||
// Mock the Intl.DateTimeFormat to return a specific format
|
// Mock the Intl.DateTimeFormat to return a specific format
|
||||||
const mockDateTimeFormat = jest.fn().mockReturnValue({
|
const mockDateTimeFormat = jest.fn().mockReturnValue({
|
||||||
formatToParts: () => [
|
formatToParts: () => [
|
||||||
@ -16,10 +15,10 @@ describe('detectDateFormat', () => {
|
|||||||
|
|
||||||
const result = detectDateFormat();
|
const result = detectDateFormat();
|
||||||
|
|
||||||
expect(result).toBe(DateFormat.MONTH_FIRST);
|
expect(result).toBe('MONTH_FIRST');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return DateFormat.DAY_FIRST if the detected format starts with day', () => {
|
it('should return DAY_FIRST if the detected format starts with day', () => {
|
||||||
// Mock the Intl.DateTimeFormat to return a specific format
|
// Mock the Intl.DateTimeFormat to return a specific format
|
||||||
const mockDateTimeFormat = jest.fn().mockReturnValue({
|
const mockDateTimeFormat = jest.fn().mockReturnValue({
|
||||||
formatToParts: () => [
|
formatToParts: () => [
|
||||||
@ -32,10 +31,10 @@ describe('detectDateFormat', () => {
|
|||||||
|
|
||||||
const result = detectDateFormat();
|
const result = detectDateFormat();
|
||||||
|
|
||||||
expect(result).toBe(DateFormat.DAY_FIRST);
|
expect(result).toBe('DAY_FIRST');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return DateFormat.YEAR_FIRST if the detected format starts with year', () => {
|
it('should return YEAR_FIRST if the detected format starts with year', () => {
|
||||||
// Mock the Intl.DateTimeFormat to return a specific format
|
// Mock the Intl.DateTimeFormat to return a specific format
|
||||||
const mockDateTimeFormat = jest.fn().mockReturnValue({
|
const mockDateTimeFormat = jest.fn().mockReturnValue({
|
||||||
formatToParts: () => [
|
formatToParts: () => [
|
||||||
@ -48,10 +47,10 @@ describe('detectDateFormat', () => {
|
|||||||
|
|
||||||
const result = detectDateFormat();
|
const result = detectDateFormat();
|
||||||
|
|
||||||
expect(result).toBe(DateFormat.YEAR_FIRST);
|
expect(result).toBe('YEAR_FIRST');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return DateFormat.MONTH_FIRST by default if the detected format does not match any specific order', () => {
|
it('should return MONTH_FIRST by default if the detected format does not match any specific order', () => {
|
||||||
// Mock the Intl.DateTimeFormat to return a specific format
|
// Mock the Intl.DateTimeFormat to return a specific format
|
||||||
const mockDateTimeFormat = jest.fn().mockReturnValue({
|
const mockDateTimeFormat = jest.fn().mockReturnValue({
|
||||||
formatToParts: () => [
|
formatToParts: () => [
|
||||||
@ -64,6 +63,6 @@ describe('detectDateFormat', () => {
|
|||||||
|
|
||||||
const result = detectDateFormat();
|
const result = detectDateFormat();
|
||||||
|
|
||||||
expect(result).toBe(DateFormat.MONTH_FIRST);
|
expect(result).toBe('MONTH_FIRST');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,8 +1,7 @@
|
|||||||
import { TimeFormat } from '@/localization/constants/TimeFormat';
|
|
||||||
import { detectTimeFormat } from '@/localization/utils/detectTimeFormat';
|
import { detectTimeFormat } from '@/localization/utils/detectTimeFormat';
|
||||||
|
|
||||||
describe('detectTimeFormat', () => {
|
describe('detectTimeFormat', () => {
|
||||||
it('should return TimeFormat.HOUR_12 if the hour format is 12-hour', () => {
|
it('should return HOUR_12 if the hour format is 12-hour', () => {
|
||||||
// Mock the resolvedOptions method to return hour12 as true
|
// Mock the resolvedOptions method to return hour12 as true
|
||||||
const mockResolvedOptions = jest.fn(() => ({ hour12: true }));
|
const mockResolvedOptions = jest.fn(() => ({ hour12: true }));
|
||||||
Intl.DateTimeFormat = jest.fn().mockImplementation(() => ({
|
Intl.DateTimeFormat = jest.fn().mockImplementation(() => ({
|
||||||
@ -11,11 +10,11 @@ describe('detectTimeFormat', () => {
|
|||||||
|
|
||||||
const result = detectTimeFormat();
|
const result = detectTimeFormat();
|
||||||
|
|
||||||
expect(result).toBe(TimeFormat.HOUR_12);
|
expect(result).toBe('HOUR_12');
|
||||||
expect(mockResolvedOptions).toHaveBeenCalled();
|
expect(mockResolvedOptions).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return TimeFormat.HOUR_24 if the hour format is 24-hour', () => {
|
it('should return HOUR_24 if the hour format is 24-hour', () => {
|
||||||
// Mock the resolvedOptions method to return hour12 as false
|
// Mock the resolvedOptions method to return hour12 as false
|
||||||
const mockResolvedOptions = jest.fn(() => ({ hour12: false }));
|
const mockResolvedOptions = jest.fn(() => ({ hour12: false }));
|
||||||
Intl.DateTimeFormat = jest.fn().mockImplementation(() => ({
|
Intl.DateTimeFormat = jest.fn().mockImplementation(() => ({
|
||||||
@ -24,7 +23,7 @@ describe('detectTimeFormat', () => {
|
|||||||
|
|
||||||
const result = detectTimeFormat();
|
const result = detectTimeFormat();
|
||||||
|
|
||||||
expect(result).toBe(TimeFormat.HOUR_24);
|
expect(result).toBe('HOUR_24');
|
||||||
expect(mockResolvedOptions).toHaveBeenCalled();
|
expect(mockResolvedOptions).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -0,0 +1,90 @@
|
|||||||
|
import { detectDateFormat } from '@/localization/utils/detectDateFormat';
|
||||||
|
import { formatDateISOStringToDateTimeSimplified } from '@/localization/utils/formatDateISOStringToDateTimeSimplified';
|
||||||
|
import { formatInTimeZone } from 'date-fns-tz';
|
||||||
|
// Mock the imported modules
|
||||||
|
jest.mock('@/localization/utils/detectDateFormat');
|
||||||
|
jest.mock('date-fns-tz');
|
||||||
|
|
||||||
|
describe('formatDateISOStringToDateTimeSimplified', () => {
|
||||||
|
const mockDate = new Date('2023-08-15T10:30:00Z');
|
||||||
|
const mockTimeZone = 'America/New_York';
|
||||||
|
const mockTimeFormat = 'HH:mm';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should format the date correctly when DATE_FORMAT is MONTH_FIRST', () => {
|
||||||
|
detectDateFormat.mockReturnValue('MONTH_FIRST');
|
||||||
|
formatInTimeZone.mockReturnValue('Oct 15 · 06:30');
|
||||||
|
|
||||||
|
const result = formatDateISOStringToDateTimeSimplified(
|
||||||
|
mockDate,
|
||||||
|
mockTimeZone,
|
||||||
|
mockTimeFormat,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(detectDateFormat).toHaveBeenCalled();
|
||||||
|
expect(formatInTimeZone).toHaveBeenCalledWith(
|
||||||
|
mockDate,
|
||||||
|
mockTimeZone,
|
||||||
|
'MMM d · HH:mm',
|
||||||
|
);
|
||||||
|
expect(result).toBe('Oct 15 · 06:30');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should format the date correctly when DATE_FORMAT is DAY_FIRST', () => {
|
||||||
|
detectDateFormat.mockReturnValue('DAY_FIRST');
|
||||||
|
formatInTimeZone.mockReturnValue('15 Oct · 06:30');
|
||||||
|
|
||||||
|
const result = formatDateISOStringToDateTimeSimplified(
|
||||||
|
mockDate,
|
||||||
|
mockTimeZone,
|
||||||
|
mockTimeFormat,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(detectDateFormat).toHaveBeenCalled();
|
||||||
|
expect(formatInTimeZone).toHaveBeenCalledWith(
|
||||||
|
mockDate,
|
||||||
|
mockTimeZone,
|
||||||
|
'd MMM · HH:mm',
|
||||||
|
);
|
||||||
|
expect(result).toBe('15 Oct · 06:30');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use the provided time format', () => {
|
||||||
|
detectDateFormat.mockReturnValue('MONTH_FIRST');
|
||||||
|
formatInTimeZone.mockReturnValue('Oct 15 · 6:30 AM');
|
||||||
|
|
||||||
|
const result = formatDateISOStringToDateTimeSimplified(
|
||||||
|
mockDate,
|
||||||
|
mockTimeZone,
|
||||||
|
'h:mm aa',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(formatInTimeZone).toHaveBeenCalledWith(
|
||||||
|
mockDate,
|
||||||
|
mockTimeZone,
|
||||||
|
'MMM d · h:mm aa',
|
||||||
|
);
|
||||||
|
expect(result).toBe('Oct 15 · 6:30 AM');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle different time zones', () => {
|
||||||
|
detectDateFormat.mockReturnValue('MONTH_FIRST');
|
||||||
|
formatInTimeZone.mockReturnValue('Oct 16 · 02:30');
|
||||||
|
|
||||||
|
const result = formatDateISOStringToDateTimeSimplified(
|
||||||
|
mockDate,
|
||||||
|
'Asia/Tokyo',
|
||||||
|
mockTimeFormat,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(formatInTimeZone).toHaveBeenCalledWith(
|
||||||
|
mockDate,
|
||||||
|
'Asia/Tokyo',
|
||||||
|
'MMM d · HH:mm',
|
||||||
|
);
|
||||||
|
expect(result).toBe('Oct 16 · 02:30');
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import { DateFormat } from '@/localization/constants/DateFormat';
|
import { DateFormat } from '@/localization/constants/DateFormat';
|
||||||
|
|
||||||
export const detectDateFormat = (): DateFormat => {
|
export const detectDateFormat = (): keyof typeof DateFormat => {
|
||||||
const date = new Date();
|
const date = new Date();
|
||||||
const formatter = new Intl.DateTimeFormat(navigator.language);
|
const formatter = new Intl.DateTimeFormat(navigator.language);
|
||||||
const parts = formatter.formatToParts(date);
|
const parts = formatter.formatToParts(date);
|
||||||
@ -9,9 +9,9 @@ export const detectDateFormat = (): DateFormat => {
|
|||||||
.filter((part) => ['year', 'month', 'day'].includes(part.type))
|
.filter((part) => ['year', 'month', 'day'].includes(part.type))
|
||||||
.map((part) => part.type);
|
.map((part) => part.type);
|
||||||
|
|
||||||
if (partOrder[0] === 'month') return DateFormat.MONTH_FIRST;
|
if (partOrder[0] === 'month') return 'MONTH_FIRST';
|
||||||
if (partOrder[0] === 'day') return DateFormat.DAY_FIRST;
|
if (partOrder[0] === 'day') return 'DAY_FIRST';
|
||||||
if (partOrder[0] === 'year') return DateFormat.YEAR_FIRST;
|
if (partOrder[0] === 'year') return 'YEAR_FIRST';
|
||||||
|
|
||||||
return DateFormat.MONTH_FIRST;
|
return 'MONTH_FIRST';
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,14 +1,14 @@
|
|||||||
import { TimeFormat } from '@/localization/constants/TimeFormat';
|
import { TimeFormat } from '@/localization/constants/TimeFormat';
|
||||||
import { isDefined } from '~/utils/isDefined';
|
import { isDefined } from '~/utils/isDefined';
|
||||||
|
|
||||||
export const detectTimeFormat = () => {
|
export const detectTimeFormat = (): keyof typeof TimeFormat => {
|
||||||
const isHour12 = Intl.DateTimeFormat(navigator.language, {
|
const isHour12 = Intl.DateTimeFormat(navigator.language, {
|
||||||
hour: 'numeric',
|
hour: 'numeric',
|
||||||
}).resolvedOptions().hour12;
|
}).resolvedOptions().hour12;
|
||||||
|
|
||||||
if (isDefined(isHour12) && isHour12) {
|
if (isDefined(isHour12) && isHour12) {
|
||||||
return TimeFormat.HOUR_12;
|
return 'HOUR_12';
|
||||||
}
|
}
|
||||||
|
|
||||||
return TimeFormat.HOUR_24;
|
return 'HOUR_24';
|
||||||
};
|
};
|
||||||
|
|||||||
@ -0,0 +1,18 @@
|
|||||||
|
import { DATE_FORMAT_WITHOUT_YEAR } from '@/localization/constants/DateFormatWithoutYear';
|
||||||
|
import { TimeFormat } from '@/localization/constants/TimeFormat';
|
||||||
|
import { detectDateFormat } from '@/localization/utils/detectDateFormat';
|
||||||
|
import { formatInTimeZone } from 'date-fns-tz';
|
||||||
|
|
||||||
|
export const formatDateISOStringToDateTimeSimplified = (
|
||||||
|
date: Date,
|
||||||
|
timeZone: string,
|
||||||
|
timeFormat: TimeFormat,
|
||||||
|
) => {
|
||||||
|
const simplifiedDateFormat = DATE_FORMAT_WITHOUT_YEAR[detectDateFormat()];
|
||||||
|
|
||||||
|
return formatInTimeZone(
|
||||||
|
date,
|
||||||
|
timeZone,
|
||||||
|
`${simplifiedDateFormat} · ${timeFormat}`,
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -7,7 +7,7 @@ export const getDateFormatFromWorkspaceDateFormat = (
|
|||||||
) => {
|
) => {
|
||||||
switch (workspaceDateFormat) {
|
switch (workspaceDateFormat) {
|
||||||
case WorkspaceMemberDateFormatEnum.System:
|
case WorkspaceMemberDateFormatEnum.System:
|
||||||
return detectDateFormat();
|
return DateFormat[detectDateFormat()];
|
||||||
case WorkspaceMemberDateFormatEnum.MonthFirst:
|
case WorkspaceMemberDateFormatEnum.MonthFirst:
|
||||||
return DateFormat.MONTH_FIRST;
|
return DateFormat.MONTH_FIRST;
|
||||||
case WorkspaceMemberDateFormatEnum.DayFirst:
|
case WorkspaceMemberDateFormatEnum.DayFirst:
|
||||||
|
|||||||
@ -7,7 +7,7 @@ export const getTimeFormatFromWorkspaceTimeFormat = (
|
|||||||
) => {
|
) => {
|
||||||
switch (workspaceTimeFormat) {
|
switch (workspaceTimeFormat) {
|
||||||
case WorkspaceMemberTimeFormatEnum.System:
|
case WorkspaceMemberTimeFormatEnum.System:
|
||||||
return detectTimeFormat();
|
return TimeFormat[detectTimeFormat()];
|
||||||
case WorkspaceMemberTimeFormatEnum.Hour_24:
|
case WorkspaceMemberTimeFormatEnum.Hour_24:
|
||||||
return TimeFormat.HOUR_24;
|
return TimeFormat.HOUR_24;
|
||||||
case WorkspaceMemberTimeFormatEnum.Hour_12:
|
case WorkspaceMemberTimeFormatEnum.Hour_12:
|
||||||
|
|||||||
@ -0,0 +1,89 @@
|
|||||||
|
import { formatDateISOStringToDateTimeSimplified } from '@/localization/utils/formatDateISOStringToDateTimeSimplified';
|
||||||
|
import { UserContext } from '@/users/contexts/UserContext';
|
||||||
|
import styled from '@emotion/styled';
|
||||||
|
import { Point } from '@nivo/line';
|
||||||
|
import { ReactElement, useContext } from 'react';
|
||||||
|
|
||||||
|
const StyledTooltipContainer = styled.div`
|
||||||
|
align-items: center;
|
||||||
|
border-radius: ${({ theme }) => theme.border.radius.md};
|
||||||
|
border: 1px solid ${({ theme }) => theme.border.color.medium};
|
||||||
|
display: flex;
|
||||||
|
width: 128px;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
background: ${({ theme }) => theme.background.transparent.secondary};
|
||||||
|
box-shadow: ${({ theme }) => theme.boxShadow.light};
|
||||||
|
backdrop-filter: ${({ theme }) => theme.blur.medium};
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StyledTooltipDateContainer = styled.div`
|
||||||
|
align-items: flex-start;
|
||||||
|
align-self: stretch;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: ${({ theme }) => theme.font.weight.medium};
|
||||||
|
font-family: ${({ theme }) => theme.font.family};
|
||||||
|
gap: ${({ theme }) => theme.spacing(2)};
|
||||||
|
color: ${({ theme }) => theme.font.color.secondary};
|
||||||
|
padding: ${({ theme }) => theme.spacing(2)};
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StyledTooltipDataRow = styled.div`
|
||||||
|
align-items: flex-start;
|
||||||
|
align-self: stretch;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
color: ${({ theme }) => theme.font.color.tertiary};
|
||||||
|
padding: ${({ theme }) => theme.spacing(2)};
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StyledLine = styled.div`
|
||||||
|
background-color: ${({ theme }) => theme.border.color.medium};
|
||||||
|
height: 1px;
|
||||||
|
width: 100%;
|
||||||
|
`;
|
||||||
|
const StyledColorPoint = styled.div<{ color: string }>`
|
||||||
|
background-color: ${({ color }) => color};
|
||||||
|
border-radius: 50%;
|
||||||
|
height: 8px;
|
||||||
|
width: 8px;
|
||||||
|
display: inline-block;
|
||||||
|
`;
|
||||||
|
const StyledDataDefinition = styled.div`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: ${({ theme }) => theme.spacing(2)};
|
||||||
|
`;
|
||||||
|
const StyledSpan = styled.span`
|
||||||
|
color: ${({ theme }) => theme.font.color.primary};
|
||||||
|
`;
|
||||||
|
type SettingsDevelopersWebhookTooltipProps = {
|
||||||
|
point: Point;
|
||||||
|
};
|
||||||
|
export const SettingsDevelopersWebhookTooltip = ({
|
||||||
|
point,
|
||||||
|
}: SettingsDevelopersWebhookTooltipProps): ReactElement => {
|
||||||
|
const { timeFormat, timeZone } = useContext(UserContext);
|
||||||
|
const windowInterval = new Date(point.data.x);
|
||||||
|
const windowIntervalDate = formatDateISOStringToDateTimeSimplified(
|
||||||
|
windowInterval,
|
||||||
|
timeZone,
|
||||||
|
timeFormat,
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<StyledTooltipContainer>
|
||||||
|
<StyledTooltipDateContainer>
|
||||||
|
{windowIntervalDate}
|
||||||
|
</StyledTooltipDateContainer>
|
||||||
|
<StyledLine />
|
||||||
|
<StyledTooltipDataRow>
|
||||||
|
<StyledDataDefinition>
|
||||||
|
<StyledColorPoint color={point.serieColor} />
|
||||||
|
{String(point.serieId)}
|
||||||
|
</StyledDataDefinition>
|
||||||
|
<StyledSpan>{String(point.data.y)}</StyledSpan>
|
||||||
|
</StyledTooltipDataRow>
|
||||||
|
</StyledTooltipContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,8 +1,13 @@
|
|||||||
|
import { SettingsDevelopersWebhookTooltip } from '@/settings/developers/webhook/components/SettingsDevelopersWebhookTooltip';
|
||||||
|
import { useGraphData } from '@/settings/developers/webhook/hooks/useGraphData';
|
||||||
import { webhookGraphDataState } from '@/settings/developers/webhook/states/webhookGraphDataState';
|
import { webhookGraphDataState } from '@/settings/developers/webhook/states/webhookGraphDataState';
|
||||||
|
import { Select } from '@/ui/input/components/Select';
|
||||||
|
import { useTheme } from '@emotion/react';
|
||||||
import styled from '@emotion/styled';
|
import styled from '@emotion/styled';
|
||||||
import { ResponsiveLine } from '@nivo/line';
|
import { ResponsiveLine } from '@nivo/line';
|
||||||
import { Section } from '@react-email/components';
|
import { Section } from '@react-email/components';
|
||||||
import { useRecoilValue } from 'recoil';
|
import { useState } from 'react';
|
||||||
|
import { useRecoilValue, useSetRecoilState } from 'recoil';
|
||||||
import { H2Title } from 'twenty-ui';
|
import { H2Title } from 'twenty-ui';
|
||||||
|
|
||||||
export type NivoLineInput = {
|
export type NivoLineInput = {
|
||||||
@ -14,22 +19,102 @@ export type NivoLineInput = {
|
|||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
const StyledGraphContainer = styled.div`
|
const StyledGraphContainer = styled.div`
|
||||||
height: 200px;
|
background-color: ${({ theme }) => theme.background.secondary};
|
||||||
width: 100%;
|
border: 1px solid ${({ theme }) => theme.border.color.medium};
|
||||||
|
border-radius: ${({ theme }) => theme.border.radius.md};
|
||||||
|
height: 199px;
|
||||||
|
|
||||||
|
padding: ${({ theme }) => theme.spacing(4, 2, 2, 2)};
|
||||||
|
width: 496px;
|
||||||
`;
|
`;
|
||||||
export const SettingsDeveloppersWebhookUsageGraph = () => {
|
const StyledTitleContainer = styled.div`
|
||||||
|
align-items: flex-start;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
`;
|
||||||
|
|
||||||
|
type SettingsDevelopersWebhookUsageGraphProps = {
|
||||||
|
webhookId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SettingsDevelopersWebhookUsageGraph = ({
|
||||||
|
webhookId,
|
||||||
|
}: SettingsDevelopersWebhookUsageGraphProps) => {
|
||||||
const webhookGraphData = useRecoilValue(webhookGraphDataState);
|
const webhookGraphData = useRecoilValue(webhookGraphDataState);
|
||||||
|
const setWebhookGraphData = useSetRecoilState(webhookGraphDataState);
|
||||||
|
const theme = useTheme();
|
||||||
|
|
||||||
|
const [windowLengthGraphOption, setWindowLengthGraphOption] = useState<
|
||||||
|
'7D' | '1D' | '12H' | '4H'
|
||||||
|
>('7D');
|
||||||
|
|
||||||
|
const { fetchGraphData } = useGraphData(webhookId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{webhookGraphData.length ? (
|
{webhookGraphData.length ? (
|
||||||
<Section>
|
<Section>
|
||||||
<H2Title title="Statistics" />
|
<StyledTitleContainer>
|
||||||
|
<H2Title
|
||||||
|
title="Activity"
|
||||||
|
description="See your webhook activity over time"
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
dropdownId="test-id-webhook-graph"
|
||||||
|
value={windowLengthGraphOption}
|
||||||
|
options={[
|
||||||
|
{ value: '7D', label: 'This week' },
|
||||||
|
{ value: '1D', label: 'Today' },
|
||||||
|
{ value: '12H', label: 'Last 12 hours' },
|
||||||
|
{ value: '4H', label: 'Last 4 hours' },
|
||||||
|
]}
|
||||||
|
onChange={(windowLengthGraphOption) => {
|
||||||
|
setWindowLengthGraphOption(windowLengthGraphOption);
|
||||||
|
fetchGraphData(windowLengthGraphOption).then((graphInput) => {
|
||||||
|
setWebhookGraphData(graphInput);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</StyledTitleContainer>
|
||||||
|
|
||||||
<StyledGraphContainer>
|
<StyledGraphContainer>
|
||||||
<ResponsiveLine
|
<ResponsiveLine
|
||||||
data={webhookGraphData}
|
data={webhookGraphData}
|
||||||
|
curve={'monotoneX'}
|
||||||
|
enableArea={true}
|
||||||
colors={(d) => d.color}
|
colors={(d) => d.color}
|
||||||
margin={{ top: 0, right: 0, bottom: 50, left: 60 }}
|
theme={{
|
||||||
|
text: {
|
||||||
|
fill: theme.font.color.light,
|
||||||
|
fontSize: theme.font.size.sm,
|
||||||
|
fontFamily: theme.font.family,
|
||||||
|
},
|
||||||
|
axis: {
|
||||||
|
domain: {
|
||||||
|
line: {
|
||||||
|
stroke: theme.border.color.light,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ticks: {
|
||||||
|
line: {
|
||||||
|
stroke: theme.border.color.light,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
line: {
|
||||||
|
stroke: theme.border.color.light,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
crosshair: {
|
||||||
|
line: {
|
||||||
|
stroke: theme.font.color.light,
|
||||||
|
strokeDasharray: '2 2',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
margin={{ top: 20, right: 0, bottom: 30, left: 30 }}
|
||||||
xFormat="time:%Y-%m-%d %H:%M%"
|
xFormat="time:%Y-%m-%d %H:%M%"
|
||||||
xScale={{
|
xScale={{
|
||||||
type: 'time',
|
type: 'time',
|
||||||
@ -37,17 +122,54 @@ export const SettingsDeveloppersWebhookUsageGraph = () => {
|
|||||||
format: '%Y-%m-%d %H:%M:%S',
|
format: '%Y-%m-%d %H:%M:%S',
|
||||||
precision: 'hour',
|
precision: 'hour',
|
||||||
}}
|
}}
|
||||||
|
defs={[
|
||||||
|
{
|
||||||
|
colors: [
|
||||||
|
{
|
||||||
|
color: 'inherit',
|
||||||
|
offset: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
color: 'inherit',
|
||||||
|
offset: 100,
|
||||||
|
opacity: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
id: 'gradientGraph',
|
||||||
|
type: 'linearGradient',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
fill={[
|
||||||
|
{
|
||||||
|
id: 'gradientGraph',
|
||||||
|
match: '*',
|
||||||
|
},
|
||||||
|
]}
|
||||||
yScale={{
|
yScale={{
|
||||||
type: 'linear',
|
type: 'linear',
|
||||||
}}
|
}}
|
||||||
axisBottom={{
|
axisBottom={{
|
||||||
tickValues: 'every day',
|
format: '%b %d, %I:%M %p',
|
||||||
format: '%b %d',
|
tickValues: 2,
|
||||||
|
tickPadding: 5,
|
||||||
|
tickSize: 6,
|
||||||
|
}}
|
||||||
|
axisLeft={{
|
||||||
|
tickPadding: 5,
|
||||||
|
tickSize: 6,
|
||||||
|
tickValues: 4,
|
||||||
}}
|
}}
|
||||||
enableTouchCrosshair={true}
|
|
||||||
enableGridY={false}
|
|
||||||
enableGridX={false}
|
enableGridX={false}
|
||||||
|
lineWidth={1}
|
||||||
|
gridYValues={4}
|
||||||
enablePoints={false}
|
enablePoints={false}
|
||||||
|
isInteractive={true}
|
||||||
|
useMesh={true}
|
||||||
|
enableSlices={false}
|
||||||
|
enableCrosshair={false}
|
||||||
|
tooltip={({ point }) => (
|
||||||
|
<SettingsDevelopersWebhookTooltip point={point} />
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</StyledGraphContainer>
|
</StyledGraphContainer>
|
||||||
</Section>
|
</Section>
|
||||||
|
|||||||
@ -1,7 +1,5 @@
|
|||||||
import { NivoLineInput } from '@/settings/developers/webhook/components/SettingsDevelopersWebhookUsageGraph';
|
import { useGraphData } from '@/settings/developers/webhook/hooks/useGraphData';
|
||||||
import { webhookGraphDataState } from '@/settings/developers/webhook/states/webhookGraphDataState';
|
import { webhookGraphDataState } from '@/settings/developers/webhook/states/webhookGraphDataState';
|
||||||
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
|
|
||||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useSetRecoilState } from 'recoil';
|
import { useSetRecoilState } from 'recoil';
|
||||||
|
|
||||||
@ -14,88 +12,13 @@ export const SettingsDevelopersWebhookUsageGraphEffect = ({
|
|||||||
}: SettingsDevelopersWebhookUsageGraphEffectProps) => {
|
}: SettingsDevelopersWebhookUsageGraphEffectProps) => {
|
||||||
const setWebhookGraphData = useSetRecoilState(webhookGraphDataState);
|
const setWebhookGraphData = useSetRecoilState(webhookGraphDataState);
|
||||||
|
|
||||||
const { enqueueSnackBar } = useSnackBar();
|
const { fetchGraphData } = useGraphData(webhookId);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchData = async () => {
|
fetchGraphData('7D').then((graphInput) => {
|
||||||
try {
|
setWebhookGraphData(graphInput);
|
||||||
const queryString = new URLSearchParams({
|
});
|
||||||
webhookIdRequest: webhookId,
|
}, [fetchGraphData, setWebhookGraphData, webhookId]);
|
||||||
}).toString();
|
|
||||||
const token = 'REPLACE_ME';
|
|
||||||
const response = await fetch(
|
|
||||||
`https://api.eu-central-1.aws.tinybird.co/v0/pipes/getWebhooksAnalytics.json?${queryString}`,
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
Authorization: 'Bearer ' + token,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const result = await response.json();
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
enqueueSnackBar('Something went wrong while fetching webhook usage', {
|
|
||||||
variant: SnackBarVariant.Error,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const graphInput = result.data
|
|
||||||
.flatMap(
|
|
||||||
(dataRow: {
|
|
||||||
start_interval: string;
|
|
||||||
failure_count: number;
|
|
||||||
success_count: number;
|
|
||||||
}) => [
|
|
||||||
{
|
|
||||||
x: dataRow.start_interval,
|
|
||||||
y: dataRow.failure_count,
|
|
||||||
id: 'failure_count',
|
|
||||||
color: 'red',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
x: dataRow.start_interval,
|
|
||||||
y: dataRow.success_count,
|
|
||||||
id: 'success_count',
|
|
||||||
color: 'green',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
)
|
|
||||||
.reduce(
|
|
||||||
(
|
|
||||||
acc: NivoLineInput[],
|
|
||||||
{
|
|
||||||
id,
|
|
||||||
x,
|
|
||||||
y,
|
|
||||||
color,
|
|
||||||
}: { id: string; x: string; y: number; color: string },
|
|
||||||
) => {
|
|
||||||
const existingGroupIndex = acc.findIndex(
|
|
||||||
(group) => group.id === id,
|
|
||||||
);
|
|
||||||
const isExistingGroup = existingGroupIndex !== -1;
|
|
||||||
|
|
||||||
if (isExistingGroup) {
|
|
||||||
return acc.map((group, index) =>
|
|
||||||
index === existingGroupIndex
|
|
||||||
? { ...group, data: [...group.data, { x, y }] }
|
|
||||||
: group,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return [...acc, { id, color, data: [{ x, y }] }];
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
setWebhookGraphData(graphInput);
|
|
||||||
} catch (error) {
|
|
||||||
enqueueSnackBar('Something went wrong while fetching webhook usage', {
|
|
||||||
variant: SnackBarVariant.Error,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
fetchData();
|
|
||||||
}, [enqueueSnackBar, setWebhookGraphData, webhookId]);
|
|
||||||
return <></>;
|
return <></>;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -0,0 +1,6 @@
|
|||||||
|
export const WEBHOOK_GRAPH_API_OPTIONS_MAP = {
|
||||||
|
'7D': { windowInHours: '168', tickIntervalInMinutes: '420' },
|
||||||
|
'1D': { windowInHours: '24', tickIntervalInMinutes: '60' },
|
||||||
|
'12H': { windowInHours: '12', tickIntervalInMinutes: '30' },
|
||||||
|
'4H': { windowInHours: '4', tickIntervalInMinutes: '10' },
|
||||||
|
};
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
import { fetchGraphDataOrThrow } from '@/settings/developers/webhook/utils/fetchGraphDataOrThrow';
|
||||||
|
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
|
||||||
|
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||||
|
|
||||||
|
export const useGraphData = (webhookId: string) => {
|
||||||
|
const { enqueueSnackBar } = useSnackBar();
|
||||||
|
const fetchGraphData = async (
|
||||||
|
windowLengthGraphOption: '7D' | '1D' | '12H' | '4H',
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
return await fetchGraphDataOrThrow({
|
||||||
|
webhookId,
|
||||||
|
windowLength: windowLengthGraphOption,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
enqueueSnackBar('Something went wrong while fetching webhook usage', {
|
||||||
|
variant: SnackBarVariant.Error,
|
||||||
|
});
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return { fetchGraphData };
|
||||||
|
};
|
||||||
@ -0,0 +1,115 @@
|
|||||||
|
import { WEBHOOK_GRAPH_API_OPTIONS_MAP } from '@/settings/developers/webhook/constants/WebhookGraphApiOptionsMap';
|
||||||
|
import { fetchGraphDataOrThrow } from '@/settings/developers/webhook/utils/fetchGraphDataOrThrow';
|
||||||
|
|
||||||
|
// Mock the global fetch function
|
||||||
|
global.fetch = jest.fn();
|
||||||
|
|
||||||
|
describe('fetchGraphDataOrThrow', () => {
|
||||||
|
const mockWebhookId = 'test-webhook-id';
|
||||||
|
const mockWindowLength = '7D';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fetch and transform data successfully', async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
ok: true,
|
||||||
|
json: jest.fn().mockResolvedValue({
|
||||||
|
data: [
|
||||||
|
{ start_interval: '2023-05-01', failure_count: 2, success_count: 8 },
|
||||||
|
{ start_interval: '2023-05-02', failure_count: 1, success_count: 9 },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
global.fetch.mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
const result = await fetchGraphDataOrThrow({
|
||||||
|
webhookId: mockWebhookId,
|
||||||
|
windowLength: mockWindowLength,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(global.fetch).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining(
|
||||||
|
`https://api.eu-central-1.aws.tinybird.co/v0/pipes/getWebhooksAnalyticsV2.json?`,
|
||||||
|
),
|
||||||
|
expect.objectContaining({
|
||||||
|
headers: {
|
||||||
|
Authorization: expect.stringContaining('Bearer '),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual([
|
||||||
|
{
|
||||||
|
id: 'Failed',
|
||||||
|
color: 'red',
|
||||||
|
data: [
|
||||||
|
{ x: '2023-05-01', y: 2 },
|
||||||
|
{ x: '2023-05-02', y: 1 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'Succeeded',
|
||||||
|
color: 'blue',
|
||||||
|
data: [
|
||||||
|
{ x: '2023-05-01', y: 8 },
|
||||||
|
{ x: '2023-05-02', y: 9 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw an error when the response is not ok', async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
ok: false,
|
||||||
|
json: jest.fn().mockResolvedValue({ error: 'Some error' }),
|
||||||
|
};
|
||||||
|
global.fetch.mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
fetchGraphDataOrThrow({
|
||||||
|
webhookId: mockWebhookId,
|
||||||
|
windowLength: mockWindowLength,
|
||||||
|
}),
|
||||||
|
).rejects.toThrow('Something went wrong while fetching webhook usage');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use correct query parameters based on window length', async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
ok: true,
|
||||||
|
json: jest.fn().mockResolvedValue({ data: [] }),
|
||||||
|
};
|
||||||
|
global.fetch.mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
await fetchGraphDataOrThrow({
|
||||||
|
webhookId: mockWebhookId,
|
||||||
|
windowLength: '1D',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(global.fetch).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining(
|
||||||
|
new URLSearchParams({
|
||||||
|
...WEBHOOK_GRAPH_API_OPTIONS_MAP['1D'],
|
||||||
|
webhookIdRequest: mockWebhookId,
|
||||||
|
}).toString(),
|
||||||
|
),
|
||||||
|
expect.any(Object),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty response data', async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
ok: true,
|
||||||
|
json: jest.fn().mockResolvedValue({ data: [] }),
|
||||||
|
};
|
||||||
|
global.fetch.mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
const result = await fetchGraphDataOrThrow({
|
||||||
|
webhookId: mockWebhookId,
|
||||||
|
windowLength: mockWindowLength,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,80 @@
|
|||||||
|
import { NivoLineInput } from '@/settings/developers/webhook/components/SettingsDevelopersWebhookUsageGraph';
|
||||||
|
import { WEBHOOK_GRAPH_API_OPTIONS_MAP } from '@/settings/developers/webhook/constants/WebhookGraphApiOptionsMap';
|
||||||
|
|
||||||
|
type fetchGraphDataOrThrowProps = {
|
||||||
|
webhookId: string;
|
||||||
|
windowLength: '7D' | '1D' | '12H' | '4H';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchGraphDataOrThrow = async ({
|
||||||
|
webhookId,
|
||||||
|
windowLength,
|
||||||
|
}: fetchGraphDataOrThrowProps) => {
|
||||||
|
const queryString = new URLSearchParams({
|
||||||
|
...WEBHOOK_GRAPH_API_OPTIONS_MAP[windowLength],
|
||||||
|
webhookIdRequest: webhookId,
|
||||||
|
}).toString();
|
||||||
|
const token = 'REPLACE_ME';
|
||||||
|
const response = await fetch(
|
||||||
|
`https://api.eu-central-1.aws.tinybird.co/v0/pipes/getWebhooksAnalyticsV2.json?${queryString}`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: 'Bearer ' + token,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Something went wrong while fetching webhook usage');
|
||||||
|
}
|
||||||
|
// Next steps: separate the map logic to a different component (response.data, {id:str, color:str}[])=>NivoLineInput[]
|
||||||
|
|
||||||
|
const graphInput = result.data
|
||||||
|
.flatMap(
|
||||||
|
(dataRow: {
|
||||||
|
start_interval: string;
|
||||||
|
failure_count: number;
|
||||||
|
success_count: number;
|
||||||
|
}) => [
|
||||||
|
{
|
||||||
|
x: dataRow.start_interval,
|
||||||
|
y: dataRow.failure_count,
|
||||||
|
id: 'Failed',
|
||||||
|
color: 'red', // need to refacto this
|
||||||
|
},
|
||||||
|
{
|
||||||
|
x: dataRow.start_interval,
|
||||||
|
y: dataRow.success_count,
|
||||||
|
id: 'Succeeded',
|
||||||
|
color: 'blue',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.reduce(
|
||||||
|
(
|
||||||
|
acc: NivoLineInput[],
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
color,
|
||||||
|
}: { id: string; x: string; y: number; color: string },
|
||||||
|
) => {
|
||||||
|
const existingGroupIndex = acc.findIndex((group) => group.id === id);
|
||||||
|
const isExistingGroup = existingGroupIndex !== -1;
|
||||||
|
|
||||||
|
if (isExistingGroup) {
|
||||||
|
return acc.map((group, index) =>
|
||||||
|
index === existingGroupIndex
|
||||||
|
? { ...group, data: [...group.data, { x, y }] }
|
||||||
|
: group,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return [...acc, { id, color, data: [{ x, y }] }];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
return graphInput;
|
||||||
|
};
|
||||||
@ -7,6 +7,8 @@ import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMembe
|
|||||||
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
|
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
|
||||||
import { isCurrentUserLoadedState } from '@/auth/states/isCurrentUserLoadingState';
|
import { isCurrentUserLoadedState } from '@/auth/states/isCurrentUserLoadingState';
|
||||||
import { workspacesState } from '@/auth/states/workspaces';
|
import { workspacesState } from '@/auth/states/workspaces';
|
||||||
|
import { DateFormat } from '@/localization/constants/DateFormat';
|
||||||
|
import { TimeFormat } from '@/localization/constants/TimeFormat';
|
||||||
import { dateTimeFormatState } from '@/localization/states/dateTimeFormatState';
|
import { dateTimeFormatState } from '@/localization/states/dateTimeFormatState';
|
||||||
import { detectDateFormat } from '@/localization/utils/detectDateFormat';
|
import { detectDateFormat } from '@/localization/utils/detectDateFormat';
|
||||||
import { detectTimeFormat } from '@/localization/utils/detectTimeFormat';
|
import { detectTimeFormat } from '@/localization/utils/detectTimeFormat';
|
||||||
@ -81,10 +83,10 @@ export const UserProviderEffect = () => {
|
|||||||
: detectTimeZone(),
|
: detectTimeZone(),
|
||||||
dateFormat: isDefined(workspaceMember.dateFormat)
|
dateFormat: isDefined(workspaceMember.dateFormat)
|
||||||
? getDateFormatFromWorkspaceDateFormat(workspaceMember.dateFormat)
|
? getDateFormatFromWorkspaceDateFormat(workspaceMember.dateFormat)
|
||||||
: detectDateFormat(),
|
: DateFormat[detectDateFormat()],
|
||||||
timeFormat: isDefined(workspaceMember.timeFormat)
|
timeFormat: isDefined(workspaceMember.timeFormat)
|
||||||
? getTimeFormatFromWorkspaceTimeFormat(workspaceMember.timeFormat)
|
? getTimeFormatFromWorkspaceTimeFormat(workspaceMember.timeFormat)
|
||||||
: detectTimeFormat(),
|
: TimeFormat[detectTimeFormat()],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { useState } from 'react';
|
|||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import { H2Title, IconTrash } from 'twenty-ui';
|
import { H2Title, IconTrash } from 'twenty-ui';
|
||||||
|
|
||||||
|
import { isAnalyticsEnabledState } from '@/client-config/states/isAnalyticsEnabledState';
|
||||||
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
|
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
|
||||||
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
|
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
|
||||||
import { useDeleteOneRecord } from '@/object-record/hooks/useDeleteOneRecord';
|
import { useDeleteOneRecord } from '@/object-record/hooks/useDeleteOneRecord';
|
||||||
@ -11,7 +12,7 @@ import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
|
|||||||
import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons';
|
import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons';
|
||||||
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
|
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
|
||||||
import { Webhook } from '@/settings/developers/types/webhook/Webhook';
|
import { Webhook } from '@/settings/developers/types/webhook/Webhook';
|
||||||
import { SettingsDeveloppersWebhookUsageGraph } from '@/settings/developers/webhook/components/SettingsDevelopersWebhookUsageGraph';
|
import { SettingsDevelopersWebhookUsageGraph } from '@/settings/developers/webhook/components/SettingsDevelopersWebhookUsageGraph';
|
||||||
import { SettingsDevelopersWebhookUsageGraphEffect } from '@/settings/developers/webhook/components/SettingsDevelopersWebhookUsageGraphEffect';
|
import { SettingsDevelopersWebhookUsageGraphEffect } from '@/settings/developers/webhook/components/SettingsDevelopersWebhookUsageGraphEffect';
|
||||||
import { getSettingsPagePath } from '@/settings/utils/getSettingsPagePath';
|
import { getSettingsPagePath } from '@/settings/utils/getSettingsPagePath';
|
||||||
import { SettingsPath } from '@/types/SettingsPath';
|
import { SettingsPath } from '@/types/SettingsPath';
|
||||||
@ -23,6 +24,7 @@ import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModa
|
|||||||
import { SubMenuTopBarContainer } from '@/ui/layout/page/SubMenuTopBarContainer';
|
import { SubMenuTopBarContainer } from '@/ui/layout/page/SubMenuTopBarContainer';
|
||||||
import { Section } from '@/ui/layout/section/components/Section';
|
import { Section } from '@/ui/layout/section/components/Section';
|
||||||
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
|
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
|
||||||
|
import { useRecoilValue } from 'recoil';
|
||||||
|
|
||||||
const StyledFilterRow = styled.div`
|
const StyledFilterRow = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -32,6 +34,8 @@ const StyledFilterRow = styled.div`
|
|||||||
|
|
||||||
export const SettingsDevelopersWebhooksDetail = () => {
|
export const SettingsDevelopersWebhooksDetail = () => {
|
||||||
const { objectMetadataItems } = useObjectMetadataItems();
|
const { objectMetadataItems } = useObjectMetadataItems();
|
||||||
|
const isAnalyticsEnabled = useRecoilValue(isAnalyticsEnabledState);
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { webhookId = '' } = useParams();
|
const { webhookId = '' } = useParams();
|
||||||
|
|
||||||
@ -178,10 +182,10 @@ export const SettingsDevelopersWebhooksDetail = () => {
|
|||||||
/>
|
/>
|
||||||
</StyledFilterRow>
|
</StyledFilterRow>
|
||||||
</Section>
|
</Section>
|
||||||
{isAnalyticsV2Enabled ? (
|
{isAnalyticsEnabled && isAnalyticsV2Enabled ? (
|
||||||
<>
|
<>
|
||||||
<SettingsDevelopersWebhookUsageGraphEffect webhookId={webhookId} />
|
<SettingsDevelopersWebhookUsageGraphEffect webhookId={webhookId} />
|
||||||
<SettingsDeveloppersWebhookUsageGraph />
|
<SettingsDevelopersWebhookUsageGraph webhookId={webhookId} />
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<></>
|
<></>
|
||||||
|
|||||||
@ -77,7 +77,7 @@ export const DateTimeSettings = () => {
|
|||||||
);
|
);
|
||||||
dateTime[settingName] =
|
dateTime[settingName] =
|
||||||
(value as DateFormat) === DateFormat.SYSTEM
|
(value as DateFormat) === DateFormat.SYSTEM
|
||||||
? detectDateFormat()
|
? DateFormat[detectDateFormat()]
|
||||||
: (value as DateFormat);
|
: (value as DateFormat);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@ -87,7 +87,7 @@ export const DateTimeSettings = () => {
|
|||||||
);
|
);
|
||||||
dateTime[settingName] =
|
dateTime[settingName] =
|
||||||
(value as TimeFormat) === TimeFormat.SYSTEM
|
(value as TimeFormat) === TimeFormat.SYSTEM
|
||||||
? detectTimeFormat()
|
? TimeFormat[detectTimeFormat()]
|
||||||
: (value as TimeFormat);
|
: (value as TimeFormat);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -20,7 +20,7 @@ export const DateTimeSettingsDateFormatSelect = ({
|
|||||||
|
|
||||||
const usedTimeZone = timeZone === 'system' ? systemTimeZone : timeZone;
|
const usedTimeZone = timeZone === 'system' ? systemTimeZone : timeZone;
|
||||||
|
|
||||||
const systemDateFormat = detectDateFormat();
|
const systemDateFormat = DateFormat[detectDateFormat()];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Select
|
<Select
|
||||||
|
|||||||
@ -20,7 +20,7 @@ export const DateTimeSettingsTimeFormatSelect = ({
|
|||||||
|
|
||||||
const usedTimeZone = timeZone === 'system' ? systemTimeZone : timeZone;
|
const usedTimeZone = timeZone === 'system' ? systemTimeZone : timeZone;
|
||||||
|
|
||||||
const systemTimeFormat = detectTimeFormat();
|
const systemTimeFormat = TimeFormat[detectTimeFormat()];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Select
|
<Select
|
||||||
|
|||||||
@ -88,6 +88,9 @@ export class ClientConfig {
|
|||||||
@Field(() => Boolean)
|
@Field(() => Boolean)
|
||||||
debugMode: boolean;
|
debugMode: boolean;
|
||||||
|
|
||||||
|
@Field(() => Boolean)
|
||||||
|
analyticsEnabled: boolean;
|
||||||
|
|
||||||
@Field(() => Support)
|
@Field(() => Support)
|
||||||
support: Support;
|
support: Support;
|
||||||
|
|
||||||
|
|||||||
@ -48,6 +48,7 @@ export class ClientConfigResolver {
|
|||||||
'MUTATION_MAXIMUM_AFFECTED_RECORDS',
|
'MUTATION_MAXIMUM_AFFECTED_RECORDS',
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
analyticsEnabled: this.environmentService.get('ANALYTICS_ENABLED'),
|
||||||
};
|
};
|
||||||
|
|
||||||
return Promise.resolve(clientConfig);
|
return Promise.resolve(clientConfig);
|
||||||
|
|||||||
Reference in New Issue
Block a user