3
0

Add files from webapp-compose with client/web/compose prefix

This commit is contained in:
Corteza Monorepo Migrator
2022-11-14 09:26:44 +01:00
parent 91424382fc
commit c40b042770
331 changed files with 47953 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
Chrome >= 60
Safari >= 10.1
iOS >= 10.3
Firefox >= 54
Edge >= 15

View File

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

View File

@@ -0,0 +1,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
View File

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

View File

@@ -0,0 +1,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"]

View File

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

View File

@@ -0,0 +1,13 @@
module.exports = {
presets: [
['@vue/app', { useBuiltIns: 'entry' }],
],
env: {
test: {
plugins: [
['istanbul', { useInlineSourceMaps: false }],
],
},
},
}

View 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

View 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;
}
}
}

View 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
}
}

View File

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

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

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 789 B

View 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>

View 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)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

View 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')

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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>

View File

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

View File

@@ -0,0 +1,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>

View File

@@ -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>

View 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>

View File

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

View File

@@ -0,0 +1,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>

View File

@@ -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>

View File

@@ -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>

View File

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

View File

@@ -0,0 +1,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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

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

View File

@@ -0,0 +1,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>

View 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>

View File

@@ -0,0 +1,124 @@
<template>
<div
v-if="module"
>
<h5>{{ $t('record-duplication.title') }}</h5>
<b-form-group
:label="$t('record-duplication.strict-fields.label')"
label-class="text-primary pb-0"
class="my-4"
>
<small>{{ $t('record-duplication.strict-fields.description') }}</small>
<field-picker
:module="module"
:fields.sync="strictFields"
:field-subset="module.fields"
disable-system-fields
style="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>

View File

@@ -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 }
}

View File

@@ -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>

View 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>

View File

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

View File

@@ -0,0 +1,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>

View File

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

View File

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

View File

@@ -0,0 +1,145 @@
<template>
<c-translator-button
v-if="canManageResourceTranslations && resourceTranslationsEnabled"
v-bind="$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>

View File

@@ -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>

View 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>

View 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>

View File

@@ -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: [],
},
},
],
}

View 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>

View 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>

View File

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

View File

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

View File

@@ -0,0 +1,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>

View 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>

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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>

View File

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

View File

@@ -0,0 +1,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>

View 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: [],
}

View 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>

View File

@@ -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>

View File

@@ -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: [],
}

View 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>

View 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>

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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>

View File

@@ -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: [],
}

View File

@@ -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>

View File

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

View File

@@ -0,0 +1,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>

View File

@@ -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: '&copy; <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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

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