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,3 @@
> 1%
last 2 versions
not dead

View File

@@ -0,0 +1,16 @@
# EditorConfig is awesome: https://EditorConfig.org
# top-most EditorConfig file
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
# Tab indentation (no size specified)
[Makefile]
indent_style = tab

View File

@@ -0,0 +1,10 @@
module.exports = {
extends: ['corteza-client'],
parser: 'vue-eslint-parser',
parserOptions: {
parser: '@babel/eslint-parser',
requireConfigFile: false,
ecmaVersion: 2020,
sourceType: 'module',
},
}

20
client/web/compose/.gitignore vendored Normal file
View File

@@ -0,0 +1,20 @@
.DS_Store
node_modules
dist
*.log
# Editor directories and files
.idea
.vscode
*.iml
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw*
.nyc_output
coverage
/.act*
/workflow

View File

@@ -0,0 +1,42 @@
.PHONY: dep test build release upload
YARN_FLAGS ?= --non-interactive --no-progress --silent --emoji false
YARN = yarn $(YARN_FLAGS)
REPO_NAME ?= corteza-webapp-compose
BUILD_FLAVOUR ?= corteza
BUILD_FLAGS ?= --production
BUILD_DEST_DIR = dist
BUILD_TIME ?= $(shell date +%FT%T%z)
BUILD_VERSION ?= $(shell git describe --tags --abbrev=0)
BUILD_NAME = $(REPO_NAME)-$(BUILD_VERSION)
RELEASE_NAME = $(BUILD_NAME).tar.gz
RELEASE_EXTRA_FILES ?= README.md LICENSE CONTRIBUTING.md DCO
RELEASE_PKEY ?= .upload-rsa
dep:
$(YARN) install
test:
$(YARN) lint
$(YARN) test:unit
build:
export BUILD_VERSION=${BUILD_VERSION} && $(YARN) build $(BUILD_FLAGS)
release:
@ echo $(RELEASE_NAME)
@ cp $(RELEASE_EXTRA_FILES) $(BUILD_DEST_DIR)
@ tar -C $(BUILD_DEST_DIR) -czf $(RELEASE_NAME) $(dir $(BUILD_DEST_DIR))
upload: $(RELEASE_PKEY)
@ echo "put *.tar.gz" | sftp -q -o "StrictHostKeyChecking no" -i $(RELEASE_PKEY) $(RELEASE_SFTP_URI)
@ rm -f $(RELEASE_PKEY)
$(RELEASE_PKEY):
@ echo $(RELEASE_SFTP_KEY) | base64 -d > $(RELEASE_PKEY)
@ chmod 0400 $@

View File

@@ -0,0 +1,5 @@
module.exports = {
presets: [
['@vue/app', { useBuiltIns: 'entry' }],
],
}

View File

@@ -0,0 +1,36 @@
/** @type {import('jest').Config} */
module.exports = {
preset: '@vue/cli-plugin-unit-jest',
testMatch: [
'**/tests/unit/**/*.spec.js',
],
moduleNameMapper: {
'^corteza-webapp-compose/(.*)$': '<rootDir>/$1',
},
moduleFileExtensions: [
'js',
'jsx',
'json',
'vue',
],
transform: {
'^.+\\.vue$': '@vue/vue2-jest',
'.+\\.(css|styl|less|sass|scss|svg|png|jpg|ttf|woff|woff2)$': 'jest-transform-stub',
'^.+\\.jsx?$': 'babel-jest',
},
transformIgnorePatterns: [
'/node_modules/(?!(chai|sinon)/)',
],
snapshotSerializers: [
'jest-serializer-vue',
],
testEnvironment: 'jsdom',
testURL: 'http://localhost/',
watchPlugins: [
'jest-watch-typeahead/filename',
'jest-watch-typeahead/testname',
],
setupFiles: [
'<rootDir>/tests/setup.js',
],
}

View File

@@ -0,0 +1,104 @@
{
"name": "corteza-webapp-compose",
"description": "Corteza Compose WebApp",
"version": "2024.9.10",
"license": "Apache-2.0",
"contributors": [
"Denis Arh <denis.arh@gmail.com>"
],
"private": true,
"scripts": {
"serve": "vue-cli-service serve",
"dev-env": "vue-cli-service serve src/dev-env.js",
"build": "vue-cli-service build --mode production",
"lint": "vue-cli-service lint --fix",
"cdeps": "yarn link @cortezaproject/corteza-js @cortezaproject/corteza-vue",
"test:unit": "vue-cli-service test:unit",
"test": "yarn test:unit"
},
"gitHooks": {
"pre-commit": "yarn lint"
},
"stylelint": {
"plugins": [
"stylelint-scss"
],
"extends": "stylelint-config-standard",
"rules": {
"color-hex-length": null,
"no-empty-source": null
}
},
"resolutions": {
"cross-spawn": "^7.0.6",
"postcss": "^8.5.3"
},
"dependencies": {
"@cortezaproject/corteza-js": "^2024.9.10",
"@cortezaproject/corteza-vue": "2024.9.10",
"@fortawesome/fontawesome-svg-core": "^6.7.2",
"@fortawesome/free-regular-svg-icons": "^6.7.2",
"@fortawesome/free-solid-svg-icons": "^6.7.2",
"@fortawesome/vue-fontawesome": "^2.0.10",
"@fullcalendar/bootstrap": "^4.3.0",
"@fullcalendar/core": "^4.3.1",
"@fullcalendar/daygrid": "^4.3.0",
"@fullcalendar/list": "^4.3.0",
"@fullcalendar/timegrid": "^4.3.0",
"@fullcalendar/vue": "^4.3.1",
"@panter/vue-i18next": "^0.15.2",
"@popperjs/core": "^2.4.0",
"axios": "^1.8.3",
"bootstrap-vue": "2.23.1",
"compact-timezone-list": "^1.0.6",
"echarts": "5.6.0",
"file-saver": "^2.0.2",
"hex-rgb": "^4.1.0",
"i18next": "^23.16.8",
"i18next-browser-languagedetector": "^8.0.4",
"lodash": "^4.17.21",
"moment": "2.30.1",
"numeral": "^2.0.6",
"portal-vue": "^2.1.7",
"vue": "2.7.16",
"vue-echarts": "7.0.3",
"vue-grid-layout": "^2.3.3",
"vue-native-websocket": "^2.0.15",
"vue-router": "3.1.5",
"vue-sortable-tree": "^1.2.2",
"vue-tweet-embed": "^2.3.0",
"vuedraggable": "^2.24.3",
"vuex": "^3.6.2"
},
"devDependencies": {
"@babel/core": "^7.26.7",
"@babel/eslint-parser": "^7.26.5",
"@vue/cli-plugin-babel": "5.0.8",
"@vue/cli-plugin-eslint": "5.0.8",
"@vue/cli-plugin-unit-jest": "^5.0.8",
"@vue/cli-service": "5.0.8",
"@vue/eslint-config-standard": "^8.0.1",
"@vue/test-utils": "1.3.6",
"@vue/vue2-jest": "^27.0.0",
"babel-jest": "^27.1.0",
"chai": "^4.3.7",
"chai-as-promised": "^7.1.1",
"eslint": "^8.7.2",
"eslint-config-corteza-client": "file:../../../lib/eslint-client",
"eslint-plugin-vue": "^9.32.0",
"jest": "^27.1.0",
"null-loader": "^4.0.1",
"resolve-url-loader": "^5.0.0",
"rtlcss": "^4.3.0",
"sass": "^1.85.1",
"sass-loader": "^10",
"sinon": "^9.2.4",
"stylelint": "16.14.1",
"stylelint-config-standard": "^37.0.0",
"stylelint-scss": "^6.11.0",
"stylelint-webpack-plugin": "^5.0.1",
"vue-eslint-parser": "^9.4.3",
"vue-template-compiler": "2.7.16",
"webpack": "^5.97.1"
}
}

View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
autoprefixer: {},
rtlcss: {},
},
}

1
client/web/compose/public/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
config.js

View File

