├── CNAME ├── src ├── lib │ ├── jsonapi-objects │ │ ├── document.test.js │ │ ├── document.js │ │ └── resource-object.js │ ├── url │ │ ├── include.js │ │ ├── include.test.js │ │ ├── filter.js │ │ ├── filter.test.js │ │ ├── filters-juissy.js │ │ ├── url.js │ │ └── url.test.js │ ├── messages.js │ └── schema │ │ ├── normalize.js │ │ ├── schema-parser.js │ │ └── normalize.test.js ├── img │ └── dropdown.svg ├── components │ ├── icon │ │ ├── index.js │ │ ├── icon-container.js │ │ ├── add.js │ │ ├── done.js │ │ ├── close.js │ │ ├── update.js │ │ └── clip.js │ ├── param-ui │ │ ├── param-select.js │ │ ├── index.js │ │ ├── include-ui.js │ │ ├── sort-ui.js │ │ ├── sort-widget.js │ │ ├── filter-ui.js │ │ ├── fieldset-ui.js │ │ ├── filter-widget.js │ │ ├── fieldset-loader.js │ │ ├── include-loader.js │ │ ├── sort-loader.js │ │ └── filter-loader.js │ ├── app-title.js │ ├── schema-ui │ │ ├── relationship.js │ │ ├── schema-relationships.js │ │ ├── schema-attributes.js │ │ ├── attribute.js │ │ ├── index.js │ │ ├── schema-menu.js │ │ ├── field-focus-toggle.js │ │ └── schema-menu-attribute.js │ ├── result-ui │ │ ├── index.js │ │ ├── page-navigation.js │ │ ├── code-mirror.js │ │ ├── display-raw.js │ │ └── summary.js │ ├── resource.js │ ├── location-ui │ │ └── index.js │ ├── link.js │ └── explorer-ui.js ├── hooks │ ├── use-schema.js │ ├── use-schema-loader.js │ └── use-filter.js ├── utils │ ├── request.js │ ├── index.js │ └── utils.test.js ├── index.js ├── css │ ├── _form.scss │ ├── _form--select.scss │ ├── _result.scss │ ├── _nav.scss │ ├── _param.scss │ ├── base │ │ └── _variables.scss │ ├── _header.scss │ └── main.scss ├── app.js └── contexts │ ├── field-focus.js │ └── location.js ├── .prettierrc.json ├── .babelrc ├── webpack.prod.js ├── webpack.dev.js ├── index.html ├── README.md ├── .gitignore ├── package.json ├── webpack.common.js └── LICENSE /CNAME: -------------------------------------------------------------------------------- 1 | explore.jsonapi.dev 2 | -------------------------------------------------------------------------------- /src/lib/jsonapi-objects/document.test.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } 5 | -------------------------------------------------------------------------------- /src/img/dropdown.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-env", "@babel/preset-react"], 3 | "plugins": [ 4 | "@babel/plugin-transform-runtime", 5 | "@babel/plugin-proposal-class-properties", 6 | "react-hot-loader/babel" 7 | ] 8 | } -------------------------------------------------------------------------------- /src/components/icon/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import Add from './add'; 4 | import Close from './close'; 5 | import Done from './done'; 6 | import Update from './update'; 7 | 8 | export { Add, Close, Done, Update }; 9 | -------------------------------------------------------------------------------- /webpack.prod.js: -------------------------------------------------------------------------------- 1 | const merge = require('webpack-merge'); 2 | const common = require('./webpack.common.js'); 3 | // const MinifyPlugin = require("babel-minify-webpack-plugin"); 4 | 5 | module.exports = merge(common, { 6 | mode: 'production' 7 | }); 8 | -------------------------------------------------------------------------------- /src/lib/url/include.js: -------------------------------------------------------------------------------- 1 | const optimizeInclude = (includeArray) => { 2 | return includeArray.filter((a, i) => { 3 | return !includeArray.some((b, j) => { 4 | return i !== j && b.startsWith(a); 5 | }) 6 | }); 7 | }; 8 | 9 | export { optimizeInclude }; 10 | -------------------------------------------------------------------------------- /src/lib/messages.js: -------------------------------------------------------------------------------- 1 | export const textDisabled = "This field is not in the response. This may be because it has been omitted by a sparse fieldset or, if it is a field on an related resource, its relationship has not been included."; 2 | 3 | export const directions = { 4 | ASC: 'Ascending', 5 | DESC: 'Descending' 6 | }; 7 | -------------------------------------------------------------------------------- /src/components/icon/icon-container.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const IconContainer = ({ height = 16, width = 16, children }) => ( 4 | 11 | {children} 12 | 13 | ); 14 | 15 | export default IconContainer; 16 | -------------------------------------------------------------------------------- /src/components/param-ui/param-select.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const ParamSelect = ({ children, selected, handleChange, name='' }) => ( 4 | 12 | ); 13 | 14 | export default ParamSelect; 15 | -------------------------------------------------------------------------------- /src/components/app-title.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const AppTitle = () => ( 4 |

5 | JSON:API{' '} 6 | 7 | Explorer beta 8 | 9 | 10 | 12 |

