glib-web 4.8.0 → 4.8.2

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.
@@ -1,6 +1,7 @@
1
1
  import jsonLogic from 'json-logic-js';
2
2
  import { fieldModels } from "../../components/composable/conditional";
3
3
  import merge from 'lodash.merge';
4
+ import { nextTick } from "vue";
4
5
 
5
6
  const subscript = function (a, b) {
6
7
  if (a) {
@@ -78,6 +79,9 @@ export default class {
78
79
  });
79
80
  }
80
81
 
81
- GLib.action.execute(spec.onSet, targetComponent);
82
+ // Make sure the update performed by the above `action_merge()` has been reflected.
83
+ nextTick(() => {
84
+ GLib.action.execute(spec.onSet, component);
85
+ })
82
86
  }
83
87
  }
package/app.scss ADDED
@@ -0,0 +1,172 @@
1
+ .border-\[2px\] {
2
+ border-width: 2px;
3
+ }
4
+
5
+ .border-\[4px\] {
6
+ border-width: 4px;
7
+ }
8
+
9
+ .\!font-semibold {
10
+ font-weight: 600px;
11
+ }
12
+
13
+ .opacity-50 {
14
+ opacity: 0.5;
15
+ }
16
+
17
+ .guploaded-file {
18
+ /* pt-6 w-full */
19
+ padding-top: 24px;
20
+ width: 100%;
21
+
22
+ .title {
23
+ color: #6b7280;
24
+ font-weight: 600;
25
+ margin-bottom: 8px;
26
+ }
27
+
28
+ .file-container {
29
+ display: flex;
30
+ align-items: center;
31
+ flex-direction: column;
32
+ border-radius: 6px;
33
+ background-color: #f3f4f6;
34
+ width: 100%;
35
+ padding: 16px;
36
+ margin-bottom: 16px;
37
+ min-height: 72px;
38
+ gap: 8px;
39
+ }
40
+
41
+ .file {
42
+ display: flex;
43
+ width: 100%;
44
+ position: relative;
45
+
46
+ .status {
47
+ display: flex;
48
+ width: 100%;
49
+ gap: 8px;
50
+ align-items: center;
51
+
52
+ .image-icon {
53
+ width: 40px;
54
+ height: 40px;
55
+ background-color: #fff;
56
+ border-radius: 5px;
57
+ display: flex;
58
+ justify-content: center;
59
+ align-items: center;
60
+
61
+ img {
62
+ width: 20px;
63
+ height: 20px;
64
+ }
65
+ }
66
+
67
+ .icon {
68
+ margin-right: 8px;
69
+ }
70
+
71
+ .progress {
72
+ display: flex;
73
+ flex-grow: 1;
74
+ flex-direction: column;
75
+ justify-content: space-between;
76
+
77
+ .label {
78
+ /* mb-1 */
79
+ display: flex;
80
+ flex-direction: column;
81
+ margin-bottom: 4px;
82
+ }
83
+
84
+ .label a {
85
+ color: #47495F;
86
+ font-weight: 600;
87
+ }
88
+
89
+ .label a:hover {
90
+ text-decoration: underline;
91
+ }
92
+
93
+ .label a[href=""],
94
+ .label a[href="#"],
95
+ .label a:not([href]) {
96
+ text-decoration: none !important;
97
+ }
98
+
99
+ }
100
+ }
101
+
102
+ .close-btn {
103
+ cursor: pointer;
104
+ }
105
+ }
106
+
107
+ .background {
108
+ width: 100%;
109
+ background-color: #e6e6e6;
110
+ border-radius: 9999px;
111
+ height: 8px;
112
+ display: flex;
113
+
114
+ .value {
115
+ background-color: #0A2A9E;
116
+ height: inherit;
117
+ border-radius: inherit;
118
+ }
119
+ }
120
+
121
+ .percentage-wrapper {
122
+ width: 100%;
123
+ display: flex;
124
+ align-items: center;
125
+ justify-content: center;
126
+ gap: 8px;
127
+
128
+ .percentage {
129
+ font-size: 14px;
130
+ font-weight: 600;
131
+ margin-bottom: 2px;
132
+ }
133
+ }
134
+ }
135
+
136
+ .gdrop-file {
137
+ /* px-6 py-10 border-[2px] border-dashed border-gray-400 w-full rounded-2xl cursor-pointer */
138
+ padding: 40px 24px;
139
+ border-style: dashed;
140
+ border-color: #9ca3af;
141
+ width: 100%;
142
+ border-radius: 16px;
143
+ cursor: pointer;
144
+
145
+ .cloud {
146
+ /* w-full flex flex-col justify-center items-center */
147
+ width: 100%;
148
+ display: flex;
149
+ flex-direction: column;
150
+ justify-content: center;
151
+ align-items: center;
152
+
153
+ .icon {
154
+ /* mb-3 text-gray-400 */
155
+ margin-bottom: 12px;
156
+ color: #9ca3af;
157
+ }
158
+
159
+ .title {
160
+ /* text-center mb-2 */
161
+ text-align: center;
162
+ margin-bottom: 8px;
163
+ color: #47495F;
164
+ }
165
+
166
+ .subtitle {
167
+ /* text-color-light text-center */
168
+ color: #A7ADB5;
169
+ text-align: center;
170
+ }
171
+ }
172
+ }
@@ -103,6 +103,8 @@ import FormPanel from "./panels/form.vue";
103
103
  import ListPanel from "./panels/list.vue";
