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

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

View File

@@ -0,0 +1,3 @@
> 1%
last 2 versions
not dead

View File

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

View File

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

20
client/web/reporter/.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,39 @@
.PHONY: dep test build release upload
YARN_FLAGS ?= --non-interactive --no-progress --silent --emoji false
YARN = yarn $(YARN_FLAGS)
REPO_NAME ?= corteza-webapp-reporter
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:
@ cp $(RELEASE_EXTRA_FILES) $(BUILD_DEST_DIR)
@ tar -C $(BUILD_DEST_DIR) -czf $(RELEASE_NAME) $(dir $(BUILD_DEST_DIR))
upload: $(RELEASE_PKEY)
@ echo "put *.tar.gz" | sftp -q -o "StrictHostKeyChecking no" -i $(RELEASE_PKEY) $(RELEASE_SFTP_URI)
@ rm -f $(RELEASE_PKEY)
$(RELEASE_PKEY):
@ echo $(RELEASE_SFTP_KEY) | base64 -d > $(RELEASE_PKEY)
@ chmod 0400 $@

View File

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

View File

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

View File

@@ -0,0 +1,87 @@
{
"name": "corteza-webapp-reporter",
"description": "Corteza Reporter WebApp",
"version": "2024.9.10",
"license": "Apache-2.0",
"private": true,
"scripts": {
"serve": "vue-cli-service serve",
"dev-env": "vue-cli-service serve src/dev-env.js",
"build": "vue-cli-service build --mode production",
"lint": "vue-cli-service lint --fix",
"cdeps": "yarn link @cortezaproject/corteza-js @cortezaproject/corteza-vue",
"test:unit": "vue-cli-service test:unit",
"test": "yarn test:unit"
},
"gitHooks": {
"pre-commit": "yarn lint"
},
"stylelint": {
"plugins": [
"stylelint-scss"
],
"extends": "stylelint-config-standard",
"rules": {
"color-hex-length": null,
"no-empty-source": null
}
},
"resolutions": {
"cross-spawn": "^7.0.6",
"postcss": "^8.5.3"
},
"dependencies": {
"@cortezaproject/corteza-js": "^2024.9.10",
"@cortezaproject/corteza-vue": "2024.9.10",
"@fortawesome/fontawesome-svg-core": "^6.7.2",
"@fortawesome/free-regular-svg-icons": "^6.7.2",
"@fortawesome/free-solid-svg-icons": "^6.7.2",
"@fortawesome/vue-fontawesome": "^2.0.10",
"@panter/vue-i18next": "^0.15.0",
"bootstrap-vue": "2.23.1",
"echarts": "5.6.0",
"i18next": "^23.16.8",
"i18next-browser-languagedetector": "^3.0.2",
"lodash": "^4.17.21",
"portal-vue": "^2.1.7",
"vue": "2.7.16",
"vue-echarts": "7.0.3",
"vue-grid-layout": "^2.3.12",
"vue-native-websocket": "^2.0.15",
"vue-router": "3.1.5",
"vue-split-panel": "^1.0.4",
"vuedraggable": "^2.24.3",
"vuex": "^3.6.2"
},
"devDependencies": {
"@babel/core": "^7.26.7",
"@babel/eslint-parser": "^7.26.5",
"@vue/cli-plugin-babel": "5.0.8",
"@vue/cli-plugin-eslint": "5.0.8",
"@vue/cli-plugin-unit-jest": "^5.0.8",
"@vue/cli-service": "5.0.8",
"@vue/eslint-config-standard": "^8.0.1",
"@vue/test-utils": "^1.3.6",
"@vue/vue2-jest": "^27.0.0",
"babel-jest": "^27.1.0",
"chai": "^4.3.7",
"chai-as-promised": "^7.1.1",
"eslint": "^8.7.2",
"eslint-config-corteza-client": "file:../../../lib/eslint-client",
"eslint-plugin-vue": "^9.32.0",
"jest": "^27.1.0",
"null-loader": "^4.0.1",
"resolve-url-loader": "^5.0.0",
"rtlcss": "^4.3.0",
"sass": "^1.85.1",
"sass-loader": "^10",
"sinon": "^9.2.4",
"stylelint": "16.14.1",
"stylelint-config-standard": "^37.0.0",
"stylelint-scss": "^6.11.0",
"stylelint-webpack-plugin": "^5.0.1",
"vue-eslint-parser": "^9.4.3",
"vue-template-compiler": "2.7.16",
"webpack": "^5.97.1"
}
}

View File

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

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

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

View File

@@ -0,0 +1,12 @@
// 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';

View File

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

After

Width:  |  Height:  |  Size: 792 B

View File

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

View File

@@ -0,0 +1,159 @@
import Vue from 'vue'
import './config-check'
import './console-splash'
import './filters'
import './plugins'
import './mixins'
import './components'
import store from './store'
import { i18n, websocket } from '@cortezaproject/corteza-vue'
import router from './router'
export default (options = {}) => {
options = {
el: '#app',
name: 'reporter',
template: '<router-view v-if="loaded && i18nLoaded" />',
data: () => ({
loaded: false,
i18nLoaded: false,
}),
async created () {
this.$i18n.i18next.on('initialized', () => {
this.i18nLoaded = true
})
this.websocket()
return this.$auth.vue(this).handle().then(async ({ user }) => {
// switch the favicon based on the settings
await this.$Settings.init({ api: this.$SystemAPI }).then(() => {
const icon = this.$Settings.attachment('ui.iconLogo') || '/icon.svg'
const favicon = document.getElementById('favicon')
if (favicon) {
favicon.href = icon
}
})
if (user.meta.preferredLanguage) {
// After user is authenticated, get his preferred language
// and instruct i18next to change it
this.$i18n.i18next.changeLanguage(user.meta.preferredLanguage)
/**
* Let the API know what kind of language do we accept and send
*/
this.$ComposeAPI
.setHeader('Accept-Language', user.meta.preferredLanguage)
.setHeader('Content-Language', user.meta.preferredLanguage)
}
// switch the webapp theme based on user preference
if (user.meta.theme) {
document.getElementsByTagName('html')[0].setAttribute('data-color-mode', user.meta.theme)
}
// Load effective permissions
this.$store.dispatch('rbac/load')
// Initialize notifications
this.$store.dispatch('notifications/fetchNotifications')
this.loaded = true
}).catch((err) => {
if (err instanceof Error && err.message === 'Unauthenticated') {
// user not logged-in,
// start with authentication flow
this.$auth.startAuthenticationFlow()
return
}
throw err
})
},
methods: {
/**
* Registers event listener for websocket messages and
* routes them depending on their type
*/
websocket () {
// cross-link auth & websocket so that ws can use the right access token
websocket.init(this)
// register event listener for messages
this.$on('websocket-message', ({ data }) => {
const msg = JSON.parse(data)
switch (msg['@type']) {
case 'notification':
this.$store.dispatch('notifications/addNotification', msg['@value'])
break
case 'notification.read':
this.$store.dispatch('notifications/updateReadNotification', msg['@value'])
break
case 'notification.unread':
this.$store.dispatch('notifications/updateUnreadNotification', msg['@value'])
break
case 'notification.read.all':
this.$store.dispatch('notifications/updateAllReadNotifications', msg['@value'])
break
case 'notification.unread.all':
this.$store.dispatch('notifications/updateAllUnreadNotifications', msg['@value'])
break
case 'notification.delete':
this.$store.dispatch('notifications/removeNotification', msg['@value'])
break
case 'error':
this.toastDanger('Websocket message with error', msg['@value'])
}
})
},
},
router,
store,
i18n: i18n(Vue,
{ app: 'corteza-webapp-reporter' },
'builder',
'create',
'datasources',
'display-element',
'edit',
'general',
'list',
'navigation',
'notification',
'notifications',
'permissions',
'scenarios',
'sidebar',
'view',
),
// Any additional options we want to merge
...options,
}
const app = new Vue(options)
// Simple HMR acceptance
if (module.hot) {
module.hot.accept()
}
return app
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

View File

@@ -0,0 +1,4 @@
export const Text = require('./Text.png')
export const Table = require('./Table.png')
export const Chart = require('./Chart.png')
export const Metric = require('./Metric.png')

View File

@@ -0,0 +1,5 @@
import * as report from './Report/C3'
export default {
...report,
}

View File

@@ -0,0 +1,111 @@
<template>
<div
class="d-flex"
>
<c-item-picker
:value.sync="selected"
:options="options"
:labels="{
searchPlaceholder: $t('list:searchPlaceholder'),
availableItems: $t('available-columns'),
selectAllItems: $t('select-all'),
selectedItems: $t('selected-columns'),
unselectAllItems: $t('unselect-all'),
noItemsFound: $t('no-columns-found'),
}"
style="max-height: 40vh;"
>
<template
#default="{ field }"
>
<b class="cursor-default text-dark">
<template
v-if="field.label"
>
{{ field.label }} ({{ field.name }})
</template>
<template
v-else
>
{{ field.name }}
</template>
</b>
</template>
</c-item-picker>
</div>
</template>
<script>
import { components } from '@cortezaproject/corteza-vue'
const { CItemPicker } = components
export default {
i18nOptions: {
namespaces: 'builder',
},
components: {
CItemPicker,
},
props: {
// source of fields
allItems: {
type: Array,
required: true,
},
// array of objects, list of all selected fields
selectedItems: {
type: Array,
required: true,
},
enableSorting: {
type: Boolean,
default: false,
},
},
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.selectedItems.map(({ name }) => name)
},
set (selected) {
this.$emit('update:selected-items', selected)
},
},
options () {
const fields = this.allItems
if (this.enableSorting) {
fields.sort((a, b) => a.label.localeCompare(b.label))
}
return [...fields,
].map(field => ({
value: field.name,
text: [
field.name,
field.label,
field.kind,
field.system ? 'system' : '',
].join(' '),
field,
}))
},
},
}
</script>

View File

@@ -0,0 +1,46 @@
<template>
<c-input-select
:value="value"
:options="columns"
:get-option-label="getColumnLabel"
:get-option-key="getOptionKey"
:placeholder="$t('general:label.none')"
:reduce="r => r.name"
append-to-body
v-on="$listeners"
/>
</template>
<script>
export default {
props: {
columns: {
type: Array,
required: true,
},
value: {
type: String,
default: '',
},
},
methods: {
getColumnLabel ({ name, label }) {
return `${label} (${name})`
},
getOptionKey ({ name }) {
return name
},
},
}
</script>
<style lang="scss">
.column-selector {
min-width: 15vw !important;
max-width: 150px !important;
}
</style>

View File

@@ -0,0 +1,108 @@
<template>
<b-container
fluid
class="p-0"
>
<b-row>
<b-col
cols="3"
>
<b-list-group>
<draggable
:list.sync="items"
:disabled="!draggable"
group="items"
handle=".grab"
>
<b-list-group-item
v-for="(item, index) in items"
:key="index"
button
:active="currentIndex ? currentIndex === index : index === 0"
class="item d-flex align-items-center justify-content-between"
@click="$emit('select', index)"
>
<slot
name="label"
:item="item"
/>
</b-list-group-item>
</draggable>
<b-list-group-item
button
class="text-primary rounded-top"
:class="{ 'border-top-0': items.length }"
@click="$emit('add')"
>
<font-awesome-icon
:icon="['fas', 'plus']"
size="sm"
class="mr-1"
/>
{{ $t('general:label.add') }}
</b-list-group-item>
</b-list-group>
</b-col>
<b-col
v-if="currentIndex !== undefined"
cols="9"
>
<slot
name="configurator"
/>
<c-input-confirm
variant="danger"
size="lg"
size-confirm="lg"
:borderless="false"
:text="$t('general:label.delete')"
class="d-flex"
@confirmed="$emit('delete')"
/>
</b-col>
</b-row>
</b-container>
</template>
<script>
import Draggable from 'vuedraggable'
export default {
components: {
Draggable,
},
props: {
items: {
type: Array,
required: true,
},
currentIndex: {
type: Number,
default: undefined,
},
draggable: {
type: Boolean,
default: false,
},
},
}
</script>
<style lang="scss">
.item {
.grab {
opacity: 0;
}
&:hover {
.grab {
opacity: 1;
}
}
}
</style>

View File

