glib-web 5.0.7 → 6.0.0

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.
Files changed (56) hide show
  1. package/.claude/commands/gen-cypress-test.md +101 -0
  2. package/.github/dependabot.yml +24 -12
  3. package/.nycrc.json +4 -0
  4. package/AGENTS.md +1 -0
  5. package/README.md +4 -0
  6. package/actions/logics/set.js +5 -0
  7. package/actions/panels/scrollTo.js +2 -2
  8. package/actions/timeouts/set.js +1 -1
  9. package/app.vue +1 -1
  10. package/components/charts/series.js +2 -2
  11. package/components/composable/gmap.js +1 -1
  12. package/components/composable/upload.js +1 -4
  13. package/components/composable/upload_nothing.js +1 -1
  14. package/components/fields/_buttonDate.vue +2 -2
  15. package/components/fields/_patternText.vue +2 -8
  16. package/components/fields/_select.vue +10 -8
  17. package/components/fields/_selectItemDefault.vue +1 -3
  18. package/components/fields/_selectItemWithIcon.vue +1 -3
  19. package/components/fields/_selectItemWithImage.vue +1 -3
  20. package/components/fields/clipboardUpload.vue +115 -0
  21. package/components/fields/dynamicGroup2.vue +1 -1
  22. package/components/fields/multiUpload.vue +12 -19
  23. package/components/fields/placeholderUpload.vue +8 -4
  24. package/components/fields/radioGroup.vue +0 -7
  25. package/components/fields/sign.vue +1 -1
  26. package/components/fields/text.vue +1 -9
  27. package/components/fields/upload.vue +4 -0
  28. package/components/mixins/longClick.js +1 -1
  29. package/components/panels/bulkEdit2.vue +1 -1
  30. package/components/panels/list.vue +1 -1
  31. package/components/popover.vue +1 -1
  32. package/components/responsive.vue +1 -1
  33. package/cypress/e2e/glib-web/dialog.cy.js +0 -1
  34. package/cypress/e2e/glib-web/dirtyState.cy.js +37 -37
  35. package/cypress/e2e/glib-web/fieldsDateTime.cy.js +46 -3
  36. package/cypress/e2e/glib-web/fieldsRadio.cy.js +65 -0
  37. package/cypress/e2e/glib-web/fieldsSelect.cy.js +116 -76
  38. package/cypress/e2e/glib-web/fieldsText.cy.js +83 -0
  39. package/cypress/e2e/glib-web/fieldsUpload.cy.js +230 -120
  40. package/cypress/e2e/glib-web/image.cy.js +62 -33
  41. package/cypress/e2e/glib-web/switch.cy.js +13 -0
  42. package/cypress/e2e/glib-web/tabBar.cy.js +23 -0
  43. package/cypress/fixtures/document.pdf +12 -0
  44. package/cypress/fixtures/large.png +0 -0
  45. package/cypress/fixtures/upload.png +0 -0
  46. package/cypress/helper.js +13 -1
  47. package/cypress/support/component.js +1 -1
  48. package/cypress/support/e2e.js +20 -13
  49. package/doc/dependabot.md +22 -0
  50. package/eslint-rules/index.js +6 -6
  51. package/nav/dialog.vue +1 -1
  52. package/package.json +18 -16
  53. package/templates/_menu.vue +2 -2
  54. package/cypress/component/inputUpload.cy.js +0 -103
  55. package/cypress/component/multiUpload.cy.js +0 -107
  56. package/cypress/component/placeholderUpload.cy.js +0 -91
