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 (
+
+ );
+ },
+};