@@ -0,0 +1,456 @@
<template>
<c-form-table-wrapper hide-add-button>
<b-table-simple
v-if="render && filter.ref"
responsive
borderless
small
>
<template
v-for="(group, groupIndex) in filter.args"
>
<tr
v-for="(arg, argIndex) in group.args[0].args"
:key="`${groupIndex}-${argIndex}`"
>
<td
class="fit text-center align-middle pl-0"
>
<b-form-select
v-if="argIndex === 1"
v-model="group.args[0].ref"
:options="conditions"
class="w-auto"
@change="reRender()"
/>
<span
v-else
class="px-3"
style="min-width: 60px;"
>
{{ argIndex === 0 ? 'Where' : `${group.args[0].ref[0].toUpperCase() + group.args[0].ref.slice(1).toLowerCase()}` }}
</span>
</td>
<template v-if="Object.keys(arg).includes('raw')">
<td>
<b-input-group>
<b-input-group-prepend>
<b-button
variant="outline-primary"
class="d-flex justify-content-center align-items-center"
@click="toggleMode(groupIndex, argIndex)"
>
<font-awesome-icon
:icon="['fas', 'filter']"
size="sm"
/>
</b-button>
</b-input-group-prepend>
<b-form-input
v-model="arg.raw"
:placeholder="$t('builder:filter-expression')"
/>
</b-input-group>
</td>
</template>
<template v-else-if="group">
<td>
<b-input-group>
<b-input-group-prepend>
<b-button
variant="primary"
class="d-flex justify-content-center align-items-center"
@click="toggleMode(groupIndex, argIndex)"
>
<font-awesome-icon
:icon="['fas', 'filter']"
size="sm"
/>
</b-button>
</b-input-group-prepend>
<template v-if="group.args[0].args[argIndex].args[0].args[0].value && getColumnData(group.args[0].args[argIndex].args[0].args[1]).multivalue">
<column-selector
:columns="columns"
:value="group.args[0].args[argIndex].args[0].args[1].symbol"
@input="setType(groupIndex, argIndex, $event, group.args[0].args[argIndex].args[0].args[0].value['@value'])"
/>
<b-form-select
v-model="group.args[0].args[argIndex].args[0].ref"
:options="getOperators(getColumnData(group.args[0].args[argIndex].args[0].args[1]))"
class="border-left-0"
style="max-width: 120px;"
/>
<b-form-input
v-model="group.args[0].args[argIndex].args[0].args[0].value['@value']"
:placeholder="$t('builder:value')"
/>
</template>
<template v-else>
<column-selector
:value="group.args[0].args[argIndex].args[0].args[0].symbol"
:columns="columns"
@input="setType(groupIndex, argIndex, $event, group.args[0].args[argIndex].args[0].args[1].value['@value'])"
/>
<b-form-select
v-model="group.args[0].args[argIndex].args[0].ref"
:options="getOperators(getColumnData(group.args[0].args[argIndex].args[0].args[0]))"
class="border-left-0"
style="max-width: 120px;"
/>
<b-form-input
v-model="group.args[0].args[argIndex].args[0].args[1].value['@value']"
:placeholder="$t('builder:value')"
/>
</template>
</b-input-group>
</td>
</template>
<td
class="fit text-center align-middle pl-2 pr-0"
>
<c-input-confirm
show-icon
@confirmed="deleteFilter(groupIndex, argIndex)"
/>
</td>
</tr>
<tr
:key="`${groupIndex}-add`"
>
<td
class="fit align-middle pl-0"
:class="{ 'text-center': group.args[0].args && group.args[0].args.length }"
>
<b-button
variant="primary"
size="sm"
class="mt-1"
@click="addFilter(groupIndex)"
>
<font-awesome-icon
:icon="['fas', 'plus']"
size="sm"
class="mr-1"
/>
{{ $t('general:label.add') }}
</b-button>
</td>
</tr>
<tr
v-if="group.args[0].args && group.args[0].args.length"
:key="`${groupIndex}-addGroup`"
>
<td
colspan="100%"
class="p-0 filter-border text-center"
:class="{ 'pb-1': groupIndex < filter.args.length - 1 }"
>
<b-form-select
v-if="groupIndex < filter.args.length - 1"
v-model="filter.ref"
:options="conditions"
class="w-auto"
@change="reRender()"
/>
<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>
</td>
</tr>
</template>
</b-table-simple>
<tr
v-else
>
<b-button
variant="primary"
size="sm"
@click="initFilter()"
>
<font-awesome-icon
:icon="['fas', 'plus']"
size="sm"
class="mr-1"
/>
{{ $t('general:label.add') }}
</b-button>
</tr>
</c-form-table-wrapper>
</template>
<script>
import ColumnSelector from 'corteza-webapp-reporter/src/components/Common/ColumnSelector.vue'
export default {
components: {
ColumnSelector,
},
props: {
filter: {
type: Object,
required: true,
},
columns: {
type: Array,
default: () => [],
},
},
data () {
return {
render: true,
defaultFilter: {
ref: 'group',
// raw: ''
args: [{
ref: 'eq',
args: [
{ symbol: '' },
{ value: { '@type': 'String', '@value': '' } },
],
}],
},
conditions: [
{ value: 'and', text: this.$t('general:label.and') },
{ value: 'or', text: this.$t('general:label.or') },
],
operators: [
{
value: 'eq',
text: this.$t('builder:filter.operators.equal'),
isMulti: false,
},
{
value: 'ne',
text: this.$t('builder:filter.operators.notEqual'),
isMulti: false,
},
{
value: 'lt',
text: this.$t('builder:filter.operators.lessThan'),
isMulti: false,
},
{
value: 'le',
text: this.$t('builder:filter.operators.lessThanEqualTo'),
isMulti: false,
},
{
value: 'gt',
text: this.$t('builder:filter.operators.greaterThan'),
isMulti: false,
},
{
value: 'ge',
text: this.$t('builder:filter.operators.greaterThanEqualTo'),
isMulti: false,
},
{
value: 'in',
text: this.$t('builder:filter.operators.contains'),
isMulti: true,
},
{
value: 'nin',
text: this.$t('builder:filter.operators.notContains'),
isMulti: true,
},
],
}
},
methods: {
initFilter () {
this.filter.ref = 'and'
this.filter.args = []
this.addGroup()
},
addGroup () {
if (this.filter.args) {
this.filter.args.push({
ref: 'group',
args: [
{
ref: 'or',
args: [{
ref: 'group',
// raw: ''
args: [{
ref: 'eq',
args: [
{ symbol: '' },
{ value: { '@type': '', '@value': '' } },
],
}],
}],
},
],
})
}
this.reRender()
},
addFilter (groupIndex) {
if (!this.filter.args[groupIndex].args[0].args) {
this.filter.args[groupIndex].args[0].args = []
}
this.filter.args[groupIndex].args[0].args.push({
ref: 'group',
// raw: ''
args: [{
ref: 'eq',
args: [
{ symbol: '' },
{ value: { '@type': '', '@value': '' } },
],
}],
})
this.reRender()
},
deleteFilter (groupIndex, argIndex) {
const { args } = this.filter.args[groupIndex].args[0]
if (args) {
// If last group and last filter, set filter to default
if (this.filter.args.length === 1 && args.length === 1) {
delete this.filter.ref
delete this.filter.args
} else if (args.length === 1) {
// If only one left in group, remove group
this.filter.args.splice(groupIndex, 1)
} else {
// Remove filter from group
args.splice(argIndex, 1)
}
}
this.reRender()
},
toggleMode (groupIndex, argIndex) {
const { args } = this.filter.args[groupIndex].args[0]
if (args[argIndex]) {
if (Object.keys(args[argIndex]).includes('raw')) {
args[argIndex].args = [{
ref: 'eq',
args: [
{ symbol: '' },
{ value: { '@type': '', '@value': '' } },
],
}]
delete args[argIndex].raw
} else {
args[argIndex].raw = ''
delete args[argIndex].args
}
this.reRender()
}
},
setType (groupIndex, argIndex, symbol, value) {
if (!this.filter.args[groupIndex].args[0].args[argIndex]) {
return
}
// Get type
const { kind = '', multivalue } = this.columns.find(({ name }) => name === symbol) || {}
// Set type
if (multivalue) {
this.filter.args[groupIndex].args[0].args[argIndex].args[0].args[0] = { value: { '@type': kind, '@value': value } }
this.filter.args[groupIndex].args[0].args[argIndex].args[0].args[1] = { symbol }
this.filter.args[groupIndex].args[0].args[argIndex].args[0].ref = 'in'
} else {
this.filter.args[groupIndex].args[0].args[argIndex].args[0].args[0] = { symbol }
this.filter.args[groupIndex].args[0].args[argIndex].args[0].args[1] = { value: { '@type': kind, '@value': value } }
this.filter.args[groupIndex].args[0].args[argIndex].args[0].ref = 'eq'
}
this.reRender()
},
reRender () {
this.render = false
this.$nextTick().then(() => {
this.render = true
})
},
getColumnData (group) {
return this.columns.find(({ name }) => name === group.symbol)
},
getOperators (column) {
return column ? this.operators.filter(value => value.isMulti === column.multivalue) : this.operators
},
},
}
</script>
<style lang="scss" scoped>
.table td.fit,
.table th.fit {
white-space: nowrap;
width: 1%;
}
.btn-add-group {
&:hover, &:active {
background-color: var(--primary) !important;
color: var(--white) !important;
}
}
.filter-border {
background-image: linear-gradient(to left, lightgray, lightgray);
background-repeat: no-repeat;
background-size: 100% 1px;
background-position: center;
}
</style>
<style lang="scss">
.prefilter .column-selector {
.vs__dropdown-toggle {
border-right: 0;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
}
</style>

View File

@@ -0,0 +1,76 @@
<template>
<b-container fluid>
<b-row>
<b-col
cols="3"
>
<b-list-group>
<b-list-group-item
v-for="element in items"
:key="element.kind"
button
class="text-truncate"
@mouseover="currentValue = element.value"
@click="$emit('select', element.kind)"
>
{{ element.label }}
</b-list-group-item>
</b-list-group>
</b-col>
<b-col
v-if="currentValue"
cols="9"
:class="{ 'my-auto': displayMode === 'image' }"
>
<b-img
v-if="displayMode === 'image'"
fluid
thumbnail
:src="currentValue"
/>
<p
v-else-if="displayMode === 'text'"
>
{{ currentValue }}
</p>
</b-col>
</b-row>
</b-container>
</template>
<script>
export default {
props: {
// { label, kind, value }
items: {
type: Array,
required: true,
},
// image or text
displayMode: {
type: String,
default: 'image',
},
},
data () {
return {
currentValue: undefined,
}
},
watch: {
items: {
immediate: true,
handler (items = []) {
const { value } = items[0]
if (value) {
this.currentValue = value
}
},
},
},
}
</script>

View File

@@ -0,0 +1,115 @@
<template>
<c-toolbar
class="bg-white shadow border-top"
>
<template #start>
<b-button
v-if="backLink"
data-test-id="button-back"
variant="link"
:to="backLink"
class="d-flex align-items-center text-dark back text-nowrap gap-1 text-decoration-none"
>
<font-awesome-icon
:icon="['fas', 'chevron-left']"
class="back-icon"
/>
{{ $t('general:label.back') }}
</b-button>
</template>
<template #center>
<slot />
</template>
<template #end>
<c-input-confirm
v-if="!hideDelete"
size="lg"
size-confirm="lg"
variant="danger"
:disabled="deleteDisabled || processingDelete || processing"
:processing="processingDelete"
:text="$t('general:label.delete')"
:borderless="false"
@confirmed="$emit('delete')"
/>
<c-button-submit
data-test-id="button-clone"
:disabled="cloneDisabled || processingClone || processing"
:processing="processingClone"
variant="light"
:text="$t('general:label.clone')"
class="text-nowrap"
size="lg"
@submit="$emit('clone')"
/>
<c-button-submit
data-test-id="button-save"
:disabled="saveDisabled || processingSave || processing"
:processing="processingSave"
:text="$t('general:label.save')"
size="lg"
@submit="$emit('save')"
/>
</template>
</c-toolbar>
</template>
<script>
import { components } from '@cortezaproject/corteza-vue'
const { CToolbar } = components
export default {
components: {
CToolbar,
},
props: {
backLink: {
type: Object,
required: false,
default: () => ({ name: 'root' }),
},
hideDelete: {
type: Boolean,
},
deleteDisabled: {
type: Boolean,
},
hideSave: {
type: Boolean,
},
saveDisabled: {
type: Boolean,
},
cloneDisabled: {
type: Boolean,
},
processing: {
type: Boolean,
},
processingDelete: {
type: Boolean,
},
processingSave: {
type: Boolean,
required: false,
},
processingClone: {
type: Boolean,
},
},
}
</script>

View File

@@ -0,0 +1,804 @@
<template>
<div>
<div
class="mb-3"
>
<h5 class="text-primary mb-2">
{{ $t('chart.configurator.general') }}
</h5>
<b-row
align-v="stretch"
>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('chart.configurator.type')"
label-class="text-primary"
>
<b-form-select
:value="displayElementOptions.type"
:options="chartTypes"
@change="typeChanged"
/>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('chart.configurator.chart-title')"
label-class="text-primary"
>
<b-form-input
v-model="options.title"
/>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('chart.configurator.color-scheme')"
label-class="text-primary"
class="mb-0"
>
<c-input-select
v-model="options.colorScheme"
:options="colorSchemes"
:get-option-key="getOptionKey"
:reduce="cs => cs.value"
:placeholder="$t('general:label.default')"
>
<template #option="option">
<p
class="mb-1"
>
{{ option.label }}
</p>
<div
v-for="(color, index) in option.colors"
:key="`${option.value}-${index}`"
:style="`background: ${color};`"
class="d-inline-block color-box mr-1 mb-1"
/>
</template>
</c-input-select>
<template
v-if="currentColorScheme"
>
<div
v-for="(color, index) in currentColorScheme.colors"
:key="`${currentColorScheme.value}-${index}`"
:style="`background: ${color};`"
class="d-inline-block color-box mr-1"
/>
</template>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
class="d-flex flex-column justify-content-center"
>
<b-form-checkbox
v-model="options.noAnimation"
:value="false || undefined"
:unchecked-value="true"
switch
class="mt-3 pt-2"
>
{{ $t('chart.configurator.animation.enabled') }}
</b-form-checkbox>
</b-col>
</b-row>
<hr>
</div>
<div
v-if="options.source"
class="mb-3"
>
<h5 class="text-primary mb-2">
{{ $t('chart.configurator.data') }}
</h5>
<b-form-group
v-if="options.labelColumn !== undefined"
:label="$t('chart.configurator.label-column')"
label-class="text-primary"
>
<column-selector
v-model="options.labelColumn"
:columns="labelColumns"
style="min-width: 100% !important;"
/>
</b-form-group>
<b-form-group
v-if="options.dataColumns && columns.length"
:label="$t('chart.configurator.data-columns')"
label-class="text-primary"
>
<column-picker
:all-items="dataColumns"
:selected-items="options.dataColumns"
class="d-flex flex-column"
@update:selected-items="updateDataColumns"
/>
</b-form-group>
<div
v-if="['bar', 'line'].includes(options.type)"
>
<hr>
<h5 class="text-primary mb-2">
{{ $t('chart.configurator.metrics.label') }}
</h5>
<b-form-group
v-if="options.dataColumns && options.dataColumns.length"
label-class="text-primary"
>
<c-form-table-wrapper
v-for="(column, index) in metricColumns"
:key="index"
hide-add-button
class="mb-3"
>
<h6 class="mb-3">
{{ column.label }}
</h6>
<b-row>
<b-col
cols="12"
md="6"
>
<b-form-group
:label="$t('chart.configurator.metrics.label-field.label')"
label-class="text-primary"
class="mb-0"
>
<b-form-input
v-model="column.label"
/>
</b-form-group>
</b-col>
<b-col
cols="12"
md="6"
>
<b-form-group
:label="$t('chart.configurator.metrics.stack.label')"
:description="$t('chart.configurator.metrics.stack.description')"
label-class="text-primary"
class="mb-0"
>
<b-form-input
v-model="column.stack"
/>
</b-form-group>
</b-col>
</b-row>
</c-form-table-wrapper>
</b-form-group>
</div>
<div
v-if="['bar', 'line'].includes(options.type)"
>
<hr>
<div
class="mb-3"
>
<h5 class="text-primary mb-2">
{{ $t('chart.configurator.x-axis.name') }}
</h5>
<b-row>
<b-col>
<b-form-group
:label="$t('chart.configurator.x-axis.label')"
label-class="text-primary"
>
<b-form-input
v-model="options.xAxis.label"
/>
</b-form-group>
</b-col>
<b-col>
<b-form-group
:label="$t('chart.configurator.x-axis.type')"
label-class="text-primary"
>
<b-form-select
v-model="options.xAxis.type"
:options="AxisTypes"
>
<template #first>
<b-form-select-option
value=""
>
{{ $t('chart.configurator.default') }}
</b-form-select-option>
</template>
</b-form-select>
</b-form-group>
</b-col>
</b-row>
<b-row>
<b-col>
<b-form-group
:label="$t('chart.configurator.default-value')"
label-class="text-primary"
class="mb-1"
>
<b-form-input
v-model="options.xAxis.defaultValue"
:disabled="options.xAxis.skipMissing"
:type="options.xAxis.type === 'time' ? 'date' : 'text'"
/>
</b-form-group>
<b-form-checkbox
v-model="options.xAxis.skipMissing"
class="mb-3"
>
{{ $t('chart.configurator.skip-missing-values') }}
</b-form-checkbox>
</b-col>
<b-col>
<b-form-group
:label="$t('chart.configurator.x-axis.labelRotation.label')"
label-class="text-primary"
class="mb-1"
>
<b-input
v-model="options.xAxis.labelRotation"
type="number"
number
/>
</b-form-group>
</b-col>
</b-row>
</div>
<hr>
<div>
<h5 class="text-primary mb-2">
{{ $t('chart.configurator.y-axis.name') }}
</h5>
<b-row>
<b-col>
<b-form-group
:label="$t('chart.configurator.y-axis.label')"
label-class="text-primary"
>
<b-form-input
v-model="options.yAxis.label"
/>
</b-form-group>
</b-col>
<b-col>
<b-form-group
:label="$t('chart.configurator.y-axis.labelPosition.label')"
label-class="text-primary"
>
<b-form-select
v-model="options.yAxis.labelPosition"
:options="axisLabelPositions"
/>
</b-form-group>
</b-col>
<b-col>
<b-form-group
:label="$t('chart.configurator.y-axis.labelRotation.label')"
label-class="text-primary"
class="mb-1"
>
<b-input
v-model="options.yAxis.labelRotation"
type="number"
number
/>
</b-form-group>
</b-col>
</b-row>
<b-row>
<b-col>
<b-form-group
:label="$t('chart.configurator.value.min')"
label-class="text-primary"
>
<b-form-input
v-model="options.yAxis.min"
type="number"
number
/>
</b-form-group>
</b-col>
<b-col>
<b-form-group
:label="$t('chart.configurator.value.max')"
label-class="text-primary"
>
<b-form-input
v-model="options.yAxis.max"
type="number"
/>
</b-form-group>
</b-col>
<b-col>
<b-form-group
:label="$t('chart.configurator.step-size')"
label-class="text-primary"
>
<b-form-input
v-model="options.yAxis.stepSize"
/>
</b-form-group>
</b-col>
</b-row>
<b-row>
<b-col>
<b-form-group>
<b-form-checkbox
v-model="options.yAxis.type"
value="logarithmic"
unchecked-value="linear"
>
{{ $t('chart.configurator.logarithmic-scale') }}
</b-form-checkbox>
<b-form-checkbox
v-model="options.yAxis.beginAtZero"
>
{{ $t('chart.configurator.begin-axis-at-zero') }}
</b-form-checkbox>
<b-form-checkbox
v-model="options.yAxis.position"
value="right"
unchecked-value="left"
>
{{ $t('chart.configurator.place-axis-on-right-side') }}
</b-form-checkbox>
</b-form-group>
</b-col>
</b-row>
</div>
</div>
<hr>
<div>
<h5 class="text-primary mb-2">
{{ $t('chart.configurator.legend.name') }}
</h5>
<b-form-checkbox
v-model="options.legend.hide"
class="mb-3"
>
{{ $t('chart.configurator.legend.hide') }}
</b-form-checkbox>
<b-row v-if="!options.legend.hide">
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('chart.configurator.legend.align.label')"
label-class="text-primary"
class="mb-1"
>
<b-form-select
v-model="options.legend.align"
:options="alignments"
:disabled="!options.legend.position.default"
/>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('chart.configurator.legend.orientation.label')"
label-class="text-primary"
class="mb-1"
>
<b-form-select
v-model="options.legend.orientation"
:options="orientations"
/>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-checkbox
v-model="options.legend.position.default"
:class="{ 'mb-3': !options.legend.position.default }"
>
{{ $t('chart.configurator.legend.position.default') }}
</b-form-checkbox>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-checkbox
v-model="options.legend.scrollable"
:disabled="options.legend.orientation !== 'horizontal'"
>
{{ $t('chart.configurator.legend.scrollable') }}
</b-form-checkbox>
</b-col>
<template
v-if="!options.legend.position.default"
>
<b-col
cols="12"
lg="6"
xl="3"
>
<b-form-group
:label="$t('chart.configurator.legend.position.top')"
label-class="text-primary"
>
<b-input
v-model="options.legend.position.top"
/>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
xl="3"
>
<b-form-group
:label="$t('chart.configurator.legend.position.right')"
label-class="text-primary"
>
<b-input
v-model="options.legend.position.right"
/>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
xl="3"
>
<b-form-group
:label="$t('chart.configurator.legend.position.bottom')"
label-class="text-primary"
>
<b-input
v-model="options.legend.position.bottom"
/>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
xl="3"
>
<b-form-group
:label="$t('chart.configurator.legend.position.left')"
label-class="text-primary"
>
<b-input
v-model="options.legend.position.left"
/>
</b-form-group>
</b-col>
<b-col>
<small>{{ $t('chart.configurator.position-description') }}</small>
</b-col>
</template>
</b-row>
</div>
<hr>
<div>
<h5 class="text-primary mb-2">
{{ $t('chart.configurator.tooltips.name') }}
</h5>
<b-row>
<b-col>
<b-form-checkbox
v-model="options.tooltips.showAlways"
>
{{ $t('chart.configurator.tooltips.show.always') }}
</b-form-checkbox>
</b-col>
</b-row>
</div>
<hr>
<div class="mb-2">
<h5 class="text-primary mb-2">
{{ $t('chart.configurator.offset.name') }}
</h5>
<b-form-checkbox
v-model="options.offset.default"
class="mb-3"
>
{{ $t('chart.configurator.offset.default') }}
</b-form-checkbox>
<b-row v-if="!options.offset.default">
<b-col
cols="12"
lg="6"
xl="3"
>
<b-form-group
:label="$t('chart.configurator.offset.top')"
label-class="text-primary"
>
<b-input
v-model="options.offset.top"
/>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
xl="3"
>
<b-form-group
:label="$t('chart.configurator.offset.right')"
label-class="text-primary"
>
<b-input
v-model="options.offset.right"
/>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
xl="3"
>
<b-form-group
:label="$t('chart.configurator.offset.bottom')"
label-class="text-primary"
>
<b-input
v-model="options.offset.bottom"
/>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
xl="3"
>
<b-form-group
:label="$t('chart.configurator.offset.left')"
label-class="text-primary"
>
<b-input
v-model="options.offset.left"
/>
</b-form-group>
</b-col>
<b-col
cols="12"
>
<small>{{ $t('chart.configurator.position-description') }}</small>
</b-col>
</b-row>
</div>
</div>
</div>
</template>
<script>
import base from './base'
import ColumnSelector from 'corteza-webapp-reporter/src/components/Common/ColumnSelector.vue'
import ColumnPicker from 'corteza-webapp-reporter/src/components/Common/ColumnPicker'
import { reporter, shared } from '@cortezaproject/corteza-js'
const { colorschemes } = shared
export default {
i18nOptions: {
namespaces: 'display-element',
},
name: 'ChartConfigurator',
components: {
ColumnSelector,
ColumnPicker,
},
extends: base,
data () {
return {
colorSchemes: [],
allowedLabelKinds: [
'Date',
'DateTime',
'Select',
'Number',
'Bool',
'String',
'Record',
'User',
],
axisLabelPositions: [
{ value: 'end', text: this.$t('chart.configurator.y-axis.labelPosition.top') },
{ value: 'center', text: this.$t('chart.configurator.y-axis.labelPosition.center') },
{ value: 'start', text: this.$t('chart.configurator.y-axis.labelPosition.bottom') },
],
orientations: [
{ value: 'horizontal', text: this.$t('chart.configurator.legend.orientation.horizontal') },
{ value: 'vertical', text: this.$t('chart.configurator.legend.orientation.vertical') },
],
alignments: [
{ value: 'left', text: this.$t('chart.configurator.legend.align.left') },
{ value: 'center', text: this.$t('chart.configurator.legend.align.center') },
{ value: 'right', text: this.$t('chart.configurator.legend.align.right') },
],
}
},
computed: {
mergedColumns () {
return [].concat.apply([], this.columns)
},
chartTypes () {
const types = [
{ value: 'bar', text: this.$t('chart.configurator.types.bar') },
{ value: 'line', text: this.$t('chart.configurator.types.line') },
{ value: 'pie', text: this.$t('chart.configurator.types.pie') },
{ value: 'doughnut', text: this.$t('chart.configurator.types.doughnut') },
]
if (this.datasource && this.datasource.step.aggregate) {
types.push({ value: 'funnel', text: this.$t('chart.configurator.types.funnel') })
}
return types
},
AxisTypes () {
return [
{ value: 'time', text: this.$t('chart.configurator.time.label') },
{ value: 'category', text: 'Category' },
]
},
timeUnits () {
return [
{ value: 'day', text: this.$t('chart.configurator.time.unit.types.date') },
{ value: 'week', text: this.$t('chart.configurator.time.unit.types.week') },
{ value: 'month', text: this.$t('chart.configurator.time.unit.types.month') },
{ value: 'quarter', text: this.$t('chart.configurator.time.unit.types.quarter') },
{ value: 'year', text: this.$t('chart.configurator.time.unit.types.year') },
]
},
labelColumns () {
const columns = this.columns.length ? this.columns[0] : []
return columns.filter(({ kind }) => this.allowedLabelKinds.includes(kind))
},
dataColumns () {
return [
...this.mergedColumns.filter(({ kind }) => kind === 'Number'),
].sort((a, b) => a.label.localeCompare(b.label))
},
currentColorScheme () {
return this.colorSchemes.find(({ value }) => value === this.options.colorScheme)
},
metricColumns () {
return this.options.dataColumns
},
},
created () {
const capitalize = w => `${w[0].toUpperCase()}${w.slice(1)}`
const splicer = sc => {
const rr = (/(\D+)(\d+)$/gi).exec(sc)
return {
label: rr[1],
count: rr[2],
}
}
const rr = []
for (const g in colorschemes) {
for (const sc in colorschemes[g]) {
const gn = splicer(sc)
rr.push({
label: `${capitalize(g)}: ${capitalize(gn.label)}`,
colors: [...colorschemes[g][sc]],
value: `${g}.${sc}`,
})
}
}
this.colorSchemes = rr
},
methods: {
updateDataColumns (columns) {
const result = columns.map((c) => this.dataColumns.find(({ name }) => name === c))
result.forEach((c, idx) => {
const initialCol = this.options.dataColumns.find(({ name }) => name === c.name)
if (initialCol) result[idx] = initialCol
})
this.options.dataColumns = result
},
setColorscheme (colorscheme) {
this.options.colorScheme = (colorscheme || {}).value || ''
},
typeChanged (type) {
this.options = reporter.ChartOptionsMaker({ ...this.options, type })
},
getOptionKey ({ value }) {
return value
},
},
}
</script>
<style lang="scss" scoped>
.color-box {
width: 28px;
height: 12px;
}
</style>

View File

@@ -0,0 +1,124 @@
<template>
<div>
<b-form-group
v-if="options.valueColumn !== undefined"
:label="$t('label-column')"
label-class="text-primary"
>
<column-selector
v-model="options.valueColumn"
:columns="valueColumns"
style="min-width: 100% !important;"
/>
</b-form-group>
<b-row>
<b-col>
<b-form-group
:label="$t('format')"
label-class="text-primary"
>
<b-form-input
v-model="options.format"
placeholder="0.00"
/>
</b-form-group>
</b-col>
<b-col>
<b-form-group
:label="$t('prefix')"
label-class="text-primary"
>
<b-form-input
v-model="options.prefix"
placeholder="$"
/>
</b-form-group>
</b-col>
<b-col>
<b-form-group
:label="$t('suffix')"
label-class="text-primary"
>
<b-form-input
v-model="options.suffix"
placeholder="USD/mo"
/>
</b-form-group>
</b-col>
</b-row>
<b-row>
<b-col>
<b-form-group
:label="$t('color.text')"
label-class="text-primary"
>
<c-input-color-picker
v-model="options.color"
:translations="{
modalTitle: $t('color.picker'),
light: $t('general:themes.labels.light'),
dark: $t('general:themes.labels.dark'),
cancelBtnLabel: $t('general:label.cancel'),
saveBtnLabel: $t('general:label.saveAndClose')
}"
:theme-settings="themeSettings"
/>
</b-form-group>
</b-col>
<b-col>
<b-form-group
:label="$t('color.background')"
label-class="text-primary"
>
<c-input-color-picker
v-model="options.backgroundColor"
:translations="{
modalTitle: $t('color.picker'),
light: $t('general:themes.labels.light'),
dark: $t('general:themes.labels.dark'),
cancelBtnLabel: $t('general:label.cancel'),
saveBtnLabel: $t('general:label.saveAndClose')
}"
:theme-settings="themeSettings"
/>
</b-form-group>
</b-col>
</b-row>
</div>
</template>
<script>
import base from './base'
import ColumnSelector from 'corteza-webapp-reporter/src/components/Common/ColumnSelector.vue'
import { components } from '@cortezaproject/corteza-vue'
const { CInputColorPicker } = components
export default {
i18nOptions: {
namespaces: 'display-element',
keyPrefix: 'metric.configurator',
},
components: {
ColumnSelector,
CInputColorPicker,
},
extends: base,
computed: {
valueColumns () {
const columns = this.columns.length ? this.columns[0] : []
return [
...columns.filter(({ kind }) => ['Number'].includes(kind)),
].sort((a, b) => a.label.localeCompare(b.label))
},
themeSettings () {
return this.$Settings.get('ui.studio.themes', [])
},
},
}
</script>