@@ -0,0 +1,101 @@
1
+ Generate a Cypress e2e test for a glib test page by visiting it with Playwright, observing its interactive elements, and writing a test file following project conventions.
2
+
3
+ ## Arguments
4
+
5
+ `$ARGUMENTS` — either a test page slug (e.g. `file_upload`) or a full URL.
6
+
7
+ ## Steps
8
+
9
+ ### 1. Resolve the URL
10
+
11
+ If `$ARGUMENTS` looks like a full URL (starts with `http`), use it as-is.
12
+ Otherwise treat it as a test page slug and build the URL:
13
+ ```
14
+ http://localhost:3000/glib/json_ui_garage?path=test_page%2F<slug>
15
+ ```
16
+
17
+ ### 2. Visit the page with Playwright and observe it
18
+
19
+ Use the Playwright MCP browser tools in this order:
20
+
21
+ 1. **Navigate** to the resolved URL.
22
+ 2. **Take a screenshot** to see the initial page state.
23
+ 3. **Snapshot** the accessibility tree to discover all interactive elements (buttons, links, inputs, selects, checkboxes).
24
+ 4. Ignore the left-side navigation menu entirely — do not interact with or assert on it.
25
+ 5. For every interactive element in the main page content area, interact with it one at a time (revisiting the page between interactions to reset state). Cover all element types:
26
+ - **Buttons** — click each one.
27
+ - **Text inputs / textareas** — type a representative value, then trigger any associated action (e.g. submit button).
28
+ - **File inputs** — note the accepted file types from the input's `accept` attribute or surrounding labels, then trigger any associated action.
29
+ - **Selects / dropdowns** — choose a non-default option.
30
+ - **Checkboxes / radio buttons / switches** — toggle them.
31
+ - **Other clickable elements** — chips, tabs, icon buttons, etc.
32
+ After each interaction:
33
+ - Take a screenshot.
34
+ - Snapshot the DOM to detect what changed: text content, new elements appearing, visibility changes, URL changes. Note the actual rendered text and HTML structure rather than component class names.
35
+ 6. If an interaction opens a dialog/sheet/overlay, interact with all elements nested inside it and record those interactions too.
36
+ 7. After observing all interactions, **close the browser**.
37
+
38
+ ### 3. Derive the output file path
39
+
40
+ - If given a slug, the filename is the slug converted from `snake_case` to `camelCase` + `.cy.js`.
41
+ - Example: `file_upload_new` → `fileUploadNew.cy.js`
42
+ - If given a full URL, extract a meaningful slug from the `path=` query param or the last path segment.
43
+ - Output path: `cypress/e2e/glib-web/<filename>`
44
+
45
+ ### 4. Check for an existing test file
46
+
47
+ Before generating anything, check if `cypress/e2e/glib-web/<filename>` already exists.
48
+
49
+ - **If it does not exist** — proceed to step 5 in "new file" mode.
50
+ - **If it exists** — read it, identify the scenarios already covered by existing `it` blocks, then ask the user:
51
+ > "Found existing `<filename>`. Overwrite it entirely, or append only the new scenarios not yet covered?"
52
+ Wait for the user's answer before continuing.
53
+
54
+ ### 5. Generate the Cypress test
55
+
56
+ Write only the content appropriate for the chosen mode.
57
+
58
+ **New file** — produce a complete file:
59
+
60
+ ```js
61
+ import { testPageUrl } from "../../helper.js"
62
+
63
+ const url = testPageUrl('<slug>')
64
+ // or: const url = '<full_url>'
65
+
66
+ describe('<slug or page name>', () => {
67
+ // one it block per logical scenario
68
+ })
69
+ ```
70
+
71
+ **Append** — produce only the new `it` blocks (no imports, no `describe` wrapper). Skip any scenario whose description already appears in the existing file.
72
+
73
+ Rules for both modes:
74
+
75
+ - One `it` block per logical scenario (one per button, or a small group of related interactions).
76
+ - Each `it` block starts with `cy.visit(url)`.
77
+ - Prefer `cy.contains('BUTTON_TEXT')` over `cy.contains('button', 'BUTTON_TEXT')`.
78
+ - Assert from rendered HTML whenever possible — prefer what the user actually sees over component class names:
79
+ - Dialog/overlay opened → `cy.contains('DIALOG_TITLE_TEXT').should('be.visible')` (assert on visible text inside the dialog, not `.v-dialog`)
80
+ - Snackbar/toast appeared → `cy.contains('MESSAGE_TEXT').should('be.visible')` (assert on the message text, not `.v-snackbar`)
81
+ - Text updated → `cy.contains('new text').should('be.visible')` or `cy.get('#element_id').should('have.text', 'new text')`
82
+ - Element hidden/shown → `.should('not.be.visible')` / `.should('be.visible')`
83
+ - URL changed → `cy.location('pathname').should('eq', '/...')`
84
+ - No observable change → `cy.contains('BUTTON_TEXT').should('exist')` (smoke)
85
+ - Fall back to component class selectors (`.v-dialog`, `.v-snackbar`, etc.) only when no stable rendered text or semantic HTML attribute is available.
86
+ - For file upload inputs, use `cy.fixture` and `.selectFile`:
87
+ - Check `cypress/fixtures/` for an existing file that matches the accepted type (e.g. `example.jpg`, `example.pdf`).
88
+ - If a suitable fixture exists, use it: `cy.get('input[type="file"]').selectFile('cypress/fixtures/<file>')`
89
+ - If no suitable fixture exists, create a minimal one in `cypress/fixtures/` (e.g. a small `.jpg`, `.png`, or `.pdf`) and use that.
90
+ - Never hardcode absolute paths — always use the `cypress/fixtures/` relative path form.
91
+ - ASCII only — no special characters in strings.
92
+ - Match the style of existing tests in `cypress/e2e/glib-web/`.
93
+
94
+ ### 6. Write the file and report
95
+
96
+ - **New file**: write the full generated content to `cypress/e2e/glib-web/<filename>`.
97
+ - **Append**: insert the new `it` blocks inside the existing `describe` block, before its closing `}`.
98
+
99
+ After writing, report:
100
+ - The file path.
101
+ - A bullet list of every `it` block written, each with a one-line note on what it asserts (e.g. `"opens dialog with title X"`, `"shows snackbar 'Saved'"`).
@@ -5,19 +5,31 @@ updates:
5
5
  schedule:
