@animaapp/anima-sdk-react 0.2.8 → 0.3.1

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,6 +1,6 @@
1
1
  {
2
2
  "name": "@animaapp/anima-sdk-react",
3
- "version": "0.2.8",
3
+ "version": "0.3.1",
4
4
  "type": "module",
5
5
  "description": "Anima's JavaScript utilities library",
6
6
  "author": "Anima App, Inc.",
@@ -20,8 +20,9 @@
20
20
  },
21
21
  "scripts": {
22
22
  "build": "vite build",
23
- "tests": "vitest run --dir ./tests",
24
- "prepack": "yarn build"
23
+ "test": "test-storybook",
24
+ "prepack": "yarn build",
25
+ "dev": "storybook dev -p 6006"
25
26
  },
26
27
  "dependencies": {
27
28
  "eventsource": "^3.0.5",
@@ -35,9 +36,21 @@
35
36
  },
36
37
  "devDependencies": {
37
38
  "@animaapp/anima-sdk": "workspace:*",
39
+ "@chromatic-com/storybook": "^3",
40
+ "@storybook/addon-essentials": "^8.6.6",
41
+ "@storybook/addon-interactions": "^8.6.6",
42
+ "@storybook/addon-onboarding": "^8.6.6",
43
+ "@storybook/blocks": "^8.6.6",
44
+ "@storybook/react": "^8.6.6",
45
+ "@storybook/react-vite": "^8.6.6",
46
+ "@storybook/test": "^8.6.6",
47
+ "@storybook/test-runner": "^0.22.0",
48
+ "@types/prop-types": "^15",
49
+ "@types/react": "^19.0.10",
50
+ "prop-types": "^15.8.1",
51
+ "storybook": "^8.6.6",
38
52
  "vite": "^6.0.11",
39
53
  "vite-plugin-dts": "^4.5.0",
40
- "vite-tsconfig-paths": "^5.1.4",
41
- "vitest": "^3.0.5"
54
+ "vite-tsconfig-paths": "^5.1.4"
42
55
  }