@@ -0,0 +1,18 @@
// Corteza API location
window.CortezaAPI = 'https://api.cortezaproject.your-domain.tld';
// CortezaAuth can be autoconfigured by replacing /api with /auth in CortezaAPI
// or by appending /auth to the end of CortezaAPI string
// When this is not possible and your configuration is more exotic you can set it
// explicitly:
// window.CortezaAuth = 'https://api.cortezaproject.your-domain.tld/auth';
// Configure CortezaWebapp when your web applications are not placed on the root.
// This is autoconfigured from the value of <base> tag href attribute in most cases.
// window.CortezaWebapp = 'https://cortezaproject.your-domain.tld';
// Set to true to enable i18next-pseudo
// Used to test translation string in a development environment
// Even if set to true, it will be disabled in production
window.i18nPseudoModeEnabled = false

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-0.0075 -0.0058 36.9075 38.5121" width="32px" height="32px">
<defs>
<style type="text/css">
.st0{fill:#95B4AF;}
</style>
</defs>
<path class="st0" d="M2.5,9.4c1.6-2.9,4-5.3,6.9-6.9c3-1.7,6.3-2.5,9.8-2.5c3.9-0.1,7.8,1.1,11.1,3.2c3.2,2.2,5.5,5.3,6.6,9h-8.7 c-0.8-1.7-2-3.1-3.6-4.1c-1.7-0.9-3.5-1.4-5.4-1.3c-2.1,0-4.1,0.5-5.9,1.5c-1.7,1-3.1,2.6-4,4.4c-1,2.1-1.5,4.3-1.4,6.6 c0,2.3,0.4,4.5,1.4,6.6c0.9,1.8,2.3,3.3,4,4.4c1.8,1,3.8,1.6,5.9,1.5c1.9,0,3.8-0.4,5.4-1.4c1.6-1,2.9-2.4,3.6-4.1h8.7 c-1.1,3.7-3.5,6.8-6.6,9c-3.3,2.2-7.2,3.3-11.1,3.2c-3.4,0-6.8-0.8-9.8-2.5c-2.9-1.6-5.2-4-6.9-6.8c-1.7-3-2.6-6.5-2.5-10 C-0.1,15.9,0.8,12.4,2.5,9.4z" id="object-0" style="fill: rgb(255, 150, 97);"/>
</svg>

After

Width:  |  Height:  |  Size: 792 B

View File

@@ -0,0 +1,35 @@
<!DOCTYPE html>
<html data-color-mode="light" lang="en">
<head>
<!--
base location is root by default for all webapps
href value should be modified when app is placed under subdir
-->
<base href="/" />
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" id="favicon" href="">
<link rel="stylesheet" type="text/css" href="<%= BASE_URL %>custom.css">
<link rel="preload" href="<%= BASE_URL %>config.js" as="script" />
<title><%= FLAVOUR %></title>
</head>
<body id="compose">
<noscript>
<strong>
JavaScript is disabled in your browser!
</strong>
<p>
We're sorry but Corteza doesn't work properly without JavaScript enabled.
Please enable it to continue.
</p>
</noscript>
<div id="app"></div>
<!-- built files will be auto injected -->
<script src="<%= BASE_URL %>config.js" type="text/javascript"></script>
<!-- contains cdns that will be injected at the head of the page -->
<script src="<%= BASE_URL %>code-snippets.js" type="text/javascript"></script>
</body>
</html>

View File

@@ -0,0 +1,231 @@
import Vue from 'vue'
import './config-check'
import './console-splash'
import './filters'
import './plugins'
import './mixins'
import './components'
import store from './store'
import router from './router'
import { compose } from '@cortezaproject/corteza-js'
import { mixins, corredor, websocket, i18n } from '@cortezaproject/corteza-vue'
const notProduction = (process.env.NODE_ENV !== 'production')
const verboseEventbus = window.location.search.includes('verboseEventbus')
export default (options = {}) => {
options = {
el: '#app',
name: 'compose',
template: '<router-view v-if="loaded && i18nLoaded" />',
mixins: [
mixins.corredor,
],
data: () => ({
loaded: false,
i18nLoaded: false,
}),
async created () {
this.$i18n.i18next.on('initialized', () => {
this.i18nLoaded = true
})
this.websocket()
return this.$auth.vue(this).handle().then(async ({ user }) => {
// switch the favicon based on the settings
await this.$Settings.init({ api: this.$SystemAPI }).then(() => {
const icon = this.$Settings.attachment('ui.iconLogo') || '/icon.svg'
const favicon = document.getElementById('favicon')
if (favicon) {
favicon.href = icon
}
})
// switch the page directionality on body based on language
document.body.setAttribute('dir', this.textDirectionality(user.meta.preferredLanguage))
if (user.meta.preferredLanguage) {
// After user is authenticated, get his preferred language
// and instruct i18next to change it
this.$i18n.i18next.changeLanguage(user.meta.preferredLanguage)
/**
* Let the API know what kind of language do we accept and send
*/
this.$ComposeAPI
.setHeader('Accept-Language', user.meta.preferredLanguage)
.setHeader('Content-Language', user.meta.preferredLanguage)
}
// switch the webapp theme based on user preference
if (user.meta.theme) {
document.getElementsByTagName('html')[0].setAttribute('data-color-mode', user.meta.theme)
}
// ref to vue is needed inside compose helper
// load and register bundle and list of client/server scripts
const bundleLoaderOpt = {
// Name of the bundle to load
bundle: 'compose',
// Debug logging
verbose: notProduction || verboseEventbus,
// Context for exec function (client scripts only!)
//
// Extended with additional helpers
ctx: new corredor.ComposeCtx(
{
$invoker: this.$auth.user,
authToken: this.$auth.accessToken,
},
this,
),
}
// Load all pending prompts:
this.$store.dispatch('wfPrompts/update')
// Load effective permissions
this.$store.dispatch('rbac/load')
// Initialize notifications
this.$store.dispatch('notifications/fetchNotifications')
// Initialize drafts store (loads from localStorage + backend, starts background sync)
this.$store.dispatch('drafts/init', {
composeAPI: this.$ComposeAPI,
systemAPI: this.$SystemAPI,
resourceType: 'compose:record',
})
this.loadBundle(bundleLoaderOpt)
.then(() => this.$ComposeAPI.automationList({ excludeInvalid: true }))
.then(this.makeAutomationScriptsRegistrator(
// compose specific handler that routes onManual events for server-scripts
// to the proper endpoint on the API
compose.TriggerComposeServerScriptOnManual(this.$ComposeAPI),
))
this.loaded = true
}).catch((err) => {
if (err instanceof Error && err.message === 'Unauthenticated') {
// user not logged-in,
// start with authentication flow
this.$auth.startAuthenticationFlow()
return
}
throw err
})
},
methods: {
/**
* Registers event listener for websocket messages and
* routes them depending on their type
*/
websocket () {
// cross-link auth & websocket so that ws can use the right access token
websocket.init(this)
// register event listener for workflow messages
this.$on('websocket-message', ({ data }) => {
const msg = JSON.parse(data)
switch (msg['@type']) {
case 'workflowSessionPrompt':
this.$store.dispatch('wfPrompts/new', msg['@value'])
break
case 'workflowSessionResumed':
this.$store.dispatch('wfPrompts/clear', msg['@value'])
break
case 'reminder':
this.$Reminder.enqueueRaw(msg['@value'])
break
case 'notification':
this.$store.dispatch('notifications/addNotification', msg['@value'])
break
case 'notification.read':
this.$store.dispatch('notifications/updateReadNotification', msg['@value'])
break
case 'notification.unread':
this.$store.dispatch('notifications/updateUnreadNotification', msg['@value'])
break
case 'notification.read.all':
this.$store.dispatch('notifications/updateAllReadNotifications', msg['@value'])
break
case 'notification.unread.all':
this.$store.dispatch('notifications/updateAllUnreadNotifications', msg['@value'])
break
case 'notification.delete':
this.$store.dispatch('notifications/removeNotification', msg['@value'])
break
case 'error':
this.toastDanger('Websocket message with error', msg['@value'])
}
})
},
},
router,
store,
i18n: i18n(Vue,
{ app: 'corteza-webapp-compose' },
'block',
'chart',
'drafts',
'field',
'general',
'module',
'namespace',
'navigation',
'notification',
'notifications',
'onboarding',
'page',
'permissions',
'preview',
'sidebar',
'resource-translator',
),
// Any additional options we want to merge
...options,
}
options.router.beforeEach((to, from, next) => {
store.dispatch('ui/setPreviousPage', from)
next()
})
const app = new Vue(options)
// Simple HMR acceptance
if (module.hot) {
module.hot.accept()
}
return app
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

View File

@@ -0,0 +1,18 @@
export const Content = require('./Content.png')
export const Chart = require('./Chart.png')
export const SocialFeed = require('./SocialFeed.png')
export const Record = require('./Record.png')
export const RecordList = require('./RecordList.png')
export const Automation = require('./Automation.png')
export const Calendar = require('./Calendar.png')
export const File = require('./File.png')
export const RecordOrganizer = require('./RecordOrganizer.png')
export const RecordRevisions = require('./RecordRevisions.png')
export const IFrame = require('./IFrame.png')
export const Metric = require('./Metric.png')
export const Comment = require('./Comment.png')
export const Report = require('./Report.png')
export const Progress = require('./Progress.png')
export const Geometry = require('./Geometry.png')
export const Tabs = require('./Tabs.png')
export const Navigation = require('./Navigation.png')

View File

@@ -0,0 +1,14 @@
export const rgbaRegex = /^rgba\((\d+),.*?(\d+),.*?(\d+),.*?(\d*\.?\d*)\)$/
const ln = (n) => Math.round(n < 0 ? 255 + n : (n > 255) ? n - 255 : n)
export const toRGBA = ([r, g, b, a]) =>
`rgba(${ln(r)}, ${ln(g)}, ${ln(b)}, ${a})`
export const removeDup = (set, key) => {
const exists = new Set()
return set.filter(s => {
const isN = !exists.has(s[key])
exists.add(s[key])
return isN
})
}

View File

@@ -0,0 +1,3 @@
export { default as FieldRowEdit } from './Module/FieldRowEdit.c3'
// disabled: uses store
// export { default as Tree } from './Page/Tree.c3'

View File

@@ -0,0 +1,24 @@
// eslint-disable-next-line
import { default as component } from './EditorToolbar.vue'
import { components } from '@cortezaproject/corteza-vue'
const { checkbox } = components.C3.controls
const props = {
backLink: {},
hideDelete: false,
hideSave: false,
disableSave: false,
}
export default {
name: 'Editor toolbar',
group: ['Admin'],
component,
props,
controls: [
checkbox('Hide delete', 'hideDelete'),
checkbox('Hide save', 'hideSave'),
checkbox('Disable save', 'disableSave'),
],
}

View File

@@ -0,0 +1,173 @@
<template>
<c-toolbar
data-test-id="editor-toolbar"
class="bg-white shadow border-top"
>
<template #start>
<b-button
data-test-id="button-back-without-save"
variant="outline-light"
:disabled="processing"
size="lg"
class="border-0 text-dark back"
@click="$emit('back')"
>
<font-awesome-icon
:icon="['fas', 'chevron-left']"
class="back-icon"
/>
{{ $t('label.backWithoutSave') }}
</b-button>
</template>
<template #center>
<slot />
</template>
<template #end>
<slot name="delete" />
<c-input-confirm
v-if="!hideDelete"
v-b-tooltip.noninteractive.hover
:disabled="disableDelete || processing"
:processing="processingDelete"
:text="$t('label.delete')"
size="lg"
size-confirm="lg"
variant="danger"
:title="deleteTooltip"
:borderless="false"
@confirmed="$emit('delete')"
/>
<slot name="saveAsCopy" />
<c-button-submit
v-if="!hideClone"
data-test-id="button-clone"
:disabled="disableClone || disableSave || processing"
:title="cloneTooltip"
:processing="processingClone"
variant="light"
:text="$t('label.saveAsCopy')"
size="lg"
class="text-nowrap"
@submit="$emit('clone')"
/>
<c-button-submit
v-if="!hideSave"
data-test-id="button-save-and-close"
:disabled="disableSave || processing"
:processing="processingSaveAndClose"
variant="light"
:text="$t('label.saveAndClose')"
size="lg"
class="text-nowrap"
@submit="$emit('saveAndClose')"
/>
<c-button-submit
v-if="!hideSave"
data-test-id="button-save"
:disabled="disableSave || processing"
:processing="processingSave"
:text="$t('label.save')"
size="lg"
@submit="$emit('save')"
/>
</template>
</c-toolbar>
</template>
<script>
import { components } from '@cortezaproject/corteza-vue'
const { CToolbar } = components
export default {
i18nOptions: {
namespaces: 'general',
},
components: {
CToolbar,
},
inheritAttrs: true,
props: {
processing: {
type: Boolean,
default: false,
},
processingSave: {
type: Boolean,
default: false,
},
processingSaveAndClose: {
type: Boolean,
default: false,
},
processingClone: {
type: Boolean,
default: false,
},
processingDelete: {
type: Boolean,
},
backLink: {
type: Object,
required: false,
default: undefined,
},
hideDelete: {
type: Boolean,
required: false,
},
hideSave: {
type: Boolean,
required: false,
},
hideClone: {
type: Boolean,
required: false,
},
disableDelete: {
type: Boolean,
required: false,
default: false,
},
disableSave: {
type: Boolean,
required: false,
default: false,
},
disableClone: {
type: Boolean,
default: false,
},
deleteTooltip: {
type: String,
required: false,
default: '',
},
cloneTooltip: {
type: String,
default: '',
},
},
}
</script>

View File

@@ -0,0 +1,46 @@
<template>
<b-button
v-if="list.length > 0"
data-test-id="button-export"
variant="light"
size="lg"
@click="jsonExport(list, type)"
>
{{ $t('label.export') }}
</b-button>
</template>
<script>
import { saveAs } from 'file-saver'
import { mapActions } from 'vuex'
export default {
i18nOptions: {
namespaces: 'general',
},
props: {
list: {
type: Array,
required: true,
},
type: {
type: String,
required: true,
},
},
methods: {
...mapActions({
findModuleByID: 'module/findByID',
}),
jsonExport (list, type) {
Promise.all(list.map(i => i.export(this.findModuleByID))).then(list => {
const blob = new Blob([JSON.stringify({ type, list }, null, 2)], { type: 'application/json' })
saveAs(blob, `${type}-export.json`)
})
},
},
}
</script>

View File

@@ -0,0 +1,200 @@
<template>
<div class="d-flex">
<b-btn
variant="light"
size="lg"
class="flex-fill"
@click="showModal=true"
>
{{ $t('label.import') }}
</b-btn>
<b-modal
v-model="showModal"
size="lg"
:title="$t('label.import')"
>
<b-input-group>
<!-- To handle file upload -->
<template v-if="!importObj">
<b-form-file
:placeholder="$t('label.importPlaceholder')"
:browse-text="$t('label.browse')"
@change="loadFile"
/>
<h6
v-if="processing"
class="my-auto ml-3 "
>
{{ $t('label.processing') }}
</h6>
</template>
<!-- To confirm selection & import -->
<template v-else>
<b-container class="p-0">
<b-row
no-gutters
class="mb-3"
>
<b-button
variant="light"
@click="selectAll(true)"
>
{{ $t('field.selectAll') }}
</b-button>
<b-button
class="ml-2"
variant="light"
@click="selectAll(false)"
>
{{ $t('field.unselectAll') }}
</b-button>
</b-row>
<b-row no-gutters>
<b-col
v-for="(o, index) in importObj.list"
:key="index"
cols="12"
md="6"
lg="4"
>
<b-form-checkbox v-model="o.import">
{{ o.name || o.title }}
</b-form-checkbox>
</b-col>
</b-row>
</b-container>
</template>
</b-input-group>
<div slot="modal-footer">
<b-button
:disabled="!importObj || !importObj.list.filter(i => i.import).length > 0"
variant="primary"
@click="jsonImport(importObj)"
>
{{ $t('label.import') }}
</b-button>
</div>
</b-modal>
</div>
</template>
<script>
import { mapGetters } from 'vuex'
import { compose } from '@cortezaproject/corteza-js'
export default {
i18nOptions: {
namespaces: 'general',
},
props: {
namespace: {
type: Object,
required: true,
},
type: {
type: String,
required: true,
},
},
data () {
return {
showModal: false,
importObj: null,
processing: false,
classes: {
module: compose.Module,
chart: compose.Chart,
},
}
},
computed: {
...mapGetters({
modules: 'module/set',
}),
},
methods: {
async jsonImport ({ list, type }) {
this.processing = true
const { namespaceID } = this.namespace
const ItemClass = this.classes[type]
try {
for (let item of list.filter(i => i.import)) {
if (this.importObj) {
item = new ItemClass(item).import(this.getModuleID)
item.namespaceID = namespaceID
await this.$store.dispatch(`${this.type}/create`, item)
} else {
break
}
}
this.$emit('importSuccessful')
} catch (e) {
this.toastErrorHandler(this.$t('notification:general.import.failed'))(e)
}
this.cancelImport()
},
getModuleID (moduleName) {
// find module, replaceID
const matchedModules = this.modules.filter(m => m.name === moduleName)
if (matchedModules.length > 0) {
return matchedModules[0].moduleID
} else {
return null
}
},
selectAll (selectAll) {
this.importObj.list = this.importObj.list.map(i => {
i.import = selectAll && true
return i
})
},
cancelImport () {
this.importObj = null
this.processing = false
this.showModal = false
},
loadFile (e = {}) {
const { files = [] } = (e.type === 'drop' ? e.dataTransfer : e.target) || {}
if (files[0]) {
this.processing = true
const reader = new FileReader()
reader.readAsText(files[0])
reader.onload = (evt) => {
try {
this.importObj = JSON.parse(evt.target.result)
if (!this.importObj.list) {
throw new Error(this.$t('notification:general.import.readingError'))
} else {
this.importObj.list = this.importObj.list.map(i => {
return { import: true, ...i }
})
}
} catch (err) {
this.toastErrorHandler(this.$t('notification:general.import.failed'))(err)
this.importObj = null
} finally {
this.processing = false
}
}
reader.onerror = (evt) => {
this.toastErrorHandler(this.$t('notification:general.import.readingError'))
this.processing = false
}
}
},
},
}
</script>

View File

@@ -0,0 +1,189 @@
<template>
<b-row
cols="12"
class="mb-2"
>
<b-col
cols="3"
align-self="center"
>
<b-form-checkbox
v-if="allowOmitStrategy"
v-model="use"
:disabled="disabled"
>
{{ label }}
</b-form-checkbox>
<div
v-else
class="font-weight-bold"
>
{{ label }}
</div>
</b-col>
<b-col
cols="3"
>
<c-input-select
v-show="strategy !== 'omit'"
v-model="strategy"
:options="strategies"
:disabled="!use"
label="text"
:reduce="strategy => strategy.value"
:clearable="false"
size="sm"
/>
</b-col>
<b-col
v-if="strategy === ''"
cols="6"
>
<b-form-input
:value="storeIdent"
:placeholder="$t('ident.placeholder')"
size="sm"
readonly
/>
</b-col>
<b-col
v-else-if="showIdentInput"
cols="6"
>
<b-form-input
v-model="draft.ident"
:placeholder="$t('ident.placeholder')"
:disabled="disableIdentInput"
size="sm"
/>
</b-col>
</b-row>
</template>
<script>
import { defaultConfigDraft, types } from './encoding-strategy'
export default {
i18nOptions: {
namespaces: 'module',
keyPrefix: 'edit.config.dal.encoding-strategy',
},
props: {
config: {
type: Object,
required: true,
},
field: {
type: String,
required: true,
},
label: {
type: String,
required: true,
},
isMulti: {
type: Boolean,
default: false,
},
// default store-ident
storeIdent: {
type: String,
required: true,
},
defaultStrategy: {
type: String,
default: types.Plain,
},
allowOmitStrategy: {
type: Boolean,
default: true,
},
disabled: {
type: Boolean,
default: false,
},
},
data () {
return {
// holds working copy of strategy config
draft: defaultConfigDraft(this.config, this.storeIdent),
// strategy before omit
undoOmit: this.defaultStrategy,
}
},
computed: {
strategies () {
return [
{ value: types.Plain, text: this.$t('strategies.plain.label'), disabled: this.isMulti },
{ value: types.Alias, text: this.$t('strategies.alias.label'), disabled: this.isMulti },
{ value: types.JSON, text: this.$t('strategies.json.label') },
].filter(({ disabled }) => !disabled)
},
showIdentInput () {
return [types.JSON, types.Alias, types.Plain].includes(this.strategy)
},
disableIdentInput () {
return [types.Plain].includes(this.strategy)
},
// current strategy
strategy: {
get () {
// iterate over all types and return the first one that matches
for (const t of Object.values(types)) {
if (this.config[t] === undefined) {
continue
}
return t
}
return this.defaultStrategy
},
set (strategy) {
this.$emit('change', { strategy, config: this.draft })
},
},
// use => when field is not used it is omitted
use: {
get () {
return this.strategy !== types.Omit
},
set (use) {
if (this.strategy !== types.Omit) {
this.undoOmit = this.strategy
}
this.strategy = use ? this.undoOmit : types.Omit
},
},
},
watch: {
draft: {
deep: true,
handler (config) {
this.$emit('change', { strategy: this.strategy, config })
},
},
},
}
</script>

View File

@@ -0,0 +1,348 @@
<template>
<b-modal
v-model="showModal"
:title="$t('title')"
size="xl"
body-class="p-0 border-top-0 position-relative"
header-class="p-3 pb-0 border-bottom-0"
no-fade
v-on="$listeners"
>
<b-table-simple
borderless
sticky-header
responsive
hover
head-variant="light"
class="mb-0"
style="min-height: 300px; max-height: 75vh;"
>
<b-thead>
<b-tr>
<b-th>
{{ $t('columns.alteration') }}
</b-th>
<b-th style="max-width: 300px;">
{{ $t('columns.change') }}
</b-th>
<b-th
class="text-center"
>
{{ $t('columns.status') }}
</b-th>
<b-th style="min-width: 200px;" />
</b-tr>
</b-thead>
<b-tbody v-if="sortedAlterations.length && !loading">
<b-tr
v-for="a in sortedAlterations"
:key="a.alterationID"
class="border-top"
:class="{ 'bg-extra-light': a.alterationID === dependOnHover }"
@mouseover="dependOnHover = a.dependsOn"
@mouseleave="dependOnHover = undefined"
>
<b-td>
{{ a.alterationID }}
</b-td>
<b-td>
{{ stringifyParams(a.params) }}
</b-td>
<b-td class="text-center align-top">
<b-badge
v-if="a.error"
variant="danger"
>
{{ a.error || '' }}
</b-badge>
<b-badge
v-else-if="a.completedAt"
variant="success"
>
{{ $t('resolved') }}
</b-badge>
<b-badge
v-else-if="a.dependsOn"
variant="extra-light"
>
{{ $t('waitingFor', { id: a.dependsOn }) }}
</b-badge>
</b-td>
<b-td class="text-right">
<b-spinner
v-if="a.processing"
variant="primary"
small
/>
<template v-else>
<c-input-confirm
v-if="!a.completedAt"
:disabled="!canResolve(a) || a.processing || processing"
:text="$t('resolve')"
variant="primary"
size="sm"
class="mx-1"
@click.stop
@confirmed="onResolve(a)"
/>
<c-input-confirm
v-if="!a.completedAt"
:disabled="!canDismiss(a) || a.processing || processing"
:text="$t('dismiss')"
variant="light"
size="sm"
class="mx-1"
@click.stop
@confirmed="onDismiss(a)"
/>
</template>
</b-td>
</b-tr>
</b-tbody>
<div
v-else
class="position-absolute text-center mt-5 d-print-none"
style="left: 0; right: 0;"
>
<b-spinner
v-if="loading"
/>
<p
v-else-if="!sortedAlterations.length"
class="mb-0 mx-2"
>
{{ $t('noAlterations') }}
</p>
</div>
</b-table-simple>
<template #modal-footer>
<b-button
:disabled="processing"
class="text-primary border-0"
variant="light"
@click="showModal = false"
>
{{ canResolveAlterations ? $t('general:label.cancel') : $t('general:label.close') }}
</b-button>
<c-input-confirm
v-if="canResolveAlterations"
:text="$t('resolveAuto')"
:processing="processing"
variant="primary"
:disabled="processing"
size="md"
@confirmed="onResolve()"
/>
</template>
</b-modal>
</template>
<script>
import { compose } from '@cortezaproject/corteza-js'
export default {
i18nOptions: {
namespaces: 'module',
keyPrefix: 'edit.schemaAlterations',
},
props: {
modal: {
type: Boolean,
required: false,
},
module: {
type: compose.Module,
required: true,
},
batch: {
type: Array,
required: false,
default: undefined,
},
},
data () {
return {
showModal: false,
loading: false,
processing: false,
dependOnHover: undefined,
alterations: [],
alterationProcessing: {},
}
},
computed: {
sortedAlterations () {
return this.alterations.toSorted((a, b) => (a.batchID || '').localeCompare(b.batchID || '') || (a.dependsOn || '').localeCompare(b.dependsOn || ''))
},
canResolveAlterations () {
return this.sortedAlterations.some(this.canResolve)
},
},
watch: {
batch: {
handler (batch) {
if (batch && batch.length) {
this.load(...batch)
}
},
},
},
methods: {
async onDismiss (alteration) {
this.processing = true
alteration = alteration ? [alteration] : this.alterations
const alterationID = []
for (const a of alteration) {
alterationID.push(a.alterationID)
a.processing = true
}
this.$SystemAPI.dalSchemaAlterationDismiss({ alterationID }).then(() => {
this.toastSuccess(this.$t('notification:module.schemaAlterations.dismiss.success'))
}).catch(this.toastErrorHandler(this.$t('notification:module.schemaAlterations.dismiss.error')))
.finally(() => {
for (const a of alteration) {
a.processing = false
}
this.load(...this.batch)
this.processing = false
})
},
async onResolve (alteration) {
this.processing = true
alteration = alteration ? [alteration] : this.alterations
const alterationID = []
for (const a of alteration) {
alterationID.push(a.alterationID)
a.processing = true
}
this.$SystemAPI.dalSchemaAlterationApply({ alterationID }).then(() => {
this.toastSuccess(this.$t('notification:module.schemaAlterations.resolve.success'))
}).catch(this.toastErrorHandler(this.$t('notification:module.schemaAlterations.resolve.error')))
.finally(() => {
for (const a of alteration) {
a.processing = false
}
this.load(...this.batch)
this.processing = false
})
},
async load (...batchID) {
if (!batchID || (batchID && !batchID.length)) {
this.alterations = []
return
}
this.loading = true
return this.$SystemAPI.dalSchemaAlterationList({ batchID }).then(({ set }) => {
this.alterations = set
if (this.alterations.length) {
this.showModal = true
}
}).catch(this.toastErrorHandler(this.$t('notification:module.schemaAlterations.load.error')))
.finally(() => {
this.loading = false
})
},
stringifyParams (params) {
switch (true) {
case !!params.attributeAdd:
return this.stringifyAttributeAddParams(params.attributeAdd)
case !!params.attributeDelete:
return this.stringifyAttributeDeleteParams(params.attributeDelete)
case !!params.attributeReType:
return this.stringifyAttributeReTypeParams(params.attributeReType)
case !!params.attributeReEncode:
return this.stringifyAttributeReEncodeParams(params.attributeReEncode)
case !!params.modelAdd:
return this.stringifyModelAddParams(params.modelAdd)
case !!params.modelDelete:
return this.stringifyModelDeleteParams(params.modelDelete)
}
throw new Error('Unknown alteration type')
},
stringifyAttributeAddParams ({ attr = {} }) {
return this.$t('params.attribute.add', { ident: attr.ident, storeType: attr.store.type, attrType: attr.type.type })
},
stringifyAttributeDeleteParams ({ attr = {} }) {
return this.$t('params.attribute.delete', { ident: attr.ident, storeType: attr.store.type })
},
stringifyAttributeReTypeParams ({ attr = {}, to = {} }) {
return this.$t('params.attribute.reType', { ident: attr.ident, toType: to.type })
},
stringifyAttributeReEncodeParams ({ attr = {}, to = {} }) {
return this.$t('params.attribute.reEncode', { ident: attr.ident, toType: to.type })
},
stringifyModelAddParams ({ model = {} }) {
return this.$t('params.model.add', { ident: model.ident })
},
stringifyModelDeleteParams ({ model = {} }) {
return this.$t('params.model.delete', { ident: model.ident })
},
canDismiss (alteration) {
if (alteration.completedAt) {
return false
}
if (alteration.dependsOn) {
return this.alterations.some(a => a.alterationID === alteration.dependsOn && !a.completedAt)
}
return true
},
canResolve (alteration) {
return this.canDismiss(alteration)
},
},
}
</script>

View File

@@ -0,0 +1,279 @@
<template>
<div
v-if="module"
>
<b-form-group
:label="$t('connection.label')"
:description="$t('connection.description')"
label-class="text-primary"
>
<c-input-select
v-model="module.config.dal.connectionID"
:options="connections"
:get-option-key="getOptionKey"
:disabled="processing"
:clearable="false"
:reduce="s => s.connectionID"
:placeholder="$t('connection.placeholder')"
:get-option-label="getConnectionLabel"
/>
</b-form-group>
<b-form-group
:label="$t('ident.label')"
:description="$t('ident.description', { interpolation: { prefix: '{{{', suffix: '}}}' } })"
label-class="text-primary"
>
<b-form-input
v-model="module.config.dal.ident"
:placeholder="$t('ident.placeholder')"
/>
</b-form-group>
<b-form-group
:label="$t('module-fields.label')"
:description="$t('module-fields.description')"
label-class="text-primary"
>
<dal-field-store-encoding
v-for="({ field, storeIdent, label, isMulti }, i) in moduleFields"
:key="i"
:config="moduleFieldEncoding[field] || {}"
:field="field"
:label="label"
:is-multi="isMulti"
:default-strategy="moduleFieldDefaultEncodingStrategy"
:store-ident="storeIdent"
@change="applyModuleFieldStrategyConfig(field, $event)"
/>
</b-form-group>
<b-form-group
:label="$t('system-fields.label')"
:description="$t('system-fields.description')"
label-class="text-primary"
>
<div class="d-flex justify-content-end align-items-center flex-wrap">
<b-form-radio-group
v-model="selectedGroup"
buttons
button-variant="outline-secondary"
size="sm"
name="buttons"
:options="optionsGroups"
class="mb-3"
@change="applySelectedSystemFields"
/>
</div>
<dal-field-store-encoding
v-for="({ field, storeIdent, label, disabled }, i) in systemFields"
:key="i"
:config="systemFieldEncoding[field] || {}"
:field="field"
:label="label"
:store-ident="storeIdent"
:allow-omit-strategy="true"
:disabled="disabled"
@change="applySystemFieldStrategyConfig(field, $event)"
/>
</b-form-group>
</div>
</template>
<script>
import { compose, NoID } from '@cortezaproject/corteza-js'
import { moduleFieldStrategyConfig, systemFieldStrategyConfig, types } from './encoding-strategy'
import DalFieldStoreEncoding from 'corteza-webapp-compose/src/components/Admin/Module/DalFieldStoreEncoding'
const PrimaryConnType = 'corteza::system:primary-dal-connection'
export default {
i18nOptions: {
namespaces: 'module',
keyPrefix: 'edit.config.dal',
},
components: {
DalFieldStoreEncoding,
},
props: {
module: {
type: compose.Module,
required: true,
},
},
data () {
const systemFieldEncoding = this.module.config.dal.systemFieldEncoding || {}
const systemFields = [
{ field: 'id', storeIdent: 'id', disabled: true },
{ field: 'namespaceID', storeIdent: 'rel_namespace', group: 'partition' },
{ field: 'moduleID', storeIdent: 'rel_module', group: 'partition' },
{ field: 'revision', storeIdent: 'revision', group: 'extras' },
{ field: 'meta', storeIdent: 'meta', group: 'extras' },
{ field: 'ownedBy', storeIdent: 'owned_by', group: 'user_reference' },
{ field: 'createdAt', storeIdent: 'created_at', group: 'timestamps' },
{ field: 'createdBy', storeIdent: 'created_by', group: 'user_reference' },
{ field: 'updatedAt', storeIdent: 'updated_at', group: 'timestamps' },
{ field: 'updatedBy', storeIdent: 'updated_by', group: 'user_reference' },
{ field: 'deletedAt', storeIdent: 'deleted_at', group: 'timestamps' },
{ field: 'deletedBy', storeIdent: 'deleted_by', group: 'user_reference' },
].map(sf => ({ ...sf, label: this.$t(`field:system.${sf.field}`) }))
return {
processing: false,
connections: [],
moduleFields: [],
moduleFieldEncoding: [],
selectedGroup: '',
systemFields,
systemFieldEncoding: systemFields.reduce((enc, { field }) => {
enc[field] = systemFieldEncoding[field] || {}
return enc
}, {}),
optionsGroups: [
{ text: this.$t('system-fields.grouptypes.all'), value: 'all' },
{ text: this.$t('system-fields.grouptypes.partition'), value: 'partition' },
{ text: this.$t('system-fields.grouptypes.userReference'), value: 'user_reference' },
{ text: this.$t('system-fields.grouptypes.timestamps'), value: 'timestamps' },
{ text: this.$t('system-fields.grouptypes.extras'), value: 'extras' },
],
}
},
computed: {
moduleFieldDefaultEncodingStrategy () {
return types.JSON
},
},
watch: {
'module.fields': {
handler (m) {
this.moduleFields = []
for (const f of this.module.fields) {
const a = {
field: f.name,
label: f.label || f.name,
storeIdent: f.name,
isMulti: f.isMulti,
}
// In case of a JSON encoding strategy, default to values
const strat = f.config.dal.encodingStrategy
if (!strat || strat[types.JSON]) {
a.storeIdent = 'values'
}
this.moduleFields.push(a)
}
this.moduleFieldEncoding = this.moduleFields.reduce((enc, { field }) => {
const f = this.module.findField(field)
if (f) {
enc[field] = f.config.dal.encodingStrategy || {}
}
return enc
}, {})
},
deep: true,
immediate: true,
},
},
mounted () {
this.fetchConnections()
},
beforeDestroy () {
this.setDefaultValues()
},
methods: {
async fetchConnections () {
this.processing = true
return this.$SystemAPI.dalConnectionList()
.then(({ set = [] }) => {
this.connections = set
const { connectionID } = this.module.config.dal || {}
if (!connectionID || connectionID === NoID) {
const primaryConnectionID = (this.connections.find(c => c.type === PrimaryConnType) || { connectionID: NoID }).connectionID
this.module.config.dal.connectionID = primaryConnectionID
}
})
.catch(this.toastErrorHandler(this.$t('connections.fetch-failed')))
.finally(() => {
this.processing = false
})
},
getConnectionLabel ({ connectionID, handle, meta = {} }) {
return meta.name || handle || connectionID
},
applyModuleFieldStrategyConfig (field, { strategy, config }) {
const value = moduleFieldStrategyConfig(strategy, config)
// merge new config into existing
this.moduleFieldEncoding = { ...this.moduleFieldEncoding, [field]: value }
// filter out empty configs and update the original config
const moduleField = this.module.findField(field)
if (moduleField) {
moduleField.config.dal.encodingStrategy = value
}
},
applySystemFieldStrategyConfig (field, { strategy, config }) {
const value = systemFieldStrategyConfig(strategy, config)
// merge new config into existing
this.systemFieldEncoding = { ...this.systemFieldEncoding, [field]: value }
// filter out empty configs and update the original config
this.module.config.dal.systemFieldEncoding = Object.entries(this.systemFieldEncoding)
.reduce((enc, [f, c]) => {
if (c === null || Object.keys(c).length) {
enc[f] = c
}
return enc
}, {})
},
applySelectedSystemFields (selectedOption) {
this.systemFieldEncoding = this.systemFields.reduce((enc, { field, group }) => {
if (field !== 'id') {
if (selectedOption === 'all') {
enc[field] = {}
} else {
enc[field] = group === selectedOption ? {} : { omit: true }
}
}
return enc
}, {})
},
getOptionKey ({ connectionID }) {
return connectionID
},
setDefaultValues () {
this.processing = false
this.connections = []
this.moduleFields = []
this.moduleFieldEncoding = []
this.selectedGroup = ''
this.systemFields = []
this.systemFieldEncoding = []
this.optionsGroups = []
},
},
}
</script>

View File

@@ -0,0 +1,72 @@
<template>
<div
v-if="resource && connection"
>
<b-form-group
:label="translations.sensitivity.label"
:description="translations.sensitivity.description"
label-class="text-primary"
>
<c-sensitivity-level-picker
v-model="resource.config.privacy.sensitivityLevelID"
:options="sensitivityLevels"
:placeholder="translations.sensitivity.placeholder"
:max-level="maxLevel"
:disabled="processing"
/>
</b-form-group>
<b-form-group
:label="translations.usage.label"
label-class="text-primary"
>
<b-textarea
v-model="resource.config.privacy.usageDisclosure"
/>
</b-form-group>
</div>
</template>
<script>
import { components } from '@cortezaproject/corteza-vue'
const { CSensitivityLevelPicker } = components
export default {
components: {
CSensitivityLevelPicker,
},
props: {
resource: {
type: Object,
required: true,
},
connection: {
type: Object,
required: true,
},
// ID of sensitivityLevel with the maximum allowed level
maxLevel: {
type: String,
default: undefined,
},
sensitivityLevels: {
type: Array,
required: true,
},
translations: {
type: Object,
required: true,
},
},
data () {
return {
processing: false,
}
},
}
</script>

View File

@@ -0,0 +1,212 @@
<template>
<b-modal
v-model="showModal"
:title="discoveryModalTitle"
:ok-title="$t('general.label.saveAndClose')"
ok-only
ok-variant="primary"
scrollable
size="lg"
body-class="p-0 border-top-0"
header-class="p-3 pb-0 border-bottom-0"
@ok="onSave()"
>
<field-picker
:module="module"
:fields.sync="currentFields"
disable-system-fields
style="height: 90vh;"
/>
</b-modal>
</template>
<script>
import { mapGetters } from 'vuex'
import { compose } from '@cortezaproject/corteza-js'
import FieldPicker from 'corteza-webapp-compose/src/components/Common/FieldPicker'
export default {
i18nOptions: {
namespaces: 'module',
},
components: {
FieldPicker,
},
props: {
modal: {
type: Boolean,
required: false,
},
module: {
type: compose.Module,
required: true,
},
},
data () {
return {
public: {},
private: {},
protected: {},
currentTabIndex: 0,
currentLang: undefined,
}
},
computed: {
...mapGetters({
languages: 'languages/set',
}),
showModal: {
get () {
return this.modal
},
set (showModal) {
this.$emit('update:modal', showModal)
},
},
currentFields: {
get () {
const { result = [] } = this.currentScope || {}
if (result[this.currentLanguageIndex]) {
return result[this.currentLanguageIndex].fields
}
return []
},
set (currentFields) {
if (!this.currentScope) {
return
}
const { result = [] } = this.currentScope || {}
if (result[this.currentLanguageIndex]) {
result[this.currentLanguageIndex].fields = [...currentFields]
}
},
},
discoveryModalTitle () {
const { handle } = this.module
return handle ? `${this.$t('edit.discoverySettings.title')} (${handle})` : this.$t('edit.discoverySettings.title')
},
currentScope () {
if (this.currentTabIndex < 0) {
return undefined
}
const { value } = this.scopeOptions[this.currentTabIndex] || {}
return this[value]
},
currentLanguageIndex () {
if (!this.currentScope) {
return 0
}
const { result = [] } = this.currentScope || {}
if (!result.length) {
return 0
}
const index = result.findIndex(({ lang }) => lang === this.currentLang)
return index >= 0 ? index : 0
},
moduleFields () {
return new Set(this.module.fields.map(({ name }) => name))
},
scopeOptions () {
return [
// { value: 'public', title: this.$t('edit.discoverySettings.public') },
{ value: 'private', title: this.$t('edit.discoverySettings.private') },
// { value: 'protected', title: this.$t('edit.discoverySettings.protected') },
]
},
},
mounted () {
this.currentLang = this.defaultTranslationLanguage
this.scopeOptions.forEach(({ value }) => {
this[value] = {
result: [],
}
this.languages.forEach(({ tag: lang }) => {
let existingFields = new Set()
if (this.module.config.discovery && this.module.config.discovery[value]) {
const indexOfLanguage = this.module.config.discovery[value].result.findIndex(r => r.lang === lang)
if (indexOfLanguage >= 0) {
existingFields = new Set(this.module.config.discovery[value].result[indexOfLanguage].fields.filter(name => this.moduleFields.has(name)))
}
}
const fields = [...existingFields].map(name => this.module.fields.find(field => field.name === name))
this[value].result.push({ lang, fields })
existingFields.clear()
})
})
},
beforeDestroy () {
this.setDefaultValues()
},
methods: {
onSave () {
const discovery = {
public: {},
private: {},
protected: {},
}
this.scopeOptions.forEach(({ value }) => {
discovery[value].result = this[value].result.map(({ lang, fields }) => {
return {
lang,
fields: fields.map(({ name }) => name),
}
})
})
this.$emit('save', {
...this.module.meta,
discovery,
})
},
setDefaultValues () {
this.public = {}
this.private = {}
this.protected = {}
this.currentTabIndex = 0
this.currentLang = undefined
},
},
}
</script>
<style scoped>
.mh-tab {
max-height: calc(100vh - 16rem);
}
</style>

View File

@@ -0,0 +1,764 @@
<template>
<b-modal
v-model="showModal"
:title="federationModalTitle"
:ok-title="$t('general.label.saveAndClose')"
ok-only
ok-variant="primary"
size="lg"
body-class="p-0 border-top-0"
header-class="p-3 pb-0 border-bottom-0"
@ok="handleFederationSettingsSave()"
@change="$emit('change', $event)"
>
<b-tabs
active-tab-class="tab-content h-auto overflow-auto"
card
>
<!-- <b-tab
:title="$t('edit.federationSettings.general.title')"
active
>
<b-form-group
class="mb-0"
>
<b-form-checkbox
v-model="upstream.enabled"
>
{{ $t('edit.federationSettings.general.send') }}
</b-form-checkbox>
<b-form-checkbox
v-model="downstream.enabled"
>
{{ $t('edit.federationSettings.general.receive') }}
</b-form-checkbox>
</b-form-group>
</b-tab> -->
<b-tab
:title="$t('edit.federationSettings.upstream.title')"
active
>
<b-list-group
vertical
class="overflow-auto server-list"
>
<b-list-group-item
v-for="f in servers"
:key="f.nodeID"
href="#"
:class="{ 'border border-primary': f.nodeID === upstream.active }"
class="border d-flex flex-column"
@click="upstream.active = f.nodeID"
>
<p
class="mb-0 text-truncate"
>
{{ f.name }}
</p>
<small
class="text-truncate"
>
{{ f.baseURL }}
</small>
</b-list-group-item>
</b-list-group>
<div
v-if="upstream.processing"
class="d-flex flex-grow-1 justify-content-center align-items-center"
>
<b-spinner
variant="primary"
/>
</div>
<div
v-else-if="upstream[upstream.active]"
class="list-group flex-grow-1 ml-4"
>
<div
v-if="upstream[upstream.active].canManageModule"
>
{{ $t('edit.federationSettings.upstream.description') }}
<b-form-group
label-cols-sm="4"
label-cols-lg="5"
:label="$t('edit.federationSettings.upstream.copyFrom')"
>
<b-form-select
:key="upstream.active"
v-model="upstream[upstream.active].copy"
:options="upstream[upstream.active].options"
value-field="nodeID"
text-field="name"
@input="copyUpstreamFrom"
/>
</b-form-group>
<b-form-checkbox
:checked="upstream[upstream.active].allFields"
class="mb-2"
@change="selectAllFields($event, 'upstream')"
>
<strong>{{ $t('edit.federationSettings.upstream.allFields') }}</strong>
</b-form-checkbox>
<div
class="overflow-auto"
>
<b-form-checkbox
v-for="f in upstream[upstream.active].fields"
:key="`${upstream.active}${f.name}`"
v-model="f.value"
class="mb-2"
@change="checkChange($event, 'upstream')"
>
{{ f.label }}
</b-form-checkbox>
</div>
</div>
<div
v-else
class="d-flex flex-grow-1 align-items-center justify-content-center"
>
{{ $t('edit.federationSettings.noPermission') }}
</div>
</div>
<div
v-else
class="d-flex flex-grow-1 align-items-center justify-content-center"
>
{{ $t('edit.federationSettings.noNodes') }}
</div>
</b-tab>
<!-- downstream tab -->
<b-tab
:title="$t('edit.federationSettings.downstream.title')"
>
<b-list-group
vertical
class="overflow-auto server-list"
>
<b-list-group-item
v-for="f in servers"
:key="f.nodeID"
href="#"
:class="{ 'border border-primary': f.nodeID === downstream.active }"
class="border d-flex flex-column"
@click="downstream.active = f.nodeID"
>
<p
class="mb-0 text-truncate"
>
{{ f.name }}
</p>
<small
class="text-truncate"
>
{{ f.baseURL }}
</small>
</b-list-group-item>
</b-list-group>
<div
v-if="downstream.processing"
class="d-flex flex-grow-1 justify-content-center align-items-center"
>
<b-spinner
variant="primary"
/>
</div>
<div
v-else-if="downstream[downstream.active]"
class="list-group flex-grow-1 ml-4"
>
<!-- dropdown list of federated shared modules -->
<b-form-group>
<b-form-select
:key="downstream.active"
v-model="downstream[downstream.active].module"
:options="downstream[downstream.active].options"
value-field="moduleID"
text-field="name"
class="w-50"
/>
</b-form-group>
<div
v-if="downstream[downstream.active].module"
class="mb-2"
>
{{ $t('edit.federationSettings.downstream.description') }}
<b-form-checkbox
:checked="downstream[downstream.active].allFields[downstream[downstream.active].module]"
@change="selectAllFields($event, 'downstream')"
>
<strong>{{ $t('edit.federationSettings.downstream.allFields') }}</strong>
</b-form-checkbox>
</div>
<div
v-if="downstream[downstream.active].module"
class="overflow-auto"
>
<!-- list of fields per shared module -->
<div
v-for="sharedModuleFields in activeSharedModules"
:key="`${downstream.active}_${sharedModuleFields.name}`"
class="d-flex align-items-center justify-content-between"
>
<b-form-checkbox
v-model="sharedModuleFields.map"
class="my-2"
@change="checkChange($event, 'downstream')"
>
{{ sharedModuleFields.label }}
</b-form-checkbox>
<!-- dropdown with a list of compose module fields -->
<b-form-select
v-show="sharedModuleFields.map"
:key="`${downstream.active}_${sharedModuleFields.name}`"
v-model="sharedModuleFields.mapped"
:options="transformedModuleFields"
value-field="name"
text-field="label"
class="w-50"
@change="setUpdated('downstream')"
/>
</div>
</div>
</div>
<div
v-else
class="d-flex flex-grow-1 align-items-center justify-content-center"
>
{{ $t('edit.federationSettings.noNodes') }}
</div>
</b-tab>
</b-tabs>
</b-modal>
</template>
<script>
import { compose } from '@cortezaproject/corteza-js'
export default {
i18nOptions: {
namespaces: 'module',
},
props: {
modal: {
type: Boolean,
required: false,
},
module: {
type: compose.Module,
required: true,
},
},
data () {
return {
showModal: false,
servers: [],
moduleFields: [],
sharedModule: null,
sharedModules: {},
sharedModulesMapped: {},
exposedModules: {},
moduleMappings: {},
downstream: {
active: undefined,
processing: false,
enabled: false,
},
upstream: {
active: undefined,
processing: false,
enabled: false,
},
}
},
computed: {
//
// shared modules
//
activeSharedModules () {
if (!this.downstream[this.downstream.active].module) return []
return (this.sharedModulesMapped[this.downstream.active] || {})[this.downstream[this.downstream.active].module] || []
},
transformedModuleMappings () {
// get the transformed module fields
const tf = this.transformFields(this.moduleFields)
// get the module mappings and convert it to the appropriate structure
const mm = ((this.sharedModules[this.downstream.active] || {})[this.sharedModule] || {}).fields || []
return tf.map((el) => {
el.origin.value = false
if (mm.find((e) => e.origin.name === el.origin.name)) {
el.destination.name = el.origin.name
el.origin.value = true
}
return el
})
},
// used on module field dropdown on field mapping screen
transformedModuleFields () {
return [
{ name: null, label: this.$t('edit.federationSettings.pickModuleField') },
...this.transformedModuleMappings.map((el) => ({
name: el.origin.name,
label: el.origin.label,
})),
]
},
federationModalTitle () {
const { handle } = this.module
return handle ? this.$t('edit.federationSettings.specificTitle', { handle }) : this.$t('edit.federationSettings.title')
},
},
watch: {
modal: {
immediate: true,
handler (show = false) {
this.showModal = show
},
},
'module.fields': {
immediate: true,
handler (fields) {
this.moduleFields = fields
.map(f => {
return {
kind: f.kind,
name: f.name,
label: f.label,
isMulti: f.isMulti,
value: false,
map: null,
}
})
.sort((a, b) => a.label.localeCompare(b.label))
},
},
'upstream.active': {
handler (nodeID) {
this.getNodeUpstream(nodeID)
},
},
'downstream.active': {
handler (nodeID) {
this.getNodeDownstream(nodeID)
},
},
},
async mounted () {
this.preload()
},
beforeDestroy () {
this.setDefaultValues()
},
methods: {
async preload () {
await this.$FederationAPI.nodeSearch({ status: 'paired' })
.then(({ set = [] }) => {
this.servers = set.filter(({ canManageNode }) => canManageNode)
})
.catch(this.toastErrorHandler(this.$t('edit.federationSettings.error.fetch.node')))
for (const node of this.servers) {
await this.loadExposedModules(node.nodeID).catch(this.toastErrorHandler(this.$t('edit.federationSettings.error.fetch.exposed')))
await this.loadSharedModules(node.nodeID).catch(this.toastErrorHandler(this.$t('edit.federationSettings.error.fetch.shared')))
await this.loadModuleMappings(node.nodeID).catch(this.toastErrorHandler(this.$t('edit.federationSettings.error.fetch.mmap')))
}
this.sharedModulesMapped = this.getSharedModulesMapped()
if (this.servers.length) {
this.downstream.active = this.servers[0].nodeID
this.upstream.active = this.servers[0].nodeID
}
},
// shared module fields get prepopulated here
// the module mappings also get applied here
// on top of the fields
getSharedModulesMapped () {
const list = {}
// first, prefill the shared module fields
for (const nodeID in this.sharedModules) {
list[nodeID] = {}
for (const sm of this.sharedModules[nodeID]) {
let f = [...sm.fields].sort((a, b) => a.label.localeCompare(b.label))
// is there any mappings for this shared module?
const mappedFields = ((this.moduleMappings[nodeID] || {})[sm.moduleID] || {}).fields || []
// fetch the shared module fields and slap the
// module mappings on top of them
f = f.map((el) => {
let found = false
let mapped = (this.moduleFields.find(({ name }) => name === el.name) || {}).name || null
if (mappedFields) {
const m = mappedFields.find((mf) => el.name === mf.origin.name)
mapped = ((m || {}).destination || {}).name || null
found = !!mapped
}
return {
...el,
map: found,
mapped,
}
})
list[nodeID][sm.moduleID] = f
}
}
return list
},
async handleFederationSettingsSave () {
// module mappings (downstream)
for (const nodeID in this.sharedModulesMapped) {
for (const moduleID in this.sharedModulesMapped[nodeID]) {
const crtModule = this.sharedModules[nodeID].find(m => m.moduleID === moduleID)
// Check if node module downstream settings were updated
if (!crtModule || !(crtModule || {}).updated) {
continue
}
const fields = this.toModuleMappingFormat(this.sharedModulesMapped[nodeID][moduleID])
const payload = {
nodeID,
moduleID,
composeModuleID: this.module.moduleID,
composeNamespaceID: this.module.namespaceID,
fields,
}
await this.persistModuleMappings(payload)
.then(() => {
// Reset update flag
crtModule.updated = false
})
.catch(this.toastErrorHandler(this.$t('edit.federationSettings.error.persist.mmap')))
}
}
const nodes = this.servers.map(s => s.nodeID)
// upstream
for (const nodeID of nodes) {
// Check if node upstream settings were updated
if (!this.upstream[nodeID] || !(this.upstream[nodeID] || {}).updated) {
continue
}
const fields = ((this.upstream[nodeID] || {}).fields || []).filter((el) => el.value)
const payload = {
nodeID,
moduleID: (this.exposedModules[nodeID] || {}).moduleID,
composeModuleID: this.module.moduleID,
composeNamespaceID: this.module.namespaceID,
name: this.module.name,
handle: this.module.handle,
fields,
}
const response = await this.persistExposedModule(payload)
.then(() => {
// Reset update flag
(this.upstream[nodeID] || {}).updated = false
})
.catch(this.toastErrorHandler(this.$t('edit.federationSettings.error.persist.exposed')))
if (!response && !response.moduleID) {
return
}
this.exposedModules[nodeID] = response
}
},
// transform internal module mappings to
// server api format
// [{ name, kind, ...}] => [{origin: { name, kind }, destination: { name, kind }}]
toModuleMappingFormat (fields) {
return fields
.filter((el) => el.map)
.filter((el) => !!el.mapped)
.map((el) => ({
origin: {
kind: el.kind,
name: el.name,
label: el.label,
isMulti: el.isMulti,
},
destination: {
kind: el.kind,
name: el.mapped,
label: el.label,
isMulti: el.isMulti,
},
}))
},
transformFields (fields) {
return fields.map((el) => ({
origin: {
kind: el.kind,
name: el.name,
label: el.label || 'N/A',
isMulti: false,
},
destination: {
kind: el.kind,
name: '',
label: '',
isMulti: false,
},
}))
},
getNodeUpstream (nodeID) {
if (this.upstream[nodeID]) {
return
}
this.upstream.processing = true
const exposedModule = this.exposedModules[nodeID] || {}
const fields = (this.moduleFields || []).map(f => ({ ...f, value: false }))
const exposedFields = exposedModule.fields || []
exposedFields.forEach(({ name }) => {
fields.find(f => f.name === name).value = true
})
const upstream = {
options: [
{ moduleID: null, name: this.$t('edit.federationSettings.pickServer') },
...this.servers.filter(s => s.nodeID !== nodeID),
],
copy: null,
allFields: false,
fields,
updated: false,
canManageModule: !!exposedModule.canManageModule || !!(this.servers.find(s => s.nodeID === nodeID) || {}).canCreateModule,
}
upstream.allFields = upstream.fields.filter(f => f.value).length === upstream.fields.length
this.$set(this.upstream, nodeID, upstream)
this.upstream.processing = false
},
async getNodeDownstream (nodeID) {
if (this.downstream[nodeID]) {
return
}
this.downstream.processing = true
const fields = (this.moduleFields || []).map(f => ({ ...f, value: false }))
const downstream = {
options: [
{ moduleID: null, name: this.$t('edit.federationSettings.pickModule') },
...Object.values(this.sharedModules[nodeID] || {})
.filter(({ canMapModule }) => canMapModule)
.map(m => ({ moduleID: m.moduleID, name: m.name })),
],
module: ((this.sharedModules[nodeID] || []).find(({ handle }) => handle === this.module.handle) || {}).moduleID || null,
allFields: {},
fields,
}
Object.entries(this.sharedModulesMapped[nodeID] || {}).forEach(([key, value]) => {
if (value.length) {
downstream.allFields[key] = (value || []).filter(f => f.map).length === value.length
} else {
downstream.allFields[key] = false
}
})
this.$set(this.downstream, nodeID, downstream)
this.downstream.processing = false
},
selectAllFields (value, target) {
const active = this[target].active
if (target === 'upstream') {
this.upstream[active].fields = this.upstream[active].fields.map(f => ({ ...f, value }))
this.upstream[active].allFields = value
this.upstream[active].updated = true
} else if (target === 'downstream') {
this.sharedModulesMapped[active][this.downstream[active].module] = this.sharedModulesMapped[active][this.downstream[active].module].map(f => ({ ...f, map: value }))
this.downstream[active].allFields[this.downstream[active].module] = value
this.sharedModules[active].find(m => m.moduleID === this.downstream[active].module).updated = true
}
},
setUpdated (target) {
const active = this[target].active
if (target === 'upstream') {
this.upstream[active].updated = true
} else if (target === 'downstream') {
this.sharedModules[active].find(m => m.moduleID === this.downstream[active].module).updated = true
}
},
copyUpstreamFrom (nodeID) {
this.upstream[this.upstream.active].fields = this.upstream[this.upstream.active].fields.map(f => {
let value = false
if (this.upstream[nodeID]) {
value = !!this.upstream[nodeID].fields.find(({ name, value }) => name === f.name && value)
} else {
value = !!this.exposedModules[nodeID].fields.find(({ name }) => name === f.name)
}
return {
...f,
value,
}
})
},
// When field checkbox changes, check and update allFields checkbox value
checkChange (value, target) {
const active = this[target].active
if (target === 'upstream') {
this.upstream[active].allFields = value ? !this.upstream[active].fields.find(f => f.value === !value) : false
} else if (target === 'downstream') {
const allSameValue = !this.sharedModulesMapped[active][this.downstream[active].module].find(({ map }) => map === !value)
this.downstream[active].allFields[this.downstream[active].module] = value ? allSameValue : false
}
this.setUpdated(target)
},
async persistExposedModule (payload) {
if (payload.moduleID) {
return this.$FederationAPI.manageStructureUpdateExposed(payload)
}
return this.$FederationAPI.manageStructureCreateExposed(payload)
},
async persistModuleMappings (payload) {
return this.$FederationAPI.manageStructureCreateMappings(payload)
},
//
// preloaders
//
async loadSharedModules (nodeID) {
if (this.sharedModules[nodeID]) {
return
}
await this.$FederationAPI.manageStructureListAll({ nodeID, shared: 1 })
.then((data = []) => {
this.sharedModules[nodeID] = data.map(d => ({ ...d, updated: false }))
})
.catch(this.toastErrorHandler(this.$t('edit.federationSettings.error.fetch.shared')))
},
async loadExposedModules (nodeID) {
if (this.exposedModules[nodeID]) {
return
}
await this.$FederationAPI.manageStructureListAll({ nodeID, exposed: 1 })
.then((data = []) => {
const exposedModule = data.find(({ composeModuleID }) => composeModuleID === this.module.moduleID)
if (exposedModule) {
this.exposedModules[nodeID] = exposedModule
}
})
.catch(this.toastErrorHandler(this.$t('edit.federationSettings.error.fetch.exposed')))
},
async loadModuleMappings (nodeID) {
if (this.moduleMappings[nodeID] || !this.sharedModules[nodeID]) {
return
}
const mm = {}
for (const { moduleID } of this.sharedModules[nodeID]) {
mm[moduleID] = []
await this.$FederationAPI.manageStructureReadMappings({ nodeID, moduleID, composeModuleID: this.module.moduleID })
.then((data) => {
mm[moduleID] = data
})
.catch(() => {})
}
this.moduleMappings[nodeID] = mm
},
setDefaultValues () {
this.showModal = false
this.servers = []
this.moduleFields = []
this.sharedModule = null
this.sharedModules = {}
this.sharedModulesMapped = {}
this.exposedModules = {}
this.moduleMappings = {}
this.downstream = {}
this.upstream = {}
},
},
}
</script>
<style lang="scss" scoped>
.tab-content {
min-height: 0;
max-height: 70vh;
display: flex;
}
.server-list {
max-width: 35%;
}
</style>

View File

@@ -0,0 +1,137 @@
<template>
<c-translator-button
v-if="canManageResourceTranslations && resourceTranslationsEnabled"
v-bind="{ ...$attrs, ...$props }"
:size="size"
:tooltip="$t('tooltip')"
:resource="resource"
:fetcher="fetcher"
:updater="updater"
class="ml-auto mr-1 py-1 px-3"
/>
</template>
<script>
import { compose } from '@cortezaproject/corteza-js'
import { mapGetters } from 'vuex'
import CTranslatorButton from 'corteza-webapp-compose/src/components/Translator/CTranslatorButton'
const keyPrefix = 'meta.bool'
const keySuffix = '.label'
export default {
components: {
CTranslatorButton,
},
i18nOptions: {
namespaces: 'resource-translator',
keyPrefix: 'resources.module.field',
},
props: {
field: {
type: compose.ModuleField,
required: true,
},
module: {
type: compose.Module,
required: true,
},
size: {
type: String,
default: 'lg',
},
disabled: {
type: Boolean,
default: () => false,
},
highlightKey: {
type: String,
default: '',
},
},
computed: {
...mapGetters({
can: 'rbac/can',
}),
canManageResourceTranslations () {
return this.can('compose/', 'resource-translations.manage')
},
resource () {
const { fieldID } = this.field
const { moduleID, namespaceID } = this.module
return `compose:module-field/${namespaceID}/${moduleID}/${fieldID}`
},
fetcher () {
const { moduleID, namespaceID } = this.module
return () => {
return this.$ComposeAPI
.moduleListTranslations({ namespaceID, moduleID })
// Fields do not have their own translation endpoints,
// we'll just filter what we need here.
.then(set => {
set = set
// Extract translations for this field
.filter(({ resource }) => this.resource === resource)
// Ignore all option translations
.filter(({ key }) => key.startsWith(keyPrefix) && key.endsWith(keySuffix))
// after translations are fetched, make sure we copy all (updates) values from
// the caller so translator editor can operate with recent values
set
.filter(({ lang }) => this.currentLanguage === lang)
return set
})
}
},
updater () {
const { moduleID, namespaceID } = this.module
return translations => {
return this.$ComposeAPI
.moduleUpdateTranslations({ namespaceID, moduleID, translations })
// re-fetch translations, sanitized and stripped
.then(() => this.fetcher())
.then((translations) => {
// When translations are successfully saved,
// scan changes and apply them back to the passed object
// not the most elegant solution but is saves us from
// handling the resource on multiple places
//
// @todo move this to ModuleFieldSelect classes
// the logic there needs to be implemented; the idea is to encode
// values from the set of translations back to the resource object
const find = (key) => {
return translations.find(t => t.key === key && t.lang === this.currentLanguage && t.resource === this.resource)
}
let tr
tr = find('meta.bool.true.label')
if (tr !== undefined) {
// eslint-disable-next-line vue/no-side-effects-in-computed-properties
this.field.options.trueLabel = tr.message
}
tr = find('meta.bool.false.label')
if (tr !== undefined) {
// eslint-disable-next-line vue/no-side-effects-in-computed-properties
this.field.options.falseLabel = tr.message
}
})
}
},
},
}
</script>

View File

@@ -0,0 +1,72 @@
// eslint-disable-next-line
import { default as component } from './FieldRowEdit.vue'
import { compose } from '@cortezaproject/corteza-js'
import { components } from '@cortezaproject/corteza-vue'
const { checkbox } = components.C3.controls
const props = {
value: {
name: 'Name',
label: 'Label',
kind: 'String',
cap: {
configurable: true,
multi: true,
required: true,
private: false,
},
isMulti: true,
isRequired: true,
fieldId: '453534534534343',
isValid: true,
},
module: new compose.Module({
namespaceID: '3242343234',
moduleID: '999999442',
}),
isDuplicate: false,
canGrant: true,
hasRecords: false,
}
export default {
name: 'Field row edit',
group: ['Admin', 'Module'],
component,
props,
controls: [
checkbox('configurable', 'cap.configurable'),
checkbox('multi', 'value.cap.multi'),
checkbox('required', 'value.cap.required'),
checkbox('private', 'value.cap.private'),
checkbox('isMulti', 'value.isMulti'),
checkbox('isRequired', 'value.isRequired'),
checkbox('isValid', 'value.isValid'),
],
scenarios: [
{
label: 'Full form',
props,
},
{
label: 'Empty form',
props: {
...props,
canGrant: false,
hasRecords: true,
value: {
cap: {
configurable: false,
multi: false,
required: false,
private: false,
},
isMulti: false,
isRequired: false,
isValid: false,
},
},
},
],
}

View File

@@ -0,0 +1,214 @@
<template>
<tr>
<td
class="handle align-middle pr-2"
>
<font-awesome-icon
:icon="['fas', 'bars']"
class="text-secondary grab"
/>
</td>
<td
style="min-width: 250px;"
>
<b-form-input
v-model="value.name"
v-b-tooltip.noninteractive.hover="{ title: nameState === false ? $t('module:edit.tooltip.name') : '', placement: 'right', boundary: 'body' }"
required
:readonly="hasData"
:state="nameState"
type="text"
/>
</td>
<td
style="min-width: 250px;"
>
<b-input-group>
<b-form-input
v-model="value.label"
type="text"
/>
<b-input-group-append>
<field-translator
:field.sync="value"
:module="module"
:disabled="isNew"
button-variant="extra-light"
highlight-key="label"
/>
</b-input-group-append>
</b-input-group>
</td>
<td
style="min-width: 250px;"
>
<b-input-group class="field-type">
<c-input-select
v-model="value.kind"
v-b-tooltip.hover="{ title: hasData ? $t('field:not-configurable') : '', placement: 'left', boundary: 'body' }"
:options="fieldKinds"
:reduce="kind => kind.kind"
:disabled="hasData"
:clearable="false"
@input="$emit('updateKind')"
/>
<b-input-group-append>
<b-button
data-test-id="button-configure-field"
variant="extra-light"
:disabled="isEditDisabled"
@click.prevent="$emit('edit')"
>
<font-awesome-icon
:icon="['fas', 'wrench']"
/>
</b-button>
</b-input-group-append>
</b-input-group>
</td>
<td />
<td />
<td
class="align-middle text-center"
>
<b-form-checkbox
v-model="value.isRequired"
:disabled="!value.cap.required"
switch
/>
</td>
<td
class="align-middle text-center"
>
<b-form-checkbox
v-model="value.isMulti"
:disabled="!value.cap.multi"
switch
class="ml-2"
/>
</td>
<td
class="text-right align-middle"
style="min-width: 7rem;"
>
<c-permissions-button
v-if="canGrant && !isNew"
button-variant="outline-extra-light"
size="sm"
:title="value.label || value.name || value.fieldID"
:target="value.label || value.name || value.fieldID"
:tooltip="$t('permissions:resources.compose.module-field.tooltip')"
:resource="`corteza::compose:module-field/${module.namespaceID}/${module.moduleID}/${value.fieldID}`"
class="text-dark border-0 mr-2"
/>
<c-input-confirm
show-icon
@confirmed="$emit('delete')"
/>
</td>
</tr>
</template>
<script>
import FieldTranslator from 'corteza-webapp-compose/src/components/Admin/Module/FieldTranslator'
import { compose, NoID } from '@cortezaproject/corteza-js'
export default {
components: {
FieldTranslator,
},
i18nOptions: {
namespaces: 'general',
},
props: {
value: {
type: Object,
required: true,
},
module: {
type: compose.Module,
required: true,
},
canGrant: {
type: Boolean,
required: false,
},
hasRecords: {
type: Boolean,
required: true,
},
isDuplicate: {
type: Boolean,
required: false,
},
},
data () {
return {
updateField: null,
}
},
computed: {
nameState () {
if (this.hasData) {
return null
}
if (this.isDuplicate) {
return false
}
return this.value.isValid ? null : false
},
hasData () {
return !this.isNew && this.hasRecords
},
isNew () {
return this.module.moduleID === NoID || this.value.fieldID === NoID
},
fieldKinds () {
return [...compose.ModuleFieldRegistry.keys()]
// for now this field is hidden, since it's implementation is mia.
.map(kind => {
return { kind, label: this.$t('fieldKinds.' + kind + '.label') }
}).sort((a, b) => a.label.localeCompare(b.label))
},
isEditDisabled () {
return !this.value.cap.configurable || this.nameState !== null
},
},
}
</script>
<style lang="scss" scoped>
td {
input,
.input-group {
min-width: 150px;
}
.handle {
width: 30px;
}
}
</style>

View File

@@ -0,0 +1,146 @@
<template>
<c-translator-button
v-if="canManageResourceTranslations && resourceTranslationsEnabled"
v-bind="{ ...$attrs, ...$props }"
:tooltip="$t('tooltip')"
:size="size"
:resource="resource"
:fetcher="fetcher"
:updater="updater"
:key-prettyfier="keyPrettifyer"
class="ml-auto mr-1 py-1 px-3"
/>
</template>
<script>
import { compose } from '@cortezaproject/corteza-js'
import { mapGetters } from 'vuex'
import CTranslatorButton from 'corteza-webapp-compose/src/components/Translator/CTranslatorButton'
import moduleFieldSelectResTr from 'corteza-webapp-compose/src/lib/resource-translations/module-field-select'
const keyPrefix = 'meta.options.'
const keySuffix = '.text'
function optionValueFromKey (key) {
return key.substring(keyPrefix.length, key.length - keySuffix.length)
}
export default {
components: {
CTranslatorButton,
},
i18nOptions: {
namespaces: 'resource-translator',
keyPrefix: 'resources.module.field',
},
props: {
field: {
type: compose.ModuleField,
required: true,
},
module: {
type: compose.Module,
required: true,
},
size: {
type: String,
default: 'lg',
},
disabled: {
type: Boolean,
default: () => false,
},
highlightKey: {
type: String,
default: '',
},
},
computed: {
...mapGetters({
can: 'rbac/can',
}),
canManageResourceTranslations () {
return this.can('compose/', 'resource-translations.manage')
},
resource () {
const { fieldID } = this.field
const { moduleID, namespaceID } = this.module
return `compose:module-field/${namespaceID}/${moduleID}/${fieldID}`
},
fetcher () {
const { moduleID, namespaceID } = this.module
return () => {
return this.$ComposeAPI
.moduleListTranslations({ namespaceID, moduleID })
// Fields do not have their own translation endpoints,
// we'll just filter what we need here.
.then(set => {
set = set
// Extract translations for this field
.filter(({ resource }) => this.resource === resource)
// Ignore all option translations
.filter(({ key }) => key.startsWith(keyPrefix) && key.endsWith(keySuffix))
// after translations are fetched, make sure we copy all (updates) values from
// the caller so translator editor can operate with recent values
set
.filter(({ lang }) => this.currentLanguage === lang)
.forEach(rt => {
// find the corresponding option
const op = this.field.options.options
.find(op => typeof op === 'object' && rt.key === `${keyPrefix}${op.value}${keySuffix}`)
if (op) {
// and update the message
rt.message = op.text
}
})
// @todo instead of this ^ pass set of translations to the object (ModuleField* class)
// The logic there needs to be implemented; the idea is to decode
// values from the resource object to the set of translations)
return set
})
}
},
keyPrettifyer () {
return optionValueFromKey
},
updater () {
const { moduleID, namespaceID } = this.module
return translations => {
return this.$ComposeAPI
.moduleUpdateTranslations({ namespaceID, moduleID, translations })
// re-fetch translations, sanitized and stripped
.then(() => {
// When translations are successfully saved,
// scan changes and apply them back to the passed object
// not the most elegant solution but is saves us from
// handling the resource on multiple places
//
// @todo move this to ModuleFieldSelect classes
// the logic there needs to be implemented; the idea is to encode
// values from the set of translations back to the resource object
moduleFieldSelectResTr(this.field, translations, this.currentLanguage, this.resource)
this.fetcher()
})
}
},
},
}
</script>

View File

@@ -0,0 +1,129 @@
<template>
<c-translator-button
v-if="canManageResourceTranslations && resourceTranslationsEnabled"
v-bind="$props"
:tooltip="$t('tooltip')"
:size="size"
:resource="resource"
:titles="titles"
:fetcher="fetcher"
:updater="updater"
class="ml-auto py-1 px-3"
/>
</template>
<script>
import { compose } from '@cortezaproject/corteza-js'
import { mapGetters } from 'vuex'
import CTranslatorButton from 'corteza-webapp-compose/src/components/Translator/CTranslatorButton'
import moduleFieldResTr from 'corteza-webapp-compose/src/lib/resource-translations/module-field'
export default {
components: {
CTranslatorButton,
},
i18nOptions: {
namespaces: 'resource-translator',
keyPrefix: 'resources.module.field',
},
props: {
field: {
type: compose.ModuleField,
required: true,
},
module: {
type: compose.Module,
required: true,
},
size: {
type: String,
default: 'lg',
},
disabled: {
type: Boolean,
default: () => false,
},
highlightKey: {
type: String,
default: '',
},
},
computed: {
...mapGetters({
can: 'rbac/can',
}),
canManageResourceTranslations () {
return this.can('compose/', 'resource-translations.manage')
},
resource () {
const { fieldID } = this.field
const { moduleID, namespaceID } = this.module
return `compose:module-field/${namespaceID}/${moduleID}/${fieldID}`
},
titles () {
const { fieldID, name } = this.field
const titles = {}
titles[this.resource] = this.$t('title', { name: name || fieldID })
return titles
},
fetcher () {
const { moduleID, namespaceID } = this.module
return () => {
return this.$ComposeAPI
.moduleListTranslations({ namespaceID, moduleID })
// Fields do not have their own translation endpoints,
// we'll just filter what we need here.
.then(set => {
return set
// Extract translations for this field
.filter(({ resource }) => this.resource === resource)
// Ignore all option translations
.filter(({ key }) => !key.startsWith('meta.options'))
.filter(({ key }) => !key.startsWith('meta.bool'))
// @todo pass set of translations to the object (ModuleField* class)
// The logic there needs to be implemented; the idea is to decode
// values from the resource object to the set of translations)
})
}
},
updater () {
const { moduleID, namespaceID } = this.module
return translations => {
return this.$ComposeAPI
.moduleUpdateTranslations({ namespaceID, moduleID, translations })
// re-fetch translations, sanitized and stripped
.then(() => this.fetcher())
.then((translations) => {
// When translations are successfully saved,
// scan changes and apply them back to the passed object
// not the most elegant solution but is saves us from
// handling the resource on multiple places
//
// @todo move this to ModuleField* classes
// the logic there needs to be implemented; the idea is to encode
// values from the set of translations back to the resource object
moduleFieldResTr(this.field, translations, this.currentLanguage, this.resource)
})
}
},
},
}
</script>

View File

@@ -0,0 +1,108 @@
<template>
<c-translator-button
v-if="canManageResourceTranslations && resourceTranslationsEnabled"
v-bind="{ ...$attrs, ...$props }"
:tooltip="$t('tooltip')"
:resource="resource"
:titles="titles"
:fetcher="fetcher"
:updater="updater"
/>
</template>
<script>
import { compose } from '@cortezaproject/corteza-js'
import { mapGetters } from 'vuex'
import CTranslatorButton from 'corteza-webapp-compose/src/components/Translator/CTranslatorButton'
import moduleResTr from 'corteza-webapp-compose/src/lib/resource-translations/module'
export default {
components: {
CTranslatorButton,
},
i18nOptions: {
namespaces: 'resource-translator',
keyPrefix: 'resources.module',
},
props: {
module: {
type: compose.Module,
required: true,
},
highlightKey: {
type: String,
default: '',
},
disabled: {
type: Boolean,
default: () => false,
},
},
computed: {
...mapGetters({
can: 'rbac/can',
}),
canManageResourceTranslations () {
return this.can('compose/', 'resource-translations.manage')
},
resource () {
const { moduleID, namespaceID } = this.module
return `compose:module/${namespaceID}/${moduleID}`
},
titles () {
const { moduleID, handle, namespaceID, fields } = this.module
const titles = {}
titles[this.resource] = this.$t('title', { handle: handle || moduleID })
fields.forEach(({ fieldID, name }) => {
titles[`compose:module-field/${namespaceID}/${moduleID}/${fieldID}`] = this.$t('field.title', { name })
})
return titles
},
fetcher () {
const { moduleID, namespaceID } = this.module
return () => {
return this.$ComposeAPI.moduleListTranslations({ namespaceID, moduleID })
// @todo pass set of translations to the resource object
// The logic there needs to be implemented; the idea is to decode
// values from the resource object to the set of translations)
}
},
updater () {
const { moduleID, namespaceID } = this.module
return translations => {
return this.$ComposeAPI
.moduleUpdateTranslations({ namespaceID, moduleID, translations })
// re-fetch translations, sanitized and stripped
.then(() => this.fetcher())
.then((translations) => {
// When translations are successfully saved,
// scan changes and apply them back to the passed object
// not the most elegant solution but is saves us from
// handling the resource on multiple places
moduleResTr(this.module, translations, this.currentLanguage, this.resource)
return this.module
})
.then(module => {
this.$emit('update:module', module)
})
}
},
},
}
</script>

View File

@@ -0,0 +1,43 @@
<template>
<div
v-if="module"
>
<b-form-group>
<b-form-checkbox
v-model="module.config.recordRevisions.enabled"
>
{{ $t('enabled') }}
</b-form-checkbox>
</b-form-group>
<b-form-group
:label="$t('ident.label')"
:description="$t('ident.description', { interpolation: { prefix: '{{{', suffix: '}}}' } })"
label-class="text-primary"
>
<b-form-input
v-model="module.config.recordRevisions.ident"
:disabled="!module.config.recordRevisions.enabled"
:placeholder="$t('ident.placeholder')"
/>
</b-form-group>
</div>
</template>
<script>
import { compose } from '@cortezaproject/corteza-js'
export default {
i18nOptions: {
namespaces: 'module',
keyPrefix: 'edit.config.record-revisions',
},
props: {
module: {
type: compose.Module,
required: true,
},
},
}
</script>

View File

@@ -0,0 +1,137 @@
<template>
<div class="d-flex">
<b-button
v-b-tooltip.noninteractive.hover="buttonTooltip"
:size="size"
:variant="variant"
:class="buttonClass"
:disabled="disabled"
@click="showModal = true"
>
<slot />
</b-button>
<b-modal
id="columns"
v-model="showModal"
size="lg"
scrollable
:ok-title="$t('general.label.saveAndClose')"
cancel-variant="light"
title-class="d-flex align-items-center p-0"
@ok="onSave"
>
<template #modal-title>
{{ $t('allRecords.columns.title') }}
<c-hint
:tooltip="$t('allRecords.tooltip.configureColumns')"
icon-class="text-warning"
/>
</template>
<field-picker
:module="module"
:fields.sync="filteredFields"
:field-subset="fieldSubset"
style="height: 71vh;"
/>
</b-modal>
</div>
</template>
<script>
import FieldPicker from 'corteza-webapp-compose/src/components/Common/FieldPicker'
export default {
i18nOptions: {
namespaces: 'module',
},
components: {
FieldPicker,
},
props: {
module: {
type: Object,
required: true,
default: () => ({}),
},
fields: {
type: Array,
required: true,
default: () => [],
},
disabled: {
type: Boolean,
default: false,
},
size: {
type: String,
default: 'lg',
},
variant: {
type: String,
default: 'light',
},
buttonClass: {
type: String,
default: 'flex-fill',
},
fieldSubset: {
type: Array,
required: false,
default: () => null,
},
buttonTooltip: {
type: Object,
default: () => ({}),
},
},
data () {
return {
showModal: false,
filteredFields: [],
}
},
watch: {
fields: {
immediate: true,
handler (fields) {
if (fields) {
this.filteredFields = this.module.filterFields(fields)
}
},
},
},
beforeDestroy () {
this.setDefaultValues()
},
methods: {
onSave () {
this.$emit('updateFields', this.filteredFields)
},
setDefaultValues () {
this.filteredFields = []
},
},
}
</script>
<style lang="scss" scoped>
.fit-modal {
max-height: calc(100% - 3.5rem);
}
</style>

View File

@@ -0,0 +1,174 @@
<template>
<div v-if="namespace.canManageNamespace">
<b-dropdown
v-if="recordPage"
:size="size"
variant="primary"
:text="$t('related-pages')"
:boundary="boundary"
:disabled="!module.name"
class="related-pages-dropdown flex-fill"
>
<b-dropdown-item
v-if="recordPage"
data-test-id="dropdown-link-record-page-edit"
:disabled="!namespace.canManageNamespace"
:to="{ name: 'admin.pages.builder', params: { pageID: recordPage.pageID } }"
>
{{ $t('recordPage.edit') }}
</b-dropdown-item>
<b-dropdown-item
v-if="recordListPage"
:to="{ name: 'admin.pages.builder', params: { pageID: recordListPage.pageID } }"
>
{{ $t('recordListPage.edit') }}
</b-dropdown-item>
<b-dropdown-item-button
v-else
data-test-id="dropdown-link-record-list-page-create"
:disabled="processing"
@click.prevent.stop="handleRecordListPageCreation"
>
{{ $t('recordListPage.create') }}
</b-dropdown-item-button>
</b-dropdown>
<b-button
v-else
data-test-id="button-record-page-create"
variant="primary"
:size="size"
:disabled="processing || !module.name"
@click.stop.prevent="handleRecordPageCreation"
>
{{ $t('recordPage.create') }}
</b-button>
</div>
</template>
<script>
import { mapGetters, mapActions } from 'vuex'
import { compose, NoID } from '@cortezaproject/corteza-js'
export default {
i18nOptions: {
namespaces: 'module',
},
props: {
namespace: {
type: compose.Namespace,
required: true,
},
module: {
type: compose.Module,
required: true,
},
size: {
type: String,
default: 'md',
},
boundary: {
type: String,
default: 'viewport',
},
},
data () {
return {
processing: false,
}
},
computed: {
...mapGetters({
pages: 'page/set',
}),
recordPage () {
return this.pages.find(p => p.moduleID === this.module.moduleID)
},
recordListPage () {
return this.pages.find(p => {
return p.blocks.find(b => b.kind === 'RecordList' && b.options.moduleID === this.module.moduleID)
})
},
},
methods: {
...mapActions({
createPage: 'page/create',
updatePage: 'page/update',
createPageLayout: 'pageLayout/create',
}),
handleRecordPageCreation () {
this.processing = true
const { name, moduleID } = this.module
const { namespaceID } = this.namespace
// A simple record block w/o preselected fields
const blocks = [new compose.PageBlockRecord({ xywh: [0, 0, 48, 82] })]
const selfID = (this.recordListPage || {}).pageID || NoID
const page = new compose.Page({
namespaceID,
moduleID,
selfID,
title: this.$t('forModule.recordPage', { name, interpolation: { escapeValue: false } }),
blocks,
})
this.createPage(page).then(({ pageID, title, blocks }) => {
const pageLayout = new compose.PageLayout({ namespaceID, pageID, handle: 'primary', blocks, meta: { title } })
return this.createPageLayout(pageLayout)
}).catch(this.toastErrorHandler(this.$t('notification:module.recordPage.createFailed')))
.finally(() => {
this.processing = false
})
},
handleRecordListPageCreation () {
this.processing = true
const { namespaceID } = this.namespace
const { name, moduleID } = this.module
const blocks = [new compose.PageBlockRecordList({
xywh: [0, 0, 48, 82],
options: {
moduleID,
fields: [],
},
})]
const page = new compose.Page({
title: name,
namespaceID,
blocks,
visible: true,
})
this.createPage(page)
.then(({ pageID, title, blocks }) => {
const pageLayout = new compose.PageLayout({ namespaceID, pageID, handle: 'primary', blocks, meta: { title } })
return Promise.all([
this.updatePage({ ...this.recordPage, selfID: pageID }),
this.createPageLayout(pageLayout),
])
})
.catch(this.toastErrorHandler(this.$t('notification:module.recordPage.createFailed')))
.finally(() => {
this.processing = false
})
},
},
}
</script>

View File

@@ -0,0 +1,284 @@
<template>
<div>
<h5 class="d-flex align-items-center mb-3">
{{ $t('duplicationDetection') }}
<c-hint
:tooltip="$t('tooltip.performance')"
icon-class="text-warning"
/>
</h5>
<div
v-for="(rule, index) in rules"
:key="index"
>
<label class="d-flex align-items-center text-primary">
{{ $t('uniqueValueConstraint', { index: index + 1 }) }}
<c-input-confirm
show-icon
size="md"
button-class="px-2"
class="ml-2"
@confirmed="rules.splice(index, 1)"
/>
</label>
<div class="d-flex align-items-center justify-content-between flex-wrap w-100">
<b-form-group>
<c-input-select
v-model="rule.currentField"
:placeholder="$t('searchFields')"
:get-option-label="getOptionLabel"
:get-option-key="getOptionKey"
:options="filterFieldOptions(rule)"
:reduce="o => o.name"
style="min-width: 300px;"
@input="updateRuleConstraint(rule)"
/>
</b-form-group>
<b-form-group
:label="$t('preventRecordsSave')"
label-class="text-primary ml-auto"
>
<c-input-checkbox
v-model="rule.strict"
switch
:labels="checkboxLabel"
/>
</b-form-group>
</div>
<div
v-if="rule.constraints && rule.constraints.length > 1"
class="text-muted small mb-2"
>
{{ $t('allFieldsMustMatch') }}
</div>
<c-form-table-wrapper
v-if="rule.constraints && rule.constraints.length > 0"
hide-add-button
>
<b-table-simple
borderless
small
responsive
class="mb-0"
>
<thead>
<tr class="text-primary">
<th scope="col">
{{ $t("field") }}
</th>
<th scope="col">
{{ $t("type") }}
</th>
<th
scope="col"
style="width: 250px;"
>
{{ $t("valueModifiers") }}
</th>
<th
scope="col"
style="width: 250px;"
>
{{ $t("multiValues") }}
</th>
<th
scope="col"
style="width: 5rem;"
/>
</tr>
</thead>
<tbody v-if="rule.constraints">
<tr
v-for="(constraint, consIndex) in rule.constraints"
:key="`constraint-${consIndex}`"
>
<td>{{ getOptionLabel(getField(constraint.attribute)) }}</td>
<td>{{ getField(constraint.attribute).kind }}</td>
<td>
<b-form-select
v-model="constraint.modifier"
:options="modifierOptions"
size="sm"
/>
</td>
<td>
<b-form-select
v-model="constraint.multiValue"
:options="multiValueOptions"
:disabled="!getField(constraint.attribute).isMulti"
size="sm"
/>
</td>
<td class="text-right align-middle">
<c-input-confirm
show-icon
@confirmed="rule.constraints.splice(consIndex, 1)"
/>
</td>
</tr>
</tbody>
</b-table-simple>
</c-form-table-wrapper>
<hr>
</div>
<div class="d-flex">
<b-button
variant="primary"
class="d-flex align-items-center"
@click="addNewConstraint"
>
<font-awesome-icon
:icon="['fas', 'plus']"
class="mr-2"
/>
{{ $t("addNewConstraint") }}
</b-button>
</div>
</div>
</template>
<script>
import { compose } from '@cortezaproject/corteza-js'
export default {
i18nOptions: {
namespaces: 'module',
keyPrefix: 'edit.config.uniqueValues',
},
props: {
module: {
type: compose.Module,
required: true,
},
},
data () {
return {
checkboxLabel: {
on: this.$t('general:label.yes'),
off: this.$t('general:label.no'),
},
}
},
computed: {
rules: {
get () {
return this.module.config.recordDeDup.rules
},
set (value) {
this.module.config.recordDeDup.rules = value
},
},
isEnabled: {
get () {
return this.module.config.recordDeDup.enabled
},
set (value) {
this.module.config.recordDeDup.enabled = value
},
},
modifierOptions () {
const ruleModifiers = this.rules.reduce((acc, { constraints }) => {
if (!constraints) {
return acc
}
constraints.forEach(({ modifier }) => {
if (!acc.includes(modifier)) {
acc.push(modifier)
}
})
return acc
}, [])
return [
{ value: 'ignore-case', text: this.$t('ignoreCase') },
{ value: 'fuzzy-match', text: this.$t('fuzzyMatch'), legacy: true },
{ value: 'sounds-like', text: this.$t('soundsLike'), legacy: true },
{ value: 'case-sensitive', text: this.$t('caseSensitive') },
].filter(({ value, legacy }) => !legacy || ruleModifiers.includes(value))
},
multiValueOptions () {
return [
{ value: 'one-of', text: this.$t('oneOf') },
{ value: 'equal', text: this.$t('equal') },
]
},
},
methods: {
addNewConstraint () {
this.rules.push({
name: '',
strict: true,
constraints: [],
})
},
updateRuleConstraint (rule) {
rule.currentField = this.module.fields.find(({ name }) => name === rule.currentField)
if (!rule.constraints) {
rule.constraints = []
}
rule.constraints.push({
attribute: rule.currentField.name,
modifier: 'case-sensitive',
multiValue: 'equal',
type: rule.currentField.kind,
isMulti: rule.currentField.isMulti,
})
rule.currentField = undefined
},
filterFieldOptions (rule) {
const selectedFields = rule.constraints ? rule.constraints.map(({ attribute }) => attribute) : []
return this.module.fields.filter(({ name }) => !selectedFields.includes(name))
},
getField (attribute) {
const field = this.module.fields.find(
({ name }) => name === attribute,
)
return field || {}
},
getOptionLabel ({ kind, label, name }) {
return label || name || kind
},
getOptionKey ({ fieldID }) {
return fieldID
},
},
}
</script>
<style lang="scss" scoped>
.list-background {
background-color: var(--body-bg);
}
</style>

View File

@@ -0,0 +1,124 @@
<template>
<div
v-if="module"
>
<h5>{{ $t('record-duplication.title') }}</h5>
<b-form-group
:label="$t('record-duplication.strict-fields.label')"
label-class="text-primary pb-0"
class="my-4"
>
<small>{{ $t('record-duplication.strict-fields.description') }}</small>
<field-picker
:module="module"
:fields.sync="strictFields"
:field-subset="module.fields"
disable-system-fields
style="height: 35vh;"
class="mt-3"
/>
</b-form-group>
<hr>
<b-form-group
:label="$t('record-duplication.non-strict-fields.label')"
label-class="text-primary pb-0"
class="mt-4"
>
<small>{{ $t('record-duplication.non-strict-fields.description') }}</small>
<field-picker
:module="module"
:fields.sync="nonStrictFields"
:field-subset="module.fields"
disable-system-fields
style="height: 35vh;"
class="mt-3"
/>
</b-form-group>
</div>
</template>
<script>
import { compose } from '@cortezaproject/corteza-js'
import FieldPicker from 'corteza-webapp-compose/src/components/Common/FieldPicker'
export default {
i18nOptions: {
namespaces: 'module',
keyPrefix: 'edit.config.validation',
},
components: {
FieldPicker,
},
props: {
module: {
type: compose.Module,
required: true,
},
},
computed: {
strictFields: {
// Get list of strict fields that are inside rules
get () {
return this.getRuleFields(true).map(({ attributes }) => {
return { name: attributes[0] }
})
},
// Merge current non strict fields with the new strict
set (fields = []) {
const fieldNames = fields.map(({ name }) => name)
this.module.config.recordDeDup.rules = [
...this.getRuleFields(false).filter(({ attributes }) => !fieldNames.includes(attributes[0])),
...fieldNames.map(name => {
return {
name: 'case-sensitive',
strict: true,
attributes: [name],
}
}),
]
},
},
nonStrictFields: {
// Get list of non-strict fields that are inside rules
get () {
return this.getRuleFields(false).map(({ attributes }) => {
return { name: attributes[0] }
})
},
// Merge current strict fields with the new non-strict
set (fields = []) {
const fieldNames = fields.map(({ name }) => name)
this.module.config.recordDeDup.rules = [
...this.getRuleFields(true).filter(({ attributes }) => !fieldNames.includes(attributes[0])),
...fieldNames.map(name => {
return {
name: 'case-sensitive',
strict: false,
attributes: [name],
}
}),
]
},
},
},
methods: {
getRuleFields (strictValue) {
return this.module.config.recordDeDup.rules.filter(({ name, strict, attributes = [] }) => {
return strict === strictValue && name === 'case-sensitive' && attributes.length
})
},
},
}
</script>

View File

@@ -0,0 +1,53 @@
export const types = {
Omit: 'omit',
Plain: 'plain',
Alias: 'alias',
JSON: 'json',
}
// default for system fields means null!
export function systemFieldStrategyConfig (strategy, config) {
switch (strategy) {
case types.JSON:
return { [strategy]: { ident: config.ident } }
case types.Alias:
return { [strategy]: { ident: config.ident } }
case types.Omit:
return { [strategy]: true }
default:
return null
}
}
// default for module fields means JSON!
export function moduleFieldStrategyConfig (strategy, config) {
switch (strategy) {
case types.Plain:
return { [types.Plain]: {} }
case types.Alias:
return { [strategy]: { ident: config.ident } }
case types.JSON:
return { [strategy]: { ident: config.ident } }
case types.Omit:
return { [strategy]: true }
default:
return null
}
}
// extract ident from config
export function defaultConfigDraft (config, ident) {
if (typeof config !== 'object') {
return { ident, def: 1 }
}
for (const t of [types.Alias, types.JSON]) {
if (config[t] && config[t].ident) {
return { ...config[t] }
}
}
return { ident, def: 2 }
}

View File

@@ -0,0 +1,228 @@
<template>
<b-container fluid>
<b-row>
<b-col
cols="12"
>
<b-button
v-for="(type) in types"
:key="type.label"
:disabled="isOptionDisabled(type)"
variant="outline-light"
class="mr-2 mb-2 text-dark"
@click="$emit('select', type.block)"
@mouseover="current = type.image"
@mouseleave="current = undefined"
>
{{ type.label }}
</b-button>
</b-col>
<b-col
cols="12"
style="height: 50vh;"
class="d-flex align-items-center"
>
<b-img
v-if="current"
:src="current"
center
fluid
class="mx-auto mh-100"
/>
</b-col>
<hr
v-if="existingBlocks.length"
class="w-100"
>
<b-col
v-if="existingBlocks.length"
cols="12"
>
<b-input-group class="d-flex w-100">
<c-input-select
v-model="selectedExistingBlock"
:get-option-label="getBlockLabel"
:get-option-key="b => b.blockID"
:options="existingBlocks"
:placeholder="$t('selector.selectableBlocks.placeholder')"
/>
<b-input-group-append>
<b-button
v-b-tooltip.noninteractive.hover="{ title: $t('selector.tooltip.clone.noRef'), boundary: 'body' }"
variant="extra-light"
:disabled="!selectedExistingBlock"
class="d-flex align-items-center"
@click="$emit('select', selectedExistingBlock.clone())"
>
<font-awesome-icon
:icon="['far', 'clone']"
/>
</b-button>
<b-button
v-b-tooltip.noninteractive.hover="{ title: $t('selector.tooltip.clone.ref'), boundary: 'body' }"
variant="extra-light"
:disabled="!selectedExistingBlock"
class="d-flex align-items-center"
@click="$emit('select', selectedExistingBlock)"
>
<font-awesome-icon
:icon="['far', 'copy']"
/>
</b-button>
</b-input-group-append>
</b-input-group>
</b-col>
</b-row>
</b-container>
</template>
<script>
import * as images from '../../../../assets/PageBlocks'
import { compose } from '@cortezaproject/corteza-js'
export default {
i18nOptions: {
namespaces: 'block',
},
props: {
recordPage: {
type: Boolean,
default: false,
},
disabledKinds: {
type: Array,
default: () => [],
},
existingBlocks: {
type: Array,
default: () => [],
},
},
data () {
return {
current: undefined,
selectedExistingBlock: undefined,
types: [
{
label: this.$t('automation.label'),
block: new compose.PageBlockAutomation(),
image: images.Automation,
},
{
label: this.$t('calendar.label'),
block: new compose.PageBlockCalendar(),
image: images.Calendar,
},
{
label: this.$t('chart.label'),
block: new compose.PageBlockChart(),
image: images.Chart,
},
{
label: this.$t('content.label'),
block: new compose.PageBlockContent(),
image: images.Content,
},
{
label: this.$t('comment.label'),
block: new compose.PageBlockComment(),
image: images.Comment,
},
{
label: this.$t('file.label'),
block: new compose.PageBlockFile(),
image: images.File,
},
{
label: this.$t('iframe.label'),
block: new compose.PageBlockIFrame(),
image: images.IFrame,
},
{
label: this.$t('metric.label'),
block: new compose.PageBlockMetric(),
image: images.Metric,
},
{
label: this.$t('record.label'),
block: new compose.PageBlockRecord(),
image: images.Record,
recordPageOnly: true,
},
{
label: this.$t('recordList.label'),
block: new compose.PageBlockRecordList(),
image: images.RecordList,
},
{
label: this.$t('recordOrganizer.label'),
block: new compose.PageBlockRecordOrganizer(),
image: images.RecordOrganizer,
},
{
label: this.$t('recordRevisions.label'),
block: new compose.PageBlockRecordRevisions(),
image: images.RecordRevisions,
recordPageOnly: true,
},
{
label: this.$t('report.label'),
block: new compose.PageBlockReport(),
image: images.Report,
},
{
label: this.$t('progress.label'),
block: new compose.PageBlockProgress(),
image: images.Progress,
},
{
label: this.$t('geometry.label'),
block: new compose.PageBlockGeometry(),
image: images.Geometry,
},
{
label: this.$t('tabs.label'),
block: new compose.PageBlockTab(),
image: images.Tabs,
},
{
label: this.$t('navigation.label'),
block: new compose.PageBlockNavigation(),
image: images.Navigation,
},
],
}
},
beforeDestroy () {
this.setDefaultValues()
},
methods: {
isOptionDisabled (type) {
return (!this.recordPage && type.recordPageOnly) || this.disabledKinds.includes(type.block.kind)
},
getBlockLabel ({ title, kind }) {
return title || kind
},
setDefaultValues () {
this.current = undefined
this.selectedExistingBlock = undefined
this.types = []
},
},
}
</script>

View File

@@ -0,0 +1,317 @@
<template>
<c-translator-button
v-if="canManageResourceTranslations && resourceTranslationsEnabled"
v-bind="{ ...$attrs, ...$props }"
:tooltip="$t('tooltip')"
:resource="resource"
:titles="titles"
:fetcher="fetcher"
:updater="updater"
/>
</template>
<script>
import { compose, NoID } from '@cortezaproject/corteza-js'
import { mapGetters } from 'vuex'
import CTranslatorButton from 'corteza-webapp-compose/src/components/Translator/CTranslatorButton'
export default {
components: {
CTranslatorButton,
},
i18nOptions: {
namespaces: 'resource-translator',
keyPrefix: 'resources.page',
},
props: {
page: {
type: compose.Page,
required: true,
},
pageLayouts: {
type: Array,
default: () => [],
},
pageLayout: {
type: compose.PageLayout,
required: false,
default: undefined,
},
block: {
type: compose.PageBlock,
required: false,
default: undefined,
},
highlightKey: {
type: String,
default: '',
},
disabled: {
type: Boolean,
default: () => false,
},
},
computed: {
...mapGetters({
can: 'rbac/can',
}),
canManageResourceTranslations () {
return this.can('compose/', 'resource-translations.manage')
},
resource () {
const { pageID, namespaceID } = this.page
return `compose:page/${namespaceID}/${pageID}`
},
titles () {
const titles = {}
if (this.block) {
const { title, blockID } = this.block
titles[this.resource] = this.$t('block.title', { title, blockID })
} else {
const { pageID, handle } = this.page
titles[this.resource] = this.$t('title', { handle: handle || pageID })
}
if (this.pageLayout) {
const { namespaceID, pageID, pageLayoutID, handle, meta } = this.pageLayout
titles[`compose:page-layout/${namespaceID}/${pageID}/${pageLayoutID}`] = this.$t('layout.title', { handle: handle || meta.title || pageLayoutID })
} else {
this.pageLayouts.forEach(({ namespaceID, pageID, pageLayoutID, handle, meta }) => {
titles[`compose:page-layout/${namespaceID}/${pageID}/${pageLayoutID}`] = this.$t('layout.title', { handle: handle || meta.title || pageLayoutID })
})
}
return titles
},
fetcher () {
const { pageID, namespaceID } = this.page
return () => {
return this.$ComposeAPI
.pageListTranslations({ namespaceID, pageID })
.then(set => {
if (this.block) {
/**
* When block is set, intercept the resolved request and filter out the
* translations that are relevant for that block
*/
set = set.filter(({ key }) => key.startsWith(`pageBlock.${this.block.blockID}.`))
}
if (this.pageLayout) {
set = set.filter(({ resource }) => resource.endsWith(`${pageID}`) || resource.endsWith(`/${this.pageLayout.pageLayoutID}`))
}
return set
})
}
},
updater () {
const { pageID, namespaceID } = this.page
return translations => {
return this.$ComposeAPI
.pageUpdateTranslations({ namespaceID, pageID, translations })
// re-fetch translations, sanitized and stripped
.then(() => this.fetcher())
.then((translations) => {
// When translations are successfully saved,
// scan changes and apply them back to the passed object
// not the most elegant solution but is saves us from
// handling the resource on multiple places
//
// @todo move this to Namespace* classes
// the logic there needs to be implemented; the idea is to encode
// values from the set of translations back to the resource object
const find = (key) => {
return translations.find(t => t.key === key && t.lang === this.currentLanguage && t.resource === this.resource)
}
const layoutResource = ({ pageLayoutID, pageID, namespaceID }) => `compose:page-layout/${namespaceID}/${pageID}/${pageLayoutID}`
let tr = find('title')
if (tr !== undefined) {
// eslint-disable-next-line vue/no-side-effects-in-computed-properties
this.page.title = tr.message
}
tr = find('description')
if (tr !== undefined) {
// eslint-disable-next-line vue/no-side-effects-in-computed-properties
this.page.description = tr.message
}
// Refresh page buttons for record pages
if (this.page.moduleID && this.page.moduleID !== NoID) {
tr = find('recordToolbar.new.label')
if (tr) {
this.$set(this.page.config.buttons.new, 'label', tr.message)
}
tr = find('recordToolbar.edit.label')
if (tr) {
this.$set(this.page.config.buttons.edit, 'label', tr.message)
}
tr = find('recordToolbar.submit.label')
if (tr) {
this.$set(this.page.config.buttons.submit, 'label', tr.message)
}
tr = find('recordToolbar.delete.label')
if (tr) {
this.$set(this.page.config.buttons.delete, 'label', tr.message)
}
tr = find('recordToolbar.clone.label')
if (tr) {
this.$set(this.page.config.buttons.clone, 'label', tr.message)
}
tr = find('recordToolbar.back.label')
if (tr) {
this.$set(this.page.config.buttons.back, 'label', tr.message)
}
}
const updateBlockTranslations = block => {
if (block.blockID === NoID) return block
block.title = (find(`pageBlock.${block.blockID}.title`) || {}).message
block.description = (find(`pageBlock.${block.blockID}.description`) || {}).message
switch (true) {
case block instanceof compose.PageBlockAutomation:
block.options.buttons.forEach((btn, index) => {
tr = find(`pageBlock.${block.blockID}.button.${btn.buttonID || index}.label`)
if (tr) {
btn.label = tr.message
}
})
break
case block instanceof compose.PageBlockRecordList:
block.options.selectionButtons.forEach((btn, index) => {
tr = find(`pageBlock.${block.blockID}.button.${btn.buttonID || index}.label`)
if (tr) {
btn.label = tr.message
}
})
break
case block instanceof compose.PageBlockContent:
tr = find(`pageBlock.${block.blockID}.content.body`)
if (tr) {
block.options.body = tr.message
}
break
}
return block
}
const updatePageLayoutTranslations = pageLayout => {
if (pageLayout.pageLayoutID === NoID) return pageLayout
const find = (key) => {
return translations.find(t => t.key === key && t.lang === this.currentLanguage && t.resource === layoutResource(pageLayout))
}
let tr = find('title')
if (tr !== undefined) {
pageLayout.meta.title = tr.message
}
tr = find('description')
if (tr !== undefined) {
pageLayout.meta.description = tr.message
}
// Refresh page buttons for record pages
if (pageLayout.moduleID && pageLayout.moduleID !== NoID) {
tr = find('config.buttons.new.label')
if (tr) {
pageLayout.config.buttons.new.label = tr.message
}
tr = find('config.buttons.edit.label')
if (tr) {
pageLayout.config.buttons.edit.label = tr.message
}
tr = find('config.buttons.submit.label')
if (tr) {
pageLayout.config.buttons.submit.label = tr.message
}
tr = find('config.buttons.delete.label')
if (tr) {
pageLayout.config.buttons.delete.label = tr.message
}
tr = find('config.buttons.clone.label')
if (tr) {
pageLayout.config.buttons.clone.label = tr.message
}
tr = find('config.buttons.back.label')
if (tr) {
pageLayout.config.buttons.back.label = tr.message
}
}
return pageLayout
}
if (this.block) {
// eslint-disable-next-line vue/no-side-effects-in-computed-properties
this.block = updateBlockTranslations(this.block)
} else {
// eslint-disable-next-line vue/no-side-effects-in-computed-properties
this.page.blocks = this.page.blocks.map(block => updateBlockTranslations(block))
}
if (this.pageLayout) {
// eslint-disable-next-line vue/no-side-effects-in-computed-properties
this.pageLayout = updatePageLayoutTranslations(this.pageLayout)
} else {
// eslint-disable-next-line vue/no-side-effects-in-computed-properties
this.pageLayouts = this.pageLayouts.map(pageLayout => updatePageLayoutTranslations(pageLayout))
}
return this.page
})
.then(page => {
this.$emit('update:page', page)
if (this.block) {
this.$emit('update:block', this.block)
}
if (this.pageLayout) {
this.$emit('update:pageLayout', this.pageLayout)
} else {
this.$emit('update:pageLayouts', this.pageLayouts)
}
})
}
},
},
}
</script>

View File

@@ -0,0 +1,69 @@
// eslint-disable-next-line
import { default as component } from './Tree.vue'
import { compose } from '@cortezaproject/corteza-js'
// import { components } from '@cortezaproject/corteza-vue'
// const { checkbox, input } = components.C3.controls
// TypeError: Cannot read property 'getters' of undefined at VueComponent.mappedGetter
const props = {
namespace: new compose.Namespace({
canCreatePage: true,
canGrant: true,
namespaceID: '',
}),
value: [
{
pageID: '44444444444',
moduleID: '324234324',
title: 'Home',
handle: 'Home',
visible: true,
canGrant: true,
canUpdatePage: true,
blocks: [{
kind: 'RecordList',
title: 'My New Leads',
}],
},
{
pageID: '456454644444',
moduleID: '004234324',
title: 'Test',
handle: 'Test',
visible: true,
canGrant: true,
canUpdatePage: true,
blocks: [{
kind: 'RecordList',
title: 'My New Leads',
}],
},
],
parentID: '',
level: 0,
}
export default {
name: 'Tree WIP',
group: ['Admin', 'Page'],
component,
props,
controls: [],
// controls: [
// checkbox('', ''),
// input(' of step', ''),
// ],
// scenarios: [
// {
// label: 'Full form',
// props,
// },
// {
// label: 'Empty form',
// props: {
// ...props,
// },
// },
// ],
}

View File

@@ -0,0 +1,478 @@
<template>
<div>
<sortable-tree
v-if="list.length"
:draggable="namespace.canCreatePage"
:data="{ children: list }"
tag="ul"
mixin-parent-key="parent"
class="list-group"
@changePosition="handleChangePosition"
>
<template
slot-scope="{item}"
>
<div
v-if="item.pageID"
no-gutters
class="d-flex flex-wrap align-content-center justify-content-between"
>
<div
class="px-2 flex-fill overflow-hidden text-truncate gap-1 rounded-lg"
:class="{'grab': namespace.canCreatePage }"
>
{{ item.title }}
<font-awesome-icon
v-if="!item.visible && item.moduleID == '0'"
v-b-tooltip.noninteractive.hover="{ title: $t('notVisible'), boundary: 'body' }"
class="text-danger"
:icon="['fas', 'eye-slash']"
/>
<b-badge
v-if="!isValid(item)"
variant="danger"
class="ml-1"
>
{{ $t('invalid') }}
</b-badge>
</div>
<div class="actions px-2">
<b-button-group
v-if="item.canUpdatePage"
size="sm"
>
<b-button
data-test-id="button-page-builder"
variant="primary"
size="sm"
:to="{name: 'admin.pages.builder', params: { pageID: item.pageID }}"
>
{{ $t('block.general.label.pageBuilder') }}
<font-awesome-icon
:icon="['fas', 'tools']"
class="ml-2"
/>
</b-button>
<b-button
v-b-tooltip.noninteractive.hover="{ title: $t('tooltip.view'), boundary: 'body' }"
data-test-id="button-page-view"
variant="primary"
:to="pageViewer(item)"
class="d-flex align-items-center"
style="margin-left:2px;"
>
<font-awesome-icon
:icon="['far', 'eye']"
/>
</b-button>
<b-button
v-b-tooltip.noninteractive.hover="{ title: $t('tooltip.edit.page'), boundary: 'body' }"
data-test-id="button-page-edit"
variant="primary"
:to="{name: 'admin.pages.edit', params: { pageID: item.pageID }}"
class="d-flex align-items-center"
style="margin-left:2px;"
>
<font-awesome-icon
:icon="['far', 'edit']"
/>
</b-button>
</b-button-group>
<b-dropdown
v-if="item.canGrant || namespace.canGrant"
v-b-tooltip.noninteractive.hover="{ title: $t('permissions:resources.compose.page.tooltip'), boundary: 'body' }"
data-test-id="dropdown-permissions"
variant="extra-light"
size="sm"
class="ml-2"
>
<template #button-content>
<font-awesome-icon :icon="['fas', 'lock']" />
</template>
<c-permissions-button
v-if="namespace.canGrant"
:title="item.title || item.handle || item.pageID"
:target="item.title || item.handle || item.pageID"
:resource="`corteza::compose:page/${namespace.namespaceID}/${item.pageID}`"
:button-label="$t('general:label.page')"
:show-button-icon="false"
class="dropdown-item"
/>
<c-permissions-button
v-if="item.canGrant"
:title="item.title || item.handle || item.pageID"
:target="item.title || item.handle || item.pageID"
:resource="`corteza::compose:page-layout/${namespace.namespaceID}/${item.pageID}/*`"
:button-label="$t('general:label.pageLayout')"
:show-button-icon="false"
all-specific
class="dropdown-item"
/>
</b-dropdown>
<template v-if="item.canDeletePage">
<b-dropdown
v-if="hasChildren(item)"
data-test-id="dropdown-delete"
variant="outline-danger"
:disabled="item.processingDelete"
size="sm"
toggle-class="border-0"
class="ml-2"
>
<template #button-content>
<font-awesome-icon
v-if="!item.processingDelete"
:icon="['far', 'trash-alt']"
/>
<b-spinner
v-else
small
/>
</template>
<b-dropdown-item-button
data-test-id="dropdown-item-delete-update-parent-of-sub-pages"
@click="handleDeletePage(item, 'rebase')"
>
{{ $t('delete.rebase') }}
</b-dropdown-item-button>
<b-dropdown-item-button
data-test-id="dropdown-item-delete-sub-pages"
@click="handleDeletePage(item, 'cascade')"
>
{{ $t('delete.cascade') }}
</b-dropdown-item-button>
</b-dropdown>
<c-input-confirm
v-else
show-icon
size="md"
button-class="px-2"
:processing="item.processingDelete"
class="ml-2"
@confirmed="handleDeletePage(item, 'cascade')"
/>
</template>
</div>
</div>
</template>
</sortable-tree>
<div
v-else
class="text-center mt-5 mb-4 pb-1"
>
{{ $t('noPages') }}
</div>
</div>
</template>
<script>
import { mapGetters, mapActions } from 'vuex'
import SortableTree from 'vue-sortable-tree'
import { compose, NoID } from '@cortezaproject/corteza-js'
export default {
i18nOptions: {
namespaces: 'page',
},
name: 'PageTree',
components: {
SortableTree,
},
props: {
namespace: {
type: compose.Namespace,
required: true,
},
value: {
type: Array,
required: true,
},
parentID: {
type: String,
default: NoID,
},
level: {
type: Number,
default: 0,
},
},
computed: {
...mapGetters({
getModuleByID: 'module/getByID',
}),
list: {
get () {
return this.value
},
set (pages) {
this.$emit('input', pages.filter(p => p))
},
},
},
methods: {
...mapActions({
deletePage: 'page/delete',
updatePage: 'page/update',
}),
moduleName ({ moduleID }) {
if (moduleID === NoID) {
return ''
}
return (this.getModuleByID(moduleID) || {}).name
},
pageViewer ({ pageID = NoID, moduleID = NoID }) {
const name = moduleID !== NoID ? 'page.record.create' : 'page'
return { name, params: { pageID } }
},
handleChangePosition ({ beforeParent, data, afterParent }) {
const { namespaceID } = this.namespace
const beforeID = beforeParent.parent ? beforeParent.pageID : NoID
const afterID = afterParent.parent ? afterParent.pageID : NoID
const reorder = () => {
const pageIDs = afterParent.children.map(p => p.pageID)
if (pageIDs.length) {
this.$ComposeAPI.pageReorder({ namespaceID, selfID: afterID, pageIDs }).then(() => {
this.$emit('reorder')
setTimeout(() => {
this.$store.dispatch('page/load', { namespaceID, clear: true, force: true })
}, 1000)
})
.catch(this.toastErrorHandler(this.$t('pageMoveFailed')))
}
}
if (beforeID !== afterID) {
// Page moved to a different parent
data.weight = 1
data.selfID = afterID
data.namespaceID = namespaceID
this.updatePage(data).then(() => {
reorder()
}).catch(this.toastErrorHandler(this.$t('pageMoveFailed')))
} else {
reorder()
}
},
handleDeletePage (page, strategy = 'abort') {
this.$set(page, 'processingDelete', true)
this.deletePage({ ...page, strategy }).then(() => {
setTimeout(() => {
this.$emit('reorder')
this.toastSuccess(this.$t('notification:page.deleted'))
}, 300)
})
.catch(this.toastErrorHandler(this.$t('notification:page.deleteFailed')))
.finally(() => {
setTimeout(() => {
this.$set(page, 'processingDelete', false)
}, 300)
})
},
/**
* Validates page, returns true if there are no problems with it
*
* @param {compose.Page} page
* @returns {boolean}
*/
isValid (page) {
if (typeof page.validate === 'function') {
return page.validate().length === 0
}
return true
},
hasChildren (page) {
return page.children && page.children.length > 0
},
},
}
</script>
<style lang="scss" scoped>
.grab {
cursor: grab;
z-index: 1;
}
</style>
<style lang="scss">
//!important usage to over-ride library styling
$input-height: 2.4rem;
$content-height: 2.4rem;
$blank-li-height: 1rem;
$padding: 0.5rem;
$border-color: var(--light);
$hover-color: var(--extra-light);
$dropping-color: var(--primary);
.page-name-input {
height: $input-height;
}
.list-group {
margin-right: $padding;
.content {
height: 0 !important;
}
.droper {
background-color: $dropping-color !important;
border-radius: 0.3rem !important;
margin-left: 0 !important;
.blank-li.first-child {
background-color: $dropping-color !important;
}
}
.draging {
background-color: var(--white) !important;
}
ul {
.content {
height: 100% !important;
min-height: $content-height !important;
line-height: $content-height !important;
background-color: var(--light);
border-radius: 0.3rem !important;
margin-left: 12px;
.actions {
display: none;
.dropdown-menu {
min-height: initial !important;
line-height: initial !important;
}
}
&:hover {
background-color: $hover-color;
.actions {
display: block;
}
}
}
}
li {
white-space: nowrap;
background: var(--white);
&.blank-li {
height: $blank-li-height !important;
.sortable-tree {
max-height: 100%;
}
&:nth-last-of-type(1)::before {
border-left-color: var(--white) !important;
height: 0;
}
}
&::before {
top: calc($content-height / -2) !important;
border-left-color: var(--white) !important;
}
&::after {
height: $content-height !important;
top: calc($content-height / 2) !important;
border-color: var(--white) !important;
}
&.parent-li:nth-last-child(2)::before {
height: $content-height !important;
top: calc($content-height / -2) !important;
}
}
.parent-li {
border-top: 1px solid $border-color;
padding-bottom: $padding !important;
padding-top: $padding !important;
.exist-li,
.blank-li {
border-top: none;
padding-top: 0 !important;
padding-bottom: 0 !important;
&::after {
border-top: 2px solid $border-color !important;
margin-left: 0;
}
&::before {
border-left: 2px solid $border-color !important;
}
}
&.blank-li {
&::before {
border-left: 2px solid $border-color !important;
}
}
&.exist-li {
&::before {
border-color: var(--white) !important;
}
.parent-li {
&.exist-li {
&::before {
border-color: $border-color !important;
}
}
}
}
}
}
.pages-list-header {
min-height: $content-height;
background-color: var(--gray-200);
margin-bottom: -1.8rem !important;
border-bottom: 2px solid var(--light);
z-index: 1;
}
</style>

View File

@@ -0,0 +1,156 @@
<template>
<c-translator-button
v-if="canManageResourceTranslations && resourceTranslationsEnabled"
v-bind="{ ...$attrs, ...$props }"
:tooltip="$t('tooltip')"
:resource="resource"
:titles="titles"
:fetcher="fetcher"
:updater="updater"
/>
</template>
<script>
import { compose, NoID } from '@cortezaproject/corteza-js'
import { mapGetters } from 'vuex'
import CTranslatorButton from 'corteza-webapp-compose/src/components/Translator/CTranslatorButton'
export default {
components: {
CTranslatorButton,
},
i18nOptions: {
namespaces: 'resource-translator',
keyPrefix: 'resources.page-layout',
},
props: {
pageLayout: {
type: compose.PageLayout,
required: true,
},
highlightKey: {
type: String,
default: '',
},
disabled: {
type: Boolean,
default: () => false,
},
},
computed: {
...mapGetters({
can: 'rbac/can',
}),
canManageResourceTranslations () {
return this.can('compose/', 'resource-translations.manage')
},
resource () {
const { pageID, namespaceID, pageLayoutID } = this.pageLayout
return `compose:page-layout/${namespaceID}/${pageID}/${pageLayoutID}`
},
titles () {
const titles = {}
const { pageID, handle, meta } = this.pageLayout
titles[this.resource] = this.$t('title', { handle: handle || meta.title || pageID })
return titles
},
fetcher () {
const { pageLayoutID, pageID, namespaceID } = this.pageLayout
return () => {
return this.$ComposeAPI
.pageLayoutListTranslations({ namespaceID, pageID, pageLayoutID })
.then(set => {
return set
})
}
},
updater () {
const { pageID, namespaceID, pageLayoutID } = this.pageLayout
return translations => {
return this.$ComposeAPI
.pageLayoutUpdateTranslations({ namespaceID, pageID, pageLayoutID, translations })
// re-fetch translations, sanitized and stripped
.then(() => this.fetcher())
.then((translations) => {
// When translations are successfully saved,
// scan changes and apply them back to the passed object
// not the most elegant solution but is saves us from
// handling the resource on multiple places
//
// @todo move this to Namespace* classes
// the logic there needs to be implemented; the idea is to encode
// values from the set of translations back to the resource object
const find = (key) => {
return translations.find(t => t.key === key && t.lang === this.currentLanguage && t.resource === this.resource)
}
let tr = find('title')
if (tr !== undefined) {
// eslint-disable-next-line vue/no-side-effects-in-computed-properties
this.pageLayout.meta.title = tr.message
}
tr = find('description')
if (tr !== undefined) {
// eslint-disable-next-line vue/no-side-effects-in-computed-properties
this.pageLayout.meta.description = tr.message
}
// Refresh page buttons for record pages
if (this.pageLayout.moduleID && this.pageLayout.moduleID !== NoID) {
tr = find('config.buttons.new.label')
if (tr) {
this.$set(this.pageLayout.config.buttons.new, 'label', tr.message)
}
tr = find('config.buttons.edit.label')
if (tr) {
this.$set(this.pageLayout.config.buttons.edit, 'label', tr.message)
}
tr = find('config.buttons.submit.label')
if (tr) {
this.$set(this.pageLayout.config.buttons.submit, 'label', tr.message)
}
tr = find('config.buttons.delete.label')
if (tr) {
this.$set(this.pageLayout.config.buttons.delete, 'label', tr.message)
}
tr = find('config.buttons.clone.label')
if (tr) {
this.$set(this.pageLayout.config.buttons.clone, 'label', tr.message)
}
tr = find('config.buttons.back.label')
if (tr) {
this.$set(this.pageLayout.config.buttons.back, 'label', tr.message)
}
}
return this.page
})
.then(page => {
this.$emit('update:pageLayout', page)
})
}
},
},
}
</script>

View File

@@ -0,0 +1,25 @@
import * as admin from './Admin/C3'
import * as chart from './Chart/C3'
import * as common from './Common/C3'
import * as moduleFields from './ModuleFields/C3'
import * as namespace from './Namespaces/C3'
import * as publicCmps from './Public/C3'
import * as translator from './Translator/C3'
import { pageBlockBase, pageBlockConfigurators } from './PageBlocks/C3'
import FileConfigurator from './Admin/EditorToolbar.c3'
// import * as C3 from '@cortezaproject/corteza-vue'
export default {
...admin,
...chart,
...common,
...moduleFields,
...namespace,
...pageBlockConfigurators,
...pageBlockBase,
...publicCmps,
...translator,
FileConfigurator,
// ...C3,
}

View File

@@ -0,0 +1,2 @@
export { default as ReportEdit } from './Report/ReportEdit.c3'
export { default as ReportItem } from './ReportItem.c3'

View File

@@ -0,0 +1,145 @@
<template>
<c-translator-button
v-if="canManageResourceTranslations && resourceTranslationsEnabled"
v-bind="{ ...$attrs, ...$props }"
:tooltip="$t('tooltip')"
:resource="resource"
:titles="titles"
:fetcher="fetcher"
:updater="updater"
/>
</template>
<script>
import { compose } from '@cortezaproject/corteza-js'
import { mapGetters } from 'vuex'
import CTranslatorButton from 'corteza-webapp-compose/src/components/Translator/CTranslatorButton'
export default {
components: {
CTranslatorButton,
},
i18nOptions: {
namespaces: 'resource-translator',
keyPrefix: 'resources.chart',
},
props: {
field: {
type: String,
default: '',
},
chart: {
type: compose.Chart,
required: true,
},
highlightKey: {
type: String,
default: '',
},
disabled: {
type: Boolean,
default: () => false,
},
},
computed: {
...mapGetters({
can: 'rbac/can',
}),
canManageResourceTranslations () {
return this.can('compose/', 'resource-translations.manage')
},
resource () {
const { namespaceID, chartID } = this.chart
return `compose:chart/${namespaceID}/${chartID}`
},
titles () {
const { chartID, handle } = this.chart
const titles = {}
titles[this.resource] = this.$t('title', { handle: handle || chartID })
return titles
},
fetcher () {
const { namespaceID, chartID } = this.chart
return () => {
return this.$ComposeAPI.chartListTranslations({ namespaceID, chartID })
// @todo pass set of translations to the resource object
// The logic there needs to be implemented; the idea is to decode
// values from the resource object to the set of translations)
}
},
updater () {
const { namespaceID, chartID } = this.chart
return translations => {
return this.$ComposeAPI
.chartUpdateTranslations({ namespaceID, chartID, translations })
// re-fetch translations, sanitized and stripped
.then(() => this.fetcher())
.then((translations) => {
// When translations are successfully saved,
// scan changes and apply them back to the passed object
// not the most elegant solution but is saves us from
// handling the resource on multiple places
//
//
// @todo move this to Namespace* classes
// the logic there needs to be implemented; the idea is to encode
// values from the set of translations back to the resource object
const find = (key) => {
return translations.find(t => t.key === key && t.lang === this.currentLanguage && t.resource === this.resource)
}
let tr
const [report = {}] = this.chart.config.reports
tr = find('yAxis.label')
if (tr !== undefined) {
this.$set(report.yAxis, 'label', tr.message)
}
report.metrics.forEach((metric) => {
tr = find(`metrics.${metric.metricID}.label`)
if (tr) {
this.$set(metric, 'label', tr.message)
}
})
if (Array.isArray(report.dimensions)) {
report.dimensions.forEach(d => {
if (!Array.isArray(d.meta.steps)) {
return
}
d.meta.steps.forEach((step) => {
tr = find(`dimensions.${d.dimensionID}.meta.steps.${step.stepID}.label`)
if (tr) {
this.$set(step, 'label', tr.message)
}
})
})
}
return this.chart
})
.then(chart => {
this.$emit('update:chart', chart)
})
}
},
},
}
</script>

View File

@@ -0,0 +1,173 @@
<template>
<report-edit
:report.sync="editReport"
:modules="modules"
:supported-metrics="1"
>
<template #dimension-options="{ index, dimension, field }">
<b-form-group
v-if="showPicker(field)"
:label="$t('edit.dimension.options.label')"
label-class="text-primary"
>
<c-item-picker
:value="getOptions(dimension)"
:options="field.options.options"
:labels="{
searchPlaceholder:$t('edit.dimension.optionsPicker.searchPlaceholder'),
availableItems: $t('edit.dimension.optionsPicker.availableItems'),
selectAllItems: $t('edit.dimension.optionsPicker.selectAllItems'),
selectedItems: $t('edit.dimension.optionsPicker.selectedItems'),
unselectAllItems: $t('edit.dimension.optionsPicker.unselectAllItems'),
noItemsFound: $t('edit.dimension.optionsPicker.noItemsFound'),
}"
class="d-flex flex-column"
style="height: 100% !important;"
@update:value="setOptions(index, field, $event)"
/>
</b-form-group>
</template>
<template #metric-options="{ metric }">
<b-row>
<b-col
cols="12"
lg="6"
offset-lg="6"
>
<b-form-group
:label="$t('edit.metric.options.label')"
label-class="text-primary"
>
<b-form-checkbox
v-model="metric.cumulative"
>
{{ $t('edit.metric.cumulative') }}
</b-form-checkbox>
<b-form-checkbox
v-model="metric.relativeValue"
>
{{ $t('edit.metric.relative') }}
</b-form-checkbox>
<b-form-checkbox
v-model="metric.fixTooltips"
>
{{ $t('edit.metric.fixTooltips') }}
</b-form-checkbox>
</b-form-group>
</b-col>
</b-row>
<hr>
<b-row>
<b-col
cols="12"
md="6"
>
<b-form-group
:label="$t('edit.formatting.prefix.label')"
label-class="text-primary"
>
<b-input
v-model="metric.formatting.prefix"
:placeholder="$t('edit.formatting.prefix.placeholder')"
/>
</b-form-group>
</b-col>
<b-col
cols="12"
md="6"
>
<b-form-group
:label="$t('edit.formatting.suffix.label')"
label-class="text-primary"
>
<b-input
v-model="metric.formatting.suffix"
:placeholder="$t('edit.formatting.suffix.placeholder')"
/>
</b-form-group>
</b-col>
<b-col
cols="12"
md="6"
>
<b-form-group
:label="$t('edit.formatting.presetFormats.label')"
label-class="text-primary"
:description="$t(`edit.formatting.presetFormats.description.${metric.formatting.presetFormat}`)"
style="white-space: pre-line;"
>
<b-form-select
v-model="metric.formatting.presetFormat"
:options="formatOptions"
/>
</b-form-group>
</b-col>
<b-col
cols="12"
md="6"
>
<b-form-group
:label="$t('edit.formatting.format.label')"
label-class="text-primary"
>
<b-input
v-model="metric.formatting.format"
:disabled="metric.formatting.presetFormat !== 'custom'"
:placeholder="$t('edit.formatting.format.placeholder')"
/>
</b-form-group>
</b-col>
</b-row>
</template>
</report-edit>
</template>
<script>
import base from './base'
import ReportEdit from './ReportEdit'
import { components } from '@cortezaproject/corteza-vue'
const { CItemPicker } = components
export default {
name: 'FunnelChart',
components: {
ReportEdit,
CItemPicker,
},
extends: base,
methods: {
showPicker (field) {
return field && field.kind === 'Select' && field.options.options
},
getOptions ({ meta = {} }) {
const { fields = [] } = meta
return fields.map(({ value }) => value)
},
setOptions (index, field, fields) {
this.editReport.dimensions[index].meta.fields = fields.map(f => {
const { options = [] } = field.options || {}
return options.find(({ value }) => value === f)
})
},
},
}
</script>
<style scoped lang="scss">
.cursor-pointer {
cursor: pointer;
}
</style>

View File

@@ -0,0 +1,227 @@
<template>
<report-edit
:report.sync="editReport"
:modules="modules"
:supported-metrics="1"
:uses-dimensions-field="false"
un-skippable
>
<template #dimension-options="{ dimension }">
<b-form-group
:label="$t('edit.dimension.gaugeSteps')"
label-class="text-primary"
>
<b-input-group
v-for="(step, i) in dimension.meta.steps"
:key="i"
class="mb-1"
>
<b-form-input
v-model="step.label"
plain
class="w-50"
:placeholder="$t('general.label.title')"
/>
<b-input-group-append>
<chart-translator
:field.sync="step.label"
:chart="chart"
:disabled="isNew"
:highlight-key="`dimensions.${dimension.dimensionID}.meta.steps.${step.stepID}.label`"
/>
</b-input-group-append>
<b-form-input
v-model="step.value"
type="number"
class="text-right w-25"
:placeholder="$t('general.value')"
/>
<b-input-group-append>
<c-input-confirm
show-icon
@confirmed="dimension.meta.steps.splice(i, 1)"
/>
</b-input-group-append>
</b-input-group>
<b-btn
variant="link"
class="p-0"
@click="dimension.meta.steps.push({ label: undefined, color: undefined, value: undefined })"
>
+ {{ $t('general.label.add') }}
</b-btn>
</b-form-group>
</template>
<template #metric-options="{ metric }">
<b-row>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('edit.metric.fx.label')"
:description="$t('edit.metric.fx.description')"
label-class="text-primary"
>
<b-form-textarea
v-model="metric.fx"
placeholder="n"
/>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('edit.metric.options.label')"
label-class="text-primary"
>
<b-form-checkbox
v-model="metric.fixTooltips"
>
{{ $t('edit.metric.fixTooltips') }}
</b-form-checkbox>
</b-form-group>
</b-col>
</b-row>
<b-row>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('edit.metric.angle.start')"
label-class="text-primary"
>
<b-form-input
v-model="metric.startAngle"
type="number"
number
/>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('edit.metric.angle.end')"
label-class="text-primary"
>
<b-form-input
v-model="metric.endAngle"
type="number"
number
/>
</b-form-group>
</b-col>
</b-row>
<hr>
<b-row>
<b-col
cols="12"
md="6"
>
<b-form-group
:label="$t('edit.formatting.prefix.label')"
label-class="text-primary"
>
<b-input
v-model="metric.formatting.prefix"
:placeholder="$t('edit.formatting.prefix.placeholder')"
/>
</b-form-group>
</b-col>
<b-col
cols="12"
md="6"
>
<b-form-group
:label="$t('edit.formatting.suffix.label')"
label-class="text-primary"
>
<b-input
v-model="metric.formatting.suffix"
:placeholder="$t('edit.formatting.suffix.placeholder')"
/>
</b-form-group>
</b-col>
<b-col
cols="12"
md="6"
>
<b-form-group
:label="$t('edit.formatting.presetFormats.label')"
label-class="text-primary"
:description="$t(`edit.formatting.presetFormats.description.${metric.formatting.presetFormat}`)"
style="white-space: pre-line;"
>
<b-form-select
v-model="metric.formatting.presetFormat"
:options="formatOptions"
/>
</b-form-group>
</b-col>
<b-col
cols="12"
md="6"
>
<b-form-group
:label="$t('edit.formatting.format.label')"
label-class="text-primary"
>
<b-input
v-model="metric.formatting.format"
:disabled="metric.formatting.presetFormat !== 'custom'"
:placeholder="$t('edit.formatting.format.placeholder')"
/>
</b-form-group>
</b-col>
</b-row>
</template>
</report-edit>
</template>
<script>
import ReportEdit from './ReportEdit'
import ChartTranslator from 'corteza-webapp-compose/src/components/Chart/ChartTranslator'
import { compose, NoID } from '@cortezaproject/corteza-js'
import base from './base'
export default {
name: 'GaugeChart',
components: {
ChartTranslator,
ReportEdit,
},
extends: base,
props: {
chart: {
type: compose.Chart,
required: true,
},
},
computed: {
isNew () {
return this.chart.chartID === NoID
},
},
}
</script>

View File

@@ -0,0 +1,705 @@
<template>
<report-edit
:report.sync="editReport"
:modules="modules"
>
<template #dimension-options-options="{ dimension, isTemporal }">
<b-form-checkbox
v-if="isTemporal && !['WEEK', 'QUARTER'].includes(dimension.modifier)"
v-model="dimension.timeLabels"
>
{{ $t('edit.dimension.timeLabels') }}
</b-form-checkbox>
</template>
<template #dimension-options="{ dimension }">
<b-row>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('edit.dimension.rotate.label')"
:description="$t('edit.dimension.rotate.description')"
label-class="text-primary"
>
<b-input
v-model="dimension.rotateLabel"
type="number"
number
/>
</b-form-group>
</b-col>
</b-row>
</template>
<template #y-axis="{ report }">
<div
class="px-3"
>
<h5 class="mb-3">
{{ $t('edit.yAxis.label') }}
</h5>
<b-row>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('edit.yAxis.labelLabel')"
label-class="text-primary"
>
<b-input-group>
<b-form-input
v-model="report.yAxis.label"
/>
<b-input-group-append>
<chart-translator
:field.sync="report.yAxis.label"
:chart="chart"
:disabled="isNew"
highlight-key="yAxis.label"
/>
</b-input-group-append>
</b-input-group>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('edit.yAxis.labelPosition.label')"
label-class="text-primary"
>
<b-form-select
v-model="report.yAxis.labelPosition"
:options="axisLabelPositions"
/>
</b-form-group>
</b-col>
</b-row>
<b-row>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('edit.yAxis.minLabel')"
label-class="text-primary"
>
<b-form-input
v-model="report.yAxis.min"
type="number"
:placeholder="$t('edit.yAxis.minPlaceholder')"
/>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('edit.yAxis.maxLabel')"
label-class="text-primary"
>
<b-form-input
v-model="report.yAxis.max"
type="number"
:placeholder="$t('edit.yAxis.maxPlaceholder')"
/>
</b-form-group>
</b-col>
</b-row>
<b-row
class="mb-2"
>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('edit.yAxis.rotate.label')"
:description="$t('edit.yAxis.rotate.description')"
label-class="text-primary"
>
<b-input
v-model="report.yAxis.rotateLabel"
type="number"
/>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('edit.yAxis.options.label')"
label-class="text-primary"
class="mb-0"
>
<b-form-checkbox
v-model="report.yAxis.axisType"
value="logarithmic"
unchecked-value="linear"
>
{{ $t('edit.yAxis.logarithmicScale') }}
</b-form-checkbox>
<b-form-checkbox
v-model="report.yAxis.axisPosition"
value="right"
unchecked-value="left"
>
{{ $t('edit.yAxis.axisOnRight') }}
</b-form-checkbox>
<b-form-checkbox
v-model="report.yAxis.beginAtZero"
>
{{ $t('edit.yAxis.axisScaleFromZero') }}
</b-form-checkbox>
<b-form-checkbox
v-model="report.yAxis.horizontal"
>
{{ $t('edit.yAxis.horizontal.label') }}
</b-form-checkbox>
</b-form-group>
</b-col>
</b-row>
<hr>
<b-row>
<b-col
cols="12"
md="6"
>
<b-form-group
:label="$t('edit.formatting.prefix.label')"
label-class="text-primary"
>
<b-input
v-model="report.yAxis.formatting.prefix"
:placeholder="$t('edit.formatting.prefix.placeholder')"
/>
</b-form-group>
</b-col>
<b-col
cols="12"
md="6"
>
<b-form-group
:label="$t('edit.formatting.suffix.label')"
label-class="text-primary"
>
<b-input
v-model="report.yAxis.formatting.suffix"
:placeholder="$t('edit.formatting.suffix.placeholder')"
/>
</b-form-group>
</b-col>
<b-col
cols="12"
md="6"
>
<b-form-group
:label="$t('edit.formatting.presetFormats.label')"
label-class="text-primary"
:description="$t(`edit.formatting.presetFormats.description.${report.yAxis.formatting.presetFormat}`)"
style="white-space: pre-line;"
>
<b-form-select
v-model="report.yAxis.formatting.presetFormat"
:options="formatOptions"
/>
</b-form-group>
</b-col>
<b-col
cols="12"
md="6"
>
<b-form-group
:label="$t('edit.formatting.format.label')"
label-class="text-primary"
>
<b-input
v-model="report.yAxis.formatting.format"
:disabled="report.yAxis.formatting.presetFormat !== 'custom'"
:placeholder="$t('edit.formatting.format.placeholder')"
/>
</b-form-group>
</b-col>
</b-row>
</div>
</template>
<template #metric-options="{ metric }">
<b-row>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('edit.metric.labelLabel')"
label-class="text-primary"
>
<b-input-group>
<b-form-input
v-model="metric.label"
/>
<b-input-group-append>
<chart-translator
:field.sync="metric.label"
:chart="chart"
:disabled="isNew"
:highlight-key="`metrics.${metric.metricID}.label`"
/>
</b-input-group-append>
</b-input-group>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('edit.metric.output.label')"
label-class="text-primary"
>
<c-input-select
v-model="metric.type"
:options="chartTypes"
label="text"
:reduce="option => option.value"
:get-option-key="option => option.text"
:placeholder="$t('edit.metric.output.placeholder')"
@input="value => chartTypeChanged(metric)"
/>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('edit.metric.fx.label')"
:description="$t('edit.metric.fx.description')"
label-class="text-primary"
>
<b-form-textarea
v-model="metric.fx"
placeholder="n"
/>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('edit.metric.options.label')"
label-class="text-primary"
>
<b-form-checkbox
v-model="metric.fixTooltips"
>
{{ $t('edit.metric.fixTooltips') }}
</b-form-checkbox>
<b-form-checkbox
v-if="hasRelativeDisplay(metric)"
v-model="metric.relativeValue"
>
{{ $t('edit.metric.relative') }}
</b-form-checkbox>
<b-form-checkbox
v-if="metric.type === 'pie'"
v-model="metric.rose"
>
{{ $t('edit.metric.rose') }}
</b-form-checkbox>
<b-form-checkbox
v-if="metric.type === 'line'"
v-model="metric.fill"
>
{{ $t('edit.metric.fillArea') }}
</b-form-checkbox>
</b-form-group>
<b-form-group
v-if="metric.type === 'line'"
:label="$t('edit.metric.lineStyle.label')"
label-class="text-primary"
>
<b-form-radio-group
:checked="getLineStyle(metric)"
:options="lineStyleOptions"
@change="setLineStyle($event, metric)"
/>
</b-form-group>
</b-col>
<b-col
v-if="!hasRelativeDisplay(metric)"
cols="12"
lg="6"
>
<b-form-group
:label="$t('edit.metric.stack.label')"
:description="$t('edit.metric.stack.description')"
label-class="text-primary"
>
<b-form-input
v-model="metric.stack"
/>
</b-form-group>
</b-col>
<b-col
v-if="metric.type === 'scatter'"
cols="12"
lg="6"
>
<b-form-group
:label="$t('edit.metric.symbol.label')"
label-class="text-primary"
>
<b-form-select
v-model="metric.symbol"
:options="scatterSymbolOptions"
/>
</b-form-group>
</b-col>
</b-row>
<hr>
<b-row>
<b-col
cols="12"
md="6"
>
<b-form-group
:label="$t('edit.formatting.prefix.label')"
label-class="text-primary"
>
<b-input
v-model="metric.formatting.prefix"
:placeholder="$t('edit.formatting.prefix.placeholder')"
/>
</b-form-group>
</b-col>
<b-col
cols="12"
md="6"
>
<b-form-group
:label="$t('edit.formatting.suffix.label')"
label-class="text-primary"
>
<b-input
v-model="metric.formatting.suffix"
:placeholder="$t('edit.formatting.suffix.placeholder')"
/>
</b-form-group>
</b-col>
<b-col
cols="12"
md="6"
>
<b-form-group
:label="$t('edit.formatting.presetFormats.label')"
label-class="text-primary"
:description="$t(`edit.formatting.presetFormats.description.${metric.formatting.presetFormat}`)"
style="white-space: pre-line;"
>
<b-form-select
v-model="metric.formatting.presetFormat"
:options="formatOptions"
/>
</b-form-group>
</b-col>
<b-col
cols="12"
md="6"
>
<b-form-group
:label="$t('edit.formatting.format.label')"
label-class="text-primary"
>
<b-input
v-model="metric.formatting.format"
:disabled="metric.formatting.presetFormat !== 'custom'"
:placeholder="$t('edit.formatting.format.placeholder')"
/>
</b-form-group>
</b-col>
</b-row>
</template>
<template #additional-config="{ hasAxis, report }">
<hr>
<div class="px-3">
<h5 class="d-flex mb-3">
{{ $t('edit.additionalConfig.tooltip.label') }}
<c-hint
:tooltip="$t('edit.additionalConfig.tooltip.formatting.disclaimer')"
icon-class="text-warning"
/>
</h5>
<b-row>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('edit.additionalConfig.tooltip.formatting.label')"
:description="$t('edit.additionalConfig.tooltip.formatting.description')"
label-class="text-primary"
>
<b-input
v-model="report.tooltip.formatting"
:placeholder="$t('edit.additionalConfig.tooltip.formatting.placeholder')"
/>
</b-form-group>
</b-col>
<b-col
v-if="!hasAxis"
cols="12"
lg="6"
>
<b-form-group
:label="$t('edit.additionalConfig.tooltip.labelNextToChart')"
label-class="text-primary"
>
<c-input-checkbox
:value="!!report.tooltip.labelsNextToPartition"
switch
:labels="checkboxLabel"
@input="$set(report.tooltip, 'labelsNextToPartition', $event)"
/>
</b-form-group>
</b-col>
</b-row>
</div>
<hr>
<div class="px-3 mb-2">
<h5 class="mb-3">
{{ $t('edit.additionalConfig.offset.label') }}
</h5>
<b-row>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('edit.additionalConfig.offset.default')"
label-class="text-primary"
>
<c-input-checkbox
v-model="report.offset.isDefault"
switch
:labels="checkboxLabel"
class="mb-3"
/>
</b-form-group>
</b-col>
</b-row>
<b-row
v-if="!report.offset.isDefault"
>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('edit.additionalConfig.offset.position.top')"
label-class="text-primary"
>
<b-input
v-model="report.offset.top"
/>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('edit.additionalConfig.offset.position.right')"
label-class="text-primary"
>
<b-input
v-model="report.offset.right"
/>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('edit.additionalConfig.offset.position.bottom')"
label-class="text-primary"
>
<b-input
v-model="report.offset.bottom"
/>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('edit.additionalConfig.offset.position.left')"
label-class="text-primary"
>
<b-input
v-model="report.offset.left"
/>
</b-form-group>
</b-col>
<b-col>
<small class="text-muted">
{{ $t('edit.additionalConfig.offset.valueRange') }}
</small>
</b-col>
</b-row>
</div>
</template>
</report-edit>
</template>
<script>
import ReportEdit from './ReportEdit'
import ChartTranslator from 'corteza-webapp-compose/src/components/Chart/ChartTranslator'
import { compose } from '@cortezaproject/corteza-js'
import base from './base'
const ignoredCharts = [
'funnel',
'gauge',
'radar',
]
export default {
name: 'GenericChart',
components: {
ReportEdit,
ChartTranslator,
},
extends: base,
data () {
return {
chartTypes: Object.values(compose.chartUtil.ChartType)
.filter(v => !ignoredCharts.includes(v))
.map(value => ({ value, text: this.$t(`edit.metric.output.${value}`) })),
legendPositions: [
{ value: 'top', text: this.$t('edit.metric.legend.top') },
{ value: 'left', text: this.$t('edit.metric.legend.left') },
{ value: 'bottom', text: this.$t('edit.metric.legend.bottom') },
{ value: 'right', text: this.$t('edit.metric.legend.right') },
],
axisLabelPositions: [
{ value: 'end', text: this.$t('edit.yAxis.labelPosition.top') },
{ value: 'center', text: this.$t('edit.yAxis.labelPosition.center') },
{ value: 'start', text: this.$t('edit.yAxis.labelPosition.bottom') },
],
tensionSteps: [
{ text: this.$t('edit.metric.lineTension.straight'), value: 0.0 },
{ text: this.$t('edit.metric.lineTension.slight'), value: 0.2 },
{ text: this.$t('edit.metric.lineTension.medium'), value: 0.4 },
{ text: this.$t('edit.metric.lineTension.curvy'), value: 0.6 },
],
lineStyleOptions: [
{ value: '', text: this.$t('edit.metric.lineStyle.default') },
{ value: 'smooth', text: this.$t('edit.metric.lineStyle.smooth') },
{ value: 'step', text: this.$t('edit.metric.lineStyle.step') },
],
scatterSymbolOptions: [
{ value: 'circle', text: this.$t('edit.metric.symbol.circle') },
{ value: 'triangle', text: this.$t('edit.metric.symbol.triangle') },
{ value: 'diamond', text: this.$t('edit.metric.symbol.diamond') },
{ value: 'pin', text: this.$t('edit.metric.symbol.pin') },
{ value: 'arrow', text: this.$t('edit.metric.symbol.arrow') },
{ value: 'rect', text: this.$t('edit.metric.symbol.rect') },
{ value: 'roundRect', text: this.$t('edit.metric.symbol.roundRect') },
],
}
},
beforeDestroy () {
this.setDefaultValues()
},
methods: {
hasRelativeDisplay: compose.chartUtil.hasRelativeDisplay,
getLineStyle (metric) {
if (metric.smooth) return 'smooth'
else if (metric.step) return 'step'
return ''
},
setLineStyle (style, metric) {
this.$set(metric, 'smooth', style === 'smooth')
this.$set(metric, 'step', style === 'step')
},
chartTypeChanged (metric) {
metric.relativeValue = false
},
setDefaultValues () {
this.chartTypes = []
this.legendPositions = []
this.axisLabelPositions = []
this.tensionSteps = []
this.lineStyleOptions = []
this.scatterSymbolOptions = []
},
},
}
</script>
<style lang="scss" scoped>
.color-picker {
max-width: 50px;
}
</style>

View File

@@ -0,0 +1,160 @@
<template>
<report-edit
:report.sync="editReport"
:modules="modules"
>
<template #metric-options="{ metric }">
<b-row>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('edit.metric.labelLabel')"
label-class="text-primary"
>
<b-input-group>
<b-form-input
v-model="metric.label"
/>
<b-input-group-append>
<chart-translator
:field.sync="metric.label"
:chart="chart"
:disabled="isNew"
:highlight-key="`metrics.${metric.metricID}.label`"
/>
</b-input-group-append>
</b-input-group>
</b-form-group>
</b-col>
</b-row>
<hr>
<b-row>
<b-col
cols="12"
md="6"
>
<b-form-group
:label="$t('edit.formatting.prefix.label')"
label-class="text-primary"
>
<b-input
v-model="metric.formatting.prefix"
:placeholder="$t('edit.formatting.prefix.placeholder')"
/>
</b-form-group>
</b-col>
<b-col
cols="12"
md="6"
>
<b-form-group
:label="$t('edit.formatting.suffix.label')"
label-class="text-primary"
>
<b-input
v-model="metric.formatting.suffix"
:placeholder="$t('edit.formatting.suffix.placeholder')"
/>
</b-form-group>
</b-col>
<b-col
cols="12"
md="6"
>
<b-form-group
:label="$t('edit.formatting.presetFormats.label')"
label-class="text-primary"
:description="$t(`edit.formatting.presetFormats.description.${metric.formatting.presetFormat}`)"
style="white-space: pre-line;"
>
<b-form-select
v-model="metric.formatting.presetFormat"
:options="formatOptions"
/>
</b-form-group>
</b-col>
<b-col
cols="12"
md="6"
>
<b-form-group
:label="$t('edit.formatting.format.label')"
label-class="text-primary"
>
<b-input
v-model="metric.formatting.format"
:disabled="metric.formatting.presetFormat !== 'custom'"
:placeholder="$t('edit.formatting.format.placeholder')"
/>
</b-form-group>
</b-col>
</b-row>
</template>
<template #dimension-options-options="{ dimension }">
<b-form-checkbox
v-model="dimension.fixTooltips"
>
{{ $t('edit.metric.fixTooltips') }}
</b-form-checkbox>
</template>
<template #dimension-options="{ dimension }">
<b-row>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('edit.metric.radar.shape.label')"
label-class="text-primary"
>
<b-form-select
v-model="dimension.shape"
:options="shapeOptions"
/>
</b-form-group>
</b-col>
</b-row>
</template>
</report-edit>
</template>
<script>
import base from './base'
import ReportEdit from './ReportEdit'
import ChartTranslator from 'corteza-webapp-compose/src/components/Chart/ChartTranslator'
export default {
name: 'RadarChart',
components: {
ReportEdit,
ChartTranslator,
},
extends: base,
data () {
return {
shapeOptions: [
{ value: 'polygon', text: this.$t('edit.metric.radar.shape.polygon') },
{ value: 'circle', text: this.$t('edit.metric.radar.shape.circle') },
],
}
},
}
</script>
<style scoped lang="scss">
.cursor-pointer {
cursor: pointer;
}
</style>

View File

@@ -0,0 +1,89 @@
// eslint-disable-next-line
import { default as component } from './ReportEdit.vue'
const props = {
report: {
moduleID: '',
filter: '',
colorScheme: '#FFFFFF',
metrics: [
{
aggregate: 'AVG',
field: 'count',
moduleID: '',
},
],
dimensions: [{
field: 'Rating',
modifier: '(no grouping / buckets)',
skipMissing: true,
default: '',
}],
},
modules: [
{
moduleID: '',
handle: 'Pool',
name: 'Pool',
fields: [{
kind: 'String',
name: 'Sample',
options: {
multiLine: false,
useRichTextEditor: false,
},
}],
},
{
moduleID: '',
handle: 'Party',
name: 'Party',
fields: [{
kind: 'String',
name: 'Party',
options: {
multiLine: false,
useRichTextEditor: false,
},
}],
},
{
moduleID: '',
handle: 'Test',
name: 'Test',
fields: [{
kind: 'String',
name: 'Test',
options: {
multiLine: false,
useRichTextEditor: false,
},
}],
},
],
supportedMetrics: -1,
dimensionFieldKind: ['DateTime', 'Select', 'Number', 'Bool', 'String'],
unSkippable: false,
}
export default {
name: 'Edit',
group: ['Chart', 'Report'],
component,
props,
controls: [],
scenarios: [
{
label: 'Full form',
props,
},
{
label: 'Empty form',
props: {
...props,
modules: [],
},
},
],
}

View File

@@ -0,0 +1,691 @@
<template>
<div>
<!-- Configure source module -->
<div
class="px-3"
>
<h5 class="mb-3">
{{ $t('edit.module.title') }}
</h5>
<b-row>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('edit.module.label')"
label-class="text-primary"
>
<c-input-select
v-model="moduleID"
:options="modules"
label="name"
:reduce="module => module.moduleID"
:get-option-key="option => option.moduleID"
:placeholder="$t('edit.module.placeholder')"
/>
</b-form-group>
</b-col>
<b-col
v-if="!!module"
cols="12"
lg="6"
>
<b-form-group
:label="$t('edit.filter.preset')"
label-class="text-primary"
>
<c-input-select
v-model="report.filter"
:options="predefinedFilters"
label="text"
:reduce="filter => filter.value"
:placeholder="$t('edit.filter.noFilter')"
/>
</b-form-group>
</b-col>
<!-- Configure report filters -->
<b-col
v-if="!!module"
cols="12"
class="mt-1"
>
<b-form-group
:label="$t('edit.filter.label')"
label-class="text-primary"
>
<b-form-textarea
v-model="report.filter"
:placeholder="$t('edit.filter.placeholder')"
/>
<i18next
path="edit.filter.footnote"
tag="small"
class="text-muted"
>
<code>${record.values.fieldName}</code>
<code>${recordID}</code>
<code>${ownerID}</code>
<span><code>${userID}</code>, <code>${user.name}</code></span>
</i18next>
</b-form-group>
</b-col>
</b-row>
</div>
<hr v-if="module">
<!-- Configure report dimensions -->
<div
v-if="!!module"
class="px-3"
>
<fieldset
v-for="(d, i) in dimensions"
:key="i"
>
<h5 class="mb-3">
{{ $t('edit.dimension.label') }}
</h5>
<template v-if="usesDimensionsField">
<b-row>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('edit.dimension.fieldLabel')"
label-class="text-primary"
>
<c-input-select
v-model="d.field"
:options="dimensionFields"
label="text"
:reduce="field => field.value"
:placeholder="$t('edit.dimension.fieldPlaceholder')"
@input="value => onDimFieldChange(value, d)"
/>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('edit.dimension.function.label')"
label-class="text-primary"
>
<c-input-select
v-model="d.modifier"
:disabled="!d.field || !isTemporalField(d.field)"
:options="dimensionModifiers"
label="text"
:reduce="modifier => modifier.value"
:placeholder="$t('edit.dimension.function.placeholder')"
@input="onDimModifierChange($event, d)"
/>
</b-form-group>
</b-col>
</b-row>
<template v-if="!unSkippable">
<b-row>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('edit.dimension.defaultValueLabel')"
:description="$t('edit.dimension.defaultValueFootnote')"
label-class="text-primary"
>
<b-form-input
v-model="d.default"
:type="defaultValueInputType(d)"
/>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('edit.dimension.options.label')"
label-class="text-primary"
>
<b-form-checkbox
v-model="d.skipMissing"
>
{{ $t('edit.dimension.skipMissingValues') }}
</b-form-checkbox>
<slot
name="dimension-options-options"
:dimension="d"
:is-temporal="isTemporalField(d.field)"
/>
</b-form-group>
</b-col>
</b-row>
</template>
</template>
<slot
name="dimension-options"
:index="i"
:dimension="d"
:field="getField(d)"
/>
</fieldset>
</div>
<hr v-if="!!module">
<!-- Configure report metrics -->
<div
v-if="!!module"
class="px-3"
>
<h5 class="d-flex align-items-center mb-3">
{{ $t('edit.metric.title') }}
<b-button
v-if="canAddMetric"
variant="link"
class="text-decoration-none"
@click.prevent="addMetric"
>
+ {{ $t('edit.metric.add') }}
</b-button>
</h5>
<draggable
class="metrics mb-3"
:list.sync="metrics"
handle=".grab"
:group="`metrics_${moduleID}`"
>
<div
v-for="(m,i) in metrics"
:key="i"
class="metric rounded border border-light p-3 mb-3"
>
<h5
v-if="metrics.length > 1"
class="d-flex align-items-center mb-3"
>
{{ $t('edit.metric.label') }} {{ i + 1 }}
<div class="d-flex align-items-center ml-auto">
<c-input-confirm
show-icon
class="mr-2"
@confirmed="removeMetric(i)"
/>
<b-button
variant="link"
size="sm"
class="ml-auto px-0"
>
<font-awesome-icon
:icon="['fas', 'bars']"
class="grab text-secondary"
/>
</b-button>
</div>
</h5>
<b-row>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('edit.metric.fieldLabel')"
label-class="text-primary"
>
<c-input-select
v-model="m.field"
:options="metricFields"
:get-option-key="option => option.text"
label="text"
:reduce="option => option.value"
@input="value => onMetricFieldChange(value, m)"
/>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('edit.metric.function.label')"
label-class="text-primary"
>
<c-input-select
v-model="m.aggregate"
:disabled="!m.field || m.field === 'count'"
:options="metricAggregates"
label="text"
:reduce="option => option.value"
:get-option-key="option => option.text"
:placeholder="$t('edit.metric.function.placeholder')"
@input="value => onMetricFieldChange(value, m)"
/>
</b-form-group>
</b-col>
</b-row>
<slot
name="metric-options"
:metric="m"
:report="editReport"
/>
</div>
</draggable>
</div>
<hr v-if="!!module && hasAxis">
<template v-if="hasAxis">
<slot
name="y-axis"
:report="editReport"
/>
</template>
<hr v-if="hasLegend">
<div
v-if="hasLegend"
class="px-3"
>
<h5 class="mb-3">
{{ $t('edit.additionalConfig.legend.label') }}
</h5>
<b-row>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('edit.additionalConfig.legend.orientation.label')"
label-class="text-primary"
>
<b-form-select
v-model="report.legend.orientation"
:options="orientations"
/>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('edit.additionalConfig.legend.show')"
label-class="text-primary"
>
<c-input-checkbox
:value="!!report.legend.isHidden"
switch
invert
:labels="checkboxLabel"
@input="$set(report.legend,'isHidden', $event)"
/>
</b-form-group>
</b-col>
</b-row>
<b-row>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('edit.additionalConfig.legend.align.label')"
label-class="text-primary"
>
<b-form-select
v-model="report.legend.align"
:options="alignments"
:disabled="!report.legend.position.isDefault"
/>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('edit.additionalConfig.legend.options.label')"
label-class="text-primary"
>
<b-form-checkbox
v-model="report.legend.isScrollable"
:disabled="report.legend.orientation !== 'horizontal'"
>
{{ $t('edit.additionalConfig.legend.scrollable') }}
</b-form-checkbox>
<b-form-checkbox
v-model="report.legend.position.isDefault"
>
{{ $t('edit.additionalConfig.legend.position.customize') }}
</b-form-checkbox>
</b-form-group>
</b-col>
</b-row>
<b-row
v-if="!report.legend.position.isDefault"
>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('edit.additionalConfig.legend.position.top')"
label-class="text-primary"
>
<b-input
v-model="report.legend.position.top"
/>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('edit.additionalConfig.legend.position.right')"
label-class="text-primary"
>
<b-input
v-model="report.legend.position.right"
/>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('edit.additionalConfig.legend.position.bottom')"
label-class="text-primary"
>
<b-input
v-model="report.legend.position.bottom"
/>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('edit.additionalConfig.legend.position.left')"
label-class="text-primary"
>
<b-input
v-model="report.legend.position.left"
/>
</b-form-group>
</b-col>
<b-col>
<small class="text-muted">
{{ $t('edit.additionalConfig.legend.valueRange') }}
</small>
</b-col>
</b-row>
</div>
<slot
name="additional-config"
:report="editReport"
:metrics="metrics"
:has-axis="hasAxis"
/>
</div>
</template>
<script>
import draggable from 'vuedraggable'
import { compose } from '@cortezaproject/corteza-js'
import base from './base'
const aggregateFunctions = [
{
value: 'SUM',
text: 'sum',
},
{
value: 'MAX',
text: 'max',
},
{
value: 'MIN',
text: 'min',
},
{
value: 'AVG',
text: 'avg',
},
{
value: 'STD',
text: 'std',
},
]
export default {
name: 'ReportEdit',
components: {
draggable,
},
extends: base,
data () {
return {
metricAggregates: aggregateFunctions.map(af => ({ ...af, text: this.$t(`edit.metric.function.${af.text}`) })),
dimensionModifiers: compose.chartUtil.dimensionFunctions.map(df => ({ ...df, text: this.$t(`edit.dimension.function.${df.text}`) })),
predefinedFilters: compose.chartUtil.predefinedFilters.map(pf => ({ ...pf, text: this.$t(`edit.filter.${pf.text}`) })),
alignments: [
{ value: 'left', text: this.$t('edit.additionalConfig.legend.align.left') },
{ value: 'center', text: this.$t('edit.additionalConfig.legend.align.center') },
{ value: 'right', text: this.$t('edit.additionalConfig.legend.align.right') },
],
orientations: [
{ value: 'horizontal', text: this.$t('edit.additionalConfig.legend.orientation.horizontal') },
{ value: 'vertical', text: this.$t('edit.additionalConfig.legend.orientation.vertical') },
],
}
},
computed: {
hasLegend () {
return !this.metrics.some(({ type }) => ['gauge'].includes(type))
},
defaultValueInputType () {
return ({ field }) => (this.module.fields.filter(f => f.name === field)[0] || {}).kind === 'DateTime' ? 'date' : 'text'
},
canAddMetric () {
return (this.supportedMetrics < 0 || this.metrics.length < this.supportedMetrics) && this.moduleID
},
module () {
return this.modules.find(m => m.moduleID === this.moduleID)
},
metricFields () {
return [
{ value: 'count', text: this.$t('general.label.count') },
...this.module.fields.filter(f => f.kind === 'Number')
.sort((a, b) => (a.label || a.name).localeCompare((b.label || b.name)))
.map(({ label, name }) => ({ value: name, text: label || name })),
]
},
hasAxis () {
return this.metrics.some(({ type }) => ['bar', 'line', 'scatter'].includes(type))
},
dimensionFields () {
return [
...[...this.module.fields].sort((a, b) => (a.label || a.name).localeCompare((b.label || b.name))),
...this.module.systemFields().map(sf => {
sf.label = this.$t(`field:system.${sf.name}`)
return sf
}),
].filter(({ kind, options = {} }) => {
return this.dimensionFieldKind.includes(kind) && !(options.useRichTextEditor || options.multiLine)
}).map(({ name, label, kind }) => {
return { value: name, text: `${label || name} (${kind})`, kind }
})
},
moduleID: {
get () {
return this.report.moduleID
},
set (v) {
this.report.moduleID = v
this.$emit('update:report', { ...this.report, moduleID: v })
},
},
colorScheme: {
get () {
return this.report.colorScheme
},
set (v) {
this.report.colorScheme = v
this.$emit('update:report', { ...this.report, colorScheme: v })
},
},
metrics: {
get () {
return this.report.metrics
},
set (v) {
this.report.metrics = v
this.$emit('update:report', { ...this.report, metrics: v })
},
},
dimensions: {
get () {
return this.report.dimensions
},
set (v) {
this.$emit('update:report', { ...this.report, dimensions: v })
},
},
},
beforeDestroy () {
this.setDefaultValues()
},
methods: {
hasRelativeDisplay: compose.chartUtil.hasRelativeDisplay,
getField ({ field }) {
if (!field || !this.module) {
return undefined
}
return this.module.fields.find(({ name }) => name === field)
},
addMetric () {
this.metrics.push(this.chart.defMetric())
},
onDimFieldChange (f, d) {
if (!this.isTemporalField(f)) {
this.$set(d, 'modifier', this.dimensionModifiers[0].value)
this.$set(d, 'timeLabels', false)
}
this.$set(d.meta, 'fields', [])
},
onDimModifierChange (modifier, d) {
if (['WEEK', 'QUARTER'].includes(modifier)) {
this.$set(d, 'timeLabels', false)
}
},
onMetricFieldChange (field, m) {
if (field === 'count') {
this.$set(m, 'aggregate', undefined)
} else if (field) {
// Set field formatting based on number field formatting
const moduleField = this.module.fields.find(f => f.name === field)
if (moduleField) {
const { presetFormat, format, prefix, suffix } = moduleField.options
this.$set(m, 'formatting', {
presetFormat,
format,
prefix,
suffix,
})
}
if (!m.aggregate) {
this.$set(m, 'aggregate', this.metricAggregates[0].value)
}
}
},
removeMetric (i) {
this.metrics.splice(i, 1)
},
isTemporalField (name) {
return this.dimensionFields.some(f => f.value === name && f.kind === 'DateTime')
},
setDefaultValues () {
this.metricAggregates = []
this.dimensionModifiers = []
this.predefinedFilters = []
this.alignments = []
this.orientations = []
},
},
}
</script>
<style lang="scss" scoped>
.metrics {
.metric {
background-color: var(--body-bg);
}
}
</style>

View File

@@ -0,0 +1,88 @@
<script>
import { compose, NoID } from '@cortezaproject/corteza-js'
export default {
i18nOptions: {
namespaces: 'chart',
},
props: {
report: {
type: Object,
required: false,
default: undefined,
},
chart: {
type: compose.Chart,
default: () => new compose.Chart(),
},
modules: {
type: Array,
required: true,
},
supportedMetrics: {
type: Number,
required: false,
default: -1,
},
// Specifies what field kinds are supported as a dimension field
dimensionFieldKind: {
type: Array,
required: false,
default: () => [
'DateTime',
'Select',
'Number',
'Bool',
'String',
'Record',
'User',
],
},
usesDimensionsField: {
type: Boolean,
default: true,
},
unSkippable: {
type: Boolean,
required: false,
default: false,
},
},
data () {
return {
checkboxLabel: {
on: this.$t('general:label.yes'),
off: this.$t('general:label.no'),
},
formatOptions: [
{ value: 'custom', text: this.$t('edit.formatting.presetFormats.options.custom') },
{ value: 'accounting', text: this.$t('edit.formatting.presetFormats.options.accounting') },
],
}
},
computed: {
editReport: {
get () {
return this.report
},
set (v) {
this.$emit('update:report', v)
},
},
isNew () {
return this.chart.chartID === NoID
},
},
}
</script>

View File

@@ -0,0 +1,11 @@
import FunnelChart from './FunnelChart'
import GenericChart from './GenericChart'
import GaugeChart from './GaugeChart'
import RadarChart from './RadarChart'
export default {
FunnelChart,
GenericChart,
GaugeChart,
RadarChart,
}

View File

@@ -0,0 +1,16 @@
// eslint-disable-next-line
import { default as component } from './ReportItem.vue'
const props = {
report: {},
fixed: true,
}
export default {
name: 'Report item',
group: ['Chart'],
component,
props,
controls: [
],
}

View File

@@ -0,0 +1,57 @@
<template>
<tr class="w-100 d-inline-block">
<td
v-b-tooltip.noninteractive.hover
class="handle align-middle w-100 d-inline-block"
:class="{ 'cursor-grab': !fixed }"
>
<font-awesome-icon
v-if="!fixed"
:icon="['fas', 'bars']"
class="text-secondary mr-1"
/>
<slot
name="report-label"
:report="report"
/>
<b-btn
variant="link"
class="ml-1 float-right text-dark p-0"
@click="$emit('edit', report)"
>
<font-awesome-icon :icon="['far', 'edit']" />
</b-btn>
<c-input-confirm
show-icon
class="float-right"
@confirmed="$emit('remove', report)"
/>
</td>
</tr>
</template>
<script>
export default {
props: {
report: {
type: Object,
required: true,
default: () => ({}),
},
// Specifies that this item can't be moved
fixed: {
type: Boolean,
required: false,
default: false,
},
},
}
</script>
<style scoped lang="scss">
.cursor-grab {
cursor: grab;
}
</style>

View File

@@ -0,0 +1,262 @@
<template>
<div class="d-flex h-100 w-100 position-relative">
<div
v-if="processing"
class="d-flex flex-column align-items-center justify-content-center flex-fill"
>
<b-spinner />
</div>
<label
v-else-if="error"
class="text-primary p-3"
>
{{ error }}
</label>
<c-chart
v-else-if="renderer"
:chart="renderer"
class="flex-fill p-1"
@click="drillDown"
/>
</div>
</template>
<script>
import { mapGetters, mapActions } from 'vuex'
import { chartConstructor } from 'corteza-webapp-compose/src/lib/charts'
import { compose } from '@cortezaproject/corteza-js'
import { components } from '@cortezaproject/corteza-vue'
const { CChart } = components
export default {
i18nOptions: {
namespaces: 'notification',
},
components: {
CChart,
},
props: {
chart: {
type: Object,
required: true,
},
reporter: {
type: Function,
required: true,
},
record: {
type: compose.Record,
required: false,
default: undefined,
},
},
data () {
return {
error: undefined,
processing: false,
valueMap: new Map(),
renderer: undefined,
}
},
computed: {
...mapGetters({
getModuleByID: 'module/getByID',
getUserByID: 'user/findByID',
}),
},
watch: {
'record.recordID': {
immediate: true,
handler () {
this.updateChart()
},
},
},
beforeDestroy () {
this.setDefaultValues()
},
methods: {
...mapActions({
resolveUsers: 'user/resolveUsers',
}),
async updateChart () {
this.error = undefined
this.renderer = undefined
const [report = {}] = this.chart.config.reports
if (!report.moduleID) {
return
}
this.processing = true
const chart = chartConstructor(this.chart)
try {
chart.isValid()
const data = await chart.fetchReports({ reporter: this.reporter })
const module = this.getModuleByID(report.moduleID)
const fields = [
...module.fields,
...module.systemFields(),
]
if (!!data.labels && Array.isArray(data.labels)) {
// Get dimension field kind
const [dimension = {}] = report.dimensions
let { field } = dimension
if (!module) throw new Error('Module not found')
field = fields.find(({ name }) => name === field)
if (!field) throw new Error('Dimension field not found')
const isValidValue = (value) => value !== dimension.default && value !== 'undefined'
if (field.kind === 'Bool') {
const { trueLabel, falseLabel } = field.options
data.labels = data.labels.map(value => {
return value === '1' ? trueLabel || this.$t('general:label.yes') : falseLabel || this.$t('general:label.no')
})
} else if (field.kind === 'Select') {
data.labels = data.labels.map(value => {
const { text } = field.options.options.find(o => o.value === value) || {}
const label = text || value
this.valueMap[label] = value
return label
})
} else if (field.kind === 'User') {
// Fetch and map users to labels
await this.resolveUsers(data.labels.filter(userID => isValidValue(userID)))
data.labels = data.labels.map(userID => {
const label = field.formatter(this.getUserByID(userID)) || userID
this.valueMap[label] = userID
return label
})
} else if (field.kind === 'Record') {
// Fetch and map records and their values to labels
const { namespaceID } = this.chart || {}
const recordModule = this.getModuleByID(field.options.moduleID)
if (recordModule && data.labels) {
await Promise.all(data.labels.map(recordID => {
if (isValidValue(recordID)) {
return this.$ComposeAPI.recordRead({ namespaceID, moduleID: recordModule.moduleID, recordID }).then(record => {
record = new compose.Record(recordModule, record)
if (field.options.recordLabelField) {
// Get actual field
const relatedField = recordModule.fields.find(({ name }) => name === field.options.labelField)
return this.$ComposeAPI.recordRead({ namespaceID, moduleID: relatedField.options.moduleID, recordID: record.values[field.options.labelField] }).then(labelRecord => {
record.values[field.options.labelField] = (labelRecord.values.find(({ name }) => name === this.field.options.recordLabelField) || {}).value
return record
})
} else {
return record
}
})
} else {
const record = { values: {} }
record.values[field.options.labelField] = recordID
return record
}
})).then(records => {
data.labels = records.map(record => {
const value = field.options.labelField ? record.values[field.options.labelField] : record.recordID
const label = Array.isArray(value) ? value.join(', ') : value
this.valueMap[label] = record.recordID
return value
})
})
}
}
}
data.datasets = data.datasets.map((dataset = {}) => {
const { label } = dataset
if (label === 'count') {
dataset.label = this.$t('chart:general.label.count')
} else {
const field = fields.find(({ name }) => name === label) || {}
dataset.label = field.label || label
}
return dataset
})
data.labels = data.labels.map(l => l === 'undefined' ? this.$t('chart:undefined') : l)
data.customColorSchemes = this.$Settings.get('ui.charts.colorSchemes', [])
data.themeVariables = this.getThemeVariables()
this.renderer = chart.makeOptions(data)
} catch (e) {
this.error = this.toastErrorHandler(this.$t('chart.optionsBuildFailed'))(e)
this.processing = false
}
setTimeout(() => {
this.processing = false
this.$emit('updated')
}, 300)
},
drillDown (e) {
e.trueName = this.valueMap[e.name] || e.name
return this.$emit('drill-down', e)
},
getThemeVariables () {
const getCssVariable = (variableName) => {
return getComputedStyle(document.documentElement).getPropertyValue(variableName).trim()
}
// Turn below into an object with key value pairs
return ['white', 'black', 'primary', 'secondary', 'success', 'warning', 'danger', 'light', 'extra-light', 'dark', 'font-regular'].reduce((acc, variable) => {
acc[variable] = getCssVariable(`--${variable}`)
return acc
}, {})
},
requestChartUpdate ({ name, handle } = {}) {
if (!name && !handle) {
this.updateChart()
}
if (name && this.chart && this.chart.name === name) {
this.updateChart()
}
if (handle && this.chart && this.chart.handle === handle) {
this.updateChart()
}
},
setDefaultValues () {
this.processing = false
this.renderer = undefined
this.valueMap = {}
},
},
}
</script>

View File

@@ -0,0 +1,7 @@
export { default as CircleStep } from './CircleStep.c3'
export { default as Grid } from './Grid.c3'
export { default as Hint } from './Hint.c3'
// disabled: waiting for TJ to explain te use
// export { default as ItemPicker } from './ItemPicker.c3'
export { default as RecordListFilter } from './RecordListFilter.c3'
export { default as FieldPicker } from './FieldPicker.c3'

View File

@@ -0,0 +1,26 @@
// eslint-disable-next-line
import { default as component } from './CircleStep.vue'
import { components } from '@cortezaproject/corteza-vue'
const { checkbox, input } = components.C3.controls
const props = {
done: true,
disabled: false,
optional: false,
small: false,
stepNumber: '111',
}
export default {
name: 'Circle step',
group: ['Common'],
component,
props,
controls: [
checkbox('Done', 'done'),
checkbox('Disabled', 'disabled'),
checkbox('Small', 'small'),
input('Number of step', 'stepNumber'),
],
}

View File

@@ -0,0 +1,84 @@
<template>
<div>
<div
v-b-popover.hover.top="popoverContent"
:class="{ 'disabled': disabled, 'small': small }"
class="h3 mx-auto rounded-circle circle border-primary text-primary text-center mb-5"
>
<font-awesome-icon
v-if="done"
:icon="['fas', 'check']"
/>
<span v-else>{{ stepNumber }}</span>
</div>
<slot />
</div>
</template>
<script>
export default {
i18nOptions: {
namespaces: 'onboarding',
},
props: {
done: {
type: Boolean,
required: true,
default: false,
},
disabled: {
type: Boolean,
default: false,
},
optional: {
type: Boolean,
default: false,
},
stepNumber: {
type: String,
default: '?',
},
small: {
type: Boolean,
default: false,
},
},
computed: {
popoverContent () {
if (this.optional) {
return this.$t('step.optional')
} else {
// If popover content is an empty string it doesnt show
return ''
}
},
},
}
</script>
<style lang="scss" scoped>
.circle {
width: 75px;
height: 75px;
font-size: 35px;
line-height: 75px;
border: 2px solid;
}
.small {
width: 50px;
height: 50px;
font-size: 25px;
line-height: 45px;
}
.disabled {
opacity: 0.65;
}
</style>

View File

@@ -0,0 +1,31 @@
// eslint-disable-next-line
import { default as component } from './FieldPicker.vue'
import { compose } from '@cortezaproject/corteza-js'
import { components } from '@cortezaproject/corteza-vue'
const { checkbox } = components.C3.controls
const f1 = new compose.ModuleField({ name: 'f1', label: 'Field One Label' })
const f2 = new compose.ModuleField({ name: 'f2' })
const f3 = new compose.ModuleField({ name: 'f3', isRequired: true })
const f4 = new compose.ModuleField({ name: 'f4', label: 'required with label', isRequired: true })
const module = new compose.Module({
name: 'modName',
fields: [f1, f2, f3, f4],
})
const props = {
module,
fields: [f2, f4],
disableSystemFields: false,
}
export default {
name: 'FieldPicker',
group: ['Picker'],
component,
props,
controls: [
checkbox('Disabled system fields', 'disableSystemFields'),
],
}

View File

@@ -0,0 +1,184 @@
<template>
<div
class="d-flex flex-column border border-light rounded p-2 "
>
<c-item-picker
:value.sync="selected"
:options="options"
:labels="{
searchPlaceholder: $t('selector.search'),
availableItems: $t('available-items'),
selectAllItems: $t('selector.selectAll'),
selectedItems: $t('selected-items'),
unselectAllItems: $t('selector.unselectAll'),
noItemsFound: $t('no-items-found'),
}"
>
<template
#default="{ field }"
>
<b class="cursor-default text-dark">
<template
v-if="field.label"
>
{{ field.label }} ({{ field.name }})
</template>
<template
v-else
>
{{ field.name }}
</template>
<template
v-if="field.isRequired"
>
*
</template>
</b>
<small
v-if="field.isSystem"
class="cursor-default ml-1 text-truncate"
>
{{ $t('selector.systemField') }}
</small>
</template>
</c-item-picker>
</div>
</template>
<script>
import { components } from '@cortezaproject/corteza-vue'
const { CItemPicker } = components
export default {
i18nOptions: {
namespaces: 'field',
},
components: {
CItemPicker,
},
props: {
// source of fields
module: {
type: Object,
required: true,
},
// array of objects, list of all selected fields
fields: {
type: Array,
required: true,
},
disabledTypes: {
type: Array,
default: () => [],
},
disableSystemFields: {
type: Boolean,
default: false,
},
disableSorting: {
type: Boolean,
},
// source of additional (system fields) we'll use
systemFields: {
type: Array,
default: null,
},
fieldSubset: {
type: Array,
default: null,
},
},
computed: {
/**
* Converting old-way of passing data to picker components
* where arrays of objects where exchanged and stored
*
* Item picker does not do that any more so we need to adapt
* whatever we get from the outside to array of picked field names
* and vice versa!
*/
selected: {
get () {
// Only need names of the fields
return this.fields.map(({ name }) => name)
},
set (selected) {
// take list of field names passed to the setter
// and filter out the options to recreate the list
// module field objects
const fields = selected.map(s => {
return (this.options.find(({ value }) => value === s) || {}).field
}).filter(f => f)
this.$emit('update:fields', fields)
},
},
options () {
let mFields = [...(this.fieldSubset ? this.module.filterFields(this.fieldSubset) : this.module.fields)]
if (this.disabledTypes.length > 0) {
mFields = mFields.filter(({ kind }) => !this.disabledTypes.find(t => t === kind))
}
// Filter out fields the user cannot read (RBAC)
mFields = mFields.filter(({ canReadRecordValue }) => canReadRecordValue !== false)
let sysFields = []
if (this.disableSystemFields && mFields) {
mFields = mFields.filter(({ isSystem }) => !isSystem)
} else if (!this.fieldSubset) {
sysFields = this.module.systemFields().map(sf => {
sf.label = this.$t(`field:system.${sf.name}`)
return sf
})
if (this.systemFields) {
sysFields = sysFields.filter(({ name }) => this.systemFields.find(sf => sf === name))
}
}
if (!this.disableSorting && mFields) {
mFields.sort((a, b) => a.label.localeCompare(b.label))
}
if (mFields && sysFields) {
return [
...[...mFields],
...sysFields,
].map(field => ({
value: field.name,
text: [
field.name,
field.label,
field.kind,
field.isSystem ? 'system' : '',
field.isRequired ? 'required' : '',
].join(' '),
field,
}))
} else {
return Object.keys(this.module).map(key => {
return this.module[key]
}).map(f => ({
...f,
field: {
name: f.text,
},
}))
}
},
},
}
</script>

