Merge remote-tracking branch 'upstream/feature/plugins' into feature/WB-1793-settings-layout

This commit is contained in:
davemfish 2025-05-27 09:06:04 -04:00
commit a03a5c7e71
9 changed files with 139 additions and 43 deletions

View File

@ -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(

View File

@ -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}

View File

@ -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 = {

View File

@ -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,
};

View File

@ -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,

View File

@ -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({

View File

@ -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}
/>
);

View File

@ -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 });

View File

@ -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', () => {