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 @@ + + + + + + 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'}` } ] }),