From caf901f5ea12a1f5be308091f15571017a4d165c Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Tue, 3 Jun 2025 09:21:59 -0700 Subject: [PATCH 01/54] Initial schema and pg tables for schema proposals feature --- .../2025.05.29T00.00.00.schema-proposals.ts | 173 ++++++++++++++ packages/services/api/src/create.ts | 2 + .../api/src/modules/proposals/index.ts | 12 + .../src/modules/proposals/module.graphql.ts | 225 ++++++++++++++++++ .../Mutation/createSchemaProposal.ts | 9 + .../Mutation/createSchemaProposalComment.ts | 7 + .../Mutation/createSchemaProposalReview.ts | 7 + .../resolvers/Query/schemaProposal.ts | 9 + .../resolvers/Query/schemaProposalReview.ts | 9 + .../resolvers/Query/schemaProposalReviews.ts | 9 + .../resolvers/Query/schemaProposals.ts | 9 + .../proposals/resolvers/SchemaVersion.ts | 14 ++ .../src/modules/proposals/resolvers/Target.ts | 14 ++ .../modules/schema/resolvers/SchemaVersion.ts | 27 ++- .../src/modules/target/resolvers/Target.ts | 1 - pnpm-lock.yaml | 24 +- 16 files changed, 537 insertions(+), 14 deletions(-) create mode 100644 packages/migrations/src/actions/2025.05.29T00.00.00.schema-proposals.ts create mode 100644 packages/services/api/src/modules/proposals/index.ts create mode 100644 packages/services/api/src/modules/proposals/module.graphql.ts create mode 100644 packages/services/api/src/modules/proposals/resolvers/Mutation/createSchemaProposal.ts create mode 100644 packages/services/api/src/modules/proposals/resolvers/Mutation/createSchemaProposalComment.ts create mode 100644 packages/services/api/src/modules/proposals/resolvers/Mutation/createSchemaProposalReview.ts create mode 100644 packages/services/api/src/modules/proposals/resolvers/Query/schemaProposal.ts create mode 100644 packages/services/api/src/modules/proposals/resolvers/Query/schemaProposalReview.ts create mode 100644 packages/services/api/src/modules/proposals/resolvers/Query/schemaProposalReviews.ts create mode 100644 packages/services/api/src/modules/proposals/resolvers/Query/schemaProposals.ts create mode 100644 packages/services/api/src/modules/proposals/resolvers/SchemaVersion.ts create mode 100644 packages/services/api/src/modules/proposals/resolvers/Target.ts diff --git a/packages/migrations/src/actions/2025.05.29T00.00.00.schema-proposals.ts b/packages/migrations/src/actions/2025.05.29T00.00.00.schema-proposals.ts new file mode 100644 index 0000000000..f9c553fb44 --- /dev/null +++ b/packages/migrations/src/actions/2025.05.29T00.00.00.schema-proposals.ts @@ -0,0 +1,173 @@ +import { type MigrationExecutor } from '../pg-migrator'; + +/** + * This migration establishes the schema proposal tables. + */ +export default { + name: '2025.05.29T00-00-00.schema-proposals.ts', + run: ({ sql }) => [ + { + name: 'create schema_proposal tables', + query: sql` + CREATE TYPE IF NOT EXISTS + schema_proposal_stage AS ENUM('DRAFT', 'OPEN', 'APPROVED', 'IMPLEMENTED', 'CLOSED') + ; + /** + * Request patterns include: + * - Get by ID + * - List target's proposals by date + * - List target's proposals by date, filtered by author/user_id and/or stage (for now) + */ + CREATE TABLE IF NOT EXISTS "schema_proposals" + ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4 () + , created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + , updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + , title VARCHAR(72) NOT NULL + , stage schema_proposal_stage NOT NULL + , target_id UUID NOT NULL REFERENCES targets (id) ON DELETE CASCADE + -- ID for the user that opened the proposal + , user_id UUID REFERENCES users (id) ON DELETE SET NULL + -- schema version that is used to calculate the diff. In case the version is deleted, + -- set this to null to avoid completely erasing the change... This should never happen. + , diff_schema_version_id UUID NOT NULL REFERENCES schema_version (id) ON DELETE SET NULL + ) + ; + CREATE INDEX IF NOT EXISTS schema_proposals_list ON schema_proposals ( + target_id + , created_at DESC + ) + ; + CREATE INDEX IF NOT EXISTS schema_proposals_list_by_user_id ON schema_proposals ( + target_id + , user_id + , created_at DESC + ) + ; + CREATE INDEX IF NOT EXISTS schema_proposals_list_by_stage ON schema_proposals ( + target_id + , stage + , created_at DESC + ) + ; + CREATE INDEX IF NOT EXISTS schema_proposals_list_by_user_id_stage ON schema_proposals ( + target_id, + , user_id + , stage + , created_at DESC + ) + ; + -- For performance during schema_version delete + CREATE INDEX IF NOT EXISTS schema_proposals_diff_schema_version_id on schema_proposals ( + diff_schema_version_id + ) + ; + -- For performance during user delete + CREATE INDEX IF NOT EXISTS schema_proposals_diff_user_id on schema_proposals ( + user_id + ) + ; + /** + * Request patterns include: + * - Get by ID + * - List proposal's latest versions for each service + * - List all proposal's versions ordered by date + */ + CREATE TABLE IF NOT EXISTS "schema_proposal_versions" + ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4 () + , user_id UUID REFERENCES users (id) ON DELETE SET NULL + , created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + , schema_proposal_id UUID NOT NULL REFERENCES schema_proposals (id) ON DELETE CASCADE + , service_name text + , schema_sdl text NOT NULL + ) + ; + CREATE INDEX IF NOT EXISTS schema_proposal_versions_list_latest_by_distinct_service ON schema_proposal_versions( + schema_proposal_id + , service_name + , created_at DESC + ) + ; + CREATE INDEX IF NOT EXISTS schema_proposal_versions_schema_proposal_id_created_at ON schema_proposal_versions( + schema_proposal_id + , created_at DESC + ) + ; + /** + * Request patterns include: + * - Get by ID + * - List proposal's latest versions for each service + * - List all proposal's versions ordered by date + */ + /** + SELECT * FROM schema_proposal_comments as c JOIN schema_proposal_reviews as r + ON r.schema_proposal_review_id = c.id + WHERE schema_proposal_id = $1 + ORDER BY created_at + LIMIT 10 + ; + */ + CREATE TABLE IF NOT EXISTS "schema_proposal_reviews" + ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4 () + , created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + -- null if just a comment + , stage_transition schema_proposal_stage NOT NULL + , user_id UUID REFERENCES users (id) ON DELETE SET NULL + , schema_proposal_id UUID NOT NULL REFERENCES schema_proposals (id) ON DELETE CASCADE + -- store the originally proposed version to be able to reference back as outdated if unable to attribute + -- the review to another version. + , original_schema_proposal_version_id UUID NOT NULL REFERENCES schema_proposal_versions (id) ON DELETE SET NULL + -- store the original text of the line that is being reviewed. If the base schema version changes, then this is + -- used to determine which line this review falls on. If no line matches in the current version, then + -- show as outdated and attribute to the original line. + , line_text text + -- used in combination with the line_text to determine what line in the current version this review is attributed to + , original_line_num INT + ) + ; + CREATE INDEX IF NOT EXISTS schema_proposal_reviews_schema_proposal_id ON schema_proposal_reviews( + schema_proposal_id + , created_at ASC + ) + ; + -- For performance on user delete + CREATE INDEX IF NOT EXISTS schema_proposal_reviews_user_id ON schema_proposal_reviews( + user_id + ) + ; + -- For performance on schema_proposal_versions delete + CREATE INDEX IF NOT EXISTS schema_proposal_reviews_original_schema_proposal_version_id ON schema_proposal_reviews( + original_schema_proposal_version_id + ) + ; + /** + * Request patterns include: + * - Get by ID + * - List a proposal's comments in order of creation, grouped by review. + */ + CREATE TABLE IF NOT EXISTS "schema_proposal_comments" + ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4 () + , user_id UUID REFERENCES users (id) ON DELETE SET NULL + , body TEXT NOT NULL + , created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + , updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + , schema_proposal_review_id UUID REFERENCES schema_proposal_reviews (id) ON DELETE CASCADE + ) + ; + CREATE INDEX IF NOT EXISTS schema_proposal_comments_list ON schema_proposal_comments( + schema_proposal_review_id, + created_at ASC + ) + ; + -- For performance on user delete + CREATE INDEX IF NOT EXISTS schema_proposal_comments_user_id ON schema_proposal_comments( + user_id + ) + ; + `, + }, + ], +} satisfies MigrationExecutor; diff --git a/packages/services/api/src/create.ts b/packages/services/api/src/create.ts index 9f9389d8f0..d9c8c8d320 100644 --- a/packages/services/api/src/create.ts +++ b/packages/services/api/src/create.ts @@ -36,6 +36,7 @@ import { SchemaPolicyServiceConfig, } from './modules/policy/providers/tokens'; import { projectModule } from './modules/project'; +import { proposalsModule } from './modules/proposals'; import { schemaModule } from './modules/schema'; import { ArtifactStorageWriter } from './modules/schema/providers/artifact-storage-writer'; import { provideSchemaModuleConfig, SchemaModuleConfig } from './modules/schema/providers/config'; @@ -88,6 +89,7 @@ const modules = [ collectionModule, appDeploymentsModule, auditLogsModule, + proposalsModule, ]; export function createRegistry({ diff --git a/packages/services/api/src/modules/proposals/index.ts b/packages/services/api/src/modules/proposals/index.ts new file mode 100644 index 0000000000..5fec7a01e8 --- /dev/null +++ b/packages/services/api/src/modules/proposals/index.ts @@ -0,0 +1,12 @@ +import { createModule } from 'graphql-modules'; +import { resolvers } from './resolvers.generated'; +import typeDefs from './module.graphql'; + +export const proposalsModule = createModule({ + id: 'proposals', + dirname: __dirname, + typeDefs, + resolvers, + providers: [], + // providers: [TargetManager, TargetsByIdCache, TargetsBySlugCache], +}); diff --git a/packages/services/api/src/modules/proposals/module.graphql.ts b/packages/services/api/src/modules/proposals/module.graphql.ts new file mode 100644 index 0000000000..9cc5e3d74b --- /dev/null +++ b/packages/services/api/src/modules/proposals/module.graphql.ts @@ -0,0 +1,225 @@ +import { gql } from 'graphql-modules'; + +export default gql` + extend type Mutation { + createSchemaProposal(input: CreateSchemaProposalInput!): SchemaProposal! + createSchemaProposalReview(input: CreateSchemaProposalReviewInput!): SchemaProposalReview! + createSchemaProposalComment(input: CreateSchemaProposalCommentInput!): SchemaProposalComment! + } + + input CreateSchemaProposalInput { + diffSchemaVersionId: ID! + title: String! + + """ + The initial changes by serviceName submitted as part of this proposal. Initial versions must have + unique "serviceName"s. + """ + initialVersions: [CreateSchemaProposalInitialVersionInput!]! = [] + + """ + The default initial stage is OPEN. Set this to true to create this as proposal + as a DRAFT instead. + """ + isDraft: Boolean! = false + } + + input CreateSchemaProposalInitialVersionInput { + schemaSDL: String! + serviceName: String + } + + input CreateSchemaProposalReviewInput { + schemaProposalVersionId: ID! + lineNumber: Int + """ + One or both of stageTransition or initialComment inputs is/are required. + """ + stageTransition: SchemaProposalStage + """ + One or both of stageTransition or initialComment inputs is/are required. + """ + commentBody: String + } + + input CreateSchemaProposalCommentInput { + schemaProposalReviewId: ID! + body: String! + } + + extend type Query { + schemaProposals(after: String, first: Int! = 30, input: SchemaProposalsInput): [SchemaProposal] + schemaProposal(input: SchemaProposalInput!): SchemaProposal + schemaProposalReviews( + after: String + first: Int! = 30 + input: SchemaProposalReviewsInput! + ): SchemaProposalReviewConnection + schemaProposalReview(input: SchemaProposalReviewInput!): SchemaProposalReview + } + + input SchemaProposalsInput { + target: TargetReferenceInput + } + + input SchemaProposalInput { + id: ID! + } + + input SchemaProposalReviewInput { + id: ID! + } + + input SchemaProposalReviewsInput { + schemaProposalId: ID! + } + + extend type User { + id: ID! + } + + extend type Target { + id: ID! + } + + type SchemaProposalConnection { + edges: [SchemaProposalEdge!] + pageInfo: PageInfo! + } + + type SchemaProposalEdge { + cursor: String! + node: SchemaProposal! + } + + extend type SchemaVersion { + id: ID! + } + + enum SchemaProposalStage { + DRAFT + OPEN + APPROVED + IMPLEMENTED + CLOSED + } + + type SchemaProposal { + id: ID! + createdAt: DateTime! + diffSchema: SchemaVersion + reviews(after: String, first: Int! = 30): SchemaProposalReviewConnection + stage: SchemaProposalStage! + target: Target + title: String + updatedAt: DateTime! + user: User + versions( + after: String + first: Int! = 15 + input: SchemaProposalVersionsInput + ): SchemaProposalVersionConnection + } + + type SchemaProposalReviewEdge { + cursor: String! + node: SchemaProposalReview! + } + + type SchemaProposalReviewConnection { + edges: [SchemaProposalReviewEdge!] + pageInfo: PageInfo! + } + + type SchemaProposalVersionEdge { + cursor: String! + node: SchemaProposalVersion! + } + + type SchemaProposalVersionConnection { + edges: [SchemaProposalVersionEdge!] + pageInfo: PageInfo! + } + + input SchemaProposalVersionsInput { + onlyLatest: Boolean! = false + } + + type SchemaProposalVersion { + id: ID! + createdAt: DateTime! + schemaProposal: SchemaProposal! + schemaSDL: String! + serviceName: String + user: ID + reviews(after: String, first: Int! = 30): SchemaProposalReviewConnection + } + + type SchemaProposalCommentEdge { + cursor: String! + node: SchemaProposalComment! + } + + type SchemaProposalCommentConnection { + edges: [SchemaProposalCommentEdge!] + pageInfo: PageInfo! + } + + type SchemaProposalReview { + id: ID! + + """ + Comments attached to this review. + """ + comments(first: Int! = 200): SchemaProposalCommentConnection + + createdAt: DateTime! + """ + If the "lineText" can be found in the referenced SchemaProposalVersion, + then the "lineNumber" will be for that version. If there is no matching + "lineText", then this "lineNumber" will reference the originally + reviewed version of the proposal, and will be considered outdated. + + If the "lineNumber" is null then this review references the entire SchemaProposalVersion + and not any specific line. + """ + lineNumber: Int + + """ + If the "lineText" is null then this review references the entire SchemaProposalVersion + and not any specific line within the proposal. + """ + lineText: String + + schemaProposalVersion: SchemaProposalVersion + schemaProposal: SchemaProposal! + + """ + If null then this review is just a comment. Otherwise, the reviewer changed the state of the + proposal as part of their review. E.g. The reviewer can approve a version with a comment. + """ + stageTransition: SchemaProposalStage + + """ + The author of this review. + """ + user: User + } + + type SchemaProposalComment { + id: ID! + + """ + Content of this comment. E.g. "Nice job!" + """ + body: String! + + createdAt: DateTime! + updatedAt: DateTime! + + """ + The author of this comment + """ + user: User + } +`; diff --git a/packages/services/api/src/modules/proposals/resolvers/Mutation/createSchemaProposal.ts b/packages/services/api/src/modules/proposals/resolvers/Mutation/createSchemaProposal.ts new file mode 100644 index 0000000000..86ae7b6669 --- /dev/null +++ b/packages/services/api/src/modules/proposals/resolvers/Mutation/createSchemaProposal.ts @@ -0,0 +1,9 @@ +import type { MutationResolvers } from './../../../../__generated__/types'; + +export const createSchemaProposal: NonNullable = async ( + _parent, + _arg, + _ctx, +) => { + /* Implement Mutation.createSchemaProposal resolver logic here */ +}; diff --git a/packages/services/api/src/modules/proposals/resolvers/Mutation/createSchemaProposalComment.ts b/packages/services/api/src/modules/proposals/resolvers/Mutation/createSchemaProposalComment.ts new file mode 100644 index 0000000000..71221db0a8 --- /dev/null +++ b/packages/services/api/src/modules/proposals/resolvers/Mutation/createSchemaProposalComment.ts @@ -0,0 +1,7 @@ +import type { MutationResolvers } from './../../../../__generated__/types'; + +export const createSchemaProposalComment: NonNullable< + MutationResolvers['createSchemaProposalComment'] +> = async (_parent, _arg, _ctx) => { + /* Implement Mutation.createSchemaProposalComment resolver logic here */ +}; diff --git a/packages/services/api/src/modules/proposals/resolvers/Mutation/createSchemaProposalReview.ts b/packages/services/api/src/modules/proposals/resolvers/Mutation/createSchemaProposalReview.ts new file mode 100644 index 0000000000..84ebe6bf1c --- /dev/null +++ b/packages/services/api/src/modules/proposals/resolvers/Mutation/createSchemaProposalReview.ts @@ -0,0 +1,7 @@ +import type { MutationResolvers } from './../../../../__generated__/types'; + +export const createSchemaProposalReview: NonNullable< + MutationResolvers['createSchemaProposalReview'] +> = async (_parent, _arg, _ctx) => { + /* Implement Mutation.createSchemaProposalReview resolver logic here */ +}; diff --git a/packages/services/api/src/modules/proposals/resolvers/Query/schemaProposal.ts b/packages/services/api/src/modules/proposals/resolvers/Query/schemaProposal.ts new file mode 100644 index 0000000000..f07665d83e --- /dev/null +++ b/packages/services/api/src/modules/proposals/resolvers/Query/schemaProposal.ts @@ -0,0 +1,9 @@ +import type { QueryResolvers } from './../../../../__generated__/types'; + +export const schemaProposal: NonNullable = async ( + _parent, + _arg, + _ctx, +) => { + /* Implement Query.schemaProposal resolver logic here */ +}; diff --git a/packages/services/api/src/modules/proposals/resolvers/Query/schemaProposalReview.ts b/packages/services/api/src/modules/proposals/resolvers/Query/schemaProposalReview.ts new file mode 100644 index 0000000000..9febec518b --- /dev/null +++ b/packages/services/api/src/modules/proposals/resolvers/Query/schemaProposalReview.ts @@ -0,0 +1,9 @@ +import type { QueryResolvers } from './../../../../__generated__/types'; + +export const schemaProposalReview: NonNullable = async ( + _parent, + _arg, + _ctx, +) => { + /* Implement Query.schemaProposalReview resolver logic here */ +}; diff --git a/packages/services/api/src/modules/proposals/resolvers/Query/schemaProposalReviews.ts b/packages/services/api/src/modules/proposals/resolvers/Query/schemaProposalReviews.ts new file mode 100644 index 0000000000..57d2f5c176 --- /dev/null +++ b/packages/services/api/src/modules/proposals/resolvers/Query/schemaProposalReviews.ts @@ -0,0 +1,9 @@ +import type { QueryResolvers } from './../../../../__generated__/types'; + +export const schemaProposalReviews: NonNullable = async ( + _parent, + _arg, + _ctx, +) => { + /* Implement Query.schemaProposalReviews resolver logic here */ +}; diff --git a/packages/services/api/src/modules/proposals/resolvers/Query/schemaProposals.ts b/packages/services/api/src/modules/proposals/resolvers/Query/schemaProposals.ts new file mode 100644 index 0000000000..4833fc42bd --- /dev/null +++ b/packages/services/api/src/modules/proposals/resolvers/Query/schemaProposals.ts @@ -0,0 +1,9 @@ +import type { QueryResolvers } from './../../../../__generated__/types'; + +export const schemaProposals: NonNullable = async ( + _parent, + _arg, + _ctx, +) => { + /* Implement Query.schemaProposals resolver logic here */ +}; diff --git a/packages/services/api/src/modules/proposals/resolvers/SchemaVersion.ts b/packages/services/api/src/modules/proposals/resolvers/SchemaVersion.ts new file mode 100644 index 0000000000..3e26ca5881 --- /dev/null +++ b/packages/services/api/src/modules/proposals/resolvers/SchemaVersion.ts @@ -0,0 +1,14 @@ +import type { SchemaVersionResolvers } from './../../../__generated__/types'; + +/* + * Note: This object type is generated because "SchemaVersionMapper" is declared. This is to ensure runtime safety. + * + * When a mapper is used, it is possible to hit runtime errors in some scenarios: + * - given a field name, the schema type's field type does not match mapper's field type + * - or a schema type's field does not exist in the mapper's fields + * + * If you want to skip this file generation, remove the mapper or update the pattern in the `resolverGeneration.object` config. + */ +export const SchemaVersion: Pick = { + /* Implement SchemaVersion resolver logic here */ +}; diff --git a/packages/services/api/src/modules/proposals/resolvers/Target.ts b/packages/services/api/src/modules/proposals/resolvers/Target.ts new file mode 100644 index 0000000000..c1fb215e2c --- /dev/null +++ b/packages/services/api/src/modules/proposals/resolvers/Target.ts @@ -0,0 +1,14 @@ +import type { TargetResolvers } from './../../../__generated__/types'; + +/* + * Note: This object type is generated because "TargetMapper" is declared. This is to ensure runtime safety. + * + * When a mapper is used, it is possible to hit runtime errors in some scenarios: + * - given a field name, the schema type's field type does not match mapper's field type + * - or a schema type's field does not exist in the mapper's fields + * + * If you want to skip this file generation, remove the mapper or update the pattern in the `resolverGeneration.object` config. + */ +export const Target: Pick = { + /* Implement Target resolver logic here */ +}; diff --git a/packages/services/api/src/modules/schema/resolvers/SchemaVersion.ts b/packages/services/api/src/modules/schema/resolvers/SchemaVersion.ts index 69f12e184a..a20166c9bf 100644 --- a/packages/services/api/src/modules/schema/resolvers/SchemaVersion.ts +++ b/packages/services/api/src/modules/schema/resolvers/SchemaVersion.ts @@ -9,7 +9,32 @@ import { SchemaManager } from '../providers/schema-manager'; import { SchemaVersionHelper } from '../providers/schema-version-helper'; import type { SchemaVersionResolvers } from './../../../__generated__/types'; -export const SchemaVersion: SchemaVersionResolvers = { +export const SchemaVersion: Pick< + SchemaVersionResolvers, + | 'baseSchema' + | 'breakingSchemaChanges' + | 'contractVersions' + | 'date' + | 'deprecatedSchema' + | 'explorer' + | 'githubMetadata' + | 'hasSchemaChanges' + | 'isComposable' + | 'isFirstComposableVersion' + | 'isValid' + | 'log' + | 'previousDiffableSchemaVersion' + | 'safeSchemaChanges' + | 'schemaChanges' + | 'schemaCompositionErrors' + | 'schemas' + | 'sdl' + | 'supergraph' + | 'tags' + | 'unusedSchema' + | 'valid' + | '__isTypeOf' +> = { isComposable: version => { return version.schemaCompositionErrors === null; }, diff --git a/packages/services/api/src/modules/target/resolvers/Target.ts b/packages/services/api/src/modules/target/resolvers/Target.ts index 8436523476..a9ad64474a 100644 --- a/packages/services/api/src/modules/target/resolvers/Target.ts +++ b/packages/services/api/src/modules/target/resolvers/Target.ts @@ -10,7 +10,6 @@ export const Target: Pick< | 'experimental_forcedLegacySchemaComposition' | 'failDiffOnDangerousChange' | 'graphqlEndpointUrl' - | 'id' | 'name' | 'project' | 'slug' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1ba0b1c635..393fd5d0e7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1867,7 +1867,7 @@ importers: version: 4.3.4(vite@6.3.4(@types/node@22.10.5)(jiti@2.3.3)(less@4.2.0)(lightningcss@1.28.2)(terser@5.37.0)(tsx@4.19.2)(yaml@2.5.0)) autoprefixer: specifier: 10.4.20 - version: 10.4.20(postcss@8.5.3) + version: 10.4.20(postcss@8.4.49) class-variance-authority: specifier: 0.7.1 version: 0.7.1 @@ -24260,8 +24260,8 @@ snapshots: '@typescript-eslint/parser': 7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3) eslint: 8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0) eslint-config-prettier: 9.1.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)) - eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-plugin-import@2.29.1)(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)) - eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)) + eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)) + eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)) eslint-plugin-jsonc: 2.11.1(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)) eslint-plugin-jsx-a11y: 6.8.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)) eslint-plugin-mdx: 3.0.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)) @@ -25277,14 +25277,14 @@ snapshots: auto-bind@4.0.0: {} - autoprefixer@10.4.20(postcss@8.5.3): + autoprefixer@10.4.20(postcss@8.4.49): dependencies: browserslist: 4.24.0 caniuse-lite: 1.0.30001669 fraction.js: 4.3.7 normalize-range: 0.1.2 picocolors: 1.1.1 - postcss: 8.5.3 + postcss: 8.4.49 postcss-value-parser: 4.2.0 available-typed-arrays@1.0.5: {} @@ -26920,13 +26920,13 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-plugin-import@2.29.1)(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)): + eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)): dependencies: debug: 4.4.0(supports-color@8.1.1) enhanced-resolve: 5.17.1 eslint: 8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0) - eslint-module-utils: 2.8.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)) - eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)) + eslint-module-utils: 2.8.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)) + eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)) fast-glob: 3.3.2 get-tsconfig: 4.7.5 is-core-module: 2.13.1 @@ -26957,14 +26957,14 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.8.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)): + eslint-module-utils@2.8.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)): dependencies: debug: 3.2.7(supports-color@8.1.1) optionalDependencies: '@typescript-eslint/parser': 7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3) eslint: 8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-plugin-import@2.29.1)(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)) + eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)) transitivePeerDependencies: - supports-color @@ -26980,7 +26980,7 @@ snapshots: eslint: 8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0) eslint-compat-utils: 0.1.2(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)) - eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)): + eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)): dependencies: array-includes: 3.1.7 array.prototype.findlastindex: 1.2.3 @@ -26990,7 +26990,7 @@ snapshots: doctrine: 2.1.0 eslint: 8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.8.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)) + eslint-module-utils: 2.8.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)) hasown: 2.0.0 is-core-module: 2.13.1 is-glob: 4.0.3 From 418dfdcc4febfeecba5b97a34a638795c0febe6b Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Tue, 3 Jun 2025 12:30:47 -0700 Subject: [PATCH 02/54] Connections everywhere --- .../src/modules/proposals/module.graphql.ts | 30 ++++++++++++++++--- .../Mutation/createSchemaProposal.ts | 7 +++++ .../Mutation/createSchemaProposalComment.ts | 8 ++++- .../Mutation/createSchemaProposalReview.ts | 13 +++++++- .../resolvers/Query/schemaProposal.ts | 8 ++++- .../resolvers/Query/schemaProposalReview.ts | 12 +++++++- .../resolvers/Query/schemaProposalReviews.ts | 9 ++++++ .../resolvers/Query/schemaProposals.ts | 19 ++++++++++++ 8 files changed, 98 insertions(+), 8 deletions(-) diff --git a/packages/services/api/src/modules/proposals/module.graphql.ts b/packages/services/api/src/modules/proposals/module.graphql.ts index 9cc5e3d74b..a5c600025b 100644 --- a/packages/services/api/src/modules/proposals/module.graphql.ts +++ b/packages/services/api/src/modules/proposals/module.graphql.ts @@ -48,7 +48,11 @@ export default gql` } extend type Query { - schemaProposals(after: String, first: Int! = 30, input: SchemaProposalsInput): [SchemaProposal] + schemaProposals( + after: String + first: Int! = 30 + input: SchemaProposalsInput + ): SchemaProposalConnection schemaProposal(input: SchemaProposalInput!): SchemaProposal schemaProposalReviews( after: String @@ -59,7 +63,9 @@ export default gql` } input SchemaProposalsInput { - target: TargetReferenceInput + target: TargetReferenceInput! + userIds: [ID!] + stages: [SchemaProposalStage!] } input SchemaProposalInput { @@ -166,6 +172,9 @@ export default gql` } type SchemaProposalReview { + """ + A UUID unique to this review. Used for querying. + """ id: ID! """ @@ -173,7 +182,12 @@ export default gql` """ comments(first: Int! = 200): SchemaProposalCommentConnection + """ + When the review was first made. Only a review's comments are mutable, so there is no + updatedAt on the review. + """ createdAt: DateTime! + """ If the "lineText" can be found in the referenced SchemaProposalVersion, then the "lineNumber" will be for that version. If there is no matching @@ -191,8 +205,15 @@ export default gql` """ lineText: String + """ + The specific version that this review is for. + """ schemaProposalVersion: SchemaProposalVersion - schemaProposal: SchemaProposal! + + """ + The parent proposal that this review is for. + """ + schemaProposal: SchemaProposal """ If null then this review is just a comment. Otherwise, the reviewer changed the state of the @@ -209,12 +230,13 @@ export default gql` type SchemaProposalComment { id: ID! + createdAt: DateTime! + """ Content of this comment. E.g. "Nice job!" """ body: String! - createdAt: DateTime! updatedAt: DateTime! """ diff --git a/packages/services/api/src/modules/proposals/resolvers/Mutation/createSchemaProposal.ts b/packages/services/api/src/modules/proposals/resolvers/Mutation/createSchemaProposal.ts index 86ae7b6669..2c0d98a325 100644 --- a/packages/services/api/src/modules/proposals/resolvers/Mutation/createSchemaProposal.ts +++ b/packages/services/api/src/modules/proposals/resolvers/Mutation/createSchemaProposal.ts @@ -1,3 +1,4 @@ +import { crypto } from '@whatwg-node/fetch'; import type { MutationResolvers } from './../../../../__generated__/types'; export const createSchemaProposal: NonNullable = async ( @@ -6,4 +7,10 @@ export const createSchemaProposal: NonNullable { /* Implement Mutation.createSchemaProposal resolver logic here */ + return { + createdAt: Date.now(), + id: crypto.randomUUID(), + stage: 'DRAFT', + updatedAt: Date.now(), + }; }; diff --git a/packages/services/api/src/modules/proposals/resolvers/Mutation/createSchemaProposalComment.ts b/packages/services/api/src/modules/proposals/resolvers/Mutation/createSchemaProposalComment.ts index 71221db0a8..b6bdea4675 100644 --- a/packages/services/api/src/modules/proposals/resolvers/Mutation/createSchemaProposalComment.ts +++ b/packages/services/api/src/modules/proposals/resolvers/Mutation/createSchemaProposalComment.ts @@ -2,6 +2,12 @@ import type { MutationResolvers } from './../../../../__generated__/types'; export const createSchemaProposalComment: NonNullable< MutationResolvers['createSchemaProposalComment'] -> = async (_parent, _arg, _ctx) => { +> = async (_parent, { input: { body } }, _ctx) => { /* Implement Mutation.createSchemaProposalComment resolver logic here */ + return { + createdAt: Date.now(), + id: crypto.randomUUID(), + updatedAt: Date.now(), + body, + }; }; diff --git a/packages/services/api/src/modules/proposals/resolvers/Mutation/createSchemaProposalReview.ts b/packages/services/api/src/modules/proposals/resolvers/Mutation/createSchemaProposalReview.ts index 84ebe6bf1c..e2c5b7ef42 100644 --- a/packages/services/api/src/modules/proposals/resolvers/Mutation/createSchemaProposalReview.ts +++ b/packages/services/api/src/modules/proposals/resolvers/Mutation/createSchemaProposalReview.ts @@ -1,7 +1,18 @@ +import { crypto } from '@whatwg-node/fetch'; import type { MutationResolvers } from './../../../../__generated__/types'; export const createSchemaProposalReview: NonNullable< MutationResolvers['createSchemaProposalReview'] -> = async (_parent, _arg, _ctx) => { +> = async (_parent, { input: { stageTransition } }, _ctx) => { /* Implement Mutation.createSchemaProposalReview resolver logic here */ + return { + createdAt: Date.now(), + id: crypto.randomUUID(), + schemaProposal: { + stage: stageTransition ?? 'OPEN', + createdAt: Date.now(), + id: crypto.randomUUID(), + updatedAt: Date.now(), + }, + }; }; diff --git a/packages/services/api/src/modules/proposals/resolvers/Query/schemaProposal.ts b/packages/services/api/src/modules/proposals/resolvers/Query/schemaProposal.ts index f07665d83e..aa582c908a 100644 --- a/packages/services/api/src/modules/proposals/resolvers/Query/schemaProposal.ts +++ b/packages/services/api/src/modules/proposals/resolvers/Query/schemaProposal.ts @@ -2,8 +2,14 @@ import type { QueryResolvers } from './../../../../__generated__/types'; export const schemaProposal: NonNullable = async ( _parent, - _arg, + { input: { id } }, _ctx, ) => { /* Implement Query.schemaProposal resolver logic here */ + return { + createdAt: Date.now(), + id, + stage: 'OPEN', + updatedAt: Date.now(), + }; }; diff --git a/packages/services/api/src/modules/proposals/resolvers/Query/schemaProposalReview.ts b/packages/services/api/src/modules/proposals/resolvers/Query/schemaProposalReview.ts index 9febec518b..1866d84a45 100644 --- a/packages/services/api/src/modules/proposals/resolvers/Query/schemaProposalReview.ts +++ b/packages/services/api/src/modules/proposals/resolvers/Query/schemaProposalReview.ts @@ -2,8 +2,18 @@ import type { QueryResolvers } from './../../../../__generated__/types'; export const schemaProposalReview: NonNullable = async ( _parent, - _arg, + { input: { id } }, _ctx, ) => { /* Implement Query.schemaProposalReview resolver logic here */ + return { + createdAt: Date.now(), + id, + schemaProposal: { + id: crypto.randomUUID(), + createdAt: Date.now(), + stage: 'OPEN', + updatedAt: Date.now(), + }, + }; }; diff --git a/packages/services/api/src/modules/proposals/resolvers/Query/schemaProposalReviews.ts b/packages/services/api/src/modules/proposals/resolvers/Query/schemaProposalReviews.ts index 57d2f5c176..fb58642964 100644 --- a/packages/services/api/src/modules/proposals/resolvers/Query/schemaProposalReviews.ts +++ b/packages/services/api/src/modules/proposals/resolvers/Query/schemaProposalReviews.ts @@ -6,4 +6,13 @@ export const schemaProposalReviews: NonNullable { /* Implement Query.schemaProposalReviews resolver logic here */ + return { + edges: [], + pageInfo: { + endCursor: '', + hasNextPage: false, + hasPreviousPage: false, + startCursor: '', + }, + }; }; diff --git a/packages/services/api/src/modules/proposals/resolvers/Query/schemaProposals.ts b/packages/services/api/src/modules/proposals/resolvers/Query/schemaProposals.ts index 4833fc42bd..e4819ac66a 100644 --- a/packages/services/api/src/modules/proposals/resolvers/Query/schemaProposals.ts +++ b/packages/services/api/src/modules/proposals/resolvers/Query/schemaProposals.ts @@ -6,4 +6,23 @@ export const schemaProposals: NonNullable = a _ctx, ) => { /* Implement Query.schemaProposals resolver logic here */ + return { + edges: [ + { + cursor: crypto.randomUUID(), + node: { + id: crypto.randomUUID(), + createdAt: Date.now(), + stage: 'DRAFT', + updatedAt: Date.now(), + }, + }, + ], + pageInfo: { + endCursor: '', + hasNextPage: false, + hasPreviousPage: false, + startCursor: '', + }, + }; }; From 1c53535c49b8f7f66a2f5c0d44379a0cfb131c99 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Tue, 3 Jun 2025 12:54:21 -0700 Subject: [PATCH 03/54] Remove crypto imports --- .../proposals/resolvers/Mutation/createSchemaProposal.ts | 3 +-- .../resolvers/Mutation/createSchemaProposalReview.ts | 5 ++--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/services/api/src/modules/proposals/resolvers/Mutation/createSchemaProposal.ts b/packages/services/api/src/modules/proposals/resolvers/Mutation/createSchemaProposal.ts index 2c0d98a325..72b0108d2f 100644 --- a/packages/services/api/src/modules/proposals/resolvers/Mutation/createSchemaProposal.ts +++ b/packages/services/api/src/modules/proposals/resolvers/Mutation/createSchemaProposal.ts @@ -1,4 +1,3 @@ -import { crypto } from '@whatwg-node/fetch'; import type { MutationResolvers } from './../../../../__generated__/types'; export const createSchemaProposal: NonNullable = async ( @@ -9,7 +8,7 @@ export const createSchemaProposal: NonNullable Date: Tue, 3 Jun 2025 13:17:33 -0700 Subject: [PATCH 04/54] Add migration import --- packages/migrations/src/run-pg-migrations.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/migrations/src/run-pg-migrations.ts b/packages/migrations/src/run-pg-migrations.ts index fc0b902d47..6790644d2f 100644 --- a/packages/migrations/src/run-pg-migrations.ts +++ b/packages/migrations/src/run-pg-migrations.ts @@ -167,5 +167,6 @@ export const runPGMigrations = async (args: { slonik: DatabasePool; runTo?: stri await import('./actions/2025.05.15T00-00-00.contracts-foreign-key-constraint-fix'), await import('./actions/2025.05.15T00-00-01.organization-member-pagination'), await import('./actions/2025.05.28T00-00-00.schema-log-by-ids'), + await import('./actions/2025.05.29T00.00.00.schema-proposals'), ], }); From 3147e5af3cfc53a995246e262313348659e76abc Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Tue, 3 Jun 2025 13:32:47 -0700 Subject: [PATCH 05/54] fix migration syntax --- .../actions/2025.05.29T00.00.00.schema-proposals.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/migrations/src/actions/2025.05.29T00.00.00.schema-proposals.ts b/packages/migrations/src/actions/2025.05.29T00.00.00.schema-proposals.ts index f9c553fb44..ba575bea4d 100644 --- a/packages/migrations/src/actions/2025.05.29T00.00.00.schema-proposals.ts +++ b/packages/migrations/src/actions/2025.05.29T00.00.00.schema-proposals.ts @@ -9,7 +9,7 @@ export default { { name: 'create schema_proposal tables', query: sql` - CREATE TYPE IF NOT EXISTS + CREATE TYPE schema_proposal_stage AS ENUM('DRAFT', 'OPEN', 'APPROVED', 'IMPLEMENTED', 'CLOSED') ; /** @@ -30,7 +30,7 @@ export default { , user_id UUID REFERENCES users (id) ON DELETE SET NULL -- schema version that is used to calculate the diff. In case the version is deleted, -- set this to null to avoid completely erasing the change... This should never happen. - , diff_schema_version_id UUID NOT NULL REFERENCES schema_version (id) ON DELETE SET NULL + , diff_schema_version_id UUID NOT NULL REFERENCES schema_versions (id) ON DELETE SET NULL ) ; CREATE INDEX IF NOT EXISTS schema_proposals_list ON schema_proposals ( @@ -51,7 +51,7 @@ export default { ) ; CREATE INDEX IF NOT EXISTS schema_proposals_list_by_user_id_stage ON schema_proposals ( - target_id, + target_id , user_id , stage , created_at DESC @@ -158,8 +158,8 @@ export default { ) ; CREATE INDEX IF NOT EXISTS schema_proposal_comments_list ON schema_proposal_comments( - schema_proposal_review_id, - created_at ASC + schema_proposal_review_id + , created_at ASC ) ; -- For performance on user delete From 4eb5b8910404db2655b03762bb558031ab25d523 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Tue, 3 Jun 2025 15:16:32 -0700 Subject: [PATCH 06/54] Generate types --- packages/services/storage/src/db/types.ts | 45 +++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/packages/services/storage/src/db/types.ts b/packages/services/storage/src/db/types.ts index 2d563d08ef..1920119761 100644 --- a/packages/services/storage/src/db/types.ts +++ b/packages/services/storage/src/db/types.ts @@ -11,6 +11,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 schema_proposal_stage = 'APPROVED' | 'CLOSED' | 'DRAFT' | 'IMPLEMENTED' | 'OPEN'; export type user_role = 'ADMIN' | 'MEMBER'; export interface alert_channels { @@ -318,6 +319,46 @@ export interface schema_policy_config { updated_at: Date; } +export interface schema_proposal_comments { + body: string; + created_at: Date; + id: string; + schema_proposal_review_id: string | null; + updated_at: Date; + user_id: string | null; +} + +export interface schema_proposal_reviews { + created_at: Date; + id: string; + line_text: string | null; + original_line_num: number | null; + original_schema_proposal_version_id: string; + schema_proposal_id: string; + stage_transition: schema_proposal_stage; + user_id: string | null; +} + +export interface schema_proposal_versions { + created_at: Date; + id: string; + schema_proposal_id: string; + schema_sdl: string; + service_name: string | null; + user_id: string | null; +} + +export interface schema_proposals { + created_at: Date; + diff_schema_version_id: string; + id: string; + stage: schema_proposal_stage; + target_id: string; + title: string; + updated_at: Date; + user_id: string | null; +} + export interface schema_version_changes { change_type: string; id: string; @@ -451,6 +492,10 @@ export interface DBTables { schema_coordinate_status: schema_coordinate_status; schema_log: schema_log; schema_policy_config: schema_policy_config; + schema_proposal_comments: schema_proposal_comments; + schema_proposal_reviews: schema_proposal_reviews; + schema_proposal_versions: schema_proposal_versions; + schema_proposals: schema_proposals; schema_version_changes: schema_version_changes; schema_version_to_log: schema_version_to_log; schema_versions: schema_versions; From f7f22b9930f1ad8cc66c9f490fae01f2c7d13e31 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Wed, 11 Jun 2025 15:44:17 -0700 Subject: [PATCH 07/54] Add schema coordinate to proposal review --- .../src/actions/2025.05.29T00.00.00.schema-proposals.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/migrations/src/actions/2025.05.29T00.00.00.schema-proposals.ts b/packages/migrations/src/actions/2025.05.29T00.00.00.schema-proposals.ts index ba575bea4d..36b5b1bbaf 100644 --- a/packages/migrations/src/actions/2025.05.29T00.00.00.schema-proposals.ts +++ b/packages/migrations/src/actions/2025.05.29T00.00.00.schema-proposals.ts @@ -125,6 +125,12 @@ export default { , line_text text -- used in combination with the line_text to determine what line in the current version this review is attributed to , original_line_num INT + -- the coordinate closest to the reviewed line. E.g. if a comment is reviewed, then + -- this is the coordinate that the comment applies to. + -- note that the line_text must still be stored in case the coordinate can no + -- longer be found in the latest proposal version. That way a preview of the reviewed + -- line can be provided. + , schema_coordinate text ) ; CREATE INDEX IF NOT EXISTS schema_proposal_reviews_schema_proposal_id ON schema_proposal_reviews( From 70987804942c1a97a10e394fde3d42c260e1a131 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Fri, 13 Jun 2025 18:39:05 -0700 Subject: [PATCH 08/54] Fix types --- packages/services/storage/src/db/types.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/services/storage/src/db/types.ts b/packages/services/storage/src/db/types.ts index 1920119761..a7d6110f8e 100644 --- a/packages/services/storage/src/db/types.ts +++ b/packages/services/storage/src/db/types.ts @@ -334,6 +334,7 @@ export interface schema_proposal_reviews { line_text: string | null; original_line_num: number | null; original_schema_proposal_version_id: string; + schema_coordinate: string | null; schema_proposal_id: string; stage_transition: schema_proposal_stage; user_id: string | null; From cae00ef23794398acaf53dfd1b67aedfc8b6ff52 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Mon, 16 Jun 2025 14:27:48 -0700 Subject: [PATCH 09/54] Schema proposals UI list (#6842) --- .../api/src/modules/proposals/index.ts | 1 - .../src/modules/proposals/module.graphql.ts | 1 + .../Mutation/createSchemaProposal.ts | 1 + .../Mutation/createSchemaProposalReview.ts | 3 +- .../resolvers/Query/schemaProposal.ts | 1 + .../resolvers/Query/schemaProposalReview.ts | 1 + .../resolvers/Query/schemaProposals.ts | 48 ++- .../src/components/common/ListNavigation.tsx | 137 ++++++++ .../web/app/src/components/layouts/target.tsx | 13 + .../target/proposals/stage-filter.tsx | 68 ++++ .../target/proposals/user-filter.tsx | 127 +++++++ .../web/app/src/components/v2/checkbox.tsx | 6 +- packages/web/app/src/index.css | 10 + .../web/app/src/pages/target-proposals.tsx | 322 ++++++++++++++++++ packages/web/app/src/router.tsx | 50 +++ pnpm-lock.yaml | 18 +- 16 files changed, 783 insertions(+), 24 deletions(-) create mode 100644 packages/web/app/src/components/common/ListNavigation.tsx create mode 100644 packages/web/app/src/components/target/proposals/stage-filter.tsx create mode 100644 packages/web/app/src/components/target/proposals/user-filter.tsx create mode 100644 packages/web/app/src/pages/target-proposals.tsx diff --git a/packages/services/api/src/modules/proposals/index.ts b/packages/services/api/src/modules/proposals/index.ts index 5fec7a01e8..d1b6862b56 100644 --- a/packages/services/api/src/modules/proposals/index.ts +++ b/packages/services/api/src/modules/proposals/index.ts @@ -8,5 +8,4 @@ export const proposalsModule = createModule({ typeDefs, resolvers, providers: [], - // providers: [TargetManager, TargetsByIdCache, TargetsBySlugCache], }); diff --git a/packages/services/api/src/modules/proposals/module.graphql.ts b/packages/services/api/src/modules/proposals/module.graphql.ts index a5c600025b..b7b782410f 100644 --- a/packages/services/api/src/modules/proposals/module.graphql.ts +++ b/packages/services/api/src/modules/proposals/module.graphql.ts @@ -125,6 +125,7 @@ export default gql` first: Int! = 15 input: SchemaProposalVersionsInput ): SchemaProposalVersionConnection + commentsCount: Int! } type SchemaProposalReviewEdge { diff --git a/packages/services/api/src/modules/proposals/resolvers/Mutation/createSchemaProposal.ts b/packages/services/api/src/modules/proposals/resolvers/Mutation/createSchemaProposal.ts index 72b0108d2f..cb75602425 100644 --- a/packages/services/api/src/modules/proposals/resolvers/Mutation/createSchemaProposal.ts +++ b/packages/services/api/src/modules/proposals/resolvers/Mutation/createSchemaProposal.ts @@ -8,6 +8,7 @@ export const createSchemaProposal: NonNullable = async (_parent, { input: { stageTransition } }, _ctx) => { +> = async (_parent, { input: { stageTransition, commentBody } }, _ctx) => { /* Implement Mutation.createSchemaProposalReview resolver logic here */ return { createdAt: Date.now(), id: `abcd-1234-efgh-5678-wxyz`, schemaProposal: { stage: stageTransition ?? 'OPEN', + commentsCount: commentBody ? 1 : 0, createdAt: Date.now(), id: `abcd-1234-efgh-5678-wxyz`, updatedAt: Date.now(), diff --git a/packages/services/api/src/modules/proposals/resolvers/Query/schemaProposal.ts b/packages/services/api/src/modules/proposals/resolvers/Query/schemaProposal.ts index aa582c908a..bfb10977c6 100644 --- a/packages/services/api/src/modules/proposals/resolvers/Query/schemaProposal.ts +++ b/packages/services/api/src/modules/proposals/resolvers/Query/schemaProposal.ts @@ -11,5 +11,6 @@ export const schemaProposal: NonNullable = asy id, stage: 'OPEN', updatedAt: Date.now(), + commentsCount: 5, }; }; diff --git a/packages/services/api/src/modules/proposals/resolvers/Query/schemaProposalReview.ts b/packages/services/api/src/modules/proposals/resolvers/Query/schemaProposalReview.ts index 1866d84a45..a50618059c 100644 --- a/packages/services/api/src/modules/proposals/resolvers/Query/schemaProposalReview.ts +++ b/packages/services/api/src/modules/proposals/resolvers/Query/schemaProposalReview.ts @@ -12,6 +12,7 @@ export const schemaProposalReview: NonNullable = a _ctx, ) => { /* Implement Query.schemaProposals resolver logic here */ + const edges = Array.from({ length: 10 }).map(() => ({ + cursor: crypto.randomUUID(), + node: { + id: crypto.randomUUID(), + createdAt: Date.now(), + stage: 'DRAFT' as const, + updatedAt: Date.now(), + title: 'Add user types to registration service.', + user: { + displayName: 'jdolle', + fullName: 'Jeff Dolle', + id: crypto.randomUUID(), + } as any, + commentsCount: 7, + }, + })); + return { - edges: [ - { - cursor: crypto.randomUUID(), - node: { - id: crypto.randomUUID(), - createdAt: Date.now(), - stage: 'DRAFT', - updatedAt: Date.now(), - }, - }, - ], + edges: edges.map((e: any, i) => { + if (i == 2) { + return { + ...e, + node: { + ...e.node, + title: + "Does some other things as well as this has a long time that should be truncated. So let's see what happens", + stage: 'OPEN' as const, + commentsCount: 3, + user: { + ...e.node.user, + }, + }, + }; + } + return e; + }), pageInfo: { endCursor: '', hasNextPage: false, hasPreviousPage: false, startCursor: '', }, - }; + } as any; }; diff --git a/packages/web/app/src/components/common/ListNavigation.tsx b/packages/web/app/src/components/common/ListNavigation.tsx new file mode 100644 index 0000000000..4b67cd9ced --- /dev/null +++ b/packages/web/app/src/components/common/ListNavigation.tsx @@ -0,0 +1,137 @@ +import { createContext, ReactNode, useCallback, useContext, useState } from 'react'; +import { cn } from '@/lib/utils'; +import { HamburgerMenuIcon } from '@radix-ui/react-icons'; +import { Button } from '../ui/button'; + +type ListNavigationContextType = { + isListNavCollapsed: boolean; + setIsListNavCollapsed: (collapsed: boolean) => void; + isListNavHidden: boolean; + setIsListNavHidden: (hidden: boolean) => void; +}; + +const ListNavigationContext = createContext({ + isListNavCollapsed: true, + setIsListNavCollapsed: () => {}, + isListNavHidden: false, + setIsListNavHidden: () => {}, +}); + +export function useListNavigationContext() { + return useContext(ListNavigationContext); +} + +export function ListNavigationProvider({ + children, + isCollapsed, + isHidden, +}: { + children: ReactNode; + isCollapsed: boolean; + isHidden: boolean; +}) { + const [isListNavCollapsed, setIsListNavCollapsed] = useState(isCollapsed); + const [isListNavHidden, setIsListNavHidden] = useState(isHidden); + + return ( + + {children} + + ); +} + +export function useListNavCollapsedToggle() { + const { setIsListNavCollapsed, isListNavCollapsed } = useListNavigationContext(); + const toggle = useCallback(() => { + setIsListNavCollapsed(!isListNavCollapsed); + }, [setIsListNavCollapsed, isListNavCollapsed]); + + return [isListNavCollapsed, toggle] as const; +} + +export function useListNavHiddenToggle() { + const { setIsListNavHidden, isListNavHidden, isListNavCollapsed, setIsListNavCollapsed } = + useListNavigationContext(); + const toggle = useCallback(() => { + if (isListNavHidden === false && isListNavCollapsed === true) { + setIsListNavCollapsed(false); + } else { + setIsListNavHidden(!isListNavHidden); + } + }, [isListNavHidden, setIsListNavHidden, isListNavCollapsed, setIsListNavCollapsed]); + + return [isListNavHidden, toggle] as const; +} + +function MenuButton({ onClick, className }: { className?: string; onClick: () => void }) { + return ( + + ); +} + +export function ListNavigationTrigger(props: { children?: ReactNode; className?: string }) { + const [_hidden, toggle] = useListNavHiddenToggle(); + + return props.children ? ( + + ) : ( + + ); +} + +export function ListNavigationWrapper(props: { list: ReactNode; content: ReactNode }) { + const { isListNavCollapsed, isListNavHidden } = useListNavigationContext(); + + return ( +
+ {props.list} +
+ {props.content} +
+
+ ); +} + +export function ListNavigation(props: { children: ReactNode }) { + const { isListNavCollapsed, isListNavHidden } = useListNavigationContext(); + return ( +
+
+
+ {props.children} +
+
+
+ ); +} diff --git a/packages/web/app/src/components/layouts/target.tsx b/packages/web/app/src/components/layouts/target.tsx index 64d508f9d6..3f83cdd2d1 100644 --- a/packages/web/app/src/components/layouts/target.tsx +++ b/packages/web/app/src/components/layouts/target.tsx @@ -41,6 +41,7 @@ export enum Page { Insights = 'insights', Laboratory = 'laboratory', Apps = 'apps', + Proposals = 'proposals', Settings = 'settings', } @@ -230,6 +231,18 @@ export const TargetLayout = ({ )} + + + Proposals + + {currentTarget.viewerCanAccessSettings && ( { + const [open, setOpen] = useState(false); + const hasSelection = selectedStages.length !== 0; + const router = useRouter(); + const search = useSearch({ strict: false }); + const stages = Object.values(SchemaProposalStage).map(s => s.toLocaleLowerCase()); + + return ( + + + + + + + + + {stages?.map(stage => ( + { + let updated: string[] | undefined = [...selectedStages]; + const selectionIdx = updated.findIndex(s => s === selectedStage); + if (selectionIdx >= 0) { + updated.splice(selectionIdx, 1); + if (updated.length === 0) { + updated = undefined; + } + } else { + updated.push(selectedStage); + } + void router.navigate({ + search: { ...search, stage: updated }, + }); + }} + className="cursor-pointer truncate" + > +
+ +
{stage}
+
+
+ ))} +
+
+
+
+
+ ); +}; diff --git a/packages/web/app/src/components/target/proposals/user-filter.tsx b/packages/web/app/src/components/target/proposals/user-filter.tsx new file mode 100644 index 0000000000..27ef2f59aa --- /dev/null +++ b/packages/web/app/src/components/target/proposals/user-filter.tsx @@ -0,0 +1,127 @@ +import { useMemo, useState } from 'react'; +import { ChevronsUpDown } from 'lucide-react'; +import { useQuery } from 'urql'; +import { Button } from '@/components/ui/button'; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, +} from '@/components/ui/command'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Checkbox } from '@/components/v2'; +import { graphql } from '@/gql'; +import { useRouter, useSearch } from '@tanstack/react-router'; + +const UsersSearchQuery = graphql(` + query UsersSearch($organizationSlug: String!, $after: String, $first: Int) { + organization(reference: { bySelector: { organizationSlug: $organizationSlug } }) { + id + viewerCanSeeMembers + members(first: $first, after: $after) { + edges { + node { + id + user { + id + displayName + fullName + } + } + } + pageInfo { + hasNextPage + startCursor + } + } + } + } +`); + +export const UserFilter = ({ + selectedUsers, + organizationSlug, +}: { + selectedUsers: string[]; + organizationSlug: string; +}) => { + const [open, setOpen] = useState(false); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [pages, setPages] = useState([{ after: null, first: 200 }]); + const hasSelection = selectedUsers.length !== 0; + const router = useRouter(); + const [query] = useQuery({ + query: UsersSearchQuery, + variables: { + after: pages[pages.length - 1]?.after, + first: pages[pages.length - 1]?.first, + organizationSlug, + }, + }); + const search = useSearch({ strict: false }); + const users = query.data?.organization?.members.edges.map(e => e.node.user) ?? []; + // @todo handle preloading selected users to populate on refresh.... And only search on open. + const selectedUserNames = useMemo(() => { + return selectedUsers.map(selectedUserId => { + const match = users.find(user => user.id === selectedUserId); + return match?.displayName ?? match?.fullName ?? 'Unknown'; + }); + }, [users]); + + return ( + + + + + + + + No results. + + + {users?.map(user => ( + { + const selectedUserId = selectedUser.split(' ')[0]; + let updated: string[] | undefined = [...selectedUsers]; + const selectionIdx = updated.findIndex(u => u === selectedUserId); + if (selectionIdx >= 0) { + updated.splice(selectionIdx, 1); + if (updated.length === 0) { + updated = undefined; + } + } else { + updated.push(selectedUserId); + } + void router.navigate({ + search: { ...search, user: updated }, + }); + }} + className="cursor-pointer truncate" + > +
+ + {user.displayName ?? user.fullName} +
+
+ ))} +
+
+
+
+
+ ); +}; diff --git a/packages/web/app/src/components/v2/checkbox.tsx b/packages/web/app/src/components/v2/checkbox.tsx index 5e67e9218f..2934872e71 100644 --- a/packages/web/app/src/components/v2/checkbox.tsx +++ b/packages/web/app/src/components/v2/checkbox.tsx @@ -1,12 +1,16 @@ import { ReactElement } from 'react'; +import { cn } from '@/lib/utils'; import { CheckboxProps, Indicator, Root } from '@radix-ui/react-checkbox'; import { CheckIcon } from '@radix-ui/react-icons'; export const Checkbox = (props: CheckboxProps): ReactElement => { return ( diff --git a/packages/web/app/src/index.css b/packages/web/app/src/index.css index 48fdc54309..832aa95c37 100644 --- a/packages/web/app/src/index.css +++ b/packages/web/app/src/index.css @@ -203,3 +203,13 @@ .hive-badge-is-changed:after { @apply absolute right-2 size-1.5 rounded-full border border-orange-600 bg-orange-400 content-['']; } + +@layer utilities { + .no-scrollbar::-webkit-scrollbar { + display: none; + } + .no-scrollbar { + -ms-overflow-style: none; + scrollbar-width: none; + } +} diff --git a/packages/web/app/src/pages/target-proposals.tsx b/packages/web/app/src/pages/target-proposals.tsx new file mode 100644 index 0000000000..c2ddb3bde8 --- /dev/null +++ b/packages/web/app/src/pages/target-proposals.tsx @@ -0,0 +1,322 @@ +import { useEffect, useState } from 'react'; +import { useQuery } from 'urql'; +import { + ListNavigationProvider, + ListNavigationTrigger, + ListNavigationWrapper, + useListNavCollapsedToggle, + useListNavigationContext, +} from '@/components/common/ListNavigation'; +import { Page, TargetLayout } from '@/components/layouts/target'; +import { StageFilter } from '@/components/target/proposals/stage-filter'; +import { UserFilter } from '@/components/target/proposals/user-filter'; +import { BadgeRounded } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Link } from '@/components/ui/link'; +import { Meta } from '@/components/ui/meta'; +import { Subtitle, Title } from '@/components/ui/page'; +import { Spinner } from '@/components/ui/spinner'; +import { TimeAgo } from '@/components/v2'; +import { graphql } from '@/gql'; +import { SchemaProposalStage } from '@/gql/graphql'; +import { cn } from '@/lib/utils'; +import { ChatBubbleIcon, PinLeftIcon, PinRightIcon } from '@radix-ui/react-icons'; +import { Outlet, Link as RouterLink, useRouter, useSearch } from '@tanstack/react-router'; + +export function TargetProposalsPage(props: { + organizationSlug: string; + projectSlug: string; + targetSlug: string; + filterUserIds?: string[]; + filterStages?: string[]; + selectedProposalId?: string; +}) { + return ( + <> + + + + + + ); +} + +const ProposalsQuery = graphql(` + query listProposals($input: SchemaProposalsInput) { + schemaProposals(input: $input) { + edges { + node { + id + title + stage + updatedAt + diffSchema { + id + } + user { + id + displayName + fullName + } + commentsCount + } + cursor + } + pageInfo { + endCursor + hasNextPage + } + } + } +`); + +const ProposalsContent = (props: Parameters[0]) => { + const isFullScreen = !props.selectedProposalId; + + return ( + +
+
+ + Proposals + + Collaborate on schema changes to reduce friction during development. +
+
+ +
+
+ {!isFullScreen && ( +
+ + + +
+ )} + } content={} /> +
+ ); +}; + +function HideMenuButton() { + return ( + + + + ); +} + +function ExpandMenuButton(props: { className?: string }) { + const { isListNavHidden } = useListNavigationContext(); + const [collapsed, toggle] = useListNavCollapsedToggle(); + + return isListNavHidden ? null : ( + + ); +} + +function ProposalsListv2(props: Parameters[0]) { + const [pageVariables, setPageVariables] = useState([{ first: 20, after: null as string | null }]); + const router = useRouter(); + const reset = () => { + void router.navigate({ + search: { stage: undefined, user: undefined }, + }); + }; + const hasFilterSelection = !!(props.filterStages?.length || props.filterUserIds?.length); + + const hasHasProposalSelected = !!props.selectedProposalId; + const { setIsListNavCollapsed, isListNavCollapsed, setIsListNavHidden } = + useListNavigationContext(); + useEffect(() => { + if (props.selectedProposalId) { + setIsListNavCollapsed(true); + } else { + setIsListNavCollapsed(false); + setIsListNavHidden(false); + } + }, [props.selectedProposalId]); + + const isFiltersHorizontalUI = !hasHasProposalSelected || !isListNavCollapsed; + + return ( + <> +
+ + + {hasFilterSelection ? ( + + ) : null} +
+ +
+ {pageVariables.map(({ after }, i) => ( + { + setPageVariables([...pageVariables, { after, first: 10 }]); + }} + /> + ))} +
+ + ); +} + +/** + * This renders a single page of proposals for the ProposalList component. + */ +const ProposalsListPage = (props: { + organizationSlug: string; + projectSlug: string; + targetSlug: string; + filterUserIds?: string[]; + filterStages?: string[]; + selectedProposalId?: string; + isLastPage: boolean; + onLoadMore: (after: string) => void | Promise; +}) => { + const [query] = useQuery({ + query: ProposalsQuery, + variables: { + input: { + target: { + byId: props.targetSlug, + }, + stages: ( + props.filterStages ?? [ + SchemaProposalStage.Draft, + SchemaProposalStage.Open, + SchemaProposalStage.Approved, + ] + ) + .sort() + .map(s => s.toUpperCase() as SchemaProposalStage), + userIds: props.filterUserIds, + }, + }, + requestPolicy: 'cache-and-network', + }); + const pageInfo = query.data?.schemaProposals?.pageInfo; + const search = useSearch({ strict: false }); + + const { isListNavCollapsed } = useListNavigationContext(); + const isWide = !props.selectedProposalId || !isListNavCollapsed; + + return ( + <> + {query.fetching ? : null} + {query.data?.schemaProposals?.edges?.map(({ node: proposal }) => { + return ( +
+ +
+
+
+ + {proposal.title} + + + + + {proposal.stage} +
+
+
+ proposed +
+ {proposal.user ? ( +
+ by {proposal.user.displayName ?? proposal.user.fullName} +
+ ) : null} +
+
+
+ {proposal.commentsCount} + +
+
+ +
+ ); + })} + {props.isLastPage && pageInfo?.hasNextPage ? ( + + ) : null} + + ); +}; + +function stageToColor(stage: SchemaProposalStage | string) { + switch (stage) { + case SchemaProposalStage.Closed: + return 'red' as const; + case SchemaProposalStage.Draft: + return 'gray' as const; + case SchemaProposalStage.Open: + return 'orange' as const; + default: + return 'green' as const; + } +} diff --git a/packages/web/app/src/router.tsx b/packages/web/app/src/router.tsx index cd018fb864..c80cb656de 100644 --- a/packages/web/app/src/router.tsx +++ b/packages/web/app/src/router.tsx @@ -20,12 +20,14 @@ import { Navigate, Outlet, useNavigate, + useParams, } from '@tanstack/react-router'; import { ErrorComponent } from './components/error'; import { NotFound } from './components/not-found'; import 'react-toastify/dist/ReactToastify.css'; import { zodValidator } from '@tanstack/zod-adapter'; import { authenticated } from './components/authenticated-container'; +import { SchemaProposalStage } from './gql/graphql'; import { AuthPage } from './pages/auth'; import { AuthCallbackPage } from './pages/auth-callback'; import { AuthOIDCPage } from './pages/auth-oidc'; @@ -72,6 +74,7 @@ import { TargetInsightsClientPage } from './pages/target-insights-client'; import { TargetInsightsCoordinatePage } from './pages/target-insights-coordinate'; import { TargetInsightsOperationPage } from './pages/target-insights-operation'; import { TargetLaboratoryPage } from './pages/target-laboratory'; +import { TargetProposalsPage } from './pages/target-proposals'; import { TargetSettingsPage, TargetSettingsPageEnum } from './pages/target-settings'; SuperTokens.init(frontendConfig()); @@ -820,6 +823,52 @@ const targetChecksSingleRoute = createRoute({ }, }); +const targetProposalsRoute = createRoute({ + getParentRoute: () => targetRoute, + path: 'proposals', + validateSearch: z.object({ + stage: z + .enum(Object.values(SchemaProposalStage).map(s => s.toLowerCase()) as [string, ...string[]]) + .array() + .optional() + .catch(() => void 0), + user: z.string().array().optional(), + }), + component: function TargetProposalsRoute() { + const { organizationSlug, projectSlug, targetSlug } = targetProposalsRoute.useParams(); + // select proposalId from child route + const proposalId = useParams({ + strict: false, + select: p => p.proposalId, + }); + const { stage, user } = targetProposalsRoute.useSearch(); + return ( + + ); + }, +}); + +const targetProposalRoute = createRoute({ + getParentRoute: () => targetProposalsRoute, + path: '$proposalId', + component: function TargetProposalRoute() { + const { proposalId } = targetProposalRoute.useParams(); + const TargetProposalPage = (props: { proposalId: string }) => ( +
+ @TODO Render {props.proposalId} +
+ ); + return ; + }, +}); + const routeTree = root.addChildren([ notFoundRoute, anonymousRoute.addChildren([ @@ -875,6 +924,7 @@ const routeTree = root.addChildren([ targetChecksRoute.addChildren([targetChecksSingleRoute]), targetAppVersionRoute, targetAppsRoute, + targetProposalsRoute.addChildren([targetProposalRoute]), ]), ]), ]); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 40ee06ef10..c74af3dddf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24259,8 +24259,8 @@ snapshots: '@typescript-eslint/parser': 7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3) eslint: 8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0) eslint-config-prettier: 9.1.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)) - eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)) - eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)) + eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-plugin-import@2.29.1)(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)) + eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)) eslint-plugin-jsonc: 2.11.1(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)) eslint-plugin-jsx-a11y: 6.8.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)) eslint-plugin-mdx: 3.0.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)) @@ -26919,13 +26919,13 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)): + eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-plugin-import@2.29.1)(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)): dependencies: debug: 4.4.0(supports-color@8.1.1) enhanced-resolve: 5.17.1 eslint: 8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0) - eslint-module-utils: 2.8.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)) - eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)) + eslint-module-utils: 2.8.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)) + eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)) fast-glob: 3.3.2 get-tsconfig: 4.7.5 is-core-module: 2.13.1 @@ -26956,14 +26956,14 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.8.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)): + eslint-module-utils@2.8.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)): dependencies: debug: 3.2.7(supports-color@8.1.1) optionalDependencies: '@typescript-eslint/parser': 7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3) eslint: 8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)) + eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-plugin-import@2.29.1)(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)) transitivePeerDependencies: - supports-color @@ -26979,7 +26979,7 @@ snapshots: eslint: 8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0) eslint-compat-utils: 0.1.2(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)) - eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)): + eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)): dependencies: array-includes: 3.1.7 array.prototype.findlastindex: 1.2.3 @@ -26989,7 +26989,7 @@ snapshots: doctrine: 2.1.0 eslint: 8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.8.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)) + eslint-module-utils: 2.8.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)) hasown: 2.0.0 is-core-module: 2.13.1 is-glob: 4.0.3 From 1857d6132ab5065233f4cca8590bf6585ad0b097 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Fri, 20 Jun 2025 12:44:48 -0700 Subject: [PATCH 10/54] WIP --- .../2025.05.29T00.00.00.schema-proposals.ts | 2 +- .../src/modules/proposals/module.graphql.ts | 9 +- .../resolvers/Query/schemaProposal.ts | 28 ++ packages/services/storage/src/db/types.ts | 1 - .../web/app/src/components/proposal/print.ts | 347 ++++++++++++++++++ .../src/components/proposal/proposal-sdl.tsx | 58 +++ .../web/app/src/components/proposal/util.ts | 22 ++ packages/web/app/src/components/v2/tag.tsx | 1 + .../app/src/pages/target-proposal-edit.tsx | 9 + .../app/src/pages/target-proposal-history.tsx | 9 + .../app/src/pages/target-proposal-layout.tsx | 84 +++++ .../src/pages/target-proposal-overview.tsx | 73 ++++ .../web/app/src/pages/target-proposals.tsx | 21 +- packages/web/app/src/router.tsx | 28 +- pnpm-lock.yaml | 261 +++++++------ 15 files changed, 811 insertions(+), 142 deletions(-) create mode 100644 packages/web/app/src/components/proposal/print.ts create mode 100644 packages/web/app/src/components/proposal/proposal-sdl.tsx create mode 100644 packages/web/app/src/components/proposal/util.ts create mode 100644 packages/web/app/src/pages/target-proposal-edit.tsx create mode 100644 packages/web/app/src/pages/target-proposal-history.tsx create mode 100644 packages/web/app/src/pages/target-proposal-layout.tsx create mode 100644 packages/web/app/src/pages/target-proposal-overview.tsx diff --git a/packages/migrations/src/actions/2025.05.29T00.00.00.schema-proposals.ts b/packages/migrations/src/actions/2025.05.29T00.00.00.schema-proposals.ts index 36b5b1bbaf..d9d52ab4a3 100644 --- a/packages/migrations/src/actions/2025.05.29T00.00.00.schema-proposals.ts +++ b/packages/migrations/src/actions/2025.05.29T00.00.00.schema-proposals.ts @@ -28,7 +28,7 @@ export default { , target_id UUID NOT NULL REFERENCES targets (id) ON DELETE CASCADE -- ID for the user that opened the proposal , user_id UUID REFERENCES users (id) ON DELETE SET NULL - -- schema version that is used to calculate the diff. In case the version is deleted, + -- The original schema version that this proposal referenced. In case the version is deleted, -- set this to null to avoid completely erasing the change... This should never happen. , diff_schema_version_id UUID NOT NULL REFERENCES schema_versions (id) ON DELETE SET NULL ) diff --git a/packages/services/api/src/modules/proposals/module.graphql.ts b/packages/services/api/src/modules/proposals/module.graphql.ts index b7b782410f..a2891238d6 100644 --- a/packages/services/api/src/modules/proposals/module.graphql.ts +++ b/packages/services/api/src/modules/proposals/module.graphql.ts @@ -207,14 +207,15 @@ export default gql` lineText: String """ - The specific version that this review is for. + The coordinate being referenced by this review. This is the most accurate location and should be used prior + to falling back to the lineNumber. Only if this coordinate does not exist in the comparing schema, should the line number be used. """ - schemaProposalVersion: SchemaProposalVersion + schemaCoordinate: String """ - The parent proposal that this review is for. + The specific version that this review is for. """ - schemaProposal: SchemaProposal + schemaProposalVersion: SchemaProposalVersion """ If null then this review is just a comment. Otherwise, the reviewer changed the state of the diff --git a/packages/services/api/src/modules/proposals/resolvers/Query/schemaProposal.ts b/packages/services/api/src/modules/proposals/resolvers/Query/schemaProposal.ts index bfb10977c6..ce8619f3b5 100644 --- a/packages/services/api/src/modules/proposals/resolvers/Query/schemaProposal.ts +++ b/packages/services/api/src/modules/proposals/resolvers/Query/schemaProposal.ts @@ -12,5 +12,33 @@ export const schemaProposal: NonNullable = asy stage: 'OPEN', updatedAt: Date.now(), commentsCount: 5, + title: 'This adds some stuff to the thing.', + user: { + id: 'asdffff', + displayName: 'jdolle', + fullName: 'Jeff Dolle', + email: 'jdolle+test@the-guild.dev', + }, + reviews: { + edges: [ + { + cursor: 'asdf', + node: { + id: '1', + comments: [], + createdAt: Date.now(), + lineText: 'type User {', + lineNumber: 2, + stageTransition: 'OPEN', + }, + }, + ], + pageInfo: { + startCursor: 'asdf', + endCursor: 'wxyz', + hasNextPage: false, + hasPreviousPage: false, + }, + }, }; }; diff --git a/packages/services/storage/src/db/types.ts b/packages/services/storage/src/db/types.ts index a7d6110f8e..1920119761 100644 --- a/packages/services/storage/src/db/types.ts +++ b/packages/services/storage/src/db/types.ts @@ -334,7 +334,6 @@ export interface schema_proposal_reviews { line_text: string | null; original_line_num: number | null; original_schema_proposal_version_id: string; - schema_coordinate: string | null; schema_proposal_id: string; stage_transition: schema_proposal_stage; user_id: string | null; diff --git a/packages/web/app/src/components/proposal/print.ts b/packages/web/app/src/components/proposal/print.ts new file mode 100644 index 0000000000..c5340cbdf3 --- /dev/null +++ b/packages/web/app/src/components/proposal/print.ts @@ -0,0 +1,347 @@ +import { type ASTNode, visit, ASTVisitor } from 'graphql'; +import type { ASTReducer } from 'graphql/language/visitor'; +import { printBlockString } from 'graphql/language/blockString'; +import { printString } from 'graphql/language/printString'; +// import type { ASTReducer } from './visitor'; + +type Maybe = null | undefined | T; + +/** + * Converts an AST into a string, using one set of reasonable + * formatting rules. + */ +export function print(ast: ASTNode): string { + return visit(ast, printDocASTReducer); +} + +const MAX_LINE_LENGTH = 80; + +const printDocASTReducer: ASTReducer = { + Name: { leave: (node) => node.value }, + Variable: { leave: (node) => '$' + node.name }, + + // Document + + Document: { + leave: (node) => join(node.definitions, '\n\n'), + }, + + OperationDefinition: { + leave(node) { + const varDefs = wrap('(', join(node.variableDefinitions, ', '), ')'); + const prefix = join( + [ + node.operation, + join([node.name, varDefs]), + join(node.directives, ' '), + ], + ' ', + ); + + // Anonymous queries with no directives or variable definitions can use + // the query short form. + return (prefix === 'query' ? '' : prefix + ' ') + node.selectionSet; + }, + }, + + VariableDefinition: { + leave: ({ variable, type, defaultValue, directives }) => + variable + + ': ' + + type + + wrap(' = ', defaultValue) + + wrap(' ', join(directives, ' ')), + }, + SelectionSet: { leave: ({ selections }) => block(selections) }, + + Field: { + leave({ alias, name, arguments: args, directives, selectionSet }) { + const prefix = wrap('', alias, ': ') + name; + let argsLine = prefix + wrap('(', join(args, ', '), ')'); + + if (argsLine.length > MAX_LINE_LENGTH) { + argsLine = prefix + wrap('(\n', indent(join(args, '\n')), '\n)'); + } + + return join([argsLine, join(directives, ' '), selectionSet], ' '); + }, + }, + + Argument: { leave: ({ name, value }) => name + ': ' + value }, + + // Fragments + + FragmentSpread: { + leave: ({ name, directives }) => + '...' + name + wrap(' ', join(directives, ' ')), + }, + + InlineFragment: { + leave: ({ typeCondition, directives, selectionSet }) => + join( + [ + '...', + wrap('on ', typeCondition), + join(directives, ' '), + selectionSet, + ], + ' ', + ), + }, + + FragmentDefinition: { + leave: ({ + name, + typeCondition, + variableDefinitions, + directives, + selectionSet, + }) => + // Note: fragment variable definitions are experimental and may be changed + // or removed in the future. + `fragment ${name}${wrap('(', join(variableDefinitions, ', '), ')')} ` + + `on ${typeCondition} ${wrap('', join(directives, ' '), ' ')}` + + selectionSet, + }, + + // Value + + IntValue: { leave: ({ value }) => value }, + FloatValue: { leave: ({ value }) => value }, + StringValue: { + leave: ({ value, block: isBlockString }) => + isBlockString ? printBlockString(value) : printString(value), + }, + BooleanValue: { leave: ({ value }) => (value ? 'true' : 'false') }, + NullValue: { leave: () => 'null' }, + EnumValue: { leave: ({ value }) => value }, + ListValue: { leave: ({ values }) => '[' + join(values, ', ') + ']' }, + ObjectValue: { leave: ({ fields }) => '{' + join(fields, ', ') + '}' }, + ObjectField: { leave: ({ name, value }) => name + ': ' + value }, + + // Directive + + Directive: { + leave: ({ name, arguments: args }) => + '@' + name + wrap('(', join(args, ', '), ')'), + }, + + // Type + + NamedType: { leave: ({ name }) => name }, + ListType: { leave: ({ type }) => '[' + type + ']' }, + NonNullType: { leave: ({ type }) => type + '!' }, + + // Type System Definitions + + SchemaDefinition: { + leave: ({ description, directives, operationTypes }) => + wrap('', description, '\n') + + join(['schema', join(directives, ' '), block(operationTypes)], ' '), + }, + + OperationTypeDefinition: { + leave: ({ operation, type }) => operation + ': ' + type, + }, + + ScalarTypeDefinition: { + leave: ({ description, name, directives }) => + wrap('', description, '\n') + + join(['scalar', name, join(directives, ' ')], ' '), + }, + + ObjectTypeDefinition: { + leave: ({ description, name, interfaces, directives, fields }) => + wrap('', description, '\n') + + join( + [ + 'type', + name, + wrap('implements ', join(interfaces, ' & ')), + join(directives, ' '), + block(fields), + ], + ' ', + ), + }, + + FieldDefinition: { + leave: ({ description, name, arguments: args, type, directives }) => + wrap('', description, '\n') + + name + + (hasMultilineItems(args) + ? wrap('(\n', indent(join(args, '\n')), '\n)') + : wrap('(', join(args, ', '), ')')) + + ': ' + + type + + wrap(' ', join(directives, ' ')), + }, + + InputValueDefinition: { + leave: ({ description, name, type, defaultValue, directives }) => + wrap('', description, '\n') + + join( + [name + ': ' + type, wrap('= ', defaultValue), join(directives, ' ')], + ' ', + ), + }, + + InterfaceTypeDefinition: { + leave: ({ description, name, interfaces, directives, fields }) => + wrap('', description, '\n') + + join( + [ + 'interface', + name, + wrap('implements ', join(interfaces, ' & ')), + join(directives, ' '), + block(fields), + ], + ' ', + ), + }, + + UnionTypeDefinition: { + leave: ({ description, name, directives, types }) => + wrap('', description, '\n') + + join( + ['union', name, join(directives, ' '), wrap('= ', join(types, ' | '))], + ' ', + ), + }, + + EnumTypeDefinition: { + leave: ({ description, name, directives, values }) => + wrap('', description, '\n') + + join(['enum', name, join(directives, ' '), block(values)], ' '), + }, + + EnumValueDefinition: { + leave: ({ description, name, directives }) => + wrap('', description, '\n') + join([name, join(directives, ' ')], ' '), + }, + + InputObjectTypeDefinition: { + leave: ({ description, name, directives, fields }) => + wrap('', description, '\n') + + join(['input', name, join(directives, ' '), block(fields)], ' '), + }, + + DirectiveDefinition: { + leave: ({ description, name, arguments: args, repeatable, locations }) => + wrap('', description, '\n') + + 'directive @' + + name + + (hasMultilineItems(args) + ? wrap('(\n', indent(join(args, '\n')), '\n)') + : wrap('(', join(args, ', '), ')')) + + (repeatable ? ' repeatable' : '') + + ' on ' + + join(locations, ' | '), + }, + + SchemaExtension: { + leave: ({ directives, operationTypes }) => + join( + ['extend schema', join(directives, ' '), block(operationTypes)], + ' ', + ), + }, + + ScalarTypeExtension: { + leave: ({ name, directives }) => + join(['extend scalar', name, join(directives, ' ')], ' '), + }, + + ObjectTypeExtension: { + leave: ({ name, interfaces, directives, fields }) => + join( + [ + 'extend type', + name, + wrap('implements ', join(interfaces, ' & ')), + join(directives, ' '), + block(fields), + ], + ' ', + ), + }, + + InterfaceTypeExtension: { + leave: ({ name, interfaces, directives, fields }) => + join( + [ + 'extend interface', + name, + wrap('implements ', join(interfaces, ' & ')), + join(directives, ' '), + block(fields), + ], + ' ', + ), + }, + + UnionTypeExtension: { + leave: ({ name, directives, types }) => + join( + [ + 'extend union', + name, + join(directives, ' '), + wrap('= ', join(types, ' | ')), + ], + ' ', + ), + }, + + EnumTypeExtension: { + leave: ({ name, directives, values }) => + join(['extend enum', name, join(directives, ' '), block(values)], ' '), + }, + + InputObjectTypeExtension: { + leave: ({ name, directives, fields }) => + join(['extend input', name, join(directives, ' '), block(fields)], ' '), + }, +}; + +/** + * Given maybeArray, print an empty string if it is null or empty, otherwise + * print all items together separated by separator if provided + */ +function join( + maybeArray: Maybe>, + separator = '', +): string { + return maybeArray?.filter((x) => x).join(separator) ?? ''; +} + +/** + * Given array, print each item on its own line, wrapped in an indented `{ }` block. + */ +function block(array: Maybe>): string { + return wrap('{\n', indent(join(array, '\n')), '\n}'); +} + +/** + * If maybeString is not null or empty, then wrap with start and end, otherwise print an empty string. + */ +function wrap( + start: string, + maybeString: Maybe, + end: string = '', +): string { + return maybeString != null && maybeString !== '' + ? start + maybeString + end + : ''; +} + +function indent(str: string): string { + return wrap(' ', str.replace(/\n/g, '\n ')); +} + +function hasMultilineItems(maybeArray: Maybe>): boolean { + // FIXME: https://github.com/graphql/graphql-js/issues/2203 + /* c8 ignore next */ + return maybeArray?.some((str: string) => str.includes('\n')) ?? false; +} \ No newline at end of file diff --git a/packages/web/app/src/components/proposal/proposal-sdl.tsx b/packages/web/app/src/components/proposal/proposal-sdl.tsx new file mode 100644 index 0000000000..6ab727a7dd --- /dev/null +++ b/packages/web/app/src/components/proposal/proposal-sdl.tsx @@ -0,0 +1,58 @@ +import { FragmentType, graphql, useFragment } from '@/gql'; +import { parse, print } from 'graphql'; + +const ProposalOverview_ReviewsFragment = graphql(/** GraphQL */` + fragment ProposalOverview_ReviewsFragment on SchemaProposalReviewConnection { + pageInfo { + startCursor + } + edges { + cursor + node { + id + schemaProposalVersion { + id + } + stageTransition + lineNumber + lineText + schemaCoordinate + comments { + edges { + cursor + node { + id + user { + id + email + displayName + fullName + } + body + updatedAt + } + } + pageInfo { + startCursor + } + } + schemaProposalVersion { + id + } + } + } + } +`); + +export function ProposalSDL(props: { + sdl: string; + reviews: FragmentType; +}) { + const document = parse(props.sdl); + console.log(print(document)); + const connection = useFragment(ProposalOverview_ReviewsFragment, props.reviews); + // for (const edge of connection.edges) { + // edge.node. + // } + return
; +} diff --git a/packages/web/app/src/components/proposal/util.ts b/packages/web/app/src/components/proposal/util.ts new file mode 100644 index 0000000000..68aadf2153 --- /dev/null +++ b/packages/web/app/src/components/proposal/util.ts @@ -0,0 +1,22 @@ +import { SchemaProposalStage } from '@/gql/graphql'; + +export function stageToColor(stage: SchemaProposalStage | string) { + switch (stage) { + case SchemaProposalStage.Closed: + return 'red' as const; + case SchemaProposalStage.Draft: + return 'gray' as const; + case SchemaProposalStage.Open: + return 'orange' as const; + default: + return 'green' as const; + } +} + +export function userText(user?: { + email: string; + displayName?: string | null; + fullName?: string | null; +} | null) { + return user?.displayName || user?.fullName || user?.email || 'Unknown'; +} diff --git a/packages/web/app/src/components/v2/tag.tsx b/packages/web/app/src/components/v2/tag.tsx index 4d0f724fb5..572c27d491 100644 --- a/packages/web/app/src/components/v2/tag.tsx +++ b/packages/web/app/src/components/v2/tag.tsx @@ -6,6 +6,7 @@ const colors = { yellow: 'bg-yellow-500/10 text-yellow-500', gray: 'bg-gray-500/10 text-gray-500', orange: 'bg-orange-500/10 text-orange-500', + red: 'bg-red-500/10 text-red-500', } as const; export function Tag({ diff --git a/packages/web/app/src/pages/target-proposal-edit.tsx b/packages/web/app/src/pages/target-proposal-edit.tsx new file mode 100644 index 0000000000..031dfce94b --- /dev/null +++ b/packages/web/app/src/pages/target-proposal-edit.tsx @@ -0,0 +1,9 @@ +export function TargetProposalEditPage(props: { + organizationSlug: string; + projectSlug: string; + targetSlug: string; + proposalId: string; + page: string; +}) { + return
Edit
; +} diff --git a/packages/web/app/src/pages/target-proposal-history.tsx b/packages/web/app/src/pages/target-proposal-history.tsx new file mode 100644 index 0000000000..bc7abc424c --- /dev/null +++ b/packages/web/app/src/pages/target-proposal-history.tsx @@ -0,0 +1,9 @@ +export function TargetProposalHistoryPage(props: { + organizationSlug: string; + projectSlug: string; + targetSlug: string; + proposalId: string; + page: string; +}) { + return
History
; +} diff --git a/packages/web/app/src/pages/target-proposal-layout.tsx b/packages/web/app/src/pages/target-proposal-layout.tsx new file mode 100644 index 0000000000..79ae4152ab --- /dev/null +++ b/packages/web/app/src/pages/target-proposal-layout.tsx @@ -0,0 +1,84 @@ +import { Link } from '@tanstack/react-router'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { TargetProposalEditPage } from './target-proposal-edit'; +import { TargetProposalHistoryPage } from './target-proposal-history'; +import { TargetProposalOverviewPage } from './target-proposal-overview'; + +enum Page { + OVERVIEW = 'overview', + HISTORY = 'history', + EDIT = 'edit', +} + +export function TargetProposalLayoutPage(props: { + organizationSlug: string; + projectSlug: string; + targetSlug: string; + proposalId: string; + page: string; +}) { + return ( +
+ + + + + Overview + + + + + History + + + + + Edit + + + + +
+ +
+
+ +
+ +
+
+ + + +
+
+ ); +} + +export const ProposalPage = Page; diff --git a/packages/web/app/src/pages/target-proposal-overview.tsx b/packages/web/app/src/pages/target-proposal-overview.tsx new file mode 100644 index 0000000000..63e8c9c856 --- /dev/null +++ b/packages/web/app/src/pages/target-proposal-overview.tsx @@ -0,0 +1,73 @@ +import { useQuery } from 'urql'; +import { Spinner } from '@/components/ui/spinner'; +import { graphql } from '@/gql'; +import { Tag, TimeAgo } from '@/components/v2'; +import { stageToColor, userText } from '@/components/proposal/util'; +import { Subtitle, Title } from '@/components/ui/page'; +import { ProposalSDL } from '@/components/proposal/proposal-sdl'; + +const ProposalOverviewQuery = graphql(/** GraphQL */ ` + query ProposalOverviewQuery($id: ID!) { + schemaProposal(input: { id: $id }) { + id + createdAt + updatedAt + commentsCount + stage + title + versions(input: { onlyLatest: true }) { + edges { + node { + id + schemaSDL + serviceName + } + } + } + user { + id + email + displayName + fullName + } + reviews { + ...ProposalOverview_ReviewsFragment + } + } + } +`); + +export function TargetProposalOverviewPage(props: { + organizationSlug: string; + projectSlug: string; + targetSlug: string; + proposalId: string; + page: string; +}) { + const [query] = useQuery({ + query: ProposalOverviewQuery, + variables: { + id: props.proposalId, + }, + requestPolicy: 'cache-and-network', + }); + + const proposal = query.data?.schemaProposal; + + return ( +
+ {query.fetching && } + {proposal && ( + <> + {userText(proposal.user)} proposed +
+ {proposal.title} + {proposal.stage} +
+
Last updated
+ + + )} +
+ ); +} diff --git a/packages/web/app/src/pages/target-proposals.tsx b/packages/web/app/src/pages/target-proposals.tsx index c2ddb3bde8..2fd7f245d9 100644 --- a/packages/web/app/src/pages/target-proposals.tsx +++ b/packages/web/app/src/pages/target-proposals.tsx @@ -21,7 +21,8 @@ import { graphql } from '@/gql'; import { SchemaProposalStage } from '@/gql/graphql'; import { cn } from '@/lib/utils'; import { ChatBubbleIcon, PinLeftIcon, PinRightIcon } from '@radix-ui/react-icons'; -import { Outlet, Link as RouterLink, useRouter, useSearch } from '@tanstack/react-router'; +import { Outlet, useRouter, useSearch } from '@tanstack/react-router'; +import { stageToColor } from '@/components/proposal/util'; export function TargetProposalsPage(props: { organizationSlug: string; @@ -83,8 +84,7 @@ const ProposalsContent = (props: Parameters[0]) => {
- [0]) => { }} > Proposals - + Collaborate on schema changes to reduce friction during development.
@@ -307,16 +307,3 @@ const ProposalsListPage = (props: { ); }; - -function stageToColor(stage: SchemaProposalStage | string) { - switch (stage) { - case SchemaProposalStage.Closed: - return 'red' as const; - case SchemaProposalStage.Draft: - return 'gray' as const; - case SchemaProposalStage.Open: - return 'orange' as const; - default: - return 'green' as const; - } -} diff --git a/packages/web/app/src/router.tsx b/packages/web/app/src/router.tsx index c80cb656de..9e67575591 100644 --- a/packages/web/app/src/router.tsx +++ b/packages/web/app/src/router.tsx @@ -74,6 +74,11 @@ import { TargetInsightsClientPage } from './pages/target-insights-client'; import { TargetInsightsCoordinatePage } from './pages/target-insights-coordinate'; import { TargetInsightsOperationPage } from './pages/target-insights-operation'; import { TargetLaboratoryPage } from './pages/target-laboratory'; +import { + ProposalPage, + TargetProposalLayoutPage, + TargetProposalsViewPage, +} from './pages/target-proposal-layout'; import { TargetProposalsPage } from './pages/target-proposals'; import { TargetSettingsPage, TargetSettingsPageEnum } from './pages/target-settings'; @@ -858,14 +863,25 @@ const targetProposalsRoute = createRoute({ const targetProposalRoute = createRoute({ getParentRoute: () => targetProposalsRoute, path: '$proposalId', + validateSearch: z.object({ + page: z + .enum(Object.values(ProposalPage).map(s => s.toLowerCase()) as [string, ...string[]]) + .optional() + .catch(() => void 0), + }), component: function TargetProposalRoute() { - const { proposalId } = targetProposalRoute.useParams(); - const TargetProposalPage = (props: { proposalId: string }) => ( -
- @TODO Render {props.proposalId} -
+ const { organizationSlug, projectSlug, targetSlug, proposalId } = + targetProposalRoute.useParams(); + const { page } = targetProposalRoute.useSearch(); + return ( + ); - return ; }, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c74af3dddf..9649f210e6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -213,10 +213,10 @@ importers: version: 5.7.3 vite-tsconfig-paths: specifier: 5.1.4 - version: 5.1.4(typescript@5.7.3)(vite@6.3.4(@types/node@22.10.5)(jiti@2.3.3)(less@4.2.0)(lightningcss@1.28.2)(terser@5.37.0)(tsx@4.19.2)(yaml@2.5.0)) + version: 5.1.4(typescript@5.7.3)(vite@6.3.4(@types/node@22.10.5)(jiti@2.3.3)(less@4.2.0)(lightningcss@1.30.1)(terser@5.37.0)(tsx@4.19.2)(yaml@2.5.0)) vitest: specifier: 3.1.1 - version: 3.1.1(@types/node@22.10.5)(jiti@2.3.3)(less@4.2.0)(lightningcss@1.28.2)(terser@5.37.0)(tsx@4.19.2)(yaml@2.5.0) + version: 3.1.1(@types/node@22.10.5)(jiti@2.3.3)(less@4.2.0)(lightningcss@1.30.1)(terser@5.37.0)(tsx@4.19.2)(yaml@2.5.0) deployment: dependencies: @@ -373,7 +373,7 @@ importers: version: 2.8.1 vitest: specifier: 3.1.1 - version: 3.1.1(@types/node@22.10.5)(jiti@2.3.3)(less@4.2.0)(lightningcss@1.28.2)(terser@5.37.0)(tsx@4.19.2)(yaml@2.5.0) + version: 3.1.1(@types/node@22.10.5)(jiti@2.3.3)(less@4.2.0)(lightningcss@1.30.1)(terser@5.37.0)(tsx@4.19.2)(yaml@2.5.0) zod: specifier: 3.24.1 version: 3.24.1 @@ -407,7 +407,7 @@ importers: version: 14.0.0 vitest: specifier: 3.1.1 - version: 3.1.1(@types/node@22.10.5)(jiti@2.3.3)(less@4.2.0)(lightningcss@1.28.2)(terser@5.37.0)(tsx@4.19.2)(yaml@2.5.0) + version: 3.1.1(@types/node@22.10.5)(jiti@2.3.3)(less@4.2.0)(lightningcss@1.30.1)(terser@5.37.0)(tsx@4.19.2)(yaml@2.5.0) ws: specifier: '>=8.18.0 || >=7.5.10 || >=6.2.3 || >=5.2.4' version: 8.18.0 @@ -530,7 +530,7 @@ importers: version: 2.8.1 vitest: specifier: 3.1.1 - version: 3.1.1(@types/node@22.10.5)(jiti@2.3.3)(less@4.2.0)(lightningcss@1.28.2)(terser@5.37.0)(tsx@4.19.2)(yaml@2.5.0) + version: 3.1.1(@types/node@22.10.5)(jiti@2.3.3)(less@4.2.0)(lightningcss@1.30.1)(terser@5.37.0)(tsx@4.19.2)(yaml@2.5.0) publishDirectory: dist packages/libraries/envelop: @@ -606,7 +606,7 @@ importers: version: 14.0.0 vitest: specifier: 3.1.1 - version: 3.1.1(@types/node@22.10.5)(jiti@2.3.3)(less@4.2.0)(lightningcss@1.28.2)(terser@5.37.0)(tsx@4.19.2)(yaml@2.5.0) + version: 3.1.1(@types/node@22.10.5)(jiti@2.3.3)(less@4.2.0)(lightningcss@1.30.1)(terser@5.37.0)(tsx@4.19.2)(yaml@2.5.0) ws: specifier: '>=8.18.0 || >=7.5.10 || >=6.2.3 || >=5.2.4' version: 8.18.0 @@ -863,7 +863,7 @@ importers: version: 6.21.2 vitest: specifier: 3.1.1 - version: 3.1.1(@types/node@22.10.5)(jiti@2.3.3)(less@4.2.0)(lightningcss@1.28.2)(terser@5.37.0)(tsx@4.19.2)(yaml@2.5.0) + version: 3.1.1(@types/node@22.10.5)(jiti@2.3.3)(less@4.2.0)(lightningcss@1.30.1)(terser@5.37.0)(tsx@4.19.2)(yaml@2.5.0) zod: specifier: 3.24.1 version: 3.24.1 @@ -896,7 +896,7 @@ importers: version: 6.21.2 vitest: specifier: 3.1.1 - version: 3.1.1(@types/node@22.10.5)(jiti@2.3.3)(less@4.2.0)(lightningcss@1.28.2)(terser@5.37.0)(tsx@4.19.2)(yaml@2.5.0) + version: 3.1.1(@types/node@22.10.5)(jiti@2.3.3)(less@4.2.0)(lightningcss@1.30.1)(terser@5.37.0)(tsx@4.19.2)(yaml@2.5.0) workers-loki-logger: specifier: 0.1.15 version: 0.1.15 @@ -1669,7 +1669,7 @@ importers: version: 7.0.4 '@fastify/vite': specifier: 6.0.7 - version: 6.0.7(patch_hash=f5ce073a4db250ed3db1d9c19e2a253418454ee379530bdee25869570d7b500b)(@types/node@22.10.5)(less@4.2.0)(lightningcss@1.28.2)(terser@5.37.0) + version: 6.0.7(patch_hash=f5ce073a4db250ed3db1d9c19e2a253418454ee379530bdee25869570d7b500b)(@types/node@22.10.5)(less@4.2.0)(lightningcss@1.30.1)(terser@5.37.0) '@graphiql/plugin-explorer': specifier: 4.0.0-alpha.2 version: 4.0.0-alpha.2(@graphiql/react@1.0.0-alpha.4(patch_hash=1018befc9149cbc43bc2bf8982d52090a580e68df34b46674234f4e58eb6d0a0)(@codemirror/language@6.10.2)(@types/node@22.10.5)(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(graphql-ws@5.16.1(graphql@16.9.0))(graphql@16.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(graphql@16.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -1798,7 +1798,7 @@ importers: version: 8.4.7(@storybook/test@8.4.7(storybook@8.4.7(prettier@3.4.2)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.4.7(prettier@3.4.2))(typescript@5.7.3) '@storybook/react-vite': specifier: 8.4.7 - version: 8.4.7(@storybook/test@8.4.7(storybook@8.4.7(prettier@3.4.2)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.4.7(prettier@3.4.2))(typescript@5.7.3)(vite@6.3.4(@types/node@22.10.5)(jiti@2.3.3)(less@4.2.0)(lightningcss@1.28.2)(terser@5.37.0)(tsx@4.19.2)(yaml@2.5.0)) + version: 8.4.7(@storybook/test@8.4.7(storybook@8.4.7(prettier@3.4.2)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.4.7(prettier@3.4.2))(typescript@5.7.3)(vite@6.3.4(@types/node@22.10.5)(jiti@2.3.3)(less@4.2.0)(lightningcss@1.30.1)(terser@5.37.0)(tsx@4.19.2)(yaml@2.5.0)) '@stripe/react-stripe-js': specifier: 3.1.1 version: 3.1.1(@stripe/stripe-js@5.5.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -1864,7 +1864,7 @@ importers: version: 7.1.0(@urql/core@5.0.3(graphql@16.9.0))(graphql@16.9.0) '@vitejs/plugin-react': specifier: 4.3.4 - version: 4.3.4(vite@6.3.4(@types/node@22.10.5)(jiti@2.3.3)(less@4.2.0)(lightningcss@1.28.2)(terser@5.37.0)(tsx@4.19.2)(yaml@2.5.0)) + version: 4.3.4(vite@6.3.4(@types/node@22.10.5)(jiti@2.3.3)(less@4.2.0)(lightningcss@1.30.1)(terser@5.37.0)(tsx@4.19.2)(yaml@2.5.0)) autoprefixer: specifier: 10.4.20 version: 10.4.20(postcss@8.4.49) @@ -2023,10 +2023,10 @@ importers: version: 1.13.2(@types/react@18.3.18)(react@18.3.1) vite: specifier: 6.3.4 - version: 6.3.4(@types/node@22.10.5)(jiti@2.3.3)(less@4.2.0)(lightningcss@1.28.2)(terser@5.37.0)(tsx@4.19.2)(yaml@2.5.0) + version: 6.3.4(@types/node@22.10.5)(jiti@2.3.3)(less@4.2.0)(lightningcss@1.30.1)(terser@5.37.0)(tsx@4.19.2)(yaml@2.5.0) vite-tsconfig-paths: specifier: 5.1.4 - version: 5.1.4(typescript@5.7.3)(vite@6.3.4(@types/node@22.10.5)(jiti@2.3.3)(less@4.2.0)(lightningcss@1.28.2)(terser@5.37.0)(tsx@4.19.2)(yaml@2.5.0)) + version: 5.1.4(typescript@5.7.3)(vite@6.3.4(@types/node@22.10.5)(jiti@2.3.3)(less@4.2.0)(lightningcss@1.30.1)(terser@5.37.0)(tsx@4.19.2)(yaml@2.5.0)) wonka: specifier: 6.3.4 version: 6.3.4 @@ -3103,11 +3103,11 @@ packages: '@codemirror/language@6.10.2': resolution: {integrity: sha512-kgbTYTo0Au6dCSc/TFy7fK3fpJmgHDv1sG1KNQKJXVi+xBTEeBPY/M30YXiU6mMXeH+YIDLsbrT4ZwNRdtF+SA==} - '@codemirror/state@6.5.0': - resolution: {integrity: sha512-MwBHVK60IiIHDcoMet78lxt6iw5gJOGSbNbOIVBHWVXIH4/Nq1+GQgLLGgI1KlnN86WDXsPudVaqYHKBIx7Eyw==} + '@codemirror/state@6.5.2': + resolution: {integrity: sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==} - '@codemirror/view@6.36.1': - resolution: {integrity: sha512-miD1nyT4m4uopZaDdO2uXU/LLHliKNYL9kB1C1wJHrunHLm/rpkb5QVSokqgw9hFqEZakrdlb/VGWX8aYZTslQ==} + '@codemirror/view@6.37.2': + resolution: {integrity: sha512-XD3LdgQpxQs5jhOOZ2HRVT+Rj59O4Suc7g2ULvZ+Yi8eCkickrkZ5JFuoDhs2ST1mNI5zSsNYgR3NGa4OUrbnw==} '@colors/colors@1.5.0': resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} @@ -3563,6 +3563,7 @@ packages: '@fastify/vite@6.0.7': resolution: {integrity: sha512-+dRo9KUkvmbqdmBskG02SwigWl06Mwkw8SBDK1zTNH6vd4DyXbRvI7RmJEmBkLouSU81KTzy1+OzwHSffqSD6w==} + bundledDependencies: [] '@floating-ui/core@1.2.6': resolution: {integrity: sha512-EvYTiXet5XqweYGClEmpu3BoxmsQ4hkj3QaYA6qEnigCWffTP3vNRwBReTdrwDwo7OoJ3wM8Uoe9Uk4n+d4hfg==} @@ -8260,6 +8261,11 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + acorn@8.15.0: + resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} + engines: {node: '>=0.4.0'} + hasBin: true + addressparser@1.0.1: resolution: {integrity: sha512-aQX7AISOMM7HFE0iZ3+YnD07oIeJqWGVnJ+ZIKaBZAk03ftmVYVqsGas/rbXKR21n4D/hKCSHypvcyOkds/xzg==} @@ -8669,8 +8675,8 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true - browserslist@4.24.3: - resolution: {integrity: sha512-1CPmv8iobE2fyRMV97dAcMVegvvWKxmq94hkLiAkUGwKVTyDLw33K+ZxiFrREKmmps4rIw6grcCFCnTMSZ/YiA==} + browserslist@4.25.0: + resolution: {integrity: sha512-PJ8gYKeS5e/whHBh8xrwYK+dAvEj7JXtz6uTucnMRB8OiGTsKccFekoRrjajPBHV8oOY+2tI4uxeceSimKwMFA==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true @@ -8793,6 +8799,9 @@ packages: caniuse-lite@1.0.30001690: resolution: {integrity: sha512-5ExiE3qQN6oF8Clf8ifIDcMRCRE/dMGcETG/XGMD8/XiXm6HXQgQTh1yZYLXXpSOsEUlJm1Xr7kGULZTuGtP/w==} + caniuse-lite@1.0.30001723: + resolution: {integrity: sha512-1R/elMjtehrFejxwmexeXAtae5UO9iSyFn6G/I806CYC/BLyyBk1EPhrKBkWhy6wM6Xnm47dSJQec+tLJ39WHw==} + capital-case@1.0.4: resolution: {integrity: sha512-ds37W8CytHgwnhGGTi88pcPyR15qoNkOpYwmMMfnWqqWgESapLqvDx6huFjQ5vqWSn2Z06173XNA7LtMOeUh1A==} @@ -9207,6 +9216,9 @@ packages: create-require@1.1.1: resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} + crelt@1.0.6: + resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==} + cron-parser@4.9.0: resolution: {integrity: sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==} engines: {node: '>=12.0.0'} @@ -9607,6 +9619,10 @@ packages: resolution: {integrity: sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==} engines: {node: '>=8'} + detect-libc@2.0.4: + resolution: {integrity: sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==} + engines: {node: '>=8'} + detect-newline@4.0.1: resolution: {integrity: sha512-qE3Veg1YXzGHQhlA6jzebZN2qVf6NX+A7m7qlhCGG30dJixrAQhYOsJjsnBjJkCSmuOPpCk30145fr8FV0bzog==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -9755,12 +9771,12 @@ packages: engines: {node: '>=0.10.0'} hasBin: true + electron-to-chromium@1.5.170: + resolution: {integrity: sha512-GP+M7aeluQo9uAyiTCxgIj/j+PrWhMlY7LFVj8prlsPljd0Fdg9AprlfUi+OCSFWy9Y5/2D/Jrj9HS8Z4rpKWA==} + electron-to-chromium@1.5.41: resolution: {integrity: sha512-dfdv/2xNjX0P8Vzme4cfzHqnPm5xsZXwsolTYr0eyW18IUmNyG08vL+fttvinTfhKfIKdRoqkDIC9e9iWQCNYQ==} - electron-to-chromium@1.5.76: - resolution: {integrity: sha512-CjVQyG7n7Sr+eBXE86HIulnL5N8xZY1sgmOPGuq/F0Rr0FJq63lg0kEtOIDfZBk44FnDLf6FUJ+dsJcuiUDdDQ==} - emoji-regex@10.3.0: resolution: {integrity: sha512-QpLs9D9v9kArv4lfDEgg1X/gN5XLnf/A6l9cs8SPZLRZR3ZkY9+kwIQTxm+fsSej5UMYGE8fdoaZVIBlqG0XTw==} @@ -11833,68 +11849,68 @@ packages: light-my-request@5.14.0: resolution: {integrity: sha512-aORPWntbpH5esaYpGOOmri0OHDOe3wC5M2MQxZ9dvMLZm6DnaAn0kJlcbU9hwsQgLzmZyReKwFwwPkR+nHu5kA==} - lightningcss-darwin-arm64@1.28.2: - resolution: {integrity: sha512-/8cPSqZiusHSS+WQz0W4NuaqFjquys1x+NsdN/XOHb+idGHJSoJ7SoQTVl3DZuAgtPZwFZgRfb/vd1oi8uX6+g==} + lightningcss-darwin-arm64@1.30.1: + resolution: {integrity: sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [darwin] - lightningcss-darwin-x64@1.28.2: - resolution: {integrity: sha512-R7sFrXlgKjvoEG8umpVt/yutjxOL0z8KWf0bfPT3cYMOW4470xu5qSHpFdIOpRWwl3FKNMUdbKtMUjYt0h2j4g==} + lightningcss-darwin-x64@1.30.1: + resolution: {integrity: sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [darwin] - lightningcss-freebsd-x64@1.28.2: - resolution: {integrity: sha512-l2qrCT+x7crAY+lMIxtgvV10R8VurzHAoUZJaVFSlHrN8kRLTvEg9ObojIDIexqWJQvJcVVV3vfzsEynpiuvgA==} + lightningcss-freebsd-x64@1.30.1: + resolution: {integrity: sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [freebsd] - lightningcss-linux-arm-gnueabihf@1.28.2: - resolution: {integrity: sha512-DKMzpICBEKnL53X14rF7hFDu8KKALUJtcKdFUCW5YOlGSiwRSgVoRjM97wUm/E0NMPkzrTi/rxfvt7ruNK8meg==} + lightningcss-linux-arm-gnueabihf@1.30.1: + resolution: {integrity: sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q==} engines: {node: '>= 12.0.0'} cpu: [arm] os: [linux] - lightningcss-linux-arm64-gnu@1.28.2: - resolution: {integrity: sha512-nhfjYkfymWZSxdtTNMWyhFk2ImUm0X7NAgJWFwnsYPOfmtWQEapzG/DXZTfEfMjSzERNUNJoQjPAbdqgB+sjiw==} + lightningcss-linux-arm64-gnu@1.30.1: + resolution: {integrity: sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - lightningcss-linux-arm64-musl@1.28.2: - resolution: {integrity: sha512-1SPG1ZTNnphWvAv8RVOymlZ8BDtAg69Hbo7n4QxARvkFVCJAt0cgjAw1Fox0WEhf4PwnyoOBaVH0Z5YNgzt4dA==} + lightningcss-linux-arm64-musl@1.30.1: + resolution: {integrity: sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - lightningcss-linux-x64-gnu@1.28.2: - resolution: {integrity: sha512-ZhQy0FcO//INWUdo/iEdbefntTdpPVQ0XJwwtdbBuMQe+uxqZoytm9M+iqR9O5noWFaxK+nbS2iR/I80Q2Ofpg==} + lightningcss-linux-x64-gnu@1.30.1: + resolution: {integrity: sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - lightningcss-linux-x64-musl@1.28.2: - resolution: {integrity: sha512-alb/j1NMrgQmSFyzTbN1/pvMPM+gdDw7YBuQ5VSgcFDypN3Ah0BzC2dTZbzwzaMdUVDszX6zH5MzjfVN1oGuww==} + lightningcss-linux-x64-musl@1.30.1: + resolution: {integrity: sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - lightningcss-win32-arm64-msvc@1.28.2: - resolution: {integrity: sha512-WnwcjcBeAt0jGdjlgbT9ANf30pF0C/QMb1XnLnH272DQU8QXh+kmpi24R55wmWBwaTtNAETZ+m35ohyeMiNt+g==} + lightningcss-win32-arm64-msvc@1.30.1: + resolution: {integrity: sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [win32] - lightningcss-win32-x64-msvc@1.28.2: - resolution: {integrity: sha512-3piBifyT3avz22o6mDKywQC/OisH2yDK+caHWkiMsF82i3m5wDBadyCjlCQ5VNgzYkxrWZgiaxHDdd5uxsi0/A==} + lightningcss-win32-x64-msvc@1.30.1: + resolution: {integrity: sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [win32] - lightningcss@1.28.2: - resolution: {integrity: sha512-ePLRrbt3fgjXI5VFZOLbvkLD5ZRuxGKm+wJ3ujCqBtL3NanDHPo/5zicR5uEKAPiIjBYF99BM4K4okvMznjkVA==} + lightningcss@1.30.1: + resolution: {integrity: sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==} engines: {node: '>= 12.0.0'} lilconfig@3.1.3: @@ -13329,6 +13345,11 @@ packages: peerDependencies: pg: ^8 + pg-cursor@2.15.1: + resolution: {integrity: sha512-H3pT6fqIO1/u55mDGen2v6gvoaIBwVxhoJWEdF0qhQfsF7hXGW1BbJ8CwMtyoZRWZH7fASVoT3p2/4BGUoSxTg==} + peerDependencies: + pg: ^8 + pg-int8@1.0.1: resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} engines: {node: '>=4.0.0'} @@ -15513,8 +15534,8 @@ packages: peerDependencies: browserslist: '>= 4.21.0' - update-browserslist-db@1.1.1: - resolution: {integrity: sha512-R8UzCaa9Az+38REPiJ1tXlImTJXlVfgHZsglwBD/k6nj76ctsH1E3q4doGrukiLQd3sGQYu56r5+lo5r94l29A==} + update-browserslist-db@1.1.3: + resolution: {integrity: sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==} hasBin: true peerDependencies: browserslist: '>= 4.21.0' @@ -17978,20 +17999,21 @@ snapshots: '@codemirror/language@6.10.2': dependencies: - '@codemirror/state': 6.5.0 - '@codemirror/view': 6.36.1 + '@codemirror/state': 6.5.2 + '@codemirror/view': 6.37.2 '@lezer/common': 1.2.3 '@lezer/highlight': 1.2.1 '@lezer/lr': 1.4.2 style-mod: 4.1.2 - '@codemirror/state@6.5.0': + '@codemirror/state@6.5.2': dependencies: '@marijn/find-cluster-break': 1.0.2 - '@codemirror/view@6.36.1': + '@codemirror/view@6.37.2': dependencies: - '@codemirror/state': 6.5.0 + '@codemirror/state': 6.5.2 + crelt: 1.0.6 style-mod: 4.1.2 w3c-keyname: 2.2.8 @@ -18485,14 +18507,14 @@ snapshots: fastq: 1.19.1 glob: 10.3.12 - '@fastify/vite@6.0.7(patch_hash=f5ce073a4db250ed3db1d9c19e2a253418454ee379530bdee25869570d7b500b)(@types/node@22.10.5)(less@4.2.0)(lightningcss@1.28.2)(terser@5.37.0)': + '@fastify/vite@6.0.7(patch_hash=f5ce073a4db250ed3db1d9c19e2a253418454ee379530bdee25869570d7b500b)(@types/node@22.10.5)(less@4.2.0)(lightningcss@1.30.1)(terser@5.37.0)': dependencies: '@fastify/middie': 8.3.1 '@fastify/static': 6.12.0 fastify-plugin: 4.5.1 fs-extra: 10.1.0 klaw: 4.1.0 - vite: 5.4.12(@types/node@22.10.5)(less@4.2.0)(lightningcss@1.28.2)(terser@5.37.0) + vite: 5.4.12(@types/node@22.10.5)(less@4.2.0)(lightningcss@1.30.1)(terser@5.37.0) transitivePeerDependencies: - '@types/node' - less @@ -20014,11 +20036,11 @@ snapshots: '@josephg/resolvable@1.0.1': {} - '@joshwooding/vite-plugin-react-docgen-typescript@0.4.2(typescript@5.7.3)(vite@6.3.4(@types/node@22.10.5)(jiti@2.3.3)(less@4.2.0)(lightningcss@1.28.2)(terser@5.37.0)(tsx@4.19.2)(yaml@2.5.0))': + '@joshwooding/vite-plugin-react-docgen-typescript@0.4.2(typescript@5.7.3)(vite@6.3.4(@types/node@22.10.5)(jiti@2.3.3)(less@4.2.0)(lightningcss@1.30.1)(terser@5.37.0)(tsx@4.19.2)(yaml@2.5.0))': dependencies: magic-string: 0.27.0 react-docgen-typescript: 2.2.2(typescript@5.7.3) - vite: 6.3.4(@types/node@22.10.5)(jiti@2.3.3)(less@4.2.0)(lightningcss@1.28.2)(terser@5.37.0)(tsx@4.19.2)(yaml@2.5.0) + vite: 6.3.4(@types/node@22.10.5)(jiti@2.3.3)(less@4.2.0)(lightningcss@1.30.1)(terser@5.37.0)(tsx@4.19.2)(yaml@2.5.0) optionalDependencies: typescript: 5.7.3 @@ -23898,13 +23920,13 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - '@storybook/builder-vite@8.4.7(storybook@8.4.7(prettier@3.4.2))(vite@6.3.4(@types/node@22.10.5)(jiti@2.3.3)(less@4.2.0)(lightningcss@1.28.2)(terser@5.37.0)(tsx@4.19.2)(yaml@2.5.0))': + '@storybook/builder-vite@8.4.7(storybook@8.4.7(prettier@3.4.2))(vite@6.3.4(@types/node@22.10.5)(jiti@2.3.3)(less@4.2.0)(lightningcss@1.30.1)(terser@5.37.0)(tsx@4.19.2)(yaml@2.5.0))': dependencies: '@storybook/csf-plugin': 8.4.7(storybook@8.4.7(prettier@3.4.2)) browser-assert: 1.2.1 storybook: 8.4.7(prettier@3.4.2) ts-dedent: 2.2.0 - vite: 6.3.4(@types/node@22.10.5)(jiti@2.3.3)(less@4.2.0)(lightningcss@1.28.2)(terser@5.37.0)(tsx@4.19.2)(yaml@2.5.0) + vite: 6.3.4(@types/node@22.10.5)(jiti@2.3.3)(less@4.2.0)(lightningcss@1.30.1)(terser@5.37.0)(tsx@4.19.2)(yaml@2.5.0) '@storybook/components@8.4.7(storybook@8.4.7(prettier@3.4.2))': dependencies: @@ -23966,11 +23988,11 @@ snapshots: react-dom: 18.3.1(react@18.3.1) storybook: 8.4.7(prettier@3.4.2) - '@storybook/react-vite@8.4.7(@storybook/test@8.4.7(storybook@8.4.7(prettier@3.4.2)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.4.7(prettier@3.4.2))(typescript@5.7.3)(vite@6.3.4(@types/node@22.10.5)(jiti@2.3.3)(less@4.2.0)(lightningcss@1.28.2)(terser@5.37.0)(tsx@4.19.2)(yaml@2.5.0))': + '@storybook/react-vite@8.4.7(@storybook/test@8.4.7(storybook@8.4.7(prettier@3.4.2)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.4.7(prettier@3.4.2))(typescript@5.7.3)(vite@6.3.4(@types/node@22.10.5)(jiti@2.3.3)(less@4.2.0)(lightningcss@1.30.1)(terser@5.37.0)(tsx@4.19.2)(yaml@2.5.0))': dependencies: - '@joshwooding/vite-plugin-react-docgen-typescript': 0.4.2(typescript@5.7.3)(vite@6.3.4(@types/node@22.10.5)(jiti@2.3.3)(less@4.2.0)(lightningcss@1.28.2)(terser@5.37.0)(tsx@4.19.2)(yaml@2.5.0)) + '@joshwooding/vite-plugin-react-docgen-typescript': 0.4.2(typescript@5.7.3)(vite@6.3.4(@types/node@22.10.5)(jiti@2.3.3)(less@4.2.0)(lightningcss@1.30.1)(terser@5.37.0)(tsx@4.19.2)(yaml@2.5.0)) '@rollup/pluginutils': 5.0.2 - '@storybook/builder-vite': 8.4.7(storybook@8.4.7(prettier@3.4.2))(vite@6.3.4(@types/node@22.10.5)(jiti@2.3.3)(less@4.2.0)(lightningcss@1.28.2)(terser@5.37.0)(tsx@4.19.2)(yaml@2.5.0)) + '@storybook/builder-vite': 8.4.7(storybook@8.4.7(prettier@3.4.2))(vite@6.3.4(@types/node@22.10.5)(jiti@2.3.3)(less@4.2.0)(lightningcss@1.30.1)(terser@5.37.0)(tsx@4.19.2)(yaml@2.5.0)) '@storybook/react': 8.4.7(@storybook/test@8.4.7(storybook@8.4.7(prettier@3.4.2)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.4.7(prettier@3.4.2))(typescript@5.7.3) find-up: 5.0.0 magic-string: 0.30.10 @@ -23980,7 +24002,7 @@ snapshots: resolve: 1.22.8 storybook: 8.4.7(prettier@3.4.2) tsconfig-paths: 4.2.0 - vite: 6.3.4(@types/node@22.10.5)(jiti@2.3.3)(less@4.2.0)(lightningcss@1.28.2)(terser@5.37.0)(tsx@4.19.2)(yaml@2.5.0) + vite: 6.3.4(@types/node@22.10.5)(jiti@2.3.3)(less@4.2.0)(lightningcss@1.30.1)(terser@5.37.0)(tsx@4.19.2)(yaml@2.5.0) transitivePeerDependencies: - '@storybook/test' - rollup @@ -24824,14 +24846,14 @@ snapshots: dependencies: graphql: 16.9.0 - '@vitejs/plugin-react@4.3.4(vite@6.3.4(@types/node@22.10.5)(jiti@2.3.3)(less@4.2.0)(lightningcss@1.28.2)(terser@5.37.0)(tsx@4.19.2)(yaml@2.5.0))': + '@vitejs/plugin-react@4.3.4(vite@6.3.4(@types/node@22.10.5)(jiti@2.3.3)(less@4.2.0)(lightningcss@1.30.1)(terser@5.37.0)(tsx@4.19.2)(yaml@2.5.0))': dependencies: '@babel/core': 7.26.0 '@babel/plugin-transform-react-jsx-self': 7.25.9(@babel/core@7.26.0) '@babel/plugin-transform-react-jsx-source': 7.25.9(@babel/core@7.26.0) '@types/babel__core': 7.20.5 react-refresh: 0.14.2 - vite: 6.3.4(@types/node@22.10.5)(jiti@2.3.3)(less@4.2.0)(lightningcss@1.28.2)(terser@5.37.0)(tsx@4.19.2)(yaml@2.5.0) + vite: 6.3.4(@types/node@22.10.5)(jiti@2.3.3)(less@4.2.0)(lightningcss@1.30.1)(terser@5.37.0)(tsx@4.19.2)(yaml@2.5.0) transitivePeerDependencies: - supports-color @@ -24849,13 +24871,13 @@ snapshots: chai: 5.2.0 tinyrainbow: 2.0.0 - '@vitest/mocker@3.1.1(vite@6.1.5(@types/node@22.10.5)(jiti@2.3.3)(less@4.2.0)(lightningcss@1.28.2)(terser@5.37.0)(tsx@4.19.2)(yaml@2.5.0))': + '@vitest/mocker@3.1.1(vite@6.1.5(@types/node@22.10.5)(jiti@2.3.3)(less@4.2.0)(lightningcss@1.30.1)(terser@5.37.0)(tsx@4.19.2)(yaml@2.5.0))': dependencies: '@vitest/spy': 3.1.1 estree-walker: 3.0.3 magic-string: 0.30.17 optionalDependencies: - vite: 6.1.5(@types/node@22.10.5)(jiti@2.3.3)(less@4.2.0)(lightningcss@1.28.2)(terser@5.37.0)(tsx@4.19.2)(yaml@2.5.0) + vite: 6.1.5(@types/node@22.10.5)(jiti@2.3.3)(less@4.2.0)(lightningcss@1.30.1)(terser@5.37.0)(tsx@4.19.2)(yaml@2.5.0) '@vitest/pretty-format@2.0.5': dependencies: @@ -25044,6 +25066,9 @@ snapshots: acorn@8.14.0: {} + acorn@8.15.0: + optional: true + addressparser@1.0.1: {} agent-base@6.0.2: @@ -25490,12 +25515,12 @@ snapshots: node-releases: 2.0.18 update-browserslist-db: 1.1.0(browserslist@4.24.0) - browserslist@4.24.3: + browserslist@4.25.0: dependencies: - caniuse-lite: 1.0.30001690 - electron-to-chromium: 1.5.76 + caniuse-lite: 1.0.30001723 + electron-to-chromium: 1.5.170 node-releases: 2.0.19 - update-browserslist-db: 1.1.1(browserslist@4.24.3) + update-browserslist-db: 1.1.3(browserslist@4.25.0) bser@2.1.1: dependencies: @@ -25658,6 +25683,8 @@ snapshots: caniuse-lite@1.0.30001690: {} + caniuse-lite@1.0.30001723: {} + capital-case@1.0.4: dependencies: no-case: 3.0.4 @@ -26101,6 +26128,8 @@ snapshots: create-require@1.1.1: {} + crelt@1.0.6: {} + cron-parser@4.9.0: dependencies: luxon: 3.5.0 @@ -26538,6 +26567,8 @@ snapshots: detect-libc@2.0.3: optional: true + detect-libc@2.0.4: {} + detect-newline@4.0.1: {} detect-node-es@1.1.0: {} @@ -26702,9 +26733,9 @@ snapshots: dependencies: jake: 10.8.5 - electron-to-chromium@1.5.41: {} + electron-to-chromium@1.5.170: {} - electron-to-chromium@1.5.76: {} + electron-to-chromium@1.5.41: {} emoji-regex@10.3.0: {} @@ -29253,50 +29284,50 @@ snapshots: process-warning: 3.0.0 set-cookie-parser: 2.7.1 - lightningcss-darwin-arm64@1.28.2: + lightningcss-darwin-arm64@1.30.1: optional: true - lightningcss-darwin-x64@1.28.2: + lightningcss-darwin-x64@1.30.1: optional: true - lightningcss-freebsd-x64@1.28.2: + lightningcss-freebsd-x64@1.30.1: optional: true - lightningcss-linux-arm-gnueabihf@1.28.2: + lightningcss-linux-arm-gnueabihf@1.30.1: optional: true - lightningcss-linux-arm64-gnu@1.28.2: + lightningcss-linux-arm64-gnu@1.30.1: optional: true - lightningcss-linux-arm64-musl@1.28.2: + lightningcss-linux-arm64-musl@1.30.1: optional: true - lightningcss-linux-x64-gnu@1.28.2: + lightningcss-linux-x64-gnu@1.30.1: optional: true - lightningcss-linux-x64-musl@1.28.2: + lightningcss-linux-x64-musl@1.30.1: optional: true - lightningcss-win32-arm64-msvc@1.28.2: + lightningcss-win32-arm64-msvc@1.30.1: optional: true - lightningcss-win32-x64-msvc@1.28.2: + lightningcss-win32-x64-msvc@1.30.1: optional: true - lightningcss@1.28.2: + lightningcss@1.30.1: dependencies: - detect-libc: 1.0.3 + detect-libc: 2.0.4 optionalDependencies: - lightningcss-darwin-arm64: 1.28.2 - lightningcss-darwin-x64: 1.28.2 - lightningcss-freebsd-x64: 1.28.2 - lightningcss-linux-arm-gnueabihf: 1.28.2 - lightningcss-linux-arm64-gnu: 1.28.2 - lightningcss-linux-arm64-musl: 1.28.2 - lightningcss-linux-x64-gnu: 1.28.2 - lightningcss-linux-x64-musl: 1.28.2 - lightningcss-win32-arm64-msvc: 1.28.2 - lightningcss-win32-x64-msvc: 1.28.2 + lightningcss-darwin-arm64: 1.30.1 + lightningcss-darwin-x64: 1.30.1 + lightningcss-freebsd-x64: 1.30.1 + lightningcss-linux-arm-gnueabihf: 1.30.1 + lightningcss-linux-arm64-gnu: 1.30.1 + lightningcss-linux-arm64-musl: 1.30.1 + lightningcss-linux-x64-gnu: 1.30.1 + lightningcss-linux-x64-musl: 1.30.1 + lightningcss-win32-arm64-msvc: 1.30.1 + lightningcss-win32-x64-msvc: 1.30.1 lilconfig@3.1.3: {} @@ -31368,6 +31399,10 @@ snapshots: dependencies: pg: 8.13.1 + pg-cursor@2.15.1(pg@8.13.1): + dependencies: + pg: 8.13.1 + pg-int8@1.0.1: {} pg-minify@1.6.5: {} @@ -31393,7 +31428,7 @@ snapshots: pg-query-stream@4.7.0(pg@8.13.1): dependencies: pg: 8.13.1 - pg-cursor: 2.12.1(pg@8.13.1) + pg-cursor: 2.15.1(pg@8.13.1) pg-types@2.2.0: dependencies: @@ -31526,8 +31561,8 @@ snapshots: postcss-lightningcss@1.0.1(postcss@8.4.49): dependencies: - browserslist: 4.24.3 - lightningcss: 1.28.2 + browserslist: 4.25.0 + lightningcss: 1.30.1 postcss: 8.4.49 postcss-load-config@4.0.2(postcss@8.4.49)(ts-node@10.9.2(@swc/core@1.10.6(@swc/helpers@0.5.15))(@types/node@22.10.5)(typescript@5.7.3)): @@ -33268,7 +33303,7 @@ snapshots: terser@5.37.0: dependencies: '@jridgewell/source-map': 0.3.6 - acorn: 8.14.0 + acorn: 8.15.0 commander: 2.20.3 source-map-support: 0.5.21 optional: true @@ -33820,9 +33855,9 @@ snapshots: escalade: 3.1.2 picocolors: 1.1.1 - update-browserslist-db@1.1.1(browserslist@4.24.3): + update-browserslist-db@1.1.3(browserslist@4.25.0): dependencies: - browserslist: 4.24.3 + browserslist: 4.25.0 escalade: 3.2.0 picocolors: 1.1.1 @@ -34000,13 +34035,13 @@ snapshots: unist-util-stringify-position: 4.0.0 vfile-message: 4.0.2 - vite-node@3.1.1(@types/node@22.10.5)(jiti@2.3.3)(less@4.2.0)(lightningcss@1.28.2)(terser@5.37.0)(tsx@4.19.2)(yaml@2.5.0): + vite-node@3.1.1(@types/node@22.10.5)(jiti@2.3.3)(less@4.2.0)(lightningcss@1.30.1)(terser@5.37.0)(tsx@4.19.2)(yaml@2.5.0): dependencies: cac: 6.7.14 debug: 4.4.0(supports-color@8.1.1) es-module-lexer: 1.6.0 pathe: 2.0.3 - vite: 6.3.4(@types/node@22.10.5)(jiti@2.3.3)(less@4.2.0)(lightningcss@1.28.2)(terser@5.37.0)(tsx@4.19.2)(yaml@2.5.0) + vite: 6.3.4(@types/node@22.10.5)(jiti@2.3.3)(less@4.2.0)(lightningcss@1.30.1)(terser@5.37.0)(tsx@4.19.2)(yaml@2.5.0) transitivePeerDependencies: - '@types/node' - jiti @@ -34021,18 +34056,18 @@ snapshots: - tsx - yaml - vite-tsconfig-paths@5.1.4(typescript@5.7.3)(vite@6.3.4(@types/node@22.10.5)(jiti@2.3.3)(less@4.2.0)(lightningcss@1.28.2)(terser@5.37.0)(tsx@4.19.2)(yaml@2.5.0)): + vite-tsconfig-paths@5.1.4(typescript@5.7.3)(vite@6.3.4(@types/node@22.10.5)(jiti@2.3.3)(less@4.2.0)(lightningcss@1.30.1)(terser@5.37.0)(tsx@4.19.2)(yaml@2.5.0)): dependencies: debug: 4.3.7(supports-color@8.1.1) globrex: 0.1.2 tsconfck: 3.0.3(typescript@5.7.3) optionalDependencies: - vite: 6.3.4(@types/node@22.10.5)(jiti@2.3.3)(less@4.2.0)(lightningcss@1.28.2)(terser@5.37.0)(tsx@4.19.2)(yaml@2.5.0) + vite: 6.3.4(@types/node@22.10.5)(jiti@2.3.3)(less@4.2.0)(lightningcss@1.30.1)(terser@5.37.0)(tsx@4.19.2)(yaml@2.5.0) transitivePeerDependencies: - supports-color - typescript - vite@5.4.12(@types/node@22.10.5)(less@4.2.0)(lightningcss@1.28.2)(terser@5.37.0): + vite@5.4.12(@types/node@22.10.5)(less@4.2.0)(lightningcss@1.30.1)(terser@5.37.0): dependencies: esbuild: 0.25.0 postcss: 8.4.49 @@ -34041,10 +34076,10 @@ snapshots: '@types/node': 22.10.5 fsevents: 2.3.3 less: 4.2.0 - lightningcss: 1.28.2 + lightningcss: 1.30.1 terser: 5.37.0 - vite@6.1.5(@types/node@22.10.5)(jiti@2.3.3)(less@4.2.0)(lightningcss@1.28.2)(terser@5.37.0)(tsx@4.19.2)(yaml@2.5.0): + vite@6.1.5(@types/node@22.10.5)(jiti@2.3.3)(less@4.2.0)(lightningcss@1.30.1)(terser@5.37.0)(tsx@4.19.2)(yaml@2.5.0): dependencies: esbuild: 0.25.0 postcss: 8.5.2 @@ -34054,12 +34089,12 @@ snapshots: fsevents: 2.3.3 jiti: 2.3.3 less: 4.2.0 - lightningcss: 1.28.2 + lightningcss: 1.30.1 terser: 5.37.0 tsx: 4.19.2 yaml: 2.5.0 - vite@6.3.4(@types/node@22.10.5)(jiti@2.3.3)(less@4.2.0)(lightningcss@1.28.2)(terser@5.37.0)(tsx@4.19.2)(yaml@2.5.0): + vite@6.3.4(@types/node@22.10.5)(jiti@2.3.3)(less@4.2.0)(lightningcss@1.30.1)(terser@5.37.0)(tsx@4.19.2)(yaml@2.5.0): dependencies: esbuild: 0.25.0 fdir: 6.4.4(picomatch@4.0.2) @@ -34072,15 +34107,15 @@ snapshots: fsevents: 2.3.3 jiti: 2.3.3 less: 4.2.0 - lightningcss: 1.28.2 + lightningcss: 1.30.1 terser: 5.37.0 tsx: 4.19.2 yaml: 2.5.0 - vitest@3.1.1(@types/node@22.10.5)(jiti@2.3.3)(less@4.2.0)(lightningcss@1.28.2)(terser@5.37.0)(tsx@4.19.2)(yaml@2.5.0): + vitest@3.1.1(@types/node@22.10.5)(jiti@2.3.3)(less@4.2.0)(lightningcss@1.30.1)(terser@5.37.0)(tsx@4.19.2)(yaml@2.5.0): dependencies: '@vitest/expect': 3.1.1 - '@vitest/mocker': 3.1.1(vite@6.1.5(@types/node@22.10.5)(jiti@2.3.3)(less@4.2.0)(lightningcss@1.28.2)(terser@5.37.0)(tsx@4.19.2)(yaml@2.5.0)) + '@vitest/mocker': 3.1.1(vite@6.1.5(@types/node@22.10.5)(jiti@2.3.3)(less@4.2.0)(lightningcss@1.30.1)(terser@5.37.0)(tsx@4.19.2)(yaml@2.5.0)) '@vitest/pretty-format': 3.1.1 '@vitest/runner': 3.1.1 '@vitest/snapshot': 3.1.1 @@ -34096,8 +34131,8 @@ snapshots: tinyexec: 0.3.2 tinypool: 1.0.2 tinyrainbow: 2.0.0 - vite: 6.1.5(@types/node@22.10.5)(jiti@2.3.3)(less@4.2.0)(lightningcss@1.28.2)(terser@5.37.0)(tsx@4.19.2)(yaml@2.5.0) - vite-node: 3.1.1(@types/node@22.10.5)(jiti@2.3.3)(less@4.2.0)(lightningcss@1.28.2)(terser@5.37.0)(tsx@4.19.2)(yaml@2.5.0) + vite: 6.1.5(@types/node@22.10.5)(jiti@2.3.3)(less@4.2.0)(lightningcss@1.30.1)(terser@5.37.0)(tsx@4.19.2)(yaml@2.5.0) + vite-node: 3.1.1(@types/node@22.10.5)(jiti@2.3.3)(less@4.2.0)(lightningcss@1.30.1)(terser@5.37.0)(tsx@4.19.2)(yaml@2.5.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 22.10.5 From 16fe4a21adca5909ee877a8b9b7742420239050e Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Tue, 24 Jun 2025 18:19:00 -0700 Subject: [PATCH 11/54] WIP --- .../src/modules/proposals/module.graphql.ts | 2 +- .../resolvers/Query/schemaProposal.ts | 20 +- .../resolvers/Query/schemaProposalReview.ts | 12 + .../resolvers/Query/schemaProposalReviews.ts | 24 +- .../src/components/common/ListNavigation.tsx | 2 +- .../app/src/components/proposal/Review.tsx | 71 ++++ .../collect-coordinate-locations.spec.ts | 109 ++++++ .../app/src/components/proposal/change.tsx | 32 ++ .../proposal/collect-coordinate-locations.ts | 106 ++++++ .../web/app/src/components/proposal/print.ts | 347 ------------------ .../src/components/proposal/proposal-sdl.tsx | 136 +++++-- .../web/app/src/components/proposal/util.ts | 12 +- .../app/src/pages/target-proposal-layout.tsx | 10 +- .../src/pages/target-proposal-overview.tsx | 101 ++++- .../web/app/src/pages/target-proposals.tsx | 2 +- pnpm-lock.yaml | 20 +- 16 files changed, 596 insertions(+), 410 deletions(-) create mode 100644 packages/web/app/src/components/proposal/Review.tsx create mode 100644 packages/web/app/src/components/proposal/__tests__/collect-coordinate-locations.spec.ts create mode 100644 packages/web/app/src/components/proposal/change.tsx create mode 100644 packages/web/app/src/components/proposal/collect-coordinate-locations.ts delete mode 100644 packages/web/app/src/components/proposal/print.ts diff --git a/packages/services/api/src/modules/proposals/module.graphql.ts b/packages/services/api/src/modules/proposals/module.graphql.ts index a2891238d6..d04b328952 100644 --- a/packages/services/api/src/modules/proposals/module.graphql.ts +++ b/packages/services/api/src/modules/proposals/module.graphql.ts @@ -181,7 +181,7 @@ export default gql` """ Comments attached to this review. """ - comments(first: Int! = 200): SchemaProposalCommentConnection + comments(after: String, first: Int! = 200): SchemaProposalCommentConnection """ When the review was first made. Only a review's comments are mutable, so there is no diff --git a/packages/services/api/src/modules/proposals/resolvers/Query/schemaProposal.ts b/packages/services/api/src/modules/proposals/resolvers/Query/schemaProposal.ts index ce8619f3b5..a518db3500 100644 --- a/packages/services/api/src/modules/proposals/resolvers/Query/schemaProposal.ts +++ b/packages/services/api/src/modules/proposals/resolvers/Query/schemaProposal.ts @@ -25,7 +25,25 @@ export const schemaProposal: NonNullable = asy cursor: 'asdf', node: { id: '1', - comments: [], + comments: { + pageInfo: { + endCursor: crypto.randomUUID(), + startCursor: crypto.randomUUID(), + hasNextPage: false, + hasPreviousPage: false, + }, + edges: [ + { + cursor: crypto.randomUUID(), + node: { + id: crypto.randomUUID(), + createdAt: Date.now(), + body: 'This is a comment. The first comment.', + updatedAt: Date.now(), + } + } + ] + }, createdAt: Date.now(), lineText: 'type User {', lineNumber: 2, diff --git a/packages/services/api/src/modules/proposals/resolvers/Query/schemaProposalReview.ts b/packages/services/api/src/modules/proposals/resolvers/Query/schemaProposalReview.ts index a50618059c..8e739842d8 100644 --- a/packages/services/api/src/modules/proposals/resolvers/Query/schemaProposalReview.ts +++ b/packages/services/api/src/modules/proposals/resolvers/Query/schemaProposalReview.ts @@ -15,6 +15,18 @@ export const schemaProposalReview: NonNullable { /* Implement Query.schemaProposalReviews resolver logic here */ return { - edges: [], + edges: [ + { + node: { + id: crypto.randomUUID(), + createdAt: Date.now(), + lineNumber: 3, + schemaCoordinate: 'User', + lineText: 'type User {', + comments: { + edges: [ + { + cursor: crypto.randomUUID(), + node: { + id: crypto.randomUUID(), + body: 'This is a comment. The first comment.', + updatedAt: Date.now(), + } + } + ] + } + } + } + ], pageInfo: { endCursor: '', hasNextPage: false, diff --git a/packages/web/app/src/components/common/ListNavigation.tsx b/packages/web/app/src/components/common/ListNavigation.tsx index 4b67cd9ced..85221a0285 100644 --- a/packages/web/app/src/components/common/ListNavigation.tsx +++ b/packages/web/app/src/components/common/ListNavigation.tsx @@ -116,7 +116,7 @@ export function ListNavigation(props: { children: ReactNode }) {
; + lineNumber: number; +}) { + const review = useFragment(ProposalOverview_ReviewCommentsFragment, props.review); + if (!review.comments) { + return null; + } + + return ( +
+ {review.comments?.edges?.map(({ node: comment }, idx) => { + return ( + + ); + })} +
+ ); +} + +const ProposalOverview_CommentFragment = graphql(/** GraphQL */ ` + fragment ProposalOverview_CommentFragment on SchemaProposalComment { + id + user { + id + email + displayName + fullName + } + body + updatedAt + } +`); + +export function ReviewComment(props: { + first?: boolean + comment: FragmentType; +}) { + const comment = useFragment(ProposalOverview_CommentFragment, props.comment); + return ( + <> +
+
{comment.user?.displayName ?? comment.user?.fullName ?? 'Unknown'}
+
+
+
{comment.body}
+ + ) +} diff --git a/packages/web/app/src/components/proposal/__tests__/collect-coordinate-locations.spec.ts b/packages/web/app/src/components/proposal/__tests__/collect-coordinate-locations.spec.ts new file mode 100644 index 0000000000..c4eb24d1dc --- /dev/null +++ b/packages/web/app/src/components/proposal/__tests__/collect-coordinate-locations.spec.ts @@ -0,0 +1,109 @@ +import { buildSchema, Source } from 'graphql'; +import { collectCoordinateLocations } from '../collect-coordinate-locations'; + +const coordinatesFromSDL = (sdl: string) => { + const schema = buildSchema(sdl); + return collectCoordinateLocations(schema, new Source(sdl)); +}; + +describe('schema coordinate location collection', () => { + describe('should include the location of', () => { + test('types', () => { + const sdl = /** GraphQL */ ` + type Query { + foo: Foo + } + type Foo { + id: ID! + } + `; + const coords = coordinatesFromSDL(sdl); + expect(coords.get('Foo')).toBe(5); + }); + + test('fields', () => { + const sdl = /** GraphQL */ ` + type Query { + foo: Foo + } + + type Foo { + id: ID! + } + `; + const coords = coordinatesFromSDL(sdl); + expect(coords.get('Query.foo')).toBe(3); + }); + + test('arguments', () => { + const sdl = /** GraphQL */ ` + type Query { + foo(bar: Boolean): Boolean + } + `; + const coords = coordinatesFromSDL(sdl); + expect(coords.get('Query.foo.bar')).toBe(3); + }); + + test('scalars', () => { + const sdl = /** GraphQL */ ` + scalar Foo + type Query { + foo(bar: Boolean): Boolean + } + `; + const coords = coordinatesFromSDL(sdl); + expect(coords.get('Foo')).toBe(2); + }); + + test('enums', () => { + const sdl = /** GraphQL */ ` + enum Foo { + FIRST + SECOND + THIRD + } + type Query { + foo(bar: Boolean): Foo + } + `; + const coords = coordinatesFromSDL(sdl); + expect(coords.get('Foo')).toBe(2); + expect(coords.get('Foo.FIRST')).toBe(3); + expect(coords.get('Foo.SECOND')).toBe(4); + expect(coords.get('Foo.THIRD')).toBe(5); + }); + + test('unions', () => { + const sdl = /** GraphQL */ ` + union Foo = + | Bar + | Blar + type Bar { + bar: Boolean + } + type Blar { + blar: String + } + type Query { + foo: Foo + } + `; + const coords = coordinatesFromSDL(sdl); + expect(coords.get('Foo')).toBe(2); + // @note The AST is limited and does not give the location of union values. + expect(coords.get('Foo.Bar')).toBe(2); + expect(coords.get('Foo.Blar')).toBe(2); + }); + + test('subscriptions', () => { + const sdl = /** GraphQL */ ` + type Subscription { + foo: String + } + `; + const coords = coordinatesFromSDL(sdl); + expect(coords.get('Subscription.foo')).toBe(3); + }); + }); +}); diff --git a/packages/web/app/src/components/proposal/change.tsx b/packages/web/app/src/components/proposal/change.tsx new file mode 100644 index 0000000000..b62c9b3ed2 --- /dev/null +++ b/packages/web/app/src/components/proposal/change.tsx @@ -0,0 +1,32 @@ +import { cn } from '@/lib/utils'; +import { ReactNode } from 'react'; + +export function ChangeDocument(props: { children: ReactNode; className?: string }) { + return ( + + + {props.children} + +
+ ); +} + +export function ChangeRow(props: { + children: ReactNode; + + /** The line number for the current schema version */ + lineNumber: number; + + /** The line number associated for the proposed schema */ + diffLineNumber?: number; + + className?: string; +}) { + return ( + + {props.lineNumber} + {props.lineNumber !== props.diffLineNumber ? props.diffLineNumber : null} + {props.children} + + ); +} diff --git a/packages/web/app/src/components/proposal/collect-coordinate-locations.ts b/packages/web/app/src/components/proposal/collect-coordinate-locations.ts new file mode 100644 index 0000000000..8ec6e13e51 --- /dev/null +++ b/packages/web/app/src/components/proposal/collect-coordinate-locations.ts @@ -0,0 +1,106 @@ +import { + getLocation, + GraphQLArgument, + GraphQLEnumType, + GraphQLField, + GraphQLInputObjectType, + GraphQLInterfaceType, + GraphQLNamedType, + GraphQLObjectType, + GraphQLSchema, + GraphQLUnionType, + isIntrospectionType, + Location, + Source, +} from 'graphql'; + +export function collectCoordinateLocations( + schema: GraphQLSchema, + source: Source, +): Map { + const coordinateToLine = new Map(); + + const collectObjectType = ( + type: GraphQLObjectType | GraphQLInputObjectType | GraphQLInterfaceType, + ) => { + collect(type.name, type.astNode?.loc); + const fields = type.getFields(); + if (fields) { + for (const field of Object.values(fields)) { + collectField(type, field); + } + } + }; + + const collect = (coordinate: string, location: Location | undefined) => { + const sourceLoc = location && getLocation(source, location.start); + if (sourceLoc?.line) { + coordinateToLine.set(coordinate, sourceLoc.line); + } else { + console.warn(`Location not found for "${coordinate}"`); + } + }; + + const collectEnumType = (type: GraphQLEnumType) => { + collect(type.name, type.astNode?.loc); + for (const val of type.getValues()) { + const coord = `${type.name}.${val.name}`; + collect(coord, val.astNode?.loc); + } + }; + + const collectUnionType = (type: GraphQLUnionType) => { + collect(type.name, type.astNode?.loc); + for (const unionType of type.getTypes()) { + const coordinate = `${type.name}.${unionType.name}`; + collect(coordinate, type.astNode?.loc); + } + }; + + const collectNamedType = (type: GraphQLNamedType) => { + if (isIntrospectionType(type)) { + return; + } + + if ( + type instanceof GraphQLObjectType || + type instanceof GraphQLInputObjectType || + type instanceof GraphQLInterfaceType + ) { + collectObjectType(type); + } else if (type instanceof GraphQLUnionType) { + collectUnionType(type); + } else if (type instanceof GraphQLEnumType) { + collectEnumType(type); + } else { + collect(type.name, type.astNode?.loc); + } + }; + + const collectArg = ( + type: GraphQLObjectType | GraphQLInputObjectType | GraphQLInterfaceType, + field: GraphQLField, + arg: GraphQLArgument, + ) => { + const coord = `${type.name}.${field.name}.${arg.name}`; + collect(coord, arg.astNode?.loc); + }; + + const collectField = ( + type: GraphQLObjectType | GraphQLInputObjectType | GraphQLInterfaceType, + field: GraphQLField, + ) => { + const coord = `${type.name}.${field.name}`; + collect(coord, field.astNode?.loc); + + for (const arg of field.args) { + collectArg(type, field, arg); + } + }; + + for (const named of Object.values(schema.getTypeMap())) { + collectNamedType(named); + } + + return coordinateToLine; +} diff --git a/packages/web/app/src/components/proposal/print.ts b/packages/web/app/src/components/proposal/print.ts deleted file mode 100644 index c5340cbdf3..0000000000 --- a/packages/web/app/src/components/proposal/print.ts +++ /dev/null @@ -1,347 +0,0 @@ -import { type ASTNode, visit, ASTVisitor } from 'graphql'; -import type { ASTReducer } from 'graphql/language/visitor'; -import { printBlockString } from 'graphql/language/blockString'; -import { printString } from 'graphql/language/printString'; -// import type { ASTReducer } from './visitor'; - -type Maybe = null | undefined | T; - -/** - * Converts an AST into a string, using one set of reasonable - * formatting rules. - */ -export function print(ast: ASTNode): string { - return visit(ast, printDocASTReducer); -} - -const MAX_LINE_LENGTH = 80; - -const printDocASTReducer: ASTReducer = { - Name: { leave: (node) => node.value }, - Variable: { leave: (node) => '$' + node.name }, - - // Document - - Document: { - leave: (node) => join(node.definitions, '\n\n'), - }, - - OperationDefinition: { - leave(node) { - const varDefs = wrap('(', join(node.variableDefinitions, ', '), ')'); - const prefix = join( - [ - node.operation, - join([node.name, varDefs]), - join(node.directives, ' '), - ], - ' ', - ); - - // Anonymous queries with no directives or variable definitions can use - // the query short form. - return (prefix === 'query' ? '' : prefix + ' ') + node.selectionSet; - }, - }, - - VariableDefinition: { - leave: ({ variable, type, defaultValue, directives }) => - variable + - ': ' + - type + - wrap(' = ', defaultValue) + - wrap(' ', join(directives, ' ')), - }, - SelectionSet: { leave: ({ selections }) => block(selections) }, - - Field: { - leave({ alias, name, arguments: args, directives, selectionSet }) { - const prefix = wrap('', alias, ': ') + name; - let argsLine = prefix + wrap('(', join(args, ', '), ')'); - - if (argsLine.length > MAX_LINE_LENGTH) { - argsLine = prefix + wrap('(\n', indent(join(args, '\n')), '\n)'); - } - - return join([argsLine, join(directives, ' '), selectionSet], ' '); - }, - }, - - Argument: { leave: ({ name, value }) => name + ': ' + value }, - - // Fragments - - FragmentSpread: { - leave: ({ name, directives }) => - '...' + name + wrap(' ', join(directives, ' ')), - }, - - InlineFragment: { - leave: ({ typeCondition, directives, selectionSet }) => - join( - [ - '...', - wrap('on ', typeCondition), - join(directives, ' '), - selectionSet, - ], - ' ', - ), - }, - - FragmentDefinition: { - leave: ({ - name, - typeCondition, - variableDefinitions, - directives, - selectionSet, - }) => - // Note: fragment variable definitions are experimental and may be changed - // or removed in the future. - `fragment ${name}${wrap('(', join(variableDefinitions, ', '), ')')} ` + - `on ${typeCondition} ${wrap('', join(directives, ' '), ' ')}` + - selectionSet, - }, - - // Value - - IntValue: { leave: ({ value }) => value }, - FloatValue: { leave: ({ value }) => value }, - StringValue: { - leave: ({ value, block: isBlockString }) => - isBlockString ? printBlockString(value) : printString(value), - }, - BooleanValue: { leave: ({ value }) => (value ? 'true' : 'false') }, - NullValue: { leave: () => 'null' }, - EnumValue: { leave: ({ value }) => value }, - ListValue: { leave: ({ values }) => '[' + join(values, ', ') + ']' }, - ObjectValue: { leave: ({ fields }) => '{' + join(fields, ', ') + '}' }, - ObjectField: { leave: ({ name, value }) => name + ': ' + value }, - - // Directive - - Directive: { - leave: ({ name, arguments: args }) => - '@' + name + wrap('(', join(args, ', '), ')'), - }, - - // Type - - NamedType: { leave: ({ name }) => name }, - ListType: { leave: ({ type }) => '[' + type + ']' }, - NonNullType: { leave: ({ type }) => type + '!' }, - - // Type System Definitions - - SchemaDefinition: { - leave: ({ description, directives, operationTypes }) => - wrap('', description, '\n') + - join(['schema', join(directives, ' '), block(operationTypes)], ' '), - }, - - OperationTypeDefinition: { - leave: ({ operation, type }) => operation + ': ' + type, - }, - - ScalarTypeDefinition: { - leave: ({ description, name, directives }) => - wrap('', description, '\n') + - join(['scalar', name, join(directives, ' ')], ' '), - }, - - ObjectTypeDefinition: { - leave: ({ description, name, interfaces, directives, fields }) => - wrap('', description, '\n') + - join( - [ - 'type', - name, - wrap('implements ', join(interfaces, ' & ')), - join(directives, ' '), - block(fields), - ], - ' ', - ), - }, - - FieldDefinition: { - leave: ({ description, name, arguments: args, type, directives }) => - wrap('', description, '\n') + - name + - (hasMultilineItems(args) - ? wrap('(\n', indent(join(args, '\n')), '\n)') - : wrap('(', join(args, ', '), ')')) + - ': ' + - type + - wrap(' ', join(directives, ' ')), - }, - - InputValueDefinition: { - leave: ({ description, name, type, defaultValue, directives }) => - wrap('', description, '\n') + - join( - [name + ': ' + type, wrap('= ', defaultValue), join(directives, ' ')], - ' ', - ), - }, - - InterfaceTypeDefinition: { - leave: ({ description, name, interfaces, directives, fields }) => - wrap('', description, '\n') + - join( - [ - 'interface', - name, - wrap('implements ', join(interfaces, ' & ')), - join(directives, ' '), - block(fields), - ], - ' ', - ), - }, - - UnionTypeDefinition: { - leave: ({ description, name, directives, types }) => - wrap('', description, '\n') + - join( - ['union', name, join(directives, ' '), wrap('= ', join(types, ' | '))], - ' ', - ), - }, - - EnumTypeDefinition: { - leave: ({ description, name, directives, values }) => - wrap('', description, '\n') + - join(['enum', name, join(directives, ' '), block(values)], ' '), - }, - - EnumValueDefinition: { - leave: ({ description, name, directives }) => - wrap('', description, '\n') + join([name, join(directives, ' ')], ' '), - }, - - InputObjectTypeDefinition: { - leave: ({ description, name, directives, fields }) => - wrap('', description, '\n') + - join(['input', name, join(directives, ' '), block(fields)], ' '), - }, - - DirectiveDefinition: { - leave: ({ description, name, arguments: args, repeatable, locations }) => - wrap('', description, '\n') + - 'directive @' + - name + - (hasMultilineItems(args) - ? wrap('(\n', indent(join(args, '\n')), '\n)') - : wrap('(', join(args, ', '), ')')) + - (repeatable ? ' repeatable' : '') + - ' on ' + - join(locations, ' | '), - }, - - SchemaExtension: { - leave: ({ directives, operationTypes }) => - join( - ['extend schema', join(directives, ' '), block(operationTypes)], - ' ', - ), - }, - - ScalarTypeExtension: { - leave: ({ name, directives }) => - join(['extend scalar', name, join(directives, ' ')], ' '), - }, - - ObjectTypeExtension: { - leave: ({ name, interfaces, directives, fields }) => - join( - [ - 'extend type', - name, - wrap('implements ', join(interfaces, ' & ')), - join(directives, ' '), - block(fields), - ], - ' ', - ), - }, - - InterfaceTypeExtension: { - leave: ({ name, interfaces, directives, fields }) => - join( - [ - 'extend interface', - name, - wrap('implements ', join(interfaces, ' & ')), - join(directives, ' '), - block(fields), - ], - ' ', - ), - }, - - UnionTypeExtension: { - leave: ({ name, directives, types }) => - join( - [ - 'extend union', - name, - join(directives, ' '), - wrap('= ', join(types, ' | ')), - ], - ' ', - ), - }, - - EnumTypeExtension: { - leave: ({ name, directives, values }) => - join(['extend enum', name, join(directives, ' '), block(values)], ' '), - }, - - InputObjectTypeExtension: { - leave: ({ name, directives, fields }) => - join(['extend input', name, join(directives, ' '), block(fields)], ' '), - }, -}; - -/** - * Given maybeArray, print an empty string if it is null or empty, otherwise - * print all items together separated by separator if provided - */ -function join( - maybeArray: Maybe>, - separator = '', -): string { - return maybeArray?.filter((x) => x).join(separator) ?? ''; -} - -/** - * Given array, print each item on its own line, wrapped in an indented `{ }` block. - */ -function block(array: Maybe>): string { - return wrap('{\n', indent(join(array, '\n')), '\n}'); -} - -/** - * If maybeString is not null or empty, then wrap with start and end, otherwise print an empty string. - */ -function wrap( - start: string, - maybeString: Maybe, - end: string = '', -): string { - return maybeString != null && maybeString !== '' - ? start + maybeString + end - : ''; -} - -function indent(str: string): string { - return wrap(' ', str.replace(/\n/g, '\n ')); -} - -function hasMultilineItems(maybeArray: Maybe>): boolean { - // FIXME: https://github.com/graphql/graphql-js/issues/2203 - /* c8 ignore next */ - return maybeArray?.some((str: string) => str.includes('\n')) ?? false; -} \ No newline at end of file diff --git a/packages/web/app/src/components/proposal/proposal-sdl.tsx b/packages/web/app/src/components/proposal/proposal-sdl.tsx index 6ab727a7dd..07961b3afc 100644 --- a/packages/web/app/src/components/proposal/proposal-sdl.tsx +++ b/packages/web/app/src/components/proposal/proposal-sdl.tsx @@ -1,7 +1,11 @@ +import { buildSchema, Source } from 'graphql'; import { FragmentType, graphql, useFragment } from '@/gql'; -import { parse, print } from 'graphql'; +import { ProposalOverview_ReviewsFragmentFragment } from '@/gql/graphql'; +import { ChangeDocument, ChangeRow } from './change'; +import { collectCoordinateLocations } from './collect-coordinate-locations'; +import { ReviewComments } from './Review'; -const ProposalOverview_ReviewsFragment = graphql(/** GraphQL */` +const ProposalOverview_ReviewsFragment = graphql(/** GraphQL */ ` fragment ProposalOverview_ReviewsFragment on SchemaProposalReviewConnection { pageInfo { startCursor @@ -12,30 +16,13 @@ const ProposalOverview_ReviewsFragment = graphql(/** GraphQL */` id schemaProposalVersion { id + serviceName } stageTransition lineNumber lineText schemaCoordinate - comments { - edges { - cursor - node { - id - user { - id - email - displayName - fullName - } - body - updatedAt - } - } - pageInfo { - startCursor - } - } + ...ProposalOverview_ReviewCommentsFragment schemaProposalVersion { id } @@ -44,15 +31,106 @@ const ProposalOverview_ReviewsFragment = graphql(/** GraphQL */` } `); +type ReviewNode = NonNullable[number]['node']; + export function ProposalSDL(props: { sdl: string; - reviews: FragmentType; + serviceName?: string; + latestProposalVersionId: string; + reviews: FragmentType | null; }) { - const document = parse(props.sdl); - console.log(print(document)); - const connection = useFragment(ProposalOverview_ReviewsFragment, props.reviews); - // for (const edge of connection.edges) { - // edge.node. - // } - return
; + /** + * Reviews can change position because the coordinate changes... placing them out of order from their original line numbers. + * Because of this, we have to fetch every single page of comments... + * But because generally they are in order, we can take our time doing this. So fetch in small batches. + * + * Odds are there will never be so many reviews/comments that this is even a problem. + */ + const reviewsConnection = useFragment(ProposalOverview_ReviewsFragment, props.reviews); + + try { + const coordinateToLineMap = collectCoordinateLocations( + buildSchema(props.sdl, { assumeValid: true, assumeValidSDL: true }), + new Source(props.sdl), + ); + + // @note assume reviews are specific to the current service... + const globalReviews: ReviewNode[] = []; + const reviewsByLine = new Map(); + const serviceReviews = reviewsConnection?.edges + ?.filter(edge => { + const { schemaProposalVersion } = edge.node; + return schemaProposalVersion?.serviceName === props.serviceName; + }) ?? []; + + for (const edge of serviceReviews) { + const { lineNumber, schemaCoordinate, schemaProposalVersion } = edge.node; + const coordinateLine = !!schemaCoordinate && coordinateToLineMap.get(schemaCoordinate); + const isStale = + !coordinateLine && schemaProposalVersion?.id !== props.latestProposalVersionId; + const line = coordinateLine || lineNumber; + if (line) { + reviewsByLine.set(line, { ...edge.node, isStale }); + } else { + globalReviews.push(edge.node); + } + } + + // let nextReviewEdge = connection.edges?.pop(); + return ( + <> + + {props.sdl.split('\n').flatMap((txt, index) => { + const lineNumber = index + 1; + const elements = [{txt}]; + + const review = reviewsByLine.get(lineNumber) + if (review) { + if (review.isStale) { + elements.push(( + + + +
This review references an outdated version of the proposal.
+ {!!review.lineText && ( + + + {review.lineText} + + + )} + + + )) + } + elements.push( + + + + + + + ); + } + return elements; + })} +
+ {globalReviews.map(r => { + return ( +
+ {r.id} +
+ ) + })} + + ); + // console.log(printJsx(document)); + } catch (e: unknown) { + return ( + <> +
Invalid SDL
+
{e instanceof Error ? e.message : String(e)}
+ + ); + } } diff --git a/packages/web/app/src/components/proposal/util.ts b/packages/web/app/src/components/proposal/util.ts index 68aadf2153..0f1ac510af 100644 --- a/packages/web/app/src/components/proposal/util.ts +++ b/packages/web/app/src/components/proposal/util.ts @@ -13,10 +13,12 @@ export function stageToColor(stage: SchemaProposalStage | string) { } } -export function userText(user?: { - email: string; - displayName?: string | null; - fullName?: string | null; -} | null) { +export function userText( + user?: { + email: string; + displayName?: string | null; + fullName?: string | null; + } | null, +) { return user?.displayName || user?.fullName || user?.email || 'Unknown'; } diff --git a/packages/web/app/src/pages/target-proposal-layout.tsx b/packages/web/app/src/pages/target-proposal-layout.tsx index 79ae4152ab..c719b6aab9 100644 --- a/packages/web/app/src/pages/target-proposal-layout.tsx +++ b/packages/web/app/src/pages/target-proposal-layout.tsx @@ -1,5 +1,5 @@ -import { Link } from '@tanstack/react-router'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Link } from '@tanstack/react-router'; import { TargetProposalEditPage } from './target-proposal-edit'; import { TargetProposalHistoryPage } from './target-proposal-history'; import { TargetProposalOverviewPage } from './target-proposal-overview'; @@ -19,7 +19,7 @@ export function TargetProposalLayoutPage(props: { }) { return (
- + - +
- +
- +
diff --git a/packages/web/app/src/pages/target-proposal-overview.tsx b/packages/web/app/src/pages/target-proposal-overview.tsx index 63e8c9c856..e4ab58a8bd 100644 --- a/packages/web/app/src/pages/target-proposal-overview.tsx +++ b/packages/web/app/src/pages/target-proposal-overview.tsx @@ -1,10 +1,10 @@ import { useQuery } from 'urql'; -import { Spinner } from '@/components/ui/spinner'; -import { graphql } from '@/gql'; -import { Tag, TimeAgo } from '@/components/v2'; +import { ProposalSDL } from '@/components/proposal/proposal-sdl'; import { stageToColor, userText } from '@/components/proposal/util'; import { Subtitle, Title } from '@/components/ui/page'; -import { ProposalSDL } from '@/components/proposal/proposal-sdl'; +import { Spinner } from '@/components/ui/spinner'; +import { Tag, TimeAgo } from '@/components/v2'; +import { graphql } from '@/gql'; const ProposalOverviewQuery = graphql(/** GraphQL */ ` query ProposalOverviewQuery($id: ID!) { @@ -54,18 +54,101 @@ export function TargetProposalOverviewPage(props: { const proposal = query.data?.schemaProposal; + const sdl = /** GraphQL */ ` + extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.3" + import: ["@key", "@shareable", "@inaccessible", "@tag"] + ) + @link(url: "https://specs.graphql-hive.com/hive/v1.0", import: ["@meta"]) + @meta(name: "priority", content: "tier1") + + directive @meta( + name: String! + content: String! + ) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + + directive @myDirective(a: String!) on FIELD_DEFINITION + + directive @hello on FIELD_DEFINITION + + type Query { + allProducts: [ProductItf] @meta(name: "owner", content: "hive-team") + product(id: ID!): ProductItf + } + + interface ProductItf implements SkuItf @meta(name: "domain", content: "products") { + id: ID! + sku: String + name: String + package: String + variation: ProductVariation + dimensions: ProductDimension + createdBy: User + hidden: String @inaccessible + oldField: String @deprecated(reason: "refactored out") + } + + interface SkuItf { + sku: String + } + + type Product implements ProductItf & SkuItf + @key(fields: "id") + @key(fields: "sku package") + @key(fields: "sku variation { id }") + @meta(name: "owner", content: "product-team") { + id: ID! @tag(name: "hi-from-products") + sku: String @meta(name: "unique", content: "true") + name: String @hello + package: String + variation: ProductVariation + dimensions: ProductDimension + createdBy: User + hidden: String + reviewsScore: Float! + oldField: String + } + + enum ShippingClass { + STANDARD + EXPRESS + } + + type ProductVariation { + id: ID! + name: String + } + + type ProductDimension @shareable { + size: String + weight: Float + } + + type User @key(fields: "email") { + email: ID! + totalProductsCreated: Int @shareable + } + + `; + return ( -
+
{query.fetching && } {proposal && ( <> - {userText(proposal.user)} proposed -
+ + {userText(proposal.user)} proposed {' '} + +
{proposal.title} {proposal.stage}
-
Last updated
- +
+ Last updated +
+ {/* @todo */} + )}
diff --git a/packages/web/app/src/pages/target-proposals.tsx b/packages/web/app/src/pages/target-proposals.tsx index 2fd7f245d9..c4020ef11b 100644 --- a/packages/web/app/src/pages/target-proposals.tsx +++ b/packages/web/app/src/pages/target-proposals.tsx @@ -8,6 +8,7 @@ import { useListNavigationContext, } from '@/components/common/ListNavigation'; import { Page, TargetLayout } from '@/components/layouts/target'; +import { stageToColor } from '@/components/proposal/util'; import { StageFilter } from '@/components/target/proposals/stage-filter'; import { UserFilter } from '@/components/target/proposals/user-filter'; import { BadgeRounded } from '@/components/ui/badge'; @@ -22,7 +23,6 @@ import { SchemaProposalStage } from '@/gql/graphql'; import { cn } from '@/lib/utils'; import { ChatBubbleIcon, PinLeftIcon, PinRightIcon } from '@radix-ui/react-icons'; import { Outlet, useRouter, useSearch } from '@tanstack/react-router'; -import { stageToColor } from '@/components/proposal/util'; export function TargetProposalsPage(props: { organizationSlug: string; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9649f210e6..3ef923ca6c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16564,8 +16564,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 @@ -16672,11 +16672,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 @@ -16715,6 +16715,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.723.0(@aws-sdk/client-sts@3.723.0)': @@ -16848,11 +16849,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 @@ -16891,7 +16892,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.723.0': @@ -17005,7 +17005,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 @@ -17124,7 +17124,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.11 '@smithy/types': 3.7.2 @@ -17299,7 +17299,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.11 '@smithy/shared-ini-file-loader': 3.1.12 From 5241a1877ee4b4e75c944d4c19e0e6c1b75bb192 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Thu, 26 Jun 2025 10:39:11 -0700 Subject: [PATCH 12/54] WIP --- package.json | 1 + .../app/src/components/proposal/Review.tsx | 2 +- .../collect-coordinate-locations.spec.ts | 4 +- .../proposal/collect-coordinate-locations.ts | 8 +- .../src/components/proposal/proposal-sdl.tsx | 69 +++++++++- .../src/pages/target-proposal-overview.tsx | 122 +++++++++++++++++- pnpm-lock.yaml | 28 ++++ 7 files changed, 213 insertions(+), 21 deletions(-) diff --git a/package.json b/package.json index 4d2f607020..c8ebc7a2e8 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "@graphql-codegen/urql-introspection": "3.0.0", "@graphql-eslint/eslint-plugin": "3.20.1", "@graphql-inspector/cli": "4.0.3", + "@graphql-inspector/core": "^6.0.0", "@manypkg/get-packages": "2.2.2", "@next/eslint-plugin-next": "14.2.23", "@parcel/watcher": "2.5.0", diff --git a/packages/web/app/src/components/proposal/Review.tsx b/packages/web/app/src/components/proposal/Review.tsx index 7881b5cee7..25ae52b633 100644 --- a/packages/web/app/src/components/proposal/Review.tsx +++ b/packages/web/app/src/components/proposal/Review.tsx @@ -33,7 +33,7 @@ export function ReviewComments(props: {
{review.comments?.edges?.map(({ node: comment }, idx) => { return ( - + ); })}
diff --git a/packages/web/app/src/components/proposal/__tests__/collect-coordinate-locations.spec.ts b/packages/web/app/src/components/proposal/__tests__/collect-coordinate-locations.spec.ts index c4eb24d1dc..c1bec574a3 100644 --- a/packages/web/app/src/components/proposal/__tests__/collect-coordinate-locations.spec.ts +++ b/packages/web/app/src/components/proposal/__tests__/collect-coordinate-locations.spec.ts @@ -92,8 +92,8 @@ describe('schema coordinate location collection', () => { const coords = coordinatesFromSDL(sdl); expect(coords.get('Foo')).toBe(2); // @note The AST is limited and does not give the location of union values. - expect(coords.get('Foo.Bar')).toBe(2); - expect(coords.get('Foo.Blar')).toBe(2); + // expect(coords.get('Foo.Bar')).toBe(2); + // expect(coords.get('Foo.Blar')).toBe(2); }); test('subscriptions', () => { diff --git a/packages/web/app/src/components/proposal/collect-coordinate-locations.ts b/packages/web/app/src/components/proposal/collect-coordinate-locations.ts index 8ec6e13e51..9ec11cbe48 100644 --- a/packages/web/app/src/components/proposal/collect-coordinate-locations.ts +++ b/packages/web/app/src/components/proposal/collect-coordinate-locations.ts @@ -51,10 +51,10 @@ export function collectCoordinateLocations( const collectUnionType = (type: GraphQLUnionType) => { collect(type.name, type.astNode?.loc); - for (const unionType of type.getTypes()) { - const coordinate = `${type.name}.${unionType.name}`; - collect(coordinate, type.astNode?.loc); - } + // for (const unionType of type.getTypes()) { + // const coordinate = `${type.name}.${unionType.name}`; + // collect(coordinate, type.astNode?.loc); + // } }; const collectNamedType = (type: GraphQLNamedType) => { diff --git a/packages/web/app/src/components/proposal/proposal-sdl.tsx b/packages/web/app/src/components/proposal/proposal-sdl.tsx index 07961b3afc..5617cc1b02 100644 --- a/packages/web/app/src/components/proposal/proposal-sdl.tsx +++ b/packages/web/app/src/components/proposal/proposal-sdl.tsx @@ -1,4 +1,4 @@ -import { buildSchema, Source } from 'graphql'; +import { buildSchema, GraphQLSchema, Source } from 'graphql'; import { FragmentType, graphql, useFragment } from '@/gql'; import { ProposalOverview_ReviewsFragmentFragment } from '@/gql/graphql'; import { ChangeDocument, ChangeRow } from './change'; @@ -17,28 +17,65 @@ const ProposalOverview_ReviewsFragment = graphql(/** GraphQL */ ` schemaProposalVersion { id serviceName + schemaSDL } stageTransition lineNumber lineText schemaCoordinate ...ProposalOverview_ReviewCommentsFragment - schemaProposalVersion { - id - } } } } `); +// @todo should this be done on proposal update AND then the proposal can reference the check???? +// So it can get the changes + +// const ProposalOverview_CheckSchema = graphql(/** GraphQL */` +// mutation ProposalOverview_CheckSchema($target: TargetReferenceInput!, $sdl: String!, $service: ID) { +// schemaCheck(input: { +// target: $target +// sdl: $sdl +// service: $service +// }) { +// __typename +// ...on SchemaCheckSuccess { + +// } +// ...on SchemaCheckError { +// changes { +// edges { +// node { +// path +// } +// } +// } +// } +// } +// } +// `); + type ReviewNode = NonNullable[number]['node']; export function ProposalSDL(props: { + diffSdl: string; sdl: string; serviceName?: string; latestProposalVersionId: string; reviews: FragmentType | null; }) { + try { + void diff( + buildSchema(props.diffSdl, { assumeValid: true, assumeValidSDL: true }), + buildSchema(props.sdl, { assumeValid: true, assumeValidSDL: true }), + ).then((changes) => { + console.log('DIFF WORKED', changes); + }); + } catch (e) { + console.error(`Handled error ${e}`); + } + /** * Reviews can change position because the coordinate changes... placing them out of order from their original line numbers. * Because of this, we have to fetch every single page of comments... @@ -49,11 +86,24 @@ export function ProposalSDL(props: { const reviewsConnection = useFragment(ProposalOverview_ReviewsFragment, props.reviews); try { + let diffSchema: GraphQLSchema | undefined; + try { + diffSchema = buildSchema(props.diffSdl, { assumeValid: true, assumeValidSDL: true }); + } catch (e) { + console.error('Diff schema is invalid.') + } + + const schema = buildSchema(props.sdl, { assumeValid: true, assumeValidSDL: true }); const coordinateToLineMap = collectCoordinateLocations( - buildSchema(props.sdl, { assumeValid: true, assumeValidSDL: true }), + schema, new Source(props.sdl), ); + if (diffSchema) { + // @todo run schema check and get diff from that API.... That way usage can be checked easily. + void diff(diffSchema, schema, undefined) + } + // @note assume reviews are specific to the current service... const globalReviews: ReviewNode[] = []; const reviewsByLine = new Map(); @@ -76,13 +126,18 @@ export function ProposalSDL(props: { } } - // let nextReviewEdge = connection.edges?.pop(); + const diffSdlLines = props.diffSdl.split('\n'); + let diffLineNumber = 0; return ( <> {props.sdl.split('\n').flatMap((txt, index) => { const lineNumber = index + 1; - const elements = [{txt}]; + const diffLineMatch = txt === diffSdlLines[diffLineNumber]; + const elements = [{txt}]; + if (diffLineMatch) { + diffLineNumber = diffLineNumber + 1; + } const review = reviewsByLine.get(lineNumber) if (review) { diff --git a/packages/web/app/src/pages/target-proposal-overview.tsx b/packages/web/app/src/pages/target-proposal-overview.tsx index e4ab58a8bd..54ad66936f 100644 --- a/packages/web/app/src/pages/target-proposal-overview.tsx +++ b/packages/web/app/src/pages/target-proposal-overview.tsx @@ -5,9 +5,33 @@ import { Subtitle, Title } from '@/components/ui/page'; import { Spinner } from '@/components/ui/spinner'; import { Tag, TimeAgo } from '@/components/v2'; import { graphql } from '@/gql'; +import { Callout } from '@/components/ui/callout'; const ProposalOverviewQuery = graphql(/** GraphQL */ ` query ProposalOverviewQuery($id: ID!) { + latestVersion { + id + isValid + } + latestValidVersion { + id + sdl + schemas { + edges { + node { + ...on CompositeSchema { + id + source + service + } + ...on SingleSchema { + id + source + } + } + } + } + } schemaProposal(input: { id: $id }) { id createdAt @@ -54,7 +78,84 @@ export function TargetProposalOverviewPage(props: { const proposal = query.data?.schemaProposal; - const sdl = /** GraphQL */ ` + // const diffSdl = /** GraphQL */ ` + // extend schema + // @link( + // url: "https://specs.apollo.dev/federation/v2.3" + // import: ["@key", "@shareable", "@inaccessible", "@tag"] + // ) + // @link(url: "https://specs.graphql-hive.com/hive/v1.0", import: ["@meta"]) + // @meta(name: "priority", content: "tier1") + + // directive @meta( + // name: String! + // content: String! + // ) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + + // directive @myDirective(a: String!) on FIELD_DEFINITION + + // directive @hello on FIELD_DEFINITION + + // type Query { + // allProducts: [ProductItf] @meta(name: "owner", content: "hive-team") + // product(id: ID!): ProductItf + // } + + // interface ProductItf implements SkuItf @meta(name: "domain", content: "products") { + // id: ID! + // sku: String + // name: String + // package: String + // variation: ProductVariation + // dimensions: ProductDimension + // createdBy: User + // hidden: String @inaccessible + // oldField: String @deprecated(reason: "refactored out") + // } + + // interface SkuItf { + // sku: String + // } + + // type Product implements ProductItf & SkuItf + // @key(fields: "id") + // @key(fields: "sku package") + // @key(fields: "sku variation { id }") + // @meta(name: "owner", content: "product-team") { + // id: ID! @tag(name: "hi-from-products") + // sku: String @meta(name: "unique", content: "true") + // name: String @hello + // package: String + // variation: ProductVariation + // dimensions: ProductDimension + // createdBy: User + // hidden: String + // reviewsScore: Float! + // oldField: String + // } + + // enum ShippingClass { + // STANDARD + // EXPRESS + // } + + // type ProductVariation { + // id: ID! + // name: String + // } + + // type ProductDimension @shareable { + // size: String + // weight: Float + // } + + // type User @key(fields: "email") { + // email: ID! + // totalProductsCreated: Int @shareable + // } + // `; + + const sdl = /** GraphQL */` extend schema @link( url: "https://specs.apollo.dev/federation/v2.3" @@ -86,7 +187,6 @@ export function TargetProposalOverviewPage(props: { dimensions: ProductDimension createdBy: User hidden: String @inaccessible - oldField: String @deprecated(reason: "refactored out") } interface SkuItf { @@ -96,7 +196,6 @@ export function TargetProposalOverviewPage(props: { type Product implements ProductItf & SkuItf @key(fields: "id") @key(fields: "sku package") - @key(fields: "sku variation { id }") @meta(name: "owner", content: "product-team") { id: ID! @tag(name: "hi-from-products") sku: String @meta(name: "unique", content: "true") @@ -107,12 +206,12 @@ export function TargetProposalOverviewPage(props: { createdBy: User hidden: String reviewsScore: Float! - oldField: String } enum ShippingClass { STANDARD EXPRESS + OVERNIGHT } type ProductVariation { @@ -129,7 +228,6 @@ export function TargetProposalOverviewPage(props: { email: ID! totalProductsCreated: Int @shareable } - `; return ( @@ -147,8 +245,18 @@ export function TargetProposalOverviewPage(props: {
Last updated
- {/* @todo */} - + {query.data?.latestVersion && query.data.latestVersion.isValid === false && ( + + The latest schema is invalid. Showing comparison against latest valid schema {query.data.latestValidVersion?.id} + + )} + )}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3ef923ca6c..0c152b465a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -121,6 +121,9 @@ importers: '@graphql-inspector/cli': specifier: 4.0.3 version: 4.0.3(@types/node@22.10.5)(encoding@0.1.13)(graphql@16.9.0) + '@graphql-inspector/core': + specifier: ^6.0.0 + version: 6.2.1(graphql@16.9.0) '@manypkg/get-packages': specifier: 2.2.2 version: 2.2.2 @@ -3778,6 +3781,12 @@ packages: peerDependencies: graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 + '@graphql-inspector/core@6.2.1': + resolution: {integrity: sha512-PxL3fNblfKx/h/B4MIXN1yGHsGdY+uuySz8MAy/ogDk7eU1+va2zDZicLMEBHf7nsKfHWCAN1WFtD1GQP824NQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 + '@graphql-inspector/coverage-command@5.0.3': resolution: {integrity: sha512-LeAsn9+LjyxCzRnDvcfnQT6I0cI8UWnjPIxDkHNlkJLB0YWUTD1Z73fpRdw+l2kbYgeoMLFOK8TmilJjFN1+qQ==} engines: {node: '>=16.0.0'} @@ -9589,6 +9598,10 @@ packages: resolution: {integrity: sha512-JeMq7fEshyepOWDfcfHK06N3MhyPhz++vtqWhMT5O9A3K42rdsEDpfdVqjaqaAhsw6a+ZqeDvQVtD0hFHQWrzg==} engines: {node: '>= 0.6.0'} + dependency-graph@1.0.0: + resolution: {integrity: sha512-cW3gggJ28HZ/LExwxP2B++aiKxhJXMSIt9K48FOXQkm+vuG5gyatXnLsONRJdzO/7VfjDIiaOOa/bs4l464Lwg==} + engines: {node: '>=4'} + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -12974,6 +12987,10 @@ packages: object-inspect@1.13.1: resolution: {integrity: sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==} + object-inspect@1.13.2: + resolution: {integrity: sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g==} + engines: {node: '>= 0.4'} + object-is@1.1.5: resolution: {integrity: sha512-3cyDsyHgtmi7I7DfSSI2LDp6SK2lwvtbg0p0R1e0RvTqF5ceGx+K2dfSjm1bKDMVCFEDAQvy+o8c6a7VujOddw==} engines: {node: '>= 0.4'} @@ -18967,6 +18984,13 @@ snapshots: object-inspect: 1.12.3 tslib: 2.6.2 + '@graphql-inspector/core@6.2.1(graphql@16.9.0)': + dependencies: + dependency-graph: 1.0.0 + graphql: 16.9.0 + object-inspect: 1.13.2 + tslib: 2.6.2 + '@graphql-inspector/coverage-command@5.0.3(@graphql-inspector/config@4.0.2(graphql@16.9.0))(@graphql-inspector/loaders@4.0.3(@babel/core@7.22.9)(@graphql-inspector/config@4.0.2(graphql@16.9.0))(graphql@16.9.0))(graphql@16.9.0)(yargs@17.7.2)': dependencies: '@graphql-inspector/commands': 4.0.3(@graphql-inspector/config@4.0.2(graphql@16.9.0))(@graphql-inspector/loaders@4.0.3(@babel/core@7.22.9)(@graphql-inspector/config@4.0.2(graphql@16.9.0))(graphql@16.9.0))(graphql@16.9.0)(yargs@17.7.2) @@ -26550,6 +26574,8 @@ snapshots: dependency-graph@0.11.0: {} + dependency-graph@1.0.0: {} + dequal@2.0.3: {} derive-valtio@0.1.0(valtio@1.13.2(@types/react@18.3.18)(react@18.3.1)): @@ -30952,6 +30978,8 @@ snapshots: object-inspect@1.13.1: {} + object-inspect@1.13.2: {} + object-is@1.1.5: dependencies: call-bind: 1.0.7 From ddc197ed3153fe5cd5e17dddd6150208641f4ba4 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Wed, 2 Jul 2025 10:11:46 -0700 Subject: [PATCH 13/54] WIP patch --- .../proposal/__tests__/diff.test.ts | 189 +++++++++ .../web/app/src/components/proposal/diff.ts | 377 ++++++++++++++++++ 2 files changed, 566 insertions(+) create mode 100644 packages/web/app/src/components/proposal/__tests__/diff.test.ts create mode 100644 packages/web/app/src/components/proposal/diff.ts diff --git a/packages/web/app/src/components/proposal/__tests__/diff.test.ts b/packages/web/app/src/components/proposal/__tests__/diff.test.ts new file mode 100644 index 0000000000..7f43c6bc58 --- /dev/null +++ b/packages/web/app/src/components/proposal/__tests__/diff.test.ts @@ -0,0 +1,189 @@ +import { buildSchema, printSchema, lexicographicSortSchema, GraphQLSchema } from 'graphql'; +import { diff } from '@graphql-inspector/core'; +import { patchSchema } from '../diff'; +// import { applyChanges } from '../diff'; + +function printSortedSchema(schema: GraphQLSchema) { + return printSchema(lexicographicSortSchema(schema)) +} + +const schemaA = buildSchema(/** GraphQL */` + extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.3" + import: ["@key", "@shareable", "@inaccessible", "@tag"] + ) + @link(url: "https://specs.graphql-hive.com/hive/v1.0", import: ["@meta"]) + @meta(name: "priority", content: "tier1") + + directive @meta( + name: String! + content: String! + ) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + + directive @myDirective(a: String!) on FIELD_DEFINITION + + directive @hello on FIELD_DEFINITION + + type Query { + allProducts: [ProductItf] @meta(name: "owner", content: "hive-team") + product(id: ID!): ProductItf + } + + interface ProductItf implements SkuItf @meta(name: "domain", content: "products") { + id: ID! + sku: String + name: String + package: String + variation: ProductVariation + dimensions: ProductDimension + createdBy: User + hidden: String @inaccessible + oldField: String @deprecated(reason: "refactored out") + } + + interface SkuItf { + sku: String + } + + type Product implements ProductItf & SkuItf + @key(fields: "id") + @key(fields: "sku package") + @key(fields: "sku variation { id }") + @meta(name: "owner", content: "product-team") { + id: ID! @tag(name: "hi-from-products") + sku: String @meta(name: "unique", content: "true") + name: String @hello + package: String + variation: ProductVariation + dimensions: ProductDimension + createdBy: User + hidden: String + reviewsScore: Float! + oldField: String + } + + enum ShippingClass { + STANDARD + EXPRESS + } + + type ProductVariation { + id: ID! + name: String + } + + type ProductDimension @shareable { + size: String + weight: Float + } + + type User @key(fields: "email") { + email: ID! + totalProductsCreated: Int @shareable + } +`, {assumeValid: true, assumeValidSDL: true}); + +const schemaB = buildSchema(/** GraphQL */` + extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.3" + import: ["@key", "@shareable", "@inaccessible", "@tag"] + ) + @link(url: "https://specs.graphql-hive.com/hive/v1.0", import: ["@meta"]) + @meta(name: "priority", content: "tier1") + + directive @meta( + name: String! + content: String! + ) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + + directive @myDirective(a: String!) on FIELD_DEFINITION + + directive @hello on FIELD_DEFINITION + + type Query { + allProducts: [ProductItf] @meta(name: "owner", content: "hive-team") + product(id: ID!): ProductItf + foo: AddedFoo + } + + interface ProductItf implements SkuItf @meta(name: "domain", content: "products") { + id: ID! + sku: String + name: String + package: String + variation: ProductVariation + dimensions: ProductDimension + hidden: String @inaccessible + } + + interface SkuItf { + sku: String + } + + type Product implements ProductItf & SkuItf + @key(fields: "id") + @key(fields: "sku package") + @key(fields: "sku variation { id }") + @meta(name: "owner", content: "product-team") { + id: ID! @tag(name: "hi-from-products") + sku: String @meta(name: "unique", content: "true") + name: String @hello + package: String + variation: ProductVariation + dimensions: ProductDimension + hidden: String + reviewsScore: Float! + oldField: String + } + + """ + New comment on ShippingClass + """ + enum ShippingClass { + STANDARD + + """ + Super duper fast + """ + EXPRESS + } + + type ProductVariation { + id: ID! + name: String + } + + type ProductDimension @shareable { + size: String + weight: Float + } + + """ + This is a comment on AddedFoo + """ + type AddedFoo { + id: ID! + bar: Bar + fooBar: FooBar + } + + enum Bar { + A + B + C + } + + scalar FooBar +`, { assumeValid: true, assumeValidSDL: true }); + +const editScript = await diff(schemaA, schemaB); + +test('patch', async () => { + expect(printSortedSchema(schemaB)).toBe(printSortedSchema(patchSchema(schemaA, editScript))); +}) + +// test.only('printDiff', () => { +// expect(printDiff(schemaB, applyChanges(schemaA, editScript))).toBe(''); +// }) \ No newline at end of file diff --git a/packages/web/app/src/components/proposal/diff.ts b/packages/web/app/src/components/proposal/diff.ts new file mode 100644 index 0000000000..a33d4bc780 --- /dev/null +++ b/packages/web/app/src/components/proposal/diff.ts @@ -0,0 +1,377 @@ +import { Change, SerializableChange, ChangeType } from '@graphql-inspector/core'; +import { GraphQLSchema, printSchema, parse, DocumentNode, DefinitionNode, Kind, buildASTSchema, NameNode, ObjectTypeDefinitionNode, FieldDefinitionNode, ASTKindToNode, InterfaceTypeDefinitionNode, SchemaDefinitionNode, SchemaExtensionNode, ExecutableDefinitionNode, print, InputValueDefinitionNode, StringValueNode, ObjectTypeExtensionNode, DirectiveDefinitionNode, TypeExtensionNode, TypeDefinitionNode, TypeSystemDefinitionNode, ValueNode, astFromValue, GraphQLString, TypeNode, InterfaceTypeExtensionNode, InputObjectTypeDefinitionNode, InputObjectTypeExtensionNode, UnionTypeDefinitionNode, UnionTypeExtensionNode, ScalarTypeDefinitionNode, ScalarTypeExtensionNode, EnumTypeDefinitionNode, EnumTypeExtensionNode, visit, parseType, ArgumentNode } from 'graphql'; + +export type TypeOfChangeType = (typeof ChangeType)[keyof typeof ChangeType]; + +function nameNode(name: string): NameNode { + return { + value: name, + kind: Kind.NAME, + }; +} + +export function patchSchema(schema: GraphQLSchema, changes: SerializableChange[]): GraphQLSchema { + const ast = parse(printSchema(schema)); + return buildASTSchema(patch(ast, changes), { assumeValid: true, assumeValidSDL: true }); +} + +function patchObjectType(node: ObjectTypeDefinitionNode | ObjectTypeExtensionNode | InterfaceTypeDefinitionNode | InterfaceTypeExtensionNode, changes: ChangesByType) { + /** REMOVE TYPES */ + const isRemoved = changes[ChangeType.TypeRemoved]?.find(change => change.meta.removedTypeName === node.name.value) ?? false; + if (isRemoved) { + return null; + } + + /** REMOVE FIELDS */ + const fieldRemovalsForType: Set = new Set( + (changes[ChangeType.FieldRemoved] ?? []) + .filter(change => change.meta.typeName === node.name.value) + // @note consider using more of the metadata OR pre-mapping the removed fields to avoid having to map for every type's list. + .map(f => f.meta.removedFieldName), + ); + + if (fieldRemovalsForType.size) { + (node.fields as any) = node.fields?.filter(f => !fieldRemovalsForType.has(f.name.value)); + } + + /** ADD FIELDS */ + const addedFields = changes[ChangeType.FieldAdded]?.filter(change => change.meta.typeName === node.name.value).map((change) => { + const fieldDefinitionNode: FieldDefinitionNode = { + kind: Kind.FIELD_DEFINITION, + name: nameNode(change.meta.addedFieldName), + // @todo typeType is bugged atm. Fix this. Prefer adding a new `addedFieldType` field. + // type: parseType(change.meta.typeType), + type: parseType('String'), + + }; + return fieldDefinitionNode; + }) + + if (addedFields?.length) { + (node.fields as any) = [...(node.fields ??[]), ...addedFields]; + } + + /** Patch fields */ + (node.fields as any) = node.fields?.map(field => patchField(node.name.value, field, changes)); + + return node; +} + +function patchField(typeName: string, field: FieldDefinitionNode, changes: ChangesByType) { + { + const change = changes[ChangeType.FieldDescriptionAdded]?.findLast(change => change.meta.typeName === typeName && field.name.value === change.meta.fieldName); + if (change && field.description) { + ((field.description as StringValueNode).value as any) = change.meta.addedDescription; + } + } + + { + const change = changes[ChangeType.FieldDescriptionChanged]?.findLast(change => change.meta.typeName === typeName && field.name.value === change.meta.fieldName); + if (change && field.description) { + ((field.description as StringValueNode).value as any) = change.meta.newDescription; + } + } + + { + const change = changes[ChangeType.FieldDescriptionRemoved]?.findLast(change => change.meta.typeName === typeName && field.name.value === change.meta.fieldName); + if (change && field.description) { + (field.description as StringValueNode | undefined) = undefined; + } + } + + { + const fieldChanges = changes[ChangeType.FieldArgumentAdded]?.filter(change => + change.meta.typeName === typeName && field.name.value === change.meta.fieldName, + ); + if (fieldChanges) { + const argumentAdditions = fieldChanges.map((change): InputValueDefinitionNode => ({ + kind: Kind.INPUT_VALUE_DEFINITION, + name: nameNode(change.meta.addedArgumentName), + type: parseType(change.meta.addedArgumentType), + // defaultValue: change.meta.hasDefaultValue ? + })); + (field.arguments as InputValueDefinitionNode[] | undefined) = [...(field.arguments ??[]), ...argumentAdditions]; + } + } + + (field.arguments as InputValueDefinitionNode[] | undefined) = field.arguments?.map((argumentNode => patchFieldArgument(typeName, field.name.value, argumentNode, changes))); + + return field; +} + +function patchFieldArgument(typeName: string, fieldName: string, arg: InputValueDefinitionNode, changes: ChangesByType) { + const descriptionChanges = (changes[ChangeType.FieldArgumentDescriptionChanged] ?? []).filter(change => change.meta.typeName === typeName && change.meta.fieldName === fieldName); + for (const change of descriptionChanges) { + if (arg.description?.value !== (change.meta.oldDescription ?? undefined)) { + console.warn('Conflict: Description does not match previous change description.'); + continue; + } + (arg.description as StringValueNode | undefined) = change.meta.newDescription ? { + kind: Kind.STRING, + ...arg.description, + value: change.meta.newDescription, + } : undefined; + } + + return arg; +} + +function patchInputObject(node: InputObjectTypeDefinitionNode | InputObjectTypeExtensionNode, changes: ChangesByType) { + return node; +} + +function patchUnionType(node: UnionTypeDefinitionNode | UnionTypeExtensionNode, changes: ChangesByType) { + return node; +} + +function patchScalarType(node: ScalarTypeDefinitionNode | ScalarTypeExtensionNode, changes: ChangesByType) { + return node; +} + +function patchEnumType(node: EnumTypeDefinitionNode | EnumTypeExtensionNode, changes: ChangesByType) { + return node; +} + +function patchDirective(node: DirectiveDefinitionNode, changes: ChangesByType) { + return node; +} + +// function patchField(typeNode: ObjectTypeDefinitionNode | ObjectTypeExtensionNode, fieldName: string, patch: (field: FieldDefinitionNode) => void) { +// switch (typeNode.kind) { +// // case Kind.DIRECTIVE_DEFINITION: { +// // const arg = typeNode.arguments?.find(a => a.name.value === argumentName); +// // if (!arg) { +// // console.warn(`Conflict: Cannot patch missing argument ${argumentName}`) +// // break; +// // } +// // patch(arg); +// // break; +// // } +// case Kind.OBJECT_TYPE_DEFINITION: +// case Kind.OBJECT_TYPE_EXTENSION: { +// const field = (typeNode.fields ?? []).find(f => f.name.value === fieldName); +// if (field) { +// patch(field); +// } else { +// console.warn(`Conflict: Cannot patch missing field ${typeNode.name.value}.${fieldName}`); +// } +// break; +// } +// } +// } + +// function patchFieldArgument(typeNode: ObjectTypeDefinitionNode | ObjectTypeExtensionNode, fieldName: string, argumentName: string, patch: (arg: InputValueDefinitionNode) => void) { +// patchField(typeNode, fieldName, (field) => { +// const arg = field?.arguments?.find(a => a.name.value === argumentName); +// if (arg) { +// patch(arg); +// } else { +// console.warn(`Conflict: Cannot patch missing argument ${argumentName}`) +// } +// }) +// } + +type ChangesByType = { [key in TypeOfChangeType]?: Array> }; + +export function patch(ast: DocumentNode, changes: SerializableChange[]): DocumentNode { + // const [schemaDefs, nodesByName] = collectDefinitions(ast); + const changesByType: ChangesByType = {}; + for (const change of changes) { + changesByType[change.type] ??= []; + changesByType[change.type]?.push(change as any); + } + + const result = visit(ast, { + ObjectTypeDefinition: (node) => patchObjectType(node, changesByType), + ObjectTypeExtension: (node) => patchObjectType(node, changesByType), + InterfaceTypeDefinition: (node) => patchObjectType(node, changesByType), + InterfaceTypeExtension: (node) => patchObjectType(node, changesByType), + InputObjectTypeDefinition: (node) => patchInputObject(node, changesByType), + InputObjectTypeExtension: (node) => patchInputObject(node, changesByType), + UnionTypeDefinition: (node) => patchUnionType(node, changesByType), + UnionTypeExtension: (node) => patchUnionType(node, changesByType), + ScalarTypeDefinition: (node) => patchScalarType(node, changesByType), + ScalarTypeExtension: (node) => patchScalarType(node, changesByType), + EnumTypeDefinition: (node) => patchEnumType(node, changesByType), + EnumTypeExtension: (node) => patchEnumType(node, changesByType), + DirectiveDefinition: (node) => patchDirective(node, changesByType), + SchemaDefinition: (node) => node, + SchemaExtension: (node) => node, + }); + + return { + ...result, + definitions: [ + ...result.definitions, + + /** ADD TYPES */ + ...(changesByType[ChangeType.TypeAdded] ?? []) + // @todo consider what to do for types that already exist. + .map(addition =>({ + kind: Kind.OBJECT_TYPE_DEFINITION, + fields: [], + name: nameNode(addition.meta.addedTypeName), + } as ObjectTypeDefinitionNode)), + ], + }; + + // ------------------- + + // const getTypeNodeSafe = (typeName: string, kinds: K[]): ASTKindToNode[K] | undefined => { + // const typeNode = nodesByName.get(typeName); + // if (!typeNode) { + // console.warn(`Conflict: Cannot apply change to missing type ${typeName}`) + // return; + // } + + // if (typeNode.kind in kinds) { + // return typeNode as ASTKindToNode[K]; + // } + + // console.warn(`Conflict: Cannot apply change. Type ${typeName} node is an unexpected kind ${typeNode.kind}`); + // } + // for (const change of changes) { + // switch (change.type) { + // case 'FIELD_ARGUMENT_DESCRIPTION_CHANGED': { + + // break; + // } + // case 'FIELD_ARGUMENT_DEFAULT_CHANGED': { + // const typeNode = getTypeNodeSafe(change.meta.typeName, [Kind.OBJECT_TYPE_DEFINITION, Kind.OBJECT_TYPE_EXTENSION]) + // if (typeNode) { + // patchFieldArgument(typeNode, change.meta.fieldName, change.meta.argumentName, (arg) => { + // if (arg.defaultValue === undefined) { + // if (change.meta.oldDefaultValue !== undefined) { + // console.warn(`Conflict: Default value "${arg.defaultValue}" does not match previous change default value of "${change.meta.oldDefaultValue}".`); + // return; + // } + // } else if (print(arg.defaultValue) !== (change.meta.oldDefaultValue)) { + // console.warn(`Conflict: Default value "${print(arg.defaultValue)}" does not match previous change default value of "${change.meta.oldDefaultValue}".`); + // return; + // } + + // const defaultValue = change.meta.newDefaultValue ? astFromValue(change.meta.newDefaultValue, GraphQLString) : undefined; + // ((arg as InputValueDefinitionNode).defaultValue as ValueNode | undefined) = defaultValue ?? undefined; + // }); + // } + // break; + // } + // case 'FIELD_ARGUMENT_TYPE_CHANGED': { + // const typeNode = getTypeNodeSafe(change.meta.typeName, [Kind.OBJECT_TYPE_DEFINITION, Kind.OBJECT_TYPE_EXTENSION]) + // if (typeNode) { + // patchFieldArgument(typeNode, change.meta.fieldName, change.meta.argumentName, (arg) => { + // if (print(arg.type) !== (change.meta.oldArgumentType)) { + // console.warn(`Conflict: Argument ${location} type "${print(arg.type)}" does not match previous change type of "${change.meta.oldArgumentType}".`); + // return; + // } + + // const argType = getTypeNodeSafe(change.meta.newArgumentType, [Kind.NAMED_TYPE, Kind.LIST_TYPE, Kind.NON_NULL_TYPE]); + // if (argType) { + // ((arg as InputValueDefinitionNode).type as TypeNode) = argType; + // } else { + // console.warn(`Conflict: Argument type cannot be changed to missing type "${change.meta.newArgumentType}"`) + // } + // }); + // } + // break; + // } + // case 'DIRECTIVE_REMOVED': + // case 'DIRECTIVE_ADDED': + // case 'DIRECTIVE_DESCRIPTION_CHANGED': + // case 'DIRECTIVE_LOCATION_ADDED': + // case 'DIRECTIVE_LOCATION_REMOVED': + // case 'DIRECTIVE_ARGUMENT_ADDED': + // case 'DIRECTIVE_ARGUMENT_REMOVED': + // case 'DIRECTIVE_ARGUMENT_DESCRIPTION_CHANGED': + // case 'DIRECTIVE_ARGUMENT_DEFAULT_VALUE_CHANGED': + // case 'DIRECTIVE_ARGUMENT_TYPE_CHANGED': + // case 'ENUM_VALUE_REMOVED': + // case 'ENUM_VALUE_ADDED': + // case 'ENUM_VALUE_DESCRIPTION_CHANGED': + // case 'ENUM_VALUE_DEPRECATION_REASON_CHANGED': + // case 'ENUM_VALUE_DEPRECATION_REASON_ADDED': + // case 'ENUM_VALUE_DEPRECATION_REASON_REMOVED': + // case 'FIELD_REMOVED': + // case 'FIELD_ADDED': + // case 'FIELD_DESCRIPTION_CHANGED': + // case 'FIELD_DESCRIPTION_ADDED': + // case 'FIELD_DESCRIPTION_REMOVED': + // case 'FIELD_DEPRECATION_ADDED': + // case 'FIELD_DEPRECATION_REMOVED': + // case 'FIELD_DEPRECATION_REASON_CHANGED': + // case 'FIELD_DEPRECATION_REASON_ADDED': + // case 'FIELD_DEPRECATION_REASON_REMOVED': + // case 'FIELD_TYPE_CHANGED': + // case 'FIELD_ARGUMENT_ADDED': + // case 'FIELD_ARGUMENT_REMOVED': + // case 'INPUT_FIELD_REMOVED': + // case 'INPUT_FIELD_ADDED': + // case 'INPUT_FIELD_DESCRIPTION_ADDED': + // case 'INPUT_FIELD_DESCRIPTION_REMOVED': + // case 'INPUT_FIELD_DESCRIPTION_CHANGED': + // case 'INPUT_FIELD_DEFAULT_VALUE_CHANGED': + // case 'INPUT_FIELD_TYPE_CHANGED': + // case 'OBJECT_TYPE_INTERFACE_ADDED': + // case 'OBJECT_TYPE_INTERFACE_REMOVED': + // case 'SCHEMA_QUERY_TYPE_CHANGED': + // case 'SCHEMA_MUTATION_TYPE_CHANGED': + // case 'SCHEMA_SUBSCRIPTION_TYPE_CHANGED': + // case 'TYPE_REMOVED': + // case 'TYPE_ADDED': + // case 'TYPE_KIND_CHANGED': + // case 'TYPE_DESCRIPTION_CHANGED': + // case 'TYPE_DESCRIPTION_REMOVED': + // case 'TYPE_DESCRIPTION_ADDED': + // case 'UNION_MEMBER_REMOVED': + // case 'UNION_MEMBER_ADDED': + // case 'DIRECTIVE_USAGE_UNION_MEMBER_ADDED': + // case 'DIRECTIVE_USAGE_UNION_MEMBER_REMOVED': + // case 'DIRECTIVE_USAGE_ENUM_ADDED': + // case 'DIRECTIVE_USAGE_ENUM_REMOVED': + // case 'DIRECTIVE_USAGE_ENUM_VALUE_ADDED': + // case 'DIRECTIVE_USAGE_ENUM_VALUE_REMOVED': + // case 'DIRECTIVE_USAGE_INPUT_OBJECT_ADDED': + // case 'DIRECTIVE_USAGE_INPUT_OBJECT_REMOVED': + // case 'DIRECTIVE_USAGE_FIELD_ADDED': + // case 'DIRECTIVE_USAGE_FIELD_REMOVED': + // case 'DIRECTIVE_USAGE_SCALAR_ADDED': + // case 'DIRECTIVE_USAGE_SCALAR_REMOVED': + // case 'DIRECTIVE_USAGE_OBJECT_ADDED': + // case 'DIRECTIVE_USAGE_OBJECT_REMOVED': + // case 'DIRECTIVE_USAGE_INTERFACE_ADDED': + // case 'DIRECTIVE_USAGE_INTERFACE_REMOVED': + // case 'DIRECTIVE_USAGE_ARGUMENT_DEFINITION_ADDED': + // case 'DIRECTIVE_USAGE_ARGUMENT_DEFINITION_REMOVED': + // case 'DIRECTIVE_USAGE_SCHEMA_ADDED': + // case 'DIRECTIVE_USAGE_SCHEMA_REMOVED': + // case 'DIRECTIVE_USAGE_FIELD_DEFINITION_ADDED': + // case 'DIRECTIVE_USAGE_FIELD_DEFINITION_REMOVED': + // case 'DIRECTIVE_USAGE_INPUT_FIELD_DEFINITION_ADDED': + // case 'DIRECTIVE_USAGE_INPUT_FIELD_DEFINITION_REMOVED': { + // break; + // } + // default: { + // console.error('Unhandled change type. Check package version compatibility.') + // } + // } + // } + + // const newAst: DocumentNode = { + // kind: Kind.DOCUMENT, + // definitions: [...schemaDefs, ...nodesByName.values()], + // }; + // return newAst; +} + +// export function printDiff(before: DocumentNode, changes: SerializableChange[]): string { +// // WHAT IF +// /** +// * modify ast to include a flag for added, removed, or updated/moved?... +// * add everything to the AST and print that as a schema.. (but it wont print duplicate field names etc right) +// * ... So write a custom printer that solves^ +// * +// * HOW do keep the removed node around and not mess up the AST?... +// */ + +// return ''; +// } \ No newline at end of file From c04ea8e46acc2e7b581c6e550f68dbf5d21d0ca5 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Wed, 2 Jul 2025 16:03:17 -0700 Subject: [PATCH 14/54] WIP --- .../web/app/src/components/proposal/diff.ts | 377 ----------------- .../{ => patch}/__tests__/diff.test.ts | 2 +- .../app/src/components/proposal/patch/diff.ts | 393 ++++++++++++++++++ 3 files changed, 394 insertions(+), 378 deletions(-) delete mode 100644 packages/web/app/src/components/proposal/diff.ts rename packages/web/app/src/components/proposal/{ => patch}/__tests__/diff.test.ts (98%) create mode 100644 packages/web/app/src/components/proposal/patch/diff.ts diff --git a/packages/web/app/src/components/proposal/diff.ts b/packages/web/app/src/components/proposal/diff.ts deleted file mode 100644 index a33d4bc780..0000000000 --- a/packages/web/app/src/components/proposal/diff.ts +++ /dev/null @@ -1,377 +0,0 @@ -import { Change, SerializableChange, ChangeType } from '@graphql-inspector/core'; -import { GraphQLSchema, printSchema, parse, DocumentNode, DefinitionNode, Kind, buildASTSchema, NameNode, ObjectTypeDefinitionNode, FieldDefinitionNode, ASTKindToNode, InterfaceTypeDefinitionNode, SchemaDefinitionNode, SchemaExtensionNode, ExecutableDefinitionNode, print, InputValueDefinitionNode, StringValueNode, ObjectTypeExtensionNode, DirectiveDefinitionNode, TypeExtensionNode, TypeDefinitionNode, TypeSystemDefinitionNode, ValueNode, astFromValue, GraphQLString, TypeNode, InterfaceTypeExtensionNode, InputObjectTypeDefinitionNode, InputObjectTypeExtensionNode, UnionTypeDefinitionNode, UnionTypeExtensionNode, ScalarTypeDefinitionNode, ScalarTypeExtensionNode, EnumTypeDefinitionNode, EnumTypeExtensionNode, visit, parseType, ArgumentNode } from 'graphql'; - -export type TypeOfChangeType = (typeof ChangeType)[keyof typeof ChangeType]; - -function nameNode(name: string): NameNode { - return { - value: name, - kind: Kind.NAME, - }; -} - -export function patchSchema(schema: GraphQLSchema, changes: SerializableChange[]): GraphQLSchema { - const ast = parse(printSchema(schema)); - return buildASTSchema(patch(ast, changes), { assumeValid: true, assumeValidSDL: true }); -} - -function patchObjectType(node: ObjectTypeDefinitionNode | ObjectTypeExtensionNode | InterfaceTypeDefinitionNode | InterfaceTypeExtensionNode, changes: ChangesByType) { - /** REMOVE TYPES */ - const isRemoved = changes[ChangeType.TypeRemoved]?.find(change => change.meta.removedTypeName === node.name.value) ?? false; - if (isRemoved) { - return null; - } - - /** REMOVE FIELDS */ - const fieldRemovalsForType: Set = new Set( - (changes[ChangeType.FieldRemoved] ?? []) - .filter(change => change.meta.typeName === node.name.value) - // @note consider using more of the metadata OR pre-mapping the removed fields to avoid having to map for every type's list. - .map(f => f.meta.removedFieldName), - ); - - if (fieldRemovalsForType.size) { - (node.fields as any) = node.fields?.filter(f => !fieldRemovalsForType.has(f.name.value)); - } - - /** ADD FIELDS */ - const addedFields = changes[ChangeType.FieldAdded]?.filter(change => change.meta.typeName === node.name.value).map((change) => { - const fieldDefinitionNode: FieldDefinitionNode = { - kind: Kind.FIELD_DEFINITION, - name: nameNode(change.meta.addedFieldName), - // @todo typeType is bugged atm. Fix this. Prefer adding a new `addedFieldType` field. - // type: parseType(change.meta.typeType), - type: parseType('String'), - - }; - return fieldDefinitionNode; - }) - - if (addedFields?.length) { - (node.fields as any) = [...(node.fields ??[]), ...addedFields]; - } - - /** Patch fields */ - (node.fields as any) = node.fields?.map(field => patchField(node.name.value, field, changes)); - - return node; -} - -function patchField(typeName: string, field: FieldDefinitionNode, changes: ChangesByType) { - { - const change = changes[ChangeType.FieldDescriptionAdded]?.findLast(change => change.meta.typeName === typeName && field.name.value === change.meta.fieldName); - if (change && field.description) { - ((field.description as StringValueNode).value as any) = change.meta.addedDescription; - } - } - - { - const change = changes[ChangeType.FieldDescriptionChanged]?.findLast(change => change.meta.typeName === typeName && field.name.value === change.meta.fieldName); - if (change && field.description) { - ((field.description as StringValueNode).value as any) = change.meta.newDescription; - } - } - - { - const change = changes[ChangeType.FieldDescriptionRemoved]?.findLast(change => change.meta.typeName === typeName && field.name.value === change.meta.fieldName); - if (change && field.description) { - (field.description as StringValueNode | undefined) = undefined; - } - } - - { - const fieldChanges = changes[ChangeType.FieldArgumentAdded]?.filter(change => - change.meta.typeName === typeName && field.name.value === change.meta.fieldName, - ); - if (fieldChanges) { - const argumentAdditions = fieldChanges.map((change): InputValueDefinitionNode => ({ - kind: Kind.INPUT_VALUE_DEFINITION, - name: nameNode(change.meta.addedArgumentName), - type: parseType(change.meta.addedArgumentType), - // defaultValue: change.meta.hasDefaultValue ? - })); - (field.arguments as InputValueDefinitionNode[] | undefined) = [...(field.arguments ??[]), ...argumentAdditions]; - } - } - - (field.arguments as InputValueDefinitionNode[] | undefined) = field.arguments?.map((argumentNode => patchFieldArgument(typeName, field.name.value, argumentNode, changes))); - - return field; -} - -function patchFieldArgument(typeName: string, fieldName: string, arg: InputValueDefinitionNode, changes: ChangesByType) { - const descriptionChanges = (changes[ChangeType.FieldArgumentDescriptionChanged] ?? []).filter(change => change.meta.typeName === typeName && change.meta.fieldName === fieldName); - for (const change of descriptionChanges) { - if (arg.description?.value !== (change.meta.oldDescription ?? undefined)) { - console.warn('Conflict: Description does not match previous change description.'); - continue; - } - (arg.description as StringValueNode | undefined) = change.meta.newDescription ? { - kind: Kind.STRING, - ...arg.description, - value: change.meta.newDescription, - } : undefined; - } - - return arg; -} - -function patchInputObject(node: InputObjectTypeDefinitionNode | InputObjectTypeExtensionNode, changes: ChangesByType) { - return node; -} - -function patchUnionType(node: UnionTypeDefinitionNode | UnionTypeExtensionNode, changes: ChangesByType) { - return node; -} - -function patchScalarType(node: ScalarTypeDefinitionNode | ScalarTypeExtensionNode, changes: ChangesByType) { - return node; -} - -function patchEnumType(node: EnumTypeDefinitionNode | EnumTypeExtensionNode, changes: ChangesByType) { - return node; -} - -function patchDirective(node: DirectiveDefinitionNode, changes: ChangesByType) { - return node; -} - -// function patchField(typeNode: ObjectTypeDefinitionNode | ObjectTypeExtensionNode, fieldName: string, patch: (field: FieldDefinitionNode) => void) { -// switch (typeNode.kind) { -// // case Kind.DIRECTIVE_DEFINITION: { -// // const arg = typeNode.arguments?.find(a => a.name.value === argumentName); -// // if (!arg) { -// // console.warn(`Conflict: Cannot patch missing argument ${argumentName}`) -// // break; -// // } -// // patch(arg); -// // break; -// // } -// case Kind.OBJECT_TYPE_DEFINITION: -// case Kind.OBJECT_TYPE_EXTENSION: { -// const field = (typeNode.fields ?? []).find(f => f.name.value === fieldName); -// if (field) { -// patch(field); -// } else { -// console.warn(`Conflict: Cannot patch missing field ${typeNode.name.value}.${fieldName}`); -// } -// break; -// } -// } -// } - -// function patchFieldArgument(typeNode: ObjectTypeDefinitionNode | ObjectTypeExtensionNode, fieldName: string, argumentName: string, patch: (arg: InputValueDefinitionNode) => void) { -// patchField(typeNode, fieldName, (field) => { -// const arg = field?.arguments?.find(a => a.name.value === argumentName); -// if (arg) { -// patch(arg); -// } else { -// console.warn(`Conflict: Cannot patch missing argument ${argumentName}`) -// } -// }) -// } - -type ChangesByType = { [key in TypeOfChangeType]?: Array> }; - -export function patch(ast: DocumentNode, changes: SerializableChange[]): DocumentNode { - // const [schemaDefs, nodesByName] = collectDefinitions(ast); - const changesByType: ChangesByType = {}; - for (const change of changes) { - changesByType[change.type] ??= []; - changesByType[change.type]?.push(change as any); - } - - const result = visit(ast, { - ObjectTypeDefinition: (node) => patchObjectType(node, changesByType), - ObjectTypeExtension: (node) => patchObjectType(node, changesByType), - InterfaceTypeDefinition: (node) => patchObjectType(node, changesByType), - InterfaceTypeExtension: (node) => patchObjectType(node, changesByType), - InputObjectTypeDefinition: (node) => patchInputObject(node, changesByType), - InputObjectTypeExtension: (node) => patchInputObject(node, changesByType), - UnionTypeDefinition: (node) => patchUnionType(node, changesByType), - UnionTypeExtension: (node) => patchUnionType(node, changesByType), - ScalarTypeDefinition: (node) => patchScalarType(node, changesByType), - ScalarTypeExtension: (node) => patchScalarType(node, changesByType), - EnumTypeDefinition: (node) => patchEnumType(node, changesByType), - EnumTypeExtension: (node) => patchEnumType(node, changesByType), - DirectiveDefinition: (node) => patchDirective(node, changesByType), - SchemaDefinition: (node) => node, - SchemaExtension: (node) => node, - }); - - return { - ...result, - definitions: [ - ...result.definitions, - - /** ADD TYPES */ - ...(changesByType[ChangeType.TypeAdded] ?? []) - // @todo consider what to do for types that already exist. - .map(addition =>({ - kind: Kind.OBJECT_TYPE_DEFINITION, - fields: [], - name: nameNode(addition.meta.addedTypeName), - } as ObjectTypeDefinitionNode)), - ], - }; - - // ------------------- - - // const getTypeNodeSafe = (typeName: string, kinds: K[]): ASTKindToNode[K] | undefined => { - // const typeNode = nodesByName.get(typeName); - // if (!typeNode) { - // console.warn(`Conflict: Cannot apply change to missing type ${typeName}`) - // return; - // } - - // if (typeNode.kind in kinds) { - // return typeNode as ASTKindToNode[K]; - // } - - // console.warn(`Conflict: Cannot apply change. Type ${typeName} node is an unexpected kind ${typeNode.kind}`); - // } - // for (const change of changes) { - // switch (change.type) { - // case 'FIELD_ARGUMENT_DESCRIPTION_CHANGED': { - - // break; - // } - // case 'FIELD_ARGUMENT_DEFAULT_CHANGED': { - // const typeNode = getTypeNodeSafe(change.meta.typeName, [Kind.OBJECT_TYPE_DEFINITION, Kind.OBJECT_TYPE_EXTENSION]) - // if (typeNode) { - // patchFieldArgument(typeNode, change.meta.fieldName, change.meta.argumentName, (arg) => { - // if (arg.defaultValue === undefined) { - // if (change.meta.oldDefaultValue !== undefined) { - // console.warn(`Conflict: Default value "${arg.defaultValue}" does not match previous change default value of "${change.meta.oldDefaultValue}".`); - // return; - // } - // } else if (print(arg.defaultValue) !== (change.meta.oldDefaultValue)) { - // console.warn(`Conflict: Default value "${print(arg.defaultValue)}" does not match previous change default value of "${change.meta.oldDefaultValue}".`); - // return; - // } - - // const defaultValue = change.meta.newDefaultValue ? astFromValue(change.meta.newDefaultValue, GraphQLString) : undefined; - // ((arg as InputValueDefinitionNode).defaultValue as ValueNode | undefined) = defaultValue ?? undefined; - // }); - // } - // break; - // } - // case 'FIELD_ARGUMENT_TYPE_CHANGED': { - // const typeNode = getTypeNodeSafe(change.meta.typeName, [Kind.OBJECT_TYPE_DEFINITION, Kind.OBJECT_TYPE_EXTENSION]) - // if (typeNode) { - // patchFieldArgument(typeNode, change.meta.fieldName, change.meta.argumentName, (arg) => { - // if (print(arg.type) !== (change.meta.oldArgumentType)) { - // console.warn(`Conflict: Argument ${location} type "${print(arg.type)}" does not match previous change type of "${change.meta.oldArgumentType}".`); - // return; - // } - - // const argType = getTypeNodeSafe(change.meta.newArgumentType, [Kind.NAMED_TYPE, Kind.LIST_TYPE, Kind.NON_NULL_TYPE]); - // if (argType) { - // ((arg as InputValueDefinitionNode).type as TypeNode) = argType; - // } else { - // console.warn(`Conflict: Argument type cannot be changed to missing type "${change.meta.newArgumentType}"`) - // } - // }); - // } - // break; - // } - // case 'DIRECTIVE_REMOVED': - // case 'DIRECTIVE_ADDED': - // case 'DIRECTIVE_DESCRIPTION_CHANGED': - // case 'DIRECTIVE_LOCATION_ADDED': - // case 'DIRECTIVE_LOCATION_REMOVED': - // case 'DIRECTIVE_ARGUMENT_ADDED': - // case 'DIRECTIVE_ARGUMENT_REMOVED': - // case 'DIRECTIVE_ARGUMENT_DESCRIPTION_CHANGED': - // case 'DIRECTIVE_ARGUMENT_DEFAULT_VALUE_CHANGED': - // case 'DIRECTIVE_ARGUMENT_TYPE_CHANGED': - // case 'ENUM_VALUE_REMOVED': - // case 'ENUM_VALUE_ADDED': - // case 'ENUM_VALUE_DESCRIPTION_CHANGED': - // case 'ENUM_VALUE_DEPRECATION_REASON_CHANGED': - // case 'ENUM_VALUE_DEPRECATION_REASON_ADDED': - // case 'ENUM_VALUE_DEPRECATION_REASON_REMOVED': - // case 'FIELD_REMOVED': - // case 'FIELD_ADDED': - // case 'FIELD_DESCRIPTION_CHANGED': - // case 'FIELD_DESCRIPTION_ADDED': - // case 'FIELD_DESCRIPTION_REMOVED': - // case 'FIELD_DEPRECATION_ADDED': - // case 'FIELD_DEPRECATION_REMOVED': - // case 'FIELD_DEPRECATION_REASON_CHANGED': - // case 'FIELD_DEPRECATION_REASON_ADDED': - // case 'FIELD_DEPRECATION_REASON_REMOVED': - // case 'FIELD_TYPE_CHANGED': - // case 'FIELD_ARGUMENT_ADDED': - // case 'FIELD_ARGUMENT_REMOVED': - // case 'INPUT_FIELD_REMOVED': - // case 'INPUT_FIELD_ADDED': - // case 'INPUT_FIELD_DESCRIPTION_ADDED': - // case 'INPUT_FIELD_DESCRIPTION_REMOVED': - // case 'INPUT_FIELD_DESCRIPTION_CHANGED': - // case 'INPUT_FIELD_DEFAULT_VALUE_CHANGED': - // case 'INPUT_FIELD_TYPE_CHANGED': - // case 'OBJECT_TYPE_INTERFACE_ADDED': - // case 'OBJECT_TYPE_INTERFACE_REMOVED': - // case 'SCHEMA_QUERY_TYPE_CHANGED': - // case 'SCHEMA_MUTATION_TYPE_CHANGED': - // case 'SCHEMA_SUBSCRIPTION_TYPE_CHANGED': - // case 'TYPE_REMOVED': - // case 'TYPE_ADDED': - // case 'TYPE_KIND_CHANGED': - // case 'TYPE_DESCRIPTION_CHANGED': - // case 'TYPE_DESCRIPTION_REMOVED': - // case 'TYPE_DESCRIPTION_ADDED': - // case 'UNION_MEMBER_REMOVED': - // case 'UNION_MEMBER_ADDED': - // case 'DIRECTIVE_USAGE_UNION_MEMBER_ADDED': - // case 'DIRECTIVE_USAGE_UNION_MEMBER_REMOVED': - // case 'DIRECTIVE_USAGE_ENUM_ADDED': - // case 'DIRECTIVE_USAGE_ENUM_REMOVED': - // case 'DIRECTIVE_USAGE_ENUM_VALUE_ADDED': - // case 'DIRECTIVE_USAGE_ENUM_VALUE_REMOVED': - // case 'DIRECTIVE_USAGE_INPUT_OBJECT_ADDED': - // case 'DIRECTIVE_USAGE_INPUT_OBJECT_REMOVED': - // case 'DIRECTIVE_USAGE_FIELD_ADDED': - // case 'DIRECTIVE_USAGE_FIELD_REMOVED': - // case 'DIRECTIVE_USAGE_SCALAR_ADDED': - // case 'DIRECTIVE_USAGE_SCALAR_REMOVED': - // case 'DIRECTIVE_USAGE_OBJECT_ADDED': - // case 'DIRECTIVE_USAGE_OBJECT_REMOVED': - // case 'DIRECTIVE_USAGE_INTERFACE_ADDED': - // case 'DIRECTIVE_USAGE_INTERFACE_REMOVED': - // case 'DIRECTIVE_USAGE_ARGUMENT_DEFINITION_ADDED': - // case 'DIRECTIVE_USAGE_ARGUMENT_DEFINITION_REMOVED': - // case 'DIRECTIVE_USAGE_SCHEMA_ADDED': - // case 'DIRECTIVE_USAGE_SCHEMA_REMOVED': - // case 'DIRECTIVE_USAGE_FIELD_DEFINITION_ADDED': - // case 'DIRECTIVE_USAGE_FIELD_DEFINITION_REMOVED': - // case 'DIRECTIVE_USAGE_INPUT_FIELD_DEFINITION_ADDED': - // case 'DIRECTIVE_USAGE_INPUT_FIELD_DEFINITION_REMOVED': { - // break; - // } - // default: { - // console.error('Unhandled change type. Check package version compatibility.') - // } - // } - // } - - // const newAst: DocumentNode = { - // kind: Kind.DOCUMENT, - // definitions: [...schemaDefs, ...nodesByName.values()], - // }; - // return newAst; -} - -// export function printDiff(before: DocumentNode, changes: SerializableChange[]): string { -// // WHAT IF -// /** -// * modify ast to include a flag for added, removed, or updated/moved?... -// * add everything to the AST and print that as a schema.. (but it wont print duplicate field names etc right) -// * ... So write a custom printer that solves^ -// * -// * HOW do keep the removed node around and not mess up the AST?... -// */ - -// return ''; -// } \ No newline at end of file diff --git a/packages/web/app/src/components/proposal/__tests__/diff.test.ts b/packages/web/app/src/components/proposal/patch/__tests__/diff.test.ts similarity index 98% rename from packages/web/app/src/components/proposal/__tests__/diff.test.ts rename to packages/web/app/src/components/proposal/patch/__tests__/diff.test.ts index 7f43c6bc58..e2dc35f4d9 100644 --- a/packages/web/app/src/components/proposal/__tests__/diff.test.ts +++ b/packages/web/app/src/components/proposal/patch/__tests__/diff.test.ts @@ -1,7 +1,6 @@ import { buildSchema, printSchema, lexicographicSortSchema, GraphQLSchema } from 'graphql'; import { diff } from '@graphql-inspector/core'; import { patchSchema } from '../diff'; -// import { applyChanges } from '../diff'; function printSortedSchema(schema: GraphQLSchema) { return printSchema(lexicographicSortSchema(schema)) @@ -181,6 +180,7 @@ const schemaB = buildSchema(/** GraphQL */` const editScript = await diff(schemaA, schemaB); test('patch', async () => { + console.log(`Applying changes: ${editScript.map(e => JSON.stringify(e)).join('\n')}`) expect(printSortedSchema(schemaB)).toBe(printSortedSchema(patchSchema(schemaA, editScript))); }) diff --git a/packages/web/app/src/components/proposal/patch/diff.ts b/packages/web/app/src/components/proposal/patch/diff.ts new file mode 100644 index 0000000000..5feff6b247 --- /dev/null +++ b/packages/web/app/src/components/proposal/patch/diff.ts @@ -0,0 +1,393 @@ +import { Change, SerializableChange, ChangeType } from '@graphql-inspector/core'; +import { GraphQLSchema, printSchema, parse, DocumentNode, Kind, buildASTSchema, NameNode, ObjectTypeDefinitionNode, FieldDefinitionNode, ASTKindToNode, InterfaceTypeDefinitionNode, SchemaDefinitionNode, SchemaExtensionNode, ExecutableDefinitionNode, print, InputValueDefinitionNode, StringValueNode, ObjectTypeExtensionNode, DirectiveDefinitionNode, TypeExtensionNode, TypeDefinitionNode, TypeSystemDefinitionNode, ValueNode, astFromValue, GraphQLString, TypeNode, InterfaceTypeExtensionNode, InputObjectTypeDefinitionNode, InputObjectTypeExtensionNode, UnionTypeDefinitionNode, UnionTypeExtensionNode, ScalarTypeDefinitionNode, ScalarTypeExtensionNode, EnumTypeDefinitionNode, EnumTypeExtensionNode, visit, parseType, ArgumentNode, parseValue, EnumValueDefinitionNode, DirectiveNode, GraphQLDeprecatedDirective, NamedTypeNode } from 'graphql'; +import { Maybe } from 'graphql/jsutils/Maybe'; + +export type TypeOfChangeType = (typeof ChangeType)[keyof typeof ChangeType]; + +function nameNode(name: string): NameNode { + return { + value: name, + kind: Kind.NAME, + }; +} + +function stringNode(value: string): StringValueNode { + return { + kind: Kind.STRING, + value, + } +} + +export function patchSchema(schema: GraphQLSchema, changes: SerializableChange[]): GraphQLSchema { + const ast = parse(printSchema(schema)); + return buildASTSchema(patch(ast, changes), { assumeValid: true, assumeValidSDL: true }); +} + +export function patchTypeDefinition(node: T, changes: ChangesByType): T { + /** CHANGE DESCRIPTION */ + { + const change = changes[ChangeType.TypeDescriptionAdded]?.findLast(({ meta }) => meta.typeName === node.name.value); + if (change) { + (node.description as StringValueNode) = stringNode(change.meta.addedTypeDescription); + } + } + { + const change = changes[ChangeType.TypeDescriptionChanged]?.findLast(({ meta }) => meta.typeName === node.name.value); + if (change) { + (node.description as StringValueNode) = stringNode(change.meta.newTypeDescription); + } + } + { + const change = changes[ChangeType.TypeDescriptionRemoved]?.findLast(({ meta }) => meta.typeName === node.name.value); + if (change) { + (node.description as StringValueNode | undefined) = undefined; + } + } + return node +} + +function patchObjectType(node: ObjectTypeDefinitionNode | ObjectTypeExtensionNode | InterfaceTypeDefinitionNode | InterfaceTypeExtensionNode, changes: ChangesByType) { + /** REMOVE TYPES */ + const isRemoved = changes[ChangeType.TypeRemoved]?.find(change => change.meta.removedTypeName === node.name.value) ?? false; + if (isRemoved) { + return null; + } + + /** REMOVE FIELDS */ + const fieldRemovalsForType: Set = new Set( + (changes[ChangeType.FieldRemoved] ?? []) + .filter(change => change.meta.typeName === node.name.value) + // @note consider using more of the metadata OR pre-mapping the removed fields to avoid having to map for every type's list. + .map(f => f.meta.removedFieldName), + ); + + if (fieldRemovalsForType.size) { + (node.fields as any) = node.fields?.filter(f => !fieldRemovalsForType.has(f.name.value)); + } + + /** ADD FIELDS */ + const addedFields = changes[ChangeType.FieldAdded]?.filter(change => change.meta.typeName === node.name.value).map((change) => { + const fieldDefinitionNode: FieldDefinitionNode = { + kind: Kind.FIELD_DEFINITION, + name: nameNode(change.meta.addedFieldName), + // @todo typeType is bugged atm. Fix this. Prefer adding a new `addedFieldType` field. + // type: parseType(change.meta.typeType), + type: parseType('String'), + + }; + return fieldDefinitionNode; + }) + + if (addedFields?.length) { + (node.fields as any) = [...(node.fields ??[]), ...addedFields]; + } + + /** Patch fields */ + (node.fields as any) = node.fields?.map(field => patchField(node.name.value, field, changes)); + + /** REMOVE INTERFACES */ + { + const removals = changes[ChangeType.ObjectTypeInterfaceRemoved] + ?.filter(change => change.meta.objectTypeName === node.name.value) + .map(node => node.meta.removedInterfaceName); + (node.interfaces as NamedTypeNode[] | undefined) = node.interfaces?.filter(({ name }) => !removals?.includes(name.value)); + } + + /** ADD INTERFACES */ + { + const additions = changes[ChangeType.ObjectTypeInterfaceAdded] + ?.filter(change => change.meta.objectTypeName === node.name.value) + .map(node => node.meta.addedInterfaceName); + if (additions) { + (node.interfaces as NamedTypeNode[]) = [ + ...node.interfaces ?? [], + ...additions.map((name): NamedTypeNode => ({ + kind: Kind.NAMED_TYPE, + name: nameNode(name), + })), + ]; + } + } + + return node; +} + +function patchField(typeName: string, field: FieldDefinitionNode, changes: ChangesByType) { + { + const change = changes[ChangeType.FieldDescriptionAdded]?.findLast(change => change.meta.typeName === typeName && field.name.value === change.meta.fieldName); + if (change && field.description) { + (field.description as StringValueNode) = stringNode(change.meta.addedDescription); + } + } + + { + const change = changes[ChangeType.FieldDescriptionChanged]?.findLast(change => change.meta.typeName === typeName && field.name.value === change.meta.fieldName); + if (change && field.description) { + (field.description as StringValueNode) = stringNode(change.meta.newDescription); + } + } + + { + const change = changes[ChangeType.FieldDescriptionRemoved]?.findLast(change => change.meta.typeName === typeName && field.name.value === change.meta.fieldName); + if (change && field.description) { + (field.description as StringValueNode | undefined) = undefined; + } + } + + { + const change = changes[ChangeType.FieldTypeChanged]?.findLast(change => change.meta.typeName === typeName && field.name.value === change.meta.fieldName); + if (change) { + (field.type as TypeNode) = parseType(change.meta.newFieldType); + } + } + + { + const fieldChanges = changes[ChangeType.FieldArgumentAdded]?.filter(change => + change.meta.typeName === typeName && field.name.value === change.meta.fieldName, + ); + if (fieldChanges) { + const argumentAdditions = fieldChanges.map((change): InputValueDefinitionNode => ({ + kind: Kind.INPUT_VALUE_DEFINITION, + name: nameNode(change.meta.addedArgumentName), + type: parseType(change.meta.addedArgumentType), + // defaultValue: change.meta.hasDefaultValue ? + })); + (field.arguments as InputValueDefinitionNode[] | undefined) = [...(field.arguments ??[]), ...argumentAdditions]; + } + } + + const patchedArguments = field.arguments + ?.map((argumentNode => patchFieldArgument(typeName, field.name.value, argumentNode, changes))) + .filter(n => !!n); + const addedArguments = changes[ChangeType.FieldArgumentAdded] + ?.filter(change => change.meta.typeName === typeName && change.meta.fieldName === field.name.value) + .map((change): InputValueDefinitionNode => ({ + kind: Kind.INPUT_VALUE_DEFINITION, + name: nameNode(change.meta.addedArgumentName), + type: parseType(change.meta.addedArgumentType), + // @todo handle default value and description etc. + })); + const fieldArgs = [...patchedArguments ?? [], ...addedArguments ?? []]; + (field.arguments as InputValueDefinitionNode[] | undefined) = fieldArgs.length ? fieldArgs : undefined; + + return field; +} + +function patchFieldArgument(typeName: string, fieldName: string, arg: InputValueDefinitionNode, changes: ChangesByType) { + const descriptionChanges = (changes[ChangeType.FieldArgumentDescriptionChanged] ?? []).filter(change => change.meta.typeName === typeName && change.meta.fieldName === fieldName); + for (const change of descriptionChanges) { + if (arg.description?.value !== (change.meta.oldDescription ?? undefined)) { + console.warn('Conflict: Description does not match previous change description.'); + continue; + } + (arg.description as StringValueNode | undefined) = change.meta.newDescription ? stringNode(change.meta.newDescription) : undefined; + } + + const defaultChanges = (changes[ChangeType.FieldArgumentDefaultChanged] ?? []).filter(change => change.meta.typeName === typeName && change.meta.fieldName === fieldName);; + for (const change of defaultChanges) { + if (arg.defaultValue === undefined) { + if (change.meta.oldDefaultValue !== undefined) { + console.warn(`Conflict: Default value "${arg.defaultValue}" does not match previous change default value of "${change.meta.oldDefaultValue}".`); + return; + } + } else if (print(arg.defaultValue) !== (change.meta.oldDefaultValue)) { + console.warn(`Conflict: Default value "${print(arg.defaultValue)}" does not match previous change default value of "${change.meta.oldDefaultValue}".`); + return; + } + ((arg as InputValueDefinitionNode).defaultValue as ValueNode | undefined) = change.meta.newDefaultValue ? parseValue(change.meta.newDefaultValue) : undefined; + } + + const removalChange = (changes[ChangeType.FieldArgumentRemoved] ?? []).find(change => change.meta.typeName === typeName && change.meta.fieldName === fieldName && change.meta.removedFieldArgumentName === arg.name.value);; + if (removalChange) { + return null; + } + + return arg; +} + +function patchInputObject(node: InputObjectTypeDefinitionNode | InputObjectTypeExtensionNode, changes: ChangesByType) { + return node; +} + +function patchUnionType(node: UnionTypeDefinitionNode | UnionTypeExtensionNode, changes: ChangesByType) { + return node; +} + +function patchEnumType(node: EnumTypeDefinitionNode | EnumTypeExtensionNode, changes: ChangesByType) { + { + const removals = changes[ChangeType.EnumValueRemoved]?.filter(change => change.meta.enumName === node.name.value) + .map(change => change.meta.removedEnumValueName); + (node.values as EnumValueDefinitionNode[] | undefined) = node.values?.filter(({ name }) => !removals?.includes(name.value)) + } + + { + const additions = changes[ChangeType.EnumValueAdded]?.filter(change => change.meta.enumName === node.name.value) + .map((change): EnumValueDefinitionNode => ({ + kind: Kind.ENUM_VALUE_DEFINITION, + name: nameNode(change.meta.addedEnumValueName), + // @todo + directives: undefined, + description: undefined, + })); + if (additions?.length) { + (node.values as EnumValueDefinitionNode[] | undefined) = [...node.values ?? [], ...additions]; + } + } + + /** CHANGED VALUE DESCRIPTION */ + { + const change = changes[ChangeType.EnumValueDescriptionChanged]?.findLast(change => change.meta.enumName === node.name.value); + if (change) { + (node.values as EnumValueDefinitionNode[] | undefined)?.map(value => { + if (value.name.value === change.meta.enumValueName) { + (value.description as StringValueNode | undefined) = change.meta.newEnumValueDescription ? stringNode(change.meta.newEnumValueDescription) : undefined; + } + }); + } + } + + { + const addedChanges = changes[ChangeType.EnumValueDeprecationReasonChanged]?.filter(change => change.meta.enumName === node.name.value); + for (const change of addedChanges ?? []) { + const enumValueNode = findNamedNode(node.values, change.meta.enumValueName) + const deprecation = getDeprecatedDirectiveNode(enumValueNode); + setArgument(deprecation, 'reason', stringNode(change.meta.newEnumValueDeprecationReason)); + } + } + + { + const addedChanges = changes[ChangeType.EnumValueDeprecationReasonAdded]?.filter(change => change.meta.enumName === node.name.value); + for (const change of addedChanges ?? []) { + const enumValueNode = findNamedNode(node.values, change.meta.enumValueName) + const deprecation = getDeprecatedDirectiveNode(enumValueNode); + setArgument(deprecation, 'reason', stringNode(change.meta.addedValueDeprecationReason)); + } + } + + { + const removalChanges = changes[ChangeType.EnumValueDeprecationReasonRemoved] + ?.filter(change => change.meta.enumName === node.name.value); + for (const change of removalChanges ?? []) { + const enumValueNode = findNamedNode(node.values, change.meta.enumValueName) + const deprecation = getDeprecatedDirectiveNode(enumValueNode); + removeArgument(deprecation, 'reason'); + } + } + + return node; +} + +function patchDirective(node: DirectiveDefinitionNode, changes: ChangesByType) { + + return node; +} + +type ChangesByType = { [key in TypeOfChangeType]?: Array> }; + +export function patch(ast: DocumentNode, changes: SerializableChange[]): DocumentNode { + // const [schemaDefs, nodesByName] = collectDefinitions(ast); + const changesByType: ChangesByType = {}; + for (const change of changes) { + changesByType[change.type] ??= []; + changesByType[change.type]?.push(change as any); + } + + const result = visit(ast, { + ObjectTypeDefinition: (node) => patchObjectType(patchTypeDefinition(node, changesByType), changesByType), + ObjectTypeExtension: (node) => patchObjectType(node, changesByType), + InterfaceTypeDefinition: (node) => patchObjectType(patchTypeDefinition(node, changesByType), changesByType), + InterfaceTypeExtension: (node) => patchObjectType(node, changesByType), + InputObjectTypeDefinition: (node) => patchInputObject(patchTypeDefinition(node, changesByType), changesByType), + InputObjectTypeExtension: (node) => patchInputObject(node, changesByType), + UnionTypeDefinition: (node) => patchUnionType(patchTypeDefinition(node, changesByType), changesByType), + UnionTypeExtension: (node) => patchUnionType(node, changesByType), + ScalarTypeDefinition: (node) => patchTypeDefinition(node, changesByType), + // ScalarTypeExtension: (node) => patchScalarType(node, changesByType), + EnumTypeDefinition: (node) => patchEnumType(patchTypeDefinition(node, changesByType), changesByType), + EnumTypeExtension: (node) => patchEnumType(node, changesByType), + DirectiveDefinition: (node) => patchDirective(node, changesByType), + SchemaDefinition: (node) => node, + SchemaExtension: (node) => node, + }); + + return { + ...result, + definitions: [ + ...result.definitions, + + /** ADD TYPES */ + ...(changesByType[ChangeType.TypeAdded] ?? []) + // @todo consider what to do for types that already exist. + .map((addition): TypeDefinitionNode => { + // addition.meta.addedTypeKind + // @todo need to figure out how to add enums and other types... + return { + kind: Kind.OBJECT_TYPE_DEFINITION, + fields: [], + name: nameNode(addition.meta.addedTypeName), + } as ObjectTypeDefinitionNode + }), + ], + }; +} + +function getDeprecatedDirectiveNode( + definitionNode: Maybe<{ readonly directives?: ReadonlyArray }>, +): Maybe { + return definitionNode?.directives?.find( + (node) => node.name.value === GraphQLDeprecatedDirective.name, + ); +} + +function setArgument( + node: Maybe<{ arguments?: ArgumentNode[] | readonly ArgumentNode[] | undefined}>, + argumentName: string, + value: ValueNode, +): void { + if (node) { + let found = false; + for (const arg of node.arguments ?? []) { + if (arg.name.value === argumentName) { + (arg.value as ValueNode) = value; + found = true; + break; + } + } + if (!found) { + node.arguments = [...(node.arguments ?? []), { + kind: Kind.ARGUMENT, + name: nameNode(argumentName), + value, + }] + } + } +} + +function findNamedNode( + nodes: Maybe>, + name: string, +): T | undefined { + return nodes?.find(value => value.name.value === name); +} + +function removeArgument( + node: Maybe<{ arguments?: ArgumentNode[] | readonly ArgumentNode[] | undefined}>, + argumentName: string, +): void { + if (node?.arguments) { + node.arguments = node.arguments.filter(arg => arg.name.value !== argumentName); + } +} + +// export function printDiff(before: DocumentNode, changes: SerializableChange[]): string { +// // WHAT IF +// /** +// * modify ast to include a flag for added, removed, or updated/moved?... +// * add everything to the AST and print that as a schema.. (but it wont print duplicate field names etc right) +// * ... So write a custom printer that solves^ +// * +// * HOW do keep the removed node around and not mess up the AST?... +// */ + +// return ''; +// } \ No newline at end of file From e055860cf6b9ff4c8451acd05147c3c8c3b8f074 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Thu, 3 Jul 2025 15:07:40 -0700 Subject: [PATCH 15/54] Prettier; patch schema --- .../resolvers/Query/schemaProposal.ts | 6 +- .../resolvers/Query/schemaProposalReview.ts | 8 +- .../resolvers/Query/schemaProposalReviews.ts | 12 +- .../src/components/common/ListNavigation.tsx | 2 +- .../app/src/components/proposal/Review.tsx | 24 +- .../app/src/components/proposal/change.tsx | 19 +- .../proposal/patch/__tests__/diff.test.ts | 24 +- .../app/src/components/proposal/patch/diff.ts | 625 +++++++++++++----- .../proposal/patch/node-templates.ts | 22 + .../src/components/proposal/patch/print.ts | 12 + .../src/components/proposal/patch/utils.ts | 172 +++++ .../src/components/proposal/proposal-sdl.tsx | 75 ++- .../src/pages/target-proposal-overview.tsx | 17 +- 13 files changed, 758 insertions(+), 260 deletions(-) create mode 100644 packages/web/app/src/components/proposal/patch/node-templates.ts create mode 100644 packages/web/app/src/components/proposal/patch/print.ts create mode 100644 packages/web/app/src/components/proposal/patch/utils.ts diff --git a/packages/services/api/src/modules/proposals/resolvers/Query/schemaProposal.ts b/packages/services/api/src/modules/proposals/resolvers/Query/schemaProposal.ts index a518db3500..defdf93f96 100644 --- a/packages/services/api/src/modules/proposals/resolvers/Query/schemaProposal.ts +++ b/packages/services/api/src/modules/proposals/resolvers/Query/schemaProposal.ts @@ -40,9 +40,9 @@ export const schemaProposal: NonNullable = asy createdAt: Date.now(), body: 'This is a comment. The first comment.', updatedAt: Date.now(), - } - } - ] + }, + }, + ], }, createdAt: Date.now(), lineText: 'type User {', diff --git a/packages/services/api/src/modules/proposals/resolvers/Query/schemaProposalReview.ts b/packages/services/api/src/modules/proposals/resolvers/Query/schemaProposalReview.ts index 8e739842d8..af1cc84e47 100644 --- a/packages/services/api/src/modules/proposals/resolvers/Query/schemaProposalReview.ts +++ b/packages/services/api/src/modules/proposals/resolvers/Query/schemaProposalReview.ts @@ -23,10 +23,10 @@ export const schemaProposalReview: NonNullable +
{review.comments?.edges?.map(({ node: comment }, idx) => { - return ( - - ); + return ; })}
); @@ -55,17 +53,21 @@ const ProposalOverview_CommentFragment = graphql(/** GraphQL */ ` `); export function ReviewComment(props: { - first?: boolean + first?: boolean; comment: FragmentType; }) { const comment = useFragment(ProposalOverview_CommentFragment, props.comment); return ( <> -
-
{comment.user?.displayName ?? comment.user?.fullName ?? 'Unknown'}
-
+
+
+ {comment.user?.displayName ?? comment.user?.fullName ?? 'Unknown'} +
+
+ +
-
{comment.body}
+
{comment.body}
- ) + ); } diff --git a/packages/web/app/src/components/proposal/change.tsx b/packages/web/app/src/components/proposal/change.tsx index b62c9b3ed2..0794428c67 100644 --- a/packages/web/app/src/components/proposal/change.tsx +++ b/packages/web/app/src/components/proposal/change.tsx @@ -1,12 +1,13 @@ -import { cn } from '@/lib/utils'; import { ReactNode } from 'react'; +import { cn } from '@/lib/utils'; export function ChangeDocument(props: { children: ReactNode; className?: string }) { return ( - - - {props.children} - +
+ {props.children}
); } @@ -24,9 +25,11 @@ export function ChangeRow(props: { }) { return ( - {props.lineNumber} - {props.lineNumber !== props.diffLineNumber ? props.diffLineNumber : null} - {props.children} + {props.lineNumber} + + {props.lineNumber !== props.diffLineNumber ? props.diffLineNumber : null} + + {props.children} ); } diff --git a/packages/web/app/src/components/proposal/patch/__tests__/diff.test.ts b/packages/web/app/src/components/proposal/patch/__tests__/diff.test.ts index e2dc35f4d9..3cafa18c7e 100644 --- a/packages/web/app/src/components/proposal/patch/__tests__/diff.test.ts +++ b/packages/web/app/src/components/proposal/patch/__tests__/diff.test.ts @@ -1,12 +1,13 @@ -import { buildSchema, printSchema, lexicographicSortSchema, GraphQLSchema } from 'graphql'; +import { buildSchema, GraphQLSchema, lexicographicSortSchema, printSchema } from 'graphql'; import { diff } from '@graphql-inspector/core'; import { patchSchema } from '../diff'; function printSortedSchema(schema: GraphQLSchema) { - return printSchema(lexicographicSortSchema(schema)) + return printSchema(lexicographicSortSchema(schema)); } -const schemaA = buildSchema(/** GraphQL */` +const schemaA = buildSchema( + /** GraphQL */ ` extend schema @link( url: "https://specs.apollo.dev/federation/v2.3" @@ -81,9 +82,12 @@ const schemaA = buildSchema(/** GraphQL */` email: ID! totalProductsCreated: Int @shareable } -`, {assumeValid: true, assumeValidSDL: true}); +`, + { assumeValid: true, assumeValidSDL: true }, +); -const schemaB = buildSchema(/** GraphQL */` +const schemaB = buildSchema( + /** GraphQL */ ` extend schema @link( url: "https://specs.apollo.dev/federation/v2.3" @@ -175,15 +179,17 @@ const schemaB = buildSchema(/** GraphQL */` } scalar FooBar -`, { assumeValid: true, assumeValidSDL: true }); +`, + { assumeValid: true, assumeValidSDL: true }, +); const editScript = await diff(schemaA, schemaB); test('patch', async () => { - console.log(`Applying changes: ${editScript.map(e => JSON.stringify(e)).join('\n')}`) + console.log(`Applying changes: ${editScript.map(e => JSON.stringify(e)).join('\n')}`); expect(printSortedSchema(schemaB)).toBe(printSortedSchema(patchSchema(schemaA, editScript))); -}) +}); // test.only('printDiff', () => { // expect(printDiff(schemaB, applyChanges(schemaA, editScript))).toBe(''); -// }) \ No newline at end of file +// }) diff --git a/packages/web/app/src/components/proposal/patch/diff.ts b/packages/web/app/src/components/proposal/patch/diff.ts index 5feff6b247..ac91e89a8c 100644 --- a/packages/web/app/src/components/proposal/patch/diff.ts +++ b/packages/web/app/src/components/proposal/patch/diff.ts @@ -1,54 +1,102 @@ -import { Change, SerializableChange, ChangeType } from '@graphql-inspector/core'; -import { GraphQLSchema, printSchema, parse, DocumentNode, Kind, buildASTSchema, NameNode, ObjectTypeDefinitionNode, FieldDefinitionNode, ASTKindToNode, InterfaceTypeDefinitionNode, SchemaDefinitionNode, SchemaExtensionNode, ExecutableDefinitionNode, print, InputValueDefinitionNode, StringValueNode, ObjectTypeExtensionNode, DirectiveDefinitionNode, TypeExtensionNode, TypeDefinitionNode, TypeSystemDefinitionNode, ValueNode, astFromValue, GraphQLString, TypeNode, InterfaceTypeExtensionNode, InputObjectTypeDefinitionNode, InputObjectTypeExtensionNode, UnionTypeDefinitionNode, UnionTypeExtensionNode, ScalarTypeDefinitionNode, ScalarTypeExtensionNode, EnumTypeDefinitionNode, EnumTypeExtensionNode, visit, parseType, ArgumentNode, parseValue, EnumValueDefinitionNode, DirectiveNode, GraphQLDeprecatedDirective, NamedTypeNode } from 'graphql'; -import { Maybe } from 'graphql/jsutils/Maybe'; +import { + buildASTSchema, + ConstValueNode, + DirectiveDefinitionNode, + DocumentNode, + EnumTypeDefinitionNode, + EnumTypeExtensionNode, + EnumValueDefinitionNode, + FieldDefinitionNode, + GraphQLSchema, + InputObjectTypeDefinitionNode, + InputObjectTypeExtensionNode, + InputValueDefinitionNode, + InterfaceTypeDefinitionNode, + InterfaceTypeExtensionNode, + Kind, + NamedTypeNode, + NameNode, + ObjectTypeDefinitionNode, + ObjectTypeExtensionNode, + parse, + parseConstValue, + parseType, + parseValue, + print, + printSchema, + StringValueNode, + TypeDefinitionNode, + TypeNode, + UnionTypeDefinitionNode, + UnionTypeExtensionNode, + ValueNode, + visit, +} from 'graphql'; +import { Change, ChangeType, SerializableChange } from '@graphql-inspector/core'; +import { namedType, nameNode, stringNode } from './node-templates'; +import { + addInputValueDefinitionArgument, + findNamedNode, + getDeprecatedDirectiveNode, + removeArgument, + removeInputValueDefinitionArgument, + removeNamedNode, + setArgument, + setInputValueDefinitionArgument, +} from './utils'; export type TypeOfChangeType = (typeof ChangeType)[keyof typeof ChangeType]; -function nameNode(name: string): NameNode { - return { - value: name, - kind: Kind.NAME, - }; -} - -function stringNode(value: string): StringValueNode { - return { - kind: Kind.STRING, - value, - } -} - export function patchSchema(schema: GraphQLSchema, changes: SerializableChange[]): GraphQLSchema { const ast = parse(printSchema(schema)); return buildASTSchema(patch(ast, changes), { assumeValid: true, assumeValidSDL: true }); } -export function patchTypeDefinition(node: T, changes: ChangesByType): T { +export function patchTypeDefinition( + node: T, + changes: ChangesByType, +): T { /** CHANGE DESCRIPTION */ { - const change = changes[ChangeType.TypeDescriptionAdded]?.findLast(({ meta }) => meta.typeName === node.name.value); + const change = changes[ChangeType.TypeDescriptionAdded]?.findLast( + ({ meta }) => meta.typeName === node.name.value, + ); if (change) { (node.description as StringValueNode) = stringNode(change.meta.addedTypeDescription); } } { - const change = changes[ChangeType.TypeDescriptionChanged]?.findLast(({ meta }) => meta.typeName === node.name.value); + const change = changes[ChangeType.TypeDescriptionChanged]?.findLast( + ({ meta }) => meta.typeName === node.name.value, + ); if (change) { (node.description as StringValueNode) = stringNode(change.meta.newTypeDescription); } } { - const change = changes[ChangeType.TypeDescriptionRemoved]?.findLast(({ meta }) => meta.typeName === node.name.value); + const change = changes[ChangeType.TypeDescriptionRemoved]?.findLast( + ({ meta }) => meta.typeName === node.name.value, + ); if (change) { (node.description as StringValueNode | undefined) = undefined; } } - return node + return node; } -function patchObjectType(node: ObjectTypeDefinitionNode | ObjectTypeExtensionNode | InterfaceTypeDefinitionNode | InterfaceTypeExtensionNode, changes: ChangesByType) { +function patchObjectType( + node: + | ObjectTypeDefinitionNode + | ObjectTypeExtensionNode + | InterfaceTypeDefinitionNode + | InterfaceTypeExtensionNode, + changes: ChangesByType, +) { /** REMOVE TYPES */ - const isRemoved = changes[ChangeType.TypeRemoved]?.find(change => change.meta.removedTypeName === node.name.value) ?? false; + const isRemoved = + changes[ChangeType.TypeRemoved]?.find( + change => change.meta.removedTypeName === node.name.value, + ) ?? false; if (isRemoved) { return null; } @@ -66,20 +114,21 @@ function patchObjectType(node: ObjectTypeDefinitionNode | ObjectTypeExtensionNod } /** ADD FIELDS */ - const addedFields = changes[ChangeType.FieldAdded]?.filter(change => change.meta.typeName === node.name.value).map((change) => { - const fieldDefinitionNode: FieldDefinitionNode = { - kind: Kind.FIELD_DEFINITION, - name: nameNode(change.meta.addedFieldName), - // @todo typeType is bugged atm. Fix this. Prefer adding a new `addedFieldType` field. - // type: parseType(change.meta.typeType), - type: parseType('String'), - - }; - return fieldDefinitionNode; - }) + const addedFields = changes[ChangeType.FieldAdded] + ?.filter(change => change.meta.typeName === node.name.value) + .map(change => { + const fieldDefinitionNode: FieldDefinitionNode = { + kind: Kind.FIELD_DEFINITION, + name: nameNode(change.meta.addedFieldName), + // @todo typeType is bugged atm. Fix this. Prefer adding a new `addedFieldType` field. + // type: parseType(change.meta.typeType), + type: parseType('String'), + }; + return fieldDefinitionNode; + }); if (addedFields?.length) { - (node.fields as any) = [...(node.fields ??[]), ...addedFields]; + (node.fields as any) = [...(node.fields ?? []), ...addedFields]; } /** Patch fields */ @@ -90,7 +139,9 @@ function patchObjectType(node: ObjectTypeDefinitionNode | ObjectTypeExtensionNod const removals = changes[ChangeType.ObjectTypeInterfaceRemoved] ?.filter(change => change.meta.objectTypeName === node.name.value) .map(node => node.meta.removedInterfaceName); - (node.interfaces as NamedTypeNode[] | undefined) = node.interfaces?.filter(({ name }) => !removals?.includes(name.value)); + (node.interfaces as NamedTypeNode[] | undefined) = node.interfaces?.filter( + ({ name }) => !removals?.includes(name.value), + ); } /** ADD INTERFACES */ @@ -100,11 +151,13 @@ function patchObjectType(node: ObjectTypeDefinitionNode | ObjectTypeExtensionNod .map(node => node.meta.addedInterfaceName); if (additions) { (node.interfaces as NamedTypeNode[]) = [ - ...node.interfaces ?? [], - ...additions.map((name): NamedTypeNode => ({ - kind: Kind.NAMED_TYPE, - name: nameNode(name), - })), + ...(node.interfaces ?? []), + ...additions.map( + (name): NamedTypeNode => ({ + kind: Kind.NAMED_TYPE, + name: nameNode(name), + }), + ), ]; } } @@ -114,90 +167,132 @@ function patchObjectType(node: ObjectTypeDefinitionNode | ObjectTypeExtensionNod function patchField(typeName: string, field: FieldDefinitionNode, changes: ChangesByType) { { - const change = changes[ChangeType.FieldDescriptionAdded]?.findLast(change => change.meta.typeName === typeName && field.name.value === change.meta.fieldName); + const change = changes[ChangeType.FieldDescriptionAdded]?.findLast( + change => change.meta.typeName === typeName && field.name.value === change.meta.fieldName, + ); if (change && field.description) { (field.description as StringValueNode) = stringNode(change.meta.addedDescription); } } { - const change = changes[ChangeType.FieldDescriptionChanged]?.findLast(change => change.meta.typeName === typeName && field.name.value === change.meta.fieldName); + const change = changes[ChangeType.FieldDescriptionChanged]?.findLast( + change => change.meta.typeName === typeName && field.name.value === change.meta.fieldName, + ); if (change && field.description) { (field.description as StringValueNode) = stringNode(change.meta.newDescription); } } { - const change = changes[ChangeType.FieldDescriptionRemoved]?.findLast(change => change.meta.typeName === typeName && field.name.value === change.meta.fieldName); + const change = changes[ChangeType.FieldDescriptionRemoved]?.findLast( + change => change.meta.typeName === typeName && field.name.value === change.meta.fieldName, + ); if (change && field.description) { (field.description as StringValueNode | undefined) = undefined; } } { - const change = changes[ChangeType.FieldTypeChanged]?.findLast(change => change.meta.typeName === typeName && field.name.value === change.meta.fieldName); + const change = changes[ChangeType.FieldTypeChanged]?.findLast( + change => change.meta.typeName === typeName && field.name.value === change.meta.fieldName, + ); if (change) { (field.type as TypeNode) = parseType(change.meta.newFieldType); } } { - const fieldChanges = changes[ChangeType.FieldArgumentAdded]?.filter(change => - change.meta.typeName === typeName && field.name.value === change.meta.fieldName, + const fieldChanges = changes[ChangeType.FieldArgumentAdded]?.filter( + change => change.meta.typeName === typeName && field.name.value === change.meta.fieldName, ); if (fieldChanges) { - const argumentAdditions = fieldChanges.map((change): InputValueDefinitionNode => ({ - kind: Kind.INPUT_VALUE_DEFINITION, - name: nameNode(change.meta.addedArgumentName), - type: parseType(change.meta.addedArgumentType), - // defaultValue: change.meta.hasDefaultValue ? - })); - (field.arguments as InputValueDefinitionNode[] | undefined) = [...(field.arguments ??[]), ...argumentAdditions]; + const argumentAdditions = fieldChanges.map( + (change): InputValueDefinitionNode => ({ + kind: Kind.INPUT_VALUE_DEFINITION, + name: nameNode(change.meta.addedArgumentName), + type: parseType(change.meta.addedArgumentType), + // defaultValue: change.meta.hasDefaultValue ? + }), + ); + (field.arguments as InputValueDefinitionNode[] | undefined) = [ + ...(field.arguments ?? []), + ...argumentAdditions, + ]; } } const patchedArguments = field.arguments - ?.map((argumentNode => patchFieldArgument(typeName, field.name.value, argumentNode, changes))) + ?.map(argumentNode => patchFieldArgument(typeName, field.name.value, argumentNode, changes)) .filter(n => !!n); const addedArguments = changes[ChangeType.FieldArgumentAdded] - ?.filter(change => change.meta.typeName === typeName && change.meta.fieldName === field.name.value) - .map((change): InputValueDefinitionNode => ({ - kind: Kind.INPUT_VALUE_DEFINITION, - name: nameNode(change.meta.addedArgumentName), - type: parseType(change.meta.addedArgumentType), - // @todo handle default value and description etc. - })); - const fieldArgs = [...patchedArguments ?? [], ...addedArguments ?? []]; - (field.arguments as InputValueDefinitionNode[] | undefined) = fieldArgs.length ? fieldArgs : undefined; + ?.filter( + change => change.meta.typeName === typeName && change.meta.fieldName === field.name.value, + ) + .map( + (change): InputValueDefinitionNode => ({ + kind: Kind.INPUT_VALUE_DEFINITION, + name: nameNode(change.meta.addedArgumentName), + type: parseType(change.meta.addedArgumentType), + // @todo handle default value and description etc. + }), + ); + const fieldArgs = [...(patchedArguments ?? []), ...(addedArguments ?? [])]; + (field.arguments as InputValueDefinitionNode[] | undefined) = fieldArgs.length + ? fieldArgs + : undefined; return field; } -function patchFieldArgument(typeName: string, fieldName: string, arg: InputValueDefinitionNode, changes: ChangesByType) { - const descriptionChanges = (changes[ChangeType.FieldArgumentDescriptionChanged] ?? []).filter(change => change.meta.typeName === typeName && change.meta.fieldName === fieldName); +function patchFieldArgument( + typeName: string, + fieldName: string, + arg: InputValueDefinitionNode, + changes: ChangesByType, +) { + const descriptionChanges = (changes[ChangeType.FieldArgumentDescriptionChanged] ?? []).filter( + change => change.meta.typeName === typeName && change.meta.fieldName === fieldName, + ); for (const change of descriptionChanges) { if (arg.description?.value !== (change.meta.oldDescription ?? undefined)) { console.warn('Conflict: Description does not match previous change description.'); continue; } - (arg.description as StringValueNode | undefined) = change.meta.newDescription ? stringNode(change.meta.newDescription) : undefined; + (arg.description as StringValueNode | undefined) = change.meta.newDescription + ? stringNode(change.meta.newDescription) + : undefined; } - const defaultChanges = (changes[ChangeType.FieldArgumentDefaultChanged] ?? []).filter(change => change.meta.typeName === typeName && change.meta.fieldName === fieldName);; + const defaultChanges = (changes[ChangeType.FieldArgumentDefaultChanged] ?? []).filter( + change => change.meta.typeName === typeName && change.meta.fieldName === fieldName, + ); for (const change of defaultChanges) { if (arg.defaultValue === undefined) { if (change.meta.oldDefaultValue !== undefined) { - console.warn(`Conflict: Default value "${arg.defaultValue}" does not match previous change default value of "${change.meta.oldDefaultValue}".`); + console.warn( + `Conflict: Default value "${arg.defaultValue}" does not match previous change default value of "${change.meta.oldDefaultValue}".`, + ); return; } - } else if (print(arg.defaultValue) !== (change.meta.oldDefaultValue)) { - console.warn(`Conflict: Default value "${print(arg.defaultValue)}" does not match previous change default value of "${change.meta.oldDefaultValue}".`); + } else if (print(arg.defaultValue) !== change.meta.oldDefaultValue) { + console.warn( + `Conflict: Default value "${print(arg.defaultValue)}" does not match previous change default value of "${change.meta.oldDefaultValue}".`, + ); return; } - ((arg as InputValueDefinitionNode).defaultValue as ValueNode | undefined) = change.meta.newDefaultValue ? parseValue(change.meta.newDefaultValue) : undefined; + ((arg as InputValueDefinitionNode).defaultValue as ValueNode | undefined) = change.meta + .newDefaultValue + ? parseValue(change.meta.newDefaultValue) + : undefined; } - const removalChange = (changes[ChangeType.FieldArgumentRemoved] ?? []).find(change => change.meta.typeName === typeName && change.meta.fieldName === fieldName && change.meta.removedFieldArgumentName === arg.name.value);; + const removalChange = (changes[ChangeType.FieldArgumentRemoved] ?? []).find( + change => + change.meta.typeName === typeName && + change.meta.fieldName === fieldName && + change.meta.removedFieldArgumentName === arg.name.value, + ); if (removalChange) { return null; } @@ -205,79 +300,310 @@ function patchFieldArgument(typeName: string, fieldName: string, arg: InputValue return arg; } -function patchInputObject(node: InputObjectTypeDefinitionNode | InputObjectTypeExtensionNode, changes: ChangesByType) { +function patchInputObject( + node: InputObjectTypeDefinitionNode | InputObjectTypeExtensionNode, + changes: ChangesByType, +) { + for (const change of changes[ChangeType.InputFieldAdded] ?? []) { + if (change.meta.inputName === node.name.value) { + (node.fields as InputValueDefinitionNode[]) = [ + ...(node.fields ?? []), + { + kind: Kind.INPUT_VALUE_DEFINITION, + name: nameNode(change.meta.addedInputFieldName), + type: parseType(change.meta.addedInputFieldType), + // defaultValue, + // description, + // directives, + // loc, + }, + ]; + } + } + + for (const change of changes[ChangeType.InputFieldDefaultValueChanged] ?? []) { + if (change.meta.inputName === node.name.value) { + const field = findNamedNode(node.fields, change.meta.inputFieldName); + if (field) { + (field.defaultValue as ConstValueNode | undefined) = change.meta.newDefaultValue + ? parseConstValue(change.meta.newDefaultValue) + : undefined; + } else { + console.error( + `Patch error. No field found at "${change.meta.inputName}.${change.meta.inputFieldName}"`, + ); + } + } else { + console.error(`Patch error. No input type found named "${change.meta.inputName}"`); + } + } + + for (const change of changes[ChangeType.InputFieldDescriptionAdded] ?? []) { + if (change.meta.inputName === node.name.value) { + const field = findNamedNode(node.fields, change.meta.inputFieldName); + if (field) { + (field.description as StringValueNode) = stringNode(change.meta.addedInputFieldDescription); + } else { + console.error( + `Patch error. No field found at "${change.meta.inputName}.${change.meta.inputFieldName}"`, + ); + } + } else { + console.error(`Patch error. No input type found named "${change.meta.inputName}"`); + } + } + + for (const change of changes[ChangeType.InputFieldDescriptionChanged] ?? []) { + if (change.meta.inputName === node.name.value) { + const field = findNamedNode(node.fields, change.meta.inputFieldName); + if (field) { + (field.description as StringValueNode) = stringNode(change.meta.newInputFieldDescription); + } else { + console.error( + `Patch error. No field found at "${change.meta.inputName}.${change.meta.inputFieldName}"`, + ); + } + } else { + console.error(`Patch error. No input type found named "${change.meta.inputName}"`); + } + } + + for (const change of changes[ChangeType.InputFieldDescriptionRemoved] ?? []) { + if (change.meta.inputName === node.name.value) { + const field = findNamedNode(node.fields, change.meta.inputFieldName); + if (field) { + (field.description as StringValueNode | undefined) = undefined; + } else { + console.error( + `Patch error. No field found at "${change.meta.inputName}.${change.meta.inputFieldName}"`, + ); + } + } else { + console.error(`Patch error. No input type found named "${change.meta.inputName}"`); + } + } + + for (const change of changes[ChangeType.InputFieldRemoved] ?? []) { + if (change.meta.inputName === node.name.value) { + const removed = removeNamedNode( + node.fields as InputValueDefinitionNode[], + change.meta.removedFieldName, + ); + if (!removed) { + console.error( + `Patch error. No field found at "${change.meta.inputName}.${change.meta.removedFieldName}"`, + ); + } + } else { + console.error(`Patch error. No input type found named "${change.meta.inputName}"`); + } + } + + for (const change of changes[ChangeType.InputFieldTypeChanged] ?? []) { + if (change.meta.inputName === node.name.value) { + const field = findNamedNode(node.fields, change.meta.inputFieldName); + if (field) { + (field.type as TypeNode | undefined) = parseType(change.meta.newInputFieldType); + } else { + console.error( + `Patch error. No field found at "${change.meta.inputName}.${change.meta.inputFieldName}"`, + ); + } + } else { + console.error(`Patch error. No input type found named "${change.meta.inputName}"`); + } + } + return node; } -function patchUnionType(node: UnionTypeDefinitionNode | UnionTypeExtensionNode, changes: ChangesByType) { +function patchUnionType( + node: UnionTypeDefinitionNode | UnionTypeExtensionNode, + changes: ChangesByType, +) { + for (const change of changes[ChangeType.UnionMemberAdded] ?? []) { + if (node.name.value === change.meta.unionName) { + (node.types as NamedTypeNode[]) = [ + ...(node.types ?? []), + { + name: nameNode(change.meta.addedUnionMemberTypeName), + kind: Kind.NAMED_TYPE, + }, + ]; + } + } + + for (const change of changes[ChangeType.UnionMemberRemoved] ?? []) { + if (node.name.value === change.meta.removedUnionMemberTypeName) { + return null; + } + } + return node; } -function patchEnumType(node: EnumTypeDefinitionNode | EnumTypeExtensionNode, changes: ChangesByType) { +function patchEnumType( + node: EnumTypeDefinitionNode | EnumTypeExtensionNode, + changes: ChangesByType, +) { { - const removals = changes[ChangeType.EnumValueRemoved]?.filter(change => change.meta.enumName === node.name.value) + const removals = changes[ChangeType.EnumValueRemoved] + ?.filter(change => change.meta.enumName === node.name.value) .map(change => change.meta.removedEnumValueName); - (node.values as EnumValueDefinitionNode[] | undefined) = node.values?.filter(({ name }) => !removals?.includes(name.value)) + (node.values as EnumValueDefinitionNode[] | undefined) = node.values?.filter( + ({ name }) => !removals?.includes(name.value), + ); } { - const additions = changes[ChangeType.EnumValueAdded]?.filter(change => change.meta.enumName === node.name.value) - .map((change): EnumValueDefinitionNode => ({ - kind: Kind.ENUM_VALUE_DEFINITION, - name: nameNode(change.meta.addedEnumValueName), - // @todo - directives: undefined, - description: undefined, - })); + const additions = changes[ChangeType.EnumValueAdded] + ?.filter(change => change.meta.enumName === node.name.value) + .map( + (change): EnumValueDefinitionNode => ({ + kind: Kind.ENUM_VALUE_DEFINITION, + name: nameNode(change.meta.addedEnumValueName), + directives: undefined, // @todo + description: undefined, // @todo + }), + ); if (additions?.length) { - (node.values as EnumValueDefinitionNode[] | undefined) = [...node.values ?? [], ...additions]; + (node.values as EnumValueDefinitionNode[] | undefined) = [ + ...(node.values ?? []), + ...additions, + ]; } } /** CHANGED VALUE DESCRIPTION */ { - const change = changes[ChangeType.EnumValueDescriptionChanged]?.findLast(change => change.meta.enumName === node.name.value); + const change = changes[ChangeType.EnumValueDescriptionChanged]?.findLast( + change => change.meta.enumName === node.name.value, + ); if (change) { (node.values as EnumValueDefinitionNode[] | undefined)?.map(value => { if (value.name.value === change.meta.enumValueName) { - (value.description as StringValueNode | undefined) = change.meta.newEnumValueDescription ? stringNode(change.meta.newEnumValueDescription) : undefined; + (value.description as StringValueNode | undefined) = change.meta.newEnumValueDescription + ? stringNode(change.meta.newEnumValueDescription) + : undefined; } }); } } { - const addedChanges = changes[ChangeType.EnumValueDeprecationReasonChanged]?.filter(change => change.meta.enumName === node.name.value); + const addedChanges = changes[ChangeType.EnumValueDeprecationReasonChanged]?.filter( + change => change.meta.enumName === node.name.value, + ); for (const change of addedChanges ?? []) { - const enumValueNode = findNamedNode(node.values, change.meta.enumValueName) + const enumValueNode = findNamedNode(node.values, change.meta.enumValueName); const deprecation = getDeprecatedDirectiveNode(enumValueNode); setArgument(deprecation, 'reason', stringNode(change.meta.newEnumValueDeprecationReason)); } } { - const addedChanges = changes[ChangeType.EnumValueDeprecationReasonAdded]?.filter(change => change.meta.enumName === node.name.value); + const addedChanges = changes[ChangeType.EnumValueDeprecationReasonAdded]?.filter( + change => change.meta.enumName === node.name.value, + ); for (const change of addedChanges ?? []) { - const enumValueNode = findNamedNode(node.values, change.meta.enumValueName) + const enumValueNode = findNamedNode(node.values, change.meta.enumValueName); const deprecation = getDeprecatedDirectiveNode(enumValueNode); setArgument(deprecation, 'reason', stringNode(change.meta.addedValueDeprecationReason)); } } { - const removalChanges = changes[ChangeType.EnumValueDeprecationReasonRemoved] - ?.filter(change => change.meta.enumName === node.name.value); + const removalChanges = changes[ChangeType.EnumValueDeprecationReasonRemoved]?.filter( + change => change.meta.enumName === node.name.value, + ); for (const change of removalChanges ?? []) { - const enumValueNode = findNamedNode(node.values, change.meta.enumValueName) - const deprecation = getDeprecatedDirectiveNode(enumValueNode); - removeArgument(deprecation, 'reason'); - } + const enumValueNode = findNamedNode(node.values, change.meta.enumValueName); + const deprecation = getDeprecatedDirectiveNode(enumValueNode); + removeArgument(deprecation, 'reason'); + } } return node; } function patchDirective(node: DirectiveDefinitionNode, changes: ChangesByType) { + for (const change of changes[ChangeType.DirectiveRemoved] ?? []) { + if (change.meta.removedDirectiveName === node.name.value) { + return null; + } + } + + for (const change of changes[ChangeType.DirectiveArgumentAdded] ?? []) { + if (change.meta.directiveName === node.name.value) { + addInputValueDefinitionArgument( + node, + change.meta.addedDirectiveArgumentName, + namedType('Foo'), // @todo type + stringNode('TBD'), // @todo defaultValue + undefined, // @todo description + undefined, // @todo directives + ); + } + } + + for (const change of changes[ChangeType.DirectiveArgumentDefaultValueChanged] ?? []) { + if (change.meta.directiveName === node.name.value) { + setInputValueDefinitionArgument(node, change.meta.directiveArgumentName, { + defaultValue: change.meta.newDirectiveArgumentDefaultValue + ? parseConstValue(change.meta.newDirectiveArgumentDefaultValue) + : undefined, + }); + } + } + + for (const change of changes[ChangeType.DirectiveArgumentDescriptionChanged] ?? []) { + if (change.meta.directiveName === node.name.value) { + setInputValueDefinitionArgument(node, change.meta.directiveArgumentName, { + description: change.meta.newDirectiveArgumentDescription + ? stringNode(change.meta.newDirectiveArgumentDescription) + : undefined, + }); + } + } + + for (const change of changes[ChangeType.DirectiveArgumentRemoved] ?? []) { + if (change.meta.directiveName === node.name.value) { + removeInputValueDefinitionArgument(node, change.meta.removedDirectiveArgumentName); + } + } + + for (const change of changes[ChangeType.DirectiveArgumentTypeChanged] ?? []) { + if (change.meta.directiveName === node.name.value) { + setInputValueDefinitionArgument(node, change.meta.directiveArgumentName, { + type: parseType(change.meta.newDirectiveArgumentType), + }); + } + } + + for (const change of changes[ChangeType.DirectiveDescriptionChanged] ?? []) { + if (change.meta.directiveName === node.name.value) { + (node.description as StringValueNode | undefined) = change.meta.newDirectiveDescription + ? stringNode(change.meta.newDirectiveDescription) + : undefined; + } + } + + for (const change of changes[ChangeType.DirectiveLocationAdded] ?? []) { + if (change.meta.directiveName === node.name.value) { + (node.locations as NameNode[]) = [ + ...node.locations, + nameNode(change.meta.addedDirectiveLocation), + ]; + } + } + + for (const change of changes[ChangeType.DirectiveLocationRemoved] ?? []) { + if (change.meta.directiveName === node.name.value) { + (node.locations as NameNode[]) = node.locations.filter( + l => l.value !== change.meta.removedDirectiveLocation, + ); + } + } return node; } @@ -286,6 +612,9 @@ type ChangesByType = { [key in TypeOfChangeType]?: Array> }; export function patch(ast: DocumentNode, changes: SerializableChange[]): DocumentNode { // const [schemaDefs, nodesByName] = collectDefinitions(ast); + // @todo changes can impact future changes... they should be applied IN ORDER. + // grouping is faster, but can cause incorrect results... Consider changing this to instead + // group by COORDINATE. This can then be applied more efficiently AND IN ORDER. const changesByType: ChangesByType = {}; for (const change of changes) { changesByType[change.type] ??= []; @@ -293,21 +622,26 @@ export function patch(ast: DocumentNode, changes: SerializableChange[]): Documen } const result = visit(ast, { - ObjectTypeDefinition: (node) => patchObjectType(patchTypeDefinition(node, changesByType), changesByType), - ObjectTypeExtension: (node) => patchObjectType(node, changesByType), - InterfaceTypeDefinition: (node) => patchObjectType(patchTypeDefinition(node, changesByType), changesByType), - InterfaceTypeExtension: (node) => patchObjectType(node, changesByType), - InputObjectTypeDefinition: (node) => patchInputObject(patchTypeDefinition(node, changesByType), changesByType), - InputObjectTypeExtension: (node) => patchInputObject(node, changesByType), - UnionTypeDefinition: (node) => patchUnionType(patchTypeDefinition(node, changesByType), changesByType), - UnionTypeExtension: (node) => patchUnionType(node, changesByType), - ScalarTypeDefinition: (node) => patchTypeDefinition(node, changesByType), + ObjectTypeDefinition: node => + patchObjectType(patchTypeDefinition(node, changesByType), changesByType), + ObjectTypeExtension: node => patchObjectType(node, changesByType), + InterfaceTypeDefinition: node => + patchObjectType(patchTypeDefinition(node, changesByType), changesByType), + InterfaceTypeExtension: node => patchObjectType(node, changesByType), + InputObjectTypeDefinition: node => + patchInputObject(patchTypeDefinition(node, changesByType), changesByType), + InputObjectTypeExtension: node => patchInputObject(node, changesByType), + UnionTypeDefinition: node => + patchUnionType(patchTypeDefinition(node, changesByType), changesByType), + UnionTypeExtension: node => patchUnionType(node, changesByType), + ScalarTypeDefinition: node => patchTypeDefinition(node, changesByType), // ScalarTypeExtension: (node) => patchScalarType(node, changesByType), - EnumTypeDefinition: (node) => patchEnumType(patchTypeDefinition(node, changesByType), changesByType), - EnumTypeExtension: (node) => patchEnumType(node, changesByType), - DirectiveDefinition: (node) => patchDirective(node, changesByType), - SchemaDefinition: (node) => node, - SchemaExtension: (node) => node, + EnumTypeDefinition: node => + patchEnumType(patchTypeDefinition(node, changesByType), changesByType), + EnumTypeExtension: node => patchEnumType(node, changesByType), + DirectiveDefinition: node => patchDirective(node, changesByType), + SchemaDefinition: node => node, + SchemaExtension: node => node, }); return { @@ -325,69 +659,8 @@ export function patch(ast: DocumentNode, changes: SerializableChange[]): Documen kind: Kind.OBJECT_TYPE_DEFINITION, fields: [], name: nameNode(addition.meta.addedTypeName), - } as ObjectTypeDefinitionNode + } as ObjectTypeDefinitionNode; }), ], }; } - -function getDeprecatedDirectiveNode( - definitionNode: Maybe<{ readonly directives?: ReadonlyArray }>, -): Maybe { - return definitionNode?.directives?.find( - (node) => node.name.value === GraphQLDeprecatedDirective.name, - ); -} - -function setArgument( - node: Maybe<{ arguments?: ArgumentNode[] | readonly ArgumentNode[] | undefined}>, - argumentName: string, - value: ValueNode, -): void { - if (node) { - let found = false; - for (const arg of node.arguments ?? []) { - if (arg.name.value === argumentName) { - (arg.value as ValueNode) = value; - found = true; - break; - } - } - if (!found) { - node.arguments = [...(node.arguments ?? []), { - kind: Kind.ARGUMENT, - name: nameNode(argumentName), - value, - }] - } - } -} - -function findNamedNode( - nodes: Maybe>, - name: string, -): T | undefined { - return nodes?.find(value => value.name.value === name); -} - -function removeArgument( - node: Maybe<{ arguments?: ArgumentNode[] | readonly ArgumentNode[] | undefined}>, - argumentName: string, -): void { - if (node?.arguments) { - node.arguments = node.arguments.filter(arg => arg.name.value !== argumentName); - } -} - -// export function printDiff(before: DocumentNode, changes: SerializableChange[]): string { -// // WHAT IF -// /** -// * modify ast to include a flag for added, removed, or updated/moved?... -// * add everything to the AST and print that as a schema.. (but it wont print duplicate field names etc right) -// * ... So write a custom printer that solves^ -// * -// * HOW do keep the removed node around and not mess up the AST?... -// */ - -// return ''; -// } \ No newline at end of file diff --git a/packages/web/app/src/components/proposal/patch/node-templates.ts b/packages/web/app/src/components/proposal/patch/node-templates.ts new file mode 100644 index 0000000000..300be024be --- /dev/null +++ b/packages/web/app/src/components/proposal/patch/node-templates.ts @@ -0,0 +1,22 @@ +import { Kind, NameNode, StringValueNode, TypeNode } from 'graphql'; + +export function nameNode(name: string): NameNode { + return { + value: name, + kind: Kind.NAME, + }; +} + +export function stringNode(value: string): StringValueNode { + return { + kind: Kind.STRING, + value, + }; +} + +export function namedType(name: string): TypeNode { + return { + kind: Kind.NAMED_TYPE, + name: nameNode(name), + }; +} diff --git a/packages/web/app/src/components/proposal/patch/print.ts b/packages/web/app/src/components/proposal/patch/print.ts new file mode 100644 index 0000000000..f1bde6904c --- /dev/null +++ b/packages/web/app/src/components/proposal/patch/print.ts @@ -0,0 +1,12 @@ +// export function printDiff(before: DocumentNode, changes: SerializableChange[]): string { +// // WHAT IF +// /** +// * modify ast to include a flag for added, removed, or updated/moved?... +// * add everything to the AST and print that as a schema.. (but it wont print duplicate field names etc right) +// * ... So write a custom printer that solves^ +// * +// * HOW do keep the removed node around and not mess up the AST?... +// */ + +// return ''; +// } diff --git a/packages/web/app/src/components/proposal/patch/utils.ts b/packages/web/app/src/components/proposal/patch/utils.ts new file mode 100644 index 0000000000..fe11eb171a --- /dev/null +++ b/packages/web/app/src/components/proposal/patch/utils.ts @@ -0,0 +1,172 @@ +import { + ArgumentNode, + ConstDirectiveNode, + ConstValueNode, + DirectiveNode, + GraphQLDeprecatedDirective, + InputValueDefinitionNode, + Kind, + NameNode, + StringValueNode, + TypeNode, + ValueNode, +} from 'graphql'; +import { Maybe } from 'graphql/jsutils/Maybe'; +import { nameNode } from './node-templates'; + +export function getDeprecatedDirectiveNode( + definitionNode: Maybe<{ readonly directives?: ReadonlyArray }>, +): Maybe { + return definitionNode?.directives?.find( + node => node.name.value === GraphQLDeprecatedDirective.name, + ); +} + +export function addInputValueDefinitionArgument( + node: Maybe<{ + arguments?: InputValueDefinitionNode[] | readonly InputValueDefinitionNode[] | undefined; + }>, + argumentName: string, + type: TypeNode, + defaultValue: ConstValueNode | undefined, + description: StringValueNode | undefined, + directives: ConstDirectiveNode[] | undefined, +): void { + if (node) { + let found = false; + for (const arg of node.arguments ?? []) { + if (arg.name.value === argumentName) { + found = true; + break; + } + } + if (found) { + console.error('Cannot patch definition that does not exist.'); + return; + } + + node.arguments = [ + ...(node.arguments ?? []), + { + kind: Kind.INPUT_VALUE_DEFINITION, + name: nameNode(argumentName), + defaultValue, + type, + description, + directives, + }, + ]; + } +} + +export function removeInputValueDefinitionArgument( + node: Maybe<{ + arguments?: InputValueDefinitionNode[] | readonly InputValueDefinitionNode[] | undefined; + }>, + argumentName: string, +): void { + if (node?.arguments) { + node.arguments = node.arguments.filter(({ name }) => name.value !== argumentName); + } else { + // @todo throw and standardize error messages + console.warn('Cannot apply input value argument removal.'); + } +} + +export function setInputValueDefinitionArgument( + node: Maybe<{ + arguments?: InputValueDefinitionNode[] | readonly InputValueDefinitionNode[] | undefined; + }>, + argumentName: string, + values: { + type?: TypeNode; + defaultValue?: ConstValueNode | undefined; + description?: StringValueNode | undefined; + directives?: ConstDirectiveNode[] | undefined; + }, +): void { + if (node) { + let found = false; + for (const arg of node.arguments ?? []) { + if (arg.name.value === argumentName) { + if (Object.hasOwn(values, 'type') && values.type !== undefined) { + (arg.type as TypeNode) = values.type; + } + if (Object.hasOwn(values, 'defaultValue')) { + (arg.defaultValue as ConstValueNode | undefined) = values.defaultValue; + } + if (Object.hasOwn(values, 'description')) { + (arg.description as StringValueNode | undefined) = values.description; + } + if (Object.hasOwn(values, 'directives')) { + (arg.directives as ConstDirectiveNode[] | undefined) = values.directives; + } + found = true; + break; + } + } + if (!found) { + console.error('Cannot patch definition that does not exist.'); + // @todo throw error? + } + } +} + +export function setArgument( + node: Maybe<{ arguments?: ArgumentNode[] | readonly ArgumentNode[] | undefined }>, + argumentName: string, + value: ValueNode, +): void { + if (node) { + let found = false; + for (const arg of node.arguments ?? []) { + if (arg.name.value === argumentName) { + (arg.value as ValueNode) = value; + found = true; + break; + } + } + if (!found) { + node.arguments = [ + ...(node.arguments ?? []), + { + kind: Kind.ARGUMENT, + name: nameNode(argumentName), + value, + }, + ]; + } + } +} + +export function findNamedNode( + nodes: Maybe>, + name: string, +): T | undefined { + return nodes?.find(value => value.name.value === name); +} + +/** + * @returns the removed node or undefined if no node matches the name. + */ +export function removeNamedNode( + nodes: Maybe>, + name: string, +): T | undefined { + if (nodes) { + const index = nodes?.findIndex(node => node.name.value === name); + if (index !== -1) { + const [deleted] = nodes.splice(index, 1); + return deleted; + } + } +} + +export function removeArgument( + node: Maybe<{ arguments?: ArgumentNode[] | readonly ArgumentNode[] | undefined }>, + argumentName: string, +): void { + if (node?.arguments) { + node.arguments = node.arguments.filter(arg => arg.name.value !== argumentName); + } +} diff --git a/packages/web/app/src/components/proposal/proposal-sdl.tsx b/packages/web/app/src/components/proposal/proposal-sdl.tsx index 5617cc1b02..b3e165f92b 100644 --- a/packages/web/app/src/components/proposal/proposal-sdl.tsx +++ b/packages/web/app/src/components/proposal/proposal-sdl.tsx @@ -67,9 +67,9 @@ export function ProposalSDL(props: { }) { try { void diff( - buildSchema(props.diffSdl, { assumeValid: true, assumeValidSDL: true }), - buildSchema(props.sdl, { assumeValid: true, assumeValidSDL: true }), - ).then((changes) => { + buildSchema(props.diffSdl, { assumeValid: true, assumeValidSDL: true }), + buildSchema(props.sdl, { assumeValid: true, assumeValidSDL: true }), + ).then(changes => { console.log('DIFF WORKED', changes); }); } catch (e) { @@ -87,28 +87,25 @@ export function ProposalSDL(props: { try { let diffSchema: GraphQLSchema | undefined; - try { + try { diffSchema = buildSchema(props.diffSdl, { assumeValid: true, assumeValidSDL: true }); } catch (e) { - console.error('Diff schema is invalid.') + console.error('Diff schema is invalid.'); } - + const schema = buildSchema(props.sdl, { assumeValid: true, assumeValidSDL: true }); - const coordinateToLineMap = collectCoordinateLocations( - schema, - new Source(props.sdl), - ); + const coordinateToLineMap = collectCoordinateLocations(schema, new Source(props.sdl)); if (diffSchema) { // @todo run schema check and get diff from that API.... That way usage can be checked easily. - void diff(diffSchema, schema, undefined) + void diff(diffSchema, schema, undefined); } // @note assume reviews are specific to the current service... const globalReviews: ReviewNode[] = []; const reviewsByLine = new Map(); - const serviceReviews = reviewsConnection?.edges - ?.filter(edge => { + const serviceReviews = + reviewsConnection?.edges?.filter(edge => { const { schemaProposalVersion } = edge.node; return schemaProposalVersion?.serviceName === props.serviceName; }) ?? []; @@ -134,48 +131,58 @@ export function ProposalSDL(props: { {props.sdl.split('\n').flatMap((txt, index) => { const lineNumber = index + 1; const diffLineMatch = txt === diffSdlLines[diffLineNumber]; - const elements = [{txt}]; + const elements = [ + + {txt} + , + ]; if (diffLineMatch) { diffLineNumber = diffLineNumber + 1; } - - const review = reviewsByLine.get(lineNumber) + + const review = reviewsByLine.get(lineNumber); if (review) { if (review.isStale) { - elements.push(( + elements.push( - - -
This review references an outdated version of the proposal.
+ + +
+ This review references an outdated version of the proposal. +
{!!review.lineText && ( - - + + {review.lineText} )} - - )) + , + ); } elements.push( - - - - + + + + - + , ); } return elements; })} {globalReviews.map(r => { - return ( -
- {r.id} -
- ) + return
{r.id}
; })} ); diff --git a/packages/web/app/src/pages/target-proposal-overview.tsx b/packages/web/app/src/pages/target-proposal-overview.tsx index 54ad66936f..2df7b0ab7a 100644 --- a/packages/web/app/src/pages/target-proposal-overview.tsx +++ b/packages/web/app/src/pages/target-proposal-overview.tsx @@ -1,11 +1,11 @@ import { useQuery } from 'urql'; import { ProposalSDL } from '@/components/proposal/proposal-sdl'; import { stageToColor, userText } from '@/components/proposal/util'; +import { Callout } from '@/components/ui/callout'; import { Subtitle, Title } from '@/components/ui/page'; import { Spinner } from '@/components/ui/spinner'; import { Tag, TimeAgo } from '@/components/v2'; import { graphql } from '@/gql'; -import { Callout } from '@/components/ui/callout'; const ProposalOverviewQuery = graphql(/** GraphQL */ ` query ProposalOverviewQuery($id: ID!) { @@ -19,12 +19,12 @@ const ProposalOverviewQuery = graphql(/** GraphQL */ ` schemas { edges { node { - ...on CompositeSchema { + ... on CompositeSchema { id source service } - ...on SingleSchema { + ... on SingleSchema { id source } @@ -155,7 +155,7 @@ export function TargetProposalOverviewPage(props: { // } // `; - const sdl = /** GraphQL */` + const sdl = /** GraphQL */ ` extend schema @link( url: "https://specs.apollo.dev/federation/v2.3" @@ -231,7 +231,7 @@ export function TargetProposalOverviewPage(props: { `; return ( -
+
{query.fetching && } {proposal && ( <> @@ -246,8 +246,9 @@ export function TargetProposalOverviewPage(props: { Last updated
{query.data?.latestVersion && query.data.latestVersion.isValid === false && ( - - The latest schema is invalid. Showing comparison against latest valid schema {query.data.latestValidVersion?.id} + + The latest schema is invalid. Showing comparison against latest valid schema{' '} + {query.data.latestValidVersion?.id} )} )} From 21e227526c7b309a225ca055d5a442c1cd2cd694 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Wed, 16 Jul 2025 18:51:28 -0700 Subject: [PATCH 16/54] In order change processing; handle most change types --- .../storage/src/schema-change-model.ts | 1 + .../proposal/patch/__tests__/diff.test.ts | 195 ----- .../proposal/patch/__tests__/index.test.ts | 397 +++++++++++ .../app/src/components/proposal/patch/diff.ts | 666 ------------------ .../src/components/proposal/patch/errors.ts | 113 +++ .../src/components/proposal/patch/index.ts | 301 ++++++++ .../proposal/patch/node-templates.ts | 11 +- .../proposal/patch/patches/directives.ts | 217 ++++++ .../components/proposal/patch/patches/enum.ts | 151 ++++ .../proposal/patch/patches/fields.ts | 262 +++++++ .../proposal/patch/patches/inputs.ts | 105 +++ .../proposal/patch/patches/interfaces.ts | 0 .../proposal/patch/patches/schema.ts | 77 ++ .../proposal/patch/patches/types.ts | 127 ++++ .../proposal/patch/patches/unions.ts | 33 + .../src/components/proposal/patch/types.ts | 32 + .../src/components/proposal/patch/utils.ts | 83 ++- 17 files changed, 1887 insertions(+), 884 deletions(-) delete mode 100644 packages/web/app/src/components/proposal/patch/__tests__/diff.test.ts create mode 100644 packages/web/app/src/components/proposal/patch/__tests__/index.test.ts delete mode 100644 packages/web/app/src/components/proposal/patch/diff.ts create mode 100644 packages/web/app/src/components/proposal/patch/errors.ts create mode 100644 packages/web/app/src/components/proposal/patch/index.ts create mode 100644 packages/web/app/src/components/proposal/patch/patches/directives.ts create mode 100644 packages/web/app/src/components/proposal/patch/patches/enum.ts create mode 100644 packages/web/app/src/components/proposal/patch/patches/fields.ts create mode 100644 packages/web/app/src/components/proposal/patch/patches/inputs.ts create mode 100644 packages/web/app/src/components/proposal/patch/patches/interfaces.ts create mode 100644 packages/web/app/src/components/proposal/patch/patches/schema.ts create mode 100644 packages/web/app/src/components/proposal/patch/patches/types.ts create mode 100644 packages/web/app/src/components/proposal/patch/patches/unions.ts create mode 100644 packages/web/app/src/components/proposal/patch/types.ts diff --git a/packages/services/storage/src/schema-change-model.ts b/packages/services/storage/src/schema-change-model.ts index 10106fe083..f48eb3be6b 100644 --- a/packages/services/storage/src/schema-change-model.ts +++ b/packages/services/storage/src/schema-change-model.ts @@ -281,6 +281,7 @@ export const DirectiveArgumentAddedModel = implement { - console.log(`Applying changes: ${editScript.map(e => JSON.stringify(e)).join('\n')}`); - expect(printSortedSchema(schemaB)).toBe(printSortedSchema(patchSchema(schemaA, editScript))); -}); - -// test.only('printDiff', () => { -// expect(printDiff(schemaB, applyChanges(schemaA, editScript))).toBe(''); -// }) diff --git a/packages/web/app/src/components/proposal/patch/__tests__/index.test.ts b/packages/web/app/src/components/proposal/patch/__tests__/index.test.ts new file mode 100644 index 0000000000..23bff3bbfc --- /dev/null +++ b/packages/web/app/src/components/proposal/patch/__tests__/index.test.ts @@ -0,0 +1,397 @@ +import { buildSchema, GraphQLSchema, lexicographicSortSchema, printSchema } from 'graphql'; +import type { Change, CriticalityLevel } from '@graphql-inspector/core'; +import { patchSchema } from '../index'; + +function printSortedSchema(schema: GraphQLSchema) { + return printSchema(lexicographicSortSchema(schema)); +} + +const schemaA = buildSchema( + /* GraphQL */ ` + extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.3" + import: ["@key", "@shareable", "@inaccessible", "@tag"] + ) + @link(url: "https://specs.graphql-hive.com/hive/v1.0", import: ["@meta"]) + @meta(name: "priority", content: "tier1") + + directive @meta( + name: String! + content: String! + ) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + + directive @myDirective(a: String!) on FIELD_DEFINITION + + directive @hello on FIELD_DEFINITION + + type Query { + allProducts: [ProductItf] @meta(name: "owner", content: "hive-team") + product(id: ID!): ProductItf + } + + interface ProductItf implements SkuItf @meta(name: "domain", content: "products") { + id: ID! + sku: String + name: String + package: String + variation: ProductVariation + dimensions: ProductDimension + createdBy: User + hidden: String @inaccessible + oldField: String @deprecated(reason: "refactored out") + } + + interface SkuItf { + sku: String + } + + type Product implements ProductItf & SkuItf + @key(fields: "id") + @key(fields: "sku package") + @key(fields: "sku variation { id }") + @meta(name: "owner", content: "product-team") { + id: ID! @tag(name: "hi-from-products") + sku: String @meta(name: "unique", content: "true") + name: String @hello + package: String + variation: ProductVariation + dimensions: ProductDimension + createdBy: User + hidden: String + reviewsScore: Float! + oldField: String + } + + enum ShippingClass { + STANDARD + EXPRESS + } + + type ProductVariation { + id: ID! + name: String + } + + type ProductDimension @shareable { + size: String + weight: Float + } + + type User @key(fields: "email") { + email: ID! + totalProductsCreated: Int @shareable + } + `, + { assumeValid: true, assumeValidSDL: true }, +); + +const schemaB = buildSchema( + /* GraphQL */ ` + extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.3" + import: ["@key", "@shareable", "@inaccessible", "@tag"] + ) + @link(url: "https://specs.graphql-hive.com/hive/v1.0", import: ["@meta"]) + @meta(name: "priority", content: "tier1") + + directive @meta( + name: String! + content: String! + ) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + + directive @myDirective(a: String!) on FIELD_DEFINITION + + directive @hello on FIELD_DEFINITION + + type Query { + allProducts(input: AllProductsInput): [ProductItf] @meta(name: "owner", content: "hive-team") + product(id: ID!): ProductItf + } + + input AllProductsInput { + """ + User ID who created the product record + """ + byCreator: ID + + """ + Search by partial match on a name. + """ + byName: String + } + + interface ProductItf implements SkuItf @meta(name: "domain", content: "products") { + id: ID! + sku: String + name: String + package: String + variation: ProductVariation + dimensions: ProductDimension + createdBy: User + hidden: String @inaccessible + } + + interface SkuItf { + sku: String + } + + type Product implements ProductItf & SkuItf + @key(fields: "id") + @key(fields: "sku package") + @key(fields: "sku variation { id }") + @meta(name: "owner", content: "product-team") { + id: ID! @tag(name: "hi-from-products") + sku: String @meta(name: "unique", content: "true") + name: String @hello + package: String + """ + The latest variation + """ + variation: ProductVariation + @deprecated(reason: "There can be multiple variations. Prefer Product.variations") + variations: [ProductVariation] + dimensions: ProductDimension + createdBy: User! + hidden: String + reviewsScore: Float! + } + + enum ShippingClass { + STANDARD + EXPRESS + OVERNIGHT + } + + type ProductVariation { + id: ID! + name: String + } + + type ProductDimension @shareable { + size: String + weight: Float + } + + type User @key(fields: "email") { + email: ID! + totalProductsCreated: Int @shareable + } + `, + { assumeValid: true, assumeValidSDL: true }, +); + +const changes: Change[] = [ + { + criticality: { + level: 'NON_BREAKING' as CriticalityLevel, + }, + message: "Type 'AllProductsInput' was added", + meta: { + addedTypeIsOneOf: false, + addedTypeKind: 'InputObjectTypeDefinition', + addedTypeName: 'AllProductsInput', + }, + path: 'AllProductsInput', + type: 'TYPE_ADDED', + }, + { + criticality: { + level: 'NON_BREAKING' as CriticalityLevel, + }, + message: + "Input field 'byCreator' of type 'ID' was added to input object type 'AllProductsInput'", + meta: { + addedInputFieldName: 'byCreator', + addedInputFieldType: 'ID', + addedToNewType: true, + inputName: 'AllProductsInput', + isAddedInputFieldTypeNullable: true, + }, + path: 'AllProductsInput.byCreator', + type: 'INPUT_FIELD_ADDED', + }, + { + criticality: { + level: 'NON_BREAKING' as CriticalityLevel, + }, + message: + "Input field 'AllProductsInput.byCreator' has description 'User ID who created the product record'", + meta: { + addedInputFieldDescription: 'User ID who created the product record', + inputFieldName: 'byCreator', + inputName: 'AllProductsInput', + }, + path: 'AllProductsInput.byCreator', + type: 'INPUT_FIELD_DESCRIPTION_ADDED', + }, + { + criticality: { + level: 'NON_BREAKING' as CriticalityLevel, + }, + message: + "Input field 'byName' of type 'String' was added to input object type 'AllProductsInput'", + meta: { + addedInputFieldName: 'byName', + addedInputFieldType: 'String', + addedToNewType: true, + inputName: 'AllProductsInput', + isAddedInputFieldTypeNullable: true, + }, + path: 'AllProductsInput.byName', + type: 'INPUT_FIELD_ADDED', + }, + { + criticality: { + level: 'NON_BREAKING' as CriticalityLevel, + }, + message: + "Input field 'AllProductsInput.byName' has description 'Search by partial match on a name.'", + meta: { + addedInputFieldDescription: 'Search by partial match on a name.', + inputFieldName: 'byName', + inputName: 'AllProductsInput', + }, + path: 'AllProductsInput.byName', + type: 'INPUT_FIELD_DESCRIPTION_ADDED', + }, + { + criticality: { + level: 'DANGEROUS' as CriticalityLevel.Dangerous, + }, + message: "Argument 'input: AllProductsInput' added to field 'Query.allProducts'", + meta: { + addedArgumentName: 'input', + addedArgumentType: 'AllProductsInput', + addedToNewField: false, + fieldName: 'allProducts', + hasDefaultValue: false, + isAddedFieldArgumentBreaking: false, + typeName: 'Query', + }, + path: 'Query.allProducts.input', + type: 'FIELD_ARGUMENT_ADDED', + }, + { + criticality: { + level: 'BREAKING' as CriticalityLevel, + reason: + "Removing a deprecated field is a breaking change. Before removing it, you may want to look at the field's usage to see the impact of removing the field.", + }, + message: "Field 'oldField' (deprecated) was removed from interface 'ProductItf'", + meta: { + isRemovedFieldDeprecated: true, + removedFieldName: 'oldField', + typeName: 'ProductItf', + typeType: 'interface', + }, + path: 'ProductItf.oldField', + type: 'FIELD_REMOVED', + }, + { + criticality: { + level: 'NON_BREAKING' as CriticalityLevel, + }, + message: "Field 'variations' was added to object type 'Product'", + meta: { + addedFieldName: 'variations', + addedFieldReturnType: '[ProductVariation]', + typeName: 'Product', + typeType: 'object type', + }, + path: 'Product.variations', + type: 'FIELD_ADDED', + }, + { + criticality: { + level: 'BREAKING' as CriticalityLevel, + reason: + 'Removing a field is a breaking change. It is preferable to deprecate the field before removing it. This applies to removed union fields as well, since removal breaks client operations that contain fragments that reference the removed type through direct (... on RemovedType) or indirect means such as __typename in the consumers.', + }, + message: "Field 'oldField' was removed from object type 'Product'", + meta: { + isRemovedFieldDeprecated: false, + removedFieldName: 'oldField', + typeName: 'Product', + typeType: 'object type', + }, + path: 'Product.oldField', + type: 'FIELD_REMOVED', + }, + { + criticality: { + level: 'NON_BREAKING' as CriticalityLevel, + }, + message: "Field 'Product.variation' has description 'The latest variation'", + meta: { + addedDescription: 'The latest variation', + fieldName: 'variation', + typeName: 'Product', + }, + path: 'Product.variation', + type: 'FIELD_DESCRIPTION_ADDED', + }, + { + criticality: { + level: 'NON_BREAKING' as CriticalityLevel, + }, + message: "Field 'Product.variation' is deprecated", + meta: { + deprecationReason: 'There can be multiple variations. Prefer Product.variations', + fieldName: 'variation', + typeName: 'Product', + }, + path: 'Product.variation', + type: 'FIELD_DEPRECATION_ADDED', + }, + { + criticality: { + level: 'NON_BREAKING' as CriticalityLevel, + reason: "Directive 'deprecated' was added to field 'variation'", + }, + message: "Directive 'deprecated' was added to field 'Product.variation'", + meta: { + addedDirectiveName: 'deprecated', + addedToNewType: false, + fieldName: 'variation', + typeName: 'Product', + }, + path: 'Product.variation.deprecated', + type: 'DIRECTIVE_USAGE_FIELD_DEFINITION_ADDED', + }, + { + criticality: { + level: 'NON_BREAKING' as CriticalityLevel, + }, + message: "Field 'Product.createdBy' changed type from 'User' to 'User!'", + meta: { + fieldName: 'createdBy', + isSafeFieldTypeChange: true, + newFieldType: 'User!', + oldFieldType: 'User', + typeName: 'Product', + }, + path: 'Product.createdBy', + type: 'FIELD_TYPE_CHANGED', + }, + { + criticality: { + level: 'DANGEROUS' as CriticalityLevel.Dangerous, + reason: + 'Adding an enum value may break existing clients that were not programming defensively against an added case when querying an enum.', + }, + message: "Enum value 'OVERNIGHT' was added to enum 'ShippingClass'", + meta: { + addedEnumValueName: 'OVERNIGHT', + addedToNewType: false, + enumName: 'ShippingClass', + }, + path: 'ShippingClass.OVERNIGHT', + type: 'ENUM_VALUE_ADDED', + }, +]; + +test('patch', async () => { + expect(printSortedSchema(schemaB)).toBe(printSortedSchema(patchSchema(schemaA, changes))); +}); diff --git a/packages/web/app/src/components/proposal/patch/diff.ts b/packages/web/app/src/components/proposal/patch/diff.ts deleted file mode 100644 index ac91e89a8c..0000000000 --- a/packages/web/app/src/components/proposal/patch/diff.ts +++ /dev/null @@ -1,666 +0,0 @@ -import { - buildASTSchema, - ConstValueNode, - DirectiveDefinitionNode, - DocumentNode, - EnumTypeDefinitionNode, - EnumTypeExtensionNode, - EnumValueDefinitionNode, - FieldDefinitionNode, - GraphQLSchema, - InputObjectTypeDefinitionNode, - InputObjectTypeExtensionNode, - InputValueDefinitionNode, - InterfaceTypeDefinitionNode, - InterfaceTypeExtensionNode, - Kind, - NamedTypeNode, - NameNode, - ObjectTypeDefinitionNode, - ObjectTypeExtensionNode, - parse, - parseConstValue, - parseType, - parseValue, - print, - printSchema, - StringValueNode, - TypeDefinitionNode, - TypeNode, - UnionTypeDefinitionNode, - UnionTypeExtensionNode, - ValueNode, - visit, -} from 'graphql'; -import { Change, ChangeType, SerializableChange } from '@graphql-inspector/core'; -import { namedType, nameNode, stringNode } from './node-templates'; -import { - addInputValueDefinitionArgument, - findNamedNode, - getDeprecatedDirectiveNode, - removeArgument, - removeInputValueDefinitionArgument, - removeNamedNode, - setArgument, - setInputValueDefinitionArgument, -} from './utils'; - -export type TypeOfChangeType = (typeof ChangeType)[keyof typeof ChangeType]; - -export function patchSchema(schema: GraphQLSchema, changes: SerializableChange[]): GraphQLSchema { - const ast = parse(printSchema(schema)); - return buildASTSchema(patch(ast, changes), { assumeValid: true, assumeValidSDL: true }); -} - -export function patchTypeDefinition( - node: T, - changes: ChangesByType, -): T { - /** CHANGE DESCRIPTION */ - { - const change = changes[ChangeType.TypeDescriptionAdded]?.findLast( - ({ meta }) => meta.typeName === node.name.value, - ); - if (change) { - (node.description as StringValueNode) = stringNode(change.meta.addedTypeDescription); - } - } - { - const change = changes[ChangeType.TypeDescriptionChanged]?.findLast( - ({ meta }) => meta.typeName === node.name.value, - ); - if (change) { - (node.description as StringValueNode) = stringNode(change.meta.newTypeDescription); - } - } - { - const change = changes[ChangeType.TypeDescriptionRemoved]?.findLast( - ({ meta }) => meta.typeName === node.name.value, - ); - if (change) { - (node.description as StringValueNode | undefined) = undefined; - } - } - return node; -} - -function patchObjectType( - node: - | ObjectTypeDefinitionNode - | ObjectTypeExtensionNode - | InterfaceTypeDefinitionNode - | InterfaceTypeExtensionNode, - changes: ChangesByType, -) { - /** REMOVE TYPES */ - const isRemoved = - changes[ChangeType.TypeRemoved]?.find( - change => change.meta.removedTypeName === node.name.value, - ) ?? false; - if (isRemoved) { - return null; - } - - /** REMOVE FIELDS */ - const fieldRemovalsForType: Set = new Set( - (changes[ChangeType.FieldRemoved] ?? []) - .filter(change => change.meta.typeName === node.name.value) - // @note consider using more of the metadata OR pre-mapping the removed fields to avoid having to map for every type's list. - .map(f => f.meta.removedFieldName), - ); - - if (fieldRemovalsForType.size) { - (node.fields as any) = node.fields?.filter(f => !fieldRemovalsForType.has(f.name.value)); - } - - /** ADD FIELDS */ - const addedFields = changes[ChangeType.FieldAdded] - ?.filter(change => change.meta.typeName === node.name.value) - .map(change => { - const fieldDefinitionNode: FieldDefinitionNode = { - kind: Kind.FIELD_DEFINITION, - name: nameNode(change.meta.addedFieldName), - // @todo typeType is bugged atm. Fix this. Prefer adding a new `addedFieldType` field. - // type: parseType(change.meta.typeType), - type: parseType('String'), - }; - return fieldDefinitionNode; - }); - - if (addedFields?.length) { - (node.fields as any) = [...(node.fields ?? []), ...addedFields]; - } - - /** Patch fields */ - (node.fields as any) = node.fields?.map(field => patchField(node.name.value, field, changes)); - - /** REMOVE INTERFACES */ - { - const removals = changes[ChangeType.ObjectTypeInterfaceRemoved] - ?.filter(change => change.meta.objectTypeName === node.name.value) - .map(node => node.meta.removedInterfaceName); - (node.interfaces as NamedTypeNode[] | undefined) = node.interfaces?.filter( - ({ name }) => !removals?.includes(name.value), - ); - } - - /** ADD INTERFACES */ - { - const additions = changes[ChangeType.ObjectTypeInterfaceAdded] - ?.filter(change => change.meta.objectTypeName === node.name.value) - .map(node => node.meta.addedInterfaceName); - if (additions) { - (node.interfaces as NamedTypeNode[]) = [ - ...(node.interfaces ?? []), - ...additions.map( - (name): NamedTypeNode => ({ - kind: Kind.NAMED_TYPE, - name: nameNode(name), - }), - ), - ]; - } - } - - return node; -} - -function patchField(typeName: string, field: FieldDefinitionNode, changes: ChangesByType) { - { - const change = changes[ChangeType.FieldDescriptionAdded]?.findLast( - change => change.meta.typeName === typeName && field.name.value === change.meta.fieldName, - ); - if (change && field.description) { - (field.description as StringValueNode) = stringNode(change.meta.addedDescription); - } - } - - { - const change = changes[ChangeType.FieldDescriptionChanged]?.findLast( - change => change.meta.typeName === typeName && field.name.value === change.meta.fieldName, - ); - if (change && field.description) { - (field.description as StringValueNode) = stringNode(change.meta.newDescription); - } - } - - { - const change = changes[ChangeType.FieldDescriptionRemoved]?.findLast( - change => change.meta.typeName === typeName && field.name.value === change.meta.fieldName, - ); - if (change && field.description) { - (field.description as StringValueNode | undefined) = undefined; - } - } - - { - const change = changes[ChangeType.FieldTypeChanged]?.findLast( - change => change.meta.typeName === typeName && field.name.value === change.meta.fieldName, - ); - if (change) { - (field.type as TypeNode) = parseType(change.meta.newFieldType); - } - } - - { - const fieldChanges = changes[ChangeType.FieldArgumentAdded]?.filter( - change => change.meta.typeName === typeName && field.name.value === change.meta.fieldName, - ); - if (fieldChanges) { - const argumentAdditions = fieldChanges.map( - (change): InputValueDefinitionNode => ({ - kind: Kind.INPUT_VALUE_DEFINITION, - name: nameNode(change.meta.addedArgumentName), - type: parseType(change.meta.addedArgumentType), - // defaultValue: change.meta.hasDefaultValue ? - }), - ); - (field.arguments as InputValueDefinitionNode[] | undefined) = [ - ...(field.arguments ?? []), - ...argumentAdditions, - ]; - } - } - - const patchedArguments = field.arguments - ?.map(argumentNode => patchFieldArgument(typeName, field.name.value, argumentNode, changes)) - .filter(n => !!n); - const addedArguments = changes[ChangeType.FieldArgumentAdded] - ?.filter( - change => change.meta.typeName === typeName && change.meta.fieldName === field.name.value, - ) - .map( - (change): InputValueDefinitionNode => ({ - kind: Kind.INPUT_VALUE_DEFINITION, - name: nameNode(change.meta.addedArgumentName), - type: parseType(change.meta.addedArgumentType), - // @todo handle default value and description etc. - }), - ); - const fieldArgs = [...(patchedArguments ?? []), ...(addedArguments ?? [])]; - (field.arguments as InputValueDefinitionNode[] | undefined) = fieldArgs.length - ? fieldArgs - : undefined; - - return field; -} - -function patchFieldArgument( - typeName: string, - fieldName: string, - arg: InputValueDefinitionNode, - changes: ChangesByType, -) { - const descriptionChanges = (changes[ChangeType.FieldArgumentDescriptionChanged] ?? []).filter( - change => change.meta.typeName === typeName && change.meta.fieldName === fieldName, - ); - for (const change of descriptionChanges) { - if (arg.description?.value !== (change.meta.oldDescription ?? undefined)) { - console.warn('Conflict: Description does not match previous change description.'); - continue; - } - (arg.description as StringValueNode | undefined) = change.meta.newDescription - ? stringNode(change.meta.newDescription) - : undefined; - } - - const defaultChanges = (changes[ChangeType.FieldArgumentDefaultChanged] ?? []).filter( - change => change.meta.typeName === typeName && change.meta.fieldName === fieldName, - ); - for (const change of defaultChanges) { - if (arg.defaultValue === undefined) { - if (change.meta.oldDefaultValue !== undefined) { - console.warn( - `Conflict: Default value "${arg.defaultValue}" does not match previous change default value of "${change.meta.oldDefaultValue}".`, - ); - return; - } - } else if (print(arg.defaultValue) !== change.meta.oldDefaultValue) { - console.warn( - `Conflict: Default value "${print(arg.defaultValue)}" does not match previous change default value of "${change.meta.oldDefaultValue}".`, - ); - return; - } - ((arg as InputValueDefinitionNode).defaultValue as ValueNode | undefined) = change.meta - .newDefaultValue - ? parseValue(change.meta.newDefaultValue) - : undefined; - } - - const removalChange = (changes[ChangeType.FieldArgumentRemoved] ?? []).find( - change => - change.meta.typeName === typeName && - change.meta.fieldName === fieldName && - change.meta.removedFieldArgumentName === arg.name.value, - ); - if (removalChange) { - return null; - } - - return arg; -} - -function patchInputObject( - node: InputObjectTypeDefinitionNode | InputObjectTypeExtensionNode, - changes: ChangesByType, -) { - for (const change of changes[ChangeType.InputFieldAdded] ?? []) { - if (change.meta.inputName === node.name.value) { - (node.fields as InputValueDefinitionNode[]) = [ - ...(node.fields ?? []), - { - kind: Kind.INPUT_VALUE_DEFINITION, - name: nameNode(change.meta.addedInputFieldName), - type: parseType(change.meta.addedInputFieldType), - // defaultValue, - // description, - // directives, - // loc, - }, - ]; - } - } - - for (const change of changes[ChangeType.InputFieldDefaultValueChanged] ?? []) { - if (change.meta.inputName === node.name.value) { - const field = findNamedNode(node.fields, change.meta.inputFieldName); - if (field) { - (field.defaultValue as ConstValueNode | undefined) = change.meta.newDefaultValue - ? parseConstValue(change.meta.newDefaultValue) - : undefined; - } else { - console.error( - `Patch error. No field found at "${change.meta.inputName}.${change.meta.inputFieldName}"`, - ); - } - } else { - console.error(`Patch error. No input type found named "${change.meta.inputName}"`); - } - } - - for (const change of changes[ChangeType.InputFieldDescriptionAdded] ?? []) { - if (change.meta.inputName === node.name.value) { - const field = findNamedNode(node.fields, change.meta.inputFieldName); - if (field) { - (field.description as StringValueNode) = stringNode(change.meta.addedInputFieldDescription); - } else { - console.error( - `Patch error. No field found at "${change.meta.inputName}.${change.meta.inputFieldName}"`, - ); - } - } else { - console.error(`Patch error. No input type found named "${change.meta.inputName}"`); - } - } - - for (const change of changes[ChangeType.InputFieldDescriptionChanged] ?? []) { - if (change.meta.inputName === node.name.value) { - const field = findNamedNode(node.fields, change.meta.inputFieldName); - if (field) { - (field.description as StringValueNode) = stringNode(change.meta.newInputFieldDescription); - } else { - console.error( - `Patch error. No field found at "${change.meta.inputName}.${change.meta.inputFieldName}"`, - ); - } - } else { - console.error(`Patch error. No input type found named "${change.meta.inputName}"`); - } - } - - for (const change of changes[ChangeType.InputFieldDescriptionRemoved] ?? []) { - if (change.meta.inputName === node.name.value) { - const field = findNamedNode(node.fields, change.meta.inputFieldName); - if (field) { - (field.description as StringValueNode | undefined) = undefined; - } else { - console.error( - `Patch error. No field found at "${change.meta.inputName}.${change.meta.inputFieldName}"`, - ); - } - } else { - console.error(`Patch error. No input type found named "${change.meta.inputName}"`); - } - } - - for (const change of changes[ChangeType.InputFieldRemoved] ?? []) { - if (change.meta.inputName === node.name.value) { - const removed = removeNamedNode( - node.fields as InputValueDefinitionNode[], - change.meta.removedFieldName, - ); - if (!removed) { - console.error( - `Patch error. No field found at "${change.meta.inputName}.${change.meta.removedFieldName}"`, - ); - } - } else { - console.error(`Patch error. No input type found named "${change.meta.inputName}"`); - } - } - - for (const change of changes[ChangeType.InputFieldTypeChanged] ?? []) { - if (change.meta.inputName === node.name.value) { - const field = findNamedNode(node.fields, change.meta.inputFieldName); - if (field) { - (field.type as TypeNode | undefined) = parseType(change.meta.newInputFieldType); - } else { - console.error( - `Patch error. No field found at "${change.meta.inputName}.${change.meta.inputFieldName}"`, - ); - } - } else { - console.error(`Patch error. No input type found named "${change.meta.inputName}"`); - } - } - - return node; -} - -function patchUnionType( - node: UnionTypeDefinitionNode | UnionTypeExtensionNode, - changes: ChangesByType, -) { - for (const change of changes[ChangeType.UnionMemberAdded] ?? []) { - if (node.name.value === change.meta.unionName) { - (node.types as NamedTypeNode[]) = [ - ...(node.types ?? []), - { - name: nameNode(change.meta.addedUnionMemberTypeName), - kind: Kind.NAMED_TYPE, - }, - ]; - } - } - - for (const change of changes[ChangeType.UnionMemberRemoved] ?? []) { - if (node.name.value === change.meta.removedUnionMemberTypeName) { - return null; - } - } - - return node; -} - -function patchEnumType( - node: EnumTypeDefinitionNode | EnumTypeExtensionNode, - changes: ChangesByType, -) { - { - const removals = changes[ChangeType.EnumValueRemoved] - ?.filter(change => change.meta.enumName === node.name.value) - .map(change => change.meta.removedEnumValueName); - (node.values as EnumValueDefinitionNode[] | undefined) = node.values?.filter( - ({ name }) => !removals?.includes(name.value), - ); - } - - { - const additions = changes[ChangeType.EnumValueAdded] - ?.filter(change => change.meta.enumName === node.name.value) - .map( - (change): EnumValueDefinitionNode => ({ - kind: Kind.ENUM_VALUE_DEFINITION, - name: nameNode(change.meta.addedEnumValueName), - directives: undefined, // @todo - description: undefined, // @todo - }), - ); - if (additions?.length) { - (node.values as EnumValueDefinitionNode[] | undefined) = [ - ...(node.values ?? []), - ...additions, - ]; - } - } - - /** CHANGED VALUE DESCRIPTION */ - { - const change = changes[ChangeType.EnumValueDescriptionChanged]?.findLast( - change => change.meta.enumName === node.name.value, - ); - if (change) { - (node.values as EnumValueDefinitionNode[] | undefined)?.map(value => { - if (value.name.value === change.meta.enumValueName) { - (value.description as StringValueNode | undefined) = change.meta.newEnumValueDescription - ? stringNode(change.meta.newEnumValueDescription) - : undefined; - } - }); - } - } - - { - const addedChanges = changes[ChangeType.EnumValueDeprecationReasonChanged]?.filter( - change => change.meta.enumName === node.name.value, - ); - for (const change of addedChanges ?? []) { - const enumValueNode = findNamedNode(node.values, change.meta.enumValueName); - const deprecation = getDeprecatedDirectiveNode(enumValueNode); - setArgument(deprecation, 'reason', stringNode(change.meta.newEnumValueDeprecationReason)); - } - } - - { - const addedChanges = changes[ChangeType.EnumValueDeprecationReasonAdded]?.filter( - change => change.meta.enumName === node.name.value, - ); - for (const change of addedChanges ?? []) { - const enumValueNode = findNamedNode(node.values, change.meta.enumValueName); - const deprecation = getDeprecatedDirectiveNode(enumValueNode); - setArgument(deprecation, 'reason', stringNode(change.meta.addedValueDeprecationReason)); - } - } - - { - const removalChanges = changes[ChangeType.EnumValueDeprecationReasonRemoved]?.filter( - change => change.meta.enumName === node.name.value, - ); - for (const change of removalChanges ?? []) { - const enumValueNode = findNamedNode(node.values, change.meta.enumValueName); - const deprecation = getDeprecatedDirectiveNode(enumValueNode); - removeArgument(deprecation, 'reason'); - } - } - - return node; -} - -function patchDirective(node: DirectiveDefinitionNode, changes: ChangesByType) { - for (const change of changes[ChangeType.DirectiveRemoved] ?? []) { - if (change.meta.removedDirectiveName === node.name.value) { - return null; - } - } - - for (const change of changes[ChangeType.DirectiveArgumentAdded] ?? []) { - if (change.meta.directiveName === node.name.value) { - addInputValueDefinitionArgument( - node, - change.meta.addedDirectiveArgumentName, - namedType('Foo'), // @todo type - stringNode('TBD'), // @todo defaultValue - undefined, // @todo description - undefined, // @todo directives - ); - } - } - - for (const change of changes[ChangeType.DirectiveArgumentDefaultValueChanged] ?? []) { - if (change.meta.directiveName === node.name.value) { - setInputValueDefinitionArgument(node, change.meta.directiveArgumentName, { - defaultValue: change.meta.newDirectiveArgumentDefaultValue - ? parseConstValue(change.meta.newDirectiveArgumentDefaultValue) - : undefined, - }); - } - } - - for (const change of changes[ChangeType.DirectiveArgumentDescriptionChanged] ?? []) { - if (change.meta.directiveName === node.name.value) { - setInputValueDefinitionArgument(node, change.meta.directiveArgumentName, { - description: change.meta.newDirectiveArgumentDescription - ? stringNode(change.meta.newDirectiveArgumentDescription) - : undefined, - }); - } - } - - for (const change of changes[ChangeType.DirectiveArgumentRemoved] ?? []) { - if (change.meta.directiveName === node.name.value) { - removeInputValueDefinitionArgument(node, change.meta.removedDirectiveArgumentName); - } - } - - for (const change of changes[ChangeType.DirectiveArgumentTypeChanged] ?? []) { - if (change.meta.directiveName === node.name.value) { - setInputValueDefinitionArgument(node, change.meta.directiveArgumentName, { - type: parseType(change.meta.newDirectiveArgumentType), - }); - } - } - - for (const change of changes[ChangeType.DirectiveDescriptionChanged] ?? []) { - if (change.meta.directiveName === node.name.value) { - (node.description as StringValueNode | undefined) = change.meta.newDirectiveDescription - ? stringNode(change.meta.newDirectiveDescription) - : undefined; - } - } - - for (const change of changes[ChangeType.DirectiveLocationAdded] ?? []) { - if (change.meta.directiveName === node.name.value) { - (node.locations as NameNode[]) = [ - ...node.locations, - nameNode(change.meta.addedDirectiveLocation), - ]; - } - } - - for (const change of changes[ChangeType.DirectiveLocationRemoved] ?? []) { - if (change.meta.directiveName === node.name.value) { - (node.locations as NameNode[]) = node.locations.filter( - l => l.value !== change.meta.removedDirectiveLocation, - ); - } - } - - return node; -} - -type ChangesByType = { [key in TypeOfChangeType]?: Array> }; - -export function patch(ast: DocumentNode, changes: SerializableChange[]): DocumentNode { - // const [schemaDefs, nodesByName] = collectDefinitions(ast); - // @todo changes can impact future changes... they should be applied IN ORDER. - // grouping is faster, but can cause incorrect results... Consider changing this to instead - // group by COORDINATE. This can then be applied more efficiently AND IN ORDER. - const changesByType: ChangesByType = {}; - for (const change of changes) { - changesByType[change.type] ??= []; - changesByType[change.type]?.push(change as any); - } - - const result = visit(ast, { - ObjectTypeDefinition: node => - patchObjectType(patchTypeDefinition(node, changesByType), changesByType), - ObjectTypeExtension: node => patchObjectType(node, changesByType), - InterfaceTypeDefinition: node => - patchObjectType(patchTypeDefinition(node, changesByType), changesByType), - InterfaceTypeExtension: node => patchObjectType(node, changesByType), - InputObjectTypeDefinition: node => - patchInputObject(patchTypeDefinition(node, changesByType), changesByType), - InputObjectTypeExtension: node => patchInputObject(node, changesByType), - UnionTypeDefinition: node => - patchUnionType(patchTypeDefinition(node, changesByType), changesByType), - UnionTypeExtension: node => patchUnionType(node, changesByType), - ScalarTypeDefinition: node => patchTypeDefinition(node, changesByType), - // ScalarTypeExtension: (node) => patchScalarType(node, changesByType), - EnumTypeDefinition: node => - patchEnumType(patchTypeDefinition(node, changesByType), changesByType), - EnumTypeExtension: node => patchEnumType(node, changesByType), - DirectiveDefinition: node => patchDirective(node, changesByType), - SchemaDefinition: node => node, - SchemaExtension: node => node, - }); - - return { - ...result, - definitions: [ - ...result.definitions, - - /** ADD TYPES */ - ...(changesByType[ChangeType.TypeAdded] ?? []) - // @todo consider what to do for types that already exist. - .map((addition): TypeDefinitionNode => { - // addition.meta.addedTypeKind - // @todo need to figure out how to add enums and other types... - return { - kind: Kind.OBJECT_TYPE_DEFINITION, - fields: [], - name: nameNode(addition.meta.addedTypeName), - } as ObjectTypeDefinitionNode; - }), - ], - }; -} diff --git a/packages/web/app/src/components/proposal/patch/errors.ts b/packages/web/app/src/components/proposal/patch/errors.ts new file mode 100644 index 0000000000..8a7a0bd35b --- /dev/null +++ b/packages/web/app/src/components/proposal/patch/errors.ts @@ -0,0 +1,113 @@ +import { Kind } from 'graphql'; +import type { Change } from '@graphql-inspector/core'; +import type { PatchConfig } from './types'; + +export function handleError(change: Change, err: Error, config: PatchConfig) { + if (config.exitOnError === true) { + throw err; + } else { + console.warn(`Cannot apply ${change.type} at "${change.path}". ${err.message}`); + } +} + +export class CoordinateNotFoundError extends Error { + constructor() { + super('Cannot find an element at the schema coordinate.'); + } +} + +export class CoordinateAlreadyExistsError extends Error { + constructor(public readonly kind: Kind) { + super(`A "${kind}" already exists at the schema coordinate.`); + } +} + +export class DeprecationReasonAlreadyExists extends Error { + constructor(reason: string) { + super(`A deprecation reason already exists: "${reason}"`); + } +} + +export class EnumValueNotFoundError extends Error { + constructor(typeName: string, value?: string | undefined) { + super(`The enum "${typeName}" does not contain "${value}".`); + } +} + +export class UnionMemberNotFoundError extends Error { + constructor(typeName: string, type: string) { + super(`The union "${typeName}" does not contain the member "${type}".`); + } +} + +export class UnionMemberAlreadyExistsError extends Error { + constructor(typeName: string, type: string) { + super(`The union "${typeName}" already contains the member "${type}".`); + } +} + +export class DirectiveLocationAlreadyExistsError extends Error { + constructor(directiveName: string, location: string) { + super(`The directive "${directiveName}" already can be located on "${location}".`); + } +} + +export class DirectiveAlreadyExists extends Error { + constructor(directiveName: string) { + super(`The directive "${directiveName}" already exists.`); + } +} + +export class KindMismatchError extends Error { + constructor( + public readonly expectedKind: Kind, + public readonly receivedKind: Kind, + ) { + super(`Expected type to have be a "${expectedKind}", but found a "${receivedKind}".`); + } +} + +export class FieldTypeMismatchError extends Error { + constructor(expectedReturnType: string, receivedReturnType: string) { + super(`Expected the field to return ${expectedReturnType} but found ${receivedReturnType}.`); + } +} + +export class OldValueMismatchError extends Error { + constructor( + expectedValue: string | null | undefined, + receivedOldValue: string | null | undefined, + ) { + super(`Expected the value ${expectedValue} but found ${receivedOldValue}.`); + } +} + +export class OldTypeMismatchError extends Error { + constructor(expectedType: string | null | undefined, receivedOldType: string | null | undefined) { + super(`Expected the type ${expectedType} but found ${receivedOldType}.`); + } +} + +export class InterfaceAlreadyExistsOnTypeError extends Error { + constructor(interfaceName: string) { + super( + `Cannot add the interface "${interfaceName}" because it already is applied at that coordinate.`, + ); + } +} + +export class ArgumentDefaultValueMismatchError extends Error { + constructor(expectedDefaultValue: string | undefined, actualDefaultValue: string | undefined) { + super( + `The argument's default value "${actualDefaultValue}" does not match the expected value "${expectedDefaultValue}".`, + ); + } +} + +export class ArgumentDescriptionMismatchError extends Error { + constructor(expectedDefaultValue: string | undefined, actualDefaultValue: string | undefined) { + super( + `The argument's description "${actualDefaultValue}" does not match the expected "${expectedDefaultValue}".`, + ); + } +} diff --git a/packages/web/app/src/components/proposal/patch/index.ts b/packages/web/app/src/components/proposal/patch/index.ts new file mode 100644 index 0000000000..18a34fda7d --- /dev/null +++ b/packages/web/app/src/components/proposal/patch/index.ts @@ -0,0 +1,301 @@ +import { + ASTNode, + buildASTSchema, + DocumentNode, + GraphQLSchema, + isDefinitionNode, + Kind, + parse, + printSchema, + SchemaDefinitionNode, + SchemaExtensionNode, + visit, +} from 'graphql'; +import { Change, ChangeType } from '@graphql-inspector/core'; +import { + directiveAdded, + directiveArgumentAdded, + directiveArgumentDefaultValueChanged, + directiveArgumentDescriptionChanged, + directiveLocationAdded, +} from './patches/directives'; +import { + enumValueAdded, + enumValueDeprecationReasonAdded, + enumValueDescriptionChanged, + enumValueRemoved, +} from './patches/enum'; +import { + fieldAdded, + fieldArgumentAdded, + fieldDeprecationAdded, + fieldDeprecationReadonAdded, + fieldDescriptionAdded, + fieldDescriptionRemoved, + fieldRemoved, + fieldTypeChanged, +} from './patches/fields'; +import { inputFieldAdded, inputFieldDescriptionAdded } from './patches/inputs'; +import { + schemaMutationTypeChanged, + schemaQueryTypeChanged, + schemaSubscriptionTypeChanged, +} from './patches/schema'; +import { + objectTypeInterfaceAdded, + typeAdded, + typeDescriptionAdded, + typeRemoved, +} from './patches/types'; +import { unionMemberAdded } from './patches/unions'; +import { PatchConfig, SchemaNode } from './types'; +import { debugPrintChange } from './utils'; + +export function patchSchema(schema: GraphQLSchema, changes: Change[]): GraphQLSchema { + const ast = parse(printSchema(schema)); + return buildASTSchema(patch(ast, changes), { assumeValid: true, assumeValidSDL: true }); +} + +function groupNodesByPath(ast: DocumentNode): [SchemaNode[], Map] { + const schemaNodes: SchemaNode[] = []; + const nodeByPath = new Map(); + const pathArray: string[] = []; + visit(ast, { + enter(node) { + switch (node.kind) { + case Kind.ARGUMENT: + case Kind.ENUM_TYPE_DEFINITION: + case Kind.DIRECTIVE_DEFINITION: + case Kind.ENUM_TYPE_EXTENSION: + case Kind.ENUM_VALUE_DEFINITION: + case Kind.FIELD_DEFINITION: + case Kind.INPUT_OBJECT_TYPE_DEFINITION: + case Kind.INPUT_OBJECT_TYPE_EXTENSION: + case Kind.INPUT_VALUE_DEFINITION: + case Kind.INTERFACE_TYPE_DEFINITION: + case Kind.INTERFACE_TYPE_EXTENSION: + case Kind.OBJECT_FIELD: + case Kind.OBJECT_TYPE_DEFINITION: + case Kind.OBJECT_TYPE_EXTENSION: + case Kind.SCALAR_TYPE_DEFINITION: + case Kind.SCALAR_TYPE_EXTENSION: + case Kind.UNION_TYPE_DEFINITION: + case Kind.UNION_TYPE_EXTENSION: { + pathArray.push(node.name.value); + const path = pathArray.join('.'); + nodeByPath.set(path, node); + break; + } + case Kind.DOCUMENT: { + break; + } + case Kind.SCHEMA_EXTENSION: + case Kind.SCHEMA_DEFINITION: { + schemaNodes.push(node); + break; + } + default: { + // by definition this things like return types, names, named nodes... + // it's nothing we want to collect. + return false; + } + } + }, + leave(node) { + switch (node.kind) { + case Kind.ARGUMENT: + case Kind.ENUM_TYPE_DEFINITION: + case Kind.DIRECTIVE_DEFINITION: + case Kind.ENUM_TYPE_EXTENSION: + case Kind.ENUM_VALUE_DEFINITION: + case Kind.FIELD_DEFINITION: + case Kind.INPUT_OBJECT_TYPE_DEFINITION: + case Kind.INPUT_OBJECT_TYPE_EXTENSION: + case Kind.INPUT_VALUE_DEFINITION: + case Kind.INTERFACE_TYPE_DEFINITION: + case Kind.INTERFACE_TYPE_EXTENSION: + case Kind.OBJECT_FIELD: + case Kind.OBJECT_TYPE_DEFINITION: + case Kind.OBJECT_TYPE_EXTENSION: + case Kind.SCALAR_TYPE_DEFINITION: + case Kind.SCALAR_TYPE_EXTENSION: + case Kind.UNION_TYPE_DEFINITION: + case Kind.UNION_TYPE_EXTENSION: { + pathArray.pop(); + } + } + }, + }); + return [schemaNodes, nodeByPath]; +} + +export function patch( + ast: DocumentNode, + changes: Change[], + patchConfig?: PatchConfig, +): DocumentNode { + const config: PatchConfig = patchConfig ?? {}; + + const [schemaDefs, nodeByPath] = groupNodesByPath(ast); + + for (const change of changes) { + if (config.debug) { + debugPrintChange(change, nodeByPath); + } + + const changedPath = change.path; + if (changedPath === undefined) { + // a change without a path is useless... (@todo Only schema changes do this?) + continue; + } + + switch (change.type) { + case ChangeType.SchemaMutationTypeChanged: { + schemaMutationTypeChanged(change, schemaDefs, config); + break; + } + case ChangeType.SchemaQueryTypeChanged: { + schemaQueryTypeChanged(change, schemaDefs, config); + break; + } + case ChangeType.SchemaSubscriptionTypeChanged: { + schemaSubscriptionTypeChanged(change, schemaDefs, config); + break; + } + case ChangeType.DirectiveAdded: { + directiveAdded(change, nodeByPath, config); + break; + } + case ChangeType.DirectiveArgumentAdded: { + directiveArgumentAdded(change, nodeByPath, config); + break; + } + case ChangeType.DirectiveLocationAdded: { + directiveLocationAdded(change, nodeByPath, config); + break; + } + case ChangeType.EnumValueAdded: { + enumValueAdded(change, nodeByPath, config); + break; + } + case ChangeType.EnumValueDeprecationReasonAdded: { + enumValueDeprecationReasonAdded(change, nodeByPath, config); + break; + } + case ChangeType.FieldAdded: { + fieldAdded(change, nodeByPath, config); + break; + } + case ChangeType.FieldArgumentAdded: { + fieldArgumentAdded(change, nodeByPath, config); + break; + } + case ChangeType.FieldDeprecationAdded: { + fieldDeprecationAdded(change, nodeByPath, config); + break; + } + case ChangeType.FieldDeprecationReasonAdded: { + fieldDeprecationReadonAdded(change, nodeByPath, config); + break; + } + case ChangeType.FieldDescriptionAdded: { + fieldDescriptionAdded(change, nodeByPath, config); + break; + } + case ChangeType.InputFieldAdded: { + inputFieldAdded(change, nodeByPath, config); + break; + } + case ChangeType.InputFieldDescriptionAdded: { + inputFieldDescriptionAdded(change, nodeByPath, config); + break; + } + case ChangeType.ObjectTypeInterfaceAdded: { + objectTypeInterfaceAdded(change, nodeByPath, config); + break; + } + case ChangeType.TypeDescriptionAdded: { + typeDescriptionAdded(change, nodeByPath, config); + break; + } + case ChangeType.TypeAdded: { + typeAdded(change, nodeByPath, config); + break; + } + case ChangeType.UnionMemberAdded: { + unionMemberAdded(change, nodeByPath, config); + break; + } + case ChangeType.FieldRemoved: { + fieldRemoved(change, nodeByPath, config); + break; + } + case ChangeType.FieldTypeChanged: { + fieldTypeChanged(change, nodeByPath, config); + break; + } + case ChangeType.TypeRemoved: { + typeRemoved(change, nodeByPath, config); + break; + } + case ChangeType.EnumValueRemoved: { + enumValueRemoved(change, nodeByPath, config); + break; + } + case ChangeType.EnumValueDescriptionChanged: { + enumValueDescriptionChanged(change, nodeByPath, config); + break; + } + case ChangeType.FieldDescriptionRemoved: { + fieldDescriptionRemoved(change, nodeByPath, config); + break; + } + case ChangeType.DirectiveArgumentDefaultValueChanged: { + directiveArgumentDefaultValueChanged(change, nodeByPath, config); + break; + } + case ChangeType.DirectiveArgumentDescriptionChanged: { + directiveArgumentDescriptionChanged(change, nodeByPath, config); + break; + } + case ChangeType.DirectiveArgumentTypeChanged: { + directiveArgumentTypeChanged(change, nodeByPath, config); + } + // DirectiveUsageArgumentDefinitionAddedChange, + // DirectiveUsageArgumentDefinitionRemovedChange, + // DirectiveUsageArgumentDefinitionChange, + // DirectiveUsageEnumAddedChange, + // DirectiveUsageEnumRemovedChange, + // DirectiveUsageEnumValueAddedChange, + // DirectiveUsageEnumValueRemovedChange, + // DirectiveUsageFieldAddedChange, + // DirectiveUsageFieldDefinitionAddedChange, + // DirectiveUsageFieldDefinitionRemovedChange, + // DirectiveUsageFieldRemovedChange, + // DirectiveUsageInputFieldDefinitionAddedChange, + // DirectiveUsageInputFieldDefinitionRemovedChange, + // DirectiveUsageInputObjectAddedChange, + // DirectiveUsageInputObjectRemovedChange, + // DirectiveUsageInterfaceAddedChange, + // DirectiveUsageInterfaceRemovedChange, + // DirectiveUsageObjectAddedChange, + // DirectiveUsageObjectRemovedChange, + // DirectiveUsageScalarAddedChange, + // DirectiveUsageScalarRemovedChange, + // DirectiveUsageSchemaAddedChange, + // DirectiveUsageSchemaRemovedChange, + // DirectiveUsageUnionMemberAddedChange, + // DirectiveUsageUnionMemberRemovedChange, + default: { + console.log(`${change.type} is not implemented yet.`); + } + } + } + + return { + kind: Kind.DOCUMENT, + + // filter out the non-definition nodes (e.g. field definitions) + definitions: nodeByPath.values().filter(isDefinitionNode).toArray(), + }; +} diff --git a/packages/web/app/src/components/proposal/patch/node-templates.ts b/packages/web/app/src/components/proposal/patch/node-templates.ts index 300be024be..09845db469 100644 --- a/packages/web/app/src/components/proposal/patch/node-templates.ts +++ b/packages/web/app/src/components/proposal/patch/node-templates.ts @@ -1,4 +1,4 @@ -import { Kind, NameNode, StringValueNode, TypeNode } from 'graphql'; +import { Kind, NamedTypeNode, NameNode, StringValueNode, TypeNode } from 'graphql'; export function nameNode(name: string): NameNode { return { @@ -14,7 +14,14 @@ export function stringNode(value: string): StringValueNode { }; } -export function namedType(name: string): TypeNode { +export function typeNode(name: string): TypeNode { + return { + kind: Kind.NAMED_TYPE, + name: nameNode(name), + }; +} + +export function namedTypeNode(name: string): NamedTypeNode { return { kind: Kind.NAMED_TYPE, name: nameNode(name), diff --git a/packages/web/app/src/components/proposal/patch/patches/directives.ts b/packages/web/app/src/components/proposal/patch/patches/directives.ts new file mode 100644 index 0000000000..8554605ed4 --- /dev/null +++ b/packages/web/app/src/components/proposal/patch/patches/directives.ts @@ -0,0 +1,217 @@ +import { + ASTNode, + DirectiveDefinitionNode, + InputValueDefinitionNode, + Kind, + NameNode, + parseConstValue, + parseType, + print, + StringValueNode, + TypeNode, + ValueNode, +} from 'graphql'; +import { Change, ChangeType } from '@graphql-inspector/core'; +import { + ArgumentDefaultValueMismatchError, + ArgumentDescriptionMismatchError, + CoordinateAlreadyExistsError, + CoordinateNotFoundError, + DirectiveLocationAlreadyExistsError, + handleError, + KindMismatchError, + OldTypeMismatchError, +} from '../errors'; +import { nameNode, stringNode } from '../node-templates'; +import { PatchConfig } from '../types'; +import { parentPath } from '../utils'; + +export function directiveAdded( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + const changedPath = change.path!; + const changedNode = nodeByPath.get(changedPath); + if (changedNode) { + handleError(change, new CoordinateAlreadyExistsError(changedNode.kind), config); + } else { + const node: DirectiveDefinitionNode = { + kind: Kind.DIRECTIVE_DEFINITION, + name: nameNode(change.meta.addedDirectiveName), + repeatable: change.meta.addedDirectiveRepeatable, + locations: change.meta.addedDirectiveLocations.map(l => nameNode(l)), + description: change.meta.addedDirectiveDescription + ? stringNode(change.meta.addedDirectiveDescription) + : undefined, + }; + nodeByPath.set(changedPath, node); + } +} + +export function directiveArgumentAdded( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + const changedPath = change.path!; + const argumentNode = nodeByPath.get(changedPath); + const directiveNode = nodeByPath.get(parentPath(changedPath)); + if (argumentNode) { + handleError(change, new CoordinateAlreadyExistsError(argumentNode.kind), config); + } else if (!directiveNode) { + handleError(change, new CoordinateNotFoundError(), config); + } else if (directiveNode.kind === Kind.DIRECTIVE_DEFINITION) { + const node: InputValueDefinitionNode = { + kind: Kind.INPUT_VALUE_DEFINITION, + name: nameNode(change.meta.addedDirectiveArgumentName), + type: parseType(change.meta.addedDirectiveArgumentType), + }; + (directiveNode.arguments as InputValueDefinitionNode[] | undefined) = [ + ...(directiveNode.arguments ?? []), + node, + ]; + nodeByPath.set(changedPath, node); + } else { + handleError( + change, + new KindMismatchError(Kind.DIRECTIVE_DEFINITION, directiveNode.kind), + config, + ); + } +} + +export function directiveLocationAdded( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + const changedPath = change.path!; + const changedNode = nodeByPath.get(changedPath); + if (changedNode) { + if (changedNode.kind === Kind.DIRECTIVE_DEFINITION) { + if (changedNode.locations.some(l => l.value === change.meta.addedDirectiveLocation)) { + handleError( + change, + new DirectiveLocationAlreadyExistsError( + change.meta.directiveName, + change.meta.addedDirectiveLocation, + ), + config, + ); + } else { + (changedNode.locations as NameNode[]) = [ + ...changedNode.locations, + nameNode(change.meta.addedDirectiveLocation), + ]; + } + } else { + handleError( + change, + new KindMismatchError(Kind.DIRECTIVE_DEFINITION, changedNode.kind), + config, + ); + } + } else { + handleError(change, new CoordinateNotFoundError(), config); + } +} + +export function directiveArgumentDefaultValueChanged( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + const changedPath = change.path!; + const argumentNode = nodeByPath.get(changedPath); + if (!argumentNode) { + handleError(change, new CoordinateNotFoundError(), config); + } else if (argumentNode.kind === Kind.INPUT_VALUE_DEFINITION) { + if ( + argumentNode.defaultValue && + print(argumentNode.defaultValue) === change.meta.oldDirectiveArgumentDefaultValue + ) { + (argumentNode.defaultValue as ValueNode | undefined) = change.meta + .newDirectiveArgumentDefaultValue + ? parseConstValue(change.meta.newDirectiveArgumentDefaultValue) + : undefined; + } else { + handleError( + change, + new ArgumentDefaultValueMismatchError( + change.meta.oldDirectiveArgumentDefaultValue, + argumentNode.defaultValue && print(argumentNode.defaultValue), + ), + config, + ); + } + } else { + handleError( + change, + new KindMismatchError(Kind.INPUT_VALUE_DEFINITION, argumentNode.kind), + config, + ); + } +} + +export function directiveArgumentDescriptionChanged( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + const changedPath = change.path!; + const argumentNode = nodeByPath.get(changedPath); + if (!argumentNode) { + handleError(change, new CoordinateNotFoundError(), config); + } else if (argumentNode.kind === Kind.INPUT_VALUE_DEFINITION) { + if (argumentNode.description?.value === change.meta.oldDirectiveArgumentDescription) { + handleError( + change, + new ArgumentDescriptionMismatchError( + change.meta.oldDirectiveArgumentDescription, + argumentNode.description.value, + ), + config, + ); + } else { + (argumentNode.description as StringValueNode | undefined) = change.meta + .newDirectiveArgumentDescription + ? stringNode(change.meta.newDirectiveArgumentDescription) + : undefined; + } + } else { + handleError( + change, + new KindMismatchError(Kind.INPUT_VALUE_DEFINITION, argumentNode.kind), + config, + ); + } +} + +export function directiveArgumentTypeChanged( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + const changedPath = change.path!; + const argumentNode = nodeByPath.get(changedPath); + if (!argumentNode) { + handleError(change, new CoordinateNotFoundError(), config); + } else if (argumentNode.kind === Kind.INPUT_VALUE_DEFINITION) { + if (print(argumentNode.type) === change.meta.oldDirectiveArgumentType) { + handleError( + change, + new OldTypeMismatchError(change.meta.oldDirectiveArgumentType, print(argumentNode.type)), + config, + ); + } else { + (argumentNode.type as TypeNode | undefined) = parseType(change.meta.newDirectiveArgumentType); + } + } else { + handleError( + change, + new KindMismatchError(Kind.INPUT_VALUE_DEFINITION, argumentNode.kind), + config, + ); + } +} diff --git a/packages/web/app/src/components/proposal/patch/patches/enum.ts b/packages/web/app/src/components/proposal/patch/patches/enum.ts new file mode 100644 index 0000000000..24a8dd2403 --- /dev/null +++ b/packages/web/app/src/components/proposal/patch/patches/enum.ts @@ -0,0 +1,151 @@ +import { ASTNode, EnumValueDefinitionNode, isEnumType, Kind, StringValueNode } from 'graphql'; +import { Change, ChangeType } from '@graphql-inspector/core'; +import { + CoordinateAlreadyExistsError, + CoordinateNotFoundError, + EnumValueNotFoundError, + handleError, + KindMismatchError, + OldValueMismatchError, +} from '../errors'; +import { nameNode, stringNode } from '../node-templates'; +import type { PatchConfig } from '../types'; +import { getDeprecatedDirectiveNode, parentPath, upsertArgument } from '../utils'; + +export function enumValueRemoved( + removal: Change, + nodeByPath: Map, + config: PatchConfig, +) { + const changedPath = removal.path!; + const typeNode = nodeByPath.get(parentPath(changedPath)) as + | (ASTNode & { values?: EnumValueDefinitionNode[] }) + | undefined; + if (!typeNode) { + handleError(removal, new CoordinateNotFoundError(), config); + } else if (!isEnumType(typeNode)) { + handleError(removal, new KindMismatchError(Kind.ENUM_TYPE_DEFINITION, typeNode.kind), config); + } else if (!typeNode.values?.length) { + handleError( + removal, + new EnumValueNotFoundError(removal.meta.enumName, removal.meta.removedEnumValueName), + config, + ); + } else { + const beforeLength = typeNode.values.length; + typeNode.values = typeNode.values.filter( + f => f.name.value !== removal.meta.removedEnumValueName, + ); + if (beforeLength === typeNode.values.length) { + handleError( + removal, + new EnumValueNotFoundError(removal.meta.enumName, removal.meta.removedEnumValueName), + config, + ); + } else { + // delete the reference to the removed field. + nodeByPath.delete(changedPath); + } + } +} + +export function enumValueAdded( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + const enumValuePath = change.path!; + const enumNode = nodeByPath.get(parentPath(enumValuePath)) as + | (ASTNode & { values: EnumValueDefinitionNode[] }) + | undefined; + const changedNode = nodeByPath.get(enumValuePath); + if (!enumNode) { + handleError(change, new CoordinateNotFoundError(), config); + console.warn( + `Cannot apply change: ${change.type} to ${enumValuePath}. Parent type is missing.`, + ); + } else if (changedNode) { + handleError(change, new CoordinateAlreadyExistsError(changedNode.kind), config); + } else if (enumNode.kind !== Kind.ENUM_TYPE_DEFINITION) { + handleError(change, new KindMismatchError(Kind.ENUM_TYPE_DEFINITION, enumNode.kind), config); + } else { + const c = change as Change; + const node: EnumValueDefinitionNode = { + kind: Kind.ENUM_VALUE_DEFINITION, + name: nameNode(c.meta.addedEnumValueName), + description: c.meta.addedDirectiveDescription + ? stringNode(c.meta.addedDirectiveDescription) + : undefined, + }; + (enumNode.values as EnumValueDefinitionNode[]) = [...(enumNode.values ?? []), node]; + nodeByPath.set(enumValuePath, node); + } +} + +export function enumValueDeprecationReasonAdded( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + const changedPath = change.path!; + const enumValueNode = nodeByPath.get(changedPath); + if (enumValueNode) { + if (enumValueNode.kind === Kind.ENUM_VALUE_DEFINITION) { + const deprecation = getDeprecatedDirectiveNode(enumValueNode); + if (deprecation) { + const argNode = upsertArgument( + deprecation, + 'reason', + stringNode(change.meta.addedValueDeprecationReason), + ); + nodeByPath.set(`${changedPath}.reason`, argNode); + } else { + handleError(change, new CoordinateNotFoundError(), config); + } + } else { + handleError( + change, + new KindMismatchError(Kind.ENUM_VALUE_DEFINITION, enumValueNode.kind), + config, + ); + } + } else { + handleError(change, new EnumValueNotFoundError(change.meta.enumName), config); + } +} + +export function enumValueDescriptionChanged( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + const changedPath = change.path!; + const enumValueNode = nodeByPath.get(changedPath); + if (enumValueNode) { + if (enumValueNode.kind === Kind.ENUM_VALUE_DEFINITION) { + if (change.meta.oldEnumValueDescription !== enumValueNode.description?.value) { + handleError( + change, + new OldValueMismatchError( + change.meta.oldEnumValueDescription, + enumValueNode.description?.value, + ), + config, + ); + } else { + (enumValueNode.description as StringValueNode | undefined) = change.meta + .newEnumValueDescription + ? stringNode(change.meta.newEnumValueDescription) + : undefined; + } + } else { + handleError( + change, + new KindMismatchError(Kind.ENUM_VALUE_DEFINITION, enumValueNode.kind), + config, + ); + } + } else { + handleError(change, new EnumValueNotFoundError(change.meta.enumName), config); + } +} diff --git a/packages/web/app/src/components/proposal/patch/patches/fields.ts b/packages/web/app/src/components/proposal/patch/patches/fields.ts new file mode 100644 index 0000000000..41169f0940 --- /dev/null +++ b/packages/web/app/src/components/proposal/patch/patches/fields.ts @@ -0,0 +1,262 @@ +import { + ArgumentNode, + ASTNode, + DirectiveNode, + FieldDefinitionNode, + GraphQLDeprecatedDirective, + InputValueDefinitionNode, + Kind, + parseType, + parseValue, + print, + StringValueNode, + TypeNode, +} from 'graphql'; +import { Change, ChangeType } from '@graphql-inspector/core'; +import { + CoordinateAlreadyExistsError, + CoordinateNotFoundError, + DeprecationReasonAlreadyExists, + DirectiveAlreadyExists, + FieldTypeMismatchError, + handleError, + KindMismatchError, +} from '../errors'; +import { nameNode, stringNode } from '../node-templates'; +import type { PatchConfig } from '../types'; +import { getDeprecatedDirectiveNode, parentPath } from '../utils'; + +export function fieldTypeChanged( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + const c = change as Change; + const node = nodeByPath.get(c.path!); + if (node) { + if (node.kind === Kind.FIELD_DEFINITION) { + const currentReturnType = print(node.type); + if (c.meta.oldFieldType === currentReturnType) { + (node.type as TypeNode) = parseType(c.meta.newFieldType); + } else { + handleError(c, new FieldTypeMismatchError(c.meta.oldFieldType, currentReturnType), config); + } + } else { + handleError(c, new KindMismatchError(Kind.FIELD_DEFINITION, node.kind), config); + } + } else { + handleError(c, new CoordinateNotFoundError(), config); + } +} + +export function fieldRemoved( + removal: Change, + nodeByPath: Map, + config: PatchConfig, +) { + const changedPath = removal.path!; + const typeNode = nodeByPath.get(parentPath(changedPath)) as + | (ASTNode & { fields?: FieldDefinitionNode[] }) + | undefined; + if (!typeNode || !typeNode.fields?.length) { + handleError(removal, new CoordinateNotFoundError(), config); + } else { + const beforeLength = typeNode.fields.length; + typeNode.fields = typeNode.fields.filter(f => f.name.value !== removal.meta.removedFieldName); + if (beforeLength === typeNode.fields.length) { + handleError(removal, new CoordinateNotFoundError(), config); + } else { + // delete the reference to the removed field. + nodeByPath.delete(changedPath); + } + } +} + +export function fieldAdded( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + const changedPath = change.path!; + const changedNode = nodeByPath.get(changedPath); + if (changedNode) { + handleError(change, new CoordinateAlreadyExistsError(changedNode.kind), config); + } else { + const typeNode = nodeByPath.get(parentPath(changedPath)) as ASTNode & { + fields?: FieldDefinitionNode[]; + }; + if (!typeNode) { + handleError(change, new CoordinateNotFoundError(), config); + } else if ( + typeNode.kind !== Kind.OBJECT_TYPE_DEFINITION && + typeNode.kind !== Kind.INTERFACE_TYPE_DEFINITION + ) { + handleError(change, new KindMismatchError(Kind.ENUM_TYPE_DEFINITION, typeNode.kind), config); + } else { + const node: FieldDefinitionNode = { + kind: Kind.FIELD_DEFINITION, + name: nameNode(change.meta.addedFieldName), + type: parseType(change.meta.addedFieldReturnType), + description: change.meta.addedFieldDescription + ? stringNode(change.meta.addedFieldDescription) + : undefined, + }; + + typeNode.fields = [...(typeNode.fields ?? []), node]; + + // add new field to the node set + nodeByPath.set(changedPath, node); + } + } +} + +export function fieldArgumentAdded( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + const changedPath = change.path!; + const existing = nodeByPath.get(changedPath); + if (existing) { + handleError(change, new CoordinateAlreadyExistsError(existing.kind), config); + } else { + const fieldNode = nodeByPath.get(parentPath(changedPath)) as ASTNode & { + arguments?: InputValueDefinitionNode[]; + }; + if (!fieldNode) { + handleError(change, new CoordinateNotFoundError(), config); + } else if (!fieldNode.arguments) { + handleError(change, new KindMismatchError(Kind.FIELD_DEFINITION, fieldNode.kind), config); + } else { + const node: InputValueDefinitionNode = { + kind: Kind.INPUT_VALUE_DEFINITION, + name: nameNode(change.meta.addedArgumentName), + type: parseType(change.meta.addedArgumentType), + description: change.meta.addedFieldArgumentDescription + ? stringNode(change.meta.addedFieldArgumentDescription) + : undefined, + }; + + fieldNode.arguments = [...(fieldNode.arguments ?? []), node]; + + // add new field to the node set + nodeByPath.set(changedPath, node); + } + } +} + +export function fieldDeprecationReadonAdded( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + const changedPath = change.path!; + const deprecationNode = nodeByPath.get(changedPath); + if (deprecationNode) { + if (deprecationNode.kind === Kind.DIRECTIVE) { + const reasonArgument = deprecationNode.arguments?.find(a => a.name.value === 'reason'); + if (reasonArgument) { + handleError( + change, + new DeprecationReasonAlreadyExists((reasonArgument.value as StringValueNode)?.value), + config, + ); + } else { + (deprecationNode.arguments as ArgumentNode[] | undefined) = [ + ...(deprecationNode.arguments ?? []), + { + kind: Kind.ARGUMENT, + name: nameNode('reason'), + value: stringNode(change.meta.addedDeprecationReason), + } as ArgumentNode, + ]; + // nodeByPath.set(changedPath) + } + } else { + handleError(change, new KindMismatchError(Kind.DIRECTIVE, deprecationNode.kind), config); + } + } else { + handleError(change, new CoordinateNotFoundError(), config); + } +} + +export function fieldDeprecationAdded( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + const changedPath = change.path!; + const fieldNode = nodeByPath.get(changedPath); + if (fieldNode) { + if (fieldNode.kind === Kind.FIELD_DEFINITION) { + const hasExistingDeprecationDirective = getDeprecatedDirectiveNode(fieldNode); + if (hasExistingDeprecationDirective) { + handleError(change, new DirectiveAlreadyExists(GraphQLDeprecatedDirective.name), config); + } else { + const directiveNode = { + kind: Kind.DIRECTIVE, + name: nameNode(GraphQLDeprecatedDirective.name), + ...(change.meta.deprecationReason + ? { + arguments: [ + { + kind: Kind.ARGUMENT, + name: nameNode('reason'), + value: stringNode(change.meta.deprecationReason), + }, + ], + } + : {}), + } as DirectiveNode; + + (fieldNode.directives as DirectiveNode[] | undefined) = [ + ...(fieldNode.directives ?? []), + directiveNode, + ]; + nodeByPath.set(`${changedPath}.${directiveNode.name.value}`, directiveNode); + } + } else { + handleError(change, new KindMismatchError(Kind.FIELD_DEFINITION, fieldNode.kind), config); + } + } else { + handleError(change, new CoordinateNotFoundError(), config); + } +} + +export function fieldDescriptionAdded( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + const changedPath = change.path!; + const fieldNode = nodeByPath.get(changedPath); + if (fieldNode) { + if (fieldNode.kind === Kind.FIELD_DEFINITION) { + (fieldNode.description as StringValueNode | undefined) = change.meta.addedDescription + ? stringNode(change.meta.addedDescription) + : undefined; + } else { + handleError(change, new KindMismatchError(Kind.FIELD_DEFINITION, fieldNode.kind), config); + } + } else { + handleError(change, new CoordinateNotFoundError(), config); + } +} + +export function fieldDescriptionRemoved( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + const changedPath = change.path!; + const fieldNode = nodeByPath.get(changedPath); + if (fieldNode) { + if (fieldNode.kind === Kind.FIELD_DEFINITION) { + (fieldNode.description as StringValueNode | undefined) = undefined; + } else { + handleError(change, new KindMismatchError(Kind.FIELD_DEFINITION, fieldNode.kind), config); + } + } else { + handleError(change, new CoordinateNotFoundError(), config); + } +} diff --git a/packages/web/app/src/components/proposal/patch/patches/inputs.ts b/packages/web/app/src/components/proposal/patch/patches/inputs.ts new file mode 100644 index 0000000000..fb795e4687 --- /dev/null +++ b/packages/web/app/src/components/proposal/patch/patches/inputs.ts @@ -0,0 +1,105 @@ +import { ASTNode, InputValueDefinitionNode, Kind, parseType, StringValueNode } from 'graphql'; +import { Change, ChangeType } from '@graphql-inspector/core'; +import { + CoordinateAlreadyExistsError, + CoordinateNotFoundError, + handleError, + KindMismatchError, +} from '../errors'; +import { nameNode, stringNode } from '../node-templates'; +import type { PatchConfig } from '../types'; +import { parentPath } from '../utils'; + +export function inputFieldAdded( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + const inputFieldPath = change.path!; + const existingNode = nodeByPath.get(inputFieldPath); + if (existingNode) { + handleError(change, new CoordinateAlreadyExistsError(existingNode.kind), config); + } else { + const typeNode = nodeByPath.get(parentPath(inputFieldPath)) as ASTNode & { + fields?: InputValueDefinitionNode[]; + }; + if (!typeNode) { + handleError(change, new CoordinateNotFoundError(), config); + } else if (typeNode.kind !== Kind.INPUT_OBJECT_TYPE_DEFINITION) { + handleError( + change, + new KindMismatchError(Kind.INPUT_OBJECT_TYPE_DEFINITION, typeNode.kind), + config, + ); + } else { + const node: InputValueDefinitionNode = { + kind: Kind.INPUT_VALUE_DEFINITION, + name: nameNode(change.meta.addedInputFieldName), + type: parseType(change.meta.addedInputFieldType), + description: change.meta.addedInputFieldDescription + ? stringNode(change.meta.addedInputFieldDescription) + : undefined, + }; + + typeNode.fields = [...(typeNode.fields ?? []), node]; + + // add new field to the node set + nodeByPath.set(inputFieldPath, node); + } + } +} + +export function inputFieldDescriptionAdded( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + const inputFieldPath = change.path!; + const existingNode = nodeByPath.get(inputFieldPath); + if (existingNode) { + if (existingNode.kind === Kind.INPUT_VALUE_DEFINITION) { + (existingNode.description as StringValueNode | undefined) = stringNode( + change.meta.addedInputFieldDescription, + ); + } else { + handleError( + change, + new KindMismatchError(Kind.INPUT_VALUE_DEFINITION, existingNode.kind), + config, + ); + } + } else { + handleError(change, new CoordinateNotFoundError(), config); + } +} + +export function inputFieldDescriptionRemoved( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + const inputFieldPath = change.path!; + const existingNode = nodeByPath.get(inputFieldPath); + if (existingNode) { + if (existingNode.kind === Kind.INPUT_VALUE_DEFINITION) { + if (existingNode.description === undefined) { + console.warn( + `Cannot remove a description at ${change.path} because no description is set.`, + ); + } else if (existingNode.description.value !== change.meta.removedDescription) { + console.warn( + `Description at ${change.path} does not match expected description, but proceeding with description removal anyways.`, + ); + } + (existingNode.description as StringValueNode | undefined) = undefined; + } else { + handleError( + change, + new KindMismatchError(Kind.INPUT_VALUE_DEFINITION, existingNode.kind), + config, + ); + } + } else { + handleError(change, new CoordinateNotFoundError(), config); + } +} diff --git a/packages/web/app/src/components/proposal/patch/patches/interfaces.ts b/packages/web/app/src/components/proposal/patch/patches/interfaces.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/web/app/src/components/proposal/patch/patches/schema.ts b/packages/web/app/src/components/proposal/patch/patches/schema.ts new file mode 100644 index 0000000000..7e4eb0ce5d --- /dev/null +++ b/packages/web/app/src/components/proposal/patch/patches/schema.ts @@ -0,0 +1,77 @@ +import { NameNode, OperationTypeNode } from 'graphql'; +import type { Change, ChangeType } from '@graphql-inspector/core'; +import { CoordinateNotFoundError, handleError, OldTypeMismatchError } from '../errors'; +import { nameNode } from '../node-templates'; +import { PatchConfig, SchemaNode } from '../types'; + +export function schemaMutationTypeChanged( + change: Change, + schemaNodes: SchemaNode[], + config: PatchConfig, +) { + // @todo handle type extensions correctly + for (const schemaNode of schemaNodes) { + const mutation = schemaNode.operationTypes?.find( + ({ operation }) => operation === OperationTypeNode.MUTATION, + ); + if (!mutation) { + handleError(change, new CoordinateNotFoundError(), config); + } else if (mutation.type.name.value === change.meta.oldMutationTypeName) { + (mutation.type.name as NameNode) = nameNode(change.meta.newMutationTypeName); + } else { + handleError( + change, + new OldTypeMismatchError(change.meta.oldMutationTypeName, mutation?.type.name.value), + config, + ); + } + } +} + +export function schemaQueryTypeChanged( + change: Change, + schemaNodes: SchemaNode[], + config: PatchConfig, +) { + // @todo handle type extensions correctly + for (const schemaNode of schemaNodes) { + const query = schemaNode.operationTypes?.find( + ({ operation }) => operation === OperationTypeNode.MUTATION, + ); + if (!query) { + handleError(change, new CoordinateNotFoundError(), config); + } else if (query.type.name.value === change.meta.oldQueryTypeName) { + (query.type.name as NameNode) = nameNode(change.meta.newQueryTypeName); + } else { + handleError( + change, + new OldTypeMismatchError(change.meta.oldQueryTypeName, query?.type.name.value), + config, + ); + } + } +} + +export function schemaSubscriptionTypeChanged( + change: Change, + schemaNodes: SchemaNode[], + config: PatchConfig, +) { + // @todo handle type extensions correctly + for (const schemaNode of schemaNodes) { + const sub = schemaNode.operationTypes?.find( + ({ operation }) => operation === OperationTypeNode.MUTATION, + ); + if (!sub) { + handleError(change, new CoordinateNotFoundError(), config); + } else if (sub.type.name.value === change.meta.oldSubscriptionTypeName) { + (sub.type.name as NameNode) = nameNode(change.meta.newSubscriptionTypeName); + } else { + handleError( + change, + new OldTypeMismatchError(change.meta.oldSubscriptionTypeName, sub?.type.name.value), + config, + ); + } + } +} diff --git a/packages/web/app/src/components/proposal/patch/patches/types.ts b/packages/web/app/src/components/proposal/patch/patches/types.ts new file mode 100644 index 0000000000..e719070976 --- /dev/null +++ b/packages/web/app/src/components/proposal/patch/patches/types.ts @@ -0,0 +1,127 @@ +import { + ASTNode, + isTypeDefinitionNode, + Kind, + NamedTypeNode, + StringValueNode, + TypeDefinitionNode, +} from 'graphql'; +import { Change, ChangeType } from '@graphql-inspector/core'; +import { + CoordinateAlreadyExistsError, + CoordinateNotFoundError, + handleError, + InterfaceAlreadyExistsOnTypeError, + KindMismatchError, +} from '../errors'; +import { namedTypeNode, nameNode, stringNode } from '../node-templates'; +import type { PatchConfig } from '../types'; + +export function typeAdded( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + const changedPath = change.path!; + const existing = nodeByPath.get(changedPath); + if (existing) { + handleError(change, new CoordinateAlreadyExistsError(existing.kind), config); + } else { + const node: TypeDefinitionNode = { + name: nameNode(change.meta.addedTypeName), + kind: change.meta.addedTypeKind as TypeDefinitionNode['kind'], + }; + // @todo is this enough? + nodeByPath.set(changedPath, node); + } +} + +export function typeRemoved( + removal: Change, + nodeByPath: Map, + config: PatchConfig, +) { + const changedPath = removal.path!; + const removedNode = nodeByPath.get(changedPath); + if (removedNode) { + if (isTypeDefinitionNode(removedNode)) { + // delete the reference to the removed field. + for (const key of nodeByPath.keys()) { + if (key.startsWith(changedPath)) { + nodeByPath.delete(key); + } + } + } else { + handleError( + removal, + new KindMismatchError(Kind.OBJECT_TYPE_DEFINITION, removedNode.kind), + config, + ); + } + } else { + handleError(removal, new CoordinateNotFoundError(), config); + } +} + +export function typeDescriptionAdded( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + const changedPath = change.path!; + const typeNode = nodeByPath.get(changedPath); + if (typeNode) { + if (isTypeDefinitionNode(typeNode)) { + (typeNode.description as StringValueNode | undefined) = change.meta.addedTypeDescription + ? stringNode(change.meta.addedTypeDescription) + : undefined; + } else { + handleError( + change, + new KindMismatchError(Kind.OBJECT_TYPE_DEFINITION, typeNode.kind), + config, + ); + } + } else { + handleError(change, new CoordinateNotFoundError(), config); + } +} + +export function objectTypeInterfaceAdded( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + const changedPath = change.path!; + const typeNode = nodeByPath.get(changedPath); + if (typeNode) { + if ( + typeNode.kind === Kind.OBJECT_TYPE_DEFINITION || + typeNode.kind === Kind.INTERFACE_TYPE_DEFINITION + ) { + const existing = typeNode.interfaces?.find( + i => i.name.value === change.meta.addedInterfaceName, + ); + if (existing) { + handleError( + change, + new InterfaceAlreadyExistsOnTypeError(change.meta.addedInterfaceName), + config, + ); + } else { + (typeNode.interfaces as NamedTypeNode[] | undefined) = [ + ...(typeNode.interfaces ?? []), + namedTypeNode(change.meta.addedInterfaceName), + ]; + } + } else { + handleError( + change, + new KindMismatchError(Kind.OBJECT_TYPE_DEFINITION, typeNode.kind), + config, + ); + } + } else { + handleError(change, new CoordinateNotFoundError(), config); + } +} diff --git a/packages/web/app/src/components/proposal/patch/patches/unions.ts b/packages/web/app/src/components/proposal/patch/patches/unions.ts new file mode 100644 index 0000000000..82d656c4b2 --- /dev/null +++ b/packages/web/app/src/components/proposal/patch/patches/unions.ts @@ -0,0 +1,33 @@ +import { ASTNode, NamedTypeNode } from 'graphql'; +import { Change, ChangeType } from '@graphql-inspector/core'; +import { CoordinateNotFoundError, handleError, UnionMemberAlreadyExistsError } from '../errors'; +import { namedTypeNode } from '../node-templates'; +import { PatchConfig } from '../types'; +import { parentPath } from '../utils'; + +export function unionMemberAdded( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + const changedPath = change.path!; + const union = nodeByPath.get(parentPath(changedPath)) as + | (ASTNode & { types?: NamedTypeNode[] }) + | undefined; + if (union) { + if (union.types?.some(n => n.name.value === change.meta.addedUnionMemberTypeName)) { + handleError( + change, + new UnionMemberAlreadyExistsError( + change.meta.unionName, + change.meta.addedUnionMemberTypeName, + ), + config, + ); + } else { + union.types = [...(union.types ?? []), namedTypeNode(change.meta.addedUnionMemberTypeName)]; + } + } else { + handleError(change, new CoordinateNotFoundError(), config); + } +} diff --git a/packages/web/app/src/components/proposal/patch/types.ts b/packages/web/app/src/components/proposal/patch/types.ts new file mode 100644 index 0000000000..29bec94391 --- /dev/null +++ b/packages/web/app/src/components/proposal/patch/types.ts @@ -0,0 +1,32 @@ +import type { SchemaDefinitionNode, SchemaExtensionNode } from 'graphql'; +import type { Change, ChangeType } from '@graphql-inspector/core'; + +// @todo remove? +export type AdditionChangeType = + | ChangeType.DirectiveAdded + | ChangeType.DirectiveArgumentAdded + | ChangeType.DirectiveLocationAdded + | ChangeType.EnumValueAdded + | ChangeType.EnumValueDeprecationReasonAdded + | ChangeType.FieldAdded + | ChangeType.FieldArgumentAdded + | ChangeType.FieldDeprecationAdded + | ChangeType.FieldDeprecationReasonAdded + | ChangeType.FieldDescriptionAdded + | ChangeType.InputFieldAdded + | ChangeType.InputFieldDescriptionAdded + | ChangeType.ObjectTypeInterfaceAdded + | ChangeType.TypeDescriptionAdded + | ChangeType.TypeAdded + | ChangeType.UnionMemberAdded; + +export type SchemaNode = SchemaDefinitionNode | SchemaExtensionNode; + +export type TypeOfChangeType = (typeof ChangeType)[keyof typeof ChangeType]; + +export type ChangesByType = { [key in TypeOfChangeType]?: Array> }; + +export type PatchConfig = { + exitOnError?: boolean; + debug?: boolean; +}; diff --git a/packages/web/app/src/components/proposal/patch/utils.ts b/packages/web/app/src/components/proposal/patch/utils.ts index fe11eb171a..3327cf5be8 100644 --- a/packages/web/app/src/components/proposal/patch/utils.ts +++ b/packages/web/app/src/components/proposal/patch/utils.ts @@ -1,5 +1,6 @@ import { ArgumentNode, + ASTNode, ConstDirectiveNode, ConstValueNode, DirectiveNode, @@ -12,7 +13,9 @@ import { ValueNode, } from 'graphql'; import { Maybe } from 'graphql/jsutils/Maybe'; +import { Change, ChangeType } from '@graphql-inspector/core'; import { nameNode } from './node-templates'; +import { AdditionChangeType } from './types'; export function getDeprecatedDirectiveNode( definitionNode: Maybe<{ readonly directives?: ReadonlyArray }>, @@ -112,31 +115,24 @@ export function setInputValueDefinitionArgument( } } -export function setArgument( - node: Maybe<{ arguments?: ArgumentNode[] | readonly ArgumentNode[] | undefined }>, +export function upsertArgument( + node: { arguments?: ArgumentNode[] | readonly ArgumentNode[] }, argumentName: string, value: ValueNode, -): void { - if (node) { - let found = false; - for (const arg of node.arguments ?? []) { - if (arg.name.value === argumentName) { - (arg.value as ValueNode) = value; - found = true; - break; - } - } - if (!found) { - node.arguments = [ - ...(node.arguments ?? []), - { - kind: Kind.ARGUMENT, - name: nameNode(argumentName), - value, - }, - ]; +): ArgumentNode { + for (const arg of node.arguments ?? []) { + if (arg.name.value === argumentName) { + (arg.value as ValueNode) = value; + return arg; } } + const arg: ArgumentNode = { + kind: Kind.ARGUMENT, + name: nameNode(argumentName), + value, + }; + node.arguments = [...(node.arguments ?? []), arg]; + return arg; } export function findNamedNode( @@ -170,3 +166,48 @@ export function removeArgument( node.arguments = node.arguments.filter(arg => arg.name.value !== argumentName); } } + +export function parentPath(path: string) { + const lastDividerIndex = path.lastIndexOf('.'); + return lastDividerIndex === -1 ? path : path.substring(0, lastDividerIndex); +} + +const isAdditionChange = (change: Change): change is Change => { + switch (change.type) { + case ChangeType.DirectiveAdded: + case ChangeType.DirectiveArgumentAdded: + case ChangeType.DirectiveLocationAdded: + case ChangeType.EnumValueAdded: + case ChangeType.EnumValueDeprecationReasonAdded: + case ChangeType.FieldAdded: + case ChangeType.FieldArgumentAdded: + case ChangeType.FieldDeprecationAdded: + case ChangeType.FieldDeprecationReasonAdded: + case ChangeType.FieldDescriptionAdded: + case ChangeType.InputFieldAdded: + case ChangeType.InputFieldDescriptionAdded: + case ChangeType.ObjectTypeInterfaceAdded: + case ChangeType.TypeDescriptionAdded: + case ChangeType.TypeAdded: + case ChangeType.UnionMemberAdded: + return true; + default: + return false; + } +}; + +export function debugPrintChange(change: Change, nodeByPath: Map) { + if (isAdditionChange(change)) { + console.log(`"${change.path}" is being added to the schema.`); + } else { + const changedNode = (change.path && nodeByPath.get(change.path)) || false; + + if (changedNode) { + console.log(`"${change.path}" has a change: [${change.type}] "${change.message}"`); + } else { + console.log( + `The change to "${change.path}" cannot be applied. That coordinate does not exist in the schema.`, + ); + } + } +} From 5e0f401635c90362dc33c03d30e110f6ad715db5 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Thu, 7 Aug 2025 10:16:27 -0700 Subject: [PATCH 17/54] Remove new patch logic; This is moved to inspector's codebase --- .../proposal/patch/__tests__/index.test.ts | 397 ------------------ .../src/components/proposal/patch/errors.ts | 113 ----- .../src/components/proposal/patch/index.ts | 301 ------------- .../proposal/patch/node-templates.ts | 29 -- .../proposal/patch/patches/directives.ts | 217 ---------- .../components/proposal/patch/patches/enum.ts | 151 ------- .../proposal/patch/patches/fields.ts | 262 ------------ .../proposal/patch/patches/inputs.ts | 105 ----- .../proposal/patch/patches/interfaces.ts | 0 .../proposal/patch/patches/schema.ts | 77 ---- .../proposal/patch/patches/types.ts | 127 ------ .../proposal/patch/patches/unions.ts | 33 -- .../src/components/proposal/patch/print.ts | 12 - .../src/components/proposal/patch/types.ts | 32 -- .../src/components/proposal/patch/utils.ts | 213 ---------- 15 files changed, 2069 deletions(-) delete mode 100644 packages/web/app/src/components/proposal/patch/__tests__/index.test.ts delete mode 100644 packages/web/app/src/components/proposal/patch/errors.ts delete mode 100644 packages/web/app/src/components/proposal/patch/index.ts delete mode 100644 packages/web/app/src/components/proposal/patch/node-templates.ts delete mode 100644 packages/web/app/src/components/proposal/patch/patches/directives.ts delete mode 100644 packages/web/app/src/components/proposal/patch/patches/enum.ts delete mode 100644 packages/web/app/src/components/proposal/patch/patches/fields.ts delete mode 100644 packages/web/app/src/components/proposal/patch/patches/inputs.ts delete mode 100644 packages/web/app/src/components/proposal/patch/patches/interfaces.ts delete mode 100644 packages/web/app/src/components/proposal/patch/patches/schema.ts delete mode 100644 packages/web/app/src/components/proposal/patch/patches/types.ts delete mode 100644 packages/web/app/src/components/proposal/patch/patches/unions.ts delete mode 100644 packages/web/app/src/components/proposal/patch/print.ts delete mode 100644 packages/web/app/src/components/proposal/patch/types.ts delete mode 100644 packages/web/app/src/components/proposal/patch/utils.ts diff --git a/packages/web/app/src/components/proposal/patch/__tests__/index.test.ts b/packages/web/app/src/components/proposal/patch/__tests__/index.test.ts deleted file mode 100644 index 23bff3bbfc..0000000000 --- a/packages/web/app/src/components/proposal/patch/__tests__/index.test.ts +++ /dev/null @@ -1,397 +0,0 @@ -import { buildSchema, GraphQLSchema, lexicographicSortSchema, printSchema } from 'graphql'; -import type { Change, CriticalityLevel } from '@graphql-inspector/core'; -import { patchSchema } from '../index'; - -function printSortedSchema(schema: GraphQLSchema) { - return printSchema(lexicographicSortSchema(schema)); -} - -const schemaA = buildSchema( - /* GraphQL */ ` - extend schema - @link( - url: "https://specs.apollo.dev/federation/v2.3" - import: ["@key", "@shareable", "@inaccessible", "@tag"] - ) - @link(url: "https://specs.graphql-hive.com/hive/v1.0", import: ["@meta"]) - @meta(name: "priority", content: "tier1") - - directive @meta( - name: String! - content: String! - ) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION - - directive @myDirective(a: String!) on FIELD_DEFINITION - - directive @hello on FIELD_DEFINITION - - type Query { - allProducts: [ProductItf] @meta(name: "owner", content: "hive-team") - product(id: ID!): ProductItf - } - - interface ProductItf implements SkuItf @meta(name: "domain", content: "products") { - id: ID! - sku: String - name: String - package: String - variation: ProductVariation - dimensions: ProductDimension - createdBy: User - hidden: String @inaccessible - oldField: String @deprecated(reason: "refactored out") - } - - interface SkuItf { - sku: String - } - - type Product implements ProductItf & SkuItf - @key(fields: "id") - @key(fields: "sku package") - @key(fields: "sku variation { id }") - @meta(name: "owner", content: "product-team") { - id: ID! @tag(name: "hi-from-products") - sku: String @meta(name: "unique", content: "true") - name: String @hello - package: String - variation: ProductVariation - dimensions: ProductDimension - createdBy: User - hidden: String - reviewsScore: Float! - oldField: String - } - - enum ShippingClass { - STANDARD - EXPRESS - } - - type ProductVariation { - id: ID! - name: String - } - - type ProductDimension @shareable { - size: String - weight: Float - } - - type User @key(fields: "email") { - email: ID! - totalProductsCreated: Int @shareable - } - `, - { assumeValid: true, assumeValidSDL: true }, -); - -const schemaB = buildSchema( - /* GraphQL */ ` - extend schema - @link( - url: "https://specs.apollo.dev/federation/v2.3" - import: ["@key", "@shareable", "@inaccessible", "@tag"] - ) - @link(url: "https://specs.graphql-hive.com/hive/v1.0", import: ["@meta"]) - @meta(name: "priority", content: "tier1") - - directive @meta( - name: String! - content: String! - ) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION - - directive @myDirective(a: String!) on FIELD_DEFINITION - - directive @hello on FIELD_DEFINITION - - type Query { - allProducts(input: AllProductsInput): [ProductItf] @meta(name: "owner", content: "hive-team") - product(id: ID!): ProductItf - } - - input AllProductsInput { - """ - User ID who created the product record - """ - byCreator: ID - - """ - Search by partial match on a name. - """ - byName: String - } - - interface ProductItf implements SkuItf @meta(name: "domain", content: "products") { - id: ID! - sku: String - name: String - package: String - variation: ProductVariation - dimensions: ProductDimension - createdBy: User - hidden: String @inaccessible - } - - interface SkuItf { - sku: String - } - - type Product implements ProductItf & SkuItf - @key(fields: "id") - @key(fields: "sku package") - @key(fields: "sku variation { id }") - @meta(name: "owner", content: "product-team") { - id: ID! @tag(name: "hi-from-products") - sku: String @meta(name: "unique", content: "true") - name: String @hello - package: String - """ - The latest variation - """ - variation: ProductVariation - @deprecated(reason: "There can be multiple variations. Prefer Product.variations") - variations: [ProductVariation] - dimensions: ProductDimension - createdBy: User! - hidden: String - reviewsScore: Float! - } - - enum ShippingClass { - STANDARD - EXPRESS - OVERNIGHT - } - - type ProductVariation { - id: ID! - name: String - } - - type ProductDimension @shareable { - size: String - weight: Float - } - - type User @key(fields: "email") { - email: ID! - totalProductsCreated: Int @shareable - } - `, - { assumeValid: true, assumeValidSDL: true }, -); - -const changes: Change[] = [ - { - criticality: { - level: 'NON_BREAKING' as CriticalityLevel, - }, - message: "Type 'AllProductsInput' was added", - meta: { - addedTypeIsOneOf: false, - addedTypeKind: 'InputObjectTypeDefinition', - addedTypeName: 'AllProductsInput', - }, - path: 'AllProductsInput', - type: 'TYPE_ADDED', - }, - { - criticality: { - level: 'NON_BREAKING' as CriticalityLevel, - }, - message: - "Input field 'byCreator' of type 'ID' was added to input object type 'AllProductsInput'", - meta: { - addedInputFieldName: 'byCreator', - addedInputFieldType: 'ID', - addedToNewType: true, - inputName: 'AllProductsInput', - isAddedInputFieldTypeNullable: true, - }, - path: 'AllProductsInput.byCreator', - type: 'INPUT_FIELD_ADDED', - }, - { - criticality: { - level: 'NON_BREAKING' as CriticalityLevel, - }, - message: - "Input field 'AllProductsInput.byCreator' has description 'User ID who created the product record'", - meta: { - addedInputFieldDescription: 'User ID who created the product record', - inputFieldName: 'byCreator', - inputName: 'AllProductsInput', - }, - path: 'AllProductsInput.byCreator', - type: 'INPUT_FIELD_DESCRIPTION_ADDED', - }, - { - criticality: { - level: 'NON_BREAKING' as CriticalityLevel, - }, - message: - "Input field 'byName' of type 'String' was added to input object type 'AllProductsInput'", - meta: { - addedInputFieldName: 'byName', - addedInputFieldType: 'String', - addedToNewType: true, - inputName: 'AllProductsInput', - isAddedInputFieldTypeNullable: true, - }, - path: 'AllProductsInput.byName', - type: 'INPUT_FIELD_ADDED', - }, - { - criticality: { - level: 'NON_BREAKING' as CriticalityLevel, - }, - message: - "Input field 'AllProductsInput.byName' has description 'Search by partial match on a name.'", - meta: { - addedInputFieldDescription: 'Search by partial match on a name.', - inputFieldName: 'byName', - inputName: 'AllProductsInput', - }, - path: 'AllProductsInput.byName', - type: 'INPUT_FIELD_DESCRIPTION_ADDED', - }, - { - criticality: { - level: 'DANGEROUS' as CriticalityLevel.Dangerous, - }, - message: "Argument 'input: AllProductsInput' added to field 'Query.allProducts'", - meta: { - addedArgumentName: 'input', - addedArgumentType: 'AllProductsInput', - addedToNewField: false, - fieldName: 'allProducts', - hasDefaultValue: false, - isAddedFieldArgumentBreaking: false, - typeName: 'Query', - }, - path: 'Query.allProducts.input', - type: 'FIELD_ARGUMENT_ADDED', - }, - { - criticality: { - level: 'BREAKING' as CriticalityLevel, - reason: - "Removing a deprecated field is a breaking change. Before removing it, you may want to look at the field's usage to see the impact of removing the field.", - }, - message: "Field 'oldField' (deprecated) was removed from interface 'ProductItf'", - meta: { - isRemovedFieldDeprecated: true, - removedFieldName: 'oldField', - typeName: 'ProductItf', - typeType: 'interface', - }, - path: 'ProductItf.oldField', - type: 'FIELD_REMOVED', - }, - { - criticality: { - level: 'NON_BREAKING' as CriticalityLevel, - }, - message: "Field 'variations' was added to object type 'Product'", - meta: { - addedFieldName: 'variations', - addedFieldReturnType: '[ProductVariation]', - typeName: 'Product', - typeType: 'object type', - }, - path: 'Product.variations', - type: 'FIELD_ADDED', - }, - { - criticality: { - level: 'BREAKING' as CriticalityLevel, - reason: - 'Removing a field is a breaking change. It is preferable to deprecate the field before removing it. This applies to removed union fields as well, since removal breaks client operations that contain fragments that reference the removed type through direct (... on RemovedType) or indirect means such as __typename in the consumers.', - }, - message: "Field 'oldField' was removed from object type 'Product'", - meta: { - isRemovedFieldDeprecated: false, - removedFieldName: 'oldField', - typeName: 'Product', - typeType: 'object type', - }, - path: 'Product.oldField', - type: 'FIELD_REMOVED', - }, - { - criticality: { - level: 'NON_BREAKING' as CriticalityLevel, - }, - message: "Field 'Product.variation' has description 'The latest variation'", - meta: { - addedDescription: 'The latest variation', - fieldName: 'variation', - typeName: 'Product', - }, - path: 'Product.variation', - type: 'FIELD_DESCRIPTION_ADDED', - }, - { - criticality: { - level: 'NON_BREAKING' as CriticalityLevel, - }, - message: "Field 'Product.variation' is deprecated", - meta: { - deprecationReason: 'There can be multiple variations. Prefer Product.variations', - fieldName: 'variation', - typeName: 'Product', - }, - path: 'Product.variation', - type: 'FIELD_DEPRECATION_ADDED', - }, - { - criticality: { - level: 'NON_BREAKING' as CriticalityLevel, - reason: "Directive 'deprecated' was added to field 'variation'", - }, - message: "Directive 'deprecated' was added to field 'Product.variation'", - meta: { - addedDirectiveName: 'deprecated', - addedToNewType: false, - fieldName: 'variation', - typeName: 'Product', - }, - path: 'Product.variation.deprecated', - type: 'DIRECTIVE_USAGE_FIELD_DEFINITION_ADDED', - }, - { - criticality: { - level: 'NON_BREAKING' as CriticalityLevel, - }, - message: "Field 'Product.createdBy' changed type from 'User' to 'User!'", - meta: { - fieldName: 'createdBy', - isSafeFieldTypeChange: true, - newFieldType: 'User!', - oldFieldType: 'User', - typeName: 'Product', - }, - path: 'Product.createdBy', - type: 'FIELD_TYPE_CHANGED', - }, - { - criticality: { - level: 'DANGEROUS' as CriticalityLevel.Dangerous, - reason: - 'Adding an enum value may break existing clients that were not programming defensively against an added case when querying an enum.', - }, - message: "Enum value 'OVERNIGHT' was added to enum 'ShippingClass'", - meta: { - addedEnumValueName: 'OVERNIGHT', - addedToNewType: false, - enumName: 'ShippingClass', - }, - path: 'ShippingClass.OVERNIGHT', - type: 'ENUM_VALUE_ADDED', - }, -]; - -test('patch', async () => { - expect(printSortedSchema(schemaB)).toBe(printSortedSchema(patchSchema(schemaA, changes))); -}); diff --git a/packages/web/app/src/components/proposal/patch/errors.ts b/packages/web/app/src/components/proposal/patch/errors.ts deleted file mode 100644 index 8a7a0bd35b..0000000000 --- a/packages/web/app/src/components/proposal/patch/errors.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { Kind } from 'graphql'; -import type { Change } from '@graphql-inspector/core'; -import type { PatchConfig } from './types'; - -export function handleError(change: Change, err: Error, config: PatchConfig) { - if (config.exitOnError === true) { - throw err; - } else { - console.warn(`Cannot apply ${change.type} at "${change.path}". ${err.message}`); - } -} - -export class CoordinateNotFoundError extends Error { - constructor() { - super('Cannot find an element at the schema coordinate.'); - } -} - -export class CoordinateAlreadyExistsError extends Error { - constructor(public readonly kind: Kind) { - super(`A "${kind}" already exists at the schema coordinate.`); - } -} - -export class DeprecationReasonAlreadyExists extends Error { - constructor(reason: string) { - super(`A deprecation reason already exists: "${reason}"`); - } -} - -export class EnumValueNotFoundError extends Error { - constructor(typeName: string, value?: string | undefined) { - super(`The enum "${typeName}" does not contain "${value}".`); - } -} - -export class UnionMemberNotFoundError extends Error { - constructor(typeName: string, type: string) { - super(`The union "${typeName}" does not contain the member "${type}".`); - } -} - -export class UnionMemberAlreadyExistsError extends Error { - constructor(typeName: string, type: string) { - super(`The union "${typeName}" already contains the member "${type}".`); - } -} - -export class DirectiveLocationAlreadyExistsError extends Error { - constructor(directiveName: string, location: string) { - super(`The directive "${directiveName}" already can be located on "${location}".`); - } -} - -export class DirectiveAlreadyExists extends Error { - constructor(directiveName: string) { - super(`The directive "${directiveName}" already exists.`); - } -} - -export class KindMismatchError extends Error { - constructor( - public readonly expectedKind: Kind, - public readonly receivedKind: Kind, - ) { - super(`Expected type to have be a "${expectedKind}", but found a "${receivedKind}".`); - } -} - -export class FieldTypeMismatchError extends Error { - constructor(expectedReturnType: string, receivedReturnType: string) { - super(`Expected the field to return ${expectedReturnType} but found ${receivedReturnType}.`); - } -} - -export class OldValueMismatchError extends Error { - constructor( - expectedValue: string | null | undefined, - receivedOldValue: string | null | undefined, - ) { - super(`Expected the value ${expectedValue} but found ${receivedOldValue}.`); - } -} - -export class OldTypeMismatchError extends Error { - constructor(expectedType: string | null | undefined, receivedOldType: string | null | undefined) { - super(`Expected the type ${expectedType} but found ${receivedOldType}.`); - } -} - -export class InterfaceAlreadyExistsOnTypeError extends Error { - constructor(interfaceName: string) { - super( - `Cannot add the interface "${interfaceName}" because it already is applied at that coordinate.`, - ); - } -} - -export class ArgumentDefaultValueMismatchError extends Error { - constructor(expectedDefaultValue: string | undefined, actualDefaultValue: string | undefined) { - super( - `The argument's default value "${actualDefaultValue}" does not match the expected value "${expectedDefaultValue}".`, - ); - } -} - -export class ArgumentDescriptionMismatchError extends Error { - constructor(expectedDefaultValue: string | undefined, actualDefaultValue: string | undefined) { - super( - `The argument's description "${actualDefaultValue}" does not match the expected "${expectedDefaultValue}".`, - ); - } -} diff --git a/packages/web/app/src/components/proposal/patch/index.ts b/packages/web/app/src/components/proposal/patch/index.ts deleted file mode 100644 index 18a34fda7d..0000000000 --- a/packages/web/app/src/components/proposal/patch/index.ts +++ /dev/null @@ -1,301 +0,0 @@ -import { - ASTNode, - buildASTSchema, - DocumentNode, - GraphQLSchema, - isDefinitionNode, - Kind, - parse, - printSchema, - SchemaDefinitionNode, - SchemaExtensionNode, - visit, -} from 'graphql'; -import { Change, ChangeType } from '@graphql-inspector/core'; -import { - directiveAdded, - directiveArgumentAdded, - directiveArgumentDefaultValueChanged, - directiveArgumentDescriptionChanged, - directiveLocationAdded, -} from './patches/directives'; -import { - enumValueAdded, - enumValueDeprecationReasonAdded, - enumValueDescriptionChanged, - enumValueRemoved, -} from './patches/enum'; -import { - fieldAdded, - fieldArgumentAdded, - fieldDeprecationAdded, - fieldDeprecationReadonAdded, - fieldDescriptionAdded, - fieldDescriptionRemoved, - fieldRemoved, - fieldTypeChanged, -} from './patches/fields'; -import { inputFieldAdded, inputFieldDescriptionAdded } from './patches/inputs'; -import { - schemaMutationTypeChanged, - schemaQueryTypeChanged, - schemaSubscriptionTypeChanged, -} from './patches/schema'; -import { - objectTypeInterfaceAdded, - typeAdded, - typeDescriptionAdded, - typeRemoved, -} from './patches/types'; -import { unionMemberAdded } from './patches/unions'; -import { PatchConfig, SchemaNode } from './types'; -import { debugPrintChange } from './utils'; - -export function patchSchema(schema: GraphQLSchema, changes: Change[]): GraphQLSchema { - const ast = parse(printSchema(schema)); - return buildASTSchema(patch(ast, changes), { assumeValid: true, assumeValidSDL: true }); -} - -function groupNodesByPath(ast: DocumentNode): [SchemaNode[], Map] { - const schemaNodes: SchemaNode[] = []; - const nodeByPath = new Map(); - const pathArray: string[] = []; - visit(ast, { - enter(node) { - switch (node.kind) { - case Kind.ARGUMENT: - case Kind.ENUM_TYPE_DEFINITION: - case Kind.DIRECTIVE_DEFINITION: - case Kind.ENUM_TYPE_EXTENSION: - case Kind.ENUM_VALUE_DEFINITION: - case Kind.FIELD_DEFINITION: - case Kind.INPUT_OBJECT_TYPE_DEFINITION: - case Kind.INPUT_OBJECT_TYPE_EXTENSION: - case Kind.INPUT_VALUE_DEFINITION: - case Kind.INTERFACE_TYPE_DEFINITION: - case Kind.INTERFACE_TYPE_EXTENSION: - case Kind.OBJECT_FIELD: - case Kind.OBJECT_TYPE_DEFINITION: - case Kind.OBJECT_TYPE_EXTENSION: - case Kind.SCALAR_TYPE_DEFINITION: - case Kind.SCALAR_TYPE_EXTENSION: - case Kind.UNION_TYPE_DEFINITION: - case Kind.UNION_TYPE_EXTENSION: { - pathArray.push(node.name.value); - const path = pathArray.join('.'); - nodeByPath.set(path, node); - break; - } - case Kind.DOCUMENT: { - break; - } - case Kind.SCHEMA_EXTENSION: - case Kind.SCHEMA_DEFINITION: { - schemaNodes.push(node); - break; - } - default: { - // by definition this things like return types, names, named nodes... - // it's nothing we want to collect. - return false; - } - } - }, - leave(node) { - switch (node.kind) { - case Kind.ARGUMENT: - case Kind.ENUM_TYPE_DEFINITION: - case Kind.DIRECTIVE_DEFINITION: - case Kind.ENUM_TYPE_EXTENSION: - case Kind.ENUM_VALUE_DEFINITION: - case Kind.FIELD_DEFINITION: - case Kind.INPUT_OBJECT_TYPE_DEFINITION: - case Kind.INPUT_OBJECT_TYPE_EXTENSION: - case Kind.INPUT_VALUE_DEFINITION: - case Kind.INTERFACE_TYPE_DEFINITION: - case Kind.INTERFACE_TYPE_EXTENSION: - case Kind.OBJECT_FIELD: - case Kind.OBJECT_TYPE_DEFINITION: - case Kind.OBJECT_TYPE_EXTENSION: - case Kind.SCALAR_TYPE_DEFINITION: - case Kind.SCALAR_TYPE_EXTENSION: - case Kind.UNION_TYPE_DEFINITION: - case Kind.UNION_TYPE_EXTENSION: { - pathArray.pop(); - } - } - }, - }); - return [schemaNodes, nodeByPath]; -} - -export function patch( - ast: DocumentNode, - changes: Change[], - patchConfig?: PatchConfig, -): DocumentNode { - const config: PatchConfig = patchConfig ?? {}; - - const [schemaDefs, nodeByPath] = groupNodesByPath(ast); - - for (const change of changes) { - if (config.debug) { - debugPrintChange(change, nodeByPath); - } - - const changedPath = change.path; - if (changedPath === undefined) { - // a change without a path is useless... (@todo Only schema changes do this?) - continue; - } - - switch (change.type) { - case ChangeType.SchemaMutationTypeChanged: { - schemaMutationTypeChanged(change, schemaDefs, config); - break; - } - case ChangeType.SchemaQueryTypeChanged: { - schemaQueryTypeChanged(change, schemaDefs, config); - break; - } - case ChangeType.SchemaSubscriptionTypeChanged: { - schemaSubscriptionTypeChanged(change, schemaDefs, config); - break; - } - case ChangeType.DirectiveAdded: { - directiveAdded(change, nodeByPath, config); - break; - } - case ChangeType.DirectiveArgumentAdded: { - directiveArgumentAdded(change, nodeByPath, config); - break; - } - case ChangeType.DirectiveLocationAdded: { - directiveLocationAdded(change, nodeByPath, config); - break; - } - case ChangeType.EnumValueAdded: { - enumValueAdded(change, nodeByPath, config); - break; - } - case ChangeType.EnumValueDeprecationReasonAdded: { - enumValueDeprecationReasonAdded(change, nodeByPath, config); - break; - } - case ChangeType.FieldAdded: { - fieldAdded(change, nodeByPath, config); - break; - } - case ChangeType.FieldArgumentAdded: { - fieldArgumentAdded(change, nodeByPath, config); - break; - } - case ChangeType.FieldDeprecationAdded: { - fieldDeprecationAdded(change, nodeByPath, config); - break; - } - case ChangeType.FieldDeprecationReasonAdded: { - fieldDeprecationReadonAdded(change, nodeByPath, config); - break; - } - case ChangeType.FieldDescriptionAdded: { - fieldDescriptionAdded(change, nodeByPath, config); - break; - } - case ChangeType.InputFieldAdded: { - inputFieldAdded(change, nodeByPath, config); - break; - } - case ChangeType.InputFieldDescriptionAdded: { - inputFieldDescriptionAdded(change, nodeByPath, config); - break; - } - case ChangeType.ObjectTypeInterfaceAdded: { - objectTypeInterfaceAdded(change, nodeByPath, config); - break; - } - case ChangeType.TypeDescriptionAdded: { - typeDescriptionAdded(change, nodeByPath, config); - break; - } - case ChangeType.TypeAdded: { - typeAdded(change, nodeByPath, config); - break; - } - case ChangeType.UnionMemberAdded: { - unionMemberAdded(change, nodeByPath, config); - break; - } - case ChangeType.FieldRemoved: { - fieldRemoved(change, nodeByPath, config); - break; - } - case ChangeType.FieldTypeChanged: { - fieldTypeChanged(change, nodeByPath, config); - break; - } - case ChangeType.TypeRemoved: { - typeRemoved(change, nodeByPath, config); - break; - } - case ChangeType.EnumValueRemoved: { - enumValueRemoved(change, nodeByPath, config); - break; - } - case ChangeType.EnumValueDescriptionChanged: { - enumValueDescriptionChanged(change, nodeByPath, config); - break; - } - case ChangeType.FieldDescriptionRemoved: { - fieldDescriptionRemoved(change, nodeByPath, config); - break; - } - case ChangeType.DirectiveArgumentDefaultValueChanged: { - directiveArgumentDefaultValueChanged(change, nodeByPath, config); - break; - } - case ChangeType.DirectiveArgumentDescriptionChanged: { - directiveArgumentDescriptionChanged(change, nodeByPath, config); - break; - } - case ChangeType.DirectiveArgumentTypeChanged: { - directiveArgumentTypeChanged(change, nodeByPath, config); - } - // DirectiveUsageArgumentDefinitionAddedChange, - // DirectiveUsageArgumentDefinitionRemovedChange, - // DirectiveUsageArgumentDefinitionChange, - // DirectiveUsageEnumAddedChange, - // DirectiveUsageEnumRemovedChange, - // DirectiveUsageEnumValueAddedChange, - // DirectiveUsageEnumValueRemovedChange, - // DirectiveUsageFieldAddedChange, - // DirectiveUsageFieldDefinitionAddedChange, - // DirectiveUsageFieldDefinitionRemovedChange, - // DirectiveUsageFieldRemovedChange, - // DirectiveUsageInputFieldDefinitionAddedChange, - // DirectiveUsageInputFieldDefinitionRemovedChange, - // DirectiveUsageInputObjectAddedChange, - // DirectiveUsageInputObjectRemovedChange, - // DirectiveUsageInterfaceAddedChange, - // DirectiveUsageInterfaceRemovedChange, - // DirectiveUsageObjectAddedChange, - // DirectiveUsageObjectRemovedChange, - // DirectiveUsageScalarAddedChange, - // DirectiveUsageScalarRemovedChange, - // DirectiveUsageSchemaAddedChange, - // DirectiveUsageSchemaRemovedChange, - // DirectiveUsageUnionMemberAddedChange, - // DirectiveUsageUnionMemberRemovedChange, - default: { - console.log(`${change.type} is not implemented yet.`); - } - } - } - - return { - kind: Kind.DOCUMENT, - - // filter out the non-definition nodes (e.g. field definitions) - definitions: nodeByPath.values().filter(isDefinitionNode).toArray(), - }; -} diff --git a/packages/web/app/src/components/proposal/patch/node-templates.ts b/packages/web/app/src/components/proposal/patch/node-templates.ts deleted file mode 100644 index 09845db469..0000000000 --- a/packages/web/app/src/components/proposal/patch/node-templates.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { Kind, NamedTypeNode, NameNode, StringValueNode, TypeNode } from 'graphql'; - -export function nameNode(name: string): NameNode { - return { - value: name, - kind: Kind.NAME, - }; -} - -export function stringNode(value: string): StringValueNode { - return { - kind: Kind.STRING, - value, - }; -} - -export function typeNode(name: string): TypeNode { - return { - kind: Kind.NAMED_TYPE, - name: nameNode(name), - }; -} - -export function namedTypeNode(name: string): NamedTypeNode { - return { - kind: Kind.NAMED_TYPE, - name: nameNode(name), - }; -} diff --git a/packages/web/app/src/components/proposal/patch/patches/directives.ts b/packages/web/app/src/components/proposal/patch/patches/directives.ts deleted file mode 100644 index 8554605ed4..0000000000 --- a/packages/web/app/src/components/proposal/patch/patches/directives.ts +++ /dev/null @@ -1,217 +0,0 @@ -import { - ASTNode, - DirectiveDefinitionNode, - InputValueDefinitionNode, - Kind, - NameNode, - parseConstValue, - parseType, - print, - StringValueNode, - TypeNode, - ValueNode, -} from 'graphql'; -import { Change, ChangeType } from '@graphql-inspector/core'; -import { - ArgumentDefaultValueMismatchError, - ArgumentDescriptionMismatchError, - CoordinateAlreadyExistsError, - CoordinateNotFoundError, - DirectiveLocationAlreadyExistsError, - handleError, - KindMismatchError, - OldTypeMismatchError, -} from '../errors'; -import { nameNode, stringNode } from '../node-templates'; -import { PatchConfig } from '../types'; -import { parentPath } from '../utils'; - -export function directiveAdded( - change: Change, - nodeByPath: Map, - config: PatchConfig, -) { - const changedPath = change.path!; - const changedNode = nodeByPath.get(changedPath); - if (changedNode) { - handleError(change, new CoordinateAlreadyExistsError(changedNode.kind), config); - } else { - const node: DirectiveDefinitionNode = { - kind: Kind.DIRECTIVE_DEFINITION, - name: nameNode(change.meta.addedDirectiveName), - repeatable: change.meta.addedDirectiveRepeatable, - locations: change.meta.addedDirectiveLocations.map(l => nameNode(l)), - description: change.meta.addedDirectiveDescription - ? stringNode(change.meta.addedDirectiveDescription) - : undefined, - }; - nodeByPath.set(changedPath, node); - } -} - -export function directiveArgumentAdded( - change: Change, - nodeByPath: Map, - config: PatchConfig, -) { - const changedPath = change.path!; - const argumentNode = nodeByPath.get(changedPath); - const directiveNode = nodeByPath.get(parentPath(changedPath)); - if (argumentNode) { - handleError(change, new CoordinateAlreadyExistsError(argumentNode.kind), config); - } else if (!directiveNode) { - handleError(change, new CoordinateNotFoundError(), config); - } else if (directiveNode.kind === Kind.DIRECTIVE_DEFINITION) { - const node: InputValueDefinitionNode = { - kind: Kind.INPUT_VALUE_DEFINITION, - name: nameNode(change.meta.addedDirectiveArgumentName), - type: parseType(change.meta.addedDirectiveArgumentType), - }; - (directiveNode.arguments as InputValueDefinitionNode[] | undefined) = [ - ...(directiveNode.arguments ?? []), - node, - ]; - nodeByPath.set(changedPath, node); - } else { - handleError( - change, - new KindMismatchError(Kind.DIRECTIVE_DEFINITION, directiveNode.kind), - config, - ); - } -} - -export function directiveLocationAdded( - change: Change, - nodeByPath: Map, - config: PatchConfig, -) { - const changedPath = change.path!; - const changedNode = nodeByPath.get(changedPath); - if (changedNode) { - if (changedNode.kind === Kind.DIRECTIVE_DEFINITION) { - if (changedNode.locations.some(l => l.value === change.meta.addedDirectiveLocation)) { - handleError( - change, - new DirectiveLocationAlreadyExistsError( - change.meta.directiveName, - change.meta.addedDirectiveLocation, - ), - config, - ); - } else { - (changedNode.locations as NameNode[]) = [ - ...changedNode.locations, - nameNode(change.meta.addedDirectiveLocation), - ]; - } - } else { - handleError( - change, - new KindMismatchError(Kind.DIRECTIVE_DEFINITION, changedNode.kind), - config, - ); - } - } else { - handleError(change, new CoordinateNotFoundError(), config); - } -} - -export function directiveArgumentDefaultValueChanged( - change: Change, - nodeByPath: Map, - config: PatchConfig, -) { - const changedPath = change.path!; - const argumentNode = nodeByPath.get(changedPath); - if (!argumentNode) { - handleError(change, new CoordinateNotFoundError(), config); - } else if (argumentNode.kind === Kind.INPUT_VALUE_DEFINITION) { - if ( - argumentNode.defaultValue && - print(argumentNode.defaultValue) === change.meta.oldDirectiveArgumentDefaultValue - ) { - (argumentNode.defaultValue as ValueNode | undefined) = change.meta - .newDirectiveArgumentDefaultValue - ? parseConstValue(change.meta.newDirectiveArgumentDefaultValue) - : undefined; - } else { - handleError( - change, - new ArgumentDefaultValueMismatchError( - change.meta.oldDirectiveArgumentDefaultValue, - argumentNode.defaultValue && print(argumentNode.defaultValue), - ), - config, - ); - } - } else { - handleError( - change, - new KindMismatchError(Kind.INPUT_VALUE_DEFINITION, argumentNode.kind), - config, - ); - } -} - -export function directiveArgumentDescriptionChanged( - change: Change, - nodeByPath: Map, - config: PatchConfig, -) { - const changedPath = change.path!; - const argumentNode = nodeByPath.get(changedPath); - if (!argumentNode) { - handleError(change, new CoordinateNotFoundError(), config); - } else if (argumentNode.kind === Kind.INPUT_VALUE_DEFINITION) { - if (argumentNode.description?.value === change.meta.oldDirectiveArgumentDescription) { - handleError( - change, - new ArgumentDescriptionMismatchError( - change.meta.oldDirectiveArgumentDescription, - argumentNode.description.value, - ), - config, - ); - } else { - (argumentNode.description as StringValueNode | undefined) = change.meta - .newDirectiveArgumentDescription - ? stringNode(change.meta.newDirectiveArgumentDescription) - : undefined; - } - } else { - handleError( - change, - new KindMismatchError(Kind.INPUT_VALUE_DEFINITION, argumentNode.kind), - config, - ); - } -} - -export function directiveArgumentTypeChanged( - change: Change, - nodeByPath: Map, - config: PatchConfig, -) { - const changedPath = change.path!; - const argumentNode = nodeByPath.get(changedPath); - if (!argumentNode) { - handleError(change, new CoordinateNotFoundError(), config); - } else if (argumentNode.kind === Kind.INPUT_VALUE_DEFINITION) { - if (print(argumentNode.type) === change.meta.oldDirectiveArgumentType) { - handleError( - change, - new OldTypeMismatchError(change.meta.oldDirectiveArgumentType, print(argumentNode.type)), - config, - ); - } else { - (argumentNode.type as TypeNode | undefined) = parseType(change.meta.newDirectiveArgumentType); - } - } else { - handleError( - change, - new KindMismatchError(Kind.INPUT_VALUE_DEFINITION, argumentNode.kind), - config, - ); - } -} diff --git a/packages/web/app/src/components/proposal/patch/patches/enum.ts b/packages/web/app/src/components/proposal/patch/patches/enum.ts deleted file mode 100644 index 24a8dd2403..0000000000 --- a/packages/web/app/src/components/proposal/patch/patches/enum.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { ASTNode, EnumValueDefinitionNode, isEnumType, Kind, StringValueNode } from 'graphql'; -import { Change, ChangeType } from '@graphql-inspector/core'; -import { - CoordinateAlreadyExistsError, - CoordinateNotFoundError, - EnumValueNotFoundError, - handleError, - KindMismatchError, - OldValueMismatchError, -} from '../errors'; -import { nameNode, stringNode } from '../node-templates'; -import type { PatchConfig } from '../types'; -import { getDeprecatedDirectiveNode, parentPath, upsertArgument } from '../utils'; - -export function enumValueRemoved( - removal: Change, - nodeByPath: Map, - config: PatchConfig, -) { - const changedPath = removal.path!; - const typeNode = nodeByPath.get(parentPath(changedPath)) as - | (ASTNode & { values?: EnumValueDefinitionNode[] }) - | undefined; - if (!typeNode) { - handleError(removal, new CoordinateNotFoundError(), config); - } else if (!isEnumType(typeNode)) { - handleError(removal, new KindMismatchError(Kind.ENUM_TYPE_DEFINITION, typeNode.kind), config); - } else if (!typeNode.values?.length) { - handleError( - removal, - new EnumValueNotFoundError(removal.meta.enumName, removal.meta.removedEnumValueName), - config, - ); - } else { - const beforeLength = typeNode.values.length; - typeNode.values = typeNode.values.filter( - f => f.name.value !== removal.meta.removedEnumValueName, - ); - if (beforeLength === typeNode.values.length) { - handleError( - removal, - new EnumValueNotFoundError(removal.meta.enumName, removal.meta.removedEnumValueName), - config, - ); - } else { - // delete the reference to the removed field. - nodeByPath.delete(changedPath); - } - } -} - -export function enumValueAdded( - change: Change, - nodeByPath: Map, - config: PatchConfig, -) { - const enumValuePath = change.path!; - const enumNode = nodeByPath.get(parentPath(enumValuePath)) as - | (ASTNode & { values: EnumValueDefinitionNode[] }) - | undefined; - const changedNode = nodeByPath.get(enumValuePath); - if (!enumNode) { - handleError(change, new CoordinateNotFoundError(), config); - console.warn( - `Cannot apply change: ${change.type} to ${enumValuePath}. Parent type is missing.`, - ); - } else if (changedNode) { - handleError(change, new CoordinateAlreadyExistsError(changedNode.kind), config); - } else if (enumNode.kind !== Kind.ENUM_TYPE_DEFINITION) { - handleError(change, new KindMismatchError(Kind.ENUM_TYPE_DEFINITION, enumNode.kind), config); - } else { - const c = change as Change; - const node: EnumValueDefinitionNode = { - kind: Kind.ENUM_VALUE_DEFINITION, - name: nameNode(c.meta.addedEnumValueName), - description: c.meta.addedDirectiveDescription - ? stringNode(c.meta.addedDirectiveDescription) - : undefined, - }; - (enumNode.values as EnumValueDefinitionNode[]) = [...(enumNode.values ?? []), node]; - nodeByPath.set(enumValuePath, node); - } -} - -export function enumValueDeprecationReasonAdded( - change: Change, - nodeByPath: Map, - config: PatchConfig, -) { - const changedPath = change.path!; - const enumValueNode = nodeByPath.get(changedPath); - if (enumValueNode) { - if (enumValueNode.kind === Kind.ENUM_VALUE_DEFINITION) { - const deprecation = getDeprecatedDirectiveNode(enumValueNode); - if (deprecation) { - const argNode = upsertArgument( - deprecation, - 'reason', - stringNode(change.meta.addedValueDeprecationReason), - ); - nodeByPath.set(`${changedPath}.reason`, argNode); - } else { - handleError(change, new CoordinateNotFoundError(), config); - } - } else { - handleError( - change, - new KindMismatchError(Kind.ENUM_VALUE_DEFINITION, enumValueNode.kind), - config, - ); - } - } else { - handleError(change, new EnumValueNotFoundError(change.meta.enumName), config); - } -} - -export function enumValueDescriptionChanged( - change: Change, - nodeByPath: Map, - config: PatchConfig, -) { - const changedPath = change.path!; - const enumValueNode = nodeByPath.get(changedPath); - if (enumValueNode) { - if (enumValueNode.kind === Kind.ENUM_VALUE_DEFINITION) { - if (change.meta.oldEnumValueDescription !== enumValueNode.description?.value) { - handleError( - change, - new OldValueMismatchError( - change.meta.oldEnumValueDescription, - enumValueNode.description?.value, - ), - config, - ); - } else { - (enumValueNode.description as StringValueNode | undefined) = change.meta - .newEnumValueDescription - ? stringNode(change.meta.newEnumValueDescription) - : undefined; - } - } else { - handleError( - change, - new KindMismatchError(Kind.ENUM_VALUE_DEFINITION, enumValueNode.kind), - config, - ); - } - } else { - handleError(change, new EnumValueNotFoundError(change.meta.enumName), config); - } -} diff --git a/packages/web/app/src/components/proposal/patch/patches/fields.ts b/packages/web/app/src/components/proposal/patch/patches/fields.ts deleted file mode 100644 index 41169f0940..0000000000 --- a/packages/web/app/src/components/proposal/patch/patches/fields.ts +++ /dev/null @@ -1,262 +0,0 @@ -import { - ArgumentNode, - ASTNode, - DirectiveNode, - FieldDefinitionNode, - GraphQLDeprecatedDirective, - InputValueDefinitionNode, - Kind, - parseType, - parseValue, - print, - StringValueNode, - TypeNode, -} from 'graphql'; -import { Change, ChangeType } from '@graphql-inspector/core'; -import { - CoordinateAlreadyExistsError, - CoordinateNotFoundError, - DeprecationReasonAlreadyExists, - DirectiveAlreadyExists, - FieldTypeMismatchError, - handleError, - KindMismatchError, -} from '../errors'; -import { nameNode, stringNode } from '../node-templates'; -import type { PatchConfig } from '../types'; -import { getDeprecatedDirectiveNode, parentPath } from '../utils'; - -export function fieldTypeChanged( - change: Change, - nodeByPath: Map, - config: PatchConfig, -) { - const c = change as Change; - const node = nodeByPath.get(c.path!); - if (node) { - if (node.kind === Kind.FIELD_DEFINITION) { - const currentReturnType = print(node.type); - if (c.meta.oldFieldType === currentReturnType) { - (node.type as TypeNode) = parseType(c.meta.newFieldType); - } else { - handleError(c, new FieldTypeMismatchError(c.meta.oldFieldType, currentReturnType), config); - } - } else { - handleError(c, new KindMismatchError(Kind.FIELD_DEFINITION, node.kind), config); - } - } else { - handleError(c, new CoordinateNotFoundError(), config); - } -} - -export function fieldRemoved( - removal: Change, - nodeByPath: Map, - config: PatchConfig, -) { - const changedPath = removal.path!; - const typeNode = nodeByPath.get(parentPath(changedPath)) as - | (ASTNode & { fields?: FieldDefinitionNode[] }) - | undefined; - if (!typeNode || !typeNode.fields?.length) { - handleError(removal, new CoordinateNotFoundError(), config); - } else { - const beforeLength = typeNode.fields.length; - typeNode.fields = typeNode.fields.filter(f => f.name.value !== removal.meta.removedFieldName); - if (beforeLength === typeNode.fields.length) { - handleError(removal, new CoordinateNotFoundError(), config); - } else { - // delete the reference to the removed field. - nodeByPath.delete(changedPath); - } - } -} - -export function fieldAdded( - change: Change, - nodeByPath: Map, - config: PatchConfig, -) { - const changedPath = change.path!; - const changedNode = nodeByPath.get(changedPath); - if (changedNode) { - handleError(change, new CoordinateAlreadyExistsError(changedNode.kind), config); - } else { - const typeNode = nodeByPath.get(parentPath(changedPath)) as ASTNode & { - fields?: FieldDefinitionNode[]; - }; - if (!typeNode) { - handleError(change, new CoordinateNotFoundError(), config); - } else if ( - typeNode.kind !== Kind.OBJECT_TYPE_DEFINITION && - typeNode.kind !== Kind.INTERFACE_TYPE_DEFINITION - ) { - handleError(change, new KindMismatchError(Kind.ENUM_TYPE_DEFINITION, typeNode.kind), config); - } else { - const node: FieldDefinitionNode = { - kind: Kind.FIELD_DEFINITION, - name: nameNode(change.meta.addedFieldName), - type: parseType(change.meta.addedFieldReturnType), - description: change.meta.addedFieldDescription - ? stringNode(change.meta.addedFieldDescription) - : undefined, - }; - - typeNode.fields = [...(typeNode.fields ?? []), node]; - - // add new field to the node set - nodeByPath.set(changedPath, node); - } - } -} - -export function fieldArgumentAdded( - change: Change, - nodeByPath: Map, - config: PatchConfig, -) { - const changedPath = change.path!; - const existing = nodeByPath.get(changedPath); - if (existing) { - handleError(change, new CoordinateAlreadyExistsError(existing.kind), config); - } else { - const fieldNode = nodeByPath.get(parentPath(changedPath)) as ASTNode & { - arguments?: InputValueDefinitionNode[]; - }; - if (!fieldNode) { - handleError(change, new CoordinateNotFoundError(), config); - } else if (!fieldNode.arguments) { - handleError(change, new KindMismatchError(Kind.FIELD_DEFINITION, fieldNode.kind), config); - } else { - const node: InputValueDefinitionNode = { - kind: Kind.INPUT_VALUE_DEFINITION, - name: nameNode(change.meta.addedArgumentName), - type: parseType(change.meta.addedArgumentType), - description: change.meta.addedFieldArgumentDescription - ? stringNode(change.meta.addedFieldArgumentDescription) - : undefined, - }; - - fieldNode.arguments = [...(fieldNode.arguments ?? []), node]; - - // add new field to the node set - nodeByPath.set(changedPath, node); - } - } -} - -export function fieldDeprecationReadonAdded( - change: Change, - nodeByPath: Map, - config: PatchConfig, -) { - const changedPath = change.path!; - const deprecationNode = nodeByPath.get(changedPath); - if (deprecationNode) { - if (deprecationNode.kind === Kind.DIRECTIVE) { - const reasonArgument = deprecationNode.arguments?.find(a => a.name.value === 'reason'); - if (reasonArgument) { - handleError( - change, - new DeprecationReasonAlreadyExists((reasonArgument.value as StringValueNode)?.value), - config, - ); - } else { - (deprecationNode.arguments as ArgumentNode[] | undefined) = [ - ...(deprecationNode.arguments ?? []), - { - kind: Kind.ARGUMENT, - name: nameNode('reason'), - value: stringNode(change.meta.addedDeprecationReason), - } as ArgumentNode, - ]; - // nodeByPath.set(changedPath) - } - } else { - handleError(change, new KindMismatchError(Kind.DIRECTIVE, deprecationNode.kind), config); - } - } else { - handleError(change, new CoordinateNotFoundError(), config); - } -} - -export function fieldDeprecationAdded( - change: Change, - nodeByPath: Map, - config: PatchConfig, -) { - const changedPath = change.path!; - const fieldNode = nodeByPath.get(changedPath); - if (fieldNode) { - if (fieldNode.kind === Kind.FIELD_DEFINITION) { - const hasExistingDeprecationDirective = getDeprecatedDirectiveNode(fieldNode); - if (hasExistingDeprecationDirective) { - handleError(change, new DirectiveAlreadyExists(GraphQLDeprecatedDirective.name), config); - } else { - const directiveNode = { - kind: Kind.DIRECTIVE, - name: nameNode(GraphQLDeprecatedDirective.name), - ...(change.meta.deprecationReason - ? { - arguments: [ - { - kind: Kind.ARGUMENT, - name: nameNode('reason'), - value: stringNode(change.meta.deprecationReason), - }, - ], - } - : {}), - } as DirectiveNode; - - (fieldNode.directives as DirectiveNode[] | undefined) = [ - ...(fieldNode.directives ?? []), - directiveNode, - ]; - nodeByPath.set(`${changedPath}.${directiveNode.name.value}`, directiveNode); - } - } else { - handleError(change, new KindMismatchError(Kind.FIELD_DEFINITION, fieldNode.kind), config); - } - } else { - handleError(change, new CoordinateNotFoundError(), config); - } -} - -export function fieldDescriptionAdded( - change: Change, - nodeByPath: Map, - config: PatchConfig, -) { - const changedPath = change.path!; - const fieldNode = nodeByPath.get(changedPath); - if (fieldNode) { - if (fieldNode.kind === Kind.FIELD_DEFINITION) { - (fieldNode.description as StringValueNode | undefined) = change.meta.addedDescription - ? stringNode(change.meta.addedDescription) - : undefined; - } else { - handleError(change, new KindMismatchError(Kind.FIELD_DEFINITION, fieldNode.kind), config); - } - } else { - handleError(change, new CoordinateNotFoundError(), config); - } -} - -export function fieldDescriptionRemoved( - change: Change, - nodeByPath: Map, - config: PatchConfig, -) { - const changedPath = change.path!; - const fieldNode = nodeByPath.get(changedPath); - if (fieldNode) { - if (fieldNode.kind === Kind.FIELD_DEFINITION) { - (fieldNode.description as StringValueNode | undefined) = undefined; - } else { - handleError(change, new KindMismatchError(Kind.FIELD_DEFINITION, fieldNode.kind), config); - } - } else { - handleError(change, new CoordinateNotFoundError(), config); - } -} diff --git a/packages/web/app/src/components/proposal/patch/patches/inputs.ts b/packages/web/app/src/components/proposal/patch/patches/inputs.ts deleted file mode 100644 index fb795e4687..0000000000 --- a/packages/web/app/src/components/proposal/patch/patches/inputs.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { ASTNode, InputValueDefinitionNode, Kind, parseType, StringValueNode } from 'graphql'; -import { Change, ChangeType } from '@graphql-inspector/core'; -import { - CoordinateAlreadyExistsError, - CoordinateNotFoundError, - handleError, - KindMismatchError, -} from '../errors'; -import { nameNode, stringNode } from '../node-templates'; -import type { PatchConfig } from '../types'; -import { parentPath } from '../utils'; - -export function inputFieldAdded( - change: Change, - nodeByPath: Map, - config: PatchConfig, -) { - const inputFieldPath = change.path!; - const existingNode = nodeByPath.get(inputFieldPath); - if (existingNode) { - handleError(change, new CoordinateAlreadyExistsError(existingNode.kind), config); - } else { - const typeNode = nodeByPath.get(parentPath(inputFieldPath)) as ASTNode & { - fields?: InputValueDefinitionNode[]; - }; - if (!typeNode) { - handleError(change, new CoordinateNotFoundError(), config); - } else if (typeNode.kind !== Kind.INPUT_OBJECT_TYPE_DEFINITION) { - handleError( - change, - new KindMismatchError(Kind.INPUT_OBJECT_TYPE_DEFINITION, typeNode.kind), - config, - ); - } else { - const node: InputValueDefinitionNode = { - kind: Kind.INPUT_VALUE_DEFINITION, - name: nameNode(change.meta.addedInputFieldName), - type: parseType(change.meta.addedInputFieldType), - description: change.meta.addedInputFieldDescription - ? stringNode(change.meta.addedInputFieldDescription) - : undefined, - }; - - typeNode.fields = [...(typeNode.fields ?? []), node]; - - // add new field to the node set - nodeByPath.set(inputFieldPath, node); - } - } -} - -export function inputFieldDescriptionAdded( - change: Change, - nodeByPath: Map, - config: PatchConfig, -) { - const inputFieldPath = change.path!; - const existingNode = nodeByPath.get(inputFieldPath); - if (existingNode) { - if (existingNode.kind === Kind.INPUT_VALUE_DEFINITION) { - (existingNode.description as StringValueNode | undefined) = stringNode( - change.meta.addedInputFieldDescription, - ); - } else { - handleError( - change, - new KindMismatchError(Kind.INPUT_VALUE_DEFINITION, existingNode.kind), - config, - ); - } - } else { - handleError(change, new CoordinateNotFoundError(), config); - } -} - -export function inputFieldDescriptionRemoved( - change: Change, - nodeByPath: Map, - config: PatchConfig, -) { - const inputFieldPath = change.path!; - const existingNode = nodeByPath.get(inputFieldPath); - if (existingNode) { - if (existingNode.kind === Kind.INPUT_VALUE_DEFINITION) { - if (existingNode.description === undefined) { - console.warn( - `Cannot remove a description at ${change.path} because no description is set.`, - ); - } else if (existingNode.description.value !== change.meta.removedDescription) { - console.warn( - `Description at ${change.path} does not match expected description, but proceeding with description removal anyways.`, - ); - } - (existingNode.description as StringValueNode | undefined) = undefined; - } else { - handleError( - change, - new KindMismatchError(Kind.INPUT_VALUE_DEFINITION, existingNode.kind), - config, - ); - } - } else { - handleError(change, new CoordinateNotFoundError(), config); - } -} diff --git a/packages/web/app/src/components/proposal/patch/patches/interfaces.ts b/packages/web/app/src/components/proposal/patch/patches/interfaces.ts deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/packages/web/app/src/components/proposal/patch/patches/schema.ts b/packages/web/app/src/components/proposal/patch/patches/schema.ts deleted file mode 100644 index 7e4eb0ce5d..0000000000 --- a/packages/web/app/src/components/proposal/patch/patches/schema.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { NameNode, OperationTypeNode } from 'graphql'; -import type { Change, ChangeType } from '@graphql-inspector/core'; -import { CoordinateNotFoundError, handleError, OldTypeMismatchError } from '../errors'; -import { nameNode } from '../node-templates'; -import { PatchConfig, SchemaNode } from '../types'; - -export function schemaMutationTypeChanged( - change: Change, - schemaNodes: SchemaNode[], - config: PatchConfig, -) { - // @todo handle type extensions correctly - for (const schemaNode of schemaNodes) { - const mutation = schemaNode.operationTypes?.find( - ({ operation }) => operation === OperationTypeNode.MUTATION, - ); - if (!mutation) { - handleError(change, new CoordinateNotFoundError(), config); - } else if (mutation.type.name.value === change.meta.oldMutationTypeName) { - (mutation.type.name as NameNode) = nameNode(change.meta.newMutationTypeName); - } else { - handleError( - change, - new OldTypeMismatchError(change.meta.oldMutationTypeName, mutation?.type.name.value), - config, - ); - } - } -} - -export function schemaQueryTypeChanged( - change: Change, - schemaNodes: SchemaNode[], - config: PatchConfig, -) { - // @todo handle type extensions correctly - for (const schemaNode of schemaNodes) { - const query = schemaNode.operationTypes?.find( - ({ operation }) => operation === OperationTypeNode.MUTATION, - ); - if (!query) { - handleError(change, new CoordinateNotFoundError(), config); - } else if (query.type.name.value === change.meta.oldQueryTypeName) { - (query.type.name as NameNode) = nameNode(change.meta.newQueryTypeName); - } else { - handleError( - change, - new OldTypeMismatchError(change.meta.oldQueryTypeName, query?.type.name.value), - config, - ); - } - } -} - -export function schemaSubscriptionTypeChanged( - change: Change, - schemaNodes: SchemaNode[], - config: PatchConfig, -) { - // @todo handle type extensions correctly - for (const schemaNode of schemaNodes) { - const sub = schemaNode.operationTypes?.find( - ({ operation }) => operation === OperationTypeNode.MUTATION, - ); - if (!sub) { - handleError(change, new CoordinateNotFoundError(), config); - } else if (sub.type.name.value === change.meta.oldSubscriptionTypeName) { - (sub.type.name as NameNode) = nameNode(change.meta.newSubscriptionTypeName); - } else { - handleError( - change, - new OldTypeMismatchError(change.meta.oldSubscriptionTypeName, sub?.type.name.value), - config, - ); - } - } -} diff --git a/packages/web/app/src/components/proposal/patch/patches/types.ts b/packages/web/app/src/components/proposal/patch/patches/types.ts deleted file mode 100644 index e719070976..0000000000 --- a/packages/web/app/src/components/proposal/patch/patches/types.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { - ASTNode, - isTypeDefinitionNode, - Kind, - NamedTypeNode, - StringValueNode, - TypeDefinitionNode, -} from 'graphql'; -import { Change, ChangeType } from '@graphql-inspector/core'; -import { - CoordinateAlreadyExistsError, - CoordinateNotFoundError, - handleError, - InterfaceAlreadyExistsOnTypeError, - KindMismatchError, -} from '../errors'; -import { namedTypeNode, nameNode, stringNode } from '../node-templates'; -import type { PatchConfig } from '../types'; - -export function typeAdded( - change: Change, - nodeByPath: Map, - config: PatchConfig, -) { - const changedPath = change.path!; - const existing = nodeByPath.get(changedPath); - if (existing) { - handleError(change, new CoordinateAlreadyExistsError(existing.kind), config); - } else { - const node: TypeDefinitionNode = { - name: nameNode(change.meta.addedTypeName), - kind: change.meta.addedTypeKind as TypeDefinitionNode['kind'], - }; - // @todo is this enough? - nodeByPath.set(changedPath, node); - } -} - -export function typeRemoved( - removal: Change, - nodeByPath: Map, - config: PatchConfig, -) { - const changedPath = removal.path!; - const removedNode = nodeByPath.get(changedPath); - if (removedNode) { - if (isTypeDefinitionNode(removedNode)) { - // delete the reference to the removed field. - for (const key of nodeByPath.keys()) { - if (key.startsWith(changedPath)) { - nodeByPath.delete(key); - } - } - } else { - handleError( - removal, - new KindMismatchError(Kind.OBJECT_TYPE_DEFINITION, removedNode.kind), - config, - ); - } - } else { - handleError(removal, new CoordinateNotFoundError(), config); - } -} - -export function typeDescriptionAdded( - change: Change, - nodeByPath: Map, - config: PatchConfig, -) { - const changedPath = change.path!; - const typeNode = nodeByPath.get(changedPath); - if (typeNode) { - if (isTypeDefinitionNode(typeNode)) { - (typeNode.description as StringValueNode | undefined) = change.meta.addedTypeDescription - ? stringNode(change.meta.addedTypeDescription) - : undefined; - } else { - handleError( - change, - new KindMismatchError(Kind.OBJECT_TYPE_DEFINITION, typeNode.kind), - config, - ); - } - } else { - handleError(change, new CoordinateNotFoundError(), config); - } -} - -export function objectTypeInterfaceAdded( - change: Change, - nodeByPath: Map, - config: PatchConfig, -) { - const changedPath = change.path!; - const typeNode = nodeByPath.get(changedPath); - if (typeNode) { - if ( - typeNode.kind === Kind.OBJECT_TYPE_DEFINITION || - typeNode.kind === Kind.INTERFACE_TYPE_DEFINITION - ) { - const existing = typeNode.interfaces?.find( - i => i.name.value === change.meta.addedInterfaceName, - ); - if (existing) { - handleError( - change, - new InterfaceAlreadyExistsOnTypeError(change.meta.addedInterfaceName), - config, - ); - } else { - (typeNode.interfaces as NamedTypeNode[] | undefined) = [ - ...(typeNode.interfaces ?? []), - namedTypeNode(change.meta.addedInterfaceName), - ]; - } - } else { - handleError( - change, - new KindMismatchError(Kind.OBJECT_TYPE_DEFINITION, typeNode.kind), - config, - ); - } - } else { - handleError(change, new CoordinateNotFoundError(), config); - } -} diff --git a/packages/web/app/src/components/proposal/patch/patches/unions.ts b/packages/web/app/src/components/proposal/patch/patches/unions.ts deleted file mode 100644 index 82d656c4b2..0000000000 --- a/packages/web/app/src/components/proposal/patch/patches/unions.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { ASTNode, NamedTypeNode } from 'graphql'; -import { Change, ChangeType } from '@graphql-inspector/core'; -import { CoordinateNotFoundError, handleError, UnionMemberAlreadyExistsError } from '../errors'; -import { namedTypeNode } from '../node-templates'; -import { PatchConfig } from '../types'; -import { parentPath } from '../utils'; - -export function unionMemberAdded( - change: Change, - nodeByPath: Map, - config: PatchConfig, -) { - const changedPath = change.path!; - const union = nodeByPath.get(parentPath(changedPath)) as - | (ASTNode & { types?: NamedTypeNode[] }) - | undefined; - if (union) { - if (union.types?.some(n => n.name.value === change.meta.addedUnionMemberTypeName)) { - handleError( - change, - new UnionMemberAlreadyExistsError( - change.meta.unionName, - change.meta.addedUnionMemberTypeName, - ), - config, - ); - } else { - union.types = [...(union.types ?? []), namedTypeNode(change.meta.addedUnionMemberTypeName)]; - } - } else { - handleError(change, new CoordinateNotFoundError(), config); - } -} diff --git a/packages/web/app/src/components/proposal/patch/print.ts b/packages/web/app/src/components/proposal/patch/print.ts deleted file mode 100644 index f1bde6904c..0000000000 --- a/packages/web/app/src/components/proposal/patch/print.ts +++ /dev/null @@ -1,12 +0,0 @@ -// export function printDiff(before: DocumentNode, changes: SerializableChange[]): string { -// // WHAT IF -// /** -// * modify ast to include a flag for added, removed, or updated/moved?... -// * add everything to the AST and print that as a schema.. (but it wont print duplicate field names etc right) -// * ... So write a custom printer that solves^ -// * -// * HOW do keep the removed node around and not mess up the AST?... -// */ - -// return ''; -// } diff --git a/packages/web/app/src/components/proposal/patch/types.ts b/packages/web/app/src/components/proposal/patch/types.ts deleted file mode 100644 index 29bec94391..0000000000 --- a/packages/web/app/src/components/proposal/patch/types.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { SchemaDefinitionNode, SchemaExtensionNode } from 'graphql'; -import type { Change, ChangeType } from '@graphql-inspector/core'; - -// @todo remove? -export type AdditionChangeType = - | ChangeType.DirectiveAdded - | ChangeType.DirectiveArgumentAdded - | ChangeType.DirectiveLocationAdded - | ChangeType.EnumValueAdded - | ChangeType.EnumValueDeprecationReasonAdded - | ChangeType.FieldAdded - | ChangeType.FieldArgumentAdded - | ChangeType.FieldDeprecationAdded - | ChangeType.FieldDeprecationReasonAdded - | ChangeType.FieldDescriptionAdded - | ChangeType.InputFieldAdded - | ChangeType.InputFieldDescriptionAdded - | ChangeType.ObjectTypeInterfaceAdded - | ChangeType.TypeDescriptionAdded - | ChangeType.TypeAdded - | ChangeType.UnionMemberAdded; - -export type SchemaNode = SchemaDefinitionNode | SchemaExtensionNode; - -export type TypeOfChangeType = (typeof ChangeType)[keyof typeof ChangeType]; - -export type ChangesByType = { [key in TypeOfChangeType]?: Array> }; - -export type PatchConfig = { - exitOnError?: boolean; - debug?: boolean; -}; diff --git a/packages/web/app/src/components/proposal/patch/utils.ts b/packages/web/app/src/components/proposal/patch/utils.ts deleted file mode 100644 index 3327cf5be8..0000000000 --- a/packages/web/app/src/components/proposal/patch/utils.ts +++ /dev/null @@ -1,213 +0,0 @@ -import { - ArgumentNode, - ASTNode, - ConstDirectiveNode, - ConstValueNode, - DirectiveNode, - GraphQLDeprecatedDirective, - InputValueDefinitionNode, - Kind, - NameNode, - StringValueNode, - TypeNode, - ValueNode, -} from 'graphql'; -import { Maybe } from 'graphql/jsutils/Maybe'; -import { Change, ChangeType } from '@graphql-inspector/core'; -import { nameNode } from './node-templates'; -import { AdditionChangeType } from './types'; - -export function getDeprecatedDirectiveNode( - definitionNode: Maybe<{ readonly directives?: ReadonlyArray }>, -): Maybe { - return definitionNode?.directives?.find( - node => node.name.value === GraphQLDeprecatedDirective.name, - ); -} - -export function addInputValueDefinitionArgument( - node: Maybe<{ - arguments?: InputValueDefinitionNode[] | readonly InputValueDefinitionNode[] | undefined; - }>, - argumentName: string, - type: TypeNode, - defaultValue: ConstValueNode | undefined, - description: StringValueNode | undefined, - directives: ConstDirectiveNode[] | undefined, -): void { - if (node) { - let found = false; - for (const arg of node.arguments ?? []) { - if (arg.name.value === argumentName) { - found = true; - break; - } - } - if (found) { - console.error('Cannot patch definition that does not exist.'); - return; - } - - node.arguments = [ - ...(node.arguments ?? []), - { - kind: Kind.INPUT_VALUE_DEFINITION, - name: nameNode(argumentName), - defaultValue, - type, - description, - directives, - }, - ]; - } -} - -export function removeInputValueDefinitionArgument( - node: Maybe<{ - arguments?: InputValueDefinitionNode[] | readonly InputValueDefinitionNode[] | undefined; - }>, - argumentName: string, -): void { - if (node?.arguments) { - node.arguments = node.arguments.filter(({ name }) => name.value !== argumentName); - } else { - // @todo throw and standardize error messages - console.warn('Cannot apply input value argument removal.'); - } -} - -export function setInputValueDefinitionArgument( - node: Maybe<{ - arguments?: InputValueDefinitionNode[] | readonly InputValueDefinitionNode[] | undefined; - }>, - argumentName: string, - values: { - type?: TypeNode; - defaultValue?: ConstValueNode | undefined; - description?: StringValueNode | undefined; - directives?: ConstDirectiveNode[] | undefined; - }, -): void { - if (node) { - let found = false; - for (const arg of node.arguments ?? []) { - if (arg.name.value === argumentName) { - if (Object.hasOwn(values, 'type') && values.type !== undefined) { - (arg.type as TypeNode) = values.type; - } - if (Object.hasOwn(values, 'defaultValue')) { - (arg.defaultValue as ConstValueNode | undefined) = values.defaultValue; - } - if (Object.hasOwn(values, 'description')) { - (arg.description as StringValueNode | undefined) = values.description; - } - if (Object.hasOwn(values, 'directives')) { - (arg.directives as ConstDirectiveNode[] | undefined) = values.directives; - } - found = true; - break; - } - } - if (!found) { - console.error('Cannot patch definition that does not exist.'); - // @todo throw error? - } - } -} - -export function upsertArgument( - node: { arguments?: ArgumentNode[] | readonly ArgumentNode[] }, - argumentName: string, - value: ValueNode, -): ArgumentNode { - for (const arg of node.arguments ?? []) { - if (arg.name.value === argumentName) { - (arg.value as ValueNode) = value; - return arg; - } - } - const arg: ArgumentNode = { - kind: Kind.ARGUMENT, - name: nameNode(argumentName), - value, - }; - node.arguments = [...(node.arguments ?? []), arg]; - return arg; -} - -export function findNamedNode( - nodes: Maybe>, - name: string, -): T | undefined { - return nodes?.find(value => value.name.value === name); -} - -/** - * @returns the removed node or undefined if no node matches the name. - */ -export function removeNamedNode( - nodes: Maybe>, - name: string, -): T | undefined { - if (nodes) { - const index = nodes?.findIndex(node => node.name.value === name); - if (index !== -1) { - const [deleted] = nodes.splice(index, 1); - return deleted; - } - } -} - -export function removeArgument( - node: Maybe<{ arguments?: ArgumentNode[] | readonly ArgumentNode[] | undefined }>, - argumentName: string, -): void { - if (node?.arguments) { - node.arguments = node.arguments.filter(arg => arg.name.value !== argumentName); - } -} - -export function parentPath(path: string) { - const lastDividerIndex = path.lastIndexOf('.'); - return lastDividerIndex === -1 ? path : path.substring(0, lastDividerIndex); -} - -const isAdditionChange = (change: Change): change is Change => { - switch (change.type) { - case ChangeType.DirectiveAdded: - case ChangeType.DirectiveArgumentAdded: - case ChangeType.DirectiveLocationAdded: - case ChangeType.EnumValueAdded: - case ChangeType.EnumValueDeprecationReasonAdded: - case ChangeType.FieldAdded: - case ChangeType.FieldArgumentAdded: - case ChangeType.FieldDeprecationAdded: - case ChangeType.FieldDeprecationReasonAdded: - case ChangeType.FieldDescriptionAdded: - case ChangeType.InputFieldAdded: - case ChangeType.InputFieldDescriptionAdded: - case ChangeType.ObjectTypeInterfaceAdded: - case ChangeType.TypeDescriptionAdded: - case ChangeType.TypeAdded: - case ChangeType.UnionMemberAdded: - return true; - default: - return false; - } -}; - -export function debugPrintChange(change: Change, nodeByPath: Map) { - if (isAdditionChange(change)) { - console.log(`"${change.path}" is being added to the schema.`); - } else { - const changedNode = (change.path && nodeByPath.get(change.path)) || false; - - if (changedNode) { - console.log(`"${change.path}" has a change: [${change.type}] "${change.message}"`); - } else { - console.log( - `The change to "${change.path}" cannot be applied. That coordinate does not exist in the schema.`, - ); - } - } -} From 373d88218aa43c1ead82cca41b9370bb9ddfc356 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Mon, 11 Aug 2025 19:39:54 -0700 Subject: [PATCH 18/54] wip: Begin sdl jsx rendering --- .../app/src/components/proposal/change.tsx | 35 -- .../proposal/print-diff/compareLists.ts | 121 ++++++ .../proposal/print-diff/components.tsx | 335 ++++++++++++++++ .../proposal/print-diff/printDiff.tsx | 374 ++++++++++++++++++ .../src/components/proposal/proposal-sdl.tsx | 271 +++++++------ packages/web/app/src/index.css | 7 + pnpm-lock.yaml | 20 +- 7 files changed, 1001 insertions(+), 162 deletions(-) delete mode 100644 packages/web/app/src/components/proposal/change.tsx create mode 100644 packages/web/app/src/components/proposal/print-diff/compareLists.ts create mode 100644 packages/web/app/src/components/proposal/print-diff/components.tsx create mode 100644 packages/web/app/src/components/proposal/print-diff/printDiff.tsx diff --git a/packages/web/app/src/components/proposal/change.tsx b/packages/web/app/src/components/proposal/change.tsx deleted file mode 100644 index 0794428c67..0000000000 --- a/packages/web/app/src/components/proposal/change.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { ReactNode } from 'react'; -import { cn } from '@/lib/utils'; - -export function ChangeDocument(props: { children: ReactNode; className?: string }) { - return ( - - {props.children} -
- ); -} - -export function ChangeRow(props: { - children: ReactNode; - - /** The line number for the current schema version */ - lineNumber: number; - - /** The line number associated for the proposed schema */ - diffLineNumber?: number; - - className?: string; -}) { - return ( - - {props.lineNumber} - - {props.lineNumber !== props.diffLineNumber ? props.diffLineNumber : null} - - {props.children} - - ); -} diff --git a/packages/web/app/src/components/proposal/print-diff/compareLists.ts b/packages/web/app/src/components/proposal/print-diff/compareLists.ts new file mode 100644 index 0000000000..2efc893246 --- /dev/null +++ b/packages/web/app/src/components/proposal/print-diff/compareLists.ts @@ -0,0 +1,121 @@ +import type { NameNode } from 'graphql'; + +export function keyMap(list: readonly T[], keyFn: (item: T) => string): Record { + return list.reduce((map, item) => { + map[keyFn(item)] = item; + return map; + }, Object.create(null)); +} + +export function isEqual(a: T, b: T): boolean { + if (Array.isArray(a) && Array.isArray(b)) { + if (a.length !== b.length) return false; + + for (let index = 0; index < a.length; index++) { + if (!isEqual(a[index], b[index])) { + return false; + } + } + + return true; + } + + if (a && b && typeof a === 'object' && typeof b === 'object') { + const aRecord = a as Record; + const bRecord = b as Record; + + const aKeys: string[] = Object.keys(aRecord); + const bKeys: string[] = Object.keys(bRecord); + + if (aKeys.length !== bKeys.length) return false; + + for (const key of aKeys) { + if (!isEqual(aRecord[key], bRecord[key])) { + return false; + } + } + + return true; + } + + return a === b || (!a && !b); +} + +export function isNotEqual(a: T, b: T): boolean { + return !isEqual(a, b); +} + +export function isVoid(a: T): boolean { + return typeof a === 'undefined' || a === null; +} + +export function diffArrays(a: T[] | readonly T[], b: T[] | readonly T[]): T[] { + return a.filter(c => !b.some(d => isEqual(d, c))); +} + +function extractName(name: string | NameNode): string { + if (typeof name === 'string') { + return name; + } + + return name.value; +} + +export function compareLists( + oldList: readonly T[], + newList: readonly T[], + callbacks?: { + onAdded?(t: T): void; + onRemoved?(t: T): void; + onMutual?(t: { newVersion: T; oldVersion: T }): void; + }, +) { + const oldMap = keyMap(oldList, ({ name }) => extractName(name)); + const newMap = keyMap(newList, ({ name }) => extractName(name)); + + const added: T[] = []; + const removed: T[] = []; + const mutual: Array<{ newVersion: T; oldVersion: T }> = []; + + for (const oldItem of oldList) { + const newItem = newMap[extractName(oldItem.name)]; + if (newItem === undefined) { + removed.push(oldItem); + } else { + mutual.push({ + newVersion: newItem, + oldVersion: oldItem, + }); + } + } + + for (const newItem of newList) { + if (oldMap[extractName(newItem.name)] === undefined) { + added.push(newItem); + } + } + + if (callbacks) { + if (callbacks.onAdded) { + for (const item of added) { + callbacks.onAdded(item); + } + } + if (callbacks.onRemoved) { + for (const item of removed) { + callbacks.onRemoved(item); + } + } + if (callbacks.onMutual) { + for (const item of mutual) { + callbacks.onMutual(item); + } + } + } + + return { + added, + removed, + mutual, + }; +} diff --git a/packages/web/app/src/components/proposal/print-diff/components.tsx b/packages/web/app/src/components/proposal/print-diff/components.tsx new file mode 100644 index 0000000000..a9bc5a3124 --- /dev/null +++ b/packages/web/app/src/components/proposal/print-diff/components.tsx @@ -0,0 +1,335 @@ +import { cn } from '@/lib/utils' +import { GraphQLArgument, GraphQLEnumType, GraphQLEnumValue, GraphQLField, GraphQLInterfaceType, GraphQLNamedType, GraphQLObjectType, GraphQLOutputType, GraphQLScalarType, GraphQLSchema, isEnumType, isInputObjectType, isInterfaceType, isObjectType, isScalarType, isUnionType, OperationTypeNode, print } from "graphql" +import { compareLists } from './compareLists' +import { ReactNode } from 'react'; + +type RootFieldsType = { + query: GraphQLField; + mutation: GraphQLField; + subscription: GraphQLField; +} + +const TAB = <>   ; + +export function ChangeDocument(props: { children: ReactNode; className?: string }) { + return ( + + {props.children} +
+ ); +} + +export function ChangeRow(props: { + children: ReactNode; + + /** The line number for the current schema version */ + lineNumber?: number; // @todo make this required and implement line numbers... + + /** The line number associated for the proposed schema */ + diffLineNumber?: number; + + className?: string; + /** Default is mutual */ + type?: 'removal' | 'addition' | 'mutual' +}) { + const incrementCounter = props.type === 'mutual' || props.type === undefined ? 'olddoc newdoc' : (props.type === 'removal' ? 'olddoc' : 'newdoc') + return ( + + + + {props.children} + + ); +} + +export function Keyword(props: { term: string }) { + return ( + {props.term} + ) +} + +export function Removal(props: { children: React.ReactElement, className?: string }): JSX.Element { + return ( + {props.children} + ) +} + +export function Addition(props: { children: React.ReactElement, className?: string }): JSX.Element { + return ( + {props.children} + ) +} + +export function Description(props: { children: React.ReactNode }): JSX.Element { + return
{props.children}
+} + +export function FieldName(props: { name: string }): JSX.Element { + return {props.name} +} + +export function FieldReturnType(props: { returnType: string }): JSX.Element { + return {props.returnType} +} + +export function FieldDiff({ oldField, newField }: { oldField: GraphQLField | null, newField: GraphQLField | null }) { + const oldReturnType = oldField?.type.toString(); + const newReturnType = newField?.type.toString(); + if (newField && oldReturnType === newReturnType) { + return ( + <> + {TAB} + + ); + } + return ( + <> + {TAB}:  + {oldReturnType && } + {newReturnType && } + + ); +} + +export function DirectiveName(props: { name: string }) { + return ( + @{props.name} + ); +} + +export function DirectiveDiff(props: { name: string, oldArguments: GraphQLArgument[], newArguments: GraphQLArgument[], newLine?: boolean }) { + const { + added, + mutual, + removed, + } = compareLists(props.oldArguments, props.newArguments) + return ( + + + {removed.map(a => ( + + ))} + {added.map(a => ( + + ))} + {/* @todo This should do a diff on the nested fields... */} + {mutual.map(a => ( + + ))} + + ); +} + +export function DirectiveArgument(props: { arg: GraphQLArgument }) { + return ( + // @todo + + {props.arg.name}: {props.arg.type.toString()}{props.arg.defaultValue === undefined ? '' : ` ${JSON.stringify(props.arg.defaultValue)}`} + + ); +} + +export function SchemaDefinitionDiff({ oldSchema, newSchema }: { oldSchema: GraphQLSchema, newSchema: GraphQLSchema }) { + const defaultNames = { + query: 'Query', + mutation: 'Mutation', + subscription: 'Subscription', + }; + const oldRoot: RootFieldsType = { + query: { + args: [], + name: 'query', + type: oldSchema.getQueryType() ?? { name: defaultNames.query, toString: () => defaultNames.query } as GraphQLOutputType, + astNode: null, + deprecationReason: null, + description: null, + extensions: {}, + }, + mutation:{ + args: [], + name: 'mutation', + type: oldSchema.getMutationType() ?? { name: defaultNames.mutation, toString: () => defaultNames.mutation } as GraphQLOutputType, + astNode: null, + deprecationReason: null, + description: null, + extensions: {}, + }, + subscription:{ + args: [], + name: 'subscription', + type: oldSchema.getSubscriptionType() ?? { name: defaultNames.subscription, toString: () => defaultNames.subscription } as GraphQLOutputType, + astNode: null, + deprecationReason: null, + description: null, + extensions: {}, + } + }; + const newRoot: RootFieldsType = { + query: { + args: [], + name: 'query', + type: newSchema.getQueryType() ?? { name: defaultNames.query, toString: () => defaultNames.query } as GraphQLOutputType, + astNode: null, + deprecationReason: null, + description: null, + extensions: {}, + }, + mutation:{ + args: [], + name: 'mutation', + type: newSchema.getMutationType() ?? { name: defaultNames.mutation, toString: () => defaultNames.mutation } as GraphQLOutputType, + astNode: null, + deprecationReason: null, + description: null, + extensions: {}, + }, + subscription:{ + args: [], + name: 'subscription', + type: newSchema.getSubscriptionType() ?? { name: defaultNames.subscription, toString: () => defaultNames.subscription } as GraphQLOutputType, + astNode: null, + deprecationReason: null, + description: null, + extensions: {}, + } + }; + + return ( + <> + + {' {'} + + + + + + + + + + + {'}'} + + ) +} + +/** For any named type */ +export function TypeDiff({ oldType, newType }: { oldType: GraphQLNamedType | null, newType: GraphQLNamedType | null}) { + if (isEnumType(oldType) && isEnumType(newType)) { + return + } + if (isUnionType(oldType) && isUnionType(newType)) { + // changesInUnion(oldType, newType, addChange); + } + if (isInputObjectType(oldType) && isInputObjectType(newType)) { + // changesInInputObject(oldType, newType, addChange); + } + if (isObjectType(oldType) && isObjectType(newType)) { + return + } + if (isInterfaceType(oldType) && isInterfaceType(newType)) { + return + } + if (isScalarType(oldType) && isScalarType(newType)) { + return + } + + { + // addChange(typeKindChanged(oldType, newType)); + } +}; + +export function TypeName({ name }: { name: string }) { + return ( + {name} + ); +} + +export function DiffObject({ oldObject, newObject }: { oldObject: GraphQLObjectType | GraphQLInterfaceType, newObject: GraphQLObjectType | GraphQLInterfaceType }) { + const { + added, + mutual, + removed, + } = compareLists(Object.values(oldObject.getFields()), Object.values(newObject.getFields())); + return ( + <> + +  {' {'} + + {removed.map(a => ( + + + + ))} + {added.map(a => ( + + + + ))} + {/* @todo This should do a diff on the nested fields... */} + {mutual.map(a => ( + + + + ))} + {'}'} + + ); +} + +export function DiffEnum({ oldEnum, newEnum }: { oldEnum: GraphQLEnumType | null, newEnum: GraphQLEnumType | null }) { + const { + added, + mutual, + removed, + } = compareLists(oldEnum?.getValues() ?? [], newEnum?.getValues() ?? []); + return ( + <> + +  {' {'} + + {removed.map(a => ( + + ))} + {added.map(a => ( + + ))} + {/* @todo This should do a diff on the nested fields... */} + {mutual.map(a => ( + + ))} + {'}'} + + ); +} + +export function DiffScalar({ oldScalar, newScalar }: { oldScalar: GraphQLScalarType | null; newScalar: GraphQLScalarType | null }) { + if (oldScalar?.name === newScalar?.name) { + return ( + +   + + {/* { @todo diff directives} */} + + ); + } + return ( + +   + {oldScalar && } + {newScalar && } + {/* { @todo diff directives} */} + + ); +} + +export function EnumValue(props: { value: GraphQLEnumValue }) { + return {TAB} +} \ No newline at end of file diff --git a/packages/web/app/src/components/proposal/print-diff/printDiff.tsx b/packages/web/app/src/components/proposal/print-diff/printDiff.tsx new file mode 100644 index 0000000000..aac674d244 --- /dev/null +++ b/packages/web/app/src/components/proposal/print-diff/printDiff.tsx @@ -0,0 +1,374 @@ +/* eslint-disable tailwindcss/no-custom-classname */ +/** Adapted from graphqljs printSchema */ +import type { Maybe } from 'graphql/jsutils/Maybe'; +import { isPrintableAsBlockString } from 'graphql/language/blockString'; +import type { + GraphQLScalarType, + GraphQLSchema, +} from 'graphql'; +import { + Kind, + print, + isIntrospectionType, + DEFAULT_DEPRECATION_REASON, +} from 'graphql'; + +import { compareLists } from './compareLists'; +import { ChangeDocument, ChangeRow, Description, SchemaDefinitionDiff, TypeDiff } from './components'; +import { isPrimitive } from '@graphql-inspector/core/utils/graphql'; + +type SchemaRootFields = { + query: string; + mutation: string; + subscription: string; +} + +export function printSchemaDiff( + oldSchema: GraphQLSchema, + newSchema: GraphQLSchema, +): JSX.Element { + + const { + added: addedTypes, + mutual: mutualTypes, + removed: removedTypes, + } = compareLists( + Object.values(oldSchema.getTypeMap()).filter(t => !isPrimitive(t) && !isIntrospectionType(t)), + Object.values(newSchema.getTypeMap()).filter(t => !isPrimitive(t) && !isIntrospectionType(t)), + ); + + return ( + + + {addedTypes.map(a => { + return ( + + ) + })} + {removedTypes.map(a => { + return ( + + ) + })} + {mutualTypes.map(a => { + return ( + + ) + })} + + ); + + // compareLists(oldSchema.getDirectives(), newSchema.getDirectives(), { + // onAdded(directive) { + // addChange(directiveAdded(directive)); + // }, + // onRemoved(directive) { + // addChange(directiveRemoved(directive)); + // }, + // onMutual(directive) { + // changesInDirective(directive.oldVersion, directive.newVersion, addChange); + // }, + // }); + + // compareLists(oldSchema.astNode?.directives || [], newSchema.astNode?.directives || [], { + // onAdded(directive) { + // addChange(directiveUsageAdded(Kind.SCHEMA_DEFINITION, directive, newSchema)); + // }, + // onRemoved(directive) { + // addChange(directiveUsageRemoved(Kind.SCHEMA_DEFINITION, directive, oldSchema)); + // }, + // }); + + // return changes; +} + +// export function printSchemaDiff(beforeSchema: GraphQLSchema, afterSchema: GraphQLSchema, printFn: (before: ASTNode | undefined, after: ASTNode | undefined) => T): T { +// return printFilteredSchema( +// beforeSchema, +// (n) => !isSpecifiedDirective(n), +// isDefinedType, +// ); +// } + +// // export function printIntrospectionSchema(schema: GraphQLSchema): string { +// // return printFilteredSchema(schema, isSpecifiedDirective, isIntrospectionType); +// } + +// function isDefinedType(type: GraphQLNamedType): boolean { +// return !isSpecifiedScalarType(type) && !isIntrospectionType(type); +// } + +// function printFilteredSchema( +// schema: GraphQLSchema, +// directiveFilter: (type: GraphQLDirective) => boolean, +// typeFilter: (type: GraphQLNamedType) => boolean, +// ): string { +// const directives = schema.getDirectives().filter(directiveFilter); +// const types = Object.values(schema.getTypeMap()).filter(typeFilter); + +// return [ +// printSchemaDefinition(schema), +// ...directives.map((directive) => printDirective(directive)), +// ...types.map((type) => printType(type)), +// ] +// .filter(Boolean) +// .join('\n\n'); +// } + +// function printSchemaDefinition(schema: GraphQLSchema): Maybe { +// if (schema.description == null && isSchemaOfCommonNames(schema)) { +// return; +// } + +// const operationTypes = []; + +// const queryType = schema.getQueryType(); +// if (queryType) { +// operationTypes.push(` query: ${queryType.name}`); +// } + +// const mutationType = schema.getMutationType(); +// if (mutationType) { +// operationTypes.push(` mutation: ${mutationType.name}`); +// } + +// const subscriptionType = schema.getSubscriptionType(); +// if (subscriptionType) { +// operationTypes.push(` subscription: ${subscriptionType.name}`); +// } + +// return printDescription(schema) + `schema {\n${operationTypes.join('\n')}\n}`; +// } + +// /** +// * GraphQL schema define root types for each type of operation. These types are +// * the same as any other type and can be named in any manner, however there is +// * a common naming convention: +// * +// * ```graphql +// * schema { +// * query: Query +// * mutation: Mutation +// * subscription: Subscription +// * } +// * ``` +// * +// * When using this naming convention, the schema description can be omitted. +// */ +// function isSchemaOfCommonNames(schema: GraphQLSchema): boolean { +// const queryType = schema.getQueryType(); +// if (queryType && queryType.name !== 'Query') { +// return false; +// } + +// const mutationType = schema.getMutationType(); +// if (mutationType && mutationType.name !== 'Mutation') { +// return false; +// } + +// const subscriptionType = schema.getSubscriptionType(); +// if (subscriptionType && subscriptionType.name !== 'Subscription') { +// return false; +// } + +// return true; +// } + +// export function printTypeName(type: GraphQLNamedType): string { +// if (isScalarType(type)) { +// return printScalar(type); +// } +// if (isObjectType(type)) { +// return printObject(type); +// } +// if (isInterfaceType(type)) { +// return printInterface(type); +// } +// if (isUnionType(type)) { +// return printUnion(type); +// } +// if (isEnumType(type)) { +// return printEnum(type); +// } +// if (isInputObjectType(type)) { +// return printInputObject(type); +// } +// /* c8 ignore next 3 */ +// // Not reachable, all possible types have been considered. +// invariant(false, 'Unexpected type: ' + inspect(type)); +// } + +// function printScalar(type: GraphQLScalarType): string { +// return ( +// printDescription(type) + `scalar ${type.name}` + printSpecifiedByURL(type) +// ); +// } + +// function printImplementedInterfaces( +// type: GraphQLObjectType | GraphQLInterfaceType, +// ): string { +// const interfaces = type.getInterfaces(); +// return interfaces.length +// ? ' implements ' + interfaces.map((i) => i.name).join(' & ') +// : ''; +// } + +// function printObject(type: GraphQLObjectType): string { +// return ( +// printDescription(type) + +// `type ${type.name}` + +// printImplementedInterfaces(type) + +// printFields(type) +// ); +// } + +// function printInterface(type: GraphQLInterfaceType): string { +// return ( +// printDescription(type) + +// `interface ${type.name}` + +// printImplementedInterfaces(type) + +// printFields(type) +// ); +// } + +// function printUnion(type: GraphQLUnionType): string { +// const types = type.getTypes(); +// const possibleTypes = types.length ? ' = ' + types.join(' | ') : ''; +// return printDescription(type) + 'union ' + type.name + possibleTypes; +// } + +// function printEnum(type: GraphQLEnumType): string { +// const values = type +// .getValues() +// .map( +// (value, i) => +// printDescription(value, ' ', !i) + +// ' ' + +// value.name + +// printDeprecated(value.deprecationReason), +// ); + +// return printDescription(type) + `enum ${type.name}` + printBlock(values); +// } + +// function printInputObject(type: GraphQLInputObjectType): string { +// const fields = Object.values(type.getFields()).map( +// (f, i) => printDescription(f, ' ', !i) + ' ' + printInputValue(f), +// ); +// return ( +// printDescription(type) + +// `input ${type.name}` + +// (type.isOneOf ? ' @oneOf' : '') + +// printBlock(fields) +// ); +// } + +// function printFields(type: GraphQLObjectType | GraphQLInterfaceType): string { +// const fields = Object.values(type.getFields()).map( +// (f, i) => +// printDescription(f, ' ', !i) + +// ' ' + +// f.name + +// printArgs(f.args, ' ') + +// ': ' + +// String(f.type) + +// printDeprecated(f.deprecationReason), +// ); +// return printBlock(fields); +// } + +// function printBlock(items: ReadonlyArray): string { +// return items.length !== 0 ? ' {\n' + items.join('\n') + '\n}' : ''; +// } + +// function printArgs( +// args: ReadonlyArray, +// indentation: string = '', +// ): string { +// if (args.length === 0) { +// return ''; +// } + +// // If every arg does not have a description, print them on one line. +// if (args.every((arg) => !arg.description)) { +// return '(' + args.map(printInputValue).join(', ') + ')'; +// } + +// return ( +// '(\n' + +// args +// .map( +// (arg, i) => +// printDescription(arg, ' ' + indentation, !i) + +// ' ' + +// indentation + +// printInputValue(arg), +// ) +// .join('\n') + +// '\n' + +// indentation + +// ')' +// ); +// } + +// function printInputValue(arg: GraphQLInputField): string { +// const defaultAST = astFromValue(arg.defaultValue, arg.type); +// let argDecl = arg.name + ': ' + String(arg.type); +// if (defaultAST) { +// argDecl += ` = ${print(defaultAST)}`; +// } +// return argDecl + printDeprecated(arg.deprecationReason); +// } + +// function printDirective(directive: GraphQLDirective): string { +// return ( +// printDescription(directive) + +// 'directive @' + +// directive.name + +// printArgs(directive.args) + +// (directive.isRepeatable ? ' repeatable' : '') + +// ' on ' + +// directive.locations.join(' | ') +// ); +// } + +// function printDeprecated(reason: Maybe): string { +// if (reason == null) { +// return ''; +// } +// if (reason !== DEFAULT_DEPRECATION_REASON) { +// const astValue = print({ kind: Kind.STRING, value: reason }); +// return ` @deprecated(reason: ${astValue})`; +// } +// return
@deprecated
; +// } + +// function printSpecifiedByURL(scalar: GraphQLScalarType): JSX.Element | null { +// if (scalar.specifiedByURL == null) { +// return null; +// } +// const astValue = print({ +// kind: Kind.STRING, +// value: scalar.specifiedByURL, +// }); +// return
{`@specifiedBy(url: ${astValue})`}
; +// } + +// function printDescription( +// def: { readonly description: Maybe }, +// indentation: string = '', +// // firstInBlock: boolean = true, +// ): JSX.Element | null { +// const { description } = def; +// if (description == null) { +// return null; +// } + +// const blockString = print({ +// kind: Kind.STRING, +// value: description, +// block: isPrintableAsBlockString(description), +// }); + +// return {blockString.replace(/\n/g, '\n' + indentation) + '\n'}; +// } diff --git a/packages/web/app/src/components/proposal/proposal-sdl.tsx b/packages/web/app/src/components/proposal/proposal-sdl.tsx index b3e165f92b..aec5fb54e0 100644 --- a/packages/web/app/src/components/proposal/proposal-sdl.tsx +++ b/packages/web/app/src/components/proposal/proposal-sdl.tsx @@ -1,9 +1,9 @@ -import { buildSchema, GraphQLSchema, Source } from 'graphql'; import { FragmentType, graphql, useFragment } from '@/gql'; -import { ProposalOverview_ReviewsFragmentFragment } from '@/gql/graphql'; -import { ChangeDocument, ChangeRow } from './change'; -import { collectCoordinateLocations } from './collect-coordinate-locations'; -import { ReviewComments } from './Review'; +import type { Change } from '@graphql-inspector/core' +import type { MonacoDiffEditor as OriginalMonacoDiffEditor } from '@monaco-editor/react'; +import { useRef } from 'react'; +import { printSchemaDiff } from './print-diff/printDiff'; +import { buildSchema } from 'graphql'; const ProposalOverview_ReviewsFragment = graphql(/** GraphQL */ ` fragment ProposalOverview_ReviewsFragment on SchemaProposalReviewConnection { @@ -17,7 +17,7 @@ const ProposalOverview_ReviewsFragment = graphql(/** GraphQL */ ` schemaProposalVersion { id serviceName - schemaSDL + # schemaSDL } stageTransition lineNumber @@ -56,26 +56,15 @@ const ProposalOverview_ReviewsFragment = graphql(/** GraphQL */ ` // } // `); -type ReviewNode = NonNullable[number]['node']; +// type ReviewNode = NonNullable[number]['node']; export function ProposalSDL(props: { - diffSdl: string; - sdl: string; + baseSchemaSDL: string; + changes: Change[]; serviceName?: string; latestProposalVersionId: string; reviews: FragmentType | null; }) { - try { - void diff( - buildSchema(props.diffSdl, { assumeValid: true, assumeValidSDL: true }), - buildSchema(props.sdl, { assumeValid: true, assumeValidSDL: true }), - ).then(changes => { - console.log('DIFF WORKED', changes); - }); - } catch (e) { - console.error(`Handled error ${e}`); - } - /** * Reviews can change position because the coordinate changes... placing them out of order from their original line numbers. * Because of this, we have to fetch every single page of comments... @@ -83,109 +72,157 @@ export function ProposalSDL(props: { * * Odds are there will never be so many reviews/comments that this is even a problem. */ - const reviewsConnection = useFragment(ProposalOverview_ReviewsFragment, props.reviews); + const _reviewsConnection = useFragment(ProposalOverview_ReviewsFragment, props.reviews); try { - let diffSchema: GraphQLSchema | undefined; - try { - diffSchema = buildSchema(props.diffSdl, { assumeValid: true, assumeValidSDL: true }); - } catch (e) { - console.error('Diff schema is invalid.'); - } - - const schema = buildSchema(props.sdl, { assumeValid: true, assumeValidSDL: true }); - const coordinateToLineMap = collectCoordinateLocations(schema, new Source(props.sdl)); - - if (diffSchema) { - // @todo run schema check and get diff from that API.... That way usage can be checked easily. - void diff(diffSchema, schema, undefined); - } + // @todo props.baseSchemaSDL + const baseSchemaSDL = /* GraphQL */` + type Query { + okay: Boolean + dokay: Boolean + } + `; + // const baseSchema = buildSchema(baseSchemaSDL, { assumeValid: true, assumeValidSDL: true }); - // @note assume reviews are specific to the current service... - const globalReviews: ReviewNode[] = []; - const reviewsByLine = new Map(); - const serviceReviews = - reviewsConnection?.edges?.filter(edge => { - const { schemaProposalVersion } = edge.node; - return schemaProposalVersion?.serviceName === props.serviceName; - }) ?? []; - - for (const edge of serviceReviews) { - const { lineNumber, schemaCoordinate, schemaProposalVersion } = edge.node; - const coordinateLine = !!schemaCoordinate && coordinateToLineMap.get(schemaCoordinate); - const isStale = - !coordinateLine && schemaProposalVersion?.id !== props.latestProposalVersionId; - const line = coordinateLine || lineNumber; - if (line) { - reviewsByLine.set(line, { ...edge.node, isStale }); - } else { - globalReviews.push(edge.node); + const patchedSchemaSDL = /* GraphQL */` + type Query { + ok: Boolean + dokay: Boolean! } - } + `;// APPLY PATCH - const diffSdlLines = props.diffSdl.split('\n'); - let diffLineNumber = 0; - return ( - <> - - {props.sdl.split('\n').flatMap((txt, index) => { - const lineNumber = index + 1; - const diffLineMatch = txt === diffSdlLines[diffLineNumber]; - const elements = [ - - {txt} - , - ]; - if (diffLineMatch) { - diffLineNumber = diffLineNumber + 1; - } - - const review = reviewsByLine.get(lineNumber); - if (review) { - if (review.isStale) { - elements.push( - - - -
- This review references an outdated version of the proposal. -
- {!!review.lineText && ( - - - {review.lineText} - - - )} - - , - ); - } - elements.push( - - - - - - , - ); - } - return elements; - })} -
- {globalReviews.map(r => { - return
{r.id}
; - })} - + return printSchemaDiff( + buildSchema(baseSchemaSDL, { assumeValid: true, assumeValidSDL: true }), + buildSchema(patchedSchemaSDL, { assumeValid: true, assumeValidSDL: true }), ); + + // const editorRef = useRef(null); + + // return ( + // } + // original={baseSchemaSDL ?? undefined} + // modified={patchedSchemaSDL ?? undefined} + // options={{ + // renderSideBySide: false, + // originalEditable: false, + // renderLineHighlightOnlyWhenFocus: true, + // readOnly: true, + // diffAlgorithm: 'advanced', + // lineNumbers: 'on', + // contextmenu: false, + // }} + // onMount={(editor, _monaco) => { + // editorRef.current = editor; + // editor.onDidUpdateDiff(() => { + // // const coordinateToLineMap = collectCoordinateLocations(baseSchema, new Source(baseSchemaSDL)); + // const originalLines = editor.getOriginalEditor().getContainerDomNode().getElementsByClassName('view-line'); + // console.log( + // 'original editor', + // Array.from(originalLines).map(e => e.textContent).join('\n'), + // ); + + // const modifiedLines = editor.getModifiedEditor().getContainerDomNode().getElementsByClassName('view-line'); + // console.log( + // 'modified', + // Array.from(modifiedLines).map(e => e.textContent).join('\n'), + // ); + // }) + // }} + // /> + // ) + + + + // // @note assume reviews are specific to the current service... + // const globalReviews: ReviewNode[] = []; + // const reviewsByLine = new Map(); + // const serviceReviews = + // reviewsConnection?.edges?.filter(edge => { + // const { schemaProposalVersion } = edge.node; + // return schemaProposalVersion?.serviceName === props.serviceName; + // }) ?? []; + + // for (const edge of serviceReviews) { + // const { lineNumber, schemaCoordinate, schemaProposalVersion } = edge.node; + // const coordinateLine = !!schemaCoordinate && coordinateToLineMap.get(schemaCoordinate); + // const isStale = + // !coordinateLine && schemaProposalVersion?.id !== props.latestProposalVersionId; + // const line = coordinateLine || lineNumber; + // if (line) { + // reviewsByLine.set(line, { ...edge.node, isStale }); + // } else { + // globalReviews.push(edge.node); + // } + // } + + // const baseSchemaSdlLines = baseSchemaSDL.split('\n'); + // let diffLineNumber = 0; + // return ( + // <> + // + // {patchedSchemaSDL.split('\n').flatMap((txt, index) => { + // const lineNumber = index + 1; + // const diffLineMatch = txt === baseSchemaSdlLines[diffLineNumber]; + // const elements = [ + // + // {txt} + // , + // ]; + // if (diffLineMatch) { + // diffLineNumber = diffLineNumber + 1; + // } + + // const review = reviewsByLine.get(lineNumber); + // if (review) { + // if (review.isStale) { + // elements.push( + // + // + // + //
+ // This review references an outdated version of the proposal. + //
+ // {!!review.lineText && ( + // + // + // {review.lineText} + // + // + // )} + // + // , + // ); + // } + // elements.push( + // + // + // + // + // + // , + // ); + // } + // return elements; + // })} + //
+ // {globalReviews.map(r => { + // return
{r.id}
; + // })} + // + // ); // console.log(printJsx(document)); } catch (e: unknown) { return ( diff --git a/packages/web/app/src/index.css b/packages/web/app/src/index.css index 832aa95c37..e381218909 100644 --- a/packages/web/app/src/index.css +++ b/packages/web/app/src/index.css @@ -198,6 +198,13 @@ input::-webkit-inner-spin-button { -webkit-appearance: none; } + + .schema-doc-row-old::before { + content: counter(olddoc) + } + .schema-doc-row-new::before { + content: counter(newdoc) + } } .hive-badge-is-changed:after { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0c152b465a..76acb6131e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16581,8 +16581,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-sts': 3.596.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/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 @@ -16689,11 +16689,11 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/client-sso-oidc@3.596.0(@aws-sdk/client-sts@3.596.0)': + '@aws-sdk/client-sso-oidc@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-sts': 3.596.0(@aws-sdk/client-sso-oidc@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 @@ -16732,7 +16732,6 @@ snapshots: '@smithy/util-utf8': 3.0.0 tslib: 2.8.1 transitivePeerDependencies: - - '@aws-sdk/client-sts' - aws-crt '@aws-sdk/client-sso-oidc@3.723.0(@aws-sdk/client-sts@3.723.0)': @@ -16866,11 +16865,11 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/client-sts@3.596.0': + '@aws-sdk/client-sts@3.596.0(@aws-sdk/client-sso-oidc@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-sts@3.596.0) + '@aws-sdk/client-sso-oidc': 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 @@ -16909,6 +16908,7 @@ snapshots: '@smithy/util-utf8': 3.0.0 tslib: 2.8.1 transitivePeerDependencies: + - '@aws-sdk/client-sso-oidc' - aws-crt '@aws-sdk/client-sts@3.723.0': @@ -17022,7 +17022,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-sts': 3.596.0(@aws-sdk/client-sso-oidc@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 @@ -17141,7 +17141,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-sts': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0) '@aws-sdk/types': 3.577.0 '@smithy/property-provider': 3.1.11 '@smithy/types': 3.7.2 @@ -17316,7 +17316,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-sts@3.596.0) + '@aws-sdk/client-sso-oidc': 3.596.0 '@aws-sdk/types': 3.577.0 '@smithy/property-provider': 3.1.11 '@smithy/shared-ini-file-loader': 3.1.12 From 280dbd82947bb3b226f27c4fbeb51b328c12c18c Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Tue, 12 Aug 2025 17:55:27 -0700 Subject: [PATCH 19/54] Support more graphql types in jsx diff printing --- .../{compareLists.ts => compare-lists.ts} | 4 + .../proposal/print-diff/components.tsx | 735 ++++++++++++++---- .../{printDiff.tsx => print-diff.tsx} | 180 +---- .../src/components/proposal/proposal-sdl.tsx | 54 +- packages/web/app/src/index.css | 4 +- 5 files changed, 671 insertions(+), 306 deletions(-) rename packages/web/app/src/components/proposal/print-diff/{compareLists.ts => compare-lists.ts} (95%) rename packages/web/app/src/components/proposal/print-diff/{printDiff.tsx => print-diff.tsx} (60%) diff --git a/packages/web/app/src/components/proposal/print-diff/compareLists.ts b/packages/web/app/src/components/proposal/print-diff/compare-lists.ts similarity index 95% rename from packages/web/app/src/components/proposal/print-diff/compareLists.ts rename to packages/web/app/src/components/proposal/print-diff/compare-lists.ts index 2efc893246..6df6613a3b 100644 --- a/packages/web/app/src/components/proposal/print-diff/compareLists.ts +++ b/packages/web/app/src/components/proposal/print-diff/compare-lists.ts @@ -53,6 +53,10 @@ export function diffArrays(a: T[] | readonly T[], b: T[] | readonly T[]): T[] return a.filter(c => !b.some(d => isEqual(d, c))); } +export function matchArrays(a: T[] | readonly T[], b: T[] | readonly T[]): T[] { + return a.filter(c => b.some(d => isEqual(d, c))); +} + function extractName(name: string | NameNode): string { if (typeof name === 'string') { return name; diff --git a/packages/web/app/src/components/proposal/print-diff/components.tsx b/packages/web/app/src/components/proposal/print-diff/components.tsx index a9bc5a3124..777c9f8bca 100644 --- a/packages/web/app/src/components/proposal/print-diff/components.tsx +++ b/packages/web/app/src/components/proposal/print-diff/components.tsx @@ -1,22 +1,44 @@ -import { cn } from '@/lib/utils' -import { GraphQLArgument, GraphQLEnumType, GraphQLEnumValue, GraphQLField, GraphQLInterfaceType, GraphQLNamedType, GraphQLObjectType, GraphQLOutputType, GraphQLScalarType, GraphQLSchema, isEnumType, isInputObjectType, isInterfaceType, isObjectType, isScalarType, isUnionType, OperationTypeNode, print } from "graphql" -import { compareLists } from './compareLists' -import { ReactNode } from 'react'; +import React, { ReactNode } from 'react'; +import { + ConstDirectiveNode, + DirectiveLocation, + GraphQLArgument, + GraphQLDirective, + GraphQLEnumType, + GraphQLField, + GraphQLInterfaceType, + GraphQLNamedType, + GraphQLObjectType, + GraphQLOutputType, + GraphQLScalarType, + GraphQLSchema, + isEnumType, + isInputObjectType, + isInterfaceType, + isObjectType, + isScalarType, + isUnionType, + Kind, + print, +} from 'graphql'; +import { isPrintableAsBlockString } from 'graphql/language/blockString'; +import { cn } from '@/lib/utils'; +import { compareLists, diffArrays, matchArrays } from './compare-lists'; type RootFieldsType = { query: GraphQLField; mutation: GraphQLField; subscription: GraphQLField; -} +}; -const TAB = <>   ; +const TAB = <>  ; export function ChangeDocument(props: { children: ReactNode; className?: string }) { return ( {props.children}
@@ -25,118 +47,374 @@ export function ChangeDocument(props: { children: ReactNode; className?: string export function ChangeRow(props: { children: ReactNode; - - /** The line number for the current schema version */ - lineNumber?: number; // @todo make this required and implement line numbers... - - /** The line number associated for the proposed schema */ - diffLineNumber?: number; - className?: string; /** Default is mutual */ - type?: 'removal' | 'addition' | 'mutual' + type?: 'removal' | 'addition' | 'mutual'; + indent?: boolean; }) { - const incrementCounter = props.type === 'mutual' || props.type === undefined ? 'olddoc newdoc' : (props.type === 'removal' ? 'olddoc' : 'newdoc') + const incrementCounter = + props.type === 'mutual' || props.type === undefined + ? 'olddoc newdoc' + : props.type === 'removal' + ? 'olddoc' + : 'newdoc'; return ( - - - {props.children} + + + + {props.indent && <>{TAB}} + {props.children} + ); } -export function Keyword(props: { term: string }) { - return ( - {props.term} - ) +function Keyword(props: { term: string }) { + return {props.term}; } -export function Removal(props: { children: React.ReactElement, className?: string }): JSX.Element { - return ( - {props.children} - ) +function Removal(props: { + children: React.ReactNode | string; + className?: string; +}): React.ReactNode { + return {props.children}; +} + +function Addition(props: { children: React.ReactNode; className?: string }): React.ReactNode { + return {props.children}; +} + +function printDescription(def: { readonly description: string | undefined | null }): string | null { + const { description } = def; + if (description == null) { + return null; + } + + const blockString = print({ + kind: Kind.STRING, + value: description, + block: isPrintableAsBlockString(description), + }); + + return blockString; } -export function Addition(props: { children: React.ReactElement, className?: string }): JSX.Element { +function Description(props: { + content: string; + type?: 'removal' | 'addition' | 'mutual'; + indent?: boolean; +}): React.ReactNode { + const lines = props.content.split('\n'); + return ( - {props.children} - ) + <> + {lines.map((line, index) => ( + + + {line} + + + ))} + + ); } -export function Description(props: { children: React.ReactNode }): JSX.Element { - return
{props.children}
+function FieldName(props: { name: string }): React.ReactNode { + return {props.name}; } -export function FieldName(props: { name: string }): JSX.Element { - return {props.name} +function FieldReturnType(props: { returnType: string }): React.ReactNode { + return {props.returnType}; } -export function FieldReturnType(props: { returnType: string }): JSX.Element { - return {props.returnType} +export function DiffDescription( + props: + | { + oldNode: { description: string | null | undefined } | null; + newNode: { description: string | null | undefined }; + indent?: boolean; + } + | { + oldNode: { description: string | null | undefined }; + newNode: { description: string | null | undefined } | null; + indent?: boolean; + }, +) { + const oldDesc = props.oldNode?.description; + const newDesc = props.newNode?.description; + if (oldDesc !== newDesc) { + return ( + <> + {/* + To improve this and how only the minimal change, + do a string diff of the description instead of this simple compare. + */} + {oldDesc && ( + + )} + {newDesc && ( + + )} + + ); + } + if (newDesc) { + return ; + } } -export function FieldDiff({ oldField, newField }: { oldField: GraphQLField | null, newField: GraphQLField | null }) { +export function DiffField({ + oldField, + newField, +}: + | { + oldField: GraphQLField | null; + newField: GraphQLField; + } + | { + oldField: GraphQLField; + newField: GraphQLField | null; + }) { const oldReturnType = oldField?.type.toString(); const newReturnType = newField?.type.toString(); - if (newField && oldReturnType === newReturnType) { + if (!!newField && oldReturnType === newReturnType) { return ( <> - {TAB} + + + {TAB} + + :  + + + ); } return ( <> - {TAB}:  - {oldReturnType && } - {newReturnType && } + + + {TAB} + + :  + {oldReturnType && ( + + + + )} + {newReturnType && ( + + + + )} + + ); } export function DirectiveName(props: { name: string }) { - return ( - @{props.name} - ); + return @{props.name}; } -export function DirectiveDiff(props: { name: string, oldArguments: GraphQLArgument[], newArguments: GraphQLArgument[], newLine?: boolean }) { - const { - added, - mutual, - removed, - } = compareLists(props.oldArguments, props.newArguments) +export function DiffArguments(props: { + oldArgs: readonly GraphQLArgument[]; + newArgs: readonly GraphQLArgument[]; +}) { + const { added, mutual, removed } = compareLists(props.oldArgs, props.newArgs); return ( - - + <> {removed.map(a => ( - + + + + + + + ))} {added.map(a => ( - + + + + + + + ))} {/* @todo This should do a diff on the nested fields... */} {mutual.map(a => ( - + + + + + + + + ))} + + ); +} + +function determineChangeType(oldType: T | null, newType: T | null) { + if (oldType && !newType) { + return 'removal' as const; + } + if (newType && !oldType) { + return 'addition' as const; + } + return 'mutual' as const; +} + +export function DiffLocations(props: { + newLocations: readonly DirectiveLocation[]; + oldLocations: readonly DirectiveLocation[]; +}) { + const locations = { + added: diffArrays(props.newLocations, props.oldLocations), + removed: diffArrays(props.oldLocations, props.newLocations), + mutual: matchArrays(props.oldLocations, props.newLocations), + }; + + const locationElements = [ + ...locations.removed.map(r => ( + + + + )), + ...locations.added.map(r => ( + + + + )), + ...locations.mutual.map(r => ), + ]; + + return ( + <> + +   + {locationElements.map((e, index) => ( + + {e} + {index !== locationElements.length - 1 && <>, } + ))} - + + ); +} + +export function DiffDirective( + props: + | { + oldDirective: GraphQLDirective | null; + newDirective: GraphQLDirective; + } + | { + oldDirective: GraphQLDirective; + newDirective: GraphQLDirective | null; + }, +) { + const changeType = determineChangeType(props.oldDirective, props.newDirective); + const hasArgs = props.oldDirective?.args.length || props.newDirective?.args.length; + return ( + <> + + + +   + + {!!hasArgs && <>(} + {!hasArgs && ( + <> +   + + + )} + + + {!!hasArgs && ( + + )  + + + )} + ); } export function DirectiveArgument(props: { arg: GraphQLArgument }) { return ( - // @todo - - {props.arg.name}: {props.arg.type.toString()}{props.arg.defaultValue === undefined ? '' : ` ${JSON.stringify(props.arg.defaultValue)}`} - + <> + :{' '} + + {props.arg.defaultValue === undefined ? '' : `= ${JSON.stringify(props.arg.defaultValue)}`} + ); } -export function SchemaDefinitionDiff({ oldSchema, newSchema }: { oldSchema: GraphQLSchema, newSchema: GraphQLSchema }) { +export function SchemaDefinitionDiff({ + oldSchema, + newSchema, +}: { + oldSchema: GraphQLSchema; + newSchema: GraphQLSchema; +}) { const defaultNames = { query: 'Query', mutation: 'Mutation', @@ -146,190 +424,349 @@ export function SchemaDefinitionDiff({ oldSchema, newSchema }: { oldSchema: Grap query: { args: [], name: 'query', - type: oldSchema.getQueryType() ?? { name: defaultNames.query, toString: () => defaultNames.query } as GraphQLOutputType, + type: + oldSchema.getQueryType() ?? + ({ name: defaultNames.query, toString: () => defaultNames.query } as GraphQLOutputType), astNode: null, deprecationReason: null, description: null, extensions: {}, }, - mutation:{ + mutation: { args: [], name: 'mutation', - type: oldSchema.getMutationType() ?? { name: defaultNames.mutation, toString: () => defaultNames.mutation } as GraphQLOutputType, + type: + oldSchema.getMutationType() ?? + ({ + name: defaultNames.mutation, + toString: () => defaultNames.mutation, + } as GraphQLOutputType), astNode: null, deprecationReason: null, description: null, extensions: {}, }, - subscription:{ + subscription: { args: [], name: 'subscription', - type: oldSchema.getSubscriptionType() ?? { name: defaultNames.subscription, toString: () => defaultNames.subscription } as GraphQLOutputType, + type: + oldSchema.getSubscriptionType() ?? + ({ + name: defaultNames.subscription, + toString: () => defaultNames.subscription, + } as GraphQLOutputType), astNode: null, deprecationReason: null, description: null, extensions: {}, - } + }, }; const newRoot: RootFieldsType = { query: { args: [], name: 'query', - type: newSchema.getQueryType() ?? { name: defaultNames.query, toString: () => defaultNames.query } as GraphQLOutputType, + type: + newSchema.getQueryType() ?? + ({ name: defaultNames.query, toString: () => defaultNames.query } as GraphQLOutputType), astNode: null, deprecationReason: null, description: null, extensions: {}, }, - mutation:{ + mutation: { args: [], name: 'mutation', - type: newSchema.getMutationType() ?? { name: defaultNames.mutation, toString: () => defaultNames.mutation } as GraphQLOutputType, + type: + newSchema.getMutationType() ?? + ({ + name: defaultNames.mutation, + toString: () => defaultNames.mutation, + } as GraphQLOutputType), astNode: null, deprecationReason: null, description: null, extensions: {}, }, - subscription:{ + subscription: { args: [], name: 'subscription', - type: newSchema.getSubscriptionType() ?? { name: defaultNames.subscription, toString: () => defaultNames.subscription } as GraphQLOutputType, + type: + newSchema.getSubscriptionType() ?? + ({ + name: defaultNames.subscription, + toString: () => defaultNames.subscription, + } as GraphQLOutputType), astNode: null, deprecationReason: null, description: null, extensions: {}, - } + }, }; return ( <> - {' {'} - - - - - - - - - + + {' {'} + + + {'}'} - ) + ); } /** For any named type */ -export function TypeDiff({ oldType, newType }: { oldType: GraphQLNamedType | null, newType: GraphQLNamedType | null}) { - if (isEnumType(oldType) && isEnumType(newType)) { - return +export function DiffType({ + oldType, + newType, +}: + | { + oldType: GraphQLNamedType; + newType: GraphQLNamedType | null; + } + | { + oldType: GraphQLNamedType | null; + newType: GraphQLNamedType; + }) { + if ((isEnumType(oldType) || oldType === null) && (isEnumType(newType) || newType === null)) { + return ; } - if (isUnionType(oldType) && isUnionType(newType)) { + if ((isUnionType(oldType) || oldType === null) && (isUnionType(newType) || newType === null)) { // changesInUnion(oldType, newType, addChange); } - if (isInputObjectType(oldType) && isInputObjectType(newType)) { + if ( + (isInputObjectType(oldType) || oldType === null) && + (isInputObjectType(newType) || newType === null) + ) { // changesInInputObject(oldType, newType, addChange); } - if (isObjectType(oldType) && isObjectType(newType)) { - return + if ((isObjectType(oldType) || oldType === null) && (isObjectType(newType) || newType === null)) { + return ; } - if (isInterfaceType(oldType) && isInterfaceType(newType)) { - return + if ( + (isInterfaceType(oldType) || oldType === null) && + (isInterfaceType(newType) || newType === null) + ) { + return ; } - if (isScalarType(oldType) && isScalarType(newType)) { - return + if ((isScalarType(oldType) || oldType === null) && (isScalarType(newType) || newType === null)) { + return ; } - - { - // addChange(typeKindChanged(oldType, newType)); - } -}; +} export function TypeName({ name }: { name: string }) { - return ( - {name} - ); + return {name}; } -export function DiffObject({ oldObject, newObject }: { oldObject: GraphQLObjectType | GraphQLInterfaceType, newObject: GraphQLObjectType | GraphQLInterfaceType }) { - const { - added, - mutual, - removed, - } = compareLists(Object.values(oldObject.getFields()), Object.values(newObject.getFields())); +export function DiffObject({ + oldObject, + newObject, +}: + | { + oldObject: GraphQLObjectType | GraphQLInterfaceType | null; + newObject: GraphQLObjectType | GraphQLInterfaceType; + } + | { + oldObject: GraphQLObjectType | GraphQLInterfaceType; + newObject: GraphQLObjectType | GraphQLInterfaceType | null; + }) { + const { added, mutual, removed } = compareLists( + Object.values(oldObject?.getFields() ?? {}), + Object.values(newObject?.getFields() ?? {}), + ); return ( <> + -  {' {'} + +   + + + {' {'} {removed.map(a => ( - - - + ))} {added.map(a => ( - - - + ))} - {/* @todo This should do a diff on the nested fields... */} {mutual.map(a => ( - - - + ))} {'}'} ); } -export function DiffEnum({ oldEnum, newEnum }: { oldEnum: GraphQLEnumType | null, newEnum: GraphQLEnumType | null }) { - const { - added, - mutual, - removed, - } = compareLists(oldEnum?.getValues() ?? [], newEnum?.getValues() ?? []); +export function DiffEnum({ + oldEnum, + newEnum, +}: { + oldEnum: GraphQLEnumType | null; + newEnum: GraphQLEnumType | null; +}) { + const { added, mutual, removed } = compareLists( + oldEnum?.getValues() ?? [], + newEnum?.getValues() ?? [], + ); + + const enumChangeType = determineChangeType(oldEnum, newEnum); + return ( <> - -  {' {'} + + +   + + {' {'} + {/* @todo move this into a DiffEnumValue function and handle directives etc. */} {removed.map(a => ( - + + + + + + + ))} {added.map(a => ( - + + + + + + + ))} {/* @todo This should do a diff on the nested fields... */} {mutual.map(a => ( - + + + + + + + ))} - {'}'} + {'}'} ); } -export function DiffScalar({ oldScalar, newScalar }: { oldScalar: GraphQLScalarType | null; newScalar: GraphQLScalarType | null }) { +export function DiffScalar({ + oldScalar, + newScalar, +}: + | { + oldScalar: GraphQLScalarType; + newScalar: GraphQLScalarType | null; + } + | { + oldScalar: GraphQLScalarType | null; + newScalar: GraphQLScalarType; + }) { + const scalarChangeType = determineChangeType(oldScalar, newScalar); if (oldScalar?.name === newScalar?.name) { return ( - -   - - {/* { @todo diff directives} */} - + <> + + + +   + + + + ); } return ( - -   - {oldScalar && } - {newScalar && } - {/* { @todo diff directives} */} + + +   + {oldScalar && ( + + + + )} + {newScalar && ( + + + + )} + ); } -export function EnumValue(props: { value: GraphQLEnumValue }) { - return {TAB} -} \ No newline at end of file +export function DiffDirectiveUsages(props: { + oldDirectives: readonly ConstDirectiveNode[]; + newDirectives: readonly ConstDirectiveNode[]; +}) { + const { added, mutual, removed } = compareLists(props.oldDirectives, props.newDirectives); + + return ( + <> + {removed.map(d => ( + + ))} + {added.map(d => ( + + ))} + {mutual.map(d => ( + + ))} + + ); +} + +export function DiffDirectiveUsage( + props: + | { + oldDirective: ConstDirectiveNode | null; + newDirective: ConstDirectiveNode; + } + | { + oldDirective: ConstDirectiveNode; + newDirective: ConstDirectiveNode | null; + }, +) { + const name = props.newDirective?.name.value ?? props.oldDirective?.name.value ?? ''; + const newArgs = props.newDirective?.arguments ?? []; + const oldArgs = props.oldDirective?.arguments ?? []; + const hasArgs = !!(newArgs.length + oldArgs.length); + const changeType = determineChangeType(props.oldDirective, props.newDirective); + const Klass = + changeType === 'addition' ? Addition : changeType === 'removal' ? Removal : React.Fragment; + const { added, mutual, removed } = compareLists(oldArgs, newArgs); + return ( + +   + + {hasArgs && <>(} + {removed.map(_ => '@TODO REMOVED DIRECTIVE ARGS')} + {added.map(_ => '@TODO ADDED DIRECTIVE ARGS')} + {mutual.map(_ => '@TODO MUTUAL DIRECTIVE ARGS')} + {hasArgs && <>)} + + ); + // !isPrimitive(t) && !isIntrospectionType(t)), ); + const { + added: addedDirectives, + mutual: mutualDirectives, + removed: removedDirectives, + } = compareLists( + oldSchema.getDirectives().filter(d => !isSpecifiedDirective(d)), + newSchema.getDirectives().filter(d => !isSpecifiedDirective(d)), + ); + return ( - - {addedTypes.map(a => { - return ( - - ) - })} - {removedTypes.map(a => { - return ( - - ) - })} - {mutualTypes.map(a => { - return ( - - ) - })} + {removedDirectives.map(d => ( + + ))} + {addedDirectives.map(d => ( + + ))} + {mutualDirectives.map(d => ( + + ))} + + {addedTypes.map(a => ( + + ))} + {removedTypes.map(a => ( + + ))} + {mutualTypes.map(a => ( + + ))} ); - - // compareLists(oldSchema.getDirectives(), newSchema.getDirectives(), { - // onAdded(directive) { - // addChange(directiveAdded(directive)); - // }, - // onRemoved(directive) { - // addChange(directiveRemoved(directive)); - // }, - // onMutual(directive) { - // changesInDirective(directive.oldVersion, directive.newVersion, addChange); - // }, - // }); - - // compareLists(oldSchema.astNode?.directives || [], newSchema.astNode?.directives || [], { - // onAdded(directive) { - // addChange(directiveUsageAdded(Kind.SCHEMA_DEFINITION, directive, newSchema)); - // }, - // onRemoved(directive) { - // addChange(directiveUsageRemoved(Kind.SCHEMA_DEFINITION, directive, oldSchema)); - // }, - // }); - - // return changes; } -// export function printSchemaDiff(beforeSchema: GraphQLSchema, afterSchema: GraphQLSchema, printFn: (before: ASTNode | undefined, after: ASTNode | undefined) => T): T { -// return printFilteredSchema( -// beforeSchema, -// (n) => !isSpecifiedDirective(n), -// isDefinedType, -// ); -// } - -// // export function printIntrospectionSchema(schema: GraphQLSchema): string { -// // return printFilteredSchema(schema, isSpecifiedDirective, isIntrospectionType); -// } - -// function isDefinedType(type: GraphQLNamedType): boolean { -// return !isSpecifiedScalarType(type) && !isIntrospectionType(type); -// } - -// function printFilteredSchema( -// schema: GraphQLSchema, -// directiveFilter: (type: GraphQLDirective) => boolean, -// typeFilter: (type: GraphQLNamedType) => boolean, -// ): string { -// const directives = schema.getDirectives().filter(directiveFilter); -// const types = Object.values(schema.getTypeMap()).filter(typeFilter); - -// return [ -// printSchemaDefinition(schema), -// ...directives.map((directive) => printDirective(directive)), -// ...types.map((type) => printType(type)), -// ] -// .filter(Boolean) -// .join('\n\n'); -// } - -// function printSchemaDefinition(schema: GraphQLSchema): Maybe { -// if (schema.description == null && isSchemaOfCommonNames(schema)) { -// return; -// } - -// const operationTypes = []; - -// const queryType = schema.getQueryType(); -// if (queryType) { -// operationTypes.push(` query: ${queryType.name}`); -// } - -// const mutationType = schema.getMutationType(); -// if (mutationType) { -// operationTypes.push(` mutation: ${mutationType.name}`); -// } - -// const subscriptionType = schema.getSubscriptionType(); -// if (subscriptionType) { -// operationTypes.push(` subscription: ${subscriptionType.name}`); -// } - -// return printDescription(schema) + `schema {\n${operationTypes.join('\n')}\n}`; -// } - // /** // * GraphQL schema define root types for each type of operation. These types are // * the same as any other type and can be named in any manner, however there is @@ -353,22 +266,3 @@ export function printSchemaDiff( // }); // return
{`@specifiedBy(url: ${astValue})`}
; // } - -// function printDescription( -// def: { readonly description: Maybe }, -// indentation: string = '', -// // firstInBlock: boolean = true, -// ): JSX.Element | null { -// const { description } = def; -// if (description == null) { -// return null; -// } - -// const blockString = print({ -// kind: Kind.STRING, -// value: description, -// block: isPrintableAsBlockString(description), -// }); - -// return {blockString.replace(/\n/g, '\n' + indentation) + '\n'}; -// } diff --git a/packages/web/app/src/components/proposal/proposal-sdl.tsx b/packages/web/app/src/components/proposal/proposal-sdl.tsx index aec5fb54e0..0c516ab6d1 100644 --- a/packages/web/app/src/components/proposal/proposal-sdl.tsx +++ b/packages/web/app/src/components/proposal/proposal-sdl.tsx @@ -1,9 +1,7 @@ -import { FragmentType, graphql, useFragment } from '@/gql'; -import type { Change } from '@graphql-inspector/core' -import type { MonacoDiffEditor as OriginalMonacoDiffEditor } from '@monaco-editor/react'; -import { useRef } from 'react'; -import { printSchemaDiff } from './print-diff/printDiff'; import { buildSchema } from 'graphql'; +import { FragmentType, graphql, useFragment } from '@/gql'; +import type { Change } from '@graphql-inspector/core'; +import { printSchemaDiff } from './print-diff/print-diff'; const ProposalOverview_ReviewsFragment = graphql(/** GraphQL */ ` fragment ProposalOverview_ReviewsFragment on SchemaProposalReviewConnection { @@ -76,20 +74,54 @@ export function ProposalSDL(props: { try { // @todo props.baseSchemaSDL - const baseSchemaSDL = /* GraphQL */` + const baseSchemaSDL = /* GraphQL */ ` + """ + This is old + """ + directive @old on FIELD + + "Doesn't change" type Query { - okay: Boolean + okay: Boolean @deprecated dokay: Boolean } `; // const baseSchema = buildSchema(baseSchemaSDL, { assumeValid: true, assumeValidSDL: true }); - const patchedSchemaSDL = /* GraphQL */` + const patchedSchemaSDL = /* GraphQL */ ` + """ + Custom scalar that can represent any valid JSON + """ + scalar JSON + """ + Enhances fields with meta data + """ + directive @meta( + "The metadata key" + name: String! + "The value of the metadata" + content: String! + ) on FIELD + + "Doesn't change" type Query { - ok: Boolean + ok: Boolean @meta(name: "team", content: "hive") + + """ + This is a new description on a field + """ dokay: Boolean! } - `;// APPLY PATCH + + "Yups" + enum Status { + OKAY + """ + Hi + """ + SMOKAY + } + `; // APPLY PATCH return printSchemaDiff( buildSchema(baseSchemaSDL, { assumeValid: true, assumeValidSDL: true }), @@ -136,8 +168,6 @@ export function ProposalSDL(props: { // /> // ) - - // // @note assume reviews are specific to the current service... // const globalReviews: ReviewNode[] = []; // const reviewsByLine = new Map(); diff --git a/packages/web/app/src/index.css b/packages/web/app/src/index.css index e381218909..aa2834d0fc 100644 --- a/packages/web/app/src/index.css +++ b/packages/web/app/src/index.css @@ -200,10 +200,10 @@ } .schema-doc-row-old::before { - content: counter(olddoc) + content: counter(olddoc); } .schema-doc-row-new::before { - content: counter(newdoc) + content: counter(newdoc); } } From b9e7ee12cdcc036b6dc073a11b501e479bdee2cc Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Wed, 13 Aug 2025 17:13:38 -0700 Subject: [PATCH 20/54] support remaining types etc --- .../proposal/print-diff/components.tsx | 468 ++++++++++++++---- .../proposal/print-diff/print-diff.tsx | 216 +------- .../src/components/proposal/proposal-sdl.tsx | 76 ++- 3 files changed, 399 insertions(+), 361 deletions(-) diff --git a/packages/web/app/src/components/proposal/print-diff/components.tsx b/packages/web/app/src/components/proposal/print-diff/components.tsx index 777c9f8bca..811e60aab0 100644 --- a/packages/web/app/src/components/proposal/print-diff/components.tsx +++ b/packages/web/app/src/components/proposal/print-diff/components.tsx @@ -1,17 +1,25 @@ +/* eslint-disable @typescript-eslint/no-non-null-asserted-optional-chain */ import React, { ReactNode } from 'react'; import { + astFromValue, + ConstArgumentNode, ConstDirectiveNode, DirectiveLocation, GraphQLArgument, GraphQLDirective, GraphQLEnumType, + GraphQLEnumValue, GraphQLField, + GraphQLInputField, + GraphQLInputObjectType, + GraphQLInputType, GraphQLInterfaceType, GraphQLNamedType, GraphQLObjectType, GraphQLOutputType, GraphQLScalarType, GraphQLSchema, + GraphQLUnionType, isEnumType, isInputObjectType, isInterfaceType, @@ -37,7 +45,7 @@ export function ChangeDocument(props: { children: ReactNode; className?: string return ( {props.children} @@ -46,11 +54,11 @@ export function ChangeDocument(props: { children: ReactNode; className?: string } export function ChangeRow(props: { - children: ReactNode; + children?: ReactNode; className?: string; /** Default is mutual */ type?: 'removal' | 'addition' | 'mutual'; - indent?: boolean; + indent?: boolean | number; }) { const incrementCounter = props.type === 'mutual' || props.type === undefined @@ -82,7 +90,10 @@ export function ChangeRow(props: { props.type === 'addition' && 'bg-green-800', )} > - {props.indent && <>{TAB}} + {props.indent && + Array.from({ length: Number(props.indent) }).map((_, i) => ( + {TAB} + ))} {props.children} @@ -122,7 +133,7 @@ function printDescription(def: { readonly description: string | undefined | null function Description(props: { content: string; type?: 'removal' | 'addition' | 'mutual'; - indent?: boolean; + indent?: boolean | number; }): React.ReactNode { const lines = props.content.split('\n'); @@ -158,12 +169,12 @@ export function DiffDescription( | { oldNode: { description: string | null | undefined } | null; newNode: { description: string | null | undefined }; - indent?: boolean; + indent?: boolean | number; } | { oldNode: { description: string | null | undefined }; newNode: { description: string | null | undefined } | null; - indent?: boolean; + indent?: boolean | number; }, ) { const oldDesc = props.oldNode?.description; @@ -197,56 +208,26 @@ export function DiffDescription( } } -export function DiffField({ +export function DiffInputField({ oldField, newField, }: | { - oldField: GraphQLField | null; - newField: GraphQLField; + oldField: GraphQLInputField | null; + newField: GraphQLInputField; } | { - oldField: GraphQLField; - newField: GraphQLField | null; + oldField: GraphQLInputField; + newField: GraphQLInputField | null; }) { - const oldReturnType = oldField?.type.toString(); - const newReturnType = newField?.type.toString(); - if (!!newField && oldReturnType === newReturnType) { - return ( - <> - - - {TAB} - - :  - - - - - ); - } + const changeType = determineChangeType(oldField, newField); return ( <> - - - {TAB} - + + + :  - {oldReturnType && ( - - - - )} - {newReturnType && ( - - - - )} + | null; + newField: GraphQLField; + } + | { + oldField: GraphQLField; + newField: GraphQLField | null; + }) { + const hasArgs = !!(newField?.args.length || oldField?.args.length); + const changeType = determineChangeType(oldField, newField); + const AfterArguments = ( + <> + :  + + + + ); + return ( + <> + + + + {hasArgs && '('} + {!hasArgs && AfterArguments} + + + {!!hasArgs && ( + + ){AfterArguments} + + )} + + ); +} + export function DirectiveName(props: { name: string }) { return @{props.name}; } @@ -263,34 +286,38 @@ export function DirectiveName(props: { name: string }) { export function DiffArguments(props: { oldArgs: readonly GraphQLArgument[]; newArgs: readonly GraphQLArgument[]; + indent: boolean | number; }) { const { added, mutual, removed } = compareLists(props.oldArgs, props.newArgs); return ( <> {removed.map(a => ( - - - + + + : + ))} {added.map(a => ( - - - + + + : + ))} - {/* @todo This should do a diff on the nested fields... */} {mutual.map(a => ( - - - + + + :{' '} + + ( {e} - {index !== locationElements.length - 1 && <>, } + {index !== locationElements.length - 1 && <> | } ))} ); } +function DiffRepeatable( + props: + | { + oldDirective: GraphQLDirective | null; + newDirective: GraphQLDirective; + } + | { + oldDirective: GraphQLDirective; + newDirective: GraphQLDirective | null; + }, +) { + const oldRepeatable = !!props.oldDirective?.isRepeatable; + const newRepeatable = !!props.newDirective?.isRepeatable; + if (oldRepeatable === newRepeatable) { + return newRepeatable ? ( + <> + +   + + ) : null; + } + return ( + <> + {oldRepeatable && ( + + +   + + )} + {newRepeatable && ( + + +   + + )} + + ); +} + export function DiffDirective( props: | { @@ -363,6 +429,16 @@ export function DiffDirective( ) { const changeType = determineChangeType(props.oldDirective, props.newDirective); const hasArgs = props.oldDirective?.args.length || props.newDirective?.args.length; + const AfterArguments = ( + <> +   + + + + ); return ( <> @@ -371,39 +447,79 @@ export function DiffDirective(   {!!hasArgs && <>(} - {!hasArgs && ( - <> -   - - - )} + {!hasArgs && AfterArguments} - {!!hasArgs && ( - - )  - - + {!!hasArgs && ){AfterArguments}} + + ); +} + +function DiffReturnType( + props: + | { + oldType: GraphQLInputType | GraphQLOutputType; + newType: GraphQLInputType | GraphQLOutputType | null | undefined; + } + | { + oldType: GraphQLInputType | GraphQLOutputType | null | undefined; + newType: GraphQLInputType | GraphQLOutputType; + } + | { + oldType: GraphQLInputType | GraphQLOutputType; + newType: GraphQLInputType | GraphQLOutputType; + }, +) { + const oldStr = props.oldType?.toString(); + const newStr = props.newType?.toString(); + if (newStr && oldStr === newStr) { + return ; + } + + return ( + <> + {oldStr && ( + + + + )} + {newStr && ( + + + )} ); } -export function DirectiveArgument(props: { arg: GraphQLArgument }) { +function printDefault(arg: GraphQLArgument) { + const defaultAST = astFromValue(arg.defaultValue, arg.type); + return defaultAST && print(defaultAST); +} + +function DiffDefaultValue({ + oldArg, + newArg, +}: { + oldArg: GraphQLArgument | null; + newArg: GraphQLArgument | null; +}) { + const oldDefault = oldArg && printDefault(oldArg); + const newDefault = newArg && printDefault(newArg); + + if (oldDefault === newDefault) { + return newDefault ? <> = {newDefault} : null; + } return ( <> - :{' '} - - {props.arg.defaultValue === undefined ? '' : `= ${JSON.stringify(props.arg.defaultValue)}`} + {oldDefault && = {oldDefault}} + {newDefault && ( + = {newDefault} + )} ); } @@ -534,13 +650,13 @@ export function DiffType({ return ; } if ((isUnionType(oldType) || oldType === null) && (isUnionType(newType) || newType === null)) { - // changesInUnion(oldType, newType, addChange); + return ; } if ( (isInputObjectType(oldType) || oldType === null) && (isInputObjectType(newType) || newType === null) ) { - // changesInInputObject(oldType, newType, addChange); + return ; } if ((isObjectType(oldType) || oldType === null) && (isObjectType(newType) || newType === null)) { return ; @@ -556,10 +672,54 @@ export function DiffType({ } } -export function TypeName({ name }: { name: string }) { +function TypeName({ name }: { name: string }) { return {name}; } +export function DiffInputObject({ + oldInput, + newInput, +}: + | { + oldInput: GraphQLInputObjectType | null; + newInput: GraphQLInputObjectType; + } + | { + oldInput: GraphQLInputObjectType; + newInput: GraphQLInputObjectType | null; + }) { + const { added, mutual, removed } = compareLists( + Object.values(oldInput?.getFields() ?? {}), + Object.values(newInput?.getFields() ?? {}), + ); + const changeType = determineChangeType(oldInput, newInput); + return ( + <> + + + +   + + + {' {'} + + {removed.map(a => ( + + ))} + {added.map(a => ( + + ))} + {mutual.map(a => ( + + ))} + {'}'} + + ); +} + export function DiffObject({ oldObject, newObject, @@ -576,10 +736,11 @@ export function DiffObject({ Object.values(oldObject?.getFields() ?? {}), Object.values(newObject?.getFields() ?? {}), ); + const changeType = determineChangeType(oldObject, newObject); return ( <> - +   @@ -598,7 +759,30 @@ export function DiffObject({ {mutual.map(a => ( ))} - {'}'} + {'}'} + + ); +} + +export function DiffEnumValue({ + oldValue, + newValue, +}: { + oldValue: GraphQLEnumValue | null; + newValue: GraphQLEnumValue | null; +}) { + const changeType = determineChangeType(oldValue, newValue); + const name = oldValue?.name ?? newValue?.name ?? ''; + return ( + <> + + + + + ); } @@ -619,45 +803,75 @@ export function DiffEnum({ return ( <> +   {' {'} - {/* @todo move this into a DiffEnumValue function and handle directives etc. */} + {removed.map(a => ( + + ))} + {added.map(a => ( + + ))} + {mutual.map(a => ( + + ))} + {'}'} + + ); +} + +export function DiffUnion({ + oldUnion, + newUnion, +}: { + oldUnion: GraphQLUnionType | null; + newUnion: GraphQLUnionType | null; +}) { + const { added, mutual, removed } = compareLists( + oldUnion?.getTypes() ?? [], + newUnion?.getTypes() ?? [], + ); + + const changeType = determineChangeType(oldUnion, newUnion); + const name = oldUnion?.name ?? newUnion?.name ?? ''; + return ( + <> + + + +   + + + {' = '} + {removed.map(a => ( - - - + | ))} {added.map(a => ( - - - + | ))} - {/* @todo This should do a diff on the nested fields... */} {mutual.map(a => ( - - - + | ))} - {'}'} ); } @@ -701,7 +915,7 @@ export function DiffScalar({ )} {newScalar && ( - + )} @@ -757,16 +971,62 @@ export function DiffDirectiveUsage( const Klass = changeType === 'addition' ? Addition : changeType === 'removal' ? Removal : React.Fragment; const { added, mutual, removed } = compareLists(oldArgs, newArgs); + const argumentElements = [ + ...removed.map(r => ), + ...added.map(r => ), + ...mutual.map(r => ), + ]; + return (   {hasArgs && <>(} - {removed.map(_ => '@TODO REMOVED DIRECTIVE ARGS')} - {added.map(_ => '@TODO ADDED DIRECTIVE ARGS')} - {mutual.map(_ => '@TODO MUTUAL DIRECTIVE ARGS')} + {argumentElements.map((e, index) => ( + + {e} + {index === argumentElements.length - 1 ? '' : ', '} + + ))} {hasArgs && <>)} ); - // { + if (oldType === newType) { + return newType; + } + return ( + <> + {oldType && {oldType}} + {newType && {newType}} + + ); + }; + + return ( + <> + + :  + + + ); } diff --git a/packages/web/app/src/components/proposal/print-diff/print-diff.tsx b/packages/web/app/src/components/proposal/print-diff/print-diff.tsx index 69cc58fed7..65cbadbdee 100644 --- a/packages/web/app/src/components/proposal/print-diff/print-diff.tsx +++ b/packages/web/app/src/components/proposal/print-diff/print-diff.tsx @@ -1,6 +1,6 @@ /* eslint-disable tailwindcss/no-custom-classname */ import type { GraphQLSchema } from 'graphql'; -import { DEFAULT_DEPRECATION_REASON, isIntrospectionType, isSpecifiedDirective } from 'graphql'; +import { isIntrospectionType, isSpecifiedDirective } from 'graphql'; import { isPrimitive } from '@graphql-inspector/core/utils/graphql'; import { compareLists } from './compare-lists'; import { ChangeDocument, DiffDirective, DiffType, SchemaDefinitionDiff } from './components'; @@ -52,217 +52,3 @@ export function printSchemaDiff(oldSchema: GraphQLSchema, newSchema: GraphQLSche ); } - -// /** -// * GraphQL schema define root types for each type of operation. These types are -// * the same as any other type and can be named in any manner, however there is -// * a common naming convention: -// * -// * ```graphql -// * schema { -// * query: Query -// * mutation: Mutation -// * subscription: Subscription -// * } -// * ``` -// * -// * When using this naming convention, the schema description can be omitted. -// */ -// function isSchemaOfCommonNames(schema: GraphQLSchema): boolean { -// const queryType = schema.getQueryType(); -// if (queryType && queryType.name !== 'Query') { -// return false; -// } - -// const mutationType = schema.getMutationType(); -// if (mutationType && mutationType.name !== 'Mutation') { -// return false; -// } - -// const subscriptionType = schema.getSubscriptionType(); -// if (subscriptionType && subscriptionType.name !== 'Subscription') { -// return false; -// } - -// return true; -// } - -// export function printTypeName(type: GraphQLNamedType): string { -// if (isScalarType(type)) { -// return printScalar(type); -// } -// if (isObjectType(type)) { -// return printObject(type); -// } -// if (isInterfaceType(type)) { -// return printInterface(type); -// } -// if (isUnionType(type)) { -// return printUnion(type); -// } -// if (isEnumType(type)) { -// return printEnum(type); -// } -// if (isInputObjectType(type)) { -// return printInputObject(type); -// } -// /* c8 ignore next 3 */ -// // Not reachable, all possible types have been considered. -// invariant(false, 'Unexpected type: ' + inspect(type)); -// } - -// function printScalar(type: GraphQLScalarType): string { -// return ( -// printDescription(type) + `scalar ${type.name}` + printSpecifiedByURL(type) -// ); -// } - -// function printImplementedInterfaces( -// type: GraphQLObjectType | GraphQLInterfaceType, -// ): string { -// const interfaces = type.getInterfaces(); -// return interfaces.length -// ? ' implements ' + interfaces.map((i) => i.name).join(' & ') -// : ''; -// } - -// function printObject(type: GraphQLObjectType): string { -// return ( -// printDescription(type) + -// `type ${type.name}` + -// printImplementedInterfaces(type) + -// printFields(type) -// ); -// } - -// function printInterface(type: GraphQLInterfaceType): string { -// return ( -// printDescription(type) + -// `interface ${type.name}` + -// printImplementedInterfaces(type) + -// printFields(type) -// ); -// } - -// function printUnion(type: GraphQLUnionType): string { -// const types = type.getTypes(); -// const possibleTypes = types.length ? ' = ' + types.join(' | ') : ''; -// return printDescription(type) + 'union ' + type.name + possibleTypes; -// } - -// function printEnum(type: GraphQLEnumType): string { -// const values = type -// .getValues() -// .map( -// (value, i) => -// printDescription(value, ' ', !i) + -// ' ' + -// value.name + -// printDeprecated(value.deprecationReason), -// ); - -// return printDescription(type) + `enum ${type.name}` + printBlock(values); -// } - -// function printInputObject(type: GraphQLInputObjectType): string { -// const fields = Object.values(type.getFields()).map( -// (f, i) => printDescription(f, ' ', !i) + ' ' + printInputValue(f), -// ); -// return ( -// printDescription(type) + -// `input ${type.name}` + -// (type.isOneOf ? ' @oneOf' : '') + -// printBlock(fields) -// ); -// } - -// function printFields(type: GraphQLObjectType | GraphQLInterfaceType): string { -// const fields = Object.values(type.getFields()).map( -// (f, i) => -// printDescription(f, ' ', !i) + -// ' ' + -// f.name + -// printArgs(f.args, ' ') + -// ': ' + -// String(f.type) + -// printDeprecated(f.deprecationReason), -// ); -// return printBlock(fields); -// } - -// function printBlock(items: ReadonlyArray): string { -// return items.length !== 0 ? ' {\n' + items.join('\n') + '\n}' : ''; -// } - -// function printArgs( -// args: ReadonlyArray, -// indentation: string = '', -// ): string { -// if (args.length === 0) { -// return ''; -// } - -// // If every arg does not have a description, print them on one line. -// if (args.every((arg) => !arg.description)) { -// return '(' + args.map(printInputValue).join(', ') + ')'; -// } - -// return ( -// '(\n' + -// args -// .map( -// (arg, i) => -// printDescription(arg, ' ' + indentation, !i) + -// ' ' + -// indentation + -// printInputValue(arg), -// ) -// .join('\n') + -// '\n' + -// indentation + -// ')' -// ); -// } - -// function printInputValue(arg: GraphQLInputField): string { -// const defaultAST = astFromValue(arg.defaultValue, arg.type); -// let argDecl = arg.name + ': ' + String(arg.type); -// if (defaultAST) { -// argDecl += ` = ${print(defaultAST)}`; -// } -// return argDecl + printDeprecated(arg.deprecationReason); -// } - -// function printDirective(directive: GraphQLDirective): string { -// return ( -// printDescription(directive) + -// 'directive @' + -// directive.name + -// printArgs(directive.args) + -// (directive.isRepeatable ? ' repeatable' : '') + -// ' on ' + -// directive.locations.join(' | ') -// ); -// } - -// function printDeprecated(reason: Maybe): string { -// if (reason == null) { -// return ''; -// } -// if (reason !== DEFAULT_DEPRECATION_REASON) { -// const astValue = print({ kind: Kind.STRING, value: reason }); -// return ` @deprecated(reason: ${astValue})`; -// } -// return
@deprecated
; -// } - -// function printSpecifiedByURL(scalar: GraphQLScalarType): JSX.Element | null { -// if (scalar.specifiedByURL == null) { -// return null; -// } -// const astValue = print({ -// kind: Kind.STRING, -// value: scalar.specifiedByURL, -// }); -// return
{`@specifiedBy(url: ${astValue})`}
; -// } diff --git a/packages/web/app/src/components/proposal/proposal-sdl.tsx b/packages/web/app/src/components/proposal/proposal-sdl.tsx index 0c516ab6d1..a0fe34673a 100644 --- a/packages/web/app/src/components/proposal/proposal-sdl.tsx +++ b/packages/web/app/src/components/proposal/proposal-sdl.tsx @@ -73,13 +73,15 @@ export function ProposalSDL(props: { const _reviewsConnection = useFragment(ProposalOverview_ReviewsFragment, props.reviews); try { - // @todo props.baseSchemaSDL + // @todo use props.baseSchemaSDL const baseSchemaSDL = /* GraphQL */ ` """ This is old """ directive @old on FIELD + directive @foo on OBJECT + "Doesn't change" type Query { okay: Boolean @deprecated @@ -93,6 +95,9 @@ export function ProposalSDL(props: { Custom scalar that can represent any valid JSON """ scalar JSON + + directive @foo repeatable on OBJECT | FIELD + """ Enhances fields with meta data """ @@ -110,7 +115,7 @@ export function ProposalSDL(props: { """ This is a new description on a field """ - dokay: Boolean! + dokay(foo: String = "What"): Boolean! } "Yups" @@ -121,6 +126,33 @@ export function ProposalSDL(props: { """ SMOKAY } + + """ + Crusty flaky delicious goodness. + """ + type Pie { + name: String! + flavor: String! + slices: Int + } + + """ + Delicious baked flour based product + """ + type Cake { + name: String! + flavor: String! + tiers: Int! + } + + input FooInput { + """ + Hi + """ + asdf: String @foo + } + + union Dessert = Pie | Cake `; // APPLY PATCH return printSchemaDiff( @@ -128,46 +160,6 @@ export function ProposalSDL(props: { buildSchema(patchedSchemaSDL, { assumeValid: true, assumeValidSDL: true }), ); - // const editorRef = useRef(null); - - // return ( - // } - // original={baseSchemaSDL ?? undefined} - // modified={patchedSchemaSDL ?? undefined} - // options={{ - // renderSideBySide: false, - // originalEditable: false, - // renderLineHighlightOnlyWhenFocus: true, - // readOnly: true, - // diffAlgorithm: 'advanced', - // lineNumbers: 'on', - // contextmenu: false, - // }} - // onMount={(editor, _monaco) => { - // editorRef.current = editor; - // editor.onDidUpdateDiff(() => { - // // const coordinateToLineMap = collectCoordinateLocations(baseSchema, new Source(baseSchemaSDL)); - // const originalLines = editor.getOriginalEditor().getContainerDomNode().getElementsByClassName('view-line'); - // console.log( - // 'original editor', - // Array.from(originalLines).map(e => e.textContent).join('\n'), - // ); - - // const modifiedLines = editor.getModifiedEditor().getContainerDomNode().getElementsByClassName('view-line'); - // console.log( - // 'modified', - // Array.from(modifiedLines).map(e => e.textContent).join('\n'), - // ); - // }) - // }} - // /> - // ) - // // @note assume reviews are specific to the current service... // const globalReviews: ReviewNode[] = []; // const reviewsByLine = new Map(); From a584913c01e528be2e18829611857ca92f7bd2e0 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Wed, 13 Aug 2025 21:48:15 -0700 Subject: [PATCH 21/54] Add spacing between elements; adjust highlighting --- .../proposal/print-diff/components.tsx | 114 +++++++++++++----- .../src/components/proposal/proposal-sdl.tsx | 1 - 2 files changed, 86 insertions(+), 29 deletions(-) diff --git a/packages/web/app/src/components/proposal/print-diff/components.tsx b/packages/web/app/src/components/proposal/print-diff/components.tsx index 811e60aab0..c707ca436c 100644 --- a/packages/web/app/src/components/proposal/print-diff/components.tsx +++ b/packages/web/app/src/components/proposal/print-diff/components.tsx @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-non-null-asserted-optional-chain */ -import React, { ReactNode } from 'react'; +import React, { ReactElement, ReactNode } from 'react'; import { astFromValue, ConstArgumentNode, @@ -53,6 +53,23 @@ export function ChangeDocument(props: { children: ReactNode; className?: string ); } +export function ChangeSpacing(props: { + type?: 'removal' | 'addition' | 'mutual'; +}) { + return ( +
+ + ); +} + export function ChangeRow(props: { children?: ReactNode; className?: string; @@ -68,33 +85,41 @@ export function ChangeRow(props: { : 'newdoc'; return ( ); @@ -108,11 +133,11 @@ function Removal(props: { children: React.ReactNode | string; className?: string; }): React.ReactNode { - return {props.children}; + return {props.children}; } function Addition(props: { children: React.ReactNode; className?: string }): React.ReactNode { - return {props.children}; + return {props.children}; } function printDescription(def: { readonly description: string | undefined | null }): string | null { @@ -223,6 +248,7 @@ export function DiffInputField({ const changeType = determineChangeType(oldField, newField); return ( <> + @@ -237,6 +263,19 @@ export function DiffInputField({ ); } +function Change(props: { + children: ReactElement; + type?: 'addition' | 'removal' | 'mutual'; +}): ReactElement { + if (props.type === 'addition') { + return {props.children} + } + if (props.type === 'removal') { + return {props.children} + } + return props.children +} + export function DiffField({ oldField, newField, @@ -249,7 +288,10 @@ export function DiffField({ oldField: GraphQLField; newField: GraphQLField | null; }) { - const hasArgs = !!(newField?.args.length || oldField?.args.length); + const hasNewArgs = !!newField?.args.length; + const hasOldArgs = !!oldField?.args.length; + const hasArgs = hasNewArgs || hasOldArgs; + const argsChangeType = hasNewArgs ? (hasOldArgs ? 'mutual' : 'addition') : (hasOldArgs ? 'removal' : 'mutual'); const changeType = determineChangeType(oldField, newField); const AfterArguments = ( <> @@ -266,13 +308,13 @@ export function DiffField({ - {hasArgs && '('} + {hasArgs && <>(} {!hasArgs && AfterArguments} {!!hasArgs && ( - ){AfterArguments} + <>){AfterArguments} )} @@ -428,7 +470,10 @@ export function DiffDirective( }, ) { const changeType = determineChangeType(props.oldDirective, props.newDirective); - const hasArgs = props.oldDirective?.args.length || props.newDirective?.args.length; + const hasNewArgs = !!props.newDirective?.args.length; + const hasOldArgs = !!props.oldDirective?.args.length; + const hasArgs = hasNewArgs || hasOldArgs; + const argsChangeType = hasNewArgs ? (hasOldArgs ? 'mutual' : 'addition') : (hasOldArgs ? 'removal' : 'mutual'); const AfterArguments = ( <>   @@ -441,12 +486,13 @@ export function DiffDirective( ); return ( <> +   - {!!hasArgs && <>(} + {!!hasArgs && <>(} {!hasArgs && AfterArguments} - {!!hasArgs && ){AfterArguments}} + {!!hasArgs && ( + + <>){AfterArguments} + + )} ); } @@ -695,6 +745,7 @@ export function DiffInputObject({ const changeType = determineChangeType(oldInput, newInput); return ( <> + @@ -739,6 +790,7 @@ export function DiffObject({ const changeType = determineChangeType(oldObject, newObject); return ( <> + @@ -799,12 +851,13 @@ export function DiffEnum({ newEnum?.getValues() ?? [], ); - const enumChangeType = determineChangeType(oldEnum, newEnum); + const changeType = determineChangeType(oldEnum, newEnum); return ( <> + - +   @@ -819,7 +872,7 @@ export function DiffEnum({ {mutual.map(a => ( ))} - {'}'} + {'}'} ); } @@ -840,6 +893,7 @@ export function DiffUnion({ const name = oldUnion?.name ?? newUnion?.name ?? ''; return ( <> + @@ -888,10 +942,11 @@ export function DiffScalar({ oldScalar: GraphQLScalarType | null; newScalar: GraphQLScalarType; }) { - const scalarChangeType = determineChangeType(oldScalar, newScalar); + const changeType = determineChangeType(oldScalar, newScalar); if (oldScalar?.name === newScalar?.name) { return ( <> + @@ -906,7 +961,7 @@ export function DiffScalar({ ); } return ( - +   {oldScalar && ( @@ -966,7 +1021,10 @@ export function DiffDirectiveUsage( const name = props.newDirective?.name.value ?? props.oldDirective?.name.value ?? ''; const newArgs = props.newDirective?.arguments ?? []; const oldArgs = props.oldDirective?.arguments ?? []; - const hasArgs = !!(newArgs.length + oldArgs.length); + const hasNewArgs = !!newArgs.length; + const hasOldArgs = !!oldArgs.length; + const hasArgs = hasNewArgs || hasOldArgs; + const argsChangeType = hasNewArgs ? (hasOldArgs ? 'mutual' : 'addition') : (hasOldArgs ? 'removal' : 'mutual'); const changeType = determineChangeType(props.oldDirective, props.newDirective); const Klass = changeType === 'addition' ? Addition : changeType === 'removal' ? Removal : React.Fragment; @@ -981,14 +1039,14 @@ export function DiffDirectiveUsage(   - {hasArgs && <>(} + {hasArgs && <>(} {argumentElements.map((e, index) => ( {e} {index === argumentElements.length - 1 ? '' : ', '} ))} - {hasArgs && <>)} + {hasArgs && <>)} ); } diff --git a/packages/web/app/src/components/proposal/proposal-sdl.tsx b/packages/web/app/src/components/proposal/proposal-sdl.tsx index a0fe34673a..550f293c35 100644 --- a/packages/web/app/src/components/proposal/proposal-sdl.tsx +++ b/packages/web/app/src/components/proposal/proposal-sdl.tsx @@ -88,7 +88,6 @@ export function ProposalSDL(props: { dokay: Boolean } `; - // const baseSchema = buildSchema(baseSchemaSDL, { assumeValid: true, assumeValidSDL: true }); const patchedSchemaSDL = /* GraphQL */ ` """ From 0dc68b50d04daa018989c49b0f99ee3245b07aeb Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Thu, 14 Aug 2025 11:55:04 -0700 Subject: [PATCH 22/54] Improve color scheme and add highlighting on hover --- .../proposal/print-diff/components.tsx | 323 +++++++++++------- .../src/components/proposal/proposal-sdl.tsx | 1 + 2 files changed, 197 insertions(+), 127 deletions(-) diff --git a/packages/web/app/src/components/proposal/print-diff/components.tsx b/packages/web/app/src/components/proposal/print-diff/components.tsx index c707ca436c..d700c1e030 100644 --- a/packages/web/app/src/components/proposal/print-diff/components.tsx +++ b/packages/web/app/src/components/proposal/print-diff/components.tsx @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-non-null-asserted-optional-chain */ -import React, { ReactElement, ReactNode } from 'react'; +import React, { ReactNode } from 'react'; import { astFromValue, ConstArgumentNode, @@ -45,7 +45,10 @@ export function ChangeDocument(props: { children: ReactNode; className?: string return (
+ + +
- {props.indent && - Array.from({ length: Number(props.indent) }).map((_, i) => ( - {TAB} - ))} - {props.children} + + {props.indent && + Array.from({ length: Number(props.indent) }).map((_, i) => ( + {TAB} + ))} + {props.children} +
{props.children} @@ -53,19 +56,19 @@ export function ChangeDocument(props: { children: ReactNode; className?: string ); } -export function ChangeSpacing(props: { - type?: 'removal' | 'addition' | 'mutual'; -}) { +export function ChangeSpacing(props: { type?: 'removal' | 'addition' | 'mutual' }) { return ( - ); } @@ -84,36 +87,38 @@ export function ChangeRow(props: { ? 'olddoc' : 'newdoc'; return ( - + - // - // , - // ); - // } - // elements.push( - // - // - // , - // ); - // } - // return elements; - // })} - // - // {globalReviews.map(r => { - // return
{r.id}
; - // })} - // - // ); - // console.log(printJsx(document)); - } catch (e: unknown) { - return ( - <> -
Invalid SDL
-
{e instanceof Error ? e.message : String(e)}
- - ); - } -} diff --git a/packages/web/app/src/components/proposal/print-diff/compare-lists.ts b/packages/web/app/src/components/proposal/schema-diff/compare-lists.ts similarity index 100% rename from packages/web/app/src/components/proposal/print-diff/compare-lists.ts rename to packages/web/app/src/components/proposal/schema-diff/compare-lists.ts diff --git a/packages/web/app/src/components/proposal/print-diff/components.tsx b/packages/web/app/src/components/proposal/schema-diff/components.tsx similarity index 71% rename from packages/web/app/src/components/proposal/print-diff/components.tsx rename to packages/web/app/src/components/proposal/schema-diff/components.tsx index f41fa0d058..c26cc6db77 100644 --- a/packages/web/app/src/components/proposal/print-diff/components.tsx +++ b/packages/web/app/src/components/proposal/schema-diff/components.tsx @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-non-null-asserted-optional-chain */ -import React, { ReactNode } from 'react'; +import { Fragment, ReactElement, ReactNode } from 'react'; import { astFromValue, ConstArgumentNode, @@ -79,6 +79,8 @@ export function ChangeRow(props: { /** Default is mutual */ type?: 'removal' | 'addition' | 'mutual'; indent?: boolean | number; + coordinate?: string; + annotations?: (coordinate: string) => ReactElement | null; }) { const incrementCounter = props.type === 'mutual' || props.type === undefined @@ -86,47 +88,55 @@ export function ChangeRow(props: { : props.type === 'removal' ? 'olddoc' : 'newdoc'; + const annotation = !!props.coordinate && props.annotations?.(props.coordinate); return ( - - + - + + {props.indent && + Array.from({ length: Number(props.indent) }).map((_, i) => ( + {TAB} + ))} + {props.children} + + + + {annotation && ( + + + + )} + ); } @@ -134,10 +144,7 @@ function Keyword(props: { term: string }) { return {props.term}; } -function Removal(props: { - children: React.ReactNode | string; - className?: string; -}): React.ReactNode { +function Removal(props: { children: ReactNode | string; className?: string }): ReactNode { return ( {props.children} @@ -177,13 +184,19 @@ function Description(props: { content: string; type?: 'removal' | 'addition' | 'mutual'; indent?: boolean | number; -}): React.ReactNode { + annotations: (coordinat: string) => ReactElement | null; +}): ReactNode { const lines = props.content.split('\n'); return ( <> {lines.map((line, index) => ( - + {line} @@ -193,11 +206,11 @@ function Description(props: { ); } -function FieldName(props: { name: string }): React.ReactNode { +function FieldName(props: { name: string }): ReactNode { return {props.name}; } -function FieldReturnType(props: { returnType: string }): React.ReactNode { +function FieldReturnType(props: { returnType: string }): ReactNode { return {props.returnType}; } @@ -246,24 +259,36 @@ export function DiffDescription( } export function DiffInputField({ + parentPath, oldField, newField, + annotations, }: | { + parentPath: string[]; oldField: GraphQLInputField | null; newField: GraphQLInputField; + annotations: (coordinat: string) => ReactElement | null; } | { + parentPath: string[]; oldField: GraphQLInputField; newField: GraphQLInputField | null; + annotations: (coordinat: string) => ReactElement | null; }) { const changeType = determineChangeType(oldField, newField); + const name = newField?.name ?? oldField?.name ?? ''; + const path = [...parentPath, name]; + // @todo consider allowing comments on nested coordinates. + // const directiveCoordinates = [...newField?.astNode?.directives ?? [], ...oldField?.astNode?.directives ?? []].map(d => { + // return [...path, `@${d.name.value}`].join('.') + // }); return ( <> - + - + @@ -283,21 +308,27 @@ function Change({ children: ReactNode; type?: 'addition' | 'removal' | 'mutual'; }): ReactNode { - const Klass = type === 'addition' ? Addition : type === 'removal' ? Removal : React.Fragment; + const Klass = type === 'addition' ? Addition : type === 'removal' ? Removal : Fragment; return {children}; } export function DiffField({ + parentPath, oldField, newField, + annotations, }: | { + parentPath: string[]; oldField: GraphQLField | null; newField: GraphQLField; + annotations: (coordinat: string) => ReactElement | null; } | { + parentPath: string[]; oldField: GraphQLField; newField: GraphQLField | null; + annotations: (coordinat: string) => ReactElement | null; }) { const hasNewArgs = !!newField?.args.length; const hasOldArgs = !!oldField?.args.length; @@ -309,6 +340,7 @@ export function DiffField({ : hasOldArgs ? 'removal' : 'mutual'; + const name = newField?.name ?? oldField?.name ?? ''; const changeType = determineChangeType(oldField, newField); const AfterArguments = ( <> @@ -320,12 +352,13 @@ export function DiffField({ /> ); + const path = [...parentPath, name]; return ( <> - + - + {hasArgs && ( @@ -334,9 +367,15 @@ export function DiffField({ )} {!hasArgs && AfterArguments} - + {!!hasArgs && ( - + <>) @@ -352,17 +391,24 @@ export function DirectiveName(props: { name: string }) { } export function DiffArguments(props: { + parentPath: string[]; oldArgs: readonly GraphQLArgument[]; newArgs: readonly GraphQLArgument[]; indent: boolean | number; + annotations: (coordinat: string) => ReactElement | null; }) { const { added, mutual, removed } = compareLists(props.oldArgs, props.newArgs); return ( <> {removed.map(a => ( - + - + @@ -370,12 +416,17 @@ export function DiffArguments(props: { - + ))} {added.map(a => ( - + - + @@ -383,12 +434,16 @@ export function DiffArguments(props: { - + ))} {mutual.map(a => ( - + - + @@ -399,7 +454,7 @@ export function DiffArguments(props: { oldDirectives={a.oldVersion.astNode?.directives ?? []} /> - + ))} ); @@ -497,10 +552,12 @@ export function DiffDirective( | { oldDirective: GraphQLDirective | null; newDirective: GraphQLDirective; + annotations: (coordinat: string) => ReactElement | null; } | { oldDirective: GraphQLDirective; newDirective: GraphQLDirective | null; + annotations: (coordinat: string) => ReactElement | null; }, ) { const name = props.newDirective?.name ?? props.oldDirective?.name ?? ''; @@ -525,11 +582,12 @@ export function DiffDirective( /> ); + const path = [`@${name}`]; return ( <> - +   @@ -546,9 +604,11 @@ export function DiffDirective( oldArgs={props.oldDirective?.args ?? []} newArgs={props.newDirective?.args ?? []} indent + parentPath={path} + annotations={props.annotations} /> {!!hasArgs && ( - + <>) @@ -627,9 +687,11 @@ function DiffDefaultValue({ export function SchemaDefinitionDiff({ oldSchema, newSchema, + annotations, }: { oldSchema: GraphQLSchema; newSchema: GraphQLSchema; + annotations: (coordinat: string) => ReactElement | null; }) { const defaultNames = { query: 'Query', @@ -718,16 +780,33 @@ export function SchemaDefinitionDiff({ extensions: {}, }, }; + // @todo verify using this as the path is correct. + const path = ['']; return ( <> - + {' {'} - - - + + + {'}'} ); @@ -737,38 +816,41 @@ export function SchemaDefinitionDiff({ export function DiffType({ oldType, newType, + annotations, }: | { oldType: GraphQLNamedType; newType: GraphQLNamedType | null; + annotations: (coordinat: string) => ReactElement | null; } | { oldType: GraphQLNamedType | null; newType: GraphQLNamedType; + annotations: (coordinat: string) => ReactElement | null; }) { if ((isEnumType(oldType) || oldType === null) && (isEnumType(newType) || newType === null)) { - return ; + return ; } if ((isUnionType(oldType) || oldType === null) && (isUnionType(newType) || newType === null)) { - return ; + return ; } if ( (isInputObjectType(oldType) || oldType === null) && (isInputObjectType(newType) || newType === null) ) { - return ; + return ; } if ((isObjectType(oldType) || oldType === null) && (isObjectType(newType) || newType === null)) { - return ; + return ; } if ( (isInterfaceType(oldType) || oldType === null) && (isInterfaceType(newType) || newType === null) ) { - return ; + return ; } if ((isScalarType(oldType) || oldType === null) && (isScalarType(newType) || newType === null)) { - return ; + return ; } } @@ -779,29 +861,34 @@ function TypeName({ name }: { name: string }) { export function DiffInputObject({ oldInput, newInput, + annotations, }: | { oldInput: GraphQLInputObjectType | null; newInput: GraphQLInputObjectType; + annotations: (coordinat: string) => ReactElement | null; } | { oldInput: GraphQLInputObjectType; newInput: GraphQLInputObjectType | null; + annotations: (coordinat: string) => ReactElement | null; }) { const { added, mutual, removed } = compareLists( Object.values(oldInput?.getFields() ?? {}), Object.values(newInput?.getFields() ?? {}), ); const changeType = determineChangeType(oldInput, newInput); + const name = oldInput?.name ?? newInput?.name ?? ''; + const path = [name]; return ( <> - +   - + {removed.map(a => ( - + ))} {added.map(a => ( - + ))} {mutual.map(a => ( - + ))} - {'}'} + + {'}'} + ); } @@ -826,29 +933,34 @@ export function DiffInputObject({ export function DiffObject({ oldObject, newObject, + annotations, }: | { oldObject: GraphQLObjectType | GraphQLInterfaceType | null; newObject: GraphQLObjectType | GraphQLInterfaceType; + annotations: (coordinat: string) => ReactElement | null; } | { oldObject: GraphQLObjectType | GraphQLInterfaceType; newObject: GraphQLObjectType | GraphQLInterfaceType | null; + annotations: (coordinat: string) => ReactElement | null; }) { const { added, mutual, removed } = compareLists( Object.values(oldObject?.getFields() ?? {}), Object.values(newObject?.getFields() ?? {}), ); + const name = oldObject?.name ?? newObject?.name ?? ''; const changeType = determineChangeType(oldObject, newObject); + const path = [name]; return ( <> - +   - + {removed.map(a => ( - + ))} {added.map(a => ( - + ))} {mutual.map(a => ( - + ))} - {'}'} + + {'}'} + ); } export function DiffEnumValue({ + parentPath, oldValue, newValue, + annotations, }: { + parentPath: string[]; oldValue: GraphQLEnumValue | null; newValue: GraphQLEnumValue | null; + annotations: (coordinat: string) => ReactElement | null; }) { const changeType = determineChangeType(oldValue, newValue); const name = oldValue?.name ?? newValue?.name ?? ''; return ( <> - + @@ -898,9 +1039,11 @@ export function DiffEnumValue({ export function DiffEnum({ oldEnum, newEnum, + annotations, }: { oldEnum: GraphQLEnumType | null; newEnum: GraphQLEnumType | null; + annotations: (coordinat: string) => ReactElement | null; }) { const { added, mutual, removed } = compareLists( oldEnum?.getValues() ?? [], @@ -908,29 +1051,50 @@ export function DiffEnum({ ); const changeType = determineChangeType(oldEnum, newEnum); + const name = oldEnum?.name ?? newEnum?.name ?? ''; return ( <> - +   - + {' {'} {removed.map(a => ( - + ))} {added.map(a => ( - + ))} {mutual.map(a => ( - + ))} - {'}'} + + {'}'} + ); } @@ -938,9 +1102,11 @@ export function DiffEnum({ export function DiffUnion({ oldUnion, newUnion, + annotations, }: { oldUnion: GraphQLUnionType | null; newUnion: GraphQLUnionType | null; + annotations: (coordinat: string) => ReactElement | null; }) { const { added, mutual, removed } = compareLists( oldUnion?.getTypes() ?? [], @@ -949,11 +1115,12 @@ export function DiffUnion({ const changeType = determineChangeType(oldUnion, newUnion); const name = oldUnion?.name ?? newUnion?.name ?? ''; + const path = [name]; return ( <> - +   @@ -962,35 +1129,50 @@ export function DiffUnion({ {' = '} {removed.map(a => ( - - + + | - + ))} {added.map(a => ( - - + + | - + ))} {mutual.map(a => ( - - + + | - + ))} ); @@ -999,29 +1181,34 @@ export function DiffUnion({ export function DiffScalar({ oldScalar, newScalar, + annotations, }: | { oldScalar: GraphQLScalarType; newScalar: GraphQLScalarType | null; + annotations: (coordinat: string) => ReactElement | null; } | { oldScalar: GraphQLScalarType | null; newScalar: GraphQLScalarType; + annotations: (coordinat: string) => ReactElement | null; }) { const changeType = determineChangeType(oldScalar, newScalar); + const name = newScalar?.name ?? oldScalar?.name ?? ''; return ( <> - +   - + @@ -1107,10 +1294,10 @@ export function DiffDirectiveUsage( )} {argumentElements.map((e, index) => ( - + {e} {index === argumentElements.length - 1 ? '' : ', '} - + ))} {hasArgs && ( @@ -1121,6 +1308,24 @@ export function DiffDirectiveUsage( ); } +const DiffTypeStr = ({ + oldType, + newType, +}: { + oldType: string | null; + newType: string | null; +}): ReactNode => { + if (oldType === newType) { + return newType; + } + return ( + <> + {oldType && {oldType}} + {newType && {newType}} + + ); +}; + export function DiffArgumentAST({ oldArg, newArg, @@ -1132,29 +1337,11 @@ export function DiffArgumentAST({ const oldType = oldArg && print(oldArg.value); const newType = newArg && print(newArg.value); - const DiffType = ({ - oldType, - newType, - }: { - oldType: string | null; - newType: string | null; - }): ReactNode => { - if (oldType === newType) { - return newType; - } - return ( - <> - {oldType && {oldType}} - {newType && {newType}} - - ); - }; - return ( <> :  - + ); } diff --git a/packages/web/app/src/components/proposal/schema-diff/schema-diff.tsx b/packages/web/app/src/components/proposal/schema-diff/schema-diff.tsx new file mode 100644 index 0000000000..7bf67d8435 --- /dev/null +++ b/packages/web/app/src/components/proposal/schema-diff/schema-diff.tsx @@ -0,0 +1,79 @@ +/* eslint-disable tailwindcss/no-custom-classname */ +import { ReactElement } from 'react'; +import type { GraphQLSchema } from 'graphql'; +import { isIntrospectionType, isSpecifiedDirective } from 'graphql'; +import { isPrimitive } from '@graphql-inspector/core/utils/graphql'; +import { compareLists } from './compare-lists'; +import { ChangeDocument, DiffDirective, DiffType, SchemaDefinitionDiff } from './components'; + +export function SchemaDiff({ + before, + after, + annotations, +}: { + before: GraphQLSchema; + after: GraphQLSchema; + annotations: (coordinate: string) => ReactElement | null; +}): JSX.Element { + const { + added: addedTypes, + mutual: mutualTypes, + removed: removedTypes, + } = compareLists( + Object.values(before.getTypeMap()).filter(t => !isPrimitive(t) && !isIntrospectionType(t)), + Object.values(after.getTypeMap()).filter(t => !isPrimitive(t) && !isIntrospectionType(t)), + ); + + const { + added: addedDirectives, + mutual: mutualDirectives, + removed: removedDirectives, + } = compareLists( + before.getDirectives().filter(d => !isSpecifiedDirective(d)), + after.getDirectives().filter(d => !isSpecifiedDirective(d)), + ); + + return ( + + {removedDirectives.map(d => ( + + ))} + {addedDirectives.map(d => ( + + ))} + {mutualDirectives.map(d => ( + + ))} + + {removedTypes.map(a => ( + + ))} + {addedTypes.map(a => ( + + ))} + {mutualTypes.map(a => ( + + ))} + + ); +} diff --git a/packages/web/app/src/pages/target-proposal-overview.tsx b/packages/web/app/src/pages/target-proposal-overview.tsx index 2df7b0ab7a..29c6f6b7f3 100644 --- a/packages/web/app/src/pages/target-proposal-overview.tsx +++ b/packages/web/app/src/pages/target-proposal-overview.tsx @@ -1,21 +1,18 @@ import { useQuery } from 'urql'; -import { ProposalSDL } from '@/components/proposal/proposal-sdl'; +import { Proposal } from '@/components/proposal'; import { stageToColor, userText } from '@/components/proposal/util'; import { Callout } from '@/components/ui/callout'; import { Subtitle, Title } from '@/components/ui/page'; import { Spinner } from '@/components/ui/spinner'; import { Tag, TimeAgo } from '@/components/v2'; import { graphql } from '@/gql'; +import { Change } from '@graphql-inspector/core'; -const ProposalOverviewQuery = graphql(/** GraphQL */ ` - query ProposalOverviewQuery($id: ID!) { - latestVersion { +const ProposalOverviewQuery = graphql(/* GraphQL */ ` + query ProposalOverviewQuery($reference: TargetReferenceInput!, $id: ID!) { + latestValidVersion(target: $reference) { id - isValid - } - latestValidVersion { - id - sdl + # sdl schemas { edges { node { @@ -39,12 +36,23 @@ const ProposalOverviewQuery = graphql(/** GraphQL */ ` commentsCount stage title - versions(input: { onlyLatest: true }) { + versions(first: 30, after: null, input: { onlyLatest: true }) { edges { + __typename node { id - schemaSDL serviceName + reviews { + edges { + cursor + node { + id + comments { + __typename + } + } + } + } } } } @@ -61,6 +69,27 @@ const ProposalOverviewQuery = graphql(/** GraphQL */ ` } `); +const ProposalChangesQuery = graphql(/* GraphQL */ ` + query ProposalChangesQuery($id: ID!) { + schemaProposal(input: { id: $id }) { + id + versions(after: null, input: { onlyLatest: true }) { + edges { + __typename + node { + id + serviceName + changes { + __typename + ...ProposalOverview_ChangeFragment + } + } + } + } + } + } +`); + export function TargetProposalOverviewPage(props: { organizationSlug: string; projectSlug: string; @@ -70,6 +99,20 @@ export function TargetProposalOverviewPage(props: { }) { const [query] = useQuery({ query: ProposalOverviewQuery, + variables: { + reference: { + bySelector: { + organizationSlug: props.organizationSlug, + projectSlug: props.projectSlug, + targetSlug: props.targetSlug, + }, + }, + id: props.proposalId, + }, + requestPolicy: 'cache-and-network', + }); + const [changesQuery] = useQuery({ + query: ProposalChangesQuery, variables: { id: props.proposalId, }, @@ -77,163 +120,12 @@ export function TargetProposalOverviewPage(props: { }); const proposal = query.data?.schemaProposal; - - // const diffSdl = /** GraphQL */ ` - // extend schema - // @link( - // url: "https://specs.apollo.dev/federation/v2.3" - // import: ["@key", "@shareable", "@inaccessible", "@tag"] - // ) - // @link(url: "https://specs.graphql-hive.com/hive/v1.0", import: ["@meta"]) - // @meta(name: "priority", content: "tier1") - - // directive @meta( - // name: String! - // content: String! - // ) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION - - // directive @myDirective(a: String!) on FIELD_DEFINITION - - // directive @hello on FIELD_DEFINITION - - // type Query { - // allProducts: [ProductItf] @meta(name: "owner", content: "hive-team") - // product(id: ID!): ProductItf - // } - - // interface ProductItf implements SkuItf @meta(name: "domain", content: "products") { - // id: ID! - // sku: String - // name: String - // package: String - // variation: ProductVariation - // dimensions: ProductDimension - // createdBy: User - // hidden: String @inaccessible - // oldField: String @deprecated(reason: "refactored out") - // } - - // interface SkuItf { - // sku: String - // } - - // type Product implements ProductItf & SkuItf - // @key(fields: "id") - // @key(fields: "sku package") - // @key(fields: "sku variation { id }") - // @meta(name: "owner", content: "product-team") { - // id: ID! @tag(name: "hi-from-products") - // sku: String @meta(name: "unique", content: "true") - // name: String @hello - // package: String - // variation: ProductVariation - // dimensions: ProductDimension - // createdBy: User - // hidden: String - // reviewsScore: Float! - // oldField: String - // } - - // enum ShippingClass { - // STANDARD - // EXPRESS - // } - - // type ProductVariation { - // id: ID! - // name: String - // } - - // type ProductDimension @shareable { - // size: String - // weight: Float - // } - - // type User @key(fields: "email") { - // email: ID! - // totalProductsCreated: Int @shareable - // } - // `; - - const sdl = /** GraphQL */ ` - extend schema - @link( - url: "https://specs.apollo.dev/federation/v2.3" - import: ["@key", "@shareable", "@inaccessible", "@tag"] - ) - @link(url: "https://specs.graphql-hive.com/hive/v1.0", import: ["@meta"]) - @meta(name: "priority", content: "tier1") - - directive @meta( - name: String! - content: String! - ) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION - - directive @myDirective(a: String!) on FIELD_DEFINITION - - directive @hello on FIELD_DEFINITION - - type Query { - allProducts: [ProductItf] @meta(name: "owner", content: "hive-team") - product(id: ID!): ProductItf - } - - interface ProductItf implements SkuItf @meta(name: "domain", content: "products") { - id: ID! - sku: String - name: String - package: String - variation: ProductVariation - dimensions: ProductDimension - createdBy: User - hidden: String @inaccessible - } - - interface SkuItf { - sku: String - } - - type Product implements ProductItf & SkuItf - @key(fields: "id") - @key(fields: "sku package") - @meta(name: "owner", content: "product-team") { - id: ID! @tag(name: "hi-from-products") - sku: String @meta(name: "unique", content: "true") - name: String @hello - package: String - variation: ProductVariation - dimensions: ProductDimension - createdBy: User - hidden: String - reviewsScore: Float! - } - - enum ShippingClass { - STANDARD - EXPRESS - OVERNIGHT - } - - type ProductVariation { - id: ID! - name: String - } - - type ProductDimension @shareable { - size: String - weight: Float - } - - type User @key(fields: "email") { - email: ID! - totalProductsCreated: Int @shareable - } - `; + const proposalVersion = proposal?.versions?.edges?.[0]; return (
{query.fetching && } - {proposal && ( + {proposalVersion && ( <> {userText(proposal.user)} proposed {' '} @@ -245,19 +137,41 @@ export function TargetProposalOverviewPage(props: {
Last updated
- {query.data?.latestVersion && query.data.latestVersion.isValid === false && ( - - The latest schema is invalid. Showing comparison against latest valid schema{' '} - {query.data.latestValidVersion?.id} - + {!query.data?.latestValidVersion && ( + This target does not have a valid schema version. )} - + {changesQuery.fetching ? ( + + ) : ( + changesQuery?.data?.schemaProposal?.versions?.edges?.length === 0 && ( + <>No proposal versions + ) + )} + {changesQuery?.data?.schemaProposal?.versions?.edges?.map(({ node: proposed }) => { + const existingSchema = query.data?.latestValidVersion?.schemas.edges.find( + ({ node }) => + (node.__typename === 'CompositeSchema' && node.service === proposed.serviceName) || + (node.__typename === 'SingleSchema' && proposed.serviceName == null), + )?.node.source; + if (existingSchema) { + return ( + + ); + } + + return ( +
+ {`Proposed changes cannot be applied to the ${proposed.serviceName ? `"${proposed.serviceName}" ` : ''}schema because it does not exist.`} +
+ ); + })} )}
diff --git a/packages/web/app/src/pages/target-proposals.tsx b/packages/web/app/src/pages/target-proposals.tsx index c4020ef11b..20e067d64c 100644 --- a/packages/web/app/src/pages/target-proposals.tsx +++ b/packages/web/app/src/pages/target-proposals.tsx @@ -57,9 +57,6 @@ const ProposalsQuery = graphql(` title stage updatedAt - diffSchema { - id - } user { id displayName diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 76acb6131e..15a5e10eeb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -124,6 +124,9 @@ importers: '@graphql-inspector/core': specifier: ^6.0.0 version: 6.2.1(graphql@16.9.0) + '@graphql-inspector/patch': + specifier: link:../graphql-inspector/packages/patch + version: link:../graphql-inspector/packages/patch '@manypkg/get-packages': specifier: 2.2.2 version: 2.2.2 @@ -1685,6 +1688,12 @@ importers: '@graphql-codegen/client-preset-swc-plugin': specifier: 0.2.0 version: 0.2.0 + '@graphql-inspector/core': + specifier: 6.2.1 + version: 6.2.1(graphql@16.9.0) + '@graphql-inspector/patch': + specifier: file:../../../../graphql-inspector/packages/patch + version: file:../graphql-inspector/packages/patch(graphql@16.9.0) '@graphql-tools/mock': specifier: 9.0.22 version: 9.0.22(graphql@16.9.0) @@ -3846,6 +3855,12 @@ packages: resolution: {integrity: sha512-2VLU90HjQyhwFgiU5uBs1+sBjs71Q42MWBQnkc3GSAaVCE+bzYRurO4bRS/z7UXl0hJsJda41z5QPOFeyeMMwQ==} engines: {node: '>=16.0.0'} + '@graphql-inspector/patch@file:../graphql-inspector/packages/patch': + resolution: {directory: ../graphql-inspector/packages/patch, type: directory} + engines: {node: '>=18.0.0'} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 + '@graphql-inspector/serve-command@4.0.3': resolution: {integrity: sha512-+8vovcRBFjrzZ+E5QTsG8GXSHm5q2czuqHosAU8bx6tfv2NM3FoSHUkctZulubA0+rodt2xrsD1L3R0ZCR8TBQ==} engines: {node: '>=16.0.0'} @@ -12984,9 +12999,6 @@ packages: object-inspect@1.12.3: resolution: {integrity: sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g==} - object-inspect@1.13.1: - resolution: {integrity: sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==} - object-inspect@1.13.2: resolution: {integrity: sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g==} engines: {node: '>= 0.4'} @@ -19090,6 +19102,12 @@ snapshots: std-env: 3.3.3 tslib: 2.6.2 + '@graphql-inspector/patch@file:../graphql-inspector/packages/patch(graphql@16.9.0)': + dependencies: + '@graphql-tools/utils': 10.8.6(graphql@16.9.0) + graphql: 16.9.0 + tslib: 2.6.2 + '@graphql-inspector/serve-command@4.0.3(@graphql-inspector/config@4.0.2(graphql@16.9.0))(@graphql-inspector/loaders@4.0.3(@babel/core@7.22.9)(@graphql-inspector/config@4.0.2(graphql@16.9.0))(graphql@16.9.0))(graphql@16.9.0)(yargs@17.7.2)': dependencies: '@graphql-inspector/commands': 4.0.3(@graphql-inspector/config@4.0.2(graphql@16.9.0))(@graphql-inspector/loaders@4.0.3(@babel/core@7.22.9)(@graphql-inspector/config@4.0.2(graphql@16.9.0))(graphql@16.9.0))(graphql@16.9.0)(yargs@17.7.2) @@ -26841,7 +26859,7 @@ snapshots: is-string: 1.0.7 is-typed-array: 1.1.12 is-weakref: 1.0.2 - object-inspect: 1.13.1 + object-inspect: 1.13.2 object-keys: 1.1.1 object.assign: 4.1.5 regexp.prototype.flags: 1.5.1 @@ -30976,8 +30994,6 @@ snapshots: object-inspect@1.12.3: {} - object-inspect@1.13.1: {} - object-inspect@1.13.2: {} object-is@1.1.5: @@ -32772,7 +32788,7 @@ snapshots: call-bind: 1.0.7 es-errors: 1.3.0 get-intrinsic: 1.2.4 - object-inspect: 1.13.1 + object-inspect: 1.13.2 siginfo@2.0.0: {} From 0f158a239e6f3b751c34bb59610a6682682cfb8f Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Thu, 21 Aug 2025 18:45:03 -0700 Subject: [PATCH 25/54] Add more spacing --- .../app/src/components/proposal/schema-diff/components.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/web/app/src/components/proposal/schema-diff/components.tsx b/packages/web/app/src/components/proposal/schema-diff/components.tsx index c26cc6db77..dd9a32c4a8 100644 --- a/packages/web/app/src/components/proposal/schema-diff/components.tsx +++ b/packages/web/app/src/components/proposal/schema-diff/components.tsx @@ -689,8 +689,8 @@ export function SchemaDefinitionDiff({ newSchema, annotations, }: { - oldSchema: GraphQLSchema; - newSchema: GraphQLSchema; + oldSchema: GraphQLSchema | undefined | null; + newSchema: GraphQLSchema | undefined | null; annotations: (coordinat: string) => ReactElement | null; }) { const defaultNames = { @@ -782,9 +782,11 @@ export function SchemaDefinitionDiff({ }; // @todo verify using this as the path is correct. const path = ['']; + const changeType = determineChangeType(oldSchema, newSchema); return ( <> + {' {'} From c42e56bd4c3ac20f87c507c1d3c79f678d7ee363 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Fri, 22 Aug 2025 20:48:38 -0700 Subject: [PATCH 26/54] adjust proposal layout; add supergraph and details pages --- .../src/modules/proposals/module.graphql.ts | 2 + .../resolvers/Query/schemaProposal.ts | 81 +----- .../web/app/src/components/proposal/index.tsx | 68 ++++-- .../proposal/schema-diff/compare-lists.ts | 1 + .../proposal/schema-diff/components.tsx | 33 ++- .../proposal/schema-diff/schema-diff.tsx | 22 +- .../target/history/errors-and-changes.tsx | 2 +- .../app/src/pages/target-proposal-details.tsx | 97 ++++++++ .../app/src/pages/target-proposal-history.tsx | 9 - .../app/src/pages/target-proposal-layout.tsx | 231 +++++++++++++----- .../src/pages/target-proposal-overview.tsx | 179 -------------- .../app/src/pages/target-proposal-schema.tsx | 137 +++++++++++ .../src/pages/target-proposal-supergraph.tsx | 123 ++++++++++ pnpm-lock.yaml | 20 +- 14 files changed, 619 insertions(+), 386 deletions(-) create mode 100644 packages/web/app/src/pages/target-proposal-details.tsx delete mode 100644 packages/web/app/src/pages/target-proposal-history.tsx delete mode 100644 packages/web/app/src/pages/target-proposal-overview.tsx create mode 100644 packages/web/app/src/pages/target-proposal-schema.tsx create mode 100644 packages/web/app/src/pages/target-proposal-supergraph.tsx diff --git a/packages/services/api/src/modules/proposals/module.graphql.ts b/packages/services/api/src/modules/proposals/module.graphql.ts index 7b07e7ba40..3044271083 100644 --- a/packages/services/api/src/modules/proposals/module.graphql.ts +++ b/packages/services/api/src/modules/proposals/module.graphql.ts @@ -147,6 +147,8 @@ export default gql` """ title: String + description: String + """ When the proposal was last modified. Adding a review or comment does not count. """ diff --git a/packages/services/api/src/modules/proposals/resolvers/Query/schemaProposal.ts b/packages/services/api/src/modules/proposals/resolvers/Query/schemaProposal.ts index 5a98a0267a..776b668dca 100644 --- a/packages/services/api/src/modules/proposals/resolvers/Query/schemaProposal.ts +++ b/packages/services/api/src/modules/proposals/resolvers/Query/schemaProposal.ts @@ -4,83 +4,6 @@ import type { SeverityLevelType, } from './../../../../__generated__/types'; -// const schemaSDL = /* GraphQL */ ` -// schema { -// query: Query -// } -// input AInput { -// """ -// a -// """ -// a: String = "1" -// b: String! -// } -// input ListInput { -// a: [String] = ["foo"] -// b: [String] = ["bar"] -// } -// """ -// The Query Root of this schema -// """ -// type Query { -// """ -// Just a simple string -// """ -// a(anArg: String): String! -// b: BType -// } -// type BType { -// a: String -// } -// type CType { -// a: String @deprecated(reason: "whynot") -// c: Int! -// d(arg: Int): String -// } -// union MyUnion = CType | BType -// interface AnInterface { -// interfaceField: Int! -// } -// interface AnotherInterface { -// anotherInterfaceField: String -// } -// type WithInterfaces implements AnInterface & AnotherInterface { -// a: String! -// } -// type WithArguments { -// a( -// """ -// Meh -// """ -// a: Int -// b: String -// ): String -// b(arg: Int = 1): String -// } -// enum Options { -// A -// B -// C -// E -// F @deprecated(reason: "Old") -// } -// """ -// Old -// """ -// directive @yolo( -// """ -// Included when true. -// """ -// someArg: Boolean! -// anotherArg: String! -// willBeRemoved: Boolean! -// ) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT -// type WillBeRemoved { -// a: String -// } -// directive @willBeRemoved on FIELD -// `; - const changes = [ { criticality: { level: 'NON_BREAKING' }, @@ -698,7 +621,9 @@ export const schemaProposal: NonNullable = ( stage: 'OPEN', updatedAt: Date.now(), commentsCount: 5, - title: 'This adds some stuff to the thing.', + title: 'Add some stuff to the thing', + description: + 'This makes a bunch of changes. Here is a description of all the stuff and things and whatsits.', versions: { pageInfo: { startCursor: 'start', diff --git a/packages/web/app/src/components/proposal/index.tsx b/packages/web/app/src/components/proposal/index.tsx index df5407d1f1..6f12c88dd0 100644 --- a/packages/web/app/src/components/proposal/index.tsx +++ b/packages/web/app/src/components/proposal/index.tsx @@ -1,3 +1,4 @@ +import { useMemo } from 'react'; import { buildSchema } from 'graphql'; import { FragmentType, graphql, useFragment } from '@/gql'; import type { Change } from '@graphql-inspector/core'; @@ -33,7 +34,8 @@ const ProposalOverview_ReviewsFragment = graphql(/** GraphQL */ ` } `); -const ProposalOverview_ChangeFragment = graphql(/* GraphQL */ ` +/** Move to utils? */ +export const ProposalOverview_ChangeFragment = graphql(/* GraphQL */ ` fragment ProposalOverview_ChangeFragment on SchemaChange { message path @@ -463,7 +465,8 @@ const ProposalOverview_ChangeFragment = graphql(/* GraphQL */ ` } `); -function toUpperSnakeCase(str: string) { +/** Move to utils */ +export function toUpperSnakeCase(str: string) { // Use a regular expression to find uppercase letters and insert underscores // The 'g' flag ensures all occurrences are replaced. // The 'replace' function uses a callback to add an underscore before the matched uppercase letter. @@ -485,6 +488,37 @@ export function Proposal(props: { latestProposalVersionId: string; reviews: FragmentType | null; }) { + const before = useMemo(() => { + return buildSchema(props.baseSchemaSDL, { assumeValid: true, assumeValidSDL: true }); + }, [props.baseSchemaSDL]); + + const changes = useMemo(() => { + return ( + props.changes + ?.map((change): Change | null => { + const c = useFragment(ProposalOverview_ChangeFragment, change); + if (c) { + return { + criticality: { + // isSafeBasedOnUsage: , + // reason: , + level: c.severityLevel as any, + }, + message: c.message, + meta: c.meta, + type: (c.meta && toUpperSnakeCase(c.meta?.__typename)) ?? '', // convert to upper snake + path: c.path?.join('.'), + }; + } + return null; + }) + .filter(c => !!c) ?? [] + ); + }, [props.changes]); + + const after = useMemo(() => { + return patchSchema(before, changes, { throwOnError: false }); + }, [before, changes]); /** * Reviews can change position because the coordinate changes... placing them out of order from their original line numbers. * Because of this, we have to fetch every single page of comments... @@ -492,8 +526,8 @@ export function Proposal(props: { * * Odds are there will never be so many reviews/comments that this is even a problem. */ - const reviewsConnection = useFragment(ProposalOverview_ReviewsFragment, props.reviews); - try { + const annotations = useMemo(() => { + const reviewsConnection = useFragment(ProposalOverview_ReviewsFragment, props.reviews); const serviceReviews = reviewsConnection?.edges?.filter(edge => { const { schemaProposalVersion } = edge.node; @@ -512,7 +546,7 @@ export function Proposal(props: { return result; }, new Map>()); - const annotations = (coordinate: string) => { + return (coordinate: string) => { const reviews = reviewssByCoordinate.get(coordinate); if (reviews) { return ( @@ -521,29 +555,9 @@ export function Proposal(props: { } return null; }; + }, [props.reviews]); - const before = buildSchema(props.baseSchemaSDL, { assumeValid: true, assumeValidSDL: true }); - const changes = - props.changes - ?.map((change): Change | null => { - const c = useFragment(ProposalOverview_ChangeFragment, change); - if (c) { - return { - criticality: { - // isSafeBasedOnUsage: , - // reason: , - level: c.severityLevel as any, - }, - message: c.message, - meta: c.meta, - type: (c.meta && toUpperSnakeCase(c.meta?.__typename)) ?? '', // convert to upper snake - path: c.path?.join('.'), - }; - } - return null; - }) - .filter(c => !!c) ?? []; - const after = patchSchema(before, changes, { throwOnError: false }); + try { return ; } catch (e: unknown) { return ( diff --git a/packages/web/app/src/components/proposal/schema-diff/compare-lists.ts b/packages/web/app/src/components/proposal/schema-diff/compare-lists.ts index 6df6613a3b..87b2f651d7 100644 --- a/packages/web/app/src/components/proposal/schema-diff/compare-lists.ts +++ b/packages/web/app/src/components/proposal/schema-diff/compare-lists.ts @@ -65,6 +65,7 @@ function extractName(name: string | NameNode): string { return name.value; } +/** @todo support repeat directives */ export function compareLists( oldList: readonly T[], newList: readonly T[], diff --git a/packages/web/app/src/components/proposal/schema-diff/components.tsx b/packages/web/app/src/components/proposal/schema-diff/components.tsx index dd9a32c4a8..19d2ec0c7a 100644 --- a/packages/web/app/src/components/proposal/schema-diff/components.tsx +++ b/packages/web/app/src/components/proposal/schema-diff/components.tsx @@ -1225,15 +1225,23 @@ export function DiffDirectiveUsages(props: { return ( <> - {removed.map(d => ( - + {removed.map((d, index) => ( + ))} - {added.map(d => ( - + {added.map((d, index) => ( + ))} - {mutual.map(d => ( + {mutual.map((d, index) => ( @@ -1269,18 +1277,18 @@ export function DiffDirectiveUsage( const changeType = determineChangeType(props.oldDirective, props.newDirective); const { added, mutual, removed } = compareLists(oldArgs, newArgs); const argumentElements = [ - ...removed.map(r => ( - + ...removed.map((r, index) => ( + )), - ...added.map(r => ( - + ...added.map((r, index) => ( + )), - ...mutual.map(r => ( - + ...mutual.map((r, index) => ( + )), @@ -1338,7 +1346,6 @@ export function DiffArgumentAST({ const name = oldArg?.name.value ?? newArg?.name.value ?? ''; const oldType = oldArg && print(oldArg.value); const newType = newArg && print(newArg.value); - return ( <> diff --git a/packages/web/app/src/components/proposal/schema-diff/schema-diff.tsx b/packages/web/app/src/components/proposal/schema-diff/schema-diff.tsx index 7bf67d8435..c32697e030 100644 --- a/packages/web/app/src/components/proposal/schema-diff/schema-diff.tsx +++ b/packages/web/app/src/components/proposal/schema-diff/schema-diff.tsx @@ -1,5 +1,5 @@ /* eslint-disable tailwindcss/no-custom-classname */ -import { ReactElement } from 'react'; +import { ReactElement, useMemo } from 'react'; import type { GraphQLSchema } from 'graphql'; import { isIntrospectionType, isSpecifiedDirective } from 'graphql'; import { isPrimitive } from '@graphql-inspector/core/utils/graphql'; @@ -19,19 +19,23 @@ export function SchemaDiff({ added: addedTypes, mutual: mutualTypes, removed: removedTypes, - } = compareLists( - Object.values(before.getTypeMap()).filter(t => !isPrimitive(t) && !isIntrospectionType(t)), - Object.values(after.getTypeMap()).filter(t => !isPrimitive(t) && !isIntrospectionType(t)), - ); + } = useMemo(() => { + return compareLists( + Object.values(before.getTypeMap()).filter(t => !isPrimitive(t) && !isIntrospectionType(t)), + Object.values(after.getTypeMap()).filter(t => !isPrimitive(t) && !isIntrospectionType(t)), + ); + }, [before, after]); const { added: addedDirectives, mutual: mutualDirectives, removed: removedDirectives, - } = compareLists( - before.getDirectives().filter(d => !isSpecifiedDirective(d)), - after.getDirectives().filter(d => !isSpecifiedDirective(d)), - ); + } = useMemo(() => { + return compareLists( + before.getDirectives().filter(d => !isSpecifiedDirective(d)), + after.getDirectives().filter(d => !isSpecifiedDirective(d)), + ); + }, [before, after]); return ( 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 index 22e317c03a..653db8f089 100644 --- a/packages/web/app/src/components/target/history/errors-and-changes.tsx +++ b/packages/web/app/src/components/target/history/errors-and-changes.tsx @@ -95,7 +95,7 @@ const ChangesBlock_SchemaChangeWithUsageFragment = graphql(` } `); -const ChangesBlock_SchemaChangeFragment = graphql(` +export const ChangesBlock_SchemaChangeFragment = graphql(` fragment ChangesBlock_SchemaChangeFragment on SchemaChange { path message(withSafeBasedOnUsageNote: false) diff --git a/packages/web/app/src/pages/target-proposal-details.tsx b/packages/web/app/src/pages/target-proposal-details.tsx new file mode 100644 index 0000000000..bcd51121c9 --- /dev/null +++ b/packages/web/app/src/pages/target-proposal-details.tsx @@ -0,0 +1,97 @@ +import { Fragment } from 'react'; +import { useQuery } from 'urql'; +import { + ChangesBlock, + ChangesBlock_SchemaChangeFragment, +} from '@/components/target/history/errors-and-changes'; +import { Spinner } from '@/components/ui/spinner'; +import { graphql, useFragment } from '@/gql'; +import { SeverityLevelType } from '@/gql/graphql'; + +const ProposalDetailsQuery = graphql(/* GraphQL */ ` + query ProposalDetailsQuery($id: ID!) { + schemaProposal(input: { id: $id }) { + id + versions(after: null, input: { onlyLatest: true }) { + edges { + __typename + node { + id + serviceName + changes { + __typename + ...ChangesBlock_SchemaChangeFragment + } + } + } + } + } + } +`); + +export function TargetProposalDetailsPage(props: { + organizationSlug: string; + projectSlug: string; + targetSlug: string; + proposalId: string; + page: string; +}) { + const [query] = useQuery({ + query: ProposalDetailsQuery, + variables: { + id: props.proposalId, + }, + requestPolicy: 'cache-and-network', + }); + + return ( +
+ {query.fetching && } + {query.data?.schemaProposal?.versions?.edges?.map(edge => { + const breakingChanges = edge.node.changes.filter((c): c is NonNullable => { + const change = useFragment(ChangesBlock_SchemaChangeFragment, c); + return !!c && change?.severityLevel === SeverityLevelType.Breaking; + }); + const dangerousChanges = edge.node.changes.filter((c): c is NonNullable => { + const change = useFragment(ChangesBlock_SchemaChangeFragment, c); + return !!c && change?.severityLevel === SeverityLevelType.Dangerous; + }); + const safeChanges = edge.node.changes.filter((c): c is NonNullable => { + const change = useFragment(ChangesBlock_SchemaChangeFragment, c); + return !!c && change?.severityLevel === SeverityLevelType.Safe; + }); + return ( + + + + + + ); + })} +
+ ); +} diff --git a/packages/web/app/src/pages/target-proposal-history.tsx b/packages/web/app/src/pages/target-proposal-history.tsx deleted file mode 100644 index bc7abc424c..0000000000 --- a/packages/web/app/src/pages/target-proposal-history.tsx +++ /dev/null @@ -1,9 +0,0 @@ -export function TargetProposalHistoryPage(props: { - organizationSlug: string; - projectSlug: string; - targetSlug: string; - proposalId: string; - page: string; -}) { - return
History
; -} diff --git a/packages/web/app/src/pages/target-proposal-layout.tsx b/packages/web/app/src/pages/target-proposal-layout.tsx index c719b6aab9..835f1b6546 100644 --- a/packages/web/app/src/pages/target-proposal-layout.tsx +++ b/packages/web/app/src/pages/target-proposal-layout.tsx @@ -1,15 +1,63 @@ +import { useQuery } from 'urql'; +import { stageToColor, userText } from '@/components/proposal/util'; +import { Subtitle, Title } from '@/components/ui/page'; +import { Spinner } from '@/components/ui/spinner'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Tag, TimeAgo } from '@/components/v2'; +import { graphql } from '@/gql'; import { Link } from '@tanstack/react-router'; +import { TargetProposalDetailsPage } from './target-proposal-details'; import { TargetProposalEditPage } from './target-proposal-edit'; -import { TargetProposalHistoryPage } from './target-proposal-history'; -import { TargetProposalOverviewPage } from './target-proposal-overview'; +import { TargetProposalSchemaPage } from './target-proposal-schema'; +import { TargetProposalSupergraphPage } from './target-proposal-supergraph'; enum Page { - OVERVIEW = 'overview', - HISTORY = 'history', + SCHEMA = 'schema', + SUPERGRAPH = 'supergraph', + DETAILS = 'details', EDIT = 'edit', } +const ProposalQuery = graphql(/* GraphQL */ ` + query ProposalQuery($id: ID!) { + schemaProposal(input: { id: $id }) { + id + createdAt + updatedAt + commentsCount + stage + title + description + versions(first: 30, after: null, input: { onlyLatest: true }) { + edges { + __typename + node { + id + serviceName + reviews { + edges { + cursor + node { + id + comments { + __typename + } + } + } + } + } + } + } + user { + id + email + displayName + fullName + } + } + } +`); + export function TargetProposalLayoutPage(props: { organizationSlug: string; projectSlug: string; @@ -17,68 +65,131 @@ export function TargetProposalLayoutPage(props: { proposalId: string; page: string; }) { + const [query] = useQuery({ + query: ProposalQuery, + variables: { + reference: { + bySelector: { + organizationSlug: props.organizationSlug, + projectSlug: props.projectSlug, + targetSlug: props.targetSlug, + }, + }, + id: props.proposalId, + }, + requestPolicy: 'cache-and-network', + }); + const proposal = query.data?.schemaProposal; return (
- - - - - Overview - - - - - History - - - - - Edit - - - - -
- + {query.fetching && } + {proposal && ( + <> + {/* @todo version dropdown Last updated */} + {/* @todo stage dropdown {proposal.stage} */} +
+ {proposal.title}
- - -
- +
{proposal.description}
+
+ {userText(proposal.user)} proposed {' '}
- - - - - + + )} +
); } +function MainContent(props: { + organizationSlug: string; + projectSlug: string; + targetSlug: string; + proposalId: string; + page: string; +}) { + return ( + + + + + Schema + + + + + Supergraph Preview + + + + + Details + + + + + Edit + + + + +
+ +
+
+ +
+ +
+
+ +
+ +
+
+ +
+ +
+
+
+ ); +} + export const ProposalPage = Page; diff --git a/packages/web/app/src/pages/target-proposal-overview.tsx b/packages/web/app/src/pages/target-proposal-overview.tsx deleted file mode 100644 index 29c6f6b7f3..0000000000 --- a/packages/web/app/src/pages/target-proposal-overview.tsx +++ /dev/null @@ -1,179 +0,0 @@ -import { useQuery } from 'urql'; -import { Proposal } from '@/components/proposal'; -import { stageToColor, userText } from '@/components/proposal/util'; -import { Callout } from '@/components/ui/callout'; -import { Subtitle, Title } from '@/components/ui/page'; -import { Spinner } from '@/components/ui/spinner'; -import { Tag, TimeAgo } from '@/components/v2'; -import { graphql } from '@/gql'; -import { Change } from '@graphql-inspector/core'; - -const ProposalOverviewQuery = graphql(/* GraphQL */ ` - query ProposalOverviewQuery($reference: TargetReferenceInput!, $id: ID!) { - latestValidVersion(target: $reference) { - id - # sdl - schemas { - edges { - node { - ... on CompositeSchema { - id - source - service - } - ... on SingleSchema { - id - source - } - } - } - } - } - schemaProposal(input: { id: $id }) { - id - createdAt - updatedAt - commentsCount - stage - title - versions(first: 30, after: null, input: { onlyLatest: true }) { - edges { - __typename - node { - id - serviceName - reviews { - edges { - cursor - node { - id - comments { - __typename - } - } - } - } - } - } - } - user { - id - email - displayName - fullName - } - reviews { - ...ProposalOverview_ReviewsFragment - } - } - } -`); - -const ProposalChangesQuery = graphql(/* GraphQL */ ` - query ProposalChangesQuery($id: ID!) { - schemaProposal(input: { id: $id }) { - id - versions(after: null, input: { onlyLatest: true }) { - edges { - __typename - node { - id - serviceName - changes { - __typename - ...ProposalOverview_ChangeFragment - } - } - } - } - } - } -`); - -export function TargetProposalOverviewPage(props: { - organizationSlug: string; - projectSlug: string; - targetSlug: string; - proposalId: string; - page: string; -}) { - const [query] = useQuery({ - query: ProposalOverviewQuery, - variables: { - reference: { - bySelector: { - organizationSlug: props.organizationSlug, - projectSlug: props.projectSlug, - targetSlug: props.targetSlug, - }, - }, - id: props.proposalId, - }, - requestPolicy: 'cache-and-network', - }); - const [changesQuery] = useQuery({ - query: ProposalChangesQuery, - variables: { - id: props.proposalId, - }, - requestPolicy: 'cache-and-network', - }); - - const proposal = query.data?.schemaProposal; - const proposalVersion = proposal?.versions?.edges?.[0]; - - return ( -
- {query.fetching && } - {proposalVersion && ( - <> - - {userText(proposal.user)} proposed {' '} - -
- {proposal.title} - {proposal.stage} -
-
- Last updated -
- {!query.data?.latestValidVersion && ( - This target does not have a valid schema version. - )} - {changesQuery.fetching ? ( - - ) : ( - changesQuery?.data?.schemaProposal?.versions?.edges?.length === 0 && ( - <>No proposal versions - ) - )} - {changesQuery?.data?.schemaProposal?.versions?.edges?.map(({ node: proposed }) => { - const existingSchema = query.data?.latestValidVersion?.schemas.edges.find( - ({ node }) => - (node.__typename === 'CompositeSchema' && node.service === proposed.serviceName) || - (node.__typename === 'SingleSchema' && proposed.serviceName == null), - )?.node.source; - if (existingSchema) { - return ( - - ); - } - - return ( -
- {`Proposed changes cannot be applied to the ${proposed.serviceName ? `"${proposed.serviceName}" ` : ''}schema because it does not exist.`} -
- ); - })} - - )} -
- ); -} diff --git a/packages/web/app/src/pages/target-proposal-schema.tsx b/packages/web/app/src/pages/target-proposal-schema.tsx new file mode 100644 index 0000000000..57f9f75829 --- /dev/null +++ b/packages/web/app/src/pages/target-proposal-schema.tsx @@ -0,0 +1,137 @@ +import { useQuery } from 'urql'; +import { Proposal } from '@/components/proposal'; +import { Callout } from '@/components/ui/callout'; +import { Spinner } from '@/components/ui/spinner'; +import { graphql } from '@/gql'; + +const ProposalSchemaQuery = graphql(/* GraphQL */ ` + query ProposalSchemaQuery($reference: TargetReferenceInput!, $id: ID!) { + latestValidVersion(target: $reference) { + id + # sdl + schemas { + edges { + node { + ... on CompositeSchema { + id + source + service + } + ... on SingleSchema { + id + source + } + } + } + } + } + schemaProposal(input: { id: $id }) { + id + reviews { + ...ProposalOverview_ReviewsFragment + } + } + } +`); + +const ProposalChangesQuery = graphql(/* GraphQL */ ` + query ProposalChangesQuery($id: ID!) { + schemaProposal(input: { id: $id }) { + id + versions(after: null, input: { onlyLatest: true }) { + edges { + __typename + node { + id + serviceName + changes { + __typename + ...ProposalOverview_ChangeFragment + } + } + } + } + } + } +`); + +export function TargetProposalSchemaPage(props: { + organizationSlug: string; + projectSlug: string; + targetSlug: string; + proposalId: string; + page: string; +}) { + const [query] = useQuery({ + query: ProposalSchemaQuery, + variables: { + reference: { + bySelector: { + organizationSlug: props.organizationSlug, + projectSlug: props.projectSlug, + targetSlug: props.targetSlug, + }, + }, + id: props.proposalId, + }, + requestPolicy: 'cache-and-network', + }); + const [changesQuery] = useQuery({ + query: ProposalChangesQuery, + variables: { + id: props.proposalId, + }, + requestPolicy: 'cache-and-network', + }); + + const proposal = query.data?.schemaProposal; + const proposalVersion = changesQuery.data?.schemaProposal?.versions?.edges?.[0]; + + if (query.fetching || changesQuery.fetching) { + return ; + } + + if ( + !query.stale && + !changesQuery.stale && + query.data?.__typename && + !query.data.latestValidVersion?.schemas + ) { + return This target does not have a valid schema version.; + } + + if (proposalVersion) { + return ( +
+ {changesQuery?.data?.schemaProposal?.versions?.edges?.length === 0 && ( + <>No proposal versions + )} + {changesQuery?.data?.schemaProposal?.versions?.edges?.map(({ node: proposed }) => { + const existingSchema = query.data?.latestValidVersion?.schemas.edges.find( + ({ node }) => + (node.__typename === 'CompositeSchema' && node.service === proposed.serviceName) || + (node.__typename === 'SingleSchema' && proposed.serviceName == null), + )?.node.source; + if (existingSchema) { + return ( + + ); + } + + // return ( + //
+ // {`Proposed changes cannot be applied to the ${proposed.serviceName ? `"${proposed.serviceName}" ` : ''}schema because it does not exist.`} + //
+ // ); + })} +
+ ); + } +} diff --git a/packages/web/app/src/pages/target-proposal-supergraph.tsx b/packages/web/app/src/pages/target-proposal-supergraph.tsx new file mode 100644 index 0000000000..c411c5c19d --- /dev/null +++ b/packages/web/app/src/pages/target-proposal-supergraph.tsx @@ -0,0 +1,123 @@ +import { buildSchema } from 'graphql'; +import { useQuery } from 'urql'; +import { ProposalOverview_ChangeFragment, toUpperSnakeCase } from '@/components/proposal'; +import { SchemaDiff } from '@/components/proposal/schema-diff/schema-diff'; +import { Spinner } from '@/components/ui/spinner'; +import { FragmentType, graphql, useFragment } from '@/gql'; +import { Change } from '@graphql-inspector/core'; +import { patchSchema } from '@graphql-inspector/patch'; + +// @todo compose the changes subgraphs instead of modifying the supergraph... +const ProposalSupergraphChangesQuery = graphql(/* GraphQL */ ` + query ProposalSupergraphChangesQuery($id: ID!) { + schemaProposal(input: { id: $id }) { + id + versions(after: null, input: { onlyLatest: true }) { + edges { + node { + id + serviceName + changes { + ...ProposalOverview_ChangeFragment + } + } + } + } + } + } +`); + +const ProposalSupergraphLatestQuery = graphql(/* GraphQL */ ` + query ProposalSupergraphLatestQuery($reference: TargetReferenceInput!) { + latestValidVersion(target: $reference) { + id + supergraph + } + } +`); + +export function TargetProposalSupergraphPage(props: { + organizationSlug: string; + projectSlug: string; + targetSlug: string; + proposalId: string; + page: string; +}) { + const [query] = useQuery({ + query: ProposalSupergraphChangesQuery, + variables: { + id: props.proposalId, + }, + requestPolicy: 'cache-and-network', + }); + const [latestQuery] = useQuery({ + query: ProposalSupergraphLatestQuery, + variables: { + reference: { + bySelector: { + organizationSlug: props.organizationSlug, + projectSlug: props.projectSlug, + targetSlug: props.targetSlug, + }, + }, + }, + }); + + // @todo use pagination to collect all + const allChanges: (FragmentType | null | undefined)[] = + []; + query?.data?.schemaProposal?.versions?.edges?.map(({ node: { changes } }) => { + allChanges.push(...changes); + }); + + return ( +
+ {query.fetching || (query.fetching && )} + +
+ ); +} + +function SupergraphDiff(props: { + baseSchemaSDL: string; + changes: (FragmentType | null | undefined)[] | null; +}) { + if (props.baseSchemaSDL.length === 0) { + return null; + } + try { + const before = buildSchema(props.baseSchemaSDL, { assumeValid: true, assumeValidSDL: true }); + const changes = + props.changes + ?.map((change): Change | null => { + const c = useFragment(ProposalOverview_ChangeFragment, change); + if (c) { + return { + criticality: { + // isSafeBasedOnUsage: , + // reason: , + level: c.severityLevel as any, + }, + message: c.message, + meta: c.meta, + type: (c.meta && toUpperSnakeCase(c.meta?.__typename)) ?? '', // convert to upper snake + path: c.path?.join('.'), + }; + } + return null; + }) + .filter(c => !!c) ?? []; + const after = patchSchema(before, changes, { throwOnError: false }); + return null} />; + } catch (e: unknown) { + return ( + <> +
Invalid SDL
+
{e instanceof Error ? e.message : String(e)}
+ + ); + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 15a5e10eeb..15b6d5af5f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16593,8 +16593,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 @@ -16701,11 +16701,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 @@ -16744,6 +16744,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.723.0(@aws-sdk/client-sts@3.723.0)': @@ -16877,11 +16878,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 @@ -16920,7 +16921,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.723.0': @@ -17034,7 +17034,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 @@ -17153,7 +17153,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.11 '@smithy/types': 3.7.2 @@ -17328,7 +17328,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.11 '@smithy/shared-ini-file-loader': 3.1.12 From 4cc835f37c9b02b81ee6f48f7510112100676ff4 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Fri, 22 Aug 2025 21:08:50 -0700 Subject: [PATCH 27/54] Fix proposal navigation w/pages --- packages/web/app/src/pages/target-proposals.tsx | 5 ++++- packages/web/app/src/router.tsx | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/web/app/src/pages/target-proposals.tsx b/packages/web/app/src/pages/target-proposals.tsx index 20e067d64c..1fdae90523 100644 --- a/packages/web/app/src/pages/target-proposals.tsx +++ b/packages/web/app/src/pages/target-proposals.tsx @@ -257,7 +257,10 @@ const ProposalsListPage = (props: { targetSlug: props.targetSlug, proposalId: proposal.id, }} - search={search} + search={{ + ...search, + page: undefined, + }} variant="secondary" >
diff --git a/packages/web/app/src/router.tsx b/packages/web/app/src/router.tsx index 9e67575591..76680f9a4b 100644 --- a/packages/web/app/src/router.tsx +++ b/packages/web/app/src/router.tsx @@ -879,7 +879,7 @@ const targetProposalRoute = createRoute({ projectSlug={projectSlug} targetSlug={targetSlug} proposalId={proposalId} - page={page ?? (ProposalPage.OVERVIEW as string)} + page={page ?? (ProposalPage.SCHEMA as string)} /> ); }, From eaeda8db6c90fe90d72c0096b3aafea9d78cefd3 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Mon, 25 Aug 2025 18:05:02 -0700 Subject: [PATCH 28/54] Add incompatible and ignored changes to schema page --- .../web/app/src/components/proposal/index.tsx | 105 ++++++++++++++---- .../app/src/pages/target-proposal-layout.tsx | 6 +- .../app/src/pages/target-proposal-schema.tsx | 2 +- 3 files changed, 86 insertions(+), 27 deletions(-) diff --git a/packages/web/app/src/components/proposal/index.tsx b/packages/web/app/src/components/proposal/index.tsx index 6f12c88dd0..142c585cc7 100644 --- a/packages/web/app/src/components/proposal/index.tsx +++ b/packages/web/app/src/components/proposal/index.tsx @@ -3,6 +3,17 @@ import { buildSchema } from 'graphql'; import { FragmentType, graphql, useFragment } from '@/gql'; import type { Change } from '@graphql-inspector/core'; import { patchSchema } from '@graphql-inspector/patch'; +import { NoopError } from '@graphql-inspector/patch/errors'; +import { labelize } from '../target/history/errors-and-changes'; +import { + Accordion, + AccordionContent, + AccordionHeader, + AccordionItem, + AccordionTrigger, +} from '../ui/accordion'; +import { Callout } from '../ui/callout'; +import { Title } from '../ui/page'; import { ReviewComments } from './Review'; import { SchemaDiff } from './schema-diff/schema-diff'; @@ -483,7 +494,7 @@ export function toUpperSnakeCase(str: string) { export function Proposal(props: { baseSchemaSDL: string; - changes: (FragmentType | null | undefined)[] | null; + changes: FragmentType[] | null; serviceName?: string; latestProposalVersionId: string; reviews: FragmentType | null; @@ -494,31 +505,40 @@ export function Proposal(props: { const changes = useMemo(() => { return ( - props.changes - ?.map((change): Change | null => { - const c = useFragment(ProposalOverview_ChangeFragment, change); - if (c) { - return { - criticality: { - // isSafeBasedOnUsage: , - // reason: , - level: c.severityLevel as any, - }, - message: c.message, - meta: c.meta, - type: (c.meta && toUpperSnakeCase(c.meta?.__typename)) ?? '', // convert to upper snake - path: c.path?.join('.'), - }; - } - return null; - }) - .filter(c => !!c) ?? [] + props.changes?.map((change): Change => { + const c = useFragment(ProposalOverview_ChangeFragment, change); + return { + criticality: { + // isSafeBasedOnUsage: , + // reason: , + level: c.severityLevel as any, + }, + message: c.message, + meta: c.meta, + type: (c.meta && toUpperSnakeCase(c.meta?.__typename)) ?? '', // convert to upper snake + path: c.path?.join('.'), + }; + }) ?? [] ); }, [props.changes]); - const after = useMemo(() => { - return patchSchema(before, changes, { throwOnError: false }); + const [after, notApplied, ignored] = useMemo(() => { + const cannotBeApplied: Array<{ change: Change; error: Error }> = []; + const ignored: Array<{ change: Change; error: Error }> = []; + const patched = patchSchema(before, changes, { + throwOnError: false, + onError(error, change) { + if (error instanceof NoopError) { + ignored.push({ change, error }); + return false; + } + cannotBeApplied.push({ change, error }); + return true; + }, + }); + return [patched, cannotBeApplied, ignored]; }, [before, changes]); + /** * Reviews can change position because the coordinate changes... placing them out of order from their original line numbers. * Because of this, we have to fetch every single page of comments... @@ -558,7 +578,27 @@ export function Proposal(props: { }, [props.reviews]); try { - return ; + return ( + <> + {notApplied.length ? ( + + Incompatible Changes + {notApplied.map((p, i) => ( + + ))} + + ) : null} + {ignored.length ? ( + + Ignored Changes + {ignored.map((p, i) => ( + + ))} + + ) : null} + + + ); } catch (e: unknown) { return ( <> @@ -568,3 +608,22 @@ export function Proposal(props: { ); } } + +function ProposalError(props: { change: Change; error: Error }) { + return ( + + + + +
+ + {labelize(props.change.message)} + +
+
+
+ {props.error.message} +
+
+ ); +} diff --git a/packages/web/app/src/pages/target-proposal-layout.tsx b/packages/web/app/src/pages/target-proposal-layout.tsx index 835f1b6546..bb7e09e4fa 100644 --- a/packages/web/app/src/pages/target-proposal-layout.tsx +++ b/packages/web/app/src/pages/target-proposal-layout.tsx @@ -90,10 +90,10 @@ export function TargetProposalLayoutPage(props: {
{proposal.title}
-
{proposal.description}
-
- {userText(proposal.user)} proposed {' '} +
+ proposed by {userText(proposal.user)}
+
{proposal.description}
)} diff --git a/packages/web/app/src/pages/target-proposal-schema.tsx b/packages/web/app/src/pages/target-proposal-schema.tsx index 57f9f75829..cabd19a3e5 100644 --- a/packages/web/app/src/pages/target-proposal-schema.tsx +++ b/packages/web/app/src/pages/target-proposal-schema.tsx @@ -119,7 +119,7 @@ export function TargetProposalSchemaPage(props: { latestProposalVersionId={proposalVersion.node.id} reviews={proposal?.reviews ?? null} baseSchemaSDL={existingSchema} - changes={proposed.changes ?? []} + changes={proposed.changes.filter(c => !!c) ?? []} serviceName={proposed.serviceName ?? ''} /> ); From 9921cb0295dbb5915198fe85ecdec86f3075674e Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Tue, 26 Aug 2025 20:45:21 -0700 Subject: [PATCH 29/54] Add detached comments; improve comment ui --- .../resolvers/Query/schemaProposal.ts | 38 +++++++++++ .../app/src/components/proposal/Review.tsx | 42 ++++++++++-- .../web/app/src/components/proposal/index.tsx | 32 ++++++--- .../proposal/schema-diff/components.tsx | 68 +++++++++++++++---- .../proposal/schema-diff/schema-diff.tsx | 12 +++- 5 files changed, 165 insertions(+), 27 deletions(-) diff --git a/packages/services/api/src/modules/proposals/resolvers/Query/schemaProposal.ts b/packages/services/api/src/modules/proposals/resolvers/Query/schemaProposal.ts index 776b668dca..43672a208b 100644 --- a/packages/services/api/src/modules/proposals/resolvers/Query/schemaProposal.ts +++ b/packages/services/api/src/modules/proposals/resolvers/Query/schemaProposal.ts @@ -672,6 +672,10 @@ export const schemaProposal: NonNullable = ( cursor: 'asdf', node: { id: '1', + schemaProposalVersion: { + id: 'asdf', + serviceName: 'panda', + }, comments: { pageInfo: { endCursor: crypto.randomUUID(), @@ -695,6 +699,40 @@ export const schemaProposal: NonNullable = ( lineText: 'type User {', lineNumber: 2, stageTransition: 'OPEN', + schemaCoordinate: 'DType' + }, + }, + { + cursor: 'asdf', + node: { + id: '2', + schemaProposalVersion: { + id: 'asdf', + serviceName: 'panda', + }, + comments: { + pageInfo: { + endCursor: crypto.randomUUID(), + startCursor: crypto.randomUUID(), + hasNextPage: false, + hasPreviousPage: false, + }, + edges: [ + { + cursor: crypto.randomUUID(), + node: { + id: crypto.randomUUID(), + createdAt: Date.now(), + body: 'This is a comment. The first comment.', + updatedAt: Date.now(), + }, + }, + ], + }, + createdAt: Date.now(), + lineText: 'foo: Boolean', + lineNumber: 3, + schemaCoordinate: 'UnknownType.foo' }, }, ], diff --git a/packages/web/app/src/components/proposal/Review.tsx b/packages/web/app/src/components/proposal/Review.tsx index d90b891b57..0b9fd93fe0 100644 --- a/packages/web/app/src/components/proposal/Review.tsx +++ b/packages/web/app/src/components/proposal/Review.tsx @@ -1,6 +1,11 @@ import { FragmentType, graphql, useFragment } from '@/gql'; import { cn } from '@/lib/utils'; import { TimeAgo } from '../v2'; +import { Fragment, ReactElement, useContext } from 'react'; +import { AnnotatedContext } from './schema-diff/components'; +import { Title } from '../ui/page'; +import { Callout } from '../ui/callout'; +import { Button } from '../ui/button'; const ProposalOverview_ReviewCommentsFragment = graphql(/** GraphQL */ ` fragment ProposalOverview_ReviewCommentsFragment on SchemaProposalReview { @@ -29,11 +34,18 @@ export function ReviewComments(props: { } return ( -
- {review.comments?.edges?.map(({ node: comment }, idx) => { - return ; - })} -
+ <> +
+ {review.comments?.edges?.map(({ node: comment }, idx) => { + return ; + })} +
+ {/* @todo check if able to reply */} +
+ + +
+ ); } @@ -70,3 +82,23 @@ export function ReviewComment(props: { ); } + +export function DetachedAnnotations(props: { + /** All of the coordinates that have annotations */ + coordinates: string[]; + annotate: (coordinate: string, withPreview?: boolean) => ReactElement | null; +}) { + /** Get the list of coordinates that have already been annotated */ + const { annotatedCoordinates } = useContext(AnnotatedContext); + const detachedReviewCoordinates = props.coordinates.filter(c => annotatedCoordinates?.has(c)); + return detachedReviewCoordinates.length ? ( + + Detached Comments + {detachedReviewCoordinates.map(c => + + {props.annotate(c, true)} + + )} + + ) : null; +} \ No newline at end of file diff --git a/packages/web/app/src/components/proposal/index.tsx b/packages/web/app/src/components/proposal/index.tsx index 142c585cc7..5d81a143cb 100644 --- a/packages/web/app/src/components/proposal/index.tsx +++ b/packages/web/app/src/components/proposal/index.tsx @@ -14,8 +14,9 @@ import { } from '../ui/accordion'; import { Callout } from '../ui/callout'; import { Title } from '../ui/page'; -import { ReviewComments } from './Review'; +import { DetachedAnnotations, ReviewComments } from './Review'; import { SchemaDiff } from './schema-diff/schema-diff'; +import { AnnotatedProvider } from './schema-diff/components'; /** * Fragment containing a list of reviews. Each review is tied to a coordinate @@ -546,7 +547,7 @@ export function Proposal(props: { * * Odds are there will never be so many reviews/comments that this is even a problem. */ - const annotations = useMemo(() => { + const [annotations, reviewssByCoordinate] = useMemo(() => { const reviewsConnection = useFragment(ProposalOverview_ReviewsFragment, props.reviews); const serviceReviews = reviewsConnection?.edges?.filter(edge => { @@ -566,20 +567,34 @@ export function Proposal(props: { return result; }, new Map>()); - return (coordinate: string) => { + const annotate = (coordinate: string, withPreview = false) => { const reviews = reviewssByCoordinate.get(coordinate); if (reviews) { return ( - <>{reviews?.map(({ node, cursor }) => )} + <> + {reviews?.map(({ node, cursor }) => ( + <> + {withPreview === true && node.lineText && {node.lineText} } + + + ))} + ); } return null; }; - }, [props.reviews]); + return [annotate, reviewssByCoordinate]; + }, [props.reviews, props.serviceName]); try { + // @todo This doesnt work 100% of the time... A different solution must be found + + // THIS IS IMPORTANT!! must be rendered first so that it sets up the state in the + // AnnotatedContext for . Otherwise, the DetachedAnnotations will be empty. + const diff = ; + return ( - <> + {notApplied.length ? ( Incompatible Changes @@ -596,8 +611,9 @@ export function Proposal(props: { ))} ) : null} - - + + {diff} + ); } catch (e: unknown) { return ( diff --git a/packages/web/app/src/components/proposal/schema-diff/components.tsx b/packages/web/app/src/components/proposal/schema-diff/components.tsx index 19d2ec0c7a..56c8aad253 100644 --- a/packages/web/app/src/components/proposal/schema-diff/components.tsx +++ b/packages/web/app/src/components/proposal/schema-diff/components.tsx @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-non-null-asserted-optional-chain */ -import { Fragment, ReactElement, ReactNode } from 'react'; +import { createContext, Fragment, ReactElement, ReactNode, useContext, useState } from 'react'; import { astFromValue, ConstArgumentNode, @@ -32,6 +32,10 @@ import { import { isPrintableAsBlockString } from 'graphql/language/blockString'; import { cn } from '@/lib/utils'; import { compareLists, diffArrays, matchArrays } from './compare-lists'; +import { SeverityLevelType } from '@/gql/graphql'; +import { CheckIcon, XIcon } from '@/components/ui/icon'; +import { ExclamationTriangleIcon } from '@radix-ui/react-icons'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; type RootFieldsType = { query: GraphQLField; @@ -41,18 +45,40 @@ type RootFieldsType = { const TAB = <>  ; +export const AnnotatedContext = createContext({ + annotatedCoordinates: null, +} as Readonly<{ + /** + * As annotations are rendered, this tracks coordinates used. This is used internally to + * show annotations that are not resolved but that are not tied to a coordinate that exists anymore. + * + * Note that adding a value to this Set does not trigger a rerender. + * Special care must be taken to ensure the render order is correct + */ + annotatedCoordinates: Set | null, +}>); + +export function AnnotatedProvider(props: { children: ReactNode; }) { + const [context] = useState({ annotatedCoordinates: new Set()}); + return ( + + {props.children} + + ); +} + export function ChangeDocument(props: { children: ReactNode; className?: string }) { return ( -
- - + + +
- + {props.indent && Array.from({ length: Number(props.indent) }).map((_, i) => ( {TAB} @@ -133,11 +138,24 @@ function Removal(props: { children: React.ReactNode | string; className?: string; }): React.ReactNode { - return {props.children}; + return ( + + {props.children} + + ); } function Addition(props: { children: React.ReactNode; className?: string }): React.ReactNode { - return {props.children}; + return ( + + {props.children} + + ); } function printDescription(def: { readonly description: string | undefined | null }): string | null { @@ -166,15 +184,9 @@ function Description(props: { <> {lines.map((line, index) => ( - - {line} - + + {line} + ))} @@ -248,10 +260,11 @@ export function DiffInputField({ const changeType = determineChangeType(oldField, newField); return ( <> - - + + + {props.children} - } - if (props.type === 'removal') { - return {props.children} - } - return props.children +}): ReactNode { + const Klass = type === 'addition' ? Addition : type === 'removal' ? Removal : React.Fragment; + return {children}; } export function DiffField({ @@ -291,7 +302,13 @@ export function DiffField({ const hasNewArgs = !!newField?.args.length; const hasOldArgs = !!oldField?.args.length; const hasArgs = hasNewArgs || hasOldArgs; - const argsChangeType = hasNewArgs ? (hasOldArgs ? 'mutual' : 'addition') : (hasOldArgs ? 'removal' : 'mutual'); + const argsChangeType = hasNewArgs + ? hasOldArgs + ? 'mutual' + : 'addition' + : hasOldArgs + ? 'removal' + : 'mutual'; const changeType = determineChangeType(oldField, newField); const AfterArguments = ( <> @@ -307,14 +324,23 @@ export function DiffField({ <> - - {hasArgs && <>(} + + + + {hasArgs && ( + + <>( + + )} {!hasArgs && AfterArguments} {!!hasArgs && ( - <>){AfterArguments} + + <>) + + {AfterArguments} )} @@ -337,7 +363,10 @@ export function DiffArguments(props: { - : + + + + : @@ -347,7 +376,10 @@ export function DiffArguments(props: { - : + + + + : @@ -357,8 +389,10 @@ export function DiffArguments(props: { - :{' '} - + + + + :   @@ -486,13 +526,19 @@ export function DiffDirective( ); return ( <> - + - -   - - {!!hasArgs && <>(} + + +   + + + {!!hasArgs && ( + + <>( + + )} {!hasArgs && AfterArguments} {!!hasArgs && ( - <>){AfterArguments} + + <>) + + {AfterArguments} )} @@ -745,12 +794,14 @@ export function DiffInputObject({ const changeType = determineChangeType(oldInput, newInput); return ( <> - + - -   - + + +   + + - + - -   - + + +   + + - + + + - + - -   - + + +   + + {' {'} {removed.map(a => ( @@ -893,12 +950,14 @@ export function DiffUnion({ const name = oldUnion?.name ?? newUnion?.name ?? ''; return ( <> - + - -   - + + +   + + ( - | + + | + ))} {added.map(a => ( - | + + | + ))} {mutual.map(a => ( - | + + | + ))} @@ -943,42 +1008,22 @@ export function DiffScalar({ newScalar: GraphQLScalarType; }) { const changeType = determineChangeType(oldScalar, newScalar); - if (oldScalar?.name === newScalar?.name) { - return ( - <> - - - + return ( + <> + + + +   - - - - ); - } - return ( - - -   - {oldScalar && ( - - - - )} - {newScalar && ( - - - - )} - - + + + + ); } @@ -1024,30 +1069,54 @@ export function DiffDirectiveUsage( const hasNewArgs = !!newArgs.length; const hasOldArgs = !!oldArgs.length; const hasArgs = hasNewArgs || hasOldArgs; - const argsChangeType = hasNewArgs ? (hasOldArgs ? 'mutual' : 'addition') : (hasOldArgs ? 'removal' : 'mutual'); + const argsChangeType = hasNewArgs + ? hasOldArgs + ? 'mutual' + : 'addition' + : hasOldArgs + ? 'removal' + : 'mutual'; const changeType = determineChangeType(props.oldDirective, props.newDirective); - const Klass = - changeType === 'addition' ? Addition : changeType === 'removal' ? Removal : React.Fragment; const { added, mutual, removed } = compareLists(oldArgs, newArgs); const argumentElements = [ - ...removed.map(r => ), - ...added.map(r => ), - ...mutual.map(r => ), + ...removed.map(r => ( + + + + )), + ...added.map(r => ( + + + + )), + ...mutual.map(r => ( + + + + )), ]; return ( - +   - {hasArgs && <>(} + {hasArgs && ( + + <>( + + )} {argumentElements.map((e, index) => ( {e} {index === argumentElements.length - 1 ? '' : ', '} ))} - {hasArgs && <>)} - + {hasArgs && ( + + <>) + + )} + ); } diff --git a/packages/web/app/src/components/proposal/proposal-sdl.tsx b/packages/web/app/src/components/proposal/proposal-sdl.tsx index 550f293c35..97ce7ab07b 100644 --- a/packages/web/app/src/components/proposal/proposal-sdl.tsx +++ b/packages/web/app/src/components/proposal/proposal-sdl.tsx @@ -109,6 +109,7 @@ export function ProposalSDL(props: { "Doesn't change" type Query { + okay: Boolean @deprecated(reason: "Use 'ok' instead.") ok: Boolean @meta(name: "team", content: "hive") """ From 7072e1c2d123e2edac71464e4181325572c3290c Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Thu, 14 Aug 2025 12:24:09 -0700 Subject: [PATCH 23/54] prettier --- .../web/app/src/components/proposal/print-diff/components.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/web/app/src/components/proposal/print-diff/components.tsx b/packages/web/app/src/components/proposal/print-diff/components.tsx index d700c1e030..f41fa0d058 100644 --- a/packages/web/app/src/components/proposal/print-diff/components.tsx +++ b/packages/web/app/src/components/proposal/print-diff/components.tsx @@ -503,6 +503,7 @@ export function DiffDirective( newDirective: GraphQLDirective | null; }, ) { + const name = props.newDirective?.name ?? props.oldDirective?.name ?? ''; const changeType = determineChangeType(props.oldDirective, props.newDirective); const hasNewArgs = !!props.newDirective?.args.length; const hasOldArgs = !!props.oldDirective?.args.length; @@ -532,7 +533,7 @@ export function DiffDirective(   - + {!!hasArgs && ( From c9d6ee2ce4fafcad2101f63a8738d9e3e9d1d78b Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Tue, 19 Aug 2025 16:16:28 -0700 Subject: [PATCH 24/54] Return sample changes and sample schema from api; use local graphql-inspector to patch schema in ui --- package.json | 1 + .../src/modules/proposals/module.graphql.ts | 713 ++++++++++++++++- .../proposals/resolvers/GraphQLKind.ts | 26 + .../Mutation/commentOnSchemaProposalReview.ts | 13 + .../Mutation/createSchemaProposalComment.ts | 13 - ...posalReview.ts => reviewSchemaProposal.ts} | 9 +- .../resolvers/Query/schemaProposal.ts | 728 +++++++++++++++++- .../resolvers/Query/schemaProposalReviews.ts | 8 + .../proposals/resolvers/SchemaChange.ts | 18 + .../modules/schema/resolvers/SchemaChange.ts | 14 +- packages/web/app/package.json | 2 + .../app/src/components/proposal/Review.tsx | 1 - .../collect-coordinate-locations.spec.ts | 109 --- .../proposal/collect-coordinate-locations.ts | 106 --- .../web/app/src/components/proposal/index.tsx | 556 +++++++++++++ .../proposal/print-diff/print-diff.tsx | 54 -- .../src/components/proposal/proposal-sdl.tsx | 257 ------- .../compare-lists.ts | 0 .../components.tsx | 439 ++++++++--- .../proposal/schema-diff/schema-diff.tsx | 79 ++ .../src/pages/target-proposal-overview.tsx | 266 +++---- .../web/app/src/pages/target-proposals.tsx | 3 - pnpm-lock.yaml | 30 +- 23 files changed, 2565 insertions(+), 880 deletions(-) create mode 100644 packages/services/api/src/modules/proposals/resolvers/GraphQLKind.ts create mode 100644 packages/services/api/src/modules/proposals/resolvers/Mutation/commentOnSchemaProposalReview.ts delete mode 100644 packages/services/api/src/modules/proposals/resolvers/Mutation/createSchemaProposalComment.ts rename packages/services/api/src/modules/proposals/resolvers/Mutation/{createSchemaProposalReview.ts => reviewSchemaProposal.ts} (59%) create mode 100644 packages/services/api/src/modules/proposals/resolvers/SchemaChange.ts delete mode 100644 packages/web/app/src/components/proposal/__tests__/collect-coordinate-locations.spec.ts delete mode 100644 packages/web/app/src/components/proposal/collect-coordinate-locations.ts create mode 100644 packages/web/app/src/components/proposal/index.tsx delete mode 100644 packages/web/app/src/components/proposal/print-diff/print-diff.tsx delete mode 100644 packages/web/app/src/components/proposal/proposal-sdl.tsx rename packages/web/app/src/components/proposal/{print-diff => schema-diff}/compare-lists.ts (100%) rename packages/web/app/src/components/proposal/{print-diff => schema-diff}/components.tsx (71%) create mode 100644 packages/web/app/src/components/proposal/schema-diff/schema-diff.tsx diff --git a/package.json b/package.json index c8ebc7a2e8..3cbcc9fa2b 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "@graphql-eslint/eslint-plugin": "3.20.1", "@graphql-inspector/cli": "4.0.3", "@graphql-inspector/core": "^6.0.0", + "@graphql-inspector/patch": "link:../graphql-inspector/packages/patch", "@manypkg/get-packages": "2.2.2", "@next/eslint-plugin-next": "14.2.23", "@parcel/watcher": "2.5.0", diff --git a/packages/services/api/src/modules/proposals/module.graphql.ts b/packages/services/api/src/modules/proposals/module.graphql.ts index d04b328952..7b07e7ba40 100644 --- a/packages/services/api/src/modules/proposals/module.graphql.ts +++ b/packages/services/api/src/modules/proposals/module.graphql.ts @@ -3,8 +3,10 @@ import { gql } from 'graphql-modules'; export default gql` extend type Mutation { createSchemaProposal(input: CreateSchemaProposalInput!): SchemaProposal! - createSchemaProposalReview(input: CreateSchemaProposalReviewInput!): SchemaProposalReview! - createSchemaProposalComment(input: CreateSchemaProposalCommentInput!): SchemaProposalComment! + reviewSchemaProposal(input: ReviewSchemaProposalInput!): SchemaProposalReview! + commentOnSchemaProposalReview( + input: CommentOnSchemaProposalReviewInput! + ): SchemaProposalComment! } input CreateSchemaProposalInput { @@ -29,9 +31,8 @@ export default gql` serviceName: String } - input CreateSchemaProposalReviewInput { + input ReviewSchemaProposalInput { schemaProposalVersionId: ID! - lineNumber: Int """ One or both of stageTransition or initialComment inputs is/are required. """ @@ -42,7 +43,7 @@ export default gql` commentBody: String } - input CreateSchemaProposalCommentInput { + input CommentOnSchemaProposalReviewInput { schemaProposalReviewId: ID! body: String! } @@ -69,10 +70,16 @@ export default gql` } input SchemaProposalInput { + """ + Unique identifier of the desired SchemaProposal + """ id: ID! } input SchemaProposalReviewInput { + """ + Unique identifier of the desired SchemaProposalReview. + """ id: ID! } @@ -113,18 +120,50 @@ export default gql` type SchemaProposal { id: ID! createdAt: DateTime! - diffSchema: SchemaVersion + + """ + A paginated list of reviews. + """ reviews(after: String, first: Int! = 30): SchemaProposalReviewConnection + + """ + The current stage of the proposal. Proposals should be transitioned through the + course of the review from DRAFT, OPEN, APPROVED, IMPLEMENTED, but may be CLOSED + at any point in its lifecycle prior to being IMPLEMENTED. DRAFT, OPEN, APPROVED, + and CLOSED may be triggered by user action. But IMPLEMENTED can only happen if + the target schema contains the proposed changes while the proposal is in the + APPROVED state. + """ stage: SchemaProposalStage! + + """ + The schema Target that this proposal is to be applied to. + """ target: Target + + """ + A short title of this proposal. Meant to give others an easy way to refer to this + set of changes. + """ title: String + + """ + When the proposal was last modified. Adding a review or comment does not count. + """ updatedAt: DateTime! + + """ + The author of the proposal. If no author has been assigned or if that member is + removed from the org, then this returns null. + """ user: User + versions( after: String first: Int! = 15 input: SchemaProposalVersionsInput ): SchemaProposalVersionConnection + commentsCount: Int! } @@ -149,16 +188,51 @@ export default gql` } input SchemaProposalVersionsInput { + """ + Option to return only the latest version of each schema proposal. Versions + """ onlyLatest: Boolean! = false + + """ + Limit the returned SchemaProposalVersions to a single version. This can still + return multiple elements if that version changed multiple services. + """ + schemaProposalVersionId: ID } + # @key(fields: "id serviceName") type SchemaProposalVersion { + """ + An identifier for a list of SchemaProposalVersions. + """ id: ID! + + """ + The service that this version applies to. + """ + serviceName: String + createdAt: DateTime! + + """ + The SchemaProposal that this version belongs to. A proposal can have + multiple versions. + """ schemaProposal: SchemaProposal! - schemaSDL: String! - serviceName: String - user: ID + + """ + The user who submitted this version of the proposal. + """ + user: User + + """ + The list of proposed changes to the Target. + """ + changes: [SchemaChange]! + + """ + A paginated list of reviews. + """ reviews(after: String, first: Int! = 30): SchemaProposalReviewConnection } @@ -190,17 +264,9 @@ export default gql` createdAt: DateTime! """ - If the "lineText" can be found in the referenced SchemaProposalVersion, - then the "lineNumber" will be for that version. If there is no matching - "lineText", then this "lineNumber" will reference the originally - reviewed version of the proposal, and will be considered outdated. - - If the "lineNumber" is null then this review references the entire SchemaProposalVersion - and not any specific line. - """ - lineNumber: Int + The text on the line at the time of being reviewed. This should be displayed if that + coordinate's text has been modified - """ If the "lineText" is null then this review references the entire SchemaProposalVersion and not any specific line within the proposal. """ @@ -213,7 +279,7 @@ export default gql` schemaCoordinate: String """ - The specific version that this review is for. + The specific version of the proposal that this review is for. """ schemaProposalVersion: SchemaProposalVersion @@ -246,4 +312,611 @@ export default gql` """ user: User } + + extend type SchemaChange { + meta: SchemaChangeMeta + } + + union SchemaChangeMeta = + | FieldArgumentDefaultChanged + | FieldArgumentDescriptionChanged + | FieldArgumentTypeChanged + | DirectiveRemoved + | DirectiveAdded + | DirectiveDescriptionChanged + | DirectiveLocationAdded + | DirectiveLocationRemoved + | DirectiveArgumentAdded + | DirectiveArgumentRemoved + | DirectiveArgumentDescriptionChanged + | DirectiveArgumentDefaultValueChanged + | DirectiveArgumentTypeChanged + | EnumValueRemoved + | EnumValueAdded + | EnumValueDescriptionChanged + | EnumValueDeprecationReasonChanged + | EnumValueDeprecationReasonAdded + | EnumValueDeprecationReasonRemoved + | FieldRemoved + | FieldAdded + | FieldDescriptionChanged + | FieldDescriptionAdded + | FieldDescriptionRemoved + | FieldDeprecationAdded + | FieldDeprecationRemoved + | FieldDeprecationReasonChanged + | FieldDeprecationReasonAdded + | FieldDeprecationReasonRemoved + | FieldTypeChanged + | DirectiveUsageUnionMemberAdded + | DirectiveUsageUnionMemberRemoved + | FieldArgumentAdded + | FieldArgumentRemoved + | InputFieldRemoved + | InputFieldAdded + | InputFieldDescriptionAdded + | InputFieldDescriptionRemoved + | InputFieldDescriptionChanged + | InputFieldDefaultValueChanged + | InputFieldTypeChanged + | ObjectTypeInterfaceAdded + | ObjectTypeInterfaceRemoved + | SchemaQueryTypeChanged + | SchemaMutationTypeChanged + | SchemaSubscriptionTypeChanged + | TypeRemoved + | TypeAdded + | TypeKindChanged + | TypeDescriptionChanged + | TypeDescriptionAdded + | TypeDescriptionRemoved + | UnionMemberRemoved + | UnionMemberAdded + | DirectiveUsageEnumAdded + | DirectiveUsageEnumRemoved + | DirectiveUsageEnumValueAdded + | DirectiveUsageEnumValueRemoved + | DirectiveUsageInputObjectRemoved + | DirectiveUsageInputObjectAdded + | DirectiveUsageInputFieldDefinitionAdded + | DirectiveUsageInputFieldDefinitionRemoved + | DirectiveUsageFieldAdded + | DirectiveUsageFieldRemoved + | DirectiveUsageScalarAdded + | DirectiveUsageScalarRemoved + | DirectiveUsageObjectAdded + | DirectiveUsageObjectRemoved + | DirectiveUsageInterfaceAdded + | DirectiveUsageSchemaAdded + | DirectiveUsageSchemaRemoved + | DirectiveUsageFieldDefinitionAdded + | DirectiveUsageFieldDefinitionRemoved + | DirectiveUsageArgumentDefinitionRemoved + | DirectiveUsageInterfaceRemoved + | DirectiveUsageArgumentDefinitionAdded + | DirectiveUsageArgumentAdded + | DirectiveUsageArgumentRemoved + + # Directive + + type FieldArgumentDescriptionChanged { + typeName: String! + fieldName: String! + argumentName: String! + oldDescription: String + newDescription: String + } + + type FieldArgumentDefaultChanged { + typeName: String! + fieldName: String! + argumentName: String! + oldDefaultValue: String + newDefaultValue: String + } + + type FieldArgumentTypeChanged { + typeName: String! + fieldName: String! + argumentName: String! + oldArgumentType: String! + newArgumentType: String! + isSafeArgumentTypeChange: Boolean + } + + type DirectiveRemoved { + removedDirectiveName: String! + } + + type DirectiveAdded { + addedDirectiveName: String! + addedDirectiveRepeatable: Boolean + addedDirectiveLocations: [String!]! + addedDirectiveDescription: String + } + + type DirectiveDescriptionChanged { + directiveName: String! + oldDirectiveDescription: String + newDirectiveDescription: String + } + + type DirectiveLocationAdded { + directiveName: String! + addedDirectiveLocation: String! + } + + type DirectiveLocationRemoved { + directiveName: String! + removedDirectiveLocation: String! + } + + type DirectiveArgumentAdded { + directiveName: String! + addedDirectiveArgumentName: String! + addedDirectiveArgumentTypeIsNonNull: Boolean + addedToNewDirective: Boolean + addedDirectiveArgumentDescription: String + addedDirectiveArgumentType: String! + addedDirectiveDefaultValue: String + } + + type DirectiveArgumentRemoved { + directiveName: String! + removedDirectiveArgumentName: String! + } + + type DirectiveArgumentDescriptionChanged { + directiveName: String! + directiveArgumentName: String! + oldDirectiveArgumentDescription: String + newDirectiveArgumentDescription: String + } + + type DirectiveArgumentDefaultValueChanged { + directiveName: String! + directiveArgumentName: String! + oldDirectiveArgumentDefaultValue: String + newDirectiveArgumentDefaultValue: String + } + + type DirectiveArgumentTypeChanged { + directiveName: String! + directiveArgumentName: String! + oldDirectiveArgumentType: String! + newDirectiveArgumentType: String! + isSafeDirectiveArgumentTypeChange: Boolean + } + + # Enum + + type EnumValueRemoved { + enumName: String! + removedEnumValueName: String! + isEnumValueDeprecated: Boolean + } + + type EnumValueAdded { + enumName: String! + addedEnumValueName: String! + addedToNewType: Boolean + addedDirectiveDescription: String + } + + type EnumValueDescriptionChanged { + enumName: String! + enumValueName: String! + oldEnumValueDescription: String + newEnumValueDescription: String + } + + type EnumValueDeprecationReasonChanged { + enumName: String! + enumValueName: String! + oldEnumValueDeprecationReason: String! + newEnumValueDeprecationReason: String! + } + + type EnumValueDeprecationReasonAdded { + enumName: String! + enumValueName: String! + addedValueDeprecationReason: String! + } + + type EnumValueDeprecationReasonRemoved { + enumName: String! + enumValueName: String! + removedEnumValueDeprecationReason: String! + } + + # Field + + type FieldRemoved { + typeName: String! + removedFieldName: String! + isRemovedFieldDeprecated: Boolean + typeType: String! + } + + type FieldAdded { + typeName: String! + addedFieldName: String! + typeType: String! + addedFieldReturnType: String! + } + + type FieldDescriptionChanged { + typeName: String! + fieldName: String! + oldDescription: String + newDescription: String + } + + type FieldDescriptionAdded { + typeName: String! + fieldName: String! + addedDescription: String! + } + + type FieldDescriptionRemoved { + typeName: String! + fieldName: String! + } + + type FieldDeprecationAdded { + typeName: String! + fieldName: String! + deprecationReason: String! + } + + type FieldDeprecationRemoved { + typeName: String! + fieldName: String! + } + + type FieldDeprecationReasonChanged { + typeName: String! + fieldName: String! + oldDeprecationReason: String! + newDeprecationReason: String! + } + + type FieldDeprecationReasonAdded { + typeName: String! + fieldName: String! + addedDeprecationReason: String! + } + + type FieldDeprecationReasonRemoved { + typeName: String! + fieldName: String! + } + + type FieldTypeChanged { + typeName: String! + fieldName: String! + oldFieldType: String! + newFieldType: String! + isSafeFieldTypeChange: Boolean + } + + type DirectiveUsageUnionMemberAdded { + unionName: String! + addedUnionMemberTypeName: String! + addedDirectiveName: String! + addedToNewType: Boolean + } + + type DirectiveUsageUnionMemberRemoved { + unionName: String! + removedUnionMemberTypeName: String! + removedDirectiveName: String! + } + + type FieldArgumentAdded { + typeName: String! + fieldName: String! + addedArgumentName: String! + addedArgumentType: String! + hasDefaultValue: Boolean + isAddedFieldArgumentBreaking: Boolean + addedToNewField: Boolean + } + + type FieldArgumentRemoved { + typeName: String! + fieldName: String! + removedFieldArgumentName: String! + removedFieldType: String! + } + + # Input + + type InputFieldRemoved { + inputName: String! + removedFieldName: String! + isInputFieldDeprecated: Boolean + } + + type InputFieldAdded { + inputName: String! + addedInputFieldName: String! + isAddedInputFieldTypeNullable: Boolean + addedInputFieldType: String! + addedFieldDefault: String + addedToNewType: Boolean + } + + type InputFieldDescriptionAdded { + inputName: String! + inputFieldName: String! + addedInputFieldDescription: String! + } + + type InputFieldDescriptionRemoved { + inputName: String! + inputFieldName: String! + removedDescription: String! + } + + type InputFieldDescriptionChanged { + inputName: String! + inputFieldName: String! + oldInputFieldDescription: String! + newInputFieldDescription: String! + } + + type InputFieldDefaultValueChanged { + inputName: String! + inputFieldName: String! + oldDefaultValue: String + newDefaultValue: String + } + + type InputFieldTypeChanged { + inputName: String! + inputFieldName: String! + oldInputFieldType: String! + newInputFieldType: String! + isInputFieldTypeChangeSafe: Boolean + } + + # Type + + type ObjectTypeInterfaceAdded { + objectTypeName: String! + addedInterfaceName: String! + addedToNewType: Boolean + } + + type ObjectTypeInterfaceRemoved { + objectTypeName: String! + removedInterfaceName: String! + } + + # Schema + + type SchemaQueryTypeChanged { + oldQueryTypeName: String! + newQueryTypeName: String! + } + + type SchemaMutationTypeChanged { + oldMutationTypeName: String! + newMutationTypeName: String! + } + + type SchemaSubscriptionTypeChanged { + oldSubscriptionTypeName: String! + newSubscriptionTypeName: String! + } + + # Type + + type TypeRemoved { + removedTypeName: String! + } + + scalar GraphQLKind + + type TypeAdded { + addedTypeName: String! + addedTypeKind: GraphQLKind + } + + type TypeKindChanged { + typeName: String! + oldTypeKind: String! + newTypeKind: String! + } + + type TypeDescriptionChanged { + typeName: String! + oldTypeDescription: String! + newTypeDescription: String! + } + + type TypeDescriptionAdded { + typeName: String! + addedTypeDescription: String! + } + + type TypeDescriptionRemoved { + typeName: String! + removedTypeDescription: String! + } + + # Union + + type UnionMemberRemoved { + unionName: String! + removedUnionMemberTypeName: String! + } + + type UnionMemberAdded { + unionName: String! + addedUnionMemberTypeName: String! + addedToNewType: Boolean + } + + # Directive Usage + + type DirectiveUsageEnumAdded { + enumName: String! + addedDirectiveName: String! + addedToNewType: Boolean + } + + type DirectiveUsageEnumRemoved { + enumName: String! + removedDirectiveName: String! + } + + type DirectiveUsageEnumValueAdded { + enumName: String! + enumValueName: String! + addedDirectiveName: String! + addedToNewType: Boolean + } + + type DirectiveUsageEnumValueRemoved { + enumName: String! + enumValueName: String! + removedDirectiveName: String! + } + + type DirectiveUsageInputObjectRemoved { + inputObjectName: String! + removedInputFieldName: String! + isRemovedInputFieldTypeNullable: Boolean + removedInputFieldType: String! + removedDirectiveName: String! + } + + type DirectiveUsageInputObjectAdded { + inputObjectName: String! + addedInputFieldName: String! + isAddedInputFieldTypeNullable: Boolean + addedInputFieldType: String! + addedDirectiveName: String! + addedToNewType: Boolean + } + + type DirectiveUsageInputFieldDefinitionAdded { + inputObjectName: String! + inputFieldName: String! + inputFieldType: String! + addedDirectiveName: String! + addedToNewType: Boolean + } + + type DirectiveUsageInputFieldDefinitionRemoved { + inputObjectName: String! + inputFieldName: String! + removedDirectiveName: String! + } + + type DirectiveUsageFieldAdded { + typeName: String! + fieldName: String! + addedDirectiveName: String! + } + + type DirectiveUsageFieldRemoved { + typeName: String! + fieldName: String! + removedDirectiveName: String! + } + + type DirectiveUsageScalarAdded { + scalarName: String! + addedDirectiveName: String! + addedToNewType: Boolean + } + + type DirectiveUsageScalarRemoved { + scalarName: String! + removedDirectiveName: String! + } + + type DirectiveUsageObjectAdded { + objectName: String! + addedDirectiveName: String! + addedToNewType: Boolean + } + + type DirectiveUsageObjectRemoved { + objectName: String! + removedDirectiveName: String! + } + + type DirectiveUsageInterfaceAdded { + interfaceName: String! + addedDirectiveName: String! + addedToNewType: Boolean + } + + type DirectiveUsageSchemaAdded { + addedDirectiveName: String! + schemaTypeName: String! + addedToNewType: Boolean + } + + type DirectiveUsageSchemaRemoved { + removedDirectiveName: String! + schemaTypeName: String! + } + + type DirectiveUsageFieldDefinitionAdded { + typeName: String! + fieldName: String! + addedDirectiveName: String! + addedToNewType: Boolean + } + + type DirectiveUsageFieldDefinitionRemoved { + typeName: String! + fieldName: String! + removedDirectiveName: String! + } + + type DirectiveUsageArgumentDefinitionRemoved { + typeName: String! + fieldName: String! + argumentName: String! + removedDirectiveName: String! + } + + type DirectiveUsageInterfaceRemoved { + interfaceName: String! + removedDirectiveName: String! + } + + type DirectiveUsageArgumentDefinitionAdded { + typeName: String! + fieldName: String! + argumentName: String! + addedDirectiveName: String! + addedToNewType: Boolean + } + + type DirectiveUsageArgumentAdded { + directiveName: String! + addedArgumentName: String! + addedArgumentValue: String! + oldArgumentValue: String + parentTypeName: String + parentFieldName: String + parentArgumentName: String + parentEnumValueName: String + } + + type DirectiveUsageArgumentRemoved { + directiveName: String! + removedArgumentName: String! + parentTypeName: String + parentFieldName: String + parentArgumentName: String + parentEnumValueName: String + } `; diff --git a/packages/services/api/src/modules/proposals/resolvers/GraphQLKind.ts b/packages/services/api/src/modules/proposals/resolvers/GraphQLKind.ts new file mode 100644 index 0000000000..97bbcdcbb7 --- /dev/null +++ b/packages/services/api/src/modules/proposals/resolvers/GraphQLKind.ts @@ -0,0 +1,26 @@ +import { GraphQLScalarType, Kind } from 'graphql'; + +const KindValues = Object.values(Kind); + +export const GraphQLKind = new GraphQLScalarType({ + name: 'GraphQLKind', + description: 'GraphQLKind description', + serialize: value => { + if (typeof value === 'string' && KindValues.includes(value as Kind)) { + return value; + } + throw new Error('GraphQLKind scalar expects a valid Kind.'); + }, + parseValue: value => { + if (typeof value === 'string' && KindValues.includes(value as Kind)) { + return value; + } + throw new Error('GraphQLKind scalar expects a valid Kind.'); + }, + parseLiteral: ast => { + if (ast.kind === Kind.STRING && KindValues.includes(ast.value as Kind)) { + return ast.value; + } + return null; + }, +}); diff --git a/packages/services/api/src/modules/proposals/resolvers/Mutation/commentOnSchemaProposalReview.ts b/packages/services/api/src/modules/proposals/resolvers/Mutation/commentOnSchemaProposalReview.ts new file mode 100644 index 0000000000..4ed39e2842 --- /dev/null +++ b/packages/services/api/src/modules/proposals/resolvers/Mutation/commentOnSchemaProposalReview.ts @@ -0,0 +1,13 @@ +import type { MutationResolvers } from '../../../../__generated__/types'; + +export const commentOnSchemaProposalReview: NonNullable< + MutationResolvers['commentOnSchemaProposalReview'] +> = async (_parent, { input: { body } }, _ctx) => { + /* Implement Mutation.commentOnSchemaProposalReview resolver logic here */ + return { + createdAt: Date.now(), + id: crypto.randomUUID(), + updatedAt: Date.now(), + body, + }; +}; diff --git a/packages/services/api/src/modules/proposals/resolvers/Mutation/createSchemaProposalComment.ts b/packages/services/api/src/modules/proposals/resolvers/Mutation/createSchemaProposalComment.ts deleted file mode 100644 index b6bdea4675..0000000000 --- a/packages/services/api/src/modules/proposals/resolvers/Mutation/createSchemaProposalComment.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { MutationResolvers } from './../../../../__generated__/types'; - -export const createSchemaProposalComment: NonNullable< - MutationResolvers['createSchemaProposalComment'] -> = async (_parent, { input: { body } }, _ctx) => { - /* Implement Mutation.createSchemaProposalComment resolver logic here */ - return { - createdAt: Date.now(), - id: crypto.randomUUID(), - updatedAt: Date.now(), - body, - }; -}; diff --git a/packages/services/api/src/modules/proposals/resolvers/Mutation/createSchemaProposalReview.ts b/packages/services/api/src/modules/proposals/resolvers/Mutation/reviewSchemaProposal.ts similarity index 59% rename from packages/services/api/src/modules/proposals/resolvers/Mutation/createSchemaProposalReview.ts rename to packages/services/api/src/modules/proposals/resolvers/Mutation/reviewSchemaProposal.ts index 251ffa65e4..c7b271a770 100644 --- a/packages/services/api/src/modules/proposals/resolvers/Mutation/createSchemaProposalReview.ts +++ b/packages/services/api/src/modules/proposals/resolvers/Mutation/reviewSchemaProposal.ts @@ -1,9 +1,10 @@ import type { MutationResolvers } from './../../../../__generated__/types'; -export const createSchemaProposalReview: NonNullable< - MutationResolvers['createSchemaProposalReview'] -> = async (_parent, { input: { stageTransition, commentBody } }, _ctx) => { - /* Implement Mutation.createSchemaProposalReview resolver logic here */ +export const reviewSchemaProposal: NonNullable = async ( + _parent, + { input: { stageTransition, commentBody } }, + _ctx, +) => { return { createdAt: Date.now(), id: `abcd-1234-efgh-5678-wxyz`, diff --git a/packages/services/api/src/modules/proposals/resolvers/Query/schemaProposal.ts b/packages/services/api/src/modules/proposals/resolvers/Query/schemaProposal.ts index defdf93f96..5a98a0267a 100644 --- a/packages/services/api/src/modules/proposals/resolvers/Query/schemaProposal.ts +++ b/packages/services/api/src/modules/proposals/resolvers/Query/schemaProposal.ts @@ -1,6 +1,692 @@ -import type { QueryResolvers } from './../../../../__generated__/types'; +import type { + CriticalityLevel, + QueryResolvers, + SeverityLevelType, +} from './../../../../__generated__/types'; -export const schemaProposal: NonNullable = async ( +// const schemaSDL = /* GraphQL */ ` +// schema { +// query: Query +// } +// input AInput { +// """ +// a +// """ +// a: String = "1" +// b: String! +// } +// input ListInput { +// a: [String] = ["foo"] +// b: [String] = ["bar"] +// } +// """ +// The Query Root of this schema +// """ +// type Query { +// """ +// Just a simple string +// """ +// a(anArg: String): String! +// b: BType +// } +// type BType { +// a: String +// } +// type CType { +// a: String @deprecated(reason: "whynot") +// c: Int! +// d(arg: Int): String +// } +// union MyUnion = CType | BType +// interface AnInterface { +// interfaceField: Int! +// } +// interface AnotherInterface { +// anotherInterfaceField: String +// } +// type WithInterfaces implements AnInterface & AnotherInterface { +// a: String! +// } +// type WithArguments { +// a( +// """ +// Meh +// """ +// a: Int +// b: String +// ): String +// b(arg: Int = 1): String +// } +// enum Options { +// A +// B +// C +// E +// F @deprecated(reason: "Old") +// } +// """ +// Old +// """ +// directive @yolo( +// """ +// Included when true. +// """ +// someArg: Boolean! +// anotherArg: String! +// willBeRemoved: Boolean! +// ) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT +// type WillBeRemoved { +// a: String +// } +// directive @willBeRemoved on FIELD +// `; + +const changes = [ + { + criticality: { level: 'NON_BREAKING' }, + type: 'TYPE_ADDED', + message: "Type 'DType' was added", + meta: { addedTypeKind: 'ObjectTypeDefinition', addedTypeName: 'DType' }, + path: 'DType', + }, + { + type: 'FIELD_ADDED', + criticality: { level: 'NON_BREAKING' }, + message: "Field 'b' was added to object type 'DType'", + meta: { + typeName: 'DType', + addedFieldName: 'b', + typeType: 'object type', + addedFieldReturnType: 'Int!', + }, + path: 'DType.b', + }, + { + criticality: { level: 'BREAKING' }, + type: 'TYPE_REMOVED', + message: "Type 'WillBeRemoved' was removed", + meta: { removedTypeName: 'WillBeRemoved' }, + path: 'WillBeRemoved', + }, + { + type: 'INPUT_FIELD_ADDED', + criticality: { + level: 'BREAKING', + reason: + 'Adding a required input field to an existing input object type is a breaking change because it will cause existing uses of this input object type to error.', + }, + message: "Input field 'c' of type 'String!' was added to input object type 'AInput'", + meta: { + inputName: 'AInput', + addedInputFieldName: 'c', + isAddedInputFieldTypeNullable: false, + addedInputFieldType: 'String!', + addedToNewType: false, + }, + path: 'AInput.c', + }, + { + type: 'INPUT_FIELD_REMOVED', + criticality: { + level: 'BREAKING', + reason: + 'Removing an input field will cause existing queries that use this input field to error.', + }, + message: "Input field 'b' was removed from input object type 'AInput'", + meta: { inputName: 'AInput', removedFieldName: 'b', isInputFieldDeprecated: false }, + path: 'AInput.b', + }, + { + type: 'INPUT_FIELD_DESCRIPTION_CHANGED', + criticality: { level: 'NON_BREAKING' }, + message: "Input field 'AInput.a' description changed from 'a' to 'changed'", + meta: { + inputName: 'AInput', + inputFieldName: 'a', + oldInputFieldDescription: 'a', + newInputFieldDescription: 'changed', + }, + path: 'AInput.a', + }, + { + type: 'INPUT_FIELD_DEFAULT_VALUE_CHANGED', + criticality: { + level: 'DANGEROUS', + reason: + 'Changing the default value for an argument may change the runtime behavior of a field if it was never provided.', + }, + message: "Input field 'AInput.a' default value changed from '\"1\"' to '1'", + meta: { + inputName: 'AInput', + inputFieldName: 'a', + oldDefaultValue: '"1"', + newDefaultValue: '1', + }, + path: 'AInput.a', + }, + { + type: 'INPUT_FIELD_TYPE_CHANGED', + criticality: { + level: 'BREAKING', + reason: + 'Changing the type of an input field can cause existing queries that use this field to error.', + }, + message: "Input field 'AInput.a' changed type from 'String' to 'Int'", + meta: { + inputName: 'AInput', + inputFieldName: 'a', + oldInputFieldType: 'String', + newInputFieldType: 'Int', + isInputFieldTypeChangeSafe: false, + }, + path: 'AInput.a', + }, + { + type: 'INPUT_FIELD_DEFAULT_VALUE_CHANGED', + criticality: { + level: 'DANGEROUS', + reason: + 'Changing the default value for an argument may change the runtime behavior of a field if it was never provided.', + }, + message: "Input field 'ListInput.a' default value changed from '[ 'foo' ]' to '[ 'bar' ]'", + meta: { + inputName: 'ListInput', + inputFieldName: 'a', + oldDefaultValue: "[ 'foo' ]", + newDefaultValue: "[ 'bar' ]", + }, + path: 'ListInput.a', + }, + { + type: 'FIELD_DESCRIPTION_CHANGED', + criticality: { level: 'NON_BREAKING' }, + message: + "Field 'Query.a' description changed from 'Just a simple string' to 'This description has been changed'", + meta: { + fieldName: 'a', + typeName: 'Query', + oldDescription: 'Just a simple string', + newDescription: 'This description has been changed', + }, + path: 'Query.a', + }, + { + type: 'FIELD_ARGUMENT_REMOVED', + criticality: { level: 'BREAKING' }, + message: "Argument 'anArg: String' was removed from field 'Query.a'", + meta: { + typeName: 'Query', + fieldName: 'a', + removedFieldArgumentName: 'anArg', + removedFieldType: 'String', + }, + path: 'Query.a.anArg', + }, + { + type: 'FIELD_TYPE_CHANGED', + criticality: { level: 'BREAKING' }, + message: "Field 'Query.b' changed type from 'BType' to 'Int!'", + meta: { + typeName: 'Query', + fieldName: 'b', + oldFieldType: 'BType', + newFieldType: 'Int!', + isSafeFieldTypeChange: false, + }, + path: 'Query.b', + }, + { + criticality: { level: 'NON_BREAKING' }, + type: 'TYPE_DESCRIPTION_CHANGED', + message: + "Description 'The Query Root of this schema' on type 'Query' has changed to 'Query Root description changed'", + path: 'Query', + meta: { + typeName: 'Query', + newTypeDescription: 'Query Root description changed', + oldTypeDescription: 'The Query Root of this schema', + }, + }, + { + criticality: { + level: 'BREAKING', + reason: + 'Changing the kind of a type is a breaking change because it can cause existing queries to error. For example, turning an object type to a scalar type would break queries that define a selection set for this type.', + }, + type: 'TYPE_KIND_CHANGED', + message: "'BType' kind changed from 'ObjectTypeDefinition' to 'InputObjectTypeDefinition'", + path: 'BType', + meta: { + typeName: 'BType', + newTypeKind: 'InputObjectTypeDefinition', + oldTypeKind: 'ObjectTypeDefinition', + }, + }, + { + type: 'OBJECT_TYPE_INTERFACE_ADDED', + criticality: { + level: 'DANGEROUS', + reason: + 'Adding an interface to an object type may break existing clients that were not programming defensively against a new possible type.', + }, + message: "'CType' object implements 'AnInterface' interface", + meta: { objectTypeName: 'CType', addedInterfaceName: 'AnInterface', addedToNewType: false }, + path: 'CType', + }, + { + type: 'FIELD_ADDED', + criticality: { level: 'NON_BREAKING' }, + message: "Field 'b' was added to object type 'CType'", + meta: { + typeName: 'CType', + addedFieldName: 'b', + typeType: 'object type', + addedFieldReturnType: 'Int!', + }, + path: 'CType.b', + }, + { + type: 'FIELD_REMOVED', + criticality: { + level: 'BREAKING', + reason: + 'Removing a field is a breaking change. It is preferable to deprecate the field before removing it. This applies to removed union fields as well, since removal breaks client operations that contain fragments that reference the removed type through direct (... on RemovedType) or indirect means such as __typename in the consumers.', + }, + message: "Field 'c' was removed from object type 'CType'", + meta: { + typeName: 'CType', + removedFieldName: 'c', + isRemovedFieldDeprecated: false, + typeType: 'object type', + }, + path: 'CType.c', + }, + { + type: 'FIELD_DEPRECATION_REASON_CHANGED', + criticality: { level: 'NON_BREAKING' }, + message: "Deprecation reason on field 'CType.a' has changed from 'whynot' to 'cuz'", + meta: { + fieldName: 'a', + typeName: 'CType', + newDeprecationReason: 'cuz', + oldDeprecationReason: 'whynot', + }, + path: 'CType.a', + }, + { + type: 'FIELD_ARGUMENT_ADDED', + criticality: { level: 'DANGEROUS' }, + message: "Argument 'arg: Int' added to field 'CType.a'", + meta: { + typeName: 'CType', + fieldName: 'a', + addedArgumentName: 'arg', + addedArgumentType: 'Int', + hasDefaultValue: false, + addedToNewField: false, + isAddedFieldArgumentBreaking: false, + }, + path: 'CType.a.arg', + }, + { + type: 'FIELD_ARGUMENT_DEFAULT_CHANGED', + criticality: { + level: 'DANGEROUS', + reason: + 'Changing the default value for an argument may change the runtime behaviour of a field if it was never provided.', + }, + message: "Default value '10' was added to argument 'arg' on field 'CType.d'", + meta: { typeName: 'CType', fieldName: 'd', argumentName: 'arg', newDefaultValue: '10' }, + path: 'CType.d.arg', + }, + { + criticality: { + level: 'DANGEROUS', + reason: + 'Adding a possible type to Unions may break existing clients that were not programming defensively against a new possible type.', + }, + type: 'UNION_MEMBER_ADDED', + message: "Member 'DType' was added to Union type 'MyUnion'", + meta: { unionName: 'MyUnion', addedUnionMemberTypeName: 'DType', addedToNewType: false }, + path: 'MyUnion', + }, + { + criticality: { + level: 'BREAKING', + reason: + 'Removing a union member from a union can cause existing queries that use this union member in a fragment spread to error.', + }, + type: 'UNION_MEMBER_REMOVED', + message: "Member 'BType' was removed from Union type 'MyUnion'", + meta: { unionName: 'MyUnion', removedUnionMemberTypeName: 'BType' }, + path: 'MyUnion', + }, + { + type: 'FIELD_ADDED', + criticality: { level: 'NON_BREAKING' }, + message: "Field 'b' was added to interface 'AnotherInterface'", + meta: { + typeName: 'AnotherInterface', + addedFieldName: 'b', + typeType: 'interface', + addedFieldReturnType: 'Int', + }, + path: 'AnotherInterface.b', + }, + { + type: 'FIELD_REMOVED', + criticality: { + level: 'BREAKING', + reason: + 'Removing a field is a breaking change. It is preferable to deprecate the field before removing it. This applies to removed union fields as well, since removal breaks client operations that contain fragments that reference the removed type through direct (... on RemovedType) or indirect means such as __typename in the consumers.', + }, + message: "Field 'anotherInterfaceField' was removed from interface 'AnotherInterface'", + meta: { + typeName: 'AnotherInterface', + removedFieldName: 'anotherInterfaceField', + isRemovedFieldDeprecated: false, + typeType: 'interface', + }, + path: 'AnotherInterface.anotherInterfaceField', + }, + { + type: 'OBJECT_TYPE_INTERFACE_REMOVED', + criticality: { + level: 'BREAKING', + reason: + 'Removing an interface from an object type can cause existing queries that use this in a fragment spread to error.', + }, + message: "'WithInterfaces' object type no longer implements 'AnotherInterface' interface", + meta: { objectTypeName: 'WithInterfaces', removedInterfaceName: 'AnotherInterface' }, + path: 'WithInterfaces', + }, + { + type: 'FIELD_ARGUMENT_DESCRIPTION_CHANGED', + criticality: { level: 'NON_BREAKING' }, + message: + "Description for argument 'a' on field 'WithArguments.a' changed from 'Meh' to 'Description for a'", + meta: { + typeName: 'WithArguments', + fieldName: 'a', + argumentName: 'a', + oldDescription: 'Meh', + newDescription: 'Description for a', + }, + path: 'WithArguments.a.a', + }, + { + type: 'FIELD_ARGUMENT_TYPE_CHANGED', + criticality: { + level: 'BREAKING', + reason: + "Changing the type of a field's argument can cause existing queries that use this argument to error.", + }, + message: "Type for argument 'b' on field 'WithArguments.a' changed from 'String' to 'String!'", + meta: { + typeName: 'WithArguments', + fieldName: 'a', + argumentName: 'b', + oldArgumentType: 'String', + newArgumentType: 'String!', + isSafeArgumentTypeChange: false, + }, + path: 'WithArguments.a.b', + }, + { + type: 'FIELD_ARGUMENT_DEFAULT_CHANGED', + criticality: { + level: 'DANGEROUS', + reason: + 'Changing the default value for an argument may change the runtime behaviour of a field if it was never provided.', + }, + message: "Default value for argument 'arg' on field 'WithArguments.b' changed from '1' to '2'", + meta: { + typeName: 'WithArguments', + fieldName: 'b', + argumentName: 'arg', + oldDefaultValue: '1', + newDefaultValue: '2', + }, + path: 'WithArguments.b.arg', + }, + { + type: 'ENUM_VALUE_ADDED', + criticality: { + level: 'DANGEROUS', + reason: + 'Adding an enum value may break existing clients that were not programming defensively against an added case when querying an enum.', + }, + message: "Enum value 'D' was added to enum 'Options'", + meta: { + enumName: 'Options', + addedEnumValueName: 'D', + addedToNewType: false, + addedDirectiveDescription: null, + }, + path: 'Options.D', + }, + { + type: 'ENUM_VALUE_REMOVED', + criticality: { + level: 'BREAKING', + reason: + 'Removing an enum value will cause existing queries that use this enum value to error.', + }, + message: "Enum value 'C' was removed from enum 'Options'", + meta: { enumName: 'Options', removedEnumValueName: 'C', isEnumValueDeprecated: false }, + path: 'Options.C', + }, + { + criticality: { level: 'NON_BREAKING' }, + type: 'ENUM_VALUE_DESCRIPTION_CHANGED', + message: "Description 'Stuff' was added to enum value 'Options.A'", + path: 'Options.A', + meta: { + enumName: 'Options', + enumValueName: 'A', + oldEnumValueDescription: null, + newEnumValueDescription: 'Stuff', + }, + }, + { + criticality: { level: 'NON_BREAKING' }, + type: 'ENUM_VALUE_DEPRECATION_REASON_ADDED', + message: "Enum value 'Options.E' was deprecated with reason 'No longer supported'", + path: 'Options.E.@deprecated', + meta: { + enumName: 'Options', + enumValueName: 'E', + addedValueDeprecationReason: 'No longer supported', + }, + }, + { + criticality: { + level: 'NON_BREAKING', + reason: "Directive 'deprecated' was added to enum value 'Options.E'", + }, + type: 'DIRECTIVE_USAGE_ENUM_VALUE_ADDED', + message: "Directive 'deprecated' was added to enum value 'Options.E'", + path: 'Options.E.@deprecated', + meta: { + enumName: 'Options', + enumValueName: 'E', + addedDirectiveName: 'deprecated', + addedToNewType: false, + }, + }, + { + criticality: { level: 'NON_BREAKING' }, + type: 'ENUM_VALUE_DEPRECATION_REASON_CHANGED', + message: "Enum value 'Options.F' deprecation reason changed from 'Old' to 'New'", + path: 'Options.F.@deprecated', + meta: { + enumName: 'Options', + enumValueName: 'F', + oldEnumValueDeprecationReason: 'Old', + newEnumValueDeprecationReason: 'New', + }, + }, + { + criticality: { level: 'NON_BREAKING' }, + type: 'DIRECTIVE_ADDED', + message: "Directive 'yolo2' was added", + path: '@yolo2', + meta: { + addedDirectiveName: 'yolo2', + addedDirectiveDescription: null, + addedDirectiveLocations: ['FIELD'], + addedDirectiveRepeatable: false, + }, + }, + { + criticality: { level: 'NON_BREAKING' }, + type: 'DIRECTIVE_LOCATION_ADDED', + message: "Location 'FIELD' was added to directive 'yolo2'", + path: '@yolo2', + meta: { directiveName: 'yolo2', addedDirectiveLocation: 'FIELD' }, + }, + { + criticality: { + level: 'NON_BREAKING', + reason: + 'Refer to the directive usage for the breaking status. If the directive is new and therefore unused, then adding an argument does not risk breaking clients.', + }, + type: 'DIRECTIVE_ARGUMENT_ADDED', + message: "Argument 'someArg' was added to directive 'yolo2'", + path: '@yolo2', + meta: { + directiveName: 'yolo2', + addedDirectiveArgumentName: 'someArg', + addedDirectiveArgumentType: 'String!', + addedDirectiveDefaultValue: '', + addedDirectiveArgumentTypeIsNonNull: true, + addedDirectiveArgumentDescription: 'Included when true.', + addedToNewDirective: true, + }, + }, + { + criticality: { + level: 'BREAKING', + reason: + 'A directive could be in use of a client application. Removing it could break the client application.', + }, + type: 'DIRECTIVE_REMOVED', + message: "Directive 'willBeRemoved' was removed", + path: '@willBeRemoved', + meta: { removedDirectiveName: 'willBeRemoved' }, + }, + { + criticality: { level: 'NON_BREAKING' }, + type: 'DIRECTIVE_DESCRIPTION_CHANGED', + message: "Directive 'yolo' description changed from 'Old' to 'New'", + path: '@yolo', + meta: { directiveName: 'yolo', oldDirectiveDescription: 'Old', newDirectiveDescription: 'New' }, + }, + { + criticality: { level: 'NON_BREAKING' }, + type: 'DIRECTIVE_LOCATION_ADDED', + message: "Location 'FIELD_DEFINITION' was added to directive 'yolo'", + path: '@yolo', + meta: { directiveName: 'yolo', addedDirectiveLocation: 'FIELD_DEFINITION' }, + }, + { + criticality: { + level: 'BREAKING', + reason: + 'A directive could be in use of a client application. Removing it could break the client application.', + }, + type: 'DIRECTIVE_LOCATION_REMOVED', + message: "Location 'FRAGMENT_SPREAD' was removed from directive 'yolo'", + path: '@yolo', + meta: { directiveName: 'yolo', removedDirectiveLocation: 'FRAGMENT_SPREAD' }, + }, + { + criticality: { + level: 'BREAKING', + reason: + 'A directive could be in use of a client application. Removing it could break the client application.', + }, + type: 'DIRECTIVE_LOCATION_REMOVED', + message: "Location 'INLINE_FRAGMENT' was removed from directive 'yolo'", + path: '@yolo', + meta: { directiveName: 'yolo', removedDirectiveLocation: 'INLINE_FRAGMENT' }, + }, + { + criticality: { + level: 'BREAKING', + reason: + 'A directive argument could be in use of a client application. Removing the argument can break client applications.', + }, + type: 'DIRECTIVE_ARGUMENT_REMOVED', + message: "Argument 'willBeRemoved' was removed from directive 'yolo'", + path: '@yolo.willBeRemoved', + meta: { directiveName: 'yolo', removedDirectiveArgumentName: 'willBeRemoved' }, + }, + { + criticality: { level: 'NON_BREAKING' }, + type: 'DIRECTIVE_ARGUMENT_DESCRIPTION_CHANGED', + message: + "Description for argument 'someArg' on directive 'yolo' changed from 'Included when true.' to 'someArg does stuff'", + path: '@yolo.someArg', + meta: { + directiveName: 'yolo', + directiveArgumentName: 'someArg', + oldDirectiveArgumentDescription: 'Included when true.', + newDirectiveArgumentDescription: 'someArg does stuff', + }, + }, + { + criticality: { level: 'BREAKING' }, + type: 'DIRECTIVE_ARGUMENT_TYPE_CHANGED', + message: "Type for argument 'someArg' on directive 'yolo' changed from 'Boolean!' to 'String!'", + path: '@yolo.someArg', + meta: { + directiveName: 'yolo', + directiveArgumentName: 'someArg', + oldDirectiveArgumentType: 'Boolean!', + newDirectiveArgumentType: 'String!', + isSafeDirectiveArgumentTypeChange: false, + }, + }, + { + criticality: { + level: 'DANGEROUS', + reason: + 'Changing the default value for an argument may change the runtime behaviour of a field if it was never provided.', + }, + type: 'DIRECTIVE_ARGUMENT_DEFAULT_VALUE_CHANGED', + message: "Default value '\"Test\"' was added to argument 'anotherArg' on directive 'yolo'", + path: '@yolo.anotherArg', + meta: { + directiveName: 'yolo', + directiveArgumentName: 'anotherArg', + newDirectiveArgumentDefaultValue: '"Test"', + }, + }, +]; + +function toPascalCase(str: string) { + // Handle empty or non-string inputs + if (typeof str !== 'string' || str.length === 0) { + return ''; + } + + // Split the string by common delimiters (spaces, hyphens, underscores) + const words = str.split(/[\s\-_]+/); + + // Capitalize the first letter of each word and convert the rest to lowercase + const pascalCasedWords = words.map(word => { + if (word.length === 0) { + return ''; + } + return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(); + }); + + // Join the words together + return pascalCasedWords.join(''); +} + +export const schemaProposal: NonNullable = ( _parent, { input: { id } }, _ctx, @@ -13,12 +699,48 @@ export const schemaProposal: NonNullable = asy updatedAt: Date.now(), commentsCount: 5, title: 'This adds some stuff to the thing.', + versions: { + pageInfo: { + startCursor: 'start', + endCursor: 'end', + hasNextPage: false, + hasPreviousPage: false, + }, + edges: [ + { + cursor: '12345', + node: { + id: '12345', + serviceName: 'panda', + changes: changes.map(c => { + return { + path: c.path, + isSafeBasedOnUsage: false, // @todo + message: c.message, + criticality: c.criticality.level as CriticalityLevel, + criticalityReason: c.criticality.reason, + severityLevel: c.criticality.level as SeverityLevelType, + severityReason: c.criticality.reason, + meta: { + __typename: toPascalCase(c.type), + ...(c.meta as any), + }, + }; + }), + createdAt: Date.now(), + schemaProposal: { + /* ??? */ + } as any, + }, + }, + ], + }, user: { id: 'asdffff', displayName: 'jdolle', fullName: 'Jeff Dolle', email: 'jdolle+test@the-guild.dev', - }, + } as any, reviews: { edges: [ { diff --git a/packages/services/api/src/modules/proposals/resolvers/Query/schemaProposalReviews.ts b/packages/services/api/src/modules/proposals/resolvers/Query/schemaProposalReviews.ts index 2ee7be2903..f1b716fe0b 100644 --- a/packages/services/api/src/modules/proposals/resolvers/Query/schemaProposalReviews.ts +++ b/packages/services/api/src/modules/proposals/resolvers/Query/schemaProposalReviews.ts @@ -9,6 +9,7 @@ export const schemaProposalReviews: NonNullable = { + /* Implement SchemaChange resolver logic here */ + meta: ({ meta }, _arg, _ctx) => { + /* SchemaChange.meta resolver is required because SchemaChange.meta and SchemaChangeMapper.meta are not compatible */ + return meta; + }, +}; diff --git a/packages/services/api/src/modules/schema/resolvers/SchemaChange.ts b/packages/services/api/src/modules/schema/resolvers/SchemaChange.ts index 26cac07f58..30936229de 100644 --- a/packages/services/api/src/modules/schema/resolvers/SchemaChange.ts +++ b/packages/services/api/src/modules/schema/resolvers/SchemaChange.ts @@ -18,7 +18,19 @@ const severityMap: Record = { [CriticalityLevelEnum.Breaking]: 'BREAKING', }; -export const SchemaChange: SchemaChangeResolvers = { +export const SchemaChange: Pick< + SchemaChangeResolvers, + | 'approval' + | 'criticality' + | 'criticalityReason' + | 'isSafeBasedOnUsage' + | 'message' + | 'path' + | 'severityLevel' + | 'severityReason' + | 'usageStatistics' + | '__isTypeOf' +> = { message: (change, args) => { return args.withSafeBasedOnUsageNote && change.isSafeBasedOnUsage === true ? `${change.message} (non-breaking based on usage)` diff --git a/packages/web/app/package.json b/packages/web/app/package.json index 05e215662b..f08245bc54 100644 --- a/packages/web/app/package.json +++ b/packages/web/app/package.json @@ -20,6 +20,8 @@ "@graphiql/react": "1.0.0-alpha.4", "@graphiql/toolkit": "0.9.1", "@graphql-codegen/client-preset-swc-plugin": "0.2.0", + "@graphql-inspector/core": "6.2.1", + "@graphql-inspector/patch": "file:../../../../graphql-inspector/packages/patch", "@graphql-tools/mock": "9.0.22", "@graphql-typed-document-node/core": "3.2.0", "@headlessui/react": "2.2.0", diff --git a/packages/web/app/src/components/proposal/Review.tsx b/packages/web/app/src/components/proposal/Review.tsx index 25cddbc62a..d90b891b57 100644 --- a/packages/web/app/src/components/proposal/Review.tsx +++ b/packages/web/app/src/components/proposal/Review.tsx @@ -22,7 +22,6 @@ const ProposalOverview_ReviewCommentsFragment = graphql(/** GraphQL */ ` export function ReviewComments(props: { review: FragmentType; - lineNumber: number; }) { const review = useFragment(ProposalOverview_ReviewCommentsFragment, props.review); if (!review.comments) { diff --git a/packages/web/app/src/components/proposal/__tests__/collect-coordinate-locations.spec.ts b/packages/web/app/src/components/proposal/__tests__/collect-coordinate-locations.spec.ts deleted file mode 100644 index c1bec574a3..0000000000 --- a/packages/web/app/src/components/proposal/__tests__/collect-coordinate-locations.spec.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { buildSchema, Source } from 'graphql'; -import { collectCoordinateLocations } from '../collect-coordinate-locations'; - -const coordinatesFromSDL = (sdl: string) => { - const schema = buildSchema(sdl); - return collectCoordinateLocations(schema, new Source(sdl)); -}; - -describe('schema coordinate location collection', () => { - describe('should include the location of', () => { - test('types', () => { - const sdl = /** GraphQL */ ` - type Query { - foo: Foo - } - type Foo { - id: ID! - } - `; - const coords = coordinatesFromSDL(sdl); - expect(coords.get('Foo')).toBe(5); - }); - - test('fields', () => { - const sdl = /** GraphQL */ ` - type Query { - foo: Foo - } - - type Foo { - id: ID! - } - `; - const coords = coordinatesFromSDL(sdl); - expect(coords.get('Query.foo')).toBe(3); - }); - - test('arguments', () => { - const sdl = /** GraphQL */ ` - type Query { - foo(bar: Boolean): Boolean - } - `; - const coords = coordinatesFromSDL(sdl); - expect(coords.get('Query.foo.bar')).toBe(3); - }); - - test('scalars', () => { - const sdl = /** GraphQL */ ` - scalar Foo - type Query { - foo(bar: Boolean): Boolean - } - `; - const coords = coordinatesFromSDL(sdl); - expect(coords.get('Foo')).toBe(2); - }); - - test('enums', () => { - const sdl = /** GraphQL */ ` - enum Foo { - FIRST - SECOND - THIRD - } - type Query { - foo(bar: Boolean): Foo - } - `; - const coords = coordinatesFromSDL(sdl); - expect(coords.get('Foo')).toBe(2); - expect(coords.get('Foo.FIRST')).toBe(3); - expect(coords.get('Foo.SECOND')).toBe(4); - expect(coords.get('Foo.THIRD')).toBe(5); - }); - - test('unions', () => { - const sdl = /** GraphQL */ ` - union Foo = - | Bar - | Blar - type Bar { - bar: Boolean - } - type Blar { - blar: String - } - type Query { - foo: Foo - } - `; - const coords = coordinatesFromSDL(sdl); - expect(coords.get('Foo')).toBe(2); - // @note The AST is limited and does not give the location of union values. - // expect(coords.get('Foo.Bar')).toBe(2); - // expect(coords.get('Foo.Blar')).toBe(2); - }); - - test('subscriptions', () => { - const sdl = /** GraphQL */ ` - type Subscription { - foo: String - } - `; - const coords = coordinatesFromSDL(sdl); - expect(coords.get('Subscription.foo')).toBe(3); - }); - }); -}); diff --git a/packages/web/app/src/components/proposal/collect-coordinate-locations.ts b/packages/web/app/src/components/proposal/collect-coordinate-locations.ts deleted file mode 100644 index 9ec11cbe48..0000000000 --- a/packages/web/app/src/components/proposal/collect-coordinate-locations.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { - getLocation, - GraphQLArgument, - GraphQLEnumType, - GraphQLField, - GraphQLInputObjectType, - GraphQLInterfaceType, - GraphQLNamedType, - GraphQLObjectType, - GraphQLSchema, - GraphQLUnionType, - isIntrospectionType, - Location, - Source, -} from 'graphql'; - -export function collectCoordinateLocations( - schema: GraphQLSchema, - source: Source, -): Map { - const coordinateToLine = new Map(); - - const collectObjectType = ( - type: GraphQLObjectType | GraphQLInputObjectType | GraphQLInterfaceType, - ) => { - collect(type.name, type.astNode?.loc); - const fields = type.getFields(); - if (fields) { - for (const field of Object.values(fields)) { - collectField(type, field); - } - } - }; - - const collect = (coordinate: string, location: Location | undefined) => { - const sourceLoc = location && getLocation(source, location.start); - if (sourceLoc?.line) { - coordinateToLine.set(coordinate, sourceLoc.line); - } else { - console.warn(`Location not found for "${coordinate}"`); - } - }; - - const collectEnumType = (type: GraphQLEnumType) => { - collect(type.name, type.astNode?.loc); - for (const val of type.getValues()) { - const coord = `${type.name}.${val.name}`; - collect(coord, val.astNode?.loc); - } - }; - - const collectUnionType = (type: GraphQLUnionType) => { - collect(type.name, type.astNode?.loc); - // for (const unionType of type.getTypes()) { - // const coordinate = `${type.name}.${unionType.name}`; - // collect(coordinate, type.astNode?.loc); - // } - }; - - const collectNamedType = (type: GraphQLNamedType) => { - if (isIntrospectionType(type)) { - return; - } - - if ( - type instanceof GraphQLObjectType || - type instanceof GraphQLInputObjectType || - type instanceof GraphQLInterfaceType - ) { - collectObjectType(type); - } else if (type instanceof GraphQLUnionType) { - collectUnionType(type); - } else if (type instanceof GraphQLEnumType) { - collectEnumType(type); - } else { - collect(type.name, type.astNode?.loc); - } - }; - - const collectArg = ( - type: GraphQLObjectType | GraphQLInputObjectType | GraphQLInterfaceType, - field: GraphQLField, - arg: GraphQLArgument, - ) => { - const coord = `${type.name}.${field.name}.${arg.name}`; - collect(coord, arg.astNode?.loc); - }; - - const collectField = ( - type: GraphQLObjectType | GraphQLInputObjectType | GraphQLInterfaceType, - field: GraphQLField, - ) => { - const coord = `${type.name}.${field.name}`; - collect(coord, field.astNode?.loc); - - for (const arg of field.args) { - collectArg(type, field, arg); - } - }; - - for (const named of Object.values(schema.getTypeMap())) { - collectNamedType(named); - } - - return coordinateToLine; -} diff --git a/packages/web/app/src/components/proposal/index.tsx b/packages/web/app/src/components/proposal/index.tsx new file mode 100644 index 0000000000..df5407d1f1 --- /dev/null +++ b/packages/web/app/src/components/proposal/index.tsx @@ -0,0 +1,556 @@ +import { buildSchema } from 'graphql'; +import { FragmentType, graphql, useFragment } from '@/gql'; +import type { Change } from '@graphql-inspector/core'; +import { patchSchema } from '@graphql-inspector/patch'; +import { ReviewComments } from './Review'; +import { SchemaDiff } from './schema-diff/schema-diff'; + +/** + * Fragment containing a list of reviews. Each review is tied to a coordinate + * and may contain one or more comments. This should be fetched in its entirety, + * but this can be done serially because there should not be so many reviews within + * a single screen's height that it matters. + * */ +const ProposalOverview_ReviewsFragment = graphql(/** GraphQL */ ` + fragment ProposalOverview_ReviewsFragment on SchemaProposalReviewConnection { + pageInfo { + startCursor + } + edges { + cursor + node { + id + schemaProposalVersion { + id + serviceName + } + stageTransition + lineText + schemaCoordinate + ...ProposalOverview_ReviewCommentsFragment + } + } + } +`); + +const ProposalOverview_ChangeFragment = graphql(/* GraphQL */ ` + fragment ProposalOverview_ChangeFragment on SchemaChange { + message + path + severityLevel + meta { + __typename + ... on FieldArgumentDescriptionChanged { + argumentName + fieldName + newDescription + oldDescription + typeName + } + ... on FieldArgumentTypeChanged { + argumentName + fieldName + isSafeArgumentTypeChange + newArgumentType + oldArgumentType + typeName + } + ... on DirectiveRemoved { + removedDirectiveName + } + ... on DirectiveAdded { + addedDirectiveDescription + addedDirectiveLocations + addedDirectiveName + addedDirectiveRepeatable + } + ... on DirectiveDescriptionChanged { + directiveName + newDirectiveDescription + oldDirectiveDescription + } + ... on DirectiveLocationAdded { + addedDirectiveLocation + directiveName + } + ... on DirectiveLocationRemoved { + directiveName + removedDirectiveLocation + } + ... on DirectiveArgumentAdded { + addedDirectiveArgumentDescription + addedDirectiveArgumentName + addedDirectiveArgumentType + addedDirectiveArgumentTypeIsNonNull + addedDirectiveDefaultValue + addedToNewDirective + directiveName + } + ... on DirectiveArgumentRemoved { + directiveName + removedDirectiveArgumentName + } + ... on DirectiveArgumentDescriptionChanged { + directiveArgumentName + directiveName + newDirectiveArgumentDescription + oldDirectiveArgumentDescription + } + ... on DirectiveArgumentDefaultValueChanged { + directiveArgumentName + directiveName + newDirectiveArgumentDefaultValue + oldDirectiveArgumentDefaultValue + } + ... on DirectiveArgumentTypeChanged { + directiveArgumentName + directiveName + isSafeDirectiveArgumentTypeChange + newDirectiveArgumentType + oldDirectiveArgumentType + } + ... on EnumValueRemoved { + enumName + isEnumValueDeprecated + removedEnumValueName + } + ... on EnumValueAdded { + addedDirectiveDescription + addedEnumValueName + addedToNewType + enumName + } + ... on EnumValueDescriptionChanged { + enumName + enumValueName + newEnumValueDescription + oldEnumValueDescription + } + ... on EnumValueDeprecationReasonChanged { + enumName + enumValueName + newEnumValueDeprecationReason + oldEnumValueDeprecationReason + } + ... on EnumValueDeprecationReasonAdded { + addedValueDeprecationReason + enumName + enumValueName + } + ... on EnumValueDeprecationReasonRemoved { + enumName + enumValueName + removedEnumValueDeprecationReason + } + ... on FieldRemoved { + isRemovedFieldDeprecated + removedFieldName + typeName + typeType + } + ... on FieldAdded { + addedFieldName + addedFieldReturnType + typeName + typeType + } + ... on FieldDescriptionChanged { + fieldName + newDescription + oldDescription + typeName + } + ... on FieldDescriptionAdded { + addedDescription + fieldName + typeName + } + ... on FieldDescriptionRemoved { + fieldName + typeName + } + ... on FieldDeprecationAdded { + deprecationReason + fieldName + typeName + } + ... on FieldDeprecationRemoved { + fieldName + typeName + } + ... on FieldDeprecationReasonChanged { + fieldName + newDeprecationReason + oldDeprecationReason + typeName + } + ... on FieldDeprecationReasonAdded { + addedDeprecationReason + fieldName + typeName + } + ... on FieldDeprecationReasonRemoved { + fieldName + typeName + } + ... on FieldTypeChanged { + fieldName + isSafeFieldTypeChange + newFieldType + oldFieldType + typeName + } + ... on DirectiveUsageUnionMemberAdded { + addedDirectiveName + addedToNewType + addedUnionMemberTypeName + addedUnionMemberTypeName + unionName + } + ... on DirectiveUsageUnionMemberRemoved { + removedDirectiveName + removedUnionMemberTypeName + unionName + } + ... on FieldArgumentAdded { + addedArgumentName + addedArgumentType + addedToNewField + fieldName + hasDefaultValue + isAddedFieldArgumentBreaking + typeName + } + ... on FieldArgumentRemoved { + fieldName + removedFieldArgumentName + removedFieldType + typeName + } + ... on InputFieldRemoved { + inputName + isInputFieldDeprecated + removedFieldName + } + ... on InputFieldAdded { + addedFieldDefault + addedInputFieldName + addedInputFieldType + addedToNewType + inputName + isAddedInputFieldTypeNullable + } + ... on InputFieldDescriptionAdded { + addedInputFieldDescription + inputFieldName + inputName + } + ... on InputFieldDescriptionRemoved { + inputFieldName + inputName + removedDescription + } + ... on InputFieldDescriptionChanged { + inputFieldName + inputName + newInputFieldDescription + oldInputFieldDescription + } + ... on InputFieldDefaultValueChanged { + inputFieldName + inputName + newDefaultValue + oldDefaultValue + } + ... on InputFieldTypeChanged { + inputFieldName + inputName + isInputFieldTypeChangeSafe + newInputFieldType + oldInputFieldType + } + ... on ObjectTypeInterfaceAdded { + addedInterfaceName + addedToNewType + objectTypeName + } + ... on ObjectTypeInterfaceRemoved { + objectTypeName + removedInterfaceName + } + ... on SchemaQueryTypeChanged { + newQueryTypeName + oldQueryTypeName + } + ... on SchemaMutationTypeChanged { + newMutationTypeName + oldMutationTypeName + } + ... on SchemaSubscriptionTypeChanged { + newSubscriptionTypeName + oldSubscriptionTypeName + } + ... on TypeRemoved { + removedTypeName + } + ... on TypeAdded { + addedTypeKind + addedTypeName + } + ... on TypeKindChanged { + newTypeKind + oldTypeKind + typeName + } + ... on TypeDescriptionChanged { + newTypeDescription + oldTypeDescription + typeName + } + ... on TypeDescriptionAdded { + addedTypeDescription + typeName + } + ... on TypeDescriptionRemoved { + removedTypeDescription + typeName + } + ... on UnionMemberRemoved { + removedUnionMemberTypeName + unionName + } + ... on UnionMemberAdded { + addedToNewType + addedUnionMemberTypeName + unionName + } + ... on DirectiveUsageEnumAdded { + addedDirectiveName + addedToNewType + enumName + } + ... on DirectiveUsageEnumRemoved { + enumName + removedDirectiveName + } + ... on DirectiveUsageEnumValueAdded { + addedDirectiveName + addedToNewType + enumName + enumValueName + } + ... on DirectiveUsageEnumValueRemoved { + enumName + enumValueName + removedDirectiveName + } + ... on DirectiveUsageInputObjectRemoved { + inputObjectName + isRemovedInputFieldTypeNullable + removedDirectiveName + removedInputFieldName + removedInputFieldType + } + ... on DirectiveUsageInputObjectAdded { + addedDirectiveName + addedInputFieldName + addedInputFieldType + addedToNewType + inputObjectName + isAddedInputFieldTypeNullable + } + ... on DirectiveUsageInputFieldDefinitionAdded { + addedDirectiveName + addedToNewType + inputFieldName + inputFieldType + inputObjectName + } + ... on DirectiveUsageInputFieldDefinitionRemoved { + inputFieldName + inputObjectName + removedDirectiveName + } + ... on DirectiveUsageFieldAdded { + addedDirectiveName + fieldName + typeName + } + ... on DirectiveUsageFieldRemoved { + fieldName + removedDirectiveName + typeName + } + ... on DirectiveUsageScalarAdded { + addedDirectiveName + addedToNewType + scalarName + } + ... on DirectiveUsageScalarRemoved { + removedDirectiveName + scalarName + } + ... on DirectiveUsageObjectAdded { + addedDirectiveName + addedToNewType + objectName + } + ... on DirectiveUsageObjectRemoved { + objectName + removedDirectiveName + } + ... on DirectiveUsageInterfaceAdded { + addedDirectiveName + addedToNewType + interfaceName + } + ... on DirectiveUsageSchemaAdded { + addedDirectiveName + addedToNewType + schemaTypeName + } + ... on DirectiveUsageSchemaRemoved { + removedDirectiveName + schemaTypeName + } + ... on DirectiveUsageFieldDefinitionAdded { + addedDirectiveName + addedToNewType + fieldName + typeName + } + ... on DirectiveUsageFieldDefinitionRemoved { + fieldName + removedDirectiveName + typeName + } + ... on DirectiveUsageArgumentDefinitionRemoved { + argumentName + fieldName + removedDirectiveName + typeName + } + ... on DirectiveUsageInterfaceRemoved { + interfaceName + removedDirectiveName + } + ... on DirectiveUsageArgumentDefinitionAdded { + addedDirectiveName + addedToNewType + argumentName + fieldName + typeName + } + ... on DirectiveUsageArgumentAdded { + addedArgumentName + addedArgumentValue + directiveName + oldArgumentValue + parentArgumentName + parentEnumValueName + parentFieldName + parentTypeName + } + ... on DirectiveUsageArgumentRemoved { + directiveName + parentArgumentName + parentEnumValueName + parentFieldName + parentTypeName + removedArgumentName + } + } + } +`); + +function toUpperSnakeCase(str: string) { + // Use a regular expression to find uppercase letters and insert underscores + // The 'g' flag ensures all occurrences are replaced. + // The 'replace' function uses a callback to add an underscore before the matched uppercase letter. + const snakeCaseString = str.replace(/([A-Z])/g, (match, p1, offset) => { + // If it's the first character, don't add an underscore + if (offset === 0) { + return p1; + } + return `_${p1}`; + }); + + return snakeCaseString.toUpperCase(); +} + +export function Proposal(props: { + baseSchemaSDL: string; + changes: (FragmentType | null | undefined)[] | null; + serviceName?: string; + latestProposalVersionId: string; + reviews: FragmentType | null; +}) { + /** + * Reviews can change position because the coordinate changes... placing them out of order from their original line numbers. + * Because of this, we have to fetch every single page of comments... + * But because generally they are in order, we can take our time doing this. So fetch in small batches. + * + * Odds are there will never be so many reviews/comments that this is even a problem. + */ + const reviewsConnection = useFragment(ProposalOverview_ReviewsFragment, props.reviews); + try { + const serviceReviews = + reviewsConnection?.edges?.filter(edge => { + const { schemaProposalVersion } = edge.node; + return schemaProposalVersion?.serviceName === props.serviceName; + }) ?? []; + const reviewssByCoordinate = serviceReviews.reduce((result, review) => { + const coordinate = review.node.schemaCoordinate; + if (coordinate) { + const reviews = result.get(coordinate); + if (reviews) { + result.set(review.node.schemaCoordinate!, [...reviews, review]); + } else { + result.set(review.node.schemaCoordinate!, [review]); + } + } + return result; + }, new Map>()); + + const annotations = (coordinate: string) => { + const reviews = reviewssByCoordinate.get(coordinate); + if (reviews) { + return ( + <>{reviews?.map(({ node, cursor }) => )} + ); + } + return null; + }; + + const before = buildSchema(props.baseSchemaSDL, { assumeValid: true, assumeValidSDL: true }); + const changes = + props.changes + ?.map((change): Change | null => { + const c = useFragment(ProposalOverview_ChangeFragment, change); + if (c) { + return { + criticality: { + // isSafeBasedOnUsage: , + // reason: , + level: c.severityLevel as any, + }, + message: c.message, + meta: c.meta, + type: (c.meta && toUpperSnakeCase(c.meta?.__typename)) ?? '', // convert to upper snake + path: c.path?.join('.'), + }; + } + return null; + }) + .filter(c => !!c) ?? []; + const after = patchSchema(before, changes, { throwOnError: false }); + return ; + } catch (e: unknown) { + return ( + <> +
Invalid SDL
+
{e instanceof Error ? e.message : String(e)}
+ + ); + } +} diff --git a/packages/web/app/src/components/proposal/print-diff/print-diff.tsx b/packages/web/app/src/components/proposal/print-diff/print-diff.tsx deleted file mode 100644 index 65cbadbdee..0000000000 --- a/packages/web/app/src/components/proposal/print-diff/print-diff.tsx +++ /dev/null @@ -1,54 +0,0 @@ -/* eslint-disable tailwindcss/no-custom-classname */ -import type { GraphQLSchema } from 'graphql'; -import { isIntrospectionType, isSpecifiedDirective } from 'graphql'; -import { isPrimitive } from '@graphql-inspector/core/utils/graphql'; -import { compareLists } from './compare-lists'; -import { ChangeDocument, DiffDirective, DiffType, SchemaDefinitionDiff } from './components'; - -export function printSchemaDiff(oldSchema: GraphQLSchema, newSchema: GraphQLSchema): JSX.Element { - const { - added: addedTypes, - mutual: mutualTypes, - removed: removedTypes, - } = compareLists( - Object.values(oldSchema.getTypeMap()).filter(t => !isPrimitive(t) && !isIntrospectionType(t)), - Object.values(newSchema.getTypeMap()).filter(t => !isPrimitive(t) && !isIntrospectionType(t)), - ); - - const { - added: addedDirectives, - mutual: mutualDirectives, - removed: removedDirectives, - } = compareLists( - oldSchema.getDirectives().filter(d => !isSpecifiedDirective(d)), - newSchema.getDirectives().filter(d => !isSpecifiedDirective(d)), - ); - - return ( - - {removedDirectives.map(d => ( - - ))} - {addedDirectives.map(d => ( - - ))} - {mutualDirectives.map(d => ( - - ))} - - {addedTypes.map(a => ( - - ))} - {removedTypes.map(a => ( - - ))} - {mutualTypes.map(a => ( - - ))} - - ); -} diff --git a/packages/web/app/src/components/proposal/proposal-sdl.tsx b/packages/web/app/src/components/proposal/proposal-sdl.tsx deleted file mode 100644 index 97ce7ab07b..0000000000 --- a/packages/web/app/src/components/proposal/proposal-sdl.tsx +++ /dev/null @@ -1,257 +0,0 @@ -import { buildSchema } from 'graphql'; -import { FragmentType, graphql, useFragment } from '@/gql'; -import type { Change } from '@graphql-inspector/core'; -import { printSchemaDiff } from './print-diff/print-diff'; - -const ProposalOverview_ReviewsFragment = graphql(/** GraphQL */ ` - fragment ProposalOverview_ReviewsFragment on SchemaProposalReviewConnection { - pageInfo { - startCursor - } - edges { - cursor - node { - id - schemaProposalVersion { - id - serviceName - # schemaSDL - } - stageTransition - lineNumber - lineText - schemaCoordinate - ...ProposalOverview_ReviewCommentsFragment - } - } - } -`); - -// @todo should this be done on proposal update AND then the proposal can reference the check???? -// So it can get the changes - -// const ProposalOverview_CheckSchema = graphql(/** GraphQL */` -// mutation ProposalOverview_CheckSchema($target: TargetReferenceInput!, $sdl: String!, $service: ID) { -// schemaCheck(input: { -// target: $target -// sdl: $sdl -// service: $service -// }) { -// __typename -// ...on SchemaCheckSuccess { - -// } -// ...on SchemaCheckError { -// changes { -// edges { -// node { -// path -// } -// } -// } -// } -// } -// } -// `); - -// type ReviewNode = NonNullable[number]['node']; - -export function ProposalSDL(props: { - baseSchemaSDL: string; - changes: Change[]; - serviceName?: string; - latestProposalVersionId: string; - reviews: FragmentType | null; -}) { - /** - * Reviews can change position because the coordinate changes... placing them out of order from their original line numbers. - * Because of this, we have to fetch every single page of comments... - * But because generally they are in order, we can take our time doing this. So fetch in small batches. - * - * Odds are there will never be so many reviews/comments that this is even a problem. - */ - const _reviewsConnection = useFragment(ProposalOverview_ReviewsFragment, props.reviews); - - try { - // @todo use props.baseSchemaSDL - const baseSchemaSDL = /* GraphQL */ ` - """ - This is old - """ - directive @old on FIELD - - directive @foo on OBJECT - - "Doesn't change" - type Query { - okay: Boolean @deprecated - dokay: Boolean - } - `; - - const patchedSchemaSDL = /* GraphQL */ ` - """ - Custom scalar that can represent any valid JSON - """ - scalar JSON - - directive @foo repeatable on OBJECT | FIELD - - """ - Enhances fields with meta data - """ - directive @meta( - "The metadata key" - name: String! - "The value of the metadata" - content: String! - ) on FIELD - - "Doesn't change" - type Query { - okay: Boolean @deprecated(reason: "Use 'ok' instead.") - ok: Boolean @meta(name: "team", content: "hive") - - """ - This is a new description on a field - """ - dokay(foo: String = "What"): Boolean! - } - - "Yups" - enum Status { - OKAY - """ - Hi - """ - SMOKAY - } - - """ - Crusty flaky delicious goodness. - """ - type Pie { - name: String! - flavor: String! - slices: Int - } - - """ - Delicious baked flour based product - """ - type Cake { - name: String! - flavor: String! - tiers: Int! - } - - input FooInput { - """ - Hi - """ - asdf: String @foo - } - - union Dessert = Pie | Cake - `; // APPLY PATCH - - return printSchemaDiff( - buildSchema(baseSchemaSDL, { assumeValid: true, assumeValidSDL: true }), - buildSchema(patchedSchemaSDL, { assumeValid: true, assumeValidSDL: true }), - ); - - // // @note assume reviews are specific to the current service... - // const globalReviews: ReviewNode[] = []; - // const reviewsByLine = new Map(); - // const serviceReviews = - // reviewsConnection?.edges?.filter(edge => { - // const { schemaProposalVersion } = edge.node; - // return schemaProposalVersion?.serviceName === props.serviceName; - // }) ?? []; - - // for (const edge of serviceReviews) { - // const { lineNumber, schemaCoordinate, schemaProposalVersion } = edge.node; - // const coordinateLine = !!schemaCoordinate && coordinateToLineMap.get(schemaCoordinate); - // const isStale = - // !coordinateLine && schemaProposalVersion?.id !== props.latestProposalVersionId; - // const line = coordinateLine || lineNumber; - // if (line) { - // reviewsByLine.set(line, { ...edge.node, isStale }); - // } else { - // globalReviews.push(edge.node); - // } - // } - - // const baseSchemaSdlLines = baseSchemaSDL.split('\n'); - // let diffLineNumber = 0; - // return ( - // <> - // - // {patchedSchemaSDL.split('\n').flatMap((txt, index) => { - // const lineNumber = index + 1; - // const diffLineMatch = txt === baseSchemaSdlLines[diffLineNumber]; - // const elements = [ - // - // {txt} - // , - // ]; - // if (diffLineMatch) { - // diffLineNumber = diffLineNumber + 1; - // } - - // const review = reviewsByLine.get(lineNumber); - // if (review) { - // if (review.isStale) { - // elements.push( - //
- // - //
- // This review references an outdated version of the proposal. - //
- // {!!review.lineText && ( - // - // - // {review.lineText} - // - // - // )} - //
- // - // - //
- - - +
+ + - {props.indent && - Array.from({ length: Number(props.indent) }).map((_, i) => ( - {TAB} - ))} - {props.children} - -
{annotation}
- {props.children} -
+ + {props.children} +
); } @@ -78,10 +104,12 @@ export function ChangeRow(props: { className?: string; /** Default is mutual */ type?: 'removal' | 'addition' | 'mutual'; + severityLevel?: SeverityLevelType; indent?: boolean | number; coordinate?: string; annotations?: (coordinate: string) => ReactElement | null; }) { + const ctx = useContext(AnnotatedContext); const incrementCounter = props.type === 'mutual' || props.type === undefined ? 'olddoc newdoc' @@ -89,6 +117,11 @@ export function ChangeRow(props: { ? 'olddoc' : 'newdoc'; const annotation = !!props.coordinate && props.annotations?.(props.coordinate); + + if (!!annotation) { + ctx.annotatedCoordinates?.add(props.coordinate!); + } + return ( <> @@ -127,13 +160,24 @@ export function ChangeRow(props: { Array.from({ length: Number(props.indent) }).map((_, i) => ( {TAB} ))} + {props.severityLevel === SeverityLevelType.Breaking && ( + + )} + {props.severityLevel === SeverityLevelType.Dangerous && ( + + )} + {props.severityLevel === SeverityLevelType.Safe && ( + + )} {props.children} {annotation && ( - {annotation} + + {annotation} + )} diff --git a/packages/web/app/src/components/proposal/schema-diff/schema-diff.tsx b/packages/web/app/src/components/proposal/schema-diff/schema-diff.tsx index c32697e030..15f1468709 100644 --- a/packages/web/app/src/components/proposal/schema-diff/schema-diff.tsx +++ b/packages/web/app/src/components/proposal/schema-diff/schema-diff.tsx @@ -9,11 +9,19 @@ import { ChangeDocument, DiffDirective, DiffType, SchemaDefinitionDiff } from '. export function SchemaDiff({ before, after, - annotations, + annotations = () => null, + // annotatedCoordinates = [], }: { before: GraphQLSchema; after: GraphQLSchema; - annotations: (coordinate: string) => ReactElement | null; + annotations?: (coordinate: string) => ReactElement | null; + /** + * A list of all the annotated coordinates, used or unused. + * Required to track which coordinates have inline annotations and which are detached + * from the current schemas. E.g. if previously commented on an addition but that addition + * has been removed. + */ + // annotatedCoordinates?: string[]; }): JSX.Element { const { added: addedTypes, From f1656bb475b459d29d1ef7872e254ee5d582ba67 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Wed, 27 Aug 2025 09:52:41 -0700 Subject: [PATCH 30/54] add todo --- .../resolvers/Query/schemaProposal.ts | 4 +- .../app/src/components/proposal/Review.tsx | 38 ++++++------ .../web/app/src/components/proposal/index.tsx | 14 ++++- .../proposal/schema-diff/components.tsx | 58 +++++++++---------- 4 files changed, 63 insertions(+), 51 deletions(-) diff --git a/packages/services/api/src/modules/proposals/resolvers/Query/schemaProposal.ts b/packages/services/api/src/modules/proposals/resolvers/Query/schemaProposal.ts index 43672a208b..9b77987672 100644 --- a/packages/services/api/src/modules/proposals/resolvers/Query/schemaProposal.ts +++ b/packages/services/api/src/modules/proposals/resolvers/Query/schemaProposal.ts @@ -699,7 +699,7 @@ export const schemaProposal: NonNullable = ( lineText: 'type User {', lineNumber: 2, stageTransition: 'OPEN', - schemaCoordinate: 'DType' + schemaCoordinate: 'DType', }, }, { @@ -732,7 +732,7 @@ export const schemaProposal: NonNullable = ( createdAt: Date.now(), lineText: 'foo: Boolean', lineNumber: 3, - schemaCoordinate: 'UnknownType.foo' + schemaCoordinate: 'UnknownType.foo', }, }, ], diff --git a/packages/web/app/src/components/proposal/Review.tsx b/packages/web/app/src/components/proposal/Review.tsx index 0b9fd93fe0..4546502747 100644 --- a/packages/web/app/src/components/proposal/Review.tsx +++ b/packages/web/app/src/components/proposal/Review.tsx @@ -1,11 +1,11 @@ +import { Fragment, ReactElement, useContext } from 'react'; import { FragmentType, graphql, useFragment } from '@/gql'; import { cn } from '@/lib/utils'; +import { Button } from '../ui/button'; +import { Callout } from '../ui/callout'; +import { Title } from '../ui/page'; import { TimeAgo } from '../v2'; -import { Fragment, ReactElement, useContext } from 'react'; import { AnnotatedContext } from './schema-diff/components'; -import { Title } from '../ui/page'; -import { Callout } from '../ui/callout'; -import { Button } from '../ui/button'; const ProposalOverview_ReviewCommentsFragment = graphql(/** GraphQL */ ` fragment ProposalOverview_ReviewCommentsFragment on SchemaProposalReview { @@ -35,15 +35,21 @@ export function ReviewComments(props: { return ( <> -
+
{review.comments?.edges?.map(({ node: comment }, idx) => { - return ; + return ( + + ); })}
{/* @todo check if able to reply */} -
- - +
+ +
); @@ -92,13 +98,11 @@ export function DetachedAnnotations(props: { const { annotatedCoordinates } = useContext(AnnotatedContext); const detachedReviewCoordinates = props.coordinates.filter(c => annotatedCoordinates?.has(c)); return detachedReviewCoordinates.length ? ( - - Detached Comments - {detachedReviewCoordinates.map(c => - - {props.annotate(c, true)} - - )} + + Detached Comments + {detachedReviewCoordinates.map(c => ( + {props.annotate(c, true)} + ))} ) : null; -} \ No newline at end of file +} diff --git a/packages/web/app/src/components/proposal/index.tsx b/packages/web/app/src/components/proposal/index.tsx index 5d81a143cb..e0b0258df4 100644 --- a/packages/web/app/src/components/proposal/index.tsx +++ b/packages/web/app/src/components/proposal/index.tsx @@ -15,8 +15,8 @@ import { import { Callout } from '../ui/callout'; import { Title } from '../ui/page'; import { DetachedAnnotations, ReviewComments } from './Review'; -import { SchemaDiff } from './schema-diff/schema-diff'; import { AnnotatedProvider } from './schema-diff/components'; +import { SchemaDiff } from './schema-diff/schema-diff'; /** * Fragment containing a list of reviews. Each review is tied to a coordinate @@ -574,7 +574,12 @@ export function Proposal(props: { <> {reviews?.map(({ node, cursor }) => ( <> - {withPreview === true && node.lineText && {node.lineText} } + {/* @todo if node.resolvedBy/resolvedAt is set, then minimize this */} + {withPreview === true && node.lineText && ( + + {node.lineText} + + )} ))} @@ -611,7 +616,10 @@ export function Proposal(props: { ))} ) : null} - + {diff} ); diff --git a/packages/web/app/src/components/proposal/schema-diff/components.tsx b/packages/web/app/src/components/proposal/schema-diff/components.tsx index 56c8aad253..12a1e09523 100644 --- a/packages/web/app/src/components/proposal/schema-diff/components.tsx +++ b/packages/web/app/src/components/proposal/schema-diff/components.tsx @@ -30,12 +30,12 @@ import { print, } from 'graphql'; import { isPrintableAsBlockString } from 'graphql/language/blockString'; -import { cn } from '@/lib/utils'; -import { compareLists, diffArrays, matchArrays } from './compare-lists'; -import { SeverityLevelType } from '@/gql/graphql'; import { CheckIcon, XIcon } from '@/components/ui/icon'; -import { ExclamationTriangleIcon } from '@radix-ui/react-icons'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { SeverityLevelType } from '@/gql/graphql'; +import { cn } from '@/lib/utils'; +import { ExclamationTriangleIcon } from '@radix-ui/react-icons'; +import { compareLists, diffArrays, matchArrays } from './compare-lists'; type RootFieldsType = { query: GraphQLField; @@ -51,34 +51,30 @@ export const AnnotatedContext = createContext({ /** * As annotations are rendered, this tracks coordinates used. This is used internally to * show annotations that are not resolved but that are not tied to a coordinate that exists anymore. - * + * * Note that adding a value to this Set does not trigger a rerender. * Special care must be taken to ensure the render order is correct */ - annotatedCoordinates: Set | null, + annotatedCoordinates: Set | null; }>); -export function AnnotatedProvider(props: { children: ReactNode; }) { - const [context] = useState({ annotatedCoordinates: new Set()}); - return ( - - {props.children} - - ); +export function AnnotatedProvider(props: { children: ReactNode }) { + const [context] = useState({ annotatedCoordinates: new Set() }); + return {props.children}; } export function ChangeDocument(props: { children: ReactNode; className?: string }) { return ( - - {props.children} -
+ + {props.children} +
); } @@ -161,13 +157,19 @@ export function ChangeRow(props: { {TAB} ))} {props.severityLevel === SeverityLevelType.Breaking && ( - + + + )} {props.severityLevel === SeverityLevelType.Dangerous && ( - + + + )} {props.severityLevel === SeverityLevelType.Safe && ( - + + + )} {props.children} @@ -175,9 +177,7 @@ export function ChangeRow(props: { {annotation && ( - - {annotation} - + {annotation} )} From f8c046503961c4a5d90b3a8d9b941a0497af1c1d Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Thu, 28 Aug 2025 13:23:19 -0700 Subject: [PATCH 31/54] Adjust navigation to separate list and single proposal pages --- .../web/app/src/components/proposal/index.tsx | 8 +- .../proposal/schema-diff/components.tsx | 6 +- .../proposals/stage-transition-select.tsx | 96 ++++++ .../target/proposals/version-select.tsx | 95 ++++++ .../app/src/pages/target-proposal-details.tsx | 1 - .../app/src/pages/target-proposal-edit.tsx | 10 +- .../app/src/pages/target-proposal-layout.tsx | 195 ------------ .../app/src/pages/target-proposal-schema.tsx | 7 - .../src/pages/target-proposal-supergraph.tsx | 1 - .../web/app/src/pages/target-proposal.tsx | 291 ++++++++++++++++++ .../web/app/src/pages/target-proposals.tsx | 136 +++----- packages/web/app/src/router.tsx | 24 +- pnpm-lock.yaml | 26 +- 13 files changed, 555 insertions(+), 341 deletions(-) create mode 100644 packages/web/app/src/components/target/proposals/stage-transition-select.tsx create mode 100644 packages/web/app/src/components/target/proposals/version-select.tsx delete mode 100644 packages/web/app/src/pages/target-proposal-layout.tsx create mode 100644 packages/web/app/src/pages/target-proposal.tsx diff --git a/packages/web/app/src/components/proposal/index.tsx b/packages/web/app/src/components/proposal/index.tsx index e0b0258df4..cb6c5edeae 100644 --- a/packages/web/app/src/components/proposal/index.tsx +++ b/packages/web/app/src/components/proposal/index.tsx @@ -1,4 +1,4 @@ -import { useMemo } from 'react'; +import { Fragment, useMemo } from 'react'; import { buildSchema } from 'graphql'; import { FragmentType, graphql, useFragment } from '@/gql'; import type { Change } from '@graphql-inspector/core'; @@ -573,15 +573,15 @@ export function Proposal(props: { return ( <> {reviews?.map(({ node, cursor }) => ( - <> + {/* @todo if node.resolvedBy/resolvedAt is set, then minimize this */} {withPreview === true && node.lineText && ( {node.lineText} )} - - + + ))} ); diff --git a/packages/web/app/src/components/proposal/schema-diff/components.tsx b/packages/web/app/src/components/proposal/schema-diff/components.tsx index 12a1e09523..008beeff1f 100644 --- a/packages/web/app/src/components/proposal/schema-diff/components.tsx +++ b/packages/web/app/src/components/proposal/schema-diff/components.tsx @@ -526,16 +526,16 @@ export function DiffLocations(props: { const locationElements = [ ...locations.removed.map(r => ( - + )), ...locations.added.map(r => ( - + )), - ...locations.mutual.map(r => ), + ...locations.mutual.map(r => ), ]; return ( diff --git a/packages/web/app/src/components/target/proposals/stage-transition-select.tsx b/packages/web/app/src/components/target/proposals/stage-transition-select.tsx new file mode 100644 index 0000000000..0e1bd8554e --- /dev/null +++ b/packages/web/app/src/components/target/proposals/stage-transition-select.tsx @@ -0,0 +1,96 @@ +import { useState } from 'react'; +import { ChevronsUpDown } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Command, CommandGroup, CommandItem } from '@/components/ui/command'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { SchemaProposalStage } from '@/gql/graphql'; +import { cn } from '@/lib/utils'; + +const STAGE_TRANSITIONS: ReadonlyArray< + Readonly<{ + fromStates: ReadonlyArray; + value: SchemaProposalStage; + label: string; + }> +> = [ + { + fromStates: [SchemaProposalStage.Open, SchemaProposalStage.Approved], + value: SchemaProposalStage.Draft, + label: 'REVERT TO DRAFT', + }, + { + fromStates: [SchemaProposalStage.Draft], + value: SchemaProposalStage.Open, + label: 'READY FOR REVIEW', + }, + { + fromStates: [SchemaProposalStage.Closed, SchemaProposalStage.Approved], + value: SchemaProposalStage.Open, + label: 'REOPEN', + }, + { + fromStates: [SchemaProposalStage.Open], + value: SchemaProposalStage.Approved, + label: 'APPROVE FOR IMPLEMENTING', + }, + { + fromStates: [SchemaProposalStage.Draft, SchemaProposalStage.Open, SchemaProposalStage.Approved], + value: SchemaProposalStage.Closed, + label: 'CANCEL PROPOSAL', + }, +]; + +const STAGE_TITLES = { + [SchemaProposalStage.Open]: 'READY FOR REVIEW', + [SchemaProposalStage.Approved]: 'AWAITING IMPLEMENTATION', + [SchemaProposalStage.Closed]: 'CANCELED', + [SchemaProposalStage.Draft]: 'IN DRAFT', + [SchemaProposalStage.Implemented]: 'IMPLEMENTED', +} as const; + +export function StageTransitionSelect(props: { stage: SchemaProposalStage }) { + const [open, setOpen] = useState(false); + return ( + + + + + + + + + {STAGE_TRANSITIONS.filter(s => s.fromStates.includes(props.stage))?.map(s => ( + { + console.log(`selected ${value}`); + }} + className="cursor-pointer truncate" + > +
+ {s.label} +
+
+ ))} +
+
+
+
+
+ ); +} diff --git a/packages/web/app/src/components/target/proposals/version-select.tsx b/packages/web/app/src/components/target/proposals/version-select.tsx new file mode 100644 index 0000000000..6478720d9c --- /dev/null +++ b/packages/web/app/src/components/target/proposals/version-select.tsx @@ -0,0 +1,95 @@ +import { useState } from 'react'; +import { ChevronsUpDown } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Command, CommandGroup, CommandItem } from '@/components/ui/command'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { TimeAgo } from '@/components/v2'; +import { FragmentType, graphql, useFragment } from '@/gql'; +import { cn } from '@/lib/utils'; +import { useRouter, useSearch } from '@tanstack/react-router'; + +const ProposalQuery_VersionsListFragment = graphql(/* GraphQL */ ` + fragment ProposalQuery_VersionsListFragment on SchemaProposalVersionConnection { + edges { + node { + id + createdAt + user { + fullName + displayName + } + } + } + } +`); + +export function VersionSelect(props: { + proposalId: string; + versions: FragmentType; +}) { + const [open, setOpen] = useState(false); + const router = useRouter(); + const search = useSearch({ strict: false }); + // @todo typing + const selectedVersionId = (search as any).version as string; + + // @todo handle pagination + const versions = + useFragment(ProposalQuery_VersionsListFragment, props.versions)?.edges?.map(e => e.node) ?? + null; + const selectedVersion = selectedVersionId + ? versions?.find(node => node.id === selectedVersionId) + : versions?.[0]; + + return ( + + + + + + + + + {versions?.map(version => ( + { + void router.navigate({ + search: { ...search, version: selectedVersion }, + }); + }} + className="cursor-pointer truncate" + > +
+
Version {version.id}
+
+ () +
+
+ by {version.user?.displayName ?? version.user?.fullName ?? 'null'} +
+
+
+ ))} +
+
+
+
+
+ ); +} diff --git a/packages/web/app/src/pages/target-proposal-details.tsx b/packages/web/app/src/pages/target-proposal-details.tsx index bcd51121c9..686e635973 100644 --- a/packages/web/app/src/pages/target-proposal-details.tsx +++ b/packages/web/app/src/pages/target-proposal-details.tsx @@ -34,7 +34,6 @@ export function TargetProposalDetailsPage(props: { projectSlug: string; targetSlug: string; proposalId: string; - page: string; }) { const [query] = useQuery({ query: ProposalDetailsQuery, diff --git a/packages/web/app/src/pages/target-proposal-edit.tsx b/packages/web/app/src/pages/target-proposal-edit.tsx index 031dfce94b..a114089ebb 100644 --- a/packages/web/app/src/pages/target-proposal-edit.tsx +++ b/packages/web/app/src/pages/target-proposal-edit.tsx @@ -1,9 +1,15 @@ +import { InlineCode } from '@/components/v2/inline-code'; + export function TargetProposalEditPage(props: { organizationSlug: string; projectSlug: string; targetSlug: string; proposalId: string; - page: string; }) { - return
Edit
; + return ( +
+ Edit is not available yet. Use @graphql-hive/cli to + propose changes. +
+ ); } diff --git a/packages/web/app/src/pages/target-proposal-layout.tsx b/packages/web/app/src/pages/target-proposal-layout.tsx deleted file mode 100644 index bb7e09e4fa..0000000000 --- a/packages/web/app/src/pages/target-proposal-layout.tsx +++ /dev/null @@ -1,195 +0,0 @@ -import { useQuery } from 'urql'; -import { stageToColor, userText } from '@/components/proposal/util'; -import { Subtitle, Title } from '@/components/ui/page'; -import { Spinner } from '@/components/ui/spinner'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { Tag, TimeAgo } from '@/components/v2'; -import { graphql } from '@/gql'; -import { Link } from '@tanstack/react-router'; -import { TargetProposalDetailsPage } from './target-proposal-details'; -import { TargetProposalEditPage } from './target-proposal-edit'; -import { TargetProposalSchemaPage } from './target-proposal-schema'; -import { TargetProposalSupergraphPage } from './target-proposal-supergraph'; - -enum Page { - SCHEMA = 'schema', - SUPERGRAPH = 'supergraph', - DETAILS = 'details', - EDIT = 'edit', -} - -const ProposalQuery = graphql(/* GraphQL */ ` - query ProposalQuery($id: ID!) { - schemaProposal(input: { id: $id }) { - id - createdAt - updatedAt - commentsCount - stage - title - description - versions(first: 30, after: null, input: { onlyLatest: true }) { - edges { - __typename - node { - id - serviceName - reviews { - edges { - cursor - node { - id - comments { - __typename - } - } - } - } - } - } - } - user { - id - email - displayName - fullName - } - } - } -`); - -export function TargetProposalLayoutPage(props: { - organizationSlug: string; - projectSlug: string; - targetSlug: string; - proposalId: string; - page: string; -}) { - const [query] = useQuery({ - query: ProposalQuery, - variables: { - reference: { - bySelector: { - organizationSlug: props.organizationSlug, - projectSlug: props.projectSlug, - targetSlug: props.targetSlug, - }, - }, - id: props.proposalId, - }, - requestPolicy: 'cache-and-network', - }); - const proposal = query.data?.schemaProposal; - return ( -
- {query.fetching && } - {proposal && ( - <> - {/* @todo version dropdown Last updated */} - {/* @todo stage dropdown {proposal.stage} */} -
- {proposal.title} -
-
- proposed by {userText(proposal.user)} -
-
{proposal.description}
- - )} - -
- ); -} - -function MainContent(props: { - organizationSlug: string; - projectSlug: string; - targetSlug: string; - proposalId: string; - page: string; -}) { - return ( - - - - - Schema - - - - - Supergraph Preview - - - - - Details - - - - - Edit - - - - -
- -
-
- -
- -
-
- -
- -
-
- -
- -
-
-
- ); -} - -export const ProposalPage = Page; diff --git a/packages/web/app/src/pages/target-proposal-schema.tsx b/packages/web/app/src/pages/target-proposal-schema.tsx index cabd19a3e5..1d990612e7 100644 --- a/packages/web/app/src/pages/target-proposal-schema.tsx +++ b/packages/web/app/src/pages/target-proposal-schema.tsx @@ -60,7 +60,6 @@ export function TargetProposalSchemaPage(props: { projectSlug: string; targetSlug: string; proposalId: string; - page: string; }) { const [query] = useQuery({ query: ProposalSchemaQuery, @@ -124,12 +123,6 @@ export function TargetProposalSchemaPage(props: { /> ); } - - // return ( - //
- // {`Proposed changes cannot be applied to the ${proposed.serviceName ? `"${proposed.serviceName}" ` : ''}schema because it does not exist.`} - //
- // ); })}
); diff --git a/packages/web/app/src/pages/target-proposal-supergraph.tsx b/packages/web/app/src/pages/target-proposal-supergraph.tsx index c411c5c19d..9903763f92 100644 --- a/packages/web/app/src/pages/target-proposal-supergraph.tsx +++ b/packages/web/app/src/pages/target-proposal-supergraph.tsx @@ -41,7 +41,6 @@ export function TargetProposalSupergraphPage(props: { projectSlug: string; targetSlug: string; proposalId: string; - page: string; }) { const [query] = useQuery({ query: ProposalSupergraphChangesQuery, diff --git a/packages/web/app/src/pages/target-proposal.tsx b/packages/web/app/src/pages/target-proposal.tsx new file mode 100644 index 0000000000..64a57d9034 --- /dev/null +++ b/packages/web/app/src/pages/target-proposal.tsx @@ -0,0 +1,291 @@ +import { useQuery } from 'urql'; +import { Page, TargetLayout } from '@/components/layouts/target'; +import { userText } from '@/components/proposal/util'; +import { StageTransitionSelect } from '@/components/target/proposals/stage-transition-select'; +import { VersionSelect } from '@/components/target/proposals/version-select'; +import { CardDescription } from '@/components/ui/card'; +import { DiffIcon, EditIcon, GraphQLIcon } from '@/components/ui/icon'; +import { Meta } from '@/components/ui/meta'; +import { Title } from '@/components/ui/page'; +import { SubPageLayoutHeader } from '@/components/ui/page-content-layout'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Spinner } from '@/components/ui/spinner'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { TimeAgo } from '@/components/v2'; +import { graphql } from '@/gql'; +import { CubeIcon, ListBulletIcon } from '@radix-ui/react-icons'; +import { Link } from '@tanstack/react-router'; +import { TargetProposalDetailsPage } from './target-proposal-details'; +import { TargetProposalEditPage } from './target-proposal-edit'; +import { TargetProposalSchemaPage } from './target-proposal-schema'; +import { TargetProposalSupergraphPage } from './target-proposal-supergraph'; + +enum Tab { + SCHEMA = 'schema', + SUPERGRAPH = 'supergraph', + DETAILS = 'details', + EDIT = 'edit', +} + +const ProposalQuery = graphql(/* GraphQL */ ` + query ProposalQuery($id: ID!) { + schemaProposal(input: { id: $id }) { + id + createdAt + updatedAt + commentsCount + stage + title + description + versions(first: 30, after: null, input: { onlyLatest: true }) { + ...ProposalQuery_VersionsListFragment + } + # latestVersions: versions(first: 30, after: null, input: { onlyLatest: true }) { + # edges { + # __typename + # node { + # id + # serviceName + # reviews { + # edges { + # cursor + # node { + # id + # comments { + # __typename + # } + # } + # } + # } + # } + # } + # } + user { + id + email + displayName + fullName + } + } + } +`); + +export function TargetProposalsSinglePage(props: { + organizationSlug: string; + projectSlug: string; + targetSlug: string; + proposalId: string; + tab?: string; +}) { + return ( + <> + + + + + + ); +} + +const ProposalsContent = (props: Parameters[0]) => { + return ( + <> +
+
+ + + Schema Proposals + {' '} + /{' '} + {/* @todo use query data to show loading */} + {props.proposalId ? ( + `${props.proposalId}` + ) : ( + + )} + + } + description={ + <> + + Collaborate on schema changes to reduce friction during development. + + + } + /> +
+
+ + + ); +}; + +function SinglePageContent(props: { + organizationSlug: string; + projectSlug: string; + targetSlug: string; + proposalId: string; + tab?: string; +}) { + const [query] = useQuery({ + query: ProposalQuery, + variables: { + reference: { + bySelector: { + organizationSlug: props.organizationSlug, + projectSlug: props.projectSlug, + targetSlug: props.targetSlug, + }, + }, + id: props.proposalId, + }, + requestPolicy: 'cache-and-network', + }); + const proposal = query.data?.schemaProposal; + return ( +
+ {query.fetching ? ( + + ) : ( + proposal && ( + <> +
+
+ +
+
+
+ +
+
+
+ {proposal.title} +
+ proposed by {userText(proposal.user)} +
+
{proposal.description}
+
+ + ) + )} + +
+ ); +} + +function TabbedContent(props: { + organizationSlug: string; + projectSlug: string; + targetSlug: string; + proposalId: string; + page?: string; +}) { + return ( + + + + + + Details + + + + + + Schema + + + + + + Supergraph Preview + + + + + + Edit + + + + +
+ +
+
+ +
+ +
+
+ +
+ +
+
+ +
+ +
+
+
+ ); +} + +export const ProposalTab = Tab; diff --git a/packages/web/app/src/pages/target-proposals.tsx b/packages/web/app/src/pages/target-proposals.tsx index 1fdae90523..793199ec47 100644 --- a/packages/web/app/src/pages/target-proposals.tsx +++ b/packages/web/app/src/pages/target-proposals.tsx @@ -1,28 +1,22 @@ -import { useEffect, useState } from 'react'; +import { useState } from 'react'; import { useQuery } from 'urql'; -import { - ListNavigationProvider, - ListNavigationTrigger, - ListNavigationWrapper, - useListNavCollapsedToggle, - useListNavigationContext, -} from '@/components/common/ListNavigation'; import { Page, TargetLayout } from '@/components/layouts/target'; import { stageToColor } from '@/components/proposal/util'; import { StageFilter } from '@/components/target/proposals/stage-filter'; import { UserFilter } from '@/components/target/proposals/user-filter'; import { BadgeRounded } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; +import { CardDescription } from '@/components/ui/card'; import { Link } from '@/components/ui/link'; import { Meta } from '@/components/ui/meta'; -import { Subtitle, Title } from '@/components/ui/page'; +import { SubPageLayoutHeader } from '@/components/ui/page-content-layout'; import { Spinner } from '@/components/ui/spinner'; import { TimeAgo } from '@/components/v2'; import { graphql } from '@/gql'; import { SchemaProposalStage } from '@/gql/graphql'; import { cn } from '@/lib/utils'; -import { ChatBubbleIcon, PinLeftIcon, PinRightIcon } from '@radix-ui/react-icons'; -import { Outlet, useRouter, useSearch } from '@tanstack/react-router'; +import { ChatBubbleIcon } from '@radix-ui/react-icons'; +import { useRouter, useSearch } from '@tanstack/react-router'; export function TargetProposalsPage(props: { organizationSlug: string; @@ -48,6 +42,31 @@ export function TargetProposalsPage(props: { ); } +const ProposalsContent = (props: Parameters[0]) => { + return ( + <> +
+
+ Schema Proposals} + description={ + <> + + Collaborate on schema changes to reduce friction during development. + + + } + /> +
+
+ +
+
+ + + ); +}; + const ProposalsQuery = graphql(` query listProposals($input: SchemaProposalsInput) { schemaProposals(input: $input) { @@ -74,65 +93,7 @@ const ProposalsQuery = graphql(` } `); -const ProposalsContent = (props: Parameters[0]) => { - const isFullScreen = !props.selectedProposalId; - - return ( - -
-
- - Proposals - - Collaborate on schema changes to reduce friction during development. -
-
- -
-
- {!isFullScreen && ( -
- - - -
- )} - } content={} /> -
- ); -}; - -function HideMenuButton() { - return ( - - - - ); -} - -function ExpandMenuButton(props: { className?: string }) { - const { isListNavHidden } = useListNavigationContext(); - const [collapsed, toggle] = useListNavCollapsedToggle(); - - return isListNavHidden ? null : ( - - ); -} - -function ProposalsListv2(props: Parameters[0]) { +function TargetProposalsList(props: Parameters[0]) { const [pageVariables, setPageVariables] = useState([{ first: 20, after: null as string | null }]); const router = useRouter(); const reset = () => { @@ -142,39 +103,16 @@ function ProposalsListv2(props: Parameters[0]) { }; const hasFilterSelection = !!(props.filterStages?.length || props.filterUserIds?.length); - const hasHasProposalSelected = !!props.selectedProposalId; - const { setIsListNavCollapsed, isListNavCollapsed, setIsListNavHidden } = - useListNavigationContext(); - useEffect(() => { - if (props.selectedProposalId) { - setIsListNavCollapsed(true); - } else { - setIsListNavCollapsed(false); - setIsListNavHidden(false); - } - }, [props.selectedProposalId]); - - const isFiltersHorizontalUI = !hasHasProposalSelected || !isListNavCollapsed; - return ( <> -
+
{hasFilterSelection ? ( - ) : null} @@ -233,9 +171,6 @@ const ProposalsListPage = (props: { const pageInfo = query.data?.schemaProposals?.pageInfo; const search = useSearch({ strict: false }); - const { isListNavCollapsed } = useListNavigationContext(); - const isWide = !props.selectedProposalId || !isListNavCollapsed; - return ( <> {query.fetching ? : null} @@ -266,7 +201,7 @@ const ProposalsListPage = (props: {
- + {proposal.title} @@ -287,8 +222,7 @@ const ProposalsListPage = (props: {
{proposal.commentsCount} diff --git a/packages/web/app/src/router.tsx b/packages/web/app/src/router.tsx index 76680f9a4b..9378c8ea64 100644 --- a/packages/web/app/src/router.tsx +++ b/packages/web/app/src/router.tsx @@ -74,11 +74,7 @@ import { TargetInsightsClientPage } from './pages/target-insights-client'; import { TargetInsightsCoordinatePage } from './pages/target-insights-coordinate'; import { TargetInsightsOperationPage } from './pages/target-insights-operation'; import { TargetLaboratoryPage } from './pages/target-laboratory'; -import { - ProposalPage, - TargetProposalLayoutPage, - TargetProposalsViewPage, -} from './pages/target-proposal-layout'; +import { ProposalTab, TargetProposalsSinglePage } from './pages/target-proposal'; import { TargetProposalsPage } from './pages/target-proposals'; import { TargetSettingsPage, TargetSettingsPageEnum } from './pages/target-settings'; @@ -860,26 +856,26 @@ const targetProposalsRoute = createRoute({ }, }); -const targetProposalRoute = createRoute({ - getParentRoute: () => targetProposalsRoute, - path: '$proposalId', +const targetProposalsSingleRoute = createRoute({ + getParentRoute: () => targetRoute, + path: 'proposals/$proposalId', validateSearch: z.object({ page: z - .enum(Object.values(ProposalPage).map(s => s.toLowerCase()) as [string, ...string[]]) + .enum(Object.values(ProposalTab).map(s => s.toLowerCase()) as [string, ...string[]]) .optional() .catch(() => void 0), }), component: function TargetProposalRoute() { const { organizationSlug, projectSlug, targetSlug, proposalId } = - targetProposalRoute.useParams(); - const { page } = targetProposalRoute.useSearch(); + targetProposalsSingleRoute.useParams(); + const { page } = targetProposalsSingleRoute.useSearch(); return ( - ); }, @@ -940,7 +936,7 @@ const routeTree = root.addChildren([ targetChecksRoute.addChildren([targetChecksSingleRoute]), targetAppVersionRoute, targetAppsRoute, - targetProposalsRoute.addChildren([targetProposalRoute]), + targetProposalsRoute.addChildren([targetProposalsSingleRoute]), ]), ]), ]); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 15b6d5af5f..59d3d1774a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1879,7 +1879,7 @@ importers: version: 4.3.4(vite@6.3.4(@types/node@22.10.5)(jiti@2.3.3)(less@4.2.0)(lightningcss@1.30.1)(terser@5.37.0)(tsx@4.19.2)(yaml@2.5.0)) autoprefixer: specifier: 10.4.20 - version: 10.4.20(postcss@8.4.49) + version: 10.4.20(postcss@8.5.3) class-variance-authority: specifier: 0.7.1 version: 0.7.1 @@ -16593,8 +16593,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-sts': 3.596.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/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 @@ -16701,11 +16701,11 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/client-sso-oidc@3.596.0(@aws-sdk/client-sts@3.596.0)': + '@aws-sdk/client-sso-oidc@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-sts': 3.596.0(@aws-sdk/client-sso-oidc@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 @@ -16744,7 +16744,6 @@ snapshots: '@smithy/util-utf8': 3.0.0 tslib: 2.8.1 transitivePeerDependencies: - - '@aws-sdk/client-sts' - aws-crt '@aws-sdk/client-sso-oidc@3.723.0(@aws-sdk/client-sts@3.723.0)': @@ -16878,11 +16877,11 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/client-sts@3.596.0': + '@aws-sdk/client-sts@3.596.0(@aws-sdk/client-sso-oidc@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-sts@3.596.0) + '@aws-sdk/client-sso-oidc': 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 @@ -16921,6 +16920,7 @@ snapshots: '@smithy/util-utf8': 3.0.0 tslib: 2.8.1 transitivePeerDependencies: + - '@aws-sdk/client-sso-oidc' - aws-crt '@aws-sdk/client-sts@3.723.0': @@ -17034,7 +17034,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-sts': 3.596.0(@aws-sdk/client-sso-oidc@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 @@ -17153,7 +17153,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-sts': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0) '@aws-sdk/types': 3.577.0 '@smithy/property-provider': 3.1.11 '@smithy/types': 3.7.2 @@ -17328,7 +17328,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-sts@3.596.0) + '@aws-sdk/client-sso-oidc': 3.596.0 '@aws-sdk/types': 3.577.0 '@smithy/property-provider': 3.1.11 '@smithy/shared-ini-file-loader': 3.1.12 @@ -25343,14 +25343,14 @@ snapshots: auto-bind@4.0.0: {} - autoprefixer@10.4.20(postcss@8.4.49): + autoprefixer@10.4.20(postcss@8.5.3): dependencies: browserslist: 4.24.0 caniuse-lite: 1.0.30001669 fraction.js: 4.3.7 normalize-range: 0.1.2 picocolors: 1.1.1 - postcss: 8.4.49 + postcss: 8.5.3 postcss-value-parser: 4.2.0 available-typed-arrays@1.0.5: {} From cc600a9f96e721770ab7a38922bbb5d3d833deb9 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Thu, 28 Aug 2025 21:57:53 -0700 Subject: [PATCH 32/54] Rework UI per feedback --- .../web/app/src/components/proposal/index.tsx | 140 ++------- .../target/proposals/change-detail.tsx | 29 ++ .../target/proposals/stage-filter.tsx | 2 +- .../target/proposals/user-filter.tsx | 2 +- .../app/src/pages/target-proposal-details.tsx | 149 +++++---- .../app/src/pages/target-proposal-schema.tsx | 132 +------- .../app/src/pages/target-proposal-types.ts | 21 ++ .../web/app/src/pages/target-proposal.tsx | 283 ++++++++++++------ .../web/app/src/pages/target-proposals.tsx | 4 +- 9 files changed, 359 insertions(+), 403 deletions(-) create mode 100644 packages/web/app/src/components/target/proposals/change-detail.tsx create mode 100644 packages/web/app/src/pages/target-proposal-types.ts diff --git a/packages/web/app/src/components/proposal/index.tsx b/packages/web/app/src/components/proposal/index.tsx index cb6c5edeae..117e0dd6b0 100644 --- a/packages/web/app/src/components/proposal/index.tsx +++ b/packages/web/app/src/components/proposal/index.tsx @@ -1,19 +1,6 @@ import { Fragment, useMemo } from 'react'; -import { buildSchema } from 'graphql'; +import { GraphQLSchema } from 'graphql'; import { FragmentType, graphql, useFragment } from '@/gql'; -import type { Change } from '@graphql-inspector/core'; -import { patchSchema } from '@graphql-inspector/patch'; -import { NoopError } from '@graphql-inspector/patch/errors'; -import { labelize } from '../target/history/errors-and-changes'; -import { - Accordion, - AccordionContent, - AccordionHeader, - AccordionItem, - AccordionTrigger, -} from '../ui/accordion'; -import { Callout } from '../ui/callout'; -import { Title } from '../ui/page'; import { DetachedAnnotations, ReviewComments } from './Review'; import { AnnotatedProvider } from './schema-diff/components'; import { SchemaDiff } from './schema-diff/schema-diff'; @@ -24,7 +11,7 @@ import { SchemaDiff } from './schema-diff/schema-diff'; * but this can be done serially because there should not be so many reviews within * a single screen's height that it matters. * */ -const ProposalOverview_ReviewsFragment = graphql(/** GraphQL */ ` +export const ProposalOverview_ReviewsFragment = graphql(/** GraphQL */ ` fragment ProposalOverview_ReviewsFragment on SchemaProposalReviewConnection { pageInfo { startCursor @@ -49,7 +36,7 @@ const ProposalOverview_ReviewsFragment = graphql(/** GraphQL */ ` /** Move to utils? */ export const ProposalOverview_ChangeFragment = graphql(/* GraphQL */ ` fragment ProposalOverview_ChangeFragment on SchemaChange { - message + message(withSafeBasedOnUsageNote: false) path severityLevel meta { @@ -119,13 +106,11 @@ export const ProposalOverview_ChangeFragment = graphql(/* GraphQL */ ` ... on DirectiveArgumentTypeChanged { directiveArgumentName directiveName - isSafeDirectiveArgumentTypeChange newDirectiveArgumentType oldDirectiveArgumentType } ... on EnumValueRemoved { enumName - isEnumValueDeprecated removedEnumValueName } ... on EnumValueAdded { @@ -157,7 +142,6 @@ export const ProposalOverview_ChangeFragment = graphql(/* GraphQL */ ` removedEnumValueDeprecationReason } ... on FieldRemoved { - isRemovedFieldDeprecated removedFieldName typeName typeType @@ -209,14 +193,12 @@ export const ProposalOverview_ChangeFragment = graphql(/* GraphQL */ ` } ... on FieldTypeChanged { fieldName - isSafeFieldTypeChange newFieldType oldFieldType typeName } ... on DirectiveUsageUnionMemberAdded { addedDirectiveName - addedToNewType addedUnionMemberTypeName addedUnionMemberTypeName unionName @@ -231,8 +213,6 @@ export const ProposalOverview_ChangeFragment = graphql(/* GraphQL */ ` addedArgumentType addedToNewField fieldName - hasDefaultValue - isAddedFieldArgumentBreaking typeName } ... on FieldArgumentRemoved { @@ -243,7 +223,6 @@ export const ProposalOverview_ChangeFragment = graphql(/* GraphQL */ ` } ... on InputFieldRemoved { inputName - isInputFieldDeprecated removedFieldName } ... on InputFieldAdded { @@ -252,7 +231,6 @@ export const ProposalOverview_ChangeFragment = graphql(/* GraphQL */ ` addedInputFieldType addedToNewType inputName - isAddedInputFieldTypeNullable } ... on InputFieldDescriptionAdded { addedInputFieldDescription @@ -279,7 +257,6 @@ export const ProposalOverview_ChangeFragment = graphql(/* GraphQL */ ` ... on InputFieldTypeChanged { inputFieldName inputName - isInputFieldTypeChangeSafe newInputFieldType oldInputFieldType } @@ -360,7 +337,6 @@ export const ProposalOverview_ChangeFragment = graphql(/* GraphQL */ ` } ... on DirectiveUsageInputObjectRemoved { inputObjectName - isRemovedInputFieldTypeNullable removedDirectiveName removedInputFieldName removedInputFieldType @@ -371,7 +347,6 @@ export const ProposalOverview_ChangeFragment = graphql(/* GraphQL */ ` addedInputFieldType addedToNewType inputObjectName - isAddedInputFieldTypeNullable } ... on DirectiveUsageInputFieldDefinitionAdded { addedDirectiveName @@ -460,17 +435,9 @@ export const ProposalOverview_ChangeFragment = graphql(/* GraphQL */ ` addedArgumentValue directiveName oldArgumentValue - parentArgumentName - parentEnumValueName - parentFieldName - parentTypeName } ... on DirectiveUsageArgumentRemoved { directiveName - parentArgumentName - parentEnumValueName - parentFieldName - parentTypeName removedArgumentName } } @@ -493,53 +460,13 @@ export function toUpperSnakeCase(str: string) { return snakeCaseString.toUpperCase(); } +// @todo ServiceProposalDetails export function Proposal(props: { - baseSchemaSDL: string; - changes: FragmentType[] | null; - serviceName?: string; - latestProposalVersionId: string; - reviews: FragmentType | null; + beforeSchema: GraphQLSchema | null; + afterSchema: GraphQLSchema | null; + reviews: FragmentType; + serviceName: string; }) { - const before = useMemo(() => { - return buildSchema(props.baseSchemaSDL, { assumeValid: true, assumeValidSDL: true }); - }, [props.baseSchemaSDL]); - - const changes = useMemo(() => { - return ( - props.changes?.map((change): Change => { - const c = useFragment(ProposalOverview_ChangeFragment, change); - return { - criticality: { - // isSafeBasedOnUsage: , - // reason: , - level: c.severityLevel as any, - }, - message: c.message, - meta: c.meta, - type: (c.meta && toUpperSnakeCase(c.meta?.__typename)) ?? '', // convert to upper snake - path: c.path?.join('.'), - }; - }) ?? [] - ); - }, [props.changes]); - - const [after, notApplied, ignored] = useMemo(() => { - const cannotBeApplied: Array<{ change: Change; error: Error }> = []; - const ignored: Array<{ change: Change; error: Error }> = []; - const patched = patchSchema(before, changes, { - throwOnError: false, - onError(error, change) { - if (error instanceof NoopError) { - ignored.push({ change, error }); - return false; - } - cannotBeApplied.push({ change, error }); - return true; - }, - }); - return [patched, cannotBeApplied, ignored]; - }, [before, changes]); - /** * Reviews can change position because the coordinate changes... placing them out of order from their original line numbers. * Because of this, we have to fetch every single page of comments... @@ -552,7 +479,7 @@ export function Proposal(props: { const serviceReviews = reviewsConnection?.edges?.filter(edge => { const { schemaProposalVersion } = edge.node; - return schemaProposalVersion?.serviceName === props.serviceName; + return (schemaProposalVersion?.serviceName ?? '') === props.serviceName; }) ?? []; const reviewssByCoordinate = serviceReviews.reduce((result, review) => { const coordinate = review.node.schemaCoordinate; @@ -592,30 +519,22 @@ export function Proposal(props: { }, [props.reviews, props.serviceName]); try { - // @todo This doesnt work 100% of the time... A different solution must be found - // THIS IS IMPORTANT!! must be rendered first so that it sets up the state in the // AnnotatedContext for . Otherwise, the DetachedAnnotations will be empty. - const diff = ; + const diff = + props.beforeSchema && props.afterSchema ? ( + + ) : ( + <> + ); + // @todo AnnotatedProvider doesnt work 100% of the time... A different solution must be found return ( - {notApplied.length ? ( - - Incompatible Changes - {notApplied.map((p, i) => ( - - ))} - - ) : null} - {ignored.length ? ( - - Ignored Changes - {ignored.map((p, i) => ( - - ))} - - ) : null} ; error: Error }) { - return ( - - - - -
- - {labelize(props.change.message)} - -
-
-
- {props.error.message} -
-
- ); -} diff --git a/packages/web/app/src/components/target/proposals/change-detail.tsx b/packages/web/app/src/components/target/proposals/change-detail.tsx new file mode 100644 index 0000000000..e2faf91596 --- /dev/null +++ b/packages/web/app/src/components/target/proposals/change-detail.tsx @@ -0,0 +1,29 @@ +import { AccordionContent, AccordionHeader, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"; +import { Accordion } from "@/components/v2"; +import type { Change } from "@graphql-inspector/core"; +import { labelize } from "../history/errors-and-changes"; +import { ReactNode } from "react"; + +export function ProposalChangeDetail(props: { + change: Change; + error?: Error; + icon?: ReactNode; +}) { + return ( + + + + +
+ + {labelize(props.change.message)} + + {props.icon} +
+
+
+ {props.error?.message ?? <>No details available for this change.} +
+
+ ); +} diff --git a/packages/web/app/src/components/target/proposals/stage-filter.tsx b/packages/web/app/src/components/target/proposals/stage-filter.tsx index 52835642a7..29332edcab 100644 --- a/packages/web/app/src/components/target/proposals/stage-filter.tsx +++ b/packages/web/app/src/components/target/proposals/stage-filter.tsx @@ -28,7 +28,7 @@ export const StageFilter = ({ selectedStages }: { selectedStages: string[] }) => - + diff --git a/packages/web/app/src/components/target/proposals/user-filter.tsx b/packages/web/app/src/components/target/proposals/user-filter.tsx index 27ef2f59aa..b11b6bceae 100644 --- a/packages/web/app/src/components/target/proposals/user-filter.tsx +++ b/packages/web/app/src/components/target/proposals/user-filter.tsx @@ -83,7 +83,7 @@ export const UserFilter = ({ - + No results. diff --git a/packages/web/app/src/pages/target-proposal-details.tsx b/packages/web/app/src/pages/target-proposal-details.tsx index 686e635973..997551b6f0 100644 --- a/packages/web/app/src/pages/target-proposal-details.tsx +++ b/packages/web/app/src/pages/target-proposal-details.tsx @@ -1,96 +1,93 @@ -import { Fragment } from 'react'; -import { useQuery } from 'urql'; -import { - ChangesBlock, - ChangesBlock_SchemaChangeFragment, -} from '@/components/target/history/errors-and-changes'; -import { Spinner } from '@/components/ui/spinner'; -import { graphql, useFragment } from '@/gql'; -import { SeverityLevelType } from '@/gql/graphql'; +import { Fragment, ReactNode } from 'react'; +import { ProposalOverview_ReviewsFragment } from '@/components/proposal'; +import { FragmentType } from '@/gql'; +import type { ServiceProposalDetails } from './target-proposal-types'; +import { ProposalChangeDetail } from '@/components/target/proposals/change-detail'; +import { Title } from '@/components/ui/page'; +import { ComponentNoneIcon, CubeIcon, ExclamationTriangleIcon, LinkBreak2Icon } from '@radix-ui/react-icons'; +import { Change, CriticalityLevel } from '@graphql-inspector/core'; -const ProposalDetailsQuery = graphql(/* GraphQL */ ` - query ProposalDetailsQuery($id: ID!) { - schemaProposal(input: { id: $id }) { - id - versions(after: null, input: { onlyLatest: true }) { - edges { - __typename - node { - id - serviceName - changes { - __typename - ...ChangesBlock_SchemaChangeFragment - } - } - } - } - } - } -`); +export enum MergeStatus { + CONFLICT, + IGNORED, +} export function TargetProposalDetailsPage(props: { organizationSlug: string; projectSlug: string; targetSlug: string; proposalId: string; + services: ServiceProposalDetails[]; + reviews: FragmentType; }) { - const [query] = useQuery({ - query: ProposalDetailsQuery, - variables: { - id: props.proposalId, - }, - requestPolicy: 'cache-and-network', - }); - return (
- {query.fetching && } - {query.data?.schemaProposal?.versions?.edges?.map(edge => { - const breakingChanges = edge.node.changes.filter((c): c is NonNullable => { - const change = useFragment(ChangesBlock_SchemaChangeFragment, c); - return !!c && change?.severityLevel === SeverityLevelType.Breaking; + {props.services?.map(({ allChanges, ignoredChanges, conflictingChanges, serviceName }) => { + const changes = allChanges + .map(c => { + const conflict = conflictingChanges.find(({ change }) => c === change); + if (conflict) { + return { + change: c, + error: conflict.error, + mergeStatus: MergeStatus.CONFLICT, + }; + } + const ignored = ignoredChanges.find(({ change }) => c === change); + if (ignored) { + return { + change: c, + error: ignored.error, + mergeStatus: MergeStatus.IGNORED, + } + } + return { change: c }; + }); + const breakingChanges = changes.filter(({ change }) => { + return change.criticality.level === CriticalityLevel.Breaking; }); - const dangerousChanges = edge.node.changes.filter((c): c is NonNullable => { - const change = useFragment(ChangesBlock_SchemaChangeFragment, c); - return !!c && change?.severityLevel === SeverityLevelType.Dangerous; + const dangerousChanges = changes.filter(({ change }) => { + return change.criticality.level === CriticalityLevel.Dangerous; }); - const safeChanges = edge.node.changes.filter((c): c is NonNullable => { - const change = useFragment(ChangesBlock_SchemaChangeFragment, c); - return !!c && change?.severityLevel === SeverityLevelType.Safe; + const safeChanges = changes.filter(({ change }) => { + return change.criticality.level === CriticalityLevel.NonBreaking; }); return ( - - - - + + {serviceName.length !== 0 && <CubeIcon className="h-6 w-auto flex-none mr-2" /> {serviceName}} + + + ); })}
); } + +function ChangeBlock(props: { + title: string; + changes: Array<{ change: Change; error?: Error, mergeStatus?: MergeStatus }>; +}) { + return props.changes.length !== 0 && ( + <> +

{props.title}

+
+ {props.changes.map(({change, error, mergeStatus }) => { + let icon: ReactNode | undefined; + if (mergeStatus === MergeStatus.CONFLICT) { + icon = CONFLICT + } else if (mergeStatus === MergeStatus.IGNORED) { + icon = NO CHANGE; + } + return + })} +
+ + ) +} \ No newline at end of file diff --git a/packages/web/app/src/pages/target-proposal-schema.tsx b/packages/web/app/src/pages/target-proposal-schema.tsx index 1d990612e7..ba307bf8dc 100644 --- a/packages/web/app/src/pages/target-proposal-schema.tsx +++ b/packages/web/app/src/pages/target-proposal-schema.tsx @@ -1,128 +1,26 @@ -import { useQuery } from 'urql'; -import { Proposal } from '@/components/proposal'; -import { Callout } from '@/components/ui/callout'; -import { Spinner } from '@/components/ui/spinner'; -import { graphql } from '@/gql'; - -const ProposalSchemaQuery = graphql(/* GraphQL */ ` - query ProposalSchemaQuery($reference: TargetReferenceInput!, $id: ID!) { - latestValidVersion(target: $reference) { - id - # sdl - schemas { - edges { - node { - ... on CompositeSchema { - id - source - service - } - ... on SingleSchema { - id - source - } - } - } - } - } - schemaProposal(input: { id: $id }) { - id - reviews { - ...ProposalOverview_ReviewsFragment - } - } - } -`); - -const ProposalChangesQuery = graphql(/* GraphQL */ ` - query ProposalChangesQuery($id: ID!) { - schemaProposal(input: { id: $id }) { - id - versions(after: null, input: { onlyLatest: true }) { - edges { - __typename - node { - id - serviceName - changes { - __typename - ...ProposalOverview_ChangeFragment - } - } - } - } - } - } -`); +import { Proposal, ProposalOverview_ReviewsFragment } from '@/components/proposal'; +import { FragmentType } from '@/gql'; +import { ServiceProposalDetails } from './target-proposal-types'; export function TargetProposalSchemaPage(props: { organizationSlug: string; projectSlug: string; targetSlug: string; - proposalId: string; + proposalId: string; // @todo pass to proposal for commenting etc + services: ServiceProposalDetails[]; + reviews: FragmentType; }) { - const [query] = useQuery({ - query: ProposalSchemaQuery, - variables: { - reference: { - bySelector: { - organizationSlug: props.organizationSlug, - projectSlug: props.projectSlug, - targetSlug: props.targetSlug, - }, - }, - id: props.proposalId, - }, - requestPolicy: 'cache-and-network', - }); - const [changesQuery] = useQuery({ - query: ProposalChangesQuery, - variables: { - id: props.proposalId, - }, - requestPolicy: 'cache-and-network', - }); - - const proposal = query.data?.schemaProposal; - const proposalVersion = changesQuery.data?.schemaProposal?.versions?.edges?.[0]; - - if (query.fetching || changesQuery.fetching) { - return ; - } - - if ( - !query.stale && - !changesQuery.stale && - query.data?.__typename && - !query.data.latestValidVersion?.schemas - ) { - return This target does not have a valid schema version.; - } - - if (proposalVersion) { + if (props.services.length) { return (
- {changesQuery?.data?.schemaProposal?.versions?.edges?.length === 0 && ( - <>No proposal versions - )} - {changesQuery?.data?.schemaProposal?.versions?.edges?.map(({ node: proposed }) => { - const existingSchema = query.data?.latestValidVersion?.schemas.edges.find( - ({ node }) => - (node.__typename === 'CompositeSchema' && node.service === proposed.serviceName) || - (node.__typename === 'SingleSchema' && proposed.serviceName == null), - )?.node.source; - if (existingSchema) { - return ( - !!c) ?? []} - serviceName={proposed.serviceName ?? ''} - /> - ); - } + {props.services.map(proposed => { + return ( + + ); })}
); diff --git a/packages/web/app/src/pages/target-proposal-types.ts b/packages/web/app/src/pages/target-proposal-types.ts new file mode 100644 index 0000000000..6a61a6255b --- /dev/null +++ b/packages/web/app/src/pages/target-proposal-types.ts @@ -0,0 +1,21 @@ +import type { GraphQLSchema } from 'graphql'; +import { ProposalOverview_ChangeFragment } from '@/components/proposal'; +import { FragmentType } from '@/gql'; +import type { Change } from '@graphql-inspector/core'; + +export type ServiceProposalDetails = { + beforeSchema: GraphQLSchema | null; + afterSchema: GraphQLSchema | null; + allChanges: Change[]; + // Required because the component ChangesBlock uses this fragment. + rawChanges: FragmentType[]; + ignoredChanges: Array<{ + change: Change; + error: Error; + }>; + conflictingChanges: Array<{ + change: Change; + error: Error; + }>; + serviceName: string; +}; diff --git a/packages/web/app/src/pages/target-proposal.tsx b/packages/web/app/src/pages/target-proposal.tsx index 64a57d9034..644095d36f 100644 --- a/packages/web/app/src/pages/target-proposal.tsx +++ b/packages/web/app/src/pages/target-proposal.tsx @@ -1,5 +1,12 @@ +import { useMemo } from 'react'; +import { buildSchema } from 'graphql'; import { useQuery } from 'urql'; import { Page, TargetLayout } from '@/components/layouts/target'; +import { + ProposalOverview_ChangeFragment, + ProposalOverview_ReviewsFragment, + toUpperSnakeCase, +} from '@/components/proposal'; import { userText } from '@/components/proposal/util'; import { StageTransitionSelect } from '@/components/target/proposals/stage-transition-select'; import { VersionSelect } from '@/components/target/proposals/version-select'; @@ -12,13 +19,17 @@ import { Skeleton } from '@/components/ui/skeleton'; import { Spinner } from '@/components/ui/spinner'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { TimeAgo } from '@/components/v2'; -import { graphql } from '@/gql'; -import { CubeIcon, ListBulletIcon } from '@radix-ui/react-icons'; +import { FragmentType, graphql, useFragment } from '@/gql'; +import { Change } from '@graphql-inspector/core'; +import { patchSchema } from '@graphql-inspector/patch'; +import { NoopError } from '@graphql-inspector/patch/errors'; +import { ListBulletIcon } from '@radix-ui/react-icons'; import { Link } from '@tanstack/react-router'; import { TargetProposalDetailsPage } from './target-proposal-details'; import { TargetProposalEditPage } from './target-proposal-edit'; import { TargetProposalSchemaPage } from './target-proposal-schema'; import { TargetProposalSupergraphPage } from './target-proposal-supergraph'; +import { ServiceProposalDetails } from './target-proposal-types'; enum Tab { SCHEMA = 'schema', @@ -28,38 +39,19 @@ enum Tab { } const ProposalQuery = graphql(/* GraphQL */ ` - query ProposalQuery($id: ID!) { - schemaProposal(input: { id: $id }) { + query ProposalQuery($proposalId: ID!, $latestValidVersionReference: TargetReferenceInput) { + schemaProposal(input: { id: $proposalId }) { id createdAt - updatedAt - commentsCount stage title description - versions(first: 30, after: null, input: { onlyLatest: true }) { + versions { ...ProposalQuery_VersionsListFragment } - # latestVersions: versions(first: 30, after: null, input: { onlyLatest: true }) { - # edges { - # __typename - # node { - # id - # serviceName - # reviews { - # edges { - # cursor - # node { - # id - # comments { - # __typename - # } - # } - # } - # } - # } - # } - # } + reviews { + ...ProposalOverview_ReviewsFragment + } user { id email @@ -67,6 +59,46 @@ const ProposalQuery = graphql(/* GraphQL */ ` fullName } } + latestValidVersion(target: $latestValidVersionReference) { + id + # sdl + schemas { + edges { + node { + ... on CompositeSchema { + id + source + service + } + ... on SingleSchema { + id + source + } + } + } + } + } + } +`); + +const ProposalChangesQuery = graphql(/* GraphQL */ ` + query ProposalChangesQuery($id: ID!) { + schemaProposal(input: { id: $id }) { + id + versions(after: null, input: { onlyLatest: true }) { + edges { + __typename + node { + id + serviceName + changes { + __typename + ...ProposalOverview_ChangeFragment + } + } + } + } + } } `); @@ -94,6 +126,101 @@ export function TargetProposalsSinglePage(props: { } const ProposalsContent = (props: Parameters[0]) => { + // fetch main page details + const [query] = useQuery({ + query: ProposalQuery, + variables: { + latestValidVersionReference: { + bySelector: { + organizationSlug: props.organizationSlug, + projectSlug: props.projectSlug, + targetSlug: props.targetSlug, + }, + }, + proposalId: props.proposalId, + }, + requestPolicy: 'cache-and-network', + }); + + // fetch all proposed changes for the selected version + const [changesQuery] = useQuery({ + query: ProposalChangesQuery, + variables: { + id: props.proposalId, + // @todo versionId + // @todo deal with pagination + }, + requestPolicy: 'cache-and-network', + }); + + // This does a lot of heavy lifting to avoid having to reapply patching etc on each tab... + // Takes all the data provided by the queries to apply the patch to the schema and + // categorize changes. + const services = useMemo(() => { + return ( + changesQuery.data?.schemaProposal?.versions?.edges?.map( + ({ node: proposalVersion }): ServiceProposalDetails => { + const existingSchema = query.data?.latestValidVersion?.schemas.edges.find( + ({ node: latestSchema }) => + (latestSchema.__typename === 'CompositeSchema' && + latestSchema.service === proposalVersion.serviceName) || + (latestSchema.__typename === 'SingleSchema' && proposalVersion.serviceName == null), + )?.node.source; + const beforeSchema = existingSchema?.length + ? buildSchema(existingSchema, { assumeValid: true, assumeValidSDL: true }) + : null; + const allChanges = + proposalVersion.changes + .filter(c => !!c) + ?.map((change): Change => { + const c = useFragment(ProposalOverview_ChangeFragment, change); + return { + criticality: { + // isSafeBasedOnUsage: , + // reason: , + level: c.severityLevel as any, + }, + message: c.message, + meta: c.meta, + type: (c.meta && toUpperSnakeCase(c.meta?.__typename)) ?? '', // convert to upper snake + path: c.path?.join('.'), + }; + }) ?? []; + const conflictingChanges: Array<{ change: Change; error: Error }> = []; + const ignoredChanges: Array<{ change: Change; error: Error }> = []; + const afterSchema = beforeSchema + ? patchSchema(beforeSchema, allChanges, { + throwOnError: false, + onError(error, change) { + if (error instanceof NoopError) { + ignoredChanges.push({ change, error }); + return false; + } + conflictingChanges.push({ change, error }); + return true; + }, + }) + : null; + + return { + beforeSchema, + afterSchema, + allChanges, + rawChanges: proposalVersion.changes.filter(c => !!c), + conflictingChanges, + ignoredChanges, + serviceName: proposalVersion.serviceName ?? '', + }; + }, + ) ?? [] + ); + }, [ + // @todo handle pagination + changesQuery.data?.schemaProposal?.versions?.edges, + query.data?.latestValidVersion?.schemas.edges, + ]); + + const proposal = query.data?.schemaProposal; return ( <>
@@ -122,72 +249,54 @@ const ProposalsContent = (props: Parameters[0] } description={ - <> - - Collaborate on schema changes to reduce friction during development. - - + + Collaborate on schema changes to reduce friction during development. + } />
- - - ); -}; - -function SinglePageContent(props: { - organizationSlug: string; - projectSlug: string; - targetSlug: string; - proposalId: string; - tab?: string; -}) { - const [query] = useQuery({ - query: ProposalQuery, - variables: { - reference: { - bySelector: { - organizationSlug: props.organizationSlug, - projectSlug: props.projectSlug, - targetSlug: props.targetSlug, - }, - }, - id: props.proposalId, - }, - requestPolicy: 'cache-and-network', - }); - const proposal = query.data?.schemaProposal; - return ( -
- {query.fetching ? ( - - ) : ( - proposal && ( - <> -
-
- +
+ {query.fetching ? ( + + ) : ( + proposal && ( + <> +
+
+ +
+
+
+ +
-
-
- +
+ {proposal.title} +
+ proposed by {userText(proposal.user)} +
+
{proposal.description}
-
-
- {proposal.title} -
- proposed by {userText(proposal.user)} -
-
{proposal.description}
-
- - ) - )} - -
+ + ) + )} + {changesQuery.fetching ? ( + + ) : !services.length ? ( + <>No changes found + ) : ( + + )} +
+ ); -} +}; function TabbedContent(props: { organizationSlug: string; @@ -195,6 +304,8 @@ function TabbedContent(props: { targetSlug: string; proposalId: string; page?: string; + services: ServiceProposalDetails[]; + reviews: FragmentType; }) { return ( diff --git a/packages/web/app/src/pages/target-proposals.tsx b/packages/web/app/src/pages/target-proposals.tsx index 793199ec47..fbd8057b36 100644 --- a/packages/web/app/src/pages/target-proposals.tsx +++ b/packages/web/app/src/pages/target-proposals.tsx @@ -105,14 +105,14 @@ function TargetProposalsList(props: Parameters[0]) { return ( <> -
+
{hasFilterSelection ? ( - ) : null} From a18322a443e992f972b5940bb4e13d0b73e1f0b1 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Thu, 28 Aug 2025 21:59:11 -0700 Subject: [PATCH 33/54] Prettier --- .../target/proposals/change-detail.tsx | 27 ++-- .../app/src/pages/target-proposal-details.tsx | 121 ++++++++++-------- .../app/src/pages/target-proposal-schema.tsx | 8 +- 3 files changed, 88 insertions(+), 68 deletions(-) diff --git a/packages/web/app/src/components/target/proposals/change-detail.tsx b/packages/web/app/src/components/target/proposals/change-detail.tsx index e2faf91596..f8556abbe3 100644 --- a/packages/web/app/src/components/target/proposals/change-detail.tsx +++ b/packages/web/app/src/components/target/proposals/change-detail.tsx @@ -1,8 +1,13 @@ -import { AccordionContent, AccordionHeader, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"; -import { Accordion } from "@/components/v2"; -import type { Change } from "@graphql-inspector/core"; -import { labelize } from "../history/errors-and-changes"; -import { ReactNode } from "react"; +import { ReactNode } from 'react'; +import { + AccordionContent, + AccordionHeader, + AccordionItem, + AccordionTrigger, +} from '@/components/ui/accordion'; +import { Accordion } from '@/components/v2'; +import type { Change } from '@graphql-inspector/core'; +import { labelize } from '../history/errors-and-changes'; export function ProposalChangeDetail(props: { change: Change; @@ -13,16 +18,16 @@ export function ProposalChangeDetail(props: { - -
- - {labelize(props.change.message)} - + +
+ {labelize(props.change.message)} {props.icon}
- {props.error?.message ?? <>No details available for this change.} + + {props.error?.message ?? <>No details available for this change.} + ); diff --git a/packages/web/app/src/pages/target-proposal-details.tsx b/packages/web/app/src/pages/target-proposal-details.tsx index 997551b6f0..06c4f5817e 100644 --- a/packages/web/app/src/pages/target-proposal-details.tsx +++ b/packages/web/app/src/pages/target-proposal-details.tsx @@ -1,11 +1,16 @@ import { Fragment, ReactNode } from 'react'; import { ProposalOverview_ReviewsFragment } from '@/components/proposal'; -import { FragmentType } from '@/gql'; -import type { ServiceProposalDetails } from './target-proposal-types'; import { ProposalChangeDetail } from '@/components/target/proposals/change-detail'; import { Title } from '@/components/ui/page'; -import { ComponentNoneIcon, CubeIcon, ExclamationTriangleIcon, LinkBreak2Icon } from '@radix-ui/react-icons'; +import { FragmentType } from '@/gql'; import { Change, CriticalityLevel } from '@graphql-inspector/core'; +import { + ComponentNoneIcon, + CubeIcon, + ExclamationTriangleIcon, + LinkBreak2Icon, +} from '@radix-ui/react-icons'; +import type { ServiceProposalDetails } from './target-proposal-types'; export enum MergeStatus { CONFLICT, @@ -23,26 +28,25 @@ export function TargetProposalDetailsPage(props: { return (
{props.services?.map(({ allChanges, ignoredChanges, conflictingChanges, serviceName }) => { - const changes = allChanges - .map(c => { - const conflict = conflictingChanges.find(({ change }) => c === change); - if (conflict) { - return { - change: c, - error: conflict.error, - mergeStatus: MergeStatus.CONFLICT, - }; - } - const ignored = ignoredChanges.find(({ change }) => c === change); - if (ignored) { - return { - change: c, - error: ignored.error, - mergeStatus: MergeStatus.IGNORED, - } - } - return { change: c }; - }); + const changes = allChanges.map(c => { + const conflict = conflictingChanges.find(({ change }) => c === change); + if (conflict) { + return { + change: c, + error: conflict.error, + mergeStatus: MergeStatus.CONFLICT, + }; + } + const ignored = ignoredChanges.find(({ change }) => c === change); + if (ignored) { + return { + change: c, + error: ignored.error, + mergeStatus: MergeStatus.IGNORED, + }; + } + return { change: c }; + }); const breakingChanges = changes.filter(({ change }) => { return change.criticality.level === CriticalityLevel.Breaking; }); @@ -54,10 +58,14 @@ export function TargetProposalDetailsPage(props: { }); return ( - {serviceName.length !== 0 && <CubeIcon className="h-6 w-auto flex-none mr-2" /> {serviceName}} - - - + {serviceName.length !== 0 && ( + + <CubeIcon className="mr-2 h-6 w-auto flex-none" /> {serviceName} + + )} + + + ); })} @@ -67,27 +75,40 @@ export function TargetProposalDetailsPage(props: { function ChangeBlock(props: { title: string; - changes: Array<{ change: Change; error?: Error, mergeStatus?: MergeStatus }>; + changes: Array<{ change: Change; error?: Error; mergeStatus?: MergeStatus }>; }) { - return props.changes.length !== 0 && ( - <> -

{props.title}

-
- {props.changes.map(({change, error, mergeStatus }) => { - let icon: ReactNode | undefined; - if (mergeStatus === MergeStatus.CONFLICT) { - icon = CONFLICT - } else if (mergeStatus === MergeStatus.IGNORED) { - icon = NO CHANGE; - } - return - })} -
- - ) -} \ No newline at end of file + return ( + props.changes.length !== 0 && ( + <> +

{props.title}

+
+ {props.changes.map(({ change, error, mergeStatus }) => { + let icon: ReactNode | undefined; + if (mergeStatus === MergeStatus.CONFLICT) { + icon = ( + + + CONFLICT + + ); + } else if (mergeStatus === MergeStatus.IGNORED) { + icon = ( + + NO CHANGE + + ); + } + return ( + + ); + })} +
+ + ) + ); +} diff --git a/packages/web/app/src/pages/target-proposal-schema.tsx b/packages/web/app/src/pages/target-proposal-schema.tsx index ba307bf8dc..64ab79d855 100644 --- a/packages/web/app/src/pages/target-proposal-schema.tsx +++ b/packages/web/app/src/pages/target-proposal-schema.tsx @@ -14,13 +14,7 @@ export function TargetProposalSchemaPage(props: { return (
{props.services.map(proposed => { - return ( - - ); + return ; })}
); From 79557b83d95372555235ff28879e6550370a4186 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Sun, 31 Aug 2025 00:35:36 -0700 Subject: [PATCH 34/54] Move to using existing schema checks; implement create, list, and get proposal resolvers --- ...> 2025.08.30T00-00-00.schema-proposals.ts} | 79 +- packages/migrations/src/run-pg-migrations.ts | 2 +- packages/services/api/src/create.ts | 8 + .../api/src/modules/auth/lib/authz.ts | 16 + .../api/src/modules/proposals/index.ts | 21 +- .../src/modules/proposals/module.graphql.ts | 277 ++++--- .../providers/schema-proposal-manager.ts | 158 ++++ .../providers/schema-proposal-storage.ts | 334 ++++++++ .../schema-proposals-enabled-token.ts | 3 + .../Mutation/createSchemaProposal.ts | 41 +- .../Mutation/replyToSchemaProposalReview.ts | 10 + .../Mutation/reviewSchemaProposal.ts | 14 +- .../resolvers/Query/schemaProposal.ts | 745 +----------------- .../resolvers/Query/schemaProposalReview.ts | 32 - .../resolvers/Query/schemaProposalReviews.ts | 48 -- .../resolvers/Query/schemaProposals.ts | 59 +- .../proposals/resolvers/SchemaChange.ts | 14 +- .../proposals/resolvers/SchemaProposal.ts | 23 + .../proposals/resolvers/SchemaVersion.ts | 14 - .../src/modules/proposals/resolvers/Target.ts | 14 - .../services/api/src/modules/schema/index.ts | 2 + .../schema/providers/schema-publisher.ts | 27 +- .../modules/schema/resolvers/SchemaVersion.ts | 27 +- .../src/modules/target/resolvers/Target.ts | 1 + packages/services/server/.env.template | 1 + packages/services/server/README.md | 1 + packages/services/server/src/environment.ts | 4 + packages/services/server/src/index.ts | 1 + packages/services/storage/src/db/types.ts | 18 +- .../app/src/components/proposal/Review.tsx | 39 +- .../web/app/src/components/proposal/index.tsx | 20 +- .../proposal/schema-diff/components.tsx | 1 - .../target/proposals/change-detail.tsx | 6 +- .../target/proposals/version-select.tsx | 7 +- .../app/src/pages/target-proposal-details.tsx | 122 ++- .../app/src/pages/target-proposal-edit.tsx | 2 - .../src/pages/target-proposal-supergraph.tsx | 20 +- .../web/app/src/pages/target-proposal.tsx | 38 +- .../web/app/src/pages/target-proposals.tsx | 20 +- pnpm-lock.yaml | 26 +- 40 files changed, 1028 insertions(+), 1267 deletions(-) rename packages/migrations/src/actions/{2025.05.29T00.00.00.schema-proposals.ts => 2025.08.30T00-00-00.schema-proposals.ts} (63%) create mode 100644 packages/services/api/src/modules/proposals/providers/schema-proposal-manager.ts create mode 100644 packages/services/api/src/modules/proposals/providers/schema-proposal-storage.ts create mode 100644 packages/services/api/src/modules/proposals/providers/schema-proposals-enabled-token.ts create mode 100644 packages/services/api/src/modules/proposals/resolvers/Mutation/replyToSchemaProposalReview.ts delete mode 100644 packages/services/api/src/modules/proposals/resolvers/Query/schemaProposalReview.ts delete mode 100644 packages/services/api/src/modules/proposals/resolvers/Query/schemaProposalReviews.ts create mode 100644 packages/services/api/src/modules/proposals/resolvers/SchemaProposal.ts delete mode 100644 packages/services/api/src/modules/proposals/resolvers/SchemaVersion.ts delete mode 100644 packages/services/api/src/modules/proposals/resolvers/Target.ts diff --git a/packages/migrations/src/actions/2025.05.29T00.00.00.schema-proposals.ts b/packages/migrations/src/actions/2025.08.30T00-00-00.schema-proposals.ts similarity index 63% rename from packages/migrations/src/actions/2025.05.29T00.00.00.schema-proposals.ts rename to packages/migrations/src/actions/2025.08.30T00-00-00.schema-proposals.ts index d9d52ab4a3..31408f6a2c 100644 --- a/packages/migrations/src/actions/2025.05.29T00.00.00.schema-proposals.ts +++ b/packages/migrations/src/actions/2025.08.30T00-00-00.schema-proposals.ts @@ -24,13 +24,13 @@ export default { , created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() , updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() , title VARCHAR(72) NOT NULL + , description text NOT NULL , stage schema_proposal_stage NOT NULL , target_id UUID NOT NULL REFERENCES targets (id) ON DELETE CASCADE - -- ID for the user that opened the proposal + -- ID for the user that opened the proposal @todo , user_id UUID REFERENCES users (id) ON DELETE SET NULL - -- The original schema version that this proposal referenced. In case the version is deleted, - -- set this to null to avoid completely erasing the change... This should never happen. - , diff_schema_version_id UUID NOT NULL REFERENCES schema_versions (id) ON DELETE SET NULL + -- projection of the number of comments on the PR to optimize the list view + , comments_count INT NOT NULL DEFAULT 0 ) ; CREATE INDEX IF NOT EXISTS schema_proposals_list ON schema_proposals ( @@ -57,57 +57,11 @@ export default { , created_at DESC ) ; - -- For performance during schema_version delete - CREATE INDEX IF NOT EXISTS schema_proposals_diff_schema_version_id on schema_proposals ( - diff_schema_version_id - ) - ; -- For performance during user delete CREATE INDEX IF NOT EXISTS schema_proposals_diff_user_id on schema_proposals ( user_id ) ; - /** - * Request patterns include: - * - Get by ID - * - List proposal's latest versions for each service - * - List all proposal's versions ordered by date - */ - CREATE TABLE IF NOT EXISTS "schema_proposal_versions" - ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4 () - , user_id UUID REFERENCES users (id) ON DELETE SET NULL - , created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() - , schema_proposal_id UUID NOT NULL REFERENCES schema_proposals (id) ON DELETE CASCADE - , service_name text - , schema_sdl text NOT NULL - ) - ; - CREATE INDEX IF NOT EXISTS schema_proposal_versions_list_latest_by_distinct_service ON schema_proposal_versions( - schema_proposal_id - , service_name - , created_at DESC - ) - ; - CREATE INDEX IF NOT EXISTS schema_proposal_versions_schema_proposal_id_created_at ON schema_proposal_versions( - schema_proposal_id - , created_at DESC - ) - ; - /** - * Request patterns include: - * - Get by ID - * - List proposal's latest versions for each service - * - List all proposal's versions ordered by date - */ - /** - SELECT * FROM schema_proposal_comments as c JOIN schema_proposal_reviews as r - ON r.schema_proposal_review_id = c.id - WHERE schema_proposal_id = $1 - ORDER BY created_at - LIMIT 10 - ; - */ CREATE TABLE IF NOT EXISTS "schema_proposal_reviews" ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4 () @@ -116,21 +70,17 @@ export default { , stage_transition schema_proposal_stage NOT NULL , user_id UUID REFERENCES users (id) ON DELETE SET NULL , schema_proposal_id UUID NOT NULL REFERENCES schema_proposals (id) ON DELETE CASCADE - -- store the originally proposed version to be able to reference back as outdated if unable to attribute - -- the review to another version. - , original_schema_proposal_version_id UUID NOT NULL REFERENCES schema_proposal_versions (id) ON DELETE SET NULL -- store the original text of the line that is being reviewed. If the base schema version changes, then this is -- used to determine which line this review falls on. If no line matches in the current version, then -- show as outdated and attribute to the original line. , line_text text - -- used in combination with the line_text to determine what line in the current version this review is attributed to - , original_line_num INT -- the coordinate closest to the reviewed line. E.g. if a comment is reviewed, then -- this is the coordinate that the comment applies to. -- note that the line_text must still be stored in case the coordinate can no -- longer be found in the latest proposal version. That way a preview of the reviewed -- line can be provided. , schema_coordinate text + , resolved_by_user_id UUID REFERENCES users (id) ON DELETE SET NULL ) ; CREATE INDEX IF NOT EXISTS schema_proposal_reviews_schema_proposal_id ON schema_proposal_reviews( @@ -143,9 +93,9 @@ export default { user_id ) ; - -- For performance on schema_proposal_versions delete - CREATE INDEX IF NOT EXISTS schema_proposal_reviews_original_schema_proposal_version_id ON schema_proposal_reviews( - original_schema_proposal_version_id + -- For performance on user delete + CREATE INDEX IF NOT EXISTS schema_proposal_reviews_resolved_by_user_id ON schema_proposal_reviews( + resolved_by_user_id ) ; /** @@ -175,5 +125,18 @@ export default { ; `, }, + { + // Associate schema checks with schema proposals + name: 'Add "organization_member_roles"."created_at" column', + query: sql` + ALTER TABLE "schema_checks" + ADD COLUMN IF NOT EXISTS "schema_proposal_id" UUID REFERENCES "schema_proposals" ("id") ON DELETE SET NULL + ; + CREATE INDEX IF NOT EXISTS schema_checks_schema_proposal_id ON schema_checks( + schema_proposal_id + ) + ; + `, + }, ], } satisfies MigrationExecutor; diff --git a/packages/migrations/src/run-pg-migrations.ts b/packages/migrations/src/run-pg-migrations.ts index 6790644d2f..b640ae650b 100644 --- a/packages/migrations/src/run-pg-migrations.ts +++ b/packages/migrations/src/run-pg-migrations.ts @@ -167,6 +167,6 @@ export const runPGMigrations = async (args: { slonik: DatabasePool; runTo?: stri await import('./actions/2025.05.15T00-00-00.contracts-foreign-key-constraint-fix'), await import('./actions/2025.05.15T00-00-01.organization-member-pagination'), await import('./actions/2025.05.28T00-00-00.schema-log-by-ids'), - await import('./actions/2025.05.29T00.00.00.schema-proposals'), + await import('./actions/2025.08.30T00-00-00.schema-proposals'), ], }); diff --git a/packages/services/api/src/create.ts b/packages/services/api/src/create.ts index d9c8c8d320..a19e06b465 100644 --- a/packages/services/api/src/create.ts +++ b/packages/services/api/src/create.ts @@ -37,6 +37,7 @@ import { } from './modules/policy/providers/tokens'; import { projectModule } from './modules/project'; import { proposalsModule } from './modules/proposals'; +import { SCHEMA_PROPOSALS_ENABLED } from './modules/proposals/providers/schema-proposals-enabled-token'; import { schemaModule } from './modules/schema'; import { ArtifactStorageWriter } from './modules/schema/providers/artifact-storage-writer'; import { provideSchemaModuleConfig, SchemaModuleConfig } from './modules/schema/providers/config'; @@ -115,6 +116,7 @@ export function createRegistry({ organizationOIDC, pubSub, appDeploymentsEnabled, + schemaProposalsEnabled, prometheus, }: { logger: Logger; @@ -159,6 +161,7 @@ export function createRegistry({ organizationOIDC: boolean; pubSub: HivePubSub; appDeploymentsEnabled: boolean; + schemaProposalsEnabled: boolean; prometheus: null | Record; }) { const s3Config: S3Config = [ @@ -286,6 +289,11 @@ export function createRegistry({ useValue: appDeploymentsEnabled, scope: Scope.Singleton, }, + { + provide: SCHEMA_PROPOSALS_ENABLED, + useValue: schemaProposalsEnabled, + scope: Scope.Singleton, + }, { provide: WEB_APP_URL, useValue: app?.baseUrl.replace(/\/$/, '') ?? 'http://localhost:3000', diff --git a/packages/services/api/src/modules/auth/lib/authz.ts b/packages/services/api/src/modules/auth/lib/authz.ts index 0e6163269e..4a25110bca 100644 --- a/packages/services/api/src/modules/auth/lib/authz.ts +++ b/packages/services/api/src/modules/auth/lib/authz.ts @@ -351,6 +351,18 @@ function defaultAppDeploymentIdentity( return ids; } +function defaultSchemaProposalIdentity( + args: { schemaProposalId: string | null } & Parameters[0], +) { + const ids = defaultTargetIdentity(args); + + if (args.schemaProposalId !== null) { + ids.push(`target/${args.targetId}/schemaProposal/${args.schemaProposalId}`); + } + + return ids; +} + function schemaCheckOrPublishIdentity( args: { serviceName: string | null } & Parameters[0], ) { @@ -418,6 +430,7 @@ const permissionsByLevel = { z.literal('appDeployment:publish'), z.literal('appDeployment:retire'), ], + schemaProposal: [z.literal('schemaProposal:modify')], } as const; export const allPermissions = [ @@ -510,6 +523,9 @@ const actionDefinitions = { ...objectFromEntries( permissionsByLevel['appDeployment'].map(t => [t.value, defaultAppDeploymentIdentity]), ), + ...objectFromEntries( + permissionsByLevel['schemaProposal'].map(t => [t.value, defaultSchemaProposalIdentity]), + ), } satisfies ActionDefinitionMap; type Actions = keyof typeof actionDefinitions; diff --git a/packages/services/api/src/modules/proposals/index.ts b/packages/services/api/src/modules/proposals/index.ts index d1b6862b56..902865682b 100644 --- a/packages/services/api/src/modules/proposals/index.ts +++ b/packages/services/api/src/modules/proposals/index.ts @@ -1,4 +1,12 @@ import { createModule } from 'graphql-modules'; +import { BreakingSchemaChangeUsageHelper } from '../schema/providers/breaking-schema-changes-helper'; +import { ContractsManager } from '../schema/providers/contracts-manager'; +import { models as schemaModels } from '../schema/providers/models'; +import { CompositionOrchestrator } from '../schema/providers/orchestrator/composition-orchestrator'; +import { RegistryChecks } from '../schema/providers/registry-checks'; +import { SchemaPublisher } from '../schema/providers/schema-publisher'; +import { SchemaProposalManager } from './providers/schema-proposal-manager'; +import { SchemaProposalStorage } from './providers/schema-proposal-storage'; import { resolvers } from './resolvers.generated'; import typeDefs from './module.graphql'; @@ -7,5 +15,16 @@ export const proposalsModule = createModule({ dirname: __dirname, typeDefs, resolvers, - providers: [], + providers: [ + SchemaProposalManager, + SchemaProposalStorage, + + /** Schema module providers -- To allow publishing checks */ + SchemaPublisher, + RegistryChecks, + ContractsManager, + BreakingSchemaChangeUsageHelper, + CompositionOrchestrator, + ...schemaModels, + ], }); diff --git a/packages/services/api/src/modules/proposals/module.graphql.ts b/packages/services/api/src/modules/proposals/module.graphql.ts index 3044271083..c662065b56 100644 --- a/packages/services/api/src/modules/proposals/module.graphql.ts +++ b/packages/services/api/src/modules/proposals/module.graphql.ts @@ -2,45 +2,129 @@ import { gql } from 'graphql-modules'; export default gql` extend type Mutation { - createSchemaProposal(input: CreateSchemaProposalInput!): SchemaProposal! - reviewSchemaProposal(input: ReviewSchemaProposalInput!): SchemaProposalReview! - commentOnSchemaProposalReview( + createSchemaProposal(input: CreateSchemaProposalInput!): CreateSchemaProposalResult! + reviewSchemaProposal(input: ReviewSchemaProposalInput!): ReviewSchemaProposalResult! + replyToSchemaProposalReview( input: CommentOnSchemaProposalReviewInput! - ): SchemaProposalComment! + ): ReplyToSchemaProposalReviewResult! + } + + type ReplyToSchemaProposalReviewResult { + ok: ReplyToSchemaProposalReviewOk + error: ReplyToSchemaProposalReviewError + } + + type ReplyToSchemaProposalReviewOk { + reply: SchemaProposalComment! + } + + type ReplyToSchemaProposalReviewError implements Error { + message: String! + } + + type ReviewSchemaProposalResult { + ok: ReviewSchemaProposalOk + error: ReviewSchemaProposalError + } + + type ReviewSchemaProposalOk { + review: SchemaProposalReview! + } + + type ReviewSchemaProposalError implements Error { + message: String! + } + + type CreateSchemaProposalResult { + error: CreateSchemaProposalError + ok: CreateSchemaProposalOk + } + + type CreateSchemaProposalOk { + schemaProposal: SchemaProposal! + } + + type CreateSchemaProposalErrorDetails { + """ + Error message for the input title. + """ + title: String + """ + Error message for the input description. + """ + description: String + } + + type CreateSchemaProposalError implements Error { + message: String! + details: CreateSchemaProposalErrorDetails! + } + + input SchemaProposalCheckInput { + service: ID + sdl: String! + github: GitHubSchemaCheckInput + meta: SchemaCheckMetaInput + """ + Optional context ID to group schema checks together. + Manually approved breaking changes will be memorized for schema checks with the same context id. + """ + contextId: String + """ + Optional url if wanting to show subgraph url changes inside checks. + """ + url: String } input CreateSchemaProposalInput { - diffSchemaVersionId: ID! + """ + Reference to the proposal's target. Either an ID or path. + """ + target: TargetReferenceInput! + + """ + The title of the proposal. A short description of the proposal's main focus/theme. + """ title: String! """ - The initial changes by serviceName submitted as part of this proposal. Initial versions must have - unique "serviceName"s. + If no description was provided then this will be an empty string. """ - initialVersions: [CreateSchemaProposalInitialVersionInput!]! = [] + description: String! = "" """ The default initial stage is OPEN. Set this to true to create this as proposal as a DRAFT instead. """ isDraft: Boolean! = false - } - input CreateSchemaProposalInitialVersionInput { - schemaSDL: String! - serviceName: String + """ + The initial proposed service changes to be ran as checks + """ + initialChecks: [SchemaProposalCheckInput!]! } input ReviewSchemaProposalInput { - schemaProposalVersionId: ID! + """ + The schema proposal that this review is being made on. + """ + schemaProposalId: ID! + + """ + The schema coordinate being referenced. E.g. "Type.field". + If null, then this review is for the entire proposal. + """ + coordinate: String + """ One or both of stageTransition or initialComment inputs is/are required. """ stageTransition: SchemaProposalStage + """ - One or both of stageTransition or initialComment inputs is/are required. + The initial comment message attached to the review """ - commentBody: String + commentBody: String = "" } input CommentOnSchemaProposalReviewInput { @@ -52,15 +136,9 @@ export default gql` schemaProposals( after: String first: Int! = 30 - input: SchemaProposalsInput + input: SchemaProposalsInput! ): SchemaProposalConnection schemaProposal(input: SchemaProposalInput!): SchemaProposal - schemaProposalReviews( - after: String - first: Int! = 30 - input: SchemaProposalReviewsInput! - ): SchemaProposalReviewConnection - schemaProposalReview(input: SchemaProposalReviewInput!): SchemaProposalReview } input SchemaProposalsInput { @@ -76,25 +154,6 @@ export default gql` id: ID! } - input SchemaProposalReviewInput { - """ - Unique identifier of the desired SchemaProposalReview. - """ - id: ID! - } - - input SchemaProposalReviewsInput { - schemaProposalId: ID! - } - - extend type User { - id: ID! - } - - extend type Target { - id: ID! - } - type SchemaProposalConnection { edges: [SchemaProposalEdge!] pageInfo: PageInfo! @@ -105,10 +164,6 @@ export default gql` node: SchemaProposal! } - extend type SchemaVersion { - id: ID! - } - enum SchemaProposalStage { DRAFT OPEN @@ -136,18 +191,16 @@ export default gql` """ stage: SchemaProposalStage! - """ - The schema Target that this proposal is to be applied to. - """ - target: Target - """ A short title of this proposal. Meant to give others an easy way to refer to this set of changes. """ - title: String + title: String! - description: String + """ + The proposal description. If no description was given, this will be an empty string. + """ + description: String! """ When the proposal was last modified. Adding a review or comment does not count. @@ -155,87 +208,60 @@ export default gql` updatedAt: DateTime! """ - The author of the proposal. If no author has been assigned or if that member is - removed from the org, then this returns null. + The author of the proposal. If no author has been assigned, then this returns an empty string. + The author is taken from the author of the oldest check ran for this proposal. """ - user: User - - versions( - after: String - first: Int! = 15 - input: SchemaProposalVersionsInput - ): SchemaProposalVersionConnection - - commentsCount: Int! - } - - type SchemaProposalReviewEdge { - cursor: String! - node: SchemaProposalReview! - } - - type SchemaProposalReviewConnection { - edges: [SchemaProposalReviewEdge!] - pageInfo: PageInfo! - } - - type SchemaProposalVersionEdge { - cursor: String! - node: SchemaProposalVersion! - } - - type SchemaProposalVersionConnection { - edges: [SchemaProposalVersionEdge!] - pageInfo: PageInfo! - } + author: String! - input SchemaProposalVersionsInput { """ - Option to return only the latest version of each schema proposal. Versions + The checks associated with this proposal. Each proposed change triggers a check + for the set of changes. And each service is checked separately. This is a limitation + of the schema check API at this time. """ - onlyLatest: Boolean! = false + checks(after: ID, first: Int! = 20, input: SchemaProposalChecksInput!): SchemaCheckConnection """ - Limit the returned SchemaProposalVersions to a single version. This can still - return multiple elements if that version changed multiple services. - """ - schemaProposalVersionId: ID - } + Applies changes to each service subgraph for each of the service's latest check belonging to the SchemaProposal. - # @key(fields: "id serviceName") - type SchemaProposalVersion { - """ - An identifier for a list of SchemaProposalVersions. + @todo consider making this a connection before going live. """ - id: ID! + rebasedSchemaSDL(checkId: ID): [SubgraphSchema!] """ - The service that this version applies to. + Applies changes to the supergraph for each of the service's latest check belonging to the SchemaProposal. """ - serviceName: String + rebasedSupergraphSDL(versionId: ID): String - createdAt: DateTime! + commentsCount: Int! + } + input SchemaProposalChecksInput { """ - The SchemaProposal that this version belongs to. A proposal can have - multiple versions. + Set to "true" to only return the latest checks for each service. """ - schemaProposal: SchemaProposal! + latestPerService: Boolean! = false + } + type SubgraphSchema { """ - The user who submitted this version of the proposal. + The SDL of the schema that was checked. """ - user: User + schemaSDL: String! """ - The list of proposed changes to the Target. + The name of the service that owns the schema. Is null for non composite project types. """ - changes: [SchemaChange]! + serviceName: String + } - """ - A paginated list of reviews. - """ - reviews(after: String, first: Int! = 30): SchemaProposalReviewConnection + type SchemaProposalReviewEdge { + cursor: String! + node: SchemaProposalReview! + } + + type SchemaProposalReviewConnection { + edges: [SchemaProposalReviewEdge!] + pageInfo: PageInfo! } type SchemaProposalCommentEdge { @@ -281,9 +307,16 @@ export default gql` schemaCoordinate: String """ - The specific version of the proposal that this review is for. + Name of the service if reviewing a specific service's schema. + Else an empty string. """ - schemaProposalVersion: SchemaProposalVersion + serviceName: String! + + # @todo + # """ + # The specific version of the proposal that this review is for. + # """ + # schemaProposalVersion: SchemaProposalVersion """ If null then this review is just a comment. Otherwise, the reviewer changed the state of the @@ -292,14 +325,17 @@ export default gql` stageTransition: SchemaProposalStage """ - The author of this review. + The stored author of this review. Does not update if a user changes their name for the time being. """ - user: User + author: String! } type SchemaProposalComment { id: ID! + """ + When the comment was initially posted + """ createdAt: DateTime! """ @@ -307,12 +343,15 @@ export default gql` """ body: String! - updatedAt: DateTime! + """ + If edited, then when it was last edited. + """ + updatedAt: DateTime """ - The author of this comment + The stored author of this comment. Does not update if a user changes their name for the time being. """ - user: User + author: String! } extend type SchemaChange { @@ -723,7 +762,7 @@ export default gql` type TypeAdded { addedTypeName: String! - addedTypeKind: GraphQLKind + addedTypeKind: String } type TypeKindChanged { diff --git a/packages/services/api/src/modules/proposals/providers/schema-proposal-manager.ts b/packages/services/api/src/modules/proposals/providers/schema-proposal-manager.ts new file mode 100644 index 0000000000..fa7267305f --- /dev/null +++ b/packages/services/api/src/modules/proposals/providers/schema-proposal-manager.ts @@ -0,0 +1,158 @@ +/** + * This wraps the higher level logic with schema proposals. + */ +import { Injectable, Scope } from 'graphql-modules'; +import { TargetReferenceInput } from 'packages/libraries/core/src/client/__generated__/types'; +import { SchemaChangeType } from '@hive/storage'; +import { SchemaProposalCheckInput, SchemaProposalStage } from '../../../__generated__/types'; +import { Session } from '../../auth/lib/authz'; +import { SchemaPublisher } from '../../schema/providers/schema-publisher'; +import { IdTranslator } from '../../shared/providers/id-translator'; +import { Logger } from '../../shared/providers/logger'; +import { SchemaProposalStorage } from './schema-proposal-storage'; + +@Injectable({ + scope: Scope.Operation, +}) +export class SchemaProposalManager { + private logger: Logger; + + constructor( + logger: Logger, + private storage: SchemaProposalStorage, + private session: Session, + private idTranslator: IdTranslator, + private schemaPublisher: SchemaPublisher, + ) { + this.logger = logger.child({ source: 'SchemaProposalsManager' }); + } + + async proposeSchema(args: { + target: TargetReferenceInput; + title: string; + description: string; + isDraft: boolean; + user: { + id: string; + displayName: string; + }; + initialChecks: ReadonlyArray; + }) { + const selector = await this.idTranslator.resolveTargetReference({ reference: args.target }); + if (selector === null) { + this.session.raise('schemaProposal:modify'); + } + + const createProposalResult = await this.storage.createProposal({ + organizationId: selector.organizationId, + userId: args.user.id, + description: args.description, + stage: args.isDraft ? 'DRAFT' : 'OPEN', + targetId: selector.targetId, + title: args.title, + }); + + if (createProposalResult.type === 'error') { + return createProposalResult; + } + + const proposal = createProposalResult.proposal; + const changes: SchemaChangeType[] = []; + const checkPromises = args.initialChecks.map(async check => { + const result = await this.schemaPublisher.check({ + ...check, + service: check.service?.toLowerCase(), + target: { byId: selector.targetId }, + schemaProposalId: proposal.id, + }); + if ('changes' in result && result.changes) { + changes.push(...result.changes); + return { + ...result, + changes: result.changes, + errors: + result.errors?.map(error => ({ + ...error, + path: 'path' in error ? error.path?.split('.') : null, + })) ?? [], + }; + } + }); + + // @todo handle errors... rollback? + const checks = await Promise.all(checkPromises); + + // @todo consider mapping this here vs using the nested resolver... This is more efficient but riskier bc logic lives in two places. + // const checkEdges = checks.map(check => ({ + // node: check, + // cursor: 'schemaCheck' in check && encodeCreatedAtAndUUIDIdBasedCursor({ id: check.schemaCheck!.id, createdAt: check.schemaCheck!.createdAt} ) || undefined, + // })) as any; // @todo + return { + type: 'ok' as const, + schemaProposal: { + title: proposal.title, + description: proposal.description, + id: proposal.id, + createdAt: proposal.createdAt, + updatedAt: proposal.updatedAt, + stage: proposal.stage, + targetId: proposal.targetId, + reviews: null, + author: args.user.displayName, + commentsCount: 0, + // checks: { + // edges: checkEdges, + // pageInfo: { + // hasNextPage: false, + // hasPreviousPage: false, + // startCursor: checkEdges[0]?.cursor || '', + // endCursor: checkEdges[checkEdges.length -1]?.cursor || '', + // }, + // }, + // rebasedSchemaSDL(checkId: ID): [SubgraphSchema!] + // rebasedSupergraphSDL(versionId: ID): String + }, + }; + } + + async getProposal(args: { proposalId: string }) { + return this.storage.getProposal(args); + } + + async getPaginatedReviews(args: { proposalId: string; first: number; after: string }) { + this.logger.debug('Get paginated reviews (target=%s, after=%s)', args.proposalId, args.after); + + return this.storage.getPaginatedReviews(args); + } + + async getPaginatedProposals(args: { + target: TargetReferenceInput; + first: number; + after: string; + stages: ReadonlyArray; + users: string[]; + }) { + this.logger.debug( + 'Get paginated proposals (target=%s, after=%s, stages=%s)', + args.target.bySelector?.targetSlug || args.target.byId, + args.after, + args.stages.join(','), + ); + const selector = await this.idTranslator.resolveTargetReference({ + reference: args.target, + }); + if (selector === null) { + this.session.raise('schemaProposal:modify'); + } + + return this.storage.getPaginatedProposals({ + targetId: selector.targetId, + after: args.after, + first: args.first, + stages: args.stages, + users: [], + }); + } + + async reviewProposal(args: { proposalId: string }) {} +} diff --git a/packages/services/api/src/modules/proposals/providers/schema-proposal-storage.ts b/packages/services/api/src/modules/proposals/providers/schema-proposal-storage.ts new file mode 100644 index 0000000000..aa4060caad --- /dev/null +++ b/packages/services/api/src/modules/proposals/providers/schema-proposal-storage.ts @@ -0,0 +1,334 @@ +/** + * This wraps the database calls for schema proposals and required validation + */ +import { Inject, Injectable, Scope } from 'graphql-modules'; +import { sql, type DatabasePool } from 'slonik'; +import { z } from 'zod'; +import { + decodeCreatedAtAndUUIDIdBasedCursor, + encodeCreatedAtAndUUIDIdBasedCursor, +} from '@hive/storage'; +import { SchemaProposalStage } from '../../../__generated__/types'; +import { Logger } from '../../shared/providers/logger'; +import { PG_POOL_CONFIG } from '../../shared/providers/pg-pool'; +import { Storage } from '../../shared/providers/storage'; +import { SCHEMA_PROPOSALS_ENABLED } from './schema-proposals-enabled-token'; + +const SchemaProposalsTitleModel = z + .string() + .min(1, 'Must be at least 1 character long') + .max(64, 'Must be at most 64 characters long'); + +const SchemaProposalsDescriptionModel = z + .string() + .trim() + .max(1024, 'Must be at most 1024 characters long') + .default(''); + +const noAccessToSchemaProposalsMessage = + 'This organization has no access to schema proposals. Please contact the Hive team for early access.'; + +@Injectable({ + scope: Scope.Operation, +}) +export class SchemaProposalStorage { + private logger: Logger; + + constructor( + logger: Logger, + @Inject(PG_POOL_CONFIG) private pool: DatabasePool, + private storage: Storage, + @Inject(SCHEMA_PROPOSALS_ENABLED) private schemaProposalsEnabled: Boolean, // @todo + ) { + this.logger = logger.child({ source: 'SchemaProposalStorage' }); + } + + private async assertSchemaProposalsEnabled(args: { + organizationId: string; + targetId: string; + proposalId?: string; + }) { + if (this.schemaProposalsEnabled === false) { + const organization = await this.storage.getOrganization({ + organizationId: args.organizationId, + }); + if (organization.featureFlags.appDeployments === false) { + this.logger.debug( + 'organization has no access to schema proposals (targetId=%s, proposalId=%s)', + args.targetId, + args.proposalId, + ); + return { + type: 'error' as const, + error: { + message: noAccessToSchemaProposalsMessage, + details: null, + }, + }; + } + } + } + + async createProposal(args: { + organizationId: string; + targetId: string; + title: string; + description: string; + stage: SchemaProposalStage; + userId: string; + }) { + this.logger.debug( + 'propose schema (targetId=%s, title=%s, stage=%b)', + args.targetId, + args.title, + args.stage, + ); + + this.assertSchemaProposalsEnabled({ + organizationId: args.organizationId, + targetId: args.targetId, + proposalId: undefined, + }); + + const titleValidationResult = SchemaProposalsTitleModel.safeParse(args.title); + const descriptionValidationResult = SchemaProposalsDescriptionModel.safeParse(args.description); + if (titleValidationResult.error || descriptionValidationResult.error) { + return { + type: 'error' as const, + error: { + message: 'Invalid input', + details: { + title: titleValidationResult.error?.issues[0].message ?? null, + description: descriptionValidationResult.error?.issues[0].message ?? null, + }, + }, + }; + } + const proposal = await this.pool + .maybeOne( + sql` + INSERT INTO schema_proposals + ( + "id" + , "created_at" + , "updated_at" + , "target_id" + , "title" + , "description" + , "stage" + , "user_id" + ) + VALUES + ( + DEFAULT + , DEFAULT + , DEFAULT + , ${args.targetId} + , ${args.title} + , ${args.description} + , ${args.stage} + , ${args.userId} + ) + RETURNING * + `, + ) + .then(row => SchemaProposalModel.parse(row)); + + return { + type: 'ok' as const, + proposal, + }; + } + + async getProposal(args: { proposalId: string }) { + const result = await this.pool + .maybeOne( + sql` + SELECT + ${schemaProposalFields} + FROM + "schema_proposals" + WHERE + "id" = ${args.proposalId} + `, + ) + .then(row => SchemaProposalModel.parse(row)); + + return result; + } + + async getPaginatedProposals(args: { + targetId: string; + first: number; + after: string; + stages: ReadonlyArray; + users: string[]; + }) { + this.logger.debug( + 'Get paginated proposals (target=%s, after=%s, stages=%s)', + args.targetId, + args.after, + args.stages.join(','), + ); + const limit = args.first ? (args.first > 0 ? Math.min(args.first, 20) : 20) : 20; + const cursor = args.after ? decodeCreatedAtAndUUIDIdBasedCursor(args.after) : null; + + this.logger.debug( + 'Select by target ID (targetId=%s, cursor=%s, limit=%d)', + args.targetId, + cursor, + limit, + ); + const result = await this.pool.query(sql` + SELECT + ${schemaProposalFields} + FROM + "schema_proposals" + WHERE + "target_id" = ${args.targetId} + ${ + cursor + ? sql` + AND ( + ( + "created_at" = ${cursor.createdAt} + AND "id" < ${cursor.id} + ) + OR "created_at" < ${cursor.createdAt} + ) + ` + : sql`` + } + ORDER BY "created_at" DESC, "id" + LIMIT ${limit + 1} + `); + + let items = result.rows.map(row => { + const node = SchemaProposalModel.parse(row); + + return { + cursor: encodeCreatedAtAndUUIDIdBasedCursor(node), + node, + }; + }); + + const hasNextPage = items.length > limit; + items = items.slice(0, limit); + + return { + edges: items, + pageInfo: { + hasNextPage, + hasPreviousPage: cursor !== null, + endCursor: items[items.length - 1]?.cursor ?? '', + startCursor: items[0]?.cursor ?? '', + }, + }; + } + + async getPaginatedReviews(args: { proposalId: string; first: number; after: string }) { + this.logger.debug('Get paginated reviews (proposal=%s, after=%s)', args.proposalId, args.after); + const limit = args.first ? (args.first > 0 ? Math.min(args.first, 20) : 20) : 20; + const cursor = args.after ? decodeCreatedAtAndUUIDIdBasedCursor(args.after) : null; + + this.logger.debug( + 'Select by proposalId ID (targetId=%s, cursor=%s, limit=%d)', + args.proposalId, + cursor, + limit, + ); + const result = await this.pool.query(sql` + SELECT + ${schemaProposalReviewFields} + FROM + "schema_proposal_reviews" + WHERE + "proposal_id" = ${args.proposalId} + ${ + cursor + ? sql` + AND ( + ( + "created_at" = ${cursor.createdAt} + AND "id" < ${cursor.id} + ) + OR "created_at" < ${cursor.createdAt} + ) + ` + : sql`` + } + ORDER BY "created_at" DESC, "id" + LIMIT ${limit + 1} + `); + + let items = result.rows.map(row => { + const node = SchemaProposalReviewModel.parse(row); + + return { + cursor: encodeCreatedAtAndUUIDIdBasedCursor(node), + node, + }; + }); + + const hasNextPage = items.length > limit; + items = items.slice(0, limit); + + return { + edges: items, + pageInfo: { + hasNextPage, + hasPreviousPage: cursor !== null, + endCursor: items[items.length - 1]?.cursor ?? '', + startCursor: items[0]?.cursor ?? '', + }, + }; + } +} + +const schemaProposalFields = sql` + "id" + , to_json("created_at") as "createdAt" + , to_json("updated_at") as "updatedAt" + , "title" + , "description" + , "stage" + , "target_id" as "targetId" + , "user_id" as "userId" + , "comments_count" as "commentsCount" +`; + +const schemaProposalReviewFields = sql` + "id" + , "schema_proposal_id" + , to_json("created_at") as "createdAt" + , "stage_transition" as "stageTransition" + , "user_id" as "userId" + , "line_text" as "lineText" + , "schema_coordinate" as "schemaCoordinate" + , "resolved_by_user_id" as "resolvedByUserId" +`; + +const SchemaProposalReviewModel = z.object({ + id: z.string(), + createdAt: z.string(), + updatedAt: z.string(), + stageTransition: z.enum(['DRAFT', 'OPEN', 'APPROVED']), + userId: z.string(), + lineText: z.string(), + schemaCoordinate: z.string(), + resolvedByUserId: z.string(), +}); + +const SchemaProposalModel = z.object({ + id: z.string(), + createdAt: z.string(), + updatedAt: z.string(), + title: z.string(), + description: z.string(), + stage: z.enum(['DRAFT', 'OPEN', 'APPROVED', 'IMPLEMENTED', 'CLOSED']), + targetId: z.string(), + userId: z.string(), + commentsCount: z.number(), +}); + +export type SchemaProposalRecord = z.infer; diff --git a/packages/services/api/src/modules/proposals/providers/schema-proposals-enabled-token.ts b/packages/services/api/src/modules/proposals/providers/schema-proposals-enabled-token.ts new file mode 100644 index 0000000000..d2f4085e51 --- /dev/null +++ b/packages/services/api/src/modules/proposals/providers/schema-proposals-enabled-token.ts @@ -0,0 +1,3 @@ +import { InjectionToken } from 'graphql-modules'; + +export const SCHEMA_PROPOSALS_ENABLED = new InjectionToken('SCHEMA_PROPOSALS_ENABLED'); diff --git a/packages/services/api/src/modules/proposals/resolvers/Mutation/createSchemaProposal.ts b/packages/services/api/src/modules/proposals/resolvers/Mutation/createSchemaProposal.ts index cb75602425..802d027b82 100644 --- a/packages/services/api/src/modules/proposals/resolvers/Mutation/createSchemaProposal.ts +++ b/packages/services/api/src/modules/proposals/resolvers/Mutation/createSchemaProposal.ts @@ -1,16 +1,39 @@ +import { SchemaProposalManager } from '../../providers/schema-proposal-manager'; import type { MutationResolvers } from './../../../../__generated__/types'; export const createSchemaProposal: NonNullable = async ( - _parent, - _arg, - _ctx, + _, + { input }, + { injector, session }, ) => { - /* Implement Mutation.createSchemaProposal resolver logic here */ + const { target, title, description, isDraft, initialChecks } = input; + const user = await session.getViewer(); + const result = await injector.get(SchemaProposalManager).proposeSchema({ + target, + title, + description: description ?? '', + isDraft: isDraft ?? false, + user: { + id: user.id, + displayName: user.displayName, + }, + initialChecks, + }); + + if (result.type === 'error') { + return { + error: { + message: result.error.message, + details: result.error.details, + }, + ok: null, + }; + } + return { - createdAt: Date.now(), - commentsCount: 5, - id: `abcd-1234-efgh-5678-wxyz`, - stage: 'DRAFT', - updatedAt: Date.now(), + error: null, + ok: { + schemaProposal: result.schemaProposal, + }, }; }; diff --git a/packages/services/api/src/modules/proposals/resolvers/Mutation/replyToSchemaProposalReview.ts b/packages/services/api/src/modules/proposals/resolvers/Mutation/replyToSchemaProposalReview.ts new file mode 100644 index 0000000000..92fe70bf11 --- /dev/null +++ b/packages/services/api/src/modules/proposals/resolvers/Mutation/replyToSchemaProposalReview.ts @@ -0,0 +1,10 @@ +import type { MutationResolvers } from '../../../../__generated__/types'; + +export const replyToSchemaProposalReview: NonNullable< + MutationResolvers['replyToSchemaProposalReview'] +> = async (_parent, { input: { body, schemaProposalReviewId } }, { session }) => { + const user = await session.getViewer(); + return { + author: user.displayName ?? user.fullName, + } as any /** @todo */; +}; diff --git a/packages/services/api/src/modules/proposals/resolvers/Mutation/reviewSchemaProposal.ts b/packages/services/api/src/modules/proposals/resolvers/Mutation/reviewSchemaProposal.ts index c7b271a770..d49b5c1506 100644 --- a/packages/services/api/src/modules/proposals/resolvers/Mutation/reviewSchemaProposal.ts +++ b/packages/services/api/src/modules/proposals/resolvers/Mutation/reviewSchemaProposal.ts @@ -2,18 +2,8 @@ import type { MutationResolvers } from './../../../../__generated__/types'; export const reviewSchemaProposal: NonNullable = async ( _parent, - { input: { stageTransition, commentBody } }, + _arg, _ctx, ) => { - return { - createdAt: Date.now(), - id: `abcd-1234-efgh-5678-wxyz`, - schemaProposal: { - stage: stageTransition ?? 'OPEN', - commentsCount: commentBody ? 1 : 0, - createdAt: Date.now(), - id: `abcd-1234-efgh-5678-wxyz`, - updatedAt: Date.now(), - }, - }; + /* Implement Mutation.reviewSchemaProposal resolver logic here */ }; diff --git a/packages/services/api/src/modules/proposals/resolvers/Query/schemaProposal.ts b/packages/services/api/src/modules/proposals/resolvers/Query/schemaProposal.ts index 9b77987672..b9baa9d798 100644 --- a/packages/services/api/src/modules/proposals/resolvers/Query/schemaProposal.ts +++ b/packages/services/api/src/modules/proposals/resolvers/Query/schemaProposal.ts @@ -1,747 +1,10 @@ -import type { - CriticalityLevel, - QueryResolvers, - SeverityLevelType, -} from './../../../../__generated__/types'; - -const changes = [ - { - criticality: { level: 'NON_BREAKING' }, - type: 'TYPE_ADDED', - message: "Type 'DType' was added", - meta: { addedTypeKind: 'ObjectTypeDefinition', addedTypeName: 'DType' }, - path: 'DType', - }, - { - type: 'FIELD_ADDED', - criticality: { level: 'NON_BREAKING' }, - message: "Field 'b' was added to object type 'DType'", - meta: { - typeName: 'DType', - addedFieldName: 'b', - typeType: 'object type', - addedFieldReturnType: 'Int!', - }, - path: 'DType.b', - }, - { - criticality: { level: 'BREAKING' }, - type: 'TYPE_REMOVED', - message: "Type 'WillBeRemoved' was removed", - meta: { removedTypeName: 'WillBeRemoved' }, - path: 'WillBeRemoved', - }, - { - type: 'INPUT_FIELD_ADDED', - criticality: { - level: 'BREAKING', - reason: - 'Adding a required input field to an existing input object type is a breaking change because it will cause existing uses of this input object type to error.', - }, - message: "Input field 'c' of type 'String!' was added to input object type 'AInput'", - meta: { - inputName: 'AInput', - addedInputFieldName: 'c', - isAddedInputFieldTypeNullable: false, - addedInputFieldType: 'String!', - addedToNewType: false, - }, - path: 'AInput.c', - }, - { - type: 'INPUT_FIELD_REMOVED', - criticality: { - level: 'BREAKING', - reason: - 'Removing an input field will cause existing queries that use this input field to error.', - }, - message: "Input field 'b' was removed from input object type 'AInput'", - meta: { inputName: 'AInput', removedFieldName: 'b', isInputFieldDeprecated: false }, - path: 'AInput.b', - }, - { - type: 'INPUT_FIELD_DESCRIPTION_CHANGED', - criticality: { level: 'NON_BREAKING' }, - message: "Input field 'AInput.a' description changed from 'a' to 'changed'", - meta: { - inputName: 'AInput', - inputFieldName: 'a', - oldInputFieldDescription: 'a', - newInputFieldDescription: 'changed', - }, - path: 'AInput.a', - }, - { - type: 'INPUT_FIELD_DEFAULT_VALUE_CHANGED', - criticality: { - level: 'DANGEROUS', - reason: - 'Changing the default value for an argument may change the runtime behavior of a field if it was never provided.', - }, - message: "Input field 'AInput.a' default value changed from '\"1\"' to '1'", - meta: { - inputName: 'AInput', - inputFieldName: 'a', - oldDefaultValue: '"1"', - newDefaultValue: '1', - }, - path: 'AInput.a', - }, - { - type: 'INPUT_FIELD_TYPE_CHANGED', - criticality: { - level: 'BREAKING', - reason: - 'Changing the type of an input field can cause existing queries that use this field to error.', - }, - message: "Input field 'AInput.a' changed type from 'String' to 'Int'", - meta: { - inputName: 'AInput', - inputFieldName: 'a', - oldInputFieldType: 'String', - newInputFieldType: 'Int', - isInputFieldTypeChangeSafe: false, - }, - path: 'AInput.a', - }, - { - type: 'INPUT_FIELD_DEFAULT_VALUE_CHANGED', - criticality: { - level: 'DANGEROUS', - reason: - 'Changing the default value for an argument may change the runtime behavior of a field if it was never provided.', - }, - message: "Input field 'ListInput.a' default value changed from '[ 'foo' ]' to '[ 'bar' ]'", - meta: { - inputName: 'ListInput', - inputFieldName: 'a', - oldDefaultValue: "[ 'foo' ]", - newDefaultValue: "[ 'bar' ]", - }, - path: 'ListInput.a', - }, - { - type: 'FIELD_DESCRIPTION_CHANGED', - criticality: { level: 'NON_BREAKING' }, - message: - "Field 'Query.a' description changed from 'Just a simple string' to 'This description has been changed'", - meta: { - fieldName: 'a', - typeName: 'Query', - oldDescription: 'Just a simple string', - newDescription: 'This description has been changed', - }, - path: 'Query.a', - }, - { - type: 'FIELD_ARGUMENT_REMOVED', - criticality: { level: 'BREAKING' }, - message: "Argument 'anArg: String' was removed from field 'Query.a'", - meta: { - typeName: 'Query', - fieldName: 'a', - removedFieldArgumentName: 'anArg', - removedFieldType: 'String', - }, - path: 'Query.a.anArg', - }, - { - type: 'FIELD_TYPE_CHANGED', - criticality: { level: 'BREAKING' }, - message: "Field 'Query.b' changed type from 'BType' to 'Int!'", - meta: { - typeName: 'Query', - fieldName: 'b', - oldFieldType: 'BType', - newFieldType: 'Int!', - isSafeFieldTypeChange: false, - }, - path: 'Query.b', - }, - { - criticality: { level: 'NON_BREAKING' }, - type: 'TYPE_DESCRIPTION_CHANGED', - message: - "Description 'The Query Root of this schema' on type 'Query' has changed to 'Query Root description changed'", - path: 'Query', - meta: { - typeName: 'Query', - newTypeDescription: 'Query Root description changed', - oldTypeDescription: 'The Query Root of this schema', - }, - }, - { - criticality: { - level: 'BREAKING', - reason: - 'Changing the kind of a type is a breaking change because it can cause existing queries to error. For example, turning an object type to a scalar type would break queries that define a selection set for this type.', - }, - type: 'TYPE_KIND_CHANGED', - message: "'BType' kind changed from 'ObjectTypeDefinition' to 'InputObjectTypeDefinition'", - path: 'BType', - meta: { - typeName: 'BType', - newTypeKind: 'InputObjectTypeDefinition', - oldTypeKind: 'ObjectTypeDefinition', - }, - }, - { - type: 'OBJECT_TYPE_INTERFACE_ADDED', - criticality: { - level: 'DANGEROUS', - reason: - 'Adding an interface to an object type may break existing clients that were not programming defensively against a new possible type.', - }, - message: "'CType' object implements 'AnInterface' interface", - meta: { objectTypeName: 'CType', addedInterfaceName: 'AnInterface', addedToNewType: false }, - path: 'CType', - }, - { - type: 'FIELD_ADDED', - criticality: { level: 'NON_BREAKING' }, - message: "Field 'b' was added to object type 'CType'", - meta: { - typeName: 'CType', - addedFieldName: 'b', - typeType: 'object type', - addedFieldReturnType: 'Int!', - }, - path: 'CType.b', - }, - { - type: 'FIELD_REMOVED', - criticality: { - level: 'BREAKING', - reason: - 'Removing a field is a breaking change. It is preferable to deprecate the field before removing it. This applies to removed union fields as well, since removal breaks client operations that contain fragments that reference the removed type through direct (... on RemovedType) or indirect means such as __typename in the consumers.', - }, - message: "Field 'c' was removed from object type 'CType'", - meta: { - typeName: 'CType', - removedFieldName: 'c', - isRemovedFieldDeprecated: false, - typeType: 'object type', - }, - path: 'CType.c', - }, - { - type: 'FIELD_DEPRECATION_REASON_CHANGED', - criticality: { level: 'NON_BREAKING' }, - message: "Deprecation reason on field 'CType.a' has changed from 'whynot' to 'cuz'", - meta: { - fieldName: 'a', - typeName: 'CType', - newDeprecationReason: 'cuz', - oldDeprecationReason: 'whynot', - }, - path: 'CType.a', - }, - { - type: 'FIELD_ARGUMENT_ADDED', - criticality: { level: 'DANGEROUS' }, - message: "Argument 'arg: Int' added to field 'CType.a'", - meta: { - typeName: 'CType', - fieldName: 'a', - addedArgumentName: 'arg', - addedArgumentType: 'Int', - hasDefaultValue: false, - addedToNewField: false, - isAddedFieldArgumentBreaking: false, - }, - path: 'CType.a.arg', - }, - { - type: 'FIELD_ARGUMENT_DEFAULT_CHANGED', - criticality: { - level: 'DANGEROUS', - reason: - 'Changing the default value for an argument may change the runtime behaviour of a field if it was never provided.', - }, - message: "Default value '10' was added to argument 'arg' on field 'CType.d'", - meta: { typeName: 'CType', fieldName: 'd', argumentName: 'arg', newDefaultValue: '10' }, - path: 'CType.d.arg', - }, - { - criticality: { - level: 'DANGEROUS', - reason: - 'Adding a possible type to Unions may break existing clients that were not programming defensively against a new possible type.', - }, - type: 'UNION_MEMBER_ADDED', - message: "Member 'DType' was added to Union type 'MyUnion'", - meta: { unionName: 'MyUnion', addedUnionMemberTypeName: 'DType', addedToNewType: false }, - path: 'MyUnion', - }, - { - criticality: { - level: 'BREAKING', - reason: - 'Removing a union member from a union can cause existing queries that use this union member in a fragment spread to error.', - }, - type: 'UNION_MEMBER_REMOVED', - message: "Member 'BType' was removed from Union type 'MyUnion'", - meta: { unionName: 'MyUnion', removedUnionMemberTypeName: 'BType' }, - path: 'MyUnion', - }, - { - type: 'FIELD_ADDED', - criticality: { level: 'NON_BREAKING' }, - message: "Field 'b' was added to interface 'AnotherInterface'", - meta: { - typeName: 'AnotherInterface', - addedFieldName: 'b', - typeType: 'interface', - addedFieldReturnType: 'Int', - }, - path: 'AnotherInterface.b', - }, - { - type: 'FIELD_REMOVED', - criticality: { - level: 'BREAKING', - reason: - 'Removing a field is a breaking change. It is preferable to deprecate the field before removing it. This applies to removed union fields as well, since removal breaks client operations that contain fragments that reference the removed type through direct (... on RemovedType) or indirect means such as __typename in the consumers.', - }, - message: "Field 'anotherInterfaceField' was removed from interface 'AnotherInterface'", - meta: { - typeName: 'AnotherInterface', - removedFieldName: 'anotherInterfaceField', - isRemovedFieldDeprecated: false, - typeType: 'interface', - }, - path: 'AnotherInterface.anotherInterfaceField', - }, - { - type: 'OBJECT_TYPE_INTERFACE_REMOVED', - criticality: { - level: 'BREAKING', - reason: - 'Removing an interface from an object type can cause existing queries that use this in a fragment spread to error.', - }, - message: "'WithInterfaces' object type no longer implements 'AnotherInterface' interface", - meta: { objectTypeName: 'WithInterfaces', removedInterfaceName: 'AnotherInterface' }, - path: 'WithInterfaces', - }, - { - type: 'FIELD_ARGUMENT_DESCRIPTION_CHANGED', - criticality: { level: 'NON_BREAKING' }, - message: - "Description for argument 'a' on field 'WithArguments.a' changed from 'Meh' to 'Description for a'", - meta: { - typeName: 'WithArguments', - fieldName: 'a', - argumentName: 'a', - oldDescription: 'Meh', - newDescription: 'Description for a', - }, - path: 'WithArguments.a.a', - }, - { - type: 'FIELD_ARGUMENT_TYPE_CHANGED', - criticality: { - level: 'BREAKING', - reason: - "Changing the type of a field's argument can cause existing queries that use this argument to error.", - }, - message: "Type for argument 'b' on field 'WithArguments.a' changed from 'String' to 'String!'", - meta: { - typeName: 'WithArguments', - fieldName: 'a', - argumentName: 'b', - oldArgumentType: 'String', - newArgumentType: 'String!', - isSafeArgumentTypeChange: false, - }, - path: 'WithArguments.a.b', - }, - { - type: 'FIELD_ARGUMENT_DEFAULT_CHANGED', - criticality: { - level: 'DANGEROUS', - reason: - 'Changing the default value for an argument may change the runtime behaviour of a field if it was never provided.', - }, - message: "Default value for argument 'arg' on field 'WithArguments.b' changed from '1' to '2'", - meta: { - typeName: 'WithArguments', - fieldName: 'b', - argumentName: 'arg', - oldDefaultValue: '1', - newDefaultValue: '2', - }, - path: 'WithArguments.b.arg', - }, - { - type: 'ENUM_VALUE_ADDED', - criticality: { - level: 'DANGEROUS', - reason: - 'Adding an enum value may break existing clients that were not programming defensively against an added case when querying an enum.', - }, - message: "Enum value 'D' was added to enum 'Options'", - meta: { - enumName: 'Options', - addedEnumValueName: 'D', - addedToNewType: false, - addedDirectiveDescription: null, - }, - path: 'Options.D', - }, - { - type: 'ENUM_VALUE_REMOVED', - criticality: { - level: 'BREAKING', - reason: - 'Removing an enum value will cause existing queries that use this enum value to error.', - }, - message: "Enum value 'C' was removed from enum 'Options'", - meta: { enumName: 'Options', removedEnumValueName: 'C', isEnumValueDeprecated: false }, - path: 'Options.C', - }, - { - criticality: { level: 'NON_BREAKING' }, - type: 'ENUM_VALUE_DESCRIPTION_CHANGED', - message: "Description 'Stuff' was added to enum value 'Options.A'", - path: 'Options.A', - meta: { - enumName: 'Options', - enumValueName: 'A', - oldEnumValueDescription: null, - newEnumValueDescription: 'Stuff', - }, - }, - { - criticality: { level: 'NON_BREAKING' }, - type: 'ENUM_VALUE_DEPRECATION_REASON_ADDED', - message: "Enum value 'Options.E' was deprecated with reason 'No longer supported'", - path: 'Options.E.@deprecated', - meta: { - enumName: 'Options', - enumValueName: 'E', - addedValueDeprecationReason: 'No longer supported', - }, - }, - { - criticality: { - level: 'NON_BREAKING', - reason: "Directive 'deprecated' was added to enum value 'Options.E'", - }, - type: 'DIRECTIVE_USAGE_ENUM_VALUE_ADDED', - message: "Directive 'deprecated' was added to enum value 'Options.E'", - path: 'Options.E.@deprecated', - meta: { - enumName: 'Options', - enumValueName: 'E', - addedDirectiveName: 'deprecated', - addedToNewType: false, - }, - }, - { - criticality: { level: 'NON_BREAKING' }, - type: 'ENUM_VALUE_DEPRECATION_REASON_CHANGED', - message: "Enum value 'Options.F' deprecation reason changed from 'Old' to 'New'", - path: 'Options.F.@deprecated', - meta: { - enumName: 'Options', - enumValueName: 'F', - oldEnumValueDeprecationReason: 'Old', - newEnumValueDeprecationReason: 'New', - }, - }, - { - criticality: { level: 'NON_BREAKING' }, - type: 'DIRECTIVE_ADDED', - message: "Directive 'yolo2' was added", - path: '@yolo2', - meta: { - addedDirectiveName: 'yolo2', - addedDirectiveDescription: null, - addedDirectiveLocations: ['FIELD'], - addedDirectiveRepeatable: false, - }, - }, - { - criticality: { level: 'NON_BREAKING' }, - type: 'DIRECTIVE_LOCATION_ADDED', - message: "Location 'FIELD' was added to directive 'yolo2'", - path: '@yolo2', - meta: { directiveName: 'yolo2', addedDirectiveLocation: 'FIELD' }, - }, - { - criticality: { - level: 'NON_BREAKING', - reason: - 'Refer to the directive usage for the breaking status. If the directive is new and therefore unused, then adding an argument does not risk breaking clients.', - }, - type: 'DIRECTIVE_ARGUMENT_ADDED', - message: "Argument 'someArg' was added to directive 'yolo2'", - path: '@yolo2', - meta: { - directiveName: 'yolo2', - addedDirectiveArgumentName: 'someArg', - addedDirectiveArgumentType: 'String!', - addedDirectiveDefaultValue: '', - addedDirectiveArgumentTypeIsNonNull: true, - addedDirectiveArgumentDescription: 'Included when true.', - addedToNewDirective: true, - }, - }, - { - criticality: { - level: 'BREAKING', - reason: - 'A directive could be in use of a client application. Removing it could break the client application.', - }, - type: 'DIRECTIVE_REMOVED', - message: "Directive 'willBeRemoved' was removed", - path: '@willBeRemoved', - meta: { removedDirectiveName: 'willBeRemoved' }, - }, - { - criticality: { level: 'NON_BREAKING' }, - type: 'DIRECTIVE_DESCRIPTION_CHANGED', - message: "Directive 'yolo' description changed from 'Old' to 'New'", - path: '@yolo', - meta: { directiveName: 'yolo', oldDirectiveDescription: 'Old', newDirectiveDescription: 'New' }, - }, - { - criticality: { level: 'NON_BREAKING' }, - type: 'DIRECTIVE_LOCATION_ADDED', - message: "Location 'FIELD_DEFINITION' was added to directive 'yolo'", - path: '@yolo', - meta: { directiveName: 'yolo', addedDirectiveLocation: 'FIELD_DEFINITION' }, - }, - { - criticality: { - level: 'BREAKING', - reason: - 'A directive could be in use of a client application. Removing it could break the client application.', - }, - type: 'DIRECTIVE_LOCATION_REMOVED', - message: "Location 'FRAGMENT_SPREAD' was removed from directive 'yolo'", - path: '@yolo', - meta: { directiveName: 'yolo', removedDirectiveLocation: 'FRAGMENT_SPREAD' }, - }, - { - criticality: { - level: 'BREAKING', - reason: - 'A directive could be in use of a client application. Removing it could break the client application.', - }, - type: 'DIRECTIVE_LOCATION_REMOVED', - message: "Location 'INLINE_FRAGMENT' was removed from directive 'yolo'", - path: '@yolo', - meta: { directiveName: 'yolo', removedDirectiveLocation: 'INLINE_FRAGMENT' }, - }, - { - criticality: { - level: 'BREAKING', - reason: - 'A directive argument could be in use of a client application. Removing the argument can break client applications.', - }, - type: 'DIRECTIVE_ARGUMENT_REMOVED', - message: "Argument 'willBeRemoved' was removed from directive 'yolo'", - path: '@yolo.willBeRemoved', - meta: { directiveName: 'yolo', removedDirectiveArgumentName: 'willBeRemoved' }, - }, - { - criticality: { level: 'NON_BREAKING' }, - type: 'DIRECTIVE_ARGUMENT_DESCRIPTION_CHANGED', - message: - "Description for argument 'someArg' on directive 'yolo' changed from 'Included when true.' to 'someArg does stuff'", - path: '@yolo.someArg', - meta: { - directiveName: 'yolo', - directiveArgumentName: 'someArg', - oldDirectiveArgumentDescription: 'Included when true.', - newDirectiveArgumentDescription: 'someArg does stuff', - }, - }, - { - criticality: { level: 'BREAKING' }, - type: 'DIRECTIVE_ARGUMENT_TYPE_CHANGED', - message: "Type for argument 'someArg' on directive 'yolo' changed from 'Boolean!' to 'String!'", - path: '@yolo.someArg', - meta: { - directiveName: 'yolo', - directiveArgumentName: 'someArg', - oldDirectiveArgumentType: 'Boolean!', - newDirectiveArgumentType: 'String!', - isSafeDirectiveArgumentTypeChange: false, - }, - }, - { - criticality: { - level: 'DANGEROUS', - reason: - 'Changing the default value for an argument may change the runtime behaviour of a field if it was never provided.', - }, - type: 'DIRECTIVE_ARGUMENT_DEFAULT_VALUE_CHANGED', - message: "Default value '\"Test\"' was added to argument 'anotherArg' on directive 'yolo'", - path: '@yolo.anotherArg', - meta: { - directiveName: 'yolo', - directiveArgumentName: 'anotherArg', - newDirectiveArgumentDefaultValue: '"Test"', - }, - }, -]; - -function toPascalCase(str: string) { - // Handle empty or non-string inputs - if (typeof str !== 'string' || str.length === 0) { - return ''; - } - - // Split the string by common delimiters (spaces, hyphens, underscores) - const words = str.split(/[\s\-_]+/); - - // Capitalize the first letter of each word and convert the rest to lowercase - const pascalCasedWords = words.map(word => { - if (word.length === 0) { - return ''; - } - return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(); - }); - - // Join the words together - return pascalCasedWords.join(''); -} +import { SchemaProposalManager } from '../../providers/schema-proposal-manager'; +import type { QueryResolvers } from './../../../../__generated__/types'; export const schemaProposal: NonNullable = ( _parent, { input: { id } }, - _ctx, + { injector }, ) => { - /* Implement Query.schemaProposal resolver logic here */ - return { - createdAt: Date.now(), - id, - stage: 'OPEN', - updatedAt: Date.now(), - commentsCount: 5, - title: 'Add some stuff to the thing', - description: - 'This makes a bunch of changes. Here is a description of all the stuff and things and whatsits.', - versions: { - pageInfo: { - startCursor: 'start', - endCursor: 'end', - hasNextPage: false, - hasPreviousPage: false, - }, - edges: [ - { - cursor: '12345', - node: { - id: '12345', - serviceName: 'panda', - changes: changes.map(c => { - return { - path: c.path, - isSafeBasedOnUsage: false, // @todo - message: c.message, - criticality: c.criticality.level as CriticalityLevel, - criticalityReason: c.criticality.reason, - severityLevel: c.criticality.level as SeverityLevelType, - severityReason: c.criticality.reason, - meta: { - __typename: toPascalCase(c.type), - ...(c.meta as any), - }, - }; - }), - createdAt: Date.now(), - schemaProposal: { - /* ??? */ - } as any, - }, - }, - ], - }, - user: { - id: 'asdffff', - displayName: 'jdolle', - fullName: 'Jeff Dolle', - email: 'jdolle+test@the-guild.dev', - } as any, - reviews: { - edges: [ - { - cursor: 'asdf', - node: { - id: '1', - schemaProposalVersion: { - id: 'asdf', - serviceName: 'panda', - }, - comments: { - pageInfo: { - endCursor: crypto.randomUUID(), - startCursor: crypto.randomUUID(), - hasNextPage: false, - hasPreviousPage: false, - }, - edges: [ - { - cursor: crypto.randomUUID(), - node: { - id: crypto.randomUUID(), - createdAt: Date.now(), - body: 'This is a comment. The first comment.', - updatedAt: Date.now(), - }, - }, - ], - }, - createdAt: Date.now(), - lineText: 'type User {', - lineNumber: 2, - stageTransition: 'OPEN', - schemaCoordinate: 'DType', - }, - }, - { - cursor: 'asdf', - node: { - id: '2', - schemaProposalVersion: { - id: 'asdf', - serviceName: 'panda', - }, - comments: { - pageInfo: { - endCursor: crypto.randomUUID(), - startCursor: crypto.randomUUID(), - hasNextPage: false, - hasPreviousPage: false, - }, - edges: [ - { - cursor: crypto.randomUUID(), - node: { - id: crypto.randomUUID(), - createdAt: Date.now(), - body: 'This is a comment. The first comment.', - updatedAt: Date.now(), - }, - }, - ], - }, - createdAt: Date.now(), - lineText: 'foo: Boolean', - lineNumber: 3, - schemaCoordinate: 'UnknownType.foo', - }, - }, - ], - pageInfo: { - startCursor: 'asdf', - endCursor: 'wxyz', - hasNextPage: false, - hasPreviousPage: false, - }, - }, - }; + return injector.get(SchemaProposalManager).getProposal({ proposalId: id }); }; diff --git a/packages/services/api/src/modules/proposals/resolvers/Query/schemaProposalReview.ts b/packages/services/api/src/modules/proposals/resolvers/Query/schemaProposalReview.ts deleted file mode 100644 index af1cc84e47..0000000000 --- a/packages/services/api/src/modules/proposals/resolvers/Query/schemaProposalReview.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { QueryResolvers } from './../../../../__generated__/types'; - -export const schemaProposalReview: NonNullable = async ( - _parent, - { input: { id } }, - _ctx, -) => { - /* Implement Query.schemaProposalReview resolver logic here */ - return { - createdAt: Date.now(), - id, - schemaProposal: { - id: crypto.randomUUID(), - createdAt: Date.now(), - commentsCount: 3, - stage: 'OPEN', - updatedAt: Date.now(), - comments: { - edges: [ - { - cursor: crypto.randomUUID(), - node: { - id: crypto.randomUUID(), - body: 'This is a comment. The first comment.', - updatedAt: Date.now(), - }, - }, - ], - }, - }, - }; -}; diff --git a/packages/services/api/src/modules/proposals/resolvers/Query/schemaProposalReviews.ts b/packages/services/api/src/modules/proposals/resolvers/Query/schemaProposalReviews.ts deleted file mode 100644 index f1b716fe0b..0000000000 --- a/packages/services/api/src/modules/proposals/resolvers/Query/schemaProposalReviews.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type { QueryResolvers } from './../../../../__generated__/types'; - -export const schemaProposalReviews: NonNullable = async ( - _parent, - _arg, - _ctx, -) => { - /* Implement Query.schemaProposalReviews resolver logic here */ - return { - edges: [ - { - cursor: '1234', - node: { - id: crypto.randomUUID(), - createdAt: Date.now(), - lineNumber: 3, - schemaCoordinate: 'User', - lineText: 'type User {', - comments: { - pageInfo: { - endCursor: '', - hasNextPage: false, - hasPreviousPage: false, - startCursor: '', - }, - edges: [ - { - cursor: crypto.randomUUID(), - node: { - createdAt: Date.now(), - id: crypto.randomUUID(), - body: 'This is a comment. The first comment.', - updatedAt: Date.now(), - }, - }, - ], - }, - }, - }, - ], - pageInfo: { - endCursor: '', - hasNextPage: false, - hasPreviousPage: false, - startCursor: '', - }, - }; -}; diff --git a/packages/services/api/src/modules/proposals/resolvers/Query/schemaProposals.ts b/packages/services/api/src/modules/proposals/resolvers/Query/schemaProposals.ts index 256bef961b..4ded25b97e 100644 --- a/packages/services/api/src/modules/proposals/resolvers/Query/schemaProposals.ts +++ b/packages/services/api/src/modules/proposals/resolvers/Query/schemaProposals.ts @@ -1,52 +1,17 @@ +import { SchemaProposalManager } from '../../providers/schema-proposal-manager'; import type { QueryResolvers } from './../../../../__generated__/types'; export const schemaProposals: NonNullable = async ( - _parent, - _arg, - _ctx, + _, + args, + { injector }, ) => { - /* Implement Query.schemaProposals resolver logic here */ - const edges = Array.from({ length: 10 }).map(() => ({ - cursor: crypto.randomUUID(), - node: { - id: crypto.randomUUID(), - createdAt: Date.now(), - stage: 'DRAFT' as const, - updatedAt: Date.now(), - title: 'Add user types to registration service.', - user: { - displayName: 'jdolle', - fullName: 'Jeff Dolle', - id: crypto.randomUUID(), - } as any, - commentsCount: 7, - }, - })); - - return { - edges: edges.map((e: any, i) => { - if (i == 2) { - return { - ...e, - node: { - ...e.node, - title: - "Does some other things as well as this has a long time that should be truncated. So let's see what happens", - stage: 'OPEN' as const, - commentsCount: 3, - user: { - ...e.node.user, - }, - }, - }; - } - return e; - }), - pageInfo: { - endCursor: '', - hasNextPage: false, - hasPreviousPage: false, - startCursor: '', - }, - } as any; + return injector.get(SchemaProposalManager).getPaginatedProposals({ + target: args.input.target, + first: args.first, + after: args.after ?? '', + stages: (args.input?.stages as any[]) ?? [], + users: [], // @todo since switching to "author"... this gets messier + // users: args.input?.userIds ?? [], + }); }; diff --git a/packages/services/api/src/modules/proposals/resolvers/SchemaChange.ts b/packages/services/api/src/modules/proposals/resolvers/SchemaChange.ts index 71f5c61d35..ff5d244756 100644 --- a/packages/services/api/src/modules/proposals/resolvers/SchemaChange.ts +++ b/packages/services/api/src/modules/proposals/resolvers/SchemaChange.ts @@ -1,18 +1,8 @@ import type { SchemaChangeResolvers } from './../../../__generated__/types'; -/* - * Note: This object type is generated because "SchemaChangeMapper" is declared. This is to ensure runtime safety. - * - * When a mapper is used, it is possible to hit runtime errors in some scenarios: - * - given a field name, the schema type's field type does not match mapper's field type - * - or a schema type's field does not exist in the mapper's fields - * - * If you want to skip this file generation, remove the mapper or update the pattern in the `resolverGeneration.object` config. - */ export const SchemaChange: Pick = { - /* Implement SchemaChange resolver logic here */ meta: ({ meta }, _arg, _ctx) => { - /* SchemaChange.meta resolver is required because SchemaChange.meta and SchemaChangeMapper.meta are not compatible */ - return meta; + // @todo consider validating + return meta as any; }, }; diff --git a/packages/services/api/src/modules/proposals/resolvers/SchemaProposal.ts b/packages/services/api/src/modules/proposals/resolvers/SchemaProposal.ts new file mode 100644 index 0000000000..c88b2f0460 --- /dev/null +++ b/packages/services/api/src/modules/proposals/resolvers/SchemaProposal.ts @@ -0,0 +1,23 @@ +import { SchemaProposalManager } from '../providers/schema-proposal-manager'; +import type { SchemaProposalResolvers } from './../../../__generated__/types'; + +// @todo +export const SchemaProposal: SchemaProposalResolvers = { + async rebasedSchemaSDL(proposal, args, { injector }) { + return []; + }, + async checks(proposal, args, { injector }) { + return proposal.checks ?? null; + }, + async rebasedSupergraphSDL(proposal, args, { injector }) { + return ''; + }, + async reviews(proposal, args, { injector }) { + injector.get(SchemaProposalManager).getPaginatedReviews({ + proposalId: proposal.id, + after: args.after ?? '', + first: args.first, + }); + return proposal.reviews ?? null; + }, +}; diff --git a/packages/services/api/src/modules/proposals/resolvers/SchemaVersion.ts b/packages/services/api/src/modules/proposals/resolvers/SchemaVersion.ts deleted file mode 100644 index 3e26ca5881..0000000000 --- a/packages/services/api/src/modules/proposals/resolvers/SchemaVersion.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { SchemaVersionResolvers } from './../../../__generated__/types'; - -/* - * Note: This object type is generated because "SchemaVersionMapper" is declared. This is to ensure runtime safety. - * - * When a mapper is used, it is possible to hit runtime errors in some scenarios: - * - given a field name, the schema type's field type does not match mapper's field type - * - or a schema type's field does not exist in the mapper's fields - * - * If you want to skip this file generation, remove the mapper or update the pattern in the `resolverGeneration.object` config. - */ -export const SchemaVersion: Pick = { - /* Implement SchemaVersion resolver logic here */ -}; diff --git a/packages/services/api/src/modules/proposals/resolvers/Target.ts b/packages/services/api/src/modules/proposals/resolvers/Target.ts deleted file mode 100644 index c1fb215e2c..0000000000 --- a/packages/services/api/src/modules/proposals/resolvers/Target.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { TargetResolvers } from './../../../__generated__/types'; - -/* - * Note: This object type is generated because "TargetMapper" is declared. This is to ensure runtime safety. - * - * When a mapper is used, it is possible to hit runtime errors in some scenarios: - * - given a field name, the schema type's field type does not match mapper's field type - * - or a schema type's field does not exist in the mapper's fields - * - * If you want to skip this file generation, remove the mapper or update the pattern in the `resolverGeneration.object` config. - */ -export const Target: Pick = { - /* Implement Target resolver logic here */ -}; diff --git a/packages/services/api/src/modules/schema/index.ts b/packages/services/api/src/modules/schema/index.ts index 88bc377e88..60f4f1c491 100644 --- a/packages/services/api/src/modules/schema/index.ts +++ b/packages/services/api/src/modules/schema/index.ts @@ -1,4 +1,5 @@ import { createModule } from 'graphql-modules'; +import { SchemaProposalStorage } from '../proposals/providers/schema-proposal-storage'; import { BreakingSchemaChangeUsageHelper } from './providers/breaking-schema-changes-helper'; import { Contracts } from './providers/contracts'; import { ContractsManager } from './providers/contracts-manager'; @@ -32,5 +33,6 @@ export const schemaModule = createModule({ BreakingSchemaChangeUsageHelper, CompositionOrchestrator, ...models, + SchemaProposalStorage, ], }); 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 4eb6982da3..4b15f1ff7c 100644 --- a/packages/services/api/src/modules/schema/providers/schema-publisher.ts +++ b/packages/services/api/src/modules/schema/providers/schema-publisher.ts @@ -27,6 +27,7 @@ import { type GitHubCheckRun, } from '../../integrations/providers/github-integration-manager'; import { OperationsReader } from '../../operations/providers/operations-reader'; +import { SchemaProposalStorage } from '../../proposals/providers/schema-proposal-storage'; import { DistributedCache } from '../../shared/providers/distributed-cache'; import { IdTranslator } from '../../shared/providers/id-translator'; import { Logger } from '../../shared/providers/logger'; @@ -87,7 +88,9 @@ const schemaDeleteCount = new promClient.Counter({ labelNames: ['model', 'projectType'], }); -export type CheckInput = Types.SchemaCheckInput; +export type CheckInput = Types.SchemaCheckInput & { + schemaProposalId?: string; +}; export type DeleteInput = Types.SchemaDeleteInput; @@ -142,6 +145,7 @@ export class SchemaPublisher { private schemaManager: SchemaManager, private targetManager: TargetManager, private alertsManager: AlertsManager, + private schemaProposals: SchemaProposalStorage, private gitHubIntegrationManager: GitHubIntegrationManager, private distributedCache: DistributedCache, private artifactStorageWriter: ArtifactStorageWriter, @@ -313,7 +317,7 @@ export class SchemaPublisher { }, }); - const [target, project, organization, latestVersion, latestComposableVersion] = + const [target, project, organization, latestVersion, latestComposableVersion, schemaProposal] = await Promise.all([ this.storage.getTarget({ organizationId: selector.organizationId, @@ -338,8 +342,27 @@ export class SchemaPublisher { targetId: selector.targetId, onlyComposable: true, }), + input.schemaProposalId && + this.schemaProposals.getProposal({ + id: input.schemaProposalId, + }), ]); + if (input.schemaProposalId && schemaProposal?.targetId !== selector.targetId) { + return { + __typename: 'SchemaCheckError', + valid: false, + changes: [], + warnings: [], + errors: [ + { + message: + 'Invalid schema proposal reference. No proposal found with that ID for the target.', + }, + ], + } as const; + } + if (input.service) { let serviceExists = false; if (latestVersion?.schemas) { diff --git a/packages/services/api/src/modules/schema/resolvers/SchemaVersion.ts b/packages/services/api/src/modules/schema/resolvers/SchemaVersion.ts index a20166c9bf..69f12e184a 100644 --- a/packages/services/api/src/modules/schema/resolvers/SchemaVersion.ts +++ b/packages/services/api/src/modules/schema/resolvers/SchemaVersion.ts @@ -9,32 +9,7 @@ import { SchemaManager } from '../providers/schema-manager'; import { SchemaVersionHelper } from '../providers/schema-version-helper'; import type { SchemaVersionResolvers } from './../../../__generated__/types'; -export const SchemaVersion: Pick< - SchemaVersionResolvers, - | 'baseSchema' - | 'breakingSchemaChanges' - | 'contractVersions' - | 'date' - | 'deprecatedSchema' - | 'explorer' - | 'githubMetadata' - | 'hasSchemaChanges' - | 'isComposable' - | 'isFirstComposableVersion' - | 'isValid' - | 'log' - | 'previousDiffableSchemaVersion' - | 'safeSchemaChanges' - | 'schemaChanges' - | 'schemaCompositionErrors' - | 'schemas' - | 'sdl' - | 'supergraph' - | 'tags' - | 'unusedSchema' - | 'valid' - | '__isTypeOf' -> = { +export const SchemaVersion: SchemaVersionResolvers = { isComposable: version => { return version.schemaCompositionErrors === null; }, diff --git a/packages/services/api/src/modules/target/resolvers/Target.ts b/packages/services/api/src/modules/target/resolvers/Target.ts index a9ad64474a..8436523476 100644 --- a/packages/services/api/src/modules/target/resolvers/Target.ts +++ b/packages/services/api/src/modules/target/resolvers/Target.ts @@ -10,6 +10,7 @@ export const Target: Pick< | 'experimental_forcedLegacySchemaComposition' | 'failDiffOnDangerousChange' | 'graphqlEndpointUrl' + | 'id' | 'name' | 'project' | 'slug' diff --git a/packages/services/server/.env.template b/packages/services/server/.env.template index 2f724735e9..adaa5f5286 100644 --- a/packages/services/server/.env.template +++ b/packages/services/server/.env.template @@ -24,6 +24,7 @@ INTEGRATION_GITHUB="" INTEGRATION_GITHUB_APP_ID="" INTEGRATION_GITHUB_APP_PRIVATE_KEY="" FEATURE_FLAGS_APP_DEPLOYMENTS_ENABLED="0" +FEATURE_FLAGS_SCHEMA_PROPOSALS_ENABLED="0" # Zendesk Support ZENDESK_SUPPORT='0' diff --git a/packages/services/server/README.md b/packages/services/server/README.md index 10f8fc5981..ccff442b49 100644 --- a/packages/services/server/README.md +++ b/packages/services/server/README.md @@ -64,6 +64,7 @@ The GraphQL API for GraphQL Hive. | `INTEGRATION_GITHUB_GITHUB_APP_ID` | No (Yes if `INTEGRATION_GITHUB` is set to `1`) | The GitHub app id. | `123` | | `INTEGRATION_GITHUB_GITHUB_APP_PRIVATE_KEY` | No (Yes if `INTEGRATION_GITHUB` is set to `1`) | The GitHub app private key. | `letmein1` | | `FEATURE_FLAGS_APP_DEPLOYMENTS_ENABLED` | No | Whether app deployments should be enabled for every organization. | `1` (enabled) or `0` (disabled) | +| `FEATURE_FLAGS_SCHEMA_PROPOSALS_ENABLED` | No | Whether schema proposals should be enabled for every organization. | `1` (enabled) or `0` (disabled) | | `S3_AUDIT_LOG` | No (audit log uses default S3 if not configured) | Whether audit logs should be stored on another S3 bucket than the artifacts. | `1` (enabled) or `0` (disabled) | | `S3_AUDIT_LOG_ENDPOINT` | **Yes** (if `S3_AUDIT_LOG` is `1`) | The S3 endpoint. | `http://localhost:9000` | | `S3_AUDIT_LOG_ACCESS_KEY_ID` | **Yes** (if `S3_AUDIT_LOG` is `1`) | The S3 access key id. | `minioadmin` | diff --git a/packages/services/server/src/environment.ts b/packages/services/server/src/environment.ts index 00f74df3b9..43c1b1c6c8 100644 --- a/packages/services/server/src/environment.ts +++ b/packages/services/server/src/environment.ts @@ -42,6 +42,9 @@ const EnvironmentModel = zod.object({ FEATURE_FLAGS_APP_DEPLOYMENTS_ENABLED: emptyString( zod.union([zod.literal('1'), zod.literal('0')]).optional(), ), + FEATURE_FLAGS_SCHEMA_PROPOSALS_ENABLED: emptyString( + zod.union([zod.literal('1'), zod.literal('0')]).optional(), + ), }); const CommerceModel = zod.object({ @@ -518,5 +521,6 @@ export const env = { featureFlags: { /** Whether app deployments should be enabled by default for everyone. */ appDeploymentsEnabled: base.FEATURE_FLAGS_APP_DEPLOYMENTS_ENABLED === '1', + schemaProposalsEnabled: base.FEATURE_FLAGS_SCHEMA_PROPOSALS_ENABLED === '1', }, } as const; diff --git a/packages/services/server/src/index.ts b/packages/services/server/src/index.ts index 51716090ab..84be8f5b23 100644 --- a/packages/services/server/src/index.ts +++ b/packages/services/server/src/index.ts @@ -400,6 +400,7 @@ export async function main() { supportConfig: env.zendeskSupport, pubSub, appDeploymentsEnabled: env.featureFlags.appDeploymentsEnabled, + schemaProposalsEnabled: env.featureFlags.schemaProposalsEnabled, prometheus: env.prometheus, }); diff --git a/packages/services/storage/src/db/types.ts b/packages/services/storage/src/db/types.ts index 1920119761..8da0526ee4 100644 --- a/packages/services/storage/src/db/types.ts +++ b/packages/services/storage/src/db/types.ts @@ -277,6 +277,7 @@ export interface schema_checks { schema_composition_errors: any | null; schema_policy_errors: any | null; schema_policy_warnings: any | null; + schema_proposal_id: string | null; schema_sdl: string | null; schema_sdl_store_id: string | null; schema_version_id: string | null; @@ -332,25 +333,17 @@ export interface schema_proposal_reviews { created_at: Date; id: string; line_text: string | null; - original_line_num: number | null; - original_schema_proposal_version_id: string; + resolved_by_user_id: string | null; + schema_coordinate: string | null; schema_proposal_id: string; stage_transition: schema_proposal_stage; user_id: string | null; } -export interface schema_proposal_versions { - created_at: Date; - id: string; - schema_proposal_id: string; - schema_sdl: string; - service_name: string | null; - user_id: string | null; -} - export interface schema_proposals { + comments_count: number; created_at: Date; - diff_schema_version_id: string; + description: string; id: string; stage: schema_proposal_stage; target_id: string; @@ -494,7 +487,6 @@ export interface DBTables { schema_policy_config: schema_policy_config; schema_proposal_comments: schema_proposal_comments; schema_proposal_reviews: schema_proposal_reviews; - schema_proposal_versions: schema_proposal_versions; schema_proposals: schema_proposals; schema_version_changes: schema_version_changes; schema_version_to_log: schema_version_to_log; diff --git a/packages/web/app/src/components/proposal/Review.tsx b/packages/web/app/src/components/proposal/Review.tsx index 4546502747..ccef69c6c3 100644 --- a/packages/web/app/src/components/proposal/Review.tsx +++ b/packages/web/app/src/components/proposal/Review.tsx @@ -2,8 +2,7 @@ import { Fragment, ReactElement, useContext } from 'react'; import { FragmentType, graphql, useFragment } from '@/gql'; import { cn } from '@/lib/utils'; import { Button } from '../ui/button'; -import { Callout } from '../ui/callout'; -import { Title } from '../ui/page'; +import { CheckIcon, PlusIcon } from '../ui/icon'; import { TimeAgo } from '../v2'; import { AnnotatedContext } from './schema-diff/components'; @@ -43,11 +42,13 @@ export function ReviewComments(props: { })}
{/* @todo check if able to reply */} -
- -
@@ -58,14 +59,10 @@ export function ReviewComments(props: { const ProposalOverview_CommentFragment = graphql(/** GraphQL */ ` fragment ProposalOverview_CommentFragment on SchemaProposalComment { id - user { - id - email - displayName - fullName - } + author body updatedAt + createdAt } `); @@ -77,14 +74,13 @@ export function ReviewComment(props: { return ( <>
-
- {comment.user?.displayName ?? comment.user?.fullName ?? 'Unknown'} -
+
{comment.author ?? 'Unknown'}
- + {!!comment.updatedAt && 'updated '} +
-
{comment.body}
+
{comment.body}
); } @@ -97,12 +93,7 @@ export function DetachedAnnotations(props: { /** Get the list of coordinates that have already been annotated */ const { annotatedCoordinates } = useContext(AnnotatedContext); const detachedReviewCoordinates = props.coordinates.filter(c => annotatedCoordinates?.has(c)); - return detachedReviewCoordinates.length ? ( - - Detached Comments - {detachedReviewCoordinates.map(c => ( - {props.annotate(c, true)} - ))} - - ) : null; + return detachedReviewCoordinates.length + ? detachedReviewCoordinates.map(c => {props.annotate(c, true)}) + : null; } diff --git a/packages/web/app/src/components/proposal/index.tsx b/packages/web/app/src/components/proposal/index.tsx index 117e0dd6b0..47445e588a 100644 --- a/packages/web/app/src/components/proposal/index.tsx +++ b/packages/web/app/src/components/proposal/index.tsx @@ -20,13 +20,10 @@ export const ProposalOverview_ReviewsFragment = graphql(/** GraphQL */ ` cursor node { id - schemaProposalVersion { - id - serviceName - } stageTransition lineText schemaCoordinate + serviceName ...ProposalOverview_ReviewCommentsFragment } } @@ -478,8 +475,7 @@ export function Proposal(props: { const reviewsConnection = useFragment(ProposalOverview_ReviewsFragment, props.reviews); const serviceReviews = reviewsConnection?.edges?.filter(edge => { - const { schemaProposalVersion } = edge.node; - return (schemaProposalVersion?.serviceName ?? '') === props.serviceName; + return edge.node.serviceName === props.serviceName; }) ?? []; const reviewssByCoordinate = serviceReviews.reduce((result, review) => { const coordinate = review.node.schemaCoordinate; @@ -491,6 +487,7 @@ export function Proposal(props: { result.set(review.node.schemaCoordinate!, [review]); } } + // @todo else add to global reviews return result; }, new Map>()); @@ -503,7 +500,7 @@ export function Proposal(props: { {/* @todo if node.resolvedBy/resolvedAt is set, then minimize this */} {withPreview === true && node.lineText && ( - + {node.lineText} )} @@ -537,7 +534,14 @@ export function Proposal(props: { ( + <> +
+ This comment refers to a schema coordinate that no longer exists. +
+ {annotations(coordinate, withPreview)} + + )} /> {diff}
diff --git a/packages/web/app/src/components/proposal/schema-diff/components.tsx b/packages/web/app/src/components/proposal/schema-diff/components.tsx index 008beeff1f..8648b9d53c 100644 --- a/packages/web/app/src/components/proposal/schema-diff/components.tsx +++ b/packages/web/app/src/components/proposal/schema-diff/components.tsx @@ -31,7 +31,6 @@ import { } from 'graphql'; import { isPrintableAsBlockString } from 'graphql/language/blockString'; import { CheckIcon, XIcon } from '@/components/ui/icon'; -import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { SeverityLevelType } from '@/gql/graphql'; import { cn } from '@/lib/utils'; import { ExclamationTriangleIcon } from '@radix-ui/react-icons'; diff --git a/packages/web/app/src/components/target/proposals/change-detail.tsx b/packages/web/app/src/components/target/proposals/change-detail.tsx index f8556abbe3..a8546344cf 100644 --- a/packages/web/app/src/components/target/proposals/change-detail.tsx +++ b/packages/web/app/src/components/target/proposals/change-detail.tsx @@ -19,9 +19,9 @@ export function ProposalChangeDetail(props: { -
- {labelize(props.change.message)} - {props.icon} +
+
{labelize(props.change.message)}
+
{props.icon}
diff --git a/packages/web/app/src/components/target/proposals/version-select.tsx b/packages/web/app/src/components/target/proposals/version-select.tsx index 6478720d9c..4542d90e71 100644 --- a/packages/web/app/src/components/target/proposals/version-select.tsx +++ b/packages/web/app/src/components/target/proposals/version-select.tsx @@ -10,14 +10,13 @@ import { cn } from '@/lib/utils'; import { useRouter, useSearch } from '@tanstack/react-router'; const ProposalQuery_VersionsListFragment = graphql(/* GraphQL */ ` - fragment ProposalQuery_VersionsListFragment on SchemaProposalVersionConnection { + fragment ProposalQuery_VersionsListFragment on SchemaCheckConnection { edges { node { id createdAt - user { - fullName - displayName + meta { + author } } } diff --git a/packages/web/app/src/pages/target-proposal-details.tsx b/packages/web/app/src/pages/target-proposal-details.tsx index 06c4f5817e..35d95c893b 100644 --- a/packages/web/app/src/pages/target-proposal-details.tsx +++ b/packages/web/app/src/pages/target-proposal-details.tsx @@ -1,14 +1,16 @@ -import { Fragment, ReactNode } from 'react'; +import { Fragment, ReactNode, useMemo, useState } from 'react'; import { ProposalOverview_ReviewsFragment } from '@/components/proposal'; import { ProposalChangeDetail } from '@/components/target/proposals/change-detail'; -import { Title } from '@/components/ui/page'; +import { Button } from '@/components/ui/button'; +import { Subtitle, Title } from '@/components/ui/page'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { FragmentType } from '@/gql'; import { Change, CriticalityLevel } from '@graphql-inspector/core'; import { ComponentNoneIcon, CubeIcon, ExclamationTriangleIcon, - LinkBreak2Icon, + InfoCircledIcon, } from '@radix-ui/react-icons'; import type { ServiceProposalDetails } from './target-proposal-types'; @@ -17,6 +19,12 @@ export enum MergeStatus { IGNORED, } +type MappedChange = { + change: Change; + error?: Error; + mergeStatus?: MergeStatus; +}; + export function TargetProposalDetailsPage(props: { organizationSlug: string; projectSlug: string; @@ -25,9 +33,9 @@ export function TargetProposalDetailsPage(props: { services: ServiceProposalDetails[]; reviews: FragmentType; }) { - return ( -
- {props.services?.map(({ allChanges, ignoredChanges, conflictingChanges, serviceName }) => { + const mappedServices = useMemo(() => { + return props.services?.map( + ({ allChanges, ignoredChanges, conflictingChanges, serviceName }) => { const changes = allChanges.map(c => { const conflict = conflictingChanges.find(({ change }) => c === change); if (conflict) { @@ -39,23 +47,41 @@ export function TargetProposalDetailsPage(props: { } const ignored = ignoredChanges.find(({ change }) => c === change); if (ignored) { - return { - change: c, - error: ignored.error, - mergeStatus: MergeStatus.IGNORED, - }; + return null; } return { change: c }; }); - const breakingChanges = changes.filter(({ change }) => { - return change.criticality.level === CriticalityLevel.Breaking; - }); - const dangerousChanges = changes.filter(({ change }) => { - return change.criticality.level === CriticalityLevel.Dangerous; - }); - const safeChanges = changes.filter(({ change }) => { - return change.criticality.level === CriticalityLevel.NonBreaking; - }); + + const breaking: MappedChange[] = []; + const dangerous: MappedChange[] = []; + const safe: MappedChange[] = []; + for (const change of changes) { + if (change) { + const level = change.change.criticality.level; + if (level === CriticalityLevel.Breaking) { + breaking.push(change); + } else if (level === CriticalityLevel.Dangerous) { + dangerous.push(change); + } else { + // if (level === CriticalityLevel.NonBreaking) { + safe.push(change); + } + } + } + return { + safe, + breaking, + dangerous, + ignored: ignoredChanges.map(c => ({ ...c, mergeStatus: MergeStatus.IGNORED })), + serviceName, + }; + }, + ); + }, [props.services]); + + return ( +
+ {mappedServices?.map(({ safe, dangerous, breaking, ignored, serviceName }) => { return ( {serviceName.length !== 0 && ( @@ -63,9 +89,26 @@ export function TargetProposalDetailsPage(props: { {serviceName} )} - - - + + + + ); })} @@ -75,25 +118,33 @@ export function TargetProposalDetailsPage(props: { function ChangeBlock(props: { title: string; - changes: Array<{ change: Change; error?: Error; mergeStatus?: MergeStatus }>; + info: string; + changes: Array<{ + change: Change; + error?: Error; + mergeStatus?: MergeStatus; + }>; }) { return ( props.changes.length !== 0 && ( <> -

{props.title}

+

+ {props.title} + {props.info && } +

{props.changes.map(({ change, error, mergeStatus }) => { let icon: ReactNode | undefined; if (mergeStatus === MergeStatus.CONFLICT) { icon = ( - + CONFLICT ); } else if (mergeStatus === MergeStatus.IGNORED) { icon = ( - + NO CHANGE ); @@ -112,3 +163,20 @@ function ChangeBlock(props: { ) ); } + +function ChangesBlockTooltip(props: { info: string }) { + return ( + + + + + + +

{props.info}

+
+
+
+ ); +} diff --git a/packages/web/app/src/pages/target-proposal-edit.tsx b/packages/web/app/src/pages/target-proposal-edit.tsx index a114089ebb..b694385e65 100644 --- a/packages/web/app/src/pages/target-proposal-edit.tsx +++ b/packages/web/app/src/pages/target-proposal-edit.tsx @@ -1,5 +1,3 @@ -import { InlineCode } from '@/components/v2/inline-code'; - export function TargetProposalEditPage(props: { organizationSlug: string; projectSlug: string; diff --git a/packages/web/app/src/pages/target-proposal-supergraph.tsx b/packages/web/app/src/pages/target-proposal-supergraph.tsx index 9903763f92..f23247e368 100644 --- a/packages/web/app/src/pages/target-proposal-supergraph.tsx +++ b/packages/web/app/src/pages/target-proposal-supergraph.tsx @@ -12,13 +12,20 @@ const ProposalSupergraphChangesQuery = graphql(/* GraphQL */ ` query ProposalSupergraphChangesQuery($id: ID!) { schemaProposal(input: { id: $id }) { id - versions(after: null, input: { onlyLatest: true }) { + checks(after: null, input: { latestPerService: true }) { edges { + __typename node { id serviceName - changes { - ...ProposalOverview_ChangeFragment + hasSchemaChanges + schemaChanges { + edges { + node { + __typename + ...ProposalOverview_ChangeFragment + } + } } } } @@ -65,8 +72,11 @@ export function TargetProposalSupergraphPage(props: { // @todo use pagination to collect all const allChanges: (FragmentType | null | undefined)[] = []; - query?.data?.schemaProposal?.versions?.edges?.map(({ node: { changes } }) => { - allChanges.push(...changes); + query?.data?.schemaProposal?.checks?.edges?.map(({ node: { schemaChanges } }) => { + if (schemaChanges) { + const changes = schemaChanges.edges.map(edge => edge.node); + allChanges.push(...changes); + } }); return ( diff --git a/packages/web/app/src/pages/target-proposal.tsx b/packages/web/app/src/pages/target-proposal.tsx index 644095d36f..9a58a697c7 100644 --- a/packages/web/app/src/pages/target-proposal.tsx +++ b/packages/web/app/src/pages/target-proposal.tsx @@ -7,7 +7,6 @@ import { ProposalOverview_ReviewsFragment, toUpperSnakeCase, } from '@/components/proposal'; -import { userText } from '@/components/proposal/util'; import { StageTransitionSelect } from '@/components/target/proposals/stage-transition-select'; import { VersionSelect } from '@/components/target/proposals/version-select'; import { CardDescription } from '@/components/ui/card'; @@ -46,18 +45,13 @@ const ProposalQuery = graphql(/* GraphQL */ ` stage title description - versions { + checks(after: null, input: {}) { ...ProposalQuery_VersionsListFragment } reviews { ...ProposalOverview_ReviewsFragment } - user { - id - email - displayName - fullName - } + author } latestValidVersion(target: $latestValidVersionReference) { id @@ -85,15 +79,20 @@ const ProposalChangesQuery = graphql(/* GraphQL */ ` query ProposalChangesQuery($id: ID!) { schemaProposal(input: { id: $id }) { id - versions(after: null, input: { onlyLatest: true }) { + checks(after: null, input: { latestPerService: true }) { edges { __typename node { id serviceName - changes { - __typename - ...ProposalOverview_ChangeFragment + hasSchemaChanges + schemaChanges { + edges { + node { + __typename + ...ProposalOverview_ChangeFragment + } + } } } } @@ -158,7 +157,7 @@ const ProposalsContent = (props: Parameters[0] // categorize changes. const services = useMemo(() => { return ( - changesQuery.data?.schemaProposal?.versions?.edges?.map( + changesQuery.data?.schemaProposal?.checks?.edges?.map( ({ node: proposalVersion }): ServiceProposalDetails => { const existingSchema = query.data?.latestValidVersion?.schemas.edges.find( ({ node: latestSchema }) => @@ -169,10 +168,11 @@ const ProposalsContent = (props: Parameters[0] const beforeSchema = existingSchema?.length ? buildSchema(existingSchema, { assumeValid: true, assumeValidSDL: true }) : null; + // @todo better handle pagination const allChanges = - proposalVersion.changes + proposalVersion.schemaChanges?.edges .filter(c => !!c) - ?.map((change): Change => { + ?.map(({ node: change }): Change => { const c = useFragment(ProposalOverview_ChangeFragment, change); return { criticality: { @@ -206,7 +206,7 @@ const ProposalsContent = (props: Parameters[0] beforeSchema, afterSchema, allChanges, - rawChanges: proposalVersion.changes.filter(c => !!c), + rawChanges: proposalVersion.schemaChanges?.edges.map(({ node }) => node) ?? [], conflictingChanges, ignoredChanges, serviceName: proposalVersion.serviceName ?? '', @@ -216,7 +216,7 @@ const ProposalsContent = (props: Parameters[0] ); }, [ // @todo handle pagination - changesQuery.data?.schemaProposal?.versions?.edges, + changesQuery.data?.schemaProposal?.checks?.edges, query.data?.latestValidVersion?.schemas.edges, ]); @@ -264,7 +264,7 @@ const ProposalsContent = (props: Parameters[0] <>
- +
@@ -274,7 +274,7 @@ const ProposalsContent = (props: Parameters[0]
{proposal.title}
- proposed by {userText(proposal.user)} + proposed by {proposal.author}
{proposal.description}
diff --git a/packages/web/app/src/pages/target-proposals.tsx b/packages/web/app/src/pages/target-proposals.tsx index fbd8057b36..0c02c9024b 100644 --- a/packages/web/app/src/pages/target-proposals.tsx +++ b/packages/web/app/src/pages/target-proposals.tsx @@ -68,7 +68,7 @@ const ProposalsContent = (props: Parameters[0]) => { }; const ProposalsQuery = graphql(` - query listProposals($input: SchemaProposalsInput) { + query listProposals($input: SchemaProposalsInput!) { schemaProposals(input: $input) { edges { node { @@ -76,11 +76,7 @@ const ProposalsQuery = graphql(` title stage updatedAt - user { - id - displayName - fullName - } + author commentsCount } cursor @@ -152,7 +148,11 @@ const ProposalsListPage = (props: { variables: { input: { target: { - byId: props.targetSlug, + bySelector: { + organizationSlug: props.organizationSlug, + projectSlug: props.projectSlug, + targetSlug: props.targetSlug, + }, }, stages: ( props.filterStages ?? [ @@ -213,11 +213,7 @@ const ProposalsListPage = (props: {
proposed
- {proposal.user ? ( -
- by {proposal.user.displayName ?? proposal.user.fullName} -
- ) : null} + {proposal.author ?
by {proposal.author}
: null}
Date: Mon, 1 Sep 2025 11:23:09 -0700 Subject: [PATCH 35/54] Implementing resolvers --- docker/docker-compose.dev.yml | 2 +- .../federation.reviews-modified.graphql | 50 ++++ .../cli/src/commands/proposal/create.ts | 250 ++++++++++++++++++ .../2025.08.30T00-00-00.schema-proposals.ts | 2 +- .../src/modules/proposals/module.graphql.ts | 42 ++- .../providers/schema-proposal-manager.ts | 9 +- .../providers/schema-proposal-storage.ts | 67 ++--- .../proposals/resolvers/GraphQLKind.ts | 26 -- .../Mutation/createSchemaProposal.ts | 27 +- .../resolvers/Query/schemaProposal.ts | 8 +- .../proposals/resolvers/SchemaChange.ts | 13 +- .../proposals/resolvers/SchemaProposal.ts | 75 +++++- .../schema/providers/schema-manager.ts | 12 + .../schema/providers/schema-publisher.ts | 15 +- .../src/modules/shared/providers/storage.ts | 28 +- packages/services/storage/src/index.ts | 83 ++++++ .../storage/src/schema-change-model.ts | 1 + .../target/proposals/version-select.tsx | 2 +- .../app/src/pages/target-proposal-details.tsx | 9 +- .../app/src/pages/target-proposal-schema.tsx | 1 + .../web/app/src/pages/target-proposal.tsx | 9 +- .../web/app/src/pages/target-proposals.tsx | 9 +- 22 files changed, 640 insertions(+), 100 deletions(-) create mode 100644 packages/libraries/cli/examples/federation.reviews-modified.graphql create mode 100644 packages/libraries/cli/src/commands/proposal/create.ts delete mode 100644 packages/services/api/src/modules/proposals/resolvers/GraphQLKind.ts diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index 876a4d7d4b..8a872df410 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -74,7 +74,7 @@ services: clickhouse: image: clickhouse/clickhouse-server:24.8-alpine - mem_limit: 2048m + mem_limit: 4096m environment: CLICKHOUSE_USER: test CLICKHOUSE_PASSWORD: test diff --git a/packages/libraries/cli/examples/federation.reviews-modified.graphql b/packages/libraries/cli/examples/federation.reviews-modified.graphql new file mode 100644 index 0000000000..343d1c0cfa --- /dev/null +++ b/packages/libraries/cli/examples/federation.reviews-modified.graphql @@ -0,0 +1,50 @@ +extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.3" + import: ["@key", "@shareable", "@override"] + ) + @link(url: "https://specs.graphql-hive.com/hive/v1.0", import: ["@meta"]) + @meta(name: "owner", content: "reviews-team") + +directive @meta( + name: String! + content: String! +) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +type Product implements ProductItf @key(fields: "id") { + id: ID! + reviewsCount: Int! + reviewsScore: Float! @shareable @override(from: "products") + reviews: [Review!]! +} + +interface ProductItf { + id: ID! + reviewsCount: Int! + reviewsScore: Float! + reviews: [Review!]! +} + +type Query { + review(id: ID!): Review + reviewsByProductId(id: ID!): [Review] +} + +type Mutation { + reviewProduct(productId: ID!, input: ReviewProductInput!): Review +} + +input ReviewProductInput { + body: String! + + """ + Rating on a scale of 0 - 5 + """ + rating: Int! +} + +type Review { + id: ID! + body: String! + rating: Int! +} diff --git a/packages/libraries/cli/src/commands/proposal/create.ts b/packages/libraries/cli/src/commands/proposal/create.ts new file mode 100644 index 0000000000..80d37a2a6d --- /dev/null +++ b/packages/libraries/cli/src/commands/proposal/create.ts @@ -0,0 +1,250 @@ +import { Args, Errors, Flags } from '@oclif/core'; +import Command from '../../base-command'; +import { graphql } from '../../gql'; +import * as GraphQLSchema from '../../gql/graphql'; +import { graphqlEndpoint } from '../../helpers/config'; +import { + APIError, + CommitRequiredError, + GithubRepositoryRequiredError, + InvalidTargetError, + MissingEndpointError, + MissingRegistryTokenError, + SchemaFileEmptyError, + SchemaFileNotFoundError, + UnexpectedError, +} from '../../helpers/errors'; +import { gitInfo } from '../../helpers/git'; +import { loadSchema, minifySchema } from '../../helpers/schema'; +import * as TargetInput from '../../helpers/target-input'; + +const proposeSchemaMutation = graphql(/* GraphQL */ ` + mutation proposeSchema($input: CreateSchemaProposalInput!) { + createSchemaProposal(input: $input) { + __typename + ok { + schemaProposal { + id + } + } + error { + message + ... on CreateSchemaProposalError { + details { + description + title + } + } + } + } + } +`); + +export default class ProposalCreate extends Command { + static description = 'Proposes a schema change'; + static flags = { + 'registry.endpoint': Flags.string({ + description: 'registry endpoint', + }), + /** @deprecated */ + registry: Flags.string({ + description: 'registry address', + deprecated: { + message: 'use --registry.endpoint instead', + version: '0.21.0', + }, + }), + 'registry.accessToken': Flags.string({ + description: 'registry access token', + }), + /** @deprecated */ + token: Flags.string({ + description: 'api token', + deprecated: { + message: 'use --registry.accessToken instead', + version: '0.21.0', + }, + }), + target: Flags.string({ + required: true, + description: + 'The target against which to propose the schema (slug or ID).' + + ' This can either be a slug following the format "$organizationSlug/$projectSlug/$targetSlug" (e.g "the-guild/graphql-hive/staging")' + + ' or an UUID (e.g. "a0f4c605-6541-4350-8cfe-b31f21a4bf80").', + }), + title: Flags.string({ + required: true, + description: 'Title of the proposal. This should be a short description of the change.', + }), + description: Flags.string({ + required: false, + description: + 'Description of the proposal. This should be a more detailed explanation of the change.', + }), + draft: Flags.boolean({ + default: false, + description: + 'Set to true to open the proposal as a Draft. This indicates the proposal is still in progress.', + }), + + /** CLI Only supports service at a time right now. */ + service: Flags.string({ + description: 'service name (only for distributed schemas)', + }), + github: Flags.boolean({ + description: 'Connect with GitHub Application', + default: false, + }), + author: Flags.string({ + description: 'Author of the change', + }), + commit: Flags.string({ + description: 'Associated commit sha', + }), + contextId: Flags.string({ + description: 'Context ID for grouping the schema check.', + }), + url: Flags.string({ + description: + 'If checking a service, then you can optionally provide the service URL to see the difference in the supergraph during the check.', + }), + }; + + static args = { + file: Args.string({ + name: 'file', + required: true, + description: 'Path to the schema file(s)', + hidden: false, + }), + }; + + async run() { + try { + const { flags, args } = await this.parse(ProposalCreate); + let target: GraphQLSchema.TargetReferenceInput | null = null; + { + const result = TargetInput.parse(flags.target); + if (result.type === 'error') { + throw new InvalidTargetError(); + } + target = result.data; + } + + const service = flags.service; + const usesGitHubApp = flags.github === true; + let endpoint: string, accessToken: string; + try { + endpoint = this.ensure({ + key: 'registry.endpoint', + args: flags, + legacyFlagName: 'registry', + defaultValue: graphqlEndpoint, + env: 'HIVE_REGISTRY', + description: ProposalCreate.flags['registry.endpoint'].description!, + }); + } catch (e) { + throw new MissingEndpointError(); + } + const file = args.file; + try { + accessToken = this.ensure({ + key: 'registry.accessToken', + args: flags, + legacyFlagName: 'token', + env: 'HIVE_TOKEN', + description: ProposalCreate.flags['registry.accessToken'].description!, + }); + } catch (e) { + throw new MissingRegistryTokenError(); + } + + const sdl = await loadSchema(file).catch(e => { + throw new SchemaFileNotFoundError(file, e); + }); + const git = await gitInfo(() => { + // noop + }); + + const commit = flags.commit || git?.commit; + const author = flags.author || git?.author; + + if (typeof sdl !== 'string' || sdl.length === 0) { + throw new SchemaFileEmptyError(file); + } + + let github: null | { + commit: string; + repository: string | null; + pullRequestNumber: string | null; + } = null; + + if (usesGitHubApp) { + if (!commit) { + throw new CommitRequiredError(); + } + if (!git.repository) { + throw new GithubRepositoryRequiredError(); + } + if (!git.pullRequestNumber) { + this.warn( + "Could not resolve pull request number. Are you running this command on a 'pull_request' event?\n" + + 'See https://the-guild.dev/graphql/hive/docs/other-integrations/ci-cd#github-workflow-for-ci', + ); + } + + github = { + commit: commit, + repository: git.repository, + pullRequestNumber: git.pullRequestNumber, + }; + } + + const result = await this.registryApi(endpoint, accessToken).request({ + operation: proposeSchemaMutation, + variables: { + input: { + target, + title: flags.title, + description: flags.description, + isDraft: flags.draft, + initialChecks: [ + { + service, + sdl: minifySchema(sdl), + github, + meta: + !!commit && !!author + ? { + commit, + author, + } + : null, + contextId: flags.contextId ?? undefined, + url: flags.url, + }, + ], + }, + }, + }); + + if (result.createSchemaProposal.ok) { + const id = result.createSchemaProposal.ok.schemaProposal.id; + if (id) { + this.logSuccess(`Created proposal ${id}.`); + } + + if (result.createSchemaProposal.error) { + throw new APIError(result.createSchemaProposal.error.message); + } + } + } catch (error) { + if (error instanceof Errors.CLIError) { + throw error; + } else { + this.logFailure('Failed to create schema proposal'); + throw new UnexpectedError(error); + } + } + } +} diff --git a/packages/migrations/src/actions/2025.08.30T00-00-00.schema-proposals.ts b/packages/migrations/src/actions/2025.08.30T00-00-00.schema-proposals.ts index 31408f6a2c..49b1add4d6 100644 --- a/packages/migrations/src/actions/2025.08.30T00-00-00.schema-proposals.ts +++ b/packages/migrations/src/actions/2025.08.30T00-00-00.schema-proposals.ts @@ -133,7 +133,7 @@ export default { ADD COLUMN IF NOT EXISTS "schema_proposal_id" UUID REFERENCES "schema_proposals" ("id") ON DELETE SET NULL ; CREATE INDEX IF NOT EXISTS schema_checks_schema_proposal_id ON schema_checks( - schema_proposal_id + schema_proposal_id, lower(service_name) ) ; `, diff --git a/packages/services/api/src/modules/proposals/module.graphql.ts b/packages/services/api/src/modules/proposals/module.graphql.ts index c662065b56..e7a7d8947e 100644 --- a/packages/services/api/src/modules/proposals/module.graphql.ts +++ b/packages/services/api/src/modules/proposals/module.graphql.ts @@ -217,20 +217,40 @@ export default gql` The checks associated with this proposal. Each proposed change triggers a check for the set of changes. And each service is checked separately. This is a limitation of the schema check API at this time. + + The check "cursor" can be considered the proposal "version". """ - checks(after: ID, first: Int! = 20, input: SchemaProposalChecksInput!): SchemaCheckConnection + checks( + after: String + first: Int! = 20 + input: SchemaProposalChecksInput! + ): SchemaCheckConnection """ Applies changes to each service subgraph for each of the service's latest check belonging to the SchemaProposal. - - @todo consider making this a connection before going live. """ - rebasedSchemaSDL(checkId: ID): [SubgraphSchema!] + rebasedSchemaSDL( + """ + A schema check cursor. This indicates from where in the list of schema checks to start applying + the changes. The check "cursor" can be considered the proposal "version". + """ + after: String + """ + The number of service SDLs return + """ + first: Int! = 20 + ): SubgraphSchemaConnection """ Applies changes to the supergraph for each of the service's latest check belonging to the SchemaProposal. """ - rebasedSupergraphSDL(versionId: ID): String + rebasedSupergraphSDL( + """ + A schema check cursor. This indicates from where in the list of schema checks to start applying + the changes. + """ + fromCursor: String + ): String commentsCount: Int! } @@ -242,6 +262,16 @@ export default gql` latestPerService: Boolean! = false } + type SubgraphSchemaConnection { + edges: [SubgraphSchemaEdge] + pageInfo: PageInfo! + } + + type SubgraphSchemaEdge { + cursor: String! + node: SubgraphSchema! + } + type SubgraphSchema { """ The SDL of the schema that was checked. @@ -758,8 +788,6 @@ export default gql` removedTypeName: String! } - scalar GraphQLKind - type TypeAdded { addedTypeName: String! addedTypeKind: String diff --git a/packages/services/api/src/modules/proposals/providers/schema-proposal-manager.ts b/packages/services/api/src/modules/proposals/providers/schema-proposal-manager.ts index fa7267305f..659a39c1c7 100644 --- a/packages/services/api/src/modules/proposals/providers/schema-proposal-manager.ts +++ b/packages/services/api/src/modules/proposals/providers/schema-proposal-manager.ts @@ -35,7 +35,7 @@ export class SchemaProposalManager { user: { id: string; displayName: string; - }; + } | null; initialChecks: ReadonlyArray; }) { const selector = await this.idTranslator.resolveTargetReference({ reference: args.target }); @@ -45,7 +45,7 @@ export class SchemaProposalManager { const createProposalResult = await this.storage.createProposal({ organizationId: selector.organizationId, - userId: args.user.id, + userId: args.user?.id ?? null, description: args.description, stage: args.isDraft ? 'DRAFT' : 'OPEN', targetId: selector.targetId, @@ -98,7 +98,7 @@ export class SchemaProposalManager { stage: proposal.stage, targetId: proposal.targetId, reviews: null, - author: args.user.displayName, + author: args.user?.displayName ?? '', commentsCount: 0, // checks: { // edges: checkEdges, @@ -115,7 +115,7 @@ export class SchemaProposalManager { }; } - async getProposal(args: { proposalId: string }) { + async getProposal(args: { id: string }) { return this.storage.getProposal(args); } @@ -154,5 +154,6 @@ export class SchemaProposalManager { }); } + // @todo async reviewProposal(args: { proposalId: string }) {} } diff --git a/packages/services/api/src/modules/proposals/providers/schema-proposal-storage.ts b/packages/services/api/src/modules/proposals/providers/schema-proposal-storage.ts index aa4060caad..5166a1e576 100644 --- a/packages/services/api/src/modules/proposals/providers/schema-proposal-storage.ts +++ b/packages/services/api/src/modules/proposals/providers/schema-proposal-storage.ts @@ -54,7 +54,7 @@ export class SchemaProposalStorage { }); if (organization.featureFlags.appDeployments === false) { this.logger.debug( - 'organization has no access to schema proposals (targetId=%s, proposalId=%s)', + 'organization has no access to schema proposals (target=%s, proposal=%s)', args.targetId, args.proposalId, ); @@ -75,10 +75,10 @@ export class SchemaProposalStorage { title: string; description: string; stage: SchemaProposalStage; - userId: string; + userId: string | null; }) { this.logger.debug( - 'propose schema (targetId=%s, title=%s, stage=%b)', + 'propose schema (targetId=%s, title=%s, stage=%s)', args.targetId, args.title, args.stage, @@ -107,30 +107,18 @@ export class SchemaProposalStorage { const proposal = await this.pool .maybeOne( sql` - INSERT INTO schema_proposals - ( - "id" - , "created_at" - , "updated_at" - , "target_id" - , "title" - , "description" - , "stage" - , "user_id" - ) - VALUES - ( - DEFAULT - , DEFAULT - , DEFAULT - , ${args.targetId} - , ${args.title} - , ${args.description} - , ${args.stage} - , ${args.userId} - ) - RETURNING * - `, + INSERT INTO "schema_proposals" + ("target_id", "title", "description", "stage", "user_id") + VALUES + ( + ${args.targetId} + , ${args.title} + , ${args.description} + , ${args.stage} + , ${args.userId} + ) + RETURNING ${schemaProposalFields} + `, ) .then(row => SchemaProposalModel.parse(row)); @@ -140,17 +128,19 @@ export class SchemaProposalStorage { }; } - async getProposal(args: { proposalId: string }) { + async getProposal(args: { id: string }) { + this.logger.debug('Get proposal (proposal=%s)', args.id); const result = await this.pool .maybeOne( sql` - SELECT - ${schemaProposalFields} - FROM - "schema_proposals" - WHERE - "id" = ${args.proposalId} - `, + SELECT + ${schemaProposalFields} + FROM + "schema_proposals" + WHERE + "id" = ${args.id} + LIMIT 1 + `, ) .then(row => SchemaProposalModel.parse(row)); @@ -232,7 +222,7 @@ export class SchemaProposalStorage { const cursor = args.after ? decodeCreatedAtAndUUIDIdBasedCursor(args.after) : null; this.logger.debug( - 'Select by proposalId ID (targetId=%s, cursor=%s, limit=%d)', + 'Select by proposalId ID (proposal=%s, cursor=%s, limit=%d)', args.proposalId, cursor, limit, @@ -243,7 +233,7 @@ export class SchemaProposalStorage { FROM "schema_proposal_reviews" WHERE - "proposal_id" = ${args.proposalId} + "schema_proposal_id" = ${args.proposalId} ${ cursor ? sql` @@ -327,8 +317,9 @@ const SchemaProposalModel = z.object({ description: z.string(), stage: z.enum(['DRAFT', 'OPEN', 'APPROVED', 'IMPLEMENTED', 'CLOSED']), targetId: z.string(), - userId: z.string(), + userId: z.string().nullable(), commentsCount: z.number(), }); export type SchemaProposalRecord = z.infer; +export type SchemaProposalReviewRecord = z.infer; diff --git a/packages/services/api/src/modules/proposals/resolvers/GraphQLKind.ts b/packages/services/api/src/modules/proposals/resolvers/GraphQLKind.ts deleted file mode 100644 index 97bbcdcbb7..0000000000 --- a/packages/services/api/src/modules/proposals/resolvers/GraphQLKind.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { GraphQLScalarType, Kind } from 'graphql'; - -const KindValues = Object.values(Kind); - -export const GraphQLKind = new GraphQLScalarType({ - name: 'GraphQLKind', - description: 'GraphQLKind description', - serialize: value => { - if (typeof value === 'string' && KindValues.includes(value as Kind)) { - return value; - } - throw new Error('GraphQLKind scalar expects a valid Kind.'); - }, - parseValue: value => { - if (typeof value === 'string' && KindValues.includes(value as Kind)) { - return value; - } - throw new Error('GraphQLKind scalar expects a valid Kind.'); - }, - parseLiteral: ast => { - if (ast.kind === Kind.STRING && KindValues.includes(ast.value as Kind)) { - return ast.value; - } - return null; - }, -}); diff --git a/packages/services/api/src/modules/proposals/resolvers/Mutation/createSchemaProposal.ts b/packages/services/api/src/modules/proposals/resolvers/Mutation/createSchemaProposal.ts index 802d027b82..a25f071915 100644 --- a/packages/services/api/src/modules/proposals/resolvers/Mutation/createSchemaProposal.ts +++ b/packages/services/api/src/modules/proposals/resolvers/Mutation/createSchemaProposal.ts @@ -7,16 +7,33 @@ export const createSchemaProposal: NonNullable { const { target, title, description, isDraft, initialChecks } = input; - const user = await session.getViewer(); + let user: { + id: string; + displayName: string; + } | null = null; + try { + const actor = await session.getActor(); + if (actor.type === 'user') { + user = { + id: actor.user.id, + displayName: actor.user.displayName, + }; + } + } catch (e) { + // ignore + } + const result = await injector.get(SchemaProposalManager).proposeSchema({ target, title, description: description ?? '', isDraft: isDraft ?? false, - user: { - id: user.id, - displayName: user.displayName, - }, + user: user + ? { + id: user.id, + displayName: user.displayName, + } + : null, initialChecks, }); diff --git a/packages/services/api/src/modules/proposals/resolvers/Query/schemaProposal.ts b/packages/services/api/src/modules/proposals/resolvers/Query/schemaProposal.ts index b9baa9d798..db6765ccfb 100644 --- a/packages/services/api/src/modules/proposals/resolvers/Query/schemaProposal.ts +++ b/packages/services/api/src/modules/proposals/resolvers/Query/schemaProposal.ts @@ -1,10 +1,14 @@ import { SchemaProposalManager } from '../../providers/schema-proposal-manager'; import type { QueryResolvers } from './../../../../__generated__/types'; -export const schemaProposal: NonNullable = ( +export const schemaProposal: NonNullable = async ( _parent, { input: { id } }, { injector }, ) => { - return injector.get(SchemaProposalManager).getProposal({ proposalId: id }); + const proposal = await injector.get(SchemaProposalManager).getProposal({ id }); + return { + ...proposal, + author: '', // populated in its own resolver + }; }; diff --git a/packages/services/api/src/modules/proposals/resolvers/SchemaChange.ts b/packages/services/api/src/modules/proposals/resolvers/SchemaChange.ts index ff5d244756..f1a6c7571a 100644 --- a/packages/services/api/src/modules/proposals/resolvers/SchemaChange.ts +++ b/packages/services/api/src/modules/proposals/resolvers/SchemaChange.ts @@ -1,8 +1,17 @@ import type { SchemaChangeResolvers } from './../../../__generated__/types'; +export function toTitleCase(str: string) { + return str.toLowerCase().replace(/^_*(.)|_+(.)/g, (_, c: string, d: string) => { + return (c ?? d).toUpperCase(); + }); +} + export const SchemaChange: Pick = { - meta: ({ meta }, _arg, _ctx) => { + meta: ({ meta, type }, _arg, _ctx) => { // @todo consider validating - return meta as any; + return { + __typename: toTitleCase(type), + ...(meta as any), + }; }, }; diff --git a/packages/services/api/src/modules/proposals/resolvers/SchemaProposal.ts b/packages/services/api/src/modules/proposals/resolvers/SchemaProposal.ts index c88b2f0460..32679dccee 100644 --- a/packages/services/api/src/modules/proposals/resolvers/SchemaProposal.ts +++ b/packages/services/api/src/modules/proposals/resolvers/SchemaProposal.ts @@ -1,13 +1,84 @@ +import { SchemaCheckManager } from '../../schema/providers/schema-check-manager'; +import { SchemaManager } from '../../schema/providers/schema-manager'; +import { toGraphQLSchemaCheckCurry } from '../../schema/to-graphql-schema-check'; +import { Storage } from '../../shared/providers/storage'; import { SchemaProposalManager } from '../providers/schema-proposal-manager'; import type { SchemaProposalResolvers } from './../../../__generated__/types'; // @todo export const SchemaProposal: SchemaProposalResolvers = { + async author(proposal, _, { injector }) { + const userId = (proposal as any)?.userId; + if (userId) { + const user = await injector.get(Storage).getUserById(userId); + return user?.displayName ?? ''; + } + return ''; + }, async rebasedSchemaSDL(proposal, args, { injector }) { - return []; + if (proposal.rebasedSchemaSDL) { + return proposal.rebasedSchemaSDL; + } + const target = await injector.get(Storage).getTargetById((proposal as any).targetId); + if (!target) { + throw new Error('uh oh'); + } + const schemaChecks = await injector + .get(SchemaManager) + .getPaginatedSchemaChecksForSchemaProposal({ + transformNode: toGraphQLSchemaCheckCurry({ + organizationId: target.orgId, + projectId: target.projectId, + }), + proposalId: proposal.id, + cursor: args.after ?? null, + first: args.first ?? null, + }); + + if (target) { + const latest = await injector.get(SchemaManager).getMaybeLatestValidVersion(target); + if (latest) { + const schemas = await injector.get(SchemaManager).getMaybeSchemasOfVersion(latest); + return { + edges: schemaChecks.edges.map(({ node, cursor }) => { + const schema = schemas.find( + s => + (node.serviceName === '' && s.kind === 'single') || + (s.kind === 'composite' && s.service_name === node.serviceName), + ); + return { + node: { + schemaSDL: schema?.sdl ?? '', // @todo patch + serviceName: node.serviceName, + }, + cursor, + }; + }), + pageInfo: schemaChecks.pageInfo, + }; + } + } + return null; + // @todo error if not found... }, async checks(proposal, args, { injector }) { - return proposal.checks ?? null; + const target = await injector.get(Storage).getTargetById((proposal as any).targetId); + if (!target) { + throw new Error('oops'); + } + const schemaChecks = await injector + .get(SchemaManager) + .getPaginatedSchemaChecksForSchemaProposal({ + transformNode: toGraphQLSchemaCheckCurry({ + organizationId: target.orgId, + projectId: target.projectId, + }), + proposalId: proposal.id, + cursor: args.after ?? null, + first: args.first ?? null, + }); + + return schemaChecks; }, async rebasedSupergraphSDL(proposal, args, { injector }) { return ''; diff --git a/packages/services/api/src/modules/schema/providers/schema-manager.ts b/packages/services/api/src/modules/schema/providers/schema-manager.ts index 8bd2bf5b7a..35f7f9447b 100644 --- a/packages/services/api/src/modules/schema/providers/schema-manager.ts +++ b/packages/services/api/src/modules/schema/providers/schema-manager.ts @@ -356,6 +356,18 @@ export class SchemaManager { }; } + async getPaginatedSchemaChecksForSchemaProposal< + TransformedSchemaCheck extends SchemaCheck = SchemaCheck, + >(args: { + transformNode?: (check: SchemaCheck) => TransformedSchemaCheck; + proposalId: string; + first: number | null; + cursor: string | null; + }) { + const connection = await this.storage.getPaginatedSchemaChecksForSchemaProposal(args); + return connection; + } + async getSchemaLog(selector: { commit: string } & TargetSelector) { this.logger.debug('Fetching schema log (selector=%o)', selector); return this.storage.getSchemaLog({ 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 4b15f1ff7c..5810f6d91c 100644 --- a/packages/services/api/src/modules/schema/providers/schema-publisher.ts +++ b/packages/services/api/src/modules/schema/providers/schema-publisher.ts @@ -89,7 +89,7 @@ const schemaDeleteCount = new promClient.Counter({ }); export type CheckInput = Types.SchemaCheckInput & { - schemaProposalId?: string; + schemaProposalId?: string | null; }; export type DeleteInput = Types.SchemaDeleteInput; @@ -342,10 +342,11 @@ export class SchemaPublisher { targetId: selector.targetId, onlyComposable: true, }), - input.schemaProposalId && - this.schemaProposals.getProposal({ - id: input.schemaProposalId, - }), + input.schemaProposalId + ? this.schemaProposals.getProposal({ + id: input.schemaProposalId, + }) + : null, ]); if (input.schemaProposalId && schemaProposal?.targetId !== selector.targetId) { @@ -485,6 +486,7 @@ export class SchemaPublisher { } if (github != null) { + // @todo should proposals do anything special here? const result = await this.createGithubCheckRunStartForSchemaCheck({ organization, project, @@ -723,6 +725,7 @@ export class SchemaPublisher { breakingSchemaChanges: contract.schemaChanges?.breaking ?? null, safeSchemaChanges: contract.schemaChanges?.safe ?? null, })) ?? null, + schemaProposalId: input.schemaProposalId, }); this.logger.info('created failed schema check. (schemaCheckId=%s)', schemaCheck.id); } else if (checkResult.conclusion === SchemaCheckConclusion.Success) { @@ -768,6 +771,7 @@ export class SchemaPublisher { breakingSchemaChanges: contract.schemaChanges?.breaking ?? null, safeSchemaChanges: contract.schemaChanges?.safe ?? null, })) ?? null, + schemaProposalId: input.schemaProposalId, }); this.logger.info('created successful schema check. (schemaCheckId=%s)', schemaCheck.id); } else if (checkResult.conclusion === SchemaCheckConclusion.Skip) { @@ -845,6 +849,7 @@ export class SchemaPublisher { })), ) : null, + schemaProposalId: input.schemaProposalId, }); this.logger.info('created skipped schema check. (schemaCheckId=%s)', schemaCheck.id); } diff --git a/packages/services/api/src/modules/shared/providers/storage.ts b/packages/services/api/src/modules/shared/providers/storage.ts index b64edcf0db..7064feddb2 100644 --- a/packages/services/api/src/modules/shared/providers/storage.ts +++ b/packages/services/api/src/modules/shared/providers/storage.ts @@ -405,6 +405,30 @@ export interface Storage { first: number | null; cursor: null | string; }): Promise; + // @todo consider moving to proposals provider + getPaginatedSchemaChecksForSchemaProposal< + TransformedSchemaCheck extends SchemaCheck = SchemaCheck, + >(_: { + proposalId: string; + first: number | null; + cursor: null | string; + transformNode?: (check: SchemaCheck) => TransformedSchemaCheck; + }): Promise< + Readonly<{ + edges: ReadonlyArray<{ + // @todo consider conditionally excluding this from the query for performance + // Omit; + node: TransformedSchemaCheck; + cursor: string; + }>; + pageInfo: Readonly<{ + hasNextPage: boolean; + hasPreviousPage: boolean; + startCursor: string; + endCursor: string; + }>; + }> + >; getVersion(_: TargetSelector & { versionId: string }): Promise; deleteSchema( _: { @@ -743,7 +767,9 @@ export interface Storage { /** * Persist a schema check record in the database. */ - createSchemaCheck(_: SchemaCheckInput & { expiresAt: Date | null }): Promise; + createSchemaCheck( + _: SchemaCheckInput & { expiresAt: Date | null; schemaProposalId?: string | null }, + ): Promise; /** * Delete the expired schema checks from the database. */ diff --git a/packages/services/storage/src/index.ts b/packages/services/storage/src/index.ts index 3e1ca34295..6bedcc365b 100644 --- a/packages/services/storage/src/index.ts +++ b/packages/services/storage/src/index.ts @@ -3901,6 +3901,7 @@ export async function createStorage( , "context_id" , "has_contract_schema_changes" , "conditional_breaking_change_metadata" + , "schema_proposal_id" ) VALUES ( ${schemaSDLHash} @@ -3929,6 +3930,7 @@ export async function createStorage( ) ?? false } , ${jsonify(InsertConditionalBreakingChangeMetadataModel.parse(args.conditionalBreakingChangeMetadata))} + , ${args.schemaProposalId ?? null} ) RETURNING "id" @@ -4248,6 +4250,86 @@ export async function createStorage( }; }, + async getPaginatedSchemaChecksForSchemaProposal(args) { + let cursor: null | { + createdAt: string; + id: string; + } = null; + + const limit = args.first ? (args.first > 0 ? Math.min(args.first, 20) : 20) : 20; + + if (args.cursor) { + cursor = decodeCreatedAtAndUUIDIdBasedCursor(args.cursor); + } + + // gets the most recently created schema checks per service name + const result = await pool.any(sql`/* getPaginatedSchemaChecksForSchemaProposal */ + SELECT + ${schemaCheckSQLFields} + FROM + "schema_checks" as c + LEFT JOIN "schema_checks" as cc + ON c.service_name = cc.service_name AND c."created_at" < cc."created_at" + LEFT JOIN "sdl_store" as s_schema + ON s_schema."id" = c."schema_sdl_store_id" + LEFT JOIN "sdl_store" as s_composite_schema + ON s_composite_schema."id" = c."composite_schema_sdl_store_id" + LEFT JOIN "sdl_store" as s_supergraph + ON s_supergraph."id" = c."supergraph_sdl_store_id" + WHERE + c."schema_proposal_id" = ${args.proposalId} + ${ + cursor + ? sql` + AND ( + ( + c."created_at" = ${cursor.createdAt} + AND c."id" < ${cursor.id} + ) + OR c."created_at" < ${cursor.createdAt} + ) + ` + : sql`` + } + ORDER BY + c."created_at" DESC + , c."id" DESC + LIMIT ${limit + 1} + `); + + let items = result.map(row => { + const node = SchemaCheckModel.parse(row); + + return { + get node() { + // TODO: remove this any cast and fix the type issues... + return (args.transformNode?.(node) ?? node) as any; + }, + get cursor() { + return encodeCreatedAtAndUUIDIdBasedCursor(node); + }, + }; + }); + + const hasNextPage = items.length > limit; + + items = items.slice(0, limit); + + return { + edges: items, + pageInfo: { + hasNextPage, + hasPreviousPage: cursor !== null, + get endCursor() { + return items[items.length - 1]?.cursor ?? ''; + }, + get startCursor() { + return items[0]?.cursor ?? ''; + }, + }, + }; + }, + async getTargetBreadcrumbForTargetId(args) { const result = await pool.maybeOne(sql`/* getTargetBreadcrumbForTargetId */ SELECT @@ -5174,6 +5256,7 @@ const schemaCheckSQLFields = sql` , c."manual_approval_comment" as "manualApprovalComment" , c."context_id" as "contextId" , c."conditional_breaking_change_metadata" as "conditionalBreakingChangeMetadata" + , c."schema_proposal_id" as "schemaProposalId" `; const schemaVersionSQLFields = (t = sql``) => sql` diff --git a/packages/services/storage/src/schema-change-model.ts b/packages/services/storage/src/schema-change-model.ts index f48eb3be6b..5fa4d484a8 100644 --- a/packages/services/storage/src/schema-change-model.ts +++ b/packages/services/storage/src/schema-change-model.ts @@ -979,6 +979,7 @@ const SchemaCheckSharedOutputFields = { githubRepository: z.string().nullable(), githubSha: z.string().nullable(), contextId: z.string().nullable(), + schemaProposalId: z.string().nullable().optional(), }; const SchemaCheckSharedInputFields = { diff --git a/packages/web/app/src/components/target/proposals/version-select.tsx b/packages/web/app/src/components/target/proposals/version-select.tsx index 4542d90e71..14e17ed452 100644 --- a/packages/web/app/src/components/target/proposals/version-select.tsx +++ b/packages/web/app/src/components/target/proposals/version-select.tsx @@ -54,7 +54,7 @@ export function VersionSelect(props: { - + diff --git a/packages/web/app/src/pages/target-proposal-details.tsx b/packages/web/app/src/pages/target-proposal-details.tsx index 35d95c893b..dd909f430e 100644 --- a/packages/web/app/src/pages/target-proposal-details.tsx +++ b/packages/web/app/src/pages/target-proposal-details.tsx @@ -1,4 +1,4 @@ -import { Fragment, ReactNode, useMemo, useState } from 'react'; +import { Fragment, ReactNode, useMemo } from 'react'; import { ProposalOverview_ReviewsFragment } from '@/components/proposal'; import { ProposalChangeDetail } from '@/components/target/proposals/change-detail'; import { Button } from '@/components/ui/button'; @@ -69,6 +69,7 @@ export function TargetProposalDetailsPage(props: { } } return { + hasChanges: allChanges.length > 0, safe, breaking, dangerous, @@ -81,7 +82,11 @@ export function TargetProposalDetailsPage(props: { return (
- {mappedServices?.map(({ safe, dangerous, breaking, ignored, serviceName }) => { + {mappedServices?.map(({ safe, dangerous, breaking, ignored, serviceName, hasChanges }) => { + // don't print service name if service was not changed + if (!hasChanges) { + return null; + } return ( {serviceName.length !== 0 && ( diff --git a/packages/web/app/src/pages/target-proposal-schema.tsx b/packages/web/app/src/pages/target-proposal-schema.tsx index 64ab79d855..e63e661166 100644 --- a/packages/web/app/src/pages/target-proposal-schema.tsx +++ b/packages/web/app/src/pages/target-proposal-schema.tsx @@ -1,4 +1,5 @@ import { Proposal, ProposalOverview_ReviewsFragment } from '@/components/proposal'; +import { Subtitle } from '@/components/ui/page'; import { FragmentType } from '@/gql'; import { ServiceProposalDetails } from './target-proposal-types'; diff --git a/packages/web/app/src/pages/target-proposal.tsx b/packages/web/app/src/pages/target-proposal.tsx index 9a58a697c7..4ca0a452ac 100644 --- a/packages/web/app/src/pages/target-proposal.tsx +++ b/packages/web/app/src/pages/target-proposal.tsx @@ -12,7 +12,7 @@ import { VersionSelect } from '@/components/target/proposals/version-select'; import { CardDescription } from '@/components/ui/card'; import { DiffIcon, EditIcon, GraphQLIcon } from '@/components/ui/icon'; import { Meta } from '@/components/ui/meta'; -import { Title } from '@/components/ui/page'; +import { Subtitle, Title } from '@/components/ui/page'; import { SubPageLayoutHeader } from '@/components/ui/page-content-layout'; import { Skeleton } from '@/components/ui/skeleton'; import { Spinner } from '@/components/ui/spinner'; @@ -284,7 +284,12 @@ const ProposalsContent = (props: Parameters[0] {changesQuery.fetching ? ( ) : !services.length ? ( - <>No changes found + <> + No changes found + + This proposed version would result in no changes to the latest schemas. + + ) : ( @@ -174,6 +175,12 @@ const ProposalsListPage = (props: { return ( <> {query.fetching ? : null} + {query.data?.schemaProposals?.edges?.length === 0 && ( +
+ No proposals have been created yet + To get started, use the Hive CLI to propose a schema change. +
+ )} {query.data?.schemaProposals?.edges?.map(({ node: proposal }) => { return (
Date: Mon, 1 Sep 2025 18:50:31 -0700 Subject: [PATCH 36/54] Add new fields to change serializer --- package.json | 6 +- .../federation.reviews-modified.graphql | 2 +- packages/libraries/cli/package.json | 2 +- packages/services/api/package.json | 2 +- .../src/modules/proposals/module.graphql.ts | 2 +- packages/services/storage/package.json | 2 +- .../storage/src/schema-change-meta.ts | 47 +- .../storage/src/schema-change-model.ts | 161 +++-- packages/web/app/package.json | 2 +- .../web/app/src/components/proposal/index.tsx | 14 - .../proposal/schema-diff/components.tsx | 4 +- .../proposal/schema-diff/schema-diff.tsx | 2 +- pnpm-lock.yaml | 679 ++---------------- 13 files changed, 204 insertions(+), 721 deletions(-) diff --git a/package.json b/package.json index 3cbcc9fa2b..2d795c44ac 100644 --- a/package.json +++ b/package.json @@ -66,9 +66,9 @@ "@graphql-codegen/typescript-resolvers": "4.4.4", "@graphql-codegen/urql-introspection": "3.0.0", "@graphql-eslint/eslint-plugin": "3.20.1", - "@graphql-inspector/cli": "4.0.3", - "@graphql-inspector/core": "^6.0.0", - "@graphql-inspector/patch": "link:../graphql-inspector/packages/patch", + "@graphql-inspector/cli": "link:../graphql-inspector/packages/cli", + "@graphql-inspector/core": "file:../graphql-inspector/packages/core", + "@graphql-inspector/patch": "file:../graphql-inspector/packages/patch", "@manypkg/get-packages": "2.2.2", "@next/eslint-plugin-next": "14.2.23", "@parcel/watcher": "2.5.0", diff --git a/packages/libraries/cli/examples/federation.reviews-modified.graphql b/packages/libraries/cli/examples/federation.reviews-modified.graphql index 343d1c0cfa..eb4d5eb164 100644 --- a/packages/libraries/cli/examples/federation.reviews-modified.graphql +++ b/packages/libraries/cli/examples/federation.reviews-modified.graphql @@ -40,7 +40,7 @@ input ReviewProductInput { """ Rating on a scale of 0 - 5 """ - rating: Int! + rating: Int! = 3 } type Review { diff --git a/packages/libraries/cli/package.json b/packages/libraries/cli/package.json index 2d2cfa2a8a..031d15c369 100644 --- a/packages/libraries/cli/package.json +++ b/packages/libraries/cli/package.json @@ -48,7 +48,7 @@ }, "dependencies": { "@graphql-hive/core": "workspace:*", - "@graphql-inspector/core": "5.1.0-alpha-20231208113249-34700c8a", + "@graphql-inspector/core": "file:../../../../graphql-inspector/packages/core", "@graphql-tools/code-file-loader": "~8.1.0", "@graphql-tools/graphql-file-loader": "~8.0.0", "@graphql-tools/json-file-loader": "~8.0.0", diff --git a/packages/services/api/package.json b/packages/services/api/package.json index eb673650bd..2db58b5344 100644 --- a/packages/services/api/package.json +++ b/packages/services/api/package.json @@ -17,7 +17,7 @@ "@date-fns/utc": "2.1.0", "@graphql-hive/core": "workspace:*", "@graphql-hive/signal": "1.0.0", - "@graphql-inspector/core": "5.1.0-alpha-20231208113249-34700c8a", + "@graphql-inspector/core": "file:../../../../graphql-inspector/packages/core", "@graphql-tools/merge": "9.0.24", "@hive/cdn-script": "workspace:*", "@hive/emails": "workspace:*", diff --git a/packages/services/api/src/modules/proposals/module.graphql.ts b/packages/services/api/src/modules/proposals/module.graphql.ts index e7a7d8947e..f9db32bbd5 100644 --- a/packages/services/api/src/modules/proposals/module.graphql.ts +++ b/packages/services/api/src/modules/proposals/module.graphql.ts @@ -790,7 +790,7 @@ export default gql` type TypeAdded { addedTypeName: String! - addedTypeKind: String + addedTypeKind: String! } type TypeKindChanged { diff --git a/packages/services/storage/package.json b/packages/services/storage/package.json index 0be5aecc22..d79c7303e1 100644 --- a/packages/services/storage/package.json +++ b/packages/services/storage/package.json @@ -16,7 +16,7 @@ "db:generate": "schemats generate --config schemats.cjs -o src/db/types.ts && prettier --write src/db/types.ts" }, "devDependencies": { - "@graphql-inspector/core": "5.1.0-alpha-20231208113249-34700c8a", + "@graphql-inspector/core": "file:../../../../graphql-inspector/packages/core", "@hive/service-common": "workspace:*", "@sentry/node": "7.120.2", "@sentry/types": "7.120.2", diff --git a/packages/services/storage/src/schema-change-meta.ts b/packages/services/storage/src/schema-change-meta.ts index 9562ea45f6..f88968514a 100644 --- a/packages/services/storage/src/schema-change-meta.ts +++ b/packages/services/storage/src/schema-change-meta.ts @@ -13,6 +13,20 @@ import { directiveLocationAddedFromMeta, directiveLocationRemovedFromMeta, directiveRemovedFromMeta, + directiveUsageArgumentAddedFromMeta, + directiveUsageArgumentRemovedFromMeta, + directiveUsageArgumentDefinitionAddedFromMeta, + directiveUsageArgumentDefinitionRemovedFromMeta, + directiveUsageInputFieldDefinitionAddedFromMeta, + directiveUsageInputObjectAddedFromMeta, + directiveUsageInterfaceAddedFromMeta, + directiveUsageObjectAddedFromMeta, + directiveUsageEnumAddedFromMeta, + directiveUsageFieldDefinitionAddedFromMeta, + directiveUsageUnionMemberAddedFromMeta, + directiveUsageEnumValueAddedFromMeta, + directiveUsageSchemaAddedFromMeta, + directiveUsageScalarAddedFromMeta, enumValueAddedFromMeta, enumValueDeprecationReasonAddedFromMeta, enumValueDeprecationReasonChangedFromMeta, @@ -95,7 +109,7 @@ export type RegistryServiceUrlChangeChange = RegistryServiceUrlChangeSerializabl */ export function schemaChangeFromSerializableChange( change: SerializableChange, -): Change | RegistryServiceUrlChangeChange { +): Change | RegistryServiceUrlChangeChange | null { switch (change.type) { case ChangeType.FieldArgumentDescriptionChanged: return fieldArgumentDescriptionChangedFromMeta(change); @@ -201,8 +215,39 @@ export function schemaChangeFromSerializableChange( return unionMemberRemovedFromMeta(change); case ChangeType.UnionMemberAdded: return buildUnionMemberAddedMessageFromMeta(change); + case ChangeType.DirectiveUsageArgumentDefinitionAdded: + return directiveUsageArgumentDefinitionAddedFromMeta(change); + case ChangeType.DirectiveUsageArgumentDefinitionRemoved: + return directiveUsageArgumentDefinitionRemovedFromMeta(change); + case ChangeType.DirectiveUsageInputFieldDefinitionAdded: + return directiveUsageInputFieldDefinitionAddedFromMeta(change); + case ChangeType.DirectiveUsageInputObjectAdded: + return directiveUsageInputObjectAddedFromMeta(change); + case ChangeType.DirectiveUsageInterfaceAdded: + return directiveUsageInterfaceAddedFromMeta(change); + case ChangeType.DirectiveUsageObjectAdded: + return directiveUsageObjectAddedFromMeta(change); + case ChangeType.DirectiveUsageEnumAdded: + return directiveUsageEnumAddedFromMeta(change); + case ChangeType.DirectiveUsageFieldDefinitionAdded: + return directiveUsageFieldDefinitionAddedFromMeta(change); + case ChangeType.DirectiveUsageUnionMemberAdded: + return directiveUsageUnionMemberAddedFromMeta(change); + case ChangeType.DirectiveUsageEnumValueAdded: + return directiveUsageEnumValueAddedFromMeta(change); + case ChangeType.DirectiveUsageSchemaAdded: + return directiveUsageSchemaAddedFromMeta(change); + case ChangeType.DirectiveUsageScalarAdded: + return directiveUsageScalarAddedFromMeta(change); + case ChangeType.DirectiveUsageArgumentAdded: + return directiveUsageArgumentAddedFromMeta(change); + case ChangeType.DirectiveUsageArgumentRemoved: + return directiveUsageArgumentRemovedFromMeta(change); case 'REGISTRY_SERVICE_URL_CHANGED': return buildRegistryServiceURLFromMeta(change); + default: + // @todo unhandled change + return null; } } diff --git a/packages/services/storage/src/schema-change-model.ts b/packages/services/storage/src/schema-change-model.ts index 5fa4d484a8..bdde0d961a 100644 --- a/packages/services/storage/src/schema-change-model.ts +++ b/packages/services/storage/src/schema-change-model.ts @@ -5,6 +5,7 @@ import { SerializableValue } from 'slonik'; import { z } from 'zod'; import { ChangeType, + TypeOfChangeType, CriticalityLevel, DirectiveAddedChange, DirectiveArgumentAddedChange, @@ -66,109 +67,109 @@ import { } from './schema-change-meta'; // prettier-ignore -const FieldArgumentDescriptionChangedLiteral = z.literal("FIELD_ARGUMENT_DESCRIPTION_CHANGED" satisfies `${ChangeType.FieldArgumentDescriptionChanged}`) +const FieldArgumentDescriptionChangedLiteral = z.literal("FIELD_ARGUMENT_DESCRIPTION_CHANGED" satisfies `${typeof ChangeType.FieldArgumentDescriptionChanged}`) // prettier-ignore -const FieldArgumentDefaultChangedLiteral = z.literal("FIELD_ARGUMENT_DEFAULT_CHANGED" satisfies `${ChangeType.FieldArgumentDefaultChanged}`) +const FieldArgumentDefaultChangedLiteral = z.literal("FIELD_ARGUMENT_DEFAULT_CHANGED" satisfies `${typeof ChangeType.FieldArgumentDefaultChanged}`) // prettier-ignore -const FieldArgumentTypeChangedLiteral = z.literal("FIELD_ARGUMENT_TYPE_CHANGED" satisfies `${ChangeType.FieldArgumentTypeChanged}`) +const FieldArgumentTypeChangedLiteral = z.literal("FIELD_ARGUMENT_TYPE_CHANGED" satisfies `${typeof ChangeType.FieldArgumentTypeChanged}`) // prettier-ignore -const DirectiveRemovedLiteral = z.literal("DIRECTIVE_REMOVED" satisfies `${ChangeType.DirectiveRemoved}`) +const DirectiveRemovedLiteral = z.literal("DIRECTIVE_REMOVED" satisfies `${typeof ChangeType.DirectiveRemoved}`) // prettier-ignore -const DirectiveAddedLiteral = z.literal("DIRECTIVE_ADDED" satisfies `${ChangeType.DirectiveAdded}`) +const DirectiveAddedLiteral = z.literal("DIRECTIVE_ADDED" satisfies `${typeof ChangeType.DirectiveAdded}`) // prettier-ignore -const DirectiveDescriptionChangedLiteral = z.literal("DIRECTIVE_DESCRIPTION_CHANGED" satisfies `${ChangeType.DirectiveDescriptionChanged}`) +const DirectiveDescriptionChangedLiteral = z.literal("DIRECTIVE_DESCRIPTION_CHANGED" satisfies `${typeof ChangeType.DirectiveDescriptionChanged}`) // prettier-ignore -const DirectiveLocationAddedLiteral = z.literal("DIRECTIVE_LOCATION_ADDED" satisfies `${ChangeType.DirectiveLocationAdded}`) +const DirectiveLocationAddedLiteral = z.literal("DIRECTIVE_LOCATION_ADDED" satisfies `${typeof ChangeType.DirectiveLocationAdded}`) // prettier-ignore -const DirectiveLocationRemovedLiteral = z.literal("DIRECTIVE_LOCATION_REMOVED" satisfies `${ChangeType.DirectiveLocationRemoved}`) +const DirectiveLocationRemovedLiteral = z.literal("DIRECTIVE_LOCATION_REMOVED" satisfies `${typeof ChangeType.DirectiveLocationRemoved}`) // prettier-ignore -const DirectiveArgumentAddedLiteral = z.literal("DIRECTIVE_ARGUMENT_ADDED" satisfies `${ChangeType.DirectiveArgumentAdded}`) +const DirectiveArgumentAddedLiteral = z.literal("DIRECTIVE_ARGUMENT_ADDED" satisfies `${typeof ChangeType.DirectiveArgumentAdded}`) // prettier-ignore -const DirectiveArgumentRemovedLiteral = z.literal("DIRECTIVE_ARGUMENT_REMOVED" satisfies `${ChangeType.DirectiveArgumentRemoved}`) +const DirectiveArgumentRemovedLiteral = z.literal("DIRECTIVE_ARGUMENT_REMOVED" satisfies `${typeof ChangeType.DirectiveArgumentRemoved}`) // prettier-ignore -const DirectiveArgumentDescriptionChangedLiteral = z.literal("DIRECTIVE_ARGUMENT_DESCRIPTION_CHANGED" satisfies `${ChangeType.DirectiveArgumentDescriptionChanged}`) +const DirectiveArgumentDescriptionChangedLiteral = z.literal("DIRECTIVE_ARGUMENT_DESCRIPTION_CHANGED" satisfies `${typeof ChangeType.DirectiveArgumentDescriptionChanged}`) // prettier-ignore -const DirectiveArgumentDefaultValueChangedLiteral = z.literal("DIRECTIVE_ARGUMENT_DEFAULT_VALUE_CHANGED" satisfies `${ChangeType.DirectiveArgumentDefaultValueChanged}`) +const DirectiveArgumentDefaultValueChangedLiteral = z.literal("DIRECTIVE_ARGUMENT_DEFAULT_VALUE_CHANGED" satisfies `${typeof ChangeType.DirectiveArgumentDefaultValueChanged}`) // prettier-ignore -const DirectiveArgumentTypeChangedLiteral = z.literal("DIRECTIVE_ARGUMENT_TYPE_CHANGED" satisfies `${ChangeType.DirectiveArgumentTypeChanged}`) +const DirectiveArgumentTypeChangedLiteral = z.literal("DIRECTIVE_ARGUMENT_TYPE_CHANGED" satisfies `${typeof ChangeType.DirectiveArgumentTypeChanged}`) // prettier-ignore -const EnumValueRemovedLiteral = z.literal("ENUM_VALUE_REMOVED" satisfies `${ChangeType.EnumValueRemoved}`) +const EnumValueRemovedLiteral = z.literal("ENUM_VALUE_REMOVED" satisfies `${typeof ChangeType.EnumValueRemoved}`) // prettier-ignore -const EnumValueAddedLiteral = z.literal("ENUM_VALUE_ADDED" satisfies `${ChangeType.EnumValueAdded}`) +const EnumValueAddedLiteral = z.literal("ENUM_VALUE_ADDED" satisfies `${typeof ChangeType.EnumValueAdded}`) // prettier-ignore -const EnumValueDescriptionChangedLiteral = z.literal("ENUM_VALUE_DESCRIPTION_CHANGED" satisfies `${ChangeType.EnumValueDescriptionChanged}`) +const EnumValueDescriptionChangedLiteral = z.literal("ENUM_VALUE_DESCRIPTION_CHANGED" satisfies `${typeof ChangeType.EnumValueDescriptionChanged}`) // prettier-ignore -const EnumValueDeprecationReasonChangedLiteral = z.literal("ENUM_VALUE_DEPRECATION_REASON_CHANGED" satisfies `${ChangeType.EnumValueDeprecationReasonChanged}`) +const EnumValueDeprecationReasonChangedLiteral = z.literal("ENUM_VALUE_DEPRECATION_REASON_CHANGED" satisfies `${typeof ChangeType.EnumValueDeprecationReasonChanged}`) // prettier-ignore -const EnumValueDeprecationReasonAddedLiteral = z.literal("ENUM_VALUE_DEPRECATION_REASON_ADDED" satisfies `${ChangeType.EnumValueDeprecationReasonAdded}`) +const EnumValueDeprecationReasonAddedLiteral = z.literal("ENUM_VALUE_DEPRECATION_REASON_ADDED" satisfies `${typeof ChangeType.EnumValueDeprecationReasonAdded}`) // prettier-ignore -const EnumValueDeprecationReasonRemovedLiteral = z.literal("ENUM_VALUE_DEPRECATION_REASON_REMOVED" satisfies `${ChangeType.EnumValueDeprecationReasonRemoved}`) +const EnumValueDeprecationReasonRemovedLiteral = z.literal("ENUM_VALUE_DEPRECATION_REASON_REMOVED" satisfies `${typeof ChangeType.EnumValueDeprecationReasonRemoved}`) // prettier-ignore -const FieldRemovedLiteral = z.literal("FIELD_REMOVED" satisfies `${ChangeType.FieldRemoved}`) +const FieldRemovedLiteral = z.literal("FIELD_REMOVED" satisfies `${typeof ChangeType.FieldRemoved}`) // prettier-ignore -const FieldAddedLiteral = z.literal("FIELD_ADDED" satisfies `${ChangeType.FieldAdded}`) +const FieldAddedLiteral = z.literal("FIELD_ADDED" satisfies `${typeof ChangeType.FieldAdded}`) // prettier-ignore -const FieldDescriptionChangedLiteral = z.literal("FIELD_DESCRIPTION_CHANGED" satisfies `${ChangeType.FieldDescriptionChanged}`) +const FieldDescriptionChangedLiteral = z.literal("FIELD_DESCRIPTION_CHANGED" satisfies `${typeof ChangeType.FieldDescriptionChanged}`) // prettier-ignore -const FieldDescriptionAddedLiteral = z.literal("FIELD_DESCRIPTION_ADDED" satisfies `${ChangeType.FieldDescriptionAdded}`) +const FieldDescriptionAddedLiteral = z.literal("FIELD_DESCRIPTION_ADDED" satisfies `${typeof ChangeType.FieldDescriptionAdded}`) // prettier-ignore -const FieldDescriptionRemovedLiteral = z.literal("FIELD_DESCRIPTION_REMOVED" satisfies `${ChangeType.FieldDescriptionRemoved}`) +const FieldDescriptionRemovedLiteral = z.literal("FIELD_DESCRIPTION_REMOVED" satisfies `${typeof ChangeType.FieldDescriptionRemoved}`) // prettier-ignore -const FieldDeprecationAddedLiteral = z.literal("FIELD_DEPRECATION_ADDED" satisfies `${ChangeType.FieldDeprecationAdded}`) +const FieldDeprecationAddedLiteral = z.literal("FIELD_DEPRECATION_ADDED" satisfies `${typeof ChangeType.FieldDeprecationAdded}`) // prettier-ignore -const FieldDeprecationRemovedLiteral = z.literal("FIELD_DEPRECATION_REMOVED" satisfies `${ChangeType.FieldDeprecationRemoved}`) +const FieldDeprecationRemovedLiteral = z.literal("FIELD_DEPRECATION_REMOVED" satisfies `${typeof ChangeType.FieldDeprecationRemoved}`) // prettier-ignore -const FieldDeprecationReasonChangedLiteral = z.literal("FIELD_DEPRECATION_REASON_CHANGED" satisfies `${ChangeType.FieldDeprecationReasonChanged}`) +const FieldDeprecationReasonChangedLiteral = z.literal("FIELD_DEPRECATION_REASON_CHANGED" satisfies `${typeof ChangeType.FieldDeprecationReasonChanged}`) // prettier-ignore -const FieldDeprecationReasonAddedLiteral = z.literal("FIELD_DEPRECATION_REASON_ADDED" satisfies `${ChangeType.FieldDeprecationReasonAdded}`) +const FieldDeprecationReasonAddedLiteral = z.literal("FIELD_DEPRECATION_REASON_ADDED" satisfies `${typeof ChangeType.FieldDeprecationReasonAdded}`) // prettier-ignore -const FieldDeprecationReasonRemovedLiteral = z.literal("FIELD_DEPRECATION_REASON_REMOVED" satisfies `${ChangeType.FieldDeprecationReasonRemoved}`) +const FieldDeprecationReasonRemovedLiteral = z.literal("FIELD_DEPRECATION_REASON_REMOVED" satisfies `${typeof ChangeType.FieldDeprecationReasonRemoved}`) // prettier-ignore -const FieldTypeChangedLiteral = z.literal("FIELD_TYPE_CHANGED" satisfies `${ChangeType.FieldTypeChanged}`) +const FieldTypeChangedLiteral = z.literal("FIELD_TYPE_CHANGED" satisfies `${typeof ChangeType.FieldTypeChanged}`) // prettier-ignore -const FieldArgumentAddedLiteral = z.literal("FIELD_ARGUMENT_ADDED" satisfies `${ChangeType.FieldArgumentAdded}`) +const FieldArgumentAddedLiteral = z.literal("FIELD_ARGUMENT_ADDED" satisfies `${typeof ChangeType.FieldArgumentAdded}`) // prettier-ignore -const FieldArgumentRemovedLiteral = z.literal("FIELD_ARGUMENT_REMOVED" satisfies `${ChangeType.FieldArgumentRemoved}`) +const FieldArgumentRemovedLiteral = z.literal("FIELD_ARGUMENT_REMOVED" satisfies `${typeof ChangeType.FieldArgumentRemoved}`) // prettier-ignore -const InputFieldRemovedLiteral = z.literal("INPUT_FIELD_REMOVED" satisfies `${ChangeType.InputFieldRemoved}`) +const InputFieldRemovedLiteral = z.literal("INPUT_FIELD_REMOVED" satisfies `${typeof ChangeType.InputFieldRemoved}`) // prettier-ignore -const InputFieldAddedLiteral = z.literal("INPUT_FIELD_ADDED" satisfies `${ChangeType.InputFieldAdded}`) +const InputFieldAddedLiteral = z.literal("INPUT_FIELD_ADDED" satisfies `${typeof ChangeType.InputFieldAdded}`) // prettier-ignore -const InputFieldDescriptionAddedLiteral = z.literal("INPUT_FIELD_DESCRIPTION_ADDED" satisfies `${ChangeType.InputFieldDescriptionAdded}`) +const InputFieldDescriptionAddedLiteral = z.literal("INPUT_FIELD_DESCRIPTION_ADDED" satisfies `${typeof ChangeType.InputFieldDescriptionAdded}`) // prettier-ignore -const InputFieldDescriptionRemovedLiteral = z.literal("INPUT_FIELD_DESCRIPTION_REMOVED" satisfies `${ChangeType.InputFieldDescriptionRemoved}`) +const InputFieldDescriptionRemovedLiteral = z.literal("INPUT_FIELD_DESCRIPTION_REMOVED" satisfies `${typeof ChangeType.InputFieldDescriptionRemoved}`) // prettier-ignore -const InputFieldDescriptionChangedLiteral = z.literal("INPUT_FIELD_DESCRIPTION_CHANGED" satisfies `${ChangeType.InputFieldDescriptionChanged}`) +const InputFieldDescriptionChangedLiteral = z.literal("INPUT_FIELD_DESCRIPTION_CHANGED" satisfies `${typeof ChangeType.InputFieldDescriptionChanged}`) // prettier-ignore -const InputFieldDefaultValueChangedLiteral = z.literal("INPUT_FIELD_DEFAULT_VALUE_CHANGED" satisfies `${ChangeType.InputFieldDefaultValueChanged}`) +const InputFieldDefaultValueChangedLiteral = z.literal("INPUT_FIELD_DEFAULT_VALUE_CHANGED" satisfies `${typeof ChangeType.InputFieldDefaultValueChanged}`) // prettier-ignore -const InputFieldTypeChangedLiteral = z.literal("INPUT_FIELD_TYPE_CHANGED" satisfies `${ChangeType.InputFieldTypeChanged}`) +const InputFieldTypeChangedLiteral = z.literal("INPUT_FIELD_TYPE_CHANGED" satisfies `${typeof ChangeType.InputFieldTypeChanged}`) // prettier-ignore -const ObjectTypeInterfaceAddedLiteral = z.literal("OBJECT_TYPE_INTERFACE_ADDED" satisfies `${ChangeType.ObjectTypeInterfaceAdded}`) +const ObjectTypeInterfaceAddedLiteral = z.literal("OBJECT_TYPE_INTERFACE_ADDED" satisfies `${typeof ChangeType.ObjectTypeInterfaceAdded}`) // prettier-ignore -const ObjectTypeInterfaceRemovedLiteral = z.literal("OBJECT_TYPE_INTERFACE_REMOVED" satisfies `${ChangeType.ObjectTypeInterfaceRemoved}`) +const ObjectTypeInterfaceRemovedLiteral = z.literal("OBJECT_TYPE_INTERFACE_REMOVED" satisfies `${typeof ChangeType.ObjectTypeInterfaceRemoved}`) // prettier-ignore -const SchemaQueryTypeChangedLiteral = z.literal("SCHEMA_QUERY_TYPE_CHANGED" satisfies `${ChangeType.SchemaQueryTypeChanged}`) +const SchemaQueryTypeChangedLiteral = z.literal("SCHEMA_QUERY_TYPE_CHANGED" satisfies `${typeof ChangeType.SchemaQueryTypeChanged}`) // prettier-ignore -const SchemaMutationTypeChangedLiteral = z.literal("SCHEMA_MUTATION_TYPE_CHANGED" satisfies `${ChangeType.SchemaMutationTypeChanged}`) +const SchemaMutationTypeChangedLiteral = z.literal("SCHEMA_MUTATION_TYPE_CHANGED" satisfies `${typeof ChangeType.SchemaMutationTypeChanged}`) // prettier-ignore -const SchemaSubscriptionTypeChangedLiteral = z.literal("SCHEMA_SUBSCRIPTION_TYPE_CHANGED" satisfies `${ChangeType.SchemaSubscriptionTypeChanged}`) +const SchemaSubscriptionTypeChangedLiteral = z.literal("SCHEMA_SUBSCRIPTION_TYPE_CHANGED" satisfies `${typeof ChangeType.SchemaSubscriptionTypeChanged}`) // prettier-ignore -const TypeRemovedLiteral = z.literal("TYPE_REMOVED" satisfies `${ChangeType.TypeRemoved}`) +const TypeRemovedLiteral = z.literal("TYPE_REMOVED" satisfies `${typeof ChangeType.TypeRemoved}`) // prettier-ignore -const TypeAddedLiteral = z.literal("TYPE_ADDED" satisfies `${ChangeType.TypeAdded}`) +const TypeAddedLiteral = z.literal("TYPE_ADDED" satisfies `${typeof ChangeType.TypeAdded}`) // prettier-ignore -const TypeKindChangedLiteral = z.literal("TYPE_KIND_CHANGED" satisfies `${ChangeType.TypeKindChanged}`) +const TypeKindChangedLiteral = z.literal("TYPE_KIND_CHANGED" satisfies `${typeof ChangeType.TypeKindChanged}`) // prettier-ignore -const TypeDescriptionChangedLiteral = z.literal("TYPE_DESCRIPTION_CHANGED" satisfies `${ChangeType.TypeDescriptionChanged}`) +const TypeDescriptionChangedLiteral = z.literal("TYPE_DESCRIPTION_CHANGED" satisfies `${typeof ChangeType.TypeDescriptionChanged}`) // prettier-ignore -const TypeDescriptionRemovedLiteral = z.literal("TYPE_DESCRIPTION_REMOVED" satisfies `${ChangeType.TypeDescriptionRemoved}`) +const TypeDescriptionRemovedLiteral = z.literal("TYPE_DESCRIPTION_REMOVED" satisfies `${typeof ChangeType.TypeDescriptionRemoved}`) // prettier-ignore -const TypeDescriptionAddedLiteral = z.literal("TYPE_DESCRIPTION_ADDED" satisfies `${ChangeType.TypeDescriptionAdded}`) +const TypeDescriptionAddedLiteral = z.literal("TYPE_DESCRIPTION_ADDED" satisfies `${typeof ChangeType.TypeDescriptionAdded}`) // prettier-ignore -const UnionMemberRemovedLiteral = z.literal("UNION_MEMBER_REMOVED" satisfies `${ChangeType.UnionMemberRemoved}`) +const UnionMemberRemovedLiteral = z.literal("UNION_MEMBER_REMOVED" satisfies `${typeof ChangeType.UnionMemberRemoved}`) // prettier-ignore -const UnionMemberAddedLiteral = z.literal("UNION_MEMBER_ADDED" satisfies `${ChangeType.UnionMemberAdded}`) +const UnionMemberAddedLiteral = z.literal("UNION_MEMBER_ADDED" satisfies `${typeof ChangeType.UnionMemberAdded}`) /** * @source https://github.com/colinhacks/zod/issues/372#issuecomment-1280054492 @@ -180,7 +181,7 @@ type Implements = { : z.ZodOptionalType> : null extends Model[key] ? z.ZodNullableType> - : Model[key] extends ChangeType + : Model[key] extends TypeOfChangeType ? z.ZodLiteral<`${Model[key]}`> : z.ZodType; }; @@ -245,7 +246,11 @@ export const DirectiveAddedModel = implement().with({ type: DirectiveAddedLiteral, meta: z.object({ addedDirectiveName: z.string(), - }), + // for backwards compatibility + addedDirectiveRepeatable: z.boolean().default(false), // boolean; + addedDirectiveLocations: z.array(z.string()).default([]), // string[]; + addedDirectiveDescription: z.string().nullable().default(null), // string | null; + }) as any // @todo fix typing }); export const DirectiveDescriptionChangedModel = implement().with( @@ -281,8 +286,11 @@ export const DirectiveArgumentAddedModel = implement().with({ @@ -343,7 +351,9 @@ export const EnumValueAdded = implement().with({ meta: z.object({ enumName: z.string(), addedEnumValueName: z.string(), - }), + addedToNewType: z.boolean().default(false), // default for backwards compatibility + addedDirectiveDescription: z.string().nullable().optional(), + }) as any, // @todo fix typing complaint }); export const EnumValueDescriptionChangedModel = implement().with( @@ -406,8 +416,9 @@ export const FieldAddedModel = implement().with({ meta: z.object({ typeName: z.string(), addedFieldName: z.string(), + addedFieldReturnType: z.string().optional(), // optional for backwards compatibility typeType: z.string(), - }), + }) as any, // @todo fix typing }); export const FieldDescriptionChangedModel = implement().with({ @@ -442,7 +453,8 @@ export const FieldDeprecationAddedModel = implement meta: z.object({ typeName: z.string(), fieldName: z.string(), - }), + deprecationReason: z.string().optional(), // for backwards compatibility + }) as any, // @todo fix typing }); export const FieldDeprecationRemovedModel = implement().with({ @@ -504,7 +516,8 @@ export const FieldArgumentAddedModel = implement().wit addedArgumentType: z.string(), hasDefaultValue: z.boolean(), isAddedFieldArgumentBreaking: z.boolean(), - }), + addedToNewField: z.boolean().optional(), // for backwards compatibility + }) as any, // @todo fix typing }); export const FieldArgumentRemovedModel = implement().with({ @@ -535,7 +548,9 @@ export const InputFieldAddedModel = implement().with({ addedInputFieldName: z.string(), isAddedInputFieldTypeNullable: z.boolean(), addedInputFieldType: z.string(), - }), + addedToNewType: z.boolean().default(false), // default to make backwards compatible + addedFieldDefault: z.string().optional(), + }) as any, // @todo fix typing }); export const InputFieldDescriptionAddedModel = implement().with({ @@ -597,7 +612,8 @@ export const ObjectTypeInterfaceAddedModel = implement().with({ @@ -605,7 +621,8 @@ export const ObjectTypeInterfaceRemovedModel = implement().with({ type: TypeAddedLiteral, meta: z.object({ addedTypeName: z.string(), - }), + addedTypeKind: z.string().optional(), // optional for backwards compatibility + }) as any, // @todo fix typing }); export const TypeKindChangedModel = implement().with({ @@ -698,7 +716,8 @@ export const UnionMemberAddedModel = implement().with({ meta: z.object({ unionName: z.string(), addedUnionMemberTypeName: z.string(), - }), + addedToNewType: z.boolean().default(false), // default for backwards compatibility + }) as any, // @todo fix typing }); // Service Registry Url Change @@ -784,6 +803,7 @@ export const SchemaChangeModel = z.union([ RegistryServiceUrlChangeModel, ]); +// @todo figure out what this is doing... ({}) as SerializableChange satisfies z.TypeOf; export type Change = z.infer; @@ -878,7 +898,12 @@ export const HiveSchemaChangeModel = z } | null; readonly breakingChangeSchemaCoordinate: string | null; } => { - const change = schemaChangeFromSerializableChange(rawChange as any); + // @todo handle more change types... + console.error(JSON.stringify(rawChange)); + let change = schemaChangeFromSerializableChange(rawChange as any); + if (!change) { + throw new Error(`Cannot deserialize change "${rawChange.type}"`) + } /** The schema coordinate used for detecting whether something is a breaking change can be different based on the change type. */ let breakingChangeSchemaCoordinate: string | null = null; @@ -888,9 +913,9 @@ export const HiveSchemaChangeModel = z if ( isInputFieldAddedChange(rawChange) && - rawChange.meta.isAddedInputFieldTypeNullable === false + change.meta.isAddedInputFieldTypeNullable === false ) { - breakingChangeSchemaCoordinate = rawChange.meta.inputName; + breakingChangeSchemaCoordinate = change.meta.inputName; } } diff --git a/packages/web/app/package.json b/packages/web/app/package.json index f08245bc54..f2e86715e9 100644 --- a/packages/web/app/package.json +++ b/packages/web/app/package.json @@ -20,7 +20,7 @@ "@graphiql/react": "1.0.0-alpha.4", "@graphiql/toolkit": "0.9.1", "@graphql-codegen/client-preset-swc-plugin": "0.2.0", - "@graphql-inspector/core": "6.2.1", + "@graphql-inspector/core": "file:../../../../graphql-inspector/packages/core", "@graphql-inspector/patch": "file:../../../../graphql-inspector/packages/patch", "@graphql-tools/mock": "9.0.22", "@graphql-typed-document-node/core": "3.2.0", diff --git a/packages/web/app/src/components/proposal/index.tsx b/packages/web/app/src/components/proposal/index.tsx index 47445e588a..06ad3e9b87 100644 --- a/packages/web/app/src/components/proposal/index.tsx +++ b/packages/web/app/src/components/proposal/index.tsx @@ -113,7 +113,6 @@ export const ProposalOverview_ChangeFragment = graphql(/* GraphQL */ ` ... on EnumValueAdded { addedDirectiveDescription addedEnumValueName - addedToNewType enumName } ... on EnumValueDescriptionChanged { @@ -226,7 +225,6 @@ export const ProposalOverview_ChangeFragment = graphql(/* GraphQL */ ` addedFieldDefault addedInputFieldName addedInputFieldType - addedToNewType inputName } ... on InputFieldDescriptionAdded { @@ -259,7 +257,6 @@ export const ProposalOverview_ChangeFragment = graphql(/* GraphQL */ ` } ... on ObjectTypeInterfaceAdded { addedInterfaceName - addedToNewType objectTypeName } ... on ObjectTypeInterfaceRemoved { @@ -308,13 +305,11 @@ export const ProposalOverview_ChangeFragment = graphql(/* GraphQL */ ` unionName } ... on UnionMemberAdded { - addedToNewType addedUnionMemberTypeName unionName } ... on DirectiveUsageEnumAdded { addedDirectiveName - addedToNewType enumName } ... on DirectiveUsageEnumRemoved { @@ -323,7 +318,6 @@ export const ProposalOverview_ChangeFragment = graphql(/* GraphQL */ ` } ... on DirectiveUsageEnumValueAdded { addedDirectiveName - addedToNewType enumName enumValueName } @@ -342,12 +336,10 @@ export const ProposalOverview_ChangeFragment = graphql(/* GraphQL */ ` addedDirectiveName addedInputFieldName addedInputFieldType - addedToNewType inputObjectName } ... on DirectiveUsageInputFieldDefinitionAdded { addedDirectiveName - addedToNewType inputFieldName inputFieldType inputObjectName @@ -369,7 +361,6 @@ export const ProposalOverview_ChangeFragment = graphql(/* GraphQL */ ` } ... on DirectiveUsageScalarAdded { addedDirectiveName - addedToNewType scalarName } ... on DirectiveUsageScalarRemoved { @@ -378,7 +369,6 @@ export const ProposalOverview_ChangeFragment = graphql(/* GraphQL */ ` } ... on DirectiveUsageObjectAdded { addedDirectiveName - addedToNewType objectName } ... on DirectiveUsageObjectRemoved { @@ -387,12 +377,10 @@ export const ProposalOverview_ChangeFragment = graphql(/* GraphQL */ ` } ... on DirectiveUsageInterfaceAdded { addedDirectiveName - addedToNewType interfaceName } ... on DirectiveUsageSchemaAdded { addedDirectiveName - addedToNewType schemaTypeName } ... on DirectiveUsageSchemaRemoved { @@ -401,7 +389,6 @@ export const ProposalOverview_ChangeFragment = graphql(/* GraphQL */ ` } ... on DirectiveUsageFieldDefinitionAdded { addedDirectiveName - addedToNewType fieldName typeName } @@ -422,7 +409,6 @@ export const ProposalOverview_ChangeFragment = graphql(/* GraphQL */ ` } ... on DirectiveUsageArgumentDefinitionAdded { addedDirectiveName - addedToNewType argumentName fieldName typeName diff --git a/packages/web/app/src/components/proposal/schema-diff/components.tsx b/packages/web/app/src/components/proposal/schema-diff/components.tsx index 8648b9d53c..25434079d0 100644 --- a/packages/web/app/src/components/proposal/schema-diff/components.tsx +++ b/packages/web/app/src/components/proposal/schema-diff/components.tsx @@ -191,7 +191,7 @@ function Removal(props: { children: ReactNode | string; className?: string }): R return ( @@ -202,7 +202,7 @@ function Removal(props: { children: ReactNode | string; className?: string }): R function Addition(props: { children: ReactNode; className?: string }): ReactNode { return ( - + {props.children} ); diff --git a/packages/web/app/src/components/proposal/schema-diff/schema-diff.tsx b/packages/web/app/src/components/proposal/schema-diff/schema-diff.tsx index 15f1468709..757692f9f7 100644 --- a/packages/web/app/src/components/proposal/schema-diff/schema-diff.tsx +++ b/packages/web/app/src/components/proposal/schema-diff/schema-diff.tsx @@ -1,7 +1,7 @@ /* eslint-disable tailwindcss/no-custom-classname */ import { ReactElement, useMemo } from 'react'; import type { GraphQLSchema } from 'graphql'; -import { isIntrospectionType, isSpecifiedDirective } from 'graphql'; +import { isIntrospectionType, isSpecifiedDirective, printSchema } from 'graphql'; import { isPrimitive } from '@graphql-inspector/core/utils/graphql'; import { compareLists } from './compare-lists'; import { ChangeDocument, DiffDirective, DiffType, SchemaDefinitionDiff } from './components'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 15b6d5af5f..d223696385 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -99,7 +99,7 @@ importers: version: 5.0.3(graphql@16.9.0) '@graphql-codegen/cli': specifier: 5.0.5 - version: 5.0.5(@babel/core@7.22.9)(@parcel/watcher@2.5.0)(@types/node@22.10.5)(encoding@0.1.13)(enquirer@2.4.1)(graphql@16.9.0)(typescript@5.7.3) + version: 5.0.5(@babel/core@7.26.0)(@parcel/watcher@2.5.0)(@types/node@22.10.5)(encoding@0.1.13)(enquirer@2.4.1)(graphql@16.9.0)(typescript@5.7.3) '@graphql-codegen/client-preset': specifier: 4.7.0 version: 4.7.0(encoding@0.1.13)(graphql@16.9.0) @@ -117,16 +117,16 @@ importers: version: 3.0.0(graphql@16.9.0) '@graphql-eslint/eslint-plugin': specifier: 3.20.1 - version: 3.20.1(patch_hash=695fba67df25ba9d46472c8398c94c6a2ccf75d902321d8f95150f68e940313e)(@babel/core@7.22.9)(@types/node@22.10.5)(encoding@0.1.13)(graphql@16.9.0) + version: 3.20.1(patch_hash=695fba67df25ba9d46472c8398c94c6a2ccf75d902321d8f95150f68e940313e)(@babel/core@7.26.0)(@types/node@22.10.5)(encoding@0.1.13)(graphql@16.9.0) '@graphql-inspector/cli': - specifier: 4.0.3 - version: 4.0.3(@types/node@22.10.5)(encoding@0.1.13)(graphql@16.9.0) + specifier: link:../graphql-inspector/packages/cli + version: link:../graphql-inspector/packages/cli '@graphql-inspector/core': - specifier: ^6.0.0 - version: 6.2.1(graphql@16.9.0) + specifier: file:../graphql-inspector/packages/core + version: file:../graphql-inspector/packages/core(graphql@16.9.0) '@graphql-inspector/patch': - specifier: link:../graphql-inspector/packages/patch - version: link:../graphql-inspector/packages/patch + specifier: file:../graphql-inspector/packages/patch + version: file:../graphql-inspector/packages/patch(graphql@16.9.0) '@manypkg/get-packages': specifier: 2.2.2 version: 2.2.2 @@ -425,8 +425,8 @@ importers: specifier: workspace:* version: link:../core/dist '@graphql-inspector/core': - specifier: 5.1.0-alpha-20231208113249-34700c8a - version: 5.1.0-alpha-20231208113249-34700c8a(graphql@16.9.0) + specifier: file:../../../../graphql-inspector/packages/core + version: file:../graphql-inspector/packages/core(graphql@16.9.0) '@graphql-tools/code-file-loader': specifier: ~8.1.0 version: 8.1.0(graphql@16.9.0) @@ -703,8 +703,8 @@ importers: specifier: 1.0.0 version: 1.0.0 '@graphql-inspector/core': - specifier: 5.1.0-alpha-20231208113249-34700c8a - version: 5.1.0-alpha-20231208113249-34700c8a(graphql@16.9.0) + specifier: file:../../../../graphql-inspector/packages/core + version: file:../graphql-inspector/packages/core(graphql@16.9.0) '@graphql-tools/merge': specifier: 9.0.24 version: 9.0.24(graphql@16.9.0) @@ -1407,8 +1407,8 @@ importers: packages/services/storage: devDependencies: '@graphql-inspector/core': - specifier: 5.1.0-alpha-20231208113249-34700c8a - version: 5.1.0-alpha-20231208113249-34700c8a(graphql@17.0.0-alpha.7) + specifier: file:../../../../graphql-inspector/packages/core + version: file:../graphql-inspector/packages/core(graphql@16.9.0) '@hive/service-common': specifier: workspace:* version: link:../service-common @@ -1689,8 +1689,8 @@ importers: specifier: 0.2.0 version: 0.2.0 '@graphql-inspector/core': - specifier: 6.2.1 - version: 6.2.1(graphql@16.9.0) + specifier: file:../../../../graphql-inspector/packages/core + version: file:../graphql-inspector/packages/core(graphql@16.9.0) '@graphql-inspector/patch': specifier: file:../../../../graphql-inspector/packages/patch version: file:../graphql-inspector/packages/patch(graphql@16.9.0) @@ -2065,13 +2065,13 @@ importers: version: 1.1.6(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) '@theguild/components': specifier: 9.7.1 - version: 9.7.1(@theguild/tailwind-config@0.6.3(postcss-import@16.1.0(postcss@8.4.49))(postcss-lightningcss@1.0.1(postcss@8.4.49))(tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.10.6(@swc/helpers@0.5.15))(@types/node@22.10.5)(typescript@5.7.3))))(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(immer@10.1.1)(next@15.2.4(@babel/core@7.22.9)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.7.3)(use-sync-external-store@1.5.0(react@19.0.0)) + version: 9.7.1(@theguild/tailwind-config@0.6.3(postcss-import@16.1.0(postcss@8.4.49))(postcss-lightningcss@1.0.1(postcss@8.4.49))(tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.10.6(@swc/helpers@0.5.15))(@types/node@22.10.5)(typescript@5.7.3))))(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(immer@10.1.1)(next@15.2.4(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.7.3)(use-sync-external-store@1.5.0(react@19.0.0)) date-fns: specifier: 4.1.0 version: 4.1.0 next: specifier: 15.2.4 - version: 15.2.4(@babel/core@7.22.9)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + version: 15.2.4(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) react: specifier: 19.0.0 version: 19.0.0 @@ -2111,7 +2111,7 @@ importers: version: 0.0.32 next-sitemap: specifier: 4.2.3 - version: 4.2.3(next@15.2.4(@babel/core@7.22.9)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)) + version: 4.2.3(next@15.2.4(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)) pagefind: specifier: ^1.2.0 version: 1.3.0 @@ -2680,10 +2680,6 @@ packages: resolution: {integrity: sha512-nHIxvKPniQXpmQLb0vhY3VaFb3S0YrTAwpOWJZh1wn3oJPjJk9Asva204PsBdmAE8vpzfHudT8DB0scYvy9q0g==} engines: {node: '>=6.9.0'} - '@babel/core@7.22.9': - resolution: {integrity: sha512-G2EgeufBcYw27U4hhoIwFcgc1XU7TlXJ3mv04oOv1WCuo900U/anZSPzEqNjwdjgffkk2Gs0AN0dW1CKVLcG7w==} - engines: {node: '>=6.9.0'} - '@babel/core@7.26.0': resolution: {integrity: sha512-i1SLeK+DzNnQ3LL/CswPCa/E5u4lh1k6IAEphON8F+cXt0t9euTshDru0q7/IqMa1PMPz5RnHuHscF8/ZJsStg==} engines: {node: '>=6.9.0'} @@ -3224,10 +3220,6 @@ packages: '@emotion/weak-memoize@0.3.0': resolution: {integrity: sha512-AHPmaAx+RYfZz0eYu6Gviiagpmiyw98ySSlQvCUhVGDRtDFe4DBS0x1bSjdF3gqUDYOczB+yYvBTtEylYSdRhg==} - '@envelop/core@4.0.3': - resolution: {integrity: sha512-O0Vz8E0TObT6ijAob8jYFVJavcGywKThM3UAsxUIBBVPYZTMiqI9lo2gmAnbMUnrDcAYkUTZEW9FDYPRdF5l6g==} - engines: {node: '>=16.0.0'} - '@envelop/core@5.0.2': resolution: {integrity: sha512-tVL6OrMe6UjqLosiE+EH9uxh2TQC0469GwF4tE014ugRaDDKKVWwFwZe0TBMlcyHKh5MD4ZxktWo/1hqUxIuhw==} engines: {node: '>=18.0.0'} @@ -3291,10 +3283,6 @@ packages: '@sentry/node': ^6 || ^7 graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 - '@envelop/types@4.0.1': - resolution: {integrity: sha512-ULo27/doEsP7uUhm2iTnElx13qTO6I5FKvmLoX41cpfuw8x6e0NUFknoqhEsLzAbgz8xVS5mjwcxGCXh4lDYzg==} - engines: {node: '>=16.0.0'} - '@envelop/types@5.0.0': resolution: {integrity: sha512-IPjmgSc4KpQRlO4qbEDnBEixvtb06WDmjKfi/7fkZaryh5HuOmTtixe1EupQI5XfXO8joc3d27uUZ0QdC++euA==} engines: {node: '>=18.0.0'} @@ -3744,147 +3732,18 @@ packages: resolution: {integrity: sha512-RiwLMc89lTjvyLEivZ/qxAC5nBHoS2CtsWFSOsN35sxG9zoo5Z+JsFHM8MlvmO9yt+MJNIyC5MLE1rsbOphlag==} engines: {node: '>=18.0.0'} - '@graphql-inspector/audit-command@4.0.3': - resolution: {integrity: sha512-cm4EtieIp9PUSDBze+Sn5HHF80jDF9V7sYyXqFa7+Vtw4Jlet98Ig48dFVtoLuFCPtCv2eZ22I8JOkBKL5WgVA==} - engines: {node: '>=16.0.0'} - peerDependencies: - graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 - - '@graphql-inspector/cli@4.0.3': - resolution: {integrity: sha512-54pJ/SkFGz/qKEP2sjiCqxTG6QkWSzG4NdfKqhlinQstlyCgtbmgwgh90fUPoG6yOgZZhcsiSJXHkz255kRu4w==} - engines: {node: '>=16.0.0'} - hasBin: true - peerDependencies: - graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 - - '@graphql-inspector/code-loader@4.0.2': - resolution: {integrity: sha512-WNMorwDSknFFnv2GF3FvjuxQy1VMxIVMsTgL3E6amhzSPvKd3SPplw9rpqhQJX9jKXHvu70PgE2FvbPCTapE5A==} - engines: {node: '>=16.0.0'} - peerDependencies: - graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 - - '@graphql-inspector/commands@4.0.3': - resolution: {integrity: sha512-68RO/nvt/9cKNmBpMCdExp1PkIeEdUpmv5WtcJwrIneTF/A9Cg45z2oDGiYr0CZgknprFgYQNVNFbYJV9AIamg==} - engines: {node: '>=16.0.0'} - peerDependencies: - '@graphql-inspector/config': 4.0.2 - '@graphql-inspector/loaders': 4.0.3 - graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 - yargs: 17.7.2 - - '@graphql-inspector/config@4.0.2': - resolution: {integrity: sha512-fnIwVpGM5AtTr4XyV8NJkDnwpXxZSBzi3BopjuXwBPXXD1F3tcVkCKNT6/5WgUQGfNPskBVbitcOPtM4hIYAOQ==} - engines: {node: '>=16.0.0'} - peerDependencies: - graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 - - '@graphql-inspector/core@5.0.2': - resolution: {integrity: sha512-pXHPCggwLmgi5NACPPV4qyf2xW/sQONnu6ZqCAid3k/S2APmVYN4Z3OvxvLA12NFhzby5Sz5K4fRsId43cK8ww==} - engines: {node: '>=16.0.0'} - peerDependencies: - graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 - - '@graphql-inspector/core@5.1.0-alpha-20231208113249-34700c8a': - resolution: {integrity: sha512-vzJEhQsZz+suo8T32o9dJNOa42IcLHfvmBm3EqRuMKQ0PU8KintUdRG1kFd6NFvNnpPHOVWFc+PYgFKCm7mPoA==} - engines: {node: '>=16.0.0'} - peerDependencies: - graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 - - '@graphql-inspector/core@6.2.1': - resolution: {integrity: sha512-PxL3fNblfKx/h/B4MIXN1yGHsGdY+uuySz8MAy/ogDk7eU1+va2zDZicLMEBHf7nsKfHWCAN1WFtD1GQP824NQ==} + '@graphql-inspector/core@file:../graphql-inspector/packages/core': + resolution: {directory: ../graphql-inspector/packages/core, type: directory} engines: {node: '>=18.0.0'} peerDependencies: graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 - '@graphql-inspector/coverage-command@5.0.3': - resolution: {integrity: sha512-LeAsn9+LjyxCzRnDvcfnQT6I0cI8UWnjPIxDkHNlkJLB0YWUTD1Z73fpRdw+l2kbYgeoMLFOK8TmilJjFN1+qQ==} - engines: {node: '>=16.0.0'} - peerDependencies: - graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 - - '@graphql-inspector/diff-command@4.0.3': - resolution: {integrity: sha512-9yDP4brhY44sgRMsy0hUJxoBLvem4J74XbQEU+87eecKDZ6SttiiadnQ6935SaQF/DOx0b6P1gH2phknDL/EDw==} - engines: {node: '>=16.0.0'} - peerDependencies: - graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 - - '@graphql-inspector/docs-command@4.0.3': - resolution: {integrity: sha512-69DPm0JpQPUfm1myNV6WqTuT9toG1fEwsheYCJf2Wn/P36HQ+neYTWbsXJF7EwSPzUsT5JWVxMyGg3RfX7IkOw==} - engines: {node: '>=16.0.0'} - peerDependencies: - graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 - - '@graphql-inspector/git-loader@4.0.2': - resolution: {integrity: sha512-RLDj4xZWpYt3gNgtu4hiAOTpabmBwcePLSs7Lem558ma7+Ud1tjRhKhVfY2/wIFGNgD7pTzVHvyB0Yc7bD6Wpg==} - engines: {node: '>=16.0.0'} - peerDependencies: - graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 - - '@graphql-inspector/github-loader@4.0.2': - resolution: {integrity: sha512-SrOUjz1RppK5Vxj8fMoCN+i+yvzIj1ZnP+mNdLZIsms9ou6u0+h8NuE/KfANR5Z1VJIQGIifMONIv2eTx4SjCQ==} - engines: {node: '>=16.0.0'} - peerDependencies: - graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 - - '@graphql-inspector/graphql-loader@4.0.2': - resolution: {integrity: sha512-cYBLE5LNQbnK3jOmi49IaaEcBjfLIYyGOY89ZH0O4kgOmKJ5WgbQCLK5xL6vCl2IXkCQy52EmN8KlVr1gVOnmw==} - engines: {node: '>=16.0.0'} - peerDependencies: - graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 - - '@graphql-inspector/introspect-command@4.0.3': - resolution: {integrity: sha512-43yJvNxGOTzoqZPGAeocC4qBSYAQ09f1QX8bzkinzWvGS8WViWGlYwN6Lxup41GOUNscl7EPp1UMl+IRU5Yu3Q==} - engines: {node: '>=16.0.0'} - peerDependencies: - graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 - - '@graphql-inspector/json-loader@4.0.2': - resolution: {integrity: sha512-cbSs/5Mzw0ltm9/SkDHKpq2u1yqEwxAuMLBs7uG+lqaFGgkIuzN9U6i4LRRYgBJcfn/mScodlvo0W0IG83Wn3g==} - engines: {node: '>=16.0.0'} - peerDependencies: - graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 - - '@graphql-inspector/loaders@4.0.3': - resolution: {integrity: sha512-V9T+IUJgmneWx1kEaSbFHLcjMs929TKLs6m3p544/CMPdFIsRYmYhd2jfUBhzXC8P6avnsLxxzVEOR45wJgOYA==} - engines: {node: '>=16.0.0'} - peerDependencies: - '@graphql-inspector/config': 4.0.2 - graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 - - '@graphql-inspector/logger@4.0.2': - resolution: {integrity: sha512-2VLU90HjQyhwFgiU5uBs1+sBjs71Q42MWBQnkc3GSAaVCE+bzYRurO4bRS/z7UXl0hJsJda41z5QPOFeyeMMwQ==} - engines: {node: '>=16.0.0'} - '@graphql-inspector/patch@file:../graphql-inspector/packages/patch': resolution: {directory: ../graphql-inspector/packages/patch, type: directory} engines: {node: '>=18.0.0'} peerDependencies: graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 - '@graphql-inspector/serve-command@4.0.3': - resolution: {integrity: sha512-+8vovcRBFjrzZ+E5QTsG8GXSHm5q2czuqHosAU8bx6tfv2NM3FoSHUkctZulubA0+rodt2xrsD1L3R0ZCR8TBQ==} - engines: {node: '>=16.0.0'} - peerDependencies: - graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 - - '@graphql-inspector/similar-command@4.0.3': - resolution: {integrity: sha512-qWJ+E0UFzba05ymwxu/M9Yb3J4GBj97T/NNEAaflgFaVjsiYuZEywfYekKMt1kALQOP8pufH0yDOYBHObKO9sQ==} - engines: {node: '>=16.0.0'} - peerDependencies: - graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 - - '@graphql-inspector/url-loader@4.0.2': - resolution: {integrity: sha512-bor9yZ6PIW8Fc5swo8t5TeexiQS8nRySF3oF4iG8d/aYPh0e7/DYM3lGF4M7VY3lH760r3caIRXXNOnn79tBrw==} - engines: {node: '>=16.0.0'} - peerDependencies: - graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 - - '@graphql-inspector/validate-command@4.0.3': - resolution: {integrity: sha512-wOcnkpU9zA30y1ggnJH1IvRDlEM24LP1J8/3tQx8rVn4zfBQpIVb7s31F/N++mZBAWXPTp2+t0JiUNbHR82odA==} - engines: {node: '>=16.0.0'} - peerDependencies: - graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 - '@graphql-tools/apollo-engine-loader@8.0.0': resolution: {integrity: sha512-axQTbN5+Yxs1rJ6cWQBOfw3AEeC+fvIuZSfJLPLLvFJLj4pUm9fhxey/g6oQZAAQJqKPfw+tLDUQvnfvRK8Kmg==} engines: {node: '>=16.0.0'} @@ -3925,12 +3784,6 @@ packages: peerDependencies: graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 - '@graphql-tools/code-file-loader@8.0.1': - resolution: {integrity: sha512-pmg81lsIXGW3uW+nFSCIG0lFQIxWVbgDjeBkSWlnP8CZsrHTQEkB53DT7t4BHLryoxDS4G4cPxM52yNINDSL8w==} - engines: {node: '>=16.0.0'} - peerDependencies: - graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 - '@graphql-tools/code-file-loader@8.1.0': resolution: {integrity: sha512-HKWW/B2z15ves8N9+xnVbGmFEVGyHEK80a4ghrjeTa6nwNZaKDVfq5CoYFfF0xpfjtH6gOVUExo2XCOEz4B8mQ==} engines: {node: '>=16.0.0'} @@ -4164,12 +4017,6 @@ packages: peerDependencies: graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 - '@graphql-tools/utils@10.0.3': - resolution: {integrity: sha512-6uO41urAEIs4sXQT2+CYGsUTkHkVo/2MpM/QjoHj6D6xoEF2woXHBpdAVi0HKIInDwZqWgEYOwIFez0pERxa1Q==} - engines: {node: '>=16.0.0'} - peerDependencies: - graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 - '@graphql-tools/utils@10.5.6': resolution: {integrity: sha512-JAC44rhbLzXUHiltceyEpWkxmX4e45Dfg19wRFoA9EbDxQVbOzVNF76eEECdg0J1owFsJwfLqCwz7/6xzrovOw==} engines: {node: '>=16.0.0'} @@ -4215,10 +4062,6 @@ packages: peerDependencies: graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 - '@graphql-yoga/logger@1.0.0': - resolution: {integrity: sha512-JYoxwnPggH2BfO+dWlWZkDeFhyFZqaTRGLvFhy+Pjp2UxitEW6nDrw+pEDw/K9tJwMjIFMmTT9VfTqrnESmBHg==} - engines: {node: '>=16.0.0'} - '@graphql-yoga/logger@2.0.1': resolution: {integrity: sha512-Nv0BoDGLMg9QBKy9cIswQ3/6aKaKjlTh87x3GiBg2Z4RrjyrM48DvOOK0pJh1C1At+b0mUIM67cwZcFTDLN4sA==} engines: {node: '>=18.0.0'} @@ -4272,18 +4115,10 @@ packages: peerDependencies: ioredis: ^5.0.6 - '@graphql-yoga/subscription@4.0.0': - resolution: {integrity: sha512-0qsN/BPPZNMoC2CZ8i+P6PgiJyHh1H35aKDt37qARBDaIOKDQuvEOq7+4txUKElcmXi7DYFo109FkhSQoEajrg==} - engines: {node: '>=16.0.0'} - '@graphql-yoga/subscription@5.0.4': resolution: {integrity: sha512-Bcj1LYVQyQmFN/vsl73TRSNistd8lBJJcPxqFVCT8diUuH/gTv2be2OYZsirpeO0l5tFjJWJv9RPgIlCYv3Khw==} engines: {node: '>=18.0.0'} - '@graphql-yoga/typed-event-target@2.0.0': - resolution: {integrity: sha512-oA/VGxGmaSDym1glOHrltw43qZsFwLLjBwvh57B79UKX/vo3+UQcRgOyE44c5RP7DCYjkrC2tuArZmb6jCzysw==} - engines: {node: '>=16.0.0'} - '@graphql-yoga/typed-event-target@3.0.2': resolution: {integrity: sha512-ZpJxMqB+Qfe3rp6uszCQoag4nSw42icURnBRfFYSOmTgEeOe4rD0vYlbA8spvCu2TlCesNTlEN9BLWtQqLxabA==} engines: {node: '>=18.0.0'} @@ -8238,10 +8073,6 @@ packages: resolution: {integrity: sha512-ydxzH1iox9AzLe+uaX9jjyVFkQO+h15j+JClropw0P4Vz+ES4+xTZVu5leUsWW8AYTVZBFkiC0iHl/PwFZ+Q1Q==} engines: {node: '>=18.0.0'} - '@whatwg-node/server@0.9.65': - resolution: {integrity: sha512-CnYTFEUJkbbAcuBXnXirVIgKBfs2YA6sSGjxeq07AUiyXuoQ0fbvTIQoteMglmn09QeGzcH/l0B7nIml83xvVw==} - engines: {node: '>=18.0.0'} - abbrev@1.1.1: resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==} @@ -10897,12 +10728,6 @@ packages: peerDependencies: graphql: '>=0.11 <=16' - graphql-yoga@4.0.3: - resolution: {integrity: sha512-MP+v+yxCqM3lXg95vaA+kXjyRvyRxHUlgZryTecN7ugzEEnyQKKu8JBXA4ziEuLi3liRriyjCAyV4pqFzhHujA==} - engines: {node: '>=16.0.0'} - peerDependencies: - graphql: ^15.2.0 || ^16.0.0 - graphql-yoga@5.13.3: resolution: {integrity: sha512-W8efVmPhKOreIJgiYbC2CCn60ORr7kj+5dRg7EoBg6+rbMdL4EqlXp1hYdrmbB5GGgS2g3ivyHutXKUK0S0UZw==} engines: {node: '>=18.0.0'} @@ -10913,10 +10738,6 @@ packages: resolution: {integrity: sha512-GGTKBX4SD7Wdb8mqeDLni2oaRGYQWjWHGKPQ24ZMnUtKfcsVoiv4uX8+LJr1K6U5VW2Lu1BwJnj7uiori0YtRw==} engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} - graphql@17.0.0-alpha.7: - resolution: {integrity: sha512-kdteHez9s0lfNAGntSwnDBpxSl09sBWEFxFRPS/Z8K1nCD4FZ2wVGwXuj5dvrTKcqOA+O8ujAJ3CiY/jXhs14g==} - engines: {node: ^16.19.0 || ^18.14.0 || >=19.7.0} - gray-matter@4.0.3: resolution: {integrity: sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==} engines: {node: '>=6.0'} @@ -12996,9 +12817,6 @@ packages: resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} engines: {node: '>= 6'} - object-inspect@1.12.3: - resolution: {integrity: sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g==} - object-inspect@1.13.2: resolution: {integrity: sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g==} engines: {node: '>= 0.4'} @@ -14787,9 +14605,6 @@ packages: resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} engines: {node: '>= 0.8'} - std-env@3.3.3: - resolution: {integrity: sha512-Rz6yejtVyWnVjC1RFvNmYL10kgjC49EOghxWn0RFqlCHGFpQx+Xe7yW3I4ceK1SGrWIGMjD5Kbue8W/udkbMJg==} - std-env@3.9.0: resolution: {integrity: sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==} @@ -16593,8 +16408,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-sts': 3.596.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/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 @@ -16701,11 +16516,11 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/client-sso-oidc@3.596.0(@aws-sdk/client-sts@3.596.0)': + '@aws-sdk/client-sso-oidc@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-sts': 3.596.0(@aws-sdk/client-sso-oidc@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 @@ -16744,7 +16559,6 @@ snapshots: '@smithy/util-utf8': 3.0.0 tslib: 2.8.1 transitivePeerDependencies: - - '@aws-sdk/client-sts' - aws-crt '@aws-sdk/client-sso-oidc@3.723.0(@aws-sdk/client-sts@3.723.0)': @@ -16878,11 +16692,11 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/client-sts@3.596.0': + '@aws-sdk/client-sts@3.596.0(@aws-sdk/client-sso-oidc@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-sts@3.596.0) + '@aws-sdk/client-sso-oidc': 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 @@ -16921,6 +16735,7 @@ snapshots: '@smithy/util-utf8': 3.0.0 tslib: 2.8.1 transitivePeerDependencies: + - '@aws-sdk/client-sso-oidc' - aws-crt '@aws-sdk/client-sts@3.723.0': @@ -17034,7 +16849,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-sts': 3.596.0(@aws-sdk/client-sso-oidc@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 @@ -17153,7 +16968,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-sts': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0) '@aws-sdk/types': 3.577.0 '@smithy/property-provider': 3.1.11 '@smithy/types': 3.7.2 @@ -17328,7 +17143,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-sts@3.596.0) + '@aws-sdk/client-sso-oidc': 3.596.0 '@aws-sdk/types': 3.577.0 '@smithy/property-provider': 3.1.11 '@smithy/shared-ini-file-loader': 3.1.12 @@ -17439,26 +17254,6 @@ snapshots: '@babel/compat-data@7.26.3': {} - '@babel/core@7.22.9': - dependencies: - '@ampproject/remapping': 2.3.0 - '@babel/code-frame': 7.26.2 - '@babel/generator': 7.26.3 - '@babel/helper-compilation-targets': 7.25.9 - '@babel/helper-module-transforms': 7.26.0(@babel/core@7.22.9) - '@babel/helpers': 7.26.10 - '@babel/parser': 7.26.3 - '@babel/template': 7.26.9 - '@babel/traverse': 7.26.4 - '@babel/types': 7.26.10 - convert-source-map: 1.9.0 - debug: 4.4.0(supports-color@8.1.1) - gensync: 1.0.0-beta.2 - json5: 2.2.3 - semver: 6.3.1 - transitivePeerDependencies: - - supports-color - '@babel/core@7.26.0': dependencies: '@ampproject/remapping': 2.3.0 @@ -17532,15 +17327,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.26.0(@babel/core@7.22.9)': - dependencies: - '@babel/core': 7.22.9 - '@babel/helper-module-imports': 7.25.9 - '@babel/helper-validator-identifier': 7.25.9 - '@babel/traverse': 7.26.4 - transitivePeerDependencies: - - supports-color - '@babel/helper-module-transforms@7.26.0(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 @@ -17622,11 +17408,6 @@ snapshots: '@babel/core': 7.26.0 '@babel/helper-plugin-utils': 7.25.9 - '@babel/plugin-syntax-import-assertions@7.24.1(@babel/core@7.22.9)': - dependencies: - '@babel/core': 7.22.9 - '@babel/helper-plugin-utils': 7.25.9 - '@babel/plugin-syntax-import-assertions@7.24.1(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 @@ -18208,11 +17989,6 @@ snapshots: '@emotion/weak-memoize@0.3.0': {} - '@envelop/core@4.0.3': - dependencies: - '@envelop/types': 4.0.1 - tslib: 2.8.1 - '@envelop/core@5.0.2': dependencies: '@envelop/types': 5.0.0 @@ -18287,10 +18063,6 @@ snapshots: graphql: 16.9.0 tslib: 2.8.1 - '@envelop/types@4.0.1': - dependencies: - tslib: 2.8.1 - '@envelop/types@5.0.0': dependencies: tslib: 2.8.1 @@ -18668,7 +18440,7 @@ snapshots: graphql: 16.9.0 tslib: 2.6.3 - '@graphql-codegen/cli@5.0.5(@babel/core@7.22.9)(@parcel/watcher@2.5.0)(@types/node@22.10.5)(encoding@0.1.13)(enquirer@2.4.1)(graphql@16.9.0)(typescript@5.7.3)': + '@graphql-codegen/cli@5.0.5(@babel/core@7.26.0)(@parcel/watcher@2.5.0)(@types/node@22.10.5)(encoding@0.1.13)(enquirer@2.4.1)(graphql@16.9.0)(typescript@5.7.3)': dependencies: '@babel/generator': 7.26.3 '@babel/template': 7.26.9 @@ -18678,7 +18450,7 @@ snapshots: '@graphql-codegen/plugin-helpers': 5.1.0(graphql@16.9.0) '@graphql-tools/apollo-engine-loader': 8.0.0(encoding@0.1.13)(graphql@16.9.0) '@graphql-tools/code-file-loader': 8.1.0(graphql@16.9.0) - '@graphql-tools/git-loader': 8.0.1(@babel/core@7.22.9)(graphql@16.9.0) + '@graphql-tools/git-loader': 8.0.1(@babel/core@7.26.0)(graphql@16.9.0) '@graphql-tools/github-loader': 8.0.0(@types/node@22.10.5)(encoding@0.1.13)(graphql@16.9.0) '@graphql-tools/graphql-file-loader': 8.0.0(graphql@16.9.0) '@graphql-tools/json-file-loader': 8.0.0(graphql@16.9.0) @@ -18861,29 +18633,6 @@ snapshots: - encoding - supports-color - '@graphql-eslint/eslint-plugin@3.20.1(patch_hash=695fba67df25ba9d46472c8398c94c6a2ccf75d902321d8f95150f68e940313e)(@babel/core@7.22.9)(@types/node@22.10.5)(encoding@0.1.13)(graphql@16.9.0)': - dependencies: - '@babel/code-frame': 7.26.2 - '@graphql-tools/code-file-loader': 7.3.23(@babel/core@7.22.9)(graphql@16.9.0) - '@graphql-tools/graphql-tag-pluck': 7.5.2(@babel/core@7.22.9)(graphql@16.9.0) - '@graphql-tools/utils': 9.2.1(graphql@16.9.0) - chalk: 4.1.2 - debug: 4.3.7(supports-color@8.1.1) - fast-glob: 3.3.2 - graphql: 16.9.0 - graphql-config: 4.5.0(@types/node@22.10.5)(encoding@0.1.13)(graphql@16.9.0) - graphql-depth-limit: 1.1.0(graphql@16.9.0) - lodash.lowercase: 4.3.0 - tslib: 2.8.1 - transitivePeerDependencies: - - '@babel/core' - - '@types/node' - - bufferutil - - cosmiconfig-toml-loader - - encoding - - supports-color - - utf-8-validate - '@graphql-eslint/eslint-plugin@3.20.1(patch_hash=695fba67df25ba9d46472c8398c94c6a2ccf75d902321d8f95150f68e940313e)(@babel/core@7.26.0)(@types/node@22.10.5)(encoding@0.1.13)(graphql@16.9.0)': dependencies: '@babel/code-frame': 7.26.2 @@ -18909,254 +18658,19 @@ snapshots: '@graphql-hive/signal@1.0.0': {} - '@graphql-inspector/audit-command@4.0.3(@graphql-inspector/config@4.0.2(graphql@16.9.0))(@graphql-inspector/loaders@4.0.3(@babel/core@7.22.9)(@graphql-inspector/config@4.0.2(graphql@16.9.0))(graphql@16.9.0))(graphql@16.9.0)(yargs@17.7.2)': - dependencies: - '@graphql-inspector/commands': 4.0.3(@graphql-inspector/config@4.0.2(graphql@16.9.0))(@graphql-inspector/loaders@4.0.3(@babel/core@7.22.9)(@graphql-inspector/config@4.0.2(graphql@16.9.0))(graphql@16.9.0))(graphql@16.9.0)(yargs@17.7.2) - '@graphql-inspector/core': 5.0.2(graphql@16.9.0) - '@graphql-inspector/logger': 4.0.2 - '@graphql-tools/utils': 10.0.3(graphql@16.9.0) - cli-table3: 0.6.3 - graphql: 16.9.0 - tslib: 2.6.2 - transitivePeerDependencies: - - '@graphql-inspector/config' - - '@graphql-inspector/loaders' - - yargs - - '@graphql-inspector/cli@4.0.3(@types/node@22.10.5)(encoding@0.1.13)(graphql@16.9.0)': - dependencies: - '@babel/core': 7.22.9 - '@graphql-inspector/audit-command': 4.0.3(@graphql-inspector/config@4.0.2(graphql@16.9.0))(@graphql-inspector/loaders@4.0.3(@babel/core@7.22.9)(@graphql-inspector/config@4.0.2(graphql@16.9.0))(graphql@16.9.0))(graphql@16.9.0)(yargs@17.7.2) - '@graphql-inspector/code-loader': 4.0.2(@babel/core@7.22.9)(graphql@16.9.0) - '@graphql-inspector/commands': 4.0.3(@graphql-inspector/config@4.0.2(graphql@16.9.0))(@graphql-inspector/loaders@4.0.3(@babel/core@7.22.9)(@graphql-inspector/config@4.0.2(graphql@16.9.0))(graphql@16.9.0))(graphql@16.9.0)(yargs@17.7.2) - '@graphql-inspector/config': 4.0.2(graphql@16.9.0) - '@graphql-inspector/coverage-command': 5.0.3(@graphql-inspector/config@4.0.2(graphql@16.9.0))(@graphql-inspector/loaders@4.0.3(@babel/core@7.22.9)(@graphql-inspector/config@4.0.2(graphql@16.9.0))(graphql@16.9.0))(graphql@16.9.0)(yargs@17.7.2) - '@graphql-inspector/diff-command': 4.0.3(@graphql-inspector/config@4.0.2(graphql@16.9.0))(@graphql-inspector/loaders@4.0.3(@babel/core@7.22.9)(@graphql-inspector/config@4.0.2(graphql@16.9.0))(graphql@16.9.0))(graphql@16.9.0)(yargs@17.7.2) - '@graphql-inspector/docs-command': 4.0.3(@graphql-inspector/config@4.0.2(graphql@16.9.0))(@graphql-inspector/loaders@4.0.3(@babel/core@7.22.9)(@graphql-inspector/config@4.0.2(graphql@16.9.0))(graphql@16.9.0))(graphql@16.9.0)(yargs@17.7.2) - '@graphql-inspector/git-loader': 4.0.2(@babel/core@7.22.9)(graphql@16.9.0) - '@graphql-inspector/github-loader': 4.0.2(@types/node@22.10.5)(encoding@0.1.13)(graphql@16.9.0) - '@graphql-inspector/graphql-loader': 4.0.2(graphql@16.9.0) - '@graphql-inspector/introspect-command': 4.0.3(@graphql-inspector/config@4.0.2(graphql@16.9.0))(@graphql-inspector/loaders@4.0.3(@babel/core@7.22.9)(@graphql-inspector/config@4.0.2(graphql@16.9.0))(graphql@16.9.0))(graphql@16.9.0)(yargs@17.7.2) - '@graphql-inspector/json-loader': 4.0.2(graphql@16.9.0) - '@graphql-inspector/loaders': 4.0.3(@babel/core@7.22.9)(@graphql-inspector/config@4.0.2(graphql@16.9.0))(graphql@16.9.0) - '@graphql-inspector/serve-command': 4.0.3(@graphql-inspector/config@4.0.2(graphql@16.9.0))(@graphql-inspector/loaders@4.0.3(@babel/core@7.22.9)(@graphql-inspector/config@4.0.2(graphql@16.9.0))(graphql@16.9.0))(graphql@16.9.0)(yargs@17.7.2) - '@graphql-inspector/similar-command': 4.0.3(@graphql-inspector/config@4.0.2(graphql@16.9.0))(@graphql-inspector/loaders@4.0.3(@babel/core@7.22.9)(@graphql-inspector/config@4.0.2(graphql@16.9.0))(graphql@16.9.0))(graphql@16.9.0)(yargs@17.7.2) - '@graphql-inspector/url-loader': 4.0.2(@types/node@22.10.5)(encoding@0.1.13)(graphql@16.9.0) - '@graphql-inspector/validate-command': 4.0.3(@graphql-inspector/config@4.0.2(graphql@16.9.0))(@graphql-inspector/loaders@4.0.3(@babel/core@7.22.9)(@graphql-inspector/config@4.0.2(graphql@16.9.0))(graphql@16.9.0))(graphql@16.9.0)(yargs@17.7.2) - graphql: 16.9.0 - tslib: 2.6.2 - yargs: 17.7.2 - transitivePeerDependencies: - - '@types/node' - - bufferutil - - encoding - - supports-color - - utf-8-validate - - '@graphql-inspector/code-loader@4.0.2(@babel/core@7.22.9)(graphql@16.9.0)': - dependencies: - '@graphql-tools/code-file-loader': 8.0.1(@babel/core@7.22.9)(graphql@16.9.0) - graphql: 16.9.0 - tslib: 2.6.2 - transitivePeerDependencies: - - '@babel/core' - - supports-color - - '@graphql-inspector/commands@4.0.3(@graphql-inspector/config@4.0.2(graphql@16.9.0))(@graphql-inspector/loaders@4.0.3(@babel/core@7.22.9)(@graphql-inspector/config@4.0.2(graphql@16.9.0))(graphql@16.9.0))(graphql@16.9.0)(yargs@17.7.2)': - dependencies: - '@graphql-inspector/config': 4.0.2(graphql@16.9.0) - '@graphql-inspector/loaders': 4.0.3(@babel/core@7.22.9)(@graphql-inspector/config@4.0.2(graphql@16.9.0))(graphql@16.9.0) - graphql: 16.9.0 - tslib: 2.6.2 - yargs: 17.7.2 - - '@graphql-inspector/config@4.0.2(graphql@16.9.0)': - dependencies: - graphql: 16.9.0 - tslib: 2.6.2 - - '@graphql-inspector/core@5.0.2(graphql@16.9.0)': - dependencies: - dependency-graph: 0.11.0 - graphql: 16.9.0 - object-inspect: 1.12.3 - tslib: 2.6.2 - - '@graphql-inspector/core@5.1.0-alpha-20231208113249-34700c8a(graphql@16.9.0)': - dependencies: - dependency-graph: 0.11.0 - graphql: 16.9.0 - object-inspect: 1.12.3 - tslib: 2.6.2 - - '@graphql-inspector/core@5.1.0-alpha-20231208113249-34700c8a(graphql@17.0.0-alpha.7)': - dependencies: - dependency-graph: 0.11.0 - graphql: 17.0.0-alpha.7 - object-inspect: 1.12.3 - tslib: 2.6.2 - - '@graphql-inspector/core@6.2.1(graphql@16.9.0)': + '@graphql-inspector/core@file:../graphql-inspector/packages/core(graphql@16.9.0)': dependencies: dependency-graph: 1.0.0 graphql: 16.9.0 object-inspect: 1.13.2 tslib: 2.6.2 - '@graphql-inspector/coverage-command@5.0.3(@graphql-inspector/config@4.0.2(graphql@16.9.0))(@graphql-inspector/loaders@4.0.3(@babel/core@7.22.9)(@graphql-inspector/config@4.0.2(graphql@16.9.0))(graphql@16.9.0))(graphql@16.9.0)(yargs@17.7.2)': - dependencies: - '@graphql-inspector/commands': 4.0.3(@graphql-inspector/config@4.0.2(graphql@16.9.0))(@graphql-inspector/loaders@4.0.3(@babel/core@7.22.9)(@graphql-inspector/config@4.0.2(graphql@16.9.0))(graphql@16.9.0))(graphql@16.9.0)(yargs@17.7.2) - '@graphql-inspector/core': 5.0.2(graphql@16.9.0) - '@graphql-inspector/logger': 4.0.2 - '@graphql-tools/utils': 10.0.3(graphql@16.9.0) - graphql: 16.9.0 - tslib: 2.6.2 - transitivePeerDependencies: - - '@graphql-inspector/config' - - '@graphql-inspector/loaders' - - yargs - - '@graphql-inspector/diff-command@4.0.3(@graphql-inspector/config@4.0.2(graphql@16.9.0))(@graphql-inspector/loaders@4.0.3(@babel/core@7.22.9)(@graphql-inspector/config@4.0.2(graphql@16.9.0))(graphql@16.9.0))(graphql@16.9.0)(yargs@17.7.2)': - dependencies: - '@graphql-inspector/commands': 4.0.3(@graphql-inspector/config@4.0.2(graphql@16.9.0))(@graphql-inspector/loaders@4.0.3(@babel/core@7.22.9)(@graphql-inspector/config@4.0.2(graphql@16.9.0))(graphql@16.9.0))(graphql@16.9.0)(yargs@17.7.2) - '@graphql-inspector/core': 5.0.2(graphql@16.9.0) - '@graphql-inspector/logger': 4.0.2 - graphql: 16.9.0 - tslib: 2.6.2 - transitivePeerDependencies: - - '@graphql-inspector/config' - - '@graphql-inspector/loaders' - - yargs - - '@graphql-inspector/docs-command@4.0.3(@graphql-inspector/config@4.0.2(graphql@16.9.0))(@graphql-inspector/loaders@4.0.3(@babel/core@7.22.9)(@graphql-inspector/config@4.0.2(graphql@16.9.0))(graphql@16.9.0))(graphql@16.9.0)(yargs@17.7.2)': - dependencies: - '@graphql-inspector/commands': 4.0.3(@graphql-inspector/config@4.0.2(graphql@16.9.0))(@graphql-inspector/loaders@4.0.3(@babel/core@7.22.9)(@graphql-inspector/config@4.0.2(graphql@16.9.0))(graphql@16.9.0))(graphql@16.9.0)(yargs@17.7.2) - graphql: 16.9.0 - open: 8.4.2 - tslib: 2.6.2 - transitivePeerDependencies: - - '@graphql-inspector/config' - - '@graphql-inspector/loaders' - - yargs - - '@graphql-inspector/git-loader@4.0.2(@babel/core@7.22.9)(graphql@16.9.0)': - dependencies: - '@graphql-tools/git-loader': 8.0.1(@babel/core@7.22.9)(graphql@16.9.0) - graphql: 16.9.0 - tslib: 2.6.2 - transitivePeerDependencies: - - '@babel/core' - - supports-color - - '@graphql-inspector/github-loader@4.0.2(@types/node@22.10.5)(encoding@0.1.13)(graphql@16.9.0)': - dependencies: - '@graphql-tools/github-loader': 8.0.0(@types/node@22.10.5)(encoding@0.1.13)(graphql@16.9.0) - graphql: 16.9.0 - tslib: 2.6.2 - transitivePeerDependencies: - - '@types/node' - - encoding - - supports-color - - '@graphql-inspector/graphql-loader@4.0.2(graphql@16.9.0)': - dependencies: - '@graphql-tools/graphql-file-loader': 8.0.0(graphql@16.9.0) - graphql: 16.9.0 - tslib: 2.6.2 - - '@graphql-inspector/introspect-command@4.0.3(@graphql-inspector/config@4.0.2(graphql@16.9.0))(@graphql-inspector/loaders@4.0.3(@babel/core@7.22.9)(@graphql-inspector/config@4.0.2(graphql@16.9.0))(graphql@16.9.0))(graphql@16.9.0)(yargs@17.7.2)': - dependencies: - '@graphql-inspector/commands': 4.0.3(@graphql-inspector/config@4.0.2(graphql@16.9.0))(@graphql-inspector/loaders@4.0.3(@babel/core@7.22.9)(@graphql-inspector/config@4.0.2(graphql@16.9.0))(graphql@16.9.0))(graphql@16.9.0)(yargs@17.7.2) - '@graphql-inspector/core': 5.0.2(graphql@16.9.0) - '@graphql-inspector/logger': 4.0.2 - graphql: 16.9.0 - tslib: 2.6.2 - transitivePeerDependencies: - - '@graphql-inspector/config' - - '@graphql-inspector/loaders' - - yargs - - '@graphql-inspector/json-loader@4.0.2(graphql@16.9.0)': - dependencies: - '@graphql-tools/json-file-loader': 8.0.0(graphql@16.9.0) - graphql: 16.9.0 - tslib: 2.6.2 - - '@graphql-inspector/loaders@4.0.3(@babel/core@7.22.9)(@graphql-inspector/config@4.0.2(graphql@16.9.0))(graphql@16.9.0)': - dependencies: - '@graphql-inspector/config': 4.0.2(graphql@16.9.0) - '@graphql-tools/code-file-loader': 8.0.1(@babel/core@7.22.9)(graphql@16.9.0) - '@graphql-tools/load': 8.0.0(graphql@16.9.0) - '@graphql-tools/utils': 10.0.3(graphql@16.9.0) - graphql: 16.9.0 - tslib: 2.6.2 - transitivePeerDependencies: - - '@babel/core' - - supports-color - - '@graphql-inspector/logger@4.0.2': - dependencies: - chalk: 4.1.2 - figures: 3.2.0 - log-symbols: 4.1.0 - std-env: 3.3.3 - tslib: 2.6.2 - '@graphql-inspector/patch@file:../graphql-inspector/packages/patch(graphql@16.9.0)': dependencies: '@graphql-tools/utils': 10.8.6(graphql@16.9.0) graphql: 16.9.0 tslib: 2.6.2 - '@graphql-inspector/serve-command@4.0.3(@graphql-inspector/config@4.0.2(graphql@16.9.0))(@graphql-inspector/loaders@4.0.3(@babel/core@7.22.9)(@graphql-inspector/config@4.0.2(graphql@16.9.0))(graphql@16.9.0))(graphql@16.9.0)(yargs@17.7.2)': - dependencies: - '@graphql-inspector/commands': 4.0.3(@graphql-inspector/config@4.0.2(graphql@16.9.0))(@graphql-inspector/loaders@4.0.3(@babel/core@7.22.9)(@graphql-inspector/config@4.0.2(graphql@16.9.0))(graphql@16.9.0))(graphql@16.9.0)(yargs@17.7.2) - '@graphql-inspector/logger': 4.0.2 - graphql: 16.9.0 - graphql-yoga: 4.0.3(graphql@16.9.0) - open: 8.4.2 - tslib: 2.6.2 - transitivePeerDependencies: - - '@graphql-inspector/config' - - '@graphql-inspector/loaders' - - yargs - - '@graphql-inspector/similar-command@4.0.3(@graphql-inspector/config@4.0.2(graphql@16.9.0))(@graphql-inspector/loaders@4.0.3(@babel/core@7.22.9)(@graphql-inspector/config@4.0.2(graphql@16.9.0))(graphql@16.9.0))(graphql@16.9.0)(yargs@17.7.2)': - dependencies: - '@graphql-inspector/commands': 4.0.3(@graphql-inspector/config@4.0.2(graphql@16.9.0))(@graphql-inspector/loaders@4.0.3(@babel/core@7.22.9)(@graphql-inspector/config@4.0.2(graphql@16.9.0))(graphql@16.9.0))(graphql@16.9.0)(yargs@17.7.2) - '@graphql-inspector/core': 5.0.2(graphql@16.9.0) - '@graphql-inspector/logger': 4.0.2 - graphql: 16.9.0 - tslib: 2.6.2 - transitivePeerDependencies: - - '@graphql-inspector/config' - - '@graphql-inspector/loaders' - - yargs - - '@graphql-inspector/url-loader@4.0.2(@types/node@22.10.5)(encoding@0.1.13)(graphql@16.9.0)': - dependencies: - '@graphql-tools/url-loader': 8.0.0(@types/node@22.10.5)(encoding@0.1.13)(graphql@16.9.0) - graphql: 16.9.0 - tslib: 2.6.2 - transitivePeerDependencies: - - '@types/node' - - bufferutil - - encoding - - utf-8-validate - - '@graphql-inspector/validate-command@4.0.3(@graphql-inspector/config@4.0.2(graphql@16.9.0))(@graphql-inspector/loaders@4.0.3(@babel/core@7.22.9)(@graphql-inspector/config@4.0.2(graphql@16.9.0))(graphql@16.9.0))(graphql@16.9.0)(yargs@17.7.2)': - dependencies: - '@graphql-inspector/commands': 4.0.3(@graphql-inspector/config@4.0.2(graphql@16.9.0))(@graphql-inspector/loaders@4.0.3(@babel/core@7.22.9)(@graphql-inspector/config@4.0.2(graphql@16.9.0))(graphql@16.9.0))(graphql@16.9.0)(yargs@17.7.2) - '@graphql-inspector/core': 5.0.2(graphql@16.9.0) - '@graphql-inspector/logger': 4.0.2 - '@graphql-tools/utils': 10.0.3(graphql@16.9.0) - graphql: 16.9.0 - tslib: 2.6.2 - transitivePeerDependencies: - - '@graphql-inspector/config' - - '@graphql-inspector/loaders' - - yargs - '@graphql-tools/apollo-engine-loader@8.0.0(encoding@0.1.13)(graphql@16.9.0)': dependencies: '@ardatan/sync-fetch': 0.0.1(encoding@0.1.13) @@ -19207,18 +18721,6 @@ snapshots: tslib: 2.8.1 value-or-promise: 1.0.12 - '@graphql-tools/code-file-loader@7.3.23(@babel/core@7.22.9)(graphql@16.9.0)': - dependencies: - '@graphql-tools/graphql-tag-pluck': 7.5.2(@babel/core@7.22.9)(graphql@16.9.0) - '@graphql-tools/utils': 9.2.1(graphql@16.9.0) - globby: 11.1.0 - graphql: 16.9.0 - tslib: 2.8.1 - unixify: 1.0.0 - transitivePeerDependencies: - - '@babel/core' - - supports-color - '@graphql-tools/code-file-loader@7.3.23(@babel/core@7.26.0)(graphql@16.9.0)': dependencies: '@graphql-tools/graphql-tag-pluck': 7.5.2(@babel/core@7.26.0)(graphql@16.9.0) @@ -19231,18 +18733,6 @@ snapshots: - '@babel/core' - supports-color - '@graphql-tools/code-file-loader@8.0.1(@babel/core@7.22.9)(graphql@16.9.0)': - dependencies: - '@graphql-tools/graphql-tag-pluck': 8.0.1(@babel/core@7.22.9)(graphql@16.9.0) - '@graphql-tools/utils': 10.8.6(graphql@16.9.0) - globby: 11.1.0 - graphql: 16.9.0 - tslib: 2.8.1 - unixify: 1.0.0 - transitivePeerDependencies: - - '@babel/core' - - supports-color - '@graphql-tools/code-file-loader@8.1.0(graphql@16.9.0)': dependencies: '@graphql-tools/graphql-tag-pluck': 8.2.0(graphql@16.9.0) @@ -19427,9 +18917,9 @@ snapshots: graphql: 16.9.0 tslib: 2.8.1 - '@graphql-tools/git-loader@8.0.1(@babel/core@7.22.9)(graphql@16.9.0)': + '@graphql-tools/git-loader@8.0.1(@babel/core@7.26.0)(graphql@16.9.0)': dependencies: - '@graphql-tools/graphql-tag-pluck': 8.0.1(@babel/core@7.22.9)(graphql@16.9.0) + '@graphql-tools/graphql-tag-pluck': 8.0.1(@babel/core@7.26.0)(graphql@16.9.0) '@graphql-tools/utils': 10.8.6(graphql@16.9.0) graphql: 16.9.0 is-glob: 4.0.3 @@ -19473,19 +18963,6 @@ snapshots: tslib: 2.8.1 unixify: 1.0.0 - '@graphql-tools/graphql-tag-pluck@7.5.2(@babel/core@7.22.9)(graphql@16.9.0)': - dependencies: - '@babel/parser': 7.26.3 - '@babel/plugin-syntax-import-assertions': 7.24.1(@babel/core@7.22.9) - '@babel/traverse': 7.26.4 - '@babel/types': 7.26.3 - '@graphql-tools/utils': 9.2.1(graphql@16.9.0) - graphql: 16.9.0 - tslib: 2.8.1 - transitivePeerDependencies: - - '@babel/core' - - supports-color - '@graphql-tools/graphql-tag-pluck@7.5.2(@babel/core@7.26.0)(graphql@16.9.0)': dependencies: '@babel/parser': 7.26.3 @@ -19499,10 +18976,10 @@ snapshots: - '@babel/core' - supports-color - '@graphql-tools/graphql-tag-pluck@8.0.1(@babel/core@7.22.9)(graphql@16.9.0)': + '@graphql-tools/graphql-tag-pluck@8.0.1(@babel/core@7.26.0)(graphql@16.9.0)': dependencies: '@babel/parser': 7.26.10 - '@babel/plugin-syntax-import-assertions': 7.24.1(@babel/core@7.22.9) + '@babel/plugin-syntax-import-assertions': 7.24.1(@babel/core@7.26.0) '@babel/traverse': 7.26.4 '@babel/types': 7.26.10 '@graphql-tools/utils': 10.8.6(graphql@16.9.0) @@ -19713,13 +19190,6 @@ snapshots: - encoding - utf-8-validate - '@graphql-tools/utils@10.0.3(graphql@16.9.0)': - dependencies: - '@graphql-typed-document-node/core': 3.2.0(graphql@16.9.0) - dset: 3.1.4 - graphql: 16.9.0 - tslib: 2.8.1 - '@graphql-tools/utils@10.5.6(graphql@16.9.0)': dependencies: '@graphql-typed-document-node/core': 3.2.0(graphql@16.9.0) @@ -19782,10 +19252,6 @@ snapshots: dependencies: graphql: 16.9.0 - '@graphql-yoga/logger@1.0.0': - dependencies: - tslib: 2.8.1 - '@graphql-yoga/logger@2.0.1': dependencies: tslib: 2.8.1 @@ -19835,13 +19301,6 @@ snapshots: '@whatwg-node/events': 0.1.1 ioredis: 5.4.2 - '@graphql-yoga/subscription@4.0.0': - dependencies: - '@graphql-yoga/typed-event-target': 2.0.0 - '@repeaterjs/repeater': 3.0.6 - '@whatwg-node/events': 0.1.1 - tslib: 2.8.1 - '@graphql-yoga/subscription@5.0.4': dependencies: '@graphql-yoga/typed-event-target': 3.0.2 @@ -19849,11 +19308,6 @@ snapshots: '@whatwg-node/events': 0.1.1 tslib: 2.8.1 - '@graphql-yoga/typed-event-target@2.0.0': - dependencies: - '@repeaterjs/repeater': 3.0.6 - tslib: 2.8.1 - '@graphql-yoga/typed-event-target@3.0.2': dependencies: '@repeaterjs/repeater': 3.0.6 @@ -24271,7 +23725,7 @@ snapshots: typescript: 4.9.5 yargs: 16.2.0 - '@theguild/components@9.7.1(@theguild/tailwind-config@0.6.3(postcss-import@16.1.0(postcss@8.4.49))(postcss-lightningcss@1.0.1(postcss@8.4.49))(tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.10.6(@swc/helpers@0.5.15))(@types/node@22.10.5)(typescript@5.7.3))))(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(immer@10.1.1)(next@15.2.4(@babel/core@7.22.9)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.7.3)(use-sync-external-store@1.5.0(react@19.0.0))': + '@theguild/components@9.7.1(@theguild/tailwind-config@0.6.3(postcss-import@16.1.0(postcss@8.4.49))(postcss-lightningcss@1.0.1(postcss@8.4.49))(tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.10.6(@swc/helpers@0.5.15))(@types/node@22.10.5)(typescript@5.7.3))))(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(immer@10.1.1)(next@15.2.4(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.7.3)(use-sync-external-store@1.5.0(react@19.0.0))': dependencies: '@giscus/react': 3.1.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0) '@next/bundle-analyzer': 15.1.5 @@ -24281,9 +23735,9 @@ snapshots: '@theguild/tailwind-config': 0.6.3(postcss-import@16.1.0(postcss@8.4.49))(postcss-lightningcss@1.0.1(postcss@8.4.49))(tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.10.6(@swc/helpers@0.5.15))(@types/node@22.10.5)(typescript@5.7.3))) clsx: 2.1.1 fuzzy: 0.1.3 - next: 15.2.4(@babel/core@7.22.9)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) - nextra: 4.0.5(patch_hash=c1d11430a02e4d51d69b615df3f615fd6dfbccfd71b122bcf781a8a35208fbc1)(next@15.2.4(@babel/core@7.22.9)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.7.3) - nextra-theme-docs: 4.0.5(patch_hash=38956679ac61493f4dbc6862445316e9909dd989c221357f4b21ce70d8c8fd5b)(@types/react@18.3.18)(immer@10.1.1)(next@15.2.4(@babel/core@7.22.9)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(nextra@4.0.5(patch_hash=c1d11430a02e4d51d69b615df3f615fd6dfbccfd71b122bcf781a8a35208fbc1)(next@15.2.4(@babel/core@7.22.9)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.7.3))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(use-sync-external-store@1.5.0(react@19.0.0)) + next: 15.2.4(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + nextra: 4.0.5(patch_hash=c1d11430a02e4d51d69b615df3f615fd6dfbccfd71b122bcf781a8a35208fbc1)(next@15.2.4(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.7.3) + nextra-theme-docs: 4.0.5(patch_hash=38956679ac61493f4dbc6862445316e9909dd989c221357f4b21ce70d8c8fd5b)(@types/react@18.3.18)(immer@10.1.1)(next@15.2.4(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(nextra@4.0.5(patch_hash=c1d11430a02e4d51d69b615df3f615fd6dfbccfd71b122bcf781a8a35208fbc1)(next@15.2.4(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.7.3))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(use-sync-external-store@1.5.0(react@19.0.0)) react: 19.0.0 react-dom: 19.0.0(react@19.0.0) react-paginate: 8.2.0(react@19.0.0) @@ -25071,12 +24525,6 @@ snapshots: '@whatwg-node/promise-helpers': 1.3.1 tslib: 2.8.1 - '@whatwg-node/server@0.9.65': - dependencies: - '@whatwg-node/disposablestack': 0.0.5 - '@whatwg-node/fetch': 0.10.6 - tslib: 2.8.1 - abbrev@1.1.1: {} abbrev@2.0.0: {} @@ -28266,21 +27714,6 @@ snapshots: dependencies: graphql: 16.9.0 - graphql-yoga@4.0.3(graphql@16.9.0): - dependencies: - '@envelop/core': 4.0.3 - '@graphql-tools/executor': 1.3.12(graphql@16.9.0) - '@graphql-tools/schema': 10.0.23(graphql@16.9.0) - '@graphql-tools/utils': 10.8.6(graphql@16.9.0) - '@graphql-yoga/logger': 1.0.0 - '@graphql-yoga/subscription': 4.0.0 - '@whatwg-node/fetch': 0.9.22 - '@whatwg-node/server': 0.9.65 - dset: 3.1.4 - graphql: 16.9.0 - lru-cache: 10.2.0 - tslib: 2.8.1 - graphql-yoga@5.13.3(graphql@16.9.0): dependencies: '@envelop/core': 5.2.3 @@ -28300,8 +27733,6 @@ snapshots: graphql@16.9.0: {} - graphql@17.0.0-alpha.7: {} - gray-matter@4.0.3: dependencies: js-yaml: 3.14.1 @@ -30719,20 +30150,20 @@ snapshots: neoip@2.1.0: {} - next-sitemap@4.2.3(next@15.2.4(@babel/core@7.22.9)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)): + next-sitemap@4.2.3(next@15.2.4(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)): dependencies: '@corex/deepmerge': 4.0.43 '@next/env': 13.5.6 fast-glob: 3.3.2 minimist: 1.2.8 - next: 15.2.4(@babel/core@7.22.9)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + next: 15.2.4(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) next-themes@0.4.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0): dependencies: react: 19.0.0 react-dom: 19.0.0(react@19.0.0) - next@15.2.4(@babel/core@7.22.9)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0): + next@15.2.4(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0): dependencies: '@next/env': 15.2.4 '@swc/counter': 0.1.3 @@ -30742,7 +30173,7 @@ snapshots: postcss: 8.4.31 react: 19.0.0 react-dom: 19.0.0(react@19.0.0) - styled-jsx: 5.1.6(@babel/core@7.22.9)(react@19.0.0) + styled-jsx: 5.1.6(@babel/core@7.26.0)(react@19.0.0) optionalDependencies: '@next/swc-darwin-arm64': 15.2.4 '@next/swc-darwin-x64': 15.2.4 @@ -30758,13 +30189,13 @@ snapshots: - '@babel/core' - babel-plugin-macros - nextra-theme-docs@4.0.5(patch_hash=38956679ac61493f4dbc6862445316e9909dd989c221357f4b21ce70d8c8fd5b)(@types/react@18.3.18)(immer@10.1.1)(next@15.2.4(@babel/core@7.22.9)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(nextra@4.0.5(patch_hash=c1d11430a02e4d51d69b615df3f615fd6dfbccfd71b122bcf781a8a35208fbc1)(next@15.2.4(@babel/core@7.22.9)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.7.3))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(use-sync-external-store@1.5.0(react@19.0.0)): + nextra-theme-docs@4.0.5(patch_hash=38956679ac61493f4dbc6862445316e9909dd989c221357f4b21ce70d8c8fd5b)(@types/react@18.3.18)(immer@10.1.1)(next@15.2.4(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(nextra@4.0.5(patch_hash=c1d11430a02e4d51d69b615df3f615fd6dfbccfd71b122bcf781a8a35208fbc1)(next@15.2.4(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.7.3))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(use-sync-external-store@1.5.0(react@19.0.0)): dependencies: '@headlessui/react': 2.2.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0) clsx: 2.1.1 - next: 15.2.4(@babel/core@7.22.9)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + next: 15.2.4(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) next-themes: 0.4.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0) - nextra: 4.0.5(patch_hash=c1d11430a02e4d51d69b615df3f615fd6dfbccfd71b122bcf781a8a35208fbc1)(next@15.2.4(@babel/core@7.22.9)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.7.3) + nextra: 4.0.5(patch_hash=c1d11430a02e4d51d69b615df3f615fd6dfbccfd71b122bcf781a8a35208fbc1)(next@15.2.4(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.7.3) react: 19.0.0 react-compiler-runtime: 0.0.0-experimental-22c6e49-20241219(react@19.0.0) react-dom: 19.0.0(react@19.0.0) @@ -30777,7 +30208,7 @@ snapshots: - immer - use-sync-external-store - nextra@4.0.5(patch_hash=c1d11430a02e4d51d69b615df3f615fd6dfbccfd71b122bcf781a8a35208fbc1)(next@15.2.4(@babel/core@7.22.9)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.7.3): + nextra@4.0.5(patch_hash=c1d11430a02e4d51d69b615df3f615fd6dfbccfd71b122bcf781a8a35208fbc1)(next@15.2.4(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.7.3): dependencies: '@formatjs/intl-localematcher': 0.5.5 '@headlessui/react': 2.2.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0) @@ -30798,7 +30229,7 @@ snapshots: mdast-util-gfm: 3.0.0 mdast-util-to-hast: 13.2.0 negotiator: 1.0.0 - next: 15.2.4(@babel/core@7.22.9)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + next: 15.2.4(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) react: 19.0.0 react-compiler-runtime: 0.0.0-experimental-22c6e49-20241219(react@19.0.0) react-dom: 19.0.0(react@19.0.0) @@ -30992,8 +30423,6 @@ snapshots: object-hash@3.0.0: {} - object-inspect@1.12.3: {} - object-inspect@1.13.2: {} object-is@1.1.5: @@ -33037,8 +32466,6 @@ snapshots: statuses@2.0.1: {} - std-env@3.3.3: {} - std-env@3.9.0: {} stoppable@1.1.0: {} @@ -33177,12 +32604,12 @@ snapshots: dependencies: inline-style-parser: 0.2.3 - styled-jsx@5.1.6(@babel/core@7.22.9)(react@19.0.0): + styled-jsx@5.1.6(@babel/core@7.26.0)(react@19.0.0): dependencies: client-only: 0.0.1 react: 19.0.0 optionalDependencies: - '@babel/core': 7.22.9 + '@babel/core': 7.26.0 stylis@4.1.3: {} From c3d167bdf857b71f3079d8dec38b4cafe6b3598d Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Mon, 1 Sep 2025 18:53:51 -0700 Subject: [PATCH 37/54] Remove log --- packages/services/storage/src/schema-change-model.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/services/storage/src/schema-change-model.ts b/packages/services/storage/src/schema-change-model.ts index bdde0d961a..d76379a9d9 100644 --- a/packages/services/storage/src/schema-change-model.ts +++ b/packages/services/storage/src/schema-change-model.ts @@ -898,9 +898,8 @@ export const HiveSchemaChangeModel = z } | null; readonly breakingChangeSchemaCoordinate: string | null; } => { - // @todo handle more change types... - console.error(JSON.stringify(rawChange)); let change = schemaChangeFromSerializableChange(rawChange as any); + // @todo figure out more permanent solution for unhandled change types. if (!change) { throw new Error(`Cannot deserialize change "${rawChange.type}"`) } From 5f4a894781ef510c9700e880b9ab3ace7a81f141 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Mon, 1 Sep 2025 21:41:08 -0700 Subject: [PATCH 38/54] add interface support on schemadiff; add shcema check list --- .../storage/src/schema-change-meta.ts | 12 +-- .../storage/src/schema-change-model.ts | 8 +- .../proposal/schema-diff/components.tsx | 48 ++++++++- .../app/src/pages/target-proposal-checks.tsx | 99 +++++++++++++++++++ .../app/src/pages/target-proposal-details.tsx | 2 +- .../web/app/src/pages/target-proposal.tsx | 37 ++++++- 6 files changed, 187 insertions(+), 19 deletions(-) create mode 100644 packages/web/app/src/pages/target-proposal-checks.tsx diff --git a/packages/services/storage/src/schema-change-meta.ts b/packages/services/storage/src/schema-change-meta.ts index f88968514a..5009472910 100644 --- a/packages/services/storage/src/schema-change-meta.ts +++ b/packages/services/storage/src/schema-change-meta.ts @@ -14,19 +14,19 @@ import { directiveLocationRemovedFromMeta, directiveRemovedFromMeta, directiveUsageArgumentAddedFromMeta, - directiveUsageArgumentRemovedFromMeta, directiveUsageArgumentDefinitionAddedFromMeta, directiveUsageArgumentDefinitionRemovedFromMeta, + directiveUsageArgumentRemovedFromMeta, + directiveUsageEnumAddedFromMeta, + directiveUsageEnumValueAddedFromMeta, + directiveUsageFieldDefinitionAddedFromMeta, directiveUsageInputFieldDefinitionAddedFromMeta, directiveUsageInputObjectAddedFromMeta, directiveUsageInterfaceAddedFromMeta, directiveUsageObjectAddedFromMeta, - directiveUsageEnumAddedFromMeta, - directiveUsageFieldDefinitionAddedFromMeta, - directiveUsageUnionMemberAddedFromMeta, - directiveUsageEnumValueAddedFromMeta, - directiveUsageSchemaAddedFromMeta, directiveUsageScalarAddedFromMeta, + directiveUsageSchemaAddedFromMeta, + directiveUsageUnionMemberAddedFromMeta, enumValueAddedFromMeta, enumValueDeprecationReasonAddedFromMeta, enumValueDeprecationReasonChangedFromMeta, diff --git a/packages/services/storage/src/schema-change-model.ts b/packages/services/storage/src/schema-change-model.ts index d76379a9d9..fddec40fa0 100644 --- a/packages/services/storage/src/schema-change-model.ts +++ b/packages/services/storage/src/schema-change-model.ts @@ -5,7 +5,6 @@ import { SerializableValue } from 'slonik'; import { z } from 'zod'; import { ChangeType, - TypeOfChangeType, CriticalityLevel, DirectiveAddedChange, DirectiveArgumentAddedChange, @@ -57,6 +56,7 @@ import { TypeDescriptionChangedChange, TypeDescriptionRemovedChange, TypeKindChangedChange, + TypeOfChangeType, TypeRemovedChange, UnionMemberAddedChange, UnionMemberRemovedChange, @@ -250,7 +250,7 @@ export const DirectiveAddedModel = implement().with({ addedDirectiveRepeatable: z.boolean().default(false), // boolean; addedDirectiveLocations: z.array(z.string()).default([]), // string[]; addedDirectiveDescription: z.string().nullable().default(null), // string | null; - }) as any // @todo fix typing + }) as any, // @todo fix typing }); export const DirectiveDescriptionChangedModel = implement().with( @@ -622,7 +622,7 @@ export const ObjectTypeInterfaceRemovedModel = implement - + )} {props.children} @@ -191,7 +191,7 @@ function Removal(props: { children: ReactNode | string; className?: string }): R return ( @@ -202,7 +202,7 @@ function Removal(props: { children: ReactNode | string; className?: string }): R function Addition(props: { children: ReactNode; className?: string }): ReactNode { return ( - + {props.children} ); @@ -1003,7 +1003,7 @@ export function DiffObject({ - +   @@ -1011,6 +1011,10 @@ export function DiffObject({ newDirectives={newObject?.astNode?.directives ?? []} oldDirectives={oldObject?.astNode?.directives ?? []} /> + {' {'} {removed.map(a => ( @@ -1260,6 +1264,42 @@ export function DiffScalar({ ); } +export function DiffInterfaces(props: { + oldInterfaces: readonly GraphQLInterfaceType[]; + newInterfaces: readonly GraphQLInterfaceType[]; +}) { + if (props.oldInterfaces.length + props.newInterfaces.length === 0) { + return null; + } + const { added, mutual, removed } = compareLists(props.oldInterfaces, props.newInterfaces); + + let implementsChangeType: 'mutual' | 'addition' | 'removal'; + if (props.oldInterfaces.length === 0 && props.newInterfaces.length !== 0) { + implementsChangeType = 'addition'; + } else if (props.oldInterfaces.length !== 0 && props.newInterfaces.length === 0) { + implementsChangeType = 'removal'; + } else { + implementsChangeType = 'mutual'; + } + const interfaces = [ + ...removed.map(r => {r.name}), + ...added.map(r => {r.name}), + ...mutual.map(({ newVersion: r }) => {r.name}), + ]; + return ( + <> +  implements  + {/* @todo wrap the ampersand in the appropriate change type */} + {interfaces.map((iface, index) => ( + <> + {iface} + {index !== interfaces.length - 1 && ' & '} + + ))} + + ); +} + export function DiffDirectiveUsages(props: { oldDirectives: readonly ConstDirectiveNode[]; newDirectives: readonly ConstDirectiveNode[]; diff --git a/packages/web/app/src/pages/target-proposal-checks.tsx b/packages/web/app/src/pages/target-proposal-checks.tsx new file mode 100644 index 0000000000..6b803ca67a --- /dev/null +++ b/packages/web/app/src/pages/target-proposal-checks.tsx @@ -0,0 +1,99 @@ +import { CalendarIcon, CheckIcon, XIcon } from '@/components/ui/icon'; +import { TimeAgo } from '@/components/v2'; +import { FragmentType, graphql, useFragment } from '@/gql'; +import { ComponentNoneIcon, CubeIcon } from '@radix-ui/react-icons'; +import { Link } from '@tanstack/react-router'; + +export const ProposalOverview_ChecksFragment = graphql(/* GraphQL */ ` + fragment ProposalOverview_ChecksFragment on SchemaCheckConnection { + pageInfo { + startCursor + } + edges { + cursor + node { + id + createdAt + serviceName + webUrl + hasSchemaCompositionErrors + hasUnapprovedBreakingChanges + hasSchemaChanges + } + } + } +`); + +export function TargetProposalChecksPage(props: { + organizationSlug: string; + projectSlug: string; + targetSlug: string; + proposalId: string; + checks: FragmentType | null; +}) { + const checks = useFragment(ProposalOverview_ChecksFragment, props.checks); + return ( +
+ {checks?.edges?.map(({ node }) => { + return ( + +
+ {node.serviceName && ( + <> + +
{node.serviceName}
+ + )} +
+
{node.id}
+
+
+ +
+
+ + +
+
+ + ); + })} +
+ ); +} + +function SchemaCheckIcon(props: { + hasSchemaCompositionErrors: boolean; + hasUnapprovedBreakingChanges: boolean; + hasSchemaChanges: boolean; +}) { + if (props.hasSchemaCompositionErrors || props.hasUnapprovedBreakingChanges) { + const issue = props.hasSchemaCompositionErrors ? 'COMPOSITION ERRORS' : 'BREAKING CHANGES'; + return ( +
+ {issue} +
+ ); + } + if (props.hasSchemaChanges) { + return ( +
+ OK +
+ ); + } + return ( +
+ NO CHANGE +
+ ); +} diff --git a/packages/web/app/src/pages/target-proposal-details.tsx b/packages/web/app/src/pages/target-proposal-details.tsx index dd909f430e..c6cc5200e5 100644 --- a/packages/web/app/src/pages/target-proposal-details.tsx +++ b/packages/web/app/src/pages/target-proposal-details.tsx @@ -90,7 +90,7 @@ export function TargetProposalDetailsPage(props: { return ( {serviceName.length !== 0 && ( - + <Title className="flex items-center text-xl"> <CubeIcon className="mr-2 h-6 w-auto flex-none" /> {serviceName} )} diff --git a/packages/web/app/src/pages/target-proposal.tsx b/packages/web/app/src/pages/target-proposal.tsx index 4ca0a452ac..7520ba0984 100644 --- a/packages/web/app/src/pages/target-proposal.tsx +++ b/packages/web/app/src/pages/target-proposal.tsx @@ -22,8 +22,12 @@ import { FragmentType, graphql, useFragment } from '@/gql'; import { Change } from '@graphql-inspector/core'; import { patchSchema } from '@graphql-inspector/patch'; import { NoopError } from '@graphql-inspector/patch/errors'; -import { ListBulletIcon } from '@radix-ui/react-icons'; +import { ListBulletIcon, PieChartIcon } from '@radix-ui/react-icons'; import { Link } from '@tanstack/react-router'; +import { + ProposalOverview_ChecksFragment, + TargetProposalChecksPage, +} from './target-proposal-checks'; import { TargetProposalDetailsPage } from './target-proposal-details'; import { TargetProposalEditPage } from './target-proposal-edit'; import { TargetProposalSchemaPage } from './target-proposal-schema'; @@ -34,6 +38,7 @@ enum Tab { SCHEMA = 'schema', SUPERGRAPH = 'supergraph', DETAILS = 'details', + CHECKS = 'checks', EDIT = 'edit', } @@ -47,6 +52,7 @@ const ProposalQuery = graphql(/* GraphQL */ ` description checks(after: null, input: {}) { ...ProposalQuery_VersionsListFragment + ...ProposalOverview_ChecksFragment } reviews { ...ProposalOverview_ReviewsFragment @@ -296,6 +302,7 @@ const ProposalsContent = (props: Parameters[0] page={props.tab} services={services ?? []} reviews={proposal?.reviews ?? {}} + checks={proposal?.checks ?? null} /> )}
@@ -311,6 +318,7 @@ function TabbedContent(props: { page?: string; services: ServiceProposalDetails[]; reviews: FragmentType; + checks: FragmentType | null; }) { return ( @@ -359,10 +367,26 @@ function TabbedContent(props: { search={{ page: 'supergraph' }} className="flex items-center" > - + Supergraph Preview + + + + Checks + + - - Edit + + Edit @@ -395,6 +419,11 @@ function TabbedContent(props: {
+ +
+ +
+
From 0298ac677ecc1c29f5444b42716c381f436b7ecc Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Mon, 1 Sep 2025 22:19:00 -0700 Subject: [PATCH 39/54] Service heading --- .../target/proposals/service-heading.tsx | 14 ++++++ .../app/src/pages/target-proposal-details.tsx | 49 +++++++++---------- .../app/src/pages/target-proposal-schema.tsx | 11 +++-- .../web/app/src/pages/target-proposal.tsx | 2 +- .../web/app/src/pages/target-proposals.tsx | 2 +- 5 files changed, 47 insertions(+), 31 deletions(-) create mode 100644 packages/web/app/src/components/target/proposals/service-heading.tsx diff --git a/packages/web/app/src/components/target/proposals/service-heading.tsx b/packages/web/app/src/components/target/proposals/service-heading.tsx new file mode 100644 index 0000000000..7662bb2892 --- /dev/null +++ b/packages/web/app/src/components/target/proposals/service-heading.tsx @@ -0,0 +1,14 @@ +import { Title } from '@/components/ui/page'; +import { CubeIcon } from '@radix-ui/react-icons'; + +export function ServiceHeading(props: { serviceName: string }) { + if (props.serviceName.length === 0) { + return null; + } + return ( + + <CubeIcon className="mr-2" /> + <span>{props.serviceName}</span> + + ); +} diff --git a/packages/web/app/src/pages/target-proposal-details.tsx b/packages/web/app/src/pages/target-proposal-details.tsx index c6cc5200e5..96b1c1e3cc 100644 --- a/packages/web/app/src/pages/target-proposal-details.tsx +++ b/packages/web/app/src/pages/target-proposal-details.tsx @@ -1,6 +1,7 @@ import { Fragment, ReactNode, useMemo } from 'react'; import { ProposalOverview_ReviewsFragment } from '@/components/proposal'; import { ProposalChangeDetail } from '@/components/target/proposals/change-detail'; +import { ServiceHeading } from '@/components/target/proposals/service-heading'; import { Button } from '@/components/ui/button'; import { Subtitle, Title } from '@/components/ui/page'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; @@ -89,31 +90,29 @@ export function TargetProposalDetailsPage(props: { } return ( - {serviceName.length !== 0 && ( - - <CubeIcon className="mr-2 h-6 w-auto flex-none" /> {serviceName} - - )} - - - - + +
+ + + + +
); })} diff --git a/packages/web/app/src/pages/target-proposal-schema.tsx b/packages/web/app/src/pages/target-proposal-schema.tsx index e63e661166..71801045b8 100644 --- a/packages/web/app/src/pages/target-proposal-schema.tsx +++ b/packages/web/app/src/pages/target-proposal-schema.tsx @@ -1,5 +1,5 @@ import { Proposal, ProposalOverview_ReviewsFragment } from '@/components/proposal'; -import { Subtitle } from '@/components/ui/page'; +import { ServiceHeading } from '@/components/target/proposals/service-heading'; import { FragmentType } from '@/gql'; import { ServiceProposalDetails } from './target-proposal-types'; @@ -14,9 +14,12 @@ export function TargetProposalSchemaPage(props: { if (props.services.length) { return (
- {props.services.map(proposed => { - return ; - })} + {props.services.map(proposed => ( + <> + + + + ))}
); } diff --git a/packages/web/app/src/pages/target-proposal.tsx b/packages/web/app/src/pages/target-proposal.tsx index 7520ba0984..ea2160a566 100644 --- a/packages/web/app/src/pages/target-proposal.tsx +++ b/packages/web/app/src/pages/target-proposal.tsx @@ -404,7 +404,7 @@ function TabbedContent(props: { - +
diff --git a/packages/web/app/src/pages/target-proposals.tsx b/packages/web/app/src/pages/target-proposals.tsx index 774ec292c7..2c2f4ef999 100644 --- a/packages/web/app/src/pages/target-proposals.tsx +++ b/packages/web/app/src/pages/target-proposals.tsx @@ -208,7 +208,7 @@ const ProposalsListPage = (props: {
- + {proposal.title} From 3d2c5f40e1ecc44d92d2f9e84a28ffbec4139c40 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Tue, 2 Sep 2025 17:52:33 -0700 Subject: [PATCH 40/54] Add proposalId to schema check command; fix latest filter sql logic; add stage transitioning; fix filtering by userId and stage --- .../cli/src/commands/schema/check.ts | 4 + .../2025.08.30T00-00-00.schema-proposals.ts | 1 + .../api/src/modules/proposals/index.ts | 1 + .../src/modules/proposals/module.graphql.ts | 8 +- .../providers/schema-proposal-manager.ts | 69 ++++++-- .../providers/schema-proposal-storage.ts | 153 ++++++++++++++---- .../Mutation/reviewSchemaProposal.ts | 29 +++- .../resolvers/Query/schemaProposals.ts | 3 +- .../proposals/resolvers/SchemaProposal.ts | 2 + .../api/src/modules/schema/module.graphql.ts | 5 + .../schema/providers/schema-manager.ts | 1 + .../schema/resolvers/Mutation/schemaCheck.ts | 1 + packages/services/storage/src/db/types.ts | 1 + packages/services/storage/src/index.ts | 10 +- .../proposal/schema-diff/components.tsx | 42 +++-- .../target/proposals/service-heading.tsx | 4 +- .../proposals/stage-transition-select.tsx | 20 ++- .../target/proposals/version-select.tsx | 17 +- .../app/src/pages/target-proposal-checks.tsx | 103 ++++++++---- .../web/app/src/pages/target-proposal.tsx | 43 +++-- .../web/app/src/pages/target-proposals.tsx | 5 +- 21 files changed, 401 insertions(+), 121 deletions(-) diff --git a/packages/libraries/cli/src/commands/schema/check.ts b/packages/libraries/cli/src/commands/schema/check.ts index e0462ce5bf..a544eb729e 100644 --- a/packages/libraries/cli/src/commands/schema/check.ts +++ b/packages/libraries/cli/src/commands/schema/check.ts @@ -150,6 +150,9 @@ export default class SchemaCheck extends Command { description: 'If checking a service, then you can optionally provide the service URL to see the difference in the supergraph during the check.', }), + schemaProposalId: Flags.string({ + description: 'Attach the schema check to a schema proposal.', + }), }; static args = { @@ -263,6 +266,7 @@ export default class SchemaCheck extends Command { contextId: flags.contextId ?? undefined, target, url: flags.url, + schemaProposalId: flags.schemaProposalId, }, }, }); diff --git a/packages/migrations/src/actions/2025.08.30T00-00-00.schema-proposals.ts b/packages/migrations/src/actions/2025.08.30T00-00-00.schema-proposals.ts index 49b1add4d6..4185c33f7c 100644 --- a/packages/migrations/src/actions/2025.08.30T00-00-00.schema-proposals.ts +++ b/packages/migrations/src/actions/2025.08.30T00-00-00.schema-proposals.ts @@ -81,6 +81,7 @@ export default { -- line can be provided. , schema_coordinate text , resolved_by_user_id UUID REFERENCES users (id) ON DELETE SET NULL + , service_name TEXT NOT NULL ) ; CREATE INDEX IF NOT EXISTS schema_proposal_reviews_schema_proposal_id ON schema_proposal_reviews( diff --git a/packages/services/api/src/modules/proposals/index.ts b/packages/services/api/src/modules/proposals/index.ts index 902865682b..658ea3a07e 100644 --- a/packages/services/api/src/modules/proposals/index.ts +++ b/packages/services/api/src/modules/proposals/index.ts @@ -5,6 +5,7 @@ import { models as schemaModels } from '../schema/providers/models'; import { CompositionOrchestrator } from '../schema/providers/orchestrator/composition-orchestrator'; import { RegistryChecks } from '../schema/providers/registry-checks'; import { SchemaPublisher } from '../schema/providers/schema-publisher'; +import { Storage } from '../shared/providers/storage'; import { SchemaProposalManager } from './providers/schema-proposal-manager'; import { SchemaProposalStorage } from './providers/schema-proposal-storage'; import { resolvers } from './resolvers.generated'; diff --git a/packages/services/api/src/modules/proposals/module.graphql.ts b/packages/services/api/src/modules/proposals/module.graphql.ts index f9db32bbd5..75a700e638 100644 --- a/packages/services/api/src/modules/proposals/module.graphql.ts +++ b/packages/services/api/src/modules/proposals/module.graphql.ts @@ -124,7 +124,13 @@ export default gql` """ The initial comment message attached to the review """ - commentBody: String = "" + commentBody: String! = "" + + """ + The service this review applies to. If the target is a monorepo, then use + an empty string. + """ + serviceName: String! } input CommentOnSchemaProposalReviewInput { diff --git a/packages/services/api/src/modules/proposals/providers/schema-proposal-manager.ts b/packages/services/api/src/modules/proposals/providers/schema-proposal-manager.ts index 659a39c1c7..d173395b75 100644 --- a/packages/services/api/src/modules/proposals/providers/schema-proposal-manager.ts +++ b/packages/services/api/src/modules/proposals/providers/schema-proposal-manager.ts @@ -3,12 +3,14 @@ */ import { Injectable, Scope } from 'graphql-modules'; import { TargetReferenceInput } from 'packages/libraries/core/src/client/__generated__/types'; +import { HiveError } from '@hive/api/shared/errors'; import { SchemaChangeType } from '@hive/storage'; import { SchemaProposalCheckInput, SchemaProposalStage } from '../../../__generated__/types'; import { Session } from '../../auth/lib/authz'; import { SchemaPublisher } from '../../schema/providers/schema-publisher'; import { IdTranslator } from '../../shared/providers/id-translator'; import { Logger } from '../../shared/providers/logger'; +import { Storage } from '../../shared/providers/storage'; import { SchemaProposalStorage } from './schema-proposal-storage'; @Injectable({ @@ -19,7 +21,8 @@ export class SchemaProposalManager { constructor( logger: Logger, - private storage: SchemaProposalStorage, + private proposalStorage: SchemaProposalStorage, + private storage: Storage, private session: Session, private idTranslator: IdTranslator, private schemaPublisher: SchemaPublisher, @@ -43,7 +46,7 @@ export class SchemaProposalManager { this.session.raise('schemaProposal:modify'); } - const createProposalResult = await this.storage.createProposal({ + const createProposalResult = await this.proposalStorage.createProposal({ organizationId: selector.organizationId, userId: args.user?.id ?? null, description: args.description, @@ -116,13 +119,19 @@ export class SchemaProposalManager { } async getProposal(args: { id: string }) { - return this.storage.getProposal(args); + return this.proposalStorage.getProposal(args); } - async getPaginatedReviews(args: { proposalId: string; first: number; after: string }) { + async getPaginatedReviews(args: { + proposalId: string; + first: number; + after: string; + stages: SchemaProposalStage[]; + authors: string[]; + }) { this.logger.debug('Get paginated reviews (target=%s, after=%s)', args.proposalId, args.after); - return this.storage.getPaginatedReviews(args); + return this.proposalStorage.getPaginatedReviews(args); } async getPaginatedProposals(args: { @@ -130,7 +139,7 @@ export class SchemaProposalManager { first: number; after: string; stages: ReadonlyArray; - users: string[]; + users: ReadonlyArray; }) { this.logger.debug( 'Get paginated proposals (target=%s, after=%s, stages=%s)', @@ -145,15 +154,55 @@ export class SchemaProposalManager { this.session.raise('schemaProposal:modify'); } - return this.storage.getPaginatedProposals({ + return this.proposalStorage.getPaginatedProposals({ targetId: selector.targetId, after: args.after, first: args.first, stages: args.stages, - users: [], + users: args.users, }); } - // @todo - async reviewProposal(args: { proposalId: string }) {} + async reviewProposal(args: { + proposalId: string; + stage: SchemaProposalStage | null; + body: string | null; + serviceName: string; + }) { + this.logger.debug(`Reviewing proposal (proposal=%s, stage=%s)`, args.proposalId, args.stage); + + // @todo check permissions for user + const proposal = await this.proposalStorage.getProposal({ id: args.proposalId }); + const user = await this.session.getViewer(); + const target = await this.storage.getTargetById(proposal.targetId); + + if (!target) { + throw new HiveError('Proposal target lookup failed.'); + } + + if (args.stage) { + const review = await this.proposalStorage.manuallyTransitionProposal({ + organizationId: target.orgId, + targetId: proposal.targetId, + id: args.proposalId, + stage: args.stage, + userId: user.id, + serviceName: args.serviceName, + }); + + if (review.type === 'error') { + return review; + } + + return { + ...review, + review: { + ...review.review, + author: user.displayName, + }, + }; + } + + throw new HiveError('Not implemented'); + } } diff --git a/packages/services/api/src/modules/proposals/providers/schema-proposal-storage.ts b/packages/services/api/src/modules/proposals/providers/schema-proposal-storage.ts index 5166a1e576..c9db2ee8be 100644 --- a/packages/services/api/src/modules/proposals/providers/schema-proposal-storage.ts +++ b/packages/services/api/src/modules/proposals/providers/schema-proposal-storage.ts @@ -69,6 +69,69 @@ export class SchemaProposalStorage { } } + async manuallyTransitionProposal(args: { + organizationId: string; + targetId: string; + id: string; + stage: SchemaProposalStage; + userId: string; + serviceName: string; + }) { + this.logger.debug( + 'manually transition schema (proposal=%s, stage=%s, userId=%s)', + args.id, + args.stage, + args.userId, + ); + + this.assertSchemaProposalsEnabled({ + organizationId: args.organizationId, + targetId: args.targetId, + proposalId: undefined, + }); + + const stageValidationResult = ManualTransitionStageModel.safeParse(args.stage); + if (stageValidationResult.error) { + return { + type: 'error' as const, + error: { + message: 'Invalid input', + details: { + stage: stageValidationResult.error?.issues[0].message ?? null, + }, + }, + }; + } + const review = await this.pool.transaction(async conn => { + await conn.maybeOne( + sql` + UPDATE "schema_proposals" + SET "stage" = ${args.stage} + WHERE "id" = ${args.id} AND "stage" <> 'IMPLEMENTED' + `, + ); + const row = await conn.maybeOne(sql` + INSERT INTO schema_proposal_reviews + ("schema_proposal_id", "stage_transition", "user_id", "service_name") + VALUES ( + ${args.id} + , ${args.stage} + , ${args.userId} + , ${args.serviceName} + ) + RETURNING ${schemaProposalReviewFields} + `); + return SchemaProposalReviewModel.parse(row); + }); + + console.log(JSON.stringify(review)); + + return { + type: 'ok' as const, + review, + }; + } + async createProposal(args: { organizationId: string; targetId: string; @@ -107,7 +170,7 @@ export class SchemaProposalStorage { const proposal = await this.pool .maybeOne( sql` - INSERT INTO "schema_proposals" + INSERT INTO "schema_proposals" as "sp" ("target_id", "title", "description", "stage", "user_id") VALUES ( @@ -135,10 +198,13 @@ export class SchemaProposalStorage { sql` SELECT ${schemaProposalFields} + , u."display_name" as "author" FROM - "schema_proposals" + "schema_proposals" AS "sp" + LEFT JOIN "users" AS "u" + ON "u"."id" = "sp"."user_id" WHERE - "id" = ${args.id} + "sp"."id" = ${args.id} LIMIT 1 `, ) @@ -152,7 +218,7 @@ export class SchemaProposalStorage { first: number; after: string; stages: ReadonlyArray; - users: string[]; + users: ReadonlyArray; }) { this.logger.debug( 'Get paginated proposals (target=%s, after=%s, stages=%s)', @@ -172,24 +238,45 @@ export class SchemaProposalStorage { const result = await this.pool.query(sql` SELECT ${schemaProposalFields} + , u."display_name" as "author" FROM - "schema_proposals" + "schema_proposals" as "sp" + LEFT JOIN "users" as "u" + ON u."id" = sp."user_id" WHERE - "target_id" = ${args.targetId} + sp."target_id" = ${args.targetId} ${ cursor ? sql` AND ( ( - "created_at" = ${cursor.createdAt} - AND "id" < ${cursor.id} + sp."created_at" = ${cursor.createdAt} + AND sp."id" < ${cursor.id} ) - OR "created_at" < ${cursor.createdAt} + OR sp."created_at" < ${cursor.createdAt} ) ` : sql`` } - ORDER BY "created_at" DESC, "id" + ${ + args.stages.length > 0 + ? sql` + AND ( + sp."stage" = ANY(${sql.array(args.stages, 'schema_proposal_stage')}) + ) + ` + : sql`` + } + ${ + args.users.length > 0 + ? sql` + AND ( + sp."user_id" = ANY(${sql.array(args.users, 'uuid')}) + ) + ` + : sql`` + } + ORDER BY sp."created_at" DESC, sp."id" LIMIT ${limit + 1} `); @@ -216,7 +303,12 @@ export class SchemaProposalStorage { }; } - async getPaginatedReviews(args: { proposalId: string; first: number; after: string }) { + async getPaginatedReviews(args: { + proposalId: string; + first: number; + after: string; + authors: string[]; + }) { this.logger.debug('Get paginated reviews (proposal=%s, after=%s)', args.proposalId, args.after); const limit = args.first ? (args.first > 0 ? Math.min(args.first, 20) : 20) : 20; const cursor = args.after ? decodeCreatedAtAndUUIDIdBasedCursor(args.after) : null; @@ -276,15 +368,15 @@ export class SchemaProposalStorage { } const schemaProposalFields = sql` - "id" - , to_json("created_at") as "createdAt" - , to_json("updated_at") as "updatedAt" - , "title" - , "description" - , "stage" - , "target_id" as "targetId" - , "user_id" as "userId" - , "comments_count" as "commentsCount" + sp."id" + , to_json(sp."created_at") as "createdAt" + , to_json(sp."updated_at") as "updatedAt" + , sp."title" + , sp."description" + , sp."stage" + , sp."target_id" as "targetId" + , sp."user_id" as "userId" + , sp."comments_count" as "commentsCount" `; const schemaProposalReviewFields = sql` @@ -296,28 +388,33 @@ const schemaProposalReviewFields = sql` , "line_text" as "lineText" , "schema_coordinate" as "schemaCoordinate" , "resolved_by_user_id" as "resolvedByUserId" + , "service_name" as "serviceName" `; +const ManualTransitionStageModel = z.enum(['DRAFT', 'OPEN', 'APPROVED', 'CLOSED']); + const SchemaProposalReviewModel = z.object({ id: z.string(), createdAt: z.string(), - updatedAt: z.string(), - stageTransition: z.enum(['DRAFT', 'OPEN', 'APPROVED']), - userId: z.string(), - lineText: z.string(), - schemaCoordinate: z.string(), - resolvedByUserId: z.string(), + stageTransition: ManualTransitionStageModel, + userId: z.string().nullable().optional().default(null), // if deleted + lineText: z.string().nullable().optional().default(null), + schemaCoordinate: z.string().nullable().optional().default(null), + resolvedByUserId: z.string().nullable().optional().default(null), + serviceName: z.string(), }); +const StageModel = z.enum(['DRAFT', 'OPEN', 'APPROVED', 'IMPLEMENTED', 'CLOSED']); + const SchemaProposalModel = z.object({ id: z.string(), createdAt: z.string(), updatedAt: z.string(), title: z.string(), description: z.string(), - stage: z.enum(['DRAFT', 'OPEN', 'APPROVED', 'IMPLEMENTED', 'CLOSED']), + stage: StageModel, targetId: z.string(), - userId: z.string().nullable(), + author: z.string().nullable().default('none'), commentsCount: z.number(), }); diff --git a/packages/services/api/src/modules/proposals/resolvers/Mutation/reviewSchemaProposal.ts b/packages/services/api/src/modules/proposals/resolvers/Mutation/reviewSchemaProposal.ts index d49b5c1506..c55e325e62 100644 --- a/packages/services/api/src/modules/proposals/resolvers/Mutation/reviewSchemaProposal.ts +++ b/packages/services/api/src/modules/proposals/resolvers/Mutation/reviewSchemaProposal.ts @@ -1,9 +1,30 @@ +import { SchemaProposalManager } from '../../providers/schema-proposal-manager'; import type { MutationResolvers } from './../../../../__generated__/types'; export const reviewSchemaProposal: NonNullable = async ( - _parent, - _arg, - _ctx, + _, + args, + { injector }, ) => { - /* Implement Mutation.reviewSchemaProposal resolver logic here */ + const result = await injector.get(SchemaProposalManager).reviewProposal({ + proposalId: args.input.schemaProposalId, + stage: args.input.stageTransition ?? null, + body: args.input.commentBody ?? null, + serviceName: args.input.serviceName, + // @todo coordinate etc + }); + if (result.type === 'error') { + return { + error: { + message: result.error.message, + details: result.error.details, + }, + ok: null, + }; + } + return { + ok: { + review: result.review, + }, + }; }; diff --git a/packages/services/api/src/modules/proposals/resolvers/Query/schemaProposals.ts b/packages/services/api/src/modules/proposals/resolvers/Query/schemaProposals.ts index 4ded25b97e..30013ceb9c 100644 --- a/packages/services/api/src/modules/proposals/resolvers/Query/schemaProposals.ts +++ b/packages/services/api/src/modules/proposals/resolvers/Query/schemaProposals.ts @@ -11,7 +11,6 @@ export const schemaProposals: NonNullable = a first: args.first, after: args.after ?? '', stages: (args.input?.stages as any[]) ?? [], - users: [], // @todo since switching to "author"... this gets messier - // users: args.input?.userIds ?? [], + users: args.input.userIds ?? [], }); }; diff --git a/packages/services/api/src/modules/proposals/resolvers/SchemaProposal.ts b/packages/services/api/src/modules/proposals/resolvers/SchemaProposal.ts index 32679dccee..ddbf5527a0 100644 --- a/packages/services/api/src/modules/proposals/resolvers/SchemaProposal.ts +++ b/packages/services/api/src/modules/proposals/resolvers/SchemaProposal.ts @@ -33,6 +33,7 @@ export const SchemaProposal: SchemaProposalResolvers = { proposalId: proposal.id, cursor: args.after ?? null, first: args.first ?? null, + latest: true, }); if (target) { @@ -76,6 +77,7 @@ export const SchemaProposal: SchemaProposalResolvers = { proposalId: proposal.id, cursor: args.after ?? null, first: args.first ?? null, + latest: args.input.latestPerService ?? false, }); return schemaChecks; diff --git a/packages/services/api/src/modules/schema/module.graphql.ts b/packages/services/api/src/modules/schema/module.graphql.ts index 0f5ea13860..77fd462aeb 100644 --- a/packages/services/api/src/modules/schema/module.graphql.ts +++ b/packages/services/api/src/modules/schema/module.graphql.ts @@ -693,6 +693,11 @@ export default gql` Optional url if wanting to show subgraph url changes inside checks. """ url: String + + """ + Optional. Attaches the check to a schema proposal. + """ + schemaProposalId: ID } input SchemaDeleteInput { diff --git a/packages/services/api/src/modules/schema/providers/schema-manager.ts b/packages/services/api/src/modules/schema/providers/schema-manager.ts index 35f7f9447b..b10e61574f 100644 --- a/packages/services/api/src/modules/schema/providers/schema-manager.ts +++ b/packages/services/api/src/modules/schema/providers/schema-manager.ts @@ -363,6 +363,7 @@ export class SchemaManager { proposalId: string; first: number | null; cursor: string | null; + latest?: boolean; }) { const connection = await this.storage.getPaginatedSchemaChecksForSchemaProposal(args); return connection; diff --git a/packages/services/api/src/modules/schema/resolvers/Mutation/schemaCheck.ts b/packages/services/api/src/modules/schema/resolvers/Mutation/schemaCheck.ts index 93160c8d92..8da9e3db98 100644 --- a/packages/services/api/src/modules/schema/resolvers/Mutation/schemaCheck.ts +++ b/packages/services/api/src/modules/schema/resolvers/Mutation/schemaCheck.ts @@ -10,6 +10,7 @@ export const schemaCheck: NonNullable = async ...input, service: input.service?.toLowerCase(), target: input.target ?? null, + schemaProposalId: input.schemaProposalId, // @todo check permission }); if ('changes' in result && result.changes) { diff --git a/packages/services/storage/src/db/types.ts b/packages/services/storage/src/db/types.ts index 8da0526ee4..56e7a8f706 100644 --- a/packages/services/storage/src/db/types.ts +++ b/packages/services/storage/src/db/types.ts @@ -336,6 +336,7 @@ export interface schema_proposal_reviews { resolved_by_user_id: string | null; schema_coordinate: string | null; schema_proposal_id: string; + service_name: string; stage_transition: schema_proposal_stage; user_id: string | null; } diff --git a/packages/services/storage/src/index.ts b/packages/services/storage/src/index.ts index 6bedcc365b..df4ba1d490 100644 --- a/packages/services/storage/src/index.ts +++ b/packages/services/storage/src/index.ts @@ -4268,8 +4268,14 @@ export async function createStorage( ${schemaCheckSQLFields} FROM "schema_checks" as c - LEFT JOIN "schema_checks" as cc - ON c.service_name = cc.service_name AND c."created_at" < cc."created_at" + ${ + args.latest + ? sql` + INNER JOIN "schema_checks" as cc + ON c.service_name = cc.service_name AND c."created_at" < cc."created_at" + ` + : sql`` + } LEFT JOIN "sdl_store" as s_schema ON s_schema."id" = c."schema_sdl_store_id" LEFT JOIN "sdl_store" as s_composite_schema diff --git a/packages/web/app/src/components/proposal/schema-diff/components.tsx b/packages/web/app/src/components/proposal/schema-diff/components.tsx index 2e936d7677..18840e5e37 100644 --- a/packages/web/app/src/components/proposal/schema-diff/components.tsx +++ b/packages/web/app/src/components/proposal/schema-diff/components.tsx @@ -84,8 +84,8 @@ export function ChangeSpacing(props: { type?: 'removal' | 'addition' | 'mutual' @@ -133,22 +133,22 @@ export function ChangeRow(props: { 'schema-doc-row-new w-[42px] min-w-fit select-none bg-gray-900 p-1 pr-3 text-right text-gray-600', props.className, props.type === 'removal' && 'invisible', - props.type === 'addition' && 'bg-green-900/50', + props.type === 'addition' && 'bg-green-900/30', )} /> {props.indent && @@ -191,7 +191,7 @@ function Removal(props: { children: ReactNode | string; className?: string }): R return ( @@ -202,7 +202,7 @@ function Removal(props: { children: ReactNode | string; className?: string }): R function Addition(props: { children: ReactNode; className?: string }): ReactNode { return ( - + {props.children} ); @@ -284,6 +284,7 @@ export function DiffDescription( content={printDescription(props.oldNode!)!} indent={props.indent} type="removal" + annotations={() => null} /> )} {newDesc && ( @@ -291,13 +292,20 @@ export function DiffDescription( content={printDescription(props.newNode!)!} indent={props.indent} type="addition" + annotations={() => null} /> )} ); } if (newDesc) { - return ; + return ( + null} + /> + ); } } @@ -746,7 +754,7 @@ export function SchemaDefinitionDiff({ args: [], name: 'query', type: - oldSchema.getQueryType() ?? + oldSchema?.getQueryType() ?? ({ name: defaultNames.query, toString: () => defaultNames.query } as GraphQLOutputType), astNode: null, deprecationReason: null, @@ -757,7 +765,7 @@ export function SchemaDefinitionDiff({ args: [], name: 'mutation', type: - oldSchema.getMutationType() ?? + oldSchema?.getMutationType() ?? ({ name: defaultNames.mutation, toString: () => defaultNames.mutation, @@ -771,7 +779,7 @@ export function SchemaDefinitionDiff({ args: [], name: 'subscription', type: - oldSchema.getSubscriptionType() ?? + oldSchema?.getSubscriptionType() ?? ({ name: defaultNames.subscription, toString: () => defaultNames.subscription, @@ -787,7 +795,7 @@ export function SchemaDefinitionDiff({ args: [], name: 'query', type: - newSchema.getQueryType() ?? + newSchema?.getQueryType() ?? ({ name: defaultNames.query, toString: () => defaultNames.query } as GraphQLOutputType), astNode: null, deprecationReason: null, @@ -798,7 +806,7 @@ export function SchemaDefinitionDiff({ args: [], name: 'mutation', type: - newSchema.getMutationType() ?? + newSchema?.getMutationType() ?? ({ name: defaultNames.mutation, toString: () => defaultNames.mutation, @@ -812,7 +820,7 @@ export function SchemaDefinitionDiff({ args: [], name: 'subscription', type: - newSchema.getSubscriptionType() ?? + newSchema?.getSubscriptionType() ?? ({ name: defaultNames.subscription, toString: () => defaultNames.subscription, diff --git a/packages/web/app/src/components/target/proposals/service-heading.tsx b/packages/web/app/src/components/target/proposals/service-heading.tsx index 7662bb2892..6c9d82116c 100644 --- a/packages/web/app/src/components/target/proposals/service-heading.tsx +++ b/packages/web/app/src/components/target/proposals/service-heading.tsx @@ -6,9 +6,9 @@ export function ServiceHeading(props: { serviceName: string }) { return null; } return ( - + <div className="flex flex-row items-center border-b-2 px-4 py-2 text-base font-semibold"> <CubeIcon className="mr-2" /> <span>{props.serviceName}</span> - +
); } diff --git a/packages/web/app/src/components/target/proposals/stage-transition-select.tsx b/packages/web/app/src/components/target/proposals/stage-transition-select.tsx index 0e1bd8554e..303bfe119a 100644 --- a/packages/web/app/src/components/target/proposals/stage-transition-select.tsx +++ b/packages/web/app/src/components/target/proposals/stage-transition-select.tsx @@ -24,6 +24,11 @@ const STAGE_TRANSITIONS: ReadonlyArray< value: SchemaProposalStage.Open, label: 'READY FOR REVIEW', }, + { + fromStates: [SchemaProposalStage.Closed], + value: SchemaProposalStage.Draft, + label: 'REOPEN AS DRAFT', + }, { fromStates: [SchemaProposalStage.Closed, SchemaProposalStage.Approved], value: SchemaProposalStage.Open, @@ -49,7 +54,10 @@ const STAGE_TITLES = { [SchemaProposalStage.Implemented]: 'IMPLEMENTED', } as const; -export function StageTransitionSelect(props: { stage: SchemaProposalStage }) { +export function StageTransitionSelect(props: { + stage: SchemaProposalStage; + onSelect: (stage: SchemaProposalStage) => void | Promise; +}) { const [open, setOpen] = useState(false); return ( @@ -57,10 +65,10 @@ export function StageTransitionSelect(props: { stage: SchemaProposalStage }) { @@ -72,8 +80,10 @@ export function StageTransitionSelect(props: { stage: SchemaProposalStage }) { { - console.log(`selected ${value}`); + onSelect={async value => { + // @todo debounce... + await props.onSelect(value.toUpperCase() as SchemaProposalStage); + setOpen(false); }} className="cursor-pointer truncate" > diff --git a/packages/web/app/src/components/target/proposals/version-select.tsx b/packages/web/app/src/components/target/proposals/version-select.tsx index 14e17ed452..f21c2d7b1f 100644 --- a/packages/web/app/src/components/target/proposals/version-select.tsx +++ b/packages/web/app/src/components/target/proposals/version-select.tsx @@ -17,6 +17,7 @@ const ProposalQuery_VersionsListFragment = graphql(/* GraphQL */ ` createdAt meta { author + commit } } } @@ -47,11 +48,15 @@ export function VersionSelect(props: { @@ -75,12 +80,14 @@ export function VersionSelect(props: { version.id === selectedVersionId && 'underline', )} > -
Version {version.id}
+
+ {version.meta?.commit ?? version.id} +
()
- by {version.user?.displayName ?? version.user?.fullName ?? 'null'} + by {version.meta?.author ?? 'null'}
diff --git a/packages/web/app/src/pages/target-proposal-checks.tsx b/packages/web/app/src/pages/target-proposal-checks.tsx index 6b803ca67a..9fcf4c4c05 100644 --- a/packages/web/app/src/pages/target-proposal-checks.tsx +++ b/packages/web/app/src/pages/target-proposal-checks.tsx @@ -1,6 +1,7 @@ import { CalendarIcon, CheckIcon, XIcon } from '@/components/ui/icon'; import { TimeAgo } from '@/components/v2'; import { FragmentType, graphql, useFragment } from '@/gql'; +import { cn } from '@/lib/utils'; import { ComponentNoneIcon, CubeIcon } from '@radix-ui/react-icons'; import { Link } from '@tanstack/react-router'; @@ -19,6 +20,10 @@ export const ProposalOverview_ChecksFragment = graphql(/* GraphQL */ ` hasSchemaCompositionErrors hasUnapprovedBreakingChanges hasSchemaChanges + meta { + commit + author + } } } } @@ -33,67 +38,95 @@ export function TargetProposalChecksPage(props: { }) { const checks = useFragment(ProposalOverview_ChecksFragment, props.checks); return ( -
- {checks?.edges?.map(({ node }) => { +
+ {checks?.edges?.map(({ node }, index) => { return ( - -
- {node.serviceName && ( - <> - -
{node.serviceName}
- - )} -
-
{node.id}
-
-
- -
-
- - -
-
- + ); })}
); } +function CheckItem(props: { + organizationSlug: string; + projectSlug: string; + targetSlug: string; + id: string; + commit?: string | null; + author?: string | null; + serviceName: string; + createdAt: string; + hasSchemaCompositionErrors: boolean; + hasUnapprovedBreakingChanges: boolean; + hasSchemaChanges: boolean; + className?: string | boolean; +}) { + return ( + +
+ +
+
+ {props.serviceName.length !== 0 && ( +
+ +
{props.serviceName}
+
+ )} +
+
{props.commit ?? props.id}
+
+ + +
+
+ {props.author ? props.author : ''} +
+ + ); +} + function SchemaCheckIcon(props: { hasSchemaCompositionErrors: boolean; hasUnapprovedBreakingChanges: boolean; hasSchemaChanges: boolean; }) { if (props.hasSchemaCompositionErrors || props.hasUnapprovedBreakingChanges) { - const issue = props.hasSchemaCompositionErrors ? 'COMPOSITION ERRORS' : 'BREAKING CHANGES'; return (
- {issue} + ERROR
); } if (props.hasSchemaChanges) { return (
- OK + PASS
); } return (
- NO CHANGE + NO CHANGE
); } diff --git a/packages/web/app/src/pages/target-proposal.tsx b/packages/web/app/src/pages/target-proposal.tsx index ea2160a566..6b9f0de866 100644 --- a/packages/web/app/src/pages/target-proposal.tsx +++ b/packages/web/app/src/pages/target-proposal.tsx @@ -1,6 +1,6 @@ import { useMemo } from 'react'; import { buildSchema } from 'graphql'; -import { useQuery } from 'urql'; +import { useMutation, useQuery } from 'urql'; import { Page, TargetLayout } from '@/components/layouts/target'; import { ProposalOverview_ChangeFragment, @@ -107,6 +107,19 @@ const ProposalChangesQuery = graphql(/* GraphQL */ ` } `); +const ReviewSchemaProposalMutation = graphql(/* GraphQL */ ` + mutation ReviewSchemaProposalMutation($input: ReviewSchemaProposalInput!) { + reviewSchemaProposal(input: $input) { + ok { + __typename + } + error { + message + } + } + } +`); + export function TargetProposalsSinglePage(props: { organizationSlug: string; projectSlug: string; @@ -132,7 +145,7 @@ export function TargetProposalsSinglePage(props: { const ProposalsContent = (props: Parameters[0]) => { // fetch main page details - const [query] = useQuery({ + const [query, refreshProposal] = useQuery({ query: ProposalQuery, variables: { latestValidVersionReference: { @@ -158,6 +171,8 @@ const ProposalsContent = (props: Parameters[0] requestPolicy: 'cache-and-network', }); + const [_, reviewSchemaProposal] = useMutation(ReviewSchemaProposalMutation); + // This does a lot of heavy lifting to avoid having to reapply patching etc on each tab... // Takes all the data provided by the queries to apply the patch to the schema and // categorize changes. @@ -268,13 +283,23 @@ const ProposalsContent = (props: Parameters[0] ) : ( proposal && ( <> -
-
- -
-
-
- +
+ +
+ { + const review = await reviewSchemaProposal({ + input: { + schemaProposalId: props.proposalId, + stageTransition: stage, + // for monorepos and non-service related comments, use an empty string + serviceName: '', + }, + }); + refreshProposal(); + }} + />
diff --git a/packages/web/app/src/pages/target-proposals.tsx b/packages/web/app/src/pages/target-proposals.tsx index 2c2f4ef999..6c053b2416 100644 --- a/packages/web/app/src/pages/target-proposals.tsx +++ b/packages/web/app/src/pages/target-proposals.tsx @@ -171,13 +171,16 @@ const ProposalsListPage = (props: { }); const pageInfo = query.data?.schemaProposals?.pageInfo; const search = useSearch({ strict: false }); + const hasFilter = props.filterStages?.length || props.filterUserIds?.length; return ( <> {query.fetching ? : null} {query.data?.schemaProposals?.edges?.length === 0 && (
- No proposals have been created yet + + No proposals {hasFilter ? 'match your search criteria' : 'have been created yet'} + To get started, use the Hive CLI to propose a schema change.
)} From 0e05df7aebbbc7355bfd91e2f11e43aea6e787e8 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Tue, 2 Sep 2025 18:10:39 -0700 Subject: [PATCH 41/54] add all stages toggle to filter --- .../target/proposals/stage-filter.tsx | 25 +++++++++++++++++++ .../target/proposals/version-select.tsx | 2 ++ 2 files changed, 27 insertions(+) diff --git a/packages/web/app/src/components/target/proposals/stage-filter.tsx b/packages/web/app/src/components/target/proposals/stage-filter.tsx index 29332edcab..f0c80027c6 100644 --- a/packages/web/app/src/components/target/proposals/stage-filter.tsx +++ b/packages/web/app/src/components/target/proposals/stage-filter.tsx @@ -32,6 +32,31 @@ export const StageFilter = ({ selectedStages }: { selectedStages: string[] }) => + { + const allSelected = stages.every(s => selectedStages.includes(s)); + let updated: string[] | undefined; + if (allSelected) { + updated = undefined; + } else { + updated = [...stages]; + } + void router.navigate({ + search: { ...search, stage: updated }, + }); + }} + className="cursor-pointer truncate border-b" + > +
+ selectedStages.includes(s))} + /> +
All
+
+
{stages?.map(stage => ( { + // @todo make more generic by taking in version via arg void router.navigate({ search: { ...search, version: selectedVersion }, }); + setOpen(false); }} className="cursor-pointer truncate" > From 2e5c9fe2178441c12099f57b5f7ef678b97ef02b Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Wed, 3 Sep 2025 14:42:35 -0700 Subject: [PATCH 42/54] Fixed multi service schema rendering --- .../federation.products-changes.graphql | 70 +++ ...hql => federation.reviews-changes.graphql} | 0 .../proposals/resolvers/SchemaProposal.ts | 1 - .../src/modules/schema/providers/inspector.ts | 8 +- .../schema/providers/schema-check-manager.ts | 2 +- .../src/modules/shared/providers/storage.ts | 1 + packages/services/storage/src/index.ts | 10 +- .../storage/src/schema-change-model.ts | 411 ++++++++++++++++-- .../{proposal => target/proposals}/Review.tsx | 6 +- .../{proposal => target/proposals}/index.tsx | 0 .../proposals}/schema-diff/compare-lists.ts | 0 .../proposals}/schema-diff/components.tsx | 36 +- .../proposals}/schema-diff/schema-diff.tsx | 2 +- .../target/proposals/service-heading.tsx | 1 - .../{proposal => target/proposals}/util.ts | 0 .../app/src/pages/target-proposal-checks.tsx | 6 +- .../app/src/pages/target-proposal-details.tsx | 10 +- .../app/src/pages/target-proposal-schema.tsx | 9 +- .../src/pages/target-proposal-supergraph.tsx | 4 +- .../app/src/pages/target-proposal-types.ts | 2 +- .../web/app/src/pages/target-proposal.tsx | 2 +- .../web/app/src/pages/target-proposals.tsx | 2 +- 22 files changed, 507 insertions(+), 76 deletions(-) create mode 100644 packages/libraries/cli/examples/federation.products-changes.graphql rename packages/libraries/cli/examples/{federation.reviews-modified.graphql => federation.reviews-changes.graphql} (100%) rename packages/web/app/src/components/{proposal => target/proposals}/Review.tsx (95%) rename packages/web/app/src/components/{proposal => target/proposals}/index.tsx (100%) rename packages/web/app/src/components/{proposal => target/proposals}/schema-diff/compare-lists.ts (100%) rename packages/web/app/src/components/{proposal => target/proposals}/schema-diff/components.tsx (98%) rename packages/web/app/src/components/{proposal => target/proposals}/schema-diff/schema-diff.tsx (97%) rename packages/web/app/src/components/{proposal => target/proposals}/util.ts (100%) diff --git a/packages/libraries/cli/examples/federation.products-changes.graphql b/packages/libraries/cli/examples/federation.products-changes.graphql new file mode 100644 index 0000000000..8a3e21caf8 --- /dev/null +++ b/packages/libraries/cli/examples/federation.products-changes.graphql @@ -0,0 +1,70 @@ +extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.3" + import: ["@key", "@shareable", "@inaccessible", "@tag"] + ) + @link(url: "https://specs.graphql-hive.com/hive/v1.0", import: ["@meta"]) + @meta(name: "priority", content: "tier1") + +directive @meta( + name: String! + content: String! +) repeatable on SCHEMA | OBJECT | INTERFACE | FIELD_DEFINITION + +type Query { + allProducts: [ProductItf] @meta(name: "owner", content: "hive-team") + product(id: ID!): ProductItf +} + +interface ProductItf implements SkuItf @meta(name: "domain", content: "products") { + id: ID! + sku: String + name: String + package: String + variation: ProductVariation + dimensions: ProductDimension + createdBy: User + hidden: String @inaccessible + oldField: String @deprecated(reason: "refactored out") +} + +interface SkuItf { + sku: String +} + +type Product implements ProductItf & SkuItf + @key(fields: "id") + @key(fields: "sku package") + @key(fields: "sku variation { id }") + @meta(name: "owner", content: "product-team") { + id: ID! + sku: String @meta(name: "unique", content: "true") + name: String + package: String + variation: ProductVariation + dimensions: ProductDimension + createdBy: User + hidden: String + reviewsScore: Float! + oldField: String @deprecated(reason: "Not used any longer") +} + +enum ShippingClass { + STANDARD + EXPRESS +} + +type ProductVariation { + id: ID! + name: String +} + +type ProductDimension @shareable { + size: String + weight: Float +} + +type User @key(fields: "email") { + email: ID! + totalProductsCreated: Int @shareable +} diff --git a/packages/libraries/cli/examples/federation.reviews-modified.graphql b/packages/libraries/cli/examples/federation.reviews-changes.graphql similarity index 100% rename from packages/libraries/cli/examples/federation.reviews-modified.graphql rename to packages/libraries/cli/examples/federation.reviews-changes.graphql diff --git a/packages/services/api/src/modules/proposals/resolvers/SchemaProposal.ts b/packages/services/api/src/modules/proposals/resolvers/SchemaProposal.ts index ddbf5527a0..2d475ec9e1 100644 --- a/packages/services/api/src/modules/proposals/resolvers/SchemaProposal.ts +++ b/packages/services/api/src/modules/proposals/resolvers/SchemaProposal.ts @@ -79,7 +79,6 @@ export const SchemaProposal: SchemaProposalResolvers = { first: args.first ?? null, latest: args.input.latestPerService ?? false, }); - return schemaChecks; }, async rebasedSupergraphSDL(proposal, args, { injector }) { diff --git a/packages/services/api/src/modules/schema/providers/inspector.ts b/packages/services/api/src/modules/schema/providers/inspector.ts index b0037768ad..f0e75a18eb 100644 --- a/packages/services/api/src/modules/schema/providers/inspector.ts +++ b/packages/services/api/src/modules/schema/providers/inspector.ts @@ -10,7 +10,7 @@ import { type GraphQLSchema, } from 'graphql'; import { Injectable, Scope } from 'graphql-modules'; -import { Change, ChangeType, diff } from '@graphql-inspector/core'; +import { Change, ChangeType, diff, TypeOfChangeType } from '@graphql-inspector/core'; import { traceFn } from '@hive/service-common'; import { HiveSchemaChangeModel } from '@hive/storage'; import { Logger } from '../../shared/providers/logger'; @@ -54,7 +54,7 @@ export class Inspector { * If they are equal, it means that the change is no longer relevant and can be dropped. * All other changes are kept. */ -function dropTrimmedDescriptionChangedChange(change: Change): boolean { +function dropTrimmedDescriptionChangedChange(change: Change): boolean { return ( matchChange(change, { [ChangeType.DirectiveArgumentDescriptionChanged]: change => @@ -112,7 +112,7 @@ function trimDescription(description: unknown): string { type PropEndsWith = T extends `${any}${E}` ? T : never; function shouldKeepDescriptionChangedChange< - T extends ChangeType, + T extends TypeOfChangeType, TO extends PropEndsWith['meta'], 'Description'>, // Prevents comparing values of the same key (e.g. newDescription, newDescription will result in TS error) TN extends Exclude['meta'], 'Description'>, TO>, @@ -120,7 +120,7 @@ function shouldKeepDescriptionChangedChange< return trimDescription(change.meta[oldKey]) !== trimDescription(change.meta[newKey]); } -function matchChange( +function matchChange( change: Change, pattern: { [K in T]?: (change: Change) => R; diff --git a/packages/services/api/src/modules/schema/providers/schema-check-manager.ts b/packages/services/api/src/modules/schema/providers/schema-check-manager.ts index 0cde5cd329..9a63b89a8e 100644 --- a/packages/services/api/src/modules/schema/providers/schema-check-manager.ts +++ b/packages/services/api/src/modules/schema/providers/schema-check-manager.ts @@ -44,7 +44,7 @@ export class SchemaCheckManager { } getAllSchemaChanges(schemaCheck: SchemaCheck) { - if (!schemaCheck.safeSchemaChanges?.length || !schemaCheck.breakingSchemaChanges?.length) { + if (!schemaCheck.safeSchemaChanges?.length && !schemaCheck.breakingSchemaChanges?.length) { return null; } diff --git a/packages/services/api/src/modules/shared/providers/storage.ts b/packages/services/api/src/modules/shared/providers/storage.ts index 7064feddb2..1167e2292c 100644 --- a/packages/services/api/src/modules/shared/providers/storage.ts +++ b/packages/services/api/src/modules/shared/providers/storage.ts @@ -413,6 +413,7 @@ export interface Storage { first: number | null; cursor: null | string; transformNode?: (check: SchemaCheck) => TransformedSchemaCheck; + latest?: boolean; }): Promise< Readonly<{ edges: ReadonlyArray<{ diff --git a/packages/services/storage/src/index.ts b/packages/services/storage/src/index.ts index df4ba1d490..c44daa44ee 100644 --- a/packages/services/storage/src/index.ts +++ b/packages/services/storage/src/index.ts @@ -4271,8 +4271,14 @@ export async function createStorage( ${ args.latest ? sql` - INNER JOIN "schema_checks" as cc - ON c.service_name = cc.service_name AND c."created_at" < cc."created_at" + INNER JOIN ( + SELECT "service_name", "schema_proposal_id", max("created_at") as maxdate + FROM schema_checks + GROUP BY "service_name", "schema_proposal_id" + ) as cc + ON c."schema_proposal_id" = cc."schema_proposal_id" + AND c."service_name" = cc."service_name" + AND c."created_at" = cc."maxdate" ` : sql`` } diff --git a/packages/services/storage/src/schema-change-model.ts b/packages/services/storage/src/schema-change-model.ts index fddec40fa0..400f1e2fda 100644 --- a/packages/services/storage/src/schema-change-model.ts +++ b/packages/services/storage/src/schema-change-model.ts @@ -16,6 +16,32 @@ import { DirectiveLocationAddedChange, DirectiveLocationRemovedChange, DirectiveRemovedChange, + DirectiveUsageArgumentAddedChange, + DirectiveUsageArgumentDefinitionAddedChange, + DirectiveUsageArgumentDefinitionRemovedChange, + DirectiveUsageArgumentRemovedChange, + DirectiveUsageEnumAddedChange, + DirectiveUsageEnumRemovedChange, + DirectiveUsageEnumValueAddedChange, + DirectiveUsageEnumValueRemovedChange, + DirectiveUsageFieldAddedChange, + DirectiveUsageFieldDefinitionAddedChange, + DirectiveUsageFieldDefinitionRemovedChange, + DirectiveUsageFieldRemovedChange, + DirectiveUsageInputFieldDefinitionAddedChange, + DirectiveUsageInputFieldDefinitionRemovedChange, + DirectiveUsageInputObjectAddedChange, + DirectiveUsageInputObjectRemovedChange, + DirectiveUsageInterfaceAddedChange, + DirectiveUsageInterfaceRemovedChange, + DirectiveUsageObjectAddedChange, + DirectiveUsageObjectRemovedChange, + DirectiveUsageScalarAddedChange, + DirectiveUsageScalarRemovedChange, + DirectiveUsageSchemaAddedChange, + DirectiveUsageSchemaRemovedChange, + DirectiveUsageUnionMemberAddedChange, + DirectiveUsageUnionMemberRemovedChange, EnumValueAddedChange, EnumValueDeprecationReasonAddedChange, EnumValueDeprecationReasonChangedChange, @@ -93,6 +119,58 @@ const DirectiveArgumentDefaultValueChangedLiteral = z.literal("DIRECTIVE_ARGUME // prettier-ignore const DirectiveArgumentTypeChangedLiteral = z.literal("DIRECTIVE_ARGUMENT_TYPE_CHANGED" satisfies `${typeof ChangeType.DirectiveArgumentTypeChanged}`) // prettier-ignore +const DirectiveUsageUnionMemberAddedLiteral = z.literal('DIRECTIVE_USAGE_UNION_MEMBER_ADDED' satisfies `${typeof ChangeType.DirectiveUsageUnionMemberAdded}`) +// prettier-ignore +const DirectiveUsageUnionMemberRemovedLiteral = z.literal('DIRECTIVE_USAGE_UNION_MEMBER_REMOVED' satisfies `${typeof ChangeType.DirectiveUsageUnionMemberRemoved}`) +// prettier-ignore +const DirectiveUsageEnumAddedLiteral = z.literal('DIRECTIVE_USAGE_ENUM_ADDED' satisfies `${typeof ChangeType.DirectiveUsageEnumAdded}`) +// prettier-ignore +const DirectiveUsageEnumRemovedLiteral = z.literal('DIRECTIVE_USAGE_ENUM_REMOVED' satisfies `${typeof ChangeType.DirectiveUsageEnumRemoved}`) +// prettier-ignore +const DirectiveUsageEnumValueAddedLiteral = z.literal('DIRECTIVE_USAGE_ENUM_VALUE_ADDED' satisfies `${typeof ChangeType.DirectiveUsageEnumValueAdded}`) +// prettier-ignore +const DirectiveUsageEnumValueRemovedLiteral = z.literal('DIRECTIVE_USAGE_ENUM_VALUE_REMOVED' satisfies `${typeof ChangeType.DirectiveUsageEnumValueRemoved}`) +// prettier-ignore +const DirectiveUsageInputObjectAddedLiteral = z.literal('DIRECTIVE_USAGE_INPUT_OBJECT_ADDED' satisfies `${typeof ChangeType.DirectiveUsageInputObjectAdded}`) +// prettier-ignore +const DirectiveUsageInputObjectRemovedLiteral = z.literal('DIRECTIVE_USAGE_INPUT_OBJECT_REMOVED' satisfies `${typeof ChangeType.DirectiveUsageInputObjectRemoved}`) +// prettier-ignore +const DirectiveUsageFieldAddedLiteral = z.literal('DIRECTIVE_USAGE_FIELD_ADDED' satisfies `${typeof ChangeType.DirectiveUsageFieldAdded}`) +// prettier-ignore +const DirectiveUsageFieldRemovedLiteral = z.literal('DIRECTIVE_USAGE_FIELD_REMOVED' satisfies `${typeof ChangeType.DirectiveUsageFieldRemoved}`) +// prettier-ignore +const DirectiveUsageScalarAddedLiteral = z.literal('DIRECTIVE_USAGE_SCALAR_ADDED' satisfies `${typeof ChangeType.DirectiveUsageScalarAdded}`) +// prettier-ignore +const DirectiveUsageScalarRemovedLiteral = z.literal('DIRECTIVE_USAGE_SCALAR_REMOVED' satisfies `${typeof ChangeType.DirectiveUsageScalarRemoved}`) +// prettier-ignore +const DirectiveUsageObjectAddedLiteral = z.literal('DIRECTIVE_USAGE_OBJECT_ADDED' satisfies `${typeof ChangeType.DirectiveUsageObjectAdded}`) +// prettier-ignore +const DirectiveUsageObjectRemovedLiteral = z.literal('DIRECTIVE_USAGE_OBJECT_REMOVED' satisfies `${typeof ChangeType.DirectiveUsageObjectRemoved}`) +// prettier-ignore +const DirectiveUsageInterfaceAddedLiteral = z.literal('DIRECTIVE_USAGE_INTERFACE_ADDED' satisfies `${typeof ChangeType.DirectiveUsageInterfaceAdded}`) +// prettier-ignore +const DirectiveUsageInterfaceRemovedLiteral = z.literal('DIRECTIVE_USAGE_INTERFACE_REMOVED' satisfies `${typeof ChangeType.DirectiveUsageInterfaceRemoved}`) +// prettier-ignore +const DirectiveUsageArgumentDefinitionAddedLiteral = z.literal('DIRECTIVE_USAGE_ARGUMENT_DEFINITION_ADDED' satisfies `${typeof ChangeType.DirectiveUsageArgumentDefinitionAdded}`) +// prettier-ignore +const DirectiveUsageArgumentDefinitionRemovedLiteral = z.literal('DIRECTIVE_USAGE_ARGUMENT_DEFINITION_REMOVED' satisfies `${typeof ChangeType.DirectiveUsageArgumentDefinitionRemoved}`) +// prettier-ignore +const DirectiveUsageSchemaAddedLiteral = z.literal('DIRECTIVE_USAGE_SCHEMA_ADDED' satisfies `${typeof ChangeType.DirectiveUsageSchemaAdded}`) +// prettier-ignore +const DirectiveUsageSchemaRemovedLiteral = z.literal('DIRECTIVE_USAGE_SCHEMA_REMOVED' satisfies `${typeof ChangeType.DirectiveUsageSchemaRemoved}`) +// prettier-ignore +const DirectiveUsageFieldDefinitionAddedLiteral = z.literal('DIRECTIVE_USAGE_FIELD_DEFINITION_ADDED' satisfies `${typeof ChangeType.DirectiveUsageFieldDefinitionAdded}`) +// prettier-ignore +const DirectiveUsageFieldDefinitionRemovedLiteral = z.literal('DIRECTIVE_USAGE_FIELD_DEFINITION_REMOVED' satisfies `${typeof ChangeType.DirectiveUsageFieldDefinitionRemoved}`) +// prettier-ignore +const DirectiveUsageInputFieldDefinitionAddedLiteral = z.literal('DIRECTIVE_USAGE_INPUT_FIELD_DEFINITION_ADDED' satisfies `${typeof ChangeType.DirectiveUsageInputFieldDefinitionAdded}`) +// prettier-ignore +const DirectiveUsageInputFieldDefinitionRemovedLiteral = z.literal('DIRECTIVE_USAGE_INPUT_FIELD_DEFINITION_REMOVED' satisfies `${typeof ChangeType.DirectiveUsageInputFieldDefinitionRemoved}`) +// prettier-ignore +const DirectiveUsageArgumentAddedLiteral = z.literal('DIRECTIVE_USAGE_ARGUMENT_ADDED' satisfies `${typeof ChangeType.DirectiveUsageArgumentAdded}`) +// prettier-ignore +const DirectiveUsageArgumentRemovedLiteral = z.literal('DIRECTIVE_USAGE_ARGUMENT_REMOVED' satisfies `${typeof ChangeType.DirectiveUsageArgumentRemoved}`) +// prettier-ignore const EnumValueRemovedLiteral = z.literal("ENUM_VALUE_REMOVED" satisfies `${typeof ChangeType.EnumValueRemoved}`) // prettier-ignore const EnumValueAddedLiteral = z.literal("ENUM_VALUE_ADDED" satisfies `${typeof ChangeType.EnumValueAdded}`) @@ -335,6 +413,255 @@ export const DirectiveArgumentTypeChangedModel = }), }); +export const DirectiveUsageUnionMemberAddedModel = + implement().with({ + type: DirectiveUsageUnionMemberAddedLiteral, + meta: z.object({ + addedUnionMember: z.string(), + unionName: z.string(), + addedUnionMemberTypeName: z.string(), + addedDirectiveName: z.string(), + addedToNewType: z.boolean(), + }), + }); +export const DirectiveUsageUnionMemberRemovedModel = + implement().with({ + type: DirectiveUsageUnionMemberRemovedLiteral, + meta: z.object({ + unionName: z.string(), + removedUnionMemberTypeName: z.string(), + removedDirectiveName: z.string(), + }), + }); +export const DirectiveUsageEnumAddedModel = implement().with({ + type: DirectiveUsageEnumAddedLiteral, + meta: z.object({ + enumName: z.string(), + addedDirectiveName: z.string(), + addedToNewType: z.boolean(), + }), +}); +export const DirectiveUsageEnumRemovedModel = implement().with({ + type: DirectiveUsageEnumRemovedLiteral, + meta: z.object({ + enumName: z.string(), + removedDirectiveName: z.string(), + }), +}); +export const DirectiveUsageEnumValueAddedModel = + implement().with({ + type: DirectiveUsageEnumValueAddedLiteral, + meta: z.object({ + enumName: z.string(), + enumValueName: z.string(), + addedDirectiveName: z.string(), + addedToNewType: z.boolean(), + }), + }); +export const DirectiveUsageEnumValueRemovedModel = + implement().with({ + type: DirectiveUsageEnumValueRemovedLiteral, + meta: z.object({ + enumName: z.string(), + enumValueName: z.string(), + removedDirectiveName: z.string(), + }), + }); +export const DirectiveUsageInputObjectAddedModel = + implement().with({ + type: DirectiveUsageInputObjectAddedLiteral, + meta: z.object({ + inputObjectName: z.string(), + addedInputFieldName: z.string(), + isAddedInputFieldTypeNullable: z.boolean(), + addedInputFieldType: z.string(), + addedDirectiveName: z.string(), + addedToNewType: z.boolean(), + }), + }); +export const DirectiveUsageInputObjectRemovedModel = + implement().with({ + type: DirectiveUsageInputObjectRemovedLiteral, + meta: z.object({ + inputObjectName: z.string(), + removedInputFieldName: z.string(), + isRemovedInputFieldTypeNullable: z.boolean(), + removedInputFieldType: z.string(), + removedDirectiveName: z.string(), + }), + }); +export const DirectiveUsageFieldAddedModel = implement().with({ + type: DirectiveUsageFieldAddedLiteral, + meta: z.object({ + typeName: z.string(), + fieldName: z.string(), + addedDirectiveName: z.string(), + }), +}); +export const DirectiveUsageFieldRemovedModel = implement().with({ + type: DirectiveUsageFieldRemovedLiteral, + meta: z.object({ + typeName: z.string(), + fieldName: z.string(), + removedDirectiveName: z.string(), + }), +}); +export const DirectiveUsageScalarAddedModel = implement().with({ + type: DirectiveUsageScalarAddedLiteral, + meta: z.object({ + scalarName: z.string(), + addedDirectiveName: z.string(), + addedToNewType: z.boolean(), + }), +}); +export const DirectiveUsageScalarRemovedModel = implement().with( + { + type: DirectiveUsageScalarRemovedLiteral, + meta: z.object({ + scalarName: z.string(), + removedDirectiveName: z.string(), + }), + }, +); +export const DirectiveUsageObjectAddedModel = implement().with({ + type: DirectiveUsageObjectAddedLiteral, + meta: z.object({ + objectName: z.string(), + addedDirectiveName: z.string(), + addedToNewType: z.boolean(), + }), +}); +export const DirectiveUsageObjectRemovedModel = implement().with( + { + type: DirectiveUsageObjectRemovedLiteral, + meta: z.object({ + objectName: z.string(), + removedDirectiveName: z.string(), + }), + }, +); +export const DirectiveUsageInterfaceAddedModel = + implement().with({ + type: DirectiveUsageInterfaceAddedLiteral, + meta: z.object({ + interfaceName: z.string(), + addedDirectiveName: z.string(), + addedToNewType: z.boolean(), + }), + }); +export const DirectiveUsageInterfaceRemovedModel = + implement().with({ + type: DirectiveUsageInterfaceRemovedLiteral, + meta: z.object({ + interfaceName: z.string(), + removedDirectiveName: z.string(), + }), + }); +export const DirectiveUsageArgumentDefinitionAddedModel = + implement().with({ + type: DirectiveUsageArgumentDefinitionAddedLiteral, + meta: z.object({ + typeName: z.string(), + fieldName: z.string(), + argumentName: z.string(), + addedDirectiveName: z.string(), + addedToNewType: z.boolean(), + }), + }); +export const DirectiveUsageArgumentDefinitionRemovedModel = + implement().with({ + type: DirectiveUsageArgumentDefinitionRemovedLiteral, + meta: z.object({ + typeName: z.string(), + fieldName: z.string(), + argumentName: z.string(), + removedDirectiveName: z.string(), + }), + }); +export const DirectiveUsageSchemaAddedModel = implement().with({ + type: DirectiveUsageSchemaAddedLiteral, + meta: z.object({ + addedDirectiveName: z.string(), + schemaTypeName: z.string(), + addedToNewType: z.boolean(), + }), +}); +export const DirectiveUsageSchemaRemovedModel = implement().with( + { + type: DirectiveUsageSchemaRemovedLiteral, + meta: z.object({ + removedDirectiveName: z.string(), + schemaTypeName: z.string(), + }), + }, +); +export const DirectiveUsageFieldDefinitionAddedModel = + implement().with({ + type: DirectiveUsageFieldDefinitionAddedLiteral, + meta: z.object({ + typeName: z.string(), + fieldName: z.string(), + addedDirectiveName: z.string(), + addedToNewType: z.boolean(), + }), + }); +export const DirectiveUsageFieldDefinitionRemovedModel = + implement().with({ + type: DirectiveUsageFieldDefinitionRemovedLiteral, + meta: z.object({ + typeName: z.string(), + fieldName: z.string(), + removedDirectiveName: z.string(), + }), + }); +export const DirectiveUsageInputFieldDefinitionAddedModel = + implement().with({ + type: DirectiveUsageInputFieldDefinitionAddedLiteral, + meta: z.object({ + inputObjectName: z.string(), + inputFieldName: z.string(), + inputFieldType: z.string(), + addedDirectiveName: z.string(), + addedToNewType: z.boolean(), + }), + }); +export const DirectiveUsageInputFieldDefinitionRemovedModel = + implement().with({ + type: DirectiveUsageInputFieldDefinitionRemovedLiteral, + meta: z.object({ + inputObjectName: z.string(), + inputFieldName: z.string(), + removedDirectiveName: z.string(), + }), + }); +export const DirectiveUsageArgumentAddedModel = implement().with( + { + type: DirectiveUsageArgumentAddedLiteral, + meta: z.object({ + directiveName: z.string(), + addedArgumentName: z.string(), + addedArgumentValue: z.string(), + oldArgumentValue: z.string().nullable(), + parentTypeName: z.string().nullable(), + parentFieldName: z.string().nullable(), + parentArgumentName: z.string().nullable(), + parentEnumValueName: z.string().nullable(), + }), + }, +); +export const DirectiveUsageArgumentRemovedModel = + implement().with({ + type: DirectiveUsageArgumentRemovedLiteral, + meta: z.object({ + directiveName: z.string(), + removedArgumentName: z.string(), + parentTypeName: z.string().nullable(), + parentFieldName: z.string().nullable(), + parentArgumentName: z.string().nullable(), + parentEnumValueName: z.string().nullable(), + }), + }); + // Enum export const EnumValueRemovedModel = implement().with({ @@ -346,7 +673,7 @@ export const EnumValueRemovedModel = implement().with({ }), }); -export const EnumValueAdded = implement().with({ +export const EnumValueAddedModel = implement().with({ type: EnumValueAddedLiteral, meta: z.object({ enumName: z.string(), @@ -747,58 +1074,84 @@ export const RegistryServiceUrlChangeModel = // TODO: figure out a way to make sure that all the changes are included in the union // Similar to implement().with() but for unions export const SchemaChangeModel = z.union([ - FieldArgumentDescriptionChangedModel, - FieldArgumentDefaultChangedModel, - FieldArgumentTypeChangedModel, - DirectiveRemovedModel, DirectiveAddedModel, + DirectiveArgumentAddedModel, + DirectiveArgumentDefaultValueChangedModel, + DirectiveArgumentDescriptionChangedModel, + DirectiveArgumentRemovedModel, DirectiveDescriptionChangedModel, DirectiveLocationAddedModel, DirectiveLocationRemovedModel, - DirectiveArgumentAddedModel, - DirectiveArgumentRemovedModel, - DirectiveArgumentDescriptionChangedModel, - DirectiveArgumentDefaultValueChangedModel, + DirectiveRemovedModel, + DirectiveUsageArgumentAddedModel, + DirectiveUsageArgumentDefinitionAddedModel, + DirectiveUsageArgumentDefinitionRemovedModel, + DirectiveUsageArgumentRemovedModel, + DirectiveUsageEnumAddedModel, + DirectiveUsageEnumRemovedModel, + DirectiveUsageEnumValueAddedModel, + DirectiveUsageEnumValueRemovedModel, + DirectiveUsageFieldAddedModel, + DirectiveUsageFieldDefinitionAddedModel, + DirectiveUsageFieldDefinitionRemovedModel, + DirectiveUsageFieldRemovedModel, + DirectiveUsageInputFieldDefinitionAddedModel, + DirectiveUsageInputFieldDefinitionRemovedModel, + DirectiveUsageInputObjectAddedModel, + DirectiveUsageInputObjectRemovedModel, + DirectiveUsageInterfaceAddedModel, + DirectiveUsageInterfaceRemovedModel, + DirectiveUsageObjectAddedModel, + DirectiveUsageObjectRemovedModel, + DirectiveUsageScalarAddedModel, + DirectiveUsageScalarRemovedModel, + DirectiveUsageSchemaAddedModel, + DirectiveUsageSchemaRemovedModel, + DirectiveUsageUnionMemberAddedModel, + DirectiveUsageUnionMemberRemovedModel, DirectiveArgumentTypeChangedModel, - EnumValueRemovedModel, - EnumValueAdded, - EnumValueDescriptionChangedModel, - EnumValueDeprecationReasonChangedModel, + EnumValueAddedModel, EnumValueDeprecationReasonAddedModel, + EnumValueDeprecationReasonChangedModel, EnumValueDeprecationReasonRemovedModel, - FieldRemovedModel, + EnumValueDescriptionChangedModel, + EnumValueRemovedModel, FieldAddedModel, - FieldDescriptionChangedModel, - FieldDescriptionAddedModel, - FieldDescriptionRemovedModel, + FieldArgumentAddedModel, + FieldArgumentDefaultChangedModel, + FieldArgumentRemovedModel, + FieldArgumentTypeChangedModel, FieldDeprecationAddedModel, - FieldDeprecationRemovedModel, - FieldDeprecationReasonChangedModel, FieldDeprecationReasonAddedModel, + FieldDeprecationReasonChangedModel, FieldDeprecationReasonRemovedModel, + FieldDeprecationRemovedModel, + FieldDescriptionAddedModel, + FieldDescriptionChangedModel, + FieldDescriptionRemovedModel, + FieldRemovedModel, FieldTypeChangedModel, - FieldArgumentAddedModel, - FieldArgumentRemovedModel, - InputFieldRemovedModel, InputFieldAddedModel, + InputFieldDefaultValueChangedModel, InputFieldDescriptionAddedModel, - InputFieldDescriptionRemovedModel, InputFieldDescriptionChangedModel, - InputFieldDefaultValueChangedModel, + InputFieldDescriptionRemovedModel, + InputFieldRemovedModel, InputFieldTypeChangedModel, ObjectTypeInterfaceAddedModel, ObjectTypeInterfaceRemovedModel, - SchemaQueryTypeChangedModel, SchemaMutationTypeChangedModel, + SchemaQueryTypeChangedModel, SchemaSubscriptionTypeChangedModel, - TypeRemovedModel, TypeAddedModel, - TypeKindChangedModel, - TypeDescriptionChangedModel, TypeDescriptionAddedModel, + TypeDescriptionChangedModel, TypeDescriptionRemovedModel, - UnionMemberRemovedModel, + TypeKindChangedModel, + TypeRemovedModel, UnionMemberAddedModel, + UnionMemberRemovedModel, + // @here >? // Hive Federation/Stitching Specific RegistryServiceUrlChangeModel, ]); diff --git a/packages/web/app/src/components/proposal/Review.tsx b/packages/web/app/src/components/target/proposals/Review.tsx similarity index 95% rename from packages/web/app/src/components/proposal/Review.tsx rename to packages/web/app/src/components/target/proposals/Review.tsx index ccef69c6c3..6a6f069db8 100644 --- a/packages/web/app/src/components/proposal/Review.tsx +++ b/packages/web/app/src/components/target/proposals/Review.tsx @@ -1,9 +1,9 @@ import { Fragment, ReactElement, useContext } from 'react'; import { FragmentType, graphql, useFragment } from '@/gql'; import { cn } from '@/lib/utils'; -import { Button } from '../ui/button'; -import { CheckIcon, PlusIcon } from '../ui/icon'; -import { TimeAgo } from '../v2'; +import { Button } from '../../ui/button'; +import { CheckIcon, PlusIcon } from '../../ui/icon'; +import { TimeAgo } from '../../v2'; import { AnnotatedContext } from './schema-diff/components'; const ProposalOverview_ReviewCommentsFragment = graphql(/** GraphQL */ ` diff --git a/packages/web/app/src/components/proposal/index.tsx b/packages/web/app/src/components/target/proposals/index.tsx similarity index 100% rename from packages/web/app/src/components/proposal/index.tsx rename to packages/web/app/src/components/target/proposals/index.tsx diff --git a/packages/web/app/src/components/proposal/schema-diff/compare-lists.ts b/packages/web/app/src/components/target/proposals/schema-diff/compare-lists.ts similarity index 100% rename from packages/web/app/src/components/proposal/schema-diff/compare-lists.ts rename to packages/web/app/src/components/target/proposals/schema-diff/compare-lists.ts diff --git a/packages/web/app/src/components/proposal/schema-diff/components.tsx b/packages/web/app/src/components/target/proposals/schema-diff/components.tsx similarity index 98% rename from packages/web/app/src/components/proposal/schema-diff/components.tsx rename to packages/web/app/src/components/target/proposals/schema-diff/components.tsx index 18840e5e37..8d24d4a151 100644 --- a/packages/web/app/src/components/proposal/schema-diff/components.tsx +++ b/packages/web/app/src/components/target/proposals/schema-diff/components.tsx @@ -191,7 +191,7 @@ function Removal(props: { children: ReactNode | string; className?: string }): R return ( @@ -202,9 +202,7 @@ function Removal(props: { children: ReactNode | string; className?: string }): R function Addition(props: { children: ReactNode; className?: string }): ReactNode { return ( - - {props.children} - + {props.children} ); } @@ -1289,20 +1287,28 @@ export function DiffInterfaces(props: { } else { implementsChangeType = 'mutual'; } - const interfaces = [ - ...removed.map(r => {r.name}), - ...added.map(r => {r.name}), - ...mutual.map(({ newVersion: r }) => {r.name}), - ]; + return ( <>  implements  - {/* @todo wrap the ampersand in the appropriate change type */} - {interfaces.map((iface, index) => ( - <> - {iface} - {index !== interfaces.length - 1 && ' & '} - + {/* @todo move amp to other side */} + {...removed.map((r, idx) => ( + + {idx !== 0 && ' & '} + {r.name} + + ))} + {...added.map((r, idx) => ( + + {(removed.length !== 0 || idx !== 0) && ' & '} + {r.name} + + ))} + {...mutual.map(({ newVersion: r }, idx) => ( + + {(removed.length !== 0 || added.length !== 0 || idx !== 0) && ' & '} + {r.name} + ))} ); diff --git a/packages/web/app/src/components/proposal/schema-diff/schema-diff.tsx b/packages/web/app/src/components/target/proposals/schema-diff/schema-diff.tsx similarity index 97% rename from packages/web/app/src/components/proposal/schema-diff/schema-diff.tsx rename to packages/web/app/src/components/target/proposals/schema-diff/schema-diff.tsx index 757692f9f7..15f1468709 100644 --- a/packages/web/app/src/components/proposal/schema-diff/schema-diff.tsx +++ b/packages/web/app/src/components/target/proposals/schema-diff/schema-diff.tsx @@ -1,7 +1,7 @@ /* eslint-disable tailwindcss/no-custom-classname */ import { ReactElement, useMemo } from 'react'; import type { GraphQLSchema } from 'graphql'; -import { isIntrospectionType, isSpecifiedDirective, printSchema } from 'graphql'; +import { isIntrospectionType, isSpecifiedDirective } from 'graphql'; import { isPrimitive } from '@graphql-inspector/core/utils/graphql'; import { compareLists } from './compare-lists'; import { ChangeDocument, DiffDirective, DiffType, SchemaDefinitionDiff } from './components'; diff --git a/packages/web/app/src/components/target/proposals/service-heading.tsx b/packages/web/app/src/components/target/proposals/service-heading.tsx index 6c9d82116c..f06ee78818 100644 --- a/packages/web/app/src/components/target/proposals/service-heading.tsx +++ b/packages/web/app/src/components/target/proposals/service-heading.tsx @@ -1,4 +1,3 @@ -import { Title } from '@/components/ui/page'; import { CubeIcon } from '@radix-ui/react-icons'; export function ServiceHeading(props: { serviceName: string }) { diff --git a/packages/web/app/src/components/proposal/util.ts b/packages/web/app/src/components/target/proposals/util.ts similarity index 100% rename from packages/web/app/src/components/proposal/util.ts rename to packages/web/app/src/components/target/proposals/util.ts diff --git a/packages/web/app/src/pages/target-proposal-checks.tsx b/packages/web/app/src/pages/target-proposal-checks.tsx index 9fcf4c4c05..c50ce96c01 100644 --- a/packages/web/app/src/pages/target-proposal-checks.tsx +++ b/packages/web/app/src/pages/target-proposal-checks.tsx @@ -38,10 +38,12 @@ export function TargetProposalChecksPage(props: { }) { const checks = useFragment(ProposalOverview_ChecksFragment, props.checks); return ( -
+
{checks?.edges?.map(({ node }, index) => { return ( diff --git a/packages/web/app/src/pages/target-proposal-details.tsx b/packages/web/app/src/pages/target-proposal-details.tsx index 96b1c1e3cc..30fb44fbb4 100644 --- a/packages/web/app/src/pages/target-proposal-details.tsx +++ b/packages/web/app/src/pages/target-proposal-details.tsx @@ -1,18 +1,12 @@ import { Fragment, ReactNode, useMemo } from 'react'; -import { ProposalOverview_ReviewsFragment } from '@/components/proposal'; +import { ProposalOverview_ReviewsFragment } from '@/components/target/proposals'; import { ProposalChangeDetail } from '@/components/target/proposals/change-detail'; import { ServiceHeading } from '@/components/target/proposals/service-heading'; import { Button } from '@/components/ui/button'; -import { Subtitle, Title } from '@/components/ui/page'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { FragmentType } from '@/gql'; import { Change, CriticalityLevel } from '@graphql-inspector/core'; -import { - ComponentNoneIcon, - CubeIcon, - ExclamationTriangleIcon, - InfoCircledIcon, -} from '@radix-ui/react-icons'; +import { ComponentNoneIcon, ExclamationTriangleIcon, InfoCircledIcon } from '@radix-ui/react-icons'; import type { ServiceProposalDetails } from './target-proposal-types'; export enum MergeStatus { diff --git a/packages/web/app/src/pages/target-proposal-schema.tsx b/packages/web/app/src/pages/target-proposal-schema.tsx index 71801045b8..8aaec6d630 100644 --- a/packages/web/app/src/pages/target-proposal-schema.tsx +++ b/packages/web/app/src/pages/target-proposal-schema.tsx @@ -1,4 +1,5 @@ -import { Proposal, ProposalOverview_ReviewsFragment } from '@/components/proposal'; +import { Fragment } from 'react'; +import { Proposal, ProposalOverview_ReviewsFragment } from '@/components/target/proposals'; import { ServiceHeading } from '@/components/target/proposals/service-heading'; import { FragmentType } from '@/gql'; import { ServiceProposalDetails } from './target-proposal-types'; @@ -15,10 +16,10 @@ export function TargetProposalSchemaPage(props: { return (
{props.services.map(proposed => ( - <> + - - + + ))}
); diff --git a/packages/web/app/src/pages/target-proposal-supergraph.tsx b/packages/web/app/src/pages/target-proposal-supergraph.tsx index f23247e368..4a593eff26 100644 --- a/packages/web/app/src/pages/target-proposal-supergraph.tsx +++ b/packages/web/app/src/pages/target-proposal-supergraph.tsx @@ -1,7 +1,7 @@ import { buildSchema } from 'graphql'; import { useQuery } from 'urql'; -import { ProposalOverview_ChangeFragment, toUpperSnakeCase } from '@/components/proposal'; -import { SchemaDiff } from '@/components/proposal/schema-diff/schema-diff'; +import { ProposalOverview_ChangeFragment, toUpperSnakeCase } from '@/components/target/proposals'; +import { SchemaDiff } from '@/components/target/proposals/schema-diff/schema-diff'; import { Spinner } from '@/components/ui/spinner'; import { FragmentType, graphql, useFragment } from '@/gql'; import { Change } from '@graphql-inspector/core'; diff --git a/packages/web/app/src/pages/target-proposal-types.ts b/packages/web/app/src/pages/target-proposal-types.ts index 6a61a6255b..e39e548531 100644 --- a/packages/web/app/src/pages/target-proposal-types.ts +++ b/packages/web/app/src/pages/target-proposal-types.ts @@ -1,5 +1,5 @@ import type { GraphQLSchema } from 'graphql'; -import { ProposalOverview_ChangeFragment } from '@/components/proposal'; +import { ProposalOverview_ChangeFragment } from '@/components/target/proposals'; import { FragmentType } from '@/gql'; import type { Change } from '@graphql-inspector/core'; diff --git a/packages/web/app/src/pages/target-proposal.tsx b/packages/web/app/src/pages/target-proposal.tsx index 6b9f0de866..2dddb325bc 100644 --- a/packages/web/app/src/pages/target-proposal.tsx +++ b/packages/web/app/src/pages/target-proposal.tsx @@ -6,7 +6,7 @@ import { ProposalOverview_ChangeFragment, ProposalOverview_ReviewsFragment, toUpperSnakeCase, -} from '@/components/proposal'; +} from '@/components/target/proposals'; import { StageTransitionSelect } from '@/components/target/proposals/stage-transition-select'; import { VersionSelect } from '@/components/target/proposals/version-select'; import { CardDescription } from '@/components/ui/card'; diff --git a/packages/web/app/src/pages/target-proposals.tsx b/packages/web/app/src/pages/target-proposals.tsx index 6c053b2416..9bebd9d973 100644 --- a/packages/web/app/src/pages/target-proposals.tsx +++ b/packages/web/app/src/pages/target-proposals.tsx @@ -1,9 +1,9 @@ import { useState } from 'react'; import { useQuery } from 'urql'; import { Page, TargetLayout } from '@/components/layouts/target'; -import { stageToColor } from '@/components/proposal/util'; import { StageFilter } from '@/components/target/proposals/stage-filter'; import { UserFilter } from '@/components/target/proposals/user-filter'; +import { stageToColor } from '@/components/target/proposals/util'; import { BadgeRounded } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { CardDescription } from '@/components/ui/card'; From a6702b4923a97bdec46f3f2e79f4bc2c01f3e69a Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Mon, 8 Sep 2025 10:53:10 +0200 Subject: [PATCH 43/54] Add padding to bottom of details service groups --- packages/web/app/src/pages/target-proposal-details.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/web/app/src/pages/target-proposal-details.tsx b/packages/web/app/src/pages/target-proposal-details.tsx index 30fb44fbb4..133307cc31 100644 --- a/packages/web/app/src/pages/target-proposal-details.tsx +++ b/packages/web/app/src/pages/target-proposal-details.tsx @@ -85,7 +85,7 @@ export function TargetProposalDetailsPage(props: { return ( -
+
Date: Mon, 29 Sep 2025 18:09:27 -0700 Subject: [PATCH 44/54] Add flag for schema proposals; improve empty proposals view --- .../api/src/modules/auth/lib/authz.ts | 2 +- .../src/modules/collection/module.graphql.ts | 1 + .../modules/collection/resolvers/Target.ts | 16 +++++ packages/services/api/src/shared/entities.ts | 1 + .../web/app/src/components/layouts/target.tsx | 27 ++++---- .../web/app/src/pages/target-proposals.tsx | 62 +++++++++++++++++-- pnpm-lock.yaml | 38 ++++++------ 7 files changed, 109 insertions(+), 38 deletions(-) diff --git a/packages/services/api/src/modules/auth/lib/authz.ts b/packages/services/api/src/modules/auth/lib/authz.ts index 4a25110bca..3913c9a789 100644 --- a/packages/services/api/src/modules/auth/lib/authz.ts +++ b/packages/services/api/src/modules/auth/lib/authz.ts @@ -430,7 +430,7 @@ const permissionsByLevel = { z.literal('appDeployment:publish'), z.literal('appDeployment:retire'), ], - schemaProposal: [z.literal('schemaProposal:modify')], + schemaProposal: [z.literal('schemaProposal:describe'), z.literal('schemaProposal:modify')], } as const; export const allPermissions = [ diff --git a/packages/services/api/src/modules/collection/module.graphql.ts b/packages/services/api/src/modules/collection/module.graphql.ts index 0cf11f1b0a..1602e70d60 100644 --- a/packages/services/api/src/modules/collection/module.graphql.ts +++ b/packages/services/api/src/modules/collection/module.graphql.ts @@ -158,6 +158,7 @@ export const typeDefs = gql` extend type Target { viewerCanViewLaboratory: Boolean! viewerCanModifyLaboratory: Boolean! + viewerCanViewSchemaProposals: Boolean! documentCollection(id: ID!): DocumentCollection documentCollections(first: Int = 100, after: String = null): DocumentCollectionConnection! documentCollectionOperation(id: ID!): DocumentCollectionOperation diff --git a/packages/services/api/src/modules/collection/resolvers/Target.ts b/packages/services/api/src/modules/collection/resolvers/Target.ts index 0aa80d2010..f970c69dc3 100644 --- a/packages/services/api/src/modules/collection/resolvers/Target.ts +++ b/packages/services/api/src/modules/collection/resolvers/Target.ts @@ -1,3 +1,5 @@ +import { OrganizationManager } from '../../organization/providers/organization-manager'; +import { SCHEMA_PROPOSALS_ENABLED } from '../../proposals/providers/schema-proposals-enabled-token'; import { CollectionProvider } from '../providers/collection.provider'; import type { TargetResolvers } from './../../../__generated__/types'; @@ -8,6 +10,7 @@ export const Target: Pick< | 'documentCollections' | 'viewerCanModifyLaboratory' | 'viewerCanViewLaboratory' + | 'viewerCanViewSchemaProposals' | '__isTypeOf' > = { documentCollections: (target, args, { injector }) => @@ -27,6 +30,19 @@ export const Target: Pick< }, }); }, + viewerCanViewSchemaProposals: async (target, _arg, { injector }) => { + const organization = await injector.get(OrganizationManager).getOrganization({ + organizationId: target.orgId, + }); + + if ( + organization.featureFlags.schemaProposals === false && + injector.get(SCHEMA_PROPOSALS_ENABLED) === false + ) { + return false; + } + return true; + }, viewerCanModifyLaboratory: (target, _arg, { session }) => { return session.canPerformAction({ action: 'laboratory:modify', diff --git a/packages/services/api/src/shared/entities.ts b/packages/services/api/src/shared/entities.ts index 3039846ed5..d5924ef50a 100644 --- a/packages/services/api/src/shared/entities.ts +++ b/packages/services/api/src/shared/entities.ts @@ -190,6 +190,7 @@ export interface Organization { */ forceLegacyCompositionInTargets: string[]; appDeployments: boolean; + schemaProposals: boolean; }; zendeskId: string | null; /** ID of the user that owns the organization */ diff --git a/packages/web/app/src/components/layouts/target.tsx b/packages/web/app/src/components/layouts/target.tsx index 3f83cdd2d1..fc53970480 100644 --- a/packages/web/app/src/components/layouts/target.tsx +++ b/packages/web/app/src/components/layouts/target.tsx @@ -68,6 +68,7 @@ const TargetLayoutQuery = graphql(` viewerCanViewLaboratory viewerCanViewAppDeployments viewerCanAccessSettings + viewerCanViewSchemaProposals } } } @@ -231,18 +232,20 @@ export const TargetLayout = ({ )} - - - Proposals - - + {currentTarget.viewerCanViewSchemaProposals && ( + + + Proposals + + + )} {currentTarget.viewerCanAccessSettings && ( { + void router.navigate({ + to: '/$organizationSlug/$projectSlug/$targetSlug', + params: { + organizationSlug: props.organizationSlug, + projectSlug: props.projectSlug, + targetSlug: props.targetSlug, + }, + }); + }, + entity: target, + }); return ( <> @@ -59,7 +106,8 @@ const ProposalsContent = (props: Parameters[0]) => { } />
-
+
+ {/* @todo implement */}
@@ -177,11 +225,13 @@ const ProposalsListPage = (props: { <> {query.fetching ? : null} {query.data?.schemaProposals?.edges?.length === 0 && ( -
- - No proposals {hasFilter ? 'match your search criteria' : 'have been created yet'} - - To get started, use the Hive CLI to propose a schema change. +
+
+ + No proposals {hasFilter ? 'match your search criteria' : 'have been created yet'} + + To get started, use the Hive CLI to propose a schema change. +
)} {query.data?.schemaProposals?.edges?.map(({ node: proposal }) => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d223696385..aece825491 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16408,8 +16408,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 @@ -16516,11 +16516,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 @@ -16559,6 +16559,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.723.0(@aws-sdk/client-sts@3.723.0)': @@ -16692,11 +16693,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 @@ -16735,7 +16736,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.723.0': @@ -16849,7 +16849,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 @@ -16968,7 +16968,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.11 '@smithy/types': 3.7.2 @@ -17143,7 +17143,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.11 '@smithy/shared-ini-file-loader': 3.1.12 @@ -23777,8 +23777,8 @@ snapshots: '@typescript-eslint/parser': 7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3) eslint: 8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0) eslint-config-prettier: 9.1.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)) - eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-plugin-import@2.29.1)(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)) - eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)) + eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)) + eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)) eslint-plugin-jsonc: 2.11.1(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)) eslint-plugin-jsx-a11y: 6.8.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)) eslint-plugin-mdx: 3.0.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)) @@ -26442,13 +26442,13 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-plugin-import@2.29.1)(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)): + eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)): dependencies: debug: 4.4.0(supports-color@8.1.1) enhanced-resolve: 5.17.1 eslint: 8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0) - eslint-module-utils: 2.8.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)) - eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)) + eslint-module-utils: 2.8.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)) + eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)) fast-glob: 3.3.2 get-tsconfig: 4.7.5 is-core-module: 2.13.1 @@ -26479,14 +26479,14 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.8.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)): + eslint-module-utils@2.8.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)): dependencies: debug: 3.2.7(supports-color@8.1.1) optionalDependencies: '@typescript-eslint/parser': 7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3) eslint: 8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-plugin-import@2.29.1)(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)) + eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)) transitivePeerDependencies: - supports-color @@ -26502,7 +26502,7 @@ snapshots: eslint: 8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0) eslint-compat-utils: 0.1.2(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)) - eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)): + eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)): dependencies: array-includes: 3.1.7 array.prototype.findlastindex: 1.2.3 @@ -26512,7 +26512,7 @@ snapshots: doctrine: 2.1.0 eslint: 8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.8.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)) + eslint-module-utils: 2.8.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)) hasown: 2.0.0 is-core-module: 2.13.1 is-glob: 4.0.3 From cd53f1fd0469fe408966f867a5d15542fae4b563 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Tue, 30 Sep 2025 16:09:56 -0700 Subject: [PATCH 45/54] more explanation on migration --- .../src/actions/2025.08.30T00-00-00.schema-proposals.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/migrations/src/actions/2025.08.30T00-00-00.schema-proposals.ts b/packages/migrations/src/actions/2025.08.30T00-00-00.schema-proposals.ts index 4185c33f7c..ba84e59f62 100644 --- a/packages/migrations/src/actions/2025.08.30T00-00-00.schema-proposals.ts +++ b/packages/migrations/src/actions/2025.08.30T00-00-00.schema-proposals.ts @@ -66,7 +66,7 @@ export default { ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4 () , created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() - -- null if just a comment + -- reviews can also be tied to a stage transition event. If the review only contains comments, then this is null , stage_transition schema_proposal_stage NOT NULL , user_id UUID REFERENCES users (id) ON DELETE SET NULL , schema_proposal_id UUID NOT NULL REFERENCES schema_proposals (id) ON DELETE CASCADE From 3471d1343ef4dbe554d2fcd2c6dd65793f11bbb1 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Fri, 3 Oct 2025 15:42:52 -0700 Subject: [PATCH 46/54] create proposal page --- .../app/src/pages/target-proposals-new.tsx | 109 ++++++++++++++++++ .../web/app/src/pages/target-proposals.tsx | 20 +++- packages/web/app/src/router.tsx | 18 ++- pnpm-lock.yaml | 26 ++--- 4 files changed, 155 insertions(+), 18 deletions(-) create mode 100644 packages/web/app/src/pages/target-proposals-new.tsx diff --git a/packages/web/app/src/pages/target-proposals-new.tsx b/packages/web/app/src/pages/target-proposals-new.tsx new file mode 100644 index 0000000000..c085907505 --- /dev/null +++ b/packages/web/app/src/pages/target-proposals-new.tsx @@ -0,0 +1,109 @@ +import { gql, useQuery } from 'urql'; +import { Page, TargetLayout } from '@/components/layouts/target'; +import { CardDescription } from '@/components/ui/card'; +import { Meta } from '@/components/ui/meta'; +import { SubPageLayoutHeader } from '@/components/ui/page-content-layout'; +import { Link } from '@tanstack/react-router'; + +const Proposals_NewProposalQuery = gql(` + query Proposals_NewProposalQuery($targetReference: TargetReferenceInput!) { + target(reference: $targetReference) { + id + slug + latestValidSchemaVersion { + schemas { + edges { + cursor + node { + __typename + ...on CompositeSchema { + id + source + service + url + } + ...on SingleSchema { + id + source + } + } + } + pageInfo { + hasNextPage + endCursor + } + } + } + } + } +`); + +export function TargetProposalsNewPage(props: { + organizationSlug: string; + projectSlug: string; + targetSlug: string; +}) { + return ( + <> + + + + + + + ); +} + +function ProposalsNewHeading(props: Parameters[0]) { + return ( +
+
+ + + Schema Proposals + {' '} + / New + + } + description={ + + Collaborate on schema changes to reduce friction during development. + + } + /> +
+
+ ); +} + +function ProposalsNewContent(props: Parameters[0]) { + const [query] = useQuery({ + query: Proposals_NewProposalQuery, + variables: { + targetReference: { + bySelector: { + organizationSlug: props.organizationSlug, + projectSlug: props.projectSlug, + targetSlug: props.targetSlug, + }, + }, + }, + }); + return
{JSON.stringify(query.data)}
; +} diff --git a/packages/web/app/src/pages/target-proposals.tsx b/packages/web/app/src/pages/target-proposals.tsx index 7ea6634548..7a41a1332b 100644 --- a/packages/web/app/src/pages/target-proposals.tsx +++ b/packages/web/app/src/pages/target-proposals.tsx @@ -18,7 +18,7 @@ import { SchemaProposalStage } from '@/gql/graphql'; import { useRedirect } from '@/lib/access/common'; import { cn } from '@/lib/utils'; import { ChatBubbleIcon } from '@radix-ui/react-icons'; -import { useRouter, useSearch } from '@tanstack/react-router'; +import { useNavigate, useRouter, useSearch } from '@tanstack/react-router'; const TargetProposalsQuery = graphql(` query TargetProposalsQuery( @@ -91,6 +91,17 @@ export function TargetProposalsPage(props: { } const ProposalsContent = (props: Parameters[0]) => { + const navigate = useNavigate(); + const proposeChange = () => { + navigate({ + to: '/$organizationSlug/$projectSlug/$targetSlug/proposals/new', + params: { + organizationSlug: props.organizationSlug, + projectSlug: props.projectSlug, + targetSlug: props.targetSlug, + }, + }); + }; return ( <>
@@ -106,9 +117,10 @@ const ProposalsContent = (props: Parameters[0]) => { } />
-
- {/* @todo implement */} - +
+
diff --git a/packages/web/app/src/router.tsx b/packages/web/app/src/router.tsx index fc1461ff8f..66df0343cf 100644 --- a/packages/web/app/src/router.tsx +++ b/packages/web/app/src/router.tsx @@ -77,6 +77,7 @@ import { TargetInsightsOperationPage } from './pages/target-insights-operation'; import { TargetLaboratoryPage } from './pages/target-laboratory'; import { ProposalTab, TargetProposalsSinglePage } from './pages/target-proposal'; import { TargetProposalsPage } from './pages/target-proposals'; +import { TargetProposalsNewPage } from './pages/target-proposals-new'; import { TargetSettingsPage, TargetSettingsPageEnum } from './pages/target-settings'; SuperTokens.init(frontendConfig()); @@ -866,6 +867,21 @@ const targetProposalsRoute = createRoute({ }, }); +const targetProposalsNewRoute = createRoute({ + getParentRoute: () => targetRoute, + path: 'proposals/new', + component: function TargetProposalRoute() { + const { organizationSlug, projectSlug, targetSlug } = targetProposalsNewRoute.useParams(); + return ( + + ); + }, +}); + const targetProposalsSingleRoute = createRoute({ getParentRoute: () => targetRoute, path: 'proposals/$proposalId', @@ -947,7 +963,7 @@ const routeTree = root.addChildren([ targetChecksRoute.addChildren([targetChecksSingleRoute]), targetAppVersionRoute, targetAppsRoute, - targetProposalsRoute.addChildren([targetProposalsSingleRoute]), + targetProposalsRoute.addChildren([targetProposalsNewRoute, targetProposalsSingleRoute]), ]), ]), ]); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9760b8983f..32b28bc458 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24444,8 +24444,8 @@ snapshots: '@typescript-eslint/parser': 7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3) eslint: 8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0) eslint-config-prettier: 9.1.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)) - eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)) - eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)) + eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-plugin-import@2.29.1)(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)) + eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)) eslint-plugin-jsonc: 2.11.1(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)) eslint-plugin-jsx-a11y: 6.8.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)) eslint-plugin-mdx: 3.0.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)) @@ -26774,11 +26774,11 @@ snapshots: detect-libc@1.0.3: {} - detect-libc@2.0.4: {} - - detect-libc@2.1.1: + detect-libc@2.0.4: optional: true + detect-libc@2.1.1: {} + detect-newline@4.0.1: {} detect-node-es@1.1.0: {} @@ -27180,13 +27180,13 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)): + eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-plugin-import@2.29.1)(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)): dependencies: debug: 4.4.0(supports-color@8.1.1) enhanced-resolve: 5.17.1 eslint: 8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0) - eslint-module-utils: 2.8.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)) - eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)) + eslint-module-utils: 2.8.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)) + eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)) fast-glob: 3.3.2 get-tsconfig: 4.7.5 is-core-module: 2.13.1 @@ -27217,14 +27217,14 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.8.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)): + eslint-module-utils@2.8.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)): dependencies: debug: 3.2.7(supports-color@8.1.1) optionalDependencies: '@typescript-eslint/parser': 7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3) eslint: 8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)) + eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-plugin-import@2.29.1)(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)) transitivePeerDependencies: - supports-color @@ -27240,7 +27240,7 @@ snapshots: eslint: 8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0) eslint-compat-utils: 0.1.2(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)) - eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)): + eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)): dependencies: array-includes: 3.1.7 array.prototype.findlastindex: 1.2.3 @@ -27250,7 +27250,7 @@ snapshots: doctrine: 2.1.0 eslint: 8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.8.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)))(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)) + eslint-module-utils: 2.8.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0))(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.1(patch_hash=08d9d41d21638cb74d0f9f34877a8839601a4e5a8263066ff23e7032addbcba0)) hasown: 2.0.0 is-core-module: 2.13.1 is-glob: 4.0.3 @@ -29581,7 +29581,7 @@ snapshots: lightningcss@1.30.1: dependencies: - detect-libc: 2.0.4 + detect-libc: 2.1.1 optionalDependencies: lightningcss-darwin-arm64: 1.30.1 lightningcss-darwin-x64: 1.30.1 From 1198d0794f392d9a5e2616e112d089ffddfd0cb4 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Mon, 6 Oct 2025 12:39:36 -0700 Subject: [PATCH 47/54] wip --- .../proposals/resolvers/SchemaChange.ts | 2 +- .../modules/schema/resolvers/SchemaChange.ts | 1 - .../app/src/pages/target-proposals-new.tsx | 118 ++++++++++++++++-- pnpm-lock.yaml | 20 +-- 4 files changed, 122 insertions(+), 19 deletions(-) diff --git a/packages/services/api/src/modules/proposals/resolvers/SchemaChange.ts b/packages/services/api/src/modules/proposals/resolvers/SchemaChange.ts index f1a6c7571a..4ec3eef8df 100644 --- a/packages/services/api/src/modules/proposals/resolvers/SchemaChange.ts +++ b/packages/services/api/src/modules/proposals/resolvers/SchemaChange.ts @@ -6,7 +6,7 @@ export function toTitleCase(str: string) { }); } -export const SchemaChange: Pick = { +export const SchemaChange: Pick = { meta: ({ meta, type }, _arg, _ctx) => { // @todo consider validating return { diff --git a/packages/services/api/src/modules/schema/resolvers/SchemaChange.ts b/packages/services/api/src/modules/schema/resolvers/SchemaChange.ts index 30936229de..d10002d633 100644 --- a/packages/services/api/src/modules/schema/resolvers/SchemaChange.ts +++ b/packages/services/api/src/modules/schema/resolvers/SchemaChange.ts @@ -29,7 +29,6 @@ export const SchemaChange: Pick< | 'severityLevel' | 'severityReason' | 'usageStatistics' - | '__isTypeOf' > = { message: (change, args) => { return args.withSafeBasedOnUsageNote && change.isSafeBasedOnUsage === true diff --git a/packages/web/app/src/pages/target-proposals-new.tsx b/packages/web/app/src/pages/target-proposals-new.tsx index c085907505..6141092a4a 100644 --- a/packages/web/app/src/pages/target-proposals-new.tsx +++ b/packages/web/app/src/pages/target-proposals-new.tsx @@ -1,12 +1,24 @@ -import { gql, useQuery } from 'urql'; +import { useEffect, useMemo, useState } from 'react'; +import { useQuery } from 'urql'; import { Page, TargetLayout } from '@/components/layouts/target'; +import { Button } from '@/components/ui/button'; +import { Callout } from '@/components/ui/callout'; import { CardDescription } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; import { Meta } from '@/components/ui/meta'; import { SubPageLayoutHeader } from '@/components/ui/page-content-layout'; +import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select'; +import { Spinner } from '@/components/ui/spinner'; +import { Textarea } from '@/components/ui/textarea'; +import { graphql } from '@/gql'; +import { usePrettify } from '@/lib/hooks'; +import { cn } from '@/lib/utils'; import { Link } from '@tanstack/react-router'; +import { SchemaEditor } from '@theguild/editor'; -const Proposals_NewProposalQuery = gql(` - query Proposals_NewProposalQuery($targetReference: TargetReferenceInput!) { +const ProposalsNewProposalQuery = graphql(` + query ProposalsNewProposalQuery($targetReference: TargetReferenceInput!) { target(reference: $targetReference) { id slug @@ -16,13 +28,13 @@ const Proposals_NewProposalQuery = gql(` cursor node { __typename - ...on CompositeSchema { + ... on CompositeSchema { id source service url } - ...on SingleSchema { + ... on SingleSchema { id source } @@ -92,9 +104,31 @@ function ProposalsNewHeading(props: Parameters[0] ); } +function schemaTitle( + schema: + | { + __typename: 'CompositeSchema'; + id: string; + source: string; + service?: string | null; + url?: string | null; + } + | { + __typename: 'SingleSchema'; + id: string; + source: string; + }, +): string { + if (schema.__typename === 'CompositeSchema') { + return schema.service ?? schema.url ?? schema.id; + } + return ''; +} + function ProposalsNewContent(props: Parameters[0]) { + const [selectedSchemaCursor, setSelectedSchemaCursor] = useState(undefined); const [query] = useQuery({ - query: Proposals_NewProposalQuery, + query: ProposalsNewProposalQuery, variables: { targetReference: { bySelector: { @@ -105,5 +139,75 @@ function ProposalsNewContent(props: Parameters[0] }, }, }); - return
{JSON.stringify(query.data)}
; + + // note: schemas is not paginated for some reason... + const schemaEdges = query.data?.target?.latestValidSchemaVersion?.schemas.edges; + useEffect(() => { + if (schemaEdges?.length === 1) { + setSelectedSchemaCursor(schemaEdges[0].cursor); + } + }, [schemaEdges]); + const selectedSchema = useMemo(() => { + return schemaEdges?.find(s => s.cursor === selectedSchemaCursor)?.node; + }, [query.data, selectedSchemaCursor]); + + const source = usePrettify(selectedSchema?.source ?? ''); + + const schemaOptions = useMemo(() => { + return ( + schemaEdges?.map(edge => ( + + {schemaTitle(edge.node)} + + )) ?? [] + ); + }, [schemaEdges]); + + if (query.fetching) { + return ; + } + if (query.error) { + return ( + + Oops, something went wrong. +
+ {query.error.message} +
+ ); + } + + return ( +
+ +