mirror of
https://github.com/hanzoai/sentinel.git
synced 2026-09-29 22:34:33 +00:00
311 lines
8.1 KiB
TypeScript
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;
|