View File

@@ -0,0 +1,232 @@
<template>
<div
v-if="options"
>
<div
class="mb-3"
>
<h5 class="text-primary mb-2">
{{ $t('display-element:table.configurator.general') }}
</h5>
<b-row no-gutters>
<b-col class="pr-3">
<b-form-group
:label="$t('display-element:table.configurator.table.variant')"
label-class="text-primary"
>
<b-form-select
v-model="options.tableVariant"
:options="tableVariants"
/>
</b-form-group>
</b-col>
<b-col>
<b-form-group
:label="$t('display-element:table.configurator.head-variant')"
label-class="text-primary"
>
<b-form-radio-group
v-model="options.headVariant"
class="mt-lg-2"
>
<b-form-radio
:value="null"
inline
>
{{ $t('display-element:table.configurator.none') }}
</b-form-radio>
<b-form-radio
value="light"
inline
>
{{ $t('display-element:table.configurator.light') }}
</b-form-radio>
<b-form-radio
value="dark"
inline
>
{{ $t('display-element:table.configurator.dark') }}
</b-form-radio>
</b-form-radio-group>
</b-form-group>
</b-col>
</b-row>
<b-form-group
:label="$t('display-element:table.configurator.table.options.label')"
label-class="text-primary"
>
<b-form-checkbox
v-model="options.striped"
inline
>
{{ $t('display-element:table.configurator.table.options.striped') }}
</b-form-checkbox>
<b-form-checkbox
v-model="options.bordered"
inline
>
{{ $t('display-element:table.configurator.table.options.bordered') }}
</b-form-checkbox>
<b-form-checkbox
v-model="options.borderless"
inline
>
{{ $t('display-element:table.configurator.table.options.borderless') }}
</b-form-checkbox>
<b-form-checkbox
v-model="options.small"
inline
>
{{ $t('display-element:table.configurator.table.options.small') }}
</b-form-checkbox>
<b-form-checkbox
v-model="options.hover"
inline
>
{{ $t('display-element:table.configurator.table.options.hover') }}
</b-form-checkbox>
<b-form-checkbox
v-model="options.dark"
inline
>
{{ $t('display-element:table.configurator.table.options.dark') }}
</b-form-checkbox>
<b-form-checkbox
v-model="options.responsive"
inline
>
{{ $t('display-element:table.configurator.table.options.responsive') }}
</b-form-checkbox>
<b-form-checkbox
v-model="options.fixed"
inline
>
{{ $t('display-element:table.configurator.table.options.fixed') }}
</b-form-checkbox>
<b-form-checkbox
v-model="options.noCollapse"
inline
>
{{ $t('display-element:table.configurator.table.options.no-collapse') }}
</b-form-checkbox>
</b-form-group>
</div>
<hr>
<div
class="mb-3"
>
<h5 class="text-primary mb-2">
{{ $t('display-element:table.configurator.data') }}
</h5>
<b-form-group
v-if="options.datasources.length > 1"
:label="$t('display-element:table.configurator.joined-datasource-handling')"
label-class="text-primary"
>
<b-form-select
v-model="currentConfigurableDatasourceName"
:options="options.datasources"
text-field="name"
value-field="name"
/>
</b-form-group>
<b-form-group
v-if="currentConfigurableDatasourceName && currentColumns.length"
:label="$t('display-element:table.configurator.columns')"
label-class="text-primary"
>
<column-picker
:all-items="currentColumns"
:selected-items="currentSelectedColumns"
class="d-flex flex-column"
@update:selected-items="updateSelectedColumns"
/>
</b-form-group>
</div>
</div>
</template>
<script>
import base from './base'
import ColumnPicker from 'corteza-webapp-reporter/src/components/Common/ColumnPicker'
export default {
components: {
ColumnPicker,
},
extends: base,
data () {
return {
currentConfigurableDatasourceName: undefined,
}
},
computed: {
tableVariants () {
return [
{ value: '', text: this.$t('display-element:table.configurator.none') },
{ value: 'primary', text: this.$t('display-element:table.configurator.table.variants.primary') },
{ value: 'secondary', text: this.$t('display-element:table.configurator.table.variants.secondary') },
{ value: 'info', text: this.$t('display-element:table.configurator.table.variants.info') },
{ value: 'danger', text: this.$t('display-element:table.configurator.table.variants.danger') },
{ value: 'warning', text: this.$t('display-element:table.configurator.table.variants.warning') },
{ value: 'success', text: this.$t('display-element:table.configurator.table.variants.success') },
{ value: 'light', text: this.$t('display-element:table.configurator.table.variants.light') },
{ value: 'dark', text: this.$t('display-element:table.configurator.table.variants.dark') },
]
},
currentColumns () {
if (this.currentConfigurableDatasourceName && this.columns) {
const datasourceIndex = this.options.datasources.findIndex(ds => ds.name === this.currentConfigurableDatasourceName)
if (datasourceIndex >= 0) {
return this.columns[datasourceIndex] || []
}
}
return []
},
currentSelectedColumns: {
get () {
return this.currentConfigurableDatasourceName ? this.options.columns[this.currentConfigurableDatasourceName] : []
},
set (columns) {
if (this.currentConfigurableDatasourceName) {
this.$set(this.options.columns, this.currentConfigurableDatasourceName, columns || [])
}
},
},
},
watch: {
'options.datasources': {
immediate: true,
handler (datasources) {
datasources.forEach(({ name }) => {
this.$set(this.options.columns, name, this.options.columns[name] || [])
})
this.currentConfigurableDatasourceName = (datasources[0] || {}).name
},
},
},
methods: {
updateSelectedColumns (columns) {
this.currentSelectedColumns = columns.map((c) => {
return this.currentColumns.find(({ name }) => name === c)
})
},
},
}
</script>

View File

@@ -0,0 +1,30 @@
<template>
<b-form-group>
<c-rich-text-input
v-if="options"
v-model="options.value"
:labels="{
urlPlaceholder: $t('label.urlPlaceholder'),
ok: $t('label.ok'),
}"
/>
</b-form-group>
</template>
<script>
import base from './base'
import { components } from '@cortezaproject/corteza-vue'
const { CRichTextInput } = components
export default {
i18nOptions: {
namespaces: 'general',
},
components: {
CRichTextInput,
},
extends: base,
}
</script>

View File

@@ -0,0 +1,32 @@
<script>
export default {
props: {
displayElementOptions: {
type: Object,
default: () => ({}),
},
columns: {
type: Array,
required: true,
},
datasource: {
type: Object,
default: undefined,
},
},
computed: {
options: {
get () {
return this.displayElementOptions || undefined
},
set (options = {}) {
this.$emit('update:displayElementOptions', options)
},
},
},
}
</script>

View File

