diff --git a/packages/web/app/src/constants.ts b/packages/web/app/src/constants.ts index 2d9c79b782..238282780c 100644 --- a/packages/web/app/src/constants.ts +++ b/packages/web/app/src/constants.ts @@ -1,3 +1,9 @@ export const LAST_VISITED_ORG_KEY = 'lastVisitedOrganization_v2'; export const CHART_PRIMARY_COLOR = 'rgb(234, 179, 8)'; + +export const PRO_RETENTION_DAYS = 90; + +export const ENTERPRISE_RETENTION_DAYS = 365; + +export const HOBBY_RETENTION_DAYS = 7; diff --git a/packages/web/app/src/pages/target-settings.tsx b/packages/web/app/src/pages/target-settings.tsx index f2c7a40b1b..e9d6b12e5d 100644 --- a/packages/web/app/src/pages/target-settings.tsx +++ b/packages/web/app/src/pages/target-settings.tsx @@ -1,10 +1,9 @@ -import { ComponentProps, PropsWithoutRef, useCallback, useMemo, useState } from 'react'; +import { ComponentProps, PropsWithoutRef, useCallback, useEffect, useMemo, useState } from 'react'; import clsx from 'clsx'; import { formatISO } from 'date-fns'; -import { useFormik } from 'formik'; +import { InfoIcon } from 'lucide-react'; import { useForm } from 'react-hook-form'; import { useMutation, useQuery } from 'urql'; -import * as Yup from 'yup'; import { z } from 'zod'; import { Page, TargetLayout } from '@/components/layouts/target'; import { SchemaEditor } from '@/components/schema-editor'; @@ -35,12 +34,14 @@ import { } from '@/components/ui/page-content-layout'; import { QueryError } from '@/components/ui/query-error'; import { Spinner } from '@/components/ui/spinner'; +import { Switch } from '@/components/ui/switch'; import { TimeAgo } from '@/components/ui/time-ago'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { useToast } from '@/components/ui/use-toast'; import { Combobox } from '@/components/v2/combobox'; -import { Switch } from '@/components/v2/switch'; import { Table, TBody, Td, Tr } from '@/components/v2/table'; import { Tag } from '@/components/v2/tag'; +import { ENTERPRISE_RETENTION_DAYS } from '@/constants'; import { env } from '@/env/frontend'; import { FragmentType, graphql, useFragment } from '@/gql'; import { ProjectType } from '@/gql/graphql'; @@ -49,6 +50,7 @@ import { canAccessTarget, TargetAccessScope } from '@/lib/access/target'; import { subDays } from '@/lib/date-time'; import { useToggle } from '@/lib/hooks'; import { cn } from '@/lib/utils'; +import { resolveRetentionInDaysBasedOrganizationPlan } from '@/utils'; import { zodResolver } from '@hookform/resolvers/zod'; import { Link, useRouter } from '@tanstack/react-router'; @@ -414,6 +416,7 @@ const TargetSettingsPage_TargetSettingsQuery = graphql(` organization(selector: $organizationSelector) { organization { id + plan rateLimit { retentionInDays } @@ -462,13 +465,37 @@ function floorDate(date: Date): Date { return new Date(Math.floor(date.getTime() / time) * time); } +const conditionalBreakingChangesFormSchema = z.object({ + period: z.preprocess( + value => Number(value), + z + .number({ required_error: 'Period is required' }) + .min(1, 'Period must be at least 1 days') + .max(ENTERPRISE_RETENTION_DAYS, `Period must be at most ${ENTERPRISE_RETENTION_DAYS} days`) + .transform(value => Math.round(value)), + ), + percentage: z.preprocess( + value => Number(value), + z + .number({ required_error: 'Percentage is required' }) + .min(0, 'Percentage must be at least 0%') + .max(100, 'Percentage must be at most 100%') + .transform(value => Math.round(value)), + ), + targetIds: z.array(z.string()), + excludedClients: z.array(z.string()), +}); + +type ConditionalBreakingChangesFormValues = z.infer; + const ConditionalBreakingChanges = (props: { organizationSlug: string; projectSlug: string; targetSlug: string; }) => { + const { toast } = useToast(); const [targetValidation, setValidation] = useMutation(SetTargetValidationMutation); - const [mutation, updateValidation] = useMutation( + const [_, updateValidation] = useMutation( TargetSettingsPage_UpdateTargetValidationSettingsMutation, ); const [targetSettings] = useQuery({ @@ -493,256 +520,339 @@ const ConditionalBreakingChanges = (props: { TargetSettings_TargetValidationSettingsFragment, targetSettings.data?.target?.validationSettings, ); + + const retentionInDaysBasedOrganizationPlan = + targetSettings.data?.organization?.organization?.rateLimit.retentionInDays; + const defaultDays = resolveRetentionInDaysBasedOrganizationPlan( + retentionInDaysBasedOrganizationPlan, + ); + const isEnabled = settings?.enabled || false; const possibleTargets = targetSettings.data?.targets.nodes; - const { toast } = useToast(); - const { - handleSubmit, - isSubmitting, - errors, - touched, - values, - handleBlur, - handleChange, - setFieldValue, - setFieldTouched, - } = useFormik({ - enableReinitialize: true, - initialValues: { - percentage: settings?.percentage || 0, - period: settings?.period || 0, + const conditionalBreakingChangesForm = useForm({ + mode: 'all', + resolver: zodResolver(conditionalBreakingChangesFormSchema), + defaultValues: { + period: settings?.period ?? defaultDays, + percentage: settings?.percentage ?? 0, targetIds: settings?.targets.map(t => t.id) || [], excludedClients: settings?.excludedClients ?? [], }, - validationSchema: Yup.object().shape({ - percentage: Yup.number().min(0).max(100).required(), - period: Yup.number() - .min(1) - .max(targetSettings.data?.organization?.organization?.rateLimit.retentionInDays ?? 30) - .test('double-precision', 'Invalid precision', num => { - if (typeof num !== 'number') { - return false; - } + }); + + // Set form values when settings are fetched + useEffect(() => { + conditionalBreakingChangesForm.setValue('period', settings?.period ?? defaultDays); + conditionalBreakingChangesForm.setValue('percentage', settings?.percentage ?? 0); + conditionalBreakingChangesForm.setValue('targetIds', settings?.targets.map(t => t.id) || []); + conditionalBreakingChangesForm.setValue('excludedClients', settings?.excludedClients ?? []); + }, [settings]); + + const orgPlan = targetSettings.data?.organization?.organization?.plan; - // Round the number to two decimal places - // and check if it is equal to the original number - return Number(num.toFixed(2)) === num; - }) - .required(), - targetIds: Yup.array().of(Yup.string()).min(1), - excludedClients: Yup.array().of(Yup.string()), - }), - onSubmit: values => - updateValidation({ + async function onConditionalBreakingChangesFormSubmit( + data: ConditionalBreakingChangesFormValues, + ) { + // This is a workaround for the issue with zod's transform function + if (data.period > defaultDays) { + conditionalBreakingChangesForm.setError('period', { + message: `Period must be at most ${defaultDays} days`, + type: 'maxLength', + }); + return; + } + try { + const result = await updateValidation({ input: { organizationSlug: props.organizationSlug, projectSlug: props.projectSlug, targetSlug: props.targetSlug, - ...values, + percentage: data.percentage, + period: data.period, + targetIds: data.targetIds, + excludedClients: data.excludedClients, }, - }).then(result => { - if (result.error || result.data?.updateTargetValidationSettings.error) { - toast({ - variant: 'destructive', - title: 'Error', - description: - result.error?.message || result.data?.updateTargetValidationSettings.error?.message, - }); - } else { - toast({ - variant: 'default', - title: 'Success', - description: 'Conditional breaking changes settings updated successfully', - }); - } - }), - }); + }); + if (result.data?.updateTargetValidationSettings.error) { + toast({ + variant: 'destructive', + title: 'Error', + description: result.data.updateTargetValidationSettings.error.message, + }); + } else { + toast({ + variant: 'default', + title: 'Success', + description: 'Conditional breaking changes updated successfully', + }); + } + } catch (error) { + toast({ + variant: 'destructive', + title: 'Error', + description: 'Failed to update conditional breaking changes', + }); + } + } return ( -
- - - - Conditional Breaking Changes can change the behavior of schema checks, based on real - traffic data sent to Hive. - - - - Learn more - - - - } - > - {targetSettings.fetching ? ( - - ) : ( - { - await setValidation({ - input: { - targetSlug: props.targetSlug, - projectSlug: props.projectSlug, - organizationSlug: props.organizationSlug, - enabled, - }, - }); - }} - disabled={targetValidation.fetching} - /> + + + + Conditional Breaking Changes can change the behavior of schema checks, based on real + traffic data sent to Hive. + + + + Learn more + + + + } + > + {targetSettings.fetching ? ( + + ) : ( + { + await setValidation({ + input: { + targetSlug: props.targetSlug, + projectSlug: props.projectSlug, + organizationSlug: props.organizationSlug, + enabled, + }, + }); + }} + disabled={targetValidation.fetching} + /> + )} + + + -
-
- A schema change is considered as breaking only if it affects more than - - % of traffic in the past - - days. -
-
- {touched.percentage && errors.percentage && ( -
{errors.percentage}
- )} - {mutation.data?.updateTargetValidationSettings.error?.inputErrors.percentage && ( -
- {mutation.data.updateTargetValidationSettings.error.inputErrors.percentage} + > +
+
+ A schema change is considered as breaking only if it affects more than + ( + + + + + + )} + /> + % of traffic in the past +
+ + + + + + +

+ You can customize Conditional Breaking Change date range, +
+ based on your data retention and your Hive plan. +
+ Your plan: {orgPlan}. +
+ Date retention: {defaultDays} days. +

+
+
+
+ ( + + + + + + )} + /> + days. +
+ {conditionalBreakingChangesForm.formState.errors.period && ( + ( + + + + )} + /> )} - {touched.period && errors.period &&
{errors.period}
} - {mutation.data?.updateTargetValidationSettings.error?.inputErrors.period && ( -
- {mutation.data.updateTargetValidationSettings.error.inputErrors.period} -
+ {conditionalBreakingChangesForm.formState.errors.percentage && ( + ( + + + + )} + /> )} -
-
-
+
-
Allow breaking change for these clients:
+
Schema usage data from these targets:
- Marks a breaking change as safe when it only affects the following clients. + Marks a breaking change as safe when it was not requested in the targets + clients.
-
- {values.targetIds.length > 0 ? ( - setFieldTouched('excludedClients')} - onChange={async options => { - await setFieldValue( - 'excludedClients', - options.map(o => o.value), - ); - }} - disabled={isSubmitting} - /> - ) : ( -
Select targets first
- )} +
+ {possibleTargets?.map(pt => ( +
+ ( + + + { + await conditionalBreakingChangesForm.setValue( + 'targetIds', + isChecked + ? [...field.value, pt.id] + : field.value.filter(value => value !== pt.id), + ); + }} + onBlur={() => + conditionalBreakingChangesForm.setValue('targetIds', field.value) + } + /> + + + )} + /> + {pt.slug} +
+ ))}
- {touched.excludedClients && errors.excludedClients && ( -
{errors.excludedClients}
- )}
-
-
-
Schema usage data from these targets:
-
- Marks a breaking change as safe when it was not requested in the targets clients. -
-
-
- {possibleTargets?.map(pt => ( -
- { - await setFieldValue( - 'targetIds', - isChecked - ? [...values.targetIds, pt.id] - : values.targetIds.filter(value => value !== pt.id), - ); - }} - onBlur={() => setFieldTouched('targetIds', true)} - />{' '} - {pt.slug} +
+
+
Allow breaking change for these clients:
+
+ Marks a breaking change as safe when it only affects the following clients. +
- ))} +
+ {conditionalBreakingChangesForm.watch('targetIds').length > 0 ? ( + ( + + + + conditionalBreakingChangesForm.setValue( + 'excludedClients', + field.value, + ) + } + onChange={async options => { + await conditionalBreakingChangesForm.setValue( + 'excludedClients', + options.map(o => o.value), + ); + }} + disabled={conditionalBreakingChangesForm.formState.isSubmitting} + /> + + + )} + /> + ) : ( +
+
+
Select a target to enable this option
+
+
+ )} +
+ } + /> +
-
- {touched.targetIds && errors.targetIds && ( -
{errors.targetIds}
- )} -
-
-
Example settings
-
Removal of a field is considered breaking if
-
- -
- - 0% - {' '} - - the field was used at least once in past 30 days -
-
- - 10% - {' '} - - the field was requested by more than 10% of all GraphQL operations in recent 30 days +
+
+
Example settings
+
Removal of a field is considered breaking if
+
+
+ + 0% + {' '} + - the field was used at least once in past 30 days +
+
+ + 10% + {' '} + - the field was requested by more than 10% of all GraphQL operations in recent 30 + days +
+
- - {mutation.error && ( - - {mutation.error.graphQLErrors[0]?.message ?? mutation.error.message} - - )} -
- - + + + ); }; @@ -805,7 +915,6 @@ function TargetSlug(props: { organizationSlug: string; projectSlug: string; targ slugForm.setError('slug', error); } } catch (error) { - console.error('error', error); toast({ variant: 'destructive', title: 'Error', @@ -884,6 +993,17 @@ const TargetSettingsPage_UpdateTargetGraphQLEndpointUrl = graphql(` } `); +const GraphQLEndpointUrlFormSchema = z.object({ + enableReinitialize: z.boolean(), + graphqlEndpointUrl: z + .string() + .url('Please enter a valid url') + .max(300, 'Max 300 chars.') + .min(1, 'Please enter a valid url.'), +}); + +type GraphQLEndpointUrlFormValues = z.infer; + function GraphQLEndpointUrl(props: { graphqlEndpointUrl: string | null; organizationSlug: string; @@ -891,44 +1011,42 @@ function GraphQLEndpointUrl(props: { targetSlug: string; }) { const { toast } = useToast(); - const [mutation, mutate] = useMutation(TargetSettingsPage_UpdateTargetGraphQLEndpointUrl); - const { handleSubmit, values, handleChange, handleBlur, isSubmitting, errors, touched } = - useFormik({ + const [_, mutate] = useMutation(TargetSettingsPage_UpdateTargetGraphQLEndpointUrl); + + const graphQLEndpointUrlForm = useForm({ + mode: 'onChange', + resolver: zodResolver(GraphQLEndpointUrlFormSchema), + defaultValues: { enableReinitialize: true, - initialValues: { - graphqlEndpointUrl: props.graphqlEndpointUrl || '', + graphqlEndpointUrl: props.graphqlEndpointUrl || '', + }, + }); + + function onGraphQLEndpointUrlFormSubmit(data: GraphQLEndpointUrlFormValues) { + mutate({ + input: { + organizationSlug: props.organizationSlug, + projectSlug: props.projectSlug, + targetSlug: props.targetSlug, + graphqlEndpointUrl: data.graphqlEndpointUrl === '' ? null : data.graphqlEndpointUrl, }, - validationSchema: Yup.object().shape({ - graphqlEndpointUrl: Yup.string() - .url('Please enter a valid url.') - .min(1, 'Please enter a valid url.') - .max(300, 'Max 300 chars.'), - }), - onSubmit: values => - mutate({ - input: { - organizationSlug: props.organizationSlug, - projectSlug: props.projectSlug, - targetSlug: props.targetSlug, - graphqlEndpointUrl: values.graphqlEndpointUrl === '' ? null : values.graphqlEndpointUrl, - }, - }).then(result => { - if (result.data?.updateTargetGraphQLEndpointUrl.error?.message || result.error) { - toast({ - variant: 'destructive', - title: 'Error', - description: - result.data?.updateTargetGraphQLEndpointUrl.error?.message || result.error?.message, - }); - } else { - toast({ - variant: 'default', - title: 'Success', - description: 'GraphQL endpoint url updated successfully', - }); - } - }), + }).then(result => { + if (result.error || result.data?.updateTargetGraphQLEndpointUrl.error) { + toast({ + variant: 'destructive', + title: 'Error', + description: + result.error?.message || result.data?.updateTargetGraphQLEndpointUrl.error?.message, + }); + } else { + toast({ + variant: 'default', + title: 'Success', + description: 'GraphQL endpoint url updated successfully', + }); + } }); + } return ( @@ -953,36 +1071,36 @@ function GraphQLEndpointUrl(props: { } /> -
-
-
- - -
- {touched.graphqlEndpointUrl && (errors.graphqlEndpointUrl || mutation.error) && ( -
- {errors.graphqlEndpointUrl ?? - mutation.error?.graphQLErrors[0]?.message ?? - mutation.error?.message} -
- )} - {mutation.data?.updateTargetGraphQLEndpointUrl.error && ( -
- {mutation.data.updateTargetGraphQLEndpointUrl.error.message} -
- )} + + + ( + + + + + + + )} + /> + -
+
); } diff --git a/packages/web/app/src/utils.ts b/packages/web/app/src/utils.ts index e050d65b06..cad8c0df3f 100644 --- a/packages/web/app/src/utils.ts +++ b/packages/web/app/src/utils.ts @@ -1,3 +1,5 @@ +import { ENTERPRISE_RETENTION_DAYS, HOBBY_RETENTION_DAYS, PRO_RETENTION_DAYS } from './constants'; + type Truthy = T extends false | '' | 0 | null | undefined ? never : T; // from lodash export function truthy(value: T): value is Truthy { @@ -25,3 +27,25 @@ export function useChartStyles() { // }, // ); } + +export function resolveRetentionInDaysBasedOrganizationPlan( + value: number | null | undefined, +): number { + if (value == null) { + return HOBBY_RETENTION_DAYS; + } + + if (value < HOBBY_RETENTION_DAYS) { + return HOBBY_RETENTION_DAYS; + } + + if (value > HOBBY_RETENTION_DAYS && value <= PRO_RETENTION_DAYS) { + return PRO_RETENTION_DAYS; + } + + if (value > PRO_RETENTION_DAYS) { + return ENTERPRISE_RETENTION_DAYS; + } + + return HOBBY_RETENTION_DAYS; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fa9839c758..c9e9c2e054 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16228,8 +16228,8 @@ snapshots: dependencies: '@aws-crypto/sha256-browser': 3.0.0 '@aws-crypto/sha256-js': 3.0.0 - '@aws-sdk/client-sso-oidc': 3.596.0 - '@aws-sdk/client-sts': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0) + '@aws-sdk/client-sso-oidc': 3.596.0(@aws-sdk/client-sts@3.596.0) + '@aws-sdk/client-sts': 3.596.0 '@aws-sdk/core': 3.592.0 '@aws-sdk/credential-provider-node': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0)(@aws-sdk/client-sts@3.596.0) '@aws-sdk/middleware-host-header': 3.577.0 @@ -16336,11 +16336,11 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/client-sso-oidc@3.596.0': + '@aws-sdk/client-sso-oidc@3.596.0(@aws-sdk/client-sts@3.596.0)': dependencies: '@aws-crypto/sha256-browser': 3.0.0 '@aws-crypto/sha256-js': 3.0.0 - '@aws-sdk/client-sts': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0) + '@aws-sdk/client-sts': 3.596.0 '@aws-sdk/core': 3.592.0 '@aws-sdk/credential-provider-node': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0)(@aws-sdk/client-sts@3.596.0) '@aws-sdk/middleware-host-header': 3.577.0 @@ -16379,6 +16379,7 @@ snapshots: '@smithy/util-utf8': 3.0.0 tslib: 2.8.1 transitivePeerDependencies: + - '@aws-sdk/client-sts' - aws-crt '@aws-sdk/client-sso-oidc@3.693.0(@aws-sdk/client-sts@3.693.0)': @@ -16512,11 +16513,11 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/client-sts@3.596.0(@aws-sdk/client-sso-oidc@3.596.0)': + '@aws-sdk/client-sts@3.596.0': dependencies: '@aws-crypto/sha256-browser': 3.0.0 '@aws-crypto/sha256-js': 3.0.0 - '@aws-sdk/client-sso-oidc': 3.596.0 + '@aws-sdk/client-sso-oidc': 3.596.0(@aws-sdk/client-sts@3.596.0) '@aws-sdk/core': 3.592.0 '@aws-sdk/credential-provider-node': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0)(@aws-sdk/client-sts@3.596.0) '@aws-sdk/middleware-host-header': 3.577.0 @@ -16555,7 +16556,6 @@ snapshots: '@smithy/util-utf8': 3.0.0 tslib: 2.8.1 transitivePeerDependencies: - - '@aws-sdk/client-sso-oidc' - aws-crt '@aws-sdk/client-sts@3.693.0': @@ -16669,7 +16669,7 @@ snapshots: '@aws-sdk/credential-provider-ini@3.596.0(@aws-sdk/client-sso-oidc@3.596.0)(@aws-sdk/client-sts@3.596.0)': dependencies: - '@aws-sdk/client-sts': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0) + '@aws-sdk/client-sts': 3.596.0 '@aws-sdk/credential-provider-env': 3.587.0 '@aws-sdk/credential-provider-http': 3.596.0 '@aws-sdk/credential-provider-process': 3.587.0 @@ -16788,7 +16788,7 @@ snapshots: '@aws-sdk/credential-provider-web-identity@3.587.0(@aws-sdk/client-sts@3.596.0)': dependencies: - '@aws-sdk/client-sts': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0) + '@aws-sdk/client-sts': 3.596.0 '@aws-sdk/types': 3.577.0 '@smithy/property-provider': 3.1.8 '@smithy/types': 3.6.0 @@ -16963,7 +16963,7 @@ snapshots: '@aws-sdk/token-providers@3.587.0(@aws-sdk/client-sso-oidc@3.596.0)': dependencies: - '@aws-sdk/client-sso-oidc': 3.596.0 + '@aws-sdk/client-sso-oidc': 3.596.0(@aws-sdk/client-sts@3.596.0) '@aws-sdk/types': 3.577.0 '@smithy/property-provider': 3.1.8 '@smithy/shared-ini-file-loader': 3.1.9