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
3
client/web/compose/.browserslistrc
Normal file
@@ -0,0 +1,3 @@
|
||||
> 1%
|
||||
last 2 versions
|
||||
not dead
|
||||
16
client/web/compose/.editorconfig
Normal 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
|
||||
10
client/web/compose/.eslintrc.js
Normal 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
@@ -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
|
||||
42
client/web/compose/Makefile
Normal 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 $@
|
||||
5
client/web/compose/babel.config.js
Normal file
@@ -0,0 +1,5 @@
|
||||
module.exports = {
|
||||
presets: [
|
||||
['@vue/app', { useBuiltIns: 'entry' }],
|
||||
],
|
||||
}
|
||||
36
client/web/compose/jest.config.js
Normal 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',
|
||||
],
|
||||
}
|
||||
104
client/web/compose/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
client/web/compose/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
autoprefixer: {},
|
||||
rtlcss: {},
|
||||
},
|
||||
}
|
||||
1
client/web/compose/public/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
config.js
|
||||
18
client/web/compose/public/config.example.js
Normal 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
|
||||
9
client/web/compose/public/icon.svg
Normal 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 |
35
client/web/compose/public/index.html
Normal 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>
|
||||
231
client/web/compose/src/app.js
Normal 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
|
||||
}
|
||||
BIN
client/web/compose/src/assets/PageBlocks/Automation.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
client/web/compose/src/assets/PageBlocks/Calendar.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
client/web/compose/src/assets/PageBlocks/Chart.png
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
client/web/compose/src/assets/PageBlocks/Comment.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
client/web/compose/src/assets/PageBlocks/Content.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
client/web/compose/src/assets/PageBlocks/File.png
Normal file
|
After Width: | Height: | Size: 90 KiB |
BIN
client/web/compose/src/assets/PageBlocks/IFrame.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
client/web/compose/src/assets/PageBlocks/Metric.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
client/web/compose/src/assets/PageBlocks/Navigation.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
client/web/compose/src/assets/PageBlocks/Progress.png
Normal file
|
After Width: | Height: | Size: 9.9 KiB |
BIN
client/web/compose/src/assets/PageBlocks/Record.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
client/web/compose/src/assets/PageBlocks/RecordList.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
client/web/compose/src/assets/PageBlocks/RecordOrganizer.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
client/web/compose/src/assets/PageBlocks/Report.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
client/web/compose/src/assets/PageBlocks/SocialFeed.png
Normal file
|
After Width: | Height: | Size: 82 KiB |
BIN
client/web/compose/src/assets/PageBlocks/Tabs.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
18
client/web/compose/src/assets/PageBlocks/index.js
Normal 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')
|
||||
14
client/web/compose/src/common/scripts.js
Normal 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
|
||||
})
|
||||
}
|
||||
3
client/web/compose/src/components/Admin/C3.js
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as FieldRowEdit } from './Module/FieldRowEdit.c3'
|
||||
// disabled: uses store
|
||||
// export { default as Tree } from './Page/Tree.c3'
|
||||
24
client/web/compose/src/components/Admin/EditorToolbar.c3.js
Normal 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'),
|
||||
],
|
||||
}
|
||||
173
client/web/compose/src/components/Admin/EditorToolbar.vue
Normal 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>
|
||||
46
client/web/compose/src/components/Admin/Export.vue
Normal 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>
|
||||
200
client/web/compose/src/components/Admin/Import.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
279
client/web/compose/src/components/Admin/Module/DalSettings.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
214
client/web/compose/src/components/Admin/Module/FieldRowEdit.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
174
client/web/compose/src/components/Admin/Module/RelatedPages.vue
Normal 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>
|
||||
284
client/web/compose/src/components/Admin/Module/UniqueValues.vue
Normal 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>
|
||||
124
client/web/compose/src/components/Admin/Module/Validation.vue
Normal 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>
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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>
|
||||
317
client/web/compose/src/components/Admin/Page/PageTranslator.vue
Normal 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>
|
||||
69
client/web/compose/src/components/Admin/Page/Tree.c3.js
Normal 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,
|
||||
// },
|
||||
// },
|
||||
// ],
|
||||
}
|
||||
478
client/web/compose/src/components/Admin/Page/Tree.vue
Normal 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>
|
||||
@@ -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>
|
||||
25
client/web/compose/src/components/C3.js
Normal 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,
|
||||
}
|
||||
2
client/web/compose/src/components/Chart/C3.js
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as ReportEdit } from './Report/ReportEdit.c3'
|
||||
export { default as ReportItem } from './ReportItem.c3'
|
||||
145
client/web/compose/src/components/Chart/ChartTranslator.vue
Normal 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>
|
||||
173
client/web/compose/src/components/Chart/Report/FunnelChart.vue
Normal 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>
|
||||
227
client/web/compose/src/components/Chart/Report/GaugeChart.vue
Normal 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>
|
||||
705
client/web/compose/src/components/Chart/Report/GenericChart.vue
Normal 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>
|
||||
160
client/web/compose/src/components/Chart/Report/RadarChart.vue
Normal 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>
|
||||
@@ -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: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
691
client/web/compose/src/components/Chart/Report/ReportEdit.vue
Normal 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>
|
||||
88
client/web/compose/src/components/Chart/Report/base.vue
Normal 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>
|
||||
11
client/web/compose/src/components/Chart/Report/index.js
Normal 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,
|
||||
}
|
||||
16
client/web/compose/src/components/Chart/ReportItem.c3.js
Normal 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: [
|
||||
],
|
||||
}
|
||||
57
client/web/compose/src/components/Chart/ReportItem.vue
Normal 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>
|
||||
262
client/web/compose/src/components/Chart/index.vue
Normal 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>
|
||||
7
client/web/compose/src/components/Common/C3.js
Normal 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'
|
||||
26
client/web/compose/src/components/Common/CircleStep.c3.js
Normal 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'),
|
||||
],
|
||||
}
|
||||
84
client/web/compose/src/components/Common/CircleStep.vue
Normal 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>
|
||||
31
client/web/compose/src/components/Common/FieldPicker.c3.js
Normal 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'),
|
||||
],
|
||||
}
|
||||
184
client/web/compose/src/components/Common/FieldPicker.vue
Normal 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>
|
||||
622
client/web/compose/src/components/Common/FilterToolbox.vue
Normal 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>
|
||||
53
client/web/compose/src/components/Common/Grid.c3.js
Normal 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,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
197
client/web/compose/src/components/Common/Grid.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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: [],
|
||||
}
|
||||
265
client/web/compose/src/components/Common/RecordListFilter.vue
Normal 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>
|
||||
320
client/web/compose/src/components/Common/RecordToolbar.vue
Normal 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>
|
||||
59
client/web/compose/src/components/Drafts/CDraftButton.vue
Normal 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>
|
||||
142
client/web/compose/src/components/Drafts/CDraftSidebar.vue
Normal 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>
|
||||
297
client/web/compose/src/components/Drafts/DraftItem.vue
Normal 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>
|
||||
276
client/web/compose/src/components/Drafts/Drafts.vue
Normal 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>
|
||||
2
client/web/compose/src/components/ModuleFields/C3.js
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as Pagination } from './Common/Pagination.c3'
|
||||
// export { default as Configurator } from './Configurator/Configurator.c3'
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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: [],
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||