75 |
76 |
77 |
78 |
480 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Data Driven Dynamic UI Generation with Vue.js and Quasar
2 |
3 | 
4 |
5 | ## Description
6 |
7 | In mid-March/2020 we started a first attempt at dynamic UI generation, based on a schema definitions in JSON (**Data Driven UI**) using the frameworks **Vue.js + Quasar**.
8 |
9 | The **Data Driven UI** concept allows interesting solutions such as:
10 |
11 | - Define UI model definition schema related to database tables and views that generates UI dynamically;
12 | - Create the UI model definition schema agnostic to technologies and frameworks (one can develop a generator for **Vue+Quasar**, another in **React+Material UI**, and so on).
13 |
14 | The idea was to link to the database schema API, an API that provides UI definitions for forms related to tables and views (entities) in the database. These UI definitions would be structured in JSON format and a client-side interpreter would generate the UI based on JSON information (at that time in **Vue.js 2.0 + Quasar framework 1.0**).
15 |
16 | The dynamically generated form would present a field definition schema for each corresponding entity field in the database with the type of edit control component (and other relevant properties) for the field. These controls would be rendered one below the other or within groups (tabs, cards, expansions, and so on). The scheme also provided lookup fields related to their dependencies on each other (_eg countries, states, cities_). The edit controls are based on the **_Quasar Framework's form controls_** with some tweaks such as the use of **_event bus_** for event communication and **_scoped slots_** for property communication between the form, edit controls and the wrapper component. Some complex component compositions using slots in the JSON schema were also implemented. A **_renderless wrapper component_** was also provided for interaction with the RESTful/GraphQL API to interact with the data of the corresponding entity / lookups in the database.
17 |
18 | For reasons of simplicity, most features were excluded from the original code to focus only on dynamic rendering of the main components, i.e. form, groups and edit controls (_which is the focus of this article_). We only kept the implementation of forms with the fields grouped in tabs.
19 |
20 | ## Pre-requisites
21 |
22 | We assume you have a good knowledge of **git cli**, **javascript**, **Vue.js** and **Quasar Framework**. You must have **Vue cli** and **quasar cli** installed on your system. This tutorial was run in a **_linux environment_**, but you would easily tweak this for your preferred operating system.
23 |
24 | ## The JSON schema structure
25 |
26 | The JSON structure is fairly simple. Define the groups and list of fields in each group item.
27 |
28 | However, defining field properties can be as complex as supported Quasar UI controls allow (_to find out which properties are supported, see the documentation for the corresponding **Quasar** control_).
29 |
30 | The field properties in the schema allow you to define validation rules on the value entered for the field, editing mask, many visual aspects and much more.
31 |
32 | The JSON structure is as follows:
33 |
34 | - **_groupModel: string_** => (Only 'tab' is currently supported);
35 | - **_groups: array_** => array of group itens:
36 | - Main group properties (**_name, label, icon_**);
37 | - Other optional group control type specific properties
38 | - **_fields: array_** => UI controls definition list for fields:
39 | - Main field properties (**_name, id, fieldType_**);
40 | - Other optional field control type specific properties.
41 |
42 | Below is an example of a JSON schema used in this article:
43 |
44 | ```javascript
45 | export default {
46 | /*
47 | * Group type: Only 'tab' is currently supported
48 | */
49 | groupModel: "tab",
50 | /*
51 | * List of group itens
52 | */
53 | groups: [
54 | {
55 | /*
56 | * Main properties (name, label, icon)
57 | */
58 | name: "Group 1",
59 | label: "Group 1",
60 | icon: "mail",
61 |
62 | /*
63 | * Control type specific properties
64 | */
65 | flat: true,
66 | "expand-separator": true,
67 |
68 | /*
69 | * Field list: name, id and fieldType
70 | are the main properties, the others are
71 | UI control specific properties.
72 | */
73 | fields: [
74 | {
75 | /*
76 | * Main field properties
77 | */
78 | name: "id",
79 | id: "g1_id",
80 | fieldType: "inputtext",
81 | /*
82 | * Control type specific properties
83 | */
84 | label: "id",
85 | dense: false,
86 | readonly: true,
87 | hidden: true,
88 | },
89 | /*
90 | * Other fields definitions...
91 | */
92 | {
93 | name: "name",
94 | id: "g1_name",
95 | fieldType: "inputtext",
96 | label: "Name",
97 | placeholder: "Name...",
98 | hint: "Inform the name...",
99 | dense: true,
100 | clearable: true,
101 | "clear-icon": "close",
102 | /*
103 | * Validation rules can be defined as in the example below
104 | */
105 | rules: [
106 | {
107 | params: ["val"],
108 | exp: '!!val || "Name is required!"',
109 | },
110 | ],
111 | },
112 | {
113 | name: "on",
114 | id: "g1_on",
115 | fieldType: "btntoggle",
116 | label: "On?",
117 | hint: "Report if ON or OFF...",
118 | dense: false,
119 | clearable: true,
120 | "stack-label": true,
121 | filled: false,
122 | options: [
123 | { label: "On", value: "on" },
124 | { label: "Off", value: "off" },
125 | ],
126 | },
127 | {
128 | name: "onoff",
129 | id: "g1_onoff",
130 | fieldType: "checkbox",
131 | "outer-label": "On or Off?",
132 | label: "On/Off",
133 | hint: "Report if ON or OFF...",
134 | "indeterminate-value": null,
135 | "true-value": "on",
136 | "false-value": "off",
137 | dense: false,
138 | clearable: true,
139 | "stack-label": true,
140 | filled: false,
141 | },
142 | {
143 | name: "alive",
144 | id: "g1_alive",
145 | fieldType: "radio",
146 | "outer-label": "Is alive?",
147 | label: "Alive",
148 | hint: "let me know if you're alive...",
149 | val: "alive",
150 | dense: false,
151 | clearable: true,
152 | "stack-label": true,
153 | filled: false,
154 | },
155 | {
156 | name: "birthday",
157 | id: "g1_birthday",
158 | fieldType: "datepicker",
159 | label: "Birthday",
160 | hint: "enter your birthday...",
161 | mask: "YYYY-MM-DD",
162 | titleFormat: "ddd., DD [de] MMM.",
163 | dense: false,
164 | clearable: true,
165 | "stack-label": true,
166 | filled: false,
167 | },
168 | {
169 | name: "time",
170 | id: "g1_time",
171 | fieldType: "timepicker",
172 | label: "Time",
173 | hint: "Inform the time...",
174 | format24h: true,
175 | dense: false,
176 | clearable: true,
177 | "stack-label": true,
178 | filled: false,
179 | },
180 | {
181 | name: "date",
182 | id: "g1_date",
183 | fieldType: "inputdate",
184 | label: "Date",
185 | placeholder: "Date...",
186 | dateMask: "DD/MM/YYYY",
187 | mask: "##/##/####",
188 | hint: "Inform the date...",
189 | titleFormat: "ddd., DD [de] MMM.",
190 | dense: true,
191 | clearable: true,
192 | },
193 | {
194 | name: "time2",
195 | id: "g1_time2",
196 | fieldType: "inputtime",
197 | label: "Time",
198 | placeholder: "Time...",
199 | timeMask: "HH:mm:ss",
200 | mask: "##:##:##",
201 | hint: "Inform the time...",
202 | format24h: true,
203 | withSeconds: true,
204 | dense: true,
205 | clearable: true,
206 | },
207 | {
208 | name: "date_time",
209 | id: "g1_date_time",
210 | fieldType: "inputdatetime",
211 | label: "Date/Time",
212 | placeholder: "Date/Time...",
213 | dateMask: "DD/MM/YYYY HH:mm:ss",
214 | mask: "##/##/#### ##:##:##",
215 | hint: "Inform the date and time...",
216 | dateTitleFormat: "ddd., DD [de] MMM.",
217 | format24h: true,
218 | withSeconds: true,
219 | dense: true,
220 | clearable: true,
221 | },
222 | {
223 | name: "options",
224 | id: "g1_options",
225 | fieldType: "select",
226 | label: "Options",
227 | hint: "Inform the option...",
228 | dense: true,
229 | clearable: true,
230 | transitionShow: "flip-up",
231 | transitionHide: "flip-down",
232 | options: ["Google", "Facebook", "Twitter", "Apple", "Oracle"],
233 | },
234 | {
235 | name: "word",
236 | id: "g1_word",
237 | fieldType: "editor",
238 | label: "Editor",
239 | hint: "Spills the beans...",
240 | clearable: true,
241 | "stack-label": true,
242 | "min-height": "5rem",
243 | },
244 | {
245 | name: "range",
246 | id: "g1_range",
247 | fieldType: "range",
248 | outerLabel: "Range",
249 | hint: "Inform the range...",
250 | clearable: true,
251 | "stack-label": true,
252 | min: 0,
253 | max: 50,
254 | label: true,
255 | },
256 | {
257 | name: "track",
258 | id: "g1_track",
259 | fieldType: "slider",
260 | outerLabel: "Track",
261 | hint: "Drag...",
262 | clearable: true,
263 | "stack-label": true,
264 | min: 0,
265 | max: 50,
266 | step: 5,
267 | label: true,
268 | },
269 | {
270 | name: "evaluate",
271 | id: "g1_evaluate",
272 | fieldType: "rating",
273 | label: "Rating",
274 | hint: "Do the evaluation...",
275 | clearable: true,
276 | "stack-label": true,
277 | max: 5,
278 | size: "2em",
279 | color: "primary",
280 | },
281 | {
282 | name: "open_close",
283 | id: "g1_open_close",
284 | fieldType: "toggle",
285 | "outer-label": "Open?",
286 | label: "Open",
287 | hint: "Open or closed report...",
288 | dense: false,
289 | clearable: true,
290 | "stack-label": true,
291 | filled: false,
292 | color: "primary",
293 | "true-value": "on",
294 | "false-value": "off",
295 | },
296 | {
297 | name: "files",
298 | id: "g1_files",
299 | fieldType: "uploader",
300 | "outer-label": "Send files",
301 | label: "Select the files",
302 | hint: "Select the files...",
303 | dense: false,
304 | clearable: true,
305 | multiple: true,
306 | "stack-label": true,
307 | },
308 | ],
309 | },
310 | {
311 | name: "Group 2",
312 | label: "Group 2",
313 | icon: "alarm",
314 |
315 | flat: true,
316 | "expand-separator": true,
317 | },
318 | {
319 | name: "Group 3",
320 | label: "Group 3",
321 | icon: "movie",
322 |
323 | flat: true,
324 | "expand-separator": true,
325 | },
326 | ],
327 | };
328 | ```
329 |
330 | ## How the magic happens
331 |
332 | ### The resources needed in the framework
333 |
334 | For the thing to work the framework would have to support the possibility to create components dynamically, conditionally and also support iteration over an array of definitions. Fortunately **Vue.js** is very good at these things!
335 |
336 | **Vue.js** suports [**Conditional Rendering - (v-if/v-else/v-else-if)**](https://vuejs.org/guide/essentials/conditional.html), and [**List Rendering - (v-for)**](https://vuejs.org/guide/essentials/list.html). These features allow you to iterate over the JSON schema and conditionally render the UI components.
337 |
338 | Conditional rerendering is ok for a few types of controls, but not the best option when you have a lot of them (_in this article we've defined about **20 different** types of form controls as bonus for you!_)
339 |
340 | For this type of challenge **Vue.js** supports [**dynamic component creation - (:is)**](https://v2.vuejs.org/v2/guide/components-dynamic-async.html). This feature allows you to reference dynamically imported component instance.
341 |
342 | Also remember the section above where we mentioned that each control type has its different set of properties. For the thing to work, **Vue.js** would need to allow linking all the properties of an object in batch. And once again Vue.js has the solution for this: [**Passing all properties of an Object - (v-bind)**](https://v2.vuejs.org/v2/guide/components-props.html#Passing-the-Properties-of-an-Object).
343 |
344 | In the section below we will see how all the features above will be used inside the `template` section of **FormGenerator.vue**
345 | to create a clean and concise solution to the problem.
346 |
347 | ### The component infrastructure
348 |
349 | The **_src/components_** folder has a series of source codes. Let's analyze them to understand how the whole thing was implemented:
350 |
351 | #### **\_compoenentMap01.js**
352 |
353 | This [**mixin object**](https://v2.vuejs.org/v2/guide/mixins.html?redirect=true) is injected into the **FormGenerator.vue**. Its function is to provide a data dictionary (**componentMap[]**) in which each component name resolves to a factory that dynamically imports and returns the component instance for that name:
354 |
355 | ```javascript
356 | /**
357 | * A mixin object that mantain a dictionary de components
358 | */
359 |
360 | export default {
361 | data() {
362 | return {
363 | componentMap: {},
364 | };
365 | },
366 | methods: {
367 | initComponentsMap() {
368 | this.componentMap = {
369 | // Group components
370 | card: () => import("./Card01"),
371 | tabs: () => import("./Tabs01"),
372 | tab: () => import("./Tab01"),
373 | tabpanel: () => import("./TabPanel01"),
374 | expansion: () => import("./Expansion01"),
375 |
376 | // Form component
377 | form: () => import("./Form01"),
378 |
379 | // From field components
380 | inputtext: () => import("./Input01"),
381 | inputdate: () => import("./DateInput01"),
382 | inputtime: () => import("./TimeInput01"),
383 | inputdatetime: () => import("./DateTimeInput01"),
384 | select: () => import("./Select01"),
385 | checkbox: () => import("./CheckBox01"),
386 | radio: () => import("./Radio01"),
387 | toggle: () => import("./Toggle01"),
388 | btntoggle: () => import("./ButtonToggle01"),
389 | optgroup: () => import("./OptionGroup01"),
390 | range: () => import("./Range01"),
391 | slider: () => import("./Slider01"),
392 | datepicker: () => import("./DatePicker01"),
393 | timepicker: () => import("./TimePicker01"),
394 | rating: () => import("./Rating01"),
395 | uploader: () => import("./Uploader01"),
396 | editor: () => import("./Editor01"),
397 |
398 | // Other
399 | icon: () => import("./Icon01"),
400 | };
401 | },
402 | },
403 | };
404 | ```
405 |
406 | Afterwards the dictionary is used to create dynamic components in the `template` by their name as:
407 |
408 | ```html
409 |
410 |
411 | ```
412 |
413 | #### **FormGenerator.vue**
414 |
415 | This one does the bulk of the work to dynamically assemble the UI based on the JSON schema.
416 |
417 | It has a series of functions for internal services, so let's focus on the part that really matters.
418 |
419 | - First it imports the componetMap so that it can be injected as a mixin and accessible in the template;
420 | - Create and provide an event bus to communicate with the component ecosystem;
421 | - Defines the property that will receive the JSON schema;
422 | - Defines the formData data to maintain the input field contents.
423 |
424 | ```javascript
425 |
426 | ...
427 |
428 | import componentMap from "./_componentMap01";
429 |
430 | ...
431 |
432 | export default {
433 | name: "FormGenerator",
434 |
435 | mixins: [componentMap],
436 |
437 | provide() {
438 | return {
439 | // The event bus to comunicate with components
440 | eventBus: this.eventBus,
441 | };
442 | },
443 | props: {
444 | // The schema placeholder property
445 | schema: {
446 | type: Object,
447 | },
448 | },
449 | data() {
450 | return {
451 | // The event bus instance
452 | eventBus: new Vue(),
453 | ...
454 | // Form data with input field contents
455 | formData: {},
456 | ...
457 | }
458 | }
459 |
460 | ...
461 |
462 | }
463 | ```
464 |
465 | And finally the `template` that creates the dynamic components - the comments in the template clearly explain how the **Vue.js** features work together to make the thing work:
466 |
467 | ```html
468 |
469 |
473 |
474 |
479 |
537 |
538 |
539 | ```
540 |
541 | #### **The other ".vue" files in /src/components**
542 |
543 | The other components basically encapsulate one or more of the original **Quasar Components** to deliver the desired functionality. They pass the events back to **FormGenerator.vue** via its `event bus` and receive event handlers and data from parent by means `v-on="$listners"` and `v-bind="$attrs"`.
544 |
545 | As an example we have the following source code from **input.vue**:
546 |
547 | ```javascript
548 |
549 |
557 |
561 |
565 |
566 |
567 |
568 |
569 |
592 | ```
593 |
594 | ### How to use the FormGenerator
595 |
596 | Now comes the easy part, in `src/pages/FormTest.vue` we have the page that loads a JSON Schema and passes it to **FormGenerator** component - and that's all!
597 |
598 | ```javascript
599 |
600 |
601 |
602 |
603 |
619 | ```
620 |
621 | By running the example with the command below:
622 |
623 | ```bash
624 | # Run the Quasar/Vue application
625 | $ yarn quasar dev
626 | ```
627 |
628 | and then enter the following URL in your preferred browser:
629 |
630 | **_http://localhost:8080_**
631 |
632 | You get this impressive result:
633 |
634 | 
635 |
636 | ## Running the example from this tutorial
637 |
638 | ### Installation
639 |
640 | ```bash
641 | # Clone tutorial repository
642 | $ git clone https://github.com/maceto2016/VueDataDrivenUI
643 |
644 | # access the project folder through the terminal
645 | $ cd VueDataDrivenUI
646 |
647 | # Install dependencies
648 | $ npm install
649 | ```
650 |
651 | ### Running the application (from NestJSDynLoad folder)
652 |
653 | ```bash
654 | # Run the Quasar/Vue application
655 | $ yarn quasar dev
656 | ```
657 |
658 | ### Testing the application
659 |
660 | Enter the following URL in your preferred browser
661 |
662 | **_http://localhost:8080_**
663 |
664 | ## Conclusion
665 |
666 | In this article we present the concept of **Data Driven UI**, which is nothing more than the dynamic creation of a UI based on the information present in a definition data. The article demonstrated how easy it is to define a **JSON Schema** and create an infrastructure using the **Vue.js + Quasar frameworks** to dynamically create components. As a **bonus** we provide about **20 UI components** based on the **Quasar framework UI** components.
667 |
668 | Feel free to use the source code and ideas presented here. There is huge room for improvement including migration to **Vue.js 3, Quasar 2 and Typescript**. Now it's up to you!
669 |
670 | I thank you for reading. I would be happy to hear your feedback!
671 |
--------------------------------------------------------------------------------