New Pages section & sitemap editor (#192)

This adds a new Pages section as discussed in
https://github.com/openhab/openhab-webui/issues/155#issuecomment-586711180
with an editor for "managed" sitemaps stored
as UI components in the system:sitemap namespace.

A code view supporting the current sitemap DSL
is provided.

Sitemaps made with the UI don't support visibility
or color rules yet.

Signed-off-by: Yannick Schaus <github@schaus.net>
pull/194/head
Yannick Schaus 2020-02-23 21:28:18 +01:00 committed by GitHub
parent c332164944
commit d7ae6036af
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 1141 additions and 49 deletions

View File

@ -6,19 +6,22 @@
"things.text": "Things are the devices and services connected to openHAB, they are provided by binding add-ons.<br><br>Installed bindings which support auto-discovery will add thing candidates to your Inbox. You can also start a scan for a certain binding or add your first thing manually with the button below.",
"things.nobindings.title": "No bindings installed",
"things.nobindings.text": "You need to install binding add-ons to add things they support to your system. Click the button below to install some.",
"things.nobindings.text": "You need to install binding add-ons to be able to add things to your system.",
"model.title": "Start modelling your home",
"model.text": "Build a model from your items to organize them and relate them to each other semantically.<br><br>Begin with a hierarchy of locations: buildings, outside areas, floors and rooms, as needed. Then, insert equipments and points from your things (or manually).",
"items.title": "No items yet",
"items.text": "Items represent the functional side of your home - you can link them to the channels defined by your things. Start with the Model view to create a clean initial structure.<br><br>You can also define items with configuration files, or with the button below.",
"model.title": "Start modelling your home",
"model.text": "Build a model from your items to organize them and relate them to each other semantically.<br><br>Begin with a hierarchy of locations: buildings, outside areas, floors and rooms, as needed. Then, insert equipments and points from your things (or manually).",
"pages.title": "No pages yet",
"pages.text": "Design pages to display information in various ways and interact with your items. You can create several kinds of pages: charts, sitemaps, floor plans...<br><br>Click the button below to create your first page.",
"rules.title": "No rules yet",
"rules.text": "Rules are the basic building blocks to automate your home - they define which actions to perform when certain events occur.<br><br>Create your first rule with the button below; for more advanced scenarios, you can also write script files in your configuration folder.",
"schedule.title": "Nothing in the schedule",
"schedule.text": "The schedule displays up to 30 days of times when rules specifically tagged \"Schedule\" are expected to run.<br><br>Click the button below to create your first scheduled rule.",
"schedule.text": "The schedule displays when rules specifically tagged \"Schedule\" are expected to run, up to 30 days.<br><br>Click the button below to create your first scheduled rule.",
"rules.missingengine.title": "Rule engine not installed",
"rules.missingengine.text": "The rule engine must be installed before rules can be created.",

View File

@ -0,0 +1,111 @@
@{%
const moo = require('moo')
let lexer = moo.compile({
WS: /[ \t]+/,
comment: /\/\/.*?$/,
number: /\-?[0-9]+(?:\.[0-9]*)?/,
string: { match: /"(?:\\["\\]|[^\n"\\])*"/, value: x => x.slice(1, -1) },
sitemap: 'sitemap ',
name: 'name=',
label: 'label=',
item: 'item=',
icon: 'icon=',
widgetattr: ['url=', 'refresh=', 'service=', 'refresh=', 'period=', 'legend=', 'height=', 'frequency=', 'sendFrequency=',
'switchEnabled=', 'mappings=', 'minValue=', 'maxValue=', 'step=', 'separator=', 'encoding='],
nlwidget: ['Switch ', 'Selection ', 'Slider ', 'List ', 'Setpoint ', 'Video ', 'Chart ', 'Webview ', 'Colorpicker ', 'Mapview ', 'Default '],
lwidget: ['Text', 'Group', 'Image', 'Frame'],
identifier: /[A-Za-z0-9_]+/,
lparen: '(',
rparen: ')',
colon: ':',
lbrace: '{',
rbrace: '}',
lbracket: '[',
rbracket: ']',
lt: '<',
gt: '>',
equals: '=',
comma: ',',
NL: { match: /\n/, lineBreaks: true },
})
function getSitemap(d) {
return {
"uid": d[2][0].text,
"component": "Sitemap",
"config": d[4],
"slots": {
"widgets": d[8]
}
}
}
function getWidget(d,l,r) {
let widget = {
component: d[0].text.trim(),
config: {}
}
if (d[2] && d[2][0]) {
for (let a of d[2][0]) {
widget.config[a[0].replace('=', '')] = a[1]
}
}
if (d[6]) {
widget.slots = {
widgets: d[6]
}
}
return widget
}
%}
@lexer lexer
Main -> _ Sitemap _ {% (d) => d[1] %}
Sitemap -> %sitemap _ SitemapName __ SitemapLabel __ %lbrace _ Widgets _ %rbrace {% getSitemap %}
SitemapName -> %identifier
SitemapLabel -> null {% (d) => { return {} } %}
| %label %string {% (d) => { return { "label": d[1].value } } %}
Widgets -> Widget {% (d) => [d[0]] %}
| Widgets _ Widget {% (d) => d[0].concat([d[2]]) %}
Widget -> %nlwidget _ WidgetAttrs:* {% getWidget %}
| %lwidget _ WidgetAttrs:* {% getWidget %}
| %lwidget _ WidgetAttrs:* __ %lbrace __ Widgets __ %rbrace {% getWidget %}
WidgetAttrs -> WidgetAttr {% (d) => [d[0]] %}
| WidgetAttrs _ WidgetAttr {% (d) => d[0].concat([d[2]]) %}
WidgetAttr -> WidgetAttrName WidgetAttrValue {% (d) => [d[0][0].value, d[1]] %}
WidgetAttrName -> %item | %label | %icon | %widgetattr
WidgetAttrValue -> %string {% (d) => d[0].value %}
| %identifier {% (d) => d[0].value %}
| %number {% (d) => { return parseFloat(d[0].value) } %}
| %lbracket _ Mappings _ %rbracket {% (d) => d[2] %}
Mappings -> Mapping {% (d) => [d[0]] %}
| Mappings _ %comma _ Mapping {% (d) => d[0].concat([d[4]]) %}
Mapping -> MappingCommand %equals MappingLabel {% (d) => d[0][0].value.toString() + '=' + d[2][0].value.toString() %}
MappingCommand -> %number | %identifier | %string
MappingLabel -> %number | %identifier | %string
_ -> null {% () => null %}
| _ %WS {% () => null %}
| _ %NL {% () => null %}
# | _ %comment {% () => null %}
__ -> %WS {% () => null %}
| %NL {% () => null %}
| %comment {% () => null %}
| __ %WS {% () => null %}
| __ %NL {% () => null %}
| __ %comment {% () => null %}
NL -> %NL {% () => null %}
| _ %NL {% () => null %}

View File

@ -28,11 +28,14 @@
<f7-list-item inset link="/settings/things/" title="Things" view=".view-main" panel-close :animate="false">
<f7-icon slot="media" f7="lightbulb" color="gray"></f7-icon>
</f7-list-item>
<f7-list-item inset link="/settings/model/" title="Model" view=".view-main" panel-close :animate="false">
<f7-icon slot="media" f7="list_bullet_indent" color="gray"></f7-icon>
</f7-list-item>
<f7-list-item inset link="/settings/items/" title="Items" view=".view-main" panel-close :animate="false">
<f7-icon slot="media" f7="square_on_circle" color="gray"></f7-icon>
</f7-list-item>
<f7-list-item inset link="/settings/model/" title="Model" view=".view-main" panel-close :animate="false">
<f7-icon slot="media" f7="list_bullet_indent" color="gray"></f7-icon>
<f7-list-item inset link="/settings/pages/" title="Pages" view=".view-main" panel-close :animate="false">
<f7-icon slot="media" f7="tv" color="gray"></f7-icon>
</f7-list-item>
<f7-list-item inset link="/settings/rules/" title="Rules" view=".view-main" panel-close :animate="false">
<f7-icon slot="media" f7="wand_rays" color="gray"></f7-icon>
@ -217,7 +220,7 @@ export default {
}
},
methods: {
loadSitemaps () {
loadSidebarPages () {
this.$oh.api.get('/rest/sitemaps').then((data) => {
this.sitemaps = data
})
@ -272,13 +275,17 @@ export default {
this.loggedIn = true
}
this.loadSitemaps()
this.loadSidebarPages()
this.$f7.on('pageBeforeIn', (page) => {
if (page.route && page.route.url) {
this.showAdministrationMenu = page.route.url.indexOf('/settings/') === 0
}
})
this.$f7.on('sidebarRefresh', () => {
this.loadSidebarPages()
})
})
}
}

