diff --git a/package.json b/package.json
index df793a15fda..a5730341cdd 100644
--- a/package.json
+++ b/package.json
@@ -123,6 +123,7 @@
"rollup-plugin-ts": "^1.4.0",
"rollup-plugin-visualizer": "^5.6.0",
"rollup-plugin-vue": "^5.1.4",
+ "shepherd.js": "^9.1.0",
"sync-fetch": "^0.3.1",
"ts-jest": "^27.1.3",
"ts-node": "^10.5.0",
diff --git a/packages/web-runtime/src/App.vue b/packages/web-runtime/src/App.vue
index 0c8c7a8291e..8ccfcc4f50b 100644
--- a/packages/web-runtime/src/App.vue
+++ b/packages/web-runtime/src/App.vue
@@ -38,6 +38,7 @@ import LayoutLoading from './layouts/Loading.vue'
import LayoutPlain from './layouts/Plain.vue'
import { getBackendVersion, getWebVersion } from './container/versions'
import { defineComponent } from '@vue/composition-api'
+import { autostartTours } from './helpers/tours'
export default defineComponent({
components: {
@@ -53,7 +54,12 @@ export default defineComponent({
},
computed: {
...mapState(['route', 'user', 'modal', 'sidebar']),
- ...mapGetters(['configuration', 'capabilities', 'getSettingsValue']),
+ ...mapGetters([
+ 'configuration',
+ 'capabilities',
+ 'getSettingsValue',
+ 'currentTranslatedTourInfos'
+ ]),
layout() {
if (this.user.isAuthenticated && !this.user.userReady) {
return LayoutLoading
@@ -93,6 +99,8 @@ export default defineComponent({
handler: function (to) {
this.announceRouteChange(to)
document.title = this.extractPageTitleFromRoute(to)
+ if (this.currentTranslatedTourInfos.length > 0)
+ autostartTours(this.currentTranslatedTourInfos, to.name)
}
},
capabilities: {
@@ -121,6 +129,7 @@ export default defineComponent({
if (languageCode) {
this.$language.current = languageCode
document.documentElement.lang = languageCode
+ this.setCurrentTranslatedTourInfos(languageCode)
}
}
}
@@ -148,7 +157,7 @@ export default defineComponent({
},
methods: {
- ...mapActions(['fetchNotifications']),
+ ...mapActions(['fetchNotifications', 'setCurrentTranslatedTourInfos']),
focusModal(component, event) {
this.focus({
diff --git a/packages/web-runtime/src/components/Topbar/ThemeSwitcher.vue b/packages/web-runtime/src/components/Topbar/ThemeSwitcher.vue
index 24b9c347284..4f617c86c35 100644
--- a/packages/web-runtime/src/components/Topbar/ThemeSwitcher.vue
+++ b/packages/web-runtime/src/components/Topbar/ThemeSwitcher.vue
@@ -5,6 +5,7 @@
:aria-label="buttonLabel"
appearance="raw"
variation="inverse"
+ style="white-space: nowrap"
@click="toggleTheme"
>
diff --git a/packages/web-runtime/src/components/Topbar/TopBar.vue b/packages/web-runtime/src/components/Topbar/TopBar.vue
index a556a7ab969..79f967723de 100644
--- a/packages/web-runtime/src/components/Topbar/TopBar.vue
+++ b/packages/web-runtime/src/components/Topbar/TopBar.vue
@@ -14,6 +14,7 @@
+
@@ -31,6 +32,7 @@ import UserMenu from './UserMenu.vue'
import Notifications from './Notifications.vue'
import FeedbackLink from './FeedbackLink.vue'
import ThemeSwitcher from './ThemeSwitcher.vue'
+import Tours from './Tours/Tours.vue'
export default {
components: {
@@ -38,7 +40,8 @@ export default {
FeedbackLink,
Notifications,
ThemeSwitcher,
- UserMenu
+ UserMenu,
+ Tours
},
mixins: [NavigationMixin],
props: {
diff --git a/packages/web-runtime/src/components/Topbar/Tours/Tours.vue b/packages/web-runtime/src/components/Topbar/Tours/Tours.vue
new file mode 100644
index 00000000000..fb2c3c1fbc9
--- /dev/null
+++ b/packages/web-runtime/src/components/Topbar/Tours/Tours.vue
@@ -0,0 +1,145 @@
+
+
+
+
+ {{ tours[0].tourName }}
+
+
+
+
+
+ Tours
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/web-runtime/src/container/bootstrap.ts b/packages/web-runtime/src/container/bootstrap.ts
index 44a5a63cec3..555a88263fb 100644
--- a/packages/web-runtime/src/container/bootstrap.ts
+++ b/packages/web-runtime/src/container/bootstrap.ts
@@ -5,6 +5,7 @@ import { Store } from 'vuex'
import VueRouter from 'vue-router'
import { VueConstructor } from 'vue'
import { loadTheme } from '../helpers/theme'
+import { loadTours } from '../helpers/tours'
import OwnCloud from 'owncloud-sdk'
import { sync as routerSync } from 'vuex-router-sync'
import getTextPlugin from 'vue-gettext'
@@ -209,6 +210,23 @@ export const announceTheme = async ({
})
}
+/**
+ * announce runtime tours
+ *
+ * @param store
+ */
+export const announceTours = async ({
+ store,
+ runtimeConfiguration
+}: {
+ store: Store
+ runtimeConfiguration?: RuntimeConfiguration
+}): Promise => {
+ const { tours } = await loadTours(runtimeConfiguration?.options?.tours)
+ await store.dispatch('setAllTranslatedTourInfos', tours)
+ await store.dispatch('setCurrentTranslatedTourInfos')
+}
+
/**
* announce runtime translations by injecting them into the getTextPlugin
*
diff --git a/packages/web-runtime/src/helpers/tours.js b/packages/web-runtime/src/helpers/tours.js
new file mode 100644
index 00000000000..e551caef428
--- /dev/null
+++ b/packages/web-runtime/src/helpers/tours.js
@@ -0,0 +1,162 @@
+import Shepherd from 'shepherd.js'
+import { $gettext } from 'files/src/router/utils'
+
+export const loadTours = async (locations = []) => {
+ const tours = []
+ for (const l of locations) {
+ if (l.split('.').pop() === 'json') {
+ try {
+ const response = await fetch(l)
+ if (response.ok) {
+ const tour = await response.json()
+ tours.push(tour)
+ }
+ } catch (e) {
+ console.error(`Failed to load tours '${l}' is not a valid json file.`)
+ }
+ }
+ }
+ return { tours }
+}
+/* autostarts the first tour of the tours array with autostart property if the current location matches tour settings for autostart */
+export function autostartTours(tourInfos, location) {
+ const autostartTours = tourInfos.filter((t) => t.autostart?.location === location)
+ if (autostartTours[0]) {
+ const t = autostartTours[0]
+ setTimeout(() => {
+ if (!(localStorage.getItem('tours/' + t.tourId) && location === t.autostart.location)) {
+ createTranslatedTour(t).start()
+ localStorage.setItem('tours/' + t.tourId, Date.now())
+ }
+ }, t.autostart.timeout)
+ }
+}
+
+export function createTranslatedTourInfos(tours) {
+ const createdTourInfos = {}
+ tours.forEach((t) => {
+ Object.keys(t.translations).forEach((language) => {
+ const translatedTour = {
+ tourName: t.translations[language].tourName,
+ confirmCancel: t.confirmCancel,
+ confirmCancelMessage: t.confirmCancelMessage,
+ classPrefix: t.classPrefix,
+ exitOnEsc: t.exitOnEsc,
+ useModalOverlay: t.useModalOverlay === true,
+ tooltip: t.translations[language].tooltip,
+ defaultStepOptions: {
+ ...(t.defaultStepOptions?.cancelIcon && {
+ cancelIcon: {
+ enabled: t.defaultStepOptions?.cancelIcon || false
+ }
+ }),
+ ...(t.defaultStepOptions?.classes && { classes: t.defaultStepOptions?.classes }),
+ ...(t.defaultStepOptions?.scrollTo && { scrollTo: t.defaultStepOptions.scrollTo })
+ }
+ }
+ translatedTour.steps = []
+ t.translations[language].steps.forEach((s, j) => {
+ const buttons = addButtons(s.buttons, s.learnMoreLink)
+ translatedTour.steps.push({
+ title: s.title,
+ text: s.text,
+ buttons: buttons,
+ id: j
+ })
+ })
+ translatedTour.tourId = t.tourId
+ translatedTour.autostart = t.autostart
+ translatedTour.allowedLocations = t.allowedLocations
+ translatedTour.deniedLocations = t.deniedLocations
+ translatedTour.defaultLanguage = t.defaultLanguage
+
+ if (!createdTourInfos[language]) createdTourInfos[language] = []
+ createdTourInfos[language].push(translatedTour)
+ })
+ })
+ return createdTourInfos
+}
+
+export function createTranslatedTour(tourInfo) {
+ const tour = new Shepherd.Tour({
+ tourName: tourInfo.tourName,
+ confirmCancel: tourInfo.confirmCancel,
+ confirmCancelMessage: tourInfo.confirmCancelMessage,
+ classPrefix: tourInfo.classPrefix,
+ exitOnEsc: tourInfo.exitOnEsc,
+ useModalOverlay: tourInfo.useModalOverlay === true,
+ tooltip: tourInfo.tooltip,
+ defaultStepOptions: {
+ ...(tourInfo.defaultStepOptions?.cancelIcon && {
+ cancelIcon: {
+ enabled: tourInfo.defaultStepOptions?.cancelIcon || false
+ }
+ }),
+ ...(tourInfo.defaultStepOptions?.classes && {
+ classes: tourInfo.defaultStepOptions?.classes
+ }),
+ ...(tourInfo.defaultStepOptions?.scrollTo && {
+ scrollTo: tourInfo.defaultStepOptions.scrollTo
+ })
+ }
+ })
+
+ tourInfo.steps.forEach((s, j) => {
+ const buttons = s.buttons
+
+ tour.addStep({
+ title: s.title,
+ text: s.text,
+ buttons: buttons,
+ id: j
+ })
+ })
+ tour.tourId = tourInfo.tourId
+ tour.autostart = tourInfo.autostart
+ tour.allowedLocations = tourInfo.allowedLocations
+ tour.deniedLocations = tourInfo.deniedLocations
+ tour.defaultLanguage = tourInfo.defaultLanguage
+
+ return tour
+}
+
+function addButtons(buttons, learnMoreLink) {
+ const actionButtons = []
+
+ learnMoreLink &&
+ actionButtons.push({
+ action() {
+ return window.open(learnMoreLink, '_blank').focus()
+ },
+ classes: 'oc-button oc-button-m oc-button-passive',
+ text: $gettext('Learn more'),
+ secondary: true
+ })
+
+ if (buttons.includes('back'))
+ actionButtons.push({
+ action() {
+ return this.back()
+ },
+ classes: 'oc-button oc-button-m oc-button-passive',
+ text: $gettext('Back'),
+ secondary: true
+ })
+
+ if (buttons.includes('next'))
+ actionButtons.push({
+ action() {
+ /* console.log(
+ Shepherd.activeTour.steps[
+ Shepherd.activeTour.steps.indexOf(Shepherd.activeTour.getCurrentStep()) + 1
+ ],
+ Shepherd.activeTour.steps
+ ) */
+ return this.next()
+ },
+ classes: 'oc-button oc-button-m oc-button-primary',
+ text: $gettext('Next')
+ })
+
+ return actionButtons
+}
diff --git a/packages/web-runtime/src/index.ts b/packages/web-runtime/src/index.ts
index 410923938d7..82ca33247e4 100644
--- a/packages/web-runtime/src/index.ts
+++ b/packages/web-runtime/src/index.ts
@@ -21,7 +21,8 @@ import {
announceVersions,
applicationStore,
announceUppyService,
- startSentry
+ startSentry,
+ announceTours
} from './container'
export const bootstrap = async (configurationPath: string): Promise => {
@@ -41,6 +42,7 @@ export const bootstrap = async (configurationPath: string): Promise => {
announceTranslations({ vue: Vue, supportedLanguages, translations })
await announceTheme({ store, vue: Vue, designSystem, runtimeConfiguration })
announceDefaults({ store, router })
+ await announceTours({ store, runtimeConfiguration })
}
export const renderSuccess = (): void => {
diff --git a/packages/web-runtime/src/store/index.js b/packages/web-runtime/src/store/index.js
index 84133dfd02c..f2d8914bbaa 100644
--- a/packages/web-runtime/src/store/index.js
+++ b/packages/web-runtime/src/store/index.js
@@ -10,6 +10,7 @@ import router from './router'
import settings from './settings'
import modal from './modal'
import navigation from './navigation'
+import tours from './tours'
const vuexPersistInSession = new VuexPersistence({
key: 'webStateInSessionStorage',
@@ -37,7 +38,8 @@ export default {
router,
settings,
modal,
- navigation
+ navigation,
+ tours
},
strict
}
diff --git a/packages/web-runtime/src/store/tours.js b/packages/web-runtime/src/store/tours.js
new file mode 100644
index 00000000000..8537d0d0acb
--- /dev/null
+++ b/packages/web-runtime/src/store/tours.js
@@ -0,0 +1,50 @@
+import { createTranslatedTourInfos } from '../helpers/tours'
+
+const state = {
+ _tours: {},
+ translatedTourInfos: {},
+ currentTranslatedTourInfos: []
+}
+
+const mutations = {
+ SET_TOUR_INFOS(state, tourInfos) {
+ state._tourInfos = tourInfos
+ },
+ SET_TRANSLATED_TOUR_INFOS(state, translatedTourInfos) {
+ state.translatedTourInfos = translatedTourInfos
+ },
+ SET_CURRENT_TRANSLATED_TOUR_INFOS(state, currentTranslatedTourInfos) {
+ state.currentTranslatedTourInfos = currentTranslatedTourInfos
+ }
+}
+
+const getters = {
+ tours: (state) => {
+ return state.tourss
+ },
+ translatedTourInfos: (state) => {
+ return state.translatedTourInfos
+ },
+ currentTranslatedTourInfos: (state) => {
+ return state.currentTranslatedTourInfos
+ }
+}
+
+const actions = {
+ setAllTranslatedTourInfos(context, tourInfos) {
+ context.commit('SET_TOUR_INFOS', tourInfos)
+ const translatedTourInfos = createTranslatedTourInfos(tourInfos) || []
+ context.commit('SET_TRANSLATED_TOUR_INFOS', translatedTourInfos)
+ },
+ setCurrentTranslatedTourInfos(context, languageCode) {
+ const language = languageCode || document.documentElement.lang
+ const currentTranslatedTourInfos = context.state.translatedTourInfos[language] || []
+ context.commit('SET_CURRENT_TRANSLATED_TOUR_INFOS', currentTranslatedTourInfos)
+ }
+}
+export default {
+ state,
+ mutations,
+ getters,
+ actions
+}
diff --git a/packages/web-runtime/tours/example_tour.json b/packages/web-runtime/tours/example_tour.json
new file mode 100644
index 00000000000..0bd528d6277
--- /dev/null
+++ b/packages/web-runtime/tours/example_tour.json
@@ -0,0 +1,72 @@
+{
+ "translations": {
+ "en": {
+ "tourName": "Example Tour",
+ "tooltip": "See new features",
+ "steps": [
+ {
+ "title": "Example News",
+ "text": "Discover the new features and try them yourself",
+ "buttons": ["back", "next"],
+ "learnMoreLink": "https://doc.owncloud.com/docs/next/"
+ },
+ {
+ "title": "1. Example Feature",
+ "text": "Example feature description
Example feature description 2
",
+ "buttons": ["back", "next"],
+ "learnMoreLink": "https://doc.owncloud.com/docs/next/"
+ },
+ {
+ "title": "Coming soon",
+ "text": "Other features will be coming in the coming months
\r\n
Stay tuned!
",
+ "buttons": ["back", "next"]
+ }
+ ]
+ },
+ "de": {
+ "tourName": "Beispielstour",
+ "tooltip": "Entdecke neue Features",
+ "steps": [
+ {
+ "title": "Beispielsneugigkeiten!",
+ "text": "Entdecke neue Features und probiere sie aus",
+ "buttons": ["back", "next"],
+ "learnMoreLink": "https://doc.owncloud.com/docs/next/"
+ },
+ {
+ "title": "1. Beispielsfeature",
+ "text": " Beschreibung des Features
Beschreibung des Features 2
",
+ "buttons": ["back", "next"],
+ "learnMoreLink": "https://doc.owncloud.com/docs/next/"
+ },
+ {
+ "title": "Bald:",
+ "text": "Mehr Festures werden in kommenden Monaten verfügbar sein
\r\n
Bleib dran!
",
+ "buttons": ["back", "next"]
+ }
+ ]
+ }
+ },
+ "confirmCancel": false,
+ "confirmCancelMessage": "",
+ "classPrefix": "",
+ "defaultStepOptions": {
+ "cancelIcon": {
+ "enabled": true
+ },
+ "classes": "a",
+ "scrollTo": {
+ "behavior": "smooth",
+ "block": "center"
+ }
+ },
+ "exitOnEsc": true,
+ "allowedLocations": [],
+ "deniedLocations": ["files-operations-resolver-public-link", "files-public-files"],
+ "useModalOverlay": true,
+ "tourId": "Example Tour",
+ "autostart": {
+ "timeout": 3000,
+ "location": "files-spaces-personal-home"
+ }
+}
diff --git a/rollup.config.js b/rollup.config.js
index 83f3b80875e..a012e0e83ff 100644
--- a/rollup.config.js
+++ b/rollup.config.js
@@ -94,6 +94,7 @@ const plugins = [
{ src: './packages/web-container/img/*', dest: 'img' },
{ src: './packages/web-container/*.{html,json,txt}' },
{ src: './packages/web-runtime/themes/**/*', dest: 'themes' },
+ { src: './packages/web-runtime/tours/*', dest: 'tours' },
{ src: `./config/${production ? 'config.json.dist' : 'config.json'}` }
]
}),