diff --git a/platypush/backend/http/webapp/src/Utils.vue b/platypush/backend/http/webapp/src/Utils.vue index bafc3aa87..17069fbee 100644 --- a/platypush/backend/http/webapp/src/Utils.vue +++ b/platypush/backend/http/webapp/src/Utils.vue @@ -4,7 +4,7 @@ import Clipboard from "@/utils/Clipboard"; import Cookies from "@/utils/Cookies"; import DateTime from "@/utils/DateTime"; import Events from "@/utils/Events"; -import Integrations from "@/utils/Integrations"; +import Extensions from "@/utils/Extensions"; import Notification from "@/utils/Notification"; import Screen from "@/utils/Screen"; import Text from "@/utils/Text"; @@ -19,7 +19,7 @@ export default { DateTime, Events, Notification, - Integrations, + Extensions, Screen, Text, Types, diff --git a/platypush/backend/http/webapp/src/assets/icons.json b/platypush/backend/http/webapp/src/assets/icons.json index a0594746c..6b7d050ca 100644 --- a/platypush/backend/http/webapp/src/assets/icons.json +++ b/platypush/backend/http/webapp/src/assets/icons.json @@ -29,6 +29,9 @@ "execute": { "class": "fa fa-play" }, + "extensions": { + "class": "fas fa-puzzle-piece" + }, "light.hue": { "class": "fas fa-lightbulb" }, diff --git a/platypush/backend/http/webapp/src/components/Nav.vue b/platypush/backend/http/webapp/src/components/Nav.vue index 74ffa87f3..48d9447f8 100644 --- a/platypush/backend/http/webapp/src/components/Nav.vue +++ b/platypush/backend/http/webapp/src/components/Nav.vue @@ -101,10 +101,11 @@ export default { return names } - let panelNames = Object.keys(this.panels) + let panelNames = Object.keys(this.panels).sort() + panelNames = prepend(panelNames, 'extensions') panelNames = prepend(panelNames, 'execute') panelNames = prepend(panelNames, 'entities') - return panelNames.sort() + return panelNames }, collapsedDefault() { @@ -125,6 +126,8 @@ export default { return 'Home' if (name === 'execute') return 'Execute' + if (name === 'extensions') + return 'Extensions' return name }, @@ -252,12 +255,12 @@ nav { } .plugins { - height: calc(100% - #{$toggler-height} - #{$footer-expanded-height} - 1em); + height: calc(100% - #{$toggler-height} - #{$footer-expanded-height} - 1.5em); overflow: auto; } .footer { - height: $footer-expanded-height; + height: calc($footer-expanded-height + 0.4em); background: $nav-footer-bg; padding: 0; margin: 0; diff --git a/platypush/backend/http/webapp/src/components/panels/Extensions/Doc.vue b/platypush/backend/http/webapp/src/components/panels/Extensions/Doc.vue new file mode 100644 index 000000000..dc8524930 --- /dev/null +++ b/platypush/backend/http/webapp/src/components/panels/Extensions/Doc.vue @@ -0,0 +1,118 @@ +<template> + <section class="doc"> + <header> + <h2> + <a class="title" :href="extension.doc_url" target="_blank"> + <i class="icon fas fa-book" /> + {{ extension.name }} + </a> + </h2> + </header> + + <article v-html="doc" v-if="doc" @click="onDocClick" /> + </section> +</template> + +<script> +import Utils from "@/Utils" +import { bus } from "@/bus"; + +export default { + name: "Doc", + mixins: [Utils], + props: { + extension: { + type: Object, + required: true, + }, + }, + + data() { + return { + doc: null, + } + }, + + methods: { + async parseDoc() { + if (!this.extension.doc?.length) + return null + + return await this.request( + 'utils.rst_to_html', + {text: this.extension.doc} + ) + }, + + refreshDoc() { + this.parseDoc().then(doc => this.doc = doc) + }, + + // Intercept links to the documentation and replace them with + // in-app connections, or opens them in a new tab if they + // don't point to an internal documentation page. + onDocClick(event) { + if (!event.target.tagName.toLowerCase() === 'a') + return + + event.preventDefault() + const href = event.target.getAttribute('href') + if (!href) + return + + const match = href.match(/^https:\/\/docs\.platypush\.tech\/platypush\/(plugins|backend)\/([\w.]+)\.html#?.*$/) + if (!match) { + event.preventDefault() + window.open(href, '_blank') + return + } + + let [_, type, name] = match + if (type === 'backend') + name = `backend.${name}` + + bus.emit('update:extension', name) + event.preventDefault() + }, + }, + + mounted() { + this.refreshDoc() + this.$watch('extension.doc', this.refreshDoc) + }, +} +</script> + +<style lang="scss" scoped> +$header-height: 3em; + +section { + height: 100%; + + header { + height: $header-height; + padding: 0.5em; + border-bottom: 1px solid $border-color-2; + + h2 { + margin: 0; + padding: 0; + font-size: 1.25em; + } + } + + article { + height: calc(100% - #{$header-height}); + padding: 0.5em; + overflow: auto; + + :deep(ul) { + margin-left: 1em; + + li { + list-style: disc; + } + } + } +} +</style> diff --git a/platypush/backend/http/webapp/src/components/panels/Extensions/Extension.vue b/platypush/backend/http/webapp/src/components/panels/Extensions/Extension.vue new file mode 100644 index 000000000..8a560edb1 --- /dev/null +++ b/platypush/backend/http/webapp/src/components/panels/Extensions/Extension.vue @@ -0,0 +1,91 @@ +<template> + <div class="extension"> + <header> + <Tabs> + <Tab :selected="selectedTab === 'doc'" icon-class="fas fa-book" + @input="selectedTab = 'doc'"> + <span class="from tablet">Documentation</span> + </Tab> + + <Tab :selected="selectedTab === 'install'" icon-class="fas fa-download" + @input="selectedTab = 'install'"> + <span class="from tablet">Install</span> + </Tab> + + <Tab :selected="selectedTab === 'conf'" icon-class="fas fa-square-check" + @input="selectedTab = 'conf'"> + <span class="from tablet">Configuration</span> + </Tab> + + <Tab :selected="selectedTab === 'actions'" icon-class="fas fa-play" + @input="selectedTab = 'actions'"> + <span class="from tablet">Actions</span> + </Tab> + </Tabs> + </header> + + <div class="extension-body"> + <Doc v-if="selectedTab === 'doc'" :extension="extension" /> + </div> + </div> +</template> + +<script> +import Tab from "@/components/elements/Tab" +import Tabs from "@/components/elements/Tabs" +import Doc from "./Doc" + +export default { + name: "Extension", + components: { + Doc, + Tab, + Tabs, + }, + props: { + extension: { + type: Object, + required: true, + }, + }, + + data() { + return { + selectedTab: 'doc', + } + }, +} +</script> + +<style lang="scss" scoped> +@import "src/style/items"; + +$header-height: 4em; + +.extension { + width: 100%; + height: 100%; + background: $background-color; + display: flex; + flex-direction: column; + border-top: 1px solid $border-color-1; + box-shadow: $border-shadow-bottom; + + header { + height: $header-height; + + :deep(.tabs) { + margin: 0; + } + } + + .extension-body { + height: calc(100% - #{$header-height}); + overflow: auto; + + :deep(section) { + height: calc(100% - #{$header-height}); + } + } +} +</style> diff --git a/platypush/backend/http/webapp/src/components/panels/Extensions/Index.vue b/platypush/backend/http/webapp/src/components/panels/Extensions/Index.vue new file mode 100644 index 000000000..1a45eeaf0 --- /dev/null +++ b/platypush/backend/http/webapp/src/components/panels/Extensions/Index.vue @@ -0,0 +1,211 @@ +<template> + <div class="row plugin extensions-container"> + <Loading v-if="loading" /> + + <header> + <div class="filter-container"> + <input type="text" + ref="filter" + placeholder="Extension name" + v-model="filter" + :disabled="loading" /> + </div> + </header> + + <main> + <div class="items"> + <div class="extension-container" v-for="name in extensionNames" :key="name"> + <div class="extension" v-if="matchesFilter(name)"> + <div class="item name" + :class="{selected: name === selectedExtension}" + :data-name="name" + @click="onInput(name, false)" + v-text="extensions[name].name" /> + + <div class="extension-body-container until tablet" + v-if="selectedExtension && name === selectedExtension"> + <Extension :extension="extensions[selectedExtension]" /> + </div> + </div> + </div> + </div> + + <div class="extension-body-container from desktop" + v-if="selectedExtension"> + <Extension :extension="extensions[selectedExtension]" /> + </div> + </main> + </div> +</template> + +<script> +import Loading from "@/components/Loading" +import Utils from "@/Utils" +import Extension from "./Extension" +import { bus } from "@/bus"; + +export default { + name: "Extensions", + mixins: [Utils], + components: { + Extension, + Loading, + Utils, + }, + + data() { + return { + loading: false, + plugins: {}, + backends: {}, + filter: '', + selectedExtension: null, + } + }, + + computed: { + extensions() { + const extensions = {} + + Object.entries(this.plugins).forEach(([name, plugin]) => { + extensions[name] = { + ...plugin, + name: name, + } + }) + + Object.entries(this.backends).forEach(([name, backend]) => { + name = `backend.${name}` + extensions[name] = { + ...backend, + name: name, + } + }) + + return extensions + }, + + extensionNames() { + return Object.keys(this.extensions).sort() + }, + }, + + methods: { + onInput(input, setFilter = true) { + if (setFilter) { + this.filter = input + } + + const name = input?.toLowerCase()?.trim() + if (name?.length && name !== this.selectedExtension && this.extensions[name]) { + this.selectedExtension = name + const el = this.$el.querySelector(`.extensions-container .item[data-name="${name}"]`) + if (el) + el.scrollIntoView({behavior: 'smooth'}) + } else { + this.selectedExtension = null + } + }, + + matchesFilter(extension) { + if (!this.filter) { + return true + } + + return extension.includes(this.filter.toLowerCase()) + }, + + async loadExtensions() { + this.loading = true + + try { + [this.plugins, this.backends] = + await Promise.all([ + this.request('inspect.get_all_plugins'), + this.request('inspect.get_all_backends'), + ]) + } finally { + this.loading = false + } + }, + }, + + mounted() { + this.loadExtensions() + bus.on('update:extension', (ext) => this.onInput(ext, false)) + this.$nextTick(() => this.$refs.filter.focus()) + } +} +</script> + +<style lang="scss" scoped> +@import "src/style/items"; +@import "../Execute/common"; + +$header-height: 3.25em; + +.extensions-container { + width: 100%; + display: flex; + flex-direction: column; + margin-top: .15em; + + header { + height: $header-height; + padding: 0.5em; + margin-bottom: 2px; + box-shadow: $border-shadow-bottom; + + .filter-container { + width: 100%; + + input { + width: 100%; + } + } + } + + main { + height: calc(100% - #{$header-height} - 0.25em); + min-height: calc(100% - #{$header-height} - 0.25em); + background: $background-color; + display: flex; + flex-direction: row; + } + + .items { + height: 100%; + flex-grow: 1; + overflow: auto; + border-bottom: $default-border-2; + } + + .extension-container { + .extension { + display: flex; + flex-direction: column; + + .name { + padding: 1em; + + &.selected { + font-weight: bold; + } + } + } + } + + .extension-body-container.desktop { + width: 70%; + height: 100%; + min-height: 100%; + border-left: $default-border-2; + border-bottom: $default-border-2; + + :deep(article) { + height: 100%; + overflow: auto; + } + } +} +</style> diff --git a/platypush/backend/http/webapp/src/components/panels/Settings/Index.vue b/platypush/backend/http/webapp/src/components/panels/Settings/Index.vue index 521689ad6..baabc173a 100644 --- a/platypush/backend/http/webapp/src/components/panels/Settings/Index.vue +++ b/platypush/backend/http/webapp/src/components/panels/Settings/Index.vue @@ -5,7 +5,6 @@ v-if="selectedPanel === 'users' && currentUser" /> <Token :session-token="sessionToken" :current-user="currentUser" v-else-if="selectedPanel === 'tokens' && currentUser" /> - <Integrations v-else-if="selectedPanel === 'integrations'" /> </main> </div> </template> @@ -13,12 +12,11 @@ <script> import Token from "@/components/panels/Settings/Token"; import Users from "@/components/panels/Settings/Users"; -import Integrations from "@/components/panels/Settings/Integrations"; import Utils from "@/Utils"; export default { name: "Settings", - components: {Users, Token, Integrations}, + components: {Users, Token}, mixins: [Utils], props: { diff --git a/platypush/backend/http/webapp/src/components/panels/Settings/Integrations.vue b/platypush/backend/http/webapp/src/components/panels/Settings/Integrations.vue deleted file mode 100644 index 52676e038..000000000 --- a/platypush/backend/http/webapp/src/components/panels/Settings/Integrations.vue +++ /dev/null @@ -1,62 +0,0 @@ -<template> - <div class="integrations-container"> - <Loading v-if="loading" /> - - <div class="body"> - <!-- TODO --> - </div> - </div> -</template> - -<script> -import Loading from "@/components/Loading"; -import Utils from "@/Utils"; - -export default { - name: "Integrations", - components: {Loading}, - mixins: [Utils], - - data() { - return { - loading: false, - plugins: {}, - backends: {}, - } - }, - - methods: { - async loadIntegrations() { - this.loading = true - - try { - [this.plugins, this.backends] = - await Promise.all([ - this.request('inspect.get_all_plugins'), - this.request('inspect.get_all_backends'), - ]) - } finally { - this.loading = false - } - }, - }, - - mounted() { - this.loadIntegrations() - } -} -</script> - -<style lang="scss"> -.integrations-container { - width: 100%; - display: flex; - flex-direction: column; - margin-top: .15em; - - .body { - background: $background-color; - display: flex; - } -} -</style> diff --git a/platypush/backend/http/webapp/src/components/panels/Settings/sections.json b/platypush/backend/http/webapp/src/components/panels/Settings/sections.json index 7f8dfe768..fc6adc452 100644 --- a/platypush/backend/http/webapp/src/components/panels/Settings/sections.json +++ b/platypush/backend/http/webapp/src/components/panels/Settings/sections.json @@ -11,12 +11,5 @@ "icon": { "class": "fas fa-key" } - }, - - "integrations": { - "name": "Integrations", - "icon": { - "class": "fas fa-puzzle-piece" - } } } diff --git a/platypush/backend/http/webapp/src/utils/Integrations.vue b/platypush/backend/http/webapp/src/utils/Extensions.vue similarity index 93% rename from platypush/backend/http/webapp/src/utils/Integrations.vue rename to platypush/backend/http/webapp/src/utils/Extensions.vue index 0a55f9361..7bbf40ce8 100644 --- a/platypush/backend/http/webapp/src/utils/Integrations.vue +++ b/platypush/backend/http/webapp/src/utils/Extensions.vue @@ -1,6 +1,6 @@ <script> export default { - name: "Integrations", + name: "Extensions", methods: { pluginDisplayName(name) { const words = name.split('.') diff --git a/platypush/backend/http/webapp/src/views/Panel.vue b/platypush/backend/http/webapp/src/views/Panel.vue index 89ab57e8f..86dc45769 100644 --- a/platypush/backend/http/webapp/src/views/Panel.vue +++ b/platypush/backend/http/webapp/src/views/Panel.vue @@ -93,8 +93,9 @@ export default { }, initializeDefaultViews() { - this.plugins.execute = {} this.plugins.entities = {} + this.plugins.execute = {} + this.plugins.extensions = {} }, },