43
- }
56
+ }
@@ -0,0 +1,222 @@
1
+ import { useState } from "react";
2
+ import { expect, userEvent, waitFor, within } from "@storybook/test";
3
+ import { AnimaSDKResult, CodegenError } from "@animaapp/anima-sdk";
4
+ import type { Meta, StoryObj } from "@storybook/react";
5
+ import { useAnimaCodegen, UseAnimaParams } from "..";
6
+
7
+ type Props = {
8
+ payload: UseAnimaParams;
9
+ };
10
+
11
+ const DummyComponent = ({ payload }: Props) => {
12
+ const [result, setResult] = useState<AnimaSDKResult | null>(null);
13
+ const [error, setError] = useState<CodegenError | null>(null);
14
+
15
+ const animaCodegen = useAnimaCodegen({
16
+ url: "http://localhost:3000",
17
+ });
18
+
19
+ const handleClick = async () => {
20
+ const { result, error } = await animaCodegen.getCode(payload);
21
+
22
+ setResult(result);
23
+ setError(error);
24
+ };
25
+
26
+ return (
27
+ <div>
28
+ <button data-testid="run-button" onClick={handleClick}>
29
+ Run request
30
+ </button>
31
+
32
+ {result && (
33
+ <div>
34
+ <h3>Success result</h3>
35
+
36
+ <pre data-testid="request-result" data-status="success">
37
+ {JSON.stringify(result, null, 2)}
38
+ </pre>
39
+ </div>
40
+ )}
41
+
42
+ {error && (
43
+ <div>
44
+ <h3>Error result</h3>
45
+
46
+ <pre data-testid="request-result" data-status="error">
47
+ {JSON.stringify(
48
+ {
49
+ name: error.name,
50
+ message: error.message,
51
+ status: error.status,
52
+ detail: error.detail,
53
+ },
54
+ null,
55
+ 2
56
+ )}
57
+ </pre>
58
+ </div>
59
+ )}
60
+ </div>
61
+ );
62
+ };
63
+
64
+ const meta = {
65
+ title: "Hooks/useAnimaCodegen",
66
+ component: DummyComponent,
67
+ } satisfies Meta<typeof DummyComponent>;
68
+ export default meta;
69
+
70
+ type Story = StoryObj<typeof meta>;
71
+
72
+ const run = async ({ canvasElement }: { canvasElement: HTMLElement }) => {
73
+ const result: { result: AnimaSDKResult | null; error: CodegenError | null } =
74
+ { result: null, error: null };
75
+
76
+ const canvas = within(canvasElement);
77
+
78
+ const runButton = canvas.getByTestId("run-button");
79
+ await userEvent.click(runButton);
80
+
81
+ const requestResult = await waitFor(
82
+ () => canvas.findByTestId("request-result"),
83
+ { timeout: 10_000 }
84
+ );
85
+
86
+ if (!requestResult.textContent) {
87
+ return result;
88
+ }
89
+
90
+ const resultStatus = requestResult.dataset.status;
91
+
92
+ if (resultStatus === "success") {
93
+ result.result = JSON.parse(requestResult.textContent);
94
+ } else {
95
+ result.error = JSON.parse(requestResult.textContent);
96
+ }
97
+
98
+ return result;
99
+ };
100
+
101
+ export const Success: Story = {
102
+ play: async (context) => {
103
+ const { result } = await run(context);
104
+
105
+ expect(result).toMatchObject({
106
+ sessionId: expect.anything(),
107
+ figmaFileName: "Anima SDK - Test File",
108
+ figmaSelectedFrameName: "MyFrame",
109
+ files: expect.anything(),
110
+ tokenUsage: expect.anything(),
111
+ });
112
+ },
113
+
114
+ args: {
115
+ payload: {
116
+ fileKey: "5d0u9PmD4GtB5fdX57pTtK",
117
+ nodesId: ["1:2"],
118
+ settings: {
119
+ framework: "react",
120
+ styling: "plain_css",
121
+ },
122
+ },
123
+ },
124
+ };
125
+
126
+ export const InvalidBody: Story = {
127
+ play: async (context) => {
128
+ const { error } = await run(context);
129
+
130
+ expect(error).toMatchObject({
131
+ name: "HTTP error from Anima API",
132
+ message: "Invalid body payload",
133
+ status: 400,
134
+ detail: [
135
+ {
136
+ code: "invalid_type",
137
+ expected: "string",
138
+ received: "null",
139
+ path: ["fileKey"],
140
+ message: "Expected string, received null",
141
+ },
142
+ ],
143
+ });
144
+ },
145
+
146
+ args: {
147
+ payload: {
148
+ // @ts-expect-error: Testing invalid body payload
149
+ fileKey: null,
150
+ nodesId: ["1:2"],
151
+ settings: {
152
+ framework: "react",
153
+ styling: "plain_css",
154
+ },
155
+ },
156
+ },
157
+ };
158
+
159
+ export const NotFoundFile: Story = {
160
+ play: async (context) => {
161
+ const { error } = await run(context);
162
+
163
+ expect(error).toMatchObject({
164
+ name: "Task Crashed",
165
+ message: "Figma file not found",
166
+ });
167
+ },
168
+
169
+ args: {
170
+ payload: {
171
+ fileKey: "invalid-file-key",
172
+ nodesId: ["1:2"],
173
+ settings: {
174
+ framework: "react",
175
+ styling: "plain_css",
176
+ },
177
+ },
178
+ },
179
+ };
180
+
181
+ export const NotSupportedNode: Story = {
182
+ play: async (context) => {
183
+ const { error } = await run(context);
184
+
185
+ expect(error).toMatchObject({
186
+ name: "Task Crashed",
187
+ message: "Selected node type is not supported",
188
+ });
189
+ },
190
+
191
+ args: {
192
+ payload: {
193
+ fileKey: "5d0u9PmD4GtB5fdX57pTtK",
194
+ nodesId: ["14:5"],
195
+ settings: {
196
+ framework: "react",
197
+ styling: "plain_css",
198
+ },
199
+ },
200
+ },
201
+ };
202
+
203
+ export const InvisibleGroupNode: Story = {
204
+ play: async (context) => {
205
+ const { error } = await run(context);
206
+
207
+ expect(error).toMatchObject({
208
+ name: "Task Crashed",
209
+ message: "Invisible group nodes are unsupported",
210
+ });
211
+ },
212
+ args: {
213
+ payload: {
214
+ fileKey: "5d0u9PmD4GtB5fdX57pTtK",
215
+ nodesId: ["134:14"],
216
+ settings: {
217
+ framework: "react",
218
+ styling: "plain_css",
219
+ },
220
+ },
221
+ },
222
+ };
@@ -4,6 +4,7 @@ import type {
4
4
  GetCodeParams,
5
5
  StreamCodgenMessage,
6
6
  } from "@animaapp/anima-sdk";
7
+ import { CodegenError } from "@animaapp/anima-sdk";
7
8
  import { EventSource } from "eventsource";
8
9
  import { useImmer } from "use-immer";
9
10
 
@@ -21,7 +22,7 @@ type TaskStatus = "pending" | "running" | "finished";
21
22
 
