snapshot of https://github.com/cortezaproject/corteza.git (2024.9.x, 2026-09-18; blobs > 131072B omitted)
Some checks failed
SonarQube / SonarQube (push) Failing after 5m28s
E2e test on main branch push / server and client test (map[name:admin port:8080]) (push) Failing after 3m33s
Server and lib tests / Library test (js) (push) Failing after 1m36s
Server and lib tests / Library test (vue) (push) Successful in 2m24s
Server and lib tests / Web client test (admin) (push) Successful in 2m52s
Server and lib tests / Web client test (compose) (push) Successful in 3m23s
Server and lib tests / Web client test (discovery) (push) Successful in 2m49s
Server and lib tests / Web client test (one) (push) Successful in 2m52s
Server and lib tests / Web client test (privacy) (push) Successful in 2m44s
Server and lib tests / Web client test (reporter) (push) Successful in 2m56s
Server and lib tests / Web client test (workflow) (push) Successful in 3m0s
Server and lib tests / Server tests (push) Failing after 4m55s
E2e test on main branch push / notify-failure (push) Failing after 9s
Server and lib tests / Send matrix testing notification (push) Successful in 2s
Mark stale issues and pull requests / stale (push) Successful in 40s

This commit is contained in:
upstream-mirror
2026-09-18 01:22:23 +05:00
commit 7e385fc126
8391 changed files with 1766236 additions and 0 deletions

5
lib/vue/.eslintignore Normal file
View File

@@ -0,0 +1,5 @@
node_modules
dist
# Test files
**/*.test.ts

102
lib/vue/.eslintrc.cjs Normal file
View File

@@ -0,0 +1,102 @@
module.exports = {
root: true,
env: {
node: true,
es6: true,
browser: true,
mocha: true,
},
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:vue/recommended',
],
rules: {
'no-console': process.env.NODE_ENV === 'production' ? 'error' : 'off',
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off',
'import/no-named-default': 'off',
'new-cap': 'off',
'prefer-const': 'off',
'comma-dangle': ['error', 'always-multiline'],
'no-misleading-character-class': 'off',
'no-useless-catch': 'off',
'no-async-promise-executor': 'off',
'no-case-declarations': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-unused-vars': 'off',
'@typescript-eslint/ban-ts-ignore': 'off',
'@typescript-eslint/ban-ts-comment': 'off',
'@typescript-eslint/camelcase': 'off',
'vue/no-v-model-argument': 'off',
'vue/component-name-in-template-casing': ['error', 'kebab-case'],
'vue/order-in-components': ['error'],
'vue/no-v-html': 'off',
'vue/multi-word-component-names': 'off',
'vue/no-lone-template': 'off',
'vue/no-mutating-props': 'off',
'vue/component-definition-name-casing': 'off',
'vue/no-v-text-v-html-on-component': 'off',
'vue/attributes-order': ['error', {
order: [
'DEFINITION',
'LIST_RENDERING',
'CONDITIONALS',
'RENDER_MODIFIERS',
'GLOBAL',
'UNIQUE',
'TWO_WAY_BINDING',
'SLOT',
'OTHER_DIRECTIVES',
'OTHER_ATTR',
'EVENTS',
'CONTENT',
],
alphabetical: false,
}],
},
parser: 'vue-eslint-parser',
parserOptions: {
parser: '@typescript-eslint/parser',
ecmaVersion: 2020,
sourceType: 'module',
extraFileExtensions: ['.vue'],
ecmaFeatures: {
jsx: true,
},
},
plugins: [
'@typescript-eslint',
'vue',
],
settings: {
'import/parsers': {
'@typescript-eslint/parser': ['.ts', '.tsx'],
},
'import/resolver': {
typescript: {
alwaysTryTypes: true,
project: 'tsconfig.json',
},
},
},
overrides: [
{
files: ['*.test.ts'],
rules: {
'no-unused-expressions': 'off',
},
},
{
files: ['*.ts', '*.tsx'],
rules: {
'no-undef': 'off', // TypeScript already checks this
},
},
{
files: ['*.vue'],
rules: {
'indent': 'off', // Handled by vue/script-indent
},
},
],
}

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*

11
lib/vue/.mocharc.cjs Normal file
View File

@@ -0,0 +1,11 @@
module.exports = {
require: [
'tsx/cjs'
],
'full-trace': true,
bail: true,
recursive: true,
extension: ['ts'],
spec: ['src/**/*.test.ts'],
timeout: 5000,
}

157
lib/vue/package.json Normal file
View File

@@ -0,0 +1,157 @@
{
"name": "@cortezaproject/corteza-vue",
"description": "Corteza Vue.js JavaScript/TypeScript libs",
"version": "2024.9.10",
"license": "Apache-2.0",
"author": "Denis Arh <denis.arh@crust.tech>",
"type": "module",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
},
"files": [
"dist/"
],
"scripts": {
"build": "rollup -c --environment BUILD:production",
"build:dev": "rollup -c",
"lint": "eslint src --ext .js,.ts,.vue",
"test:unit": "mocha",
"test": "yarn test:unit",
"cdeps": "yarn link @cortezaproject/corteza-js"
},
"gitHooks": {
"pre-commit": "yarn lint"
},
"resolutions": {
"postcss": "^8.5.3",
"json5": "^2.2.3",
"loader-utils": "^3.2.0",
"prosemirror-model": "^1.25.0"
},
"dependencies": {
"@cortezaproject/corteza-js": "^2024.9.10",
"@floating-ui/dom": "^1.7.4",
"@fortawesome/fontawesome-svg-core": "^6.7.2",
"@fortawesome/free-regular-svg-icons": "^6.7.2",
"@fortawesome/free-solid-svg-icons": "^6.7.2",
"@fortawesome/vue-fontawesome": "^2.0.10",
"@panter/vue-i18next": "^0.15.0",
"@popperjs/core": "^2.11.8",
"@tiptap/extension-emoji": "^3.4.2",
"@tiptap/extension-mention": "^3.4.2",
"@tiptap/extension-placeholder": "^3.4.2",
"@tiptap/extension-table": "^3.4.2",
"@tiptap/extension-text-align": "^3.4.2",
"@tiptap/extension-text-style": "^3.4.2",
"@tiptap/starter-kit": "^3.4.2",
"@tiptap/suggestion": "^3.4.2",
"@tiptap/vue-2": "^3.4.2",
"bootstrap-vue": "2.23.1",
"echarts": "5.6.0",
"fuzzysort": "^3.1.0",
"html-parse-stringify": "^3.0.1",
"i18next": "^23.16.8",
"i18next-browser-languagedetector": "^8.0.4",
"i18next-http-backend": "^3.0.2",
"i18next-multiload-backend-adapter": "^2.3.0",
"leaflet": "^1.9.4",
"leaflet-geosearch": "^4.1.0",
"lodash": "^4.17.21",
"mime": "^4.0.6",
"moment": "2.30.1",
"numeral": "^2.0.6",
"pdfjs-dist": "^4.10.38",
"pino": "^9.6.0",
"portal-vue": "^3.0.0",
"qs": "^6.14.0",
"v-photoswipe": "^1.0.3-1",
"vue": "2.7.16",
"vue-color": "^2.8.1",
"vue-echarts": "7.0.3",
"vue-select": "3.20.3",
"vue2-ace-editor": "^0.0.15",
"vue2-dropzone": "^3.6.0",
"vue2-leaflet": "^2.7.1",
"vuedraggable": "^2.24.3",
"vuex": "^3.6.2",
"websocket-extensions": "^0.1.4"
},
"devDependencies": {
"@babel/core": "^7.26.7",
"@babel/eslint-parser": "^7.26.5",
"@babel/plugin-transform-class-properties": "^7.25.9",
"@babel/plugin-transform-private-methods": "^7.25.9",
"@babel/plugin-transform-private-property-in-object": "^7.25.9",
"@babel/preset-env": "^7.26.7",
"@rollup/plugin-babel": "^6.0.4",
"@rollup/plugin-commonjs": "^25.0.0",
"@rollup/plugin-json": "^6.1.0",
"@rollup/plugin-node-resolve": "^16.0.0",
"@types/chai": "^5.0.1",
"@types/chai-as-promised": "^8.0.1",
"@types/leaflet": "^1.9.16",
"@types/lodash": "^4.17.15",
"@types/mocha": "^10.0.10",
"@types/node": "^22.13.10",
"@types/qs": "^6.9.0",
"@typescript-eslint/eslint-plugin": "^8.22.0",
"@typescript-eslint/parser": "^8.22.0",
"@vue/eslint-config-standard": "^8.0.1",
"chai": "^5.1.2",
"chai-as-promised": "^8.0.1",
"eslint": "^8.7.2",
"eslint-config-standard": "^17.1.0",
"eslint-import-resolver-typescript": "^3.7.0",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-json": "^4.0.1",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-promise": "^7.2.1",
"eslint-plugin-standard": "^5.0.0",
"eslint-plugin-vue": "^9.32.0",
"esm": "^3.2.25",
"i18next-pseudo": "^2.2.1",
"mocha": "^11.1.0",
"node-watch": "^0.7.4",
"nodemon": "^3.1.9",
"rollup": "^4.32.1",
"rollup-plugin-import-css": "^3.0.0",
"rollup-plugin-styles": "^4.0.0",
"rollup-plugin-typescript2": "^0.36.0",
"rollup-plugin-vue": "^5.1.9",
"sass": "^1.85.1",
"sinon": "^9.2.4",
"source-map-support": "^0.5.21",
"tsx": "^4.19.2",
"typescript": "^5.0.0",
"vue-eslint-parser": "^9.4.3",
"vue-property-decorator": "^8.3.0",
"vue-template-compiler": "2.7.16"
},
"stylelint": {
"plugins": [
"stylelint-scss"
],
"extends": "stylelint-config-standard",
"rules": {
"color-hex-length": null,
"no-empty-source": null
}
},
"engines": {
"node": ">=20.18.2"
},
"browserslist": [
"> 1%",
"last 2 versions",
"not ie <= 8"
],
"browser": {
"fs": false,
"path": false
}
}

92
lib/vue/rollup.config.js Normal file
View File

@@ -0,0 +1,92 @@
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 { readFileSync } from 'fs'
import { createRequire } from 'module'
import { resolve as resolvePath } from 'path'
import { fileURLToPath } from 'url'
const require = createRequire(import.meta.url)
const pkg = JSON.parse(readFileSync(new URL('./package.json', import.meta.url)))
const ts = require('typescript')
export default {
input: 'src/index.ts',
output: [
{
file: 'dist/index.cjs',
format: 'cjs',
sourcemap: true,
inlineDynamicImports: true,
},
{
file: 'dist/index.js',
format: 'es',
sourcemap: true,
inlineDynamicImports: true,
},
],
external: [
...Object.keys(pkg.dependencies || {}),
...Object.keys(pkg.peerDependencies || {}),
'fs',
'path',
],
plugins: [
// Alias ~corteza-vue/ to src/ so internal components can import each other
// without self-referencing the package dist (which causes circular deps)
{
name: 'internal-alias',
resolveId (source) {
if (source.startsWith('~corteza-vue/')) {
const rel = source.slice('~corteza-vue/'.length)
return resolvePath(fileURLToPath(new URL('.', import.meta.url)), 'src', rel)
}
return null
},
},
resolve({
browser: true,
preferBuiltins: false,
}),
commonjs({
include: /node_modules/,
}),
typescript({
typescript: ts,
tsconfig: './tsconfig.json',
sourceMap: true,
check: true,
}),
vue({
preprocessStyles: true,
}),
babel({
exclude: /node_modules\/(?!pdfjs-dist).*/,
babelHelpers: 'bundled',
presets: [
['@babel/preset-env'],
],
plugins: [
'@babel/plugin-transform-class-properties',
'@babel/plugin-transform-private-methods',
'@babel/plugin-transform-private-property-in-object',
],
}),
json(),
styles({
exclude: ['node_modules/vue2-dropzone/**'],
}),
],
watch: {
exclude: ['node_modules/**'],
},
}

View File

