glib-web 4.26.1 → 4.27.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.
- package/README.md +22 -0
- package/app.vue +6 -6
- package/components/component.vue +2 -1
- package/components/composable/form.js +2 -2
- package/components/composable/upload.js +2 -2
- package/components/fields/_buttonDate.vue +8 -2
- package/components/fields/dynamicGroup2.vue +10 -2
- package/components/fields/multiUpload.vue +1 -1
- package/components/fields/rawText.vue +11 -0
- package/components/fields/richText.vue +42 -59
- package/components/fields/richText2.vue +221 -0
- package/components/progressCircle.vue +17 -2
- package/cypress/e2e/glib-web/formDynamic.cy.ts +3 -3
- package/cypress/e2e/glib-web/lifecycle.cy.ts +3 -0
- package/cypress.yml.example +56 -0
- package/package.json +3 -3
- package/utils/format.js +21 -0
package/README.md
CHANGED
|
@@ -7,6 +7,28 @@
|
|
|
7
7
|
- `CTRL+SHIFT+P` -> `Preferences: Open User Settings (JSON)` -- This will open an editor for `settings.json`.
|
|
8
8
|
- Copy content of `settings.json.example` into the editor.
|
|
9
9
|
|
|
10
|
+
## Set up Javascript CI
|
|
11
|
+
|
|
12
|
+
- Install typescript `yarn add typescript --dev`
|
|
13
|
+
- Install cypress, use same version as cypress `package.json`. E.g. `yarn add cypress@13.13.1 --dev`
|
|
14
|
+
- Add `cypress.config.js` to your project
|
|
15
|
+
```javascript
|
|
16
|
+
const { defineConfig } = require('cypress');
|
|
17
|
+
|
|
18
|
+
module.exports = defineConfig({
|
|
19
|
+
e2e: {
|
|
20
|
+
specPattern: 'node_modules/glib-web/cypress/e2e',
|
|
21
|
+
defaultBrowser: 'chrome',
|
|
22
|
+
supportFile: 'node_modules/glib-web/cypress/support/e2e.{js,jsx,ts,tsx}'
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
```
|
|
26
|
+
- Copy `cypress.yml.example` to `YOUR_PROJECT/.github/workflows/cypress.yml`
|
|
27
|
+
- Make sure the workflow is compatible with the project
|
|
28
|
+
- Run the test
|
|
29
|
+
- Run rails server `bin/rails s`
|
|
30
|
+
- Execute `yarn run cypress run`
|
|
31
|
+
|
|
10
32
|
## Best practices
|
|
11
33
|
|
|
12
34
|
- To prevent circular dependencies between components:
|
package/app.vue
CHANGED
|
@@ -170,12 +170,6 @@ export default {
|
|
|
170
170
|
},
|
|
171
171
|
true
|
|
172
172
|
);
|
|
173
|
-
|
|
174
|
-
if (isRerender()) {
|
|
175
|
-
GLib.action.execute(this.page.onRerender, this);
|
|
176
|
-
} else {
|
|
177
|
-
GLib.action.execute(this.page.onLoad, this);
|
|
178
|
-
}
|
|
179
173
|
},
|
|
180
174
|
$ready() {
|
|
181
175
|
this.$type.ifString(this.page.title, (title) => {
|
|
@@ -219,6 +213,12 @@ export default {
|
|
|
219
213
|
"animate": true
|
|
220
214
|
}, this);
|
|
221
215
|
}
|
|
216
|
+
|
|
217
|
+
if (isRerender()) {
|
|
218
|
+
GLib.action.execute(this.page.onRerender, this);
|
|
219
|
+
} else {
|
|
220
|
+
GLib.action.execute(this.page.onLoad, this);
|
|
221
|
+
}
|
|
222
222
|
},
|
|
223
223
|
updateMainHeight() {
|
|
224
224
|
this.mainHeight = window.innerHeight - this.$refs.navBar.$el.offsetHeight;
|
package/components/component.vue
CHANGED
|
@@ -69,6 +69,7 @@ import TextField from "./fields/text.vue";
|
|
|
69
69
|
import SubmitField from "./fields/submit.vue";
|
|
70
70
|
import TextAreaField from "./fields/textarea.vue";
|
|
71
71
|
const RichTextField = defineAsyncComponent(() => import("./fields/richText.vue"));
|
|
72
|
+
const RichTextField2 = defineAsyncComponent(() => import("./fields/richText2.vue"));
|
|
72
73
|
// import NewRichTextField from "./fields/newRichText.vue";
|
|
73
74
|
const FileField = defineAsyncComponent(() => import("./fields/file.vue"));
|
|
74
75
|
const MultiUploadField = defineAsyncComponent(() => import("./fields/multiUpload.vue"));
|
|
@@ -170,7 +171,7 @@ export default {
|
|
|
170
171
|
"fields-text": TextField,
|
|
171
172
|
"fields-submit": SubmitField,
|
|
172
173
|
"fields-textarea": TextAreaField,
|
|
173
|
-
"fields-richText":
|
|
174
|
+
"fields-richText": RichTextField2,
|
|
174
175
|
// "fields-newRichText": NewRichTextField,
|
|
175
176
|
"fields-file": FileField,
|
|
176
177
|
"fields-multiUpload": MultiUploadField,
|
|
@@ -106,8 +106,8 @@ function useGlibInput({ props, cacheValue = true }) {
|
|
|
106
106
|
|
|
107
107
|
// save fieldModel to spec so data still intact even if component unmounted
|
|
108
108
|
onBeforeUpdate(() => {
|
|
109
|
-
if (!instance.
|
|
110
|
-
instance.
|
|
109
|
+
if (!instance.props.spec || !cacheValue) return;
|
|
110
|
+
instance.props.spec.value = instance.data.fieldModel;
|
|
111
111
|
});
|
|
112
112
|
|
|
113
113
|
}
|
|
@@ -32,7 +32,7 @@ function setBusyWhenUploading({ files }) {
|
|
|
32
32
|
});
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
function uploadFiles({ droppedFiles, files, spec, container }) {
|
|
35
|
+
function uploadFiles({ droppedFiles, files, spec, container, onAfterUploaded }) {
|
|
36
36
|
let { responseMessages } = spec;
|
|
37
37
|
responseMessages ||= {};
|
|
38
38
|
|
|
@@ -41,7 +41,7 @@ function uploadFiles({ droppedFiles, files, spec, container }) {
|
|
|
41
41
|
// show new dropped file and track progress
|
|
42
42
|
key = makeKey();
|
|
43
43
|
files.value[key] = new Item({ el: droppedFiles[index], status: 'pending' });
|
|
44
|
-
uploadOneFile({ files, key, spec, container });
|
|
44
|
+
uploadOneFile({ files, key, spec, container, onAfterUploaded });
|
|
45
45
|
}
|
|
46
46
|
}
|
|
47
47
|
|
|
@@ -18,10 +18,11 @@
|
|
|
18
18
|
<v-input :name="fieldName" :rules="$validation()" v-model="model" />
|
|
19
19
|
|
|
20
20
|
<v-dialog v-model="dialog" width="auto">
|
|
21
|
-
<v-date-picker :model-value="new Date(model)" @update:modelValue="handleDatePickerChanged"
|
|
21
|
+
<v-date-picker :model-value="model ? new Date(model) : null" @update:modelValue="handleDatePickerChanged"
|
|
22
22
|
:disabled="inputDisabled" :min="sanitizeValue(spec.min)" :max="sanitizeValue(spec.max)" show-adjacent-months
|
|
23
23
|
location="center" position="fixed">
|
|
24
24
|
<template v-slot:actions>
|
|
25
|
+
<v-btn color="error" @click="remove">Remove</v-btn>
|
|
25
26
|
<v-btn color="primary" @click="dialog = false">Save</v-btn>
|
|
26
27
|
</template>
|
|
27
28
|
</v-date-picker>
|
|
@@ -69,6 +70,11 @@ export default {
|
|
|
69
70
|
model.value = sanitizeValue(val.spec.value);
|
|
70
71
|
});
|
|
71
72
|
|
|
73
|
+
function remove() {
|
|
74
|
+
model.value = '';
|
|
75
|
+
ctx.emit('datePicked', model.value);
|
|
76
|
+
}
|
|
77
|
+
|
|
72
78
|
function handleChanged(e) {
|
|
73
79
|
const { value } = e.srcElement;
|
|
74
80
|
model.value = value;
|
|
@@ -83,7 +89,7 @@ export default {
|
|
|
83
89
|
|
|
84
90
|
const { smAndUp } = useDisplay();
|
|
85
91
|
|
|
86
|
-
return { text, sanitizeValue, dateInput, handleChanged, model, template, dialog, handleDatePickerChanged, smAndUp };
|
|
92
|
+
return { text, sanitizeValue, dateInput, handleChanged, model, template, dialog, handleDatePickerChanged, smAndUp, remove };
|
|
87
93
|
},
|
|
88
94
|
methods: {
|
|
89
95
|
showPicker(e) {
|
|
@@ -56,8 +56,16 @@ export default defineComponent({
|
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
function prefixFieldName(index, fieldName) {
|
|
59
|
-
if (fieldName.
|
|
60
|
-
|
|
59
|
+
if (fieldName.match(/\[|\]/g)) {
|
|
60
|
+
let suffix = '';
|
|
61
|
+
const indexOfFirstBracket = fieldName.indexOf('[');
|
|
62
|
+
if (indexOfFirstBracket > 0) {
|
|
63
|
+
suffix = '[' + fieldName.slice(0, indexOfFirstBracket) + ']' + fieldName.slice(indexOfFirstBracket, fieldName.length);
|
|
64
|
+
} else {
|
|
65
|
+
suffix = fieldName;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return `${namePrefix}[${index}]${suffix}`;
|
|
61
69
|
} else {
|
|
62
70
|
return `${namePrefix}[${index}][${fieldName}]`;
|
|
63
71
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div>Raw Mode</div>
|
|
3
|
+
<v-textarea :value="props.producedValue" auto-grow></v-textarea>
|
|
4
|
+
<ul>
|
|
5
|
+
<li v-for="signedId in signedIds" :key="signedId">{{ signedId }}</li>
|
|
6
|
+
</ul>
|
|
7
|
+
</template>
|
|
8
|
+
|
|
9
|
+
<script setup>
|
|
10
|
+
const props = defineProps(['producedValue', 'signedIds']);
|
|
11
|
+
</script>
|
|
@@ -7,13 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
<v-progress-linear v-if="showProgress" v-model="progress.value" />
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
<!-- <VueEditor v-if="!rawMode" id="rich-editor" v-model="richEditorValue" :editor-toolbar="customToolbar"
|
|
12
|
-
use-custom-image-handler :editor-options="editorSettings" @text-change="onRichTextEditorChanged"
|
|
13
|
-
@image-added="uploadImage" /> -->
|
|
14
|
-
<QuillEditor v-if="!rawMode" ref="quilEditor" theme="snow" :toolbar="customToolbar" :content="richEditorValue"
|
|
15
|
-
contentType="html" :modules="modules" @textChange="onRichTextEditorChanged" class="rich-editor"
|
|
16
|
-
:placeholder="spec.placeholder" />
|
|
10
|
+
<div v-if="!rawMode" ref="editor" id="editor"></div>
|
|
17
11
|
<!-- Hide these fields but don't remove them because these are the values that will get submitted. -->
|
|
18
12
|
<div :style="{ display: rawMode ? 'block' : 'none' }">
|
|
19
13
|
<v-textarea class="raw-editor" v-model="rawEditorValue" :style="$styles()" :class="$classes()"
|
|
@@ -27,20 +21,20 @@
|
|
|
27
21
|
</template>
|
|
28
22
|
|
|
29
23
|
<script>
|
|
30
|
-
import '@vueup/vue-quill/dist/vue-quill.snow.css';
|
|
31
|
-
|
|
32
24
|
import Uploader from "../../utils/glibDirectUpload";
|
|
33
|
-
import { QuillEditor, Quill } from '@vueup/vue-quill';
|
|
34
25
|
|
|
35
26
|
import TurndownService from "turndown";
|
|
36
27
|
import { gfm } from "turndown-plugin-gfm";
|
|
37
28
|
import eventFiltering from "../../utils/eventFiltering";
|
|
38
|
-
import
|
|
29
|
+
// import ImageDropAndPaste from "quill-image-drop-and-paste";
|
|
39
30
|
import bus from "../../utils/eventBus";
|
|
40
31
|
|
|
41
|
-
import {
|
|
42
|
-
import { triggerOnInput, useGlibInput } from "../composable/form";
|
|
32
|
+
import { useGlibInput } from "../composable/form";
|
|
43
33
|
import { validateFile } from "../composable/file";
|
|
34
|
+
import Quill from "quill";
|
|
35
|
+
import "quill/dist/quill.snow.css";
|
|
36
|
+
|
|
37
|
+
// Quill.register('modules/ImageDropAndPaste', ImageDropAndPaste);
|
|
44
38
|
|
|
45
39
|
var ImageBlot = Quill.import("formats/image");
|
|
46
40
|
ImageBlot.sanitize = function (url) {
|
|
@@ -157,23 +151,7 @@ class TextEditor {
|
|
|
157
151
|
}
|
|
158
152
|
}
|
|
159
153
|
|
|
160
|
-
const modules = {
|
|
161
|
-
name: 'imageDropAndPaste',
|
|
162
|
-
module: QuillImageDropAndPaste,
|
|
163
|
-
options: {
|
|
164
|
-
// add an custom image handler
|
|
165
|
-
handler: function (imageDataUrl, type, imageData) {
|
|
166
|
-
bus.$emit("richText/dropOrPaste", {
|
|
167
|
-
file: imageData.toFile(),
|
|
168
|
-
editor: this.quill,
|
|
169
|
-
cursorLocation: this.getIndex(),
|
|
170
|
-
});
|
|
171
|
-
},
|
|
172
|
-
}
|
|
173
|
-
};
|
|
174
|
-
|
|
175
154
|
export default {
|
|
176
|
-
components: { QuillEditor },
|
|
177
155
|
props: {
|
|
178
156
|
spec: { type: Object, required: true },
|
|
179
157
|
},
|
|
@@ -182,13 +160,6 @@ export default {
|
|
|
182
160
|
},
|
|
183
161
|
data: function () {
|
|
184
162
|
return {
|
|
185
|
-
customToolbar: [
|
|
186
|
-
["bold", "italic", "strike"],
|
|
187
|
-
[{ header: [false, 1, 2, 3, 4, 5] }],
|
|
188
|
-
[{ list: "ordered" }, { list: "bullet" }],
|
|
189
|
-
["link"],
|
|
190
|
-
],
|
|
191
|
-
modules: this.spec.imageUploader ? modules : null,
|
|
192
163
|
richEditorValue: "",
|
|
193
164
|
rawEditorValue: "",
|
|
194
165
|
producedValue: "",
|
|
@@ -238,9 +209,39 @@ export default {
|
|
|
238
209
|
);
|
|
239
210
|
},
|
|
240
211
|
mounted() {
|
|
212
|
+
const quill = new Quill('#editor', {
|
|
213
|
+
readOnly: this.spec.readOnly,
|
|
214
|
+
placeholder: this.spec.placeholder,
|
|
215
|
+
theme: 'snow',
|
|
216
|
+
modules: {
|
|
217
|
+
clipboard: true,
|
|
218
|
+
toolbar: [
|
|
219
|
+
["bold", "italic", "strike"],
|
|
220
|
+
[{ header: [false, 1, 2, 3, 4, 5] }],
|
|
221
|
+
[{ list: "ordered" }, { list: "bullet" }],
|
|
222
|
+
["link"],
|
|
223
|
+
],
|
|
224
|
+
// ImageDropAndPaste: {
|
|
225
|
+
// // add an custom image handler
|
|
226
|
+
// handler: function (imageDataUrl, type, imageData) {
|
|
227
|
+
// bus.$emit("richText/dropOrPaste", {
|
|
228
|
+
// file: imageData.toFile(),
|
|
229
|
+
// editor: quill,
|
|
230
|
+
// cursorLocation: this.getIndex(),
|
|
231
|
+
// });
|
|
232
|
+
// },
|
|
233
|
+
// }
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
const delta = quill.clipboard.convert({ html: this.richEditorValue });
|
|
238
|
+
quill.updateContents(delta, 'user');
|
|
239
|
+
|
|
240
|
+
quill.on('text-change', this.onRichTextEditorChanged);
|
|
241
|
+
|
|
241
242
|
if (this.spec.imageUploader) {
|
|
242
|
-
bus.$on("richText/dropOrPaste", ({ file,
|
|
243
|
-
this.uploadImage(file,
|
|
243
|
+
bus.$on("richText/dropOrPaste", ({ file, cursorLocation }) =>
|
|
244
|
+
this.uploadImage(file, quill, cursorLocation)
|
|
244
245
|
);
|
|
245
246
|
}
|
|
246
247
|
},
|
|
@@ -287,17 +288,13 @@ export default {
|
|
|
287
288
|
);
|
|
288
289
|
},
|
|
289
290
|
onRichTextEditorChanged: eventFiltering.debounce(function () {
|
|
290
|
-
this.richEditorValue = this.$refs.
|
|
291
|
+
this.richEditorValue = this.$refs.editor.getHTML();
|
|
291
292
|
this.producedValue = this.textEditor.producedValue(
|
|
292
293
|
this.richEditorValue,
|
|
293
294
|
"html",
|
|
294
295
|
this.produce
|
|
295
296
|
);
|
|
296
297
|
|
|
297
|
-
if (this.spec.cacheKey) {
|
|
298
|
-
vueApp.richTextValues[this.spec.cacheKey] = this.richEditorValue;
|
|
299
|
-
}
|
|
300
|
-
|
|
301
298
|
this.$executeOnChange(this.producedValue);
|
|
302
299
|
|
|
303
300
|
}),
|
|
@@ -362,22 +359,8 @@ export default {
|
|
|
362
359
|
</script>
|
|
363
360
|
|
|
364
361
|
<style>
|
|
365
|
-
|
|
366
|
-
@import "~vue2-editor/dist/vue2-editor.css";
|
|
367
|
-
@import '~quill/dist/quill.core.css';
|
|
368
|
-
@import '~quill/dist/quill.snow.css';
|
|
369
|
-
|
|
370
|
-
#editor-container {
|
|
371
|
-
height: 375px;
|
|
372
|
-
}
|
|
373
|
-
*/
|
|
374
|
-
/* .ql-toolbar.sticky {
|
|
375
|
-
position: fixed;
|
|
376
|
-
z-index: 99;
|
|
377
|
-
background-color: white;
|
|
378
|
-
} */
|
|
379
|
-
.rich-editor,
|
|
380
|
-
.raw-editor {
|
|
362
|
+
.ql-editor {
|
|
381
363
|
height: 350px;
|
|
364
|
+
overflow-y: auto;
|
|
382
365
|
}
|
|
383
366
|
</style>
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div :style="$styles()" :class="$classes()" v-if="loadIf">
|
|
3
|
+
<div id="editor" ref="editor"></div>
|
|
4
|
+
<input type="hidden" :name="fieldName" :value="producedValue" />
|
|
5
|
+
<template v-if="Object.keys(files).length > 0">
|
|
6
|
+
<input type="hidden" v-for="(file, index) in files" :name="spec.imageUploader.name" :value="file.signedId"
|
|
7
|
+
:key="index" />
|
|
8
|
+
</template>
|
|
9
|
+
<RawText v-if="spec.debug" v-bind="rawTextProps"></RawText>
|
|
10
|
+
</div>
|
|
11
|
+
</template>
|
|
12
|
+
|
|
13
|
+
<script>
|
|
14
|
+
import { computed, defineComponent, getCurrentInstance, nextTick, onMounted, ref } from "vue";
|
|
15
|
+
import Quill from "quill";
|
|
16
|
+
import "quill/dist/quill.snow.css";
|
|
17
|
+
import 'quill-mention/dist/quill.mention.css';
|
|
18
|
+
import { Mention, MentionBlot } from "quill-mention";
|
|
19
|
+
const Link = Quill.import('formats/link');
|
|
20
|
+
// Override the existing property on the Quill global object and add custom protocols
|
|
21
|
+
Link.PROTOCOL_WHITELIST = ['http', 'https', 'mailto', 'tel', 'radar', 'rdar', 'smb', 'sms'];
|
|
22
|
+
|
|
23
|
+
class CustomLinkSanitizer extends Link {
|
|
24
|
+
static sanitize(url) {
|
|
25
|
+
// Run default sanitize method from Quill
|
|
26
|
+
const sanitizedUrl = super.sanitize(url);
|
|
27
|
+
|
|
28
|
+
// Not whitelisted URL based on protocol so, let's return `blank`
|
|
29
|
+
if (!sanitizedUrl || sanitizedUrl === 'about:blank') return sanitizedUrl;
|
|
30
|
+
|
|
31
|
+
// Verify if the URL already have a whitelisted protocol
|
|
32
|
+
const hasWhitelistedProtocol = this.PROTOCOL_WHITELIST.some(function (protocol) {
|
|
33
|
+
return sanitizedUrl.startsWith(protocol);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
if (hasWhitelistedProtocol) return sanitizedUrl;
|
|
37
|
+
|
|
38
|
+
// if not, then append only 'http' to not to be a relative URL
|
|
39
|
+
return `https://${sanitizedUrl}`;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
Quill.register(CustomLinkSanitizer, true);
|
|
44
|
+
|
|
45
|
+
import { useGlibInput } from "../composable/form";
|
|
46
|
+
import format from "../../utils/format";
|
|
47
|
+
import Action from "../../action";
|
|
48
|
+
import eventFiltering from "../../utils/eventFiltering";
|
|
49
|
+
import { useFileUtils } from "../composable/file";
|
|
50
|
+
import { setBusyWhenUploading, uploadFiles } from "../composable/upload";
|
|
51
|
+
import dom from "../../utils/dom";
|
|
52
|
+
import RawText from "./rawText.vue";
|
|
53
|
+
|
|
54
|
+
const { Item, makeKey } = useFileUtils();
|
|
55
|
+
|
|
56
|
+
export default defineComponent({
|
|
57
|
+
props: {
|
|
58
|
+
spec: Object
|
|
59
|
+
},
|
|
60
|
+
components: { RawText },
|
|
61
|
+
setup(props) {
|
|
62
|
+
useGlibInput({ props });
|
|
63
|
+
|
|
64
|
+
let quill = undefined;
|
|
65
|
+
const instance = getCurrentInstance();
|
|
66
|
+
const editor = ref(null);
|
|
67
|
+
const producedValue = ref(undefined);
|
|
68
|
+
|
|
69
|
+
const { accept, produce, imageUploader } = props.spec;
|
|
70
|
+
|
|
71
|
+
const files = ref([props.spec.images || []].flat().reduce((prev, curr) => {
|
|
72
|
+
if (!curr) return prev;
|
|
73
|
+
return Object.assign({}, prev, { [makeKey()]: new Item(curr) });
|
|
74
|
+
}, {}));
|
|
75
|
+
setBusyWhenUploading({ files });
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
function sanitizedValue() {
|
|
79
|
+
// let index = 0;
|
|
80
|
+
// const value = quill.root.innerHTML.replace(/src="([^"]+)"|href="([^"]+)"/g, function (_, g1, g2) {
|
|
81
|
+
// if (g1) {
|
|
82
|
+
// return `src="{{image${++index}}}"`;
|
|
83
|
+
// } else if (g2) {
|
|
84
|
+
// return `href="{{image${++index}}}"`;
|
|
85
|
+
// }
|
|
86
|
+
// });
|
|
87
|
+
const value = quill.root.innerHTML;
|
|
88
|
+
|
|
89
|
+
return produce === 'markdown' ? format.htmlToMarkdown(value) : value;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function onAfterUploaded(file) {
|
|
93
|
+
fetch(imageUploader.blobUrlGenerator, {
|
|
94
|
+
method: 'POST',
|
|
95
|
+
body: new URLSearchParams({ signed_id: file.signedId, authenticity_token: dom.getCsrf() })
|
|
96
|
+
}).then(async (res) => {
|
|
97
|
+
if (res.ok) {
|
|
98
|
+
const selection = quill.getSelection();
|
|
99
|
+
const index = selection ? selection.index : quill.getLength() - 1;
|
|
100
|
+
|
|
101
|
+
const data = await res.json();
|
|
102
|
+
|
|
103
|
+
if (!file.isImage()) {
|
|
104
|
+
quill.insertText(index, ' ');
|
|
105
|
+
quill.insertText(index, file.name, 'link', data.url);
|
|
106
|
+
} else {
|
|
107
|
+
quill.insertEmbed(index, 'image', data.url, 'user');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
} else {
|
|
111
|
+
console.error('Failed to create blob url');
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function handlePaste(e) {
|
|
118
|
+
if (
|
|
119
|
+
e.clipboardData &&
|
|
120
|
+
e.clipboardData.items &&
|
|
121
|
+
e.clipboardData.items.length
|
|
122
|
+
) {
|
|
123
|
+
e.preventDefault();
|
|
124
|
+
uploadFiles({ droppedFiles: e.clipboardData.files, files, spec: props.spec.imageUploader, onAfterUploaded });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function handleDrop(e) {
|
|
129
|
+
if (e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files.length) {
|
|
130
|
+
e.preventDefault();
|
|
131
|
+
uploadFiles({ droppedFiles: e.dataTransfer.files, files, spec: props.spec.imageUploader, onAfterUploaded });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const rawTextProps = computed(() => {
|
|
136
|
+
const signedIds = Object.values(files.value).map((file) => file.signedId);
|
|
137
|
+
|
|
138
|
+
return { producedValue: producedValue.value, signedIds };
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// init quill
|
|
142
|
+
const modules = {
|
|
143
|
+
uploader: {
|
|
144
|
+
handler(range, files) {
|
|
145
|
+
// disable uploader
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
toolbar: [
|
|
149
|
+
["bold", "italic", "strike"],
|
|
150
|
+
[{ header: [false, 1, 2, 3, 4, 5] }],
|
|
151
|
+
[{ list: "ordered" }, { list: "bullet" }],
|
|
152
|
+
["link"],
|
|
153
|
+
]
|
|
154
|
+
};
|
|
155
|
+
if (props.spec.mentionList) {
|
|
156
|
+
Quill.register({ "blots/mention": MentionBlot, "modules/mention": Mention });
|
|
157
|
+
modules.mention = {
|
|
158
|
+
allowedChars: /^[A-Za-z\sÅÄÖåäö]*$/,
|
|
159
|
+
mentionDenotationChars: ["@"],
|
|
160
|
+
source: function (searchTerm, renderList, mentionChar) {
|
|
161
|
+
const values = props.spec.mentionList.map((v, index) => ({ id: index + 1, value: v }));
|
|
162
|
+
|
|
163
|
+
if (searchTerm.length === 0) {
|
|
164
|
+
renderList(values, searchTerm);
|
|
165
|
+
} else {
|
|
166
|
+
renderList(values.filter((v) => v.value.toLowerCase().includes(searchTerm)), searchTerm);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
onMounted(() => {
|
|
172
|
+
quill = new Quill('#editor', {
|
|
173
|
+
readOnly: props.spec.readOnly,
|
|
174
|
+
placeholder: props.spec.placeholder,
|
|
175
|
+
theme: 'snow',
|
|
176
|
+
modules
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
let value = props.spec.value;
|
|
180
|
+
switch (accept) {
|
|
181
|
+
case 'html':
|
|
182
|
+
break;
|
|
183
|
+
|
|
184
|
+
default:
|
|
185
|
+
value = format.markdownForEditor(value);
|
|
186
|
+
break;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const delta = quill.clipboard.convert({ html: value });
|
|
190
|
+
quill.updateContents(delta, 'silent');
|
|
191
|
+
|
|
192
|
+
producedValue.value = sanitizedValue();
|
|
193
|
+
|
|
194
|
+
quill.on('text-change', eventFiltering.debounce(function () {
|
|
195
|
+
producedValue.value = sanitizedValue();
|
|
196
|
+
nextTick(() => {
|
|
197
|
+
Action.executeWithFormData(props.spec.onChange || props.spec.onChangeAndLoad, instance.ctx, producedValue.value);
|
|
198
|
+
});
|
|
199
|
+
}));
|
|
200
|
+
|
|
201
|
+
if (imageUploader) {
|
|
202
|
+
quill.root.addEventListener('paste', handlePaste, false);
|
|
203
|
+
quill.root.addEventListener('drop', handleDrop, false);
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
return { producedValue, editor, files, rawTextProps };
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
</script>
|
|
211
|
+
|
|
212
|
+
<style>
|
|
213
|
+
.ql-editor {
|
|
214
|
+
height: 350px;
|
|
215
|
+
overflow-y: auto;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
.ql-container {
|
|
219
|
+
height: fit-content;
|
|
220
|
+
}
|
|
221
|
+
</style>
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<v-progress-circular :class="$classes()" :rotate="spec.rotate || 0" :size="spec.size || 100" :width="spec.width || 20"
|
|
3
3
|
:color="spec.color" :model-value="spec.value" :indeterminate="spec.indeterminate">
|
|
4
|
-
|
|
4
|
+
<div>
|
|
5
|
+
<div class="value-style text-center">{{ spec.value }}%</div>
|
|
6
|
+
<span v-if="spec.text" class="text-style">{{ spec.text }}</span>
|
|
7
|
+
</div>
|
|
5
8
|
</v-progress-circular>
|
|
6
9
|
</template>
|
|
7
10
|
|
|
@@ -13,4 +16,16 @@ export default {
|
|
|
13
16
|
};
|
|
14
17
|
</script>
|
|
15
18
|
|
|
16
|
-
<style scoped
|
|
19
|
+
<style scoped>
|
|
20
|
+
.value-style {
|
|
21
|
+
font-size: 16px;
|
|
22
|
+
line-height: 150%;
|
|
23
|
+
font-weight: 700;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
.text-style {
|
|
27
|
+
font-size: 16px;
|
|
28
|
+
line-height: 150%;
|
|
29
|
+
font-weight: 400;
|
|
30
|
+
}
|
|
31
|
+
</style>
|
|
@@ -5,8 +5,8 @@ describe('form dynamic', () => {
|
|
|
5
5
|
it('add, delete, and submit', () => {
|
|
6
6
|
cy.visit(url)
|
|
7
7
|
|
|
8
|
-
cy.get('.question
|
|
9
|
-
cy.get('.answer_type
|
|
8
|
+
cy.get('.question').eq(0).click().type(' edited')
|
|
9
|
+
cy.get('.answer_type').eq(0).click()
|
|
10
10
|
cy.get('.v-overlay-container').contains('Yes no').click()
|
|
11
11
|
|
|
12
12
|
cy.contains('Add item').click()
|
|
@@ -54,7 +54,7 @@ Form Data:
|
|
|
54
54
|
it('can execute action just like normal component', () => {
|
|
55
55
|
cy.visit(url)
|
|
56
56
|
|
|
57
|
-
cy.get('.answer_type
|
|
57
|
+
cy.get('.answer_type').eq(0).click()
|
|
58
58
|
cy.get('.v-overlay-container').contains('Choices').click()
|
|
59
59
|
|
|
60
60
|
const result = `{
|
|
@@ -5,6 +5,9 @@ describe('glib lifecycle hooks', () => {
|
|
|
5
5
|
it('execute onLoad and onRerender', () => {
|
|
6
6
|
cy.visit(url)
|
|
7
7
|
|
|
8
|
+
cy.contains('page.onLoad').should('be.exist')
|
|
9
|
+
cy.contains('selectable').click()
|
|
10
|
+
cy.contains('lifecycle').click()
|
|
8
11
|
cy.contains('page.onLoad').should('be.exist')
|
|
9
12
|
|
|
10
13
|
// check if onLoad executed
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
name: "Cypress"
|
|
2
|
+
on:
|
|
3
|
+
push:
|
|
4
|
+
paths:
|
|
5
|
+
- "yarn.lock"
|
|
6
|
+
- ".github/workflows/cypress.yml"
|
|
7
|
+
jobs:
|
|
8
|
+
build-and-test:
|
|
9
|
+
env:
|
|
10
|
+
RAILS_ENV: test
|
|
11
|
+
POSTGRES_USER: postgres
|
|
12
|
+
POSTGRES_PASSWORD: postgres
|
|
13
|
+
PARALLEL_WORKERS: 2
|
|
14
|
+
RUN_ON: github
|
|
15
|
+
runs-on: ubuntu-latest
|
|
16
|
+
services:
|
|
17
|
+
db:
|
|
18
|
+
image: postgres:11
|
|
19
|
+
ports: ["5432:5432"]
|
|
20
|
+
env:
|
|
21
|
+
POSTGRES_USER: postgres
|
|
22
|
+
POSTGRES_PASSWORD: postgres
|
|
23
|
+
POSTGRES_DB: postgres
|
|
24
|
+
options: >-
|
|
25
|
+
--health-cmd pg_isready
|
|
26
|
+
--health-interval 10s
|
|
27
|
+
--health-timeout 5s
|
|
28
|
+
--health-retries 5
|
|
29
|
+
steps:
|
|
30
|
+
- uses: actions/checkout@v2
|
|
31
|
+
- name: Setup Ruby
|
|
32
|
+
uses: ruby/setup-ruby@v1
|
|
33
|
+
with:
|
|
34
|
+
ruby-version: 3.2.1
|
|
35
|
+
bundler-cache: true
|
|
36
|
+
- name: Setup Node
|
|
37
|
+
uses: actions/setup-node@v2
|
|
38
|
+
with:
|
|
39
|
+
cache: "yarn"
|
|
40
|
+
node-version: 20.0.0
|
|
41
|
+
- run: yarn install --frozen-lockfile
|
|
42
|
+
- run: sudo apt-get -yqq install libpq-dev
|
|
43
|
+
- run: cp config/database.yml.github-actions config/database.yml
|
|
44
|
+
- run: bundle exec rake db:test:prepare
|
|
45
|
+
- run: bundle exec rails server -d
|
|
46
|
+
- name: Cypress run
|
|
47
|
+
uses: cypress-io/github-action@v6
|
|
48
|
+
with:
|
|
49
|
+
browser: chrome
|
|
50
|
+
- name: Upload screenshots
|
|
51
|
+
uses: actions/upload-artifact@v4
|
|
52
|
+
if: failure()
|
|
53
|
+
with:
|
|
54
|
+
name: cypress-screenshots
|
|
55
|
+
path: cypress/screenshots
|
|
56
|
+
- run: yarn run cypress run
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "glib-web",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.27.2",
|
|
4
4
|
"description": "",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -13,7 +13,6 @@
|
|
|
13
13
|
"@googlemaps/markerclusterer": "^2.5.0",
|
|
14
14
|
"@rails/actioncable": "^7.1.1",
|
|
15
15
|
"@rails/activestorage": "^7.1.1",
|
|
16
|
-
"@vueup/vue-quill": "^1.2.0",
|
|
17
16
|
"awesome-phonenumber": "2.15.0",
|
|
18
17
|
"chart.js": "^4.3.1",
|
|
19
18
|
"chartjs-plugin-datalabels": "^2.2.0",
|
|
@@ -30,7 +29,8 @@
|
|
|
30
29
|
"papaparse": "^5.4.1",
|
|
31
30
|
"phoenix": "^1.5.3",
|
|
32
31
|
"push.js": "^1.0.12",
|
|
33
|
-
"quill
|
|
32
|
+
"quill": "2.0.3",
|
|
33
|
+
"quill-mention": "^6.0.2",
|
|
34
34
|
"tiny-emitter": "^2.1.0",
|
|
35
35
|
"turndown": "^7.1.1",
|
|
36
36
|
"turndown-plugin-gfm": "^1.0.2",
|
package/utils/format.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { marked } from "marked";
|
|
2
|
+
import { gfm } from "turndown-plugin-gfm";
|
|
3
|
+
import TurndownService from "turndown";
|
|
2
4
|
|
|
3
5
|
export default class {
|
|
4
6
|
static markdownForEditor(text) {
|
|
@@ -27,6 +29,25 @@ export default class {
|
|
|
27
29
|
return marked.parse(text);
|
|
28
30
|
}
|
|
29
31
|
|
|
32
|
+
static htmlToMarkdown(text) {
|
|
33
|
+
const turndownService = new TurndownService({ headingStyle: "atx" });
|
|
34
|
+
turndownService.use(gfm);
|
|
35
|
+
turndownService.addRule("strikethrough", {
|
|
36
|
+
filter: ["del", "s", "strike"],
|
|
37
|
+
replacement: function (content) {
|
|
38
|
+
return "~~" + content + "~~";
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
turndownService.addRule("codeblock", {
|
|
43
|
+
filter: ["pre"],
|
|
44
|
+
replacement: function (content) {
|
|
45
|
+
return "```\n" + content + "```";
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
return turndownService.turndown(text);
|
|
49
|
+
}
|
|
50
|
+
|
|
30
51
|
static local_iso8601(date) {
|
|
31
52
|
// See https://stackoverflow.com/a/29774197
|
|
32
53
|
const offset = date.getTimezoneOffset();
|