diff --git a/client/web/compose/src/assets/PageBlocks/Tabs.png b/client/web/compose/src/assets/PageBlocks/Tabs.png new file mode 100644 index 000000000..17023c94d Binary files /dev/null and b/client/web/compose/src/assets/PageBlocks/Tabs.png differ diff --git a/client/web/compose/src/assets/PageBlocks/index.js b/client/web/compose/src/assets/PageBlocks/index.js index 5cbb221c9..bc457aac6 100644 --- a/client/web/compose/src/assets/PageBlocks/index.js +++ b/client/web/compose/src/assets/PageBlocks/index.js @@ -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') diff --git a/client/web/compose/src/components/Admin/Page/Builder/Selector.vue b/client/web/compose/src/components/Admin/Page/Builder/Selector.vue index ca4a8aaff..698f6d520 100644 --- a/client/web/compose/src/components/Admin/Page/Builder/Selector.vue +++ b/client/web/compose/src/components/Admin/Page/Builder/Selector.vue @@ -8,7 +8,7 @@ [], + }, }, 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) + }, + }, } diff --git a/client/web/compose/src/components/Chart/index.vue b/client/web/compose/src/components/Chart/index.vue index 496a9c62e..cad39a814 100644 --- a/client/web/compose/src/components/Chart/index.vue +++ b/client/web/compose/src/components/Chart/index.vue @@ -1,5 +1,7 @@
+ + + + {{ $t('general.tabbed.label') }} + + @@ -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: { diff --git a/client/web/compose/src/components/PageBlocks/TabsBase.vue b/client/web/compose/src/components/PageBlocks/TabsBase.vue new file mode 100644 index 000000000..a6bcabad3 --- /dev/null +++ b/client/web/compose/src/components/PageBlocks/TabsBase.vue @@ -0,0 +1,87 @@ + + + diff --git a/client/web/compose/src/components/PageBlocks/TabsConfigurator.vue b/client/web/compose/src/components/PageBlocks/TabsConfigurator.vue new file mode 100644 index 000000000..cfb980aa3 --- /dev/null +++ b/client/web/compose/src/components/PageBlocks/TabsConfigurator.vue @@ -0,0 +1,337 @@ + + + + + diff --git a/client/web/compose/src/components/PageBlocks/index.js b/client/web/compose/src/components/PageBlocks/index.js index e4082b1d0..71434d440 100644 --- a/client/web/compose/src/components/PageBlocks/index.js +++ b/client/web/compose/src/components/PageBlocks/index.js @@ -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, } diff --git a/client/web/compose/src/lib/tabs.js b/client/web/compose/src/lib/tabs.js new file mode 100644 index 000000000..c1b12455b --- /dev/null +++ b/client/web/compose/src/lib/tabs.js @@ -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 +} diff --git a/client/web/compose/src/views/Admin/Pages/Builder.vue b/client/web/compose/src/views/Admin/Pages/Builder.vue index 967cb207e..89e0a4b89 100644 --- a/client/web/compose/src/views/Admin/Pages/Builder.vue +++ b/client/web/compose/src/views/Admin/Pages/Builder.vue @@ -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" > + @@ -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'))) diff --git a/lib/js/src/compose/helpers/idgen.ts b/lib/js/src/compose/helpers/idgen.ts new file mode 100644 index 000000000..8873d5c55 --- /dev/null +++ b/lib/js/src/compose/helpers/idgen.ts @@ -0,0 +1,4 @@ +export function generateUID (): string { + let uid = Math.random().toString(36).substring(2) + (new Date()).getTime().toString(36) + return `tempID-${uid}` +} \ No newline at end of file diff --git a/lib/js/src/compose/types/page-block/base.ts b/lib/js/src/compose/types/page-block/base.ts index 3906c1972..e393bd9c8 100644 --- a/lib/js/src/compose/types/page-block/base.ts +++ b/lib/js/src/compose/types/page-block/base.ts @@ -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 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 { 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() diff --git a/lib/js/src/compose/types/page-block/calendar/page-block.ts b/lib/js/src/compose/types/page-block/calendar/page-block.ts index c22241977..12f14ed93 100644 --- a/lib/js/src/compose/types/page-block/calendar/page-block.ts +++ b/lib/js/src/compose/types/page-block/calendar/page-block.ts @@ -35,6 +35,7 @@ class CalendarOptions { public feeds: Array = [] public header: Partial = {} 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 } /** diff --git a/lib/js/src/compose/types/page-block/chart.ts b/lib/js/src/compose/types/page-block/chart.ts index 0b6e4dc8c..ce5df51a8 100644 --- a/lib/js/src/compose/types/page-block/chart.ts +++ b/lib/js/src/compose/types/page-block/chart.ts @@ -17,14 +17,14 @@ interface Options { } const defaults: Readonly = Object.freeze({ - chartID: '', + chartID: NoID, refreshRate: 0, showRefresh: false, magnifyOption: '', drillDown: { enabled: false, - blockID: '' - } + blockID: '', + }, }) export class PageBlockChart extends PageBlock { diff --git a/lib/js/src/compose/types/page-block/index.ts b/lib/js/src/compose/types/page-block/index.ts index c99b6789d..398bd69cc 100644 --- a/lib/js/src/compose/types/page-block/index.ts +++ b/lib/js/src/compose/types/page-block/index.ts @@ -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 (i: { kind: string }): T { +export function PageBlockMaker(i: { kind: string }): T { const PageBlockTemp = Registry.get(i.kind) if (PageBlockTemp === undefined) { throw new Error(`unknown block kind '${i.kind}'`) diff --git a/lib/js/src/compose/types/page-block/progress.ts b/lib/js/src/compose/types/page-block/progress.ts index 9e97e9136..f42bef46c 100644 --- a/lib/js/src/compose/types/page-block/progress.ts +++ b/lib/js/src/compose/types/page-block/progress.ts @@ -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 } } diff --git a/lib/js/src/compose/types/page-block/record-revisions.ts b/lib/js/src/compose/types/page-block/record-revisions.ts index 8d399a89e..001960176 100644 --- a/lib/js/src/compose/types/page-block/record-revisions.ts +++ b/lib/js/src/compose/types/page-block/record-revisions.ts @@ -16,6 +16,7 @@ interface Options { // referenced fields (records, users) we want to expand expRefFields: string[]; + refreshRate: number; showRefresh: boolean; magnifyOption: string; diff --git a/lib/js/src/compose/types/page-block/tabs.ts b/lib/js/src/compose/types/page-block/tabs.ts new file mode 100644 index 000000000..4b27a4c58 --- /dev/null +++ b/lib/js/src/compose/types/page-block/tabs.ts @@ -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 = 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) + } + + applyOptions (o?: Partial): void { + if (!o) return + + if (o.tabs) { + this.options.tabs = o.tabs + } + + if (o.style) { + this.options.style = o.style + } + } +} + +Registry.set(kind, PageBlockTab) diff --git a/locale/en/corteza-webapp-compose/block.yaml b/locale/en/corteza-webapp-compose/block.yaml index 7857ebf9b..9731ffd98 100644 --- a/locale/en/corteza-webapp-compose/block.yaml +++ b/locale/en/corteza-webapp-compose/block.yaml @@ -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 diff --git a/locale/en/corteza-webapp-compose/page.yaml b/locale/en/corteza-webapp-compose/page.yaml index 171eb3a82..0f677d714 100644 --- a/locale/en/corteza-webapp-compose/page.yaml +++ b/locale/en/corteza-webapp-compose/page.yaml @@ -33,6 +33,7 @@ label: pageBuilder: Page builder permissions: Permissions saveAndClose: Save and close + delete: Delete Block loading: Loading moduleEdit: Edit module navigation: diff --git a/server/compose/types/page.go b/server/compose/types/page.go index 7e5b1fe9c..84073e46f 100644 --- a/server/compose/types/page.go +++ b/server/compose/types/page.go @@ -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