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

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,181 @@
<template>
<div class="d-inline-flex gap-1">
<b-button
v-if="!inConfirmation"
v-b-tooltip.noninteractive.hover="{ title: tooltip, boundary: 'body' }"
:data-test-id="dataTestId"
:variant="variant"
:size="size"
:disabled="disabled || processing"
:class="`${buttonClass} ${borderless ? 'border-0' : ''} flex-fill`"
:style="buttonStyle"
@click.stop.prevent="onPrompt"
>
<b-spinner
v-if="processing"
data-test-id="spinner"
class="align-middle"
small
/>
<template v-else>
<font-awesome-icon
v-if="showIcon || !text"
:icon="icon"
:class="iconClass"
/>
<span
v-if="text"
:class="textClass"
>
{{ text }}
</span>
</template>
</b-button>
<template v-else>
<b-button
:data-test-id="`${dataTestId}-confirm`"
:variant="variantOk"
:size="sizeConfirm"
:disabled="okDisabled"
:class="[ borderless && 'border-0', 'flex-fill' ]"
@blur.prevent="onCancel()"
@click.prevent.stop="onConfirmation()"
>
<slot name="yes">
<font-awesome-icon
data-test-id="confirm"
:icon="['fas', 'check']"
/>
</slot>
</b-button>
<b-button
:data-test-id="`${dataTestId}-cancel`"
:variant="variantCancel"
:size="sizeConfirm"
:disabled="cancelDisabled"
:class="[ borderless && 'border-0', 'flex-fill' ]"
@click.prevent.stop="onCancel()"
>
<slot name="no">
<font-awesome-icon
data-test-id="reject"
:icon="['fas', 'times']"
/>
</slot>
</b-button>
</template>
</div>
</template>
<script lang="js">
export default {
props: {
disabled: Boolean,
okDisabled: Boolean,
cancelDisabled: Boolean,
noPrompt: Boolean,
processing: Boolean,
showIcon: Boolean,
icon: {
type: Array,
default: () => ['far', 'trash-alt'],
},
buttonClass: {
type: String,
default: '',
},
buttonStyle: {
type: Object,
default: () => ({}),
},
iconClass: {
type: String,
default: '',
},
textClass: {
type: String,
default: '',
},
borderless: {
type: Boolean,
default: true,
},
variant: {
type: String,
default: 'outline-danger',
},
size: {
type: String,
default: 'sm',
},
variantOk: {
type: String,
default: 'danger',
},
variantCancel: {
type: String,
default: 'light',
},
sizeConfirm: {
type: String,
default: 'sm',
},
tooltip: {
type: String,
default: '',
},
text: {
type: String,
default: '',
},
dataTestId: {
type: String,
default: 'button-delete',
},
},
data () {
return {
inConfirmation: false,
}
},
methods: {
onPrompt () {
if (this.noPrompt) {
this.$emit('confirmed')
} else {
this.inConfirmation = true
}
},
onConfirmation () {
this.inConfirmation = false
this.$emit('confirmed')
},
onCancel () {
this.inConfirmation = false
this.$emit('canceled')
},
},
}
</script>

View File

