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,
|
||||
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,
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -15,11 +15,13 @@
|
|||
export let tooltip = undefined
|
||||
export let dataCy
|
||||
export let newStyles = true
|
||||
export let id
|
||||
|
||||
let showTooltip = false
|
||||
</script>
|
||||
|
||||
<button
|
||||
{id}
|
||||
class:spectrum-Button--cta={cta}
|
||||
class:spectrum-Button--primary={primary}
|
||||
class:spectrum-Button--secondary={secondary}
|
||||
|
|
|
@ -19,9 +19,7 @@
|
|||
export let showTip = false
|
||||
export let open = false
|
||||
export let useAnchorWidth = false
|
||||
|
||||
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>'
|
||||
export let dismissible = true
|
||||
|
||||
$: tooltipClasses = showTip
|
||||
? `spectrum-Popover--withTip spectrum-Popover--${direction}`
|
||||
|
@ -67,9 +65,15 @@
|
|||
<Portal {target}>
|
||||
<div
|
||||
tabindex="0"
|
||||
use:positionDropdown={{ anchor, align, maxWidth, useAnchorWidth }}
|
||||
use:positionDropdown={{
|
||||
anchor,
|
||||
align,
|
||||
maxWidth,
|
||||
useAnchorWidth,
|
||||
showTip: false,
|
||||
}}
|
||||
use:clickOutside={{
|
||||
callback: handleOutsideClick,
|
||||
callback: dismissible ? handleOutsideClick : () => {},
|
||||
anchor,
|
||||
}}
|
||||
on:keydown={handleEscape}
|
||||
|
@ -78,10 +82,6 @@
|
|||
data-cy={dataCy}
|
||||
transition:fly|local={{ y: -20, duration: 200 }}
|
||||
>
|
||||
{#if showTip}
|
||||
{@html tipSvg}
|
||||
{/if}
|
||||
|
||||
<slot />
|
||||
</div>
|
||||
</Portal>
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
import Portal from "svelte-portal"
|
||||
export let title
|
||||
export let icon = ""
|
||||
export let id
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
let selected = getContext("tab")
|
||||
|
@ -31,10 +32,7 @@
|
|||
$: {
|
||||
if ($selected.title === title && tab_internal) {
|
||||
if ($selected.info?.left !== tab_internal.getBoundingClientRect().left) {
|
||||
$selected = {
|
||||
...$selected,
|
||||
info: tab_internal.getBoundingClientRect(),
|
||||
}
|
||||
setTabInfo()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -50,6 +48,7 @@
|
|||
</script>
|
||||
|
||||
<div
|
||||
{id}
|
||||
bind:this={tab_internal}
|
||||
on:click={onClick}
|
||||
class:is-selected={$selected.title === title}
|
||||
|
|
|
@ -63,6 +63,10 @@ const INITIAL_FRONTEND_STATE = {
|
|||
selectedScreenId: null,
|
||||
selectedComponentId: null,
|
||||
selectedLayoutId: null,
|
||||
|
||||
// onboarding
|
||||
onboarding: false,
|
||||
tourNodes: null,
|
||||
}
|
||||
|
||||
export const getFrontendStore = () => {
|
||||
|
|
|
@ -11,6 +11,8 @@
|
|||
import { store } from "builderStore"
|
||||
import { ProgressCircle } from "@budibase/bbui"
|
||||
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 asyncModal
|
||||
|
@ -54,7 +56,11 @@
|
|||
}
|
||||
</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}>
|
||||
<ModalContent
|
||||
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>
|
||||
import { store, automationStore } from "builderStore"
|
||||
import { roles, flags } from "stores/backend"
|
||||
import { auth } from "stores/portal"
|
||||
import {
|
||||
ActionMenu,
|
||||
MenuItem,
|
||||
|
@ -10,6 +11,7 @@
|
|||
Heading,
|
||||
notifications,
|
||||
} from "@budibase/bbui"
|
||||
|
||||
import RevertModal from "components/deploy/RevertModal.svelte"
|
||||
import VersionModal from "components/deploy/VersionModal.svelte"
|
||||
import DeployNavigation from "components/deploy/DeployNavigation.svelte"
|
||||
|
@ -17,6 +19,9 @@
|
|||
import { isActive, goto, layout, redirect } from "@roxi/routify"
|
||||
import { capitalise } from "helpers"
|
||||
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
|
||||
|
||||
|
@ -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 () => {
|
||||
if (!hasSynced && application) {
|
||||
try {
|
||||
|
@ -69,6 +91,7 @@
|
|||
// check if user has beta access
|
||||
// const betaResponse = await API.checkBetaAccess($auth?.user?.email)
|
||||
// betaAccess = betaResponse.access
|
||||
initTour()
|
||||
} catch (error) {
|
||||
notifications.error("Failed to sync with production database")
|
||||
}
|
||||
|
@ -88,6 +111,7 @@
|
|||
<!-- This should probably be some kind of loading state? -->
|
||||
<div class="loading" />
|
||||
{:then _}
|
||||
<TourPopover />
|
||||
<div class="root">
|
||||
<div class="top-nav">
|
||||
<div class="topleftnav">
|
||||
|
@ -140,12 +164,15 @@
|
|||
<div class="topcenternav">
|
||||
<Tabs {selected} size="M">
|
||||
{#each $layout.children as { path, title }}
|
||||
<Tab
|
||||
quiet
|
||||
selected={$isActive(path)}
|
||||
on:click={topItemNavigate(path)}
|
||||
title={capitalise(title)}
|
||||
/>
|
||||
<TourWrap tourStepKey={`builder-${title}-section`}>
|
||||
<Tab
|
||||
quiet
|
||||
selected={$isActive(path)}
|
||||
on:click={topItemNavigate(path)}
|
||||
title={capitalise(title)}
|
||||
id={`builder-${title}-tab`}
|
||||
/>
|
||||
</TourWrap>
|
||||
{/each}
|
||||
</Tabs>
|
||||
</div>
|
||||
|
|
|
@ -47,6 +47,7 @@ export interface User extends ThirdPartyUser {
|
|||
account?: {
|
||||
authType: string
|
||||
}
|
||||
onboardedAt?: string
|
||||
}
|
||||
|
||||
export interface UserRoles {
|
||||
|
|
|
@ -6,6 +6,9 @@ export enum Event {
|
|||
USER_UPDATED = "user:updated",
|
||||
USER_DELETED = "user:deleted",
|
||||
|
||||
// USER / ONBOARDING
|
||||
USER_ONBOARDING_COMPLETE = "user:onboarding:complete",
|
||||
|
||||
// USER / PERMISSIONS
|
||||
USER_PERMISSION_ADMIN_ASSIGNED = "user:admin:assigned",
|
||||
USER_PERMISSION_ADMIN_REMOVED = "user:admin:removed",
|
||||
|
|
|
@ -12,6 +12,11 @@ export interface UserDeletedEvent extends BaseEvent {
|
|||
userId: string
|
||||
}
|
||||
|
||||
export interface UserOnboardingEvent extends BaseEvent {
|
||||
userId: string
|
||||
step?: string
|
||||
}
|
||||
|
||||
export interface UserPermissionAssignedEvent extends BaseEvent {
|
||||
userId: string
|
||||
}
|
||||
|
|
|
@ -73,6 +73,10 @@ export const handleSaveEvents = async (
|
|||
await events.user.permissionAdminRemoved(user)
|
||||
}
|
||||
|
||||
if (isOnboardingComplete(user, existingUser)) {
|
||||
await events.user.onboardingComplete(user)
|
||||
}
|
||||
|
||||
if (
|
||||
!existingUser.forceResetPassword &&
|
||||
user.forceResetPassword &&
|
||||
|
@ -114,6 +118,10 @@ const isRemovingAdmin = (user: any, existingUser: any) => {
|
|||
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.
|
||||
*/
|
||||
|
|
Loading…
Reference in New Issue