Merge pull request #9409 from Budibase/feature/user-onboarding-overlays
Feature/user onboarding overlays
This commit is contained in:
commit
ee288c1f2d
|
@ -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,
|
||||||
|
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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 = () => {
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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>
|
|
@ -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 />
|
|
@ -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>
|
|
@ -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>
|
|
@ -0,0 +1,7 @@
|
||||||
|
<div>
|
||||||
|
Once you’re 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>
|
|
@ -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"
|
|
@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
|
@ -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()
|
|
@ -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>
|
||||||
|
|
|
@ -47,6 +47,7 @@ export interface User extends ThirdPartyUser {
|
||||||
account?: {
|
account?: {
|
||||||
authType: string
|
authType: string
|
||||||
}
|
}
|
||||||
|
onboardedAt?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserRoles {
|
export interface UserRoles {
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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.
|
||||||
*/
|
*/
|
||||||
|
|
Loading…
Reference in New Issue