feat(settings): display alert messages from login in new settings

Some actions in fxa-content-server end up displaying an alert message on the settings page. We want to be able to display those messages across the "app" boundary between the content-server front-end and new settings.

To do this we store the message in localStorage and whichever app gets to it first will display it and remove it from storage.
This commit is contained in:
Danny Coates 2020-08-10 14:06:15 -07:00
Родитель 226c677577
Коммит 4de585d4d4
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4C442633C62E00CB
12 изменённых файлов: 157 добавлений и 19 удалений

Просмотреть файл

@ -55,6 +55,7 @@ const PERSISTENT = {
sessionTokenContext: undefined,
uid: undefined,
verified: undefined,
alertText: undefined,
};
const DEFAULTS = _.extend(

Просмотреть файл

@ -435,6 +435,10 @@ var BaseView = Backbone.View.extend({
if (success) {
this.displaySuccess(success);
this.model.unset('success');
const account = this.model.get('account');
if (account) {
account.unset('alertText');
}
}
var unsafeSuccess = this.model.get('unsafeSuccess');

Просмотреть файл

@ -10,6 +10,9 @@ import _ from 'underscore';
const NavigationBehavior = function (endpoint, options = {}) {
const behavior = function (view, account) {
if (account && options.success) {
account.set('alertText', options.success);
}
const navigateOptions = _.assign({}, options, { account });
view.navigate(endpoint, navigateOptions);

Просмотреть файл

@ -1243,4 +1243,14 @@ describe('views/base', function () {
assert.isTrue(view._onRequiredClick.calledOnce);
});
});
describe('displayStatusMessages', () => {
it('clears the alertText after displaying success', () => {
const account = { unset: sinon.spy() };
model.set('account', account);
model.set('success', 'ok');
view.displayStatusMessages();
assert.isTrue(account.unset.calledOnceWithExactly('alertText'));
});
});
});

Просмотреть файл

@ -18,7 +18,7 @@ describe('views/behaviors/navigate', function () {
navigate: sinon.spy(),
};
const accountMock = {};
const accountMock = { set: sinon.spy() };
const promise = navigateBehavior(viewMock, accountMock);
// navigateBehavior returns a promise that never resolves,
@ -28,6 +28,9 @@ describe('views/behaviors/navigate', function () {
const endpoint = viewMock.navigate.args[0][0];
const navigateOptions = viewMock.navigate.args[0][1];
assert.isTrue(
accountMock.set.calledOnceWithExactly('alertText', options.success)
);
assert.equal(endpoint, 'settings');
assert.equal(navigateOptions.success, 'success');
assert.equal(navigateOptions.error, 'error');

Просмотреть файл

@ -0,0 +1,46 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import React, { useRef, ReactNode } from 'react';
import { render, screen } from '@testing-library/react';
import AlertExternal from './index';
import { AlertBarRootAndContextProvider } from '../../lib/AlertBarContext';
import { MockedCache } from '../../models/_mocks';
describe('AlertExternal', () => {
it('renders as expected', () => {
const { rerender } = render(
<MockedCache account={{ alertTextExternal: 'ok' }}>
<AlertBarRootAndContextProvider />
</MockedCache>
);
rerender(
<MockedCache account={{ alertTextExternal: 'ok' }}>
<AlertBarRootAndContextProvider>
<AlertExternal />
</AlertBarRootAndContextProvider>
</MockedCache>
);
expect(screen.getByTestId('alert-bar-root')).toContainElement(
screen.getByTestId('alert-bar')
);
expect(screen.queryByTestId('alert-external-text')).toBeInTheDocument();
});
it('does not render with no alertTextExternal text', () => {
const { rerender } = render(
<MockedCache>
<AlertBarRootAndContextProvider />
</MockedCache>
);
rerender(
<MockedCache>
<AlertBarRootAndContextProvider>
<AlertExternal />
</AlertBarRootAndContextProvider>
</MockedCache>
);
expect(screen.queryByTestId('alert-external-text')).not.toBeInTheDocument();
});
});

Просмотреть файл

@ -0,0 +1,20 @@
import React from 'react';
import { AlertBar } from '../AlertBar';
import { useAccount } from '../../models';
import { alertTextExternal } from '../../lib/cache';
export const AlertExternal = () => {
const account = useAccount();
return account.alertTextExternal ? (
<AlertBar
onDismiss={() => {
alertTextExternal(null);
}}
>
<p data-testid="alert-external-text">{account.alertTextExternal}</p>
</AlertBar>
) : null;
};
export default AlertExternal;

Просмотреть файл

@ -43,6 +43,7 @@ export const GET_INITIAL_STATE = gql`
created
productName
}
alertTextExternal @client
}
session {
verified
@ -72,12 +73,9 @@ export const App = ({ queryParams }: AppProps) => {
<AppLayout>
<Router basepath="/beta/settings">
<Settings path="/" />
<FlowContainer path="/avatar/change" title="Profile picture"/>
<FlowContainer path="/display_name" title="Display name"/>
<FlowContainer
path="/change_password"
title="Change password"
/>
<FlowContainer path="/avatar/change" title="Profile picture" />
<FlowContainer path="/display_name" title="Display name" />
<FlowContainer path="/change_password" title="Change password" />
</Router>
</AppLayout>
);

Просмотреть файл

@ -7,8 +7,8 @@ import UnitRow from '../UnitRow';
import UnitRowWithAvatar from '../UnitRowWithAvatar';
import Security from '../Security';
import UnitRowSecondaryEmail from '../UnitRowSecondaryEmail';
import { useLocation, RouteComponentProps } from '@reach/router';
import { RouteComponentProps } from '@reach/router';
import AlertExternal from '../AlertExternal';
import { useAccount } from '../../models';
export const Settings = (_: RouteComponentProps) => {
@ -30,6 +30,7 @@ export const Settings = (_: RouteComponentProps) => {
return (
<>
<AlertExternal />
<section className="mt-11" id="profile" data-testid="settings-profile">
<h2 className="font-header font-bold ml-4 mb-4">Profile</h2>
@ -38,7 +39,11 @@ export const Settings = (_: RouteComponentProps) => {
<hr className="unit-row-hr" />
<UnitRow header="Display name" headerValue={displayName} route="/beta/settings/display_name" />
<UnitRow
header="Display name"
headerValue={displayName}
route="/beta/settings/display_name"
/>
<hr className="unit-row-hr" />
@ -46,7 +51,7 @@ export const Settings = (_: RouteComponentProps) => {
header="Password"
headerValueClassName="tracking-wider"
headerValue="••••••••••••••••••"
route="/beta/settings/change_password"
route="/beta/settings/change_password"
>
<p className="text-grey-400 text-xs mobileLandscape:mt-3">
Created {pwdDateText}

Просмотреть файл

@ -1,27 +1,67 @@
import { InMemoryCache, gql } from '@apollo/client';
import { InMemoryCache, gql, makeVar } from '@apollo/client';
import Storage from './storage';
import { Email } from '../models';
const storage = Storage.factory('localStorage');
export function sessionToken(newToken?: string) {
export interface OldSettingsData {
uid: hexstring;
sessionToken: hexstring;
alertText?: string;
}
type LocalAccount = OldSettingsData | undefined;
type LocalAccounts = Record<hexstring, LocalAccount> | undefined;
function accounts(accounts?: LocalAccounts) {
if (accounts) {
storage.set('accounts', accounts);
return accounts;
}
return storage.get('accounts') as LocalAccounts;
}
function currentAccount(account?: OldSettingsData) {
const all = accounts() || {};
const uid = storage.get('currentAccountUid') as hexstring;
if (account) {
all[account.uid] = account;
accounts(all);
return account;
}
return all[uid];
}
export function sessionToken(newToken?: hexstring) {
try {
const storedAccounts = storage.get('accounts');
const currentAccountUid = storage.get('currentAccountUid');
const account = currentAccount();
if (newToken) {
storedAccounts[currentAccountUid].sessionToken = newToken;
storage.set('accounts', storedAccounts);
account!.sessionToken = newToken;
currentAccount(account);
}
return storedAccounts[currentAccountUid].sessionToken as string;
return account!.sessionToken;
} catch (e) {
return null;
}
}
function consumeAlertTextExternal() {
const account = currentAccount();
const text = account?.alertText || null;
if (text) {
account!.alertText = undefined;
currentAccount(account);
}
return text;
}
export const alertTextExternal = makeVar(consumeAlertTextExternal());
// sessionToken is added as a local field as an example.
export const typeDefs = gql`
extend type Account {
primaryEmail: Email!
alertTextExternal: String
}
extend type Session {
token: String!
@ -38,6 +78,11 @@ export const cache = new InMemoryCache({
return emails?.find((email) => email.isPrimary);
},
},
alertTextExternal: {
read() {
return alertTextExternal();
},
},
},
},
Session: {

Просмотреть файл

@ -7,7 +7,7 @@ export interface Email {
}
export interface Account {
uid: string;
uid: hexstring;
displayName: string | null;
avatarUrl: string | null;
accountCreated: number;
@ -30,6 +30,7 @@ export interface Account {
created: number;
productName: string;
}[];
alertTextExternal: string | null;
}
export const GET_ACCOUNT = gql`
@ -62,6 +63,7 @@ export const GET_ACCOUNT = gql`
created
productName
}
alertTextExternal @client
}
}
`;

Просмотреть файл

@ -39,6 +39,7 @@ const MOCK_ACCOUNT: Account = {
exists: true,
verified: true,
},
alertTextExternal: null,
};
export interface MockedProps {