@@ -0,0 +1,309 @@
<template>
<div
v-if="displayElement"
>
<b-form-group
:label="$t('general:label.name')"
label-class="text-primary"
>
<b-form-input
v-model="displayElement.name"
/>
</b-form-group>
<b-button
v-b-toggle.datasources
block
:disabled="!usesDatasources"
variant="primary"
class="mb-2"
>
{{ $t('builder:datasources.label') }}
</b-button>
<b-collapse
v-if="usesDatasources"
id="datasources"
:visible="usesDatasources"
accordion
>
<b-form-group
:label="$t('builder:datasources.label')"
label-class="text-primary"
>
<b-form-select
v-model="options.source"
:options="sources"
@change="setConfigurableSources"
/>
</b-form-group>
<div
v-if="currentConfigurableDatasourceName"
>
<b-form-group
v-if="hasMultipleConfigurableDatasources"
:label="$t('builder:joined-datasource-handling')"
label-class="text-primary"
>
<b-form-select
v-model="currentConfigurableDatasourceName"
:options="options.datasources"
text-field="name"
value-field="name"
@change="configurableDatasourceChanged"
/>
</b-form-group>
<div
v-if="currentConfigurableDatasourceIndex >= 0"
>
<b-form-group
v-if="columns.length"
:label="$t('builder:prefilter')"
label-class="text-primary"
>
<prefilter
:filter.sync="options.datasources[currentConfigurableDatasourceIndex].filter"
:columns="columns[currentConfigurableDatasourceIndex]"
/>
</b-form-group>
<b-form-group
v-if="columns.length"
:label="$t('builder:presort-order')"
label-class="text-primary"
>
<c-input-presort
v-model="options.datasources[currentConfigurableDatasourceIndex].sort"
:fields="columns[currentConfigurableDatasourceIndex]"
:labels="{
add: $t('general:label.add'),
ascending: $t('general:label.ascending'),
descending: $t('general:label.descending'),
none: $t('general:label.none'),
addButton: $t('general:label.add'),
}"
/>
</b-form-group>
<b-form-group
v-if="displayElement.kind === 'Table'"
:label="$t('builder:limit')"
label-class="text-primary"
>
<b-form-input
v-model="pagingLimit"
type="number"
/>
</b-form-group>
</div>
</div>
</b-collapse>
<b-button
v-b-toggle.display
block
:disabled="!showDisplayElementConfigurator"
variant="primary"
class="mb-2"
>
{{ $t('builder:element') }}
</b-button>
<b-collapse
id="display"
:visible="showDisplayElementConfigurator"
accordion
>
<component
:is="displayElementConfigurator"
:display-element-options.sync="options"
:columns="columns"
:datasource="currentDatasource"
/>
</b-collapse>
</div>
</template>
<script>
import getDisplayElementConfigurator from './loader'
import Prefilter from 'corteza-webapp-reporter/src/components/Common/Prefilter'
import { components } from '@cortezaproject/corteza-vue'
const { CInputPresort } = components
export default {
components: {
Prefilter,
CInputPresort,
},
props: {
displayElement: {
type: Object,
required: true,
},
block: {
type: Object,
required: true,
},
datasources: {
type: Array,
required: true,
},
},
data () {
return {
columns: [],
currentConfigurableDatasourceName: undefined,
currentConfigurableDatasourceIndex: undefined,
}
},
computed: {
usesDatasources () {
return !['Text'].includes(this.displayElement.kind)
},
displayElementConfigurator () {
return getDisplayElementConfigurator(this.displayElement.kind)
},
showDisplayElementConfigurator () {
return this.usesDatasources ? !!this.currentDatasource : true
},
sources () {
const options = [{ value: '', text: this.$t('general:label.none') }]
this.datasources.forEach(({ step }, index) => {
Object.values(step).forEach(({ name }) => {
options.push({ value: name || `${index}`, text: name || `${index}` })
})
})
return options
},
currentDatasource () {
if (this.options.source) {
return this.datasources.find(({ step: { load = {}, link = {}, join = {}, aggregate = {} } }) => [load.name, link.name, join.name, aggregate.name].includes(this.options.source))
}
return undefined
},
hasMultipleConfigurableDatasources () {
return this.currentDatasource && this.currentDatasource.step.link && this.options.datasources.length > 1
},
options: {
get () {
return this.displayElement ? this.displayElement.options : undefined
},
set (options = {}) {
if (this.displayElement) {
this.$emit('update:displayElement', { ...this.displayElement, options })
}
},
},
pagingLimit: {
get () {
let limit = 0
if (this.currentConfigurableDatasourceIndex >= 0) {
const { paging = {} } = this.options.datasources[this.currentConfigurableDatasourceIndex] || {}
limit = paging.limit || 0
}
return limit
},
set (limit = 0) {
if (this.currentConfigurableDatasourceIndex >= 0) {
if (!this.options.datasources[this.currentConfigurableDatasourceIndex].paging) {
this.options.datasources[this.currentConfigurableDatasourceIndex].paging = {}
}
this.options.datasources[this.currentConfigurableDatasourceIndex].paging.limit = limit || 0
}
},
},
},
watch: {
'options.source': {
immediate: true,
handler (source) {
this.describeReport(source)
},
},
'displayElement.elementID': {
immediate: true,
handler () {
this.currentConfigurableDatasourceIndex = this.datasources.length ? 0 : -1
if (this.usesDatasources) {
this.currentConfigurableDatasourceName = (this.options.datasources[0] || {}).name
}
},
},
},
methods: {
describeReport (source) {
this.columns = []
if (source) {
const steps = this.datasources.map(({ step }) => step)
this.$SystemAPI.reportDescribe({ steps, describe: [this.options.source] })
.then((frames = []) => {
this.columns = frames.filter(({ source }) => source === this.options.source).map(({ columns = [] }) => columns) || []
}).catch((e) => {
this.toastErrorHandler(this.$t('notification:datasource.describe-failed'))(e)
})
}
},
setConfigurableSources (source) {
this.options.datasources = []
this.currentConfigurableDatasourceName = undefined
let configurableDatasources = []
if (source) {
configurableDatasources = [source]
const { link } = this.currentDatasource.step
if (link) {
configurableDatasources = [link.localSource, link.foreignSource]
}
}
this.currentConfigurableDatasourceName = configurableDatasources[0]
this.options.datasources = configurableDatasources.map(s => {
return {
name: s,
sort: '',
filter: {},
}
})
},
configurableDatasourceChanged (source) {
if (source) {
this.currentConfigurableDatasourceIndex = this.options.datasources.findIndex(({ name }) => source === name)
}
},
},
}
</script>
<style>
</style>

View File

@@ -0,0 +1,15 @@
import Text from './Text'
import Chart from './Chart'
import Table from './Table'
import Metric from './Metric'
const Registry = {
Text,
Chart,
Table,
Metric,
}
export default function (k) {
return Registry[k]
}

View File

@@ -0,0 +1,42 @@
import Vue from 'vue'
import { capitalize } from 'lodash'
import { components } from '@cortezaproject/corteza-vue'
const {
CReportChart,
CReportMetric,
CReportTable,
CReportText,
} = components
/**
* List of all known display element components
*
*/
const Registry = {
Chart: CReportChart,
Metric: CReportMetric,
Table: CReportTable,
Text: CReportText,
}
function GetComponent ({ displayElement }) {
if (!displayElement) {
throw new Error('displayElement prop missing')
}
const { kind } = displayElement
if (Object.hasOwnProperty.call(Registry, capitalize(kind))) {
return Registry[kind]
}
throw new Error('unknown displayElement kind: ' + kind)
}
export default Vue.component('display-element', {
functional: true,
render (ce, ctx) {
return ce(GetComponent(ctx.props), ctx.data, ctx.children)
},
})

View File

@@ -0,0 +1,33 @@
<template>
<div class="h-100 p-2 overflow-hidden">
<b-card
no-body
class="h-100 border-0 shadow-sm overflow-hidden"
>
<b-card-header class="sticky-top border-0 p-0">
<slot
name="header"
/>
</b-card-header>
<b-card-body
v-if="bodySet"
class="p-0 h-100"
>
<slot
name="default"
/>
</b-card-body>
</b-card>
</div>
</template>
<script>
export default {
computed: {
bodySet () {
return !!this.$scopedSlots.default
},
},
}
</script>

View File

@@ -0,0 +1,34 @@
import Vue from 'vue'
import { capitalize } from 'lodash'
import Card from './Card'
const Registry = {
Card,
}
const defaultWrap = 'Card'
/**
* @param block {reporter.Block}
* @returns component
*/
function GetWrapComponent ({ block, wrap = defaultWrap }) {
const cmpName = capitalize(wrap)
if (Object.hasOwnProperty.call(Registry, cmpName)) {
return Registry[capitalize(cmpName)]
}
throw new Error('unknown wrap: ' + wrap)
}
/**
* Wraps page block with one of the configured (on page block options) components
*/
export default Vue.component('block', {
functional: true,
render (ce, ctx) {
return ce(GetWrapComponent(ctx.props), ctx.data, ctx.children)
},
})

View File

@@ -0,0 +1,257 @@
<template>
<wrap
v-if="block"
v-bind="$props"
v-on="$listeners"
>
<template
#header
>
<div
v-if="block.title || block.description"
class="d-flex flex-column p-3 border-bottom gap-1"
>
<h4
v-if="block.title"
class="text-primary text-truncate mb-0"
>
{{ block.title }}
</h4>
<b-card-text
v-if="block.description"
class="text-dark text-wrap"
>
{{ block.description }}
</b-card-text>
</div>
</template>
<template #default>
<split
v-if="showDisplayElements"
ref="split"
:direction="block.layout"
:gutter-size="12"
class="h-100"
@onDragEnd="setDisplayElementSizes"
>
<split-area
v-for="(element, displayElementIndex) in block.elements"
:key="displayElementIndex"
:size="element.meta.size"
:min-size="0"
:class="{
'overflow-hidden h-100': element.kind !== 'Text',
'w-100': block.elements.length === 1,
}"
class="position-relative"
>
<div
v-if="processing"
class="d-flex align-items-center justify-content-center h-100"
>
<b-spinner />
</div>
<display-element
v-else
:display-element="element"
:labels="{
previous: $t('display-element:table.view.previous'),
next: $t('display-element:table.view.next'),
}"
@update="updateDataframes({ displayElementIndex, definition: $event })"
/>
</split-area>
</split>
</template>
</wrap>
</template>
<script>
import Wrap from './Wrap'
import { Split, SplitArea } from 'vue-split-panel'
import DisplayElement from './DisplayElements/Viewers'
import { reporter } from '@cortezaproject/corteza-js'
export default {
name: 'Block',
components: {
Split,
SplitArea,
Wrap,
DisplayElement,
},
props: {
index: {
type: Number,
default: () => -1,
},
block: {
type: Object,
required: true,
},
scenario: {
type: Object,
default: () => ({}),
},
reportID: {
type: String,
required: false,
default: '0',
},
},
data () {
return {
processing: false,
dataframes: {},
showDisplayElements: false,
}
},
watch: {
'block.blockID': {
immediate: true,
handler () {
this.renderBlock()
},
},
'block.elements.length': {
handler (length, oldLength) {
const addedOrRemoved = length !== oldLength && oldLength
if (addedOrRemoved) {
const defaultSize = Math.floor(100 / length)
// Reset sizes to default if element was added or removed
this.block.elements = this.block.elements.map(e => {
e.meta.size = defaultSize
return e
})
}
this.renderBlock()
},
},
},
methods: {
renderBlock () {
const { elements = [] } = this.block || {}
if (elements.length) {
this.runReport()
// Hack around split not rerendering
this.showDisplayElements = false
this.$nextTick().then(() => {
this.showDisplayElements = true
})
}
},
setDisplayElementSizes (sizes = []) {
sizes.forEach((size, index) => {
this.block.elements[index].meta.size = size
})
this.$emit('item-updated', this.index)
},
getScenarioDefinition (element) {
const scenarioDefinition = {}
// Generate filter for each load datasource
if (this.scenario.filters) {
element.options.datasources.forEach(({ name }) => {
scenarioDefinition[name] = {
ref: name,
filter: this.scenario.filters[name] || {},
}
})
}
return scenarioDefinition
},
runReport () {
this.processing = true
this.dataframes = {}
const frames = []
this.block.elements.forEach((element, key) => {
element = reporter.DisplayElementMaker(element)
if (element && element.kind !== 'Text') {
if (element.elementID === '0') {
element.elementID = `${key}`
}
const { dataframes = [] } = element.reportDefinitions(this.getScenarioDefinition(element))
frames.push(...dataframes.filter(({ source }) => source))
}
})
if (frames.length) {
this.$SystemAPI.reportRun({ frames, reportID: this.reportID })
.then(({ frames = [] }) => {
this.block.elements = this.block.elements.map((element, key) => {
if (element.elementID === '0') {
element.elementID = `${key}`
}
const dataframes = frames.filter(({ name }) => name === element.elementID)
return { ...element, dataframes }
})
}).catch((e) => {
this.toastErrorHandler(this.$t('notification:report.runFailed'))(e)
}).finally(() => {
this.processing = false
})
} else {
this.processing = false
}
},
updateDataframes ({ displayElementIndex, definition }) {
const element = reporter.DisplayElementMaker(this.block.elements[displayElementIndex])
const frames = []
if (element && element.kind !== 'Text') {
const scenarioDefinition = this.getScenarioDefinition(element)
Object.entries(definition).forEach(([key, value]) => {
definition[key] = { ...value, ...scenarioDefinition[key] }
})
const { dataframes = [] } = element.reportDefinitions(definition)
frames.push(...dataframes.filter(({ source }) => source))
if (frames.length) {
this.$SystemAPI.reportRun({ frames, reportID: this.reportID })
.then(({ frames = [] }) => {
this.block.elements.find(({ elementID }) => elementID === element.elementID).dataframes = frames
}).catch((e) => {
this.toastErrorHandler(this.$t('notification:report.runFailed'))(e)
})
}
}
},
},
}
</script>
<style lang="scss">
.split .gutter {
background-color: transparent;
}
</style>

View File

@@ -0,0 +1,44 @@
// 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',
title: 'Title',
x: 0,
w: 20,
y: 0,
h: 15,
i: 0,
xywh: [0, 0, 20, 15],
},
],
}
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,111 @@
<template>
<c-form-table-wrapper
:labels="{
addButton: $t('general:label.add')
}"
@add-item="addColumn"
>
<b-table-simple
v-if="columns.length"
responsive
borderless
small
>
<b-thead>
<b-tr>
<b-th
class="w-25"
>
{{ $t('datasources:name') }}
</b-th>
<b-th
class="w-25"
>
{{ $t('datasources:label') }}
</b-th>
<b-th
class="w-50"
>
{{ $t('datasources:expression') }}
</b-th>
</b-tr>
</b-thead>
<b-tbody>
<b-tr
v-for="(column, index) in columns"
:key="index"
>
<b-td>
<b-form-input
v-model="column.name"
:placeholder="$t('datasources:new.name')"
/>
</b-td>
<b-td>
<b-form-input
v-model="column.label"
:placeholder="$t('datasources:new.label')"
/>
</b-td>
<b-td>
<b-form-input
v-model="column.def.raw"
:placeholder="$t('datasources:expression')"
/>
</b-td>
<b-td class="align-middle">
<c-input-confirm
show-icon
@confirmed="deleteColumn(index)"
/>
</b-td>
</b-tr>
</b-tbody>
</b-table-simple>
</c-form-table-wrapper>
</template>
<script>
export default {
props: {
aggregate: {
type: Array,
required: true,
},
},
computed: {
columns: {
get () {
return this.aggregate || []
},
set (aggregate) {
this.$emit('update:aggregate', aggregate)
},
},
},
methods: {
addColumn () {
this.columns.push({
name: '',
label: '',
def: {
raw: '',
},
})
},
deleteColumn (index) {
this.columns.splice(index, 1)
},
},
}
</script>

View File

@@ -0,0 +1,138 @@
<template>
<c-form-table-wrapper
:labels="{
addButton: $t('general:label.add')
}"
@add-item="addParam"
>
<b-table-simple
v-if="groups.length"
responsive
borderless
small
>
<b-thead>
<b-tr>
<b-th
class="w-25"
>
{{ $t('datasources:name') }}
</b-th>
<b-th
class="w-25"
>
{{ $t('datasources:label') }}
</b-th>
<b-th
class="w-50"
>
{{ $t('datasources:expression') }}
</b-th>
</b-tr>
</b-thead>
<b-tbody>
<b-tr
v-for="(group, index) in groups"
:key="index"
>
<b-td>
<b-form-input
v-model="group.name"
:placeholder="$t('datasources:new.name')"
/>
</b-td>
<b-td>
<b-form-input
v-model="group.label"
:placeholder="$t('datasources:new.label')"
/>
</b-td>
<b-td>
<b-form-input
v-model="group.def.raw"
:placeholder="$t('datasources:expression')"
/>
</b-td>
<b-td class="align-middle">
<c-input-confirm
show-icon
@confirmed="deleteGroup(index)"
/>
</b-td>
</b-tr>
</b-tbody>
</b-table-simple>
</c-form-table-wrapper>
</template>
<script>
export default {
props: {
groupBy: {
type: Array,
required: true,
},
},
data () {
return {
}
},
computed: {
groups: {
get () {
return this.groupBy || []
},
set (groupBy) {
this.$emit('update:groupBy', groupBy)
},
},
},
methods: {
addParam () {
this.groups.push({
name: '',
label: '',
def: {
raw: '',
},
})
},
deleteGroup (index) {
this.groups.splice(index, 1)
},
},
}
</script>
<style lang="scss" scoped>
.table td.fit,
.table th.fit {
white-space: nowrap;
width: 1%;
}
.btn-add-group {
&:hover, &:active {
background-color: var(--primary) !important;
color: var(--white) !important;
}
}
.filter-border {
background-image: linear-gradient(to left, lightgray, lightgray);
background-repeat: no-repeat;
background-size: 100% 1px;
background-position: center;
}
</style>

View File

@@ -0,0 +1,163 @@
<template>
<div
v-if="step.aggregate"
>
<b-form-group
:label="$t('datasources:name-required')"
label-class="text-primary"
>
<b-form-input
v-model="step.aggregate.name"
:state="nameState"
:disabled="!creating"
:placeholder="$t('datasources:datasource-name')"
/>
</b-form-group>
<hr>
<b-form-group
:label="$t('datasources:source')"
label-class="text-primary"
>
<b-form-select
v-model="step.aggregate.source"
:options="supportedSources"
@change="reset"
>
<template #first>
<b-form-select-option
:value="undefined"
>
{{ $t('general:label.none') }}
</b-form-select-option>
</template>
</b-form-select>
</b-form-group>
<div
v-if="step.aggregate.source"
>
<b-form-group
:label="$t('datasources:group-by')"
label-class="text-primary"
>
<group-by
:group-by.sync="step.aggregate.keys"
/>
</b-form-group>
<b-form-group
:label="$t('datasources:aggregate')"
label-class="text-primary"
>
<aggregate
:aggregate.sync="step.aggregate.columns"
/>
</b-form-group>
<!-- <b-form-group
v-if="columns.length"
label="Prefilter"
label-class="text-primary"
>
<prefilter
:filter.sync="step.aggregate.filter"
:columns="columns"
/>
</b-form-group> -->
<!-- <b-form-group
v-if="columns.length"
label="Presort order"
label-class="text-primary"
>
<c-input-presort
v-model="step.aggregate.sort"
:fields="columns"
:labels="{
add: $t('general:label.add'),
ascending: $t('general:label.ascending'),
descending: $t('general:label.descending'),
none: $t('general:label.none'),
}"
/>
</b-form-group> -->
</div>
</div>
</template>
<script>
import base from '../base.vue'
import GroupBy from './GroupBy'
import Aggregate from './Aggregate'
// import Prefilter from 'corteza-webapp-reporter/src/components/Common/Prefilter'
// import { components } from '@cortezaproject/corteza-vue'
// const { CInputPresort } = components
export default {
components: {
GroupBy,
Aggregate,
// Prefilter,
// CInputPresort,
},
extends: base,
props: {
datasources: {
type: Array,
required: false,
default: () => [],
},
},
data () {
return {
columns: [],
}
},
computed: {
supportedSources () {
const options = []
this.datasources.forEach(({ step }, index) => {
Object.entries(step).forEach(([kind, { name }]) => {
if (kind === 'load') {
options.push({ value: name || `${index}`, text: name || `${index}` })
}
})
})
return options
},
},
methods: {
async getSourceColumns () {
const steps = this.datasources.filter(({ step }) => step.load).map(({ step }) => step)
steps.push(this.step)
const describe = [this.step.aggregate.name]
if (steps.length && describe.length) {
this.$SystemAPI.reportDescribe({ steps, describe })
.then((frames = []) => {
const { columns = [] } = frames.find(({ source }) => describe.includes(source)) || {}
this.columns = columns
}).catch((e) => {
this.toastErrorHandler(this.$t('notification:datasource.describe-failed'))(e)
})
}
},
reset () {
this.step.aggregate.filter = {}
this.step.aggregate.sort = ''
this.step.aggregate.keys = []
this.step.aggregate.columns = []
},
},
}
</script>

