Skip to content
Snippets Groups Projects
Unverified Commit beef97d7 authored by Jed Fox's avatar Jed Fox Committed by GitHub
Browse files

Move the welcome modal to an interstitial, add import button (#762)

I noticed that the first run flow is suboptimal for people who want to
import an existing file from Actual/YNAB. I’ve moved the welcome modal
into the management app and set it up to appear when there are no
budgets available (which also has the benefit of allowing people to see
the modal again!)

I think there’s some weirdness around getting the modal to reappear when
deleting a budget file which I want to work out before merging this.

This PR also reorganizes the management app a bit to reduce usage of
modals (currently, hitting escape while the budget list is open leaves
you with a blank page).

<img width="539" alt="Screenshot_2023-03-18 08 53 54"
src="https://user-images.githubusercontent.com/25517624/226107462-b2b88791-1015-4397-b290-c64e7fcc0f41.png">

- [x] Ensure modal consistently appears when needed (no longer a modal!)
- [x] Fix e2e tests
parent 98948744
No related branches found
No related tags found
No related merge requests found
Showing
with 255 additions and 417 deletions
......@@ -5,6 +5,5 @@ export class ConfigurationPage {
async createTestFile() {
await this.page.getByRole('button', { name: 'Create test file' }).click();
await this.page.getByRole('button', { name: 'Close' }).click();
}
}
......@@ -28,7 +28,6 @@ import EditRule from './modals/EditRule';
import FixEncryptionKey from './modals/FixEncryptionKey';
import ManageRulesModal from './modals/ManageRulesModal';
import MergeUnusedPayees from './modals/MergeUnusedPayees';
import WelcomeScreen from './modals/WelcomeScreen';
function Modals({
history,
......@@ -278,10 +277,6 @@ function Modals({
}}
/>
<Route path="/welcome-screen">
<WelcomeScreen modalProps={modalProps} actions={actions} />
</Route>
<Route path="/budget-summary">
<BudgetSummary
key={name}
......
......@@ -6,6 +6,7 @@ import { createBrowserHistory } from 'history';
import * as actions from 'loot-core/src/client/actions';
import { View, Text } from 'loot-design/src/components/common';
import BudgetList from 'loot-design/src/components/manager/BudgetList';
import { colors } from 'loot-design/src/style';
import tokens from 'loot-design/src/tokens';
......@@ -20,6 +21,7 @@ import Bootstrap from './subscribe/Bootstrap';
import ChangePassword from './subscribe/ChangePassword';
import Error from './subscribe/Error';
import Login from './subscribe/Login';
import WelcomeScreen from './WelcomeScreen';
function Version() {
const version = useServerVersion();
......@@ -92,62 +94,20 @@ class ManagementApp extends React.Component {
});
}
async showModal() {
// This runs when `files` has changed, and we need to perform
// actions based on whether or not files is empty or not
if (this.props.managerHasInitialized) {
let { currentModals, userData, files, replaceModal } = this.props;
// We want to decide where to take the user if they have logged
// in and we've tried to load their files
if (files && userData) {
if (files.length > 0) {
// If the user is logged in and files exist, show the budget
// list screen
if (!currentModals.includes('select-budget')) {
replaceModal('select-budget');
}
} else {
// If the user is logged in and there's no existing files,
// automatically create one. This will load the budget and
// swap out the manager with the new budget, so there's
// nothing else we need to do
this.props.createBudget();
}
}
}
}
componentDidUpdate(prevProps) {
if (!this.mounted) {
return;
}
if (
this.props.managerHasInitialized !== prevProps.managerHasInitialized ||
this.props.files !== prevProps.files ||
this.props.userData !== prevProps.userData
) {
this.showModal();
}
}
componentWillUnmount() {
this.mounted = false;
}
render() {
let { userData, managerHasInitialized, loadingText } = this.props;
let { files, userData, managerHasInitialized } = this.props;
if (!managerHasInitialized) {
return null;
}
let isHidden = loadingText != null;
return (
<Router history={this.history}>
<View style={{ height: '100%', minHeight: 500 }}>
<View style={{ height: '100%' }}>
<View
style={{
position: 'absolute',
......@@ -174,7 +134,7 @@ class ManagementApp extends React.Component {
/>
</View>
{!isHidden && (
{managerHasInitialized && (
<View
style={{
alignItems: 'center',
......@@ -187,7 +147,7 @@ class ManagementApp extends React.Component {
top: 0,
}}
>
{userData ? (
{userData && files ? (
<>
<Switch>
<Route
......@@ -200,7 +160,11 @@ class ManagementApp extends React.Component {
path="/change-password"
component={ChangePassword}
/>
<Route exact path="/" component={Modals} />
{files && files.length > 0 ? (
<Route exact path="/" component={BudgetList} />
) : (
<Route exact path="/" component={WelcomeScreen} />
)}
{/* Redirect all other pages to this route */}
<Route path="/" render={() => <Redirect to="/" />} />
</Switch>
......@@ -245,6 +209,7 @@ class ManagementApp extends React.Component {
</Switch>
<Version />
</View>
<Modals history={this.history} />
</Router>
);
}
......
......@@ -7,7 +7,6 @@ import { bindActionCreators } from 'redux';
import * as actions from 'loot-core/src/client/actions';
import { send } from 'loot-core/src/platform/client/fetch';
import { View } from 'loot-design/src/components/common';
import BudgetList from 'loot-design/src/components/manager/BudgetList';
import DeleteFile from 'loot-design/src/components/manager/DeleteFile';
import Import from 'loot-design/src/components/manager/Import';
import ImportActual from 'loot-design/src/components/manager/ImportActual';
......@@ -29,7 +28,7 @@ function Modals({
}) {
let stack = modalStack.map(({ name, options }, idx) => {
const modalProps = {
onClose: actions.closeModal,
onClose: actions.popModal,
onPush: actions.pushModal,
onBack: actions.popModal,
isCurrent: idx === modalStack.length - 1,
......@@ -38,17 +37,6 @@ function Modals({
};
switch (name) {
case 'select-budget':
return (
<BudgetList
key={name}
modalProps={modalProps}
files={allFiles}
actions={actions}
isLoggedIn={isLoggedIn}
onDownload={cloudFileId => actions.downloadBudget(cloudFileId)}
/>
);
case 'delete-budget':
return (
<DeleteFile
......
import React from 'react';
import { connect } from 'react-redux';
import * as actions from 'loot-core/src/client/actions';
import {
View,
Button,
Text,
P,
ExternalLink,
} from 'loot-design/src/components/common';
import { colors, styles } from 'loot-design/src/style';
function WelcomeScreen({ createBudget, pushModal }) {
return (
<View
style={{
gap: 10,
maxWidth: 500,
fontSize: 15,
maxHeight: '100vh',
marginBlock: 20,
}}
>
<Text style={styles.veryLargeText}>Lets get started!</Text>
<View style={{ overflowY: 'auto' }}>
<P>
Actual is a personal finance tool that focuses on beautiful design and
a slick user experience.{' '}
<strong>Editing your data should be as fast as possible.</strong> On
top of that, we want to provide powerful tools to allow you to do
whatever you want with your data.
</P>
<P style={{ '& a, & a:visited': { color: colors.p5 } }}>
Currently, Actual implements budgeting based on a{' '}
<ExternalLink
asAnchor
style={{ color: colors.p5 }}
href="https://actualbudget.github.io/docs/Budgeting/howitworks"
>
monthly envelope system
</ExternalLink>
. Consider taking our{' '}
<ExternalLink
asAnchor
href="https://actualbudget.github.io/docs/Getting-Started/using-actual/"
>
guided tour
</ExternalLink>{' '}
to help you get your bearings, and check out the rest of the
documentation while youre there to learn more about advanced topics.
</P>
<P style={{ color: colors.n5 }}>
Get started by importing an existing budget file from Actual or
another budgeting app, or start fresh with an empty budget. You can
always create or import another budget later.
</P>
</View>
<View
style={{
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-end',
flexShrink: 0,
}}
>
<Button onClick={() => pushModal('import')}>Import my budget</Button>
<Button primary onClick={createBudget}>
Start fresh
</Button>
</View>
</View>
);
}
export default connect(null, actions)(WelcomeScreen);
import React from 'react';
import { connect } from 'react-redux';
import * as actions from 'loot-core/src/client/actions';
import {
View,
Modal,
P,
ExternalLink,
} from 'loot-design/src/components/common';
import { colors } from 'loot-design/src/style';
function WelcomeScreen({ modalProps, actions }) {
return (
<Modal title="Welcome to Actual" {...modalProps}>
{() => (
<View style={{ maxWidth: 500, fontSize: 15 }}>
<P>
Actual is a personal finance tool that focuses on beautiful design
and a slick user experience.{' '}
<strong>Editing your data should be as fast as possible.</strong> On
top of that, we want to provide powerful tools to allow you to do
whatever you want with your data.
</P>
<P>
Currently Actual implements budgeting based on a{' '}
<ExternalLink
asAnchor
href="https://actualbudget.github.io/docs/Budgeting/howitworks"
>
monthly envelope system
</ExternalLink>
.
</P>
<P>
In the future, we{"'"}ll support multiple ways to do budgeting. We
{"'"}re also working hard on custom reports and a lot more things.
</P>
<P
style={{
color: colors.p5,
fontWeight: 600,
'& a, & a:visited': { color: colors.p5 },
}}
>
Read the{' '}
<ExternalLink asAnchor href="https://actualbudget.github.io/docs/">
documentation
</ExternalLink>{' '}
to get started and learn about{' '}
<ExternalLink
asAnchor
href="https://actualbudget.github.io/docs/Budgeting/howitworks"
>
budgeting
</ExternalLink>
,{' '}
<ExternalLink
asAnchor
href="https://actualbudget.github.io/docs/Accounts/overview"
>
accounts
</ExternalLink>{' '}
and more.
</P>
</View>
)}
</Modal>
);
}
export default connect(null, actions)(WelcomeScreen);
......@@ -5,7 +5,6 @@ import * as constants from '../constants';
import { setAppState } from './app';
import { closeModal, pushModal } from './modals';
import { loadPrefs, loadGlobalPrefs } from './prefs';
import { startTutorialFirstTime } from './tutorial';
export function updateStatusText(text) {
return (dispatch, getState) => {
......@@ -95,7 +94,6 @@ export function loadBudget(id, loadingText = '', options = {}) {
const prefs = getState().prefs.local;
dispatch(setAppState({ loadingText: null }));
dispatch(setAppState({ maxMonths: prefs.maxMonths }));
dispatch(startTutorialFirstTime());
};
}
......@@ -150,7 +148,6 @@ export function createBudget({ testMode, demoMode } = {}) {
await dispatch(loadAllFiles());
await dispatch(loadPrefs());
dispatch(startTutorialFirstTime());
// Set the loadingText to null after we've loaded the budget prefs
// so that the existing manager page doesn't flash
......@@ -168,7 +165,6 @@ export function importBudget(filepath, type) {
dispatch(closeModal());
await dispatch(loadPrefs());
dispatch(startTutorialFirstTime());
};
}
......
......@@ -6,7 +6,6 @@ export * from './notifications';
export * from './prefs';
export * from './budgets';
export * from './app';
export * from './tutorial';
export * from './backups';
export * from './sync';
export * from './user';
import { send } from '../../platform/client/fetch';
import * as Platform from '../platform';
import { pushModal } from './modals';
export function startTutorialFirstTime() {
return (dispatch, getState) => {
let { seenTutorial } = getState().prefs.global;
if (!seenTutorial) {
send('set-tutorial-seen');
if (Platform.env === 'web') {
setTimeout(() => {
dispatch(pushModal('welcome-screen'));
}, 500);
return true;
}
}
return false;
};
}
import React, { useState, useRef } from 'react';
import { connect } from 'react-redux';
import * as actions from 'loot-core/src/client/actions';
import Loading from 'loot-design/src/svg/AnimatedLoading';
import Key from 'loot-design/src/svg/v2/Key';
import RefreshArrow from 'loot-design/src/svg/v2/RefreshArrow';
......@@ -10,11 +12,12 @@ import CloudDownload from '../../svg/v1/CloudDownload';
import DotsHorizontalTriple from '../../svg/v1/DotsHorizontalTriple';
import FileDouble from '../../svg/v1/FileDouble';
import CloudUnknown from '../../svg/v2/CloudUnknown';
import tokens from '../../tokens';
import {
isDevelopmentEnvironment,
isPreviewEnvironment,
} from '../../util/environment';
import { View, Text, Modal, Button, Tooltip, Menu } from '../common';
import { View, Text, Button, Tooltip, Menu } from '../common';
function getFileDescription(file) {
if (file.state === 'unknown') {
......@@ -34,7 +37,7 @@ function getFileDescription(file) {
return null;
}
function FileMenu({ state, onDelete, onUpload, onClose, onDownload }) {
function FileMenu({ onDelete, onClose }) {
function onMenuSelect(type) {
onClose();
......@@ -192,30 +195,29 @@ function File({ file, onSelect, onDelete }) {
);
}
class BudgetTable extends React.Component {
render() {
const { files, onSelect, onDelete } = this.props;
return (
<View
style={{
flex: 1,
function BudgetTable({ files, onSelect, onDelete }) {
return (
<View
style={{
flexGrow: 1,
[`@media (min-width: ${tokens.breakpoint_narrow})`]: {
flexGrow: 0,
maxHeight: 310,
overflow: 'auto',
'& *': { userSelect: 'none' },
}}
>
{files.map((file, idx) => (
<File
key={file.id || file.cloudFileId}
file={file}
onSelect={onSelect}
onDelete={onDelete}
/>
))}
</View>
);
}
},
overflow: 'auto',
'& *': { userSelect: 'none' },
}}
>
{files.map(file => (
<File
key={file.id || file.cloudFileId}
file={file}
onSelect={onSelect}
onDelete={onDelete}
/>
))}
</View>
);
}
function RefreshButton({ onRefresh }) {
......@@ -239,107 +241,114 @@ function RefreshButton({ onRefresh }) {
);
}
class BudgetList extends React.Component {
creating = false;
onCreate = ({ testMode } = {}) => {
if (!this.creating) {
this.creating = true;
this.props.actions.createBudget({ testMode });
function BudgetList({
files = [],
getUserData,
loadAllFiles,
pushModal,
loadBudget,
createBudget,
downloadBudget,
}) {
const [creating, setCreating] = useState(false);
const onCreate = ({ testMode } = {}) => {
if (!creating) {
setCreating(true);
createBudget({ testMode });
}
};
render() {
let { modalProps, files = [], actions, onDownload } = this.props;
return (
<Modal
{...modalProps}
noAnimation={true}
showHeader={false}
showOverlay={false}
padding={0}
style={{ boxShadow: 'none', backgroundColor: 'transparent' }}
return (
<View
style={{
flex: 1,
justifyContent: 'center',
minWidth: tokens.breakpoint_narrow,
[`@media (max-width: ${tokens.breakpoint_narrow})`]: {
width: '100vw',
minWidth: '100vw',
marginInline: -20,
marginTop: 20,
},
}}
>
<View>
<Text style={[styles.veryLargeText, { margin: 20 }]}>Files</Text>
<View
style={{
position: 'absolute',
right: 0,
top: 0,
bottom: 0,
justifyContent: 'center',
marginRight: 5,
}}
>
<RefreshButton
onRefresh={() => {
getUserData();
loadAllFiles();
}}
/>
</View>
</View>
<BudgetTable
files={files}
actions={actions}
onSelect={file => {
if (file.state === 'remote') {
downloadBudget(file.cloudFileId);
} else {
loadBudget(file.id);
}
}}
onDelete={file => pushModal('delete-budget', { file })}
/>
<View
style={{
flexDirection: 'row',
justifyContent: 'flex-end',
padding: 25,
paddingLeft: 5,
}}
>
{() => (
<View style={{ flex: 1 }}>
<View>
<Text style={[styles.veryLargeText, { margin: 20 }]}>Files</Text>
<View
style={{
position: 'absolute',
right: 0,
top: 0,
bottom: 0,
justifyContent: 'center',
marginRight: 5,
}}
>
<RefreshButton
onRefresh={() => {
actions.getUserData();
actions.loadAllFiles();
}}
/>
</View>
</View>
<BudgetTable
files={files}
actions={actions}
onSelect={file => {
if (file.state === 'remote') {
onDownload(file.cloudFileId);
} else {
actions.loadBudget(file.id);
}
}}
onDelete={file => actions.pushModal('delete-budget', { file })}
/>
<View
style={{
flexDirection: 'row',
justifyContent: 'flex-end',
padding: 25,
paddingLeft: 5,
}}
>
<Button
bare
style={{
marginLeft: 10,
color: colors.n4,
}}
onClick={e => {
e.preventDefault();
actions.pushModal('import');
}}
>
Import file
</Button>
<Button
primary
onClick={() => this.onCreate()}
style={{ marginLeft: 15 }}
>
Create new file
</Button>
{(isDevelopmentEnvironment() || isPreviewEnvironment()) && (
<Button
primary
onClick={() => this.onCreate({ testMode: true })}
style={{ marginLeft: 15 }}
>
Create test file
</Button>
)}
</View>
</View>
<Button
bare
style={{
marginLeft: 10,
color: colors.n4,
}}
onClick={e => {
e.preventDefault();
pushModal('import');
}}
>
Import file
</Button>
<Button primary onClick={onCreate} style={{ marginLeft: 15 }}>
Create new file
</Button>
{(isDevelopmentEnvironment() || isPreviewEnvironment()) && (
<Button
primary
onClick={() => onCreate({ testMode: true })}
style={{ marginLeft: 15 }}
>
Create test file
</Button>
)}
</Modal>
);
}
</View>
</View>
);
}
export default BudgetList;
export default connect(
state => ({
files: state.budgets.allFiles,
isLoggedIn: !!state.user.data,
}),
actions,
)(BudgetList);
import React, { useState } from 'react';
import { styles, colors } from '../../style';
import { View, Block, Modal, Button } from '../common';
import { View, Block, Modal, Button, Text } from '../common';
function getErrorMessage(error) {
switch (error) {
......@@ -12,19 +12,6 @@ function getErrorMessage(error) {
}
}
// const res = await window.Actual.openFileDialog({
// // Windows treats the ynab4 file as a directroy, while Mac
// // treats it like a normal file
// properties: ['openDirectory', 'openFile'],
// filters: [{ name: 'ynab', extensions: ['ynab4'] }]
// });
// if (res) {
// this.doImport(res[0]);
// }
// this.props.actions.importBudget(filepath).catch(err => {
// this.setState({ error: err.message, importing: false });
// });
function Import({ modalProps, actions }) {
const [error] = useState(false);
......@@ -52,63 +39,38 @@ function Import({ modalProps, actions }) {
};
return (
<Modal
{...modalProps}
noAnimation={true}
showHeader={false}
showOverlay={false}
style={{ width: 400 }}
>
<Modal {...modalProps} title="Import From" style={{ width: 400 }}>
{() => (
<View style={[styles.smallText, { lineHeight: 1.5, marginTop: 20 }]}>
<View style={[styles.smallText, { lineHeight: 1.5 }]}>
{error && (
<Block style={{ color: colors.r4, marginBottom: 15 }}>
{getErrorMessage(error)}
</Block>
)}
<View>
<View style={{ fontSize: 25, fontWeight: 700, marginBottom: 20 }}>
Import from:
</View>
<Text style={{ marginBottom: 15 }}>
Select an app to import from, and we'll guide you through the
process.
</Text>
<View>
<Button style={itemStyle} onClick={() => onSelectType('ynab4')}>
<span style={{ fontWeight: 700 }}>YNAB4</span>
<View style={{ color: colors.n5 }}>
The old unsupported desktop app
</View>
</Button>
<Button style={itemStyle} onClick={() => onSelectType('ynab5')}>
<span style={{ fontWeight: 700 }}>nYNAB</span>
<View style={{ color: colors.n5 }}>
<div>The newer web app</div>
</View>
</Button>
<Button style={itemStyle} onClick={() => onSelectType('actual')}>
<span style={{ fontWeight: 700 }}>Actual</span>
<View style={{ color: colors.n5 }}>
<div>Import a file exported from Actual</div>
</View>
</Button>
<Button style={itemStyle} onClick={() => onSelectType('ynab4')}>
<span style={{ fontWeight: 700 }}>YNAB4</span>
<View style={{ color: colors.n5 }}>
The old unsupported desktop app
</View>
</View>
<View
style={{
flexDirection: 'row',
marginTop: 20,
alignItems: 'center',
}}
>
<View style={{ flex: 1 }} />
<Button
style={{ marginRight: 10 }}
onClick={() => modalProps.onBack()}
>
Back
</Button>
</View>
</Button>
<Button style={itemStyle} onClick={() => onSelectType('ynab5')}>
<span style={{ fontWeight: 700 }}>nYNAB</span>
<View style={{ color: colors.n5 }}>
<div>The newer web app</div>
</View>
</Button>
<Button style={itemStyle} onClick={() => onSelectType('actual')}>
<span style={{ fontWeight: 700 }}>Actual</span>
<View style={{ color: colors.n5 }}>
<div>Import a file exported from Actual</div>
</View>
</Button>
</View>
)}
</Modal>
......
......@@ -4,7 +4,7 @@ import { useDispatch } from 'react-redux';
import { importBudget } from 'loot-core/src/client/actions/budgets';
import { styles, colors } from '../../style';
import { View, Block, Modal, ButtonWithLoading, Button, P } from '../common';
import { View, Block, Modal, ButtonWithLoading, P } from '../common';
function getErrorMessage(error) {
switch (error) {
......@@ -43,9 +43,7 @@ function Import({ modalProps, availableImports }) {
return (
<Modal
{...modalProps}
showHeader={false}
showOverlay={false}
noAnimation={true}
title="Import from Actual export"
style={{ width: 400 }}
>
{() => (
......@@ -72,22 +70,6 @@ function Import({ modalProps, availableImports }) {
</ButtonWithLoading>
</View>
</View>
<View
style={{
flexDirection: 'row',
marginTop: 20,
alignItems: 'center',
}}
>
<View style={{ flex: 1 }} />
<Button
style={{ marginRight: 10 }}
onClick={() => modalProps.onBack()}
>
Back
</Button>
</View>
</View>
)}
</Modal>
......
......@@ -4,7 +4,7 @@ import { useDispatch } from 'react-redux';
import { importBudget } from 'loot-core/src/client/actions/budgets';
import { styles, colors } from '../../style';
import { View, Block, Modal, Button, ButtonWithLoading, P } from '../common';
import { View, Block, Modal, ButtonWithLoading, P } from '../common';
function getErrorMessage(error) {
switch (error) {
......@@ -39,13 +39,7 @@ function Import({ modalProps, availableImports }) {
}
return (
<Modal
{...modalProps}
showHeader={false}
showOverlay={false}
noAnimation={true}
style={{ width: 400 }}
>
<Modal {...modalProps} title="Import from YNAB4" style={{ width: 400 }}>
{() => (
<View style={[styles.smallText, { lineHeight: 1.5, marginTop: 20 }]}>
{error && (
......@@ -73,22 +67,6 @@ function Import({ modalProps, availableImports }) {
</ButtonWithLoading>
</View>
</View>
<View
style={{
flexDirection: 'row',
marginTop: 20,
alignItems: 'center',
}}
>
<View style={{ flex: 1 }} />
<Button
style={{ marginRight: 10 }}
onClick={() => modalProps.onBack()}
>
Back
</Button>
</View>
</View>
)}
</Modal>
......
......@@ -9,7 +9,6 @@ import {
Block,
Modal,
ButtonWithLoading,
Button,
P,
ExternalLink,
} from '../common';
......@@ -49,13 +48,7 @@ function Import({ modalProps, availableImports }) {
}
return (
<Modal
{...modalProps}
showHeader={false}
showOverlay={false}
noAnimation={true}
style={{ width: 400 }}
>
<Modal {...modalProps} title="Import from nYNAB" style={{ width: 400 }}>
{() => (
<View style={[styles.smallText, { lineHeight: 1.5, marginTop: 20 }]}>
{error && (
......@@ -91,22 +84,6 @@ function Import({ modalProps, availableImports }) {
</ButtonWithLoading>
</View>
</View>
<View
style={{
flexDirection: 'row',
marginTop: 20,
alignItems: 'center',
}}
>
<View style={{ flex: 1 }} />
<Button
style={{ marginRight: 10 }}
onClick={() => modalProps.onBack()}
>
Back
</Button>
</View>
</View>
)}
</Modal>
......
---
category: Enhancements
authors: [j-f1]
---
Change when the welcome screen is shown, add a button to start by importing a file
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment