3
0

Add support for page icon and the option to expand sub-pages in the sidebar

This commit is contained in:
Katrin Yordanova
2023-01-25 13:56:49 +02:00
committed by Jože Fortun
parent c9740a6527
commit 72d44713f2
13 changed files with 509 additions and 69 deletions

View File

@@ -24,9 +24,7 @@
label-cols="2"
class="mb-0"
>
<b-input-group>
<b-form-input v-model="pageAttachmentWhitelist" />
</b-input-group>
<b-form-input v-model="pageAttachmentWhitelist" />
</b-form-group>
<hr>
@@ -36,13 +34,11 @@
:label="$t('attachments.max-size')"
label-cols="2"
>
<b-input-group>
<b-form-input
v-model="basic['compose.record.attachments.max-size']"
type="number"
number
/>
</b-input-group>
<b-form-input
v-model="basic['compose.record.attachments.max-size']"
type="number"
number
/>
</b-form-group>
<b-form-group
:label="$t('attachments.type.whitelist')"
@@ -50,9 +46,29 @@
label-cols="2"
class="mb-0"
>
<b-input-group class="m-0">
<b-form-input v-model="recordAttachmentWhitelist" />
</b-input-group>
<b-form-input v-model="recordAttachmentWhitelist" />
</b-form-group>
<hr>
<h5>{{ $t('attachments.icon') }}</h5>
<b-form-group
:label="$t('attachments.max-size')"
label-cols="2"
>
<b-form-input
v-model="basic['compose.icon.attachments.max-size']"
type="number"
number
/>
</b-form-group>
<b-form-group
:label="$t('attachments.type.whitelist')"
:description="$t('attachments.type.description')"
label-cols="2"
class="mb-0"
>
<b-form-input v-model="iconAttachmentWhitelist" />
</b-form-group>
</b-form>
@@ -131,6 +147,16 @@ export default {
this.basic['compose.record.attachments.mimetypes'] = this.convertToExternal(value)
},
},
iconAttachmentWhitelist: {
get () {
return (this.basic['compose.icon.attachments.mimetypes'] || []).join(',')
},
set (value) {
this.basic['compose.icon.attachments.mimetypes'] = this.convertToExternal(value)
},
},
},
methods: {

View File

@@ -103,36 +103,6 @@ import { Portal } from 'portal-vue'
import { VueSelect } from 'vue-select'
const { CSidebarNavItems, CInputSearch } = components
const publicPageWrap = ({ pageID, selfID, title, visible }) => ({
page: {
// name omitted as default is provided
pageID,
selfID,
title,
visible,
},
children: [],
params: {
pageID,
},
})
const adminPageWrap = (page) => {
return {
page: {
name: 'admin.pages.builder',
pageID: `page-${page.pageID}`,
selfID: page.selfID !== NoID ? `page-${page.selfID}` : 'pages',
rootSelfID: 'pages',
title: page.title || page.handle,
visible: true,
},
children: [],
params: {
pageID: page.pageID,
},
}
}
const moduleWrap = (module) => {
return {
page: {
@@ -241,8 +211,7 @@ export default {
filteredPages () {
if (this.namespace) {
// If on admin page, show admin pages. Otherwise show public pages
const pages = [...(this.isAdminPage ? this.adminRoutes() : this.publicRoutes.map(publicPageWrap))]
const pages = [...(this.isAdminPage ? this.adminRoutes() : this.publicPageWrap(this.publicRoutes))]
if (!this.query) {
return pages
}
@@ -259,7 +228,7 @@ export default {
navItems () {
const current = this.filteredPages
const ax = this.pageIndex(this.isAdminPage ? this.adminRoutes() : this.pages.map(publicPageWrap))
const ax = this.pageIndex(this.isAdminPage ? this.adminRoutes() : this.publicPageWrap(this.pages))
// Correct potentially missing parent references
for (const cp of current) {
@@ -374,7 +343,7 @@ export default {
},
children: [],
},
...this.pages.map(adminPageWrap),
...this.adminPageWrap(this.pages),
{
page: {
pageID: 'charts',
@@ -388,6 +357,68 @@ export default {
...this.charts.map(chartWrap),
]
},
publicPageWrap (pages) {
return pages.map(({ pageID, selfID, title, visible, config }) => {
const { navItem = {} } = config
const { icon = {}, expanded = '' } = navItem
const { type = '', src = '' } = icon
const iconType = 'attachment'
let iconSrc = src
if (type === iconType) {
iconSrc = `${this.$ComposeAPI.baseURL}${src}`
}
return {
page: {
// name omitted as default is provided
pageID,
selfID,
title,
visible,
expanded,
icon: iconSrc,
},
children: [],
params: {
pageID,
},
}
})
},
adminPageWrap (pages) {
return pages.map(({ pageID, selfID, title, handle, config }) => {
const { navItem = {} } = config
const { icon = {} } = navItem
const { type = '', src = '' } = icon
const iconType = 'attachment'
let iconSrc = src
if (type === iconType) {
iconSrc = `${this.$ComposeAPI.baseURL}${src}`
}
return {
page: {
name: 'admin.pages.builder',
pageID: `page-${pageID}`,
selfID: selfID !== NoID ? `page-${selfID}` : 'pages',
rootSelfID: 'pages',
title: title || handle,
visible: true,
icon: iconSrc,
},
children: [],
params: {
pageID: pageID,
},
}
})
},
},
}
</script>

View File

@@ -81,6 +81,10 @@ export default {
required: false,
default: () => ({}),
},
paramName: {
type: String,
default: 'upload',
},
},
data () {
@@ -100,7 +104,7 @@ export default {
const vm = this
return {
paramName: 'upload',
paramName: this.paramName,
maxFilesize: this.maxFilesize, // mb
url: () => this.baseUrl + this.endpoint,
thumbnailMethod: 'contain',

View File

@@ -111,6 +111,33 @@
/>
</b-form-group>
<b-form-group
:label="$t('icon.page')"
label-class="text-primary"
>
<img
v-if="icon.src"
:src="pageIcon"
width="50"
heigth="50"
class="d-block"
>
<span
v-else
class="d-block"
>
{{ $t('icon.not-set') }}
</span>
<b-button
variant="light"
size="md"
class="mt-2 text-dark"
@click="showIconModal = true"
>
{{ $t('icon.set') }}
</b-button>
</b-form-group>
<b-form-group
v-if="!isRecordPage"
>
@@ -122,6 +149,118 @@
{{ $t('edit.visible') }}
</b-form-checkbox>
</b-form-group>
<b-form-group
data-test-id="checkbox-show-sub-pages-in-sidebar"
class="d-flex"
>
<b-form-checkbox
v-model="page.config.navItem.expanded"
switch
>
{{ $t('showSubPages') }}
</b-form-checkbox>
</b-form-group>
<b-modal
v-model="showIconModal"
:title="$t('icon.configure')"
:ok-title="$t('label.saveAndClose')"
ok-only
size="lg"
label-class="text-primary"
@show="openIconModal"
@close="closeIconModal"
>
<b-form-group
:label="$t('icon.upload')"
label-class="text-primary"
class="mb-0"
>
<uploader
:endpoint="endpoint"
:accepted-files="['image/*']"
:param-name="'icon'"
@uploaded="uploadAttachment"
/>
<b-form-group
:label="$t('url.label')"
label-class="text-primary"
class="my-2"
>
<b-input-group>
<b-input
v-model="linkUrl"
:disabled="isIconSet"
:style="{ 'cursor': isIconSet ? 'not-allowed' : 'default' }"
/>
<b-input-group-append>
<b-button
v-b-modal.logo
:title="$t('tooltip.preview-link')"
:disabled="!linkUrl"
variant="light"
rounded
class="d-flex align-items-center btn-light"
>
<font-awesome-icon :icon="['fas', 'external-link-alt']" />
</b-button>
</b-input-group-append>
</b-input-group>
</b-form-group>
</b-form-group>
<template v-if="attachments.length > 0">
<hr>
<b-form-group
:label="$t('icon.list')"
label-class="text-primary"
>
<div
v-if="processing"
class="d-flex align-items-center justify-content-center h-100"
>
<b-spinner />
</div>
<div
v-else
class="d-flex flex-wrap px-2"
>
<div
v-for="a in attachments"
:key="a.attachmentID"
:class="[selectedIconID === a.attachmentID ? 'border-success' : '2px solid']"
class="mt-2 mr-2 p-2 border rounded-circle"
>
<img
:src="a.src"
:alt="a.name"
:style="{ 'cursor': !!linkUrl ? 'not-allowed' : 'pointer' }"
class="rounded"
style="height: 2.3em; width: 2.3em;"
@click="toggleSelectedIcon(a.attachmentID)"
>
</div>
</div>
</b-form-group>
</template>
</b-modal>
<b-modal
id="logo"
hide-header
hide-footer
centered
body-class="p-1"
>
<b-img
:src="linkUrl"
fluid-grow
/>
</b-modal>
</b-form>
</b-card>
</b-col>
@@ -171,6 +310,7 @@ import { mapGetters, mapActions } from 'vuex'
import EditorToolbar from 'corteza-webapp-compose/src/components/Admin/EditorToolbar'
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 { compose, NoID } from '@cortezaproject/corteza-js'
import { handle } from '@cortezaproject/corteza-vue'
@@ -184,6 +324,7 @@ export default {
components: {
EditorToolbar,
PageTranslator,
Uploader,
},
mixins: [
@@ -206,6 +347,13 @@ export default {
return {
modulesList: [],
page: new compose.Page(),
showIconModal: false,
attachments: [],
selectedIconID: '',
isSelected: false,
linkUrl: '',
processing: false,
}
},
@@ -250,6 +398,29 @@ export default {
return this.hasChildren && this.page.canDeletePage && !this.page.deletedAt
},
endpoint () {
return this.$ComposeAPI.iconUploadEndpoint({
namespaceID: this.namespace.namespaceID,
})
},
icon: {
get () {
return this.page.config.navItem.icon || {}
},
set (icon) {
this.page.config.navItem = icon
},
},
isIconSet () {
return !!this.attachments.find(a => a.attachmentID === this.selectedIconID)
},
pageIcon () {
return this.icon.type === 'link' ? this.icon.src : this.makeAttachmentUrl(this.icon.src)
},
},
watch: {
@@ -257,8 +428,7 @@ export default {
immediate: true,
handler (pageID) {
if (pageID) {
const { namespaceID } = this.namespace
this.findPageByID({ namespaceID, pageID }).then((page) => {
this.findPageByID({ namespaceID: this.namespace.namespaceID, pageID }).then((page) => {
this.page = page.clone()
}).catch(this.toastErrorHandler(this.$t('notification:page.loadFailed')))
}
@@ -275,14 +445,14 @@ export default {
loadPages: 'page/load',
}),
handleSave ({ closeOnSuccess = false } = {}) {
async 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
this.updatePage({ namespaceID, ...this.page, resourceTranslationLanguage }).then((page) => {
await this.setIcon().then(icon => { this.page.config.navItem = { icon, expanded: this.page.config.navItem.expanded } })
this.updatePage({ namespaceID: this.namespace.namespaceID, ...this.page, resourceTranslationLanguage }).then((page) => {
this.page = page.clone()
this.toastSuccess(this.$t('notification:page.saved'))
if (closeOnSuccess) {
@@ -296,6 +466,95 @@ export default {
this.$router.push({ name: 'admin.pages' })
}).catch(this.toastErrorHandler(this.$t('notification:page.deleteFailed')))
},
uploadAttachment () {
this.fetchAttachments()
},
fetchAttachments () {
this.processing = true
this.$ComposeAPI.iconList({ sort: 'id DESC' })
.then(({ set: attachments = [] }) => {
const baseURL = this.$ComposeAPI.baseURL
this.attachments = []
if (attachments) {
attachments.forEach(a => {
const src = !a.url.includes(baseURL) ? this.makeAttachmentUrl(a.url) : a.url
const currSelectedIcon = a.url === this.icon.src
if (currSelectedIcon) {
this.selectedIconID = a.attachmentID
}
this.attachments.push({ ...a, src })
})
}
})
.catch(this.toastErrorHandler(this.$t('notification:page.iconFetchFailed')))
.finally(() => {
this.processing = false
})
},
async setIcon () {
const selectedAttachmentUrl = this.isSelected ? this.attachments.find(att => att.attachmentID === this.selectedIconID).url : ''
let attachmentType = 'link'
let attachmentSource = this.linkUrl
if (!attachmentSource) {
attachmentType = 'attachment'
attachmentSource = selectedAttachmentUrl
}
const navItem = {
icon: {
type: '',
src: '',
},
}
if (attachmentType && attachmentSource) {
await this.$ComposeAPI.pageUpdateIcon({
namespaceID: this.namespace.namespaceID,
pageID: this.pageID,
type: attachmentType,
source: attachmentSource,
}).then(({ type, src }) => {
navItem.icon.type = type
navItem.icon.src = src
})
}
return !this.icon.src ? navItem.icon : this.icon
},
toggleSelectedIcon (attachmentID = '') {
if (!this.linkUrl) {
this.isSelected = this.selectedIconID !== attachmentID
this.selectedIconID = this.isSelected ? attachmentID : ''
}
},
closeIconModal () {
this.isSelected = this.icon.src
this.selectedIconID = this.isSelected ? this.isIconSet : ''
if (this.icon.type === 'link') {
this.linkUrl = this.icon.src
}
},
makeAttachmentUrl (src) {
return `${this.$ComposeAPI.baseURL}${src}`
},
openIconModal () {
this.linkUrl = this.icon.type === 'link' ? this.icon.src : ''
this.fetchAttachments()
},
},
}
</script>

View File

@@ -919,6 +919,94 @@ export default class Compose {
return `/namespace/${namespaceID}/page/${pageID}/translation`
}
// Update icon for page
async pageUpdateIcon (a: KV, extra: AxiosRequestConfig = {}): Promise<KV> {
const {
namespaceID,
pageID,
type,
source,
style,
} = (a as KV) || {}
if (!namespaceID) {
throw Error('field namespaceID is empty')
}
if (!pageID) {
throw Error('field pageID is empty')
}
if (!type) {
throw Error('field type is empty')
}
const cfg: AxiosRequestConfig = {
...extra,
method: 'patch',
url: this.pageUpdateIconEndpoint({
namespaceID, pageID,
}),
}
cfg.data = {
type,
source,
style,
}
return this.api().request(cfg).then(result => stdResolve(result))
}
pageUpdateIconEndpoint (a: KV): string {
const {
namespaceID,
pageID,
} = a || {}
return `/namespace/${namespaceID}/page/${pageID}/icon`
}
// List icons
async iconList (a: KV, extra: AxiosRequestConfig = {}): Promise<KV> {
const {
limit,
incTotal,
pageCursor,
sort,
} = (a as KV) || {}
const cfg: AxiosRequestConfig = {
...extra,
method: 'get',
url: this.iconListEndpoint(),
}
cfg.params = {
limit,
incTotal,
pageCursor,
sort,
}
return this.api().request(cfg).then(result => stdResolve(result))
}
iconListEndpoint (): string {
return '/icon/'
}
// Upload icon
async iconUpload (a: KV, extra: AxiosRequestConfig = {}): Promise<KV> {
const {
icon,
} = (a as KV) || {}
const cfg: AxiosRequestConfig = {
...extra,
method: 'post',
url: this.iconUploadEndpoint(),
}
cfg.data = {
icon,
}
return this.api().request(cfg).then(result => stdResolve(result))
}
iconUploadEndpoint (): string {
return '/icon/'
}
// List modules
async moduleList (a: KV, extra: AxiosRequestConfig = {}): Promise<KV> {
const {

View File

@@ -22,6 +22,14 @@ interface PageConfig {
clone: Button;
back: Button;
};
attachments: [];
navItem: {
icon: {
type: string;
src: string;
};
expanded: false;
};
}
export class Page {
@@ -52,6 +60,14 @@ export class Page {
clone: { enabled: true },
back: { enabled: true },
},
attachments: [],
navItem: {
icon: {
type: '',
src: '',
},
expanded: false,
},
}
public createdAt?: Date = undefined;

View File

@@ -7,7 +7,6 @@ export default {
props: {
inline: {
type: Boolean,
default: false,
},
},

View File

@@ -25,12 +25,11 @@ export default {
props: {
alt: {
required: false,
type: String,
default: null,
},
title: {
required: false,
type: String,
default: null,
},
@@ -81,6 +80,7 @@ export default {
<style scoped lang="scss">
div {
object-fit: contain;
img {
&.loaded {
width: auto;
@@ -88,6 +88,7 @@ div {
display: block;
}
}
&.inline {
img {
cursor: zoom-in;

View File

@@ -2,7 +2,6 @@
export default {
props: {
labels: {
required: false,
type: Object,
default: () => ({}),
},
@@ -14,7 +13,6 @@ export default {
},
mime: {
required: false,
type: String,
default: undefined,
},
@@ -25,26 +23,22 @@ export default {
},
name: {
required: false,
type: String,
default: null,
},
previewStyle: {
required: false,
type: Object,
default: () => ({}),
},
previewClass: {
required: false,
type: Array,
default: () => [],
},
inline: {
type: Boolean,
default: false,
},
},
}

View File

@@ -13,11 +13,22 @@
class="d-inline-block w-75 text-nowrap text-truncate"
@click="closeSidebar()"
>
<font-awesome-icon
<template
v-if="page.icon"
class="icon"
:icon="page.icon"
/>
>
<font-awesome-icon
v-if="Array.isArray(page.icon)"
class="icon"
:icon="page.icon"
/>
<template v-else>
<img
:src="page.icon"
class="rounded-circle mr-1"
style="height: 1.5em; width: 1.5em;"
/>
</template>
</template>
<span
class="title"
>
@@ -102,9 +113,7 @@ export default {
items.forEach(({ page }) => {
const px = this.pageIndex(page)
// Apply startExpanded only if page isn't currently expanded
if (!this.collapses[px]) {
this.$set(this.collapses, px, this.startExpanded)
}
this.$set(this.collapses, px, this.startExpanded || page.expanded)
})
},
},

View File

@@ -5,6 +5,7 @@ editor:
max-size: Max size (MB)
page: Page attachments
record: Record attachments
icon: Icon attachments
type:
description: 'MIME types, separated with ",". Example: "text/plain,text/csv"'
whitelist: File type whitelist

View File

@@ -73,6 +73,7 @@ page:
createFailed: Could not create a page
cloneFailed: Could not clone this page
deleteFailed: Could not delete this page
iconFetchFailed: Could not fetch list of icons
loadFailed: Could not load the page tree
noPages: No pages found
saveFailed: Could not save this page

View File

@@ -23,6 +23,15 @@ edit:
edit: Edit page
pageDescription: Page description
visible: Page visibility
icon:
configure: Configure page icon
not-set: No icon set
page: Page icon
set: Set icon
upload: Upload icon
list: List of uploaded icons
url:
label: Or add URL to icon
import: 'Import page(s):'
instructions: Drag and drop pages to adjust the order and hierarchy
invalid: Invalid configuration
@@ -45,6 +54,7 @@ noPages: No pages
notVisible: Hidden
pageMoveFailed: Could not move this page
reordered: Page reordered
showSubPages: Expand sub-pages in the sidebar
title: List of Pages
tooltip:
unsavedChanges: Unsaved Changes
@@ -56,6 +66,7 @@ tooltip:
block: Copy block
clone:
block: Clone block
preview-link: Preview link
saveAsCopy: Cannot duplicate record page
view: View
visible: Visible