123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288 |
- import {browserHistory} from 'react-router';
- import {createStore} from 'reflux';
- import getGuidesContent from 'sentry/components/assistant/getGuidesContent';
- import {Guide, GuidesContent, GuidesServerData} from 'sentry/components/assistant/types';
- import {IS_ACCEPTANCE_TEST} from 'sentry/constants';
- import ConfigStore from 'sentry/stores/configStore';
- import HookStore from 'sentry/stores/hookStore';
- import {Organization} from 'sentry/types';
- import {trackAnalytics} from 'sentry/utils/analytics';
- import {CommonStoreDefinition} from './types';
- function guidePrioritySort(a: Guide, b: Guide) {
- const a_priority = a.priority ?? Number.MAX_SAFE_INTEGER;
- const b_priority = b.priority ?? Number.MAX_SAFE_INTEGER;
- if (a_priority === b_priority) {
- return a.guide.localeCompare(b.guide);
- }
- // lower number takes priority
- return a_priority - b_priority;
- }
- export type GuideStoreState = {
- /**
- * Anchors that are currently mounted
- */
- anchors: Set<string>;
- /**
- * The current guide
- */
- currentGuide: Guide | null;
- /**
- * Current step of the current guide
- */
- currentStep: number;
- /**
- * Hides guides that normally would be shown
- */
- forceHide: boolean;
- /**
- * We force show a guide if the URL contains #assistant
- */
- forceShow: boolean;
- /**
- * All tooltip guides
- */
- guides: Guide[];
- /**
- * Current organization id
- */
- orgId: string | null;
- /**
- * Current organization slug
- */
- orgSlug: string | null;
- /**
- * Current Organization
- */
- organization: Organization | null;
- /**
- * The previously shown guide
- */
- prevGuide: Guide | null;
- };
- const defaultState: GuideStoreState = {
- forceHide: false,
- guides: [],
- anchors: new Set(),
- currentGuide: null,
- currentStep: 0,
- orgId: null,
- orgSlug: null,
- organization: null,
- forceShow: false,
- prevGuide: null,
- };
- interface GuideStoreDefinition extends CommonStoreDefinition<GuideStoreState> {
- browserHistoryListener: null | (() => void);
- closeGuide(dismissed?: boolean): void;
- fetchSucceeded(data: GuidesServerData): void;
- nextStep(): void;
- recordCue(guide: string): void;
- registerAnchor(target: string): void;
- setActiveOrganization(data: Organization): void;
- setForceHide(forceHide: boolean): void;
- state: GuideStoreState;
- teardown(): void;
- toStep(step: number): void;
- unregisterAnchor(target: string): void;
- updatePrevGuide(nextGuide: Guide | null): void;
- }
- const storeConfig: GuideStoreDefinition = {
- state: defaultState,
- browserHistoryListener: null,
- init() {
- // XXX: Do not use `this.listenTo` in this store. We avoid usage of reflux
- // listeners due to their leaky nature in tests.
- this.state = defaultState;
- window.addEventListener('load', this.onURLChange, false);
- this.browserHistoryListener = browserHistory.listen(() => this.onURLChange());
- },
- teardown() {
- window.removeEventListener('load', this.onURLChange);
- if (this.browserHistoryListener) {
- this.browserHistoryListener();
- }
- },
- getState() {
- return this.state;
- },
- onURLChange() {
- this.state.forceShow = window.location.hash === '#assistant';
- this.updateCurrentGuide();
- },
- setActiveOrganization(data: Organization) {
- this.state.orgId = data ? data.id : null;
- this.state.orgSlug = data ? data.slug : null;
- this.state.organization = data ? data : null;
- this.updateCurrentGuide();
- },
- fetchSucceeded(data) {
- // It's possible we can get empty responses (seems to be Firefox specific)
- // Do nothing if `data` is empty
- // also, temporarily check data is in the correct format from the updated
- // assistant endpoint
- if (!data || !Array.isArray(data)) {
- return;
- }
- const guidesContent: GuidesContent = getGuidesContent(this.state.orgSlug);
- // map server guide state (i.e. seen status) with guide content
- const guides = guidesContent.reduce((acc: Guide[], content) => {
- const serverGuide = data.find(guide => guide.guide === content.guide);
- serverGuide &&
- acc.push({
- ...content,
- ...serverGuide,
- });
- return acc;
- }, []);
- this.state.guides = guides;
- this.updateCurrentGuide();
- },
- closeGuide(dismissed?: boolean) {
- const {currentGuide, guides} = this.state;
- // update the current guide seen to true or all guides
- // if markOthersAsSeen is true and the user is dismissing
- guides
- .filter(
- guide =>
- guide.guide === currentGuide?.guide ||
- (currentGuide?.markOthersAsSeen && dismissed)
- )
- .forEach(guide => (guide.seen = true));
- this.state.forceShow = false;
- this.updateCurrentGuide();
- },
- nextStep() {
- this.state.currentStep += 1;
- this.trigger(this.state);
- },
- toStep(step: number) {
- this.state.currentStep = step;
- this.trigger(this.state);
- },
- registerAnchor(target) {
- this.state.anchors.add(target);
- this.updateCurrentGuide();
- },
- unregisterAnchor(target) {
- this.state.anchors.delete(target);
- this.updateCurrentGuide();
- },
- setForceHide(forceHide) {
- this.state.forceHide = forceHide;
- this.trigger(this.state);
- },
- recordCue(guide) {
- const user = ConfigStore.get('user');
- if (!user) {
- return;
- }
- trackAnalytics('assistant.guide_cued', {
- organization: this.state.orgId,
- guide,
- });
- },
- updatePrevGuide(nextGuide) {
- const {prevGuide} = this.state;
- if (!nextGuide) {
- return;
- }
- if (!prevGuide || prevGuide.guide !== nextGuide.guide) {
- this.recordCue(nextGuide.guide);
- this.state.prevGuide = nextGuide;
- }
- },
- /**
- * Logic to determine if a guide is shown:
- *
- * - If any required target is missing, don't show the guide
- * - If the URL ends with #assistant, show the guide
- * - If the user has already seen the guide, don't show the guide
- * - Otherwise show the guide
- */
- updateCurrentGuide(dismissed?: boolean) {
- const {anchors, guides, forceShow} = this.state;
- let guideOptions = guides
- .sort(guidePrioritySort)
- .filter(guide => guide.requiredTargets.every(target => anchors.has(target)));
- const user = ConfigStore.get('user');
- const assistantThreshold = new Date(2019, 6, 1);
- const userDateJoined = new Date(user?.dateJoined);
- if (!forceShow) {
- guideOptions = guideOptions.filter(({seen, dateThreshold}) => {
- if (seen) {
- return false;
- }
- if (user?.isSuperuser && !IS_ACCEPTANCE_TEST) {
- return true;
- }
- if (dateThreshold) {
- // Show the guide to users who've joined before the date threshold
- return userDateJoined < dateThreshold;
- }
- return userDateJoined > assistantThreshold;
- });
- }
- // Remove steps that are missing anchors, unless the anchor is included in
- // the expectedTargets and will appear at the step.
- const nextGuide =
- guideOptions.length > 0
- ? {
- ...guideOptions[0],
- steps: guideOptions[0].steps.filter(
- step =>
- anchors.has(step.target) ||
- guideOptions[0]?.expectedTargets?.includes(step.target)
- ),
- }
- : null;
- this.updatePrevGuide(nextGuide);
- this.state.currentStep =
- this.state.currentGuide &&
- nextGuide &&
- this.state.currentGuide.guide === nextGuide.guide
- ? this.state.currentStep
- : 0;
- this.state.currentGuide = nextGuide;
- this.trigger(this.state);
- HookStore.get('callback:on-guide-update').map(cb => cb(nextGuide, {dismissed}));
- },
- };
- const GuideStore = createStore(storeConfig);
- export default GuideStore;
|