UNPKG

datajunction-ui

Version:
656 lines (620 loc) 22.5 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 './styles.css'; 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 { OwnersField } from './OwnersField'; 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'; import { CustomMetadataField } from './CustomMetadataField'; 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; // A read-only (git-managed) node can't be edited in the UI. If someone hits // the edit route directly, send them back to the node view once the // namespace's read-only verdict (from NamespaceHeader) is known. const [isReadOnly, setIsReadOnly] = useState(undefined); useEffect(() => { if (action === Action.Edit && isReadOnly === true) { navigate(`/nodes/${name}`, { replace: true }); } }, [action, isReadOnly, name, navigate]); const headerNamespace = initialNamespace ? initialNamespace : name ? name.substring(0, name.lastIndexOf('.')) : ''; const initialValues = { name: action === Action.Edit ? name : '', namespace: action === Action.Add ? initialNamespace : '', display_name: '', query: '', type: nodeType, description: '', primary_key: '', mode: 'published', owners: [], custom_metadata: '', }; const validator = values => { const errors = {}; // Display name drives the synthesized full name (`namespace.display_name`). // Without it the full name collapses to `namespace.` — non-empty but // useless. Validate the user-facing field instead of the derived one. if (action === Action.Add && !values.display_name?.trim()) { errors.display_name = 'Required'; } if ( !values.name || values.name.endsWith('.') || values.name.startsWith('.') || values.name.includes('..') ) { 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 parseCustomMetadata = customMetadata => { if (!customMetadata || customMetadata.trim() === '') { return null; } try { return JSON.parse(customMetadata); } catch (err) { return null; } }; /** * Build the metric query based on whether an upstream node is provided. * - With upstream node: `SELECT <expression> FROM <upstream_node>` (regular metric) * - Without upstream node: `SELECT <expression>` (derived metric referencing other metrics) */ const buildMetricQuery = (aggregateExpression, upstreamNode) => { if (upstreamNode && upstreamNode.trim() !== '') { return `SELECT ${aggregateExpression} \n FROM ${upstreamNode}`; } // Derived metric - no FROM clause needed, expression references other metrics directly return `SELECT ${aggregateExpression}`; }; const createNode = async (values, setStatus) => { const { status, json } = await djClient.createNode( nodeType, values.name, values.display_name, values.description, values.type === 'metric' ? buildMetricQuery(values.aggregate_expression, values.upstream_node) : values.query, values.mode, values.namespace, values.primary_key ? primaryKeyToList(values.primary_key) : null, values.metric_direction, values.metric_unit, // Only send required_dimensions if it's set (for metrics) values.required_dimensions && Array.isArray(values.required_dimensions) ? values.required_dimensions : undefined, parseCustomMetadata(values.custom_metadata), ); 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' ? buildMetricQuery(values.aggregate_expression, values.upstream_node) : values.query, values.mode, values.primary_key ? primaryKeyToList(values.primary_key) : null, values.metric_direction, values.metric_unit, values.significant_digits, // Only send required_dimensions if it's set (for metrics) values.required_dimensions && Array.isArray(values.required_dimensions) ? values.required_dimensions : undefined, values.owners, parseCustomMetadata(values.custom_metadata), ); 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(), owners: node.owners, custom_metadata: node.current.customMetadata, }; if (node.type === 'METRIC') { // Check if this is a derived metric (any parent is a metric) const isDerivedMetric = node.current.isDerivedMetric; // For regular metrics, get the first non-metric parent as upstream node const nonMetricParent = node.current.parents.find( p => p.type !== 'METRIC', ); if (isDerivedMetric) { // Derived metric: no upstream node, expression is the full query projection // Parse the expression from the query (format: "SELECT <expression>") const query = node.current.query || ''; const selectMatch = query.match(/SELECT\s+(.+)/is); const derivedExpression = selectMatch ? selectMatch[1].trim() : node.current.metricMetadata?.expression || ''; 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: '', // Derived metrics have no upstream node aggregate_expression: derivedExpression, }; } else { // Regular metric: has upstream node (first non-metric parent) 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: nonMetricParent?.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, setSelectOwners, ) => { // 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', 'required_dimensions', 'owners', 'custom_metadata', ]; fields.forEach(field => { if (field === 'tags') { setFieldValue( field, data[field].map(tag => tag.name), ); } else if (field === 'owners') { setFieldValue( field, data[field].map(owner => owner.username), ); } else if (field === 'custom_metadata') { const value = data[field] ? JSON.stringify(data[field], null, 2) : ''; setFieldValue(field, value, false); } 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 }; })} />, ); } // For derived metrics, upstream_node is empty - pass null to clear the select setSelectUpstreamNode( <UpstreamNodeField defaultValue={ data.upstream_node ? { value: data.upstream_node, label: data.upstream_node } : null } />, ); if (data.owners) { setSelectOwners( <OwnersField defaultValue={data.owners.map(owner => { return { value: owner.username, label: owner.username }; })} />, ); } }; // Editing a node we can't yet confirm is editable (verdict still loading) or // one that is read-only: render just the header (which resolves the verdict // and triggers the redirect above) — never the editor form. Avoids flashing // the editor before redirecting a read-only node. if (action === Action.Edit && isReadOnly !== false) { return ( <div className="mid"> <NamespaceHeader namespace={headerNamespace} onReadOnlyChange={setIsReadOnly} /> </div> ); } return ( <div className="mid"> <NamespaceHeader namespace={headerNamespace} onReadOnlyChange={setIsReadOnly} /> <div className="node-builder"> <div className="node-builder-main"> <Formik initialValues={initialValues} validate={validator} validateOnChange={true} validateOnBlur={true} 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(formikProps) { const { isSubmitting, status, setFieldValue, errors, touched, isValid, dirty, } = formikProps; const [node, setNode] = useState([]); const [selectPrimaryKey, setSelectPrimaryKey] = useState(null); const [selectRequiredDims, setSelectRequiredDims] = useState(null); const [selectUpstreamNode, setSelectUpstreamNode] = useState(null); const [selectOwners, setSelectOwners] = 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, setSelectOwners, ); } }; fetchData().catch(console.error); }, [setFieldValue]); return ( <Form> {displayMessageAfterSubmit(status)} {action === Action.Edit && message ? ( <AlertMessage message={message} /> ) : ( <> <div className="node-builder-form-header"> {pageTitle} <NodeModeField /> </div> <div className="node-group"> <div className="node-row"> {action === Action.Add ? ( <NamespaceField initialNamespace={initialNamespace} /> ) : ( staticFieldsInEdit(node) )} <DisplayNameField /> </div> {action === Action.Add ? fullNameInput : ''} <DescriptionField /> </div> <div className="node-group"> {nodeType === 'metric' || node?.type === 'metric' ? ( action === Action.Edit ? ( selectUpstreamNode ) : ( <UpstreamNodeField /> ) ) : null} {nodeType === 'metric' || node.type === 'metric' ? ( <MetricQueryField djClient={djClient} value={ node.aggregate_expression ? node.aggregate_expression : '' } /> ) : ( <NodeQueryField djClient={djClient} value={node.query ? node.query : ''} /> )} {(nodeType === 'metric' || node.type === 'metric') && ( <MetricMetadataFields /> )} </div> <div className="node-group"> {nodeType !== 'metric' && node.type !== 'metric' ? ( action === Action.Edit ? ( selectPrimaryKey ) : ( <ColumnsSelect defaultValue={[]} fieldName="primary_key" label="Primary Key" isMulti={true} /> ) ) : action === Action.Edit ? ( selectRequiredDims ) : ( <RequiredDimensionsSelect /> )} </div> <div className="node-group"> <div className="node-row"> {action === Action.Edit ? ( selectOwners ) : ( <OwnersField /> )} {action === Action.Edit ? selectTags : <TagsField />} </div> </div> {Object.entries(extensions).map( ([key, ExtensionComponent]) => ( <div key={key} className="node-group"> <ExtensionComponent node={node} action={action} registerSubmitHandler={( onSubmit, { prepend } = {}, ) => { if (!submitHandlers.includes(onSubmit)) { if (prepend) { submitHandlers.unshift(onSubmit); } else { submitHandlers.push(onSubmit); } } }} /> </div> ), )} <div className="node-group node-group-last"> <CustomMetadataField value={ node.custom_metadata ? node.custom_metadata : '' } /> </div> <button type="submit" disabled={isSubmitting || !isValid} className={`save-node-btn${ isSubmitting ? ' save-node-btn--loading' : '' }`} > {isSubmitting ? ( <> <LoadingIcon /> Saving... </> ) : ( (action === Action.Add ? 'Create ' : 'Save ') + (nodeType ? nodeType : '') )} </button> </> )} </Form> ); }} </Formik> </div> </div> </div> ); }