View File

@@ -0,0 +1,197 @@
<template>
<div
v-if="step.join"
>
<b-row>
<b-col>
<b-form-group
:label="$t('datasources:name-required')"
label-class="text-primary"
>
<b-form-input
v-model="step.join.name"
:disabled="!creating"
:state="nameState"
:placeholder="$t('datasources:datasource-name')"
/>
</b-form-group>
</b-col>
</b-row>
<hr>
<b-row>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('datasources:primary.source')"
label-class="text-primary"
>
<b-form-select
v-model="step.join.localSource"
:options="supportedSources"
@change="onSourceChange('local')"
>
<template #first>
<b-form-select-option
value=""
>
{{ $t('general:label.none') }}
</b-form-select-option>
</template>
</b-form-select>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('datasources:secondary.source')"
label-class="text-primary"
>
<b-form-select
v-model="step.join.foreignSource"
:options="supportedSources"
@change="onSourceChange('foreign')"
>
<template #first>
<b-form-select-option
value=""
>
{{ $t('general:label.none') }}
</b-form-select-option>
</template>
</b-form-select>
</b-form-group>
</b-col>
</b-row>
<b-row>
<b-col
cols="12"
lg="6"
>
<b-form-group
v-if="step.join.localSource"
:label="$t('datasources:primary.column')"
label-class="text-primary"
>
<column-selector
v-model="step.join.localColumn"
:columns="localColumns"
style="min-width: 100% !important;"
/>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
v-if="step.join.foreignSource"
:label="$t('datasources:secondary.column')"
label-class="text-primary"
>
<column-selector
v-model="step.join.foreignColumn"
:columns="foreignColumns"
style="min-width: 100% !important;"
/>
</b-form-group>
</b-col>
</b-row>
</div>
</template>
<script>
import base from './base.vue'
import ColumnSelector from 'corteza-webapp-reporter/src/components/Common/ColumnSelector.vue'
export default {
components: {
ColumnSelector,
},
extends: base,
data () {
return {
localColumns: [],
foreignColumns: [],
}
},
computed: {
supportedSources () {
const options = []
this.datasources.forEach(({ step }, index) => {
Object.entries(step).forEach(([kind, { name }]) => {
if (['load', 'group', 'join'].includes(kind) && index !== this.index) {
options.push({ value: name || `${index}`, text: name || `${index}` })
}
})
})
return options
},
},
watch: {
'step.join.name': {
immediate: true,
handler (newStep, oldStep) {
if (!oldStep && newStep) {
this.getSourceColumns(['local', 'foreign'])
}
},
},
'step.join.localSource': {
handler () {
this.getSourceColumns(['local'])
},
},
'step.join.foreignSource': {
handler () {
this.getSourceColumns(['foreign'])
},
},
},
methods: {
onSourceChange (source) {
this.step.join[`${source}Column`] = ''
},
async getSourceColumns (sources = []) {
sources.forEach(source => {
this[`${source}Columns`] = []
const sourceType = this.step.join[`${source}Source`]
if (sourceType) {
const steps = this.datasources.filter(({ step }, index) => index !== this.index && !step.link).map(({ step }) => step)
const describe = [sourceType]
if (steps.length && describe.length) {
this.$SystemAPI.reportDescribe({ steps, describe })
.then((frames = []) => {
const { columns = [] } = frames.find(({ source }) => describe.includes(source)) || {}
this[`${source}Columns`] = columns
}).catch((e) => {
this.toastErrorHandler(this.$t('notification:datasource.describe-failed'))(e)
})
}
}
})
},
},
}
</script>

View File

@@ -0,0 +1,195 @@
<template>
<div
v-if="step.link"
>
<b-row>
<b-col>
<b-form-group
:label="$t('datasources:name-required')"
label-class="text-primary"
>
<b-form-input
v-model="step.link.name"
:disabled="!creating"
:state="nameState"
:placeholder="$t('datasources:datasource-name')"
/>
</b-form-group>
</b-col>
</b-row>
<hr>
<b-row>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('datasources:primary.source')"
label-class="text-primary"
>
<b-form-select
v-model="step.link.localSource"
:options="supportedSources"
@change="onSourceChange('local')"
>
<template #first>
<b-form-select-option
value=""
>
{{ $t('general:label.none') }}
</b-form-select-option>
</template>
</b-form-select>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
:label="$t('datasources:secondary.source')"
label-class="text-primary"
>
<b-form-select
v-model="step.link.foreignSource"
:options="supportedSources"
@change="onSourceChange('foreign')"
>
<template #first>
<b-form-select-option
value=""
>
{{ $t('general:label.none') }}
</b-form-select-option>
</template>
</b-form-select>
</b-form-group>
</b-col>
</b-row>
<b-row>
<b-col
cols="12"
lg="6"
>
<b-form-group
v-if="step.link.localSource"
:label="$t('datasources:primary.column')"
label-class="text-primary"
>
<column-selector
v-model="step.link.localColumn"
:columns="localColumns"
style="min-width: 100% !important;"
/>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
>
<b-form-group
v-if="step.link.foreignSource"
:label="$t('datasources:secondary.column')"
label-class="text-primary"
>
<column-selector
v-model="step.link.foreignColumn"
:columns="foreignColumns"
style="min-width: 100% !important;"
/>
</b-form-group>
</b-col>
</b-row>
</div>
</template>
<script>
import base from './base.vue'
import ColumnSelector from 'corteza-webapp-reporter/src/components/Common/ColumnSelector.vue'
export default {
components: {
ColumnSelector,
},
extends: base,
data () {
return {
localColumns: [],
foreignColumns: [],
}
},
computed: {
supportedSources () {
const options = []
this.datasources.forEach(({ step }, index) => {
Object.entries(step).forEach(([kind, { name }]) => {
if (['load', 'group'].includes(kind)) {
options.push({ value: name || `${index}`, text: name || `${index}` })
}
})
})
return options
},
},
watch: {
'step.link.name': {
immediate: true,
handler (newStep, oldStep) {
if (!oldStep && newStep) {
this.getSourceColumns(['local', 'foreign'])
}
},
},
'step.link.localSource': {
handler () {
this.getSourceColumns(['local'])
},
},
'step.link.foreignSource': {
handler () {
this.getSourceColumns(['foreign'])
},
},
},
methods: {
onSourceChange (source) {
this.step.link[`${source}Column`] = ''
},
async getSourceColumns (sources = []) {
sources.forEach(source => {
this[`${source}Columns`] = []
const sourceType = this.step.link[`${source}Source`]
if (sourceType) {
const steps = this.datasources.filter(({ step }) => step.load || step.aggregate).map(({ step }) => step)
const describe = [sourceType]
if (steps.length && describe.length) {
this.$SystemAPI.reportDescribe({ steps, describe })
.then((frames = []) => {
const { columns = [] } = frames.find(({ source }) => describe.includes(source)) || {}
this[`${source}Columns`] = columns
}).catch((e) => {
this.toastErrorHandler(this.$t('notification:datasource.describe-failed'))(e)
})
}
}
})
},
},
}
</script>

View File

@@ -0,0 +1,164 @@
<template>
<div>
<b-row>
<b-col>
<b-form-group
:label="$t('datasources:namespace')"
label-class="text-primary"
>
<b-form-select
v-model="namespace"
:options="namespaces"
text-field="name"
value-field="namespaceID"
@change="selectNamespace"
>
<template #first>
<b-form-select-option
:value="undefined"
>
{{ $t('general:label.none') }}
</b-form-select-option>
</template>
</b-form-select>
</b-form-group>
</b-col>
<b-col>
<b-form-group
v-if="namespace"
:label="$t('datasources:module')"
label-class="text-primary"
>
<b-form-select
v-model="module"
:options="modules"
text-field="name"
value-field="moduleID"
>
<template #first>
<b-form-select-option
:value="undefined"
>
{{ $t('general:label.none') }}
</b-form-select-option>
</template>
</b-form-select>
</b-form-group>
</b-col>
</b-row>
</div>
</template>
<script>
export default {
props: {
definition: {
type: Object,
required: true,
default: () => ({}),
},
},
data () {
return {
processing: false,
namespaces: [],
modules: [],
}
},
computed: {
namespace: {
get () {
return this.definition.namespaceID
},
set (namespaceID) {
this.$emit('update:definition', { ...this.definition, namespaceID })
},
},
module: {
get () {
return this.definition.moduleID
},
set (moduleID) {
this.$emit('update:definition', { ...this.definition, moduleID })
},
},
filter: {
get () {
return this.definition.filter
},
set (filter) {
this.$emit('update:definition', { ...this.definition, filter })
},
},
sort: {
get () {
return this.definition.sort
},
set (sort) {
this.$emit('update:definition', { ...this.definition, sort })
},
},
},
watch: {
namespace: {
immediate: true,
handler (namespace) {
if (namespace) {
this.processing = true
this.fetchModules(namespace)
.finally(() => {
this.processing = false
})
}
},
},
},
created () {
this.processing = true
this.fetchNamespaces()
.then(() => {
if (this.namespace) {
return this.fetchModules(this.namespace)
}
}).finally(() => {
this.processing = false
})
},
methods: {
selectNamespace () {
this.module = undefined
},
fetchNamespaces () {
return this.$ComposeAPI.namespaceList({ sort: 'name' }).then(({ set = [] }) => {
this.namespaces = set
}).catch((e) => {
this.toastErrorHandler(this.$t('notification:namespace.fetch-failed'))(e)
})
},
fetchModules (namespaceID) {
return this.$ComposeAPI.moduleList({ namespaceID, sort: 'name' }).then(({ set = [] }) => {
this.modules = set
}).catch((e) => {
this.toastErrorHandler(this.$t('notification:module.fetch-failed'))(e)
})
},
},
}
</script>

View File

@@ -0,0 +1,150 @@
<template>
<div
v-if="step.load"
>
<b-form-group
:label="$t('datasources:name-required')"
label-class="text-primary"
>
<b-form-input
v-model="step.load.name"
:state="nameState"
:disabled="!creating"
:placeholder="$t('datasources:datasource-name')"
/>
</b-form-group>
<hr>
<b-form-group
:label="$t('datasources:source')"
label-class="text-primary"
>
<b-form-select
v-model="step.load.source"
:options="supportedSources"
@change="reset"
/>
</b-form-group>
<component
:is="sourceTypeComponent(step.load.source)"
v-if="step.load.source"
:definition.sync="stepDefinition"
/>
<b-form-group
v-if="columns.length"
:label="$t('datasources:prefilter')"
label-class="text-primary"
>
<prefilter
:filter.sync="step.load.filter"
:columns="columns"
/>
</b-form-group>
<!-- <b-form-group
v-if="columns.length"
label="Presort order"
label-class="text-primary"
>
<c-input-presort
v-model="step.load.sort"
:fields="columns"
:labels="{
add: $t('general:label.add'),
ascending: $t('general:label.ascending'),
descending: $t('general:label.descending'),
none: $t('general:label.none'),
}"
/>
</b-form-group> -->
</div>
</template>
<script>
import base from '../base.vue'
import loader from './loader'
import Prefilter from 'corteza-webapp-reporter/src/components/Common/Prefilter'
// import { components } from '@cortezaproject/corteza-vue'
// const { CInputPresort } = components
export default {
components: {
Prefilter,
// CInputPresort,
},
extends: base,
data () {
return {
// @todo get this from the API
supportedSources: [
{
text: this.$t('datasources:compose-records'),
value: 'composeRecords',
definition: [{
label: 'namespace',
key: 'namespace',
}, {
label: 'module',
key: 'module',
}],
},
],
columns: [],
}
},
computed: {
stepDefinition: {
get () {
return this.step.load ? this.step.load.definition : undefined
},
set (definition) {
this.step.load.definition = definition
this.$emit('update:step', this.step)
},
},
},
watch: {
stepDefinition: {
immediate: true,
deep: true,
handler ({ moduleID, namespaceID }) {
if (moduleID && namespaceID) {
this.getSourceColumns()
}
},
},
},
methods: {
sourceTypeComponent: loader,
async getSourceColumns () {
const steps = [this.step]
const describe = [this.step.load.name]
if (steps.length && describe.length) {
this.$SystemAPI.reportDescribe({ steps, describe })
.then((frames = []) => {
const { columns = [] } = frames.find(({ source }) => describe.includes(source)) || {}
this.columns = columns
}).catch((e) => {
this.toastErrorHandler(this.$t('notification:datasource.describe-failed'))(e)
})
}
},
reset () {
this.step.load.filter = {}
this.step.load.sort = ''
},
},
}
</script>

View File

@@ -0,0 +1,9 @@
import ComposeRecordConfigurator from './ComposeRecordConfigurator'
const Registry = {
composeRecords: ComposeRecordConfigurator,
}
export default function (k) {
return Registry[k]
}

View File

@@ -0,0 +1,42 @@
<script>
export default {
props: {
index: {
type: Number,
required: true,
},
step: {
type: Object,
required: true,
default: () => ({}),
},
datasources: {
type: Array,
required: false,
default: () => [],
},
creating: {
type: Boolean,
default: true,
},
},
computed: {
kind () {
return Object.keys(this.step)
},
nameState () {
const name = this.step[this.kind].name
const isDuplicate = this.datasources.some(({ step }, index) => {
return index !== this.index && step[Object.keys(step)].name === name && name
})
return name.length > 0 && !isDuplicate ? null : false
},
},
}
</script>

View File

@@ -0,0 +1,15 @@
import { default as Load } from './Load'
import { default as Link } from './Link'
import { default as Join } from './Join'
import { default as Aggregate } from './Aggregate'
const Registry = {
load: Load,
link: Link,
join: Join,
aggregate: Aggregate,
}
export default function (k) {
return Registry[k]
}

View File

@@ -0,0 +1,124 @@
<template>
<div
v-if="layout.length"
class="w-100"
>
<grid-layout
:layout.sync="layout"
:col-num="48"
:row-height="10"
vertical-compact
:is-resizable="editable"
:is-draggable="editable"
:cols="{ lg: 48, md: 48, sm: 1, xs: 1, xxs: 1 }"
:margin="[0, 0]"
:responsive="!editable"
:use-css-transforms="false"
>
<grid-item
v-for="(item, index) in grid"
:key="item.i"
ref="items"
:min-w="3"
:min-h="3"
:i="item.i"
:h="item.h"
:w="item.w"
:x="item.x"
:y="item.y"
:class="{ 'editable-grid-item': editable }"
drag-ignore-from=".gutter"
@moved="onBlockUpdated(index)"
@resized="onBlockUpdated(index)"
>
<slot
:block="blocks[item.i]"
:index="index"
:block-index="item.i"
v-on="$listeners"
/>
</grid-item>
</grid-layout>
</div>
<div
v-else
class="d-flex align-items-center justify-content-center h-50 w-100"
>
<h4>
{{ $t('builder:no-blocks-added') }}
</h4>
</div>
</template>
<script>
import { GridLayout, GridItem } from 'vue-grid-layout'
export default {
name: 'Grid',
components: {
GridLayout,
GridItem,
},
props: {
blocks: {
type: Array,
default: () => ([]),
},
editable: {
type: Boolean,
default: false,
},
},
data () {
return {
// All blocks in vue-grid friendly structure
grid: undefined,
}
},
computed: {
layout: {
get () {
return this.grid || this.blocks
},
set (layout) {
// Only update parent blocks when editable to avoid unnecessary updates
if (this.editable) {
this.$emit('update:blocks', layout)
} else {
this.grid = layout
}
},
},
},
watch: {
blocks: {
immediate: true,
deep: true,
handler (blocks) {
if (this.editable) {
this.grid = blocks
}
},
},
},
methods: {
onBlockUpdated (index) {
this.$emit('item-updated', index)
},
},
}
</script>
<style lang="scss">
.vue-grid-item.vue-grid-placeholder {
background: var(--primary) !important;
}
</style>

View File

@@ -0,0 +1,133 @@
<template>
<div
v-if="scenario"
>
<b-form-group
:label="$t('scenarios:label')"
label-class="text-primary"
>
<b-form-input
v-model="scenario.label"
:placeholder="$t('scenarios:scenario-name')"
/>
</b-form-group>
<b-form-group
:label="$t('scenarios:datasource')"
label-class="text-primary"
>
<b-form-select
v-model="currentDatasourceName"
:options="datasourceOptions"
/>
</b-form-group>
<b-form-group
v-if="currentDatasourceName && scenario.filters[currentDatasourceName]"
:label="$t('scenarios:prefilter')"
label-class="text-primary"
>
<prefilter
:filter="scenario.filters[currentDatasourceName]"
:columns="columns"
/>
</b-form-group>
</div>
</template>
<script>
import Prefilter from 'corteza-webapp-reporter/src/components/Common/Prefilter'
export default {
components: {
Prefilter,
},
props: {
currentIndex: {
type: Number,
default: () => -1,
},
scenario: {
type: Object,
required: true,
default: () => ({}),
},
datasources: {
type: Array,
required: false,
default: () => [],
},
},
data () {
return {
currentDatasourceName: '',
columns: [],
}
},
computed: {
datasourceOptions () {
const options = [{ value: '', text: this.$t('general:label.none') }]
this.datasources.forEach(({ step }, index) => {
Object.entries(step).forEach(([kind, { name }]) => {
if (['load'].includes(kind)) {
options.push({ value: name || `${index}`, text: name || `${index}` })
}
})
})
return options
},
},
watch: {
currentIndex: {
immediate: true,
handler () {
// Select first defined filter on switch
const { filters = {} } = this.scenario
const definedFilters = Object.keys(filters)
this.currentDatasourceName = definedFilters.length ? definedFilters[0] : ''
},
},
currentDatasourceName: {
immediate: true,
handler (name) {
if (name && !this.scenario.filters[name]) {
this.scenario.filters[name] = {}
}
this.getSourceColumns()
},
},
},
methods: {
async getSourceColumns () {
this.columns = []
if (this.currentDatasourceName) {
const steps = this.datasources.map(({ step }) => step)
this.$SystemAPI.reportDescribe({ steps, describe: [this.currentDatasourceName] })
.then((frames = []) => {
this.columns = ((frames[0] || {}).columns || [])
}).catch((e) => {
this.toastErrorHandler(this.$t('notification:datasource.describe-failed'))(e)
})
}
},
},
}
</script>
<style>
</style>

