Merge pull request #1934 from davemfish/feature/WB-1793-settings-layout

convert settings modal to a dropdown menu, etc
This commit is contained in:
Emily Davis 2025-05-27 10:06:10 -06:00 committed by GitHub
commit a08d9b555f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 709 additions and 382 deletions

View File

@ -53,6 +53,18 @@ export default class InvestJob {
return InvestJob.getJobStore();
}
static async deleteJob(hash) {
await investJobStore.removeItem(hash);
// also remove item from the array that tracks the order of the jobs
const sortedJobHashes = await investJobStore.getItem(HASH_ARRAY_KEY);
const idx = sortedJobHashes.indexOf(hash);
if (idx > -1) {
sortedJobHashes.splice(idx, 1); // remove one item only
}
await investJobStore.setItem(HASH_ARRAY_KEY, sortedJobHashes);
return InvestJob.getJobStore();
}
static async saveJob(job) {
job.hash = window.crypto.getRandomValues(
new Uint32Array(1)

View File

@ -19,10 +19,12 @@ import { AiOutlineTrademarkCircle } from 'react-icons/ai';
import HomeTab from './components/HomeTab';
import InvestTab from './components/InvestTab';
import AppMenu from './components/AppMenu';
import SettingsModal from './components/SettingsModal';
import DataDownloadModal from './components/DataDownloadModal';
import DownloadProgressBar from './components/DownloadProgressBar';
import PluginModal from './components/PluginModal';
import MetadataModal from './components/MetadataModal';
import InvestJob from './InvestJob';
import { dragOverHandlerNone } from './utils';
import { ipcMainChannels } from '../main/ipcMainChannels';
@ -45,8 +47,11 @@ export default class App extends React.Component {
investList: null,
recentJobs: [],
showDownloadModal: false,
showPluginModal: false,
downloadedNofN: null,
showChangelog: false,
showSettingsModal: false,
showMetadataModal: false,
changelogDismissed: false,
};
this.switchTabs = this.switchTabs.bind(this);
@ -54,8 +59,12 @@ export default class App extends React.Component {
this.closeInvestModel = this.closeInvestModel.bind(this);
this.updateJobProperties = this.updateJobProperties.bind(this);
this.saveJob = this.saveJob.bind(this);
this.deleteJob = this.deleteJob.bind(this);
this.clearRecentJobs = this.clearRecentJobs.bind(this);
this.showDownloadModal = this.showDownloadModal.bind(this);
this.toggleDownloadModal = this.toggleDownloadModal.bind(this);
this.toggleSettingsModal = this.toggleSettingsModal.bind(this);
this.toggleMetadataModal = this.toggleMetadataModal.bind(this);
this.togglePluginModal = this.togglePluginModal.bind(this);
this.updateInvestList = this.updateInvestList.bind(this);
}
@ -86,8 +95,8 @@ export default class App extends React.Component {
ipcRenderer.removeAllListeners('download-status');
}
/** Change the tab that is currently visible.
*
/**
* Change the tab that is currently visible.
* @param {string} key - the value of one of the Nav.Link eventKey.
*/
switchTabs(key) {
@ -96,7 +105,7 @@ export default class App extends React.Component {
);
}
showDownloadModal(shouldShow) {
toggleDownloadModal(shouldShow) {
this.setState({
showDownloadModal: shouldShow,
});
@ -116,8 +125,26 @@ export default class App extends React.Component {
});
}
/** Push data for a new InvestTab component to an array.
*
togglePluginModal(show) {
this.setState({
showPluginModal: show
});
}
toggleMetadataModal(show) {
this.setState({
showMetadataModal: show
});
}
toggleSettingsModal(show) {
this.setState({
showSettingsModal: show
});
}
/**
* Push data for a new InvestTab component to an array.
* @param {InvestJob} job - as constructed by new InvestJob()
*/
openInvestModel(job) {
@ -135,7 +162,6 @@ export default class App extends React.Component {
/**
* Click handler for the close-tab button on an Invest model tab.
*
* @param {string} tabID - the eventKey of the tab containing the
* InvestTab component that will be removed.
*/
@ -163,8 +189,8 @@ export default class App extends React.Component {
});
}
/** Update properties of an open InvestTab.
*
/**
* Update properties of an open InvestTab.
* @param {string} tabID - the unique identifier of an open tab
* @param {obj} jobObj - key-value pairs of any job properties to be updated
*/
@ -176,10 +202,8 @@ export default class App extends React.Component {
});
}
/** Save data describing an invest job to a persistent store.
*
* And update the app's view of that store.
*
/**
* Save data describing an invest job to a persistent store.
* @param {string} tabID - the unique identifier of an open InvestTab.
*/
async saveJob(tabID) {
@ -190,6 +214,20 @@ export default class App extends React.Component {
});
}
/**
* Delete the job record from the store.
* @param {string} jobHash - the unique identifier of a saved Job.
*/
async deleteJob(jobHash) {
const recentJobs = await InvestJob.deleteJob(jobHash);
this.setState({
recentJobs: recentJobs,
});
}
/**
* Delete all the jobs from the store.
*/
async clearRecentJobs() {
const recentJobs = await InvestJob.clearStore();
this.setState({
@ -221,7 +259,10 @@ export default class App extends React.Component {
openTabIDs,
activeTab,
showDownloadModal,
showPluginModal,
showChangelog,
showSettingsModal,
showMetadataModal,
downloadedNofN,
} = this.state;
@ -325,17 +366,38 @@ export default class App extends React.Component {
{showDownloadModal && (
<DataDownloadModal
show={showDownloadModal}
closeModal={() => this.showDownloadModal(false)}
closeModal={() => this.toggleDownloadModal(false)}
/>
)}
{showPluginModal && (
<PluginModal
show={showPluginModal}
closeModal={() => this.togglePluginModal(false)}
openModal={() => this.togglePluginModal(true)}
updateInvestList={this.updateInvestList}
closeInvestModel={this.closeInvestModel}
openJobs={openJobs}
/>
)}
{showChangelog && (
<Changelog
show={showChangelog}
close={() => this.closeChangelogModal()}
/>
)}
{showMetadataModal && (
<MetadataModal
show={showMetadataModal}
close={() => this.toggleMetadataModal(false)}
/>
)}
{showSettingsModal && (
<SettingsModal
show={showSettingsModal}
close={() => this.toggleSettingsModal(false)}
nCPU={this.props.nCPU}
/>
)}
{
showChangelog && (
<Changelog
show={showChangelog}
close={() => this.closeChangelogModal()}
/>
)
}
<TabContainer activeKey={activeTab}>
<Navbar
onDragOver={dragOverHandlerNone}
@ -377,16 +439,12 @@ export default class App extends React.Component {
)
: <div />
}
<PluginModal
updateInvestList={this.updateInvestList}
closeInvestModel={this.closeInvestModel}
openJobs={openJobs}
/>
<SettingsModal
className="mx-3"
clearJobsStorage={this.clearRecentJobs}
showDownloadModal={() => this.showDownloadModal(true)}
nCPU={this.props.nCPU}
<AppMenu
openDownloadModal={() => this.toggleDownloadModal(true)}
openPluginModal={() => this.togglePluginModal(true)}
openChangelogModal={() => this.setState({ showChangelog: true })}
openSettingsModal={() => this.toggleSettingsModal(true)}
openMetadataModal={() => this.toggleMetadataModal(true)}
/>
</Col>
</Row>
@ -407,6 +465,8 @@ export default class App extends React.Component {
openInvestModel={this.openInvestModel}
recentJobs={recentJobs}
batchUpdateArgs={this.batchUpdateArgs}
deleteJob={this.deleteJob}
clearRecentJobs={this.clearRecentJobs}
/>
) : <div />}
</TabPane>

View File

@ -0,0 +1,56 @@
import React from 'react';
import Dropdown from 'react-bootstrap/Dropdown';
import { useTranslation } from 'react-i18next';
import { GiHamburgerMenu } from 'react-icons/gi';
export default function AppMenu(props) {
const { t } = useTranslation();
return (
<Dropdown>
<Dropdown.Toggle
className="app-menu-button"
aria-label="menu"
childBsPrefix="outline-secondary"
>
<GiHamburgerMenu />
</Dropdown.Toggle>
<Dropdown.Menu
align="right"
className="shadow"
>
<Dropdown.Item
as="button"
onClick={props.openPluginModal}
>
Manage Plugins
</Dropdown.Item>
<Dropdown.Item
as="button"
onClick={props.openDownloadModal}
>
Download Sample Data
</Dropdown.Item>
<Dropdown.Item
as="button"
onClick={props.openMetadataModal}
>
Configure Metadata
</Dropdown.Item>
<Dropdown.Item
as="button"
onClick={props.openChangelogModal}
>
View Changelog
</Dropdown.Item>
<Dropdown.Item
as="button"
onClick={props.openSettingsModal}
>
Settings
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
);
}

View File

@ -88,8 +88,7 @@ export default function Changelog(props) {
and not, for example, sourced from user input. */}
<Modal.Body
dangerouslySetInnerHTML={htmlContent}
>
</Modal.Body>
/>
</Modal>
);
}

