├── .npmrc ├── dist-js └── style.css ├── static └── favicon.png ├── src ├── lib │ ├── img │ │ ├── upload.zip │ │ ├── upload.svg │ │ ├── duplicate.svg │ │ ├── right.svg │ │ ├── up.svg │ │ ├── down.svg │ │ ├── left-arrow.svg │ │ ├── add.svg │ │ ├── info.svg │ │ ├── add-colour.svg │ │ ├── del-box.svg │ │ ├── delete.svg │ │ └── link.svg │ ├── index.ts │ ├── schemasafe-extended.d.ts │ ├── editors │ │ ├── Hidden.svelte │ │ ├── Boolean.svelte │ │ ├── Number.svelte │ │ ├── TextArea.svelte │ │ ├── FieldWrapper.svelte │ │ ├── Enum.svelte │ │ ├── String.svelte │ │ ├── Radio.svelte │ │ ├── Object.svelte │ │ ├── Currency.svelte │ │ ├── Autocomplete.svelte │ │ ├── Array.svelte │ │ ├── ArrayBlocks.svelte │ │ ├── Upload.svelte │ │ └── ListDetail.svelte │ ├── SubSchemaForm.svelte │ ├── errorMapper.ts │ ├── types │ │ ├── CommonComponentParameters.ts │ │ └── schema.ts │ ├── arrayOps.ts │ ├── schema │ │ └── schema.test.ts │ ├── css │ │ ├── layout.scss │ │ └── basic-skin.scss │ ├── SubmitForm.svelte │ ├── SchemaForm.svelte │ └── utilities.ts ├── app.html ├── app.d.ts └── routes │ └── +page.svelte ├── .gitignore ├── vite.config.ts ├── svelte.config.js ├── tsconfig.json ├── vite-script.config.ts ├── .github └── workflows │ └── build.yml ├── LICENSE ├── exampleWithScript.html ├── package.json └── README.md /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | -------------------------------------------------------------------------------- /dist-js/style.css: -------------------------------------------------------------------------------- 1 | textarea.svelte-vofknr{background-color:#fff} 2 | -------------------------------------------------------------------------------- /static/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/restspace/svelte-schema-form/HEAD/static/favicon.png -------------------------------------------------------------------------------- /src/lib/img/upload.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/restspace/svelte-schema-form/HEAD/src/lib/img/upload.zip -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /build 4 | /.svelte-kit 5 | /package 6 | /dist 7 | .env 8 | .env.* 9 | !.env.example 10 | -------------------------------------------------------------------------------- /src/lib/index.ts: -------------------------------------------------------------------------------- 1 | export { default as SchemaForm } from "./SchemaForm.svelte"; 2 | export { default as SubmitForm } from "./SubmitForm.svelte"; -------------------------------------------------------------------------------- /src/lib/schemasafe-extended.d.ts: -------------------------------------------------------------------------------- 1 | declare module "@exodus/schemasafe/src/pointer" { 2 | let get: (obj: any, pointer: string, objpath?: string) => any; 3 | export { get }; 4 | }; -------------------------------------------------------------------------------- /src/lib/img/upload.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/lib/img/duplicate.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | %sveltekit.head% 8 | 9 | 10 |
%sveltekit.body%
11 | 12 | 13 | -------------------------------------------------------------------------------- /src/app.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | // See https://kit.svelte.dev/docs/types#app 4 | // for information about these interfaces 5 | // and what to do when importing types 6 | declare namespace App { 7 | // interface Locals {} 8 | // interface Platform {} 9 | // interface Session {} 10 | // interface Stuff {} 11 | } 12 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { sveltekit } from '@sveltejs/kit/vite'; 2 | import type { UserConfig } from 'vite'; 3 | import path from 'path'; 4 | 5 | const config: UserConfig = { 6 | plugins: [sveltekit()], 7 | resolve: { 8 | alias: { 9 | 'svelte-schema-form': path.resolve('src/lib') 10 | } 11 | } 12 | }; 13 | 14 | export default config; 15 | -------------------------------------------------------------------------------- /src/lib/img/right.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/lib/img/up.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/lib/img/down.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /svelte.config.js: -------------------------------------------------------------------------------- 1 | import adapter from '@sveltejs/adapter-auto'; 2 | import { vitePreprocess } from '@sveltejs/kit/vite'; 3 | 4 | /** @type {import('@sveltejs/kit').Config} */ 5 | const config = { 6 | // Consult https://kit.svelte.dev/docs/integrations#preprocessors 7 | // for more information about preprocessors 8 | preprocess: vitePreprocess(), 9 | 10 | kit: { 11 | adapter: adapter(), 12 | } 13 | }; 14 | 15 | export default config; 16 | -------------------------------------------------------------------------------- /src/lib/editors/Hidden.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.svelte-kit/tsconfig.json", 3 | "compilerOptions": { 4 | "allowJs": true, 5 | "checkJs": true, 6 | "esModuleInterop": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "resolveJsonModule": true, 9 | "skipLibCheck": true, 10 | "sourceMap": true, 11 | "strict": true, 12 | "outDir": "dist", 13 | "ignoreDeprecations": "5.0", 14 | "verbatimModuleSyntax": true 15 | }, 16 | "exclude": [ 17 | "dist" 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /vite-script.config.ts: -------------------------------------------------------------------------------- 1 | import { svelte } from '@sveltejs/vite-plugin-svelte'; 2 | import { defineConfig } from 'vite'; 3 | import { resolve } from 'path'; 4 | 5 | export default defineConfig({ 6 | build: { 7 | lib: { 8 | entry: resolve(__dirname, 'dist/index.js'), 9 | name: 'SchemaForm', 10 | fileName: 'schemaForm', 11 | }, 12 | outDir: 'dist-js', 13 | }, 14 | plugins: [ 15 | svelte(), 16 | ], 17 | }); -------------------------------------------------------------------------------- /src/lib/img/left-arrow.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/lib/SubSchemaForm.svelte: -------------------------------------------------------------------------------- 1 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: "Build package" 2 | 3 | on: 4 | push: 5 | branches: 6 | - dev 7 | jobs: 8 | build: 9 | runs-on: ubuntu/latest 10 | strategy: 11 | matrix: 12 | node-version: 13 | - '14.x' 14 | steps: 15 | - uses: actions/checkout@v3 16 | - name: Use Node.js ${{ matrix.node-version }} 17 | uses: actions/setup-node@v3 18 | with: 19 | node-version: ${{ matrix.node-version }} 20 | - name: Install dependencies 21 | run: npm install 22 | 23 | - run: npm run check 24 | - run: npm run package 25 | - run: npm test -------------------------------------------------------------------------------- /src/lib/img/add.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/lib/img/info.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/lib/img/add-colour.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /src/lib/img/del-box.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /src/lib/editors/Boolean.svelte: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 10 | params.pathChanged(params.path, ev.currentTarget.checked)} 14 | /> 15 | -------------------------------------------------------------------------------- /src/lib/editors/Number.svelte: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 10 | { 14 | let val = parseFloat(ev.currentTarget.value); 15 | params.pathChanged(params.path, isNaN(val) ? undefined : val); 16 | }} 17 | /> 18 | -------------------------------------------------------------------------------- /src/lib/img/delete.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/lib/editors/TextArea.svelte: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 10 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/lib/img/link.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/lib/editors/FieldWrapper.svelte: -------------------------------------------------------------------------------- 1 | 13 | 14 | {#if params.containerParent !== "array"} 15 | 21 | {/if} 22 | 23 | {#if error && params.showErrors} 24 |
{error}
25 | {/if} -------------------------------------------------------------------------------- /src/lib/editors/Enum.svelte: -------------------------------------------------------------------------------- 1 | 11 | 12 | 13 | 14 | 24 | -------------------------------------------------------------------------------- /src/lib/editors/String.svelte: -------------------------------------------------------------------------------- 1 | 18 | 19 | 20 | 21 | params.pathChanged(params.path, ev.currentTarget.value || undefined)} /> 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 restspace 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /src/lib/editors/Radio.svelte: -------------------------------------------------------------------------------- 1 | 13 | 14 | 15 | 16 |
21 | {#each enumVals as enumVal, idx} 22 | params.pathChanged(params.path, ev.currentTarget.value || undefined)} 27 | value={enumVal} 28 | name={id} 29 | checked={enumVal === value}/> 30 | 31 | {/each} 32 |
33 |
-------------------------------------------------------------------------------- /exampleWithScript.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 5 | 34 | 35 | -------------------------------------------------------------------------------- /src/lib/errorMapper.ts: -------------------------------------------------------------------------------- 1 | import { jsonPointerToPath } from "./types/schema"; 2 | import { afterLast } from "./utilities.js"; 3 | 4 | export function errorMapper(schema: any, value: any, keywordLocation: string, instanceLocation: string): [ string, string ] { 5 | const location = jsonPointerToPath(instanceLocation); 6 | const keyword = afterLast(keywordLocation, '/'); 7 | const fullKeyPath = jsonPointerToPath(keywordLocation); 8 | const keyValue = fullKeyPath.split('.').reduce((sub, key) => sub[key], schema); 9 | switch (keyword) { 10 | case "required": 11 | return [ location, 'Please enter a value for this item' ]; 12 | case "minimum": 13 | return [ location, `Please enter a number at least ${keyValue}` ]; 14 | case "maximum": 15 | return [ location, `Please enter a number at most ${keyValue}` ]; 16 | case "minLength": 17 | return [ location, `Please enter text of at least ${keyValue} characters` ]; 18 | case "maxLength": 19 | return [ location, `Please enter text no longer than ${keyValue} characters` ]; 20 | case "pattern": 21 | return [ location, `Please enter properly formatted value: ${keyValue}` ]; 22 | case "format": 23 | const valMap = { 24 | "date-time": "date and time", 25 | time: "time", 26 | date: "date", 27 | email: "email address", 28 | ipv4: "IPv4 address", 29 | } as Record; 30 | return [ location, `Please enter a properly formatted ${valMap[keyValue] || keyValue}` ]; 31 | } 32 | return [ location, `Fails to satisfy schema at ${jsonPointerToPath(keywordLocation)}` ]; 33 | } -------------------------------------------------------------------------------- /src/lib/types/CommonComponentParameters.ts: -------------------------------------------------------------------------------- 1 | export type ValidationErrors = Record; 2 | 3 | export const FileNone = Symbol(); 4 | export const ProgressContext = Symbol(); 5 | 6 | export interface CommonComponentParameters { 7 | path: string[]; 8 | pathChanged: (path: string[], val: any, op?: string, subPath?: string) => boolean, 9 | components: Record any>, 10 | componentContext?: Record, 11 | value: any, 12 | validationErrors: ValidationErrors, 13 | required?: boolean, 14 | containerParent: "none" | "array" | "object", 15 | containerReadOnly: boolean, 16 | showErrors: boolean, 17 | collapsible: boolean, 18 | idx: number 19 | } 20 | 21 | export const childComponentParameters = (params: CommonComponentParameters, propName: string) => { 22 | return { 23 | ...params, 24 | path: [ ...params.path, propName ] 25 | }; 26 | } 27 | 28 | /* 29 | export const valuePath = (value: any, path: string[]) => { 30 | if (path.length === 0) { 31 | return value; 32 | } else { 33 | return valuePath(value[path[0]], path.slice(1)); 34 | } 35 | } 36 | 37 | export const schemaPath = (schema: any, path: string[]) => { 38 | if (path.length === 0 && schema.type === "array") { 39 | return schema.items; 40 | } else if (path.length === 0) { 41 | return schema; 42 | } else if (schema.type === "array") { 43 | return schemaPath(schema.items, path); 44 | } else if (schema.type === "object") { 45 | return schemaPath(schema.properties[path[0]], path.slice(1)); 46 | } else { 47 | throw new Error('path not present in schema'); 48 | } 49 | } 50 | */ -------------------------------------------------------------------------------- /src/lib/types/schema.ts: -------------------------------------------------------------------------------- 1 | import { camelToTitle } from "../utilities.js"; 2 | 3 | export function editorForSchema(schema: any): string { 4 | let type = schema['type']; 5 | if (schema['enum']) 6 | type = "enum"; 7 | if (schema['format']) 8 | type += "-" + schema['format']; 9 | if (schema['hidden']) 10 | type = "hidden"; 11 | if (schema['editor']) 12 | type = schema['editor']; 13 | switch (type) { 14 | case "string-date-time": 15 | case "string-date": 16 | case "string-time": 17 | case "string-email": 18 | case "string-password": 19 | case "number-currency": 20 | return schema['format']; 21 | default: 22 | return type; 23 | } 24 | } 25 | 26 | export function emptyValue(schema: any): any { 27 | switch (schema['type'] || '') { 28 | case 'object': return {}; 29 | case 'array': return []; 30 | default: return null; 31 | } 32 | } 33 | 34 | export function schemaLabel(schema: any, path: string[]): string { 35 | return schema.title || camelToTitle(path.slice(-1)?.[0] || ''); 36 | } 37 | 38 | export function jsonPointerToPath(pointer: string) { 39 | if (pointer.startsWith('/')) { 40 | pointer = pointer.substring(1); 41 | } else if (pointer.startsWith('#/')) { 42 | pointer = pointer.substring(2); 43 | } else if (pointer.startsWith('http')) { 44 | pointer = pointer.split('#/')?.[1] || ''; 45 | } 46 | 47 | const pathEls = [] as string[]; 48 | pointer.split('/').forEach(el => { 49 | const int = parseInt(el); 50 | if (isNaN(int)) { 51 | pathEls.push(`.${el}`); 52 | } else { 53 | pathEls.push(`[${el}]`); 54 | } 55 | }); 56 | let path = pathEls.join(''); 57 | if (path.startsWith('.')) path = path.substring(1); 58 | return path; 59 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@restspace/svelte-schema-form", 3 | "version": "0.1.6", 4 | "description": "JSON Schema based form generator in Svelte", 5 | "author": "James Ellis-Jones", 6 | "license": "MIT", 7 | "repository": { 8 | "type": "git", 9 | "url": "git+https://github.com/restspace/svelte-schema-form.git" 10 | }, 11 | "keywords": [ 12 | "svelte", 13 | "form generator", 14 | "form builder", 15 | "json schema" 16 | ], 17 | "bugs": { 18 | "url": "https://github.com/restspace/svelte-schema-form/issues" 19 | }, 20 | "homepage": "https://github.com/restspace/svelte-schema-form#readme", 21 | "exports": { 22 | ".": { 23 | "types": "./dist/index.d.ts", 24 | "svelte": "./dist/index.js" 25 | }, 26 | "./*": "./dist/*" 27 | }, 28 | "files": ["dist"], 29 | "main": "dist/index.js", 30 | "scripts": { 31 | "dev": "vite dev", 32 | "build": "svelte-kit sync && svelte-package && sass dist/css:dist/css", 33 | "convertBuildToScript": "vite -c vite-script.config.js build", 34 | "package": "svelte-kit sync && svelte-package && sass dist/css:dist/css", 35 | "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", 36 | "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", 37 | "copyout": "xcopy /s /y src\\lib ..\\svelte-schema-form-script\\svelte-schema-form\\src\\lib" 38 | }, 39 | "devDependencies": { 40 | "@sveltejs/adapter-auto": "^2.0.1", 41 | "@sveltejs/kit": "^1.16.3", 42 | "@sveltejs/package": "^2.0.2", 43 | "@types/lodash-es": "^4.17.6", 44 | "sass": "^1.57.1", 45 | "svelte": "^3.54.0", 46 | "svelte-check": "^3.0.1", 47 | "tslib": "^2.4.1", 48 | "typescript": "^4.9.3", 49 | "vite": "^4.0.0" 50 | }, 51 | "type": "module", 52 | "dependencies": { 53 | "@exodus/schemasafe": "^1.0.0-rc.9", 54 | "lodash-es": "^4.17.21" 55 | }, 56 | "peerDependencies": { 57 | "svelte": "^3.0.0" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/lib/arrayOps.ts: -------------------------------------------------------------------------------- 1 | import type { CommonComponentParameters } from "./types/CommonComponentParameters"; 2 | import { emptyValue } from "./types/schema"; 3 | 4 | export const arrayAdd = (schema: any, params: CommonComponentParameters, value: any[]) => () => { 5 | params.pathChanged(params.path, 6 | [ 7 | ...(value || []), 8 | emptyValue(schema.items) 9 | ]); 10 | } 11 | 12 | export const arrayFill = (schema: any, params: CommonComponentParameters, value: any[]) => () => { 13 | const val = value || []; 14 | if (typeof schema.minItems !== 'number' || val.length >= schema.minItems) return; 15 | const addValues = new Array(schema.minItems - val.length).fill(emptyValue(schema.items)); 16 | 17 | params.pathChanged(params.path, 18 | [ 19 | ...val, 20 | ...addValues 21 | ]); 22 | } 23 | 24 | export const arrayDelete = (idx: number, params: CommonComponentParameters, value: any[]) => () => { 25 | params.pathChanged(params.path, 26 | [ 27 | ...value.slice(0, idx), 28 | ...value.slice(idx + 1) 29 | ], "delete", idx.toString()); 30 | }; 31 | 32 | export const arrayDuplicate = (idx: number, params: CommonComponentParameters, value: any[]) => () => { 33 | params.pathChanged(params.path, 34 | [ 35 | ...value.slice(0, idx), 36 | value[idx], 37 | JSON.parse(JSON.stringify(value[idx])), 38 | ...value.slice(idx + 1) 39 | ], "duplicate", (idx + 1).toString()); 40 | }; 41 | 42 | export const arrayUp = (idx: number, params: CommonComponentParameters, value: any[]) => () => { 43 | if (idx > 0) { 44 | params.pathChanged(params.path, 45 | [ 46 | ...value.slice(0, idx-1), 47 | value[idx], 48 | value[idx-1], 49 | ...value.slice(idx + 1) 50 | ], "up", (idx - 1).toString()); 51 | } 52 | }; 53 | 54 | export const arrayDown = (idx: number, params: CommonComponentParameters, value: any[]) => () => { 55 | if (idx < value.length - 1) { 56 | params.pathChanged(params.path, 57 | [ 58 | ...value.slice(0, idx), 59 | value[idx+1], 60 | value[idx], 61 | ...value.slice(idx + 2) 62 | ], "down", (idx + 1).toString()); 63 | } 64 | }; -------------------------------------------------------------------------------- /src/lib/editors/Object.svelte: -------------------------------------------------------------------------------- 1 | 25 | 26 |
27 | {#if showLegend } 28 | 29 | {#if params.collapsible } 30 | 31 | {/if} 32 | {#if params.containerParent !== "array" || schema.title} 33 | {@html stringToHtml(schemaLabel(schema, params.path))} 34 | {#if schema.description} 35 | {@html stringToHtml(schema.description)} 36 | {/if} 37 | {/if} 38 | 39 | {/if} 40 | 41 | {#if collapserOpenState === "open"} 42 | {#each propNames as propName (propName)} 43 | 54 | {/each} 55 | {/if} 56 |
-------------------------------------------------------------------------------- /src/lib/editors/Currency.svelte: -------------------------------------------------------------------------------- 1 | 41 | 42 | 43 | 44 | 48 | -------------------------------------------------------------------------------- /src/lib/schema/schema.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "vitest"; 2 | import { validator } from "@exodus/schemasafe"; 3 | 4 | describe("schemasafe behaviour", () => { 5 | test("ignores special properties", () => { 6 | const schema = { 7 | type: "object", 8 | properties: { 9 | salutation: { type: "string", enum: ["Mr", "Mrs", "Ms", "Dr"] }, 10 | firstName: { type: "string", maxLength: 10 }, 11 | lastName: { type: "string", readOnly: true }, 12 | canContact: { 13 | type: "string", 14 | enum: ["yes", "no"], 15 | editor: "radioButtons", 16 | description: "Whether can contact", 17 | }, 18 | preferredContact: { 19 | type: "array", 20 | items: { type: "string", enum: ["phone", "email", "text"] }, 21 | editor: "multiCheck", 22 | }, 23 | dateOfBirth: { type: "string", format: "date" }, 24 | password: { type: "string", format: "password" }, 25 | comments: { type: "string", editor: "textarea" }, 26 | files: { type: "string", editor: "upload" }, 27 | things: { 28 | type: "array", 29 | items: { 30 | type: "object", 31 | properties: { 32 | first: { type: "string" }, 33 | second: { type: "string" }, 34 | }, 35 | }, 36 | }, 37 | address: { 38 | type: "object", 39 | properties: { 40 | addressLine: { type: "string" }, 41 | postcode: { type: "string" }, 42 | }, 43 | required: ["postcode"], 44 | }, 45 | }, 46 | propertyOrder: [ 47 | "salutation", 48 | ["firstName", "lastName"], 49 | "canContact", 50 | "preferredContact", 51 | "dateOfBirth", 52 | "password", 53 | "comments", 54 | "files", 55 | "things", 56 | "address", 57 | ], 58 | if: { 59 | type: "object", 60 | properties: { salutation: { type: "string", const: "Dr" } }, 61 | }, 62 | then: { 63 | type: "object", 64 | properties: { isMedical: { type: "boolean" } }, 65 | propertyOrder: ["canContact", "isMedical"], 66 | }, 67 | }; 68 | const refSchema = { 69 | $id: "http://schema.org/myschema", 70 | type: "object", 71 | properties: { 72 | def: { type: "string" }, 73 | }, 74 | propertyOrder: ["def"], 75 | }; 76 | const validate = validator(schema, { 77 | includeErrors: true, 78 | allErrors: true, 79 | allowUnusedKeywords: true, 80 | schemas: [refSchema], 81 | formats: { 82 | password: (v) => true, 83 | }, 84 | }); 85 | const res = validate({ 86 | abc: "xxx", 87 | }); 88 | console.log("valid: " + JSON.stringify(res)); 89 | }); 90 | }); 91 | -------------------------------------------------------------------------------- /src/lib/editors/Autocomplete.svelte: -------------------------------------------------------------------------------- 1 | 73 | 74 | 75 | 76 |
77 |
78 | {#if inputState === "searching"} 79 | 80 | {:else} 81 | {#if selected?.image} 82 | {selected.text}/ 83 | {/if} 84 | {selected?.text || ''} 85 | {/if} 86 |
87 | 88 |
89 | {#each options as item (item.id)} 90 |
91 | {#if item.image} 92 | {item.text}/ 93 | {/if} 94 | {item.text} 95 |
96 | {/each} 97 |
98 |
99 |
-------------------------------------------------------------------------------- /src/lib/editors/Array.svelte: -------------------------------------------------------------------------------- 1 | 28 | 29 | {#if showWrapper} 30 |
31 | {#if params.collapsible || legendText} 32 | 33 | {#if params.collapsible } 34 | 35 | {/if} 36 | {@html stringToHtml(legendText)} 37 | {#if schema.description} 38 | {@html stringToHtml(schema.description)} 39 | {/if} 40 | 41 | {/if} 42 | 43 | {#if collapserOpenState === "open"} 44 | {#if !emptyText} 45 | {#each value || [] as item, idx (idx)} 46 | 56 |
57 | {#if controls.includes('delete') && !atMinItems} 58 | 59 | {/if} 60 | {#if controls.includes('duplicate') && !atMaxItems} 61 | 62 | {/if} 63 | {#if controls.includes('reorder') && idx > 0} 64 | 65 | {/if} 66 | {#if controls.includes('reorder') && idx < (value || []).length - 1} 67 | 68 | {/if} 69 |
70 | 71 | {/each} 72 | {:else} 73 |
{emptyText}
74 | {/if} 75 | {#if controls.includes('add') && !atMaxItems} 76 | 77 | {/if} 78 | {/if} 79 |
80 | {/if} -------------------------------------------------------------------------------- /src/lib/css/layout.scss: -------------------------------------------------------------------------------- 1 | form.svelte-schema-form { 2 | .subset { 3 | display: grid; 4 | grid-template-rows: auto; 5 | grid-gap: 1em; 6 | align-items: flex-start; 7 | padding-left: 1em; 8 | box-sizing: border-box; 9 | border: none; 10 | } 11 | 12 | .depth-0 { 13 | padding-left: 0; 14 | } 15 | 16 | .object { 17 | grid-template-columns: max-content 1fr; 18 | border-left: 1px solid #999; 19 | grid-column: span 2; 20 | } 21 | .array > .object { 22 | grid-column: span 1; 23 | } 24 | 25 | .object.depth-0 { 26 | border-left: none; 27 | } 28 | 29 | .array { 30 | grid-column: span 2; 31 | grid-template-columns: 1fr max-content; 32 | } 33 | 34 | .subset > .subset-label { 35 | margin-bottom: 1em; 36 | 37 | .subset-label-title { 38 | display: block; 39 | } 40 | } 41 | .array > legend { 42 | margin-left: -1em; 43 | } 44 | 45 | 46 | .array > .object { 47 | margin-left: -1em; 48 | } 49 | 50 | .list-item { 51 | display: flex; 52 | } 53 | 54 | input[type="checkbox"] { 55 | justify-self: start; 56 | } 57 | 58 | .error { 59 | grid-column: 1 / span 2; 60 | } 61 | 62 | .sf-drop-area { 63 | width: 100%; 64 | display: flex; 65 | 66 | .sf-upload-caption { 67 | position: absolute; 68 | top: 0; 69 | bottom: 0; 70 | left: 0; 71 | right: 30px; 72 | display: flex; 73 | justify-content: center; 74 | align-items: center; 75 | z-index: -1; 76 | } 77 | 78 | .sf-upload-controls { 79 | position: absolute; 80 | right: 0; 81 | display: flex; 82 | flex-direction: column; 83 | justify-content:space-between; 84 | height: 100%; 85 | padding: 4px; 86 | box-sizing: border-box; 87 | 88 | button { 89 | border: 0; 90 | padding: 0; 91 | height: 20px; 92 | width: 20px; 93 | cursor: pointer; 94 | } 95 | 96 | .sf-upload-deleter { 97 | width: 20px; 98 | height: 20px; 99 | cursor: pointer; 100 | } 101 | } 102 | 103 | .sf-upload-input { 104 | align-self: center; 105 | width: calc(100% - 30px); 106 | margin: 0 10px; 107 | } 108 | 109 | &.link .sf-upload-thumb, 110 | &.link .sf-upload-file { 111 | display: none; 112 | } 113 | .sf-upload-file { 114 | font-size: 1.4em; 115 | font-weight: bold; 116 | display: flex; 117 | flex-direction: column; 118 | justify-content: center; 119 | align-items: center; 120 | padding: 0.4em; 121 | color: white; 122 | background-color: #ddd; 123 | } 124 | } 125 | 126 | .sf-progress-bars { 127 | grid-column: 2; 128 | display: flex; 129 | flex-direction: column; 130 | gap: 3px; 131 | box-sizing: border-box; 132 | 133 | .sf-progress-bar { 134 | width: 100%; 135 | position: relative; 136 | box-sizing: border-box; 137 | 138 | .sf-progress-done { 139 | position: absolute; 140 | left: 0; 141 | top: 0; 142 | bottom: 0; 143 | } 144 | } 145 | } 146 | 147 | .sf-autocomplete { 148 | width: 100%; 149 | position: relative; 150 | 151 | .sf-items { 152 | z-index: 1; 153 | position: absolute; 154 | right: 0; 155 | left: 0; 156 | max-height: 12em; 157 | overflow-y: auto; 158 | overflow-x: hidden; 159 | } 160 | 161 | .sf-items > div, 162 | .sf-selected-item { 163 | display: flex; 164 | align-items: center; 165 | } 166 | } 167 | 168 | .list-detail { 169 | width: 100%; 170 | 171 | .table-container { 172 | display: grid; 173 | } 174 | 175 | .row-wrapper { 176 | display: contents; 177 | } 178 | 179 | .item { 180 | display: flex; 181 | } 182 | 183 | .table-container > .element { 184 | grid-column: span 3; 185 | } 186 | 187 | .add-button-container { 188 | grid-column: span 3; 189 | } 190 | } 191 | } -------------------------------------------------------------------------------- /src/routes/+page.svelte: -------------------------------------------------------------------------------- 1 | 82 | 83 |
84 |
85 |
86 | 87 | 88 |
89 | 90 |
91 |
92 | 93 |
94 |
95 |
 96 | 			{valueJson}
 97 | 		
98 |
99 |
100 | 101 | -------------------------------------------------------------------------------- /src/lib/SubmitForm.svelte: -------------------------------------------------------------------------------- 1 | 2 | 3 | 118 | 119 |
120 | 121 |
122 | 123 |
124 | -------------------------------------------------------------------------------- /src/lib/SchemaForm.svelte: -------------------------------------------------------------------------------- 1 | 2 | 3 | 146 | 147 | 148 | -------------------------------------------------------------------------------- /src/lib/css/basic-skin.scss: -------------------------------------------------------------------------------- 1 | :root { 2 | --input-padding: 3px; 3 | --input-border: 1px solid #aaa; 4 | } 5 | 6 | form.svelte-schema-form { 7 | font-family: 'Montserrat', sans-serif; 8 | 9 | input { 10 | font-family: 'Montserrat', sans-serif; 11 | } 12 | 13 | .collapser { 14 | background-size: 2rem 2rem; 15 | background-position: 50% 35%; 16 | display: inline-block; 17 | width: 1rem; 18 | height: 1rem; 19 | background-image: url(../img/right.svg); 20 | } 21 | .collapser.open { 22 | background-image: url(../img/down.svg); 23 | } 24 | 25 | .list-control { 26 | background-size: contain; 27 | background-repeat: no-repeat; 28 | width: 1.1em; 29 | height: 1.1em; 30 | border: none; 31 | cursor: pointer; 32 | } 33 | .add { 34 | background-image: url(../img/add.svg); 35 | width: 1.7em; 36 | height: 1.7em; 37 | } 38 | .delete { 39 | background-image: url(../img/delete.svg); 40 | } 41 | .up { 42 | background-image: url(../img/up.svg); 43 | } 44 | .down { 45 | background-image: url(../img/down.svg); 46 | } 47 | .duplicate { 48 | background-image: url(../img/duplicate.svg); 49 | } 50 | .info { 51 | background-size: contain; 52 | background-repeat: no-repeat; 53 | width: 0.6em; 54 | height: 0.6em; 55 | display: inline-block; 56 | background-image: url(../img/info.svg); 57 | background-size: 90% 90%; 58 | cursor: pointer; 59 | } 60 | 61 | 62 | label, .subset-label { 63 | color: #777; 64 | } 65 | .subset-label { 66 | font-weight: bold; 67 | 68 | .subset-label-description { 69 | font-size: 0.8em; 70 | } 71 | } 72 | 73 | .depth-0 > .subset-label { 74 | font-size: 1.5em; 75 | text-decoration: underline; 76 | } 77 | 78 | input, select, .input { 79 | border: var(--input-border); 80 | border-radius: 2px; 81 | padding: var(--input-padding); 82 | color: #777; 83 | } 84 | 85 | button { 86 | background-color: transparent; 87 | } 88 | 89 | .error { 90 | position: relative; 91 | top: -0.5em; 92 | color: red; 93 | } 94 | 95 | .button-container { 96 | display: flex; 97 | justify-content: center; 98 | margin-top: 2em; 99 | 100 | .submit-button { 101 | border: 1px solid #ccc; 102 | padding: 4px 8px; 103 | cursor: pointer; 104 | color: #aaa; 105 | } 106 | button:hover { 107 | background-color: #f8f4f4; 108 | } 109 | 110 | button { 111 | border: 2px solid #aaa; 112 | color: #777; 113 | } 114 | } 115 | 116 | &.dirty .submit-button { 117 | border: 2px solid #aaa; 118 | color: #777; 119 | } 120 | 121 | .sf-drop-area { 122 | height: 4em; 123 | border: var(--input-border); 124 | padding-right: 16px; 125 | position: relative; 126 | box-sizing: border-box; 127 | 128 | &.highlight { 129 | background-color: #eee; 130 | } 131 | 132 | .sf-upload-caption { 133 | font-weight: bold; 134 | font-size: 1.1em; 135 | color: #ccc; 136 | } 137 | 138 | .sf-upload-controls { 139 | .sf-upload-deleter { 140 | background-image: url(../img/delete.svg); 141 | background-repeat: no-repeat; 142 | background-size: contain; 143 | } 144 | 145 | .sf-upload-to-uploader { 146 | background-image: url(../img/upload.svg); 147 | background-repeat: no-repeat; 148 | background-size: contain; 149 | } 150 | .sf-upload-to-link { 151 | background-image: url(../img/link.svg); 152 | background-repeat: no-repeat; 153 | background-size: contain; 154 | } 155 | } 156 | } 157 | 158 | .sf-progress-bars .sf-progress-bar { 159 | border: var(--input-border); 160 | padding: var(--input-padding); 161 | font-size: 1em; 162 | 163 | .sf-progress-done { 164 | background-color: #80808050; 165 | } 166 | } 167 | 168 | .sf-autocomplete { 169 | .sf-items { 170 | background-color: #f8f8f8; 171 | } 172 | 173 | .sf-items, .sf-selected-item { 174 | img { 175 | max-height: 3em; 176 | max-width: 5em; 177 | margin-right: 1em; 178 | } 179 | } 180 | 181 | .sf-items > div { 182 | border: var(--input-border); 183 | border-top: none; 184 | font-size: .8em; 185 | padding: 6px; 186 | } 187 | 188 | .sf-selected-item { 189 | min-height: 22px; 190 | padding: 0; 191 | padding-left: var(--input-padding); 192 | font-size: .8em; 193 | 194 | input { 195 | border: none; 196 | padding: var(--input-padding); 197 | margin: 0; 198 | margin-left: -3px; 199 | width: 100%; 200 | } 201 | } 202 | } 203 | 204 | .list-detail { 205 | margin: 1em 0; 206 | 207 | .array-label { 208 | border: none; 209 | } 210 | 211 | .table-container { 212 | padding: 1em; 213 | border: 1px solid black; 214 | } 215 | 216 | .heading { 217 | text-align: center; 218 | font-weight: bold; 219 | } 220 | .heading, 221 | .heading + .buttons-header { 222 | border-bottom: 1px solid #777; 223 | } 224 | .heading.asc:after, 225 | .heading.desc:after { 226 | content: " "; 227 | display: inline-block; 228 | height: 14px; 229 | width: 14px; 230 | } 231 | .heading.asc:after { 232 | background-image: url(../img/down.svg); 233 | } 234 | .heading.desc:after { 235 | background-image: url(../img/up.svg); 236 | } 237 | 238 | .row-wrapper { 239 | display: contents; 240 | } 241 | 242 | .item { 243 | align-items: center; 244 | justify-content:center; 245 | } 246 | .item, 247 | .row-wrapper + .array-buttons { 248 | border-bottom: 1px solid #ccc; 249 | } 250 | .row-wrapper:hover div, 251 | .row-wrapper:hover + .sf-array-buttons { 252 | background-color: #f8f8f8; 253 | } 254 | 255 | .row-wrapper.selected div, 256 | .row-wrapper.selected + .sf-array-buttons { 257 | background-color: #eef; 258 | } 259 | .table-container > .element { 260 | padding: 5px 13px; 261 | } 262 | 263 | .control-button { 264 | width: 18px; 265 | height: 18px; 266 | margin-top: 0; 267 | } 268 | 269 | .row-buttons { 270 | padding: 5px 0 1px 0; 271 | } 272 | 273 | .add-button-container { 274 | justify-self: center; 275 | padding-top: 10px; 276 | } 277 | 278 | .add-button { 279 | width: 30px; 280 | height: 30px; 281 | } 282 | 283 | .element > .subset { 284 | border-left :none; 285 | } 286 | 287 | .to-list { 288 | border: none; 289 | position: relative; 290 | left: 10px; 291 | cursor: pointer; 292 | top: -7px; 293 | 294 | &:before { 295 | content: " "; 296 | background-image: url(../img/left-arrow.svg); 297 | height: 20px; 298 | width: 20px; 299 | background-size: contain; 300 | display: block; 301 | position: absolute; 302 | left: -19px; 303 | top: -2px; 304 | } 305 | } 306 | } 307 | } -------------------------------------------------------------------------------- /src/lib/editors/ArrayBlocks.svelte: -------------------------------------------------------------------------------- 1 | 165 | 166 |
167 |
    168 | {#each value || [] as item, idx (item)} 169 |
  1. false} 175 | on:dragenter|preventDefault={() => hovering = idx} 176 | on:dragleave={() => hovering = false} 177 | class:drag-over={hovering === idx}> 178 | 179 |

    180 | {getName(item)} 181 |

    182 |
    183 |
    184 | 185 | 186 |
    187 |
  2. 188 | {/each} 189 | 190 | {#if !adding} 191 |
  3. false} 194 | on:dragenter|preventDefault={() => hovering = lastIdx} 195 | on:dragleave={() => hovering = false} 196 | class:drag-over={hovering === lastIdx}> 197 |
  4. 198 | {/if} 199 |
200 | {#if adding} 201 | 210 | 211 | {/if} 212 |
-------------------------------------------------------------------------------- /src/lib/utilities.ts: -------------------------------------------------------------------------------- 1 | import { get } from "lodash-es"; 2 | 3 | export const upTo = (str: string, match: string, start?: number) => { 4 | const pos = str.indexOf(match, start); 5 | return pos < 0 ? str.substring(start || 0) : str.substring(start || 0, pos); 6 | } 7 | 8 | export const upToLast = (str: string, match: string, end?: number) => { 9 | const pos = str.lastIndexOf(match, end); 10 | return pos < 0 ? str.substring(0, end || str.length) : str.substring(0, pos); 11 | } 12 | 13 | export const after = (str: string, match: string, start?: number) => { 14 | const pos = str.indexOf(match, start); 15 | return pos < 0 ? '' : str.substring(pos + match.length); 16 | } 17 | 18 | export const afterLast = (str: string, match: string, end?: number) => { 19 | const pos = str.lastIndexOf(match, end); 20 | return pos < 0 ? '' : str.substring(pos + match.length, end || str.length); 21 | } 22 | 23 | export function camelToWords(camel: string): string { 24 | camel = camel.trim(); 25 | const words: string[] = []; 26 | let start = 0; 27 | for (let end = 1; end < camel.length; end++) { 28 | if ('A' <= camel[end] && camel[end] <= 'Z') { 29 | words.push(camel.substring(start, end).toLowerCase()); 30 | start = end; 31 | } 32 | } 33 | words.push(camel.substring(start, camel.length).toLowerCase()); 34 | 35 | return words.join(' '); 36 | } 37 | 38 | export function camelToTitle(camel: string): string { 39 | return camelToWords(camel).replace(/[a-z]/i, (ltr) => ltr.toUpperCase()) 40 | } 41 | 42 | /** manipulate the schema to allow any optional property to have a null value 43 | * which is appropriate for form input */ 44 | export function nullOptionalsAllowed(schema: object): object { 45 | if (schema === null || schema === undefined) schema = {}; 46 | let newSchema = deepCopy(schema); 47 | nullOptionalsAllowedApply(newSchema as Record); 48 | return newSchema; 49 | } 50 | 51 | function nullOptionalsAllowedApply(schema: Record) { 52 | let req = (schema['required'] || []) as Array; 53 | if (schema['$ref']) return; 54 | switch (schema['type']) { 55 | case 'object': 56 | const properties = (schema['properties'] || {}) as Record; 57 | for (let prop in properties) { 58 | if (req.indexOf(prop) < 0) { 59 | nullOptionalsAllowedApply(properties[prop] as Record); 60 | } 61 | } 62 | break; 63 | case 'array': 64 | const items = (schema['items'] || {}) as Record; 65 | nullOptionalsAllowedApply(items); 66 | if (items['oneOf'] && !(items['oneOf'] as any[]).some(subschema => subschema["type"] == "null")) { 67 | (items['oneOf'] as any[]).push({ type: 'null' }); 68 | } 69 | break; 70 | default: 71 | if (Array.isArray(schema['type'])) { 72 | if (schema['type'].indexOf('null') < 0) { 73 | schema['type'].push('null'); 74 | } 75 | } else if (schema['type'] != 'null') { 76 | schema['type'] = [schema['type'], 'null']; 77 | } 78 | break; 79 | } 80 | const defns = schema['definitions'] as Record; 81 | if (defns) { 82 | for (let defn in defns) { 83 | nullOptionalsAllowedApply(defns[defn] as Record); 84 | } 85 | } 86 | } 87 | 88 | export function deepCopy(obj: object): object { 89 | var copy; 90 | 91 | // Handle the 3 simple types, and null or undefined 92 | if (null == obj || "object" != typeof obj) return obj; 93 | 94 | // Handle Date 95 | if (obj instanceof Date) { 96 | copy = new Date(); 97 | copy.setTime(obj.getTime()); 98 | return copy; 99 | } 100 | 101 | // Handle Array 102 | if (obj instanceof Array) { 103 | copy = []; 104 | for (var i = 0, len = obj.length; i < len; i++) { 105 | copy[i] = deepCopy(obj[i]); 106 | } 107 | return copy; 108 | } 109 | 110 | // Handle Object 111 | if (obj instanceof Object) { 112 | copy = {} as Record; 113 | const recObj = obj as Record; 114 | for (var attr in recObj) { 115 | if (recObj.hasOwnProperty(attr)) copy[attr] = deepCopy(recObj[attr] as object); 116 | } 117 | return copy; 118 | } 119 | 120 | throw new Error("Unable to copy obj! Its type isn't supported."); 121 | } 122 | 123 | let incrVal = 0; 124 | export const incr = () => incrVal++; 125 | 126 | export const substituteProperties = (subsPattern: string, value: any) => { 127 | if (!subsPattern || !value) return subsPattern; 128 | const parts = subsPattern.split('${'); 129 | const partsOut: string[] = []; 130 | partsOut.push(parts.shift()!); 131 | for (let part of parts) { 132 | if (part.includes('}')) { 133 | const path = upTo(part, '}'); 134 | const subsVal = (path === '' ? value : get(value, path)) || ''; 135 | partsOut.push(`${subsVal}${after(part, '}')}`); 136 | } 137 | } 138 | return partsOut.join(''); 139 | } 140 | 141 | export function slashTrim(s: string): string { 142 | let start = 0; 143 | let end = s.length; 144 | if (s[start] === '/') start++; 145 | if (s[end - 1] === '/') end--; 146 | if (end <= start) return ''; 147 | return s.substring(start, end); 148 | } 149 | 150 | export function slashTrimLeft(s: string): string { 151 | return s.startsWith('/') ? s.substr(1) : s; 152 | } 153 | 154 | export function pathToArray(path: string) { 155 | return slashTrim(path).split('/').filter(s => !!s); 156 | } 157 | 158 | export function getExtension(s: string): string { 159 | let extStart = s.lastIndexOf('.'); 160 | return extStart < 0 ? '' : s.substr(extStart + 1); 161 | } 162 | 163 | export function getFirstLine(s: string): string { 164 | let lineEnd = s.indexOf('\n'); 165 | if (lineEnd < 0) return s; 166 | if (lineEnd > 0 && s[lineEnd - 1] === '\r') lineEnd--; 167 | return s.substring(0, lineEnd); 168 | } 169 | 170 | export function getTailLines(s: string): string { 171 | return s.substring(s.indexOf('\n') + 1); 172 | } 173 | 174 | export function pathCombine(...args: string[]): string { 175 | const stripped = args.filter(a => !!a); 176 | if (stripped.length === 0) return ''; 177 | const startSlash = stripped[0].startsWith('/'); 178 | const endSlash = stripped[stripped.length - 1].endsWith('/'); 179 | let joined = stripped.map(a => slashTrim(a)).filter(a => !!a).join('/'); 180 | if (startSlash) joined = '/' + joined; 181 | if (endSlash && joined !== '/') joined += '/'; 182 | return joined; 183 | } 184 | 185 | export function stringToHtml(s: string) { 186 | return (s || '').replace("\n", "
"); 187 | } -------------------------------------------------------------------------------- /src/lib/editors/Upload.svelte: -------------------------------------------------------------------------------- 1 | 148 | 149 | 150 | 155 |
164 | {#if mode === "uploader" && !readOnly} 165 |
166 | Drop files or click to upload 167 |
168 | {/if} 169 | {#if value && isImage(value) && mode === "uploader"} 170 | upload file 171 | {/if} 172 | {#if value && !isImage(value) && mode === "uploader"} 173 |
{afterLast(value, ".")}
174 | {/if} 175 | {#if mode === "link"} 176 | {}} 183 | on:input={ev => params.pathChanged(params.path, ev.currentTarget.value || undefined)} /> 184 | {/if} 185 |
186 | {#if !(readOnly)} 187 | 188 | {/if} 189 | 194 |
195 |
196 | {#if Object.keys(progress).length > 0} 197 |
198 | {#each Object.entries(progress) as [name, percent]} 199 |
200 |
201 | {name} 202 |
203 | {/each} 204 |
205 | {/if} 206 |
-------------------------------------------------------------------------------- /src/lib/editors/ListDetail.svelte: -------------------------------------------------------------------------------- 1 | 137 | 138 | {#if showWrapper} 139 |
140 | {#if params.collapsible || legendText} 141 | 142 | {#if params.collapsible } 143 | 144 | {/if} 145 | {@html stringToHtml(legendText)} 146 | {#if schema.description} 147 | {@html stringToHtml(schema.description)} 148 | {/if} 149 | 150 | {/if} 151 | 152 | {#if collapserOpenState === "open"} 153 | {#if !emptyText} 154 |
155 | {#if mode === "list"} 156 | {#each listFields as fieldName, idx} 157 |
{fieldName}
158 | {/each} 159 | {#if !readOnly} 160 |
 
161 | {/if} 162 | {#each rowView as item, idx (idx)} 163 |
164 | {#each listProps as propName} 165 |
{item[propName] === undefined ? '\u00A0' : item[propName]}
166 | {/each} 167 |
168 | {#if !readOnly} 169 |
170 |
171 | {#if controls.includes('delete')} 172 | 173 | {/if} 174 | {#if controls.includes('duplicate')} 175 | 176 | {/if} 177 | {#if controls.includes('reorder') && sort === null && idx > 0} 178 | 179 | {/if} 180 | {#if controls.includes('reorder') && sort === null && idx < (value || []).length - 1} 181 | 182 | {/if} 183 |
184 |
185 | {/if} 186 | {/each} 187 | {:else} 188 | 189 |
190 | 200 | {#if schema.submit} 201 | 202 | {/if} 203 |
204 | {/if} 205 |
206 | {:else} 207 |
{emptyText}
208 | {/if} 209 | {#if controls.includes('add')} 210 | 211 | {/if} 212 | {/if} 213 |
214 | {/if} -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Svelte Schema Form 2 | 3 | This is a Svelte implementation of a form generator from JSON Schema. It supports subforms, lists with reordering of items, custom renderer components, and customisable CSS skinning separating layout and look-and-feel. 4 | 5 | JSON Schema is a powerful validation/type definition language for JSON data. See this for more information on [JSON Schema](https://cswr.github.io/JsonSchema/spec/introduction/) 6 | 7 | ## How to use 8 | 9 | Install with npm 10 | 11 | npm install @restspace/svelte-schema-form 12 | 13 | Use in a component 14 | 15 | 32 | 33 | 34 | 35 | The `layout.css` file creates a standard form layout. `basic-skin.css` adds a very simple skin with fonts, colours etc on top of that. Note, you'll likely need to configure your bundler to interpret importing css to make this work. 36 | 37 | The `SubmitForm` component manages validation of the entered data using the full JSON Schema spec and renders any error messages beside the relevant components. Standard behaviour is that errors are not shown until the Submit button has been clicked at least once. 38 | 39 | ## JSON Schema support 40 | 41 | | Feature | Support | 42 | |---|---| 43 | |title|This property is used to label a field or fieldset, if it's absent a conversion from camel case (myFieldName) to proper case (My Field Name) is done. Newlines in the string are converted to br tags in HTML| 44 | |description|This property is shown beside the label as a tooltip using the HTML title attribute or as a subheading for arrays/objects.| 45 | |readOnly|This property if present and set to `true` will disable the editor for this field, only using it for display. All children of this field will also be read only| 46 | |type="string"|By default, renders as an input element with type="text"| 47 | |minLength, maxLength|Supported in validation| 48 | |pattern|Supported in validation| 49 | |format|Support for `password`, `email`, `date`, `time` and `date-time` via HTML input type| 50 | |type="number"|By default, renders as an input element with type="number"| 51 | |minimum, maximum|Supported in validation| 52 | |exclusiveMinimum, exclusiveMaximum|Supported in validation| 53 | |enum|A field with the enum property is instead rendered as a Select element with all the enum options given| 54 | |enumText|Custom property to supply display labels for the Select element as an array of strings, one for each enum value| 55 | |type="object"|Every object is rendered within an HTML Fieldset element with a form field for each property| 56 | |required|Object fields named in the `required` list have to have a value entered to be valid. Required fields have the `required` class added to their labels to enable this to be displayed. 57 | |type="array"|Every array is rendered within an HTML Fieldset element with controls for adding, deleting, moving and duplicating array items (based on the `items` property)| 58 | |emptyDisplay|Custom property which determines how the array displays if it has no items. `false` means don't show a header or wrapper. `true` means show the header and wrapper with no items. A string value means display this message in the wrapper. 59 | |controls|Custom property which is a comma separated list of controls including `delete`, `duplicate`, `reorder`, `add`. Default is all these. A readOnly array has no controls.| 60 | |editor|Custom property that lets you pick a custom editor for a schema.| 61 | ||| 62 | 63 | ## Components 64 | 65 | ### SchemaForm 66 | A group of based on a schema, no submit functionality 67 | 68 | import {SchemaForm} from "@restspace/svelte-schema-form"; 69 | 70 | 80 | 81 | ### SubmitForm 82 | An HTML form with a submit button and a submit flow 83 | 84 | import {SubmitForm} from "@restspace/svelte-schema-form"; 85 | 86 | 99 | 100 | ## Custom editors 101 | 102 | ### Currency 103 | 104 | Setting `editor="currency"` on a `type="number"` subschema renders it as a currency field. This automatically inserts a currency symbol in the input field, by default this is a `$` but can be changed to another symbol via setting the `currencySymbol` property on the object passed in to the `componentContext` prop, to e.g. '£'. You can also specify a custom formatting function which takes a value of type number and returns a string and set the `formatCurrency` property on `componentContext` to this function. 105 | 106 | ### Radio 107 | 108 | Setting `editor="radio"` on a subschema with `enum` set renders the enum as radio buttons instead of a select. 109 | 110 | ### Upload 111 | 112 | Setting `editor="upload"` on a `type="string"` subschema means that this property will be rendered as a file uploader. This component requies a `SubmitForm`. The file uploader allows files to be dragged onto it, or to be clicked to open a file dialog. Files are sent on submit via a PUT request to a path composed: 113 | 114 | /// 115 | 116 | - `uploadBaseUrl` is the prop on `SubmitForm` 117 | - `uploadName` is the `uploadNamePattern` with form field value(s) substituted in for `${}` codes so as to create a name that will be unique for each stored record 118 | - `path` is the property path of the upload control with dot separators and array poitions just being an index with no square brackets e.g. `profilePics.3.image` 119 | 120 | After successful submit, the property value for the editor is set to the url where the file was uploaded. The uploader has a button at the right bottom to switch modes to show a text input field with the url of the stored file. 121 | 122 | ### Autocomplete 123 | 124 | Setting `editor="autocomplete"` on a `type="string"` subschema means this property will be rendered as an autocomplete dropdown. The autocomplete component is a text box which on each keystroke sends the entered text to a remote url to get a list of matching items which are then shown as a dropdown. The user can choose one of the displayed items at any point. 125 | 126 | The schema needs an additional property `url` set to the base url for remote querying. Example subschema: 127 | 128 | { 129 | "type": "string", 130 | "editor": "autocomplete", 131 | "url": "https://mysite.com/autocompletes" 132 | } 133 | 134 | 135 | To this url is added the query string item `match=xyz` where xyz is the current text in the search box to match on. A `GET` request is made to the result. The url should respond with an `application/json` body which can either be 136 | 137 | [ "dropdown item 1", "dropdown item 2", ... ] 138 | 139 | or 140 | 141 | [ 142 | { 143 | "id": "1234", 144 | "text": "An item title", 145 | "image": "https://images.com/an-image.jpg" 146 | }, ... 147 | ] 148 | 149 | In the latter case, the `id` field is returned as the value of the editor, the `text` field determines the text shown in the field, and the optional `image` url gives an image to display alongside the text. 150 | 151 | ### List Detail 152 | 153 | Setting `editor="list-detail"` on a `type="array"` subschema whose items are `type="object"` shows the list of objects in a listing grid which when a row is selected, switches to the normal editor for the object selected. It also provides heading-click view ordering (without mutating the order of the underlying data list). It responds to the `emptyDisplay` and 154 | `controls` custom properties defined for an array. 155 | 156 | The `type="object"` subschema can have two optional custom schema properties: 157 | - `headings`: an array of property names which are included as columns in the list. Defaults to all columns. 158 | - `defaultSort`: an object with properties `field` which specifies the default heading field to sort on, and `direction` which can be `"asc"` or `"desc"` to specify the direction of the default sort. 159 | 160 | ## Custom rendering components 161 | 162 | Svelte Schema Form can override or add rendering components at any level by supplying a map of component type names and component classes. 163 | 164 | For example components, look in the /src/lib/editors directory. As an illustration, consider the Number.svelte default editor: 165 | 166 | 172 | 173 | 174 | 175 | { 179 | let val = parseFloat(ev.currentTarget.value); 180 | params.pathChanged(params.path, isNaN(val) ? undefined : val); 181 | }} 182 | /> 183 | 184 | 185 | The component needs to have the 3 props shown in this component. `schema` and `value` are the local parts of the full schema and value which need to be rendered by this component. `params` contains a number of constant values defining the component including `params.pathChanged` which is a function that needs to be called with the property path to the value the component is rendering (this is `params.path`) and the new value when the value of the form field is changed. 186 | 187 | Note how the editor supports the `readOnly` attribute by disabling the editor if set. 188 | 189 | The `` component wrapping the markup renders the default FieldWrapper.svelte component around the actual editor: this adds the field's standard label and error message. 190 | 191 | An example of how to configure a custom component: 192 | 193 | 201 | 202 | 203 | 204 | You'd then make use of this custom editor component in a schema like this: 205 | 206 | { 207 | "type": "object", 208 | "properties": { 209 | "myField": { 210 | "type": "string", 211 | "editor": "myEditor" 212 | } 213 | } 214 | } 215 | 216 | The key in the `components` map is matched to, in priority order, the `editor` property, then the `format` property, then the `type` property. This means you can also substitute editors for field groupings, which is more tricky. Work from the Array.svelte and Object.svelte default editor components in the repo. 217 | --------------------------------------------------------------------------------