@principal-ai/principal-view-react 0.7.34 → 0.7.35
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/dist/index.d.ts +0 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +0 -1
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/components/PendingChanges.test.tsx +3 -3
- package/src/index.ts +0 -3
- package/src/stories/NodeFieldsAudit.stories.tsx +0 -186
- package/src/components/NarrativeRenderer.tsx +0 -171
- package/src/components/TestEventPanel.tsx +0 -588
- package/src/utils/narrative-converter.ts +0 -157
- package/src/utils/narrative-loader.ts +0 -172
package/dist/index.d.ts
CHANGED
|
@@ -14,8 +14,6 @@ export { NodeInfoPanel } from './components/NodeInfoPanel';
|
|
|
14
14
|
export type { NodeInfoPanelProps } from './components/NodeInfoPanel';
|
|
15
15
|
export { ConfigurationSelector } from './components/ConfigurationSelector';
|
|
16
16
|
export type { ConfigurationSelectorProps } from './components/ConfigurationSelector';
|
|
17
|
-
export { TestEventPanel } from './components/TestEventPanel';
|
|
18
|
-
export type { TestEventPanelProps } from './components/TestEventPanel';
|
|
19
17
|
export { GenericNode } from './nodes/GenericNode';
|
|
20
18
|
export type { GenericNodeProps } from './nodes/GenericNode';
|
|
21
19
|
export { CustomNode } from './nodes/CustomNode';
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAGH,YAAY,EACV,kBAAkB,EAClB,UAAU,EACV,YAAY,EACZ,SAAS,EACT,OAAO,EACP,gBAAgB,EAChB,WAAW,EACX,kBAAkB,EAClB,kBAAkB,EAClB,cAAc,EACd,SAAS,EACT,SAAS,EACT,iBAAiB,EACjB,uBAAuB,EAEvB,gBAAgB,EAChB,oBAAoB,EACpB,oBAAoB,GACrB,MAAM,2CAA2C,CAAC;AAGnD,OAAO,EAAE,aAAa,EAAE,MAAM,4BAA4B,CAAC;AAC3D,YAAY,EACV,kBAAkB,EAClB,mBAAmB,EACnB,kBAAkB,EAClB,cAAc,GACf,MAAM,4BAA4B,CAAC;AAEpC,OAAO,EAAE,aAAa,EAAE,MAAM,4BAA4B,CAAC;AAC3D,YAAY,EAAE,kBAAkB,EAAE,MAAM,4BAA4B,CAAC;AAErE,OAAO,EAAE,aAAa,EAAE,MAAM,4BAA4B,CAAC;AAC3D,YAAY,EAAE,kBAAkB,EAAE,MAAM,4BAA4B,CAAC;AAErE,OAAO,EAAE,qBAAqB,EAAE,MAAM,oCAAoC,CAAC;AAC3E,YAAY,EAAE,0BAA0B,EAAE,MAAM,oCAAoC,CAAC;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAGH,YAAY,EACV,kBAAkB,EAClB,UAAU,EACV,YAAY,EACZ,SAAS,EACT,OAAO,EACP,gBAAgB,EAChB,WAAW,EACX,kBAAkB,EAClB,kBAAkB,EAClB,cAAc,EACd,SAAS,EACT,SAAS,EACT,iBAAiB,EACjB,uBAAuB,EAEvB,gBAAgB,EAChB,oBAAoB,EACpB,oBAAoB,GACrB,MAAM,2CAA2C,CAAC;AAGnD,OAAO,EAAE,aAAa,EAAE,MAAM,4BAA4B,CAAC;AAC3D,YAAY,EACV,kBAAkB,EAClB,mBAAmB,EACnB,kBAAkB,EAClB,cAAc,GACf,MAAM,4BAA4B,CAAC;AAEpC,OAAO,EAAE,aAAa,EAAE,MAAM,4BAA4B,CAAC;AAC3D,YAAY,EAAE,kBAAkB,EAAE,MAAM,4BAA4B,CAAC;AAErE,OAAO,EAAE,aAAa,EAAE,MAAM,4BAA4B,CAAC;AAC3D,YAAY,EAAE,kBAAkB,EAAE,MAAM,4BAA4B,CAAC;AAErE,OAAO,EAAE,qBAAqB,EAAE,MAAM,oCAAoC,CAAC;AAC3E,YAAY,EAAE,0BAA0B,EAAE,MAAM,oCAAoC,CAAC;AAGrF,OAAO,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AAClD,YAAY,EAAE,gBAAgB,EAAE,MAAM,qBAAqB,CAAC;AAE5D,OAAO,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAC;AAChD,YAAY,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAC;AAEzD,OAAO,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AAClD,YAAY,EAAE,gBAAgB,EAAE,MAAM,qBAAqB,CAAC;AAE5D,OAAO,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAC;AAChD,YAAY,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAC;AAGzD,OAAO,EAAE,WAAW,EAAE,MAAM,0BAA0B,CAAC;AACvD,YAAY,EAAE,gBAAgB,EAAE,QAAQ,EAAE,MAAM,0BAA0B,CAAC;AAG3E,OAAO,EACL,oBAAoB,EACpB,oBAAoB,GACrB,MAAM,wBAAwB,CAAC;AAChC,YAAY,EAAE,oBAAoB,EAAE,MAAM,wBAAwB,CAAC;AACnE,OAAO,EAAE,IAAI,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAC;AACzD,YAAY,EAAE,SAAS,EAAE,MAAM,sBAAsB,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -10,7 +10,6 @@ export { GraphRenderer } from './components/GraphRenderer';
|
|
|
10
10
|
export { EdgeInfoPanel } from './components/EdgeInfoPanel';
|
|
11
11
|
export { NodeInfoPanel } from './components/NodeInfoPanel';
|
|
12
12
|
export { ConfigurationSelector } from './components/ConfigurationSelector';
|
|
13
|
-
export { TestEventPanel } from './components/TestEventPanel';
|
|
14
13
|
// Export node/edge renderers
|
|
15
14
|
export { GenericNode } from './nodes/GenericNode';
|
|
16
15
|
export { CustomNode } from './nodes/CustomNode';
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAwBH,oBAAoB;AACpB,OAAO,EAAE,aAAa,EAAE,MAAM,4BAA4B,CAAC;AAQ3D,OAAO,EAAE,aAAa,EAAE,MAAM,4BAA4B,CAAC;AAG3D,OAAO,EAAE,aAAa,EAAE,MAAM,4BAA4B,CAAC;AAG3D,OAAO,EAAE,qBAAqB,EAAE,MAAM,oCAAoC,CAAC;AAG3E,
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAwBH,oBAAoB;AACpB,OAAO,EAAE,aAAa,EAAE,MAAM,4BAA4B,CAAC;AAQ3D,OAAO,EAAE,aAAa,EAAE,MAAM,4BAA4B,CAAC;AAG3D,OAAO,EAAE,aAAa,EAAE,MAAM,4BAA4B,CAAC;AAG3D,OAAO,EAAE,qBAAqB,EAAE,MAAM,oCAAoC,CAAC;AAG3E,6BAA6B;AAC7B,OAAO,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AAGlD,OAAO,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAC;AAGhD,OAAO,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AAGlD,OAAO,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAC;AAGhD,2BAA2B;AAC3B,OAAO,EAAE,WAAW,EAAE,MAAM,0BAA0B,CAAC;AAGvD,mBAAmB;AACnB,OAAO,EACL,oBAAoB,EACpB,oBAAoB,GACrB,MAAM,wBAAwB,CAAC;AAEhC,OAAO,EAAE,IAAI,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@principal-ai/principal-view-react",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.35",
|
|
4
4
|
"description": "React components for graph-based principal view framework",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"dependencies": {
|
|
17
17
|
"@principal-ade/industry-theme": "0.1.7",
|
|
18
18
|
"@principal-ai/codebase-composition": "^0.2.41",
|
|
19
|
-
"@principal-ai/principal-view-core": "
|
|
19
|
+
"@principal-ai/principal-view-core": "0.10.4",
|
|
20
20
|
"@xyflow/react": "12.0.0",
|
|
21
21
|
"elkjs": "0.9.0",
|
|
22
22
|
"js-yaml": "4.1.1",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { describe, expect, test,
|
|
1
|
+
import { describe, expect, test, mock } from 'bun:test';
|
|
2
2
|
import React, { createRef } from 'react';
|
|
3
|
-
import { render
|
|
4
|
-
import { GraphRenderer, type GraphRendererHandle
|
|
3
|
+
import { render } from '@testing-library/react';
|
|
4
|
+
import { GraphRenderer, type GraphRendererHandle } from './GraphRenderer';
|
|
5
5
|
import type { ExtendedCanvas } from '@principal-ai/principal-view-core/browser';
|
|
6
6
|
import { ThemeProvider, defaultEditorTheme } from '@principal-ade/industry-theme';
|
|
7
7
|
import { Window } from 'happy-dom';
|
package/src/index.ts
CHANGED
|
@@ -46,9 +46,6 @@ export type { NodeInfoPanelProps } from './components/NodeInfoPanel';
|
|
|
46
46
|
export { ConfigurationSelector } from './components/ConfigurationSelector';
|
|
47
47
|
export type { ConfigurationSelectorProps } from './components/ConfigurationSelector';
|
|
48
48
|
|
|
49
|
-
export { TestEventPanel } from './components/TestEventPanel';
|
|
50
|
-
export type { TestEventPanelProps } from './components/TestEventPanel';
|
|
51
|
-
|
|
52
49
|
// Export node/edge renderers
|
|
53
50
|
export { GenericNode } from './nodes/GenericNode';
|
|
54
51
|
export type { GenericNodeProps } from './nodes/GenericNode';
|
|
@@ -23,192 +23,6 @@ const meta = {
|
|
|
23
23
|
export default meta;
|
|
24
24
|
type Story = StoryObj<typeof meta>;
|
|
25
25
|
|
|
26
|
-
/**
|
|
27
|
-
* Canvas showing a single node with ALL fields populated and labeled
|
|
28
|
-
*/
|
|
29
|
-
const allFieldsCanvas: ExtendedCanvas = {
|
|
30
|
-
nodes: [
|
|
31
|
-
{
|
|
32
|
-
id: 'all-fields-node',
|
|
33
|
-
type: 'text',
|
|
34
|
-
x: 200,
|
|
35
|
-
y: 150,
|
|
36
|
-
width: 160,
|
|
37
|
-
height: 120,
|
|
38
|
-
text: 'Display Label (from dataSchema)',
|
|
39
|
-
color: '#6366f1', // color field
|
|
40
|
-
pv: {
|
|
41
|
-
nodeType: 'fully-populated',
|
|
42
|
-
shape: 'rectangle', // shape field
|
|
43
|
-
icon: 'Server', // icon field
|
|
44
|
-
fill: '#6366f1', // pv.fill (takes priority over node.color)
|
|
45
|
-
stroke: '#4f46e5', // pv.stroke (border color)
|
|
46
|
-
dataSchema: {
|
|
47
|
-
name: { type: 'string', displayInLabel: true },
|
|
48
|
-
description: { type: 'string', displayInLabel: true },
|
|
49
|
-
status: { type: 'string', displayInLabel: false },
|
|
50
|
-
},
|
|
51
|
-
states: {
|
|
52
|
-
idle: { color: '#94a3b8', icon: 'Circle', label: 'Idle' },
|
|
53
|
-
active: { color: '#22c55e', icon: 'CheckCircle', label: 'Active' },
|
|
54
|
-
error: { color: '#ef4444', icon: 'XCircle', label: 'Error' },
|
|
55
|
-
},
|
|
56
|
-
},
|
|
57
|
-
},
|
|
58
|
-
],
|
|
59
|
-
edges: [],
|
|
60
|
-
pv: {
|
|
61
|
-
version: '1.0.0',
|
|
62
|
-
name: 'All Fields Audit',
|
|
63
|
-
edgeTypes: {},
|
|
64
|
-
},
|
|
65
|
-
};
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* Canvas showing nodes with each field labeled for reference
|
|
69
|
-
*/
|
|
70
|
-
const labeledFieldsCanvas: ExtendedCanvas = {
|
|
71
|
-
nodes: [
|
|
72
|
-
// Reference node with annotations
|
|
73
|
-
{
|
|
74
|
-
id: 'labeled-node',
|
|
75
|
-
type: 'text',
|
|
76
|
-
x: 300,
|
|
77
|
-
y: 200,
|
|
78
|
-
width: 180,
|
|
79
|
-
height: 140,
|
|
80
|
-
text: 'API Gateway',
|
|
81
|
-
color: '#3b82f6',
|
|
82
|
-
pv: {
|
|
83
|
-
nodeType: 'server',
|
|
84
|
-
shape: 'rectangle',
|
|
85
|
-
icon: 'Server',
|
|
86
|
-
fill: '#3b82f6',
|
|
87
|
-
stroke: '#2563eb',
|
|
88
|
-
dataSchema: {
|
|
89
|
-
name: { type: 'string', displayInLabel: true },
|
|
90
|
-
},
|
|
91
|
-
states: {
|
|
92
|
-
processing: { color: '#3b82f6', icon: 'Loader', label: 'Processing' },
|
|
93
|
-
},
|
|
94
|
-
},
|
|
95
|
-
},
|
|
96
|
-
// Icon label
|
|
97
|
-
{
|
|
98
|
-
id: 'icon-label',
|
|
99
|
-
type: 'text',
|
|
100
|
-
x: 100,
|
|
101
|
-
y: 140,
|
|
102
|
-
width: 120,
|
|
103
|
-
height: 30,
|
|
104
|
-
text: 'ICON',
|
|
105
|
-
color: '#f97316',
|
|
106
|
-
pv: {
|
|
107
|
-
nodeType: 'label',
|
|
108
|
-
shape: 'rectangle',
|
|
109
|
-
fill: '#f97316',
|
|
110
|
-
dataSchema: {},
|
|
111
|
-
},
|
|
112
|
-
},
|
|
113
|
-
// Label field annotation
|
|
114
|
-
{
|
|
115
|
-
id: 'label-annotation',
|
|
116
|
-
type: 'text',
|
|
117
|
-
x: 100,
|
|
118
|
-
y: 200,
|
|
119
|
-
width: 120,
|
|
120
|
-
height: 30,
|
|
121
|
-
text: 'LABEL',
|
|
122
|
-
color: '#f97316',
|
|
123
|
-
pv: {
|
|
124
|
-
nodeType: 'label',
|
|
125
|
-
shape: 'rectangle',
|
|
126
|
-
fill: '#f97316',
|
|
127
|
-
dataSchema: {},
|
|
128
|
-
},
|
|
129
|
-
},
|
|
130
|
-
// State badge annotation
|
|
131
|
-
{
|
|
132
|
-
id: 'state-annotation',
|
|
133
|
-
type: 'text',
|
|
134
|
-
x: 100,
|
|
135
|
-
y: 260,
|
|
136
|
-
width: 120,
|
|
137
|
-
height: 30,
|
|
138
|
-
text: 'STATE BADGE',
|
|
139
|
-
color: '#f97316',
|
|
140
|
-
pv: {
|
|
141
|
-
nodeType: 'label',
|
|
142
|
-
shape: 'rectangle',
|
|
143
|
-
fill: '#f97316',
|
|
144
|
-
dataSchema: {},
|
|
145
|
-
},
|
|
146
|
-
},
|
|
147
|
-
// Border/stroke annotation
|
|
148
|
-
{
|
|
149
|
-
id: 'stroke-annotation',
|
|
150
|
-
type: 'text',
|
|
151
|
-
x: 540,
|
|
152
|
-
y: 200,
|
|
153
|
-
width: 140,
|
|
154
|
-
height: 30,
|
|
155
|
-
text: 'STROKE/BORDER',
|
|
156
|
-
color: '#f97316',
|
|
157
|
-
pv: {
|
|
158
|
-
nodeType: 'label',
|
|
159
|
-
shape: 'rectangle',
|
|
160
|
-
fill: '#f97316',
|
|
161
|
-
dataSchema: {},
|
|
162
|
-
},
|
|
163
|
-
},
|
|
164
|
-
],
|
|
165
|
-
edges: [
|
|
166
|
-
{
|
|
167
|
-
id: 'icon-pointer',
|
|
168
|
-
fromNode: 'icon-label',
|
|
169
|
-
toNode: 'labeled-node',
|
|
170
|
-
fromSide: 'right',
|
|
171
|
-
toSide: 'left',
|
|
172
|
-
pv: { edgeType: 'pointer' },
|
|
173
|
-
},
|
|
174
|
-
{
|
|
175
|
-
id: 'label-pointer',
|
|
176
|
-
fromNode: 'label-annotation',
|
|
177
|
-
toNode: 'labeled-node',
|
|
178
|
-
fromSide: 'right',
|
|
179
|
-
toSide: 'left',
|
|
180
|
-
pv: { edgeType: 'pointer' },
|
|
181
|
-
},
|
|
182
|
-
{
|
|
183
|
-
id: 'state-pointer',
|
|
184
|
-
fromNode: 'state-annotation',
|
|
185
|
-
toNode: 'labeled-node',
|
|
186
|
-
fromSide: 'right',
|
|
187
|
-
toSide: 'left',
|
|
188
|
-
pv: { edgeType: 'pointer' },
|
|
189
|
-
},
|
|
190
|
-
{
|
|
191
|
-
id: 'stroke-pointer',
|
|
192
|
-
fromNode: 'stroke-annotation',
|
|
193
|
-
toNode: 'labeled-node',
|
|
194
|
-
fromSide: 'left',
|
|
195
|
-
toSide: 'right',
|
|
196
|
-
pv: { edgeType: 'pointer' },
|
|
197
|
-
},
|
|
198
|
-
],
|
|
199
|
-
pv: {
|
|
200
|
-
version: '1.0.0',
|
|
201
|
-
name: 'Labeled Fields',
|
|
202
|
-
edgeTypes: {
|
|
203
|
-
pointer: {
|
|
204
|
-
style: 'dashed',
|
|
205
|
-
color: '#f97316',
|
|
206
|
-
directed: true,
|
|
207
|
-
},
|
|
208
|
-
},
|
|
209
|
-
},
|
|
210
|
-
};
|
|
211
|
-
|
|
212
26
|
/**
|
|
213
27
|
* Canvas showing all visual variations of node fields
|
|
214
28
|
*/
|
|
@@ -1,171 +0,0 @@
|
|
|
1
|
-
import React, { useMemo } from 'react';
|
|
2
|
-
import { renderNarrative } from '@principal-ai/principal-view-core/browser';
|
|
3
|
-
import type { NarrativeTemplate, OtelEvent } from '@principal-ai/principal-view-core/browser';
|
|
4
|
-
import { useTheme } from '@principal-ade/industry-theme';
|
|
5
|
-
|
|
6
|
-
export interface NarrativeRendererProps {
|
|
7
|
-
/** Narrative template to use for rendering */
|
|
8
|
-
template: NarrativeTemplate;
|
|
9
|
-
|
|
10
|
-
/** OTEL events to render */
|
|
11
|
-
events: OtelEvent[];
|
|
12
|
-
|
|
13
|
-
/** Optional CSS class name */
|
|
14
|
-
className?: string;
|
|
15
|
-
|
|
16
|
-
/** Optional custom style */
|
|
17
|
-
style?: React.CSSProperties;
|
|
18
|
-
|
|
19
|
-
/** Show metadata panel */
|
|
20
|
-
showMetadata?: boolean;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Renders OTEL events as a human-readable narrative using a template
|
|
25
|
-
*/
|
|
26
|
-
export const NarrativeRenderer: React.FC<NarrativeRendererProps> = ({
|
|
27
|
-
template,
|
|
28
|
-
events,
|
|
29
|
-
className,
|
|
30
|
-
style,
|
|
31
|
-
showMetadata = false,
|
|
32
|
-
}) => {
|
|
33
|
-
const { theme } = useTheme();
|
|
34
|
-
|
|
35
|
-
// Render the narrative
|
|
36
|
-
const result = useMemo(() => {
|
|
37
|
-
try {
|
|
38
|
-
return renderNarrative(template, events);
|
|
39
|
-
} catch (error) {
|
|
40
|
-
return {
|
|
41
|
-
text: `Error rendering narrative: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
42
|
-
scenarioId: 'error',
|
|
43
|
-
metadata: {
|
|
44
|
-
eventCount: events.length,
|
|
45
|
-
spanCount: 0,
|
|
46
|
-
logCount: 0,
|
|
47
|
-
},
|
|
48
|
-
};
|
|
49
|
-
}
|
|
50
|
-
}, [template, events]);
|
|
51
|
-
|
|
52
|
-
// Parse narrative text to add syntax highlighting
|
|
53
|
-
const renderHighlightedText = (text: string) => {
|
|
54
|
-
const lines = text.split('\n');
|
|
55
|
-
|
|
56
|
-
return lines.map((line, idx) => {
|
|
57
|
-
// Determine line style based on content
|
|
58
|
-
let lineStyle: React.CSSProperties = {};
|
|
59
|
-
let content = line;
|
|
60
|
-
|
|
61
|
-
// Status indicators (✅ ❌ ⚠️ 📋)
|
|
62
|
-
if (/^[✅❌⚠️📋]/.test(line)) {
|
|
63
|
-
lineStyle = {
|
|
64
|
-
fontWeight: 'bold',
|
|
65
|
-
fontSize: '16px',
|
|
66
|
-
marginTop: idx > 0 ? '8px' : '0',
|
|
67
|
-
marginBottom: '4px',
|
|
68
|
-
};
|
|
69
|
-
}
|
|
70
|
-
// Separators (━━━━)
|
|
71
|
-
else if (/^━+/.test(line)) {
|
|
72
|
-
lineStyle = {
|
|
73
|
-
color: theme.colors.border,
|
|
74
|
-
opacity: 0.6,
|
|
75
|
-
};
|
|
76
|
-
}
|
|
77
|
-
// Arrow items (→)
|
|
78
|
-
else if (/^(\s*)→/.test(line)) {
|
|
79
|
-
const indent = line.match(/^(\s*)/)?.[1] || '';
|
|
80
|
-
lineStyle = {
|
|
81
|
-
color: theme.colors.text,
|
|
82
|
-
fontWeight: indent.length === 0 ? 'bold' : 'normal',
|
|
83
|
-
marginTop: indent.length === 0 ? '12px' : '4px',
|
|
84
|
-
};
|
|
85
|
-
}
|
|
86
|
-
// Bullet items (•)
|
|
87
|
-
else if (/^\s+•/.test(line)) {
|
|
88
|
-
lineStyle = {
|
|
89
|
-
color: theme.colors.textMuted,
|
|
90
|
-
paddingLeft: '8px',
|
|
91
|
-
};
|
|
92
|
-
}
|
|
93
|
-
// Section headers (UPPERCASE at start)
|
|
94
|
-
else if (/^[A-Z\s]+:/.test(line)) {
|
|
95
|
-
lineStyle = {
|
|
96
|
-
fontWeight: 'bold',
|
|
97
|
-
marginTop: '8px',
|
|
98
|
-
color: theme.colors.text,
|
|
99
|
-
};
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
return (
|
|
103
|
-
<div key={idx} style={lineStyle}>
|
|
104
|
-
{content}
|
|
105
|
-
</div>
|
|
106
|
-
);
|
|
107
|
-
});
|
|
108
|
-
};
|
|
109
|
-
|
|
110
|
-
return (
|
|
111
|
-
<div
|
|
112
|
-
className={className}
|
|
113
|
-
style={{
|
|
114
|
-
width: '100%',
|
|
115
|
-
height: '100%',
|
|
116
|
-
display: 'flex',
|
|
117
|
-
flexDirection: 'column',
|
|
118
|
-
...style,
|
|
119
|
-
}}
|
|
120
|
-
>
|
|
121
|
-
{/* Narrative Text */}
|
|
122
|
-
<div
|
|
123
|
-
style={{
|
|
124
|
-
flex: 1,
|
|
125
|
-
overflow: 'auto',
|
|
126
|
-
padding: '20px',
|
|
127
|
-
fontFamily: theme.fonts.monospace,
|
|
128
|
-
fontSize: '14px',
|
|
129
|
-
lineHeight: '1.6',
|
|
130
|
-
color: theme.colors.text,
|
|
131
|
-
backgroundColor: theme.colors.background,
|
|
132
|
-
whiteSpace: 'pre-wrap',
|
|
133
|
-
wordWrap: 'break-word',
|
|
134
|
-
}}
|
|
135
|
-
>
|
|
136
|
-
{renderHighlightedText(result.text)}
|
|
137
|
-
</div>
|
|
138
|
-
|
|
139
|
-
{/* Metadata Panel (optional) */}
|
|
140
|
-
{showMetadata && (
|
|
141
|
-
<div
|
|
142
|
-
style={{
|
|
143
|
-
borderTop: `1px solid ${theme.colors.border}`,
|
|
144
|
-
padding: '12px 20px',
|
|
145
|
-
backgroundColor: theme.colors.surface,
|
|
146
|
-
fontSize: '12px',
|
|
147
|
-
color: theme.colors.textMuted,
|
|
148
|
-
fontFamily: theme.fonts.monospace,
|
|
149
|
-
}}
|
|
150
|
-
>
|
|
151
|
-
<div style={{ marginBottom: '4px' }}>
|
|
152
|
-
<strong style={{ color: theme.colors.text }}>Template:</strong> {template.name}
|
|
153
|
-
</div>
|
|
154
|
-
<div style={{ marginBottom: '4px' }}>
|
|
155
|
-
<strong style={{ color: theme.colors.text }}>Scenario:</strong> {result.scenarioId}
|
|
156
|
-
</div>
|
|
157
|
-
<div>
|
|
158
|
-
<strong style={{ color: theme.colors.text }}>Events:</strong> {result.metadata.eventCount} total
|
|
159
|
-
({result.metadata.spanCount} spans, {result.metadata.logCount} logs)
|
|
160
|
-
</div>
|
|
161
|
-
{result.metadata.timeRange && (
|
|
162
|
-
<div style={{ marginTop: '4px' }}>
|
|
163
|
-
<strong style={{ color: theme.colors.text }}>Duration:</strong>{' '}
|
|
164
|
-
{Number(result.metadata.timeRange.end) - Number(result.metadata.timeRange.start)}ms
|
|
165
|
-
</div>
|
|
166
|
-
)}
|
|
167
|
-
</div>
|
|
168
|
-
)}
|
|
169
|
-
</div>
|
|
170
|
-
);
|
|
171
|
-
};
|
|
@@ -1,588 +0,0 @@
|
|
|
1
|
-
import React, { useState, useMemo } from 'react';
|
|
2
|
-
import { useTheme } from '@principal-ade/industry-theme';
|
|
3
|
-
import { HelpCircle } from 'lucide-react';
|
|
4
|
-
import yaml from 'js-yaml';
|
|
5
|
-
import type { NarrativeTemplate, JsonValue } from '@principal-ai/principal-view-core/browser';
|
|
6
|
-
import { NarrativeRenderer } from './NarrativeRenderer';
|
|
7
|
-
import { convertToOtelEvents } from '../utils/narrative-converter';
|
|
8
|
-
|
|
9
|
-
interface SpanEvent {
|
|
10
|
-
time: number;
|
|
11
|
-
name: string;
|
|
12
|
-
attributes: Record<string, string | number | boolean>;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
interface TestSpan {
|
|
16
|
-
id: string;
|
|
17
|
-
name: string;
|
|
18
|
-
startTime: number;
|
|
19
|
-
endTime?: number;
|
|
20
|
-
duration?: number;
|
|
21
|
-
attributes: Record<string, string | number | boolean>;
|
|
22
|
-
events: SpanEvent[];
|
|
23
|
-
status: 'OK' | 'ERROR';
|
|
24
|
-
errorMessage?: string;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
// OTEL Log types
|
|
28
|
-
export type OtelSeverity = 'TRACE' | 'DEBUG' | 'INFO' | 'WARN' | 'ERROR' | 'FATAL';
|
|
29
|
-
|
|
30
|
-
export interface OtelLog {
|
|
31
|
-
timestamp: number;
|
|
32
|
-
severity: OtelSeverity;
|
|
33
|
-
body: string | Record<string, unknown>;
|
|
34
|
-
resource: Record<string, string | number>;
|
|
35
|
-
attributes?: Record<string, JsonValue>;
|
|
36
|
-
traceId?: string;
|
|
37
|
-
spanId?: string;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
// Timeline item (event or log)
|
|
41
|
-
interface TimelineItem {
|
|
42
|
-
type: 'event' | 'log';
|
|
43
|
-
time: number;
|
|
44
|
-
// For events
|
|
45
|
-
name?: string;
|
|
46
|
-
attributes?: Record<string, JsonValue>;
|
|
47
|
-
// For logs
|
|
48
|
-
severity?: OtelSeverity;
|
|
49
|
-
body?: string | Record<string, unknown>;
|
|
50
|
-
resource?: Record<string, string | number>;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
// View mode type
|
|
54
|
-
export type ViewMode = 'raw' | 'narrative';
|
|
55
|
-
|
|
56
|
-
export interface TestEventPanelProps {
|
|
57
|
-
spans: TestSpan[];
|
|
58
|
-
logs?: OtelLog[]; // Optional for backward compatibility
|
|
59
|
-
currentSpanIndex: number;
|
|
60
|
-
currentEventIndex: number;
|
|
61
|
-
highlightedPhase?: string; // 'setup' | 'execution' | 'assertion'
|
|
62
|
-
onSpanIndexChange?: (index: number) => void;
|
|
63
|
-
|
|
64
|
-
// Narrative view props
|
|
65
|
-
viewMode?: ViewMode;
|
|
66
|
-
narrativeTemplate?: NarrativeTemplate;
|
|
67
|
-
onViewModeChange?: (mode: ViewMode) => void;
|
|
68
|
-
showNarrativeMetadata?: boolean;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
// Helper functions for log severity
|
|
72
|
-
function getSeverityColor(severity: OtelSeverity): string {
|
|
73
|
-
const colors = {
|
|
74
|
-
TRACE: '#6b7280',
|
|
75
|
-
DEBUG: '#60a5fa',
|
|
76
|
-
INFO: '#4ade80',
|
|
77
|
-
WARN: '#fbbf24',
|
|
78
|
-
ERROR: '#f87171',
|
|
79
|
-
FATAL: '#dc2626',
|
|
80
|
-
};
|
|
81
|
-
return colors[severity] || '#9ca3af';
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
function getSeverityIcon(severity: OtelSeverity): string {
|
|
85
|
-
const icons = {
|
|
86
|
-
TRACE: '○',
|
|
87
|
-
DEBUG: '◐',
|
|
88
|
-
INFO: '●',
|
|
89
|
-
WARN: '⚠',
|
|
90
|
-
ERROR: '✕',
|
|
91
|
-
FATAL: '☠',
|
|
92
|
-
};
|
|
93
|
-
return icons[severity] || '•';
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
export const TestEventPanel: React.FC<TestEventPanelProps> = ({
|
|
97
|
-
spans,
|
|
98
|
-
logs = [],
|
|
99
|
-
currentSpanIndex,
|
|
100
|
-
currentEventIndex,
|
|
101
|
-
highlightedPhase,
|
|
102
|
-
onSpanIndexChange,
|
|
103
|
-
viewMode = 'raw',
|
|
104
|
-
narrativeTemplate,
|
|
105
|
-
onViewModeChange,
|
|
106
|
-
showNarrativeMetadata = false,
|
|
107
|
-
}) => {
|
|
108
|
-
const { theme } = useTheme();
|
|
109
|
-
const [showHelp, setShowHelp] = useState(false);
|
|
110
|
-
|
|
111
|
-
const currentSpan = spans[currentSpanIndex];
|
|
112
|
-
|
|
113
|
-
// Convert current span to OtelEvents for narrative rendering
|
|
114
|
-
const otelEvents = useMemo(() => {
|
|
115
|
-
if (!currentSpan || viewMode !== 'narrative') return [];
|
|
116
|
-
return convertToOtelEvents(currentSpan, logs);
|
|
117
|
-
}, [currentSpan, logs, viewMode]);
|
|
118
|
-
|
|
119
|
-
const handlePrevTest = () => {
|
|
120
|
-
if (currentSpanIndex > 0 && onSpanIndexChange) {
|
|
121
|
-
onSpanIndexChange(currentSpanIndex - 1);
|
|
122
|
-
}
|
|
123
|
-
};
|
|
124
|
-
|
|
125
|
-
const handleNextTest = () => {
|
|
126
|
-
if (currentSpanIndex < spans.length - 1 && onSpanIndexChange) {
|
|
127
|
-
onSpanIndexChange(currentSpanIndex + 1);
|
|
128
|
-
}
|
|
129
|
-
};
|
|
130
|
-
|
|
131
|
-
// Build interleaved timeline
|
|
132
|
-
const timeline = useMemo(() => {
|
|
133
|
-
if (!currentSpan) return [];
|
|
134
|
-
|
|
135
|
-
const items: TimelineItem[] = [
|
|
136
|
-
// Span events
|
|
137
|
-
...currentSpan.events.slice(0, currentEventIndex + 1).map((event) => ({
|
|
138
|
-
type: 'event' as const,
|
|
139
|
-
time: event.time,
|
|
140
|
-
name: event.name,
|
|
141
|
-
attributes: event.attributes,
|
|
142
|
-
})),
|
|
143
|
-
|
|
144
|
-
// Correlated logs (matching current span's traceId)
|
|
145
|
-
...logs
|
|
146
|
-
.filter((log) => log.traceId === currentSpan.id)
|
|
147
|
-
.map((log) => ({
|
|
148
|
-
type: 'log' as const,
|
|
149
|
-
time: typeof log.timestamp === 'number' ? log.timestamp : new Date(log.timestamp).getTime(),
|
|
150
|
-
severity: log.severity,
|
|
151
|
-
body: log.body,
|
|
152
|
-
resource: log.resource,
|
|
153
|
-
attributes: log.attributes,
|
|
154
|
-
})),
|
|
155
|
-
].sort((a, b) => a.time - b.time);
|
|
156
|
-
|
|
157
|
-
return items;
|
|
158
|
-
}, [currentSpan, currentEventIndex, logs]);
|
|
159
|
-
|
|
160
|
-
return (
|
|
161
|
-
<div
|
|
162
|
-
style={{
|
|
163
|
-
width: '100%',
|
|
164
|
-
height: '100%',
|
|
165
|
-
backgroundColor: theme.colors.background,
|
|
166
|
-
color: theme.colors.text,
|
|
167
|
-
fontFamily: theme.fonts.monospace,
|
|
168
|
-
fontSize: '14px',
|
|
169
|
-
boxSizing: 'border-box',
|
|
170
|
-
display: 'flex',
|
|
171
|
-
flexDirection: 'column',
|
|
172
|
-
}}
|
|
173
|
-
>
|
|
174
|
-
{/* Static Header */}
|
|
175
|
-
<div
|
|
176
|
-
style={{
|
|
177
|
-
padding: '20px 20px 0 20px',
|
|
178
|
-
backgroundColor: theme.colors.background,
|
|
179
|
-
borderBottom: `1px solid ${theme.colors.border}`,
|
|
180
|
-
flexShrink: 0,
|
|
181
|
-
}}
|
|
182
|
-
>
|
|
183
|
-
{/* Test Navigation - replacing title */}
|
|
184
|
-
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '12px' }}>
|
|
185
|
-
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
|
186
|
-
<button
|
|
187
|
-
onClick={handlePrevTest}
|
|
188
|
-
disabled={currentSpanIndex === 0}
|
|
189
|
-
style={{
|
|
190
|
-
padding: '4px 12px',
|
|
191
|
-
background: theme.colors.surface,
|
|
192
|
-
border: `1px solid ${theme.colors.border}`,
|
|
193
|
-
borderRadius: '4px',
|
|
194
|
-
color: currentSpanIndex === 0 ? theme.colors.textMuted : theme.colors.text,
|
|
195
|
-
cursor: currentSpanIndex === 0 ? 'not-allowed' : 'pointer',
|
|
196
|
-
fontSize: '14px',
|
|
197
|
-
opacity: currentSpanIndex === 0 ? 0.5 : 1,
|
|
198
|
-
}}
|
|
199
|
-
>
|
|
200
|
-
← Prev
|
|
201
|
-
</button>
|
|
202
|
-
<div style={{ fontSize: '14px', fontWeight: 'bold' }}>
|
|
203
|
-
Test {currentSpanIndex + 1} of {spans.length}
|
|
204
|
-
</div>
|
|
205
|
-
<button
|
|
206
|
-
onClick={handleNextTest}
|
|
207
|
-
disabled={currentSpanIndex === spans.length - 1}
|
|
208
|
-
style={{
|
|
209
|
-
padding: '4px 12px',
|
|
210
|
-
background: theme.colors.surface,
|
|
211
|
-
border: `1px solid ${theme.colors.border}`,
|
|
212
|
-
borderRadius: '4px',
|
|
213
|
-
color: currentSpanIndex === spans.length - 1 ? theme.colors.textMuted : theme.colors.text,
|
|
214
|
-
cursor: currentSpanIndex === spans.length - 1 ? 'not-allowed' : 'pointer',
|
|
215
|
-
fontSize: '14px',
|
|
216
|
-
opacity: currentSpanIndex === spans.length - 1 ? 0.5 : 1,
|
|
217
|
-
}}
|
|
218
|
-
>
|
|
219
|
-
Next →
|
|
220
|
-
</button>
|
|
221
|
-
</div>
|
|
222
|
-
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
|
223
|
-
<div style={{ fontSize: '13px', color: theme.colors.textMuted }}>
|
|
224
|
-
<span style={{ color: '#4ade80' }}>All Passed ✓</span>
|
|
225
|
-
</div>
|
|
226
|
-
<button
|
|
227
|
-
onClick={() => setShowHelp(true)}
|
|
228
|
-
style={{
|
|
229
|
-
background: 'transparent',
|
|
230
|
-
border: 'none',
|
|
231
|
-
cursor: 'pointer',
|
|
232
|
-
padding: '4px',
|
|
233
|
-
display: 'flex',
|
|
234
|
-
alignItems: 'center',
|
|
235
|
-
color: theme.colors.textMuted,
|
|
236
|
-
}}
|
|
237
|
-
onMouseEnter={(e) => {
|
|
238
|
-
e.currentTarget.style.color = theme.colors.text;
|
|
239
|
-
}}
|
|
240
|
-
onMouseLeave={(e) => {
|
|
241
|
-
e.currentTarget.style.color = theme.colors.textMuted;
|
|
242
|
-
}}
|
|
243
|
-
>
|
|
244
|
-
<HelpCircle size={20} />
|
|
245
|
-
</button>
|
|
246
|
-
</div>
|
|
247
|
-
</div>
|
|
248
|
-
|
|
249
|
-
{/* View Mode Toggle */}
|
|
250
|
-
{narrativeTemplate && onViewModeChange && (
|
|
251
|
-
<div style={{ display: 'flex', gap: '8px', marginBottom: '12px' }}>
|
|
252
|
-
<button
|
|
253
|
-
onClick={() => onViewModeChange('raw')}
|
|
254
|
-
style={{
|
|
255
|
-
padding: '6px 12px',
|
|
256
|
-
background: viewMode === 'raw' ? theme.colors.primary : theme.colors.surface,
|
|
257
|
-
border: `1px solid ${theme.colors.border}`,
|
|
258
|
-
borderRadius: '4px',
|
|
259
|
-
color: viewMode === 'raw' ? '#ffffff' : theme.colors.text,
|
|
260
|
-
cursor: 'pointer',
|
|
261
|
-
fontSize: '13px',
|
|
262
|
-
fontWeight: viewMode === 'raw' ? 'bold' : 'normal',
|
|
263
|
-
}}
|
|
264
|
-
>
|
|
265
|
-
Raw Events
|
|
266
|
-
</button>
|
|
267
|
-
<button
|
|
268
|
-
onClick={() => onViewModeChange('narrative')}
|
|
269
|
-
style={{
|
|
270
|
-
padding: '6px 12px',
|
|
271
|
-
background: viewMode === 'narrative' ? theme.colors.primary : theme.colors.surface,
|
|
272
|
-
border: `1px solid ${theme.colors.border}`,
|
|
273
|
-
borderRadius: '4px',
|
|
274
|
-
color: viewMode === 'narrative' ? '#ffffff' : theme.colors.text,
|
|
275
|
-
cursor: 'pointer',
|
|
276
|
-
fontSize: '13px',
|
|
277
|
-
fontWeight: viewMode === 'narrative' ? 'bold' : 'normal',
|
|
278
|
-
}}
|
|
279
|
-
>
|
|
280
|
-
Narrative
|
|
281
|
-
</button>
|
|
282
|
-
</div>
|
|
283
|
-
)}
|
|
284
|
-
|
|
285
|
-
<div style={{ fontSize: '13px', color: theme.colors.textMuted, marginBottom: '15px' }}>
|
|
286
|
-
Test: {currentSpan?.name || 'Loading...'}
|
|
287
|
-
</div>
|
|
288
|
-
</div>
|
|
289
|
-
|
|
290
|
-
{/* Help Modal */}
|
|
291
|
-
{showHelp && (
|
|
292
|
-
<div
|
|
293
|
-
style={{
|
|
294
|
-
position: 'fixed',
|
|
295
|
-
top: 0,
|
|
296
|
-
left: 0,
|
|
297
|
-
right: 0,
|
|
298
|
-
bottom: 0,
|
|
299
|
-
backgroundColor: 'rgba(0, 0, 0, 0.7)',
|
|
300
|
-
display: 'flex',
|
|
301
|
-
alignItems: 'center',
|
|
302
|
-
justifyContent: 'center',
|
|
303
|
-
zIndex: 9999,
|
|
304
|
-
}}
|
|
305
|
-
onClick={() => setShowHelp(false)}
|
|
306
|
-
>
|
|
307
|
-
<div
|
|
308
|
-
style={{
|
|
309
|
-
backgroundColor: theme.colors.background,
|
|
310
|
-
color: theme.colors.text,
|
|
311
|
-
padding: '24px',
|
|
312
|
-
borderRadius: '8px',
|
|
313
|
-
maxWidth: '600px',
|
|
314
|
-
border: `1px solid ${theme.colors.border}`,
|
|
315
|
-
}}
|
|
316
|
-
onClick={(e) => e.stopPropagation()}
|
|
317
|
-
>
|
|
318
|
-
<div style={{ fontWeight: 'bold', fontSize: '18px', marginBottom: '16px' }}>
|
|
319
|
-
How to Read This Panel
|
|
320
|
-
</div>
|
|
321
|
-
<div style={{ fontSize: '14px', marginBottom: '16px', lineHeight: '1.6' }}>
|
|
322
|
-
<p style={{ marginBottom: '12px' }}>
|
|
323
|
-
<strong>Timeline shows both events and logs:</strong>
|
|
324
|
-
</p>
|
|
325
|
-
<ul style={{ marginLeft: '20px', marginBottom: '16px' }}>
|
|
326
|
-
<li style={{ marginBottom: '8px' }}>
|
|
327
|
-
<span style={{ color: '#f59e0b' }}>🟧 Events</span> - Structured lifecycle points
|
|
328
|
-
</li>
|
|
329
|
-
<li style={{ marginBottom: '8px' }}>
|
|
330
|
-
<span style={{ color: '#4ade80' }}>● Logs</span> - Standalone log records (color = severity)
|
|
331
|
-
</li>
|
|
332
|
-
<li style={{ marginBottom: '8px' }}>
|
|
333
|
-
<span style={{ color: '#60a5fa' }}>Blue = Test file</span>
|
|
334
|
-
</li>
|
|
335
|
-
<li>
|
|
336
|
-
<span style={{ color: '#4ade80' }}>Green → Code under test</span>
|
|
337
|
-
</li>
|
|
338
|
-
</ul>
|
|
339
|
-
</div>
|
|
340
|
-
<button
|
|
341
|
-
onClick={() => setShowHelp(false)}
|
|
342
|
-
style={{
|
|
343
|
-
padding: '8px 16px',
|
|
344
|
-
backgroundColor: theme.colors.primary,
|
|
345
|
-
color: '#ffffff',
|
|
346
|
-
border: 'none',
|
|
347
|
-
borderRadius: '4px',
|
|
348
|
-
cursor: 'pointer',
|
|
349
|
-
fontSize: '14px',
|
|
350
|
-
fontWeight: 500,
|
|
351
|
-
}}
|
|
352
|
-
>
|
|
353
|
-
Got it
|
|
354
|
-
</button>
|
|
355
|
-
</div>
|
|
356
|
-
</div>
|
|
357
|
-
)}
|
|
358
|
-
|
|
359
|
-
{/* Scrollable Content */}
|
|
360
|
-
<div
|
|
361
|
-
style={{
|
|
362
|
-
flex: 1,
|
|
363
|
-
overflow: 'auto',
|
|
364
|
-
padding: viewMode === 'narrative' ? '0' : '20px',
|
|
365
|
-
}}
|
|
366
|
-
>
|
|
367
|
-
{/* Narrative View */}
|
|
368
|
-
{viewMode === 'narrative' && narrativeTemplate && currentSpan ? (
|
|
369
|
-
<NarrativeRenderer
|
|
370
|
-
template={narrativeTemplate}
|
|
371
|
-
events={otelEvents}
|
|
372
|
-
showMetadata={showNarrativeMetadata}
|
|
373
|
-
/>
|
|
374
|
-
) : viewMode === 'narrative' && !narrativeTemplate ? (
|
|
375
|
-
<div
|
|
376
|
-
style={{
|
|
377
|
-
padding: '40px 20px',
|
|
378
|
-
textAlign: 'center',
|
|
379
|
-
color: theme.colors.textMuted,
|
|
380
|
-
}}
|
|
381
|
-
>
|
|
382
|
-
<div style={{ fontSize: '16px', marginBottom: '12px' }}>ⓘ No narrative template available</div>
|
|
383
|
-
<div style={{ fontSize: '14px', lineHeight: '1.6' }}>
|
|
384
|
-
Create a narrative template to see a human-readable
|
|
385
|
-
<br />
|
|
386
|
-
summary of this test execution.
|
|
387
|
-
</div>
|
|
388
|
-
<button
|
|
389
|
-
onClick={() => onViewModeChange?.('raw')}
|
|
390
|
-
style={{
|
|
391
|
-
marginTop: '20px',
|
|
392
|
-
padding: '8px 16px',
|
|
393
|
-
background: theme.colors.primary,
|
|
394
|
-
color: '#ffffff',
|
|
395
|
-
border: 'none',
|
|
396
|
-
borderRadius: '4px',
|
|
397
|
-
cursor: 'pointer',
|
|
398
|
-
fontSize: '14px',
|
|
399
|
-
}}
|
|
400
|
-
>
|
|
401
|
-
View Raw Events
|
|
402
|
-
</button>
|
|
403
|
-
</div>
|
|
404
|
-
) : null}
|
|
405
|
-
|
|
406
|
-
{/* Raw Events View (Timeline) */}
|
|
407
|
-
{viewMode === 'raw' && currentSpan && (
|
|
408
|
-
<div>
|
|
409
|
-
{timeline.map((item, idx) => {
|
|
410
|
-
if (item.type === 'event') {
|
|
411
|
-
// SPAN EVENT RENDERING
|
|
412
|
-
const filepath = item.attributes?.['code.filepath'] as string;
|
|
413
|
-
const lineno = item.attributes?.['code.lineno'] as number;
|
|
414
|
-
const isCodeUnderTest = filepath && filepath !== 'GraphConverter.test.ts';
|
|
415
|
-
|
|
416
|
-
// Determine which phase this event belongs to
|
|
417
|
-
const eventPhase = item.name?.split('.')[0]; // 'setup', 'execution', 'assertion'
|
|
418
|
-
const isHighlighted = highlightedPhase === eventPhase;
|
|
419
|
-
|
|
420
|
-
return (
|
|
421
|
-
<div
|
|
422
|
-
key={idx}
|
|
423
|
-
style={{
|
|
424
|
-
marginBottom: '12px',
|
|
425
|
-
paddingBottom: '12px',
|
|
426
|
-
paddingLeft: '12px',
|
|
427
|
-
borderBottom: idx < timeline.length - 1 ? `1px solid ${theme.colors.border}` : 'none',
|
|
428
|
-
borderLeft: '3px solid #f59e0b',
|
|
429
|
-
opacity: highlightedPhase && !isHighlighted ? 0.4 : 1,
|
|
430
|
-
transition: 'opacity 0.2s ease',
|
|
431
|
-
transform: isHighlighted ? 'scale(1.02)' : 'scale(1)',
|
|
432
|
-
backgroundColor: isHighlighted ? theme.colors.surface : 'transparent',
|
|
433
|
-
padding: isHighlighted ? '8px 8px 8px 12px' : '0 0 12px 12px',
|
|
434
|
-
borderRadius: '4px',
|
|
435
|
-
}}
|
|
436
|
-
>
|
|
437
|
-
<div
|
|
438
|
-
style={{
|
|
439
|
-
display: 'flex',
|
|
440
|
-
justifyContent: 'space-between',
|
|
441
|
-
alignItems: 'center',
|
|
442
|
-
marginBottom: '4px',
|
|
443
|
-
gap: '8px',
|
|
444
|
-
}}
|
|
445
|
-
>
|
|
446
|
-
<div style={{ color: '#f59e0b', fontSize: '13px', fontWeight: 'bold', flexShrink: 0 }}>
|
|
447
|
-
EVENT: {item.name}
|
|
448
|
-
</div>
|
|
449
|
-
{filepath && (
|
|
450
|
-
<div
|
|
451
|
-
style={{
|
|
452
|
-
fontSize: '12px',
|
|
453
|
-
color: isCodeUnderTest ? '#4ade80' : '#60a5fa',
|
|
454
|
-
fontFamily: 'monospace',
|
|
455
|
-
background: isCodeUnderTest ? '#064e3b' : '#1e3a8a',
|
|
456
|
-
padding: '2px 6px',
|
|
457
|
-
borderRadius: '3px',
|
|
458
|
-
flexShrink: 1,
|
|
459
|
-
minWidth: 0,
|
|
460
|
-
overflow: 'hidden',
|
|
461
|
-
textOverflow: 'ellipsis',
|
|
462
|
-
whiteSpace: 'nowrap',
|
|
463
|
-
}}
|
|
464
|
-
>
|
|
465
|
-
{isCodeUnderTest && '→ '}
|
|
466
|
-
{filepath}:{lineno}
|
|
467
|
-
</div>
|
|
468
|
-
)}
|
|
469
|
-
</div>
|
|
470
|
-
<pre
|
|
471
|
-
style={{
|
|
472
|
-
background: theme.colors.surface,
|
|
473
|
-
padding: '8px',
|
|
474
|
-
borderRadius: '4px',
|
|
475
|
-
margin: 0,
|
|
476
|
-
fontSize: '13px',
|
|
477
|
-
lineHeight: '1.5',
|
|
478
|
-
overflow: 'auto',
|
|
479
|
-
maxWidth: '100%',
|
|
480
|
-
}}
|
|
481
|
-
>
|
|
482
|
-
{yaml.dump(
|
|
483
|
-
Object.fromEntries(
|
|
484
|
-
Object.entries(item.attributes || {}).filter(
|
|
485
|
-
([key]) => key !== 'code.filepath' && key !== 'code.lineno'
|
|
486
|
-
)
|
|
487
|
-
),
|
|
488
|
-
{ indent: 2, lineWidth: -1 }
|
|
489
|
-
)}
|
|
490
|
-
</pre>
|
|
491
|
-
</div>
|
|
492
|
-
);
|
|
493
|
-
} else {
|
|
494
|
-
// OTEL LOG RENDERING
|
|
495
|
-
const serviceName = item.resource?.['service.name'];
|
|
496
|
-
const severityColor = getSeverityColor(item.severity!);
|
|
497
|
-
|
|
498
|
-
return (
|
|
499
|
-
<div
|
|
500
|
-
key={idx}
|
|
501
|
-
style={{
|
|
502
|
-
marginBottom: '12px',
|
|
503
|
-
paddingBottom: '12px',
|
|
504
|
-
paddingLeft: '12px',
|
|
505
|
-
borderBottom: idx < timeline.length - 1 ? `1px solid ${theme.colors.border}` : 'none',
|
|
506
|
-
borderLeft: `3px solid ${severityColor}`,
|
|
507
|
-
}}
|
|
508
|
-
>
|
|
509
|
-
<div
|
|
510
|
-
style={{
|
|
511
|
-
display: 'flex',
|
|
512
|
-
justifyContent: 'space-between',
|
|
513
|
-
alignItems: 'center',
|
|
514
|
-
marginBottom: '4px',
|
|
515
|
-
}}
|
|
516
|
-
>
|
|
517
|
-
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
|
518
|
-
<span style={{ fontSize: '16px' }}>{getSeverityIcon(item.severity!)}</span>
|
|
519
|
-
<span
|
|
520
|
-
style={{
|
|
521
|
-
color: severityColor,
|
|
522
|
-
fontSize: '13px',
|
|
523
|
-
fontWeight: 'bold',
|
|
524
|
-
}}
|
|
525
|
-
>
|
|
526
|
-
LOG: {item.severity}
|
|
527
|
-
</span>
|
|
528
|
-
</div>
|
|
529
|
-
{serviceName && (
|
|
530
|
-
<div
|
|
531
|
-
style={{
|
|
532
|
-
fontSize: '12px',
|
|
533
|
-
color: '#9ca3af',
|
|
534
|
-
background: '#1e293b',
|
|
535
|
-
padding: '2px 6px',
|
|
536
|
-
borderRadius: '3px',
|
|
537
|
-
}}
|
|
538
|
-
>
|
|
539
|
-
{serviceName}
|
|
540
|
-
</div>
|
|
541
|
-
)}
|
|
542
|
-
</div>
|
|
543
|
-
|
|
544
|
-
{/* Log body */}
|
|
545
|
-
<div
|
|
546
|
-
style={{
|
|
547
|
-
background: theme.colors.surface,
|
|
548
|
-
padding: '8px',
|
|
549
|
-
borderRadius: '4px',
|
|
550
|
-
marginBottom: item.attributes && Object.keys(item.attributes).length > 0 ? '8px' : '0',
|
|
551
|
-
fontSize: '13px',
|
|
552
|
-
}}
|
|
553
|
-
>
|
|
554
|
-
{typeof item.body === 'string' ? (
|
|
555
|
-
item.body
|
|
556
|
-
) : (
|
|
557
|
-
<pre style={{ margin: 0, fontSize: '13px', lineHeight: '1.5' }}>
|
|
558
|
-
{yaml.dump(item.body, { indent: 2, lineWidth: -1 })}
|
|
559
|
-
</pre>
|
|
560
|
-
)}
|
|
561
|
-
</div>
|
|
562
|
-
|
|
563
|
-
{/* Log attributes */}
|
|
564
|
-
{item.attributes && Object.keys(item.attributes).length > 0 && (
|
|
565
|
-
<pre
|
|
566
|
-
style={{
|
|
567
|
-
background: theme.colors.surface,
|
|
568
|
-
padding: '8px',
|
|
569
|
-
borderRadius: '4px',
|
|
570
|
-
margin: 0,
|
|
571
|
-
fontSize: '12px',
|
|
572
|
-
lineHeight: '1.5',
|
|
573
|
-
opacity: 0.8,
|
|
574
|
-
}}
|
|
575
|
-
>
|
|
576
|
-
{yaml.dump(item.attributes, { indent: 2, lineWidth: -1 })}
|
|
577
|
-
</pre>
|
|
578
|
-
)}
|
|
579
|
-
</div>
|
|
580
|
-
);
|
|
581
|
-
}
|
|
582
|
-
})}
|
|
583
|
-
</div>
|
|
584
|
-
)}
|
|
585
|
-
</div>
|
|
586
|
-
</div>
|
|
587
|
-
);
|
|
588
|
-
};
|
|
@@ -1,157 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Utilities for converting TestEventPanel data to OtelEvent format
|
|
3
|
-
* for use with the narrative renderer
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import type { OtelEvent } from '@principal-ai/principal-view-core/browser';
|
|
7
|
-
import type { OtelLog, OtelSeverity } from '../components/TestEventPanel';
|
|
8
|
-
|
|
9
|
-
// TestEventPanel types (matching TestEventPanel.tsx)
|
|
10
|
-
interface SpanEvent {
|
|
11
|
-
time: number;
|
|
12
|
-
name: string;
|
|
13
|
-
attributes: Record<string, string | number | boolean>;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
interface TestSpan {
|
|
17
|
-
id: string;
|
|
18
|
-
name: string;
|
|
19
|
-
startTime: number;
|
|
20
|
-
endTime?: number;
|
|
21
|
-
duration?: number;
|
|
22
|
-
attributes: Record<string, string | number | boolean>;
|
|
23
|
-
events: SpanEvent[];
|
|
24
|
-
status: 'OK' | 'ERROR';
|
|
25
|
-
errorMessage?: string;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Convert OtelSeverity to severity number (OpenTelemetry spec)
|
|
30
|
-
*/
|
|
31
|
-
function severityToNumber(severity: OtelSeverity): number {
|
|
32
|
-
const severityMap: Record<OtelSeverity, number> = {
|
|
33
|
-
TRACE: 1,
|
|
34
|
-
DEBUG: 5,
|
|
35
|
-
INFO: 9,
|
|
36
|
-
WARN: 13,
|
|
37
|
-
ERROR: 17,
|
|
38
|
-
FATAL: 21,
|
|
39
|
-
};
|
|
40
|
-
return severityMap[severity] || 9;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* Convert TestSpan events to OtelEvent spans
|
|
45
|
-
*/
|
|
46
|
-
function convertSpanEvents(span: TestSpan): OtelEvent[] {
|
|
47
|
-
return span.events.map((event) => ({
|
|
48
|
-
name: event.name,
|
|
49
|
-
timestamp: event.time,
|
|
50
|
-
type: 'span' as const,
|
|
51
|
-
spanId: span.id,
|
|
52
|
-
traceId: span.id, // Use span ID as trace ID for single-span tests
|
|
53
|
-
attributes: event.attributes,
|
|
54
|
-
}));
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* Convert OtelLogs to OtelEvent logs
|
|
59
|
-
*/
|
|
60
|
-
function convertLogs(logs: OtelLog[]): OtelEvent[] {
|
|
61
|
-
return logs.map((log) => {
|
|
62
|
-
// Filter out null values from attributes (OtelAttributeValue doesn't include null)
|
|
63
|
-
const filterNullValues = (obj: Record<string, any> | undefined) => {
|
|
64
|
-
if (!obj) return {};
|
|
65
|
-
return Object.fromEntries(
|
|
66
|
-
Object.entries(obj).filter(([, value]) => value !== null)
|
|
67
|
-
);
|
|
68
|
-
};
|
|
69
|
-
|
|
70
|
-
return {
|
|
71
|
-
name: 'log',
|
|
72
|
-
timestamp: typeof log.timestamp === 'number' ? log.timestamp : new Date(log.timestamp).getTime(),
|
|
73
|
-
type: 'log' as const,
|
|
74
|
-
spanId: log.spanId,
|
|
75
|
-
traceId: log.traceId,
|
|
76
|
-
severityText: log.severity,
|
|
77
|
-
severityNumber: severityToNumber(log.severity),
|
|
78
|
-
body: typeof log.body === 'string' ? log.body : JSON.stringify(log.body),
|
|
79
|
-
attributes: {
|
|
80
|
-
...filterNullValues(log.attributes),
|
|
81
|
-
...filterNullValues(log.resource),
|
|
82
|
-
},
|
|
83
|
-
};
|
|
84
|
-
});
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/**
|
|
88
|
-
* Convert TestSpan and OtelLogs to OtelEvent array
|
|
89
|
-
*
|
|
90
|
-
* @param span - The test span to convert
|
|
91
|
-
* @param logs - Optional OTEL logs to include
|
|
92
|
-
* @returns Array of OtelEvents in chronological order
|
|
93
|
-
*/
|
|
94
|
-
export function convertToOtelEvents(span: TestSpan, logs: OtelLog[] = []): OtelEvent[] {
|
|
95
|
-
const spanEvents = convertSpanEvents(span);
|
|
96
|
-
|
|
97
|
-
// Filter logs for this specific span
|
|
98
|
-
const spanLogs = logs.filter((log) => log.spanId === span.id || log.traceId === span.id);
|
|
99
|
-
const logEvents = convertLogs(spanLogs);
|
|
100
|
-
|
|
101
|
-
// Combine and sort by timestamp
|
|
102
|
-
const allEvents = [...spanEvents, ...logEvents].sort((a, b) => {
|
|
103
|
-
const aTime = typeof a.timestamp === 'number' ? a.timestamp : new Date(a.timestamp).getTime();
|
|
104
|
-
const bTime = typeof b.timestamp === 'number' ? b.timestamp : new Date(b.timestamp).getTime();
|
|
105
|
-
return aTime - bTime;
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
return allEvents;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/**
|
|
112
|
-
* Convert all TestSpans to OtelEvents
|
|
113
|
-
*
|
|
114
|
-
* @param spans - Array of test spans
|
|
115
|
-
* @param logs - Optional OTEL logs to include
|
|
116
|
-
* @returns Array of OtelEvents for all spans
|
|
117
|
-
*/
|
|
118
|
-
export function convertAllSpansToOtelEvents(spans: TestSpan[], logs: OtelLog[] = []): OtelEvent[] {
|
|
119
|
-
const allEvents: OtelEvent[] = [];
|
|
120
|
-
|
|
121
|
-
for (const span of spans) {
|
|
122
|
-
const spanEvents = convertToOtelEvents(span, logs);
|
|
123
|
-
allEvents.push(...spanEvents);
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
// Sort all events chronologically
|
|
127
|
-
return allEvents.sort((a, b) => {
|
|
128
|
-
const aTime = typeof a.timestamp === 'number' ? a.timestamp : new Date(a.timestamp).getTime();
|
|
129
|
-
const bTime = typeof b.timestamp === 'number' ? b.timestamp : new Date(b.timestamp).getTime();
|
|
130
|
-
return aTime - bTime;
|
|
131
|
-
});
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/**
|
|
135
|
-
* Extract test result attributes from TestSpan
|
|
136
|
-
* Useful for narrative templates that check assertions
|
|
137
|
-
*/
|
|
138
|
-
export function extractTestAttributes(span: TestSpan): Record<string, unknown> {
|
|
139
|
-
// Find assertion.complete event
|
|
140
|
-
const assertionComplete = span.events.find((e) => e.name === 'assertion.complete');
|
|
141
|
-
|
|
142
|
-
if (assertionComplete) {
|
|
143
|
-
return {
|
|
144
|
-
'assertions.passed': assertionComplete.attributes['assertions.passed'] || 0,
|
|
145
|
-
'assertions.failed': assertionComplete.attributes['assertions.failed'] || 0,
|
|
146
|
-
'test.status': span.status,
|
|
147
|
-
'test.name': span.name,
|
|
148
|
-
...span.attributes,
|
|
149
|
-
};
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
return {
|
|
153
|
-
'test.status': span.status,
|
|
154
|
-
'test.name': span.name,
|
|
155
|
-
...span.attributes,
|
|
156
|
-
};
|
|
157
|
-
}
|
|
@@ -1,172 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Utilities for loading and managing narrative templates
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
import type { NarrativeTemplate } from '@principal-ai/principal-view-core/browser';
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* Load a narrative template from a URL or file path
|
|
9
|
-
*
|
|
10
|
-
* Note: In most cases, you should import templates directly:
|
|
11
|
-
* import template from './my-template.narrative.json';
|
|
12
|
-
*
|
|
13
|
-
* This function is useful when you need to dynamically load templates.
|
|
14
|
-
*
|
|
15
|
-
* @param path - Path to the narrative template JSON file
|
|
16
|
-
* @returns Promise resolving to the narrative template, or null if not found
|
|
17
|
-
*/
|
|
18
|
-
export async function loadNarrativeTemplate(path: string): Promise<NarrativeTemplate | null> {
|
|
19
|
-
try {
|
|
20
|
-
const response = await fetch(path);
|
|
21
|
-
if (!response.ok) {
|
|
22
|
-
console.warn(`Narrative template not found: ${path}`);
|
|
23
|
-
return null;
|
|
24
|
-
}
|
|
25
|
-
const template = await response.json();
|
|
26
|
-
return template as NarrativeTemplate;
|
|
27
|
-
} catch (error) {
|
|
28
|
-
console.warn(`Failed to load narrative template from ${path}:`, error);
|
|
29
|
-
return null;
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Auto-discover narrative template for a given canvas path
|
|
35
|
-
*
|
|
36
|
-
* Example:
|
|
37
|
-
* Canvas: "/path/to/graph-converter.otel.canvas"
|
|
38
|
-
* Narrative: "/path/to/graph-converter.narrative.json"
|
|
39
|
-
*
|
|
40
|
-
* @param canvasPath - Path to the .otel.canvas file
|
|
41
|
-
* @returns Promise resolving to the narrative template, or null if not found
|
|
42
|
-
*/
|
|
43
|
-
export async function discoverNarrativeTemplate(canvasPath: string): Promise<NarrativeTemplate | null> {
|
|
44
|
-
const narrativePath = canvasPath.replace('.otel.canvas', '.narrative.json');
|
|
45
|
-
return loadNarrativeTemplate(narrativePath);
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* Validate a narrative template has required fields
|
|
50
|
-
*
|
|
51
|
-
* @param template - Template to validate
|
|
52
|
-
* @returns True if valid, false otherwise
|
|
53
|
-
*/
|
|
54
|
-
export function validateNarrativeTemplate(template: unknown): template is NarrativeTemplate {
|
|
55
|
-
if (!template || typeof template !== 'object') {
|
|
56
|
-
return false;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
const t = template as Partial<NarrativeTemplate>;
|
|
60
|
-
|
|
61
|
-
// Check required fields
|
|
62
|
-
if (!t.version || !t.canvas || !t.name || !t.mode || !t.scenarioSelection || !t.scenarios) {
|
|
63
|
-
return false;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
// Check scenarios array
|
|
67
|
-
if (!Array.isArray(t.scenarios) || t.scenarios.length === 0) {
|
|
68
|
-
return false;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
// Check each scenario has required fields
|
|
72
|
-
for (const scenario of t.scenarios) {
|
|
73
|
-
if (!scenario.id || !scenario.priority || !scenario.condition || !scenario.template) {
|
|
74
|
-
return false;
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
return true;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* Get a user-friendly error message for invalid templates
|
|
83
|
-
*
|
|
84
|
-
* @param template - Template to check
|
|
85
|
-
* @returns Error message, or null if valid
|
|
86
|
-
*/
|
|
87
|
-
export function getNarrativeTemplateError(template: unknown): string | null {
|
|
88
|
-
if (!template || typeof template !== 'object') {
|
|
89
|
-
return 'Template must be a valid JSON object';
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
const t = template as Partial<NarrativeTemplate>;
|
|
93
|
-
|
|
94
|
-
if (!t.version) return 'Missing required field: version';
|
|
95
|
-
if (!t.canvas) return 'Missing required field: canvas';
|
|
96
|
-
if (!t.name) return 'Missing required field: name';
|
|
97
|
-
if (!t.mode) return 'Missing required field: mode';
|
|
98
|
-
if (!t.scenarioSelection) return 'Missing required field: scenarioSelection';
|
|
99
|
-
if (!t.scenarios) return 'Missing required field: scenarios';
|
|
100
|
-
|
|
101
|
-
if (!Array.isArray(t.scenarios)) {
|
|
102
|
-
return 'Field "scenarios" must be an array';
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
if (t.scenarios.length === 0) {
|
|
106
|
-
return 'Template must have at least one scenario';
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
for (let i = 0; i < t.scenarios.length; i++) {
|
|
110
|
-
const scenario = t.scenarios[i];
|
|
111
|
-
if (!scenario.id) return `Scenario ${i}: missing required field "id"`;
|
|
112
|
-
if (scenario.priority === undefined) return `Scenario ${i}: missing required field "priority"`;
|
|
113
|
-
if (!scenario.condition) return `Scenario ${i}: missing required field "condition"`;
|
|
114
|
-
if (!scenario.template) return `Scenario ${i}: missing required field "template"`;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
return null;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
/**
|
|
121
|
-
* React hook for loading narrative templates
|
|
122
|
-
*
|
|
123
|
-
* @param templatePath - Path to template, or null to skip loading
|
|
124
|
-
* @returns {template, loading, error}
|
|
125
|
-
*/
|
|
126
|
-
export function useNarrativeTemplate(templatePath: string | null) {
|
|
127
|
-
const [template, setTemplate] = React.useState<NarrativeTemplate | null>(null);
|
|
128
|
-
const [loading, setLoading] = React.useState<boolean>(false);
|
|
129
|
-
const [error, setError] = React.useState<string | null>(null);
|
|
130
|
-
|
|
131
|
-
React.useEffect(() => {
|
|
132
|
-
if (!templatePath) {
|
|
133
|
-
setTemplate(null);
|
|
134
|
-
setLoading(false);
|
|
135
|
-
setError(null);
|
|
136
|
-
return;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
setLoading(true);
|
|
140
|
-
setError(null);
|
|
141
|
-
|
|
142
|
-
loadNarrativeTemplate(templatePath)
|
|
143
|
-
.then((loadedTemplate) => {
|
|
144
|
-
if (loadedTemplate) {
|
|
145
|
-
const validationError = getNarrativeTemplateError(loadedTemplate);
|
|
146
|
-
if (validationError) {
|
|
147
|
-
setError(validationError);
|
|
148
|
-
setTemplate(null);
|
|
149
|
-
} else {
|
|
150
|
-
setTemplate(loadedTemplate);
|
|
151
|
-
setError(null);
|
|
152
|
-
}
|
|
153
|
-
} else {
|
|
154
|
-
setError('Template not found');
|
|
155
|
-
setTemplate(null);
|
|
156
|
-
}
|
|
157
|
-
})
|
|
158
|
-
.catch((err) => {
|
|
159
|
-
setError(err instanceof Error ? err.message : 'Unknown error');
|
|
160
|
-
setTemplate(null);
|
|
161
|
-
})
|
|
162
|
-
.finally(() => {
|
|
163
|
-
setLoading(false);
|
|
164
|
-
});
|
|
165
|
-
}, [templatePath]);
|
|
166
|
-
|
|
167
|
-
return { template, loading, error };
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
// Note: React import is expected to be available in the React package
|
|
171
|
-
// If this causes issues, we can remove the hook and export it separately
|
|
172
|
-
import React from 'react';
|