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
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:
560
lib/vue/src/components/input/CAceEditor.vue
Normal file
560
lib/vue/src/components/input/CAceEditor.vue
Normal 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>
|
||||
69
lib/vue/src/components/input/CInputCheckbox.vue
Normal file
69
lib/vue/src/components/input/CInputCheckbox.vue
Normal 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>
|
||||
320
lib/vue/src/components/input/CInputColorPicker.vue
Normal file
320
lib/vue/src/components/input/CInputColorPicker.vue
Normal 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>
|
||||
181
lib/vue/src/components/input/CInputConfirm.vue
Normal file
181
lib/vue/src/components/input/CInputConfirm.vue
Normal 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>
|
||||
157
lib/vue/src/components/input/CInputDateTime/index.vue
Normal file
157
lib/vue/src/components/input/CInputDateTime/index.vue
Normal 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>
|
||||
@@ -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())
|
||||
})
|
||||
})
|
||||
})
|
||||
59
lib/vue/src/components/input/CInputDateTime/lib/index.ts
Normal file
59
lib/vue/src/components/input/CInputDateTime/lib/index.ts
Normal 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()
|
||||
}
|
||||
168
lib/vue/src/components/input/CInputExpression.vue
Normal file
168
lib/vue/src/components/input/CInputExpression.vue
Normal 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>
|
||||
220
lib/vue/src/components/input/CInputPresort.vue
Normal file
220
lib/vue/src/components/input/CInputPresort.vue
Normal 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>
|
||||
40
lib/vue/src/components/input/CInputProcessing.vue
Normal file
40
lib/vue/src/components/input/CInputProcessing.vue
Normal file
@@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<b-button
|
||||
:variant="variant"
|
||||
:disabled="disabled || processing"
|
||||
:size="size"
|
||||
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>
|
||||
130
lib/vue/src/components/input/CInputRole.vue
Normal file
130
lib/vue/src/components/input/CInputRole.vue
Normal 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>
|
||||
219
lib/vue/src/components/input/CInputSearch.vue
Normal file
219
lib/vue/src/components/input/CInputSearch.vue
Normal 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>
|
||||
425
lib/vue/src/components/input/CInputSelect.vue
Normal file
425
lib/vue/src/components/input/CInputSelect.vue
Normal 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>
|
||||
160
lib/vue/src/components/input/CInputUser.vue
Normal file
160
lib/vue/src/components/input/CInputUser.vue
Normal 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>
|
||||
122
lib/vue/src/components/input/CInputUserGroup.vue
Normal file
122
lib/vue/src/components/input/CInputUserGroup.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,5 @@
|
||||
import Item from './Item.vue'
|
||||
|
||||
export default {
|
||||
Item,
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,6 @@
|
||||
import Alignment from './Alignment.vue'
|
||||
import Table from './Table.vue'
|
||||
export default {
|
||||
Alignment,
|
||||
Table,
|
||||
}
|
||||
172
lib/vue/src/components/input/CRichTextInput/RToolbar/index.vue
Normal file
172
lib/vue/src/components/input/CRichTextInput/RToolbar/index.vue
Normal 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>
|
||||
@@ -0,0 +1,9 @@
|
||||
import mark from './TMark/loader'
|
||||
import node from './TNode/loader'
|
||||
import nodeAttr from './TNodeAttr/loader'
|
||||
|
||||
export default {
|
||||
mark,
|
||||
node,
|
||||
nodeAttr,
|
||||
}
|
||||
148
lib/vue/src/components/input/CRichTextInput/emoji/EmojiList.vue
Normal file
148
lib/vue/src/components/input/CRichTextInput/emoji/EmojiList.vue
Normal 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>
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as EmojiList } from './EmojiList.vue'
|
||||
export { default as suggestion } from './suggestion.js'
|
||||
123
lib/vue/src/components/input/CRichTextInput/emoji/suggestion.js
Normal file
123
lib/vue/src/components/input/CRichTextInput/emoji/suggestion.js
Normal 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()
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
185
lib/vue/src/components/input/CRichTextInput/index.vue
Normal file
185
lib/vue/src/components/input/CRichTextInput/index.vue
Normal 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>
|
||||
103
lib/vue/src/components/input/CRichTextInput/lib/formats.js
Normal file
103
lib/vue/src/components/input/CRichTextInput/lib/formats.js
Normal 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)
|
||||
2
lib/vue/src/components/input/CRichTextInput/lib/index.js
Normal file
2
lib/vue/src/components/input/CRichTextInput/lib/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './formats';
|
||||
|
||||
@@ -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>
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default as MentionList } from './MentionList.vue'
|
||||
export { default as suggestion } from './suggestion.js'
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
32
lib/vue/src/components/input/CUserLabel.vue
Normal file
32
lib/vue/src/components/input/CUserLabel.vue
Normal 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>
|
||||
87
lib/vue/src/components/input/button/CButtonSubmit.vue
Normal file
87
lib/vue/src/components/input/button/CButtonSubmit.vue
Normal 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>
|
||||
1
lib/vue/src/components/input/button/index.ts
Normal file
1
lib/vue/src/components/input/button/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as CButtonSubmit } from './CButtonSubmit.vue'
|
||||
18
lib/vue/src/components/input/index.ts
Normal file
18
lib/vue/src/components/input/index.ts
Normal 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'
|
||||
Reference in New Issue
Block a user