Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Workbench Form Validation UI Updates #1614

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions HISTORY.rst
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,10 @@

.. :changelog:


..
Unreleased Changes
------------------
Unreleased Changes
------------------
* Workbench
* Several small updates to the model input form UI to improve usability and visual consistency (https://github.com/natcap/invest/issues/912)

3.14.2 (2024-05-29)
-------------------
Expand Down
13 changes: 9 additions & 4 deletions src/natcap/invest/validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,9 @@
'BBOX_NOT_INTERSECT': gettext(
'Not all of the spatial layers overlap each '
'other. All bounding boxes must intersect: {bboxes}'),
'NEED_PERMISSION': gettext(
'NEED_PERMISSION_DIRECTORY': gettext(
'You must have {permission} access to this directory'),
'NEED_PERMISSION_FILE': gettext(
'You must have {permission} access to this file'),
'WRONG_GEOM_TYPE': gettext('Geometry type must be one of {allowed}')
}
Expand Down Expand Up @@ -191,7 +193,7 @@ def check_directory(dirpath, must_exist=True, permissions='rx', **kwargs):
dirpath = parent
break

permissions_warning = check_permissions(dirpath, permissions)
permissions_warning = check_permissions(dirpath, permissions, True)
if permissions_warning:
return permissions_warning

Expand All @@ -218,7 +220,7 @@ def check_file(filepath, permissions='r', **kwargs):
return permissions_warning


def check_permissions(path, permissions):
def check_permissions(path, permissions, is_directory=False):
"""Validate permissions on a filesystem object.

This function uses ``os.access`` to determine permissions access.
Expand All @@ -229,6 +231,8 @@ def check_permissions(path, permissions):
and/or ``x`` (lowercase), indicating read, write, and execute
permissions (respectively) that the filesystem object at ``path``
must have.
is_directory (boolean): Indicates whether the path refers to a directory
(True) or a file (False). Defaults to False.

Returns:
A string error message if an error was found. ``None`` otherwise.
Expand All @@ -239,7 +243,8 @@ def check_permissions(path, permissions):
('w', os.W_OK, 'write'),
('x', os.X_OK, 'execute')):
if letter in permissions and not os.access(path, mode):
return MESSAGES['NEED_PERMISSION'].format(permission=letter)
message_key = 'NEED_PERMISSION_DIRECTORY' if is_directory else 'NEED_PERMISSION_FILE'
return MESSAGES[message_key].format(permission=descriptor)


def _check_projection(srs, projected, projection_units):
Expand Down
82 changes: 50 additions & 32 deletions workbench/src/renderer/components/SetupTab/ArgInput/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,38 +41,44 @@ function filterSpatialOverlapFeedback(message, filepath) {

function FormLabel(props) {
const {
argkey, argname, required, units,
argkey, argname, argtype, required, units,
} = props;

const userFriendlyArgType = parseArgType(argtype);
const optional = typeof required === 'boolean' && !required;
const includeComma = userFriendlyArgType && optional;

return (
<Form.Label column sm="3" htmlFor={argkey}>
<span id="argname">
{argname}
</span>
<span>
{
(typeof required === 'boolean' && !required)
? <em> ({i18n.t('optional')})</em>
: <React.Fragment />
}
{/* display units at the end of the arg name, if applicable */}
{ (units && units !== 'unitless') ? ` (${units})` : <React.Fragment /> }
</span>
<span className="argname">{argname} </span>
{
(userFriendlyArgType || optional) &&
<span>
(
{userFriendlyArgType}
{includeComma && ', '}
{optional && <em>{i18n.t('optional')}</em>}
)
</span>
}
{/* display units at the end of the arg name, if applicable */}
{ (units && units !== 'unitless') ? <span> ({units})</span> : <React.Fragment /> }
</Form.Label>
);
}

FormLabel.propTypes = {
argkey: PropTypes.string.isRequired,
argname: PropTypes.string.isRequired,
argtype: PropTypes.string.isRequired,
required: PropTypes.oneOfType(
[PropTypes.string, PropTypes.bool]
),
units: PropTypes.string,
};

function Feedback(props) {
const { argkey, argtype, message } = props;
const { argkey, message } = props;
return (
// d-block class is needed because of a bootstrap bug
// https://github.com/twbs/bootstrap/issues/29439
Expand All @@ -81,13 +87,12 @@ function Feedback(props) {
type="invalid"
id={`${argkey}-feedback`}
>
{`${i18n.t(argtype)} : ${(message)}`}
{message}
</Form.Control.Feedback>
);
}
Feedback.propTypes = {
argkey: PropTypes.string.isRequired,
argtype: PropTypes.string.isRequired,
message: PropTypes.string,
};
Feedback.defaultProps = {
Expand Down Expand Up @@ -127,6 +132,30 @@ function dragLeavingHandler(event) {
event.currentTarget.classList.remove('input-dragging');
}

function parseArgType(argtype) {
const { t, i18n } = useTranslation();
// These types benefit from more descriptive placeholder text.
let userFriendlyArgType;
switch (argtype) {
case 'freestyle_string':
userFriendlyArgType = t('text');
break;
case 'percent':
userFriendlyArgType = t('percent: a number from 0 - 100');
break;
case 'ratio':
userFriendlyArgType = t('ratio: a decimal from 0 - 1');
break;
case 'boolean':
case 'option_string':
userFriendlyArgType = '';
break;
default:
userFriendlyArgType = t(argtype);
}
return userFriendlyArgType;
}

export default function ArgInput(props) {
const inputRef = useRef();

Expand All @@ -147,8 +176,6 @@ export default function ArgInput(props) {
} = props;
let { validationMessage } = props;

const { t, i18n } = useTranslation();

// Occasionaly we want to force a scroll to the end of input fields
// so that the most important part of a filepath is visible.
// scrollEventCount changes on drop events and on use of the browse button.
Expand Down Expand Up @@ -207,20 +234,7 @@ export default function ArgInput(props) {
}

// These types benefit from more descriptive placeholder text.
let placeholderText;
switch (argSpec.type) {
case 'freestyle_string':
placeholderText = t('text');
break;
case 'percent':
placeholderText = t('percent: a number from 0 - 100');
break;
case 'ratio':
placeholderText = t('ratio: a decimal from 0 - 1');
break;
default:
placeholderText = t(argSpec.type);
}
const placeholderText = parseArgType(argSpec.type);

let form;
if (argSpec.type === 'boolean') {
Expand All @@ -246,6 +260,8 @@ export default function ArgInput(props) {
onChange={handleChange}
onFocus={handleChange}
disabled={!enabled}
isValid={enabled && isValid}
custom
>
{
Array.isArray(dropdownOptions) ?
Expand Down Expand Up @@ -278,6 +294,7 @@ export default function ArgInput(props) {
onDragOver={dragOverHandler}
onDragEnter={dragEnterHandler}
onDragLeave={dragLeavingHandler}
aria-describedby={`${argkey}-feedback`}
/>
{fileSelector}
</React.Fragment>
Expand All @@ -294,6 +311,7 @@ export default function ArgInput(props) {
<FormLabel
argkey={argkey}
argname={argSpec.name}
argtype={argSpec.type}
required={argSpec.required}
units={argSpec.units} // undefined for all types except number
/>
Expand Down
34 changes: 27 additions & 7 deletions workbench/src/renderer/styles/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -483,23 +483,33 @@ exceed 100% of window.*/
}

.args-form .form-group {
align-items: center;
align-items: flex-start;
}

#argname {
.argname {
text-transform: capitalize;
font-weight: 600;
}

.args-form .form-control {
.args-form .form-control,
.custom-select {
font-family: monospace;
font-size:1.3rem;
font-size: 1.3rem;
}

.args-form {
--form-field-padding-right: 2em;
}

.args-form .form-label {
padding-top: 0;
padding-bottom: 0;
}

.args-form .form-control[type=text] {
/*always hold space for a validation mark so the rightmost
text is never hidden by the mark when it appears.*/
padding-right: 2em;
padding-right: var(--form-field-padding-right);
}

input.input-dragging {
Expand All @@ -522,14 +532,24 @@ input[type=text]::placeholder {
font-size: 0.9rem;
font-family: monospace;
white-space: pre-wrap;
padding-left: 3rem;
text-indent: -3rem;
padding-left: calc(3.5rem + 2px);
}

.args-form svg {
font-size: 1.5rem;
}

.custom-select,
.custom-select.is-valid {
--caret-width: 1.875rem;
padding-right: var(--form-field-padding-right);
/* Custom dropdown icon is react-icons/md/MdKeyboardArrowDown, as a URL-encoded SVG */
background-image: url('data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" stroke="currentColor" fill="currentColor" stroke-width="0" viewBox="0 0 24 24" height="200px" width="200px"%3E%3Cpath fill="none" d="M0 0h24v24H0V0z"%3E%3C/path%3E%3Cpath d="M7.41 8.59 12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z"%3E%3C/path%3E%3C/svg%3E');
background-repeat: no-repeat;
background-position: center right calc((var(--form-field-padding-right) - var(--caret-width)) / 2);
background-size: var(--caret-width);
}

/* InVEST Log Tab */
#log-display {
overflow: auto;
Expand Down
8 changes: 4 additions & 4 deletions workbench/tests/binary_tests/puppet.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -183,16 +183,16 @@ test('Run a real invest model', async () => {
const argsForm = await page.waitForSelector('.args-form');
const typeDelay = 10;
const workspace = await argsForm.waitForSelector(
'aria/[name="Workspace"][role="textbox"]');
'aria/[name="Workspace (directory)"][role="textbox"]');
await workspace.type(TMP_DIR, { delay: typeDelay });
const aoi = await argsForm.waitForSelector(
'aria/[name="Area Of Interest"][role="textbox"]');
'aria/[name="Area Of Interest (vector)"][role="textbox"]');
await aoi.type(TMP_AOI_PATH, { delay: typeDelay });
const startYear = await argsForm.waitForSelector(
'aria/[name="Start Year"][role="textbox"]');
'aria/[name="Start Year (number)"][role="textbox"]');
await startYear.type('2008', { delay: typeDelay });
const endYear = await argsForm.waitForSelector(
'aria/[name="End Year"][role="textbox"]');
'aria/[name="End Year (number)"][role="textbox"]');
await endYear.type('2012', { delay: typeDelay });
await page.screenshot({ path: `${SCREENSHOT_PREFIX}4-complete-setup-form.png` });

Expand Down
4 changes: 2 additions & 2 deletions workbench/tests/renderer/app.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ describe('Various ways to open and close InVEST models', () => {
expect(setupTab.classList.contains('active')).toBeTruthy();

// Expect some arg values that were loaded from the saved job:
const input = await findByLabelText(SAMPLE_SPEC.args.workspace_dir.name);
const input = await findByLabelText((content) => content.startsWith(SAMPLE_SPEC.args.workspace_dir.name));
expect(input).toHaveValue(
argsValues.workspace_dir
);
Expand Down Expand Up @@ -155,7 +155,7 @@ describe('Various ways to open and close InVEST models', () => {
expect(executeButton).toBeDisabled();
const setupTab = await findByText('Setup');
const input = await findByLabelText(
SAMPLE_SPEC.args.carbon_pools_path.name
(content) => content.startsWith(SAMPLE_SPEC.args.carbon_pools_path.name)
);
expect(setupTab.classList.contains('active')).toBeTruthy();
expect(input).toHaveValue(mockDatastack.args.carbon_pools_path);
Expand Down
8 changes: 4 additions & 4 deletions workbench/tests/renderer/invest_subprocess.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ describe('InVEST subprocess testing', () => {
);
await userEvent.click(carbon);
const workspaceInput = await findByLabelText(
`${spec.args.workspace_dir.name}`
(content) => content.startsWith(spec.args.workspace_dir.name)
);
await userEvent.type(workspaceInput, fakeWorkspace);
const execute = await findByRole('button', { name: /Run/ });
Expand Down Expand Up @@ -195,7 +195,7 @@ describe('InVEST subprocess testing', () => {
);
await userEvent.click(carbon);
const workspaceInput = await findByLabelText(
`${spec.args.workspace_dir.name}`
(content) => content.startsWith(spec.args.workspace_dir.name)
);
await userEvent.type(workspaceInput, fakeWorkspace);

Expand Down Expand Up @@ -250,7 +250,7 @@ describe('InVEST subprocess testing', () => {
);
await userEvent.click(carbon);
const workspaceInput = await findByLabelText(
`${spec.args.workspace_dir.name}`
(content) => content.startsWith(spec.args.workspace_dir.name)
);
await userEvent.type(workspaceInput, fakeWorkspace);

Expand Down Expand Up @@ -301,7 +301,7 @@ describe('InVEST subprocess testing', () => {
);
await userEvent.click(carbon);
const workspaceInput = await findByLabelText(
`${spec.args.workspace_dir.name}`
(content) => content.startsWith(spec.args.workspace_dir.name)
);
await userEvent.type(workspaceInput, fakeWorkspace);

Expand Down
8 changes: 4 additions & 4 deletions workbench/tests/renderer/investtab.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -299,9 +299,9 @@ describe('Sidebar Buttons', () => {
const setupTab = await findByRole('tab', { name: 'Setup' });
expect(setupTab.classList.contains('active')).toBeTruthy();

const input1 = await findByLabelText(spec.args.workspace.name);
const input1 = await findByLabelText((content) => content.startsWith(spec.args.workspace.name));
expect(input1).toHaveValue(mockDatastack.args.workspace);
const input2 = await findByLabelText(spec.args.port.name);
const input2 = await findByLabelText((content) => content.startsWith(spec.args.port.name));
expect(input2).toHaveValue(mockDatastack.args.port);
});

Expand Down Expand Up @@ -427,8 +427,8 @@ describe('InVEST Run Button', () => {
const runButton = await findByRole('button', { name: /Run/ });
expect(runButton).toBeDisabled();

const a = await findByLabelText(`${spec.args.a.name}`);
const b = await findByLabelText(`${spec.args.b.name}`);
const a = await findByLabelText((content) => content.startsWith(spec.args.a.name));
const b = await findByLabelText((content) => content.startsWith(spec.args.b.name));

expect(a).toHaveClass('is-invalid');
expect(b).toHaveClass('is-invalid');
Expand Down
Loading
Loading