6
6
  interval: weekly
7
7
  day: monday
8
- open-pull-requests-limit: 5
8
+ time: "09:00"
9
+ timezone: "Asia/Jakarta"
10
+ open-pull-requests-limit: 0
11
+ labels:
12
+ - "dependencies"
13
+ - "automated"
14
+ assignees:
15
+ - "ikhwanh"
16
+ - "hgani"
9
17
  ignore:
10
18
  # Major version bumps on framework deps need manual migration; review separately.
11
19
  - dependency-name: vite
12
- update-types: ["version-update:semver-major"]
20
+ update-types: [ "version-update:semver-major" ]
13
21
  - dependency-name: vue
14
- update-types: ["version-update:semver-major"]
22
+ update-types: [ "version-update:semver-major" ]
15
23
  - dependency-name: vuetify
16
- update-types: ["version-update:semver-major"]
17
- - dependency-name: eslint
18
- update-types: ["version-update:semver-major"]
19
- - dependency-name: cypress
20
- update-types: ["version-update:semver-major"]
24
+ update-types: [ "version-update:semver-major" ]
25
+ - dependency-name: chart.js
26
+ update-types: [ "version-update:semver-major" ]
27
+ - dependency-name: lodash
28
+ update-types: [ "version-update:semver-major" ]
29
+ - dependency-name: lodash-es
30
+ update-types: [ "version-update:semver-major" ]
31
+ - dependency-name: v-phone-input
32
+ update-types: [ "version-update:semver-major" ]
21
33
  groups:
22
34
  vue:
23
35
  applies-to: version-updates
@@ -25,22 +37,20 @@ updates:
25
37
  - vue
26
38
  - vuetify
27
39
  - vuedraggable
28
- - vue-chartkick
29
40
  - vue-social-sharing
30
41
  - v-phone-input
31
42
  - "@vitejs/plugin-vue"
32
43
  - eslint-plugin-vue
33
- chart:
34
- applies-to: version-updates
35
- patterns:
36
44
  - chart.js
37
45
  - chartjs-*
46
+ - vue-chartkick
38
47
  - "@types/chart.js"
39
48
  cypress:
40
49
  applies-to: version-updates
41
50
  patterns:
42
51
  - cypress
