Skip to content

Commit f84d759

Browse files
committed
chore: use schema to validate input
1 parent b6cde43 commit f84d759

File tree

3 files changed

+339
-19
lines changed

3 files changed

+339
-19
lines changed
Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
import { describe, expect, it } from 'vitest'
2+
3+
import { inputSchema } from '../../../actions/for-each/schema'
4+
5+
describe('inputSchema', () => {
6+
describe('table input format', () => {
7+
it('should parse valid table input', () => {
8+
const validTableInput = JSON.stringify({
9+
rows: [
10+
{
11+
data: { name: 'John', age: 25 },
12+
rowId: 'row1',
13+
},
14+
{
15+
data: { name: 'Jane', age: 30 },
16+
},
17+
],
18+
columns: [
19+
{ id: 'name', name: 'Name', value: 'name' },
20+
{ id: 'age', name: 'Age', value: 'age' },
21+
],
22+
})
23+
24+
const result = inputSchema.safeParse(validTableInput)
25+
26+
expect(result.success).toBe(true)
27+
if (result.success) {
28+
const { type, items } = result.data
29+
expect(type).toBe('table')
30+
if (type === 'table') {
31+
expect(items.rows).toHaveLength(2)
32+
expect(items.columns).toHaveLength(2)
33+
expect(items.rows[0].data.name).toBe('John')
34+
expect(items.rows[0].rowId).toBe('row1')
35+
expect(items.rows[1].rowId).toBeUndefined()
36+
}
37+
}
38+
})
39+
40+
it('should parse table input without rowId', () => {
41+
const validTableInput = JSON.stringify({
42+
rows: [
43+
{
44+
data: { name: 'Alice', score: 95.5 },
45+
},
46+
],
47+
columns: [
48+
{ id: 'name', name: 'Name', value: 'name' },
49+
{ id: 'score', name: 'Score', value: 'score' },
50+
],
51+
})
52+
53+
const result = inputSchema.safeParse(validTableInput)
54+
55+
expect(result.success).toBe(true)
56+
if (result.success) {
57+
expect(result.data.type).toBe('table')
58+
if (result.data.type === 'table') {
59+
expect(result.data.items.rows[0].data.score).toBe(95.5)
60+
}
61+
}
62+
})
63+
64+
it('should trim whitespace before processing', () => {
65+
const validTableInput = JSON.stringify({
66+
rows: [{ data: { name: 'John' } }],
67+
columns: [{ id: 'name', name: 'Name', value: 'name' }],
68+
})
69+
70+
const result = inputSchema.safeParse(` ${validTableInput} `)
71+
72+
expect(result.success).toBe(true)
73+
if (result.success) {
74+
expect(result.data.type).toBe('table')
75+
}
76+
})
77+
78+
it('should reject table input with missing rows', () => {
79+
const invalidTableInput = JSON.stringify({
80+
columns: [{ id: 'name', name: 'Name', value: 'name' }],
81+
})
82+
83+
const result = inputSchema.safeParse(invalidTableInput)
84+
85+
expect(result.success).toBe(false)
86+
if (result.success === false) {
87+
expect(result.error.issues[0].message).toBe(
88+
'Invalid table format: must have rows and columns',
89+
)
90+
}
91+
})
92+
93+
it('should reject table input with missing columns', () => {
94+
const invalidTableInput = JSON.stringify({
95+
rows: [
96+
{
97+
data: { name: 'John' },
98+
},
99+
],
100+
})
101+
102+
const result = inputSchema.safeParse(invalidTableInput)
103+
104+
expect(result.success).toBe(false)
105+
if (result.success === false) {
106+
expect((result as any).error.issues[0].message).toBe(
107+
'Invalid table format: must have rows and columns',
108+
)
109+
}
110+
})
111+
112+
it('should reject table input with invalid row structure', () => {
113+
const invalidTableInput = JSON.stringify({
114+
rows: [
115+
{
116+
invalidField: 'test',
117+
},
118+
],
119+
columns: [{ id: 'name', name: 'Name', value: 'name' }],
120+
})
121+
122+
const result = inputSchema.safeParse(invalidTableInput)
123+
124+
expect(result.success).toBe(false)
125+
if (result.success === false) {
126+
expect(result.error.issues[0].message).toBe(
127+
'Invalid table format: must have rows and columns',
128+
)
129+
}
130+
})
131+
132+
it('should reject table input with invalid column structure', () => {
133+
const invalidTableInput = JSON.stringify({
134+
rows: [
135+
{
136+
data: { name: 'John' },
137+
},
138+
],
139+
columns: [
140+
{ id: 'name', name: 'Name' }, // missing 'value' field
141+
],
142+
})
143+
144+
const result = inputSchema.safeParse(invalidTableInput)
145+
146+
expect(result.success).toBe(false)
147+
if (result.success === false) {
148+
expect(result.error.issues[0].message).toBe(
149+
'Invalid table format: must have rows and columns',
150+
)
151+
}
152+
})
153+
154+
it.each([
155+
'{ "rows": [invalid json',
156+
'{"item1": value1, "item2": "value2"}',
157+
'item1,item2}',
158+
])('should reject malformed JSON', (malformedJson) => {
159+
const result = inputSchema.safeParse(malformedJson)
160+
161+
expect(result.success).toBe(false)
162+
if (result.success === false) {
163+
expect(result.error.issues[0].message).toBe('Invalid JSON format')
164+
}
165+
})
166+
})
167+
168+
describe('checkbox input format', () => {
169+
it('should parse single item', () => {
170+
const result = inputSchema.safeParse('item1')
171+
172+
expect(result.success).toBe(true)
173+
if (result.success) {
174+
expect(result.data.type).toBe('checkbox')
175+
expect(result.data.items).toEqual(['item1'])
176+
}
177+
})
178+
179+
it('should parse multiple comma-separated items', () => {
180+
const result = inputSchema.safeParse('item1,item2,item3')
181+
182+
expect(result.success).toBe(true)
183+
if (result.success) {
184+
expect(result.data.type).toBe('checkbox')
185+
expect(result.data.items).toEqual(['item1', 'item2', 'item3'])
186+
}
187+
})
188+
189+
it('should handle items with spaces', () => {
190+
const result = inputSchema.safeParse('item 1, item 2 , item 3')
191+
192+
expect(result.success).toBe(true)
193+
if (result.success) {
194+
expect(result.data.type).toBe('checkbox')
195+
expect(result.data.items).toEqual(['item 1', ' item 2 ', ' item 3'])
196+
}
197+
})
198+
199+
it('should handle empty items in comma-separated list', () => {
200+
const result = inputSchema.safeParse('item1,,item3')
201+
202+
expect(result.success).toBe(true)
203+
if (result.success) {
204+
expect(result.data.type).toBe('checkbox')
205+
expect(result.data.items).toEqual(['item1', '', 'item3'])
206+
}
207+
})
208+
209+
it('should trim whitespace for checkbox input', () => {
210+
const result = inputSchema.safeParse(' item1,item2 ')
211+
212+
expect(result.success).toBe(true)
213+
if (result.success) {
214+
expect(result.data.type).toBe('checkbox')
215+
expect(result.data.items).toEqual(['item1', 'item2'])
216+
}
217+
})
218+
219+
it('should handle single comma (results in two empty items)', () => {
220+
const result = inputSchema.safeParse(',')
221+
222+
expect(result.success).toBe(true)
223+
if (result.success) {
224+
expect(result.data.type).toBe('checkbox')
225+
expect(result.data.items).toEqual(['', ''])
226+
}
227+
})
228+
})
229+
230+
describe('edge cases and validation', () => {
231+
it('should reject empty string', () => {
232+
const result = inputSchema.safeParse('')
233+
234+
expect(result.success).toBe(false)
235+
if (result.success === false) {
236+
expect(result.error.issues[0].message).toBe('Input cannot be empty')
237+
}
238+
})
239+
240+
it('should reject whitespace-only string', () => {
241+
const result = inputSchema.safeParse(' ')
242+
243+
expect(result.success).toBe(false)
244+
if (result.success === false) {
245+
expect(result.error.issues[0].message).toBe('Input cannot be empty')
246+
}
247+
})
248+
})
249+
})