View File

@@ -0,0 +1,622 @@
<template>
<b-table-simple
v-if="mockModule"
borderless
class="mb-0"
>
<template v-for="(filterGroup, groupIndex) in internalFilter">
<template v-if="filterGroup.filter.length">
<b-tr
v-for="(filter, index) in filterGroup.filter"
:key="`${groupIndex}-${index}`"
class="pb-2"
>
<b-td style="width: 250px;">
<c-input-select
v-model="filter.name"
:options="fields"
:get-option-key="getOptionKey"
:clearable="false"
:placeholder="$t('recordList.filter.fieldPlaceholder')"
:reduce="(f) => f.name"
:class="{ 'filter-field-picker': !!filter.name }"
@input="onChange($event, groupIndex, index)"
/>
</b-td>
<b-td
v-if="getPreparedField(filter.name)"
style="width: 250px;"
:class="{ 'px-2': getPreparedField(filter.name) }"
>
<b-form-select
v-if="getPreparedField(filter.name)"
v-model="filter.operator"
:options="getOperators(filter.kind, getPreparedField(filter.name))"
class="d-flex field-operator w-100"
/>
</b-td>
<b-td
v-if="getPreparedField(filter.name)"
:key="`${getPreparedField(filter.name)?.fieldID}-${filter.name}`"
>
<template v-if="isBetweenOperator(filter.operator)">
<template v-if="getPreparedField(`${filter.name}-start`)">
<field-editor
:field="getPreparedField(`${filter.name}-start`)"
:record="filter.record"
:module="mockModule"
:namespace="namespace"
:errors="errors"
value-only
class="mb-0 field-editor"
@change="onValueChange"
/>
<div class="my-1 text-center w-100">
{{ $t("general.label.and") }}
</div>
<field-editor
:field="getPreparedField(`${filter.name}-end`)"
:record="filter.record"
:module="mockModule"
:namespace="namespace"
:errors="errors"
value-only
class="mb-0 field-editor"
@change="onValueChange"
/>
</template>
</template>
<template v-else>
<field-editor
:field="getPreparedField(filter.name)"
:errors="errors"
:record="filter.record"
:module="mockModule"
:namespace="namespace"
value-only
class="mb-0 field-editor"
@change="onValueChange"
/>
</template>
</b-td>
<b-td
v-if="getPreparedField(filter.name)"
style="width: 1%;"
>
<b-button
:id="`${groupIndex}-${index}`"
ref="delete"
variant="outline-extra-light"
class="d-block text-dark border-0 h-full ml-2 px-2 mt-1"
@click="deleteFilter(groupIndex, index)"
>
<font-awesome-icon
:icon="['far', 'trash-alt']"
size="sm"
/>
</b-button>
</b-td>
</b-tr>
<b-tr
v-if="showAddCondition"
:key="`addFilter-${groupIndex}`"
>
<b-td class="pb-0">
<b-button
variant="primary"
size="sm"
class="d-block mr-auto"
@click="addFilter(groupIndex)"
>
<font-awesome-icon
:icon="['fas', 'plus']"
class="mr-1"
/>
{{ $t("general.label.add") }}
</b-button>
</b-td>
</b-tr>
<b-tr :key="`groupCondtion-${groupIndex}`">
<b-td
colspan="100%"
class="p-0 justify-content-center"
:class="{ 'pb-2': groupIndex !== (internalFilter.length - 1) }"
>
<div class="group-separator">
<b-button
v-if="groupIndex === (internalFilter.length - 1)"
variant="outline-primary"
class="btn-add-group d-block py-2 px-3 m-auto bg-white"
@click="addGroup()"
>
<font-awesome-icon
:icon="['fas', 'plus']"
class="mb-0 h6"
/>
</b-button>
<b-form-select
v-else
v-model="internalFilter[groupIndex + 1].groupCondition"
:options="groupConditionOptions"
size="sm"
class="group-condition-select"
@change="onGroupConditionChange"
/>
</div>
</b-td>
</b-tr>
</template>
</template>
</b-table-simple>
</template>
<script>
import { compose, validator } from '@cortezaproject/corteza-js'
import FieldEditor from 'corteza-webapp-compose/src/components/ModuleFields/Editor'
import { isBetweenOperator } from 'corteza-webapp-compose/src/lib/record-filter.js'
export default {
i18nOptions: {
namespaces: 'block',
},
name: 'FilterToolbox',
components: {
FieldEditor,
},
props: {
// Raw filter data (without Record objects) - same format as parent components use
value: {
type: Array,
default: undefined,
},
module: {
type: compose.Module,
required: true,
},
namespace: {
type: compose.Namespace,
required: true,
},
selectedField: {
type: Object,
default: undefined,
},
startEmpty: {
type: Boolean,
default: false,
},
},
data () {
return {
conditions: [
{ value: 'AND', text: this.$t('recordList.filter.conditions.and') },
{ value: 'OR', text: this.$t('recordList.filter.conditions.or') },
],
errors: new validator.Validated(),
mockModule: undefined,
preparedFields: [],
// Internal filter format (with Record objects) for editing
internalFilter: [],
// Flag to prevent circular updates when loading external data
isLoadingExternalData: false,
}
},
computed: {
fields () {
return [
...[...this.mockModule.fields].sort((a, b) => (a.label || a.name).localeCompare(b.label || b.name)),
...this.mockModule.systemFields().map((sf) => {
sf.label = this.$t(`field:system.${sf.name}`)
return sf
}),
].filter(({ isFilterable, canReadRecordValue }) => {
// Field must be filterable AND user must have permission to read its values
// Using !== false allows system fields (undefined) and explicitly allowed fields
return isFilterable && canReadRecordValue !== false
})
},
resolvedSelectedField () {
if (this.selectedField) {
return this.selectedField
} else if (this.fields.length) {
return this.fields[0]
}
return {}
},
showAddCondition () {
return this.internalFilter.length >= 1 && this.internalFilter[0].filter[0].name
},
groupConditionOptions () {
return [
{ value: 'OR', text: this.$t('recordList.filter.conditions.or') },
{ value: 'AND', text: this.$t('recordList.filter.conditions.and') },
]
},
},
watch: {
module: {
immediate: true,
handler (newModule) {
this.mockModule = new compose.Module(newModule)
if (this.mockModule) {
this.prepareFields()
}
},
},
value: {
immediate: true,
deep: true,
handler (rawFilter, oldValue) {
let internal = []
if (!rawFilter) {
internal = [this.createDefaultFilterGroup()]
} else {
internal = this.rawFilterToInternal(rawFilter)
if (!internal.length) {
internal = [this.createDefaultFilterGroup(this.startEmpty ? undefined : this.resolvedSelectedField)]
} else {
this.isLoadingExternalData = true
}
}
this.internalFilter = internal
this.$nextTick(() => {
this.isLoadingExternalData = false
})
},
},
internalFilter: {
deep: true,
handler (internalFilter) {
if (this.isLoadingExternalData) {
return
}
this.$emit('input', this.processInternalFilter(internalFilter))
},
},
},
methods: {
isBetweenOperator,
/**
* Converts raw filter data (without Record objects) to internal format (with Record objects)
* Used when loading filter data from parent component
*/
rawFilterToInternal (recordListFilter = []) {
if (!recordListFilter.length || !this.mockModule) {
return []
}
return recordListFilter.map(({ filter = [], name, groupCondition = 'OR' }) => {
filter = filter.map(({ value, ...f } = {}) => {
f.record = new compose.Record(this.mockModule, {})
if (this.isBetweenOperator(f.operator)) {
const field = this.getPreparedField(f.name)
if (field && field.isSystem) {
f.record[`${f.name}-start`] = value?.start
f.record[`${f.name}-end`] = value?.end
} else {
f.record.values[`${f.name}-start`] = value?.start
f.record.values[`${f.name}-end`] = value?.end
}
} else if (Object.keys(f.record.values).includes(f.name)) {
f.record.values[f.name] = value
} else if (Object.keys(f.record).includes(f.name)) {
// If its a system field add value to root of record
f.record[f.name] = value
}
return f
})
return { filter, name, groupCondition }
})
},
/**
* Processes internal filter format (with Record objects) to output format (without Record objects)
* Used when emitting filter data to parent component
*/
processInternalFilter (filter = []) {
if (!filter.length || !this.mockModule) {
return []
}
return filter.map(({ filter = [], name, groupCondition }) => {
filter = filter.map(({ record, ...f }) => {
if (!f.name || !record) {
return undefined
}
if (this.isBetweenOperator(f.operator)) {
const field = this.getPreparedField(f.name)
if (field) {
f.value = {
start: field.isSystem ? record[`${f.name}-start`] : record.values[`${f.name}-start`],
end: field.isSystem ? record[`${f.name}-end`] : record.values[`${f.name}-end`],
}
}
} else if (Object.keys(record.values).includes(f.name)) {
f.value = record.values[f.name]
} else if (Object.keys(record).includes(f.name)) {
f.value = record[f.name]
}
return f
})
return { filter, name, groupCondition }
})
},
prepareFields () {
const fields = []
this.fields.forEach(f => {
if (f.isMulti) {
f.isMulti = false
f.multi = true
}
if (f.kind === 'Record') {
f.options.prefilter = ''
}
if (f.kind === 'DateTime') {
f.options.onlyFutureValues = false
f.options.onlyPastValues = false
}
if (f.kind === 'Number') {
f.options.min = undefined
f.options.max = undefined
}
fields.push(f)
if (f.kind === 'DateTime' || f.kind === 'Number') {
fields.push({ ...f, name: `${f.name}-start` })
fields.push({ ...f, name: `${f.name}-end` })
}
})
this.preparedFields = fields
},
onChange (fieldName, groupIndex, index) {
const field = this.getPreparedField(fieldName)
const filterExists = !!(
this.internalFilter[groupIndex] || { filter: [] }
).filter[index]
if (field && filterExists) {
this.internalFilter[groupIndex].filter[index].kind = field.kind
this.internalFilter[groupIndex].filter[index].name = field.name
this.internalFilter[groupIndex].filter[index].value = undefined
this.internalFilter[groupIndex].filter[index].operator = field.multi
? 'IN'
: '='
}
},
onValueChange () {
this.$emit('value-change')
},
getOperators (kind, field) {
const operators = [
{
value: '=',
text: this.$t('recordList.filter.operators.equal'),
},
{
value: '!=',
text: this.$t('recordList.filter.operators.notEqual'),
},
]
const inOperators = [
{
value: 'IN',
text: this.$t('recordList.filter.operators.contains'),
},
{
value: 'NOT IN',
text: this.$t('recordList.filter.operators.notContains'),
},
]
const lgOperators = [
{
value: '>',
text: this.$t('recordList.filter.operators.greaterThan'),
},
{
value: '<',
text: this.$t('recordList.filter.operators.lessThan'),
},
]
const matchOperators = [
{
value: 'LIKE',
text: this.$t('recordList.filter.operators.like'),
},
{
value: 'NOT LIKE',
text: this.$t('recordList.filter.operators.notLike'),
},
]
const betweenOperators = [
{
value: 'BETWEEN',
text: this.$t('recordList.filter.operators.between'),
},
{
value: 'NOT BETWEEN',
text: this.$t('recordList.filter.operators.notBetween'),
},
]
if (field.multi) {
return inOperators
}
switch (kind) {
case 'Number':
return [...operators, ...lgOperators, ...betweenOperators]
case 'DateTime':
return [...operators, ...lgOperators, ...betweenOperators]
case 'String':
case 'Url':
case 'Email':
return [...operators, ...matchOperators]
default:
return operators
}
},
deleteFilter (groupIndex, index) {
const filterExists = !!(
this.internalFilter[groupIndex] || { filter: [] }
).filter[index]
if (filterExists) {
// Delete filter from filterGroup
this.internalFilter[groupIndex].filter.splice(index, 1)
// If filter was last in filterGroup, delete filterGroup
if (!this.internalFilter[groupIndex].filter.length) {
this.internalFilter.splice(groupIndex, 1)
// If no more filterGroups, add default back
if (!this.internalFilter.length) {
this.internalFilter = [this.createDefaultFilterGroup()]
}
}
}
this.$emit('value-change')
},
getOptionKey ({ name }) {
return name
},
getPreparedField (name = '') {
if (!this.preparedFields.length) {
return undefined
}
return this.preparedFields.find(f => f.name === name)
},
addFilter (groupIndex) {
if ((this.internalFilter[groupIndex] || {}).filter) {
this.internalFilter[groupIndex].filter.push(
this.createDefaultFilter(this.resolvedSelectedField),
)
}
},
createDefaultFilter (field = {}) {
return {
name: field.name,
operator: field.isMulti ? 'IN' : '=',
value: undefined,
kind: field.kind,
record: new compose.Record(this.mockModule, {}),
}
},
createDefaultFilterGroup (field, groupCondition = 'OR') {
return {
filter: [this.createDefaultFilter(field)],
groupCondition,
}
},
onGroupConditionChange () {
this.$emit('value-change')
},
addGroup () {
this.internalFilter.push(this.createDefaultFilterGroup(this.resolvedSelectedField))
this.$emit('value-change')
},
setDefaultValues () {
this.mockModule = undefined
},
},
}
</script>
<style lang="scss" scoped>
.group-separator {
display: flex;
align-items: center;
justify-content: center;
background-image: linear-gradient(to left, lightgray, lightgray);
background-repeat: no-repeat;
background-size: 100% 1px;
background-position: center;
}
td {
padding: 0;
padding-bottom: 0.5rem;
}
.btn-add-group {
&:hover,
&:active {
background-color: var(--primary) !important;
color: var(--white) !important;
}
}
.group-condition-select {
width: auto;
min-width: 80px;
border: 1px solid var(--light);
}
</style>

