diff --git a/.changeset/sharp-melons-perform.md b/.changeset/sharp-melons-perform.md new file mode 100644 index 0000000000..7617acfcb7 --- /dev/null +++ b/.changeset/sharp-melons-perform.md @@ -0,0 +1,5 @@ +--- +'hive': minor +--- + +Add option for checking breaking changes by a fixed request count diff --git a/integration-tests/testkit/seed.ts b/integration-tests/testkit/seed.ts index cd89c37dd8..34f306e94d 100644 --- a/integration-tests/testkit/seed.ts +++ b/integration-tests/testkit/seed.ts @@ -49,6 +49,7 @@ import { updateTargetValidationSettings, } from './flow'; import { + BreakingChangeFormula, OrganizationAccessScope, ProjectAccessScope, ProjectType, @@ -632,9 +633,13 @@ export function initSeed() { excludedClients, percentage, target: ttarget = target, + requestCount, + breakingChangeFormula, }: { excludedClients?: string[]; percentage: number; + requestCount?: number; + breakingChangeFormula?: BreakingChangeFormula; target?: TargetOverwrite; }) { const result = await updateTargetValidationSettings( @@ -644,6 +649,8 @@ export function initSeed() { targetSlug: ttarget.slug, excludedClients, percentage, + requestCount, + breakingChangeFormula, period: 2, targetIds: [target.id], }, diff --git a/integration-tests/tests/api/target/usage.spec.ts b/integration-tests/tests/api/target/usage.spec.ts index 64caabf8ae..bafb036610 100644 --- a/integration-tests/tests/api/target/usage.spec.ts +++ b/integration-tests/tests/api/target/usage.spec.ts @@ -5,7 +5,7 @@ import { subHours } from 'date-fns/subHours'; import { buildASTSchema, buildSchema, parse, print, TypeInfo } from 'graphql'; import { createLogger } from 'graphql-yoga'; import { graphql } from 'testkit/gql'; -import { ProjectType } from 'testkit/gql/graphql'; +import { BreakingChangeFormula, ProjectType } from 'testkit/gql/graphql'; import { execute } from 'testkit/graphql'; import { getServiceHost } from 'testkit/utils'; import { UTCDate } from '@date-fns/utc'; @@ -2135,7 +2135,7 @@ const SubscriptionSchemaCheckQuery = graphql(/* GraphQL */ ` `); test.concurrent( - 'test threshold when using conditional breaking change detection', + 'test threshold when using conditional breaking change "PERCENTAGE" detection', async ({ expect }) => { const { createOrg } = await initSeed().createOwner(); const { createProject } = await createOrg(); @@ -2347,6 +2347,168 @@ test.concurrent( }, ); +test.concurrent( + 'test threshold when using conditional breaking change "REQUEST_COUNT" detection', + async ({ expect }) => { + const { createOrg } = await initSeed().createOwner(); + const { createProject } = await createOrg(); + const { createTargetAccessToken, toggleTargetValidation, updateTargetValidationSettings } = + await createProject(ProjectType.Single); + const token = await createTargetAccessToken({}); + await toggleTargetValidation(true); + await updateTargetValidationSettings({ + excludedClients: [], + requestCount: 2, + percentage: 0, + breakingChangeFormula: BreakingChangeFormula.RequestCount, + }); + + const sdl = /* GraphQL */ ` + type Query { + a: String + b: String + c: String + } + `; + + const queryA = parse(/* GraphQL */ ` + query { + a + } + `); + + function collectA() { + client.collectUsage()( + { + document: queryA, + schema, + contextValue: { + request, + }, + }, + {}, + ); + } + + const schema = buildASTSchema(parse(sdl)); + + const schemaPublishResult = await token + .publishSchema({ + sdl, + author: 'Kamil', + commit: 'initial', + }) + .then(res => res.expectNoGraphQLErrors()); + + expect(schemaPublishResult.schemaPublish.__typename).toEqual('SchemaPublishSuccess'); + + const unused = await token + .checkSchema(/* GraphQL */ ` + type Query { + b: String + c: String + } + `) + .then(r => r.expectNoGraphQLErrors()); + + if (unused.schemaCheck.__typename !== 'SchemaCheckSuccess') { + throw new Error(`Expected SchemaCheckSuccess, got ${unused.schemaCheck.__typename}`); + } + + expect(unused.schemaCheck.changes).toEqual( + expect.objectContaining({ + nodes: expect.arrayContaining([ + expect.objectContaining({ + message: "Field 'a' was removed from object type 'Query' (non-breaking based on usage)", + }), + ]), + total: 1, + }), + ); + + const usageAddress = await getServiceHost('usage', 8081); + + const client = createHive({ + enabled: true, + token: token.secret, + usage: true, + debug: false, + agent: { + logger: createLogger('debug'), + maxSize: 1, + }, + selfHosting: { + usageEndpoint: 'http://' + usageAddress, + graphqlEndpoint: 'http://noop/', + applicationUrl: 'http://noop/', + }, + }); + + const request = new Request('http://localhost:4000/graphql', { + method: 'POST', + headers: { + 'x-graphql-client-name': 'integration-tests', + 'x-graphql-client-version': '6.6.6', + }, + }); + + collectA(); + + await waitFor(8000); + + const below = await token + .checkSchema(/* GraphQL */ ` + type Query { + b: String + c: String + } + `) + .then(r => r.expectNoGraphQLErrors()); + + if (below.schemaCheck.__typename !== 'SchemaCheckSuccess') { + throw new Error(`Expected SchemaCheckSuccess, got ${below.schemaCheck.__typename}`); + } + + expect(below.schemaCheck.changes).toEqual( + expect.objectContaining({ + nodes: expect.arrayContaining([ + expect.objectContaining({ + message: "Field 'a' was removed from object type 'Query' (non-breaking based on usage)", + }), + ]), + total: 1, + }), + ); + + // Now let's make Query.a above threshold by making a 2nd query for Query.a + collectA(); + + await waitFor(8000); + + const above = await token + .checkSchema(/* GraphQL */ ` + type Query { + b: String + c: String + } + `) + .then(r => r.expectNoGraphQLErrors()); + + if (above.schemaCheck.__typename !== 'SchemaCheckError') { + throw new Error(`Expected SchemaCheckError, got ${above.schemaCheck.__typename}`); + } + + expect(above.schemaCheck.errors).toEqual({ + nodes: [ + { + message: "Field 'a' was removed from object type 'Query'", + }, + ], + total: 1, + }); + }, +); + test.concurrent( 'subscription operation is used for conditional breaking change detection', async ({ expect }) => { diff --git a/packages/migrations/src/actions/2025.01.10T00.00.00.breaking-changes-request-count.ts b/packages/migrations/src/actions/2025.01.10T00.00.00.breaking-changes-request-count.ts new file mode 100644 index 0000000000..2e719b4acf --- /dev/null +++ b/packages/migrations/src/actions/2025.01.10T00.00.00.breaking-changes-request-count.ts @@ -0,0 +1,19 @@ +import { type MigrationExecutor } from '../pg-migrator'; + +export default { + name: '2025.01.10T00.00.00.breaking-changes-request-count.ts', + run: ({ sql }) => sql` +CREATE TYPE + breaking_change_formula AS ENUM('PERCENTAGE', 'REQUEST_COUNT'); + +ALTER TABLE + targets +ADD COLUMN + validation_request_count INT NOT NULL DEFAULT 1; + +ALTER TABLE + targets +ADD COLUMN + validation_breaking_change_formula breaking_change_formula NOT NULL DEFAULT 'PERCENTAGE'; +`, +} satisfies MigrationExecutor; diff --git a/packages/migrations/src/run-pg-migrations.ts b/packages/migrations/src/run-pg-migrations.ts index c0840042da..e662d2bd0b 100644 --- a/packages/migrations/src/run-pg-migrations.ts +++ b/packages/migrations/src/run-pg-migrations.ts @@ -153,5 +153,6 @@ export const runPGMigrations = async (args: { slonik: DatabasePool; runTo?: stri await import('./actions/2025.01.02T00-00-00.cascade-deletion-indices'), await import('./actions/2025.01.02T00-00-00.legacy-user-org-cleanup'), await import('./actions/2025.01.09T00-00-00.legacy-member-scopes'), + await import('./actions/2025.01.10T00.00.00.breaking-changes-request-count'), ], }); diff --git a/packages/services/api/src/modules/schema/providers/schema-publisher.ts b/packages/services/api/src/modules/schema/providers/schema-publisher.ts index a3286e0ca2..ce18f97482 100644 --- a/packages/services/api/src/modules/schema/providers/schema-publisher.ts +++ b/packages/services/api/src/modules/schema/providers/schema-publisher.ts @@ -112,6 +112,8 @@ type ConditionalBreakingChangeConfiguration = { conditionalBreakingChangeDiffConfig: ConditionalBreakingChangeDiffConfig; retentionInDays: number; percentage: number; + requestCount: number; + breakingChangeFormula: 'PERCENTAGE' | 'REQUEST_COUNT'; totalRequestCount: number; }; @@ -196,12 +198,15 @@ export class SchemaPublisher { excludedClientNames: settings.validation.excludedClients?.length ? settings.validation.excludedClients : null, - requestCountThreshold: Math.ceil( - totalRequestCount * (settings.validation.percentage / 100), - ), + requestCountThreshold: + settings.validation.breakingChangeFormula === 'PERCENTAGE' + ? Math.ceil(totalRequestCount * (settings.validation.percentage / 100)) + : settings.validation.requestCount, }, retentionInDays: settings.validation.period, percentage: settings.validation.percentage, + requestCount: settings.validation.requestCount, + breakingChangeFormula: settings.validation.breakingChangeFormula, totalRequestCount, }; } catch (error: unknown) { @@ -228,6 +233,8 @@ export class SchemaPublisher { retentionInDays: args.conditionalBreakingChangeConfiguration.retentionInDays, excludedClientNames: conditionalBreakingChangeDiffConfig.excludedClientNames, percentage: args.conditionalBreakingChangeConfiguration.percentage, + requestCount: args.conditionalBreakingChangeConfiguration.requestCount, + breakingChangeFormula: args.conditionalBreakingChangeConfiguration.breakingChangeFormula, targets: await Promise.all( conditionalBreakingChangeDiffConfig.targetIds.map(async targetId => { return { diff --git a/packages/services/api/src/modules/target/module.graphql.ts b/packages/services/api/src/modules/target/module.graphql.ts index 667da2110b..a53ef5919f 100644 --- a/packages/services/api/src/modules/target/module.graphql.ts +++ b/packages/services/api/src/modules/target/module.graphql.ts @@ -110,6 +110,8 @@ export default gql` targetSlug: String! period: Int! percentage: Float! + requestCount: Int! = 1 + breakingChangeFormula: BreakingChangeFormula! = PERCENTAGE targetIds: [ID!]! excludedClients: [String!] } @@ -122,6 +124,7 @@ export default gql` type UpdateTargetValidationSettingsInputErrors { percentage: String period: String + requestCount: String } type UpdateTargetValidationSettingsError implements Error { @@ -178,11 +181,35 @@ export default gql` type TargetValidationSettings { enabled: Boolean! period: Int! + + """ + If TargetValidationSettings.breakingChangeFormula is PERCENTAGE, then this + is the percent of the total operations over the TargetValidationSettings.period + required for a change to be considered breaking. + """ percentage: Float! + + """ + If TargetValidationSettings.breakingChangeFormula is REQUEST_COUNT, then this + is the total number of operations over the TargetValidationSettings.period + required for a change to be considered breaking. + """ + requestCount: Int! + + """ + Determines which formula is used to determine if a change is considered breaking + or not. Only one formula can be used at a time. + """ + breakingChangeFormula: BreakingChangeFormula! targets: [Target!]! excludedClients: [String!]! } + enum BreakingChangeFormula { + REQUEST_COUNT + PERCENTAGE + } + input CreateTargetInput { organizationSlug: String! projectSlug: String! diff --git a/packages/services/api/src/modules/target/resolvers/Mutation/updateTargetValidationSettings.ts b/packages/services/api/src/modules/target/resolvers/Mutation/updateTargetValidationSettings.ts index 92d21fd0ad..1d006c4ec1 100644 --- a/packages/services/api/src/modules/target/resolvers/Mutation/updateTargetValidationSettings.ts +++ b/packages/services/api/src/modules/target/resolvers/Mutation/updateTargetValidationSettings.ts @@ -3,7 +3,7 @@ import { OrganizationManager } from '../../../organization/providers/organizatio import { IdTranslator } from '../../../shared/providers/id-translator'; import { TargetManager } from '../../providers/target-manager'; import { PercentageModel } from '../../validation'; -import type { MutationResolvers } from './../../../../__generated__/types'; +import { MutationResolvers } from './../../../../__generated__/types'; export const updateTargetValidationSettings: NonNullable< MutationResolvers['updateTargetValidationSettings'] @@ -24,6 +24,8 @@ export const updateTargetValidationSettings: NonNullable< period: z.number().min(1).max(org.monthlyRateLimit.retentionInDays).int(), targetIds: z.array(z.string()).min(1), excludedClients: z.optional(z.array(z.string())), + requestCount: z.number().min(1), + breakingChangeFormula: z.enum(['PERCENTAGE', 'REQUEST_COUNT']), }); const result = UpdateTargetValidationSettingsModel.safeParse(input); @@ -35,6 +37,7 @@ export const updateTargetValidationSettings: NonNullable< inputErrors: { percentage: result.error.formErrors.fieldErrors.percentage?.[0], period: result.error.formErrors.fieldErrors.period?.[0], + requestCount: result.error.formErrors.fieldErrors.requestCount?.[0], }, }, }; @@ -44,6 +47,8 @@ export const updateTargetValidationSettings: NonNullable< await targetManager.updateTargetValidationSettings({ period: input.period, percentage: input.percentage, + requestCount: input.requestCount ?? 1, + breakingChangeFormula: input.breakingChangeFormula ?? 'PERCENTAGE', targetId: target, projectId: project, organizationId: organization, diff --git a/packages/services/api/src/shared/entities.ts b/packages/services/api/src/shared/entities.ts index 9b59739291..34307797a7 100644 --- a/packages/services/api/src/shared/entities.ts +++ b/packages/services/api/src/shared/entities.ts @@ -367,6 +367,8 @@ export interface TargetSettings { enabled: boolean; period: number; percentage: number; + requestCount: number; + breakingChangeFormula: 'PERCENTAGE' | 'REQUEST_COUNT'; targets: string[]; excludedClients: string[]; }; diff --git a/packages/services/storage/src/db/types.ts b/packages/services/storage/src/db/types.ts index 552393d2c9..b08109c5a2 100644 --- a/packages/services/storage/src/db/types.ts +++ b/packages/services/storage/src/db/types.ts @@ -9,6 +9,7 @@ export type alert_channel_type = 'MSTEAMS_WEBHOOK' | 'SLACK' | 'WEBHOOK'; export type alert_type = 'SCHEMA_CHANGE_NOTIFICATIONS'; +export type breaking_change_formula = 'PERCENTAGE' | 'REQUEST_COUNT'; export type schema_policy_resource = 'ORGANIZATION' | 'PROJECT'; export type user_role = 'ADMIN' | 'MEMBER'; @@ -365,10 +366,12 @@ export interface targets { id: string; name: string; project_id: string; + validation_breaking_change_formula: breaking_change_formula; validation_enabled: boolean; validation_excluded_clients: Array | null; validation_percentage: number; validation_period: number; + validation_request_count: number; } export interface tokens { diff --git a/packages/services/storage/src/index.ts b/packages/services/storage/src/index.ts index e6b0d49dc9..f343e05946 100644 --- a/packages/services/storage/src/index.ts +++ b/packages/services/storage/src/index.ts @@ -361,6 +361,8 @@ export async function createStorage( | 'validation_percentage' | 'validation_period' | 'validation_excluded_clients' + | 'validation_request_count' + | 'validation_breaking_change_formula' > & { targets: target_validation['destination_target_id'][] | null; }, @@ -370,6 +372,8 @@ export async function createStorage( enabled: row.validation_enabled, percentage: row.validation_percentage, period: row.validation_period, + requestCount: row.validation_request_count ?? 1, + breakingChangeFormula: row.validation_breaking_change_formula ?? 'PERCENTAGE', targets: Array.isArray(row.targets) ? row.targets.filter(isDefined) : [], excludedClients: Array.isArray(row.validation_excluded_clients) ? row.validation_excluded_clients.filter(isDefined) @@ -1813,6 +1817,8 @@ export async function createStorage( | 'validation_percentage' | 'validation_period' | 'validation_excluded_clients' + | 'validation_request_count' + | 'validation_breaking_change_formula' > & { targets: target_validation['destination_target_id'][]; } @@ -1822,6 +1828,8 @@ export async function createStorage( t.validation_percentage, t.validation_period, t.validation_excluded_clients, + t.validation_request_count, + t.validation_breaking_change_formula, array_agg(tv.destination_target_id) as targets FROM targets AS t LEFT JOIN target_validation AS tv ON (tv.target_id = t.id) @@ -1852,6 +1860,8 @@ export async function createStorage( | 'validation_percentage' | 'validation_period' | 'validation_excluded_clients' + | 'validation_breaking_change_formula' + | 'validation_request_count' > & { targets: target_validation['destination_target_id'][]; } @@ -1870,7 +1880,7 @@ export async function createStorage( LIMIT 1 ) ret WHERE t.id = ret.id - RETURNING ret.id, t.validation_enabled, t.validation_percentage, t.validation_period, t.validation_excluded_clients, ret.targets + RETURNING ret.id, t.validation_enabled, t.validation_percentage, t.validation_period, t.validation_excluded_clients, ret.targets, t.validation_request_count, t.validation_breaking_change_formula; `); }), ).validation; @@ -1882,6 +1892,8 @@ export async function createStorage( period, targets, excludedClients, + breakingChangeFormula, + requestCount, }) { return transformTargetSettings( await tracedTransaction('updateTargetValidationSettings', pool, async trx => { @@ -1910,7 +1922,7 @@ export async function createStorage( SET validation_percentage = ${percentage}, validation_period = ${period}, validation_excluded_clients = ${sql.array( excludedClients, 'text', - )} + )} , validation_request_count = ${requestCount}, validation_breaking_change_formula = ${breakingChangeFormula} FROM ( SELECT it.id, @@ -1922,7 +1934,7 @@ export async function createStorage( LIMIT 1 ) ret WHERE t.id = ret.id - RETURNING t.id, t.validation_enabled, t.validation_percentage, t.validation_period, t.validation_excluded_clients, ret.targets; + RETURNING t.id, t.validation_enabled, t.validation_percentage, t.validation_period, t.validation_excluded_clients, ret.targets, t.validation_request_count, t.validation_breaking_change_formula; `); }), ).validation; diff --git a/packages/services/storage/src/schema-change-model.ts b/packages/services/storage/src/schema-change-model.ts index 840e1d5c06..3d5fca06c5 100644 --- a/packages/services/storage/src/schema-change-model.ts +++ b/packages/services/storage/src/schema-change-model.ts @@ -1007,6 +1007,8 @@ export const ConditionalBreakingChangeMetadataModel = z.object({ settings: z.object({ retentionInDays: z.number(), percentage: z.number(), + requestCount: z.number(), + breakingChangeFormula: z.enum(['PERCENTAGE', 'REQUEST_COUNT']), excludedClientNames: z.array(z.string()).nullable(), /** we keep both reference to id and name so in case target gets deleted we can still display the name */ targets: z.array( diff --git a/packages/web/app/src/pages/target-settings.tsx b/packages/web/app/src/pages/target-settings.tsx index 9564bd3ca8..ca62e0311c 100644 --- a/packages/web/app/src/pages/target-settings.tsx +++ b/packages/web/app/src/pages/target-settings.tsx @@ -34,6 +34,7 @@ import { SubPageLayoutHeader, } from '@/components/ui/page-content-layout'; import { QueryError } from '@/components/ui/query-error'; +import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; import { Spinner } from '@/components/ui/spinner'; import { TimeAgo } from '@/components/ui/time-ago'; import { useToast } from '@/components/ui/use-toast'; @@ -43,13 +44,14 @@ import { Table, TBody, Td, Tr } from '@/components/v2/table'; import { Tag } from '@/components/v2/tag'; import { env } from '@/env/frontend'; import { FragmentType, graphql, useFragment } from '@/gql'; -import { ProjectType } from '@/gql/graphql'; +import { BreakingChangeFormula, ProjectType } from '@/gql/graphql'; import { useRedirect } from '@/lib/access/common'; import { canAccessTarget, TargetAccessScope } from '@/lib/access/target'; import { subDays } from '@/lib/date-time'; import { useToggle } from '@/lib/hooks'; import { cn } from '@/lib/utils'; import { zodResolver } from '@hookform/resolvers/zod'; +import { RadioGroupIndicator } from '@radix-ui/react-radio-group'; import { Link, useRouter } from '@tanstack/react-router'; const RegistryAccessTokens_MeFragment = graphql(` @@ -385,6 +387,8 @@ const TargetSettings_TargetValidationSettingsFragment = graphql(` enabled period percentage + requestCount + breakingChangeFormula targets { id slug @@ -451,6 +455,7 @@ const TargetSettingsPage_UpdateTargetValidationSettingsMutation = graphql(` inputErrors { percentage period + requestCount } } } @@ -511,12 +516,23 @@ const ConditionalBreakingChanges = (props: { enableReinitialize: true, initialValues: { percentage: settings?.percentage || 0, + requestCount: settings?.requestCount || 1, period: settings?.period || 0, + breakingChangeFormula: settings?.breakingChangeFormula ?? BreakingChangeFormula.Percentage, targetIds: settings?.targets.map(t => t.id) || [], excludedClients: settings?.excludedClients ?? [], }, validationSchema: Yup.object().shape({ - percentage: Yup.number().min(0).max(100).required(), + percentage: Yup.number().when('breakingChangeFormula', { + is: 'PERCENTAGE', + then: schema => schema.min(0).max(100).required(), + otherwise: schema => schema.nullable(), + }), + requestCount: Yup.number().when('breakingChangeFormula', { + is: 'REQUEST_COUNT', + then: schema => schema.min(1).required(), + otherwise: schema => schema.nullable(), + }), period: Yup.number() .min(1) .max(targetSettings.data?.organization?.organization?.rateLimit.retentionInDays ?? 30) @@ -530,6 +546,10 @@ const ConditionalBreakingChanges = (props: { return Number(num.toFixed(2)) === num; }) .required(), + breakingChangeFormula: Yup.string().oneOf([ + BreakingChangeFormula.Percentage, + BreakingChangeFormula.RequestCount, + ]), targetIds: Yup.array().of(Yup.string()).min(1), excludedClients: Yup.array().of(Yup.string()), }), @@ -540,6 +560,20 @@ const ConditionalBreakingChanges = (props: { projectSlug: props.projectSlug, targetSlug: props.targetSlug, ...values, + /** + * In case the input gets messed up, fallback to default values in cases + * where it won't matter based on the selected formula. + */ + requestCount: + values.breakingChangeFormula === BreakingChangeFormula.Percentage && + (typeof values.requestCount !== 'number' || values.requestCount < 1) + ? 1 + : values.requestCount, + percentage: + values.breakingChangeFormula === BreakingChangeFormula.RequestCount && + (typeof values.percentage !== 'number' || values.percentage < 0) + ? 0 + : values.percentage, }, }).then(result => { if (result.error || result.data?.updateTargetValidationSettings.error) { @@ -602,21 +636,73 @@ const ConditionalBreakingChanges = (props: { )}
+
A schema change is considered as breaking only if it affects more than
+
+ { + await setFieldValue('breakingChangeFormula', value); + }} + > +
+ + + + { + const value = Number(event.target.value); + if (!Number.isNaN(value)) { + await setFieldValue('percentage', value < 0 ? 0 : value, true); + } + }} + onBlur={handleBlur} + value={values.percentage} + disabled={isSubmitting} + type="number" + step="0.01" + className="mx-2 !inline-flex w-16 text-center" + /> + +
+
+ + + + { + const value = Math.round(Number(event.target.value)); + if (!Number.isNaN(value)) { + await setFieldValue('requestCount', value <= 0 ? 1 : value, true); + } + }} + onBlur={handleBlur} + value={values.requestCount} + disabled={isSubmitting} + type="number" + step="1" + className="mx-2 !inline-flex w-16 text-center" + /> + +
+
+
- A schema change is considered as breaking only if it affects more than - - % of traffic in the past + in the past )} + {touched.requestCount && errors.requestCount && ( +
{errors.requestCount}
+ )} + {mutation.data?.updateTargetValidationSettings.error?.inputErrors.requestCount && ( +
+ {mutation.data.updateTargetValidationSettings.error.inputErrors.requestCount} +
+ )} + {/* @todo: inputErrors */} {touched.period && errors.period &&
{errors.period}
} {mutation.data?.updateTargetValidationSettings.error?.inputErrors.period && (