phoenix_duskmoon 9.14.0 → 9.16.1
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/README.md +7 -7
- package/assets/css/element-theme-bridge.css +0 -3
- package/assets/css/phoenix_duskmoon.css +2 -0
- package/assets/js/react_chat/chat.js +225 -0
- package/assets/js/react_chat/index.js +105 -0
- package/assets/js/react_chat/state.js +84 -0
- package/assets/js/react_form/component.js +40 -0
- package/assets/js/react_form/index.js +117 -0
- package/assets/js/react_form/schema.js +53 -0
- package/assets/js/react_form/values.js +29 -0
- package/package.json +12 -7
- package/priv/static/assets/css/app.css +1 -1
package/README.md
CHANGED
|
@@ -143,17 +143,17 @@ Components that require hooks:
|
|
|
143
143
|
## Available Components
|
|
144
144
|
|
|
145
145
|
- **Action**: buttons, dropdowns, floating actions, links, menus, swaps, toggles
|
|
146
|
-
- **Data Display**: accordion, avatar, badge, card, carousel, chat, chat scroll, chip, collapse, countdown, datetime, diff,
|
|
146
|
+
- **Data Display**: accordion, avatar, badge, card, carousel, chat, chat scroll, chip, collapse, countdown, datetime, diff, flash, git repository, list, markdown, markdown body, pagination, popover, progress, radial progress, skeleton, stat, table, timeline, tooltip
|
|
147
147
|
- **Data Entry**: autocomplete, cascader, checkbox, compact input, file upload, filter group, form, input, multi-select, OTP input, PIN input, radio, rating, segment control, select, slider, switch, textarea, time input, tree select
|
|
148
148
|
- **Feedback**: dialog, loading, snackbar, toast
|
|
149
|
-
- **Navigation**: actionbar, appbar, bottom nav, breadcrumb, left menu, megamenu, navbar,
|
|
150
|
-
- **Layout**: bottom sheet, divider, drawer,
|
|
149
|
+
- **Navigation**: actionbar, appbar, bottom nav, breadcrumb, left menu, megamenu, navbar, page footer, page header, stepper, tabs
|
|
150
|
+
- **Layout**: bottom sheet, divider, drawer, sidebar layout, theme switcher
|
|
151
151
|
- **CSS Art**: button noise, eclipse, plasma ball, signature, snow, spotlight search
|
|
152
152
|
|
|
153
153
|
|
|
154
154
|
## Upstream design alignment
|
|
155
155
|
|
|
156
|
-
The current frontend uses Core/CSS Art 1.19.
|
|
156
|
+
The current frontend uses Core/CSS Art 1.19.10 and Elements/Art Elements 1.8.0.
|
|
157
157
|
Individually published element dependencies are pinned to their current versions;
|
|
158
158
|
see the [contract audit](apps/phoenix_duskmoon/guides/upstream-design-alignment.md) for the exact set.
|
|
159
159
|
|
|
@@ -163,10 +163,10 @@ native scrolling, swap uses a checkbox, floating actions and megamenu
|
|
|
163
163
|
use the browser Popover API. No element registration is needed for these primitives.
|
|
164
164
|
|
|
165
165
|
```heex
|
|
166
|
-
|
|
167
|
-
|
|
166
|
+
<div class="indicator">
|
|
167
|
+
<span class="indicator-item badge badge-primary" aria-label="3 unread messages">3</span>
|
|
168
168
|
<button type="button" class="btn">Inbox</button>
|
|
169
|
-
|
|
169
|
+
</div>
|
|
170
170
|
|
|
171
171
|
<.dm_sidebar_layout>
|
|
172
172
|
<:sidebar><nav aria-label="Workspace">Workspace links</nav></:sidebar>
|
|
@@ -29,12 +29,10 @@ el-dm-art-snowball-preloader,
|
|
|
29
29
|
el-dm-art-sun,
|
|
30
30
|
el-dm-art-synthwave-starfield,
|
|
31
31
|
el-dm-autocomplete,
|
|
32
|
-
el-dm-badge,
|
|
33
32
|
el-dm-bottom-navigation,
|
|
34
33
|
el-dm-bottom-sheet,
|
|
35
34
|
el-dm-breadcrumbs,
|
|
36
35
|
el-dm-button,
|
|
37
|
-
el-dm-card,
|
|
38
36
|
el-dm-cascader,
|
|
39
37
|
el-dm-chip,
|
|
40
38
|
el-dm-circle-menu,
|
|
@@ -60,7 +58,6 @@ el-dm-progress,
|
|
|
60
58
|
el-dm-segment-control,
|
|
61
59
|
el-dm-select,
|
|
62
60
|
el-dm-slider,
|
|
63
|
-
el-dm-stepper,
|
|
64
61
|
el-dm-switch,
|
|
65
62
|
el-dm-table,
|
|
66
63
|
el-dm-tabs,
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import React, { memo, useEffect, useId, useRef, useState } from "react";
|
|
2
|
+
import { Markdown } from "@duskmoon-dev/components/markdown";
|
|
3
|
+
import { Input } from "@duskmoon-dev/components/input";
|
|
4
|
+
import { Button } from "@duskmoon-dev/components/button";
|
|
5
|
+
import { Chat } from "@duskmoon-dev/components/chat";
|
|
6
|
+
const h = React.createElement;
|
|
7
|
+
|
|
8
|
+
const Message = memo(function Message({ message, bridge, targetId, timeline }) {
|
|
9
|
+
const [notice, setNotice] = useState("");
|
|
10
|
+
return h(
|
|
11
|
+
Chat,
|
|
12
|
+
{
|
|
13
|
+
id: targetId,
|
|
14
|
+
role: "article",
|
|
15
|
+
placement: message.role === "user" ? "end" : "start",
|
|
16
|
+
timeline,
|
|
17
|
+
"data-message-id": message.id,
|
|
18
|
+
},
|
|
19
|
+
h(Chat.Header, null, message.author || message.role),
|
|
20
|
+
h(
|
|
21
|
+
Chat.Bubble,
|
|
22
|
+
{ streaming: message.status === "streaming" },
|
|
23
|
+
...(message.tools || []).map((tool) =>
|
|
24
|
+
h(
|
|
25
|
+
Chat.Tool,
|
|
26
|
+
{ key: tool.id, status: tool.status || "running" },
|
|
27
|
+
h(Chat.ToolHeader, null, `${tool.name || tool.id}: ${tool.status || "running"}`),
|
|
28
|
+
h(
|
|
29
|
+
tool.result == null ? Chat.ToolCall : Chat.ToolResult,
|
|
30
|
+
null,
|
|
31
|
+
h("pre", null, JSON.stringify(tool.result ?? tool.input ?? {}, null, 2)),
|
|
32
|
+
),
|
|
33
|
+
),
|
|
34
|
+
),
|
|
35
|
+
h(Markdown, { className: "chat-bubble-content", markdown: message.content || "" }),
|
|
36
|
+
),
|
|
37
|
+
message.status === "streaming" &&
|
|
38
|
+
h(Chat.Status, { role: "status" }, h(Chat.StatusItem, null, "Generating…")),
|
|
39
|
+
message.error && h("p", { role: "alert" }, message.error),
|
|
40
|
+
h(
|
|
41
|
+
Chat.Footer,
|
|
42
|
+
null,
|
|
43
|
+
h(
|
|
44
|
+
Chat.Actions,
|
|
45
|
+
null,
|
|
46
|
+
h(
|
|
47
|
+
Button,
|
|
48
|
+
{
|
|
49
|
+
type: "button",
|
|
50
|
+
appearance: "ghost",
|
|
51
|
+
onClick: async () => {
|
|
52
|
+
try {
|
|
53
|
+
await navigator.clipboard.writeText(message.content || "");
|
|
54
|
+
setNotice("Copied");
|
|
55
|
+
} catch {
|
|
56
|
+
setNotice("Copy failed");
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
"Copy",
|
|
61
|
+
),
|
|
62
|
+
["error", "cancelled"].includes(message.status) &&
|
|
63
|
+
h(
|
|
64
|
+
Button,
|
|
65
|
+
{
|
|
66
|
+
type: "button",
|
|
67
|
+
appearance: "ghost",
|
|
68
|
+
onClick: () =>
|
|
69
|
+
bridge
|
|
70
|
+
.send("retry", { message_id: message.id })
|
|
71
|
+
.catch((error) => setNotice(error.message)),
|
|
72
|
+
},
|
|
73
|
+
"Retry",
|
|
74
|
+
),
|
|
75
|
+
...(message.actions || []).map((action) =>
|
|
76
|
+
h(
|
|
77
|
+
Button,
|
|
78
|
+
{
|
|
79
|
+
key: action.id,
|
|
80
|
+
type: "button",
|
|
81
|
+
appearance: "ghost",
|
|
82
|
+
onClick: () =>
|
|
83
|
+
bridge
|
|
84
|
+
.send("action", { message_id: message.id, action: action.id })
|
|
85
|
+
.catch((error) => setNotice(error.message)),
|
|
86
|
+
},
|
|
87
|
+
action.label,
|
|
88
|
+
),
|
|
89
|
+
),
|
|
90
|
+
),
|
|
91
|
+
),
|
|
92
|
+
h("span", { role: "status" }, notice),
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
export function ReactChat({ state, bridge, connected, label }) {
|
|
97
|
+
const transcriptId = useId();
|
|
98
|
+
const [draft, setDraft] = useState("");
|
|
99
|
+
const [busy, setBusy] = useState(false);
|
|
100
|
+
const [notice, setNotice] = useState("");
|
|
101
|
+
const pending = useRef(false);
|
|
102
|
+
const alive = useRef(true);
|
|
103
|
+
const scroll = useRef(null);
|
|
104
|
+
const follow = useRef(true);
|
|
105
|
+
useEffect(
|
|
106
|
+
() => () => {
|
|
107
|
+
alive.current = false;
|
|
108
|
+
},
|
|
109
|
+
[],
|
|
110
|
+
);
|
|
111
|
+
useEffect(() => {
|
|
112
|
+
if (follow.current && scroll.current) scroll.current.scrollTop = scroll.current.scrollHeight;
|
|
113
|
+
}, [state.messages]);
|
|
114
|
+
const streaming = state.messages.some((message) => message.status === "streaming");
|
|
115
|
+
const replies = state.messages.filter((message) => message.role === "assistant").slice(-24);
|
|
116
|
+
const timelines = new Map(replies.map((message, index) => [message.id, index + 1]));
|
|
117
|
+
const targetId = (message) => `${transcriptId}-message-${encodeURIComponent(message.id)}`;
|
|
118
|
+
async function submit(event) {
|
|
119
|
+
event.preventDefault();
|
|
120
|
+
if (!draft.trim() || pending.current || streaming || !connected || state.needsSync) return;
|
|
121
|
+
const text = draft;
|
|
122
|
+
pending.current = true;
|
|
123
|
+
setBusy(true);
|
|
124
|
+
setNotice("");
|
|
125
|
+
try {
|
|
126
|
+
await bridge.send("send", { text, request_id: crypto.randomUUID() });
|
|
127
|
+
if (alive.current) setDraft((current) => (current === text ? "" : current));
|
|
128
|
+
} catch (error) {
|
|
129
|
+
if (alive.current) setNotice(error.message);
|
|
130
|
+
} finally {
|
|
131
|
+
pending.current = false;
|
|
132
|
+
if (alive.current) setBusy(false);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return h(
|
|
136
|
+
"section",
|
|
137
|
+
{ className: "flex flex-col gap-3 h-full", "aria-label": label },
|
|
138
|
+
h(
|
|
139
|
+
Chat.Scroll,
|
|
140
|
+
{
|
|
141
|
+
ref: scroll,
|
|
142
|
+
className: "flex-1 min-h-0",
|
|
143
|
+
"data-chat-messages": true,
|
|
144
|
+
onScroll: () => {
|
|
145
|
+
const el = scroll.current;
|
|
146
|
+
follow.current = el.scrollHeight - el.scrollTop - el.clientHeight < 48;
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
h(
|
|
150
|
+
Chat.ScrollTrack,
|
|
151
|
+
{ role: "navigation", "aria-label": "Assistant replies" },
|
|
152
|
+
replies.map((message) =>
|
|
153
|
+
h(Chat.ScrollIndicator, {
|
|
154
|
+
key: message.id,
|
|
155
|
+
targetId: targetId(message),
|
|
156
|
+
timeline: timelines.get(message.id),
|
|
157
|
+
onClick: () => {
|
|
158
|
+
follow.current = false;
|
|
159
|
+
},
|
|
160
|
+
}),
|
|
161
|
+
),
|
|
162
|
+
),
|
|
163
|
+
h(
|
|
164
|
+
Chat.ScrollBody,
|
|
165
|
+
{ className: "gap-3" },
|
|
166
|
+
state.messages.map((message) =>
|
|
167
|
+
h(Message, {
|
|
168
|
+
key: message.id,
|
|
169
|
+
message,
|
|
170
|
+
bridge,
|
|
171
|
+
targetId: targetId(message),
|
|
172
|
+
timeline: timelines.get(message.id),
|
|
173
|
+
}),
|
|
174
|
+
),
|
|
175
|
+
),
|
|
176
|
+
),
|
|
177
|
+
h(
|
|
178
|
+
Button,
|
|
179
|
+
{
|
|
180
|
+
type: "button",
|
|
181
|
+
appearance: "ghost",
|
|
182
|
+
onClick: () => {
|
|
183
|
+
follow.current = true;
|
|
184
|
+
scroll.current.scrollTop = scroll.current.scrollHeight;
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
"Jump to latest",
|
|
188
|
+
),
|
|
189
|
+
(!connected || state.needsSync) &&
|
|
190
|
+
h(
|
|
191
|
+
"p",
|
|
192
|
+
{ role: "status" },
|
|
193
|
+
connected ? "Synchronizing conversation…" : "Disconnected. Draft preserved.",
|
|
194
|
+
),
|
|
195
|
+
notice && h("p", { role: "alert" }, notice),
|
|
196
|
+
h(
|
|
197
|
+
"form",
|
|
198
|
+
{ className: "flex gap-2", onSubmit: submit },
|
|
199
|
+
h(Input, {
|
|
200
|
+
value: draft,
|
|
201
|
+
onChange: (event) => setDraft(event.target.value),
|
|
202
|
+
"aria-label": "Message",
|
|
203
|
+
placeholder: "Write a message",
|
|
204
|
+
}),
|
|
205
|
+
h(
|
|
206
|
+
Button,
|
|
207
|
+
{
|
|
208
|
+
type: "submit",
|
|
209
|
+
disabled: busy || streaming || !connected || state.needsSync || !draft.trim(),
|
|
210
|
+
},
|
|
211
|
+
"Send",
|
|
212
|
+
),
|
|
213
|
+
streaming &&
|
|
214
|
+
h(
|
|
215
|
+
Button,
|
|
216
|
+
{
|
|
217
|
+
type: "button",
|
|
218
|
+
disabled: !connected,
|
|
219
|
+
onClick: () => bridge.send("stop", {}).catch((error) => setNotice(error.message)),
|
|
220
|
+
},
|
|
221
|
+
"Stop",
|
|
222
|
+
),
|
|
223
|
+
),
|
|
224
|
+
);
|
|
225
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { createRoot } from "react-dom/client";
|
|
3
|
+
import { ReactChat } from "./chat.js";
|
|
4
|
+
import { initialState, reduceChat } from "./state.js";
|
|
5
|
+
|
|
6
|
+
export const DuskmoonReactChat = {
|
|
7
|
+
mounted() {
|
|
8
|
+
this.config = JSON.parse(this.el.dataset.chat);
|
|
9
|
+
this.state = initialState(this.config.conversationId, this.config.messages);
|
|
10
|
+
this.root = createRoot(this.el);
|
|
11
|
+
this.connected = true;
|
|
12
|
+
this.requests = new Set();
|
|
13
|
+
this.bridge = { send: (action, payload) => this.sendChat(action, payload) };
|
|
14
|
+
this.eventRef = this.handleEvent(this.config.event, (event) => {
|
|
15
|
+
if (event.id !== this.el.id) return;
|
|
16
|
+
const previous = this.state;
|
|
17
|
+
this.state = reduceChat(this.state, event);
|
|
18
|
+
if (previous === this.state) return;
|
|
19
|
+
if (this.state.needsSync && !previous.needsSync) this.requestSync();
|
|
20
|
+
this.scheduleRender();
|
|
21
|
+
});
|
|
22
|
+
this.renderChat();
|
|
23
|
+
},
|
|
24
|
+
sendChat(action, payload) {
|
|
25
|
+
if (!this.connected || this.dead)
|
|
26
|
+
return Promise.reject(new Error("Chat is disconnected"));
|
|
27
|
+
return new Promise((resolve, reject) => {
|
|
28
|
+
const finish = (error, reply) => {
|
|
29
|
+
if (!this.requests.delete(request)) return;
|
|
30
|
+
clearTimeout(request.timer);
|
|
31
|
+
error ? reject(error) : resolve(reply);
|
|
32
|
+
};
|
|
33
|
+
const request = {
|
|
34
|
+
cancel: () =>
|
|
35
|
+
finish(new Error("Chat disconnected; check status before retrying")),
|
|
36
|
+
};
|
|
37
|
+
this.requests.add(request);
|
|
38
|
+
request.timer = setTimeout(
|
|
39
|
+
() =>
|
|
40
|
+
finish(
|
|
41
|
+
new Error("Chat request timed out; check status before retrying"),
|
|
42
|
+
),
|
|
43
|
+
15000,
|
|
44
|
+
);
|
|
45
|
+
const body = {
|
|
46
|
+
...payload,
|
|
47
|
+
id: this.el.id,
|
|
48
|
+
conversation_id: this.state.conversationId,
|
|
49
|
+
};
|
|
50
|
+
const reply = (response) =>
|
|
51
|
+
finish(
|
|
52
|
+
response?.status === "ok"
|
|
53
|
+
? null
|
|
54
|
+
: new Error(response?.message || "Chat request rejected"),
|
|
55
|
+
response,
|
|
56
|
+
);
|
|
57
|
+
try {
|
|
58
|
+
const event = this.config.actions[action];
|
|
59
|
+
if (this.config.target)
|
|
60
|
+
this.pushEventTo(this.config.target, event, body, reply);
|
|
61
|
+
else this.pushEvent(event, body, reply);
|
|
62
|
+
} catch (error) {
|
|
63
|
+
finish(error);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
},
|
|
67
|
+
requestSync() {
|
|
68
|
+
this.sendChat("sync", { sequences: this.state.sequences }).catch(() => {});
|
|
69
|
+
},
|
|
70
|
+
scheduleRender() {
|
|
71
|
+
if (!this.frame)
|
|
72
|
+
this.frame = requestAnimationFrame(() => {
|
|
73
|
+
this.frame = null;
|
|
74
|
+
if (!this.dead) this.renderChat();
|
|
75
|
+
});
|
|
76
|
+
},
|
|
77
|
+
renderChat() {
|
|
78
|
+
this.root.render(
|
|
79
|
+
React.createElement(ReactChat, {
|
|
80
|
+
state: this.state,
|
|
81
|
+
bridge: this.bridge,
|
|
82
|
+
connected: this.connected,
|
|
83
|
+
label: this.config.label,
|
|
84
|
+
}),
|
|
85
|
+
);
|
|
86
|
+
},
|
|
87
|
+
disconnected() {
|
|
88
|
+
this.connected = false;
|
|
89
|
+
this.requests.forEach((request) => request.cancel());
|
|
90
|
+
this.renderChat();
|
|
91
|
+
},
|
|
92
|
+
reconnected() {
|
|
93
|
+
this.connected = true;
|
|
94
|
+
this.state = { ...this.state, needsSync: true };
|
|
95
|
+
this.renderChat();
|
|
96
|
+
this.requestSync();
|
|
97
|
+
},
|
|
98
|
+
destroyed() {
|
|
99
|
+
this.dead = true;
|
|
100
|
+
cancelAnimationFrame(this.frame);
|
|
101
|
+
this.removeHandleEvent(this.eventRef);
|
|
102
|
+
this.requests.forEach((request) => request.cancel());
|
|
103
|
+
this.root.unmount();
|
|
104
|
+
},
|
|
105
|
+
};
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
export function initialState(conversationId, messages = []) {
|
|
2
|
+
return {
|
|
3
|
+
conversationId,
|
|
4
|
+
messages,
|
|
5
|
+
sequences: {},
|
|
6
|
+
error: null,
|
|
7
|
+
needsSync: false,
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function reduceChat(state, event) {
|
|
12
|
+
if (event?.conversation_id !== state.conversationId) return state;
|
|
13
|
+
if (["chat.snapshot", "chat.reset"].includes(event.type)) {
|
|
14
|
+
return {
|
|
15
|
+
...initialState(state.conversationId, event.messages || []),
|
|
16
|
+
sequences: event.sequences || {},
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
if (
|
|
20
|
+
!event.message_id ||
|
|
21
|
+
!Number.isSafeInteger(event.sequence) ||
|
|
22
|
+
event.sequence < 1
|
|
23
|
+
)
|
|
24
|
+
return state;
|
|
25
|
+
const last = state.sequences[event.message_id] || 0;
|
|
26
|
+
if (event.sequence <= last) return state;
|
|
27
|
+
if (event.sequence !== last + 1) return { ...state, needsSync: true };
|
|
28
|
+
if (state.needsSync) return state;
|
|
29
|
+
const index = state.messages.findIndex(
|
|
30
|
+
(message) => message.id === event.message_id,
|
|
31
|
+
);
|
|
32
|
+
let messages = state.messages;
|
|
33
|
+
if (event.type === "chat.message") {
|
|
34
|
+
if (!event.message || event.message.id !== event.message_id) return state;
|
|
35
|
+
messages =
|
|
36
|
+
index < 0
|
|
37
|
+
? [...messages, event.message]
|
|
38
|
+
: messages.map((m, i) => (i === index ? event.message : m));
|
|
39
|
+
} else {
|
|
40
|
+
if (index < 0) return { ...state, needsSync: true };
|
|
41
|
+
const message = messages[index];
|
|
42
|
+
let next;
|
|
43
|
+
switch (event.type) {
|
|
44
|
+
case "chat.delta":
|
|
45
|
+
if (message.status !== "streaming" || typeof event.text !== "string")
|
|
46
|
+
return state;
|
|
47
|
+
next = { ...message, content: (message.content || "") + event.text };
|
|
48
|
+
break;
|
|
49
|
+
case "chat.tool":
|
|
50
|
+
if (!event.tool?.id) return state;
|
|
51
|
+
next = {
|
|
52
|
+
...message,
|
|
53
|
+
tools: [
|
|
54
|
+
...(message.tools || []).filter(
|
|
55
|
+
(tool) => tool.id !== event.tool.id,
|
|
56
|
+
),
|
|
57
|
+
event.tool,
|
|
58
|
+
],
|
|
59
|
+
};
|
|
60
|
+
break;
|
|
61
|
+
case "chat.complete":
|
|
62
|
+
next = {
|
|
63
|
+
...message,
|
|
64
|
+
status: event.status === "cancelled" ? "cancelled" : "complete",
|
|
65
|
+
};
|
|
66
|
+
break;
|
|
67
|
+
case "chat.error":
|
|
68
|
+
next = {
|
|
69
|
+
...message,
|
|
70
|
+
status: "error",
|
|
71
|
+
error: event.message || "Generation failed",
|
|
72
|
+
};
|
|
73
|
+
break;
|
|
74
|
+
default:
|
|
75
|
+
return state;
|
|
76
|
+
}
|
|
77
|
+
messages = messages.map((m, i) => (i === index ? next : m));
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
...state,
|
|
81
|
+
messages,
|
|
82
|
+
sequences: { ...state.sequences, [event.message_id]: event.sequence },
|
|
83
|
+
};
|
|
84
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import React, { useId } from "react";
|
|
2
|
+
import { Form } from "@duskmoon-dev/components/form";
|
|
3
|
+
import { Input } from "@duskmoon-dev/components/input";
|
|
4
|
+
import { InputNumber } from "@duskmoon-dev/components/input-number";
|
|
5
|
+
import { Checkbox } from "@duskmoon-dev/components/checkbox";
|
|
6
|
+
import { Select } from "@duskmoon-dev/components/select";
|
|
7
|
+
|
|
8
|
+
const h = React.createElement;
|
|
9
|
+
|
|
10
|
+
export function ReactField({ config, value, errors, disabled, onChange }) {
|
|
11
|
+
const errorId = useId();
|
|
12
|
+
const label = config.label || (Array.isArray(config.name) ? config.name.join(".") : config.name);
|
|
13
|
+
const common = { disabled, "aria-label": label, "aria-invalid": errors?.length ? "true" : undefined, "aria-describedby": errors?.length ? errorId : undefined };
|
|
14
|
+
let control;
|
|
15
|
+
|
|
16
|
+
switch (config.type) {
|
|
17
|
+
case "checkbox":
|
|
18
|
+
control = h(Checkbox, { ...common, checked: Boolean(value), onChange: (event) => onChange(event.target.checked) });
|
|
19
|
+
break;
|
|
20
|
+
case "number":
|
|
21
|
+
control = h(InputNumber, { ...common, value: value ?? null, onChange });
|
|
22
|
+
break;
|
|
23
|
+
case "select":
|
|
24
|
+
case "multiselect":
|
|
25
|
+
control = h(Select, { ...common, value: value ?? undefined, mode: config.type === "multiselect" ? "multiple" : undefined,
|
|
26
|
+
options: config.options || [], showSearch: true, allowClear: true, onChange: (next) => onChange(next ?? null) });
|
|
27
|
+
break;
|
|
28
|
+
case "textarea":
|
|
29
|
+
control = h("textarea", { ...common, className: "textarea", value: value ?? "", onChange: (event) => onChange(event.target.value) });
|
|
30
|
+
break;
|
|
31
|
+
default:
|
|
32
|
+
control = h(Input, { ...common, type: config.type, value: value ?? "", onChange: (event) => onChange(event.target.value) });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return h(Form.Item, {
|
|
36
|
+
label,
|
|
37
|
+
className: errors?.length ? "form-item-error" : undefined,
|
|
38
|
+
help: errors?.length ? h(Form.ErrorList, { id: errorId, errors, role: "alert" }) : undefined,
|
|
39
|
+
}, control);
|
|
40
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { createRoot } from "react-dom/client";
|
|
3
|
+
import { ReactField } from "./component.js";
|
|
4
|
+
import { compileForm } from "@duskmoon-dev/components/json-schema-form";
|
|
5
|
+
import { ReactSchemaForm, changedPath, schemaErrors } from "./schema.js";
|
|
6
|
+
import { collectValues, fieldKey, getAt, pathOf, setAt } from "./values.js";
|
|
7
|
+
function parseJSON(value, fallback = {}) { try { return JSON.parse(value); } catch { return fallback; } }
|
|
8
|
+
function flattenErrors(errors, prefix = [], result = {}) {
|
|
9
|
+
Object.entries(errors || {}).forEach(([key, value]) => {
|
|
10
|
+
const path = [...prefix, key];
|
|
11
|
+
if (Array.isArray(value)) result[path.join(".")] = value;
|
|
12
|
+
else if (value && typeof value === "object") flattenErrors(value, path, result);
|
|
13
|
+
});
|
|
14
|
+
return result;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const DuskmoonReactForm = {
|
|
18
|
+
mounted() {
|
|
19
|
+
this.revision = 0; this.roots = new Map(); this.disconnectedState = false;
|
|
20
|
+
this.schemaElement = this.el.querySelector("[data-dm-react-schema]");
|
|
21
|
+
if (this.schemaElement) this.mountSchema();
|
|
22
|
+
else this.mountForm();
|
|
23
|
+
this.handleEvent("dm:form:reset", (payload) => {
|
|
24
|
+
if (payload?.id === this.el.id) this.resetForm(payload.values ?? this.initialValues);
|
|
25
|
+
});
|
|
26
|
+
},
|
|
27
|
+
mountForm() {
|
|
28
|
+
this.form = this.el.querySelector("[data-dm-react-form]");
|
|
29
|
+
const nativeValues = collectValues([...this.form.querySelectorAll("[name]")]);
|
|
30
|
+
this.values = { ...nativeValues, ...parseJSON(this.el.dataset.initialValues) };
|
|
31
|
+
this.initialValues = structuredClone(this.values);
|
|
32
|
+
this.fields = [...this.el.querySelectorAll("[data-dm-react-field]")];
|
|
33
|
+
const fieldKeys = this.fields.map((element) => fieldKey(parseJSON(element.dataset.dmReactField).name));
|
|
34
|
+
if (new Set(fieldKeys).size !== fieldKeys.length) throw new Error("React form field names must be unique");
|
|
35
|
+
this.mountFields(); this.bindNative(); this.bindForm();
|
|
36
|
+
},
|
|
37
|
+
mountSchema() {
|
|
38
|
+
this.compiledSchema = compileForm(JSON.parse(this.el.dataset.schema));
|
|
39
|
+
this.values = parseJSON(this.el.dataset.initialValues, this.compiledSchema.initialValue());
|
|
40
|
+
this.initialValues = structuredClone(this.values);
|
|
41
|
+
this.schemaRevision = 0; this.schemaRoot = createRoot(this.schemaElement);
|
|
42
|
+
this.renderSchema();
|
|
43
|
+
},
|
|
44
|
+
renderSchema() {
|
|
45
|
+
this.schemaRoot.render(React.createElement(ReactSchemaForm, {
|
|
46
|
+
key: this.schemaRevision, compiled: this.compiledSchema, value: this.values,
|
|
47
|
+
defaultValue: this.initialValues, errors: this.errors, disabled: this.disconnectedState,
|
|
48
|
+
onChange: (values) => {
|
|
49
|
+
const path = changedPath(this.values, values);
|
|
50
|
+
this.values = structuredClone(values); this.errors = {}; this.renderAll(); this.scheduleChange(path);
|
|
51
|
+
},
|
|
52
|
+
onErrorsChange: () => { this.errors = {}; this.renderAll(); },
|
|
53
|
+
onSubmit: (values) => { this.values = structuredClone(values); this.submit(); },
|
|
54
|
+
onReset: () => this.resetForm(),
|
|
55
|
+
}));
|
|
56
|
+
},
|
|
57
|
+
mountFields() {
|
|
58
|
+
this.fields.forEach((element) => {
|
|
59
|
+
const config = parseJSON(element.dataset.dmReactField);
|
|
60
|
+
const key = fieldKey(config.name); const root = createRoot(element);
|
|
61
|
+
this.roots.set(key, { config, root }); this.renderField(element);
|
|
62
|
+
});
|
|
63
|
+
},
|
|
64
|
+
renderField(element) {
|
|
65
|
+
const config = parseJSON(element.dataset.dmReactField); const entry = this.roots.get(fieldKey(config.name));
|
|
66
|
+
entry?.root.render(React.createElement(ReactField, {
|
|
67
|
+
config, value: getAt(this.values, config.name), errors: this.errors?.[fieldKey(config.name)], disabled: this.disconnectedState,
|
|
68
|
+
onChange: (value) => this.change(config.name, value),
|
|
69
|
+
}));
|
|
70
|
+
},
|
|
71
|
+
bindNative() {
|
|
72
|
+
this.native = [...this.form.querySelectorAll("[name]")].filter((element) => !element.closest("[data-dm-react-field]"));
|
|
73
|
+
this.native.forEach((element) => {
|
|
74
|
+
const handler = () => this.change(element.name, this.nativeValue(element));
|
|
75
|
+
element.addEventListener("input", handler); element.addEventListener("change", handler); element.__dmReactHandler = handler;
|
|
76
|
+
});
|
|
77
|
+
},
|
|
78
|
+
nativeValue(element) {
|
|
79
|
+
if (element.type === "checkbox") return element.checked;
|
|
80
|
+
if (element.type === "number") return element.value === "" ? null : Number(element.value);
|
|
81
|
+
if (element.multiple) return [...element.selectedOptions].map((option) => option.value);
|
|
82
|
+
return element.value;
|
|
83
|
+
},
|
|
84
|
+
bindForm() {
|
|
85
|
+
this.submitHandler = (event) => { event.preventDefault(); this.submit(); };
|
|
86
|
+
this.resetHandler = () => setTimeout(() => this.resetForm(), 0);
|
|
87
|
+
this.form.addEventListener("submit", this.submitHandler); this.form.addEventListener("reset", this.resetHandler);
|
|
88
|
+
},
|
|
89
|
+
change(name, value) {
|
|
90
|
+
this.values = setAt(this.values, name, value); this.errors = { ...this.errors, [fieldKey(name)]: [] }; this.renderAll();
|
|
91
|
+
this.scheduleChange(name);
|
|
92
|
+
},
|
|
93
|
+
scheduleChange(name) {
|
|
94
|
+
const event = this.el.getAttribute("phx-change"); if (!event || this.disconnectedState) return;
|
|
95
|
+
clearTimeout(this.changeTimer); this.changeTimer = setTimeout(() => this.send(event, name), Number(this.el.getAttribute("phx-debounce") || 0));
|
|
96
|
+
},
|
|
97
|
+
submit() { clearTimeout(this.changeTimer); const event = this.el.getAttribute("phx-submit"); if (event && !this.disconnectedState) this.send(event); },
|
|
98
|
+
send(event, changed) {
|
|
99
|
+
const revision = ++this.revision; const values = this.values;
|
|
100
|
+
const payload = { id: this.el.id, values, revision, ...(changed ? { changed: pathOf(changed) } : {}) };
|
|
101
|
+
const target = this.el.getAttribute("phx-target"); const callback = (reply) => {
|
|
102
|
+
if (this.destroyedState || revision !== this.revision || values !== this.values || !reply) return;
|
|
103
|
+
this.errors = this.schemaRoot ? schemaErrors(reply.errors) : flattenErrors(reply.errors); this.renderAll();
|
|
104
|
+
};
|
|
105
|
+
if (target) this.pushEventTo(target, event, payload, callback); else this.pushEvent(event, payload, callback);
|
|
106
|
+
},
|
|
107
|
+
renderAll() { if (this.schemaRoot) this.renderSchema(); else this.fields.forEach((element) => this.renderField(element)); },
|
|
108
|
+
resetForm(values = this.initialValues) {
|
|
109
|
+
clearTimeout(this.changeTimer);
|
|
110
|
+
this.values = structuredClone(values); this.errors = {}; this.revision++;
|
|
111
|
+
if (this.schemaRoot) { this.schemaRevision++; this.renderAll(); return; }
|
|
112
|
+
this.native.forEach((element) => { if (element.type === "checkbox") element.checked = Boolean(getAt(this.values, element.name)); else element.value = getAt(this.values, element.name) ?? ""; }); this.renderAll();
|
|
113
|
+
},
|
|
114
|
+
disconnected() { clearTimeout(this.changeTimer); this.disconnectedState = true; this.renderAll(); },
|
|
115
|
+
reconnected() { this.disconnectedState = false; this.renderAll(); },
|
|
116
|
+
destroyed() { this.destroyedState = true; clearTimeout(this.changeTimer); this.schemaRoot?.unmount(); this.form?.removeEventListener("submit", this.submitHandler); this.form?.removeEventListener("reset", this.resetHandler); this.native?.forEach((element) => { element.removeEventListener("input", element.__dmReactHandler); element.removeEventListener("change", element.__dmReactHandler); }); this.roots.forEach(({ root }) => root.unmount()); },
|
|
117
|
+
};
|