ui-components.tsx 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716
  1. 'use client';
  2. import React, { type ReactNode } from 'react';
  3. import { type ComponentRenderProps } from '@json-render/react';
  4. import { useData, useFieldValidation } from '@json-render/react';
  5. import { getByPath } from '@json-render/core';
  6. // Helper to get value from data model
  7. function useResolvedValue<T>(value: T | { path: string } | null | undefined): T | undefined {
  8. const { data } = useData();
  9. if (value === null || value === undefined) {
  10. return undefined;
  11. }
  12. if (typeof value === 'object' && 'path' in value) {
  13. return getByPath(data, value.path) as T | undefined;
  14. }
  15. return value as T;
  16. }
  17. // Styles
  18. const styles = {
  19. card: {
  20. backgroundColor: '#fff',
  21. borderRadius: '8px',
  22. border: '1px solid #e5e7eb',
  23. overflow: 'hidden',
  24. },
  25. cardHeader: {
  26. padding: '16px 20px',
  27. borderBottom: '1px solid #e5e7eb',
  28. },
  29. cardBody: (padding: string | null | undefined) => ({
  30. padding:
  31. padding === 'none' ? 0 :
  32. padding === 'sm' ? '12px' :
  33. padding === 'lg' ? '24px' : '16px',
  34. }),
  35. metric: {
  36. display: 'flex',
  37. flexDirection: 'column' as const,
  38. gap: '4px',
  39. },
  40. metricLabel: {
  41. fontSize: '14px',
  42. color: '#6b7280',
  43. },
  44. metricValue: {
  45. fontSize: '32px',
  46. fontWeight: 600,
  47. color: '#111827',
  48. },
  49. metricTrend: (trend: string | null | undefined) => ({
  50. fontSize: '14px',
  51. color: trend === 'up' ? '#10b981' : trend === 'down' ? '#ef4444' : '#6b7280',
  52. }),
  53. };
  54. // Card component
  55. export function Card({ element, children }: ComponentRenderProps) {
  56. const { title, description, padding } = element.props as {
  57. title?: string | null;
  58. description?: string | null;
  59. padding?: string | null;
  60. };
  61. return (
  62. <div style={styles.card}>
  63. {(title || description) && (
  64. <div style={styles.cardHeader}>
  65. {title && (
  66. <h3 style={{ margin: 0, fontSize: '16px', fontWeight: 600 }}>{title}</h3>
  67. )}
  68. {description && (
  69. <p style={{ margin: '4px 0 0', fontSize: '14px', color: '#6b7280' }}>
  70. {description}
  71. </p>
  72. )}
  73. </div>
  74. )}
  75. <div style={styles.cardBody(padding)}>{children}</div>
  76. </div>
  77. );
  78. }
  79. // Heading component
  80. export function Heading({ element }: ComponentRenderProps) {
  81. const { text, level } = element.props as {
  82. text: string;
  83. level?: string | null;
  84. };
  85. const Tag = (level || 'h2') as keyof JSX.IntrinsicElements;
  86. const sizes: Record<string, string> = {
  87. h1: '28px',
  88. h2: '24px',
  89. h3: '20px',
  90. h4: '16px',
  91. };
  92. return (
  93. <Tag style={{ margin: '0 0 16px', fontSize: sizes[level || 'h2'], fontWeight: 600 }}>
  94. {text}
  95. </Tag>
  96. );
  97. }
  98. // Text component
  99. export function Text({ element }: ComponentRenderProps) {
  100. const { content, variant } = element.props as {
  101. content: string;
  102. variant?: string | null;
  103. };
  104. const colors: Record<string, string> = {
  105. default: '#111827',
  106. muted: '#6b7280',
  107. success: '#10b981',
  108. warning: '#f59e0b',
  109. error: '#ef4444',
  110. };
  111. return (
  112. <p style={{ margin: 0, color: colors[variant || 'default'] }}>{content}</p>
  113. );
  114. }
  115. // Metric component
  116. export function Metric({ element }: ComponentRenderProps) {
  117. const { label, valuePath, format, trend, trendValue } = element.props as {
  118. label: string;
  119. valuePath: string;
  120. format?: string | null;
  121. trend?: string | null;
  122. trendValue?: string | null;
  123. };
  124. const { data } = useData();
  125. const rawValue = getByPath(data, valuePath);
  126. let displayValue = String(rawValue ?? '—');
  127. if (format === 'currency' && typeof rawValue === 'number') {
  128. displayValue = new Intl.NumberFormat('en-US', {
  129. style: 'currency',
  130. currency: 'USD',
  131. }).format(rawValue);
  132. } else if (format === 'percent' && typeof rawValue === 'number') {
  133. displayValue = new Intl.NumberFormat('en-US', {
  134. style: 'percent',
  135. minimumFractionDigits: 1,
  136. }).format(rawValue);
  137. } else if (format === 'number' && typeof rawValue === 'number') {
  138. displayValue = new Intl.NumberFormat('en-US').format(rawValue);
  139. }
  140. return (
  141. <div style={styles.metric}>
  142. <span style={styles.metricLabel}>{label}</span>
  143. <span style={styles.metricValue}>{displayValue}</span>
  144. {(trend || trendValue) && (
  145. <span style={styles.metricTrend(trend)}>
  146. {trend === 'up' ? '↑' : trend === 'down' ? '↓' : ''} {trendValue}
  147. </span>
  148. )}
  149. </div>
  150. );
  151. }
  152. // Chart component (simplified placeholder)
  153. export function Chart({ element }: ComponentRenderProps) {
  154. const { title, dataPath, type } = element.props as {
  155. title?: string | null;
  156. dataPath: string;
  157. type?: string | null;
  158. };
  159. const { data } = useData();
  160. const chartData = getByPath(data, dataPath) as Array<{ label: string; value: number }> | undefined;
  161. if (!chartData || !Array.isArray(chartData)) {
  162. return <div style={{ padding: '20px', color: '#6b7280' }}>No chart data</div>;
  163. }
  164. const maxValue = Math.max(...chartData.map((d) => d.value));
  165. return (
  166. <div>
  167. {title && (
  168. <h4 style={{ margin: '0 0 16px', fontSize: '14px', fontWeight: 600 }}>{title}</h4>
  169. )}
  170. <div style={{ display: 'flex', gap: '8px', alignItems: 'flex-end', height: '120px' }}>
  171. {chartData.map((d, i) => (
  172. <div
  173. key={i}
  174. style={{
  175. flex: 1,
  176. display: 'flex',
  177. flexDirection: 'column',
  178. alignItems: 'center',
  179. gap: '4px',
  180. }}
  181. >
  182. <div
  183. style={{
  184. width: '100%',
  185. height: `${(d.value / maxValue) * 100}%`,
  186. backgroundColor: '#6366f1',
  187. borderRadius: '4px 4px 0 0',
  188. minHeight: '4px',
  189. }}
  190. />
  191. <span style={{ fontSize: '12px', color: '#6b7280' }}>{d.label}</span>
  192. </div>
  193. ))}
  194. </div>
  195. </div>
  196. );
  197. }
  198. // Table component
  199. export function Table({ element }: ComponentRenderProps) {
  200. const { title, dataPath, columns } = element.props as {
  201. title?: string | null;
  202. dataPath: string;
  203. columns: Array<{ key: string; label: string; format?: string | null }>;
  204. };
  205. const { data } = useData();
  206. const tableData = getByPath(data, dataPath) as Array<Record<string, unknown>> | undefined;
  207. if (!tableData || !Array.isArray(tableData)) {
  208. return <div style={{ padding: '20px', color: '#6b7280' }}>No data</div>;
  209. }
  210. const formatCell = (value: unknown, format?: string | null) => {
  211. if (value === null || value === undefined) return '—';
  212. if (format === 'currency' && typeof value === 'number') {
  213. return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(value);
  214. }
  215. if (format === 'date' && typeof value === 'string') {
  216. return new Date(value).toLocaleDateString();
  217. }
  218. if (format === 'badge') {
  219. const colors: Record<string, { bg: string; text: string }> = {
  220. completed: { bg: '#d1fae5', text: '#065f46' },
  221. pending: { bg: '#fef3c7', text: '#92400e' },
  222. failed: { bg: '#fee2e2', text: '#991b1b' },
  223. };
  224. const style = colors[String(value).toLowerCase()] || { bg: '#f3f4f6', text: '#374151' };
  225. return (
  226. <span
  227. style={{
  228. padding: '2px 8px',
  229. borderRadius: '12px',
  230. fontSize: '12px',
  231. fontWeight: 500,
  232. backgroundColor: style.bg,
  233. color: style.text,
  234. }}
  235. >
  236. {String(value)}
  237. </span>
  238. );
  239. }
  240. return String(value);
  241. };
  242. return (
  243. <div>
  244. {title && (
  245. <h4 style={{ margin: '0 0 16px', fontSize: '14px', fontWeight: 600 }}>{title}</h4>
  246. )}
  247. <table style={{ width: '100%', borderCollapse: 'collapse' }}>
  248. <thead>
  249. <tr>
  250. {columns.map((col) => (
  251. <th
  252. key={col.key}
  253. style={{
  254. textAlign: 'left',
  255. padding: '12px 8px',
  256. borderBottom: '1px solid #e5e7eb',
  257. fontSize: '12px',
  258. fontWeight: 600,
  259. color: '#6b7280',
  260. textTransform: 'uppercase',
  261. }}
  262. >
  263. {col.label}
  264. </th>
  265. ))}
  266. </tr>
  267. </thead>
  268. <tbody>
  269. {tableData.map((row, i) => (
  270. <tr key={i}>
  271. {columns.map((col) => (
  272. <td
  273. key={col.key}
  274. style={{
  275. padding: '12px 8px',
  276. borderBottom: '1px solid #e5e7eb',
  277. fontSize: '14px',
  278. }}
  279. >
  280. {formatCell(row[col.key], col.format)}
  281. </td>
  282. ))}
  283. </tr>
  284. ))}
  285. </tbody>
  286. </table>
  287. </div>
  288. );
  289. }
  290. // Button component
  291. export function Button({ element, onAction, loading }: ComponentRenderProps) {
  292. const { label, variant, action, disabled } = element.props as {
  293. label: string;
  294. variant?: string | null;
  295. action: { name: string };
  296. disabled?: boolean | null;
  297. };
  298. const variants: Record<string, React.CSSProperties> = {
  299. primary: { backgroundColor: '#6366f1', color: '#fff', border: 'none' },
  300. secondary: { backgroundColor: '#fff', color: '#374151', border: '1px solid #d1d5db' },
  301. danger: { backgroundColor: '#dc2626', color: '#fff', border: 'none' },
  302. ghost: { backgroundColor: 'transparent', color: '#6b7280', border: 'none' },
  303. };
  304. const handleClick = () => {
  305. if (!disabled && action && onAction) {
  306. onAction(action);
  307. }
  308. };
  309. return (
  310. <button
  311. onClick={handleClick}
  312. disabled={!!disabled || loading}
  313. style={{
  314. padding: '8px 16px',
  315. borderRadius: '6px',
  316. fontSize: '14px',
  317. fontWeight: 500,
  318. cursor: disabled ? 'not-allowed' : 'pointer',
  319. opacity: disabled ? 0.5 : 1,
  320. ...variants[variant || 'primary'],
  321. }}
  322. >
  323. {loading ? 'Loading...' : label}
  324. </button>
  325. );
  326. }
  327. // Alert component
  328. export function Alert({ element }: ComponentRenderProps) {
  329. const { message, variant, dismissible } = element.props as {
  330. message: string | { path: string };
  331. variant?: string | null;
  332. dismissible?: boolean | null;
  333. };
  334. const resolvedMessage = useResolvedValue(message);
  335. const defaultStyle = { bg: '#eff6ff', border: '#bfdbfe', text: '#1e40af' };
  336. const variants: Record<string, { bg: string; border: string; text: string }> = {
  337. info: defaultStyle,
  338. success: { bg: '#d1fae5', border: '#a7f3d0', text: '#065f46' },
  339. warning: { bg: '#fef3c7', border: '#fcd34d', text: '#92400e' },
  340. error: { bg: '#fee2e2', border: '#fca5a5', text: '#991b1b' },
  341. };
  342. const style = variants[variant || 'info'] ?? defaultStyle;
  343. return (
  344. <div
  345. style={{
  346. padding: '12px 16px',
  347. borderRadius: '8px',
  348. backgroundColor: style.bg,
  349. border: `1px solid ${style.border}`,
  350. color: style.text,
  351. fontSize: '14px',
  352. }}
  353. >
  354. {resolvedMessage}
  355. </div>
  356. );
  357. }
  358. // Grid component
  359. export function Grid({ element, children }: ComponentRenderProps) {
  360. const { columns, gap } = element.props as {
  361. columns?: number | null;
  362. gap?: string | null;
  363. };
  364. const gaps: Record<string, string> = {
  365. none: '0',
  366. sm: '8px',
  367. md: '16px',
  368. lg: '24px',
  369. };
  370. return (
  371. <div
  372. style={{
  373. display: 'grid',
  374. gridTemplateColumns: `repeat(${columns || 2}, 1fr)`,
  375. gap: gaps[gap || 'md'],
  376. }}
  377. >
  378. {children}
  379. </div>
  380. );
  381. }
  382. // Stack component
  383. export function Stack({ element, children }: ComponentRenderProps) {
  384. const { direction, gap, align } = element.props as {
  385. direction?: string | null;
  386. gap?: string | null;
  387. align?: string | null;
  388. };
  389. const gaps: Record<string, string> = {
  390. none: '0',
  391. sm: '8px',
  392. md: '16px',
  393. lg: '24px',
  394. };
  395. const alignments: Record<string, string> = {
  396. start: 'flex-start',
  397. center: 'center',
  398. end: 'flex-end',
  399. stretch: 'stretch',
  400. };
  401. return (
  402. <div
  403. style={{
  404. display: 'flex',
  405. flexDirection: direction === 'horizontal' ? 'row' : 'column',
  406. gap: gaps[gap || 'md'],
  407. alignItems: alignments[align || 'stretch'],
  408. }}
  409. >
  410. {children}
  411. </div>
  412. );
  413. }
  414. // Divider component
  415. export function Divider({ element }: ComponentRenderProps) {
  416. const { orientation } = element.props as {
  417. orientation?: string | null;
  418. };
  419. if (orientation === 'vertical') {
  420. return (
  421. <div
  422. style={{
  423. width: '1px',
  424. backgroundColor: '#e5e7eb',
  425. alignSelf: 'stretch',
  426. }}
  427. />
  428. );
  429. }
  430. return (
  431. <hr
  432. style={{
  433. border: 'none',
  434. borderTop: '1px solid #e5e7eb',
  435. margin: '16px 0',
  436. }}
  437. />
  438. );
  439. }
  440. // Badge component
  441. export function Badge({ element }: ComponentRenderProps) {
  442. const { text, variant } = element.props as {
  443. text: string | { path: string };
  444. variant?: string | null;
  445. };
  446. const resolvedText = useResolvedValue(text);
  447. const defaultBadgeStyle = { bg: '#f3f4f6', text: '#374151' };
  448. const variants: Record<string, { bg: string; text: string }> = {
  449. default: defaultBadgeStyle,
  450. success: { bg: '#d1fae5', text: '#065f46' },
  451. warning: { bg: '#fef3c7', text: '#92400e' },
  452. error: { bg: '#fee2e2', text: '#991b1b' },
  453. info: { bg: '#dbeafe', text: '#1e40af' },
  454. };
  455. const style = variants[variant || 'default'] ?? defaultBadgeStyle;
  456. return (
  457. <span
  458. style={{
  459. display: 'inline-block',
  460. padding: '2px 8px',
  461. borderRadius: '12px',
  462. fontSize: '12px',
  463. fontWeight: 500,
  464. backgroundColor: style.bg,
  465. color: style.text,
  466. }}
  467. >
  468. {resolvedText}
  469. </span>
  470. );
  471. }
  472. // TextField component
  473. export function TextField({ element }: ComponentRenderProps) {
  474. const { label, valuePath, placeholder, type, checks, validateOn } = element.props as {
  475. label: string;
  476. valuePath: string;
  477. placeholder?: string | null;
  478. type?: string | null;
  479. checks?: Array<{ fn: string; message: string }> | null;
  480. validateOn?: string | null;
  481. };
  482. const { data, set } = useData();
  483. const value = getByPath(data, valuePath) as string | undefined;
  484. const { errors, validate, touch } = useFieldValidation(valuePath, {
  485. checks: checks ?? undefined,
  486. validateOn: (validateOn as 'change' | 'blur' | 'submit') ?? 'blur',
  487. });
  488. const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
  489. set(valuePath, e.target.value);
  490. if (validateOn === 'change') {
  491. validate();
  492. }
  493. };
  494. const handleBlur = () => {
  495. touch();
  496. if (validateOn === 'blur' || !validateOn) {
  497. validate();
  498. }
  499. };
  500. return (
  501. <div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
  502. <label style={{ fontSize: '14px', fontWeight: 500, color: '#374151' }}>
  503. {label}
  504. </label>
  505. <input
  506. type={type || 'text'}
  507. value={value ?? ''}
  508. onChange={handleChange}
  509. onBlur={handleBlur}
  510. placeholder={placeholder ?? ''}
  511. style={{
  512. padding: '8px 12px',
  513. borderRadius: '6px',
  514. border: errors.length > 0 ? '1px solid #ef4444' : '1px solid #d1d5db',
  515. fontSize: '14px',
  516. outline: 'none',
  517. }}
  518. />
  519. {errors.map((error, i) => (
  520. <span key={i} style={{ fontSize: '12px', color: '#ef4444' }}>
  521. {error}
  522. </span>
  523. ))}
  524. </div>
  525. );
  526. }
  527. // Select component
  528. export function Select({ element }: ComponentRenderProps) {
  529. const { label, valuePath, options, placeholder } = element.props as {
  530. label: string;
  531. valuePath: string;
  532. options: Array<{ value: string; label: string }>;
  533. placeholder?: string | null;
  534. };
  535. const { data, set } = useData();
  536. const value = getByPath(data, valuePath) as string | undefined;
  537. return (
  538. <div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
  539. <label style={{ fontSize: '14px', fontWeight: 500, color: '#374151' }}>
  540. {label}
  541. </label>
  542. <select
  543. value={value ?? ''}
  544. onChange={(e) => set(valuePath, e.target.value)}
  545. style={{
  546. padding: '8px 12px',
  547. borderRadius: '6px',
  548. border: '1px solid #d1d5db',
  549. fontSize: '14px',
  550. outline: 'none',
  551. backgroundColor: '#fff',
  552. }}
  553. >
  554. {placeholder && <option value="">{placeholder}</option>}
  555. {options.map((opt) => (
  556. <option key={opt.value} value={opt.value}>
  557. {opt.label}
  558. </option>
  559. ))}
  560. </select>
  561. </div>
  562. );
  563. }
  564. // DatePicker component (simplified)
  565. export function DatePicker({ element }: ComponentRenderProps) {
  566. const { label, valuePath } = element.props as {
  567. label: string;
  568. valuePath: string;
  569. };
  570. const { data, set } = useData();
  571. const value = getByPath(data, valuePath) as string | undefined;
  572. return (
  573. <div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
  574. <label style={{ fontSize: '14px', fontWeight: 500, color: '#374151' }}>
  575. {label}
  576. </label>
  577. <input
  578. type="date"
  579. value={value ?? ''}
  580. onChange={(e) => set(valuePath, e.target.value)}
  581. style={{
  582. padding: '8px 12px',
  583. borderRadius: '6px',
  584. border: '1px solid #d1d5db',
  585. fontSize: '14px',
  586. outline: 'none',
  587. }}
  588. />
  589. </div>
  590. );
  591. }
  592. // Empty component
  593. export function Empty({ element }: ComponentRenderProps) {
  594. const { icon, title, description } = element.props as {
  595. icon?: string | null;
  596. title: string;
  597. description?: string | null;
  598. };
  599. return (
  600. <div style={{ textAlign: 'center', padding: '40px 20px' }}>
  601. {icon && <div style={{ fontSize: '48px', marginBottom: '16px' }}>{icon}</div>}
  602. <h3 style={{ margin: '0 0 8px', fontSize: '16px', fontWeight: 600, color: '#111827' }}>
  603. {title}
  604. </h3>
  605. {description && (
  606. <p style={{ margin: 0, fontSize: '14px', color: '#6b7280' }}>{description}</p>
  607. )}
  608. </div>
  609. );
  610. }
  611. // List component
  612. export function List({ element, children }: ComponentRenderProps) {
  613. const { dataPath } = element.props as {
  614. dataPath: string;
  615. itemKey?: string | null;
  616. };
  617. const { data } = useData();
  618. const listData = getByPath(data, dataPath) as Array<unknown> | undefined;
  619. if (!listData || !Array.isArray(listData)) {
  620. return <div style={{ color: '#6b7280' }}>No items</div>;
  621. }
  622. // For now, just render children
  623. // In a full implementation, children would be cloned for each item
  624. return <div>{children}</div>;
  625. }
  626. // Export all components as a registry
  627. export const componentRegistry = {
  628. Card,
  629. Heading,
  630. Text,
  631. Metric,
  632. Chart,
  633. Table,
  634. Button,
  635. Alert,
  636. Grid,
  637. Stack,
  638. Divider,
  639. Badge,
  640. TextField,
  641. Select,
  642. DatePicker,
  643. Empty,
  644. List,
  645. };