Skip to content

Commit

Permalink
feat: add support for defining initial values for all schema types
Browse files Browse the repository at this point in the history
Co-authored-by: Bjørge Næss <bjoerge@gmail.com>
Co-authored-by: Rex Isaac Raphael <rex.raphael@outlook.com>
Co-authored-by: Co Espen Hovlandsdal <espen@hovlandsdal.com>
  • Loading branch information
3 people committed Apr 28, 2021
1 parent 0717b58 commit 28593a0
Show file tree
Hide file tree
Showing 21 changed files with 835 additions and 122 deletions.
2 changes: 1 addition & 1 deletion packages/@sanity/desk-tool/src/utils/withInitialValue.js
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ function resolveInitialValueWithParameters(templateName, parameters) {
return of({isResolving: false, initialValue: undefined})
}

return from(resolveInitialValue(getTemplateById(templateName), parameters)).pipe(
return from(resolveInitialValue(schema, getTemplateById(templateName), parameters)).pipe(
map((initialValue) => ({isResolving: false, initialValue}))
)
}
Expand Down
7 changes: 7 additions & 0 deletions packages/@sanity/initial-value-templates/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@
"oneline": "^1.0.3"
},
"devDependencies": {
"@sanity/schema": "^2.7.4",
"@sanity/types": "^2.8.0",
"@types/jest": "^26.0.22",
"jest": "^26.6.3",
"rimraf": "^2.7.1",
Expand Down Expand Up @@ -62,6 +64,11 @@
"json",
"node"
],
"globals": {
"ts-jest": {
"diagnostics": false
}
},
"moduleNameMapper": {
"^part:@sanity/base/schema$": "<rootDir>/test/mocks/schema.js",
"^part:@sanity/base/initial-value-templates?": "<rootDir>/test/mocks/templates.js"
Expand Down
11 changes: 5 additions & 6 deletions packages/@sanity/initial-value-templates/src/Template.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
import {InitialValueProperty, SchemaType} from '@sanity/types'
import {TemplateParameter} from './TemplateParameters'

type ValueResolver = (parameters: {[key: string]: any}) => {[key: string]: any}

export interface Template {
id: string
title: string
description?: string
schemaType: string
icon?: Function
value: ValueResolver | {[key: string]: any}
icon?: SchemaType['icon']
value: InitialValueProperty
parameters?: TemplateParameter[]
}

Expand Down Expand Up @@ -51,15 +50,15 @@ export class TemplateBuilder {
return this.spec.schemaType
}

icon(icon: Function) {
icon(icon: SchemaType['icon']) {
return this.clone({icon})
}

getIcon() {
return this.spec.icon
}

value(value: ValueResolver | {[key: string]: any}) {
value(value: InitialValueProperty) {
return this.clone({value})
}

Expand Down
5 changes: 3 additions & 2 deletions packages/@sanity/initial-value-templates/src/builder.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {SchemaType} from '@sanity/types'
import {Template, TemplateBuilder} from './Template'
import {Schema, SchemaType, getDefaultSchema} from './parts/Schema'
import {Schema, getDefaultSchema} from './parts/Schema'

function defaultTemplateForType(
schemaType: string | SchemaType,
Expand All @@ -22,7 +23,7 @@ function defaultTemplateForType(
})
}

function defaults(sanitySchema?: Schema) {
function defaults(sanitySchema?: Schema): TemplateBuilder[] {
const schema = sanitySchema || getDefaultSchema()
if (!schema) {
throw new Error(
Expand Down
2 changes: 2 additions & 0 deletions packages/@sanity/initial-value-templates/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,5 @@ export {
getParameterlessTemplatesBySchemaType,
getTemplateErrors,
} from './templates'

export {resolveInitialValueForType} from './resolveInitialValueForType'
48 changes: 1 addition & 47 deletions packages/@sanity/initial-value-templates/src/parts/Schema.ts
Original file line number Diff line number Diff line change
@@ -1,52 +1,6 @@
import {Schema} from '@sanity/types'
import getDefaultModule from './getDefaultModule'

interface Schema {
name: string
get(typeName: string): any
getTypeNames(): string[]
}

interface SchemaField {
name: string
type: SchemaType
}

interface PreviewFields {
media?: string
}

interface PreviewPreparer {
(selection: {}): PreviewFields
}

type SortDirection = 'asc' | 'desc'

interface SortItem {
field: string
direction: SortDirection
}

interface Ordering {
title: string
name?: string
by: SortItem[]
}

export interface SchemaType {
name: string
title?: string
icon?: Function
type?: SchemaType
to?: SchemaField[]
fields?: SchemaField[]
orderings?: Ordering[]
initialValue?: Function | {[key: string]: any}
preview?: {
select?: PreviewFields
prepare?: PreviewPreparer
}
}

// We are lazy-loading the part to work around typescript trying to resolve it
const getDefaultSchema = (): Schema => {
const schema: Schema = getDefaultModule(require('part:@sanity/base/schema'))
Expand Down
41 changes: 25 additions & 16 deletions packages/@sanity/initial-value-templates/src/resolve.ts
Original file line number Diff line number Diff line change
@@ -1,39 +1,48 @@
import {isPlainObject} from 'lodash'
import {Schema} from '@sanity/types'
import {Template, TemplateBuilder} from './Template'
import {validateInitialValue} from './validate'
import {validateInitialObjectValue} from './validate'
import deepAssign from './util/deepAssign'
import {resolveInitialValueForType} from './resolveInitialValueForType'
import {resolveValue} from './util/resolveValue'
import {isRecord} from './util/isRecord'

export function isBuilder(template: Template | TemplateBuilder): template is TemplateBuilder {
return typeof (template as TemplateBuilder).serialize === 'function'
}

async function resolveInitialValue(
export async function resolveInitialValue(
schema: Schema,
template: Template | TemplateBuilder,
params: {[key: string]: any} = {}
): Promise<{[key: string]: any}> {
// Template builder?
if (isBuilder(template)) {
return resolveInitialValue(template.serialize(), params)
return resolveInitialValue(schema, template.serialize(), params)
}

const {id, value} = template
const {id, schemaType, value} = template
if (!value) {
throw new Error(`Template "${id}" has invalid "value" property`)
}

// Static value?
if (isPlainObject(value)) {
return validateInitialValue(value, template)
}
let resolvedValue = await resolveValue(value, params)

// Not an object, so should be a function
if (typeof value !== 'function') {
if (!isRecord(resolvedValue)) {
throw new Error(
`Template "${id}" has invalid "value" property - must be a plain object or a resolver function`
`Template "${id}" has invalid "value" property - must be a plain object or a resolver function returning a plain object`
)
}

const resolved = await value(params)
return validateInitialValue(resolved, template)
}
// validate default document initial values
resolvedValue = validateInitialObjectValue(resolvedValue, template)

export {resolveInitialValue}
// Get deep initial values from schema types (note: the initial value from template overrides the types)
const newValue = deepAssign(
(await resolveInitialValueForType(schema.get(schemaType), params)) || {},
resolvedValue as Record<string, unknown>
)

// revalidate and return new initial values
// todo: would be better to do validation as part of type resolution
return validateInitialObjectValue(newValue, template)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import {isEmpty, resolveTypeName} from '@sanity/util/content'

import {
ArraySchemaType,
InitialValueParams,
isArraySchemaType,
isObjectSchemaType,
ObjectSchemaType,
SchemaType,
} from '@sanity/types'

import {randomKey} from '@sanity/util/paths'
import deepAssign from './util/deepAssign'
import {resolveValue} from './util/resolveValue'

export function getItemType(arrayType: ArraySchemaType, item: unknown): SchemaType | undefined {
const itemTypeName = resolveTypeName(item)

return itemTypeName === 'object' && arrayType.of.length === 1
? arrayType.of[0]
: arrayType.of.find((memberType) => memberType.name === itemTypeName)
}

const MAX_RECURSION_DEPTH = 10

/**
* Resolve initial value for the given schema type (recursively)
*
* @param type {SchemaType} this is the name of the document
* @param params {Record<string, unknown>} params is a sanity context object passed to every initial value function
* @param maxDepth {Record<string, unknown>} maximum recursion depth (default 9)
*/
export function resolveInitialValueForType(
type: SchemaType,
params: InitialValueParams = {},
maxDepth = MAX_RECURSION_DEPTH
) {
if (maxDepth <= 0) {
return undefined
}
if (isObjectSchemaType(type)) {
return resolveInitialObjectValue(type, params, maxDepth)
}
if (isArraySchemaType(type)) {
return resolveInitialArrayValue(type, params, maxDepth)
}
return resolveValue(type.initialValue, params)
}

async function resolveInitialArrayValue(type, params: InitialValueParams, maxDepth: number) {
const initialArray = await resolveValue(type.initialValue)
return Array.isArray(initialArray)
? Promise.all(
initialArray.map(async (initialItem) => {
const itemType = getItemType(type, initialItem)!
return isObjectSchemaType(itemType)
? {
...initialItem,
...(await resolveInitialValueForType(itemType, params, maxDepth - 1)),
_key: randomKey(),
}
: initialItem
})
)
: undefined
}
async function resolveInitialObjectValue(
type: ObjectSchemaType,
params: InitialValueParams,
maxDepth: number
) {
const initialObject: Record<string, unknown> = {
...((await resolveValue(type.initialValue, params)) || {}),
}

const fieldValues = {}
await Promise.all(
type.fields.map(async (field) => {
const initialFieldValue = await resolveInitialValueForType(field.type, params, maxDepth - 1)
if (initialFieldValue !== undefined && initialFieldValue !== null) {
fieldValues[field.name] = initialFieldValue
}
})
)

const merged = deepAssign(fieldValues, initialObject)
if (isEmpty(merged)) {
return undefined
}
if (type.name !== 'object') {
merged._type = type.name
}
return merged
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import deepAssign from './deepAssign'

it('ignores undefined values', () => {
expect(deepAssign({foo: undefined}, {bar: undefined})).toStrictEqual({
foo: undefined,
bar: undefined,
})
})

it('assigns undefined values from source', () => {
expect(deepAssign({foo: 'bar', bar: 'hello'}, {foo: undefined})).toStrictEqual({
foo: undefined,
bar: 'hello',
})
})

it('assigns non-undefined values from source', () => {
expect(deepAssign({foo: undefined}, {bar: 'hello'})).toStrictEqual({foo: undefined, bar: 'hello'})
})

it('merges non-undefined values', () => {
expect(deepAssign({foo: 'foo'}, {bar: 'bar'})).toStrictEqual({foo: 'foo', bar: 'bar'})
})

it("doesn't merge arrays", () => {
expect(deepAssign({arr: ['foo']}, {arr: ['bar']})).toStrictEqual({arr: ['bar']})
})

it('merges deep', () => {
expect(
deepAssign({some: {deep: {object: true}}}, {some: {deep: {array: ['foo']}}})
).toStrictEqual({
some: {
deep: {
array: ['foo'],
object: true,
},
},
})
})
19 changes: 19 additions & 0 deletions packages/@sanity/initial-value-templates/src/util/deepAssign.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import {isRecord} from './isRecord'

// deep object assign for objects
// note: doesn't mutate target
export default function deepAssign(
target: Record<string, unknown>,
source: Record<string, unknown>
): Record<string, unknown> {
const result = {...target, ...source}

Object.keys(result).forEach((key) => {
const sourceVal = source[key]
const targetVal = target[key]
if (isRecord(sourceVal) && isRecord(targetVal)) {
result[key] = deepAssign(targetVal, sourceVal)
}
})
return result
}
5 changes: 5 additions & 0 deletions packages/@sanity/initial-value-templates/src/util/isRecord.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import {isPlainObject} from 'lodash'

export function isRecord(value: unknown): value is Record<string, unknown> {
return isPlainObject(value)
}
11 changes: 11 additions & 0 deletions packages/@sanity/initial-value-templates/src/util/resolveValue.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import {InitialValueParams, InitialValueProperty, InitialValueResolver} from '@sanity/types'

// returns the "resolved" value from an initial value property (e.g. type.initialValue)
export async function resolveValue<InitialValue>(
initialValueOpt: InitialValueProperty<InitialValue>,
params?: InitialValueParams
): Promise<InitialValue | undefined> {
return typeof initialValueOpt === 'function'
? (initialValueOpt as InitialValueResolver<InitialValue>)(params)
: initialValueOpt
}

0 comments on commit 28593a0

Please sign in to comment.