@@ -0,0 +1,157 @@
<template>
<div class="c-input-date-time d-flex flex-wrap w-100 gap-1">
<b-form-datepicker
v-if="!noDate"
v-model="date"
data-test-id="picker-date"
:placeholder="labels.none"
:date-format-options="{ year: 'numeric', month: 'numeric', day: 'numeric' }"
:min="minDate"
:max="maxDate"
:label-reset-button="labels.clear"
:label-today-button="labels.today"
:locale="browserLocale"
:start-weekday="weekStartDay"
label-help=""
today-variant="info"
selected-variant="extra-light"
boundary="window"
hide-header
reset-button
today-button
show-decade-nav
class="h-100 overflow-hidden"
/>
<b-form-timepicker
v-if="!noTime"
v-model="time"
data-test-id="picker-time"
:placeholder="labels.none"
:label-reset-button="labels.clear"
:label-now-button="labels.now"
:locale="browserLocale"
boundary="window"
hide-header
no-close-button
reset-button
now-button
class="h-100 overflow-hidden"
/>
<slot />
</div>
</template>
<script lang="js">
import { getDate, setDate, getTime, setTime } from './lib/index.ts'
import { shared } from '@cortezaproject/corteza-js'
const { getWeekStartDay } = shared
export default {
props: {
value: {
type: [String, Date],
required: false,
default: '',
},
noTime: {
type: Boolean,
default: false,
},
noDate: {
type: Boolean,
default: false,
},
onlyFuture: {
type: Boolean,
default: false,
},
onlyPast: {
type: Boolean,
default: false,
},
size: {
type: String,
default: 'md',
},
labels: {
type: Object,
required: true,
},
},
computed: {
date: {
get () {
return getDate(this.value)
},
set (date) {
this.$emit('input', setDate(date, this.value, this.noDate, this.noTime))
},
},
time: {
get () {
return getTime(this.value)
},
set (time) {
this.$emit('input', setTime(time, this.value, this.noDate, this.noTime))
},
},
minDate () {
return this.onlyFuture ? new Date() : undefined
},
maxDate () {
return this.onlyPast ? new Date() : undefined
},
browserLocale () {
return navigator.language || navigator.languages?.[0] || 'en-US'
},
weekStartDay () {
return getWeekStartDay(this.browserLocale)
},
},
}
</script>
<style lang="scss">
.c-input-date-time {
min-width: 120px;
.btn {
padding: 0.25rem 0.5rem;
}
label {
font-family: var(--font-regular);
color: var(--black) !important;
}
.b-form-datepicker, .b-form-timepicker {
flex: 1 0 130px;
}
.b-calendar-inner {
background-color: var(--white);
.b-calendar-grid-body {
height: 14rem;
display: flex;
flex-direction: column;
justify-content: center;
}
}
}
</style>

View File

@@ -0,0 +1,45 @@
import { expect } from 'chai'
import moment from 'moment'
import { getDate, setDate, getTime, setTime } from './index'
describe(__filename, () => {
describe('Can get and set time', () => {
it('getDate', () => {
expect(getDate(undefined)).to.equal(undefined)
expect(getDate('2022-01-01')).to.equal('2022-01-01')
expect(getDate('2022-01-01T12:00:00Z')).to.equal(moment.utc('2022-01-01T12:00:00Z').local().format('YYYY-MM-DD'))
expect(getDate('2021-12-31T23:00:00Z')).to.equal(moment.utc('2021-12-31T23:00:00Z').local().format('YYYY-MM-DD'))
})
it('setDate', () => {
// Should undefined if noDate or date is invalid
expect(setDate(undefined, '2022-01-01T12:00:00Z')).to.equal(undefined)
expect(setDate('2022-01-02', '2022-01-01T12:00:00Z', true)).to.equal(undefined)
// If noTime, return just date
expect(setDate('2022-01-02', '2022-01-01', false, true)).to.equal('2022-01-02')
expect(setDate('2022-01-02', '2022-01-01T12:00:00Z')).to.equal(moment(`2022-01-02 ${moment.utc('2022-01-01T12:00:00Z').local().format('HH:mm')}`, 'YYYY-MM-DD HH:mm').utc().format())
expect(setDate('2022-01-02', undefined)).to.equal(moment('2022-01-02 00:00', 'YYYY-MM-DD HH:mm').utc().format())
})
it('getTime', () => {
expect(getTime(undefined)).to.equal(undefined)
expect(getTime('12:00')).to.equal('12:00')
expect(getTime('2022-01-01T12:00:00Z')).to.equal(moment.utc('2022-01-01T12:00:00Z').local().format('HH:mm'))
expect(getTime('2021-12-31T23:00:00Z')).to.equal(moment.utc('2021-12-31T23:00:00Z').local().format('HH:mm'))
})
it('setTime', () => {
// Should undefined if noTime or time is invalid
expect(setTime(undefined, '2022-01-01T12:00:00Z')).to.equal(undefined)
expect(setTime('12:00', '2022-01-01T12:00:00Z', false, true)).to.equal(undefined)
// If noDate, return just time
expect(setTime('12:00', '15:00', true, false)).to.equal('12:00')
expect(setTime('12:00', '2022-01-01T12:00:00Z')).to.equal(moment(`${moment.utc('2022-01-01T12:00:00Z').local().format('YYYY-MM-DD')} 12:00`, 'YYYY-MM-DD HH:mm').utc().format())
expect(setTime('12:00', undefined)).to.equal(moment(`${moment().local().format('YYYY-MM-DD')} 12:00`, 'YYYY-MM-DD HH:mm').utc().format())
})
})
})

View File

