diff --git a/integration-tests/testkit/flow.ts b/integration-tests/testkit/flow.ts index 65a7bbb06..6fd708712 100644 --- a/integration-tests/testkit/flow.ts +++ b/integration-tests/testkit/flow.ts @@ -799,6 +799,12 @@ export function fetchLatestSchema(token: string) { } total } + errors { + nodes { + message + } + total + } } } `), diff --git a/packages/services/api/src/modules/schema/module.graphql.ts b/packages/services/api/src/modules/schema/module.graphql.ts index 72bb9ce17..59f7c48c7 100644 --- a/packages/services/api/src/modules/schema/module.graphql.ts +++ b/packages/services/api/src/modules/schema/module.graphql.ts @@ -389,6 +389,7 @@ export default gql` Experimental: This field is not stable and may change in the future. """ explorer(usage: SchemaExplorerUsageInput): SchemaExplorer! + errors: SchemaErrorConnection! } type SchemaVersionConnection { diff --git a/packages/services/api/src/modules/schema/resolvers.ts b/packages/services/api/src/modules/schema/resolvers.ts index 2930868f6..cc2f90973 100644 --- a/packages/services/api/src/modules/schema/resolvers.ts +++ b/packages/services/api/src/modules/schema/resolvers.ts @@ -482,6 +482,29 @@ export const resolvers: SchemaModule.Resolvers = { target: version.target, }); }, + async errors(version, _, { injector }) { + const schemaManager = injector.get(SchemaManager); + const schemaHelper = injector.get(SchemaHelper); + const [schemas, project] = await Promise.all([ + schemaManager.getSchemasOfVersion({ + version: version.id, + organization: version.organization, + project: version.project, + target: version.target, + }), + injector.get(ProjectManager).getProject({ + organization: version.organization, + project: version.project, + }), + ]); + + const orchestrator = schemaManager.matchOrchestrator(project.type); + + return orchestrator.validate( + schemas.map(s => schemaHelper.createSchemaObject(s)), + project.externalComposition, + ); + }, async supergraph(version, _, { injector }) { const project = await injector.get(ProjectManager).getProject({ organization: version.organization, diff --git a/packages/web/app/.storybook/preview.js b/packages/web/app/.storybook/preview.js index 2811315d0..67711b4f8 100644 --- a/packages/web/app/.storybook/preview.js +++ b/packages/web/app/.storybook/preview.js @@ -1,5 +1,11 @@ import '../public/styles.css'; +console.log('[dark]'); +if (!document.body.className.includes('dark')) { + console.log('[dark] added'); + document.body.className += ' ' + 'dark'; +} + /** @type { import('@storybook/react').Preview } */ const preview = { parameters: { diff --git a/packages/web/app/pages/[orgId]/[projectId]/[targetId]/history.tsx b/packages/web/app/pages/[orgId]/[projectId]/[targetId]/history.tsx index 6e2be1946..f231e4744 100644 --- a/packages/web/app/pages/[orgId]/[projectId]/[targetId]/history.tsx +++ b/packages/web/app/pages/[orgId]/[projectId]/[targetId]/history.tsx @@ -1,11 +1,10 @@ import { ReactElement, useCallback, useState } from 'react'; import NextLink from 'next/link'; import { clsx } from 'clsx'; -import reactStringReplace from 'react-string-replace'; import { useQuery } from 'urql'; import { authenticated } from '@/components/authenticated-container'; -import { Label } from '@/components/common'; import { TargetLayout } from '@/components/layouts'; +import { VersionErrorsAndChanges } from '@/components/target/history/errors-and-changes'; import { Badge, Button, @@ -19,64 +18,11 @@ import { } from '@/components/v2'; import { DiffIcon } from '@/components/v2/icon'; import { graphql } from '@/gql'; -import { - CompareDocument, - CriticalityLevel, - SchemaChangeFieldsFragment, - VersionsDocument, -} from '@/graphql'; +import { CompareDocument, VersionsDocument } from '@/graphql'; import { useRouteSelector } from '@/lib/hooks/use-route-selector'; import { withSessionProtection } from '@/lib/supertokens/guard'; import { CrossCircledIcon, RowsIcon } from '@radix-ui/react-icons'; -function labelize(message: string) { - const findSingleQuotes = /'([^']+)'/gim; - - return reactStringReplace(message, findSingleQuotes, (match, i) => ( - - )); -} - -const titleMap: Record = { - Safe: 'Safe Changes', - Breaking: 'Breaking Changes', - Dangerous: 'Dangerous Changes', -}; - -const criticalityLevelMapping = { - [CriticalityLevel.Safe]: clsx('text-emerald-400'), - [CriticalityLevel.Dangerous]: clsx('text-yellow-400'), -} as Record; - -function ChangesBlock({ - changes, - criticality, -}: { - changes: SchemaChangeFieldsFragment[]; - criticality: CriticalityLevel; -}): ReactElement | null { - const filteredChanges = changes.filter(c => c.criticality === criticality); - - if (!filteredChanges.length) { - return null; - } - - return ( -
-

- {titleMap[criticality]} -

-
    - {filteredChanges.map((change, key) => ( -
  • - {labelize(change.message)} -
  • - ))} -
-
- ); -} - function DiffView({ view, versionId, @@ -95,6 +41,7 @@ function DiffView({ }, }); const comparison = compareQuery.data?.schemaCompareToPrevious; + const compositionErrors = compareQuery.data?.schemaVersion?.errors; const { error } = compareQuery; if (error) { @@ -114,7 +61,7 @@ function DiffView({ ); } - if (!comparison) { + if (!comparison || !compositionErrors) { return null; } @@ -141,13 +88,7 @@ function DiffView({ return ; } - return ( -
- - - -
- ); + return ; } // URQL's Infinite scrolling pattern diff --git a/packages/web/app/src/components/target/history/errors-and-changes.tsx b/packages/web/app/src/components/target/history/errors-and-changes.tsx new file mode 100644 index 000000000..33f24992b --- /dev/null +++ b/packages/web/app/src/components/target/history/errors-and-changes.tsx @@ -0,0 +1,132 @@ +import { ReactElement } from 'react'; +import { clsx } from 'clsx'; +import reactStringReplace from 'react-string-replace'; +import { Label } from '@/components/common'; +import { Accordion } from '@/components/v2'; +import { CriticalityLevel, SchemaChangeFieldsFragment } from '@/graphql'; + +function labelize(message: string) { + // Turn " into ' + // Replace '...' with + return reactStringReplace(message.replace(/"/g, "'"), /'([^']+)'/gim, (match, i) => { + return ; + }); +} + +const titleMap: Record = { + Safe: 'Safe Changes', + Breaking: 'Breaking Changes', + Dangerous: 'Dangerous Changes', +}; + +const criticalityLevelMapping = { + [CriticalityLevel.Safe]: clsx('text-emerald-400'), + [CriticalityLevel.Dangerous]: clsx('text-yellow-400'), +} as Record; + +function ChangesBlock({ + changes, + criticality, +}: { + changes: SchemaChangeFieldsFragment[]; + criticality: CriticalityLevel; +}): ReactElement | null { + const filteredChanges = changes.filter(c => c.criticality === criticality); + + if (!filteredChanges.length) { + return null; + } + + return ( +
+

+ {titleMap[criticality]} +

+
    + {filteredChanges.map((change, key) => ( +
  • + {labelize(change.message)} +
  • + ))} +
+
+ ); +} + +export function VersionErrorsAndChanges(props: { + changes: { + nodes: SchemaChangeFieldsFragment[]; + total: number; + }; + errors: { + nodes: Array<{ + message: string; + }>; + total: number; + }; +}) { + const generalErrors = props.errors.nodes.filter(err => err.message.startsWith('[') === false); + const groupedServiceErrors = new Map(); + + props.errors.nodes.forEach(err => { + if (err.message.startsWith('[')) { + const [service, ...message] = err.message.split('] '); + const serviceName = service.replace('[', ''); + const errorMessage = message.join('] '); + + if (!groupedServiceErrors.has(serviceName)) { + groupedServiceErrors.set(serviceName, [errorMessage]); + } + + groupedServiceErrors.get(serviceName)!.push(errorMessage); + } + }); + + const serviceErrorEntries = Array.from(groupedServiceErrors.entries()); + + return ( + 0 ? 'changes' : 'errors'}> + {props.changes.total ? ( + + Changes + +
+ + + +
+
+
+ ) : null} + {props.errors.total ? ( + + Composition errors + +
    + {generalErrors.map((error, key) => ( +
  • + {labelize(error.message)} +
  • + ))} + {serviceErrorEntries.map(([service, errors]) => ( +
  • + {service} +
      + {errors.map((error, key) => ( +
    • + {labelize(error)} +
    • + ))} +
    +
  • + ))} +
+
+
+ ) : null} +
+ ); +} diff --git a/packages/web/app/src/graphql/query.compare.graphql b/packages/web/app/src/graphql/query.compare.graphql index cf1b49d2b..14752a990 100644 --- a/packages/web/app/src/graphql/query.compare.graphql +++ b/packages/web/app/src/graphql/query.compare.graphql @@ -6,4 +6,15 @@ query compare($organization: ID!, $project: ID!, $target: ID!, $version: ID!) { ...SchemaCompareResultFields ...SchemaCompareErrorFields } + schemaVersion( + selector: { organization: $organization, project: $project, target: $target, version: $version } + ) { + id + errors { + nodes { + message + } + total + } + } } diff --git a/packages/web/app/src/stories/accordion.stories.tsx b/packages/web/app/src/stories/accordion.stories.tsx index a40541162..0728eb023 100644 --- a/packages/web/app/src/stories/accordion.stories.tsx +++ b/packages/web/app/src/stories/accordion.stories.tsx @@ -12,7 +12,7 @@ type Story = StoryObj; export const Default: Story = { render: () => ( -
+
First @@ -35,7 +35,7 @@ export const Dynamic: Story = { render: () => { const [list, setList] = useState(['Tab #1', 'Tab #2', 'Tab #3']); return ( -
+
{list.map(tab => ( diff --git a/packages/web/app/src/stories/history-page.stories.tsx b/packages/web/app/src/stories/history-page.stories.tsx new file mode 100644 index 000000000..b84f00c12 --- /dev/null +++ b/packages/web/app/src/stories/history-page.stories.tsx @@ -0,0 +1,97 @@ +import { VersionErrorsAndChanges } from '@/components/target/history/errors-and-changes'; +import { CriticalityLevel } from '@/graphql'; +import type { Meta, StoryObj } from '@storybook/react'; + +const changes = [ + { + message: 'Type "Foo" was removed', + criticality: CriticalityLevel.Breaking, + }, + { + message: 'Input field "limit" was added to input object type "Filter"', + criticality: CriticalityLevel.Breaking, + }, + { + message: 'Field "User.nickname" is no longer deprecated', + criticality: CriticalityLevel.Dangerous, + }, + { + message: 'Field "type" was added to object type "User"', + criticality: CriticalityLevel.Safe, + }, +]; + +const errors = [ + { + message: 'Field "Foo.id" can only be defined once.', + }, + { + message: + '[subgraph-a] Foo.name -> is marked as @external but is not used by a @requires, @key, or @provides directive.', + }, + { + message: + '[subgraph-b] Foo.name -> is marked as @external but is not used by a @requires, @key, or @provides directive.', + }, +]; + +const meta: Meta = { + title: 'VersionErrorsAndChanges', + component: VersionErrorsAndChanges, +}; + +export default meta; +type Story = StoryObj; + +export const Changes: Story = { + render: () => { + return ( +
+ +
+ ); + }, +}; + +export const Errors: Story = { + render: () => { + return ( + + ); + }, +}; + +export const Both: Story = { + render: () => { + return ( + + ); + }, +};