glib-web 4.42.0 → 4.42.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,6 +2,7 @@ import jsonLogic from 'json-logic-js';
2
2
  import merge from 'lodash.merge';
3
3
  import { nextTick } from "vue";
4
4
  import { sanitize } from "../../components/composable/date";
5
+ import { htmlElement } from "../../components/helper";
5
6
  import { isPresent } from "../../utils/type";
6
7
  import { getFormData as _getFormData } from "../../components/composable/form";
7
8
 
@@ -94,6 +95,16 @@ export default class {
94
95
  if (!targetComponent) {
95
96
  console.warn("Component ID not found", id);
96
97
  }
98
+ Utils.type.ifObject(targetComponent, (component) => {
99
+ const element = htmlElement(component);
100
+ Utils.type.ifObject(element, (el) => {
101
+ Utils.type.ifFunction(el.closest, () => {
102
+ if (!el.closest('form')) {
103
+ console.warn("Target component is not inside a form", component.viewId || id);
104
+ }
105
+ });
106
+ });
107
+ });
97
108
  return targetComponent;
98
109
  }).filter((comp) => comp);
99
110
 
@@ -1,7 +1,9 @@
1
1
  import { Chart, Colors } from "chart.js";
2
2
  import chartDataLabels from 'chartjs-plugin-datalabels';
3
3
  import doughnutLabel from 'chartjs-plugin-doughnutlabel-v3';
4
- import { settings, Vue, vueApp } from "../..";
4
+ import { settings } from "../../utils/settings";
5
+ import { vueApp } from "../../store";
6
+ import * as TypeUtils from "../../utils/type";
5
7
  import { computePosition, flip, offset } from '@floating-ui/dom';
6
8
 
7
9
  import 'chartkick/chart.js';
@@ -15,8 +17,18 @@ Chart.register(Colors);
15
17
  if (settings.chartPlugin.htmlLegendPlugin) Chart.register(settings.chartPlugin.htmlLegendPlugin);
16
18
 
17
19
  import VueChartkick from 'vue-chartkick';
18
- import { computed } from "vue";
19
- Vue.use(VueChartkick);
20
+ import { computed, getCurrentInstance } from "vue";
21
+
22
+ let chartkickInstalled = false;
23
+ const installChartkick = () => {
24
+ if (chartkickInstalled) return;
25
+ const instance = getCurrentInstance();
26
+ const app = instance?.appContext?.app;
27
+ if (TypeUtils.isObject(app) && TypeUtils.isFunction(app.use)) {
28
+ app.use(VueChartkick);
29
+ chartkickInstalled = true;
30
+ }
31
+ };
20
32
 
