3
0

Add visibility condition to page blocks

This commit is contained in:
Kelani Tolulope
2024-03-20 17:55:03 +01:00
committed by Jože Fortun
parent 8ed2248f0c
commit 0c91950f15
8 changed files with 442 additions and 212 deletions

View File

@@ -264,7 +264,7 @@ export default {
updateSize () {
this.$nextTick(() => {
this.api().updateSize()
this.api() && this.api().updateSize()
})
},

View File

@@ -228,6 +228,114 @@
</b-form-checkbox>
</b-form-group>
</b-col>
<b-col
v-if="block.options.magnifyOption !== undefined"
cols="12"
lg="6"
:offset-lg="block.options.showRefresh !== undefined ? 6 : 0"
>
<b-form-group
:label="$t('general.magnifyLabel')"
label-class="text-primary"
>
<b-form-select
v-model="block.options.magnifyOption"
:options="magnifyOptions"
/>
</b-form-group>
</b-col>
<b-col
cols="12"
sm="12"
>
<hr>
<h5 class="mb-3">
{{ $t('general.visibility.label') }}
</h5>
<b-form-group
label-class="d-flex align-items-center text-primary mb-0"
>
<template #label>
{{ $t('general.visibility.condition.label') }}
<c-hint
:tooltip="$t('general.visibility.tooltip.performance.condition')"
icon-class="text-warning"
/>
</template>
<b-input-group>
<b-input-group-prepend>
<b-button variant="extra-light">
ƒ
</b-button>
</b-input-group-prepend>
<b-form-input
v-model="block.meta.visibility.expression"
:placeholder="$t('general.visibility.condition.placeholder')"
/>
<b-input-group-append>
<b-button
variant="outline-secondary"
:href="visibilityDocumentationURL"
class="d-flex justify-content-center align-items-center"
target="_blank"
>
?
</b-button>
</b-input-group-append>
</b-input-group>
<i18next
v-if="isRecordPage"
path="general.visibility.condition.description.record-page"
tag="small"
class="text-muted"
>
<code>record.values.fieldName</code>
<code>user.(userID/email...)</code>
<code>screen.(width/height)</code>
<code>isView/isCreate/isEdit</code>
<code>user.userID == record.values.createdBy</code>
<code>screen.width &lt; 1024</code>
</i18next>
<i18next
v-else
path="general.visibility.condition.description.non-record-page"
tag="small"
class="text-muted"
>
<code>user.(userID/email...)</code>
<code>screen.(width/height)</code>
<code>user.email == "test@mail.com"</code>
<code>screen.width &lt; 1024</code>
</i18next>
</b-form-group>
</b-col>
<b-col
cols="12"
sm="12"
class="pt-2"
>
<b-form-group
:label="$t('general.visibility.roles.label')"
label-class="text-primary"
>
<c-input-select
v-model="currentRoles"
:options="roles.options"
:loading="roles.processing"
:placeholder="$t('general.visibility.roles.placeholder')"
:get-option-label="role => role.name"
:reduce="role => role.roleID"
:selectable="role => !currentRoles.includes(role.roleID)"
multiple
/>
</b-form-group>
</b-col>
</b-row>
</b-tab>
@@ -277,6 +385,16 @@ export default {
},
},
data () {
return {
roles: {
processing: false,
options: [],
},
abortableRequests: [],
}
},
computed: {
textVariants () {
return [
@@ -318,6 +436,39 @@ export default {
activeTab () {
return this.isNew ? 0 : 1
},
isRecordPage () {
return this.page && this.page.moduleID !== NoID
},
visibilityDocumentationURL () {
// eslint-disable-next-line no-undef
const [year, month] = VERSION.split('.')
return `https://docs.cortezaproject.org/corteza-docs/${year}.${month}/integrator-guide/compose-configuration/page-layouts.html#visibility-condition`
},
currentRoles: {
get () {
if (!this.block.meta.visibility.roles) {
return []
}
return this.block.meta.visibility.roles
},
set (roles) {
this.$set(this.block.meta.visibility, 'roles', roles)
},
},
},
created () {
this.fetchRoles()
},
beforeDestroy () {
this.abortRequests()
this.setDefaultValues()
},
methods: {
@@ -325,6 +476,36 @@ export default {
// If value is less than 5 but greater than 0 make it 5. Otherwise value stays the same.
this.block.options.refreshRate = e.target.value < 5 && e.target.value > 0 ? 5 : e.target.value
},
fetchRoles () {
this.roles.processing = true
const { response, cancel } = this.$SystemAPI
.roleListCancellable({})
this.abortableRequests.push(cancel)
response()
.then(({ set: roles = [] }) => {
this.roles.options = roles.filter(({ meta }) => !(meta.context && meta.context.resourceTypes))
}).finally(() => {
this.roles.processing = false
})
},
abortRequests () {
this.abortableRequests.forEach((cancel) => {
cancel()
})
},
setDefaultValues () {
this.roles = {
processing: false,
options: [],
}
this.abortableRequests = []
},
},
}
</script>

View File

@@ -0,0 +1,200 @@
import { compose } from '@cortezaproject/corteza-js'
import { mapGetters } from 'vuex'
export default {
props: {
namespace: {
// via router-view
type: compose.Namespace,
required: true,
},
page: {
// via route-view
type: compose.Page,
required: true,
},
// We're using recordID to check if we need to display router-view or grid component
recordID: {
type: String,
default: '',
},
},
data () {
return {
layouts: [],
layout: undefined,
blocks: undefined,
}
},
computed: {
...mapGetters({
getPageLayouts: 'pageLayout/getByPageID',
}),
isRecordPage () {
return this.recordID || this.$route.name === 'page.record.create'
},
expressionVariables () {
return {
user: this.$auth.user,
record: this.record ? this.record.serialize() : {},
screen: {
width: window.innerWidth,
height: window.innerHeight,
userAgent: navigator.userAgent,
breakpoint: this.getBreakpoint(), // This is from a global mixin uiHelpers
},
oldLayout: this.layout,
layout: undefined,
...(this.isRecordPage && {
isView: !this.edit && !this.isNew,
isCreate: this.isNew,
isEdit: this.edit && !this.isNew,
}),
}
},
},
mounted () {
this.createEvents()
},
methods: {
evaluateLayoutExpressions (variables = {}) {
const expressions = {}
variables = {
...this.expressionVariables,
...variables,
}
this.layouts.forEach(layout => {
const { config = {} } = layout
if (!config.visibility.expression) return
variables.layout = layout
expressions[layout.pageLayoutID] = config.visibility.expression
})
return this.$SystemAPI.expressionEvaluate({ variables, expressions }).catch(e => {
this.toastErrorHandler(this.$t('notification:evaluate.failed'))(e)
Object.keys(expressions).forEach(key => (expressions[key] = false))
return expressions
})
},
async determineLayout (pageLayoutID, variables = {}, redirectOnFail = true) {
// Clear stored records so they can be refetched with latest values
this.clearRecordSet()
if (this.isRecordPage) {
this.resetErrors()
}
let expressions = {}
// Only evaluate if one of the layouts has an expressions variable
if (this.layouts.some(({ config = {} }) => config.visibility.expression)) {
expressions = await this.evaluateLayoutExpressions(variables)
}
// Check layouts for expressions/roles and find the first one that fits
const matchedLayout = this.layouts.find(l => {
if (pageLayoutID && l.pageLayoutID !== pageLayoutID) return
const { expression, roles = [] } = l.config.visibility
if (expression && !expressions[l.pageLayoutID]) return false
if (!roles.length) return true
return this.$auth.user.roles.some(roleID => roles.includes(roleID))
})
this.processing = false
if (!matchedLayout) {
this.toastWarning(this.$t('notification:page.page-layout.notFound.view'))
if (redirectOnFail) {
this.$router.go(-1)
}
return this.$router.go(-1)
}
if (this.isRecordPage) {
this.inEditing = this.edit
}
if (this.layout && matchedLayout.pageLayoutID === this.layout.pageLayoutID) {
return
}
this.layout = matchedLayout
if (this.isRecordPage) {
this.handleRecordButtons()
} else {
const { handle, meta = {} } = this.layout || {}
const title = meta.title || this.page.title
this.pageTitle = title || handle || this.$t('navigation:noPageTitle')
document.title = [title, this.namespace.name, this.$t('general:label.app-name.public')].filter(v => v).join(' | ')
}
await this.updateBlocks(variables)
},
async updateBlocks (variables = {}) {
const tempBlocks = []
const { blocks = [] } = this.layout || {}
let blocksExpressions = {}
if (blocks.some(({ meta = {} }) => (meta.visibility || {}).expression)) {
blocksExpressions = await this.evaluateBlocksExpressions(variables)
}
blocks.forEach(({ blockID, xywh, meta }) => {
const block = this.page.blocks.find(b => b.blockID === blockID)
const { roles = [], expression = '' } = meta.visibility || {}
if (block && (!expression || blocksExpressions[blockID])) {
block.xywh = xywh
if (!roles.length || this.$auth.user.roles.some(roleID => roles.includes(roleID))) {
tempBlocks.push(block)
}
}
})
this.blocks = tempBlocks
},
evaluateBlocksExpressions (variables = {}) {
const expressions = {}
variables = {
...this.expressionVariables,
...variables,
}
this.layout.blocks.forEach(block => {
const { visibility } = block.meta
if (!(visibility || {}).expression) return
expressions[block.blockID] = visibility.expression
})
return this.$SystemAPI.expressionEvaluate({ variables, expressions }).catch(e => {
this.toastErrorHandler(this.$t('notification:evaluate.failed'))(e)
Object.keys(expressions).forEach(key => (expressions[key] = false))
return expressions
})
},
},
}

View File

@@ -184,6 +184,7 @@
</b-modal>
<b-modal
scrollable
:ok-title="$t('build.addBlock')"
ok-variant="primary"
:ok-disabled="blockEditorOkDisabled"
@@ -222,6 +223,7 @@
</b-modal>
<b-modal
scrollable
size="xl"
:visible="showEditor"
body-class="p-0 border-top-0"

View File

@@ -117,6 +117,7 @@ import { mapGetters, mapActions } from 'vuex'
import Grid from 'corteza-webapp-compose/src/components/Public/Page/Grid'
import RecordToolbar from 'corteza-webapp-compose/src/components/Common/RecordToolbar'
import record from 'corteza-webapp-compose/src/mixins/record'
import page from 'corteza-webapp-compose/src/mixins/page'
import { compose, NoID } from '@cortezaproject/corteza-js'
import { evaluatePrefilter } from 'corteza-webapp-compose/src/lib/record-filter'
@@ -135,6 +136,7 @@ export default {
mixins: [
// The record mixin contains all of the logic for creating/editing/deleting/undeleting the record
record,
page,
],
beforeRouteLeave (to, from, next) {
@@ -155,28 +157,12 @@ export default {
},
props: {
namespace: {
type: compose.Namespace,
required: true,
},
module: {
type: compose.Module,
required: false,
default: () => ({}),
},
page: {
type: compose.Page,
required: true,
},
recordID: {
type: String,
required: false,
default: '',
},
// When creating from related record blocks
refRecord: {
type: compose.Record,
@@ -207,10 +193,7 @@ export default {
return {
inEditing: this.edit,
layouts: [],
layout: undefined,
layoutButtons: new Set(),
blocks: undefined,
recordNavigation: {
prev: undefined,
@@ -224,7 +207,6 @@ export default {
computed: {
...mapGetters({
getNextAndPrevRecord: 'ui/getNextAndPrevRecord',
getPageLayouts: 'pageLayout/getByPageID',
previousPages: 'ui/previousPages',
modalPreviousPages: 'ui/modalPreviousPages',
}),
@@ -354,14 +336,6 @@ export default {
},
},
mounted () {
this.$root.$on('refetch-record-blocks', this.refetchRecordBlocks)
if (this.showRecordModal) {
this.$root.$on('bv::modal::hide', this.checkUnsavedChanges)
}
},
beforeDestroy () {
this.abortRequests()
this.destroyEvents()
@@ -375,6 +349,23 @@ export default {
popModalPreviousPage: 'ui/popModalPreviousPage',
}),
createEvents () {
this.$root.$on('refetch-record-blocks', this.refetchRecordBlocks)
this.$root.$on('record-field-change', this.validateBlocksVisibilityCondition)
if (this.showRecordModal) {
this.$root.$on('bv::modal::hide', this.checkUnsavedChanges)
}
},
validateBlocksVisibilityCondition ({ fieldName }) {
const { blocks = [] } = this.page
if (blocks.some(({ meta = {} }) => ((meta.visibility || {}).expression).includes(fieldName))) {
this.updateBlocks()
}
},
async loadRecord (recordID = this.recordID) {
if (!this.page) {
return
@@ -507,19 +498,7 @@ export default {
evaluateLayoutExpressions (variables = {}) {
const expressions = {}
variables = {
user: this.$auth.user,
record: this.record ? this.record.serialize() : {},
screen: {
width: window.innerWidth,
height: window.innerHeight,
userAgent: navigator.userAgent,
breakpoint: this.getBreakpoint(), // This is from a global mixin uiHelpers
},
oldLayout: this.layout,
layout: undefined,
isView: !this.edit && !this.isNew,
isCreate: this.isNew,
isEdit: this.edit && !this.isNew,
...this.expressionVariables,
...variables,
}
@@ -540,51 +519,8 @@ export default {
})
},
async determineLayout (pageLayoutID, variables = {}, redirectOnFail = true) {
// Clear stored records so they can be refetched with latest values
this.clearRecordSet()
this.resetErrors()
let expressions = {}
// Only evaluate if one of the layouts has an expressions variable
if (this.layouts.some(({ config = {} }) => config.visibility.expression)) {
expressions = await this.evaluateLayoutExpressions(variables)
}
// Check layouts for expressions/roles and find the first one that fits
const matchedLayout = this.layouts.find(l => {
if (pageLayoutID && l.pageLayoutID !== pageLayoutID) return
const { expression, roles = [] } = l.config.visibility
if (expression && !expressions[l.pageLayoutID]) return false
if (!roles.length) return true
return this.$auth.user.roles.some(roleID => roles.includes(roleID))
})
this.processing = false
if (!matchedLayout) {
this.toastWarning(this.$t('notification:page.page-layout.notFound.view'))
if (redirectOnFail) {
this.$router.go(-1)
}
return
}
this.inEditing = this.edit
if (this.layout && matchedLayout.pageLayoutID === this.layout.pageLayoutID) {
return
}
this.layout = matchedLayout
const { blocks = [], config = {} } = this.layout || {}
handleRecordButtons () {
const { config = {} } = this.layout
const { buttons = [] } = config
this.layoutButtons = Object.entries(buttons).reduce((acc, [key, value]) => {
@@ -593,19 +529,6 @@ export default {
}
return acc
}, new Set())
const tempBlocks = []
blocks.forEach(({ blockID, xywh }) => {
const block = this.page.blocks.find(b => b.blockID === blockID)
if (block) {
block.xywh = xywh
tempBlocks.push(block)
}
})
this.blocks = tempBlocks
},
refetchRecordBlocks () {
@@ -668,6 +591,7 @@ export default {
destroyEvents () {
this.$root.$off('refetch-record-blocks', this.refetchRecordBlocks)
this.$root.$off('record-field-change', this.validateBlocksVisibilityCondition)
if (this.showRecordModal) {
this.$root.$off('bv::modal::hide', this.checkUnsavedChanges)

View File

@@ -88,7 +88,8 @@ import Grid from 'corteza-webapp-compose/src/components/Public/Page/Grid'
import RecordModal from 'corteza-webapp-compose/src/components/Public/Record/Modal'
import MagnificationModal from 'corteza-webapp-compose/src/components/Public/Page/Block/Modal'
import PageTranslator from 'corteza-webapp-compose/src/components/Admin/Page/PageTranslator'
import { compose, NoID } from '@cortezaproject/corteza-js'
import { NoID } from '@cortezaproject/corteza-js'
import page from 'corteza-webapp-compose/src/mixins/page'
export default {
i18nOptions: {
@@ -102,6 +103,10 @@ export default {
MagnificationModal,
},
mixins: [
page,
],
beforeRouteLeave (to, from, next) {
this.setPreviousPages([])
next()
@@ -123,30 +128,8 @@ export default {
next()
},
props: {
namespace: { // via router-view
type: compose.Namespace,
required: true,
},
page: { // via route-view
type: compose.Page,
required: true,
},
// We're using recordID to check if we need to display router-view or grid component
recordID: {
type: String,
default: '',
},
},
data () {
return {
layouts: [],
layout: undefined,
blocks: undefined,
pageTitle: '',
}
},
@@ -154,13 +137,8 @@ export default {
computed: {
...mapGetters({
recordPaginationUsable: 'ui/recordPaginationUsable',
getPageLayouts: 'pageLayout/getByPageID',
}),
isRecordPage () {
return this.recordID || this.$route.name === 'page.record.create'
},
module () {
if (this.page.moduleID && this.page.moduleID !== NoID) {
return this.$store.getters['module/getByID'](this.page.moduleID)
@@ -194,8 +172,9 @@ export default {
handler (pageID) {
if (pageID === NoID) return
this.layouts = []
this.layouts = this.getPageLayouts(this.page.pageID)
this.layout = undefined
this.pageTitle = this.page.title
if (!this.isRecordPage) {
this.determineLayout()
@@ -213,10 +192,6 @@ export default {
},
},
mounted () {
this.$root.$on('refetch-records', this.refetchRecords)
},
beforeDestroy () {
this.destroyEvents()
this.setDefaultValues()
@@ -232,84 +207,8 @@ export default {
clearRecordSet: 'record/clearSet',
}),
evaluateLayoutExpressions () {
const expressions = {}
const variables = {
screen: {
width: window.innerWidth,
height: window.innerHeight,
userAgent: navigator.userAgent,
breakpoint: this.getBreakpoint(), // This is from a global mixin uiHelpers
},
user: this.$auth.user,
oldLayout: this.layout,
layout: undefined,
}
this.layouts.forEach(layout => {
const { config = {} } = layout
if (!config.visibility.expression) return
variables.layout = layout
expressions[layout.pageLayoutID] = config.visibility.expression
})
return this.$SystemAPI.expressionEvaluate({ variables, expressions }).catch(e => {
this.toastErrorHandler(this.$t('notification:evaluate.failed'))(e)
Object.keys(expressions).forEach(key => (expressions[key] = false))
return expressions
})
},
async determineLayout () {
// Clear stored records so they can be refetched with latest values
this.clearRecordSet()
this.layouts = this.getPageLayouts(this.page.pageID)
let expressions = {}
// Only evaluate if one of the layouts has an expressions variable
if (this.layouts.some(({ config = {} }) => config.visibility.expression)) {
this.pageTitle = this.page.title
expressions = await this.evaluateLayoutExpressions()
}
// Check layouts for expressions/roles and find the first one that fits
this.layout = this.layouts.find(({ pageLayoutID, config = {} }) => {
const { expression, roles = [] } = config.visibility
if (expression && !expressions[pageLayoutID]) return false
if (!roles.length) return true
return this.$auth.user.roles.some(roleID => roles.includes(roleID))
})
if (!this.layout) {
this.toastWarning(this.$t('notification:page.page-layout.notFound.view'))
return this.$router.go(-1)
}
const { handle, meta = {} } = this.layout || {}
const title = meta.title || this.page.title
this.pageTitle = title || handle || this.$t('navigation:noPageTitle')
document.title = [title, this.namespace.name, this.$t('general:label.app-name.public')].filter(v => v).join(' | ')
const tempBlocks = []
const { blocks = [] } = this.layout || {}
blocks.forEach(({ blockID, xywh }) => {
const block = this.page.blocks.find(b => b.blockID === blockID)
if (block) {
block.xywh = xywh
tempBlocks.push(block)
}
})
this.blocks = tempBlocks
createEvents () {
this.$root.$on('refetch-records', this.refetchRecords)
},
refetchRecords () {

View File

@@ -21,11 +21,17 @@ interface PageBlockStyle {
border?: PageBlockStyleBorder;
}
interface Visibility {
expression: string;
roles: string[];
}
interface PageBlockMeta {
hidden?: boolean;
tempID?: string;
customID?: string;
customCSSClass?: string;
visibility: Visibility;
}
export type PageBlockInput = PageBlock | Partial<PageBlock>
@@ -49,6 +55,10 @@ export class PageBlock {
tempID: undefined,
customID: undefined,
customCSSClass: undefined,
visibility: {
expression: '',
roles: [],
},
}
public style: PageBlockStyle = {

View File

@@ -145,6 +145,20 @@ general:
placeholder: custom-class-1 custom-class-2
description: Used to reference the block in Custom CSS (.custom-class)
invalid-state: Can contain only letters, numbers, underscores and dashes. Must end with letter or number. Use spaces to separate multiple classes
visibility:
label: Visibility
roles:
label: Roles
placeholder: Pick roles that the block will be shown to
condition:
label: Condition
placeholder: When will the block be shown
description:
record-page: "You can use {{0}}, {{1}}, {{2}}, {{3}} inside the expression, for example: {{4}} or {{5}}"
non-record-page: "You can use {{0}}, {{1}} inside the expression, for example: {{2}} or {{3}}"
tooltip:
performance:
condition: Using visibility conditions will impact performance
magnifyOptions:
disabled: Disabled
modal: Modal
@@ -513,7 +527,7 @@ recordList:
permissions: Permissions
recordDisplayOptions: On record click
recordSelectorDisplayOptions: On record selector click
addRecordOptions: On add record click
addRecordOptions: On add record click
textStyles: Text Styles
configureNonWrappingFelids: Configure non-wrapping fields
showFullText: Show full text