Move JSON Schema logic to Declaform instead of making another component.

This commit is contained in:
Austin Smith
2025-11-18 09:25:35 -05:00
parent 0d4adef274
commit c8d52f15cc
6 changed files with 137 additions and 136 deletions

View File

@@ -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"
}
} }

View File

@@ -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();
await this.submit(); const isValid = (this.hasAttribute('schema')) ?
}) await this.validate(e) : true;
if (isValid) {
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();

View File

@@ -1,66 +1,69 @@
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(' ');
<form slot="form"> return `
<div class="input-group"> <decla-form id="form" ${attributeStr}>
<label for="name">Name</label> <form slot="form">
<input id="name" name="name" />
</div>
<select id="genre" name="genre">
<option value="comedy">Comedy</option>
<option value="horror">Horror</option>
</select>
<fieldset>
<legend>Rating</legend>
<div class="input-group"> <div class="input-group">
<label for="rating-type-user">Type</label> <label for="name">Name</label>
<input id="rating-type-user" name="rating.type" type="radio" value="user" /> <input id="name" name="name" />
</div> </div>
<div class="input-group"> <select id="genre" name="genre">
<label for="rating-type-critic">Type</label> <option value="comedy">Comedy</option>
<input id="rating-type-critic" name="rating.type" type="radio" value="critic" /> <option value="horror">Horror</option>
</div> </select>
<div class="input-group"> <fieldset>
<label for="rating-score">Score</label> <legend>Rating</legend>
<input id="rating-score" name="rating.score" type="number" /> <div class="input-group">
</div> <label for="rating-type-user">Type</label>
</fieldset> <input id="rating-type-user" name="rating.type" type="radio" value="user" />
<button type="submit">Submit</button> </div>
</form> <div class="input-group">
</decla-form> <label for="rating-type-critic">Type</label>
`; <input id="rating-type-critic" name="rating.type" type="radio" value="critic" />
</div>
<div class="input-group">
<label for="rating-score">Score</label>
<input id="rating-score" name="rating.score" type="number" />
</div>
</fieldset>
<button type="submit">Submit</button>
</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) {

View File

@@ -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"
}
}

View File

@@ -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);

View File

@@ -1,8 +0,0 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "../../dist/json-schema"
},
"include": ["src"]
}