21
33
  const multipleDataSeries = (dataSeries) => {
22
34
  return dataSeries.map((value) => {
@@ -110,6 +122,7 @@ const getData = (multiple, dataSeries, context) => {
110
122
  };
111
123
 
112
124
  function useChart({ dataSeries, spec, multiple = true }) {
125
+ installChartkick();
113
126
  const isDonut = [spec.styleClasses].flat().includes('donut');
114
127
  const { datalabels, centerLabel, customTooltip } = spec.plugins || {};
115
128
  const legend = spec.legend || { display: true };
@@ -198,4 +211,4 @@ function useChart({ dataSeries, spec, multiple = true }) {
198
211
  }
199
212
 
200
213
 
201
- export { useChart };
214
+ export { useChart };
@@ -1,5 +1,6 @@
1
1
  import { getCurrentInstance, inject, nextTick, onBeforeUnmount, onBeforeUpdate, onMounted, provide, ref, watch } from "vue";
2
2
  import { closest, htmlElement } from "../helper";
3
+ import { isArray, isFunction } from "../../utils/type.js";
3
4
 
4
5
  const setBusy = (htmlElement, value) => {
5
6
  const event = new Event('forms/setBusy', { bubbles: true });
@@ -13,6 +14,33 @@ const triggerOnChange = (htmlElement) => {
13
14
 
14
15
  const triggerOnInput = (htmlElement) => nextTick(() => htmlElement.dispatchEvent(new Event('input', { bubbles: true })));
15
16
 
17
+ const getCheckboxNames = (el) => {
18
+ const checkboxNames = new Set();
19
+ const nonCheckboxNames = new Set();
20
+ if (!el || !isFunction(el.querySelectorAll)) return checkboxNames;
21
+
22
+ el.querySelectorAll('[name]').forEach((input) => {
23
+ const name = input.name;
24
+ if (input instanceof HTMLInputElement && input.type === 'checkbox') {
25
+ if (!nonCheckboxNames.has(name)) checkboxNames.add(name);
26
+ return;
27
+ }
28
+ nonCheckboxNames.add(name);
29
+ checkboxNames.delete(name);
30
+ });
31
+
32
+ return checkboxNames;
33
+ };
34
+
35
+ const warnDuplicateNames = (key, seenNames, warnedNames, checkboxNames) => {
36
+ if (seenNames.has(key) && !warnedNames.has(key) && !checkboxNames.has(key)) {
37
+ console.warn(`Multiple inputs share the same name: "${key}".`);
38
+ warnedNames.add(key);
39
+ return;
40
+ }
41
+ seenNames.add(key);
42
+ };
43
+
16
44
  const getFormData = (el, ignoredFields = new Set()) => {
17
45
  if (!el) return {};
18
46
  if (!(el instanceof HTMLFormElement) && el instanceof HTMLElement) {
@@ -21,7 +49,11 @@ const getFormData = (el, ignoredFields = new Set()) => {
21
49
 
22
50
  const formData = new FormData(el);
23
51
  const obj = {};
52
+ const seenNames = new Set();
53
+ const warnedNames = new Set();
54
+ const checkboxNames = getCheckboxNames(el);
24
55
  formData.forEach((value, key) => {
56
+ warnDuplicateNames(key, seenNames, warnedNames, checkboxNames);
25
57
  if (ignoredFields.has(key)) return;
26
58
 
27
59
  // Reflect.has in favor of: object.hasOwnProperty(key)
@@ -29,7 +61,7 @@ const getFormData = (el, ignoredFields = new Set()) => {
29
61
  obj[key] = value;
30
62
  return;
31
63
  }
32
- if (!Array.isArray(obj[key])) {
64
+ if (!isArray(obj[key])) {
33
65
  obj[key] = [obj[key]];
34
66
  }
35
67
  obj[key].push(value);
@@ -118,4 +150,4 @@ function useGlibInput({ props, cacheValue = true }) {
118
150
 
119
151
  }
120
152
 
121
- export { setBusy, triggerOnChange, triggerOnInput, useGlibForm, useGlibInput, getFormData, getAllFormData };
153
+ export { setBusy, triggerOnChange, triggerOnInput, useGlibForm, useGlibInput, getFormData, getAllFormData };
@@ -94,7 +94,7 @@
94
94
  import inputVariant from '../mixins/inputVariant';
95
95
  import { determineDensity } from "../../utils/constant";
96
96
  import { triggerOnChange, triggerOnInput, useGlibInput } from "../composable/form";
97
- import { isBoolean } from '../../utils/type';
97
+ import { isBoolean, isArray } from '../../utils/type';
98
98
 
99
99
  import { useGlibSelectable, watchNoneOfAbove } from '../composable/selectable';
100
100
  import { ref, defineExpose, computed, watch, nextTick, onMounted, onUnmounted } from 'vue';
@@ -402,9 +402,17 @@ export default {
402
402
  }
403
403
  },
404
404
  removeItem(item) {
405
- const index = this.fieldModel.indexOf(item.value);
406
- if (index >= 0) {
407
- this.fieldModel.splice(index, 1);
405
+ // fieldModel may not be an array when useChips is true but multiple is false.
406
+ // This can happen when spec.useChips is explicitly set to true while spec.multiple is false.
407
+ // In this scenario, fieldModel is a single value (not an array), so we need to handle both cases.
408
+ if (isArray(this.fieldModel)) {
409
+ const index = this.fieldModel.indexOf(item.value);
410
+ if (index >= 0) {
411
+ this.fieldModel.splice(index, 1);
412
+ }
413
+ } else {
414
+ // Single select mode - clear the value
415
+ this.fieldModel = null;
408
416
  }
409
417
  },
410
418
  $registryEnabled() {
@@ -43,7 +43,7 @@ export default {
43
43
  Utils.type.ifObject(this.spec.validation, val => {
44
44
  Object.keys(val).forEach((key) => {
45
45
  const validator = ValidationFactory.getValidator(key, val[key]);
46
- augmentedRules = augmentedRules.concat([validator.build.bind(validator)]);
46
+ augmentedRules = [validator.build.bind(validator), ...augmentedRules];
47
47
  });
48
48
  });
49
49
  return augmentedRules;
@@ -22,7 +22,6 @@ export default {
22
22
  if (spec && spec.id && this.$registryEnabled()) {
23
23
 
24
24
  const id = this.viewId;
25
-
26
25
  const existingComponent = GLib.component.findById(id);
27
26
  // A component with the same ID in a different page shouldn't be considered a
28
27
  // duplicate. See `utils/components#deregister` for more details.
@@ -122,35 +122,30 @@ export default {
122
122
  },
123
123
  methods: {
124
124
  applyStyles(styles, spec) {
125
- if (spec.gap) {
126
- styles['column-gap'] = `${spec.gap.y}px`;
127
- styles['row-gap'] = `${spec.gap.x}px`;
128
- if (spec.gap.all) {
129
- styles['gap'] = `${spec.gap.all}px`;
130
- }
131
- }
132
-
133
- if (spec.padding) {
125
+ Utils.type.ifObject(spec.gap, gap => {
126
+ Utils.type.ifNumber(gap.all, all => (styles["gap"] = `${all}px`));
127
+ Utils.type.ifNumber(gap.x, x => (styles["column-gap"] = `${x}px`));
128
+ Utils.type.ifNumber(gap.y, y => (styles["row-gap"] = `${y}px`));
129
+ });
134
130
 
135
- Utils.type.ifObject(spec.padding, padding => {
136
- Utils.type.ifNumber(
137
- padding.top || padding.y || padding.all,
138
- top => (styles["padding-top"] = `${top}px`)
139
- );
140
- Utils.type.ifNumber(
141
- padding.bottom || padding.y || padding.all,
142
- bottom => (styles["padding-bottom"] = `${bottom}px`)
143
- );
144
- Utils.type.ifNumber(
145
- padding.left || padding.x || padding.all,
146
- left => (styles["padding-left"] = `${left}px`)
147
- );
148
- Utils.type.ifNumber(
149
- padding.right || padding.x || padding.all,
150
- right => (styles["padding-right"] = `${right}px`)
151
- );
152
- });
153
- }
131
+ Utils.type.ifObject(spec.padding, padding => {
132
+ Utils.type.ifNumber(
133
+ padding.top || padding.y || padding.all,
134
+ top => (styles["padding-top"] = `${top}px`)
135
+ );
136
+ Utils.type.ifNumber(
137
+ padding.bottom || padding.y || padding.all,
138
+ bottom => (styles["padding-bottom"] = `${bottom}px`)
139
+ );
140
+ Utils.type.ifNumber(
141
+ padding.left || padding.x || padding.all,
142
+ left => (styles["padding-left"] = `${left}px`)
143
+ );
144
+ Utils.type.ifNumber(
145
+ padding.right || padding.x || padding.all,
146
+ right => (styles["padding-right"] = `${right}px`)
147
+ );
148
+ });
154
149
  },
155
150
  }
156
151
  };
@@ -135,9 +135,6 @@ export default {
135
135
  const onChange = this.spec.onChange || this.spec.onChangeAndLoad;
136
136
  this.formCtx = { form: this.$refs.form };
137
137
  const onChangeHandler = () => {
138
- if (this.$refs.form) {
139
- this.$refs.form.resetValidation();
140
- }
141
138
  this.formCtx = { form: this.$refs.form };
142
139
  if (onChange) this.$executeOnChange();
143
140
  };
@@ -0,0 +1,432 @@
1
+ import { mount } from "cypress/vue";
2
+ import { ref } from "vue";
3
+ import Component from "../../components/component.vue";
4
+ import genericMixin from "../../components/mixins/generic";
5
+ import eventsMixin from "../../components/mixins/events";
6
+ import stylesMixin from "../../components/mixins/styles";
7
+ import updatableComponent from "../../components/mixins/updatableComponent";
8
+ import Framework from "../../utils/public";
9
+ import * as TypeUtils from "../../utils/type";
10
+ import vuetify from "../../plugins/vuetify";
11
+ import { vueApp } from "../../store";
12
+ import App from "../../utils/app";
13
+ import UrlUtils from "../../utils/url";
14
+ import Format from "../../utils/format";
15
+ import Dom from "../../utils/dom";
16
+ import Settings from "../../utils/settings";
17
+
18
+ const setupGlibGlobals = () => {
19
+ const win = window as Window & {
20
+ Utils?: {
21
+ app: typeof App;
22
+ type: typeof TypeUtils;
23
+ url: typeof UrlUtils;
24
+ format: typeof Format;
25
+ http: {
26
+ execute: (
27
+ properties: Record<string, unknown>,
28
+ methodName: string,
29
+ component: unknown,
30
+ jsonHandler?: (data: Record<string, unknown>, response: { url: string }) => void
31
+ ) => { request: { cancel: () => void } };
32
+ startIndicator?: () => void;
33
+ stopIndicator?: () => void;
34
+ };
35
+ dom: typeof Dom;
36
+ settings: typeof Settings;
37
+ launch: {
38
+ dialog: { alert: () => void };
39
+ snackbar: { error: () => void };
40
+ };
41
+ };
42
+ GLib?: typeof Framework;
43
+ Stripe?: (key: string) => {
44
+ elements: () => {
45
+ create: () => {
46
+ mount: (target: Element) => void;
47
+ addEventListener: (_name: string, _handler: (event: unknown) => void) => void;
48
+ clear: () => void;
49
+ };
50
+ };
51
+ createPaymentMethod: () => Promise<{
52
+ paymentMethod: { id: string; card: { brand: string; country: string; last4: string } };
53
+ }>;
54
+ };
55
+ };
56
+
57
+ win.Utils = {
58
+ app: App,
59
+ type: TypeUtils,
60
+ url: UrlUtils,
61
+ format: Format,
62
+ dom: Dom,
63
+ settings: Settings,
64
+ http: {
65
+ execute: (properties, _methodName, _component, jsonHandler) => {
66
+ if (TypeUtils.isFunction(jsonHandler)) {
67
+ jsonHandler(
68
+ { rows: [], pins: [], nextPageUrl: null },
69
+ { url: TypeUtils.isString(properties?.url) ? properties.url : "" }
70
+ );
71
+ }
72
+ return { request: { cancel: () => {} } };
73
+ },
74
+ startIndicator: () => {},
75
+ stopIndicator: () => {},
76
+ },
77
+ launch: {
78
+ dialog: { alert: () => {} },
79
+ snackbar: { error: () => {} },
80
+ },
81
+ };
82
+ win.GLib = Framework;
83
+ vueApp.colors = {};
84
+ if (!document.getElementById("page_body")) {
85
+ const pageBody = document.createElement("div");
86
+ pageBody.id = "page_body";
87
+ document.body.appendChild(pageBody);
88
+ }
89
+
90
+ if (!win.Stripe) {
91
+ win.Stripe = (_key: string) => ({
92
+ elements: () => ({
93
+ create: () => ({
94
+ mount: () => {},
95
+ addEventListener: () => {},
96
+ clear: () => {},
97
+ }),
98
+ }),
99
+ createPaymentMethod: () =>
100
+ Promise.resolve({
101
+ paymentMethod: {
102
+ id: "pm_test",
103
+ card: { brand: "visa", country: "US", last4: "4242" },
104
+ },
105
+ }),
106
+ });
107
+ }
108
+
109
+ const http = win.Utils.http;
110
+ http.execute = (properties, _methodName, _component, jsonHandler) => {
111
+ if (TypeUtils.isFunction(jsonHandler)) {
112
+ jsonHandler(
113
+ { rows: [], pins: [], nextPageUrl: null },
114
+ { url: TypeUtils.isString(properties?.url) ? properties.url : "" }
115
+ );
116
+ }
117
+ return { request: { cancel: () => {} } };
118
+ };
119
+ };
120
+
121
+ const provideDefaults = () => ({
122
+ isChrome: false,
123
+ mousePosition: ref({ x: 0, y: 0 }),
124
+ formCtx: { form: { isValid: true } },
125
+ radioGroupCtx: { fieldModel: null },
126
+ });
127
+
128
+ const componentRegistry = TypeUtils.isObject(Component.components)
129
+ ? (Component.components as Record<string, unknown>)
130
+ : {};
131
+ const componentNames = Object.keys(componentRegistry).filter(
132
+ (name) => !name.toLowerCase().includes("stripe")
133
+ );
134
+ const viewFromName = (name: string) => name.replace("-", "/");
135
+ const sampleLabelView = (text = "Sample label") => ({
136
+ view: "label",
137
+ text,
138
+ });
139
+ const sampleResponsiveSpec = (childViews = [sampleLabelView()]) => ({
140
+ view: "panels/responsive",
141
+ childViews,
142
+ });
143
+ const sampleFieldView = (name = "field", label = "Field") => ({
144
+ view: "fields/text",
145
+ name,
146
+ label,
147
+ value: "Sample",
148
+ });
149
+ const sampleOptions = [
150
+ { text: "Option A", value: "a" },
151
+ { text: "Option B", value: "b" },
152
+ ];
153
+ const componentSpecOverrides: Record<string, Record<string, unknown>> = {
154
+ "views-skeleton": { template: "textArea" },
155
+ "views-datetime": { value: "2024-01-01T00:00:00Z" },
156
+ "views-markdown": { text: "Sample **markdown**" },
157
+ "views-html": { text: "<strong>Sample</strong>" },
158
+ "views-progressBar": { value: 0.6 },
159
+ "views-progressCircle": { value: 75, text: "Complete" },
160
+ "views-image": { url: "https://example.com/image.png", width: 120, height: 80 },
161
+ "views-avatar": { url: "https://example.com/avatar.png", text: "A" },
162
+ "views-icon": { material: { name: "info" } },
163
+ "views-tabBar": {
164
+ buttons: [{ text: "Tab 1" }, { text: "Tab 2" }],
165
+ activeIndex: 0,
166
+ },
167
+ "views-calendar": { dataUrl: "https://example.com/calendar" },
168
+ "views-map": {
169
+ latitude: 37.7749,
170
+ longitude: -122.4194,
171
+ zoom: 10,
172
+ dataUrl: "https://example.com/map",
173
+ },
174
+ "views-shareButton": {
175
+ network: "facebook",
176
+ url: "https://example.com",
177
+ text: "Share",
178
+ },
179
+ "views-treeView": {
180
+ items: [
181
+ {
182
+ id: "parent-1",
183
+ label: "Parent",
184
+ children: [{ id: "child-1", label: "Child" }],
185
+ },
186
+ ],
187
+ },
188
+ "panels-responsive": { childViews: [sampleLabelView()] },
189
+ "panels-scroll": { childViews: [sampleLabelView()] },
190
+ "panels-vertical": { childViews: [sampleLabelView()] },
191
+ "panels-horizontal": { childViews: [sampleLabelView()] },
192
+ "panels-flow": { childViews: [sampleLabelView()] },
193
+ "panels-column": { childViews: [sampleLabelView()] },
194
+ "panels-ul": { childViews: [sampleLabelView()] },
195
+ "panels-grid": {
196
+ childViews: [sampleLabelView()],
197
+ boxMinWidth: 120,
198
+ boxMaxWidth: 240,
199
+ columnGap: 8,
200
+ rowGap: 8,
201
+ },
202
+ "panels-split": {
203
+ left: sampleResponsiveSpec([sampleLabelView("Left")]),
204
+ center: sampleResponsiveSpec([sampleLabelView("Center")]),
205
+ right: sampleResponsiveSpec([sampleLabelView("Right")]),
206
+ },
207
+ "panels-form": {
208
+ url: "https://example.com/form",
209
+ childViews: [sampleFieldView("user[name]", "Name")],
210
+ },
211
+ "panels-list": {
212
+ sections: [
213
+ {
214
+ header: sampleResponsiveSpec([sampleLabelView("Header")]),
215
+ rows: [
216
+ {
217
+ template: "thumbnail",
218
+ title: "List Item",
219
+ subtitle: "Subtitle",
220
+ },
221
+ ],
222
+ },
223
+ ],
224
+ },
225
+ "panels-table": {
226
+ sections: [
227
+ {
228
+ header: { dataCells: ["Header"] },
229
+ rows: [{ cellViews: [sampleLabelView("Cell")] }],
230
+ },
231
+ ],
232
+ },
233
+ "panels-bulkEdit": {
234
+ sections: [
235
+ {
236
+ header: { dataCells: ["Header"] },
237
+ rows: [],
238
+ dataRows: [],
239
+ },
240
+ ],
241
+ },
242
+ "panels-bulkEdit2": {
243
+ viewHeaders: [{ id: "col-1", text: "Header" }],
244
+ viewCells: [{ view: "fields/text", name: "col-1", label: "Header" }],
245
+ dataRows: [],
246
+ import: { submitUrl: "https://example.com/bulk-edit", paramName: "rows" },
247
+ },
248
+ "panels-custom": {
249
+ template: "thumbnail",
250
+ data: { title: "Custom", subtitle: "Template" },
251
+ },
252
+ "panels-carousel": { childViews: [sampleLabelView("Slide 1"), sampleLabelView("Slide 2")] },
253
+ "panels-timeline": {
254
+ events: [
255
+ { text: "1", color: "#333333" },
256
+ { icon: "check", color: "#333333" },
257
+ ],
258
+ childViews: [sampleLabelView("Event 1"), sampleLabelView("Event 2")],
259
+ },
260
+ "panels-pagination": { length: 3, value: 1 },
261
+ "panels-tree": {
262
+ sections: [
263
+ {
264
+ rows: [
265
+ { template: "standard", title: "Root", rows: [] },
266
+ ],
267
+ },
268
+ ],
269
+ },
270
+ "panels-web": { url: "https://example.com" },
271
+ "panels-association": { childViews: [sampleLabelView()] },
272
+ "fields-select": { options: sampleOptions, value: "a" },
273
+ "fields-timeZone": { options: sampleOptions, value: "a" },
274
+ "fields-dynamicSelect": {
275
+ url: "https://example.com/dynamic-select",
276
+ selectedOptions: [{ title: "Option A", value: "a" }],
277
+ },
278
+ "fields-date": { value: "2024-01-01" },
279
+ "fields-datetime": { value: "2024-01-01T10:00" },
280
+ "fields-radioGroup": {
281
+ childViews: [
282
+ { view: "fields/radio", label: "Option A", value: "a" },
283
+ { view: "fields/radio", label: "Option B", value: "b" },
284
+ ],
285
+ },
286
+ "fields-radio": { label: "Option", value: "a" },
287
+ "fields-checkGroup": {
288
+ childViews: [
289
+ { view: "fields/check", label: "Option A", checkValue: "a" },
290
+ { view: "fields/check", label: "Option B", checkValue: "b" },
291
+ ],
292
+ },
293
+ "fields-check": {
294
+ label: "Check",
295
+ checkValue: "yes",
296
+ uncheckValue: "no",
297
+ value: "yes",
298
+ },
299
+ "fields-chipGroup": { options: sampleOptions, value: "a" },
300
+ "fields-dynamicGroup": {
301
+ name: "items",
302
+ titlePrefix: "Item",
303
+ groupFieldProperties: [[]],
304
+ template: {
305
+ childViews: [sampleFieldView("title", "Title")],
306
+ },
307
+ },
308
+ "fields-creditCard": { publicKey: "pk_test_123", name: "payment" },
309
+ "fields-submit": { text: "Submit" },
310
+ "banners-alert": { title: "Alert", message: "Something happened." },
311
+ "banners-select": {
312
+ title: "Select",
313
+ message: "Choose an option.",
314
+ buttons: [{ text: "Ok" }],
315
+ },
316
+ "charts-line": {
317
+ dataSeries: [
318
+ { title: "Series A", points: [{ x: "Jan", y: 10 }, { x: "Feb", y: 20 }] },
319
+ ],
320
+ },
321
+ "charts-column": {
322
+ dataSeries: [
323
+ { title: "Series A", points: [{ x: "Jan", y: 5 }, { x: "Feb", y: 15 }] },
324
+ ],
325
+ },
326
+ "charts-area": {
327
+ dataSeries: [
328
+ { title: "Series A", points: [{ x: "Jan", y: 8 }, { x: "Feb", y: 12 }] },
329
+ ],
330
+ },
331
+ "charts-pie": {
332
+ dataSeries: [
333
+ { title: "A", value: 10 },
334
+ { title: "B", value: 20 },
335
+ ],
336
+ },
337
+ "multimedia-video": {
338
+ url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
339
+ width: 320,
340
+ height: 180,
341
+ },
342
+ };
343
+ const buildComponentSpec = (name: string) => {
344
+ const view = viewFromName(name);
345
+ const spec: Record<string, unknown> = {
346
+ view,
347
+ id: `${name}-spec`,
348
+ key: `${name}-key`,
349
+ };
350
+
351
+ if (name.startsWith("views-")) {
352
+ Object.assign(spec, { text: `${name} sample` });
353
+ }
354
+
355
+ if (name.startsWith("fields-")) {
356
+ Object.assign(spec, {
357
+ name: `${name}-field`,
358
+ label: `${name} label`,
359
+ value: "sample",
360
+ });
361
+ }
362
+
363
+ if (name.startsWith("panels-")) {
364
+ Object.assign(spec, { childViews: [sampleLabelView()] });
365
+ }
366
+
367
+ const override = componentSpecOverrides[name];
368
+ if (TypeUtils.isObject(override)) {
369
+ Object.assign(spec, override);
370
+ }
371
+
372
+ return spec;
373
+ };
374
+ const componentSpecs = componentNames.map((name) => ({
375
+ name,
376
+ spec: buildComponentSpec(name),
377
+ }));
378
+
379
+
380
+ const mountComponent = (spec: Record<string, unknown>) => {
381
+ mount(Component, {
382
+ props: { spec },
383
+ global: {
384
+ mixins: [genericMixin, eventsMixin, stylesMixin, updatableComponent],
385
+ plugins: [vuetify],
386
+ config: {
387
+ globalProperties: {
388
+ $type: {
389
+ isObject: TypeUtils.isObject,
390
+ isString: TypeUtils.isString,
391
+ isNumber: TypeUtils.isNumber,
392
+ isArray: TypeUtils.isArray,
393
+ ifObject: TypeUtils.ifObject,
394
+ ifString: TypeUtils.ifString,
395
+ ifNumber: TypeUtils.ifNumber,
396
+ ifArray: TypeUtils.ifArray,
397
+ },
398
+ },
399
+ },
400
+ provide: provideDefaults(),
401
+ },
402
+ });
403
+ };
404
+
405
+ const textFieldVariants = [
406
+ { view: "fields/text", type: "text" },
407
+ { view: "fields/number", type: "number" },
408
+ { view: "fields/email", type: "email" },
409
+ { view: "fields/url", type: "url" },
410
+ { view: "fields/password", type: "password" },
411
+ ];
412
+
413
+ describe("component.vue", () => {
414
+ beforeEach(() => {
415
+ setupGlibGlobals();
416
+ });
417
+
418
+ componentSpecs.forEach(({ name, spec }) => {
419
+ it(`renders ${name} for ${spec.view}`, () => {
420
+ mountComponent(spec);
421
+ // cy.get(`[data-testid="${name}"]`).should("exist");
422
+ });
423
+ });
424
+
425
+ textFieldVariants.forEach(({ view, type }) => {
426
+ it(`renders fields-text for ${view}`, () => {
427
+ mountComponent({ view });
428
+ // cy.get('[data-testid="fields-text"]').should("have.attr", "data-type", type);
429
+ });
430
+ });
431
+
432
+ });
@@ -20,4 +20,23 @@ describe('autoValidate', () => {
20
20
  cy.contains('is not included in the list').should('exist')
21
21
  cy.get('.fields-check .v-input--error').should('exist')
22
22
  })
23
- })
23
+
24
+ it('has email format validation', () => {
25
+ cy.visit(url)
26
+
27
+ cy.get('input[type="email"]').first().as('emailInput')
28
+ cy.get('@emailInput').closest('.v-input').as('emailField')
29
+
30
+ cy.get('@emailInput').clear().type('invalid-email').blur()
31
+ cy.get('@emailField').contains('E-mail must be valid').should('exist')
32
+
33
+ cy.get('@emailInput').clear().type('jane.doe@example').blur()
34
+ cy.get('@emailField').contains('E-mail must be valid').should('exist')
35
+
36
+ cy.get('@emailInput').clear().type('jane.doe@example.com').blur()
37
+ cy.get('@emailField').contains('E-mail must be valid').should('not.exist')
38
+
39
+ cy.get('@emailInput').clear().type('jane.doe@example.com.au').blur()
40
+ cy.get('@emailField').contains('E-mail must be valid').should('not.exist')
41
+ })
42
+ })
@@ -0,0 +1,39 @@
1
+ import { testPageUrl } from "../../helper"
2
+
3
+ const url = testPageUrl('http')
4
+
5
+ describe('http', () => {
6
+ it('http/post sends form data', () => {
7
+ cy.visit(url)
8
+
9
+ cy.contains('button', /^http\/post$/).click()
10
+
11
+ cy.get('.v-dialog .unformatted').should('contain.text', 'Method: POST')
12
+ cy.get('.v-dialog .unformatted').should('contain.text', '"first": "New"')
13
+ cy.get('.v-dialog .unformatted').should('contain.text', '"last": "Joe"')
14
+ cy.get('.v-dialog').contains('OK').click()
15
+ cy.get('.v-dialog').should('not.exist')
16
+ })
17
+
18
+ it('http/patch sends form data', () => {
19
+ cy.visit(url)
20
+
21
+ cy.contains('button', /^http\/patch$/).click()
22
+
23
+ cy.get('.v-dialog .unformatted').should('contain.text', 'Method: PATCH')
24
+ cy.get('.v-dialog .unformatted').should('contain.text', '"name": "Edit Joe"')
25
+ cy.get('.v-dialog').contains('OK').click()
26
+ cy.get('.v-dialog').should('not.exist')
27
+ })
28
+
29
+ it('http/delete sends form data', () => {
30
+ cy.visit(url)
31
+
32
+ cy.contains('button', /^http\/delete$/).click()
33
+
34
+ cy.get('.v-dialog .unformatted').should('contain.text', 'Method: DELETE')
35
+ cy.get('.v-dialog .unformatted').should('contain.text', '"name": "Delete Joe"')
36
+ cy.get('.v-dialog').contains('OK').click()
37
+ cy.get('.v-dialog').should('not.exist')
38
+ })
39
+ })
@@ -0,0 +1,28 @@
1
+ import { testPageUrl } from "../../helper"
2
+
3
+ const url = testPageUrl('list_editable')
4
+
5
+ describe('list_editable', () => {
6
+ it('check/uncheck all items', () => {
7
+ cy.visit(url)
8
+
9
+ cy.get('input[type="checkbox"][name="user[check_all]"]').as('checkAll')
10
+ cy.get('input[type="checkbox"][name^="user[items]"]').as('itemChecks')
11
+
12
+ cy.get('@itemChecks').should('have.length', 20)
13
+
14
+ cy.get('@checkAll').click()
15
+
16
+ cy.get('@checkAll').should('be.checked')
17
+ cy.get('@itemChecks').each(($input) => {
18
+ cy.wrap($input).should('be.checked')
19
+ })
20
+
21
+ cy.get('@checkAll').click()
22
+
23
+ cy.get('@checkAll').should('not.be.checked')
24
+ cy.get('@itemChecks').each(($input) => {
25
+ cy.wrap($input).should('not.be.checked')
26
+ })
27
+ })
28
+ })
@@ -20,4 +20,20 @@ describe('logics_set', () => {
20
20
 
21
21
  // Test passes if no errors occurred
22
22
  })
23
+
24
+ it('warns on duplicate form field names', () => {
25
+ cy.visit(url, {
26
+ onBeforeLoad(win) {
27
+ cy.stub(win.console, 'warn').as('consoleWarn')
28
+ }
29
+ })
30
+
31
+ cy.contains('Logics Set - Icon Badge').should('be.visible')
32
+
33
+ cy.contains('Update label').scrollIntoView().should('be.visible').click()
34
+ cy.get('#say_label').should('contain.text', 'Hello').and('contain.text', 'Silent')
35
+
36
+ cy.get('@consoleWarn')
37
+ .should('have.been.calledWith', 'Multiple inputs share the same name: "user[say]".')
38
+ })
23
39
  })
@@ -0,0 +1,27 @@
1
+ import { testPageUrl } from "../../helper"
2
+
3
+ const url = testPageUrl('sheets')
4
+
5
+ describe('sheets', () => {
6
+ it('sheets/show renders content and can close', () => {
7
+ cy.visit(url)
8
+
9
+ cy.contains('button', /^sheets\/show$/).click()
10
+
11
+ cy.get('.views-sheet').should('have.class', 'right')
12
+ cy.get('.views-sheet').contains('First ordered list item').should('exist')
13
+
14
+ cy.get('.views-sheet').contains('close').click()
15
+ cy.get('.views-sheet').should('not.exist')
16
+ })
17
+
18
+ it("sheets/open placement: 'right'", () => {
19
+ cy.visit(url)
20
+
21
+ cy.contains("button", "sheets/open placement: 'right'").click()
22
+
23
+ cy.get('.views-sheet', { timeout: 10000 }).should('have.class', 'right')
24
+ cy.get('.views-sheet', { timeout: 10000 }).contains('close').click()
25
+ cy.get('.views-sheet').should('not.exist')
26
+ })
27
+ })
@@ -0,0 +1,40 @@
1
+ import { testPageUrl } from "../../helper"
2
+
3
+ const url = testPageUrl('snackbars')
4
+
5
+ describe('snackbars', () => {
6
+ const closeDialogByText = (text: string) => {
7
+ cy.contains('.v-dialog', text, { timeout: 10000 }).within(() => {
8
+ cy.contains('.v-btn__content', 'OK').click()
9
+ })
10
+ cy.contains('.v-dialog', text).should('not.exist')
11
+ }
12
+
13
+ it('alert with no timeout triggers close action', () => {
14
+ cy.visit(url)
15
+
16
+ cy.contains('button', /^snackbars\/alert with no timeout$/).click()
17
+
18
+ cy.get('.v-snackbar')
19
+ .contains('This is a persistent alert snackbar')
20
+ .should('exist')
21
+ cy.get('.v-snackbar').contains('CLOSE').click()
22
+
23
+ cy.get('.v-dialog .unformatted').should('contain.text', 'Closed')
24
+ closeDialogByText('Closed')
25
+ cy.get('.v-dialog').should('not.exist')
26
+ })
27
+
28
+ it('select snackbar action triggers dialog', () => {
29
+ cy.visit(url)
30
+
31
+ cy.contains('button', /^snackbars\/select$/).click()
32
+
33
+ cy.get('.v-snackbar').contains('Option1').click()
34
+
35
+ cy.get('.v-dialog .unformatted').should('contain.text', 'Option 1')
36
+ closeDialogByText('Closed')
37
+ closeDialogByText('Option 1')
38
+ cy.get('.v-dialog').should('not.exist')
39
+ })
40
+ })
@@ -0,0 +1,10 @@
1
+ <!doctype html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ </head>
7
+ <body>
8
+ <div data-cy-root></div>
9
+ </body>
10
+ </html>
@@ -0,0 +1,11 @@
1
+ import "./commands";
2
+
3
+ const win = window as Window & { __settings?: { theme?: object }; __page?: object };
4
+
5
+ if (!win.__settings) {
6
+ win.__settings = { theme: {} };
7
+ }
8
+
9
+ if (!win.__page) {
10
+ win.__page = {};
11
+ }
@@ -0,0 +1,8 @@
1
+ const noop = () => {};
2
+
3
+ export default {
4
+ dialog: { open: noop, close: noop },
5
+ sheet: { open: noop, close: noop },
6
+ snackbar: { open: noop, close: noop },
7
+ popover: { open: noop, close: noop },
8
+ };
package/cypress.config.ts CHANGED
@@ -1,7 +1,42 @@
1
1
  import { defineConfig } from "cypress";
2
+ import vue from "@vitejs/plugin-vue";
2
3
  import codeCoverageTask from "@cypress/code-coverage/task";
4
+ import path from "path";
5
+ import { fileURLToPath } from "url";
6
+
7
+ const __filename = fileURLToPath(import.meta.url);
8
+ const __dirname = path.dirname(__filename);
9
+ const launchStub = path.resolve(__dirname, "cypress/support/launch-stub.js");
10
+ const launchModule = path.resolve(__dirname, "utils/launch.js");
3
11
 
4
12
  export default defineConfig({
13
+ component: {
14
+ devServer: {
15
+ framework: "vue",
16
+ bundler: "vite",
17
+ viteConfig: {
18
+ plugins: [vue()],
19
+ resolve: {
20
+ alias: [
21
+ {
22
+ find: "../../utils/launch",
23
+ replacement: launchStub,
24
+ },
25
+ {
26
+ find: launchModule,
27
+ replacement: launchStub,
28
+ },
29
+ {
30
+ find: /utils\/launch$/,
31
+ replacement: launchStub,
32
+ },
33
+ ],
34
+ },
35
+ },
36
+ },
37
+ specPattern: "cypress/component/**/*.cy.{js,jsx,ts,tsx}",
38
+ supportFile: "cypress/support/component.ts",
39
+ },
5
40
  e2e: {
6
41
  setupNodeEvents(on, config) {
7
42
  codeCoverageTask(on, config);
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "glib-web",
4
- "version": "4.42.0",
4
+ "version": "4.42.1",
5
5
  "description": "",
6
6
  "main": "index.js",
7
7
  "scripts": {
8
- "test": "cypress run --browser chrome",
9
- "test:coverage": "VITE_COVERAGE=true cypress run --browser chrome && nyc report"
8
+ "test": "env -u ELECTRON_RUN_AS_NODE cypress run --browser chrome",
9
+ "test:component": "env -u ELECTRON_RUN_AS_NODE cypress run --component",
10
+ "test:coverage": "env -u ELECTRON_RUN_AS_NODE VITE_COVERAGE=true cypress run --browser chrome && env -u ELECTRON_RUN_AS_NODE VITE_COVERAGE=true cypress run --component && nyc report"
10
11
  },
11
12
  "author": "",
12
13
  "license": "ISC",
@@ -45,12 +46,14 @@
45
46
  },
46
47
  "devDependencies": {
47
48
  "@cypress/code-coverage": "^3.14.7",
49
+ "@cypress/vite-dev-server": "^5.0.0",
48
50
  "@types/chart.js": "^2.9.34",
49
51
  "@vitejs/plugin-vue": "^6.0.2",
50
52
  "cypress": "^13.13.1",
51
53
  "eslint": "^8.36.0",
52
54
  "eslint-plugin-vue": "^9.26.0",
53
55
  "prettier": "^1.18.2",
56
+ "sass-embedded": "^1.97.1",
54
57
  "typescript": "^4.9.5",
55
58
  "vite": "^7.2.7",
56
59
  "vite-plugin-compression": "^0.5.1",
@@ -58,4 +61,4 @@
58
61
  "vite-plugin-istanbul": "^7.2.1",
59
62
  "vite-plugin-ruby": "^5.1.1"
60
63
  }
61
- }
64
+ }
@@ -1,6 +1,6 @@
1
1
  <template>
2
2
  <component :is="componentName" :href="$href()" class="thumbnail" :class="cssClasses" @[clickCondition]="$onClick()">
3
- <panels-responsive :spec="spec.header" />
3
+ <panels-responsive v-if="headerSpec" :spec="headerSpec" />
4
4
  <div style="display:flex;">
5
5
  <!-- <div v-if="spec.leftOuterButtons" style="display:flex; margin-top:10px;">
6
6
  <template v-for="(item, index) in spec.leftOuterButtons" :key="index">
@@ -14,7 +14,7 @@
14
14
  <!-- <v-icon v-if="spec.onReorder" class="handle">drag_indicator</v-icon> -->
15
15
 
16
16
  <!-- Specify a key to prevent reuse which causes an issue where the checkbox would use the previous name. -->
17
- <fields-check v-if="checkSpec" :key="checkSpec.name" :spec="checkSpec" />
17
+ <glib-component v-if="checkSpec" :key="checkSpec.name" :spec="checkSpec" />
18
18
 
19
19
  <template v-slot:prepend>
20
20
  <div style="display: flex">
@@ -50,10 +50,10 @@
50
50
 
51
51
  <v-list-item-subtitle v-if="spec.subtitle">{{
52
52
  spec.subtitle
53
- }}</v-list-item-subtitle>
53
+ }}</v-list-item-subtitle>
54
54
  <v-list-item-subtitle v-if="spec.subsubtitle">{{
55
55
  spec.subsubtitle
56
- }}</v-list-item-subtitle>
56
+ }}</v-list-item-subtitle>
57
57
 
58
58
  <div v-if="hasChips" class="chips">
59
59
  <template v-for="(item, index) in chips" :key="index">
@@ -71,19 +71,16 @@
71
71
  </template>
72
72
 
73
73
  </v-list-item>
74
- <panels-responsive :spec="spec.right" />
74
+ <panels-responsive v-if="rightSpec" :spec="rightSpec" />
75
75
  </div>
76
- <panels-responsive :spec="spec.footer" />
76
+ <panels-responsive v-if="footerSpec" :spec="footerSpec" />
77
77
  </component>
78
78
  </template>
79
79
 
80
80
  <script>
81
- import CheckField from "../components/fields/check.vue";
81
+ import * as TypeUtils from "../utils/type.js";
82
82
 
83
83
  export default {
84
- components: {
85
- "fields-check": CheckField
86
- },
87
84
  props: {
88
85
  spec: { type: Object, required: true },
89
86
  responsiveCols: { type: Number, default: () => 0 }
@@ -93,6 +90,32 @@ export default {
93
90
  // editButtons: []
94
91
  };
95
92
  },
93
+ setup(props) {
94
+ const name = props.spec.fieldCheckName;
95
+ const checkSpec = TypeUtils.isString(name) && name.length > 0 ? {
96
+ id: props.spec.id,
97
+ view: "fields/check",
98
+ name: name,
99
+ checkValue: true,
100
+ valueIf: props.spec.fieldCheckValueIf,
101
+ padding: { left: 16 }
102
+ } : null;
103
+
104
+ const chipsSource = TypeUtils.isArray(props.spec.chips) ? props.spec.chips : [];
105
+ const chips = chipsSource.filter(item => TypeUtils.isObject(item)).map(item => {
106
+ let color = null;
107
+ TypeUtils.ifArray(item.styleClasses, classes => {
108
+ for (const val of ["success", "info", "warning", "error"]) {
109
+ if (classes.includes(val)) {
110
+ color = val;
111
+ }
112
+ }
113
+ });
114
+ return Object.assign({}, item, { color: color, view: "chip" });
115
+ });
116
+
117
+ return { checkSpec, chips };
118
+ },
96
119
  computed: {
97
120
  componentName() {
98
121
  // Use `a` to enable "open in new tab".
@@ -102,6 +125,15 @@ export default {
102
125
  cssClasses() {
103
126
  return this.$classes(this.spec, "templates/thumbnail");
104
127
  },
128
+ headerSpec() {
129
+ return TypeUtils.isObject(this.spec.header) ? this.spec.header : null;
130
+ },
131
+ rightSpec() {
132
+ return TypeUtils.isObject(this.spec.right) ? this.spec.right : null;
133
+ },
134
+ footerSpec() {
135
+ return TypeUtils.isObject(this.spec.footer) ? this.spec.footer : null;
136
+ },
105
137
  clickCondition() {
106
138
  if (this.spec.onClick || this.spec.onLongPress) {
107
139
  // This will show the clickable indication
@@ -110,35 +142,8 @@ export default {
110
142
  return null;
111
143
  },
112
144
  hasChips() {
113
- return this.spec.chips && this.spec.chips.length > 0;
145
+ return this.chips.length > 0;
114
146
  },
115
- // Implemented as computed so that it gets reflected when reordering
116
- chips() {
117
- return (this.spec.chips || []).map(item => {
118
- var color = null;
119
- this.$type.ifArray(item.styleClasses, classes => {
120
- for (const val of ["success", "info", "warning", "error"]) {
121
- if (classes.includes(val)) {
122
- color = val;
123
- }
124
- }
125
- });
126
- return Object.assign({}, item, { color: color, view: "chip" });
127
- });
128
- },
129
- // Implemented as computed so that it gets reflected when navigating to another list containing check fields.
130
- checkSpec() {
131
- if (this.spec.fieldCheckName) {
132
- return {
133
- view: "fields/checkGroup",
134
- name: this.spec.fieldCheckName,
135
- checkValue: true,
136
- valueIf: this.spec.fieldCheckValueIf,
137
- padding: { left: 16 }
138
- };
139
- }
140
- return null;
141
- }
142
147
  // cssStyles() {
143
148
  // const styles = this.$styles();
144
149
  // // switch(this.spec.align) {
@@ -155,6 +160,9 @@ export default {
155
160
  // },
156
161
  },
157
162
  methods: {
163
+ $registryEnabled() {
164
+ return false;
165
+ },
158
166
  buttonSpec(item) {
159
167
  // Classes should be coming from the backend
160
168
  // const styleClasses = ["text", "x-small"]
@@ -229,4 +237,4 @@ a.thumbnail {
229
237
  // cursor: move;
230
238
  // }
231
239
  }
232
- </style>
240
+ </style>