View File

@@ -0,0 +1,53 @@
// eslint-disable-next-line
import { default as component } from './Grid.vue'
import { components } from '@cortezaproject/corteza-vue'
const { checkbox } = components.C3.controls
const props = {
editable: true,
blocks: [
{
description: 'Description',
kind: 'Chart',
options: { chartID: '242313186186166275' },
style: {
variants: {
bodyBg: 'white',
border: 'primary',
headerBg: 'white',
headerText: 'primary',
},
title: 'Title',
wrap: {
kind: 'card',
},
},
title: 'Leads by type',
xywh: [9, 32, 3, 8],
},
],
}
export default {
name: 'Grid',
group: ['Common'],
component,
props,
controls: [
checkbox('Editable', 'editable'),
],
scenarios: [
{
label: 'Full form',
props,
},
{
label: 'Empty form',
props: {
...props,
editable: false,
},
},
],
}

View File

@@ -0,0 +1,197 @@
<template>
<div
v-if="layout.length"
class="w-100"
:class="{
'editable': editable,
'flex-grow-1 d-flex': isStretchable,
}"
>
<grid-layout
:key="gridKey"
:layout.sync="layout"
:col-num="48"
:row-height="10"
vertical-compact
:is-resizable="editable"
:is-draggable="editable"
:cols="columnNumber"
:margin="[0, 0]"
:responsive="!editable"
:use-css-transforms="false"
class="flex-grow-1 d-flex w-100 h-100"
@layout-updated="onLayoutUpdated"
>
<template
v-for="(item, index) in layout"
>
<grid-item
v-if="blocks[item.i] && !blocks[item.i].meta.hidden && (!blocks[item.i].meta.invisible || editable)"
:key="item.i"
ref="items"
:i="item.i"
:h="item.h"
:w="item.w"
:x="item.x"
:y="item.y"
:min-w="6"
:min-h="5"
:class="{ 'h-100': isStretchable }"
:style="{ 'touch-action': editable ? 'none' : 'auto' }"
class="grid-item"
@move="onGridAction"
@resize="onGridAction"
>
<slot
v-if="!blocks[item.i].meta.invisible"
:block="blocks[item.i]"
:index="index"
:block-index="item.i"
:resizing="resizing"
:loading-record="loadingRecord"
v-on="$listeners"
/>
</grid-item>
</template>
</grid-layout>
</div>
<div
v-else
class="no-builder-grid h-100 pt-5 container text-center"
>
<h4>
{{ $t('noBlock') }}
</h4>
</div>
</template>
<script>
import { GridLayout, GridItem } from 'vue-grid-layout'
export default {
i18nOptions: {
namespaces: 'page',
},
components: {
GridLayout,
GridItem,
},
props: {
blocks: {
type: Array,
default: () => ([]),
},
editable: {
type: Boolean,
},
loadingRecord: {
type: Boolean,
default: false,
},
},
data () {
return {
// all blocks in vue-grid friendly structure
layout: [],
resizing: false,
}
},
computed: {
oneBlockLayout () {
return this.blocks.filter(({ meta }) => !meta.hidden && (!meta.invisible || this.editable)).length === 1
},
isStretchable () {
return !this.editable && this.oneBlockLayout
},
columnNumber () {
if (this.oneBlockLayout) {
return { lg: 1, md: 1, sm: 1, xs: 1, xxs: 1 }
}
return { lg: 48, md: 48, sm: 1, xs: 1, xxs: 1 }
},
gridKey () {
// Force rerender when visible blocks change
return this.layout.map(b => b.i).join(',')
},
},
watch: {
blocks: {
immediate: true,
deep: true,
handler (blocks) {
const layout = blocks.map(({ meta = {}, xywh = [] }, i) => {
const [x = 0, y = 0, w = 48, h = 15] = (xywh || []).map(v => Number(v) || 0)
// To avoid collision with hidden elements
if (meta.hidden || (meta.invisible && !this.editable)) {
return null
}
return { i, x, y, w, h }
}).filter(b => b)
this.$set(this, 'layout', layout)
this.$nextTick(() => {
this.forceRerender()
})
},
},
},
beforeDestroy () {
this.setDefaultValues()
},
methods: {
onLayoutUpdated () {
if (!this.editable) return
this.resizing = false
this.layout.forEach(({ i, x, y, w, h }) => {
const layoutXYWH = [x, y, w, h]
const { xywh = [] } = this.blocks[i] || {}
if (xywh.toString() === layoutXYWH.toString()) return
this.$emit('item-updated', i)
this.$set(this.blocks[i], 'xywh', layoutXYWH)
})
},
onGridAction () {
if (!this.resizing) {
this.resizing = true
}
},
setDefaultValues () {
this.layout = []
this.resizing = false
},
forceRerender () {
// Force the grid layout to recalculate its dimensions
window.dispatchEvent(new Event('resize'))
},
},
}
</script>
<style lang="scss">
.vue-grid-item.vue-grid-placeholder {
background: var(--primary) !important;
}
</style>

