Add tab block to compose
This commit is contained in:
BIN
client/web/compose/src/assets/PageBlocks/Tabs.png
Normal file
BIN
client/web/compose/src/assets/PageBlocks/Tabs.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 160 KiB |
@@ -15,4 +15,5 @@ export const Report = require('./Report.png')
|
||||
export const Progress = require('./Progress.png')
|
||||
export const Nylas = require('./Nylas.jpg')
|
||||
export const Geometry = require('./Geometry.png')
|
||||
export const Tabs = require('./Tabs.png')
|
||||
export const Navigation = require('./Navigation.png')
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<b-list-group-item
|
||||
v-for="(type) in types"
|
||||
:key="type.label"
|
||||
:disabled="!recordPage && type.recordPageOnly"
|
||||
:disabled="isOptionDisabled(type)"
|
||||
button
|
||||
@click="$emit('select', type.block)"
|
||||
@mouseover="current = type.image"
|
||||
@@ -56,6 +56,11 @@ export default {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
|
||||
disabledKinds: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
},
|
||||
|
||||
data () {
|
||||
@@ -149,6 +154,11 @@ export default {
|
||||
block: new compose.PageBlockGeometry(),
|
||||
image: images.Geometry,
|
||||
},
|
||||
{
|
||||
label: this.$t('tabs.label'),
|
||||
block: new compose.PageBlockTab(),
|
||||
image: images.Tabs,
|
||||
},
|
||||
{
|
||||
label: this.$t('navigation.label'),
|
||||
block: new compose.PageBlockNavigation(),
|
||||
@@ -157,5 +167,11 @@ export default {
|
||||
],
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
isOptionDisabled (type) {
|
||||
return (!this.recordPage && type.recordPageOnly) || this.disabledKinds.includes(type.block.kind)
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<template>
|
||||
<div class="d-flex flex-column align-items-center justify-content-center h-100 position-relative">
|
||||
<div
|
||||
class="d-flex flex-column align-items-center justify-content-center position-relative h-100"
|
||||
>
|
||||
<div
|
||||
v-if="processing"
|
||||
class="d-flex flex-column align-items-center justify-content-center flex-fill"
|
||||
|
||||
@@ -19,27 +19,31 @@
|
||||
:use-css-transforms="false"
|
||||
@layout-updated="handleLayoutUpdate"
|
||||
>
|
||||
<grid-item
|
||||
<template
|
||||
v-for="(item, index) in gridCollection"
|
||||
:key="item.i"
|
||||
ref="items"
|
||||
class="grid-item"
|
||||
:class="{
|
||||
'h-100': isStretchable,
|
||||
}"
|
||||
style="touch-action: none;"
|
||||
v-bind="{ ...item }"
|
||||
@moved="onBlockUpdated(index)"
|
||||
@resized="onBlockUpdated(index)"
|
||||
>
|
||||
<slot
|
||||
:block="blocks[item.i]"
|
||||
:index="index"
|
||||
:block-index="item.i"
|
||||
:bounding-rect="boundingRects[index]"
|
||||
v-on="$listeners"
|
||||
/>
|
||||
</grid-item>
|
||||
<grid-item
|
||||
v-if="blocks[item.i].meta.tabbed !== true"
|
||||
:key="item.i"
|
||||
ref="items"
|
||||
class="grid-item"
|
||||
:class="{
|
||||
'h-100': isStretchable,
|
||||
}"
|
||||
style="touch-action: none;"
|
||||
v-bind="{ ...item }"
|
||||
@moved="onBlockUpdated(index)"
|
||||
@resized="onBlockUpdated(index)"
|
||||
>
|
||||
<slot
|
||||
:block="blocks[item.i]"
|
||||
:index="index"
|
||||
:block-index="item.i"
|
||||
:bounding-rect="boundingRects[index]"
|
||||
v-on="$listeners"
|
||||
/>
|
||||
</grid-item>
|
||||
</template>
|
||||
</grid-layout>
|
||||
</div>
|
||||
<div
|
||||
|
||||
@@ -148,6 +148,19 @@
|
||||
/>
|
||||
</b-form-group>
|
||||
</b-col>
|
||||
|
||||
<b-col
|
||||
v-if="showTabOption"
|
||||
cols="12"
|
||||
>
|
||||
<b-form-checkbox
|
||||
v-model="block.meta.tabbed"
|
||||
switch
|
||||
class="mb-2"
|
||||
>
|
||||
{{ $t('general.tabbed.label') }}
|
||||
</b-form-checkbox>
|
||||
</b-col>
|
||||
</b-row>
|
||||
</b-tab>
|
||||
|
||||
@@ -215,6 +228,10 @@ export default {
|
||||
{ value: 'fullscreen', text: this.$t('general.magnifyOptions.fullscreen') },
|
||||
]
|
||||
},
|
||||
|
||||
showTabOption () {
|
||||
return this.block.kind !== 'Tabs' && this.block.meta !== undefined
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
|
||||
87
client/web/compose/src/components/PageBlocks/TabsBase.vue
Normal file
87
client/web/compose/src/components/PageBlocks/TabsBase.vue
Normal file
@@ -0,0 +1,87 @@
|
||||
<template>
|
||||
<wrap
|
||||
v-bind="$props"
|
||||
:scrollable-body="false"
|
||||
v-on="$listeners"
|
||||
>
|
||||
<div
|
||||
v-if="!options.tabs.length"
|
||||
class="d-flex h-100 align-items-center justify-content-center"
|
||||
>
|
||||
<p class="mb-0">
|
||||
{{ $t('tabs.noTabsBase') }}
|
||||
</p>
|
||||
</div>
|
||||
<b-tabs
|
||||
v-else
|
||||
v-model="activeTab"
|
||||
nav-wrapper-class="bg-white white border-bottom"
|
||||
card
|
||||
content-class="flex-fill card overflow-hidden"
|
||||
v-bind="{
|
||||
align: block.options.style.alignment,
|
||||
fill: block.options.style.fillJustify === 'fill',
|
||||
justified: block.options.style.fillJustify === 'justified',
|
||||
pills: block.options.style.appearance === 'pills',
|
||||
tabs: block.options.style.appearance === 'tabs',
|
||||
small: block.options.style.appearance === 'small',
|
||||
vertical: block.options.style.verticalHorizontal === 'vertical',
|
||||
}"
|
||||
class="d-flex flex-column h-100"
|
||||
>
|
||||
<b-tab
|
||||
v-for="(tab, index) in tabbedBlocks"
|
||||
:key="index"
|
||||
:title="tab.title || tab.block.title || `Block-${tab.block.kind}`"
|
||||
class="h-100 "
|
||||
title-item-class="text-truncate"
|
||||
title-link-class="text-truncate"
|
||||
no-body
|
||||
>
|
||||
<page-block-tab
|
||||
lazy
|
||||
v-bind="{ ...$attrs, ...$props, page, block: tab.block, blockIndex: index }"
|
||||
:record="record"
|
||||
:module="module"
|
||||
/>
|
||||
</b-tab>
|
||||
</b-tabs>
|
||||
</wrap>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import base from './base'
|
||||
import { compose } from '@cortezaproject/corteza-js'
|
||||
import { fetchID } from 'corteza-webapp-compose/src/lib/tabs.js'
|
||||
|
||||
export default {
|
||||
i18nOptions: {
|
||||
namespaces: 'block',
|
||||
},
|
||||
|
||||
name: 'TabBase',
|
||||
|
||||
components: {
|
||||
PageBlockTab: () => import('corteza-webapp-compose/src/components/PageBlocks'),
|
||||
},
|
||||
|
||||
extends: base,
|
||||
|
||||
data () {
|
||||
return {
|
||||
activeTab: 0,
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
tabbedBlocks () {
|
||||
return this.block.options.tabs.map(({ blockID, title }) => {
|
||||
return {
|
||||
block: compose.PageBlockMaker(this.page.blocks.find(b => fetchID(b) === blockID)),
|
||||
title,
|
||||
}
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,337 @@
|
||||
<template>
|
||||
<b-tab title="Tabs">
|
||||
<div>
|
||||
<h5 class="text-primary">
|
||||
{{ $t('tabs.displayTitle') }}
|
||||
</h5>
|
||||
|
||||
<b-row
|
||||
class="mb-3 mt-3 ml-0 mr-0 justify-content-between"
|
||||
no-gutters
|
||||
>
|
||||
<b-form-group label="Appearance">
|
||||
<b-form-radio-group
|
||||
v-model="block.options.style.appearance"
|
||||
buttons
|
||||
button-variant="outline-primary"
|
||||
size="sm"
|
||||
:options="style.appearance"
|
||||
/>
|
||||
</b-form-group>
|
||||
|
||||
<b-form-group label="Alignment">
|
||||
<b-form-radio-group
|
||||
v-model="block.options.style.alignment"
|
||||
buttons
|
||||
button-variant="outline-primary"
|
||||
size="sm"
|
||||
:options="style.alignment"
|
||||
/>
|
||||
</b-form-group>
|
||||
<b-form-group label="Fill or Justify">
|
||||
<b-form-radio-group
|
||||
v-model="block.options.style.fillJustify"
|
||||
buttons
|
||||
button-variant="outline-primary"
|
||||
size="sm"
|
||||
:options="style.fillJustify"
|
||||
/>
|
||||
</b-form-group>
|
||||
</b-row>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="d-flex"
|
||||
>
|
||||
<h5
|
||||
class="font-weight-light m-0 p-0 text-primary"
|
||||
>
|
||||
{{ $t('tabs.title') }}
|
||||
</h5>
|
||||
|
||||
<b-button
|
||||
variant="link"
|
||||
size="md"
|
||||
:title="shouldDisableAdd ? $t('tabs.tooltip.tabCondition') : $t('tabs.tooltip.addTab')"
|
||||
:disabled="shouldDisableAdd"
|
||||
class="p-0 ml-3 text-decoration-none"
|
||||
@click="addTab"
|
||||
>
|
||||
{{ $t('tabs.addTab') }}
|
||||
</b-button>
|
||||
</div>
|
||||
|
||||
<b-table-simple
|
||||
v-if="block.options.tabs.length"
|
||||
borderless
|
||||
small
|
||||
>
|
||||
<b-thead>
|
||||
<tr>
|
||||
<th />
|
||||
|
||||
<th
|
||||
class="text-primary"
|
||||
>
|
||||
{{ $t('tabs.table.columns.title.label') }}
|
||||
</th>
|
||||
|
||||
<th
|
||||
class="text-primary"
|
||||
>
|
||||
{{ $t('tabs.table.columns.block.label') }}
|
||||
</th>
|
||||
|
||||
<th />
|
||||
</tr>
|
||||
</b-thead>
|
||||
|
||||
<draggable
|
||||
v-model="block.options.tabs"
|
||||
handle=".handle"
|
||||
tag="b-tbody"
|
||||
>
|
||||
<tr
|
||||
v-for="(tab, index) in block.options.tabs"
|
||||
:key="index"
|
||||
>
|
||||
<b-td class="handle align-middle">
|
||||
<font-awesome-icon
|
||||
:icon="['fas', 'bars']"
|
||||
class="grab m-0 text-light p-0"
|
||||
/>
|
||||
</b-td>
|
||||
|
||||
<b-td
|
||||
class="align-middle"
|
||||
style="width: 50%"
|
||||
>
|
||||
<b-form-input
|
||||
v-model="tab.title"
|
||||
:title="$t('tabs.tooltip.title')"
|
||||
:disabled="!tab.blockID"
|
||||
:placeholder="$t('tabs.form.title')"
|
||||
/>
|
||||
</b-td>
|
||||
|
||||
<b-td
|
||||
class="align-middle"
|
||||
style="width: 50%"
|
||||
>
|
||||
<div
|
||||
class="d-flex"
|
||||
>
|
||||
<vue-select
|
||||
v-model="tab.blockID"
|
||||
:title="$t('tabs.tooltip.selectBlock')"
|
||||
:options="options"
|
||||
:placeholder="$t('tabs.form.placeholder')"
|
||||
:selectable="option => isSelectable(option)"
|
||||
class="block-selector bg-white m-0"
|
||||
append-to-body
|
||||
style="min-width: 95%;"
|
||||
:reduce="option => option.value"
|
||||
>
|
||||
<template #list-footer>
|
||||
<b-button
|
||||
id="CreateBlockSelectorTab"
|
||||
variant="link"
|
||||
size="sm"
|
||||
:title="$t('tabs.tooltip.newBlock')"
|
||||
class="text-decoration-none"
|
||||
block
|
||||
@click="showBlockSelector(index)"
|
||||
>
|
||||
{{ $t('tabs.addTab') }}
|
||||
</b-button>
|
||||
</template>
|
||||
</vue-select>
|
||||
|
||||
<b-button
|
||||
id="popover-edit"
|
||||
size="sm"
|
||||
:disabled="!tab.blockID"
|
||||
:title="$t('tabs.tooltip.edit')"
|
||||
variant="light"
|
||||
@click="editBlock(tab.blockID)"
|
||||
>
|
||||
<font-awesome-icon
|
||||
:icon="['far', 'edit']"
|
||||
/>
|
||||
</b-button>
|
||||
</div>
|
||||
</b-td>
|
||||
|
||||
<td
|
||||
class="text-right align-middle pr-2"
|
||||
style="min-width: 100px;"
|
||||
>
|
||||
<c-input-confirm
|
||||
:title="$t('tabs.tooltip.delete')"
|
||||
@confirmed="deleteTab(index)"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</draggable>
|
||||
</b-table-simple>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="text-center pt-5 pb-5"
|
||||
>
|
||||
<p>
|
||||
{{ $t('tabs.noTabs') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<b-modal
|
||||
id="createBlockSelectorTab"
|
||||
size="lg"
|
||||
scrollable
|
||||
hide-footer
|
||||
:title="$t('tabs.newBlockModal')"
|
||||
>
|
||||
<new-block-selector
|
||||
:record-page="!!module"
|
||||
:disable-kind="['Tabs']"
|
||||
@select="addBlock"
|
||||
/>
|
||||
</b-modal>
|
||||
</b-tab>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import base from './base'
|
||||
import draggable from 'vuedraggable'
|
||||
import { VueSelect } from 'vue-select'
|
||||
import { fetchID } from 'corteza-webapp-compose/src/lib/tabs.js'
|
||||
|
||||
export default {
|
||||
i18nOptions: {
|
||||
namespaces: 'block',
|
||||
},
|
||||
|
||||
name: 'TabConfigurator',
|
||||
|
||||
components: {
|
||||
draggable,
|
||||
VueSelect,
|
||||
// Importing like this because configurator is recursive
|
||||
NewBlockSelector: () => import('corteza-webapp-compose/src/components/Admin/Page/Builder/Selector'),
|
||||
},
|
||||
|
||||
extends: base,
|
||||
|
||||
data () {
|
||||
return {
|
||||
activeIndex: null,
|
||||
style: {
|
||||
appearance: [
|
||||
{ text: this.$t('tabs.style.appearance.tabs'), value: 'tabs', disabled: false },
|
||||
{ text: this.$t('tabs.style.appearance.pills'), value: 'pills', disabled: false },
|
||||
{ text: this.$t('tabs.style.appearance.small'), value: 'small', disabled: false },
|
||||
],
|
||||
|
||||
alignment: [
|
||||
{ text: this.$t('tabs.style.alignment.left'), value: 'left', disabled: false },
|
||||
{ text: this.$t('tabs.style.alignment.center'), value: 'center', disabled: false },
|
||||
{ text: this.$t('tabs.style.alignment.right'), value: 'right', disabled: false },
|
||||
],
|
||||
|
||||
fillJustify: [
|
||||
{ text: this.$t('tabs.style.fillJustify.fill'), value: 'fill', disabled: false },
|
||||
{ text: this.$t('tabs.style.fillJustify.justified'), value: 'justified', disabled: false },
|
||||
{ text: this.$t('tabs.style.fillJustify.none'), value: 'none', disabled: false },
|
||||
],
|
||||
},
|
||||
untabbedBlock: [],
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
|
||||
options () {
|
||||
return this.page.blocks.filter(b => b.kind !== 'Tabs').map((b, i) => {
|
||||
// block title is going to look ugly till you save the page. Inevitable.
|
||||
return { value: fetchID(b), label: b.title || `Block-${b.kind}` }
|
||||
})
|
||||
},
|
||||
|
||||
shouldDisableAdd () {
|
||||
return this.page.blocks.find(b => fetchID(b) === fetchID(this.block)) === undefined
|
||||
},
|
||||
},
|
||||
|
||||
created () {
|
||||
this.$root.$on('builder-createRequestFulfilled', this.createRequestFulfilled)
|
||||
},
|
||||
|
||||
destroyed () {
|
||||
this.$root.$off('builder-createRequestFulfilled', this.createRequestFulfilled)
|
||||
},
|
||||
|
||||
methods: {
|
||||
createRequestFulfilled ({ tab }) {
|
||||
if (tab) {
|
||||
this.updateTab(tab, this.activeIndex)
|
||||
}
|
||||
},
|
||||
|
||||
addTab () {
|
||||
this.block.options.tabs.push({
|
||||
blockID: null,
|
||||
title: undefined,
|
||||
})
|
||||
},
|
||||
|
||||
isSelectable (option) {
|
||||
return !this.block.options.tabs.some(t => t.blockID === option.value)
|
||||
},
|
||||
|
||||
showBlockSelector (index) {
|
||||
this.$bvModal.show('createBlockSelectorTab')
|
||||
this.activeIndex = index
|
||||
},
|
||||
|
||||
editBlock (blockID = undefined) {
|
||||
this.$root.$emit('tab-editRequest', blockID)
|
||||
},
|
||||
|
||||
addBlock (block) {
|
||||
this.$bvModal.hide('createBlockSelectorTab')
|
||||
block.meta.tabbed = true
|
||||
this.$root.$emit('tab-createRequest', block)
|
||||
},
|
||||
|
||||
updateTab (tab, index) {
|
||||
this.block.options.tabs.splice(index, 1, tab)
|
||||
},
|
||||
|
||||
deleteTab (tabIndex) {
|
||||
this.block.options.tabs.splice(tabIndex, 1)
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.block-selector {
|
||||
.vs__selected-options {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.vs__selected {
|
||||
max-width: 200px;
|
||||
display: inline-block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.vs__dropdown-menu .vs__dropdown-option {
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -38,6 +38,8 @@ import GeometryBase from './GeometryBase'
|
||||
import GeometryConfigurator from './GeometryConfigurator/index'
|
||||
import NavigationConfigurator from './Navigation/Configurator'
|
||||
import NavigationBase from './Navigation/Base'
|
||||
import TabsBase from './TabsBase'
|
||||
import TabsConfigurator from './TabsConfigurator'
|
||||
|
||||
/**
|
||||
* List of all known page block components
|
||||
@@ -79,6 +81,8 @@ const Registry = {
|
||||
NylasConfigurator,
|
||||
GeometryBase,
|
||||
GeometryConfigurator,
|
||||
TabsBase,
|
||||
TabsConfigurator,
|
||||
NavigationConfigurator,
|
||||
NavigationBase,
|
||||
}
|
||||
|
||||
8
client/web/compose/src/lib/tabs.js
Normal file
8
client/web/compose/src/lib/tabs.js
Normal file
@@ -0,0 +1,8 @@
|
||||
import { NoID } from '@cortezaproject/corteza-js'
|
||||
/**
|
||||
* If block has no ID, it is a new block and we need to use tempID
|
||||
* to find it in the list of blocks.
|
||||
*/
|
||||
export function fetchID (block) {
|
||||
return block.blockID === NoID ? block.meta.tempID : block.blockID
|
||||
}
|
||||
@@ -149,7 +149,7 @@
|
||||
:visible="showCreator"
|
||||
body-class="p-0 border-top-0"
|
||||
header-class="p-3 pb-0 border-bottom-0"
|
||||
@ok="updateBlocks"
|
||||
@ok="$event => updateBlocks()"
|
||||
@hide="editor = undefined"
|
||||
>
|
||||
<configurator
|
||||
@@ -164,15 +164,11 @@
|
||||
|
||||
<b-modal
|
||||
:title="$t('changeBlock')"
|
||||
:ok-title="$t('label.saveAndClose')"
|
||||
ok-variant="primary"
|
||||
:cancel-title="$t('label.cancel')"
|
||||
cancel-variant="link"
|
||||
size="xl"
|
||||
:visible="showEditor"
|
||||
body-class="p-0 border-top-0"
|
||||
footer-class="d-flex justify-content-between"
|
||||
header-class="p-3 pb-0 border-bottom-0"
|
||||
@ok="updateBlocks"
|
||||
@hide="editor = undefined"
|
||||
>
|
||||
<configurator
|
||||
@@ -184,6 +180,39 @@
|
||||
:block-index="editor.index"
|
||||
:record="record"
|
||||
/>
|
||||
<template #modal-footer="{ cancel }">
|
||||
<c-input-confirm
|
||||
size="md"
|
||||
size-confirm="md"
|
||||
variant="danger"
|
||||
:title="$t('label.delete')"
|
||||
:borderless="false"
|
||||
@confirmed="deleteBlock(editor.index)"
|
||||
>
|
||||
{{ $t('label.delete') }}
|
||||
</c-input-confirm>
|
||||
|
||||
<div>
|
||||
<b-button
|
||||
variant="link"
|
||||
size="md"
|
||||
:title="$t('label.cancel')"
|
||||
class="text-decoration-none"
|
||||
@click="cancel()"
|
||||
>
|
||||
{{ $t('label.cancel') }}
|
||||
</b-button>
|
||||
|
||||
<b-button
|
||||
variant="primary"
|
||||
size="md"
|
||||
:title="$t('label.saveAndClose')"
|
||||
@click="updateBlocks()"
|
||||
>
|
||||
{{ $t('label.saveAndClose') }}
|
||||
</b-button>
|
||||
</div>
|
||||
</template>
|
||||
</b-modal>
|
||||
|
||||
<portal to="admin-toolbar">
|
||||
@@ -246,6 +275,7 @@ 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.js'
|
||||
|
||||
export default {
|
||||
i18nOptions: {
|
||||
@@ -284,6 +314,7 @@ export default {
|
||||
blocks: [],
|
||||
board: null,
|
||||
unsavedBlocks: new Set(),
|
||||
id: null,
|
||||
}
|
||||
},
|
||||
|
||||
@@ -370,6 +401,7 @@ export default {
|
||||
cloneTooltip () {
|
||||
return this.disableClone ? this.$t('tooltip.saveAsCopy') : ''
|
||||
},
|
||||
|
||||
},
|
||||
|
||||
watch: {
|
||||
@@ -392,6 +424,12 @@ export default {
|
||||
},
|
||||
},
|
||||
|
||||
created () {
|
||||
this.$root.$on('tab-editRequest', this.fulfilEditRequest)
|
||||
this.$root.$on('tab-createRequest', this.fulfilCreateRequest)
|
||||
this.$root.$on('tabChange', this.untabBlock)
|
||||
},
|
||||
|
||||
mounted () {
|
||||
window.addEventListener('paste', this.pasteBlock)
|
||||
},
|
||||
@@ -406,6 +444,8 @@ export default {
|
||||
|
||||
destroyed () {
|
||||
window.removeEventListener('paste', this.pasteBlock)
|
||||
this.$root.$off('tab-editRequest', this.fulfilEditRequest)
|
||||
this.$root.$off('tab-createRequest', this.fulfilCreateRequest)
|
||||
},
|
||||
|
||||
methods: {
|
||||
@@ -418,6 +458,39 @@ export default {
|
||||
loadPages: 'page/load',
|
||||
}),
|
||||
|
||||
fulfilEditRequest (blockID) {
|
||||
// this ensures whatever changes in tabs is not lost before we lose its configurator
|
||||
// because we are reusing that modal component
|
||||
this.updateBlocks()
|
||||
this.blocks.find((block, i) => fetchID(block) === blockID && this.editBlock(i))
|
||||
},
|
||||
|
||||
fulfilCreateRequest (block) {
|
||||
this.updateBlocks(block)
|
||||
},
|
||||
|
||||
untabBlock (block) {
|
||||
const where = this.tabLocation(block)
|
||||
|
||||
if (!where.length) return
|
||||
|
||||
where.forEach(({ block, index }) => {
|
||||
const { tabs } = block.options
|
||||
tabs.splice(index, 1)
|
||||
})
|
||||
},
|
||||
|
||||
tabLocation (tabbedBlock) {
|
||||
const where = []
|
||||
this.blocks.forEach((block, i) => {
|
||||
if (block.kind !== 'Tabs') return
|
||||
const { tabs } = block.options
|
||||
const index = tabs.findIndex(({ blockID }) => blockID === fetchID(tabbedBlock))
|
||||
where.push({ block, index })
|
||||
})
|
||||
return where
|
||||
},
|
||||
|
||||
addBlock (block, index = undefined) {
|
||||
this.$bvModal.hide('createBlockSelector')
|
||||
this.editor = { index, block: compose.PageBlockMaker(block) }
|
||||
@@ -428,8 +501,44 @@ export default {
|
||||
},
|
||||
|
||||
deleteBlock (index) {
|
||||
/**
|
||||
* If the block is tabbed, we need to remove it from the tabs when it is deleted.
|
||||
* We find where it is tabbed then remove it from its tabs.
|
||||
* We don't bother checking if it is actually tabbed because you cannot delete a
|
||||
* manually tabed block that is not added to a Tabs block.
|
||||
*/
|
||||
if (this.blocks[index].meta.tabbed) {
|
||||
const whereIsItTabbed = this.blocks.filter((block) => block.kind === 'Tabs' && block.options.tabs.some(({ blockID }) => blockID === fetchID(this.blocks[index])))
|
||||
whereIsItTabbed.forEach((block) => {
|
||||
block.options.tabs = block.options.tabs.filter(({ blockID }) => blockID !== fetchID(this.blocks[index]))
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* First we get all the tabs that are tabbed in other Tabs blocks.
|
||||
* The reduce eliminates duplicates as we only need reps of what is tabbed
|
||||
* We check what needs to be freed by comparing allTabs to the tabs of the block
|
||||
* being deleted.
|
||||
*/
|
||||
if (this.blocks[index].kind === 'Tabs') {
|
||||
const allTabs = this.blocks.filter((block) => block.kind === 'Tabs' && fetchID(block) !== fetchID(this.blocks[index]))
|
||||
.map(({ options }) => options.tabs).flat().reduce((unique, o) => {
|
||||
if (!unique.some(tab => tab.blockID === o.blockID)) {
|
||||
unique.push(o)
|
||||
}
|
||||
return unique
|
||||
}, []).map(({ blockID }) => blockID)
|
||||
|
||||
const tobefreed = this.blocks[index].options.tabs.filter(({ blockID }) => !allTabs.includes(blockID))
|
||||
tobefreed.forEach(({ blockID }) => {
|
||||
this.blocks.find((b) => fetchID(b) === blockID).meta.tabbed = false
|
||||
})
|
||||
}
|
||||
|
||||
this.blocks.splice(index, 1)
|
||||
this.page.blocks = this.blocks
|
||||
|
||||
if (this.editor) this.editor = undefined
|
||||
this.unsavedBlocks.add(index)
|
||||
},
|
||||
|
||||
@@ -441,22 +550,71 @@ export default {
|
||||
this.unsavedBlocks.add(index)
|
||||
},
|
||||
|
||||
updateBlocks () {
|
||||
const block = compose.PageBlockMaker(this.editor.block)
|
||||
generateUID () {
|
||||
return Math.random().toString(36).substring(2) + (new Date()).getTime().toString(36)
|
||||
},
|
||||
|
||||
updateBlocks (block = this.editor.block) {
|
||||
block = compose.PageBlockMaker(block)
|
||||
this.page.blocks = this.blocks
|
||||
|
||||
/**
|
||||
* Check if an existing block has been updated or a new block has been added
|
||||
*/
|
||||
if (this.editor.index !== undefined) {
|
||||
this.page.blocks.splice(this.editor.index, 1, block)
|
||||
this.unsavedBlocks.add(this.editor.index)
|
||||
const oldBlock = this.blocks[this.editor.index]
|
||||
|
||||
if (oldBlock.meta.tabbed === true && this.editor.block.meta.tabbed === false) {
|
||||
this.untabBlock(this.editor.block)
|
||||
}
|
||||
|
||||
if (this.editor.block.kind !== block.kind) {
|
||||
this.page.blocks.push(block)
|
||||
const tab = {
|
||||
blockID: fetchID(block),
|
||||
title: block.title,
|
||||
}
|
||||
this.$root.$emit('builder-createRequestFulfilled', { tab })
|
||||
} else {
|
||||
this.page.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.editor = undefined
|
||||
if (block.kind === 'Tabs') {
|
||||
block.options.tabs = block.options.tabs.filter(t => t.blockID !== null)
|
||||
block.options.tabs.forEach((tab) => {
|
||||
this.page.blocks.find(b => fetchID(b) === tab.blockID).meta.tabbed = true
|
||||
})
|
||||
}
|
||||
|
||||
if (this.editor.block.kind === block.kind) {
|
||||
this.editor = undefined
|
||||
}
|
||||
},
|
||||
|
||||
cloneBlock (index) {
|
||||
this.appendBlock({ ...this.blocks[index].clone() }, this.$t('notification:page.cloneSuccess'))
|
||||
},
|
||||
|
||||
appendBlock (block, msg) {
|
||||
if (this.blocks.length) {
|
||||
// ensuring we append the block to the end of the page
|
||||
// eslint-disable-next-line
|
||||
const maxY = this.blocks.map((block) => block.xywh[1]).reduce((acc, val) => {
|
||||
return acc > val ? acc : val
|
||||
}, 0)
|
||||
block.xywh = [0, maxY + 2, 3, 3]
|
||||
}
|
||||
this.editor = { index: undefined, block: compose.PageBlockMaker(block) }
|
||||
this.updateBlocks()
|
||||
if (!this.editor) {
|
||||
msg && this.toastSuccess(msg)
|
||||
return true
|
||||
} else {
|
||||
msg && this.toastErrorHandler(this.$t('notification:page.duplicateFailed'))
|
||||
return false
|
||||
}
|
||||
},
|
||||
|
||||
async handleSave ({ closeOnSuccess = false, previewOnSuccess = false } = {}) {
|
||||
@@ -493,6 +651,32 @@ export default {
|
||||
const mergedPage = new compose.Page({ namespaceID, ...page, blocks: this.blocks })
|
||||
|
||||
this.updatePage(mergedPage).then((page) => {
|
||||
// get the Tabs Block that still has tabs with tempIDs
|
||||
const tabsWithTempIDs = this.page.blocks.filter(b => b.kind === 'Tabs').filter(b => b.options.tabs.some(t => t.blockID.startsWith('tempID-')))
|
||||
|
||||
if (!tabsWithTempIDs.length) return page
|
||||
|
||||
page.blocks.forEach(b => {
|
||||
if (b.kind !== 'Tabs') return
|
||||
|
||||
tabsWithTempIDs.find(t => t.meta.tempID === b.meta.tempID).options.tabs.forEach((t, j) => {
|
||||
if (!t.blockID.startsWith('tempID-')) return false
|
||||
|
||||
// find a block with the same tempID that should be updated by now and get its blockID
|
||||
const updatedBlock = page.blocks.find(block => block.meta.tempID === t.blockID)
|
||||
|
||||
if (!updatedBlock) return false
|
||||
|
||||
const tab = {
|
||||
// fetchID gets the blockID using the found block
|
||||
blockID: fetchID(updatedBlock),
|
||||
title: updatedBlock.title,
|
||||
}
|
||||
b.options.tabs.splice(j, 1, tab)
|
||||
})
|
||||
})
|
||||
return this.updatePage(page)
|
||||
}).then((page) => {
|
||||
this.unsavedBlocks.clear()
|
||||
this.toastSuccess(this.$t('notification:page.saved'))
|
||||
if (closeOnSuccess) {
|
||||
@@ -549,12 +733,8 @@ export default {
|
||||
return true
|
||||
},
|
||||
|
||||
cloneBlock (index) {
|
||||
this.appendBlock({ ...this.blocks[index] }, this.$t('notification:page.cloneSuccess'))
|
||||
},
|
||||
|
||||
async copyBlock (index) {
|
||||
const parsedBlock = JSON.stringify(this.blocks[index])
|
||||
const parsedBlock = JSON.stringify(this.blocks[index].clone())
|
||||
navigator.clipboard.writeText(parsedBlock).then(() => {
|
||||
this.toastSuccess(this.$t('notification:page.copySuccess'))
|
||||
this.toastInfo(this.$t('notification:page.blockWaiting'))
|
||||
@@ -585,27 +765,6 @@ export default {
|
||||
}
|
||||
},
|
||||
|
||||
appendBlock (block, msg) {
|
||||
if (this.blocks.length) {
|
||||
// ensuring we append the block to the end of the page
|
||||
const maxY = this.blocks.map((block) => block.xywh[1]).reduce((acc, val) => {
|
||||
return acc > val ? acc : val
|
||||
}, 0)
|
||||
block.xywh = [0, maxY + 2, 3, 3]
|
||||
}
|
||||
|
||||
// Doing this to avoid blockID duplicates when saved
|
||||
block.blockID = NoID
|
||||
this.editor = { index: undefined, block: compose.PageBlockMaker(block) }
|
||||
this.updateBlocks()
|
||||
|
||||
if (!this.editor) {
|
||||
this.toastSuccess(msg)
|
||||
} else {
|
||||
this.toastErrorHandler(this.$t('notification:page.duplicateFailed'))
|
||||
}
|
||||
},
|
||||
|
||||
// Trigger browser dialog on page leave to prevent unsaved changes
|
||||
checkUnsavedBlocks (next) {
|
||||
next(!this.unsavedBlocks.size || window.confirm(this.$t('build.unsavedChanges')))
|
||||
|
||||
4
lib/js/src/compose/helpers/idgen.ts
Normal file
4
lib/js/src/compose/helpers/idgen.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export function generateUID (): string {
|
||||
let uid = Math.random().toString(36).substring(2) + (new Date()).getTime().toString(36)
|
||||
return `tempID-${uid}`
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { merge } from 'lodash'
|
||||
import { Apply } from '../../../cast'
|
||||
import { generateUID } from '../../helpers/idgen'
|
||||
|
||||
interface PageBlockStyleVariants {
|
||||
[_: string]: string;
|
||||
@@ -19,6 +20,11 @@ interface PageBlockStyle {
|
||||
border?: PageBlockStyleBorder;
|
||||
}
|
||||
|
||||
interface PageBlockMeta {
|
||||
tabbed?: boolean;
|
||||
tempID?: string;
|
||||
}
|
||||
|
||||
export type PageBlockInput = PageBlock | Partial<PageBlock>
|
||||
|
||||
const defaultXYWH = [0, 2000, 3, 3]
|
||||
@@ -34,6 +40,11 @@ export class PageBlock {
|
||||
|
||||
public options = {}
|
||||
|
||||
public meta: PageBlockMeta = {
|
||||
tabbed: false,
|
||||
tempID: undefined,
|
||||
}
|
||||
|
||||
public style: PageBlockStyle = {
|
||||
variants: {
|
||||
headerText: 'dark',
|
||||
@@ -48,6 +59,7 @@ export class PageBlock {
|
||||
|
||||
constructor (i?: PageBlockInput) {
|
||||
this.apply(i)
|
||||
this.setTempID()
|
||||
}
|
||||
|
||||
apply (i?: PageBlockInput): void {
|
||||
@@ -75,12 +87,29 @@ export class PageBlock {
|
||||
if (i.style) {
|
||||
this.style = merge({}, this.style, i.style)
|
||||
}
|
||||
|
||||
if (i.meta) {
|
||||
this.meta = merge({}, this.meta, i.meta)
|
||||
}
|
||||
}
|
||||
|
||||
// Returns Page Block configuration errors
|
||||
validate (): Array<string> {
|
||||
return []
|
||||
}
|
||||
|
||||
setTempID (): void {
|
||||
this.meta.tempID = this.meta.tempID || generateUID()
|
||||
}
|
||||
|
||||
clone(): PageBlockInput {
|
||||
const clone = new PageBlock()
|
||||
clone.kind = this.kind
|
||||
clone.title = this.title
|
||||
clone.style = merge({}, this.style)
|
||||
clone.options = merge({}, this.options)
|
||||
return clone
|
||||
}
|
||||
}
|
||||
|
||||
export const Registry = new Map<string, typeof PageBlock>()
|
||||
|
||||
@@ -35,6 +35,7 @@ class CalendarOptions {
|
||||
public feeds: Array<Feed> = []
|
||||
public header: Partial<CalendarOptionsHeader> = {}
|
||||
public locale = 'en-gb'
|
||||
public tabbed = false
|
||||
public refreshRate = 0
|
||||
public showRefresh = false
|
||||
public magnifyOption = ''
|
||||
@@ -75,6 +76,7 @@ export class PageBlockCalendar extends PageBlock {
|
||||
)
|
||||
|
||||
this.options.locale = o.locale || 'en-gb'
|
||||
this.options.tabbed = o.tabbed || false
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -17,14 +17,14 @@ interface Options {
|
||||
}
|
||||
|
||||
const defaults: Readonly<Options> = Object.freeze({
|
||||
chartID: '',
|
||||
chartID: NoID,
|
||||
refreshRate: 0,
|
||||
showRefresh: false,
|
||||
magnifyOption: '',
|
||||
drillDown: {
|
||||
enabled: false,
|
||||
blockID: ''
|
||||
}
|
||||
blockID: '',
|
||||
},
|
||||
})
|
||||
|
||||
export class PageBlockChart extends PageBlock {
|
||||
|
||||
@@ -15,10 +15,11 @@ export { PageBlockComment } from './comment'
|
||||
export { PageBlockReport } from './report'
|
||||
export { PageBlockProgress } from './progress'
|
||||
export { PageBlockNylas } from './nylas'
|
||||
export { PageBlockGeometry } from './geometry'
|
||||
export { PageBlockNavigation } from './navigation'
|
||||
export { PageBlockTab } from './tabs'
|
||||
export { PageBlockGeometry } from './geometry'
|
||||
|
||||
export function PageBlockMaker<T extends PageBlock> (i: { kind: string }): T {
|
||||
export function PageBlockMaker<T extends PageBlock>(i: { kind: string }): T {
|
||||
const PageBlockTemp = Registry.get(i.kind)
|
||||
if (PageBlockTemp === undefined) {
|
||||
throw new Error(`unknown block kind '${i.kind}'`)
|
||||
|
||||
@@ -103,7 +103,7 @@ export class PageBlockProgress extends PageBlock {
|
||||
}
|
||||
|
||||
if (o.display) {
|
||||
this.options.display = { ...this.options.display, ...o.display }
|
||||
this.options.display = o.display
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ interface Options {
|
||||
|
||||
// referenced fields (records, users) we want to expand
|
||||
expRefFields: string[];
|
||||
|
||||
refreshRate: number;
|
||||
showRefresh: boolean;
|
||||
magnifyOption: string;
|
||||
|
||||
53
lib/js/src/compose/types/page-block/tabs.ts
Normal file
53
lib/js/src/compose/types/page-block/tabs.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { PageBlock, PageBlockInput, Registry } from './base'
|
||||
|
||||
const kind = 'Tabs'
|
||||
|
||||
interface Style {
|
||||
appearance: string;
|
||||
alignment: string;
|
||||
fillJustify: string;
|
||||
}
|
||||
|
||||
interface Tab {
|
||||
blockID: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface Options {
|
||||
style: Style;
|
||||
tabs: Tab[];
|
||||
}
|
||||
|
||||
const defaults: Readonly<Options> = Object.freeze({
|
||||
style: {
|
||||
appearance: 'tabs',
|
||||
alignment: 'left',
|
||||
fillJustify: 'none',
|
||||
},
|
||||
tabs: [],
|
||||
})
|
||||
|
||||
export class PageBlockTab extends PageBlock {
|
||||
readonly kind = kind
|
||||
|
||||
options: Options = { ...defaults }
|
||||
|
||||
constructor (i?: PageBlockInput) {
|
||||
super(i)
|
||||
this.applyOptions(i?.options as Partial<Options>)
|
||||
}
|
||||
|
||||
applyOptions (o?: Partial<Options>): void {
|
||||
if (!o) return
|
||||
|
||||
if (o.tabs) {
|
||||
this.options.tabs = o.tabs
|
||||
}
|
||||
|
||||
if (o.style) {
|
||||
this.options.style = o.style
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Registry.set(kind, PageBlockTab)
|
||||
@@ -123,6 +123,8 @@ general:
|
||||
modal: Modal
|
||||
fullscreen: Fullscreen
|
||||
wrap: Display block as a card
|
||||
tabbed:
|
||||
label: Tabbed
|
||||
refresh:
|
||||
label: Refresh
|
||||
auto: Auto refresh
|
||||
@@ -694,3 +696,54 @@ geometry:
|
||||
lockBounds: Lock bounds
|
||||
topLeft: Bounds top left
|
||||
lowerRight: Bounds lower right
|
||||
|
||||
tabs:
|
||||
alertTitle: Set a title for your block
|
||||
title: Tabs
|
||||
addTab: + Add
|
||||
selectBlock: Choose a block
|
||||
noTabs: No tabs have been made
|
||||
noTabsBase: No tabs have been made yet
|
||||
displayTitle: Display Options
|
||||
preview: Live example
|
||||
newBlockModal: Add new block
|
||||
form:
|
||||
title: Set a title for your tab
|
||||
placeholder: Tab a block
|
||||
style:
|
||||
appearance:
|
||||
tabs: Tabs
|
||||
pills: Pills
|
||||
small: Small
|
||||
alignment:
|
||||
left: Left
|
||||
center: Center
|
||||
right: Right
|
||||
fillJustify:
|
||||
fill: Fill
|
||||
justified: Justify
|
||||
none: None
|
||||
verticalHorizontal:
|
||||
vertical: Vertical
|
||||
horizontal: Horizontal
|
||||
tabPosition:
|
||||
top: Top
|
||||
bottom: Bottom
|
||||
table:
|
||||
columns:
|
||||
title:
|
||||
label: Title
|
||||
block:
|
||||
label: Block
|
||||
tooltip:
|
||||
edit: Edit block
|
||||
changeTab: Change tab
|
||||
addTab: Add tab
|
||||
cancel: Switch to view mode
|
||||
newBlock: Add new block
|
||||
delete: Delete tab
|
||||
selectBlock: Select a block to tab
|
||||
move: Move tab
|
||||
title: Tab title
|
||||
tabCondition: You can't make tabs until you add this block
|
||||
label: Tabs
|
||||
|
||||
@@ -33,6 +33,7 @@ label:
|
||||
pageBuilder: Page builder
|
||||
permissions: Permissions
|
||||
saveAndClose: Save and close
|
||||
delete: Delete Block
|
||||
loading: Loading
|
||||
moduleEdit: Edit module
|
||||
navigation:
|
||||
|
||||
@@ -3,11 +3,12 @@ package types
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"github.com/cortezaproject/corteza/server/pkg/sql"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/sql"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/filter"
|
||||
"github.com/cortezaproject/corteza/server/pkg/locale"
|
||||
"github.com/spf13/cast"
|
||||
@@ -58,6 +59,7 @@ type (
|
||||
Style PageBlockStyle `json:"style,omitempty"`
|
||||
Kind string `json:"kind"`
|
||||
XYWH [4]int `json:"xywh"` // x,y,w,h
|
||||
Meta map[string]any `json:"meta,omitempty"`
|
||||
|
||||
// Warning: value of this field is now handled via resource-translation facility
|
||||
// struct field is kept for the convenience for now since it allows us
|
||||
|
||||
Reference in New Issue
Block a user