View File

@@ -0,0 +1,124 @@
<template>
<div>
<portal to="sidebar-body-expanded">
<div
v-if="reports.length"
class="h-100"
>
<div class="bg-white sticky-top py-2">
<b-button
data-test-id="button-report-list"
variant="light"
class="w-100 mb-2"
:to="{ name: 'report.list' }"
>
{{ $t('report-list') }}
</b-button>
<c-input-search
v-model.trim="query"
:placeholder="$t('search-reports')"
/>
</div>
<c-sidebar-nav-items
:items="filteredReports"
:start-expanded="!!query"
default-route-name="report.view"
class="overflow-auto h-100"
/>
</div>
<h5
v-else
class="d-flex justify-content-center mt-5"
>
{{ $t('no-reports') }}
</h5>
</portal>
</div>
</template>
<script>
import { components } from '@cortezaproject/corteza-vue'
const { CSidebarNavItems, CInputSearch } = components
export default {
i18nOptions: {
namespaces: 'sidebar',
},
components: {
CSidebarNavItems,
CInputSearch,
},
data () {
return {
query: '',
reports: [],
}
},
computed: {
filteredReports () {
let reports = this.reports
if (this.query) {
reports = this.reports.filter(({ reportID, handle, meta: { name = '' } }) => {
const reportString = `${reportID}${handle}${name}`.toLowerCase().trim()
return reportString.indexOf(this.query.toLowerCase().trim()) > -1
})
}
return reports.map(({ reportID, handle, meta: { name = '' } }) => {
return {
page: { pageID: reportID, name: this.$route.name, title: name || handle },
params: { reportID },
}
})
},
},
watch: {
'$route.name': {
immediate: true,
handler ({ name }) {
if (!['report.list', 'report.create', 'report.edit'].includes(name)) {
this.fetchReports()
}
},
},
},
created () {
this.$root.$on('refetch:reports', this.fetchReports)
},
beforeDestroy () {
this.$root.$off('refetch:reports', this.fetchReports)
},
methods: {
fetchReports () {
this.$SystemAPI.reportList()
.then(res => {
this.reports = (res || {}).set || []
})
.catch(this.toastErrorHandler(this.$t('notification:report.listFetchFailed')))
},
},
}
</script>
<style scoped lang="scss">
// This has to be there, so chevrons are clickable inside the button
.pointer-none {
pointer-events: none;
}
// Using font-weight-bold moves the sidebar nav content; text-stroke keeps in nicely in place
.nav-active {
color: var(--primary);
-webkit-text-stroke: 0.4px var(--primary);
}
</style>

View File

@@ -0,0 +1,133 @@
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faCheck,
faCheckDouble,
faTimes,
faHome,
faSave,
faKeyboard,
faMouse,
faCog,
faChevronLeft,
faChevronRight,
faChevronDown,
faGripHorizontal,
faUserCog,
faExclamationTriangle,
faSearchPlus,
faSearchMinus,
faSearch,
faPlus,
faThumbtack,
faPen,
faCogs,
faRemoveFormat,
faBold,
faItalic,
faUnderline,
faStrikethrough,
faQuoteRight,
faCode,
faParagraph,
faListOl,
faListUl,
faTasks,
faAlignLeft,
faAlignRight,
faAlignCenter,
faAlignJustify,
faAngleRight,
faAngleLeft,
faAngleDoubleLeft,
faLink,
faBars,
faAngleUp,
faAngleDown,
faFilter,
faGripVertical,
faEyeSlash,
faQuestion,
faEllipsisV,
faTools,
faClone,
faDownload,
faTable,
faBellSlash,
faBell as faBellSolid,
} from '@fortawesome/free-solid-svg-icons'
import {
faEye,
faEdit,
faTrashAlt,
faQuestionCircle,
faUser,
faBell,
faEnvelope,
faEnvelopeOpen,
} from '@fortawesome/free-regular-svg-icons'
library.add(
faEdit,
faEye,
faTrashAlt,
faCheck,
faCheckDouble,
faTimes,
faHome,
faSave,
faKeyboard,
faMouse,
faCog,
faChevronLeft,
faChevronRight,
faGripHorizontal,
faUserCog,
faChevronDown,
faExclamationTriangle,
faQuestionCircle,
faSearchPlus,
faSearchMinus,
faSearch,
faPlus,
faUser,
faThumbtack,
faPen,
faCogs,
faRemoveFormat,
faBold,
faItalic,
faUnderline,
faStrikethrough,
faQuoteRight,
faCode,
faParagraph,
faListOl,
faListUl,
faTasks,
faAlignLeft,
faAlignRight,
faAlignCenter,
faAlignJustify,
faLink,
faBars,
faAngleUp,
faAngleDown,
faFilter,
faAngleRight,
faAngleLeft,
faAngleDoubleLeft,
faGripVertical,
faEyeSlash,
faQuestion,
faEllipsisV,
faTools,
faClone,
faDownload,
faTable,
faBell,
faBellSolid,
faBellSlash,
faEnvelope,
faEnvelopeOpen,
)

View File

@@ -0,0 +1,56 @@
import Vue from 'vue'
import { FontAwesomeIcon, FontAwesomeLayers } from '@fortawesome/vue-fontawesome'
import PortalVue from 'portal-vue'
import './faIcons'
import { components } from '@cortezaproject/corteza-vue'
// import ECharts modules manually to reduce bundle size
import ECharts from 'vue-echarts'
import { use } from 'echarts/core'
import {
SVGRenderer,
} from 'echarts/renderers'
import {
LineChart,
BarChart,
PieChart,
GaugeChart,
RadarChart,
FunnelChart,
} from 'echarts/charts'
import {
TitleComponent,
GridComponent,
LegendComponent,
TooltipComponent,
VisualMapComponent,
ToolboxComponent,
} from 'echarts/components'
use([
BarChart,
LineChart,
PieChart,
GaugeChart,
RadarChart,
FunnelChart,
SVGRenderer,
TitleComponent,
GridComponent,
TooltipComponent,
LegendComponent,
VisualMapComponent,
ToolboxComponent,
])
Vue.component('e-charts', ECharts)
Vue.use(PortalVue)
Vue.component('font-awesome-icon', FontAwesomeIcon)
Vue.component('font-awesome-layers', FontAwesomeLayers)
Vue.component('c-permissions-button', components.CPermissionsButton)
Vue.component('c-input-confirm', components.CInputConfirm)
Vue.component('c-button-submit', components.CButtonSubmit)
Vue.component('c-input-select', components.CInputSelect)
Vue.component('c-form-table-wrapper', components.CFormTableWrapper)

View File

@@ -0,0 +1,8 @@
[
'CortezaAPI',
].forEach((cfg) => {
if (window[cfg] === undefined) {
throw new Error(`Missing or invalid configuration.
Make sure there is a public/config.js configuration file with window.${cfg} entry.`)
}
})

View File

@@ -0,0 +1,6 @@
/* eslint-disable no-undef */
const text = `%c${WEBAPP || 'Corteza Webapp'}, version: ${VERSION}, build time: ${BUILD_TIME}`
const style = 'background-color: #1397CB; color: white; padding: 3px 10px; border: 1px solid black; font: Courier'
/* eslint-disable no-console */
console.log(text, style)

View File

@@ -0,0 +1,46 @@
import Vue from 'vue'
import Router from 'vue-router'
import BootstrapVue from 'bootstrap-vue'
import c3catalogue from './components/C3'
import { components, i18n } from '@cortezaproject/corteza-vue'
import './components'
import './themes'
const routes = [
{
path: '/c3',
name: 'c3',
component: components.C3.View,
props: { catalogue: c3catalogue },
},
{ path: '*', redirect: { name: 'c3' } },
]
Vue.use(Router)
Vue.use(BootstrapVue)
export default new Vue({
el: '#app',
name: 'DevEnv',
template: '<router-view/>',
router: new Router({
mode: 'history',
routes,
}),
i18n: i18n(Vue,
{ app: 'corteza-webapp-reporter' },
'builder',
'create',
'datasources',
'display-element',
'edit',
'general',
'list',
'navigation',
'notification',
'permissions',
'scenarios',
'sidebar',
'view',
),
})

View File

@@ -0,0 +1,6 @@
import Vue from 'vue'
import { filters } from '@cortezaproject/corteza-vue'
for (const n in filters) {
Vue.filter(n, filters[n])
}

View File

@@ -0,0 +1,4 @@
import app from './app'
import './themes'
app()

View File

@@ -0,0 +1,5 @@
import Vue from 'vue'
import { mixins } from '@cortezaproject/corteza-vue'
Vue.mixin(mixins.toast)

View File

@@ -0,0 +1,227 @@
import axios from 'axios'
import { mapActions } from 'vuex'
export default {
data () {
return {
/**
* Placeholder, if component does not define own filter
*/
filter: {},
pagination: {
limit: 100,
pageCursor: undefined,
prevPage: '',
nextPage: '',
total: 0,
page: 1,
},
// Used to save query when fetching for total if we came to the page with a pageCursor in URL
tempQuery: undefined,
sorting: {},
abortableRequests: [],
cancelled: false,
}
},
watch: {
/**
* When fullPath for this component changes, we most likely should update our
* filters.
* @todo make this.filter reactive
*/
'$route.fullPath': {
handler () {
this.handleQueryParams()
},
},
},
created () {
this.handleQueryParams(true)
},
beforeDestroy () {
this.abortRequests()
},
methods: {
...mapActions({
incLoader: 'ui/incLoader',
decLoader: 'ui/decLoader',
}),
/**
* Parses query params into list filtering params.
* @param initial {Boolean} - used to determine it this is the initial fetch
*/
handleQueryParams (initial = false) {
// Pagination
let {
limit = this.pagination.limit,
pageCursor = this.pagination.pageCursor,
prevPage = this.pagination.prevCursor,
nextPage = this.pagination.nextCursor,
total = this.pagination.total,
page = this.pagination.page,
...r1
} = this.$route.query
limit = parseInt(limit)
total = parseInt(total)
page = parseInt(page)
// If we came to the page with a pageCursor in the URL
if (initial && pageCursor) {
this.tempQuery = this.$route.query
// Fetch replace query to trigger fetch of total number of items
this.$router.replace({ query: { ...this.$route.query, limit: 1, pageCursor: undefined } })
return
}
/// To prevent extra list fetch, check if pageCursor is defined (not first page)
const refresh = this.$route.query.pageCursor !== this.pagination.pageCursor
this.pagination = { limit, pageCursor, prevPage, nextPage, total, page }
// Sorting
let { sortBy = this.sorting.sortBy, sortDesc = this.sorting.sortDesc, ...r2 } = r1
sortDesc = sortDesc === true || sortDesc === 'true'
// Reset pageCursor when sort changes, except on first fetch (so we use the pageCursor from url)
if (!initial && (sortBy !== this.sorting.sortBy || sortDesc !== this.sorting.sortDesc)) {
this.pagination.pageCursor = ''
this.pagination.page = 1
}
this.sorting = { sortBy, sortDesc }
// Filtering
// make sure filter fields are of the right type
for (const key in r2) {
if (typeof this.filter[key] === 'boolean') {
r2[key] = r2[key] === 'true'
}
}
this.filter = { ...this.filter, ...r2 }
// Only refresh if pageCursor actually changed,
if (refresh) {
this.$root.$emit('bv::refresh::table', 'resource-list')
}
},
filterList () {
// reset pagination when filtering changes
//
// we want to prevent situations with page is preset to a number that
// exceeds the number of pages of a filtered results
this.pagination.pageCursor = ''
this.pagination.page = 1
// notify b-table about the change
//
// this effectively calls items()/procListResults()
this.abortRequests()
this.$root.$emit('bv::refresh::table', 'resource-list')
},
encodeListParams () {
let { sortBy, sortDesc } = this.sorting
const { limit, pageCursor } = this.pagination
if (sortBy === 'changedAt') {
sortBy = 'coalesce(deletedAt, updatedAt, createdAt)'
}
const sort = sortBy ? `${sortBy} ${sortDesc ? 'DESC' : 'ASC'}` : undefined
return {
limit,
sort: pageCursor ? undefined : sort,
...this.filter,
pageCursor,
incTotal: !pageCursor || this.tempQuery,
}
},
encodeRouteParams () {
const { limit, pageCursor, page } = this.pagination
return {
query: {
limit,
...this.sorting,
...this.filter,
page,
pageCursor,
},
}
},
/**
*
* @param p {Promise}
* @returns {Promise}
*/
procListResults (p, updateQuery = true) {
this.abortRequests()
const { response, cancel } = p
this.abortableRequests.push(cancel)
// Push new router/params to cause URL change
//
// We want this because in case when user refreshes or shares URL
// he needs to land on the same page with the same parameters
if (updateQuery && !this.tempQuery) {
this.$router.replace(this.encodeRouteParams())
}
return Promise.all([response(), new Promise(resolve => setTimeout(resolve, 300))])
.then(async ([{ set, filter }]) => {
if (filter.incTotal) {
this.pagination.total = filter.total
}
// This was a fetch of total number of items
if (this.tempQuery) {
const query = this.tempQuery
this.tempQuery = undefined
this.$router.replace({ query })
return []
}
this.pagination.pageCursor = undefined
this.pagination.nextPage = filter.nextPage
this.pagination.prevPage = filter.prevPage
return set
}).catch(error => {
if (!axios.isCancel(error)) {
this.toastErrorHandler(this.$t('notification:list.load.error'))(error)
} else {
this.cancelled = true
}
}).finally(() => {
this.cancelled = false
})
},
genericRowClass (item) {
return { 'text-secondary': item && !!item.deletedAt }
},
abortRequests () {
this.abortableRequests.forEach((cancel) => {
cancel()
})
this.abortableRequests = []
},
},
}

View File

@@ -0,0 +1,121 @@
import { system } from '@cortezaproject/corteza-js'
export default {
data () {
return {
processing: false,
processingSave: false,
processingDelete: false,
processingClone: false,
report: undefined,
}
},
methods: {
async fetchReport (reportID) {
this.processing = true
return this.$SystemAPI.reportRead({ reportID })
.then(report => {
this.report = new system.Report(report)
this.initialReportState = this.report.clone()
})
.catch(this.toastErrorHandler(this.$t('notification:report.fetchFailed')))
.finally(() => {
setTimeout(() => {
this.processing = false
}, 400)
})
},
async handleSave () {
this.processing = true
this.processingSave = true
const { blocks } = this.report
// Remove dataframes from elements before saving report
const report = {
...this.report,
blocks: blocks.map(block => {
block.elements = block.elements.map(element => {
delete element.dataframes
return element
})
return block
}),
}
// If new then create otherwise update
if (this.isNew) {
return this.$SystemAPI.reportCreate(report)
.then(report => {
this.report = new system.Report(report)
this.initialReportState = this.report.clone()
this.detectStateChange = false
this.toastSuccess(this.$t('notification:report.created'))
this.$router.push({ name: 'report.builder', params: { reportID: report.reportID } })
})
.catch(this.toastErrorHandler(this.$t('notification:report.createFailed')))
.finally(() => {
this.processing = false
this.processingSave = false
})
} else {
return this.$SystemAPI.reportUpdate(report)
.then(report => {
this.report = new system.Report(report)
this.initialReportState = this.report.clone()
this.detectStateChange = false
this.toastSuccess(this.$t('notification:report.updated'))
})
.catch(this.toastErrorHandler(this.$t('notification:report.updateFailed')))
.finally(() => {
this.processing = false
this.processingSave = false
})
}
},
handleDelete () {
this.processing = true
this.processingDelete = true
return this.$SystemAPI.reportDelete(this.report)
.then(() => {
this.report.deletedAt = new Date()
this.toastSuccess(this.$t('notification:report.delete'))
this.$router.push({ name: 'report.list' })
})
.catch(this.toastErrorHandler(this.$t('notification:report.deleteFailed')))
.finally(() => {
this.processing = false
this.processingDelete = false
})
},
handleClone (report) {
this.processing = true
this.processingClone = true
const { meta, sources, blocks, scenarios, labels } = report
const cloneSuffix = `${meta.name} (${this.$t('general:cloneSuffix')})`
let name = meta.name
if (!this.initialReportState || this.initialReportState.meta.name === name) {
name = cloneSuffix
}
return this.$SystemAPI.reportCreate({ handle: '', meta: { ...meta, name }, sources, blocks, scenarios, labels })
.then(report => {
report = new system.Report(report)
this.toastSuccess(this.$t('notification:report.created'))
return report
})
.catch(this.toastErrorHandler(this.$t('notification:report.createFailed')))
.finally(() => {
this.processing = false
this.processingClone = false
})
},
},
}

