Move JSON Schema logic to Declaform instead of making another component.
This commit is contained in:
@@ -10,5 +10,9 @@
|
|||||||
"test": "npx jest"
|
"test": "npx jest"
|
||||||
},
|
},
|
||||||
"author": "Austin Smith",
|
"author": "Austin Smith",
|
||||||
"license": "ISC"
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"ajv": "^8.17.1",
|
||||||
|
"ajv-formats": "^3.0.1"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,11 @@
|
|||||||
|
import Ajv, { type ValidateFunction } from 'ajv';
|
||||||
|
import addFormats from 'ajv-formats';
|
||||||
|
|
||||||
|
const ajv = new Ajv({
|
||||||
|
coerceTypes: true,
|
||||||
|
});
|
||||||
|
addFormats(ajv);
|
||||||
|
|
||||||
interface FieldElement {
|
interface FieldElement {
|
||||||
name: string;
|
name: string;
|
||||||
type: string;
|
type: string;
|
||||||
@@ -93,13 +101,17 @@ export class Declaform extends HTMLElement {
|
|||||||
'src', // defines api endpoint used to hydrate this object
|
'src', // defines api endpoint used to hydrate this object
|
||||||
'action', // defines api endpoint used to submit this object to
|
'action', // defines api endpoint used to submit this object to
|
||||||
'method', // defines http method to use on "action" endpoint
|
'method', // defines http method to use on "action" endpoint
|
||||||
]
|
'schema', // url for a remote JSON Schema definition
|
||||||
|
];
|
||||||
|
|
||||||
/* The character that gets used to join multiple field values */
|
/* The character that gets used to join multiple field values */
|
||||||
static joining_char = ',';
|
static joining_char = ',';
|
||||||
|
|
||||||
private _fields: Field[];
|
private _fields: Field[];
|
||||||
private _form: HTMLFormElement | null;
|
private _form: HTMLFormElement | null;
|
||||||
|
private _validate: ValidateFunction | undefined;
|
||||||
|
ready: Promise<void>;
|
||||||
|
private readyResolve!: () => void;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
@@ -109,6 +121,10 @@ export class Declaform extends HTMLElement {
|
|||||||
this.shadowRoot!.innerHTML = '<slot name="form"></slot>';
|
this.shadowRoot!.innerHTML = '<slot name="form"></slot>';
|
||||||
this._fields = [];
|
this._fields = [];
|
||||||
this._form = null;
|
this._form = null;
|
||||||
|
this._validate = undefined;
|
||||||
|
this.ready = new Promise(resolve => {
|
||||||
|
this.readyResolve = resolve;
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async connectedCallback() {
|
async connectedCallback() {
|
||||||
@@ -151,10 +167,23 @@ export class Declaform extends HTMLElement {
|
|||||||
this.hydrateFromEndpoint(src);
|
this.hydrateFromEndpoint(src);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const schemaUrl = this.getAttribute('schema');
|
||||||
|
if (schemaUrl) {
|
||||||
|
const res = await fetch(schemaUrl);
|
||||||
|
const schema = await res.json();
|
||||||
|
this._validate = ajv.compile(schema);
|
||||||
|
}
|
||||||
|
|
||||||
this._form.addEventListener('submit', async (e) => {
|
this._form.addEventListener('submit', async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
const isValid = (this.hasAttribute('schema')) ?
|
||||||
|
await this.validate(e) : true;
|
||||||
|
if (isValid) {
|
||||||
await this.submit();
|
await this.submit();
|
||||||
})
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.readyResolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
get form() {
|
get form() {
|
||||||
@@ -202,13 +231,24 @@ export class Declaform extends HTMLElement {
|
|||||||
populateFields(obj, '');
|
populateFields(obj, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async validate(e?: SubmitEvent) {
|
||||||
|
e?.preventDefault();
|
||||||
|
if (!this._validate) {
|
||||||
|
console.warn(`[Declaform] Warning: _validate is not defined`)
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const obj = this.toObject();
|
||||||
|
const isValid = this._validate(obj);
|
||||||
|
return isValid;
|
||||||
|
}
|
||||||
|
|
||||||
private async hydrateFromEndpoint(endpoint: string): Promise<void> {
|
private async hydrateFromEndpoint(endpoint: string): Promise<void> {
|
||||||
const response = await fetch(endpoint);
|
const response = await fetch(endpoint);
|
||||||
const obj = await response.json();
|
const obj = await response.json();
|
||||||
this.hydrate(obj);
|
this.hydrate(obj);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async submit(): Promise<void> {
|
async submit(): Promise<void> {
|
||||||
const method = this.getAttribute('method') ?? 'POST';
|
const method = this.getAttribute('method') ?? 'POST';
|
||||||
const endpoint = new URL(this.getAttribute('action') ?? '');
|
const endpoint = new URL(this.getAttribute('action') ?? '');
|
||||||
const obj = this.toObject();
|
const obj = this.toObject();
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { Declaform } from '../src';
|
import { Declaform } from '../src';
|
||||||
|
|
||||||
const MOVIE_FORM_NO_SRC = `
|
function initTestFormWithAttributes(attributes: Record<string, any>) {
|
||||||
<decla-form id="form">
|
const attributeStr = Object.entries(attributes).map(x => x.join('=')).join(' ');
|
||||||
|
return `
|
||||||
|
<decla-form id="form" ${attributeStr}>
|
||||||
<form slot="form">
|
<form slot="form">
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<label for="name">Name</label>
|
<label for="name">Name</label>
|
||||||
@@ -29,38 +31,39 @@ const MOVIE_FORM_NO_SRC = `
|
|||||||
<button type="submit">Submit</button>
|
<button type="submit">Submit</button>
|
||||||
</form>
|
</form>
|
||||||
</decla-form>
|
</decla-form>
|
||||||
`;
|
`
|
||||||
|
|
||||||
const MOVIE_FORM_HTTP = `
|
}
|
||||||
<decla-form id="form" src="movie/jaws" action="movie/jaws" method="PUT">
|
|
||||||
<form slot="form">
|
const MOVIE_SCHEMA = {
|
||||||
<div class="input-group">
|
type: 'object',
|
||||||
<label for="name">Name</label>
|
properties: {
|
||||||
<input id="name" name="name" />
|
name: {
|
||||||
</div>
|
type: 'string',
|
||||||
<select id="genre" name="genre">
|
minLength: 1,
|
||||||
<option value="comedy">Comedy</option>
|
},
|
||||||
<option value="horror">Horror</option>
|
genre: {
|
||||||
</select>
|
type: 'string',
|
||||||
<fieldset>
|
enum: ['comedy', 'horror'],
|
||||||
<legend>Rating</legend>
|
},
|
||||||
<div class="input-group">
|
rating: {
|
||||||
<label for="rating-type-user">Type</label>
|
type: 'object',
|
||||||
<input id="rating-type-user" name="rating.type" type="radio" value="user" />
|
properties: {
|
||||||
</div>
|
type: {
|
||||||
<div class="input-group">
|
type: 'string',
|
||||||
<label for="rating-type-critic">Type</label>
|
enum: ['critic', 'user'],
|
||||||
<input id="rating-type-critic" name="rating.type" type="radio" value="critic" />
|
},
|
||||||
</div>
|
score: {
|
||||||
<div class="input-group">
|
type: 'number',
|
||||||
<label for="rating-score">Score</label>
|
minimum: 0,
|
||||||
<input id="rating-score" name="rating.score" type="number" />
|
maximum: 5,
|
||||||
</div>
|
},
|
||||||
</fieldset>
|
},
|
||||||
<button type="submit">Submit</button>
|
required: ['type', 'score'],
|
||||||
</form>
|
}
|
||||||
</decla-form>
|
},
|
||||||
`;
|
required: ['name', 'genre', 'rating']
|
||||||
|
}
|
||||||
|
|
||||||
describe('Declaform', () => {
|
describe('Declaform', () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -68,12 +71,12 @@ describe('Declaform', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('Should mount.', () => {
|
it('Should mount.', () => {
|
||||||
document.body.innerHTML = MOVIE_FORM_NO_SRC;
|
document.body.innerHTML = initTestFormWithAttributes({});
|
||||||
expect(document.getElementById('form')).toBeTruthy();
|
expect(document.getElementById('form')).toBeTruthy();
|
||||||
})
|
})
|
||||||
|
|
||||||
it('.toObject() should serialize form into a JS object', () => {
|
it('.toObject() should serialize form into a JS object', () => {
|
||||||
document.body.innerHTML = MOVIE_FORM_NO_SRC;
|
document.body.innerHTML = initTestFormWithAttributes({});
|
||||||
const form = document.getElementById('form') as Declaform;
|
const form = document.getElementById('form') as Declaform;
|
||||||
setInputValue('name', 'Monty Python');
|
setInputValue('name', 'Monty Python');
|
||||||
setInputValue('genre', 'comedy');
|
setInputValue('genre', 'comedy');
|
||||||
@@ -91,7 +94,7 @@ describe('Declaform', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('.hydrate() should populate input fields from a JS object', () => {
|
it('.hydrate() should populate input fields from a JS object', () => {
|
||||||
document.body.innerHTML = MOVIE_FORM_NO_SRC;
|
document.body.innerHTML = initTestFormWithAttributes({});
|
||||||
const form = document.getElementById('form') as Declaform;
|
const form = document.getElementById('form') as Declaform;
|
||||||
form.hydrate({
|
form.hydrate({
|
||||||
name: 'Jaws',
|
name: 'Jaws',
|
||||||
@@ -120,7 +123,11 @@ describe('Declaform', () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
document.body.innerHTML = MOVIE_FORM_HTTP;
|
document.body.innerHTML = initTestFormWithAttributes({
|
||||||
|
src: 'movie/123',
|
||||||
|
action: 'movie/123',
|
||||||
|
method: 'PUT',
|
||||||
|
});
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||||
|
|
||||||
@@ -129,6 +136,25 @@ describe('Declaform', () => {
|
|||||||
expect(getInputValue('rating-score')).toBe('5');
|
expect(getInputValue('rating-score')).toBe('5');
|
||||||
expect(document.getElementById('rating-type-critic')?.hasAttribute('checked')).toBe(true)
|
expect(document.getElementById('rating-type-critic')?.hasAttribute('checked')).toBe(true)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('Should be able to be validated against a JSON Schema', async () => {
|
||||||
|
globalThis.fetch = jest.fn().mockResolvedValue({
|
||||||
|
status: 200,
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve(MOVIE_SCHEMA),
|
||||||
|
});
|
||||||
|
|
||||||
|
document.body.innerHTML = initTestFormWithAttributes({
|
||||||
|
schema: 'movie_schema'
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = document.getElementById('form') as Declaform;
|
||||||
|
await form.ready;
|
||||||
|
const isValid = await form.validate();
|
||||||
|
|
||||||
|
expect(isValid).toBe(false);
|
||||||
|
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function setInputValue(id: string, value: string) {
|
function setInputValue(id: string, value: string) {
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "json-schema",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"description": "Web component which validates a form against a JSON Schema.",
|
|
||||||
"main": "index.js",
|
|
||||||
"scripts": {
|
|
||||||
"test": "npx jest"
|
|
||||||
},
|
|
||||||
"author": "Austin Smith",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"ajv": "^8.17.1",
|
|
||||||
"ajv-formats": "^3.0.1"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
/**
|
|
||||||
* Validates a form against a JSON Schema validated by Ajv.
|
|
||||||
*
|
|
||||||
* Emits CustomEvents:
|
|
||||||
* - validationSuccess
|
|
||||||
* - validationFailure
|
|
||||||
*/
|
|
||||||
import Ajv from 'ajv';
|
|
||||||
import addFormats from 'ajv-formats';
|
|
||||||
|
|
||||||
const ajv = new Ajv();
|
|
||||||
addFormats(ajv);
|
|
||||||
|
|
||||||
export class JsonSchema extends HTMLElement {
|
|
||||||
static observedAttributes = [
|
|
||||||
'src', // url of remote schema
|
|
||||||
'for', // id of form the schema is applied to`
|
|
||||||
]
|
|
||||||
|
|
||||||
private form: HTMLFormElement | null;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
super();
|
|
||||||
|
|
||||||
this.attachShadow({ mode: 'open' });
|
|
||||||
this.shadowRoot!.innerHTML = '<div><slot name="schema"></slot></div>';
|
|
||||||
this.form = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async connectedCallback() {
|
|
||||||
const for_attr = this.getAttribute('for');
|
|
||||||
if (!for_attr) {
|
|
||||||
throw new Error('[Json-Schema] Error: "for" attribute must be defined');
|
|
||||||
}
|
|
||||||
this.form = document.getElementById(for_attr) as HTMLFormElement;
|
|
||||||
this.form.addEventListener('submit', () => {
|
|
||||||
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
validate() {
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
customElements.define('json-schema', JsonSchema);
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "../../tsconfig.json",
|
|
||||||
"compilerOptions": {
|
|
||||||
"rootDir": "./src",
|
|
||||||
"outDir": "../../dist/json-schema"
|
|
||||||
},
|
|
||||||
"include": ["src"]
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user