Skip to content

Commit

Permalink
offline supported scope rule added
Browse files Browse the repository at this point in the history
  • Loading branch information
haifeng-li-at-salesforce committed May 20, 2024
1 parent 52b93d7 commit 61b4a83
Show file tree
Hide file tree
Showing 7 changed files with 345 additions and 8 deletions.
3 changes: 2 additions & 1 deletion src/configs/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}
}
]
Expand Down
9 changes: 8 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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
}
};
106 changes: 106 additions & 0 deletions src/rules/graphql/offline-graphql-unsupported-scope.ts
Original file line number Diff line number Diff line change
@@ -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<FieldNode>;
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
}
});
}
}
}
}
}
};
}
};
138 changes: 138 additions & 0 deletions src/rules/graphql/types.ts
Original file line number Diff line number Diff line change
@@ -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 ASTNode> = T extends { type: TypeNode }
? Omit<T, 'loc' | 'type'> & { gqlType: T['type'] }
: Omit<T, 'loc'>;

type Writeable<T> = { -readonly [K in keyof T]: T[K] };

export type TypeInformation = {
argument: ReturnType<TypeInfo['getArgument']>;
defaultValue: ReturnType<TypeInfo['getDefaultValue']>;
directive: ReturnType<TypeInfo['getDirective']>;
enumValue: ReturnType<TypeInfo['getEnumValue']>;
fieldDef: ReturnType<TypeInfo['getFieldDef']>;
inputType: ReturnType<TypeInfo['getInputType']>;
parentInputType: ReturnType<TypeInfo['getParentInputType']>;
parentType: ReturnType<TypeInfo['getParentType']>;
gqlType: ReturnType<TypeInfo['getType']>;
};

type NodeWithName =
| ArgumentNode
| DirectiveDefinitionNode
| EnumValueDefinitionNode
| ExecutableDefinitionNode
| FieldDefinitionNode
| FieldNode
| FragmentSpreadNode
| NamedTypeNode
| TypeDefinitionNode
| TypeExtensionNode
| VariableNode;

type NodeWithType =
| FieldDefinitionNode
| InputValueDefinitionNode
| ListTypeNode
| NonNullTypeNode
| OperationTypeDefinitionNode
| VariableDefinitionNode;

type ParentNode<T> = 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<T extends ASTNode, WithTypeInfo extends boolean> =
// Remove readonly for friendly editor popup
Writeable<SafeGraphQLType<T>> & {
type: T['kind'];
loc: SourceLocation;
range: AST.Range;
leadingComments: Comment[];
typeInfo: () => WithTypeInfo extends true ? TypeInformation : Record<string, never>;
rawNode: () => T;
parent: GraphQLESTreeNode<ParentNode<T>>;
};

export type GraphQLESTreeNode<T, W extends boolean = false> =
// if value is ASTNode => convert to Node type
T extends ASTNode
? {
// Loop recursively over object values
[K in keyof Node<T, W>]: Node<T, W>[K] extends
| ReadonlyArray<infer ArrayItem>
| undefined // If optional readonly array => loop over array items
? GraphQLESTreeNode<ArrayItem, W>[]
: GraphQLESTreeNode<Node<T, W>[K], W>;
}
: // If Program node => add `parent: null` field
T extends AST.Program
? T & { parent: null }
: // Return value as is
T;
9 changes: 3 additions & 6 deletions test/rules/graphql/no-mutation-supported.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [],
Expand Down
82 changes: 82 additions & 0 deletions test/rules/graphql/offline-graphql-unsupported-scope.spec.ts
Original file line number Diff line number Diff line change
@@ -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 }]
}
]
});
6 changes: 6 additions & 0 deletions test/shared.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export const RULE_TESTER_CONFIG = {
parser: '@graphql-eslint/eslint-plugin',
parserOptions: {
graphQLConfig: {}
}
};

0 comments on commit 61b4a83

Please sign in to comment.