@t3q-ui/design-system 0.0.0-stage → 1.56.0
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/CHANGELOG.md +1977 -0
- package/LICENSE.md +88 -0
- package/README.md +464 -2
- package/cli/doctor.mjs +196 -0
- package/cli/lib.mjs +425 -0
- package/cli/setup.mjs +181 -0
- package/cli/t3q-design.mjs +78 -0
- package/dist/chunk-IPF3K4BS.js +4274 -0
- package/dist/chunk-IPF3K4BS.js.map +1 -0
- package/dist/chunk-MHAO45AL.cjs +4427 -0
- package/dist/chunk-MHAO45AL.cjs.map +1 -0
- package/dist/fonts.css +12 -0
- package/dist/index-Ci1Wh5sA.d.cts +253 -0
- package/dist/index-Ci1Wh5sA.d.ts +253 -0
- package/dist/index.cjs +10998 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.css +332 -0
- package/dist/index.css.map +1 -0
- package/dist/index.d.cts +2554 -0
- package/dist/index.d.ts +2554 -0
- package/dist/index.js +10354 -0
- package/dist/index.js.map +1 -0
- package/dist/patterns/index.cjs +885 -0
- package/dist/patterns/index.cjs.map +1 -0
- package/dist/patterns/index.css +265 -0
- package/dist/patterns/index.css.map +1 -0
- package/dist/patterns/index.d.cts +373 -0
- package/dist/patterns/index.d.ts +373 -0
- package/dist/patterns/index.js +873 -0
- package/dist/patterns/index.js.map +1 -0
- package/dist/styles.css +2014 -0
- package/package.json +212 -4
- package/skills/tds/SKILL.md +91 -0
- package/skills/tds/examples/dashboard-page.tsx +344 -0
- package/skills/tds/examples/detail-page.tsx +388 -0
- package/skills/tds/examples/form-page.tsx +460 -0
- package/skills/tds/examples/list-page.tsx +275 -0
- package/skills/tds/reference/components.md +1424 -0
- package/skills/tds/rules.md +28 -0
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TDS 생성 폼 예제 — "PageHeader → 섹션 4개(구분선) → 누르면 검증 → 취소 확인 → 성공 toast" 흐름.
|
|
3
|
+
* 이 파일은 TDS 저장소의 타입 검사(`pnpm typecheck`)와 렌더 테스트를 통과한 코드다. 전용 URL 을 가진 생성·수정 화면은 이 구조에서 시작한다.
|
|
4
|
+
*
|
|
5
|
+
* 이 예제가 보여 주는 것 (자주 틀리는 곳):
|
|
6
|
+
* - FormPage 가 머리(PageHeader)·푸터 버튼·전체 오류를 그린다. 제목·버튼 줄을 직접 만들지 않는다
|
|
7
|
+
* - 저장(생성) 버튼은 기본(`submitMode='always'`)으로 **항상 눌리고**, 누르면 react-hook-form 이 검증해 틀린 항목마다 `FormMessage` 를 보인다.
|
|
8
|
+
* `useForm({ mode: 'onSubmit', reValidateMode: 'onChange' })` + 필드 `rules` 로 충분하다 — 유효해질 때까지 잠그고 싶을 때만 `submitMode='whenValid'`
|
|
9
|
+
* - 필수 항목은 `FormLabel required` + `requiredLegend` 안내 한 줄
|
|
10
|
+
* - 숫자 입력의 단위는 `TextField elemAfterInput='개'` → 입력 칸 오른쪽에 한 줄로 붙는다. 라벨에 "(GB)" 를 넣지 않는다
|
|
11
|
+
* - 선택지 목록(실행환경)을 못 불러왔으면 **폼을 지우지 말고** 그 섹션 안에 `Alert variant='danger'` 한 줄(제목 + 설명 + `AlertActions` 의 다시 시도 링크)을 둔다. 화면 전체 실패가 아니라 `ErrorState` 상자는 쓰지 않는다. 불러오는 동안은 `Skeleton`
|
|
12
|
+
* - 조건부 필드(주기 → cron, 알림 켬 → 이메일)는 `form.watch` 로 보이고, 숨길 때 값·오류를 지운다
|
|
13
|
+
* - 입력한 내용이 있을 때(`formState.isDirty`) 취소는 `AlertDialog`(variant warning)로 확인받는다.
|
|
14
|
+
* `isDirty` 는 **렌더 중에 읽어 둔다**(`const { isDirty } = form.formState`) — react-hook-form 은 렌더에서 읽은 값만 구독해 갱신하므로,
|
|
15
|
+
* 콜백 안에서만 `form.formState.isDirty` 를 읽으면 입력해도 false 그대로라 확인창이 뜨지 않는다
|
|
16
|
+
* - 제출 중 상태는 앱이 `submitting` 으로 갖고 `FormPage submitting` 에 넘긴다(try/finally) → 저장·취소가 잠기고 스피너가 보여 중복 제출을 막는다
|
|
17
|
+
* - 서버 오류는 FormPage `error` (푸터 위 Alert), 성공은 `toast`
|
|
18
|
+
* - 검토·요약 패널은 `FormPage aside` 로 폼 옆에 둔다(패턴이 세로선·sticky 로 그린다). FormPage 를 flex 로 감싸 옆에 `<aside>` 를 두면
|
|
19
|
+
* 헤더가 화면 폭을 다 못 써 구분선이 끊긴다
|
|
20
|
+
* - 도움말(`FormDescription`)은 **규칙·전제·추천값**을 적는다 — placeholder 와 같은 말("1~32")을 되풀이하지 않는다. placeholder 는 "예: 4" 같은 보기.
|
|
21
|
+
* 검토 패널의 빈 값은 "—" 하나(단위는 값이 있을 때만 — "—코어" 금지), 항목은 한 행에 하나(여러 값을 "·" 로 이어 붙이지 않는다)
|
|
22
|
+
* - 검토 패널(`aside`)이 있으면 취소·제출 버튼은 FormPage 가 **패널 바닥**에 그린다 — 폼 끝에 버튼을 따로 두지 않는다(2026-10-20 16차 D1: 긴 폼 옆 패널 아래 빈 공간·먼 제출 버튼 감점)
|
|
23
|
+
*/
|
|
24
|
+
import { useCallback, useEffect, useState } from 'react'
|
|
25
|
+
import { useForm } from 'react-hook-form'
|
|
26
|
+
import {
|
|
27
|
+
Alert,
|
|
28
|
+
AlertActions,
|
|
29
|
+
AlertDescription,
|
|
30
|
+
AlertDialog,
|
|
31
|
+
AlertDialogAction,
|
|
32
|
+
AlertDialogCancel,
|
|
33
|
+
AlertDialogContent,
|
|
34
|
+
AlertDialogDescription,
|
|
35
|
+
AlertDialogFooter,
|
|
36
|
+
AlertDialogHeader,
|
|
37
|
+
AlertDialogTitle,
|
|
38
|
+
AlertTitle,
|
|
39
|
+
Button,
|
|
40
|
+
FormControl,
|
|
41
|
+
FormDescription,
|
|
42
|
+
FormField,
|
|
43
|
+
FormItem,
|
|
44
|
+
FormLabel,
|
|
45
|
+
FormMessage,
|
|
46
|
+
FormSection,
|
|
47
|
+
LucideIcon,
|
|
48
|
+
MetaList,
|
|
49
|
+
MetaRow,
|
|
50
|
+
RadioSection,
|
|
51
|
+
RadioSectionGroup,
|
|
52
|
+
Select,
|
|
53
|
+
SelectContent,
|
|
54
|
+
SelectItem,
|
|
55
|
+
SelectTrigger,
|
|
56
|
+
SelectValue,
|
|
57
|
+
Skeleton,
|
|
58
|
+
SwitchSection,
|
|
59
|
+
Textarea,
|
|
60
|
+
TextField,
|
|
61
|
+
toast,
|
|
62
|
+
} from '@t3q-ui/design-system'
|
|
63
|
+
import { FormPage } from '@t3q-ui/design-system/patterns'
|
|
64
|
+
|
|
65
|
+
export type Runtime = { id: string; name: string; description?: string }
|
|
66
|
+
type Values = {
|
|
67
|
+
name: string
|
|
68
|
+
description: string
|
|
69
|
+
runtimeId: string
|
|
70
|
+
schedule: 'manual' | 'cron'
|
|
71
|
+
cron: string
|
|
72
|
+
cpu: string
|
|
73
|
+
memoryGb: string
|
|
74
|
+
gpu: string
|
|
75
|
+
notifyOnFailure: boolean
|
|
76
|
+
email: string
|
|
77
|
+
}
|
|
78
|
+
const DEFAULTS: Values = {
|
|
79
|
+
name: '',
|
|
80
|
+
description: '',
|
|
81
|
+
runtimeId: '',
|
|
82
|
+
schedule: 'manual',
|
|
83
|
+
cron: '',
|
|
84
|
+
cpu: '',
|
|
85
|
+
memoryGb: '',
|
|
86
|
+
gpu: '0',
|
|
87
|
+
notifyOnFailure: false,
|
|
88
|
+
email: '',
|
|
89
|
+
}
|
|
90
|
+
const intInRange = (min: number, max: number, label: string) => (v: string) =>
|
|
91
|
+
/^\d+$/.test(v) && Number(v) >= min && Number(v) <= max
|
|
92
|
+
? true
|
|
93
|
+
: `${label}은(는) ${min}~${max} 사이 정수여야 합니다.`
|
|
94
|
+
|
|
95
|
+
type RuntimeState =
|
|
96
|
+
| { status: 'loading' }
|
|
97
|
+
| { status: 'error' }
|
|
98
|
+
| { status: 'ready'; runtimes: Runtime[] }
|
|
99
|
+
|
|
100
|
+
export function PipelineCreatePage({
|
|
101
|
+
fetchRuntimes,
|
|
102
|
+
createPipeline,
|
|
103
|
+
onLeave,
|
|
104
|
+
}: {
|
|
105
|
+
fetchRuntimes: () => Promise<Runtime[]>
|
|
106
|
+
createPipeline: (values: Values) => Promise<void>
|
|
107
|
+
onLeave: () => void
|
|
108
|
+
}) {
|
|
109
|
+
const form = useForm<Values>({
|
|
110
|
+
defaultValues: DEFAULTS,
|
|
111
|
+
mode: 'onSubmit',
|
|
112
|
+
reValidateMode: 'onChange',
|
|
113
|
+
})
|
|
114
|
+
const schedule = form.watch('schedule')
|
|
115
|
+
const notifyOnFailure = form.watch('notifyOnFailure')
|
|
116
|
+
// 검토 패널에 보일 입력값 — watch() 는 렌더마다 최신 값을 준다
|
|
117
|
+
const values = form.watch()
|
|
118
|
+
const [runtimes, setRuntimes] = useState<RuntimeState>({ status: 'loading' })
|
|
119
|
+
const [serverError, setServerError] = useState<string | null>(null)
|
|
120
|
+
const [confirmCancel, setConfirmCancel] = useState(false)
|
|
121
|
+
// 제출 중 상태를 앱 상태로 드러낸다 — FormPage 가 이 동안 저장·취소 버튼을 잠그고 스피너를 보인다(중복 제출 방지)
|
|
122
|
+
const [submitting, setSubmitting] = useState(false)
|
|
123
|
+
const { isDirty } = form.formState // 렌더에서 읽어야 RHF 가 구독해 갱신한다
|
|
124
|
+
|
|
125
|
+
const loadRuntimes = useCallback(() => {
|
|
126
|
+
setRuntimes({ status: 'loading' })
|
|
127
|
+
fetchRuntimes().then(
|
|
128
|
+
(list) => setRuntimes({ status: 'ready', runtimes: list }),
|
|
129
|
+
() => setRuntimes({ status: 'error' }),
|
|
130
|
+
)
|
|
131
|
+
}, [fetchRuntimes])
|
|
132
|
+
useEffect(loadRuntimes, [loadRuntimes])
|
|
133
|
+
|
|
134
|
+
const handleSubmit = async (values: Values) => {
|
|
135
|
+
setServerError(null)
|
|
136
|
+
setSubmitting(true)
|
|
137
|
+
try {
|
|
138
|
+
await createPipeline(values)
|
|
139
|
+
toast({
|
|
140
|
+
title: '파이프라인을 생성했습니다',
|
|
141
|
+
description: `${values.name} 을(를) 만들었습니다.`,
|
|
142
|
+
variant: 'success',
|
|
143
|
+
})
|
|
144
|
+
onLeave()
|
|
145
|
+
} catch {
|
|
146
|
+
setServerError('파이프라인을 생성하지 못했습니다. 잠시 후 다시 시도해 주세요.')
|
|
147
|
+
} finally {
|
|
148
|
+
setSubmitting(false)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return (
|
|
153
|
+
<>
|
|
154
|
+
<FormPage
|
|
155
|
+
title='파이프라인 생성'
|
|
156
|
+
description='실행환경과 리소스, 스케줄을 정해 새 파이프라인을 만듭니다.'
|
|
157
|
+
form={form}
|
|
158
|
+
onSubmit={handleSubmit}
|
|
159
|
+
onCancel={() => (isDirty ? setConfirmCancel(true) : onLeave())}
|
|
160
|
+
submitLabel='생성'
|
|
161
|
+
submitting={submitting}
|
|
162
|
+
error={serverError}
|
|
163
|
+
requiredLegend
|
|
164
|
+
asideTitle='검토'
|
|
165
|
+
aside={
|
|
166
|
+
<MetaList>
|
|
167
|
+
<MetaRow align='start' label='이름' value={values.name || '—'} />
|
|
168
|
+
<MetaRow
|
|
169
|
+
align='start'
|
|
170
|
+
label='실행환경'
|
|
171
|
+
value={
|
|
172
|
+
(runtimes.status === 'ready' &&
|
|
173
|
+
runtimes.runtimes.find((r) => r.id === values.runtimeId)?.name) ||
|
|
174
|
+
'—'
|
|
175
|
+
}
|
|
176
|
+
/>
|
|
177
|
+
<MetaRow
|
|
178
|
+
align='start'
|
|
179
|
+
label='스케줄'
|
|
180
|
+
value={values.schedule === 'cron' ? values.cron || '—' : '수동 실행'}
|
|
181
|
+
/>
|
|
182
|
+
{/* 빈 값은 "—" 하나 — 단위는 값이 있을 때만 붙인다("—코어" 는 어색하다). 값마다 한 행 */}
|
|
183
|
+
<MetaRow align='start' label='CPU' value={values.cpu ? `${values.cpu} 코어` : '—'} />
|
|
184
|
+
<MetaRow
|
|
185
|
+
align='start'
|
|
186
|
+
label='메모리'
|
|
187
|
+
value={values.memoryGb ? `${values.memoryGb} GB` : '—'}
|
|
188
|
+
/>
|
|
189
|
+
<MetaRow align='start' label='GPU' value={values.gpu ? `${values.gpu}개` : '—'} />
|
|
190
|
+
<MetaRow
|
|
191
|
+
align='start'
|
|
192
|
+
label='실패 알림'
|
|
193
|
+
value={values.notifyOnFailure ? values.email || '—' : '끔'}
|
|
194
|
+
/>
|
|
195
|
+
</MetaList>
|
|
196
|
+
}
|
|
197
|
+
>
|
|
198
|
+
<FormSection title='기본 정보'>
|
|
199
|
+
<FormField
|
|
200
|
+
control={form.control}
|
|
201
|
+
name='name'
|
|
202
|
+
rules={{
|
|
203
|
+
required: '이름을 입력하세요.',
|
|
204
|
+
pattern: {
|
|
205
|
+
value: /^[a-z0-9-]{3,40}$/,
|
|
206
|
+
message: '영문 소문자·숫자·하이픈으로 3~40자여야 합니다.',
|
|
207
|
+
},
|
|
208
|
+
}}
|
|
209
|
+
render={({ field }) => (
|
|
210
|
+
<FormItem>
|
|
211
|
+
<FormLabel required>이름</FormLabel>
|
|
212
|
+
<FormControl>
|
|
213
|
+
<TextField {...field} placeholder='예: daily-sales-etl' />
|
|
214
|
+
</FormControl>
|
|
215
|
+
{/* 도움말은 규칙 + 전제(왜 바꿀 수 없는지) — placeholder 를 되풀이하지 않는다 */}
|
|
216
|
+
<FormDescription>
|
|
217
|
+
실행 경로에 쓰여 만든 뒤에는 바꿀 수 없습니다. 영문 소문자·숫자·하이픈 3~40자
|
|
218
|
+
</FormDescription>
|
|
219
|
+
<FormMessage />
|
|
220
|
+
</FormItem>
|
|
221
|
+
)}
|
|
222
|
+
/>
|
|
223
|
+
<FormField
|
|
224
|
+
control={form.control}
|
|
225
|
+
name='description'
|
|
226
|
+
rules={{ maxLength: { value: 200, message: '200자 이하로 적어 주세요.' } }}
|
|
227
|
+
render={({ field }) => (
|
|
228
|
+
<FormItem>
|
|
229
|
+
<FormLabel>설명</FormLabel>
|
|
230
|
+
<FormControl>
|
|
231
|
+
<Textarea
|
|
232
|
+
{...field}
|
|
233
|
+
placeholder='파이프라인이 하는 일을 적어 주세요.'
|
|
234
|
+
minRows={3}
|
|
235
|
+
/>
|
|
236
|
+
</FormControl>
|
|
237
|
+
<FormDescription>{field.value.length} / 200자</FormDescription>
|
|
238
|
+
<FormMessage />
|
|
239
|
+
</FormItem>
|
|
240
|
+
)}
|
|
241
|
+
/>
|
|
242
|
+
</FormSection>
|
|
243
|
+
|
|
244
|
+
<FormSection title='실행 설정'>
|
|
245
|
+
<FormField
|
|
246
|
+
control={form.control}
|
|
247
|
+
name='runtimeId'
|
|
248
|
+
rules={{ required: '실행환경을 선택하세요.' }}
|
|
249
|
+
render={({ field }) => (
|
|
250
|
+
<FormItem>
|
|
251
|
+
<FormLabel required>실행환경</FormLabel>
|
|
252
|
+
{runtimes.status === 'loading' ? <Skeleton className='h-[36px] w-full' /> : null}
|
|
253
|
+
{/* 선택지를 못 불러와도 폼은 그대로 두고 이 섹션 안에만 Alert(danger) 한 줄 + 다시 시도 링크. 화면 전체 실패가 아니라서 ErrorState 상자는 쓰지 않는다 */}
|
|
254
|
+
{runtimes.status === 'error' ? (
|
|
255
|
+
<Alert variant='danger'>
|
|
256
|
+
<LucideIcon iconComponentName='CircleAlert' size={20} />
|
|
257
|
+
<AlertTitle>실행환경 목록을 불러오지 못했습니다</AlertTitle>
|
|
258
|
+
<AlertDescription>
|
|
259
|
+
네트워크 상태를 확인한 뒤 다시 시도해 주세요.
|
|
260
|
+
</AlertDescription>
|
|
261
|
+
<AlertActions>
|
|
262
|
+
<Button type='button' variant='secondaryLink' onClick={loadRuntimes}>
|
|
263
|
+
다시 시도
|
|
264
|
+
</Button>
|
|
265
|
+
</AlertActions>
|
|
266
|
+
</Alert>
|
|
267
|
+
) : null}
|
|
268
|
+
{runtimes.status === 'ready' ? (
|
|
269
|
+
<FormControl>
|
|
270
|
+
<Select value={field.value} onValueChange={field.onChange}>
|
|
271
|
+
<SelectTrigger>
|
|
272
|
+
<SelectValue placeholder='실행환경 선택' />
|
|
273
|
+
</SelectTrigger>
|
|
274
|
+
<SelectContent>
|
|
275
|
+
{runtimes.runtimes.map((rt) => (
|
|
276
|
+
<SelectItem key={rt.id} value={rt.id} description={rt.description}>
|
|
277
|
+
{rt.name}
|
|
278
|
+
</SelectItem>
|
|
279
|
+
))}
|
|
280
|
+
</SelectContent>
|
|
281
|
+
</Select>
|
|
282
|
+
</FormControl>
|
|
283
|
+
) : null}
|
|
284
|
+
<FormMessage />
|
|
285
|
+
</FormItem>
|
|
286
|
+
)}
|
|
287
|
+
/>
|
|
288
|
+
<FormField
|
|
289
|
+
control={form.control}
|
|
290
|
+
name='schedule'
|
|
291
|
+
render={({ field }) => (
|
|
292
|
+
<FormItem>
|
|
293
|
+
<FormLabel id='schedule-label'>스케줄</FormLabel>
|
|
294
|
+
{/* 유형·방식 선택은 카드형 RadioSectionGroup — 선택지마다 제목·설명 (dl-ui-v3 관례). 설명 없는 짧은 선택만 RadioGroup */}
|
|
295
|
+
<FormControl>
|
|
296
|
+
<RadioSectionGroup
|
|
297
|
+
aria-labelledby='schedule-label'
|
|
298
|
+
value={field.value}
|
|
299
|
+
onValueChange={(v) => {
|
|
300
|
+
field.onChange(v)
|
|
301
|
+
if (v === 'manual') {
|
|
302
|
+
form.setValue('cron', '')
|
|
303
|
+
form.clearErrors('cron')
|
|
304
|
+
}
|
|
305
|
+
}}
|
|
306
|
+
>
|
|
307
|
+
<RadioSection
|
|
308
|
+
value='manual'
|
|
309
|
+
title='수동'
|
|
310
|
+
description='필요할 때 직접 실행합니다'
|
|
311
|
+
/>
|
|
312
|
+
<RadioSection
|
|
313
|
+
value='cron'
|
|
314
|
+
title='주기'
|
|
315
|
+
description='cron 식에 따라 반복 실행합니다'
|
|
316
|
+
/>
|
|
317
|
+
</RadioSectionGroup>
|
|
318
|
+
</FormControl>
|
|
319
|
+
</FormItem>
|
|
320
|
+
)}
|
|
321
|
+
/>
|
|
322
|
+
{schedule === 'cron' ? (
|
|
323
|
+
<FormField
|
|
324
|
+
control={form.control}
|
|
325
|
+
name='cron'
|
|
326
|
+
rules={{ required: 'cron 식을 입력하세요.' }}
|
|
327
|
+
render={({ field }) => (
|
|
328
|
+
<FormItem>
|
|
329
|
+
<FormLabel required>cron 식</FormLabel>
|
|
330
|
+
<FormControl>
|
|
331
|
+
<TextField {...field} placeholder='예: 0 3 * * *' />
|
|
332
|
+
</FormControl>
|
|
333
|
+
<FormDescription>
|
|
334
|
+
분 시 일 월 요일 순서. 예: 매일 03:00 은 0 3 * * *
|
|
335
|
+
</FormDescription>
|
|
336
|
+
<FormMessage />
|
|
337
|
+
</FormItem>
|
|
338
|
+
)}
|
|
339
|
+
/>
|
|
340
|
+
) : null}
|
|
341
|
+
</FormSection>
|
|
342
|
+
|
|
343
|
+
<FormSection title='리소스'>
|
|
344
|
+
<FormField
|
|
345
|
+
control={form.control}
|
|
346
|
+
name='cpu'
|
|
347
|
+
rules={{
|
|
348
|
+
required: 'CPU 코어 수를 입력하세요.',
|
|
349
|
+
validate: intInRange(1, 32, 'CPU 코어'),
|
|
350
|
+
}}
|
|
351
|
+
render={({ field }) => (
|
|
352
|
+
<FormItem>
|
|
353
|
+
<FormLabel required>CPU</FormLabel>
|
|
354
|
+
<FormControl>
|
|
355
|
+
<TextField {...field} inputMode='numeric' placeholder='예: 4' elemAfterInput='코어' />
|
|
356
|
+
</FormControl>
|
|
357
|
+
{/* 범위 + 추천값. placeholder 는 보기 하나 */}
|
|
358
|
+
<FormDescription>1~32 코어. 전처리는 2~4, 모델 학습은 8 이상을 권합니다</FormDescription>
|
|
359
|
+
<FormMessage />
|
|
360
|
+
</FormItem>
|
|
361
|
+
)}
|
|
362
|
+
/>
|
|
363
|
+
<FormField
|
|
364
|
+
control={form.control}
|
|
365
|
+
name='memoryGb'
|
|
366
|
+
rules={{ required: '메모리를 입력하세요.', validate: intInRange(1, 256, '메모리') }}
|
|
367
|
+
render={({ field }) => (
|
|
368
|
+
<FormItem>
|
|
369
|
+
<FormLabel required>메모리</FormLabel>
|
|
370
|
+
<FormControl>
|
|
371
|
+
<TextField {...field} inputMode='numeric' placeholder='예: 16' elemAfterInput='GB' />
|
|
372
|
+
</FormControl>
|
|
373
|
+
<FormDescription>1~256 GB. 입력 데이터 크기의 2배 이상을 권합니다</FormDescription>
|
|
374
|
+
<FormMessage />
|
|
375
|
+
</FormItem>
|
|
376
|
+
)}
|
|
377
|
+
/>
|
|
378
|
+
<FormField
|
|
379
|
+
control={form.control}
|
|
380
|
+
name='gpu'
|
|
381
|
+
rules={{ validate: intInRange(0, 8, 'GPU 수') }}
|
|
382
|
+
render={({ field }) => (
|
|
383
|
+
<FormItem>
|
|
384
|
+
<FormLabel>GPU</FormLabel>
|
|
385
|
+
<FormControl>
|
|
386
|
+
<TextField {...field} inputMode='numeric' placeholder='예: 1' elemAfterInput='개' />
|
|
387
|
+
</FormControl>
|
|
388
|
+
<FormDescription>0~8개. GPU 가 필요 없으면 0 — 학습 작업은 1개 이상</FormDescription>
|
|
389
|
+
<FormMessage />
|
|
390
|
+
</FormItem>
|
|
391
|
+
)}
|
|
392
|
+
/>
|
|
393
|
+
</FormSection>
|
|
394
|
+
|
|
395
|
+
<FormSection title='알림'>
|
|
396
|
+
<FormField
|
|
397
|
+
control={form.control}
|
|
398
|
+
name='notifyOnFailure'
|
|
399
|
+
render={({ field }) => (
|
|
400
|
+
<FormItem>
|
|
401
|
+
{/* 켜기/끄기 하나의 설정은 SwitchSection(박스 + 제목·설명 + Switch) — dl-ui-v3 ToggleField 관례. 폼 제출값이어도 Checkbox 가 아니다 */}
|
|
402
|
+
<FormControl>
|
|
403
|
+
<SwitchSection
|
|
404
|
+
title='실패 시 이메일 알림 받기'
|
|
405
|
+
description='켜면 실행이 실패했을 때 아래 주소로 메일을 보냅니다'
|
|
406
|
+
checked={field.value}
|
|
407
|
+
onCheckedChange={(checked) => {
|
|
408
|
+
field.onChange(checked)
|
|
409
|
+
if (!checked) {
|
|
410
|
+
form.setValue('email', '')
|
|
411
|
+
form.clearErrors('email')
|
|
412
|
+
}
|
|
413
|
+
}}
|
|
414
|
+
/>
|
|
415
|
+
</FormControl>
|
|
416
|
+
</FormItem>
|
|
417
|
+
)}
|
|
418
|
+
/>
|
|
419
|
+
{notifyOnFailure ? (
|
|
420
|
+
<FormField
|
|
421
|
+
control={form.control}
|
|
422
|
+
name='email'
|
|
423
|
+
rules={{
|
|
424
|
+
required: '받을 이메일을 입력하세요.',
|
|
425
|
+
pattern: {
|
|
426
|
+
value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
|
|
427
|
+
message: '이메일 형식이 아닙니다.',
|
|
428
|
+
},
|
|
429
|
+
}}
|
|
430
|
+
render={({ field }) => (
|
|
431
|
+
<FormItem>
|
|
432
|
+
<FormLabel required>받을 이메일</FormLabel>
|
|
433
|
+
<FormControl>
|
|
434
|
+
<TextField {...field} type='email' placeholder='name@example.com' />
|
|
435
|
+
</FormControl>
|
|
436
|
+
<FormMessage />
|
|
437
|
+
</FormItem>
|
|
438
|
+
)}
|
|
439
|
+
/>
|
|
440
|
+
) : null}
|
|
441
|
+
</FormSection>
|
|
442
|
+
</FormPage>
|
|
443
|
+
|
|
444
|
+
<AlertDialog open={confirmCancel} onOpenChange={setConfirmCancel}>
|
|
445
|
+
<AlertDialogContent variant='warning'>
|
|
446
|
+
<AlertDialogHeader>
|
|
447
|
+
<AlertDialogTitle>파이프라인 생성을 취소할까요?</AlertDialogTitle>
|
|
448
|
+
<AlertDialogDescription>입력한 내용은 저장되지 않고 사라집니다.</AlertDialogDescription>
|
|
449
|
+
</AlertDialogHeader>
|
|
450
|
+
<AlertDialogFooter>
|
|
451
|
+
<AlertDialogCancel>계속 작성</AlertDialogCancel>
|
|
452
|
+
<AlertDialogAction variant='warningBold' onClick={onLeave}>
|
|
453
|
+
취소하고 나가기
|
|
454
|
+
</AlertDialogAction>
|
|
455
|
+
</AlertDialogFooter>
|
|
456
|
+
</AlertDialogContent>
|
|
457
|
+
</AlertDialog>
|
|
458
|
+
</>
|
|
459
|
+
)
|
|
460
|
+
}
|