@mawaru/sdk 0.39.0 → 0.40.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/_schemas/graph.test.ts +15 -9
- package/_schemas/graph.ts +20 -6
- package/_schemas/human-manifest.test.ts +129 -0
- package/_schemas/human-manifest.ts +158 -0
- package/_schemas/port-spec.test.ts +40 -40
- package/_schemas/port-spec.ts +10 -24
- package/dist/_cli/login.js +1 -15
- package/dist/_cli/openBrowser.d.ts +1 -0
- package/dist/_cli/openBrowser.js +15 -0
- package/dist/_cli/previewApi.d.ts +1 -1
- package/dist/_cli/previewCommand.d.ts +8 -0
- package/dist/_cli/previewCommand.js +95 -37
- package/dist/_cli/previewFiles.d.ts +2 -1
- package/dist/_cli/previewFiles.js +42 -19
- package/dist/_cli/previewSlack.d.ts +1 -1
- package/dist/_cli/previewSlack.js +1 -1
- package/dist/_schemas/graph.d.ts +21 -3
- package/dist/_schemas/graph.js +17 -6
- package/dist/_schemas/human-manifest.d.ts +27 -0
- package/dist/_schemas/human-manifest.js +121 -0
- package/dist/_schemas/port-spec.d.ts +6 -9
- package/dist/_schemas/port-spec.js +7 -7
- package/dist/cli.js +3 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/init.js +22 -3
- package/dist/typegen.d.ts +3 -1
- package/dist/typegen.js +96 -14
- package/dist/validate.js +55 -0
- package/dist/views/_approval/ApprovalBar.d.ts +20 -0
- package/dist/views/_approval/ApprovalBar.js +74 -0
- package/dist/views/_approvalActions.d.ts +18 -0
- package/dist/views/_approvalActions.js +10 -0
- package/dist/views/_bridge.d.ts +12 -0
- package/dist/views/_bridge.js +10 -0
- package/dist/views/_parts/DocumentCard.d.ts +2 -0
- package/dist/views/_parts/DocumentCard.js +16 -0
- package/dist/views/_parts/FilePreview.d.ts +5 -0
- package/dist/views/_parts/FilePreview.js +101 -0
- package/dist/views/_parts/Linkify.d.ts +7 -0
- package/dist/views/_parts/Linkify.js +28 -0
- package/dist/views/_parts/Markdown.d.ts +44 -0
- package/dist/views/_parts/Markdown.js +313 -0
- package/dist/views/_parts/SchemaForm.d.ts +19 -0
- package/dist/views/_parts/SchemaForm.js +310 -0
- package/dist/views/_parts/fileRef.d.ts +7 -0
- package/dist/views/_parts/fileRef.js +11 -0
- package/dist/views/_parts/useNarrow.d.ts +5 -0
- package/dist/views/_parts/useNarrow.js +23 -0
- package/dist/views/host.d.ts +18 -0
- package/dist/views/host.js +20 -0
- package/dist/views/human.d.ts +73 -0
- package/dist/views/human.js +6 -0
- package/dist/views/index.d.ts +8 -0
- package/dist/views/index.js +12 -0
- package/dist/views/protocol.d.ts +34 -0
- package/dist/views/protocol.js +5 -0
- package/dist/views/runtime.d.ts +3 -0
- package/dist/views/runtime.js +86 -0
- package/dist/{slack-view.d.ts → views/slack.d.ts} +1 -1
- package/docs/development.md +40 -2
- package/index.ts +1 -0
- package/package.json +36 -4
- package/skills/create-loop/SKILL.md +6 -3
- package/views/_approval/ApprovalBar.tsx +239 -0
- package/views/_approvalActions.tsx +31 -0
- package/views/_bridge.tsx +36 -0
- package/views/_parts/DocumentCard.ts +18 -0
- package/views/_parts/FilePreview.tsx +180 -0
- package/views/_parts/Linkify.test.ts +40 -0
- package/views/_parts/Linkify.tsx +40 -0
- package/views/_parts/Markdown.test.ts +131 -0
- package/views/_parts/Markdown.tsx +472 -0
- package/views/_parts/SchemaForm.tsx +742 -0
- package/views/_parts/fileRef.ts +21 -0
- package/views/_parts/useNarrow.ts +24 -0
- package/views/host.ts +23 -0
- package/views/human.ts +91 -0
- package/views/index.ts +16 -0
- package/views/protocol.ts +40 -0
- package/views/runtime.tsx +132 -0
- package/views/slack.test.ts +40 -0
- package/{slack-view.ts → views/slack.ts} +3 -3
- /package/dist/{slack-view.js → views/slack.js} +0 -0
|
@@ -0,0 +1,742 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type CSSProperties,
|
|
3
|
+
type ReactNode,
|
|
4
|
+
useEffect,
|
|
5
|
+
useRef,
|
|
6
|
+
useState,
|
|
7
|
+
} from "react"
|
|
8
|
+
import { useViewBridge } from "../_bridge.js"
|
|
9
|
+
import { type FileRef, isFileRef } from "./fileRef.js"
|
|
10
|
+
import { isLongText, Markdown, useAutoGrow } from "./Markdown.js"
|
|
11
|
+
|
|
12
|
+
// JSON Schema(ポートの schema)を解釈して入力フォームを描画する(手動実行の入力データ用・
|
|
13
|
+
// 承認画面の書類)。object の各 property を string / number / integer / boolean / enum / file /
|
|
14
|
+
// ネスト object のフィールドとして描画し、array や未知の型はフィールド単位の
|
|
15
|
+
// JSON 入力にフォールバックする。
|
|
16
|
+
// 値の入った長文 string は「読み物」として扱う:既定は Markdown の整形表示で、
|
|
17
|
+
// 「編集」を押した時だけ raw の textarea に入れ替わる(生ソースを常時見せない)。
|
|
18
|
+
// 長文が複数あるときは先頭(=主役の成果物)だけ開き、以降は畳んで出す。
|
|
19
|
+
|
|
20
|
+
export type JsonSchema = {
|
|
21
|
+
type?: string
|
|
22
|
+
title?: string
|
|
23
|
+
description?: string
|
|
24
|
+
properties?: Record<string, JsonSchema>
|
|
25
|
+
required?: string[]
|
|
26
|
+
enum?: unknown[]
|
|
27
|
+
items?: JsonSchema
|
|
28
|
+
// file 型のマーカー(port-spec.ts の fileSchema)
|
|
29
|
+
"x-mawaru-type"?: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const isFileSchema = (schema: JsonSchema | undefined): boolean =>
|
|
33
|
+
schema?.["x-mawaru-type"] === "file"
|
|
34
|
+
|
|
35
|
+
/** フォーム描画できる schema(properties を持つ object)なら型を付けて返す */
|
|
36
|
+
export const asFormSchema = (schema: unknown): JsonSchema | null => {
|
|
37
|
+
if (schema == null || typeof schema !== "object") return null
|
|
38
|
+
const s = schema as JsonSchema
|
|
39
|
+
if (s.type !== "object") return null
|
|
40
|
+
if (!s.properties || Object.keys(s.properties).length === 0) return null
|
|
41
|
+
return s
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const nameStyle: CSSProperties = {
|
|
45
|
+
fontFamily: "var(--font-mono)",
|
|
46
|
+
fontSize: "var(--text-sm)",
|
|
47
|
+
fontWeight: "var(--weight-medium)" as CSSProperties["fontWeight"],
|
|
48
|
+
color: "var(--ink)",
|
|
49
|
+
whiteSpace: "nowrap",
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// 見出し行の右に置く小さな操作(「編集」「表示に戻す」)
|
|
53
|
+
const headActionStyle: CSSProperties = {
|
|
54
|
+
minHeight: "22px",
|
|
55
|
+
padding: "0 var(--space-2)",
|
|
56
|
+
fontSize: "var(--text-xs)",
|
|
57
|
+
fontWeight: "var(--weight-normal)" as CSSProperties["fontWeight"],
|
|
58
|
+
flex: "none",
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const FieldHead = ({
|
|
62
|
+
name,
|
|
63
|
+
schema,
|
|
64
|
+
required,
|
|
65
|
+
marker,
|
|
66
|
+
action,
|
|
67
|
+
}: {
|
|
68
|
+
name: string
|
|
69
|
+
schema: JsonSchema
|
|
70
|
+
required: boolean
|
|
71
|
+
/** 開閉の三角(畳める見出しのときだけ) */
|
|
72
|
+
marker?: ReactNode
|
|
73
|
+
/** 見出し右の操作(長文フィールドの「編集」など) */
|
|
74
|
+
action?: ReactNode
|
|
75
|
+
}) => (
|
|
76
|
+
<span
|
|
77
|
+
style={{
|
|
78
|
+
display: "flex",
|
|
79
|
+
// boolean ではチェックボックスと横並びの flex アイテムになる。残り幅に広げて説明を出す
|
|
80
|
+
flex: 1,
|
|
81
|
+
minWidth: 0,
|
|
82
|
+
alignItems: "baseline",
|
|
83
|
+
gap: "var(--space-2)",
|
|
84
|
+
fontSize: "var(--text-xs)",
|
|
85
|
+
color: "var(--muted)",
|
|
86
|
+
}}
|
|
87
|
+
>
|
|
88
|
+
{marker}
|
|
89
|
+
<span style={nameStyle}>{name}</span>
|
|
90
|
+
{required && (
|
|
91
|
+
<span style={{ color: "var(--accent)", flex: "none" }}>*</span>
|
|
92
|
+
)}
|
|
93
|
+
{/* 説明は残り幅に収めて省略する。width: 0 で最小幅に数えない(数えると長い説明が
|
|
94
|
+
フォーム全体を押し広げ、サイドパネルからはみ出す) */}
|
|
95
|
+
<span
|
|
96
|
+
style={{
|
|
97
|
+
flex: 1,
|
|
98
|
+
width: 0,
|
|
99
|
+
overflow: "hidden",
|
|
100
|
+
textOverflow: "ellipsis",
|
|
101
|
+
whiteSpace: "nowrap",
|
|
102
|
+
}}
|
|
103
|
+
>
|
|
104
|
+
{schema.description}
|
|
105
|
+
</span>
|
|
106
|
+
{action}
|
|
107
|
+
</span>
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
// array・未知の型のフォールバック:フィールド単位の JSON 入力。
|
|
111
|
+
// 入力途中の不正 JSON は text に留め、valid になった時点で親へ反映する。
|
|
112
|
+
const JsonField = ({
|
|
113
|
+
value,
|
|
114
|
+
onChange,
|
|
115
|
+
}: {
|
|
116
|
+
value: unknown
|
|
117
|
+
onChange: (v: unknown) => void
|
|
118
|
+
}) => {
|
|
119
|
+
const [text, setText] = useState(() => JSON.stringify(value ?? null, null, 2))
|
|
120
|
+
const [invalid, setInvalid] = useState(false)
|
|
121
|
+
const ref = useAutoGrow(text)
|
|
122
|
+
return (
|
|
123
|
+
<div style={{ display: "grid", gap: "2px" }}>
|
|
124
|
+
<textarea
|
|
125
|
+
ref={ref}
|
|
126
|
+
rows={3}
|
|
127
|
+
value={text}
|
|
128
|
+
onChange={(e) => {
|
|
129
|
+
setText(e.target.value)
|
|
130
|
+
try {
|
|
131
|
+
onChange(JSON.parse(e.target.value))
|
|
132
|
+
setInvalid(false)
|
|
133
|
+
} catch {
|
|
134
|
+
setInvalid(true)
|
|
135
|
+
}
|
|
136
|
+
}}
|
|
137
|
+
style={{
|
|
138
|
+
fontFamily: "var(--font-mono)",
|
|
139
|
+
fontSize: "var(--text-xs)",
|
|
140
|
+
lineHeight: 1.6,
|
|
141
|
+
maxHeight: "60vh",
|
|
142
|
+
resize: "vertical",
|
|
143
|
+
}}
|
|
144
|
+
/>
|
|
145
|
+
{invalid && (
|
|
146
|
+
<p role="alert" style={{ fontSize: "var(--text-xs)" }}>
|
|
147
|
+
JSON として不正です
|
|
148
|
+
</p>
|
|
149
|
+
)}
|
|
150
|
+
</div>
|
|
151
|
+
)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ---- file 型(x-mawaru-type: "file")の添付フィールド ----
|
|
155
|
+
// 選択と同時に backend(POST /tenants/:id/files)へアップロードし、フォーム値には
|
|
156
|
+
// path 参照(fileRef)を入れる。実体は private バケットに置かれ、実行時に署名 URL が発行される
|
|
157
|
+
|
|
158
|
+
const fileRefOf = (value: unknown): FileRef | null =>
|
|
159
|
+
isFileRef(value) ? value : null
|
|
160
|
+
|
|
161
|
+
const formatSize = (size?: number): string | null => {
|
|
162
|
+
if (size == null) return null
|
|
163
|
+
if (size >= 1024 * 1024) return `${(size / 1024 / 1024).toFixed(1)}MB`
|
|
164
|
+
return `${Math.max(1, Math.round(size / 1024))}KB`
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const useFileUpload = () => {
|
|
168
|
+
const bridge = useViewBridge()
|
|
169
|
+
const [uploading, setUploading] = useState(false)
|
|
170
|
+
const [error, setError] = useState<string | null>(null)
|
|
171
|
+
const upload = async (list: FileList): Promise<FileRef[]> => {
|
|
172
|
+
setUploading(true)
|
|
173
|
+
setError(null)
|
|
174
|
+
try {
|
|
175
|
+
const refs: FileRef[] = []
|
|
176
|
+
for (const file of Array.from(list)) {
|
|
177
|
+
refs.push(await bridge.uploadFile(file))
|
|
178
|
+
}
|
|
179
|
+
return refs
|
|
180
|
+
} catch (err) {
|
|
181
|
+
setError(err instanceof Error ? err.message : String(err))
|
|
182
|
+
return []
|
|
183
|
+
} finally {
|
|
184
|
+
setUploading(false)
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return { upload, uploading, error }
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const FileChip = ({
|
|
191
|
+
file,
|
|
192
|
+
onRemove,
|
|
193
|
+
}: {
|
|
194
|
+
file: FileRef
|
|
195
|
+
onRemove: () => void
|
|
196
|
+
}) => (
|
|
197
|
+
<span
|
|
198
|
+
style={{
|
|
199
|
+
display: "inline-flex",
|
|
200
|
+
alignItems: "center",
|
|
201
|
+
gap: "var(--space-1)",
|
|
202
|
+
maxWidth: "100%",
|
|
203
|
+
border: "var(--border-w) solid var(--border)",
|
|
204
|
+
borderRadius: "var(--radius-s)",
|
|
205
|
+
background: "var(--surface-2)",
|
|
206
|
+
padding: "2px var(--space-2)",
|
|
207
|
+
fontSize: "var(--text-xs)",
|
|
208
|
+
}}
|
|
209
|
+
>
|
|
210
|
+
<span aria-hidden="true">📎</span>
|
|
211
|
+
<span
|
|
212
|
+
style={{
|
|
213
|
+
minWidth: 0,
|
|
214
|
+
overflow: "hidden",
|
|
215
|
+
textOverflow: "ellipsis",
|
|
216
|
+
whiteSpace: "nowrap",
|
|
217
|
+
}}
|
|
218
|
+
>
|
|
219
|
+
{file.name}
|
|
220
|
+
</span>
|
|
221
|
+
{formatSize(file.size) && (
|
|
222
|
+
<span style={{ color: "var(--muted)", flex: "none" }}>
|
|
223
|
+
{formatSize(file.size)}
|
|
224
|
+
</span>
|
|
225
|
+
)}
|
|
226
|
+
<button
|
|
227
|
+
type="button"
|
|
228
|
+
className="ghost"
|
|
229
|
+
onClick={onRemove}
|
|
230
|
+
aria-label={`${file.name} を外す`}
|
|
231
|
+
style={{
|
|
232
|
+
minHeight: "18px",
|
|
233
|
+
padding: "0 4px",
|
|
234
|
+
fontSize: "var(--text-xs)",
|
|
235
|
+
}}
|
|
236
|
+
>
|
|
237
|
+
✕
|
|
238
|
+
</button>
|
|
239
|
+
</span>
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
// 破線のドロップゾーン兼選択ボタン。クリックで隠し input[type=file] を開き、
|
|
243
|
+
// ドラッグ&ドロップも受け付ける(アップロード中は無効化)
|
|
244
|
+
const FilePicker = ({
|
|
245
|
+
label,
|
|
246
|
+
multiple,
|
|
247
|
+
uploading,
|
|
248
|
+
onPick,
|
|
249
|
+
}: {
|
|
250
|
+
label: string
|
|
251
|
+
multiple?: boolean
|
|
252
|
+
uploading: boolean
|
|
253
|
+
onPick: (list: FileList) => void
|
|
254
|
+
}) => {
|
|
255
|
+
const inputRef = useRef<HTMLInputElement>(null)
|
|
256
|
+
// dragenter/leave は子要素をまたぐたびに発火するため深さを数えて判定する
|
|
257
|
+
const depthRef = useRef(0)
|
|
258
|
+
const [dragOver, setDragOver] = useState(false)
|
|
259
|
+
const hasFiles = (e: React.DragEvent) =>
|
|
260
|
+
Array.from(e.dataTransfer.types).includes("Files")
|
|
261
|
+
return (
|
|
262
|
+
<>
|
|
263
|
+
<input
|
|
264
|
+
ref={inputRef}
|
|
265
|
+
type="file"
|
|
266
|
+
multiple={multiple}
|
|
267
|
+
style={{ display: "none" }}
|
|
268
|
+
onChange={(e) => {
|
|
269
|
+
if (e.target.files && e.target.files.length > 0) {
|
|
270
|
+
onPick(e.target.files)
|
|
271
|
+
}
|
|
272
|
+
e.target.value = ""
|
|
273
|
+
}}
|
|
274
|
+
/>
|
|
275
|
+
<button
|
|
276
|
+
type="button"
|
|
277
|
+
onClick={() => inputRef.current?.click()}
|
|
278
|
+
disabled={uploading}
|
|
279
|
+
onDragEnter={(e) => {
|
|
280
|
+
if (uploading || !hasFiles(e)) return
|
|
281
|
+
e.preventDefault()
|
|
282
|
+
depthRef.current += 1
|
|
283
|
+
setDragOver(true)
|
|
284
|
+
}}
|
|
285
|
+
onDragOver={(e) => {
|
|
286
|
+
if (uploading || !hasFiles(e)) return
|
|
287
|
+
e.preventDefault()
|
|
288
|
+
}}
|
|
289
|
+
onDragLeave={() => {
|
|
290
|
+
if (depthRef.current > 0) depthRef.current -= 1
|
|
291
|
+
if (depthRef.current === 0) setDragOver(false)
|
|
292
|
+
}}
|
|
293
|
+
onDrop={(e) => {
|
|
294
|
+
e.preventDefault()
|
|
295
|
+
depthRef.current = 0
|
|
296
|
+
setDragOver(false)
|
|
297
|
+
if (uploading) return
|
|
298
|
+
if (e.dataTransfer.files.length > 0) onPick(e.dataTransfer.files)
|
|
299
|
+
}}
|
|
300
|
+
style={{
|
|
301
|
+
display: "flex",
|
|
302
|
+
alignItems: "center",
|
|
303
|
+
justifyContent: "center",
|
|
304
|
+
gap: "var(--space-1)",
|
|
305
|
+
width: "100%",
|
|
306
|
+
minHeight: "40px",
|
|
307
|
+
padding: "var(--space-2)",
|
|
308
|
+
border: `var(--border-w) dashed ${dragOver ? "var(--accent)" : "var(--border)"}`,
|
|
309
|
+
borderRadius: "var(--radius-s)",
|
|
310
|
+
background: dragOver ? "var(--surface-2)" : "transparent",
|
|
311
|
+
color: dragOver ? "var(--accent)" : "var(--muted)",
|
|
312
|
+
fontSize: "var(--text-xs)",
|
|
313
|
+
}}
|
|
314
|
+
>
|
|
315
|
+
<span aria-hidden="true">📎</span>
|
|
316
|
+
{uploading ? "アップロード中…" : `${label}、またはドラッグ&ドロップ`}
|
|
317
|
+
</button>
|
|
318
|
+
</>
|
|
319
|
+
)
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// file 単体:未選択(null)⇄ 選択済みチップ
|
|
323
|
+
const FileField = ({
|
|
324
|
+
value,
|
|
325
|
+
onChange,
|
|
326
|
+
}: {
|
|
327
|
+
value: unknown
|
|
328
|
+
onChange: (v: unknown) => void
|
|
329
|
+
}) => {
|
|
330
|
+
const { upload, uploading, error } = useFileUpload()
|
|
331
|
+
const file = fileRefOf(value)
|
|
332
|
+
const pick = async (list: FileList) => {
|
|
333
|
+
const refs = await upload(list)
|
|
334
|
+
if (refs[0]) onChange(refs[0])
|
|
335
|
+
}
|
|
336
|
+
return (
|
|
337
|
+
<div style={{ display: "grid", gap: "var(--space-1)" }}>
|
|
338
|
+
{file ? (
|
|
339
|
+
<FileChip file={file} onRemove={() => onChange(null)} />
|
|
340
|
+
) : (
|
|
341
|
+
<FilePicker
|
|
342
|
+
label="ファイルを選択"
|
|
343
|
+
uploading={uploading}
|
|
344
|
+
onPick={pick}
|
|
345
|
+
/>
|
|
346
|
+
)}
|
|
347
|
+
{error && (
|
|
348
|
+
<p role="alert" style={{ margin: 0, fontSize: "var(--text-xs)" }}>
|
|
349
|
+
{error}
|
|
350
|
+
</p>
|
|
351
|
+
)}
|
|
352
|
+
</div>
|
|
353
|
+
)
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// file 配列:チップのリスト+追加(複数選択可)
|
|
357
|
+
const FileArrayField = ({
|
|
358
|
+
value,
|
|
359
|
+
onChange,
|
|
360
|
+
}: {
|
|
361
|
+
value: unknown
|
|
362
|
+
onChange: (v: unknown) => void
|
|
363
|
+
}) => {
|
|
364
|
+
const { upload, uploading, error } = useFileUpload()
|
|
365
|
+
const files = Array.isArray(value)
|
|
366
|
+
? value.map(fileRefOf).filter((f): f is FileRef => f !== null)
|
|
367
|
+
: []
|
|
368
|
+
const pick = async (list: FileList) => {
|
|
369
|
+
const refs = await upload(list)
|
|
370
|
+
if (refs.length > 0) onChange([...files, ...refs])
|
|
371
|
+
}
|
|
372
|
+
return (
|
|
373
|
+
<div style={{ display: "grid", gap: "var(--space-1)" }}>
|
|
374
|
+
{files.map((file, i) => (
|
|
375
|
+
<FileChip
|
|
376
|
+
key={file.path}
|
|
377
|
+
file={file}
|
|
378
|
+
onRemove={() => onChange(files.filter((_, j) => j !== i))}
|
|
379
|
+
/>
|
|
380
|
+
))}
|
|
381
|
+
<FilePicker
|
|
382
|
+
label="ファイルを追加"
|
|
383
|
+
multiple
|
|
384
|
+
uploading={uploading}
|
|
385
|
+
onPick={pick}
|
|
386
|
+
/>
|
|
387
|
+
{error && (
|
|
388
|
+
<p role="alert" style={{ margin: 0, fontSize: "var(--text-xs)" }}>
|
|
389
|
+
{error}
|
|
390
|
+
</p>
|
|
391
|
+
)}
|
|
392
|
+
</div>
|
|
393
|
+
)
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// 長文の raw 編集欄:内容の高さまで伸び、上限(60vh)を超えたらその中でスクロールする
|
|
397
|
+
const LongTextArea = ({
|
|
398
|
+
value,
|
|
399
|
+
onChange,
|
|
400
|
+
autoFocus,
|
|
401
|
+
}: {
|
|
402
|
+
value: string
|
|
403
|
+
onChange: (v: string) => void
|
|
404
|
+
autoFocus?: boolean
|
|
405
|
+
}) => {
|
|
406
|
+
const ref = useAutoGrow(value)
|
|
407
|
+
return (
|
|
408
|
+
<textarea
|
|
409
|
+
ref={ref}
|
|
410
|
+
// biome-ignore lint/a11y/noAutofocus: 「編集」を押して現れる欄なので即入力できる方が自然
|
|
411
|
+
autoFocus={autoFocus}
|
|
412
|
+
rows={3}
|
|
413
|
+
value={value}
|
|
414
|
+
onChange={(e) => onChange(e.target.value)}
|
|
415
|
+
style={{
|
|
416
|
+
fontSize: "var(--text-base)",
|
|
417
|
+
lineHeight: 1.9,
|
|
418
|
+
maxHeight: "60vh",
|
|
419
|
+
resize: "vertical",
|
|
420
|
+
}}
|
|
421
|
+
/>
|
|
422
|
+
)
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// string フィールド:長文・改行入りの値は textarea に昇格する(AI の下書き等)。
|
|
426
|
+
// 入力中に input ⇄ textarea が flip してフォーカスが飛ばないよう、昇格したら戻さない
|
|
427
|
+
const StringInput = ({
|
|
428
|
+
value,
|
|
429
|
+
onChange,
|
|
430
|
+
}: {
|
|
431
|
+
value: string
|
|
432
|
+
onChange: (v: string) => void
|
|
433
|
+
}) => {
|
|
434
|
+
const long = isLongText(value)
|
|
435
|
+
const [multiline, setMultiline] = useState(long)
|
|
436
|
+
useEffect(() => {
|
|
437
|
+
if (long) setMultiline(true)
|
|
438
|
+
}, [long])
|
|
439
|
+
return multiline ? (
|
|
440
|
+
<LongTextArea value={value} onChange={onChange} />
|
|
441
|
+
) : (
|
|
442
|
+
<input
|
|
443
|
+
type="text"
|
|
444
|
+
value={value}
|
|
445
|
+
onChange={(e) => onChange(e.target.value)}
|
|
446
|
+
/>
|
|
447
|
+
)
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// 値の入った長文 string=読み物。既定は Markdown の整形表示で、「編集」を押した時だけ
|
|
451
|
+
// raw に入れ替わる。長文が複数あるときの2つ目以降は畳んだ状態で始まる(見出しだけ出す)
|
|
452
|
+
const LongTextField = ({
|
|
453
|
+
name,
|
|
454
|
+
schema,
|
|
455
|
+
required,
|
|
456
|
+
value,
|
|
457
|
+
editable,
|
|
458
|
+
defaultCollapsed,
|
|
459
|
+
onChange,
|
|
460
|
+
}: {
|
|
461
|
+
name: string
|
|
462
|
+
schema: JsonSchema
|
|
463
|
+
required: boolean
|
|
464
|
+
value: string
|
|
465
|
+
editable: boolean
|
|
466
|
+
defaultCollapsed: boolean
|
|
467
|
+
onChange: (v: string) => void
|
|
468
|
+
}) => {
|
|
469
|
+
// 開閉は <details>:読み取り専用の局面で書類全体を <fieldset disabled> で包んでも
|
|
470
|
+
// (フォーム部品ではないので)畳んだ本文を開いて読める
|
|
471
|
+
const [open, setOpen] = useState(!defaultCollapsed)
|
|
472
|
+
const [editing, setEditing] = useState(false)
|
|
473
|
+
const lines = value.split("\n").length
|
|
474
|
+
|
|
475
|
+
return (
|
|
476
|
+
// details 自身には display を当てない(当てると閉じた中身が出てしまうブラウザがある)
|
|
477
|
+
<details open={open} onToggle={(e) => setOpen(e.currentTarget.open)}>
|
|
478
|
+
<summary style={{ cursor: "pointer", listStyle: "none" }}>
|
|
479
|
+
<FieldHead
|
|
480
|
+
name={name}
|
|
481
|
+
schema={schema}
|
|
482
|
+
required={required}
|
|
483
|
+
marker={
|
|
484
|
+
<span aria-hidden="true" style={{ flex: "none" }}>
|
|
485
|
+
{open ? "▾" : "▸"}
|
|
486
|
+
</span>
|
|
487
|
+
}
|
|
488
|
+
action={
|
|
489
|
+
open ? (
|
|
490
|
+
editable ? (
|
|
491
|
+
<button
|
|
492
|
+
type="button"
|
|
493
|
+
className="ghost"
|
|
494
|
+
// summary の中なので、押しても開閉させない
|
|
495
|
+
onClick={(e) => {
|
|
496
|
+
e.preventDefault()
|
|
497
|
+
setEditing((current) => !current)
|
|
498
|
+
}}
|
|
499
|
+
style={headActionStyle}
|
|
500
|
+
>
|
|
501
|
+
{editing ? "表示に戻す" : "編集"}
|
|
502
|
+
</button>
|
|
503
|
+
) : null
|
|
504
|
+
) : (
|
|
505
|
+
<span
|
|
506
|
+
style={{
|
|
507
|
+
flex: "none",
|
|
508
|
+
fontVariantNumeric:
|
|
509
|
+
"var(--numeric)" as CSSProperties["fontVariantNumeric"],
|
|
510
|
+
}}
|
|
511
|
+
>
|
|
512
|
+
{lines}行
|
|
513
|
+
</span>
|
|
514
|
+
)
|
|
515
|
+
}
|
|
516
|
+
/>
|
|
517
|
+
</summary>
|
|
518
|
+
<div
|
|
519
|
+
style={{
|
|
520
|
+
display: "grid",
|
|
521
|
+
marginTop: "var(--space-3)",
|
|
522
|
+
borderTop: "var(--border-w) solid var(--border)",
|
|
523
|
+
paddingTop: "var(--space-3)",
|
|
524
|
+
}}
|
|
525
|
+
>
|
|
526
|
+
{editing ? (
|
|
527
|
+
<LongTextArea value={value} onChange={onChange} autoFocus />
|
|
528
|
+
) : (
|
|
529
|
+
<Markdown text={value} />
|
|
530
|
+
)}
|
|
531
|
+
</div>
|
|
532
|
+
</details>
|
|
533
|
+
)
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
const Field = ({
|
|
537
|
+
name,
|
|
538
|
+
schema,
|
|
539
|
+
required,
|
|
540
|
+
value,
|
|
541
|
+
readOnly,
|
|
542
|
+
asDocument,
|
|
543
|
+
defaultCollapsed,
|
|
544
|
+
onChange,
|
|
545
|
+
}: {
|
|
546
|
+
name: string
|
|
547
|
+
schema: JsonSchema
|
|
548
|
+
required: boolean
|
|
549
|
+
value: unknown
|
|
550
|
+
readOnly: boolean
|
|
551
|
+
/** 読み物(Markdown 表示)として描くか。開いた時点の値で決まる */
|
|
552
|
+
asDocument: boolean
|
|
553
|
+
/** 読み物を畳んだ状態で始めるか(先頭の読み物だけ開く) */
|
|
554
|
+
defaultCollapsed: boolean
|
|
555
|
+
onChange: (v: unknown) => void
|
|
556
|
+
}) => {
|
|
557
|
+
const head = <FieldHead name={name} schema={schema} required={required} />
|
|
558
|
+
|
|
559
|
+
// file 型は enum / type 判定より先に見る(実体は object / array なので)
|
|
560
|
+
if (isFileSchema(schema)) {
|
|
561
|
+
return (
|
|
562
|
+
<div style={{ display: "grid", gap: "var(--space-1)" }}>
|
|
563
|
+
{head}
|
|
564
|
+
<FileField value={value} onChange={onChange} />
|
|
565
|
+
</div>
|
|
566
|
+
)
|
|
567
|
+
}
|
|
568
|
+
if (schema.type === "array" && isFileSchema(schema.items)) {
|
|
569
|
+
return (
|
|
570
|
+
<div style={{ display: "grid", gap: "var(--space-1)" }}>
|
|
571
|
+
{head}
|
|
572
|
+
<FileArrayField value={value} onChange={onChange} />
|
|
573
|
+
</div>
|
|
574
|
+
)
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
const options = schema.enum
|
|
578
|
+
|
|
579
|
+
if (options && options.length > 0) {
|
|
580
|
+
const idx = options.findIndex(
|
|
581
|
+
(opt) => JSON.stringify(opt) === JSON.stringify(value),
|
|
582
|
+
)
|
|
583
|
+
return (
|
|
584
|
+
<label>
|
|
585
|
+
{head}
|
|
586
|
+
<select
|
|
587
|
+
value={String(idx)}
|
|
588
|
+
onChange={(e) => onChange(options[Number(e.target.value)])}
|
|
589
|
+
>
|
|
590
|
+
{idx < 0 && (
|
|
591
|
+
<option value="-1" disabled>
|
|
592
|
+
選択してください
|
|
593
|
+
</option>
|
|
594
|
+
)}
|
|
595
|
+
{options.map((opt, i) => (
|
|
596
|
+
<option key={JSON.stringify(opt)} value={String(i)}>
|
|
597
|
+
{typeof opt === "string" ? opt : JSON.stringify(opt)}
|
|
598
|
+
</option>
|
|
599
|
+
))}
|
|
600
|
+
</select>
|
|
601
|
+
</label>
|
|
602
|
+
)
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
switch (schema.type) {
|
|
606
|
+
case "string": {
|
|
607
|
+
const text = typeof value === "string" ? value : ""
|
|
608
|
+
if (asDocument) {
|
|
609
|
+
return (
|
|
610
|
+
<LongTextField
|
|
611
|
+
name={name}
|
|
612
|
+
schema={schema}
|
|
613
|
+
required={required}
|
|
614
|
+
value={text}
|
|
615
|
+
editable={!readOnly}
|
|
616
|
+
defaultCollapsed={defaultCollapsed}
|
|
617
|
+
onChange={onChange}
|
|
618
|
+
/>
|
|
619
|
+
)
|
|
620
|
+
}
|
|
621
|
+
return (
|
|
622
|
+
<div style={{ display: "grid", gap: "var(--space-1)" }}>
|
|
623
|
+
{head}
|
|
624
|
+
<StringInput value={text} onChange={onChange} />
|
|
625
|
+
</div>
|
|
626
|
+
)
|
|
627
|
+
}
|
|
628
|
+
case "number":
|
|
629
|
+
case "integer":
|
|
630
|
+
return (
|
|
631
|
+
<label>
|
|
632
|
+
{head}
|
|
633
|
+
<input
|
|
634
|
+
type="number"
|
|
635
|
+
step={schema.type === "integer" ? 1 : "any"}
|
|
636
|
+
value={typeof value === "number" ? value : ""}
|
|
637
|
+
onChange={(e) => {
|
|
638
|
+
const raw = e.target.value
|
|
639
|
+
onChange(raw === "" ? null : Number(raw))
|
|
640
|
+
}}
|
|
641
|
+
/>
|
|
642
|
+
</label>
|
|
643
|
+
)
|
|
644
|
+
case "boolean":
|
|
645
|
+
return (
|
|
646
|
+
<label
|
|
647
|
+
style={{
|
|
648
|
+
display: "flex",
|
|
649
|
+
alignItems: "center",
|
|
650
|
+
gap: "var(--space-2)",
|
|
651
|
+
}}
|
|
652
|
+
>
|
|
653
|
+
<input
|
|
654
|
+
type="checkbox"
|
|
655
|
+
checked={Boolean(value)}
|
|
656
|
+
onChange={(e) => onChange(e.target.checked)}
|
|
657
|
+
style={{ minHeight: 0, width: "14px", height: "14px" }}
|
|
658
|
+
/>
|
|
659
|
+
{head}
|
|
660
|
+
</label>
|
|
661
|
+
)
|
|
662
|
+
case "object": {
|
|
663
|
+
if (!schema.properties || Object.keys(schema.properties).length === 0) {
|
|
664
|
+
break
|
|
665
|
+
}
|
|
666
|
+
const obj = (
|
|
667
|
+
value != null && typeof value === "object" ? value : {}
|
|
668
|
+
) as Record<string, unknown>
|
|
669
|
+
return (
|
|
670
|
+
<div style={{ display: "grid", gap: "var(--space-2)" }}>
|
|
671
|
+
{head}
|
|
672
|
+
<div
|
|
673
|
+
style={{
|
|
674
|
+
borderLeft: "var(--border-w) solid var(--border)",
|
|
675
|
+
paddingLeft: "var(--space-3)",
|
|
676
|
+
}}
|
|
677
|
+
>
|
|
678
|
+
<SchemaForm
|
|
679
|
+
schema={schema}
|
|
680
|
+
value={obj}
|
|
681
|
+
readOnly={readOnly}
|
|
682
|
+
onChange={onChange}
|
|
683
|
+
/>
|
|
684
|
+
</div>
|
|
685
|
+
</div>
|
|
686
|
+
)
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
return (
|
|
691
|
+
<div style={{ display: "grid", gap: "var(--space-1)" }}>
|
|
692
|
+
{head}
|
|
693
|
+
<JsonField value={value} onChange={onChange} />
|
|
694
|
+
</div>
|
|
695
|
+
)
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
export const SchemaForm = ({
|
|
699
|
+
schema,
|
|
700
|
+
value,
|
|
701
|
+
readOnly = false,
|
|
702
|
+
onChange,
|
|
703
|
+
}: {
|
|
704
|
+
schema: JsonSchema
|
|
705
|
+
value: Record<string, unknown>
|
|
706
|
+
/** 値を直せない局面(担当外・全員承認・対応済み)では「編集」を出さない */
|
|
707
|
+
readOnly?: boolean
|
|
708
|
+
onChange: (v: Record<string, unknown>) => void
|
|
709
|
+
}) => {
|
|
710
|
+
const fields = Object.entries(schema.properties ?? {})
|
|
711
|
+
// 中身の入った長文=読み物(Markdown 表示)。開いた時点の値で決め、以後は変えない:
|
|
712
|
+
// 入力中に 80 文字を超えた瞬間に入力欄が表示に化けてフォーカスが飛ぶのを避ける
|
|
713
|
+
// (空欄から書き始める手動実行の入力フォームは従来どおり入力欄のまま)
|
|
714
|
+
const [documentKeys] = useState(() =>
|
|
715
|
+
fields
|
|
716
|
+
.filter(([key]) => {
|
|
717
|
+
const field = value[key]
|
|
718
|
+
return typeof field === "string" && isLongText(field)
|
|
719
|
+
})
|
|
720
|
+
.map(([key]) => key),
|
|
721
|
+
)
|
|
722
|
+
|
|
723
|
+
return (
|
|
724
|
+
<div style={{ display: "grid", gap: "var(--space-5)" }}>
|
|
725
|
+
{fields.map(([key, child]) => (
|
|
726
|
+
<Field
|
|
727
|
+
key={key}
|
|
728
|
+
name={key}
|
|
729
|
+
schema={child}
|
|
730
|
+
required={schema.required?.includes(key) ?? false}
|
|
731
|
+
value={value[key]}
|
|
732
|
+
readOnly={readOnly}
|
|
733
|
+
asDocument={documentKeys.includes(key)}
|
|
734
|
+
// 読み物が複数あるとき、先頭(=主役の成果物)だけ開いて以降は畳む。
|
|
735
|
+
// key 名には依存せず、スキーマの並び順だけで決める
|
|
736
|
+
defaultCollapsed={key !== documentKeys[0]}
|
|
737
|
+
onChange={(v) => onChange({ ...value, [key]: v })}
|
|
738
|
+
/>
|
|
739
|
+
))}
|
|
740
|
+
</div>
|
|
741
|
+
)
|
|
742
|
+
}
|