View File

@ -0,0 +1,50 @@
function writeWidget (widget, indent) {
let dsl = ' '.repeat(indent)
dsl += widget.component
if (widget.config) {
for (let key in widget.config) {
if (!widget.config[key]) continue
dsl += ` ${key}=`
if (key === 'item' || Number.isFinite(widget.config[key])) {
dsl += widget.config[key]
} else if (key === 'mappings') {
dsl += '['
const mappingsDsl = widget.config.mappings.map((m) =>
`${m.split('=')[0]}="${m.substring(m.indexOf('=') + 1)}"`
)
dsl += mappingsDsl.join(',')
dsl += ']'
} else {
dsl += '"' + widget.config[key] + '"'
}
}
}
if (widget.slots) {
dsl += ' {\n'
widget.slots.widgets.forEach((w) => {
dsl += writeWidget(w, indent + 4)
})
dsl += ' '.repeat(indent) + '}'
}
dsl += '\n'
return dsl
}
export default {
toDsl (sitemap) {
let dsl = 'sitemap ' + sitemap.uid
if (sitemap.config && sitemap.config.label) {
dsl += ` label="${sitemap.config.label}"`
}
dsl += ' {\n'
if (sitemap.slots && sitemap.slots.widgets) {
sitemap.slots.widgets.forEach((w) => {
dsl += writeWidget(w, 4)
})
}
dsl += '}\n'
return dsl
}
}

View File

@ -0,0 +1,51 @@
<template>
<f7-card v-if="widget">
<f7-card-content v-if="mappings.length">
<f7-list inline-labels sortable @sortable:sort="onSort">
<f7-list-input v-for="(mapping, idx) in mappings" :key="idx"
:label="`#${idx+1}`" type="text" placeholder="command=Label" :value="mapping" @input="updateMapping(idx, $event)" clear-button>
</f7-list-input>
</f7-list>
</f7-card-content>
<f7-card-footer key="item-card-buttons-edit-mode" v-if="widget.component !== 'Sitemap'">
<f7-button color="blue" @click="addMapping">Add</f7-button>
</f7-card-footer>
</f7-card>
</template>
<script>
export default {
props: ['widget'],
computed: {
mappings () {
if (this.widget && this.widget.config && this.widget.config.mappings) {
return this.widget.config.mappings
}
return []
}
},
methods: {
updateMapping (idx, $event) {
const value = $event.target.value
if (!value) {
this.widget.config.mappings.splice(idx, 1)
} else {
this.$set(this.widget.config.mappings, idx, value)
}
},
addMapping () {
if (this.widget && this.widget.config && this.widget.config.mappings) {
this.widget.config.mappings.push('')
} else {
this.$set(this.widget.config, 'mappings', [''])
}
},
onSort (ev) {
const element = this.widget.config.mappings[ev.from]
this.widget.config.mappings.splice(ev.from, 1)
this.widget.config.mappings.splice(ev.to, 0, element)
}
}
}
</script>

View File

