3
0

Add files from vue with lib/vue prefix

This commit is contained in:
Corteza Monorepo Migrator
2022-11-14 09:26:42 +01:00
parent 7f6a912f59
commit c865b34840
153 changed files with 23877 additions and 0 deletions

40
lib/vue/.eslintrc.js Normal file
View 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
View 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
View 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
View 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
View 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/**'],
},
}

View 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>

View 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>

View File

@@ -0,0 +1,2 @@
export { default as Tour } from './Tour.vue'
export { default as TourStart } from './TourStart.vue'

View File

@@ -0,0 +1,5 @@
import * as itemPicker from '../itemPicker/C3'
export default {
itemPicker,
}

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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,
},
})
}

View 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'] },
])
})
})
})

View 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)
}

View File

@@ -0,0 +1,7 @@
import View from './C3.vue'
import * as controls from './Controls'
export default {
View,
controls,
}

View 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>

View File

@@ -0,0 +1 @@
export { default as CChart } from './CChart.vue'

View 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>

View File

@@ -0,0 +1 @@
export { default as CCorredorManualButtons } from './CCorredorManualButtons.vue'

View 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>

View 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>

View 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>

View 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 })
}

View File

@@ -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>

View File

@@ -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>

View 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>

View File

@@ -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
}

View 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>

View 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'

View 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'

View 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'

View 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>

View 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>

View 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>

View File

@@ -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())
})
})
})

View 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()
}

View 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>

View 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>

View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -0,0 +1,9 @@
import Color from './Color.vue'
import Item from './Item.vue'
import Link from './Link.vue'
export default {
Color,
Item,
Link,
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -0,0 +1,5 @@
import Item from './Item.vue'
export default {
Item,
}

View File

@@ -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>

View File

@@ -0,0 +1,5 @@
import Alignment from './Alignment.vue'
export default {
Alignment,
}

View 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>

View File

@@ -0,0 +1,9 @@
import mark from './TMark/loader'
import node from './TNode/loader'
import nodeAttr from './TNodeAttr/loader'
export default {
mark,
node,
nodeAttr,
}

View File

@@ -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)
}
}
}

View File

@@ -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(/&#32;/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]
}
}

View File

@@ -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'

View File

@@ -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],
}
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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) }
},
})

View 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>

View 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)

View File

@@ -0,0 +1 @@
export * from './formats'

View 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'

View File

@@ -0,0 +1 @@
export { default as CItemPicker } from './CItemPicker.c3'

View 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'),
],
}

View 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>

View 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>

View File

@@ -0,0 +1,2 @@
export { default as CItemPicker } from './CItemPicker.vue'
export { default as CItemPickerItem } from './CItemPickerItem.vue'

View 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')"
>
&times;
</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>

View File

@@ -0,0 +1 @@
export { default as CLightbox } from './CLightbox.vue'

View 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>

View File

@@ -0,0 +1 @@
export { default as CLoaderLogo } from './CLoaderLogo.vue'

View 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>

View 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>

View 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>

View 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'

View 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>

View 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>

View 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'}`,
}
}

View 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>

View 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>

View 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>

View File

@@ -0,0 +1,2 @@
export { default as CPermissionsButton } from './CPermissionsButton.vue'
export { default as CPermissionsModal } from './CPermissionsModal.vue'

View 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>

View File

@@ -0,0 +1 @@
export { default as CSensitivityLevelPicker } from './CSensitivityLevelPicker.vue'

View 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)"
>
&laquo; 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>

View 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>

View 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>

View 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>

View 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' })))

View 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'

View 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>

View 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>

View File

@@ -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>

View 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>

View 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