@@ -0,0 +1,528 @@
<template>
<div class="c-emoji-picker">
<div class="c-emoji-picker-search-wrap">
<input
ref="searchInput"
type="text"
class="c-emoji-picker-search-input"
:placeholder="labels.search || 'Search'"
@input="onSearch"
@click.stop
@keydown.stop
>
<svg
class="c-emoji-picker-search-icon"
viewBox="0 0 16 16"
fill="currentColor"
>
<path d="M11.742 10.344a6.5 6.5 0 1 0-1.397 1.398h-.001l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85zm-5.44 1.156a5 5 0 1 1 0-10 5 5 0 0 1 0 10z" />
</svg>
</div>
<div
ref="viewport"
class="c-emoji-picker-viewport"
:style="{ height: viewportHeight + 'px', width: viewportWidth + 'px' }"
@scroll="onScroll"
>
<div
ref="scrollContent"
class="c-emoji-picker-scroll-content"
/>
</div>
<div
v-if="showQuickReactions"
class="c-emoji-picker-quick"
>
<div class="c-emoji-picker-quick-label">
{{ labels.quickReactions || 'Quick Reactions' }}
</div>
<div class="c-emoji-picker-quick-row">
<span
v-for="qr in quickReactionsList"
:key="qr.emoji"
class="epi"
:title="qr.name"
@click.stop="onQuickReactionClick(qr)"
>
{{ qr.emoji }}
</span>
</div>
</div>
</div>
</template>
<script>
const STORAGE_KEY = 'corteza:emoji:frequently-used'
const MAX_FREQUENT = 18
// Virtual scroll constants (px)
const ITEM_SIZE = 30
const LABEL_HEIGHT = 26
const ITEMS_PER_ROW = 8
const BUFFER_PX = 90
const EMOJI_BLACKLIST = new Set(['relaxed', 'frowning_face'])
const GROUP_CONFIG = [
{ name: 'smileys-people', icon: '😀', label: 'Smileys & People', sources: ['', 'people & body'] },
{ name: 'animals & nature', icon: '🐶', label: 'Animals & Nature', sources: ['animals & nature'] },
{ name: 'food & drink', icon: '🍎', label: 'Food & Drink', sources: ['food & drink'] },
{ name: 'travel & places', icon: '🚗', label: 'Travel & Places', sources: ['travel & places'] },
{ name: 'activities', icon: '⚽', label: 'Activities', sources: ['activities'] },
{ name: 'objects', icon: '💡', label: 'Objects', sources: ['objects'] },
{ name: 'symbols', icon: '❤️', label: 'Symbols', sources: ['symbols'] },
]
export default {
name: 'CEmojiPicker',
props: {
/**
* Override emoji list. If not provided, uses tiptap's built-in emoji data.
*/
emojis: {
type: Array,
default: () => [],
},
/**
* Height of the scrollable emoji viewport in px.
*/
viewportHeight: {
type: Number,
default: 260,
},
/**
* Width of the scrollable emoji viewport in px.
*/
viewportWidth: {
type: Number,
default: 260,
},
/**
* Whether to show the "Frequently Used" section.
*/
showFrequent: {
type: Boolean,
default: true,
},
/**
* Whether to show the "Quick Reactions" footer.
*/
showQuickReactions: {
type: Boolean,
default: true,
},
/**
* Labels for translatable strings.
*/
labels: {
type: Object,
default: () => ({}),
},
},
data () {
return {
search: '',
frequentlyUsed: [],
virtualRows: [],
totalHeight: 0,
renderedRange: { start: -1, end: -1 },
}
},
computed: {
allEmojis () {
return this.emojis || []
},
emojiByGroup () {
const raw = {}
for (const emoji of this.allEmojis) {
if (emoji.name.startsWith('regional_indicator')) continue
if (EMOJI_BLACKLIST.has(emoji.name)) continue
if (!raw[emoji.group]) raw[emoji.group] = []
raw[emoji.group].push(emoji)
}
const map = {}
for (const group of GROUP_CONFIG) {
const merged = []
for (const src of group.sources) {
if (raw[src]) merged.push(...raw[src])
}
if (merged.length) map[group.name] = merged
}
return map
},
groups () {
const byGroup = this.emojiByGroup
return GROUP_CONFIG.filter(g => byGroup[g.name]?.length)
},
frequentEmojis () {
if (!this.showFrequent) return []
return this.frequentlyUsed
.map(name => this.allEmojis.find(e => e.name === name))
.filter(Boolean)
},
quickReactionsList () {
const names = ['+1', '-1', 'smile', 'tada', 'blush', 'rocket', 'eyes']
return names
.map(name => this.allEmojis.find(e => e.name === name))
.filter(Boolean)
},
filteredEmojis () {
if (!this.search) return []
const q = this.search.toLowerCase()
return this.allEmojis.filter((emoji) => {
if (emoji.name.startsWith('regional_indicator')) return false
if (EMOJI_BLACKLIST.has(emoji.name)) return false
if (emoji.name.toLowerCase().includes(q)) return true
if (emoji.shortcodes && emoji.shortcodes.some(s => s.toLowerCase().includes(q))) return true
if (emoji.tags && emoji.tags.some(t => t.toLowerCase().includes(q))) return true
return false
}).slice(0, 60)
},
},
mounted () {
this.loadFrequentlyUsed()
},
methods: {
/**
* Call this externally after the picker becomes visible (e.g. popover @shown).
* Resets search, rebuilds layout, focuses input.
*/
async reset () {
this.search = ''
this.loadFrequentlyUsed()
this.$nextTick(() => {
if (this.$refs.searchInput) {
this.$refs.searchInput.value = ''
this.$refs.searchInput.focus()
}
const content = this.$refs.scrollContent
if (content && !content._bound) {
content.addEventListener('click', this.onEmojiClick)
content._bound = true
}
this.rebuildVirtualRows()
this.renderedRange = { start: -1, end: -1 }
if (this.$refs.viewport) {
this.$refs.viewport.scrollTop = 0
}
this.renderVisible()
})
},
loadFrequentlyUsed () {
try {
const stored = localStorage.getItem(STORAGE_KEY)
this.frequentlyUsed = stored ? JSON.parse(stored) : []
} catch {
this.frequentlyUsed = []
}
},
saveFrequentlyUsed (emojiName) {
const list = [emojiName, ...this.frequentlyUsed.filter(n => n !== emojiName)].slice(0, MAX_FREQUENT)
this.frequentlyUsed = list
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(list))
} catch {
// ignore
}
},
onSearch (e) {
this.search = e.target.value
this.rebuildVirtualRows()
this.renderedRange = { start: -1, end: -1 }
if (this.$refs.viewport) {
this.$refs.viewport.scrollTop = 0
}
this.renderVisible()
},
onEmojiClick (e) {
const el = e.target.closest('[data-emoji]')
if (el) {
e.stopPropagation()
const name = el.dataset.emoji
const emoji = this.allEmojis.find(em => em.name === name)
this.saveFrequentlyUsed(name)
/**
* Emitted when an emoji is selected.
* @event select
* @property {Object} emojiItem - The full emoji object { emoji, name, shortcodes, tags, group }
*/
this.$emit('select', emoji || { name, emoji: el.textContent })
}
},
rebuildVirtualRows () {
const rows = []
let y = 0
const addSection = (label, emojis) => {
rows.push({ type: 'label', text: label, y, height: LABEL_HEIGHT })
y += LABEL_HEIGHT
for (let i = 0; i < emojis.length; i += ITEMS_PER_ROW) {
const chunk = emojis.slice(i, i + ITEMS_PER_ROW)
rows.push({ type: 'emojis', items: chunk, y, height: ITEM_SIZE })
y += ITEM_SIZE
}
}
if (this.search) {
const results = this.filteredEmojis
if (results.length) {
addSection(this.labels.searchResults || 'Search Results', results)
} else {
rows.push({ type: 'empty', y, height: 60 })
y += 60
}
} else {
const freq = this.frequentEmojis
if (freq.length) {
addSection(this.labels.frequentlyUsed || 'Frequently Used', freq)
}
const byGroup = this.emojiByGroup
for (const group of this.groups) {
const emojis = byGroup[group.name]
if (emojis) {
addSection(group.label, emojis)
}
}
}
this.virtualRows = rows
this.totalHeight = y
},
onScroll () {
this.renderVisible()
},
renderVisible () {
const viewport = this.$refs.viewport
const content = this.$refs.scrollContent
if (!viewport || !content) return
const rows = this.virtualRows
content.style.height = this.totalHeight + 'px'
if (!rows.length) {
content.innerHTML = ''
return
}
const scrollTop = viewport.scrollTop
const rangeStart = scrollTop - BUFFER_PX
const rangeEnd = scrollTop + this.viewportHeight + BUFFER_PX
let first = this.findFirstRow(rangeStart)
let last = this.findLastRow(rangeEnd)
if (first === this.renderedRange.start && last === this.renderedRange.end) {
return
}
this.renderedRange = { start: first, end: last }
const parts = []
for (let i = first; i <= last; i++) {
const row = rows[i]
if (row.type === 'label') {
parts.push(`<div class="c-emoji-picker-section-label" style="position:absolute;top:${row.y}px;left:0;right:0;">${this.esc(row.text)}</div>`)
} else if (row.type === 'emojis') {
parts.push(`<div class="c-emoji-picker-vrow" style="position:absolute;top:${row.y}px;left:0;right:0;height:${ITEM_SIZE}px;">`)
for (const e of row.items) {
parts.push(`<span class="epi" data-emoji="${e.name}" title=":${e.name}:">${e.emoji}</span>`)
}
parts.push('</div>')
} else if (row.type === 'empty') {
parts.push(`<div class="c-emoji-picker-empty" style="position:absolute;top:${row.y}px;left:0;right:0;">${this.esc(this.labels.noResults || 'No emojis found')}</div>`)
}
}
content.innerHTML = parts.join('')
},
findFirstRow (y) {
const rows = this.virtualRows
let lo = 0
let hi = rows.length - 1
while (lo < hi) {
const mid = (lo + hi) >> 1
if (rows[mid].y + rows[mid].height <= y) {
lo = mid + 1
} else {
hi = mid
}
}
return lo
},
findLastRow (y) {
const rows = this.virtualRows
let lo = 0
let hi = rows.length - 1
while (lo < hi) {
const mid = (lo + hi + 1) >> 1
if (rows[mid].y >= y) {
hi = mid - 1
} else {
lo = mid
}
}
return Math.min(lo, rows.length - 1)
},
onQuickReactionClick (emoji) {
this.saveFrequentlyUsed(emoji.name)
this.$emit('select', emoji)
},
esc (str) {
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
},
},
}
</script>
<style lang="scss">
.c-emoji-picker-search-wrap {
position: relative;
padding: 0.4rem 0.5rem;
}
.c-emoji-picker-search-input {
width: 100%;
padding: 0.3rem 0.5rem 0.3rem 1.75rem;
border: 1px solid var(--extra-light, #ddd);
border-radius: 0.35rem;
font-size: 0.8rem;
outline: none;
background: var(--white, #fff);
color: var(--dark, #333);
&:focus {
border-color: var(--primary, #4080ff);
box-shadow: 0 0 0 2px rgba(64, 128, 255, 0.15);
}
&::placeholder {
color: var(--secondary, #999);
}
}
.c-emoji-picker-search-icon {
position: absolute;
left: 1rem;
top: 50%;
transform: translateY(-50%);
width: 0.8rem;
height: 0.8rem;
color: var(--secondary, #999);
pointer-events: none;
}
.c-emoji-picker-viewport {
overflow-y: auto;
position: relative;
padding: 0 0.4rem;
}
.c-emoji-picker-scroll-content {
position: relative;
width: 100%;
}
.c-emoji-picker-section-label {
font-size: 0.7rem;
font-weight: 700;
color: var(--secondary, #888);
text-transform: uppercase;
letter-spacing: 0.03em;
padding: 0.3rem 0.15rem 0.15rem;
background: var(--white, #fff);
line-height: 1;
height: 26px;
display: flex;
align-items: center;
}
.c-emoji-picker-vrow {
display: flex;
}
.epi {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
padding: 0;
margin: 0;
border-radius: 0.25rem;
cursor: pointer;
font-size: 1.15rem;
line-height: 1;
&:hover {
background-color: var(--light, #f0f0f0);
transform: scale(1.15);
}
&:active {
transform: scale(0.95);
}
}
.c-emoji-picker-empty {
text-align: center;
color: var(--secondary, #999);
padding: 1.5rem 0;
font-size: 0.8rem;
}
.c-emoji-picker-quick {
border-top: 1px solid var(--extra-light, #e0e0e0);
padding: 0.25rem 0.4rem 0.3rem;
}
.c-emoji-picker-quick-label {
font-size: 0.65rem;
font-weight: 700;
color: var(--secondary, #888);
text-transform: uppercase;
letter-spacing: 0.03em;
padding: 0.1rem 0.15rem;
}
.c-emoji-picker-quick-row {
display: flex;
}
</style>

View File

@@ -0,0 +1,161 @@
<template>
<b-modal
v-model="showModal"
centered
hide-header
hide-footer
no-close-on-backdrop
no-close-on-esc
body-class="d-flex flex-column justify-content-center align-items-center gap-2 p-4"
@hide="stopCountdown"
>
<h5>{{ labels.warning(countdownTime) }}</h5>
<b-button
variant="primary"
size="lg"
@click="extendSession"
>
{{ labels.extend }}
</b-button>
</b-modal>
</template>
<script>
import { throttle } from 'lodash'
export default {
name: 'CExtendSession',
props: {
timeout: {
type: Number,
default: 60,
},
labels: {
type: Object,
default: () => ({
extend: 'Extend Session',
warning: (countdownTime) => `You will be logged out in ${countdownTime} seconds`,
}),
},
},
data () {
return {
countdownTime: null,
countdownTimer: null,
lastActivityTime: 0,
heartbeatInterval: null,
debouncedUpdateActivity: null,
showModal: false,
}
},
mounted () {
this.debouncedUpdateActivity = throttle(this.updateActivity, 5000, { leading: true, trailing: true })
this.setupActivityListeners()
this.setupHeartbeatMonitoring()
},
beforeDestroy () {
clearInterval(this.heartbeatInterval)
clearInterval(this.countdownTimer)
this.removeActivityListeners()
this.debouncedUpdateActivity.cancel()
},
methods: {
extendSession () {
this.showModal = false
this.$auth.stopAutoLogout().then(() => {
this.setupHeartbeatMonitoring()
}).catch((err) => {
console.error('Failed to stop auto logout', err)
this.$auth.logout()
})
},
startCountdown (expiresIn) {
this.countdownTime = expiresIn
this.countdownTimer = setInterval(() => {
this.countdownTime--
if (this.countdownTime <= 0) {
clearInterval(this.countdownTimer)
this.$auth.logout()
}
}, 1000)
},
stopCountdown () {
if (!this.countdownTimer) {
return
}
clearInterval(this.countdownTimer)
},
promptExtendSession () {
clearInterval(this.heartbeatInterval)
this.$auth.startAutoLogout().then((expiresIn) => {
this.startCountdown(expiresIn)
this.showModal = true
}).catch((err) => {
console.error('Failed to start auto logout', err)
this.$auth.logout()
})
},
setupActivityListeners () {
// Desktop events
window.addEventListener('mousemove', this.debouncedUpdateActivity, { passive: true })
window.addEventListener('keypress', this.debouncedUpdateActivity, { passive: true })
window.addEventListener('click', this.debouncedUpdateActivity, { passive: true })
window.addEventListener('scroll', this.debouncedUpdateActivity, { passive: true })
// Mobile events
window.addEventListener('touchstart', this.debouncedUpdateActivity, { passive: true })
window.addEventListener('touchmove', this.debouncedUpdateActivity, { passive: true })
window.addEventListener('touchend', this.debouncedUpdateActivity, { passive: true })
},
removeActivityListeners () {
// Desktop events
window.removeEventListener('mousemove', this.debouncedUpdateActivity)
window.removeEventListener('keypress', this.debouncedUpdateActivity)
window.removeEventListener('click', this.debouncedUpdateActivity)
window.removeEventListener('scroll', this.debouncedUpdateActivity)
// Mobile events
window.removeEventListener('touchstart', this.debouncedUpdateActivity)
window.removeEventListener('touchmove', this.debouncedUpdateActivity)
window.removeEventListener('touchend', this.debouncedUpdateActivity)
},
updateActivity () {
this.lastActivityTime = Date.now()
},
setupHeartbeatMonitoring () {
if (this.timeout === 0) {
return
}
this.lastActivityTime = Date.now()
this.heartbeatInterval = setInterval(() => {
const secondsSinceLastActivity = Math.floor((Date.now() - this.lastActivityTime) / 1000)
if (secondsSinceLastActivity > this.timeout) {
this.promptExtendSession()
}
}, 5000)
},
},
}
</script>

View File

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

View File

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

View File

@@ -0,0 +1,185 @@
<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
v-if="current"
class="controls px-5 py-2 mt-2"
>
<div
v-for="(cg, g) in controlGroups"
:key="`control-group-${g}`"
class="control-group mr-2"
>
<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,
},
},
data () {
return {
current: undefined,
}
},
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]
},
},
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,
#F9FAFB 21.43%,
#FFFFFF 21.43%,
#FFFFFF 50%,
#F9FAFB 50%,
#F9FAFB 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"
class="component ml-2"
@click="$emit('select', cmp)"
>
{{ 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="(g) in subgroups"
:key="g"
:catalogue="catalogue"
:path="[...path, g]"
class="my-3"
@select="$emit('select', $event)"
/>
</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 CatalogueItem {
group?: Array<string>;
name?: string;
}
export interface Catalogue {
[_: string]: CatalogueItem;
}
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,36 @@
<template>
<e-charts
ref="chart"
:option="chart"
:theme="theme"
autoresize
class="position-absolute w-100 h-100 overflow-hidden"
v-on="$listeners"
/>
</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'
},
},
beforeDestroy () {
if (this.$refs.chart) {
this.$refs.chart.dispose()
}
},
}
</script>

View File

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

View File

@@ -0,0 +1,58 @@
<template>
<b-button-group
v-if="buttons.length"
:size="size"
>
<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: {
type: String,
required: true,
},
uiSlot: {
type: String,
required: true,
},
uiPage: {
type: String,
required: true,
},
defaultVariant: {
type: String,
default: 'link',
},
buttonClass: {
type: String,
default: 'mr-1',
},
size: {
type: String,
default: 'md',
},
},
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,29 @@
<template>
<div class="inline h-100">
<div class="h-100">
<component
:is="previewType"
v-bind="$attrs"
:max-pages="1"
:initial-scale="1.5"
inline
v-on="$listeners"
/>
</div>
</div>
</template>
<script lang="js">
import { InlineIMG as IMG, PDF, NoPreview } from './common/types'
import base from './common/base.vue'
export default {
components: {
IMG,
PDF,
NoPreview,
},
extends: base,
}
</script>

View File

@@ -0,0 +1,39 @@
<template>
<c-lightbox v-on="$listeners">
<!-- Proxy slots -->
<template
v-for="(_, slot) of $scopedSlots"
#[slot]="scope"
>
<slot
:name="slot"
v-bind="scope"
v-on="$listeners"
/>
</template>
<component
:is="previewType"
v-if="previewType"
v-bind="$attrs"
v-on="$listeners"
/>
</c-lightbox>
</template>
<script lang="js">
import { LightboxIMG as IMG, PDF, NoPreview } from './common/types'
import { CLightbox } from '../lightbox/index.ts'
import base from './common/base.vue'
export default {
components: {
CLightbox,
IMG,
PDF,
NoPreview,
},
extends: base,
}
</script>

View File

@@ -0,0 +1,20 @@
<script lang="js">
import { getComponent } from './index.js'
export default {
inheritAttrs: false,
props: {
inline: {
type: Boolean,
default: false,
},
},
computed: {
previewType () {
return getComponent({ type: this.$attrs.mime, src: this.$attrs.src, name: this.$attrs.name })
},
},
}
</script>

View File

@@ -0,0 +1,69 @@
import mime from 'mime'
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.getType(src) || mime.getType(name)
if (!srcType) {
return
}
for (const { type, component } of types) {
if (srcType.indexOf(type) >= 0) {
return component
}
}
return 'NoPreview'
}
/**
* 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 })
}
export const getExtensionIconType = (ext) => {
switch (ext) {
case 'odt':
case 'doc':
case 'docx':
return 'word'
case 'pdf':
return 'pdf'
case 'ppt':
case 'pptx':
return 'powerpoint'
case 'zip':
case 'rar':
return 'archive'
case 'xls':
case 'xlsx':
case 'csv':
return 'excel'
case 'mov':
case 'mp3':
case 'mp4':
return 'video'
case 'png':
case 'jpg':
case 'jpeg':
return 'image'
default: return 'alt'
}
}

View File

@@ -0,0 +1,109 @@
<template>
<div class="d-flex inline h-100 mb-1">
<img
ref="image"
:key="src"
:src="src"
:title="title"
:alt="alt"
:class="getClass"
:style="previewStyle"
:width="getWidth"
:height="getHeight"
@click.stop="$emit('openPreview', {})"
@error.once="reloadBrokenImage"
@load="loaded=true"
>
</div>
</template>
<script lang="js">
import base from '../base.vue'
export default {
extends: base,
props: {
alt: {
type: String,
default: null,
},
title: {
type: String,
default: null,
},
},
data () {
return {
loaded: false,
}
},
computed: {
isSvg () {
return this.mime === 'image/svg+xml' ||
(this.name && this.name.toLowerCase().endsWith('.svg'))
},
getClass () {
const rtr = [...this.previewClass]
if (this.$listeners.click) {
rtr.push('clickable')
}
if (this.loaded) {
rtr.push('loaded')
}
return rtr
},
getWidth () {
if (this.isSvg) return undefined
return this.meta.preview.image.width
},
getHeight () {
if (this.isSvg) return undefined
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;
}
img.disable-zoom-cursor {
cursor: default;
}
}
}
</style>

View File

@@ -0,0 +1,118 @@
<template>
<div class="popup-img-preview">
<div
v-if="isSvg"
class="svg-preview"
>
<img
:src="src"
class="svg-lightbox-img"
>
</div>
<photo-swipe
v-else
: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: {
isSvg () {
return this.mime === 'image/svg+xml' ||
(this.name && this.name.toLowerCase().endsWith('.svg'))
},
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,
}]
},
},
beforeUnmount () {
this.setDefaultValues()
},
methods: {
setDefaultValues () {
this.options = {}
},
},
}
</script>
<style lang="scss">
.popup-img-preview {
.pswp {
pointer-events: none;
.pswp__img {
pointer-events: all;
}
}
.pswp__top-bar {
display: none!important;
}
.svg-preview {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
.svg-lightbox-img {
max-width: 90%;
max-height: 90%;
object-fit: contain;
}
}
}
</style>

View File

@@ -0,0 +1,54 @@
<template>
<div class="d-flex align-items-center justify-content-center h-100 mb-2">
<font-awesome-icon
v-if="inline"
:title="name"
:icon="['far', `file-${icon}`]"
:style="previewStyle"
class="inline-icon d-block text-secondary"
@click.stop="$emit('openPreview')"
/>
<p
v-else
class="text-secondary"
>
{{ labels.previewUnavailable || 'Preview unavailable' }}
</p>
</div>
</template>
<script lang="js">
import { getExtensionIconType } from '../index.js'
import base from '../base.vue'
export default {
extends: base,
props: {
previewStyle: {
type: Object,
default: () => ({}),
},
labels: {
type: Object,
default: () => ({}),
},
},
computed: {
icon () {
const { original = {} } = this.meta || {}
const { ext } = original || {}
return getExtensionIconType(ext)
},
},
}
</script>
<style lang="scss" scoped>
.inline-icon {
cursor: zoom-in;
}
</style>

View File

@@ -0,0 +1,358 @@
<template>
<div
:style="previewStyle"
:class="[...previewClass, 'pdf-preview', inline ? 'inline' : '', $listeners.click ? 'clickable' : '']"
@click.stop="onPreviewClick"
>
<!-- Container for pdf's pages -->
<div
v-show="show"
ref="pages"
class="pages shadow-sm"
/>
<div
v-if="loadError"
class="doc-msg doc-err"
>
<p class="err-message">
{{ loadError.message }}
</p>
</div>
<div
v-else-if="!show && labels.loading"
class="doc-msg"
>
<p class="d-flex align-items-center gap-1">
<b-spinner
variant="primary"
small
/>
{{ labels.loading }}
</p>
</div>
<div
v-else-if="!pageCount && labels.noPages"
class="doc-msg doc-err"
>
<p>{{ labels.noPages }}</p>
</div>
</div>
</template>
<script lang="js">
import * as pdfjsLib from 'pdfjs-dist'
import base from '../base.vue'
import { makePlaceholder, makeFailedPage, Page, Document } from './helpers'
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString()
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: 25,
},
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())
},
beforeUnmount () {
this.setDefaultValues()
},
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 pdfjsLib.getDocument({
url: src,
useWorkerFetch: true,
isEvalSupported: true,
useSystemFonts: true,
}).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).promise.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) {
console.error(err)
this.loadError = err
this.$emit('error', err)
},
setDefaultValues () {
this.document = null
this.pages = []
this.show = false
this.loadError = undefined
},
},
}
</script>
<style lang="scss" scoped>
.doc-msg {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
width: 100%;
background-color: var(--white);
}
.doc-err {
cursor: pointer;
.err-message {
color: var(--danger);
}
}
</style>
<style lang="scss">
// Style not scoped, since pages are manually rendered
.pdf-preview {
text-align: center;
width: 100%;
height: auto;
&.inline {
cursor: zoom-in;
canvas {
width: 100%;
height: auto;
}
}
canvas {
margin-bottom: 10px;
width: 80%;
height: auto;
&: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
this.page = page
this.loading = loading
this.loaded = loaded
this.rendered = rendered
this.failed = failed
this.node = node
}
}
/**
* Represents the document source
*/
export class Document {
constructor (params) {
this.merge(params)
}
merge ({ src, pdf, loading, loaded, failed, scale }) {
this.src = src
this.pdf = pdf
this.loading = loading
this.loaded = loaded
this.failed = failed
this.scale = 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,47 @@
<script lang="js">
export default {
props: {
labels: {
type: Object,
default: () => ({}),
},
src: {
required: true,
type: [String, Object],
default: undefined,
},
mime: {
type: String,
default: undefined,
},
meta: {
type: Object,
default: () => ({}),
},
name: {
type: String,
default: null,
},
previewStyle: {
type: Object,
default: () => ({}),
},
previewClass: {
type: Array,
default: () => [],
},
inline: {
type: Boolean,
default: false,
},
},
}
</script>

View File

@@ -0,0 +1,4 @@
export { default as InlineIMG } from './IMG/InlineIMG.vue'
export { default as LightboxIMG } from './IMG/LightboxIMG.vue'
export { default as PDF } from './PDF/PDF.vue'
export { default as NoPreview } from './NoPreview.vue'

View File

@@ -0,0 +1,3 @@
export { default as CPreviewLightbox } from './CPreviewLightbox.vue'
export { default as CPreviewInline } from './CPreviewInline.vue'
export { canPreview, getExtensionIconType } from './common/index.js'

View File

@@ -0,0 +1,39 @@
<template>
<div
v-if="tooltip"
v-b-tooltip.noninteractive.hover.right="{ variant: tooltipVariant, title: tooltip, boundary: 'body' }"
class="d-flex align-items-center ml-1"
>
<font-awesome-icon
:icon="icon"
:class="iconClass"
/>
</div>
</template>
<script>
export default {
props: {
icon: {
type: Array,
default: () => ['far', 'question-circle'],
},
iconClass: {
type: String,
default: 'text-primary',
},
tooltip: {
type: String,
default: '',
},
tooltipVariant: {
type: String,
default: 'dark',
},
},
}
</script>

View File

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

View File

@@ -0,0 +1,131 @@
export {
CExtendSession,
} from './auth'
export {
CCorredorManualButtons,
} from './corredor'
export { default as CUploader } from './uploader/CUploader.vue'
export {
CPreviewInline,
CPreviewLightbox,
canPreview,
getExtensionIconType,
} from './filePreview'
export {
CInputConfirm,
CInputProcessing,
CInputDateTime,
CInputPresort,
CInputSearch,
CRichTextInput,
CInputCheckbox,
CInputColorPicker,
CAceEditor,
CInputExpression,
CButtonSubmit,
CInputSelect,
CInputRole,
CInputUser,
CInputUserGroup,
CUserLabel,
} 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 {
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 {
CProgress,
} from './progress'
export {
CToolbar,
} from './toolbar'
export {
CHint,
} from './hint'
export {
CMap,
} from './map'
export {
CFormTableWrapper,
} from './wrapper'
export {
CWebcam,
} from './webcam'
export {
CNotificationButton,
CNotificationSidebar,
} from './notifications'
export {
CTopbarSearch,
CSearchButton,
RecordItem,
ItemGroup,
} from './search'
export { default as C3 } from './c3'
export { default as CEmojiPicker } from './CEmojiPicker.vue'
export { emojis as emojiData } from '@tiptap/extension-emoji'

View File

@@ -0,0 +1,560 @@
<template>
<div
ref="container"
class="position-relative ace-editor-wrapper"
:class="{ 'resizable': resizable }"
:style="containerStyle"
>
<ace-editor
ref="aceeditor"
v-model="editorValue"
:lang="lang"
:mode="lang"
theme="chrome"
width="100%"
:height="effectiveHeight"
:class="{ 'border-0 rounded-0': !border }"
v-on="$listeners"
@init="editorInit"
/>
<b-button
v-if="showPopout"
variant="link"
class="popout position-absolute px-2 py-1 mr-3"
@click="$emit('open')"
>
<font-awesome-icon
:icon="['fas', 'expand-alt']"
/>
</b-button>
</div>
</template>
<script>
import AceEditor from 'vue2-ace-editor'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faExpandAlt } from '@fortawesome/free-solid-svg-icons'
library.add(faExpandAlt)
export default {
components: {
AceEditor,
},
props: {
value: {
type: String,
default: '',
},
lang: {
type: String,
default: 'text',
},
minHeight: {
type: String,
default: '2.35rem',
},
showLineNumbers: {
type: Boolean,
default: false,
},
fontSize: {
type: String,
default: '14px',
},
border: {
type: Boolean,
default: true,
},
showPopout: {
type: Boolean,
default: false,
},
readOnly: {
type: Boolean,
default: false,
},
autoComplete: {
type: Boolean,
default: false,
},
highlightActiveLine: {
type: Boolean,
default: false,
},
showPrintMargin: {
type: Boolean,
default: false,
},
autoCompleteSuggestions: {
type: [Array, Object],
default: () => ([]),
},
initExpressions: {
type: Boolean,
required: false,
},
fontFamily: {
type: String,
default: '',
},
placeholder: {
type: String,
default: '',
},
resizable: {
type: Boolean,
default: false,
},
},
data () {
return {
resizeObserver: null,
manualHeight: null,
contentHeight: null,
programmaticUpdate: false,
resizeTimeout: null,
}
},
computed: {
editorValue: {
get () {
return this.value
},
set (value = '') {
this.$emit('update:value', value)
},
},
containerStyle () {
if (this.resizable) {
return {
minHeight: this.minHeight,
height: this.effectiveHeight,
}
}
return {}
},
effectiveHeight () {
if (this.resizable) {
// Use the larger of manual height, content height, or minimum height
const manualHeightPx = this.manualHeight ? this.parseHeight(this.manualHeight) : 0
const contentHeightPx = this.contentHeight ? this.parseHeight(this.contentHeight) : 0
const minHeightPx = this.parseHeight(this.minHeight)
const maxHeight = Math.max(manualHeightPx, contentHeightPx, minHeightPx)
return `${maxHeight}px`
}
return this.minHeight
},
},
watch: {
value () {
if (this.resizable && this.$refs.aceeditor && this.$refs.aceeditor.editor) {
this.$nextTick(() => {
this.calculateContentHeight(this.$refs.aceeditor.editor)
})
}
},
},
mounted () {
if (this.resizable) {
this.setupResizeObserver()
}
},
beforeDestroy () {
if (this.resizeObserver) {
this.resizeObserver.disconnect()
}
if (this.resizeTimeout) {
clearTimeout(this.resizeTimeout)
}
},
methods: {
parseHeight (height) {
if (typeof height === 'number') return height
if (typeof height === 'string') {
// Remove 'px' and parse to number
return parseFloat(height.replace('px', '').replace('rem', '')) * (height.includes('rem') ? 16 : 1)
}
return 0
},
calculateContentHeight (editor) {
if (!editor) return
const session = editor.session
const lineCount = session.getLength()
const lineHeight = editor.renderer.lineHeight || 16
const padding = 14 // Top and bottom padding
const contentHeight = (lineCount * lineHeight) + padding
// Parse minHeight to ensure we don't go below it
const minHeightPx = this.parseHeight(this.minHeight)
const finalHeight = Math.max(contentHeight, minHeightPx)
// Set flag to prevent ResizeObserver from reacting to this change
this.programmaticUpdate = true
this.contentHeight = `${finalHeight}px`
// Clear the flag after a short delay
this.$nextTick(() => {
setTimeout(() => {
this.programmaticUpdate = false
}, 50)
})
},
setupResizeObserver () {
let lastHeight = null
let isInitialized = false
this.resizeObserver = new ResizeObserver((entries) => {
// Skip if we're in the middle of a programmatic update
if (this.programmaticUpdate) {
return
}
for (const entry of entries) {
const newHeight = entry.contentRect.height
// Skip the first observation (initialization)
if (!isInitialized) {
lastHeight = newHeight
isInitialized = true
return
}
// Only update if height actually changed significantly (more than 2px to avoid rounding issues)
if (lastHeight !== null && Math.abs(lastHeight - newHeight) > 2) {
// Clear any pending timeout
if (this.resizeTimeout) {
clearTimeout(this.resizeTimeout)
}
// Debounce the update slightly to prevent flickering
this.resizeTimeout = setTimeout(() => {
this.manualHeight = `${newHeight}px`
this.updateEditorSize()
lastHeight = newHeight
}, 10)
}
}
})
if (this.$refs.container) {
this.resizeObserver.observe(this.$refs.container)
}
},
updateEditorSize () {
// Force ace editor to resize
this.$nextTick(() => {
if (this.$refs.aceeditor && this.$refs.aceeditor.editor) {
const editor = this.$refs.aceeditor.editor
editor.resize()
}
})
},
editorInit (editor) {
import('brace/mode/text')
import('brace/mode/html')
import('brace/mode/css')
import('brace/mode/scss')
import('brace/mode/json')
import('brace/mode/javascript')
import('brace/snippets/text')
import('brace/snippets/html')
import('brace/snippets/css')
import('brace/snippets/scss')
import('brace/snippets/json')
import('brace/snippets/javascript')
import('brace/theme/chrome')
import('brace/ext/language_tools')
import('brace/ext/emmet')
editor.setOptions({
tabSize: 2,
fontSize: this.fontSize,
wrap: true,
indentedSoftWrap: false,
showPrintMargin: this.showPrintMargin,
showLineNumbers: this.showLineNumbers,
showGutter: this.showLineNumbers,
displayIndentGuides: this.lang !== 'text',
useWorker: false,
readOnly: this.readOnly,
highlightActiveLine: this.highlightActiveLine,
cursorStyle: 'smooth',
...(this.autoComplete && {
enableBasicAutocompletion: true,
enableLiveAutocompletion: true,
enableSnippets: true,
enableEmmet: true,
}),
...(this.fontFamily && { fontFamily: this.fontFamily }),
...(this.fontSize && { fontSize: this.fontSize }),
})
editor.on('input', this.updatePlaceholder)
this.updatePlaceholder(undefined, editor)
// Calculate initial content height and listen for changes if resizable
if (this.resizable) {
// Calculate initial height
this.$nextTick(() => {
this.calculateContentHeight(editor)
})
// Recalculate on content change
editor.session.on('change', () => {
this.calculateContentHeight(editor)
})
}
if (this.initExpressions) {
this.processExpressionAutoComplete(editor)
} else if (this.autoComplete) {
const staticWordCompleter = {
getCompletions: (editor, session, pos, prefix, callback) => {
const autoCompleteSuggestions = this.autoCompleteSuggestions
callback(
null,
autoCompleteSuggestions.map(({ caption, value, meta }) => ({
caption,
value,
meta,
})),
)
},
}
editor.completers.push(staticWordCompleter)
}
},
updatePlaceholder (_, editor) {
if (!this.placeholder) return
const shouldShow = !editor.session.getValue().length
let node = editor.renderer.emptyMessageNode
if (!shouldShow && node) {
editor.renderer.scroller.removeChild(editor.renderer.emptyMessageNode)
editor.renderer.emptyMessageNode = null
} else if (shouldShow && !node) {
node = editor.renderer.emptyMessageNode = document.createElement('div')
node.textContent = this.placeholder
node.className = 'ace_placeholder'
node.style.padding = '7px 10px'
node.style.position = 'absolute'
node.style.zIndex = 9
node.style.opacity = 0.5
editor.renderer.scroller.appendChild(node)
}
},
processExpressionAutoComplete (editor) {
// Extract text context from current cursor position
const getTextContext = (pos) => {
const session = editor.session
const line = session.getLine(pos.row)
const lastSpaceIndex = Math.max(0, line.lastIndexOf(' '))
const textAfterSpace = line.slice(lastSpaceIndex, pos.column).trim()
const lastDotIndex = textAfterSpace.lastIndexOf('.')
const searchTextForCaption = lastDotIndex >= 0 ? textAfterSpace.slice(lastDotIndex + 1) : textAfterSpace
return { line, textAfterSpace, searchTextForCaption }
}
// Check if suggestion matches search text
const matchesSuggestion = (suggestion, textAfterSpace, searchTextForCaption) => {
const suggestionValue = typeof suggestion === 'string' ? suggestion : suggestion.value
const suggestionCaption = typeof suggestion === 'string' ? suggestion : suggestion.caption
return suggestionValue.toLowerCase().startsWith(textAfterSpace.toLowerCase()) ||
suggestionCaption.toLowerCase().startsWith(searchTextForCaption.toLowerCase())
}
// Check and trigger autocomplete if there are matching suggestions
const checkAndTriggerAutocomplete = () => {
setTimeout(() => {
const pos = editor.getCursorPosition()
const { line, textAfterSpace, searchTextForCaption } = getTextContext(pos)
// Don't trigger after closing braces
const charBeforeCursor = pos.column > 0 ? line[pos.column - 1] : ''
if (charBeforeCursor === '}' || charBeforeCursor === ')' || textAfterSpace.length === 0) {
return
}
const context = this.getContext(editor, editor.session, pos)
const suggestions = this.getSuggestionsForContext(context)
const hasMatches = suggestions.some(s => matchesSuggestion(s, textAfterSpace, searchTextForCaption))
if (hasMatches) {
editor.execCommand('startAutocomplete')
}
}, 10)
}
const staticWordCompleter = {
identifierRegexps: [/[${\w]+/],
getCompletions: (editor, session, pos, prefix, callback) => {
const context = this.getContext(editor, session, pos)
const suggestions = this.getSuggestionsForContext(context)
const { textAfterSpace, searchTextForCaption } = getTextContext(pos)
const filteredSuggestions = suggestions
.filter(s => matchesSuggestion(s, textAfterSpace, searchTextForCaption))
.map(suggestion => {
const caption = typeof suggestion === 'string' ? suggestion : suggestion.caption
const value = typeof suggestion === 'string' ? suggestion : suggestion.value
const captionMatch = caption.toLowerCase().indexOf(searchTextForCaption.toLowerCase())
return {
caption,
value,
score: captionMatch === 0 ? 10000 : 1000,
meta: 'variable',
completer: {
insertMatch: (insertEditor, data) => {
insertEditor.jumpToMatching()
const line = session.getLine(pos.row)
const spaceIndex = line.lastIndexOf(' ')
const startCol = spaceIndex > 0 ? spaceIndex + 1 : 0
insertEditor.session.replace({
start: { row: pos.row, column: startCol },
end: { row: pos.row, column: pos.column },
}, data.value)
checkAndTriggerAutocomplete()
},
},
}
})
callback(null, filteredSuggestions)
},
}
editor.completers = [staticWordCompleter]
// Only trigger autocomplete automatically when there's a partial match
editor.commands.on('afterExec', (e) => {
if (['insertstring', 'Return'].includes(e.command.name) || /^[\w.($]$/.test(e.args)) {
checkAndTriggerAutocomplete()
}
})
editor.renderer.setScrollMargin(7, 7)
editor.renderer.setPadding(10)
},
getContext (editor, session, pos) {
const line = session.getLine(pos.row)
const lastSpaceIndex = Math.max(0, line.lastIndexOf(' '))
const textBeforeCursor = line.slice(lastSpaceIndex, pos.column).trim()
return textBeforeCursor.split('.').slice(0, -1).join('.')
},
getSuggestionsForContext (context) {
return this.autoCompleteSuggestions[context] || []
},
},
}
</script>
<style lang="scss" scoped>
.popout {
z-index: 7;
bottom: 0;
right: 0;
}
.ace-editor-wrapper {
&.resizable {
resize: vertical;
overflow: auto;
.ace_editor {
height: 100% !important;
}
}
}
</style>
<style lang="scss">
.ace_editor {
color: var(--black) !important;
background-color: var(--white) !important;
border-radius: 0.25rem;
border: 2px solid var(--extra-light);
font-size: initial !important;
.ace_gutter {
background-color: var(--light) !important;
color: var(--black) !important;
.ace_gutter-active-line {
background-color: var(--extra-light) !important;
}
}
.ace_hidden-cursors {
.ace_cursor {
color: var(--white) !important;
}
}
.ace_cursor {
color: var(--black) !important;
}
}
.ace_autocomplete {
border: none !important;
.ace_active-line {
background-color: var(--extra-light) !important;
}
.ace_line-hover {
border: none !important;
background-color: var(--extra-light) !important;
}
.ace_completion-highlight {
color: var(--primary) !important;
}
}
</style>

View File

@@ -0,0 +1,69 @@
<template>
<div
class="d-flex align-items-center"
>
<span
class="mb-0 inline-block text-primary"
:class="offClass"
>
{{ labels.off }}
</span>
<b-form-checkbox
v-bind="$attrs"
:checked="value"
:value="!invert"
:unchecked-value="invert"
v-on="$listeners"
>
<slot />
</b-form-checkbox>
<span
class="mb-0 inline-block strong text-primary"
:class="onClass"
>
{{ labels.on }}
</span>
</div>
</template>
<script>
export default {
props: {
value: {
type: Boolean,
},
labels: {
type: Object,
default: () => ({}),
},
invert: {
type: Boolean,
default: false,
},
},
computed: {
offClass () {
const value = this.invert ? !this.value : this.value
return {
'text-muted': value,
'font-weight-bold': !value,
'mr-2': !!this.labels.off,
}
},
onClass () {
const value = this.invert ? !this.value : this.value
return {
'text-muted': !value,
'font-weight-bold': value,
'ml-1': !!this.labels.on,
}
},
},
}
</script>

View File

@@ -0,0 +1,320 @@
<template>
<div>
<div class="d-flex align-items-center">
<b-button
:style="`color: ${value}; fill: ${value};`"
class="p-0 rounded-circle bg-white border-white shadow-none"
@click="toggleMenu"
>
<svg
viewBox="0 0 32 32"
:style="{ width: width, height: height }"
class="border border-light rounded-circle"
>
<pattern
id="checkerboard"
width="12"
height="12"
patternUnits="userSpaceOnUse"
fill="FFF"
>
<rect
fill="#7080707f"
x="0"
width="6"
height="6"
y="0"
/>
<rect
fill="#7080707f"
x="6"
width="6"
height="6"
y="6"
/>
</pattern>
<circle
cx="16"
cy="16"
r="16"
fill="url(#checkerboard)"
/>
<circle
cx="16"
cy="16"
r="16"
/>
</svg>
</b-button>
<span
v-if="showText"
class="ml-2"
>
{{ value }}
</span>
</div>
<b-modal
:visible="showModal"
:title="translations.modalTitle"
centered
size="md"
:hide-header="!Boolean(translations.modalTitle)"
body-class="p-0"
no-fade
@hide="closeMenu"
>
<chrome
:value="currentColor"
class="w-100 shadow-none"
@input="updateColor"
/>
<div
v-if="themes.length > 0"
class="d-flex flex-column border-top p-3 gap-2"
>
<b-form-group
v-for="theme in themes"
:key="theme.id"
:label="translations[theme.id]"
label-class="text-primary"
class="mb-0"
>
<div
class="d-flex flex-wrap border"
>
<b-button
v-for="variable in themeVariables"
:key="variable.key"
v-b-tooltip.noninteractive.hover="{ title: variable.label, boundary: 'body' }"
class="swatch flex-grow-1 rounded-0"
:style="{ backgroundColor: theme.values[variable.key], borderColor: theme.values[variable.key] }"
@click="setColor(theme.values[variable.key])"
/>
</div>
</b-form-group>
</div>
<template #modal-footer>
<b-button
v-if="defaultValue"
variant="light"
@click="setColor()"
>
{{ translations.defaultBtnLabel || 'Default' }}
</b-button>
<slot name="footer" />
<b-button
variant="outline-light"
class="ml-auto text-primary border-0"
@click="closeMenu"
>
{{ translations.cancelBtnLabel || 'Cancel' }}
</b-button>
<b-button
variant="primary"
@click="saveColor"
>
{{ translations.saveBtnLabel || 'Save' }}
</b-button>
</template>
</b-modal>
</div>
</template>
<script>
import { Chrome } from 'vue-color'
import { debounce } from 'lodash'
export default {
name: 'CInputColorPicker',
components: {
Chrome,
},
props: {
value: {
type: String,
default: '#000000FF',
},
defaultValue: {
type: String,
default: '',
},
translations: {
type: Object,
default: () => ({}),
},
width: {
type: String,
default: '32px',
},
height: {
type: String,
default: '32px',
},
showText: {
type: Boolean,
default: true,
},
themeSettings: {
type: Array,
default: () => [],
},
themeVariables: {
type: Array,
default: () => [
{
key: 'white',
label: 'White',
},
{
key: 'black',
label: 'Black',
},
{
key: 'primary',
label: 'Primary',
},
{
key: 'secondary',
label: 'Secondary',
},
{
key: 'success',
label: 'Success',
},
{
key: 'warning',
label: 'Warning',
},
{
key: 'danger',
label: 'Danger',
},
{
key: 'light',
label: 'Light',
},
{
key: 'extra-light',
label: 'Extra light',
},
],
},
},
data () {
return {
showModal: false,
currentColor: '',
}
},
computed: {
themes () {
return this.themeSettings
.filter((theme) => theme.id !== 'general') // remove general theme
.map((theme) => {
return {
id: theme.id,
values: JSON.parse(theme.values),
}
})
},
},
watch: {
value: {
immediate: true,
handler (value) {
this.currentColor = value
if (!value && this.defaultValue) {
this.$emit('input', this.defaultValue)
}
},
},
},
methods: {
updateColor: debounce(function ({ hex8 = '' }) {
this.currentColor = hex8
}, 300),
setColor (defaultColor = this.defaultValue) {
this.currentColor = defaultColor
},
saveColor () {
this.$emit('input', this.currentColor)
this.closeMenu()
},
toggleMenu () {
if (this.showModal) {
this.closeMenu()
} else {
this.openMenu()
}
},
openMenu () {
this.showModal = true
},
closeMenu () {
this.showModal = false
},
},
}
</script>
<style lang="scss" scoped>
.swatch {
height: 58px;
min-width: 50px;
}
</style>
<style lang="scss">
.vc-chrome {
font-family: var(--font-medium) !important;
.vc-chrome-body {
background: var(--white) !important;
.vc-input__input {
color: var(--black) !important;
background-color: var(--white) !important;
}
.vc-input__label {
color: var(--black) !important;
}
.vc-chrome-toggle-btn {
path {
fill: var(--black) !important;
}
.vc-chrome-toggle-icon-highlight {
background: var(--light) !important;
}
}
}
}
</style>

View File

@@ -0,0 +1,181 @@
<template>
<div class="d-inline-flex gap-1">
<b-button
v-if="!inConfirmation"
v-b-tooltip.noninteractive.hover="{ title: tooltip, boundary: 'body' }"
:data-test-id="dataTestId"
:variant="variant"
:size="size"
:disabled="disabled || processing"
:class="`${buttonClass} ${borderless ? 'border-0' : ''} flex-fill`"
:style="buttonStyle"
@click.stop.prevent="onPrompt"
>
<b-spinner
v-if="processing"
data-test-id="spinner"
class="align-middle"
small
/>
<template v-else>
<font-awesome-icon
v-if="showIcon || !text"
:icon="icon"
:class="iconClass"
/>
<span
v-if="text"
:class="textClass"
>
{{ text }}
</span>
</template>
</b-button>
<template v-else>
<b-button
:data-test-id="`${dataTestId}-confirm`"
:variant="variantOk"
:size="sizeConfirm"
:disabled="okDisabled"
:class="[ borderless && 'border-0', 'flex-fill' ]"
@blur.prevent="onCancel()"
@click.prevent.stop="onConfirmation()"
>
<slot name="yes">
<font-awesome-icon
data-test-id="confirm"
:icon="['fas', 'check']"
/>
</slot>
</b-button>
<b-button
:data-test-id="`${dataTestId}-cancel`"
:variant="variantCancel"
:size="sizeConfirm"
:disabled="cancelDisabled"
:class="[ borderless && 'border-0', 'flex-fill' ]"
@click.prevent.stop="onCancel()"
>
<slot name="no">
<font-awesome-icon
data-test-id="reject"
:icon="['fas', 'times']"
/>
</slot>
</b-button>
</template>
</div>
</template>
<script lang="js">
export default {
props: {
disabled: Boolean,
okDisabled: Boolean,
cancelDisabled: Boolean,
noPrompt: Boolean,
processing: Boolean,
showIcon: Boolean,
icon: {
type: Array,
default: () => ['far', 'trash-alt'],
},
buttonClass: {
type: String,
default: '',
},
buttonStyle: {
type: Object,
default: () => ({}),
},
iconClass: {
type: String,
default: '',
},
textClass: {
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: 'light',
},
sizeConfirm: {
type: String,
default: 'sm',
},
tooltip: {
type: String,
default: '',
},
text: {
type: String,
default: '',
},
dataTestId: {
type: String,
default: 'button-delete',
},
},
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,157 @@
<template>
<div class="c-input-date-time d-flex flex-wrap w-100 gap-1">
<b-form-datepicker
v-if="!noDate"
v-model="date"
data-test-id="picker-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"
:locale="browserLocale"
:start-weekday="weekStartDay"
label-help=""
today-variant="info"
selected-variant="extra-light"
boundary="window"
hide-header
reset-button
today-button
show-decade-nav
class="h-100 overflow-hidden"
/>
<b-form-timepicker
v-if="!noTime"
v-model="time"
data-test-id="picker-time"
:placeholder="labels.none"
:label-reset-button="labels.clear"
:label-now-button="labels.now"
:locale="browserLocale"
boundary="window"
hide-header
no-close-button
reset-button
now-button
class="h-100 overflow-hidden"
/>
<slot />
</div>
</template>
<script lang="js">
import { getDate, setDate, getTime, setTime } from './lib/index.ts'
import { shared } from '@cortezaproject/corteza-js'
const { getWeekStartDay } = shared
export default {
props: {
value: {
type: [String, Date],
required: false,
default: '',
},
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
},
browserLocale () {
return navigator.language || navigator.languages?.[0] || 'en-US'
},
weekStartDay () {
return getWeekStartDay(this.browserLocale)
},
},
}
</script>
<style lang="scss">
.c-input-date-time {
min-width: 120px;
.btn {
padding: 0.25rem 0.5rem;
}
label {
font-family: var(--font-regular);
color: var(--black) !important;
}
.b-form-datepicker, .b-form-timepicker {
flex: 1 0 130px;
}
.b-calendar-inner {
background-color: var(--white);
.b-calendar-grid-body {
height: 14rem;
display: flex;
flex-direction: column;
justify-content: center;
}
}
}
</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,59 @@
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
}
if (moment(value, 'YYYY-MM-DDTHH:mm:ssZ', true).isValid()) {
return moment.utc(value).local().format('YYYY-MM-DD')
}
return value
}
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,168 @@
<template>
<div
class="position-relative"
>
<c-ace-editor
v-model="editorValue"
auto-complete
init-expressions
:auto-complete-suggestions="autoCompleteSuggestions"
v-bind="{ ...$attrs, ...$props }"
v-on="$listeners"
/>
</div>
</template>
<script>
import CAceEditor from './CAceEditor.vue'
export default {
components: {
CAceEditor,
},
props: {
value: {
type: String,
default: '',
},
lang: {
type: String,
default: 'text',
},
minHeight: {
type: String,
default: '2.35rem',
},
showLineNumbers: {
type: Boolean,
default: false,
},
fontSize: {
type: String,
default: '14px',
},
border: {
type: Boolean,
default: true,
},
showPopout: {
type: Boolean,
default: false,
},
readOnly: {
type: Boolean,
default: false,
},
highlightActiveLine: {
type: Boolean,
default: false,
},
showPrintMargin: {
type: Boolean,
default: false,
},
suggestionParams: {
type: Array,
default: () => [],
},
fontFamily: {
type: String,
default: '',
},
placeholder: {
type: String,
default: '',
},
resizable: {
type: Boolean,
default: true,
},
},
computed: {
editorValue: {
get () {
return this.value
},
set (value = '') {
this.$emit('update:value', value)
},
},
autoCompleteSuggestions () {
return this.getRecordBasedSuggestions(this.suggestionParams)
},
},
methods: {
getRecordBasedSuggestions (params = []) {
const result = {}
function addSuggestion (key, caption, value) {
if (!result[key]) result[key] = []
result[key].push({ caption, value })
}
function processProperties (prefix, properties, interpolate) {
(properties || []).forEach((prop) => {
if (typeof prop === 'string') {
const value = prefix + '.' + prop + (interpolate ? '}' : '')
addSuggestion(prefix, prop, value)
} else {
const nestedPrefix = prefix + '.' + prop.value + '.'
addSuggestion(prefix, prop.value, nestedPrefix)
if (prop.properties) {
(prop.properties || []).forEach((nestedProp) => {
const nestedValue = nestedPrefix + nestedProp + (interpolate ? '}' : '')
addSuggestion(prefix + '.' + prop.value, nestedProp, nestedValue)
})
}
}
})
}
(params || []).forEach((p) => {
if (typeof p === 'string') {
addSuggestion('', '', p)
} else {
const { interpolate = false, properties = [], value, root = true } = p
const prefix = interpolate ? '${' : ''
const suffix = interpolate && !properties.length ? '}' : ''
const prefixAsValue = prefix + value + suffix + (properties.length > 0 ? '.' : '')
if (root) {
addSuggestion('', '', prefixAsValue)
}
if (interpolate) {
addSuggestion('$', prefixAsValue.slice(1), prefixAsValue)
addSuggestion('${', prefixAsValue.slice(2), prefixAsValue)
}
if (properties.length) {
processProperties(prefix + value, properties, interpolate)
}
}
})
return result
},
},
}
</script>

View File

@@ -0,0 +1,220 @@
<template>
<c-form-table-wrapper
:labels="{ addButton: labels.addButton }"
:hide-add-button="textInput"
@add-item="items.push({ field: undefined, descending: false })"
>
<b-form-group
v-if="!textInput"
:label="labels.title"
label-class="text-primary"
>
<b-table-simple
borderless
small
responsive
class="mb-0"
>
<draggable
:list.sync="items"
group="sort"
handle=".grab"
tag="tbody"
>
<tr
v-for="(column, index) in items"
:key="index"
>
<td
class="grab text-center align-middle"
style="width: 40px;"
>
<font-awesome-icon
:icon="['fas', 'bars']"
class="text-secondary"
/>
</td>
<td
class="align-middle"
style="min-width: 250px;"
>
<c-input-select
v-model="column.field"
:options="availableFields"
:reduce="o => o.name"
:placeholder="labels.none"
class="rounded"
/>
</td>
<td
class="text-center align-middle"
style="min-width: 200px;"
>
<b-form-radio-group
v-model="column.descending"
:options="sortDirections"
buttons
button-variant="outline-primary"
class="bg-white"
/>
</td>
<td
class="align-middle text-right"
style="min-width: 80px; width: 80px;"
>
<c-input-confirm
show-icon
@confirmed="items.splice(index, 1)"
/>
</td>
</tr>
</draggable>
</b-table-simple>
</b-form-group>
<div
v-else
>
<b-form-textarea
v-model="presortValue"
:placeholder="labels.placeholder"
/>
<b-form-text>
{{ labels.footnote }}
</b-form-text>
</div>
<div
v-if="allowTextInput"
class="d-flex align-items-center mt-1"
>
<b-button
variant="link"
size="sm"
class="text-decoration-none ml-auto"
@click="textInput = !textInput"
>
{{ labels.toggleInput }}
</b-button>
</div>
</c-form-table-wrapper>
</template>
<script>
import Draggable from 'vuedraggable'
import CInputSelect from './CInputSelect.vue'
import CFormTableWrapper from '../wrapper/CFormTableWrapper.vue'
export default {
components: {
Draggable,
CInputSelect,
CFormTableWrapper,
},
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 },
]
},
availableFields () {
return this.fields.map(f => ({ ...f, label: `${f.label} (${f.name})` }))
},
},
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(','))
}
},
},
},
beforeUnmount () {
this.setDefaultValues()
},
methods: {
setDefaultValues () {
this.items = []
this.textInput = false
},
},
}
</script>

