3
0

Add base multi page layout implementation

This commit is contained in:
Jože Fortun
2023-03-22 20:08:12 +01:00
parent 4f77edce5e
commit e2e284ce0a
24 changed files with 1175 additions and 363 deletions

View File

@@ -112,6 +112,7 @@ export default {
...mapActions({
createPage: 'page/create',
updatePage: 'page/update',
createPageLayout: 'pageLayout/create',
}),
handleRecordPageCreation () {
@@ -121,19 +122,21 @@ export default {
const { namespaceID } = this.namespace
// A simple record block w/o preselected fields
const blocks = [new compose.PageBlockRecord({ xywh: [0, 0, 12, 16] })]
const blocks = [new compose.PageBlockRecord({ xywh: [0, 0, 48, 82] })]
const selfID = (this.recordListPage || {}).pageID || NoID
const page = {
const page = new compose.Page({
namespaceID,
moduleID,
selfID,
title: `${this.$t('forModule.recordPage')} "${name || moduleID}"`,
blocks,
}
})
this.createPage(page)
.catch(this.toastErrorHandler(this.$t('notification:module.recordPage.createFailed')))
this.createPage(page).then(({ pageID, blocks }) => {
const pageLayout = new compose.PageLayout({ namespaceID, pageID, blocks, meta: { title: 'Primary' } })
return this.createPageLayout(pageLayout)
}).catch(this.toastErrorHandler(this.$t('notification:module.recordPage.createFailed')))
.finally(() => {
this.processing = false
})
@@ -146,7 +149,7 @@ export default {
const { name, moduleID } = this.module
const blocks = [new compose.PageBlockRecordList({
xywh: [0, 0, 12, 17],
xywh: [0, 0, 48, 82],
options: {
moduleID,
fields: [],
@@ -156,15 +159,19 @@ export default {
},
})]
const page = {
const page = new compose.Page({
title: `${this.$t('forModule.recordList')} "${name || moduleID}"`,
namespaceID,
blocks,
}
})
this.createPage(page)
.then(({ pageID: selfID = NoID }) => {
return this.updatePage({ ...this.recordPage, selfID })
.then(({ pageID = NoID, blocks }) => {
const pageLayout = new compose.PageLayout({ namespaceID, pageID, blocks, meta: { title: 'Primary' } })
return Promise.all([
this.updatePage({ ...this.recordPage, selfID: pageID }),
this.createPageLayout(pageLayout),
])
})
.catch(this.toastErrorHandler(this.$t('notification:module.recordPage.createFailed')))
.finally(() => {

View File

@@ -2,55 +2,101 @@
<b-container fluid>
<b-row>
<b-col
cols="4"
cols="12"
>
<b-list-group>
<b-list-group-item
v-for="(type) in types"
:key="type.label"
:disabled="isOptionDisabled(type)"
button
@click="$emit('select', type.block)"
@mouseover="current = type.image"
>
{{ type.label }}
</b-list-group-item>
</b-list-group>
<b-button
v-for="(type) in types"
:key="type.label"
:disabled="isOptionDisabled(type)"
variant="outline-light"
class="mr-2 mb-2 text-dark"
@click="$emit('select', type.block)"
@mouseover="current = type.image"
>
{{ type.label }}
</b-button>
</b-col>
<b-col
cols="8"
class="my-auto"
cols="12"
>
<b-img
v-if="current"
fluid
thumbnail
:src="current"
/>
</b-col>
</b-row>
<b-row
class="border-top mt-2"
>
<b-col>
<div
class="mt-2"
class="d-flex"
style="height: 300px"
>
{{ $t('selectBlockFootnote') }}
<b-img
v-if="current"
:src="current"
center
fluid
class="mx-auto"
/>
</div>
</b-col>
<hr
v-if="existingBlocks.length"
class="w-100"
>
<b-col
v-if="existingBlocks.length"
cols="12"
>
<b-input-group class="d-flex w-100">
<vue-select
v-model="selectedExistingBlock"
:get-option-label="getBlockLabel"
:options="existingBlocks"
:calculate-position="calculateDropdownPosition"
placeholder="Blocks from other layouts"
append-to-body
class="block-selector bg-white position-relative"
/>
<b-input-group-append>
<b-button
title="Clone block without reference"
variant="light"
:disabled="!selectedExistingBlock"
class="d-flex align-items-center"
@click="$emit('select', selectedExistingBlock.clone())"
>
<font-awesome-icon
:icon="['far', 'clone']"
/>
</b-button>
<b-button
title="Copy block with references"
variant="light"
:disabled="!selectedExistingBlock"
class="d-flex align-items-center"
@click="$emit('select', selectedExistingBlock)"
>
<font-awesome-icon
:icon="['far', 'copy']"
/>
</b-button>
</b-input-group-append>
</b-input-group>
</b-col>
</b-row>
</b-container>
</template>
<script>
import { compose } from '@cortezaproject/corteza-js'
import * as images from '../../../../assets/PageBlocks'
import { VueSelect } from 'vue-select'
import { compose } from '@cortezaproject/corteza-js'
export default {
i18nOptions: {
namespaces: 'block',
},
components: {
VueSelect,
},
props: {
recordPage: {
type: Boolean,
@@ -61,11 +107,19 @@ export default {
type: Array,
default: () => [],
},
existingBlocks: {
type: Array,
default: () => [],
},
},
data () {
return {
current: undefined,
selectedExistingBlock: undefined,
types: [
{
label: this.$t('automation.label'),
@@ -172,6 +226,50 @@ export default {
isOptionDisabled (type) {
return (!this.recordPage && type.recordPageOnly) || this.disabledKinds.includes(type.block.kind)
},
getBlockLabel ({ title, kind }) {
return title || kind
},
},
}
</script>
<style lang="scss">
.block-selector {
position: relative;
-ms-flex: 1 1 auto;
flex: 1 1 auto;
width: 1%;
margin-bottom: 0;
}
.block-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>

View File

@@ -1,37 +1,42 @@
<template>
<div
v-if="grid.length"
v-if="layout.length"
class="w-100"
:class="{
editable: !!editable,
'editable': editable,
'flex-grow-1 d-flex': isStretchable,
}"
>
<grid-layout
class="flex-grow-1 d-flex w-100 h-100"
:layout.sync="layout"
:row-height="50"
:is-resizable="!!editable"
:is-draggable="!!editable"
:responsive="!editable"
:col-num="48"
:row-height="10"
vertical-compact
:is-resizable="editable"
:is-draggable="editable"
:cols="columnNumber"
:margin="[0, 0]"
:use-css-transforms="false"
@layout-updated="handleLayoutUpdate"
:responsive="!editable"
use-css-transforms
class="flex-grow-1 d-flex w-100 h-100"
>
<template
v-for="(item, index) in gridCollection"
v-for="(item, index) in layout"
>
<grid-item
v-if="!blocks[item.i].meta.hidden"
:key="item.i"
ref="items"
:i="item.i"
:h="item.h"
:w="item.w"
:x="item.x"
:y="item.y"
:min-w="6"
:min-h="5"
:class="{ 'h-100': isStretchable }"
class="grid-item"
:class="{
'h-100': isStretchable,
}"
style="touch-action: none;"
v-bind="{ ...item }"
@moved="onBlockUpdated(index)"
@resized="onBlockUpdated(index)"
>
@@ -46,6 +51,7 @@
</template>
</grid-layout>
</div>
<div
v-else
class="no-builder-grid h-100 pt-5 container text-center"
@@ -57,9 +63,9 @@
</template>
<script>
import VueGridLayout from 'vue-grid-layout'
import { compose } from '@cortezaproject/corteza-js'
import { GridLayout, GridItem } from 'vue-grid-layout'
import { throttle } from 'lodash'
import { compose } from '@cortezaproject/corteza-js'
export default {
i18nOptions: {
@@ -67,25 +73,25 @@ export default {
},
components: {
GridLayout: VueGridLayout.GridLayout,
GridItem: VueGridLayout.GridItem,
GridLayout,
GridItem,
},
props: {
editable: {
type: Boolean,
},
blocks: {
type: Array,
required: true,
default: () => ([]),
},
editable: {
type: Boolean,
},
},
data () {
return {
// all blocks in vue-grid friendly structure
grid: [],
layout: [],
// Grid items bounding rect info
boundingRects: [],
@@ -93,65 +99,19 @@ export default {
},
computed: {
layout: {
get () {
return this.grid
},
set (layout) {
this.grid = layout
this.handleLayoutUpdate(layout)
},
},
sortedGrid () {
return Array.from(this.grid).sort((a, b) => Math.sqrt(a.x * a.x + a.y * a.y) - Math.sqrt(b.x * b.x + b.y * b.y))
oneBlockLayout () {
return this.blocks.filter(({ meta }) => !meta.hidden).length === 1
},
isStretchable () {
if (this.editable) {
// When in-edit mode do not stretch the blocks
return false
}
const minHeight = 10
let heightCheck = -1
for (let b = 0; b < this.blocks.length; b++) {
const { xywh: [, y, , h] } = this.blocks[b]
if (y > 0) {
// If block is not positioned at the top,
// do not try to make it stretchable
return false
}
if (heightCheck === -1) {
// Set block height for the next check
heightCheck = h
}
if (heightCheck !== h && minHeight > h) {
// Not full height
return false
}
}
return true
return !this.editable && this.oneBlockLayout
},
columnNumber () {
if (this.grid.length === 1) {
if (this.oneBlockLayout) {
return { lg: 1, md: 1, sm: 1, xs: 1, xxs: 1 }
}
return { lg: 12, md: 12, sm: 1, xs: 1, xxs: 1 }
},
gridCollection () {
if (this.grid.length === 1) {
return this.sortedGrid
}
return this.grid
return { lg: 48, md: 48, sm: 1, xs: 1, xxs: 1 }
},
},
@@ -160,8 +120,7 @@ export default {
immediate: true,
deep: true,
handler (blocks) {
if (blocks.length === 0) this.$emit('change', [])
this.grid = blocks.map(({ meta, xywh: [x, y, w, h] }, i) => {
this.layout = blocks.map(({ meta, xywh: [x, y, w, h] }, i) => {
// To avoid collision with hidden elements
return meta.hidden ? { i, x: 0, y: 0, w: 0, h: 0 } : { i, x, y, w, h }
})
@@ -190,16 +149,11 @@ export default {
})
},
handleLayoutUpdate (layout) {
this.$emit('change', layout.map(
({ x, y, w, h, i }) => new compose.PageBlockMaker({ ...this.blocks[i], xywh: [x, y, w, h] }),
))
this.recalculateBoundingRect()
},
// emit event when block has been moved or resized
onBlockUpdated (index) {
this.$emit('item-updated', index)
this.$emit('update:blocks', this.layout.map(
({ x, y, w, h, i }) => new compose.PageBlockMaker({ ...this.blocks[i], xywh: [x, y, w, h] }),
))
},
},
}

View File

@@ -829,7 +829,7 @@ export default {
},
/*
Inline record editor is disabled if:
Inline record editor is disabled if:
- An inline record editor for the same module already exists
- Record list module doesn't have record page (inline record autoselected and disabled)
*/

View File

@@ -65,7 +65,7 @@
<script>
import base from './base'
import { compose } from '@cortezaproject/corteza-js'
import { fetchID } from 'corteza-webapp-compose/src/lib/tabs'
import { fetchID } from 'corteza-webapp-compose/src/lib/block'
export default {
i18nOptions: {

View File

@@ -245,7 +245,7 @@
import base from './base'
import draggable from 'vuedraggable'
import { VueSelect } from 'vue-select'
import { fetchID } from 'corteza-webapp-compose/src/lib/tabs'
import { fetchID } from 'corteza-webapp-compose/src/lib/block'
export default {
i18nOptions: {

View File

@@ -1,8 +1,8 @@
<template>
<grid
v-if="page.blocks"
:key="page.pageID"
:blocks="page.blocks"
v-if="layout"
:key="layout.layoutID"
:blocks="blocks"
:editable="false"
>
<template
@@ -17,6 +17,7 @@
</grid>
</template>
<script>
import { mapGetters } from 'vuex'
import Grid from '../../Common/Grid'
import PageBlock from '../../PageBlocks'
import { compose } from '@cortezaproject/corteza-js'
@@ -35,5 +36,43 @@ export default {
required: true,
},
},
data () {
return {
layouts: [],
layout: undefined,
blocks: [],
}
},
computed: {
...mapGetters({
getPageLayouts: 'pageLayout/getByPageID',
}),
},
watch: {
'page.pageID': {
immediate: true,
handler (pageID) {
this.layouts = this.getPageLayouts(pageID)
const { layoutID } = this.$route.query
if (layoutID) {
this.layout = this.layouts.find(({ pageLayoutID }) => pageLayoutID === layoutID)
} else {
this.layout = this.layouts[0]
this.$router.replace({ ...this.$route, query: { ...this.$route.query, layoutID: this.layout.pageLayoutID } })
}
this.blocks = (this.layout || {}).blocks.map(({ blockID, xywh }) => {
const block = this.page.blocks.find(b => b.blockID === blockID)
block.xywh = xywh
return block
})
},
},
},
}
</script>

View File

@@ -1,5 +1,5 @@
import { NoID } from '@cortezaproject/corteza-js'
import { fetchID } from 'corteza-webapp-compose/src/lib/tabs'
import { fetchID } from 'corteza-webapp-compose/src/lib/block'
export default {
methods: {
@@ -78,6 +78,8 @@ export default {
async updateTabbedBlockIDs (page) {
// get the Tabs Block that still has tabs with tempIDs
let updatePage = false
page.blocks.filter(({ kind }) => kind === 'Tabs')
.filter(({ options = {} }) => options.tabs.some(({ blockID }) => (blockID || '').startsWith('tempID-')))
.forEach(b => {
@@ -98,9 +100,14 @@ export default {
}
b.options.tabs.splice(j, 1, tab)
updatePage = true
})
})
if (!updatePage) {
return page
}
return this.updatePage(page)
},
},

View File

@@ -4,6 +4,7 @@ import Vuex from 'vuex'
import namespace from './namespace'
import module from './module'
import page from './page'
import pageLayout from './page-layout'
import chart from './chart'
import user from './user'
import languages from './languages'
@@ -19,6 +20,7 @@ export default new Vuex.Store({
namespace: namespace(Vue.prototype.$ComposeAPI),
module: module(Vue.prototype.$ComposeAPI),
page: page(Vue.prototype.$ComposeAPI),
pageLayout: pageLayout(Vue.prototype.$ComposeAPI),
chart: chart(Vue.prototype.$ComposeAPI),
user: user(Vue.prototype.$SystemAPI),
languages: languages(Vue.prototype.$SystemAPI),

View File

@@ -0,0 +1,194 @@
import { compose } from '@cortezaproject/corteza-js'
import * as request from '../lib/request'
const types = {
loading: 'loading',
loaded: 'loaded',
pending: 'pending',
completed: 'completed',
updateSet: 'updateSet',
removeFromSet: 'removeFromSet',
clearSet: 'clearSet',
}
export default function (ComposeAPI) {
return {
namespaced: true,
state: {
loading: false,
pending: false,
set: [],
},
getters: {
loading: (state) => state.loading,
pending: (state) => state.pending,
getByID (state) {
return (ID) => state.set.find(({ pageLayoutID }) => ID === pageLayoutID)
},
getByHandle (state) {
return (handle) => state.set.find((pl) => handle === pl.handle)
},
getByPageID (state) {
return (ID) => state.set.filter(({ pageID }) => ID === pageID)
},
set (state) {
return state.set
},
},
actions: {
async load ({ commit, getters }, { namespaceID, clear = false, force = false } = {}) {
if (clear) {
commit(types.clearSet)
}
if (!force && getters.set.length > 1) {
return new Promise((resolve) => resolve(getters.set))
}
commit(types.loading)
commit(types.pending)
return ComposeAPI.pageLayoutListNamespace({ namespaceID }).then(({ set, filter }) => {
if (set && set.length > 0) {
commit(types.updateSet, set.map(pl => new compose.PageLayout(pl)))
}
return getters.set
}).finally(() => {
commit(types.loaded)
commit(types.completed)
})
},
async findByID ({ commit, getters }, { namespaceID, pageLayoutID, force = false } = {}) {
if (!force) {
const oldItem = getters.getByID(pageLayoutID)
if (oldItem) {
return new Promise((resolve) => resolve(oldItem))
}
}
commit(types.pending)
return ComposeAPI.pageLayoutRead({ namespaceID, pageLayoutID }).then(pl => {
const pageLayout = new compose.PageLayout(pl)
commit(types.updateSet, [pageLayout])
return pageLayout
}).finally(() => {
commit(types.completed)
})
},
async findByPageID ({ commit, getters }, { namespaceID, pageID, force = false } = {}) {
if (!force) {
const oldItems = getters.getByPageID(pageID)
return new Promise((resolve) => resolve(oldItems))
}
commit(types.pending)
return ComposeAPI.pageLayoutList({ namespaceID, pageID }).then(({ set }) => {
commit(types.updateSet, set.map(pl => new compose.PageLayout(pl)))
return set
}).finally(() => {
commit(types.completed)
})
},
async create ({ commit }, item) {
commit(types.pending)
return ComposeAPI.pageLayoutCreate(item, request.config(item)).then(pl => {
const pageLayout = new compose.PageLayout(pl)
commit(types.updateSet, [pageLayout])
return pageLayout
}).finally(() => {
commit(types.completed)
})
},
async update ({ commit }, item) {
commit(types.pending)
return ComposeAPI.pageLayoutUpdate(item, request.config(item)).then(pl => {
const pageLayout = new compose.PageLayout(pl)
commit(types.updateSet, [pageLayout])
return pageLayout
}).finally(() => {
commit(types.completed)
})
},
async delete ({ commit, dispatch }, item) {
commit(types.pending)
return ComposeAPI.pageLayoutDelete(item).then(() => {
commit(types.removeFromSet, [item])
return true
}).finally(() => {
commit(types.completed)
})
},
updateSet ({ commit }, pageLayout) {
commit(types.updateSet, [pageLayout])
},
clearSet ({ commit }) {
commit(types.clearSet)
},
},
mutations: {
[types.loading] (state) {
state.loading = true
},
[types.loaded] (state) {
state.loading = false
},
[types.pending] (state) {
state.pending = true
},
[types.completed] (state) {
state.pending = false
},
[types.updateSet] (state, set) {
set = set.map(i => Object.freeze(i))
if (state.set.length === 0) {
state.set = set
return
}
set.forEach(newItem => {
const oldIndex = state.set.findIndex(({ pageLayoutID }) => pageLayoutID === newItem.pageLayoutID)
if (oldIndex > -1) {
state.set.splice(oldIndex, 1, newItem)
} else {
state.set.push(newItem)
}
})
},
[types.removeFromSet] (state, removedSet) {
(removedSet || []).forEach(removedItem => {
const i = state.set.findIndex(({ pageLayoutID }) => pageLayoutID === removedItem.pageLayoutID)
if (i > -1) {
state.set.splice(i, 1)
}
})
},
[types.clearSet] (state) {
state.pending = false
state.set.splice(0)
},
},
}
}

View File

@@ -1,6 +1,6 @@
<template>
<div
v-if="!!page"
v-if="page"
id="page-builder"
ref="pageBuilder"
class="flex-grow-1 overflow-auto d-flex px-2 w-100"
@@ -11,10 +11,22 @@
</portal>
<portal to="topbar-tools">
<b-button-group
v-if="page && page.canUpdatePage"
<b-form-select
v-if="layout"
ref="layoutSelect"
size="sm"
class="mr-1"
:value="layout.pageLayoutID"
value-field="pageLayoutID"
text-field="label"
:options="layouts"
style="width: 300px;"
@change="switchLayout"
/>
<b-button-group
v-if="page.canUpdatePage"
size="sm"
class="ml-2 text-nowrap"
>
<b-button
variant="primary"
@@ -47,9 +59,8 @@
</portal>
<grid
:blocks="page.blocks"
:blocks.sync="blocks"
editable
@change="updatePageBlockGrid"
@item-updated="onBlockUpdated"
>
<template
@@ -58,7 +69,6 @@
<div
:data-test-id="`block-${block.kind}`"
class="h-100 editable-block"
:class="{ 'bg-warning': !isValid(block) }"
>
<div
class="toolbox border-0 p-2 m-0 text-light text-center"
@@ -121,9 +131,17 @@
</div>
<page-block
v-bind="{ ...$attrs, ...$props, page, block, boundingRect, blockIndex: index, editable: true }"
:record="record"
v-bind="{
...$attrs,
...$props
}"
:page="page"
:module="module"
:record="record"
:block-index="index"
:block="block"
:bounding-rect="boundingRect"
editable
class="p-2"
/>
</div>
@@ -134,13 +152,17 @@
id="createBlockSelector"
size="lg"
scrollable
hide-footer
:title="$t('build.selectBlockTitle')"
>
<new-block-selector
:record-page="!!module"
:existing-blocks="selectableExistingBlocks"
@select="addBlock"
/>
<template #modal-footer>
{{ $t('block:selectBlockFootnote') }}
</template>
</b-modal>
<b-modal
@@ -282,8 +304,8 @@ import PageBlock from 'corteza-webapp-compose/src/components/PageBlocks'
import EditorToolbar from 'corteza-webapp-compose/src/components/Admin/EditorToolbar'
import { compose, NoID } from '@cortezaproject/corteza-js'
import Configurator from 'corteza-webapp-compose/src/components/PageBlocks/Configurator'
import { fetchID } from 'corteza-webapp-compose/src/lib/tabs'
import MagnificationModal from 'corteza-webapp-compose/src/components/Public/Page/Block/Modal'
import { fetchID } from 'corteza-webapp-compose/src/lib/block'
export default {
i18nOptions: {
@@ -320,9 +342,14 @@ export default {
return {
processing: false,
editor: undefined,
page: undefined,
layout: undefined,
layouts: [],
blocks: [],
editor: undefined,
unsavedBlocks: new Set(),
}
},
@@ -411,6 +438,9 @@ export default {
return this.disableClone ? this.$t('tooltip.saveAsCopy') : ''
},
selectableExistingBlocks () {
return this.page.blocks.filter(({ blockID }) => !this.blocks.some(b => b.blockID === blockID))
},
},
watch: {
@@ -418,16 +448,19 @@ export default {
immediate: true,
handler (pageID) {
this.page = undefined
this.layout = undefined
this.layouts = []
this.unsavedBlocks.clear()
if (pageID) {
const { namespaceID, name } = this.namespace
this.findPageByID({ namespaceID, pageID, force: true })
.then(page => {
document.title = [page.title, name, this.$t('general:label.app-name.private')].filter(v => v).join(' | ')
this.page = page.clone()
})
this.findPageByID({ namespaceID, pageID, force: true }).then(page => {
document.title = [page.title, name, this.$t('general:label.app-name.private')].filter(v => v).join(' | ')
this.page = page.clone()
return this.fetchPageLayouts()
}).then(() => {
this.setLayout()
})
}
},
},
@@ -465,6 +498,9 @@ export default {
updatePageSet: 'page/updateSet',
createPage: 'page/create',
loadPages: 'page/load',
findLayoutByID: 'pageLayout/findByID',
findLayoutsByPageID: 'pageLayout/findByPageID',
updatePageLayout: 'pageLayout/update',
}),
fulfilEditRequest (blockID) {
@@ -538,23 +574,17 @@ export default {
}
this.blocks.splice(index, 1)
this.page.blocks = this.blocks
if (this.editor) this.editor = undefined
this.unsavedBlocks.add(index)
},
updatePageBlockGrid (blocks) {
this.blocks = blocks
},
onBlockUpdated (index) {
this.unsavedBlocks.add(index)
},
updateBlocks (block = this.editor.block) {
block = compose.PageBlockMaker(block)
this.page.blocks = this.blocks
if (this.editor.index !== undefined) {
const oldBlock = this.blocks[this.editor.index]
@@ -565,24 +595,24 @@ export default {
}
if (this.editor.block.kind !== block.kind) {
this.page.blocks.push(block)
this.blocks.push(block)
this.$root.$emit('builder-createRequestFulfilled', {
blockID: fetchID(block),
title: block.title,
})
} else {
this.page.blocks.splice(this.editor.index, 1, block)
this.blocks.splice(this.editor.index, 1, block)
this.unsavedBlocks.add(this.editor.index)
}
} else {
this.page.blocks.push(block)
this.unsavedBlocks.add(this.page.blocks.length - 1)
this.blocks.push(block)
this.unsavedBlocks.add(this.blocks.length - 1)
}
if (block.kind === 'Tabs') {
block.options.tabs.forEach((tab) => {
if (!tab.blockID) return
this.page.blocks.find(b => fetchID(b) === tab.blockID).meta.hidden = true
this.blocks.find(b => fetchID(b) === tab.blockID).meta.hidden = true
})
}
@@ -617,10 +647,22 @@ export default {
const maxY = this.blocks.filter(({ meta }) => !meta.hidden).map((block) => block.xywh[1]).reduce((acc, val) => {
return acc > val ? acc : val
}, 0)
block.xywh = [0, maxY + 2, 3, 3]
block.xywh = [0, maxY + 2, 20, 15]
}
},
async fetchPageLayouts () {
const { namespaceID } = this.namespace
return this.findLayoutsByPageID({ namespaceID, pageID: this.pageID }).then(layouts => {
this.layouts = layouts.map(l => {
l = new compose.PageLayout(l)
l.label = l.meta.title || l.handle || l.pageLayoutID
return l
})
})
},
async handleSave ({ closeOnSuccess = false, previewOnSuccess = false } = {}) {
const { namespaceID } = this.namespace
@@ -651,25 +693,51 @@ export default {
this.processing = true
this.findPageByID({ namespaceID, pageID: this.pageID, force: true })
.then(page => {
// Merge changes
const mergedPage = new compose.Page({ namespaceID, ...page, blocks: this.blocks })
return Promise.all([
this.findPageByID({ ...this.page, force: true }),
this.findLayoutByID({ ...this.layout }),
]).then(([page, layout]) => {
const blocks = [
...page.blocks.filter(({ blockID }) => {
if (this.blocks.some(b => b.blockID === blockID)) {
return false
}
return this.updatePage(mergedPage).then(this.updateTabbedBlockIDs)
}).then((page) => {
this.unsavedBlocks.clear()
this.toastSuccess(this.$t('notification:page.saved'))
if (closeOnSuccess) {
this.$router.push({ name: 'admin.pages' })
} else if (previewOnSuccess) {
this.$router.push({ name: 'page', params: { pageID: this.pageID } })
}
this.page = new compose.Page(page)
}).finally(() => {
this.processing = false
})
.catch(this.toastErrorHandler(this.$t('notification:page.saveFailed')))
// Check if block exists in any other layout, if not delete it permanently
return this.layouts.some(({ blocks }) => blocks.some(b => b.blockID === blockID))
}),
...this.blocks,
]
return this.updatePage({ namespaceID, ...page, blocks })
.then(this.updateTabbedBlockIDs)
.then(async page => {
const blocks = this.blocks.map(({ blockID, meta, xywh }) => {
if (blockID === NoID) {
blockID = (page.blocks.find(block => block.meta.tempID === meta.tempID) || {}).blockID
}
return { blockID, xywh }
})
layout = await this.updatePageLayout({ ...layout, blocks })
return { page, layout }
})
}).then(({ page, layout }) => {
this.page = new compose.Page(page)
this.layout = new compose.PageLayout(layout)
this.fetchPageLayouts()
this.$route.query.layoutID = layout.pageLayoutID
this.unsavedBlocks.clear()
this.toastSuccess(this.$t('notification:page.saved'))
if (closeOnSuccess) {
this.$router.push({ name: 'admin.pages' })
} else if (previewOnSuccess) {
this.$router.push({ name: 'page', params: { pageID: this.pageID } })
}
}).finally(() => {
this.processing = false
}).catch(this.toastErrorHandler(this.$t('notification:page.saveFailed')))
},
validateModuleFieldSelection (module, page) {
@@ -732,7 +800,6 @@ export default {
navigator.clipboard.writeText(block).then(() => {
this.toastSuccess(this.$t('notification:page.copySuccess'))
this.toastInfo(this.$t('notification:page.blockWaiting'))
this.$refs.pageBuilder.focus()
},
(err) => {
@@ -764,6 +831,38 @@ export default {
checkUnsavedBlocks (next) {
next(!this.unsavedBlocks.size || window.confirm(this.$t('build.unsavedChanges')))
},
async setLayout () {
if (this.$route.query.layoutID) {
const { namespaceID } = this.namespace
this.layout = await this.findLayoutByID({ namespaceID, pageLayoutID: this.$route.query.layoutID })
} else {
this.layout = this.layouts[0]
if (!this.layout) {
this.toastWarning('No layout, create one to edit it')
return this.$router.push(this.pageEditor)
}
this.$router.replace({ ...this.$route, query: { ...this.$route.query, layoutID: this.layout.pageLayoutID } })
}
this.unsavedBlocks.clear()
const { blocks = [] } = this.layout || {}
this.blocks = blocks.map(({ blockID, xywh }) => {
const block = this.page.blocks.find(b => fetchID(b) === blockID)
block.xywh = xywh
return block
})
},
switchLayout (layoutID) {
this.$router.push({ ...this.$route, query: { ...this.$route.query, layoutID } }).then(() => {
this.setLayout()
}).catch(() => {
// Change layout value of select back to previous one if redirect was canceled
this.$refs.layoutSelect.localValue = this.layout.pageLayoutID
})
},
},
}
</script>

View File

@@ -1,5 +1,8 @@
<template>
<div class="py-3">
<div
v-if="page"
class="py-3"
>
<portal to="topbar-title">
{{ $t('edit.edit') }}
</portal>
@@ -17,7 +20,7 @@
>
{{ $t('label.pageBuilder') }}
<font-awesome-icon
:icon="['fas', 'cogs']"
:icon="['far', 'edit']"
class="ml-2"
/>
</b-button>
@@ -44,142 +47,297 @@
</portal>
<b-container fluid="xl">
<b-row no-gutters>
<b-col>
<b-card
no-body
class="shadow-sm"
<b-card
no-body
class="shadow-sm"
>
<b-form-row
v-if="page"
class="px-4 py-3"
>
<b-col
cols="12"
md="6"
>
<b-form
class="px-4 py-3"
<b-form-group
:label="`${$t('newPlaceholder')} *`"
label-class="text-primary"
>
<b-row>
<b-col
cols="12"
md="6"
<input
id="id"
v-model="page.pageID"
required
type="hidden"
>
<b-form-input
v-model="page.title"
data-test-id="input-title"
required
:state="titleState"
class="mb-2"
/>
</b-form-group>
</b-col>
<b-col
cols="12"
md="6"
>
<b-form-group
:label="$t('label.handle')"
label-class="text-primary"
>
<b-form-input
v-model="page.handle"
data-test-id="input-handle"
:state="handleState"
class="mb-2"
:placeholder="$t('block.general.placeholder.handle')"
/>
<b-form-invalid-feedback :state="handleState">
{{ $t('block.general.invalid-handle-characters') }}
</b-form-invalid-feedback>
</b-form-group>
</b-col>
<b-col
cols="12"
>
<b-form-group
:label="$t('label.description')"
label-class="text-primary"
>
<b-form-textarea
v-model="page.description"
data-test-id="input-description"
:placeholder="$t('edit.pageDescription')"
rows="4"
/>
</b-form-group>
</b-col>
<b-col
cols="12"
md="6"
>
<b-form-group
label-class="d-flex align-items-center text-primary"
>
<template #label>
{{ $t('icon.page') }}
<b-button
:title="$t('icon.configure')"
variant="outline-light"
class="d-flex align-items-center px-1 text-primary border-0 ml-1"
@click="openIconModal"
>
<b-form-group
:label="`${$t('newPlaceholder')} *`"
label-class="text-primary"
>
<input
id="id"
v-model="page.pageID"
required
type="hidden"
<font-awesome-icon
:icon="['fas', 'cog']"
/>
</b-button>
</template>
<img
v-if="icon.src"
:src="pageIcon"
width="auto"
height="50"
>
<span v-else>
{{ $t('icon.noIcon') }}
</span>
</b-form-group>
</b-col>
<b-col
cols="12"
md="6"
>
<b-form-group
:label="$t('edit.otherOptions')"
label-class="text-primary"
>
<b-form-checkbox
v-if="!isRecordPage"
v-model="page.visible"
data-test-id="checkbox-page-visibility"
>
{{ $t('edit.visible') }}
</b-form-checkbox>
<b-form-checkbox
v-model="page.config.navItem.expanded"
data-test-id="checkbox-show-sub-pages-in-sidebar"
>
{{ $t('showSubPages') }}
</b-form-checkbox>
</b-form-group>
</b-col>
<b-col
cols="12"
>
<hr>
<b-form-group
label="Layouts"
label-class="text-primary"
>
<b-table-simple
responsive="lg"
borderless
small
>
<b-thead>
<tr>
<th />
<th
class="text-primary"
style="width: 45%; min-width: 200px;"
>
<b-form-input
v-model="page.title"
data-test-id="input-title"
required
:state="titleState"
class="mb-2"
/>
</b-form-group>
</b-col>
Title *
</th>
<b-col
cols="12"
md="6"
>
<b-form-group
:label="$t('label.handle')"
label-class="text-primary"
>
<b-form-input
v-model="page.handle"
data-test-id="input-handle"
:state="handleState"
class="mb-2"
:placeholder="$t('block.general.placeholder.handle')"
/>
<b-form-invalid-feedback :state="handleState">
{{ $t('block.general.invalid-handle-characters') }}
</b-form-invalid-feedback>
</b-form-group>
</b-col>
<th
class="text-primary"
style="width: 45%; min-width: 200px;"
>
Handle
</th>
<b-col
cols="12"
>
<b-form-group
:label="$t('label.description')"
label-class="text-primary"
>
<b-form-textarea
v-model="page.description"
data-test-id="input-description"
:placeholder="$t('edit.pageDescription')"
rows="4"
/>
</b-form-group>
</b-col>
<th style="width: 80px;" />
</tr>
</b-thead>
<b-col
cols="12"
md="6"
<draggable
v-model="layouts"
handle=".handle"
tag="b-tbody"
>
<b-form-group
label-class="d-flex align-items-center text-primary"
<tr
v-for="(layout, index) in layouts"
:key="index"
>
<template #label>
{{ $t('icon.page') }}
<b-button
:title="$t('icon.configure')"
variant="outline-light"
class="d-flex align-items-center px-1 text-primary border-0 ml-1"
@click="openIconModal"
>
<font-awesome-icon
:icon="['fas', 'cog']"
<b-td class="handle text-center align-middle pr-2">
<font-awesome-icon
:icon="['fas', 'bars']"
class="grab m-0 text-light p-0"
/>
</b-td>
<b-td
class="align-middle"
>
<b-form-input
v-model="layout.meta.title"
:state="layoutTitleState(layout.meta.title)"
@input="layout.meta.updated = true"
/>
</b-td>
<b-td
class="align-middle"
>
<b-input-group>
<b-form-input
v-model="layout.handle"
:state="layoutHandleState(layout.handle)"
@input="layout.meta.updated = true"
/>
</b-button>
</template>
<img
v-if="icon.src"
:src="pageIcon"
width="auto"
height="50"
<b-input-group-append>
<b-button
variant="light"
class="d-flex align-items-center px-3"
@click="configureLayout(index)"
>
<font-awesome-icon
:icon="['fas', 'wrench']"
/>
</b-button>
<b-button
variant="primary"
class="d-flex align-items-center"
:to="{ name: 'admin.pages.builder', query: { layoutID: layout.pageLayoutID} }"
>
<font-awesome-icon
:icon="['far', 'edit']"
/>
</b-button>
</b-input-group-append>
</b-input-group>
</b-td>
<td
class="text-center align-middle"
>
<span v-else>
{{ $t('icon.noIcon') }}
</span>
</b-form-group>
</b-col>
<b-col
cols="12"
md="6"
>
<b-form-group
:label="$t('edit.otherOptions')"
label-class="text-primary"
>
<b-form-checkbox
v-if="!isRecordPage"
v-model="page.visible"
data-test-id="checkbox-page-visibility"
>
{{ $t('edit.visible') }}
</b-form-checkbox>
<b-form-checkbox
v-model="page.config.navItem.expanded"
data-test-id="checkbox-show-sub-pages-in-sidebar"
>
{{ $t('showSubPages') }}
</b-form-checkbox>
</b-form-group>
</b-col>
</b-row>
</b-form>
</b-card>
</b-col>
</b-row>
<c-input-confirm
:title="$t('tabs.tooltip.delete')"
class="ml-2"
@confirmed="deleteLayout(index)"
/>
</td>
</tr>
</draggable>
</b-table-simple>
<b-button
variant="primary"
@click="addLayout"
>
Add layout
</b-button>
</b-form-group>
</b-col>
</b-form-row>
</b-card>
</b-container>
<b-modal
v-if="layoutEditor.layout"
:visible="!!layoutEditor.layout"
title="Configure layout"
:ok-title="$t('general:label.saveAndClose')"
ok-variant="primary"
cancel-variant="link"
size="lg"
@ok="updateLayout()"
@cancel="layoutEditor.layout = undefined"
>
<b-form-group
label="Condition"
label-class="text-primary"
>
<b-input-group>
<b-input-group-prepend>
<b-button variant="dark">
ƒ
</b-button>
</b-input-group-prepend>
<b-form-input
v-model="layoutEditor.layout.config.visibility.expression"
/>
</b-input-group>
</b-form-group>
<b-form-group
label="Roles"
label-class="text-primary"
>
<vue-select
v-model="currentLayoutRoles"
:options="roles.options"
:loading="roles.processing"
:get-option-label="role => role.name"
:reduce="role => role.roleID"
:selectable="role => !currentLayoutRoles.includes(role.roleID)"
append-to-body
multiple
class="bg-white"
/>
</b-form-group>
</b-modal>
<b-modal
v-model="showIconModal"
:title="$t('icon.configure')"
@@ -319,8 +477,10 @@ import EditorToolbar from 'corteza-webapp-compose/src/components/Admin/EditorToo
import PageTranslator from 'corteza-webapp-compose/src/components/Admin/Page/PageTranslator'
import pages from 'corteza-webapp-compose/src/mixins/pages'
import Uploader from 'corteza-webapp-compose/src/components/Public/Page/Attachment/Uploader'
import Draggable from 'vuedraggable'
import { compose, NoID } from '@cortezaproject/corteza-js'
import { handle } from '@cortezaproject/corteza-vue'
import { VueSelect } from 'vue-select'
export default {
i18nOptions: {
@@ -333,6 +493,8 @@ export default {
EditorToolbar,
PageTranslator,
Uploader,
Draggable,
VueSelect,
},
mixins: [
@@ -361,6 +523,19 @@ export default {
linkUrl: '',
processing: false,
layouts: [],
layoutEditor: {
index: undefined,
layout: undefined,
},
deletedLayouts: new Set(),
roles: {
processing: false,
options: [],
},
}
},
@@ -390,11 +565,11 @@ export default {
},
hasChildren () {
return this.pages.some(({ selfID }) => selfID === this.page.pageID)
return this.page ? this.pages.some(({ selfID }) => selfID === this.page.pageID) : false
},
disableSave () {
return [this.titleState, this.handleState].includes(false)
return [this.titleState, this.handleState].includes(false) || this.layouts.some(l => !l.meta.title || handle.handleState(l.handle) === false)
},
hideDelete () {
@@ -417,7 +592,7 @@ export default {
},
set (icon) {
this.page.config.navItem.icon = icon
this.$set(this.page.config.navItem, 'icon', icon)
},
},
@@ -426,24 +601,54 @@ export default {
},
pageIcon () {
if (!this.icon.src) {
return
}
return this.icon.type === 'link' ? this.icon.src : this.makeAttachmentUrl(this.icon.src)
},
currentLayoutRoles: {
get () {
if (!this.layoutEditor.layout) {
return []
}
return this.layoutEditor.layout.config.visibility.roles
},
set (roles) {
this.$set(this.layoutEditor.layout.config.visibility, 'roles', roles)
},
},
},
watch: {
pageID: {
immediate: true,
handler (pageID) {
this.page = undefined
this.layouts = []
this.deletedLayouts = new Set()
if (pageID) {
this.findPageByID({ namespaceID: this.namespace.namespaceID, pageID }).then((page) => {
const { namespaceID } = this.namespace
this.findPageByID({ namespaceID, pageID }).then((page) => {
this.page = page.clone()
return this.fetchAttachments()
}).catch(this.toastErrorHandler(this.$t('notification:page.loadFailed')))
this.fetchLayouts({ namespaceID, pageID }).catch(this.toastErrorHandler(this.$t('notification:page.loadFailed')))
}
},
},
},
created () {
this.fetchRoles()
},
methods: {
...mapActions({
findPageByID: 'page/findByID',
@@ -451,23 +656,86 @@ export default {
deletePage: 'page/delete',
createPage: 'page/create',
loadPages: 'page/load',
findLayoutsByPageID: 'pageLayout/findByPageID',
createPageLayout: 'pageLayout/create',
updatePageLayout: 'pageLayout/update',
deletePageLayout: 'pageLayout/delete',
}),
async handleSave ({ closeOnSuccess = false } = {}) {
async fetchLayouts (payload) {
return this.findLayoutsByPageID(payload).then(layouts => {
this.layouts = layouts.map(layout => new compose.PageLayout(layout))
})
},
async fetchRoles () {
this.roles.processing = true
this.$SystemAPI.roleList().then(({ set: roles = [] }) => {
this.roles.options = roles.filter(({ meta }) => !(meta.context && meta.context.resourceTypes))
}).finally(() => {
this.roles.processing = false
})
},
addLayout () {
this.layouts.push(new compose.PageLayout({ namespaceID: this.namespace.namespaceID, pageID: this.pageID }))
},
updateLayout () {
this.layoutEditor.layout.meta.updated = true
this.layouts.splice(this.layoutEditor.index, 1, this.layoutEditor.layout)
this.layoutEditor.index = undefined
this.layoutEditor.layout = undefined
},
deleteLayout (index) {
const { pageLayoutID } = this.layouts[index] || {}
if (pageLayoutID !== NoID) {
this.deletedLayouts.add(this.layouts[index])
}
this.layouts.splice(index, 1)
},
configureLayout (index) {
this.layoutEditor.index = index
this.layoutEditor.layout = { ...this.layouts[index] }
},
async handleSaveLayouts () {
return Promise.all([
...[...this.deletedLayouts].map(this.deletePageLayout),
...this.layouts.map(layout => {
if (layout.pageLayoutID === NoID) {
return this.createPageLayout(layout)
} else if (layout.meta.updated) {
return this.updatePageLayout(layout)
}
}),
])
},
handleSave ({ closeOnSuccess = false } = {}) {
/**
* Pass a special tag alongside payload that
* instructs store layer to add content-language header to the API request
*/
const resourceTranslationLanguage = this.currentLanguage
const { namespaceID } = this.namespace
return this.saveIcon().then(icon => {
this.page.config.navItem.icon = icon
return this.updatePage({ namespaceID: this.namespace.namespaceID, ...this.page, resourceTranslationLanguage }).then((page) => {
return this.updatePage({ namespaceID, ...this.page, resourceTranslationLanguage }).then((page) => {
this.page = page.clone()
this.toastSuccess(this.$t('notification:page.saved'))
if (closeOnSuccess) {
this.$router.push({ name: 'admin.pages' })
}
return this.handleSaveLayouts()
})
}).then(() => {
this.deletedLayouts = new Set()
this.toastSuccess(this.$t('notification:page.saved'))
if (closeOnSuccess) {
this.$router.push({ name: 'admin.pages' })
}
}).catch(this.toastErrorHandler(this.$t('notification:page.saveFailed')))
},
@@ -541,6 +809,13 @@ export default {
return `${this.$ComposeAPI.baseURL}${src}`
},
layoutTitleState (title) {
return title ? null : false
},
layoutHandleState (layoutHandle) {
return handle.handleState(layoutHandle)
},
},
}
</script>

View File

@@ -131,6 +131,7 @@ export default {
methods: {
...mapActions({
createPage: 'page/create',
createPageLayout: 'pageLayout/create',
}),
loadTree () {
@@ -148,7 +149,10 @@ export default {
const { namespaceID } = this.namespace
this.page.weight = this.tree.length
this.createPage({ ...this.page, namespaceID }).then(({ pageID }) => {
this.$router.push({ name: 'admin.pages.edit', params: { pageID } })
const pageLayout = new compose.PageLayout({ namespaceID, pageID })
return this.createPageLayout(pageLayout).then(() => {
this.$router.push({ name: 'admin.pages.edit', params: { pageID } })
})
}).catch(this.toastErrorHandler(this.$t('notification:page.saveFailed')))
},

View File

@@ -32,7 +32,7 @@
style="margin-left:2px;"
>
<font-awesome-icon
:icon="['fas', 'cogs']"
:icon="['far', 'edit']"
/>
</b-button>
<namespace-translator

View File

@@ -157,6 +157,9 @@ export default {
this.$store.dispatch('page/load', p)
.catch(this.errHandler),
this.$store.dispatch('pageLayout/load', p)
.catch(this.errHandler),
]).catch(this.errHandler).then(() => {
this.loaded = true
})

View File

@@ -21,7 +21,7 @@
>
{{ $t('general:label.pageBuilder') }}
<font-awesome-icon
:icon="['fas', 'cogs']"
:icon="['far', 'edit']"
class="ml-2"
/>
</b-button>

View File

@@ -63,7 +63,7 @@ export default {
props: {
blocks: {
type: Array,
default: () => [],
default: () => ([]),
},
editable: {
@@ -110,14 +110,6 @@ export default {
},
},
},
mounted () {
window.addEventListener('resize', this.windowResizeThrottledHandler)
},
destroyed () {
window.removeEventListener('resize', this.windowResizeThrottledHandler)
},
}
</script>

View File

@@ -555,6 +555,7 @@ export default class Compose {
visible,
blocks,
config,
meta,
} = (a as KV) || {}
if (!namespaceID) {
throw Error('field namespaceID is empty')
@@ -580,6 +581,7 @@ export default class Compose {
visible,
blocks,
config,
meta,
}
return this.api().request(cfg).then(result => stdResolve(result))
}
@@ -663,6 +665,7 @@ export default class Compose {
visible,
blocks,
config,
meta,
} = (a as KV) || {}
if (!namespaceID) {
throw Error('field namespaceID is empty')
@@ -691,6 +694,7 @@ export default class Compose {
visible,
blocks,
config,
meta,
}
return this.api().request(cfg).then(result => stdResolve(result))
}
@@ -1112,6 +1116,7 @@ export default class Compose {
namespaceID,
pageID,
parentID,
weight,
moduleID,
handle,
primary,
@@ -1136,6 +1141,7 @@ export default class Compose {
}
cfg.data = {
parentID,
weight,
moduleID,
handle,
primary,
@@ -1199,6 +1205,7 @@ export default class Compose {
pageID,
pageLayoutID,
parentID,
weight,
moduleID,
handle,
primary,
@@ -1226,6 +1233,7 @@ export default class Compose {
}
cfg.data = {
parentID,
weight,
moduleID,
handle,
primary,
@@ -1247,6 +1255,43 @@ export default class Compose {
return `/namespace/${namespaceID}/page/${pageID}/layout/${pageLayoutID}`
}
// Reorder page layouts
async pageLayoutReorder (a: KV, extra: AxiosRequestConfig = {}): Promise<KV> {
const {
namespaceID,
pageID,
pageIDs,
} = (a as KV) || {}
if (!namespaceID) {
throw Error('field namespaceID is empty')
}
if (!pageID) {
throw Error('field pageID is empty')
}
if (!pageIDs) {
throw Error('field pageIDs is empty')
}
const cfg: AxiosRequestConfig = {
...extra,
method: 'post',
url: this.pageLayoutReorderEndpoint({
namespaceID, pageID,
}),
}
cfg.data = {
pageIDs,
}
return this.api().request(cfg).then(result => stdResolve(result))
}
pageLayoutReorderEndpoint (a: KV): string {
const {
namespaceID,
pageID,
} = a || {}
return `/namespace/${namespaceID}/page/${pageID}/layout/reorder`
}
// Delete page layout
async pageLayoutDelete (a: KV, extra: AxiosRequestConfig = {}): Promise<KV> {
const {

View File

@@ -4,6 +4,7 @@ export * from './types/revision'
export * from './types/module-field'
export { Namespace } from './types/namespace'
export { Page } from './types/page'
export { PageLayout } from './types/page-layout'
export * from './types/page-block'
export { RecordValidator } from './validators/record'
export { getModuleFromYaml } from './helpers'

View File

@@ -235,8 +235,6 @@ export class Module {
if (IsOf(m, 'config')) {
this.config = merge({}, this.config, m.config)
// Remove when we improve duplicate detection, for now its always enabled
}
if (IsOf(m, 'labels')) {

View File

@@ -27,16 +27,18 @@ interface PageBlockMeta {
export type PageBlockInput = PageBlock | Partial<PageBlock>
const defaultXYWH = [0, 2000, 3, 3]
const defaultXYWH = [0, 0, 20, 15]
export class PageBlock {
// blockID is auto generated by the server in order to support resource translations
public blockID = NoID;
public kind = ''
public title = '';
public description = '';
// blockID is auto generated by the server in order to support resource translations
public blockID = '0';
xywh: number[] = defaultXYWH
kind = ''
public xywh: number[] = defaultXYWH
public options = {}

View File

@@ -0,0 +1,106 @@
import { merge } from 'lodash'
import { Apply, CortezaID, ISO8601Date, NoID } from '../../cast'
import { PageBlock } from './page-block/base'
import { Button } from './page-block/types'
export type PageLayoutInput = PageLayout | Partial<PageLayout>
interface PageLayoutConfig {
visibility: Visibility;
actions: Action[];
}
interface Action {
actionID: string;
kind: string;
placement: string;
params: unknown;
meta: unknown;
}
interface Visibility {
expression: string;
roles: string[];
}
interface Meta {
title: string;
description: string;
}
export class PageLayout {
public pageLayoutID = NoID;
public namespaceID = NoID;
public pageID = NoID
public handle = '';
public weight = 0;
public blocks: (Partial<PageBlock>)[] = [];
public config: PageLayoutConfig = {
visibility: {
expression: '',
roles: [],
},
actions: [],
}
public meta: Meta = {
title: '',
description: ''
};
public createdAt?: Date = undefined;
public updatedAt?: Date = undefined;
public deletedAt?: Date = undefined;
public ownedBy = NoID;
constructor (pl?: PageLayoutInput) {
this.apply(pl)
}
apply (pl?: PageLayoutInput): void {
if (!pl) return
Apply(this, pl, CortezaID, 'pageLayoutID', 'namespaceID', 'pageID', 'ownedBy')
Apply(this, pl, String, 'handle')
Apply(this, pl, ISO8601Date, 'createdAt', 'updatedAt', 'deletedAt')
this.blocks = (pl.blocks || []).map(({ blockID, xywh }) => ({ blockID, xywh }))
if (pl.meta) {
this.meta = { ...this.meta, ...pl.meta }
}
if (pl.config) {
this.config = merge({}, this.config, pl.config)
}
}
clone (): PageLayout {
return new PageLayout(JSON.parse(JSON.stringify(this)))
}
/**
* Returns resource ID
*/
get resourceID (): string {
return `${this.resourceType}:${this.pageLayoutID}`
}
/**
* Resource type
*/
get resourceType (): string {
return 'compose:page-layout'
}
export (): PageLayoutInput {
return {
blocks: this.blocks,
meta: this.meta,
}
}
}

View File

@@ -1,12 +1,14 @@
import { Apply, CortezaID, ISO8601Date, NoID } from '../../cast'
import { IsOf, AreObjectsOf } from '../../guards'
import { PageBlock, PageBlockMaker } from './page-block'
import { Button } from './page-block/types'
import { merge } from 'lodash'
interface PartialPage extends Partial<Omit<Page, 'children' | 'blocks' | 'createdAt' | 'updatedAt' | 'deletedAt'>> {
interface PartialPage extends Partial<Omit<Page, 'children' | 'meta' | 'blocks' |'createdAt' | 'updatedAt' | 'deletedAt'>> {
children?: Array<PartialPage>;
blocks?: (Partial<PageBlock>)[];
blocks?: PageBlock[];
meta?: object;
createdAt?: string|number|Date;
updatedAt?: string|number|Date;
@@ -14,15 +16,6 @@ interface PartialPage extends Partial<Omit<Page, 'children' | 'blocks' | 'create
}
interface PageConfig {
buttons: {
submit: Button;
delete: Button;
new: Button;
edit: Button;
clone: Button;
back: Button;
};
attachments: [];
navItem: {
icon: {
type: string;
@@ -47,20 +40,11 @@ export class Page {
public visible = false;
public children?: Array<Page>
public children?: Page[];
public blocks: (InstanceType<typeof PageBlock>)[] = [];
public blocks: PageBlock[] = [];
public config: PageConfig = {
buttons: {
submit: { enabled: true },
delete: { enabled: true },
new: { enabled: true },
edit: { enabled: true },
clone: { enabled: true },
back: { enabled: true },
},
attachments: [],
navItem: {
icon: {
type: '',
@@ -69,6 +53,7 @@ export class Page {
expanded: false,
},
}
public meta: object = {};
public createdAt?: Date = undefined;
public updatedAt?: Date = undefined;
@@ -95,10 +80,7 @@ export class Page {
Apply(this, i, Boolean, 'visible')
if (i.blocks) {
this.blocks = []
if (AreObjectsOf<PageBlock>(i.blocks, 'kind') && AreObjectsOf<PageBlock>(i.blocks, 'xywh')) {
this.blocks = i.blocks.map((b: { kind: string }) => PageBlockMaker(b))
}
this.blocks = i.blocks.map(block => PageBlockMaker(block))
}
if (i.children) {
@@ -108,8 +90,12 @@ export class Page {
}
}
if (i.config) {
this.config = i.config
if (IsOf(i, 'config')) {
this.config = merge({}, this.config, i.config)
}
if (IsOf(i, 'meta')) {
this.meta = merge({}, this.meta, i.meta)
}
if (IsOf(i, 'labels')) {