View File

@ -8,6 +8,7 @@ import Alert from 'react-bootstrap/Alert';
import Table from 'react-bootstrap/Table';
import {
MdErrorOutline,
MdClose,
} from 'react-icons/md';
import { withTranslation } from 'react-i18next';
@ -19,7 +20,7 @@ const { logger } = window.Workbench;
// A URL for sampledata to use in devMode, when the token containing the URL
// associated with a production build of the Workbench does not exist.
const BASE_URL = 'https://storage.googleapis.com/releases.naturalcapitalproject.org/invest/3.13.0/data';
const BASE_URL = 'https://storage.googleapis.com/releases.naturalcapitalproject.org/invest/3.15.1/data';
const DEFAULT_FILESIZE = 0;
/** Render a dialog with a form for configuring global invest settings */
@ -263,10 +264,20 @@ class DataDownloadModal extends React.Component {
<p className="mb-0"><em>{this.state.alertPath}</em></p>
</Alert>
)
: <Modal.Title id="download-modal-title">
: (
<Modal.Title id="download-modal-title">
{t("Download InVEST sample data")}
</Modal.Title>
)
}
<Button
variant="secondary-outline"
onClick={this.closeDialog}
className="float-right"
aria-label="Close modal"
>
<MdClose />
</Button>
</Modal.Header>
<Modal.Body>
<Table
@ -294,12 +305,6 @@ class DataDownloadModal extends React.Component {
</Table>
</Modal.Body>
<Modal.Footer>
<Button
variant="secondary"
onClick={this.closeDialog}
>
{t("Cancel")}
</Button>
<Button
variant="primary"
onClick={this.handleSubmit}

View File

@ -7,7 +7,11 @@ import Card from 'react-bootstrap/Card';
import Container from 'react-bootstrap/Container';
import Col from 'react-bootstrap/Col';
import Row from 'react-bootstrap/Row';
import Button from 'react-bootstrap/Button';
import { useTranslation } from 'react-i18next';
import {
MdClose,
} from 'react-icons/md';
import OpenButton from '../OpenButton';
import InvestJob from '../../InvestJob';
@ -35,7 +39,13 @@ export default class HomeTab extends React.Component {
}
render() {
const { recentJobs, investList, openInvestModel } = this.props;
const {
recentJobs,
investList,
openInvestModel,
deleteJob,
clearRecentJobs
} = this.props;
let sortedModelIds = {};
if (investList) {
// sort the model list alphabetically, by the model title,
@ -74,9 +84,10 @@ export default class HomeTab extends React.Component {
name={modelTitle}
action
onClick={() => this.handleClick(modelID)}
className="invest-button"
>
{ badge }
<span className="invest-button">{modelTitle}</span>
<span>{modelTitle}</span>
</ListGroup.Item>
);
});
@ -86,6 +97,16 @@ export default class HomeTab extends React.Component {
<Col md={6} className="invest-list-container">
<ListGroup className="invest-list-group">
{investButtons}
<ListGroup.Item
key="browse"
className="py-2 border-0"
>
<OpenButton
className="w-100 border-1 py-2 pl-3 text-left text-truncate"
openInvestModel={openInvestModel}
investList={investList}
/>
</ListGroup.Item>
</ListGroup>
</Col>
<Col className="recent-job-card-col">
@ -93,6 +114,8 @@ export default class HomeTab extends React.Component {
openInvestModel={openInvestModel}
recentJobs={recentJobs}
investList={investList}
deleteJob={deleteJob}
clearRecentJobs={clearRecentJobs}
/>
</Col>
</Row>
@ -117,13 +140,20 @@ HomeTab.propTypes = {
status: PropTypes.string,
})
).isRequired,
deleteJob: PropTypes.func.isRequired,
clearRecentJobs: PropTypes.func.isRequired,
};
/**
* Renders a button for each recent invest job.
*/
function RecentInvestJobs(props) {
const { recentJobs, openInvestModel, investList } = props;
const {
recentJobs,
openInvestModel,
deleteJob,
clearRecentJobs
} = props;
const { t } = useTranslation();
const handleClick = (jobMetadata) => {
@ -143,16 +173,26 @@ function RecentInvestJobs(props) {
}
recentButtons.push(
<Card
className="text-left recent-job-card"
as="button"
className="text-left recent-job-card mr-2 w-100"
key={job.hash}
onClick={() => handleClick(job)}
>
<Card.Body>
<Card.Header>
{badge}
<span className="header-title">{job.modelTitle}</span>
</Card.Header>
<Card.Header>
{badge}
<span className="header-title">{job.modelTitle}</span>
<Button
variant="outline-light"
onClick={() => deleteJob(job.hash)}
className="float-right p-1 mr-1 border-0"
aria-label="delete"
>
<MdClose />
</Button>
</Card.Header>
<Card.Body
className="text-left border-0"
as="button"
onClick={() => handleClick(job)}
>
<Card.Title>
<span className="text-heading">{'Workspace: '}</span>
<span className="text-mono">{job.argsValues.workspace_dir}</span>
@ -177,37 +217,43 @@ function RecentInvestJobs(props) {
});
return (
<>
<Container>
<Row>
<Col className="recent-header-col">
{recentButtons.length
? (
<h4>
{t('Recent runs:')}
</h4>
)
: (
<div className="default-text">
{t("Set up a model from a sample datastack file (.json) " +
"or from an InVEST model's logfile (.txt): ")}
</div>
)}
</Col>
<Col className="open-button-col">
{investList
? (
<OpenButton
className="mr-2"
openInvestModel={openInvestModel}
investList={investList}
/>
) : ''}
</Col>
</Row>
</Container>
{recentButtons}
</>
<Container>
<Row>
{recentButtons.length
? <div />
: (
<Card
className="text-left recent-job-card mr-2 w-100"
key="placeholder"
>
<Card.Header>
<span className="header-title">{t('Welcome!')}</span>
</Card.Header>
<Card.Body>
<Card.Title>
<span className="text-heading">
{t('After running a model, find your recent model runs here.')}
</span>
</Card.Title>
</Card.Body>
</Card>
)}
{recentButtons}
</Row>
{recentButtons.length
? (
<Row>
<Button
variant="secondary"
onClick={clearRecentJobs}
className="mr-2 w-100"
>
{t('Clear all model runs')}
</Button>
</Row>
)
: <div />}
</Container>
);
}
@ -222,4 +268,6 @@ RecentInvestJobs.propTypes = {
})
).isRequired,
openInvestModel: PropTypes.func.isRequired,
deleteJob: PropTypes.func.isRequired,
clearRecentJobs: PropTypes.func.isRequired,
};