View File

@@ -0,0 +1,40 @@
<template>
<b-button
:variant="variant"
:disabled="disabled || processing"
:size="size"
class="d-inline-flex align-items-center justify-content-center"
v-on="$listeners"
>
<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,130 @@
<template>
<c-input-select
ref="roleSelect"
:value="value"
:options="roles"
:placeholder="placeholder"
:get-option-key="r => r.roleID"
:get-option-label="r => getRoleLabel(r)"
:filterable="false"
:selectable="selectable"
:multiple="multiple"
:clearable="clearable"
:loading="loading"
@search="search"
@input="updateValue"
/>
</template>
<script>
import { debounce } from 'lodash'
import axios from 'axios'
export default {
props: {
value: {
type: [Array, String, Object],
default: '',
},
visible: {
type: Function,
default: () => true,
},
placeholder: {
type: String,
default: 'Start typing to search for roles',
},
multiple: {
type: Boolean,
default: false,
},
clearOnSelect: {
type: Boolean,
default: false,
},
selectable: {
type: Function,
default: () => true,
},
clearable: {
type: Boolean,
default: true,
},
preselect: {
type: Boolean,
default: false,
},
},
data () {
return {
loading: false,
cancelRequest: null,
roles: [],
filter: '',
}
},
mounted () {
this.fetchRoles(this.preselect)
},
methods: {
fetchRoles (preselect = false) {
this.loading = true
if (this.cancelRequest) {
this.cancelRequest()
this.cancelRequest = null
}
const { response, cancel } = this.$SystemAPI.roleListCancellable({ query: this.filter, limit: 20 })
this.cancelRequest = cancel
return Promise.all([response(), new Promise(resolve => setTimeout(resolve, 300))])
.then(([{ set }]) => {
this.roles = set.filter(this.visible)
if (preselect && (!this.value || !this.value.length)) {
this.updateValue(this.roles[0])
}
this.loading = false
})
.catch((e) => {
if (axios.isCancel(e)) return
this.loading = false
throw e
})
},
search: debounce(function (query = '') {
if (query !== this.filter) {
this.filter = query
}
this.fetchRoles()
}, 400),
updateValue (role) {
// reset role-select value for better value presentation
if (this.clearOnSelect && this.$refs.roleSelect) {
this.$refs.roleSelect._data._value = undefined
}
this.$emit('input', role)
},
getRoleLabel ({ name, handle, roleID }) {
return name || handle || roleID
},
},
}
</script>