@@ -0,0 +1,59 @@
import moment from 'moment'
export function getDate (value: string|undefined): string | undefined {
if (!value) {
return undefined
}
if (value === 'Invalid date') {
// Make sure this weird value does not cause us problems
return undefined
}
if (moment(value, 'YYYY-MM-DDTHH:mm:ssZ', true).isValid()) {
return moment.utc(value).local().format('YYYY-MM-DD')
}
return value
}
export function getTime (value: string|undefined): string | undefined {
if (!value) {
return undefined
}
if (moment(value, 'YYYY-MM-DDTHH:mm:ssZ', true).isValid()) {
return moment.utc(value).local().format('HH:mm')
}
return value
}
export function setDate (date: string|undefined, value: string|undefined, noDate = false, noTime = false): string | undefined {
if (noDate || !date || !date.length) {
return undefined
}
if (noTime) {
return moment(date, 'YYYY-MM-DD').format('YYYY-MM-DD')
}
const time = getTime(value) || '00:00'
return moment(date + ' ' + time, 'YYYY-MM-DD HH:mm').utc().format()
}
export function setTime (time: string|undefined, value: string|undefined, noDate = false, noTime = false): string | undefined {
if (noTime || !time || !time.length) {
return undefined
}
if (noDate) {
return moment(time, 'HH:mm').format('HH:mm')
}
// Default to today if date not provided
const date = getDate(value) || moment().local().format('YYYY-MM-DD')
return moment(date + ' ' + time, 'YYYY-MM-DD HH:mm').utc().format()
}

View File

@@ -0,0 +1,168 @@
<template>
<div
class="position-relative"
>
<c-ace-editor
v-model="editorValue"
auto-complete
init-expressions
:auto-complete-suggestions="autoCompleteSuggestions"
v-bind="{ ...$attrs, ...$props }"
v-on="$listeners"
/>
</div>
</template>
<script>
import CAceEditor from './CAceEditor.vue'
export default {
components: {
CAceEditor,
},
props: {
value: {
type: String,
default: '',
},
lang: {
type: String,
default: 'text',
},
minHeight: {
type: String,
default: '2.35rem',
},
showLineNumbers: {
type: Boolean,
default: false,
},
fontSize: {
type: String,
default: '14px',
},
border: {
type: Boolean,
default: true,
},
showPopout: {
type: Boolean,
default: false,
},
readOnly: {
type: Boolean,
default: false,
},
highlightActiveLine: {
type: Boolean,
default: false,
},
showPrintMargin: {
type: Boolean,
default: false,
},
suggestionParams: {
type: Array,
default: () => [],
},
fontFamily: {
type: String,
default: '',
},
placeholder: {
type: String,
default: '',
},
resizable: {
type: Boolean,
default: true,
},
},
computed: {
editorValue: {
get () {
return this.value
},
set (value = '') {
this.$emit('update:value', value)
},
},
autoCompleteSuggestions () {
return this.getRecordBasedSuggestions(this.suggestionParams)
},
},
methods: {
getRecordBasedSuggestions (params = []) {
const result = {}
function addSuggestion (key, caption, value) {
if (!result[key]) result[key] = []
result[key].push({ caption, value })
}
function processProperties (prefix, properties, interpolate) {
(properties || []).forEach((prop) => {
if (typeof prop === 'string') {
const value = prefix + '.' + prop + (interpolate ? '}' : '')
addSuggestion(prefix, prop, value)
} else {
const nestedPrefix = prefix + '.' + prop.value + '.'
addSuggestion(prefix, prop.value, nestedPrefix)
if (prop.properties) {
(prop.properties || []).forEach((nestedProp) => {
const nestedValue = nestedPrefix + nestedProp + (interpolate ? '}' : '')
addSuggestion(prefix + '.' + prop.value, nestedProp, nestedValue)
})
}
}
})
}
(params || []).forEach((p) => {
if (typeof p === 'string') {
addSuggestion('', '', p)
} else {
const { interpolate = false, properties = [], value, root = true } = p
const prefix = interpolate ? '${' : ''
const suffix = interpolate && !properties.length ? '}' : ''
const prefixAsValue = prefix + value + suffix + (properties.length > 0 ? '.' : '')
if (root) {
addSuggestion('', '', prefixAsValue)
}
if (interpolate) {
addSuggestion('$', prefixAsValue.slice(1), prefixAsValue)
addSuggestion('${', prefixAsValue.slice(2), prefixAsValue)
}
if (properties.length) {
processProperties(prefix + value, properties, interpolate)
}
}
})
return result
},
},
}
</script>

