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') }} +
+ + + + + + + ƒ + + + + + + ? + + + + + + 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