View File

@@ -0,0 +1,219 @@
<template>
<div
style="min-width: 150px;"
:class="{ 'submittable': isSubmittable }"
class="c-input-search d-flex position-relative"
>
<b-input
ref="searchInput"
data-test-id="input-search"
:type="inputType"
name="search"
:value="localValue"
:debounce="debounce"
:disabled="disabled"
:placeholder="placeholder"
:autocomplete="autocomplete"
:size="size"
class="text-truncate"
@input="onInput"
@keyup.enter="submitQuery"
/>
<div
v-if="loading"
class="spinner d-inline-flex align-items-center p-3"
>
<b-spinner
small
variant="secondary"
/>
</div>
<b-button
v-else-if="clearable && localValue && !disabled"
variant="outline-extra-light"
class="clear-button d-inline-flex align-items-center rounded-0 p-3"
@click="onClear"
>
<font-awesome-icon
:icon="['fas', 'times']"
class="text-secondary"
/>
</b-button>
<b-button
v-if="showSubmittable"
:variant="isSubmittable ? 'outline-light' : 'link'"
:disabled="disabled"
:class="{ 'border-0 cursor-default': !isSubmittable }"
class="search-button d-inline-flex align-items-center rounded-0 border-light"
@[isSubmittable]="submitQuery"
>
<font-awesome-icon
:icon="['fas', 'search']"
class="align-middle text-primary"
/>
</b-button>
</div>
</template>
<script>
export default {
name: 'CInputSearch',
props: {
value: {
type: String,
default: '',
},
placeholder: {
type: String,
default: '',
},
size: {
type: String,
default: 'md',
},
disabled: {
type: Boolean,
},
clearable: {
type: Boolean,
default: true,
},
submittable: {
type: Boolean,
default: false,
},
autocomplete: {
type: String,
default: 'on',
},
debounce: {
type: Number,
default: 0,
},
loading: {
type: Boolean,
default: false,
},
},
data () {
return {
localValue: this.value,
}
},
computed: {
inputType () {
return this.clearable ? 'search' : 'text'
},
showSubmittable () {
return !this.localValue || this.showSubmittableAndClearable
},
isSubmittable () {
return this.submittable && !this.disabled ? 'click' : null
},
showSubmittableAndClearable () {
return this.clearable && this.submittable
},
},
watch: {
value (value) {
this.localValue = value
},
},
methods: {
onInput (value) {
this.localValue = value
this.$emit('input', value)
},
submitQuery () {
if (this.submittable) {
this.$emit('search', this.localValue)
}
},
onClear () {
this.localValue = ''
this.$emit('input', '')
this.$nextTick(() => {
this.$refs.searchInput.focus()
})
},
},
}
</script>
<style lang="scss" scoped>
input:focus::placeholder {
color: transparent;
}
.c-input-search {
.search-button {
position: absolute;
right: 2px;
top: 2px;
bottom: 2px;
z-index: 4;
border-left-width: 2px;
}
.clear-button,
.spinner {
position: absolute;
right: 1px;
top: 1px;
bottom: 1px;
z-index: 5;
outline: none !important;
box-shadow: none !important;
border: none !important;
background-color: transparent !important;
&:hover {
text-decoration: none;
}
}
&.submittable {
.clear-button,
.spinner {
right: 48px;
}
}
.form-control {
padding-right: 40px;
}
&.submittable .form-control {
padding-right: 85px;
}
::-webkit-search-cancel-button {
-webkit-appearance: none;
display: none;
}
}
.cursor-default {
cursor: default !important;
}
</style>

View File

