diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000000..df6f38ae13 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,2 @@ +**/tools/templates/crud/client-react/graphql/* +**/tools/templates/crud/server-ts/*.graphql \ No newline at end of file diff --git a/modules/core/common/log.ts b/modules/core/common/log.ts index 86776ca598..cb7827594c 100644 --- a/modules/core/common/log.ts +++ b/modules/core/common/log.ts @@ -10,7 +10,7 @@ const log = minilog(loggerName); (log as any).suggest.defaultResult = false; (log as any).suggest.clear().allow(loggerName, settings.app.logging.level); -if (__DEV__ && __SERVER__ && !__TEST__) { +if (typeof __DEV__ !== 'undefined' && typeof __SERVER__ !== 'undefined' && typeof __TEST__ !== 'undefined') { const consoleLog = global.console.log; global.console.log = (...args: any[]) => { if (args.length === 1 && typeof args[0] === 'string' && args[0].match(/^\[(HMR|WDS)\]/)) { diff --git a/modules/core/common/net.ts b/modules/core/common/net.ts index 1c2155202f..755c8e80ba 100644 --- a/modules/core/common/net.ts +++ b/modules/core/common/net.ts @@ -1,17 +1,19 @@ import url from 'url'; import { PLATFORM } from './utils'; +const apiUrlDefine = typeof __API_URL__ !== 'undefined' ? __API_URL__ : '/graphql'; + export const serverPort = PLATFORM === 'server' && (process.env.PORT || (typeof __SERVER_PORT__ !== 'undefined' ? __SERVER_PORT__ : 8080)); -export const isApiExternal = !!url.parse(__API_URL__).protocol; +export const isApiExternal = !!url.parse(apiUrlDefine).protocol; const clientApiUrl = !isApiExternal && PLATFORM === 'web' ? `${window.location.protocol}//${window.location.hostname}${ __DEV__ ? ':8080' : window.location.port ? ':' + window.location.port : '' - }${__API_URL__}` - : __API_URL__; + }${apiUrlDefine}` + : apiUrlDefine; -const serverApiUrl = !isApiExternal ? `http://localhost:${serverPort}${__API_URL__}` : __API_URL__; +const serverApiUrl = !isApiExternal ? `http://localhost:${serverPort}${apiUrlDefine}` : apiUrlDefine; export const apiUrl = PLATFORM === 'server' ? serverApiUrl : clientApiUrl; diff --git a/modules/core/server-ts/api/rootSchema.graphql b/modules/core/server-ts/api/rootSchema.graphql index 71d9551662..5533e213a6 100644 --- a/modules/core/server-ts/api/rootSchema.graphql +++ b/modules/core/server-ts/api/rootSchema.graphql @@ -3,6 +3,21 @@ type FieldError { message: String! } +type PageInfo { + totalCount: Int + hasNextPage: Boolean +} + +type BatchPayload { + count: Int + errors: [FieldError!] +} + +input OrderByInput { + column: String + order: String +} + type Query { dummy: Int } diff --git a/modules/core/server-ts/generatedSchemas.js b/modules/core/server-ts/generatedSchemas.js new file mode 100644 index 0000000000..ff8b4c5632 --- /dev/null +++ b/modules/core/server-ts/generatedSchemas.js @@ -0,0 +1 @@ +export default {}; diff --git a/modules/core/server-ts/index.ts b/modules/core/server-ts/index.ts index 4574816de0..53ace60882 100644 --- a/modules/core/server-ts/index.ts +++ b/modules/core/server-ts/index.ts @@ -5,6 +5,8 @@ import { createServer } from './entry'; export { serverPromise } from './entry'; export { createSchema } from './api/schema'; +export { default as schemas } from './generatedSchemas'; + export default new ServerModule({ onAppCreate: [createServer] }); diff --git a/modules/database/server-ts/sql/crud.js b/modules/database/server-ts/sql/crud.js index ff3f59aa7e..bef26b0f51 100644 --- a/modules/database/server-ts/sql/crud.js +++ b/modules/database/server-ts/sql/crud.js @@ -1,14 +1,18 @@ import _ from 'lodash'; import uuidv4 from 'uuid'; -import { camelize, decamelizeKeys, camelizeKeys } from 'humps'; +import { decamelize, decamelizeKeys, camelize, camelizeKeys, pascalize } from 'humps'; import { log } from '@gqlapp/core-common'; +import knexnest from 'knexnest'; +import parseFields from 'graphql-parse-fields'; +import moment from 'moment'; +import { FieldError } from '@gqlapp/validation-common-react'; import knex from './connector'; - -import { orderedFor } from './helpers'; - +import { selectBy, orderedFor } from './helpers'; import selectAdapter from './select'; +const dateFormat = 'YYYY-MM-DD'; + export function createWithIdGenAdapter(options) { const T = options.table; let idGen = uuidv4; @@ -402,3 +406,630 @@ export function deleteRelationAdapter(options) { } }; } + +export class Crud { + getTableName() { + return decamelize(this.schema.__.tableName ? this.schema.__.tableName : this.schema.name); + } + + getFullTableName() { + return `${this.schema.__.tablePrefix}${this.getTableName()}`; + } + + isSortable() { + if (this.schema.__.sortable) { + return true; + } + return false; + } + + sortableField() { + if (this.schema.__.sortable) { + return this.schema.__.sortable; + } + return null; + } + + getSchema() { + return this.schema; + } + + getBaseQuery() { + return knex(`${this.getFullTableName()} as ${this.getTableName()}`); + } + + normalizeFields(data) { + //console.log('normalizeFields: ', data); + for (const key of _.keys(data)) { + if (this.schema.values.hasOwnProperty(key)) { + const value = this.schema.values[key]; + const hasTypeOf = targetType => value.type === targetType || value.type.prototype instanceof targetType; + if (hasTypeOf(Date)) { + data[key] = moment(data[key]).format('YYYY-MM-DD'); + } + } + } + //console.log('normalizeFields: ', data); + return data; + } + + _getList({ limit, offset, orderBy, filter }, info) { + const select = selectBy(this.schema, info, false); + const queryBuilder = select(this.getBaseQuery()); + + if (limit) { + queryBuilder.limit(limit); + } + + if (offset) { + queryBuilder.offset(offset); + } + + if (orderBy && orderBy.column) { + let column = orderBy.column; + const order = orderBy.order ? orderBy.order : 'asc'; + + for (const key of this.schema.keys()) { + if (column !== key) { + continue; + } + const value = this.schema.values[key]; + if (value.type.isSchema) { + const tableName = decamelize(value.type.__.tableName ? value.type.__.tableName : value.type.name); + const foundValue = value.type.keys().find(key => { + return value.type.values[key].sortBy; + }); + column = `${tableName}.${foundValue ? foundValue : 'name'}`; + } else { + column = `${this.getTableName()}.${decamelize(column)}`; + } + } + queryBuilder.orderBy(column, order); + } else { + queryBuilder.orderBy(`${this.getTableName()}.id`); + } + + this._filter(filter, this.schema, queryBuilder, this.getTableName()); + + return knexnest(queryBuilder); + } + + _filter(filter, schema, queryBuilder, tableName) { + if (!_.isEmpty(filter)) { + const addFilterWhere = this.schemaIterator( + (filterKey, value, isSchema, isArray, _this, tableName, schema, filter) => { + const hasTypeOf = targetType => value.type === targetType || value.type.prototype instanceof targetType; + if (hasTypeOf(Date)) { + if (filter[`${filterKey}_lte`]) { + const filterValue_lte = moment(filter[`${filterKey}_lte`]).format(dateFormat); + _this.andWhere(`${tableName}.${decamelize(filterKey)}`, '<=', `${filterValue_lte}`); + } + if (filter[`${filterKey}_gte`]) { + const filterValue_gte = moment(filter[`${filterKey}_gte`]).format(dateFormat); + _this.andWhere(`${tableName}.${decamelize(filterKey)}`, '>=', `${filterValue_gte}`); + } + } else if (hasTypeOf(Boolean)) { + if (_.has(filter, filterKey)) { + _this.andWhere(`${tableName}.${decamelize(filterKey)}`, '=', `${+filter[filterKey]}`); + } + } else if (isArray) { + // do nothing + } else { + const tableColumn = isSchema ? `${decamelize(filterKey)}_id` : decamelize(filterKey); + + const filterValue = isSchema ? filter[`${filterKey}Id`] : filter[filterKey]; + if (filterValue) { + _this.andWhere(`${tableName}.${tableColumn}`, '=', `${filterValue}`); + } + + const filterValueIn = isSchema ? filter[`${filterKey}Id_in`] : filter[`${filterKey}_in`]; + if (filterValueIn) { + _this.whereIn(`${tableName}.${tableColumn}`, filterValueIn); + } + + const filterValueContains = isSchema ? filter[`${filterKey}Id_contains`] : filter[`${filterKey}_contains`]; + if (filterValueContains) { + _this.andWhere(`${tableName}.${tableColumn}`, 'like', `%${filterValueContains}%`); + } + + const filterValueGt = isSchema ? filter[`${filterKey}Id_gt`] : filter[`${filterKey}_gt`]; + if (_.isString(filterValueGt) || _.isNumber(filterValueGt)) { + _this.andWhere(`${tableName}.${tableColumn}`, '>', filterValueGt); + } + } + } + ); + + for (const key of schema.keys()) { + const value = schema.values[key]; + const isArray = value.type.constructor === Array; + if (isArray && !_.isEmpty(filter[key])) { + const type = value.type[0]; + const fieldName = decamelize(key); + const prefix = type.__.tablePrefix ? type.__.tablePrefix : ''; + const foreignTableName = decamelize(type.__.tableName ? type.__.tableName : type.name); + const baseTableName = decamelize(schema.name); + const suffix = value.noIdSuffix ? '' : '_id'; + + queryBuilder.leftJoin( + `${prefix}${foreignTableName} as ${fieldName}`, + `${baseTableName}.id`, + `${fieldName}.${baseTableName}${suffix}` + ); + + this._filter(filter[key], value.type[0], queryBuilder, fieldName); + } + } + + queryBuilder.where(function() { + addFilterWhere(this, tableName, schema, filter); + }); + if (filter.searchText) { + const addSearchTextWhere = this.schemaIterator( + (key, value, isSchema, isArray, _this, tableName, schema, filter) => { + if (value.searchText) { + _this.orWhere(`${tableName}.${decamelize(key)}`, 'like', `%${filter.searchText}%`); + } + } + ); + queryBuilder.where(function() { + addSearchTextWhere(this, tableName, schema, filter); + }); + } + } + } + + schemaIterator = fn => { + return (_this, tableName, schema, filter) => { + for (const key of schema.keys()) { + const value = schema.values[key]; + const isSchema = value.type.isSchema; + const isArray = value.type.constructor === Array; + fn(key, value, isSchema, isArray, _this, tableName, schema, filter); + } + }; + }; + + async getPaginated(args, info) { + const edges = await this._getList(args, parseFields(info).edges); + const { count } = await this.getTotal(args); + + return { + edges, + pageInfo: { + totalCount: count, + hasNextPage: edges && edges.length === args.limit + } + }; + } + + getList(args, info) { + return this._getList(args, parseFields(info)); + } + + getTotal(args = {}) { + const queryBuilder = knex(`${this.getFullTableName()} as ${this.getTableName()}`) + .countDistinct(`${this.getTableName()}.id as count`) + .first(); + + if (args.filter) { + this._filter(args.filter, this.schema, queryBuilder, this.getTableName()); + } + return queryBuilder; + } + + _get({ where }, info) { + const baseQuery = knex(`${this.getFullTableName()} as ${this.getTableName()}`); + const select = selectBy(this.schema, info, true); + + const tableName = this.getTableName(); + baseQuery.where(function() { + Object.keys(where).map(key => { + if (key.endsWith('_in')) { + const keyIn = key.substring(0, key.length - 3); + this.whereIn(`${tableName}.${decamelize(keyIn)}`, where[key]); + } else { + this.andWhere(`${tableName}.${decamelize(key)}`, '=', where[key]); + } + }); + }); + + return knexnest(select(baseQuery)); + } + + _getMany( + { + where: { id_in } + }, + info + ) { + const baseQuery = knex(`${this.getFullTableName()} as ${this.getTableName()}`); + const select = selectBy(this.schema, info); + + baseQuery.whereIn('id', [...id_in]); + + return knexnest(select(baseQuery)); + } + + async get(args, info) { + const node = await this._get(args, parseFields(info).node); + return { node }; + } + + _create(data) { + return knex(this.getFullTableName()) + .insert(decamelizeKeys(this.normalizeFields(data))) + .returning('id'); + } + + async create({ data }, ctx, info) { + try { + const e = new FieldError(); + e.throwIf(); + + // extract nested entries from data + let nestedEntries = []; + for (const key of this.schema.keys()) { + const value = this.schema.values[key]; + if (value.type.constructor === Array && data[key]) { + nestedEntries.push({ key, data: data[key] }); + delete data[key]; + } + } + + if (this.isSortable()) { + const total = await this.getTotal(); + data[this.sortableField()] = total.count + 1; + } + + const [id] = await this._create(data); + + // create nested entries + if (nestedEntries.length > 0) { + nestedEntries.map(nested => { + if (nested.data.create) { + nested.data.create.map(async create => { + create[`${camelize(this.schema.name)}Id`] = id; + await ctx[pascalize(nested.key)]._create(create); + }); + } + }); + } + + return await this.get({ where: { id } }, info); + } catch (e) { + await ctx.Log.create({ + type: 'error', + module: this.getTableName(), + action: 'create', + message: JSON.stringify(e), + userId: ctx.user.id + }); + return { errors: e }; + } + } + + _update({ data, where }, ctx) { + // extract nested entries from data + let nestedEntries = []; + for (const key of this.schema.keys()) { + const value = this.schema.values[key]; + if (value.type.constructor === Array && data[key]) { + nestedEntries.push({ key: value.type[0].name, data: data[key] }); + delete data[key]; + } + } + + // create, update, delete nested entries + if (nestedEntries.length > 0) { + nestedEntries.map(nested => { + if (nested.data.create) { + nested.data.create.map(async create => { + create[`${this.getTableName()}Id`] = where.id; + await ctx[pascalize(nested.key)]._create(create); + }); + } + if (nested.data.update) { + nested.data.update.map(async update => { + await ctx[pascalize(nested.key)]._update(update, ctx); + }); + } + if (nested.data.delete) { + nested.data.delete.map(async where => { + await ctx[pascalize(nested.key)]._delete({ where }); + }); + } + }); + } + + return knex(this.getFullTableName()) + .update(decamelizeKeys(this.normalizeFields(data))) + .where(where); + } + + async update(args, ctx, info) { + try { + const e = new FieldError(); + e.throwIf(); + + await this._update(args, ctx); + + return await this.get(args, info); + } catch (e) { + await ctx.Log.create({ + type: 'error', + module: this.getTableName(), + action: 'update', + message: JSON.stringify(e), + userId: ctx.user.id + }); + return { errors: e }; + } + } + + _delete({ where }) { + return knex(this.getFullTableName()) + .where(decamelizeKeys(where)) + .del(); + } + + async delete(args, info) { + try { + const e = new FieldError(); + + const node = await this.get(args, info); + + if (!node) { + e.setError('delete', 'Node does not exist.'); + e.throwIf(); + } + if (this.isSortable()) { + const object = await knex(this.getFullTableName()) + .select(`${this.sortableField()} as rank`) + .where(args.where) + .first(); + + await knex(this.getFullTableName()) + .decrement(this.sortableField(), 1) + .where(this.sortableField(), '>', object.rank); + } + + const isDeleted = await this._delete(args); + + if (isDeleted) { + return node; + } else { + e.setError('delete', 'Could not delete Node. Please try again later.'); + e.throwIf(); + } + } catch (e) { + return { errors: e }; + } + } + + async _sort({ data }) { + // console.log('_sort, data:', data); + const oldId = data[0]; + // const newId = data[1]; + + const oldPosition = data[3]; + const newPosition = data[2]; + + if (oldPosition === newPosition) { + return 0; + } + + const fullTableName = this.getFullTableName(); + const sortableField = this.sortableField(); + const total = await this.getTotal(); + return knex.transaction(async function(trx) { + try { + // Move the object away + await knex(fullTableName) + .update({ [sortableField]: total.count + 1 }) + .where({ id: oldId }) + .transacting(trx); + // Shift the objects between the old and the new position + const baseQuery = knex(fullTableName); + if (oldPosition < newPosition) { + baseQuery.decrement(sortableField, 1); + } else { + baseQuery.increment(sortableField, 1); + } + let count = await baseQuery + .whereBetween(sortableField, [Math.min(oldPosition, newPosition), Math.max(oldPosition, newPosition)]) + .transacting(trx); + // Move the object back in + count += await knex(fullTableName) + .update({ [sortableField]: newPosition }) + .where({ id: oldId }) + .transacting(trx); + await trx.commit; + return count; + } catch (e) { + trx.rollback; + return 0; + } + }); + } + + async sort(args) { + try { + const e = new FieldError(); + e.throwIf(); + + const count = await this._sort(args); + if (count > 1) { + return { count }; + } else { + e.setError('sort', 'Could not sort Node. Please try again later.'); + e.throwIf(); + } + } catch (e) { + return { errors: e }; + } + } + + async _updateMany({ data, where: { id_in } }) { + // console.log('_updateMany:', data); + let normalizedData = {}; + for (const key of Object.keys(data)) { + const schemaKey = key.endsWith('Id') ? key.substring(0, key.length - 2) : key; + const value = this.schema.values[schemaKey]; + // add fields from one to one relation + if (value.type.constructor === Array && value.hasOne) { + let normalizedNestedData = {}; + const type = value.type[0]; + const fieldName = decamelize(key); + const prefix = type.__.tablePrefix ? type.__.tablePrefix : ''; + const suffix = value.noIdSuffix ? '' : '_id'; + const foreignTableName = decamelize(type.__.tableName ? type.__.tableName : type.name); + + // get ids of one to one entity + const ids = await knex + .select('id') + .from(`${prefix}${foreignTableName}`) + .whereIn(`${this.getTableName()}${suffix}`, id_in) + .reduce((array, value) => { + array.push(value.id); + return array; + }, []); + // console.log('ids:', ids); + + for (const nestedKey of Object.keys(data[key].update[0].data)) { + const nestedSchemaKey = nestedKey.endsWith('Id') ? nestedKey.substring(0, nestedKey.length - 2) : nestedKey; + if (type.values[nestedSchemaKey].type.constructor !== Array) { + normalizedNestedData[nestedKey] = data[key].update[0].data[nestedKey]; + } else { + // add new entries for many to many properties + if (Object.keys(data[key].update[0].data[nestedKey].update[0].data).length > 0) { + const nestedType = type.values[nestedSchemaKey].type[0]; + + // console.log('nestedKey:', nestedKey); + // console.log('type:', nestedType.name); + // console.log('tableName:', nestedType.__.tableName); + + const nestedTableName = `${nestedType.__.tablePrefix}${decamelize( + nestedType.__.tableName ? nestedType.__.tableName : nestedType.name + )}`; + + // get related field + let nestedChildId = null; + let nestedChildValue = null; + for (const nestedChildKey of Object.keys(data[key].update[0].data[nestedKey].update[0].data)) { + if (nestedChildKey.endsWith('Id')) { + nestedChildId = decamelize(nestedChildKey); + nestedChildValue = data[key].update[0].data[nestedKey].update[0].data[nestedChildKey]; + } + } + + if (nestedChildId) { + // console.log('nestedTableName:', nestedTableName); + // console.log('nestedChildId:', nestedChildId); + // console.log('nestedChildValue:', nestedChildValue); + // console.log('field name:', `${fieldName}${suffix}`); + // console.log('ids:', ids); + // delete all existing records for this property + await knex(nestedTableName) + .whereIn(`${fieldName}${suffix}`, ids) + .andWhere(nestedChildId, '=', nestedChildValue) + .del(); + + // insert new entries for all selected records + for (const id of ids) { + let insertFields = decamelizeKeys(data[key].update[0].data[nestedKey].update[0].data); + insertFields[`${fieldName}${suffix}`] = id; + // console.log('insertFields:', insertFields); + await knex(nestedTableName).insert(insertFields); + } + } + } + } + } + + if (Object.keys(normalizedNestedData).length > 0) { + normalizedNestedData = decamelizeKeys(this.normalizeFields(normalizedNestedData)); + // console.log('normalizedNestedData:', normalizedNestedData); + return knex(`${prefix}${foreignTableName}`) + .update(normalizedNestedData) + .whereIn('id', ids); + } + } else { + normalizedData[`${this.getTableName()}.${key}`] = data[key]; + } + } + + if (Object.keys(normalizedData).length > 0) { + normalizedData = decamelizeKeys(this.normalizeFields(normalizedData)); + return this.getBaseQuery() + .update(normalizedData) + .whereIn(`${this.getTableName()}.id`, id_in); + } else { + return 1; + } + } + + async updateMany(args) { + try { + const e = new FieldError(); + const updateCount = await this._updateMany(args); + + if (updateCount > 0) { + return { count: updateCount }; + } else { + e.setError('update', 'Could not update any of selected Node. Please try again later.'); + e.throwIf(); + } + } catch (e) { + console.error(`Error in ${this.getFullTableName()}.updateMany()`, e); + return { errors: e }; + } + } + + _deleteMany({ where: { id_in } }) { + return knex(this.getFullTableName()) + .whereIn('id', id_in) + .del(); + } + + async deleteMany(args) { + try { + const e = new FieldError(); + + if (this.isSortable()) { + // for every deleted object decrease rank acordingly + for (const id of args.where.id_in) { + const object = await knex(this.getFullTableName()) + .select(`${this.sortableField()} as rank`) + .where({ id }) + .first(); + + await knex(this.getFullTableName()) + .decrement(this.sortableField(), 1) + .where(this.sortableField(), '>', object.rank); + } + } + + const deleteCount = await this._deleteMany(args); + + if (deleteCount > 0) { + return { count: deleteCount }; + } else { + e.setError('delete', 'Could not delete any of selected Node. Please try again later.'); + e.throwIf(); + } + } catch (e) { + return { errors: e }; + } + } + + async getByIds(ids, by, Obj, info, infoCustom = null) { + info = infoCustom === null ? parseFields(info) : infoCustom; + const remoteId = by !== 'id' ? `${by}Id` : by; + info[remoteId] = true; + const baseQuery = knex(`${Obj.getFullTableName()} as ${Obj.getTableName()}`); + const select = selectBy(Obj.getSchema(), info, false); + const res = await knexnest(select(baseQuery).whereIn(`${Obj.getTableName()}.${decamelize(remoteId)}`, ids)); + return orderedFor(res, ids, remoteId, false); + } +} diff --git a/modules/database/server-ts/sql/helpers.js b/modules/database/server-ts/sql/helpers.js index 3bcdca0b9d..cca4231f87 100644 --- a/modules/database/server-ts/sql/helpers.js +++ b/modules/database/server-ts/sql/helpers.js @@ -1,4 +1,5 @@ -import { groupBy } from 'lodash'; +import { groupBy, findIndex } from 'lodash'; +import { decamelize } from 'humps'; import settings from '@gqlapp/config'; @@ -30,3 +31,130 @@ export const orderedFor = (rows, collection, field, singleObject) => { return singleObject ? {} : []; }); }; + +export const orderedForArray = (rows, collection, field, arrayElement) => { + // return the rows ordered for the collection + const inGroupsOfField = groupBy(rows, field); + return collection.map(element => { + const elementArray = inGroupsOfField[element]; + if (elementArray) { + return inGroupsOfField[element].map(elm => { + return elm[arrayElement]; + }); + } + return []; + }); +}; + +/** + * Collecting selects and joins + * @param graphqlFields + * @param domainSchema + * @param selectItems + * @param joinNames + * @param single + * @param parentField + * @private + */ +const _getSelectFields = (graphqlFields, domainSchema, selectItems, joinNames, single, parentKey, parentPath) => { + for (const fieldName of Object.keys(graphqlFields)) { + if (fieldName === '__typename') { + continue; + } + const value = domainSchema.values[fieldName]; + if (graphqlFields[fieldName] === true) { + if (value && value.transient) { + continue; + } + selectItems.push(_getSelectField(fieldName, parentPath, domainSchema, single, parentKey)); + } else { + if (Array.isArray(value.type) || findIndex(joinNames, { fieldName: decamelize(fieldName) }) > -1) { + continue; + } + if (!value.type.__.transient) { + joinNames.push(_getJoinEntity(fieldName, value, domainSchema)); + } + + parentPath.push(fieldName); + + _getSelectFields( + graphqlFields[fieldName], + value.type, + selectItems, + joinNames, + single, + decamelize(fieldName), + parentPath + ); + + parentPath.pop(); + } + } +}; + +/** + * Computing select field + * @param fieldName + * @param parentField + * @param domainSchema + * @param single + * @returns {string} + * @private + */ +const _getSelectField = (fieldName, parentPath, domainSchema, single, parentKey) => { + const alias = parentPath.length > 0 ? `${parentPath.join('_')}_${fieldName}` : fieldName; + const tableName = `${decamelize(domainSchema.__.tableName ? domainSchema.__.tableName : domainSchema.name)}`; + const fullTableName = parentKey !== null && parentKey !== tableName ? `${parentKey}_${tableName}` : tableName; + // returning object would be array or no + const arrayPrefix = single ? '' : '_'; + return `${fullTableName}.${decamelize(fieldName)} as ${arrayPrefix}${alias}`; +}; + +/** + * Computing join entity object + * @param fieldName + * @param value + * @param domainSchema + * @returns {{fieldName: *, prefix: string, suffix: string, baseTableName: *, foreignTableName: *}} + * @private + */ +const _getJoinEntity = (fieldName, value, domainSchema) => { + return { + fieldName: decamelize(fieldName), + prefix: value.type.__.tablePrefix ? value.type.__.tablePrefix : '', + suffix: value.noIdSuffix ? '' : '_id', + baseTableName: decamelize(domainSchema.name), + foreignTableName: decamelize(value.type.__.tableName ? value.type.__.tableName : value.type.name) + }; +}; + +/** + * Computing query with selects and joins + * @param schema + * @param fields + * @param single + * @returns {function(*): *} + */ +export const selectBy = (schema, fields, single = false) => { + // select fields and joins + const parentPath = []; + const selectItems = []; + const joinNames = []; + _getSelectFields(fields, schema, selectItems, joinNames, single, null, parentPath); + + return query => { + // join table names + joinNames.map(({ fieldName, prefix, suffix, baseTableName, foreignTableName }) => { + // if fieldName (schema key) diff with table name than make proper table alias + const tableNameAlias = + fieldName !== null && fieldName !== foreignTableName ? `${fieldName}_${foreignTableName}` : foreignTableName; + query.leftJoin( + `${prefix}${foreignTableName} as ${tableNameAlias}`, + `${tableNameAlias}.id`, + `${baseTableName}.${fieldName}${suffix}` + ); + }); + + return query.select(selectItems); + }; +}; diff --git a/modules/database/server-ts/sql/index.ts b/modules/database/server-ts/sql/index.ts index 797cc2df05..b3ff47c0ad 100644 --- a/modules/database/server-ts/sql/index.ts +++ b/modules/database/server-ts/sql/index.ts @@ -2,3 +2,4 @@ export { default as knex } from './connector'; export { default as populateTestDb } from './populateTestDb'; export { default as createTransaction } from './createTransaction'; export * from './helpers'; +export * from './crud'; diff --git a/modules/user/server-ts/schema.graphql b/modules/user/server-ts/schema.graphql index e5290d5408..674243b7e9 100644 --- a/modules/user/server-ts/schema.graphql +++ b/modules/user/server-ts/schema.graphql @@ -1,7 +1,13 @@ +enum Role { + admin + editor + user +} + type User { id: Int! username: String! - role: String! + role: Role! isActive: Boolean email: String! profile: UserProfile @@ -125,7 +131,7 @@ input AddUserInput { username: String! email: String! password: String! - role: String! + role: Role! isActive: Boolean profile: ProfileInput auth: AuthInput @@ -135,7 +141,7 @@ input AddUserInput { input EditUserInput { id: Int! username: String! - role: String! + role: Role! isActive: Boolean email: String! password: String diff --git a/modules/user/server-ts/scopes.js b/modules/user/server-ts/scopes.js index fc642a0cf2..e3cc840ad8 100644 --- a/modules/user/server-ts/scopes.js +++ b/modules/user/server-ts/scopes.js @@ -1,4 +1,5 @@ export default { - admin: ['user:*'], + admin: ['admin:*', 'editor:*', 'user:*'], + editor: ['editor:*', 'user:*'], user: ['user:view:self', 'user:update:self', 'stripe:*'] }; diff --git a/packages/server/package.json b/packages/server/package.json index e2764c128c..d11f5c2f1e 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -54,6 +54,9 @@ "dependencies": { "@babel/preset-env": "^7.0.0", "@babel/register": "^7.0.0", + "@domain-schema/core": "^0.0.34", + "@domain-schema/graphql": "^0.0.33", + "@domain-schema/knex": "^0.0.35", "@gqlapp/core-common": "^0.1.0", "@gqlapp/database-server-ts": "^0.1.0", "@gqlapp/testing-server-ts": "^0.1.0", @@ -85,6 +88,7 @@ "graphql": "^14.3.1", "graphql-auth": "0.2.6", "graphql-iso-date": "^3.5.0", + "graphql-parse-fields": "^1.2.0", "graphql-resolve-batch": "^1.0.2", "graphql-subscriptions": "^1.1.0", "graphql-tag": "^2.6.0", @@ -95,13 +99,15 @@ "isomorphic-fetch": "^2.2.1", "jsonwebtoken": "^8.1.0", "knex": "^0.14.2", - "objection": "^2.0.7", + "knexnest": "^1.0.0", "lerna": "^2.5.1", "lint-staged": "^7.0.4", "lodash": "^4.17.4", "minilog": "^3.1.0", + "moment": "^2.22.2", "mysql2": "^1.5.1", "nodemailer": "^4.4.1", + "objection": "^2.0.7", "passport": "^0.4.0", "passport-facebook": "^2.1.1", "passport-github": "^1.1.0", diff --git a/tools/cli.js b/tools/cli.js index 26443a0587..69e3eb2f58 100644 --- a/tools/cli.js +++ b/tools/cli.js @@ -12,6 +12,8 @@ const addModuleCommand = require('./cli/commands/addModule'); const deleteModuleCommand = require('./cli/commands/deleteModule'); const chooseTemplateCommand = require('./cli/commands/chooseTemplate'); const deleteStackCommand = require('./cli/commands/deleteStack'); +const addCrudCommand = require('./cli/commands/addCrud'); +const updateSchemaCommand = require('./cli/commands/updateSchema'); const CommandInvoker = require('./cli/CommandInvoker'); @@ -19,7 +21,9 @@ const commandInvoker = new CommandInvoker( addModuleCommand, deleteModuleCommand, chooseTemplateCommand, - deleteStackCommand + deleteStackCommand, + addCrudCommand, + updateSchemaCommand ); prog @@ -55,6 +59,20 @@ List of technologies [react, angular, vue, scala, node]` .action(({ stackList }, { list }, logger) => { commandInvoker.runDeleteStack(stackList, logger, list); - }); + }) + // Add CRUD module + .command('addcrud', 'Create a new Module with CRUD') + .argument('', 'Module name') + .argument( + '[location]', + 'Where should CRUD module be created. [both, server, client]', + ['both', 'server', 'client'], + 'both' + ) + .action((args, options, logger) => commandInvoker.runAddCrud(args, options, logger)) + // Update schema + .command('updateschema', 'Update Module Schema') + .argument('', 'Module name') + .action((args, options, logger) => commandInvoker.runUpdateSchema(args, options, logger)); prog.parse(process.argv); diff --git a/tools/cli/CommandInvoker.js b/tools/cli/CommandInvoker.js index 3e61413b9e..41fa5c2d89 100644 --- a/tools/cli/CommandInvoker.js +++ b/tools/cli/CommandInvoker.js @@ -11,11 +11,13 @@ class CommandInvoker { * @param chooseStack - The function for choosing stack of technologies. * @param deleteStack - The function for delete stack of technologies. */ - constructor(addModule, deleteModule, chooseStack, deleteStack) { + constructor(addModule, deleteModule, chooseStack, deleteStack, addCrud, updateSchema) { this.addModule = addModule; this.deleteModule = deleteModule; this.chooseStack = chooseStack; this.deleteStack = deleteStack; + this.addCrud = addCrud; + this.updateSchema = updateSchema; } /** @@ -63,6 +65,20 @@ class CommandInvoker { runDeleteStack(args, logger, isShowStackList) { this.deleteStack(args, logger, isShowStackList); } + + /** + * Runs operation (function) for creating a new CRUD module. + */ + runAddCrud(args, options, logger) { + runOperation(this.addCrud, args, options, logger); + } + + /** + * Runs operation (function) for updating existing module schema. + */ + runUpdateSchema(args, options, logger) { + runOperation(this.updateSchema, args, options, logger); + } } function runOperation(operation, args, options, logger) { diff --git a/tools/cli/commands/addCrud.js b/tools/cli/commands/addCrud.js new file mode 100644 index 0000000000..766de82826 --- /dev/null +++ b/tools/cli/commands/addCrud.js @@ -0,0 +1,48 @@ +const chalk = require('chalk'); +const { pascalize, decamelize } = require('humps'); +const addModule = require('./addModule'); +const { + getModulePackageName, + computeGeneratedSchemasPath, + updateFileWithExports, + runPrettier +} = require('../helpers/util'); + +/** + * Adds CRUD module in server and adds a new module to the Feature connector. + * + * @param logger - The Logger. + * @param moduleName - The name of a new module. + * @param tablePrefix + * @param packageName - The location for a new module [client|server|both]. + */ +function addCrud({ logger, packageName, moduleName, old }) { + console.log('packageName:', packageName); + console.log('moduleName:', moduleName); + console.log('old:', old); + + // add module in server, client + addModule({ logger, packageName, moduleName, old, crud: true }); + + // pascalize + const Module = pascalize(moduleName); + const modulePackageName = getModulePackageName(packageName, old); + + if (packageName === 'server') { + const schema = `${Module}Schema`; + const fileName = 'generatedSchemas.js'; + const options = { + pathToFileWithExports: computeGeneratedSchemasPath(packageName, fileName, old), + exportName: schema, + importString: `import { ${schema} } from '@gqlapp/${decamelize(moduleName, { + separator: '-' + })}-${modulePackageName}/schema';\n` + }; + updateFileWithExports(options); + runPrettier(options.pathToFileWithExports); + } + + logger.info(chalk.green(`✔ Module for ${packageName} successfully created!`)); +} + +module.exports = addCrud; diff --git a/tools/cli/commands/addModule.js b/tools/cli/commands/addModule.js index 29808e58e5..a92bd407f1 100644 --- a/tools/cli/commands/addModule.js +++ b/tools/cli/commands/addModule.js @@ -21,9 +21,9 @@ const { * @param templatesPath - The path to the templates for a new module * @param moduleName - The name of a new module */ -function addModule({ logger, packageName, moduleName, old }) { +function addModule({ logger, packageName, moduleName, old, crud = false }) { const modulePackageName = getModulePackageName(packageName, old); - const templatesPath = getTemplatesPath(old); + const templatesPath = getTemplatesPath(crud, old); const params = { logger, packageName, moduleName, modulePackageName, templatesPath, old }; copyTemplates(params); diff --git a/tools/cli/commands/deleteModule.js b/tools/cli/commands/deleteModule.js index bc46882758..5884d73882 100644 --- a/tools/cli/commands/deleteModule.js +++ b/tools/cli/commands/deleteModule.js @@ -1,6 +1,7 @@ const shell = require('shelljs'); const fs = require('fs'); const chalk = require('chalk'); +const { pascalize } = require('humps'); const { getModulePackageName, computeModulePath, @@ -8,6 +9,8 @@ const { computeRootModulesPath, computePackagePath, computeModulePackageName, + computeGeneratedSchemasPath, + deleteFromFileWithExports, removeSymlink, runPrettier } = require('../helpers/util'); @@ -119,6 +122,14 @@ function removeDependency({ moduleName, packageName, modulePackageName, old }) { .to(packagePath); removeSymlink(moduleName, modulePackageName); + + const Module = pascalize(moduleName); + const fileName = 'generatedSchemas.js'; + const generatedSchemaPath = computeGeneratedSchemasPath(packageName, fileName, old); + if (fs.existsSync(generatedSchemaPath)) { + const schema = `${Module}Schema`; + deleteFromFileWithExports(generatedSchemaPath, schema); + } } module.exports = deleteModule; diff --git a/tools/cli/commands/updateSchema.js b/tools/cli/commands/updateSchema.js new file mode 100644 index 0000000000..714b37db80 --- /dev/null +++ b/tools/cli/commands/updateSchema.js @@ -0,0 +1,173 @@ +const shell = require('shelljs'); +const fs = require('fs'); +const chalk = require('chalk'); +const GraphQLGenerator = require('@domain-schema/graphql').default; +const { pascalize, camelize } = require('humps'); + +const { getModulePackageName, computeModulePath, generateField, runPrettier } = require('../helpers/util'); +const schemas = require('../../../modules/core/server-ts/generatedSchemas'); + +/** + * Update module schema. + * + * @param logger - The Logger. + * @param moduleName - The name of a new module. + * @param packageName - The location for a new module [client|server|both]. + */ +function updateModule({ logger, packageName, moduleName, old }) { + if (packageName === 'server') { + logger.info(`Updating ${moduleName} Schema…`); + + // pascalize + const Module = pascalize(moduleName); + //const modulePath = `${BASE_PATH}/packages/server/src/modules/${moduleName}`; + const modulePackageName = getModulePackageName(packageName, old); + const destinationPath = computeModulePath(modulePackageName, old, moduleName); + + if (fs.existsSync(destinationPath)) { + // get module schema + const schema = schemas.default[`${Module}Schema`]; + + // schema file + const file = `schema.graphql`; + + // regenerate input fields + let inputCreate = ''; + let inputUpdate = ''; + let inputFilter = ` searchText: String\n`; + let manyInput = ''; + for (const key of schema.keys()) { + const value = schema.values[key]; + const hasTypeOf = targetType => value.type === targetType || value.type.prototype instanceof targetType; + if (value.type.isSchema) { + let required = value.optional ? '' : '!'; + const id = value.noIdSuffix ? '' : 'Id'; + inputCreate += ` ${key}${id}: Int${required}\n`; + inputUpdate += ` ${key}${id}: Int\n`; + inputFilter += ` ${key}${id}: Int\n`; + inputFilter += ` ${key}${id}_in: [Int!]\n`; + inputFilter += ` ${key}${id}_contains: Int\n`; + } else if (value.type.constructor !== Array) { + if (key !== 'id') { + inputCreate += ` ${key}: ${generateField(value)}\n`; + inputUpdate += ` ${key}: ${generateField(value, true)}\n`; + } + + if (hasTypeOf(Date)) { + inputFilter += ` ${key}_lte: ${generateField(value, true)}\n`; + inputFilter += ` ${key}_gte: ${generateField(value, true)}\n`; + } else if (key === 'id' || hasTypeOf(String)) { + inputFilter += ` ${key}: ${generateField(value, true)}\n`; + inputFilter += ` ${key}_in: [${generateField(value, true)}!]\n`; + inputFilter += ` ${key}_contains: ${generateField(value, true)}\n`; + } else { + inputFilter += ` ${key}: ${generateField(value, true)}\n`; + } + } else if (value.type.constructor === Array && value.type[0].isSchema) { + inputCreate += ` ${key}: ${pascalize(key)}CreateManyInput\n`; + inputUpdate += ` ${key}: ${pascalize(key)}UpdateManyInput\n`; + inputFilter += ` ${key}: ${pascalize(value.type[0].name)}FilterInput\n`; + manyInput += ` + +input ${pascalize(key)}CreateManyInput { + create: [${pascalize(value.type[0].name)}CreateInput!] +} + +input ${pascalize(key)}UpdateManyInput { + create: [${pascalize(value.type[0].name)}CreateInput!] + delete: [${pascalize(value.type[0].name)}WhereUniqueInput!] + update: [${pascalize(value.type[0].name)}UpdateWhereInput!] +} + +input ${pascalize(value.type[0].name)}UpdateWhereInput { + where: ${pascalize(value.type[0].name)}WhereUniqueInput! + data: ${pascalize(value.type[0].name)}UpdateInput! +}`; + } + } + + shell.cd(destinationPath); + // override Module type in schema.graphql file + const replaceType = `### schema type definitions([^()]+)### end schema type definitions`; + shell + .ShellString( + shell + .cat(file) + .replace( + RegExp(replaceType, 'g'), + `### schema type definitions\n${new GraphQLGenerator().generateTypes( + schema + )}${manyInput}\n\n### end schema type definitions` + ) + ) + .to(file); + + // override ModuleCreateInput in schema.graphql file + const replaceCreate = `input ${Module}CreateInput {([^}])*\\n}`; + shell + .ShellString( + shell.cat(file).replace(RegExp(replaceCreate, 'g'), `input ${Module}CreateInput {\n${inputCreate}}`) + ) + .to(file); + + // override ModuleUpdateInput in schema.graphql file + const replaceUpdate = `input ${Module}UpdateInput {([^}])*\\n}`; + shell + .ShellString( + shell.cat(file).replace(RegExp(replaceUpdate, 'g'), `input ${Module}UpdateInput {\n${inputUpdate}}`) + ) + .to(file); + + // override ModuleFilterInput in schema.graphql file + const replaceFilter = `input ${Module}FilterInput {([^}])*\\n}`; + shell + .ShellString( + shell.cat(file).replace(RegExp(replaceFilter, 'g'), `input ${Module}FilterInput {\n${inputFilter}}`) + ) + .to(file); + + logger.info(chalk.green(`✔ Schema in ${destinationPath}${file} successfully updated!`)); + + const resolverFile = `resolvers.ts`; + let hasBatchResolvers = false; + let replace = ` ${schema.name}: { +`; + for (const key of schema.keys()) { + const value = schema.values[key]; + if (value.type.constructor === Array) { + hasBatchResolvers = true; + const remoteField = value.remoteField ? camelize(value.remoteField) : camelize(schema.name); + replace += ` ${key}: createBatchResolver((sources, args, ctx, info) => { + return ctx.${schema.name}.getByIds(sources.map(({ id }) => id), '${remoteField}', ctx.${ + value.type[0].name + }, info); + }), +`; + } + } + replace += ` }, +`; + replace = hasBatchResolvers ? replace : ''; + + // override batch resolvers in resolvers.ts file + const replaceBatchResolvers = `// schema batch resolvers([^*]+)// end schema batch resolvers`; + shell + .ShellString( + shell + .cat(resolverFile) + .replace( + RegExp(replaceBatchResolvers, 'g'), + `// schema batch resolvers\n${replace} // end schema batch resolvers` + ) + ) + .to(resolverFile); + runPrettier(resolverFile); + + logger.info(chalk.green(`✔ Resolver in ${destinationPath}${resolverFile} successfully updated!`)); + } else { + logger.info(chalk.red(`✘ Module ${moduleName} in path ${destinationPath} not found!`)); + } + } +} + +module.exports = updateModule; diff --git a/tools/cli/config.js b/tools/cli/config.js index 2d13ae6339..4d1a783a4f 100644 --- a/tools/cli/config.js +++ b/tools/cli/config.js @@ -4,6 +4,7 @@ const BASE_PATH = path.resolve(`${__dirname}/../..`); const TEMPLATES_DIR = `${BASE_PATH}/tools/templates`; const MODULE_TEMPLATES = `${TEMPLATES_DIR}/module`; const MODULE_TEMPLATES_OLD = `${TEMPLATES_DIR}/module_old`; +const CRUD_TEMPLATES = `${TEMPLATES_DIR}/crud`; const STACK_MAP = { client: { @@ -38,5 +39,6 @@ module.exports = { TEMPLATES_DIR, MODULE_TEMPLATES, MODULE_TEMPLATES_OLD, - STACK_MAP + STACK_MAP, + CRUD_TEMPLATES }; diff --git a/tools/cli/helpers/util.js b/tools/cli/helpers/util.js index d6aefead97..20bfebc337 100644 --- a/tools/cli/helpers/util.js +++ b/tools/cli/helpers/util.js @@ -1,8 +1,9 @@ const shell = require('shelljs'); const fs = require('fs'); +const DomainSchema = require('@domain-schema/core').default; const { pascalize, decamelize } = require('humps'); const { startCase } = require('lodash'); -const { MODULE_TEMPLATES, MODULE_TEMPLATES_OLD, BASE_PATH } = require('../config'); +const { MODULE_TEMPLATES, MODULE_TEMPLATES_OLD, CRUD_TEMPLATES, BASE_PATH } = require('../config'); /** * Provides a package name for the particular module based on the command option --old . @@ -18,10 +19,11 @@ const getModulePackageName = (packageName, old) => { /** * Provides a path to the module templates. * + * @param crud - The flag that describes if the command invoked for crud template structure or not * @param old - The flag that describes if the command invoked for a new structure or not * @returns {string} - path to the templates */ -const getTemplatesPath = old => (old ? MODULE_TEMPLATES_OLD : MODULE_TEMPLATES); +const getTemplatesPath = (crud, old) => (crud ? CRUD_TEMPLATES : old ? MODULE_TEMPLATES_OLD : MODULE_TEMPLATES); /** * Copies the templates to the destination directory. @@ -47,6 +49,12 @@ function renameFiles(destinationPath, moduleName) { shell.cd(destinationPath); // rename files + const timestamp = new Date().getTime(); + shell.ls('-Rl', '.').forEach(entry => { + if (entry.isFile()) { + shell.mv(entry.name, entry.name.replace('T_Module', `${timestamp}_Module`)); + } + }); shell.ls('-Rl', '.').forEach(entry => { if (entry.isFile()) { shell.mv(entry.name, entry.name.replace('Module', Module)); @@ -135,6 +143,19 @@ function computePackagePath(packageName) { return `${BASE_PATH}/packages/${packageName}/package.json`; } +/** + * Gets the computed path for generated module list. + * + * @param packageName - The application package ([client|server]) + * @param fileName - File name of generated module + * @param old - The flag that describes if the command invoked for a new structure or not + * @returns {string} - Return the computed path + */ +function computeGeneratedSchemasPath(packageName, fileName, old) { + const modulePackageName = getModulePackageName(packageName, old); + return `${BASE_PATH}/modules/core/${modulePackageName}/${fileName}`; +} + /** * Adds a symlink. * @@ -230,6 +251,84 @@ function deleteStackDir(stackDirList) { }); } +/** + * + * @param value + * @param update + * @returns {string} + */ +function generateField(value, update = false) { + let result = ''; + const hasTypeOf = targetType => value.type === targetType || value.type.prototype instanceof targetType; + if (hasTypeOf(Boolean)) { + result += 'Boolean'; + } else if (hasTypeOf(DomainSchema.ID)) { + result += 'ID'; + } else if (hasTypeOf(DomainSchema.Int)) { + result += 'Int'; + } else if (hasTypeOf(DomainSchema.Float)) { + result += 'Float'; + } else if (hasTypeOf(String)) { + result += 'String'; + } else if (hasTypeOf(Date)) { + result += 'Date'; + } else if (hasTypeOf(DomainSchema.DateTime)) { + result += 'DateTime'; + } else if (hasTypeOf(DomainSchema.Time)) { + result += 'Time'; + } + + if (!update && !value.optional) { + result += '!'; + } + + return result; +} + +/** + * + * @param pathToFileWithExports + * @param exportName + * @param importString + */ +function updateFileWithExports({ pathToFileWithExports, exportName, importString }) { + const exportGraphqlContainer = `\nexport default {\n ${exportName}\n};\n`; + + if (fs.existsSync(pathToFileWithExports)) { + const generatedContainerData = fs.readFileSync(pathToFileWithExports); + const generatedContainer = generatedContainerData.toString().trim(); + if (generatedContainer.length > 1) { + const index = generatedContainer.lastIndexOf("';"); + const computedIndex = index >= 0 ? index + 3 : false; + if (computedIndex) { + let computedGeneratedContainer = + generatedContainer.slice(0, computedIndex) + + importString + + generatedContainer.slice(computedIndex, generatedContainer.length); + computedGeneratedContainer = computedGeneratedContainer.replace(/(,|)\s};/g, `,\n ${exportName}\n};`); + return fs.writeFileSync(pathToFileWithExports, computedGeneratedContainer); + } + } + return fs.writeFileSync(pathToFileWithExports, importString + exportGraphqlContainer); + } +} + +/** + * + * @param pathToFileWithExports + * @param exportName + */ +function deleteFromFileWithExports(pathToFileWithExports, exportName) { + if (fs.existsSync(pathToFileWithExports)) { + const generatedElementData = fs.readFileSync(pathToFileWithExports); + const reg = `(\\n\\s\\s${exportName}(.|)|import (${exportName}|{ ${exportName} }).+;\\n+(?!ex))`; + const generatedElement = generatedElementData.toString().replace(new RegExp(reg, 'g'), ''); + fs.writeFileSync(pathToFileWithExports, generatedElement); + + runPrettier(pathToFileWithExports); + } +} + module.exports = { getModulePackageName, getTemplatesPath, @@ -241,11 +340,15 @@ module.exports = { computeRootModulesPath, computePackagePath, computeModulePackageName, + computeGeneratedSchemasPath, addSymlink, removeSymlink, runPrettier, moveToDirectory, deleteDir, getPathsSubdir, - deleteStackDir + deleteStackDir, + generateField, + updateFileWithExports, + deleteFromFileWithExports }; diff --git a/tools/templates/crud/client-react/__tests__/Module.spec.ts b/tools/templates/crud/client-react/__tests__/Module.spec.ts new file mode 100644 index 0000000000..cb749ef11f --- /dev/null +++ b/tools/templates/crud/client-react/__tests__/Module.spec.ts @@ -0,0 +1,21 @@ +import { expect } from 'chai'; +import { step } from 'mocha-steps'; + +import Renderer from '../../../../packages/client/src/testHelpers/Renderer'; +import { updateContent } from '../../../../packages/client/src/testHelpers/testUtils'; + +describe('$Module$ UI works', () => { + const renderer = new Renderer({}); + const app = renderer.mount(); + renderer.history.push('/$Module$'); + const content = updateContent(app.container); + + step('$Module$ page renders on mount', () => { + // tslint:disable:no-unused-expression + expect(content).to.not.be.empty; + }); + + step('$Module$ page has title', async () => { + expect(content.textContent).to.include('Hello, This is the $Module$ module'); + }); +}); diff --git a/tools/templates/crud/client-react/components/ModuleView.native.tsx b/tools/templates/crud/client-react/components/ModuleView.native.tsx new file mode 100644 index 0000000000..22ca9cd123 --- /dev/null +++ b/tools/templates/crud/client-react/components/ModuleView.native.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { StyleSheet, Text, View } from 'react-native'; +import { TranslateFunction } from '@gqlapp/i18n-client-react'; + +interface $Module$ViewProps { + t: TranslateFunction; +} + +const $Module$View = ({ t }: $Module$ViewProps) => { + return ( + + + {t('welcomeText')} + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#fff', + alignItems: 'center', + justifyContent: 'center' + }, + element: { + paddingTop: 30 + }, + box: { + textAlign: 'center', + marginLeft: 15, + marginRight: 15 + } +}); + +export default $Module$View; diff --git a/tools/templates/crud/client-react/components/ModuleView.tsx b/tools/templates/crud/client-react/components/ModuleView.tsx new file mode 100644 index 0000000000..8c232948e0 --- /dev/null +++ b/tools/templates/crud/client-react/components/ModuleView.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import Helmet from 'react-helmet'; +import { PageLayout } from '@gqlapp/look-client-react'; +import { TranslateFunction } from '@gqlapp/i18n-client-react'; +import settings from '../../../../settings'; + +interface $Module$ViewProps { + t: TranslateFunction; +} + +const renderMetaData = (t: TranslateFunction) => ( + +); + +const $Module$View = ({ t }: $Module$ViewProps) => { + return ( + + {renderMetaData(t)} +
+

{t('welcomeText')}

+
+
+ ); +}; + +export default $Module$View; diff --git a/tools/templates/crud/client-react/containers/Module.tsx b/tools/templates/crud/client-react/containers/Module.tsx new file mode 100644 index 0000000000..b4cab4027b --- /dev/null +++ b/tools/templates/crud/client-react/containers/Module.tsx @@ -0,0 +1,16 @@ +import React from 'react'; + +import { translate, TranslateFunction } from '@gqlapp/i18n-client-react'; +import $Module$View from '../components/$Module$View'; + +interface $Module$Props { + t: TranslateFunction; +} + +class $Module$ extends React.Component<$Module$Props> { + public render() { + return <$Module$View {...this.props} />; + } +} + +export default translate('$module$')($Module$); diff --git a/tools/templates/crud/client-react/graphql/ModuleQuery.graphql b/tools/templates/crud/client-react/graphql/ModuleQuery.graphql new file mode 100644 index 0000000000..f9c5113153 --- /dev/null +++ b/tools/templates/crud/client-react/graphql/ModuleQuery.graphql @@ -0,0 +1,6 @@ +# GraphQL queries +query queryNameQuery { + queryName { + typename + } +} diff --git a/tools/templates/crud/client-react/index.native.tsx b/tools/templates/crud/client-react/index.native.tsx new file mode 100644 index 0000000000..e3a08d4277 --- /dev/null +++ b/tools/templates/crud/client-react/index.native.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { createStackNavigator } from 'react-navigation'; + +import { translate } from '@gqlapp/i18n-client-react'; +import ClientModule from '@gqlapp/module-client-react-native'; + +import { HeaderTitle, IconButton } from '@gqlapp/look-client-react-native'; +import $Module$ from './containers/$Module$'; +import resources from './locales'; + +const HeaderTitleWithI18n = translate('$module$')(HeaderTitle); + +export default new ClientModule({ + drawerItem: [ + { + $Module$: { + screen: createStackNavigator({ + $Module$: { + screen: $Module$, + navigationOptions: ({ navigation }: any) => ({ + headerTitle: , + headerLeft: ( + navigation.openDrawer()} /> + ), + headerStyle: { backgroundColor: '#fff' } + }) + } + }), + navigationOptions: { + drawerLabel: + } + } + } + ], + localization: [{ ns: '$module$', resources }] +}); diff --git a/tools/templates/crud/client-react/index.tsx b/tools/templates/crud/client-react/index.tsx new file mode 100644 index 0000000000..bb5cdfb98a --- /dev/null +++ b/tools/templates/crud/client-react/index.tsx @@ -0,0 +1,25 @@ +import React from 'react'; + +import ClientModule from '@gqlapp/module-client-react'; +import { translate, TranslateFunction } from '@gqlapp/i18n-client-react'; + +import { Route, NavLink } from 'react-router-dom'; +import { MenuItem } from '@gqlapp/look-client-react'; +import $Module$ from './containers/$Module$'; +import resources from './locales'; + +const NavLinkWithI18n = translate('$module$')(({ t }: { t: TranslateFunction }) => ( + + {t('$module$:navLink')} + +)); + +export default new ClientModule({ + route: [], + navItem: [ + + + + ], + localization: [{ ns: '$module$', resources }] +}); diff --git a/tools/templates/crud/client-react/locales/en/translations.json b/tools/templates/crud/client-react/locales/en/translations.json new file mode 100644 index 0000000000..8131aae904 --- /dev/null +++ b/tools/templates/crud/client-react/locales/en/translations.json @@ -0,0 +1,6 @@ +{ + "navLink": "$Module$ nav link", + "title": "$Module$ module", + "meta": "$Module$ example", + "welcomeText": "Hello, This is the $Module$ module" +} \ No newline at end of file diff --git a/tools/templates/crud/client-react/locales/index.js b/tools/templates/crud/client-react/locales/index.js new file mode 100644 index 0000000000..9abc2aac88 --- /dev/null +++ b/tools/templates/crud/client-react/locales/index.js @@ -0,0 +1,5 @@ +/* +* The index.js can be empty, it's just needed to point the loader to the root directory of the locales. +* https://github.com/alienfast/i18next-loader#option-2-use-with-import-syntax +*/ +export default {}; \ No newline at end of file diff --git a/tools/templates/crud/client-react/locales/ru/translations.json b/tools/templates/crud/client-react/locales/ru/translations.json new file mode 100644 index 0000000000..1bace811f7 --- /dev/null +++ b/tools/templates/crud/client-react/locales/ru/translations.json @@ -0,0 +1,6 @@ +{ + "navLink": "$Module$ ссылка", + "title": "$Module$ модуль", + "meta": "$Module$ пример", + "welcomeText": "Привет, это $Module$ модуль" +} \ No newline at end of file diff --git a/tools/templates/crud/client-react/package.json b/tools/templates/crud/client-react/package.json new file mode 100644 index 0000000000..0a162b9c24 --- /dev/null +++ b/tools/templates/crud/client-react/package.json @@ -0,0 +1,5 @@ +{ + "name": "@gqlapp/$-module$-client-react", + "version": "1.0.0", + "private": true +} diff --git a/tools/templates/crud/server-ts/__tests__/Module.spec.ts b/tools/templates/crud/server-ts/__tests__/Module.spec.ts new file mode 100644 index 0000000000..79504308a1 --- /dev/null +++ b/tools/templates/crud/server-ts/__tests__/Module.spec.ts @@ -0,0 +1,29 @@ +import { expect } from 'chai'; +import { ApolloClient } from 'apollo-client'; +import { step } from 'mocha-steps'; +import gql from 'graphql-tag'; + +import { getApollo } from '@gqlapp/testing-server-ts'; + +const INTROSPECTION_QUERY = gql` + query introspectionQuery { + __schema { + types { + name + } + } + } +`; + +describe('$Module$ API works', () => { + let apollo: ApolloClient; + + before(() => { + apollo = getApollo(); + }); + + step('Should send a query to the GraphQL back end', async () => { + const result = await apollo.query({ query: INTROSPECTION_QUERY }); + expect(result.data).to.have.property('__schema'); + }); +}); diff --git a/tools/templates/crud/server-ts/index.ts b/tools/templates/crud/server-ts/index.ts new file mode 100644 index 0000000000..278f423cc7 --- /dev/null +++ b/tools/templates/crud/server-ts/index.ts @@ -0,0 +1,11 @@ +import ServerModule from '@gqlapp/module-server-ts'; + +import schema from './schema.graphql'; +import createResolvers from './resolvers'; +import $Module$ from './sql'; + +export default new ServerModule({ + schema: [schema], + createResolversFunc: [createResolvers], + createContextFunc: [() => ({ $Module$: new $Module$() })] +}); diff --git a/tools/templates/crud/server-ts/migrations/T_Module.js b/tools/templates/crud/server-ts/migrations/T_Module.js new file mode 100644 index 0000000000..0ffd69a56d --- /dev/null +++ b/tools/templates/crud/server-ts/migrations/T_Module.js @@ -0,0 +1,7 @@ +import KnexGenerator from '@domain-schema/knex'; + +import { $Module$Schema } from '../schema'; + +exports.up = knex => new KnexGenerator(knex).createTables($Module$Schema); + +exports.down = knex => new KnexGenerator(knex).dropTables($Module$Schema); diff --git a/tools/templates/crud/server-ts/package.json b/tools/templates/crud/server-ts/package.json new file mode 100644 index 0000000000..8dc16993cf --- /dev/null +++ b/tools/templates/crud/server-ts/package.json @@ -0,0 +1,5 @@ +{ + "name": "@gqlapp/$-module$-server-ts", + "version": "1.0.0", + "private": true +} diff --git a/tools/templates/crud/server-ts/resolvers.ts b/tools/templates/crud/server-ts/resolvers.ts new file mode 100644 index 0000000000..452879d39a --- /dev/null +++ b/tools/templates/crud/server-ts/resolvers.ts @@ -0,0 +1,56 @@ +import withAuth from 'graphql-auth'; + +// @ts-ignore +import { createBatchResolver } from 'graphql-resolve-batch'; + +const $MODULE$S_SUBSCRIPTION = '$module$s_subscription'; + +export default (pubsub: any) => ({ + Query: { + $module$s: withAuth(['editor:view:all'], (parent: any, args: any, ctx: any, info: any) => { + return ctx.$Module$.getList(args, info); + }), + $module$sConnection: withAuth(['editor:view:all'], (parent: any, args: any, ctx: any, info: any) => { + return ctx.$Module$.getPaginated(args, info); + }), + $module$: withAuth(['editor:view'], (parent: any, args: any, ctx: any, info: any) => { + return ctx.$Module$.get(args, info); + }) + }, + // schema batch resolvers + // end schema batch resolvers + Mutation: { + create$Module$: withAuth(['editor:create'], async (parent: any, args: any, ctx: any, info: any) => { + const $module$ = await ctx.$Module$.create(args, ctx, info); + + pubsub.publish($MODULE$S_SUBSCRIPTION, { + $module$sUpdated: { + mutation: 'CREATED', + node: $module$.node + } + }); + + return $module$; + }), + update$Module$: withAuth(['editor:update'], (parent: any, args: any, ctx: any, info: any) => { + return ctx.$Module$.update(args, ctx, info); + }), + delete$Module$: withAuth(['editor:delete'], (parent: any, args: any, ctx: any, info: any) => { + return ctx.$Module$.delete(args, info); + }), + sort$Module$s: withAuth(['editor:update'], (parent: any, args: any, ctx: any) => { + return ctx.$Module$.sort(args); + }), + updateMany$Module$s: withAuth(['editor:update'], (parent: any, args: any, ctx: any) => { + return ctx.$Module$.updateMany(args); + }), + deleteMany$Module$s: withAuth(['editor:delete'], (parent: any, args: any, ctx: any) => { + return ctx.$Module$.deleteMany(args); + }) + }, + Subscription: { + $module$sUpdated: { + subscribe: () => pubsub.asyncIterator($MODULE$S_SUBSCRIPTION) + } + } +}); diff --git a/tools/templates/crud/server-ts/schema.graphql b/tools/templates/crud/server-ts/schema.graphql new file mode 100644 index 0000000000..700b778c11 --- /dev/null +++ b/tools/templates/crud/server-ts/schema.graphql @@ -0,0 +1,71 @@ +### schema type definitions +type $Module$ { + id: Int! + name: String! +} + +### end schema type definitions + +type $Module$Connection { + pageInfo: PageInfo + edges: [$Module$] +} + +type $Module$Data { + node: $Module$ +} + +type $Module$Payload { + node: $Module$ + errors: [FieldError!] +} + +input $Module$FilterInput { + searchText: String + id: Int + id_in: [Int!] + id_contains: Int + name: String + name_in: [String!] + name_contains: String +} + +input $Module$CreateInput { + name: String! +} + +input $Module$UpdateInput { + name: String +} + +input $Module$WhereUniqueInput { + id: Int! +} + +input $Module$WhereInput { + id_in: [Int!] +} + +extend type Query { + $module$s(limit: Int, offset: Int, orderBy: OrderByInput, filter: $Module$FilterInput): [$Module$] + $module$sConnection(limit: Int, offset: Int, orderBy: OrderByInput, filter: $Module$FilterInput): $Module$Connection! + $module$(where: $Module$WhereUniqueInput!): $Module$Data +} + +extend type Mutation { + create$Module$(data: $Module$CreateInput!): $Module$Payload! + update$Module$(data: $Module$UpdateInput!, where: $Module$WhereUniqueInput!): $Module$Payload! + delete$Module$(where: $Module$WhereUniqueInput!): $Module$Payload! + sort$Module$s(data: [Int!]): BatchPayload! + updateMany$Module$s(data: $Module$UpdateInput!, where: $Module$WhereInput!): BatchPayload! + deleteMany$Module$s(where: $Module$WhereInput!): BatchPayload! +} + +extend type Subscription { + $module$sUpdated: Update$Module$Payload +} + +type Update$Module$Payload { + mutation: String! + node: $Module$! +} diff --git a/tools/templates/crud/server-ts/schema.js b/tools/templates/crud/server-ts/schema.js new file mode 100644 index 0000000000..2d8f2f9471 --- /dev/null +++ b/tools/templates/crud/server-ts/schema.js @@ -0,0 +1,15 @@ +import DomainSchema, { Schema } from '@domain-schema/core'; + +export class $Module$ extends Schema { + constructor() { + super(); + this.__ = { name: '$Module$', tablePrefix: '' }; + this.id = DomainSchema.Int; + this.name = { + type: String, + searchText: true + }; + } +} + +export const $Module$Schema = new DomainSchema($Module$); diff --git a/tools/templates/crud/server-ts/seeds/.eslintrc b/tools/templates/crud/server-ts/seeds/.eslintrc new file mode 100644 index 0000000000..712a899471 --- /dev/null +++ b/tools/templates/crud/server-ts/seeds/.eslintrc @@ -0,0 +1,5 @@ +{ + "rules": { + "import/prefer-default-export": 0 + } +} \ No newline at end of file diff --git a/tools/templates/crud/server-ts/seeds/T_Module.js b/tools/templates/crud/server-ts/seeds/T_Module.js new file mode 100644 index 0000000000..c30c461693 --- /dev/null +++ b/tools/templates/crud/server-ts/seeds/T_Module.js @@ -0,0 +1,7 @@ +import { returnId, truncateTables } from '@gqlapp/database-server-ts'; + +export async function seed(knex, Promise) { + await truncateTables(knex, Promise, ['$_module$']); + + await returnId(knex('$_module$').insert({ name: 'test' })); +} diff --git a/tools/templates/crud/server-ts/sql.ts b/tools/templates/crud/server-ts/sql.ts new file mode 100644 index 0000000000..5e1f697e78 --- /dev/null +++ b/tools/templates/crud/server-ts/sql.ts @@ -0,0 +1,10 @@ +import { $Module$Schema } from './schema'; +import { Crud } from '@gqlapp/database-server-ts'; + +export default class $Module$ extends Crud { + public schema: any; + constructor() { + super(); + this.schema = $Module$Schema; + } +} diff --git a/yarn.lock b/yarn.lock index 4c3c2b643e..a2cffbabce 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1899,6 +1899,28 @@ exec-sh "^0.3.2" minimist "^1.2.0" +"@domain-schema/core@^0.0.34": + version "0.0.34" + resolved "https://registry.yarnpkg.com/@domain-schema/core/-/core-0.0.34.tgz#5900ae4f0844cb8e7ffa91f5be32bd0417d96429" + integrity sha512-XpYSDStMKxMtRQ81DHwNTjN7psCoPp+1PBMEfG4vsroyUVn7zV8K0wGb9AszJaPvdqhIuVQZiWUS04ukCf1Yxw== + dependencies: + debug "^3.1.0" + +"@domain-schema/graphql@^0.0.33": + version "0.0.33" + resolved "https://registry.yarnpkg.com/@domain-schema/graphql/-/graphql-0.0.33.tgz#201d8cc5d21a9eba69f2eb8cb1fe44f012381cec" + integrity sha512-s81kImrjdwzzXU/nRBdNnw4ujqSAToKApUYUTaDF92vfesWsvmEFKhkYLC9L0tFx2XyAQrMmh+w5zG7lZkBBow== + dependencies: + debug "^3.1.0" + +"@domain-schema/knex@^0.0.35": + version "0.0.35" + resolved "https://registry.yarnpkg.com/@domain-schema/knex/-/knex-0.0.35.tgz#3ea8fb1fdf57e9c9334843ed8bfa3b4e9e1f03c4" + integrity sha512-Qd3Q4MGiSJh7XxodMSTFBSHy0PzoB9DXrvLstsNe0Enn5mi6sYEZ08HYzVgvtdbh6zvpqSARs6aZeWmZgEeydQ== + dependencies: + debug "^3.1.0" + humps "^2.0.1" + "@emotion/cache@^10.0.14": version "10.0.14" resolved "https://registry.yarnpkg.com/@emotion/cache/-/cache-10.0.14.tgz#56093cff025c04b0330bdd92afe8335ed326dd18" @@ -7953,6 +7975,13 @@ caseless@~0.12.0: resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc" integrity sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw= +cast-array@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/cast-array/-/cast-array-1.0.1.tgz#264ef1129e5888bc48cac40fe914e9f69b8d189d" + integrity sha1-Jk7xEp5YiLxIysQP6RTp9puNGJ0= + dependencies: + isarray "0.0.1" + ccount@^1.0.3: version "1.0.4" resolved "https://registry.yarnpkg.com/ccount/-/ccount-1.0.4.tgz#9cf2de494ca84060a2a8d2854edd6dfb0445f386" @@ -12663,6 +12692,13 @@ graphql-iso-date@^3.5.0: resolved "https://registry.yarnpkg.com/graphql-iso-date/-/graphql-iso-date-3.6.1.tgz#bd2d0dc886e0f954cbbbc496bbf1d480b57ffa96" integrity sha512-AwFGIuYMJQXOEAgRlJlFL4H1ncFM8n8XmoVDTNypNOZyQ8LFDG2ppMFlsS862BSTCDcSUfHp8PD3/uJhv7t59Q== +graphql-parse-fields@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/graphql-parse-fields/-/graphql-parse-fields-1.2.0.tgz#c97119a91951b98dcbe656c46a203e966a720f28" + integrity sha1-yXEZqRlRuY3L5lbEaiA+lmpyDyg= + dependencies: + cast-array "^1.0.1" + graphql-resolve-batch@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/graphql-resolve-batch/-/graphql-resolve-batch-1.0.2.tgz#ab069df8ab3410a0188701ed9fa8e382bbb8d6a5" @@ -15377,7 +15413,7 @@ kleur@^3.0.2, kleur@^3.0.3: resolved "https://registry.yarnpkg.com/kleur/-/kleur-3.0.3.tgz#a79c9ecc86ee1ce3fa6206d1216c501f147fc07e" integrity sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w== -knex@^0.14.2: +knex@^0.14.2, knex@^0.14.6: version "0.14.6" resolved "https://registry.yarnpkg.com/knex/-/knex-0.14.6.tgz#ad57c4ef8fa1b51ebc8c37c2c9b483f6fb34e41e" integrity sha512-A+iP8oSSmEF3JbSMfUGuJveqduDMEgyS5E/dO0ycVzAT4EE5askfunk7+37+hPqC951vnbFK/fIiNDaJIjVW0w== @@ -15401,6 +15437,14 @@ knex@^0.14.2: uuid "^3.2.1" v8flags "^3.0.2" +knexnest@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/knexnest/-/knexnest-1.0.0.tgz#b8840d13c2692bdd5fa3d5de67a456283d538fce" + integrity sha512-pClYHQu04G7yFBCRdwWxG/OPUAw/GR/A1Soqqb/JtcM2TADunt490HFA9+5nx1nC+MnnAIaCVgeAt0yiqBt0Kg== + dependencies: + knex "^0.14.6" + nesthydrationjs "^1.0.5" + last-call-webpack-plugin@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/last-call-webpack-plugin/-/last-call-webpack-plugin-3.0.0.tgz#9742df0e10e3cf46e5c0381c2de90d3a7a2d7555" @@ -15900,6 +15944,11 @@ lodash.isarray@^3.0.0: resolved "https://registry.yarnpkg.com/lodash.isarray/-/lodash.isarray-3.0.4.tgz#79e4eb88c36a8122af86f844aa9bcd851b5fbb55" integrity sha1-eeTriMNqgSKvhvhEqpvNhRtfu1U= +lodash.isarray@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/lodash.isarray/-/lodash.isarray-4.0.0.tgz#2aca496b28c4ca6d726715313590c02e6ea34403" + integrity sha1-KspJayjEym1yZxUxNZDALm6jRAM= + lodash.isboolean@^3.0.3: version "3.0.3" resolved "https://registry.yarnpkg.com/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz#6c2e171db2a257cd96802fd43b01b20d5f5870f6" @@ -15969,6 +16018,11 @@ lodash.keys@^3.0.0, lodash.keys@^3.1.2: lodash.isarguments "^3.0.0" lodash.isarray "^3.0.0" +lodash.keys@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/lodash.keys/-/lodash.keys-4.2.0.tgz#a08602ac12e4fb83f91fc1fb7a360a4d9ba35205" + integrity sha1-oIYCrBLk+4P5H8H7ejYKTZujUgU= + lodash.memoize@^4.1.2: version "4.1.2" resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe" @@ -17095,7 +17149,7 @@ modify-values@^1.0.0: resolved "https://registry.yarnpkg.com/modify-values/-/modify-values-1.0.1.tgz#b3939fa605546474e3e3e3c63d64bd43b4ee6022" integrity sha512-xV2bxeN6F7oYjZWTe/YPAy6MN2M+sL4u/Rlm2AHCIVGfo2p1yGmBHQ6vHehl4bRTZBdHu3TSkWdYgkwpYzAGSw== -moment@2.x, moment@^2.10.6, moment@^2.19.0, moment@^2.24.0, moment@^2.6.0: +moment@2.x, moment@^2.10.6, moment@^2.19.0, moment@^2.22.2, moment@^2.24.0, moment@^2.6.0: version "2.24.0" resolved "https://registry.yarnpkg.com/moment/-/moment-2.24.0.tgz#0d055d53f5052aa653c9f6eb68bb5d12bf5c2b5b" integrity sha512-bV7f+6l2QigeBBZSM/6yTNq4P2fNpSWj/0e7jQcy87A8e7o2nAfP/34/2ky5Vw4B9S446EtIhodAzkFCcR4dQg== @@ -17330,6 +17384,17 @@ neo-async@^2.5.0, neo-async@^2.6.0, neo-async@^2.6.1: resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.1.tgz#ac27ada66167fa8849a6addd837f6b189ad2081c" integrity sha512-iyam8fBuCUpWeKPGpaNMetEocMt364qkCsfL9JuhjXX6dRnguRVOfk2GZaDpPjcOKiiXCPINZC1GczQ7iTq3Zw== +nesthydrationjs@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/nesthydrationjs/-/nesthydrationjs-1.0.5.tgz#89fe5be87055184461de5a550475ac515491f921" + integrity sha512-LnqhV0LfczSX5LTp3UP1QIIyP8iEd0tn5ckslsPsnnbFxHWU25VXmR4cj/Ast/4MuEEYC8hxjrTQSHaZYwmBDQ== + dependencies: + lodash.isarray "^4.0.0" + lodash.isfunction "^3.0.9" + lodash.isplainobject "^4.0.6" + lodash.keys "^4.2.0" + lodash.values "^4.3.0" + next-tick@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/next-tick/-/next-tick-1.0.0.tgz#ca86d1fe8828169b0120208e3dc8424b9db8342c"