UNPKG

datajunction-ui

Version:
534 lines (499 loc) 19.3 kB
import React, { useCallback, useContext, useEffect, useState } from 'react'; import Select from 'react-select'; import NamespaceHeader from '../../components/NamespaceHeader'; import { DataJunctionAPI } from '../../services/DJService'; import DJClientContext from '../../providers/djclient'; import { useCurrentUser } from '../../providers/UserProvider'; import 'react-querybuilder/dist/query-builder.scss'; import 'styles/styles.scss'; import './styles.css'; import '../QueryPlannerPage/styles.css'; import { ErrorMessage, FastField, Field, Form, Formik, useField } from 'formik'; import { displayMessageAfterSubmit } from '../../../utils/form'; import { useParams, useNavigate } from 'react-router-dom'; import { Action } from '../../components/forms/Action'; import NodeNameField from '../../components/forms/NodeNameField'; import { MetricsSelect } from './MetricsSelect'; import { DimensionsSelect } from './DimensionsSelect'; import { CubePreviewPanel } from './CubePreviewPanel'; // Description textarea using FastField so typing here doesn't trigger // re-renders of unrelated form sections (heavy ones like the SQL preview). const DescriptionField = () => ( <FastField as="textarea" id="Description" name="description" placeholder="Describe your cube" /> ); // Simple Tags select matching MetricsSelect styling const CubeTagsSelect = ({ defaultValue }) => { const djClient = useContext(DJClientContext).DataJunctionAPI; const [field, , helpers] = useField('tags'); const [options, setOptions] = useState([]); const [selected, setSelected] = useState(defaultValue || []); useEffect(() => { djClient.listTags().then(tags => { setOptions(tags.map(t => ({ value: t.name, label: t.display_name }))); }); }, [djClient]); useEffect(() => { if (defaultValue) setSelected(defaultValue); }, [defaultValue]); const handleChange = sel => { setSelected(sel || []); helpers.setValue((sel || []).map(s => s.value)); }; return ( <Select value={selected} options={options} onChange={handleChange} isMulti placeholder="Select tags..." /> ); }; // Simple Owners select matching MetricsSelect styling const CubeOwnersSelect = ({ defaultValue, isEdit = false }) => { const djClient = useContext(DJClientContext).DataJunctionAPI; const { currentUser } = useCurrentUser(); const [field, , helpers] = useField('owners'); const [options, setOptions] = useState([]); const [selected, setSelected] = useState(defaultValue || []); useEffect(() => { djClient.users().then(users => { setOptions(users.map(u => ({ value: u.username, label: u.username }))); }); }, [djClient]); // Only auto-default to current user in Add mode; in Edit mode wait for defaultValue useEffect(() => { if (defaultValue) { setSelected(defaultValue); helpers.setValue(defaultValue.map(d => d.value)); } else if (!isEdit && currentUser) { const def = [ { value: currentUser.username, label: currentUser.username }, ]; setSelected(def); helpers.setValue([currentUser.username]); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [defaultValue, currentUser, isEdit]); const handleChange = sel => { setSelected(sel || []); helpers.setValue((sel || []).map(s => s.value)); }; return ( <Select value={selected} options={options} onChange={handleChange} isMulti placeholder="Select owners..." /> ); }; export function CubeBuilderPage() { const djClient = useContext(DJClientContext).DataJunctionAPI; const navigate = useNavigate(); let { nodeType, initialNamespace, name } = useParams(); const action = name !== undefined ? Action.Edit : Action.Add; const validator = ruleType => !!ruleType.value; const initialValues = { name: action === Action.Edit ? name : '', namespace: action === Action.Add ? initialNamespace : '', display_name: '', description: '', mode: 'published', metrics: [], dimensions: [], filters: [], tags: [], owners: [], }; const handleSubmit = (values, { setSubmitting, setStatus }) => { if (action === Action.Add) { setTimeout(() => { createNode(values, setStatus); setSubmitting(false); }, 400); } else { setTimeout(() => { patchNode(values, setStatus); setSubmitting(false); }, 400); } window.scrollTo({ top: 0, left: 0, behavior: 'smooth' }); }; const createNode = async (values, setStatus) => { const { status, json } = await djClient.createCube( values.name, values.display_name, values.description, values.mode, values.metrics, values.dimensions, values.filters || [], ); if (status === 200 || status === 201) { if (values.tags) { await djClient.tagsNode(values.name, values.tags); } setStatus({ success: true, savedName: json.name, }); } else { setStatus({ failure: `${json.message}`, }); } }; const patchNode = async (values, setStatus) => { const { status, json } = await djClient.patchCube( values.name, values.display_name, values.description, values.mode, values.metrics, values.dimensions, values.filters || [], values.owners, ); const tagsResponse = await djClient.tagsNode( values.name, (values.tags || []).map(tag => tag), ); if ((status === 200 || status === 201) && tagsResponse.status === 200) { setStatus({ success: true, savedName: json.name, }); } else { setStatus({ failure: `${json.message}`, }); } }; const updateFieldsWithNodeData = ( data, setFieldValue, setSelectTags, setSelectOwners, ) => { setFieldValue('display_name', data.current.displayName || '', false); setFieldValue('description', data.current.description || '', false); setFieldValue('mode', data.current.mode.toLowerCase() || 'draft', false); setFieldValue( 'tags', data.tags.map(tag => tag.name), ); // Store default values as arrays for the Select components setSelectTags( data.tags.map(t => ({ value: t.name, label: t.displayName })), ); if (data.owners) { setSelectOwners( data.owners.map(owner => ({ value: owner.username, label: owner.username, })), ); } }; // @ts-ignore return ( <> <div className="mid"> <NamespaceHeader namespace={ initialNamespace ? initialNamespace : name ? name.substring(0, name.lastIndexOf('.')) : '' } /> <Formik initialValues={initialValues} validate={validator} onSubmit={handleSubmit} > {function Render({ isSubmitting, status, setFieldValue, values, props, }) { const [node, setNode] = useState([]); const [selectTags, setSelectTags] = useState(null); const [selectOwners, setSelectOwners] = useState(null); const [justSaved, setJustSaved] = useState(false); const [saveError, setSaveError] = useState(null); // Get cube useEffect(() => { const fetchData = async () => { if (name) { const cube = await djClient.getCubeForEditing(name); setNode(cube); updateFieldsWithNodeData( cube, setFieldValue, setSelectTags, setSelectOwners, ); } }; fetchData().catch(console.error); }, [setFieldValue]); // Stable callbacks so the memoized child components don't re-render // on every Formik state change (e.g. typing in display_name). const setMetrics = useCallback( v => setFieldValue('metrics', v), [setFieldValue], ); const setDimensions = useCallback( v => setFieldValue('dimensions', v), [setFieldValue], ); // Briefly show "Saved" state on the button, then redirect to the cube page useEffect(() => { if (status?.success) { setJustSaved(true); setSaveError(null); const savedName = status.savedName; const timer = setTimeout(() => { if (savedName) { navigate(`/nodes/${savedName}`); } }, 1200); return () => clearTimeout(timer); } if (status?.failure) { setSaveError(status.failure); setJustSaved(false); } }, [status, navigate]); return ( <Form className="cube-builder"> {/* Header */} <div className="cube-builder-header"> <h2> {action === Action.Edit ? 'Edit' : 'Create'}{' '} <span className="node_type__cube node_type_creation_heading"> Cube </span> </h2> </div> {/* Two-column layout */} <div className="cube-builder-layout"> {/* Left: Main form */} <div className="cube-builder-main"> {/* Details Section */} <div className="cube-form-section"> <div className="cube-form-section-body"> {action === Action.Add ? ( <> {/* Add mode uses NodeNameField for namespace/name */} <NodeNameField /> {/* Row: Description | Mode */} <div className="cube-field-row"> <div className="cube-field cube-field-grow"> <ErrorMessage name="description" component="span" /> <label className="cube-field-label" htmlFor="Description" > Description </label> <DescriptionField /> </div> <div className="cube-field cube-field-small"> <label className="cube-field-label" htmlFor="Mode" > Mode </label> <Field as="select" name="mode" id="Mode"> <option value="draft">Draft</option> <option value="published">Published</option> </Field> </div> </div> {/* Row: Tags | Owners */} <div className="cube-field-row"> <div className="cube-field cube-field-half"> <label className="cube-field-label">Tags</label> <CubeTagsSelect /> </div> <div className="cube-field cube-field-half"> <label className="cube-field-label"> Owners </label> <CubeOwnersSelect /> </div> </div> </> ) : ( <> {/* Row 1: Name (full width) */} <div className="cube-field"> <label className="cube-field-label">Name</label> <div className="cube-field-static">{name}</div> </div> {/* Row 2: Display Name | Mode */} <div className="cube-field-row"> <div className="cube-field cube-field-grow"> <ErrorMessage name="display_name" component="span" /> <label className="cube-field-label" htmlFor="displayName" > Display Name </label> <FastField type="text" name="display_name" id="displayName" placeholder="Human readable name" /> </div> <div className="cube-field cube-field-small"> <label className="cube-field-label" htmlFor="Mode" > Mode </label> <Field as="select" name="mode" id="Mode"> <option value="draft">Draft</option> <option value="published">Published</option> </Field> </div> </div> {/* Row 3: Description (full width) */} <div className="cube-field"> <ErrorMessage name="description" component="span" /> <label className="cube-field-label" htmlFor="Description" > Description </label> <DescriptionField /> </div> {/* Row 4: Tags | Owners */} <div className="cube-field-row"> <div className="cube-field cube-field-half"> <label className="cube-field-label">Tags</label> <CubeTagsSelect defaultValue={selectTags} /> </div> <div className="cube-field cube-field-half"> <label className="cube-field-label"> Owners </label> <CubeOwnersSelect defaultValue={selectOwners} isEdit={true} /> </div> </div> </> )} </div> </div> {/* Metrics Section */} <div className="cube-form-section"> <div className="cube-form-section-header"> <h3>Metrics</h3> </div> <div className="cube-form-section-body"> <div data-testid="select-metrics"> {action === Action.Edit ? ( <MetricsSelect cube={node} onChange={setMetrics} /> ) : ( <MetricsSelect onChange={setMetrics} /> )} </div> </div> </div> {/* Dimensions Section */} <div className="cube-form-section"> <div className="cube-form-section-header"> <h3>Dimensions</h3> </div> <div className="cube-form-section-body"> <div data-testid="select-dimensions"> {action === Action.Edit ? ( <DimensionsSelect cube={node} metrics={values.metrics} onChange={setDimensions} /> ) : ( <DimensionsSelect metrics={values.metrics} onChange={setDimensions} /> )} </div> </div> </div> </div> {/* Right: Sidebar */} <div className="cube-builder-sidebar"> <CubePreviewPanel metrics={values.metrics} dimensions={values.dimensions} /> <div className="cube-settings"> {saveError && ( <div className="save-error-message">{saveError}</div> )} <button type="submit" disabled={isSubmitting || justSaved} aria-label="CreateCube" className={`save-cube-btn${ justSaved ? ' save-cube-btn--saved' : '' }${isSubmitting ? ' save-cube-btn--loading' : ''}`} > {isSubmitting ? ( <> <span className="save-spinner" aria-hidden="true" /> Saving... </> ) : justSaved ? ( '✓ Saved' ) : action === Action.Add ? ( 'Create Cube' ) : ( 'Save' )} </button> </div> </div> </div> </Form> ); }} </Formik> </div> </> ); } CubeBuilderPage.defaultProps = { djClient: DataJunctionAPI, };