diff --git a/packages/declaform/package.json b/packages/declaform/package.json index c4199f5..b67ea69 100644 --- a/packages/declaform/package.json +++ b/packages/declaform/package.json @@ -10,5 +10,9 @@ "test": "npx jest" }, "author": "Austin Smith", - "license": "ISC" + "license": "ISC", + "dependencies": { + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1" + } } diff --git a/packages/declaform/src/index.ts b/packages/declaform/src/index.ts index d7daae8..66ec355 100644 --- a/packages/declaform/src/index.ts +++ b/packages/declaform/src/index.ts @@ -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 { name: string; type: string; @@ -93,13 +101,17 @@ export class Declaform extends HTMLElement { 'src', // defines api endpoint used to hydrate this object 'action', // defines api endpoint used to submit this object to '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 */ static joining_char = ','; private _fields: Field[]; private _form: HTMLFormElement | null; + private _validate: ValidateFunction | undefined; + ready: Promise; + private readyResolve!: () => void; constructor() { super(); @@ -109,6 +121,10 @@ export class Declaform extends HTMLElement { this.shadowRoot!.innerHTML = ''; this._fields = []; this._form = null; + this._validate = undefined; + this.ready = new Promise(resolve => { + this.readyResolve = resolve; + }) } async connectedCallback() { @@ -151,10 +167,23 @@ export class Declaform extends HTMLElement { 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) => { e.preventDefault(); - await this.submit(); - }) + const isValid = (this.hasAttribute('schema')) ? + await this.validate(e) : true; + if (isValid) { + await this.submit(); + } + }); + + this.readyResolve(); } get form() { @@ -202,13 +231,24 @@ export class Declaform extends HTMLElement { 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 { const response = await fetch(endpoint); const obj = await response.json(); this.hydrate(obj); } - private async submit(): Promise { + async submit(): Promise { const method = this.getAttribute('method') ?? 'POST'; const endpoint = new URL(this.getAttribute('action') ?? ''); const obj = this.toObject(); diff --git a/packages/declaform/tests/declaform.test.ts b/packages/declaform/tests/declaform.test.ts index 20db120..ff6b3e6 100644 --- a/packages/declaform/tests/declaform.test.ts +++ b/packages/declaform/tests/declaform.test.ts @@ -1,66 +1,69 @@ import { Declaform } from '../src'; -const MOVIE_FORM_NO_SRC = ` - -
-
- - -
- -
- Rating +function initTestFormWithAttributes(attributes: Record) { + const attributeStr = Object.entries(attributes).map(x => x.join('=')).join(' '); + return ` + +
- - + +
-
- - -
-
- - -
-
- -
-
-`; + +
+ Rating +
+ + +
+
+ + +
+
+ + +
+
+ + + +` -const MOVIE_FORM_HTTP = ` - -
-
- - -
- -
- Rating -
- - -
-
- - -
-
- - -
-
- -
-
-`; +} + +const MOVIE_SCHEMA = { + type: 'object', + properties: { + name: { + type: 'string', + minLength: 1, + }, + genre: { + type: 'string', + enum: ['comedy', 'horror'], + }, + rating: { + type: 'object', + properties: { + type: { + type: 'string', + enum: ['critic', 'user'], + }, + score: { + type: 'number', + minimum: 0, + maximum: 5, + }, + }, + required: ['type', 'score'], + } + }, + required: ['name', 'genre', 'rating'] +} describe('Declaform', () => { afterEach(() => { @@ -68,12 +71,12 @@ describe('Declaform', () => { }) it('Should mount.', () => { - document.body.innerHTML = MOVIE_FORM_NO_SRC; + document.body.innerHTML = initTestFormWithAttributes({}); expect(document.getElementById('form')).toBeTruthy(); }) 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; setInputValue('name', 'Monty Python'); setInputValue('genre', 'comedy'); @@ -91,7 +94,7 @@ describe('Declaform', () => { }); 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; form.hydrate({ 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)) @@ -129,6 +136,25 @@ describe('Declaform', () => { expect(getInputValue('rating-score')).toBe('5'); 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) { diff --git a/packages/json-schema/package.json b/packages/json-schema/package.json deleted file mode 100644 index 1099c71..0000000 --- a/packages/json-schema/package.json +++ /dev/null @@ -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" - } -} diff --git a/packages/json-schema/src/index.ts b/packages/json-schema/src/index.ts deleted file mode 100644 index 3a0dc6e..0000000 --- a/packages/json-schema/src/index.ts +++ /dev/null @@ -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 = '
'; - 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); \ No newline at end of file diff --git a/packages/json-schema/tsconfig.json b/packages/json-schema/tsconfig.json deleted file mode 100644 index 5e426b4..0000000 --- a/packages/json-schema/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "../../tsconfig.json", - "compilerOptions": { - "rootDir": "./src", - "outDir": "../../dist/json-schema" - }, - "include": ["src"] -} \ No newline at end of file