diff --git a/client/web/compose/src/components/PageBlocks/CalendarBase.vue b/client/web/compose/src/components/PageBlocks/CalendarBase.vue
index d58ab50b9..7a4ca0a6f 100644
--- a/client/web/compose/src/components/PageBlocks/CalendarBase.vue
+++ b/client/web/compose/src/components/PageBlocks/CalendarBase.vue
@@ -264,7 +264,7 @@ export default {
updateSize () {
this.$nextTick(() => {
- this.api().updateSize()
+ this.api() && this.api().updateSize()
})
},
diff --git a/client/web/compose/src/components/PageBlocks/Configurator.vue b/client/web/compose/src/components/PageBlocks/Configurator.vue
index 3e5f27975..63e19b465 100644
--- a/client/web/compose/src/components/PageBlocks/Configurator.vue
+++ b/client/web/compose/src/components/PageBlocks/Configurator.vue
@@ -228,6 +228,114 @@
+
+
+
+
+
+
+
+
+
+
+ {{ $t('general.visibility.label') }}
+
+
+
+
+ {{ $t('general.visibility.condition.label') }}
+
+
+
+
+
+ ƒ
+
+
+
+
+
+ ?
+
+
+
+
+
+ record.values.fieldName
+ user.(userID/email...)
+ screen.(width/height)
+ isView/isCreate/isEdit
+ user.userID == record.values.createdBy
+ screen.width < 1024
+
+
+
+ user.(userID/email...)
+ screen.(width/height)
+ user.email == "test@mail.com"
+ screen.width < 1024
+
+
+
+
+
+
+
+
+
@@ -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 = []
+ },
},
}
diff --git a/client/web/compose/src/mixins/page.js b/client/web/compose/src/mixins/page.js
new file mode 100644
index 000000000..a8bca803c
--- /dev/null
+++ b/client/web/compose/src/mixins/page.js
@@ -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
+ })
+ },
+ },
+}
diff --git a/client/web/compose/src/views/Admin/Pages/Builder.vue b/client/web/compose/src/views/Admin/Pages/Builder.vue
index aa106a596..5e76639c9 100644
--- a/client/web/compose/src/views/Admin/Pages/Builder.vue
+++ b/client/web/compose/src/views/Admin/Pages/Builder.vue
@@ -184,6 +184,7 @@
({}),
},
- 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)
diff --git a/client/web/compose/src/views/Public/Pages/View.vue b/client/web/compose/src/views/Public/Pages/View.vue
index 8b7ec05ba..b265a3411 100644
--- a/client/web/compose/src/views/Public/Pages/View.vue
+++ b/client/web/compose/src/views/Public/Pages/View.vue
@@ -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 () {
diff --git a/lib/js/src/compose/types/page-block/base.ts b/lib/js/src/compose/types/page-block/base.ts
index 3ced11028..fedc4d017 100644
--- a/lib/js/src/compose/types/page-block/base.ts
+++ b/lib/js/src/compose/types/page-block/base.ts
@@ -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
@@ -49,6 +55,10 @@ export class PageBlock {
tempID: undefined,
customID: undefined,
customCSSClass: undefined,
+ visibility: {
+ expression: '',
+ roles: [],
+ },
}
public style: PageBlockStyle = {
diff --git a/locale/en/corteza-webapp-compose/block.yaml b/locale/en/corteza-webapp-compose/block.yaml
index ab36d1f2d..dcb263a58 100644
--- a/locale/en/corteza-webapp-compose/block.yaml
+++ b/locale/en/corteza-webapp-compose/block.yaml
@@ -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