From 2472b37792092e448b4050c776b35acb3e6ffafb Mon Sep 17 00:00:00 2001 From: Qinzheng Sun Date: Tue, 23 Jul 2019 15:23:46 +0800 Subject: [PATCH] [Web Portal] fix port list bug (#3240) --- .../controls/debounced-text-field.jsx | 59 +++++++++++++++++++ .../components/controls/key-value-list.jsx | 46 +++++++++++++-- .../components/form-text-field.jsx | 22 +++---- .../components/task-role/ports-list.jsx | 17 ++++++ .../job-submission/models/job-task-role.js | 8 ++- 5 files changed, 131 insertions(+), 21 deletions(-) create mode 100644 src/webportal/src/app/job-submission/components/controls/debounced-text-field.jsx diff --git a/src/webportal/src/app/job-submission/components/controls/debounced-text-field.jsx b/src/webportal/src/app/job-submission/components/controls/debounced-text-field.jsx new file mode 100644 index 0000000000..f33ed53aa7 --- /dev/null +++ b/src/webportal/src/app/job-submission/components/controls/debounced-text-field.jsx @@ -0,0 +1,59 @@ +/* + * Copyright (c) Microsoft Corporation + * All rights reserved. + * + * MIT License + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import React, {useEffect, useState, useCallback, useMemo} from 'react'; +import {TextField} from 'office-ui-fabric-react'; + +import PropTypes from 'prop-types'; +import {debounce} from 'lodash'; + + +export const DebouncedTextField = (props) => { + const {onChange, value} = props; + const [cachedValue, setCachedValue] = useState(''); + useEffect(() => setCachedValue(value), [value]); + const debouncedOnChange = useMemo(() => debounce(onChange, 200), [onChange]); + + const onChangeWrapper = useCallback( + (e, val) => { + setCachedValue(val); + debouncedOnChange(e, val); + }, + [setCachedValue, debouncedOnChange], + ); + + return ( + + ); +}; + +DebouncedTextField.propTypes = { + onChange: PropTypes.func, + value: PropTypes.string, +}; diff --git a/src/webportal/src/app/job-submission/components/controls/key-value-list.jsx b/src/webportal/src/app/job-submission/components/controls/key-value-list.jsx index d2e40d4b8c..0574d107d4 100644 --- a/src/webportal/src/app/job-submission/components/controls/key-value-list.jsx +++ b/src/webportal/src/app/job-submission/components/controls/key-value-list.jsx @@ -23,14 +23,15 @@ * SOFTWARE. */ -import {camelCase, isEmpty} from 'lodash'; -import {TextField, IconButton, Stack, DetailsList, CheckboxVisibility, DetailsListLayoutMode, CommandBarButton, getTheme, SelectionMode} from 'office-ui-fabric-react'; +import {camelCase, isEmpty, isNil} from 'lodash'; +import {IconButton, Stack, DetailsList, CheckboxVisibility, DetailsListLayoutMode, CommandBarButton, getTheme, SelectionMode} from 'office-ui-fabric-react'; import PropTypes from 'prop-types'; import React, {useCallback, useLayoutEffect, useMemo, useState, useContext} from 'react'; +import {DebouncedTextField} from './debounced-text-field'; import {dispatchResizeEvent} from '../../utils/utils'; import context from '../context'; -export const KeyValueList = ({name, value, onChange, onError, columnWidth, keyName, keyField, valueName, valueField, secret}) => { +export const KeyValueList = ({name, value, onChange, onError, columnWidth, keyName, keyField, valueName, valueField, secret, onValidateKey, onValidateValue}) => { columnWidth = columnWidth || 180; keyName = keyName || 'Key'; keyField = keyField || camelCase(keyName); @@ -58,6 +59,24 @@ export const KeyValueList = ({name, value, onChange, onError, columnWidth, keyNa if (value.some((x) => isEmpty(x[keyField]) && !isEmpty(x[valueField]))) { errorMessage = `${name || 'KeyValueList'} has value with empty key.`; } + if (!isNil(onValidateKey) || !isNil(onValidateValue)) { + for (const item of value) { + if (!isNil(onValidateKey)) { + const key = item[keyField]; + const res = onValidateKey(key); + if (!isEmpty(res)) { + errorMessage = res; + } + } + if (!isNil(onValidateValue)) { + const value = item[valueField]; + const res = onValidateValue(value); + if (!isEmpty(res)) { + errorMessage = res; + } + } + } + } if (onError) { onError(errorMessage); } @@ -104,8 +123,14 @@ export const KeyValueList = ({name, value, onChange, onError, columnWidth, keyNa if (isEmpty(item[keyField]) && !isEmpty(item[valueField])) { errorMessage = 'empty key'; } + if (!isNil(onValidateKey)) { + const res = onValidateKey(item[keyField]); + if (!isEmpty(res)) { + errorMessage = res; + } + } return ( - onKeyChange(idx, val)} @@ -118,8 +143,16 @@ export const KeyValueList = ({name, value, onChange, onError, columnWidth, keyNa name: valueName, minWidth: columnWidth, onRender: (item, idx) => { + let errorMessage = null; + if (!isNil(onValidateValue)) { + const res = onValidateValue(item[valueField]); + if (!isEmpty(res)) { + errorMessage = res; + } + } return ( - onValueChange(idx, val)} @@ -189,4 +222,7 @@ KeyValueList.propTypes = { keyField: PropTypes.string, valueName: PropTypes.string, valueField: PropTypes.string, + // validation + onValidateKey: PropTypes.func, + onValidateValue: PropTypes.func, }; diff --git a/src/webportal/src/app/job-submission/components/form-text-field.jsx b/src/webportal/src/app/job-submission/components/form-text-field.jsx index 1dd6ffff58..7b1860a88b 100644 --- a/src/webportal/src/app/job-submission/components/form-text-field.jsx +++ b/src/webportal/src/app/job-submission/components/form-text-field.jsx @@ -23,20 +23,17 @@ * SOFTWARE. */ -import React, {useEffect, useState, useCallback, useMemo} from 'react'; -import {TextField} from 'office-ui-fabric-react'; +import {isEmpty} from 'lodash'; +import PropTypes from 'prop-types'; +import React, {useCallback} from 'react'; import {BasicSection} from './basic-section'; import {FormShortSection} from './form-page'; - -import PropTypes from 'prop-types'; -import {debounce, isEmpty} from 'lodash'; +import {DebouncedTextField} from './controls/debounced-text-field'; const TEXT_FILED_REGX = /^[A-Za-z0-9\-._~]+$/; export const FormTextField = React.memo((props) => { const {sectionLabel, onChange, sectionOptional, sectionTooltip, shortStyle, value} = props; - const [cachedValue, setCachedValue] = useState(''); - useEffect(() => setCachedValue(value), [value]); const _onGetErrorMessage = (value) => { const match = TEXT_FILED_REGX.exec(value); if (isEmpty(match)) { @@ -45,20 +42,17 @@ export const FormTextField = React.memo((props) => { return ''; }; - const debouncedOnChange = useMemo(() => debounce(onChange, 200), [onChange]); - const onChangeWrapper = useCallback( (_, val) => { - setCachedValue(val); - debouncedOnChange(val); + onChange(val); }, - [setCachedValue, debouncedOnChange], + [onChange], ); const textField = ( - diff --git a/src/webportal/src/app/job-submission/components/task-role/ports-list.jsx b/src/webportal/src/app/job-submission/components/task-role/ports-list.jsx index 16ba5b3c87..7434b3704e 100644 --- a/src/webportal/src/app/job-submission/components/task-role/ports-list.jsx +++ b/src/webportal/src/app/job-submission/components/task-role/ports-list.jsx @@ -23,12 +23,15 @@ * SOFTWARE. */ +import {isNaN} from 'lodash'; import PropTypes from 'prop-types'; import React from 'react'; import {BasicSection} from '../basic-section'; import {FormShortSection} from '../form-page'; import {KeyValueList} from '../controls/key-value-list'; +const PORT_LABEL_REGEX = /^[a-zA-Z_][a-zA-Z0-9_]*$/; + export const PortsList = React.memo(({onChange, ports}) => ( @@ -41,6 +44,20 @@ export const PortsList = React.memo(({onChange, ports}) => ( keyField='key' valueName='Port Field' valueField='value' + onValidateKey={(val) => { + if (!PORT_LABEL_REGEX.test(val)) { + return 'Should be string in ^[a-zA-Z_][a-zA-Z0-9_]*$ format'; + } + }} + onValidateValue={(val) => { + let int = val; + if (typeof val === 'string') { + int = parseInt(val, 10); + } + if (int <= 0 || isNaN(int)) { + return 'Should be integer and no less than 1'; + } + }} /> diff --git a/src/webportal/src/app/job-submission/models/job-task-role.js b/src/webportal/src/app/job-submission/models/job-task-role.js index 885b95f15b..b2f744ced3 100644 --- a/src/webportal/src/app/job-submission/models/job-task-role.js +++ b/src/webportal/src/app/job-submission/models/job-task-role.js @@ -57,7 +57,7 @@ export class JobTaskRole { const taskDeployment = get(deployments[0], `taskRoles.${name}`, {}); const dockerInfo = prerequisites.find((prerequisite) => prerequisite.name === dockerImage) || {}; const ports = isNil(resourcePerInstance.ports) ? [] : - Object.entries(resourcePerInstance.ports).map(([key, value]) => ({key, value})); + Object.entries(resourcePerInstance.ports).map(([key, value]) => ({key, value: value.toString()})); const taskRetryCount = get(taskRoleProtocol, 'taskRetryCount', 0); const jobTaskRole = new JobTaskRole({ @@ -89,7 +89,11 @@ export class JobTaskRole { convertToProtocolFormat() { const taskRole = {}; const ports = this.ports.reduce((val, x) => { - val[x.key] = x.value; + if (typeof x.value === 'string') { + val[x.key] = parseInt(x.value); + } else { + val[x.key] = x.value; + } return val; }, {}); const resourcePerInstance = removeEmptyProperties({...this.containerSize, ports: ports});