packages/backend/src/apps/toolbox/actions/for-each/index.ts

Lines changed: 13 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { IRawAction } from '@plumber/types'
33
import StepError from '@/errors/step'
44

55
import getDataOutMetadata from './get-data-out-metadata'
6+
import { inputSchema } from './schema'
67

78
const action: IRawAction = {
89
name: 'For each',
@@ -14,42 +15,35 @@ const action: IRawAction = {
1415
label: 'Choose items',
1516
description:
1617
'Supported items include rows in Tiles/M365 Excel and FormSG checkboxes',
17-
key: 'forEachInputList',
18+
key: 'items',
1819
type: 'string' as const,
1920
required: true,
2021
variables: true,
21-
variableTypes: ['array', 'multiple-row-object'],
22+
variableTypes: ['array', 'table'],
2223
},
2324
],
2425

2526
getDataOutMetadata,
2627

2728
async run($) {
28-
const { forEachInputList } = $.step.parameters as {
29-
forEachInputList: string
30-
}
29+
const { items: rawItems } = $.step.parameters
30+
const parsedResult = inputSchema.safeParse(rawItems)
3131

32-
try {
33-
const isJsonString =
34-
forEachInputList.includes('[') || forEachInputList.includes('{')
35-
const inputList = isJsonString
36-
? JSON.parse(forEachInputList)
37-
: forEachInputList.split(',').map((item) => item.trim())
38-
39-
$.setActionItem({
40-
raw: {
41-
iterations: isJsonString ? inputList.rows.length : inputList.length,
42-
},
43-
})
44-
} catch (err) {
45-
console.error(err)
32+
if (!parsedResult.success) {
4633
throw new StepError(
4734
'Invalid input list',
4835
'Select a valid input list',
4936
$.step.position,
5037
$.app.name,
5138
)
5239
}
40+
41+
const { type, items } = parsedResult.data
42+
$.setActionItem({
43+
raw: {
44+
iterations: type === 'checkbox' ? items.length : items.rows.length,
45+
},
46+
})
5347
},
5448
}
5549

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { z } from 'zod'
2+
3+
// Define specific schemas for each input type
4+
const tableInputSchema = z.object({
5+
rows: z.array(
6+
z.object({
7+
data: z.record(z.string(), z.string().or(z.number())),
8+
rowId: z.string().optional(), // only for tiles
9+
}),
10+
),
11+
columns: z.array(
12+
z.object({
13+
id: z.string(),
14+
name: z.string(),
15+
value: z.string(),
16+
}),
17+
),
18+
})
19+
20+
// Create a discriminated union based on input format
21+
export const inputSchema = z
22+
.string()
23+
.min(1, 'Input cannot be empty')
24+
.transform((str, ctx) => {
25+
const trimmed = str.trim()
26+
27+
if (trimmed.length === 0) {
28+
ctx.addIssue({
29+
code: z.ZodIssueCode.custom,
30+
message: 'Input cannot be empty',
31+
})
32+
return z.NEVER
33+
}
34+
35+
// Handle table input (JSON object)
36+
if (trimmed.startsWith('{') || trimmed.endsWith('}')) {
37+
try {
38+
const parsed = JSON.parse(trimmed)
39+
const result = tableInputSchema.safeParse(parsed)
40+
41+
if (!result.success) {
42+
ctx.addIssue({
43+
code: z.ZodIssueCode.custom,
44+
message: 'Invalid table format: must have rows and columns',
45+
})
46+
return z.NEVER
47+
}
48+
49+
return {
50+
type: 'table' as const,
51+
items: result.data,
52+
}
53+
} catch (error) {
54+
ctx.addIssue({
55+
code: z.ZodIssueCode.custom,
56+
message: 'Invalid JSON format',
57+
})
58+
return z.NEVER
59+
}
60+
}
61+
62+
// Handle checkbox input (comma-separated values)
63+
const items = trimmed.split(',')
64+
65+
if (items.length === 0) {
66+
ctx.addIssue({
67+
code: z.ZodIssueCode.custom,
68+
message: 'No valid items found in comma-separated input',
69+
})
70+
return z.NEVER
71+
}
72+
73+
return {
74+
type: 'checkbox' as const,
75+
items,
76+
}
77+
})

0 commit comments

Comments
 (0)