View File

@@ -0,0 +1,57 @@
<template>
<b-form-group class="p-0 m-0">
<div
v-for="(expr, ei) in value"
:key="ei"
class="d-flex align-items-center gap-1 mb-2"
>
<b-input-group>
<b-input-group-prepend>
<b-input-group-text
v-b-tooltip.noninteractive.hover="{ title: $t('validators.expression.tooltip'), boundary: 'body' }"
variant="extra-light"
>
ƒ
</b-input-group-text>
</b-input-group-prepend>
<slot :value="value[ei]">
<b-form-input
v-model="value[ei]"
:placeholder="placeholder"
/>
</slot>
</b-input-group>
<c-input-confirm
:no-prompt="noPrompt(value[ei])"
show-icon
@confirmed="$emit('remove', ei)"
/>
</div>
</b-form-group>
</template>
<script>
export default {
i18nOptions: {
namespaces: 'field',
},
props: {
value: {
type: Array,
default: () => ([]),
},
placeholder: {
type: String,
default: () => {},
},
noPrompt: {
type: Function,
default: v => v.length === 0,
},
},
}
</script>

View File

@@ -0,0 +1,30 @@
// eslint-disable-next-line
import { default as component } from './RecordListFilter.vue'
import { compose } from '@cortezaproject/corteza-js'
const props = {
selectedField: { name: 'CaseNumber' },
namespace: new compose.Namespace(),
module: new compose.Module({
fields: [
{
isMulti: false,
label: 'Account Name',
name: 'AccountId',
},
],
}),
recordListFilter: [{
filter: [{
name: '',
}],
}],
}
export default {
name: 'Record list filter',
group: ['Common'],
component,
props,
controls: [],
}