@ -0,0 +1,76 @@
<template>
<div class="row">
<div class="col">
<editor class="sitemap-parser" :value="sitemapDsl" @input="updateSitemap" />
<div v-if="parsedSitemap.error" class="sitemap-results error">
<pre><code class="text-color-red">{{parsedSitemap.error}}</code></pre>
</div>
<div v-else class="sitemap-results">
<pre><code class="text-color-teal">Your sitemap definition looks valid.</code></pre>
<pre><code>{{parsedSitemap}}</code></pre>
</div>
</div>
</div>
</template>
<style lang="stylus">
.sitemap-parser.vue-codemirror
display block
top calc(var(--f7-navbar-height) + var(--f7-tabbar-height))
height calc(50% - 2*var(--f7-navbar-height))
width 100%
.sitemap-results
position absolute
top 50%
height 50%
overflow-y auto
width 100%
&.error
pre
padding 0 1rem
pre
padding 0 1rem
</style>
<script>
import { Parser, Grammar } from 'nearley'
import grammar from '@/assets/sitemap-lexer.nearley'
import dslUtil from './dslUtil'
export default {
components: {
'editor': () => import('@/components/config/controls/script-editor.vue')
},
props: ['sitemap'],
data () {
return {
sitemapDsl: ''
}
},
created () {
this.sitemapDsl = dslUtil.toDsl(this.sitemap)
},
methods: {
updateSitemap (value) {
this.sitemapDsl = value
const parsed = this.parsedSitemap
if (!parsed.error) {
this.$emit('updated', parsed)
}
}
},
computed: {
parsedSitemap () {
try {
const parser = new Parser(Grammar.fromCompiled(grammar))
parser.feed(this.sitemapDsl.trim())
if (!parser.results.length) return { error: 'Unable to parse, check your input' }
// return parser.results[0].map((i) => i.name).join('\n')
return parser.results[0]
} catch (e) {
return { error: e }
}
}
}
}
</script>

View File

@ -0,0 +1,77 @@
<template>
<f7-treeview-item selectable :label="widget.config.label"
:icon-ios="icon('ios')" :icon-aurora="icon('aurora')" :icon-md="icon('md')"
:textColor="iconColor" :color="'blue'"
:selected="selected && selected === widget"
:opened="!widget.closed"
@click="select">
<sitemap-treeview-item v-for="(childwidget, idx) in children"
:key="idx"
:widget="childwidget" :parent-widget="widget"
@selected="(event) => $emit('selected', event)"
:selected="selected" />
<div slot="label" class="subtitle"> {{subtitle()}}</div>
</f7-treeview-item>
</template>
<script>
export default {
props: ['widget', 'parentWidget', 'selected'],
methods: {
icon (theme) {
switch (this.widget.component) {
case 'Switch':
return 'f7:power'
case 'Selection':
return 'f7:text_justify'
case 'Slider':
return 'f7:slider_horizontal_3'
case 'List':
return 'f7:square_list'
case 'Setpoint':
return 'f7:plus_slash_minus'
case 'Video':
return 'f7:videocam'
case 'Chart':
return 'f7:chart_bar_square'
case 'Webview':
return 'f7:globe'
case 'Colorpicker':
return 'f7:drop'
case 'Mapview':
return 'f7:map'
case 'Default':
return 'f7:rectangle'
case 'Text':
return 'f7:textformat'
case 'Group':
return 'f7:square_stack_3d_down_right'
case 'Image':
return 'f7:photo'
case 'Frame':
return 'f7:macwindow'
default:
return 'f7:slider_horizontal_below_rectangle'
}
},
subtitle () {
return this.widget.component + ((this.widget.config && this.widget.config.item) ? ': ' + this.widget.config.item : '')
},
select (event) {
var self = this
var $ = self.$$
if ($(event.target).is('.treeview-toggle')) return
this.$emit('selected', [this.widget, this.parentWidget])
}
},
computed: {
iconColor () {
return ''
},
children () {
if (!this.widget.slots || !this.widget.slots.widgets) return []
return this.widget.slots.widgets
}
}
}
</script>

View File