View File

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

View File

@@ -0,0 +1,40 @@
<template>
<b-button
:variant="variant"
:disabled="disabled || processing"
:size="size"
class="d-inline-flex align-items-center justify-content-center"
v-on="$listeners"
>
<slot />
<b-spinner
v-if="processing"
:processing="processing"
small
class="ml-1"
/>
</b-button>
</template>
<script>
export default {
props: {
variant: {
type: String,
default: 'primary',
},
size: {
type: String,
default: 'md',
},
processing: {
type: Boolean,
default: false,
},
disabled: {
type: Boolean,
default: false,
},
},
}
</script>

View File

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

View File

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

View File

@@ -0,0 +1,425 @@
<template>
<vue-select
v-bind="$attrs"
ref="vueSelect"
v-model="_value"
data-test-id="select"
:clearable="clearable"
:options="options"
:searchable="searchable"
:disabled="disabled"
:selectable="selectable"
:multiple="multiple"
:loading="loading"
:calculate-position="calculateDropdownPosition"
:append-to-body="appendToBody"
class="bg-white rounded"
:class="sizeClass"
@search="onSearch"
>
<template
v-for="(_, name) in $scopedSlots"
#[name]="data"
>
<slot
:name="name"
v-bind="data"
/>
</template>
<template
v-if="badge"
#option="option"
>
<span
class="badge badge-pill"
:style="getOptionStyle(option)"
>
{{ option.text }}
</span>
</template>
<template
v-if="!multiple && badge"
#selected-option="option"
>
<span
class="badge badge-pill"
:style="getOptionStyle(option)"
>
{{ option.text }}
</span>
</template>
<template
v-if="multiple && badge"
#selected-option-container="{ option, deselect }"
>
<span
class="d-flex align-items-center badge badge-pill mx-1 mt-1 w-auto"
:style="getOptionStyle(option)"
>
{{ option.text }}
<font-awesome-icon
:icon="['fas', 'times']"
class="pointer ml-2"
@click="deselect(option)"
/>
</span>
</template>
</vue-select>
</template>
<script>
import { VueSelect } from 'vue-select'
import { createPopper } from '@popperjs/core'
import 'vue-select/dist/vue-select.css'
export default {
name: 'CInputSelect',
components: {
VueSelect,
},
props: {
value: {
type: [String, Array, Object],
default: () => '',
},
options: {
type: Array,
default: () => {
return []
},
},
clearable: {
type: Boolean,
default: true,
},
searchable: {
type: Boolean,
default: true,
},
appendToBody: {
type: Boolean,
default: true,
},
defaultValue: {
type: [String, Array, Object],
default: () => '',
},
size: {
type: String,
default: 'md',
},
disabled: {
type: Boolean,
default: false,
},
selectable: {
type: Function,
default: o => !o.disabled,
},
multiple: {
type: Boolean,
default: false,
},
loading: {
type: Boolean,
default: false,
},
badge: {
type: Boolean,
default: false,
},
},
data () {
return {
query: '',
}
},
computed: {
_value: {
get () {
const fallbackValue = this.multiple ? [] : ''
return !!this.defaultValue && (this.value === this.defaultValue) ? fallbackValue : this.value
},
set (v) {
this.$emit('input', !v ? this.defaultValue : v)
},
},
sizeClass () {
return this.size === 'sm' ? 'c-input-sm' : this.size === 'lg' ? 'c-input-lg' : ''
},
},
methods: {
calculateDropdownPosition (dropdownList, component, { width }) {
/**
* We need to explicitly define the dropdown width since
* it is usually inherited from the parent with CSS.
*/
dropdownList.style.width = width
/**
* Here we position the dropdownList relative to the $refs.toggle Element.
*
* The 'offset' modifier aligns the dropdown so that the $refs.toggle and
* the dropdownList overlap by 1 pixel.
*
* The 'toggleClass' modifier adds a 'drop-up' class to the Vue Select
* wrapper so that we can set some styles for when the dropdown is placed
* above.
*/
const popper = createPopper(component.$refs.toggle, dropdownList, {
placement: 'bottom',
modifiers: [
{
name: 'offset',
options: {
offset: [0, -1],
},
},
{
name: 'toggleClass',
enabled: true,
phase: 'write',
fn ({ state }) {
component.$el.classList.toggle('drop-up', state.placement === 'top')
},
}],
})
/**
* To prevent memory leaks Popper needs to be destroyed.
* If you return function, it will be called just before dropdown is removed from DOM.
*/
return () => popper.destroy()
},
onSearch (query, loading) {
if (query !== this.query) {
this.query = query
}
this.$emit('search', query, loading)
},
getOptionStyle ({ style = {} } = {}) {
if (this.badge) {
style.fontSize = '0.9rem'
style.color = style.textColor || 'var(--dark)'
style.backgroundColor = style.backgroundColor || 'var(--extra-light)'
}
return style
},
},
}
</script>
<style lang="scss">
:root {
--vs-dropdown-bg: var(--white);
--vs-dropdown-option--active-bg: var(--light);
--vs-state-disabled-color: var(--secondary);
--vs-state-disabled-bg: var(--light);
--vs-colors--light: var(--black);
--vs-colors--dark: var(--black);
--vs-dropdown-option-color: var(--black);
--vs-dropdown-option--active-color: var(--black);
--vs-selected-bg: var(--extra-light);
--vs-search-input-color: var(--secondary);
--vs-search-input-bg: var(--white);
}
.v-select {
min-width: auto;
position: relative;
-ms-flex: 1 1 auto;
flex: 1 1 auto;
margin-bottom: 0;
font-size: .9rem !important;
font-family: var(--font-regular);
.vs__selected-options {
// do not allow growing
width: 0;
padding: 0;
}
.vs__selected {
display: flex;
white-space: nowrap;
text-overflow: ellipsis;
max-width: 100%;
overflow: hidden;
border: 0;
color: var(--black);
}
.vs__search {
font-size: .9rem;
border: 0;
padding: 0 2px;
padding-top: 0.375rem;
margin: 0;
}
&:not(.vs--open):not(.vs--loading) .vs__selected + .vs__search {
// force this to not use any space
// we still need it to be rendered for the focus
width: 0;
margin: 0;
border: none;
padding: 0;
height: 0;
}
.vs__dropdown-toggle {
min-height: calc(1.5em + 0.75rem + 4px);
padding: 0.375rem calc(0.75rem - 2px);
padding-top: 0 !important;
border-width: 2px;
border-color: var(--extra-light);
.vs__selected {
margin-top: 0.375rem;
}
.vs__actions {
padding-top: 0.375rem;
padding-right: 0;
}
}
.vs__clear,
.vs__open-indicator {
fill: var(--black);
display: inline-flex;
}
.vs__clear {
padding: 0;
border: 0;
background-color: transparent;
cursor: pointer;
margin-right: 8px
}
&.vs--single {
.vs__selected {
margin-left: 0;
margin-right: 0;
}
}
}
.vs--open {
.vs__dropdown-toggle {
border-color: var(--primary);
border-radius: 0.25rem !important;
}
}
.input-group > .v-select:not(:last-child) {
.vs__dropdown-toggle {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
}
.vs__spinner {
border: .7em solid var(--dark);
border-left-color: var(--white);
}
.vs__spinner, .vs__spinner::after {
width: 4em;
height: 4em;
}
.vs__dropdown-menu {
z-index: 1100;
.vs__dropdown-option {
&.vs__dropdown-option--selected {
background: var(--vs-dropdown-option--active-bg);
color: var(--vs-dropdown-option--active-color);
}
&.vs__dropdown-option--disabled {
cursor: var(--vs-state-disabled-cursor) !important;
opacity: 0.5;
}
&:active {
color: var(--white);
background-color: var(--primary);
}
}
}
.c-input-sm {
font-size: 0.7875rem !important;
.vs__search {
font-size: 0.7875rem;
padding-top: 0.25rem;
}
.vs__dropdown-toggle {
min-height: calc(1.5em + 0.5rem + 4px);
padding: 0.25rem calc(0.5rem - 2px);
border-radius: 0.2rem;
.vs__selected {
margin-top: 0.25rem;
}
.vs__actions {
padding-top: 0.25rem;
}
}
}
.c-input-lg {
font-size: 1.125rem !important;
.vs__search {
font-size: 1.125rem;
padding-top: .5rem;
}
.vs__dropdown-toggle {
min-height: calc(1.5em + 1rem + 4px);
padding: .5rem calc(1rem - 2px);
border-radius: 0.3rem;
.vs__selected {
margin-top: .5rem;
}
.vs__actions {
padding-top: .5rem;
}
}
}
</style>

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,30 @@
<template>
<b-button
variant="link"
class="text-dark font-weight-bold text-decoration-none"
@click="onClick(format.type, format.attrs)"
>
<span :class="activeClasses(format.attrs)">
<font-awesome-icon
v-if="format.icon"
:icon="format.icon"
/>
<span v-else>
{{ format.label }}
</span>
</span>
</b-button>
</template>
<script>
import base from './base.vue'
/**
* Component is used to display simple formatter options such as bold, italic, ...
*/
export default {
name: 'TMarkItem',
extends: base,
}
</script>

View File

@@ -0,0 +1,114 @@
<template>
<div id="link-popover-container">
<b-button
id="link-popover"
variant="link"
class="text-dark font-weight-bold text-decoration-none"
@click="showPopover"
>
<span :class="activeClasses(format.attrs)">
<font-awesome-icon icon="link" />
</span>
</b-button>
<b-popover
v-if="currentValue"
ref="popover"
:show.sync="visible"
triggers="focus"
target="link-popover"
placement="auto"
container="link-popover-container"
custom-class="bg-white"
>
<b-input-group style="min-width: 250px;">
<b-form-input
v-model="attrs.href"
type="url"
autofocus
:placeholder="labels.urlPlaceholder"
@keydown.enter.prevent.stop="link"
@keydown.esc.prevent.stop="close"
/>
<b-input-group-append>
<b-button
variant="outline-success"
@click="link"
>
{{ labels.ok }}
</b-button>
</b-input-group-append>
</b-input-group>
</b-popover>
</div>
</template>
<script>
import base from './base.vue'
/**
* Component is used to display link formatters. It provides an interface to
* input the URL that should be applied.
*/
export default {
name: 'TMarkLink',
extends: base,
props: {
labels: {
type: Object,
default: () => ({}),
},
},
data () {
return {
visible: false,
attrs: { href: null, target: '_self' },
}
},
computed: {
/**
* Does a simple check if entered URL is valid.
* @todo Improve this
* @returns {Boolean}
*/
urlValid () {
if (!this.attrs.href) {
return false
}
return !!this.attrs.href
},
},
methods: {
/**
* Helper to show the popup & determine if a link already exists
*/
showPopover () {
if (this.currentValue) {
this.visible = true
this.attrs = { ...this.getMarkAttrs(this.format.type) }
}
},
/**
* Helper to submit the given link
*/
link () {
this.onClick(this.format.type, this.attrs)
this.close()
},
/**
* Helper to close the popup & reset the state
*/
close () {
this.attrs.href = null
this.visible = false
},
},
}
</script>

View File

@@ -0,0 +1,57 @@
<script>
/**
* Defines common props, methods between different toolbar item types
*/
export default {
props: {
editor: {
type: Object,
required: true,
default: () => ({}),
},
format: {
type: Object,
required: true,
default: () => ({}),
},
isActive: {
type: Object,
required: false,
default: () => ({}),
},
getMarkAttrs: {
type: Function,
required: false,
default: () => {},
},
currentValue: {
type: String,
default: '',
},
},
methods: {
/**
* Helper method to emit format selection
* @param {String} type Format's type
* @param {Object} attrs Format's extra attributes
*/
onClick (type, attrs) {
this.$emit('click', { type, attrs })
},
/**
* Helper method to determine if given format is active or not.
* When attrs is provided, it will check for that exact match
* @param {Object|undefined} attrs Format's extra attributes
* @returns {Array|undefined}
*/
activeClasses (attrs) {
const isActive = this.editor.isActive(this.format.type, attrs)
if (isActive) {
return ['text-primary']
}
return undefined
},
},
}
</script>

View File

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

View File

@@ -0,0 +1,30 @@
<template>
<b-button
variant="link"
class="text-dark font-weight-bold text-decoration-none"
@click="onClick(format.type, format.attrs)"
>
<span :class="activeClasses(format.attrs)">
<font-awesome-icon
v-if="format.icon"
:icon="format.icon"
/>
<span v-else>
{{ format.label }}
</span>
</span>
</b-button>
</template>
<script>
import base from './base.vue'
/**
* Component is used to display node types; paragraph, headding, ...
*/
export default {
name: 'TNodeItem',
extends: base,
}
</script>

View File

@@ -0,0 +1,118 @@
<script>
import { nodeTypes } from '../../lib/formats'
/**
* Defines common props, methods between different toolbar item types
*/
export default {
props: {
editor: {
type: Object,
required: true,
default: () => ({}),
},
format: {
type: Object,
required: true,
default: () => ({}),
},
},
methods: {
/**
* Helper method to emit format selection
* @param {String} type Format's type
* @param {Object} attrs Format's extra attributes
*/
onClick (type, attrs) {
if (!attrs) {
attrs = {}
}
// manual toggling, since tiptap's isActive fails to resolve modified nodes
const act = this.activeNode([type], attrs)
if (act) {
type = 'paragraph'
}
const nn = this.activeNode(nodeTypes)
if (!nn) {
throw new Error('no node selected')
}
const n = nn.node
// preserve some attrs
const cAttr = n.attrs
const target = this.editor.schema.nodes[type]
const nAttrs = {}
const targetAttrs = (target && target.spec && target.spec.attrs) ? Object.keys(target.spec.attrs) : []
for (const a of targetAttrs) {
if (attrs[a] === undefined) {
nAttrs[a] = cAttr[a]
} else {
nAttrs[a] = attrs[a]
}
}
this.$emit('click', { type, attrs: nAttrs })
},
/**
* Helper to determine active nodes in the given selection.
* Replaces tiptap's built in isActive, since it can't handle our modified
* nodes.
*/
activeNodes (types, attrs) {
const ed = this.editor
const rtr = []
ed.state.doc.nodesBetween(
ed.state.selection.from,
ed.state.selection.to,
(n, pos) => {
if (types.includes(n.type.name)) {
if (attrs) {
if (!Object.entries(attrs || {}).find(([k, v]) => n.attrs[k] !== v)) {
rtr.push({ node: n, position: pos })
}
} else {
rtr.push({ node: n, position: pos })
}
}
},
)
return rtr
},
/**
* Helper to determine active node in the given selection
*/
activeNode (types, attrs) {
const ann = this.activeNodes(types, attrs)
if (!ann) {
return undefined
}
return ann[0]
},
isActiveCheck (types, attrs) {
return !!this.activeNode(types, attrs)
},
/**
* Helper method to determine if given format is active or not.
* When attrs is provided, it will check for that exact match
* @param {Object|undefined} attrs Format's extra attributes
* @returns {Array|undefined}
*/
activeClasses (attrs) {
if (this.isActiveCheck([this.format.type], attrs)) {
return ['text-primary']
}
return undefined
},
},
}
</script>

View File

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

View File

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

View File

@@ -0,0 +1,98 @@
<template>
<b-dropdown
menu-class="text-center bg-white"
variant="link"
boundary="window"
no-caret
>
<template #button-content>
<span class="text-dark font-weight-bold">
<font-awesome-icon
v-if="format.icon"
:icon="format.icon"
/>
<span v-else>
{{ format.label }}
</span>
</span>
</template>
<b-dropdown-item-button
v-for="v of format.variants"
:key="v.variant"
@click="emitClick(v)"
>
{{ v.label }}
</b-dropdown-item-button>
</b-dropdown>
</template>
<script>
import { nodeTypes } from '../../lib/formats'
import base from '../TNode/base.vue'
/**
* Component is used to display node alignment formatting
*/
export default {
name: 'TNattrTable',
extends: base,
props: {
isActive: {
type: Object,
required: false,
default: () => ({}),
},
},
methods: {
activeClasses (attrs) {
const an = this.activeNode(nodeTypes, attrs)
if (!an || !an.node) {
return undefined
}
const ac = (type, attrs) => {
return this.editor.isActive(type, attrs)
}
if (ac(an.node.type.name, { ...an.node.attrs, ...attrs })) {
return ['text-primary']
}
return undefined
},
/**
* dispatches node attr update for all affected nodes
* use a single transaction, so ctrl + z works as intended
*/
dispatchTransaction (v) {
const ann = this.activeNodes(nodeTypes)
const tr = this.editor.state.tr
for (const an of ann) {
tr.setNodeMarkup(an.position, an.node.type, { ...an.node.attrs, ...v.attrs })
}
this.editor.dispatchTransaction(tr)
},
emitClick (v) {
this.$emit('click', { type: v.type, attrs: { ...v.attrs } })
},
/**
* Helper method to determine if the root formater should be shown as active
* @returns {Array|undefined}
*/
rootActiveClasses (v) {
if (this.format.variants.find(({ type, attrs }) => this.activeClasses(attrs))) {
return ['text-primary']
}
},
},
}
</script>
<style lang="scss">
</style>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,185 @@
<template>
<b-card
:header-class="!hideToolbar ? 'p-0 border-bottom' : 'd-none'"
body-class="p-0"
class="border border-light rounded"
>
<template
v-if="editor && !hideToolbar"
#header
>
<r-toolbar
:editor="editor"
:formats="toolbar"
:labels="labels"
:current-value="currentValue"
/>
</template>
<editor-content
:editor="editor"
:class="bodyClass"
class="rt-editor-content rt-content p-2"
:style="{ minHeight: minBodyHeight, maxHeight: maxBodyHeight }"
@drop.native="onDrop"
@paste.native="onPaste"
@dragover.native.prevent
/>
</b-card>
</template>
<script>
import { Editor, EditorContent } from '@tiptap/vue-2'
import RToolbar from './RToolbar/index.vue'
import { getFormats, getToolbar } from './lib'
export default {
name: 'CRichTextInput',
components: {
EditorContent,
RToolbar,
},
props: {
value: {
type: String,
required: false,
default: null,
},
labels: {
type: Object,
default: () => ({}),
},
minBodyHeight: {
type: String,
default: '10rem',
},
maxBodyHeight: {
type: String,
default: '',
},
bodyClass: {
type: String,
default: '',
},
placeholder: {
type: String,
default: '',
},
hideToolbar: {
type: Boolean,
default: false,
},
},
data () {
return {
formats: getFormats({
placeholder: this.placeholder,
}),
toolbar: getToolbar(),
// Helper to determine if current content differs from prop's content
emittedContent: false,
editor: undefined,
currentValue: '',
}
},
watch: {
value: {
handler: function (val) {
// Update happened due to external content change, not model change
if (!this.emittedContent) {
this.editor.commands.setContent(val, false)
}
this.emittedContent = false
},
deep: true,
},
},
mounted () {
this.init()
},
beforeDestroy () {
if (this.editor) this.editor.destroy()
},
methods: {
/**
* Initialize the editor, state, ...
*/
init () {
this.editor = new Editor({
extensions: this.formats,
content: this.value || '',
parseOptions: {
preserveWhitespace: 'full',
},
onUpdate: this.onUpdate,
systemAPI: this.$SystemAPI,
})
},
/**
* Handle on update events. Process current document & update data model
* @note Currently, build-in toHTML function removes empty lines.
* Because of this, we are using `view.dom.innerHTML`. This should be improved at a later point
*/
onUpdate () {
// Add <br> tags to empty paragraphs
const editorValue = this.editor.getHTML().replace(/<p><\/p>/g, '<p><br></p>')
this.currentValue = editorValue === '<p><br></p>' ? '' : editorValue
this.emittedContent = true
this.$emit('input', this.currentValue)
},
focus () {
if (this.editor) {
this.editor.commands.focus()
}
},
onDrop (event) {
if (event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files.length > 0) {
event.preventDefault()
this.$emit('upload', event.dataTransfer.files)
}
},
onPaste (event) {
if (event.clipboardData && event.clipboardData.files && event.clipboardData.files.length > 0) {
event.preventDefault()
this.$emit('upload', event.clipboardData.files)
}
},
},
}
</script>
<style lang="scss">
.rt-editor-content {
height: 100%;
.ProseMirror {
height: 100%;
}
/* Make checkboxes only editable inside the editor */
input[type="checkbox"] {
pointer-events: auto !important;
cursor: pointer !important;
}
}
</style>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,18 @@
export { default as CInputConfirm } from './CInputConfirm.vue'
export { default as CInputProcessing } from './CInputProcessing.vue'
export { default as CInputDateTime } from './CInputDateTime/index.vue'
export { default as CInputPresort } from './CInputPresort.vue'
export { default as CInputSearch } from './CInputSearch.vue'
export { default as CRichTextInput } from './CRichTextInput/index.vue'
export { default as CInputCheckbox } from './CInputCheckbox.vue'
export { default as CInputColorPicker } from './CInputColorPicker.vue'
export { default as CAceEditor } from './CAceEditor.vue'
export { default as CInputExpression } from './CInputExpression.vue'
export {
CButtonSubmit,
} from './button'
export { default as CInputSelect } from './CInputSelect.vue'
export { default as CInputRole } from './CInputRole.vue'
export { default as CInputUser } from './CInputUser.vue'
export { default as CInputUserGroup } from './CInputUserGroup.vue'
export { default as CUserLabel } from './CUserLabel.vue'