4.0.1-CI-20240507-231949 • Published 4 days ago

@quandis/qbo4.ui v4.0.1-CI-20240507-231949

Weekly downloads
-
License
MIT
Repository
-
Last release
4 days ago

Overview

The qbo4.ui package comprises the following features:

Api Endpoint Registration

If your web page needs to interface with multiple APIs, you can register them with the qbo-api component:

<qbo-api name="myService" method="POST" apiEndpoint="https://api.mysite.com/some/endpoint">
    <header name="Accept">application/json</header>
</qbo-api>

<qbo-api name="addresses" method="GET" apiEndpoint="https://nominatim.openstreetmap.org/search?q=${value}&format=json">
    <header name="Accept">application/xml</header>
</qbo-api>

Then, in typescript:

import { getApiService } from '@quandis/qbo4.ui';

const myApi = getApiService('myService');
const myResult = await myApi.fetch('/more/route/data', { Foo: 'Bar' });

const geoApi = getApiService('addresses');
const address = getApiService('geoApi').fetch('', { value: '1600 Pennsylvania Ave NW, Washington, DC' });

A few items to point out:

  • The IApiService.fetch(relativePath, json) can substitute against the apiEndpoint; useful for a GET operation.
  • POST operations will post the json object as the body of the request.
  • The json object can be a string, or an object. If it is an object, it will be stringified.
  • Additional headers can be specified in the qbo-api component.

Default API Endpoint

The qbo4-ui package will automatically register a default API endpoint using the window.location. So, if your endpoints are on the same page as your web page, you can use the default API endpoint.

// This will point to the same website you are on.
const defaultApi = getApiService('default');

Reusing the same API with different paths

You may register an API, and then reuse it with different paths:

<qbo-api name="qms" method="POST" apiEndpoint="https://services.quandis.io/api/military/">
    <header name="Accept">application/json</header>
</qbo-api>

Then, in typescript:

import { getApiService } from '@quandis/qbo4.ui';
const qmsSearch = getApiService('api://qms/scra/instant/{clientID}');
const qmsHealth = getApiService('api://qms/scra/healthcheck');

In this example, the qms is cloned in getApiService, and the relativePath is substituted into the apiEndpoint.

Custom API Services

You can write your own IApiService class, and register it to qbo's DI container:

import { services, IApiService } from "@quandis/qbo4.ui";

@injectable()
export class MyApi implements IApiService {

    async fetch(relativePath: string | null, payload: Record<string, string> | null = null): Promise<any> {
        // implement your own fetch logic here
    }
}

// Register your service to qbo's DI container
services.container.registerInstance<IApiService>('myApi', new MyApi());

QboFormElement

The QboFormElement web component is used to wrap form elements, and present them to a form element upon submission.

For example, a credit card components might look like this:

export class QboPayment extends QboFormElement {
    render() {
        return html`<slot>
    <input type="text" name="Number" placeholder="Card Number" required />
    <input type="text" name="Expiry" placeholder="MM/YY" required />
    <input type="text" name="CVC" placeholder="CVC" required />
    <input type="text" name="Name" placeholder="Name on Card" required />
</slot>`;
    }
}

This is a simple example without any credit card validation logic.

This can be used within a form multiple times:

<form id='fact'>
    <qbo-payment name="primary"></qbo-payment>
    <qbo-payment name="backup"></qbo-payment>
</form>

Upon submission, the form will contain the following fields:

{
	"primaryNumber": "...",
	"primaryExpiry": "...",
	"primaryCVC": "...",
	"primaryName": "..."
	"backupNumber": "...",
	"backupExpiry": "...",
	"backupCVC": "...",
	"backupName": "..."
}

The name attribute is used to prefix the field names for any fields in the ShadowDOM.

If you render elements as children of a QboFormElement, they will be directly visible to the form:

<form id='fact'>
    <qbo-payment name="primary">
      Number: <input type="text" name="myNumber" placeholder="Card Number" required value="1234567890">
    </qbo-payment>
    <qbo-payment name="backup"></qbo-payment>
</form>

Upon submission, the form will contain the following fields:

{
	"myNumber": "...",
	"backupNumber": "...",
	"backupExpiry": "...",
	"backupCVC": "...",
	"backupName": "..."
}

