@malloy-publisher/sdk 0.0.145 → 0.0.147

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@malloy-publisher/sdk",
3
3
  "description": "Malloy Publisher SDK",
4
- "version": "0.0.145",
4
+ "version": "0.0.147",
5
5
  "type": "module",
6
6
  "main": "dist/index.cjs.js",
7
7
  "module": "dist/index.es.js",
@@ -363,7 +363,7 @@ export default function Notebook({
363
363
  elevation={0}
364
364
  sx={{
365
365
  p: 3,
366
- backgroundColor: "#f8f9fa",
366
+ backgroundColor: "#ffffff",
367
367
  border: "1px solid #e0e0e0",
368
368
  borderRadius: 2,
369
369
  }}
@@ -67,6 +67,14 @@ export function NotebookCell({
67
67
  const IMPORT_MODEL_PATH_REGEX =
68
68
  /import\s*(?:\{[^}]*\}\s*from\s*)?['"`]([^'"`]+)['"`]/;
69
69
 
70
+ // Filter out lines starting with ## from Malloy code
71
+ const filterMalloyCode = (code: string): string => {
72
+ return code
73
+ .split("\n")
74
+ .filter((line) => !line.trimStart().startsWith("##"))
75
+ .join("\n");
76
+ };
77
+
70
78
  const hasValidImport =
71
79
  !!cell.text &&
72
80
  (IMPORT_NAMES_REGEX.test(cell.text) ||
@@ -124,7 +132,7 @@ export function NotebookCell({
124
132
 
125
133
  useEffect(() => {
126
134
  if (cell.type === "code")
127
- highlight(cell.text, "malloy").then((code) => {
135
+ highlight(filterMalloyCode(cell.text), "malloy").then((code) => {
128
136
  setHighlightedMalloyCode(code);
129
137
  });
130
138
  }, [cell]);
@@ -214,7 +222,7 @@ export function NotebookCell({
214
222
  sx={{
215
223
  flexDirection: "column",
216
224
  gap: "8px",
217
- marginBottom: "16px",
225
+ marginBottom: "2px",
218
226
  }}
219
227
  >
220
228
  {cell.newSources && cell.newSources.length > 0 && (
@@ -433,8 +441,8 @@ export function NotebookCell({
433
441
  >
434
442
  <ResultContainer
435
443
  result={cell.result}
436
- minHeight={300}
437
- maxHeight={1000}
444
+ minHeight={200}
445
+ maxHeight={700}
438
446
  maxResultSize={maxResultSize}
439
447
  />
440
448
  </Box>
@@ -1,10 +1,4 @@
1
- import React, {
2
- Suspense,
3
- useEffect,
4
- useLayoutEffect,
5
- useRef,
6
- useState,
7
- } from "react";
1
+ import React, { Suspense, useLayoutEffect, useRef } from "react";
8
2
 
9
3
  type MalloyRenderElement = HTMLElement & Record<string, unknown>;
10
4
 
@@ -45,31 +39,14 @@ const createRenderer = async (onDrill?: (element: unknown) => void) => {
45
39
  return renderer.createViz();
46
40
  };
47
41
 
48
- function RenderResultSimple({ result, onDrill }: RenderedResultProps) {
49
- const ref = useRef<HTMLDivElement>(null);
50
-
51
- useLayoutEffect(() => {
52
- if (!ref.current || !result) return;
53
- const element = ref.current;
54
-
55
- createRenderer(onDrill).then((viz) => {
56
- viz.setResult(JSON.parse(result));
57
- viz.render(element);
58
- });
59
- }, [result, onDrill]);
60
-
61
- return <div ref={ref} style={{ width: "100%", height: "100%" }} />;
62
- }
63
42
  // Inner component that actually renders the visualization
64
43
  function RenderedResultInner({
65
44
  result,
66
45
  height,
67
- isFillElement,
68
- onSizeChange,
69
46
  onDrill,
47
+ onSizeChange,
70
48
  }: RenderedResultProps) {
71
49
  const ref = useRef<HTMLDivElement>(null);
72
- const [isRendered, setIsRendered] = useState(false);
73
50
 
74
51
  // Render the visualization once the component mounts
75
52
  useLayoutEffect(() => {
@@ -83,37 +60,44 @@ function RenderedResultInner({
83
60
  element.removeChild(element.firstChild);
84
61
  }
85
62
 
63
+ // Set up observer to measure size after render completes
64
+ let observer: MutationObserver | null = null;
65
+ let measureTimeout: NodeJS.Timeout | null = null;
66
+
67
+ const measureRenderedSize = () => {
68
+ if (!isMounted || !element.firstElementChild) return;
69
+
70
+ // It's the grandchild that is the actual visualization.
71
+ const child = element.firstElementChild as HTMLElement;
72
+ const grandchild = child.firstElementChild as HTMLElement;
73
+ if (!grandchild) return;
74
+ const renderedHeight =
75
+ grandchild.scrollHeight || grandchild.offsetHeight || 0;
76
+
77
+ if (renderedHeight > 0 && onSizeChange) {
78
+ onSizeChange(renderedHeight);
79
+ }
80
+ };
81
+
86
82
  createRenderer(onDrill)
87
83
  .then((viz) => {
88
84
  if (!isMounted) return;
89
85
 
90
- // Set up a mutation observer to detect when content is added
91
- const observer = new MutationObserver((mutations) => {
92
- for (const mutation of mutations) {
93
- if (
94
- mutation.type === "childList" &&
95
- mutation.addedNodes.length > 0
96
- ) {
97
- const hasContent = Array.from(mutation.addedNodes).some(
98
- (node) => node.nodeType === Node.ELEMENT_NODE,
99
- );
100
- if (hasContent) {
101
- observer.disconnect();
102
- setTimeout(() => {
103
- if (isMounted) {
104
- setIsRendered(true);
105
- }
106
- }, 50);
107
- break;
108
- }
109
- }
110
- }
86
+ // Set up mutation observer to detect when rendering is complete
87
+ observer = new MutationObserver(() => {
88
+ // Debounce - wait for mutations to settle
89
+ if (measureTimeout) clearTimeout(measureTimeout);
90
+ measureTimeout = setTimeout(() => {
91
+ measureRenderedSize();
92
+ // Disconnect after measuring to prevent infinite loops
93
+ observer?.disconnect();
94
+ }, 100);
111
95
  });
112
96
 
113
97
  observer.observe(element, {
114
98
  childList: true,
115
99
  subtree: true,
116
- characterData: true,
100
+ attributes: true,
117
101
  });
118
102
 
119
103
  try {
@@ -121,71 +105,21 @@ function RenderedResultInner({
121
105
  viz.render(element);
122
106
  } catch (error) {
123
107
  console.error("Error rendering visualization:", error);
124
- observer.disconnect();
125
- if (isMounted) {
126
- setIsRendered(true);
127
- }
108
+ observer?.disconnect();
128
109
  }
129
110
  })
130
111
  .catch((error) => {
131
112
  console.error("Failed to create renderer:", error);
132
- if (isMounted) {
133
- setIsRendered(true);
134
- }
135
113
  });
136
114
 
137
115
  return () => {
138
116
  isMounted = false;
139
- };
140
- }, [result, onDrill]);
141
-
142
- // Set up size measurement using scrollHeight instead of ResizeObserver
143
- useEffect(() => {
144
- if (!ref.current || !isRendered) return;
145
- const element = ref.current;
146
-
147
- // Function to measure and report size
148
- const measureSize = () => {
149
- if (element) {
150
- const measuredHeight = element.offsetHeight;
151
- if (measuredHeight > 0) {
152
- if (onSizeChange) {
153
- onSizeChange(measuredHeight);
154
- }
155
- } else if (isFillElement && element.firstChild) {
156
- // HACK- we If there's a child and it's height is 0, then we're in a fill element
157
- // We use the callback `isFillElement` to notify the parent that we're in a fill element
158
- // the parent should then set height for this element, otherwise it will have size 0.
159
- const child = element.firstChild as HTMLElement;
160
- const childHeight = child.offsetHeight;
161
- if (childHeight == 0) {
162
- isFillElement(true);
163
- } else {
164
- isFillElement(false);
165
- }
166
- }
167
- }
168
- };
169
-
170
- // Initial measurement after a brief delay to let content render
171
- const timeoutId = setTimeout(measureSize, 100);
172
-
173
- let observer: MutationObserver | null = null;
174
- // Also measure when the malloy result changes
175
- observer = new MutationObserver(measureSize);
176
- observer.observe(element, {
177
- childList: true,
178
- subtree: true,
179
- attributes: true,
180
- });
181
-
182
- // Cleanup
183
- return () => {
184
- clearTimeout(timeoutId);
185
117
  observer?.disconnect();
118
+ if (measureTimeout) clearTimeout(measureTimeout);
186
119
  };
187
- }, [onSizeChange, result, isFillElement, isRendered]);
120
+ }, [result, onDrill, onSizeChange]);
188
121
 
122
+ // Always use fixed height - no measurement, no resizing
189
123
  return (
190
124
  <div
191
125
  ref={ref}
@@ -234,11 +168,7 @@ export default function RenderedResult(props: RenderedResultProps) {
234
168
  </div>
235
169
  }
236
170
  >
237
- {props.onSizeChange ? (
238
- <RenderedResultInner {...props} />
239
- ) : (
240
- <RenderResultSimple {...props} />
241
- )}
171
+ <RenderedResultInner {...props} />
242
172
  </Suspense>
243
173
  );
244
174
  }
@@ -1,13 +1,6 @@
1
- import { ExpandLess, ExpandMore, Warning } from "@mui/icons-material";
2
- import { Box, Button, IconButton, Typography } from "@mui/material";
3
- import {
4
- lazy,
5
- Suspense,
6
- useCallback,
7
- useEffect,
8
- useRef,
9
- useState,
10
- } from "react";
1
+ import { Warning } from "@mui/icons-material";
2
+ import { Box, Button, Typography } from "@mui/material";
3
+ import { lazy, Suspense, useRef, useState } from "react";
11
4
  import { Loading } from "../Loading";
12
5
 
13
6
  const RenderedResult = lazy(() => import("../RenderedResult/RenderedResult"));
@@ -31,62 +24,12 @@ export default function ResultContainer({
31
24
  result,
32
25
  minHeight,
33
26
  maxHeight,
34
- hideToggle = false,
27
+ hideToggle: _hideToggle = false,
35
28
  maxResultSize = 0,
36
29
  }: ResultContainerProps) {
37
- const [isExpanded, setIsExpanded] = useState(false);
38
- const [contentHeight, setContentHeight] = useState<number>(0);
39
- const [shouldShowToggle, setShouldShowToggle] = useState(false);
40
- const contentRef = useRef<HTMLDivElement>(null);
41
30
  const containerRef = useRef<HTMLDivElement>(null);
42
- const [explicitHeight, setExplicitHeight] = useState<number>(undefined);
43
- const [isFillElement, setIsFillElement] = useState(false);
31
+ const [measuredHeight, setMeasuredHeight] = useState(maxHeight);
44
32
  const [userAcknowledged, setUserAcknowledged] = useState(false);
45
- const handleToggle = useCallback(() => {
46
- const wasExpanded = isExpanded;
47
- setIsExpanded(!isExpanded);
48
-
49
- // If we're collapsing (going from expanded to collapsed), scroll to top
50
- if (wasExpanded && containerRef.current) {
51
- setTimeout(() => {
52
- containerRef.current?.scrollIntoView({
53
- behavior: "smooth",
54
- block: "start",
55
- });
56
- }, 100); // Small delay to allow the collapse animation to start
57
- }
58
- }, [isExpanded]);
59
-
60
- // Handle size changes from RenderedResult
61
- const handleSizeChange = useCallback((height: number) => {
62
- setContentHeight(height);
63
- }, []);
64
-
65
- // Determine if toggle should be shown based on content height vs container height
66
- useEffect(() => {
67
- if (hideToggle) {
68
- setShouldShowToggle(false);
69
- return;
70
- }
71
- if (isFillElement) {
72
- setShouldShowToggle(true);
73
- return;
74
- }
75
- // Only proceed if we have a measured content height
76
- if (contentHeight === 0) {
77
- setShouldShowToggle(false);
78
- return;
79
- }
80
-
81
- // The available height should be the minHeight minus the padding
82
- // We don't subtract toggle button height here since we're deciding whether to show it
83
- const availableHeight = minHeight - 20; // Estimate padding
84
- const exceedsHeight = contentHeight > availableHeight;
85
- if (contentHeight < availableHeight) {
86
- setExplicitHeight(contentHeight + 20);
87
- }
88
- setShouldShowToggle(exceedsHeight);
89
- }, [contentHeight, isFillElement, minHeight, hideToggle]);
90
33
 
91
34
  if (!result) {
92
35
  return null;
@@ -128,98 +71,29 @@ export default function ResultContainer({
128
71
  }
129
72
 
130
73
  const loading = <Loading text="Loading..." centered={true} size={32} />;
131
- const renderedHeight = isFillElement
132
- ? isExpanded
133
- ? maxHeight - 40
134
- : minHeight - 40
135
- : undefined;
136
- const height = explicitHeight
137
- ? {
138
- minHeight: `${explicitHeight}px`,
139
- height: `100%`,
140
- }
141
- : { height: `100%` };
142
- return (
143
- <>
144
- <Box
145
- ref={containerRef}
146
- sx={{
147
- position: "relative",
148
- minHeight: `${minHeight}px`,
149
- maxHeight: `${isExpanded ? maxHeight : minHeight}px`,
150
- border: "0px",
151
- borderRadius: 0,
152
- overflow: "hidden",
153
- display: "flex",
154
- flexDirection: "column",
155
- ...height,
156
- }}
157
- >
158
- {/* Content area */}
159
- <Box
160
- ref={contentRef}
161
- sx={{
162
- flex: 1,
163
- overflow: "hidden",
164
- p: 0,
165
- // Adjust bottom padding when toggle is shown to prevent content overlap
166
- pb: shouldShowToggle ? "40px" : 1,
167
- }}
168
- >
169
- {(result && (
170
- <Suspense fallback={loading}>
171
- <RenderedResult
172
- result={result}
173
- height={renderedHeight}
174
- isFillElement={(isFill) => {
175
- setIsFillElement(isFill);
176
- }}
177
- onSizeChange={handleSizeChange}
178
- />
179
- </Suspense>
180
- )) ||
181
- loading}
182
- </Box>
74
+ // Fixed height for content - no resizing
75
+ const renderedHeight = Math.min(maxHeight, measuredHeight);
183
76
 
184
- {/* Toggle button - only show if content exceeds container height */}
185
- {shouldShowToggle && (
186
- <Box
187
- sx={{
188
- position: "absolute",
189
- bottom: 0,
190
- left: 0,
191
- right: 0,
192
- height: "32px",
193
- backgroundColor: "rgba(255,255,255,0.75)",
194
- display: "flex",
195
- alignItems: "center",
196
- justifyContent: "center",
197
- }}
198
- >
199
- <IconButton
200
- size="small"
201
- onClick={handleToggle}
202
- sx={{
203
- color: "text.secondary",
204
- "&:hover": {
205
- backgroundColor: "rgba(0, 0, 0, 0.04)",
206
- },
207
- }}
208
- title={
209
- isExpanded
210
- ? "Collapse to original size"
211
- : "Expand to full size"
212
- }
213
- >
214
- {isExpanded ? (
215
- <ExpandLess sx={{ fontSize: 30 }} />
216
- ) : (
217
- <ExpandMore sx={{ fontSize: 30 }} />
218
- )}
219
- </IconButton>
220
- </Box>
221
- )}
222
- </Box>
223
- </>
77
+ return (
78
+ <Box
79
+ ref={containerRef}
80
+ sx={{
81
+ position: "relative",
82
+ height: `${renderedHeight}px`,
83
+ border: "0px",
84
+ borderRadius: 0,
85
+ overflow: "hidden",
86
+ }}
87
+ >
88
+ {result && (
89
+ <Suspense fallback={loading}>
90
+ <RenderedResult
91
+ result={result}
92
+ height={renderedHeight}
93
+ onSizeChange={setMeasuredHeight}
94
+ />
95
+ </Suspense>
96
+ )}
97
+ </Box>
224
98
  );
225
99
  }
@@ -187,7 +187,7 @@ export function DimensionFilter({
187
187
  setRetrievalSearched(true);
188
188
  }
189
189
  }
190
- }, 300);
190
+ }, 500);
191
191
 
192
192
  // Cleanup: cancel timer on unmount or when dependencies change
193
193
  return () => {
@@ -311,11 +311,41 @@ export function DimensionFilter({
311
311
  };
312
312
 
313
313
  return (
314
- <Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
315
- {/* Dimension Name */}
316
- <Box sx={{ fontWeight: 600, fontSize: "0.875rem" }}>
317
- {spec.dimensionName}
318
- </Box>
314
+ <Box
315
+ sx={{
316
+ display: "flex",
317
+ flexDirection: "column",
318
+ gap: 1.5,
319
+ fontSize: "0.75rem", // 25% smaller than default
320
+ "& .MuiInputBase-root": { fontSize: "0.75rem" },
321
+ "& .MuiInputBase-input": { padding: "6px 10px" },
322
+ "& .MuiSelect-select": { padding: "6px 10px !important" },
323
+ "& .MuiAutocomplete-input": { padding: "3px 6px !important" },
324
+ "& .MuiAutocomplete-root .MuiInputBase-root": {
325
+ padding: "3px 6px",
326
+ },
327
+ "& .MuiChip-root": { height: "20px" },
328
+ "& .MuiChip-label": { fontSize: "0.7rem", padding: "0 6px" },
329
+ "& .MuiInputLabel-root": {
330
+ fontSize: "0.75rem",
331
+ transform: "translate(10px, 6px) scale(1)",
332
+ },
333
+ "& .MuiInputLabel-shrink": {
334
+ fontSize: "0.85rem",
335
+ transform: "translate(14px, -9px) scale(0.75)",
336
+ },
337
+ "& .MuiOutlinedInput-notchedOutline legend": {
338
+ fontSize: "0.65rem",
339
+ },
340
+ "& .MuiFormHelperText-root": {
341
+ fontSize: "0.65rem",
342
+ marginTop: "2px",
343
+ },
344
+ "& .MuiSvgIcon-root": { fontSize: "1.25rem" },
345
+ }}
346
+ >
347
+ {/* Dimension Label/Name */}
348
+ <Box sx={{ fontWeight: 600 }}>{spec.label ?? spec.dimensionName}</Box>
319
349
 
320
350
  {/* Match Type Selector */}
321
351
  {spec.filterType !== "Boolean" && (
@@ -325,6 +355,18 @@ export function DimensionFilter({
325
355
  value={matchType}
326
356
  label="Match Type"
327
357
  onChange={handleMatchTypeChange}
358
+ disabled={availableMatchTypes.length === 1}
359
+ MenuProps={{
360
+ PaperProps: {
361
+ sx: {
362
+ "& .MuiMenuItem-root": {
363
+ fontSize: "0.75rem",
364
+ minHeight: "auto",
365
+ padding: "4px 10px",
366
+ },
367
+ },
368
+ },
369
+ }}
328
370
  >
329
371
  {availableMatchTypes.map((type) => (
330
372
  <MenuItem key={type} value={type}>
@@ -383,6 +425,17 @@ export function DimensionFilter({
383
425
  />
384
426
  )}
385
427
  freeSolo={!spec.values || spec.values.length === 0}
428
+ slotProps={{
429
+ paper: {
430
+ sx: {
431
+ "& .MuiAutocomplete-option": {
432
+ fontSize: "0.75rem",
433
+ minHeight: "auto",
434
+ padding: "4px 10px",
435
+ },
436
+ },
437
+ },
438
+ }}
386
439
  />
387
440
  )}
388
441
 
@@ -411,6 +464,17 @@ export function DimensionFilter({
411
464
  else if (val === "false") handleValueChange(false);
412
465
  else handleClear();
413
466
  }}
467
+ MenuProps={{
468
+ PaperProps: {
469
+ sx: {
470
+ "& .MuiMenuItem-root": {
471
+ fontSize: "0.75rem",
472
+ minHeight: "auto",
473
+ padding: "4px 10px",
474
+ },
475
+ },
476
+ },
477
+ }}
414
478
  >
415
479
  <MenuItem value="">
416
480
  <em>Blank</em>
@@ -508,6 +572,17 @@ export function DimensionFilter({
508
572
  )}
509
573
  freeSolo
510
574
  filterOptions={(x) => x}
575
+ slotProps={{
576
+ paper: {
577
+ sx: {
578
+ "& .MuiAutocomplete-option": {
579
+ fontSize: "0.75rem",
580
+ minHeight: "auto",
581
+ padding: "4px 10px",
582
+ },
583
+ },
584
+ },
585
+ }}
511
586
  />
512
587
  )}
513
588
 
@@ -109,6 +109,19 @@ export function parseDimensionFilterAnnotation(
109
109
  return null;
110
110
  }
111
111
 
112
+ /**
113
+ * Parse # label="..." annotation from a dimension annotation string
114
+ * Returns the label value or null if not found
115
+ */
116
+ export function parseLabelAnnotation(annotation: string): string | null {
117
+ // Match # label="..." pattern (with optional spaces around the equals sign)
118
+ const match = annotation.match(/^#\s*label\s*=\s*"([^"]+)"/);
119
+ if (match) {
120
+ return match[1];
121
+ }
122
+ return null;
123
+ }
124
+
112
125
  /**
113
126
  * Parse all source infos from notebook cells and create a map of source_name -> SourceInfo
114
127
  * Also returns the model path from the first import statement found
@@ -206,20 +219,27 @@ export function extractDimensionSpecs(
206
219
  continue;
207
220
  }
208
221
 
209
- // Check for #(filter) annotation
222
+ // Check for #(filter) annotation and # label="..." annotation
210
223
  let filterType: FilterType = "Star"; // Default
224
+ let label: string | undefined = undefined;
211
225
 
212
226
  // Check annotations on the field (dimension/measure fields have annotations)
213
227
  if ("annotations" in field && field.annotations) {
214
228
  for (const annotation of field.annotations) {
215
229
  // Annotation type has a 'value' property
216
230
  if (annotation.value) {
231
+ // Check for filter type annotation
217
232
  const filterAnn = parseDimensionFilterAnnotation(
218
233
  annotation.value,
219
234
  );
220
235
  if (filterAnn) {
221
236
  filterType = filterAnn.type;
222
- break;
237
+ }
238
+
239
+ // Check for label annotation
240
+ const labelValue = parseLabelAnnotation(annotation.value);
241
+ if (labelValue) {
242
+ label = labelValue;
223
243
  }
224
244
  }
225
245
  }
@@ -230,6 +250,7 @@ export function extractDimensionSpecs(
230
250
  model: modelPath,
231
251
  dimensionName: dimension,
232
252
  filterType,
253
+ label,
233
254
  });
234
255
  }
235
256
 
@@ -55,7 +55,8 @@ export const CleanNotebookCell = styled("div")({
55
55
 
56
56
  export const CleanMetricCard = styled("div")({
57
57
  backgroundColor: "#ffffff",
58
- padding: "24px",
58
+ paddingTop: "12px",
59
+ paddingBottom: "2px",
59
60
  borderRadius: "8px",
60
61
  border: "1px solid #f0f0f0",
61
62
  boxShadow: "0 1px 3px rgba(0, 0, 0, 0.04)",
@@ -26,6 +26,8 @@ export interface DimensionSpec {
26
26
  source: string;
27
27
  /** Model path */
28
28
  model: string;
29
+ /** Label to display in the UI (derived from # label="..." annotation) */
30
+ label?: string;
29
31
  /** Minimum similarity score for Retrieval filter type (default: 0.1) */
30
32
  minSimilarityScore?: number;
31
33
  /** Optional list of static values to use for the dropdown instead of querying */