View File

@@ -0,0 +1,26 @@
import Vue from 'vue'
import Router from 'vue-router'
import { BootstrapVue, BootstrapVueIcons } from 'bootstrap-vue'
import VueNativeSock from 'vue-native-websocket'
import { plugins, websocket } from '@cortezaproject/corteza-vue'
Vue.use(Router)
Vue.use(BootstrapVue, {
BToast: {
// see https://bootstrap-vue.org/docs/components/toast#comp-ref-b-toast-props
autoHideDelay: 7000,
toaster: 'b-toaster-bottom-right',
},
BModal: {
noEnforceFocus: true,
},
})
Vue.use(BootstrapVueIcons)
Vue.use(plugins.Auth(), { app: 'reporter' })
Vue.use(plugins.CortezaAPI('system'))
Vue.use(plugins.CortezaAPI('compose'))
Vue.use(plugins.Settings, { api: Vue.prototype.$SystemAPI })
Vue.use(VueNativeSock, websocket.endpoint(), websocket.config)

View File

@@ -0,0 +1,7 @@
import Router from 'vue-router'
import routes from './views/routes.js'
export default new Router({
mode: 'history',
routes,
})

View File

@@ -0,0 +1,23 @@
import Vue from 'vue'
import Vuex from 'vuex'
import { store as cvStore } from '@cortezaproject/corteza-vue'
Vue.use(Vuex)
const store = new Vuex.Store({
modules: {
rbac: {
namespaced: true,
...cvStore.RBAC(Vue.prototype.$SystemAPI),
},
notifications: {
namespaced: true,
...cvStore.notifications({
api: Vue.prototype.$SystemAPI,
}),
},
},
})
export default store

View File

@@ -0,0 +1 @@
import './index.scss'

View File

@@ -0,0 +1,2 @@
@import "./custom";
@import "./poppins.scss";

View File

@@ -0,0 +1,146 @@
// Fonts Path
$fonts_dir : './fonts/' !default;
@font-face {
font-family: 'Poppins-Bold';
src: url($fonts_dir + 'poppins/Poppins-Bold.ttf') format('truetype');
font-weight: 100 900;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Poppins-BlackItalic';
src: url($fonts_dir + 'poppins/Poppins-BlackItalic.ttf') format('truetype');
font-weight: 100 900;
font-style: italic;
font-display: swap;
}
@font-face {
font-family: 'Poppins-Italic';
src: url($fonts_dir + 'poppins/Poppins-Italic.ttf') format('truetype');
font-weight: 100 900;
font-style: italic;
font-display: swap;
}
@font-face {
font-family: 'Poppins-BoldItalic';
src: url($fonts_dir + 'poppins/Poppins-BoldItalic.ttf') format('truetype');
font-weight: 100 900;
font-style: italic;
font-display: swap;
}
@font-face {
font-family: 'Poppins-ExtraBoldItalic';
src: url($fonts_dir + 'poppins/Poppins-ExtraBoldItalic.ttf') format('truetype');
font-weight: 100 900;
font-style: italic;
font-display: swap;
}
@font-face {
font-family: 'Poppins-ExtraLightItalic';
src: url($fonts_dir + 'poppins/Poppins-ExtraLightItalic.ttf') format('truetype');
font-weight: 100 900;
font-style: italic;
font-display: swap;
}
@font-face {
font-family: 'Poppins-Black';
src: url($fonts_dir + 'poppins/Poppins-Black.ttf') format('truetype');
font-weight: 100 900;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Poppins-ExtraBold';
src: url($fonts_dir + 'poppins/Poppins-ExtraBold.ttf') format('truetype');
font-weight: 100 900;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Poppins-ExtraLight';
src: url($fonts_dir + 'poppins/Poppins-ExtraLight.ttf') format('truetype');
font-weight: 100 900;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Poppins-MediumItalic';
src: url($fonts_dir + 'poppins/Poppins-MediumItalic.ttf') format('truetype');
font-weight: 100 900;
font-style: italic;
font-display: swap;
}
@font-face {
font-family: 'Poppins-SemiBoldItalic';
src: url($fonts_dir + 'poppins/Poppins-SemiBoldItalic.ttf') format('truetype');
font-weight: 100 900;
font-style: italic;
font-display: swap;
}
@font-face {
font-family: 'Poppins-SemiBold';
src: url($fonts_dir + 'poppins/Poppins-SemiBold.ttf') format('truetype');
font-weight: 100 900;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Poppins-LightItalic';
src: url($fonts_dir + 'poppins/Poppins-LightItalic.ttf') format('truetype');
font-weight: 100 900;
font-style: italic;
font-display: swap;
}
@font-face {
font-family: 'Poppins-Regular';
src: url($fonts_dir + 'poppins/Poppins-Regular.ttf') format('truetype');
font-weight: 100 900;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Poppins-Medium';
src: url($fonts_dir + 'poppins/Poppins-Medium.ttf') format('truetype');
font-weight: 100 900;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Poppins-Light';
src: url($fonts_dir + 'poppins/Poppins-Light.ttf') format('truetype');
font-weight: 100 900;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Poppins-ThinItalic';
src: url($fonts_dir + 'poppins/Poppins-ThinItalic.ttf') format('truetype');
font-weight: 100 900;
font-style: italic;
font-display: swap;
}
@font-face {
font-family: 'Poppins-Thin';
src: url($fonts_dir + 'poppins/Poppins-Thin.ttf') format('truetype');
font-weight: 100 900;
font-style: normal;
font-display: swap;
}

View File

@@ -0,0 +1,2 @@
// eslint-disable-next-line no-unused-expressions
import(/* webpackChunkName: 'corteza-base' */ './corteza-base')

View File

@@ -0,0 +1,198 @@
<template>
<div class="d-flex flex-column w-100 h-viewport overflow-hidden">
<header>
<c-topbar
:expanded="expanded"
:settings="$Settings.get('ui.topbar', {})"
:labels="{
appMenu: $t('navigation:appMenu'),
helpForum: $t('navigation:help.forum'),
helpDocumentation: $t('navigation:help.documentation'),
helpFeedback: $t('navigation:help.feedback'),
helpVersion: $t('navigation:help.version'),
userSettingsLoggedInAs: $t('navigation:userSettings.loggedInAs', { user }),
userSettingsProfile: $t('navigation:userSettings.profile'),
userSettingsChangePassword: $t('navigation:userSettings.changePassword'),
userSettingsLogout: $t('navigation:userSettings.logout'),
userSettingsTheme: $t('navigation:userSettings.theme'),
lightTheme: $t('general:themes.labels.light'),
darkTheme: $t('general:themes.labels.dark'),
}"
>
<template #title>
<portal-target
name="topbar-title"
/>
</template>
<template #tools>
<portal-target
name="topbar-tools"
class="gap-1"
/>
</template>
</c-topbar>
</header>
<aside>
<c-sidebar
:expanded.sync="expanded"
:icon="icon"
:logo="logo"
:disabled-routes="disabledRoutes"
expand-on-click
>
<template #header-expanded>
<portal-target name="sidebar-header-expanded" />
</template>
<template #body-expanded>
<portal-target name="sidebar-body-expanded" />
</template>
<template #footer-expanded>
<portal-target name="sidebar-footer-expanded" />
</template>
</c-sidebar>
</aside>
<main class="d-inline-flex h-100 overflow-auto">
<!--
Content spacer
Large and xl screens should push in content when the nav is expanded
-->
<template>
<div
class="sidebar-spacer d-print-none"
:class="{
'expanded': expanded,
}"
/>
</template>
<div
class="d-flex flex-column w-100"
>
<router-view
class="flex-grow-1 overflow-auto"
/>
<portal-target
name="report-toolbar"
/>
</div>
</main>
<c-permissions-modal
:labels="{
save: $t('permissions:ui.save'),
cancel: $t('permissions:ui.cancel'),
loading: $t('permissions:ui.loading'),
edit: {
label: $t('permissions:ui.edit.label'),
description: $t('permissions:ui.edit.description'),
},
evaluate: {
title: $t('permissions:ui.evaluate.title'),
description: $t('permissions:ui.evaluate.description'),
},
add: {
label: $t('permissions:ui.add.label'),
title: $t('permissions:ui.add.title'),
save: $t('permissions:ui.add.save'),
role: {
label: $t('permissions:ui.add.role.label'),
placeholder: $t('permissions:ui.add.role.placeholder'),
},
user: {
label: $t('permissions:ui.add.user.label'),
placeholder: $t('permissions:ui.add.user.placeholder'),
},
},
}"
/>
<report-sidebar />
<c-extend-session
v-if="isAutoLogoutEnabled"
:timeout="$Settings.get('auth.autoLogout.timeout')"
:labels="{
extend: $t('general:extendSession.labels.extend'),
warning: (countdownTime) => $t('general:extendSession.labels.warning', { countdownTime }),
}"
/>
<c-notification-sidebar v-if="!$Settings.get('ui.topbar', {}).hideNotifications" />
</div>
</template>
<script>
import { components } from '@cortezaproject/corteza-vue'
import ReportSidebar from 'corteza-webapp-reporter/src/components/ReportSidebar'
const { CPermissionsModal, CTopbar, CSidebar, CExtendSession, CNotificationSidebar } = components
export default {
components: {
CPermissionsModal,
CTopbar,
CSidebar,
ReportSidebar,
CExtendSession,
CNotificationSidebar,
},
data () {
return {
expanded: false,
disabledRoutes: [
'report.list',
'report.create',
'report.edit',
],
}
},
computed: {
user () {
const { user } = this.$auth
return user.name || user.handle || user.email || ''
},
icon () {
return this.$Settings.attachment('ui.iconLogo')
},
logo () {
return this.$Settings.attachment('ui.mainLogo')
},
isAutoLogoutEnabled () {
return this.$Settings.get('auth.autoLogout.enabled')
},
},
created () {
this.$root.$on('alert', this.displayToast)
},
methods: {
displayToast ({ title, message, variant, countdown }) {
this.$bvToast.toast(message, {
title,
variant,
solid: true,
autoHideDelay: countdown,
toaster: 'b-toaster-bottom-right',
})
},
},
}
</script>
<style scoped>
.h-viewport {
height: 100vh;
height: 100dvh;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,341 @@
<template>
<div class="py-3">
<portal to="topbar-title">
<div
class="d-flex w-100"
>
{{ pageTitle }}
</div>
</portal>
<portal to="topbar-tools">
<b-button-group
v-if="report && !isNew"
size="sm"
>
<b-button
variant="primary"
data-test-id="button-report-builder"
class="d-flex align-items-center justify-content-center"
:disabled="!report.canUpdateReport"
:to="reportBuilder"
>
{{ $t('report.builder') }}
<font-awesome-icon
class="ml-2"
:icon="['fas', 'tools']"
/>
</b-button>
<b-button
v-b-tooltip.noninteractive.hover="{ title: $t('tooltip.view-report'), boundary: 'body' }"
variant="primary"
style="margin-left:2px;"
:disabled="!canRead"
:to="reportViewer"
>
<font-awesome-icon
:icon="['far', 'eye']"
/>
</b-button>
</b-button-group>
</portal>
<b-container v-if="report">
<b-row no-gutters>
<b-col>
<b-card
no-body
show
class="shadow-sm"
>
<b-card-header
v-if="!isNew"
header-bg-variant="white border-bottom"
>
<b-row
no-gutters
class="align-items-center"
>
<div>
<b-button
v-if="canCreate"
data-test-id="button-create-report"
variant="primary"
size="lg"
class="mr-1"
:to="{ name: 'report.create' }"
>
{{ $t('new-report') }}
</b-button>
<c-permissions-button
v-if="canGrant"
:title="report.meta.name || report.handle || report.reportID"
:target="report.meta.name || report.handle || report.reportID"
:resource="`corteza::system:report/${report.reportID}`"
:button-label="$t('permissions')"
class="btn-lg ml-1"
/>
</div>
</b-row>
</b-card-header>
<b-container
fluid
class="py-3"
>
<b-row>
<b-col
cols="12"
lg="6"
xl="4"
>
<b-form-group
:label="$t('name-with-star')"
label-class="text-primary"
>
<b-form-input
v-model="report.meta.name"
data-test-id="input-name"
:placeholder="$t('name')"
required
:state="nameState"
@input="handleDetectStateChange"
/>
</b-form-group>
</b-col>
<b-col
cols="12"
lg="6"
xl="4"
>
<b-form-group
:label="$t('handle-with-star')"
label-class="text-primary"
>
<b-form-input
v-model="report.handle"
data-test-id="input-handle"
:placeholder="$t('placeholder-handle')"
required
:state="handleState"
@input="handleDetectStateChange"
/>
<b-form-invalid-feedback
data-test-id="input-handle-invalid-state"
:state="handleState"
>
{{ $t('invalid-handle-characters') }}
</b-form-invalid-feedback>
</b-form-group>
</b-col>
</b-row>
<b-form-group
:label="$t('description')"
label-class="text-primary"
class="mb-0"
>
<b-form-textarea
v-model="report.meta.description"
data-test-id="input-description"
:placeholder="$t('report.description')"
rows="5"
@input="handleDetectStateChange"
/>
</b-form-group>
<!-- <b-form-group
label="Tags"
class="text-primary"
>
<b-form-tags
v-model="report.meta.tags"
/>
</b-form-group> -->
</b-container>
</b-card>
</b-col>
</b-row>
</b-container>
<div
v-else
class="d-flex align-items-center justify-content-center w-100 h-100"
>
<b-spinner />
</div>
<portal to="report-toolbar">
<editor-toolbar
:back-link="{ name: 'report.list' }"
:hide-delete="isNew"
:delete-disabled="!canDelete"
:save-disabled="!canSave"
:clone-disabled="!canSave"
:processing="processing"
:processing-save="processingSave"
:processing-delete="processingDelete"
:processing-clone="processingClone"
@clone="handleReportCloning"
@delete="handleDelete"
@save="handleSave"
/>
</portal>
</div>
</template>
<script>
import { system } from '@cortezaproject/corteza-js'
import { handle } from '@cortezaproject/corteza-vue'
import report from 'corteza-webapp-reporter/src/mixins/report'
import EditorToolbar from 'corteza-webapp-reporter/src/components/EditorToolbar'
import { mapGetters } from 'vuex'
import { isEqual } from 'lodash'
export default {
name: 'EditReport',
i18nOptions: {
namespaces: 'edit',
},
components: {
EditorToolbar,
},
mixins: [
report,
],
beforeRouteUpdate (to, from, next) {
this.checkUnsavedChart(next)
},
beforeRouteLeave (to, from, next) {
this.checkUnsavedChart(next)
},
data () {
return {
processing: false,
report: undefined,
initialReportState: undefined,
detectStateChange: false,
}
},
computed: {
...mapGetters({
can: 'rbac/can',
}),
canGrant () {
return this.can('system/', 'grant')
},
canCreate () {
return this.can('system/', 'report.create')
},
canRead () {
return this.report ? this.report.canReadReport : false
},
canDelete () {
return this.report ? this.report.canDeleteReport : false
},
canUpdate () {
return this.isNew ? this.canCreate : (this.report && this.report.canUpdateReport) || false
},
reportID () {
return this.$route.params.reportID
},
isNew () {
return !this.reportID
},
pageTitle () {
return this.isNew ? this.$t('report.create') : this.$t('report.edit')
},
reportBuilder () {
return this.report ? { name: 'report.builder', params: { reportID: this.report.reportID } } : undefined
},
reportViewer () {
return this.report ? { name: 'report.view', params: { reportID: this.report.reportID } } : undefined
},
nameState () {
const { name = '' } = this.report.meta
return name.length ? null : false
},
handleState () {
return handle.handleState(this.report.handle)
},
canSave () {
return this.canUpdate && ![this.nameState, this.handleState].includes(false)
},
},
watch: {
reportID: {
immediate: true,
handler (reportID) {
// Fetch report or make a new one
if (reportID) {
this.fetchReport(reportID)
} else {
this.report = new system.Report()
this.initialReportState = new system.Report()
}
},
},
},
methods: {
handleDetectStateChange () {
this.detectStateChange = true
},
checkUnsavedChart (next) {
if (this.report.deletedAt) {
return next(true)
}
const reportState = {
handle: this.report.handle,
meta: {
name: this.report.meta.name,
description: this.report.meta.description,
},
}
const initialReportState = {
handle: this.initialReportState.handle,
meta: {
name: this.initialReportState.meta.name,
description: this.initialReportState.meta.description,
},
}
next(!isEqual(reportState, initialReportState) ? window.confirm(this.$t('unsavedChanges')) : true)
},
handleReportCloning () {
this.handleClone(this.report).then(({ reportID }) => {
this.$router.push({ name: 'report.builder', params: { reportID } })
})
},
},
}
</script>

View File

@@ -0,0 +1,262 @@
<template>
<b-container
fluid="xl"
class="d-flex flex-column py-3"
>
<portal to="topbar-title">
{{ $t('report.list') }}
</portal>
<c-resource-list
:primary-key="primaryKey"
:filter="filter"
:sorting="sorting"
:pagination="pagination"
:fields="tableFields"
:items="reportList"
:translations="{
searchPlaceholder: $t('searchPlaceholder'),
notFound: $t('general:resourceList.notFound'),
noItems: $t('general:resourceList.noItems'),
loading: $t('general:label.loading'),
showingPagination: 'general:resourceList.pagination.showing',
singlePluralPagination: 'general:resourceList.pagination.single',
prevPagination: $t('general:resourceList.pagination.prev'),
nextPagination: $t('general:resourceList.pagination.next'),
resourceSingle: $t('general:label.report.single'),
resourcePlural: $t('general:label.report.plural')
}"
sticky-header
clickable
class="h-100 flex-fill"
@search="filterList"
@row-clicked="viewReport"
>
<template #header>
<b-button
v-if="canCreate"
data-test-id="button-create-report"
variant="primary"
size="lg"
:to="{ name: 'report.create' }"
>
{{ $t('report.new') }}
</b-button>
<c-permissions-button
v-if="canGrant"
resource="corteza::system:report/*"
:button-label="$t('permissions')"
size="lg"
/>
</template>
<template #name="{ item: r }">
{{ r.meta.name }}
</template>
<template #changedAt="{ item }">
{{ (item.deletedAt || item.updatedAt || item.createdAt) | locFullDateTime }}
</template>
<template #actions="{ item: r }">
<b-button-group
v-if="r.canUpdateReport"
size="sm"
>
<b-button
data-test-id="button-report-builder"
variant="primary"
size="sm"
:to="{ name: 'report.builder', params: { reportID: r.reportID } }"
>
{{ $t('report.builder') }}
<font-awesome-icon
:icon="['fas', 'tools']"
class="ml-2"
/>
</b-button>
<b-button
v-b-tooltip.noninteractive.hover="{ title: $t('report.edit'), boundary: 'body' }"
data-test-id="button-report-edit"
variant="primary"
:to="{ name: 'report.edit', params: { reportID: r.reportID } }"
class="d-flex align-items-center"
style="margin-left:2px;"
>
<font-awesome-icon
:icon="['far', 'edit']"
/>
</b-button>
</b-button-group>
<b-dropdown
v-if="r.canUpdateReport || r.canGrant || r.canDeleteReport"
variant="outline-extra-light"
toggle-class="d-flex align-items-center justify-content-center text-primary border-0 py-2"
no-caret
lazy
menu-class="m-0"
class="ml-2"
>
<template #button-content>
<font-awesome-icon
:icon="['fas', 'ellipsis-v']"
/>
</template>
<b-dropdown-item-button @click="handleReportCloning(r)">
<font-awesome-icon
:icon="['fa','clone']"
/>
{{ $t('general:resourceList.clone') }}
</b-dropdown-item-button>
<c-permissions-button
v-if="r.canGrant"
:tooltip="$t('permissions:resources.system.report.tooltip')"
:title="r.meta.name || r.handle || r.reportID"
:target="r.meta.name || r.handle || r.reportID"
:resource="`corteza::system:report/${r.reportID}`"
:button-label="$t('permissions:ui.label')"
class="dropdown-item"
/>
<c-input-confirm
v-if="r.canDeleteReport"
:processing="processingDelete"
:text="$t('report.delete')"
borderless
variant="link"
size="md"
show-icon
text-class="p-1"
button-class="dropdown-item"
icon-class="text-danger"
class="w-100"
@confirmed="handleDelete(r)"
/>
</b-dropdown>
</template>
</c-resource-list>
</b-container>
</template>
<script>
import { mapGetters } from 'vuex'
import listHelpers from 'corteza-webapp-reporter/src/mixins/listHelpers'
import report from 'corteza-webapp-reporter/src/mixins/report'
import { components } from '@cortezaproject/corteza-vue'
const { CResourceList } = components
export default {
name: 'ReportList',
i18nOptions: {
namespaces: 'list',
},
components: {
CResourceList,
},
mixins: [
listHelpers,
report,
],
data () {
return {
primaryKey: 'reportID',
processingDelete: false,
filter: {
query: '',
},
sorting: {
sortBy: 'handle',
sortDesc: false,
},
}
},
computed: {
...mapGetters({
can: 'rbac/can',
}),
canGrant () {
return this.can('system/', 'grant')
},
canCreate () {
return this.can('system/', 'report.create')
},
tableFields () {
return [
{
key: 'name',
label: this.$t('columns.name'),
sortable: false,
tdClass: 'text-nowrap',
},
{
key: 'handle',
label: this.$t('columns.handle'),
sortable: true,
},
{
key: 'changedAt',
label: this.$t('columns.changedAt'),
sortable: true,
class: 'text-right text-nowrap',
},
{
key: 'actions',
label: '',
tdClass: 'text-right text-nowrap actions gap-1',
},
]
},
},
methods: {
viewReport ({ reportID, canReadReport = false }) {
if (reportID) {
this.$router.push({
name: 'report.view',
params: { reportID },
})
}
},
reportList () {
return this.procListResults(this.$SystemAPI.reportListCancellable(this.encodeListParams()))
},
handleDelete (report) {
this.processingDelete = true
return this.$SystemAPI.reportDelete(report)
.then(() => {
this.toastSuccess(this.$t('notification:report.delete'))
this.filterList()
})
.catch(this.toastErrorHandler(this.$t('notification:report.deleteFailed')))
.finally(() => {
this.processingDelete = false
})
},
handleReportCloning (report) {
this.handleClone(report).then(({ reportID }) => {
this.viewReport({ reportID })
})
},
},
}
</script>