View File

@@ -0,0 +1,265 @@
<template>
<div>
<b-button
:id="popoverTarget"
v-b-tooltip.noninteractive.hover="{ title: $t('recordList.filter.title'), boundary: 'body' }"
:variant="variant"
class="d-flex align-items-center d-print-none border-0 px-1 h-100"
:class="buttonClass"
:style="buttonStyle"
@click.stop
>
<font-awesome-icon
:icon="['fas', 'filter']"
:class="[inFilter ? 'text-primary' : inactiveIconClass]"
/>
</b-button>
<b-popover
ref="popover"
custom-class="record-list-filter shadow-sm"
triggers="click blur"
placement="bottom"
delay="0"
boundary="window"
boundary-padding="2"
:target="popoverTarget"
@hide="onHide"
@show="onOpen"
>
<b-card
no-body
class="position-static w-100"
>
<b-card-body class="px-3 pb-0 overflow-auto">
<filter-toolbox
v-model="componentFilter"
:module="module"
:namespace="namespace"
:selected-field="selectedField"
@value-change="preventClose"
/>
</b-card-body>
<b-card-footer class="d-flex justify-content-between shadow-sm rounded">
<b-button
variant="light"
@click="resetFilter"
>
{{ $t('general:label.reset') }}
</b-button>
<div class="d-flex">
<b-button
v-if="allowFilterPresetSave"
variant="outline-primary"
class="mr-2"
@click="onSave(true, 'filter-preset')"
>
{{ $t('recordList.filter.addFilterToPreset') }}
</b-button>
<b-button
ref="btnSave"
variant="primary"
@click="onSave"
>
{{ $t('general.label.save') }}
</b-button>
</div>
</b-card-footer>
</b-card>
<a
ref="focusMe"
href=""
disabled
/>
</b-popover>
</div>
</template>
<script>
import FilterToolbox from 'corteza-webapp-compose/src/components/Common/FilterToolbox.vue'
export default {
i18nOptions: {
namespaces: 'block',
},
components: {
FilterToolbox,
},
props: {
target: {
type: String,
default: '',
},
selectedField: {
type: Object,
default: undefined,
},
namespace: {
type: Object,
required: true,
},
module: {
type: Object,
required: true,
},
recordListFilter: {
type: Array,
required: true,
},
variant: {
type: String,
default: 'outline-light',
},
inactiveIconClass: {
type: String,
default: 'text-secondary',
},
buttonClass: {
type: String,
default: '',
},
buttonStyle: {
type: String,
default: '',
},
allowFilterPresetSave: {
type: Boolean,
default: false,
},
},
data () {
return {
componentFilter: [],
// Used to prevent unwanted closure of popover
preventPopoverClose: false,
}
},
computed: {
inFilter () {
return this.recordListFilter.some(({ filter }) => {
return filter.some(({ name }) => name === (this.selectedField || {}).name)
})
},
popoverTarget () {
return `${this.target || '0'}-${(this.selectedField || {}).name}`
},
},
watch: {
recordListFilter: {
immediate: true,
deep: true,
handler (recordListFilter) {
this.componentFilter = [...recordListFilter]
},
},
},
beforeDestroy () {
this.setDefaultValues()
},
methods: {
onHide (e) {
if (this.preventPopoverClose) {
e.preventDefault()
// Focuses invisible element to refocus popover (problems with closing it otherwise)
this.$nextTick(() => {
this.$refs.focusMe.focus()
})
}
},
onOpen () {
this.componentFilter = [...this.recordListFilter]
},
preventClose () {
this.preventPopoverClose = true
setTimeout(() => {
this.preventPopoverClose = false
}, 100)
},
resetFilter () {
this.componentFilter = undefined
this.$emit('reset')
},
onSave (close = true, type = 'filter') {
if (close) {
this.$refs.popover.$emit('close')
}
setTimeout(() => {
this.$emit(type, this.componentFilter.filter(({ filter = [] }) => filter.filter((f = {}) => !!f.name).length > 0))
}, 100)
},
setDefaultValues () {
this.componentFilter = []
this.preventPopoverClose = false
},
},
}
</script>
<style lang="scss">
.record-list-filter {
z-index: 1040;
max-width: 800px !important;
opacity: 1 !important;
border-color: transparent;
.popover-body {
display: flex;
width: 800px;
min-width: min(99vw, 350px);
max-width: 99vw;
max-height: 25rem;
padding: 0;
color: var(--black);
background: var(--white);
border-radius: 0.25rem;
opacity: 1 !important;
box-shadow: 0 3px 48px #00000026;
font-size: 0.9rem;
}
.v-select,
.field-operator,
.field-editor {
min-width: 120px;
}
.arrow {
&::before {
border-bottom-color: var(--white);
border-top-color: var(--white);
}
&::after {
border-top-color: var(--white);
}
}
}
</style>

