datajunction-ui
Version:
DataJunction UI
332 lines (305 loc) • 11.4 kB
JSX
/**
* A select component for picking dimensions.
* Dimensions are grouped by hop distance (how many joins away from the metrics).
*/
import Select from 'react-select';
import React, { useContext, useEffect, useState } from 'react';
import DJClientContext from '../../providers/djclient';
import { labelize } from '../../../utils/form';
/**
* Calculate hop distance from path length.
* path.length represents how many joins away the dimension is.
*/
const getHopDistance = path => {
if (!path || path.length === 0) return 0;
return path.length;
};
/**
* Render role information as a label suffix when a dimension is reached
* via a named role (e.g. "[birth_country]"). Stored role values can be raw
* (e.g. "birth_country") or already bracketed; normalize either form to
* " [role]". Returns "" when there is no role.
*/
const formatRoleSuffix = role => {
if (!role) return '';
const stripped = role.replace(/^\[|\]$/g, '');
return stripped ? ` [${stripped}]` : '';
};
/**
* Parse a role suffix off the end of a dimension's full name.
* "default.user_dim.country_code[birth_country]" → ["default.user_dim.country_code", "birth_country"]
* "default.user_dim.country_code" → ["default.user_dim.country_code", ""]
*/
const splitRole = fullName => {
if (!fullName) return [fullName, ''];
const match = fullName.match(/^(.+?)\[([^\]]+)\]$/);
return match ? [match[1], match[2]] : [fullName, ''];
};
/**
* Get human-readable label for hop distance.
*/
const getHopLabel = hopDistance => {
if (hopDistance === 0) return 'Direct Dimensions';
if (hopDistance === 1) return '1 Hop Away';
return `${hopDistance} Hops Away`;
};
export const DimensionsSelect = React.memo(function DimensionsSelect({
cube,
metrics,
onChange,
}) {
const djClient = useContext(DJClientContext).DataJunctionAPI;
// Dimensions grouped by hop distance, then by node+path
const [dimensionsByHop, setDimensionsByHop] = useState({});
// The selected dimensions, grouped by dimension node and path
const [selectedDimensionsByGroup, setSelectedDimensionsByGroup] = useState(
{},
);
// The existing cube node's dimensions, if editing a cube
const [defaultDimensions, setDefaultDimensions] = useState([]);
useEffect(() => {
const fetchData = async () => {
let cubeDimensions = undefined;
// `cube` may be the bare cube object or a ref-like { current: ... }.
// Either shape can also arrive partially populated while data is in
// flight, so guard before touching `.cubeDimensions` to avoid the
// Suspense boundary surfacing a TypeError to the user.
const cubeData = cube?.current ?? cube;
if (cubeData?.cubeDimensions) {
cubeDimensions = cubeData.cubeDimensions.map(cubeDim => {
return {
value: cubeDim.name,
label:
labelize(cubeDim.attribute) +
formatRoleSuffix(cubeDim.role) +
(cubeDim.properties?.includes('primary_key') ? ' (PK)' : ''),
};
});
setDefaultDimensions(cubeDimensions);
onChange(cubeDimensions.map(m => m.value));
}
if (metrics && metrics.length > 0) {
// Populate the common dimensions list based on the selected metrics
const commonDimensions = await djClient.commonDimensions(metrics);
// First group by node_name + path (original grouping)
const groupedByNodePath = commonDimensions.reduce(
(group, dimension) => {
const key = dimension.node_name + JSON.stringify(dimension.path);
group[key] = group[key] ?? [];
group[key].push(dimension);
return group;
},
{},
);
// Then organize by hop distance
const byHop = {};
Object.values(groupedByNodePath).forEach(dimensionsInGroup => {
const hopDistance = getHopDistance(dimensionsInGroup[0].path);
byHop[hopDistance] = byHop[hopDistance] ?? [];
byHop[hopDistance].push(dimensionsInGroup);
});
setDimensionsByHop(byHop);
// Set the selected cube dimensions if an existing cube is being edited.
// Only run when we actually have the cube's dimensions in hand — the
// outer guard leaves `cubeDimensions` undefined when the cube data
// hasn't loaded yet.
if (cube && cubeDimensions) {
const currentSelectedDimensionsByGroup = {};
Object.values(groupedByNodePath).forEach(dimensionsInGroup => {
const groupKey =
dimensionsInGroup[0].node_name +
JSON.stringify(dimensionsInGroup[0].path);
currentSelectedDimensionsByGroup[groupKey] = getValue(
cubeDimensions.filter(
dim =>
dimensionsInGroup.filter(x => dim.value === x.name).length >
0,
),
);
});
setSelectedDimensionsByGroup(currentSelectedDimensionsByGroup);
onChange(Object.values(currentSelectedDimensionsByGroup).flat(2));
}
} else {
setDimensionsByHop({});
}
};
fetchData().catch(console.error);
}, [djClient, onChange, metrics, cube]);
// Retrieves the selected values as a list (since it is a multi-select)
const getValue = options => {
if (options) {
return options.map(option => option.value);
} else {
return [];
}
};
// Sort hop distances numerically
const sortedHops = Object.keys(dimensionsByHop)
.map(Number)
.sort((a, b) => a - b);
// Custom styles to color-code dimension tags (matching Query Planner exactly)
const dimensionStyles = {
multiValue: base => ({
...base,
backgroundColor: '#ffefd0',
border: '1px solid rgba(169, 102, 33, 0.3)',
borderRadius: '3px',
margin: '2px',
}),
multiValueLabel: base => ({
...base,
color: '#a96621',
fontSize: '10px',
fontWeight: 500,
padding: '2px 4px 2px 6px',
}),
multiValueRemove: base => ({
...base,
color: '#a96621',
padding: '0 4px',
':hover': {
backgroundColor: '#ffe4b3',
color: '#a96621',
},
}),
};
if (sortedHops.length === 0) {
return null;
}
return (
<div>
{sortedHops.map(hopDistance => {
const groupsAtHop = dimensionsByHop[hopDistance];
const dimensionCount = groupsAtHop.reduce(
(sum, g) => sum + g.length,
0,
);
return (
<div key={hopDistance} style={{ marginBottom: '20px' }}>
{/* Hop distance header */}
<div
style={{
display: 'flex',
alignItems: 'center',
padding: '6px 0',
borderBottom: '1px solid #dee2e6',
marginBottom: '8px',
marginTop: hopDistance > 0 ? '24px' : '0',
}}
>
<span
style={{
fontWeight: 600,
color: '#212529',
fontSize: '14px',
}}
>
{getHopLabel(hopDistance)}
</span>
<span
style={{
marginLeft: '10px',
color: '#6c757d',
fontSize: '13px',
}}
>
({dimensionCount} dimension{dimensionCount !== 1 ? 's' : ''})
</span>
</div>
{/* Dimension groups within this hop - indented */}
<div style={{ paddingLeft: '6px' }}>
{groupsAtHop.map(dimensionsInGroup => {
const groupKey =
dimensionsInGroup[0].node_name +
JSON.stringify(dimensionsInGroup[0].path);
// Convert path node names to display names
const pathDisplayNames = dimensionsInGroup[0].path.map(
nodeName => {
const lastSegment = nodeName.split('.').pop();
return labelize(lastSegment);
},
);
const groupHeader = (
<h5
style={{
fontWeight: 'normal',
marginBottom: '5px',
marginTop: '10px',
fontSize: '13px',
}}
title={dimensionsInGroup[0].path.join(' → ')}
>
<a href={`/nodes/${dimensionsInGroup[0].node_name}`}>
<b>{dimensionsInGroup[0].node_display_name}</b>
</a>
{pathDisplayNames.length > 0 && (
<>
{' '}
<span style={{ color: '#6c757d' }}>via</span>{' '}
{pathDisplayNames.map((displayName, idx) => (
<span key={idx}>
{idx > 0 && ' → '}
<a
href={`/nodes/${dimensionsInGroup[0].path[idx]}`}
>
{displayName}
</a>
</span>
))}
</>
)}
</h5>
);
const dimensionGroupOptions = dimensionsInGroup.map(dim => {
const [bareName, role] = splitRole(dim.name);
return {
value: dim.name,
label:
labelize(bareName.split('.').slice(-1)[0]) +
formatRoleSuffix(role) +
(dim.properties?.includes('primary_key') ? ' (PK)' : ''),
};
});
const cubeDimensions = defaultDimensions.filter(
dim =>
dimensionGroupOptions.filter(x => dim.value === x.value)
.length > 0,
);
return (
<div key={groupKey}>
{groupHeader}
<span
data-testid={
'dimensions-' + dimensionsInGroup[0].node_name
}
>
<Select
className=""
name={'dimensions-' + groupKey}
defaultValue={cubeDimensions}
options={dimensionGroupOptions}
styles={dimensionStyles}
isMulti
isClearable
closeMenuOnSelect={false}
onChange={selected => {
const newSelected = {
...selectedDimensionsByGroup,
[groupKey]: getValue(selected),
};
setSelectedDimensionsByGroup(newSelected);
onChange(Object.values(newSelected).flat(2));
}}
/>
</span>
</div>
);
})}
</div>
</div>
);
})}
</div>
);
});