(core) Direct users to last visited site when possible

Summary:
When clicking the logo in the top-left corner, or finishing a tutorial, we
now direct users to the site they last visited, if possible. If unknown, a
new redirect endpoint, /welcome/home, is used instead, which directs users
to a sensible location based on the number of sites they have.

Test Plan: Browser tests.

Reviewers: paulfitz

Reviewed By: paulfitz

Differential Revision: https://phab.getgrist.com/D3878
This commit is contained in:
George Gevoian 2023-05-01 11:24:23 -07:00
parent 65013331a3
commit 959f8a45c6
7 changed files with 109 additions and 28 deletions

View File

@ -26,14 +26,24 @@ export function sessionStorageBoolObs(key: string, defValue = false): Observable
return getStorageBoolObs(getSessionStorage(), key, defValue); return getStorageBoolObs(getSessionStorage(), key, defValue);
} }
function getStorageObs(store: Storage, key: string, defaultValue?: string) {
const obs = Observable.create<string|null>(null, store.getItem(key) ?? defaultValue ?? null);
obs.addListener((val) => (val === null) ? store.removeItem(key) : store.setItem(key, val));
return obs;
}
/** /**
* Helper to create a string observable whose state is stored in localStorage. * Helper to create a string observable whose state is stored in localStorage.
*/ */
export function localStorageObs(key: string, defaultValue?: string): Observable<string|null> { export function localStorageObs(key: string, defaultValue?: string): Observable<string|null> {
const store = getStorage(); return getStorageObs(getStorage(), key, defaultValue);
const obs = Observable.create<string|null>(null, store.getItem(key) ?? defaultValue ?? null); }
obs.addListener((val) => (val === null) ? store.removeItem(key) : store.setItem(key, val));
return obs; /**
* Similar to `localStorageObs`, but always uses sessionStorage (or an in-memory equivalent).
*/
export function sessionStorageObs(key: string, defaultValue?: string): Observable<string|null> {
return getStorageObs(getSessionStorage(), key, defaultValue);
} }
/** /**

View File

@ -1,6 +1,7 @@
import {BehavioralPromptsManager} from 'app/client/components/BehavioralPromptsManager'; import {BehavioralPromptsManager} from 'app/client/components/BehavioralPromptsManager';
import {get as getBrowserGlobals} from 'app/client/lib/browserGlobals'; import {get as getBrowserGlobals} from 'app/client/lib/browserGlobals';
import {makeT} from 'app/client/lib/localization'; import {makeT} from 'app/client/lib/localization';
import {sessionStorageObs} from 'app/client/lib/localStorageObs';
import {error} from 'app/client/lib/log'; import {error} from 'app/client/lib/log';
import {reportError, setErrorNotifier} from 'app/client/models/errors'; import {reportError, setErrorNotifier} from 'app/client/models/errors';
import {urlState} from 'app/client/models/gristUrlState'; import {urlState} from 'app/client/models/gristUrlState';
@ -83,6 +84,7 @@ export interface AppModel {
isTeamSite: boolean; // Is it a team site? isTeamSite: boolean; // Is it a team site?
isLegacySite: boolean; // Is it a legacy site? isLegacySite: boolean; // Is it a legacy site?
orgError?: OrgError; // If currentOrg is null, the error that caused it. orgError?: OrgError; // If currentOrg is null, the error that caused it.
lastVisitedOrgDomain: Observable<string|null>;
currentProduct: Product|null; // The current org's product. currentProduct: Product|null; // The current org's product.
currentFeatures: Features; // Features of the current org's product. currentFeatures: Features; // Features of the current org's product.
@ -227,6 +229,8 @@ export class AppModelImpl extends Disposable implements AppModel {
public readonly currentOrgUsage: Observable<OrgUsageSummary|null> = Observable.create(this, null); public readonly currentOrgUsage: Observable<OrgUsageSummary|null> = Observable.create(this, null);
public readonly lastVisitedOrgDomain = this.autoDispose(sessionStorageObs('grist-last-visited-org-domain'));
public readonly currentProduct = this.currentOrg?.billingAccount?.product ?? null; public readonly currentProduct = this.currentOrg?.billingAccount?.product ?? null;
public readonly currentFeatures = this.currentProduct?.features ?? {}; public readonly currentFeatures = this.currentProduct?.features ?? {};
@ -284,6 +288,14 @@ export class AppModelImpl extends Disposable implements AppModel {
this.dismissedPopups.set(seen ? DismissedPopup.values : []); this.dismissedPopups.set(seen ? DismissedPopup.values : []);
this.behavioralPromptsManager.reset(); this.behavioralPromptsManager.reset();
}; };
this.autoDispose(subscribe(urlState().state, async (_use, {doc, org}) => {
// Keep track of the last valid org domain the user visited, ignoring those
// with a document id in the URL.
if (!this.currentOrg || doc) { return; }
this.lastVisitedOrgDomain.set(org ?? null);
}));
} }
public get planName() { public get planName() {

View File

@ -85,6 +85,10 @@ export function getLoginOrSignupUrl(nextUrl: string = _getCurrentUrl()): string
return _getLoginLogoutUrl('signin', nextUrl); return _getLoginLogoutUrl('signin', nextUrl);
} }
export function getWelcomeHomeUrl() {
return _buildUrl('welcome/home').href;
}
// Returns the relative URL (i.e. path) of the current page, except when it's the // Returns the relative URL (i.e. path) of the current page, except when it's the
// "/signed-out" page, in which case it returns the home page ("/"). // "/signed-out" page, in which case it returns the home page ("/").
// This is a good URL to use for a post-login redirect. // This is a good URL to use for a post-login redirect.
@ -97,14 +101,19 @@ function _getCurrentUrl(): string {
// Returns the URL for the given login page, with 'next' param optionally set. // Returns the URL for the given login page, with 'next' param optionally set.
function _getLoginLogoutUrl(page: 'login'|'logout'|'signin'|'signup', nextUrl?: string | null): string { function _getLoginLogoutUrl(page: 'login'|'logout'|'signin'|'signup', nextUrl?: string | null): string {
const startUrl = new URL(window.location.href); const startUrl = _buildUrl(page);
startUrl.pathname = addOrgToPath('', window.location.href, true) + '/' + page;
startUrl.search = '';
startUrl.hash = '';
if (nextUrl) { startUrl.searchParams.set('next', nextUrl); } if (nextUrl) { startUrl.searchParams.set('next', nextUrl); }
return startUrl.href; return startUrl.href;
} }
function _buildUrl(page?: string): URL {
const startUrl = new URL(window.location.href);
startUrl.pathname = addOrgToPath('', window.location.href, true) + '/' + (page ?? '');
startUrl.search = '';
startUrl.hash = '';
return startUrl;
}
/** /**
* Implements the interface expected by UrlState. It is only exported for the sake of tests; the * Implements the interface expected by UrlState. It is only exported for the sake of tests; the
* only public interface is the urlState() accessor. * only public interface is the urlState() accessor.

View File

@ -1,4 +1,4 @@
import {urlState} from 'app/client/models/gristUrlState'; import {getWelcomeHomeUrl, urlState} from 'app/client/models/gristUrlState';
import {buildAppMenuBillingItem} from 'app/client/ui/BillingButtons'; import {buildAppMenuBillingItem} from 'app/client/ui/BillingButtons';
import {getTheme} from 'app/client/ui/CustomThemes'; import {getTheme} from 'app/client/ui/CustomThemes';
import {cssLeftPane} from 'app/client/ui/PagePanels'; import {cssLeftPane} from 'app/client/ui/PagePanels';
@ -48,7 +48,7 @@ export class AppHeader extends Disposable {
cssAppLogo( cssAppLogo(
{title: `Version ${version.version}` + {title: `Version ${version.version}` +
((version.gitcommit as string) !== 'unknown' ? ` (${version.gitcommit})` : '')}, ((version.gitcommit as string) !== 'unknown' ? ` (${version.gitcommit})` : '')},
urlState().setLinkUrl({}), this._setHomePageUrl(),
testId('dm-logo') testId('dm-logo')
), ),
cssOrg( cssOrg(
@ -79,6 +79,15 @@ export class AppHeader extends Disposable {
), ),
); );
} }
private _setHomePageUrl() {
const lastVisitedOrg = this._appModel.lastVisitedOrgDomain.get();
if (lastVisitedOrg) {
return urlState().setLinkUrl({org: lastVisitedOrg});
} else {
return {href: getWelcomeHomeUrl()};
}
}
} }
export function productPill(org: Organization|null, options: {large?: boolean} = {}): DomContents { export function productPill(org: Organization|null, options: {large?: boolean} = {}): DomContents {

View File

@ -1,5 +1,5 @@
import {GristDoc} from 'app/client/components/GristDoc'; import {GristDoc} from 'app/client/components/GristDoc';
import {urlState} from 'app/client/models/gristUrlState'; import {getWelcomeHomeUrl, urlState} from 'app/client/models/gristUrlState';
import {renderer} from 'app/client/ui/DocTutorialRenderer'; import {renderer} from 'app/client/ui/DocTutorialRenderer';
import {cssPopupBody, FloatingPopup} from 'app/client/ui/FloatingPopup'; import {cssPopupBody, FloatingPopup} from 'app/client/ui/FloatingPopup';
import {sanitizeHTML} from 'app/client/ui/sanitizeHTML'; import {sanitizeHTML} from 'app/client/ui/sanitizeHTML';
@ -242,7 +242,12 @@ export class DocTutorial extends FloatingPopup {
private async _finishTutorial() { private async _finishTutorial() {
this._saveCurrentSlidePositionDebounced.cancel(); this._saveCurrentSlidePositionDebounced.cancel();
await this._saveCurrentSlidePosition(); await this._saveCurrentSlidePosition();
await urlState().pushUrl({}); const lastVisitedOrg = this._appModel.lastVisitedOrgDomain.get();
if (lastVisitedOrg) {
await urlState().pushUrl({org: lastVisitedOrg});
} else {
window.location.assign(getWelcomeHomeUrl());
}
} }
private async _restartTutorial() { private async _restartTutorial() {

View File

@ -1239,20 +1239,32 @@ export class FlexServer implements GristServer {
return this._redirectToLoginOrSignup({ return this._redirectToLoginOrSignup({
nextUrl: new URL(getOrgUrl(req, '/welcome/start')), nextUrl: new URL(getOrgUrl(req, '/welcome/start')),
}, req, resp); }, req, resp);
} else {
const userId = getUserId(req);
const domain = getOrgFromRequest(req);
const orgs = this._dbManager.unwrapQueryResult(
await this._dbManager.getOrgs(userId, domain, {
ignoreEveryoneShares: true,
})
);
if (orgs.length > 1) {
resp.redirect(getOrgUrl(req, '/welcome/teams'));
} else {
resp.redirect(getOrgUrl(req));
}
} }
await this._redirectToHomeOrWelcomePage(req as RequestWithLogin, resp);
}));
/**
* Like /welcome/start, but doesn't redirect anonymous users to sign in.
*
* Used by the client when the last site the user visited is unknown, and
* a suitable site is needed for the home page.
*
* For example, on templates.getgrist.com it does:
* 1) If logged in and no team site -> https://docs.getgrist.com/
* 2) If logged in and has team sites -> https://docs.getgrist.com/welcome/teams
* 3) If logged out -> https://docs.getgrist.com/
*/
this.app.get('/welcome/home', [
this._redirectToHostMiddleware,
this._userIdMiddleware,
], expressWrap(async (req, resp) => {
const mreq = req as RequestWithLogin;
if (isAnonymousUser(req)) {
return resp.redirect(this.getMergedOrgUrl(mreq));
}
await this._redirectToHomeOrWelcomePage(mreq, resp, {redirectToMergedOrg: true});
})); }));
this.app.post('/welcome/info', ...middleware, expressWrap(async (req, resp, next) => { this.app.post('/welcome/info', ...middleware, expressWrap(async (req, resp, next) => {
@ -1803,6 +1815,26 @@ export class FlexServer implements GristServer {
const getRedirectUrl = signUp ? this._getSignUpRedirectUrl : this._getLoginRedirectUrl; const getRedirectUrl = signUp ? this._getSignUpRedirectUrl : this._getLoginRedirectUrl;
resp.redirect(await getRedirectUrl(req, nextUrl)); resp.redirect(await getRedirectUrl(req, nextUrl));
} }
private async _redirectToHomeOrWelcomePage(
mreq: RequestWithLogin,
resp: express.Response,
options: {redirectToMergedOrg?: boolean} = {}
) {
const {redirectToMergedOrg} = options;
const userId = getUserId(mreq);
const domain = getOrgFromRequest(mreq);
const orgs = this._dbManager.unwrapQueryResult(
await this._dbManager.getOrgs(userId, domain, {
ignoreEveryoneShares: true,
})
);
if (orgs.length > 1) {
resp.redirect(getOrgUrl(mreq, '/welcome/teams'));
} else {
resp.redirect(redirectToMergedOrg ? this.getMergedOrgUrl(mreq) : getOrgUrl(mreq));
}
}
} }
/** /**

View File

@ -520,10 +520,14 @@ describe('DocTutorial', function () {
assert.deepEqual(await gu.getPageNames(), ['Page 1', 'Page 2', 'NewTable']); assert.deepEqual(await gu.getPageNames(), ['Page 1', 'Page 2', 'NewTable']);
}); });
it('redirects to the doc menu when finished', async function() { it('redirects to the last visited site when finished', async function() {
await driver.find('.test-doc-tutorial-popup-slide-13').click(); const otherSession = await gu.session().personalSite.user('user1').addLogin();
await otherSession.loadDocMenu('/');
await session.loadDoc(`/doc/${doc.id}`);
await driver.findWait('.test-doc-tutorial-popup-slide-13', 2000).click();
await driver.find('.test-doc-tutorial-popup-next').click(); await driver.find('.test-doc-tutorial-popup-next').click();
await driver.findWait('.test-dm-doclist', 2000); await gu.waitForDocMenuToLoad();
assert.match(await driver.getCurrentUrl(), /o\/docs\/$/);
}); });
}); });