From c983a5b15ecfb404b45c88c2148e59c30ae0acdf Mon Sep 17 00:00:00 2001 From: Mike Taylor Date: Fri, 14 Jul 2023 18:46:24 +0100 Subject: [PATCH] Support maintenance of pipelines and steps (#50) --- src/settings/PipelineDetail.js | 94 +++++++++++++ src/settings/PipelineForm.js | 92 +++++++++++++ src/settings/PipelineSettings.js | 72 ++++++++-- src/settings/StepDetail.js | 126 ++++++++++++++++++ src/settings/StepForm.js | 94 +++++++++++++ src/settings/StepSettings.js | 78 +++++++++-- src/settings/StorageDetail.js | 5 +- src/settings/StorageForm.js | 40 +----- src/settings/StorageSettings.js | 13 +- src/settings/renderPaneFooter.js | 36 +++++ src/settings/transformBooleans.js | 24 ++++ .../lib/EntryManager/ConnectedWrapper.js | 6 +- translations/ui-harvester-admin/en.json | 21 +++ 13 files changed, 641 insertions(+), 60 deletions(-) create mode 100644 src/settings/PipelineDetail.js create mode 100644 src/settings/PipelineForm.js create mode 100644 src/settings/StepDetail.js create mode 100644 src/settings/StepForm.js create mode 100644 src/settings/renderPaneFooter.js create mode 100644 src/settings/transformBooleans.js diff --git a/src/settings/PipelineDetail.js b/src/settings/PipelineDetail.js new file mode 100644 index 0000000..3655406 --- /dev/null +++ b/src/settings/PipelineDetail.js @@ -0,0 +1,94 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { FormattedMessage } from 'react-intl'; +import { Col, Row, KeyValue, MultiColumnList, Accordion } from '@folio/stripes/components'; +import { bool2display } from './transformBooleans'; + + +const PipelineDetail = (props) => { + const data = props.initialValues; + + return ( + <> + + + } + value={data.name} + /> + + + + + } + value={data.description} + /> + + + + + } + value={bool2display(data.enabled)} + /> + + + + + } + value={bool2display(data.parallel)} + /> + + + +

+ , + name: , + in: , + out: , + }} + contentData={data.stepAssociations} + formatter={{ + name: r => r.step.name, + in: r => r.step.inputFormat, + out: r => r.step.outputFormat, + }} + /> + + } + closedByDefault + > +
+          {JSON.stringify(data, null, 2)}
+        
+
+ + ); +}; + + +PipelineDetail.propTypes = { + initialValues: PropTypes.shape({ + id: PropTypes.string.isRequired, + // See https://github.com/indexdata/mod-harvester-admin/blob/master/src/main/resources/openapi/schemas/transformationGet.json + // No properties seem to be mandatory + name: PropTypes.string, + description: PropTypes.string, + enabled: PropTypes.bool, + parallel: PropTypes.bool, + stepAssociations: PropTypes.arrayOf( + PropTypes.shape({ + }).isRequired, + ), + }).isRequired, +}; + + +export default PipelineDetail; diff --git a/src/settings/PipelineForm.js b/src/settings/PipelineForm.js new file mode 100644 index 0000000..8dc4136 --- /dev/null +++ b/src/settings/PipelineForm.js @@ -0,0 +1,92 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { FormattedMessage } from 'react-intl'; +import { Field } from 'react-final-form'; +import arrayMutators from 'final-form-arrays'; +import { Pane, Row, Col, Checkbox, TextField, TextArea } from '@folio/stripes/components'; +import { TitleManager } from '@folio/stripes/core'; +import stripesFinalForm from '@folio/stripes/final-form'; +import { isEqual } from 'lodash'; +import setFieldData from 'final-form-set-field-data'; // XXX do we need this? +import { RCF, CF, RCLF } from '../components/CF'; +import renderPaneFooter from './renderPaneFooter'; + + +function validate(values) { + const errors = {}; + const requiredTextMessage = ; + + if (!values.name) { + errors.name = requiredTextMessage; + } + + return errors; +} + + +const PipelineForm = (props) => { + const { handleSubmit, onCancel, pristine, submitting } = props; + + const title = props.initialValues?.name; + + return ( + + +
+ + + + + + + +
+ ( + + + + + + + + + + + + )} + emptyValue={{ key: '', value: '' }} + /> + +
+
+ ); +}; + + +PipelineForm.propTypes = { + initialValues: PropTypes.object, + handleSubmit: PropTypes.func.isRequired, + onCancel: PropTypes.func, + pristine: PropTypes.bool, + submitting: PropTypes.bool, +}; + + +export default stripesFinalForm({ + initialValuesEqual: (a, b) => isEqual(a, b), + validate, + navigationCheck: true, + subscription: { + values: true, + }, + mutators: { setFieldData, ...arrayMutators } +})(PipelineForm); diff --git a/src/settings/PipelineSettings.js b/src/settings/PipelineSettings.js index 32b255b..1e28943 100644 --- a/src/settings/PipelineSettings.js +++ b/src/settings/PipelineSettings.js @@ -1,15 +1,71 @@ +import { sortBy } from 'lodash'; import React from 'react'; import PropTypes from 'prop-types'; -import { Pane } from '@folio/stripes/components'; +import { injectIntl } from 'react-intl'; +import { stripesConnect } from '@folio/stripes/core'; +import { EntryManager } from '../smart-components'; +import { boolValues2string, stringValues2bool } from './transformBooleans'; +import PipelineDetail from './PipelineDetail'; +import PipelineForm from './PipelineForm'; -const PipelineSettings = ({ label }) => ( - - This is the pipeline settings page - -); +const PERMS = { + put: 'harvester-admin.transformations.item.put', + post: 'harvester-admin.transformations.item.post', + delete: 'harvester-admin.transformations.item.delete', +}; + +const PipelineSettings = (props) => { + const { mutator, resources, intl } = props; + + return ( + { + if (!values) return values; // Necessary if the edit-form is reloaded, for some reason + return boolValues2string(values, ['enabled', 'parallel']); + }} + onBeforeSave={values => { + const newValues = stringValues2bool(values, ['enabled', 'parallel']); + if (!newValues.type) newValues.type = 'basicTransformation'; + return newValues; + }} + /> + ); +}; PipelineSettings.propTypes = { - label: PropTypes.object.isRequired, + resources: PropTypes.shape({ + entries: PropTypes.shape({ + records: PropTypes.arrayOf(PropTypes.object), + }), + }).isRequired, + mutator: PropTypes.shape({ + entries: PropTypes.shape({ + POST: PropTypes.func, + PUT: PropTypes.func, + DELETE: PropTypes.func, + }), + }).isRequired, + intl: PropTypes.object.isRequired, }; -export default PipelineSettings; +PipelineSettings.manifest = Object.freeze({ + entries: { + type: 'okapi', + records: 'transformations', + path: 'harvester-admin/transformations', + throwErrors: false, + }, +}); + +export default stripesConnect(injectIntl(PipelineSettings)); diff --git a/src/settings/StepDetail.js b/src/settings/StepDetail.js new file mode 100644 index 0000000..d787053 --- /dev/null +++ b/src/settings/StepDetail.js @@ -0,0 +1,126 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { FormattedMessage } from 'react-intl'; +import { Col, Row, KeyValue, Accordion } from '@folio/stripes/components'; +import { bool2display } from './transformBooleans'; + + +const StepDetail = (props) => { + const data = props.initialValues; + + return ( + <> + + + } + value={data.name} + /> + + + + + } + value={data.description} + /> + + + + + } + value={bool2display(data.enabled)} + /> + + + + + } + value={data.type} + /> + + + + + } + value={data.inputFormat} + /> + + + + + } + value={data.outputFormat} + /> + + + + + } + value={data.script} + /> + + + + + } + value={data.testData} + /> + + + + + } + value={data.testOutput} + /> + + + + + } + value={data.customClass} + /> + + + + } + closedByDefault + > +
+          {JSON.stringify(data, null, 2)}
+        
+
+ + ); +}; + + +StepDetail.propTypes = { + initialValues: PropTypes.shape({ + id: PropTypes.string.isRequired, + // See https://github.com/indexdata/mod-harvester-admin/blob/master/src/main/resources/openapi/schemas/step.json + name: PropTypes.string.isRequired, + description: PropTypes.string, + enabled: PropTypes.bool, + type: PropTypes.string.isRequired, + inputFormat: PropTypes.string, + outputFormat: PropTypes.string, + script: PropTypes.string, + testData: PropTypes.string, + testOutput: PropTypes.string, + customClass: PropTypes.string, // XXX to be supported within a discriminated union + }).isRequired, +}; + + +export default StepDetail; diff --git a/src/settings/StepForm.js b/src/settings/StepForm.js new file mode 100644 index 0000000..1b296fc --- /dev/null +++ b/src/settings/StepForm.js @@ -0,0 +1,94 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { useIntl, FormattedMessage } from 'react-intl'; +import arrayMutators from 'final-form-arrays'; +import { Pane, Row, Select, Checkbox, TextArea } from '@folio/stripes/components'; +import { TitleManager } from '@folio/stripes/core'; +import stripesFinalForm from '@folio/stripes/final-form'; +import { isEqual } from 'lodash'; +import setFieldData from 'final-form-set-field-data'; // XXX do we need this? +import { RCF, CF } from '../components/CF'; +import renderPaneFooter from './renderPaneFooter'; + + +function validate(values) { + const errors = {}; + const requiredTextMessage = ; + const requiredSelectMessage = ; + + if (!values.name) { + errors.name = requiredTextMessage; + } + if (!values.type) { + errors.type = requiredSelectMessage; + } + + // XXX should validate XML as StorageForm.js does for JSON + + return errors; +} + + +const StepForm = (props) => { + const { handleSubmit, onCancel, pristine, submitting } = props; + const intl = useIntl(); + + const noValue = { + value: '', + label: intl.formatMessage({ id: 'ui-harvester-admin.selectValue' }), + }; + const types = ['XmlTransformStep', 'CustomTransformStep'].map(x => ({ value: x, label: x })); + const formats = ['XML', 'JSON', 'Other'].map(x => ({ value: x, label: x })); + + const title = props.initialValues?.name; + + return ( + + +
+ + + + + + + + + + + + + + + + +
+
+ ); +}; + + +StepForm.propTypes = { + initialValues: PropTypes.object, + handleSubmit: PropTypes.func.isRequired, + onCancel: PropTypes.func, + pristine: PropTypes.bool, + submitting: PropTypes.bool, +}; + + +export default stripesFinalForm({ + initialValuesEqual: (a, b) => isEqual(a, b), + validate, + navigationCheck: true, + subscription: { + values: true, + }, + mutators: { setFieldData, ...arrayMutators } +})(StepForm); diff --git a/src/settings/StepSettings.js b/src/settings/StepSettings.js index 0eb52f4..84a3cd9 100644 --- a/src/settings/StepSettings.js +++ b/src/settings/StepSettings.js @@ -1,15 +1,77 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { Pane } from '@folio/stripes/components'; +import { injectIntl } from 'react-intl'; +import { stripesConnect } from '@folio/stripes/core'; +import { EntryManager } from '../smart-components'; +import { boolValues2string, stringValues2bool } from './transformBooleans'; +import StepDetail from './StepDetail'; +import StepForm from './StepForm'; -const StepSettings = ({ label }) => ( - - This is the step settings page - -); +const PERMS = { + put: 'harvester-admin.steps.item.put', + post: 'harvester-admin.steps.item.post', + delete: 'harvester-admin.steps.item.delete', +}; + +const StepSettings = (props) => { + const { mutator, resources, intl } = props; + + const entriesWithVirtualName = ((resources.entries || {}).records || []) + .map(entry => ({ + ...entry, + virtualName: `${entry.name} (${entry.inputFormat}→${entry.outputFormat})`, + })); + + return ( + { + if (!values) return values; // Necessary if the edit-form is reloaded, for some reason + return boolValues2string(values, ['enabled']); + }} + onBeforeSave={values => { + return stringValues2bool(values, ['enabled']); + }} + /> + ); +}; StepSettings.propTypes = { - label: PropTypes.object.isRequired, + resources: PropTypes.shape({ + entries: PropTypes.shape({ + records: PropTypes.arrayOf(PropTypes.object), + }), + }).isRequired, + mutator: PropTypes.shape({ + entries: PropTypes.shape({ + POST: PropTypes.func, + PUT: PropTypes.func, + DELETE: PropTypes.func, + }), + }).isRequired, + intl: PropTypes.object.isRequired, }; -export default StepSettings; +StepSettings.manifest = Object.freeze({ + entries: { + type: 'okapi', + records: 'transformationSteps', + path: 'harvester-admin/steps', + throwErrors: false, + GET: { + path: 'harvester-admin/steps?limit=1000', // XXX will this always be enough? + }, + }, +}); + +export default stripesConnect(injectIntl(StepSettings)); diff --git a/src/settings/StorageDetail.js b/src/settings/StorageDetail.js index 2a5627e..f625903 100644 --- a/src/settings/StorageDetail.js +++ b/src/settings/StorageDetail.js @@ -2,6 +2,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import { FormattedMessage } from 'react-intl'; import { Col, Row, KeyValue, Accordion } from '@folio/stripes/components'; +import { bool2display } from './transformBooleans'; const StorageDetail = (props) => { @@ -29,7 +30,7 @@ const StorageDetail = (props) => { } - value={data.enabled} + value={bool2display(data.enabled)} /> @@ -74,7 +75,7 @@ StorageDetail.propTypes = { // ESLint's dumb proptypes-checking thinks all fields are in initialValues, not resources.storage.record name: PropTypes.string, description: PropTypes.string, - enabled: PropTypes.string, // "true" or "false" + enabled: PropTypes.bool, url: PropTypes.string, type: PropTypes.string, json: PropTypes.string, diff --git a/src/settings/StorageForm.js b/src/settings/StorageForm.js index 86bda1e..8cc7c61 100644 --- a/src/settings/StorageForm.js +++ b/src/settings/StorageForm.js @@ -2,12 +2,13 @@ import React from 'react'; import PropTypes from 'prop-types'; import { useIntl, FormattedMessage } from 'react-intl'; import arrayMutators from 'final-form-arrays'; -import { Button, Pane, PaneFooter, Row, Select, Checkbox, TextArea } from '@folio/stripes/components'; +import { Pane, Row, Select, Checkbox, TextArea } from '@folio/stripes/components'; import { TitleManager } from '@folio/stripes/core'; import stripesFinalForm from '@folio/stripes/final-form'; import { isEqual } from 'lodash'; import setFieldData from 'final-form-set-field-data'; // XXX do we need this? import { RCF, CF } from '../components/CF'; +import renderPaneFooter from './renderPaneFooter'; function validate(values) { @@ -42,47 +43,18 @@ const StorageForm = (props) => { }; const types = ['inventoryStorage', 'solrStorage'].map(x => ({ value: x, label: x })); - function renderPaneFooter() { - return ( - - - - )} - renderEnd={( - - )} - /> - ); - } - - const title = props.initialValues.name; + const title = props.initialValues?.name; return ( -
+ diff --git a/src/settings/StorageSettings.js b/src/settings/StorageSettings.js index 5188a90..04986ef 100644 --- a/src/settings/StorageSettings.js +++ b/src/settings/StorageSettings.js @@ -4,7 +4,7 @@ import PropTypes from 'prop-types'; import { injectIntl } from 'react-intl'; import { stripesConnect } from '@folio/stripes/core'; import { EntryManager } from '../smart-components'; - +import { boolValues2string, stringValues2bool } from './transformBooleans'; import StorageDetail from './StorageDetail'; import StorageForm from './StorageForm'; @@ -31,12 +31,15 @@ const StorageSettings = (props) => { permissions={PERMS} enableDetailsActionMenu parseInitialValues={values => { - if (!values.json) return values; - return { ...values, json: JSON.stringify(values.json, null, 2) }; + if (!values) return values; // Necessary if the edit-form is reloaded, for some reason + const newValues = boolValues2string(values, ['enabled']); + if (values.json) newValues.json = JSON.stringify(values.json, null, 2); + return newValues; }} onBeforeSave={values => { - if (!values.json) return values; - return { ...values, json: JSON.parse(values.json) }; + const newValues = stringValues2bool(values, ['enabled']); + if (values.json) newValues.json = JSON.parse(values.json); + return newValues; }} /> ); diff --git a/src/settings/renderPaneFooter.js b/src/settings/renderPaneFooter.js new file mode 100644 index 0000000..36c66ad --- /dev/null +++ b/src/settings/renderPaneFooter.js @@ -0,0 +1,36 @@ +import React from 'react'; +import { Button, PaneFooter } from '@folio/stripes/components'; +import { FormattedMessage } from 'react-intl'; + + +function renderPaneFooter(handleSubmit, onCancel, pristine, submitting) { + return ( + + + + )} + renderEnd={( + + )} + /> + ); +} + + +export default renderPaneFooter; diff --git a/src/settings/transformBooleans.js b/src/settings/transformBooleans.js new file mode 100644 index 0000000..5625cec --- /dev/null +++ b/src/settings/transformBooleans.js @@ -0,0 +1,24 @@ +// Translates all the specified boolean fieds in the object from +// strings "true" or "false" to boolean values true and false. + +function boolValues2string(obj, booleanFields) { + const newObj = { ...obj }; + booleanFields.forEach(field => { + newObj[field] = (obj[field] === 'true'); + }); + return newObj; +} + +function stringValues2bool(obj, booleanFields) { + const newObj = { ...obj }; + booleanFields.forEach(field => { + newObj[field] = (obj[field] === true) ? 'true' : 'false'; + }); + return newObj; +} + +function bool2display(val) { + return val ? '✅' : '❌'; +} + +export { boolValues2string, stringValues2bool, bool2display }; diff --git a/src/smart-components/lib/EntryManager/ConnectedWrapper.js b/src/smart-components/lib/EntryManager/ConnectedWrapper.js index b8b104d..65a3be0 100644 --- a/src/smart-components/lib/EntryManager/ConnectedWrapper.js +++ b/src/smart-components/lib/EntryManager/ConnectedWrapper.js @@ -9,7 +9,7 @@ function ConnectedWrapper({ resourcePath, initialValues, underlyingComponent, .. const Component = underlyingComponent; useEffect(() => { - if (initialValues.id) { + if (initialValues?.id) { okapiKy(`${resourcePath}/${initialValues.id}`) .then(res => res.json().then(rec => { setRecord(rec); @@ -17,9 +17,9 @@ function ConnectedWrapper({ resourcePath, initialValues, underlyingComponent, .. } // If `okpaiKy` is included in the dependency list, the effect fires on every render. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [initialValues.id, resourcePath]); + }, [initialValues?.id, resourcePath]); - if (!initialValues.id) { + if (!initialValues?.id) { // Creating a new record: pass through return ; } diff --git a/translations/ui-harvester-admin/en.json b/translations/ui-harvester-admin/en.json index 1f6f8a4..a8248bc 100644 --- a/translations/ui-harvester-admin/en.json +++ b/translations/ui-harvester-admin/en.json @@ -283,6 +283,27 @@ "storage.field.json": "JSON configuration", "storage.field.type": "Storage type:", + "pipeline.field.name": "Name", + "pipeline.field.description": "Description", + "pipeline.field.enabled": "Enabled", + "pipeline.field.parallel": "Parallel", + "pipeline.field.stepAssociations": "Transformation steps", + "pipeline.steps.position": "#", + "pipeline.steps.name": "Name", + "pipeline.steps.in": "In", + "pipeline.steps.out": "Out", + "pipeline.steps.actions": "Actions", + "step.field.name": "Name", + "step.field.description": "Description", + "step.field.enabled": "Enabled (This is unused)", + "step.field.type": "Type", + "step.field.inputFormat": "Input format", + "step.field.outputFormat": "Output format", + "step.field.script": "Script", + "step.field.testData": "Test data", + "step.field.testOutput": "Test output", + "step.field.customClass": "Custom class", + "logs.plainTextLog.running": "Live plain text log for current running job", "logs.plainTextLog.previous": "Plain text log for last job", "logs.plainTextLog.refresh": "Refresh",