The myNumber field is slotted into the ShadowDOM from the normal markup, and is visible to the form. Thus, myNumber is not prefixed with primary.

Form Validation

Modern browers support a very rich set of form valiation functionality; use it! Custom functionality can be introduced as follows:

<form>
    <qbo-validate></qbo-validate>
    <div>
        <label for="city">City</label>
        <input type="text" name="address" required data-exists="addresses">
    </div>
    <button type="submit">Submit form</button>
</form>

Note that the data-exists tag has a value of addresses, which corresponds to the qbo-api name attribute.

This markup, combined with our custom ExistsValidator class, will validate that the value entered in the input field exists in the addresses API.

Here is our ExistsValidator class:

import { injectable, InjectionToken } from 'tsyringe';

@injectable()
export class ExistsValidator implements IValidate {
    async validate(input: HTMLElement): Promise<boolean> {
        var url = input.getAttribute('data-exists');
        if (!url) return false;
        var path = input.getAttribute('data-exists-path');
        const service: IApiService = container.isRegistered(url) ? container.resolve<IApiService>(url) : new RestApiService(url);

        if (input instanceof HTMLInputElement || input instanceof HTMLSelectElement || input instanceof HTMLTextAreaElement) {
            const response = await service.fetch(path, { value: input.value });
            const json = getArray(response);
            if (json == null)
                return false;
            return json.length > 0;
        }
        return false;
    };
    message = 'This value does not appear to exist.';
    selector = '[data-exists]';
}
container.register(ValidateToken, { useClass: ExistsValidator });

The IValidate interface is:

export interface IValidate {
    // Called by `QboValidate` when fields change or a form is submitted.
    validate(input: HTMLElement): Promise<boolean>; 
    // Called by `QboValidate` when the component is connected to the DOM.
    connect(form: HTMLFormElement): void { };
    // Called by `QboValidate` when the component is disconnected from the DOM.
    disconnect(): void { };
    // Message to display when validation fails.
    message: string;
    // Selector to use to find elements to apply the IValidate implementation to.
    selector: string;
}

Paired with the qbo-validate component, you can decorate your HTML markup with simple attributes, and automatically trigger form validate against any IValidate class registered with the DI container.

It's important that your selector be unique amont all registered IValidate classes. We recommend the selector be [data-{IValidate class name}] for consistency.

Dependencies

It's common for form controls (and other UI components) to depend on the values of each other.

For example:

<input type="text" name="This" placeholder="Enter something here" />
<input type="text" name="That" placeholder="Or something here" />
<input type="text" name="Other" placeholder="To enable this" data-depend-options='{"depends": "This,That"}'/>

In this example, the Other field will only be enabled if either the This or That field have a value.

DependValidator Options

OptionDefaultDescription
dependsundefinedA comma-delimited list of field names (or ids) that the element is dependent upon.
conditionorIf or, just 1 dependency must be met. If and, every dependency must be met.
emptyOnDisablefalseIf true, dependent values will be set to empty if dependencies are not met.
resetOnDisabletrueIf true, dependent values will be reset to their original value if dependencies are not met.
disabledClassdisabledCss class to apply to an element if dependencies are not met.
disableChildrentrueIf true, all child elements will be disabled if dependencies is not met.

Depends examples

ExampleDescription
{"depends": "This,That"}Either This or That must have a non-empty value.
{"depends": "This,That", "condition": "and"}Both This and That must have a non-empty value.
{"depends": "This=Foo"}This must have a value Foo.
{"depends": "This!=Foo"}This must not have a value Foo.
{"depends": "This="}This must have an empty value.
{"depends": "!This"}This must have an empty value.
{"depends": "This=Foo*"}This must have a value that starts with Foo.
{"depends": "This=*Bar"}This must have a value that ends with Bar.
{"depends": "This=*oo*"}This must have a value that contains oo.

The depends attribute may reference elements by id or by name. In the case of a conflict, the id will be used. The following expression is used find the target element:

const target = this.form.querySelector(`#${CSS.escape(selector)}`)
    ?? this.form.querySelector(`[name="${CSS.escape(selector)}"]`);

HTML markup and browser standards require that attributes containing JSON be double-quoted:

<input type="text" data-depend-options='{"depends": "This"}'/>

is valid, but the following is not:

<input type="text" data-depend-options="{'depends': 'This'}"/>