glib-web 6.0.2 → 6.0.4
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.
- package/action.js +2 -0
- package/actions/lists/remove.js +23 -0
- package/app.scss +14 -1
- package/components/_internal_button.vue +2 -44
- package/components/_internal_icon.vue +5 -10
- package/components/composable/listAutoload.js +32 -0
- package/components/composable/tableAutoload.js +33 -0
- package/components/fab.vue +19 -9
- package/components/fields/_patternText.vue +100 -19
- package/components/fields/_select.vue +8 -1
- package/components/fields/text.vue +12 -1
- package/components/mixins/buttonProps.js +30 -0
- package/components/mixins/longClick.js +13 -7
- package/components/mixins/scrolling.js +2 -2
- package/components/mixins/styles.js +8 -0
- package/components/panels/custom.vue +10 -7
- package/components/panels/list.vue +42 -10
- package/components/panels/table.vue +1 -76
- package/components/validation.js +24 -19
- package/cypress/component/testUtils.js +11 -11
- package/cypress/e2e/glib-web/fields.cy.js +29 -0
- package/cypress/e2e/glib-web/fieldsDateTime.cy.js +67 -0
- package/cypress/e2e/glib-web/fieldsSelect.cy.js +48 -0
- package/cypress/e2e/glib-web/list.cy.js +103 -10
- package/package.json +1 -1
- package/plugins/vuetify.js +2 -1
- package/templates/comment.vue +8 -0
- package/templates/editable.vue +6 -1
- package/templates/featured.vue +7 -0
- package/templates/thumbnail.vue +8 -2
- package/utils/constant.js +9 -1
- package/components/composable/tableExport.js +0 -84
- package/components/composable/tableImport.js +0 -163
- package/cypress/e2e/glib-web/listsAppend.cy.js +0 -27
|
@@ -21,8 +21,9 @@
|
|
|
21
21
|
:data-dragSectionIndex="sectionIndex" :disabled="!$type.isObject(dragSupport)" item-key="id"
|
|
22
22
|
@start="onDragStart" @end="onDragEnd">
|
|
23
23
|
<template #item="{ element }">
|
|
24
|
-
<
|
|
25
|
-
|
|
24
|
+
<panels-custom v-if="isCustomRow(element)" :spec="element" :data-dragRowId="element.id" />
|
|
25
|
+
<component :is="template(element)" v-else :spec="element" :compName="element.template"
|
|
26
|
+
:responsive-cols="spec.responsiveCols" :data-dragRowId="element.id" />
|
|
26
27
|
</template>
|
|
27
28
|
</draggable>
|
|
28
29
|
</div>
|
|
@@ -46,23 +47,29 @@
|
|
|
46
47
|
</template>
|
|
47
48
|
|
|
48
49
|
<script>
|
|
50
|
+
import { getCurrentInstance } from "vue";
|
|
49
51
|
import { vueApp } from "../../store";
|
|
50
52
|
import draggable from "vuedraggable";
|
|
51
53
|
import ThumbnailTemplate from "../../templates/thumbnail.vue";
|
|
52
54
|
import EditableTemplate from "../../templates/editable.vue";
|
|
53
55
|
import FeaturedTemplate from "../../templates/featured.vue";
|
|
54
56
|
import CommentTemplate from "../../templates/comment.vue";
|
|
57
|
+
import UnsupportedTemplate from "../../templates/unsupported.vue";
|
|
58
|
+
import CustomPanel from "./custom.vue";
|
|
55
59
|
import { useListAutoload } from "../composable/listAutoload";
|
|
56
60
|
import GlibBase from "../base/glibBase.js";
|
|
57
61
|
|
|
58
62
|
export default {
|
|
59
63
|
components: {
|
|
60
64
|
draggable,
|
|
65
|
+
"template-standard": ThumbnailTemplate,
|
|
61
66
|
"template-thumbnail": ThumbnailTemplate,
|
|
62
67
|
"template-editable": EditableTemplate,
|
|
63
68
|
"template-featured": FeaturedTemplate,
|
|
64
69
|
"template-commentOutgoing": CommentTemplate,
|
|
65
70
|
"template-commentIncoming": CommentTemplate,
|
|
71
|
+
"template-unsupported": UnsupportedTemplate,
|
|
72
|
+
"panels-custom": CustomPanel,
|
|
66
73
|
},
|
|
67
74
|
extends: GlibBase,
|
|
68
75
|
props: {
|
|
@@ -193,18 +200,28 @@ export default {
|
|
|
193
200
|
$tearDown() {
|
|
194
201
|
this.cancelAutoloadRequest();
|
|
195
202
|
},
|
|
203
|
+
isCustomRow(row) {
|
|
204
|
+
// The Ruby `custom` list template emits a `data` payload (the inner template
|
|
205
|
+
// name lands in `row.template`). Route these through panels/custom so the
|
|
206
|
+
// payload is unwrapped into the rendered template's spec.
|
|
207
|
+
return this.$type.isObject(row.data);
|
|
208
|
+
},
|
|
196
209
|
template(row) {
|
|
197
210
|
const name = `template-${row.template.replace("/", "-")}`;
|
|
198
|
-
|
|
199
|
-
|
|
211
|
+
|
|
212
|
+
// Built-in templates registered locally on this component.
|
|
213
|
+
if (this.$options.components[name]) {
|
|
214
|
+
return name;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Project-defined custom templates (e.g. `row.custom`) registered globally
|
|
218
|
+
// as `template-<name>`. Mirrors `panels/custom.vue` and `skeleton.vue`.
|
|
219
|
+
if (getCurrentInstance().appContext.components[name]) {
|
|
200
220
|
return name;
|
|
201
|
-
} else {
|
|
202
|
-
if (this.$options.components[name]) {
|
|
203
|
-
return name;
|
|
204
|
-
}
|
|
205
|
-
console.warn(`Invalid template: ${row.template}`);
|
|
206
|
-
return null;
|
|
207
221
|
}
|
|
222
|
+
|
|
223
|
+
console.warn(`Invalid template: ${row.template}`);
|
|
224
|
+
return "template-unsupported";
|
|
208
225
|
},
|
|
209
226
|
serializedSpec(row) {
|
|
210
227
|
return JSON.stringify(row);
|
|
@@ -290,6 +307,21 @@ export default {
|
|
|
290
307
|
// Publicly invokable
|
|
291
308
|
action_appendRow(row, sectionIndex) {
|
|
292
309
|
this.sections[sectionIndex].rows.push(row);
|
|
310
|
+
},
|
|
311
|
+
// Publicly invokable. Removes a row by id from any section reactively,
|
|
312
|
+
// preserving scroll position and already-loaded pages.
|
|
313
|
+
action_removeRow(rowId) {
|
|
314
|
+
let removed = false;
|
|
315
|
+
for (const section of this.sections) {
|
|
316
|
+
const before = section.rows.length;
|
|
317
|
+
section.rows = section.rows.filter((row) => row.id !== rowId);
|
|
318
|
+
if (section.rows.length !== before) {
|
|
319
|
+
removed = true;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
if (!removed) {
|
|
323
|
+
console.warn("Row ID not found in list", rowId);
|
|
324
|
+
}
|
|
293
325
|
}
|
|
294
326
|
},
|
|
295
327
|
};
|
|
@@ -5,32 +5,6 @@
|
|
|
5
5
|
<table v-if="loadIf" :style="$styles()" :class="$classes()">
|
|
6
6
|
<template v-for="(section, sectionIndex) in sections" :key="`head_${sectionIndex}`">
|
|
7
7
|
<thead>
|
|
8
|
-
<tr v-if="importable || exportable">
|
|
9
|
-
<td colspan="10">
|
|
10
|
-
<div class="pa-3">
|
|
11
|
-
<template v-if="importable">
|
|
12
|
-
<span>{{
|
|
13
|
-
rows(section).length + section.dataRows.length
|
|
14
|
-
}}
|
|
15
|
-
rows</span>
|
|
16
|
-
<input ref="fileInput" style="display: none;" type="file" accept=".csv"
|
|
17
|
-
@change="loadFile($event, section)" />
|
|
18
|
-
<v-btn @click="triggerImport(sectionIndex)">Import</v-btn>
|
|
19
|
-
<v-btn :disabled="totalRows(section) <= 0" @click="submitRows($event, section)">Save</v-btn>
|
|
20
|
-
<v-btn :disabled="totalRows(section) <= 0" @click="clear(section)">Clear</v-btn>
|
|
21
|
-
</template>
|
|
22
|
-
|
|
23
|
-
<div class="float-right">
|
|
24
|
-
<v-btn v-if="exportable" :download="exportFile" :href="exportCsv(section)">{{ exportLabel }}</v-btn>
|
|
25
|
-
</div>
|
|
26
|
-
</div>
|
|
27
|
-
|
|
28
|
-
<div v-if="output" style="white-space: pre-line;">
|
|
29
|
-
{{ output }}
|
|
30
|
-
</div>
|
|
31
|
-
</td>
|
|
32
|
-
</tr>
|
|
33
|
-
|
|
34
8
|
<tr v-if="section.header" :style="$styles(section.header)">
|
|
35
9
|
<template v-if="section.header.dataCells">
|
|
36
10
|
<th v-for="(cell, index) in section.header.dataCells" :key="index"
|
|
@@ -64,12 +38,6 @@
|
|
|
64
38
|
</td>
|
|
65
39
|
</tr>
|
|
66
40
|
</template>
|
|
67
|
-
|
|
68
|
-
<tr v-for="(row, rowIndex) in section.dataRows" :key="`data_row_${rowIndex}`">
|
|
69
|
-
<td v-for="(cell, cellIndex) in row" :key="`data_cell_${cellIndex}`">
|
|
70
|
-
<a class="data-cell">{{ cell }}</a>
|
|
71
|
-
</td>
|
|
72
|
-
</tr>
|
|
73
41
|
</tbody>
|
|
74
42
|
|
|
75
43
|
<tfoot>
|
|
@@ -97,8 +65,6 @@
|
|
|
97
65
|
|
|
98
66
|
<script>
|
|
99
67
|
import { useTableAutoload } from "../composable/tableAutoload";
|
|
100
|
-
import { useTableExport } from "../composable/tableExport";
|
|
101
|
-
import { useTableImport } from "../composable/tableImport";
|
|
102
68
|
import GlibBase from "../base/glibBase.js";
|
|
103
69
|
|
|
104
70
|
export default {
|
|
@@ -108,9 +74,7 @@ export default {
|
|
|
108
74
|
},
|
|
109
75
|
setup() {
|
|
110
76
|
return {
|
|
111
|
-
...useTableAutoload()
|
|
112
|
-
...useTableExport(),
|
|
113
|
-
...useTableImport()
|
|
77
|
+
...useTableAutoload()
|
|
114
78
|
};
|
|
115
79
|
},
|
|
116
80
|
data() {
|
|
@@ -118,24 +82,6 @@ export default {
|
|
|
118
82
|
sections: []
|
|
119
83
|
};
|
|
120
84
|
},
|
|
121
|
-
computed: {
|
|
122
|
-
output() {
|
|
123
|
-
// let str = ""
|
|
124
|
-
// let count = 0
|
|
125
|
-
// for (const section of this.sections) {
|
|
126
|
-
// for (const row of section.dataRows) {
|
|
127
|
-
// const name = row[2]
|
|
128
|
-
// const email = row[3]
|
|
129
|
-
// const createdAt = row[5]
|
|
130
|
-
// const activationState = row[14] ? 'active' : 'pending'
|
|
131
|
-
// count += 1
|
|
132
|
-
// str += `{ name: "${name}".to_s, email: '${email}'.to_s, created_at: '${createdAt}', activation_state: '${activationState}'},\n`
|
|
133
|
-
// }
|
|
134
|
-
// }
|
|
135
|
-
// return `Processing ${count} rows:\n[\n${str}\n]`
|
|
136
|
-
return "";
|
|
137
|
-
}
|
|
138
|
-
},
|
|
139
85
|
watch: {
|
|
140
86
|
spec: {
|
|
141
87
|
handler() {
|
|
@@ -157,23 +103,16 @@ export default {
|
|
|
157
103
|
if (Utils.type.isObject(specSection.footer)) {
|
|
158
104
|
section.footer = Object.assign({}, specSection.footer);
|
|
159
105
|
}
|
|
160
|
-
section.dataRows = [];
|
|
161
106
|
sections.push(section);
|
|
162
107
|
});
|
|
163
108
|
});
|
|
164
109
|
this.sections = sections;
|
|
165
110
|
this.autoloadAll(this.spec.nextPage);
|
|
166
|
-
this.initCsvExport();
|
|
167
|
-
this.initCsvImport();
|
|
168
111
|
this.enableInfiniteScrollIfApplicable();
|
|
169
112
|
},
|
|
170
113
|
$tearDown() {
|
|
171
114
|
this.cancelAutoloadRequest();
|
|
172
115
|
},
|
|
173
|
-
clear(section) {
|
|
174
|
-
section.rows = [];
|
|
175
|
-
section.dataRows = [];
|
|
176
|
-
},
|
|
177
116
|
colSpan(row, index) {
|
|
178
117
|
const spans = row.colSpans || [];
|
|
179
118
|
return spans[index] || 1;
|
|
@@ -188,16 +127,6 @@ export default {
|
|
|
188
127
|
rowStyles = rowStyles.concat('clickable');
|
|
189
128
|
}
|
|
190
129
|
return rowStyles;
|
|
191
|
-
},
|
|
192
|
-
rows(section) {
|
|
193
|
-
return section.rows || [];
|
|
194
|
-
},
|
|
195
|
-
triggerImport(index) {
|
|
196
|
-
const input = this.$refs.fileInput[index];
|
|
197
|
-
input.click();
|
|
198
|
-
},
|
|
199
|
-
totalRows(section) {
|
|
200
|
-
return (section.rows || []).length + (section.dataRows || []).length;
|
|
201
130
|
}
|
|
202
131
|
}
|
|
203
132
|
};
|
|
@@ -259,10 +188,6 @@ tbody {
|
|
|
259
188
|
overflow: auto;
|
|
260
189
|
}
|
|
261
190
|
|
|
262
|
-
.data-cell {
|
|
263
|
-
white-space: pre-line;
|
|
264
|
-
}
|
|
265
|
-
|
|
266
191
|
table.table--grid {
|
|
267
192
|
tbody {
|
|
268
193
|
td {
|
package/components/validation.js
CHANGED
|
@@ -53,6 +53,13 @@ class NumericalityValidator extends AbstractValidator {
|
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
validate(key, count, value) {
|
|
56
|
+
// Reject non-numeric input (empty/whitespace string, null, undefined, NaN)
|
|
57
|
+
// instead of letting JS coerce it to 0 in the numeric comparisons below.
|
|
58
|
+
if (key !== 'in') {
|
|
59
|
+
const blank = value == null || (typeof value === 'string' && value.trim() === '');
|
|
60
|
+
if (blank || isNaN(Number(value))) return false;
|
|
61
|
+
}
|
|
62
|
+
|
|
56
63
|
const obj = {
|
|
57
64
|
'greater_than': () => count < value,
|
|
58
65
|
'greater_than_or_equal_to': () => count <= value,
|
|
@@ -81,7 +88,7 @@ class FormatValidator extends AbstractValidator {
|
|
|
81
88
|
if (this.isAllowBlank() && !isPresent(model)) return true;
|
|
82
89
|
|
|
83
90
|
const regex = this.validationOptions.with || this.validationOptions.without || this.validationOptions.regex;
|
|
84
|
-
let match = !!(model
|
|
91
|
+
let match = !!String(model ?? '').match(regex);
|
|
85
92
|
match = this.isInverse() ? !match : match;
|
|
86
93
|
|
|
87
94
|
return match ? true : this.validationOptions.message;
|
|
@@ -112,30 +119,28 @@ class ExclusionValidator extends AbstractValidator {
|
|
|
112
119
|
}
|
|
113
120
|
}
|
|
114
121
|
|
|
115
|
-
class LengthValidator extends
|
|
122
|
+
class LengthValidator extends AbstractValidator {
|
|
116
123
|
build(model) {
|
|
117
124
|
model ||= '';
|
|
118
125
|
if (this.isAllowBlank() && !isPresent(model)) return true;
|
|
119
126
|
|
|
120
|
-
const { minimum, maximum } = this.validationOptions;
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
if (
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
if (isPresent(this.validationOptions.in)) {
|
|
135
|
-
result = (model.length >= this.validationOptions.is || model.length <= this.validatorOptions.is) ? true : this.errorMessage('wrong_length', this.validationOptions.in);
|
|
127
|
+
const { minimum, maximum, is } = this.validationOptions;
|
|
128
|
+
const length = model.length;
|
|
129
|
+
|
|
130
|
+
if (isPresent(minimum) && length < minimum) return this.errorMessage('too_short', minimum);
|
|
131
|
+
if (isPresent(maximum) && length > maximum) return this.errorMessage('too_long', maximum);
|
|
132
|
+
if (isPresent(is) && length !== is) return this.errorMessage('wrong_length', is);
|
|
133
|
+
|
|
134
|
+
const range = this.validationOptions.in || this.validationOptions.within;
|
|
135
|
+
if (isPresent(range)) {
|
|
136
|
+
const bounds = [range].flat();
|
|
137
|
+
const min = Math.min(...bounds);
|
|
138
|
+
const max = Math.max(...bounds);
|
|
139
|
+
if (length < min) return this.errorMessage('too_short', min);
|
|
140
|
+
if (length > max) return this.errorMessage('too_long', max);
|
|
136
141
|
}
|
|
137
142
|
|
|
138
|
-
return
|
|
143
|
+
return true;
|
|
139
144
|
}
|
|
140
145
|
|
|
141
146
|
errorMessage(key, count) {
|
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
import { mount } from "cypress/vue";
|
|
2
|
-
import genericMixin from "../../components/mixins/generic";
|
|
2
|
+
import genericMixin from "../../components/mixins/generic.js";
|
|
3
3
|
import stylesMixin from "../../components/mixins/styles.js";
|
|
4
|
-
import updatableComponent from "../../components/mixins/updatableComponent";
|
|
5
|
-
import vuetify from "../../plugins/vuetify";
|
|
6
|
-
import Uploader from "../../utils/glibDirectUpload";
|
|
7
|
-
import * as TypeUtils from "../../utils/type";
|
|
8
|
-
import Framework from "../../utils/public";
|
|
9
|
-
import App from "../../utils/app";
|
|
10
|
-
import UrlUtils from "../../utils/url";
|
|
11
|
-
import Format from "../../utils/format";
|
|
12
|
-
import Dom from "../../utils/dom";
|
|
13
|
-
import Settings from "../../utils/settings";
|
|
4
|
+
import updatableComponent from "../../components/mixins/updatableComponent.js";
|
|
5
|
+
import vuetify from "../../plugins/vuetify.js";
|
|
6
|
+
import Uploader from "../../utils/glibDirectUpload.js";
|
|
7
|
+
import * as TypeUtils from "../../utils/type.js";
|
|
8
|
+
import Framework from "../../utils/public.js";
|
|
9
|
+
import App from "../../utils/app.js";
|
|
10
|
+
import UrlUtils from "../../utils/url.js";
|
|
11
|
+
import Format from "../../utils/format.js";
|
|
12
|
+
import Dom from "../../utils/dom.js";
|
|
13
|
+
import Settings from "../../utils/settings.js";
|
|
14
14
|
|
|
15
15
|
const glibGlobals = {
|
|
16
16
|
app: App,
|
|
@@ -49,4 +49,33 @@ describe('fields', () => {
|
|
|
49
49
|
cy.get('input[name="user[age]"]').should('have.value', '')
|
|
50
50
|
cy.get('textarea[name="user[bio]"]').should('have.value', '')
|
|
51
51
|
})
|
|
52
|
+
|
|
53
|
+
it('marks a tooltip-bearing checkbox label with the dotted-underline affordance', () => {
|
|
54
|
+
cy.visit(url)
|
|
55
|
+
|
|
56
|
+
// Checkbox with a tooltip gets the affordance.
|
|
57
|
+
cy.get('#field_terms').should('have.class', 'has-tooltip')
|
|
58
|
+
cy.get('#field_terms .v-label')
|
|
59
|
+
.should('have.css', 'text-decoration-style', 'dotted')
|
|
60
|
+
.and('have.css', 'cursor', 'help')
|
|
61
|
+
|
|
62
|
+
// Checkbox without a tooltip is left undecorated.
|
|
63
|
+
cy.get('#field_news').should('not.have.class', 'has-tooltip')
|
|
64
|
+
cy.get('#field_news .v-label')
|
|
65
|
+
.should('have.css', 'text-decoration-line', 'none')
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('keeps a focused number field value on wheel without blocking other inputs', () => {
|
|
69
|
+
cy.visit(url)
|
|
70
|
+
|
|
71
|
+
// A focused number input must not change its value on wheel; it blurs instead
|
|
72
|
+
// so the wheel still scrolls the page.
|
|
73
|
+
cy.get('input[name="user[age]"]').type('25').should('have.value', '25')
|
|
74
|
+
cy.get('input[name="user[age]"]').focus().trigger('wheel', { deltaY: 120 })
|
|
75
|
+
cy.get('input[name="user[age]"]').should('have.value', '25').and('not.be.focused')
|
|
76
|
+
|
|
77
|
+
// A focused text input is untouched by wheel.
|
|
78
|
+
cy.get('input[name="user[full_name]"]').focus().trigger('wheel', { deltaY: 120 })
|
|
79
|
+
cy.get('input[name="user[full_name]"]').should('be.focused')
|
|
80
|
+
})
|
|
52
81
|
})
|
|
@@ -88,4 +88,71 @@ describe('fields_date_time', () => {
|
|
|
88
88
|
expect(comp.spec.template.type).to.eq('plain')
|
|
89
89
|
})
|
|
90
90
|
})
|
|
91
|
+
|
|
92
|
+
it('parses a typed date and stores it as YYYY-MM-DD', () => {
|
|
93
|
+
cy.visit(url)
|
|
94
|
+
|
|
95
|
+
cy.get('#date_basic .v-field__input').clear()
|
|
96
|
+
cy.get('#date_basic .v-field__input').type('03/15/2025').blur()
|
|
97
|
+
|
|
98
|
+
cy.get('#date_basic .v-field__input').should('have.value', 'Mar 15, 2025')
|
|
99
|
+
cy.get('input[name="user[date_basic]"]').should('have.value', '2025-03-15')
|
|
100
|
+
cy.get('#date_status').should('contain.text', 'Date changed')
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('parses a typed date in a named-month format', () => {
|
|
104
|
+
cy.visit(url)
|
|
105
|
+
|
|
106
|
+
cy.get('#date_basic .v-field__input').clear()
|
|
107
|
+
cy.get('#date_basic .v-field__input').type('Mar 20 2025').blur()
|
|
108
|
+
|
|
109
|
+
cy.get('input[name="user[date_basic]"]').should('have.value', '2025-03-20')
|
|
110
|
+
cy.get('#date_status').should('contain.text', 'Date changed')
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('parses a typed date on Enter', () => {
|
|
114
|
+
cy.visit(url)
|
|
115
|
+
|
|
116
|
+
cy.get('#date_basic .v-field__input').clear()
|
|
117
|
+
cy.get('#date_basic .v-field__input').type('03/15/2025{enter}')
|
|
118
|
+
|
|
119
|
+
cy.get('input[name="user[date_basic]"]').should('have.value', '2025-03-15')
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('parses a typed datetime and stores it', () => {
|
|
123
|
+
cy.visit(url)
|
|
124
|
+
|
|
125
|
+
cy.get('#datetime_basic .v-field__input').clear()
|
|
126
|
+
cy.get('#datetime_basic .v-field__input').type('12/25/2024 20:15').blur()
|
|
127
|
+
|
|
128
|
+
cy.get('input[name="user[datetime_basic]"]').should('have.value', '2024-12-25T20:15')
|
|
129
|
+
cy.get('#datetime_status').should('contain.text', 'Datetime changed')
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('shows a validation error for an unparseable date and keeps the value', () => {
|
|
133
|
+
cy.visit(url)
|
|
134
|
+
|
|
135
|
+
cy.get('#date_basic .v-field__input').clear()
|
|
136
|
+
cy.get('#date_basic .v-field__input').type('not a date').blur()
|
|
137
|
+
|
|
138
|
+
cy.get('#date_basic').contains('Invalid date').should('be.visible')
|
|
139
|
+
cy.get('input[name="user[date_basic]"]').should('have.value', '2024-12-12')
|
|
140
|
+
cy.get('#date_status').should('contain.text', 'idle')
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('clears the date when the typed text is emptied', () => {
|
|
144
|
+
cy.visit(url)
|
|
145
|
+
|
|
146
|
+
cy.get('#date_basic .v-field__input').clear().blur()
|
|
147
|
+
|
|
148
|
+
cy.get('input[name="user[date_basic]"]').should('have.value', '')
|
|
149
|
+
cy.get('#date_status').should('contain.text', 'Date changed')
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('exposes the picker through the calendar append-inner icon', () => {
|
|
153
|
+
cy.visit(url)
|
|
154
|
+
|
|
155
|
+
cy.get('#date_basic').contains('calendar_today').should('be.visible')
|
|
156
|
+
cy.get('#date_basic').find('input.native-picker').should('exist')
|
|
157
|
+
})
|
|
91
158
|
})
|
|
@@ -119,4 +119,52 @@ describe('fields_select', () => {
|
|
|
119
119
|
cy.get('#select_media .v-select--multiple').should('not.exist');
|
|
120
120
|
cy.get('#select_media .v-chip').should('not.exist');
|
|
121
121
|
});
|
|
122
|
+
|
|
123
|
+
it('renders icon options in the basic example by default', () => {
|
|
124
|
+
cy.visit(url);
|
|
125
|
+
|
|
126
|
+
cy.get('#select_media .v-field__input').should('contain.text', 'Option 1');
|
|
127
|
+
cy.get('#select_media').click();
|
|
128
|
+
cy.get('.v-overlay--active .v-list-item').should('have.length', 4);
|
|
129
|
+
cy.get('.v-overlay--active .v-list-item').first().find('.v-icon').should('exist');
|
|
130
|
+
cy.get('.v-overlay--active .v-list-item').first().find('img').should('not.exist');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('selects a different option in the basic example', () => {
|
|
134
|
+
cy.visit(url);
|
|
135
|
+
|
|
136
|
+
cy.get('#select_media').click();
|
|
137
|
+
cy.get('.v-overlay--active .v-list-item').contains('Option 3').click();
|
|
138
|
+
cy.get('#select_media input[type="hidden"]').should('have.value', 'option3');
|
|
139
|
+
cy.get('#select_media .v-field__input').should('contain.text', 'Option 3');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('clears the basic example selection', () => {
|
|
143
|
+
cy.visit(url);
|
|
144
|
+
|
|
145
|
+
cy.get('#select_media input[type="hidden"]').should('have.value', 'option1');
|
|
146
|
+
cy.get('#select_media [aria-label="Clear Select with icon"]').click();
|
|
147
|
+
cy.get('#select_media input[type="hidden"]').should('have.value', '');
|
|
148
|
+
cy.get('#select_media .v-field__input').should('not.contain.text', 'Option 1');
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('filters searchable options by text and subtitle', () => {
|
|
152
|
+
cy.on('uncaught:exception', (err) => {
|
|
153
|
+
if (err.message.includes('ResizeObserver')) return false;
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
cy.visit(url);
|
|
157
|
+
|
|
158
|
+
cy.get('#select_searchable_subtitle').click();
|
|
159
|
+
cy.get('#select_searchable_subtitle input[type="text"]').type('app');
|
|
160
|
+
cy.get('.v-overlay--active .v-list-item').should('have.length', 1);
|
|
161
|
+
cy.get('.v-overlay--active .v-list-item').contains('Apple').should('be.visible');
|
|
162
|
+
|
|
163
|
+
cy.get('#select_searchable_subtitle input[type="text"]').clear();
|
|
164
|
+
cy.get('#select_searchable_subtitle input[type="text"]').type('root');
|
|
165
|
+
cy.get('.v-overlay--active .v-list-item').should('have.length', 2);
|
|
166
|
+
cy.get('.v-overlay--active .v-list-item').contains('Carrot').should('be.visible');
|
|
167
|
+
cy.get('.v-overlay--active .v-list-item').contains('Daikon').should('be.visible');
|
|
168
|
+
cy.get('.v-overlay--active .v-list-item').contains('Apple').should('not.exist');
|
|
169
|
+
});
|
|
122
170
|
});
|
|
@@ -39,18 +39,111 @@ describe('list', () => {
|
|
|
39
39
|
})
|
|
40
40
|
})
|
|
41
41
|
|
|
42
|
-
|
|
43
|
-
|
|
42
|
+
it('fires scroll callbacks when scrolling inside a fixed-height list', () => {
|
|
43
|
+
cy.visit(url)
|
|
44
|
+
|
|
45
|
+
// Bring the events list on-screen and wait past the registration delay.
|
|
46
|
+
cy.get('#list_events').scrollIntoView()
|
|
47
|
+
cy.wait(1100)
|
|
48
|
+
|
|
49
|
+
// Scrolling INSIDE the inner list (not the page) should fire the callbacks.
|
|
50
|
+
cy.get('#list_events').scrollTo('bottom')
|
|
51
|
+
cy.get('#list_event_status').should('contain', 'Scroll events: bottom reached')
|
|
52
|
+
|
|
53
|
+
cy.get('#list_events').scrollTo('top')
|
|
54
|
+
cy.get('#list_event_status').should('contain', 'Scroll events: top reached')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('appends rows to the populated list (lists/append)', () => {
|
|
58
|
+
cy.visit(url)
|
|
59
|
+
|
|
60
|
+
cy.contains('Starter row').should('exist')
|
|
61
|
+
|
|
62
|
+
cy.contains('lists/append (thumbnail)').click()
|
|
63
|
+
cy.get('#lists_append_status').should('contain.text', 'Status: appended')
|
|
64
|
+
cy.contains('New row').should('exist')
|
|
65
|
+
|
|
66
|
+
cy.contains('Append + snackbar').click()
|
|
67
|
+
cy.contains('Snackbar row').should('exist')
|
|
68
|
+
cy.contains('.v-snackbar', 'Row appended').should('exist')
|
|
69
|
+
|
|
70
|
+
cy.contains('Append two rows').click()
|
|
71
|
+
cy.contains('Batch row A').should('exist')
|
|
72
|
+
cy.contains('Batch row B').should('exist')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('removes a row by id (lists/remove)', () => {
|
|
76
|
+
cy.visit(url)
|
|
77
|
+
|
|
78
|
+
cy.contains('Removable Alpha').should('exist')
|
|
79
|
+
|
|
80
|
+
cy.contains('button', 'Remove Alpha').click()
|
|
81
|
+
cy.contains('Removable Alpha').should('not.exist')
|
|
82
|
+
cy.contains('Removable Bravo').should('exist')
|
|
83
|
+
cy.get('#lists_remove_status').should('contain.text', 'removed Alpha')
|
|
84
|
+
|
|
85
|
+
// Removing a non-existent row is a no-op but still runs onRemove.
|
|
86
|
+
cy.contains('button', 'Remove missing').click()
|
|
87
|
+
cy.get('#lists_remove_status').should('contain.text', 'no row matched')
|
|
88
|
+
cy.contains('Removable Bravo').should('exist')
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('registers a row id so logics/set can target the whole row', () => {
|
|
92
|
+
cy.visit(url)
|
|
93
|
+
|
|
94
|
+
cy.window().then((win) => {
|
|
95
|
+
expect(win.GLib.component.findById('target_row_alpha')).to.exist
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
cy.contains('button', 'Update Alpha (logics/set)').click()
|
|
99
|
+
cy.contains('Target Alpha (updated)').should('exist')
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('renders custom row templates (row.custom)', () => {
|
|
103
|
+
cy.visit(url)
|
|
104
|
+
|
|
105
|
+
cy.contains('Custom (thumbnail)').should('exist')
|
|
106
|
+
cy.contains('Custom (featured)').should('exist')
|
|
107
|
+
cy.contains('Unsupported template: nonExistentTemplate').should('exist')
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('loads more rows when scrolling the autoload list (nextPage asNeeded)', () => {
|
|
111
|
+
cy.visit(url)
|
|
112
|
+
|
|
113
|
+
// The autoload list starts with a finite page of rows.
|
|
114
|
+
cy.get('#list_autoload').scrollIntoView()
|
|
115
|
+
cy.contains('Item 29').should('exist')
|
|
116
|
+
|
|
117
|
+
// Autoload registers its scroll handler after a short delay; wait past it
|
|
118
|
+
// before scrolling so the bottom anchor visibility check is active.
|
|
119
|
+
cy.wait(1100)
|
|
44
120
|
|
|
45
|
-
|
|
46
|
-
|
|
121
|
+
cy.get('#list_autoload').scrollTo('bottom')
|
|
122
|
+
cy.get('#list_autoload').scrollTo('bottom')
|
|
47
123
|
|
|
48
|
-
|
|
124
|
+
// Reaching the bottom fetches the next page and appends more rows.
|
|
125
|
+
cy.contains('Item 30').should('exist')
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('removes Bravo by id (lists/remove)', () => {
|
|
129
|
+
cy.visit(url)
|
|
49
130
|
|
|
50
|
-
|
|
51
|
-
// cy.get('#list_event_status').should('contain', 'Scroll events: bottom reached')
|
|
131
|
+
cy.contains('Removable Bravo').should('exist')
|
|
52
132
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
133
|
+
cy.contains('button', 'Remove Bravo').click()
|
|
134
|
+
cy.contains('Removable Bravo').should('not.exist')
|
|
135
|
+
cy.contains('Removable Charlie').should('exist')
|
|
136
|
+
cy.get('#lists_remove_status').should('contain.text', 'removed Bravo')
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('updates Bravo via logics/set targeting the row id', () => {
|
|
140
|
+
cy.visit(url)
|
|
141
|
+
|
|
142
|
+
cy.window().then((win) => {
|
|
143
|
+
expect(win.GLib.component.findById('target_row_bravo')).to.exist
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
cy.contains('button', 'Update Bravo (logics/set)').click()
|
|
147
|
+
cy.contains('Target Bravo (updated)').should('exist')
|
|
148
|
+
})
|
|
56
149
|
})
|
package/package.json
CHANGED
package/plugins/vuetify.js
CHANGED
|
@@ -3,6 +3,7 @@ import { createVuetify } from "vuetify";
|
|
|
3
3
|
import { aliases, md } from 'vuetify/iconsets/md';
|
|
4
4
|
import 'vuetify/styles';
|
|
5
5
|
import * as components from 'vuetify/components';
|
|
6
|
+
import { Ripple } from 'vuetify/directives';
|
|
6
7
|
import { jsonSettings } from "../store";
|
|
7
8
|
// import * as directives from 'vuetify/directives'
|
|
8
9
|
// import { md3 } from 'vuetify/blueprints';
|
|
@@ -10,7 +11,7 @@ import { jsonSettings } from "../store";
|
|
|
10
11
|
const opts = {
|
|
11
12
|
// blueprint: md3,
|
|
12
13
|
components,
|
|
13
|
-
|
|
14
|
+
directives: { Ripple },
|
|
14
15
|
date: {
|
|
15
16
|
formats: {
|
|
16
17
|
glibDate: (d) => {
|