diff --git a/src/configs/base.ts b/src/configs/base.ts index e37e5b3..37327d5 100644 --- a/src/configs/base.ts +++ b/src/configs/base.ts @@ -21,7 +21,8 @@ export = { skipGraphQLConfig: true }, rules: { - '@salesforce/lwc-mobile/mutation-not-supported': 'warn' + '@salesforce/lwc-mobile/mutation-not-supported': 'warn', + '@salesforce/lwc-mobile/offline-graphql-unsupported-scope': 'warn' } } ] diff --git a/src/index.ts b/src/index.ts index b9c6e64..05a629c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,6 +12,12 @@ import { rule as mutionNotSupported, NO_MUTATION_SUPPORTED_RULE_ID } from './rules/graphql/no-mutation-supported.js'; + +import { + rule as offlineGraphqlUnsupportedScope, + OFFLINE_GRAPHQL_UNSUPPORTED_SCOPE_RULE_ID +} from './rules/graphql/offline-graphql-unsupported-scope.js'; + import { name, version } from '../package.json'; export = { configs: { @@ -24,6 +30,7 @@ export = { }, rules: { 'enforce-foo-bar': enforceFooBar, - [NO_MUTATION_SUPPORTED_RULE_ID]: mutionNotSupported + [NO_MUTATION_SUPPORTED_RULE_ID]: mutionNotSupported, + [OFFLINE_GRAPHQL_UNSUPPORTED_SCOPE_RULE_ID]: offlineGraphqlUnsupportedScope } }; diff --git a/src/rules/graphql/offline-graphql-unsupported-scope.ts b/src/rules/graphql/offline-graphql-unsupported-scope.ts new file mode 100644 index 0000000..aebc55a --- /dev/null +++ b/src/rules/graphql/offline-graphql-unsupported-scope.ts @@ -0,0 +1,106 @@ +import { GraphQLESLintRule, GraphQLESLintRuleContext } from '@graphql-eslint/eslint-plugin'; +import { Kind, FieldNode } from 'graphql'; +import { GraphQLESTreeNode } from './types'; +export const OFFLINE_GRAPHQL_UNSUPPORTED_SCOPE_RULE_ID = 'offline-graphql-unsupported-scope'; + +export const ASSIGNED_TO_ME_SUPPORTED_FOR_SERVICEAPPOINTMENT_ONLY = + 'ASSIGNED_TO_ME__SERVICEAPPOINTMENT_ONLY'; +export const OTHER_UNSUPPORTED_SCOPE = 'OTHER_UNSUPPORTED_SCOPE'; +export const rule: GraphQLESLintRule = { + meta: { + type: 'problem', + docs: { + description: `For mobile offline use cases, scope "ASSIGNEDTOME" is only supported for ServiceAppointment . All other unsupported scopes are team, queue-owned, user-owned and everything. `, + category: 'Operations', + recommended: true, + examples: [ + { + title: 'Incorrect', + code: /* GraphQL */ ` + query scopeQuery { + uiapi { + query { + Case(scope: EVERYTHING) { + edges { + node { + Id + } + } + } + } + } + } + ` + }, + { + title: 'Incorrect', + code: /* GraphQL */ ` + query assignedtomeQuery { + uiapi { + query { + Case(scope: ASSIGNEDTOME) { + edges { + node { + Id + } + } + } + } + } + } + ` + } + ] + }, + messages: { + [ASSIGNED_TO_ME_SUPPORTED_FOR_SERVICEAPPOINTMENT_ONLY]: + 'Offline GraphQL: Scope ‘ASSIGNEDTOME’ is only supported for the ServiceAppointment entity, for mobile offline use cases', + [OTHER_UNSUPPORTED_SCOPE]: + 'Offline GraphQL: Scope "{{scopeName}}" is unsupported for mobile offline use cases.' + }, + schema: [] + }, + + create(context: GraphQLESLintRuleContext) { + return { + Argument(node) { + if (node.name.value === 'scope') { + if (node.value.kind === Kind.ENUM) { + const scopeName = node.value.value; + if ( + scopeName === 'TEAM' || + scopeName === 'QUEUE_OWNED' || + scopeName === 'USER_OWNED' || + scopeName === 'EVERYTHING' + ) { + context.report({ + messageId: OTHER_UNSUPPORTED_SCOPE, + data: { + scopeName + }, + loc: { + start: node.loc.start, + end: node.value.loc.end + } + }); + } else if (node.value.value === 'ASSIGNEDTOME') { + const entityNode = node.parent as GraphQLESTreeNode; + if ( + entityNode.name.kind === Kind.NAME && + entityNode.name.value !== 'ServiceAppointment' + ) { + context.report({ + messageId: ASSIGNED_TO_ME_SUPPORTED_FOR_SERVICEAPPOINTMENT_ONLY, + loc: { + start: node.loc.start, + end: node.value.loc.end + } + }); + } + } + } + } + } + }; + } +}; diff --git a/src/rules/graphql/types.ts b/src/rules/graphql/types.ts new file mode 100644 index 0000000..e4bf90b --- /dev/null +++ b/src/rules/graphql/types.ts @@ -0,0 +1,138 @@ +import { AST } from 'eslint'; +import { Comment, SourceLocation } from 'estree'; +import { + ArgumentNode, + ASTNode, + DefinitionNode, + DirectiveDefinitionNode, + DirectiveNode, + DocumentNode, + EnumTypeDefinitionNode, + EnumTypeExtensionNode, + EnumValueDefinitionNode, + ExecutableDefinitionNode, + FieldDefinitionNode, + FieldNode, + FragmentSpreadNode, + InlineFragmentNode, + InputObjectTypeDefinitionNode, + InputObjectTypeExtensionNode, + InputValueDefinitionNode, + InterfaceTypeDefinitionNode, + InterfaceTypeExtensionNode, + ListTypeNode, + NamedTypeNode, + NameNode, + NonNullTypeNode, + ObjectTypeDefinitionNode, + ObjectTypeExtensionNode, + OperationTypeDefinitionNode, + SelectionNode, + SelectionSetNode, + TypeDefinitionNode, + TypeExtensionNode, + TypeInfo, + TypeNode, + VariableDefinitionNode, + VariableNode +} from 'graphql'; + +type SafeGraphQLType = T extends { type: TypeNode } + ? Omit & { gqlType: T['type'] } + : Omit; + +type Writeable = { -readonly [K in keyof T]: T[K] }; + +export type TypeInformation = { + argument: ReturnType; + defaultValue: ReturnType; + directive: ReturnType; + enumValue: ReturnType; + fieldDef: ReturnType; + inputType: ReturnType; + parentInputType: ReturnType; + parentType: ReturnType; + gqlType: ReturnType; +}; + +type NodeWithName = + | ArgumentNode + | DirectiveDefinitionNode + | EnumValueDefinitionNode + | ExecutableDefinitionNode + | FieldDefinitionNode + | FieldNode + | FragmentSpreadNode + | NamedTypeNode + | TypeDefinitionNode + | TypeExtensionNode + | VariableNode; + +type NodeWithType = + | FieldDefinitionNode + | InputValueDefinitionNode + | ListTypeNode + | NonNullTypeNode + | OperationTypeDefinitionNode + | VariableDefinitionNode; + +type ParentNode = T extends DocumentNode + ? AST.Program + : T extends DefinitionNode + ? DocumentNode + : T extends EnumValueDefinitionNode + ? EnumTypeDefinitionNode | EnumTypeExtensionNode + : T extends InputValueDefinitionNode + ? + | DirectiveDefinitionNode + | FieldDefinitionNode + | InputObjectTypeDefinitionNode + | InputObjectTypeExtensionNode + : T extends FieldDefinitionNode + ? + | InterfaceTypeDefinitionNode + | InterfaceTypeExtensionNode + | ObjectTypeDefinitionNode + | ObjectTypeExtensionNode + : T extends SelectionSetNode + ? ExecutableDefinitionNode | FieldNode | InlineFragmentNode + : T extends SelectionNode + ? SelectionSetNode + : T extends TypeNode + ? NodeWithType + : T extends NameNode + ? NodeWithName + : T extends DirectiveNode + ? InputObjectTypeDefinitionNode | ObjectTypeDefinitionNode + : T extends VariableNode + ? VariableDefinitionNode + : unknown; // Explicitly show error to add new ternary with parent nodes + +type Node = + // Remove readonly for friendly editor popup + Writeable> & { + type: T['kind']; + loc: SourceLocation; + range: AST.Range; + leadingComments: Comment[]; + typeInfo: () => WithTypeInfo extends true ? TypeInformation : Record; + rawNode: () => T; + parent: GraphQLESTreeNode>; + }; + +export type GraphQLESTreeNode = + // if value is ASTNode => convert to Node type + T extends ASTNode + ? { + // Loop recursively over object values + [K in keyof Node]: Node[K] extends + | ReadonlyArray + | undefined // If optional readonly array => loop over array items + ? GraphQLESTreeNode[] + : GraphQLESTreeNode[K], W>; + } + : // If Program node => add `parent: null` field + T extends AST.Program + ? T & { parent: null } + : // Return value as is + T; diff --git a/test/rules/graphql/no-mutation-supported.spec.ts b/test/rules/graphql/no-mutation-supported.spec.ts index d44c9b1..53c6d8f 100644 --- a/test/rules/graphql/no-mutation-supported.spec.ts +++ b/test/rules/graphql/no-mutation-supported.spec.ts @@ -4,12 +4,9 @@ import { NO_MUTATION_SUPPORTED_RULE_ID } from '../../../src/rules/graphql/no-mutation-supported'; -const ruleTester = new RuleTester({ - parser: '@graphql-eslint/eslint-plugin', - parserOptions: { - graphQLConfig: {} - } -}); +import { RULE_TESTER_CONFIG } from '../../shared'; + +const ruleTester = new RuleTester(RULE_TESTER_CONFIG); ruleTester.run('@salesforce/lwc-mobile/no-mutation-supported', rule as any, { valid: [], diff --git a/test/rules/graphql/offline-graphql-unsupported-scope.spec.ts b/test/rules/graphql/offline-graphql-unsupported-scope.spec.ts new file mode 100644 index 0000000..7800f66 --- /dev/null +++ b/test/rules/graphql/offline-graphql-unsupported-scope.spec.ts @@ -0,0 +1,82 @@ +import { RuleTester } from '@typescript-eslint/rule-tester'; +import { + rule, + ASSIGNED_TO_ME_SUPPORTED_FOR_SERVICEAPPOINTMENT_ONLY, + OTHER_UNSUPPORTED_SCOPE +} from '../../../src/rules/graphql/offline-graphql-unsupported-scope'; +import { RULE_TESTER_CONFIG } from '../../shared'; + +const ruleTester = new RuleTester(RULE_TESTER_CONFIG); + +ruleTester.run('@salesforce/lwc-mobile/offline-graphql-unsupported-scope', rule as any, { + valid: [ + { + code: /* GraphQL */ ` + query scopeQuery { + uiapi { + query { + ServiceAppointment(first: 20, scope: ASSIGNEDTOME) { + edges { + node { + Id + Name { + value + } + } + } + } + } + } + } + ` + } + ], + invalid: [ + { + code: /* GraphQL */ ` + query scopeQuery { + uiapi { + query { + Case(scope: EVERYTHING) { + edges { + node { + Id + } + } + } + } + } + } + `, + errors: [ + { + messageId: OTHER_UNSUPPORTED_SCOPE, + data: { + scopeName: 'EVERYTHING' + } + } + ] + }, + { + code: /* GraphQL */ ` + query scopeQuery { + uiapi { + query { + Case(first: 20, scope: ASSIGNEDTOME) { + edges { + node { + Id + Name { + value + } + } + } + } + } + } + } + `, + errors: [{ messageId: ASSIGNED_TO_ME_SUPPORTED_FOR_SERVICEAPPOINTMENT_ONLY }] + } + ] +}); diff --git a/test/shared.ts b/test/shared.ts new file mode 100644 index 0000000..a732a85 --- /dev/null +++ b/test/shared.ts @@ -0,0 +1,6 @@ +export const RULE_TESTER_CONFIG = { + parser: '@graphql-eslint/eslint-plugin', + parserOptions: { + graphQLConfig: {} + } +};