Add files from vue with lib/vue prefix
This commit is contained in:
40
lib/vue/.eslintrc.js
Normal file
40
lib/vue/.eslintrc.js
Normal file
@@ -0,0 +1,40 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: {
|
||||
node: true,
|
||||
es6: true,
|
||||
mocha: true,
|
||||
},
|
||||
extends: [
|
||||
'standard',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
],
|
||||
rules: {
|
||||
'no-console': process.env.NODE_ENV === 'production' ? 'error' : 'off',
|
||||
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off',
|
||||
'comma-dangle': [ 'error', 'always-multiline' ],
|
||||
},
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: [
|
||||
'@typescript-eslint',
|
||||
],
|
||||
settings: {
|
||||
'import/parsers': {
|
||||
'@typescript-eslint/parser': [
|
||||
'.ts',
|
||||
],
|
||||
},
|
||||
'import/resolver': {
|
||||
typescript: {},
|
||||
},
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
"files": ["*.test.ts"],
|
||||
"rules": {
|
||||
"no-unused-expressions": "off"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
17
lib/vue/.gitignore
vendored
Normal file
17
lib/vue/.gitignore
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
.DS_Store
|
||||
node_modules
|
||||
dist
|
||||
*.log
|
||||
.env
|
||||
coverage
|
||||
.nyc_output
|
||||
|
||||
# Editor directories and files
|
||||
.idea
|
||||
.vscode
|
||||
*.iml
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw*
|
||||
13
lib/vue/.mocharc.js
Normal file
13
lib/vue/.mocharc.js
Normal file
@@ -0,0 +1,13 @@
|
||||
module.exports = {
|
||||
require: [
|
||||
'esm',
|
||||
'ts-node/register',
|
||||
'source-map-support/register',
|
||||
],
|
||||
'full-trace': true,
|
||||
bail: true,
|
||||
recursive: true,
|
||||
extension: ['ts', 'js'],
|
||||
spec: 'src/**/*.test.ts',
|
||||
'watch-files': [ 'src/**' ],
|
||||
}
|
||||
159
lib/vue/package.json
Normal file
159
lib/vue/package.json
Normal file
@@ -0,0 +1,159 @@
|
||||
{
|
||||
"name": "@cortezaproject/corteza-vue",
|
||||
"description": "Corteza Vue.js JavaScript/TypeScript libs",
|
||||
"version": "2022.9.2",
|
||||
"license": "Apache-2.0",
|
||||
"author": "Denis Arh <denis.arh@crust.tech>",
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/index.es.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"files": [
|
||||
"dist/"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "rollup -c --environment BUILD:production",
|
||||
"build:dev": "rollup -c",
|
||||
"lint": "eslint src/** --ext .js,.ts",
|
||||
"test:unit": "mocha",
|
||||
"test:unit:cc": "nyc mocha",
|
||||
"cdeps": "yarn upgrade @cortezaproject/corteza-js"
|
||||
},
|
||||
"gitHooks": {
|
||||
"pre-commit": "yarn lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cortezaproject/corteza-js": "^2022.9.2",
|
||||
"@fortawesome/fontawesome-svg-core": "^1.2.26",
|
||||
"@fortawesome/free-solid-svg-icons": "^5.12.0",
|
||||
"@fortawesome/vue-fontawesome": "^0.1.9",
|
||||
"@panter/vue-i18next": "^0.15.0",
|
||||
"@popperjs/core": "^2.11.2",
|
||||
"acorn": "^7.3.1",
|
||||
"bootstrap-vue": "^2.21.2",
|
||||
"echarts": "^5.3.3",
|
||||
"fuzzysort": "^1.2.1",
|
||||
"highlight.js": "^10.4.1",
|
||||
"html-parse-stringify": "^3.0.1",
|
||||
"i18next": "^20.0.0",
|
||||
"i18next-browser-languagedetector": "^3.0.2",
|
||||
"i18next-http-backend": "^1.3.0",
|
||||
"i18next-multiload-backend-adapter": "^1.0.0",
|
||||
"kind-of": "^6.0.3",
|
||||
"lodash": "^4.17.21",
|
||||
"moment": "2.29.2",
|
||||
"pdfjs-dist": "2.2.228",
|
||||
"pino": "^5.16.0",
|
||||
"portal-vue": "^2.1.7",
|
||||
"qs": "^6.9.1",
|
||||
"tiptap": "^1.26.0",
|
||||
"tiptap-extensions": "^1.28.4",
|
||||
"v-photoswipe": "^1.0.3-1",
|
||||
"vue-echarts": "^6.2.3",
|
||||
"vue-select": "^3.4.0",
|
||||
"vue-swatches": "^1.0.4",
|
||||
"vuedraggable": "^2.23.0",
|
||||
"vuex": "^3.6.2",
|
||||
"websocket-extensions": "^0.1.4",
|
||||
"y18n": "^4.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@istanbuljs/nyc-config-typescript": "1.0.1",
|
||||
"@rollup/plugin-commonjs": "^11.0.1",
|
||||
"@rollup/plugin-node-resolve": "^7.0.0",
|
||||
"@types/chai": "^4.2.6",
|
||||
"@types/chai-as-promised": "^7.1.2",
|
||||
"@types/lodash": "^4.14.149",
|
||||
"@types/mime-types": "^2.1.0",
|
||||
"@types/mocha": "^5.2.7",
|
||||
"@types/pino": "^5.15.3",
|
||||
"@types/qs": "^6.9.0",
|
||||
"@typescript-eslint/eslint-plugin": "^2.11.0",
|
||||
"@typescript-eslint/parser": "^2.11.0",
|
||||
"@vue/cli-plugin-babel": "4.1.2",
|
||||
"@vue/cli-plugin-eslint": "4.1.2",
|
||||
"@vue/cli-service": "^4.1.2",
|
||||
"@vue/eslint-config-standard": "5.1.0",
|
||||
"@vue/test-utils": "^1.0.0-beta.25",
|
||||
"chai": "^4.2.0",
|
||||
"chai-as-promised": "^7.1.1",
|
||||
"eslint": "^6.7.2",
|
||||
"eslint-config-standard": "^14.1.0",
|
||||
"eslint-import-resolver-typescript": "^2.0.0",
|
||||
"eslint-plugin-import": "^2.19.1",
|
||||
"eslint-plugin-json": "^2.0.1",
|
||||
"eslint-plugin-node": "11.0.0",
|
||||
"eslint-plugin-promise": "^4.2.1",
|
||||
"eslint-plugin-standard": "^4.0.1",
|
||||
"eslint-plugin-vue": "6.1.2",
|
||||
"esm": "^3.2.25",
|
||||
"i18next-pseudo": "^2.2.1",
|
||||
"mocha": "7.0.0",
|
||||
"node-watch": "^0.6.3",
|
||||
"nodemon": "2.0.2",
|
||||
"nyc": "15.0.0",
|
||||
"rollup": "^1.29.0",
|
||||
"rollup-plugin-babel": "^4.3.3",
|
||||
"rollup-plugin-import-css": "^3.0.0",
|
||||
"rollup-plugin-json": "^4.0.0",
|
||||
"rollup-plugin-styles": "^4.0.0",
|
||||
"rollup-plugin-typescript2": "^0.25.3",
|
||||
"rollup-plugin-vue": "^5.1.5",
|
||||
"sass": "^1.49.9",
|
||||
"sinon": "8.1.0",
|
||||
"source-map-support": "^0.5.16",
|
||||
"ts-node": "^9.0.0",
|
||||
"ts-node-dev": "^1.1.0",
|
||||
"typescript": "^3.7.4",
|
||||
"vue-property-decorator": "^8.3.0",
|
||||
"vue-template-compiler": "^2.6.14",
|
||||
"vue-tour": "^2.0.0"
|
||||
},
|
||||
"resolutions": {
|
||||
"**/**/yargs-parser": "^13.1.2",
|
||||
"**/**/minimist": "^0.2.1",
|
||||
"**/**/highlight.js": "^10.4.1",
|
||||
"**/**/moment": "2.29.2",
|
||||
"**/**/serialize-javascript": "^3.1.0",
|
||||
"**/**/node-forge": "^0.10.0",
|
||||
"**/**/y18n": "^4.0.1"
|
||||
},
|
||||
"stylelint": {
|
||||
"plugins": [
|
||||
"stylelint-scss"
|
||||
],
|
||||
"extends": "stylelint-config-standard",
|
||||
"rules": {
|
||||
"color-hex-case": null,
|
||||
"color-hex-length": null,
|
||||
"no-empty-source": null,
|
||||
"selector-list-comma-newline-after": null
|
||||
}
|
||||
},
|
||||
"nyc": {
|
||||
"extends": "@istanbuljs/nyc-config-typescript",
|
||||
"require": [
|
||||
"ts-node/register"
|
||||
],
|
||||
"all": true,
|
||||
"check-coverage": true,
|
||||
"per-file": true,
|
||||
"lines": 90,
|
||||
"functions": 90,
|
||||
"statements": 90,
|
||||
"reporter": [
|
||||
"text",
|
||||
"lcov"
|
||||
],
|
||||
"include": [
|
||||
"src/**/*.ts"
|
||||
]
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.14.0"
|
||||
},
|
||||
"browserslist": [
|
||||
"> 1%",
|
||||
"last 2 versions",
|
||||
"not ie <= 8"
|
||||
]
|
||||
}
|
||||
60
lib/vue/rollup.config.js
Normal file
60
lib/vue/rollup.config.js
Normal file
@@ -0,0 +1,60 @@
|
||||
import vue from 'rollup-plugin-vue'
|
||||
import resolve from '@rollup/plugin-node-resolve'
|
||||
import commonjs from '@rollup/plugin-commonjs'
|
||||
import typescript from 'rollup-plugin-typescript2'
|
||||
import babel from 'rollup-plugin-babel'
|
||||
import json from 'rollup-plugin-json'
|
||||
import styles from "rollup-plugin-styles"
|
||||
|
||||
import pkg from './package.json'
|
||||
|
||||
export default {
|
||||
input: 'src/index.ts',
|
||||
output: [
|
||||
{
|
||||
file: pkg.main,
|
||||
format: 'cjs',
|
||||
sourcemap: true,
|
||||
},
|
||||
{
|
||||
file: pkg.module,
|
||||
format: 'es',
|
||||
sourcemap: true,
|
||||
},
|
||||
],
|
||||
|
||||
external: [
|
||||
...Object.keys(pkg.dependencies || {}),
|
||||
...Object.keys(pkg.peerDependencies || {}),
|
||||
],
|
||||
|
||||
plugins: [
|
||||
resolve({
|
||||
main: true,
|
||||
browser: true,
|
||||
preferBuiltins: true,
|
||||
}),
|
||||
typescript({
|
||||
typescript: require('typescript'),
|
||||
|
||||
// Preventing :
|
||||
// [!] (plugin rpt2) Error: Unknown object type "asyncfunction"
|
||||
//
|
||||
// see: https://github.com/ezolenko/rollup-plugin-typescript2/issues/105
|
||||
objectHashIgnoreUnknownHack: true,
|
||||
}),
|
||||
commonjs(),
|
||||
vue({
|
||||
compileTemplate: true,
|
||||
}),
|
||||
babel({
|
||||
exclude: 'node_modules/**',
|
||||
}),
|
||||
json(),
|
||||
styles(),
|
||||
],
|
||||
|
||||
watch: {
|
||||
exclude: ['node_modules/**'],
|
||||
},
|
||||
}
|
||||
196
lib/vue/src/components/Tour/Tour.vue
Normal file
196
lib/vue/src/components/Tour/Tour.vue
Normal file
@@ -0,0 +1,196 @@
|
||||
<template>
|
||||
<v-tour
|
||||
v-if="tour !== null"
|
||||
:name="tour.name"
|
||||
:steps="tour.steps"
|
||||
:options="tour"
|
||||
:callbacks="this.callbacks"
|
||||
@onStop="onStop"
|
||||
>
|
||||
<template slot-scope="tour">
|
||||
<transition name="fade">
|
||||
<template v-for="(step, index) of tour.steps">
|
||||
<v-step
|
||||
v-if="tour.currentStep === index"
|
||||
:key="index"
|
||||
class="shadow p-3 text-light text-center"
|
||||
:step="step"
|
||||
:previous-step="tour.previousStep"
|
||||
:next-step="tour.nextStep"
|
||||
:stop="tour.stop"
|
||||
:is-first="tour.isFirst"
|
||||
:is-last="tour.isLast"
|
||||
:labels="tour.labels"
|
||||
@onStop="onStop"
|
||||
>
|
||||
<div slot="header">
|
||||
<h5
|
||||
v-if="(step.header || {}).title"
|
||||
v-html="$t(step.header.title)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div slot="content">
|
||||
<div class="p-1 mb-2">
|
||||
<div v-html="$t(step.content)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
slot="actions"
|
||||
>
|
||||
<b-button
|
||||
data-test-id="button-stop-tour"
|
||||
variant="primary"
|
||||
@click="onStop"
|
||||
>
|
||||
<template v-if="tour.isLast && !(callbacks || {}).onNextRedirect">
|
||||
{{ $t('buttons.end') }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ $t('buttons.skip') }}
|
||||
</template>
|
||||
</b-button>
|
||||
<b-button
|
||||
data-test-id="button-previous"
|
||||
v-if="tour.isFirst && (callbacks || {}).onPrevRedirect"
|
||||
variant="primary"
|
||||
:href="callbacks.onPrevRedirect"
|
||||
>
|
||||
{{ $t('buttons.previous') }}
|
||||
</b-button>
|
||||
<b-button
|
||||
data-test-id="button-previous"
|
||||
v-else-if="!tour.isFirst"
|
||||
variant="primary"
|
||||
@click="tour.previousStep"
|
||||
>
|
||||
{{ $t('buttons.previous') }}
|
||||
</b-button>
|
||||
<b-button
|
||||
data-test-id="button-next"
|
||||
v-if="tour.isLast && (callbacks || {}).onNextRedirect"
|
||||
variant="primary"
|
||||
:href="callbacks.onNextRedirect"
|
||||
>
|
||||
{{ $t('buttons.next') }}
|
||||
</b-button>
|
||||
<b-button
|
||||
data-test-id="button-next"
|
||||
v-else-if="!tour.isLast"
|
||||
variant="primary"
|
||||
@click="tour.nextStep"
|
||||
>
|
||||
{{ $t('buttons.next') }}
|
||||
</b-button>
|
||||
</div>
|
||||
</v-step>
|
||||
</template>
|
||||
</transition>
|
||||
</template>
|
||||
</v-tour>
|
||||
</template>
|
||||
<script>
|
||||
export default {
|
||||
name: 'TourComponent',
|
||||
i18nOptions: {
|
||||
namespaces: 'onboarding-tour',
|
||||
},
|
||||
props: {
|
||||
name: String,
|
||||
callbacks: {
|
||||
type: Object,
|
||||
},
|
||||
steps: {
|
||||
default: () => [],
|
||||
type: Array,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
labels () {
|
||||
return this.tour.labels
|
||||
},
|
||||
tour () {
|
||||
return {
|
||||
name: this.name,
|
||||
steps: this.steps.map(step => {
|
||||
return {
|
||||
name: step,
|
||||
target: `[data-v-onboarding="${step}"]`,
|
||||
header: {
|
||||
title: this.$t(`steps.${step}.title`),
|
||||
},
|
||||
content: this.$t(`steps.${step}.content`),
|
||||
}
|
||||
}),
|
||||
}
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onStart () {
|
||||
if (JSON.parse(localStorage.getItem('corteza.tour')) && this.tour.steps.length) {
|
||||
this.$tours[this.tour.name].start()
|
||||
}
|
||||
},
|
||||
onStop () {
|
||||
localStorage.setItem('corteza.tour', JSON.stringify(false))
|
||||
this.$tours[this.tour.name].stop()
|
||||
},
|
||||
onStartClick () {
|
||||
localStorage.setItem('corteza.tour', JSON.stringify(true))
|
||||
this.onStart()
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.v-step {
|
||||
background: #5C5C5C;
|
||||
max-width: 320px;
|
||||
z-index: 10000;
|
||||
border-radius: 1.1rem;
|
||||
}
|
||||
.v-step--sticky {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
-webkit-transform: translate(-50%, -50%);
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
.v-step--sticky .v-step__arrow {
|
||||
display: none;
|
||||
}
|
||||
.v-step__arrow,
|
||||
.v-step__arrow:before {
|
||||
position: absolute;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: inherit;
|
||||
}
|
||||
.v-step__arrow {
|
||||
visibility: hidden;
|
||||
}
|
||||
.v-step__arrow--dark:before {
|
||||
background: #5C5C5C;
|
||||
}
|
||||
.v-step__arrow:before {
|
||||
visibility: visible;
|
||||
content: "";
|
||||
-webkit-transform: rotate(45deg);
|
||||
transform: rotate(45deg);
|
||||
margin-left: -5px;
|
||||
}
|
||||
.v-step[data-popper-placement^="top"] > .v-step__arrow {
|
||||
bottom: -5px;
|
||||
}
|
||||
.v-step[data-popper-placement^="bottom"] > .v-step__arrow {
|
||||
top: -5px;
|
||||
}
|
||||
.v-step[data-popper-placement^="right"] > .v-step__arrow {
|
||||
left: -5px;
|
||||
}
|
||||
.v-step[data-popper-placement^="left"] > .v-step__arrow {
|
||||
right: -5px;
|
||||
}
|
||||
</style>
|
||||
49
lib/vue/src/components/Tour/TourStart.vue
Normal file
49
lib/vue/src/components/Tour/TourStart.vue
Normal file
@@ -0,0 +1,49 @@
|
||||
<template>
|
||||
<b-modal
|
||||
id="tourStart"
|
||||
size="lg"
|
||||
:title="$t('start.welcome')"
|
||||
:ok-title="$t('start.show')"
|
||||
:cancel-title="$t('start.skip')"
|
||||
body-class="p-0"
|
||||
@ok="onSave"
|
||||
@cancel="onCancel"
|
||||
>
|
||||
<div
|
||||
class="card-body"
|
||||
>
|
||||
{{ $t('start.instructions') }}
|
||||
</div>
|
||||
</b-modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
i18nOptions: {
|
||||
namespaces: 'onboarding-tour',
|
||||
},
|
||||
|
||||
created () {
|
||||
this.$nextTick(() => {
|
||||
if (window.localStorage.getItem('corteza.tour') === null) {
|
||||
this.$bvModal.show('tourStart')
|
||||
}
|
||||
})
|
||||
},
|
||||
methods: {
|
||||
onSave () {
|
||||
this.updateStorage(true)
|
||||
this.$emit('start')
|
||||
this.showModal = false
|
||||
},
|
||||
onCancel () {
|
||||
this.updateStorage(false)
|
||||
this.showModal = false
|
||||
},
|
||||
updateStorage (status) {
|
||||
window.localStorage.setItem('corteza.tour', JSON.stringify(status))
|
||||
},
|
||||
|
||||
},
|
||||
}
|
||||
</script>
|
||||
2
lib/vue/src/components/Tour/index.ts
Normal file
2
lib/vue/src/components/Tour/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as Tour } from './Tour.vue'
|
||||
export { default as TourStart } from './TourStart.vue'
|
||||
5
lib/vue/src/components/c3/C3.js
Normal file
5
lib/vue/src/components/c3/C3.js
Normal file
@@ -0,0 +1,5 @@
|
||||
import * as itemPicker from '../itemPicker/C3'
|
||||
|
||||
export default {
|
||||
itemPicker,
|
||||
}
|
||||
184
lib/vue/src/components/c3/C3.vue
Normal file
184
lib/vue/src/components/c3/C3.vue
Normal file
@@ -0,0 +1,184 @@
|
||||
<template>
|
||||
<div class="layout">
|
||||
<aside class="sidebar p-2">
|
||||
<h5
|
||||
class="border-bottom">
|
||||
C3: Component Catalogue
|
||||
</h5>
|
||||
<component-list
|
||||
:catalogue="catalogue"
|
||||
@select="setCurrent($event)"
|
||||
/>
|
||||
</aside>
|
||||
|
||||
<main
|
||||
class="p-5"
|
||||
>
|
||||
<component
|
||||
:is="current.component"
|
||||
v-if="current"
|
||||
v-bind="current.props"
|
||||
/>
|
||||
<p
|
||||
v-else
|
||||
class="text-center"
|
||||
>
|
||||
Select a component from the C3 Catalogue and start hacking :)
|
||||
</p>
|
||||
</main>
|
||||
|
||||
<div
|
||||
class="controls px-5 py-2 mt-2"
|
||||
v-if="current"
|
||||
>
|
||||
<div
|
||||
class="control-group mr-2"
|
||||
v-for="(cg, g) in controlGroups"
|
||||
:key="`control-group-${g}`"
|
||||
>
|
||||
<h3>
|
||||
Controls
|
||||
</h3>
|
||||
<component
|
||||
:is="c.component"
|
||||
v-for="(c, i) in cg"
|
||||
:key="i"
|
||||
:value="c.value(current.props)"
|
||||
v-bind="c.props"
|
||||
@update="c.update(current.props, $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="current.scenarios"
|
||||
class="control-group float-right"
|
||||
>
|
||||
<h3>
|
||||
Pre-set controls
|
||||
</h3>
|
||||
<ul
|
||||
class="pl-0"
|
||||
>
|
||||
<li
|
||||
v-for="(s, i) in current.scenarios"
|
||||
:key="i"
|
||||
class="list-unstyled scenario"
|
||||
@click="setScenario(s)"
|
||||
>
|
||||
{{ s.label }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import ComponentList from './ComponentList.vue'
|
||||
|
||||
export default {
|
||||
name: 'C3',
|
||||
|
||||
components: {
|
||||
ComponentList,
|
||||
},
|
||||
|
||||
props: {
|
||||
catalogue: {
|
||||
required: true,
|
||||
type: Object,
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
controlGroups () {
|
||||
if (this.current.controls.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
if (Array.isArray(this.current.controls[0])) {
|
||||
// already grouped
|
||||
return this.current.controls
|
||||
}
|
||||
|
||||
// make one virtual group holding all controls
|
||||
return [this.current.controls]
|
||||
},
|
||||
},
|
||||
|
||||
data () {
|
||||
return {
|
||||
current: undefined,
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
setCurrent (component) {
|
||||
this.current = { props: {}, ...component }
|
||||
this.setScenario(this.current)
|
||||
},
|
||||
|
||||
setScenario ({ props = {}, controls = [] }) {
|
||||
// create missing props from controls
|
||||
const apply = (c, props) => c.update(props, c.value(props) || null)
|
||||
controls.forEach(c => {
|
||||
if (Array.isArray(c)) {
|
||||
c.forEach(c => apply(c, props))
|
||||
} else {
|
||||
apply(c, props)
|
||||
}
|
||||
})
|
||||
|
||||
this.current.props = props
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
<style lang="scss">
|
||||
.layout {
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
|
||||
display: grid;
|
||||
grid-template-rows: auto 400px;
|
||||
grid-template-columns: 300px auto;
|
||||
align-content: stretch;
|
||||
grid-template-areas:
|
||||
"side main"
|
||||
"side controls"
|
||||
;
|
||||
|
||||
aside {
|
||||
grid-area: side;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
main {
|
||||
grid-area: main;
|
||||
background-image: linear-gradient(
|
||||
135deg,
|
||||
#f3f3f5 21.43%,
|
||||
#ffffff 21.43%,
|
||||
#ffffff 50%,
|
||||
#f3f3f5 50%,
|
||||
#f3f3f5 71.43%,
|
||||
#ffffff 71.43%,
|
||||
#ffffff 100%
|
||||
);
|
||||
background-size: 35.00px 35.00px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.controls {
|
||||
grid-area: controls;
|
||||
overflow: auto;
|
||||
|
||||
.control-group {
|
||||
float: left;
|
||||
}
|
||||
}
|
||||
|
||||
.scenario {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
83
lib/vue/src/components/c3/ComponentList.vue
Normal file
83
lib/vue/src/components/c3/ComponentList.vue
Normal file
@@ -0,0 +1,83 @@
|
||||
<template>
|
||||
<div>
|
||||
<h5
|
||||
v-if="!root"
|
||||
class="mb-0"
|
||||
>
|
||||
{{ group }}
|
||||
</h5>
|
||||
|
||||
<div
|
||||
v-if="subgroups.length > 0 || components.length > 0"
|
||||
>
|
||||
<div
|
||||
v-for="(cmp, i) in components"
|
||||
:key="i"
|
||||
@click="$emit('select', cmp)"
|
||||
class="component ml-2"
|
||||
>
|
||||
{{ cmp.name || cmp.component.name || 'Untitled' }}
|
||||
<b-badge
|
||||
v-if="cmp.wip"
|
||||
variant="warning"
|
||||
class="float-right"
|
||||
>
|
||||
wip
|
||||
</b-badge>
|
||||
</div>
|
||||
<component-list
|
||||
v-for="(group) in subgroups"
|
||||
:key="group"
|
||||
:catalogue="catalogue"
|
||||
:path="[...path, group]"
|
||||
@select="$emit('select', $event)"
|
||||
class="my-3"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import {ExtractComponents, ExtractSubgroups} from './helpers.ts'
|
||||
|
||||
export default {
|
||||
name: 'ComponentList',
|
||||
props: {
|
||||
catalogue: {
|
||||
required: true,
|
||||
type: Object,
|
||||
},
|
||||
|
||||
path: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
// name of the current group
|
||||
root () {
|
||||
return this.path.length === 0
|
||||
},
|
||||
|
||||
// name of the current group
|
||||
group () {
|
||||
return this.root ? undefined : this.path[this.path.length - 1]
|
||||
},
|
||||
|
||||
// returns all groups at this level
|
||||
subgroups () {
|
||||
return ExtractSubgroups(this.catalogue, ...this.path)
|
||||
},
|
||||
|
||||
components () {
|
||||
// return ExtractComponents(this.catalogue, ...this.path)
|
||||
return ExtractComponents(this.catalogue, ...this.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.component {
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
24
lib/vue/src/components/c3/Controls/C3Checkbox.vue
Normal file
24
lib/vue/src/components/c3/Controls/C3Checkbox.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<template>
|
||||
<b-form-group>
|
||||
<b-form-checkbox
|
||||
:checked="value"
|
||||
@change="$emit('update', $event)"
|
||||
>
|
||||
{{ label }}
|
||||
</b-form-checkbox>
|
||||
</b-form-group>
|
||||
</template>
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
label: {
|
||||
required: true,
|
||||
type: String,
|
||||
},
|
||||
value: {
|
||||
type: Boolean,
|
||||
default: () => false,
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
25
lib/vue/src/components/c3/Controls/C3Input.vue
Normal file
25
lib/vue/src/components/c3/Controls/C3Input.vue
Normal file
@@ -0,0 +1,25 @@
|
||||
<template>
|
||||
<b-form-group
|
||||
:label="label"
|
||||
>
|
||||
<b-form-input
|
||||
:value="value"
|
||||
@update="$emit('update', $event)"
|
||||
/>
|
||||
</b-form-group>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
label: {
|
||||
required: true,
|
||||
type: String,
|
||||
},
|
||||
value: {
|
||||
type: String,
|
||||
default: () => '',
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
30
lib/vue/src/components/c3/Controls/C3Select.vue
Normal file
30
lib/vue/src/components/c3/Controls/C3Select.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<b-form-group
|
||||
:label="label"
|
||||
>
|
||||
<b-form-select
|
||||
:value="value"
|
||||
:options="options"
|
||||
@input="$emit('update', $event)"
|
||||
/>
|
||||
</b-form-group>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
label: {
|
||||
required: true,
|
||||
type: String,
|
||||
},
|
||||
value: {
|
||||
type: String,
|
||||
default: () => undefined,
|
||||
},
|
||||
options: {
|
||||
default: () => [],
|
||||
type: Array,
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
25
lib/vue/src/components/c3/Controls/C3Textarea.vue
Normal file
25
lib/vue/src/components/c3/Controls/C3Textarea.vue
Normal file
@@ -0,0 +1,25 @@
|
||||
<template>
|
||||
<b-form-group
|
||||
:label="label"
|
||||
>
|
||||
<b-form-textarea
|
||||
:value="value"
|
||||
@update="$emit('update', $event)"
|
||||
/>
|
||||
</b-form-group>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
label: {
|
||||
required: true,
|
||||
type: String,
|
||||
},
|
||||
value: {
|
||||
type: String,
|
||||
default: () => '',
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
67
lib/vue/src/components/c3/Controls/index.ts
Normal file
67
lib/vue/src/components/c3/Controls/index.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import cmpInput from './C3Input.vue'
|
||||
import cmpTextarea from './C3Textarea.vue'
|
||||
import cmpCheckbox from './C3Checkbox.vue'
|
||||
import cmpSelect from './C3Select.vue'
|
||||
import { Component } from 'vue'
|
||||
import _ from 'lodash'
|
||||
|
||||
interface Handler {
|
||||
value (props: object): unknown;
|
||||
update (props: object, value: unknown): void;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
[_: string]: unknown;
|
||||
}
|
||||
|
||||
interface Control extends Handler {
|
||||
component: Component;
|
||||
props?: Props;
|
||||
}
|
||||
|
||||
interface Specs {
|
||||
handler: string | Handler;
|
||||
props?: Props;
|
||||
}
|
||||
|
||||
function makeHandler (prop: string): Handler {
|
||||
const path = prop.split('.')
|
||||
return {
|
||||
value: (props: Props): unknown => _.get(props, path),
|
||||
update: (props: Props, value: unknown): void => { _.set(props, path, value) },
|
||||
}
|
||||
}
|
||||
|
||||
export function generic (component: Component, { handler, props }: Specs): Control {
|
||||
if (typeof handler === 'string') {
|
||||
handler = makeHandler(handler)
|
||||
}
|
||||
|
||||
return {
|
||||
component,
|
||||
props,
|
||||
...handler,
|
||||
}
|
||||
}
|
||||
|
||||
export function input (label: string, handler: string | Handler): Control {
|
||||
return generic(cmpInput, { handler, props: { label } })
|
||||
}
|
||||
|
||||
export function textarea (label: string, handler: string | Handler): Control {
|
||||
return generic(cmpTextarea, { handler, props: { label } })
|
||||
}
|
||||
|
||||
export function checkbox (label: string, handler: string | Handler): Control {
|
||||
return generic(cmpCheckbox, { handler, props: { label } })
|
||||
}
|
||||
|
||||
export function select (label: string, handler: string | Handler, options: object): Control {
|
||||
return generic(cmpSelect, {
|
||||
handler,
|
||||
props: {
|
||||
label,
|
||||
options,
|
||||
},
|
||||
})
|
||||
}
|
||||
50
lib/vue/src/components/c3/helpers.test.ts
Normal file
50
lib/vue/src/components/c3/helpers.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { expect } from 'chai'
|
||||
import { ExtractComponents, extractGroups } from './helpers'
|
||||
|
||||
const case1 = [['a'], ['a', 'b'], ['a', 'b'], ['a', 'c', 'd', '123'], ['c'], []]
|
||||
|
||||
describe(__filename, () => {
|
||||
describe('extractGroups', () => {
|
||||
it('return empty array when there are no groups', () => {
|
||||
expect(extractGroups([])).to.be.empty
|
||||
})
|
||||
it('should return unique groups on the 1st level', () => {
|
||||
expect(extractGroups(case1)).to.eql(['a', 'c'])
|
||||
})
|
||||
it('should return unique groups on the 2nd level', () => {
|
||||
expect(extractGroups(case1, 'a')).to.eql(['b', 'c'])
|
||||
})
|
||||
it('should return nothing on nonexisgin paths', () => {
|
||||
expect(extractGroups(case1, 'foo')).to.eql([])
|
||||
})
|
||||
it('should properly handle deep paths', () => {
|
||||
expect(extractGroups(case1, 'a', 'c', 'd')).to.eql(['123'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('ExtractComponents', () => {
|
||||
it('nothing in, nothing out', () => {
|
||||
expect(ExtractComponents({})).to.be.empty
|
||||
})
|
||||
|
||||
it('match all on empty path', () => {
|
||||
expect(ExtractComponents({
|
||||
a: { name: 'a' },
|
||||
b: { name: 'b' },
|
||||
c: { name: 'c', group: ['foo'] },
|
||||
})).to.be.eql([
|
||||
{ name: 'a' },
|
||||
{ name: 'b' },
|
||||
])
|
||||
})
|
||||
|
||||
it('root match', () => {
|
||||
expect(ExtractComponents({
|
||||
a: { name: 'a', group: ['foo'] },
|
||||
b: { name: 'b' },
|
||||
}, 'foo')).to.be.eql([
|
||||
{ name: 'a', group: ['foo'] },
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
||||
54
lib/vue/src/components/c3/helpers.ts
Normal file
54
lib/vue/src/components/c3/helpers.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
export interface Catalogue {
|
||||
[_: string]: CatalogueItem;
|
||||
}
|
||||
|
||||
export interface CatalogueItem {
|
||||
group?: Array<string>;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
const del = '|||'
|
||||
|
||||
/**
|
||||
* Extract subgroups from the given groups
|
||||
* @param groups
|
||||
* @param path Array of groups that represent the path
|
||||
*/
|
||||
export function extractGroups (groups: Array<Array<string>>, ...path: Array<string>): Array<string> {
|
||||
const jpath = path.join(del)
|
||||
return [...(new Set(
|
||||
groups
|
||||
.filter(g => g.slice(0, path.length).join(del) === jpath)
|
||||
.map(g => g.slice(path.length, path.length + 1)[0])
|
||||
.filter(g => !!g),
|
||||
)).values()]
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract subgroups from the catalogue that match the given path
|
||||
*
|
||||
* @param cat Catalogue
|
||||
* @param path Array of groups that represent the path
|
||||
* @constructor
|
||||
*/
|
||||
export function ExtractSubgroups (cat: Catalogue, ...path: Array<string>): Array<string> {
|
||||
const groups = Object.values(cat)
|
||||
.map(({ group }) => group)
|
||||
.filter(g => g !== undefined) as Array<Array<string>>
|
||||
|
||||
return extractGroups(groups, ...path)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all components that match the given path
|
||||
*
|
||||
* @param cat Catalogue
|
||||
* @param path Array of groups that represent the path
|
||||
|
||||
*/
|
||||
export function ExtractComponents (cat: Catalogue, ...path: Array<string>): Array<CatalogueItem> {
|
||||
const jpath = path.join(del)
|
||||
|
||||
return Object.values(cat)
|
||||
.filter(({ group }) => (group || []).join(del) === jpath)
|
||||
}
|
||||
7
lib/vue/src/components/c3/index.ts
Normal file
7
lib/vue/src/components/c3/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import View from './C3.vue'
|
||||
import * as controls from './Controls'
|
||||
|
||||
export default {
|
||||
View,
|
||||
controls,
|
||||
}
|
||||
28
lib/vue/src/components/chart/CChart.vue
Normal file
28
lib/vue/src/components/chart/CChart.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<e-charts
|
||||
:option="chart"
|
||||
:theme="theme"
|
||||
autoresize
|
||||
class="position-absolute w-100 h-100 overflow-hidden"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { shared } from '@cortezaproject/corteza-js'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
chart: {
|
||||
type: shared.Chart,
|
||||
required: true,
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
theme () {
|
||||
const { darkMode } = this.chart || {}
|
||||
return darkMode ? 'dark' : 'light'
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
1
lib/vue/src/components/chart/index.ts
Normal file
1
lib/vue/src/components/chart/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as CChart } from './CChart.vue'
|
||||
47
lib/vue/src/components/corredor/CCorredorManualButtons.vue
Normal file
47
lib/vue/src/components/corredor/CCorredorManualButtons.vue
Normal file
@@ -0,0 +1,47 @@
|
||||
<template>
|
||||
<b-button-group>
|
||||
<b-button
|
||||
v-for="(b, i) in buttons"
|
||||
:key="i"
|
||||
:variant="b.variant || defaultVariant"
|
||||
:class="buttonClass"
|
||||
@click="$emit('click', b)"
|
||||
>
|
||||
{{ b.label }}
|
||||
</b-button>
|
||||
</b-button-group>
|
||||
</template>
|
||||
|
||||
<script lang="js">
|
||||
export default {
|
||||
props: {
|
||||
resourceType: {},
|
||||
|
||||
uiSlot: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
|
||||
uiPage: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
|
||||
defaultVariant: {
|
||||
type: String,
|
||||
default: () => 'link'
|
||||
},
|
||||
|
||||
buttonClass: {
|
||||
type: String,
|
||||
default: () => 'mr-1'
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
buttons () {
|
||||
return this.$UIHooks.Find(this.resourceType, this.uiPage, this.uiSlot)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
1
lib/vue/src/components/corredor/index.ts
Normal file
1
lib/vue/src/components/corredor/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as CCorredorManualButtons } from './CCorredorManualButtons.vue'
|
||||
28
lib/vue/src/components/filePreview/CPreviewInline.vue
Normal file
28
lib/vue/src/components/filePreview/CPreviewInline.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<div class="inline">
|
||||
<main>
|
||||
<component
|
||||
:is="previewType"
|
||||
v-bind="$attrs"
|
||||
:max-pages="1"
|
||||
:inline="inline"
|
||||
:initial-scale="1.5"
|
||||
v-on="$listeners"
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="js">
|
||||
import { InlineIMG as IMG, PDF } from './common/types'
|
||||
import base from './common/base.vue'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
IMG,
|
||||
PDF,
|
||||
},
|
||||
|
||||
extends: base,
|
||||
}
|
||||
</script>
|
||||
38
lib/vue/src/components/filePreview/CPreviewLightbox.vue
Normal file
38
lib/vue/src/components/filePreview/CPreviewLightbox.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<template>
|
||||
<c-lightbox v-on="$listeners">
|
||||
<!-- Proxy slots -->
|
||||
<template
|
||||
v-for="(_, slot) of $scopedSlots"
|
||||
v-slot:[slot]="scope"
|
||||
>
|
||||
<slot
|
||||
:name="slot"
|
||||
v-bind="scope"
|
||||
v-on="$listeners"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<component
|
||||
:is="previewType"
|
||||
slot="main"
|
||||
v-bind="$attrs"
|
||||
v-on="$listeners"
|
||||
/>
|
||||
</c-lightbox>
|
||||
</template>
|
||||
|
||||
<script lang="js">
|
||||
import { LightboxIMG as IMG, PDF } from './common/types'
|
||||
import { CLightbox } from '../lightbox/index.ts'
|
||||
import base from './common/base.vue'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
CLightbox,
|
||||
IMG,
|
||||
PDF,
|
||||
},
|
||||
|
||||
extends: base,
|
||||
}
|
||||
</script>
|
||||
24
lib/vue/src/components/filePreview/common/base.vue
Normal file
24
lib/vue/src/components/filePreview/common/base.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<script lang="js">
|
||||
import { getComponent } from './index.js'
|
||||
|
||||
export default {
|
||||
inheritAttrs: false,
|
||||
|
||||
props: {
|
||||
inline: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
previewType () {
|
||||
const component = getComponent({ type: this.$attrs.mime, src: this.$attrs.src, name: this.$attrs.name })
|
||||
if (!component) {
|
||||
this.$emit('previewUnavailable', { type: this.$attrs.mime })
|
||||
}
|
||||
return component
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
38
lib/vue/src/components/filePreview/common/index.js
Normal file
38
lib/vue/src/components/filePreview/common/index.js
Normal file
@@ -0,0 +1,38 @@
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
import mime from 'mime-types'
|
||||
|
||||
const types = [
|
||||
{ type: 'application/pdf', component: 'PDF' },
|
||||
{ type: 'image/', component: 'IMG' },
|
||||
]
|
||||
|
||||
/**
|
||||
* Tells what component (if any) can preview the given file
|
||||
* @param {String|undefined} type pre defined mime type of object
|
||||
* @param {String|undefined} src object's src
|
||||
* @param {String|undefined} name object's name
|
||||
* @returns {String|undefined} preview component or undefined
|
||||
*/
|
||||
export const getComponent = ({ type, src, name }) => {
|
||||
const srcType = type || mime.lookup(src) || mime.lookup(name)
|
||||
if (!srcType) {
|
||||
return
|
||||
}
|
||||
|
||||
for (const { type, component } of types) {
|
||||
if (srcType.indexOf(type) >= 0) {
|
||||
return component
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tells if we support the given file type preview
|
||||
* @param {String|undefined} type pre defined mime type of object
|
||||
* @param {String|undefined} src object's src
|
||||
* @param {String|undefined} name object's name
|
||||
* @returns {Boolean} if file can be previewed
|
||||
*/
|
||||
export const canPreview = ({ type, src, name }) => {
|
||||
return !!getComponent({ type, src, name })
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<div class="inline">
|
||||
<img
|
||||
ref="image"
|
||||
:key="src"
|
||||
:src="src"
|
||||
:title="title"
|
||||
:alt="alt"
|
||||
:class="getClass"
|
||||
:style="previewStyle"
|
||||
:width="getWidth"
|
||||
:height="getHeight"
|
||||
@click="$emit('openPreview', {})"
|
||||
@error.once="reloadBrokenImage"
|
||||
@load="loaded=true"
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="js">
|
||||
import base from '../base.vue'
|
||||
|
||||
export default {
|
||||
extends: base,
|
||||
|
||||
props: {
|
||||
alt: {
|
||||
required: false,
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
title: {
|
||||
required: false,
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
|
||||
data () {
|
||||
return {
|
||||
loaded: false,
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
getClass () {
|
||||
const rtr = [...this.previewClass]
|
||||
if (this.$listeners.click) {
|
||||
rtr.push('clickable')
|
||||
}
|
||||
if (this.loaded) {
|
||||
rtr.push('loaded')
|
||||
}
|
||||
return rtr
|
||||
},
|
||||
|
||||
getWidth () {
|
||||
return this.meta.preview.image.width
|
||||
},
|
||||
getHeight () {
|
||||
return this.meta.preview.image.height
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
reloadBrokenImage (ev) {
|
||||
if (ev.target && ev.target.src) {
|
||||
window.setTimeout(() => {
|
||||
if (!ev.target && !ev.target.src) return
|
||||
|
||||
// This forces Vue to re-try image download
|
||||
// eslint-disable-next-line
|
||||
ev.target.src = ev.target.src
|
||||
}, 500)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
div {
|
||||
object-fit: contain;
|
||||
img {
|
||||
&.loaded {
|
||||
width: auto;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
&.inline {
|
||||
img {
|
||||
cursor: zoom-in;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,79 @@
|
||||
<template>
|
||||
<div class="popup-img-preview">
|
||||
<photo-swipe
|
||||
:is-open="true"
|
||||
:items="items"
|
||||
:options="options"
|
||||
@close="() => $emit('close')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="js">
|
||||
import { PhotoSwipe } from 'v-photoswipe'
|
||||
import base from '../base.vue'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
PhotoSwipe,
|
||||
},
|
||||
|
||||
extends: base,
|
||||
|
||||
data () {
|
||||
return {
|
||||
options: {
|
||||
index: 0,
|
||||
bgOpacity: 0,
|
||||
closeOnScroll: false,
|
||||
escKey: false,
|
||||
history: false,
|
||||
arrowKeys: false,
|
||||
modal: false,
|
||||
|
||||
closeEl: false,
|
||||
captionEl: false,
|
||||
fullscreenEl: false,
|
||||
zoomEl: false,
|
||||
shareEl: false,
|
||||
counterEl: false,
|
||||
arrowEl: false,
|
||||
preloaderEl: false,
|
||||
|
||||
clickToCloseNonZoomable: false,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
items () {
|
||||
const { original, preview } = this.meta
|
||||
const image = (original || preview || {}).image
|
||||
if (!image) {
|
||||
this.$emit('close')
|
||||
return []
|
||||
}
|
||||
|
||||
return [{
|
||||
src: this.src,
|
||||
w: image.width,
|
||||
h: image.height,
|
||||
}]
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.popup-img-preview {
|
||||
.pswp {
|
||||
pointer-events: none;
|
||||
.pswp__img {
|
||||
pointer-events: all;
|
||||
}
|
||||
}
|
||||
.pswp__top-bar {
|
||||
display: none!important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
359
lib/vue/src/components/filePreview/common/types/PDF/PDF.vue
Normal file
359
lib/vue/src/components/filePreview/common/types/PDF/PDF.vue
Normal file
@@ -0,0 +1,359 @@
|
||||
<template>
|
||||
<div
|
||||
:style="previewStyle"
|
||||
:class="[...previewClass, 'pdf-preview', inline ? 'inline' : '', $listeners.click ? 'clickable' : '']"
|
||||
@click="onPreviewClick"
|
||||
>
|
||||
<!-- Container for pdf's pages -->
|
||||
<div
|
||||
v-show="show"
|
||||
ref="pages"
|
||||
class="pages"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="loadError"
|
||||
class="doc-msg doc-err"
|
||||
>
|
||||
<div>
|
||||
<h4 class="err-message">
|
||||
{{ loadError.message }}
|
||||
</h4>
|
||||
<p v-if="labels.clickToRetry">
|
||||
{{ labels.clickToRetry }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="!show && labels.loading"
|
||||
class="doc-msg doc-err"
|
||||
>
|
||||
<p>{{ labels.loading }}</p>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="!pageCount && labels.noPages"
|
||||
class="doc-msg doc-err"
|
||||
>
|
||||
<p>{{ labels.noPages }}</p>
|
||||
</div>
|
||||
<template v-else>
|
||||
<div v-if="!inline && labels.downloadForAll && show && hasMore">
|
||||
<p>{{ labels.downloadForAll }}</p>
|
||||
</div>
|
||||
<div v-else-if="inline && labels.firstPagePreview && show">
|
||||
<p>{{ labels.firstPagePreview }}</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="js">
|
||||
import pdfjs from 'pdfjs-dist'
|
||||
import pdfjsWorker from 'pdfjs-dist/build/pdf.worker.entry'
|
||||
import base from '../base.vue'
|
||||
import { makePlaceholder, makeFailedPage, Page, Document } from './helpers'
|
||||
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = pdfjsWorker
|
||||
|
||||
function sleep (t) {
|
||||
return new Promise(resolve => setTimeout(resolve, t))
|
||||
}
|
||||
|
||||
export default {
|
||||
extends: base,
|
||||
|
||||
props: {
|
||||
retryBackoff: {
|
||||
type: Number,
|
||||
required: false,
|
||||
default: 300,
|
||||
},
|
||||
maxRetries: {
|
||||
type: Number,
|
||||
required: false,
|
||||
default: 10,
|
||||
},
|
||||
|
||||
maxPages: {
|
||||
required: false,
|
||||
type: Number,
|
||||
default: 5,
|
||||
},
|
||||
|
||||
initialScale: {
|
||||
required: false,
|
||||
type: Number,
|
||||
default: 1,
|
||||
},
|
||||
},
|
||||
|
||||
data () {
|
||||
return {
|
||||
document: null,
|
||||
pages: [],
|
||||
show: false,
|
||||
loadError: undefined,
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
/**
|
||||
* Helper to provide the number of pages for the given PDF
|
||||
* @returns {Number}
|
||||
*/
|
||||
pageCount () {
|
||||
if (!this.document || !this.document.pdf) {
|
||||
return 0
|
||||
}
|
||||
return this.document.pdf.numPages
|
||||
},
|
||||
|
||||
/**
|
||||
* Helper to determine if the given PDF has more pages then we are willing to show
|
||||
* @returns {Boolean}
|
||||
*/
|
||||
hasMore () {
|
||||
return this.maxPages < this.pageCount
|
||||
},
|
||||
},
|
||||
|
||||
created () {
|
||||
if (!this.src) {
|
||||
this.stdErr(new Error('src.missing'))
|
||||
return
|
||||
}
|
||||
|
||||
this.$nextTick(() => this.init())
|
||||
},
|
||||
|
||||
methods: {
|
||||
/**
|
||||
* Helper to handle on preview click. It either requests a retry or
|
||||
* emits an open event
|
||||
*/
|
||||
onPreviewClick () {
|
||||
if (this.loadError) {
|
||||
this.init()
|
||||
} else {
|
||||
this.$emit('openPreview', { document: this.document })
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Initializes the state, loads the document & renderes it's pages
|
||||
*/
|
||||
async init () {
|
||||
this.document = null
|
||||
this.pages = []
|
||||
this.show = false
|
||||
this.loadError = undefined
|
||||
|
||||
return this.loadDocument(this.src)
|
||||
.then(this.renderDocument)
|
||||
.catch(this.stdErr)
|
||||
},
|
||||
|
||||
/**
|
||||
* Helper method to load the given document. Needed for test stubbing
|
||||
* @param {String} src Document's src
|
||||
* @returns {Promise<PDFDocumentProxy>}
|
||||
*/
|
||||
async pdfjsLoad (src) {
|
||||
return pdfjs.getDocument(src).promise
|
||||
},
|
||||
|
||||
/**
|
||||
* Loads the given PDF. It can load it from API or re-use existing document
|
||||
* @param {Document|String} src PDF's source or a Document object
|
||||
* @returns {Document}
|
||||
*/
|
||||
async loadDocument (src) {
|
||||
if (src instanceof Document) {
|
||||
this.document = new Document({ ...src, scale: this.initialScale })
|
||||
} else if (typeof src === 'string') {
|
||||
let retries = 0
|
||||
let err
|
||||
const pdfl = async () => {
|
||||
return sleep(retries * this.retryBackoff)
|
||||
.then(() => this.pdfjsLoad(src))
|
||||
.then(pdf => {
|
||||
this.document = new Document({ pdf, src, scale: this.initialScale })
|
||||
})
|
||||
}
|
||||
|
||||
// Retry with backoff it it fails to load
|
||||
while (!this.document && retries < this.maxRetries) {
|
||||
await pdfl().catch(e => {
|
||||
retries++
|
||||
err = e
|
||||
})
|
||||
}
|
||||
|
||||
if (!this.document) {
|
||||
throw err
|
||||
}
|
||||
} else {
|
||||
throw new Error('src.notValid')
|
||||
}
|
||||
return this.document
|
||||
},
|
||||
|
||||
/**
|
||||
* Renders the given PDF
|
||||
* @param {Document} doc The Document to render
|
||||
* @return {Promise<undefined>}
|
||||
*/
|
||||
async renderDocument (doc) {
|
||||
const rf = this.$refs.pages
|
||||
|
||||
const pgCount = Math.min(this.pageCount, this.maxPages)
|
||||
this.pages = [...new Array(pgCount)].map((_, i) => new Page({ index: i }))
|
||||
|
||||
if (pgCount <= 0) {
|
||||
this.show = true
|
||||
return
|
||||
}
|
||||
|
||||
// Loadup pages
|
||||
for (let i = 0; i < pgCount; i++) {
|
||||
const node = makePlaceholder(this.labels)
|
||||
rf.appendChild(node)
|
||||
this.pages.splice(i, 1, new Page({ ...this.pages[i], node, loading: true }))
|
||||
|
||||
this.renderPage(this.pages[i], doc, rf)
|
||||
.then(page => {
|
||||
this.pages.splice(page.index, 1, page)
|
||||
|
||||
if (page.index === 0) {
|
||||
this.show = true
|
||||
}
|
||||
})
|
||||
.catch(this.stdErr)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Renders the given page
|
||||
* @param {Page} page The page in question
|
||||
* @param {Document} doc Page source
|
||||
* @param {Node} rf PDF container
|
||||
* @returns {Promise<undefined>}
|
||||
*/
|
||||
async renderPage (page, doc, rf) {
|
||||
// pdfjs starts with 1!
|
||||
return doc.pdf.getPage(page.index + 1).then(p => {
|
||||
const np = new Page(page)
|
||||
np.loading = false
|
||||
np.loaded = true
|
||||
np.page = p
|
||||
|
||||
// Render page
|
||||
const canvas = document.createElement('canvas')
|
||||
const scale = doc.scale
|
||||
const viewport = np.page.getViewport(scale)
|
||||
const canvasContext = canvas.getContext('2d')
|
||||
const renderContext = { canvasContext, viewport }
|
||||
canvas.height = viewport.height
|
||||
canvas.width = viewport.width
|
||||
|
||||
return np.page.render(renderContext).then(() => {
|
||||
np.node = canvas
|
||||
np.rendered = true
|
||||
if (this.inline) {
|
||||
canvas.classList.add('inline')
|
||||
}
|
||||
return np
|
||||
})
|
||||
|
||||
.catch(() => {
|
||||
const node = makeFailedPage(this.labels)
|
||||
np.node = node
|
||||
np.rendered = false
|
||||
np.failed = true
|
||||
return np
|
||||
})
|
||||
|
||||
.then(np => {
|
||||
rf.replaceChild(np.node, page.node)
|
||||
return np
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Standard error handler
|
||||
* @param {Error} err The error
|
||||
*/
|
||||
stdErr (err) {
|
||||
this.loadError = err
|
||||
this.$emit('error', err)
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
$white: white !default;
|
||||
$danger: red !default;
|
||||
|
||||
.doc-msg {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background-color: $white;
|
||||
}
|
||||
.doc-err {
|
||||
cursor: pointer;
|
||||
|
||||
.err-message {
|
||||
color: $danger;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
// Style not scoped, since pages are manually rendered
|
||||
|
||||
.pdf-preview {
|
||||
text-align: center;
|
||||
&:not(.inline) {
|
||||
padding-top: 20px;
|
||||
padding-bottom: 20px;
|
||||
canvas {
|
||||
box-shadow: 0 0 3px #1E1E1E41;
|
||||
}
|
||||
}
|
||||
&.inline {
|
||||
height: 200px;
|
||||
overflow-y: scroll;
|
||||
display: inline-block;
|
||||
cursor: zoom-in;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
canvas {
|
||||
margin-bottom: 10px;
|
||||
&.inline {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&:not(.inline) {
|
||||
margin: 0 auto 10px auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
&:last-of-type {
|
||||
margin-bottom: unset;
|
||||
}
|
||||
}
|
||||
|
||||
.loader {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,64 @@
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
/**
|
||||
* Represents a PDF's page + system metadata
|
||||
*/
|
||||
export class Page {
|
||||
constructor (params) {
|
||||
this.merge(params)
|
||||
}
|
||||
|
||||
merge ({ index, page, loading, loaded, rendered, failed, node }) {
|
||||
this.index = index !== undefined ? index : this.index
|
||||
this.page = page !== undefined ? page : this.page
|
||||
this.loading = !!loading !== undefined ? loading : this.loading
|
||||
this.loaded = !!loaded !== undefined ? loaded : this.loaded
|
||||
this.rendered = !!rendered !== undefined ? rendered : this.rendered
|
||||
this.failed = !!failed !== undefined ? failed : this.failed
|
||||
this.node = node !== undefined ? node : this.node
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the document source
|
||||
*/
|
||||
export class Document {
|
||||
constructor (params) {
|
||||
this.merge(params)
|
||||
}
|
||||
|
||||
merge ({ src, pdf, loading, loaded, failed, scale }) {
|
||||
this.src = src || this.src
|
||||
this.pdf = pdf || this.pdf
|
||||
this.loading = loading || this.loading
|
||||
this.loaded = loaded || this.loaded
|
||||
this.failed = failed || this.failed
|
||||
this.scale = scale || this.scale
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to generate a placeholder page node
|
||||
* @param {Object} labels Available labels
|
||||
* @returns {Node}
|
||||
*/
|
||||
export function makePlaceholder (labels) {
|
||||
const node = document.createElement('div')
|
||||
node.classList.add('loader')
|
||||
if (labels.pageLoading) {
|
||||
node.innerHTML = labels.pageLoading
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to generate a failed page node
|
||||
* @param {Object} labels Available labels
|
||||
*/
|
||||
export function makeFailedPage (labels) {
|
||||
const node = document.createElement('div')
|
||||
node.classList.add('failed')
|
||||
if (labels.pageLoadFailed) {
|
||||
node.innerHTML = labels.pageLoadFailed
|
||||
}
|
||||
return node
|
||||
}
|
||||
52
lib/vue/src/components/filePreview/common/types/base.vue
Normal file
52
lib/vue/src/components/filePreview/common/types/base.vue
Normal file
@@ -0,0 +1,52 @@
|
||||
<script lang="js">
|
||||
export default {
|
||||
props: {
|
||||
labels: {
|
||||
required: false,
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
|
||||
src: {
|
||||
required: true,
|
||||
type: [ String, Object ],
|
||||
default: undefined,
|
||||
},
|
||||
|
||||
mime: {
|
||||
required: false,
|
||||
type: String,
|
||||
default: undefined,
|
||||
},
|
||||
|
||||
meta: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
|
||||
name: {
|
||||
required: false,
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
|
||||
previewStyle: {
|
||||
required: false,
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
|
||||
previewClass: {
|
||||
required: false,
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
|
||||
inline: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
</script>
|
||||
3
lib/vue/src/components/filePreview/common/types/index.js
Normal file
3
lib/vue/src/components/filePreview/common/types/index.js
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as InlineIMG } from './IMG/InlineIMG.vue'
|
||||
export { default as LightboxIMG } from './IMG/LightboxIMG.vue'
|
||||
export { default as PDF } from './PDF/PDF.vue'
|
||||
3
lib/vue/src/components/filePreview/index.ts
Normal file
3
lib/vue/src/components/filePreview/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as CPreviewLightbox } from './CPreviewLightbox.vue'
|
||||
export { default as CPreviewInline } from './CPreviewInline.vue'
|
||||
export { canPreview } from './common/index.js'
|
||||
80
lib/vue/src/components/index.ts
Normal file
80
lib/vue/src/components/index.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
export {
|
||||
CCorredorManualButtons,
|
||||
} from './corredor'
|
||||
|
||||
export {
|
||||
CPreviewInline,
|
||||
CPreviewLightbox,
|
||||
canPreview,
|
||||
} from './filePreview'
|
||||
|
||||
export {
|
||||
CInputConfirm,
|
||||
CInputProcessing,
|
||||
CInputDateTime,
|
||||
CInputPresort,
|
||||
CInputCron,
|
||||
CInputSearch,
|
||||
CRichTextInput,
|
||||
} from './input'
|
||||
|
||||
export {
|
||||
CLightbox,
|
||||
} from './lightbox'
|
||||
|
||||
export {
|
||||
CPermissionsButton,
|
||||
CPermissionsModal,
|
||||
} from './permissions'
|
||||
|
||||
export * from './prompts'
|
||||
|
||||
export {
|
||||
CLoaderLogo,
|
||||
} from './loader'
|
||||
|
||||
export {
|
||||
CTopbar,
|
||||
CSidebar,
|
||||
CSidebarNavItems,
|
||||
} from './navigation'
|
||||
|
||||
export {
|
||||
Tour,
|
||||
TourStart,
|
||||
} from './Tour'
|
||||
|
||||
export {
|
||||
CToaster,
|
||||
CToasterLink,
|
||||
} from './toaster'
|
||||
|
||||
export {
|
||||
CReminderSidebar,
|
||||
} from './reminders'
|
||||
|
||||
export {
|
||||
CItemPicker,
|
||||
CItemPickerItem,
|
||||
} from './itemPicker'
|
||||
|
||||
export {
|
||||
CReportChart,
|
||||
CReportMetric,
|
||||
CReportTable,
|
||||
CReportText,
|
||||
} from './reporter'
|
||||
|
||||
export {
|
||||
CSensitivityLevelPicker,
|
||||
} from './privacy'
|
||||
|
||||
export {
|
||||
CResourceList,
|
||||
} from './resourceList'
|
||||
|
||||
export {
|
||||
CChart,
|
||||
} from './chart'
|
||||
|
||||
export { default as C3 } from './c3'
|
||||
117
lib/vue/src/components/input/CInputConfirm.vue
Normal file
117
lib/vue/src/components/input/CInputConfirm.vue
Normal file
@@ -0,0 +1,117 @@
|
||||
<template>
|
||||
<span class="text-center">
|
||||
<span v-if="!inConfirmation">
|
||||
<b-button
|
||||
data-test-id="button-delete"
|
||||
:variant="variant"
|
||||
:size="size"
|
||||
:disabled="disabled"
|
||||
:class="`${buttonClass} ${borderless ? 'border-0' : ''}`"
|
||||
@click.prevent="onPrompt"
|
||||
>
|
||||
<slot>
|
||||
<font-awesome-icon
|
||||
:icon="['far', 'trash-alt']"
|
||||
/>
|
||||
</slot>
|
||||
</b-button>
|
||||
|
||||
</span>
|
||||
<span v-else>
|
||||
<b-button
|
||||
data-test-id="button-delete-confirm"
|
||||
:variant="variantOk"
|
||||
:size="sizeConfirm"
|
||||
:disabled="okDisabled"
|
||||
class="mr-1"
|
||||
:class="[ borderless && 'border-0' ]"
|
||||
@blur.prevent="onCancel()"
|
||||
@click.prevent="onConfirmation()"
|
||||
>
|
||||
<slot name="yes">
|
||||
<font-awesome-icon
|
||||
:icon="['fas', 'check']"
|
||||
/>
|
||||
</slot>
|
||||
</b-button>
|
||||
<b-button
|
||||
data-test-id="button-delete-cancel"
|
||||
:variant="variantCancel"
|
||||
:size="sizeConfirm"
|
||||
:disabled="cancelDisabled"
|
||||
:class="[ borderless && 'border-0' ]"
|
||||
@click.prevent="onCancel()"
|
||||
>
|
||||
<slot name="no">
|
||||
<font-awesome-icon
|
||||
:icon="['fas', 'times']"
|
||||
/></slot>
|
||||
</b-button>
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
<script lang="js">
|
||||
export default {
|
||||
props: {
|
||||
disabled: Boolean,
|
||||
okDisabled: Boolean,
|
||||
cancelDisabled: Boolean,
|
||||
noPrompt: Boolean,
|
||||
|
||||
buttonClass: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
borderless: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
variant: {
|
||||
type: String,
|
||||
default: 'outline-danger',
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: 'sm',
|
||||
},
|
||||
variantOk: {
|
||||
type: String,
|
||||
default: 'danger',
|
||||
},
|
||||
variantCancel: {
|
||||
type: String,
|
||||
default: undefined,
|
||||
},
|
||||
sizeConfirm: {
|
||||
type: String,
|
||||
default: 'sm',
|
||||
},
|
||||
},
|
||||
|
||||
data () {
|
||||
return {
|
||||
inConfirmation: false,
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
onPrompt () {
|
||||
if (this.noPrompt) {
|
||||
this.$emit('confirmed')
|
||||
} else {
|
||||
this.inConfirmation = true
|
||||
}
|
||||
},
|
||||
|
||||
onConfirmation () {
|
||||
this.inConfirmation = false
|
||||
this.$emit('confirmed')
|
||||
},
|
||||
|
||||
onCancel () {
|
||||
this.inConfirmation = false
|
||||
this.$emit('canceled')
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
30
lib/vue/src/components/input/CInputCron.vue
Normal file
30
lib/vue/src/components/input/CInputCron.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<template>
|
||||
|
||||
</template>
|
||||
<script lang="js">
|
||||
export default {
|
||||
props: {
|
||||
value: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
|
||||
simple: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
cronValue: {
|
||||
get () {
|
||||
return this.value
|
||||
},
|
||||
|
||||
set (value) {
|
||||
this.$emit('input', value)
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
145
lib/vue/src/components/input/CInputDateTime/index.vue
Normal file
145
lib/vue/src/components/input/CInputDateTime/index.vue
Normal file
@@ -0,0 +1,145 @@
|
||||
<template>
|
||||
<b-row
|
||||
align-v="stretch"
|
||||
no-gutters
|
||||
class="c-input-date-time"
|
||||
>
|
||||
<b-col
|
||||
:lg="noTime ? 12 : 6"
|
||||
class="position-static overflow-hidden"
|
||||
>
|
||||
<b-form-datepicker
|
||||
v-if="!noDate"
|
||||
v-model="date"
|
||||
:placeholder="labels.none"
|
||||
:date-format-options="{ year: 'numeric', month: 'numeric', day: 'numeric' }"
|
||||
:min="minDate"
|
||||
:max="maxDate"
|
||||
:label-reset-button="labels.clear"
|
||||
:label-today-button="labels.today"
|
||||
label-help=""
|
||||
today-variant="info"
|
||||
selected-variant="secondary"
|
||||
boundary="window"
|
||||
hide-header
|
||||
reset-button
|
||||
today-button
|
||||
class="h-100 overflow-hidden"
|
||||
/>
|
||||
</b-col>
|
||||
<b-col
|
||||
:lg="noDate ? 12 : 6"
|
||||
class="position-static overflow-hidden"
|
||||
:class="{ 'mt-1 mt-lg-0 pl-lg-1': !noDate }"
|
||||
>
|
||||
<b-form-timepicker
|
||||
v-if="!noTime"
|
||||
v-model="time"
|
||||
:placeholder="labels.none"
|
||||
:label-reset-button="labels.clear"
|
||||
:label-now-button="labels.now"
|
||||
boundary="window"
|
||||
hide-header
|
||||
no-close-button
|
||||
reset-button
|
||||
now-button
|
||||
class="h-100 overflow-hidden"
|
||||
/>
|
||||
</b-col>
|
||||
</b-row>
|
||||
</template>
|
||||
<script lang="js">
|
||||
import { getDate, setDate, getTime, setTime } from './lib/index.ts'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
value: {
|
||||
type: String,
|
||||
required: false,
|
||||
},
|
||||
|
||||
noTime: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
|
||||
noDate: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
|
||||
onlyFuture: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
|
||||
onlyPast: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
|
||||
size: {
|
||||
type: String,
|
||||
default: 'md',
|
||||
},
|
||||
|
||||
labels: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
date: {
|
||||
get () {
|
||||
return getDate(this.value)
|
||||
},
|
||||
|
||||
set (date) {
|
||||
this.$emit('input', setDate(date, this.value, this.noDate, this.noTime))
|
||||
},
|
||||
},
|
||||
|
||||
time: {
|
||||
get () {
|
||||
return getTime(this.value)
|
||||
},
|
||||
|
||||
set (time) {
|
||||
this.$emit('input', setTime(time, this.value, this.noDate, this.noTime))
|
||||
},
|
||||
},
|
||||
|
||||
minDate () {
|
||||
return this.onlyFuture ? new Date() : undefined
|
||||
},
|
||||
|
||||
maxDate () {
|
||||
return this.onlyPast ? new Date() : undefined
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.c-input-date-time {
|
||||
min-width: 120px;
|
||||
.btn {
|
||||
padding: 0.25rem 0.5rem;
|
||||
}
|
||||
label {
|
||||
font-family: "Poppins-Regular";
|
||||
color: #495057 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.b-calendar-inner {
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
@media (min-width: 992px) {
|
||||
.c-input-date-time {
|
||||
min-width: 250px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,45 @@
|
||||
import { expect } from 'chai'
|
||||
import moment from 'moment'
|
||||
import { getDate, setDate, getTime, setTime } from './index'
|
||||
|
||||
describe(__filename, () => {
|
||||
describe('Can get and set time', () => {
|
||||
it('getDate', () => {
|
||||
expect(getDate(undefined)).to.equal(undefined)
|
||||
expect(getDate('2022-01-01')).to.equal('2022-01-01')
|
||||
expect(getDate('2022-01-01T12:00:00Z')).to.equal(moment.utc('2022-01-01T12:00:00Z').local().format('YYYY-MM-DD'))
|
||||
expect(getDate('2021-12-31T23:00:00Z')).to.equal(moment.utc('2021-12-31T23:00:00Z').local().format('YYYY-MM-DD'))
|
||||
})
|
||||
|
||||
it('setDate', () => {
|
||||
// Should undefined if noDate or date is invalid
|
||||
expect(setDate(undefined, '2022-01-01T12:00:00Z')).to.equal(undefined)
|
||||
expect(setDate('2022-01-02', '2022-01-01T12:00:00Z', true)).to.equal(undefined)
|
||||
|
||||
// If noTime, return just date
|
||||
expect(setDate('2022-01-02', '2022-01-01', false, true)).to.equal('2022-01-02')
|
||||
|
||||
expect(setDate('2022-01-02', '2022-01-01T12:00:00Z')).to.equal(moment(`2022-01-02 ${moment.utc('2022-01-01T12:00:00Z').local().format('HH:mm')}`, 'YYYY-MM-DD HH:mm').utc().format())
|
||||
expect(setDate('2022-01-02', undefined)).to.equal(moment('2022-01-02 00:00', 'YYYY-MM-DD HH:mm').utc().format())
|
||||
})
|
||||
|
||||
it('getTime', () => {
|
||||
expect(getTime(undefined)).to.equal(undefined)
|
||||
expect(getTime('12:00')).to.equal('12:00')
|
||||
expect(getTime('2022-01-01T12:00:00Z')).to.equal(moment.utc('2022-01-01T12:00:00Z').local().format('HH:mm'))
|
||||
expect(getTime('2021-12-31T23:00:00Z')).to.equal(moment.utc('2021-12-31T23:00:00Z').local().format('HH:mm'))
|
||||
})
|
||||
|
||||
it('setTime', () => {
|
||||
// Should undefined if noTime or time is invalid
|
||||
expect(setTime(undefined, '2022-01-01T12:00:00Z')).to.equal(undefined)
|
||||
expect(setTime('12:00', '2022-01-01T12:00:00Z', false, true)).to.equal(undefined)
|
||||
|
||||
// If noDate, return just time
|
||||
expect(setTime('12:00', '15:00', true, false)).to.equal('12:00')
|
||||
|
||||
expect(setTime('12:00', '2022-01-01T12:00:00Z')).to.equal(moment(`${moment.utc('2022-01-01T12:00:00Z').local().format('YYYY-MM-DD')} 12:00`, 'YYYY-MM-DD HH:mm').utc().format())
|
||||
expect(setTime('12:00', undefined)).to.equal(moment(`${moment().local().format('YYYY-MM-DD')} 12:00`, 'YYYY-MM-DD HH:mm').utc().format())
|
||||
})
|
||||
})
|
||||
})
|
||||
55
lib/vue/src/components/input/CInputDateTime/lib/index.ts
Normal file
55
lib/vue/src/components/input/CInputDateTime/lib/index.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import moment from 'moment'
|
||||
|
||||
export function getDate (value: string|undefined): string | undefined {
|
||||
if (!value) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (value === 'Invalid date') {
|
||||
// Make sure this weird value does not cause us problems
|
||||
return undefined
|
||||
}
|
||||
|
||||
return moment.utc(value).local().format('YYYY-MM-DD')
|
||||
}
|
||||
|
||||
export function getTime (value: string|undefined): string | undefined {
|
||||
if (!value) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (moment(value, 'YYYY-MM-DDTHH:mm:ssZ', true).isValid()) {
|
||||
return moment.utc(value).local().format('HH:mm')
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
export function setDate (date: string|undefined, value: string|undefined, noDate = false, noTime = false): string | undefined {
|
||||
if (noDate || !date || !date.length) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (noTime) {
|
||||
return moment(date, 'YYYY-MM-DD').format('YYYY-MM-DD')
|
||||
}
|
||||
|
||||
const time = getTime(value) || '00:00'
|
||||
|
||||
return moment(date + ' ' + time, 'YYYY-MM-DD HH:mm').utc().format()
|
||||
}
|
||||
|
||||
export function setTime (time: string|undefined, value: string|undefined, noDate = false, noTime = false): string | undefined {
|
||||
if (noTime || !time || !time.length) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (noDate) {
|
||||
return moment(time, 'HH:mm').format('HH:mm')
|
||||
}
|
||||
|
||||
// Default to today if date not provided
|
||||
const date = getDate(value) || moment().local().format('YYYY-MM-DD')
|
||||
|
||||
return moment(date + ' ' + time, 'YYYY-MM-DD HH:mm').utc().format()
|
||||
}
|
||||
210
lib/vue/src/components/input/CInputPresort.vue
Normal file
210
lib/vue/src/components/input/CInputPresort.vue
Normal file
@@ -0,0 +1,210 @@
|
||||
<template>
|
||||
<div>
|
||||
<draggable
|
||||
v-if="!textInput"
|
||||
:list.sync="items"
|
||||
group="sort"
|
||||
handle=".grab"
|
||||
>
|
||||
<b-form-row
|
||||
v-for="(column, index) in items"
|
||||
:key="index"
|
||||
class="mb-1"
|
||||
>
|
||||
<b-col
|
||||
cols="1"
|
||||
class="d-flex align-items-center justify-content-center"
|
||||
>
|
||||
<font-awesome-icon
|
||||
:icon="['fas', 'bars']"
|
||||
class="grab text-grey"
|
||||
/>
|
||||
</b-col>
|
||||
|
||||
<b-col
|
||||
cols="5"
|
||||
>
|
||||
<b-form-select
|
||||
v-model="column.field"
|
||||
:options="fields"
|
||||
text-field="label"
|
||||
value-field="name"
|
||||
class="rounded"
|
||||
>
|
||||
<template #first>
|
||||
<b-form-select-option
|
||||
:value="undefined"
|
||||
disabled
|
||||
>
|
||||
{{ labels.none }}
|
||||
</b-form-select-option>
|
||||
</template>
|
||||
</b-form-select>
|
||||
</b-col>
|
||||
|
||||
<b-col
|
||||
cols="6"
|
||||
class="d-flex align-items-center justify-content-around"
|
||||
>
|
||||
<b-form-radio-group
|
||||
v-model="column.descending"
|
||||
:options="sortDirections"
|
||||
buttons
|
||||
size="sm"
|
||||
button-variant="outline-primary"
|
||||
/>
|
||||
<c-input-confirm
|
||||
variant="link"
|
||||
size="lg"
|
||||
button-class="text-dark px-0"
|
||||
@confirmed="items.splice(index, 1)"
|
||||
/>
|
||||
</b-col>
|
||||
</b-form-row>
|
||||
</draggable>
|
||||
|
||||
<div
|
||||
v-else
|
||||
>
|
||||
<b-form-textarea
|
||||
v-model="presortValue"
|
||||
:placeholder="labels.placeholder"
|
||||
/>
|
||||
<b-form-text>
|
||||
{{ labels.footnote }}
|
||||
</b-form-text>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="d-flex align-items-center"
|
||||
>
|
||||
<b-button
|
||||
v-if="!textInput"
|
||||
variant="link"
|
||||
class="d-flex align-items-center px-0 text-decoration-none"
|
||||
@click="items.push({ field: undefined, descending: false })"
|
||||
>
|
||||
<font-awesome-icon
|
||||
:icon="['fas', 'plus']"
|
||||
size="sm"
|
||||
class="mr-1"
|
||||
/>
|
||||
{{ labels.add }}
|
||||
</b-button>
|
||||
|
||||
<b-button
|
||||
v-if="allowTextInput"
|
||||
variant="link"
|
||||
size="sm"
|
||||
class="text-decoration-none ml-auto"
|
||||
@click="textInput = !textInput"
|
||||
>
|
||||
{{ labels.toggleInput }}
|
||||
</b-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Draggable from 'vuedraggable'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Draggable,
|
||||
},
|
||||
|
||||
props: {
|
||||
value: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
|
||||
fields: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
|
||||
labels: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
|
||||
allowTextInput: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
|
||||
data () {
|
||||
return {
|
||||
items: [],
|
||||
|
||||
textInput: false,
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
presortValue: {
|
||||
get () {
|
||||
return this.value
|
||||
},
|
||||
|
||||
set (value) {
|
||||
this.$emit('input', value)
|
||||
},
|
||||
},
|
||||
|
||||
sortDirections () {
|
||||
return [
|
||||
{ value: false, text: this.labels.ascending },
|
||||
{ value: true, text: this.labels.descending },
|
||||
]
|
||||
},
|
||||
|
||||
sortedFields () {
|
||||
return [...this.fields.sort((a, b) => a.label.localeCompare(b.label))]
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
value: {
|
||||
immediate: true,
|
||||
handler (value) {
|
||||
if (value) {
|
||||
const sort = value.includes(',') ? value.split(',') : [value]
|
||||
|
||||
this.items = sort.map(field => {
|
||||
let descending = false
|
||||
|
||||
if (field.includes(' ')) {
|
||||
field = field.split(' ')[0]
|
||||
descending = true
|
||||
}
|
||||
|
||||
return {
|
||||
field,
|
||||
descending: !!descending,
|
||||
}
|
||||
})
|
||||
} else {
|
||||
this.items = [{
|
||||
field: undefined,
|
||||
descending: false,
|
||||
}]
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
items: {
|
||||
deep: true,
|
||||
handler (items = [], oldItems = undefined) {
|
||||
if (oldItems) {
|
||||
this.$emit('input', items.filter(({ field }) => field).map(({ field, descending }) => {
|
||||
return descending ? `${field} DESC` : field
|
||||
}).join(','))
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
40
lib/vue/src/components/input/CInputProcessing.vue
Normal file
40
lib/vue/src/components/input/CInputProcessing.vue
Normal file
@@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<b-button
|
||||
:variant="variant"
|
||||
:disabled="disabled || processing"
|
||||
:size="size"
|
||||
v-on="$listeners"
|
||||
class="d-inline-flex align-items-center justify-content-center"
|
||||
>
|
||||
<slot />
|
||||
<b-spinner
|
||||
v-if="processing"
|
||||
:processing="processing"
|
||||
small
|
||||
class="ml-1"
|
||||
/>
|
||||
</b-button>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
variant: {
|
||||
type: String,
|
||||
default: "primary",
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: "md",
|
||||
},
|
||||
processing: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
147
lib/vue/src/components/input/CInputSearch.vue
Normal file
147
lib/vue/src/components/input/CInputSearch.vue
Normal file
@@ -0,0 +1,147 @@
|
||||
<template>
|
||||
<b-input-group>
|
||||
<b-input
|
||||
ref="searchInput"
|
||||
data-test-id="input-search"
|
||||
:type="inputType"
|
||||
name="search"
|
||||
:value="value"
|
||||
:debounce="debounce"
|
||||
:disabled="disabled"
|
||||
:placeholder="placeholder"
|
||||
:autocomplete="autocomplete"
|
||||
:size="size"
|
||||
class="h-100 pr-0 border-light border-right-0 text-truncate bg-white"
|
||||
@input="search"
|
||||
@keyup.enter="submitQuery"
|
||||
/>
|
||||
<b-input-group-append
|
||||
:class="{ 'border-left': showSubmittable }"
|
||||
class="bg-white border-light rounded-right append-group-border"
|
||||
>
|
||||
<b-button
|
||||
v-if="showSubmittable"
|
||||
variant="link"
|
||||
:class="{
|
||||
'search-icon-border': showSubmittableAndClearable,
|
||||
'cursor-default': !isSubmittable
|
||||
}"
|
||||
@[isSubmittable]="submitQuery"
|
||||
>
|
||||
<font-awesome-icon
|
||||
:icon="['fas', 'search']"
|
||||
class="align-middle"
|
||||
/>
|
||||
</b-button>
|
||||
</b-input-group-append>
|
||||
</b-input-group>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'CInputSearch',
|
||||
|
||||
props: {
|
||||
value: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
|
||||
size: {
|
||||
type: String,
|
||||
},
|
||||
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
},
|
||||
|
||||
clearable: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
|
||||
submittable: {
|
||||
type: Boolean,
|
||||
},
|
||||
|
||||
autocomplete: {
|
||||
type: String,
|
||||
default: 'on',
|
||||
},
|
||||
|
||||
debounce: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
inputType () {
|
||||
return this.clearable ? 'search' : 'text'
|
||||
},
|
||||
|
||||
showSubmittable () {
|
||||
return !this.value || (this.value && this.showSubmittableAndClearable)
|
||||
},
|
||||
|
||||
isSubmittable () {
|
||||
return this.submittable ? 'click' : null
|
||||
},
|
||||
|
||||
showSubmittableAndClearable () {
|
||||
return this.clearable && this.submittable
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
search (e) {
|
||||
if (!this.submittable) {
|
||||
this.$emit('input', e)
|
||||
}
|
||||
},
|
||||
|
||||
submitQuery () {
|
||||
if (this.submittable) {
|
||||
this.$emit('search', this.$refs.searchInput.localValue)
|
||||
}
|
||||
},
|
||||
|
||||
clearQuery () {
|
||||
this.$refs.searchInput.focus()
|
||||
this.$emit('input', '')
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
$border-color: 2px solid #E4E9EF;
|
||||
|
||||
input:focus::placeholder {
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
input[type="search"]::-webkit-search-cancel-button {
|
||||
height: 13px;
|
||||
width: 13px;
|
||||
padding-left: 12px;
|
||||
cursor: pointer;
|
||||
background: url("data:image/svg+xml;charset=UTF-8,%3csvg viewPort='0 0 12 12' version='1.1' xmlns='http://www.w3.org/2000/svg'%3e%3cline x1='1' y1='11' x2='11' y2='1' stroke='black' stroke-width='2'/%3e%3cline x1='1' y1='1' x2='11' y2='11' stroke='black' stroke-width='2'/%3e%3c/svg%3e");
|
||||
}
|
||||
|
||||
.append-group-border {
|
||||
border: $border-color;
|
||||
}
|
||||
|
||||
.search-icon-border {
|
||||
border-left: $border-color;
|
||||
}
|
||||
|
||||
.cursor-default {
|
||||
cursor: default !important;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,83 @@
|
||||
<template>
|
||||
<div>
|
||||
<b-button
|
||||
variant="link"
|
||||
class="text-dark font-weight-bold text-decoration-none"
|
||||
:id="`color-popover-${format.type}`">
|
||||
|
||||
<span
|
||||
class="icon"
|
||||
:class="typeStyle">A</span>
|
||||
|
||||
</b-button>
|
||||
|
||||
<b-popover
|
||||
:delay="0"
|
||||
no-fade
|
||||
custom-class="tcl-popover"
|
||||
triggers="focus"
|
||||
:target="`color-popover-${format.type}`"
|
||||
placement="bottom"
|
||||
:container="null"
|
||||
ref="popover">
|
||||
|
||||
<swatches
|
||||
swatch-size="14"
|
||||
colors="text-advanced"
|
||||
@input="onClick(format.type, { color: $event })"
|
||||
inline />
|
||||
|
||||
</b-popover>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import base from './base.vue'
|
||||
import Swatches from 'vue-swatches'
|
||||
import 'vue-swatches/dist/vue-swatches.min.css'
|
||||
|
||||
/**
|
||||
* Component is used to display link formatters. It provides an interface to
|
||||
* input the URL that should be applied.
|
||||
*/
|
||||
export default {
|
||||
name: 't-mark-color',
|
||||
|
||||
components: {
|
||||
Swatches,
|
||||
},
|
||||
extends: base,
|
||||
|
||||
computed: {
|
||||
typeStyle () {
|
||||
return `${this.format.type}-icon`
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
// Since we don't have icons for color related formatters,
|
||||
// this extra styling makes up for that
|
||||
.icon {
|
||||
padding: 0 2px;
|
||||
|
||||
&.color-icon {
|
||||
border-bottom: 1px solid black;
|
||||
}
|
||||
|
||||
&.background-icon {
|
||||
background-color: rgba(black, 0.07);
|
||||
padding: 2px 3px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
// Bootstrap popover doesn't provide a way to style it's body component
|
||||
// Can't be scoped, since it's not rendered in here
|
||||
.tcl-popover .popover-body {
|
||||
padding: 0 !important;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<b-button
|
||||
variant="link"
|
||||
class="text-dark font-weight-bold text-decoration-none"
|
||||
@click="onClick(format.type, format.attrs)">
|
||||
<span :class="activeClasses(format.attrs)">
|
||||
<font-awesome-icon
|
||||
v-if="format.icon"
|
||||
:icon="format.icon" />
|
||||
|
||||
<span v-else>
|
||||
{{ format.label }}
|
||||
</span>
|
||||
</span>
|
||||
</b-button>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import base from './base.vue'
|
||||
|
||||
/**
|
||||
* Component is used to display simple formatter options such as bold, italic, ...
|
||||
*/
|
||||
export default {
|
||||
name: 't-mark-item',
|
||||
extends: base,
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,120 @@
|
||||
<template>
|
||||
<div id="link-popover-container">
|
||||
<b-button
|
||||
variant="link"
|
||||
class="text-dark font-weight-bold text-decoration-none"
|
||||
id="link-popover"
|
||||
@click="showPopover">
|
||||
|
||||
<span :class="activeClasses(format.attrs)">
|
||||
<font-awesome-icon icon="link" />
|
||||
</span>
|
||||
</b-button>
|
||||
|
||||
<b-popover
|
||||
v-if="currentValue"
|
||||
triggers="focus"
|
||||
target="link-popover"
|
||||
placement="auto"
|
||||
:show.sync="visible"
|
||||
container="link-popover-container"
|
||||
ref="popover">
|
||||
|
||||
<b-input-group>
|
||||
<b-form-input
|
||||
type="url"
|
||||
v-model="attrs.href"
|
||||
autofocus
|
||||
:state="urlValid ? null : false"
|
||||
:placeholder="labels.urlPlaceholder"
|
||||
@keydown.enter.prevent.stop="link"
|
||||
@keydown.esc.prevent.stop="close" />
|
||||
<b-input-group-append>
|
||||
<b-button
|
||||
@click="link"
|
||||
:disabled="!urlValid"
|
||||
variant="outline-success">
|
||||
{{ labels.ok }}
|
||||
</b-button>
|
||||
</b-input-group-append>
|
||||
<b-checkbox
|
||||
v-model="attrs.target"
|
||||
value="_blank"
|
||||
unchecked-value="_self"
|
||||
class="py-1">
|
||||
{{ labels.openLinkInNewTab }}
|
||||
</b-checkbox>
|
||||
</b-input-group>
|
||||
</b-popover>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import base from './base.vue'
|
||||
|
||||
/**
|
||||
* Component is used to display link formatters. It provides an interface to
|
||||
* input the URL that should be applied.
|
||||
*/
|
||||
export default {
|
||||
name: 't-mark-link',
|
||||
extends: base,
|
||||
|
||||
props: {
|
||||
labels: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
},
|
||||
|
||||
data () {
|
||||
return {
|
||||
visible: false,
|
||||
attrs: { href: null, target: '_self' },
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
/**
|
||||
* Does a simple check if entered URL is valid.
|
||||
* @todo Improve this
|
||||
* @returns {Boolean}
|
||||
*/
|
||||
urlValid () {
|
||||
if (!this.attrs.href) {
|
||||
return false
|
||||
}
|
||||
return !!this.attrs.href
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
/**
|
||||
* Helper to show the popup & determine if a link already exists
|
||||
*/
|
||||
showPopover () {
|
||||
if (this.currentValue) {
|
||||
this.visible = true
|
||||
this.attrs = { ...this.getMarkAttrs(this.format.type) }
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Helper to submit the given link
|
||||
*/
|
||||
link () {
|
||||
this.onClick(this.format.type, this.attrs)
|
||||
this.close()
|
||||
},
|
||||
|
||||
/**
|
||||
* Helper to close the popup & reset the state
|
||||
*/
|
||||
close () {
|
||||
this.attrs.href = null
|
||||
this.visible = false
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
</script>
|
||||
@@ -0,0 +1,51 @@
|
||||
<script>
|
||||
/**
|
||||
* Defines common props, methods between different toolbar item types
|
||||
*/
|
||||
export default {
|
||||
props: {
|
||||
format: {
|
||||
type: Object,
|
||||
required: true,
|
||||
default: () => ({}),
|
||||
},
|
||||
isActive: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => ({}),
|
||||
},
|
||||
getMarkAttrs: {
|
||||
type: Function,
|
||||
required: false,
|
||||
default: () => {},
|
||||
},
|
||||
currentValue: {
|
||||
type: String,
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
/**
|
||||
* Helper method to emit format selection
|
||||
* @param {String} type Format's type
|
||||
* @param {Object} attrs Format's extra attributes
|
||||
*/
|
||||
onClick (type, attrs) {
|
||||
this.$emit('click', { type, attrs })
|
||||
},
|
||||
|
||||
/**
|
||||
* Helper method to determine if given format is active or not.
|
||||
* When attrs is provided, it will check for that exact match
|
||||
* @param {Object|undefined} attrs Format's extra attributes
|
||||
* @returns {Array|undefined}
|
||||
*/
|
||||
activeClasses (attrs) {
|
||||
if ((this.isActive[this.format.type])(attrs)) {
|
||||
return ['text-success']
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,9 @@
|
||||
import Color from './Color.vue'
|
||||
import Item from './Item.vue'
|
||||
import Link from './Link.vue'
|
||||
|
||||
export default {
|
||||
Color,
|
||||
Item,
|
||||
Link,
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<b-button
|
||||
variant="link"
|
||||
class="text-dark font-weight-bold text-decoration-none"
|
||||
@click="onClick(format.type, format.attrs)">
|
||||
<span :class="activeClasses(format.attrs)">
|
||||
<font-awesome-icon
|
||||
v-if="format.icon"
|
||||
:icon="format.icon" />
|
||||
|
||||
<span v-else>
|
||||
{{ format.label }}
|
||||
</span>
|
||||
</span>
|
||||
</b-button>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import base from './base.vue'
|
||||
|
||||
/**
|
||||
* Component is used to display node types; paragraph, headding, ...
|
||||
*/
|
||||
export default {
|
||||
name: 't-node-item',
|
||||
extends: base,
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,111 @@
|
||||
<script>
|
||||
import { nodeTypes } from '../../lib/formats'
|
||||
|
||||
/**
|
||||
* Defines common props, methods between different toolbar item types
|
||||
*/
|
||||
export default {
|
||||
props: {
|
||||
format: {
|
||||
type: Object,
|
||||
required: true,
|
||||
default: () => ({}),
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
/**
|
||||
* Helper method to emit format selection
|
||||
* @param {String} type Format's type
|
||||
* @param {Object} attrs Format's extra attributes
|
||||
*/
|
||||
onClick (type, attrs) {
|
||||
if (!attrs) {
|
||||
attrs = {}
|
||||
}
|
||||
|
||||
// manual toggling, since tiptap's isActive fails to resolve modified nodes
|
||||
const act = this.activeNode([type], attrs)
|
||||
if (act) {
|
||||
type = 'paragraph'
|
||||
}
|
||||
|
||||
const nn = this.activeNode(nodeTypes)
|
||||
if (!nn) {
|
||||
throw new Error('no node selected')
|
||||
}
|
||||
const n = nn.node
|
||||
|
||||
// preserve some attrs
|
||||
const cAttr = n.attrs
|
||||
const target = this.$attrs.editor.nodes[type]
|
||||
const nAttrs = {}
|
||||
|
||||
for (const a in target.attrs) {
|
||||
if (attrs[a] === undefined) {
|
||||
nAttrs[a] = cAttr[a]
|
||||
} else {
|
||||
nAttrs[a] = attrs[a]
|
||||
}
|
||||
}
|
||||
|
||||
this.$emit('click', { type, attrs: nAttrs })
|
||||
},
|
||||
|
||||
/**
|
||||
* Helper to determine active nodes in the given selection.
|
||||
* Replaces tiptap's built in isActive, since it can't handle our modified
|
||||
* nodes.
|
||||
*/
|
||||
activeNodes (types, attrs) {
|
||||
const ed = this.$attrs.editor
|
||||
const rtr = []
|
||||
ed.state.doc.nodesBetween(
|
||||
ed.selection.from,
|
||||
ed.selection.to,
|
||||
(n, pos) => {
|
||||
if (types.includes(n.type.name)) {
|
||||
if (attrs) {
|
||||
if (!Object.entries(attrs || {}).find(([k, v]) => n.attrs[k] !== v)) {
|
||||
rtr.push({ node: n, position: pos })
|
||||
}
|
||||
} else {
|
||||
rtr.push({ node: n, position: pos })
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
return rtr
|
||||
},
|
||||
|
||||
/**
|
||||
* Helper to determine active node in the given selection
|
||||
*/
|
||||
activeNode (types, attrs) {
|
||||
const ann = this.activeNodes(types, attrs)
|
||||
if (!ann) {
|
||||
return undefined
|
||||
}
|
||||
return ann[0]
|
||||
},
|
||||
|
||||
isActiveCheck (types, attrs) {
|
||||
return !!this.activeNode(types, attrs)
|
||||
},
|
||||
|
||||
/**
|
||||
* Helper method to determine if given format is active or not.
|
||||
* When attrs is provided, it will check for that exact match
|
||||
* @param {Object|undefined} attrs Format's extra attributes
|
||||
* @returns {Array|undefined}
|
||||
*/
|
||||
activeClasses (attrs) {
|
||||
if (this.isActiveCheck([this.format.type], attrs)) {
|
||||
return ['text-success']
|
||||
}
|
||||
|
||||
return undefined
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,5 @@
|
||||
import Item from './Item.vue'
|
||||
|
||||
export default {
|
||||
Item,
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
<template>
|
||||
<b-dropdown
|
||||
menu-class="text-center"
|
||||
variant="link">
|
||||
|
||||
<template slot="button-content">
|
||||
<span class="text-dark font-weight-bold">
|
||||
<span :class="rootActiveClasses()">
|
||||
<font-awesome-icon
|
||||
v-if="format.icon"
|
||||
:icon="format.icon" />
|
||||
<span v-else>
|
||||
{{ format.label }}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<b-dropdown-item
|
||||
v-for="v of format.variants"
|
||||
:key="v.variant"
|
||||
@click="dispatchTransaction(v)">
|
||||
|
||||
<b-button
|
||||
variant="link"
|
||||
class="text-dark font-weight-bold">
|
||||
|
||||
<span :class="activeClasses(v.attrs)">
|
||||
<font-awesome-icon
|
||||
v-if="format.icon"
|
||||
:icon="v.icon" />
|
||||
<span v-else>
|
||||
{{ v.label }}
|
||||
</span>
|
||||
</span>
|
||||
</b-button>
|
||||
</b-dropdown-item>
|
||||
</b-dropdown>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import base from '../TNode/base.vue'
|
||||
import { nodeTypes } from '../../lib/formats'
|
||||
|
||||
/**
|
||||
* Component is used to display node alignment formatting
|
||||
*/
|
||||
export default {
|
||||
name: 't-nattr-alignment',
|
||||
extends: base,
|
||||
|
||||
props: {
|
||||
isActive: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => ({}),
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
activeClasses (attrs) {
|
||||
const an = this.activeNode(nodeTypes, attrs)
|
||||
if (!an || !an.node) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const ac = (type, attrs) => {
|
||||
const b = (this.isActive[type])
|
||||
return b && (b(attrs))
|
||||
}
|
||||
if (ac(an.node.type.name, { ...an.node.attrs, ...attrs })) {
|
||||
return ['text-success']
|
||||
}
|
||||
|
||||
return undefined
|
||||
},
|
||||
|
||||
/**
|
||||
* dispatches node attr update for all affected nodes
|
||||
* use a single transaction, so ctrl + z works as intended
|
||||
*/
|
||||
dispatchTransaction (v) {
|
||||
const ann = this.activeNodes(nodeTypes)
|
||||
const tr = this.$attrs.editor.state.tr
|
||||
for (const an of ann) {
|
||||
tr.setNodeMarkup(an.position, an.node.type, { ...an.node.attrs, ...v.attrs })
|
||||
}
|
||||
this.$attrs.editor.dispatchTransaction(tr)
|
||||
},
|
||||
|
||||
/**
|
||||
* Helper method to determine if the root formater should be shown as active
|
||||
* @returns {Array|undefined}
|
||||
*/
|
||||
rootActiveClasses (v) {
|
||||
if (this.format.variants.find(({ type, attrs }) => this.activeClasses(attrs))) {
|
||||
return ['text-success']
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,5 @@
|
||||
import Alignment from './Alignment.vue'
|
||||
|
||||
export default {
|
||||
Alignment,
|
||||
}
|
||||
108
lib/vue/src/components/input/CRichTextInput/RToolbar/index.vue
Normal file
108
lib/vue/src/components/input/CRichTextInput/RToolbar/index.vue
Normal file
@@ -0,0 +1,108 @@
|
||||
<template>
|
||||
<div class="d-flex flex-wrap">
|
||||
<component
|
||||
v-for="(f, i) of formats"
|
||||
:key="`${f.name}${i}}`"
|
||||
:is="getItem(f)"
|
||||
:format="f"
|
||||
v-bind="$props"
|
||||
:labels="labels"
|
||||
:current-value="currentValue"
|
||||
@click="(commands[$event.type])($event.attrs)" />
|
||||
|
||||
<!-- Extra button to remove formatting -->
|
||||
<b-button
|
||||
variant="link"
|
||||
class="text-dark font-weight-bold"
|
||||
@click="removeMarks">
|
||||
|
||||
<font-awesome-icon icon="remove-format" />
|
||||
</b-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import cc from './loader'
|
||||
import { removeMark } from 'tiptap-commands'
|
||||
|
||||
export default {
|
||||
inheritAttrs: true,
|
||||
|
||||
props: {
|
||||
editor: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
commands: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
isActive: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
getMarkAttrs: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
formats: {
|
||||
type: Array,
|
||||
required: true,
|
||||
default: () => [],
|
||||
},
|
||||
labels: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
currentValue: {
|
||||
type: String,
|
||||
required: false,
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
/**
|
||||
* Helper method to determine what item type we should display.
|
||||
* It can be a simple button (bold, italic, ...) or a dropdown (alignment)
|
||||
* @param {Object} f Format in question
|
||||
* @returns {Component}
|
||||
*/
|
||||
getItem (f) {
|
||||
let b
|
||||
if (f.mark) {
|
||||
b = cc.mark
|
||||
} else if (f.node) {
|
||||
b = cc.node
|
||||
} else if (f.nodeAttr) {
|
||||
b = cc.nodeAttr
|
||||
}
|
||||
|
||||
if (!b) {
|
||||
throw new Error('invalid node type')
|
||||
}
|
||||
|
||||
let comp
|
||||
if (f.component) {
|
||||
comp = b[f.component]
|
||||
} else {
|
||||
comp = b.Item
|
||||
}
|
||||
|
||||
if (!comp) {
|
||||
throw new Error('invalid component type')
|
||||
}
|
||||
|
||||
return comp
|
||||
},
|
||||
|
||||
/**
|
||||
* Helper method for removing marks.
|
||||
* It will remove all marks from the current state's range.
|
||||
* @returns {Range}
|
||||
*/
|
||||
removeMarks () {
|
||||
removeMark(null)(this.editor.view.state, this.editor.view.dispatch)
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,9 @@
|
||||
import mark from './TMark/loader'
|
||||
import node from './TNode/loader'
|
||||
import nodeAttr from './TNodeAttr/loader'
|
||||
|
||||
export default {
|
||||
mark,
|
||||
node,
|
||||
nodeAttr,
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
import { toggleBlockType } from 'tiptap-commands'
|
||||
import { Heading as Base } from 'tiptap-extensions'
|
||||
import { toAttrs, makeDOMParser } from './utils'
|
||||
|
||||
/**
|
||||
* Extends original heading node to allow content alignment
|
||||
*/
|
||||
export default class Heading extends Base {
|
||||
get defaultOptions () {
|
||||
return {
|
||||
...super.defaultOptions,
|
||||
alignment: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
get baseP () {
|
||||
return this.options.levels
|
||||
.map(level => ({
|
||||
tag: `h${level}`,
|
||||
getAttrs: () => ({ level }),
|
||||
}))
|
||||
}
|
||||
|
||||
get schema () {
|
||||
return {
|
||||
...super.schema,
|
||||
|
||||
attrs: {
|
||||
...super.schema.attrs,
|
||||
|
||||
alignment: {
|
||||
default: undefined,
|
||||
},
|
||||
},
|
||||
|
||||
parseDOM: this.baseP.map(makeDOMParser),
|
||||
|
||||
toDOM: (node) => [
|
||||
`h${node.attrs.level}`,
|
||||
toAttrs(node),
|
||||
0,
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
commands ({ type, schema }) {
|
||||
return (attrs) => {
|
||||
return toggleBlockType(type, schema.nodes.paragraph, attrs)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
|
||||
import hps from 'html-parse-stringify'
|
||||
import {
|
||||
Extension,
|
||||
Plugin,
|
||||
PluginKey,
|
||||
} from 'tiptap'
|
||||
|
||||
const knownContentRoots = new Set(['pre', 'p'])
|
||||
|
||||
function tp (c) {
|
||||
if (c.type && c.type.name === 'text') {
|
||||
c.text = (c.text || '').replace(/\u00a0/ig, '\u0020')
|
||||
}
|
||||
|
||||
for (const cc of c.content.content) {
|
||||
tp(cc)
|
||||
}
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
/**
|
||||
* This plugin handles HTML pasting.
|
||||
* It prevents white spaces from collapsing by converting them into
|
||||
* non-breakable spaces.
|
||||
*/
|
||||
const plug = new Plugin({
|
||||
key: new PluginKey('htmlPaster'),
|
||||
|
||||
view () {
|
||||
return {
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
update: () => {},
|
||||
}
|
||||
},
|
||||
|
||||
state: {
|
||||
init () {
|
||||
return {}
|
||||
},
|
||||
|
||||
apply (tr, prev) {
|
||||
return { ...prev }
|
||||
},
|
||||
},
|
||||
|
||||
props: {
|
||||
/**
|
||||
* Method is invoked when a user pastes HTML content into the editor.
|
||||
* The method replaces regular white spaces (\u0020) with non-breakable white spaces (\u00a0).
|
||||
* This forces the underlying HTML parser to not collapse white spaces.
|
||||
*
|
||||
* It also performs a slight HTML node manipulation to assure consistent behaviour between different
|
||||
* environments.
|
||||
*
|
||||
* @param {String} html Pasted HTML content
|
||||
*/
|
||||
transformPastedHTML (html) {
|
||||
const tree = hps.parse(html).filter(({ type }) => type !== 'text')
|
||||
if (tree.length === 1) {
|
||||
// 1. find main content node
|
||||
let root
|
||||
if (tree[0].name === 'html') {
|
||||
root = tree[0].children.find(({ name }) => name === 'body' || name === 'main')
|
||||
} else {
|
||||
root = tree[0]
|
||||
}
|
||||
|
||||
if (root) {
|
||||
// 2. make content nodes consistent
|
||||
root.children.forEach((c, i) => {
|
||||
if (!c.voidElement && !knownContentRoots.has(c.name)) {
|
||||
c.name = 'p'
|
||||
}
|
||||
if (c.name === 'br') {
|
||||
root.children[i].voidElement = false
|
||||
root.children[i].name = 'p'
|
||||
}
|
||||
})
|
||||
|
||||
html = hps.stringify(tree)
|
||||
}
|
||||
}
|
||||
|
||||
html = html.replace(/ /ig, '\u00a0')
|
||||
html = html.replace(/\n/ig, '<br>')
|
||||
// lets be smart and only replace trailing/leading white spaces.
|
||||
// replacing every white space can mess with other plugins (hint hint mentions)
|
||||
const rr = /(\u0020+)<|>(\u0020+)/gi
|
||||
let exec
|
||||
do {
|
||||
exec = rr.exec(html)
|
||||
if (exec) {
|
||||
const cc = exec[1] !== undefined ? exec[1] : exec[2]
|
||||
html = html.substring(0, exec.index + 1) +
|
||||
new Array(cc.length + 1).join('\u00a0') +
|
||||
html.substring(exec.index + cc.length + (exec[1] === undefined ? 1 : 0))
|
||||
}
|
||||
} while (exec)
|
||||
|
||||
return html
|
||||
},
|
||||
|
||||
/**
|
||||
* Method is invoked before the given content slice is inserted into the document.
|
||||
* The method replaces the above created non-breakable white spaces (\u00a0) with regular white spaces (\u0020).
|
||||
*
|
||||
* @param {Slice} c Pasted content slice
|
||||
*/
|
||||
transformPasted (c) {
|
||||
return tp(c)
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export default class HtmlPaster extends Extension {
|
||||
get name () {
|
||||
return 'html-paster'
|
||||
}
|
||||
|
||||
get plugins () {
|
||||
return [plug]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export { default as Paragraph } from './paragraph'
|
||||
export { default as Heading } from './heading'
|
||||
export { default as TextColor } from './textColor'
|
||||
export { default as TextBackground } from './textBackground'
|
||||
export { default as Link } from './link'
|
||||
export { default as HtmlPaster } from './htmlPaster'
|
||||
@@ -0,0 +1,20 @@
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
|
||||
import { Link as BaseLink } from 'tiptap-extensions'
|
||||
|
||||
/**
|
||||
* Extends original Link node to allow custom target attr
|
||||
*/
|
||||
export default class Link extends BaseLink {
|
||||
get schema () {
|
||||
const base = super.schema
|
||||
return {
|
||||
...base,
|
||||
toDOM: node => ['a', {
|
||||
target: '_blank',
|
||||
...node.attrs,
|
||||
rel: 'noopener noreferrer nofollow',
|
||||
}, 0],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
|
||||
import { toggleBlockType } from 'tiptap-commands'
|
||||
import { Paragraph as Base } from 'tiptap'
|
||||
import { toAttrs, makeDOMParser } from './utils'
|
||||
|
||||
/**
|
||||
* Extends original paragraph node to allow content alignment
|
||||
*/
|
||||
export default class Paragraph extends Base {
|
||||
get schema () {
|
||||
return {
|
||||
...super.schema,
|
||||
|
||||
attrs: {
|
||||
...super.schema.attrs,
|
||||
|
||||
alignment: {
|
||||
default: undefined,
|
||||
},
|
||||
},
|
||||
|
||||
parseDOM: super.schema.parseDOM.map(makeDOMParser),
|
||||
|
||||
toDOM: (node) => [
|
||||
'p',
|
||||
toAttrs(node),
|
||||
0,
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
commands ({ type }) {
|
||||
return (attrs) => toggleBlockType(type, type, attrs)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
|
||||
import { Mark } from 'tiptap'
|
||||
import { updateMark } from 'tiptap-commands'
|
||||
|
||||
/**
|
||||
* Represents text's background color
|
||||
*/
|
||||
export default class Background extends Mark {
|
||||
get name () {
|
||||
return 'background'
|
||||
}
|
||||
|
||||
get schema () {
|
||||
return {
|
||||
attrs: {
|
||||
color: {},
|
||||
},
|
||||
|
||||
parseDOM: [
|
||||
{
|
||||
style: 'background-color',
|
||||
getAttrs: color => ({ color }),
|
||||
},
|
||||
],
|
||||
|
||||
toDOM: node => [
|
||||
'span',
|
||||
{ style: `background-color: ${node.attrs.color}` },
|
||||
0,
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
commands ({ type }) {
|
||||
return (attr) => updateMark(type, attr)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
|
||||
import { Mark } from 'tiptap'
|
||||
import { updateMark } from 'tiptap-commands'
|
||||
|
||||
/**
|
||||
* Represents text's color
|
||||
*/
|
||||
export default class Color extends Mark {
|
||||
get name () {
|
||||
return 'color'
|
||||
}
|
||||
|
||||
get schema () {
|
||||
return {
|
||||
attrs: {
|
||||
color: {},
|
||||
},
|
||||
parseDOM: [
|
||||
{
|
||||
style: 'color',
|
||||
getAttrs: color => ({ color }),
|
||||
},
|
||||
],
|
||||
|
||||
toDOM: node => [
|
||||
'span',
|
||||
{ style: `color: ${node.attrs.color}` },
|
||||
0,
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
commands ({ type }) {
|
||||
return (attr) => updateMark(type, attr)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
|
||||
// maps legacy Quill alignment classes to new classes
|
||||
export const qAlignments = {
|
||||
'ql-align-right': 'right',
|
||||
'ql-align-left': 'left',
|
||||
'ql-align-center': 'center',
|
||||
'ql-align-justify': 'justify',
|
||||
}
|
||||
|
||||
// helper to construct node's attributes
|
||||
export function toAttrs (node) {
|
||||
if (node.attrs.alignment) {
|
||||
return { style: `text-align: ${node.attrs.alignment}` }
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
// helper to determine node's alignment
|
||||
export function alignmentParser (node) {
|
||||
// Covers current structure
|
||||
let alignment = node.style.textAlign
|
||||
if (alignment) {
|
||||
return { alignment }
|
||||
}
|
||||
|
||||
// Covers legacy structure
|
||||
node.classList.forEach((c) => {
|
||||
alignment = alignment || qAlignments[c]
|
||||
})
|
||||
|
||||
return { alignment: alignment || undefined }
|
||||
}
|
||||
|
||||
// helper to construct DOM parsers with alignment attrs
|
||||
export const makeDOMParser = pd => ({
|
||||
...pd,
|
||||
getAttrs: (node) => {
|
||||
let rtr = {}
|
||||
if (pd.getAttrs) {
|
||||
rtr = pd.getAttrs(node)
|
||||
}
|
||||
return { ...rtr, ...alignmentParser(node) }
|
||||
},
|
||||
})
|
||||
133
lib/vue/src/components/input/CRichTextInput/index.vue
Normal file
133
lib/vue/src/components/input/CRichTextInput/index.vue
Normal file
@@ -0,0 +1,133 @@
|
||||
<template>
|
||||
<b-card no-body class="editor rt-content">
|
||||
<template v-if="editor">
|
||||
<b-card-header header-class="p-0 rounded-sm">
|
||||
<editor-menu-bar
|
||||
:editor="editor"
|
||||
v-slot="{ commands, isActive, getMarkAttrs, getNodeAttrs }"
|
||||
>
|
||||
|
||||
<r-toolbar
|
||||
:editor="editor"
|
||||
:formats="toolbar"
|
||||
:commands="commands"
|
||||
:is-active="isActive"
|
||||
:get-mark-attrs="getMarkAttrs"
|
||||
:get-node-attrs="getNodeAttrs"
|
||||
:labels="labels"
|
||||
:current-value="currentValue"
|
||||
/>
|
||||
</editor-menu-bar>
|
||||
</b-card-header>
|
||||
|
||||
<b-card-body>
|
||||
<editor-content
|
||||
:editor="editor"
|
||||
class="editor__content"
|
||||
/>
|
||||
|
||||
</b-card-body>
|
||||
</template>
|
||||
</b-card>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import RToolbar from './RToolbar/index.vue'
|
||||
import { EditorMenuBar, Editor, EditorContent } from 'tiptap'
|
||||
import { getFormats, getToolbar } from './lib'
|
||||
|
||||
export default {
|
||||
name: 'CRichTextInput',
|
||||
|
||||
components: {
|
||||
EditorContent,
|
||||
RToolbar,
|
||||
EditorMenuBar,
|
||||
},
|
||||
|
||||
props: {
|
||||
value: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
labels: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
},
|
||||
|
||||
data () {
|
||||
const formats = getFormats()
|
||||
return {
|
||||
formats,
|
||||
toolbar: getToolbar(),
|
||||
// Helper to determine if current content differes from prop's content
|
||||
emittedContent: false,
|
||||
editor: undefined,
|
||||
currentValue: '',
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
value: {
|
||||
handler: function (val) {
|
||||
// Update happened due to external content change, not model change
|
||||
if (!this.emittedContent) {
|
||||
this.editor.setContent(val)
|
||||
}
|
||||
|
||||
this.emittedContent = false
|
||||
},
|
||||
deep: true,
|
||||
},
|
||||
},
|
||||
|
||||
mounted () {
|
||||
this.init()
|
||||
},
|
||||
|
||||
beforeDestroy () {
|
||||
this.editor.destroy()
|
||||
},
|
||||
|
||||
methods: {
|
||||
/**
|
||||
* Initialize the editor, state, ...
|
||||
*/
|
||||
init () {
|
||||
this.editor = new Editor({
|
||||
extensions: this.formats,
|
||||
content: this.value,
|
||||
parseOptions: {
|
||||
preserveWhitespace: 'full',
|
||||
},
|
||||
onUpdate: this.onUpdate,
|
||||
})
|
||||
|
||||
/**
|
||||
* Since we migrated to TipTap, the new content should be emitted
|
||||
* after tiptap is done parsing it.
|
||||
*/
|
||||
this.$nextTick(() => {
|
||||
this.onUpdate()
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Handle on update events. Process current document & update data model
|
||||
* @note Currently, build-in toHTML function removes empty lines.
|
||||
* Because of this, we are using `view.dom.innerHTML`. This should be improved at a later point
|
||||
*/
|
||||
onUpdate () {
|
||||
this.currentValue = this.editor.view.dom.innerHTML
|
||||
|
||||
// Makes sure to default to '' as the value if no text is present, for validation purposes
|
||||
this.currentValue = this.currentValue !== '<p><br></p>' ? this.currentValue : ''
|
||||
|
||||
this.emittedContent = true
|
||||
this.$emit('input', this.currentValue)
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
89
lib/vue/src/components/input/CRichTextInput/lib/formats.js
Normal file
89
lib/vue/src/components/input/CRichTextInput/lib/formats.js
Normal file
@@ -0,0 +1,89 @@
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
|
||||
import {
|
||||
Paragraph,
|
||||
Heading,
|
||||
TextColor,
|
||||
TextBackground,
|
||||
Link,
|
||||
HtmlPaster,
|
||||
} from '../extensions'
|
||||
|
||||
import {
|
||||
Blockquote,
|
||||
CodeBlock,
|
||||
HorizontalRule,
|
||||
OrderedList,
|
||||
BulletList,
|
||||
TodoList,
|
||||
Bold,
|
||||
Italic,
|
||||
Strike,
|
||||
Underline,
|
||||
ListItem,
|
||||
TodoItem,
|
||||
History,
|
||||
} from 'tiptap-extensions'
|
||||
|
||||
// Defines a set of formats that our document supports
|
||||
export const getFormats = () => [
|
||||
new HtmlPaster(),
|
||||
new Bold(),
|
||||
new Italic(),
|
||||
new Underline(),
|
||||
new Strike(),
|
||||
new Blockquote(),
|
||||
new CodeBlock(),
|
||||
new OrderedList(),
|
||||
new BulletList(),
|
||||
new TodoList(),
|
||||
new Heading({ levels: [1, 2, 3] }),
|
||||
new Paragraph({ alignments: ['left', 'right', 'center', 'justify'] }),
|
||||
new Link(),
|
||||
new HorizontalRule(),
|
||||
new ListItem(),
|
||||
new TodoItem(),
|
||||
new History(),
|
||||
new TextBackground(),
|
||||
new TextColor(),
|
||||
]
|
||||
|
||||
// Defines the structure of our editor toolbar
|
||||
export const getToolbar = () => [
|
||||
{ type: 'bold', mark: true, icon: 'bold' },
|
||||
{ type: 'italic', mark: true, icon: 'italic' },
|
||||
{ type: 'underline', mark: true, icon: 'underline' },
|
||||
{ type: 'strike', mark: true, icon: 'strikethrough' },
|
||||
{ type: 'color', mark: true, component: 'Color' },
|
||||
{ type: 'background', mark: true, component: 'Color' },
|
||||
|
||||
{ type: 'blockquote', node: true, icon: 'quote-right' },
|
||||
{ type: 'code_block', node: true, icon: 'code' },
|
||||
{ type: 'heading', node: true, label: 'H1', attrs: { level: 1 } },
|
||||
{ type: 'heading', node: true, label: 'H2', attrs: { level: 2 } },
|
||||
{ type: 'heading', node: true, label: 'H3', attrs: { level: 3 } },
|
||||
{ type: 'paragraph', node: true, icon: 'paragraph' },
|
||||
{ type: 'ordered_list', node: true, icon: 'list-ol' },
|
||||
{ type: 'bullet_list', node: true, icon: 'list-ul' },
|
||||
{ type: 'todo_list', node: true, icon: 'tasks' },
|
||||
|
||||
{
|
||||
type: 'alignment',
|
||||
icon: 'align-left',
|
||||
nodeAttr: true,
|
||||
component: 'Alignment',
|
||||
variants: [
|
||||
{ variant: 'left', icon: 'align-left', attrs: { alignment: 'left' } },
|
||||
{ variant: 'center', icon: 'align-center', attrs: { alignment: 'center' } },
|
||||
{ variant: 'right', icon: 'align-right', attrs: { alignment: 'right' } },
|
||||
{ variant: 'justify', icon: 'align-justify', attrs: { alignment: 'justify' } },
|
||||
],
|
||||
},
|
||||
|
||||
{ type: 'link', mark: true, component: 'Link', icon: 'link', attrs: { href: null } },
|
||||
|
||||
// @note There is no free FA icon for this
|
||||
{ type: 'horizontal_rule', node: true, label: '__' },
|
||||
]
|
||||
|
||||
export const nodeTypes = getToolbar().filter(({ node }) => node).map(({ type }) => type)
|
||||
1
lib/vue/src/components/input/CRichTextInput/lib/index.js
Normal file
1
lib/vue/src/components/input/CRichTextInput/lib/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export * from './formats'
|
||||
7
lib/vue/src/components/input/index.ts
Normal file
7
lib/vue/src/components/input/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export { default as CInputConfirm } from './CInputConfirm.vue'
|
||||
export { default as CInputProcessing } from './CInputProcessing.vue'
|
||||
export { default as CInputDateTime } from './CInputDateTime/index.vue'
|
||||
export { default as CInputPresort } from './CInputPresort.vue'
|
||||
export { default as CInputCron } from './CInputCron.vue'
|
||||
export { default as CInputSearch } from './CInputSearch.vue'
|
||||
export { default as CRichTextInput } from './CRichTextInput/index.vue'
|
||||
1
lib/vue/src/components/itemPicker/C3.js
Normal file
1
lib/vue/src/components/itemPicker/C3.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default as CItemPicker } from './CItemPicker.c3'
|
||||
49
lib/vue/src/components/itemPicker/CItemPicker.c3.js
Normal file
49
lib/vue/src/components/itemPicker/CItemPicker.c3.js
Normal file
@@ -0,0 +1,49 @@
|
||||
// eslint-disable-next-line
|
||||
import { default as component } from './CItemPicker.vue'
|
||||
import { components } from '@cortezaproject/corteza-vue'
|
||||
const { checkbox } = components.C3.controls
|
||||
|
||||
const props = {
|
||||
options: [
|
||||
{ value: 'pl', text: 'pl' },
|
||||
{ value: 'pl2', text: 'pl' },
|
||||
{ value: 'pl3', text: 'pl' },
|
||||
{ value: 'oo', text: 'oo' },
|
||||
],
|
||||
value: [
|
||||
'pl',
|
||||
'pl2',
|
||||
'pl3',
|
||||
],
|
||||
labels: {
|
||||
searchPlaceholder: 'Type here to search among module fields',
|
||||
availableItems: 'Available fields',
|
||||
selectAllItems: 'Select all',
|
||||
selectedItems: 'Selected fields',
|
||||
systemItem: '(system field)',
|
||||
unselectAllItems: 'Unselect all',
|
||||
noItemsFound: 'No Items Found',
|
||||
},
|
||||
|
||||
hideFilter: false,
|
||||
hideIcons: false,
|
||||
disabled: false,
|
||||
disabledFilter: false,
|
||||
disabledSorting: false,
|
||||
disabledDragging: false,
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'CItemPicker',
|
||||
group: ['Picker'],
|
||||
component,
|
||||
props,
|
||||
controls: [
|
||||
checkbox('Hide filter', 'hideFilter'),
|
||||
checkbox('Hide icons', 'hideIcons'),
|
||||
checkbox('Disabled', 'disabled'),
|
||||
checkbox('Disabled filter', 'disabledFilter'),
|
||||
checkbox('Disabled sorting', 'disabledSorting'),
|
||||
checkbox('Disabled dragging', 'disabledDragging'),
|
||||
],
|
||||
}
|
||||
465
lib/vue/src/components/itemPicker/CItemPicker.vue
Normal file
465
lib/vue/src/components/itemPicker/CItemPicker.vue
Normal file
@@ -0,0 +1,465 @@
|
||||
<template>
|
||||
<div
|
||||
class="overflow-hidden d-flex flex-column w-100 vh-100"
|
||||
>
|
||||
<b-card
|
||||
no-body
|
||||
class="h-100"
|
||||
>
|
||||
<b-card-header
|
||||
class="bg-white p-0"
|
||||
>
|
||||
|
||||
<c-input-search
|
||||
v-if="!hideFilter"
|
||||
v-model.trim="query"
|
||||
:disabled="_disabledFilter"
|
||||
:placeholder="labels.searchPlaceholder"
|
||||
/>
|
||||
|
||||
</b-card-header>
|
||||
<b-card-body
|
||||
class="d-flex p-0"
|
||||
>
|
||||
<b-card
|
||||
no-body
|
||||
class="col-sm-6 col-12 h-100 p-0"
|
||||
>
|
||||
<b-card-header
|
||||
class="bg-white py-2 pl-0 pr-2"
|
||||
>
|
||||
<div
|
||||
class="d-flex align-items-center"
|
||||
>
|
||||
<label
|
||||
class="text-primary mb-0"
|
||||
>
|
||||
{{ labels.availableItems }}
|
||||
</label>
|
||||
<b-button
|
||||
v-show="filteredAvailable.length && !disabled"
|
||||
variant="link"
|
||||
class="ml-auto px-0 text-muted"
|
||||
@click="selectAll()"
|
||||
>
|
||||
{{ labels.selectAllItems }}
|
||||
</b-button>
|
||||
</div>
|
||||
</b-card-header>
|
||||
<b-card-body
|
||||
class="overflow-auto py-0 pl-0 pr-2"
|
||||
>
|
||||
<b-list-group
|
||||
vertical
|
||||
class="h-100"
|
||||
>
|
||||
<draggable
|
||||
v-model="filteredAvailable"
|
||||
:sort="!_disabledSorting"
|
||||
:move="_disableDragging"
|
||||
draggable=".item"
|
||||
group="items"
|
||||
class="overflow-auto h-100"
|
||||
>
|
||||
<b-list-group-item
|
||||
v-for="item in filteredAvailable"
|
||||
:key="item.value"
|
||||
class="item mb-3 border rounded"
|
||||
@dblclick="select(item)"
|
||||
>
|
||||
<c-item-picker-item
|
||||
:item="item"
|
||||
:disabled="disabled"
|
||||
:disabled-dragging="disabledDragging"
|
||||
:disabled-sorting="disabledSorting"
|
||||
:hide-icons="hideIcons"
|
||||
@select="select(item)"
|
||||
>
|
||||
<template
|
||||
v-for="(_, slot) of $scopedSlots"
|
||||
v-slot:[slot]="scope"
|
||||
>
|
||||
<slot
|
||||
:name="slot"
|
||||
:textField="textField"
|
||||
:disabled="disabled"
|
||||
:disabled-dragging="disabledDragging"
|
||||
:disabled-sorting="disabledSorting"
|
||||
:hide-icons="hideIcons"
|
||||
v-bind="scope"
|
||||
/>
|
||||
</template>
|
||||
</c-item-picker-item>
|
||||
</b-list-group-item>
|
||||
|
||||
<template #footer>
|
||||
<h6
|
||||
v-if="!filteredAvailable.length && query"
|
||||
class="text-center my-4"
|
||||
>
|
||||
{{ labels.noItemsFound }}
|
||||
</h6>
|
||||
</template>
|
||||
</draggable>
|
||||
</b-list-group>
|
||||
</b-card-body>
|
||||
</b-card>
|
||||
<b-card
|
||||
no-body
|
||||
class="h-100 pl-sm-0 col-sm-6 col-12 p-0"
|
||||
>
|
||||
<b-card-header
|
||||
class="bg-white py-2 pl-2 pr-0"
|
||||
>
|
||||
<div
|
||||
class="d-flex align-items-center"
|
||||
>
|
||||
<label
|
||||
class="text-primary mb-0"
|
||||
>
|
||||
{{ labels.selectedItems }}
|
||||
</label>
|
||||
<b-button
|
||||
v-show="filteredSelected.length && !disabled"
|
||||
variant="link"
|
||||
class="ml-auto px-0 text-muted"
|
||||
@click="unselectAll()"
|
||||
>
|
||||
{{ labels.unselectAllItems }}
|
||||
</b-button>
|
||||
</div>
|
||||
</b-card-header>
|
||||
<b-card-body
|
||||
class="overflow-auto py-0 pl-2 pr-0"
|
||||
>
|
||||
<b-list-group
|
||||
vertical
|
||||
class="h-100"
|
||||
>
|
||||
<draggable
|
||||
v-model="filteredSelected"
|
||||
:sort="!_disabledSorting"
|
||||
:move="_disableDragging"
|
||||
draggable=".item"
|
||||
group="items"
|
||||
class="overflow-auto h-100"
|
||||
>
|
||||
<b-list-group-item
|
||||
v-for="item in filteredSelected"
|
||||
:key="item.value"
|
||||
class="item mb-3 border rounded"
|
||||
@dblclick="unselect(item)"
|
||||
>
|
||||
<c-item-picker-item
|
||||
:item="item"
|
||||
:disabled="disabled"
|
||||
:disabled-dragging="disabledDragging"
|
||||
:disabled-sorting="disabledSorting"
|
||||
:hide-icons="hideIcons"
|
||||
selected
|
||||
@unselect="unselect(item)"
|
||||
>
|
||||
<template
|
||||
v-for="(_, slot) of $scopedSlots"
|
||||
v-slot:[slot]="scope"
|
||||
>
|
||||
<slot
|
||||
:name="slot"
|
||||
:textField="textField"
|
||||
:disabled="disabled"
|
||||
:disabled-dragging="disabledDragging"
|
||||
:disabled-sorting="disabledSorting"
|
||||
:hide-icons="hideIcons"
|
||||
v-bind="scope"
|
||||
/>
|
||||
</template>
|
||||
</c-item-picker-item>
|
||||
</b-list-group-item>
|
||||
|
||||
<template #footer>
|
||||
<h6
|
||||
v-if="!filteredSelected.length && query"
|
||||
class="text-center my-4"
|
||||
>
|
||||
{{ labels.noItemsFound }}
|
||||
</h6>
|
||||
</template>
|
||||
</draggable>
|
||||
</b-list-group>
|
||||
</b-card-body>
|
||||
</b-card>
|
||||
</b-card-body>
|
||||
</b-card>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import draggable from 'vuedraggable'
|
||||
import CItemPickerItem from './CItemPickerItem.vue'
|
||||
import CInputSearch from '../input/CInputSearch.vue'
|
||||
import { throttle } from 'lodash'
|
||||
|
||||
export default {
|
||||
name: 'CItemPicker',
|
||||
|
||||
components: {
|
||||
draggable,
|
||||
CItemPickerItem,
|
||||
CInputSearch,
|
||||
},
|
||||
|
||||
props: {
|
||||
/**
|
||||
* List of all items, available and selected/picked
|
||||
*
|
||||
* Internally, we'll build 2 arrays for each group (available + selected)
|
||||
* and work with them.
|
||||
* On the outside, we'll always deal input array of (full) items and
|
||||
* array of selected/picked values of those items.
|
||||
*
|
||||
* This component mimics the behaviour of <b-form-select> component from
|
||||
* Vue Bootstrap and could serve as a drop-in replacement!
|
||||
*/
|
||||
options: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
|
||||
/**
|
||||
* List of values that can be found in the items
|
||||
*/
|
||||
value: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
|
||||
valueField: {
|
||||
type: String,
|
||||
default: 'value',
|
||||
},
|
||||
|
||||
textField: {
|
||||
type: String,
|
||||
default: 'text',
|
||||
},
|
||||
|
||||
labels: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
searchPlaceholder: 'Filter items',
|
||||
availableItems: 'Available',
|
||||
selectAllItems: 'Select all',
|
||||
selectedItems: 'Selected',
|
||||
unselectAllItems: 'Unselect all',
|
||||
noItemsFound: 'No items found',
|
||||
}),
|
||||
},
|
||||
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
},
|
||||
|
||||
disabledFilter: {
|
||||
type: Boolean,
|
||||
},
|
||||
|
||||
disabledSorting: {
|
||||
type: Boolean,
|
||||
},
|
||||
|
||||
disabledDragging: {
|
||||
type: Boolean,
|
||||
},
|
||||
|
||||
hideIcons: {
|
||||
type: Boolean,
|
||||
},
|
||||
|
||||
hideFilter: {
|
||||
type: Boolean,
|
||||
},
|
||||
},
|
||||
|
||||
data () {
|
||||
return {
|
||||
query: '',
|
||||
available: [],
|
||||
selected: [],
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
_disabledFilter () {
|
||||
return this.disabled || this.disabledFilter
|
||||
},
|
||||
|
||||
_disabledSorting () {
|
||||
return this.disabled || this.disabledSorting
|
||||
},
|
||||
|
||||
/**
|
||||
* Provides list of all available items, filtered by query
|
||||
* and a setter for draggable component to update available items on drag
|
||||
*/
|
||||
filteredAvailable: {
|
||||
get () {
|
||||
const q = this.query.toLowerCase()
|
||||
return this.available.filter(i => i.text.toLowerCase().indexOf(q) > -1)
|
||||
},
|
||||
|
||||
set (items) {
|
||||
this.available = items
|
||||
},
|
||||
},
|
||||
|
||||
/**
|
||||
* Provides list of all selected items, filtered by query
|
||||
* and a setter for draggable component to update selected items on drag
|
||||
*/
|
||||
filteredSelected: {
|
||||
get () {
|
||||
const q = this.query.toLowerCase()
|
||||
return this.selected.filter(i => i.text.toLowerCase().indexOf(q) > -1)
|
||||
},
|
||||
|
||||
set (items) {
|
||||
this.selected = items
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
/**
|
||||
* Update parent component (if needed)
|
||||
* @param v
|
||||
*/
|
||||
selected: {
|
||||
immediate: false,
|
||||
handler (items) {
|
||||
const value = items.map(i => i[this.valueField])
|
||||
|
||||
// satisfy value.sync
|
||||
this.$emit('update:value', value)
|
||||
|
||||
// satisfy v-model
|
||||
this.$emit('input', value)
|
||||
}
|
||||
},
|
||||
|
||||
options: {
|
||||
deep: true,
|
||||
immediate: true,
|
||||
handler () {
|
||||
this.sync()
|
||||
},
|
||||
},
|
||||
|
||||
value: {
|
||||
immediate: false,
|
||||
handler (value = [], oldValue = []) {
|
||||
/**
|
||||
* Make sure we do not fall into an infinite loop
|
||||
*
|
||||
* If we update the value thenn sync will trigger recomputation of selected
|
||||
* Which then emits the update event and the loop will begin
|
||||
*/
|
||||
if (value.length === oldValue.length) {
|
||||
if (value.filter(v => !oldValue.includes(v)).length === 0) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
this.sync()
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
created () {
|
||||
this.options.forEach(o => {
|
||||
if (typeof o !== 'object') {
|
||||
throw new Error('expecting array of objects for options prop')
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
methods: {
|
||||
_disableDragging (e) {
|
||||
if (this.disabledDragging && e.to !== e.from) {
|
||||
return false
|
||||
}
|
||||
},
|
||||
|
||||
selectAll () {
|
||||
if (this.disabledSorting) {
|
||||
// sorting disabled, just reuse list from the frozen list
|
||||
this.selected = [...this.frozen()]
|
||||
} else {
|
||||
// sorting enabled, push all items left in available
|
||||
// at the end of the list
|
||||
this.selected.push(...this.available)
|
||||
}
|
||||
|
||||
this.available = []
|
||||
},
|
||||
|
||||
select: throttle(function (item) {
|
||||
// remove available
|
||||
this.available = this.available.filter(i => i !== item)
|
||||
|
||||
// put the selected item in the place where it was (if sorting is disabled)
|
||||
// or at the end
|
||||
if (this.disabledSorting) {
|
||||
this.selected = this.frozen().filter(i => !this.available.includes(i))
|
||||
} else {
|
||||
if (!this.selected.some(({ value = '' }) => value === item.value)) {
|
||||
this.selected.push(item)
|
||||
}
|
||||
}
|
||||
}, 300),
|
||||
|
||||
unselectAll () {
|
||||
this.available = [...this.frozen()]
|
||||
this.selected = []
|
||||
},
|
||||
|
||||
unselect (item) {
|
||||
// filtering out the unselected item
|
||||
this.selected = this.selected.filter(i => i !== item)
|
||||
|
||||
// sync available from the list of frozen items without selected
|
||||
this.available = this.frozen().filter(i => !this.selected.includes(i))
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns true given item is selected or not
|
||||
*
|
||||
* Item is considered selected if value of item's value-field is inside the selected array.
|
||||
*
|
||||
* @param item object
|
||||
* @returns boolean
|
||||
*/
|
||||
isPicked (item) {
|
||||
return item[this.valueField] && this.value.includes(item[this.valueField])
|
||||
},
|
||||
|
||||
sync () {
|
||||
/**
|
||||
* filter all unpicked options, freeze each item in the array and
|
||||
* build list of available items
|
||||
*/
|
||||
this.available = this.frozen().filter(opt => !this.isPicked(opt))
|
||||
|
||||
/**
|
||||
* filter all unpicked options, freeze each item in the array and
|
||||
* build list of selected items, traverse value to keep order
|
||||
*/
|
||||
this.selected = this.value.map(v => {
|
||||
return this.frozen().find(item => item[this.valueField] === v)
|
||||
}).filter(f => f)
|
||||
},
|
||||
|
||||
frozen () {
|
||||
return this.options.map(Object.freeze)
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
93
lib/vue/src/components/itemPicker/CItemPickerItem.vue
Normal file
93
lib/vue/src/components/itemPicker/CItemPickerItem.vue
Normal file
@@ -0,0 +1,93 @@
|
||||
<template>
|
||||
<div
|
||||
class="d-flex align-items-center"
|
||||
>
|
||||
<font-awesome-icon
|
||||
v-if="!disabled && !disabledDragging && !disabledSorting && !hideIcons"
|
||||
:icon="['fas', 'grip-vertical']"
|
||||
class="handle align-baseline mr-3 text-primary"
|
||||
/>
|
||||
<b
|
||||
class="cursor-default text-truncate"
|
||||
>
|
||||
<slot
|
||||
v-bind="item"
|
||||
>
|
||||
{{ item[textField] }}
|
||||
</slot>
|
||||
</b>
|
||||
<template
|
||||
v-if="_hideIcons"
|
||||
>
|
||||
<font-awesome-icon
|
||||
v-if="selected"
|
||||
:icon="['far', 'eye']"
|
||||
class="align-baseline ml-auto text-muted pointer"
|
||||
@click="$emit('unselect')"
|
||||
/>
|
||||
<font-awesome-icon
|
||||
v-else
|
||||
:icon="['fas', 'eye-slash']"
|
||||
class="align-baseline ml-auto text-muted pointer"
|
||||
@click="$emit('select')"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'CItemPickerItem',
|
||||
|
||||
props: {
|
||||
item: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
|
||||
textField: {
|
||||
type: String,
|
||||
default: 'text',
|
||||
},
|
||||
|
||||
selected: {
|
||||
type: Boolean,
|
||||
},
|
||||
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
},
|
||||
|
||||
disabledDragging: {
|
||||
type: Boolean,
|
||||
},
|
||||
|
||||
disabledSorting: {
|
||||
type: Boolean,
|
||||
},
|
||||
|
||||
hideIcons: {
|
||||
type: Boolean,
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
_hideIcons () {
|
||||
return !this.disabled && !this.hideIcons
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
<style scoped>
|
||||
.handle {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.cursor-default {
|
||||
cursor: default;
|
||||
}
|
||||
</style>
|
||||
2
lib/vue/src/components/itemPicker/index.ts
Normal file
2
lib/vue/src/components/itemPicker/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as CItemPicker } from './CItemPicker.vue'
|
||||
export { default as CItemPickerItem } from './CItemPickerItem.vue'
|
||||
87
lib/vue/src/components/lightbox/CLightbox.vue
Normal file
87
lib/vue/src/components/lightbox/CLightbox.vue
Normal file
@@ -0,0 +1,87 @@
|
||||
<template>
|
||||
<div class="lightbox">
|
||||
<header>
|
||||
<div class="left">
|
||||
<slot name="header.left" />
|
||||
</div>
|
||||
<div class="right">
|
||||
<slot name="header.right" />
|
||||
<button
|
||||
class="closer"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<main>
|
||||
<slot name="main" />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Temporary style; update when messaging is updated with bootstrap */
|
||||
|
||||
.lightbox {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
|
||||
background-color: #EEF1F3;
|
||||
z-index: 2000;
|
||||
}
|
||||
|
||||
.lightbox header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
background-color: #FFFFFF;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
right: 0;
|
||||
box-shadow: 0 0 3px #1E1E1E41;
|
||||
z-index: 1000;
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
.lightbox header .left {
|
||||
margin-left: 10px;
|
||||
}
|
||||
.lightbox header .right {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.lightbox header > div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.lightbox main {
|
||||
position: absolute;
|
||||
top: 50px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
overflow-y: scroll;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.lightbox header button {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-size: 25px;
|
||||
font-weight: bold;
|
||||
margin: 10px;
|
||||
cursor: pointer;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.lightbox header button:last-of-type {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
</style>
|
||||
1
lib/vue/src/components/lightbox/index.ts
Normal file
1
lib/vue/src/components/lightbox/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as CLightbox } from './CLightbox.vue'
|
||||
43
lib/vue/src/components/loader/CLoaderLogo.vue
Normal file
43
lib/vue/src/components/loader/CLoaderLogo.vue
Normal file
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<b-container
|
||||
fluid
|
||||
class="d-flex justify-content-center vh-100 logo"
|
||||
>
|
||||
<img
|
||||
v-if="logo"
|
||||
:src="logo"
|
||||
class="w-25 my-auto"
|
||||
>
|
||||
</b-container>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
logo: {
|
||||
type: String,
|
||||
default: () => ''
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.logo {
|
||||
img {
|
||||
animation: pulse 4.2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
65% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
1
lib/vue/src/components/loader/index.ts
Normal file
1
lib/vue/src/components/loader/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as CLoaderLogo } from './CLoaderLogo.vue'
|
||||
373
lib/vue/src/components/navigation/CSidebar.vue
Normal file
373
lib/vue/src/components/navigation/CSidebar.vue
Normal file
@@ -0,0 +1,373 @@
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
@mouseleave="onHover(false)"
|
||||
>
|
||||
<b-sidebar
|
||||
v-model="isExpanded"
|
||||
:sidebar-class="`sidebar ${isExpanded ? 'expanded' : ''}`"
|
||||
:header-class="`d-block sidebar-header ${isExpanded ? 'expanded border-bottom p-2' : ''}`"
|
||||
:body-class="`bg-white ${isExpanded ? 'py-2 px-3' : ''}`"
|
||||
:footer-class="`bg-white rounded-right ${isExpanded ? 'p-2' : ''}`"
|
||||
:no-header="!isExpanded"
|
||||
:backdrop="isMobile"
|
||||
:shadow="isExpanded"
|
||||
no-slide
|
||||
:right="right"
|
||||
no-close-on-route-change
|
||||
no-close-on-esc
|
||||
>
|
||||
<template #header>
|
||||
<div
|
||||
class="d-flex align-items-center justify-content-between px-2"
|
||||
style="height: 50px;"
|
||||
>
|
||||
<img
|
||||
class="logo w-auto border-0"
|
||||
:src="logo"
|
||||
>
|
||||
|
||||
<b-button
|
||||
v-if="isMobile"
|
||||
variant="outline-light border-0"
|
||||
class="d-flex align-items-center justify-content-center p-2"
|
||||
@click="closeSidebar()"
|
||||
>
|
||||
<font-awesome-icon
|
||||
:icon="['fas', 'times']"
|
||||
class="h6 mb-0 text-dark"
|
||||
/>
|
||||
</b-button>
|
||||
|
||||
<b-button
|
||||
v-else
|
||||
variant="outline-light border-0"
|
||||
class="d-flex align-items-center justify-content-center p-2"
|
||||
@click="togglePin()"
|
||||
>
|
||||
<font-awesome-icon
|
||||
:icon="['fas', 'thumbtack']"
|
||||
:class="`h6 mb-0 ${isPinned ? 'text-primary' : 'text-secondary'}`"
|
||||
/>
|
||||
</b-button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!isExpanded"
|
||||
class="d-flex align-items-center justify-content-center my-3"
|
||||
>
|
||||
<b-button
|
||||
variant="link"
|
||||
@click="togglePin()"
|
||||
>
|
||||
<font-awesome-icon
|
||||
:icon="['fas', 'chevron-right']"
|
||||
class="h6 mb-0"
|
||||
/>
|
||||
</b-button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isExpanded"
|
||||
class="px-2"
|
||||
>
|
||||
<slot
|
||||
name="header-expanded"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<hr
|
||||
v-if="!isExpanded"
|
||||
class="my-2"
|
||||
>
|
||||
</template>
|
||||
|
||||
<slot
|
||||
v-if="isExpanded"
|
||||
name="body-expanded"
|
||||
/>
|
||||
|
||||
<template #footer>
|
||||
<slot
|
||||
v-if="isExpanded"
|
||||
name="footer-expanded"
|
||||
/>
|
||||
</template>
|
||||
</b-sidebar>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="d-flex align-items-center justify-content-center tab position-absolute p-2"
|
||||
>
|
||||
<b-button
|
||||
v-if="expandOnHover && !disabledRoutes.includes($route.name)"
|
||||
variant="outline-light"
|
||||
size="lg"
|
||||
class="d-flex align-items-center border-0"
|
||||
@mouseover="onHover(true)"
|
||||
>
|
||||
<font-awesome-icon
|
||||
:icon="['fas', 'bars']"
|
||||
class="h4 mb-0 text-primary"
|
||||
/>
|
||||
</b-button>
|
||||
|
||||
<b-button
|
||||
v-else-if="!disabledRoutes.includes($route.name)"
|
||||
variant="outline-light"
|
||||
size="lg"
|
||||
class="d-flex align-items-center p-2 border-0"
|
||||
:to="{ name: 'root' }"
|
||||
>
|
||||
<font-awesome-icon
|
||||
:icon="['fas', 'home']"
|
||||
class="h4 mb-0 text-primary"
|
||||
/>
|
||||
</b-button>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="d-flex align-items-center border-0 p-2"
|
||||
>
|
||||
<img
|
||||
class="icon w-auto border-0"
|
||||
:src="icon"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { throttle } from 'lodash'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
expanded: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
|
||||
pinned: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
|
||||
expandOnHover: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
|
||||
disabledRoutes: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
|
||||
icon: {
|
||||
type: String,
|
||||
default: () => ''
|
||||
},
|
||||
|
||||
logo: {
|
||||
type: String,
|
||||
default: () => ''
|
||||
},
|
||||
|
||||
right: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
|
||||
data () {
|
||||
return {
|
||||
sidebar_settings : {}
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
isExpanded: {
|
||||
get () {
|
||||
return this.expanded
|
||||
},
|
||||
|
||||
set (expanded) {
|
||||
this.$emit('update:expanded', expanded)
|
||||
},
|
||||
},
|
||||
|
||||
isPinned: {
|
||||
get () {
|
||||
return this.pinned
|
||||
},
|
||||
|
||||
set (pinned) {
|
||||
this.$emit('update:pinned', pinned)
|
||||
},
|
||||
},
|
||||
|
||||
isMobile () {
|
||||
return window.innerWidth < 576
|
||||
},
|
||||
},
|
||||
|
||||
created () {
|
||||
this.$root.$on('close-sidebar', () => {
|
||||
this.isExpanded = false
|
||||
this.isPinned = false
|
||||
})
|
||||
},
|
||||
|
||||
beforeDestroy () {
|
||||
this.$root.$off('close-sidebar')
|
||||
},
|
||||
|
||||
watch: {
|
||||
'$route.name': {
|
||||
immediate: true,
|
||||
handler (name) {
|
||||
// If sidebar should be disabled on route, close and unpin when navigating to route
|
||||
if (this.disabledRoutes.includes(name)) {
|
||||
this.isPinned = false
|
||||
this.isExpanded = false
|
||||
} else if(this.expandOnHover){
|
||||
this.defaultSidebarAppearance()
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
onHover: throttle(function (expand) {
|
||||
if (!this.pinned && this.expandOnHover) {
|
||||
setTimeout(() => {
|
||||
this.isExpanded = expand
|
||||
}, expand ? 0 : 100)
|
||||
}
|
||||
}, 300),
|
||||
|
||||
togglePin () {
|
||||
this.saveSettings(!this.isPinned)
|
||||
this.isPinned = !this.isPinned
|
||||
},
|
||||
|
||||
defaultSidebarAppearance () {
|
||||
const localstorage_settings = JSON.parse(window.localStorage.getItem('sidebar_settings'))
|
||||
if (localstorage_settings) {
|
||||
this.sidebar_settings = localstorage_settings
|
||||
}
|
||||
const app_sidebar = (localstorage_settings || {})[this.$root.$options.name]
|
||||
if (!this.isMobile) {
|
||||
if (app_sidebar) {
|
||||
this.isExpanded = app_sidebar.pinned
|
||||
this.isPinned = app_sidebar.pinned
|
||||
} else {
|
||||
this.openSidebar()
|
||||
}
|
||||
} else {
|
||||
this.closeSidebar()
|
||||
}
|
||||
},
|
||||
|
||||
saveSettings (pinned) {
|
||||
if (this.sidebar_settings[this.$root.$options.name]) {
|
||||
this.sidebar_settings[this.$root.$options.name].pinned = pinned
|
||||
} else {
|
||||
this.sidebar_settings[this.$root.$options.name] = { pinned: pinned }
|
||||
}
|
||||
window.localStorage.setItem('sidebar_settings', JSON.stringify(this.sidebar_settings))
|
||||
},
|
||||
|
||||
openSidebar () {
|
||||
this.isPinned = true
|
||||
this.isExpanded = true
|
||||
},
|
||||
|
||||
closeSidebar () {
|
||||
this.isPinned = false
|
||||
this.isExpanded = false
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
$header-height: 64px;
|
||||
|
||||
.tab {
|
||||
z-index: 1021;
|
||||
top: 0;
|
||||
height: $header-height;
|
||||
width: 66px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
max-height: 40px;
|
||||
max-width: 40px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
max-height: 40px;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
height: $header-height;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
$nav-width: 320px;
|
||||
$sidebar-bg: #F4F7FA;
|
||||
|
||||
.sidebar {
|
||||
display: flex !important;
|
||||
left: calc(-#{$nav-width}) !important;
|
||||
-webkit-transition: left 0.15s ease-in-out;
|
||||
-moz-transition: left 0.15s ease-in-out;
|
||||
-o-transition: left 0.15s ease-in-out;
|
||||
transition: left 0.15s ease-in-out;
|
||||
|
||||
header {
|
||||
background-color: white;
|
||||
|
||||
&.expanded {
|
||||
background-color: $sidebar-bg !important;
|
||||
}
|
||||
}
|
||||
|
||||
&.expanded {
|
||||
left: 0 !important;
|
||||
-webkit-transition: left 0.2s ease-in-out;
|
||||
-moz-transition: left 0.2s ease-in-out;
|
||||
-o-transition: left 0.2s ease-in-out;
|
||||
transition: left 0.2s ease-in-out;
|
||||
}
|
||||
}
|
||||
|
||||
[dir="rtl"] {
|
||||
.sidebar {
|
||||
right: calc(-#{$nav-width}) !important;
|
||||
-webkit-transition: right 0.15s ease-in-out;
|
||||
-moz-transition: right 0.15s ease-in-out;
|
||||
-o-transition: right 0.15s ease-in-out;
|
||||
transition: right 0.15s ease-in-out;
|
||||
|
||||
header {
|
||||
background-color: white;
|
||||
|
||||
&.expanded {
|
||||
background-color: $sidebar-bg !important;
|
||||
}
|
||||
}
|
||||
|
||||
&.expanded {
|
||||
right: 0 !important;
|
||||
-webkit-transition: right 0.2s ease-in-out;
|
||||
-moz-transition: right 0.2s ease-in-out;
|
||||
-o-transition: right 0.2s ease-in-out;
|
||||
transition: right 0.2s ease-in-out;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
180
lib/vue/src/components/navigation/CSidebarNavItems.vue
Normal file
180
lib/vue/src/components/navigation/CSidebarNavItems.vue
Normal file
@@ -0,0 +1,180 @@
|
||||
<template>
|
||||
<div class="nav-sidebar">
|
||||
<b-button
|
||||
v-for="({page = {}, params = {}, children = []}) of items"
|
||||
:key="pageIndex(page)"
|
||||
variant="link"
|
||||
class="w-100 text-dark text-decoration-none p-0 pt-2 nav-item"
|
||||
active-class="nav-active"
|
||||
exact-active-class="nav-active"
|
||||
:to="{ name: page.name || defaultRouteName, params }"
|
||||
>
|
||||
<span
|
||||
class="d-inline-block w-75 text-nowrap text-truncate"
|
||||
@click="closeSidebar()"
|
||||
>
|
||||
<font-awesome-icon
|
||||
v-if="page.icon"
|
||||
class="icon"
|
||||
:icon="page.icon"
|
||||
/>
|
||||
<span
|
||||
class="title"
|
||||
>
|
||||
{{ page.title }}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<template
|
||||
v-if="children.length"
|
||||
>
|
||||
<b-button
|
||||
variant="link"
|
||||
class="p-0 float-right"
|
||||
:disabled="showChildren({ params, children })"
|
||||
@click.self.stop.prevent="toggle(page)"
|
||||
>
|
||||
<font-awesome-icon
|
||||
v-if="!collapses[pageIndex(page)]"
|
||||
class="pointer-none"
|
||||
:icon="['fas', 'chevron-down']"
|
||||
/>
|
||||
<font-awesome-icon
|
||||
v-else
|
||||
class="pointer-none"
|
||||
:icon="['fas', 'chevron-up']"
|
||||
/>
|
||||
</b-button>
|
||||
|
||||
<b-collapse
|
||||
:visible="collapses[pageIndex(page)] || showChildren({ params, children })"
|
||||
@click.stop.prevent
|
||||
>
|
||||
<c-sidebar-nav-items
|
||||
class="ml-2"
|
||||
:items="children"
|
||||
:start-expanded="startExpanded"
|
||||
:default-route-name="defaultRouteName"
|
||||
v-on="$listeners"
|
||||
/>
|
||||
</b-collapse>
|
||||
</template>
|
||||
</b-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'CSidebarNavItems',
|
||||
|
||||
props: {
|
||||
/*
|
||||
* {
|
||||
page: { name, title }
|
||||
params: {...}
|
||||
}
|
||||
*/
|
||||
items: {
|
||||
type: Array,
|
||||
required: true,
|
||||
default: () => [],
|
||||
},
|
||||
defaultRouteName: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
startExpanded: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
|
||||
data () {
|
||||
return {
|
||||
collapses: {},
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
items: {
|
||||
immediate: true,
|
||||
handler (items = []) {
|
||||
items.forEach(({ page }) => {
|
||||
const px = this.pageIndex(page)
|
||||
// Apply startExpanded only if page isn't currently expanded
|
||||
if (!this.collapses[px]) {
|
||||
this.$set(this.collapses, px, this.startExpanded)
|
||||
}
|
||||
})
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
closeSidebar () {
|
||||
if (window.innerWidth < 576) {
|
||||
this.$root.$emit('close-sidebar')
|
||||
}
|
||||
},
|
||||
|
||||
pageIndex (p) {
|
||||
return p.pageID || p.name || p.title
|
||||
},
|
||||
|
||||
toggle (p) {
|
||||
const px = this.pageIndex(p)
|
||||
this.$set(this.collapses, px, !this.collapses[px])
|
||||
},
|
||||
|
||||
// Recursively check for child pages that are open, so that parents can open aswell
|
||||
showChildren ({ params = {}, children = [] }) {
|
||||
const partialParamsMatch = Object.entries(params).some(([key, value]) => {
|
||||
return this.$route.params[key] === value
|
||||
})
|
||||
|
||||
if (partialParamsMatch) {
|
||||
return partialParamsMatch
|
||||
}
|
||||
|
||||
return children.map(c => this.showChildren(c)).some(isOpen => isOpen)
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
// This has to be there, so chevrons are clickable inside the button
|
||||
.pointer-none {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.svg-inline--fa {
|
||||
width: 30px;
|
||||
}
|
||||
|
||||
.nav-item > span > {
|
||||
.title {
|
||||
font-family: 'Poppins-Regular'
|
||||
}
|
||||
}
|
||||
|
||||
.nav-active > span > {
|
||||
.icon {
|
||||
color: #4D7281;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-family: 'Poppins-SemiBold'
|
||||
}
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
[dir="rtl"] {
|
||||
.nav-item {
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
317
lib/vue/src/components/navigation/CTopbar.vue
Normal file
317
lib/vue/src/components/navigation/CTopbar.vue
Normal file
@@ -0,0 +1,317 @@
|
||||
<template>
|
||||
<div class="header-navigation d-flex align-items-center justify-content-between sticky-top pr-3 pl-5 ">
|
||||
<div
|
||||
class="d-flex text-nowrap flex-grow-1 align-items-center header h-100 w-100"
|
||||
>
|
||||
<template>
|
||||
<div
|
||||
class="spacer"
|
||||
:class="{
|
||||
'expanded': sidebarPinned,
|
||||
}"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<div
|
||||
class="d-flex align-items-center ml-2 text-truncate mr-auto h-100"
|
||||
>
|
||||
<h2
|
||||
class="d-none d-sm-block mb-0"
|
||||
>
|
||||
<slot name="title" />
|
||||
</h2>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="topbar-tools ml-3">
|
||||
<slot name="tools" />
|
||||
</div>
|
||||
|
||||
<b-button
|
||||
v-if="!hideAppSelector && !settings.hideAppSelector"
|
||||
variant="outline-light"
|
||||
:href="appSelectorURL"
|
||||
size="lg"
|
||||
class="d-flex align-items-center justify-content-center text-dark border-0 nav-icon rounded-circle ml-2"
|
||||
>
|
||||
<font-awesome-icon
|
||||
class="m-0 h5"
|
||||
:icon="['fas', 'grip-horizontal']"
|
||||
/>
|
||||
</b-button>
|
||||
|
||||
<b-dropdown
|
||||
data-test-id="dropdown-helper"
|
||||
v-if="!settings.hideHelp"
|
||||
size="lg"
|
||||
variant="outline-light"
|
||||
class="nav-icon mx-1"
|
||||
toggle-class="text-decoration-none text-dark rounded-circle border-0 w-100"
|
||||
menu-class="topbar-dropdown-menu border-0 shadow-sm text-dark font-weight-bold mt-2"
|
||||
right
|
||||
no-caret
|
||||
>
|
||||
<template #button-content>
|
||||
<div
|
||||
class="d-flex align-items-center justify-content-center"
|
||||
>
|
||||
<font-awesome-icon
|
||||
class="m-0 h5"
|
||||
:icon="['far', 'question-circle']"
|
||||
/>
|
||||
<span class="sr-only">
|
||||
{{ labels.helpForum }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<div>
|
||||
<slot name="help-dropdown" />
|
||||
</div>
|
||||
<b-dropdown-item
|
||||
v-for="(helpLink, index) in helpLinks"
|
||||
:key="index"
|
||||
:href="helpLink.url"
|
||||
:target="helpLink.newTab ? '_blank' : ''"
|
||||
>
|
||||
{{ helpLink.handle }}
|
||||
</b-dropdown-item>
|
||||
<b-dropdown-item
|
||||
data-test-id="dropdown-helper-forum"
|
||||
v-if="!settings.hideForumLink"
|
||||
href="https://forum.cortezaproject.org/"
|
||||
target="_blank"
|
||||
>
|
||||
{{ labels.helpForum }}
|
||||
</b-dropdown-item>
|
||||
<b-dropdown-item
|
||||
data-test-id="dropdown-helper-docs"
|
||||
v-if="!settings.hideDocumentationLink"
|
||||
:href="documentationURL"
|
||||
target="_blank"
|
||||
>
|
||||
{{ labels.helpDocumentation }}
|
||||
</b-dropdown-item>
|
||||
<b-dropdown-item
|
||||
data-test-id="dropdown-helper-feedback"
|
||||
v-if="!settings.hideFeedbackLink"
|
||||
href="mailto:info@crust.tech"
|
||||
target="_blank"
|
||||
>
|
||||
{{ labels.helpFeedback }}
|
||||
</b-dropdown-item>
|
||||
<b-dropdown-divider
|
||||
v-if="!onlyVersion"
|
||||
/>
|
||||
<b-dropdown-item
|
||||
disabled
|
||||
class="small"
|
||||
>
|
||||
{{ labels.helpVersion }}
|
||||
<br>
|
||||
{{ frontendVersion }}
|
||||
</b-dropdown-item>
|
||||
</b-dropdown>
|
||||
<b-dropdown
|
||||
data-test-id="dropdown-profile"
|
||||
v-if="!settings.hideProfile"
|
||||
data-v-onboarding="profile"
|
||||
size="lg"
|
||||
variant="outline-light"
|
||||
class="nav-user-icon"
|
||||
toggle-class="nav-icon text-decoration-none text-dark rounded-circle border"
|
||||
menu-class="topbar-dropdown-menu border-0 shadow-sm text-dark font-weight-bold mt-2"
|
||||
right
|
||||
no-caret
|
||||
>
|
||||
<template #button-content>
|
||||
<div
|
||||
class="d-flex align-items-center justify-content-center"
|
||||
>
|
||||
<font-awesome-icon
|
||||
class="m-0 h5"
|
||||
:icon="['far', 'user']"
|
||||
/>
|
||||
<span class="sr-only">
|
||||
{{ labels.helpForum }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<b-dropdown-text class="text-muted mb-2">
|
||||
{{ labels.userSettingsLoggedInAs }}
|
||||
</b-dropdown-text>
|
||||
<div>
|
||||
<slot name="avatar-dropdown" />
|
||||
</div>
|
||||
<b-dropdown-item
|
||||
v-for="(profileLink, index) in profileLinks"
|
||||
:key="index"
|
||||
:href="profileLink.url"
|
||||
:target="profileLink.newTab ? '_blank' : ''"
|
||||
>
|
||||
{{ profileLink.handle }}
|
||||
</b-dropdown-item>
|
||||
<b-dropdown-item
|
||||
data-test-id="dropdown-profile-user"
|
||||
v-if="!settings.hideProfileLink"
|
||||
:href="userProfileURL"
|
||||
target="_blank"
|
||||
>
|
||||
{{ labels.userSettingsProfile }}
|
||||
</b-dropdown-item>
|
||||
<b-dropdown-item
|
||||
data-test-id="dropdown-profile-change-password"
|
||||
v-if="!settings.hideChangePasswordLink"
|
||||
:href="changePasswordURL"
|
||||
target="_blank"
|
||||
>
|
||||
{{ labels.userSettingsChangePassword }}
|
||||
</b-dropdown-item>
|
||||
<b-dropdown-divider />
|
||||
<b-dropdown-item
|
||||
data-test-id="dropdown-profile-logout"
|
||||
href=""
|
||||
@click="$auth.logout()"
|
||||
class="mt-2"
|
||||
>
|
||||
{{ labels.userSettingsLogout }}
|
||||
</b-dropdown-item>
|
||||
</b-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
sidebarPinned: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
default: false,
|
||||
},
|
||||
|
||||
hideAppSelector: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
|
||||
appSelectorURL: {
|
||||
type: String,
|
||||
default: '../'
|
||||
},
|
||||
|
||||
settings: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
|
||||
labels: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
userProfileURL () {
|
||||
return this.$auth.cortezaAuthURL
|
||||
},
|
||||
|
||||
changePasswordURL () {
|
||||
return `${this.$auth.cortezaAuthURL}/change-password`
|
||||
},
|
||||
|
||||
documentationURL () {
|
||||
const [year, month] = VERSION.split('.')
|
||||
return `https://docs.cortezaproject.org/corteza-docs/${year}.${month}/index.html`
|
||||
},
|
||||
|
||||
helpLinks () {
|
||||
const { helpLinks = [] } = this.settings || {}
|
||||
return (helpLinks || []).filter(({ handle, url }) => handle && url)
|
||||
},
|
||||
|
||||
profileLinks () {
|
||||
const { profileLinks = [] } = this.settings || {}
|
||||
return (profileLinks || []).filter(({ handle, url }) => handle && url)
|
||||
},
|
||||
|
||||
onlyVersion () {
|
||||
const {
|
||||
hideForumLink,
|
||||
hideDocumentationLink,
|
||||
hideFeedbackLink,
|
||||
} = this.settings || {}
|
||||
|
||||
return !this.helpLinks.length && hideForumLink && hideDocumentationLink && hideFeedbackLink
|
||||
},
|
||||
|
||||
frontendVersion () {
|
||||
/* eslint-disable no-undef */
|
||||
return VERSION
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
$header-height: 64px;
|
||||
$nav-width: 320px;
|
||||
$nav-icon-size: 40px;
|
||||
$nav-user-icon-size: 50px;
|
||||
|
||||
.icon-logo {
|
||||
height: calc(#{$header-height} / 2);
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
width: $nav-icon-size;
|
||||
height: $nav-icon-size;
|
||||
}
|
||||
|
||||
.nav-user-icon {
|
||||
width: $nav-user-icon-size;
|
||||
height: $nav-user-icon-size;
|
||||
}
|
||||
|
||||
.header-navigation {
|
||||
width: 100vw;
|
||||
height: $header-height;
|
||||
background-color: #F3F3F5 !important;
|
||||
|
||||
h2 {
|
||||
padding-left: calc(0.5rem + 2px);
|
||||
}
|
||||
}
|
||||
|
||||
.topbar-dropdown-menu {
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
min-width: 0px;
|
||||
-webkit-transition: min-width 0.15s ease-in-out;
|
||||
-moz-transition: min-width 0.15s ease-in-out;
|
||||
-o-transition: min-width 0.15s ease-in-out;
|
||||
transition: min-width 0.15s ease-in-out;
|
||||
|
||||
&.expanded {
|
||||
min-width: calc(#{$nav-width} - 42px);
|
||||
-webkit-transition: min-width 0.2s ease-in-out;
|
||||
-moz-transition: min-width 0.2s ease-in-out;
|
||||
-o-transition: min-width 0.2s ease-in-out;
|
||||
transition: min-width 0.2s ease-in-out;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
.topbar-tools {
|
||||
.vue-portal-target {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
3
lib/vue/src/components/navigation/index.ts
Normal file
3
lib/vue/src/components/navigation/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as CTopbar } from './CTopbar.vue'
|
||||
export { default as CSidebar } from './CSidebar.vue'
|
||||
export { default as CSidebarNavItems } from './CSidebarNavItems.vue'
|
||||
103
lib/vue/src/components/permissions/CPermissionsButton.vue
Normal file
103
lib/vue/src/components/permissions/CPermissionsButton.vue
Normal file
@@ -0,0 +1,103 @@
|
||||
<template>
|
||||
<a
|
||||
v-if="link"
|
||||
data-test-id="link-permissions"
|
||||
class="pointer"
|
||||
:title="tooltip"
|
||||
@click="onClick"
|
||||
>
|
||||
<font-awesome-icon :icon="['fas', 'lock']" />
|
||||
</a>
|
||||
<b-button
|
||||
v-else
|
||||
data-test-id="button-permissions"
|
||||
:title="tooltip"
|
||||
:variant="buttonVariant"
|
||||
@click="onClick"
|
||||
>
|
||||
<slot>
|
||||
<font-awesome-icon v-if="showButtonIcon" :icon="['fas', 'lock']" />
|
||||
<span v-if="buttonLabel">
|
||||
{{ buttonLabel }}
|
||||
</span>
|
||||
</slot>
|
||||
</b-button>
|
||||
</template>
|
||||
<script lang="js">
|
||||
import { modalOpenEventName } from './def.ts'
|
||||
import { library } from '@fortawesome/fontawesome-svg-core'
|
||||
import { faLock } from '@fortawesome/free-solid-svg-icons'
|
||||
|
||||
library.add(faLock)
|
||||
|
||||
export default {
|
||||
props: {
|
||||
link: {
|
||||
type: Boolean,
|
||||
},
|
||||
|
||||
buttonVariant: {
|
||||
type: String,
|
||||
default: undefined,
|
||||
},
|
||||
|
||||
resource: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
|
||||
title: {
|
||||
type: String,
|
||||
default: undefined,
|
||||
},
|
||||
|
||||
buttonLabel: {
|
||||
type: String,
|
||||
default: undefined,
|
||||
},
|
||||
|
||||
modalOpenEvent: {
|
||||
type: String,
|
||||
default: modalOpenEventName,
|
||||
},
|
||||
|
||||
target: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: undefined,
|
||||
},
|
||||
|
||||
showButtonIcon: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
|
||||
// Use this prop if you want the translations to look for all-specific key instead of all/specific
|
||||
allSpecific: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
|
||||
tooltip: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
onClick () {
|
||||
this.$root.$emit(this.modalOpenEvent, {
|
||||
target: this.target,
|
||||
resource: this.resource,
|
||||
title: this.title,
|
||||
allSpecific: this.allSpecific
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
597
lib/vue/src/components/permissions/CPermissionsModal.vue
Normal file
597
lib/vue/src/components/permissions/CPermissionsModal.vue
Normal file
@@ -0,0 +1,597 @@
|
||||
<template>
|
||||
<div>
|
||||
<b-modal
|
||||
id="permissions-modal"
|
||||
:visible="showModal"
|
||||
size="xl"
|
||||
:title="translatedTitle"
|
||||
lazy
|
||||
scrollable
|
||||
:ok-disabled="submitDisabled"
|
||||
:ok-title="labels.save"
|
||||
:cancel-title="labels.cancel"
|
||||
cancel-variant="light"
|
||||
body-class="d-flex flex-column p-0"
|
||||
class="h-100 overflow-hidden"
|
||||
@hide="onHide"
|
||||
@ok="onSubmit"
|
||||
>
|
||||
<b-row
|
||||
no-gutters
|
||||
class="bg-extra-light border-bottom"
|
||||
>
|
||||
<b-col
|
||||
lg="4"
|
||||
class="p-3"
|
||||
>
|
||||
{{ labels.edit.description }}
|
||||
</b-col>
|
||||
<b-col
|
||||
class="d-none d-lg-block border-left p-3"
|
||||
>
|
||||
{{ labels.evaluate.description }}
|
||||
</b-col>
|
||||
</b-row>
|
||||
|
||||
<b-row
|
||||
no-gutters
|
||||
align-v="stretch"
|
||||
class="bg-extra-light"
|
||||
>
|
||||
<b-col
|
||||
lg="4"
|
||||
class="d-flex align-items-center p-3 border-bottom"
|
||||
>
|
||||
<b-form-group
|
||||
class="mb-0 w-100"
|
||||
:label="labels.edit.label"
|
||||
label-class="text-primary"
|
||||
>
|
||||
<vue-select
|
||||
v-model="currentRole"
|
||||
data-test-id="select-user-list-roles"
|
||||
key="roleID"
|
||||
label="name"
|
||||
:clearable="false"
|
||||
:options="roles"
|
||||
append-to-body
|
||||
class="h-100 bg-white"
|
||||
@input="onRoleChange"
|
||||
/>
|
||||
</b-form-group>
|
||||
</b-col>
|
||||
|
||||
<b-col
|
||||
v-for="(e, i) in evaluate"
|
||||
data-test-id="icon-remove"
|
||||
:key="i"
|
||||
lg="2"
|
||||
class="pointer hide-eval border-bottom d-none d-lg-flex flex-column align-items-center justify-content-center overflow-hidden border-left p-3"
|
||||
@click="onHideEval(i)"
|
||||
>
|
||||
<label
|
||||
v-for="(n, index) in getEvalName(e)"
|
||||
:key="index"
|
||||
:title="n"
|
||||
class="pointer text-center text-primary text-break mb-1"
|
||||
>
|
||||
{{ n }}
|
||||
</label>
|
||||
<font-awesome-icon
|
||||
:icon="['fas', 'plus']"
|
||||
class="text-secondary rotate mt-1"
|
||||
/>
|
||||
</b-col>
|
||||
|
||||
<b-col
|
||||
v-if="evaluate.length < 4"
|
||||
v-b-modal.permissions-modal-eval
|
||||
data-test-id="icon-add"
|
||||
class="d-none d-lg-flex pointer border-bottom flex-column align-items-center justify-content-center overflow-hidden border-left p-3"
|
||||
>
|
||||
<label
|
||||
class="pointer text-center text-primary text-break mb-1"
|
||||
>
|
||||
{{ labels.add.label }}
|
||||
</label>
|
||||
<font-awesome-icon
|
||||
:icon="['fas', 'plus']"
|
||||
class="text-success d-block mx-auto mt-1"
|
||||
/>
|
||||
</b-col>
|
||||
</b-row>
|
||||
|
||||
<div
|
||||
v-if="processing"
|
||||
class="d-flex flex-column align-items-center justify-content-center h-100 py-4"
|
||||
style="min-height: 50vh;"
|
||||
>
|
||||
<b-spinner />
|
||||
<div>
|
||||
{{ labels.loading }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<b-row
|
||||
v-else
|
||||
no-gutters
|
||||
>
|
||||
<b-col
|
||||
lg="4"
|
||||
class="p-3"
|
||||
>
|
||||
<rules
|
||||
:rules.sync="rules"
|
||||
/>
|
||||
</b-col>
|
||||
<b-col
|
||||
v-for="(e, i) in evaluate"
|
||||
:key="i"
|
||||
lg="2"
|
||||
class="d-none d-lg-flex border-left p-3 bg-extra-light not-allowed"
|
||||
>
|
||||
<div
|
||||
class="d-flex flex-column align-items-center justify-content-between mt-4 w-100"
|
||||
>
|
||||
<h5
|
||||
v-for="r in e.rules"
|
||||
:key="r.operation"
|
||||
:title="getRuleTooltip(r.access === 'unknown-context', !!e.userID)"
|
||||
class="text-center mb-1 mt-2 w-100"
|
||||
>
|
||||
<font-awesome-icon
|
||||
v-if="r.access === 'unknown-context'"
|
||||
:icon="['fas', 'question']"
|
||||
class="text-secondary"
|
||||
/>
|
||||
<font-awesome-icon
|
||||
v-else-if="r.access === 'allow'"
|
||||
:icon="['fas', 'check']"
|
||||
class="text-success"
|
||||
/>
|
||||
<font-awesome-icon
|
||||
v-else
|
||||
:icon="['fas', 'times']"
|
||||
class="text-danger"
|
||||
/>
|
||||
</h5>
|
||||
</div>
|
||||
</b-col>
|
||||
|
||||
<b-col
|
||||
class="d-none d-lg-block pt-4 border-left"
|
||||
/>
|
||||
</b-row>
|
||||
</b-modal>
|
||||
|
||||
<b-modal
|
||||
id="permissions-modal-eval"
|
||||
:title="labels.add.title"
|
||||
centered
|
||||
ok-only
|
||||
:ok-title="labels.add.save"
|
||||
:ok-disabled="!addEnabled"
|
||||
@ok="onAddEval"
|
||||
>
|
||||
<b-form-group
|
||||
:label="labels.add.role.label"
|
||||
label-class="text-primary"
|
||||
class="mb-0"
|
||||
>
|
||||
<vue-select
|
||||
data-test-id="select-role"
|
||||
key="roleID"
|
||||
v-model="add.roleID"
|
||||
:options="roles"
|
||||
label="name"
|
||||
multiple
|
||||
clearable
|
||||
:disabled="!!add.userID"
|
||||
:placeholder="labels.add.role.placeholder"
|
||||
class="bg-white"
|
||||
/>
|
||||
</b-form-group>
|
||||
|
||||
<b-form-group
|
||||
:label="labels.add.user.label"
|
||||
label-class="text-primary"
|
||||
class="mt-3 mb-0"
|
||||
>
|
||||
<vue-select
|
||||
data-test-id="select-user"
|
||||
key="userID"
|
||||
v-model="add.userID"
|
||||
:disabled="!!add.roleID.length"
|
||||
:options="userOptions"
|
||||
:get-option-label="getUserLabel"
|
||||
label="name"
|
||||
clearable
|
||||
:placeholder="labels.add.user.placeholder"
|
||||
class="bg-white"
|
||||
@search="searchUsers"
|
||||
/>
|
||||
</b-form-group>
|
||||
</b-modal>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="js">
|
||||
import { modalOpenEventName, split } from './def.ts'
|
||||
import { VueSelect } from 'vue-select'
|
||||
import Rules from './form/Rules.vue'
|
||||
|
||||
export default {
|
||||
i18nOptions: {
|
||||
namespaces: 'permissions',
|
||||
},
|
||||
|
||||
components: {
|
||||
Rules,
|
||||
VueSelect,
|
||||
},
|
||||
|
||||
props: {
|
||||
labels: {
|
||||
required: false,
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
},
|
||||
|
||||
data () {
|
||||
return {
|
||||
processing: false,
|
||||
|
||||
backendComponentName: undefined,
|
||||
|
||||
resource: undefined,
|
||||
title: undefined,
|
||||
target: undefined,
|
||||
allSpecific: false,
|
||||
|
||||
userOptions: [],
|
||||
|
||||
// List of available permissions (for a specific resource)
|
||||
permissions: [],
|
||||
|
||||
// List of rules for the current role
|
||||
rules: [],
|
||||
|
||||
// List of all available roles
|
||||
roles: [],
|
||||
|
||||
// Current role object
|
||||
currentRole: undefined,
|
||||
|
||||
evaluate: [],
|
||||
|
||||
add: {
|
||||
roleID: [],
|
||||
userID: undefined,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
api () {
|
||||
const s = this.backendComponentName
|
||||
return s ? this['$' + s.charAt(0).toUpperCase() + s.slice(1) + 'API'] : undefined
|
||||
},
|
||||
|
||||
showModal () {
|
||||
return !!(this.resource && this.api)
|
||||
},
|
||||
|
||||
dirty () {
|
||||
return this.collectChangedRules().length > 0
|
||||
},
|
||||
|
||||
submitDisabled () {
|
||||
return !this.dirty
|
||||
},
|
||||
|
||||
addEnabled () {
|
||||
const { roleID = [], userID } = this.add
|
||||
return (roleID && roleID.length) || userID
|
||||
},
|
||||
|
||||
translatedTitle () {
|
||||
if (this.resource) {
|
||||
const { i18nPrefix } = split(this.resource)
|
||||
|
||||
let target
|
||||
if (this.allSpecific) {
|
||||
target = this.$t(`permissions:${i18nPrefix}.all-specific`, { target: this.title, interpolation: { escapeValue: false } })
|
||||
} else if (this.title) {
|
||||
target = this.$t(`permissions:${i18nPrefix}.specific`, { target: this.title, interpolation: { escapeValue: false } })
|
||||
} else {
|
||||
target = this.$t(`permissions:${i18nPrefix}.all`,)
|
||||
}
|
||||
|
||||
return this.$t('permissions:ui.set-for', { target: target, interpolation: { escapeValue: false } })
|
||||
}
|
||||
|
||||
return undefined
|
||||
},
|
||||
},
|
||||
|
||||
mounted () {
|
||||
this.searchUsers('', () => {})
|
||||
|
||||
this.$root.$on(modalOpenEventName, ({ resource, title, target, allSpecific }) => {
|
||||
this.resource = resource
|
||||
this.title = title
|
||||
this.target = target
|
||||
this.allSpecific = allSpecific
|
||||
|
||||
// <schema>::<backend-component-name>:...
|
||||
this.backendComponentName = resource.split(':')[2]
|
||||
|
||||
this.fetchPermissions().then(() => {
|
||||
if (!this.roles.length) {
|
||||
return this.fetchRoles()
|
||||
} else if (this.currentRole) {
|
||||
const { roleID } = this.currentRole
|
||||
this.processing = true
|
||||
|
||||
return this.fetchRules(roleID).then(() => {
|
||||
return Promise.all(this.evaluate.map(e => {
|
||||
const { userID } = e.userID || {}
|
||||
let { roleID = [] } = e
|
||||
roleID = roleID.map(({ roleID }) => roleID)
|
||||
|
||||
return this.evaluatePermissions({ roleID, userID }).then(rules => {
|
||||
return {
|
||||
...e,
|
||||
rules,
|
||||
}
|
||||
})
|
||||
})).then(evaluate => {
|
||||
this.evaluate = evaluate
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
})
|
||||
},
|
||||
|
||||
destroyed () {
|
||||
this.$root.$off(modalOpenEventName)
|
||||
},
|
||||
|
||||
methods: {
|
||||
onHide () {
|
||||
this.clear()
|
||||
},
|
||||
|
||||
clear () {
|
||||
this.resource = undefined
|
||||
this.title = undefined
|
||||
this.target = undefined
|
||||
},
|
||||
|
||||
onRoleChange ({ roleID }) {
|
||||
this.fetchRules(roleID)
|
||||
},
|
||||
|
||||
onSubmit () {
|
||||
this.processing = true
|
||||
|
||||
const rules = this.collectChangedRules()
|
||||
const { roleID } = this.currentRole
|
||||
|
||||
this.api.permissionsUpdate({ roleID, rules }).then(() => {
|
||||
this.fetchRules(roleID)
|
||||
}).finally(() => {
|
||||
this.processing = false
|
||||
})
|
||||
},
|
||||
|
||||
async fetchPermissions () {
|
||||
this.processing = true
|
||||
|
||||
// clean loaded rules
|
||||
this.rules = []
|
||||
this.permissions = []
|
||||
|
||||
return this.api.permissionsList().then((pp) => {
|
||||
this.permissions = this.filterPermissions(pp)
|
||||
}).finally(() => {
|
||||
this.processing = false
|
||||
})
|
||||
},
|
||||
|
||||
async fetchRules (roleID) {
|
||||
this.processing = true
|
||||
|
||||
return this.api.permissionsRead({ roleID, resource: this.resource }).then((rules) => {
|
||||
this.rules = this.normalizeRules(rules)
|
||||
}).finally(() => {
|
||||
this.processing = false
|
||||
})
|
||||
},
|
||||
|
||||
async fetchRoles () {
|
||||
this.processing = true
|
||||
// Roles are always fetched from $SystemAPI.
|
||||
return this.$SystemAPI.roleList().then(({ set }) => {
|
||||
this.roles = set
|
||||
.filter(({ isBypass }) => !isBypass)
|
||||
.sort((a, b) => a.roleID.localeCompare(b.roleID))
|
||||
|
||||
if (this.roles.length > 0) {
|
||||
this.currentRole = this.roles[0]
|
||||
this.onRoleChange(this.currentRole)
|
||||
}
|
||||
}).finally(() => {
|
||||
this.processing = false
|
||||
})
|
||||
},
|
||||
|
||||
async evaluatePermissions ({ resource = this.resource, roleID, userID }) {
|
||||
this.processing = true
|
||||
|
||||
return this.api.permissionsTrace({ resource, roleID, userID })
|
||||
.then(this.normalizeRules, true)
|
||||
.finally(() => {
|
||||
this.processing = false
|
||||
})
|
||||
},
|
||||
|
||||
searchUsers (query = '', loading) {
|
||||
loading(true)
|
||||
|
||||
this.$SystemAPI.userList({ query, limit: 15 })
|
||||
.then(({ set }) => {
|
||||
this.userOptions = set.map(m => Object.freeze(m))
|
||||
})
|
||||
.finally(() => {
|
||||
loading(false)
|
||||
})
|
||||
},
|
||||
|
||||
getUserLabel ({ userID, email, name, username }) {
|
||||
return name || username || email || `<@${userID}>`
|
||||
},
|
||||
|
||||
onAddEval () {
|
||||
const { userID } = this.add.userID || {}
|
||||
let { roleID = [] } = this.add
|
||||
roleID = roleID.map(({ roleID }) => roleID)
|
||||
|
||||
this.evaluatePermissions({ roleID, userID }).then(rules => {
|
||||
this.evaluate.push({
|
||||
...this.add,
|
||||
rules,
|
||||
})
|
||||
|
||||
this.add = {
|
||||
roleID: [],
|
||||
userID: undefined,
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
onHideEval (i) {
|
||||
this.evaluate.splice(i, 1)
|
||||
},
|
||||
|
||||
getEvalName ({ roleID, userID }) {
|
||||
if (userID) {
|
||||
const { name, username, email, handle } = userID
|
||||
return [name || username || email || handle || userID || '']
|
||||
} else {
|
||||
return roleID.map(({ name }) => name)
|
||||
}
|
||||
},
|
||||
|
||||
normalizeRules (rr, fallback = false) {
|
||||
const inherit = 'inherit'
|
||||
|
||||
// merges roleRules (subset) with list of all permissions
|
||||
const findCurrent = ({ resource, operation }) => {
|
||||
if (!rr) {
|
||||
return inherit
|
||||
}
|
||||
|
||||
let { resolution, access = inherit } = (rr.find(r => r.resource === resource && r.operation === operation) || {})
|
||||
|
||||
if (resolution === 'unknown-context') {
|
||||
access = 'unknown-context'
|
||||
} else if (fallback && access === inherit) {
|
||||
access = 'deny'
|
||||
}
|
||||
|
||||
return access
|
||||
}
|
||||
|
||||
return this.permissions.map((p) => {
|
||||
const current = findCurrent(p)
|
||||
return { ...p, access: current, current }
|
||||
})
|
||||
},
|
||||
|
||||
// Removes unneeded permissions (ones that do not match resource prop)
|
||||
// and translates the rest
|
||||
filterPermissions (pp) {
|
||||
const [ resourceType ] = this.resource.split('/', 2)
|
||||
return pp
|
||||
.filter(({ type }) => resourceType === type)
|
||||
.map(({ type, op: operation }) => {
|
||||
return {
|
||||
...this.describePermission({ resource: type, operation }),
|
||||
operation,
|
||||
// override resource-type with the actual resource-ID
|
||||
resource: this.resource
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
collectChangedRules () {
|
||||
return this.rules.filter(r => r.access !== r.current).map(({ resource, operation, access }) => {
|
||||
return { resource, operation, access }
|
||||
})
|
||||
},
|
||||
|
||||
describePermission ({ resource, operation }) {
|
||||
const i18nPrefix = split(resource).i18nPrefix + `.operations.${operation}`
|
||||
|
||||
let title = ''
|
||||
if (this.allSpecific) {
|
||||
title = this.$t(`permissions:${i18nPrefix}.all-specific`, { target: this.target, interpolation: { escapeValue: false } })
|
||||
} else if (this.target) {
|
||||
title = this.$t(`permissions:${i18nPrefix}.specific`, { target: this.target, interpolation: { escapeValue: false } })
|
||||
} else {
|
||||
title = this.$t(`permissions:${i18nPrefix}.title`)
|
||||
}
|
||||
|
||||
return {
|
||||
title,
|
||||
description: this.$t(`permissions:${i18nPrefix}.description`),
|
||||
}
|
||||
},
|
||||
|
||||
getRuleTooltip (isUnknown = false, isUser) {
|
||||
if (!isUnknown) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return this.$t(`permissions:ui.tooltip.unknown-context.${isUser ? 'user' : 'role'}`)
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.not-allowed {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.bg-extra-light {
|
||||
background-color: #F3F5F7;
|
||||
}
|
||||
.pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
.rotate {
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.hide-eval:hover {
|
||||
.rotate {
|
||||
color: #162425 !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
#permissions-modal, #permissions-modal-eval {
|
||||
.v-select {
|
||||
min-width: 100%;
|
||||
|
||||
.vs__selected-options {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
</style>
|
||||
24
lib/vue/src/components/permissions/def.ts
Normal file
24
lib/vue/src/components/permissions/def.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
export const modalOpenEventName = 'c-permissions-modal-open'
|
||||
|
||||
interface ResourceParts {
|
||||
component: string;
|
||||
resourceType?: string;
|
||||
references: Array<string>;
|
||||
i18nPrefix: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Splitting strings:
|
||||
* - corteza::compose:moduleField/42/21/12
|
||||
* - corteza::compose/42/21/12
|
||||
*/
|
||||
export function split (input: string): ResourceParts {
|
||||
const [tmp = '', references = ''] = input.split('/', 2)
|
||||
const [,, component, resourceType = undefined] = tmp.split(':')
|
||||
return {
|
||||
component,
|
||||
resourceType,
|
||||
references: references.split('/').filter(r => !!r),
|
||||
i18nPrefix: `resources.${component}.${resourceType || 'component'}`,
|
||||
}
|
||||
}
|
||||
77
lib/vue/src/components/permissions/form/Access.vue
Normal file
77
lib/vue/src/components/permissions/form/Access.vue
Normal file
@@ -0,0 +1,77 @@
|
||||
<template>
|
||||
<b-form-radio-group
|
||||
v-model="selected"
|
||||
data-test-id="toggle-role-permissions"
|
||||
buttons
|
||||
:disabled="!enabled"
|
||||
:button-variant="variant"
|
||||
:options="options"
|
||||
class="access rounded"
|
||||
/>
|
||||
</template>
|
||||
<script lang="js">
|
||||
export default {
|
||||
props: {
|
||||
enabled: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
|
||||
access: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: undefined,
|
||||
},
|
||||
|
||||
current: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: undefined,
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
options () {
|
||||
return ['allow', 'inherit', 'deny'].map(value => ({
|
||||
value,
|
||||
text: this.$t('permissions:ui.access.' + value)
|
||||
}))
|
||||
},
|
||||
|
||||
isChanged () {
|
||||
return this.selected !== this.current
|
||||
},
|
||||
|
||||
variant () {
|
||||
return this.isChanged ? 'outline-warning' : 'outline-primary'
|
||||
},
|
||||
|
||||
selected: {
|
||||
get () {
|
||||
return this.access
|
||||
},
|
||||
|
||||
set (sel) {
|
||||
if (this.access !== sel) {
|
||||
this.$emit('update', sel)
|
||||
}
|
||||
|
||||
this.$emit('update:access', sel)
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.access {
|
||||
.btn {
|
||||
background-color: #E4E9EF;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.btn:nth-child(2), .btn:nth-child(3) {
|
||||
margin-left: 0.2rem !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
98
lib/vue/src/components/permissions/form/Rule.vue
Normal file
98
lib/vue/src/components/permissions/form/Rule.vue
Normal file
@@ -0,0 +1,98 @@
|
||||
<template>
|
||||
<div
|
||||
:data-test-id="title || `${operation} on ${resource}`"
|
||||
>
|
||||
<p
|
||||
:title="title || `${operation} on ${resource}`"
|
||||
class="mb-1"
|
||||
>
|
||||
{{ title || `${operation} on ${resource}` }}
|
||||
</p>
|
||||
|
||||
<access
|
||||
:access="access"
|
||||
:current="current"
|
||||
:enabled="enabled"
|
||||
class="w-100"
|
||||
@update="onUpdate"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="js">
|
||||
import Access from './Access.vue'
|
||||
|
||||
export default {
|
||||
i18nOptions: {
|
||||
namespaces: 'permissions',
|
||||
},
|
||||
|
||||
components: {
|
||||
Access,
|
||||
},
|
||||
|
||||
props: {
|
||||
resource: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
|
||||
operation: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
|
||||
title: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: undefined,
|
||||
},
|
||||
|
||||
description: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: undefined,
|
||||
},
|
||||
|
||||
enabled: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
|
||||
access: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: 'inherit',
|
||||
},
|
||||
|
||||
current: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: 'inherit',
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
isChanged () {
|
||||
return this.access !== this.current
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
onUpdate (access) {
|
||||
this.emit(access)
|
||||
},
|
||||
|
||||
onReset () {
|
||||
this.emit(this.current)
|
||||
},
|
||||
|
||||
emit (access) {
|
||||
this.$emit('update', {
|
||||
resource: this.resource,
|
||||
operation: this.operation,
|
||||
access,
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
40
lib/vue/src/components/permissions/form/Rules.vue
Normal file
40
lib/vue/src/components/permissions/form/Rules.vue
Normal file
@@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<div
|
||||
data-test-id="role-permissions-list"
|
||||
>
|
||||
<rule
|
||||
v-for="(p, i) in rules"
|
||||
:key="p.resource + p.operation"
|
||||
v-bind="p"
|
||||
:class="{ 'mt-4': i > 0 }"
|
||||
@update="onUpdate"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="js">
|
||||
import Rule from './Rule.vue'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Rule,
|
||||
},
|
||||
|
||||
props: {
|
||||
rules: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
onUpdate ({ resource, operation, access }) {
|
||||
let rr = this.rules
|
||||
let ri = rr.findIndex(r => r.resource === resource && r.operation === operation)
|
||||
if (ri > -1) {
|
||||
rr[ri].access = access
|
||||
this.$emit('update:rules', rr)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
2
lib/vue/src/components/permissions/index.ts
Normal file
2
lib/vue/src/components/permissions/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as CPermissionsButton } from './CPermissionsButton.vue'
|
||||
export { default as CPermissionsModal } from './CPermissionsModal.vue'
|
||||
105
lib/vue/src/components/privacy/CSensitivityLevelPicker.vue
Normal file
105
lib/vue/src/components/privacy/CSensitivityLevelPicker.vue
Normal file
@@ -0,0 +1,105 @@
|
||||
<template>
|
||||
<vue-select
|
||||
key="type"
|
||||
:value="_value"
|
||||
:disabled="_disabled"
|
||||
:options="sensitivityLevels"
|
||||
:get-option-label="getLabel"
|
||||
:placeholder="placeholder"
|
||||
:reduce="l => l.sensitivityLevelID"
|
||||
append-to-body
|
||||
class="bg-white"
|
||||
@input="onInput"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { NoID } from '@cortezaproject/corteza-js'
|
||||
import { VueSelect } from 'vue-select'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
VueSelect,
|
||||
},
|
||||
|
||||
props: {
|
||||
value: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
|
||||
options: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
|
||||
// ID of sensitivityLevel with the maximum allowed level
|
||||
maxLevel: {
|
||||
type: String,
|
||||
default: undefined
|
||||
},
|
||||
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
_value () {
|
||||
return this.value === NoID ? undefined : this.value
|
||||
},
|
||||
|
||||
_disabled () {
|
||||
return this.disabled || this.maxLevel === NoID
|
||||
},
|
||||
|
||||
sensitivityLevels () {
|
||||
if (this.maxLevel === NoID) {
|
||||
return []
|
||||
}
|
||||
|
||||
if (this.maxLevel) {
|
||||
const maxLevelConnection = this.options.find(({ sensitivityLevelID }) => sensitivityLevelID === this.maxLevel)
|
||||
if (maxLevelConnection) {
|
||||
return this.options.filter(({ level }) => level <= maxLevelConnection.level)
|
||||
}
|
||||
}
|
||||
|
||||
return this.options
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
// If sensitivityLevels change because of maxLevel change, then assert if value is still allowed, otherwise reset it
|
||||
sensitivityLevels: {
|
||||
immediate: true,
|
||||
handler () {
|
||||
const isValueCompatible = this.sensitivityLevels.some(({ sensitivityLevelID }) => sensitivityLevelID === this.value)
|
||||
if (!isValueCompatible) {
|
||||
this.$emit('input', NoID)
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
getLabel ({ handle, meta = {} }) {
|
||||
return meta.name || handle
|
||||
},
|
||||
|
||||
onInput (sensitivityLevelID) {
|
||||
this.$emit('input', sensitivityLevelID ? sensitivityLevelID : NoID)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
||||
</style>
|
||||
1
lib/vue/src/components/privacy/index.ts
Normal file
1
lib/vue/src/components/privacy/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as CSensitivityLevelPicker } from './CSensitivityLevelPicker.vue'
|
||||
126
lib/vue/src/components/prompts/CPromptModal.vue
Normal file
126
lib/vue/src/components/prompts/CPromptModal.vue
Normal file
@@ -0,0 +1,126 @@
|
||||
<template>
|
||||
<b-modal
|
||||
v-model="isOpened"
|
||||
size="lg"
|
||||
lazy
|
||||
:hide-footer="!current"
|
||||
:title="current ? current.title : 'Workflow prompts'"
|
||||
:busy="isLoading"
|
||||
|
||||
@hide="deactivate()"
|
||||
>
|
||||
<component
|
||||
v-if="current"
|
||||
:is="current.component"
|
||||
:payload="current.prompt.payload"
|
||||
:loading="isLoading"
|
||||
@submit="resume({ input: $event, prompt: current.prompt })"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
>
|
||||
<div
|
||||
class="d-flex flex-grow-1 align-items-baseline"
|
||||
v-for="({ key, title, age, prompt }) in list"
|
||||
:key="key"
|
||||
>
|
||||
<span
|
||||
class="mr-auto"
|
||||
@click="activate(prompt)"
|
||||
>
|
||||
{{ title }}
|
||||
<time
|
||||
class="muted small"
|
||||
:datetime="prompt.createdAt"
|
||||
>
|
||||
{{ age }}
|
||||
</time>
|
||||
</span>
|
||||
<b-button
|
||||
variant="link"
|
||||
size="sm"
|
||||
@click="remove(prompt)"
|
||||
:disabled="isLoading"
|
||||
v-if="false"
|
||||
>Remove</b-button>
|
||||
</div>
|
||||
</div>
|
||||
<template
|
||||
v-if="current"
|
||||
#modal-footer
|
||||
>
|
||||
<b-button
|
||||
variant="link"
|
||||
@click="activate(true)"
|
||||
>
|
||||
« Back to list
|
||||
</b-button>
|
||||
</template>
|
||||
</b-modal>
|
||||
</template>
|
||||
<script lang="js">
|
||||
import { mapGetters, mapActions } from 'vuex'
|
||||
import definitions from './kinds/index.ts'
|
||||
import { pVal } from './utils.ts'
|
||||
import moment from 'moment'
|
||||
|
||||
export default {
|
||||
name: 'c-prompt-modal',
|
||||
computed: {
|
||||
...mapGetters({
|
||||
isLoading: 'wfPrompts/isLoading',
|
||||
isActive: 'wfPrompts/isActive',
|
||||
prompts: 'wfPrompts/all',
|
||||
}),
|
||||
|
||||
isOpened: {
|
||||
get () {
|
||||
return this.isActive
|
||||
},
|
||||
|
||||
set (open) {
|
||||
if (!open) {
|
||||
this.deactivate()
|
||||
} else {
|
||||
this.activate()
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
list () {
|
||||
return this.prompts
|
||||
.filter(({ ref }) => !!definitions[ref] && !!definitions[ref].component)
|
||||
.map(prompt => ({ ...definitions[prompt.ref], prompt }))
|
||||
.filter(({ passive }) => !passive)
|
||||
.map(p => ({
|
||||
key: p.prompt.stateID,
|
||||
title: pVal(p.prompt.payload, 'title', 'Workflow prompt'),
|
||||
age: moment(p.prompt.createdAt).fromNow(),
|
||||
...p,
|
||||
}))
|
||||
},
|
||||
|
||||
current () {
|
||||
const c = this.$store.getters['wfPrompts/current']
|
||||
if (!c) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return this.list.find(({ prompt }) => prompt.stateID === c.stateID)
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
...mapActions({
|
||||
remove: 'wfPrompts/remove',
|
||||
resume: 'wfPrompts/resume',
|
||||
activate: 'wfPrompts/activate',
|
||||
deactivate: 'wfPrompts/deactivate',
|
||||
}),
|
||||
|
||||
clear () {
|
||||
this.deactivate()
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
215
lib/vue/src/components/prompts/CPromptToast.vue
Normal file
215
lib/vue/src/components/prompts/CPromptToast.vue
Normal file
@@ -0,0 +1,215 @@
|
||||
<template>
|
||||
<div>
|
||||
<b-toast
|
||||
v-for="({ prompt, component, passive }) in toasts"
|
||||
:id="'wfPromptToast-'+prompt.stateID"
|
||||
:key="'wfPromptToast-'+prompt.stateID"
|
||||
:variant="pVal(prompt, 'variant', 'primary')"
|
||||
:visible="!isActive"
|
||||
solid
|
||||
:no-auto-hide="!passive"
|
||||
:auto-hide-delay="pVal(prompt, 'timeout', defaultTimeout) * 1000"
|
||||
:no-close-button="!passive"
|
||||
>
|
||||
<template #toast-title>
|
||||
<div class="d-flex flex-grow-1 align-items-baseline">
|
||||
<strong class="mr-auto">{{ pVal(prompt, 'title', 'Workflow prompt') }}</strong>
|
||||
<b-button
|
||||
variant="link"
|
||||
size="sm"
|
||||
v-if="!passive && active.length > 1"
|
||||
@click="activate(true)"
|
||||
>
|
||||
{{ active.length }} waiting
|
||||
</b-button>
|
||||
</div>
|
||||
</template>
|
||||
<component
|
||||
v-if="component"
|
||||
:is="component"
|
||||
:payload="prompt.payload"
|
||||
:loading="isLoading"
|
||||
@submit="resumeToast({ input: $event, prompt })"
|
||||
/>
|
||||
</b-toast>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import { mapGetters, mapActions } from 'vuex'
|
||||
import definitions from './kinds/index.ts'
|
||||
import { pVal } from './utils.ts'
|
||||
|
||||
export default {
|
||||
name: 'c-prompt-toast',
|
||||
|
||||
props: {
|
||||
hideToasts: {
|
||||
type: Boolean,
|
||||
},
|
||||
},
|
||||
|
||||
data () {
|
||||
return {
|
||||
passive: new Set(),
|
||||
|
||||
/**
|
||||
* Set initial value to NULL
|
||||
*
|
||||
* First interval will detect that null is not true|false
|
||||
* and set it accordingly to the current state
|
||||
*/
|
||||
hasFocus: null,
|
||||
hasFocusObserver: 0,
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapGetters({
|
||||
prompts: 'wfPrompts/all',
|
||||
isActive: 'wfPrompts/isActive',
|
||||
isLoading: 'wfPrompts/isLoading',
|
||||
}),
|
||||
|
||||
/**
|
||||
* Prompts with handlers, observed with "watch"
|
||||
*
|
||||
* Prompts are only returned when document has focus!
|
||||
*
|
||||
* @returns {*}
|
||||
*/
|
||||
withHandlers () {
|
||||
return (this.hasFocus ? this.prompts : [])
|
||||
.filter(({ ref }) => !!definitions[ref] && !!definitions[ref].handler)
|
||||
.map(prompt => ({ ...definitions[prompt.ref], prompt }))
|
||||
},
|
||||
|
||||
/**
|
||||
* Prompts with components
|
||||
*
|
||||
* Prompts are only returned when document has focus!
|
||||
*
|
||||
* @returns {*}
|
||||
*/
|
||||
withComponents () {
|
||||
return (this.hasFocus ? this.prompts : [])
|
||||
.filter(({ ref }) => !!definitions[ref] && !!definitions[ref].component)
|
||||
.map(prompt => ({ ...definitions[prompt.ref], prompt }))
|
||||
},
|
||||
|
||||
/**
|
||||
* All non-passive prompts with components
|
||||
*/
|
||||
active() {
|
||||
return this.withComponents.filter(({ passive }) => !passive)
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns list of prompts that we can interpret as toasts: display component is defined
|
||||
*
|
||||
* Toasts (prompts with components) are displayed in order received but
|
||||
* passive (no feedback or input from user required) first and the rest later
|
||||
*/
|
||||
toasts () {
|
||||
return this.hideToasts ? [] : [
|
||||
...this.passive.values(),
|
||||
...this.active
|
||||
]
|
||||
},
|
||||
|
||||
defaultTimeout () {
|
||||
return 7
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
// watch prompts with handlers and when a new one arrives
|
||||
// shift it from the stack, resume the prompt and handle it
|
||||
withHandlers (hh) {
|
||||
if (hh.length > 0) {
|
||||
const { handler, prompt } = hh.shift()
|
||||
this.resume({ input: {}, prompt }).then(() => {
|
||||
handler.call(this, prompt.payload)
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Make a copy of prompt if it is defined as passive
|
||||
*
|
||||
* We do this because we do not want it to be removed right away
|
||||
* but through a toast component's timeout
|
||||
*/
|
||||
withComponents: {
|
||||
immediate: true,
|
||||
handler (wc) {
|
||||
wc.forEach(p => {
|
||||
if (p.passive) {
|
||||
this.passive.add(p)
|
||||
}
|
||||
})
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
...mapActions({
|
||||
resume: 'wfPrompts/resume',
|
||||
activate: 'wfPrompts/activate',
|
||||
}),
|
||||
|
||||
resumeToast (values) {
|
||||
// Only reset input if prompt is kept open
|
||||
if (values.input && values.input.keep) {
|
||||
values.input = {}
|
||||
}
|
||||
|
||||
this.resume(values)
|
||||
},
|
||||
|
||||
pVal (prompt, k, def = undefined) {
|
||||
return pVal(prompt.payload, k, def)
|
||||
},
|
||||
|
||||
clearDocumentFocusObserver() {
|
||||
if (this.hasFocusObserver) {
|
||||
window.clearInterval(this.hasFocusObserver)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Interval check if window has focus
|
||||
*/
|
||||
setDocumentFocusObserver() {
|
||||
this.clearDocumentFocusObserver()
|
||||
|
||||
this.hasFocusObserver = window.setInterval(() => {
|
||||
const f = document.hasFocus()
|
||||
if (this.hasFocus !== f) {
|
||||
this.hasFocus = f
|
||||
}
|
||||
}, 250)
|
||||
}
|
||||
},
|
||||
|
||||
mounted () {
|
||||
this.setDocumentFocusObserver()
|
||||
},
|
||||
|
||||
beforeDestroy () {
|
||||
this.clearDocumentFocusObserver()
|
||||
},
|
||||
}
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
.slide-enter-active {
|
||||
transition: all .3s ease;
|
||||
}
|
||||
.slide-leave-active {
|
||||
transition: all .8s cubic-bezier(1.0, 0.5, 0.8, 1.0);
|
||||
}
|
||||
.slide-enter, .slide-leave-to
|
||||
/* .slide-leave-active below version 2.1.8 */ {
|
||||
transform: translateX(10px);
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
35
lib/vue/src/components/prompts/CPrompts.vue
Normal file
35
lib/vue/src/components/prompts/CPrompts.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<template>
|
||||
<div>
|
||||
<c-prompt-toast
|
||||
:hide-toasts="hideToasts"
|
||||
/>
|
||||
<c-prompt-modal />
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import CPromptToast from './CPromptToast.vue'
|
||||
import CPromptModal from './CPromptModal.vue'
|
||||
|
||||
/**
|
||||
* Container for prompt toast and modal
|
||||
*
|
||||
* Toast serves as a notifier and placeholder for light prompts
|
||||
* Modal serves as prompt lister and placeholder all prompt types
|
||||
*
|
||||
* All communication between the two components go through wf-prompt state
|
||||
*/
|
||||
export default {
|
||||
name: 'c-prompts',
|
||||
|
||||
components: {
|
||||
CPromptToast,
|
||||
CPromptModal
|
||||
},
|
||||
|
||||
props: {
|
||||
hideToasts: {
|
||||
type: Boolean,
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
44
lib/vue/src/components/prompts/common/CPagination.vue
Normal file
44
lib/vue/src/components/prompts/common/CPagination.vue
Normal file
@@ -0,0 +1,44 @@
|
||||
<template>
|
||||
<li
|
||||
class="d-flex mt-1 mx-1"
|
||||
>
|
||||
<b-button
|
||||
class="flex-grow-1"
|
||||
:disabled="!hasPrevPage"
|
||||
size="sm"
|
||||
@click="$emit('prev')"
|
||||
>
|
||||
<font-awesome-icon
|
||||
:icon="['fas', 'chevron-left']"
|
||||
/>
|
||||
</b-button>
|
||||
<b-button
|
||||
class="flex-grow-1 ml-1"
|
||||
:disabled="!hasNextPage"
|
||||
size="sm"
|
||||
@click="$emit('next')"
|
||||
>
|
||||
<font-awesome-icon
|
||||
:icon="['fas', 'chevron-right']"
|
||||
/>
|
||||
</b-button>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
// This file is copied over from corteza-webapp-compose; src/components/ModuleFields/Common/Pagination.vue
|
||||
// Refactor it out when those components get moved into corteza-vue
|
||||
export default {
|
||||
props: {
|
||||
hasPrevPage: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
|
||||
hasNextPage: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
173
lib/vue/src/components/prompts/definitions.ts
Normal file
173
lib/vue/src/components/prompts/definitions.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { automation } from '@cortezaproject/corteza-js'
|
||||
|
||||
const variants = [
|
||||
{ value: 'primary', text: 'Primary' },
|
||||
{ value: 'secondary', text: 'Secondary' },
|
||||
{ value: 'success', text: 'Success' },
|
||||
{ value: 'warning', text: 'Warning' },
|
||||
{ value: 'danger', text: 'Danger' },
|
||||
{ value: 'info', text: 'Info' },
|
||||
{ value: 'light', text: 'Light' },
|
||||
{ value: 'dark', text: 'Dark' },
|
||||
]
|
||||
|
||||
export const prompts = Object.freeze([
|
||||
{
|
||||
ref: 'redirect',
|
||||
meta: { short: 'Redirect user to an outside URL' },
|
||||
parameters: [
|
||||
{ name: 'owner', types: ['User', 'ID'], required: false },
|
||||
{ name: 'url', types: ['String'], required: true },
|
||||
{ name: 'delay', types: ['Integer'], meta: { description: 'Redirection delay in seconds' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
ref: 'reroute',
|
||||
meta: { short: 'Redirect user to an internal application route' },
|
||||
parameters: [
|
||||
{ name: 'owner', types: ['User', 'ID'], required: false },
|
||||
{ name: 'name', types: ['String'], required: true },
|
||||
{ name: 'params', types: ['KV'] },
|
||||
{ name: 'query', types: ['KV'] },
|
||||
{ name: 'delay', types: ['Integer'], meta: { description: 'Redirection delay in seconds' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
ref: 'recordPage',
|
||||
meta: {
|
||||
short: 'Redirect user to the record page',
|
||||
webapps: ['compose'],
|
||||
},
|
||||
parameters: [
|
||||
{ name: 'owner', types: ['User', 'ID'], required: false },
|
||||
{ name: 'module', types: ['ID', 'Handle', 'ComposeModule'] },
|
||||
{ name: 'namespace', types: ['ID', 'Handle', 'ComposeNamespace'] },
|
||||
{ name: 'record', types: ['ID', 'ComposeRecord'] },
|
||||
{ name: 'edit', types: ['Boolean'] },
|
||||
{ name: 'delay', types: ['Integer'], meta: { description: 'Redirection delay in seconds' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
ref: 'refetchRecords',
|
||||
meta: { short: 'Refreshes all record values on the page' },
|
||||
},
|
||||
{
|
||||
ref: 'notification',
|
||||
meta: { short: 'Show non-blocking message to user' },
|
||||
parameters: [
|
||||
{ name: 'owner', types: ['User', 'ID'], required: false },
|
||||
{ name: 'title', types: ['String'] },
|
||||
{ name: 'message', types: ['String'], required: true },
|
||||
{ name: 'variant', types: ['String'], meta: { visual: { input: { type: 'select', properties: { options: variants } } } } },
|
||||
{ name: 'timeout', types: ['Integer'], meta: { description: 'How long do we show the notification in seconds' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
ref: 'alert',
|
||||
meta: { short: 'Prompt user with an alert' },
|
||||
parameters: [
|
||||
{ name: 'owner', types: ['User', 'ID'], required: false },
|
||||
{ name: 'title', types: ['String'] },
|
||||
{ name: 'message', types: ['String'], required: true },
|
||||
{ name: 'buttonLabel', types: ['String'] },
|
||||
{ name: 'buttonVariant', types: ['String'], meta: { visual: { input: { type: 'select', properties: { options: variants } } } } },
|
||||
{ name: 'buttonValue', types: ['Any'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
ref: 'choice',
|
||||
meta: { short: 'Prompt user with choice' },
|
||||
parameters: [
|
||||
{ name: 'owner', types: ['User', 'ID'], required: false },
|
||||
{ name: 'title', types: ['String'] },
|
||||
{ name: 'message', types: ['String'], required: true },
|
||||
{ name: 'confirmButtonLabel', types: ['String'] },
|
||||
{ name: 'confirmButtonVariant', types: ['String'], meta: { visual: { input: { type: 'select', properties: { options: variants } } } } },
|
||||
{ name: 'confirmButtonValue', types: ['Any'] },
|
||||
{ name: 'rejectButtonLabel', types: ['String'] },
|
||||
{ name: 'rejectButtonVariant', types: ['String'], meta: { visual: { input: { type: 'select', properties: { options: variants } } } } },
|
||||
{ name: 'rejectButtonValue', types: ['Any'] },
|
||||
],
|
||||
results: [
|
||||
{ name: 'value', types: ['Any'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
ref: 'composeRecordPicker',
|
||||
meta: { short: 'Prompt user to select a Compose Record' },
|
||||
parameters: [
|
||||
{ name: 'owner', types: ['User', 'ID'], required: false },
|
||||
{ name: 'title', types: ['String'] },
|
||||
{ name: 'message', types: ['String'], required: true },
|
||||
{ name: 'namespace', types: ['ID', 'Handle', 'ComposeNamespace'], required: true },
|
||||
{ name: 'module', types: ['ID', 'Handle', 'ComposeModule'], required: true },
|
||||
{ name: 'labelField', types: ['Handle'], required: true },
|
||||
{ name: 'queryFields', types: ['Array'] },
|
||||
{ name: 'prefilter', types: ['String'] },
|
||||
],
|
||||
results: [
|
||||
{ name: 'value', types: ['ComposeRecord'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
ref: 'input',
|
||||
meta: { short: 'Prompt user with a single input' },
|
||||
parameters: [
|
||||
{ name: 'owner', types: ['User', 'ID'], required: false },
|
||||
{ name: 'title', types: ['String'] },
|
||||
{ name: 'variant', types: ['String'], meta: { visual: { input: { type: 'select', properties: { options: variants } } } } },
|
||||
{ name: 'message', types: ['String'], required: true },
|
||||
{ name: 'label', types: ['String'] },
|
||||
{
|
||||
name: 'type',
|
||||
types: ['String'],
|
||||
meta: {
|
||||
visual: {
|
||||
options: [
|
||||
{ value: 'text', text: 'Text' },
|
||||
{ value: 'number', text: 'Number' },
|
||||
{ value: 'email', text: 'Email' },
|
||||
{ value: 'password', text: 'Password' },
|
||||
{ value: 'search', text: 'Search' },
|
||||
{ value: 'date', text: 'Date' },
|
||||
{ value: 'time', text: 'Time' },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{ name: 'inputValue', types: ['String'] },
|
||||
],
|
||||
results: [
|
||||
{ name: 'value', types: ['Any'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
ref: 'options',
|
||||
meta: { short: 'Prompt user with options' },
|
||||
parameters: [
|
||||
{ name: 'owner', types: ['User', 'ID'], required: false },
|
||||
{ name: 'title', types: ['String'] },
|
||||
{ name: 'variant', types: ['String'], meta: { visual: { input: { type: 'select', properties: { options: variants } } } } },
|
||||
{ name: 'message', types: ['String'], required: true },
|
||||
{ name: 'label', types: ['String'] },
|
||||
{
|
||||
name: 'type',
|
||||
types: ['String'],
|
||||
meta: {
|
||||
visual: {
|
||||
options: [
|
||||
{ value: 'select', text: 'Select' },
|
||||
{ value: 'radio', text: 'Radio' },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{ name: 'value', types: ['String', 'Array'] },
|
||||
{ name: 'options', types: ['KV'] },
|
||||
{ name: 'multiselect', types: ['Boolean'] },
|
||||
],
|
||||
results: [
|
||||
{ name: 'value', types: ['Any'] },
|
||||
],
|
||||
},
|
||||
].map(f => new automation.Function({ ...f, kind: 'prompt' })))
|
||||
4
lib/vue/src/components/prompts/index.ts
Normal file
4
lib/vue/src/components/prompts/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { default as CPrompts } from './CPrompts.vue'
|
||||
export { default as CPromptModal } from './CPromptModal.vue'
|
||||
export { default as CPromptToast } from './CPromptToast.vue'
|
||||
export { prompts as promptDefinitions } from './definitions'
|
||||
24
lib/vue/src/components/prompts/kinds/CPromptAlert.vue
Normal file
24
lib/vue/src/components/prompts/kinds/CPromptAlert.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<template>
|
||||
<div>
|
||||
<p v-html="message"></p>
|
||||
<div
|
||||
class="text-center m-2"
|
||||
>
|
||||
<b-button
|
||||
@click="$emit('submit', { confirmed: pRaw('buttonValue', true, 'Boolean') })"
|
||||
:variant="pVal('buttonVariant', 'primary')"
|
||||
:disabled="loading"
|
||||
>
|
||||
{{ pVal('buttonLabel', 'OK') }}
|
||||
</b-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="js">
|
||||
import base from './base.vue'
|
||||
|
||||
export default {
|
||||
extends: base,
|
||||
name: 'c-prompt-alert',
|
||||
}
|
||||
</script>
|
||||
31
lib/vue/src/components/prompts/kinds/CPromptChoice.vue
Normal file
31
lib/vue/src/components/prompts/kinds/CPromptChoice.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<template>
|
||||
<div>
|
||||
<p v-html="message"></p>
|
||||
<div
|
||||
class="text-center m-2"
|
||||
>
|
||||
<b-button
|
||||
@click="$emit('submit', { value: pRaw('confirmButtonValue', true, 'Boolean') })"
|
||||
:variant="pVal('confirmButtonVariant', 'primary')"
|
||||
:disabled="loading"
|
||||
>
|
||||
{{ pVal('confirmButtonLabel', 'Yes') }}
|
||||
</b-button>
|
||||
<b-button
|
||||
@click="$emit('submit', { value: pRaw('rejectButtonValue', false, 'Boolean') })"
|
||||
:disabled="loading"
|
||||
:variant="pVal('rejectButtonVariant', 'primary')"
|
||||
>
|
||||
{{ pVal('rejectButtonLabel', 'No') }}
|
||||
</b-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="js">
|
||||
import base from './base.vue'
|
||||
|
||||
export default {
|
||||
extends: base,
|
||||
name: 'c-prompt-choice',
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,292 @@
|
||||
<template>
|
||||
<div>
|
||||
<p>
|
||||
{{ message }}
|
||||
</p>
|
||||
<div
|
||||
class="text-center m-2"
|
||||
>
|
||||
<vue-select
|
||||
v-model="value"
|
||||
:options="options"
|
||||
:loading="processing"
|
||||
append-to-body
|
||||
:calculate-position="calculatePosition"
|
||||
option-value="recordID"
|
||||
option-text="label"
|
||||
placeholder="Select record"
|
||||
:filterable="false"
|
||||
class="bg-white w-100 mb-3"
|
||||
@search="search"
|
||||
>
|
||||
<c-pagination
|
||||
v-if="showPagination"
|
||||
slot="list-footer"
|
||||
:has-prev-page="hasPrevPage"
|
||||
:has-next-page="hasNextPage"
|
||||
@prev="goToPage(false)"
|
||||
@next="goToPage(true)"
|
||||
/>
|
||||
</vue-select>
|
||||
|
||||
<b-button
|
||||
@click="$emit('submit', { value: encodeValue() })"
|
||||
:disabled="loading"
|
||||
>
|
||||
{{ pVal('buttonLabel', 'Submit') }}
|
||||
</b-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="js">
|
||||
import base from './base.vue'
|
||||
import CPagination from '../common/CPagination.vue'
|
||||
import { pVal } from '../utils.ts'
|
||||
import { VueSelect } from 'vue-select'
|
||||
import { compose, NoID } from '@cortezaproject/corteza-js'
|
||||
import { createPopper } from '@popperjs/core'
|
||||
import { debounce } from 'lodash'
|
||||
|
||||
export default {
|
||||
extends: base,
|
||||
name: 'c-prompt-compose-record-picker',
|
||||
|
||||
components: {
|
||||
VueSelect,
|
||||
CPagination,
|
||||
},
|
||||
|
||||
data () {
|
||||
return {
|
||||
processing: false,
|
||||
query: '',
|
||||
filter: {
|
||||
query: '',
|
||||
sort: '',
|
||||
limit: 10,
|
||||
pageCursor: '',
|
||||
prevPage: '',
|
||||
nextPage: '',
|
||||
},
|
||||
|
||||
namespaceID: NoID,
|
||||
module: undefined,
|
||||
|
||||
options: [],
|
||||
value: undefined,
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
// moduleFields returns the available field names
|
||||
moduleFields () {
|
||||
if (!this.module) {
|
||||
return []
|
||||
}
|
||||
return this.module.fields.map(({ name }) => name)
|
||||
},
|
||||
|
||||
showPagination () {
|
||||
return this.hasPrevPage || this.hasNextPage
|
||||
},
|
||||
|
||||
hasPrevPage () {
|
||||
return !!this.filter.prevPage
|
||||
},
|
||||
|
||||
hasNextPage () {
|
||||
return !!this.filter.nextPage
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
'filter.pageCursor': {
|
||||
handler (pageCursor) {
|
||||
if (pageCursor) {
|
||||
this.fetchPrefiltered(this.filter)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
async created () {
|
||||
// Prep the data
|
||||
const module = this.pVal('module')
|
||||
const moduleType = this.pType('module')
|
||||
const namespace = this.pVal('namespace')
|
||||
const namespaceType = this.pType('namespace')
|
||||
|
||||
// Resolve bits
|
||||
// namespace
|
||||
if (namespaceType === 'ID') {
|
||||
this.namespaceID = namespace
|
||||
} else if (namespaceType === 'ComposeNamespace') {
|
||||
this.namespaceID = namespace.namespaceID
|
||||
} else {
|
||||
// @ts-ignore
|
||||
const { set: nn } = await this.$ComposeAPI.namespaceList({ slug: namespace })
|
||||
if (!nn || nn.length !== 1) {
|
||||
throw new Error('namespace not resolved')
|
||||
}
|
||||
|
||||
this.namespaceID = nn[0].namespaceID
|
||||
}
|
||||
|
||||
// module; get the full thing as we need fields
|
||||
if (moduleType === 'ID') {
|
||||
this.module = await this.$ComposeAPI.moduleRead({ namespaceID: this.namespaceID, moduleID: module })
|
||||
if (!this.module) {
|
||||
throw new Error('module not resolved')
|
||||
}
|
||||
} else if (moduleType === 'ComposeModule') {
|
||||
this.module = module
|
||||
} else {
|
||||
// @ts-ignore
|
||||
const { set: nn } = await this.$ComposeAPI.moduleList({ handle: module, namespaceID: this.namespaceID })
|
||||
if (!nn || nn.length !== 1) {
|
||||
throw new Error('module not resolved')
|
||||
}
|
||||
|
||||
this.module = nn[0]
|
||||
}
|
||||
|
||||
// Preload
|
||||
this.loadLatest()
|
||||
},
|
||||
|
||||
methods: {
|
||||
encodeValue () {
|
||||
if (!this.value) {
|
||||
return { '@type': 'Any', '@value': null }
|
||||
}
|
||||
|
||||
return { '@type': 'ComposeRecord', '@value': this.value.record }
|
||||
},
|
||||
|
||||
loadLatest () {
|
||||
const namespaceID = this.namespaceID
|
||||
const moduleID = this.module.moduleID
|
||||
const { limit } = this.filter
|
||||
if (moduleID && moduleID !== NoID) {
|
||||
this.fetchPrefiltered({ namespaceID, moduleID, limit })
|
||||
}
|
||||
},
|
||||
|
||||
search: debounce(function (query = '') {
|
||||
if (query !== this.query) {
|
||||
this.query = query
|
||||
this.filter.pageCursor = undefined
|
||||
}
|
||||
|
||||
const { limit, pageCursor } = this.filter
|
||||
const namespaceID = this.namespaceID
|
||||
const moduleID = this.module.moduleID
|
||||
|
||||
if (moduleID && moduleID !== NoID) {
|
||||
// Determine what fields to use for searching
|
||||
// Default to label field
|
||||
let qf = this.pVal('queryFields').map(f => f['@value']).filter(f => !!f)
|
||||
if (!qf || qf.length === 0) {
|
||||
qf = [this.pVal('labelField')]
|
||||
}
|
||||
|
||||
if (query.length > 0) {
|
||||
// Construct query
|
||||
query = qf.map(qf => {
|
||||
return `${qf} LIKE '%${query}%'`
|
||||
}).join(' OR ')
|
||||
}
|
||||
|
||||
this.fetchPrefiltered({ namespaceID, moduleID, query, limit })
|
||||
}
|
||||
}, 300),
|
||||
|
||||
calculatePosition (dropdownList, component, { width }) {
|
||||
/**
|
||||
* We need to explicitly define the dropdown width since
|
||||
* it is usually inherited from the parent with CSS.
|
||||
*/
|
||||
dropdownList.style.width = width
|
||||
dropdownList.style['z-index'] = 10000
|
||||
|
||||
/**
|
||||
* Here we position the dropdownList relative to the $refs.toggle Element.
|
||||
*
|
||||
* The 'offset' modifier aligns the dropdown so that the $refs.toggle and
|
||||
* the dropdownList overlap by 1 pixel.
|
||||
*
|
||||
* The 'toggleClass' modifier adds a 'drop-up' class to the Vue Select
|
||||
* wrapper so that we can set some styles for when the dropdown is placed
|
||||
* above.
|
||||
*/
|
||||
const popper = createPopper(component.$refs.toggle, dropdownList, {
|
||||
placement: 'bottom',
|
||||
modifiers: [
|
||||
{
|
||||
name: 'offset',
|
||||
options: {
|
||||
offset: [0, -1],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'toggleClass',
|
||||
enabled: true,
|
||||
phase: 'write',
|
||||
fn ({ state }) {
|
||||
component.$el.classList.toggle('drop-up', state.placement === 'top')
|
||||
},
|
||||
}],
|
||||
})
|
||||
|
||||
/**
|
||||
* To prevent memory leaks Popper needs to be destroyed.
|
||||
* If you return function, it will be called just before dropdown is removed from DOM.
|
||||
*/
|
||||
return () => popper.destroy()
|
||||
},
|
||||
|
||||
|
||||
fetchPrefiltered (q) {
|
||||
this.processing = true
|
||||
|
||||
// Prefilter...
|
||||
let { query = '' } = q
|
||||
if (this.pVal('prefilter')) {
|
||||
const pf = this.pVal('prefilter')
|
||||
if (query) {
|
||||
query = `(${pf}) AND (${query})`
|
||||
} else {
|
||||
query = pf
|
||||
}
|
||||
}
|
||||
|
||||
this.$ComposeAPI.recordList({ ...q, query })
|
||||
.then(({ filter, set }) => {
|
||||
this.filter = { ...this.filter, ...filter }
|
||||
this.filter.nextPage = filter.nextPage
|
||||
this.filter.prevPage = filter.prevPage
|
||||
|
||||
this.options = set.map(r => {
|
||||
const record = new compose.Record(this.module, r)
|
||||
const label = record.values[this.pVal('labelField')] || record.recordID
|
||||
|
||||
return {
|
||||
recordID: record.recordID,
|
||||
label,
|
||||
record,
|
||||
}
|
||||
})
|
||||
|
||||
return { filter, set }
|
||||
})
|
||||
.finally(() => {
|
||||
this.processing = false
|
||||
})
|
||||
},
|
||||
|
||||
goToPage (next = true) {
|
||||
this.filter.pageCursor = next ? this.filter.nextPage : this.filter.prevPage
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
64
lib/vue/src/components/prompts/kinds/CPromptInput.vue
Normal file
64
lib/vue/src/components/prompts/kinds/CPromptInput.vue
Normal file
@@ -0,0 +1,64 @@
|
||||
<template>
|
||||
<div>
|
||||
<p v-html="message"></p>
|
||||
<b-form-group
|
||||
:label="label"
|
||||
>
|
||||
<b-input
|
||||
:type="type"
|
||||
:disabled="loading"
|
||||
v-model="value"
|
||||
/>
|
||||
</b-form-group>
|
||||
<b-button
|
||||
:disabled="loading"
|
||||
@click="$emit('submit', { value: { '@value': value, '@type': 'String' }})"
|
||||
>
|
||||
{{ pVal('buttonLabel', 'Submit') }}
|
||||
</b-button>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="js">
|
||||
import base from './base.vue'
|
||||
|
||||
const validTypes = [
|
||||
'text',
|
||||
'number',
|
||||
'email',
|
||||
'password',
|
||||
'search',
|
||||
'date',
|
||||
'time',
|
||||
]
|
||||
|
||||
export default {
|
||||
extends: base,
|
||||
name: 'c-prompt-input',
|
||||
|
||||
data () {
|
||||
return {
|
||||
value: undefined
|
||||
}
|
||||
},
|
||||
|
||||
beforeMount() {
|
||||
this.value = this.pVal('value')
|
||||
},
|
||||
|
||||
computed: {
|
||||
type () {
|
||||
const t = this.pVal('type', 'text')
|
||||
if (validTypes.indexOf(t) === -1) {
|
||||
return 'text'
|
||||
}
|
||||
|
||||
return t
|
||||
},
|
||||
|
||||
label () {
|
||||
return this.pVal('label', '')
|
||||
}
|
||||
},
|
||||
|
||||
}
|
||||
</script>
|
||||
22
lib/vue/src/components/prompts/kinds/CPromptNotification.vue
Normal file
22
lib/vue/src/components/prompts/kinds/CPromptNotification.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<template>
|
||||
<div>
|
||||
<p
|
||||
v-html="message"
|
||||
class="mb-0"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="js">
|
||||
import base from './base.vue'
|
||||
|
||||
export default {
|
||||
extends: base,
|
||||
name: 'c-prompt-notification',
|
||||
|
||||
mounted () {
|
||||
// Non blocking, return to workflow right away
|
||||
// keep indicates if toast will be kept open until auto delay hides it
|
||||
this.$emit('submit', { keep: true })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user