From 08c5b11e30b6f18810dfcd300a8eee3a4d890341 Mon Sep 17 00:00:00 2001 From: Shekar Siri Date: Tue, 11 Apr 2023 15:38:44 +0200 Subject: [PATCH] feat(ui) - getting started (#1148) * feat(ui) - getting started - wip * feat(ui) - getting started - wip * feat(ui) - getting started - wip * feat(ui) - getting started - wip * change(ui) - getting started * change(ui) - getting started - css changes --- frontend/app/components/Header/Header.js | 6 +- .../shared/GettingStarted/CircleProgress.tsx | 72 +++++++++ .../GettingStarted/GettingStarted.stories.tsx | 44 ++++++ .../GettingStarted/GettingStartedModal.tsx | 36 +++++ .../GettingStarted/GettingStartedProgress.tsx | 40 +++++ .../shared/GettingStarted/StepList.tsx | 103 +++++++++++++ .../components/shared/GettingStarted/index.ts | 1 + frontend/app/constants/storageKeys.ts | 3 +- frontend/app/mstore/settingsStore.ts | 118 ++++++++------- frontend/app/mstore/types/gettingStarted.ts | 141 ++++++++++++++++++ frontend/app/services/ConfigService.ts | 5 + 11 files changed, 509 insertions(+), 60 deletions(-) create mode 100644 frontend/app/components/shared/GettingStarted/CircleProgress.tsx create mode 100644 frontend/app/components/shared/GettingStarted/GettingStarted.stories.tsx create mode 100644 frontend/app/components/shared/GettingStarted/GettingStartedModal.tsx create mode 100644 frontend/app/components/shared/GettingStarted/GettingStartedProgress.tsx create mode 100644 frontend/app/components/shared/GettingStarted/StepList.tsx create mode 100644 frontend/app/components/shared/GettingStarted/index.ts create mode 100644 frontend/app/mstore/types/gettingStarted.ts diff --git a/frontend/app/components/Header/Header.js b/frontend/app/components/Header/Header.js index 021f96df3..f26d23229 100644 --- a/frontend/app/components/Header/Header.js +++ b/frontend/app/components/Header/Header.js @@ -20,6 +20,7 @@ import SettingsMenu from './SettingsMenu'; import DefaultMenuView from './DefaultMenuView'; import PreferencesView from './PreferencesView'; import HealthStatus from './HealthStatus' +import GettingStartedProgress from 'Shared/GettingStarted/GettingStartedProgress'; const CLIENT_PATH = client(CLIENT_DEFAULT_TAB); @@ -60,11 +61,12 @@ const Header = (props) => { {!isPreferences && } {isPreferences && }
- {boardingCompletion < 75 && !hideDiscover && ( + {/* {boardingCompletion < 75 && !hideDiscover && ( setHideDiscover(true)} /> - )} + )} */} +
diff --git a/frontend/app/components/shared/GettingStarted/CircleProgress.tsx b/frontend/app/components/shared/GettingStarted/CircleProgress.tsx new file mode 100644 index 000000000..9504dfbc2 --- /dev/null +++ b/frontend/app/components/shared/GettingStarted/CircleProgress.tsx @@ -0,0 +1,72 @@ +import React, { useEffect, useState } from 'react'; + +interface Props { + percentage: number; + radius?: number; + progressColor?: string; + bgColor?: string; + label?: string; +} +const CircleProgress = ({ + percentage = 0, + radius = 18, + progressColor = '#394eff', + bgColor = '#9fa8da', + label = '', +}: Props) => { + const [offset, setOffset] = useState(0); + + useEffect(() => { + const progress = percentage / 100; + const _radius = radius * 0.8; + const circumference = 2 * Math.PI * _radius; + const offsetValue = circumference * (1 - progress); + + setOffset(offsetValue); + }, [percentage, radius]); + + const strokeWidth = radius * 0.3; + const _radius = radius * 0.8; + + const circumference = 2 * Math.PI * _radius; + const dashOffset = circumference * (1 - (percentage / 100)); + const circleStyle = { + transition: 'stroke-dashoffset 1s ease-in-out', + }; + + return ( + + + + + {label} + + + ); +}; + +export default CircleProgress; diff --git a/frontend/app/components/shared/GettingStarted/GettingStarted.stories.tsx b/frontend/app/components/shared/GettingStarted/GettingStarted.stories.tsx new file mode 100644 index 000000000..1892b3893 --- /dev/null +++ b/frontend/app/components/shared/GettingStarted/GettingStarted.stories.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import { Story, Meta } from '@storybook/react'; + +import GettingStartedModal, { Props } from './GettingStartedModal'; +import { Step } from './StepList'; + +const list: Step[] = [ + { + title: '🕵️ Install OpenReplay', + status: 'pending', + description: 'Install OpenReplay on your website or mobile app.', + icon: 'tools', + }, + { + title: '🕵️ Identify Users', + status: 'pending', + description: 'Identify users across devices and sessions.', + icon: 'users', + }, + { + title: '🕵️ Integrations', + status: 'completed', + description: 'Identify users across devices and sessions.', + icon: 'users', + }, + { + title: '🕵️ Invite Team Members', + status: 'ignored', + description: 'Identify users across devices and sessions.', + icon: 'users', + }, +]; + +export default { + title: 'GettingStarted', + component: GettingStartedModal, +} as Meta; + +const Template: Story = (args) => ; + +export const Default = Template.bind({}); +Default.args = { + list, +}; diff --git a/frontend/app/components/shared/GettingStarted/GettingStartedModal.tsx b/frontend/app/components/shared/GettingStarted/GettingStartedModal.tsx new file mode 100644 index 000000000..dd5926b88 --- /dev/null +++ b/frontend/app/components/shared/GettingStarted/GettingStartedModal.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import StepList, { Step } from './StepList'; +import Modal from 'App/components/Modal/Modal'; +import CircleProgress from './CircleProgress'; +import GettingStartedProgress from './GettingStartedProgress'; +import { observer } from 'mobx-react-lite'; + +export interface Props { + list: Step[]; +} + +function GettingStartedModal(props: Props) { + const { list } = props; + const pendingSteps = list.filter((step) => step.status === 'pending'); + const completedSteps = list.filter( + (step) => step.status === 'completed' || step.status === 'ignored' + ); + + return ( + <> + +
+
Setup Openreplay
+

Find all the ways in which OpenReplay can benefit you and your product.

+
+
+ + + + + + + ); +} + +export default observer(GettingStartedModal); diff --git a/frontend/app/components/shared/GettingStarted/GettingStartedProgress.tsx b/frontend/app/components/shared/GettingStarted/GettingStartedProgress.tsx new file mode 100644 index 000000000..6ebbb5412 --- /dev/null +++ b/frontend/app/components/shared/GettingStarted/GettingStartedProgress.tsx @@ -0,0 +1,40 @@ +import React, { useEffect } from 'react'; +import CircleProgress from './CircleProgress'; +import { useModal } from 'App/components/Modal'; +import GettingStartedModal from './GettingStartedModal'; +import { useStore } from 'App/mstore'; +import { observer } from 'mobx-react-lite'; + +const GettingStartedProgress: React.FC = () => { + const { showModal } = useModal(); + + const { + settingsStore: { gettingStarted }, + } = useStore(); + + useEffect(() => { + gettingStarted.fetchData(); + }, []); + + const clickHandler = () => { + showModal(, { right: true, width: 450 }); + }; + return gettingStarted.status === 'completed' ? null : ( +
+
+ +
+
+ Setup +
+
{gettingStarted.numPending} Pending
+
+
+
+ ); +}; + +export default observer(GettingStartedProgress); diff --git a/frontend/app/components/shared/GettingStarted/StepList.tsx b/frontend/app/components/shared/GettingStarted/StepList.tsx new file mode 100644 index 000000000..442f112f2 --- /dev/null +++ b/frontend/app/components/shared/GettingStarted/StepList.tsx @@ -0,0 +1,103 @@ +import React from 'react'; +import { Icon } from 'UI'; +import cn from 'classnames'; +import { Step } from 'App/mstore/types/gettingStarted'; +import { useStore } from 'App/mstore'; +import { onboarding as onboardingRoute, withSiteId } from 'App/routes'; +import { RouteComponentProps, withRouter } from 'react-router'; +import { connect } from 'react-redux'; +import { useModal } from 'App/components/Modal'; + +interface StepListProps extends RouteComponentProps { + title: string; + steps: Step[]; + status: 'pending' | 'completed'; + docsLink?: string; + siteId: string; +} + +const StepItem = React.memo( + ({ + step, + onClick, + onIgnore, + }: { + step: Step; + onIgnore: (e: React.MouseEvent, step: any) => void; + onClick: () => void; + }) => { + const { title, description, status, docsLink } = step; + const isCompleted = status === 'completed'; + + return ( +
+
+ +
+
+
{}}>{title}
+
{description}
+
+ + Docs + + {!isCompleted && ( + onIgnore(e, step)}> + Ignore + + )} +
+
+
+ ); + } +); + +const StepList = React.memo((props: StepListProps) => { + const { title, steps, status } = props; + const { hideModal } = useModal(); + + const { + settingsStore: { gettingStarted }, + } = useStore(); + + const onIgnore = (e: React.MouseEvent, step: any) => { + e.preventDefault(); + gettingStarted.completeStep(step); + }; + + if (steps.length === 0) { + return null; + } + + const onClick = (step: any) => { + const { siteId, history } = props; + console.log('step', withSiteId(onboardingRoute(step.url), siteId)); + hideModal(); + history.push(withSiteId(onboardingRoute(step.url), siteId)); + }; + + return ( +
+
+ {title} {steps.length} +
+ {steps.map((step) => ( + onClick(step)}/> + ))} +
+ ); +}); + +export default connect((state: any) => ({ + siteId: state.getIn(['site', 'siteId']), +}))(withRouter(StepList)); diff --git a/frontend/app/components/shared/GettingStarted/index.ts b/frontend/app/components/shared/GettingStarted/index.ts new file mode 100644 index 000000000..449316b96 --- /dev/null +++ b/frontend/app/components/shared/GettingStarted/index.ts @@ -0,0 +1 @@ +export { default } from './GettingStartedModal'; \ No newline at end of file diff --git a/frontend/app/constants/storageKeys.ts b/frontend/app/constants/storageKeys.ts index e06625fb6..70a930618 100644 --- a/frontend/app/constants/storageKeys.ts +++ b/frontend/app/constants/storageKeys.ts @@ -4,4 +4,5 @@ export const DURATION_FILTER = "__$session-durationFilter$__" export const SESSION_FILTER = "__$session-filter$__" export const GLOBAL_DESTINATION_PATH = "__$global-destinationPath$__" export const GLOBAL_HAS_NO_RECORDINGS = "__$global-hasNoRecordings$__" -export const SITE_ID_STORAGE_KEY = "__$user-siteId$__" \ No newline at end of file +export const SITE_ID_STORAGE_KEY = "__$user-siteId$__" +export const GETTING_STARTED = "__$user-gettingStarted$__" \ No newline at end of file diff --git a/frontend/app/mstore/settingsStore.ts b/frontend/app/mstore/settingsStore.ts index c31071694..641167dd5 100644 --- a/frontend/app/mstore/settingsStore.ts +++ b/frontend/app/mstore/settingsStore.ts @@ -1,98 +1,102 @@ -import { makeAutoObservable, observable } from "mobx" -import SessionSettings from "./types/sessionSettings" -import { sessionService } from "App/services" +import { makeAutoObservable, observable } from 'mobx'; +import SessionSettings from './types/sessionSettings'; +import { sessionService } from 'App/services'; import { toast } from 'react-toastify'; import Webhook, { IWebhook } from 'Types/webhook'; -import { - webhookService -} from 'App/services'; +import { webhookService } from 'App/services'; +import { GettingStarted } from './types/gettingStarted'; export default class SettingsStore { loadingCaptureRate: boolean = false; - sessionSettings: SessionSettings = new SessionSettings() + sessionSettings: SessionSettings = new SessionSettings(); captureRateFetched: boolean = false; limits: any = null; - - webhooks: Webhook[] = [] - webhookInst = new Webhook() - - hooksLoading = false + webhooks: Webhook[] = []; + webhookInst = new Webhook(); + hooksLoading = false; + gettingStarted: GettingStarted = new GettingStarted(); constructor() { makeAutoObservable(this, { sessionSettings: observable, - }) + }); } saveCaptureRate(data: any) { - return sessionService.saveCaptureRate(data) - .then(data => data.json()) + return sessionService + .saveCaptureRate(data) + .then((data) => data.json()) .then(({ data }) => { this.sessionSettings.merge({ captureRate: data.rate, - captureAll: data.captureAll - }) - toast.success("Settings updated successfully"); - }).catch(err => { - toast.error("Error saving capture rate"); + captureAll: data.captureAll, + }); + toast.success('Settings updated successfully'); }) + .catch((err) => { + toast.error('Error saving capture rate'); + }); } fetchCaptureRate(): Promise { this.loadingCaptureRate = true; - return sessionService.fetchCaptureRate() - .then(data => { + return sessionService + .fetchCaptureRate() + .then((data) => { this.sessionSettings.merge({ captureRate: data.rate, - captureAll: data.captureAll - }) + captureAll: data.captureAll, + }); this.captureRateFetched = true; - }).finally(() => { - this.loadingCaptureRate = false; }) + .finally(() => { + this.loadingCaptureRate = false; + }); } fetchWebhooks = () => { - this.hooksLoading = true - return webhookService.fetchList() - .then(data => { - this.webhooks = data.map(hook => new Webhook(hook)) - this.hooksLoading = false - }) - } + this.hooksLoading = true; + return webhookService.fetchList().then((data) => { + this.webhooks = data.map((hook) => new Webhook(hook)); + this.hooksLoading = false; + }); + }; initWebhook = (inst?: Partial | Webhook) => { - this.webhookInst = inst instanceof Webhook ? inst : new Webhook(inst) - } + this.webhookInst = inst instanceof Webhook ? inst : new Webhook(inst); + }; saveWebhook = (inst: Webhook) => { - this.hooksLoading = true - return webhookService.saveWebhook(inst) - .then(data => { - this.webhookInst = new Webhook(data) - if (inst.webhookId === undefined) this.setWebhooks([...this.webhooks, this.webhookInst]) - else this.setWebhooks([...this.webhooks.filter(hook => hook.webhookId !== data.webhookId), this.webhookInst]) - - }) - .finally(() => { - this.hooksLoading = false + this.hooksLoading = true; + return webhookService + .saveWebhook(inst) + .then((data) => { + this.webhookInst = new Webhook(data); + if (inst.webhookId === undefined) this.setWebhooks([...this.webhooks, this.webhookInst]); + else + this.setWebhooks([ + ...this.webhooks.filter((hook) => hook.webhookId !== data.webhookId), + this.webhookInst, + ]); }) - } + .finally(() => { + this.hooksLoading = false; + }); + }; setWebhooks = (webhooks: Webhook[]) => { - this.webhooks = webhooks - } + this.webhooks = webhooks; + }; removeWebhook = (hookId: string) => { - this.hooksLoading = true - return webhookService.removeWebhook(hookId) - .then(() => { - this.webhooks = this.webhooks.filter(hook => hook.webhookId!== hookId) - this.hooksLoading = false - }) - } + this.hooksLoading = true; + return webhookService.removeWebhook(hookId).then(() => { + this.webhooks = this.webhooks.filter((hook) => hook.webhookId !== hookId); + this.hooksLoading = false; + }); + }; editWebhook = (diff: Partial) => { - Object.assign(this.webhookInst, diff) - } + Object.assign(this.webhookInst, diff); + }; } diff --git a/frontend/app/mstore/types/gettingStarted.ts b/frontend/app/mstore/types/gettingStarted.ts new file mode 100644 index 000000000..ddff89d61 --- /dev/null +++ b/frontend/app/mstore/types/gettingStarted.ts @@ -0,0 +1,141 @@ +import { action, computed, makeObservable, observable } from 'mobx'; +import { configService } from 'App/services'; +import { GETTING_STARTED } from 'App/constants/storageKeys'; + +const stepsMap: any = { + 'Install OpenReplay': { + title: '🛠️ Install OpenReplay', + status: 'pending', + description: 'Install via script or NPM package', + docsLink: 'https://docs.openreplay.com/en/sdk/constructor/', + url: 'installing', + }, + 'Identify Users': { + title: '🕵️ Identify Users', + status: 'pending', + description: 'Filter sessions by user ID.', + docsLink: 'https://docs.openreplay.com/en/v1.10.0/installation/identify-user/', + url: 'identify-users', + }, + 'Invite Team Members': { + title: '🧑‍💻 Invite Team Members', + status: 'pending', + description: 'Invite team members, collaborate and start improving your app now.', + docsLink: 'https://docs.openreplay.com/en/tutorials/adding-users/', + url: 'team', + }, + Integrations: { + title: '🔌 Integrations', + status: 'pending', + description: 'Sync your backend errors with sessions replays.', + docsLink: 'https://docs.openreplay.com/en/integrations/', + url: 'integrations', + }, +}; +export interface Step { + title: string; + status: 'pending' | 'ignored' | 'completed'; + description: string; + url: string; + docsLink: string; +} + +export class GettingStarted { + steps: Step[] = []; + status: 'in-progress' | 'completed'; + + constructor() { + makeObservable(this, { + steps: observable, + completeStep: action, + status: observable, + fetchData: action, + numCompleted: computed, + numPending: computed, + percentageCompleted: computed, + label: computed, + numPendingSteps: computed, + }); + + // steps = {'tenatId': {steps: [], status: 'in-progress'} + const gettingStartedSteps = localStorage.getItem(GETTING_STARTED); + if (gettingStartedSteps) { + const steps = JSON.parse(gettingStartedSteps); + this.steps = steps.steps; + this.status = steps.status; + } + } + + fetchData() { + if (this.status === 'completed') { + return; + } + configService.fetchGettingStarted().then((data) => { + this.steps = data.map((item: any) => { + const step = stepsMap[item.task]; + + return { + ...step, + status: item.done ? 'completed' : 'pending', + }; + }); + this.status = this.calculateStatus(); + this.updateLocalStorage(); + }); + } + + updateLocalStorage() { + localStorage.setItem( + GETTING_STARTED, + JSON.stringify({ + steps: this.steps.map((item: any) => ({ + title: item.title, + status: item.status, + })), + status: this.status, + }) + ); + } + + calculateStatus() { + const numCompleted = this.numCompleted; + const numPending = this.numPending; + const numIgnored = this.steps.length - numCompleted - numPending; + + if (numIgnored > 0) { + return 'in-progress'; + } else { + return numPending > 0 ? 'in-progress' : 'completed'; + } + } + + completeStep(step: Step) { + step.status = 'completed'; + this.status = this.calculateStatus(); + this.updateLocalStorage(); + } + + get numCompleted() { + return this.steps.filter((step) => step.status === 'completed').length; + } + + get numPending() { + return this.steps.filter((step) => step.status === 'pending').length; + } + + get percentageCompleted() { + const completed = this.numCompleted; + const total = this.steps.length; + return Math.round((completed / total) * 100); + } + + get label() { + const completed = this.numCompleted; + const total = this.steps.length; + return `${completed}/${total}`; + } + + get numPendingSteps() { + return this.steps.filter((step) => step.status === 'pending').length; + } +} diff --git a/frontend/app/services/ConfigService.ts b/frontend/app/services/ConfigService.ts index 6afda4858..676987e49 100644 --- a/frontend/app/services/ConfigService.ts +++ b/frontend/app/services/ConfigService.ts @@ -14,4 +14,9 @@ export default class ConfigService extends BaseService { return this.client.post('/config/weekly_report', config) .then(r => r.json()).then(j => j.data) } + + async fetchGettingStarted(): Promise { + return this.client.get('/boarding') + .then(r => r.json()).then(j => j.data) + } } \ No newline at end of file