@ -0,0 +1,125 @@
<template>
<f7-card v-if="widget">
<f7-card-content>
<f7-list inline-labels>
<f7-list-input v-if="widget.component === 'Sitemap'" label="ID" type="text" placeholder="ID" :value="widget.uid" @input="widget.uid = $event.target.value"
required validate pattern="[A-Za-z0-9_]+" error-message="Required. Alphanumeric &amp; underscores only" :disabled="!createMode">
</f7-list-input>
<f7-list-input label="Label" type="text" placeholder="Label" :value="widget.config.label" @input="updateParameter('label', $event)" clear-button>
</f7-list-input>
<item-picker v-if="widget.component !== 'Sitemap'" title="Item" :value="widget.config.item" @input="(value) => widget.config.item = value"></item-picker>
<ul v-if="widget.component !== 'Sitemap'">
<f7-list-input ref="icon" label="Icon" autocomplete="off" type="text" placeholder="temperature, firstfloor..." :value="widget.config.icon"
@input="updateParameter('icon', $event)" clear-button>
<div slot="root-end" style="margin-left: calc(35% + 8px)">
<oh-icon :icon="widget.config.icon" height="32" width="32" />
</div>
</f7-list-input>
</ul>
<ul>
<!-- additional controls -->
<f7-list-input v-if="supports('url')" label="URL" type="text" :value="widget.config.url" @input="updateParameter('url', $event)" clear-button />
<f7-list-input v-if="supports('refresh')" label="Refresh interval" type="text" :value="widget.config.refresh" @input="updateParameter('url', $event)" clear-button />
<f7-list-input v-if="supports('encoding')" label="Encoding" type="text" :value="widget.config.encoding" @input="updateParameter('url', $event)" clear-button />
<f7-list-input v-if="supports('service')" label="Service" type="text" :value="widget.config.service" @input="updateParameter('service', $event)" clear-button />
<f7-list-input v-if="supports('period')" label="Period" type="text" :value="widget.config.period" @input="updateParameter('period', $event)" clear-button />
<f7-list-input v-if="supports('height')" label="Height" type="text" :value="widget.config.height" @input="updateParameter('height', $event)" clear-button />
<f7-list-input v-if="supports('sendFrequency')" label="Frequency" type="text" :value="widget.config.sendFrequency" @input="updateParameter('sendFrequency', $event)" clear-button />
<f7-list-input v-if="supports('frequency')" label="Frequency" type="text" :value="widget.config.frequency" @input="updateParameter('frequency', $event)" clear-button />
<f7-list-input v-if="supports('minValue')" label="Minimum" type="number" :value="widget.config.minValue" @input="updateParameter('minValue', $event)" clear-button />
<f7-list-input v-if="supports('maxValue')" label="Maximum" type="number" :value="widget.config.maxValue" @input="updateParameter('maxValue', $event)" clear-button />
<f7-list-input v-if="supports('step')" label="Step" type="number" :value="widget.config.step" @input="updateParameter('step', $event)" clear-button />
<f7-list-input v-if="supports('separator')" label="Separator" type="number" :value="widget.config.separator" @input="updateParameter('separator', $event)" clear-button />
<f7-list-item v-if="supports('switchEnabled')" title="Switch enabled">
<f7-toggle slot="after" :checked="widget.config.switchEnabled" @toggle:change="widget.config.switchEnabled = $event"></f7-toggle>
</f7-list-item>
</ul>
</f7-list>
</f7-card-content>
<f7-card-footer key="sitemap-widget-buttons-edit-mode" v-if="widget.component !== 'Sitemap'">
<!-- <f7-button v-if="!editMode && !createMode" color="blue" @click="editMode = true" icon-ios="material:expand_more" icon-md="material:expand_more" icon-aurora="material:expand_more">Edit</f7-button> -->
<f7-segmented>
<f7-button color="blue" @click="$emit('moveup', widget)" icon-f7="chevron_up"></f7-button>
<f7-button color="blue" @click="$emit('movedown', widget)" icon-f7="chevron_down"></f7-button>
</f7-segmented>
<f7-button v-if="widget.component !== 'Sitemap'" color="red" @click="$emit('remove', widget)">Remove</f7-button>
</f7-card-footer>
</f7-card>
</template>
<style>
</style>
<script>
import { Categories } from '@/assets/categories.js'
import ItemPicker from '@/components/config/controls/item-picker.vue'
export default {
components: {
ItemPicker
},
props: ['widget', 'createMode'],
data () {
return {
iconInputId: '',
iconAutocomplete: null,
additionalControls: {
Image: ['url', 'refresh'],
Video: ['url', 'encoding'],
Chart: ['service', 'period', 'refresh', 'legend'],
Webview: ['url', 'height'],
Mapview: ['height'],
Slider: ['sendFrequency', 'switchEnabled', 'minValue', 'maxValue', 'step'],
List: ['separator'],
Setpoint: ['minValue', 'maxValue', 'step'],
Colorpicker: ['frequency'],
Default: ['height']
}
}
},
methods: {
initializeAutocomplete (inputElement) {
this.iconAutocomplete = this.$f7.autocomplete.create({
inputEl: inputElement,
openIn: 'dropdown',
source (query, render) {
if (!query || !query.length) {
render([])
} else {
render(Categories.filter((c) => c.toLowerCase().indexOf(query.toLowerCase()) >= 0))
}
}
})
},
supports (parameter) {
if (!this.additionalControls[this.widget.component]) return false
return (this.additionalControls[this.widget.component].indexOf(parameter) >= 0)
},
updateParameter (parameter, $event) {
let value = $event.target.value
if (value && !isNaN(value)) {
value = parseFloat(value)
}
this.$set(this.widget.config, parameter, value)
},
remove () {
this.$emit('remove')
}
},
mounted () {
if (!this.widget) return
if (!this.widget.config.icon) this.$set(this.widget.config, 'icon', '')
const iconControl = this.$refs.icon
if (!iconControl || !iconControl.$el) return
const inputElement = this.$$(iconControl.$el).find('input')
this.initializeAutocomplete(inputElement)
},
beforeDestroy () {
if (this.iconControl) {
this.$f7.autocomplete.destroy(this.iconControl)
}
}
}
</script>

View File