13 | ); 14 | 15 | export default AppTitle; 16 | -------------------------------------------------------------------------------- /src/components/param-ui/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const ParamUI = ({ name, title, children }) => { 4 | return ( 5 |
6 | {title} 7 |
10 | {children} 11 |
12 |
13 | ); 14 | }; 15 | 16 | export default ParamUI; 17 | -------------------------------------------------------------------------------- /src/hooks/use-schema.js: -------------------------------------------------------------------------------- 1 | import { useContext, useEffect, useState } from 'react'; 2 | import { LocationContext } from '../contexts/location'; 3 | 4 | const useSchema = (forPath = []) => { 5 | const { responseDocument } = useContext(LocationContext); 6 | const [schema, setSchema] = useState(null); 7 | useEffect(() => { 8 | if (responseDocument) responseDocument.getSchema(forPath).then(setSchema); 9 | }, [responseDocument]); 10 | return schema; 11 | }; 12 | 13 | export default useSchema; 14 | -------------------------------------------------------------------------------- /src/utils/request.js: -------------------------------------------------------------------------------- 1 | export const request = url => { 2 | const options = { 3 | method: 'GET', 4 | accept: 'application/vnd.api+json', 5 | }; 6 | 7 | return fetch(url, options).then(res => { 8 | if (res.ok) { 9 | return res.json(); 10 | } else { 11 | return new Promise(async (_, reject) => { 12 | reject( 13 | await res.json().catch(() => { 14 | reject(res.statusText); 15 | }), 16 | ); 17 | }); 18 | } 19 | }); 20 | }; 21 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from 'react-dom'; 3 | import html from '../index.html'; 4 | import css from './css/main.scss'; 5 | 6 | import App from './app'; 7 | 8 | document.addEventListener('DOMContentLoaded', function() { 9 | const domContainer = document.querySelector('#jsonapi-explorer-root'); 10 | const exploredUrl = domContainer.getAttribute('data-explored-url'); 11 | if (domContainer) { 12 | render(, domContainer); 13 | } 14 | }); 15 | -------------------------------------------------------------------------------- /src/hooks/use-schema-loader.js: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | 3 | const useSchemaLoader = initialPath => { 4 | const initialState = [{ forPath: initialPath }]; 5 | const [paths, setPaths] = useState(initialState); 6 | 7 | const load = next => { 8 | // forPath length corresponds to array depth. 9 | const current = paths.slice(0, next.forPath.length); 10 | setPaths([...current, next]); 11 | }; 12 | 13 | const reset = () => { 14 | setPaths(initialState); 15 | }; 16 | 17 | return { paths, load, reset }; 18 | }; 19 | 20 | export default useSchemaLoader; 21 | -------------------------------------------------------------------------------- /src/components/schema-ui/relationship.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | 3 | import SchemaUI from '.'; 4 | 5 | const Relationship = ({ forPath, relationship }) => { 6 | const [showSchema, setShowSchema] = useState(false); 7 | 8 | return ( 9 |
10 |

{relationship.name}

11 | {showSchema ? ( 12 | 13 | ) : ( 14 | 17 | )} 18 |
19 | ); 20 | }; 21 | 22 | export default Relationship; 23 | -------------------------------------------------------------------------------- /src/components/schema-ui/schema-relationships.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import Relationship from './relationship'; 4 | 5 | const SchemaRelationships = ({ forPath, relationships }) => 6 | relationships.length > 0 ? ( 7 |
8 |

Relationships

9 | 16 |
17 | ) : ( 18 |
19 | ); 20 | 21 | export default SchemaRelationships; 22 | -------------------------------------------------------------------------------- /webpack.dev.js: -------------------------------------------------------------------------------- 1 | const merge = require('webpack-merge'); 2 | const common = require('./webpack.common.js'); 3 | const path = require('path'); 4 | 5 | module.exports = merge(common, { 6 | mode: 'development', 7 | devtool: 'source-map', 8 | devServer: { 9 | hot: true, 10 | inline: true, 11 | index: path.join(__dirname, 'index.html') 12 | }, 13 | module: { 14 | rules: [ 15 | { 16 | test: /\.(js|jsx)$/, 17 | exclude: /node_modules/, 18 | use: [ 19 | { 20 | loader: "react-hot-loader/webpack" 21 | } 22 | ] 23 | } 24 | ] 25 | } 26 | }); 27 | 28 | console.log('Env is ', process.env.NODE_ENV); 29 | -------------------------------------------------------------------------------- /src/components/result-ui/index.js: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | 3 | import { LocationContext } from '../../contexts/location'; 4 | import DisplayRaw from './display-raw'; 5 | import Summary from "./summary"; 6 | 7 | const ResultUI = () => { 8 | const { responseDocument } = useContext(LocationContext); 9 | const data = responseDocument ? responseDocument.getData() : []; 10 | 11 | return ( 12 | <> 13 | 18 | 19 | 20 | 21 | ); 22 | }; 23 | 24 | export default ResultUI; 25 | -------------------------------------------------------------------------------- /src/css/_form.scss: -------------------------------------------------------------------------------- 1 | .form-element { 2 | appearance: none; /* Being able to control inner box shadow on iOS. */ 3 | box-sizing: border-box; 4 | padding: calc(0.75rem - var(--size-input-border)) calc(1rem - var(--size-input-border)); 5 | max-width: 100%; 6 | border: var(--size-input-border) solid var(--color-input-border); 7 | border-radius: var(--size-input-border-radius); 8 | background: var(--color-input-bg); 9 | color: var(--color-input-fg); 10 | min-height: 3rem; /* iOS. */ 11 | } 12 | 13 | .form-element--small { 14 | font-size: var(--font-size-label); 15 | padding: calc(0.25rem - var(--size-input-border)) calc(0.5rem - var(--size-input-border)); 16 | line-height: 0.875rem; 17 | min-height: 1.5rem; /* iOS. */ 18 | } 19 | -------------------------------------------------------------------------------- /src/components/schema-ui/schema-attributes.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import Attribute from './attribute'; 4 | 5 | const SchemaAttributes = ({ forPath, attributes, type, includesEnabled }) => 6 | attributes.length > 0 ? ( 7 |
8 |

Attributes

9 |
    10 | {attributes.map((attr, index) => ( 11 |
  • 12 | 18 |
  • 19 | ))} 20 |
21 |
22 | ) : ( 23 |
24 | ); 25 | 26 | export default SchemaAttributes; 27 | -------------------------------------------------------------------------------- /src/hooks/use-filter.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useReducer } from 'react'; 2 | import { expandFilter } from '../lib/url/filter'; 3 | 4 | const load = filter => 5 | Object.entries(filter).map(([id, value]) => ({ 6 | id, 7 | expanded: expandFilter({ [id]: value }), 8 | })); 9 | 10 | const reducer = (state, action) => { 11 | switch (action.type) { 12 | case 'refresh': 13 | return load(action.value); 14 | break; 15 | } 16 | }; 17 | 18 | const useFilter = filter => { 19 | const [filters, dispatch] = useReducer(reducer, []); 20 | 21 | useEffect(() => { 22 | dispatch({ type: 'refresh', value: filter }); 23 | }, [filter]); 24 | 25 | return { 26 | filters, 27 | }; 28 | }; 29 | 30 | export default useFilter; 31 | -------------------------------------------------------------------------------- /src/components/icon/add.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import IconContainer from './icon-container'; 4 | 5 | const Add = ({ color = '#228572' }) => ( 6 | 7 | <> 8 | 14 | 20 | 21 | 22 | ); 23 | 24 | export default Add; 25 | -------------------------------------------------------------------------------- /src/components/icon/done.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import IconContainer from './icon-container'; 4 | 5 | const Done = ({ color = '#228572' }) => ( 6 | 7 | <> 8 | 14 | 20 | 21 | 22 | ); 23 | 24 | export default Done; 25 | -------------------------------------------------------------------------------- /src/components/icon/close.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import IconContainer from './icon-container'; 4 | 5 | const Close = ({ color = '#d72222' }) => ( 6 | 7 | <> 8 | 14 | 20 | 21 | 22 | ); 23 | 24 | export default Close; 25 | -------------------------------------------------------------------------------- /src/lib/url/include.test.js: -------------------------------------------------------------------------------- 1 | import {optimizeInclude} from "./include"; 2 | 3 | 4 | describe.each([ 5 | [['uid'], ['uid']], 6 | [['uid.roles'], ['uid.roles']], 7 | [['uid', 'uid.roles'], ['uid.roles']], 8 | [['uid.roles', 'uid.user_picture'], ['uid.roles', 'uid.user_picture']], 9 | [['uid', 'uid.roles', 'uid.user_picture'], ['uid.roles', 'uid.user_picture']], 10 | [ 11 | ['field_image', 'uid', 'uid.roles', 'uid.user_picture'], 12 | ['field_image', 'uid.roles', 'uid.user_picture'] 13 | ], 14 | [ 15 | ['field_image', 'field_image.uid', 'uid', 'uid.roles', 'uid.user_picture'], 16 | ['field_image.uid', 'uid.roles', 'uid.user_picture'] 17 | ], 18 | ])('optimizeInclude', (input, expected) => { 19 | const msg = `${JSON.stringify(input)} should be optimized into ${JSON.stringify(expected)}`; 20 | console.log(msg); 21 | test(msg, () => { 22 | expect(optimizeInclude(input)).toStrictEqual(expected); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 7 | 8 | JSON:API Explorer 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /src/components/schema-ui/attribute.js: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | import { LocationContext } from '../../contexts/location'; 3 | import { hasSetEntry } from '../../utils'; 4 | 5 | const Attribute = ({ forPath, attribute, type, includeEnabled }) => { 6 | const { fields, toggleField, setFilter } = useContext(LocationContext); 7 | 8 | return ( 9 |
10 | 17 | toggleField(type, attribute.name)} 25 | /> 26 | {attribute.name} 27 |
28 | ); 29 | }; 30 | 31 | export default Attribute; 32 | -------------------------------------------------------------------------------- /src/components/icon/update.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import IconContainer from './icon-container'; 4 | 5 | const Update = ({ color = '#ffd23f' }) => ( 6 | 7 | <> 8 | 14 | 18 | 19 | 20 | ); 21 | 22 | export default Update; 23 | -------------------------------------------------------------------------------- /src/components/param-ui/include-ui.js: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | 3 | import { LocationContext } from '../../contexts/location'; 4 | import IncludeLoader from './include-loader'; 5 | import { Close } from '../icon'; 6 | 7 | import ParamUI from '.'; 8 | 9 | const IncludeUI = () => { 10 | const { include, toggleInclude } = useContext(LocationContext); 11 | 12 | return ( 13 | 14 | 15 | {include.map((path, index) => ( 16 |
20 | {path} 21 | 27 |
28 | ))} 29 |
30 | ); 31 | }; 32 | 33 | export default IncludeUI; 34 | -------------------------------------------------------------------------------- /src/components/result-ui/page-navigation.js: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | import {LocationContext} from "../../contexts/location"; 3 | 4 | const PageNavigation = ({links}) => { 5 | const location = useContext(LocationContext); 6 | const {first, prev, next, last} = links; 7 | 8 | const followLink = link => () => { 9 | location.setUrl(link.href); 10 | }; 11 | 12 | return (
13 | {first && «} 14 | {prev && } 15 | {next && } 16 | {last && »} 17 |
); 18 | }; 19 | 20 | export default PageNavigation; 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # JSON:API Explorer 2 | The JSON:API Explorer is an interactive web application for exploring JSON:API 3 | servers. [Try it!](https://explore.jsonapi.dev) 4 | 5 | **Caveat**: this project and the example server behind it are using non-standard 6 | features including: 7 | - JSON Schema 8 | - An unprofiled filter syntax 9 | - A "home document" at `/jsonapi` 10 | 11 | Over time, we hope these features will be validated, polished and made part of 12 | the official standard. 13 | 14 | ## Contributing 15 | 16 | We're looking for help of all kinds! 17 | 18 | Particularly: 19 | 20 | - We want it to be beautiful 21 | - We want it to be intuitive 22 | - We want it to support alternative filtering strategies via profiles 23 | - We want its use of JSON Schema to be standardized via a profile or the base spec 24 | 25 | But most of all: 26 | 27 | - We want you to love it 28 | 29 | So, please, feel free to make open issues to discuss potential improvements and 30 | then open PRs to make them. 31 | 32 | ## Set 33 | 34 | 1. Run `npm install` 35 | 2. Run `npm run start` to activate the development server. 36 | 37 | -------------------------------------------------------------------------------- /src/app.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | 3 | import { Location } from './contexts/location'; 4 | import ExplorerUI from './components/explorer-ui'; 5 | import LocationBar from './components/location-ui'; 6 | import AppTitle from './components/app-title'; 7 | import FieldFocus from './contexts/field-focus'; 8 | 9 | const App = ({options}) => { 10 | const { exploredUrl } = options; 11 | const initialLandingUrl = exploredUrl || new URL(document.location.href).searchParams.get('location') || ''; 12 | const [landingUrl, setLandingUrl] = useState(initialLandingUrl); 13 | 14 | return ( 15 |
16 | {landingUrl ? ( 17 | 18 | 19 | 20 | 21 | 22 | ) : ( 23 |
24 | 25 | 26 |
27 | )} 28 |
29 | ); 30 | }; 31 | 32 | export default App; 33 | -------------------------------------------------------------------------------- /src/components/icon/clip.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import IconContainer from './icon-container'; 4 | 5 | const Clip = ({ color = '#222330' }) => ( 6 | 7 | <> 8 | 13 | 17 | 18 | 19 | ); 20 | 21 | export default Clip; 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # next.js build output 61 | .next 62 | 63 | # phpstorm config 64 | .idea 65 | 66 | # compiled js 67 | dist/ -------------------------------------------------------------------------------- /src/components/schema-ui/index.js: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | 3 | import SchemaAttributes from './schema-attributes'; 4 | import SchemaRelationships from './schema-relationships'; 5 | 6 | import { checkIncludesPath } from '../../utils'; 7 | import { LocationContext } from '../../contexts/location'; 8 | import useSchema from '../../hooks/use-schema'; 9 | 10 | const SchemaUI = ({ forPath = [] }) => { 11 | const schema = useSchema(forPath); 12 | const { type = '', attributes = [], relationships = [] } = schema || {}; 13 | const { include, toggleInclude } = useContext(LocationContext); 14 | 15 | const includesEnabled = checkIncludesPath(include, forPath); 16 | const includePathString = forPath.join('.'); 17 | 18 | return ( 19 |
20 | {includePathString && ( 21 |
22 | toggleInclude(includePathString)} 26 | /> 27 | {includePathString} 28 |
29 | )} 30 | 36 | 37 |
38 | ); 39 | }; 40 | 41 | export default SchemaUI; 42 | -------------------------------------------------------------------------------- /src/components/param-ui/sort-ui.js: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | 3 | import ParamUI from '.'; 4 | import SortLoader from './sort-loader'; 5 | import { Close } from '../icon'; 6 | import { directions } from '../../lib/messages'; 7 | 8 | import { LocationContext } from '../../contexts/location'; 9 | 10 | const SortActive = ({ name, direction }) => { 11 | const { sort, setSort } = useContext(LocationContext); 12 | 13 | const removeSort = () => { 14 | const current = [...sort.filter(param => param.path !== name)]; 15 | setSort(current); 16 | }; 17 | 18 | return ( 19 |
20 | 21 | {name} 22 | {direction} 23 | 24 | 27 |
28 | ); 29 | }; 30 | 31 | const SortUI = () => { 32 | const { sort } = useContext(LocationContext); 33 | 34 | return ( 35 | 36 | 37 | {sort.map((param, index) => ( 38 | 43 | ))} 44 | 45 | ); 46 | }; 47 | 48 | export default SortUI; 49 | -------------------------------------------------------------------------------- /src/components/result-ui/code-mirror.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | 3 | import CodeMirrorEditor from 'codemirror/lib/codemirror'; 4 | import 'codemirror/mode/javascript/javascript'; 5 | import 'codemirror/addon/display/autorefresh'; 6 | import 'codemirror/addon/fold/brace-fold'; 7 | import 'codemirror/addon/fold/foldcode'; 8 | import 'codemirror/addon/fold/foldgutter'; 9 | import 'codemirror/addon/scroll/simplescrollbars'; 10 | 11 | const CodeMirror = ({ code, options = {}}) => { 12 | const [codeElem, setCodeElem] = useState(null); 13 | const [codeMirror, setCodeMirror] = useState(null); 14 | 15 | useEffect(() => { 16 | if (codeElem) { 17 | if (!codeMirror) { 18 | const codeMirror = CodeMirrorEditor(codeElem, Object.assign({ 19 | value: code, 20 | mode: 'application/ld+json', 21 | readOnly: true, 22 | lineWrapping: false, 23 | lineNumbers: true, 24 | foldGutter: true, 25 | autoRefresh: true, 26 | gutters: ['CodeMirror-linenumbers', 'CodeMirror-foldgutter'], 27 | scrollbarStyle: 'simple', 28 | }, options)); 29 | setCodeMirror(codeMirror); 30 | } else { 31 | codeMirror.setValue(code); 32 | } 33 | } 34 | }, [codeElem, code]); 35 | 36 | return
; 37 | }; 38 | 39 | export default CodeMirror; 40 | -------------------------------------------------------------------------------- /src/components/param-ui/sort-widget.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useContext } from 'react'; 2 | 3 | import { LocationContext } from '../../contexts/location'; 4 | import ParamSelect from './param-select'; 5 | import { directions } from '../../lib/messages'; 6 | 7 | const SortWidget = ({ param }) => { 8 | const { path } = param; 9 | const [direction, setDirection] = useState(param.direction); 10 | const { sort, setSort } = useContext(LocationContext); 11 | 12 | const handleChange = e => { 13 | setDirection(e.target.value); 14 | }; 15 | 16 | const handleApply = () => { 17 | const index = sort.findIndex(param => param.path === path); 18 | 19 | setSort([ 20 | ...sort.slice(0, index), 21 | { path, direction }, 22 | ...sort.slice(index + 1), 23 | ]); 24 | }; 25 | 26 | useEffect(() => { 27 | handleApply(); 28 | }, [path, direction]); 29 | 30 | console.log({ directions }); 31 | Object.entries(directions).map(([key, value]) => console.log({ key, value })); 32 | 33 | return ( 34 |
35 | {path} 36 | 41 | {Object.entries(directions).map(([key, value]) => ( 42 | 45 | ))} 46 | 47 |
48 | ); 49 | }; 50 | 51 | export default SortWidget; 52 | -------------------------------------------------------------------------------- /src/utils/index.js: -------------------------------------------------------------------------------- 1 | export function copyObject(obj) { 2 | return JSON.parse(JSON.stringify(obj)); 3 | } 4 | 5 | export function extract(obj, path, dflt) { 6 | const $n = {}; 7 | return path.split('.').reduce((obj, key) => (obj || $n)[key], obj) || dflt; 8 | } 9 | 10 | export function removeEmpty(value) { 11 | let obj = { ...value }; 12 | Object.entries(obj).forEach(([key, val]) => { 13 | if (val && !Set.prototype.isPrototypeOf(val) && !Array.isArray(val)) { 14 | if (typeof val === 'object') { 15 | obj[key] = removeEmpty(val); 16 | } 17 | } else if (val === null || val === '') { 18 | delete obj[key]; 19 | } 20 | }); 21 | 22 | return obj; 23 | } 24 | 25 | export function isEmpty(value) { 26 | if (Set.prototype.isPrototypeOf(value)) { 27 | return !value.size; 28 | } else if (Array.isArray(value)) { 29 | return !value.length; 30 | } else if (value === null) { 31 | return true; 32 | } else if (typeof value === 'object') { 33 | return !Object.keys(value).length; 34 | } else { 35 | return !value; 36 | } 37 | } 38 | 39 | export function hasSetEntry(set, entry) { 40 | return set.has(entry); 41 | } 42 | 43 | export function toggleSetEntry(set, entry) { 44 | if (set.has(entry)) { 45 | set.delete(entry); 46 | } else { 47 | set.add(entry); 48 | } 49 | 50 | return set; 51 | } 52 | 53 | export function checkIncludesPath(include, includePath) { 54 | return includePath.length > 0 55 | ? Array.from(new Set(include)).some(include => include.startsWith(includePath.join('.'))) 56 | : true; 57 | } 58 | -------------------------------------------------------------------------------- /src/components/resource.js: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | 3 | import { LinkElement } from './link'; 4 | import SchemaUI from './schema-ui'; 5 | import FilterUI from './param-ui/filter-ui'; 6 | import IncludeUI from './param-ui/include-ui'; 7 | import FieldsetUI from './param-ui/fieldset-ui'; 8 | import SortUI from './param-ui/sort-ui'; 9 | import ResultUI from './result-ui'; 10 | import { LocationContext } from '../contexts/location'; 11 | 12 | const Resource = () => { 13 | const { responseDocument } = useContext(LocationContext); 14 | const resourceLinks = responseDocument 15 | ? responseDocument.getOutgoingLinks() 16 | : {}; 17 | 18 | return ( 19 |
20 |
21 |
22 | 23 |
24 |
25 | 26 |
27 |
28 | 29 |
30 |
31 | 32 |
33 |
34 |
35 |
    36 | {Object.keys(resourceLinks).map((key, index) => ( 37 |
  • 38 | 39 |
  • 40 | ))} 41 |
42 | 43 |
44 |
45 | ); 46 | }; 47 | 48 | export default Resource; 49 | -------------------------------------------------------------------------------- /src/lib/schema/normalize.js: -------------------------------------------------------------------------------- 1 | import { extract } from '../../utils'; 2 | 3 | function getDefinitions(schema, definition, process = null) { 4 | const extracted = extract( 5 | schema, 6 | (schema && schema.type === 'array' ? 'items.' : '') + 7 | `definitions.${definition}.properties`, 8 | ); 9 | return extracted ? mapDefinitions(extracted, process) : []; 10 | } 11 | 12 | export function mapDefinitions(definitions, process = null) { 13 | return Object.keys(definitions).map(name => { 14 | const value = definitions[name]; 15 | 16 | return { name, value: process ? process(value) : value }; 17 | }); 18 | } 19 | 20 | export function getRelationshipSchema(relationship) { 21 | const relatedLink = extract(relationship, 'links', []) 22 | .find(ldo => ldo.rel === 'related'); 23 | return extract(relatedLink, 'targetSchema'); 24 | } 25 | 26 | export const getAttributes = schema => getDefinitions(schema, 'attributes'); 27 | 28 | export const getRelationships = schema => getDefinitions(schema, 'relationships', getRelationshipSchema); 29 | 30 | export const processAttributeValue = value => { 31 | 32 | let { type, title, description, properties, items, ...values } = value; 33 | 34 | if (type === 'array') { 35 | type = `[${items.type}]`; 36 | if (Array.isArray(items)) { 37 | properties = items; 38 | } else if (items.type === 'object') { 39 | properties = items.properties; 40 | } else { 41 | const {type: _, title: __, ...itemValues} = items; 42 | values = itemValues; 43 | } 44 | } 45 | 46 | return { type, title, description, properties, items, values }; 47 | }; 48 | -------------------------------------------------------------------------------- /src/css/_form--select.scss: -------------------------------------------------------------------------------- 1 | /** 2 | * @file 3 | * Select input elements. 4 | */ 5 | 6 | .form-element--type-select { 7 | background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 14 9'%3E%3Cpath fill='none' stroke-width='1.5' d='M1 1L7 7L13 1' stroke='%23545560'/%3E%3C/svg%3E%0A"); 8 | background-position: 100% 50%; 9 | background-size: 2.75rem 0.5625rem; /* w: 14px + (2 * 15px), h: 9px */ 10 | background-repeat: no-repeat; 11 | padding-right: calc(2rem - var(--size-input-border)); 12 | } 13 | [dir="rtl"] .form-element--type-select { 14 | padding-right: calc(1rem - var(--size-input-border)); 15 | padding-left: calc(2rem - var(--size-input-border)); 16 | background-position: 0 50%; 17 | } 18 | .form-element--type-select.form-element--small { 19 | background-size: 1.75rem 0.5625rem; /* w: 14px + (2 * 7px), h: 9px */ 20 | padding-right: calc(1.5rem - var(--size-input-border)); 21 | } 22 | [dir="rtl"] .form-element--type-select.form-element--small { 23 | padding-right: calc(0.5rem - var(--size-input-border)); 24 | padding-left: calc(1.5rem - var(--size-input-border)); 25 | } 26 | 27 | .form-element--type-select::-ms-expand { 28 | display: none; 29 | } 30 | 31 | /** 32 | * Select states. 33 | */ 34 | .form-element--type-select:focus { 35 | background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 14 9'%3E%3Cpath fill='none' stroke-width='1.5' d='M1 1L7 7L13 1' stroke='%23004adc'/%3E%3C/svg%3E%0A"); 36 | } 37 | .form-element--type-select[disabled] { 38 | background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 14 9'%3E%3Cpath fill='none' stroke-width='1.5' d='M1 1L7 7L13 1' stroke='%238e929c'/%3E%3C/svg%3E%0A"); 39 | } 40 | -------------------------------------------------------------------------------- /src/components/param-ui/filter-ui.js: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | 3 | import useFilter from '../../hooks/use-filter'; 4 | import FilterLoader from './filter-loader'; 5 | import ParamUI from '.'; 6 | import { Close } from '../icon'; 7 | 8 | import { LocationContext } from '../../contexts/location'; 9 | 10 | const FilterUI = () => { 11 | const { filter, setFilter } = useContext(LocationContext); 12 | const { filters } = useFilter(filter); 13 | 14 | return ( 15 | 16 | 17 | {filters.map( 18 | (fObj, index) => 19 | fObj.expanded[fObj.id].condition && ( 20 |
24 | 25 | {fObj.expanded[fObj.id].condition.path}{' '} 26 | {fObj.expanded[fObj.id].condition.operator}{' '} 27 | {fObj.expanded[fObj.id].condition.value ? ( 28 | fObj.expanded[fObj.id].condition.value 29 | ) : ( 30 | 31 | ... 32 | 33 | )} 34 | 35 | 42 |
43 | ), 44 | )} 45 |
46 | ); 47 | }; 48 | 49 | export default FilterUI; 50 | -------------------------------------------------------------------------------- /src/components/param-ui/fieldset-ui.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useContext } from 'react'; 2 | 3 | import { LocationContext } from '../../contexts/location'; 4 | import FieldsetLoader from './fieldset-loader'; 5 | import ParamUI from '.'; 6 | import { Close } from '../icon'; 7 | 8 | const FieldsetUI = () => { 9 | const { fields, toggleField, clearFieldSet } = useContext(LocationContext); 10 | 11 | return ( 12 | 13 | 14 |
    15 | {Object.keys(fields).map((type, index) => ( 16 |
  • 17 |
    18 | 19 | {type} 20 | 26 | 27 |
    28 |
      29 | {Array.from(fields[type]).map(setEntry => ( 30 |
    • 31 |
      32 | {setEntry} 33 | 39 |
      40 |
    • 41 | ))} 42 |
    43 |
  • 44 | ))} 45 |
46 |
47 | ); 48 | }; 49 | 50 | export default FieldsetUI; 51 | -------------------------------------------------------------------------------- /src/components/param-ui/filter-widget.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useContext } from 'react'; 2 | 3 | import { LocationContext } from '../../contexts/location'; 4 | import { Conditions } from '../../lib/url/filters-juissy'; 5 | import { Close, Update } from '../icon'; 6 | import ParamSelect from './param-select'; 7 | 8 | const FilterWidget = ({ filter }) => { 9 | const { id, expanded } = filter; 10 | const { condition } = expanded[id]; 11 | 12 | const [value, setValue] = useState(condition.value); 13 | const [operator, setOperator] = useState(condition.operator); 14 | const { setFilter } = useContext(LocationContext); 15 | 16 | const handleChange = e => { 17 | switch (e.target.name) { 18 | case 'value': 19 | setValue(e.target.value); 20 | break; 21 | case 'operator': 22 | setOperator(e.target.value); 23 | break; 24 | } 25 | }; 26 | 27 | const handleApply = () => { 28 | setFilter(id, 'update', { 29 | ...expanded, 30 | [id]: { condition: { ...condition, operator, value } }, 31 | }); 32 | }; 33 | 34 | const handleRemove = () => { 35 | setFilter(id, 'delete'); 36 | }; 37 | 38 | useEffect(() => { 39 | handleApply(); 40 | }, [value, operator]); 41 | 42 | return ( 43 |
44 | {id} 45 | 46 | {[...Conditions.unaryOperators].map((unary, index) => ( 47 | 50 | ))} 51 | 52 | 53 |
54 | ); 55 | }; 56 | 57 | export default FilterWidget; 58 | -------------------------------------------------------------------------------- /src/components/schema-ui/schema-menu.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import useSchema from '../../hooks/use-schema'; 4 | import SchemaMenuAttribute from './schema-menu-attribute'; 5 | import FieldFocusToggle from "./field-focus-toggle"; 6 | 7 | const SchemaMenu = ({ title, forPath, load, back, next }) => { 8 | const schema = useSchema(forPath); 9 | const { type = '', attributes = [], relationships = [] } = schema || {}; 10 | 11 | const loadNext = name => { 12 | load({ title: name, forPath: [...forPath, name] }); 13 | next(); 14 | }; 15 | 16 | const handleClick = name => e => { 17 | if (e.target.classList.contains('link__toggle')) { 18 | return; 19 | } 20 | loadNext(name); 21 | }; 22 | 23 | return ( 24 |
25 |
26 | 29 | {title} 30 |
31 |
    32 | {attributes.map((attribute, index) => ( 33 |
  • 34 | 35 |
  • 36 | ))} 37 | {relationships.map((relationship, index) => ( 38 |
  • 39 | 48 |
  • 49 | ))} 50 |
51 |
52 | ); 53 | }; 54 | 55 | export default SchemaMenu; 56 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jsonapi_explorer", 3 | "version": "0.1.0", 4 | "description": "Interactive JSON:API explorer", 5 | "private": true, 6 | "scripts": { 7 | "test": "jest", 8 | "watch": "webpack --watch", 9 | "build": "NODE_ENV=production webpack --config webpack.prod.js", 10 | "start": "NODE_ENV=development webpack-dev-server --open --config webpack.dev.js", 11 | "predeploy": "npm run build && cp CNAME ./dist", 12 | "deploy": "gh-pages -d dist" 13 | }, 14 | "author": "zrpnr", 15 | "homepage": "https://zrpnr.github.io/jsonapi_explorer", 16 | "license": "GPL-2.0", 17 | "dependencies": { 18 | "@babel/runtime": "^7.4.5", 19 | "codemirror": "^5.47.0", 20 | "dotenv-webpack": "^1.7.0", 21 | "gh-pages": "^2.0.1", 22 | "json-schema-ref-parser": "^6.1.0", 23 | "react": "^16.8.6", 24 | "react-clipboard.js": "^2.0.16", 25 | "react-dom": "^16.8.6" 26 | }, 27 | "devDependencies": { 28 | "@babel/core": "^7.4.4", 29 | "@babel/plugin-proposal-class-properties": "^7.4.4", 30 | "@babel/plugin-transform-runtime": "^7.4.4", 31 | "@babel/preset-env": "^7.4.5", 32 | "@babel/preset-react": "^7.0.0", 33 | "autoprefixer": "^9.6.0", 34 | "babel-loader": "^8.0.6", 35 | "babel-minify-webpack-plugin": "^0.3.1", 36 | "css-loader": "^3.0.0", 37 | "html-loader": "^0.5.5", 38 | "jest": "^24.8.0", 39 | "mini-css-extract-plugin": "^0.7.0", 40 | "node-sass": "^4.12.0", 41 | "optimize-css-assets-webpack-plugin": "^5.0.3", 42 | "path": "^0.12.7", 43 | "postcss-custom-properties": "^8.0.10", 44 | "postcss-loader": "^3.0.0", 45 | "prettier": "1.17.1", 46 | "react-hot-loader": "^4.8.7", 47 | "sass-loader": "^7.1.0", 48 | "svg-url-loader": "^2.3.3", 49 | "webpack": "^4.32.0", 50 | "webpack-cli": "^3.3.2", 51 | "webpack-dev-server": "^3.4.1", 52 | "webpack-merge": "^4.2.1" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/components/location-ui/index.js: -------------------------------------------------------------------------------- 1 | import React, { useContext, useEffect, useState } from 'react'; 2 | import { LocationContext } from '../../contexts/location'; 3 | import Clip from '../../components/icon/clip'; 4 | import Clipboard from 'react-clipboard.js'; 5 | 6 | const LocationBar = ({ onNewUrl, value = '', exampleURL = false }) => { 7 | const { readOnly } = useContext(LocationContext); 8 | const [inputUrl, setInputUrl] = useState(value); 9 | 10 | useEffect(() => setInputUrl(value), [value]); 11 | 12 | const setSampleLocation = e => { 13 | e.preventDefault(); 14 | onNewUrl(exampleURL); 15 | }; 16 | 17 | const handleSubmit = e => { 18 | e.preventDefault(); 19 | inputUrl.length && onNewUrl(inputUrl); 20 | }; 21 | 22 | return ( 23 |
24 |
25 | setInputUrl(readOnly ? inputUrl : e.target.value)} 31 | /> 32 | {inputUrl && ( 33 | 38 | 39 | 40 | )} 41 |
42 |
47 | or try an 48 | 55 |
56 |
57 | ); 58 | }; 59 | 60 | export default LocationBar; 61 | -------------------------------------------------------------------------------- /webpack.common.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const Dotenv = require('dotenv-webpack'); 3 | const MiniCssExtractPlugin = require('mini-css-extract-plugin'); 4 | const postcssCustomProperties = require('postcss-custom-properties'); 5 | const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin'); 6 | const autoprefixer = require('autoprefixer'); 7 | 8 | module.exports = { 9 | context: path.resolve(__dirname, "src"), 10 | entry: "./index.js", 11 | optimization: { 12 | minimizer: [new OptimizeCSSAssetsPlugin({})] 13 | }, 14 | output: { 15 | filename: "main.js", 16 | path: path.join(__dirname, 'dist') 17 | }, 18 | module: { 19 | rules: [ 20 | { 21 | test: /\.(js|jsx)$/, 22 | exclude: /node_modules/, 23 | use: [ 24 | { 25 | loader: "babel-loader" 26 | } 27 | ] 28 | }, 29 | { 30 | test: /\.scss$/, 31 | use: [ 32 | { 33 | loader: MiniCssExtractPlugin.loader, 34 | options: { 35 | hmr: process.env.NODE_ENV === 'development', 36 | }, 37 | }, 38 | 'css-loader', 39 | { 40 | loader: 'postcss-loader', 41 | options: { 42 | ident: 'postcss', 43 | plugins: () => [ 44 | postcssCustomProperties(), 45 | autoprefixer 46 | ] 47 | } 48 | }, 49 | 'sass-loader' 50 | ], 51 | }, 52 | { 53 | test: /\.svg/, 54 | use: { 55 | loader: 'svg-url-loader', 56 | options: {} 57 | } 58 | }, 59 | { 60 | test: /\.(html)$/, 61 | use: { 62 | loader: 'file-loader', 63 | options: { 64 | name: '[name].[ext]' 65 | } 66 | } 67 | } 68 | ], 69 | }, 70 | plugins: [ 71 | new Dotenv(), 72 | new MiniCssExtractPlugin({ 73 | filename: '[name].css', 74 | chunkFilename: '[id].css', 75 | }), 76 | ] 77 | }; 78 | -------------------------------------------------------------------------------- /src/components/schema-ui/field-focus-toggle.js: -------------------------------------------------------------------------------- 1 | import React, { useContext, useState } from 'react'; 2 | import {FieldFocusContext} from "../../contexts/field-focus"; 3 | import { textDisabled } from '../../lib/messages' 4 | 5 | const getFocusString = (focus) => { 6 | return [...(focus.path||[]), ...(focus.field ? [focus.field] : [])].join('.') 7 | }; 8 | 9 | const FieldFocusToggle = ({path}) => { 10 | const { focus, changeFocus, availableFocusPaths } = useContext(FieldFocusContext); 11 | const [ pinned, setPinned ] = useState(false); 12 | const [ unpinned, setUnpinned ] = useState(false); 13 | 14 | const onMouseEnter = () => { 15 | changeFocus('focusOn', { 16 | path: path.slice(0, -1), 17 | field: path[path.length - 1], 18 | }); 19 | }; 20 | 21 | const onMouseLeave = () => { 22 | if (unpinned) { 23 | changeFocus('focusOff'); 24 | } else if (pinned) { 25 | changeFocus('focusOn', { 26 | path: path.slice(0, -1), 27 | field: path[path.length - 1], 28 | }); 29 | } 30 | else { 31 | changeFocus('toLast'); 32 | } 33 | setPinned(false); 34 | setUnpinned(false); 35 | }; 36 | 37 | if (availableFocusPaths.includes([...path].join('.'))) { 38 | const focusString = getFocusString(focus); 39 | const active = focusString === path.join('.'); 40 | const classes = active 41 | ? 'link__toggle link__toggle--active' 42 | : 'link__toggle'; 43 | return ( 44 | { 47 | (getFocusString(focus.last) === path.join('.')) || pinned ? setUnpinned(!unpinned) : setPinned(!pinned); 48 | }} 49 | onMouseEnter={onMouseEnter} 50 | onMouseLeave={onMouseLeave} 51 | >{(active && !unpinned) || (!active && pinned) ? <>⊕ : <>⊙} 52 | ); 53 | } else { 54 | return ( 55 | ); 59 | } 60 | }; 61 | 62 | export default FieldFocusToggle; 63 | -------------------------------------------------------------------------------- /src/components/result-ui/display-raw.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | 3 | import CodeMirrorElem from './code-mirror'; 4 | import PageNavigation from "./page-navigation"; 5 | 6 | const DisplayRaw = ({ title, name, responseDocument, children }) => { 7 | const [activeTab, setActiveTab] = useState(0); 8 | const isCollection = responseDocument && responseDocument.isCollectionDocument(); 9 | const resultCount = isCollection ? responseDocument.getData().length : 1; 10 | const links = responseDocument ? responseDocument.getPaginationLinks() : false; 11 | 12 | const TabMenu = ({ title, id }) => ( 13 |
  • setActiveTab(id)} 16 | > 17 | {title} 18 |
  • 19 | ); 20 | 21 | return ( 22 |
    23 | {responseDocument && ( 24 | <> 25 |
      26 | 27 | 28 |
    29 |
    30 |
    31 | {isCollection ? resultCount ? `${resultCount} results` : 'No results' : 'Result'} 32 | {links && } 33 |
    34 | {resultCount ? children : <>} 35 |
    36 |
    37 |
    38 | {isCollection ? resultCount ? `${resultCount} results` : 'No results' : 'Result'} 39 | {links && } 40 |
    41 | 44 |
    45 | 46 | )} 47 |
    48 | ); 49 | }; 50 | 51 | export default DisplayRaw; 52 | -------------------------------------------------------------------------------- /src/css/_result.scss: -------------------------------------------------------------------------------- 1 | .results__row { 2 | margin: var(--space-s) 0; 3 | padding: 0 0 var(--space-s) 0; 4 | border-radius: var(--radius-s); 5 | overflow: auto; 6 | } 7 | 8 | .results__row__header { 9 | padding: .5rem; 10 | background: var(--color-neutral); 11 | color: var(--color-davysgrey); 12 | display: flex; 13 | justify-content: space-between; 14 | } 15 | 16 | .results_rows { 17 | padding: var(--space-s); 18 | } 19 | 20 | .results__row { 21 | &--hidden { 22 | display: none; 23 | } 24 | } 25 | 26 | .results__field { 27 | background-color: var(--color-white); 28 | transition: .75s max-height linear, .675s opacity ease-in; 29 | overflow: hidden; 30 | max-height: 300px; 31 | opacity: 1; 32 | display: flex; 33 | flex-direction: column; 34 | 35 | &--hidden { 36 | opacity: 0; 37 | max-height: 0; 38 | } 39 | 40 | &--hidden-above { 41 | justify-content: flex-start; 42 | } 43 | 44 | &--hidden-below { 45 | justify-content: flex-end; 46 | } 47 | 48 | & > div { 49 | margin: 1px 0; 50 | display: grid; 51 | grid-template-columns: 20vw 1fr 52 | } 53 | } 54 | 55 | .results__field__path_container { 56 | padding: .5rem; 57 | background: var(--color-bgblue-light); 58 | font-family: monospace; 59 | 60 | span { 61 | overflow: hidden; 62 | text-overflow: ellipsis; 63 | display: block; 64 | } 65 | } 66 | 67 | .results__field__focus--down { 68 | color: var(--color-absolutezero-hover); 69 | cursor: pointer; 70 | } 71 | 72 | .results__field__value { 73 | font-family: $ff-mono; 74 | padding: .5rem; 75 | max-height: 300px; 76 | overflow-y: auto; 77 | flex: 1; 78 | 79 | &--empty { 80 | color: var(--color-lightgray); 81 | } 82 | } 83 | 84 | .results__links { 85 | margin: 0; 86 | padding: var(--space-s); 87 | display: flex; 88 | justify-content: flex-end; 89 | 90 | > li { 91 | padding: 0; 92 | } 93 | } 94 | 95 | .results__actions { 96 | ul { 97 | display: flex; 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/components/link.js: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | 3 | import { extract } from '../utils'; 4 | import { LocationContext } from '../contexts/location'; 5 | 6 | class Link { 7 | constructor({ href, rel, title }, text = '') { 8 | this.href = href; 9 | this.rel = rel; 10 | this.title = title; 11 | this.text = text; 12 | } 13 | 14 | static parseLinks(links, schema = null) { 15 | return Object.keys(links).reduce((parsed, key) => { 16 | const current = links[key]; 17 | const href = current.href; 18 | const rel = extract(current, 'meta.linkParams.rel', key); 19 | const title = schema 20 | ? extract(extract(schema, 'links', []).find(linkSchema => linkSchema.rel === rel), 'title', null) 21 | : null; 22 | const params = { 23 | href, 24 | rel, 25 | title: extract(current, 'meta.linkParams.title', title || key), 26 | }; 27 | const link = new Link(params, key); 28 | return Object.assign(parsed, {[key]: link}) 29 | }, {}); 30 | } 31 | } 32 | 33 | const LinkElement = ({ link }) => { 34 | const location = useContext(LocationContext); 35 | return ( 36 | 42 | ); 43 | }; 44 | 45 | const MenuLinkElement = ({ link, next }) => { 46 | const location = useContext(LocationContext); 47 | const handleClick = () => { 48 | location.setUrl(link.href); 49 | next(); 50 | }; 51 | 52 | return ( 53 | 70 | ); 71 | }; 72 | 73 | export { Link, LinkElement, MenuLinkElement }; 74 | -------------------------------------------------------------------------------- /src/components/schema-ui/schema-menu-attribute.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import {isEmpty} from '../../utils'; 4 | import { processAttributeValue } from '../../lib/schema/normalize'; 5 | import FieldFocusToggle from "./field-focus-toggle"; 6 | 7 | const SchemaMenuAttributeName = ({ name }) => ( 8 |
    9 | {name} 10 |
    11 | ); 12 | 13 | const SchemaMenuAttributeValue = ({ name, value, forPath, level }) => { 14 | 15 | const { type, title, description, properties, values } = processAttributeValue(value); 16 | 17 | return ( 18 |
    19 |
    20 | 21 | {title} 22 | {level === 0 && } 23 | 24 | {name} 25 | {type||'undefined'} 26 | {description &&

    {description}

    } 27 |
    28 | { (!isEmpty(properties) || !isEmpty(values)) && 29 |
      30 | {properties 31 | ? Object.entries(properties).map(([key, value], index) => ( 32 |
    • 33 | 34 |
    • 35 | )) 36 | : Object.entries(values).map(([key, value], index) => ( 37 |
    • 38 | {key} 39 | 40 | : {JSON.stringify(value)} 41 | 42 |
    • 43 | ))} 44 |
    } 45 |
    46 | ); 47 | }; 48 | 49 | const SchemaMenuAttribute = ({ attribute, forPath, level = 0 }) => { 50 | const { name, value } = attribute; 51 | 52 | return value ? ( 53 | 54 | ) : ( 55 | 56 | ); 57 | }; 58 | 59 | export default SchemaMenuAttribute; 60 | -------------------------------------------------------------------------------- /src/lib/jsonapi-objects/document.js: -------------------------------------------------------------------------------- 1 | import { Link } from '../../components/link'; 2 | import { extract } from "../../utils"; 3 | import ResourceObject from './resource-object'; 4 | import SchemaParser from '../schema/schema-parser'; 5 | 6 | const schemaParser = new SchemaParser(); 7 | 8 | export default class Document { 9 | constructor({ raw }) { 10 | this.raw = raw; 11 | this.data = false; 12 | this.included = false; 13 | this.schema = undefined; 14 | } 15 | 16 | static parse(raw) { 17 | return new Document({ raw }); 18 | } 19 | 20 | getData() { 21 | if (this.data === false) { 22 | this.data = [this.raw.data] 23 | .flat() 24 | .map(ResourceObject.parse) 25 | .map(obj => obj.withParentDocument(this)); 26 | } 27 | return this.data; 28 | } 29 | 30 | getIncluded() { 31 | if (this.included === false) { 32 | this.included = this.hasIncluded() 33 | ? this.raw.included 34 | .map(ResourceObject.parse) 35 | .map(obj => obj.withParentDocument(this)) 36 | : []; 37 | } 38 | return this.included; 39 | } 40 | 41 | getRelated(fieldName) { 42 | return this.getData().flatMap(resourceObject => resourceObject.getRelated(current)); 43 | } 44 | 45 | getResourceObjects() { 46 | return !this.isEmptyDocument() 47 | ? [this.getData()].flat().concat(this.getIncluded()) 48 | : []; 49 | } 50 | 51 | getSchema(forPath = []) { 52 | if (this.schema && forPath.length === 0) { 53 | return Promise.resolve(this.schema); 54 | } 55 | return schemaParser.parse(this, forPath).then(schema => { 56 | if (forPath.length === 0) { 57 | this.schema = schema; 58 | } 59 | return schema; 60 | }); 61 | } 62 | 63 | getLinks() { 64 | return Link.parseLinks(this.raw.links || {}, this.schema || null); 65 | } 66 | 67 | getPaginationLinks() { 68 | const {first, prev, next, last} = this.getLinks(); 69 | return {first, prev, next, last}; 70 | } 71 | 72 | getOutgoingLinks() { 73 | const { self, describedby, first, prev, next, last, ...outgoing } = this.getLinks(); 74 | return outgoing; 75 | } 76 | 77 | hasIncluded() { 78 | return Array.isArray(this.raw.included); 79 | } 80 | 81 | isEmptyDocument() { 82 | return this.isErrorDocument() || ![this.raw.data].flat().length; 83 | } 84 | 85 | isIndividualDocument() { 86 | return !this.isErrorDocument() && !this.isCollectionDocument(); 87 | } 88 | 89 | isCollectionDocument() { 90 | return !this.isErrorDocument() && Array.isArray(this.raw.data); 91 | } 92 | 93 | isErrorDocument() { 94 | return this.raw.errors; 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/css/_nav.scss: -------------------------------------------------------------------------------- 1 | 2 | nav { 3 | background: var(--color-neutral); 4 | overflow: hidden; 5 | display: flex; 6 | } 7 | 8 | .menu__container { 9 | flex: 1; 10 | display: flex; 11 | flex-direction: column; 12 | transform: translateX(calc(-1 * var(--nav-offset) * var(--width-aside))); 13 | transition: .25s transform ease-in-out; 14 | } 15 | 16 | .menu__nav { 17 | flex: 1; 18 | overflow: scroll; 19 | width: var(--width-aside); 20 | 21 | & > li { 22 | margin: var(--space-s) 0; 23 | padding: 0; 24 | } 25 | } 26 | 27 | .menu__nav_item { 28 | 29 | & > .menu__attribute { 30 | border-top-left-radius: var(--radius-s); 31 | border-bottom-left-radius: var(--radius-s); 32 | 33 | & > .menu__attribute_header, 34 | & > .menu__attribute_properties { 35 | padding: var(--space-s); 36 | } 37 | & > .menu__attribute_header { 38 | background: var(--color-white); 39 | } 40 | & > .menu__attribute_properties { 41 | background: var(--color-bgblue-light); 42 | } 43 | } 44 | 45 | font-size: .8rem; 46 | } 47 | 48 | .menu__location { 49 | font-weight: 700; 50 | box-shadow: 10px 0px 20px rgba(0, 0, 0, 0.25); 51 | z-index: 2; 52 | background: var(--color-white); 53 | } 54 | 55 | .menu__location_title { 56 | padding: var(--space-s); 57 | display: block; 58 | } 59 | 60 | .menu__attribute_header { 61 | border-top-left-radius: var(--radius-s); 62 | } 63 | 64 | .menu__attribute_properties { 65 | border-bottom-left-radius: var(--radius-s); 66 | } 67 | 68 | .menu__container button { 69 | font-size: .8rem; 70 | line-height: 1.2em; 71 | 72 | &:focus, &:hover { 73 | background-color: var(--color-bgblue-hover); 74 | outline: none; 75 | } 76 | 77 | &.active { 78 | background-color: var(--color-bgblue-active); 79 | } 80 | } 81 | 82 | .menu__attribute_properties { 83 | 84 | .menu__attribute { 85 | padding: 0; 86 | } 87 | } 88 | 89 | .menu__container button { 90 | border: none; 91 | text-align: left; 92 | width: 100%; 93 | } 94 | 95 | .link--prev, 96 | .link--next { 97 | padding-top: var(--space-s); 98 | padding-bottom: var(--space-s); 99 | display: flex; 100 | justify-content: space-between; 101 | align-items: center; 102 | } 103 | 104 | .link--prev:before, 105 | .link--next:after { 106 | content: ''; 107 | width: 24px; 108 | height: 24px; 109 | top: var(--space-s); 110 | background: url(../img/dropdown.svg) no-repeat; 111 | } 112 | 113 | .link--prev { 114 | padding-right: var(--space-s); 115 | padding-left: var(--space-s); 116 | 117 | &:before { 118 | left: var(--space-s); 119 | transform: rotate(90deg); 120 | } 121 | } 122 | .link--next { 123 | padding-right: var(--space-s); 124 | padding-left: var(--space-s); 125 | 126 | &:after { 127 | right: var(--space-s); 128 | transform: rotate(-90deg); 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/css/_param.scss: -------------------------------------------------------------------------------- 1 | .param_ui { 2 | display: flex; 3 | align-items: center; 4 | flex-wrap: wrap; 5 | } 6 | 7 | 8 | .param_ui__content--edit { 9 | form { 10 | display: flex; 11 | align-items: flex-start; 12 | } 13 | 14 | flex-basis: 100%; 15 | } 16 | 17 | .param_ui__content--view { 18 | display: flex; 19 | align-items: center; 20 | } 21 | 22 | .param_ui__include--view { 23 | 24 | } 25 | 26 | .param_ui__fieldset_form { 27 | display: flex; 28 | 29 | & > div { 30 | overflow-y: scroll; 31 | } 32 | } 33 | 34 | .param_ui__fieldset--view { 35 | 36 | } 37 | 38 | .fieldset__list_all { 39 | border: none; 40 | width: 16px; 41 | height: 16px; 42 | } 43 | 44 | .fieldset__list { 45 | margin-bottom: 2px; 46 | 47 | & > ul { 48 | display: flex; 49 | } 50 | } 51 | 52 | .param_ui__loader { 53 | display: flex; 54 | } 55 | .param_ui__attribute_list { 56 | border: var(--size-input-border) solid var(--color-input-border); 57 | border-radius: var(--size-input-border-radius); 58 | background: var(--color-input-bg); 59 | 60 | .attribute { 61 | background: var(--color-whitesmoke-light); 62 | border-bottom: 1px solid var(--color-whitesmoke); 63 | margin-bottom: 1px; 64 | padding: .2rem .4rem; 65 | display: flex; 66 | align-items: center; 67 | } 68 | } 69 | .param_ui__loader_list { 70 | margin-right: 2px; 71 | select { 72 | width: 100%; 73 | } 74 | } 75 | 76 | .param_ui__title { 77 | margin-right: .5rem; 78 | 79 | span { 80 | display: flex; 81 | } 82 | 83 | button { 84 | padding: .2rem 1rem; 85 | margin-left: var(--space-s); 86 | } 87 | } 88 | .param_ui__attribute_list { 89 | max-height: 100px; 90 | overflow-y: scroll; 91 | } 92 | 93 | .param_ui__relationship_list { 94 | 95 | } 96 | 97 | .attribute_header { 98 | border: 1px solid var(--color-lightgray); 99 | margin: 5px 0; 100 | padding: 2px; 101 | background: #fff; 102 | & > span { 103 | margin-left: .5rem; 104 | } 105 | } 106 | 107 | 108 | .param_ui__item { 109 | display: flex; 110 | align-items: center; 111 | margin-right: 5px; 112 | padding: .2rem .4rem; 113 | font-size: var(--font-size-xs); 114 | 115 | code, 116 | span { 117 | margin: 0 .4rem 118 | } 119 | span + button { 120 | 121 | } 122 | } 123 | 124 | .param_ui__item--pill, 125 | .param_ui__item--large { 126 | background: var(--color-whitesmoke); 127 | border: 1px solid var(--color-oldsilver); 128 | } 129 | 130 | .param_ui__item--pill { 131 | border-radius: 16px; 132 | } 133 | 134 | .param_ui__item--large { 135 | border-radius: var(--radius-s); 136 | } 137 | 138 | button.param_ui__button--icon { 139 | border: 1px solid transparent; 140 | background: none; 141 | display: flex; 142 | align-items: center; 143 | width: 16px; 144 | height: 16px; 145 | padding: 0; 146 | } 147 | 148 | .param_ui__item--include { 149 | 150 | } 151 | 152 | .form_widget, 153 | .form_widget__action { 154 | display: flex; 155 | } 156 | 157 | .form_widget { 158 | border: var(--size-input-border) solid var(--color-input-border); 159 | margin: .5rem 0; 160 | padding: .5rem; 161 | align-items: center; 162 | 163 | > * { 164 | margin: 0 .2rem; 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /src/utils/utils.test.js: -------------------------------------------------------------------------------- 1 | import { checkIncludesPath, isEmpty, removeEmpty } from '.'; 2 | 3 | describe('Enabled if matches includes', () => { 4 | test('Top level: No includes', () => { 5 | expect(checkIncludesPath([], [])).toBe(true); 6 | }); 7 | 8 | test('Top level: One include', () => { 9 | expect(checkIncludesPath(['uid'], [])).toBe(true); 10 | }); 11 | 12 | test('Top level: Multiple includes', () => { 13 | expect(checkIncludesPath(['uid', 'node_type'], [])).toBe(true); 14 | }); 15 | 16 | test('Relationship: No includes', () => { 17 | expect(checkIncludesPath([], ['uid'])).toBe(false); 18 | }); 19 | 20 | test('Relationship: matching include', () => { 21 | expect(checkIncludesPath(['uid'], ['uid'])).toBe(true); 22 | }); 23 | 24 | test('Relationship: mismatch include', () => { 25 | expect(checkIncludesPath(['node_type'], ['uid'])).toBe(false); 26 | }); 27 | 28 | test('Relationship: matching include plus other', () => { 29 | expect(checkIncludesPath(['uid', 'node_type'], ['uid'])).toBe(true); 30 | }); 31 | 32 | test('Relationship: matching deep include', () => { 33 | expect(checkIncludesPath(['uid.roles'], ['uid', 'roles'])).toBe(true); 34 | }); 35 | 36 | test('Relationship: mismatching deep include', () => { 37 | expect(checkIncludesPath(['uid'], ['uid', 'user_picture', 'uid'])).toBe( 38 | false, 39 | ); 40 | }); 41 | }); 42 | 43 | describe('Check if different type variables are empty', () => { 44 | test('Arrays are empty', () => { 45 | expect(isEmpty([])).toBe(true); 46 | expect(isEmpty(['foo'])).toBe(false); 47 | }); 48 | 49 | test('Objects are empty', () => { 50 | expect(isEmpty({})).toBe(true); 51 | expect(isEmpty({ foo: 'bar' })).toBe(false); 52 | }); 53 | 54 | test('Sets are empty', () => { 55 | expect(isEmpty(new Set())).toBe(true); 56 | expect(isEmpty(new Set(['foo']))).toBe(false); 57 | }); 58 | 59 | test('null is empty', () => { 60 | expect(isEmpty(null)).toBe(true); 61 | }); 62 | 63 | test('undefined is empty', () => { 64 | expect(isEmpty(undefined)).toBe(true); 65 | }); 66 | }); 67 | 68 | describe('Remove empty properties from object', () => { 69 | test('Empty object is returned the same', () => { 70 | expect(removeEmpty({})).toEqual({}); 71 | expect(removeEmpty({ filter: {} })).toEqual({ filter: {} }); 72 | }); 73 | 74 | test('Object with blank property is returned without it', () => { 75 | expect(removeEmpty({ filter: { drupal_internal__id: '' } })).toEqual({ 76 | filter: {}, 77 | }); 78 | }); 79 | 80 | test('Object with non-blank properties are returned with them', () => { 81 | expect(removeEmpty({ filter: { status: '1' } })).toEqual({ 82 | filter: { status: '1' }, 83 | }); 84 | 85 | expect( 86 | removeEmpty({ filter: { drupal_internal__id: '', status: '1' } }), 87 | ).toEqual({ filter: { status: '1' } }); 88 | }); 89 | 90 | test('Object with sets are removed unchanged', () => { 91 | expect( 92 | removeEmpty({ fields: { 'node--article': new Set(['title']) } }), 93 | ).toEqual({ fields: { 'node--article': new Set(['title']) } }); 94 | }); 95 | 96 | test('Object with arrays are removed unchanged', () => { 97 | expect(removeEmpty({ include: ['uid'] })).toEqual({ include: ['uid'] }); 98 | }); 99 | }); 100 | -------------------------------------------------------------------------------- /src/components/explorer-ui.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useContext } from 'react'; 2 | import { extract } from "../utils"; 3 | 4 | import { MenuLinkElement } from './link'; 5 | import Resource from './resource'; 6 | import { LocationContext } from '../contexts/location'; 7 | import LocationBar from './location-ui'; 8 | import AppTitle from './app-title'; 9 | import SchemaMenu from './schema-ui/schema-menu'; 10 | import useSchema from '../hooks/use-schema'; 11 | 12 | const ExplorerUI = () => { 13 | const schema = useSchema([]); 14 | const [activeMenu, setActiveMenu] = useState(0); 15 | const [loadedMenus, setLoadedMenus] = useState([]); 16 | const [entrypointLinks, setEntrypointLinks] = useState({}); 17 | const { locationUrl, setUrl, entrypointDocument } = useContext( 18 | LocationContext, 19 | ); 20 | 21 | useEffect(() => { 22 | if (entrypointDocument) { 23 | entrypointDocument.getSchema().then(entrypointSchema => { 24 | const documentLinks = entrypointDocument.getOutgoingLinks(); 25 | Object.keys(documentLinks).forEach(key => { 26 | const link = documentLinks[key]; 27 | if (link.title === key && entrypointSchema.links) { 28 | const linkTemplate = entrypointSchema.links.find(linkTemplate => linkTemplate.templatePointers.instanceHref === `/links/${key}/href`); 29 | if (linkTemplate) { 30 | link.title = extract(linkTemplate, 'title', null); 31 | } 32 | } 33 | }); 34 | setEntrypointLinks(documentLinks); 35 | }) 36 | } 37 | }, [entrypointDocument]); 38 | 39 | const loadNext = next => { 40 | // forPath length corresponds to array depth. 41 | const loaded = loadedMenus.slice(0, next.forPath.length); 42 | setLoadedMenus([...loaded, next]); 43 | }; 44 | 45 | useEffect(() => { 46 | const style = document.documentElement.style; 47 | style.setProperty('--nav-offset', activeMenu); 48 | }, [activeMenu]); 49 | 50 | useEffect(() => { 51 | setActiveMenu(loadedMenus.length); 52 | }, [loadedMenus]); 53 | 54 | useEffect(() => { 55 | if (schema) { 56 | const title = entrypointLinks.hasOwnProperty(schema.type) 57 | ? entrypointLinks[schema.type].title 58 | : schema.title; 59 | 60 | loadNext({ 61 | title, 62 | forPath: [], 63 | }); 64 | } 65 | }, [schema]); 66 | 67 | return ( 68 | <> 69 |
    70 | 71 | 72 |
    73 | 100 | 101 | 102 | ); 103 | }; 104 | 105 | export default ExplorerUI; 106 | -------------------------------------------------------------------------------- /src/css/base/_variables.scss: -------------------------------------------------------------------------------- 1 | :root { 2 | /* 3 | * Color Palette. 4 | */ 5 | --color-absolutezero: #003cc5; 6 | --color-white: #fff; 7 | --color-text: #222330; 8 | --color-whitesmoke: #f3f4f9; 9 | --color-whitesmoke-light: #fafbfd; 10 | --color-whitesmoke-o-40: rgba(243, 244, 249, 0.4); 11 | /* Secondary. */ 12 | --color-lightgray: #d8d9e0; 13 | --color-lightgray-o-80: rgba(216, 217, 224, 0.8); 14 | --color-grayblue: #8e929c; 15 | --color-oldsilver: #82828c; 16 | --color-davysgrey: #545560; 17 | --size-input-border: 0.0625rem; /* (1/16)em ~ 1px */ 18 | --color-maximumred: #d72222; 19 | --color-sunglow: #ffd23f; 20 | --color-celadongreen: #228572; 21 | --color-focus: #26a769; 22 | /* Variations. */ 23 | --color-lightgray-hover: #c2c3ca; /* 5% darker than base. */ 24 | --color-lightgray-active: #adaeb3; /* 10% darker than base. */ 25 | --color-absolutezero-hover: #0036b1; /* 5% darker than base. */ 26 | --color-absolutezero-active: #00309e; /* 10% darker than base. */ 27 | --color-maximumred-hover: #c11f1f; /* 5% darker than base. */ 28 | --color-maximumred-active: #ab1b1b; /* 10% darker than base. */ 29 | --color-bgblue-hover: #f0f5fd; /* 5% darker than base. */ 30 | --color-bgblue-active: #e6ecf8; /* 10% darker than base. */ 31 | --color-bgred-hover: #fdf5f5; /* 5% darker than base. */ 32 | --color-bgred-active: #fceded; /* 10% darker than base. */ 33 | 34 | 35 | /* 36 | * Inputs. 37 | */ 38 | --color-input-fg: var(--color-fg); 39 | --color-input-bg: var(--color-bg); 40 | --color-description-fg: var(--color-davysgrey); 41 | --color-placeholder-fg: var(--color-grayblue); 42 | --color-input-border: var(--color-grayblue); 43 | --color-input-border-hover: var(--color-text); 44 | --color-input-border-focus: var(--color-absolutezero); 45 | --color-input-focus-shadow: rgba(0, 74, 220, 0.3); /* Absolute zero with opacity. */ 46 | --color-input-error: var(--color-maximumred); 47 | --color-input-border-error: var(--color-maximumred); 48 | --color-input-label-disabled: rgba(84, 85, 96, 0.6); /* Davy's grey with 0.6 opacity. */ 49 | --color-input-fg-disabled: var(--color-oldsilver); 50 | --color-input-bg-disabled: #f2f2f3; /* Light gray with 0.3 opacity on white bg. */ 51 | --color-input-border-disabled: #bababf; /* Old silver with 0.5 opacity on white bg. */ 52 | --opacity-input-border-disabled: 0.5; 53 | --size-input-border-radius: 0.125rem; /* (1/8)em ~ 2px */ 54 | --size-input-border: 0.0625rem; /* (1/16)em ~ 1px */ 55 | --size-required-mark: 0.4375rem; /* 7px inside the form element label. */ 56 | 57 | --line-height: 1.5; 58 | --font-size-base: 1rem; /* 1rem = 16px if font root is 100% ands browser defaults are used. */ 59 | --font-size-h1: 2.027rem; /* ~32px */ 60 | --font-size-h2: 1.802rem; /* ~29px */ 61 | --font-size-h3: 1.602rem; /* ~26px */ 62 | --font-size-h4: 1.424rem; /* ~23px */ 63 | --font-size-h5: 1.266rem; /* ~20px */ 64 | --font-size-h6: 1.125rem; /* 18px */ 65 | --font-size-s: 0.889rem; /* ~14px */ 66 | --font-size-xs: 0.79rem; /* ~13px */ 67 | --font-size-xxs: 0.702rem; /* ~11px */ 68 | --font-size-label: var(--font-size-s); 69 | --font-size-description: var(--font-size-xs); 70 | 71 | /** 72 | * App extras 73 | */ 74 | --color-primary: var(--color-text); 75 | --color-light: #C4C4C4; 76 | --color-neutral: #E5E5E5; 77 | 78 | --color-bgblue-light: #F3F7FD; 79 | 80 | --width-aside: 320px; 81 | --nav-offset: 0; 82 | 83 | --radius-s: 5px; 84 | 85 | /** 86 | * Spaces. 87 | */ 88 | --space-xl: 3rem; /* 4 * 16px = 48px */ 89 | --space-l: 1.5rem; /* 1.5 * 16px = 24px */ 90 | --space-m: 1rem; /* 1 * 16px = 16px */ 91 | --space-s: 0.75rem; /* 0.75 * 16px = 12px */ 92 | --space-xs: 0.5rem; /* 0.5 * 16px = 8px */ 93 | 94 | } 95 | 96 | $ff-default: 'Oxygen', sans-serif; 97 | $ff-mono: 'Oxygen Mono', monospace; 98 | -------------------------------------------------------------------------------- /src/lib/url/filter.js: -------------------------------------------------------------------------------- 1 | import {copyObject} from "../../utils"; 2 | 3 | /** 4 | * The key for the implicit root group. 5 | */ 6 | const ROOT_ID = '@root'; 7 | 8 | /** 9 | * The value key in the filter condition: filter[lorem][condition][]. 10 | * 11 | * @var string 12 | */ 13 | const VALUE_KEY = 'value'; 14 | 15 | /** 16 | * Key in the filter[] parameter for conditions. 17 | * 18 | * @var string 19 | */ 20 | const CONDITION_KEY = 'condition'; 21 | 22 | /** 23 | * The field key in the filter condition: filter[lorem][condition][]. 24 | * 25 | * @var string 26 | */ 27 | const PATH_KEY = 'path'; 28 | 29 | /** 30 | * The operator key in the condition: filter[lorem][condition][]. 31 | * 32 | * @var string 33 | */ 34 | const OPERATOR_KEY = 'operator'; 35 | 36 | /** 37 | * Key in the filter[] parameter for groups. 38 | * 39 | * @var string 40 | */ 41 | const GROUP_KEY = 'group'; 42 | 43 | /** 44 | * Key in the filter[][] parameter for group membership. 45 | * 46 | * @var string 47 | */ 48 | const MEMBER_KEY = 'memberOf'; 49 | 50 | export function newFilter(param) { 51 | return { 52 | [param]: { 53 | [CONDITION_KEY]: { 54 | [PATH_KEY]: param, 55 | [OPERATOR_KEY]: '=', 56 | [VALUE_KEY]: '', 57 | [MEMBER_KEY]: ROOT_ID, 58 | }, 59 | }, 60 | }; 61 | } 62 | 63 | function expandItem(filterIndex, filterItem) { 64 | if (filterItem.hasOwnProperty(VALUE_KEY)) { 65 | if (!filterItem[PATH_KEY]) { 66 | filterItem[PATH_KEY] = filterIndex; 67 | } 68 | 69 | filterItem = { 70 | [CONDITION_KEY]: filterItem, 71 | }; 72 | } 73 | 74 | if (filterItem[CONDITION_KEY] && !filterItem[CONDITION_KEY][OPERATOR_KEY]) { 75 | filterItem[CONDITION_KEY][OPERATOR_KEY] = '='; 76 | } 77 | 78 | return filterItem; 79 | } 80 | 81 | export const expandFilter = unexpandedFilter => { 82 | let filter = copyObject(unexpandedFilter); 83 | const expanded = {}; 84 | 85 | // Allow extreme shorthand filters, f.e. `?filter[promote]=1`. 86 | for (let key in filter) { 87 | if (filter.hasOwnProperty(key)) { 88 | let value = filter[key]; 89 | 90 | if (typeof value !== 'object') { 91 | value = { 92 | [VALUE_KEY]: value, 93 | [MEMBER_KEY]: ROOT_ID, 94 | }; 95 | } 96 | 97 | // Add a memberOf key to all items. 98 | if (value[CONDITION_KEY] && !value[CONDITION_KEY][MEMBER_KEY]) { 99 | value[CONDITION_KEY][MEMBER_KEY] = ROOT_ID; 100 | } else if (value[GROUP_KEY] && !value[GROUP_KEY][MEMBER_KEY]) { 101 | value[GROUP_KEY][MEMBER_KEY] = ROOT_ID; 102 | } 103 | 104 | // Expands shorthand filters. 105 | expanded[key] = expandItem(key, value); 106 | } 107 | } 108 | 109 | return expanded; 110 | }; 111 | 112 | export const optimizeFilter = unoptimizedFilter => { 113 | let filter = copyObject(unoptimizedFilter); 114 | let optimized = {}; 115 | 116 | const expanded = expandFilter(filter); 117 | 118 | for (let [key, value] of Object.entries(expanded)) { 119 | if ( 120 | value[CONDITION_KEY] && 121 | value[CONDITION_KEY][OPERATOR_KEY] === '=' && 122 | value[CONDITION_KEY][MEMBER_KEY] === ROOT_ID 123 | ) { 124 | optimized[value[CONDITION_KEY][PATH_KEY]] = value[CONDITION_KEY][VALUE_KEY]; 125 | } else if ( 126 | value[CONDITION_KEY] && value[CONDITION_KEY][MEMBER_KEY] === ROOT_ID 127 | ) { 128 | optimized[key] = value; 129 | delete(optimized[key][CONDITION_KEY][MEMBER_KEY]); 130 | } else if ( 131 | value[GROUP_KEY] && value[GROUP_KEY][MEMBER_KEY] === ROOT_ID 132 | ) { 133 | optimized[key] = value; 134 | delete(optimized[key][GROUP_KEY][MEMBER_KEY]); 135 | } else { 136 | // Original unoptimized 137 | optimized[key] = filter[key]; 138 | } 139 | } 140 | 141 | return optimized; 142 | }; 143 | -------------------------------------------------------------------------------- /src/lib/jsonapi-objects/resource-object.js: -------------------------------------------------------------------------------- 1 | import { Link } from '../../components/link'; 2 | import {copyObject, extract} from '../../utils'; 3 | 4 | export default class ResourceObject { 5 | constructor({ raw }) { 6 | this.raw = raw; 7 | this.parentDocument = null; 8 | this.related = false; 9 | this.relatedBy = null; 10 | } 11 | 12 | static parse(raw) { 13 | return new ResourceObject({ raw }); 14 | } 15 | 16 | withParentDocument(responseDocument) { 17 | this.parentDocument = responseDocument; 18 | return this; 19 | } 20 | 21 | withRelated(related) { 22 | this.related = related; 23 | return this; 24 | } 25 | 26 | withRelatedBy(resourceObject) { 27 | this.relatedBy = resourceObject; 28 | return this; 29 | } 30 | 31 | getType() { 32 | return this.raw.type; 33 | } 34 | 35 | getID() { 36 | return this.raw.id; 37 | } 38 | 39 | getIdentifier() { 40 | return { 41 | type: this.raw.type, 42 | id: this.raw.id, 43 | }; 44 | } 45 | 46 | getFieldnames() { 47 | return [ 48 | ...Object.keys(this.getAttributes()), 49 | ...Object.keys(this.getRelationships()), 50 | ]; 51 | } 52 | 53 | getAttributes() { 54 | return this.raw.attributes || {}; 55 | } 56 | 57 | hasAttribute(fieldName) { 58 | return this.raw.attributes && this.raw.attributes.hasOwnProperty(fieldName); 59 | } 60 | 61 | getRelationships() { 62 | return this.raw.relationships || {}; 63 | } 64 | 65 | hasRelationship(fieldName) { 66 | return this.raw.relationships && this.raw.relationships.hasOwnProperty(fieldName); 67 | } 68 | 69 | getRelated(fieldName) { 70 | if (this.related === false) { 71 | const identifiersByField = Object.entries(this.getRelationships()).reduce((identifiers, [fieldName, relationship]) => { 72 | return Object.assign(identifiers, { 73 | [fieldName]: [relationship.data].flat().filter(i => i), 74 | }); 75 | }, {}); 76 | const allIdentifiers = Object.values(identifiersByField).flat(); 77 | const allRelated = this.parentDocument 78 | .getIncluded() 79 | .filter(object => allIdentifiers.some(identifies(object))) 80 | .map(relatedObject => relatedObject.copy()) 81 | .map(relatedObject => relatedObject.withRelatedBy(this)); 82 | this.related = Object.entries(identifiersByField).reduce((related, [fieldName, identifiers]) => { 83 | const identified = allRelated.filter(object => identifiers.some(identifies(object))); 84 | return Object.assign(related, { 85 | [fieldName]: Array.isArray(extract(this.raw, `relationships.${fieldName}.data`) || null) 86 | ? identified 87 | : identified.pop() || null, 88 | }); 89 | }, {}) 90 | } 91 | return this.related[fieldName]; 92 | } 93 | 94 | getRelatedBy() { 95 | return this.relatedBy; 96 | } 97 | 98 | getRootResourceObject() { 99 | return this.relatedBy ? this.relatedBy.getRootResourceObject() : this; 100 | } 101 | 102 | getLinks() { 103 | return Link.parseLinks(this.raw.links || {}); 104 | } 105 | 106 | getOutgoingLinks() { 107 | const {self, describedby, ...outgoing} = this.getLinks(); 108 | return outgoing; 109 | } 110 | 111 | matches({ type, id }) { 112 | return this.getType() === type && this.getID() === id; 113 | } 114 | 115 | same(resourceObject) { 116 | return this.matches(resourceObject.getIdentifier()) && ((!this.getRelatedBy() && !this.getRelatedBy()) || this.getRelatedBy().same(resourceObject.getRelatedBy())); 117 | } 118 | 119 | copy() { 120 | return ResourceObject 121 | .parse(copyObject(this.raw)) 122 | .withParentDocument(this.parentDocument) 123 | .withRelated(this.related) 124 | .withRelatedBy(this.relatedBy); 125 | } 126 | } 127 | 128 | const identifies = object => identifier => object.matches(identifier); 129 | -------------------------------------------------------------------------------- /src/contexts/field-focus.js: -------------------------------------------------------------------------------- 1 | import React, { createContext, useContext, useEffect, useReducer } from 'react'; 2 | import {LocationContext} from "./location"; 3 | 4 | const defaults = { 5 | focus: { 6 | path: [], 7 | field: null, 8 | on: null, 9 | last: null, 10 | }, 11 | changeFocus: () => {}, 12 | availableFocusPaths: [], 13 | }; 14 | 15 | const findUniqueFieldNames = (resourceObjects) => { 16 | const reduceAvailableFieldPaths = crumb => (reduced, resourceObject) => { 17 | const fields = resourceObject.getFieldnames(); 18 | return fields.reduce((reduced, field) => { 19 | reduced = reduced.add(`${crumb}${field}`); 20 | if (resourceObject.hasRelationship(field)) { 21 | const related = [resourceObject.getRelated(field)].flat().filter(o => o); 22 | reduced = related.reduce(reduceAvailableFieldPaths(`${crumb}${field}.`), reduced); 23 | } 24 | return reduced; 25 | }, reduced); 26 | }; 27 | return [...resourceObjects.reduce(reduceAvailableFieldPaths(''), new Set([]))]; 28 | }; 29 | 30 | const fieldFocusReducer = (state, action) => { 31 | let {path, field, on} = action.arg || defaults.focus; 32 | switch(action.type) { 33 | case 'focusOn': 34 | const newState = {path, field, last: {...state}}; 35 | if (path.join('.') !== state.path.join('.')) { 36 | newState['on'] = null; 37 | } 38 | return Object.assign({...state}, newState); 39 | case 'focusOff': 40 | return Object.assign({...state}, { 41 | path: defaults.focus.path, 42 | field: defaults.focus.field, 43 | last: {...state}, 44 | }); 45 | case 'focusUp': 46 | return chain({...state}, { 47 | type: 'focusOn', 48 | arg: { 49 | path: state.path.slice(0, -1), 50 | field: state.path[state.path.length - 1], 51 | } 52 | }, { 53 | type: 'zoomOn', 54 | arg: {on: action.arg.on.getRelatedBy()}, 55 | }); 56 | case 'focusDown': 57 | return chain({...state}, { 58 | type: 'focusOn', 59 | arg: { 60 | path: [...state.path, field], 61 | field: null, 62 | } 63 | }, { 64 | type: 'zoomOn', 65 | arg: {on: on.getRelated(field)}, 66 | }); 67 | case 'zoomOn': 68 | return Object.assign({...state}, {on: action.arg.on, last: state}); 69 | case 'zoomOff': 70 | return Object.assign({...state}, {on: defaults.focus.on, last: state}); 71 | case 'expand': 72 | return chain(state, { 73 | type: 'focusOn', 74 | arg: { 75 | path: state.path, 76 | field: null, 77 | } 78 | }, { 79 | type: 'zoomOn', 80 | arg: action.arg, 81 | }); 82 | case 'set': 83 | return Object.assign({path, field, on}, {last: state}); 84 | case 'toLast': 85 | return Object.assign({...state.last}, {last: state}); 86 | case 'reset': 87 | return defaults.focus; 88 | } 89 | }; 90 | 91 | const chain = (state, ...actions) => { 92 | return Object.assign(actions.reduce(fieldFocusReducer, state), {last: {...state}}); 93 | }; 94 | 95 | const FieldFocusContext = createContext(defaults); 96 | 97 | const FieldFocus = ({children}) => { 98 | const { baseUrl, fields, responseDocument } = useContext(LocationContext); 99 | const [ focus, dispatch ] = useReducer(fieldFocusReducer, defaults.focus); 100 | const availableFocusPaths = findUniqueFieldNames(responseDocument 101 | ? [responseDocument.getData()].flat() 102 | : [] 103 | ); 104 | 105 | const changeFocus = (command, arg) => dispatch({type: command, arg}); 106 | 107 | useEffect(() => { 108 | changeFocus('reset'); 109 | }, [baseUrl, fields]); 110 | 111 | return ( 112 | 117 | {children} 118 | 119 | ); 120 | }; 121 | 122 | export { FieldFocusContext }; 123 | export default FieldFocus; 124 | -------------------------------------------------------------------------------- /src/lib/url/filter.test.js: -------------------------------------------------------------------------------- 1 | import { expandFilter, optimizeFilter } from './filter'; 2 | 3 | const expanded = [ 4 | { 5 | field_first_name: { 6 | condition: { 7 | path: 'field_first_name', 8 | operator: '=', 9 | value: '', 10 | memberOf: '@root', 11 | }, 12 | }, 13 | }, 14 | { 15 | field_first_name: { 16 | condition: { 17 | path: 'field_first_name', 18 | operator: '=', 19 | value: 'Janis', 20 | memberOf: '@root', 21 | }, 22 | }, 23 | }, 24 | { 25 | foo: { 26 | condition: { 27 | path: 'foo', 28 | operator: '=', 29 | value: 'bar', 30 | memberOf: '@root', 31 | }, 32 | }, 33 | }, 34 | { 35 | foo: { 36 | condition: { 37 | path: 'foo', 38 | operator: '=', 39 | value: 'bar', 40 | memberOf: 'baz', 41 | }, 42 | }, 43 | baz: { 44 | group: { 45 | conjunction: 'OR', 46 | memberOf: '@root', 47 | }, 48 | }, 49 | }, 50 | { 51 | foo: { 52 | condition: { 53 | path: 'foo', 54 | operator: 'STARTS_WITH', 55 | value: 'bar', 56 | memberOf: '@root', 57 | }, 58 | }, 59 | }, 60 | { 61 | foo: { 62 | condition: { 63 | path: 'foo', 64 | operator: '=', 65 | value: 'bar', 66 | memberOf: 'baz', 67 | }, 68 | }, 69 | bar: { 70 | condition: { 71 | path: 'foo', 72 | operator: 'STARTS_WITH', 73 | value: 'bar', 74 | memberOf: '@root', 75 | }, 76 | }, 77 | baz: { 78 | group: { 79 | conjunction: 'AND', 80 | memberOf: '@root', 81 | }, 82 | }, 83 | }, 84 | ]; 85 | 86 | const optimized = [ 87 | { field_first_name: '' }, 88 | { field_first_name: 'Janis' }, 89 | { foo: 'bar' }, 90 | { 91 | foo: { 92 | condition: { 93 | path: 'foo', 94 | operator: '=', 95 | value: 'bar', 96 | memberOf: 'baz', 97 | }, 98 | }, 99 | baz: { 100 | group: { 101 | conjunction: 'OR', 102 | }, 103 | }, 104 | }, 105 | { 106 | foo: { 107 | condition: { 108 | path: 'foo', 109 | operator: 'STARTS_WITH', 110 | value: 'bar', 111 | }, 112 | }, 113 | }, 114 | { 115 | foo: { 116 | condition: { 117 | path: 'foo', 118 | operator: '=', 119 | value: 'bar', 120 | memberOf: 'baz', 121 | }, 122 | }, 123 | bar: { 124 | condition: { 125 | path: 'foo', 126 | operator: 'STARTS_WITH', 127 | value: 'bar', 128 | }, 129 | }, 130 | baz: { 131 | group: { 132 | conjunction: 'AND', 133 | }, 134 | }, 135 | }, 136 | ]; 137 | 138 | const unoptimizable = [ 139 | { 140 | field_first_name: { 141 | condition: { 142 | path: 'field_first_name', 143 | value: 'Janis', 144 | memberOf: 'bug', 145 | }, 146 | }, 147 | }, 148 | { 149 | field_first_name: { 150 | condition: { 151 | path: 'field_first_name', 152 | value: 'Janis', 153 | operator: 'STARTS_WITH', 154 | }, 155 | }, 156 | }, 157 | ]; 158 | 159 | describe('Expand Filter', () => { 160 | test('Expanded filter should be processed', () => { 161 | expanded.forEach((filter, index) => { 162 | expect(expandFilter(filter)).toStrictEqual(expanded[index]); 163 | }); 164 | }); 165 | 166 | test('Optimized filter should be expanded', () => { 167 | optimized.forEach((filter, index) => { 168 | expect(expandFilter(filter)).toStrictEqual(expanded[index]); 169 | }); 170 | }); 171 | }); 172 | 173 | describe('Optimize Filter', () => { 174 | test('Expanded filter should be optimized', () => { 175 | expanded.forEach((filter, index) => { 176 | expect(optimizeFilter(filter)).toStrictEqual(optimized[index]); 177 | }); 178 | }); 179 | 180 | test('Optimized filter should be unchanged', () => { 181 | optimized.forEach((filter, index) => { 182 | expect(optimizeFilter(filter)).toStrictEqual(optimized[index]); 183 | }); 184 | }); 185 | 186 | test('Unexpanded filter should be optimized', () => { 187 | unoptimizable.forEach((filter, index) => { 188 | expect(optimizeFilter(filter)).toStrictEqual(unoptimizable[index]); 189 | }); 190 | }); 191 | }); 192 | -------------------------------------------------------------------------------- /src/components/param-ui/fieldset-loader.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useContext } from 'react'; 2 | 3 | import useSchema from '../../hooks/use-schema'; 4 | import useSchemaLoader from '../../hooks/use-schema-loader'; 5 | import { checkIncludesPath, hasSetEntry, toggleSetEntry } from '../../utils'; 6 | import { LocationContext } from '../../contexts/location'; 7 | import { textDisabled } from '../../lib/messages'; 8 | import ParamSelect from './param-select'; 9 | import { Add, Done } from '../icon'; 10 | 11 | const Attribute = ({ attribute, type, includeEnabled }) => { 12 | const { fields, toggleField } = useContext(LocationContext); 13 | const name = `${type}-${attribute.name}`; 14 | return ( 15 |
    16 | toggleField(type, attribute.name)} 27 | /> 28 | 29 |
    30 | ); 31 | }; 32 | const AttributeLoaderList = ({ path, load }) => { 33 | const { forPath } = path; 34 | const { include } = useContext(LocationContext); 35 | const includesEnabled = checkIncludesPath(include, forPath); 36 | const schema = useSchema(forPath); 37 | 38 | const handleChange = e => { 39 | load({ forPath: [...forPath, e.target.value] }); 40 | }; 41 | 42 | if (schema) { 43 | const { attributes, relationships } = schema; 44 | 45 | return ( 46 |
    47 |
    48 | {attributes.map(attribute => ( 49 | 55 | ))} 56 |
    57 | 58 | 59 | {relationships.map(relationship => ( 60 | 66 | ))} 67 | 68 |
    69 | ); 70 | } 71 | 72 | return <>; 73 | }; 74 | 75 | const FieldsetForm = ({ onSubmit, hide }) => { 76 | const [values, setValues] = useState(new Set([])); 77 | const { paths, load } = useSchemaLoader([]); 78 | 79 | const addAttribute = attribute => { 80 | const current = new Set([...values]); 81 | setValues(toggleSetEntry(current, attribute)); 82 | }; 83 | 84 | const handleSubmit = e => { 85 | e.preventDefault(); 86 | }; 87 | 88 | return ( 89 |
    90 |
    91 | {paths.map((path, index) => ( 92 | 100 | ))} 101 |
    102 |
    103 | 110 |
    111 |
    112 | ); 113 | }; 114 | 115 | const FieldsetLoader = () => { 116 | const [visible, setVisible] = useState(false); 117 | 118 | const showForm = () => { 119 | setVisible(true); 120 | }; 121 | 122 | const hideForm = () => { 123 | setVisible(false); 124 | }; 125 | 126 | return visible ? ( 127 | 128 | ) : ( 129 |
    130 | 137 |
    138 | ); 139 | }; 140 | 141 | export default FieldsetLoader; 142 | -------------------------------------------------------------------------------- /src/css/_header.scss: -------------------------------------------------------------------------------- 1 | .app-header { 2 | display: grid; 3 | grid-template-columns: var(--width-aside) 1fr; 4 | grid-gap: 1px; 5 | background: var(--color-light); 6 | } 7 | 8 | .app-title { 9 | margin: 0; 10 | padding: var(--space-m); 11 | background: var(--color-primary); 12 | position: relative; 13 | line-height: 1.4em; 14 | font-size: 1.4rem; 15 | font-weight: 700; 16 | color: #fff; 17 | 18 | .subtitle { 19 | display: block; 20 | font-weight: 400; 21 | font-size: 2rem; 22 | } 23 | 24 | sup { 25 | font-size: 1rem; 26 | font-weight: 700; 27 | text-transform: uppercase; 28 | color: var(--color-maximumred); 29 | } 30 | } 31 | 32 | .button__github { 33 | padding: .3rem .5rem .3rem .4rem; 34 | background-image: linear-gradient(to bottom, var(--color-whitesmoke), var(--color-lightgray)); 35 | box-shadow: 36 | inset 0 0 0 1px var(--color-davysgrey), 37 | inset 0 0 0 3px var(--color-whitesmoke), 38 | 0 0 0 1px var(--color-whitesmoke-o-40); 39 | border-radius: .5rem; 40 | display: flex; 41 | align-items: center; 42 | position: absolute; 43 | top: var(--space-xs); 44 | right: var(--space-xs); 45 | line-height: 1; 46 | font-size: .7rem; 47 | text-decoration: none; 48 | color: var(--color-text); 49 | } 50 | .icon__github { 51 | margin-right: .3rem; 52 | width: 1rem; 53 | height: 1rem; 54 | display: block; 55 | background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB2ZXJzaW9uPSIxLjEiIGlkPSJMYXllcl8xIiB4PSIwcHgiIHk9IjBweCIgd2lkdGg9IjQwcHgiIGhlaWdodD0iNDBweCIgdmlld0JveD0iMTIgMTIgNDAgNDAiIGVuYWJsZS1iYWNrZ3JvdW5kPSJuZXcgMTIgMTIgNDAgNDAiIHhtbDpzcGFjZT0icHJlc2VydmUiPjxwYXRoIGZpbGw9IiMzMzMzMzMiIGQ9Ik0zMiAxMy40Yy0xMC41IDAtMTkgOC41LTE5IDE5YzAgOC40IDUuNSAxNS41IDEzIDE4YzEgMC4yIDEuMy0wLjQgMS4zLTAuOWMwLTAuNSAwLTEuNyAwLTMuMiBjLTUuMyAxLjEtNi40LTIuNi02LjQtMi42QzIwIDQxLjYgMTguOCA0MSAxOC44IDQxYy0xLjctMS4yIDAuMS0xLjEgMC4xLTEuMWMxLjkgMC4xIDIuOSAyIDIuOSAyYzEuNyAyLjkgNC41IDIuMSA1LjUgMS42IGMwLjItMS4yIDAuNy0yLjEgMS4yLTIuNmMtNC4yLTAuNS04LjctMi4xLTguNy05LjRjMC0yLjEgMC43LTMuNyAyLTUuMWMtMC4yLTAuNS0wLjgtMi40IDAuMi01YzAgMCAxLjYtMC41IDUuMiAyIGMxLjUtMC40IDMuMS0wLjcgNC44LTAuN2MxLjYgMCAzLjMgMC4yIDQuNyAwLjdjMy42LTIuNCA1LjItMiA1LjItMmMxIDIuNiAwLjQgNC42IDAuMiA1YzEuMiAxLjMgMiAzIDIgNS4xYzAgNy4zLTQuNSA4LjktOC43IDkuNCBjMC43IDAuNiAxLjMgMS43IDEuMyAzLjVjMCAyLjYgMCA0LjYgMCA1LjJjMCAwLjUgMC40IDEuMSAxLjMgMC45YzcuNS0yLjYgMTMtOS43IDEzLTE4LjFDNTEgMjEuOSA0Mi41IDEzLjQgMzIgMTMuNHoiLz48L3N2Zz4='); 56 | background-size: 100%; 57 | background-repeat: no-repeat; 58 | } 59 | 60 | .location { 61 | padding: var(--space-s); 62 | background-color: var(--color-whitesmoke-o-40); 63 | position: relative; 64 | display: flex; 65 | flex-direction: column; 66 | } 67 | 68 | .location__form { 69 | flex: 1; 70 | display: flex; 71 | flex-direction: row; 72 | } 73 | 74 | .location__suggestion { 75 | padding: var(--space-xs); 76 | position: absolute; 77 | display: inline-block; 78 | top: calc(100% - 4px); 79 | left: var(--space-s); 80 | background: var(--color-text); 81 | color: var(--color-white-smoke-light); 82 | border-radius: var(--radius-s); 83 | 84 | transition: opacity .25s ease-out; 85 | 86 | &--hidden { 87 | opacity: 0; 88 | pointer-events: none; 89 | } 90 | 91 | &::before { 92 | content: ''; 93 | width: 0; 94 | height: 0; 95 | border-left: var(--space-xs) solid transparent; 96 | border-right: var(--space-xs) solid transparent; 97 | border-bottom: var(--space-xs) solid var(--color-text); 98 | position: absolute; 99 | bottom: 100%; 100 | left: var(--space-m); 101 | } 102 | 103 | span { 104 | color: var(--color-whitesmoke-light); 105 | } 106 | } 107 | 108 | .location__suggestion_button { 109 | font-size: 1rem; 110 | padding: 4px var(--space-s); 111 | text-decoration: none; 112 | background: var(--color-text); 113 | border: none; 114 | border-top-right-radius: var(--radius-s); 115 | border-bottom-right-radius: var(--radius-s); 116 | font-size: 1rem; 117 | background: var(--color-bgblue-light); 118 | color: var(--color-text); 119 | 120 | &:hover, &:focus { 121 | background: var(--color-bgblue-active); 122 | } 123 | } 124 | 125 | .query-url { 126 | padding: var(--space-s); 127 | background: var(--color-white); 128 | border: none; 129 | flex: 1; 130 | display: flex; 131 | align-items: center; 132 | font-size: 1.5rem; 133 | font-family: $ff-mono; 134 | word-break: break-word; 135 | } 136 | -------------------------------------------------------------------------------- /src/components/param-ui/include-loader.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useContext } from 'react'; 2 | 3 | import { LocationContext } from '../../contexts/location'; 4 | import ParamSelect from './param-select'; 5 | import { isEmpty } from '../../utils'; 6 | import useSchema from '../../hooks/use-schema'; 7 | import useSchemaLoader from '../../hooks/use-schema-loader'; 8 | import { Add, Close } from '../icon'; 9 | 10 | const IncludeLoaderOption = ({ name }) => ; 11 | 12 | const IncludeLoaderList = ({ path, load }) => { 13 | const { forPath } = path; 14 | const [selected, setSelected] = useState(''); 15 | const schema = useSchema(forPath); 16 | 17 | if (!schema) { 18 | return
    ; 19 | } 20 | 21 | const { relationships } = schema; 22 | 23 | const handleChange = e => { 24 | if (e.target.value !== '') { 25 | load({ forPath: [...forPath, e.target.value] }); 26 | } else { 27 | // Trim unselected option. 28 | load({ forPath: [...forPath] }); 29 | } 30 | 31 | setSelected(e.target.value); 32 | }; 33 | 34 | return ( 35 |
    36 | {relationships.length > 0 && ( 37 | 38 | 39 | {relationships 40 | .map(relationship => relationship.name) 41 | .map((name, index) => ( 42 | 43 | ))} 44 | 45 | )} 46 |
    47 | ); 48 | }; 49 | 50 | const IncludeForm = ({ onSubmit, visible, setVisible }) => { 51 | const [active, setActive] = useState(false); 52 | const schema = useSchema([]); 53 | const { paths, load, reset } = useSchemaLoader([]); 54 | const current = paths.length > 1 ? paths.slice(-1).pop() : null; 55 | 56 | const showForm = () => { 57 | setActive(true); 58 | }; 59 | 60 | const handleSubmit = e => { 61 | e.preventDefault(); 62 | 63 | if (current) { 64 | onSubmit(current.forPath); 65 | } 66 | 67 | setActive(false); 68 | reset(); 69 | }; 70 | 71 | useEffect(() => { 72 | if (schema) { 73 | setVisible(schema.relationships && schema.relationships.length > 0); 74 | } 75 | }, [schema]); 76 | 77 | const type = schema ? schema.type : ''; 78 | 79 | return ( 80 |
    81 | {active ? ( 82 |
    83 | {paths.map((path, index) => { 84 | return ( 85 | 90 | ); 91 | })} 92 | {current && ( 93 |
    94 | {current.forPath.join('.')} 95 | 102 |
    103 | )} 104 |
    105 | ) : ( 106 | visible && ( 107 |
    108 | 115 |
    116 | ) 117 | )} 118 |
    119 | ); 120 | }; 121 | 122 | const IncludeLoader = () => { 123 | const { baseUrl, include, toggleInclude } = useContext(LocationContext); 124 | const [visible, setVisible] = useState(true); 125 | 126 | // At this level path should be a real forPath like ['uid', 'roles'] 127 | const addInclude = path => { 128 | const includePathString = path.join('.'); 129 | if (include.indexOf(includePathString) === -1) { 130 | toggleInclude(includePathString); 131 | } 132 | }; 133 | 134 | useEffect(() => { 135 | setVisible(true); 136 | }, [baseUrl]); 137 | 138 | return ( 139 | <> 140 | 145 | {!visible && ( 146 | 152 | )} 153 | 154 | ); 155 | }; 156 | 157 | export default IncludeLoader; 158 | -------------------------------------------------------------------------------- /src/css/main.scss: -------------------------------------------------------------------------------- 1 | @import 'base/variables'; 2 | @import 'header'; 3 | @import 'nav'; 4 | @import 'param'; 5 | @import 'result'; 6 | @import 'form'; 7 | @import 'form--select'; 8 | 9 | html, body { 10 | margin: 0; 11 | padding: 0; 12 | } 13 | body { 14 | font-size: 18px; 15 | font-family: $ff-default; 16 | color: var(--color-text); 17 | } 18 | 19 | h1 { 20 | margin: 1rem; 21 | } 22 | 23 | .container { 24 | display: grid; 25 | grid-template-areas: "a a" 26 | "b c"; 27 | grid-template-rows: auto 1fr; 28 | grid-template-columns: var(--width-aside) 1fr; 29 | grid-gap: 1px; 30 | min-height: 100vh; 31 | max-height: 100vh; 32 | background: var(--color-light); 33 | } 34 | 35 | .controls { 36 | display: grid; 37 | grid-gap: 4px; 38 | padding: var(--space-s); 39 | } 40 | 41 | .controls_panel { 42 | padding: .5rem; 43 | background: var(--color-white); 44 | border-radius: var(--radius-s); 45 | } 46 | 47 | .results-container { 48 | flex: 1; 49 | overflow: scroll; 50 | } 51 | 52 | .links { 53 | } 54 | .schema { 55 | grid-area: schema; 56 | } 57 | .tree { 58 | grid-area: tree; 59 | } 60 | .raw { 61 | grid-area: raw; 62 | } 63 | 64 | header.app-header { 65 | grid-area: a; 66 | } 67 | nav { 68 | grid-area: b; 69 | } 70 | main { 71 | grid-area: c; 72 | display: flex; 73 | flex-direction: column; 74 | overflow: hidden; 75 | } 76 | 77 | ul { 78 | list-style: none; 79 | padding: 0; 80 | margin: 0; 81 | } 82 | 83 | li { 84 | margin: 0; 85 | padding: 0; 86 | } 87 | 88 | .flex-height { 89 | flex: 1; 90 | display: flex; 91 | flex-direction: column; 92 | } 93 | 94 | .tabs { 95 | display: flex; 96 | } 97 | 98 | .tabs li { 99 | padding: .5rem 1rem; 100 | margin-left: 2px; 101 | cursor: pointer; 102 | } 103 | 104 | .tabs li.is_active { 105 | background: #fff; 106 | } 107 | 108 | .links li { 109 | margin-left: 1rem; 110 | display: inline-block; 111 | } 112 | 113 | .tabs li, 114 | .links li:first-child { 115 | margin-left: 0; 116 | } 117 | 118 | .link__title--readable { 119 | display: block; 120 | font-weight: 700; 121 | } 122 | 123 | .link__text--machine { 124 | font-family: $ff-mono; 125 | } 126 | 127 | .link__toggle { 128 | font-size: 1.25rem; 129 | line-height: 0; 130 | margin-left: .5rem; 131 | color: var(--color-davysgrey); 132 | cursor: pointer; 133 | &:hover { 134 | color: var(--color-absolutezero-hover); 135 | } 136 | } 137 | 138 | .link__toggle--active { 139 | color: var(--color-absolutezero); 140 | } 141 | 142 | .link__toggle--disabled { 143 | color: var(--color-grayblue); 144 | 145 | &:hover { 146 | color: var(--color-grayblue); 147 | } 148 | } 149 | 150 | .link__text_type { 151 | color: var(--color-absolutezero); 152 | &::before { 153 | content: ':'; 154 | } 155 | } 156 | 157 | 158 | .link__text_description { 159 | margin: .5rem 0; 160 | font-style: italic; 161 | color: var(--color-davysgrey); 162 | } 163 | 164 | button { 165 | padding: .5rem 2rem; 166 | appearance: none; 167 | border: 1px solid #8E929C; 168 | background: #fff; 169 | border-radius: 2px; 170 | cursor: pointer; 171 | } 172 | 173 | .pane { 174 | padding: .5rem; 175 | background: #F3F4F9; 176 | } 177 | 178 | .tab { 179 | display: none; 180 | } 181 | .tab.tab__active { 182 | display: flex; 183 | } 184 | 185 | .tab__header { 186 | font-size: 1.25em; 187 | font-weight: bold; 188 | margin: 0; 189 | padding: var(--space-l) var(--space-s); 190 | background: var(--color-white); 191 | box-shadow: 0 20px 20px -20px rgba(0, 0, 0, .25); 192 | display: flex; 193 | } 194 | 195 | .page_navigation { 196 | margin-left: auto; 197 | 198 | & > * { 199 | padding: 0 var(--space-s); 200 | &:first-child { 201 | padding-left: 0; 202 | } 203 | &:last-child { 204 | padding-right: 0; 205 | } 206 | } 207 | } 208 | 209 | .page_navigation__link_arrow { 210 | cursor: pointer; 211 | } 212 | 213 | .results__empty { 214 | margin: 0; 215 | padding: var(--space-l) var(--space-s); 216 | } 217 | 218 | .results__raw .CodeMirror { 219 | flex: 1; 220 | display: flex; 221 | flex-direction: column; 222 | } 223 | 224 | .results_rows { 225 | flex: 1; 226 | overflow: scroll; 227 | } 228 | 229 | .schema-list { 230 | margin-left: 10px; 231 | padding-left: 5px; 232 | border-left: 5px solid #ccc; 233 | } 234 | 235 | .schema > .schema-list { 236 | margin-left: 0; 237 | padding-left: 0; 238 | border-left: none; 239 | } 240 | 241 | pre { 242 | margin: -.5rem 0; 243 | display: block; 244 | background: var(--color-whitesmoke-o-40); 245 | border: 1px solid var(--color-whitesmoke-light); 246 | width: 100%; 247 | overflow: scroll; 248 | } 249 | 250 | .title--readable { 251 | font-weight: 700; 252 | } 253 | .title--machine { 254 | font-family: $ff-mono; 255 | } 256 | -------------------------------------------------------------------------------- /src/lib/url/filters-juissy.js: -------------------------------------------------------------------------------- 1 | const Groups = { 2 | and: (...members) => { 3 | return Groups.group(members, 'AND'); 4 | }, 5 | 6 | or: (...members) => { 7 | return Groups.group(members, 'OR'); 8 | }, 9 | 10 | group: (members, conjunction) => { 11 | return { 12 | conjunction, 13 | members, 14 | }; 15 | }, 16 | }; 17 | 18 | const Conditions = function(path, value) { 19 | return Conditions.eq(path, value); 20 | }; 21 | 22 | Conditions.and = Groups.and; 23 | 24 | Conditions.or = Groups.or; 25 | 26 | Conditions.eq = (path, value) => { 27 | return Conditions.condition(path, value, '='); 28 | }; 29 | 30 | Conditions.notEq = (path, value) => { 31 | return Conditions.condition(path, value, '<>'); 32 | }; 33 | 34 | Conditions.gt = (path, value) => { 35 | return Conditions.condition(path, value, '>'); 36 | }; 37 | 38 | Conditions.gtEq = (path, value) => { 39 | return Conditions.condition(path, value, '>='); 40 | }; 41 | 42 | Conditions.lt = (path, value) => { 43 | return Conditions.condition(path, value, '<'); 44 | }; 45 | 46 | Conditions.ltEq = (path, value) => { 47 | return Conditions.condition(path, value, '<='); 48 | }; 49 | 50 | Conditions.startsWith = (path, value) => { 51 | return Conditions.condition(path, value, 'STARTS_WITH'); 52 | }; 53 | 54 | Conditions.contains = (path, value) => { 55 | return Conditions.condition(path, value, 'CONTAINS'); 56 | }; 57 | 58 | Conditions.endsWith = (path, value) => { 59 | return Conditions.condition(path, value, 'ENDS_WITH'); 60 | }; 61 | 62 | Conditions.in = (path, value) => { 63 | return Conditions.condition(path, value, 'IN'); 64 | }; 65 | 66 | Conditions.notIn = (path, value) => { 67 | return Conditions.condition(path, value, 'NOT IN'); 68 | }; 69 | 70 | Conditions.between = (path, value) => { 71 | return Conditions.condition(path, value, 'BETWEEN'); 72 | }; 73 | 74 | Conditions.notBetween = (path, value) => { 75 | return Conditions.condition(path, value, 'NOT BETWEEN'); 76 | }; 77 | 78 | Conditions.null = path => { 79 | return Conditions.condition(path, undefined, 'IS NULL'); 80 | }; 81 | 82 | Conditions.notNull = path => { 83 | return Conditions.condition(path, undefined, 'IS NOT NULL'); 84 | }; 85 | 86 | Conditions.condition = (path, value, operator) => { 87 | return Conditions.validate({ path, value, operator }); 88 | }; 89 | 90 | Conditions.unaryOperators = new Set([ 91 | '=', 92 | '<>', 93 | '>', 94 | '>=', 95 | '<', 96 | '<=', 97 | 'STARTS_WITH', 98 | 'CONTAINS', 99 | 'ENDS_WITH', 100 | ]); 101 | Conditions.unaryValueTypes = new Set(['string', 'boolean', 'number']); 102 | Conditions.binaryOperators = new Set(['BETWEEN', 'NOT BETWEEN']); 103 | Conditions.stringOperators = new Set(['STARTS_WITH', 'CONTAINS', 'ENDS_WITH']); 104 | Conditions.nullOperators = new Set(['IS NULL', 'IS NOT NULL']); 105 | 106 | Conditions.validate = condition => { 107 | if ( 108 | condition.operator instanceof Function || 109 | condition.value instanceof Function 110 | ) { 111 | return condition; 112 | } 113 | if (Conditions.nullOperators.has(condition.operator)) { 114 | if (typeof condition.value !== 'undefined') { 115 | throw new Error( 116 | `Conditions with an '${ 117 | condition.operator 118 | }' operator must not specify a value.`, 119 | ); 120 | } 121 | } else if (Conditions.unaryOperators.has(condition.operator)) { 122 | if (!Conditions.unaryValueTypes.has(typeof condition.value)) { 123 | throw new Error( 124 | `The '${condition.operator}' operator requires a single value.`, 125 | ); 126 | } 127 | if ( 128 | Conditions.stringOperators.has(condition.operator) && 129 | typeof condition.value != 'string' 130 | ) { 131 | throw new Error( 132 | `The '${ 133 | condition.operator 134 | }' operator requires that the condition value be a string.`, 135 | ); 136 | } 137 | } else { 138 | if (!Array.isArray(condition.value)) { 139 | throw new Error( 140 | `The '${condition.operator}' operator requires an array of values.`, 141 | ); 142 | } 143 | if ( 144 | Conditions.binaryOperators.has(condition.operator) && 145 | condition.value.length !== 2 146 | ) { 147 | throw new Error( 148 | `The '${ 149 | condition.operator 150 | }' operator requires an array of exactly 2 values.`, 151 | ); 152 | } 153 | } 154 | return condition; 155 | }; 156 | 157 | Conditions.process = (condition, parameters) => { 158 | let revalidate = false; 159 | const replace = item => { 160 | if (item instanceof Function) { 161 | revalidate = true; 162 | return item(parameters); 163 | } 164 | return item; 165 | }; 166 | const processed = { 167 | path: replace(condition.path), 168 | operator: replace(condition.operator), 169 | }; 170 | if (!Conditions.nullOperators.has(processed.operator)) { 171 | processed.value = replace(condition.value); 172 | } 173 | if (revalidate) { 174 | Conditions.validate(processed); 175 | } 176 | return processed; 177 | }; 178 | 179 | export { Conditions }; 180 | -------------------------------------------------------------------------------- /src/components/param-ui/sort-loader.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useContext } from 'react'; 2 | 3 | import { Add, Done } from '../icon'; 4 | import useSchemaLoader from '../../hooks/use-schema-loader'; 5 | import { LocationContext } from '../../contexts/location'; 6 | import SortWidget from './sort-widget'; 7 | import { checkIncludesPath, isEmpty, toggleSetEntry } from '../../utils'; 8 | import useSchema from '../../hooks/use-schema'; 9 | 10 | import ParamSelect from './param-select'; 11 | import { processAttributeValue } from '../../lib/schema/normalize'; 12 | 13 | const Attribute = ({ name, path }) => { 14 | const { sort, setSort } = useContext(LocationContext); 15 | 16 | const handleClick = () => { 17 | setSort([...sort, {path, direction: 'ASC' }]); 18 | }; 19 | 20 | return ( 21 |
    22 | 25 | {name} 26 |
    27 | ); 28 | }; 29 | 30 | const AttributeValue = ({ forPath, attribute, includeEnabled }) => { 31 | const { name, value } = attribute; 32 | 33 | if (!value) { 34 | return ; 35 | } 36 | 37 | const { type, properties } = processAttributeValue(value); 38 | 39 | return type === 'object' ? ( 40 |
    41 | {name} 42 | {!isEmpty(properties) && ( 43 |
    44 | {Object.entries(properties).map(([key, value], index) => ( 45 | 50 | ))} 51 |
    52 | )} 53 |
    54 | ) : ( 55 | 56 | ); 57 | }; 58 | 59 | const SortLoaderList = ({ path, load }) => { 60 | const { forPath } = path; 61 | const { include } = useContext(LocationContext); 62 | const includesEnabled = checkIncludesPath(include, forPath); 63 | const schema = useSchema(forPath); 64 | 65 | const handleChange = e => { 66 | load({ forPath: [...forPath, e.target.value] }); 67 | }; 68 | 69 | if (schema) { 70 | const { attributes, relationships } = schema; 71 | 72 | return ( 73 |
    74 |
    75 | {attributes.map(attribute => ( 76 | 83 | ))} 84 |
    85 | 86 | 87 | {relationships.map(relationship => ( 88 | 94 | ))} 95 | 96 |
    97 | ); 98 | } 99 | 100 | return <>; 101 | }; 102 | 103 | const SortLoaderForm = ({ hide }) => { 104 | const { sort } = useContext(LocationContext); 105 | const [values, setValues] = useState(new Set([])); 106 | const { paths, load } = useSchemaLoader([]); 107 | 108 | const addAttribute = attribute => { 109 | const current = new Set([...values]); 110 | setValues(toggleSetEntry(current, attribute)); 111 | }; 112 | 113 | const handleSubmit = e => { 114 | e.preventDefault(); 115 | }; 116 | 117 | return ( 118 |
    119 |
    120 |
    121 | {paths.map((path, index) => ( 122 | 130 | ))} 131 |
    132 |
    133 | 140 |
    141 |
    142 | {sort.map(param => ( 143 | 144 | ))} 145 |
    146 | ); 147 | 148 | }; 149 | 150 | const SortLoader = () => { 151 | const [visible, setVisible] = useState(false); 152 | 153 | const showForm = () => { 154 | setVisible(true); 155 | }; 156 | 157 | const hideForm = () => { 158 | setVisible(false); 159 | }; 160 | 161 | return visible ? ( 162 | 163 | ) : ( 164 |
    165 | 172 |
    173 | ); 174 | }; 175 | 176 | export default SortLoader; 177 | -------------------------------------------------------------------------------- /src/components/param-ui/filter-loader.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useContext } from 'react'; 2 | 3 | import useSchema from '../../hooks/use-schema'; 4 | import useSchemaLoader from '../../hooks/use-schema-loader'; 5 | import { checkIncludesPath, isEmpty, toggleSetEntry } from '../../utils'; 6 | import { LocationContext } from '../../contexts/location'; 7 | import FilterWidget from './filter-widget'; 8 | import useFilter from '../../hooks/use-filter'; 9 | import { processAttributeValue } from '../../lib/schema/normalize'; 10 | import ParamSelect from './param-select'; 11 | import { Add, Done } from '../icon'; 12 | 13 | const Attribute = ({ name, filterName }) => { 14 | const { setFilter } = useContext(LocationContext); 15 | 16 | const handleClick = () => { 17 | setFilter(filterName, 'create'); 18 | }; 19 | 20 | return ( 21 |
    22 | 25 | {name} 26 |
    27 | ); 28 | }; 29 | 30 | const AttributeValue = ({ forPath, attribute, includeEnabled }) => { 31 | const { name, value } = attribute; 32 | 33 | if (!value) { 34 | return ; 35 | } 36 | 37 | const { type, properties } = processAttributeValue(value); 38 | 39 | return type === 'object' ? ( 40 |
    41 | {name} 42 | {!isEmpty(properties) && ( 43 |
    44 | {Object.entries(properties).map(([key, value], index) => ( 45 | 50 | ))} 51 |
    52 | )} 53 |
    54 | ) : ( 55 | 56 | ); 57 | }; 58 | const FilterLoaderList = ({ path, load }) => { 59 | const { forPath } = path; 60 | const { include } = useContext(LocationContext); 61 | const includesEnabled = checkIncludesPath(include, forPath); 62 | const schema = useSchema(forPath); 63 | 64 | const handleChange = e => { 65 | load({ forPath: [...forPath, e.target.value] }); 66 | }; 67 | 68 | if (schema) { 69 | const { attributes, relationships } = schema; 70 | 71 | return ( 72 |
    73 |
    74 | {attributes.map(attribute => ( 75 | 82 | ))} 83 |
    84 | 85 | 86 | {relationships.map(relationship => ( 87 | 93 | ))} 94 | 95 |
    96 | ); 97 | } 98 | 99 | return <>; 100 | }; 101 | 102 | const FilterLoaderForm = ({ visible, hide }) => { 103 | 104 | const [values, setValues] = useState(new Set([])); 105 | const { paths, load } = useSchemaLoader([]); 106 | const { filter } = useContext(LocationContext); 107 | const { filters } = useFilter(filter); 108 | 109 | const addAttribute = attribute => { 110 | const current = new Set([...values]); 111 | setValues(toggleSetEntry(current, attribute)); 112 | }; 113 | 114 | const handleSubmit = e => { 115 | e.preventDefault(); 116 | }; 117 | 118 | return ( 119 |
    120 |
    121 |
    122 | {paths.map((path, index) => ( 123 | 131 | ))} 132 |
    133 |
    134 | 141 |
    142 |
    143 | {filters.map((filter, index) => ( 144 | 145 | ))} 146 |
    147 | ); 148 | 149 | }; 150 | 151 | const FilterLoader = () => { 152 | const [visible, setVisible] = useState(false); 153 | 154 | const showForm = () => { 155 | setVisible(true); 156 | }; 157 | 158 | const hideForm = () => { 159 | setVisible(false); 160 | }; 161 | 162 | return visible ? ( 163 | 164 | ) : ( 165 |
    166 | 173 |
    174 | ); 175 | }; 176 | 177 | export default FilterLoader; 178 | -------------------------------------------------------------------------------- /src/contexts/location.js: -------------------------------------------------------------------------------- 1 | import React, { createContext, useState, useEffect, useReducer } from 'react'; 2 | import { extract, toggleSetEntry, removeEmpty } from '../utils'; 3 | 4 | import { request } from '../utils/request'; 5 | import { 6 | parseJsonApiUrl, 7 | compileJsonApiUrl, 8 | getEntryPointForUrl, getBaseUrl, 9 | } from '../lib/url/url'; 10 | import Document from '../lib/jsonapi-objects/document'; 11 | import { newFilter, optimizeFilter } from '../lib/url/filter'; 12 | import {optimizeInclude} from "../lib/url/include"; 13 | 14 | const LocationContext = createContext({}); 15 | 16 | const filterReducer = (state, action) => { 17 | switch (action.type) { 18 | case 'refresh': 19 | return { ...action.updated }; 20 | case 'create': 21 | return optimizeFilter({ ...state, ...newFilter(action.name) }); 22 | case 'update': 23 | return optimizeFilter({ ...state, ...action.updated }); 24 | break; 25 | case 'delete': 26 | const { [action.name]: current, ...remaining } = state; 27 | return remaining; 28 | default: 29 | return { ...state }; 30 | } 31 | }; 32 | 33 | const Location = ({ landingUrl, readOnly, children }) => { 34 | // Set the location state to a parsed url and a compiled url. 35 | const [parsedUrl, setParsedUrl] = useState(parseJsonApiUrl(landingUrl)); 36 | const [locationUrl, setLocationUrl] = useState(compileJsonApiUrl(parsedUrl)); 37 | const [responseDocument, setDocument] = useState(null); 38 | const [entrypointURL, setEntrypointURL] = useState( 39 | getEntryPointForUrl(locationUrl), 40 | ); 41 | const [entrypointDocument, setEntrypointDocument] = useState(null); 42 | 43 | const setUrl = newLocationUrl => { 44 | window.history.pushState( 45 | {}, 46 | '', 47 | `?location=${encodeURIComponent(newLocationUrl)}`, 48 | ); 49 | setParsedUrl(parseJsonApiUrl(newLocationUrl)); 50 | }; 51 | 52 | // Extract and surface useful url components in the location context as 53 | // readable values. 54 | const { filter: queryFilter, fields, include, sort } = parsedUrl.query; 55 | const { fragment } = parsedUrl; 56 | 57 | const [filter, dispatchFilter] = useReducer(filterReducer, queryFilter); 58 | 59 | // Takes a single query parameter and updates the parsed url. 60 | const updateQuery = param => { 61 | setUrl( 62 | compileJsonApiUrl( 63 | Object.assign({}, parsedUrl, { 64 | query: Object.assign({}, parsedUrl.query, removeEmpty(param)), 65 | }), 66 | ), 67 | ); 68 | }; 69 | 70 | // If the parsed url is updated, compile it and update the location url. 71 | useEffect(() => setLocationUrl(compileJsonApiUrl(parsedUrl)), [parsedUrl]); 72 | useEffect(() => { 73 | setEntrypointURL(getEntryPointForUrl(locationUrl)); 74 | request(locationUrl) 75 | .then(Document.parse) 76 | .then(document => { 77 | document.getSchema().then(() => { 78 | setDocument(document); 79 | }); 80 | }); 81 | }, [locationUrl]); 82 | useEffect(() => { 83 | window.onpopstate = () => { 84 | const historyLocationURL = new URL( 85 | document.location.href, 86 | ).searchParams.get('location'); 87 | if (historyLocationURL) { 88 | setParsedUrl(parseJsonApiUrl(historyLocationURL)); 89 | } 90 | }; 91 | }, []); 92 | 93 | useEffect(() => { 94 | dispatchFilter({ type: 'refresh', updated: queryFilter }); 95 | }, [document]); 96 | 97 | useEffect(() => { 98 | updateQuery({ filter }); 99 | }, [filter]); 100 | 101 | useEffect(() => { 102 | request(entrypointURL) 103 | .then(Document.parse) 104 | .then(document => { 105 | document.getSchema().then(() => { 106 | setEntrypointDocument(document); 107 | }); 108 | }); 109 | }, [entrypointURL]); 110 | 111 | return ( 112 | { 127 | dispatchFilter({ name, type, updated }); 128 | }, 129 | toggleField: (type, field) => { 130 | const queryFields = extract(parsedUrl, 'query.fields'); 131 | const fieldSet = queryFields.hasOwnProperty(type) 132 | ? queryFields[type] 133 | : new Set(); 134 | 135 | const newParam = Object.assign({}, queryFields, { 136 | [type]: toggleSetEntry(fieldSet, field), 137 | }); 138 | updateQuery({ fields: newParam }); 139 | }, 140 | clearFieldSet: type => { 141 | const newFieldsParam = parsedUrl.query.fields; 142 | delete newFieldsParam[type]; 143 | updateQuery({ fields: newFieldsParam }); 144 | }, 145 | toggleInclude: path => { 146 | const includeList = extract(parsedUrl, `query.include`); 147 | updateQuery({ 148 | include: optimizeInclude(Array.from(toggleSetEntry(new Set(includeList), path))), 149 | }); 150 | }, 151 | setSort: newParam => updateQuery({ sort: newParam }), 152 | setFragment: fragment => 153 | setParsedUrl(Object.assign({}, parsedUrl, { fragment })), 154 | }} 155 | > 156 | {children} 157 | 158 | ); 159 | }; 160 | 161 | export { Location, LocationContext }; 162 | -------------------------------------------------------------------------------- /src/lib/url/url.js: -------------------------------------------------------------------------------- 1 | import { isEmpty } from '../../utils'; 2 | 3 | const entrypointPath = process.env.ENTRYPOINT_PATH; 4 | 5 | const queryParams = ['include', 'filter', 'fields', 'sort', 'page']; 6 | 7 | export const parseListParameter = value => { 8 | return value ? value.split(',') : []; 9 | }; 10 | 11 | export const parseSortParameter = param => { 12 | return parseListParameter(param).map(value => { 13 | return value.charAt(0) === '-' 14 | ? {path: value.slice(1), direction: 'DESC'} 15 | : {path: value, direction: 'ASC'}; 16 | }); 17 | }; 18 | 19 | export const parseQueryParameterFamily = (baseName, query) => { 20 | const members = []; 21 | 22 | const lex = (key, value) => { 23 | const stateMachine = (mode, token, index) => { 24 | if (key.length === index) { 25 | return token.length ? { [token]: value } : value; 26 | } 27 | const current = key.charAt(index); 28 | switch (mode) { 29 | case 'word': 30 | return ['[', ']'].includes(current) 31 | ? stateMachine('bracket', token, index) 32 | : stateMachine(mode, token + current, index + 1); 33 | case 'bracket': 34 | switch (current) { 35 | case '[': 36 | return { [token]: stateMachine('word', '', index + 1) }; 37 | case ']': 38 | return stateMachine('bracket', token, index + 1); 39 | } 40 | } 41 | }; 42 | return stateMachine('word', '', 0); 43 | }; 44 | 45 | for (let [key, value] of query) { 46 | const lexed = lex(key, value); 47 | if (lexed.hasOwnProperty(baseName)) { 48 | members.push(lexed); 49 | } 50 | } 51 | 52 | const merge = (a, b) => { 53 | if (typeof a !== 'object' && typeof b !== 'object') { 54 | return new Set(Set.prototype.isPrototypeOf(a) ? [...a, b] : [a, b]); 55 | } else { 56 | return Object.keys(b).reduce((merged, key) => { 57 | return Object.assign({}, merged, { 58 | [key]: merged.hasOwnProperty(key) ? merge(a[key], b[key]) : b[key], 59 | }); 60 | }, a); 61 | } 62 | }; 63 | 64 | return members.length ? members.reduce(merge, {})[baseName] : {}; 65 | }; 66 | 67 | export const parseDictionaryParameter = (baseName, query) => { 68 | const family = parseQueryParameterFamily(baseName, query); 69 | return Object.keys(family).reduce((dictionary, key) => { 70 | return Object.assign({}, dictionary, { 71 | [key]: new Set(parseListParameter(family[key])), 72 | }); 73 | }, {}); 74 | }; 75 | 76 | export const compileListParameter = value => { 77 | return [...value].join(','); 78 | }; 79 | 80 | export const compileSortParameter = (baseName, value) => { 81 | return `${baseName}=${compileListParameter(value.map(({path, direction}) => { 82 | return direction === 'ASC' ? path : `-${path}`; 83 | }))}`; 84 | }; 85 | 86 | export const compileDictionaryParameter = (baseName, type, query) => { 87 | return `${baseName}[${type}]=${compileListParameter(query[type])}`; 88 | }; 89 | 90 | export const compileQueryParameterFamily = (baseName, query) => { 91 | const create = (path, value) => ({ [path]: encodeURIComponent(value) }); 92 | const extract = (query, path = '') => { 93 | return Object.keys(query).reduce((extracted, key) => { 94 | const value = query[key]; 95 | const current = `${path}[${key}]`; 96 | 97 | if (Set.prototype.isPrototypeOf(value)) { 98 | [...value].forEach(val => { 99 | extracted.push(create(`${current}[]`, val)); 100 | }); 101 | } else if (typeof value === 'string') { 102 | extracted.push(create(current, value)); 103 | } else { 104 | extracted.push(...extract(value, current)); 105 | } 106 | 107 | return extracted; 108 | }, []); 109 | }; 110 | 111 | return extract(query) 112 | .map(Object.entries) 113 | .map(entries => entries.pop()) 114 | .map(([key, value]) => `${baseName}${key}=${value}`) 115 | .join('&'); 116 | }; 117 | 118 | export const compileQueryParameter = (baseName, query) => { 119 | const queryValue = query[baseName]; 120 | 121 | switch (baseName) { 122 | case 'fields': 123 | return Object.keys(queryValue) 124 | .map(type => compileDictionaryParameter(baseName, type, queryValue)) 125 | .join('&'); 126 | case 'filter': 127 | return compileQueryParameterFamily(baseName, queryValue); 128 | case 'sort': 129 | return compileSortParameter(baseName, queryValue); 130 | case 'page': 131 | return compileQueryParameterFamily(baseName, queryValue); 132 | 133 | default: 134 | return `${baseName}=${compileListParameter(queryValue)}`; 135 | } 136 | }; 137 | 138 | export const parseJsonApiUrl = fromUrl => { 139 | const url = new URL(fromUrl); 140 | const query = url.searchParams; 141 | 142 | return { 143 | protocol: url.protocol, 144 | host: url.host, 145 | port: url.port, 146 | path: url.pathname, 147 | query: { 148 | filter: parseQueryParameterFamily('filter', query.entries()), 149 | include: parseListParameter(query.get('include')), 150 | fields: parseDictionaryParameter('fields', query.entries()), 151 | sort: parseSortParameter(query.get('sort')), 152 | page: parseQueryParameterFamily('page', query.entries()) 153 | }, 154 | fragment: url.hash, 155 | }; 156 | }; 157 | 158 | export const compileJsonApiUrl = ({ 159 | protocol, 160 | host, 161 | port = '', 162 | path = '', 163 | query = {}, 164 | fragment = '', 165 | }) => { 166 | const queryString = queryParams 167 | .filter(name => query[name] && !isEmpty(query[name])) 168 | .map(name => compileQueryParameter(name, query)) 169 | .join('&'); 170 | 171 | return `${protocol}//${host}${path}${ 172 | fragment.length ? '#' + fragment : '' 173 | }${queryString.length ? '?' + queryString : ''}`; 174 | }; 175 | 176 | export const getBaseUrl = url => { 177 | const { protocol, host, port, path } = parseJsonApiUrl(url); 178 | return compileJsonApiUrl({ protocol, host, port, path}); 179 | }; 180 | 181 | export const getEntryPointForUrl = url => { 182 | const { protocol, host, port } = parseJsonApiUrl(url); 183 | return compileJsonApiUrl({ protocol, host, port, path: entrypointPath }); 184 | }; 185 | -------------------------------------------------------------------------------- /src/components/result-ui/summary.js: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | import { LinkElement } from "../link"; 3 | import { FieldFocusContext } from "../../contexts/field-focus"; 4 | import { checkIncludesPath, isEmpty } from "../../utils"; 5 | import { LocationContext } from "../../contexts/location"; 6 | 7 | const FieldValue = ({value}) => { 8 | const json = JSON.stringify(value, null, ' '); 9 | return ( 10 | value && typeof value === "object" 11 | ?
    {json}
    12 | : json 13 | ); 14 | }; 15 | 16 | const FieldRow = ({ fieldPath, fieldValue, crumbPath = [], isRelationship, resourceObject }) => { 17 | const path = (crumbPath||[]).concat([fieldPath]); 18 | const { changeFocus } = useContext(FieldFocusContext); 19 | const { include } = useContext(LocationContext); 20 | const hasRelated = isRelationship && !isEmpty(resourceObject.getRelated(fieldPath)); 21 | const showDownLink = isRelationship && hasRelated && checkIncludesPath(include, path); 22 | 23 | const handleClick = () => { 24 | changeFocus('focusDown', {field: fieldPath, on: resourceObject}); 25 | }; 26 | 27 | return ( 28 |
    29 |
    30 | {path.join(' > ')} 31 | {showDownLink && } 34 |
    35 |
    { 36 | Array.isArray(fieldValue) 37 | ? ( 38 | fieldValue.length ?
      39 | {fieldValue.map((value, i) => (
    • ))} 40 |
    : empty 41 | ) 42 | : 43 | }
    44 |
    45 | ); 46 | }; 47 | 48 | const Summary = ({responseDocument}) => { 49 | const { focus, changeFocus } = useContext(FieldFocusContext); 50 | 51 | let resourceObjects = [responseDocument.getData()].flat(); 52 | 53 | let currentPath = focus.path; 54 | while (currentPath && currentPath.length > 0) { 55 | const [current, ...remaining] = currentPath; 56 | resourceObjects = resourceObjects 57 | .flatMap(o => o.getRelated(current)) 58 | .filter(o => !!o); 59 | currentPath = remaining; 60 | } 61 | 62 | return (resourceObjects.length &&
    63 |
      64 | {resourceObjects.map((resourceObject, i) => { 65 | const type = resourceObject.getType(), id = resourceObject.getID(); 66 | const isInclude = !!resourceObject.getRelatedBy(); 67 | const root = resourceObject.getRootResourceObject(); 68 | const withFocus = isRelationship => ([name, value]) => ({ 69 | name, 70 | value: isRelationship ? value.data : value, 71 | isFocused: !focus.field || name === focus.field, 72 | isRelationship, 73 | }); 74 | const identification = [['type', type], ['id', id]].map(withFocus(false)); 75 | const attributes = Object.entries(resourceObject.getAttributes()).map(withFocus(false)); 76 | const relationships = Object.entries(resourceObject.getRelationships()).map(withFocus(true)); 77 | const fields = identification.concat(attributes).concat(relationships); 78 | const links = Object.entries(resourceObject.getOutgoingLinks()); 79 | const showExpand = focus.field; 80 | const isZoomed = focus.on && (Array.isArray(focus.on) 81 | ? focus.on.some(onObject => onObject.same(resourceObject)) 82 | : resourceObject.same(focus.on) 83 | ); 84 | const index = [isInclude ? responseDocument.getIncluded() : responseDocument.getData()].flat().findIndex(obj => obj.matches(resourceObject.getIdentifier())); 85 | let documentPointer = `/${isInclude ? 'included' : 'data'}`; 86 | if (isInclude || responseDocument.isCollectionDocument()) { 87 | documentPointer += `/${index}`; 88 | } 89 | const rowClass = ['results__row'].concat(focus.on && !isZoomed ? ['results__row--hidden'] : []); 90 | let above = true; 91 | return ( 92 |
    • 93 |
      94 | root: {root.getID()} ({root.getType()}) 95 | pointer: {documentPointer} 96 |
      97 |
        98 | {fields.map(({name, value, isFocused, isRelationship}, j) => { 99 | if (isFocused) { 100 | above = false; 101 | } 102 | let fieldClass = 'results__field'; 103 | fieldClass += focus.field && !isFocused ? ` results__field--hidden results__field--hidden-${above ? 'above' : 'below'}` : ''; 104 | return ( 105 |
      • 106 | 116 |
      • 117 | ); 118 | })} 119 |
      120 |
      121 |
        122 | {!!resourceObject.getRelatedBy() &&
      • } 125 | {showExpand &&
      • } 128 | {links.map(([key, link], index) => ( 129 |
      • 130 | 131 |
      • 132 | ))} 133 |
      134 |
      135 |
    • 136 | ) 137 | })} 138 |
    139 | {focus.on && } 140 |
    ); 141 | }; 142 | 143 | export default Summary; 144 | -------------------------------------------------------------------------------- /src/lib/schema/schema-parser.js: -------------------------------------------------------------------------------- 1 | import $RefParser from 'json-schema-ref-parser'; 2 | import { getAttributes, getRelationships } from './normalize'; 3 | import { request } from '../../utils/request'; 4 | import { extract } from '../../utils'; 5 | import Document from '../jsonapi-objects/document'; 6 | import { compileJsonApiUrl, parseJsonApiUrl } from '../url/url'; 7 | 8 | export default class SchemaParser { 9 | constructor() { 10 | this.schemaCache = {}; 11 | this.inferenceCache = {}; 12 | } 13 | 14 | async parse(root, forPath = []) { 15 | if (typeof root === 'string') { 16 | return this.loadSchema(root).then(schema => 17 | this.parseSchema(schema, forPath), 18 | ); 19 | } 20 | const links = root.getLinks(); 21 | const describedByURL = extract(links, 'describedby.href'); 22 | if (describedByURL) { 23 | return this.parseSchema(await this.loadSchema(describedByURL), forPath); 24 | } 25 | const selfURL = extract(links, 'self.href'); 26 | const parsedSelfURL = parseJsonApiUrl(selfURL); 27 | if (Object.keys(parsedSelfURL.query.fields).length) { 28 | const completeFieldsetUrl = Object.assign({}, parsedSelfURL, { 29 | query: Object.assign({}, parsedSelfURL.query, { fields: [] }), 30 | }); 31 | return this.parse( 32 | Document.parse(await request(compileJsonApiUrl(completeFieldsetUrl))), 33 | forPath, 34 | ); 35 | } 36 | const baseResourceURL = compileJsonApiUrl( 37 | Object.assign({}, parsedSelfURL, { protocol: 'inferred:', query: {} }), 38 | ); 39 | const inferredSchema = this.inferSchema(root, forPath); 40 | return !forPath.length 41 | ? this.mergeWithCachedInference(baseResourceURL, inferredSchema) 42 | : inferredSchema; 43 | } 44 | 45 | parseSchema(schema, forPath) { 46 | const dataSchema = extract(schema, 'definitions.data'); 47 | const relationships = getRelationships(dataSchema); 48 | const discovered = { 49 | title: extract(schema, 'title'), 50 | type: dataSchema ? extract(dataSchema, (dataSchema.type === 'array' ? 'items.' : '') + 'definitions.type.const') : undefined, 51 | attributes: getAttributes(dataSchema), 52 | relationships, 53 | links: extract(extract(schema, 'allOf', [{}, {}])[1], 'links', []), 54 | }; 55 | if (forPath.length) { 56 | const [next, ...further] = forPath; 57 | const targetSchema = relationships.find(obj => obj.name === next).value; 58 | return targetSchema ? this.parse(targetSchema, further) : null; 59 | } else { 60 | return discovered; 61 | } 62 | } 63 | 64 | inferSchema(responseDocument, forPath) { 65 | if (responseDocument.isEmptyDocument()) { 66 | return null; 67 | } 68 | let inferred; 69 | if (forPath.length) { 70 | const [next, ...further] = forPath; 71 | const documentData = [responseDocument.getData()] 72 | .flat() 73 | .reduce((grouped, resourceObject) => { 74 | return Object.assign(grouped, { 75 | [resourceObject.getType()]: [ 76 | ...(grouped[resourceObject.getType()] || []), 77 | resourceObject, 78 | ], 79 | }); 80 | }, {}); 81 | inferred = Object.entries(documentData) 82 | .flatMap(([type, groupedData]) => { 83 | const relatedData = groupedData 84 | .flatMap(resourceObject => { 85 | return resourceObject.getRelated(next); 86 | }) 87 | .reduce((raw, item) => raw.concat(item ? [item.raw] : []), []); 88 | const included = responseDocument.getIncluded().map(item => item.raw); 89 | const syntheticDocument = Document.parse({ 90 | data: relatedData, 91 | included, 92 | }); 93 | return this.mergeWithCachedInference( 94 | `${type}/${further.join('/')}`, 95 | this.inferSchema(syntheticDocument, further), 96 | ); 97 | }) 98 | .reduce(this.mergeResourceObjectSchema); 99 | } else { 100 | [responseDocument.getData()].flat().forEach(item => { 101 | inferred = this.buildInferenceFromResourceObject(item); 102 | }); 103 | } 104 | return inferred; 105 | } 106 | 107 | buildInferenceFromResourceObject(resourceObject) { 108 | const type = resourceObject.getType(); 109 | 110 | const inference = { 111 | type, 112 | attributes: Object.keys(resourceObject.getAttributes()).map(name => { 113 | return { name }; 114 | }), 115 | relationships: Object.keys(resourceObject.getRelationships()).map( 116 | name => { 117 | return { name }; 118 | }, 119 | ), 120 | }; 121 | 122 | return this.mergeWithCachedInference(inference.type, inference); 123 | } 124 | 125 | mergeWithCachedInference(key, inference) { 126 | if (!inference) { 127 | return this.inferenceCache[key] || inference; 128 | } 129 | this.inferenceCache[key] = this.mergeResourceObjectSchema( 130 | inference, 131 | this.inferenceCache[key] || { 132 | type: inference.type, 133 | attributes: [], 134 | relationships: [], 135 | }, 136 | ); 137 | return this.inferenceCache[key]; 138 | } 139 | 140 | mergeResourceObjectSchema(schema, otherSchema) { 141 | if (schema.type !== otherSchema.type) { 142 | return schema; 143 | } 144 | 145 | const mergeFields = (merged, otherField) => 146 | merged.concat( 147 | merged.some(knownField => knownField.name === otherField.name) 148 | ? [] 149 | : [otherField], 150 | ); 151 | 152 | const mergedSchema = { 153 | type: schema.type, 154 | attributes: [], 155 | relationships: [], 156 | }; 157 | mergedSchema.attributes.push( 158 | ...schema.attributes.reduce(mergeFields, otherSchema.attributes), 159 | ); 160 | mergedSchema.relationships.push( 161 | ...schema.relationships.reduce(mergeFields, otherSchema.relationships), 162 | ); 163 | 164 | return mergedSchema; 165 | } 166 | 167 | loadSchema(schemaId) { 168 | let schemaPromise; 169 | if (!this.schemaCache.hasOwnProperty(schemaId)) { 170 | const publish = (success, result) => 171 | this.schemaCache[schemaId].forEach(([resolve, reject]) => 172 | success ? resolve(result) : reject(result), 173 | ); 174 | $RefParser 175 | .dereference(schemaId) 176 | .then(result => { 177 | publish(true, result); 178 | this.schemaCache[schemaId] = result; 179 | }) 180 | .catch(result => publish(false, result)); 181 | } 182 | if ( 183 | !this.schemaCache.hasOwnProperty(schemaId) || 184 | Array.isArray(this.schemaCache[schemaId]) 185 | ) { 186 | schemaPromise = new Promise( 187 | (resolve, reject) => 188 | (this.schemaCache[schemaId] = [ 189 | ...(this.schemaCache[schemaId] || []), 190 | [resolve, reject], 191 | ]), 192 | ); 193 | } else { 194 | schemaPromise = Promise.resolve(this.schemaCache[schemaId]); 195 | } 196 | return schemaPromise; 197 | } 198 | } 199 | -------------------------------------------------------------------------------- /src/lib/url/url.test.js: -------------------------------------------------------------------------------- 1 | import { 2 | parseJsonApiUrl, 3 | compileJsonApiUrl, 4 | compileQueryParameterFamily, 5 | } from './url'; 6 | 7 | const baseUrl = 'http://drupal.test/jsonapi'; 8 | const articleUrl = `${baseUrl}/node/article`; 9 | 10 | const local = { 11 | url: 'http://127.0.0.1:8080/', 12 | parsed: { 13 | protocol: 'http:', 14 | host: '127.0.0.1:8080', 15 | port: '8080', 16 | path: '/', 17 | query: { 18 | filter: {}, 19 | include: [], 20 | fields: {}, 21 | page: {}, 22 | sort: [], 23 | }, 24 | fragment: '', 25 | } 26 | } 27 | 28 | const base = { 29 | url: 'http://drupal.test/jsonapi', 30 | parsed: { 31 | protocol: 'http:', 32 | host: 'drupal.test', 33 | port: '', 34 | path: '/jsonapi', 35 | query: { 36 | filter: {}, 37 | include: [], 38 | fields: {}, 39 | page: {}, 40 | sort: [], 41 | }, 42 | fragment: '', 43 | }, 44 | }; 45 | 46 | const article = { 47 | url: `${base.url}/node/article`, 48 | parsed: { 49 | protocol: 'http:', 50 | host: 'drupal.test', 51 | port: '', 52 | path: '/jsonapi/node/article', 53 | query: { 54 | filter: {}, 55 | include: [], 56 | fields: {}, 57 | page: {}, 58 | sort: [], 59 | }, 60 | fragment: '', 61 | }, 62 | }; 63 | 64 | const include = { 65 | urls: ['include=uid', 'include=uid,node_type'], 66 | parsed: [ 67 | { 68 | protocol: 'http:', 69 | host: 'drupal.test', 70 | port: '', 71 | path: '/jsonapi/node/article', 72 | query: { 73 | filter: {}, 74 | include: ['uid'], 75 | fields: {}, 76 | page: {}, 77 | sort: [], 78 | }, 79 | fragment: '', 80 | }, 81 | { 82 | protocol: 'http:', 83 | host: 'drupal.test', 84 | port: '', 85 | path: '/jsonapi/node/article', 86 | query: { 87 | filter: {}, 88 | include: ['uid', 'node_type'], 89 | fields: {}, 90 | page: {}, 91 | sort: [], 92 | }, 93 | fragment: '', 94 | }, 95 | ], 96 | }; 97 | 98 | const fields = { 99 | urls: [ 100 | 'fields[node--article]=drupal_internal__nid', 101 | 'fields[node--article]=drupal_internal__nid,status', 102 | 'fields[node--article]=drupal_internal__nid&fields[user_role--user_role]=drupal_internal__id', 103 | ], 104 | parsed: [ 105 | { 106 | protocol: 'http:', 107 | host: 'drupal.test', 108 | port: '', 109 | path: '/jsonapi/node/article', 110 | query: { 111 | filter: {}, 112 | include: [], 113 | fields: { 114 | 'node--article': new Set(['drupal_internal__nid']), 115 | }, 116 | page: {}, 117 | sort: [], 118 | }, 119 | fragment: '', 120 | }, 121 | { 122 | protocol: 'http:', 123 | host: 'drupal.test', 124 | port: '', 125 | path: '/jsonapi/node/article', 126 | query: { 127 | filter: {}, 128 | include: [], 129 | fields: { 130 | 'node--article': new Set(['drupal_internal__nid', 'status']), 131 | }, 132 | page: {}, 133 | sort: [], 134 | }, 135 | fragment: '', 136 | }, 137 | { 138 | protocol: 'http:', 139 | host: 'drupal.test', 140 | port: '', 141 | path: '/jsonapi/node/article', 142 | query: { 143 | filter: {}, 144 | include: [], 145 | fields: { 146 | 'node--article': new Set(['drupal_internal__nid']), 147 | 'user_role--user_role': new Set(['drupal_internal__id']), 148 | }, 149 | page: {}, 150 | sort: [], 151 | }, 152 | fragment: '', 153 | }, 154 | ], 155 | }; 156 | 157 | const filters = { 158 | urls: [ 159 | 'filter[foo]=bar', 160 | 'filter[foo][bar]=baz', 161 | 'filter[foo][]=bar&filter[foo][]=baz', 162 | 'filter[foo][bar]=qux&filter[foo][baz]=quux', 163 | 'filter[foo][bar][]=baz&filter[foo][bar][]=qux', 164 | 'filter[foo][bar][]=qux&filter[foo][bar][]=quux&filter[foo][baz][]=quz&filter[foo][baz][]=quuz', 165 | 'filter[a-label][condition][path]=field_first_name&filter[a-label][condition][operator]=%3D&filter[a-label][condition][value]=Janis', 166 | 'filter[name-filter][condition][path]=uid.name&filter[name-filter][condition][operator]=IN&filter[name-filter][condition][value][]=admin&filter[name-filter][condition][value][]=john', 167 | ], 168 | 169 | parsed: [ 170 | { 171 | protocol: 'http:', 172 | host: 'drupal.test', 173 | port: '', 174 | path: '/jsonapi/node/article', 175 | query: { 176 | filter: { foo: 'bar' }, 177 | include: [], 178 | fields: {}, 179 | page: {}, 180 | sort: [], 181 | }, 182 | fragment: '', 183 | }, 184 | { 185 | protocol: 'http:', 186 | host: 'drupal.test', 187 | port: '', 188 | path: '/jsonapi/node/article', 189 | query: { 190 | filter: { foo: { bar: 'baz' } }, 191 | include: [], 192 | fields: {}, 193 | page: {}, 194 | sort: [], 195 | }, 196 | fragment: '', 197 | }, 198 | { 199 | protocol: 'http:', 200 | host: 'drupal.test', 201 | port: '', 202 | path: '/jsonapi/node/article', 203 | query: { 204 | filter: { foo: new Set(['bar', 'baz']) }, 205 | include: [], 206 | fields: {}, 207 | page: {}, 208 | sort: [], 209 | }, 210 | fragment: '', 211 | }, 212 | { 213 | protocol: 'http:', 214 | host: 'drupal.test', 215 | port: '', 216 | path: '/jsonapi/node/article', 217 | query: { 218 | filter: { foo: { bar: 'qux', baz: 'quux' } }, 219 | include: [], 220 | fields: {}, 221 | page: {}, 222 | sort: [], 223 | }, 224 | fragment: '', 225 | }, 226 | { 227 | protocol: 'http:', 228 | host: 'drupal.test', 229 | port: '', 230 | path: '/jsonapi/node/article', 231 | query: { 232 | filter: { foo: { bar: new Set(['baz', 'qux']) } }, 233 | include: [], 234 | fields: {}, 235 | page: {}, 236 | sort: [], 237 | }, 238 | fragment: '', 239 | }, 240 | { 241 | protocol: 'http:', 242 | host: 'drupal.test', 243 | port: '', 244 | path: '/jsonapi/node/article', 245 | query: { 246 | filter: { 247 | foo: { 248 | bar: new Set(['qux', 'quux']), 249 | baz: new Set(['quz', 'quuz']), 250 | }, 251 | }, 252 | include: [], 253 | fields: {}, 254 | page: {}, 255 | sort: [], 256 | }, 257 | fragment: '', 258 | }, 259 | { 260 | protocol: 'http:', 261 | host: 'drupal.test', 262 | port: '', 263 | path: '/jsonapi/node/article', 264 | query: { 265 | filter: { 266 | 'a-label': { 267 | condition: { 268 | path: 'field_first_name', 269 | operator: '=', 270 | value: 'Janis', 271 | }, 272 | }, 273 | }, 274 | include: [], 275 | fields: {}, 276 | page: {}, 277 | sort: [], 278 | }, 279 | fragment: '', 280 | }, 281 | { 282 | protocol: 'http:', 283 | host: 'drupal.test', 284 | port: '', 285 | path: '/jsonapi/node/article', 286 | query: { 287 | filter: { 288 | 'name-filter': { 289 | condition: { 290 | path: 'uid.name', 291 | operator: 'IN', 292 | value: new Set(['admin', 'john']), 293 | }, 294 | }, 295 | }, 296 | include: [], 297 | fields: {}, 298 | page: {}, 299 | sort: [], 300 | }, 301 | fragment: '', 302 | }, 303 | ], 304 | }; 305 | 306 | const complex = { 307 | urls: [ 308 | [ 309 | 'include=node_type,uid.roles', 310 | 'filter[last-name-filter][condition][path]=field_last_name', 311 | 'filter[last-name-filter][condition][operator]=STARTS_WITH', 312 | 'filter[last-name-filter][condition][value]=J', 313 | 'fields[node--article]=drupal_internal__nid', 314 | 'fields[node_type--node_type]=drupal_internal__type,name', 315 | 'fields[user_role--user_role]=drupal_internal__id', 316 | ], 317 | ], 318 | parsed: [ 319 | { 320 | protocol: 'http:', 321 | host: 'drupal.test', 322 | port: '', 323 | path: '/jsonapi/node/article', 324 | query: { 325 | filter: { 326 | 'last-name-filter': { 327 | condition: { 328 | path: 'field_last_name', 329 | operator: 'STARTS_WITH', 330 | value: 'J', 331 | }, 332 | }, 333 | }, 334 | include: ['node_type', 'uid.roles'], 335 | fields: { 336 | 'node--article': new Set(['drupal_internal__nid']), 337 | 'node_type--node_type': new Set(['drupal_internal__type', 'name']), 338 | 'user_role--user_role': new Set(['drupal_internal__id']), 339 | }, 340 | page: {}, 341 | sort: [], 342 | }, 343 | fragment: '', 344 | }, 345 | ], 346 | }; 347 | 348 | describe('Parse JSON:API url from url string', () => { 349 | test('Top Level url', () => { 350 | expect(parseJsonApiUrl(base.url)).toEqual(base.parsed); 351 | expect(parseJsonApiUrl(local.url)).toEqual(local.parsed); 352 | }); 353 | 354 | test('Collection url', () => { 355 | expect(parseJsonApiUrl(article.url)).toEqual(article.parsed); 356 | }); 357 | 358 | test('With Include', () => { 359 | include.urls.forEach((url, index) => { 360 | expect(parseJsonApiUrl(`${article.url}?${url}`)).toEqual( 361 | include.parsed[index], 362 | ); 363 | }); 364 | }); 365 | 366 | test('With Fields', () => { 367 | fields.urls.forEach((url, index) => { 368 | expect(parseJsonApiUrl(`${article.url}?${url}`)).toEqual( 369 | fields.parsed[index], 370 | ); 371 | }); 372 | }); 373 | 374 | test('With Filters', () => { 375 | filters.urls.forEach((url, index) => { 376 | expect(parseJsonApiUrl(`${article.url}?${url}`)).toEqual( 377 | filters.parsed[index], 378 | ); 379 | }); 380 | }); 381 | 382 | test('Complex url with fields and include', () => { 383 | complex.urls.forEach((url, index) => { 384 | expect(parseJsonApiUrl(`${article.url}?${url.join('&')}`)).toEqual( 385 | complex.parsed[index], 386 | ); 387 | }); 388 | }); 389 | }); 390 | 391 | describe('Compile url from JSON:API url object', () => { 392 | test('Top level url', () => { 393 | expect(compileJsonApiUrl(base.parsed)).toBe(base.url); 394 | expect(compileJsonApiUrl(local.parsed)).toBe(local.url); 395 | }); 396 | 397 | test('Collection url', () => { 398 | expect(compileJsonApiUrl(article.parsed)).toBe(article.url); 399 | }); 400 | 401 | test('With Include', () => { 402 | include.urls.forEach((url, index) => { 403 | expect(compileJsonApiUrl(include.parsed[index])).toEqual( 404 | `${article.url}?${url}`, 405 | ); 406 | }); 407 | }); 408 | 409 | test('With Fields', () => { 410 | fields.urls.forEach((url, index) => { 411 | expect(compileJsonApiUrl(fields.parsed[index])).toEqual( 412 | `${article.url}?${url}`, 413 | ); 414 | }); 415 | }); 416 | 417 | test('With Filters', () => { 418 | filters.urls.forEach((url, index) => { 419 | expect(compileJsonApiUrl(filters.parsed[index])).toEqual( 420 | `${article.url}?${url}`, 421 | ); 422 | }); 423 | }); 424 | 425 | test('Complex url', () => { 426 | complex.urls.forEach((url, index) => { 427 | expect(compileJsonApiUrl(complex.parsed[index])).toEqual( 428 | `${article.url}?${url.join('&')}`, 429 | ); 430 | }); 431 | }); 432 | }); 433 | 434 | describe('Compile filter query', () => { 435 | filters.urls.forEach((url, index) => { 436 | expect( 437 | compileQueryParameterFamily('filter', filters.parsed[index].query.filter), 438 | ).toBe(url); 439 | }); 440 | }); 441 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 2, June 1991 3 | 4 | Copyright (C) 1989, 1991 Free Software Foundation, Inc., 5 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 6 | Everyone is permitted to copy and distribute verbatim copies 7 | of this license document, but changing it is not allowed. 8 | 9 | Preamble 10 | 11 | The licenses for most software are designed to take away your 12 | freedom to share and change it. By contrast, the GNU General Public 13 | License is intended to guarantee your freedom to share and change free 14 | software--to make sure the software is free for all its users. This 15 | General Public License applies to most of the Free Software 16 | Foundation's software and to any other program whose authors commit to 17 | using it. (Some other Free Software Foundation software is covered by 18 | the GNU Lesser General Public License instead.) You can apply it to 19 | your programs, too. 20 | 21 | When we speak of free software, we are referring to freedom, not 22 | price. Our General Public Licenses are designed to make sure that you 23 | have the freedom to distribute copies of free software (and charge for 24 | this service if you wish), that you receive source code or can get it 25 | if you want it, that you can change the software or use pieces of it 26 | in new free programs; and that you know you can do these things. 27 | 28 | To protect your rights, we need to make restrictions that forbid 29 | anyone to deny you these rights or to ask you to surrender the rights. 30 | These restrictions translate to certain responsibilities for you if you 31 | distribute copies of the software, or if you modify it. 32 | 33 | For example, if you distribute copies of such a program, whether 34 | gratis or for a fee, you must give the recipients all the rights that 35 | you have. You must make sure that they, too, receive or can get the 36 | source code. And you must show them these terms so they know their 37 | rights. 38 | 39 | We protect your rights with two steps: (1) copyright the software, and 40 | (2) offer you this license which gives you legal permission to copy, 41 | distribute and/or modify the software. 42 | 43 | Also, for each author's protection and ours, we want to make certain 44 | that everyone understands that there is no warranty for this free 45 | software. If the software is modified by someone else and passed on, we 46 | want its recipients to know that what they have is not the original, so 47 | that any problems introduced by others will not reflect on the original 48 | authors' reputations. 49 | 50 | Finally, any free program is threatened constantly by software 51 | patents. We wish to avoid the danger that redistributors of a free 52 | program will individually obtain patent licenses, in effect making the 53 | program proprietary. To prevent this, we have made it clear that any 54 | patent must be licensed for everyone's free use or not licensed at all. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | GNU GENERAL PUBLIC LICENSE 60 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 61 | 62 | 0. This License applies to any program or other work which contains 63 | a notice placed by the copyright holder saying it may be distributed 64 | under the terms of this General Public License. The "Program", below, 65 | refers to any such program or work, and a "work based on the Program" 66 | means either the Program or any derivative work under copyright law: 67 | that is to say, a work containing the Program or a portion of it, 68 | either verbatim or with modifications and/or translated into another 69 | language. (Hereinafter, translation is included without limitation in 70 | the term "modification".) Each licensee is addressed as "you". 71 | 72 | Activities other than copying, distribution and modification are not 73 | covered by this License; they are outside its scope. The act of 74 | running the Program is not restricted, and the output from the Program 75 | is covered only if its contents constitute a work based on the 76 | Program (independent of having been made by running the Program). 77 | Whether that is true depends on what the Program does. 78 | 79 | 1. You may copy and distribute verbatim copies of the Program's 80 | source code as you receive it, in any medium, provided that you 81 | conspicuously and appropriately publish on each copy an appropriate 82 | copyright notice and disclaimer of warranty; keep intact all the 83 | notices that refer to this License and to the absence of any warranty; 84 | and give any other recipients of the Program a copy of this License 85 | along with the Program. 86 | 87 | You may charge a fee for the physical act of transferring a copy, and 88 | you may at your option offer warranty protection in exchange for a fee. 89 | 90 | 2. You may modify your copy or copies of the Program or any portion 91 | of it, thus forming a work based on the Program, and copy and 92 | distribute such modifications or work under the terms of Section 1 93 | above, provided that you also meet all of these conditions: 94 | 95 | a) You must cause the modified files to carry prominent notices 96 | stating that you changed the files and the date of any change. 97 | 98 | b) You must cause any work that you distribute or publish, that in 99 | whole or in part contains or is derived from the Program or any 100 | part thereof, to be licensed as a whole at no charge to all third 101 | parties under the terms of this License. 102 | 103 | c) If the modified program normally reads commands interactively 104 | when run, you must cause it, when started running for such 105 | interactive use in the most ordinary way, to print or display an 106 | announcement including an appropriate copyright notice and a 107 | notice that there is no warranty (or else, saying that you provide 108 | a warranty) and that users may redistribute the program under 109 | these conditions, and telling the user how to view a copy of this 110 | License. (Exception: if the Program itself is interactive but 111 | does not normally print such an announcement, your work based on 112 | the Program is not required to print an announcement.) 113 | 114 | These requirements apply to the modified work as a whole. If 115 | identifiable sections of that work are not derived from the Program, 116 | and can be reasonably considered independent and separate works in 117 | themselves, then this License, and its terms, do not apply to those 118 | sections when you distribute them as separate works. But when you 119 | distribute the same sections as part of a whole which is a work based 120 | on the Program, the distribution of the whole must be on the terms of 121 | this License, whose permissions for other licensees extend to the 122 | entire whole, and thus to each and every part regardless of who wrote it. 123 | 124 | Thus, it is not the intent of this section to claim rights or contest 125 | your rights to work written entirely by you; rather, the intent is to 126 | exercise the right to control the distribution of derivative or 127 | collective works based on the Program. 128 | 129 | In addition, mere aggregation of another work not based on the Program 130 | with the Program (or with a work based on the Program) on a volume of 131 | a storage or distribution medium does not bring the other work under 132 | the scope of this License. 133 | 134 | 3. You may copy and distribute the Program (or a work based on it, 135 | under Section 2) in object code or executable form under the terms of 136 | Sections 1 and 2 above provided that you also do one of the following: 137 | 138 | a) Accompany it with the complete corresponding machine-readable 139 | source code, which must be distributed under the terms of Sections 140 | 1 and 2 above on a medium customarily used for software interchange; or, 141 | 142 | b) Accompany it with a written offer, valid for at least three 143 | years, to give any third party, for a charge no more than your 144 | cost of physically performing source distribution, a complete 145 | machine-readable copy of the corresponding source code, to be 146 | distributed under the terms of Sections 1 and 2 above on a medium 147 | customarily used for software interchange; or, 148 | 149 | c) Accompany it with the information you received as to the offer 150 | to distribute corresponding source code. (This alternative is 151 | allowed only for noncommercial distribution and only if you 152 | received the program in object code or executable form with such 153 | an offer, in accord with Subsection b above.) 154 | 155 | The source code for a work means the preferred form of the work for 156 | making modifications to it. For an executable work, complete source 157 | code means all the source code for all modules it contains, plus any 158 | associated interface definition files, plus the scripts used to 159 | control compilation and installation of the executable. However, as a 160 | special exception, the source code distributed need not include 161 | anything that is normally distributed (in either source or binary 162 | form) with the major components (compiler, kernel, and so on) of the 163 | operating system on which the executable runs, unless that component 164 | itself accompanies the executable. 165 | 166 | If distribution of executable or object code is made by offering 167 | access to copy from a designated place, then offering equivalent 168 | access to copy the source code from the same place counts as 169 | distribution of the source code, even though third parties are not 170 | compelled to copy the source along with the object code. 171 | 172 | 4. You may not copy, modify, sublicense, or distribute the Program 173 | except as expressly provided under this License. Any attempt 174 | otherwise to copy, modify, sublicense or distribute the Program is 175 | void, and will automatically terminate your rights under this License. 176 | However, parties who have received copies, or rights, from you under 177 | this License will not have their licenses terminated so long as such 178 | parties remain in full compliance. 179 | 180 | 5. You are not required to accept this License, since you have not 181 | signed it. However, nothing else grants you permission to modify or 182 | distribute the Program or its derivative works. These actions are 183 | prohibited by law if you do not accept this License. Therefore, by 184 | modifying or distributing the Program (or any work based on the 185 | Program), you indicate your acceptance of this License to do so, and 186 | all its terms and conditions for copying, distributing or modifying 187 | the Program or works based on it. 188 | 189 | 6. Each time you redistribute the Program (or any work based on the 190 | Program), the recipient automatically receives a license from the 191 | original licensor to copy, distribute or modify the Program subject to 192 | these terms and conditions. You may not impose any further 193 | restrictions on the recipients' exercise of the rights granted herein. 194 | You are not responsible for enforcing compliance by third parties to 195 | this License. 196 | 197 | 7. If, as a consequence of a court judgment or allegation of patent 198 | infringement or for any other reason (not limited to patent issues), 199 | conditions are imposed on you (whether by court order, agreement or 200 | otherwise) that contradict the conditions of this License, they do not 201 | excuse you from the conditions of this License. If you cannot 202 | distribute so as to satisfy simultaneously your obligations under this 203 | License and any other pertinent obligations, then as a consequence you 204 | may not distribute the Program at all. For example, if a patent 205 | license would not permit royalty-free redistribution of the Program by 206 | all those who receive copies directly or indirectly through you, then 207 | the only way you could satisfy both it and this License would be to 208 | refrain entirely from distribution of the Program. 209 | 210 | If any portion of this section is held invalid or unenforceable under 211 | any particular circumstance, the balance of the section is intended to 212 | apply and the section as a whole is intended to apply in other 213 | circumstances. 214 | 215 | It is not the purpose of this section to induce you to infringe any 216 | patents or other property right claims or to contest validity of any 217 | such claims; this section has the sole purpose of protecting the 218 | integrity of the free software distribution system, which is 219 | implemented by public license practices. Many people have made 220 | generous contributions to the wide range of software distributed 221 | through that system in reliance on consistent application of that 222 | system; it is up to the author/donor to decide if he or she is willing 223 | to distribute software through any other system and a licensee cannot 224 | impose that choice. 225 | 226 | This section is intended to make thoroughly clear what is believed to 227 | be a consequence of the rest of this License. 228 | 229 | 8. If the distribution and/or use of the Program is restricted in 230 | certain countries either by patents or by copyrighted interfaces, the 231 | original copyright holder who places the Program under this License 232 | may add an explicit geographical distribution limitation excluding 233 | those countries, so that distribution is permitted only in or among 234 | countries not thus excluded. In such case, this License incorporates 235 | the limitation as if written in the body of this License. 236 | 237 | 9. The Free Software Foundation may publish revised and/or new versions 238 | of the General Public License from time to time. Such new versions will 239 | be similar in spirit to the present version, but may differ in detail to 240 | address new problems or concerns. 241 | 242 | Each version is given a distinguishing version number. If the Program 243 | specifies a version number of this License which applies to it and "any 244 | later version", you have the option of following the terms and conditions 245 | either of that version or of any later version published by the Free 246 | Software Foundation. If the Program does not specify a version number of 247 | this License, you may choose any version ever published by the Free Software 248 | Foundation. 249 | 250 | 10. If you wish to incorporate parts of the Program into other free 251 | programs whose distribution conditions are different, write to the author 252 | to ask for permission. For software which is copyrighted by the Free 253 | Software Foundation, write to the Free Software Foundation; we sometimes 254 | make exceptions for this. Our decision will be guided by the two goals 255 | of preserving the free status of all derivatives of our free software and 256 | of promoting the sharing and reuse of software generally. 257 | 258 | NO WARRANTY 259 | 260 | 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY 261 | FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN 262 | OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES 263 | PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED 264 | OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 265 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS 266 | TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE 267 | PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, 268 | REPAIR OR CORRECTION. 269 | 270 | 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 271 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR 272 | REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, 273 | INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING 274 | OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED 275 | TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY 276 | YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER 277 | PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE 278 | POSSIBILITY OF SUCH DAMAGES. 279 | 280 | END OF TERMS AND CONDITIONS 281 | 282 | How to Apply These Terms to Your New Programs 283 | 284 | If you develop a new program, and you want it to be of the greatest 285 | possible use to the public, the best way to achieve this is to make it 286 | free software which everyone can redistribute and change under these terms. 287 | 288 | To do so, attach the following notices to the program. It is safest 289 | to attach them to the start of each source file to most effectively 290 | convey the exclusion of warranty; and each file should have at least 291 | the "copyright" line and a pointer to where the full notice is found. 292 | 293 | 294 | Copyright (C) 295 | 296 | This program is free software; you can redistribute it and/or modify 297 | it under the terms of the GNU General Public License as published by 298 | the Free Software Foundation; either version 2 of the License, or 299 | (at your option) any later version. 300 | 301 | This program is distributed in the hope that it will be useful, 302 | but WITHOUT ANY WARRANTY; without even the implied warranty of 303 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 304 | GNU General Public License for more details. 305 | 306 | You should have received a copy of the GNU General Public License along 307 | with this program; if not, write to the Free Software Foundation, Inc., 308 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 309 | 310 | Also add information on how to contact you by electronic and paper mail. 311 | 312 | If the program is interactive, make it output a short notice like this 313 | when it starts in an interactive mode: 314 | 315 | Gnomovision version 69, Copyright (C) year name of author 316 | Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 317 | This is free software, and you are welcome to redistribute it 318 | under certain conditions; type `show c' for details. 319 | 320 | The hypothetical commands `show w' and `show c' should show the appropriate 321 | parts of the General Public License. Of course, the commands you use may 322 | be called something other than `show w' and `show c'; they could even be 323 | mouse-clicks or menu items--whatever suits your program. 324 | 325 | You should also get your employer (if you work as a programmer) or your 326 | school, if any, to sign a "copyright disclaimer" for the program, if 327 | necessary. Here is a sample; alter the names: 328 | 329 | Yoyodyne, Inc., hereby disclaims all copyright interest in the program 330 | `Gnomovision' (which makes passes at compilers) written by James Hacker. 331 | 332 | , 1 April 1989 333 | Ty Coon, President of Vice 334 | 335 | This General Public License does not permit incorporating your program into 336 | proprietary programs. If your program is a subroutine library, you may 337 | consider it more useful to permit linking proprietary applications with the 338 | library. If this is what you want to do, use the GNU Lesser General 339 | Public License instead of this License. 340 | -------------------------------------------------------------------------------- /src/lib/schema/normalize.test.js: -------------------------------------------------------------------------------- 1 | import { 2 | getAttributes, 3 | getRelationships, 4 | getRelationshipSchema, 5 | mapDefinitions, 6 | } from './normalize'; 7 | 8 | let schemaUnd; 9 | 10 | const emptyVals = [[], {}, null, schemaUnd]; 11 | 12 | const schemaMenu = { 13 | $schema: 'http://json-schema.org/draft-07/schema', 14 | $id: 'http://drupal.test/jsonapi/menu/menu/resource/schema.json', 15 | allOf: [ 16 | { 17 | type: 'object', 18 | properties: { 19 | attributes: { 20 | $ref: '#/definitions/attributes', 21 | }, 22 | relationships: { 23 | $ref: '#/definitions/relationships', 24 | }, 25 | }, 26 | }, 27 | { 28 | $ref: 'https://jsonapi.org/schema#/definitions/resource', 29 | }, 30 | ], 31 | properties: { 32 | attributes: { 33 | $ref: '#/definitions/attributes', 34 | }, 35 | }, 36 | definitions: { 37 | attributes: { 38 | type: 'object', 39 | properties: { 40 | drupal_internal__id: {}, 41 | langcode: {}, 42 | status: {}, 43 | dependencies: {}, 44 | third_party_settings: {}, 45 | label: {}, 46 | description: {}, 47 | locked: {}, 48 | }, 49 | additionalProperties: false, 50 | }, 51 | }, 52 | }; 53 | 54 | const schemaArticleCollection = { 55 | $schema: 'http://json-schema.org/draft-07/schema', 56 | $id: 'http://drupal.test/jsonapi/node/article/collection/schema.json', 57 | allOf: [ 58 | { 59 | $ref: 'https://jsonapi.org/schema', 60 | }, 61 | { 62 | if: { 63 | $ref: 'https://jsonapi.org/schema#/definitions/success', 64 | }, 65 | then: { 66 | type: 'object', 67 | properties: { 68 | data: { 69 | $ref: '#/definitions/data', 70 | }, 71 | }, 72 | required: ['data'], 73 | }, 74 | }, 75 | ], 76 | definitions: { 77 | data: { 78 | type: 'array', 79 | items: { 80 | $ref: 'http://drupal.test/jsonapi/node/article/resource/schema.json', 81 | }, 82 | }, 83 | }, 84 | }; 85 | 86 | const schemaNodeTypeRelated = { 87 | $schema: 'http://json-schema.org/draft-07/schema', 88 | $id: 89 | 'http://drupal.test/jsonapi/node/article/resource/relationships/node_type/related/schema.json', 90 | allOf: [ 91 | { 92 | $ref: 'https://jsonapi.org/schema', 93 | }, 94 | { 95 | if: { 96 | $ref: 'https://jsonapi.org/schema#/definitions/success', 97 | }, 98 | then: { 99 | type: 'object', 100 | properties: { 101 | data: { 102 | $ref: '#/definitions/data', 103 | }, 104 | }, 105 | required: ['data'], 106 | }, 107 | }, 108 | ], 109 | definitions: { 110 | data: { 111 | $ref: 112 | 'http://drupal.test/jsonapi/node_type/node_type/resource/schema.json', 113 | }, 114 | }, 115 | }; 116 | 117 | const schemaArticle = { 118 | "$schema": "https://json-schema.org/draft/2019-09/hyper-schema", 119 | "$id": "http://drupal.test/jsonapi/node/article/resource/schema", 120 | "title": "Article content item", 121 | "allOf": [ 122 | { 123 | "type": "object", 124 | "properties": { 125 | "type": { 126 | "$ref": "#definitions/type" 127 | }, 128 | "attributes": { 129 | "$ref": "#/definitions/attributes" 130 | }, 131 | "relationships": { 132 | "$ref": "#/definitions/relationships" 133 | } 134 | } 135 | }, 136 | { 137 | "$ref": "https://jsonapi.org/schema#/definitions/resource" 138 | } 139 | ], 140 | "definitions": { 141 | "type": { 142 | "const": "node--article" 143 | }, 144 | "attributes": { 145 | "description": "Entity attributes", 146 | "type": "object", 147 | "properties": { 148 | "uuid": { 149 | "type": "string", 150 | "title": "UUID", 151 | "maxLength": 128 152 | }, 153 | "drupal_internal__nid": { 154 | "type": "integer", 155 | "title": "ID" 156 | }, 157 | "drupal_internal__vid": { 158 | "type": "integer", 159 | "title": "Revision ID" 160 | }, 161 | "langcode": { 162 | "type": "string", 163 | "title": "Language" 164 | }, 165 | "revision_timestamp": { 166 | "type": "number", 167 | "title": "Revision create time", 168 | "format": "utc-millisec", 169 | "description": "The time that the current revision was created." 170 | }, 171 | "revision_log": { 172 | "type": "string", 173 | "title": "Revision log message", 174 | "description": "Briefly describe the changes you have made.", 175 | "default": "" 176 | }, 177 | "status": { 178 | "type": "boolean", 179 | "title": "Published", 180 | "default": true 181 | }, 182 | "title": { 183 | "type": "string", 184 | "title": "Title", 185 | "maxLength": 255 186 | }, 187 | "created": { 188 | "type": "number", 189 | "title": "Authored on", 190 | "format": "utc-millisec", 191 | "description": "The time that the node was created." 192 | }, 193 | "changed": { 194 | "type": "number", 195 | "title": "Changed", 196 | "format": "utc-millisec", 197 | "description": "The time that the node was last edited." 198 | }, 199 | "promote": { 200 | "type": "boolean", 201 | "title": "Promoted to front page", 202 | "default": true 203 | }, 204 | "sticky": { 205 | "type": "boolean", 206 | "title": "Sticky at top of lists", 207 | "default": false 208 | }, 209 | "default_langcode": { 210 | "type": "boolean", 211 | "title": "Default translation", 212 | "description": "A flag indicating whether this is the default translation.", 213 | "default": true 214 | }, 215 | "revision_default": { 216 | "type": "boolean", 217 | "title": "Default revision", 218 | "description": "A flag indicating whether this was a default revision when it was saved." 219 | }, 220 | "revision_translation_affected": { 221 | "type": "boolean", 222 | "title": "Revision translation affected", 223 | "description": "Indicates if the last edit of a translation belongs to current revision." 224 | }, 225 | "path": { 226 | "type": "object", 227 | "properties": { 228 | "alias": { 229 | "type": "string", 230 | "title": "Path alias" 231 | }, 232 | "pid": { 233 | "type": "integer", 234 | "title": "Path id" 235 | }, 236 | "langcode": { 237 | "type": "string", 238 | "title": "Language Code" 239 | } 240 | }, 241 | "title": "URL alias" 242 | }, 243 | "body": { 244 | "type": "object", 245 | "properties": { 246 | "value": { 247 | "type": "string", 248 | "title": "Text" 249 | }, 250 | "format": { 251 | "type": "string", 252 | "title": "Text format" 253 | }, 254 | "summary": { 255 | "type": "string", 256 | "title": "Summary" 257 | } 258 | }, 259 | "required": [ 260 | "value" 261 | ], 262 | "title": "Body" 263 | }, 264 | "comment": { 265 | "type": "object", 266 | "properties": { 267 | "status": { 268 | "type": "integer", 269 | "title": "Comment status" 270 | }, 271 | "cid": { 272 | "type": "integer", 273 | "title": "Last comment ID" 274 | }, 275 | "last_comment_timestamp": { 276 | "type": "integer", 277 | "title": "Last comment timestamp", 278 | "description": "The time that the last comment was created." 279 | }, 280 | "last_comment_name": { 281 | "type": "string", 282 | "title": "Last comment name", 283 | "description": "The name of the user posting the last comment." 284 | }, 285 | "last_comment_uid": { 286 | "type": "integer", 287 | "title": "Last comment user ID" 288 | }, 289 | "comment_count": { 290 | "type": "integer", 291 | "title": "Number of comments", 292 | "description": "The number of comments." 293 | } 294 | }, 295 | "required": [ 296 | "status" 297 | ], 298 | "title": "Comments", 299 | "default": { 300 | "status": 2, 301 | "cid": 0, 302 | "last_comment_timestamp": 0, 303 | "last_comment_name": null, 304 | "last_comment_uid": 0, 305 | "comment_count": 0 306 | } 307 | }, 308 | "field_multivalue": { 309 | "type": "array", 310 | "title": "multivalue", 311 | "items": { 312 | "type": "object", 313 | "properties": { 314 | "value": { 315 | "type": "string", 316 | "title": "Text", 317 | "maxLength": 255 318 | }, 319 | "format": { 320 | "type": "string", 321 | "title": "Text format" 322 | } 323 | }, 324 | "required": [ 325 | "value" 326 | ] 327 | } 328 | }, 329 | "field_number": { 330 | "type": "array", 331 | "title": "Number", 332 | "items": { 333 | "type": "integer", 334 | "title": "Integer value" 335 | } 336 | } 337 | }, 338 | "required": [ 339 | "uuid", 340 | "drupal_internal__nid", 341 | "drupal_internal__vid", 342 | "title", 343 | "revision_translation_affected", 344 | "path" 345 | ], 346 | "additionalProperties": false 347 | }, 348 | "relationships": { 349 | "description": "Entity relationships", 350 | "properties": { 351 | "node_type": { 352 | "type": "object", 353 | "properties": { 354 | "data": { 355 | "type": "object", 356 | "required": [ 357 | "type", 358 | "id" 359 | ], 360 | "properties": { 361 | "type": { 362 | "type": "string", 363 | "title": "Referenced resource", 364 | "enum": [ 365 | "node_type--node_type" 366 | ] 367 | }, 368 | "id": { 369 | "type": "string", 370 | "title": "Resource ID", 371 | "format": "uuid", 372 | "maxLength": 128 373 | }, 374 | "meta": { 375 | "type": "string", 376 | "title": "Content type ID" 377 | } 378 | } 379 | } 380 | }, 381 | "title": "Content type", 382 | "links": [ 383 | { 384 | "href": "{instanceHref}", 385 | "rel": "related", 386 | "targetMediaType": "application/vnd.api+json", 387 | "targetSchema": "http://drupal.test/jsonapi/node/article/resource/relationships/node_type/related/schema", 388 | "templatePointers": { 389 | "instanceHref": "/links/related/href" 390 | }, 391 | "templateRequired": [ 392 | "instanceHref" 393 | ] 394 | } 395 | ] 396 | }, 397 | "revision_uid": { 398 | "type": "object", 399 | "properties": { 400 | "data": { 401 | "type": "object", 402 | "required": [ 403 | "type", 404 | "id" 405 | ], 406 | "properties": { 407 | "type": { 408 | "type": "string", 409 | "title": "Referenced resource", 410 | "enum": [ 411 | "user--user" 412 | ] 413 | }, 414 | "id": { 415 | "type": "string", 416 | "title": "Resource ID", 417 | "format": "uuid", 418 | "maxLength": 128 419 | }, 420 | "meta": { 421 | "type": "integer", 422 | "title": "User ID" 423 | } 424 | } 425 | } 426 | }, 427 | "title": "Revision user", 428 | "links": [ 429 | { 430 | "href": "{instanceHref}", 431 | "rel": "related", 432 | "targetMediaType": "application/vnd.api+json", 433 | "targetSchema": "http://drupal.test/jsonapi/node/article/resource/relationships/revision_uid/related/schema", 434 | "templatePointers": { 435 | "instanceHref": "/links/related/href" 436 | }, 437 | "templateRequired": [ 438 | "instanceHref" 439 | ] 440 | } 441 | ] 442 | }, 443 | "uid": { 444 | "type": "object", 445 | "properties": { 446 | "data": { 447 | "type": "object", 448 | "required": [ 449 | "type", 450 | "id" 451 | ], 452 | "properties": { 453 | "type": { 454 | "type": "string", 455 | "title": "Referenced resource", 456 | "enum": [ 457 | "user--user" 458 | ] 459 | }, 460 | "id": { 461 | "type": "string", 462 | "title": "Resource ID", 463 | "format": "uuid", 464 | "maxLength": 128 465 | }, 466 | "meta": { 467 | "type": "integer", 468 | "title": "User ID" 469 | } 470 | } 471 | } 472 | }, 473 | "title": "Authored by", 474 | "links": [ 475 | { 476 | "href": "{instanceHref}", 477 | "rel": "related", 478 | "targetMediaType": "application/vnd.api+json", 479 | "targetSchema": "http://drupal.test/jsonapi/node/article/resource/relationships/uid/related/schema", 480 | "templatePointers": { 481 | "instanceHref": "/links/related/href" 482 | }, 483 | "templateRequired": [ 484 | "instanceHref" 485 | ] 486 | } 487 | ] 488 | }, 489 | "field_image": { 490 | "type": "object", 491 | "properties": { 492 | "data": { 493 | "type": "array", 494 | "items": { 495 | "type": "object", 496 | "required": [ 497 | "type", 498 | "id" 499 | ], 500 | "properties": { 501 | "type": { 502 | "type": "string", 503 | "title": "Referenced resource", 504 | "enum": [ 505 | "file--file" 506 | ] 507 | }, 508 | "id": { 509 | "type": "string", 510 | "title": "Resource ID", 511 | "format": "uuid", 512 | "maxLength": 128 513 | }, 514 | "meta": { 515 | "type": "object", 516 | "properties": { 517 | "target_id": { 518 | "type": "integer", 519 | "title": "File ID" 520 | }, 521 | "alt": { 522 | "type": "string", 523 | "title": "Alternative text", 524 | "description": "Alternative image text, for the image\\'s \\'alt\\' attribute." 525 | }, 526 | "title": { 527 | "type": "string", 528 | "title": "Title", 529 | "description": "Image title text, for the image\\'s \\'title\\' attribute." 530 | }, 531 | "width": { 532 | "type": "integer", 533 | "title": "Width", 534 | "description": "The width of the image in pixels." 535 | }, 536 | "height": { 537 | "type": "integer", 538 | "title": "Height", 539 | "description": "The height of the image in pixels." 540 | } 541 | }, 542 | "required": [ 543 | "target_id" 544 | ] 545 | } 546 | } 547 | } 548 | } 549 | }, 550 | "title": "Image", 551 | "maxItems": 2, 552 | "links": [ 553 | { 554 | "href": "{instanceHref}", 555 | "rel": "related", 556 | "targetMediaType": "application/vnd.api+json", 557 | "targetSchema": "http://drupal.test/jsonapi/node/article/resource/relationships/field_image/related/schema", 558 | "templatePointers": { 559 | "instanceHref": "/links/related/href" 560 | }, 561 | "templateRequired": [ 562 | "instanceHref" 563 | ] 564 | } 565 | ] 566 | }, 567 | "field_tags": { 568 | "type": "object", 569 | "properties": { 570 | "data": { 571 | "type": "array", 572 | "items": { 573 | "type": "object", 574 | "required": [ 575 | "type", 576 | "id" 577 | ], 578 | "properties": { 579 | "type": { 580 | "type": "string", 581 | "title": "Referenced resource", 582 | "enum": [ 583 | "taxonomy_term--other", 584 | "taxonomy_term--tags" 585 | ] 586 | }, 587 | "id": { 588 | "type": "string", 589 | "title": "Resource ID", 590 | "format": "uuid", 591 | "maxLength": 128 592 | }, 593 | "meta": { 594 | "type": "integer", 595 | "title": "Taxonomy term ID" 596 | } 597 | } 598 | } 599 | } 600 | }, 601 | "title": "Tags", 602 | "links": [ 603 | { 604 | "href": "{instanceHref}", 605 | "rel": "related", 606 | "targetMediaType": "application/vnd.api+json", 607 | "targetSchema": "http://drupal.test/jsonapi/node/article/resource/relationships/field_tags/related/schema", 608 | "templatePointers": { 609 | "instanceHref": "/links/related/href" 610 | }, 611 | "templateRequired": [ 612 | "instanceHref" 613 | ] 614 | } 615 | ] 616 | } 617 | }, 618 | "type": "object", 619 | "additionalProperties": false 620 | } 621 | } 622 | }; 623 | 624 | const mappedMenuAttributes = [ 625 | { name: 'drupal_internal__id', value: {} }, 626 | { name: 'langcode', value: {} }, 627 | { name: 'status', value: {} }, 628 | { name: 'dependencies', value: {} }, 629 | { name: 'third_party_settings', value: {} }, 630 | { name: 'label', value: {} }, 631 | { name: 'description', value: {} }, 632 | { name: 'locked', value: {} }, 633 | ]; 634 | 635 | const mappedArticleRelationships = [ 636 | { 637 | name: 'node_type', 638 | value: 'http://drupal.test/jsonapi/node/article/resource/relationships/node_type/related/schema', 639 | }, 640 | { 641 | name: 'revision_uid', 642 | value: 'http://drupal.test/jsonapi/node/article/resource/relationships/revision_uid/related/schema', 643 | }, 644 | { 645 | name: 'uid', 646 | value: 'http://drupal.test/jsonapi/node/article/resource/relationships/uid/related/schema', 647 | }, 648 | { 649 | name: 'field_image', 650 | value: 'http://drupal.test/jsonapi/node/article/resource/relationships/field_image/related/schema', 651 | }, 652 | { 653 | name: 'field_tags', 654 | value: 'http://drupal.test/jsonapi/node/article/resource/relationships/field_tags/related/schema', 655 | }, 656 | ]; 657 | 658 | const schemaNoDefinitions = { 659 | $schema: 'http://json-schema.org/draft-07/schema', 660 | $id: 'http://drupal.test/jsonapi/menu/menu/resource/schema.json', 661 | }; 662 | 663 | const schemaNoProperties = { 664 | $schema: 'http://json-schema.org/draft-07/schema', 665 | $id: 'http://drupal.test/jsonapi/menu/menu/resource/schema.json', 666 | definitions: {}, 667 | }; 668 | 669 | describe('Schema Attributes', () => { 670 | test('Extract attribute names from schema definitions', () => { 671 | expect(getAttributes(schemaMenu)).toEqual(mappedMenuAttributes); 672 | expect(getAttributes(schemaArticle)).toEqual([ 673 | { name: 'drupal_internal__nid', value: {} }, 674 | { name: 'drupal_internal__vid', value: {} }, 675 | { name: 'langcode', value: {} }, 676 | { name: 'revision_timestamp', value: {} }, 677 | { name: 'revision_log', value: {} }, 678 | { name: 'status', value: {} }, 679 | { name: 'title', value: {} }, 680 | { name: 'created', value: {} }, 681 | { name: 'changed', value: {} }, 682 | { name: 'promote', value: {} }, 683 | { name: 'sticky', value: {} }, 684 | { name: 'default_langcode', value: {} }, 685 | { name: 'revision_default', value: {} }, 686 | { name: 'revision_translation_affected', value: {} }, 687 | { name: 'path', value: {} }, 688 | { name: 'body', value: {} }, 689 | ]); 690 | }); 691 | 692 | test('Return empty array for incomplete or empty schema', () => { 693 | expect(getAttributes(schemaNoDefinitions)).toEqual([]); 694 | expect(getAttributes(schemaNoProperties)).toEqual([]); 695 | emptyVals.forEach(val => { 696 | expect(getAttributes(val)).toEqual([]); 697 | }); 698 | }); 699 | }); 700 | 701 | describe('Schema Includes', () => { 702 | test('Get relationship list from schema', () => { 703 | expect(getRelationships(schemaArticle)).toEqual(mappedArticleRelationships); 704 | }); 705 | 706 | test('Return empty array for incomplete or empty schema', () => { 707 | expect(getRelationships(schemaNoDefinitions)).toEqual([]); 708 | expect(getRelationships(schemaNoProperties)).toEqual([]); 709 | emptyVals.forEach(val => { 710 | expect(getRelationships(val)).toEqual([]); 711 | }); 712 | }); 713 | }); 714 | 715 | describe('Normalize Properties', () => { 716 | test('Get flattened object from nested properties', () => { 717 | expect( 718 | getRelationshipSchema( 719 | schemaArticle.definitions.relationships.properties.node_type, 720 | ), 721 | ).toEqual('http://drupal.test/jsonapi/node/article/resource/relationships/node_type/related/schema'); 722 | }); 723 | 724 | test('Get an empty object if recursion fails', () => { 725 | emptyVals.forEach(val => { 726 | expect(getRelationshipSchema(val)).toEqual(undefined); 727 | }); 728 | }); 729 | 730 | test('Map property names and values', () => { 731 | expect( 732 | mapDefinitions(schemaMenu.definitions.attributes.properties), 733 | ).toEqual(mappedMenuAttributes); 734 | }); 735 | 736 | test('Map property names and processed values', () => { 737 | expect( 738 | mapDefinitions( 739 | schemaArticle.definitions.relationships.properties, 740 | getRelationshipSchema, 741 | ), 742 | ).toEqual(mappedArticleRelationships); 743 | }); 744 | }); 745 | --------------------------------------------------------------------------------