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.
- package/.claude/commands/gen-cypress-test.md +101 -0
- package/.github/dependabot.yml +24 -12
- package/.nycrc.json +4 -0
- package/AGENTS.md +1 -0
- package/README.md +4 -0
- package/actions/logics/set.js +5 -0
- package/actions/panels/scrollTo.js +2 -2
- package/actions/timeouts/set.js +1 -1
- package/app.vue +1 -1
- package/components/charts/series.js +2 -2
- package/components/composable/gmap.js +1 -1
- package/components/composable/upload.js +1 -4
- package/components/composable/upload_nothing.js +1 -1
- package/components/fields/_buttonDate.vue +2 -2
- package/components/fields/_patternText.vue +2 -8
- package/components/fields/_select.vue +10 -8
- package/components/fields/_selectItemDefault.vue +1 -3
- package/components/fields/_selectItemWithIcon.vue +1 -3
- package/components/fields/_selectItemWithImage.vue +1 -3
- package/components/fields/clipboardUpload.vue +115 -0
- package/components/fields/dynamicGroup2.vue +1 -1
- package/components/fields/multiUpload.vue +12 -19
- package/components/fields/placeholderUpload.vue +8 -4
- package/components/fields/radioGroup.vue +0 -7
- package/components/fields/sign.vue +1 -1
- package/components/fields/text.vue +1 -9
- package/components/fields/upload.vue +4 -0
- package/components/mixins/longClick.js +1 -1
- package/components/panels/bulkEdit2.vue +1 -1
- package/components/panels/list.vue +1 -1
- package/components/popover.vue +1 -1
- package/components/responsive.vue +1 -1
- package/cypress/e2e/glib-web/dialog.cy.js +0 -1
- package/cypress/e2e/glib-web/dirtyState.cy.js +37 -37
- package/cypress/e2e/glib-web/fieldsDateTime.cy.js +46 -3
- package/cypress/e2e/glib-web/fieldsRadio.cy.js +65 -0
- package/cypress/e2e/glib-web/fieldsSelect.cy.js +116 -76
- package/cypress/e2e/glib-web/fieldsText.cy.js +83 -0
- package/cypress/e2e/glib-web/fieldsUpload.cy.js +230 -120
- package/cypress/e2e/glib-web/image.cy.js +62 -33
- package/cypress/e2e/glib-web/switch.cy.js +13 -0
- package/cypress/e2e/glib-web/tabBar.cy.js +23 -0
- package/cypress/fixtures/document.pdf +12 -0
- package/cypress/fixtures/large.png +0 -0
- package/cypress/fixtures/upload.png +0 -0
- package/cypress/helper.js +13 -1
- package/cypress/support/component.js +1 -1
- package/cypress/support/e2e.js +20 -13
- package/doc/dependabot.md +22 -0
- package/eslint-rules/index.js +6 -6
- package/nav/dialog.vue +1 -1
- package/package.json +18 -16
- package/templates/_menu.vue +2 -2
- package/cypress/component/inputUpload.cy.js +0 -103
- package/cypress/component/multiUpload.cy.js +0 -107
- 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'"`).
|
package/.github/dependabot.yml
CHANGED
|
@@ -5,19 +5,31 @@ updates:
|
|
|
5
5
|
schedule:
|
|
6
6
|
interval: weekly
|
|
7
7
|
day: monday
|
|
8
|
-
|
|
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:
|
|
18
|
-
update-types: ["version-update:semver-major"]
|
|
19
|
-
- dependency-name:
|
|
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
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
|
package/actions/logics/set.js
CHANGED
|
@@ -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:
|
|
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 <
|
|
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({
|
package/actions/timeouts/set.js
CHANGED
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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-
|
|
31
|
-
|
|
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
|
-
|
|
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-
|
|
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-
|
|
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-
|
|
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"
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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(
|
|
182
|
+
function handleClick() {
|
|
186
183
|
const onchange = () => {
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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,
|