datajunction-ui 0.0.23-rc.0 → 0.0.26

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.
Files changed (25) hide show
  1. package/package.json +8 -2
  2. package/src/app/index.tsx +6 -0
  3. package/src/app/pages/NamespacePage/CompactSelect.jsx +100 -0
  4. package/src/app/pages/NamespacePage/NodeModeSelect.jsx +8 -5
  5. package/src/app/pages/NamespacePage/__tests__/CompactSelect.test.jsx +190 -0
  6. package/src/app/pages/NamespacePage/__tests__/index.test.jsx +297 -8
  7. package/src/app/pages/NamespacePage/index.jsx +489 -62
  8. package/src/app/pages/QueryPlannerPage/Loadable.jsx +6 -0
  9. package/src/app/pages/QueryPlannerPage/MetricFlowGraph.jsx +311 -0
  10. package/src/app/pages/QueryPlannerPage/PreAggDetailsPanel.jsx +470 -0
  11. package/src/app/pages/QueryPlannerPage/SelectionPanel.jsx +384 -0
  12. package/src/app/pages/QueryPlannerPage/__tests__/MetricFlowGraph.test.jsx +239 -0
  13. package/src/app/pages/QueryPlannerPage/__tests__/PreAggDetailsPanel.test.jsx +638 -0
  14. package/src/app/pages/QueryPlannerPage/__tests__/SelectionPanel.test.jsx +429 -0
  15. package/src/app/pages/QueryPlannerPage/__tests__/index.test.jsx +317 -0
  16. package/src/app/pages/QueryPlannerPage/index.jsx +209 -0
  17. package/src/app/pages/QueryPlannerPage/styles.css +1251 -0
  18. package/src/app/pages/Root/index.tsx +5 -0
  19. package/src/app/services/DJService.js +61 -2
  20. package/src/styles/index.css +2 -2
  21. package/src/app/icons/FilterIcon.jsx +0 -7
  22. package/src/app/pages/NamespacePage/FieldControl.jsx +0 -21
  23. package/src/app/pages/NamespacePage/NodeTypeSelect.jsx +0 -30
  24. package/src/app/pages/NamespacePage/TagSelect.jsx +0 -44
  25. package/src/app/pages/NamespacePage/UserSelect.jsx +0 -47
