Merge remote-tracking branch 'upstream/feature/plugins' into feature/WB-1793-settings-layout
This commit is contained in:
commit
a03a5c7e71
|
@ -312,7 +312,7 @@ class RasterInput(FileInput):
|
|||
are allowed), which may have multiple bands.
|
||||
"""
|
||||
bands: typing.Iterable[RasterBand] = dataclasses.field(default_factory=[])
|
||||
"""An iterable of `RasterBand`s representing the bands expected to be in
|
||||
"""An iterable of `RasterBand` representing the bands expected to be in
|
||||
the raster."""
|
||||
|
||||
projected: typing.Union[bool, None] = None
|
||||
|
@ -510,15 +510,32 @@ class VectorInput(FileInput):
|
|||
|
||||
@dataclasses.dataclass
|
||||
class RasterOrVectorInput(FileInput):
|
||||
"""An invest model input of either a single-band raster or a vector."""
|
||||
type: typing.ClassVar[str] = 'raster_or_vector'
|
||||
"""An invest model input that can be either a single-band raster or a vector."""
|
||||
|
||||
data_type: typing.Type = float
|
||||
"""Data type for the raster values (float or int)"""
|
||||
|
||||
units: typing.Union[pint.Unit, None] = None
|
||||
"""Units of measurement of the raster values"""
|
||||
|
||||
geometry_types: set = dataclasses.field(default_factory=dict)
|
||||
"""A set of geometry type(s) that are allowed for this vector"""
|
||||
|
||||
fields: typing.Union[typing.Iterable[Input], None] = None
|
||||
"""An iterable of `Input`s representing the fields that this vector is
|
||||
expected to have. The `key` of each input must match the corresponding
|
||||
field name."""
|
||||
|
||||
projected: typing.Union[bool, None] = None
|
||||
"""Defaults to None, indicating a projected (as opposed to geographic)
|
||||
coordinate system is not required. Set to True if a projected coordinate
|
||||
system is required."""
|
||||
|
||||
projection_units: typing.Union[pint.Unit, None] = None
|
||||
"""Defaults to None. If `projected` is `True`, and a specific unit of
|
||||
projection (such as meters) is required, indicate it here."""
|
||||
|
||||
type: typing.ClassVar[str] = 'raster_or_vector'
|
||||
|
||||
def __post_init__(self):
|
||||
self.single_band_raster_input = SingleBandRasterInput(
|
||||
|
|
|
@ -244,6 +244,7 @@ class InvestTab extends React.Component {
|
|||
const logDisabled = !logfile;
|
||||
const sidebarSetupElementId = `sidebar-setup-${tabID}`;
|
||||
const sidebarFooterElementId = `sidebar-footer-${tabID}`;
|
||||
const isCoreModel = investList[modelID].type === 'core';
|
||||
|
||||
return (
|
||||
<>
|
||||
|
@ -275,6 +276,7 @@ class InvestTab extends React.Component {
|
|||
<div className="sidebar-row sidebar-links">
|
||||
<ResourcesLinks
|
||||
modelID={modelID}
|
||||
isCoreModel={isCoreModel}
|
||||
docs={modelSpec.userguide}
|
||||
/>
|
||||
</div>
|
||||
|
@ -303,6 +305,7 @@ class InvestTab extends React.Component {
|
|||
>
|
||||
<SetupTab
|
||||
userguide={modelSpec.userguide}
|
||||
isCoreModel={isCoreModel}
|
||||
modelID={modelID}
|
||||
argsSpec={argsSpec}
|
||||
inputFieldOrder={modelSpec.input_field_order}
|
||||
|
|
|
@ -40,17 +40,6 @@ const FORUM_TAGS = {
|
|||
wind_energy: 'wind-energy',
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Open the target href in an electron window.
|
||||
*/
|
||||
function handleUGClick(event) {
|
||||
event.preventDefault();
|
||||
ipcRenderer.send(
|
||||
ipcMainChannels.OPEN_LOCAL_HTML, event.currentTarget.href
|
||||
);
|
||||
}
|
||||
|
||||
/** Render model-relevant links to the User's Guide and Forum.
|
||||
*
|
||||
* This should be a link to the model's User's Guide chapter and
|
||||
|
@ -58,7 +47,7 @@ function handleUGClick(event) {
|
|||
* e.g. https://community.naturalcapitalproject.org/tag/carbon
|
||||
*/
|
||||
export default function ResourcesTab(props) {
|
||||
const { docs, modelID } = props;
|
||||
const { docs, isCoreModel, modelID } = props;
|
||||
|
||||
let forumURL = FORUM_ROOT;
|
||||
const tagName = FORUM_TAGS[modelID];
|
||||
|
@ -67,23 +56,51 @@ export default function ResourcesTab(props) {
|
|||
}
|
||||
|
||||
const { t } = useTranslation();
|
||||
const userGuideURL = `${window.Workbench.USERGUIDE_PATH}/${window.Workbench.LANGUAGE}/${docs}`;
|
||||
|
||||
const userGuideURL = (
|
||||
isCoreModel
|
||||
? `${window.Workbench.USERGUIDE_PATH}/${window.Workbench.LANGUAGE}/${docs}`
|
||||
: docs
|
||||
);
|
||||
const userGuideDisplayText = isCoreModel ? "User's Guide" : "Plugin Documentation";
|
||||
const userGuideAddlInfo = isCoreModel ? '(opens in new window)' : '(opens in web browser)';
|
||||
const userGuideAriaLabel = `${userGuideDisplayText} ${userGuideAddlInfo}`;
|
||||
|
||||
/**
|
||||
* Open the target href in an electron window.
|
||||
*/
|
||||
const handleUGClick = (event) => {
|
||||
event.preventDefault();
|
||||
if (isCoreModel) {
|
||||
ipcRenderer.send(
|
||||
ipcMainChannels.OPEN_LOCAL_HTML, event.currentTarget.href
|
||||
);
|
||||
} else {
|
||||
ipcRenderer.send(
|
||||
ipcMainChannels.OPEN_EXTERNAL_URL, event.currentTarget.href
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<a
|
||||
href={userGuideURL}
|
||||
title={userGuideURL}
|
||||
aria-label="go to user's guide in web browser"
|
||||
onClick={handleUGClick}
|
||||
>
|
||||
<MdOpenInNew className="mr-1" />
|
||||
{t("User's Guide")}
|
||||
</a>
|
||||
{
|
||||
userGuideURL
|
||||
&&
|
||||
<a
|
||||
href={userGuideURL}
|
||||
title={userGuideURL}
|
||||
aria-label={t(userGuideAriaLabel)}
|
||||
onClick={handleUGClick}
|
||||
>
|
||||
<MdOpenInNew className="mr-1" />
|
||||
{t(userGuideDisplayText)}
|
||||
</a>
|
||||
}
|
||||
<a
|
||||
href={forumURL}
|
||||
title={forumURL}
|
||||
aria-label="go to frequently asked questions in web browser"
|
||||
aria-label={t('Frequently Asked Questions (opens in web browser)')}
|
||||
onClick={openLinkInBrowser}
|
||||
>
|
||||
<MdOpenInNew className="mr-1" />
|
||||
|
@ -95,6 +112,7 @@ export default function ResourcesTab(props) {
|
|||
|
||||
ResourcesTab.propTypes = {
|
||||
modelID: PropTypes.string,
|
||||
isCoreModel: PropTypes.bool.isRequired,
|
||||
docs: PropTypes.string,
|
||||
};
|
||||
ResourcesTab.defaultProps = {
|
||||
|
|
|
@ -164,6 +164,7 @@ export default function ArgInput(props) {
|
|||
argkey,
|
||||
argSpec,
|
||||
userguide,
|
||||
isCoreModel,
|
||||
enabled,
|
||||
updateArgValues,
|
||||
handleFocus,
|
||||
|
@ -322,7 +323,12 @@ export default function ArgInput(props) {
|
|||
<Col>
|
||||
<InputGroup>
|
||||
<div className="d-flex flex-nowrap w-100">
|
||||
<AboutModal arg={argSpec} userguide={userguide} argkey={argkey} />
|
||||
<AboutModal
|
||||
arg={argSpec}
|
||||
userguide={userguide}
|
||||
isCoreModel={isCoreModel}
|
||||
argkey={argkey}
|
||||
/>
|
||||
{form}
|
||||
</div>
|
||||
{feedback}
|
||||
|
@ -341,6 +347,7 @@ ArgInput.propTypes = {
|
|||
units: PropTypes.string, // for numbers only
|
||||
}).isRequired,
|
||||
userguide: PropTypes.string.isRequired,
|
||||
isCoreModel: PropTypes.bool.isRequired,
|
||||
value: PropTypes.oneOfType(
|
||||
[PropTypes.string, PropTypes.bool, PropTypes.number]),
|
||||
touched: PropTypes.bool,
|
||||
|
@ -380,13 +387,16 @@ function AboutModal(props) {
|
|||
const handleAboutClose = () => setAboutShow(false);
|
||||
const handleAboutOpen = () => setAboutShow(true);
|
||||
|
||||
const { userguide, arg, argkey } = props;
|
||||
const { userguide, arg, argkey, isCoreModel } = props;
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
// create link to users guide entry for this arg
|
||||
// create link to users guide entry for this arg IFF this is a core model
|
||||
// anchor name is the arg name, with underscores replaced with hyphens
|
||||
const userguideURL = `
|
||||
${window.Workbench.USERGUIDE_PATH}/${window.Workbench.LANGUAGE}/${userguide}#${argkey.replace(/_/g, '-')}`;
|
||||
const userguideURL = (
|
||||
isCoreModel
|
||||
? `${window.Workbench.USERGUIDE_PATH}/${window.Workbench.LANGUAGE}/${userguide}#${argkey.replace(/_/g, '-')}`
|
||||
: null
|
||||
);
|
||||
return (
|
||||
<React.Fragment>
|
||||
<Button
|
||||
|
@ -404,15 +414,19 @@ function AboutModal(props) {
|
|||
<Modal.Body>
|
||||
{arg.about}
|
||||
<br />
|
||||
<a
|
||||
href={userguideURL}
|
||||
title={userguideURL}
|
||||
aria-label="open user guide section for this input in web browser"
|
||||
onClick={handleClickUsersGuideLink}
|
||||
>
|
||||
{t("User's guide entry")}
|
||||
<MdOpenInNew className="mr-1" />
|
||||
</a>
|
||||
{
|
||||
isCoreModel
|
||||
&&
|
||||
<a
|
||||
href={userguideURL}
|
||||
title={userguideURL}
|
||||
aria-label={t("User's guide entry (opens in new window)")}
|
||||
onClick={handleClickUsersGuideLink}
|
||||
>
|
||||
{t("User's guide entry")}
|
||||
<MdOpenInNew className="mr-1" />
|
||||
</a>
|
||||
}
|
||||
</Modal.Body>
|
||||
</Modal>
|
||||
</React.Fragment>
|
||||
|
@ -425,5 +439,6 @@ AboutModal.propTypes = {
|
|||
about: PropTypes.string,
|
||||
}).isRequired,
|
||||
userguide: PropTypes.string.isRequired,
|
||||
isCoreModel: PropTypes.bool.isRequired,
|
||||
argkey: PropTypes.string.isRequired,
|
||||
};
|
||||
|
|
|
@ -124,6 +124,7 @@ class ArgsForm extends React.Component {
|
|||
argsEnabled,
|
||||
argsDropdownOptions,
|
||||
userguide,
|
||||
isCoreModel,
|
||||
scrollEventCount,
|
||||
} = this.props;
|
||||
const formItems = [];
|
||||
|
@ -137,6 +138,7 @@ class ArgsForm extends React.Component {
|
|||
argkey={argkey}
|
||||
argSpec={argsSpec[argkey]}
|
||||
userguide={userguide}
|
||||
isCoreModel={isCoreModel}
|
||||
dropdownOptions={argsDropdownOptions[argkey]}
|
||||
enabled={argsEnabled[argkey]}
|
||||
updateArgValues={this.props.updateArgValues}
|
||||
|
@ -201,6 +203,7 @@ ArgsForm.propTypes = {
|
|||
).isRequired,
|
||||
argsEnabled: PropTypes.objectOf(PropTypes.bool),
|
||||
userguide: PropTypes.string.isRequired,
|
||||
isCoreModel: PropTypes.bool.isRequired,
|
||||
updateArgValues: PropTypes.func.isRequired,
|
||||
loadParametersFromFile: PropTypes.func.isRequired,
|
||||
scrollEventCount: PropTypes.number,
|
||||
|
|
|
@ -552,6 +552,7 @@ class SetupTab extends React.Component {
|
|||
const {
|
||||
argsSpec,
|
||||
userguide,
|
||||
isCoreModel,
|
||||
inputFieldOrder,
|
||||
sidebarSetupElementId,
|
||||
sidebarFooterElementId,
|
||||
|
@ -598,6 +599,7 @@ class SetupTab extends React.Component {
|
|||
)
|
||||
: <span>{t('Run')}</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<Container fluid>
|
||||
<Row>
|
||||
|
@ -609,6 +611,7 @@ class SetupTab extends React.Component {
|
|||
argsDropdownOptions={argsDropdownOptions}
|
||||
argsOrder={inputFieldOrder}
|
||||
userguide={userguide}
|
||||
isCoreModel={isCoreModel}
|
||||
updateArgValues={this.updateArgValues}
|
||||
updateArgTouched={this.updateArgTouched}
|
||||
loadParametersFromFile={this.loadParametersFromFile}
|
||||
|
@ -666,6 +669,7 @@ export default withTranslation()(SetupTab);
|
|||
|
||||
SetupTab.propTypes = {
|
||||
userguide: PropTypes.string.isRequired,
|
||||
isCoreModel: PropTypes.bool.isRequired,
|
||||
modelID: PropTypes.string.isRequired,
|
||||
argsSpec: PropTypes.objectOf(
|
||||
PropTypes.shape({
|
||||
|
|
|
@ -239,6 +239,7 @@ describe('Test building model UIs and forum links', () => {
|
|||
modelID={modelID}
|
||||
argsSpec={argsSpec.args}
|
||||
userguide={argsSpec.userguide}
|
||||
isCoreModel={true}
|
||||
inputFieldOrder={argsSpec.input_field_order}
|
||||
argsInitValues={undefined}
|
||||
investExecute={() => {}}
|
||||
|
@ -258,6 +259,7 @@ describe('Test building model UIs and forum links', () => {
|
|||
const { findByRole } = render(
|
||||
<ResourcesLinks
|
||||
modelID={modelID}
|
||||
isCoreModel={true}
|
||||
docs={argsSpec.userguide}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -40,7 +40,10 @@ function renderInvestTab(job = DEFAULT_JOB) {
|
|||
tabID={tabID}
|
||||
saveJob={() => {}}
|
||||
updateJobProperties={() => {}}
|
||||
investList={{ foo: { modelTitle: 'Foo Model' } }}
|
||||
investList={{
|
||||
carbon: { modelTitle: 'Carbon Model', type: 'core' },
|
||||
foo: { modelTitle: 'Foo Model', type: 'plugin' },
|
||||
}}
|
||||
/>
|
||||
);
|
||||
return utils;
|
||||
|
@ -88,6 +91,7 @@ describe('Run status Alert renders with status from a recent run', () => {
|
|||
status: status,
|
||||
argsValues: {},
|
||||
logfile: 'foo.txt',
|
||||
type: 'core',
|
||||
});
|
||||
|
||||
const { findByRole } = renderInvestTab(job);
|
||||
|
@ -573,6 +577,25 @@ describe('Sidebar Buttons', () => {
|
|||
});
|
||||
});
|
||||
|
||||
test('Plugin Documentation link points to userguide URL from plugin model spec and invokes OPEN_EXTERNAL_URL', async () => {
|
||||
const spy = jest.spyOn(ipcRenderer, 'send')
|
||||
.mockImplementation(() => Promise.resolve());
|
||||
|
||||
const { findByRole, queryByRole } = renderInvestTab(new InvestJob({
|
||||
modelID: 'foo',
|
||||
modelTitle: 'Foo Model',
|
||||
}));
|
||||
const ugLink = await queryByRole('link', { name: /user's guide/i });
|
||||
expect(ugLink).toBeNull();
|
||||
const docsLink = await findByRole('link', { name: /plugin documentation/i });
|
||||
expect(docsLink.getAttribute('href')).toEqual(spec.userguide);
|
||||
await userEvent.click(docsLink);
|
||||
await waitFor(() => {
|
||||
const calledChannels = spy.mock.calls.map(call => call[0]);
|
||||
expect(calledChannels).toContain(ipcMainChannels.OPEN_EXTERNAL_URL);
|
||||
});
|
||||
});
|
||||
|
||||
test('Forum link opens externally', async () => {
|
||||
const { findByRole } = renderInvestTab();
|
||||
const link = await findByRole('link', { name: /frequently asked questions/i });
|
||||
|
|
|
@ -61,7 +61,7 @@ const INPUT_FIELD_ORDER = [Object.keys(BASE_MODEL_SPEC.args)];
|
|||
* @param {object} baseSpec - an invest model spec
|
||||
* @returns {object} - containing the test utility functions returned by render
|
||||
*/
|
||||
function renderSetupFromSpec(baseSpec, inputFieldOrder, initValues = undefined) {
|
||||
function renderSetupFromSpec(baseSpec, inputFieldOrder, initValues = undefined, isCoreModel = true) {
|
||||
// some MODEL_SPEC boilerplate that is not under test,
|
||||
// but is required by PropType-checking
|
||||
const spec = { ...baseSpec };
|
||||
|
@ -71,6 +71,7 @@ function renderSetupFromSpec(baseSpec, inputFieldOrder, initValues = undefined)
|
|||
const { ...utils } = render(
|
||||
<SetupTab
|
||||
userguide={spec.userguide}
|
||||
isCoreModel={isCoreModel}
|
||||
modelID={spec.model_id}
|
||||
argsSpec={spec.args}
|
||||
inputFieldOrder={inputFieldOrder}
|
||||
|
@ -356,13 +357,23 @@ describe('Arguments form interactions', () => {
|
|||
const { findByText, findByRole } = renderSetupFromSpec(spec, INPUT_FIELD_ORDER);
|
||||
await userEvent.click(await findByRole('button', { name: /info about/ }));
|
||||
expect(await findByText(spec.args.arg.about)).toBeInTheDocument();
|
||||
const link = await findByRole('link', { name: /user guide/ });
|
||||
const link = await findByRole('link', { name: /User's guide entry/ });
|
||||
expect(link).toBeInTheDocument();
|
||||
await userEvent.click(link);
|
||||
await waitFor(() => {
|
||||
const calledChannels = spy.mock.calls.map(call => call[0]);
|
||||
expect(calledChannels).toContain(ipcMainChannels.OPEN_LOCAL_HTML);
|
||||
});
|
||||
});
|
||||
|
||||
test('Open info dialog, expect text but no link if model is a plugin', async () => {
|
||||
const spec = baseArgsSpec('directory');
|
||||
const { findByText, findByRole, queryByRole } = renderSetupFromSpec(spec, INPUT_FIELD_ORDER, undefined, false);
|
||||
await userEvent.click(await findByRole('button', { name: /info about/ }));
|
||||
expect(await findByText(spec.args.arg.about)).toBeInTheDocument();
|
||||
const link = await queryByRole('link', { name: /User's guide entry/ });
|
||||
expect(link).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('UI spec functionality', () => {
|
||||
|
|
Loading…
Reference in New Issue