@ -3,7 +3,8 @@ import Vue from 'vue'
import SitemapWidgetGeneric from '../components/sitemap/widget-generic.vue'
import OHIconComponent from '../components/oh-icon.vue'
import TreeviewItem from '../components/model/treeview-item.vue'
import ModelTreeviewItem from '../components/model/treeview-item.vue'
import SitemapTreeviewItem from '../components/pagedesigner/sitemap/treeview-item.vue'
import EmptyStatePlaceholder from '../components/empty-state-placeholder.vue'
// Import Framework7
@ -47,5 +48,6 @@ const app = new Vue({
Vue.component('sitemap-widget-generic', SitemapWidgetGeneric)
Vue.component('oh-icon', OHIconComponent)
Vue.component('model-treeview-item', TreeviewItem)
Vue.component('model-treeview-item', ModelTreeviewItem)
Vue.component('sitemap-treeview-item', SitemapTreeviewItem)
Vue.component('empty-state-placeholder', EmptyStatePlaceholder)

View File

@ -31,6 +31,9 @@ import RulesListPage from '../pages/settings/rules/rules-list.vue'
import RuleEditPage from '../pages/settings/rules/rule-edit.vue'
import RuleConfigureModulePage from '../pages/settings/rules/rule-configure-module.vue'
import PagesListPage from '../pages/settings/pages/pages-list.vue'
import SitemapEditPage from '../pages/settings/pages/sitemap/sitemap-edit.vue'
// import SchedulePage from '../pages/settings/schedule/schedule.vue'
import Analyzer from '../pages/analyzer/analyzer.vue'
@ -135,22 +138,25 @@ export default [
}
]
},
// {
// path: 'items-virtual',
// component: ItemsVirtualListPage,
// routes: [
// {
// path: ':itemName',
// component: ItemDetailsPage,
// routes: [
// {
// path: 'edit',
// component: ItemEditPage
// }
// ]
// }
// ]
// },
{
path: 'pages',
component: PagesListPage,
routes: [
{
path: 'sitemap/add',
component: SitemapEditPage,
options: {
props: {
createMode: true
}
}
},
{
path: 'sitemap/:uid',
component: SitemapEditPage
}
]
},
{
path: 'things/',
component: ThingsListPage,

View File

@ -0,0 +1,211 @@
<template>
<f7-page @page:afterin="onPageAfterIn" @page:afterout="onPageAfterOut">
<f7-navbar title="Pages" back-link="Settings" back-link-url="/settings/" back-link-force>
<f7-nav-right>
<f7-link icon-md="material:done_all" @click="toggleCheck()"
:text="(!$theme.md) ? ((showCheckboxes) ? 'Done' : 'Select') : ''"></f7-link>
</f7-nav-right>
<f7-subnavbar :inner="false" v-show="initSearchbar">
<f7-searchbar
v-if="initSearchbar"
class="searchbar-pages"
:init="initSearchbar"
search-container=".pages-list"
search-item=".pagelist-item"
search-in=".item-title, .item-subtitle, .item-header, .item-footer"
remove-diacritics
:disable-button="!$theme.aurora"
></f7-searchbar>
</f7-subnavbar>
</f7-navbar>
<f7-toolbar class="contextual-toolbar" :class="{ 'navbar': $theme.md }" v-if="showCheckboxes" bottom-ios bottom-aurora>
<f7-link color="red" v-show="selectedItems.length" v-if="!$theme.md" class="delete" icon-ios="f7:trash" icon-aurora="f7:trash" @click="removeSelected">Remove {{selectedItems.length}}</f7-link>
<f7-link v-if="$theme.md" icon-md="material:close" icon-color="white" @click="showCheckboxes = false"></f7-link>
<div class="title" v-if="$theme.md">
{{selectedItems.length}} selected
</div>
<div class="right" v-if="$theme.md">
<f7-link icon-md="material:delete" icon-color="white" @click="removeSelected"></f7-link>
<f7-link icon-md="material:more_vert" icon-color="white" @click="removeSelected"></f7-link>
</div>
</f7-toolbar>
<f7-list class="searchbar-not-found">
<f7-list-item title="Nothing found"></f7-list-item>
</f7-list>
<!-- skeleton for not ready -->
<f7-block class="block-narrow">
<f7-col v-show="!ready">
<f7-block-title>&nbsp;Loading...</f7-block-title>
<f7-list media-list class="col wide">
<f7-list-group>
<f7-list-item
media-item
v-for="n in 20"
:key="n"
:class="`skeleton-text skeleton-effect-blink`"
title="Title of the page"
subtitle="Type of the page"
after="status badge"
>
</f7-list-item>
</f7-list-group>
</f7-list>
</f7-col>
<f7-col v-if="ready">
<f7-block-title class="searchbar-hide-on-search">{{pages.length}} pages</f7-block-title>
<f7-list
v-show="pages.length > 0"
class="searchbar-found col pages-list"
ref="pagesList"
media-list>
<f7-list-item
v-for="(page, index) in pages"
:key="index"
media-item
class="pagelist-item"
:checkbox="showCheckboxes"
:checked="isChecked(page.uid)"
@change="(e) => toggleItemCheck(e, page.uid, page)"
:link="showCheckboxes ? null : page.component.toLowerCase() + '/' + page.uid"
:title="page.config.label"
:subtitle="page.component"
>
<div slot="subtitle">
<f7-chip v-for="tag in page.tags" :key="tag" :text="tag" media-bg-color="blue" style="margin-right: 6px">
<f7-icon slot="media" ios="f7:tag_fill" md="material:label" aurora="f7:tag_fill" ></f7-icon>
</f7-chip>
</div>
<!-- <span slot="media" class="item-initial">{{page.config.label[0].toUpperCase()}}</span> -->
<f7-icon slot="media" color="gray" :f7="icon(page)" :size="32"></f7-icon>
</f7-list-item>
</f7-list>
</f7-col>
</f7-block>
<f7-block v-if="ready && !pages.length" class="service-config block-narrow">
<empty-state-placeholder icon="tv" title="pages.title" text="pages.text" />
</f7-block>
<f7-fab v-show="ready && !showCheckboxes" position="right-bottom" slot="fixed" color="blue">
<f7-icon ios="f7:plus" md="material:add" aurora="f7:plus"></f7-icon>
<f7-icon ios="f7:multiply" md="material:close" aurora="f7:multiply"></f7-icon>
<f7-fab-buttons position="top">
<f7-fab-button fab-close label="Create sitemap" href="sitemap/add"><f7-icon f7="menu"></f7-icon></f7-fab-button>
<!-- <f7-fab-button fab-close label="Create layout" href="add"><f7-icon f7="rectangle_grid_2x2"></f7-icon></f7-fab-button>
<f7-fab-button fab-close label="Create map view" href="add"><f7-icon f7="map"></f7-icon></f7-fab-button>
<f7-fab-button fab-close label="Create chart" href="add"><f7-icon f7="graph_square"></f7-icon></f7-fab-button>
<f7-fab-button fab-close label="Create floor plan" href="add"><f7-icon f7="layers"></f7-icon></f7-fab-button> -->
</f7-fab-buttons>
</f7-fab>
</f7-page>
</template>
<script>
export default {
data () {
return {
ready: false,
loading: false,
pages: [],
initSearchbar: false,
selectedItems: [],
showCheckboxes: false
}
},
created () {
},
methods: {
onPageAfterIn () {
this.load()
},
onPageAfterOut () {
},
icon (page) {
switch (page.component) {
case 'Sitemap':
return 'menu'
default:
return 'tv'
}
},
load () {
if (this.loading) return
this.loading = true
var promises = [
this.$oh.api.get('/rest/ui/components/system:sitemap'),
this.$oh.api.get('/rest/ui/components/ui:page')
]
Promise.all(promises).then(data => {
const pagesAndSitemaps = data[0].concat(data[1])
this.pages = pagesAndSitemaps.sort((a, b) => {
return a.config.label.localeCompare(b.config.label)
})
this.loading = false
this.ready = true
setTimeout(() => { this.initSearchbar = true })
})
},
toggleCheck () {
this.showCheckboxes = !this.showCheckboxes
},
isChecked (item) {
return this.selectedItems.indexOf(item) >= 0
},
toggleItemCheck (event, itemName, item) {
console.log('toggle check')
itemName = (item.component === 'Sitemap') ? 'system:sitemap:' + itemName : 'ui:page:' + itemName
if (this.isChecked(itemName)) {
this.selectedItems.splice(this.selectedItems.indexOf(itemName), 1)
} else {
this.selectedItems.push(itemName)
}
},
removeSelected () {
const vm = this
this.$f7.dialog.confirm(
`Remove ${this.selectedItems.length} selected pages?`,
'Remove Pages',
() => {
vm.doRemoveSelected()
}
)
},
doRemoveSelected () {
let dialog = this.$f7.dialog.progress('Deleting Pages...')
const promises = this.selectedItems.map((p) => {
if (p.startsWith('system:sitemap')) {
return this.$oh.api.delete('/rest/ui/components/system:sitemap/' + p.replace('system:sitemap:', ''))
} else {
return this.$oh.api.delete('/rest/ui/components/ui:page/' + p.replace('ui:page:', ''))
}
})
Promise.all(promises).then((data) => {
this.$f7.toast.create({
text: 'Pages removed',
destroyOnClose: true,
closeTimeout: 2000
}).open()
this.selectedItems = []
dialog.close()
this.load()
this.$f7.emit('sidebarRefresh', null)
}).catch((err) => {
dialog.close()
this.load()
console.error(err)
this.$f7.dialog.alert('An error occurred while deleting: ' + err)
this.$f7.emit('sidebarRefresh', null)
})
}
},
asyncComputed: {
iconUrl () {
return icon => this.$oh.media.getIcon(icon)
}
}
}
</script>

View File

@ -0,0 +1,378 @@
<template>
<f7-page @page:afterin="onPageAfterIn" @page:beforeout="onPageBeforeOut">
<f7-navbar :title="(!ready) ? '' : (createMode) ? 'Create sitemap' : 'Sitemap: ' + sitemap.config.label" back-link="Back" no-hairline>
<f7-nav-right>
<f7-link @click="save()" v-if="$theme.md" icon-md="material:save" icon-only></f7-link>
<f7-link @click="save()" v-if="!$theme.md">Save</f7-link>
</f7-nav-right>
</f7-navbar>
<f7-toolbar tabbar position="top">
<f7-link @click="currentTab = 'tree'" :tab-link-active="currentTab === 'tree'" class="tab-link">Design</f7-link>
<f7-link @click="currentTab = 'code'" :tab-link-active="currentTab === 'code'" class="tab-link">Code</f7-link>
</f7-toolbar>
<f7-toolbar bottom class="toolbar-details" v-show="currentTab === 'tree'">
<f7-link class="left details-link" @click="detailsOpened = true">Details</f7-link>
<f7-link :disabled="selectedWidget != null" class="right" @click="selectedWidget = null">Clear</f7-link>
</f7-toolbar>
<f7-tabs class="sitemap-editor-tabs">
<f7-tab id="tree" @tab:show="() => this.currentTab = 'tree'" :tab-active="currentTab === 'tree'">
<f7-block v-if="!ready" class="text-align-center">
<f7-preloader></f7-preloader>
<div>Loading...</div>
</f7-block>
<f7-block v-else class="sitemap-tree-wrapper" :class="{ 'sheet-opened' : detailsOpened }">
<f7-row v-if="currentTab === 'tree'">
<f7-col width="100" medium="50">
<f7-block strong class="sitemap-tree" no-gap @click.native="clearSelection">
<f7-treeview>
<sitemap-treeview-item :widget="sitemap" @selected="selectWidget" :selected="selectedWidget">
</sitemap-treeview-item>
</f7-treeview>
</f7-block>
</f7-col>
<f7-col width="100" medium="50" class="details-pane">
<f7-block v-if="selectedWidget" no-gap>
<widget-details :widget="selectedWidget" :createMode="createMode" @remove="removeWidget" @movedown="moveWidgetDown" @moveup="moveWidgetUp" />
</f7-block>
<f7-block v-else>
<div class="padding text-align-center">Nothing selected</div>
</f7-block>
<f7-block v-if="selectedWidget && ['Switch', 'Selection'].indexOf(selectedWidget.component) >= 0">
<div><f7-block-title>Mappings</f7-block-title></div>
<mapping-details :widget="selectedWidget" />
</f7-block>
<f7-block v-if="selectedWidget && canAddChildren">
<div><f7-block-title>Add Child Widget</f7-block-title></div>
<f7-card>
<f7-card-content>
<f7-list>
<f7-list-button color="blue" :title="`Insert Widget Inside ${selectedWidget.component}`" actions-open="#widget-type-selection"></f7-list-button>
</f7-list>
</f7-card-content>
</f7-card>
</f7-block>
</f7-col>
</f7-row>
</f7-block>
<f7-actions ref="widgetTypeSelection" id="widget-type-selection" :grid="true">
<f7-actions-group>
<f7-actions-button v-for="widgetType in widgetTypes" :key="widgetType.type" @click="addWidget(widgetType.type)">
<f7-icon :f7="widgetType.icon" slot="media" />
<span>{{widgetType.type}}</span>
</f7-actions-button>
</f7-actions-group>
</f7-actions>
</f7-tab>
<f7-tab id="code" @tab:show="() => { this.currentTab = 'code' }" :tab-active="currentTab === 'code'">
<sitemap-code v-if="currentTab === 'code'" :sitemap="sitemap" @updated="(value) => update(value)" />
</f7-tab>
</f7-tabs>
<f7-fab class="add-to-sitemap-fab" v-if="canAddChildren" position="right-bottom" slot="fixed" color="blue" @click="$refs.widgetTypeSelection.open()">
<f7-icon ios="f7:plus" md="material:add" aurora="f7:plus"></f7-icon>
<f7-icon ios="f7:multiply" md="material:close" aurora="f7:multiply"></f7-icon>
</f7-fab>
<f7-sheet class="sitemap-details-sheet" :backdrop="false" :close-on-escape="true" :opened="detailsOpened" @sheet:closed="detailsOpened = false">
<f7-page>
<f7-toolbar tabbar>
<f7-link class="padding-left padding-right" :tab-link-active="detailsTab === 'item'" @click="detailsTab = 'widget'">Widget</f7-link>
<f7-link class="padding-left padding-right" :tab-link-active="detailsTab === 'mappings'" @click="detailsTab = 'mappings'">Mappings</f7-link>
<div class="right">
<f7-link sheet-close class="padding-right"><f7-icon f7="chevron_down"></f7-icon></f7-link>
</div>
</f7-toolbar>
<f7-block style="margin-bottom: 6rem" v-if="selectedWidget && detailsTab === 'widget'">
<widget-details :widget="selectedWidget" :createMode="createMode" @remove="removeWidget" @movedown="moveWidgetDown" @moveup="moveWidgetUp" />
</f7-block>
<f7-block style="margin-bottom: 6rem" v-if="selectedWidget && detailsTab === 'mappings' && ['Switch', 'Selection'].indexOf(selectedWidget.component) >= 0">
<mapping-details :widget="selectedWidget" />
</f7-block>
</f7-page>
</f7-sheet>
</f7-page>
</template>
<style lang="stylus">
.sitemap-editor-tabs
--f7-grid-gap 0px
height calc(100% - var(--f7-toolbar-height))
.tab
height 100%
.sitemap-tree-wrapper
padding 0
margin-bottom 0
.sitemap-tree
padding 0
border-right 1px solid var(--f7-block-strong-border-color)
.treeview
--f7-treeview-item-height 40px
.treeview-item-label
font-size 10pt
white-space nowrap
overflow-x hidden
.subtitle
font-size 8pt
color var(--f7-list-item-footer-text-color)
.sitemap-details-sheet
z-index 10900
.md .sitemap-details-sheet .toolbar .link
width 35%
@media (min-width: 768px)
.sitemap-tree-wrapper
height 100%
.row
height 100%
.col-100
height 100%
overflow auto
.sitemap-tree
min-height 100%
margin 0
height auto
.details-pane
padding-top 0
.block
margin-top 0
.toolbar-details
.details-link
visibility hidden !important
.add-to-sitemap-fab
visibility hidden !important
@media (max-width: 767px)
.details-pane
display none
.sitemap-tree-wrapper.sheet-opened
margin-bottom var(--f7-sheet-height)
.details-sheet
height calc(1.4*var(--f7-sheet-height))
</style>
<script>
import SitemapCode from '@/components/pagedesigner/sitemap/sitemap-code.vue'
import WidgetDetails from '@/components/pagedesigner/sitemap/widget-details.vue'
import MappingDetails from '@/components/pagedesigner/sitemap/mapping-details.vue'
function uuidv4 () {
return ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, c =>
(c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16)
)
}
export default {
components: {
SitemapCode,
WidgetDetails,
MappingDetails
},
props: ['createMode', 'uid'],
data () {
return {
ready: false,
loading: false,
sitemap: {
uid: 'page_' + uuidv4().split('-')[0],
component: 'Sitemap',
config: {
label: 'New Sitemap'
},
tags: [],
slots: { widgets: [] }
},
selectedWidget: null,
selectedWidgetParent: null,
previousSelection: null,
detailsOpened: false,
detailsTab: 'widget',
currentTab: 'tree',
eventSource: null,
widgetTypes: [
{ type: 'Text', icon: 'textformat' },
{ type: 'Switch', icon: 'power' },
{ type: 'Selection', icon: 'text_justify' },
{ type: 'Slider', icon: 'slider_horizontal_3' },
{ type: 'Frame', icon: 'macwindow' },
{ type: 'Setpoint', icon: 'plus_slash_minus' },
{ type: 'Default', icon: 'rectangle' },
{ type: 'Group', icon: 'square_stack_3d_down_right' },
{ type: 'Chart', icon: 'chart_bar_square' },
{ type: 'Webview', icon: 'globe' },
{ type: 'Colorpicker', icon: 'drop' },
{ type: 'Mapview', icon: 'map' },
{ type: 'List', icon: 'square_list' },
{ type: 'Image', icon: 'photo' },
{ type: 'Video', icon: 'videocam' }
],
linkableWidgetTypes: ['Sitemap', 'Text', 'Frame', 'Group', 'Image']
}
},
created () {
},
computed: {
canAddChildren () {
if (!this.selectedWidget) return false
if (this.linkableWidgetTypes.indexOf(this.selectedWidget.component) < 0) return false
return true
}
},
methods: {
onPageAfterIn () {
if (window) {
window.addEventListener('keydown', this.keyDown)
}
this.load()
},
onPageBeforeOut () {
if (window) {
window.removeEventListener('keydown', this.keyDown)
}
this.detailsOpened = false
},
keyDown (ev) {
if (ev.keyCode === 83 && (ev.ctrlKey || ev.metaKey)) {
if (this.createMode) return // not supported!
this.save(true)
ev.stopPropagation()
ev.preventDefault()
}
},
load () {
if (this.loading) return
this.loading = true
if (this.createMode) {
this.loading = false
this.ready = true
} else {
this.$oh.api.get('/rest/ui/components/system:sitemap/' + this.uid).then((data) => {
this.$set(this, 'sitemap', data)
this.ready = true
this.loading = false
})
}
},
save (stay) {
if (!this.sitemap.uid) {
this.$f7.dialog.alert('Please give an ID to the sitemap')
return
}
if (!this.sitemap.config.label) {
this.$f7.dialog.alert('Please give an label to the sitemap')
return
}
if (!this.createMode && this.uid !== this.sitemap.uid) {
this.$f7.dialog.alert('You cannot change the ID of an existing sitemap. Duplicate it with the new ID then delete this one.')
return
}
const promise = (this.createMode)
? this.$oh.api.postPlain('/rest/ui/components/system:sitemap', JSON.stringify(this.sitemap), 'text/plain', 'application/json')
: this.$oh.api.put('/rest/ui/components/system:sitemap/' + this.sitemap.uid, this.sitemap)
promise.then((data) => {
if (this.createMode) {
this.$f7.toast.create({
text: 'Sitemap created',
destroyOnClose: true,
closeTimeout: 2000
}).open()
this.load()
} else {
this.$f7.toast.create({
text: 'Sitemap updated',
destroyOnClose: true,
closeTimeout: 2000
}).open()
}
this.$f7.emit('sidebarRefresh', null)
if (!stay) this.$f7router.back()
}).catch((err) => {
this.$f7.toast.create({
text: 'Error while saving sitemap: ' + err,
destroyOnClose: true,
closeTimeout: 2000
}).open()
})
},
cleanConfig (widget) {
if (widget.config) {
for (let key in widget.config) {
if (!widget.config[key]) {
delete widget.config[key]
}
}
}
if (widget.slots && widget.slots.widgets) {
widget.slots.widgets.forEach(this.cleanConfig)
}
},
update (value) {
this.selectedWidget = null
this.selectedWidgetParent = null
this.$set(this, 'sitemap', value)
this.cleanConfig(this.sitemap)
},
startEventSource () {
},
stopEventSource () {
},
removeWidget () {
this.selectedWidgetParent.slots.widgets.splice(this.selectedWidgetParent.slots.widgets.indexOf(this.selectedWidget), 1)
if (!this.selectedWidgetParent.slots.widgets.length) {
delete this.selectedWidgetParent.slots
}
this.selectedWidget = null
this.selectedWidgetParent = null
},
moveWidgetDown () {
let widgets = this.selectedWidgetParent.slots.widgets
let pos = widgets.indexOf(this.selectedWidget)
if (pos >= widgets.length - 1) return
widgets.splice(pos, 1)
widgets.splice(pos + 1, 0, this.selectedWidget)
},
moveWidgetUp () {
let widgets = this.selectedWidgetParent.slots.widgets
let pos = widgets.indexOf(this.selectedWidget)
if (pos <= 0) return
widgets.splice(pos, 1)
widgets.splice(pos - 1, 0, this.selectedWidget)
},
selectWidget (widgets) {
const widget = widgets[0]
const parentWidget = widgets[1]
this.selectedWidget = null
this.selectedWidgetParent = null
this.$nextTick(() => {
this.selectedWidget = widget
this.selectedWidgetParent = parentWidget
})
},
clearSelection (ev) {
if (ev.target && ev.currentTarget && ev.target === ev.currentTarget) {
this.selectedWidget = null
this.selectedWidgetParent = null
}
},
addWidget (widgetType) {
if (!this.selectedWidget.slots) {
this.$set(this.selectedWidget, 'slots', { widgets: [] })
}
const widget = {
component: widgetType,
config: {}
}
this.selectedWidget.slots.widgets.push(widget)
this.selectWidget([widget, this.selectedWidget])
}
}
}
</script>