@@ -0,0 +1,384 @@
1
+ import { useState, useMemo, useEffect, useRef } from 'react';
2
+
3
+ /**
4
+ * SelectionPanel - Browse and select metrics and dimensions
5
+ */
6
+ export function SelectionPanel({
7
+ metrics,
8
+ selectedMetrics,
9
+ onMetricsChange,
10
+ dimensions,
11
+ selectedDimensions,
12
+ onDimensionsChange,
13
+ loading,
14
+ }) {
15
+ const [metricsSearch, setMetricsSearch] = useState('');
16
+ const [dimensionsSearch, setDimensionsSearch] = useState('');
17
+ const [expandedNamespaces, setExpandedNamespaces] = useState(new Set());
18
+ const prevSearchRef = useRef('');
19
+
20
+ // Get short name from full metric name
21
+ const getShortName = fullName => {
22
+ const parts = fullName.split('.');
23
+ return parts[parts.length - 1];
24
+ };
25
+
26
+ // Get namespace from full metric name
27
+ const getNamespace = fullName => {
28
+ const parts = fullName.split('.');
29
+ return parts.length > 1 ? parts.slice(0, -1).join('.') : 'default';
30
+ };
31
+
32
+ // Group metrics by namespace (e.g., "default.cube" -> "default")
33
+ const groupedMetrics = useMemo(() => {
34
+ const groups = {};
35
+ metrics.forEach(metric => {
36
+ const namespace = getNamespace(metric);
37
+ if (!groups[namespace]) {
38
+ groups[namespace] = [];
39
+ }
40
+ groups[namespace].push(metric);
41
+ });
42
+ return groups;
43
+ }, [metrics]);
44
+
45
+ // Filter and sort namespaces/metrics by search relevance
46
+ // Namespaces matching the search term appear first, then sorted by metric matches
47
+ const { filteredGroups, sortedNamespaces } = useMemo(() => {
48
+ const search = metricsSearch.trim().toLowerCase();
49
+
50
+ if (!search) {
51
+ // No search - return original groups, sorted alphabetically
52
+ const namespaces = Object.keys(groupedMetrics).sort();
53
+ return { filteredGroups: groupedMetrics, sortedNamespaces: namespaces };
54
+ }
55
+
56
+ // Filter to groups that have matching metrics
57
+ const filtered = {};
58
+ Object.entries(groupedMetrics).forEach(([namespace, items]) => {
59
+ const matchingItems = items.filter(m => m.toLowerCase().includes(search));
60
+ if (matchingItems.length > 0) {
61
+ // Sort metrics within namespace: prefix matches first
62
+ matchingItems.sort((a, b) => {
63
+ const aShort = getShortName(a).toLowerCase();
64
+ const bShort = getShortName(b).toLowerCase();
65
+
66
+ const aPrefix = aShort.startsWith(search);
67
+ const bPrefix = bShort.startsWith(search);
68
+ if (aPrefix && !bPrefix) return -1;
69
+ if (!aPrefix && bPrefix) return 1;
70
+
71
+ return aShort.localeCompare(bShort);
72
+ });
73
+ filtered[namespace] = matchingItems;
74
+ }
75
+ });
76
+
77
+ // Sort namespaces by relevance
78
+ const namespaces = Object.keys(filtered).sort((a, b) => {
79
+ const aLower = a.toLowerCase();
80
+ const bLower = b.toLowerCase();
81
+
82
+ // Priority 1: Namespace starts with search term
83
+ const aPrefix = aLower.startsWith(search);
84
+ const bPrefix = bLower.startsWith(search);
85
+ if (aPrefix && !bPrefix) return -1;
86
+ if (!aPrefix && bPrefix) return 1;
87
+
88
+ // Priority 2: Namespace contains search term
89
+ const aContains = aLower.includes(search);
90
+ const bContains = bLower.includes(search);
91
+ if (aContains && !bContains) return -1;
92
+ if (!aContains && bContains) return 1;
93
+
94
+ // Priority 3: Has more matching metrics
95
+ const aCount = filtered[a].length;
96
+ const bCount = filtered[b].length;
97
+ if (aCount !== bCount) return bCount - aCount;
98
+
99
+ // Priority 4: Alphabetical
100
+ return aLower.localeCompare(bLower);
101
+ });
102
+
103
+ return { filteredGroups: filtered, sortedNamespaces: namespaces };
104
+ }, [groupedMetrics, metricsSearch]);
105
+
106
+ // Auto-expand all matching namespaces when search changes
107
+ useEffect(() => {
108
+ const currentSearch = metricsSearch.trim();
109
+ const prevSearch = prevSearchRef.current;
110
+
111
+ // Only auto-expand when starting a new search or search term changes
112
+ if (currentSearch && currentSearch !== prevSearch) {
113
+ setExpandedNamespaces(new Set(sortedNamespaces));
114
+ }
115
+
116
+ prevSearchRef.current = currentSearch;
117
+ }, [metricsSearch, sortedNamespaces]);
118
+
119
+ // Dedupe dimensions by name, keeping shortest path for each
120
+ const dedupedDimensions = useMemo(() => {
121
+ const byName = new Map();
122
+ dimensions.forEach(d => {
123
+ if (!d.name) return;
124
+ const existing = byName.get(d.name);
125
+ if (
126
+ !existing ||
127
+ (d.path?.length || 0) < (existing.path?.length || Infinity)
128
+ ) {
129
+ byName.set(d.name, d);
130
+ }
131
+ });
132
+ return Array.from(byName.values());
133
+ }, [dimensions]);
134
+
135
+ // Filter and sort dimensions by search (prefix matches first)
136
+ const filteredDimensions = useMemo(() => {
137
+ const search = dimensionsSearch.trim().toLowerCase();
138
+ if (!search) return dedupedDimensions;
139
+
140
+ // Search in both full name and short display name
141
+ const matches = dedupedDimensions.filter(d => {
142
+ if (!d.name) return false;
143
+ const fullName = d.name.toLowerCase();
144
+ const parts = d.name.split('.');
145
+ const shortDisplay = parts.slice(-2).join('.').toLowerCase();
146
+ return fullName.includes(search) || shortDisplay.includes(search);
147
+ });
148
+
149
+ // Sort: prefix matches on short name first
150
+ matches.sort((a, b) => {
151
+ const aParts = (a.name || '').split('.');
152
+ const bParts = (b.name || '').split('.');
153
+ const aShort = aParts.slice(-2).join('.').toLowerCase();
154
+ const bShort = bParts.slice(-2).join('.').toLowerCase();
155
+
156
+ const aPrefix = aShort.startsWith(search);
157
+ const bPrefix = bShort.startsWith(search);
158
+ if (aPrefix && !bPrefix) return -1;
159
+ if (!aPrefix && bPrefix) return 1;
160
+
161
+ return aShort.localeCompare(bShort);
162
+ });
163
+
164
+ return matches;
165
+ }, [dedupedDimensions, dimensionsSearch]);
166
+
167
+ // Get display name for dimension (last 2 segments: dim_node.column)
168
+ const getDimDisplayName = fullName => {
169
+ const parts = (fullName || '').split('.');
170
+ return parts.slice(-2).join('.');
171
+ };
172
+
173
+ const toggleNamespace = namespace => {
174
+ setExpandedNamespaces(prev => {
175
+ const next = new Set(prev);
176
+ if (next.has(namespace)) {
177
+ next.delete(namespace);
178
+ } else {
179
+ next.add(namespace);
180
+ }
181
+ return next;
182
+ });
183
+ };
184
+
185
+ const toggleMetric = metric => {
186
+ if (selectedMetrics.includes(metric)) {
187
+ onMetricsChange(selectedMetrics.filter(m => m !== metric));
188
+ } else {
189
+ onMetricsChange([...selectedMetrics, metric]);
190
+ }
191
+ };
192
+
193
+ const toggleDimension = dimName => {
194
+ if (selectedDimensions.includes(dimName)) {
195
+ onDimensionsChange(selectedDimensions.filter(d => d !== dimName));
196
+ } else {
197
+ onDimensionsChange([...selectedDimensions, dimName]);
198
+ }
199
+ };
200
+
201
+ const selectAllInNamespace = (namespace, items) => {
202
+ const newSelection = [...new Set([...selectedMetrics, ...items])];
203
+ onMetricsChange(newSelection);
204
+ };
205
+
206
+ const deselectAllInNamespace = (namespace, items) => {
207
+ onMetricsChange(selectedMetrics.filter(m => !items.includes(m)));
208
+ };
209
+
210
+ return (
211
+ <div className="selection-panel">
212
+ {/* Metrics Section */}
213
+ <div className="selection-section">
214
+ <div className="section-header">
215
+ <h3>Metrics</h3>
216
+ <span className="selection-count">
217
+ {selectedMetrics.length} selected
218
+ </span>
219
+ </div>
220
+
221
+ <div className="search-box">
222
+ <input
223
+ type="text"
224
+ placeholder="Search metrics..."
225
+ value={metricsSearch}
226
+ onChange={e => setMetricsSearch(e.target.value)}
227
+ />
228
+ {metricsSearch && (
229
+ <button
230
+ className="clear-search"
231
+ onClick={() => setMetricsSearch('')}
232
+ >
233
+ ×
234
+ </button>
235
+ )}
236
+ </div>
237
+
238
+ <div className="selection-list">
239
+ {sortedNamespaces.map(namespace => {
240
+ const items = filteredGroups[namespace];
241
+ const isExpanded = expandedNamespaces.has(namespace);
242
+ const selectedInNamespace = items.filter(m =>
243
+ selectedMetrics.includes(m),
244
+ ).length;
245
+
246
+ return (
247
+ <div key={namespace} className="namespace-group">
248
+ <div
249
+ className="namespace-header"
250
+ onClick={() => toggleNamespace(namespace)}
251
+ >
252
+ <span className="expand-icon">{isExpanded ? '▼' : '▶'}</span>
253
+ <span className="namespace-name">{namespace}</span>
254
+ <span className="namespace-count">
255
+ {selectedInNamespace > 0 && (
256
+ <span className="selected-badge">
257
+ {selectedInNamespace}
258
+ </span>
259
+ )}
260
+ {items.length}
261
+ </span>
262
+ </div>
263
+
264
+ {isExpanded && (
265
+ <div className="namespace-items">
266
+ <div className="namespace-actions">
267
+ <button
268
+ type="button"
269
+ className="select-all-btn"
270
+ onClick={() => selectAllInNamespace(namespace, items)}
271
+ >
272
+ Select all
273
+ </button>
274
+ <button
275
+ type="button"
276
+ className="select-all-btn"
277
+ onClick={() => deselectAllInNamespace(namespace, items)}
278
+ >
279
+ Clear
280
+ </button>
281
+ </div>
282
+ {items.map(metric => (
283
+ <label key={metric} className="selection-item">
284
+ <input
285
+ type="checkbox"
286
+ checked={selectedMetrics.includes(metric)}
287
+ onChange={() => toggleMetric(metric)}
288
+ />
289
+ <span className="item-name">
290
+ {getShortName(metric)}
291
+ </span>
292
+ </label>
293
+ ))}
294
+ </div>
295
+ )}
296
+ </div>
297
+ );
298
+ })}
299
+
300
+ {sortedNamespaces.length === 0 && (
301
+ <div className="empty-list">
302
+ {metricsSearch
303
+ ? 'No metrics match your search'
304
+ : 'No metrics available'}
305
+ </div>
306
+ )}
307
+ </div>
308
+ </div>
309
+
310
+ {/* Divider */}
311
+ <div className="section-divider" />
312
+
313
+ {/* Dimensions Section */}
314
+ <div className="selection-section">
315
+ <div className="section-header">
316
+ <h3>Dimensions</h3>
317
+ <span className="selection-count">
318
+ {selectedDimensions.length} selected
319
+ {dimensions.length > 0 && ` / ${dimensions.length} available`}
320
+ </span>
321
+ </div>
322
+
323
+ {selectedMetrics.length === 0 ? (
324
+ <div className="empty-list hint">
325
+ Select metrics to see available dimensions
326
+ </div>
327
+ ) : loading ? (
328
+ <div className="empty-list">Loading dimensions...</div>
329
+ ) : (
330
+ <>
331
+ <div className="search-box">
332
+ <input
333
+ type="text"
334
+ placeholder="Search dimensions..."
335
+ value={dimensionsSearch}
336
+ onChange={e => setDimensionsSearch(e.target.value)}
337
+ />
338
+ {dimensionsSearch && (
339
+ <button
340
+ className="clear-search"
341
+ onClick={() => setDimensionsSearch('')}
342
+ >
343
+ ×
344
+ </button>
345
+ )}
346
+ </div>
347
+
348
+ <div className="selection-list dimensions-list">
349
+ {filteredDimensions.map(dim => (
350
+ <label key={dim.name} className="selection-item dimension-item">
351
+ <input
352
+ type="checkbox"
353
+ checked={selectedDimensions.includes(dim.name)}
354
+ onChange={() => toggleDimension(dim.name)}
355
+ />
356
+ <div className="dimension-info">
357
+ <span className="item-name">
358
+ {getDimDisplayName(dim.name)}
359
+ </span>
360
+ {dim.path && dim.path.length > 1 && (
361
+ <span className="dimension-path">
362
+ {dim.path.slice(1).join(' ▶ ')}
363
+ </span>
364
+ )}
365
+ </div>
366
+ </label>
367
+ ))}
368
+
369
+ {filteredDimensions.length === 0 && (
370
+ <div className="empty-list">
371
+ {dimensionsSearch
372
+ ? 'No dimensions match your search'
373
+ : 'No shared dimensions'}
374
+ </div>
375
+ )}
376
+ </div>
377
+ </>
378
+ )}
379
+ </div>
380
+ </div>
381
+ );
382
+ }
383
+
384
+ export default SelectionPanel;
@@ -0,0 +1,239 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import React from 'react';
3
+
4
+ // Mock the entire MetricFlowGraph module since dagre is difficult to mock
5
+ jest.mock('../MetricFlowGraph', () => ({
6
+ MetricFlowGraph: ({
7
+ grainGroups,
8
+ metricFormulas,
9
+ selectedNode,
10
+ onNodeSelect,
11
+ }) => {
12
+ if (!grainGroups?.length || !metricFormulas?.length) {
13
+ return (
14
+ <div data-testid="graph-empty">
15
+ Select metrics and dimensions above to visualize the data flow
16
+ </div>
17
+ );
18
+ }
19
+ return (
20
+ <div data-testid="metric-flow-graph">
21
+ <div data-testid="nodes-count">
22
+ {grainGroups.length + metricFormulas.length}
23
+ </div>
24
+ {grainGroups.map((gg, i) => (
25
+ <div
26
+ key={`preagg-${i}`}
27
+ data-testid={`preagg-node-${i}`}
28
+ onClick={() =>
29
+ onNodeSelect?.({ type: 'preagg', index: i, data: gg })
30
+ }
31
+ >
32
+ {gg.parent_name?.split('.').pop()}
33
+ </div>
34
+ ))}
35
+ {metricFormulas.map((m, i) => (
36
+ <div
37
+ key={`metric-${i}`}
38
+ data-testid={`metric-node-${i}`}
39
+ onClick={() =>
40
+ onNodeSelect?.({ type: 'metric', index: i, data: m })
41
+ }
42
+ >
43
+ {m.short_name}
44
+ </div>
45
+ ))}
46
+ <div data-testid="legend">
47
+ <span>Pre-agg</span>
48
+ <span>Metric</span>
49
+ <span>Derived</span>
50
+ </div>
51
+ </div>
52
+ );
53
+ },
54
+ }));
55
+
56
+ // Import after mock
57
+ const { MetricFlowGraph } = require('../MetricFlowGraph');
58
+
59
+ const mockGrainGroups = [
60
+ {
61
+ parent_name: 'default.repair_orders',
62
+ aggregability: 'FULL',
63
+ grain: ['date_id', 'customer_id'],
64
+ components: [
65
+ { name: 'sum_revenue', expression: 'SUM(revenue)' },
66
+ { name: 'count_orders', expression: 'COUNT(*)' },
67
+ ],
68
+ },
69
+ {
70
+ parent_name: 'inventory.stock',
71
+ aggregability: 'LIMITED',
72
+ grain: ['warehouse_id'],
73
+ components: [{ name: 'sum_quantity', expression: 'SUM(quantity)' }],
74
+ },
75
+ ];
76
+
77
+ const mockMetricFormulas = [
78
+ {
79
+ name: 'default.total_revenue',
80
+ short_name: 'total_revenue',
81
+ combiner: 'SUM(sum_revenue)',
82
+ is_derived: false,
83
+ components: ['sum_revenue'],
84
+ },
85
+ {
86
+ name: 'default.order_count',
87
+ short_name: 'order_count',
88
+ combiner: 'SUM(count_orders)',
89
+ is_derived: false,
90
+ components: ['count_orders'],
91
+ },
92
+ {
93
+ name: 'default.avg_order_value',
94
+ short_name: 'avg_order_value',
95
+ combiner: 'SUM(sum_revenue) / SUM(count_orders)',
96
+ is_derived: true,
97
+ components: ['sum_revenue', 'count_orders'],
98
+ },
99
+ ];
100
+
101
+ describe('MetricFlowGraph', () => {
102
+ const defaultProps = {
103
+ grainGroups: mockGrainGroups,
104
+ metricFormulas: mockMetricFormulas,
105
+ selectedNode: null,
106
+ onNodeSelect: jest.fn(),
107
+ };
108
+
109
+ beforeEach(() => {
110
+ jest.clearAllMocks();
111
+ });
112
+
113
+ describe('Empty State', () => {
114
+ it('shows empty state when no grain groups', () => {
115
+ render(<MetricFlowGraph {...defaultProps} grainGroups={[]} />);
116
+ expect(screen.getByTestId('graph-empty')).toBeInTheDocument();
117
+ expect(
118
+ screen.getByText(
119
+ 'Select metrics and dimensions above to visualize the data flow',
120
+ ),
121
+ ).toBeInTheDocument();
122
+ });
123
+
124
+ it('shows empty state when no metric formulas', () => {
125
+ render(<MetricFlowGraph {...defaultProps} metricFormulas={[]} />);
126
+ expect(screen.getByTestId('graph-empty')).toBeInTheDocument();
127
+ });
128
+
129
+ it('shows empty state when both are null', () => {
130
+ render(
131
+ <MetricFlowGraph
132
+ {...defaultProps}
133
+ grainGroups={null}
134
+ metricFormulas={null}
135
+ />,
136
+ );
137
+ expect(screen.getByTestId('graph-empty')).toBeInTheDocument();
138
+ });
139
+ });
140
+
141
+ describe('Graph Rendering', () => {
142
+ it('renders graph container when data is provided', () => {
143
+ render(<MetricFlowGraph {...defaultProps} />);
144
+ expect(screen.getByTestId('metric-flow-graph')).toBeInTheDocument();
145
+ });
146
+
147
+ it('renders correct number of nodes', () => {
148
+ render(<MetricFlowGraph {...defaultProps} />);
149
+ // 2 pre-agg nodes + 3 metric nodes = 5 total
150
+ expect(screen.getByTestId('nodes-count')).toHaveTextContent('5');
151
+ });
152
+
153
+ it('displays pre-aggregation short names', () => {
154
+ render(<MetricFlowGraph {...defaultProps} />);
155
+ expect(screen.getByText('repair_orders')).toBeInTheDocument();
156
+ expect(screen.getByText('stock')).toBeInTheDocument();
157
+ });
158
+
159
+ it('displays metric short names', () => {
160
+ render(<MetricFlowGraph {...defaultProps} />);
161
+ expect(screen.getByText('total_revenue')).toBeInTheDocument();
162
+ expect(screen.getByText('order_count')).toBeInTheDocument();
163
+ expect(screen.getByText('avg_order_value')).toBeInTheDocument();
164
+ });
165
+ });
166
+
167
+ describe('Node Selection', () => {
168
+ it('calls onNodeSelect when preagg node is clicked', () => {
169
+ const onNodeSelect = jest.fn();
170
+ render(<MetricFlowGraph {...defaultProps} onNodeSelect={onNodeSelect} />);
171
+
172
+ const preaggNode = screen.getByTestId('preagg-node-0');
173
+ preaggNode.click();
174
+
175
+ expect(onNodeSelect).toHaveBeenCalledWith(
176
+ expect.objectContaining({
177
+ type: 'preagg',
178
+ index: 0,
179
+ }),
180
+ );
181
+ });
182
+
183
+ it('calls onNodeSelect when metric node is clicked', () => {
184
+ const onNodeSelect = jest.fn();
185
+ render(<MetricFlowGraph {...defaultProps} onNodeSelect={onNodeSelect} />);
186
+
187
+ const metricNode = screen.getByTestId('metric-node-0');
188
+ metricNode.click();
189
+
190
+ expect(onNodeSelect).toHaveBeenCalledWith(
191
+ expect.objectContaining({
192
+ type: 'metric',
193
+ index: 0,
194
+ }),
195
+ );
196
+ });
197
+
198
+ it('passes grain data when preagg is selected', () => {
199
+ const onNodeSelect = jest.fn();
200
+ render(<MetricFlowGraph {...defaultProps} onNodeSelect={onNodeSelect} />);
201
+
202
+ const preaggNode = screen.getByTestId('preagg-node-0');
203
+ preaggNode.click();
204
+
205
+ expect(onNodeSelect).toHaveBeenCalledWith(
206
+ expect.objectContaining({
207
+ data: expect.objectContaining({
208
+ grain: ['date_id', 'customer_id'],
209
+ }),
210
+ }),
211
+ );
212
+ });
213
+
214
+ it('passes combiner data when metric is selected', () => {
215
+ const onNodeSelect = jest.fn();
216
+ render(<MetricFlowGraph {...defaultProps} onNodeSelect={onNodeSelect} />);
217
+
218
+ const metricNode = screen.getByTestId('metric-node-0');
219
+ metricNode.click();
220
+
221
+ expect(onNodeSelect).toHaveBeenCalledWith(
222
+ expect.objectContaining({
223
+ data: expect.objectContaining({
224
+ combiner: 'SUM(sum_revenue)',
225
+ }),
226
+ }),
227
+ );
228
+ });
229
+ });
230
+
231
+ describe('Legend', () => {
232
+ it('renders graph legend', () => {
233
+ render(<MetricFlowGraph {...defaultProps} />);
234
+ expect(screen.getByText('Pre-agg')).toBeInTheDocument();
235
+ expect(screen.getByText('Metric')).toBeInTheDocument();
236
+ expect(screen.getByText('Derived')).toBeInTheDocument();
237
+ });
238
+ });
239
+ });