View File

@ -6,13 +6,15 @@ import Col from 'react-bootstrap/Col';
import Alert from 'react-bootstrap/Alert';
import Button from 'react-bootstrap/Button';
import Form from 'react-bootstrap/Form';
import Modal from 'react-bootstrap/Modal';
import { MdClose } from 'react-icons/md';
import {
getGeoMetaMakerProfile,
setGeoMetaMakerProfile,
} from '../../../server_requests';
} from '../../server_requests';
import { openLinkInBrowser } from '../../../utils';
import { openLinkInBrowser } from '../../utils';
function AboutMetadataDiv() {
const { t } = useTranslation();
@ -67,9 +69,9 @@ function FormRow(label, value, handler) {
}
/**
* A form for submitting GeoMetaMaker profile data.
* A Modal with form for submitting GeoMetaMaker profile data.
*/
export default function MetadataForm() {
export default function MetadataModal(props) {
const { t } = useTranslation();
const [contactName, setContactName] = useState('');
@ -80,7 +82,6 @@ export default function MetadataForm() {
const [licenseURL, setLicenseURL] = useState('');
const [alertMsg, setAlertMsg] = useState('');
const [alertError, setAlertError] = useState(false);
const [showInfo, setShowInfo] = useState(false);
useEffect(() => {
async function loadProfile() {
@ -127,65 +128,74 @@ export default function MetadataForm() {
};
return (
<div id="metadata-form">
{
(showInfo)
? <AboutMetadataDiv />
: (
<Form onSubmit={handleSubmit} onChange={handleChange}>
<fieldset>
<legend>{t('Contact Information')}</legend>
<Form.Group controlId="name">
{FormRow(t('Full name'), contactName, setContactName)}
</Form.Group>
<Form.Group controlId="email">
{FormRow(t('Email address'), contactEmail, setContactEmail)}
</Form.Group>
<Form.Group controlId="job-title">
{FormRow(t('Job title'), contactPosition, setContactPosition)}
</Form.Group>
<Form.Group controlId="organization">
{FormRow(t('Organization name'), contactOrg, setContactOrg)}
</Form.Group>
</fieldset>
<fieldset>
<legend>{t('Data License Information')}</legend>
<Form.Group controlId="license-title">
{FormRow(t('Title'), licenseTitle, setLicenseTitle)}
</Form.Group>
<Form.Group controlId="license-url">
{FormRow('URL', licenseURL, setLicenseURL)}
</Form.Group>
</fieldset>
<Form.Row>
<Button
type="submit"
variant="primary"
className="my-1 py2 mx-2"
<Modal
show={props.show}
onHide={props.close}
size="lg"
aria-labelledby="metadata-modal-title"
>
<Modal.Header>
<Modal.Title id="metadata-modal-title">
{t('Configure Metadata')}
</Modal.Title>
<Button
variant="secondary-outline"
onClick={props.close}
className="float-right"
aria-label="Close modal"
>
<MdClose />
</Button>
</Modal.Header>
<Modal.Body>
<AboutMetadataDiv />
<hr />
<Form onSubmit={handleSubmit} onChange={handleChange}>
<fieldset>
<legend>{t('Contact Information')}</legend>
<Form.Group controlId="name">
{FormRow(t('Full name'), contactName, setContactName)}
</Form.Group>
<Form.Group controlId="email">
{FormRow(t('Email address'), contactEmail, setContactEmail)}
</Form.Group>
<Form.Group controlId="job-title">
{FormRow(t('Job title'), contactPosition, setContactPosition)}
</Form.Group>
<Form.Group controlId="organization">
{FormRow(t('Organization name'), contactOrg, setContactOrg)}
</Form.Group>
</fieldset>
<fieldset>
<legend>{t('Data License Information')}</legend>
<Form.Group controlId="license-title">
{FormRow(t('Title'), licenseTitle, setLicenseTitle)}
</Form.Group>
<Form.Group controlId="license-url">
{FormRow('URL', licenseURL, setLicenseURL)}
</Form.Group>
</fieldset>
<Form.Row>
<Button
type="submit"
variant="primary"
className="my-1 py2 mx-2"
>
{t('Save Metadata')}
</Button>
{
(alertMsg) && (
<Alert
className="my-1 py-2"
variant={alertError ? 'danger' : 'success'}
>
{t('Save Metadata')}
</Button>
{
(alertMsg) && (
<Alert
className="my-1 py-2"
variant={alertError ? 'danger' : 'success'}
>
{alertMsg}
</Alert>
)
}
</Form.Row>
</Form>
)
}
<Button
variant="outline-secondary"
className="my-1 py-2 mx-2 info-toggle"
onClick={() => setShowInfo((prevState) => !prevState)}
>
{showInfo ? t('Hide Info') : t('More Info')}
</Button>
</div>
{alertMsg}
</Alert>
)
}
</Form.Row>
</Form>
</Modal.Body>
</Modal>
);
}

View File

@ -49,19 +49,21 @@ class OpenButton extends React.Component {
render() {
const { t, className } = this.props;
const tipText = t('Browse to a datastack (.json) or InVEST logfile (.txt)');
const tipText = t(
`Open an InVEST model by loading input parameters from
a .json, .tgz, or InVEST logfile (.txt)`);
return (
<OverlayTrigger
placement="left"
placement="right"
delay={{ show: 250, hide: 400 }}
overlay={<Tooltip>{tipText}</Tooltip>}
>
<Button
className={className}
onClick={this.browseFile}
variant="outline-dark"
variant="outline-primary"
>
{t('Open')}
{t('Browse to a datastack or InVEST logfile')}
</Button>
</OverlayTrigger>
);

View File

@ -7,14 +7,21 @@ import Form from 'react-bootstrap/Form';
import Modal from 'react-bootstrap/Modal';
import Spinner from 'react-bootstrap/Spinner';
import { useTranslation } from 'react-i18next';
import { MdClose } from 'react-icons/md';
import { ipcMainChannels } from '../../../main/ipcMainChannels';
const { ipcRenderer } = window.Workbench.electron;
export default function PluginModal(props) {
const { updateInvestList, closeInvestModel, openJobs } = props;
const [showPluginModal, setShowPluginModal] = useState(false);
const {
updateInvestList,
closeInvestModel,
openJobs,
show,
closeModal,
openModal,
} = props;
const [url, setURL] = useState('');
const [revision, setRevision] = useState('');
const [path, setPath] = useState('');
@ -33,15 +40,7 @@ export default function PluginModal(props) {
setRevision('');
setInstallErr('');
setUninstallErr('');
setShowPluginModal(false);
};
const handleModalOpen = () => {
if (window.Workbench.OS === 'win32') {
ipcRenderer.invoke(ipcMainChannels.HAS_MSVC).then((hasMSVC) => {
setNeedsMSVC(!hasMSVC);
});
}
setShowPluginModal(true);
closeModal();
};
const addPlugin = () => {
@ -84,11 +83,21 @@ export default function PluginModal(props) {
};
const downloadMSVC = () => {
setShowPluginModal(false);
closeModal();
ipcRenderer.invoke(ipcMainChannels.DOWNLOAD_MSVC).then(
() => { handleModalOpen(); }
openModal()
);
}
};
useEffect(() => {
if (show) {
if (window.Workbench.OS === 'win32') {
ipcRenderer.invoke(ipcMainChannels.HAS_MSVC).then((hasMSVC) => {
setNeedsMSVC(!hasMSVC);
});
}
}
}, [show]);
useEffect(() => {
ipcRenderer.invoke(ipcMainChannels.GET_SETTING, 'plugins').then(
@ -286,22 +295,27 @@ export default function PluginModal(props) {
}
return (
<React.Fragment>
<Button onClick={handleModalOpen} variant="outline-dark" aria-label="plugins">
{t('Manage plugins')}
</Button>
<Modal show={showPluginModal} onHide={handleModalClose} contentClassName="plugin-modal">
<Modal.Header>
<Modal.Title>{t('Manage plugins')}</Modal.Title>
</Modal.Header>
{modalBody}
</Modal>
</React.Fragment>
<Modal show={show} onHide={handleModalClose} contentClassName="plugin-modal">
<Modal.Header>
<Modal.Title>{t('Manage plugins')}</Modal.Title>
<Button
variant="secondary-outline"
onClick={handleModalClose}
className="float-right"
aria-label="Close modal"
>
<MdClose />
</Button>
</Modal.Header>
{modalBody}
</Modal>
);
}
PluginModal.propTypes = {
show: PropTypes.bool.isRequired,
closeModal: PropTypes.func.isRequired,
openModal: PropTypes.func.isRequired,
updateInvestList: PropTypes.func.isRequired,
closeInvestModel: PropTypes.func.isRequired,
openJobs: PropTypes.shape({

View File

@ -8,7 +8,6 @@ import Form from 'react-bootstrap/Form';
import Button from 'react-bootstrap/Button';
import Modal from 'react-bootstrap/Modal';
import {
MdSettings,
MdClose,
MdTranslate,
} from 'react-icons/md';
@ -17,7 +16,6 @@ import { withTranslation } from 'react-i18next';
import { ipcMainChannels } from '../../../main/ipcMainChannels';
import { getSupportedLanguages } from '../../server_requests';
import MetadataForm from './MetadataForm';
const { ipcRenderer } = window.Workbench.electron;
@ -26,21 +24,17 @@ class SettingsModal extends React.Component {
constructor(props) {
super(props);
this.state = {
show: false,
languageOptions: null,
loggingLevel: null,
taskgraphLoggingLevel: null,
nWorkers: null,
loggingLevel: '',
taskgraphLoggingLevel: '',
nWorkers: -1,
language: window.Workbench.LANGUAGE,
showConfirmLanguageChange: false,
};
this.handleShow = this.handleShow.bind(this);
this.handleClose = this.handleClose.bind(this);
this.handleChange = this.handleChange.bind(this);
this.handleChangeNumber = this.handleChangeNumber.bind(this);
this.loadSettings = this.loadSettings.bind(this);
this.handleChangeLanguage = this.handleChangeLanguage.bind(this);
this.switchToDownloadModal = this.switchToDownloadModal.bind(this);
}
async componentDidMount() {
@ -51,16 +45,6 @@ class SettingsModal extends React.Component {
this.loadSettings();
}
handleClose() {
this.setState({
show: false,
});
}
handleShow() {
this.setState({ show: true });
}
handleChange(event) {
const { name, value } = event.currentTarget;
this.setState({ [name]: value });
@ -97,14 +81,8 @@ class SettingsModal extends React.Component {
}
}
switchToDownloadModal() {
this.props.showDownloadModal();
this.handleClose();
}
render() {
const {
show,
languageOptions,
language,
loggingLevel,
@ -112,7 +90,7 @@ class SettingsModal extends React.Component {
nWorkers,
showConfirmLanguageChange,
} = this.state;
const { clearJobsStorage, nCPU, t } = this.props;
const { show, close, nCPU, t } = this.props;
const nWorkersOptions = [
[-1, `${t('Synchronous')} (-1)`],
@ -129,28 +107,18 @@ class SettingsModal extends React.Component {
};
return (
<React.Fragment>
<Button
aria-label="settings"
className="settings-icon-btn"
onClick={this.handleShow}
>
<MdSettings
className="settings-icon"
/>
</Button>
<Modal
className="settings-modal"
show={show}
onHide={this.handleClose}
onHide={close}
>
<Modal.Header>
<Modal.Title>{t('InVEST Settings')}</Modal.Title>
<Button
variant="secondary-outline"
onClick={this.handleClose}
onClick={close}
className="float-right"
aria-label="close settings"
aria-label="close modal"
>
<MdClose />
</Button>
@ -273,44 +241,14 @@ class SettingsModal extends React.Component {
: <div />
}
<hr />
<Button
variant="primary"
onClick={this.switchToDownloadModal}
className="w-100"
>
{t('Download Sample Data')}
</Button>
<hr />
<Button
variant="secondary"
onClick={clearJobsStorage}
className="mr-2 w-100"
>
{t('Clear Recent Jobs')}
</Button>
<span><em>{t('*no invest workspaces will be deleted')}</em></span>
<hr />
<Accordion>
<Accordion.Toggle
as={Button}
variant="outline-secondary"
eventKey="0"
className="mr-2 w-100"
>
{t('Configure Metadata')}
<BsChevronDown className="mx-1" />
</Accordion.Toggle>
<Accordion.Collapse eventKey="0">
<MetadataForm />
</Accordion.Collapse>
</Accordion>
</Modal.Body>
</Modal>
{
(languageOptions) ? (
<Modal show={showConfirmLanguageChange} className="confirm-modal" >
<Modal show={showConfirmLanguageChange} className="confirm-modal">
<Modal.Header>
<Modal.Title as="h5" >{t('Warning')}</Modal.Title>
<Modal.Title as="h5">{t('Warning')}</Modal.Title>
</Modal.Header>
<Modal.Body>
<p>
@ -336,8 +274,8 @@ class SettingsModal extends React.Component {
}
SettingsModal.propTypes = {
clearJobsStorage: PropTypes.func.isRequired,
showDownloadModal: PropTypes.func.isRequired,
show: PropTypes.bool.isRequired,
close: PropTypes.func.isRequired,
nCPU: PropTypes.number.isRequired,
};

View File

@ -26,7 +26,7 @@ body {
.navbar {
border-bottom: 3px solid var(--invest-green);
padding: 0;
height: var(--header-height)
height: var(--header-height);
}
.navbar .row {
@ -156,15 +156,22 @@ body {
white-space: nowrap;
}
.settings-icon-btn, .settings-icon-btn:hover {
.app-menu-button {
color: gray;
background-color: transparent;
border-color: transparent;
}
.settings-icon {
color: black;
border: none;
font-size: 2rem;
vertical-align: text-bottom;
margin-right: 2px;
}
.app-menu-button:hover{
color: white;
background-color: gray;
}
.app-menu-button:after {
display: none;
}
.language-icon {
@ -206,10 +213,6 @@ exceed 100% of window.*/
text-overflow: ellipsis;
}
.invest-list-group .list-group-item:last-child {
border-radius: 0;
}
.invest-button {
color: var(--invest-green);
font-weight: 600;
@ -254,23 +257,30 @@ exceed 100% of window.*/
.recent-job-card {
width: inherit;
margin-bottom: 1rem;
padding: 0;
height: fit-content;
filter: drop-shadow(2px 2px 2px grey);
margin-top: 0.5rem;
margin-bottom: 0.5rem;
}
.card-body {
padding: 0;
padding-top: 1rem;
padding-left: 0rem;
padding-right: 0rem;
padding-bottom: 0rem;
width: inherit;
background-color: transparent;
}
.card-header {
background-color: var(--invest-green);
filter: opacity(0.75);
margin-bottom: 1rem;
margin-left: -0.1rem;
margin-right: -0.05rem;
padding-right: 0;
padding-top: 0.5rem;
padding-bottom: 0.5rem;
}
.card-header .header-title {
@ -280,8 +290,14 @@ exceed 100% of window.*/
letter-spacing: 0.5px;
}
.card-header .btn:hover {
background-color: white;
color: var(--invest-green);
}
.card-title {
padding-left: 1.25rem;
font-size: 1.1rem;
}
.card-title .text-heading {
@ -290,7 +306,7 @@ exceed 100% of window.*/
.card-title .text-mono {
font-family: monospace;
font-size: 1.2rem;
font-size: 1.1rem;
}
.card-footer {

View File

@ -181,7 +181,7 @@ test('Run a real invest model', async () => {
'aria/[name="Download InVEST sample data"][role="dialog"]'
);
const downloadModalCancel = await downloadModal.waitForSelector(
'aria/[name="Cancel"][role="button"]');
'aria/[name="Close modal"][role="button"]');
await page.waitForTimeout(WAIT_TO_CLICK); // waiting for click handler to be ready
await downloadModalCancel.click();
@ -240,7 +240,7 @@ test('Run a real invest model', async () => {
await page.screenshot({ path: `${SCREENSHOT_PREFIX}6-run-canceled.png` });
}, 240000); // >2x the sum of all the max timeouts within this test
test('Check local userguide links', async () => {
test.only('Check local userguide links', async () => {
// On GHA MacOS, we seem to have to wait a long time for the browser
// to be ready. Maybe related to https://github.com/natcap/invest-workbench/issues/158
let i = 0;
@ -262,7 +262,7 @@ test('Check local userguide links', async () => {
'aria/[name="Download InVEST sample data"][role="dialog"]'
);
const downloadModalCancel = await downloadModal.waitForSelector(
'aria/[name="Cancel"][role="button"]');
'aria/[name="Close modal"][role="button"]');
await page.waitForTimeout(WAIT_TO_CLICK); // waiting for click handler to be ready
await downloadModalCancel.click();
@ -275,7 +275,7 @@ test('Check local userguide links', async () => {
await changelogModalClose.click();
const investList = await page.waitForSelector('.invest-list-group');
const modelButtons = await investList.$$('aria/[role="button"]');
const modelButtons = await investList.$$('button.invest-button');
await page.waitForTimeout(WAIT_TO_CLICK); // first btn click does not register w/o this pause
for (const btn of modelButtons) {
@ -323,7 +323,7 @@ test.skip('Install and run a plugin', async () => {
await page.screenshot({ path: `${SCREENSHOT_PREFIX}1-page-load.png` });
const downloadModal = await page.waitForSelector('.modal-dialog');
const downloadModalCancel = await downloadModal.waitForSelector(
'aria/[name="Cancel"][role="button"]'
'aria/[name="close modal"][role="button"]'
);
await page.waitForTimeout(WAIT_TO_CLICK); // waiting for click handler to be ready
await downloadModalCancel.click();
@ -334,9 +334,11 @@ test.skip('Install and run a plugin', async () => {
await page.waitForTimeout(WAIT_TO_CLICK);
await changelogModalCancel.click();
const addPluginButton = await page.waitForSelector('aria/[name="plugins"][role="button"]');
await addPluginButton.click();
console.log('clicked add plugin');
const dropdownButton = await page.waitForSelector('aria/[name="menu"][role="button"]');
await dropdownButton.click();
const pluginsModalButton = await page.waitForSelector('aria/[name="Manage plugins"][role="button"]');
await pluginsModalButton.click();
console.log('opened plugin modal');
const urlInputField = await page.waitForSelector('aria/[name="Git URL"][role="textbox"]');
console.log('found url field');
await urlInputField.type(TEST_PLUGIN_GIT_URL, { delay: TYPE_DELAY });

View File

@ -1,4 +1,3 @@
import crypto from 'crypto';
import fetch from 'node-fetch';
import '../src/renderer/i18n/i18n';

View File

@ -13,16 +13,11 @@ import {
fetchValidation,
fetchDatastackFromFile,
fetchArgsEnabled,
getSupportedLanguages,
getGeoMetaMakerProfile,
} from '../../src/renderer/server_requests';
import InvestJob from '../../src/renderer/InvestJob';
import {
settingsStore,
setupSettingsHandlers
} from '../../src/main/settingsStore';
import { ipcMainChannels } from '../../src/main/ipcMainChannels';
import { removeIpcMainListeners } from '../../src/main/main';
import pkg from '../../package.json';
jest.mock('../../src/renderer/server_requests');
@ -145,7 +140,8 @@ describe('Various ways to open and close InVEST models', () => {
<App />
);
const openButton = await findByRole('button', { name: 'Open' });
const openButton = await findByRole(
'button', { name: /browse to a datastack or invest logfile/i });
expect(openButton).not.toBeDisabled();
await userEvent.click(openButton);
const executeButton = await findByRole('button', { name: /Run/ });
@ -175,7 +171,8 @@ describe('Various ways to open and close InVEST models', () => {
<App />
);
const openButton = await findByRole('button', { name: 'Open' });
const openButton = await findByRole(
'button', { name: /browse to a datastack or invest logfile/i });
await userEvent.click(openButton);
const homeTab = await findByRole('tabpanel', { name: 'home tab' });
// expect we're on the same tab we started on instead of switching to Setup
@ -305,7 +302,7 @@ describe('Display recently executed InVEST jobs on Home tab', () => {
await waitFor(() => {
initialJobs.forEach((job, idx) => {
const recent = recentJobs[idx];
const card = getByText(job.modelTitle)
const card = getByText(job.argsValues.workspace_dir)
.closest('button');
expect(within(card).getByText(job.argsValues.workspace_dir))
.toBeInTheDocument();
@ -369,7 +366,7 @@ describe('Display recently executed InVEST jobs on Home tab', () => {
const { findByText, queryByText } = render(<App />);
expect(queryByText(job1.modelTitle)).toBeNull();
expect(await findByText(/Set up a model from a sample datastack file/))
expect(await findByText('Welcome!'))
.toBeInTheDocument();
});
@ -378,13 +375,11 @@ describe('Display recently executed InVEST jobs on Home tab', () => {
<App />
);
const node = await findByText(/Set up a model from a sample datastack file/);
const node = await findByText('Welcome!');
expect(node).toBeInTheDocument();
});
test('Recent Jobs: cleared by button', async () => {
// we need this mock because the settings dialog is opened
getGeoMetaMakerProfile.mockResolvedValue({});
test('Recent Jobs: cleared by clear all button', async () => {
const job1 = new InvestJob({
modelID: MOCK_MODEL_ID,
modelTitle: 'Carbon Sequestration',
@ -405,100 +400,167 @@ describe('Display recently executed InVEST jobs on Home tab', () => {
.toBeTruthy();
});
});
await userEvent.click(getByRole('button', { name: 'settings' }));
await userEvent.click(getByText('Clear Recent Jobs'));
const node = await findByText(/Set up a model from a sample datastack file/);
await userEvent.click(getByText(/clear all model runs/i));
const node = await findByText('Welcome!');
expect(node).toBeInTheDocument();
});
test('Recent Jobs: delete single job', async () => {
const job1 = new InvestJob({
modelID: MOCK_MODEL_ID,
modelTitle: 'Carbon Sequestration',
argsValues: {
workspace_dir: 'work1',
},
status: 'success',
// leave out the 'type' attribute to make sure it defaults to core
// for backwards compatibility
});
const recentJobs = await InvestJob.saveJob(job1);
const { getByText, findByText, getByRole } = render(<App />);
await waitFor(() => {
recentJobs.forEach((job) => {
expect(getByText(job.argsValues.workspace_dir))
.toBeTruthy();
});
});
await userEvent.click(getByRole('button', { name: 'delete' }));
const node = await findByText('Welcome!');
expect(node).toBeInTheDocument();
});
});
describe('InVEST global settings: dialog interactions', () => {
const nWorkersLabelText = 'Taskgraph n_workers parameter';
const loggingLabelText = 'Logging threshold';
const tgLoggingLabelText = 'Taskgraph logging threshold';
const languageLabelText = 'Language';
beforeAll(() => {
setupSettingsHandlers();
describe('Main menu interactions', () => {
beforeEach(() => {
getInvestModelIDs.mockResolvedValue(MOCK_INVEST_LIST);
});
afterAll(() => {
removeIpcMainListeners();
});
beforeEach(async () => {
getInvestModelIDs.mockResolvedValue({});
getSupportedLanguages.mockResolvedValue({ en: 'english', es: 'spanish' });
getGeoMetaMakerProfile.mockResolvedValue({});
});
test('Invest settings save on change', async () => {
const nWorkersLabel = 'Threaded task management (0)';
const nWorkersValue = 0;
const loggingLevel = 'DEBUG';
const tgLoggingLevel = 'DEBUG';
const languageValue = 'es';
const spyInvoke = jest.spyOn(ipcRenderer, 'invoke');
test('Open sampledata download Modal from menu', async () => {
const {
getByText, getByLabelText, findByRole, findByText,
findByText, findByRole,
} = render(
<App />
);
await userEvent.click(await findByRole('button', { name: 'settings' }));
const nWorkersInput = getByLabelText(nWorkersLabelText, { exact: false });
const loggingInput = getByLabelText(loggingLabelText);
const tgLoggingInput = getByLabelText(tgLoggingLabelText);
const dropdownBtn = await findByRole('button', { name: 'menu' });
await userEvent.click(dropdownBtn);
await userEvent.click(
await findByRole('button', { name: /Download Sample Data/i })
);
await userEvent.selectOptions(nWorkersInput, [getByText(nWorkersLabel)]);
await waitFor(() => { expect(nWorkersInput).toHaveValue(nWorkersValue.toString()); });
await userEvent.selectOptions(loggingInput, [loggingLevel]);
await waitFor(() => { expect(loggingInput).toHaveValue(loggingLevel); });
await userEvent.selectOptions(tgLoggingInput, [tgLoggingLevel]);
await waitFor(() => { expect(tgLoggingInput).toHaveValue(tgLoggingLevel); });
// Check values were saved
expect(settingsStore.get('nWorkers')).toBe(nWorkersValue);
expect(settingsStore.get('loggingLevel')).toBe(loggingLevel);
expect(settingsStore.get('taskgraphLoggingLevel')).toBe(tgLoggingLevel);
// language is handled differently; changing it triggers electron to restart
const languageInput = getByLabelText(languageLabelText, { exact: false });
await userEvent.selectOptions(languageInput, [languageValue]);
await userEvent.click(await findByText('Change to spanish'));
expect(spyInvoke)
.toHaveBeenCalledWith(ipcMainChannels.CHANGE_LANGUAGE, languageValue);
expect(await findByText(/Download InVEST sample data/i))
.toBeInTheDocument();
await userEvent.click(
await findByRole('button', { name: /close modal/i })
);
});
test('Access sampledata download Modal from settings', async () => {
test('Open Metadata Modal from menu', async () => {
const {
findByText, findByRole, queryByText,
findByText, findByRole,
} = render(
<App />
);
const settingsBtn = await findByRole('button', { name: 'settings' });
await userEvent.click(settingsBtn);
const dropdownBtn = await findByRole('button', { name: 'menu' });
await userEvent.click(dropdownBtn);
await userEvent.click(
await findByRole('button', { name: 'Download Sample Data' })
await findByRole('button', { name: /Configure Metadata/i })
);
expect(await findByText('Download InVEST sample data'))
expect(await findByText(/contact information/i))
.toBeInTheDocument();
expect(queryByText('Settings')).toBeNull();
await userEvent.click(
await findByRole('button', { name: /close modal/i })
);
});
test('Access metadata form from settings', async () => {
const { findByRole } = render(<App />);
test('Open Plugins Modal from menu', async () => {
ipcRenderer.invoke.mockImplementation((channel) => {
if (channel === ipcMainChannels.HAS_MSVC) {
return Promise.resolve(true);
}
return Promise.resolve();
});
const settingsBtn = await findByRole('button', { name: 'settings' });
await userEvent.click(settingsBtn);
await userEvent.click(
await findByRole('button', { name: 'Configure Metadata' })
const {
findByText, findByRole,
} = render(
<App />
);
expect(await findByRole('button', { name: 'Save Metadata' }))
const dropdownBtn = await findByRole('button', { name: 'menu' });
await userEvent.click(dropdownBtn);
await userEvent.click(
await findByRole('button', { name: /Manage Plugins/i })
);
expect(await findByText(/add a plugin/i))
.toBeInTheDocument();
await userEvent.click(
await findByRole('button', { name: /close modal/i })
);
});
test('Open Changelog Modal from menu', async () => {
const currentVersion = pkg.version;
const nonexistentVersion = '1.0.0';
jest.spyOn(window, 'fetch')
.mockResolvedValueOnce({
ok: true,
text: () => `
<html>
<head></head>
<body>
<section>
<h1>${currentVersion}</h1>
</section>
<section>
<h1>${nonexistentVersion}</h1>
</section>
</body>
</html>
`
});
const {
findByText, findByRole,
} = render(
<App />
);
const dropdownBtn = await findByRole('button', { name: 'menu' });
await userEvent.click(dropdownBtn);
await userEvent.click(
await findByRole('button', { name: /view changelog/i })
);
expect(await findByText(/new in this version/i))
.toBeInTheDocument();
await userEvent.click(
await findByRole('button', { name: /close modal/i })
);
});
test('Open Settings Modal from menu', async () => {
const {
findByText, findByRole,
} = render(
<App />
);
const dropdownBtn = await findByRole('button', { name: 'menu' });
await userEvent.click(dropdownBtn);
await userEvent.click(
await findByRole('button', { name: /settings/i })
);
expect(await findByText(/invest settings/i))
.toBeInTheDocument();
await userEvent.click(
await findByRole('button', { name: /close modal/i })
);
});
});

View File

@ -10,10 +10,10 @@ import { getInvestModelIDs } from '../../src/renderer/server_requests';
jest.mock('../../src/renderer/server_requests');
const MOCK_MODEL_TITLE = 'Carbon';
const MOCK_MODEL_RUN_NAME = 'carbon';
const MOCK_MODEL_ID = 'carbon';
const MOCK_INVEST_LIST = {
[MOCK_MODEL_TITLE]: {
model_name: MOCK_MODEL_RUN_NAME,
[MOCK_MODEL_ID]: {
model_title: MOCK_MODEL_TITLE,
},
};
@ -48,7 +48,7 @@ describe('Changelog', () => {
});
test('On first run (of any version), Changelog modal opens after Download modal is closed', async () => {
const { findByRole, getByText } = render(<App isFirstRun isNewVersion />);
const { findByRole, getByRole } = render(<App isFirstRun isNewVersion />);
let changelogModalFound = true;
try {
@ -61,7 +61,7 @@ describe('Changelog', () => {
const downloadModal = await findByRole('dialog', { name: 'Download InVEST sample data' });
expect(downloadModal).toBeInTheDocument();
await userEvent.click(getByText('Cancel'));
await userEvent.click(getByRole('button', { name: /close modal/i }));
expect(downloadModal).not.toBeInTheDocument();
const changelogModal = await findByRole('dialog', { name: 'New in this version' });
expect(changelogModal).toBeInTheDocument();

View File

@ -29,12 +29,12 @@ describe('Sample Data Download Form', () => {
test('Modal displays immediately on user`s first run', async () => {
const {
findByText,
getByText,
getByRole,
} = render(<App isFirstRun />);
const modalTitle = await findByText('Download InVEST sample data');
expect(modalTitle).toBeInTheDocument();
userEvent.click(getByText('Cancel'));
await userEvent.click(getByRole('button', { name: /close modal/i }));
await waitFor(() => {
expect(modalTitle).not.toBeInTheDocument();
});

View File

@ -115,4 +115,22 @@ describe('InvestJob', () => {
recentJobs = await InvestJob.clearStore();
expect(recentJobs).toHaveLength(0);
});
test('deleteJob deletes the job', async () => {
const job1 = new InvestJob({
modelID: 'foo',
modelTitle: 'Foo',
argsValues: baseArgsValues,
});
const job2 = new InvestJob({
modelID: 'foo',
modelTitle: 'Foo',
argsValues: baseArgsValues,
});
let recentJobs = await InvestJob.saveJob(job1);
recentJobs = await InvestJob.saveJob(job2);
expect(recentJobs).toHaveLength(2);
recentJobs = await InvestJob.deleteJob(job1.hash);
expect(recentJobs).toHaveLength(1);
});
});

View File

@ -3,7 +3,7 @@ import { render, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import userEvent from '@testing-library/user-event';
import MetadataForm from '../../src/renderer/components/SettingsModal/MetadataForm';
import MetadataModal from '../../src/renderer/components/MetadataModal';
import {
getGeoMetaMakerProfile,
setGeoMetaMakerProfile,
@ -36,8 +36,12 @@ test('Metadata form interact and submit', async () => {
findByRole,
getByLabelText,
getByRole,
getByText,
} = render(<MetadataForm />);
} = render(
<MetadataModal
show
close={() => {}}
/>
);
// The form should render with content from an existing profile
const nameInput = getByLabelText('Full name');
@ -56,11 +60,6 @@ test('Metadata form interact and submit', async () => {
await user.type(nameInput, name);
await user.type(licenseInput, license);
// Exercise the "more info" button
await user.click(getByRole('button', { name: /more info/i }));
expect(getByText('Metadata for InVEST results')).toBeInTheDocument();
await user.click(getByRole('button', { name: /hide info/i }));
const submit = getByRole('button', { name: /save metadata/i });
await user.click(submit);
@ -104,7 +103,12 @@ test('Metadata form error on submit', async () => {
findByRole,
getByLabelText,
getByRole,
} = render(<MetadataForm />);
} = render(
<MetadataModal
show
close={() => {}}
/>
);
const submit = getByRole('button', { name: /save metadata/i });
await user.click(submit);

View File

@ -19,9 +19,10 @@ test('Open File: displays a tooltip on hover', async () => {
/>
);
const openButton = await findByRole('button', { name: 'Open' });
const openButton = await findByRole(
'button', { name: /Browse to a datastack or InVEST logfile/i });
await userEvent.hover(openButton);
const hoverText = 'Browse to a datastack (.json) or InVEST logfile (.txt)';
const hoverText = /Open an InVEST model by loading/i;
expect(await findByText(hoverText)).toBeInTheDocument();
await userEvent.unhover(openButton);
await waitFor(() => {
@ -53,7 +54,8 @@ test('Open File: sends correct payload', async () => {
/>
);
const openButton = await findByRole('button', { name: 'Open' });
const openButton = await findByRole(
'button', { name: /Browse to a datastack or InVEST logfile/i });
await userEvent.click(openButton);
await waitFor(() => {

View File

@ -1,7 +1,7 @@
import { ipcRenderer } from 'electron';
import React from 'react';
import {
act, within, render, waitFor
within, render, waitFor
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import '@testing-library/jest-dom';
@ -62,10 +62,11 @@ describe('Add plugin modal', () => {
return Promise.resolve();
});
const {
findByText, findByLabelText, findByRole, queryByRole,
findByText, findByLabelText, findByRole,
} = render(<App />);
const managePluginsButton = await findByText('Manage plugins');
await userEvent.click(await findByRole('button', { name: 'menu' }));
const managePluginsButton = await findByText(/Manage plugins/i);
userEvent.click(managePluginsButton);
const urlField = await findByLabelText('Git URL');
@ -105,9 +106,7 @@ describe('Add plugin modal', () => {
const spy = jest.spyOn(ipcRenderer, 'send');
const { findByRole } = render(<App />);
const pluginButton = await findByRole('button', { name: /Foo/ });
await act(async () => {
userEvent.click(pluginButton);
});
await userEvent.click(pluginButton);
const executeButton = await findByRole('button', { name: /Run/ });
expect(executeButton).toBeEnabled();
// Nothing is really different about plugin tabs on the renderer side, so
@ -151,7 +150,8 @@ describe('Add plugin modal', () => {
const pluginButton = await findByRole('button', { name: /Foo/ });
await userEvent.click(pluginButton);
const managePluginsButton = await findByText('Manage plugins');
await userEvent.click(await findByRole('button', { name: 'menu' }));
const managePluginsButton = await findByText(/Manage plugins/i);
await userEvent.click(managePluginsButton);
const pluginDropdown = await findByLabelText('Plugin name');

View File

@ -0,0 +1,80 @@
import React from 'react';
import { ipcRenderer } from 'electron';
import {
render, waitFor
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import '@testing-library/jest-dom';
import {
settingsStore,
setupSettingsHandlers
} from '../../src/main/settingsStore';
import { ipcMainChannels } from '../../src/main/ipcMainChannels';
import { removeIpcMainListeners } from '../../src/main/main';
import { getSupportedLanguages } from '../../src/renderer/server_requests';
import SettingsModal from '../../src/renderer/components/SettingsModal';
jest.mock('../../src/renderer/server_requests');
describe('InVEST global settings: dialog interactions', () => {
const nWorkersLabelText = 'Taskgraph n_workers parameter';
const loggingLabelText = 'Logging threshold';
const tgLoggingLabelText = 'Taskgraph logging threshold';
const languageLabelText = 'Language';
beforeAll(() => {
setupSettingsHandlers();
});
afterAll(() => {
removeIpcMainListeners();
});
beforeEach(async () => {
getSupportedLanguages.mockResolvedValue({ en: 'english', es: 'spanish' });
});
test('Invest settings save on change', async () => {
const nWorkersLabel = 'Threaded task management (0)';
const nWorkersValue = 0;
const loggingLevel = 'DEBUG';
const tgLoggingLevel = 'DEBUG';
const languageValue = 'es';
const spyInvoke = jest.spyOn(ipcRenderer, 'invoke');
const {
getByText, getByLabelText, findByText,
} = render(
<SettingsModal
show
close={() => {}}
nCPU={4}
/>
);
const nWorkersInput = getByLabelText(nWorkersLabelText, { exact: false });
const loggingInput = getByLabelText(loggingLabelText);
const tgLoggingInput = getByLabelText(tgLoggingLabelText);
await userEvent.selectOptions(nWorkersInput, [getByText(nWorkersLabel)]);
await waitFor(() => { expect(nWorkersInput).toHaveValue(nWorkersValue.toString()); });
await userEvent.selectOptions(loggingInput, [loggingLevel]);
await waitFor(() => { expect(loggingInput).toHaveValue(loggingLevel); });
await userEvent.selectOptions(tgLoggingInput, [tgLoggingLevel]);
await waitFor(() => { expect(tgLoggingInput).toHaveValue(tgLoggingLevel); });
// Check values were saved
expect(settingsStore.get('nWorkers')).toBe(nWorkersValue);
expect(settingsStore.get('loggingLevel')).toBe(loggingLevel);
expect(settingsStore.get('taskgraphLoggingLevel')).toBe(tgLoggingLevel);
// language is handled differently; changing it triggers electron to restart
const languageInput = getByLabelText(languageLabelText, { exact: false });
await userEvent.selectOptions(languageInput, [languageValue]);
await userEvent.click(await findByText('Change to spanish'));
expect(spyInvoke)
.toHaveBeenCalledWith(ipcMainChannels.CHANGE_LANGUAGE, languageValue);
});
});