From 2dcc5c9484c9f1b1e989829a24aa60eaf0fdabb5 Mon Sep 17 00:00:00 2001 From: Maxim Chervonny Date: Thu, 25 Mar 2021 09:01:43 +0300 Subject: [PATCH] Prepopulate date (#2121) --- catalog/app/components/JsonEditor/State.js | 14 +- .../PackageDialog/PackageDialog.spec.ts | 6 +- .../Bucket/PackageDialog/PackageDialog.tsx | 14 +- catalog/app/utils/json-schema/json-schema.js | 136 ----------- ...son-schema.spec.js => json-schema.spec.ts} | 101 ++++++++- catalog/app/utils/json-schema/json-schema.ts | 214 ++++++++++++++++++ docs/CHANGELOG.md | 1 + 7 files changed, 333 insertions(+), 153 deletions(-) delete mode 100644 catalog/app/utils/json-schema/json-schema.js rename catalog/app/utils/json-schema/{json-schema.spec.js => json-schema.spec.ts} (80%) create mode 100644 catalog/app/utils/json-schema/json-schema.ts diff --git a/catalog/app/components/JsonEditor/State.js b/catalog/app/components/JsonEditor/State.js index 6e8442cfb13..af8e17d82e7 100644 --- a/catalog/app/components/JsonEditor/State.js +++ b/catalog/app/components/JsonEditor/State.js @@ -1,3 +1,4 @@ +import * as dateFns from 'date-fns' import * as R from 'ramda' import * as React from 'react' @@ -141,8 +142,17 @@ function calcReactId(valuePath, value) { function getDefaultValue(jsonDictItem) { if (!jsonDictItem || !jsonDictItem.valueSchema) return EMPTY_VALUE - if (jsonDictItem.valueSchema.default === undefined) return EMPTY_VALUE - return jsonDictItem.valueSchema.default + + const schema = jsonDictItem.valueSchema + try { + if (schema.format === 'date' && schema.dateformat) + return dateFns.format(new Date(), schema.dateformat) + } catch (error) { + // eslint-disable-next-line no-console + console.error(error) + } + if (schema.default !== undefined) return schema.default + return EMPTY_VALUE } function getJsonDictItem(jsonDict, obj, parentPath, key, sortOrder) { diff --git a/catalog/app/containers/Bucket/PackageDialog/PackageDialog.spec.ts b/catalog/app/containers/Bucket/PackageDialog/PackageDialog.spec.ts index a005e55839a..c40ec586881 100644 --- a/catalog/app/containers/Bucket/PackageDialog/PackageDialog.spec.ts +++ b/catalog/app/containers/Bucket/PackageDialog/PackageDialog.spec.ts @@ -38,19 +38,19 @@ describe('containers/Bucket/PackageDialog/PackageDialog', () => { describe('mkMetaValidator', () => { test('should return no error when no metadata', () => { - expect(PD.mkMetaValidator(null)(null)).toBeUndefined() + expect(PD.mkMetaValidator()(null)).toBeUndefined() }) test('should return error when metadata is not an object', () => { // TODO: remove this test when all references will be in typescript // @ts-expect-error - expect(PD.mkMetaValidator(null)(123)).toMatchObject({ + expect(PD.mkMetaValidator()(123)).toMatchObject({ message: 'Metadata must be a valid JSON object', }) }) test('should return no error when no Schema and metadata is object', () => { - expect(PD.mkMetaValidator(null)({ any: 'thing' })).toBeUndefined() + expect(PD.mkMetaValidator()({ any: 'thing' })).toBeUndefined() }) test("should return error when metadata isn't compliant with Schema", () => { diff --git a/catalog/app/containers/Bucket/PackageDialog/PackageDialog.tsx b/catalog/app/containers/Bucket/PackageDialog/PackageDialog.tsx index c15d716912a..db47d0aa84d 100644 --- a/catalog/app/containers/Bucket/PackageDialog/PackageDialog.tsx +++ b/catalog/app/containers/Bucket/PackageDialog/PackageDialog.tsx @@ -18,7 +18,11 @@ import AsyncResult from 'utils/AsyncResult' import * as APIConnector from 'utils/APIConnector' import * as AWS from 'utils/AWS' import useDragging from 'utils/dragging' -import { makeSchemaDefaultsSetter, makeSchemaValidator } from 'utils/json-schema' +import { + JsonSchema, + makeSchemaDefaultsSetter, + makeSchemaValidator, +} from 'utils/json-schema' import pipeThru from 'utils/pipeThru' import * as spreadsheets from 'utils/spreadsheets' import { readableBytes } from 'utils/string' @@ -100,8 +104,6 @@ const readTextFile = (file: File): Promise => reader.readAsText(file) }) -type JsonSchema = $TSFixMe - const readFile = (file: File, schema?: JsonSchema): Promise => { const mimeType = mime.extension(file.type) if (mimeType && /ods|odt|csv|xlsx|xls/.test(mimeType)) @@ -182,7 +184,7 @@ export function useNameExistence(bucket: string) { return React.useMemo(() => ({ validate, inc }), [validate, inc]) } -export function mkMetaValidator(schema: object | null) { +export function mkMetaValidator(schema?: JsonSchema) { // TODO: move schema validation to utils/validators // but don't forget that validation depends on library. // Maybe we should split validators to files at first @@ -205,7 +207,7 @@ export function mkMetaValidator(schema: object | null) { } } -export const getMetaValue = (value: unknown, optSchema: unknown) => +export const getMetaValue = (value: unknown, optSchema: JsonSchema) => value ? pipeThru(value || {})( makeSchemaDefaultsSetter(optSchema), @@ -690,7 +692,7 @@ export function SchemaFetcher({ AsyncResult.Ok({ ...defaultProps, responseError, - validate: mkMetaValidator(null), + validate: mkMetaValidator(), }), _: () => AsyncResult.Ok({ ...defaultProps, schemaLoading: true }), }), diff --git a/catalog/app/utils/json-schema/json-schema.js b/catalog/app/utils/json-schema/json-schema.js deleted file mode 100644 index 026def51f5c..00000000000 --- a/catalog/app/utils/json-schema/json-schema.js +++ /dev/null @@ -1,136 +0,0 @@ -import Ajv from 'ajv' -import * as R from 'ramda' - -export const isSchemaArray = (optSchema) => R.prop('type', optSchema) === 'array' - -export const isSchemaObject = (optSchema) => R.prop('type', optSchema) === 'object' - -const isSchemaString = (optSchema) => R.prop('type', optSchema) === 'string' - -const isSchemaNumber = (optSchema) => R.prop('type', optSchema) === 'number' - -const isSchemaInteger = (optSchema) => R.prop('type', optSchema) === 'integer' - -export const isSchemaBoolean = (optSchema) => R.prop('type', optSchema) === 'boolean' - -const isSchemaNull = (optSchema) => R.prop('type', optSchema) === 'null' - -export const isSchemaEnum = (optSchema) => !!R.prop('enum', optSchema) - -export const isSchemaOneOf = (optSchema) => !!R.prop('oneOf', optSchema) - -export const isSchemaAnyOf = (optSchema) => !!R.prop('anyOf', optSchema) - -export const isSchemaAllOf = (optSchema) => !!R.prop('allOf', optSchema) - -const isSchemaConst = (optSchema) => !!R.prop('const', optSchema) - -function isSchemaCompound(optSchema) { - if (!optSchema) return false - return ['anyOf', 'oneOf', 'not', 'allOf'].some((key) => optSchema[key]) -} - -const isSchemaReference = (optSchema) => !!R.prop('$ref', optSchema) - -export const isNestedType = R.either(isSchemaArray, isSchemaObject) - -function compoundTypeToHumanString(optSchema, condition, divider) { - if (!Array.isArray(R.prop(condition, optSchema))) return '' - - return optSchema[condition] - .map(schemaTypeToHumanString) - .filter((v) => v !== 'undefined') // NOTE: sic, see default case of `schemaTypeToHumanString` - .join(divider) -} - -export function schemaTypeToHumanString(optSchema) { - return R.cond([ - [isSchemaEnum, () => 'enum'], - [isSchemaConst, () => 'const'], - [isSchemaBoolean, () => 'boolean'], - [isSchemaNull, () => 'null'], - // NOTE: enum and const can be string too, - // that's why they are first - [ - R.prop('type'), - () => (Array.isArray(optSchema.type) ? optSchema.type.join('|') : optSchema.type), - ], - [isSchemaAnyOf, () => compoundTypeToHumanString(optSchema, 'anyOf', '|')], - [isSchemaOneOf, () => compoundTypeToHumanString(optSchema, 'oneOf', '&')], - [isSchemaAllOf, () => compoundTypeToHumanString(optSchema, 'allOf', '+')], - [isSchemaCompound, () => 'compound'], - [isSchemaReference, () => '$ref'], - [R.T, () => 'undefined'], - ])(optSchema) -} - -function doesTypeMatchCompoundSchema(value, optSchema, condition) { - if (!Array.isArray(R.prop(condition, optSchema))) return false - - return optSchema[condition] - .filter(R.has('type')) - .some((subSchema) => doesTypeMatchSchema(value, subSchema)) -} - -export function doesTypeMatchSchema(value, optSchema) { - return R.cond([ - [isSchemaEnum, () => R.includes(value, R.propOr([], 'enum', optSchema))], - [ - (s) => Array.isArray(R.prop('type', s)), - () => - optSchema.type.some((subSchema) => - doesTypeMatchSchema(value, { type: subSchema }), - ), - ], - [isSchemaAnyOf, () => doesTypeMatchCompoundSchema(value, optSchema, 'anyOf')], - [isSchemaOneOf, () => doesTypeMatchCompoundSchema(value, optSchema, 'oneOf')], - [isSchemaAllOf, () => doesTypeMatchCompoundSchema(value, optSchema, 'allOf')], - [isSchemaArray, () => Array.isArray(value)], - [isSchemaObject, () => R.is(Object, value)], - [isSchemaString, () => R.is(String, value)], - [isSchemaInteger, () => Number.isInteger(value)], - [isSchemaNumber, () => R.is(Number, value)], - [isSchemaNull, () => R.equals(null, value)], - [isSchemaBoolean, () => R.is(Boolean, value)], - [R.T, R.T], // It's not a user's fault that we can't handle the type - ])(optSchema) -} - -export const EMPTY_SCHEMA = {} - -export function makeSchemaValidator(optSchema) { - const schema = optSchema || EMPTY_SCHEMA - - const ajv = new Ajv({ useDefaults: true, schemaId: 'auto' }) - - try { - const validate = ajv.compile(schema) - - return (obj) => { - validate(R.clone(obj)) - // TODO: add custom errors - return validate.errors || [] - } - } catch (e) { - // TODO: add custom errors - return () => [e] - } -} - -export function makeSchemaDefaultsSetter(optSchema) { - const schema = optSchema || EMPTY_SCHEMA - - const ajv = new Ajv({ useDefaults: true, schemaId: 'auto' }) - - try { - const validate = ajv.compile(schema) - - return (obj) => { - const clonedObj = R.clone(obj) - validate(clonedObj) - return clonedObj - } - } catch (e) { - return R.identity - } -} diff --git a/catalog/app/utils/json-schema/json-schema.spec.js b/catalog/app/utils/json-schema/json-schema.spec.ts similarity index 80% rename from catalog/app/utils/json-schema/json-schema.spec.js rename to catalog/app/utils/json-schema/json-schema.spec.ts index fa283fa90d1..7084cc0e826 100644 --- a/catalog/app/utils/json-schema/json-schema.spec.js +++ b/catalog/app/utils/json-schema/json-schema.spec.ts @@ -271,12 +271,6 @@ describe('utils/json-schema', () => { }) }) - it('should ignore default value from Schema, when value set', () => { - expect(setDefaults({ a: 123 })).toMatchObject({ - a: 123, - }) - }) - it('should set default value from Schema, when nested value set', () => { expect(setDefaults({ optList: [{}] })).toMatchObject({ optList: [ @@ -287,5 +281,100 @@ describe('utils/json-schema', () => { ], }) }) + + it('should return the same value if no schema', () => { + const obj = { a: 1 } + expect(makeSchemaDefaultsSetter()(obj)).toBe(obj) + }) + + it('should return the same value if no properties schema', () => { + const obj = { a: 1 } + const schema = { type: 'array', items: { type: 'number' } } + expect(makeSchemaDefaultsSetter(schema)(obj)).toBe(obj) + }) + + it('should return value with defaults', () => { + const obj = { a: { b: 1 } } + const schema = { + type: 'object', + properties: { + a: { + type: 'object', + properties: { + b: { type: 'string', default: 'User set it' }, + c: { type: 'number', default: 123 }, + d: { + type: 'object', + properties: { + e: { + type: 'object', + properties: { + f: { type: 'number', default: 456 }, + }, + }, + }, + }, + }, + }, + g: { type: 'number', default: 789 }, + }, + } + expect(makeSchemaDefaultsSetter(schema)(obj)).toMatchObject({ + a: { + b: 1, + c: 123, + d: { + e: { + f: 456, + }, + }, + }, + g: 789, + }) + }) + + it('should return value with prepopulated date', () => { + jest.useFakeTimers('modern') + jest.setSystemTime(new Date(2020, 0, 30)) + + const obj = { a: { b: 1 } } + const schema = { + type: 'object', + properties: { + a: { + type: 'object', + properties: { + b: { type: 'string', format: 'date', dateformat: 'yyyy-MM-dd' }, + c: { type: 'string', format: 'date', dateformat: 'yyyy-MM-dd' }, + d: { + type: 'object', + properties: { + e: { + type: 'object', + properties: { + f: { type: 'string', format: 'date', dateformat: 'yyyy-MM-dd' }, + }, + }, + }, + }, + }, + }, + g: { type: 'string', format: 'date', dateformat: 'yyyy-MM-dd' }, + }, + } + expect(makeSchemaDefaultsSetter(schema)(obj)).toMatchObject({ + a: { + b: 1, + c: '2020-01-30', + d: { + e: { + f: '2020-01-30', + }, + }, + }, + g: '2020-01-30', + }) + jest.useRealTimers() + }) }) }) diff --git a/catalog/app/utils/json-schema/json-schema.ts b/catalog/app/utils/json-schema/json-schema.ts new file mode 100644 index 00000000000..ea388009561 --- /dev/null +++ b/catalog/app/utils/json-schema/json-schema.ts @@ -0,0 +1,214 @@ +import Ajv from 'ajv' +import * as dateFns from 'date-fns' +import * as R from 'ramda' + +type CompoundCondition = 'anyOf' | 'oneOf' | 'not' | 'allOf' + +export type JsonSchema = Partial< + { + $ref: string + const: string + dateformat: string + default: any + enum: $TSFixMe[] + format: string + items: JsonSchema + properties: Record + type: string | string[] | JsonSchema[] + } & Record +> + +export const isSchemaArray = (optSchema?: JsonSchema) => optSchema?.type === 'array' + +export const isSchemaObject = (optSchema?: JsonSchema) => optSchema?.type === 'object' + +const isSchemaString = (optSchema?: JsonSchema) => optSchema?.type === 'string' + +const isSchemaNumber = (optSchema?: JsonSchema) => optSchema?.type === 'number' + +const isSchemaInteger = (optSchema?: JsonSchema) => optSchema?.type === 'integer' + +export const isSchemaBoolean = (optSchema?: JsonSchema) => optSchema?.type === 'boolean' + +const isSchemaNull = (optSchema?: JsonSchema) => optSchema?.type === 'null' + +export const isSchemaEnum = (optSchema?: JsonSchema) => !!optSchema?.enum + +export const isSchemaOneOf = (optSchema?: JsonSchema) => !!optSchema?.oneOf + +export const isSchemaAnyOf = (optSchema?: JsonSchema) => !!optSchema?.anyOf + +export const isSchemaAllOf = (optSchema?: JsonSchema) => !!optSchema?.allOf + +const isSchemaConst = (optSchema?: JsonSchema) => !!optSchema?.const + +function isSchemaCompound(optSchema?: JsonSchema) { + if (!optSchema) return false + return ['anyOf', 'oneOf', 'not', 'allOf'].some( + (key) => optSchema[key as 'anyOf' | 'oneOf' | 'not' | 'allOf'], + ) +} + +const isSchemaReference = (optSchema: JsonSchema) => !!optSchema?.$ref + +export const isNestedType = R.either(isSchemaArray, isSchemaObject) + +function compoundTypeToHumanString( + optSchema: JsonSchema, + condition: CompoundCondition, + divider: string, +): string { + if (!isSchemaCompound(optSchema)) return '' + + return optSchema[condition]!.map(schemaTypeToHumanString) + .filter((v) => v !== 'undefined') // NOTE: sic, see default case of `schemaTypeToHumanString` + .join(divider) +} + +export function schemaTypeToHumanString(optSchema?: JsonSchema) { + if (!optSchema) return '' + return R.cond([ + [isSchemaEnum, () => 'enum'], + [isSchemaConst, () => 'const'], + [isSchemaBoolean, () => 'boolean'], + [isSchemaNull, () => 'null'], + // NOTE: enum and const can be string too, + // that's why they are first + [ + R.propOr('', 'type'), + () => + Array.isArray(optSchema.type) + ? optSchema.type.join('|') + : (optSchema.type as string), + ], + [isSchemaAnyOf, () => compoundTypeToHumanString(optSchema, 'anyOf', '|')], + [isSchemaOneOf, () => compoundTypeToHumanString(optSchema, 'oneOf', '|')], + [isSchemaAllOf, () => compoundTypeToHumanString(optSchema, 'allOf', '&')], + [isSchemaCompound, () => 'compound'], + [isSchemaReference, () => '$ref'], + [R.T, () => 'undefined'], + ])(optSchema) +} + +function doesTypeMatchCompoundSchema( + value: any, + condition: CompoundCondition, + optSchema?: JsonSchema, +): boolean { + if (!optSchema) return true + + if (!isSchemaCompound(optSchema)) return false + + return optSchema[condition]!.filter(R.has('type')).some((subSchema) => + doesTypeMatchSchema(value, subSchema), + ) +} + +// Purpose is to find mismatch explicitly +// TODO: rename and redesign function to avoid "if no schema -> return true aka 'type matches schema'" +export function doesTypeMatchSchema(value: any, optSchema?: JsonSchema): boolean { + if (!optSchema) return true + return R.cond([ + [isSchemaEnum, () => R.includes(value, R.propOr([], 'enum', optSchema))], + [ + (s) => Array.isArray(s?.type), + () => + (optSchema.type as JsonSchema[]).some((subSchema) => + doesTypeMatchSchema(value, subSchema), + ), + ], + [isSchemaAnyOf, () => doesTypeMatchCompoundSchema(value, 'anyOf', optSchema)], + [isSchemaOneOf, () => doesTypeMatchCompoundSchema(value, 'oneOf', optSchema)], + [isSchemaAllOf, () => doesTypeMatchCompoundSchema(value, 'allOf', optSchema)], + [isSchemaArray, () => Array.isArray(value)], + [isSchemaObject, () => R.is(Object, value)], + [isSchemaString, () => R.is(String, value)], + [isSchemaInteger, () => Number.isInteger(value)], + [isSchemaNumber, () => R.is(Number, value)], + [isSchemaNull, () => R.equals(null, value)], + [isSchemaBoolean, () => R.is(Boolean, value)], + [R.T, R.T], // It's not a user's fault that we can't handle the type + ])(optSchema) +} + +export const EMPTY_SCHEMA = {} + +export function makeSchemaValidator(optSchema?: JsonSchema) { + const schema = optSchema || EMPTY_SCHEMA + + const ajv = new Ajv({ useDefaults: true, schemaId: 'auto' }) + + try { + const validate = ajv.compile(schema) + + return (obj: any) => { + validate(R.clone(obj)) + // TODO: add custom errors + return validate.errors || [] + } + } catch (e) { + // TODO: add custom errors + return () => [e] + } +} + +// TODO: make general purpose function like reduce, +// do "prefill Value" at callback, +// and use it for iterating Schema in JsonEditor/State +function scanSchemaAndPrefillValues( + getValue: (s?: JsonSchema) => any, + value: Record, + optSchema?: JsonSchema, +): Record { + if (!optSchema) return value + + if (!optSchema?.properties) return value + + return Object.keys(optSchema.properties).reduce((memo, key) => { + const valueItem = value === undefined ? undefined : value[key] + + // don't touch user's primitive value + if (valueItem && !R.is(Object, valueItem)) return memo + + const schemaItem = R.propOr({}, key, optSchema.properties) as JsonSchema + + if (schemaItem.properties) + return R.assoc( + key, + scanSchemaAndPrefillValues(getValue, valueItem, schemaItem), + memo, + ) + + if (schemaItem.items && Array.isArray(valueItem)) + return R.assoc( + key, + valueItem.map((v) => scanSchemaAndPrefillValues(getValue, v, schemaItem.items)), + memo, + ) + + const preDefinedValue = getValue(schemaItem) + if (preDefinedValue) return R.assoc(key, preDefinedValue, memo) + + return memo + }, value) +} + +export function getDefaultValue(optSchema?: JsonSchema): any { + if (!optSchema) return undefined + + if (optSchema.default !== undefined) return optSchema.default + + try { + if (optSchema.format === 'date' && optSchema.dateformat) + return dateFns.format(new Date(), optSchema.dateformat) + } catch (error) { + // eslint-disable-next-line no-console + console.error(error) + } + + return undefined +} + +export function makeSchemaDefaultsSetter(optSchema?: JsonSchema) { + return (obj: any) => scanSchemaAndPrefillValues(getDefaultValue, obj, optSchema) +} diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 911db321f58..94dfcc4ce54 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -13,6 +13,7 @@ ## CLI ## Catalog, Lambdas +* [Added] Prepopulate today date for metadata ([#2121](https://github.com/quiltdata/quilt/pull/2121)) * [Changed] New DataGrid-based file listing UI with arbitrary sorting and filtering ([#2097](https://github.com/quiltdata/quilt/pull/2097)) * [Changed] Item selection in folder-to-package dialog ([#2122](https://github.com/quiltdata/quilt/pull/2122))