View File

@ -57,7 +57,7 @@
</f7-list>
</f7-col>
<f7-col v-if="ready">
<f7-block-title v-show="rules.length" class="searchbar-hide-on-search">{{rules.length}} rules</f7-block-title>
<f7-block-title class="searchbar-hide-on-search">{{rules.length}} rules</f7-block-title>
<f7-list
v-show="rules.length > 0"
class="searchbar-found col rules-list"

View File

@ -33,6 +33,14 @@
:footer="objectsSubtitles.things">
<f7-icon slot="media" f7="lightbulb" color="gray"></f7-icon>
</f7-list-item>
<f7-list-item
media-item
link="model/"
title="Model"
badge-color="blue"
:footer="objectsSubtitles.model">
<f7-icon slot="media" f7="list_bullet_indent" color="gray"></f7-icon>
</f7-list-item>
<f7-list-item
media-item
link="items/"
@ -43,20 +51,12 @@
<f7-icon slot="media" f7="square_on_circle" color="gray"></f7-icon>
</f7-list-item>
<f7-list-item
media-item
link="model/"
title="Model"
link="pages/"
title="Pages"
badge-color="blue"
:footer="objectsSubtitles.model">
<f7-icon slot="media" f7="list_bullet_indent" color="gray"></f7-icon>
:footer="objectsSubtitles.pages">
<f7-icon slot="media" f7="tv" color="gray"></f7-icon>
</f7-list-item>
<!-- <f7-list-item
link="items-virtual/"
title="Items (virtual)"
:after="itemsCount"
badge-color="blue"
:footer="objectsSubtitles.items"
></f7-list-item> -->
<f7-list-item
media-item
link="rules/"
@ -123,8 +123,9 @@ export default {
otherServices: [],
objectsSubtitles: {
things: 'Manage the physical layer',
items: 'Manage the functional layer',
model: 'The semantic model of your home',
items: 'Manage the functional layer',
pages: 'Design displays for user control & monitoring',
rules: 'Automate with triggers and actions',
schedule: 'View upcoming time-based rules'
},

View File

@ -7,6 +7,9 @@
<f7-nav-title-large v-if="isRoot">{{sitemap.title}}</f7-nav-title-large>
<f7-nav-title>{{sitemap.title}}</f7-nav-title>
</f7-navbar>
<f7-toolbar position="bottom">
<span class="text-color-red">Warning: sitemaps are not functional. Please use Basic UI.</span>
</f7-toolbar>
<f7-block class="block-narrow" v-if="sitemap.widgets && sitemap.widgets.length > 0">
<f7-row>
<f7-col>
@ -30,12 +33,8 @@
</template>
<script>
// import SitemapWidgetGeneric from '../components/sitemap/widget-generic.vue'
export default {
// components: {
// SitemapWidgetGeneric
// },
props: ['sitemapId', 'pageId'],
data () {
return {
@ -46,11 +45,6 @@ export default {
this.$oh.api.get('/rest/sitemaps/' + this.sitemapId + '/' + this.pageId).then(data => {
this.sitemap = data
})
this.$f7.toast.create({
text: 'The sitemap rendering is currently for demonstration purposes only. It is not functional nor updates in real time. Please use another app like Basic UI or HABPanel to interact with your items.',
closeButton: true,
destroyOnClose: true
}).open()
},
computed: {
isRoot () {