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
This commit is contained in:
Shekar Siri 2023-04-11 15:38:44 +02:00 committed by GitHub
parent 578cb1af06
commit 08c5b11e30
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 509 additions and 60 deletions

View file

@ -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 && <DefaultMenuView siteId={siteId} />}
{isPreferences && <PreferencesView />}
<div className={styles.right}>
{boardingCompletion < 75 && !hideDiscover && (
{/* {boardingCompletion < 75 && !hideDiscover && (
<React.Fragment>
<OnboardingExplore onComplete={() => setHideDiscover(true)} />
</React.Fragment>
)}
)} */}
<GettingStartedProgress />
<Notifications />
<div className={cn(styles.userDetails, 'group cursor-pointer')}>

View file

@ -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 (
<svg width={radius * 2} height={radius * 2}>
<circle
cx={radius}
cy={radius}
r={_radius}
stroke={bgColor}
strokeWidth={strokeWidth}
fill="none"
/>
<circle
cx={radius}
cy={radius}
r={_radius}
stroke={progressColor}
strokeWidth={strokeWidth}
strokeDasharray={circumference}
strokeDashoffset={dashOffset + ''}
fill="none"
transform={`rotate(-90 ${radius} ${radius})`}
style={circleStyle}
/>
<text
x={radius}
y={radius}
textAnchor="middle"
dominantBaseline="middle"
fontSize={radius * 0.5}
>
{label}
</text>
</svg>
);
};
export default CircleProgress;

View file

@ -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<Props> = (args) => <GettingStartedModal {...args} />;
export const Default = Template.bind({});
Default.args = {
list,
};

View file

@ -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 (
<>
<Modal.Header title="Setup Openreplay">
<div className="px-4 pt-4">
<div className="text-2xl">Setup Openreplay</div>
<p>Find all the ways in which OpenReplay can benefit you and your product.</p>
</div>
</Modal.Header>
<Modal.Content className="p-4 pb-20">
<StepList title="Pending" steps={pendingSteps} status="pending" />
<StepList title="Completed" steps={completedSteps} status="completed" />
</Modal.Content>
</>
);
}
export default observer(GettingStartedModal);

View file

@ -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<null> = () => {
const { showModal } = useModal();
const {
settingsStore: { gettingStarted },
} = useStore();
useEffect(() => {
gettingStarted.fetchData();
}, []);
const clickHandler = () => {
showModal(<GettingStartedModal list={gettingStarted.steps} />, { right: true, width: 450 });
};
return gettingStarted.status === 'completed' ? null : (
<div className="mr-6 flex items-cetner cursor-pointer hover:bg-active-blue px-4">
<div className="flex items-center cursor-pointer" onClick={clickHandler}>
<CircleProgress
label={gettingStarted.label}
percentage={gettingStarted.percentageCompleted}
/>
<div className="ml-2">
<div className="text-lg color-teal" style={{ lineHeight: '15px' }}>
Setup
</div>
<div className="color-gray-meidum text-sm">{gettingStarted.numPending} Pending</div>
</div>
</div>
</div>
);
};
export default observer(GettingStartedProgress);

View file

@ -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<HTMLAnchorElement>, step: any) => void;
onClick: () => void;
}) => {
const { title, description, status, docsLink } = step;
const isCompleted = status === 'completed';
return (
<div
className={cn('border rounded p-3 mb-4 flex items-start', {
'bg-gray-lightest': isCompleted,
'hover:bg-active-blue': !isCompleted,
})}
>
<div className="w-10 mt-1 shrink-0">
<Icon
name={isCompleted ? 'check-circle-fill' : 'check-circle'}
size={20}
color={isCompleted ? 'teal' : 'gray-dark'}
/>
</div>
<div>
<div className={cn('font-medium', { link: !isCompleted })} onClick={!isCompleted ? onClick : () => {}}>{title}</div>
<div className="text-sm">{description}</div>
<div className="flex gap-6 mt-3">
<a className="link" href={docsLink} target="_blank">
Docs
</a>
{!isCompleted && (
<a className="link" onClick={(e) => onIgnore(e, step)}>
Ignore
</a>
)}
</div>
</div>
</div>
);
}
);
const StepList = React.memo((props: StepListProps) => {
const { title, steps, status } = props;
const { hideModal } = useModal();
const {
settingsStore: { gettingStarted },
} = useStore();
const onIgnore = (e: React.MouseEvent<HTMLAnchorElement>, 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 (
<div className="my-3">
<div className="text-lg font-medium mb-2">
{title} {steps.length}
</div>
{steps.map((step) => (
<StepItem key={step.title} onIgnore={onIgnore} step={step} onClick={() => onClick(step)}/>
))}
</div>
);
});
export default connect((state: any) => ({
siteId: state.getIn(['site', 'siteId']),
}))(withRouter(StepList));

View file

@ -0,0 +1 @@
export { default } from './GettingStartedModal';

View file

@ -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$__"
export const SITE_ID_STORAGE_KEY = "__$user-siteId$__"
export const GETTING_STARTED = "__$user-gettingStarted$__"

View file

@ -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<any> {
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<IWebhook> | 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<IWebhook>) => {
Object.assign(this.webhookInst, diff)
}
Object.assign(this.webhookInst, diff);
};
}

View file

@ -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;
}
}

View file

@ -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<any> {
return this.client.get('/boarding')
.then(r => r.json()).then(j => j.data)
}
}