@obiba/quasar-ui-json-form
v0.12.2
Published
Quasar components for JSON Forms
Readme
Component QJsonForm
Compatible with Quasar UI v2 and Vue 3.
Requirements
- Quasar v2, Vue 3
- vue-i18n v11 (peer dependency), registered in the application
with
app.use(i18n)(composition mode,legacy: false). Every title, description, option label, hint and message is passed throught(), so they can be either i18n keys or literal strings (an unknown key is displayed as-is; setmissingWarn: falseto silence vue-i18n). Without vue-i18n installed, the forms still render: strings are displayed untranslated and validation messages come from the built-in english defaults (a warning is logged once).
A Quasar UI component that renders dynamic, schema-driven forms based on a JSON Schema and an optional UI schema, using JSON Forms renderers styled with Quasar components.
Usage
Quasar CLI project
Install the App Extension.
OR:
Create and register a boot file:
import Vue from 'vue'
import Plugin from '@obiba/quasar-ui-json-form'
import '@obiba/quasar-ui-json-form/dist/index.css'
Vue.use(Plugin)OR:
<style src="@obiba/quasar-ui-json-form/dist/index.css"></style>
<script>
import { Component as QJsonForm } from '@obiba/quasar-ui-json-form'
export default {
components: {
QJsonForm
}
}
</script>Vue CLI project
import Vue from 'vue'
import Plugin from '@obiba/quasar-ui-json-form'
import '@obiba/quasar-ui-json-form/dist/index.css'
Vue.use(Plugin)OR:
<style src="@obiba/quasar-ui-json-form/dist/index.css"></style>
<script>
import { Component as QJsonForm } from '@obiba/quasar-ui-json-form'
export default {
components: {
QJsonForm
}
}
</script>QJsonForm
<QJsonForm
v-model="data"
v-model:errors="errors"
:schema="schema"
:uischema="uischema"
:readonly="false"
validation-mode="ValidateAndShow"
/>| Prop | Default | Description |
|---|---|---|
| modelValue | {} | Form data (v-model) |
| schema | | JSON Schema |
| uischema | generated | JSON Forms UI schema; when omitted, one Control per property |
| readonly | false | Render every control read-only (inputs are not editable, list/upload buttons are hidden) |
| validationMode | ValidateAndShow | ValidateAndShow: validate against the schema and show errors on the controls; ValidateAndHide: validate, do not show; NoValidation: skip schema validation. validation rules are always evaluated |
| ajv | | Custom AJV instance (createAjv from @jsonforms/core). The default one knows the custom formats of the renderers and validates time and date-time as the pickers store them (HH:mm, YYYY-MM-DD HH:mm, seconds and timezone optional) |
| additionalErrors | [] | AJV-shaped errors to display in addition to validation errors (e.g. from a server) |
| config | | JSON Forms config passed to renderers (languages, countries, fileUpload, see below) |
| languages | | Languages of the localized strings: ['en', 'fr'] or { en: 'English', fr: 'Français' } |
Events: update:modelValue (data), update:errors (AJV-shaped ErrorObject[], also emitted on mount).
The errors contain the AJV validation errors (none in NoValidation mode) followed by the errors found by
the renderers themselves, which are always evaluated: validation rules (keyword: "validation"),
localized strings not completed (completed), word limits (wordLimit), file counts (files), radio
matrix (allItemsSelected) and date bounds (date); their instancePath points at the control.
A control hidden by a visible rule (its own, or the rule of an enclosing layout) is not validated:
its AJV errors (a required property shown under a condition, for instance) are left out, as
angular-schema-form did with non-rendered fields. The rules of the elements of a list item are
relative to the item and are not considered; a hidden list hides the errors of all its items.
A control can also be read-only through options.readonly in the UI schema or readOnly in the schema.
Validation messages
Required controls get a * appended to their label. Error messages are looked up with vue-i18n
under the JSON Forms i18n keys: <path>.error.<keyword> for a
specific control, then error.<keyword> (error.required, error.minLength, error.pattern...)
with the AJV error params available for interpolation ({limit}, {pattern}, {format}...),
then error.default. Keys missing from the application bundles fall back to the library defaults
(english and french), exported as messages:
import { messages } from '@obiba/quasar-ui-json-form'
createI18n({ messages: { en: { ...messages.en, ...appEn } } })Layout classes
options.class sets CSS classes on the root element of any layout, group, section, label or
control. A Group displays its label (or title) as a heading; label: false on a control hides
its title. VerticalLayout / HorizontalLayout render their elements as direct children, so Quasar
grid classes work as in Bootstrap:
{
"type": "VerticalLayout",
"options": { "class": "row q-col-gutter-md" },
"elements": [
{ "type": "VerticalLayout", "options": { "class": "col-md-6" }, "elements": [ ... ] },
{ "type": "Control", "scope": "#/properties/notes", "options": { "class": "col-12" } }
]
}When row, column or flex is present in the class, the default flex stacking of the layout is
not applied.
Labels
{ "type": "Label", "text": "..." } renders markdown with raw HTML allowed (<h3>, alert
<div>s...), sanitized with DOMPurify; the text is first resolved as a vue-i18n key.
Placeholders
The texts of a form can show the data: a {{ expression }} placeholder in the title,
description, label or hint of a control (on the schema property or the Control element), in the
label / title, description or hint of a Group / Category, in the label or
description of a Section, in the text of a Label, or in the labels of a TabsLayout /
StepperLayout, is replaced by the value of the expression:
{ "type": "Label", "text": "Thank you **{{participant.firstName}}**, your score is {{scores.total}}." }- The expression is a rule expression, with the same variables (the form data) and functions as the
visible/computerules. - The placeholders are replaced after the translation with
t(), so the form translations can carry them too (not the application vue-i18n messages, whose syntax reserves{,|and@). - The value is inserted after the markdown rendering, HTML-escaped (percent-encoded in a link) and the
result sanitized, so the data cannot inject markup or a
javascript:URL;undefinedandnullshow nothing, the values of an array are separated by commas. - The text is updated as the data changes; an expression that fails to evaluate shows nothing.
- The form builder reports the placeholders whose expression does not compile.
- Any
{{ ... }}of a text is a placeholder, there is no escape for a literal{{; the expression ends at the first}}, so it cannot contain}}(a nested object literal, a string). - The other texts (option titles, validation messages, matrix item names, list buttons...) do not replace placeholders.
fillPlaceholders(text, evaluate) (plain text), renderPlaceholders(text, render, evaluate)
(the HTML of a markdown text, render being renderMarkdown or renderMarkdownInline) and
placeholderError(text) are exported, for the texts an application displays outside of the form
(a table of contents, an error summary).
Renderer messages
A renderer-level check reads its message from options.validationMessage.<name> on the control when
defined (translated with t(); a single string applies to every check of the control, and in a map
default is the fallback of the named messages), else from the built-in messages (localized.completed, error.wordLimit,
files.missing...), which the application can override in its own vue-i18n bundles.
Lists
Arrays of objects and arrays of primitives (strings, numbers, booleans, or objects with a format
such as localized strings) render as a list with add / remove / reorder buttons. The UI schema of
one item comes from options.items (Control scopes relative to the item schema, # for the item
itself), by default one control per property of an object item, or the item itself. Options:
addLabel, addIcon, ordering (default true), confirmation (confirm before removing);
minItems / maxItems from the schema (or the min / max rules) bound the list. A list hidden
by a visible rule is removed from the data, like the other hidden controls.
Localized strings
{ "type": "object", "format": "localizedString" } holds { "en": "...", "fr": "..." }. One input is
displayed for the current language, with a language selector when there is more than one language
(switching it switches every localized control of the form). Languages come from options.languages
on the control, then config.languages, then the languages prop of QJsonForm (or a
jsonforms-languages provide at the application level, a plain value or a ref), and default to
['en'].
Options: rows (textarea when > 1), marked: true for a markdown editor with preview (also the
default of format: "obibaSimpleMde"), validationMessage.completed. A required localized string
must be completed in every language; a string emptied in every language becomes undefined so that
required applies. Read-only, the markdown variant renders the text.
{ "type": "string", "format": "markdown" } renders the same markdown editor for a plain string.
File upload
| Schema | Data |
|---|---|
| type: string, format: file | the uploaded file path (pathKey in the JSON response, path by default) |
| type: array, format: files | a list of file items [{ id, fileName, size, ... }] |
| type: object, format: files / obibaFiles | { [itemsKey]: [...] } (itemsKey: obibaFiles for that format, files otherwise) |
Options: multiple (default: yes, unless maxItems is 1), accept, emptyMessage (read-only, no
file), validationMessage.missingFiles / minItems / maxItems; minItems / maxItems from the
schema (or the min / max rules) are enforced on object controls.
Upload flow, declarative: uploadUrl (+ uploadMethod, uploadHeaders, fileField, default
file), then the file item is read from the JSON response (pathKey, or the whole body), or, when
metadataUrl is set ({id} placeholder), from a GET of the id found in the Location header (the
Mica temp file flow: uploadUrl: "/ws/files/temp", metadataUrl: "/ws/files/temp/{id}"). Uploaded
items get justUploaded: true; removing such an item calls deleteUrl ({id}) when set. downloadUrl
is a template with the item properties (/ws/files/{id}/{fileName}) used as link in the list.
Programmatic, through config.fileUpload (see FileUploadHooks): upload(file, context, onProgress)
returning the item (or the path) and calling onProgress (0 to 100) for a determinate progress bar,
remove(item, context), downloadUrl(item, context), and download(item, context): an async handler
called when the file name is clicked, instead of a link. A link cannot carry an Authorization header,
and a token in the URL leaks in the logs and the history: the handler fetches the file with the
application authentication and saves it.
Radio matrix
{ "type": "object", "format": "radioGroupCollection" } (or "radio-matrix") renders items as rows and
values as radio columns; data is { [item.key]: value.key }. Rows and columns come from items
([{ key, name }]) and values ([{ key, caption }] or { key: caption }) in the control options or,
as in the angular-schema-form add-on, on the schema itself. options.checkboxMode renders one checkbox
per item ({ [item.key]: boolean }). A required matrix must have every row answered (at least one
checked in checkbox mode): validationMessage.allItemsSelected.
Dates
The date renderer (format: date, datepicker) accepts, directly or under dateOptions:
dateFormat: mask of the stored value (YYYY-MM-DDby default; angular-strap masks such asyyyy-MM-ddare accepted). It applies todatepicker/ymdatepickercontrols:format: "date"is validated by AJV as an ISO date and keepsYYYY-MM-DD.format: "year-month"usesYYYY-MMwith a month picker.min/max: bounds (ISO or mask format; themin/maxrules work too), withvalidationMessage.dateRange/dateMin/dateMax; a value that does not match the mask reportsvalidationMessage.dateInvalid.yearRef/monthRef(format: "ymdatepicker"): names of the year and month fields (siblings of the control, or root fields) the date must belong to. The input is disabled until both are set, the value defaults to the first day of that month (lastDay: true: the last one) and follows them;validationMessage.invalidYMDate.
Word limits
On string controls, options.wordLimit: "min:max" (or a maximum), wordMin, wordMax validate the
number of words and show a word counter (validationMessage.wordLimitError / wordMinError /
wordMaxError). The rule engine also provides wordCount(text) and contains(list, value) for
rules: { "validation": [{ "expr": "wordCount(abstract) <= 500", "message": "..." }] }.
Rule functions
The application registers its own rule functions on the ruleEngine export, once, before the
forms are rendered (a boot file in a Quasar CLI project): ruleEngine.addFunction('isAdult', (age) =>
age >= 18) makes isAdult(person.age) available to every rule. A function receives the evaluated
arguments and its result is the value of the call; return plain values only.
Countries and typeahead
format: "countries" (or obibaCountriesUiSelect) is a searchable select of ISO codes, single
(type: string) or multiple (type: array). The [{ code, name }] list comes from
options.countries, config.countries or a jsonforms-countries provide, either an array or a
{ locale: [...] } map (the current locale is used). The countryCodes export holds the english and
french names of the ISO 3166-1 alpha-3 codes:
import { countryCodes } from '@obiba/quasar-ui-json-form'
// <QJsonForm :config="{ countries: countryCodes }" ... />format: "typeahead" on a string is an input with suggestions from options.values (strings or
{ label, value }), or the schema examples / enum; options.editable: true accepts any text.
Geo
format: "geo" on an object is a map (OpenLayers, loaded on demand) on which a point, a line or a
polygon is drawn; the data is a GeoJSON geometry in WGS84 longitude / latitude, for instance
{ "type": "Point", "coordinates": [lon, lat] }. options.geometries restricts the kinds
(point, linestring, polygon), the toolbar picks the one to draw, locates the user
(Geolocation API) and clears the value; a point can also be typed as a latitude and a longitude.
The tiles are those of OpenStreetMap unless options.tiles or config.geo.tiles (a {z}/{x}/{y}
URL template, or { url, attributions }) selects another source, displayed in a scale of greys unless
grayscale: false; config.geo also holds default center, zoom, height, precision and
grayscale:
// <QJsonForm :config="{ geo: { tiles: { url: 'https://.../{z}/{x}/{y}.png', attributions: '...' }, center: [lon, lat], zoom: 6 } }" ... />angular-schema-form compatibility
@obiba/quasar-ui-json-form/asf converts a form of the
angular-schema-form dialect (a JSON
schema and a definition array, as stored by Mica) into a (schema, uischema) pair for QJsonForm.
It is pure TypeScript (also exported by the main entry as convertAsf / toJsonForms):
import { convert, toJsonForms } from '@obiba/quasar-ui-json-form/asf'
const { schema, uischema, diagnostics } = convert(asfSchema, asfDefinition, { translate: t })
// <QJsonForm :schema="schema" :uischema="uischema" ... />
// either dialect, detected by shape (array: ASF definition, object: JSON Forms UI schema)
const result = toJsonForms(schema, definitionOrUischema)Options: translate(key) resolves the t(key) tokens of the schema and the definition (titles,
help blocks, option labels, messages); without it the t() wrapper is removed and the key kept, so
that strings made of a single token are still translated by the renderers with vue-i18n. readonly
sets options.readonly on every control, languages sets options.languages on the localized
strings, rowClass replaces the Bootstrap row class (row q-col-gutter-md by default),
textareaRows (3), logger receives the diagnostics (console.warn by default, false to silence).
| ASF | JSON Forms |
|---|---|
| "a.b", { key: "a.b" }, "*" (the properties not listed elsewhere) | Control with scope #/properties/a/properties/b |
| section (+ htmlClass), object key with items | VerticalLayout (+ options.class) |
| fieldset (+ title), object key without items | Group (+ label), one control per property |
| help + helpvalue | Label (text, HTML allowed) |
| tabs | Categorization / Category |
| htmlClass | options.class, with col-xs-N → col-N, col-*-offset-N → offset-*-N, row → rowClass |
| condition | rules.visible (see below) |
| notitle | label: false |
| title, description | written on the schema property |
| titleMap | oneOf ({ const, title }) on the property or its items; enum arrays get uniqueItems |
| radios, checkboxes | options.format: "radio" / "checkbox"; enum arrays default to checkboxes |
| textarea, rows | options.rows |
| localizedstring, obibaSimpleMde (marked), obibaFileUpload, radioGroupCollection, obibaCountriesUiSelect, sf-typeahead, datepicker | options.format when the schema format does not already select the renderer |
| wordLimit, emptyMessage, validationMessage (object or string), placeholder, marked | same option |
| readonly | options.readonly, also on the item controls of an array |
| add | options.addLabel |
| minItems, maxItems, required: true | written on the schema |
| dateOptions (dateFormat, yearRef, monthRef, lastDay, validationMessage) | options.dateOptions / options.validationMessage |
| dateOptions.minDate / maxDate (+ minDateIsRef / maxDateIsRef) | options.min / max, or rules.min / max when it names a field |
| array key with items ("arr[].x" keys) | options.items (item UI schema, scopes relative to the item) |
| x-schema-form on a schema property | definition defaults for that key |
| actions, submit, button, template, hidden, sf-obiba-selection-tree | skipped (info diagnostic) |
Unknown keys and unsupported elements without a key are skipped, a keyed control of an unknown type
is rendered from its schema, and a condition that cannot be translated leaves its element visible; all
of these are reported in diagnostics ({ level, message, key?, element? }).
Conditions (transpileCondition) accept the JavaScript subset found in form definitions:
model.a.b paths, string / number / boolean / null literals, !, &&, ||, parentheses,
== / === / != / !== / < / <= / > / >=, model.list.indexOf(v) >= 0 (or > -1,
!= -1, and the negative forms), model.list.includes(v) and model.list.length. Rules being a
JavaScript subset, the literals and operators are kept; indexOf / includes become
contains(list, v), length becomes length(list), and a value used as a boolean is coerced with
!! so that the rule is a boolean (model.a && model.b == 1 → !!a && b == 1, !model.b → !b).
The only deviation from JavaScript is a missing object in a path, undefined rather than an error.
The 13 default Mica forms are converted as acceptance fixtures (test/fixtures/asf, snapshots in
__snapshots__), and the ui/dev page "test-asf-converter" renders any pasted pair.
Amber forms compatibility
@obiba/quasar-ui-json-form/amber converts the schema of an Amber form (a tree of items with its
i18n translations) into a (schema, uischema, translations) bundle for QJsonForm that collects
the same data (groups as nested objects, multiple choices as arrays, maps as GeoJSON features):
import { convert, rewriteExpression } from '@obiba/quasar-ui-json-form/amber'
const { schema, uischema, translations, diagnostics } = convert(amberSchema)
// <QJsonForm :schema="schema" :uischema="uischema" :translations="translations" apply-defaults ... />The root UI schema has one element per top-level item (uischema.elements[page] for a paged
form). The item expressions (condition, required, disabled, validation, compute) are
rewritten into rules: $('A.B') → A.B, val → the item path, Date.parse() / Date.now() /
new Date().getFullYear()... → parseDate() / now() / currentYear()..., includes / indexOf → contains(). What cannot be converted is
reported in diagnostics and skipped. The Amber demo form is the acceptance fixture
(test/fixtures/amber), and the builder imports Amber schemas (fromAmber).
Renderer catalog
@obiba/quasar-ui-json-form/catalog describes every built-in renderer and the form component:
the schema shapes that trigger it, the element keys, options and validation checks it understands,
the data it writes, and the items it contributes to the form builder palette (name, label, icon,
and the schema and UI schema fragments a new element starts from). It is the source of the API
documentation of the site and of the form builder.
import { catalog, catalogItems, controlApi, rendererOptions } from '@obiba/quasar-ui-json-form/catalog'
catalog.QStringRenderer.options.rows.desc // 'Number of rows; when greater than 1 the input is a `textarea`.'
rendererOptions(catalog.QStringRenderer) // its options, with the ones common to every control
catalogItems.find((item) => item.name === 'textarea') // { renderer: 'QStringRenderer', schema, uischema, ... }ui/test/catalog.test.ts checks that every renderer registered by the plugin is described, and
that every options.<name> read by a renderer is documented.
Form builder
@obiba/quasar-ui-json-form/builder holds the QJsonFormBuilder component and the model it
works on. The component edits a form bound to v-model as { schema, uischema, translations }:
the outline of the form with its palette (drag and drop to reorder and reparent, with sortablejs),
the properties of the selected node (key, required, texts in the builder language, choices, the
settings of its renderer from the catalog, validation keywords, rules, raw JSON), the live
preview with a language switch, the translations editor (a button collects the texts of the form,
turning literals into keys and adding the missing entries of every language; the translations as a CSV file,
key then one column per language, downloaded for the translators and uploaded back: the
non-empty cells update or add keys and languages), and the source with import and export (a
form, a schema alone, or an angular-schema-form pair).
<QJsonFormBuilder v-model="form" :languages="['en', 'fr']" :catalog="[colorApi]" :renderers="[colorRenderer]" :config="config" />import { QJsonFormBuilder } from '@obiba/quasar-ui-json-form/builder'| Prop | Content |
|---|---|
| modelValue | the form, { schema, uischema, translations } (v-model) |
| languages | languages of the form, added to the ones of its translations (['en', 'fr'] or { en: 'English' }) |
| locale | language edited and previewed initially (the vue-i18n locale when it is one of the languages) |
| catalog | RendererApi descriptions of the renderers of the application, added to the palette |
| renderers, config | passed to the preview form |
| no-caps | no uppercase transform on the import/export buttons and the tab labels (default false) |
| no-import | no import button, when the application imports the forms its own way (default false) |
| no-export | no export button, when the application exports the forms its own way (default false) |
The labels of the builder come from the builder.* keys of the built-in messages (english and
french), overridable in the application bundles.
Model
The model of a form under construction: a tree of nodes mirroring the UI schema (control,
layout or plain element nodes, a list of objects carrying the layout of its items as
detail), bound to the JSON schema of the data and to the translations of the form.
import { fromDefinition, toDefinition, addNode, setText, textSlots } from '@obiba/quasar-ui-json-form/builder'
import { findCatalogItem } from '@obiba/quasar-ui-json-form/catalog'
const model = fromDefinition({ schema, uischema, translations })
const node = addNode(model, model.root.id, findCatalogItem('textarea'), undefined, 'comment')
setText(model, node, textSlots(model, node)[0], 'en', 'Your comment') // schema title: `comment.title`
const { schema, uischema, translations } = toDefinition(model)fromDefinitioncopies the form (a missing UI schema is generated, nested translations are flattened to dotted keys) and reports inmodel.diagnosticswhat it could not model; the unknown keywords of the schema and of the elements are kept and written back bytoDefinition.fromAsfaccepts an angular-schema-form pair.addNode,removeNode,moveNode,duplicateNodeandrenamePropertykeep the schema in step with the tree: the property of a control is created in its container (the root schema, or theitemsof its list), removed with it unless another control uses it, copied under a new key, or renamed with the scopes of the nested controls. A control cannot move into or out of a list.ruleReferencesfinds the rules mentioning a property.- Every text is a translation key:
textSlotslists the texts of a node (title, description, label, hint, option labels, messages...),getTextandsetTextread and write them in a language, the key being generated from the position of the node (name.title,contacts.items.email.hint,group.1.label).usedKeys,missingTranslationsandpruneTranslationsserve the translations editor.
Setup
$ npm installDeveloping
$ npm run devBuilding package
$ npm run buildTesting
$ npm testUnit tests (vitest + @vue/test-utils, jsdom) live in ui/test.
Adding Testing Components
in the ui/dev/src/pages you can add Vue files to test your component/directive. When using npm run dev to build the UI, any pages in that location will automatically be picked up by dynamic routing and added to the test page.
Adding Assets
If you have a component that has assets, like language or icon-sets, you will need to provide these for UMD. In the ui/build/script.javascript.js file, you will find a couple of commented out commands that call addAssets. Uncomment what you need and add your assets to have them be built and put into the ui/dist folder.
Donate
If you appreciate the work that went into this, please consider donating to Quasar.
License
MIT (c) Yannick Marcon [email protected]
