datajunction-ui
Version:
DataJunction Metrics Platform UI
478 lines (455 loc) • 15.8 kB
JSX
/**
* 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>
);
}