View File

@@ -0,0 +1,208 @@
<template>
<div
class="d-flex overflow-auto p-2 w-100"
>
<portal
v-if="!fetchingReport"
to="topbar-title"
>
{{ pageTitle }}
</portal>
<portal to="topbar-tools">
<c-input-select
v-if="scenarioOptions.length"
v-model="scenarios.selected"
:options="scenarioOptions"
:get-option-key="getOptionKey"
:placeholder="$t('pick-scenario')"
:disabled="processing || fetchingReport"
size="sm"
style="max-width: 300px; min-width: 150px;"
@input="refreshReport()"
/>
<b-button-group
v-if="canUpdate"
size="sm"
>
<b-button
variant="primary"
class="d-flex align-items-center justify-content-center"
:to="reportBuilder"
>
{{ $t('report.builder') }}
<font-awesome-icon
class="ml-2"
:icon="['fas', 'tools']"
/>
</b-button>
<b-button
v-b-tooltip.noninteractive.hover="{ title: $t('tooltip.edit.report'), boundary: 'body' }"
variant="primary"
class="d-flex align-items-center justify-content-center"
style="margin-left:2px;"
:to="reportEditor"
>
<font-awesome-icon
:icon="['far', 'edit']"
/>
</b-button>
</b-button-group>
</portal>
<div
v-if="fetchingReport"
class="d-flex align-items-center justify-content-center w-100 h-100"
>
<b-spinner />
</div>
<grid
v-if="report && canRead && showReport && !fetchingReport"
:blocks="report.blocks"
>
<template
slot-scope="{ block, index }"
>
<block
:index="index"
:block="block"
:scenario="currentSelectedScenario"
:report-i-d="reportID"
/>
</template>
</grid>
</div>
</template>
<script>
import { system } from '@cortezaproject/corteza-js'
import Grid from 'corteza-webapp-reporter/src/components/Report/Grid'
import Block from 'corteza-webapp-reporter/src/components/Report/Blocks'
export default {
name: 'ReportView',
i18nOptions: {
namespaces: 'view',
},
components: {
Grid,
Block,
},
data () {
return {
processing: false,
showReport: true,
fetchingReport: false,
report: undefined,
dataframes: [],
scenarios: {
selected: undefined,
},
}
},
computed: {
reportID () {
return this.$route.params.reportID
},
pageTitle () {
const title = this.report ? (this.report.meta.name || this.report.handle) : ''
return title || this.$t('report.view')
},
canRead () {
return this.report ? this.report.canReadReport : false
},
canUpdate () {
return this.isNew ? this.canCreate : (this.report && this.report.canUpdateReport) || false
},
reportBuilder () {
return this.report ? { name: 'report.builder', params: { reportID: this.report.reportID } } : undefined
},
reportEditor () {
return this.report ? { name: 'report.edit', params: { reportID: this.report.reportID } } : undefined
},
reportDatasources () {
return this.report ? this.report.sources : []
},
reportScenarios () {
return this.report ? this.report.scenarios : []
},
scenarioOptions () {
return this.report ? this.reportScenarios.map(({ label }) => label) : []
},
currentSelectedScenario () {
return this.scenarios.selected ? this.reportScenarios.find(({ label }) => label === this.scenarios.selected) : undefined
},
},
watch: {
reportID: {
immediate: true,
handler (reportID) {
this.scenarios.selected = undefined
this.report = undefined
if (reportID) {
this.fetchingReport = true
this.processing = true
this.fetchReport(reportID)
.catch(() => {
this.toastErrorHandler(this.$t('notification:report.loadFailed'))
})
.finally(() => {
setTimeout(() => {
this.fetchingReport = false
this.processing = false
}, 400)
})
}
},
},
},
methods: {
refreshReport () {
this.showReport = false
return setTimeout(() => {
this.showReport = true
}, 50)
},
async fetchReport (reportID) {
this.report = undefined
return this.$SystemAPI.reportRead({ reportID })
.then(report => {
this.report = new system.Report(report)
this.report.blocks = this.report.blocks.map(({ xywh, ...p }, i) => {
const [x, y, w, h] = xywh
return { ...p, x, y, w, h, i }
})
})
.catch(this.toastErrorHandler(this.$t('notification:report.fetchFailed')))
},
getOptionKey (scenario) {
return scenario
},
},
}
</script>

View File

@@ -0,0 +1,17 @@
export default [
{
path: '',
name: 'root',
redirect: { name: 'report.list' },
component: () => import('./Layout'),
children: [
{ name: 'report.list', path: 'list', component: () => import('./Report/List') },
{ name: 'report.create', path: 'new', component: () => import('./Report/Edit') },
{ name: 'report.view', path: '/:reportID', component: () => import('./Report/View') },
{ name: 'report.edit', path: '/:reportID/edit', component: () => import('./Report/Edit') },
{ name: 'report.builder', path: '/:reportID/builder', component: () => import('./Report/Builder') },
],
},
{ path: '*', redirect: { name: 'root' } },
]

View File

@@ -0,0 +1,31 @@
/* global jest */
jest.mock('@cortezaproject/corteza-js', () => ({}), { virtual: true })
jest.mock('@cortezaproject/corteza-vue', () => ({
components: {
CToaster: jest.fn(),
CPrompts: {
name: 'c-prompts',
render: () => {},
},
CPermissionsModal: {
name: 'c-permissions-modal',
render: () => {},
},
CTopbar: {
name: 'c-topbar',
render: () => {},
},
CSidebar: {
name: 'c-sidebar',
render: () => {},
},
},
mixins: {
corredor: {
methods: {
triggerScript: jest.fn(),
},
},
},
}), { virtual: true })

View File

@@ -0,0 +1,7 @@
module.exports = {
env: {
es6: true,
node: true,
mocha: true,
},
}

View File

@@ -0,0 +1,78 @@
/* eslint-disable no-unused-expressions */
/* global jest */
import { expect } from 'chai'
import { shallowMount, createLocalVue } from '@vue/test-utils'
import Layout from '../../../src/views/Layout'
import BootstrapVue from 'bootstrap-vue'
import PortalVue from 'portal-vue'
import Vuex from 'vuex'
import sinon from 'sinon'
describe('Layout.vue', () => {
let localVue
let store
let rbacModule
beforeEach(() => {
localVue = createLocalVue()
localVue.use(BootstrapVue)
localVue.use(PortalVue)
localVue.use(Vuex)
// Mock the Vuex store
rbacModule = {
namespaced: true,
getters: {
can: () => () => true,
},
}
store = new Vuex.Store({
modules: {
rbac: rbacModule,
},
})
})
afterEach(() => {
sinon.restore()
})
it('renders', () => {
const wrapper = shallowMount(Layout, {
localVue,
store,
mocks: {
$auth: {
user: {},
},
$Settings: {
get: () => ({}),
attachment: () => '',
},
$t: (key) => key,
textDirectionality: () => 'ltr',
$root: {
$on: jest.fn(),
},
},
stubs: [
'router-view',
'portal-target',
'c-topbar',
'c-sidebar',
'c-prompts',
'c-permissions-modal',
],
})
expect(wrapper.find('div').exists()).to.be.true
expect(wrapper.find('div').classes()).to.include.members([
'd-flex',
'flex-column',
'w-100',
'h-viewport',
'overflow-hidden',
])
})
})

View File

@@ -0,0 +1,239 @@
const webpack = require('webpack')
const exec = require('child_process').execSync
const path = require('path')
const Vue = require('vue')
module.exports = ({ appFlavour, appLabel, version = process.env.BUILD_VERSION, theme, packageAlias, root = path.resolve('.'), env = process.env.NODE_ENV }) => {
const isDevelopment = (env === 'development')
const isTest = (env === 'test')
if (isTest) {
Vue.config.devtools = false
Vue.config.productionTip = false
}
if (isDevelopment) {
Vue.config.devtools = true
Vue.config.performance = true
}
const optimization = isTest
? {}
: {
usedExports: true,
runtimeChunk: 'single',
splitChunks: {
chunks: 'all',
minSize: 20000,
maxSize: 244000,
cacheGroups: {
vendor: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
chunks: 'all',
priority: -10,
},
common: {
minChunks: 2,
priority: -20,
reuseExistingChunk: true,
},
},
},
}
return {
publicPath: isDevelopment ? '/' : './',
lintOnSave: true,
runtimeCompiler: true,
configureWebpack: {
// other webpack options to merge in ...
// Webpack 5 specific configuration
resolve: {
symlinks: true,
fallback: {
path: false,
fs: false,
crypto: false,
stream: false,
util: false,
},
},
plugins: [
new webpack.DefinePlugin({
FLAVOUR: JSON.stringify(appFlavour),
WEBAPP: JSON.stringify(appLabel),
VERSION: JSON.stringify(version || ('' + exec('git describe --always --tags')).trim()),
BUILD_TIME: JSON.stringify((new Date()).toISOString()),
}),
],
optimization: {
...optimization,
moduleIds: 'deterministic',
chunkIds: 'named',
emitOnErrors: false,
},
stats: {
warnings: false,
errors: false,
},
performance: {
hints: isDevelopment ? false : 'warning',
maxEntrypointSize: 512000,
maxAssetSize: 512000,
},
},
chainWebpack: config => {
// Remove css extraction issues
// https://github.com/vuejs/vue-cli/issues/3771#issuecomment-526228100
config.plugin('friendly-errors').tap(args => {
const vueCli3Transformer = args[0].additionalTransformers[0]
args[0].additionalTransformers = [
vueCli3Transformer,
error => {
return /\[mini-css-extract-plugin\]/.test(error.message) ? {} : error
},
]
return args
})
// Update copy-webpack-plugin configuration for v8+
config.plugins.has('copy') && config.plugin('copy').tap(options => {
options[0].patterns = [{
from: 'public',
globOptions: {
ignore: ['**/config*js', '**/*gitignore', '**/index.html'],
},
noErrorOnMissing: true,
}]
return options
})
// Aliasing full package name instead of '@' so we do
// not break imports on apps that import this code
config.resolve.alias.delete('@')
if (packageAlias) {
config.resolve.alias.set(packageAlias, root)
}
if (isTest) {
const scssRule = config.module.rule('scss')
scssRule.uses.clear()
scssRule
.use('null-loader')
.loader('null-loader')
}
const scssNormal = config.module.rule('scss').oneOf('normal')
scssNormal.use('sass-loader')
.loader('sass-loader')
.tap(options => ({
...options,
sourceMap: true,
sassOptions: {
outputStyle: isDevelopment ? 'expanded' : 'compressed',
},
}))
// Load CSS assets according to their location
scssNormal.use('resolve-url-loader')
.loader('resolve-url-loader').options({
keepQuery: true,
removeCR: true,
root: path.join(root, 'src/themes', theme),
})
.before('sass-loader')
// Keep this to ensure we don't have multiple HTML plugins
},
devServer: {
host: '127.0.0.1',
port: 8080,
hot: true,
allowedHosts: 'all',
webSocketServer: 'ws',
liveReload: false,
headers: {
'Access-Control-Allow-Origin': '*',
},
proxy: {
'^/custom.css': {
target: fetchBaseUrl(),
},
'^/code-snippets.js': {
target: fetchBaseUrl(),
},
},
watchFiles: {
paths: [
'**/*',
'!**/node_modules/!(@cortezaproject)/**',
],
options: {
usePolling: true,
aggregateTimeout: 200,
poll: 1000,
},
},
client: {
overlay: false,
progress: true,
},
},
css: {
sourceMap: isDevelopment,
extract: !isTest,
loaderOptions: {
sass: {
sassOptions: {
outputStyle: isDevelopment ? 'expanded' : 'compressed',
},
},
postcss: {
postcssOptions: {
plugins: [
require('autoprefixer'),
require('rtlcss'),
],
},
},
},
},
}
}
function fetchBaseUrl () {
const fs = require('fs')
const window = {}
const fileContents =
fs.existsSync('public/config.js')
? fs.readFileSync('public/config.js', 'utf-8')
: ''
try {
// eslint-disable-next-line no-eval
eval(fileContents)
const u = window.CortezaAPI || ''
const ur = new URL(u.startsWith('//') ? `http:${u}` : u)
return `${ur.protocol}//${ur.host}/`
} catch (e) {
return '/'
}
}

View File

@@ -0,0 +1,9 @@
const buildVueConfig = require('./vue.config-builder')
module.exports = buildVueConfig({
appFlavour: 'Reporter',
appName: 'reporter',
appLabel: 'Corteza Reporter Editor',
theme: 'corteza-base',
packageAlias: 'corteza-webapp-reporter',
})