Add drill down for compose chart page block

This commit is contained in:
Jože Fortun
2023-02-17 18:02:18 +01:00
parent 0488e50e8c
commit cfc4ab7424
12 changed files with 325 additions and 92 deletions
@@ -11,6 +11,7 @@
v-if="renderer"
:chart="renderer"
class="flex-fill p-1"
v-on="$listeners"
/>
</div>
</template>
@@ -10,6 +10,7 @@
:chart="chart"
:record="record"
:reporter="reporter"
@click="drillDown"
/>
</wrap>
</template>
@@ -17,7 +18,7 @@
import { mapActions } from 'vuex'
import base from './base'
import ChartComponent from '../Chart'
import { NoID } from '@cortezaproject/corteza-js'
import { NoID, compose } from '@cortezaproject/corteza-js'
import { evaluatePrefilter } from 'corteza-webapp-compose/src/lib/record-filter'
export default {
@@ -33,12 +34,18 @@ export default {
data () {
return {
chart: null,
filter: undefined,
drillDownFilter: undefined,
}
},
mounted () {
this.fetchChart()
this.refreshBlock(this.refresh)
this.$root.$on('drill-down-chart', this.drillDown)
},
methods: {
@@ -49,7 +56,7 @@ export default {
async fetchChart (params = {}) {
const { chartID } = this.options
if (chartID === NoID) {
if (!chartID) {
return
}
@@ -61,15 +68,18 @@ export default {
},
reporter (r) {
const nr = { ...r }
if (nr.filter) {
this.filter = r
let filter = r.filter
if (filter) {
// If we use ${record} or ${ownerID} and there is no record, resolve empty
/* eslint-disable no-template-curly-in-string */
if (!this.record && (nr.filter.includes('${record') || nr.filter.includes('${ownerID}'))) {
if (!this.record && (filter.includes('${record') || filter.includes('${ownerID}'))) {
return new Promise((resolve) => resolve([]))
}
nr.filter = evaluatePrefilter(nr.filter, {
filter = evaluatePrefilter(filter, {
record: this.record,
recordID: (this.record || {}).recordID || NoID,
ownerID: (this.record || {}).ownedBy || NoID,
@@ -78,7 +88,8 @@ export default {
}
const { namespaceID } = this.namespace
return this.$ComposeAPI.recordReport({ namespaceID, ...nr })
return this.$ComposeAPI.recordReport({ namespaceID, ...r, filter })
},
refresh () {
@@ -87,6 +98,60 @@ export default {
this.key++
})
},
/**
*
* @param {*} name
* Based on drill down configuration, either changes the linked block on the page
* or opens it in a modal wit the filter and dimensions from the chart and the clicked value
*/
drillDown ({ name }) {
const { chartID, drillDown } = this.options
if (!drillDown.enabled) {
return
}
// Get recordListID that is linked
let { moduleID, dimensions, filter } = this.filter
// Construct filter
const dimensionFilter = dimensions ? `(${dimensions} = '${name}')` : ''
filter = filter ? `(${filter})` : ''
const prefilter = [dimensionFilter, filter].filter(f => f).join(' AND ')
if (drillDown.blockID) {
// Use linked record list to display drill down data
const { pageID = NoID } = this.page
const { recordID = NoID } = this.record || {}
// Construct its uniqueID to identify it
const recordListUniqueID = [pageID, recordID, drillDown.blockID].map(v => v || NoID).join('-')
this.$root.$emit(`drill-down-recordList:${recordListUniqueID}`, prefilter)
} else {
// Open in modal
const block = new compose.PageBlockRecordList({
title: `${dimensions} = '${name}'`,
blockID: `drillDown-${chartID}`,
options: {
moduleID,
prefilter,
presort: 'createdAt DESC',
hideRecordReminderButton: true,
hideRecordViewButton: false,
hideConfigureFieldsButton: false,
hideImportButton: true,
selectable: true,
allowExport: true,
perPage: 14,
showTotalCount: true,
magnifyOption: 'modal',
},
})
this.$root.$emit('magnify-page-block', { block })
}
},
},
}
@@ -3,28 +3,68 @@
<b-form-group
:label="$t('chart.display')"
>
<b-form-select
v-model="block.options.chartID"
:options="chartOptions"
text-field="name"
value-field="chartID"
/>
<b-input-group class="d-flex w-100">
<vue-select
v-model="block.options.chartID"
:options="charts"
:clearable="true"
:placeholder="$t('chart.pick')"
:reduce="option => option.chartID"
label="name"
append-to-body
label-f
class="chart-selector bg-white"
@input="chartSelected"
/>
<b-input-group-append>
<b-button
:title="$t('chart.openInBuilder')"
:disabled="!selectedChart || (!selectedChart.canUpdateChart && !selectedChart.canDeleteChart)"
variant="light"
class="d-flex align-items-center"
:to="{ name: 'admin.charts.edit', params: { chartID: (selectedChart || {}).chartID }, query: null }"
>
<font-awesome-icon :icon="['fas', 'external-link-alt']" />
</b-button>
</b-input-group-append>
</b-input-group>
</b-form-group>
<b-button
v-if="selectedChart"
:disabled="!selectedChart.canUpdateChart && !selectedChart.canDeleteChart"
variant="light"
:to="{ name: 'admin.charts.edit', params: { chartID: selectedChart.chartID }, query: null }"
>
{{ $t('chart.openInBuilder') }}
</b-button>
<template v-if="isDrillDownAvailable">
<b-form-group
:description="$t('chart.drillDown.description')"
label-class="d-flex align-items-center"
class="mb-1"
>
<template #label>
{{ $t('chart.drillDown.label') }}
<b-form-checkbox
v-model="options.drillDown.enabled"
switch
class="ml-1"
/>
</template>
<vue-select
v-model="options.drillDown.blockID"
:options="drillDownOptions"
:disabled="!options.drillDown.enabled"
:get-option-label="o => o.title || o.kind"
:reduce="option => option.blockID"
:clearable="true"
:placeholder="$t('chart.drillDown.openInModal')"
append-to-body
class="block-selector bg-white"
/>
</b-form-group>
</template>
</b-tab>
</template>
<script>
import base from './base'
import { mapGetters } from 'vuex'
import { NoID } from '@cortezaproject/corteza-js'
import base from './base'
import { VueSelect } from 'vue-select'
export default {
i18nOptions: {
@@ -33,6 +73,10 @@ export default {
name: 'Chart',
components: {
VueSelect,
},
extends: base,
computed: {
@@ -40,20 +84,82 @@ export default {
charts: 'chart/set',
}),
chartOptions () {
return [
{ chartID: NoID, name: this.$t('chart.pick') },
...this.charts,
]
},
selectedChart () {
if (!this.options.chartID || this.options.chartID === NoID) {
return
}
return this.chartOptions.find(({ chartID }) => chartID === this.options.chartID)
return this.charts.find(({ chartID }) => chartID === this.options.chartID)
},
selectedChartModuleID () {
if (!this.selectedChart) return
const { moduleID } = (this.selectedChart.config.reports[0] || {})
return moduleID
},
isDrillDownAvailable () {
if (!this.selectedChart) return
const { metrics = [] } = (this.selectedChart.config.reports[0] || {})
return !metrics.some(({ type }) => type === 'gauge')
},
drillDownOptions () {
return this.page.blocks.filter(({ blockID, kind, options = {} }) => kind === 'RecordList' && blockID !== NoID && options.moduleID === this.selectedChartModuleID)
},
},
methods: {
chartSelected () {
this.options.drillDown = {
enabled: false,
blockID: '',
}
},
},
}
</script>
<style lang="scss">
.input-group > .chart-selector {
position: relative;
-ms-flex: 1 1 auto;
flex: 1 1 auto;
width: 1%;
margin-bottom: 0;
}
.block-selector, .chart-selector {
&:not(.vs--open) .vs__selected + .vs__search {
// force this to not use any space
// we still need it to be rendered for the focus
width: 0;
padding: 0;
margin: 0;
border: none;
height: 0;
}
.vs__selected-options {
// do not allow growing
width: 0;
}
.vs__selected {
display: block;
white-space: nowrap;
text-overflow: ellipsis;
max-width: 100%;
overflow: hidden;
}
}
.vs__dropdown-menu .vs__dropdown-option {
text-overflow: ellipsis;
overflow: hidden !important;
}
</style>
@@ -78,7 +78,7 @@
/>
<column-picker
v-if="options.allRecords"
v-if="!options.hideConfigureFieldsButton"
:module="recordListModule"
:fields="fields"
class="float-left"
@@ -96,27 +96,39 @@
/>
</div>
</b-row>
<b-row
v-if="options.selectable"
v-show="selected.length > 0"
class="mt-2 no-gutters"
<div
v-if="drillDownFilter"
class="d-flex justify-content-end mt-1"
>
<b-col
cols="4"
class="pt-1 text-nowrap font-weight-bold"
<b-button
variant="outline-light"
size="sm"
class="text-nowrap text-primary border-0"
@click="setDrillDownFilter(undefined)"
>
{{ $t('recordList.drillDown.filter.remove') }}
</b-button>
</div>
<div
v-if="options.selectable && selected.length"
class="d-flex align-items-center mt-1"
>
<div
class="d-flex align-items-baseline my-auto pt-1 text-nowrap h-100"
>
{{ $t('recordList.selected', { count: selected.length, total: items.length }) }}
<a
href="#"
<b-button
variant="link"
class="p-0 text-decoration-none"
@click.prevent="handleSelectAllOnPage({ isChecked: false })"
>
({{ $t('recordList.cancelSelection') }})
</a>
</b-col>
<b-col
class="text-right"
cols="8"
>
</b-button>
</div>
<div class="ml-auto">
<automation-buttons
class="d-inline m-0"
:buttons="options.selectionButtons"
@@ -171,8 +183,8 @@
/>
</b-button>
</template>
</b-col>
</b-row>
</div>
</div>
</b-container>
</template>
@@ -665,7 +677,9 @@ export default {
processing: false,
// prefilter from block config
prefilter: null,
prefilter: undefined,
recordListFilter: [],
drillDownFilter: undefined,
// raw query string used to build final filter
query: null,
@@ -696,8 +710,6 @@ export default {
// component
ctr: 0,
items: [],
idPrefix: `rl:${this.blockIndex}`,
recordListFilter: [],
showingDeletedRecords: false,
}
},
@@ -884,23 +896,8 @@ export default {
'record.recordID': {
immediate: true,
handler (recordID = NoID) {
const { pageID = NoID } = this.page
// Set uniqueID so that events dont mix
if (this.uniqueID) {
this.$root.$off(`record-line:collect:${this.uniqueID}`)
this.$root.$off(`page-block:validate:${this.uniqueID}`)
this.$root.$off(`refetch-non-record-blocks:${pageID}`)
}
this.uniqueID = [pageID, recordID, this.blockIndex].map(v => v || NoID).join('-')
this.$root.$on(`record-line:collect:${this.uniqueID}`, this.resolveRecords)
this.$root.$on(`page-block:validate:${this.uniqueID}`, this.validatePageBlock)
this.$root.$on(`refetch-non-record-blocks:${pageID}`, () => {
this.refresh(true)
})
handler () {
this.createEvents()
this.getStorageRecordListFilter()
this.prepRecordList()
this.refresh(true)
@@ -908,10 +905,12 @@ export default {
},
},
mounted () {
this.createEvents()
},
beforeDestroy () {
this.$root.$off(`record-line:collect:${this.uniqueID}`)
this.$root.$off(`page-block:validate:${this.uniqueID}`)
this.$root.$off(`refetch-non-record-blocks:${this.page.pageID}`)
this.destroyEvents()
},
created () {
@@ -921,6 +920,34 @@ export default {
},
methods: {
createEvents () {
const { pageID = NoID } = this.page
const { recordID = NoID } = this.record || {}
// Set uniqueID so that events dont mix
if (this.uniqueID) {
this.$root.$off(`record-line:collect:${this.uniqueID}`)
this.$root.$off(`page-block:validate:${this.uniqueID}`)
this.$root.$off(`drill-down-recordList:${this.uniqueID}`)
this.$root.$off(`refetch-non-record-blocks:${pageID}`)
}
this.uniqueID = [pageID, recordID, this.block.blockID].map(v => v || NoID).join('-')
this.$root.$on(`record-line:collect:${this.uniqueID}`, this.resolveRecords)
this.$root.$on(`page-block:validate:${this.uniqueID}`, this.validatePageBlock)
this.$root.$on(`drill-down-recordList:${this.uniqueID}`, this.setDrillDownFilter)
this.$root.$on(`refetch-non-record-blocks:${pageID}`, () => {
this.refresh(true)
})
},
destroyEvents () {
this.$root.$off(`record-line:collect:${this.uniqueID}`)
this.$root.$off(`page-block:validate:${this.uniqueID}`)
this.$root.$off(`refetch-non-record-blocks:${this.page.pageID}`)
this.$root.$off(`drill-down-recordList:${this.uniqueID}`)
},
onFilter (filter = []) {
this.recordListFilter = filter
this.setStorageRecordListFilter()
@@ -989,7 +1016,7 @@ export default {
return {
r,
id: id || (r.recordID !== NoID ? r.recordID : `${this.idPrefix}:${this.ctr++}`),
id: id || (r.recordID !== NoID ? r.recordID : `${this.uniqueID}:${this.ctr++}`),
}
},
@@ -1022,7 +1049,7 @@ export default {
module: this.recordListModule,
refField: this.options.refField,
positionField: this.options.positionField,
idPrefix: this.idPrefix,
idPrefix: this.uniqueID,
})
},
@@ -1350,7 +1377,7 @@ export default {
this.selected = []
// Compute query based on query, prefilter and recordListFilter
const query = queryToFilter(this.query, this.prefilter, this.recordListModule.filterFields(this.options.fields), this.recordListFilter)
const query = queryToFilter(this.query, this.drillDownFilter || this.prefilter, this.recordListModule.filterFields(this.options.fields), this.recordListFilter)
const { moduleID, namespaceID } = this.recordListModule
if (this.filter.pageCursor) {
@@ -1455,6 +1482,11 @@ export default {
onImportSuccessful () {
this.refresh(true)
},
setDrillDownFilter (drillDownFilter) {
this.drillDownFilter = drillDownFilter
this.pullRecords(true)
},
},
}
</script>
@@ -43,7 +43,7 @@
:title="$t('general.label.magnify')"
variant="outline-light"
class="text-secondary d-print-none border-0"
@click="$root.$emit('magnify-page-block', isBlockOpened ? undefined : block.blockID)"
@click="$root.$emit('magnify-page-block', isBlockOpened ? undefined : { blockID: block.blockID })"
>
<font-awesome-icon :icon="['fas', isBlockOpened ? 'times' : 'search-plus']" />
</b-button>
@@ -41,7 +41,7 @@
:title="$t('general.label.magnify')"
variant="outline-light"
class="d-print-none border-0"
@click="$root.$emit('magnify-page-block', isBlockOpened ? undefined : block.blockID)"
@click="$root.$emit('magnify-page-block', isBlockOpened ? undefined : { blockID: block.blockID })"
>
<font-awesome-icon :icon="['fas', isBlockOpened ? 'times' : 'search-plus']" />
</b-button>
@@ -51,6 +51,10 @@ export default {
block: undefined,
record: undefined,
page: undefined,
// Used if you want to display a specific block in the modal
// Otherwise its retrieved based on the page and blockID
customBlock: undefined,
}
},
@@ -95,7 +99,9 @@ export default {
},
created () {
this.$root.$on('magnify-page-block', blockID => {
this.$root.$on('magnify-page-block', ({ blockID, block } = {}) => {
this.customBlock = block
blockID = blockID || (block || {}).blockID
this.$router.push({ query: { ...this.$route.query, blockID } })
})
},
@@ -117,7 +123,7 @@ export default {
return
}
this.block = this.page.blocks.find(block => block.blockID === blockID)
this.block = this.customBlock || this.page.blocks.find(block => block.blockID === blockID)
const { namespaceID, moduleID } = this.page
const recordID = paramsRecordID || queryRecordID
@@ -123,17 +123,14 @@ export default {
fullPageNavigation: true,
showTotalCount: true,
presort: 'createdAt DESC',
// Set allrecords configuration
allRecords: true,
hideConfigureFieldsButton: false,
rowViewUrl: 'admin.modules.record.view',
rowEditUrl: 'admin.modules.record.edit',
rowCreateUrl: 'admin.modules.record.create',
},
})
// Set allrecords configuration
this.block.options = {
...this.block.options,
allRecords: true,
rowViewUrl: 'admin.modules.record.view',
rowEditUrl: 'admin.modules.record.edit',
rowCreateUrl: 'admin.modules.record.create',
}
},
methods: {
+19 -4
View File
@@ -3,18 +3,28 @@ import { Apply, CortezaID, NoID } from '../../../cast'
const kind = 'Chart'
interface DrillDown {
enabled: boolean;
blockID?: string;
}
interface Options {
chartID: string;
refreshRate: number;
showRefresh: boolean;
magnifyOption: string;
drillDown: DrillDown;
}
const defaults: Readonly<Options> = Object.freeze({
chartID: NoID,
chartID: '',
refreshRate: 0,
magnifyOption: '',
showRefresh: false,
magnifyOption: '',
drillDown: {
enabled: false,
blockID: ''
}
})
export class PageBlockChart extends PageBlock {
@@ -30,10 +40,15 @@ export class PageBlockChart extends PageBlock {
applyOptions (o?: Partial<Options>): void {
if (!o) return
Apply(this.options, o, CortezaID, 'chartID')
o.chartID = o.chartID === NoID ? '' : o.chartID
Apply(this.options, o, String, 'chartID', 'magnifyOption')
Apply(this.options, o, Number, 'refreshRate')
Apply(this.options, o, Boolean, 'showRefresh')
Apply(this.options, o, String, 'magnifyOption')
if (o.drillDown) {
this.options.drillDown = o.drillDown
}
}
}
@@ -13,6 +13,7 @@ interface Options {
hideHeader: boolean;
hideAddButton: boolean;
hideImportButton: boolean;
hideConfigureFieldsButton: boolean;
hideSearch: boolean;
hidePaging: boolean;
hideSorting: boolean;
@@ -62,6 +63,7 @@ const defaults: Readonly<Options> = Object.freeze({
hideHeader: false,
hideAddButton: false,
hideImportButton: false,
hideConfigureFieldsButton: true,
hideSearch: false,
hidePaging: false,
hideSorting: false,
@@ -130,6 +132,7 @@ export class PageBlockRecordList extends PageBlock {
'hideHeader',
'hideAddButton',
'hideImportButton',
'hideConfigureFieldsButton',
'hideSearch',
'hidePaging',
'fullPageNavigation',
+1
View File
@@ -4,6 +4,7 @@
:theme="theme"
autoresize
class="position-absolute w-100 h-100 overflow-hidden"
v-on="$listeners"
/>
</template>
+8 -1
View File
@@ -80,7 +80,11 @@ chart:
label: Configure chart
reportLabel: Report {{l}}
reportsLabel: Reports
display: 'Chart to display inside this block:'
display: Chart to display inside this block
drillDown:
label: Drill down
description: If a record list is selected, it will be used to display the drill down data. If no record list is selected the drill down data will be shown in a modal.
openInModal: Open in modal
edit:
label: Edit chart
dimension:
@@ -224,6 +228,9 @@ record:
recordList:
addRecord: Add
cancelSelection: Cancel
drillDown:
filter:
remove: Remove drill down filter
tooltip:
deleteSelected: Delete selected records
undeleteSelected: Undelete selected records