@@ -0,0 +1,425 @@
<template>
<vue-select
v-bind="$attrs"
ref="vueSelect"
v-model="_value"
data-test-id="select"
:clearable="clearable"
:options="options"
:searchable="searchable"
:disabled="disabled"
:selectable="selectable"
:multiple="multiple"
:loading="loading"
:calculate-position="calculateDropdownPosition"
:append-to-body="appendToBody"
class="bg-white rounded"
:class="sizeClass"
@search="onSearch"
>
<template
v-for="(_, name) in $scopedSlots"
#[name]="data"
>
<slot
:name="name"
v-bind="data"
/>
</template>
<template
v-if="badge"
#option="option"
>
<span
class="badge badge-pill"
:style="getOptionStyle(option)"
>
{{ option.text }}
</span>
</template>
<template
v-if="!multiple && badge"
#selected-option="option"
>
<span
class="badge badge-pill"
:style="getOptionStyle(option)"
>
{{ option.text }}
</span>
</template>
<template
v-if="multiple && badge"
#selected-option-container="{ option, deselect }"
>
<span
class="d-flex align-items-center badge badge-pill mx-1 mt-1 w-auto"
:style="getOptionStyle(option)"
>
{{ option.text }}
<font-awesome-icon
:icon="['fas', 'times']"
class="pointer ml-2"
@click="deselect(option)"
/>
</span>
</template>
</vue-select>
</template>
<script>
import { VueSelect } from 'vue-select'
import { createPopper } from '@popperjs/core'
import 'vue-select/dist/vue-select.css'
export default {
name: 'CInputSelect',
components: {
VueSelect,
},
props: {
value: {
type: [String, Array, Object],
default: () => '',
},
options: {
type: Array,
default: () => {
return []
},
},
clearable: {
type: Boolean,
default: true,
},
searchable: {
type: Boolean,
default: true,
},
appendToBody: {
type: Boolean,
default: true,
},
defaultValue: {
type: [String, Array, Object],
default: () => '',
},
size: {
type: String,
default: 'md',
},
disabled: {
type: Boolean,
default: false,
},
selectable: {
type: Function,
default: o => !o.disabled,
},
multiple: {
type: Boolean,
default: false,
},
loading: {
type: Boolean,
default: false,
},
badge: {
type: Boolean,
default: false,
},
},
data () {
return {
query: '',
}
},
computed: {
_value: {
get () {
const fallbackValue = this.multiple ? [] : ''
return !!this.defaultValue && (this.value === this.defaultValue) ? fallbackValue : this.value
},
set (v) {
this.$emit('input', !v ? this.defaultValue : v)
},
},
sizeClass () {
return this.size === 'sm' ? 'c-input-sm' : this.size === 'lg' ? 'c-input-lg' : ''
},
},
methods: {
calculateDropdownPosition (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
/**
* 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()
},
onSearch (query, loading) {
if (query !== this.query) {
this.query = query
}
this.$emit('search', query, loading)
},
getOptionStyle ({ style = {} } = {}) {
if (this.badge) {
style.fontSize = '0.9rem'
style.color = style.textColor || 'var(--dark)'
style.backgroundColor = style.backgroundColor || 'var(--extra-light)'
}
return style
},
},
}
</script>
<style lang="scss">
:root {
--vs-dropdown-bg: var(--white);
--vs-dropdown-option--active-bg: var(--light);
--vs-state-disabled-color: var(--secondary);
--vs-state-disabled-bg: var(--light);
--vs-colors--light: var(--black);
--vs-colors--dark: var(--black);
--vs-dropdown-option-color: var(--black);
--vs-dropdown-option--active-color: var(--black);
--vs-selected-bg: var(--extra-light);
--vs-search-input-color: var(--secondary);
--vs-search-input-bg: var(--white);
}
.v-select {
min-width: auto;
position: relative;
-ms-flex: 1 1 auto;
flex: 1 1 auto;
margin-bottom: 0;
font-size: .9rem !important;
font-family: var(--font-regular);
.vs__selected-options {
// do not allow growing
width: 0;
padding: 0;
}
.vs__selected {
display: flex;
white-space: nowrap;
text-overflow: ellipsis;
max-width: 100%;
overflow: hidden;
border: 0;
color: var(--black);
}
.vs__search {
font-size: .9rem;
border: 0;
padding: 0 2px;
padding-top: 0.375rem;
margin: 0;
}
&:not(.vs--open):not(.vs--loading) .vs__selected + .vs__search {
// force this to not use any space
// we still need it to be rendered for the focus
width: 0;
margin: 0;
border: none;
padding: 0;
height: 0;
}
.vs__dropdown-toggle {
min-height: calc(1.5em + 0.75rem + 4px);
padding: 0.375rem calc(0.75rem - 2px);
padding-top: 0 !important;
border-width: 2px;
border-color: var(--extra-light);
.vs__selected {
margin-top: 0.375rem;
}
.vs__actions {
padding-top: 0.375rem;
padding-right: 0;
}
}
.vs__clear,
.vs__open-indicator {
fill: var(--black);
display: inline-flex;
}
.vs__clear {
padding: 0;
border: 0;
background-color: transparent;
cursor: pointer;
margin-right: 8px
}
&.vs--single {
.vs__selected {
margin-left: 0;
margin-right: 0;
}
}
}
.vs--open {
.vs__dropdown-toggle {
border-color: var(--primary);
border-radius: 0.25rem !important;
}
}
.input-group > .v-select:not(:last-child) {
.vs__dropdown-toggle {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
}
.vs__spinner {
border: .7em solid var(--dark);
border-left-color: var(--white);
}
.vs__spinner, .vs__spinner::after {
width: 4em;
height: 4em;
}
.vs__dropdown-menu {
z-index: 1100;
.vs__dropdown-option {
&.vs__dropdown-option--selected {
background: var(--vs-dropdown-option--active-bg);
color: var(--vs-dropdown-option--active-color);
}
&.vs__dropdown-option--disabled {
cursor: var(--vs-state-disabled-cursor) !important;
opacity: 0.5;
}
&:active {
color: var(--white);
background-color: var(--primary);
}
}
}
.c-input-sm {
font-size: 0.7875rem !important;
.vs__search {
font-size: 0.7875rem;
padding-top: 0.25rem;
}
.vs__dropdown-toggle {
min-height: calc(1.5em + 0.5rem + 4px);
padding: 0.25rem calc(0.5rem - 2px);
border-radius: 0.2rem;
.vs__selected {
margin-top: 0.25rem;
}
.vs__actions {
padding-top: 0.25rem;
}
}
}
.c-input-lg {
font-size: 1.125rem !important;
.vs__search {
font-size: 1.125rem;
padding-top: .5rem;
}
.vs__dropdown-toggle {
min-height: calc(1.5em + 1rem + 4px);
padding: .5rem calc(1rem - 2px);
border-radius: 0.3rem;
.vs__selected {
margin-top: .5rem;
}
.vs__actions {
padding-top: .5rem;
}
}
}
</style>

View File

@@ -0,0 +1,160 @@
<template>
<c-input-select
ref="userSelect"
:value="user.value"
data-test-id="select-user"
:options="user.options"
:get-option-label="getOptionLabel"
:get-option-key="getOptionKey"
:placeholder="placeholder"
:loading="processing"
:filterable="false"
:clearable="clearable"
v-bind="$attrs"
@search="search"
@input="onUserUpdate"
>
<template #option="option">
<c-user-label :user="option" />
</template>
<template #selected-option="option">
<c-user-label :user="option" />
</template>
</c-input-select>
</template>
<script>
import { NoID } from '@cortezaproject/corteza-js'
import { debounce } from 'lodash'
import axios from 'axios'
import CUserLabel from './CUserLabel.vue'
export default {
name: 'CInputUser',
components: {
CUserLabel,
},
props: {
value: {
type: String,
default: null,
},
placeholder: {
type: String,
default: '',
},
clearable: {
type: Boolean,
default: false,
},
clearOnSelect: {
type: Boolean,
default: false,
},
},
data () {
return {
processing: false,
cancelRequest: null,
user: {
options: [],
value: undefined,
filter: {
query: null,
limit: 20,
},
},
}
},
created () {
this.fetchUsers().then(() => {
this.getUserByID(this.value)
})
},
methods: {
search: debounce(function (query) {
if (query !== this.user.filter.query) {
this.user.filter.query = query
}
this.fetchUsers()
}, 300),
fetchUsers () {
this.processing = true
if (this.cancelRequest) {
this.cancelRequest()
this.cancelRequest = null
}
const params = { ...this.user.filter }
if (params.query) {
params.suspended = 1
params.deleted = 1
}
const { response, cancel } = this.$SystemAPI.userListCancellable(params)
this.cancelRequest = cancel
return Promise.all([response(), new Promise(resolve => setTimeout(resolve, 300))])
.then(([{ set }]) => {
this.user.options = set.map(m => Object.freeze(m))
this.processing = false
})
.catch((e) => {
if (axios.isCancel(e)) return
this.processing = false
throw e
})
},
getUserByID (userID) {
if (!userID || userID === NoID) {
this.user.value = undefined
return
}
const user = this.user.options.find(o => o.userID === userID)
if (user) {
this.user.value = user
} else {
return this.$SystemAPI.userRead({ userID }).then(user => {
this.user.value = user
this.user.options.push(Object.freeze(user))
})
}
},
onUserUpdate (user) {
if (this.clearOnSelect && this.$refs.userSelect) {
this.$refs.userSelect._data._value = undefined
} else {
this.user.value = user
}
this.$emit('input', user.userID)
this.$emit('input-object', user)
},
getOptionKey ({ userID }) {
return userID
},
getOptionLabel ({ userID, email, name, username }) {
return name || username || email || `<@${userID}>`
},
},
}
</script>

View File

@@ -0,0 +1,122 @@
<template>
<c-input-select
v-model="userGroup.value"
data-test-id="select-user-group"
:options="userGroup.options"
:get-option-label="getOptionLabel"
:get-option-key="getOptionKey"
:placeholder="placeholder"
:loading="processing"
:filterable="false"
:clearable="false"
v-bind="$attrs"
@search="search"
@input="onUserGroupUpdate"
/>
</template>
<script>
import { NoID, system } from '@cortezaproject/corteza-js'
import { debounce } from 'lodash'
import axios from 'axios'
export default {
name: 'CInputUserGroup',
props: {
value: {
type: String,
default: null,
},
placeholder: {
type: String,
default: '',
},
},
data () {
return {
processing: false,
cancelRequest: null,
userGroup: {
options: [],
value: undefined,
filter: {
query: null,
limit: 20,
},
},
}
},
created () {
this.fetchUserGroups().then(() => {
this.getUserGroupByID(this.value)
})
},
methods: {
search: debounce(function (query) {
if (query !== this.userGroup.filter.query) {
this.userGroup.filter.query = query
this.userGroup.filter.page = 1
}
this.fetchUserGroups()
}, 300),
fetchUserGroups() {
this.processing = true
if (this.cancelRequest) {
this.cancelRequest()
this.cancelRequest = null
}
const { response, cancel } = this.$SystemAPI.userGroupListCancellable(this.userGroup.filter)
this.cancelRequest = cancel
return Promise.all([response(), new Promise(resolve => setTimeout(resolve, 300))])
.then(([{ set }]) => {
this.userGroup.options = set.map((m) => new system.UserGroup(m))
this.processing = false
})
.catch((e) => {
if (axios.isCancel(e)) return
this.processing = false
throw e
})
},
getUserGroupByID (userGroupID) {
if (!userGroupID || userGroupID === NoID) {
this.userGroup.value = this.userGroup.options.find(({ isRoot }) => !!isRoot)
this.$emit('input', this.userGroup.value?.userGroupID)
return
}
const userGroup = this.userGroup.options.find(o => o.userGroupID === userGroupID)
if (userGroup) {
this.userGroup.value = userGroup
}
},
onUserGroupUpdate ({ userGroupID }) {
this.$emit('input', userGroupID)
},
getOptionKey ({ userGroupID }) {
return userGroupID
},
getOptionLabel ({ handle, meta = {}, userGroupID }) {
return meta.short || handle || userGroupID
},
},
}
</script>

View File

@@ -0,0 +1,86 @@
<template>
<div>
<b-button
:id="`color-popover-${format.type}`"
variant="link"
class="text-dark font-weight-bold text-decoration-none mb-1"
@click.stop.prevent="showPicker"
>
<span
:style="{
backgroundColor: background ? selectedColor : 'transparent',
'border-bottom': background ? 'none' : `2px solid ${selectedColor}`,
}"
>
A
</span>
</b-button>
<c-input-color-picker
ref="picker"
class="d-none"
:value="selectedColor"
:default-value="getDefaultColor()"
:show-text="false"
:width="'0px'"
:height="'0px'"
@input="applyFromPicker"
/>
</div>
</template>
<script>
import CInputColorPicker from '../../../CInputColorPicker.vue'
import base from './base.vue'
export default {
name: 'TMarkColor',
components: {
CInputColorPicker,
},
extends: base,
props: {
background: {
type: Boolean,
default: false,
},
},
data () {
return {
selectedColor: this.getDefaultColor(),
}
},
methods: {
getComputedColor (cssVar) {
try {
const computedStyle = getComputedStyle(document.documentElement)
return computedStyle.getPropertyValue(cssVar).trim()
} catch (error) {
return null
}
},
showPicker () {
if (this.$refs.picker && this.$refs.picker.openMenu) {
this.$refs.picker.openMenu()
}
},
applyFromPicker (val) {
if (!val) return
this.selectedColor = val
this.onClick(this.format.type, { color: val })
},
getDefaultColor () {
return this.background ? this.getComputedColor('--white') : this.getComputedColor('--dark')
},
},
}
</script>

View File

@@ -0,0 +1,107 @@
<template>
<span>
<b-button
:id="popoverId"
variant="link"
class="text-dark font-weight-bold text-decoration-none"
>
<font-awesome-icon :icon="['far', 'face-smile']" />
</b-button>
<b-popover
:target="popoverId"
triggers="click blur"
placement="bottom"
container="body"
custom-class="emoji-picker-popover border-light"
@shown="onShown"
>
<c-emoji-picker
ref="picker"
:emojis="allEmojis"
:labels="labels.emojiPicker || {}"
:show-quick-reactions="false"
@select="onSelect"
/>
</b-popover>
</span>
</template>
<script>
// Use ~corteza-vue/ alias to avoid circular self-reference to dist
import CEmojiPicker from '~corteza-vue/components/CEmojiPicker.vue'
let popoverCounter = 0
export default {
name: 'TMarkEmoji',
components: {
CEmojiPicker,
},
props: {
editor: {
type: Object,
required: true,
},
format: {
type: Object,
default: () => ({}),
},
labels: {
type: Object,
default: () => ({}),
},
},
data () {
return {
popoverId: `emoji-picker-${++popoverCounter}`,
}
},
computed: {
allEmojis () {
return this.editor.storage?.emoji?.emojis || []
},
},
methods: {
onShown () {
this.$nextTick(() => {
if (this.$refs.picker) {
this.$refs.picker.reset()
}
})
},
onSelect (emoji) {
if (emoji && emoji.name) {
this.editor.chain().focus().insertContent({
type: 'emoji',
attrs: { name: emoji.name },
}).run()
}
this.$root.$emit('bv::hide::popover', this.popoverId)
},
},
}
</script>
<style lang="scss">
.emoji-picker-popover {
background: var(--white, #fff);
max-width: 300px;
.popover-body {
padding: 0;
}
.arrow::after {
border-bottom-color: var(--white, #fff);
}
}
</style>

View File

@@ -0,0 +1,30 @@
<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: 'TMarkItem',
extends: base,
}
</script>

View File

@@ -0,0 +1,114 @@
<template>
<div id="link-popover-container">
<b-button
id="link-popover"
variant="link"
class="text-dark font-weight-bold text-decoration-none"
@click="showPopover"
>
<span :class="activeClasses(format.attrs)">
<font-awesome-icon icon="link" />
</span>
</b-button>
<b-popover
v-if="currentValue"
ref="popover"
:show.sync="visible"
triggers="focus"
target="link-popover"
placement="auto"
container="link-popover-container"
custom-class="bg-white"
>
<b-input-group style="min-width: 250px;">
<b-form-input
v-model="attrs.href"
type="url"
autofocus
:placeholder="labels.urlPlaceholder"
@keydown.enter.prevent.stop="link"
@keydown.esc.prevent.stop="close"
/>
<b-input-group-append>
<b-button
variant="outline-success"
@click="link"
>
{{ labels.ok }}
</b-button>
</b-input-group-append>
</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: 'TMarkLink',
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,57 @@
<script>
/**
* Defines common props, methods between different toolbar item types
*/
export default {
props: {
editor: {
type: Object,
required: true,
default: () => ({}),
},
format: {
type: Object,
required: true,
default: () => ({}),
},
isActive: {
type: Object,
required: false,
default: () => ({}),
},
getMarkAttrs: {
type: Function,
required: false,
default: () => {},
},
currentValue: {
type: String,
default: '',
},
},
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) {
const isActive = this.editor.isActive(this.format.type, attrs)
if (isActive) {
return ['text-primary']
}
return undefined
},
},
}
</script>

View File

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

View File

@@ -0,0 +1,30 @@
<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: 'TNodeItem',
extends: base,
}
</script>

View File

@@ -0,0 +1,118 @@
<script>
import { nodeTypes } from '../../lib/formats'
/**
* Defines common props, methods between different toolbar item types
*/
export default {
props: {
editor: {
type: Object,
required: true,
default: () => ({}),
},
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.editor.schema.nodes[type]
const nAttrs = {}
const targetAttrs = (target && target.spec && target.spec.attrs) ? Object.keys(target.spec.attrs) : []
for (const a of targetAttrs) {
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.editor
const rtr = []
ed.state.doc.nodesBetween(
ed.state.selection.from,
ed.state.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-primary']
}
return undefined
},
},
}
</script>

View File

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

View File

@@ -0,0 +1,81 @@
<template>
<b-dropdown
menu-class="text-center bg-white"
variant="link"
boundary="window"
no-caret
>
<template #button-content>
<span class="text-dark font-weight-bold">
<span :class="{ 'text-primary': !!activeType && activeType !== 'left' }">
<font-awesome-icon
v-if="activeIcon"
:icon="activeIcon"
/>
<span v-else>
{{ format.label }}
</span>
</span>
</span>
</template>
<b-dropdown-item-button
v-for="v of format.variants"
:key="v.variant"
@click="$emit('click', { type: 'alignment', attrs: v.attrs })"
>
<font-awesome-icon
v-if="format.icon"
:icon="v.icon"
/>
<span v-else>
{{ v.label }}
</span>
</b-dropdown-item-button>
</b-dropdown>
</template>
<script>
import base from '../TNode/base.vue'
/**
* Component is used to display node alignment formatting
*/
export default {
name: 'TNattrAlignment',
extends: base,
props: {
isActive: {
type: Object,
required: false,
default: () => ({}),
},
},
computed: {
activeType () {
const alignments = ['left', 'center', 'right', 'justify']
return alignments.find(alignment =>
this.editor.isActive({ textAlign: alignment }),
)
},
activeIcon () {
const alignmentMap = {
left: 'align-left',
center: 'align-center',
right: 'align-right',
justify: 'align-justify',
}
return alignmentMap[this.activeType] || 'align-left'
},
},
}
</script>
<style lang="scss">
</style>

View File

@@ -0,0 +1,98 @@
<template>
<b-dropdown
menu-class="text-center bg-white"
variant="link"
boundary="window"
no-caret
>
<template #button-content>
<span class="text-dark font-weight-bold">
<font-awesome-icon
v-if="format.icon"
:icon="format.icon"
/>
<span v-else>
{{ format.label }}
</span>
</span>
</template>
<b-dropdown-item-button
v-for="v of format.variants"
:key="v.variant"
@click="emitClick(v)"
>
{{ v.label }}
</b-dropdown-item-button>
</b-dropdown>
</template>
<script>
import { nodeTypes } from '../../lib/formats'
import base from '../TNode/base.vue'
/**
* Component is used to display node alignment formatting
*/
export default {
name: 'TNattrTable',
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) => {
return this.editor.isActive(type, attrs)
}
if (ac(an.node.type.name, { ...an.node.attrs, ...attrs })) {
return ['text-primary']
}
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.editor.state.tr
for (const an of ann) {
tr.setNodeMarkup(an.position, an.node.type, { ...an.node.attrs, ...v.attrs })
}
this.editor.dispatchTransaction(tr)
},
emitClick (v) {
this.$emit('click', { type: v.type, attrs: { ...v.attrs } })
},
/**
* 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-primary']
}
},
},
}
</script>
<style lang="scss">
</style>

View File

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

View File

@@ -0,0 +1,172 @@
<template>
<div class="d-flex flex-wrap">
<component
:is="getItem(f)"
v-for="(f, i) of formats"
:key="`${f.name}${i}`"
:format="f"
v-bind="{ ...$props, ...f.props }"
:is-active="isActive()"
:get-mark-attrs="getMarkAttrs"
:labels="labels"
:current-value="currentValue"
class="toolbar-item"
@click="triggerCommand"
/>
<!-- Extra button to remove formatting -->
<b-button
variant="link"
class="toolbar-item text-dark font-weight-bold"
@click="removeMarks"
>
<font-awesome-icon icon="remove-format" />
</b-button>
</div>
</template>
<script>
import cc from './loader'
export default {
inheritAttrs: true,
props: {
editor: {
type: Object,
required: true,
},
formats: {
type: Array,
required: true,
default: () => [],
},
labels: {
type: Object,
default: () => ({}),
},
currentValue: {
type: String,
default: '',
},
},
methods: {
isActive () {
return new Proxy({}, {
get: (_, prop) => (attrs) => this.editor.isActive(prop, attrs),
})
},
/**
* 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 () {
this.editor.chain().focus().unsetAllMarks().run()
},
triggerCommand (v) {
const e = this.editor.chain().focus()
const t = v.type
const a = v.attrs || {}
switch (t) {
case 'bold': e.toggleBold().run(); break
case 'italic': e.toggleItalic().run(); break
case 'underline': e.toggleUnderline().run(); break
case 'strike': e.toggleStrike().run(); break
case 'color': e.setColor(a.color).run(); break
case 'background': e.setBackgroundColor(a.color).run(); break
case 'blockquote': e.toggleBlockquote().run(); break
case 'codeBlock': e.toggleCodeBlock().run(); break
case 'heading': e.setHeading(a).run(); break
case 'paragraph': e.setParagraph().run(); break
case 'orderedList': e.toggleOrderedList().run(); break
case 'bulletList': e.toggleBulletList().run(); break
case 'taskList': e.toggleTaskList().run(); break
case 'horizontalRule': e.setHorizontalRule().run(); break
case 'alignment': e.setTextAlign(a).run(); break
case 'link': {
if (!a.href) {
e.unsetLink().run()
} else {
e.setLink(a).run()
}
break
}
// table actions
case 'insertTable': e.insertTable(a).run(); break
case 'addColumnBefore': e.addColumnBefore().run(); break
case 'addColumnAfter': e.addColumnAfter().run(); break
case 'deleteColumn': e.deleteColumn().run(); break
case 'addRowBefore': e.addRowBefore().run(); break
case 'addRowAfter': e.addRowAfter().run(); break
case 'deleteRow': e.deleteRow().run(); break
case 'mergeCells': e.mergeCells().run(); break
case 'splitCell': e.splitCell().run(); break
case 'toggleHeaderRow': e.toggleHeaderRow().run(); break
case 'toggleHeaderCell': e.toggleHeaderCell().run(); break
case 'toggleHeaderColumn': e.toggleHeaderColumn().run(); break
case 'deleteTable': e.deleteTable().run(); break
case 'emoji': e.setEmoji({ name: a.name }).run(); break
default:
break
}
},
},
}
</script>
<style lang="scss">
.toolbar-item {
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 0.25rem !important;
width: 2.25rem !important;
height: 2.25rem !important;
border-radius: 0.25rem !important;
&:hover {
background-color: var(--light) !important;
}
}
</style>

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,148 @@
<template>
<div
v-if="items.length"
class="emoji-dropdown"
>
<button
v-for="(item, index) in items"
:key="item.name"
type="button"
:class="[
'emoji-option',
{ 'emoji-option--highlighted': index === selectedIndex }
]"
@click="handleClick(index)"
@mouseenter="selectedIndex = index"
>
<span class="emoji-option-emoji">{{ item.emoji }}</span>
<span class="emoji-option-name">:{{ item.name }}:</span>
</button>
</div>
</template>
<script>
export default {
name: 'EmojiList',
props: {
items: {
type: Array,
required: true,
},
command: {
type: Function,
required: true,
},
},
data() {
return {
selectedIndex: 0,
}
},
watch: {
items() {
this.selectedIndex = 0
},
},
methods: {
onKeyDown({ event }) {
if (event.key === 'ArrowUp') {
this.upHandler()
return true
}
if (event.key === 'ArrowDown') {
this.downHandler()
return true
}
if (event.key === 'Enter') {
this.enterHandler()
return true
}
return false
},
upHandler() {
this.selectedIndex = ((this.selectedIndex + this.items.length) - 1) % this.items.length
},
downHandler() {
this.selectedIndex = (this.selectedIndex + 1) % this.items.length
},
enterHandler() {
this.selectItem(this.selectedIndex)
},
handleClick(index) {
this.selectItem(index)
},
selectItem(index) {
const item = this.items[index]
if (item) {
this.command({ name: item.name })
}
},
},
}
</script>
<style lang="scss" scoped>
.emoji-dropdown {
background: var(--white);
border: 1px solid var(--extra-light);
border-radius: 0.25rem;
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
max-height: 200px;
overflow-y: auto;
font-size: 0.9rem;
font-family: var(--font-regular);
}
.emoji-option {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
background: var(--white);
color: var(--black);
padding: 0.35rem 0.75rem;
border: none;
text-align: left;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
&:hover,
&.emoji-option--highlighted {
background: var(--light);
color: var(--black);
}
&:active,
&:focus {
color: var(--white);
background-color: var(--primary);
outline: none;
}
}
.emoji-option-emoji {
font-size: 1.2em;
line-height: 1;
}
.emoji-option-name {
color: var(--secondary);
font-size: 0.85em;
}
</style>

View File

@@ -0,0 +1,2 @@
export { default as EmojiList } from './EmojiList.vue'
export { default as suggestion } from './suggestion.js'

View File

@@ -0,0 +1,123 @@
import { createPopper } from '@popperjs/core'
import { VueRenderer } from '@tiptap/vue-2'
import EmojiList from './EmojiList.vue'
const EMOJI_BLACKLIST = new Set(['relaxed', 'frowning_face'])
export default {
items: ({ query, editor }) => {
const emojis = editor.storage.emoji?.emojis || []
if (!query) {
// Show face emojis by default when user just types ':'
return emojis
.filter(e => (e.group === '' || e.group === 'people & body') && !e.name.startsWith('regional_indicator') && !EMOJI_BLACKLIST.has(e.name))
.slice(0, 20)
}
const q = query.toLowerCase()
return emojis
.filter((emoji) => {
// Skip regional indicators
if (emoji.name.startsWith('regional_indicator')) return false
if (EMOJI_BLACKLIST.has(emoji.name)) return false
// Match against name
if (emoji.name.toLowerCase().includes(q)) return true
// Match against shortcodes
if (emoji.shortcodes && emoji.shortcodes.some(s => s.toLowerCase().includes(q))) return true
// Match against tags
if (emoji.tags && emoji.tags.some(t => t.toLowerCase().includes(q))) return true
return false
})
.slice(0, 20)
},
render: () => {
let component
let popup
let popperInstance
return {
onStart: (props) => {
component = new VueRenderer(EmojiList, {
parent: props.editor.view.dom.ownerDocument.defaultView?.Vue || undefined,
propsData: props,
})
if (!props.clientRect) {
return
}
const virtualElement = {
getBoundingClientRect: props.clientRect,
}
popup = document.createElement('div')
popup.appendChild(component.element)
popup.style.position = 'absolute'
popup.style.zIndex = '1100'
popup.style.minWidth = '200px'
popup.style.pointerEvents = 'auto'
document.body.appendChild(popup)
popperInstance = createPopper(virtualElement, popup, {
placement: 'bottom-start',
modifiers: [
{
name: 'offset',
options: {
offset: [0, 4],
},
},
{
name: 'preventOverflow',
options: {
boundary: 'viewport',
},
},
{
name: 'flip',
options: {
fallbackPlacements: ['top-start', 'bottom-start'],
},
},
],
})
},
onUpdate(props) {
component.updateProps(props)
if (!props.clientRect || !popperInstance) {
return
}
popperInstance.update()
},
onKeyDown(props) {
if (props.event.key === 'Escape') {
return true
}
return component.ref?.onKeyDown(props)
},
onExit() {
if (popperInstance) {
popperInstance.destroy()
}
if (popup) {
popup.remove()
}
component.destroy()
},
}
},
}

View File

@@ -0,0 +1,185 @@
<template>
<b-card
:header-class="!hideToolbar ? 'p-0 border-bottom' : 'd-none'"
body-class="p-0"
class="border border-light rounded"
>
<template
v-if="editor && !hideToolbar"
#header
>
<r-toolbar
:editor="editor"
:formats="toolbar"
:labels="labels"
:current-value="currentValue"
/>
</template>
<editor-content
:editor="editor"
:class="bodyClass"
class="rt-editor-content rt-content p-2"
:style="{ minHeight: minBodyHeight, maxHeight: maxBodyHeight }"
@drop.native="onDrop"
@paste.native="onPaste"
@dragover.native.prevent
/>
</b-card>
</template>
<script>
import { Editor, EditorContent } from '@tiptap/vue-2'
import RToolbar from './RToolbar/index.vue'
import { getFormats, getToolbar } from './lib'
export default {
name: 'CRichTextInput',
components: {
EditorContent,
RToolbar,
},
props: {
value: {
type: String,
required: false,
default: null,
},
labels: {
type: Object,
default: () => ({}),
},
minBodyHeight: {
type: String,
default: '10rem',
},
maxBodyHeight: {
type: String,
default: '',
},
bodyClass: {
type: String,
default: '',
},
placeholder: {
type: String,
default: '',
},
hideToolbar: {
type: Boolean,
default: false,
},
},
data () {
return {
formats: getFormats({
placeholder: this.placeholder,
}),
toolbar: getToolbar(),
// Helper to determine if current content differs 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.commands.setContent(val, false)
}
this.emittedContent = false
},
deep: true,
},
},
mounted () {
this.init()
},
beforeDestroy () {
if (this.editor) 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,
systemAPI: this.$SystemAPI,
})
},
/**
* 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 () {
// Add <br> tags to empty paragraphs
const editorValue = this.editor.getHTML().replace(/<p><\/p>/g, '<p><br></p>')
this.currentValue = editorValue === '<p><br></p>' ? '' : editorValue
this.emittedContent = true
this.$emit('input', this.currentValue)
},
focus () {
if (this.editor) {
this.editor.commands.focus()
}
},
onDrop (event) {
if (event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files.length > 0) {
event.preventDefault()
this.$emit('upload', event.dataTransfer.files)
}
},
onPaste (event) {
if (event.clipboardData && event.clipboardData.files && event.clipboardData.files.length > 0) {
event.preventDefault()
this.$emit('upload', event.clipboardData.files)
}
},
},
}
</script>
<style lang="scss">
.rt-editor-content {
height: 100%;
.ProseMirror {
height: 100%;
}
/* Make checkboxes only editable inside the editor */
input[type="checkbox"] {
pointer-events: auto !important;
cursor: pointer !important;
}
}
</style>

