UNPKG

datajunction-ui

Version:

DataJunction Metrics Platform UI

478 lines (455 loc) 15.8 kB
/** * Node add + edit page for transforms, metrics, and dimensions. The creation and edit flow for these * node types is largely the same, with minor differences handled server-side. For the `query` * field, this page will render a CodeMirror SQL editor with autocompletion and syntax highlighting. */ import { ErrorMessage, Form, Formik } from 'formik'; import NamespaceHeader from '../../components/NamespaceHeader'; import { useContext, useEffect, useState } from 'react'; import DJClientContext from '../../providers/djclient'; import 'styles/node-creation.scss'; import { useParams, useNavigate } from 'react-router-dom'; import { FullNameField } from './FullNameField'; import { MetricQueryField } from './MetricQueryField'; import { displayMessageAfterSubmit } from '../../../utils/form'; import { NodeQueryField } from './NodeQueryField'; import { MetricMetadataFields } from './MetricMetadataFields'; import { UpstreamNodeField } from './UpstreamNodeField'; import { TagsField } from './TagsField'; import { NamespaceField } from './NamespaceField'; import { AlertMessage } from './AlertMessage'; import { DisplayNameField } from './DisplayNameField'; import { DescriptionField } from './DescriptionField'; import { NodeModeField } from './NodeModeField'; import { RequiredDimensionsSelect } from './RequiredDimensionsSelect'; import LoadingIcon from '../../icons/LoadingIcon'; import { ColumnsSelect } from './ColumnsSelect'; class Action { static Add = new Action('add'); static Edit = new Action('edit'); constructor(name) { this.name = name; } } export function AddEditNodePage({ extensions = {} }) { const djClient = useContext(DJClientContext).DataJunctionAPI; const navigate = useNavigate(); let { nodeType, initialNamespace, name } = useParams(); const action = name !== undefined ? Action.Edit : Action.Add; const initialValues = { name: action === Action.Edit ? name : '', namespace: action === Action.Add ? initialNamespace : '', display_name: '', query: '', type: nodeType, description: '', primary_key: '', mode: 'published', }; const validator = values => { const errors = {}; if (!values.name) { errors.name = 'Required'; } if (values.type !== 'metric' && !values.query) { errors.query = 'Required'; } return errors; }; const handleSubmit = async (values, { setSubmitting, setStatus }) => { if (action === Action.Add) { await createNode(values, setStatus).then(_ => { window.scrollTo({ top: 0, left: 0, behavior: 'smooth' }); setSubmitting(false); }); } else { await patchNode(values, setStatus).then(_ => { window.scrollTo({ top: 0, left: 0, behavior: 'smooth' }); setSubmitting(false); }); } }; const submitHandlers = [handleSubmit]; const pageTitle = action === Action.Add ? ( <h2> Create{' '} <span className={`node_type__${nodeType} node_type_creation_heading`}> {nodeType} </span> </h2> ) : ( <h2>Edit</h2> ); const staticFieldsInEdit = node => ( <> <div className="NodeNameInput NodeCreationInput"> <label>Name</label> {name} </div> <div className="NodeNameInput NodeCreationInput"> <label>Type</label> {node.type} </div> </> ); const primaryKeyToList = primaryKey => { return primaryKey.map(columnName => columnName.trim()); }; const createNode = async (values, setStatus) => { const { status, json } = await djClient.createNode( nodeType, values.name, values.display_name, values.description, values.type === 'metric' ? `SELECT ${values.aggregate_expression} FROM ${values.upstream_node}` : values.query, values.mode, values.namespace, values.primary_key ? primaryKeyToList(values.primary_key) : null, values.metric_direction, values.metric_unit, values.required_dimensions, ); if (status === 200 || status === 201) { if (values.tags) { await djClient.tagsNode(values.name, values.tags); } setStatus({ success: ( <> Successfully created {json.type} node{' '} <a href={`/nodes/${json.name}`}>{json.name}</a>! </> ), }); } else { setStatus({ failure: `${json.message}`, }); } }; const patchNode = async (values, setStatus) => { const { status, json } = await djClient.patchNode( values.name, values.display_name, values.description, values.type === 'metric' ? `SELECT ${values.aggregate_expression} FROM ${values.upstream_node}` : values.query, values.mode, values.primary_key ? primaryKeyToList(values.primary_key) : null, values.metric_direction, values.metric_unit, values.significant_digits, values.required_dimensions, ); const tagsResponse = await djClient.tagsNode( values.name, values.tags.map(tag => tag), ); if ((status === 200 || status === 201) && tagsResponse.status === 200) { setStatus({ success: ( <> Successfully updated {json.type} node{' '} <a href={`/nodes/${json.name}`}>{json.name}</a>! </> ), }); } else { setStatus({ failure: `${json.message}`, }); } }; const fullNameInput = ( <div className="FullNameInput NodeCreationInput"> <ErrorMessage name="name" component="span" /> <label htmlFor="FullName">Full Name *</label> <FullNameField type="text" name="name" /> </div> ); const nodeCanBeEdited = nodeType => { return new Set(['transform', 'metric', 'dimension']).has(nodeType); }; const getExistingNodeData = async name => { const node = await djClient.getNodeForEditing(name); if (node === null) { return { message: `Node ${name} does not exist` }; } const baseData = { name: node.name, type: node.type.toLowerCase(), display_name: node.current.displayName, description: node.current.description, primary_key: node.current.primaryKey, query: node.current.query, tags: node.tags, mode: node.current.mode.toLowerCase(), }; if (node.type === 'METRIC') { return { ...baseData, metric_direction: node.current.metricMetadata?.direction?.toLowerCase(), metric_unit: node.current.metricMetadata?.unit?.name?.toLowerCase(), significant_digits: node.current.metricMetadata?.significantDigits, required_dimensions: node.current.requiredDimensions.map( dim => dim.name, ), upstream_node: node.current.parents[0]?.name, aggregate_expression: node.current.metricMetadata?.expression, }; } return baseData; }; const runValidityChecks = (data, setNode, setMessage) => { // Check if node exists if (data.message !== undefined) { setNode(null); setMessage(`Node ${name} does not exist!`); return; } // Check if node type can be edited if (!nodeCanBeEdited(data.type)) { setNode(null); if (data.type === 'cube') { navigate(`/nodes/${data.name}/edit-cube`); } setMessage(`Node ${name} is of type ${data.type} and cannot be edited`); } }; const updateFieldsWithNodeData = ( data, setFieldValue, setNode, setSelectTags, setSelectPrimaryKey, setSelectUpstreamNode, setSelectRequiredDims, ) => { // Update fields with existing data to prepare for edit const fields = [ 'display_name', 'query', 'type', 'description', 'primary_key', 'mode', 'tags', 'aggregate_expression', 'upstream_node', 'metric_unit', 'metric_direction', 'significant_digits', ]; fields.forEach(field => { if (field === 'tags') { setFieldValue( field, data[field].map(tag => tag.name), ); } else { setFieldValue(field, data[field] || '', false); } }); setNode(data); // For react-select fields, we have to explicitly set the entire // field rather than just the values setSelectTags( <TagsField defaultValue={data.tags.map(t => { return { value: t.name, label: t.displayName }; })} />, ); setSelectPrimaryKey( <ColumnsSelect defaultValue={data.primary_key} fieldName="primary_key" label="Primary Key" isMulti={true} />, ); if (data.required_dimensions) { setSelectRequiredDims( <RequiredDimensionsSelect defaultValue={data.required_dimensions.map(dim => { return { value: dim, label: dim }; })} />, ); } setSelectUpstreamNode( <UpstreamNodeField defaultValue={{ value: data.upstream_node, label: data.upstream_node, }} />, ); }; return ( <div className="mid"> <NamespaceHeader namespace={ initialNamespace ? initialNamespace : name ? name.substring(0, name.lastIndexOf('.')) : '' } /> <div className="card"> <div className="card-header"> {pageTitle} <center> <Formik initialValues={initialValues} validate={validator} onSubmit={async (values, { setSubmitting, setStatus }) => { try { for (const handler of submitHandlers) { await handler(values, { setSubmitting, setStatus }); } } catch (error) { console.error('Error in submission', error); } finally { setSubmitting(false); } }} > {function Render({ isSubmitting, status, setFieldValue }) { const [node, setNode] = useState([]); const [selectPrimaryKey, setSelectPrimaryKey] = useState(null); const [selectRequiredDims, setSelectRequiredDims] = useState(null); const [selectUpstreamNode, setSelectUpstreamNode] = useState(null); const [selectTags, setSelectTags] = useState(null); const [message, setMessage] = useState(''); useEffect(() => { const fetchData = async () => { if (action === Action.Edit) { const data = await getExistingNodeData(name); runValidityChecks(data, setNode, setMessage); updateFieldsWithNodeData( data, setFieldValue, setNode, setSelectTags, setSelectPrimaryKey, setSelectUpstreamNode, setSelectRequiredDims, ); } }; fetchData().catch(console.error); }, [setFieldValue]); return ( <Form> {displayMessageAfterSubmit(status)} {action === Action.Edit && message ? ( <AlertMessage message={message} /> ) : ( <> {action === Action.Add ? ( <NamespaceField initialNamespace={initialNamespace} /> ) : ( staticFieldsInEdit(node) )} <DisplayNameField /> {action === Action.Add ? fullNameInput : ''} <DescriptionField /> <br /> {nodeType === 'metric' || node?.type === 'metric' ? ( action === Action.Edit ? ( selectUpstreamNode ) : ( <UpstreamNodeField /> ) ) : ( '' )} <br /> <br /> {nodeType === 'metric' || node.type === 'metric' ? ( <MetricQueryField djClient={djClient} value={ node.aggregate_expression ? node.aggregate_expression : '' } /> ) : ( <NodeQueryField djClient={djClient} value={node.query ? node.query : ''} /> )} <br /> {nodeType === 'metric' || node.type === 'metric' ? ( <MetricMetadataFields /> ) : ( '' )} {nodeType !== 'metric' && node.type !== 'metric' ? ( action === Action.Edit ? ( selectPrimaryKey ) : ( <ColumnsSelect defaultValue={[]} fieldName="primary_key" label="Primary Key" isMulti={true} /> ) ) : action === Action.Edit ? ( selectRequiredDims ) : ( <RequiredDimensionsSelect /> )} {Object.entries(extensions).map( ([key, ExtensionComponent]) => ( <div key={key} className="mt-4 border-t pt-4"> <ExtensionComponent node={node} action={action} registerSubmitHandler={( onSubmit, { prepend } = {}, ) => { if (!submitHandlers.includes(onSubmit)) { if (prepend) { submitHandlers.unshift(onSubmit); } else { submitHandlers.push(onSubmit); } } }} /> </div> ), )} {action === Action.Edit ? selectTags : <TagsField />} <NodeModeField /> <button type="submit" disabled={isSubmitting}> {isSubmitting ? ( <LoadingIcon /> ) : ( (action === Action.Add ? 'Create ' : 'Save ') + (nodeType ? nodeType : '') )} </button> </> )} </Form> ); }} </Formik> </center> </div> </div> </div> ); }