Skip to content

Commit f30b873

Browse files
authored
Merge pull request #93 from fedify-dev/feat/create-obj
Add object create page
2 parents 312ffe9 + 85edfad commit f30b873

4 files changed

Lines changed: 548 additions & 2 deletions

File tree

‎packages/web/src/routes/instance/[slug].tsx‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -127,14 +127,24 @@ export default function InstanceDetailPage(
127127

128128
<header class={styles.header}>
129129
<h1>{instance().host}</h1>
130+
</header>
131+
132+
<section class={styles.toolbar}>
133+
<A
134+
class={styles.createButton}
135+
href={`/workspace/create/${instance().id}/objects`}
136+
>
137+
<span aria-hidden="true">+</span>
138+
Object
139+
</A>
130140
<A
131141
class={styles.createButton}
132142
href={`/workspace/create/${instance().id}/actors`}
133143
>
134144
<span aria-hidden="true">+</span>
135-
Create actor
145+
Actor
136146
</A>
137-
</header>
147+
</section>
138148

139149
<section class={styles.details} aria-labelledby="connection-title">
140150
<div>
Lines changed: 367 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,367 @@
1+
// DrFed: A web-based platform for developing and debugging ActivityPub apps
2+
// Copyright (C) 2026 DrFed team
3+
//
4+
// This program is free software: you can redistribute it and/or modify
5+
// it under the terms of the GNU Affero General Public License as published by
6+
// the Free Software Foundation, either version 3 of the License, or
7+
// (at your option) any later version.
8+
//
9+
// This program is distributed in the hope that it will be useful,
10+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
// GNU Affero General Public License for more details.
13+
//
14+
// You should have received a copy of the GNU Affero General Public License
15+
// along with this program. If not, see <https://www.gnu.org/licenses/>.
16+
17+
/* eslint-disable unicorn/no-null -- Kobalte requires null for an empty selection. */
18+
19+
import { Select } from "@kobalte/core/select";
20+
import { TextField } from "@kobalte/core/text-field";
21+
import { Title } from "@solidjs/meta";
22+
import {
23+
type RouteDefinition,
24+
type RouteSectionProps,
25+
query,
26+
} from "@solidjs/router";
27+
import { graphql } from "relay-runtime";
28+
import { Show, createSignal } from "solid-js";
29+
import {
30+
createMutation,
31+
createPreloadedQuery,
32+
loadQuery,
33+
useRelayEnvironment,
34+
} from "solid-relay";
35+
import * as v from "valibot";
36+
37+
import { showToast } from "~/components/Toast.tsx";
38+
39+
import type { CreateObjectMutation } from "./__generated__/CreateObjectMutation.graphql.ts";
40+
import type { InstanceActorListQuery } from "./__generated__/InstanceActorListQuery.graphql.ts";
41+
42+
import styles from "~/styles/form.module.css";
43+
import objectStyles from "~/styles/object.module.css";
44+
45+
const instanceActorListQuery = graphql`
46+
query InstanceActorListQuery($instanceId: ID!) {
47+
instance: node(id: $instanceId) {
48+
... on Instance {
49+
id
50+
host
51+
url
52+
actors(first: 100) {
53+
totalCount
54+
edges {
55+
node {
56+
handle
57+
id
58+
}
59+
}
60+
}
61+
}
62+
}
63+
}
64+
`;
65+
66+
const createObjectMutation = graphql`
67+
mutation CreateObjectMutation(
68+
$actor: ID!
69+
$contentHtml: String!
70+
$type: ObjectType!
71+
$addressing: AddressingInput!
72+
$name: String
73+
) {
74+
createObject(
75+
actor: $actor
76+
contentHtml: $contentHtml
77+
type: $type
78+
addressing: $addressing
79+
name: $name
80+
) {
81+
resultType: __typename
82+
... on Object {
83+
id
84+
}
85+
... on CreateObjectError {
86+
message
87+
}
88+
}
89+
}
90+
`;
91+
92+
const recipientSchema = v.pipe(v.string(), v.url());
93+
const recipientsSchema = v.optional(v.array(recipientSchema), []);
94+
const createObjectSchema = v.object({
95+
actor: v.pipe(v.string("Select an actor."), v.nonEmpty("Select an actor.")),
96+
name: v.string(),
97+
contentHtml: v.pipe(
98+
v.string(),
99+
v.check((content) => content.trim() !== "", "Enter content."),
100+
),
101+
type: v.picklist(["Note", "Article"]),
102+
addressing: v.strictObject({
103+
to: recipientsSchema,
104+
cc: recipientsSchema,
105+
bto: recipientsSchema,
106+
bcc: recipientsSchema,
107+
audience: recipientsSchema,
108+
}),
109+
});
110+
111+
const loadInstanceActorListQuery = query(
112+
(instanceId: string) =>
113+
loadQuery<InstanceActorListQuery>(
114+
useRelayEnvironment()(),
115+
instanceActorListQuery,
116+
{ instanceId },
117+
),
118+
"InstanceActorListQuery",
119+
);
120+
121+
export const route = {
122+
preload({ params }) {
123+
if (params.instance_id === undefined) {
124+
throw new Error("Missing instance_id route parameter.");
125+
}
126+
return loadInstanceActorListQuery(params.instance_id);
127+
},
128+
} satisfies RouteDefinition;
129+
130+
type RouteData = ReturnType<typeof loadInstanceActorListQuery>;
131+
interface ActorOption {
132+
readonly id: string;
133+
readonly handle: string;
134+
}
135+
136+
export default function CreateObjectsPage(props: RouteSectionProps<RouteData>) {
137+
const data = createPreloadedQuery<InstanceActorListQuery>(
138+
instanceActorListQuery,
139+
() => props.data,
140+
);
141+
142+
const actors = () => data()?.instance?.actors?.edges;
143+
144+
const [currentActor, setCurrentActor] = createSignal<ActorOption | null>(
145+
null,
146+
);
147+
148+
const [commitCreateObject, isCreating] =
149+
createMutation<CreateObjectMutation>(createObjectMutation);
150+
const [message, setMessage] = createSignal<string>("");
151+
152+
const submit = (form: HTMLFormElement) => {
153+
if (isCreating()) return;
154+
setMessage("");
155+
const formData = new FormData(form);
156+
const addressingText = formData.get("addressing");
157+
let addressing: unknown;
158+
const nameText = formData.get("name");
159+
const name = typeof nameText === "string" ? nameText : "";
160+
try {
161+
addressing = JSON.parse(
162+
typeof addressingText === "string" ? addressingText : "{}",
163+
);
164+
} catch {
165+
setMessage("Addressing must be valid JSON.");
166+
showToast(message(), "fail");
167+
return;
168+
}
169+
const input = v.safeParse(createObjectSchema, {
170+
actor: (currentActor() ?? actors()?.[0]?.node)?.id,
171+
name,
172+
contentHtml: formData.get("content"),
173+
type: formData.get("type"),
174+
addressing,
175+
});
176+
if (!input.success) {
177+
setMessage(input.issues.map((issue) => issue.message).join("\n"));
178+
showToast(message(), "fail");
179+
return;
180+
}
181+
commitCreateObject({
182+
variables: input.output,
183+
onCompleted(response, errors) {
184+
if (errors != null && errors.length > 0) {
185+
setMessage(errors.map((error) => error.message).join("\n"));
186+
showToast(message(), "fail");
187+
188+
return;
189+
}
190+
const result = response.createObject;
191+
if (result.resultType === "Object") {
192+
form.reset();
193+
setMessage("Object created successfully.");
194+
showToast(message(), "success");
195+
} else {
196+
setMessage(
197+
result.resultType === "CreateObjectError"
198+
? result.message
199+
: "Unable to create the object.",
200+
);
201+
showToast(message(), "fail");
202+
}
203+
},
204+
onError: (error) => {
205+
setMessage(error.message);
206+
showToast(message(), "fail");
207+
},
208+
});
209+
};
210+
211+
return (
212+
<Show
213+
when={actors()}
214+
fallback={
215+
<main class={styles.page}>
216+
<p role="alert">Instance not found.</p>
217+
</main>
218+
}
219+
>
220+
{(actorList) => (
221+
<main class={styles.page}>
222+
<Title>Create Object</Title>
223+
<section
224+
class={`${styles.panel} ${objectStyles.panel}`}
225+
aria-labelledby="create-object-title"
226+
>
227+
<header class={styles.header}>
228+
<h1 id="create-object-title">Create Object</h1>
229+
<p>Selected Instance: {data()?.instance?.host}</p>
230+
</header>
231+
232+
<form
233+
class={styles.form}
234+
aria-busy={isCreating()}
235+
onSubmit={(event) => {
236+
event.preventDefault();
237+
submit(event.currentTarget);
238+
}}
239+
onReset={() => {
240+
setCurrentActor(null);
241+
setMessage("");
242+
}}
243+
>
244+
<fieldset class={objectStyles.fields} disabled={isCreating()}>
245+
<div class={objectStyles.identity}>
246+
<Select<ActorOption>
247+
class={styles.field}
248+
disabled={isCreating()}
249+
value={currentActor() ?? actorList()[0]?.node ?? null}
250+
onChange={setCurrentActor}
251+
optionValue="id"
252+
optionTextValue="handle"
253+
placeholder="Select Handle"
254+
options={actorList().map((actor) => actor.node)}
255+
itemComponent={(itemProps) => (
256+
<Select.Item
257+
class={objectStyles.option}
258+
item={itemProps.item}
259+
>
260+
<Select.ItemLabel>
261+
{itemProps.item.rawValue.handle}
262+
</Select.ItemLabel>
263+
<Select.ItemIndicator aria-hidden="true">
264+
✓
265+
</Select.ItemIndicator>
266+
</Select.Item>
267+
)}
268+
>
269+
<Select.Label class={objectStyles.label}>
270+
Actor
271+
</Select.Label>
272+
<Select.Trigger
273+
class={`${styles.input} ${objectStyles.control} ${objectStyles.trigger}`}
274+
type="button"
275+
>
276+
<Select.Value<ActorOption>>
277+
{(state) => state.selectedOption().handle}
278+
</Select.Value>
279+
<Select.Icon aria-hidden="true">▾</Select.Icon>
280+
</Select.Trigger>
281+
<Select.Portal>
282+
<Select.Content class={objectStyles.selectContent}>
283+
<Select.Listbox class={objectStyles.listbox} />
284+
</Select.Content>
285+
</Select.Portal>
286+
</Select>
287+
288+
<label class={styles.field}>
289+
<span class={objectStyles.label}>Type</span>
290+
<select
291+
class={`${styles.input} ${objectStyles.control}`}
292+
name="type"
293+
>
294+
<option value="Note" selected>
295+
Note
296+
</option>
297+
<option value="Article">Article</option>
298+
</select>
299+
</label>
300+
</div>
301+
302+
<TextField class={styles.field} name="name" defaultValue="">
303+
<TextField.Label class={styles.fieldHeading}>
304+
Name <span class={styles.fieldStatus}>Optional</span>
305+
</TextField.Label>
306+
<TextField.Input
307+
class={styles.input}
308+
placeholder="Object title"
309+
/>
310+
</TextField>
311+
<TextField class={styles.field} name="content" required>
312+
<TextField.Label class={styles.fieldHeading}>
313+
Content <span class={styles.required}>Required</span>
314+
</TextField.Label>
315+
<TextField.TextArea
316+
class={`${styles.input} ${objectStyles.content}`}
317+
rows={8}
318+
placeholder="<p>Write your content here.</p>"
319+
/>
320+
<TextField.Description class={styles.hint}>
321+
HTML content is stored as entered. This object will not be
322+
delivered to remote servers.
323+
</TextField.Description>
324+
</TextField>
325+
<TextField
326+
class={styles.field}
327+
name="addressing"
328+
defaultValue="{}"
329+
>
330+
<TextField.Label class={styles.fieldHeading}>
331+
Addressing <span class={styles.fieldStatus}>JSON</span>
332+
</TextField.Label>
333+
<TextField.TextArea
334+
class={`${styles.input} ${objectStyles.addressing}`}
335+
rows={4}
336+
spellcheck={false}
337+
/>
338+
<TextField.Description class={styles.hint}>
339+
Use to, cc, bto, bcc, or audience with arrays of recipient
340+
URLs. Leave {"{}"} for no recipients.
341+
</TextField.Description>
342+
</TextField>
343+
<Show when={actorList().length === 0}>
344+
<p class={styles.hint}>
345+
Create an actor in this instance before creating an object.
346+
</p>
347+
</Show>
348+
<div class={objectStyles.actions}>
349+
<button class={objectStyles.reset} type="reset">
350+
Reset
351+
</button>
352+
<button
353+
class={styles.button}
354+
type="submit"
355+
disabled={isCreating() || (actors()?.length ?? 0) === 0}
356+
>
357+
{isCreating() ? "Creating…" : "Create Object"}
358+
</button>
359+
</div>
360+
</fieldset>
361+
</form>
362+
</section>
363+
</main>
364+
)}
365+
</Show>
366+
);
367+
}

0 commit comments

Comments
 (0)