diff --git a/packages/backend-core/src/events/publishers/user.ts b/packages/backend-core/src/events/publishers/user.ts index c296a8bc49..1fe50149b5 100644 --- a/packages/backend-core/src/events/publishers/user.ts +++ b/packages/backend-core/src/events/publishers/user.ts @@ -13,6 +13,7 @@ import { UserPermissionAssignedEvent, UserPermissionRemovedEvent, UserUpdatedEvent, + UserOnboardingEvent, } from "@budibase/types" async function created(user: User, timestamp?: number) { @@ -36,6 +37,13 @@ async function deleted(user: User) { await publishEvent(Event.USER_DELETED, properties) } +export async function onboardingComplete(user: User) { + const properties: UserOnboardingEvent = { + userId: user._id as string, + } + await publishEvent(Event.USER_ONBOARDING_COMPLETE, properties) +} + // PERMISSIONS async function permissionAdminAssigned(user: User, timestamp?: number) { @@ -126,6 +134,7 @@ export default { permissionAdminRemoved, permissionBuilderAssigned, permissionBuilderRemoved, + onboardingComplete, invited, inviteAccepted, passwordForceReset, diff --git a/packages/bbui/src/Actions/position_dropdown.js b/packages/bbui/src/Actions/position_dropdown.js index 463b69169f..09264d5250 100644 --- a/packages/bbui/src/Actions/position_dropdown.js +++ b/packages/bbui/src/Actions/position_dropdown.js @@ -3,6 +3,9 @@ export default function positionDropdown( { anchor, align, maxWidth, useAnchorWidth } ) { const update = () => { + if (!anchor) { + return + } const anchorBounds = anchor.getBoundingClientRect() const elementBounds = element.getBoundingClientRect() let styles = { @@ -13,6 +16,8 @@ export default function positionDropdown( top: null, } + let popoverLeftPad = 20 + // Determine vertical styles if (window.innerHeight - anchorBounds.bottom < 100) { styles.top = anchorBounds.top - elementBounds.height - 5 @@ -29,7 +34,13 @@ export default function positionDropdown( styles.minWidth = anchorBounds.width } if (align === "right") { - styles.left = anchorBounds.left + anchorBounds.width - elementBounds.width + let left = + anchorBounds.left + anchorBounds.width / 2 - elementBounds.width + // Accommodate margin on popover: 1.25rem; ~20px + if (left + elementBounds.width + popoverLeftPad > window.innerWidth) { + left -= 20 + } + styles.left = left } else if (align === "right-side") { styles.left = anchorBounds.left + anchorBounds.width } else { @@ -54,8 +65,11 @@ export default function positionDropdown( const resizeObserver = new ResizeObserver(entries => { entries.forEach(update) }) - resizeObserver.observe(anchor) + if (anchor) { + resizeObserver.observe(anchor) + } resizeObserver.observe(element) + resizeObserver.observe(document.body) document.addEventListener("scroll", update, true) diff --git a/packages/bbui/src/Button/Button.svelte b/packages/bbui/src/Button/Button.svelte index 979ec6a728..b8ffe9f7e6 100644 --- a/packages/bbui/src/Button/Button.svelte +++ b/packages/bbui/src/Button/Button.svelte @@ -15,11 +15,13 @@ export let tooltip = undefined export let dataCy export let newStyles = true + export let id let showTooltip = false + + + + import { Popover, Layout, Heading, Body, Button } from "@budibase/bbui" + import { store } from "builderStore" + import { TOURS } from "./tours.js" + import { goto, layout, isActive } from "@roxi/routify" + + let popoverAnchor + let popover + let tourSteps = null + let tourStep + let tourStepIdx + let lastStep + + $: tourNodes = { ...$store.tourNodes } + $: tourKey = $store.tourKey + $: tourStepKey = $store.tourStepKey + + const initTour = targetKey => { + if (!targetKey) { + return + } + tourSteps = [...TOURS[targetKey]] + tourStepIdx = 0 + tourStep = { ...tourSteps[tourStepIdx] } + } + + $: initTour(tourKey) + + const updateTourStep = targetStepKey => { + if (!tourSteps?.length) { + return + } + tourStepIdx = getCurrentStepIdx(tourSteps, targetStepKey) + lastStep = tourStepIdx + 1 == tourSteps.length + tourStep = { ...tourSteps[tourStepIdx] } + tourStep.onLoad() + } + + $: updateTourStep(tourStepKey) + + const showPopover = (tourStep, tourNodes, popover) => { + if (!tourStep) { + return + } + popoverAnchor = tourNodes[tourStep.id] + popover?.show() + } + + $: showPopover(tourStep, tourNodes, popover) + + const navigateStep = step => { + if (step.route) { + const activeNav = $layout.children.find(c => $isActive(c.path)) + if (activeNav) { + store.update(state => { + if (!state.previousTopNavPath) state.previousTopNavPath = {} + state.previousTopNavPath[activeNav.path] = window.location.pathname + $goto(state.previousTopNavPath[step.route] || step.route) + return state + }) + } + } + } + + const nextStep = async () => { + if (!lastStep === true) { + let target = tourSteps[tourStepIdx + 1] + if (target) { + store.update(state => ({ + ...state, + tourStepKey: target.id, + })) + navigateStep(target) + } else { + console.log("Could not retrieve step") + } + } else { + if (typeof tourStep.onComplete === "function") { + tourStep.onComplete() + } + popover.hide() + } + } + + const previousStep = async () => { + if (tourStepIdx > 0) { + let target = tourSteps[tourStepIdx - 1] + if (target) { + store.update(state => ({ + ...state, + tourStepKey: target.id, + })) + navigateStep(target) + } else { + console.log("Could not retrieve step") + } + } + } + + const getCurrentStepIdx = (steps, tourStepKey) => { + if (!steps?.length) { + return + } + if (steps?.length && !tourStepKey) { + return 0 + } + return steps.findIndex(step => step.id === tourStepKey) + } + + +{#key tourStepKey} + + +
+ {tourStep?.title || "-"} +
{`${tourStepIdx + 1}/${tourSteps?.length}`}
+
+ + + {#if tourStep.layout} + + {:else} + {tourStep?.body || ""} + {/if} + + + +
+
+{/key} + + diff --git a/packages/builder/src/components/portal/onboarding/TourWrap.svelte b/packages/builder/src/components/portal/onboarding/TourWrap.svelte new file mode 100644 index 0000000000..1be149f7fa --- /dev/null +++ b/packages/builder/src/components/portal/onboarding/TourWrap.svelte @@ -0,0 +1,29 @@ + + + diff --git a/packages/builder/src/components/portal/onboarding/steps/OnboardingData.svelte b/packages/builder/src/components/portal/onboarding/steps/OnboardingData.svelte new file mode 100644 index 0000000000..674d5c14ab --- /dev/null +++ b/packages/builder/src/components/portal/onboarding/steps/OnboardingData.svelte @@ -0,0 +1,10 @@ +
+ In this section you can mange the data for your app: +
    +
  • Connect data sources
  • +
  • Edit data
  • +
  • Manage read & write access
  • +
  • Create views
  • +
  • Add bindings
  • +
+
diff --git a/packages/builder/src/components/portal/onboarding/steps/OnboardingDesign.svelte b/packages/builder/src/components/portal/onboarding/steps/OnboardingDesign.svelte new file mode 100644 index 0000000000..84d84777f5 --- /dev/null +++ b/packages/builder/src/components/portal/onboarding/steps/OnboardingDesign.svelte @@ -0,0 +1,10 @@ +
+ After setting up your data, Design is where you build the screens for your + app: +
    +
  • Add screens
  • +
  • Add components
  • +
  • Choose your theme
  • +
  • Edit navigation
  • +
+
diff --git a/packages/builder/src/components/portal/onboarding/steps/OnboardingPublish.svelte b/packages/builder/src/components/portal/onboarding/steps/OnboardingPublish.svelte new file mode 100644 index 0000000000..8913d77482 --- /dev/null +++ b/packages/builder/src/components/portal/onboarding/steps/OnboardingPublish.svelte @@ -0,0 +1,7 @@ +
+ Once you’re happy with your app you can publish it to production! +

+ After publishing, any changes you make will not take affect until you next + publish. +

+
diff --git a/packages/builder/src/components/portal/onboarding/steps/index.js b/packages/builder/src/components/portal/onboarding/steps/index.js new file mode 100644 index 0000000000..8e27748f36 --- /dev/null +++ b/packages/builder/src/components/portal/onboarding/steps/index.js @@ -0,0 +1,3 @@ +export { default as OnboardingData } from "./OnboardingData.svelte" +export { default as OnboardingDesign } from "./OnboardingDesign.svelte" +export { default as OnboardingPublish } from "./OnboardingPublish.svelte" diff --git a/packages/builder/src/components/portal/onboarding/tourHandler.js b/packages/builder/src/components/portal/onboarding/tourHandler.js new file mode 100644 index 0000000000..d4a564f23a --- /dev/null +++ b/packages/builder/src/components/portal/onboarding/tourHandler.js @@ -0,0 +1,47 @@ +import { store } from "builderStore/index" +import { get } from "svelte/store" + +const registerNode = async (node, tourStepKey) => { + if (!node) { + console.log("Tour Handler - an anchor node is required") + } + + if (!get(store).tourKey) { + console.log("Tour Handler - No active tour ", tourStepKey, node) + return + } + + store.update(state => { + const update = { + ...state, + tourNodes: { + ...state.tourNodes, + [tourStepKey]: node, + }, + } + return update + }) +} + +export function tourHandler(node, tourStepKey) { + if (node && tourStepKey) { + registerNode(node, tourStepKey) + } + return { + destroy: () => { + const updatedTourNodes = get(store).tourNodes + if (updatedTourNodes && updatedTourNodes[tourStepKey]) { + delete updatedTourNodes[tourStepKey] + store.update(state => { + const update = { + ...state, + tourNodes: { + ...updatedTourNodes, + }, + } + return update + }) + } + }, + } +} diff --git a/packages/builder/src/components/portal/onboarding/tours.js b/packages/builder/src/components/portal/onboarding/tours.js new file mode 100644 index 0000000000..8acd5bb8ce --- /dev/null +++ b/packages/builder/src/components/portal/onboarding/tours.js @@ -0,0 +1,95 @@ +import { get } from "svelte/store" +import { store } from "builderStore" +import { users, auth } from "stores/portal" +import analytics from "analytics" +import { OnboardingData, OnboardingDesign, OnboardingPublish } from "./steps" +const ONBOARDING_EVENT_PREFIX = "onboarding" + +export const TOUR_STEP_KEYS = { + BUILDER_APP_PUBLISH: "builder-app-publish", + BUILDER_DATA_SECTION: "builder-data-section", + BUILDER_DESIGN_SECTION: "builder-design-section", + BUILDER_AUTOMATE_SECTION: "builder-automate-section", +} + +export const TOUR_KEYS = { + TOUR_BUILDER_ONBOARDING: "builder-onboarding", +} + +const tourEvent = eventKey => { + analytics.captureEvent(`${ONBOARDING_EVENT_PREFIX}:${eventKey}`, { + eventSource: EventSource.PORTAL, + }) +} + +const getTours = () => { + return { + [TOUR_KEYS.TOUR_BUILDER_ONBOARDING]: [ + { + id: TOUR_STEP_KEYS.BUILDER_DATA_SECTION, + title: "Data", + route: "/builder/app/:application/data", + layout: OnboardingData, + query: ".topcenternav .spectrum-Tabs-item#builder-data-tab", + onLoad: async () => { + tourEvent(TOUR_STEP_KEYS.BUILDER_DATA_SECTION) + }, + align: "left", + }, + { + id: TOUR_STEP_KEYS.BUILDER_DESIGN_SECTION, + title: "Design", + route: "/builder/app/:application/design", + layout: OnboardingDesign, + query: ".topcenternav .spectrum-Tabs-item#builder-design-tab", + onLoad: () => { + tourEvent(TOUR_STEP_KEYS.BUILDER_DESIGN_SECTION) + }, + align: "left", + }, + { + id: TOUR_STEP_KEYS.BUILDER_AUTOMATE_SECTION, + title: "Automations", + route: "/builder/app/:application/automate", + query: ".topcenternav .spectrum-Tabs-item#builder-automate-tab", + body: "Once you have your app screens made, you can set up automations to fit in with your current workflow", + onLoad: () => { + tourEvent(TOUR_STEP_KEYS.BUILDER_AUTOMATE_SECTION) + }, + align: "left", + }, + { + id: TOUR_STEP_KEYS.BUILDER_APP_PUBLISH, + title: "Publish", + layout: OnboardingPublish, + query: ".toprightnav #builder-app-publish-button", + onLoad: () => { + tourEvent(TOUR_STEP_KEYS.BUILDER_APP_PUBLISH) + }, + onComplete: async () => { + // Mark the users onboarding as complete + // Clear all tour related state + if (get(auth).user) { + await users.save({ + ...get(auth).user, + onboardedAt: new Date().toISOString(), + }) + + // Update the cached user + await auth.getSelf() + + store.update(state => ({ + ...state, + tourNodes: undefined, + tourKey: undefined, + tourKeyStep: undefined, + onboarding: false, + })) + } + }, + }, + ], + } +} + +export const TOURS = getTours() diff --git a/packages/builder/src/pages/builder/app/[application]/_layout.svelte b/packages/builder/src/pages/builder/app/[application]/_layout.svelte index 06ae57fa85..c99776320f 100644 --- a/packages/builder/src/pages/builder/app/[application]/_layout.svelte +++ b/packages/builder/src/pages/builder/app/[application]/_layout.svelte @@ -1,6 +1,7 @@