104
104
  import CarouselPanel from "./panels/carousel.vue";
105
105
  import TablePanel from "./panels/table.vue";
106
+ import BulkEditPanel from "./panels/bulkEdit.vue";
107
+ import BulkEditPanel2 from "./panels/bulkEdit2.vue";
106
108
  import CustomPanel from "./panels/custom.vue";
107
109
  import ColumnPanel from "./panels/column.vue";
108
110
  import ResponsivePanel from "./panels/responsive.vue";
@@ -198,6 +200,8 @@ export default {
198
200
  "panels-list": ListPanel,
199
201
  "panels-carousel": CarouselPanel,
200
202
  "panels-table": TablePanel,
203
+ "panels-bulkEdit": BulkEditPanel,
204
+ "panels-bulkEdit2": BulkEditPanel2,
201
205
  "panels-custom": CustomPanel,
202
206
  "panels-responsive": ResponsivePanel,
203
207
  "panels-column": ColumnPanel,
@@ -0,0 +1,41 @@
1
+ export function parseCsv(csvString) {
2
+ // https://stackoverflow.com/a/41563966/9970813
3
+ let prevLetter = "",
4
+ row = [""],
5
+ result = [row],
6
+ columnIndex = 0,
7
+ rowIndex = 0,
8
+ canSplit = true,
9
+ letter;
10
+ for (let i = 0; i <= csvString.length; ++i) {
11
+ letter = csvString[i];
12
+
13
+ if ('"' === letter) {
14
+ if (canSplit && letter === prevLetter) {
15
+ row[columnIndex] += letter;
16
+ }
17
+ canSplit = !canSplit;
18
+ } else if ("," === letter && canSplit) {
19
+ letter = row[++columnIndex] = "";
20
+ } else if ("\n" === letter && canSplit) {
21
+ if ("\r" === prevLetter) {
22
+ row[columnIndex] = row[columnIndex].slice(0, -1);
23
+ }
24
+ row = result[++rowIndex] = [(letter = "")];
25
+ columnIndex = 0;
26
+ } else if (Utils.type.isString(letter)) {
27
+ row[columnIndex] += letter;
28
+ }
29
+ prevLetter = letter;
30
+ }
31
+
32
+ // Remove any blank rows
33
+ result = result.filter(r => {
34
+ return r[0] !== "";
35
+ });
36
+ // result = result.filter(r => {
37
+ // return r[0] !== "undefined"
38
+ // });
39
+
40
+ return result;
41
+ }
@@ -38,12 +38,14 @@
38
38
  </div>
39
39
  </div>
40
40
  </div>
41
- <div class="percentage-wrapper" v-show="file[1].progress.value > 0 && file[1].progress.value < 100">
42
- <div class="background">
43
- <div class="value" :style="{ width: `${file[1].progress.value}%` }"></div>
41
+ <Transition name="slide-fade">
42
+ <div class="percentage-wrapper " v-show="file[1].progress.value > 0 && !file[1].message">
43
+ <div class="background">
44
+ <div class="value" :style="{ width: `${file[1].progress.value}%` }"></div>
45
+ </div>
46
+ <div class="percentage">{{ parseInt(file[1].progress.value) }}%</div>
44
47
  </div>
45
- <div class="percentage">{{ parseInt(file[1].progress.value) }}%</div>
46
- </div>
48
+ </Transition>
47
49
  </div>
48
50
  </template>
49
51
  </div>
@@ -51,181 +53,6 @@
51
53
  </div>
52
54
  </template>
53
55
 
54
- <style>
55
- .border-\[2px\] {
56
- border-width: 2px;
57
- }
58
-
59
- .border-\[4px\] {
60
- border-width: 4px;
61
- }
62
-
63
- .\!font-semibold {
64
- font-weight: 600px;
65
- }
66
-
67
- .opacity-50 {
68
- opacity: 0.5;
69
- }
70
-
71
- .guploaded-file {
72
- /* pt-6 w-full */
73
- padding-top: 24px;
74
- width: 100%;
75
-
76
- .title {
77
- color: #6b7280;
78
- font-weight: 600;
79
- margin-bottom: 8px;
80
- }
81
-
82
- .file-container {
83
- display: flex;
84
- align-items: center;
85
- flex-direction: column;
86
- border-radius: 6px;
87
- background-color: #f3f4f6;
88
- width: 100%;
89
- padding: 16px;
90
- margin-bottom: 16px;
91
- min-height: 72px;
92
- gap: 8px;
93
- }
94
-
95
- .file {
96
- display: flex;
97
- width: 100%;
98
- position: relative;
99
-
100
- .status {
101
- display: flex;
102
- width: 100%;
103
- gap: 8px;
104
- align-items: center;
105
-
106
- .image-icon {
107
- width: 40px;
108
- height: 40px;
109
- background-color: #fff;
110
- border-radius: 5px;
111
- display: flex;
112
- justify-content: center;
113
- align-items: center;
114
-
115
- img {
116
- width: 20px;
117
- height: 20px;
118
- }
119
- }
120
-
121
- .icon {
122
- margin-right: 8px;
123
- }
124
-
125
- .progress {
126
- display: flex;
127
- flex-grow: 1;
128
- flex-direction: column;
129
- justify-content: space-between;
130
-
131
- .label {
132
- /* mb-1 */
133
- display: flex;
134
- flex-direction: column;
135
- margin-bottom: 4px;
136
- }
137
-
138
- .label a {
139
- color: #47495F;
140
- font-weight: 600;
141
- }
142
-
143
- .label a:hover {
144
- text-decoration: underline;
145
- }
146
-
147
- .label a[href=""],
148
- .label a[href="#"],
149
- .label a:not([href]) {
150
- text-decoration: none !important;
151
- }
152
-
153
- }
154
- }
155
-
156
- .close-btn {
157
- cursor: pointer;
158
- }
159
- }
160
-
161
- .background {
162
- width: 100%;
163
- background-color: #e6e6e6;
164
- border-radius: 9999px;
165
- height: 8px;
166
- display: flex;
167
-
168
- .value {
169
- background-color: #0A2A9E;
170
- height: inherit;
171
- border-radius: inherit;
172
- }
173
- }
174
-
175
- .percentage-wrapper {
176
- width: 100%;
177
- display: flex;
178
- align-items: center;
179
- justify-content: center;
180
- gap: 8px;
181
-
182
- .percentage {
183
- font-size: 14px;
184
- font-weight: 600;
185
- margin-bottom: 2px;
186
- }
187
- }
188
- }
189
-
190
- .gdrop-file {
191
- /* px-6 py-10 border-[2px] border-dashed border-gray-400 w-full rounded-2xl cursor-pointer */
192
- padding: 40px 24px;
193
- border-style: dashed;
194
- border-color: #9ca3af;
195
- width: 100%;
196
- border-radius: 16px;
197
- cursor: pointer;
198
-
199
- .cloud {
200
- /* w-full flex flex-col justify-center items-center */
201
- width: 100%;
202
- display: flex;
203
- flex-direction: column;
204
- justify-content: center;
205
- align-items: center;
206
-
207
- .icon {
208
- /* mb-3 text-gray-400 */
209
- margin-bottom: 12px;
210
- color: #9ca3af;
211
- }
212
-
213
- .title {
214
- /* text-center mb-2 */
215
- text-align: center;
216
- margin-bottom: 8px;
217
- color: #47495F;
218
- }
219
-
220
- .subtitle {
221
- /* text-color-light text-center */
222
- color: #A7ADB5;
223
- text-align: center;
224
- }
225
- }
226
- }
227
- </style>
228
-
229
56
  <script>
230
57
  import { ref, computed, defineComponent, watch, getCurrentInstance } from 'vue';
231
58
  import { VIcon } from 'vuetify/components';
@@ -234,9 +61,9 @@ import * as delegateUploader from "../composable/upload_delegator";
234
61
  import { triggerOnChange } from "../composable/form";
235
62
  import { nextTick } from "vue";
236
63
  import { useFilesState, useFileUtils } from "../composable/file";
237
- import doc from "./selectAsset/doc-1.png"
238
- import pic from "./selectAsset/pic-1.png"
239
- import pdf from "./selectAsset/pdf-1.png"
64
+ import doc from "./selectAsset/doc-1.png";
65
+ import pic from "./selectAsset/pic-1.png";
66
+ import pdf from "./selectAsset/pdf-1.png";
240
67
  const { makeKey, Item, signedIds } = useFileUtils();
241
68
 
242
69
  export default defineComponent({
@@ -375,7 +202,7 @@ export default defineComponent({
375
202
  props,
376
203
  doc,
377
204
  pic,
378
- pdf,
205
+ pdf
379
206
  };
380
207
  }
381
208
  })
@@ -29,13 +29,12 @@ export default {
29
29
  align-items: center;
30
30
  justify-content: center;
31
31
  width: 240px;
32
- height: 254px;
32
+ height: 100%;
33
33
  transition: border-color 0.3s, box-shadow 0.3s, color 0.3s;
34
34
  text-align: center;
35
35
  cursor: pointer;
36
- padding: 16px;
37
36
  position: relative;
38
- border: 1px solid #E6E6E6;
37
+ border: 2px solid #E6E6E6;
39
38
  border-radius: 24px;
40
39
  }
41
40
 
@@ -56,18 +55,15 @@ export default {
56
55
  align-items: center;
57
56
  justify-content: center;
58
57
  width: 100%;
59
- height: 100%;
60
- margin-right: 30px;
61
- }
62
-
63
- .custom-radio .custom-radio-icon {
64
- margin-bottom: 8px;
58
+ padding: 40px;
65
59
  }
66
60
 
67
61
  .custom-radio .custom-radio-label {
68
62
  font-size: 22px;
69
63
  color: inherit;
70
- margin-top: 16px;
64
+ margin-top: 24px;
65
+ word-break: break-word;
66
+ min-width: 180px;
71
67
  }
72
68
 
73
69
  .custom-radio ::v-deep .v-selection-control__input {
@@ -90,6 +86,9 @@ export default {
90
86
  display: none;
91
87
  }
92
88
 
89
+ .custom-radio ::v-deep .v-selection-control__wrapper {
90
+ display: none;
91
+ }
93
92
 
94
93
  .custom-radio .v-ripple__container {
95
94
  display: none;
@@ -38,13 +38,12 @@ export default {
38
38
  align-items: center;
39
39
  justify-content: center;
40
40
  width: 240px;
41
- height: 254px;
41
+ min-height: 254px;
42
42
  transition: border-color 0.3s, box-shadow 0.3s, color 0.3s;
43
43
  text-align: center;
44
44
  cursor: pointer;
45
- padding: 16px;
46
45
  position: relative;
47
- border: 1px solid #E6E6E6;
46
+ border: 2px solid #E6E6E6;
48
47
  border-radius: 24px;
49
48
  }
50
49
 
@@ -65,20 +64,13 @@ export default {
65
64
  align-items: center;
66
65
  justify-content: center;
67
66
  width: 100%;
68
- height: 100%;
69
- margin-right: 30px;
70
- }
71
-
72
- .custom-radio .custom-radio-icon {
73
- width: 80px;
74
- height: 80px;
75
- margin-bottom: 8px;
67
+ padding: 40px;
76
68
  }
77
69
 
78
70
  .custom-radio .custom-radio-label {
79
71
  font-size: 22px;
80
72
  color: inherit;
81
- margin-top: 16px;
73
+ margin-top: 24px;
82
74
  }
83
75
 
84
76
  .custom-radio ::v-deep .v-selection-control__input {
@@ -101,6 +93,9 @@ export default {
101
93
  display: none;
102
94
  }
103
95
 
96
+ .custom-radio ::v-deep .v-selection-control__wrapper {
97
+ display: none;
98
+ }
104
99
 
105
100
  .custom-radio .v-ripple__container {
106
101
  display: none;
@@ -1,3 +1,5 @@
1
+ import { fieldModels } from "../../composable/conditional";
2
+
1
3
  export default {
2
4
  data: function() {
3
5
  return {
@@ -15,34 +17,61 @@ export default {
15
17
  vm.importParamName = obj.paramName;
16
18
  });
17
19
  },
20
+ rowSelected(sectionIndex, rowIndex) {
21
+ return fieldModels[this.rowCheckId(sectionIndex, rowIndex)];
22
+ },
23
+ selectedRowCount(section) {
24
+ const sectionIndex = section.index;
25
+ let count = 0;
26
+ section.dataRows.forEach((_row, rowIndex) => {
27
+ if (this.rowSelected(sectionIndex, rowIndex)) {
28
+ count++;
29
+ }
30
+ });
31
+ return count;
32
+ },
18
33
  submitRows(event, section) {
19
- const vm = this;
20
34
  const keys = section.header.dataCells;
21
35
  const rows = [];
22
- section.dataRows.forEach(row => {
36
+ const sectionIndex = section.index;
37
+ let count = 0;
38
+ section.dataRows.forEach((row, rowIndex) => {
39
+ if (!this.rowSelected(sectionIndex, rowIndex)) { // Don't submit
40
+ return;
41
+ }
42
+
43
+ count++;
23
44
  const cells = {};
24
- row.forEach((cell, index) => {
25
- const key = `${vm.importParamName}[${keys[index]}]`;
45
+ row.forEach((cell, cellIndex) => {
46
+ const key = `${this.importParamName}[${keys[cellIndex]}]`;
26
47
  cells[key] = cell;
27
48
  });
49
+ cells['_index'] = rowIndex;
28
50
  rows.push(cells);
29
51
  });
30
52
 
31
- if (Utils.type.isString(this.importSubmitUrl)) {
32
- vm._submitEachRow(rows, event.target);
53
+ if (count > 0) {
54
+ this._submitEachRow(rows);
55
+ } else {
56
+ Utils.launch.dialog.alert("Please select at least one row.", this);
33
57
  }
34
58
  },
35
- _submitEachRow(rows, target) {
59
+ _submitEachRow(rows) {
60
+ const url = Utils.type.string(this.importSubmitUrl)
61
+ if (!url) {
62
+ return;
63
+ }
64
+
36
65
  const vm = this;
37
66
  const row = rows.shift();
38
67
  if (row) {
39
68
  const data = {
40
- url: this.importSubmitUrl,
69
+ url: url,
41
70
  formData: row
42
71
  };
43
72
  Utils.http.execute(data, "POST", vm, response => {
44
73
  GLib.action.handleResponse(response, vm);
45
- vm._submitEachRow(rows, target);
74
+ vm._submitEachRow(rows);
46
75
  });
47
76
  }
48
77
  },
@@ -55,6 +84,13 @@ export default {
55
84
  return cell.trim();
56
85
  });
57
86
  section.dataRows = rows;
87
+
88
+ if (rows.length > 0) {
89
+ this.fileLoaded = true;
90
+ GLib.action.execute(this.spec.onLoadRows, this);
91
+ } else {
92
+ Utils.launch.dialog.alert("File doesn't contain valid data.", this);
93
+ }
58
94
  };
59
95
 
60
96
  // Reset value so it will trigger again the next time the same file is selected.
@@ -0,0 +1,300 @@
1
+ <template>
2
+ <div :style="$styles()" :class="$classes()">
3
+ <panels-responsive v-if="header" :spec="header" />
4
+
5
+ <div class="scrollable">
6
+ <div ref="topAnchor"></div>
7
+
8
+ <table v-if="loadIf">
9
+ <template v-for="(section, sectionIndex) in sections" :key="`head_${sectionIndex}`">
10
+ <thead>
11
+ <tr v-if="importable || exportable">
12
+ <td colspan="20">
13
+ <div>
14
+ <template v-if="importable && fileLoaded">
15
+ <span>{{ section.dataRows.length }} rows loaded</span> --
16
+ <span>{{ selectedRowCount(section) }} rows selected</span>
17
+ </template>
18
+
19
+ <div class="float-right">
20
+ <v-btn v-if="exportable" :download="exportFile" :href="exportCsv(section)">{{ exportLabel }}</v-btn>
21
+ </div>
22
+ </div>
23
+
24
+ <div v-if="output" style="white-space: pre-line;">
25
+ {{ output }}
26
+ </div>
27
+ </td>
28
+ </tr>
29
+
30
+ <tr v-if="section.header" :style="$styles(section.header)">
31
+ <template v-if="section.header.dataCells">
32
+ <th class="status" v-if="fileLoaded">
33
+ <glib-component :spec="headerCheckSpec(section)" />
34
+ </th>
35
+ <th class="fixed-width" v-for="(cell, index) in section.header.dataCells" :key="index"
36
+ :colSpan="colSpan(section.header, index)">
37
+ {{ cell }}
38
+ </th>
39
+ </template>
40
+ <th class="fixed-width" v-for="(cell, index) in section.header.cellViews" v-else :key="index"
41
+ :colSpan="colSpan(section.header, index)">
42
+ <glib-component :spec="cell" />
43
+ </th>
44
+ </tr>
45
+ </thead>
46
+
47
+ <tbody>
48
+ <!-- <template v-for="(row, rowIndex) in section.rows" :key="`row_${rowIndex}`">
49
+ <tr :class="row.onClick ? 'clickable' : ''">
50
+ <td v-for="(cell, cellIndex) in row.cellViews" :key="`cell_${cellIndex}`"
51
+ :colSpan="colSpan(row, cellIndex)" :style="colStyles(row, cellIndex)">
52
+ <glib-component :spec="cell" />
53
+ </td>
54
+ </tr>
55
+ </template> -->
56
+
57
+ <tr v-for="(row, rowIndex) in section.dataRows" :key="`data_row_${rowIndex}`">
58
+ <!-- TODO: Make this first column sticky.
59
+ See https://css-tricks.com/a-table-with-both-a-sticky-header-and-a-sticky-first-column/
60
+ -->
61
+ <td class="status" v-if="fileLoaded">
62
+ <glib-component :spec="rowCheckSpec(sectionIndex, rowIndex)" />
63
+ <glib-component :spec="pendingIconSpec(rowIndex)" />
64
+ </td>
65
+ <td v-for="(cell, cellIndex) in row" :key="`data_cell_${cellIndex}`">
66
+ <v-text-field density="compact" variant="solo-filled" v-model="row[cellIndex]" />
67
+ </td>
68
+ </tr>
69
+
70
+ <tr v-if="importable && section.dataRows.length <= 0">
71
+ <td colspan="20">
72
+ <!-- TODO: Reuse code from multiUpload so it supports drag-and-drop too -->
73
+ <input ref="fileInput" style="display: none;" type="file" accept=".csv"
74
+ @change="loadFile($event, section)" />
75
+ <div
76
+ style="cursor: pointer; border: 1px solid rgba(0, 0, 0, 0.12); text-align: center; padding: 20px; margin: 20px;"
77
+ @click="triggerImport(sectionIndex)">
78
+ Drag your CSV file here<br />
79
+ or click to browse
80
+ </div>
81
+ </td>
82
+ </tr>
83
+ </tbody>
84
+ </template>
85
+ </table>
86
+
87
+ <div ref="bottomAnchor" class="py-3 px-6" :style="bottomAnchorStyles">
88
+ Loading...
89
+ </div>
90
+ </div>
91
+
92
+ <panels-responsive v-if="footer" :spec="footer" />
93
+ </div>
94
+ </template>
95
+
96
+ <script>
97
+ import autoloadMixin from "../mixins/table/autoload.js";
98
+ import exportMixin from "../mixins/table/export.js";
99
+ import importMixin from "../mixins/table/import.js";
100
+
101
+ export default {
102
+ mixins: [autoloadMixin, exportMixin, importMixin],
103
+ props: {
104
+ spec: { type: Object, required: true }
105
+ },
106
+ data() {
107
+ return {
108
+ sections: [],
109
+ fileLoaded: false
110
+ };
111
+ },
112
+ computed: {
113
+ header() {
114
+ return this.spec.header;
115
+ },
116
+ footer() {
117
+ return this.spec.footer;
118
+ },
119
+ output() {
120
+ // let str = ""
121
+ // let count = 0
122
+ // for (const section of this.sections) {
123
+ // for (const row of section.dataRows) {
124
+ // const name = row[2]
125
+ // const email = row[3]
126
+ // const createdAt = row[5]
127
+ // const activationState = row[14] ? 'active' : 'pending'
128
+ // count += 1
129
+ // str += `{ name: "${name}".to_s, email: '${email}'.to_s, created_at: '${createdAt}', activation_state: '${activationState}'},\n`
130
+ // }
131
+ // }
132
+ // return `Processing ${count} rows:\n[\n${str}\n]`
133
+ return "";
134
+ }
135
+ },
136
+ methods: {
137
+ $mounted() {
138
+ this.$onEvent("forms/directSubmit", (e) => {
139
+ for (const section of this.sections) {
140
+ this.submitRows(e.data.url, section);
141
+ }
142
+ });
143
+ },
144
+ $ready() {
145
+ this.sections = this.spec.sections;
146
+ this.sections.forEach((section, sectionIndex) => {
147
+ section.header = section.header || {};
148
+ section.index = sectionIndex;
149
+ // Use Object.assign() to bind the nested property
150
+ Object.assign(section, { dataRows: [] });
151
+ });
152
+ this.autoloadAll(this.spec.nextPage);
153
+ this.initCsvExport();
154
+ this.initCsvImport();
155
+ this.enableInfiniteScrollIfApplicable();
156
+ },
157
+ $tearDown() {
158
+ this.cancelAutoloadRequest();
159
+ },
160
+ pendingIconSpec(rowIndex) {
161
+ const statusViewIdPrefix = this.spec.statusViewIdPrefix || 'data_status_';
162
+ return {
163
+ view: 'icon',
164
+ styleClasses: ['warning'],
165
+ id: `${statusViewIdPrefix}${rowIndex}`,
166
+ // TODO: It seems that logics_set/components_set doesn't work when changing the icon name.
167
+ material: {
168
+ name: 'preview'
169
+ },
170
+ // TODO: Implement this in the frontend. Right now, this is implemented in glib-web backend.
171
+ tooltip: {
172
+ text: "Review before submitting"
173
+ }
174
+ };
175
+ },
176
+ headerCheckSpec(section) {
177
+ const sectionIndex = section.index;
178
+ const maxIndex = section.dataRows.length;
179
+ const result = {
180
+ view: 'fields/check',
181
+ name: 'check_all',
182
+ checkValue: true,
183
+ onChange: {
184
+ action: 'logics/set',
185
+ targetIds: Array(maxIndex).fill().map((_, i) => `_check_row_${sectionIndex}_${i}`),
186
+ conditionalData: {
187
+ checkValue: true,
188
+ value: { "var": "check_all" }
189
+ }
190
+ }
191
+ };
192
+ return result;
193
+ },
194
+ rowCheckSpec(sectionIndex, rowIndex) {
195
+ const id = this.rowCheckId(sectionIndex, rowIndex);
196
+ return {
197
+ view: 'fields/check',
198
+ checkValue: true,
199
+ name: `${id}-name`, // For some unknown reason, equating the id and name values ​​causes this function to be executed causing the component to be reset.
200
+ // name: id,
201
+ id: id
202
+ };
203
+ },
204
+ rowCheckId(sectionIndex, rowIndex) {
205
+ // Use underscore to prevent conflict with server-defined IDs, e.g. for pendingIconSpec()
206
+ return `_check_row_${sectionIndex}_${rowIndex}`;
207
+ },
208
+ colSpan(row, index) {
209
+ const spans = row.colSpans || [];
210
+ return spans[index] || 1;
211
+ },
212
+ colStyles(row, index) {
213
+ const colStyles = row.colStyles || [];
214
+ return this.$styles(colStyles[index] || {});
215
+ },
216
+ rows(section) {
217
+ return section.rows || [];
218
+ },
219
+ triggerImport(index) {
220
+ const input = this.$refs.fileInput[index];
221
+ input.click();
222
+ },
223
+ totalRows(section) {
224
+ return (section.rows || []).length + (section.dataRows || []).length;
225
+ }
226
+ }
227
+ };
228
+ </script>
229
+
230
+ <style lang="scss" scoped>
231
+ table {
232
+ border-spacing: 0;
233
+ }
234
+
235
+ // thead {
236
+ // th {
237
+ // padding: 10px 4px;
238
+ // border-top: 1px solid rgba(0, 0, 0, 0.12);
239
+ // // border-left: 1px solid rgba(0, 0, 0, 0.12);
240
+ // }
241
+ // }
242
+
243
+ thead {
244
+ th {
245
+ &.fixed-width {
246
+ min-width: 200px; // TODO: Make this configurable
247
+ }
248
+
249
+ &.status {
250
+ min-width: 80px;
251
+ }
252
+ }
253
+
254
+ }
255
+
256
+ tbody {
257
+ // tr.clickable {
258
+ // td>a {
259
+ // cursor: pointer;
260
+ // }
261
+
262
+ // &:hover {
263
+ // background: #eee;
264
+ // }
265
+ // }
266
+
267
+ td {
268
+ border-top: 1px solid rgba(0, 0, 0, 0.12);
269
+
270
+ span {
271
+ display: block;
272
+ color: inherit;
273
+ cursor: default;
274
+ }
275
+
276
+ &.status {
277
+ display: flex;
278
+ align-items: center;
279
+
280
+ }
281
+ }
282
+ }
283
+
284
+ .scrollable {
285
+ width: 100%;
286
+ overflow: auto;
287
+ }
288
+
289
+ .data-cell {
290
+ white-space: pre-line;
291
+ }
292
+
293
+ table.table--grid {
294
+ tbody {
295
+ td {
296
+ border-right: 1px solid rgba(0, 0, 0, 0.12);
297
+ }
298
+ }
299
+ }
300
+ </style>
@@ -0,0 +1,197 @@
1
+ <template>
2
+ <div :style="$styles()" :class="$classes()" v-if="loadIf">
3
+ <div>Rows: {{ loadedRowCount }} Selected: {{ selectedRowCount }}</div>
4
+ <input ref="fileInput" style="display: none;" type="file" accept=".csv" @change="handleClick" />
5
+ <div class="scrollable">
6
+ <table>
7
+ <thead v-if="props.spec.viewHeaders">
8
+ <tr>
9
+ <th v-if="rowLoaded"><v-checkbox v-model="checkbox" color="primary" @change="checkAll"></v-checkbox></th>
10
+ <th v-for="cell in props.spec.viewHeaders" :key="cell.id"
11
+ :style="{ minWidth: `${cell.minWidth || 100}px` }">
12
+ <span>{{ cell.text }}</span>
13
+ </th>
14
+ </tr>
15
+ </thead>
16
+
17
+ <tbody>
18
+ <template v-if="rowLoaded">
19
+ <template v-for="(section, sectionIndex) in sections" :key="`head_${sectionIndex}`">
20
+ <tr v-for="(row, rowIndex) in section.rows" :key="`row_${rowIndex}`">
21
+ <td><v-checkbox v-model="row.selected" color="primary"></v-checkbox></td>
22
+ <td v-for="(cell, cellIndex) in row.columns" :key="`cell_${cellIndex}`"
23
+ @change="(e) => handleCellChange(e, cell)">
24
+ <glib-component :spec="cell.view"></glib-component>
25
+ </td>
26
+ </tr>
27
+ </template>
28
+ </template>
29
+
30
+ <tr v-else>
31
+ <td colspan="100%" style="padding-top: 8px">
32
+ <div class="gdrop-file border-[2px]" @dragover="(e) => e.preventDefault()" @drop="handleDrop"
33
+ @click="fileInput.click()">
34
+
35
+ <div class="cloud" style="pointer-events: none;">
36
+ <v-icon ref="icon" size="48" class="icon">cloud_upload</v-icon>
37
+ <h4 class="title">Drag your CSV file here</h4>
38
+ <p class="subtitle">or click to browse</p>
39
+ </div>
40
+ </div>
41
+ </td>
42
+ </tr>
43
+ </tbody>
44
+ </table>
45
+ </div>
46
+ </div>
47
+ </template>
48
+
49
+ <style lang="scss" scoped>
50
+ table thead th {
51
+ position: sticky;
52
+ top: 0px;
53
+ z-index: 1005;
54
+ background-color: white;
55
+ }
56
+ </style>
57
+
58
+ <script setup>
59
+ import { computed, getCurrentInstance, ref, watch } from "vue";
60
+ import { parseCsv } from "../composable/parser";
61
+ import Action from "../../action";
62
+
63
+ class Cell {
64
+ constructor({ viewHeaders, cellIndex, rowId, view, value }) {
65
+ this.viewHeaders = viewHeaders;
66
+ this.cellIndex = cellIndex;
67
+ this.rowId = rowId;
68
+ this.view = view;
69
+ this.value = value;
70
+ }
71
+
72
+ get cellId() {
73
+ return this.viewHeaders[this.cellIndex].id;
74
+ }
75
+ }
76
+
77
+ function handleClick(e) {
78
+ loadFile(e.target.files);
79
+ e.target.value = '';
80
+ }
81
+
82
+ function handleDrop(e) {
83
+ e.preventDefault();
84
+ loadFile(e.dataTransfer.files);
85
+ }
86
+
87
+ function makeSection(section) {
88
+ const rows = section.dataRows.map((dataRow) => {
89
+ const selected = false;
90
+ const rowId = dataRow.rowId;
91
+ const columns = props.spec.viewColumns.map((viewColumn, viewColumnIndex) => {
92
+ const view = Object.assign({}, viewColumn, dataRow.columns[viewColumnIndex]);
93
+ const cellIndex = viewColumnIndex;
94
+ const viewHeaders = props.spec.viewHeaders;
95
+ const value = dataRow.columns[viewColumnIndex].value;
96
+ return new Cell({ rowId, view, cellIndex, viewHeaders, value });
97
+ });
98
+ return { selected, columns, rowId };
99
+ });
100
+ return Object.assign({}, section, { rows });
101
+ }
102
+
103
+ const props = defineProps(['spec']);
104
+ const fileInput = ref(null);
105
+ const checkbox = ref(false);
106
+ const instance = getCurrentInstance();
107
+
108
+ const fillableColumnIndexes = props.spec.viewHeaders.reduce((prev, curr, index) => {
109
+ if (curr.importable) prev.push(index);
110
+ return prev;
111
+ }, []);
112
+
113
+ const sections = ref(props.spec.sections.map((section) => makeSection(section)));
114
+
115
+ watch(props, (value) => {
116
+ sections.value = value.spec.sections.map((section) => makeSection(section));
117
+ });
118
+
119
+ const selectedRows = computed(() => {
120
+ const rows = [];
121
+ sections.value.forEach((section) => section.rows.filter((row) => row.selected).forEach((row) => rows.push(row)));
122
+ return rows;
123
+ });
124
+
125
+ watch(selectedRows, (value) => {
126
+ const rows = value.map((row) => {
127
+ const rowId = row.rowId;
128
+ const columns = row.columns.map((col) => ({ cellId: col.cellId, value: col.value }));
129
+
130
+ return { rowId, columns };
131
+ });
132
+ const data = Object.assign(
133
+ {},
134
+ props.spec.onRowSelected,
135
+ { [props.spec.paramNameForFormData || 'formData']: rows }
136
+ );
137
+ Action.execute(data, instance.ctx);
138
+ });
139
+
140
+ const loadedRowCount = computed(() => sections.value.reduce((prev, curr) => prev + curr.dataRows.length, 0));
141
+ const selectedRowCount = computed(() => selectedRows.value.length);
142
+ const rowLoaded = computed(() => !!(sections.value[0] && sections.value[0].rows));
143
+
144
+ function checkAll() {
145
+ sections.value.forEach((section) => section.rows.forEach((row) => row.selected = checkbox.value));
146
+ }
147
+
148
+ function handleCellChange(e, cell) {
149
+ const rowId = cell.rowId;
150
+ const columnId = cell.cellId;
151
+
152
+ let value = null;
153
+ if (e.target instanceof HTMLInputElement) {
154
+ value = e.target.value;
155
+ } else {
156
+ value = e.target.querySelector('input').value;
157
+ }
158
+
159
+ cell.value = value;
160
+
161
+ const data = Object.assign(
162
+ {},
163
+ props.spec.onCellChange,
164
+ { [props.spec.paramNameForFormData || 'formData']: { rowId, columnId, value } }
165
+ );
166
+
167
+ Action.execute(data, instance.ctx);
168
+ }
169
+
170
+ function loadFile(files) {
171
+ const reader = new FileReader();
172
+ reader.readAsText(files[0]);
173
+
174
+ reader.onload = (ev) => {
175
+ const csvData = parseCsv(ev.target.result);
176
+ const dataRows = csvData.map((columns) => {
177
+ let i = 0;
178
+ const cols = [];
179
+ props.spec.viewColumns.forEach((viewColumn, index) => {
180
+ const obj = {};
181
+ if (fillableColumnIndexes.includes(index)) {
182
+ cols.push(Object.assign(obj, viewColumn, { value: columns[i] }));
183
+ i++;
184
+ } else {
185
+ cols.push(Object.assign(obj, viewColumn));
186
+ }
187
+ });
188
+ return { rowId: null, columns: cols };
189
+ });
190
+ const newSections = makeSection({ dataRows });
191
+ sections.value.push(newSections);
192
+
193
+ Action.execute(props.spec.onLoadRows, instance.ctx);
194
+ };
195
+ }
196
+
197
+ </script>
package/index.js CHANGED
@@ -5,6 +5,8 @@ import { settings } from "./utils/settings";
5
5
  import { useTheme } from "vuetify";
6
6
  import { jsonView, vueApp } from "./store";
7
7
 
8
+ import './app.scss';
9
+
8
10
  // lib for deep merge
9
11
  import merge from 'lodash.merge';
10
12
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "glib-web",
3
- "version": "4.8.0",
3
+ "version": "4.8.2",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -24,20 +24,19 @@ export default {
24
24
 
25
25
  const id = this.viewId;
26
26
 
27
- // const id = spec.id;
28
- // const existingComponent = GLib.component.findById(id, false);
27
+ const existingComponent = GLib.component.findById(id);
29
28
  // A component with the same ID in a different page shouldn't be considered a
30
29
  // duplicate. See `utils/components#deregister` for more details.
31
- // if (existingComponent) {
32
- // console.warn(
33
- // "Duplicate component ID:",
34
- // id,
35
- // "Existing:",
36
- // GLib.component.vueName(existingComponent),
37
- // "New:",
38
- // GLib.component.vueName(this)
39
- // );
40
- // }
30
+ if (existingComponent) {
31
+ console.warn(
32
+ "Duplicate component ID:",
33
+ id,
34
+ "Existing:",
35
+ GLib.component.vueName(existingComponent),
36
+ "New:",
37
+ GLib.component.vueName(this)
38
+ );
39
+ }
41
40
  const newComponent = this;
42
41
  GLib.component.register(id, newComponent);
43
42
  }
@@ -3,9 +3,12 @@ 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 { jsonView } from "../store";
6
7
  // import * as directives from 'vuetify/directives'
8
+ // import { md3 } from 'vuetify/blueprints';
7
9
 
8
10
  const opts = {
11
+ // blueprint: md3,
9
12
  components,
10
13
  // directives,
11
14
  icons: {
@@ -15,9 +18,7 @@ const opts = {
15
18
  md,
16
19
  },
17
20
  },
18
- theme: {
19
- themes: {}
20
- },
21
+ theme: jsonView.page.theme || {},
21
22
  display: {
22
23
  smAndDown: true
23
24
  }
package/utils/type.js CHANGED
@@ -66,4 +66,7 @@ export default class {
66
66
  const val = parseFloat(obj);
67
67
  return isNaN(val) ? null : val;
68
68
  }
69
+ static string(obj) {
70
+ return this.isString(obj) ? obj : null
71
+ }
69
72
  }