Skip to content

Commit 5565fa8

Browse files
authored
feat(api): add api endpoint for component props (#198)
* feat(api): add api endpoint for component props * revert lock * coderabbit feedback * update error logic & add tests
1 parent 2ac006c commit 5565fa8

File tree

6 files changed

+945
-6
lines changed

6 files changed

+945
-6
lines changed
Lines changed: 357 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,357 @@
1+
import { GET } from '../../../../../../../pages/api/[version]/[section]/[page]/props'
2+
import { getConfig } from '../../../../../../../../cli/getConfig'
3+
import { sentenceCase } from '../../../../../../../utils/case'
4+
5+
/**
6+
* Mock getConfig to return a test configuration
7+
*/
8+
jest.mock('../../../../../../../../cli/getConfig', () => ({
9+
getConfig: jest.fn().mockResolvedValue({
10+
outputDir: '/mock/output/dir',
11+
}),
12+
}))
13+
14+
/**
15+
* Mock node:path join function
16+
*/
17+
const mockJoin = jest.fn((...paths: string[]) => paths.join('/'))
18+
jest.mock('node:path', () => ({
19+
join: (...args: any[]) => mockJoin(...args),
20+
}))
21+
22+
/**
23+
* Mock node:fs readFileSync function
24+
*/
25+
const mockReadFileSync = jest.fn()
26+
jest.mock('node:fs', () => ({
27+
readFileSync: (...args: any[]) => mockReadFileSync(...args),
28+
}))
29+
30+
/**
31+
* Mock sentenceCase utility
32+
*/
33+
jest.mock('../../../../../../../utils/case', () => ({
34+
sentenceCase: jest.fn((id: string) =>
35+
// Simple mock: convert kebab-case to Sentence Case
36+
id
37+
.split('-')
38+
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
39+
.join(' ')
40+
),
41+
}))
42+
43+
const mockData = {
44+
Alert: {
45+
name: 'Alert',
46+
description: '',
47+
props: [
48+
{
49+
name: 'variant',
50+
type: 'string',
51+
description: 'Alert variant style',
52+
},
53+
],
54+
},
55+
Button: {
56+
name: 'Button',
57+
description: '',
58+
props: [
59+
{
60+
name: 'onClick',
61+
type: 'function',
62+
description: 'Click handler function',
63+
},
64+
],
65+
},
66+
'Sample Data Row': {
67+
name: 'SampleDataRow',
68+
description: '',
69+
props: [
70+
{
71+
name: 'applications',
72+
type: 'number',
73+
description: null,
74+
required: true,
75+
},
76+
],
77+
},
78+
'Dashboard Wrapper': {
79+
name: 'DashboardWrapper',
80+
description: '',
81+
props: [
82+
{
83+
name: 'hasDefaultBreadcrumb',
84+
type: 'boolean',
85+
description: 'Flag to render sample breadcrumb if custom breadcrumb not passed',
86+
},
87+
],
88+
},
89+
'Keyboard Handler': {
90+
name: 'KeyboardHandler',
91+
description: '',
92+
props: [
93+
{
94+
name: 'containerRef',
95+
type: 'React.RefObject<any>',
96+
description: 'Reference of the container to apply keyboard interaction',
97+
defaultValue: 'null',
98+
},
99+
],
100+
},
101+
}
102+
103+
beforeEach(() => {
104+
jest.clearAllMocks()
105+
// Reset process.cwd mock
106+
process.cwd = jest.fn(() => '/mock/workspace')
107+
// Reset mockReadFileSync to return default mock data
108+
mockReadFileSync.mockReturnValue(JSON.stringify(mockData))
109+
})
110+
111+
it('returns props data for a valid page', async () => {
112+
const response = await GET({
113+
params: { version: 'v6', section: 'components', page: 'alert' },
114+
url: new URL('http://localhost:4321/api/v6/components/alert/props'),
115+
} as any)
116+
const body = await response.json()
117+
118+
expect(response.status).toBe(200)
119+
expect(response.headers.get('Content-Type')).toBe('application/json; charset=utf-8')
120+
expect(body).toHaveProperty('name')
121+
expect(body).toHaveProperty('description')
122+
expect(body).toHaveProperty('props')
123+
expect(body.name).toBe('Alert')
124+
expect(Array.isArray(body.props)).toBe(true)
125+
expect(sentenceCase).toHaveBeenCalledWith('alert')
126+
})
127+
128+
it('converts kebab-case page name to sentence case for lookup', async () => {
129+
const response = await GET({
130+
params: { version: 'v6', section: 'components', page: 'sample-data-row' },
131+
url: new URL('http://localhost:4321/api/v6/components/sample-data-row/props'),
132+
} as any)
133+
const body = await response.json()
134+
135+
expect(response.status).toBe(200)
136+
expect(body.name).toBe('SampleDataRow')
137+
expect(sentenceCase).toHaveBeenCalledWith('sample-data-row')
138+
})
139+
140+
it('handles multi-word page names correctly', async () => {
141+
const response = await GET({
142+
params: { version: 'v6', section: 'components', page: 'dashboard-wrapper' },
143+
url: new URL('http://localhost:4321/api/v6/components/dashboard-wrapper/props'),
144+
} as any)
145+
const body = await response.json()
146+
147+
expect(response.status).toBe(200)
148+
expect(body.name).toBe('DashboardWrapper')
149+
expect(sentenceCase).toHaveBeenCalledWith('dashboard-wrapper')
150+
})
151+
152+
it('returns 404 error when props data is not found', async () => {
153+
const response = await GET({
154+
params: { version: 'v6', section: 'components', page: 'nonexistent' },
155+
url: new URL('http://localhost:4321/api/v6/components/nonexistent/props'),
156+
} as any)
157+
const body = await response.json()
158+
159+
expect(response.status).toBe(404)
160+
expect(body).toHaveProperty('error')
161+
expect(body.error).toContain('nonexistent')
162+
expect(body.error).toContain('not found')
163+
})
164+
165+
it('returns 400 error when page parameter is missing', async () => {
166+
const response = await GET({
167+
params: { version: 'v6', section: 'components' },
168+
url: new URL('http://localhost:4321/api/v6/components/props'),
169+
} as any)
170+
const body = await response.json()
171+
172+
expect(response.status).toBe(400)
173+
expect(body).toHaveProperty('error')
174+
expect(body.error).toContain('Page parameter is required')
175+
})
176+
177+
it('returns 500 error when props.json file is not found', async () => {
178+
mockReadFileSync.mockImplementation(() => {
179+
const error = new Error('ENOENT: no such file or directory')
180+
; (error as any).code = 'ENOENT'
181+
throw error
182+
})
183+
184+
const response = await GET({
185+
params: { version: 'v6', section: 'components', page: 'alert' },
186+
url: new URL('http://localhost:4321/api/v6/components/alert/props'),
187+
} as any)
188+
const body = await response.json()
189+
190+
expect(response.status).toBe(500)
191+
expect(body).toHaveProperty('error')
192+
expect(body.error).toBe('Props data not found')
193+
expect(body).toHaveProperty('details')
194+
expect(body.details).toContain('ENOENT')
195+
})
196+
197+
it('returns 500 error when props.json contains invalid JSON', async () => {
198+
mockReadFileSync.mockReturnValue('invalid json content')
199+
200+
const response = await GET({
201+
params: { version: 'v6', section: 'components', page: 'alert' },
202+
url: new URL('http://localhost:4321/api/v6/components/alert/props'),
203+
} as any)
204+
const body = await response.json()
205+
206+
expect(response.status).toBe(500)
207+
expect(body).toHaveProperty('error')
208+
expect(body.error).toBe('Props data not found')
209+
expect(body).toHaveProperty('details')
210+
})
211+
212+
it('returns 500 error when file read throws an error', async () => {
213+
mockReadFileSync.mockImplementation(() => {
214+
throw new Error('Permission denied')
215+
})
216+
217+
const response = await GET({
218+
params: { version: 'v6', section: 'components', page: 'alert' },
219+
url: new URL('http://localhost:4321/api/v6/components/alert/props'),
220+
} as any)
221+
const body = await response.json()
222+
223+
expect(response.status).toBe(500)
224+
expect(body).toHaveProperty('error')
225+
expect(body.error).toBe('Props data not found')
226+
expect(body).toHaveProperty('details')
227+
expect(body.details).toContain('Permission denied')
228+
})
229+
230+
it('uses default outputDir when config does not provide one', async () => {
231+
jest.mocked(getConfig).mockResolvedValueOnce({
232+
content: [],
233+
propsGlobs: [],
234+
outputDir: '',
235+
})
236+
237+
const response = await GET({
238+
params: { version: 'v6', section: 'components', page: 'alert' },
239+
url: new URL('http://localhost:4321/api/v6/components/alert/props'),
240+
} as any)
241+
const body = await response.json()
242+
243+
expect(response.status).toBe(200)
244+
expect(body).toHaveProperty('name')
245+
expect(mockJoin).toHaveBeenCalledWith('/mock/workspace/dist', 'props.json')
246+
})
247+
248+
it('uses custom outputDir from config when provided', async () => {
249+
jest.mocked(getConfig).mockResolvedValueOnce({
250+
outputDir: '/custom/output/path',
251+
content: [],
252+
propsGlobs: [],
253+
})
254+
255+
const response = await GET({
256+
params: { version: 'v6', section: 'components', page: 'alert' },
257+
url: new URL('http://localhost:4321/api/v6/components/alert/props'),
258+
} as any)
259+
const body = await response.json()
260+
261+
expect(response.status).toBe(200)
262+
expect(body).toHaveProperty('name')
263+
// Verify that join was called with custom outputDir
264+
expect(mockJoin).toHaveBeenCalledWith('/custom/output/path', 'props.json')
265+
})
266+
267+
it('reads props.json from the correct file path', async () => {
268+
await GET({
269+
params: { version: 'v6', section: 'components', page: 'alert' },
270+
url: new URL('http://localhost:4321/api/v6/components/alert/props'),
271+
} as any)
272+
273+
// Verify readFileSync was called with the correct path
274+
expect(mockReadFileSync).toHaveBeenCalledWith('/mock/output/dir/props.json')
275+
})
276+
277+
it('returns full props structure with all fields', async () => {
278+
const response = await GET({
279+
params: { version: 'v6', section: 'components', page: 'keyboard-handler' },
280+
url: new URL('http://localhost:4321/api/v6/components/keyboard-handler/props'),
281+
} as any)
282+
const body = await response.json()
283+
284+
expect(response.status).toBe(200)
285+
expect(body).toHaveProperty('name')
286+
expect(body).toHaveProperty('description')
287+
expect(body).toHaveProperty('props')
288+
expect(Array.isArray(body.props)).toBe(true)
289+
expect(body.props.length).toBeGreaterThan(0)
290+
expect(body.props[0]).toHaveProperty('name')
291+
expect(body.props[0]).toHaveProperty('type')
292+
expect(body.props[0]).toHaveProperty('description')
293+
})
294+
295+
it('handles props with defaultValue field', async () => {
296+
const response = await GET({
297+
params: { version: 'v6', section: 'components', page: 'keyboard-handler' },
298+
url: new URL('http://localhost:4321/api/v6/components/keyboard-handler/props'),
299+
} as any)
300+
const body = await response.json()
301+
302+
expect(response.status).toBe(200)
303+
const propWithDefault = body.props.find((p: any) => p.defaultValue !== undefined)
304+
if (propWithDefault) {
305+
expect(propWithDefault).toHaveProperty('defaultValue')
306+
}
307+
})
308+
309+
it('handles props with required field', async () => {
310+
const response = await GET({
311+
params: { version: 'v6', section: 'components', page: 'sample-data-row' },
312+
url: new URL('http://localhost:4321/api/v6/components/sample-data-row/props'),
313+
} as any)
314+
const body = await response.json()
315+
316+
expect(response.status).toBe(200)
317+
const requiredProp = body.props.find((p: any) => p.required === true)
318+
if (requiredProp) {
319+
expect(requiredProp.required).toBe(true)
320+
}
321+
})
322+
323+
it('handles components with empty props array', async () => {
324+
const emptyPropsData = {
325+
'Empty Component': {
326+
name: 'EmptyComponent',
327+
description: '',
328+
props: [],
329+
},
330+
}
331+
mockReadFileSync.mockReturnValueOnce(JSON.stringify(emptyPropsData))
332+
333+
const response = await GET({
334+
params: { version: 'v6', section: 'components', page: 'empty-component' },
335+
url: new URL('http://localhost:4321/api/v6/components/empty-component/props'),
336+
} as any)
337+
const body = await response.json()
338+
339+
expect(response.status).toBe(200)
340+
expect(body.name).toBe('EmptyComponent')
341+
expect(Array.isArray(body.props)).toBe(true)
342+
expect(body.props).toEqual([])
343+
})
344+
345+
it('handles request when tab is in URL path but not in params', async () => {
346+
// Note: props.ts route is at [page] level, so tab parameter is not available
347+
// This test verifies the route works correctly with just page parameter
348+
const response = await GET({
349+
params: { version: 'v6', section: 'components', page: 'alert' },
350+
url: new URL('http://localhost:4321/api/v6/components/alert/react/props'),
351+
} as any)
352+
const body = await response.json()
353+
354+
expect(response.status).toBe(200)
355+
expect(body).toHaveProperty('name')
356+
expect(body.name).toBe('Alert')
357+
})

0 commit comments

Comments
 (0)