View File

@@ -0,0 +1,320 @@
<template>
<c-toolbar
:class="{ 'shadow border-top': !inModal }"
:style="{ padding: inModal ? '0 !important' : '' }"
>
<template #start>
<b-button
v-if="!(hideBack || settings.hideBack)"
data-test-id="button-back"
:disabled="isProcessing"
variant="outline-light"
size="lg"
class="border-0 text-dark back"
@click.prevent="$emit('back')"
>
<span class="d-flex align-items-center gap-1">
<font-awesome-icon
:icon="['fas', hasBack ? 'chevron-left' : 'times']"
:class="hasBack ? 'back-icon' : ''"
/>
{{ backLabel }}
</span>
</b-button>
<slot name="start-actions" />
</template>
<template #center>
<div
v-if="isCreated && (recordNavigation.prev || recordNavigation.next)"
class="d-flex align-items-center fill-width gap-1"
>
<span v-b-tooltip.noninteractive.hover="{ title: $t('recordNavigation.prev'), boundary: 'body' }">
<b-button
pill
size="lg"
variant="outline-primary"
:disabled="!record || isProcessing || !recordNavigation.prev"
class="w-100"
@click="navigateToRecord(recordNavigation.prev)"
>
<font-awesome-icon :icon="['fas', 'angle-left']" />
</b-button>
</span>
<span v-b-tooltip.noninteractive.hover="{ title: $t('recordNavigation.next'), boundary: 'body' }">
<b-button
pill
size="lg"
variant="outline-primary"
:disabled="!record || isProcessing || !recordNavigation.next"
class="w-100"
@click="navigateToRecord(recordNavigation.next)"
>
<font-awesome-icon :icon="['fas', 'angle-right']" />
</b-button>
</span>
</div>
<slot name="center-actions" />
</template>
<template
v-if="module"
#end
>
<slot name="end-actions" />
<c-input-confirm
v-if="(processingAction === 'delete' || isCreated || isDraft) && !(isDeleted || hideDelete || settings.hideDelete) && canDeleteRecord"
:disabled="!record || isProcessing"
:processing="processingAction === 'delete'"
:text="labels.delete || $t('label.delete')"
size="lg"
size-confirm="lg"
variant="danger"
class="text-nowrap"
@confirmed="$emit('delete')"
/>
<c-input-confirm
v-else-if="(processingAction === 'undelete' || isDeleted) && !(hideDelete || settings.hideDelete) && canUndeleteRecord"
:disabled="!record || isProcessing"
:processing="processingAction === 'undelete'"
:text="$t('label.restore')"
size="lg"
size-confirm="lg"
variant="warning"
variant-ok="warning"
class="text-nowrap"
@confirmed="$emit('undelete')"
/>
<b-button
v-if="(isCreated || isDraft) && module.canCreateRecord && !(hideClone || settings.hideClone)"
data-test-id="button-clone"
variant="light"
size="lg"
:disabled="!record || isProcessing"
class="text-nowrap"
@click.prevent="$emit('clone')"
>
{{ labels.clone || (isDraft ? $t('label.saveAsNewDraft') : $t('label.saveAsCopy')) }}
</b-button>
<b-button
v-if="!inEditing && (isCreated || isDraft) && !(hideEdit || settings.hideEdit) && canManageRecord"
data-test-id="button-edit"
:disabled="!record || isProcessing"
variant="light"
size="lg"
class="text-nowrap"
@click.prevent="$emit('edit')"
>
{{ labels.edit || $t('label.edit') }}
</b-button>
<b-button
v-else-if="inEditing && (isCreated || (isDraft && !isNew)) && !(hideEdit || settings.hideEdit)"
data-test-id="button-view"
:disabled="!record || isProcessing"
variant="light"
size="lg"
class="text-nowrap"
@click.prevent="$emit('view')"
>
{{ labels.edit || $t('label.view') }}
</b-button>
<b-button
v-if="!inEditing && module.canCreateRecord && !(hideNew || settings.hideNew)"
data-test-id="button-add-new"
variant="primary"
size="lg"
:disabled="!record || isProcessing"
class="text-nowrap"
@click.prevent="$emit('add')"
>
{{ labels.new || $t('label.addNew') }}
</b-button>
<c-button-submit
v-if="inEditing && !(hideSubmit || settings.hideSubmit) && canManageRecord"
data-test-id="button-save"
:disabled="!record || isProcessing"
:processing="processingAction === 'submit'"
:text="labels.submit || $t('label.save')"
size="lg"
class="text-nowrap"
@submit="$emit('submit')"
/>
</template>
</c-toolbar>
</template>
<script>
import { components } from '@cortezaproject/corteza-vue'
import { compose, NoID } from '@cortezaproject/corteza-js'
import { throttle } from 'lodash'
const { CToolbar } = components
export default {
i18nOptions: {
namespaces: 'general',
},
components: {
CToolbar,
},
props: {
module: {
type: compose.Module,
required: false,
default: undefined,
},
record: {
type: compose.Record,
required: false,
default: undefined,
},
labels: {
type: Object,
default: () => ({}),
},
processing: {
type: Boolean,
default: false,
},
processingAction: {
type: String,
default: '',
},
isCreated: {
type: Boolean,
required: true,
},
inEditing: {
type: Boolean,
required: true,
},
hideBack: {
type: Boolean,
default: () => true,
},
hideDelete: {
type: Boolean,
default: () => true,
},
hideNew: {
type: Boolean,
default: () => true,
},
hideClone: {
type: Boolean,
default: () => true,
},
hideEdit: {
type: Boolean,
default: () => true,
},
hideSubmit: {
type: Boolean,
default: () => true,
},
inModal: {
type: Boolean,
required: false,
},
recordNavigation: {
type: Object,
required: false,
default: () => ({}),
},
hasBack: {
type: Boolean,
default: true,
},
isDraft: {
type: Boolean,
default: false,
},
isNew: {
type: Boolean,
default: false,
},
},
computed: {
isDeleted () {
return this.record && this.record.deletedAt
},
isProcessing () {
return this.processing || !!this.processingAction
},
settings () {
return this.$Settings.get('compose.ui.record-toolbar', {})
},
canManageRecord () {
if (!this.module || !this.record) {
return false
}
return this.record.recordID === NoID
? this.module.canCreateRecord
: this.record.canUpdateRecord
},
canDeleteRecord () {
if (!this.module || !this.record) {
return false
}
return !this.isDeleted && (this.isDraft || (this.record.canDeleteRecord && this.record.recordID !== NoID))
},
canUndeleteRecord () {
if (!this.module || !this.record) {
return false
}
return this.isDeleted && this.record.canUndeleteRecord && this.record.recordID !== NoID
},
backLabel () {
if (this.inModal) {
return this.hasBack ? this.$t('label.back') : this.$t('label.close')
}
return this.hasBack ? this.labels.back || this.$t('label.back') : this.$t('label.home')
},
},
methods: {
navigateToRecord: throttle(function (recordID) {
this.$emit('update-navigation', recordID)
}, 500),
},
}
</script>

View File

@@ -0,0 +1,59 @@
<template>
<b-button
v-b-tooltip.hover="{ title: $t('drafts:title'), delay: { show: 500, hide: 0 } }"
variant="outline-extra-light"
size="lg"
class="nav-icon rounded-circle text-center border-0 d-flex align-items-center justify-content-center position-relative"
@click="toggleDrafts"
>
<font-awesome-icon
:icon="['far', 'file']"
class="text-dark"
/>
<b-badge
v-if="draftCount > 0"
variant="primary"
pill
class="position-absolute draft-badge"
>
{{ draftCount > 9 ? '9+' : draftCount }}
</b-badge>
</b-button>
</template>
<script>
import { mapActions, mapGetters } from 'vuex'
export default {
name: 'CDraftButton',
computed: {
...mapGetters({
drafts: 'drafts/getAllDrafts',
}),
draftCount () {
return (this.drafts || []).length
},
},
methods: {
...mapActions({
toggleVisibility: 'drafts/toggleVisibility',
}),
toggleDrafts () {
this.toggleVisibility()
},
},
}
</script>
<style lang="scss" scoped>
.draft-badge {
top: 0;
right: 0;
transform: translate(25%, -25%);
font-size: 0.7rem;
}
</style>

View File

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

View File

