Files
2024-02-15 17:01:50 +00:00

311 lines
8.1 KiB
TypeScript

import {browserHistory} from 'react-router';
import {createStore} from 'reflux';
import getGuidesContent from 'sentry/components/assistant/getGuidesContent';
import type {
Guide,
GuidesContent,
GuidesServerData,
} from 'sentry/components/assistant/types';
import ConfigStore from 'sentry/stores/configStore';
import HookStore from 'sentry/stores/hookStore';
import ModalStore from 'sentry/stores/modalStore';
import type {Organization} from 'sentry/types';
import {trackAnalytics} from 'sentry/utils/analytics';
import type {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;
init(): void;
modalStoreListener: null | Function;
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,
modalStoreListener: 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());
// Guides will show above modals, but are not interactable because
// of the focus trap, so we force them to be hidden while a modal is open.
this.modalStoreListener = ModalStore.listen(() => {
const isOpen = typeof ModalStore.getState().renderer === 'function';
if (isOpen) {
this.setForceHide(true);
} else {
this.setForceHide(false);
}
}, undefined);
},
teardown() {
window.removeEventListener('load', this.onURLChange);
if (this.browserHistoryListener) {
this.browserHistoryListener();
}
if (this.modalStoreListener) {
this.modalStoreListener();
}
},
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) {
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;