Add files from webapp-compose with client/web/compose prefix
5
client/web/compose/.browserslistrc
Normal file
@@ -0,0 +1,5 @@
|
||||
Chrome >= 60
|
||||
Safari >= 10.1
|
||||
iOS >= 10.3
|
||||
Firefox >= 54
|
||||
Edge >= 15
|
||||
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
|
||||
23
client/web/compose/.eslintrc.js
Normal file
@@ -0,0 +1,23 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: {
|
||||
es6: true,
|
||||
node: true,
|
||||
mocha: true,
|
||||
},
|
||||
extends: [
|
||||
'plugin:vue/recommended',
|
||||
'@vue/standard',
|
||||
],
|
||||
rules: {
|
||||
'no-console': process.env.NODE_ENV === 'production' ? 'error' : 'off',
|
||||
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off',
|
||||
'vue/order-in-components': ['error'],
|
||||
'comma-dangle': ['error', 'always-multiline'],
|
||||
// https://github.com/vuejs/eslint-plugin-vue/blob/master/docs/rules/order-in-components.md
|
||||
'vue/no-v-html': 'off',
|
||||
},
|
||||
parserOptions: {
|
||||
parser: 'babel-eslint',
|
||||
},
|
||||
}
|
||||
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
|
||||
37
client/web/compose/Dockerfile
Normal file
@@ -0,0 +1,37 @@
|
||||
# build-stage
|
||||
FROM node:12.14-alpine as build-stage
|
||||
|
||||
ENV PATH /app/node_modules/.bin:$PATH
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk update && apk add --no-cache git
|
||||
|
||||
COPY package.json ./
|
||||
COPY yarn.lock ./
|
||||
|
||||
RUN yarn install
|
||||
|
||||
COPY . ./
|
||||
|
||||
RUN yarn build
|
||||
|
||||
|
||||
# deploy stage
|
||||
FROM nginx:stable-alpine
|
||||
|
||||
WORKDIR /usr/share/nginx/html
|
||||
|
||||
COPY --from=build-stage /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/nginx.conf
|
||||
COPY CONTRIBUTING.* DCO LICENSE README.* ./
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
HEALTHCHECK --interval=30s --start-period=10s --timeout=30s \
|
||||
CMD wget --quiet --tries=1 --spider "http://127.0.0.1:80/config.js" || exit 1
|
||||
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
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 $@
|
||||
13
client/web/compose/babel.config.js
Normal file
@@ -0,0 +1,13 @@
|
||||
module.exports = {
|
||||
presets: [
|
||||
['@vue/app', { useBuiltIns: 'entry' }],
|
||||
],
|
||||
|
||||
env: {
|
||||
test: {
|
||||
plugins: [
|
||||
['istanbul', { useInlineSourceMaps: false }],
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
42
client/web/compose/entrypoint.sh
Normal file
@@ -0,0 +1,42 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -eu
|
||||
|
||||
if [ ! -z "${1:-}" ]; then
|
||||
exec "$@"
|
||||
else
|
||||
# check if config.js is present (via volume)
|
||||
# or if it's missing
|
||||
if [ ! -f "./config.js" ]; then
|
||||
# config.js missing, generate it
|
||||
if [ ! -z "${CONFIGJS:-}" ]; then
|
||||
# use $CONFIGJS variable that might be passed to the container:
|
||||
# --env CONFIGJS="$(cat public/config.example.js)"
|
||||
echo "${CONFIGJS}" > ./config.js
|
||||
else
|
||||
# Try to guess where the API is located by using DOMAIN or VIRTUAL_HOST and prefix it with "api."
|
||||
API_HOST=${API_HOST:-"api.${VIRTUAL_HOST:-"${DOMAIN:-"local.cortezaproject.org"}"}"}
|
||||
API_BASEURL=${API_FULL_URL:-"//${API_HOST}"}
|
||||
|
||||
echo "window.CortezaAPI = '${API_BASEURL}'" > ./config.js
|
||||
fi
|
||||
fi
|
||||
|
||||
BASE_PATH=${BASE_PATH:-"/"}
|
||||
if [ $BASE_PATH != "/" ]; then
|
||||
BASE_PATH_LENGTH=${#BASE_PATH}
|
||||
BASE_PATH_LAST_CHAR=${BASE_PATH:BASE_PATH_LENGTH-1:1}
|
||||
|
||||
if [ $BASE_PATH_LAST_CHAR != "/" ]; then
|
||||
BASE_PATH="$BASE_PATH/"
|
||||
fi
|
||||
fi
|
||||
|
||||
sed -i "s|<base href=/ >|<base href=\"$BASE_PATH\">|g" ./index.html
|
||||
sed -i "s|<base href=\"/\">|<base href=\"$BASE_PATH\">|g" ./index.html
|
||||
|
||||
sed -i "s|{{BASE_PATH}}|$BASE_PATH|g" /etc/nginx/nginx.conf
|
||||
|
||||
|
||||
nginx -g "daemon off;"
|
||||
fi
|
||||
46
client/web/compose/nginx.conf
Normal file
@@ -0,0 +1,46 @@
|
||||
user nginx;
|
||||
worker_processes 1;
|
||||
|
||||
error_log /dev/stdout warn;
|
||||
|
||||
pid /var/run/nginx.pid;
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
log_format json escape=json
|
||||
'{'
|
||||
'"@timestamp":"$time_iso8601",'
|
||||
'"remote_addr":"$remote_addr",'
|
||||
'"request":"$request",'
|
||||
'"status":$status,'
|
||||
'"body_bytes_sent":$body_bytes_sent,'
|
||||
'"request_time":$request_time,'
|
||||
'"http_referrer":"$http_referer",'
|
||||
'"http_user_agent":"$http_user_agent"'
|
||||
'}';
|
||||
|
||||
access_log /dev/stdout json;
|
||||
|
||||
sendfile on;
|
||||
keepalive_timeout 300;
|
||||
|
||||
server {
|
||||
listen 80 default_server;
|
||||
listen [::]:80 default_server;
|
||||
server_name _;
|
||||
|
||||
index index.html;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
|
||||
location {{BASE_PATH}} {
|
||||
try_files $uri {{BASE_PATH}}index.html;
|
||||
}
|
||||
}
|
||||
}
|
||||
160
client/web/compose/package.json
Normal file
@@ -0,0 +1,160 @@
|
||||
{
|
||||
"name": "corteza-webapp-compose",
|
||||
"description": "Corteza Compose WebApp",
|
||||
"version": "2022.9.2",
|
||||
"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 && stylelint '**/*.{vue,scss}' --fix",
|
||||
"test:unit": "vue-cli-service test:unit",
|
||||
"test:unit:cc": "nyc vue-cli-service test:unit",
|
||||
"cdeps": "yarn upgrade @cortezaproject/corteza-js @cortezaproject/corteza-vue"
|
||||
},
|
||||
"gitHooks": {
|
||||
"pre-commit": "yarn lint"
|
||||
},
|
||||
"stylelint": {
|
||||
"plugins": [
|
||||
"stylelint-scss"
|
||||
],
|
||||
"extends": "stylelint-config-standard",
|
||||
"rules": {
|
||||
"color-hex-case": null,
|
||||
"color-hex-length": null,
|
||||
"no-empty-source": null,
|
||||
"selector-list-comma-newline-after": null
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@cortezaproject/corteza-js": "^2022.9.2",
|
||||
"@cortezaproject/corteza-vue": "^2022.9.2",
|
||||
"@fortawesome/fontawesome-svg-core": "^1.2.21",
|
||||
"@fortawesome/free-regular-svg-icons": "^5.10.1",
|
||||
"@fortawesome/free-solid-svg-icons": "^5.10.1",
|
||||
"@fortawesome/vue-fontawesome": "^0.1.9",
|
||||
"@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",
|
||||
"@popperjs/core": "^2.4.0",
|
||||
"axios": "^0.21.2",
|
||||
"bootstrap-vue": "^2.21.2",
|
||||
"c3": "^0.7.12",
|
||||
"compact-timezone-list": "^1.0.6",
|
||||
"echarts": "^5.3.3",
|
||||
"ejs": "^3.1.7",
|
||||
"file-saver": "^2.0.2",
|
||||
"flush-promises": "^1.0.2",
|
||||
"fstream": "^1.0.12",
|
||||
"hex-rgb": "^4.1.0",
|
||||
"highlight.js": "^10.4.1",
|
||||
"html-parse-stringify": "^2.0.3",
|
||||
"i": "^0.3.7",
|
||||
"jquery": "^3.5.0",
|
||||
"js-yaml": "^3.13.1",
|
||||
"json-bigint": "^1.0.0",
|
||||
"leaflet": "^1.7.1",
|
||||
"lodash": "^4.17.19",
|
||||
"markdown-it": "^12.3.2",
|
||||
"mixin-deep": "^1.3.2",
|
||||
"moment": "2.29.2",
|
||||
"numeral": "^2.0.6",
|
||||
"portal-vue": "^2.1.7",
|
||||
"postcss-rtl": "^1.7.3",
|
||||
"prosemirror-model": "^1.11.2",
|
||||
"resolve-url-loader": "^3.1.0",
|
||||
"set-value": "^4.0.1",
|
||||
"v-tooltip": "^2.0.2",
|
||||
"vue": "2.6.14",
|
||||
"vue-echarts": "^6.2.3",
|
||||
"vue-grid-layout": "^2.3.3",
|
||||
"vue-native-websocket": "^2.0.14",
|
||||
"vue-router": "3.1.5",
|
||||
"vue-select": "^3.1.0",
|
||||
"vue-sortable-tree": "^1.2.2",
|
||||
"vue-tweet-embed": "^2.3.0",
|
||||
"vue2-brace-editor": "^2.0.2",
|
||||
"vue2-dropzone": "^3.6.0",
|
||||
"vue2-leaflet": "^2.7.1",
|
||||
"vuedraggable": "^2.23.0",
|
||||
"vuex": "^3.1.1",
|
||||
"vuex-router-sync": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.0.0",
|
||||
"@emotion/core": "^10.0.15",
|
||||
"@types/lodash": "^4.14.136",
|
||||
"@vue/cli-plugin-babel": "^3.10.0",
|
||||
"@vue/cli-plugin-eslint": "^4.1.2",
|
||||
"@vue/cli-plugin-unit-mocha": "^3.10.0",
|
||||
"@vue/cli-service": "^3.10.0",
|
||||
"@vue/composition-api": "^1.7.0",
|
||||
"@vue/eslint-config-prettier": "^6.0.0",
|
||||
"@vue/eslint-config-standard": "^5.1.0",
|
||||
"@vue/test-utils": "^1.0.0-beta.20",
|
||||
"babel-eslint": "^10.0.3",
|
||||
"babel-loader": "^7.0.0",
|
||||
"babel-plugin-istanbul": "^5.2.0",
|
||||
"babel-preset-vue": "^2.0.2",
|
||||
"chai": "^4.1.2",
|
||||
"chai-as-promised": "^7.1.1",
|
||||
"css-loader": "*",
|
||||
"eslint": "^6.8.0",
|
||||
"eslint-plugin-import": "^2.20.0",
|
||||
"eslint-plugin-node": "^11.0.0",
|
||||
"eslint-plugin-prettier": "^3.1.0",
|
||||
"eslint-plugin-promise": "^4.2.1",
|
||||
"eslint-plugin-standard": "^4.0.1",
|
||||
"eslint-plugin-vue": "^6.1.2",
|
||||
"null-loader": "^4.0.1",
|
||||
"nyc": "^14.1.1",
|
||||
"sass": "^1.49.9",
|
||||
"sass-loader": "^10",
|
||||
"sinon": "^7.3.2",
|
||||
"stylelint": "^8.0.0",
|
||||
"stylelint-config-standard": "^18.3.0",
|
||||
"stylelint-scss": "^3.14.2",
|
||||
"stylelint-webpack-plugin": "^0.10.5",
|
||||
"vue-loader": "^15.8.3",
|
||||
"vue-template-compiler": "2.6.14",
|
||||
"webpack": "^4.41.5"
|
||||
},
|
||||
"resolutions": {
|
||||
"**/**/highlight.js": "^10.4.1",
|
||||
"**/**/moment": "2.29.2",
|
||||
"**/**/prosemirror-model": "^1.11.2",
|
||||
"**/**/chart.js": "2.9.3"
|
||||
},
|
||||
"nyc": {
|
||||
"all": true,
|
||||
"reporter": [
|
||||
"lcov",
|
||||
"text"
|
||||
],
|
||||
"include": [
|
||||
"src/**/*.{js,vue}"
|
||||
],
|
||||
"exclude": [
|
||||
"**/index.js",
|
||||
"**/*.spec.js"
|
||||
],
|
||||
"extension": [
|
||||
".js",
|
||||
".vue"
|
||||
],
|
||||
"check-coverage": true,
|
||||
"per-file": true,
|
||||
"branches": 0,
|
||||
"lines": 0,
|
||||
"functions": 0,
|
||||
"statements": 0
|
||||
}
|
||||
}
|
||||
6
client/web/compose/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
autoprefixer: {},
|
||||
'postcss-rtl': {},
|
||||
},
|
||||
}
|
||||
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
|
||||
BIN
client/web/compose/public/favicon32x32x.png
Normal file
|
After Width: | Height: | Size: 789 B |
31
client/web/compose/public/index.html
Normal file
@@ -0,0 +1,31 @@
|
||||
<!DOCTYPE html>
|
||||
<html 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">
|
||||
<link rel="preload" href="<%= BASE_URL %>config.js" as="script" />
|
||||
<title><%= FLAVOUR %></title>
|
||||
</head>
|
||||
<body>
|
||||
<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>
|
||||
</body>
|
||||
</html>
|
||||
183
client/web/compose/src/app.js
Normal file
@@ -0,0 +1,183 @@
|
||||
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: '<div><router-view v-if="loaded && i18nLoaded" /></div>',
|
||||
|
||||
mixins: [
|
||||
mixins.corredor,
|
||||
],
|
||||
|
||||
data: () => ({
|
||||
loaded: false,
|
||||
i18nLoaded: false,
|
||||
}),
|
||||
|
||||
async created () {
|
||||
this.$i18n.i18next.on('loaded', () => {
|
||||
this.i18nLoaded = true
|
||||
})
|
||||
|
||||
this.websocket()
|
||||
|
||||
return this.$auth.vue(this).handle().then(({ user }) => {
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 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')
|
||||
|
||||
// Initializes reminders subsystems, do prefetch of all pending reminders
|
||||
this.$Reminder.init(this, { filter: { assignedTo: user.userID } })
|
||||
|
||||
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.$Settings.init({ api: this.$SystemAPI }).finally(() => {
|
||||
this.loaded = true
|
||||
|
||||
// This bit removes code from the query params
|
||||
//
|
||||
// Vue router can't be used here because when on any child route there is no
|
||||
// guarantee that the route has loaded and so it may redirect us to the root page.
|
||||
//
|
||||
// @todo dig a bit deeper if there is a better vue-like solution; atm none were ok.
|
||||
const url = new URL(window.location.href)
|
||||
if (url.searchParams.get('code')) {
|
||||
url.searchParams.delete('code')
|
||||
window.location.replace(url.toString())
|
||||
}
|
||||
})
|
||||
}).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 'error':
|
||||
console.error('websocket message with error', msg['@value'])
|
||||
}
|
||||
})
|
||||
},
|
||||
},
|
||||
|
||||
router,
|
||||
store,
|
||||
i18n: i18n(Vue,
|
||||
{ app: 'corteza-webapp-compose' },
|
||||
'block',
|
||||
'chart',
|
||||
'field',
|
||||
'general',
|
||||
'module',
|
||||
'namespace',
|
||||
'navigation',
|
||||
'notification',
|
||||
'onboarding',
|
||||
'page',
|
||||
'permissions',
|
||||
'preview',
|
||||
'sidebar',
|
||||
'resource-translator',
|
||||
),
|
||||
|
||||
// Any additional options we want to merge
|
||||
...options,
|
||||
}
|
||||
|
||||
return new Vue(options)
|
||||
}
|
||||
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: 25 KiB |
BIN
client/web/compose/src/assets/PageBlocks/Comment.png
Normal file
|
After Width: | Height: | Size: 2.3 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: 112 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: 15 KiB |
BIN
client/web/compose/src/assets/PageBlocks/Progress.png
Normal file
|
After Width: | Height: | Size: 1.6 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/RecordRevisions.png
Normal file
|
After Width: | Height: | Size: 165 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: 58 KiB |
15
client/web/compose/src/assets/PageBlocks/index.js
Normal file
@@ -0,0 +1,15 @@
|
||||
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')
|
||||
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'),
|
||||
],
|
||||
}
|
||||
161
client/web/compose/src/components/Admin/EditorToolbar.vue
Normal file
@@ -0,0 +1,161 @@
|
||||
<template>
|
||||
<b-container
|
||||
fluid
|
||||
data-test-id="editor-toolbar"
|
||||
class="bg-white shadow border-top p-3"
|
||||
>
|
||||
<b-row
|
||||
no-gutters
|
||||
class="wrap-with-vertical-gutters align-items-center"
|
||||
>
|
||||
<div
|
||||
class="wrap-with-vertical-gutters align-items-center"
|
||||
>
|
||||
<b-button
|
||||
v-if="backLink"
|
||||
data-test-id="button-back-without-save"
|
||||
variant="link"
|
||||
:to="backLink"
|
||||
:disabled="processing"
|
||||
class="text-dark back mr-auto"
|
||||
>
|
||||
<font-awesome-icon
|
||||
:icon="['fas', 'chevron-left']"
|
||||
class="back-icon"
|
||||
/>
|
||||
{{ $t('label.backWithoutSave') }}
|
||||
</b-button>
|
||||
</div>
|
||||
<div
|
||||
class="ml-auto"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
<div
|
||||
class="d-flex wrap-with-vertical-gutters align-items-center ml-auto"
|
||||
>
|
||||
<slot name="delete" />
|
||||
<c-input-confirm
|
||||
v-if="!hideDelete"
|
||||
v-b-tooltip.hover
|
||||
:disabled="disableDelete || processing"
|
||||
size="lg"
|
||||
size-confirm="lg"
|
||||
variant="danger"
|
||||
:title="deleteTooltip"
|
||||
:borderless="false"
|
||||
@confirmed="$emit('delete')"
|
||||
>
|
||||
{{ $t('label.delete') }}
|
||||
</c-input-confirm>
|
||||
<b-button
|
||||
v-if="!hideClone"
|
||||
data-test-id="button-clone"
|
||||
:disabled="disableClone || processing"
|
||||
variant="light"
|
||||
size="lg"
|
||||
class="ml-2"
|
||||
@click="$emit('clone')"
|
||||
>
|
||||
{{ $t('label.clone') }}
|
||||
</b-button>
|
||||
<b-button
|
||||
v-if="!hideSave"
|
||||
data-test-id="button-save-and-close"
|
||||
:disabled="disableSave || processing"
|
||||
variant="light"
|
||||
size="lg"
|
||||
class="ml-2"
|
||||
@click.prevent="$emit('saveAndClose')"
|
||||
>
|
||||
{{ $t('label.saveAndClose') }}
|
||||
</b-button>
|
||||
<b-button
|
||||
v-if="!hideSave"
|
||||
data-test-id="button-save"
|
||||
:disabled="disableSave || processing"
|
||||
variant="primary"
|
||||
size="lg"
|
||||
class="ml-2"
|
||||
@click.prevent="$emit('save')"
|
||||
>
|
||||
{{ $t('label.save') }}
|
||||
</b-button>
|
||||
</div>
|
||||
</b-row>
|
||||
</b-container>
|
||||
</template>
|
||||
<script>
|
||||
|
||||
export default {
|
||||
i18nOptions: {
|
||||
namespaces: 'general',
|
||||
},
|
||||
|
||||
inheritAttrs: true,
|
||||
|
||||
props: {
|
||||
processing: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
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,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
deleteTooltip: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.back {
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
|
||||
.back-icon {
|
||||
transition: transform 0.3s ease-out;
|
||||
transform: translateX(-4px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[dir="rtl"] {
|
||||
.back {
|
||||
.back-icon {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
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>
|
||||
225
client/web/compose/src/components/Admin/Import.vue
Normal file
@@ -0,0 +1,225 @@
|
||||
<template>
|
||||
<div>
|
||||
<b-btn
|
||||
variant="light"
|
||||
size="lg"
|
||||
class="float-left"
|
||||
@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')"
|
||||
class="font-wight-normal pointer"
|
||||
@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"
|
||||
sm="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"
|
||||
size="lg"
|
||||
@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.toastSuccess(this.$t('notification:general.import.successful'))
|
||||
} 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
|
||||
var 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>
|
||||
<style lang="scss">
|
||||
$input-height: 42px;
|
||||
$line-height: 30px;
|
||||
|
||||
.custom-file-input {
|
||||
height: $input-height;
|
||||
}
|
||||
|
||||
.custom-file-label {
|
||||
height: $input-height;
|
||||
font-family: $font-regular;
|
||||
|
||||
&::after {
|
||||
height: 100%;
|
||||
font-family: $btn-font-family;
|
||||
line-height: $line-height;
|
||||
background-color: $light;
|
||||
color: $dark;
|
||||
font-weight: 400;
|
||||
padding: $btn-padding-y $btn-padding-x;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,174 @@
|
||||
<template>
|
||||
<b-row
|
||||
cols="12"
|
||||
class="mx-1 mb-2"
|
||||
>
|
||||
<b-col
|
||||
cols="3"
|
||||
align-self="center"
|
||||
>
|
||||
<b-form-checkbox
|
||||
v-if="allowOmitStrategy"
|
||||
v-model="use"
|
||||
>
|
||||
{{ label }}
|
||||
</b-form-checkbox>
|
||||
<div
|
||||
v-else
|
||||
class="font-weight-bold"
|
||||
>
|
||||
{{ label }}
|
||||
</div>
|
||||
</b-col>
|
||||
|
||||
<b-col
|
||||
cols="3"
|
||||
>
|
||||
<b-form-select
|
||||
v-show="strategy !== 'omit'"
|
||||
v-model="strategy"
|
||||
:options="strategies"
|
||||
:disabled="!use"
|
||||
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,
|
||||
},
|
||||
|
||||
// default store-ident
|
||||
storeIdent: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
|
||||
defaultStrategy: {
|
||||
type: String,
|
||||
default: types.Plain,
|
||||
},
|
||||
|
||||
allowOmitStrategy: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
|
||||
data () {
|
||||
return {
|
||||
// holds working copy of strategy config
|
||||
draft: defaultConfigDraft(this.config, this.storeIdent),
|
||||
|
||||
// strategy before omit
|
||||
undoOmit: this.defaultStrategy,
|
||||
|
||||
// list of available strategies
|
||||
strategies: [
|
||||
{ value: types.Plain, text: this.$t('strategies.plain.label') },
|
||||
{ value: types.Alias, text: this.$t('strategies.alias.label') },
|
||||
{ value: types.JSON, text: this.$t('strategies.json.label') },
|
||||
],
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
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>
|
||||
223
client/web/compose/src/components/Admin/Module/DalSettings.vue
Normal file
@@ -0,0 +1,223 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="module"
|
||||
>
|
||||
<b-form-group
|
||||
:label="$t('connection.label')"
|
||||
:description="$t('connection.description')"
|
||||
label-class="text-primary"
|
||||
>
|
||||
<vue-select
|
||||
v-model="module.config.dal.connectionID"
|
||||
:options="connections"
|
||||
:disabled="processing"
|
||||
:clearable="false"
|
||||
:reduce="s => s.connectionID"
|
||||
:placeholder="$t('connection.placeholder')"
|
||||
:get-option-label="getConnectionLabel"
|
||||
class="bg-white"
|
||||
/>
|
||||
</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')"
|
||||
>
|
||||
<dal-field-store-encoding
|
||||
v-for="({ field, storeIdent, label }) in moduleFields"
|
||||
:key="field"
|
||||
:config="moduleFieldEncoding[field] || {}"
|
||||
:field="field"
|
||||
:label="label"
|
||||
: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')"
|
||||
>
|
||||
<dal-field-store-encoding
|
||||
v-for="({ field, storeIdent, label }) in systemFields"
|
||||
:key="field"
|
||||
:config="systemFieldEncoding[field] || {}"
|
||||
:field="field"
|
||||
:label="label"
|
||||
:store-ident="storeIdent"
|
||||
:allow-omit-strategy="true"
|
||||
@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 VueSelect from 'vue-select'
|
||||
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: {
|
||||
VueSelect,
|
||||
DalFieldStoreEncoding,
|
||||
},
|
||||
|
||||
props: {
|
||||
module: {
|
||||
type: compose.Module,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
data () {
|
||||
const systemFieldEncoding = this.module.config.dal.systemFieldEncoding || {}
|
||||
const systemFields = [
|
||||
{ field: 'id', storeIdent: 'id' },
|
||||
{ field: 'namespaceID', storeIdent: 'rel_namespace' },
|
||||
{ field: 'moduleID', storeIdent: 'rel_module' },
|
||||
{ field: 'revision', storeIdent: 'revision' },
|
||||
{ field: 'meta', storeIdent: 'meta' },
|
||||
{ field: 'ownedBy', storeIdent: 'owned_by' },
|
||||
{ field: 'createdAt', storeIdent: 'created_at' },
|
||||
{ field: 'createdBy', storeIdent: 'created_by' },
|
||||
{ field: 'updatedAt', storeIdent: 'updated_at' },
|
||||
{ field: 'updatedBy', storeIdent: 'updated_by' },
|
||||
{ field: 'deletedAt', storeIdent: 'deleted_at' },
|
||||
{ field: 'deletedBy', storeIdent: 'deleted_by' },
|
||||
].map(sf => ({ ...sf, label: this.$t(`field:system.${sf.field}`) }))
|
||||
|
||||
return {
|
||||
processing: false,
|
||||
connections: [],
|
||||
|
||||
moduleFields: [],
|
||||
moduleFieldEncoding: [],
|
||||
|
||||
systemFields,
|
||||
systemFieldEncoding: systemFields.reduce((enc, { field }) => {
|
||||
enc[field] = systemFieldEncoding[field] || {}
|
||||
return enc
|
||||
}, {}),
|
||||
}
|
||||
},
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
// 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()
|
||||
},
|
||||
|
||||
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
|
||||
}, {})
|
||||
},
|
||||
},
|
||||
}
|
||||
</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,193 @@
|
||||
<template>
|
||||
<b-modal
|
||||
v-model="showModal"
|
||||
:title="discoveryModalTitle"
|
||||
: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="onSave()"
|
||||
>
|
||||
<b-tabs
|
||||
v-if="modal"
|
||||
v-model="currentTabIndex"
|
||||
active-nav-item-class="bg-grey"
|
||||
nav-wrapper-class="bg-white border-bottom"
|
||||
card
|
||||
>
|
||||
<b-tab
|
||||
v-for="scope in scopeOptions"
|
||||
:key="scope.value"
|
||||
:title="scope.title"
|
||||
class="mh-tab"
|
||||
>
|
||||
<field-picker
|
||||
:module="module"
|
||||
:fields.sync="currentFields"
|
||||
disable-system-fields
|
||||
style="max-height: 70vh;"
|
||||
/>
|
||||
</b-tab>
|
||||
</b-tabs>
|
||||
</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 () {
|
||||
if (this[this.currentScope] && this[this.currentScope].result[this.currentLanguageIndex]) {
|
||||
return this[this.currentScope].result[this.currentLanguageIndex].fields
|
||||
}
|
||||
return []
|
||||
},
|
||||
|
||||
set (currentFields) {
|
||||
if (this.currentScope && this[this.currentScope].result[this.currentLanguageIndex]) {
|
||||
this[this.currentScope].result[this.currentLanguageIndex].fields = [...currentFields]
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
discoveryModalTitle () {
|
||||
const { handle } = this.module
|
||||
return handle ? `${this.$t('edit.discoverySettings.title')} (${handle})` : this.$t('edit.discoverySettings.title')
|
||||
},
|
||||
|
||||
currentScope () {
|
||||
return this.currentTabIndex >= 0 ? (this.scopeOptions[this.currentTabIndex] || {}).value : undefined
|
||||
},
|
||||
|
||||
currentLanguageIndex () {
|
||||
return this.currentScope ? this[this.currentScope].result.findIndex(({ lang }) => lang === this.currentLang) : -1
|
||||
},
|
||||
|
||||
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') },
|
||||
]
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
modal: {
|
||||
immediate: true,
|
||||
handler (modal) {
|
||||
if (modal && this.module) {
|
||||
this.currentLang = this.defaultTranslationLanguage
|
||||
|
||||
this.scopeOptions.forEach(({ value }) => {
|
||||
this[value] = {
|
||||
result: [],
|
||||
}
|
||||
|
||||
this.languages.forEach(({ tag: lang }) => {
|
||||
let existingFields = new Set()
|
||||
|
||||
if (this.module.meta.discovery && this.module.meta.discovery[value]) {
|
||||
const indexOfLanguage = this.module.meta.discovery[value].result.findIndex(r => r.lang === lang)
|
||||
if (indexOfLanguage >= 0) {
|
||||
existingFields = new Set(this.module.meta.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 })
|
||||
})
|
||||
})
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
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,
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mh-tab {
|
||||
max-height: calc(100vh - 16rem);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,734 @@
|
||||
<template>
|
||||
<b-modal
|
||||
v-model="showModal"
|
||||
:title="federationModalTitle"
|
||||
:ok-title="$t('general.label.saveAndClose')"
|
||||
ok-only
|
||||
ok-variant="dark"
|
||||
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-nav-item-class="bg-grey"
|
||||
nav-wrapper-class="bg-white border-bottom"
|
||||
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 text-truncate"
|
||||
@click="upstream.active = f.nodeID"
|
||||
>
|
||||
{{ f.name }}<br>
|
||||
<small>{{ 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 text-truncate"
|
||||
@click="downstream.active = f.nodeID"
|
||||
>
|
||||
{{ f.name }}<br>
|
||||
<small>{{ 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()
|
||||
},
|
||||
|
||||
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.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.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
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.tab-content {
|
||||
min-height: 0;
|
||||
max-height: 70vh;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.server-list {
|
||||
max-width: 35%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,136 @@
|
||||
<template>
|
||||
<c-translator-button
|
||||
v-if="canManageResourceTranslations && resourceTranslationsEnabled"
|
||||
button-variant="light"
|
||||
v-bind="$props"
|
||||
:size="size"
|
||||
:title="$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) {
|
||||
this.field.options.trueLabel = tr.message
|
||||
}
|
||||
|
||||
tr = find('meta.bool.false.label')
|
||||
if (tr !== undefined) {
|
||||
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
195
client/web/compose/src/components/Admin/Module/FieldRowEdit.vue
Normal file
@@ -0,0 +1,195 @@
|
||||
<template>
|
||||
<tr>
|
||||
<td
|
||||
v-b-tooltip.hover
|
||||
class="handle align-middle"
|
||||
>
|
||||
<font-awesome-icon
|
||||
:icon="['fas', 'bars']"
|
||||
class="text-light grab"
|
||||
/>
|
||||
</td>
|
||||
<td
|
||||
style="width: 25%;"
|
||||
>
|
||||
<b-form-input
|
||||
v-model="value.name"
|
||||
required
|
||||
:readonly="disabled"
|
||||
:state="nameState"
|
||||
type="text"
|
||||
class="form-control"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<b-input-group>
|
||||
<b-form-input
|
||||
v-model="value.label"
|
||||
type="text"
|
||||
class="form-control"
|
||||
/>
|
||||
<b-input-group-append>
|
||||
<field-translator
|
||||
:field.sync="value"
|
||||
:module="module"
|
||||
:disabled="isNew"
|
||||
highlight-key="label"
|
||||
button-variant="light"
|
||||
/>
|
||||
</b-input-group-append>
|
||||
</b-input-group>
|
||||
</td>
|
||||
<td>
|
||||
<b-input-group class="field-type">
|
||||
<b-select
|
||||
v-model="value.kind"
|
||||
:disabled="disabled"
|
||||
>
|
||||
<option
|
||||
v-for="({ kind, label }) in fieldKinds"
|
||||
:key="kind"
|
||||
:value="kind"
|
||||
>
|
||||
{{ label }}
|
||||
</option>
|
||||
</b-select>
|
||||
<b-input-group-append>
|
||||
<b-button
|
||||
variant="light"
|
||||
:title="$t('tooltip.field')"
|
||||
:disabled="!value.cap.configurable"
|
||||
class="px-2"
|
||||
@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="text-right align-middle pr-2"
|
||||
style="min-width: 100px;"
|
||||
>
|
||||
<c-input-confirm
|
||||
:no-prompt="!value.name"
|
||||
class="mr-2"
|
||||
@confirmed="$emit('delete')"
|
||||
/>
|
||||
<c-permissions-button
|
||||
v-if="canGrant && exists"
|
||||
class="text-dark px-0"
|
||||
button-variant="link"
|
||||
:title="value.name"
|
||||
:target="value.name"
|
||||
:tooltip="$t('permissions:resources.compose.module-field.tooltip')"
|
||||
:resource="`corteza::compose:module-field/${module.namespaceID}/${module.moduleID}/${value.fieldID}`"
|
||||
/>
|
||||
</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.disabled) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (this.isDuplicate) {
|
||||
return false
|
||||
}
|
||||
|
||||
return this.value.isValid ? null : false
|
||||
},
|
||||
|
||||
disabled () {
|
||||
return this.value.fieldID !== NoID && 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.text))
|
||||
},
|
||||
|
||||
exists () {
|
||||
return this.module.ID !== NoID && this.value.fieldID !== NoID
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
td {
|
||||
input, .input-group {
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.handle {
|
||||
width: 30px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<tr>
|
||||
<td />
|
||||
<td class="pl-3">
|
||||
{{ field.name }}
|
||||
</td>
|
||||
<td class="pl-3">
|
||||
{{ field.label }}
|
||||
</td>
|
||||
<td class="pl-3">
|
||||
{{ field.kind }}
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
export default {
|
||||
props: {
|
||||
field: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,147 @@
|
||||
<template>
|
||||
<c-translator-button
|
||||
v-if="canManageResourceTranslations && resourceTranslationsEnabled"
|
||||
button-variant="light"
|
||||
v-bind="$props"
|
||||
:title="$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(() => 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
|
||||
moduleFieldSelectResTr(this.field, translations, this.currentLanguage, this.resource)
|
||||
})
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,130 @@
|
||||
<template>
|
||||
<c-translator-button
|
||||
v-if="canManageResourceTranslations && resourceTranslationsEnabled"
|
||||
v-bind="$props"
|
||||
button-variant="light"
|
||||
:title="$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,113 @@
|
||||
<template>
|
||||
<c-translator-button
|
||||
v-if="canManageResourceTranslations && resourceTranslationsEnabled"
|
||||
v-bind="$props"
|
||||
:title="$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: '',
|
||||
},
|
||||
|
||||
buttonVariant: {
|
||||
type: String,
|
||||
default: () => 'primary',
|
||||
},
|
||||
|
||||
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,85 @@
|
||||
<template>
|
||||
<div>
|
||||
<b-button
|
||||
v-b-modal.columns
|
||||
size="lg"
|
||||
variant="light"
|
||||
>
|
||||
{{ $t('allRecords.columns.title') }}
|
||||
</b-button>
|
||||
<b-modal
|
||||
id="columns"
|
||||
size="lg"
|
||||
scrollable
|
||||
:title="$t('allRecords.columns.title')"
|
||||
:ok-title="$t('general.label.saveAndClose')"
|
||||
body-class="p-0"
|
||||
@ok="onSave"
|
||||
>
|
||||
<b-card-body
|
||||
class="d-flex flex-column mh-100"
|
||||
>
|
||||
<p>
|
||||
{{ $t('allRecords.columns.description') }}
|
||||
</p>
|
||||
<field-picker
|
||||
:module="module"
|
||||
:fields.sync="filteredFields"
|
||||
style="max-height: 71vh;"
|
||||
/>
|
||||
</b-card-body>
|
||||
</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: () => [],
|
||||
},
|
||||
},
|
||||
|
||||
data () {
|
||||
return {
|
||||
filteredFields: [],
|
||||
}
|
||||
},
|
||||
|
||||
created () {
|
||||
this.filteredFields = this.fields.map(f => {
|
||||
return { ...f.moduleField }
|
||||
})
|
||||
},
|
||||
|
||||
methods: {
|
||||
onSave () {
|
||||
this.$emit('updateFields', this.filteredFields)
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.fit-modal {
|
||||
max-height: calc(100% - 3.5rem);
|
||||
}
|
||||
</style>
|
||||
184
client/web/compose/src/components/Admin/Module/RelatedPages.vue
Normal file
@@ -0,0 +1,184 @@
|
||||
<template>
|
||||
<div
|
||||
class="d-inline-block"
|
||||
>
|
||||
<b-dropdown
|
||||
v-if="recordPage"
|
||||
:size="size"
|
||||
variant="light"
|
||||
:text="$t('related-pages')"
|
||||
boundary="viewport"
|
||||
class="related-pages-dropdown"
|
||||
>
|
||||
<b-dropdown-item>
|
||||
<b-button
|
||||
data-test-id="dropdown-link-record-page-edit"
|
||||
:disabled="!namespace.canManageNamespace"
|
||||
:to="{ name: 'admin.pages.builder', params: { pageID: recordPage.pageID } }"
|
||||
variant="link"
|
||||
class="text-dark text-decoration-none"
|
||||
>
|
||||
{{ $t('recordPage.edit') }}
|
||||
</b-button>
|
||||
</b-dropdown-item>
|
||||
|
||||
<b-dropdown-item>
|
||||
<b-button
|
||||
v-if="recordListPage"
|
||||
data-test-id="dropdown-link-record-list-page-edit"
|
||||
:disabled="!namespace.canManageNamespace"
|
||||
:to="{ name: 'admin.pages.builder', params: { pageID: recordListPage.pageID } }"
|
||||
variant="link"
|
||||
class="text-dark text-decoration-none"
|
||||
>
|
||||
{{ $t('recordListPage.edit') }}
|
||||
</b-button>
|
||||
<b-button
|
||||
v-else
|
||||
data-test-id="dropdown-link-record-list-page-create"
|
||||
variant="link"
|
||||
href=""
|
||||
:disabled="processing"
|
||||
class="text-dark text-decoration-none"
|
||||
@click.stop.prevent="handleRecordListPageCreation"
|
||||
>
|
||||
{{ $t('recordListPage.create') }}
|
||||
</b-button>
|
||||
</b-dropdown-item>
|
||||
</b-dropdown>
|
||||
|
||||
<b-button
|
||||
v-else
|
||||
data-test-id="button-record-page-create"
|
||||
variant="primary"
|
||||
:size="size"
|
||||
:disabled="processing"
|
||||
@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',
|
||||
},
|
||||
},
|
||||
|
||||
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',
|
||||
}),
|
||||
|
||||
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, 12, 16] })]
|
||||
const selfID = (this.recordListPage || {}).pageID || NoID
|
||||
|
||||
const page = {
|
||||
namespaceID,
|
||||
moduleID,
|
||||
selfID,
|
||||
title: `${this.$t('forModule.recordPage')} "${name || moduleID}"`,
|
||||
blocks,
|
||||
}
|
||||
|
||||
this.createPage(page)
|
||||
.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, 12, 17],
|
||||
options: {
|
||||
moduleID,
|
||||
fields: [],
|
||||
perPage: 14,
|
||||
fullPageNavigation: false,
|
||||
showTotalCount: false,
|
||||
},
|
||||
})]
|
||||
|
||||
const page = {
|
||||
title: `${this.$t('forModule.recordList')} "${name || moduleID}"`,
|
||||
namespaceID,
|
||||
blocks,
|
||||
}
|
||||
|
||||
this.createPage(page)
|
||||
.then(({ pageID: selfID = NoID }) => {
|
||||
return this.updatePage({ ...this.recordPage, selfID })
|
||||
})
|
||||
.catch(this.toastErrorHandler(this.$t('notification:module.recordPage.createFailed')))
|
||||
.finally(() => {
|
||||
this.processing = false
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.related-pages-dropdown {
|
||||
.dropdown-item {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
</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="max-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="max-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,54 @@
|
||||
|
||||
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,146 @@
|
||||
<template>
|
||||
<b-container fluid>
|
||||
<b-row>
|
||||
<b-col
|
||||
cols="4"
|
||||
>
|
||||
<b-list-group>
|
||||
<b-list-group-item
|
||||
v-for="(type) in types"
|
||||
:key="type.label"
|
||||
:disabled="!recordPage && type.recordPageOnly"
|
||||
button
|
||||
@click="$emit('select', type.block)"
|
||||
@mouseover="current = type.image"
|
||||
>
|
||||
{{ type.label }}
|
||||
</b-list-group-item>
|
||||
</b-list-group>
|
||||
</b-col>
|
||||
<b-col
|
||||
cols="8"
|
||||
class="my-auto"
|
||||
>
|
||||
<b-img
|
||||
v-if="current"
|
||||
fluid
|
||||
thumbnail
|
||||
:src="current"
|
||||
/>
|
||||
</b-col>
|
||||
</b-row>
|
||||
<b-row
|
||||
class="border-top mt-2"
|
||||
>
|
||||
<b-col>
|
||||
<div
|
||||
class="mt-2"
|
||||
>
|
||||
{{ $t('selectBlockFootnote') }}
|
||||
</div>
|
||||
</b-col>
|
||||
</b-row>
|
||||
</b-container>
|
||||
</template>
|
||||
<script>
|
||||
import { compose } from '@cortezaproject/corteza-js'
|
||||
import * as images from '../../../../assets/PageBlocks'
|
||||
|
||||
export default {
|
||||
i18nOptions: {
|
||||
namespaces: 'block',
|
||||
},
|
||||
|
||||
props: {
|
||||
recordPage: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
|
||||
data () {
|
||||
return {
|
||||
current: 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('socialFeed.label'),
|
||||
block: new compose.PageBlockSocialFeed(),
|
||||
image: images.SocialFeed,
|
||||
},
|
||||
{
|
||||
label: this.$t('progress.label'),
|
||||
block: new compose.PageBlockProgress(),
|
||||
image: images.Progress,
|
||||
},
|
||||
],
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
223
client/web/compose/src/components/Admin/Page/PageTranslator.vue
Normal file
@@ -0,0 +1,223 @@
|
||||
<template>
|
||||
<c-translator-button
|
||||
v-if="canManageResourceTranslations && resourceTranslationsEnabled"
|
||||
v-bind="$props"
|
||||
:title="$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,
|
||||
},
|
||||
|
||||
block: {
|
||||
type: compose.PageBlock,
|
||||
required: false,
|
||||
default: undefined,
|
||||
},
|
||||
|
||||
buttonVariant: {
|
||||
type: String,
|
||||
default: () => 'primary',
|
||||
},
|
||||
|
||||
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 })
|
||||
}
|
||||
|
||||
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}.`))
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
let tr = find('title')
|
||||
if (tr !== undefined) {
|
||||
this.page.title = tr.message
|
||||
}
|
||||
|
||||
tr = find('description')
|
||||
if (tr !== undefined) {
|
||||
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 => {
|
||||
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
|
||||
}
|
||||
|
||||
if (this.block) {
|
||||
this.block = updateBlockTranslations(this.block)
|
||||
}
|
||||
|
||||
this.page.blocks = this.page.blocks.map(block => updateBlockTranslations(block))
|
||||
|
||||
return this.page
|
||||
})
|
||||
.then(page => {
|
||||
this.$emit('update:page', page)
|
||||
|
||||
if (this.block) {
|
||||
this.$emit('update:block', this.block)
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</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,
|
||||
// },
|
||||
// },
|
||||
// ],
|
||||
}
|
||||
253
client/web/compose/src/components/Admin/Page/Tree.vue
Normal file
@@ -0,0 +1,253 @@
|
||||
<template>
|
||||
<div>
|
||||
<sortable-tree
|
||||
v-if="list.length"
|
||||
:draggable="namespace.canCreatePage"
|
||||
:data="{children:list}"
|
||||
tag="ul"
|
||||
mixin-parent-key="parent"
|
||||
class="list-group pb-3"
|
||||
@changePosition="handleChangePosition"
|
||||
>
|
||||
<template
|
||||
slot-scope="{item}"
|
||||
>
|
||||
<b-row
|
||||
v-if="item.pageID"
|
||||
no-gutters
|
||||
class="wrap d-flex pr-2"
|
||||
>
|
||||
<b-col
|
||||
cols="12"
|
||||
xl="6"
|
||||
lg="5"
|
||||
class="flex-fill pl-2 overflow-hidden"
|
||||
:class="{'grab': namespace.canCreatePage}"
|
||||
>
|
||||
{{ item.title }}
|
||||
<span
|
||||
v-if="!item.visible && item.moduleID == '0'"
|
||||
class="text-danger"
|
||||
>
|
||||
<font-awesome-icon
|
||||
:icon="['fas', 'eye-slash']"
|
||||
:title="$t('notVisible')"
|
||||
/>
|
||||
</span>
|
||||
<b-badge
|
||||
v-if="!isValid(item)"
|
||||
variant="danger"
|
||||
>
|
||||
{{ $t('invalid') }}
|
||||
</b-badge>
|
||||
</b-col>
|
||||
<b-col
|
||||
cols="12"
|
||||
xl="6"
|
||||
lg="7"
|
||||
class="text-right pr-2"
|
||||
>
|
||||
<router-link
|
||||
v-if="item.canUpdatePage"
|
||||
data-test-id="button-page-builder"
|
||||
:to="{name: 'admin.pages.builder', params: { pageID: item.pageID }}"
|
||||
class="btn btn-light mr-2"
|
||||
>
|
||||
{{ $t('block.general.label.pageBuilder') }}
|
||||
</router-link>
|
||||
<span class="view d-inline-block">
|
||||
<router-link
|
||||
v-if="!hideViewPageButton(item)"
|
||||
data-test-id="button-page-view"
|
||||
:to="pageViewer(item)"
|
||||
class="btn"
|
||||
>
|
||||
{{ $t('view') }}
|
||||
</router-link>
|
||||
</span>
|
||||
<span
|
||||
class="d-none d-md-inline-block edit text-left"
|
||||
>
|
||||
<router-link
|
||||
v-if="item.moduleID !== '0'"
|
||||
v-b-tooltip.hover.top
|
||||
data-test-id="button-module-edit"
|
||||
:title="moduleName(item)"
|
||||
class="btn text-primary"
|
||||
:to="{ name: 'admin.modules.edit', params: { moduleID: item.moduleID }}"
|
||||
>
|
||||
{{ $t('moduleEdit') }}
|
||||
</router-link>
|
||||
<router-link
|
||||
v-if="item.canUpdatePage && item.moduleID === '0'"
|
||||
:to="{name: 'admin.pages.edit', params: { pageID: item.pageID }}"
|
||||
data-test-id="button-page-edit"
|
||||
class="btn text-primary"
|
||||
>
|
||||
{{ $t('edit.edit') }}
|
||||
</router-link>
|
||||
|
||||
</span>
|
||||
<c-permissions-button
|
||||
v-if="namespace.canGrant"
|
||||
:title="item.title"
|
||||
:target="item.title"
|
||||
:resource="`corteza::compose:page/${namespace.namespaceID}/${item.pageID}`"
|
||||
:tooltip="$t('permissions:resources.compose.page.tooltip')"
|
||||
link
|
||||
class="btn px-2"
|
||||
/>
|
||||
</b-col>
|
||||
</b-row>
|
||||
</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({
|
||||
updatePage: 'page/update',
|
||||
}),
|
||||
|
||||
moduleName ({ moduleID }) {
|
||||
if (moduleID === NoID) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return (this.getModuleByID(moduleID) || {}).name
|
||||
},
|
||||
|
||||
pageViewer ({ pageID = NoID }) {
|
||||
return { name: 'page', params: { pageID } }
|
||||
},
|
||||
|
||||
hideViewPageButton ({ blocks = {}, moduleID = NoID }) {
|
||||
return blocks && blocks.length >= 1 && moduleID !== NoID
|
||||
},
|
||||
|
||||
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: pageIDs }).then(() => {
|
||||
return this.$store.dispatch('page/load', { namespaceID, clear: true, force: true })
|
||||
}).then(() => {
|
||||
this.toastSuccess(this.$t('reordered'))
|
||||
this.$emit('reorder')
|
||||
})
|
||||
.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()
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 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
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
$edit-width: 130px;
|
||||
$view-width: 70px;
|
||||
|
||||
.edit {
|
||||
width: $edit-width;
|
||||
}
|
||||
|
||||
.view {
|
||||
width: $view-width;
|
||||
}
|
||||
|
||||
.grab {
|
||||
cursor: grab;
|
||||
z-index: 1;
|
||||
}
|
||||
</style>
|
||||
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="$props"
|
||||
class="ml-auto py-1 px-3"
|
||||
: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>
|
||||
@@ -0,0 +1,82 @@
|
||||
<template>
|
||||
<report-edit
|
||||
:report.sync="editReport"
|
||||
:modules="modules"
|
||||
:dimension-field-kind="['Select']"
|
||||
:supported-metrics="1"
|
||||
un-skippable
|
||||
>
|
||||
<template #dimension-options="{ index, dimension, field }">
|
||||
<c-item-picker
|
||||
v-if="showPicker(field)"
|
||||
: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="max-height: 45vh;"
|
||||
@update:value="setOptions(index, field, $event)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #metric-options="{ metric }">
|
||||
<b-form-group
|
||||
horizontal
|
||||
:label-cols="2"
|
||||
breakpoint="md"
|
||||
>
|
||||
<b-form-checkbox
|
||||
v-model="metric.cumulative"
|
||||
>
|
||||
{{ $t('edit.metric.cumulative') }}
|
||||
</b-form-checkbox>
|
||||
</b-form-group>
|
||||
</template>
|
||||
</report-edit>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import base from './base'
|
||||
import ReportEdit from './ReportEdit'
|
||||
import { components } from '@cortezaproject/corteza-vue'
|
||||
const { CItemPicker } = components
|
||||
|
||||
export default {
|
||||
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>
|
||||
116
client/web/compose/src/components/Chart/Report/GaugeChart.vue
Normal file
@@ -0,0 +1,116 @@
|
||||
<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-cols="2"
|
||||
>
|
||||
<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`"
|
||||
button-variant="light"
|
||||
/>
|
||||
</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>
|
||||
<b-button
|
||||
variant="link"
|
||||
class="border-0 text-danger"
|
||||
@click.prevent="dimension.meta.steps.splice(i, 1)"
|
||||
>
|
||||
<font-awesome-icon :icon="['far', 'trash-alt']" />
|
||||
</b-button>
|
||||
</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-form-group
|
||||
:label="$t('edit.metric.fx.label')"
|
||||
:description="$t('edit.metric.fx.description')"
|
||||
>
|
||||
<b-form-textarea
|
||||
v-model="metric.fx"
|
||||
placeholder="n"
|
||||
/>
|
||||
</b-form-group>
|
||||
|
||||
<b-form-checkbox
|
||||
v-model="metric.fixTooltips"
|
||||
:value="true"
|
||||
:unchecked-value="false"
|
||||
>
|
||||
{{ $t('edit.metric.fixTooltips') }}
|
||||
</b-form-checkbox>
|
||||
</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 {
|
||||
i18nOptions: {
|
||||
namespaces: 'chart',
|
||||
},
|
||||
|
||||
components: {
|
||||
ChartTranslator,
|
||||
ReportEdit,
|
||||
},
|
||||
|
||||
extends: base,
|
||||
|
||||
props: {
|
||||
chart: {
|
||||
type: compose.Chart,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
isNew () {
|
||||
return this.chart.chartID === NoID
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
251
client/web/compose/src/components/Chart/Report/GenericChart.vue
Normal file
@@ -0,0 +1,251 @@
|
||||
<template>
|
||||
<report-edit
|
||||
:report.sync="editReport"
|
||||
:modules="modules"
|
||||
>
|
||||
<template #y-axis="{ report }">
|
||||
<div>
|
||||
<h4 class="mb-3">
|
||||
{{ $t('edit.yAxis.label') }}
|
||||
</h4>
|
||||
<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"
|
||||
:value="true"
|
||||
:unchecked-value="false"
|
||||
checked
|
||||
>
|
||||
{{ $t('edit.yAxis.axisScaleFromZero') }}
|
||||
</b-form-checkbox>
|
||||
|
||||
<b-form-group
|
||||
horizontal
|
||||
:label-cols="2"
|
||||
class="mt-2"
|
||||
breakpoint="md"
|
||||
:label="$t('edit.yAxis.labelLabel')"
|
||||
>
|
||||
<b-input-group>
|
||||
<b-form-input
|
||||
v-model="report.yAxis.label"
|
||||
:placeholder="$t('edit.yAxis.labelPlaceholder')"
|
||||
/>
|
||||
<b-input-group-append>
|
||||
<chart-translator
|
||||
:field.sync="report.yAxis.label"
|
||||
:chart="chart"
|
||||
:disabled="isNew"
|
||||
highlight-key="yAxis.label"
|
||||
button-variant="light"
|
||||
/>
|
||||
</b-input-group-append>
|
||||
</b-input-group>
|
||||
</b-form-group>
|
||||
|
||||
<b-form-group
|
||||
horizontal
|
||||
:label-cols="2"
|
||||
class="mt-1"
|
||||
breakpoint="md"
|
||||
:label="$t('edit.yAxis.minLabel')"
|
||||
>
|
||||
<b-form-input
|
||||
v-model="report.yAxis.min"
|
||||
:placeholder="$t('edit.yAxis.minPlaceholder')"
|
||||
/>
|
||||
</b-form-group>
|
||||
<b-form-group
|
||||
horizontal
|
||||
:label-cols="2"
|
||||
class="mt-1"
|
||||
breakpoint="md"
|
||||
:label="$t('edit.yAxis.maxLabel')"
|
||||
>
|
||||
<b-form-input
|
||||
v-model="report.yAxis.max"
|
||||
:placeholder="$t('edit.yAxis.maxPlaceholder')"
|
||||
/>
|
||||
</b-form-group>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #metric-options="{ metric }">
|
||||
<b-form-group
|
||||
horizontal
|
||||
:label-cols="2"
|
||||
class="mt-1"
|
||||
breakpoint="md"
|
||||
:label="$t('edit.metric.labelLabel')"
|
||||
>
|
||||
<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`"
|
||||
button-variant="light"
|
||||
/>
|
||||
</b-input-group-append>
|
||||
</b-input-group>
|
||||
</b-form-group>
|
||||
|
||||
<b-form-group
|
||||
horizontal
|
||||
:label-cols="2"
|
||||
breakpoint="md"
|
||||
:label="$t('edit.metric.fx.label')"
|
||||
:description="$t('edit.metric.fx.description')"
|
||||
>
|
||||
<b-form-textarea
|
||||
v-model="metric.fx"
|
||||
placeholder="n"
|
||||
/>
|
||||
</b-form-group>
|
||||
|
||||
<b-form-group
|
||||
horizontal
|
||||
:label-cols="2"
|
||||
breakpoint="md"
|
||||
:label="$t('edit.metric.output.label')"
|
||||
>
|
||||
<b-form-select
|
||||
v-model="metric.type"
|
||||
:options="chartTypes"
|
||||
>
|
||||
<template slot="first">
|
||||
<option
|
||||
:value="undefined"
|
||||
>
|
||||
{{ $t('edit.metric.output.placeholder') }}
|
||||
</option>
|
||||
</template>
|
||||
</b-form-select>
|
||||
</b-form-group>
|
||||
|
||||
<b-form-group
|
||||
horizontal
|
||||
:label-cols="2"
|
||||
breakpoint="md"
|
||||
label=""
|
||||
>
|
||||
<template v-if="hasRelativeDisplay(metric)">
|
||||
<b-form-checkbox
|
||||
v-model="metric.relativeValue"
|
||||
:value="true"
|
||||
:unchecked-value="false"
|
||||
>
|
||||
{{ $t('edit.metric.relative') }}
|
||||
</b-form-checkbox>
|
||||
</template>
|
||||
|
||||
<template v-if="metric.type === 'line'">
|
||||
<b-form-checkbox
|
||||
v-model="metric.fill"
|
||||
:value="true"
|
||||
:unchecked-value="false"
|
||||
>
|
||||
{{ $t('edit.metric.fillArea') }}
|
||||
</b-form-checkbox>
|
||||
</template>
|
||||
|
||||
<b-form-checkbox
|
||||
v-model="metric.fixTooltips"
|
||||
:value="true"
|
||||
:unchecked-value="false"
|
||||
>
|
||||
{{ $t('edit.metric.fixTooltips') }}
|
||||
</b-form-checkbox>
|
||||
</b-form-group>
|
||||
</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'
|
||||
|
||||
const ignoredCharts = [
|
||||
'funnel',
|
||||
'gauge',
|
||||
]
|
||||
|
||||
export default {
|
||||
i18nOptions: {
|
||||
namespaces: 'chart',
|
||||
},
|
||||
|
||||
components: {
|
||||
ReportEdit,
|
||||
ChartTranslator,
|
||||
},
|
||||
|
||||
extends: base,
|
||||
|
||||
props: {
|
||||
chart: {
|
||||
type: compose.Chart,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
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') },
|
||||
],
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
tensionSteps () {
|
||||
return [
|
||||
{ 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 },
|
||||
]
|
||||
},
|
||||
|
||||
isNew () {
|
||||
return this.chart.chartID === NoID
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
hasRelativeDisplay: compose.chartUtil.hasRelativeDisplay,
|
||||
},
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.color-picker {
|
||||
max-width: 50px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,89 @@
|
||||
// eslint-disable-next-line
|
||||
import { default as component } from './ReportEdit.vue'
|
||||
|
||||
const props = {
|
||||
report: {
|
||||
moduleID: '',
|
||||
filter: '',
|
||||
colorScheme: '#fffff',
|
||||
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: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
430
client/web/compose/src/components/Chart/Report/ReportEdit.vue
Normal file
@@ -0,0 +1,430 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Configure source module -->
|
||||
<b-form-group
|
||||
:label="$t('edit.module.label')"
|
||||
>
|
||||
<b-form-select
|
||||
v-model="moduleID"
|
||||
:options="modules"
|
||||
text-field="name"
|
||||
class="mt-1"
|
||||
value-field="moduleID"
|
||||
>
|
||||
<template slot="first">
|
||||
<option
|
||||
:value="undefined"
|
||||
>
|
||||
{{ $t('edit.module.placeholder') }}
|
||||
</option>
|
||||
</template>
|
||||
</b-form-select>
|
||||
</b-form-group>
|
||||
|
||||
<!-- Configure report filters -->
|
||||
<div
|
||||
v-if="!!module"
|
||||
class="mt-1"
|
||||
>
|
||||
<b-form-group
|
||||
:label="$t('edit.filter.label')"
|
||||
>
|
||||
<b-form-select
|
||||
v-model="report.filter"
|
||||
:disabled="customFilter"
|
||||
:options="predefinedFilters"
|
||||
>
|
||||
<template slot="first">
|
||||
<option value="">
|
||||
{{ $t('edit.filter.noFilter') }}
|
||||
</option>
|
||||
</template>
|
||||
</b-form-select>
|
||||
|
||||
<b-form-checkbox
|
||||
v-model="customFilter"
|
||||
class="mt-1"
|
||||
>
|
||||
{{ $t('edit.filter.customize') }}
|
||||
</b-form-checkbox>
|
||||
|
||||
<b-form-textarea
|
||||
v-if="customFilter"
|
||||
v-model="report.filter"
|
||||
placeholder="a = 1 AND b > 2"
|
||||
/>
|
||||
</b-form-group>
|
||||
</div>
|
||||
|
||||
<slot
|
||||
name="y-axis"
|
||||
:report="editReport"
|
||||
/>
|
||||
|
||||
<!-- Configure report dimensions -->
|
||||
<div v-if="!!module">
|
||||
<hr>
|
||||
|
||||
<fieldset
|
||||
v-for="(d, i) in dimensions"
|
||||
:key="i"
|
||||
>
|
||||
<h4 class="mb-3">
|
||||
{{ $t('edit.dimension.label') }}
|
||||
</h4>
|
||||
|
||||
<template v-if="usesDimensionsField">
|
||||
<b-form-group
|
||||
horizontal
|
||||
:label-cols="2"
|
||||
breakpoint="md"
|
||||
:label="$t('edit.dimension.fieldLabel')"
|
||||
>
|
||||
<b-form-select
|
||||
v-model="d.field"
|
||||
:options="dimensionFields"
|
||||
@change="onDimFieldChange($event, d)"
|
||||
>
|
||||
<template slot="first">
|
||||
<option
|
||||
:value="undefined"
|
||||
>
|
||||
{{ $t('edit.dimension.fieldPlaceholder') }}
|
||||
</option>
|
||||
</template>
|
||||
</b-form-select>
|
||||
</b-form-group>
|
||||
|
||||
<b-form-group
|
||||
horizontal
|
||||
:label-cols="2"
|
||||
breakpoint="md"
|
||||
:label="$t('edit.dimension.function.label')"
|
||||
>
|
||||
<b-form-select
|
||||
v-model="d.modifier"
|
||||
:disabled="!d.field || !isTemporalField(d.field)"
|
||||
:options="dimensionModifiers"
|
||||
>
|
||||
<template slot="first">
|
||||
<option
|
||||
:value="undefined"
|
||||
>
|
||||
{{ $t('edit.dimension.function.placeholder') }}
|
||||
</option>
|
||||
</template>
|
||||
</b-form-select>
|
||||
</b-form-group>
|
||||
|
||||
<template v-if="!unSkippable">
|
||||
<b-form-group
|
||||
horizontal
|
||||
:label-cols="2"
|
||||
breakpoint="md"
|
||||
>
|
||||
<b-form-checkbox
|
||||
v-model="d.skipMissing"
|
||||
>
|
||||
{{ $t('edit.dimension.skipMissingValues') }}
|
||||
</b-form-checkbox>
|
||||
</b-form-group>
|
||||
|
||||
<b-form-group
|
||||
v-if="!d.skipMissing"
|
||||
horizontal
|
||||
:label-cols="2"
|
||||
breakpoint="md"
|
||||
:label="$t('edit.dimension.defaultValueLabel')"
|
||||
:description="$t('edit.dimension.defaultValueFootnote')"
|
||||
>
|
||||
<b-form-input
|
||||
v-model="d.default"
|
||||
:type="defaultValueInputType(d)"
|
||||
/>
|
||||
</b-form-group>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<slot
|
||||
name="dimension-options"
|
||||
:index="i"
|
||||
:dimension="d"
|
||||
:field="getField(d)"
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<hr>
|
||||
|
||||
<h4 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>
|
||||
</h4>
|
||||
|
||||
<!-- Configure report metrics -->
|
||||
<draggable
|
||||
class="metrics mb-3"
|
||||
:list.sync="metrics"
|
||||
handle=".metric-handle"
|
||||
:options="{ group: `metrics_${moduleID}` }"
|
||||
>
|
||||
<fieldset
|
||||
v-for="(m,i) in metrics"
|
||||
:key="i"
|
||||
class="main-fieldset mb-3"
|
||||
>
|
||||
<h5
|
||||
v-if="metrics.length > 1"
|
||||
class="d-flex align-items-center mb-3"
|
||||
>
|
||||
<font-awesome-icon
|
||||
class="grab metric-handle align-baseline text-secondary mr-2"
|
||||
:icon="['fas', 'grip-vertical']"
|
||||
/>
|
||||
{{ $t('edit.metric.label') }} {{ i + 1 }}
|
||||
<b-button
|
||||
variant="link"
|
||||
class="text-danger align-baseline"
|
||||
@click.prevent="removeMetric(i)"
|
||||
>
|
||||
<font-awesome-icon :icon="['far', 'trash-alt']" />
|
||||
</b-button>
|
||||
</h5>
|
||||
|
||||
<b-form-group
|
||||
horizontal
|
||||
:label-cols="2"
|
||||
breakpoint="md"
|
||||
:label="$t('edit.metric.fieldLabel')"
|
||||
>
|
||||
<b-form-select
|
||||
v-model="m.field"
|
||||
:options="metricFields"
|
||||
@change="onMetricFieldChange($event, m)"
|
||||
>
|
||||
<template slot="first">
|
||||
<option
|
||||
:value="undefined"
|
||||
>
|
||||
{{ $t('edit.metric.fieldPlaceholder') }}
|
||||
</option>
|
||||
</template>
|
||||
</b-form-select>
|
||||
</b-form-group>
|
||||
|
||||
<b-form-group
|
||||
horizontal
|
||||
:label-cols="2"
|
||||
breakpoint="md"
|
||||
:label="$t('edit.metric.function.label')"
|
||||
>
|
||||
<b-form-select
|
||||
v-model="m.aggregate"
|
||||
:disabled="!m.field || m.field === 'count'"
|
||||
:options="metricAggregates"
|
||||
>
|
||||
<template slot="first">
|
||||
<option
|
||||
:value="undefined"
|
||||
>
|
||||
{{ $t('edit.metric.function.placeholder') }}
|
||||
</option>
|
||||
</template>
|
||||
</b-form-select>
|
||||
</b-form-group>
|
||||
|
||||
<slot
|
||||
name="metric-options"
|
||||
:metric="m"
|
||||
/>
|
||||
</fieldset>
|
||||
</draggable>
|
||||
</div>
|
||||
</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 {
|
||||
i18nOptions: {
|
||||
namespaces: 'chart',
|
||||
},
|
||||
|
||||
name: 'ReportEdit',
|
||||
|
||||
components: {
|
||||
draggable,
|
||||
},
|
||||
|
||||
extends: base,
|
||||
|
||||
data () {
|
||||
return {
|
||||
customFilter: false,
|
||||
|
||||
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}`) })),
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
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: 'Count' },
|
||||
...this.module.fields.filter(f => f.kind === 'Number')
|
||||
.map(({ name }) => ({ value: name, text: name }))
|
||||
.sort((a, b) => a.text.localeCompare(b.text)),
|
||||
]
|
||||
},
|
||||
|
||||
dimensionFields () {
|
||||
return [
|
||||
...[...this.module.fields].sort((a, b) => a.label.localeCompare(b.text)),
|
||||
...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} (${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 })
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
'report.filter': {
|
||||
handler: function (v) {
|
||||
// !! is required, since :disabled="..." marks the field as disabled if '' is provided
|
||||
this.customFilter = (!!v && !!compose.chartUtil.predefinedFilters.find(({ value }) => value === v)) ||
|
||||
(!!v)
|
||||
},
|
||||
immediate: true,
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
hasRelativeDisplay: compose.chartUtil.hasRelativeDisplay,
|
||||
|
||||
getField ({ field }) {
|
||||
if (!field || !this.module) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return this.module.fields.find(({ name }) => name === field)
|
||||
},
|
||||
|
||||
addMetric () {
|
||||
this.metrics = this.metrics.concat([{}])
|
||||
},
|
||||
|
||||
onDimFieldChange (f, d) {
|
||||
if (!this.isTemporalField(f)) {
|
||||
this.$set(d, 'modifier', this.dimensionModifiers[0].value)
|
||||
}
|
||||
},
|
||||
|
||||
onMetricFieldChange (field, m) {
|
||||
if (field === 'count') {
|
||||
this.$set(m, 'aggregate', undefined)
|
||||
}
|
||||
},
|
||||
|
||||
removeMetric (i) {
|
||||
this.metrics.splice(i, 1)
|
||||
},
|
||||
|
||||
isTemporalField (name) {
|
||||
return this.dimensionFields.some(f => f.value === name && f.kind === 'DateTime')
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
59
client/web/compose/src/components/Chart/Report/base.vue
Normal file
@@ -0,0 +1,59 @@
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
report: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: undefined,
|
||||
},
|
||||
|
||||
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,
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
editReport: {
|
||||
get () {
|
||||
return this.report
|
||||
},
|
||||
set (v) {
|
||||
this.$emit('update:report', v)
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
9
client/web/compose/src/components/Chart/Report/index.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import FunnelChart from './FunnelChart'
|
||||
import GenericChart from './GenericChart'
|
||||
import GaugeChart from './GaugeChart'
|
||||
|
||||
export default {
|
||||
FunnelChart,
|
||||
GenericChart,
|
||||
GaugeChart,
|
||||
}
|
||||
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: [
|
||||
],
|
||||
}
|
||||
58
client/web/compose/src/components/Chart/ReportItem.vue
Normal file
@@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<tr class="w-100 d-inline-block">
|
||||
<td
|
||||
v-b-tooltip.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-light 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>
|
||||
<b-btn
|
||||
variant="link"
|
||||
class="float-right text-danger p-0"
|
||||
@click="$emit('remove', report)"
|
||||
>
|
||||
<font-awesome-icon :icon="['far', 'trash-alt']" />
|
||||
</b-btn>
|
||||
</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>
|
||||
198
client/web/compose/src/components/Chart/index.vue
Normal file
@@ -0,0 +1,198 @@
|
||||
<template>
|
||||
<div class="h-100 position-relative">
|
||||
<div
|
||||
v-if="processing"
|
||||
class="d-flex align-items-center justify-content-center h-100"
|
||||
>
|
||||
<b-spinner />
|
||||
</div>
|
||||
|
||||
<c-chart
|
||||
v-if="renderer"
|
||||
:chart="renderer"
|
||||
class="p-1"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapGetters } from 'vuex'
|
||||
import { chartConstructor } from 'corteza-webapp-compose/src/lib/charts'
|
||||
import { compose, NoID } 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 {
|
||||
processing: false,
|
||||
|
||||
renderer: undefined,
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapGetters({
|
||||
getModuleByID: 'module/getByID',
|
||||
getUserByID: 'user/findByID',
|
||||
}),
|
||||
},
|
||||
|
||||
watch: {
|
||||
'record.recordID': {
|
||||
immediate: true,
|
||||
handler () {
|
||||
const { pageID = NoID } = this.$route.params
|
||||
this.$root.$on(`refetch-non-record-blocks:${pageID}`, this.requestChartUpdate)
|
||||
this.updateChart()
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
beforeDestroy () {
|
||||
const { pageID = NoID } = this.$route.params
|
||||
this.$root.$off(`refetch-non-record-blocks:${pageID}`)
|
||||
},
|
||||
|
||||
methods: {
|
||||
async updateChart () {
|
||||
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 })
|
||||
|
||||
if (!!data.labels && Array.isArray(data.labels)) {
|
||||
// Get dimension field kind
|
||||
const [dimension = {}] = report.dimensions
|
||||
let { field, meta = {} } = dimension
|
||||
const module = this.getModuleByID(report.moduleID)
|
||||
|
||||
if (module) {
|
||||
field = [
|
||||
...module.fields,
|
||||
...module.systemFields(),
|
||||
].find(({ name }) => name === field)
|
||||
|
||||
if (meta.fields) {
|
||||
data.labels = data.labels.map(value => {
|
||||
const { text } = field.options.options.find(o => o.value === value) || {}
|
||||
return text || value
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (field && ['User', 'Record'].includes(field.kind)) {
|
||||
if (field.kind === 'User') {
|
||||
// Fetch and map users to labels
|
||||
await this.$store.dispatch('user/fetchUsers', data.labels)
|
||||
data.labels = data.labels.map(label => {
|
||||
return field.formatter(this.getUserByID(label)) || label
|
||||
})
|
||||
} else {
|
||||
// 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 (recordID && recordID !== 'undefined') {
|
||||
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
|
||||
return Array.isArray(value) ? value.join(', ') : value
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data.labels = data.labels.map(l => l === 'undefined' ? this.$t('chart:undefined') : l)
|
||||
|
||||
this.renderer = chart.makeOptions(data)
|
||||
} else {
|
||||
data.labels = []
|
||||
}
|
||||
} catch (e) {
|
||||
this.processing = false
|
||||
this.toastErrorHandler(this.$t('chart.optionsBuildFailed'))(e)
|
||||
}
|
||||
this.processing = false
|
||||
this.$emit('updated')
|
||||
},
|
||||
|
||||
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()
|
||||
}
|
||||
},
|
||||
|
||||
error (msg) {
|
||||
/* eslint-disable no-console */
|
||||
console.error(msg)
|
||||
},
|
||||
},
|
||||
}
|
||||
</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'),
|
||||
],
|
||||
}
|
||||
186
client/web/compose/src/components/Common/FieldPicker.vue
Normal file
@@ -0,0 +1,186 @@
|
||||
<template>
|
||||
<div
|
||||
class="d-flex flex-column"
|
||||
>
|
||||
<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
|
||||
v-slot: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 = []
|
||||
if (this.fieldSubset) {
|
||||
mFields = this.module.filterFields(this.fieldSubset)
|
||||
} else {
|
||||
mFields = this.module.fields
|
||||
}
|
||||
|
||||
if (this.disabledTypes.length > 0) {
|
||||
mFields = mFields.filter(({ kind }) => !this.disabledTypes.find(t => t === kind))
|
||||
}
|
||||
|
||||
let sysFields = []
|
||||
if (!this.disableSystemFields) {
|
||||
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))
|
||||
}
|
||||
} else {
|
||||
if (mFields) {
|
||||
mFields = mFields.filter(({ isSystem }) => !isSystem)
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
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,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
200
client/web/compose/src/components/Common/Grid.vue
Normal file
@@ -0,0 +1,200 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="grid.length"
|
||||
class="w-100"
|
||||
:class="{
|
||||
editable: !!editable,
|
||||
'flex-grow-1 d-flex': isStretchable,
|
||||
}"
|
||||
>
|
||||
<grid-layout
|
||||
class="flex-grow-1 d-flex w-100 h-100"
|
||||
:layout.sync="layout"
|
||||
:row-height="50"
|
||||
:is-resizable="!!editable"
|
||||
:is-draggable="!!editable"
|
||||
:cols="columnNumber"
|
||||
:margin="[0, 0]"
|
||||
:responsive="!editable"
|
||||
:use-css-transforms="false"
|
||||
@layout-updated="handleLayoutUpdate"
|
||||
>
|
||||
<grid-item
|
||||
v-for="(item, index) in gridCollection"
|
||||
:key="item.i"
|
||||
ref="items"
|
||||
class="grid-item"
|
||||
:class="{
|
||||
'h-100': isStretchable,
|
||||
}"
|
||||
style="touch-action: none;"
|
||||
v-bind="{ ...item }"
|
||||
>
|
||||
<slot
|
||||
:block="blocks[item.i]"
|
||||
:index="index"
|
||||
:block-index="item.i"
|
||||
:bounding-rect="boundingRects[index]"
|
||||
v-on="$listeners"
|
||||
/>
|
||||
</grid-item>
|
||||
</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 VueGridLayout from 'vue-grid-layout'
|
||||
import { compose } from '@cortezaproject/corteza-js'
|
||||
import { throttle } from 'lodash'
|
||||
|
||||
export default {
|
||||
i18nOptions: {
|
||||
namespaces: 'page',
|
||||
},
|
||||
|
||||
components: {
|
||||
GridLayout: VueGridLayout.GridLayout,
|
||||
GridItem: VueGridLayout.GridItem,
|
||||
},
|
||||
|
||||
props: {
|
||||
editable: {
|
||||
type: Boolean,
|
||||
},
|
||||
|
||||
blocks: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
data () {
|
||||
return {
|
||||
// all blocks in vue-grid friendly structure
|
||||
grid: [],
|
||||
|
||||
// Grid items bounding rect info
|
||||
boundingRects: [],
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
layout: {
|
||||
get () {
|
||||
return this.grid
|
||||
},
|
||||
|
||||
set (layout) {
|
||||
this.grid = layout
|
||||
this.handleLayoutUpdate(layout)
|
||||
},
|
||||
},
|
||||
|
||||
sortedGrid () {
|
||||
return Array.from(this.grid).sort((a, b) => Math.sqrt(a.x * a.x + a.y * a.y) - Math.sqrt(b.x * b.x + b.y * b.y))
|
||||
},
|
||||
|
||||
isStretchable () {
|
||||
if (this.editable) {
|
||||
// When in-edit mode do not stretch the blocks
|
||||
return false
|
||||
}
|
||||
|
||||
const minHeight = 10
|
||||
let heightCheck = -1
|
||||
|
||||
for (let b = 0; b < this.blocks.length; b++) {
|
||||
const { xywh: [, y, , h] } = this.blocks[b]
|
||||
|
||||
if (y > 0) {
|
||||
// If block is not positioned at the top,
|
||||
// do not try to make it stretchable
|
||||
return false
|
||||
}
|
||||
|
||||
if (heightCheck === -1) {
|
||||
// Set block height for the next check
|
||||
heightCheck = h
|
||||
}
|
||||
|
||||
if (heightCheck !== h && minHeight > h) {
|
||||
// Not full height
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
|
||||
columnNumber () {
|
||||
if (this.grid.length === 1) {
|
||||
return { lg: 1, md: 1, sm: 1, xs: 1, xxs: 1 }
|
||||
}
|
||||
return { lg: 12, md: 12, sm: 1, xs: 1, xxs: 1 }
|
||||
},
|
||||
|
||||
gridCollection () {
|
||||
if (this.grid.length === 1) {
|
||||
return this.sortedGrid
|
||||
}
|
||||
return this.grid
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
blocks: {
|
||||
immediate: true,
|
||||
deep: true,
|
||||
handler (blocks) {
|
||||
if (blocks.length === 0) this.$emit('change', [])
|
||||
this.grid = blocks.map(({ xywh: [x, y, w, h] }, i) => ({ i, x, y, w, h }))
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
mounted () {
|
||||
window.addEventListener('resize', this.windowResizeThrottledHandler)
|
||||
this.recalculateBoundingRect()
|
||||
},
|
||||
|
||||
destroyed () {
|
||||
window.removeEventListener('resize', this.windowResizeThrottledHandler)
|
||||
},
|
||||
|
||||
methods: {
|
||||
windowResizeThrottledHandler: throttle(function () { this.recalculateBoundingRect() }, 500),
|
||||
|
||||
// Fetch bounding boxes of all grid items
|
||||
recalculateBoundingRect () {
|
||||
this.boundingRects = (this.$refs.items || [])
|
||||
.map(({ $el }) => {
|
||||
const { x, y, width: w, height: h } = $el.getBoundingClientRect()
|
||||
return { x, y, w, h }
|
||||
})
|
||||
},
|
||||
|
||||
handleLayoutUpdate (layout) {
|
||||
this.$emit('change', layout.map(
|
||||
({ x, y, w, h, i }) => new compose.PageBlockMaker({ ...this.blocks[i], xywh: [x, y, w, h] }),
|
||||
))
|
||||
this.recalculateBoundingRect()
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
<style lang="scss">
|
||||
.editable {
|
||||
.grid-item {
|
||||
background-image: linear-gradient(45deg, #f3f3f5 25%, #ffffff 25%, #ffffff 50%, #f3f3f5 50%, #f3f3f5 75%, #ffffff 75%, #ffffff 100%);
|
||||
background-size: 28.28px 28.28px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
15
client/web/compose/src/components/Common/Hint.c3.js
Normal file
@@ -0,0 +1,15 @@
|
||||
// eslint-disable-next-line
|
||||
import { default as component } from './Hint.vue'
|
||||
|
||||
const props = {
|
||||
text: 'I\'m the hint!',
|
||||
id: '34324',
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'Hint',
|
||||
group: ['Common'],
|
||||
component,
|
||||
props,
|
||||
controls: [],
|
||||
}
|
||||
36
client/web/compose/src/components/Common/Hint.vue
Normal file
@@ -0,0 +1,36 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="text"
|
||||
:id="id"
|
||||
class="ml-1"
|
||||
>
|
||||
<font-awesome-icon
|
||||
:icon="['far', 'question-circle']"
|
||||
/>
|
||||
|
||||
<b-tooltip
|
||||
:target="id"
|
||||
triggers="hover"
|
||||
placement="right"
|
||||
variant="dark"
|
||||
custom-class="m-1"
|
||||
>
|
||||
{{ text }}
|
||||
</b-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
text: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
id: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,51 @@
|
||||
<template>
|
||||
<b-form-group
|
||||
class="p-0 m-0"
|
||||
>
|
||||
<b-form-row
|
||||
v-for="(expr, e) in value"
|
||||
:key="e"
|
||||
class="mb-2"
|
||||
no-gutters
|
||||
>
|
||||
<b-input-group>
|
||||
<b-input-group-prepend>
|
||||
<b-button variant="dark">
|
||||
ƒ
|
||||
</b-button>
|
||||
</b-input-group-prepend>
|
||||
<slot :value="value[e]">
|
||||
<b-form-input
|
||||
v-model="value[e]"
|
||||
:placeholder="placeholder"
|
||||
/>
|
||||
</slot>
|
||||
<b-input-group-addon
|
||||
class="m-1"
|
||||
>
|
||||
<!-- no prompt/confirmation on empty input -->
|
||||
<c-input-confirm
|
||||
:no-prompt="value[e].length === 0"
|
||||
@confirmed="$emit('remove', e)"
|
||||
/>
|
||||
</b-input-group-addon>
|
||||
</b-input-group>
|
||||
</b-form-row>
|
||||
</b-form-group>
|
||||
</template>
|
||||
<script>
|
||||
|
||||
export default {
|
||||
props: {
|
||||
value: {
|
||||
type: Array,
|
||||
default: () => ([]),
|
||||
},
|
||||
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: () => {},
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,31 @@
|
||||
// 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: [{
|
||||
groupCondition: '',
|
||||
filter: [{
|
||||
name: '',
|
||||
}],
|
||||
}],
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'Record list filter',
|
||||
group: ['Common'],
|
||||
component,
|
||||
props,
|
||||
controls: [],
|
||||
}
|
||||
588
client/web/compose/src/components/Common/RecordListFilter.vue
Normal file
@@ -0,0 +1,588 @@
|
||||
<template>
|
||||
<div>
|
||||
<b-button
|
||||
:id="popoverTarget"
|
||||
:title="$t('recordList.filter.title')"
|
||||
variant="link p-0 ml-1"
|
||||
:class="[inFilter ? 'text-primary' : 'text-secondary']"
|
||||
@click.stop
|
||||
>
|
||||
<font-awesome-icon :icon="['fas', 'filter']" />
|
||||
</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"
|
||||
@show="onOpen"
|
||||
@hide="onHide"
|
||||
>
|
||||
<b-card
|
||||
no-body
|
||||
class="position-static w-100"
|
||||
>
|
||||
<b-card-body
|
||||
class="px-1 pb-0 overflow-auto"
|
||||
>
|
||||
<b-table-simple
|
||||
v-if="componentFilter.length"
|
||||
borderless
|
||||
class="mb-0"
|
||||
>
|
||||
<template
|
||||
v-for="(filterGroup, groupIndex) in componentFilter"
|
||||
>
|
||||
<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: 1%;"
|
||||
>
|
||||
<h6
|
||||
v-if="index === 0"
|
||||
class="mb-0"
|
||||
>
|
||||
{{ $t('recordList.filter.where') }}
|
||||
</h6>
|
||||
<b-form-select
|
||||
v-else
|
||||
v-model="filter.condition"
|
||||
:options="conditions"
|
||||
/>
|
||||
</b-td>
|
||||
<b-td
|
||||
class="px-2"
|
||||
>
|
||||
<vue-select
|
||||
v-model="filter.name"
|
||||
:options="fieldOptions"
|
||||
:clearable="false"
|
||||
:placeholder="$t('recordList.filter.fieldPlaceholder')"
|
||||
option-value="name"
|
||||
option-text="label"
|
||||
:reduce="f => f.name"
|
||||
append-to-body
|
||||
:calculate-position="calculatePosition"
|
||||
:class="{ 'filter-field-picker': !!filter.name }"
|
||||
class="field-selector bg-white"
|
||||
@input="onChange($event, groupIndex, index)"
|
||||
/>
|
||||
</b-td>
|
||||
<b-td
|
||||
v-if="getField(filter.name)"
|
||||
:class="{ 'pr-2': getField(filter.name) }"
|
||||
>
|
||||
<b-form-select
|
||||
v-if="getField(filter.name)"
|
||||
v-model="filter.operator"
|
||||
:options="getOperators(filter.kind)"
|
||||
class="d-flex field-operator w-100"
|
||||
/>
|
||||
</b-td>
|
||||
<b-td
|
||||
v-if="getField(filter.name)"
|
||||
>
|
||||
<field-editor
|
||||
v-bind="mock"
|
||||
class="field-editor mb-0"
|
||||
value-only
|
||||
:field="getField(filter.name)"
|
||||
:record="filter.record"
|
||||
@change="onValueChange"
|
||||
/>
|
||||
</b-td>
|
||||
<b-td
|
||||
v-if="getField(filter.name)"
|
||||
style="width: 1%;"
|
||||
>
|
||||
<b-button
|
||||
:id="`${groupIndex}-${index}`"
|
||||
ref="delete"
|
||||
variant="link"
|
||||
class="d-flex align-items-center"
|
||||
@click="deleteFilter(groupIndex, index)"
|
||||
>
|
||||
<font-awesome-icon
|
||||
:icon="['far', 'trash-alt']"
|
||||
size="sm"
|
||||
/>
|
||||
</b-button>
|
||||
</b-td>
|
||||
</b-tr>
|
||||
|
||||
<b-tr :key="`addFilter-${groupIndex}`">
|
||||
<b-td class="pb-0">
|
||||
<b-button
|
||||
variant="link text-decoration-none"
|
||||
style="min-height: 38px; min-width: 84px;"
|
||||
@click="addFilter(groupIndex)"
|
||||
>
|
||||
<font-awesome-icon
|
||||
:icon="['fas', 'plus']"
|
||||
size="sm"
|
||||
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-3': filterGroup.groupCondition }"
|
||||
>
|
||||
<div
|
||||
class="group-separator"
|
||||
>
|
||||
<b-form-select
|
||||
v-if="filterGroup.groupCondition"
|
||||
v-model="filterGroup.groupCondition"
|
||||
class="w-auto"
|
||||
:options="conditions"
|
||||
/>
|
||||
|
||||
<b-button
|
||||
v-else
|
||||
variant="outline-primary"
|
||||
class="btn-add-group bg-white py-2 px-3"
|
||||
@click="addGroup()"
|
||||
>
|
||||
<font-awesome-icon
|
||||
:icon="['fas', 'plus']"
|
||||
class="h6 mb-0 "
|
||||
/>
|
||||
</b-button>
|
||||
</div>
|
||||
</b-td>
|
||||
</b-tr>
|
||||
</template>
|
||||
</template>
|
||||
</b-table-simple>
|
||||
</b-card-body>
|
||||
|
||||
<b-card-footer
|
||||
class="d-flex justify-content-between bg-white shadow-sm rounded"
|
||||
>
|
||||
<b-button
|
||||
variant="light"
|
||||
@click="resetFilter"
|
||||
>
|
||||
{{ $t('general:label.reset') }}
|
||||
</b-button>
|
||||
|
||||
<b-button
|
||||
ref="btnSave"
|
||||
variant="primary"
|
||||
@click="onSave"
|
||||
>
|
||||
{{ $t('general.label.save') }}
|
||||
</b-button>
|
||||
</b-card-footer>
|
||||
</b-card>
|
||||
|
||||
<a
|
||||
ref="focusMe"
|
||||
href=""
|
||||
disabled
|
||||
/>
|
||||
</b-popover>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import FieldEditor from '../ModuleFields/Editor'
|
||||
import { compose, validator } from '@cortezaproject/corteza-js'
|
||||
import { VueSelect } from 'vue-select'
|
||||
import calculatePosition from 'corteza-webapp-compose/src/mixins/vue-select-position'
|
||||
|
||||
export default {
|
||||
i18nOptions: {
|
||||
namespaces: 'block',
|
||||
},
|
||||
|
||||
components: {
|
||||
FieldEditor,
|
||||
VueSelect,
|
||||
},
|
||||
|
||||
mixins: [
|
||||
calculatePosition,
|
||||
],
|
||||
|
||||
props: {
|
||||
target: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
|
||||
selectedField: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
namespace: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
module: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
recordListFilter: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
data () {
|
||||
return {
|
||||
componentFilter: [],
|
||||
|
||||
conditions: [
|
||||
{ value: 'AND', text: this.$t('recordList.filter.conditions.and') },
|
||||
{ value: 'OR', text: this.$t('recordList.filter.conditions.or') },
|
||||
],
|
||||
|
||||
mock: {},
|
||||
|
||||
// Used to prevent unwanted closure of popover
|
||||
preventPopoverClose: false,
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
fields () {
|
||||
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(({ isFilterable }) => isFilterable)
|
||||
},
|
||||
|
||||
fieldOptions () {
|
||||
return this.fields.map(({ name, label }) => ({ name, label: label || name }))
|
||||
},
|
||||
|
||||
inFilter () {
|
||||
return this.recordListFilter.some(({ filter }) => {
|
||||
return filter.some(({ name }) => name === this.selectedField.name)
|
||||
})
|
||||
},
|
||||
|
||||
popoverTarget () {
|
||||
return `${this.target || '0'}-${this.selectedField.name}`
|
||||
},
|
||||
},
|
||||
|
||||
created () {
|
||||
// Change all module fields to single value to keep multi value fields and single value
|
||||
const module = JSON.parse(JSON.stringify(this.module || {}))
|
||||
|
||||
module.fields = [
|
||||
...[...module.fields].map(f => {
|
||||
f.isMulti = false
|
||||
return f
|
||||
}),
|
||||
...this.module.systemFields().map(sf => {
|
||||
return { ...sf, label: this.$t(`field:system.${sf.name}`) }
|
||||
}),
|
||||
]
|
||||
|
||||
this.mock = {
|
||||
namespace: this.namespace,
|
||||
module,
|
||||
errors: new validator.Validated(),
|
||||
}
|
||||
},
|
||||
|
||||
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()
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
onValueChange () {
|
||||
this.preventPopoverClose = true
|
||||
|
||||
setTimeout(() => {
|
||||
this.preventPopoverClose = false
|
||||
}, 100)
|
||||
},
|
||||
|
||||
onChange (fieldName, groupIndex, index) {
|
||||
const field = this.getField(fieldName)
|
||||
const filterExists = !!(this.componentFilter[groupIndex] || { filter: [] }).filter[index]
|
||||
if (field && filterExists) {
|
||||
const tempFilter = [...this.componentFilter]
|
||||
tempFilter[groupIndex].filter[index].kind = field.kind
|
||||
tempFilter[groupIndex].filter[index].name = field.name
|
||||
tempFilter[groupIndex].filter[index].value = undefined
|
||||
tempFilter[groupIndex].filter[index].operator = '='
|
||||
this.componentFilter = tempFilter
|
||||
}
|
||||
this.onValueChange()
|
||||
},
|
||||
|
||||
getOperators (kind) {
|
||||
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'),
|
||||
},
|
||||
]
|
||||
|
||||
switch (kind) {
|
||||
case 'Number':
|
||||
return [...operators, ...inOperators, ...lgOperators]
|
||||
|
||||
case 'DateTime':
|
||||
return [...operators, ...lgOperators]
|
||||
|
||||
case 'String':
|
||||
case 'Url':
|
||||
case 'Email':
|
||||
return [...operators, ...inOperators, ...lgOperators, ...matchOperators]
|
||||
|
||||
default:
|
||||
return operators
|
||||
}
|
||||
},
|
||||
|
||||
getField (name = '') {
|
||||
const field = name ? this.mock.module.fields.find(f => f.name === name) : undefined
|
||||
|
||||
return field ? { ...field } : undefined
|
||||
},
|
||||
|
||||
createDefaultFilter (condition, field = {}) {
|
||||
return {
|
||||
condition,
|
||||
name: field.name,
|
||||
operator: '=',
|
||||
value: undefined,
|
||||
kind: field.kind,
|
||||
record: new compose.Record(this.mock.module, {}),
|
||||
}
|
||||
},
|
||||
|
||||
createDefaultFilterGroup (groupCondition = undefined, field) {
|
||||
return {
|
||||
groupCondition,
|
||||
filter: [
|
||||
this.createDefaultFilter('Where', field),
|
||||
],
|
||||
}
|
||||
},
|
||||
|
||||
addFilter (groupIndex) {
|
||||
if ((this.componentFilter[groupIndex] || {}).filter) {
|
||||
this.componentFilter[groupIndex].filter.push(this.createDefaultFilter('AND', this.selectedField))
|
||||
}
|
||||
},
|
||||
|
||||
addGroup () {
|
||||
this.$refs.btnSave.focus()
|
||||
|
||||
this.componentFilter[this.componentFilter.length - 1].groupCondition = 'AND'
|
||||
this.componentFilter.push(this.createDefaultFilterGroup(undefined, this.selectedField))
|
||||
},
|
||||
|
||||
resetFilter () {
|
||||
this.componentFilter = [
|
||||
this.createDefaultFilterGroup(),
|
||||
]
|
||||
|
||||
this.onSave(false)
|
||||
},
|
||||
|
||||
deleteFilter (groupIndex, index) {
|
||||
const filterExists = !!(this.componentFilter[groupIndex] || { filter: [] }).filter[index]
|
||||
|
||||
if (filterExists) {
|
||||
// Set focus to previous element
|
||||
this.onSetFocus(groupIndex, index)
|
||||
// Delete filter from filterGroup
|
||||
this.componentFilter[groupIndex].filter.splice(index, 1)
|
||||
|
||||
// If filter was last in filterGroup, delete filterGroup
|
||||
if (!this.componentFilter[groupIndex].filter.length) {
|
||||
this.componentFilter.splice(groupIndex, 1)
|
||||
|
||||
// If no more filterGroups, add default back
|
||||
if (!this.componentFilter.length) {
|
||||
this.resetFilter()
|
||||
} else if (groupIndex === this.componentFilter.length) {
|
||||
// Reset first filterGroup groupCondition if last filterGroup was deleted
|
||||
this.componentFilter[groupIndex - 1].groupCondition = undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
onSetFocus (groupIndex, index) {
|
||||
let focusIndex = this.$refs.delete.findIndex(r => r.id === `${groupIndex}-${index}`)
|
||||
if (focusIndex > 0) {
|
||||
focusIndex--
|
||||
}
|
||||
this.$refs.delete[focusIndex].focus()
|
||||
},
|
||||
|
||||
onOpen () {
|
||||
if (this.recordListFilter.length) {
|
||||
// Create record and fill its values property if value exists
|
||||
this.componentFilter = this.recordListFilter
|
||||
.filter(({ filter = [] }) => filter.some(f => f.name))
|
||||
.map(({ groupCondition, filter = [] }) => {
|
||||
filter = filter.map(({ value, ...f }) => {
|
||||
f.record = new compose.Record(this.mock.module, {})
|
||||
f.record.values[f.name] = value
|
||||
return f
|
||||
})
|
||||
|
||||
return { groupCondition, filter }
|
||||
})
|
||||
}
|
||||
|
||||
// If no filterGroups, add default
|
||||
if (!this.componentFilter.length) {
|
||||
this.componentFilter.push(this.createDefaultFilterGroup(undefined, this.selectedField))
|
||||
}
|
||||
},
|
||||
|
||||
onSave (close = true) {
|
||||
if (close) {
|
||||
this.$refs.popover.$emit('close')
|
||||
}
|
||||
|
||||
// Emit only value and not whole record with every filter
|
||||
this.$emit('filter', this.componentFilter.map(({ groupCondition, filter = [] }) => {
|
||||
filter = filter.map(({ record, ...f }) => {
|
||||
if (record) {
|
||||
f.value = record.values[f.name]
|
||||
}
|
||||
|
||||
return f
|
||||
})
|
||||
|
||||
return { groupCondition, filter }
|
||||
}))
|
||||
},
|
||||
},
|
||||
}
|
||||
</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: 60vh;
|
||||
padding: 0;
|
||||
color: #2d2d2d;
|
||||
text-align: center;
|
||||
background: 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: white;
|
||||
border-top-color: white;
|
||||
}
|
||||
|
||||
&::after {
|
||||
border-top-color: white;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.group-separator {
|
||||
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;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.btn-add-group {
|
||||
&:hover, &:active {
|
||||
background-color: $primary !important;
|
||||
color: white !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
218
client/web/compose/src/components/Common/RecordToolbar.vue
Normal file
@@ -0,0 +1,218 @@
|
||||
<template>
|
||||
<b-container
|
||||
fluid
|
||||
class="bg-white shadow border-top p-3"
|
||||
>
|
||||
<b-row
|
||||
no-gutters
|
||||
class="wrap-with-vertical-gutters align-items-center"
|
||||
>
|
||||
<div
|
||||
class="wrap-with-vertical-gutters align-items-center"
|
||||
>
|
||||
<b-button
|
||||
v-if="!settings.hideBack"
|
||||
data-test-id="button-back"
|
||||
variant="link"
|
||||
class="text-dark back"
|
||||
:disabled="processing"
|
||||
@click.prevent="$emit('back')"
|
||||
>
|
||||
<font-awesome-icon
|
||||
:icon="['fas', 'chevron-left']"
|
||||
class="back-icon"
|
||||
/>
|
||||
{{ labels.back || $t('label.back') }}
|
||||
</b-button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="module"
|
||||
class="d-flex wrap-with-vertical-gutters align-items-center ml-auto"
|
||||
>
|
||||
<c-input-confirm
|
||||
v-if="isCreated && !settings.hideDelete"
|
||||
:disabled="!canDeleteRecord"
|
||||
size="lg"
|
||||
size-confirm="lg"
|
||||
variant="danger"
|
||||
:borderless="false"
|
||||
@confirmed="$emit('delete')"
|
||||
>
|
||||
<b-spinner
|
||||
v-if="processingDelete"
|
||||
small
|
||||
type="grow"
|
||||
/>
|
||||
|
||||
<span v-else>
|
||||
{{ labels.delete || $t('label.delete') }}
|
||||
</span>
|
||||
</c-input-confirm>
|
||||
|
||||
<b-button
|
||||
v-if="!inEditing && module.canCreateRecord && !hideClone && isCreated && !settings.hideClone"
|
||||
data-test-id="button-clone"
|
||||
variant="light"
|
||||
size="lg"
|
||||
:disabled="!record || processing"
|
||||
class="ml-2"
|
||||
@click.prevent="$emit('clone')"
|
||||
>
|
||||
{{ labels.clone || $t('label.clone') }}
|
||||
</b-button>
|
||||
|
||||
<b-button
|
||||
v-if="!inEditing && !settings.hideEdit && isCreated"
|
||||
data-test-id="button-edit"
|
||||
:disabled="!record.canUpdateRecord || processing"
|
||||
variant="light"
|
||||
size="lg"
|
||||
class="ml-2"
|
||||
@click.prevent="$emit('edit')"
|
||||
>
|
||||
{{ labels.edit || $t('label.edit') }}
|
||||
</b-button>
|
||||
|
||||
<b-button
|
||||
v-if="module.canCreateRecord && !hideAdd && !inEditing && !settings.hideNew"
|
||||
data-test-id="button-add-new"
|
||||
variant="primary"
|
||||
size="lg"
|
||||
:disabled="processing"
|
||||
class="ml-2"
|
||||
@click.prevent="$emit('add')"
|
||||
>
|
||||
{{ labels.new || $t('label.addNew') }}
|
||||
</b-button>
|
||||
|
||||
<b-button
|
||||
v-if="inEditing && !settings.hideSubmit"
|
||||
data-test-id="button-submit"
|
||||
:disabled="!canSaveRecord || processing"
|
||||
class="d-flex align-items-center justify-content-center ml-2"
|
||||
variant="primary"
|
||||
size="lg"
|
||||
@click.prevent="$emit('submit')"
|
||||
>
|
||||
<b-spinner
|
||||
v-if="processingSubmit"
|
||||
small
|
||||
type="grow"
|
||||
/>
|
||||
|
||||
<span
|
||||
v-else
|
||||
data-test-id="button-save"
|
||||
>
|
||||
{{ labels.submit || $t('label.save') }}
|
||||
</span>
|
||||
</b-button>
|
||||
</div>
|
||||
</b-row>
|
||||
</b-container>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { compose, NoID } from '@cortezaproject/corteza-js'
|
||||
|
||||
export default {
|
||||
i18nOptions: {
|
||||
namespaces: 'general',
|
||||
},
|
||||
|
||||
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,
|
||||
},
|
||||
|
||||
processingDelete: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
|
||||
processingSubmit: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
|
||||
inEditing: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
|
||||
hideClone: {
|
||||
type: Boolean,
|
||||
default: () => false,
|
||||
},
|
||||
|
||||
hideAdd: {
|
||||
type: Boolean,
|
||||
default: () => false,
|
||||
},
|
||||
|
||||
isDeleted: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
isCreated () {
|
||||
return this.record && this.record.recordID !== NoID
|
||||
},
|
||||
|
||||
settings () {
|
||||
return this.$Settings.get('compose.ui.record-toolbar', {})
|
||||
},
|
||||
|
||||
canSaveRecord () {
|
||||
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.record.canDeleteRecord && !this.processing && this.record.recordID !== NoID
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.back {
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
|
||||
.back-icon {
|
||||
transition: transform 0.3s ease-out;
|
||||
transform: translateX(-4px);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
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,71 @@
|
||||
<template>
|
||||
<div>
|
||||
<b-form-group
|
||||
:label="$t('kind.bool.checkedValueLabel')"
|
||||
>
|
||||
<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"
|
||||
button-variant="light"
|
||||
/>
|
||||
</b-input-group-append>
|
||||
</b-input-group>
|
||||
</b-form-group>
|
||||
|
||||
<b-form-group
|
||||
:label="$t('kind.bool.uncheckedValueLabel')"
|
||||
>
|
||||
<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"
|
||||
button-variant="light"
|
||||
/>
|
||||
</b-input-group-append>
|
||||
</b-input-group>
|
||||
</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,23 @@
|
||||
// eslint-disable-next-line
|
||||
import { compose } from '@cortezaproject/corteza-js'
|
||||
import * as fieldTypes from './loader'
|
||||
|
||||
console.error({ ...fieldTypes })
|
||||
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,71 @@
|
||||
<template>
|
||||
<div>
|
||||
<div>
|
||||
<b-form-checkbox
|
||||
v-model="f.options.onlyDate"
|
||||
:disabled="f.options.onlyTime"
|
||||
>
|
||||
{{ $t('kind.dateTime.dateOnly') }}
|
||||
</b-form-checkbox>
|
||||
</div>
|
||||
<div>
|
||||
<b-form-checkbox
|
||||
v-model="f.options.onlyTime"
|
||||
:disabled="f.options.onlyDate"
|
||||
>
|
||||
{{ $t('kind.dateTime.timeOnly') }}
|
||||
</b-form-checkbox>
|
||||
</div>
|
||||
<div>
|
||||
<b-form-checkbox
|
||||
v-model="f.options.onlyPastValues"
|
||||
:disabled="f.options.onlyFutureValues"
|
||||
>
|
||||
{{ $t('kind.dateTime.pastValuesOnly') }}
|
||||
</b-form-checkbox>
|
||||
</div>
|
||||
<div>
|
||||
<b-form-checkbox
|
||||
v-model="f.options.onlyFutureValues"
|
||||
:disabled="f.options.onlyPastValues"
|
||||
>
|
||||
{{ $t('kind.dateTime.futureValuesOnly') }}
|
||||
</b-form-checkbox>
|
||||
</div>
|
||||
<div>
|
||||
<b-form-checkbox v-model="f.options.outputRelative">
|
||||
{{ $t('kind.dateTime.relativeOutput') }}
|
||||
</b-form-checkbox>
|
||||
</div>
|
||||
<div>
|
||||
{{ $t('kind.dateTime.outputFormat') }}<b-form-input
|
||||
v-model="f.options.format"
|
||||
plain
|
||||
placeholder="YYYY-MM-DD HH:ii"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<script>
|
||||
import base from './base'
|
||||
|
||||
export default {
|
||||
i18nOptions: {
|
||||
namespaces: 'field',
|
||||
},
|
||||
|
||||
extends: base,
|
||||
}
|
||||
</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,76 @@
|
||||
<template>
|
||||
<div>
|
||||
<b-form-group
|
||||
:label="$t('kind.file.view.maxSizeLabel')"
|
||||
>
|
||||
<b-input-group>
|
||||
<b-form-input
|
||||
v-model="f.options.maxSize"
|
||||
type="number"
|
||||
/>
|
||||
</b-input-group>
|
||||
</b-form-group>
|
||||
|
||||
<b-form-group
|
||||
:label="$t('kind.file.view.mimetypesLabel')"
|
||||
:description="$t('kind.file.view.mimetypesFootnote')"
|
||||
class="mt-2"
|
||||
>
|
||||
<b-form-input
|
||||
v-model="f.options.mimetypes"
|
||||
/>
|
||||
</b-form-group>
|
||||
|
||||
<b-form-group
|
||||
:description="$t('kind.file.view.modeFootnote')"
|
||||
:label="$t('kind.file.view.modeLabel')"
|
||||
>
|
||||
<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
|
||||
v-if="enableFileNameHiding"
|
||||
class="mb-0"
|
||||
>
|
||||
<b-form-checkbox
|
||||
v-model="f.options.hideFileName"
|
||||
>
|
||||
{{ $t('kind.file.view.showName') }}
|
||||
</b-form-checkbox>
|
||||
</b-form-group>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import base from './base'
|
||||
|
||||
export default {
|
||||
i18nOptions: {
|
||||
namespaces: 'field',
|
||||
},
|
||||
|
||||
extends: base,
|
||||
|
||||
computed: {
|
||||
modes () {
|
||||
return [
|
||||
{ value: 'list', text: this.$t('kind.file.view.list') },
|
||||
{ value: 'grid', text: this.$t('kind.file.view.grid') },
|
||||
{ value: 'single', text: this.$t('kind.file.view.single') },
|
||||
{ value: 'gallery', text: this.$t('kind.file.view.gallery') },
|
||||
]
|
||||
},
|
||||
|
||||
enableFileNameHiding () {
|
||||
return (this.f.options.mode === 'single') || (this.f.options.mode === 'gallery')
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,50 @@
|
||||
<template>
|
||||
<div>
|
||||
<b-form-group
|
||||
:label="$t('kind.geometry.initialZoomAndPosition')"
|
||||
class="mb-0"
|
||||
>
|
||||
<l-map
|
||||
:zoom="zoom"
|
||||
:center="center"
|
||||
class="w-100"
|
||||
style="height: 60vh;"
|
||||
@update:zoom="f.options.zoom = $event"
|
||||
@update:center="f.options.center = $event"
|
||||
>
|
||||
<l-tile-layer
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
:attribution="attribution"
|
||||
/>
|
||||
</l-map>
|
||||
</b-form-group>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import base from './base'
|
||||
|
||||
export default {
|
||||
i18nOptions: {
|
||||
namespaces: 'field',
|
||||
},
|
||||
|
||||
extends: base,
|
||||
|
||||
data () {
|
||||
return {
|
||||
attribution: '© <a target="_blank" href="http://osm.org/copyright">OpenStreetMap</a>',
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
center () {
|
||||
return this.f.options.center || [30, 30]
|
||||
},
|
||||
|
||||
zoom () {
|
||||
return this.f.options.zoom || 3
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,378 @@
|
||||
<template>
|
||||
<div>
|
||||
<b-form-group
|
||||
:label="$t('kind.number.displayType.label')"
|
||||
>
|
||||
<b-form-radio-group
|
||||
v-model="f.options.display"
|
||||
button-variant="outline-primary"
|
||||
:options="displayOptions"
|
||||
buttons
|
||||
/>
|
||||
</b-form-group>
|
||||
|
||||
<b-row>
|
||||
<template v-if="f.options.display === 'number'">
|
||||
<b-col
|
||||
cols="12"
|
||||
sm="6"
|
||||
>
|
||||
<b-form-group
|
||||
:label="$t('kind.number.prefixLabel')"
|
||||
>
|
||||
<b-form-input
|
||||
v-model="f.options.prefix"
|
||||
:placeholder="$t('kind.number.prefixPlaceholder')"
|
||||
/>
|
||||
</b-form-group>
|
||||
</b-col>
|
||||
|
||||
<b-col
|
||||
cols="12"
|
||||
sm="6"
|
||||
>
|
||||
<b-form-group
|
||||
:label="$t('kind.number.suffixLabel')"
|
||||
>
|
||||
<b-form-input
|
||||
v-model="f.options.suffix"
|
||||
:placeholder="$t('kind.number.suffixPlaceholder')"
|
||||
/>
|
||||
</b-form-group>
|
||||
</b-col>
|
||||
</template>
|
||||
|
||||
<b-col
|
||||
cols="12"
|
||||
sm="6"
|
||||
>
|
||||
<label class="d-block mb-3">
|
||||
{{ $t('kind.number.precisionLabel') }} ({{ f.options.precision }})
|
||||
</label>
|
||||
<b-form-input
|
||||
v-model="f.options.precision"
|
||||
:placeholder="$t('kind.number.precisionPlaceholder')"
|
||||
type="range"
|
||||
min="0"
|
||||
max="6"
|
||||
size="lg"
|
||||
class="mt-1 mb-2"
|
||||
/>
|
||||
</b-col>
|
||||
|
||||
<template v-if="f.options.display === 'number'">
|
||||
<b-col
|
||||
cols="12"
|
||||
sm="6"
|
||||
>
|
||||
<b-form-group
|
||||
:label="$t('kind.number.formatLabel')"
|
||||
>
|
||||
<b-form-input
|
||||
v-model="f.options.format"
|
||||
:placeholder="$t('kind.number.formatPlaceholder')"
|
||||
/>
|
||||
</b-form-group>
|
||||
</b-col>
|
||||
</template>
|
||||
|
||||
<template v-if="f.options.display === 'progress'">
|
||||
<b-col
|
||||
cols="12"
|
||||
sm="6"
|
||||
>
|
||||
<b-form-group
|
||||
:label="$t('kind.number.maximumValue')"
|
||||
>
|
||||
<b-form-input
|
||||
v-model="f.options.max"
|
||||
type="number"
|
||||
/>
|
||||
</b-form-group>
|
||||
</b-col>
|
||||
|
||||
<b-col
|
||||
cols="12"
|
||||
sm="6"
|
||||
>
|
||||
<b-form-group
|
||||
:label="$t('kind.number.progress.variants.default')"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
{{ $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>
|
||||
|
||||
<b-form-group
|
||||
:label=" $t('kind.number.liveExample')"
|
||||
class="mb-0 w-100"
|
||||
>
|
||||
<b-row
|
||||
align-v="center"
|
||||
>
|
||||
<b-col
|
||||
cols="12"
|
||||
sm="6"
|
||||
>
|
||||
<b-form-input
|
||||
v-model="liveExample"
|
||||
type="number"
|
||||
step="0.1"
|
||||
/>
|
||||
</b-col>
|
||||
|
||||
<b-col
|
||||
cols="12"
|
||||
sm="6"
|
||||
>
|
||||
<field-viewer
|
||||
value-only
|
||||
v-bind="mock"
|
||||
/>
|
||||
</b-col>
|
||||
</b-row>
|
||||
</b-form-group>
|
||||
|
||||
<b-form-group
|
||||
v-if="f.options.display === 'number'"
|
||||
:label="$t('kind.number.examplesLabel')"
|
||||
class="mt-3"
|
||||
>
|
||||
<table
|
||||
style="width: 100%;"
|
||||
>
|
||||
<tr>
|
||||
<th>{{ $t('kind.number.exampleInput') }}</th>
|
||||
<th>{{ $t('kind.number.exampleFormat') }}</th>
|
||||
<th>{{ $t('kind.number.exampleResult') }}</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>10000.234</td>
|
||||
<td>$0.00</td>
|
||||
<td>$10000.23</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>0.974878234</td>
|
||||
<td>0.000%</td>
|
||||
<td>97.488%</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>1</td>
|
||||
<td>0%</td>
|
||||
<td>100%</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>238</td>
|
||||
<td>00:00:00</td>
|
||||
<td>0:03:58</td>
|
||||
</tr>
|
||||
</table>
|
||||
</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(),
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
'field.options.display': {
|
||||
handler (display) {
|
||||
this.liveExample = display === 'number' ? 123.45679 : 33
|
||||
},
|
||||
},
|
||||
|
||||
'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' ? 123.45679 : 33
|
||||
},
|
||||
|
||||
methods: {
|
||||
addThreshold () {
|
||||
this.field.options.thresholds.push({ value: 0, variant: 'success' })
|
||||
},
|
||||
|
||||
removeThreshold (index) {
|
||||
if (index > -1) {
|
||||
this.field.options.thresholds.splice(index, 1)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,207 @@
|
||||
<template>
|
||||
<div>
|
||||
<b-form-group
|
||||
:label="$t('kind.record.moduleLabel')"
|
||||
>
|
||||
<b-form-select
|
||||
v-model="f.options.moduleID"
|
||||
:options="moduleOptions"
|
||||
text-field="name"
|
||||
value-field="moduleID"
|
||||
/>
|
||||
</b-form-group>
|
||||
|
||||
<template
|
||||
v-if="selectedModule"
|
||||
>
|
||||
<b-form-group
|
||||
:label="$t('kind.record.moduleField')"
|
||||
>
|
||||
<b-form-select
|
||||
v-model="f.options.labelField"
|
||||
:options="fieldOptions"
|
||||
/>
|
||||
</b-form-group>
|
||||
|
||||
<div
|
||||
v-if="labelField && labelField.kind === 'Record'"
|
||||
>
|
||||
<b-form-group
|
||||
:label="$t('kind.record.fieldFromModuleField')"
|
||||
>
|
||||
<b-form-select
|
||||
v-model="f.options.recordLabelField"
|
||||
:options="labelFieldOptions"
|
||||
:disabled="!labelFieldModule"
|
||||
/>
|
||||
</b-form-group>
|
||||
</div>
|
||||
|
||||
<b-form-group
|
||||
:label="$t('kind.record.queryFieldsLabel')"
|
||||
>
|
||||
<b-form-select
|
||||
v-model="f.options.queryFields"
|
||||
class="form-control"
|
||||
:options="queryFieldOptions"
|
||||
multiple
|
||||
/>
|
||||
</b-form-group>
|
||||
|
||||
<b-form-group
|
||||
:label="$t('kind.record.prefilterLabel')"
|
||||
>
|
||||
<b-form-textarea
|
||||
v-model="f.options.prefilter"
|
||||
class="form-control"
|
||||
:placeholder="$t('kind.record.prefilterPlaceholder')"
|
||||
/>
|
||||
<b-form-text>
|
||||
<i18next
|
||||
path="kind.record.prefilterFootnote"
|
||||
tag="label"
|
||||
>
|
||||
<code>${recordID}</code>
|
||||
<code>${ownerID}</code>
|
||||
<code>${userID}</code>
|
||||
</i18next>
|
||||
</b-form-text>
|
||||
</b-form-group>
|
||||
</template>
|
||||
|
||||
<b-form-group
|
||||
v-if="field.isMulti"
|
||||
:label="$t('kind.select.optionType.label')"
|
||||
>
|
||||
<b-form-radio-group
|
||||
v-model="f.options.selectType"
|
||||
:options="selectOptions"
|
||||
stacked
|
||||
/>
|
||||
</b-form-group>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapGetters } from 'vuex'
|
||||
import { NoID } from '@cortezaproject/corteza-js'
|
||||
import base from './base'
|
||||
|
||||
export default {
|
||||
i18nOptions: {
|
||||
namespaces: 'field',
|
||||
},
|
||||
|
||||
extends: base,
|
||||
|
||||
data () {
|
||||
return {
|
||||
selected: null,
|
||||
selectOptions: [
|
||||
{ text: this.$t('kind.select.optionType.default'), value: 'default' },
|
||||
{ text: this.$t('kind.select.optionType.multiple'), value: 'multiple' },
|
||||
{ text: this.$t('kind.select.optionType.each'), value: 'each' },
|
||||
],
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapGetters({
|
||||
modules: 'module/set',
|
||||
}),
|
||||
|
||||
moduleOptions () {
|
||||
let modules = this.modules
|
||||
|
||||
// If current module hasn't been created add it to modules
|
||||
if (this.module.moduleID === NoID) {
|
||||
modules = [
|
||||
({ moduleID: '-1', name: this.module.name || this.$t('kind.record.currentUnnamedModule') }),
|
||||
...modules,
|
||||
]
|
||||
}
|
||||
|
||||
return [
|
||||
{ moduleID: NoID, name: this.$t('kind.record.modulePlaceholder'), disabled: true },
|
||||
...modules,
|
||||
]
|
||||
},
|
||||
|
||||
selectedModule () {
|
||||
if (this.field.options.moduleID === '-1') {
|
||||
return this.module
|
||||
} else if (this.field.options.moduleID !== NoID) {
|
||||
return this.$store.getters['module/getByID'](this.field.options.moduleID)
|
||||
} else {
|
||||
return undefined
|
||||
}
|
||||
},
|
||||
|
||||
fieldOptions () {
|
||||
const fields = this.selectedModule
|
||||
? this.selectedModule.fields
|
||||
.map(({ label, name }) => { return { value: name, text: label || name } })
|
||||
: []
|
||||
return [
|
||||
{
|
||||
value: undefined,
|
||||
text: this.$t('kind.record.pickField'),
|
||||
disabled: true,
|
||||
},
|
||||
...fields.sort((a, b) => a.text.localeCompare(b.text)),
|
||||
]
|
||||
},
|
||||
|
||||
queryFieldOptions () {
|
||||
return this.fieldOptions.slice(1)
|
||||
},
|
||||
|
||||
labelField () {
|
||||
if (this.field.options.labelField) {
|
||||
return this.selectedModule.fields.find(({ name }) => name === this.field.options.labelField)
|
||||
}
|
||||
|
||||
return undefined
|
||||
},
|
||||
|
||||
labelFieldModule () {
|
||||
if (this.labelField) {
|
||||
return this.$store.getters['module/getByID'](this.labelField.options.moduleID)
|
||||
}
|
||||
|
||||
return undefined
|
||||
},
|
||||
|
||||
labelFieldOptions () {
|
||||
let fields = []
|
||||
|
||||
if (this.labelField && this.labelFieldModule) {
|
||||
fields = this.labelFieldModule.fields.map(({ label, name }) => { return { value: name, text: label || name } })
|
||||
|
||||
return [
|
||||
{
|
||||
value: undefined,
|
||||
text: this.$t('kind.record.pickField'),
|
||||
disabled: true,
|
||||
},
|
||||
...fields.sort((a, b) => a.text.localeCompare(b.text)),
|
||||
]
|
||||
}
|
||||
|
||||
return fields
|
||||
},
|
||||
|
||||
labelFieldQueryOptions () {
|
||||
return this.labelFieldOptions.filter(({ name }) => name !== this.field.options.recordLabelField)
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
'field.options.moduleID' () {
|
||||
this.f.options.labelField = undefined
|
||||
this.f.options.queryFields = []
|
||||
this.f.options.selectType = 'default'
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,162 @@
|
||||
<template>
|
||||
<b-row no-gutters>
|
||||
<b-col>
|
||||
<b-form-group
|
||||
:label="$t('kind.select.optionsLabel')"
|
||||
>
|
||||
<b-input-group
|
||||
v-for="(option, index) in f.options.options"
|
||||
:key="index"
|
||||
class="mb-1"
|
||||
>
|
||||
<b-form-input
|
||||
v-model="f.options.options[index].value"
|
||||
plain
|
||||
size="sm"
|
||||
:placeholder="$t('kind.select.optionValuePlaceholder')"
|
||||
/>
|
||||
|
||||
<b-form-input
|
||||
v-model="f.options.options[index].text"
|
||||
plain
|
||||
size="sm"
|
||||
:placeholder="$t('kind.select.optionLabelPlaceholder')"
|
||||
/>
|
||||
|
||||
<b-input-group-append>
|
||||
<field-select-translator
|
||||
v-if="field"
|
||||
:field="field"
|
||||
:module="module"
|
||||
:highlight-key="`meta.options.${option.value}.text`"
|
||||
size="sm"
|
||||
:disabled="isNew || option.new"
|
||||
button-variant="light"
|
||||
/>
|
||||
<b-button
|
||||
variant="outline-danger"
|
||||
class="border-0"
|
||||
@click.prevent="f.options.options.splice(index, 1)"
|
||||
>
|
||||
<font-awesome-icon :icon="['far', 'trash-alt']" />
|
||||
</b-button>
|
||||
</b-input-group-append>
|
||||
</b-input-group>
|
||||
|
||||
<b-input-group>
|
||||
<b-form-input
|
||||
v-model="newOption.value"
|
||||
plain
|
||||
size="sm"
|
||||
:placeholder="$t('kind.select.optionValuePlaceholder')"
|
||||
:state="newOptState"
|
||||
@keypress.enter.prevent="handleAddOption"
|
||||
/>
|
||||
|
||||
<b-form-input
|
||||
v-model="newOption.text"
|
||||
plain
|
||||
size="sm"
|
||||
:placeholder="$t('kind.select.optionLabelPlaceholder')"
|
||||
:state="newOptState"
|
||||
@keypress.enter.prevent="handleAddOption"
|
||||
/>
|
||||
|
||||
<b-input-group-append>
|
||||
<b-button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
:disabled="newOptState === false || newEmpty"
|
||||
@click.prevent="handleAddOption"
|
||||
>
|
||||
+ {{ $t('kind.select.optionAdd') }}
|
||||
</b-button>
|
||||
</b-input-group-append>
|
||||
</b-input-group>
|
||||
</b-form-group>
|
||||
|
||||
<b-form-group
|
||||
v-if="f.isMulti"
|
||||
>
|
||||
<label class="d-block">{{ $t('kind.select.optionType.label') }}</label>
|
||||
<b-form-radio-group
|
||||
v-model="f.options.selectType"
|
||||
:options="selectOptions"
|
||||
stacked
|
||||
/>
|
||||
</b-form-group>
|
||||
</b-col>
|
||||
</b-row>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import base from './base'
|
||||
import { NoID } from '@cortezaproject/corteza-js'
|
||||
import FieldSelectTranslator from 'corteza-webapp-compose/src/components/Admin/Module/FieldSelectTranslator'
|
||||
|
||||
export default {
|
||||
i18nOptions: {
|
||||
namespaces: 'field',
|
||||
},
|
||||
|
||||
components: {
|
||||
FieldSelectTranslator,
|
||||
},
|
||||
|
||||
extends: base,
|
||||
|
||||
data () {
|
||||
return {
|
||||
newOption: { value: undefined, text: undefined, new: true },
|
||||
selectOptions: [
|
||||
{ text: this.$t('kind.select.optionType.default'), value: 'default' },
|
||||
{ text: this.$t('kind.select.optionType.multiple'), value: 'multiple' },
|
||||
{ text: this.$t('kind.select.optionType.each'), value: 'each' },
|
||||
],
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
/**
|
||||
* Determines if newly entered option is empty
|
||||
* @returns {Boolean}
|
||||
*/
|
||||
newEmpty () {
|
||||
return !this.newOption.text || !this.newOption.value
|
||||
},
|
||||
|
||||
/**
|
||||
* Determines the state of new select option
|
||||
* @returns {Boolean|null}
|
||||
*/
|
||||
newOptState () {
|
||||
// No duplicates
|
||||
if (this.f.options.options.find(({ text, value }) => text === this.newOption.text || value === this.newOption.value)) {
|
||||
return false
|
||||
}
|
||||
return null
|
||||
},
|
||||
|
||||
isNew () {
|
||||
return this.module.moduleID === NoID || this.field.fieldID === NoID
|
||||
},
|
||||
},
|
||||
|
||||
created () {
|
||||
if (!this.f) {
|
||||
this.f.options = { options: [] }
|
||||
} else if (!this.f.options.options) {
|
||||
this.f.options.options = []
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
handleAddOption () {
|
||||
if (this.newOption.value) {
|
||||
this.f.options.options.push(this.newOption)
|
||||
this.newOption = { value: undefined, text: undefined, new: true }
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,23 @@
|
||||
<template>
|
||||
<div>
|
||||
<b-form-checkbox v-model="f.options.multiLine">
|
||||
{{ $t('kind.string.multiLine') }}
|
||||
</b-form-checkbox>
|
||||
|
||||
<b-form-checkbox v-model="f.options.useRichTextEditor">
|
||||
{{ $t('kind.string.richText') }}
|
||||
</b-form-checkbox>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import base from './base'
|
||||
|
||||
export default {
|
||||
i18nOptions: {
|
||||
namespaces: 'field',
|
||||
},
|
||||
|
||||
extends: base,
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,41 @@
|
||||
<template>
|
||||
<div>
|
||||
<div>
|
||||
<b-form-checkbox v-model="f.options.trimFragment">
|
||||
{{ $t('kind.url.label') }}
|
||||
</b-form-checkbox>
|
||||
</div>
|
||||
<div>
|
||||
<b-form-checkbox v-model="f.options.trimQuery">
|
||||
{{ $t('kind.url.trimHash') }}
|
||||
</b-form-checkbox>
|
||||
</div>
|
||||
<div>
|
||||
<b-form-checkbox v-model="f.options.trimPath">
|
||||
{{ $t('kind.url.trimQuestionMark') }}
|
||||
</b-form-checkbox>
|
||||
</div>
|
||||
<div>
|
||||
<b-form-checkbox v-model="f.options.onlySecure">
|
||||
{{ $t('kind.url.sshOnly') }}
|
||||
</b-form-checkbox>
|
||||
</div>
|
||||
<div>
|
||||
<b-form-checkbox v-model="f.options.outputPlain">
|
||||
{{ $t('kind.url.preventToLink') }}
|
||||
</b-form-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import base from './base'
|
||||
|
||||
export default {
|
||||
i18nOptions: {
|
||||
namespaces: 'field',
|
||||
},
|
||||
|
||||
extends: base,
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,73 @@
|
||||
<template>
|
||||
<div>
|
||||
<b-form-group>
|
||||
<b-form-checkbox v-model="f.options.presetWithAuthenticated">
|
||||
{{ $t('kind.user.presetWithCurrentUser') }}
|
||||
</b-form-checkbox>
|
||||
</b-form-group>
|
||||
|
||||
<b-form-group
|
||||
v-if="f.options.roles"
|
||||
:label="$t('kind.user.roles.label')"
|
||||
>
|
||||
<vue-select
|
||||
v-model="f.options.roles"
|
||||
:options="roleOptions"
|
||||
:reduce="role => role.roleID"
|
||||
option-value="roleID"
|
||||
option-text="name"
|
||||
:close-on-select="false"
|
||||
append-to-body
|
||||
:placeholder="$t('kind.user.roles.placeholder')"
|
||||
multiple
|
||||
label="name"
|
||||
class="bg-white"
|
||||
/>
|
||||
</b-form-group>
|
||||
|
||||
<b-form-group
|
||||
v-if="f.isMulti"
|
||||
>
|
||||
<label class="d-block">{{ $t('kind.select.optionType.label') }}</label>
|
||||
<b-form-radio-group
|
||||
v-model="f.options.selectType"
|
||||
:options="selectOptions"
|
||||
stacked
|
||||
/>
|
||||
</b-form-group>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import base from './base'
|
||||
import { VueSelect } from 'vue-select'
|
||||
|
||||
export default {
|
||||
i18nOptions: {
|
||||
namespaces: 'field',
|
||||
},
|
||||
|
||||
components: {
|
||||
VueSelect,
|
||||
},
|
||||
|
||||
extends: base,
|
||||
|
||||
data () {
|
||||
return {
|
||||
selectOptions: [
|
||||
{ text: this.$t('kind.select.optionType.default'), value: 'default' },
|
||||
{ text: this.$t('kind.select.optionType.multiple'), value: 'multiple' },
|
||||
{ text: this.$t('kind.select.optionType.each'), value: 'each' },
|
||||
],
|
||||
roleOptions: [],
|
||||
}
|
||||
},
|
||||
|
||||
mounted () {
|
||||
this.$SystemAPI.roleList().then(({ set: roles = [] }) => {
|
||||
this.roleOptions = roles
|
||||
})
|
||||
},
|
||||
}
|
||||
</script>
|
||||