Merge pull request #9409 from Budibase/feature/user-onboarding-overlays

Feature/user onboarding overlays
This commit is contained in:
deanhannigan 2023-01-31 16:37:18 +00:00 committed by GitHub
commit ee288c1f2d
20 changed files with 474 additions and 22 deletions

View File

@ -13,6 +13,7 @@ import {
UserPermissionAssignedEvent, UserPermissionAssignedEvent,
UserPermissionRemovedEvent, UserPermissionRemovedEvent,
UserUpdatedEvent, UserUpdatedEvent,
UserOnboardingEvent,
} from "@budibase/types" } from "@budibase/types"
async function created(user: User, timestamp?: number) { async function created(user: User, timestamp?: number) {
@ -36,6 +37,13 @@ async function deleted(user: User) {
await publishEvent(Event.USER_DELETED, properties) 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 // PERMISSIONS
async function permissionAdminAssigned(user: User, timestamp?: number) { async function permissionAdminAssigned(user: User, timestamp?: number) {
@ -126,6 +134,7 @@ export default {
permissionAdminRemoved, permissionAdminRemoved,
permissionBuilderAssigned, permissionBuilderAssigned,
permissionBuilderRemoved, permissionBuilderRemoved,
onboardingComplete,
invited, invited,
inviteAccepted, inviteAccepted,
passwordForceReset, passwordForceReset,

View File

@ -3,6 +3,9 @@ export default function positionDropdown(
{ anchor, align, maxWidth, useAnchorWidth } { anchor, align, maxWidth, useAnchorWidth }
) { ) {
const update = () => { const update = () => {
if (!anchor) {
return
}
const anchorBounds = anchor.getBoundingClientRect() const anchorBounds = anchor.getBoundingClientRect()
const elementBounds = element.getBoundingClientRect() const elementBounds = element.getBoundingClientRect()
let styles = { let styles = {
@ -13,6 +16,8 @@ export default function positionDropdown(
top: null, top: null,
} }
let popoverLeftPad = 20
// Determine vertical styles // Determine vertical styles
if (window.innerHeight - anchorBounds.bottom < 100) { if (window.innerHeight - anchorBounds.bottom < 100) {
styles.top = anchorBounds.top - elementBounds.height - 5 styles.top = anchorBounds.top - elementBounds.height - 5
@ -29,7 +34,13 @@ export default function positionDropdown(
styles.minWidth = anchorBounds.width styles.minWidth = anchorBounds.width
} }
if (align === "right") { 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") { } else if (align === "right-side") {
styles.left = anchorBounds.left + anchorBounds.width styles.left = anchorBounds.left + anchorBounds.width
} else { } else {
@ -54,8 +65,11 @@ export default function positionDropdown(
const resizeObserver = new ResizeObserver(entries => { const resizeObserver = new ResizeObserver(entries => {
entries.forEach(update) entries.forEach(update)
}) })
if (anchor) {
resizeObserver.observe(anchor) resizeObserver.observe(anchor)
}
resizeObserver.observe(element) resizeObserver.observe(element)
resizeObserver.observe(document.body)
document.addEventListener("scroll", update, true) document.addEventListener("scroll", update, true)

View File

@ -15,11 +15,13 @@
export let tooltip = undefined export let tooltip = undefined
export let dataCy export let dataCy
export let newStyles = true export let newStyles = true
export let id
let showTooltip = false let showTooltip = false
</script> </script>
<button <button
{id}
class:spectrum-Button--cta={cta} class:spectrum-Button--cta={cta}
class:spectrum-Button--primary={primary} class:spectrum-Button--primary={primary}
class:spectrum-Button--secondary={secondary} class:spectrum-Button--secondary={secondary}

View File

@ -19,9 +19,7 @@
export let showTip = false export let showTip = false
export let open = false export let open = false
export let useAnchorWidth = false export let useAnchorWidth = false
export let dismissible = true
let tipSvg =
'<svg xmlns="http://www.w3.org/svg/2000" width="23" height="12" class="spectrum-Popover-tip" > <path class="spectrum-Popover-tip-triangle" d="M 0.7071067811865476 0 L 11.414213562373096 10.707106781186548 L 22.121320343559645 0" /> </svg>'
$: tooltipClasses = showTip $: tooltipClasses = showTip
? `spectrum-Popover--withTip spectrum-Popover--${direction}` ? `spectrum-Popover--withTip spectrum-Popover--${direction}`
@ -67,9 +65,15 @@
<Portal {target}> <Portal {target}>
<div <div
tabindex="0" tabindex="0"
use:positionDropdown={{ anchor, align, maxWidth, useAnchorWidth }} use:positionDropdown={{
anchor,
align,
maxWidth,
useAnchorWidth,
showTip: false,
}}
use:clickOutside={{ use:clickOutside={{
callback: handleOutsideClick, callback: dismissible ? handleOutsideClick : () => {},
anchor, anchor,
}} }}
on:keydown={handleEscape} on:keydown={handleEscape}
@ -78,10 +82,6 @@
data-cy={dataCy} data-cy={dataCy}
transition:fly|local={{ y: -20, duration: 200 }} transition:fly|local={{ y: -20, duration: 200 }}
> >
{#if showTip}
{@html tipSvg}
{/if}
<slot /> <slot />
</div> </div>
</Portal> </Portal>

View File

@ -3,6 +3,7 @@
import Portal from "svelte-portal" import Portal from "svelte-portal"
export let title export let title
export let icon = "" export let icon = ""
export let id
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
let selected = getContext("tab") let selected = getContext("tab")
@ -31,10 +32,7 @@
$: { $: {
if ($selected.title === title && tab_internal) { if ($selected.title === title && tab_internal) {
if ($selected.info?.left !== tab_internal.getBoundingClientRect().left) { if ($selected.info?.left !== tab_internal.getBoundingClientRect().left) {
$selected = { setTabInfo()
...$selected,
info: tab_internal.getBoundingClientRect(),
}
} }
} }
} }
@ -50,6 +48,7 @@
</script> </script>
<div <div
{id}
bind:this={tab_internal} bind:this={tab_internal}
on:click={onClick} on:click={onClick}
class:is-selected={$selected.title === title} class:is-selected={$selected.title === title}

View File

@ -63,6 +63,10 @@ const INITIAL_FRONTEND_STATE = {
selectedScreenId: null, selectedScreenId: null,
selectedComponentId: null, selectedComponentId: null,
selectedLayoutId: null, selectedLayoutId: null,
// onboarding
onboarding: false,
tourNodes: null,
} }
export const getFrontendStore = () => { export const getFrontendStore = () => {

View File

@ -11,6 +11,8 @@
import { store } from "builderStore" import { store } from "builderStore"
import { ProgressCircle } from "@budibase/bbui" import { ProgressCircle } from "@budibase/bbui"
import CopyInput from "components/common/inputs/CopyInput.svelte" import CopyInput from "components/common/inputs/CopyInput.svelte"
import TourWrap from "../portal/onboarding/TourWrap.svelte"
import { TOUR_STEP_KEYS } from "../portal/onboarding/tours.js"
let publishModal let publishModal
let asyncModal let asyncModal
@ -54,7 +56,11 @@
} }
</script> </script>
<Button cta on:click={publishModal.show}>Publish</Button> <TourWrap tourStepKey={TOUR_STEP_KEYS.BUILDER_APP_PUBLISH}>
<Button cta on:click={publishModal.show} id={"builder-app-publish-button"}>
Publish
</Button>
</TourWrap>
<Modal bind:this={publishModal}> <Modal bind:this={publishModal}>
<ModalContent <ModalContent
title="Publish to production" title="Publish to production"

View File

@ -0,0 +1,173 @@
<script>
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)
}
</script>
{#key tourStepKey}
<Popover
align={tourStep?.align}
bind:this={popover}
anchor={popoverAnchor}
dataCy="tour-popover-menu"
maxWidth={300}
dismissible={false}
>
<Layout gap="M">
<div class="tour-header">
<Heading size="XS">{tourStep?.title || "-"}</Heading>
<div>{`${tourStepIdx + 1}/${tourSteps?.length}`}</div>
</div>
<Body size="S">
<span class="tour-body">
{#if tourStep.layout}
<svelte:component this={tourStep.layout} />
{:else}
{tourStep?.body || ""}
{/if}
</span>
</Body>
<div class="tour-footer">
<div class="tour-navigation">
{#if tourStepIdx > 0}
<Button
secondary
on:click={previousStep}
disabled={tourStepIdx == 0}
>
<div>Back</div>
</Button>
{/if}
<Button cta on:click={nextStep}>
<div>{lastStep ? "Finish" : "Next"}</div>
</Button>
</div>
</div>
</Layout>
</Popover>
{/key}
<style>
.tour-navigation {
grid-gap: var(--spectrum-alias-grid-baseline);
display: flex;
justify-content: end;
}
:global([data-cy="tour-popover-menu"]) {
padding: 10px;
margin-top: var(--spacing-l);
}
.tour-body :global(.feature-list) {
margin-bottom: 0px;
padding-left: var(--spacing-xl);
}
.tour-header {
display: flex;
align-items: center;
justify-content: space-between;
}
</style>

View File

@ -0,0 +1,29 @@
<script>
import { tourHandler } from "./tourHandler"
import { TOURS } from "./tours"
import { onMount, onDestroy } from "svelte"
import { store } from "builderStore"
export let tourStepKey
let currentTour
let ready = false
let handler
onMount(() => {
if (!$store.tourKey) return
currentTour = TOURS[$store.tourKey].find(step => step.id === tourStepKey)
const elem = document.querySelector(currentTour.query)
handler = tourHandler(elem, tourStepKey)
ready = true
})
onDestroy(() => {
if (handler) {
handler.destroy()
}
})
</script>
<slot />

View File

@ -0,0 +1,10 @@
<div>
In this section you can mange the data for your app:
<ul class="feature-list">
<li>Connect data sources</li>
<li>Edit data</li>
<li>Manage read & write access</li>
<li>Create views</li>
<li>Add bindings</li>
</ul>
</div>

View File

@ -0,0 +1,10 @@
<div>
After setting up your data, Design is where you build the screens for your
app:
<ul class="feature-list">
<li>Add screens</li>
<li>Add components</li>
<li>Choose your theme</li>
<li>Edit navigation</li>
</ul>
</div>

View File

@ -0,0 +1,7 @@
<div>
Once youre happy with your app you can publish it to production!
<p>
After publishing, any changes you make will not take affect until you next
publish.
</p>
</div>

View File

@ -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"

View File

@ -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
})
}
},
}
}

View File

@ -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()

View File

@ -1,6 +1,7 @@
<script> <script>
import { store, automationStore } from "builderStore" import { store, automationStore } from "builderStore"
import { roles, flags } from "stores/backend" import { roles, flags } from "stores/backend"
import { auth } from "stores/portal"
import { import {
ActionMenu, ActionMenu,
MenuItem, MenuItem,
@ -10,6 +11,7 @@
Heading, Heading,
notifications, notifications,
} from "@budibase/bbui" } from "@budibase/bbui"
import RevertModal from "components/deploy/RevertModal.svelte" import RevertModal from "components/deploy/RevertModal.svelte"
import VersionModal from "components/deploy/VersionModal.svelte" import VersionModal from "components/deploy/VersionModal.svelte"
import DeployNavigation from "components/deploy/DeployNavigation.svelte" import DeployNavigation from "components/deploy/DeployNavigation.svelte"
@ -17,6 +19,9 @@
import { isActive, goto, layout, redirect } from "@roxi/routify" import { isActive, goto, layout, redirect } from "@roxi/routify"
import { capitalise } from "helpers" import { capitalise } from "helpers"
import { onMount, onDestroy } from "svelte" import { onMount, onDestroy } from "svelte"
import TourWrap from "components/portal/onboarding/TourWrap.svelte"
import TourPopover from "components/portal/onboarding/TourPopover.svelte"
import { TOUR_KEYS, TOURS } from "components/portal/onboarding/tours.js"
export let application export let application
@ -62,6 +67,23 @@
}) })
} }
const initTour = async () => {
if (!$auth.user?.onboardedAt) {
// Determine the correct step
const activeNav = $layout.children.find(c => $isActive(c.path))
const onboardingTour = TOURS[TOUR_KEYS.TOUR_BUILDER_ONBOARDING]
const targetStep = activeNav
? onboardingTour.find(step => step.route === activeNav?.path)
: null
await store.update(state => ({
...state,
onboarding: true,
tourKey: TOUR_KEYS.TOUR_BUILDER_ONBOARDING,
tourStepKey: targetStep?.id,
}))
}
}
onMount(async () => { onMount(async () => {
if (!hasSynced && application) { if (!hasSynced && application) {
try { try {
@ -69,6 +91,7 @@
// check if user has beta access // check if user has beta access
// const betaResponse = await API.checkBetaAccess($auth?.user?.email) // const betaResponse = await API.checkBetaAccess($auth?.user?.email)
// betaAccess = betaResponse.access // betaAccess = betaResponse.access
initTour()
} catch (error) { } catch (error) {
notifications.error("Failed to sync with production database") notifications.error("Failed to sync with production database")
} }
@ -88,6 +111,7 @@
<!-- This should probably be some kind of loading state? --> <!-- This should probably be some kind of loading state? -->
<div class="loading" /> <div class="loading" />
{:then _} {:then _}
<TourPopover />
<div class="root"> <div class="root">
<div class="top-nav"> <div class="top-nav">
<div class="topleftnav"> <div class="topleftnav">
@ -140,12 +164,15 @@
<div class="topcenternav"> <div class="topcenternav">
<Tabs {selected} size="M"> <Tabs {selected} size="M">
{#each $layout.children as { path, title }} {#each $layout.children as { path, title }}
<TourWrap tourStepKey={`builder-${title}-section`}>
<Tab <Tab
quiet quiet
selected={$isActive(path)} selected={$isActive(path)}
on:click={topItemNavigate(path)} on:click={topItemNavigate(path)}
title={capitalise(title)} title={capitalise(title)}
id={`builder-${title}-tab`}
/> />
</TourWrap>
{/each} {/each}
</Tabs> </Tabs>
</div> </div>

View File

@ -47,6 +47,7 @@ export interface User extends ThirdPartyUser {
account?: { account?: {
authType: string authType: string
} }
onboardedAt?: string
} }
export interface UserRoles { export interface UserRoles {

View File

@ -6,6 +6,9 @@ export enum Event {
USER_UPDATED = "user:updated", USER_UPDATED = "user:updated",
USER_DELETED = "user:deleted", USER_DELETED = "user:deleted",
// USER / ONBOARDING
USER_ONBOARDING_COMPLETE = "user:onboarding:complete",
// USER / PERMISSIONS // USER / PERMISSIONS
USER_PERMISSION_ADMIN_ASSIGNED = "user:admin:assigned", USER_PERMISSION_ADMIN_ASSIGNED = "user:admin:assigned",
USER_PERMISSION_ADMIN_REMOVED = "user:admin:removed", USER_PERMISSION_ADMIN_REMOVED = "user:admin:removed",

View File

@ -12,6 +12,11 @@ export interface UserDeletedEvent extends BaseEvent {
userId: string userId: string
} }
export interface UserOnboardingEvent extends BaseEvent {
userId: string
step?: string
}
export interface UserPermissionAssignedEvent extends BaseEvent { export interface UserPermissionAssignedEvent extends BaseEvent {
userId: string userId: string
} }

View File

@ -73,6 +73,10 @@ export const handleSaveEvents = async (
await events.user.permissionAdminRemoved(user) await events.user.permissionAdminRemoved(user)
} }
if (isOnboardingComplete(user, existingUser)) {
await events.user.onboardingComplete(user)
}
if ( if (
!existingUser.forceResetPassword && !existingUser.forceResetPassword &&
user.forceResetPassword && user.forceResetPassword &&
@ -114,6 +118,10 @@ const isRemovingAdmin = (user: any, existingUser: any) => {
return isRemovingPermission(user, existingUser, isAdmin) return isRemovingPermission(user, existingUser, isAdmin)
} }
const isOnboardingComplete = (user: any, existingUser: any) => {
return !existingUser?.onboardedAt && typeof user.onboardedAt === "string"
}
/** /**
* Check if a permission is being added to a new or existing user. * Check if a permission is being added to a new or existing user.
*/ */