Skip to content

Commit

Permalink
Convert list hooks implementation to TypeScript (#5472)
Browse files Browse the repository at this point in the history
  • Loading branch information
timleslie committed Apr 15, 2021
1 parent 3c0b46b commit 5f26737
Show file tree
Hide file tree
Showing 8 changed files with 174 additions and 37 deletions.
5 changes: 5 additions & 0 deletions .changeset/gentle-zebras-flow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@keystone-next/utils-legacy': patch
---

Improved types of `arrayToObject`.
3 changes: 2 additions & 1 deletion packages-next/fields/src/Implementation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,8 @@ class Field<P extends string> {
return resolvedData[this.path];
}

async validateInput() {}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async validateInput(args: any) {}

async beforeChange() {}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { createError } from 'apollo-errors';
import { opToType } from './utils';

export const AccessDeniedError = createError('AccessDeniedError', {
message: 'You do not have access to this resource',
Expand All @@ -13,6 +14,12 @@ export const LimitsExceededError = createError('LimitsExceededError', {
options: { showPath: true },
});

export const throwAccessDenied = (type, target, internalData = {}, extraData = {}) => {
type ValueOf<T> = T[keyof T];
export const throwAccessDenied = (
type: ValueOf<typeof opToType>,
target: string,
internalData = {},
extraData = {}
) => {
throw new AccessDeniedError({ data: { type, target, ...extraData }, internalData });
};
Original file line number Diff line number Diff line change
@@ -1,22 +1,46 @@
import { omitBy, arrayToObject } from '@keystone-next/utils-legacy';
import { Implementation } from '@keystone-next/fields';
import { KeystoneContext, ListHooks } from '@keystone-next/types';
import { mapToFields } from './utils';
import { ValidationFailureError } from './graphqlErrors';

type ValidationError = { msg: string; data: {}; internalData: {} };

export class HookManager {
constructor({ fields, hooks, listKey }) {
fields: Implementation<any>[];
hooks: ListHooks<any>;
listKey: string;
fieldsByPath: Record<string, Implementation<any>>;
constructor({
fields,
hooks,
listKey,
}: {
fields: Implementation<any>[];
hooks: ListHooks<any>;
listKey: string;
}) {
this.fields = fields;
this.hooks = hooks;
this.listKey = listKey;
this.fieldsByPath = arrayToObject(this.fields, 'path');
}

_fieldsFromObject(obj) {
_fieldsFromObject(obj: Record<string, any>) {
return Object.keys(obj)
.map(fieldPath => this.fieldsByPath[fieldPath])
.filter(field => field);
}

_throwValidationFailure({ errors, operation, originalInput }) {
_throwValidationFailure({
errors,
operation,
originalInput,
}: {
errors: ValidationError[];
operation: 'create' | 'update' | 'delete';
originalInput: Record<string, any>;
}) {
throw new ValidationFailureError({
data: {
messages: errors.map(e => e.msg),
Expand All @@ -28,14 +52,28 @@ export class HookManager {
});
}

async resolveInput({ resolvedData, existingItem, context, operation, originalInput }) {
async resolveInput({
resolvedData,
existingItem,
context,
operation,
originalInput,
}: {
resolvedData: Record<string, any>;
existingItem?: Record<string, any>;
context: KeystoneContext;
operation: 'create' | 'update';
originalInput: Record<string, any>;
}) {
const { listKey } = this;
const args = { resolvedData, existingItem, context, originalInput, operation, listKey };

// First we run the field type hooks
// NOTE: resolveInput is run on _every_ field, regardless if it has a value
// passed in or not
resolvedData = await mapToFields(this.fields, field => field.resolveInput(args));
resolvedData = await mapToFields(this.fields, (field: Implementation<any>) =>
field.resolveInput(args)
);

// We then filter out the `undefined` results (they should return `null` or
// a value)
Expand All @@ -47,7 +85,8 @@ export class HookManager {
...resolvedData,
...(await mapToFields(
this.fields.filter(field => field.hooks.resolveInput),
field => field.hooks.resolveInput({ ...args, fieldPath: field.path, resolvedData })
(field: Implementation<any>) =>
field.hooks.resolveInput({ ...args, fieldPath: field.path, resolvedData })
)),
};

Expand All @@ -70,7 +109,19 @@ export class HookManager {
return resolvedData;
}

async validateInput({ resolvedData, existingItem, context, operation, originalInput }) {
async validateInput({
resolvedData,
existingItem,
context,
operation,
originalInput,
}: {
resolvedData: Record<string, any>;
existingItem?: Record<string, any>;
context: KeystoneContext;
operation: 'create' | 'update';
originalInput: Record<string, any>;
}) {
const { listKey } = this;
const args = { resolvedData, existingItem, context, originalInput, operation, listKey };
// Check for isRequired
Expand Down Expand Up @@ -98,34 +149,54 @@ export class HookManager {
await this._validateHook({ args, fields, operation, hookName: 'validateInput' });
}

async validateDelete({ existingItem, context, operation }) {
async validateDelete({
existingItem,
context,
operation,
}: {
existingItem?: Record<string, any>;
context: KeystoneContext;
operation: 'delete';
}) {
const { listKey } = this;
const args = { existingItem, context, operation, listKey };
const fields = this.fields;
await this._validateHook({ args, fields, operation, hookName: 'validateDelete' });
}

async _validateHook({ args, fields, operation, hookName }) {
async _validateHook({
args,
fields,
operation,
hookName,
}: {
args: any;
fields: Implementation<any>[];
operation: 'create' | 'update' | 'delete';
hookName: 'validateInput' | 'validateDelete';
}) {
const { originalInput } = args;
const fieldValidationErrors = [];
const fieldValidationErrors: ValidationError[] = [];
// FIXME: Can we do this in a way where we simply return validation errors instead?
const addFieldValidationError = (msg, _data = {}, internalData = {}) =>
const addFieldValidationError = (msg: string, _data = {}, internalData = {}) =>
fieldValidationErrors.push({ msg, data: _data, internalData });
const fieldArgs = { addFieldValidationError, ...args };
await mapToFields(fields, field => field[hookName]({ fieldPath: field.path, ...fieldArgs }));
await mapToFields(fields, (field: Implementation<any>) =>
field[hookName]({ fieldPath: field.path, ...fieldArgs })
);
await mapToFields(
fields.filter(field => field.hooks[hookName]),
field => field.hooks[hookName]({ fieldPath: field.path, ...fieldArgs })
(field: Implementation<any>) => field.hooks[hookName]({ fieldPath: field.path, ...fieldArgs })
);
if (fieldValidationErrors.length) {
this._throwValidationFailure({ errors: fieldValidationErrors, operation, originalInput });
}

if (this.hooks[hookName]) {
const listValidationErrors = [];
await this.hooks[hookName]({
const listValidationErrors: ValidationError[] = [];
await this.hooks[hookName]!({
...args,
addValidationError: (msg, _data = {}, internalData = {}) =>
addValidationError: (msg: string, _data = {}, internalData = {}) =>
listValidationErrors.push({ msg, data: _data, internalData }),
});
if (listValidationErrors.length) {
Expand All @@ -134,39 +205,87 @@ export class HookManager {
}
}

async beforeChange({ resolvedData, existingItem, context, operation, originalInput }) {
async beforeChange({
resolvedData,
existingItem,
context,
operation,
originalInput,
}: {
resolvedData: Record<string, any>;
existingItem?: Record<string, any>;
context: KeystoneContext;
operation: 'create' | 'update';
originalInput: Record<string, any>;
}) {
const { listKey } = this;
const args = { resolvedData, existingItem, context, originalInput, operation, listKey };
await this._runHook({ args, fieldObject: resolvedData, hookName: 'beforeChange' });
}

async beforeDelete({ existingItem, context, operation }) {
async beforeDelete({
existingItem,
context,
operation,
}: {
existingItem: Record<string, any>;
context: KeystoneContext;
operation: 'delete';
}) {
const { listKey } = this;
const args = { existingItem, context, operation, listKey };
await this._runHook({ args, fieldObject: existingItem, hookName: 'beforeDelete' });
}

async afterChange({ updatedItem, existingItem, context, operation, originalInput }) {
async afterChange({
updatedItem,
existingItem,
context,
operation,
originalInput,
}: {
updatedItem: Record<string, any>;
existingItem?: Record<string, any>;
context: KeystoneContext;
operation: 'create' | 'update';
originalInput: Record<string, any>;
}) {
const { listKey } = this;
const args = { updatedItem, originalInput, existingItem, context, operation, listKey };
await this._runHook({ args, fieldObject: updatedItem, hookName: 'afterChange' });
}

async afterDelete({ existingItem, context, operation }) {
async afterDelete({
existingItem,
context,
operation,
}: {
existingItem: Record<string, any>;
context: KeystoneContext;
operation: 'delete';
}) {
const { listKey } = this;
const args = { existingItem, context, operation, listKey };
await this._runHook({ args, fieldObject: existingItem, hookName: 'afterDelete' });
}

async _runHook({ args, fieldObject, hookName }) {
async _runHook({
args,
fieldObject,
hookName,
}: {
args: any;
fieldObject: Record<string, any>;
hookName: keyof ListHooks<any>;
}) {
// Used to apply hooks that only produce side effects
const fields = this._fieldsFromObject(fieldObject);
await mapToFields(fields, field => field[hookName](args));
await mapToFields(fields, (field: Implementation<any>) => field[hookName](args));
await mapToFields(
fields.filter(field => field.hooks[hookName]),
field => field.hooks[hookName]({ fieldPath: field.path, ...args })
(field: Implementation<any>) => field.hooks[hookName]({ fieldPath: field.path, ...args })
);

if (this.hooks[hookName]) await this.hooks[hookName](args);
if (this.hooks[hookName]) await this.hooks[hookName]!(args);
}
}
6 changes: 3 additions & 3 deletions packages-next/keystone/src/lib/core/ListTypes/list.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@ import {
createLazyDeferred,
} from '@keystone-next/utils-legacy';
import { parseListAccess } from '@keystone-next/access-control-legacy';
import { keyToLabel, labelToPath, labelToClass, opToType, mapToFields } from './utils';
import { HookManager } from './hooks';
import { LimitsExceededError, throwAccessDenied } from './graphqlErrors';
import { keyToLabel, labelToPath, labelToClass, opToType, mapToFields } from './utils.ts';
import { HookManager } from './hooks.ts';
import { LimitsExceededError, throwAccessDenied } from './graphqlErrors.ts';

export class List {
constructor(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Implementation } from '@keystone-next/fields';
import { humanize, resolveAllKeys, arrayToObject } from '@keystone-next/utils-legacy';

export const keyToLabel = str => {
export const keyToLabel = (str: string) => {
let label = humanize(str);

// Retain the leading underscore for auxiliary lists
Expand All @@ -10,23 +11,27 @@ export const keyToLabel = str => {
return label;
};

export const labelToPath = str => str.split(' ').join('-').toLowerCase();
export const labelToPath = (str: string) => str.split(' ').join('-').toLowerCase();

export const labelToClass = str => str.replace(/\s+/g, '');
export const labelToClass = (str: string) => str.replace(/\s+/g, '');

export const opToType = {
read: 'query',
create: 'mutation',
update: 'mutation',
delete: 'mutation',
};
} as const;

export const mapToFields = (fields, action) =>
export const mapToFields = (
fields: Implementation<any>[],
action: (f: Implementation<any>) => Promise<unknown>
) =>
resolveAllKeys(arrayToObject(fields, 'path', action)).catch(error => {
if (!error.errors) {
throw error;
}
const errorCopy = new Error(error.message || error.toString());
// @ts-ignore
errorCopy.errors = Object.values(error.errors);
throw errorCopy;
});
2 changes: 1 addition & 1 deletion packages-next/keystone/src/lib/core/tests/List.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { gql } from 'apollo-server-express';
import { print } from 'graphql/language/printer';
import { text, relationship } from '@keystone-next/fields';
import { List } from '../ListTypes';
import { AccessDeniedError } from '../ListTypes/graphqlErrors';
import { AccessDeniedError } from '../ListTypes/graphqlErrors.ts';

const Relationship = relationship().type;
const Text = text().type;
Expand Down
4 changes: 2 additions & 2 deletions packages/utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,9 +130,9 @@ export const filterValues = <T>(obj: T, predicate: (value: T[keyof T]) => boolea
* @param {String} keyedBy The property on the input objects to key the result.
* @param {Function} mapFn A function returning the output object values. Takes each full input object.
*/
export const arrayToObject = <V extends string, T extends Record<string, V>, R>(
export const arrayToObject = <K extends string, V extends string, T extends Record<K, V>, R>(
objs: T[],
keyedBy: keyof T,
keyedBy: K,
mapFn: (a: T) => R = i => i as any
) => objs.reduce((acc, obj) => ({ ...acc, [obj[keyedBy]]: mapFn(obj) }), {} as Record<V, R>);

Expand Down

1 comment on commit 5f26737

@vercel
Copy link

@vercel vercel bot commented on 5f26737 Apr 15, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.