diff --git a/packages/@sanity/form-builder/package.json b/packages/@sanity/form-builder/package.json index 709fed7a2c4..c0ace3324ed 100644 --- a/packages/@sanity/form-builder/package.json +++ b/packages/@sanity/form-builder/package.json @@ -36,6 +36,7 @@ "@sanity/imagetool": "0.140.0", "@sanity/mutator": "0.140.0", "@sanity/schema": "0.140.0", + "@sanity/util": "0.140.0", "attr-accept": "^1.1.0", "canvas-to-blob": "^0.0.0", "classnames": "^2.2.5", diff --git a/packages/@sanity/form-builder/src/FormBuilderInput.js b/packages/@sanity/form-builder/src/FormBuilderInput.js index 26c16a73ab1..a3f49acbb11 100644 --- a/packages/@sanity/form-builder/src/FormBuilderInput.js +++ b/packages/@sanity/form-builder/src/FormBuilderInput.js @@ -4,7 +4,7 @@ import React from 'react' import type {Path} from './typedefs/path' import PatchEvent from './PatchEvent' import generateHelpUrl from '@sanity/generate-help-url' -import * as PathUtils from './utils/pathUtils' +import * as PathUtils from '@sanity/util/paths' import type {Type} from './typedefs' type Props = { diff --git a/packages/@sanity/form-builder/src/inputs/ArrayInput/ArrayInput.js b/packages/@sanity/form-builder/src/inputs/ArrayInput/ArrayInput.js index ccad6a13cef..1b40a9dfe84 100644 --- a/packages/@sanity/form-builder/src/inputs/ArrayInput/ArrayInput.js +++ b/packages/@sanity/form-builder/src/inputs/ArrayInput/ArrayInput.js @@ -7,7 +7,7 @@ import type {Marker, Type} from '../../typedefs' import type {Path} from '../../typedefs/path' import type {Subscription} from '../../typedefs/observable' import {resolveTypeName} from '../../utils/resolveTypeName' -import {FOCUS_TERMINATOR, startsWith} from '../../utils/pathUtils' +import {FOCUS_TERMINATOR, startsWith} from '@sanity/util/paths' import UploadTargetFieldset from '../../utils/UploadTargetFieldset' import {insert, PatchEvent, set, setIfMissing, unset} from '../../PatchEvent' import styles from './styles/ArrayInput.css' @@ -296,9 +296,7 @@ export default class ArrayInput extends React.Component { Some items in this list are missing their keys. We need to fix this before the list can be edited.
- +
Why is this happening?}> This usually happens when items are created through the API client from outside the diff --git a/packages/@sanity/form-builder/src/inputs/ArrayInput/ItemValue.js b/packages/@sanity/form-builder/src/inputs/ArrayInput/ItemValue.js index 69b4548d9b2..80decaddd76 100644 --- a/packages/@sanity/form-builder/src/inputs/ArrayInput/ItemValue.js +++ b/packages/@sanity/form-builder/src/inputs/ArrayInput/ItemValue.js @@ -22,7 +22,7 @@ import Preview from '../../Preview' import {resolveTypeName} from '../../utils/resolveTypeName' import type {Path} from '../../typedefs/path' import type {Marker, Type} from '../../typedefs' -import * as PathUtils from '../../utils/pathUtils' +import * as PathUtils from '@sanity/util/paths' import ConfirmButton from './ConfirmButton' import styles from './styles/ItemValue.css' import type {ArrayType, ItemValue} from './typedefs' diff --git a/packages/@sanity/form-builder/src/inputs/ArrayOfPrimitivesInput/ArrayOfPrimitivesInput.js b/packages/@sanity/form-builder/src/inputs/ArrayOfPrimitivesInput/ArrayOfPrimitivesInput.js index e506f737348..b9b9203db40 100644 --- a/packages/@sanity/form-builder/src/inputs/ArrayOfPrimitivesInput/ArrayOfPrimitivesInput.js +++ b/packages/@sanity/form-builder/src/inputs/ArrayOfPrimitivesInput/ArrayOfPrimitivesInput.js @@ -6,7 +6,7 @@ import {Item as SortableItem, List as SortableList} from 'part:@sanity/component import ArrayFunctions from 'part:@sanity/form-builder/input/array/functions' import Fieldset from 'part:@sanity/components/fieldsets/default' import {PatchEvent, set, unset} from '../../PatchEvent' -import {startsWith} from '../../utils/pathUtils' +import {startsWith} from '@sanity/util/paths' import {resolveTypeName} from '../../utils/resolveTypeName' import type {Path} from '../../typedefs/path' import type {Type, Marker} from '../../typedefs' diff --git a/packages/@sanity/form-builder/src/inputs/BlockEditor/Toolbar/AnnotationButtons.js b/packages/@sanity/form-builder/src/inputs/BlockEditor/Toolbar/AnnotationButtons.js index 01e462ef6b6..c14b1212feb 100644 --- a/packages/@sanity/form-builder/src/inputs/BlockEditor/Toolbar/AnnotationButtons.js +++ b/packages/@sanity/form-builder/src/inputs/BlockEditor/Toolbar/AnnotationButtons.js @@ -7,7 +7,7 @@ import LinkIcon from 'part:@sanity/base/link-icon' import SanityLogoIcon from 'part:@sanity/base/sanity-logo-icon' import ToggleButton from 'part:@sanity/components/toggles/button' import type {BlockContentFeature, BlockContentFeatures, Path, SlateEditor} from '../typeDefs' -import {FOCUS_TERMINATOR} from '../../../utils/pathUtils' +import {FOCUS_TERMINATOR} from '@sanity/util/paths' import CustomIcon from './CustomIcon' import ToolbarClickAction from './ToolbarClickAction' diff --git a/packages/@sanity/form-builder/src/inputs/BlockEditor/Toolbar/InsertMenu.js b/packages/@sanity/form-builder/src/inputs/BlockEditor/Toolbar/InsertMenu.js index 8e38684e89c..dc88e9d0bbe 100644 --- a/packages/@sanity/form-builder/src/inputs/BlockEditor/Toolbar/InsertMenu.js +++ b/packages/@sanity/form-builder/src/inputs/BlockEditor/Toolbar/InsertMenu.js @@ -6,7 +6,7 @@ import BlockObjectIcon from 'part:@sanity/base/block-object-icon' import InlineObjectIcon from 'part:@sanity/base/inline-object-icon' import type {Type, SlateValue, SlateEditor, Path} from '../typeDefs' -import {FOCUS_TERMINATOR} from '../../../utils/pathUtils' +import {FOCUS_TERMINATOR} from '@sanity/util/paths' import styles from './styles/InsertMenu.css' type Props = { diff --git a/packages/@sanity/form-builder/src/inputs/BlockEditor/nodes/BlockObject.js b/packages/@sanity/form-builder/src/inputs/BlockEditor/nodes/BlockObject.js index ed63d69c672..ca501a7bb20 100644 --- a/packages/@sanity/form-builder/src/inputs/BlockEditor/nodes/BlockObject.js +++ b/packages/@sanity/form-builder/src/inputs/BlockEditor/nodes/BlockObject.js @@ -28,7 +28,7 @@ import type { } from '../typeDefs' import {PatchEvent} from '../../../PatchEvent' -import {FOCUS_TERMINATOR} from '../../../utils/pathUtils' +import {FOCUS_TERMINATOR} from '@sanity/util/paths' import {resolveTypeName} from '../../../utils/resolveTypeName' import InvalidValue from '../../InvalidValueInput' diff --git a/packages/@sanity/form-builder/src/inputs/BlockEditor/nodes/InlineObject.js b/packages/@sanity/form-builder/src/inputs/BlockEditor/nodes/InlineObject.js index e6330b993ed..cd49fe93c39 100644 --- a/packages/@sanity/form-builder/src/inputs/BlockEditor/nodes/InlineObject.js +++ b/packages/@sanity/form-builder/src/inputs/BlockEditor/nodes/InlineObject.js @@ -20,7 +20,7 @@ import type { import {resolveTypeName} from '../../../utils/resolveTypeName' import {PatchEvent} from '../../../PatchEvent' -import {FOCUS_TERMINATOR} from '../../../utils/pathUtils' +import {FOCUS_TERMINATOR} from '@sanity/util/paths' import InvalidValue from '../../InvalidValueInput' import Preview from '../../../Preview' diff --git a/packages/@sanity/form-builder/src/inputs/BlockEditor/nodes/Span.js b/packages/@sanity/form-builder/src/inputs/BlockEditor/nodes/Span.js index 9fd6039350d..149113bb0e5 100644 --- a/packages/@sanity/form-builder/src/inputs/BlockEditor/nodes/Span.js +++ b/packages/@sanity/form-builder/src/inputs/BlockEditor/nodes/Span.js @@ -3,7 +3,7 @@ import type {Node} from 'react' import React from 'react' import type {BlockContentFeatures, Type, Marker, Path, SlateEditor} from '../typeDefs' -import {FOCUS_TERMINATOR} from '../../../utils/pathUtils' +import {FOCUS_TERMINATOR} from '@sanity/util/paths' import styles from './styles/Span.css' import {Inline} from 'slate' diff --git a/packages/@sanity/util/.babelrc.js b/packages/@sanity/util/.babelrc.js index c073ff12111..a0da2895ceb 100644 --- a/packages/@sanity/util/.babelrc.js +++ b/packages/@sanity/util/.babelrc.js @@ -1,3 +1,4 @@ module.exports = { - extends: '../../../.babelrc.js' + extends: '../../../.babelrc.js', + presets: ['@babel/preset-flow'] } diff --git a/packages/@sanity/util/.flowconfig b/packages/@sanity/util/.flowconfig new file mode 100644 index 00000000000..5708b9e134d --- /dev/null +++ b/packages/@sanity/util/.flowconfig @@ -0,0 +1,7 @@ +[include] +./src +../../../ +[libs] +defs +[ignore] +.*/examples/.* diff --git a/packages/@sanity/util/package.json b/packages/@sanity/util/package.json index 6cb5ba5f0bb..524e157fc53 100644 --- a/packages/@sanity/util/package.json +++ b/packages/@sanity/util/package.json @@ -38,6 +38,7 @@ "resolve-from": "^4.0.0" }, "devDependencies": { + "@babel/preset-flow": "^7.0.0", "jest": "^23.6.0", "rimraf": "^2.6.2" } diff --git a/packages/@sanity/util/paths.js b/packages/@sanity/util/paths.js new file mode 100644 index 00000000000..fc1a0b6f053 --- /dev/null +++ b/packages/@sanity/util/paths.js @@ -0,0 +1 @@ +module.exports = require('./lib/pathUtils') diff --git a/packages/@sanity/form-builder/src/utils/pathUtils.js b/packages/@sanity/util/src/pathUtils.js similarity index 52% rename from packages/@sanity/form-builder/src/utils/pathUtils.js rename to packages/@sanity/util/src/pathUtils.js index 160b177b463..63f2e2604f6 100644 --- a/packages/@sanity/form-builder/src/utils/pathUtils.js +++ b/packages/@sanity/util/src/pathUtils.js @@ -1,6 +1,50 @@ +/* eslint-disable max-depth */ // @flow import type {Path, PathSegment} from '../typedefs/path' +const rePropName = /[^.[\]]+|\[(?:(-?\d+(?:\.\d+)?)|(["'])((?:(?!\2)[^\\]|\\.)*?)\2)\]|(?=(?:\.|\[\])(?:\.|\[\]|$))/g +const reKeySegment = /_key\s*==\s*['"](.*)['"]/ + +export const FOCUS_TERMINATOR = '$' + +// eslint-disable-next-line complexity +export function get(obj: mixed, path: Path | string, defaultVal: mixed) { + const select = typeof path === 'string' ? fromString(path) : path + if (!Array.isArray(select)) { + throw new Error('Path must be an array or a string') + } + + let acc = obj + for (let i = 0; i < select.length; i++) { + const segment = select[i] + if (isIndexSegment(segment)) { + if (!Array.isArray(acc)) { + return defaultVal + } + + acc = acc[segment] + } + + if (isKeySegment(segment)) { + if (!Array.isArray(acc)) { + return defaultVal + } + + acc = acc.find(item => item._key === segment._key) + } + + if (typeof segment === 'string') { + acc = typeof acc === 'object' && acc !== null ? acc[segment] : undefined + } + + if (typeof acc === 'undefined') { + return defaultVal + } + } + + return acc +} + export function isEqual(path: Path, otherPath: Path) { return ( path.length === otherPath.length && @@ -8,8 +52,6 @@ export function isEqual(path: Path, otherPath: Path) { ) } -export const FOCUS_TERMINATOR = '$' - export function isSegmentEqual(pathSegment: PathSegment, otherPathSegment: PathSegment) { const pathSegmentType = typeof pathSegment const otherPathSegmentType = typeof otherPathSegment @@ -56,7 +98,7 @@ export function trimLeft(prefix: Path, path: Path): Path { return trimLeft(prefixTail, pathTail) } -export function trimRight(suffix, path) { +export function trimRight(suffix: Path, path: Path): Path { const sufLen = suffix.length const pathLen = path.length if (sufLen === 0 || pathLen === 0) { @@ -75,7 +117,11 @@ export function trimRight(suffix, path) { return path.slice(0, pathLen - i) } -export function toString(path) { +export function toString(path: Path): string { + if (!Array.isArray(path)) { + throw new Error('Path is not an array') + } + return path.reduce((target, segment, i) => { const segmentType = typeof segment if (segmentType === 'number') { @@ -94,3 +140,49 @@ export function toString(path) { throw new Error(`Unsupported path segment \`${JSON.stringify(segment)}\``) }, '') } + +export function fromString(path: string): Path { + if (typeof path !== 'string') { + throw new Error('Path is not a string') + } + + const segments = path.match(rePropName) + if (!segments) { + throw new Error('Invalid path string') + } + + return segments.map(normalizePathSegment) +} + +function normalizePathSegment(segment: string): PathSegment { + if (isIndexSegment(segment)) { + return normalizeIndexSegment(segment) + } + + if (isKeySegment(segment)) { + return normalizeKeySegment(segment) + } + + return segment +} + +function normalizeIndexSegment(segment: string): PathSegment { + return Number(segment.replace(/[^\d]/g, '')) +} + +function normalizeKeySegment(segment: string): PathSegment { + const segments = segment.match(reKeySegment) + return {_key: segments[1]} +} + +function isIndexSegment(segment: string | number): boolean { + return typeof segment === 'number' || /^\[\d+\]$/.test(segment) +} + +function isKeySegment(segment: PathSegment): boolean { + if (typeof segment === 'string') { + return reKeySegment.test(segment.trim()) + } + + return segment && segment._key +} diff --git a/packages/@sanity/util/src/typedefs/path.js b/packages/@sanity/util/src/typedefs/path.js new file mode 100644 index 00000000000..c40728e167d --- /dev/null +++ b/packages/@sanity/util/src/typedefs/path.js @@ -0,0 +1,7 @@ +type KeyedSegment = { + _key: string +} + +export type PathSegment = string | number | KeyedSegment + +export type Path = Array diff --git a/packages/@sanity/util/test/PathUtils.test.js b/packages/@sanity/util/test/PathUtils.test.js new file mode 100644 index 00000000000..22a4119b503 --- /dev/null +++ b/packages/@sanity/util/test/PathUtils.test.js @@ -0,0 +1,174 @@ +/* eslint-disable max-nested-callbacks */ +import {fromString, toString, get} from '../src/pathUtils' + +const srcObject = { + title: 'Hei', + nested: {'0': 'Zero-Key'}, + nullVal: null, + body: [{_key: 'foo', title: 'Foo'}, {_key: 'bar', children: [{_key: 'child1', text: 'Heisann'}]}], + multiDimensional: [ + [[{_key: 'abc', title: 'hai'}], [{_key: 'def', title: 'def'}]], + [[13, 14], [15, 16]] + ] +} + +describe('path utilities', () => { + describe('fromString', () => { + test('throws if not a string', () => { + expect(() => fromString()).toThrowError('Path is not a string') + expect(() => fromString(13)).toThrowError('Path is not a string') + expect(() => fromString(null)).toThrowError('Path is not a string') + expect(() => fromString(false)).toThrowError('Path is not a string') + }) + + test('handles plain property segments', () => { + expect(fromString('foo')).toEqual(['foo']) + }) + + test('handles index segments', () => { + expect(fromString('[0]')).toEqual([0]) + expect(fromString('[1337]')).toEqual([1337]) + }) + + test('handles key segments', () => { + expect(fromString('[_key == "foo"]')).toEqual([{_key: 'foo'}]) + expect(fromString("[ _key== 'B4z']")).toEqual([{_key: 'B4z'}]) + expect(fromString('[_key=="bar"]')).toEqual([{_key: 'bar'}]) + }) + + test('handles deep prop segments', () => { + expect(fromString('foo.bar')).toEqual(['foo', 'bar']) + expect(fromString('bar.foo')).toEqual(['bar', 'foo']) + expect(fromString('bar.foo.baz')).toEqual(['bar', 'foo', 'baz']) + }) + + test('handles deep array index segments', () => { + expect(fromString('foo[13]')).toEqual(['foo', 13]) + expect(fromString('bar.foo[3]')).toEqual(['bar', 'foo', 3]) + expect(fromString('[3][18]')).toEqual([3, 18]) + }) + + test('handles deep key segments', () => { + expect(fromString('foo[_key=="bar"].body[_key=="13ch"')).toEqual([ + 'foo', + {_key: 'bar'}, + 'body', + {_key: '13ch'} + ]) + expect(fromString('bar.foo[3][_key == "seg"]')).toEqual(['bar', 'foo', 3, {_key: 'seg'}]) + expect(fromString('[_key=="foo"][_key== "bar"][_key =="baz"][ _key == "seg" ')).toEqual([ + {_key: 'foo'}, + {_key: 'bar'}, + {_key: 'baz'}, + {_key: 'seg'} + ]) + }) + }) + + describe('toString', () => { + test('throws if not an array', () => { + expect(() => toString()).toThrowError('Path is not an array') + expect(() => toString(13)).toThrowError('Path is not an array') + expect(() => toString(null)).toThrowError('Path is not an array') + expect(() => toString(false)).toThrowError('Path is not an array') + }) + + test('handles plain property segments', () => { + expect(toString(['foo'])).toEqual('foo') + }) + + test('handles index segments', () => { + expect(toString([0])).toEqual('[0]') + expect(toString([1337])).toEqual('[1337]') + }) + + test('handles key segments', () => { + expect(toString([{_key: 'foo'}])).toEqual('[_key=="foo"]') + expect(toString([{_key: 'B4z'}])).toEqual('[_key=="B4z"]') + expect(toString([{_key: 'bar'}])).toEqual('[_key=="bar"]') + }) + + test('handles deep prop segments', () => { + expect(toString(['foo', 'bar'])).toEqual('foo.bar') + expect(toString(['bar', 'foo'])).toEqual('bar.foo') + expect(toString(['bar', 'foo', 'baz'])).toEqual('bar.foo.baz') + }) + + test('handles deep array index segments', () => { + expect(toString(['foo', 13])).toEqual('foo[13]') + expect(toString(['bar', 'foo', 3])).toEqual('bar.foo[3]') + expect(toString([3, 18])).toEqual('[3][18]') + }) + + test('handles deep key segments', () => { + expect(toString(['foo', {_key: 'bar'}, 'body', {_key: '13ch'}])).toEqual( + 'foo[_key=="bar"].body[_key=="13ch"]' + ) + expect(toString(['bar', 'foo', 3, {_key: 'seg'}])).toEqual('bar.foo[3][_key=="seg"]') + expect(toString([{_key: 'foo'}, {_key: 'bar'}, {_key: 'baz'}, {_key: 'seg'}])).toEqual( + '[_key=="foo"][_key=="bar"][_key=="baz"][_key=="seg"]' + ) + }) + + test('throws on unrecognized segment types', () => { + expect(() => toString([{foo: 'bar'}])).toThrowError( + 'Unsupported path segment `{"foo":"bar"}`' + ) + }) + }) + + describe('get', () => { + test('throws on non-array/non-string path', () => { + expect(() => get(srcObject, null)).toThrowError('Path must be an array or a string') + expect(() => get(srcObject, 13)).toThrowError('Path must be an array or a string') + expect(() => get(srcObject, false)).toThrowError('Path must be an array or a string') + expect(() => get(srcObject, true)).toThrowError('Path must be an array or a string') + }) + + test('can get simple props', () => { + expect(get(srcObject, 'title')).toBe(srcObject.title) + expect(get(srcObject, ['title'])).toBe(srcObject.title) + }) + + test('can pass default value', () => { + const defaultVal = Math.random() + expect(get(srcObject, 'notSet', defaultVal)).toBe(defaultVal) + expect(get(srcObject, ['notSet'], defaultVal)).toBe(defaultVal) + }) + + test('can use array indexes', () => { + expect(get(srcObject, ['body', 0])).toBe(srcObject.body[0]) + expect(get(srcObject, ['body', 1, 'children', 0])).toBe(srcObject.body[1].children[0]) + expect(get(srcObject, ['multiDimensional', 1, 1])).toBe(srcObject.multiDimensional[1][1]) + }) + + test('can use key lookup', () => { + expect(get(srcObject, ['body', {_key: 'bar'}])).toBe(srcObject.body[1]) + expect(get(srcObject, ['body', {_key: 'bar'}, 'children', {_key: 'child1'}])).toBe( + srcObject.body[1].children[0] + ) + }) + + test('falls back to default value on array index at non-array', () => { + const defaultVal = {} + expect(get(srcObject, ['title', 1], defaultVal)).toBe(defaultVal) + }) + + test('falls back to default value on property lookup at non-object', () => { + const defaultVal = {} + expect(get(srcObject, ['title', 'bar'], defaultVal)).toBe(defaultVal) + expect(get(srcObject, ['multiDimensional', 'bar'], defaultVal)).toBe(defaultVal) + expect(get(srcObject, ['nullVal', 'bar'], defaultVal)).toBe(defaultVal) + }) + + test('falls back to default value on key lookup at non-array', () => { + const defaultVal = {} + expect(get(srcObject, ['nullVal', {_key: 'abc'}], defaultVal)).toBe(defaultVal) + }) + + test('can get numbered key from object', () => { + expect(get(srcObject, 'nested.0')).toBe('Zero-Key') + expect(get(srcObject, ['nested', '0'])).toBe('Zero-Key') + }) + }) +})