43
52
  - "@cypress/*"
53
+ - vite-plugin-istanbul
44
54
  eslint:
45
55
  applies-to: version-updates
46
56
  patterns:
@@ -53,6 +63,8 @@ updates:
53
63
  patterns:
54
64
  - vite
55
65
  - vite-plugin-*
66
+ exclude-patterns:
67
+ - vite-plugin-istanbul
56
68
  lodash:
57
69
  applies-to: version-updates
58
70
  patterns:
package/.nycrc.json CHANGED
@@ -25,6 +25,10 @@
25
25
  "templates/**",
26
26
  "utils/**"
27
27
  ],
28
+ "branches": 55,
29
+ "functions": 63,
30
+ "lines": 65,
31
+ "statements": 64,
28
32
  "exclude": [
29
33
  "actions/cables/*",
30
34
  "actions/analytics/*",
package/AGENTS.md CHANGED
@@ -22,6 +22,7 @@
22
22
  - Read [behavior.md](doc/common/ai/behavior.md)
23
23
 
24
24
  ## Rules
25
+ * Always use `yarn` instead of `npm` for package management and running scripts.
25
26
  * Always use `utils/type.js` to check types
26
27
  - For example, avoid checking against `undefined` manually.
27
28
 
package/README.md CHANGED
@@ -89,6 +89,10 @@ view.chipGroup styleClasses: ['custom']
89
89
  - `bin/vite clobber`
90
90
  - `bin/vite dev`
91
91
 
92
+ ## Handling Dependabot Dependency Updates
93
+
94
+ See [doc/dependabot.md](doc/dependabot.md).
95
+
92
96
  ## Prepare for publishing
93
97
 
94
98
  - Create a PR to the `master` branch
@@ -82,6 +82,11 @@ const isPresentOperator = function (value) {
82
82
  };
83
83
  jsonLogic.add_operation("isPresent", isPresentOperator);
84
84
 
85
+ const strlen = function (value) {
86
+ return value ? String(value).length : 0;
87
+ };
88
+ jsonLogic.add_operation("strlen", strlen);
89
+
85
90
  function getFormData() {
86
91
  return Array.from(document.querySelectorAll('form')).reduce((prev, curr) => {
87
92
  const obj = _getFormData(curr);
@@ -31,12 +31,12 @@ export default class {
31
31
  scrollIfNeeded(element, spec, component, alignment) {
32
32
  const vm = this;
33
33
  const observerOptions = {
34
- threshold: 1 // Trigger callback when 100% of the element becomes/unbecomes visible
34
+ threshold: 0.99 // Trigger callback when element is effectively fully visible (1.0 can miss due to sub-pixel rendering)
35
35
  };
36
36
  new IntersectionObserver(function([entry]) {
37
37
  const ratio = entry.intersectionRatio;
38
38
 
39
- if (ratio < 1) {
39
+ if (ratio < 0.99) {
40
40
  // const place = ratio <= 0 && centerIfNeeded ? "center" : "nearest";
41
41
  const place = ratio <= 0 ? alignment : "nearest";
42
42
  element.scrollIntoView({
@@ -14,7 +14,7 @@ export default class {
14
14
  delete timerIds[timerId];
15
15
  }
16
16
 
17
- let id = null;
17
+ let id;
18
18
  if (repeat) {
19
19
  id = setInterval(() => {
20
20
  this.onTimeout(properties, component);
package/app.vue CHANGED
@@ -70,7 +70,6 @@ import { htmlElement } from "./components/helper";
70
70
  import { nextTick } from "vue";
71
71
 
72
72
  export default {
73
- expose: ['updateMainHeight'],
74
73
  components: {
75
74
  "nav-layout": NavLayout,
76
75
  "panels-form": FormPanel,
@@ -79,6 +78,7 @@ export default {
79
78
  props: {
80
79
  page: { type: Object, required: true },
81
80
  },
81
+ expose: ['updateMainHeight'],
82
82
  setup(props) {
83
83
  const filePaster = computed(() => props.page.filePaster);
84
84
  usePasteable(filePaster);
@@ -32,7 +32,7 @@ const installChartkick = () => {
32
32
 
33
33
  const multipleDataSeries = (dataSeries) => {
34
34
  return dataSeries.map((value) => {
35
- let points = null;
35
+ let points;
36
36
  if (TypeUtils.isArray(value.points)) {
37
37
  points = value.points.reduce((prev, curr) => {
38
38
  return Object.assign(prev, { [curr.x]: curr.y });
@@ -136,7 +136,7 @@ function useChart({ dataSeries, spec, multiple = true }) {
136
136
  const { datalabels, centerLabel, customTooltip } = spec.plugins || {};
137
137
  const legend = spec.legend || { display: true };
138
138
 
139
- let series = null;
139
+ let series;
140
140
  if (multiple) {
141
141
  series = computed(() => multipleDataSeries(normalizedDataSeries));
142
142
  } else {
@@ -17,7 +17,7 @@ export function useAutocomplete({ inputRef, options, onPlaceChanged }) {
17
17
  onMounted(async () => {
18
18
  await waitForGmapLoaded();
19
19
 
20
- let inputElement = null;
20
+ let inputElement;
21
21
  if (inputRef.value instanceof HTMLElement) {
22
22
  inputElement = inputRef.value;
23
23
  } else {
@@ -33,12 +33,9 @@ function setBusyWhenUploading({ files }) {
33
33
  }
34
34
 
35
35
  function uploadFiles({ droppedFiles, files, spec, container, onAfterUploaded }) {
36
- let { responseMessages } = spec;
37
- responseMessages ||= {};
38
-
39
36
  if (!validateFiles({ newFiles: droppedFiles, spec, files })) return;
40
37
 
41
- let key = '';
38
+ let key;
42
39
  for (let index = 0; index < droppedFiles.length; index++) {
43
40
  // show new dropped file and track progress
44
41
  key = makeKey();
@@ -6,7 +6,7 @@ function uploadFiles({ droppedFiles, files, spec, onAfterUploaded }) {
6
6
 
7
7
  if (!validateFiles({ newFiles: droppedFiles, files, spec })) return;
8
8
 
9
- let key = '';
9
+ let key;
10
10
  files.value = {};
11
11
  for (let index = 0; index < droppedFiles.length; index++) {
12
12
  key = makeKey();
@@ -104,6 +104,7 @@ export default {
104
104
  return;
105
105
  }
106
106
 
107
+ this.dateInput.focus();
107
108
  this.dateInput.showPicker();
108
109
  }
109
110
  }
@@ -143,8 +144,7 @@ export default {
143
144
  width: 100%;
144
145
  height: 100%;
145
146
  opacity: 0;
146
- cursor: pointer;
147
- z-index: 2;
147
+ pointer-events: none;
148
148
  }
149
149
 
150
150
  .calendar-icon {
@@ -6,8 +6,8 @@
6
6
  <template v-else>
7
7
  <v-text-field ref="field" :color="gcolor" :label="spec.label" :hint="spec.hint" :readonly="spec.readOnly"
8
8
  :disabled="inputDisabled" :style="$styles()" :density="$classes().includes('compact') ? 'compact' : 'default'"
9
- :clearable="spec.clearable" :variant="variant" validate-on="blur" persistent-placeholder :placeholder="spec.placeholder"
10
- :value="format" @change="onChange" @click="openPicker" />
9
+ :clearable="spec.clearable" :variant="variant" validate-on="blur" persistent-placeholder
10
+ :placeholder="spec.placeholder" :value="format" @change="onChange" @click="openPicker" />
11
11
  <input ref="nativePicker" v-model="fieldModel" class="native-picker" :type="type" :name="fieldName"
12
12
  :min="sanitizeValue(spec.min)" :max="sanitizeValue(spec.max)" :value="sanitizeValue(fieldModel)"
13
13
  @change="handleNativeChange" />
@@ -41,12 +41,6 @@ export default {
41
41
  return { adapter, variant };
42
42
  },
43
43
  computed: {
44
- isDateType() {
45
- return this.type === 'date';
46
- },
47
- isDateTimeType() {
48
- return this.type === 'datetime-local';
49
- },
50
44
  format() {
51
45
  if (!isPresent(this.fieldModel)) return;
52
46
  if (!isPresent(this.spec.format)) {
@@ -27,10 +27,8 @@
27
27
  <template v-if="spec.prependSelectAll">
28
28
  <v-list-item title="Select All" @click="checkAll">
29
29
  <template #prepend>
30
- <v-list-item-action start>
31
- <v-checkbox-btn color="primary" :indeterminate="isIndeterminate"
32
- :model-value="isAllSelected"></v-checkbox-btn>
33
- </v-list-item-action>
30
+ <v-checkbox-btn color="primary" :indeterminate="isIndeterminate"
31
+ :model-value="isAllSelected"></v-checkbox-btn>
34
32
  </template>
35
33
  </v-list-item>
36
34
  <v-divider class="mt-2" :opacity="20"></v-divider>
@@ -69,6 +67,10 @@
69
67
 
70
68
  <input v-for="(item, index) in values" :key="index" type="hidden" :disabled="inputDisabled" :name="fieldName"
71
69
  :value="item" />
70
+
71
+ <select hidden aria-hidden="true">
72
+ <option v-for="(opt, i) in selectableOptions" :key="i" :value="opt.value">{{ opt.text }}</option>
73
+ </select>
72
74
  </div>
73
75
  </template>
74
76
 
@@ -364,6 +366,9 @@ export default {
364
366
  emptyValue() {
365
367
  return [null];
366
368
  },
369
+ selectableOptions() {
370
+ return this.normalizedOptions.filter(o => !o.divider && o.type !== 'view');
371
+ },
367
372
  density() {
368
373
  return determineDensity(this.spec.styleClasses);
369
374
  },
@@ -391,10 +396,7 @@ export default {
391
396
  // This can happen when spec.useChips is explicitly set to true while spec.multiple is false.
392
397
  // In this scenario, fieldModel is a single value (not an array), so we need to handle both cases.
393
398
  if (isArray(this.fieldModel)) {
394
- const index = this.fieldModel.indexOf(item.value);
395
- if (index >= 0) {
396
- this.fieldModel.splice(index, 1);
397
- }
399
+ this.fieldModel = this.fieldModel.filter(v => v !== item.value);
398
400
  } else {
399
401
  // Single select mode - clear the value
400
402
  this.fieldModel = null;
@@ -1,9 +1,7 @@
1
1
  <template>
2
2
  <v-list-item v-bind="props.context" :disabled="props.item.raw.disabled">
3
3
  <template #prepend="{ isActive }">
4
- <v-list-item-action v-if="props.spec.multiple" start>
5
- <v-checkbox-btn :model-value="isActive"></v-checkbox-btn>
6
- </v-list-item-action>
4
+ <v-checkbox-btn v-if="props.spec.multiple" :model-value="isActive"></v-checkbox-btn>
7
5
  </template>
8
6
  </v-list-item>
9
7
  </template>
@@ -1,9 +1,7 @@
1
1
  <template>
2
2
  <v-list-item v-bind="props.context" :disabled="props.item.raw.disabled">
3
3
  <template #prepend="{ isActive }">
4
- <v-list-item-action v-if="props.spec.multiple">
5
- <v-checkbox-btn :model-value="isActive"></v-checkbox-btn>
6
- </v-list-item-action>
4
+ <v-checkbox-btn v-if="props.spec.multiple" :model-value="isActive"></v-checkbox-btn>
7
5
  <glib-component :spec="itemIconSpec(props.item.raw.icon)" />
8
6
  </template>
9
7
  </v-list-item>
@@ -6,9 +6,7 @@
6
6
  </v-avatar>
7
7
  </template>
8
8
  <template #append="{ isActive }">
9
- <v-list-item-action v-if="props.spec.multiple" start>
10
- <v-checkbox-btn :model-value="isActive"></v-checkbox-btn>
11
- </v-list-item-action>
9
+ <v-checkbox-btn v-if="props.spec.multiple" :model-value="isActive"></v-checkbox-btn>
12
10
  </template>
13
11
  </v-list-item>
14
12
  </template>
@@ -0,0 +1,115 @@
1
+ <template>
2
+ <div ref="container" class="clipboard-upload">
3
+ <v-text-field :label="props.spec.label" :placeholder="props.spec.placeholder" :variant="props.spec.variant"
4
+ :loading="uploading" :model-value="fileName" readonly>
5
+ <template #append-inner>
6
+ <v-icon v-if="fileName" :disabled="uploading" style="cursor: pointer; pointer-events: auto" icon="cancel"
7
+ size="small" @click.stop="reset"></v-icon>
8
+ </template>
9
+ <template #append>
10
+ <v-icon :disabled="uploading" style="cursor: pointer; pointer-events: auto" icon="content_paste"
11
+ @click.stop="handlePasteClick"></v-icon>
12
+ </template>
13
+ </v-text-field>
14
+
15
+ <template v-if="props.spec.directUploadUrl">
16
+ <input v-for="(file, index) in Object.values(files)" :key="`hidden-${index}`" type="hidden" :name="fieldName"
17
+ :value="file.signedId">
18
+ </template>
19
+ <input v-if="!props.spec.directUploadUrl && fileName" type="hidden" :name="fieldName" :value="fileName">
20
+ </div>
21
+ </template>
22
+
23
+ <script>
24
+ import GlibBase from "../base/glibBase.js";
25
+
26
+ export default {
27
+ extends: GlibBase
28
+ };
29
+ </script>
30
+
31
+ <script setup>
32
+ import { computed, getCurrentInstance, nextTick, ref, watch } from "vue";
33
+ import { useUploader } from "../composable/uploader";
34
+ import { useFileUtils, useFilesState } from "../composable/file";
35
+ import Action from "../../action";
36
+
37
+ const props = defineProps(['spec']);
38
+ const container = ref(null);
39
+ const uploader = useUploader(props.spec);
40
+ const instance = getCurrentInstance();
41
+ const files = ref({});
42
+ const { makeKey, Item } = useFileUtils();
43
+ const { uploading, uploaded } = useFilesState(files);
44
+
45
+ const fileName = computed(() => {
46
+ const items = Object.values(files.value);
47
+ if (items.length === 0) return '';
48
+ return items.map(f => f.name).filter(Boolean).join(', ');
49
+ });
50
+
51
+ files.value = (props.spec.files || []).reduce((prev, curr) => {
52
+ const key = makeKey();
53
+ prev[key] = new Item({
54
+ status: null,
55
+ url: curr.url,
56
+ name: curr.name,
57
+ signedId: curr.signed_id || curr.signedId || props.spec.value,
58
+ type: curr.type,
59
+ el: null
60
+ });
61
+ return prev;
62
+ }, {});
63
+
64
+ function uploadFileList(fileList) {
65
+ if (!fileList || fileList.length === 0) return;
66
+ const spec = props.spec;
67
+ const onAfterUploaded = () => {
68
+ Action.execute(spec.onFinishUpload, instance.ctx);
69
+ };
70
+ uploader.uploadFiles({ container, files, spec, onAfterUploaded, droppedFiles: fileList });
71
+ }
72
+
73
+ async function handlePasteClick() {
74
+ try {
75
+ const clipboardItems = await navigator.clipboard.read();
76
+ const fileItems = [];
77
+
78
+ for (const item of clipboardItems) {
79
+ for (const type of item.types) {
80
+ if (type.startsWith('image/')) {
81
+ const blob = await item.getType(type);
82
+ const ext = type.split('/')[1];
83
+ fileItems.push(new File([blob], `clipboard.${ext}`, { type }));
84
+ }
85
+ }
86
+ }
87
+
88
+ if (fileItems.length === 0) return;
89
+
90
+ const dt = new DataTransfer();
91
+ fileItems.forEach(f => dt.items.add(f));
92
+ uploadFileList(dt.files);
93
+ } catch {
94
+ // clipboard access denied or nothing to paste
95
+ }
96
+ }
97
+
98
+ if (props.spec.onFinishUpload) {
99
+ watch(uploaded, (val) => {
100
+ if (val) {
101
+ nextTick(() => Action.execute(props.spec.onFinishUpload, instance.ctx));
102
+ }
103
+ });
104
+ }
105
+
106
+ function trigger() {
107
+ handlePasteClick();
108
+ }
109
+
110
+ function reset() {
111
+ files.value = {};
112
+ }
113
+
114
+ defineExpose({ trigger, reset });
115
+ </script>
@@ -57,7 +57,7 @@ export default defineComponent({
57
57
 
58
58
  function prefixFieldName(index, fieldName) {
59
59
  if (fieldName.match(/\[|\]/g)) {
60
- let suffix = '';
60
+ let suffix;
61
61
  const indexOfFirstBracket = fieldName.indexOf('[');
62
62
  if (indexOfFirstBracket > 0) {
63
63
  suffix = '[' + fieldName.slice(0, indexOfFirstBracket) + ']' + fieldName.slice(indexOfFirstBracket, fieldName.length);
@@ -2,8 +2,8 @@
2
2
  <div v-if="loadIf" :style="$styles()" :class="$classes()">
3
3
  <div ref="container" class="gdrop-file border-[2px]" @click="handleClick" @drop="handleDrop" @dragover="handleDragOver"
4
4
  @dragleave="handleDragLeave">
5
- <input ref="fileSelect" :name="props.spec.directUploadUrl ? '' : fieldName" type="file" multiple
6
- style="display: none">
5
+ <input ref="fileSelect" :name="props.spec.directUploadUrl ? '' : fieldName" type="file"
6
+ :multiple="isMultiple" style="display: none">
7
7
 
8
8
  <div class="cloud" style="pointer-events: none;">
9
9
  <VIcon ref="icon" size="48" class="icon">cloud_upload</VIcon>
@@ -87,6 +87,8 @@ export default defineComponent({
87
87
 
88
88
  const uploadTitle = props.spec.uploadTitle || 'File added';
89
89
 
90
+ const isMultiple = computed(() => props.spec.multiple ?? true);
91
+
90
92
  const files = ref({});
91
93
 
92
94
  function initFiles(fls) {
@@ -138,14 +140,9 @@ export default defineComponent({
138
140
 
139
141
  function handleDrop(e) {
140
142
  e.preventDefault();
141
- uploader.uploadFiles(
142
- {
143
- droppedFiles: e.dataTransfer.files,
144
- files: files,
145
- spec: props.spec,
146
- container: container
147
- }
148
- );
143
+ const droppedFiles = isMultiple.value ? e.dataTransfer.files : [e.dataTransfer.files[0]].filter(Boolean);
144
+ if (!isMultiple.value) files.value = {};
145
+ uploader.uploadFiles({ droppedFiles, files, spec: props.spec, container });
149
146
 
150
147
  e.currentTarget.classList.remove('border-[4px]');
151
148
  container.value.classList.add('border-[2px]');
@@ -182,16 +179,11 @@ export default defineComponent({
182
179
  }
183
180
  }
184
181
 
185
- function handleClick(e) {
182
+ function handleClick() {
186
183
  const onchange = () => {
187
- uploader.uploadFiles(
188
- {
189
- droppedFiles: e.target.files,
190
- files: files,
191
- spec: props.spec,
192
- container: container
193
- }
194
- );
184
+ const selectedFiles = isMultiple.value ? fileSelect.value.files : [fileSelect.value.files[0]].filter(Boolean);
185
+ if (!isMultiple.value) files.value = {};
186
+ uploader.uploadFiles({ droppedFiles: selectedFiles, files, spec: props.spec, container });
195
187
  };
196
188
 
197
189
  fileSelect.value.onchange = onchange;
@@ -201,6 +193,7 @@ export default defineComponent({
201
193
  useGlibInput({ props, cacheValue: false });
202
194
 
203
195
  return {
196
+ isMultiple,
204
197
  files,
205
198
  fileSelect,
206
199
  container,