View File

@@ -0,0 +1,103 @@
/* eslint-disable @typescript-eslint/explicit-function-return-type */
import { TaskItem, TaskList } from '@tiptap/extension-list'
import Mention from '@tiptap/extension-mention'
import Emoji from '@tiptap/extension-emoji'
import { TableKit } from '@tiptap/extension-table'
import TextAlign from '@tiptap/extension-text-align'
import { TextStyleKit } from '@tiptap/extension-text-style'
import StarterKit from '@tiptap/starter-kit'
import { Placeholder } from '@tiptap/extension-placeholder'
import suggestion from '../mentions/suggestion.js'
import emojiSuggestion from '../emoji/suggestion.js'
// Defines a set of formats that our document supports
export const getFormats = ({ placeholder = '' }) => [
StarterKit,
TextStyleKit,
TaskList,
TaskItem.configure({
nested: true,
}),
TextAlign.configure({
types: ['heading', 'paragraph'],
}),
TableKit.configure({ table: { resizable: true } }),
Mention.configure({
HTMLAttributes: {
class: 'mention',
},
suggestion,
}),
Placeholder.configure({
placeholder,
}),
Emoji.configure({
enableEmoticons: true,
suggestion: emojiSuggestion,
}),
]
// 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', props: { background: true } },
{ type: 'blockquote', node: true, icon: 'quote-right' },
{ type: 'codeBlock', 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: 'orderedList', node: true, icon: 'list-ol' },
{ type: 'bulletList', node: true, icon: 'list-ul' },
{ type: 'taskList', node: true, icon: 'tasks' },
{
type: 'alignment',
icon: 'align-left',
nodeAttr: true,
component: 'Alignment',
variants: [
{ variant: 'left', icon: 'align-left', attrs: 'left' },
{ variant: 'center', icon: 'align-center', attrs: 'center' },
{ variant: 'right', icon: 'align-right', attrs: 'right' },
{ variant: 'justify', icon: 'align-justify', attrs: 'justify' },
],
},
{
type: 'table',
icon: 'table',
nodeAttr: true,
component: 'Table',
variants: [
{ label: 'Insert Table', type: 'insertTable', attrs: { rows: 3, cols: 3, withHeaderRow: true } },
{ label: 'Insert Column Before', type: 'addColumnBefore' },
{ label: 'Insert Column After', type: 'addColumnAfter' },
{ label: 'Delete Column', type: 'deleteColumn' },
{ label: 'Add Row Before', type: 'addRowBefore' },
{ label: 'Add Row After', type: 'addRowAfter' },
{ label: 'Delete Row', type: 'deleteRow' },
{ label: 'Merge Cells', type: 'mergeCells' },
{ label: 'Split Cell', type: 'splitCell' },
{ label: 'Toggle Header Row', type: 'toggleHeaderRow' },
{ label: 'Toggle Header Cell', type: 'toggleHeaderCell' },
{ label: 'Toggle Header Column', type: 'toggleHeaderColumn' },
{ label: 'Delete Table', type: 'deleteTable' },
],
},
{ type: 'link', mark: true, component: 'Link', icon: 'link', attrs: { href: null } },
{ type: 'emoji', mark: true, component: 'Emoji' },
// @note There is no free FA icon for this
{ type: 'horizontalRule', node: true, label: '__' },
]
export const nodeTypes = getToolbar().filter(({ node }) => node).map(({ type }) => type)

View File

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

View File

@@ -0,0 +1,143 @@
<template>
<div
v-if="items.length"
class="mention-dropdown"
>
<button
v-for="(item, index) in items"
:key="index"
type="button"
:class="[
'mention-option',
{ 'mention-option--highlighted': index === selectedIndex }
]"
@click="handleClick(index, $event)"
@mouseenter="selectedIndex = index"
>
{{ getDisplayName(item) }}
</button>
</div>
</template>
<script>
export default {
name: 'MentionList',
props: {
items: {
type: Array,
required: true,
},
command: {
type: Function,
required: true,
},
},
data() {
return {
selectedIndex: 0,
}
},
watch: {
items() {
this.selectedIndex = 0
},
},
methods: {
onKeyDown({ event }) {
if (event.key === 'ArrowUp') {
this.upHandler()
return true
}
if (event.key === 'ArrowDown') {
this.downHandler()
return true
}
if (event.key === 'Enter') {
this.enterHandler()
return true
}
return false
},
upHandler() {
this.selectedIndex = ((this.selectedIndex + this.items.length) - 1) % this.items.length
},
downHandler() {
this.selectedIndex = (this.selectedIndex + 1) % this.items.length
},
enterHandler() {
this.selectItem(this.selectedIndex)
},
handleClick(index, event) {
this.selectItem(index)
},
selectItem(index) {
const item = this.items[index]
if (item) {
this.command({
id: item.userID,
label: this.getDisplayName(item),
})
}
},
getDisplayName(user) {
const { name, username, email, userID } = user
return name || username || email || userID
},
},
}
</script>
<style lang="scss" scoped>
.mention-dropdown {
background: var(--white);
border: 1px solid var(--extra-light);
border-radius: 0.25rem;
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
max-height: 200px;
overflow-y: auto;
font-size: 0.9rem;
font-family: var(--font-regular);
}
.mention-option {
display: block;
width: 100%;
background: var(--white);
color: var(--black);
padding: 0.5rem 0.75rem;
border: none;
text-align: left;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
&:hover,
&.mention-option--highlighted {
background: var(--light);
color: var(--black);
}
&:active,
&:focus {
color: var(--white);
background-color: var(--primary);
outline: none;
}
}
</style>

View File

@@ -0,0 +1,3 @@
export { default as MentionList } from './MentionList.vue'
export { default as suggestion } from './suggestion.js'

View File

@@ -0,0 +1,134 @@
import { createPopper } from '@popperjs/core'
import { VueRenderer } from '@tiptap/vue-2'
import MentionList from './MentionList.vue'
export default {
items: ({ query, editor }) => {
if (!query) {
return []
}
let systemAPI = editor.options?.systemAPI
if (!systemAPI) {
return []
}
return systemAPI.userList({
query,
limit: 10,
}).then(({ set }) => {
return set.map(user => Object.freeze(user))
}).catch(() => {
return []
})
},
render: () => {
let component
let popup
let popperInstance
return {
onStart: (props) => {
component = new VueRenderer(MentionList, {
parent: props.editor.view.dom.ownerDocument.defaultView?.Vue || undefined,
propsData: props,
})
if (!props.clientRect) {
return
}
const virtualElement = {
getBoundingClientRect: props.clientRect,
}
popup = document.createElement('div')
popup.appendChild(component.element)
popup.style.position = 'absolute'
popup.style.zIndex = '1100'
popup.style.minWidth = '200px'
popup.style.pointerEvents = 'auto'
document.body.appendChild(popup)
const handleSelection = (event) => {
const options = popup.querySelectorAll('.mention-option')
options.forEach((option, index) => {
if (option.contains(event.target) || option === event.target) {
if (props.items[index]) {
const item = props.items[index]
props.command({
id: item.userID,
label: item.name || item.username || item.email || item.userID,
})
event.preventDefault()
event.stopPropagation()
}
}
})
}
popup.addEventListener('click', () => handleSelection)
popup.addEventListener('mouseup', () => handleSelection)
popperInstance = createPopper(virtualElement, popup, {
placement: 'bottom-start',
modifiers: [
{
name: 'offset',
options: {
offset: [0, 4],
},
},
{
name: 'preventOverflow',
options: {
boundary: 'viewport',
},
},
{
name: 'flip',
options: {
fallbackPlacements: ['top-start', 'bottom-start'],
},
},
],
})
},
onUpdate(props) {
component.updateProps(props)
if (!props.clientRect || !popperInstance) {
return
}
// Update popper position
popperInstance.update()
},
onKeyDown(props) {
if (props.event.key === 'Escape') {
return true
}
return component.ref?.onKeyDown(props)
},
onExit() {
if (popperInstance) {
popperInstance.destroy()
}
if (popup) {
popup.remove()
}
component.destroy()
},
}
},
}

View File

@@ -0,0 +1,32 @@
<template>
<span :class="stateClass">{{ label }}</span>
</template>
<script>
import { NoID } from '@cortezaproject/corteza-js'
export default {
name: 'CUserLabel',
props: {
user: {
type: Object,
default: () => ({}),
},
},
computed: {
label () {
const { userID, name, username, email, handle } = this.user || {}
return name || email || username || handle || (userID && userID !== NoID ? `<@${userID}>` : '')
},
stateClass () {
const { deletedAt, suspendedAt } = this.user || {}
if (deletedAt) return 'text-danger'
if (suspendedAt) return 'text-muted'
return ''
},
},
}
</script>

View File

@@ -0,0 +1,87 @@
<template>
<b-button
data-test-id="button-submit"
type="submit"
:variant="variant"
:disabled="disabled || processing || success"
:size="size"
:block="block"
:title="title"
:class="buttonClass"
@click.prevent="$emit('submit')"
>
<b-spinner
v-if="processing"
data-test-id="spinner"
small
class="align-middle"
/>
<font-awesome-icon
v-else-if="success"
data-test-id="icon-success"
:icon="['fas', 'check']"
/>
<span
v-else-if="text"
data-test-id="button-text"
>
{{ text }}
</span>
</b-button>
</template>
<script>
export default {
name: 'CButtonSubmit',
props: {
processing: {
type: Boolean,
},
success: {
type: Boolean,
},
disabled: {
type: Boolean,
},
title: {
type: String,
default: '',
},
buttonClass: {
type: String,
default: '',
},
text: {
type: String,
default: '',
},
loadingText: {
type: String,
default: '',
},
size: {
type: String,
default: 'md',
},
block: {
type: Boolean,
},
variant: {
type: String,
default: 'primary',
},
},
}
</script>

View File

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

View File

@@ -0,0 +1,18 @@
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 CInputSearch } from './CInputSearch.vue'
export { default as CRichTextInput } from './CRichTextInput/index.vue'
export { default as CInputCheckbox } from './CInputCheckbox.vue'
export { default as CInputColorPicker } from './CInputColorPicker.vue'
export { default as CAceEditor } from './CAceEditor.vue'
export { default as CInputExpression } from './CInputExpression.vue'
export {
CButtonSubmit,
} from './button'
export { default as CInputSelect } from './CInputSelect.vue'
export { default as CInputRole } from './CInputRole.vue'
export { default as CInputUser } from './CInputUser.vue'
export { default as CInputUserGroup } from './CInputUserGroup.vue'
export { default as CUserLabel } from './CUserLabel.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,491 @@
<template>
<div
class="overflow-hidden d-flex flex-column w-100 vh-100"
>
<b-card
no-body
class="h-100"
>
<b-card-header
class="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="py-2 pl-0 pr-2"
>
<div
class="d-flex align-items-center"
>
<label
class="text-primary mb-0 py-1"
>
{{ labels.availableItems }}
</label>
<b-button
v-show="filteredAvailable.length && !disabled"
data-test-id="link-select-all"
variant="outline-light"
class="ml-auto border-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"
:disabled="query"
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"
:class="{
'handle': !isDraggable
}"
@dblclick="select(item)"
>
<c-item-picker-item
:item="item"
:disabled="disabled"
:disabled-dragging="isDraggable"
:disabled-sorting="disabledSorting"
:hide-icons="hideIcons"
@select="select(item)"
>
<template
v-for="(_, slot) of $scopedSlots"
#[slot]="scope"
>
<slot
:name="slot"
:text-field="textField"
:disabled="disabled"
:disabled-dragging="isDraggable"
: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="py-2 pl-2 pr-0"
>
<div
class="d-flex align-items-center"
>
<label
class="text-primary mb-0 py-1"
>
{{ labels.selectedItems }}
</label>
<b-button
v-show="filteredSelected.length && !disabled"
data-test-id="link-unselect-all"
variant="outline-light"
class="ml-auto border-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"
:disabled="query"
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"
:class="{
'handle': !isDraggable
}"
@dblclick="unselect(item)"
>
<c-item-picker-item
:item="item"
:disabled="disabled"
:disabled-dragging="isDraggable"
:disabled-sorting="disabledSorting"
:hide-icons="hideIcons"
selected
@unselect="unselect(item)"
>
<template
v-for="(_, slot) of $scopedSlots"
#[slot]="scope"
>
<slot
:name="slot"
:text-field="textField"
:disabled="disabled"
:disabled-dragging="isDraggable"
: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
},
},
isDraggable () {
return this.disabledDragging || this.query.length > 0
},
},
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 then 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')
}
})
},
beforeUnmount () {
this.setDefaultValues()
},
methods: {
_disableDragging (e) {
if (this.disabledDragging && e.to !== e.from) {
return false
}
},
selectAll () {
this.selected.push(...this.filteredAvailable)
this.available = this.frozen().filter(i => !this.selected.includes(i))
},
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.filteredSelected.forEach(this.unselect)
},
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)
},
setDefaultValues () {
this.query = ''
this.available = []
this.selected = []
},
},
}
</script>
<style lang="scss" scoped>
.handle {
cursor: grab;
}
.handle:active {
cursor: grabbing;
}
</style>

View File

