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.
@@ -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
- <component :is="template(element)" :spec="element" :responsive-cols="spec.responsiveCols"
25
- :data-dragRowId="element.id" />
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
- const strict = true;
199
- if (strict) {
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 {
@@ -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 || '').match(regex);
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 AbsenceValidator {
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
- let result;
122
- if (isPresent(minimum)) {
123
- result = model.length > minimum ? true : this.errorMessage('too_short', minimum);
124
- }
125
- if (result !== true) return result;
126
- if (isPresent(maximum)) {
127
- result = model.length < maximum ? true : this.errorMessage('too_long', maximum);
128
- }
129
- if (result !== true) return result;
130
- if (isPresent(this.validationOptions.is)) {
131
- result = model.length === this.validationOptions.is ? true : this.errorMessage('wrong_length', this.validationOptions.is);
132
- }
133
- if (result !== true) return result;
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 result;
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
- // it('fires scroll callbacks on list events', () => {
43
- // cy.visit(url)
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
- // cy.contains('Actions and Events').scrollIntoView()
46
- // cy.get('#list_event_status').should('contain', 'Scroll events: idle')
121
+ cy.get('#list_autoload').scrollTo('bottom')
122
+ cy.get('#list_autoload').scrollTo('bottom')
47
123
 
48
- // cy.wait(1100)
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
- // cy.contains('Row 12').scrollIntoView()
51
- // cy.get('#list_event_status').should('contain', 'Scroll events: bottom reached')
131
+ cy.contains('Removable Bravo').should('exist')
52
132
 
53
- // cy.contains('Row 1').scrollIntoView()
54
- // cy.get('#list_event_status').should('contain', 'Scroll events: top reached')
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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "glib-web",
4
- "version": "6.0.2",
4
+ "version": "6.0.4",
5
5
  "description": "",
6
6
  "main": "index.js",
7
7
  "scripts": {
@@ -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
- // directives,
14
+ directives: { Ripple },
14
15
  date: {
15
16
  formats: {
16
17
  glibDate: (d) => {