@@ -0,0 +1,297 @@
<template>
<div class="draft-item-container px-3 pt-3 pb-2 border-bottom">
<b-list-group-item
:id="`draft-item-${draft.revision.changeID}`"
:class="{ 'border-primary': active }"
class="draft-item border rounded bg-white p-3 position-relative"
@click="$emit('click', draft)"
>
<div
class="action-menu bg-white pb-2 pl-2"
style="margin-left: -1rem;"
>
<b-dropdown
right
variant="outline-extra-light"
toggle-class="text-decoration-none border-0 dropdown-toggle-no-caret"
no-caret
>
<template #button-content>
<font-awesome-icon
:icon="['fas', 'ellipsis-v']"
class="text-secondary"
style="margin-top: 0.3rem;"
/>
</template>
<b-dropdown-item
v-if="recordID"
@click.stop="$emit('view', draft)"
>
<font-awesome-icon
:icon="['far', 'file-alt']"
class="text-primary"
/>
{{ $t('viewRecord') }}
</b-dropdown-item>
<c-input-confirm
:text="$t('general:label.delete')"
show-icon
borderless
variant="link"
size="md"
button-class="dropdown-item"
icon-class="text-danger"
class="w-100"
@confirmed="$emit('delete', draft)"
/>
</b-dropdown>
</div>
<div class="draft-item-content">
<h5
v-if="recordLabel && !useFieldViewer"
class="font-weight-bold text-break"
>
{{ recordLabel }}
</h5>
<div
v-if="useFieldViewer"
class="changed-field mb-2"
>
<div class="text-primary font-weight-bold">
{{ firstChangedField.label || firstChangedField.name }}
</div>
<field-viewer
v-bind="{ namespace, field: firstChangedField, record }"
value-only
class="text-break"
/>
</div>
<b-button
:id="`draft-description-${draft.revision.changeID}`"
variant="link"
class="small text-secondary mb-1 text-break cursor-pointer p-0"
@click.prevent.stop
>
{{ description }}
</b-button>
<div class="d-flex align-items-center justify-content-end flex-wrap gap-1">
<b-badge
v-b-tooltip.hover="{ title: $t('general:label.namespace.single'), delay: { show: 500, hide: 0 } }"
variant="primary"
style="font-size: 85%;"
>
{{ namespaceLabel }}
</b-badge>
<b-badge
v-b-tooltip.hover="{ title: $t('general:label.module.single'), delay: { show: 500, hide: 0 } }"
variant="extra-light"
style="font-size: 85%;"
>
{{ moduleLabel }}
</b-badge>
</div>
</div>
</b-list-group-item>
<div class="d-flex justify-content-end mt-2">
<div
:title="draft.revision.timestamp"
class="text-muted small"
>
{{ draft.revision.timestamp | locFullDateTime }}
</div>
</div>
<b-popover
v-if="changedFields.length > 0"
:target="`draft-description-${draft.revision.changeID}`"
triggers="hover"
placement="left"
boundary="viewport"
>
<div
v-for="field in changedFields"
:key="field.name"
class="mb-2"
>
<div class="text-primary font-weight-bold">
{{ field.label || field.name }}
</div>
<field-viewer
v-bind="{ namespace, field, record }"
value-only
class="text-break"
/>
</div>
</b-popover>
</div>
</template>
<script>
import { compose } from '@cortezaproject/corteza-js'
import { components } from '@cortezaproject/corteza-vue'
import FieldViewer from 'corteza-webapp-compose/src/components/ModuleFields/Viewer'
const { CInputConfirm } = components
export default {
i18nOptions: {
namespaces: ['drafts', 'general'],
},
components: {
CInputConfirm,
FieldViewer,
},
props: {
draft: {
type: Object,
required: true,
},
module: {
type: Object,
required: false,
default: undefined,
},
namespace: {
type: Object,
required: false,
default: undefined,
},
active: {
type: Boolean,
default: false,
},
},
computed: {
namespaceLabel () {
const { revision } = this.draft
const parts = revision.resource.split('/')
const namespaceID = parts[1]
if (this.namespace) {
return this.namespace.name || this.namespace.slug || namespaceID
}
return namespaceID
},
moduleLabel () {
const { revision } = this.draft
const parts = revision.resource.split('/')
const moduleID = parts[2]
if (this.module) {
return this.module.name || this.module.handle || moduleID
}
return moduleID
},
recordLabel () {
const { revision } = this.draft
if (this.module && revision.record) {
const record = new compose.Record(this.module, revision.record)
// Try to find the first field with a value
const firstField = (this.module.fields || [])[0]
if (firstField) {
const value = record.values[firstField.name]
if (value) {
return Array.isArray(value) ? value[0] : value
}
}
}
const isNew = revision.resource.endsWith('/0') || revision.operation === 'created'
if (isNew) {
return this.$t('general:label.newRecord') || 'New Record'
}
const parts = revision.resource.split('/')
const recordID = parts[3]
return recordID
},
description () {
const { revision } = this.draft
const isNew = revision.resource.endsWith('/0') || revision.operation === 'created'
if (isNew) {
return this.$t('general:label.newRecord') || 'New Record'
}
const changesCount = revision.changes.length
return this.$t('general:changes', { count: changesCount })
},
record () {
const { revision } = this.draft
if (this.module && revision.record) {
return new compose.Record(this.module, revision.record)
}
return null
},
recordID () {
const parts = this.draft.revision.resource.split('/')
return parts[3] === '0' ? undefined : parts[3]
},
firstChangedField () {
if (!this.module || !this.draft.revision.changes || this.draft.revision.changes.length === 0) return null
const changedNames = this.draft.revision.changes.map(c => c.key)
return (this.module.fields || []).find(f => changedNames.includes(f.name))
},
useFieldViewer () {
return !!this.module && !!this.record && !!this.firstChangedField
},
changedFields () {
if (!this.module || !this.draft.revision.changes) return []
const changedNames = this.draft.revision.changes.map(c => c.key)
return (this.module.fields || []).filter(f => changedNames.includes(f.name))
},
},
}
</script>
<style lang="scss" scoped>
.draft-item-container {
&:hover {
background-color: var(--light) !important;
}
.draft-item {
transition: background-color 0.2s ease;
cursor: pointer;
}
&:hover {
.action-menu {
opacity: 1 !important;
pointer-events: auto;
}
}
.action-menu {
position: absolute;
top: 0.5rem;
right: 0.5rem;
z-index: 2;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease;
}
}
</style>

View File

@@ -0,0 +1,276 @@
<template>
<div class="h-100 d-flex flex-column">
<div class="overflow-auto flex-grow-1 h-100">
<div
v-if="loading"
class="d-flex justify-content-center p-5"
>
<b-spinner variant="primary" />
</div>
<div
v-if="drafts.length > 0 && !loading"
class="d-flex align-items-center justify-content-between p-2 pl-3 border-bottom bg-light"
>
<div class="text-secondary small font-weight-bold">
{{ $t('count', { count: drafts.length }) }}
</div>
<div class="d-flex align-items-center gap-1">
<c-input-select
v-model="sortOrder"
:options="sortOptions"
:reduce="option => option.value"
:clearable="false"
:searchable="false"
size="sm"
style="width: 10rem;"
class="border-0"
>
<template #option="option">
<div class="d-flex align-items-center gap-1">
<font-awesome-icon
v-if="option && option.icon"
:icon="['fas', option.icon]"
class="text-secondary"
/>
{{ option.text }}
</div>
</template>
<template #selected-option="option">
<div
v-if="option"
class="d-flex align-items-center gap-1 text-secondary"
>
<font-awesome-icon
v-if="option.icon"
:icon="['fas', option.icon]"
/>
{{ option.text }}
</div>
</template>
</c-input-select>
<c-input-confirm
v-b-tooltip.hover.bottomleft="{ title: $t('deleteAllDrafts'), delay: { show: 500, hide: 0 } }"
show-icon
@confirmed="onClearAll"
/>
</div>
</div>
<b-list-group v-if="sortedDrafts.length > 0 && !loading">
<draft-item
v-for="draft in sortedDrafts"
:key="draft.revision.changeID"
:draft="draft"
:namespace="namespaces[draft.revision.resource.split('/')[1]]"
:module="modules[draft.revision.resource.split('/')[2]]"
:active="$route.query.draftID === String(draft.revision.changeID)"
@click="onDraftClick(draft)"
@view="onDraftClick(draft, true)"
@delete="onDeleteDraft"
/>
</b-list-group>
<div
v-else-if="!loading"
class="text-center p-5"
>
<font-awesome-icon
:icon="['far', 'edit']"
class="text-secondary mb-3"
size="3x"
/>
<p class="text-secondary">
{{ $t('empty') }}
</p>
</div>
</div>
</div>
</template>
<script>
import { mapGetters, mapActions, mapMutations } from 'vuex'
import { compose } from '@cortezaproject/corteza-js'
import { components } from '@cortezaproject/corteza-vue'
import DraftItem from 'corteza-webapp-compose/src/components/Drafts/DraftItem.vue'
const { CInputConfirm, CInputSelect } = components
export default {
i18nOptions: {
namespaces: ['drafts', 'notifications', 'general'],
},
components: {
DraftItem,
CInputConfirm,
CInputSelect,
},
data () {
return {
modules: {},
namespaces: {},
sortOrder: 'desc',
}
},
computed: {
...mapGetters({
drafts: 'drafts/getAllDrafts',
loading: 'drafts/isLoading',
}),
sortedDrafts () {
return [...this.drafts].sort((a, b) => {
const aTime = new Date(a.revision.timestamp)
const bTime = new Date(b.revision.timestamp)
return this.sortOrder === 'desc' ? bTime - aTime : aTime - bTime
})
},
sortOptions () {
return [
{ value: 'desc', text: this.$t('newestFirst'), icon: 'sort-amount-down', label: this.$t('newestFirst') },
{ value: 'asc', text: this.$t('oldestFirst'), icon: 'sort-amount-up', label: this.$t('oldestFirst') },
]
},
},
watch: {
drafts: {
immediate: true,
handler (drafts) {
this.fetchMetadata(drafts)
},
},
},
methods: {
...mapActions({
removeDraft: 'drafts/removeDraft',
clearDrafts: 'drafts/clearDrafts',
}),
...mapMutations({
setVisible: 'drafts/setVisible',
}),
async fetchMetadata (drafts) {
try {
for (const draft of drafts) {
const parts = draft.revision.resource.split('/')
const namespaceID = parts[1]
const moduleID = parts[2]
if (!this.namespaces[namespaceID]) {
const ns = await this.$ComposeAPI.namespaceRead({ namespaceID })
if (ns) {
this.$set(this.namespaces, namespaceID, new compose.Namespace(ns))
}
}
if (!this.modules[moduleID]) {
const mod = await this.$ComposeAPI.moduleRead({ namespaceID, moduleID })
if (mod) {
this.$set(this.modules, moduleID, new compose.Module(mod))
}
}
}
} catch (e) {
console.error('Failed to fetch metadata:', e)
}
},
async onDraftClick (draft, view = false) {
const { revision } = draft
// Resource format: compose:record/{namespaceID}/{moduleID}/{recordID}
const parts = revision.resource.split('/')
if (parts.length < 4) return
const namespaceID = parts[1]
const moduleID = parts[2]
const recordID = parts[3] === '0' ? undefined : parts[3]
let pageID
try {
const pages = await this.$ComposeAPI.pageList({ namespaceID, moduleID })
const recordPage = (pages.set || []).find(p => p.moduleID === moduleID)
if (recordPage) {
pageID = recordPage.pageID
}
} catch (e) {
console.error('Failed to fetch page metadata:', e)
}
if (!pageID) {
this.toastDanger(this.$t('notifications:recordRedirectError'))
return
}
const isCompose = this.$router.app.$options.name === 'compose'
const ns = this.namespaces[namespaceID]
const slug = ns ? ns.slug || ns.namespaceID : namespaceID
if (!isCompose) {
const u = new URL(window.location)
let url = `${u.origin}/compose/ns/${slug}/pages/${pageID}/record/`
if (recordID) {
url += recordID
if (!view) {
url += '/edit'
}
}
if (!view) {
url += `?draftID=${revision.changeID}`
}
window.location.assign(url)
return
}
const route = {
name: view ? 'page.record' : (recordID ? 'page.record.edit' : 'page.record.create'),
params: {
slug,
pageID,
recordID,
},
query: view
? {}
: {
draftID: revision.changeID,
},
}
this.$router.push(route).catch(err => {
console.error('Draft navigation failed:', err)
})
},
onDeleteDraft ({ revision }) {
this.removeDraft({ changeID: revision.changeID })
.then(() => {
this.toastSuccess(this.$t('deleted'))
})
.catch(() => {
this.toastDanger(this.$t('deleteError'))
})
},
onClearAll () {
this.clearDrafts()
.then(() => {
this.toastSuccess(this.$t('allDeleted'))
})
.catch(() => {
this.toastDanger(this.$t('deleteError'))
})
},
},
}
</script>

View File

@@ -0,0 +1,2 @@
export { default as Pagination } from './Common/Pagination.c3'
// export { default as Configurator } from './Configurator/Configurator.c3'

View File

@@ -0,0 +1,34 @@
// eslint-disable-next-line
import { default as component } from './Pagination.vue'
import { components } from '@cortezaproject/corteza-vue'
const { checkbox } = components.C3.controls
const props = {
hasPrevPage: true,
hasNextPage: true,
}
export default {
name: 'Pagination',
group: ['ModuleFields'],
component,
props,
controls: [
checkbox('HasPrevPage', 'hasPrevPage'),
checkbox('HasNextPage', 'hasNextPage'),
],
scenarios: [
{
label: 'Full form',
props,
},
{
label: 'Empty form',
props: {
hasPrevPage: false,
hasNextPage: false,
},
},
],
}

View File

@@ -0,0 +1,42 @@
<template>
<li
class="d-flex mt-1 mx-1"
>
<b-button
class="flex-grow-1"
:disabled="!hasPrevPage"
size="sm"
@click="$emit('prev')"
>
<font-awesome-icon
:icon="['fas', 'chevron-left']"
/>
</b-button>
<b-button
class="flex-grow-1 ml-1"
:disabled="!hasNextPage"
size="sm"
@click="$emit('next')"
>
<font-awesome-icon
:icon="['fas', 'chevron-right']"
/>
</b-button>
</li>
</template>
<script>
export default {
props: {
hasPrevPage: {
type: Boolean,
required: true,
},
hasNextPage: {
type: Boolean,
required: true,
},
},
}
</script>

View File

@@ -0,0 +1,82 @@
<template>
<div>
<b-form-group
:label="$t('kind.bool.checkedValueLabel')"
label-class="text-primary"
>
<b-input-group>
<b-form-input
v-model="f.options.trueLabel"
:placeholder="$t('kind.bool.checkedValuePlaceholder')"
/>
<b-input-group-append>
<field-bool-translator
v-if="field"
:field="field"
:module="module"
:highlight-key="`meta.bool.true.label`"
size="sm"
:disabled="isNew"
/>
</b-input-group-append>
</b-input-group>
</b-form-group>
<b-form-group
:label="$t('kind.bool.uncheckedValueLabel')"
label-class="text-primary"
>
<b-input-group>
<b-form-input
v-model="f.options.falseLabel"
:placeholder="$t('kind.bool.uncheckedValuePlaceholder')"
/>
<b-input-group-append>
<field-bool-translator
v-if="field"
:field="field"
:module="module"
:highlight-key="`meta.bool.fals.label`"
size="sm"
:disabled="isNew"
/>
</b-input-group-append>
</b-input-group>
</b-form-group>
<b-form-group
:label="$t('kind.bool.toggleTypeLabel')"
label-class="text-primary"
>
<b-form-checkbox v-model="f.options.switch">
{{ $t('kind.bool.toggleType') }}
</b-form-checkbox>
</b-form-group>
</div>
</template>
<script>
import base from './base'
import { NoID } from '@cortezaproject/corteza-js'
import FieldBoolTranslator from 'corteza-webapp-compose/src/components/Admin/Module/FieldBoolTranslator'
export default {
i18nOptions: {
namespaces: 'field',
},
components: {
FieldBoolTranslator,
},
extends: base,
computed: {
isNew () {
return this.module.moduleID === NoID || this.field.fieldID === NoID
},
},
}
</script>

View File

@@ -0,0 +1,22 @@
// eslint-disable-next-line
import { compose } from '@cortezaproject/corteza-js'
import * as fieldTypes from './loader'
const props = {
namespace: new compose.Namespace(),
module: new compose.Module(),
field: new compose.ModuleFieldBool({
options: {
trueLabel: 'true',
falseLabel: 'false',
},
}),
}
export default {
name: { ...fieldTypes },
group: ['ModuleFields', 'Configurator'],
...fieldTypes,
props,
controls: [],
}

View File

@@ -0,0 +1,114 @@
<template>
<div>
<b-form-group
:label="$t('kind.dateTime.type.label')"
label-class="text-primary"
class="w-25"
>
<b-form-radio-group
v-b-tooltip.hover="{ title: hasData ? $t('not-configurable') : '', placement: 'left', boundary: 'body' }"
:checked="inputType"
:options="[
{ value: 'dateTime', text: $t('kind.dateTime.type.options.dateTime') },
{ value: 'date', text: $t('kind.dateTime.type.options.date') },
{ value: 'time', text: $t('kind.dateTime.type.options.time') },
]"
:disabled="hasData"
stacked
@input="onTypeChange"
/>
</b-form-group>
<b-form-group
v-if="!f.options.onlyTime"
:label="$t('kind.dateTime.constraints.label')"
label-class="text-primary"
class="mt-2"
>
<b-form-radio-group
:checked="constraintType"
:options="[
{ value: 'all', text: $t('kind.dateTime.constraints.options.all') },
{ value: 'pastValuesOnly', text: $t('kind.dateTime.constraints.options.pastValuesOnly') },
{ value: 'futureValuesOnly', text: $t('kind.dateTime.constraints.options.futureValuesOnly') },
]"
stacked
@input="onConstraintChange"
/>
</b-form-group>
<b-form-group
:label="$t('kind.dateTime.outputFormat')"
label-class="text-primary"
class="mt-2"
>
<b-form-checkbox
v-model="f.options.outputRelative"
class="mb-2"
>
{{ $t('kind.dateTime.relativeOutput') }}
</b-form-checkbox>
<template v-if="!f.options.outputRelative">
<b-form-input
v-model="f.options.format"
plain
placeholder="YYYY-MM-DD HH:ii"
/>
<div class="small text-muted">
<i18next
path="kind.dateTime.outputFormatFootnote"
tag="label"
>
<a
href="https://momentjs.com/docs/#/displaying/format/"
target="_blank"
rel="noopener noreferrer"
>Moment.js</a>
</i18next>
</div>
</template>
</b-form-group>
</div>
</template>
<script>
import base from './base'
export default {
i18nOptions: {
namespaces: 'field',
},
extends: base,
computed: {
inputType () {
if (this.f.options.onlyDate) return 'date'
if (this.f.options.onlyTime) return 'time'
return 'dateTime'
},
constraintType () {
if (this.f.options.onlyPastValues) return 'pastValuesOnly'
if (this.f.options.onlyFutureValues) return 'futureValuesOnly'
return 'all'
},
},
methods: {
onTypeChange (v) {
this.f.options.onlyDate = v === 'date'
this.f.options.onlyTime = v === 'time'
},
onConstraintChange (v) {
this.f.options.onlyPastValues = v === 'pastValuesOnly'
this.f.options.onlyFutureValues = v === 'futureValuesOnly'
},
},
}
</script>

View File

@@ -0,0 +1,19 @@
<template>
<div>
<b-form-checkbox v-model="f.options.outputPlain">
{{ $t('kind.email.preventToLink') }}
</b-form-checkbox>
</div>
</template>
<script>
import base from './base'
export default {
i18nOptions: {
namespaces: 'field',
},
extends: base,
}
</script>

View File

@@ -0,0 +1,231 @@
<template>
<div>
<b-form-group
:label="$t('kind.file.view.maxSizeLabel')"
label-class="text-primary"
>
<b-input-group>
<b-form-input
v-model="f.options.maxSize"
type="number"
number
/>
</b-input-group>
</b-form-group>
<b-form-group
:label="$t('kind.file.view.mimetypesLabel')"
:description="$t('kind.file.view.mimetypesFootnote')"
label-class="text-primary"
class="mt-2"
>
<b-form-input
v-model="f.options.mimetypes"
/>
</b-form-group>
<b-form-group
:description="$t('kind.file.view.webcam.enable.footnote')"
class="mt-2"
>
<b-form-checkbox
v-model="f.options.enableWebcam"
>
{{ $t('kind.file.view.webcam.enable.label') }}
</b-form-checkbox>
</b-form-group>
<b-form-group
:description="$t('kind.file.view.modeFootnote')"
:label="$t('kind.file.view.modeLabel')"
label-class="text-primary"
>
<b-form-radio-group
v-model="f.options.mode"
buttons
button-variant="outline-secondary"
size="sm"
name="buttons2"
:options="modes"
/>
</b-form-group>
<b-form-group>
<b-form-checkbox
v-if="enablePreviewStyling"
v-model="f.options.hideFileName"
>
{{ $t('kind.file.view.showName') }}
</b-form-checkbox>
<b-form-checkbox
v-model="f.options.clickToView"
>
{{ $t('kind.file.view.clickToView') }}
</b-form-checkbox>
<b-form-checkbox
v-model="f.options.enableDownload"
>
{{ $t('kind.file.view.enableDownload') }}
</b-form-checkbox>
</b-form-group>
<template v-if="enablePreviewStyling">
<hr>
<h5 class="mb-2">
{{ $t('kind.file.view.previewStyle') }}
</h5>
<small>{{ $t('kind.file.view.description' ) }}</small>
<b-row
align-v="center"
class="mb-2 mt-2"
>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('kind.file.view.height')"
label-class="text-primary"
>
<b-form-input
v-model="f.options.height"
/>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('kind.file.view.width')"
label-class="text-primary"
>
<b-form-input
v-model="f.options.width"
/>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('kind.file.view.maxHeight')"
label-class="text-primary"
>
<b-form-input
v-model="f.options.maxHeight"
/>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('kind.file.view.maxWidth')"
label-class="text-primary"
>
<b-form-input
v-model="f.options.maxWidth"
/>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('kind.file.view.borderRadius')"
label-class="text-primary"
>
<b-form-input
v-model="f.options.borderRadius"
/>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('kind.file.view.margin')"
label-class="text-primary"
>
<b-form-input
v-model="f.options.margin"
/>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('kind.file.view.background')"
label-class="text-primary"
>
<c-input-color-picker
v-model="f.options.backgroundColor"
:translations="{
modalTitle: $t('kind.file.view.colorPicker'),
light: $t('general:themes.labels.light'),
dark: $t('general:themes.labels.dark'),
cancelBtnLabel: $t('general:label.cancel'),
saveBtnLabel: $t('general:label.saveAndClose')
}"
:theme-settings="themeSettings"
/>
</b-form-group>
</b-col>
</b-row>
</template>
</div>
</template>
<script>
import base from './base'
import { components } from '@cortezaproject/corteza-vue'
const { CInputColorPicker } = components
export default {
i18nOptions: {
namespaces: 'field',
},
components: {
CInputColorPicker,
},
extends: base,
computed: {
modes () {
return [
{ value: 'list', text: this.$t('kind.file.view.list') },
{ value: 'gallery', text: this.$t('kind.file.view.gallery') },
]
},
enablePreviewStyling () {
const { mode } = this.f.options
return mode === 'gallery'
},
themeSettings () {
return this.$Settings.get('ui.studio.themes', [])
},
},
}
</script>

View File

@@ -0,0 +1,65 @@
<template>
<div class="d-flex flex-column">
<b-form-group>
<b-form-checkbox v-model="f.options.prefillWithCurrentLocation">
{{ $t('prefillWithCurrentLocation') }}
</b-form-checkbox>
<b-form-checkbox v-model="f.options.hideCurrentLocationButton">
{{ $t('hideCurrentLocationButton') }}
</b-form-checkbox>
<b-form-checkbox v-model="f.options.hideGeoSearch">
{{ $t('hideGeoSearch') }}
</b-form-checkbox>
</b-form-group>
<b-form-group
:label="$t('initialZoomAndPosition')"
label-class="text-primary"
class="mb-0"
>
<c-map
:map="{
zoom,
center,
}"
:labels="{
tooltip: { 'goToCurrentLocation': $t('tooltip.goToCurrentLocation') }
}"
style="height: 50vh;"
@on-zoom="f.options.zoom = $event"
@on-center="f.options.center = $event"
/>
</b-form-group>
</div>
</template>
<script>
import base from './base'
import { components } from '@cortezaproject/corteza-vue'
const { CMap } = components
export default {
i18nOptions: {
namespaces: 'field',
keyPrefix: 'kind.geometry',
},
components: {
CMap,
},
extends: base,
computed: {
center () {
return this.f.options.center || [30, 30]
},
zoom () {
return this.f.options.zoom || 3
},
},
}
</script>

View File

@@ -0,0 +1,496 @@
<template>
<div>
<b-row>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('kind.number.displayType.label')"
label-class="text-primary"
>
<b-form-radio-group
v-model="f.options.display"
button-variant="outline-primary"
:options="displayOptions"
buttons
/>
</b-form-group>
</b-col>
</b-row>
<b-row>
<b-col
cols="12"
lg="6"
>
<b-form-group
v-b-tooltip.hover="{ title: hasData ? $t('not-configurable') : $t('kind.number.precisionTooltip'), boundary: 'body' }"
:label="`${$t('kind.number.precisionLabel')} ${(f.options.precision)}`"
label-class="mb-2 text-primary"
>
<b-form-input
v-model="f.options.precision"
:disabled="hasData"
type="range"
min="0"
max="6"
/>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('kind.number.step.label')"
:description="$t('kind.number.step.description')"
label-class="text-primary"
>
<b-form-input
v-model="f.options.step"
type="number"
number
/>
</b-form-group>
</b-col>
</b-row>
<hr>
<b-row>
<template v-if="f.options.display === 'number'">
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('kind.number.prefixLabel')"
label-class="text-primary"
>
<b-form-input
v-model="f.options.prefix"
placeholder="USD/mo"
/>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('kind.number.suffixLabel')"
label-class="text-primary"
>
<b-form-input
v-model="f.options.suffix"
placeholder="$"
/>
</b-form-group>
</b-col>
</template>
<template v-if="f.options.display === 'number'">
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('kind.number.presetFormats.label')"
label-class="text-primary"
:description="formattedOptionsDescription"
style="white-space: pre-line;"
>
<b-form-select
v-model="f.options.presetFormat"
:options="formatOptions"
/>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('kind.number.formatLabel')"
label-class="text-primary"
>
<b-form-input
v-model="f.options.format"
:disabled="f.options.presetFormat !== 'custom'"
placeholder="0.00"
/>
</b-form-group>
</b-col>
<b-col
cols="12"
>
<b-form-group
v-if="f.options.display === 'number'"
:label="$t('kind.number.examplesLabel')"
label-class="text-primary"
>
<b-table-simple class="w-100 table-sm">
<thead>
<tr>
<th id="example-input">
{{ $t('kind.number.exampleInput') }}
</th>
<th id="example-format">
{{ $t('kind.number.exampleFormat') }}
</th>
<th id="example-result">
{{ $t('kind.number.exampleResult') }}
</th>
</tr>
</thead>
<tr>
<td>1000.234</td>
<td>0,0.00</td>
<td>1,000.23</td>
</tr>
<tr>
<td>1000.234</td>
<td>0,0</td>
<td>1,000</td>
</tr>
<tr>
<td>0.974878234</td>
<td>0.000%</td>
<td>97.488%</td>
</tr>
<tr>
<td>100</td>
<td>0o</td>
<td>100th</td>
</tr>
<tr>
<td>238</td>
<td>00:00:00</td>
<td>0:03:58</td>
</tr>
</b-table-simple>
</b-form-group>
</b-col>
</template>
<template v-if="f.options.display === 'progress'">
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('kind.number.progress.minimumValue')"
label-class="text-primary"
>
<b-form-input
v-model="f.options.min"
type="number"
number
/>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('kind.number.progress.maximumValue')"
label-class="text-primary"
>
<b-form-input
v-model="f.options.max"
type="number"
number
/>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('kind.number.progress.variants.default')"
label-class="text-primary"
>
<b-form-select
v-model="f.options.variant"
:options="variants"
/>
</b-form-group>
</b-col>
<b-col
cols="12"
sm="3"
>
<b-form-group
class="mb-0"
>
<b-form-checkbox
v-model="f.options.showValue"
class="mb-2"
>
{{ $t('kind.number.progress.show.value') }}
</b-form-checkbox>
<b-form-checkbox
v-model="f.options.animated"
>
{{ $t('kind.number.progress.animated') }}
</b-form-checkbox>
</b-form-group>
</b-col>
<b-col
cols="12"
sm="3"
>
<b-form-group
v-if="f.options.showValue"
class="mb-0"
>
<b-form-checkbox
v-model="f.options.showRelative"
class="mb-2"
>
{{ $t('kind.number.progress.show.relative') }}
</b-form-checkbox>
<b-form-checkbox
v-model="f.options.showProgress"
>
{{ $t('kind.number.progress.show.progress') }}
</b-form-checkbox>
</b-form-group>
</b-col>
<b-col
cols="12"
>
<b-form-group>
<template #label>
<div
class="d-flex align-items-center text-primary"
>
{{ $t('kind.number.progress.thresholds.label') }}
<b-button
variant="link"
class="text-decoration-none ml-1"
@click="addThreshold()"
>
{{ $t('general:label.add-with-plus') }}
</b-button>
</div>
<small
class="text-muted"
>
{{ $t('kind.number.progress.thresholds.description') }}
</small>
</template>
<b-row
v-for="(t, i) in field.options.thresholds"
:key="i"
align-v="center"
:class="{ 'mt-2': i }"
>
<b-col>
<b-input-group
append="%"
>
<b-form-input
v-model="t.value"
:placeholder="'Threshold'"
type="number"
number
/>
</b-input-group>
</b-col>
<b-col
class="d-flex align-items-center justify-content-center"
>
<b-form-select
v-model="t.variant"
:options="variants"
/>
<font-awesome-icon
:icon="['fas', 'times']"
class="pointer text-danger ml-3"
@click="removeThreshold(i)"
/>
</b-col>
</b-row>
</b-form-group>
</b-col>
</template>
</b-row>
<hr>
<b-form-group
:label=" $t('kind.number.liveExample')"
label-class="text-primary"
class="mb-0 w-100"
>
<b-row
align-v="center"
>
<b-col
cols="12"
lg="6"
>
<b-form-input
v-model="liveExample"
type="number"
number
:step="f.options.step"
/>
</b-col>
<b-col
cols="12"
lg="6"
>
<field-viewer
value-only
v-bind="mock"
/>
</b-col>
</b-row>
</b-form-group>
</div>
</template>
<script>
import base from './base'
import FieldViewer from '../Viewer'
import { compose, validator } from '@cortezaproject/corteza-js'
export default {
i18nOptions: {
namespaces: 'field',
},
components: {
FieldViewer,
},
extends: base,
data () {
return {
liveExample: undefined,
displayOptions: [
{ text: this.$t('kind.number.displayType.number'), value: 'number' },
{ text: this.$t('kind.number.displayType.progress'), value: 'progress' },
],
variants: [
{ text: this.$t('kind.number.progress.variants.primary'), value: 'primary' },
{ text: this.$t('kind.number.progress.variants.secondary'), value: 'secondary' },
{ text: this.$t('kind.number.progress.variants.success'), value: 'success' },
{ text: this.$t('kind.number.progress.variants.warning'), value: 'warning' },
{ text: this.$t('kind.number.progress.variants.danger'), value: 'danger' },
{ text: this.$t('kind.number.progress.variants.info'), value: 'info' },
{ text: this.$t('kind.number.progress.variants.light'), value: 'light' },
{ text: this.$t('kind.number.progress.variants.dark'), value: 'dark' },
],
mock: {
namespace: undefined,
module: undefined,
field: undefined,
record: undefined,
errors: new validator.Validated(),
},
formatOptions: [
{ value: 'custom', text: this.$t('kind.number.presetFormats.options.custom') },
{ value: 'accounting', text: this.$t('kind.number.presetFormats.options.accounting') },
],
}
},
computed: {
formattedOptionsDescription () {
return this.$t(`kind.number.presetFormats.description.${this.f.options.presetFormat}`)
},
},
watch: {
'field.options.display': {
handler (display) {
this.liveExample = display === 'number' ? 1234.56789 : 33.45679
},
},
'field.options': {
deep: true,
handler (options) {
if (this.mock.field) {
this.mock.field.options = options
this.mock.record.values.mockField = Number(this.liveExample).toFixed(this.field.options.precision)
}
},
},
liveExample: {
handler (value) {
if (this.mock.field) {
value = Number(value).toFixed(this.field.options.precision)
this.mock.record.values.mockField = value
}
},
},
},
created () {
this.mock.namespace = this.namespace
this.mock.field = compose.ModuleFieldMaker(this.field)
this.mock.field.isMulti = false
this.mock.field.apply({ name: 'mockField' })
this.mock.module = new compose.Module({ fields: [this.mock.field] }, this.namespace)
this.mock.record = new compose.Record(this.mock.module, { })
this.liveExample = this.field.options.display === 'number' ? 1234.56789 : 33.45679
},
beforeDestroy () {
this.setDefaultValues()
},
methods: {
addThreshold () {
this.field.options.thresholds.push({ value: 0, variant: 'success' })
},
removeThreshold (index) {
if (index > -1) {
this.field.options.thresholds.splice(index, 1)
}
},
setDefaultValues () {
this.liveExample = undefined
this.displayOptions = []
this.variants = []
this.mock = {}
this.formatOptions = []
},
},
}
</script>

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