22
23
  type CodegenStatus = {
23
24
  status: Status;
24
- error: Error | null;
25
+ error: CodegenError | null;
25
26
  result: AnimaSDKResult | null;
26
27
  tasks: {
27
28
  fetchDesign: { status: TaskStatus };
@@ -98,18 +99,26 @@ export const useAnimaCodegen = ({
98
99
  };
99
100
  }
100
101
 
102
+ // TODO: We have two workarounds here because of limitations on the `eventsource` package:
103
+ // 1. We need to use the `fetch` function from the `EventSource` constructor to send the request with the correct method and body (https://github.com/EventSource/eventsource/issues/316#issuecomment-2525315835).
104
+ // 2. We need to store the last fetch response to handle errors to read its body response, since it isn't expoted by the package (https://github.com/EventSource/eventsource/blob/8aa7057bccd7fb819372a3b2c1292e7b53424d52/src/EventSource.ts#L348-L376)
105
+ // We might need to use other library, or do it from our self, to improve the code quality.
106
+ let lastFetchResponse: ReturnType<typeof fetch>;
101
107
  const es = new EventSource(url, {
102
- fetch: (url, init) =>
103
- fetch(url, {
108
+ fetch: (url, init) => {
109
+ lastFetchResponse = fetch(url, {
104
110
  ...init,
105
111
  method,
106
112
  body: JSON.stringify(params),
107
- }),
113
+ });
114
+
115
+ return lastFetchResponse;
116
+ },
108
117
  });
109
118
 
110
119
  const promise = new Promise<{
111
120
  result: AnimaSDKResult | null;
112
- error: Error | null;
121
+ error: CodegenError | null;
113
122
  }>((resolve) => {
114
123
  const result: Partial<AnimaSDKResult> = {};
115
124
 
@@ -145,12 +154,16 @@ export const useAnimaCodegen = ({
145
154
  });
146
155
 
147
156
  es.addEventListener("aborted", () => {
157
+ const error = new CodegenError({ name: "Aborted", reason: "Unknown" });
158
+
148
159
  updateStatus((draft) => {
149
160
  draft.status = "aborted";
161
+ (draft.result = null), (draft.error = error);
150
162
  });
163
+
151
164
  resolve({
152
165
  result: null,
153
- error: new Error("The request was aborted"),
166
+ error,
154
167
  });
155
168
  });
156
169
 
@@ -189,35 +202,34 @@ export const useAnimaCodegen = ({
189
202
  });
190
203
 
191
204
  // TODO: For some reason, we receive errors even after the `done` event is triggered.
192
- es.addEventListener("error", (error: ErrorEvent | MessageEvent) => {
193
- // Differentiate between an error message from the server and an error event from the EventSource
194
- if (error instanceof MessageEvent) {
195
- const message = JSON.parse(
196
- error.data
197
- ) as StreamMessageByType<"error">;
198
- updateStatus((draft) => {
199
- draft.status = "error";
200
- draft.error = new Error(message.payload.message);
201
- });
205
+ es.addEventListener("error", async (error: ErrorEvent | MessageEvent) => {
206
+ let errorPayload: StreamMessageByType<"error"> | undefined;
207
+
208
+ try {
209
+ if (error instanceof MessageEvent) {
210
+ errorPayload = JSON.parse(error.data);
211
+ } else {
212
+ const response = await lastFetchResponse;
213
+ errorPayload = await response.json();
214
+ }
215
+ } catch {}
202
216
 
203
- resolve({
204
- result: null,
205
- error: new Error(message.payload.message),
206
- });
207
- } else {
208
- // It's an EventSource error (e.g. HTTP error)
209
- console.error("EventSource error:", error);
217
+ const codegenError = new CodegenError({
218
+ name: errorPayload?.payload.name ?? "Unknown error",
219
+ reason: errorPayload?.payload.message ?? "Unknown",
220
+ status: errorPayload?.payload.status,
221
+ detail: errorPayload?.payload.detail,
222
+ });
210
223
 
211
- updateStatus((draft) => {
212
- draft.status = "error";
213
- draft.error = new Error("HTTP error: " + error.message);
214
- });
224
+ updateStatus((draft) => {
225
+ draft.status = "error";
226
+ draft.error = codegenError;
227
+ });
215
228
 
216
- resolve({
217
- result: null,
218
- error: new Error("HTTP error: " + error.message),
219
- });
220
- }
229
+ resolve({
230
+ result: null,
231
+ error: codegenError,
232
+ });
221
233
  });
222
234
 
223
235
  es.addEventListener("done", (event) => {
@@ -278,13 +290,6 @@ export const useAnimaCodegen = ({
278
290
  return { result: null, error };
279
291
  }
280
292
 
281
- if (Object.keys(result?.files ?? {}).length === 0) {
282
- return {
283
- result: null,
284
- error: new Error("No files received"),
285
- };
286
- }
287
-
288
293
  return { result, error };
289
294
  } finally {
290
295
  es.close();