@@ -0,0 +1,80 @@
<template>
<div
class="d-flex align-items-center"
>
<font-awesome-icon
v-if="!disabled && !disabledSorting && !hideIcons"
:icon="['fas', 'grip-vertical']"
:class="{
'text-muted': disabledDragging,
}"
class="align-baseline mr-3 text-primary"
/>
<b
class="text-truncate"
>
<slot
v-bind="item"
>
{{ item[textField] }}
</slot>
</b>
<b-button
v-if="_hideIcons"
v-b-tooltip.noninteractive.hover="{ title: selected ? 'Unselect' : 'Select', boundary: 'body' , delay: 1000 }"
:data-test-id="`button-${selected ? 'unselect' : 'select'}`"
variant="outline-light"
class="d-flex align-items-center ml-auto p-2 border-0"
@click.prevent.stop="$emit(selected ? 'unselect' : 'select')"
>
<font-awesome-icon
:icon="[selected ? 'far' : 'fas', selected ? 'eye' : 'eye-slash']"
class="text-muted"
/>
</b-button>
</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>

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,91 @@
<template>
<div class="lightbox">
<div class="header shadow-sm">
<div class="left">
<slot name="header.left" />
</div>
<div class="right">
<slot name="header.right" />
<button
class="closer"
@click="$emit('close')"
>
&times;
</button>
</div>
</div>
<div class="main">
<slot />
</div>
</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: var(--body-bg);
color: var(--body-color);
z-index: 2000;
}
.lightbox .header {
display: flex;
justify-content: space-between;
background-color: var(--white);
color: var(--body-color);
position: absolute;
left: 0;
top: 0;
right: 0;
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;
color: var(--body-color);
}
.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,461 @@
<template>
<div
class="position-relative"
>
<div
v-if="!hideGeoSearch"
class="geosearch-container"
@mouseover="disableMap"
@mouseleave="enableMap"
>
<c-input-search
v-model="geoSearch.query"
:placeholder="labels.geosearchInputPlaceholder"
:autocomplete="'off'"
:debounce="300"
@input="onGeoSearch"
/>
<div class="geosearch-results">
<div
v-for="(result, idx) in geoSearch.results"
:key="idx"
class="geosearch-result"
@click="placeGeoSearchMarker(result)"
>
{{ result.label }}
</div>
</div>
</div>
<l-map
ref="map"
:zoom="mapOptions.zoom"
:center="mapOptions.center"
:min-zoom="mapOptions.minZoom"
:max-zoom="mapOptions.maxZoom"
:bounds="mapOptions.bounds"
:max-bounds="mapOptions.maxBounds"
class="w-100 h-100"
@click="onMapClick"
@locationfound="onLocationFound"
@update:zoom="onZoom"
@update:center="onCenter"
@update:bounds="onBoundsUpdate"
>
<l-tile-layer
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
:attribution="mapOptions.attribution"
/>
<l-polygon
v-for="(geometry, i) in polygons"
:key="`polygon-${i}`"
:lat-lngs="geometry.map(value => value.geometry)"
:color="(geometry.find(g => g) || {}).color"
/>
<l-marker
v-for="(marker, i) in markerValues"
:key="`marker-${i}`"
:lat-lng="marker.value"
:icon="getIcon(marker.color)"
:opacity="marker.opacity || 1.0"
@click="onMarkerClick(i, marker)"
>
<l-tooltip
v-if="$scopedSlots['marker-tooltip'] || marker.title"
:options="{
offset: [-1, 5],
direction: 'bottom',
}"
>
<slot
name="marker-tooltip"
:marker="marker"
>
{{ marker.title }}
</slot>
</l-tooltip>
</l-marker>
<l-marker
v-if="geoSearch.marker"
:lat-lng="geoSearch.marker.latlng"
:icon="getIcon(getCSSVariable('--secondary'))"
>
<l-tooltip
:options="{
offset: [-1, 5],
direction: 'bottom',
}"
>
{{ geoSearch.marker.title }}
</l-tooltip>
</l-marker>
<l-control class="leaflet-bar">
<a
v-if="!hideCurrentLocationButton"
v-b-tooltip.noninteractive.hover="{ title: labels.tooltip && labels.tooltip.goToCurrentLocation, boundary: 'body' }"
role="button"
class="d-flex justify-content-center align-items-center"
@click="goToCurrentLocation"
>
<font-awesome-icon
:icon="['fas', 'location-crosshairs']"
/>
</a>
</l-control>
</l-map>
</div>
</template>
<script>
import { divIcon, latLng, latLngBounds } from 'leaflet'
import {
OpenStreetMapProvider,
OpenCageProvider,
EsriProvider,
GeoapifyProvider,
GeocodeEarthProvider,
GoogleProvider,
LocationIQProvider,
} from 'leaflet-geosearch'
import { isNumber } from 'lodash'
import { LControl, LMap, LMarker, LPolygon, LTileLayer, LTooltip } from 'vue2-leaflet'
import CInputSearch from '../input/CInputSearch.vue'
import 'leaflet/dist/leaflet.css'
export default {
components: {
LControl,
CInputSearch,
LPolygon,
LTileLayer,
LMarker,
LMap,
LTooltip,
},
props: {
hideCurrentLocationButton: {
type: Boolean,
default: false,
},
labels: {
type: Object,
default: () => ({}),
},
map: {
type: Object,
default: () => ({}),
},
markers: {
type: Array,
default: () => ([]),
},
polygons: {
type: Array,
default: () => ([]),
},
hideGeoSearch: {
type: Boolean,
default: false,
},
disabled: {
type: Boolean,
default: false,
},
},
data () {
return {
geoSearch: {
query: '',
results: [],
marker: null,
},
}
},
computed: {
markerValues () {
return this.markers.map((m) => {
return {
...m,
value: this.getLatLng(m.value),
}
}).filter(c => c.value) || []
},
mapOptions () {
const map = { ...this.map }
const defaultOptions = {
zoom: 3,
center: [30, 30],
rotation: 0,
attribution: '&copy; <a target="_blank" href="http://osm.org/copyright">OpenStreetMap</a>',
}
map.bounds = map.bounds ? latLngBounds(map.bounds) : null
return {
...defaultOptions,
...map,
}
},
geoSearchApiKey () {
return this.$Settings.get('ui.location.geoSearchApiKey', '')
},
geoSearchProviderName () {
return this.$Settings.get('ui.location.geoSearchProvider', '')
},
geoSearchProvider () {
const providerName = this.geoSearchProviderName.toLowerCase() || 'openstreetmap'
const apiKey = this.geoSearchApiKey
// Map of available providers
const providers = {
openstreetmap: () => new OpenStreetMapProvider(),
opencage: () => new OpenCageProvider({
params: { key: apiKey },
}),
esri: () => new EsriProvider(),
geoapify: () => new GeoapifyProvider({
params: { apiKey },
}),
geocodeearth: () => new GeocodeEarthProvider({
params: { api_key: apiKey },
}),
google: () => new GoogleProvider({
apiKey,
}),
locationiq: () => new LocationIQProvider({
params: { key: apiKey },
}),
}
if (providers[providerName]) {
return providers[providerName]()
}
console.warn(`Unknown geosearch provider: ${providerName}, falling back to OpenStreetMap`)
return new OpenStreetMapProvider()
},
tileProviderUrl () {
return ''
},
tileProvider () {
if (this.tileProviderUrl) {
return this.tileProviderUrl
}
return 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png'
},
},
mounted () {
if (this.$refs.map && this.$refs.map.mapObject) {
this.onBoundsUpdate(this.$refs.map.mapObject.getBounds())
}
},
methods: {
onGeoSearch (query) {
if (!query || !this.geoSearchProvider) {
this.geoSearch.results = []
this.geoSearch.marker = null
return
}
this.geoSearchProvider.search({ query }).then(results => {
this.geoSearch.results = results.map(result => {
// Different providers return coordinates in different formats
let lat, lng
if (result.y !== undefined && result.x !== undefined) {
// ESRI and some others use x, y
lat = result.y
lng = result.x
} else if (result.raw) {
// Most providers (OpenStreetMap, OpenCage, etc.) use raw.lat/lon or raw.latitude/longitude
lat = result.raw.lat || result.raw.latitude
lng = result.raw.lon || result.raw.lng || result.raw.longitude
}
return {
...result,
latlng: {
lat,
lng,
},
}
})
}).catch(() => {
this.$emit('on-geosearch-error')
})
},
placeGeoSearchMarker (result) {
const zoom = this.$refs.map.mapObject._zoom >= 15 ? this.$refs.map.mapObject._zoom : 15
this.$refs.map.mapObject.flyTo([result.latlng.lat, result.latlng.lng], zoom, { animate: false })
this.geoSearch.marker = { title: result.label, latlng: result.latlng }
this.geoSearch.results = []
this.onMapClick(result)
},
getLatLng (coordinates = [undefined, undefined]) {
const [lat, lng] = coordinates
if (isNumber(lat) && isNumber(lng)) {
return latLng(lat, lng)
}
},
onLocationFound ({ latitude, longitude }) {
const zoom = this.$refs.map.mapObject._zoom >= 15 ? this.$refs.map.mapObject._zoom : 15
this.$refs.map.mapObject.flyTo([latitude, longitude], zoom)
this.$emit('location-found', { latlng: { lat: latitude, lng: longitude } })
},
disableMap () {
if (this.disabled) this.$refs.map.mapObject._handlers.forEach(handler => handler.disable())
},
enableMap () {
if (this.disabled) this.$refs.map.mapObject._handlers.forEach(handler => handler.enable())
},
onMarkerClick (index, marker) {
this.$emit('on-marker-click', { index, marker })
},
goToCurrentLocation () {
this.$refs.map.mapObject.locate()
},
onMapClick (e) {
this.$emit('on-map-click', e)
},
getCSSVariable (variable) {
return getComputedStyle(document.documentElement).getPropertyValue(variable)
},
getIcon (markerColor = this.getCSSVariable('--primary')) {
const markerIconHtml = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 34.892337" height="60" width="40" style="margin-top: -40px;margin-left: -15px;height: 35px;">
<g transform="translate(-814.59595,-274.38623)">
<g transform="matrix(1.1855854,0,0,1.1855854,-151.17715,-57.3976)">
<path d="m 817.11249,282.97118 c -1.25816,1.34277 -2.04623,3.29881 -2.01563,5.13867 0.0639,3.84476 1.79693,5.3002 4.56836,10.59179 0.99832,2.32851 2.04027,4.79237 3.03125,8.87305 0.13772,0.60193 0.27203,1.16104 0.33416,1.20948 0.0621,0.0485 0.19644,-0.51262 0.33416,-1.11455 0.99098,-4.08068 2.03293,-6.54258 3.03125,-8.87109 2.77143,-5.29159 4.50444,-6.74704 4.56836,-10.5918 0.0306,-1.83986 -0.75942,-3.79785 -2.01758,-5.14062 -1.43724,-1.53389 -3.60504,-2.66908 -5.91619,-2.71655 -2.31115,-0.0475 -4.4809,1.08773 -5.91814,2.62162 z" style="fill:${markerColor};stroke:${markerColor};"/>
<circle r="3.0355" cy="288.25278" cx="823.03064" id="path3049" style="display:inline;fill:#FFFFFF;"/>
</g>
</g>
</svg>`
return divIcon({
className: 'marker-pin',
html: markerIconHtml,
})
},
onZoom (e) {
this.$emit('on-zoom', e)
},
onCenter (e) {
this.$emit('on-center', e)
},
onBoundsUpdate (value) {
this.$nextTick(() => {
setTimeout(() => {
this.$refs.map.mapObject.invalidateSize()
}, 100)
})
value = value || this.$refs.map.mapObject.getBounds()
this.$emit('on-bounds-update', value)
},
},
}
</script>
<style lang="scss">
.leaflet-touch .leaflet-bar {
border: 1px solid transparent;
border-radius: 0.3rem;
}
.leaflet-bar a {
background-color: var(--white) !important;
color: var(--primary) !important;
text-decoration: none !important;
&:hover {
background-color: var(--white) !important;
transition: background-color 0.15s ease;
}
}
.geosearch-result {
&:hover {
background-color: var(--light) !important;
color: var(--black);
}
&:active {
color: var(--white) !important;
background-color: var(--primary) !important;
}
}
</style>
<style scoped>
.geosearch-container {
position: absolute;
display: block;
height: auto;
width: 50%;
max-width: 50%;
cursor: auto;
z-index: 1030;
left: 50%;
transform: translateX(-50%);
top: 10px;
}
.geosearch-results {
margin: 1px;
border-radius: 2px;
background-color: var(--white);
max-height: 50%;
overflow: auto;
}
.geosearch-result {
border-radius: 2px;
line-height: 32px;
padding: 0 8px;
font-size: 12px;
white-space: nowrap;
}
.geosearch-result:hover {
background-color: var(--gray-200);
cursor: pointer;
}
</style>

View File

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

View File

@@ -0,0 +1,304 @@
<template>
<div>
<b-sidebar
v-model="isExpanded"
data-test-id="sidebar"
:sidebar-class="`sidebar ${isExpanded ? 'expanded' : ''}`"
:header-class="`d-block sidebar-header ${isExpanded ? 'expanded border-bottom p-2' : ''}`"
:body-class="`${isExpanded ? 'px-3' : ''}`"
:footer-class="`rounded-right ${isExpanded ? 'px-2' : ''}`"
:no-header="!isExpanded"
:backdrop="isMobile"
backdrop-variant="white"
:shadow="isExpanded && 'sm'"
no-slide
:right="right"
no-close-on-route-change
no-close-on-esc
z-index="1037"
>
<template #header>
<div
class="d-flex align-items-center justify-content-between pl-2"
style="height: 47px;"
>
<img
data-test-id="img-main-logo"
class="logo w-auto border-0"
:src="logo"
>
<b-button
variant="outline-light"
class="d-flex align-items-center justify-content-center p-2 border-0 text-secondary"
@click="closeSidebar()"
>
<font-awesome-icon
:icon="['fas', 'times']"
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
class="d-flex align-items-center justify-content-center tab position-absolute p-2"
>
<div
v-if="disabledRoutes.includes($route.name)"
class="d-flex align-items-center border-0 p-2"
>
<img
class="icon w-auto border-0"
:src="icon"
>
</div>
<b-button
v-else-if="expandOnClick"
data-test-id="button-sidebar-open"
variant="outline-extra-light"
size="lg"
class="d-flex align-items-center border-0 text-primary"
@click="openSidebar()"
>
<font-awesome-icon
:icon="['fas', 'bars']"
class="h4 mb-0"
/>
</b-button>
<b-button
v-else
data-test-id="button-home"
variant="outline-extra-light"
size="lg"
class="d-flex align-items-center p-2 border-0 text-primary"
:to="{ name: 'root' }"
>
<font-awesome-icon
:icon="['fas', 'home']"
class="h4 mb-0"
/>
</b-button>
</div>
</div>
</template>
<script>
import { throttle } from 'lodash'
export default {
props: {
expanded: {
type: Boolean,
default: false,
},
expandOnClick: {
type: Boolean,
default: false,
},
disabledRoutes: {
type: Array,
default: () => [],
},
icon: {
type: String,
default: () => '',
},
logo: {
type: String,
default: () => '',
},
right: {
type: Boolean,
default: false,
},
storageKey: {
type: String,
default: 'sidebar-expanded',
},
},
data () {
return {
isMobile: false,
}
},
computed: {
isExpanded: {
get () {
return this.expanded
},
set (expanded) {
this.$emit('update:expanded', expanded)
},
},
},
watch: {
'$route.name': {
handler () {
this.checkSidebar()
},
},
disabledRoutes: {
handler () {
this.checkSidebar()
},
},
},
mounted () {
this.checkSidebar(true)
this.checkIfMobile()
this.$root.$on('close-sidebar', this.closeSidebar)
window.addEventListener('resize', this.checkIfMobile)
},
beforeUnmount () {
this.$root.$off('close-sidebar', this.closeSidebar)
window.removeEventListener('resize', this.checkIfMobile)
},
methods: {
checkIfMobile: throttle(function () {
this.isMobile = window.innerWidth < 1024
}, 500),
checkSidebar (initial = false) {
// If sidebar should be disabled on route, close and unpin when navigating to route
if (this.disabledRoutes.includes(this.$route.name)) {
this.isExpanded = false
} else if (!this.isMobile && initial) {
const stored = localStorage.getItem(this.storageKey)
// Default to closed if no stored value
this.isExpanded = stored ? stored === 'true' : true
}
},
openSidebar () {
this.isExpanded = true
localStorage.setItem(this.storageKey, 'true')
},
closeSidebar () {
this.isExpanded = false
localStorage.setItem(this.storageKey, '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;
$nav-width-mobile: 400px;
.b-sidebar {
background-color: var(--white) !important;
}
.b-sidebar-backdrop {
opacity: 0.75 !important;
}
.sidebar {
display: flex !important;
left: calc(-#{$nav-width}) !important;
transition: left 0.2s cubic-bezier(0.4, 0, 0.2, 1);
&.expanded {
left: 0 !important;
transition: left 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
}
// Mobile sidebar should be 400px wide to match notifications and drafts
@media (max-width: 1023px) {
.sidebar {
width: $nav-width-mobile !important;
left: calc(-#{$nav-width-mobile}) !important;
}
}
[dir="rtl"] {
.sidebar {
right: calc(-#{$nav-width}) !important;
left: auto !important;
transition: right 0.2s cubic-bezier(0.4, 0, 0.2, 1);
&.expanded {
right: 0 !important;
transition: right 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
}
@media (max-width: 1023px) {
.sidebar {
width: $nav-width-mobile !important;
right: calc(-#{$nav-width-mobile}) !important;
}
}
}
</style>

View File

@@ -0,0 +1,197 @@
<template>
<div
class="nav-sidebar"
:class="{ 'mt-1': root }"
>
<div
v-for="({page = {}, params = {}, children = []}) of items"
:key="pageKey(page)"
:class="{ 'mb-1': root }"
>
<div class="d-flex align-items-start pointer pb-1">
<b-button
variant="link"
active-class="nav-active"
exact-active-class="nav-exact-active"
:title="page.title"
:to="{ name: page.name || defaultRouteName, params }"
class="nav-item d-flex align-items-center text-decoration-none rounded flex-grow-1 text-left pl-1 py-1 gap-1"
@click="onItemClick()"
>
<template v-if="page.icon">
<font-awesome-icon
v-if="Array.isArray(page.icon)"
:icon="page.icon"
class="icon"
style="height: 1rem; width: 1rem;"
/>
<img
v-else
:src="page.icon"
class="mr-1"
style="height: 1rem; width: 1rem;"
>
</template>
<label
class="title pointer mb-0"
:class="{ 'root': root }"
>
{{ page.title }}
</label>
</b-button>
<b-button
v-if="children.length"
variant="outline-light"
class="p-0 border-0 ml-auto"
style="min-width: 2rem; min-height: 2rem;"
@click="toggle(page)"
>
<font-awesome-icon
v-if="!collapses[pageKey(page)]"
class="text-dark"
:icon="['fas', 'chevron-down']"
/>
<font-awesome-icon
v-else
class="text-primary"
:icon="['fas', 'chevron-up']"
/>
</b-button>
</div>
<b-collapse
v-if="children.length"
:visible="collapses[pageKey(page)]"
>
<c-sidebar-nav-items
:items="children"
:start-expanded="startExpanded"
:default-route-name="defaultRouteName"
:root="false"
class="py-1 ml-2"
v-on="$listeners"
/>
</b-collapse>
</div>
</div>
</template>
<script>
export default {
name: 'CSidebarNavItems',
props: {
/*
* {
page: { name, title }
params: {...}
}
*/
items: {
type: Array,
required: true,
default: () => [],
},
root: {
type: Boolean,
default: true,
},
defaultRouteName: {
type: String,
required: true,
},
startExpanded: {
type: Boolean,
required: false,
},
},
data () {
return {
collapses: {},
}
},
watch: {
items: {
immediate: true,
handler (items = []) {
items.forEach(({ page, params, children }) => {
const px = this.pageKey(page)
// Apply startExpanded only if page isn't currently expanded
this.$set(this.collapses, px, this.startExpanded || page.expanded || this.showChildren({ params, children }))
})
},
},
},
methods: {
onItemClick () {
if (window.innerWidth < 1024) {
this.$root.$emit('close-sidebar')
}
},
pageKey (p) {
return p.pageID || p.name || p.title
},
toggle (p) {
const px = this.pageKey(p)
this.$set(this.collapses, px, !this.collapses[px])
},
// Recursively check for child pages that are open, so that parents can open as well
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">
.nav-sidebar {
.nav-item {
transition: background-color 0.2s ease-out;
.icon {
color: var(--black);
transition: color 0.3s ease-in-out;
}
.title {
color: var(--black);
font-family: var(--font-regular) !important;
transition: color 0.3s ease-in-out;
text-align: left;
}
&:hover {
background-color: var(--light);
}
}
.nav-active {
.icon {
color: var(--primary);
}
.title {
font-family: var(--font-medium) !important;
color: var(--primary);
}
}
}
</style>

View File

@@ -0,0 +1,439 @@
<template>
<div class="header-navigation d-flex flex-wrap align-items-center py-2 pl-3 pr-2 gap-2">
<div
class="sidebar-spacer"
:class="{ 'expanded': expanded }"
/>
<h2 class="title mb-0">
<slot name="title" />
</h2>
<div class="tools-wrapper ml-auto">
<slot name="tools" />
</div>
<div class="d-flex align-items-center ml-auto gap-1">
<b-button
v-if="!hideAppSelector && !settings.hideAppSelector"
data-test-id="app-selector"
variant="outline-extra-light"
:href="appSelectorURL"
class="text-dark border-0 px-1"
>
{{ labels.appMenu }}
</b-button>
<slot name="right-tools" />
<c-notification-button
v-if="!settings.hideNotifications"
/>
<b-dropdown
v-if="!settings.hideHelp"
data-test-id="dropdown-helper"
size="lg"
variant="outline-extra-light"
toggle-class="text-decoration-none text-dark rounded-circle border-0 w-100"
menu-class="topbar-dropdown-menu border-0 shadow-sm text-dark mt-2"
right
no-caret
class="nav-icon text-sm-nowrap"
>
<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 | checkValidURL"
:target="helpLink.newTab ? '_blank' : ''"
>
{{ helpLink.handle }}
</b-dropdown-item>
<b-dropdown-item
v-if="!settings.hideForumLink"
data-test-id="dropdown-helper-forum"
href="https://forum.cortezaproject.org/"
target="_blank"
>
{{ labels.helpForum }}
</b-dropdown-item>
<b-dropdown-item
v-if="!settings.hideDocumentationLink"
data-test-id="dropdown-helper-docs"
:href="documentationURL"
target="_blank"
>
{{ labels.helpDocumentation }}
</b-dropdown-item>
<b-dropdown-item
v-if="!settings.hideFeedbackLink"
data-test-id="dropdown-helper-feedback"
href="mailto:info@cortezaproject.org"
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
v-if="!settings.hideProfile"
data-test-id="dropdown-profile"
:variant="avatarExists ? 'link' : 'outline-extra-light'"
:toggle-class="`nav-icon text-decoration-none text-dark rounded-circle border ${avatarExists ? 'p-0' : ''}`"
size="lg"
right
menu-class="topbar-dropdown-menu border-0 shadow-sm text-dark mt-2"
no-caret
class="nav-user-icon"
@hide="preventDropdownClose"
>
<template #button-content>
<div
v-if="avatarExists"
class="avatar d-flex h-100"
:style="{
'background-image': avatarExists ? `url(${profileAvatarUrl})` : 'none',
}"
/>
<div
v-else
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
data-test-id="dropdown-item-username"
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 | checkValidURL"
:target="profileLink.newTab ? '_blank' : ''"
>
{{ profileLink.handle }}
</b-dropdown-item>
<b-dropdown-item
v-if="!settings.hideProfileLink"
data-test-id="dropdown-profile-user"
:href="userProfileURL"
target="_blank"
>
{{ labels.userSettingsProfile }}
</b-dropdown-item>
<b-dropdown-item
v-if="!settings.hideChangePasswordLink"
data-test-id="dropdown-profile-change-password"
:href="changePasswordURL"
target="_blank"
>
{{ labels.userSettingsChangePassword }}
</b-dropdown-item>
<b-dropdown
v-if="!settings.hideThemeSelector"
id="theme-dropleft"
variant="link"
:text="labels.userSettingsTheme"
dropleft
no-caret
toggle-class="text-decoration-none text-left dropdown-item rounded-0"
class="d-flex"
@show="isThemeDropdownVisible = true"
@hide="isThemeDropdownVisible = false"
@click.prevent.stop
>
<b-dropdown-item-button
v-for="theme in themes"
:key="theme.id"
:disabled="currentTheme === theme.id"
@click="saveThemeMode(theme.id)"
>
{{ theme.label }}
</b-dropdown-item-button>
</b-dropdown>
<b-dropdown-divider />
<b-dropdown-item-button
data-test-id="dropdown-profile-logout"
class="mt-2"
@click="$auth.logout()"
>
{{ labels.userSettingsLogout }}
</b-dropdown-item-button>
</b-dropdown>
</div>
</div>
</template>
<script>
import CNotificationButton from '../notifications/CNotificationButton.vue'
export default {
components: {
CNotificationButton,
},
props: {
expanded: {
type: Boolean,
default: false,
},
hideAppSelector: {
type: Boolean,
required: false,
default: false,
},
appSelectorURL: {
type: String,
default: '../',
},
settings: {
type: Object,
required: true,
},
labels: {
type: Object,
required: true,
},
},
data () {
return {
currentTheme: 'light',
isThemeDropdownVisible: false,
}
},
computed: {
userProfileURL () {
return this.$auth.cortezaAuthURL
},
changePasswordURL () {
return `${this.$auth.cortezaAuthURL}/change-password`
},
documentationURL () {
/* eslint-disable no-undef */
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
},
profileAvatarUrl () {
return `${this.$SystemAPI.baseURL}/attachment/avatar/${this.$auth.user.meta.avatarID}/original/profile-photo-avatar`
},
avatarExists () {
return this.$auth.user.meta.avatarID !== '0' && this.$auth.user.meta.avatarID
},
themes () {
return [
{
id: 'light',
label: this.labels.lightTheme,
},
{
id: 'dark',
label: this.labels.darkTheme,
},
]
},
},
watch: {
'$auth.user.meta.theme': {
immediate: true,
handler (theme) {
this.currentTheme = theme
},
},
},
methods: {
async saveThemeMode (theme) {
this.currentTheme = theme
this.$set(this.$auth.user.meta, 'theme', theme)
this.$SystemAPI.userUpdate(this.$auth.user).then(() => {
document.getElementsByTagName('html')[0].setAttribute('data-color-mode', theme)
}).catch(console.error)
},
preventDropdownClose (e) {
if (this.isThemeDropdownVisible) {
e.preventDefault()
}
},
},
}
</script>
<style lang="scss" scoped>
$nav-icon-size: calc(var(--topbar-height) - 24px);
$nav-user-icon-size: calc(var(--topbar-height) - 16px);
.nav-icon {
width: $nav-icon-size;
height: $nav-icon-size;
}
.nav-user-icon {
min-width: $nav-user-icon-size;
min-height: $nav-user-icon-size;
}
.header-navigation {
width: 100vw;
min-height: var(--topbar-height);
background-color: var(--topbar-bg);
.sidebar-spacer {
display: none;
min-width: calc(var(--sidebar-width) - 60px);
@media (min-width: 1024px) {
&.expanded {
display: block;
}
}
}
}
.avatar {
border-radius: 50%;
background-size: cover;
background-repeat: no-repeat;
background-position: center;
&:hover {
opacity: 0.8;
transition: opacity .25s ease-in-out;
-moz-transition: opacity .25s ease-in-out;
-webkit-transition: opacity .25s ease-in-out;
}
}
.title {
display: flex;
align-items: center;
min-height: $nav-user-icon-size;
padding-left: 47px;
> * {
padding: 0.25rem 0;
display: -webkit-box; /* For Safari and old versions of Chrome */
display: -ms-flexbox; /* For old versions of IE */
-webkit-box-orient: vertical; /* For Safari and old versions of Chrome */
-webkit-line-clamp: 3; /* Maximum number of lines to display */
line-clamp: 3; /* Maximum number of lines to display */
overflow: hidden;
text-overflow: ellipsis;
}
}
.tools-wrapper {
flex-grow: 1;
> * {
display: flex;
justify-content: end;
align-items: center;
flex-wrap: wrap;
}
}
</style>
<style lang="scss">
.topbar-dropdown-menu {
z-index: 1051;
}
#theme-dropleft {
.btn {
font-family: var(--font-regular);
}
}
</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,56 @@
<template>
<b-button
v-b-tooltip.hover="{ title: $t('notifications:title'), delay: { show: 500, hide: 0 } }"
variant="outline-extra-light"
size="lg"
class="nav-icon rounded-circle text-center border-0 d-flex align-items-center justify-content-center position-relative"
@click="toggleNotifications"
>
<font-awesome-icon
:icon="['far', 'bell']"
class="text-dark"
/>
<b-badge
v-if="unreadCount > 0 && !muted"
variant="primary"
pill
class="position-absolute notification-badge"
>
{{ unreadCount > 9 ? '9+' : unreadCount }}
</b-badge>
</b-button>
</template>
<script>
import { mapGetters, mapActions } from 'vuex'
export default {
name: 'CNotificationButton',
computed: {
...mapGetters({
unreadCount: 'notifications/unreadCount',
muted: 'notifications/muted',
}),
},
methods: {
...mapActions({
toggleVisibility: 'notifications/toggleVisibility',
}),
toggleNotifications () {
this.toggleVisibility()
},
},
}
</script>
<style lang="scss" scoped>
.notification-badge {
top: 0;
right: 0;
transform: translate(25%, -25%);
font-size: 0.7rem;
}
</style>

View File

@@ -0,0 +1,142 @@
<template>
<b-sidebar
v-model="isVisible"
header-class="d-flex align-items-center justify-content-between bg-white pr-2 pl-3 py-3"
body-class="d-flex flex-column overflow-hidden bg-white"
sidebar-class="notification-sidebar"
bg-variant="white"
:backdrop="isMobile"
backdrop-variant="white"
no-footer
right
shadow
no-close-on-route-change
no-close-on-esc
width="400px"
>
<template #header>
<h5
class="text-primary mb-0"
>
<b>{{ $t('title') }}</b>
</h5>
<b-button
v-b-tooltip.hover="{ title: $t('general:label.close'), delay: { show: 500, hide: 0 } }"
variant="outline-light"
class="d-flex align-items-center justify-content-center p-2 border-0 text-secondary"
@click="isVisible = false"
>
<font-awesome-icon
:icon="['fas', 'times']"
class="h6 mb-0"
/>
</b-button>
</template>
<notifications />
</b-sidebar>
</template>
<script lang="js">
import Notifications from './Notifications.vue'
import { mapGetters, mapMutations } from 'vuex'
import { throttle } from 'lodash'
export default {
i18nOptions: {
namespaces: 'notifications',
},
components: {
Notifications,
},
data () {
return {
isMobile: false,
}
},
computed: {
...mapGetters({
visible: 'notifications/visible',
}),
isVisible: {
get () {
return this.visible
},
set (visible) {
this.setVisible(visible)
},
},
},
watch: {
isVisible (visible) {
if (visible) {
this.$root.$emit('right-sidebar:opened', 'notifications')
}
},
},
created () {
this.$root.$on('right-sidebar:opened', this.handleSidebarOpened)
},
mounted () {
this.checkIfMobile()
window.addEventListener('resize', this.checkIfMobile)
},
beforeDestroy () {
this.$root.$off('right-sidebar:opened', this.handleSidebarOpened)
window.removeEventListener('resize', this.checkIfMobile)
},
methods: {
...mapMutations({
setVisible: 'notifications/setVisible',
}),
checkIfMobile: throttle(function () {
this.isMobile = window.innerWidth < 1024
}, 500),
handleSidebarOpened (name) {
if (name !== 'notifications') {
this.isVisible = false
}
},
},
}
</script>
<style lang="scss">
.b-sidebar-backdrop {
opacity: 0.75 !important;
}
@media (min-width: 1024px) {
.b-sidebar.notification-sidebar {
top: calc(var(--topbar-height) + 0.5rem) !important;
right: 0.5rem !important;
height: calc(100% - var(--topbar-height) - 1rem) !important;
border-radius: 1rem !important;
border: none !important;
z-index: 1048 !important;
.b-sidebar-header {
border-top-left-radius: 1rem !important;
border-top-right-radius: 1rem !important;
}
.b-sidebar-body {
border-bottom-left-radius: 1rem !important;
border-bottom-right-radius: 1rem !important;
}
}
}
</style>

View File

@@ -0,0 +1,147 @@
<template>
<div class="notification-item-container px-3 pt-3 pb-2 border-bottom">
<b-list-group-item
class="notification-item border rounded bg-white p-3 position-relative"
:class="{ 'read': notification.readAt }"
@click="$emit('click', notification)"
>
<div
class="action-menu bg-white pb-2 pl-2"
style="margin-left: -1rem;"
>
<b-dropdown
right
variant="outline-extra-light"
toggle-class="text-decoration-none border-0 dropdown-toggle-no-caret"
no-caret
>
<template #button-content>
<font-awesome-icon
:icon="['fas', 'ellipsis-v']"
class="text-secondary"
style="margin-top: 0.3rem;"
/>
</template>
<b-dropdown-item
v-if="!notification.readAt"
@click.stop="$emit('mark-read', notification)"
>
<font-awesome-icon
:icon="['far', 'envelope-open']"
class="text-primary"
/>
{{ $t('markAsRead') }}
</b-dropdown-item>
<b-dropdown-item
v-else
@click.stop="$emit('mark-unread', notification)"
>
<font-awesome-icon
:icon="['far', 'envelope']"
class="text-primary"
/>
{{ $t('markAsUnread') }}
</b-dropdown-item>
<b-dropdown-divider />
<c-input-confirm
:text="$t('delete')"
show-icon
borderless
variant="link"
size="md"
button-class="dropdown-item"
icon-class="text-danger"
class="w-100"
@confirmed="$emit('delete', notification)"
/>
</b-dropdown>
</div>
<component
:is="notificationComponent"
:notification="notification"
class="notification-item-content"
/>
</b-list-group-item>
<div class="d-flex justify-content-end mt-2">
<div
:title="notification.createdAt"
class="text-muted small cursor-pointer"
@click="$emit('click', notification)"
>
{{ notification.createdAt | locFullDateTime }}
</div>
</div>
</div>
</template>
<script>
import NotificationTypes from './types/index.js'
import CInputConfirm from '../input/CInputConfirm.vue'
export default {
i18nOptions: {
namespaces: 'notifications',
},
components: {
CInputConfirm,
},
props: {
notification: {
type: Object,
required: true,
},
},
computed: {
notificationComponent () {
let { kind } = this.notification
kind = kind.charAt(0).toUpperCase() + kind.slice(1)
return NotificationTypes[`Notification${kind}`]
},
},
}
</script>
<style lang="scss" scoped>
.notification-item-container {
&:hover {
background-color: var(--light) !important;
}
.notification-item {
transition: background-color 0.2s ease;
cursor: pointer;
&.read {
.notification-item-content {
opacity: 0.5 !important;
}
}
}
&:hover {
.action-menu {
opacity: 1 !important;
pointer-events: auto;
}
}
.action-menu {
position: absolute;
top: 0.5rem;
right: 0.5rem;
z-index: 2;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease;
}
}
</style>

View File

@@ -0,0 +1,308 @@
<template>
<div class="h-100 d-flex flex-column">
<b-tabs
v-model="activeTab"
card
fill
nav-class="border-bottom"
content-class="h-100 overflow-hidden"
class="h-100 d-flex flex-column"
>
<template #tabs-end>
<div
class="d-flex align-items-center justify-content-end"
style="min-width: 6rem;"
>
<!-- <b-button
v-if="activeTab === 1 && hasRead"
v-b-tooltip.hover
variant="outline-light"
class="p-2 border-0 d-flex align-items-center justify-content-center"
style="width: 2rem; height: 2rem;"
:title="$t('markAllAsUnread')"
@click="handleMarkAllAsUnread"
>
<font-awesome-icon
:icon="['far', 'envelope']"
class="h6 mb-0 text-primary"
/>
</b-button> -->
<b-button
v-if="hasUnread"
v-b-tooltip.hover="{ title: $t('markAllAsRead'), delay: { show: 500, hide: 0 } }"
variant="outline-light"
class="p-2 border-0 d-flex align-items-center justify-content-center"
style="width: 2rem; height: 2rem;"
@click="handleMarkAllAsRead"
>
<font-awesome-icon
:icon="['fas', 'check-double']"
class="h6 mb-0 text-primary"
/>
</b-button>
<b-button
v-b-tooltip.hover="{ title: $t(muted ? 'unmute' : 'mute'), delay: { show: 500, hide: 0 } }"
variant="outline-light"
class="p-2 border-0 d-flex align-items-center justify-content-center"
style="width: 2rem; height: 2rem;"
@click="toggleMuted"
>
<font-awesome-icon
:icon="['fas', muted ? 'bell-slash' : 'bell']"
class="h6 mb-0"
:class="{ 'text-secondary': muted, 'text-primary': !muted }"
/>
</b-button>
</div>
</template>
<b-tab
:title="$t('unread')"
active
class="d-flex flex-column h-100 p-0"
>
<div class="overflow-auto flex-grow-1 h-100">
<div
v-if="loading"
class="d-flex justify-content-center p-5"
>
<b-spinner variant="primary" />
</div>
<b-list-group v-else-if="notifications.length > 0">
<notification-item
v-for="notification in notifications"
:key="notification.notificationID"
:notification="notification"
@click="onNotificationClick(notification)"
@mark-read="onMarkAsRead"
@mark-unread="onMarkAsUnread"
@delete="onDeleteNotification"
/>
<div
v-if="hasMorePages"
class="text-center my-3"
>
<b-button
variant="outline-primary"
size="sm"
:disabled="loadingMore"
@click="loadMore()"
>
<b-spinner
v-if="loadingMore"
small
/>
<span v-else>{{ $t('loadMore') }}</span>
</b-button>
</div>
</b-list-group>
<div
v-else
class="text-center p-5"
>
<font-awesome-icon
:icon="['far', 'bell']"
class="text-secondary mb-3"
size="3x"
/>
<p class="text-secondary">
{{ $t('empty') }}
</p>
<p class="text-muted small">
{{ $t('emptyDescription') }}
</p>
</div>
</div>
</b-tab>
<b-tab
:title="$t('all')"
class="d-flex flex-column h-100 p-0"
>
<div class="overflow-auto flex-grow-1 h-100">
<div
v-if="loading"
class="d-flex justify-content-center p-5"
>
<b-spinner variant="primary" />
</div>
<b-list-group v-else-if="notifications.length > 0">
<notification-item
v-for="notification in notifications"
:key="notification.notificationID"
:notification="notification"
@click="onNotificationClick(notification)"
@mark-read="onMarkAsRead"
@delete="onDeleteNotification"
/>
<div
v-if="hasMorePages"
class="text-center my-3"
>
<b-button
variant="outline-primary"
size="sm"
:disabled="loadingMore"
@click="loadMore()"
>
<b-spinner
v-if="loadingMore"
small
/>
<span v-else>{{ $t('loadMore') }}</span>
</b-button>
</div>
</b-list-group>
<div
v-else
class="text-center p-5"
>
<font-awesome-icon
:icon="['far', 'bell']"
class="text-secondary mb-3"
size="3x"
/>
<p class="text-secondary">
{{ $t('empty') }}
</p>
<p class="text-muted small">
{{ $t('emptyDescription') }}
</p>
</div>
</div>
</b-tab>
</b-tabs>
</div>
</template>
<script>
import { mapGetters, mapActions } from 'vuex'
import NotificationItem from './NotificationItem.vue'
export default {
i18nOptions: {
namespaces: 'notifications',
},
components: {
NotificationItem,
},
data () {
return {
activeTab: 0,
loading: false,
loadingMore: false,
}
},
computed: {
...mapGetters({
notifications: 'notifications/notifications',
hasUnread: 'notifications/hasUnread',
hasRead: 'notifications/hasRead',
hasMorePages: 'notifications/hasMorePages',
muted: 'notifications/muted',
}),
},
watch: {
activeTab: {
handler () {
this.loading = true
this.setPageCursor(null)
this.loadNotifications()
.finally(() => {
setTimeout(() => {
this.loading = false
}, 300)
})
},
},
},
methods: {
...mapActions({
fetchNotifications: 'notifications/fetchNotifications',
markAsRead: 'notifications/markAsRead',
markAsUnread: 'notifications/markAsUnread',
markAllAsRead: 'notifications/markAllAsRead',
markAllAsUnread: 'notifications/markAllAsUnread',
deleteNotification: 'notifications/deleteNotification',
setPageCursor: 'notifications/setPageCursor',
toggleMuted: 'notifications/toggleMuted',
}),
onNotificationClick ({ notificationID, readAt }) {
if (!readAt) {
this.markAsRead(notificationID)
}
},
onMarkAsRead ({ notificationID }) {
this.markAsRead(notificationID)
},
onMarkAsUnread ({ notificationID }) {
this.markAsUnread(notificationID)
},
onDeleteNotification ({ notificationID }) {
this.deleteNotification(notificationID)
.then(() => {
this.toastSuccess(this.$t('notificationDeleted'))
})
.catch(() => {
this.toastError(this.$t('notificationDeletedError'))
})
},
handleMarkAllAsRead () {
this.markAllAsRead()
.then(() => {
this.toastSuccess(this.$t('allMarkedAsRead'))
})
.catch(() => {
this.toastError(this.$t('markAllAsReadError'))
})
},
// handleMarkAllAsUnread () {
// this.markAllAsUnread()
// .then(() => {
// this.toastSuccess(this.$t('allMarkedAsUnread'))
// })
// .catch(() => {
// this.toastError(this.$t('markAllAsUnreadError'))
// })
// },
loadNotifications () {
return this.fetchNotifications({ unreadOnly: this.activeTab === 0 })
},
loadMore () {
this.loadingMore = true
return this.loadNotifications()
.finally(() => {
setTimeout(() => {
this.loadingMore = false
}, 300)
})
},
},
}
</script>

Some files were not shown because too many files have changed in this diff Show More