@conexus-x/next-view 1.0.3 → 1.0.4
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/app/dev/AddBoardForm.tsx +44 -0
- package/app/dev/CopyId.tsx +30 -0
- package/app/dev/actions.ts +73 -0
- package/app/dev/page.tsx +14 -9
- package/app/page.tsx +96 -43
- package/package.json +2 -2
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useActionState } from "react";
|
|
4
|
+
|
|
5
|
+
import { createBoard, type CreateBoardState } from "./actions";
|
|
6
|
+
|
|
7
|
+
const initialState: CreateBoardState = { error: null };
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Add a board to the pinned workspace, without leaving this page.
|
|
11
|
+
*
|
|
12
|
+
* `useActionState` gives the pending state and the returned error for free —
|
|
13
|
+
* the action itself does the actual POST, entirely on the server (see
|
|
14
|
+
* actions.ts). This form never sees the API key.
|
|
15
|
+
*/
|
|
16
|
+
export default function AddBoardForm({ workspaceId }: { workspaceId: string }) {
|
|
17
|
+
const [state, formAction, pending] = useActionState(createBoard, initialState);
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<form action={formAction} className="mb-4 flex items-center gap-2">
|
|
21
|
+
<input type="hidden" name="workspaceId" value={workspaceId} />
|
|
22
|
+
|
|
23
|
+
<input
|
|
24
|
+
name="name"
|
|
25
|
+
placeholder="New board name"
|
|
26
|
+
required
|
|
27
|
+
disabled={pending}
|
|
28
|
+
className="min-w-0 flex-1 rounded-lg border border-line bg-card px-3 py-1.5 text-[13px] outline-none transition placeholder:text-muted focus:border-accent disabled:opacity-60"
|
|
29
|
+
/>
|
|
30
|
+
|
|
31
|
+
<button
|
|
32
|
+
type="submit"
|
|
33
|
+
disabled={pending}
|
|
34
|
+
className="shrink-0 rounded-lg bg-accent px-3 py-1.5 text-[13px] font-medium text-white transition hover:bg-accent-hover disabled:cursor-not-allowed disabled:opacity-60"
|
|
35
|
+
>
|
|
36
|
+
{pending ? "Adding…" : "Add board"}
|
|
37
|
+
</button>
|
|
38
|
+
|
|
39
|
+
{state.error ? (
|
|
40
|
+
<span className="text-[12px] text-danger">{state.error}</span>
|
|
41
|
+
) : null}
|
|
42
|
+
</form>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Copies a workspace or board id.
|
|
7
|
+
*
|
|
8
|
+
* The one piece of this page that has to run in the browser — clipboard access
|
|
9
|
+
* has no server-side equivalent — so it is its own tiny client component rather
|
|
10
|
+
* than turning the whole page client-side and losing the guarantee that the
|
|
11
|
+
* API key never ships to the browser.
|
|
12
|
+
*/
|
|
13
|
+
export default function CopyId({ value }: { value: string }) {
|
|
14
|
+
const [copied, setCopied] = useState(false);
|
|
15
|
+
|
|
16
|
+
return (
|
|
17
|
+
<button
|
|
18
|
+
type="button"
|
|
19
|
+
onClick={async () => {
|
|
20
|
+
await navigator.clipboard.writeText(value);
|
|
21
|
+
setCopied(true);
|
|
22
|
+
setTimeout(() => setCopied(false), 1500);
|
|
23
|
+
}}
|
|
24
|
+
title={copied ? "Copied" : `Copy ${value}`}
|
|
25
|
+
className="shrink-0 rounded-md border border-line px-2 py-0.5 font-mono text-[11px] text-muted transition hover:border-accent hover:text-accent"
|
|
26
|
+
>
|
|
27
|
+
{copied ? "Copied" : value}
|
|
28
|
+
</button>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"use server";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The one write the developer page needs: creating a board.
|
|
5
|
+
*
|
|
6
|
+
* A SERVER ACTION, for the same reason page.tsx is a Server Component — the
|
|
7
|
+
* function body, including every reference to `process.env.API_KEY`, stays on
|
|
8
|
+
* the server. Next compiles the client side down to an action id plus a
|
|
9
|
+
* dispatcher; the key it captures is never serialised into that reference.
|
|
10
|
+
* See node_modules/next/dist/docs/01-app/02-guides/server-actions.md
|
|
11
|
+
* "Closure variable encryption" for the mechanism this relies on.
|
|
12
|
+
*
|
|
13
|
+
* Deliberately the ONLY write on this page. Renaming or deleting a board from
|
|
14
|
+
* a debug screen is a bigger blast radius than a debug screen should carry —
|
|
15
|
+
* creating one is reversible in one click from Conexus X itself.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { revalidatePath } from "next/cache";
|
|
19
|
+
|
|
20
|
+
const PLACEHOLDER = "Conexus_x_api_key";
|
|
21
|
+
|
|
22
|
+
export interface CreateBoardState {
|
|
23
|
+
error: string | null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function createBoard(
|
|
27
|
+
_previous: CreateBoardState,
|
|
28
|
+
formData: FormData
|
|
29
|
+
): Promise<CreateBoardState> {
|
|
30
|
+
const apiKey = process.env.API_KEY;
|
|
31
|
+
const workspaceId = String(formData.get("workspaceId") ?? "").trim();
|
|
32
|
+
const name = String(formData.get("name") ?? "").trim();
|
|
33
|
+
|
|
34
|
+
if (!apiKey || apiKey === PLACEHOLDER) {
|
|
35
|
+
return { error: "API_KEY in .env is missing or still the placeholder." };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (!workspaceId) {
|
|
39
|
+
return { error: "No workspace is pinned. Set WORKSPACE_ID in .env first." };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (!name) {
|
|
43
|
+
return { error: "Give the board a name." };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const apiUrl = (process.env.CONEXUS_API_URL ?? "http://localhost:4040/api").replace(/\/$/, "");
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
const response = await fetch(`${apiUrl}/modules/${workspaceId}`, {
|
|
50
|
+
method: "POST",
|
|
51
|
+
headers: {
|
|
52
|
+
"x-api-key": apiKey,
|
|
53
|
+
"Content-Type": "application/json"
|
|
54
|
+
},
|
|
55
|
+
body: JSON.stringify({ name })
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
if (!response.ok) {
|
|
59
|
+
const detail = await response.text();
|
|
60
|
+
return { error: `The API answered ${response.status}: ${detail.slice(0, 200)}` };
|
|
61
|
+
}
|
|
62
|
+
} catch (error) {
|
|
63
|
+
return {
|
|
64
|
+
error: `Could not reach the API — ${error instanceof Error ? error.message : String(error)}`
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Re-renders /dev with the fresh board list, in the SAME response as this
|
|
69
|
+
// action — the developer sees the new board without a manual reload.
|
|
70
|
+
revalidatePath("/dev");
|
|
71
|
+
|
|
72
|
+
return { error: null };
|
|
73
|
+
}
|
package/app/dev/page.tsx
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { notFound } from "next/navigation";
|
|
2
2
|
|
|
3
|
+
import AddBoardForm from "./AddBoardForm";
|
|
4
|
+
import CopyId from "./CopyId";
|
|
5
|
+
|
|
3
6
|
/**
|
|
4
7
|
* DEVELOPER MODE — http://localhost:5173/dev
|
|
5
8
|
*
|
|
@@ -188,11 +191,12 @@ export default async function DevPage() {
|
|
|
188
191
|
<div className="truncate font-medium">
|
|
189
192
|
{membership.workspace?.name ?? "Untitled workspace"}
|
|
190
193
|
</div>
|
|
191
|
-
<div className="mt-0.5 font-mono text-[12px] text-muted">
|
|
192
|
-
{membership.workspace?._id}
|
|
193
|
-
</div>
|
|
194
194
|
</div>
|
|
195
195
|
|
|
196
|
+
{membership.workspace?._id ? (
|
|
197
|
+
<CopyId value={membership.workspace._id} />
|
|
198
|
+
) : null}
|
|
199
|
+
|
|
196
200
|
{membership.role ? (
|
|
197
201
|
<span className="shrink-0 rounded-md border border-line px-2 py-0.5 text-[12px] text-muted">
|
|
198
202
|
{membership.role}
|
|
@@ -214,11 +218,10 @@ export default async function DevPage() {
|
|
|
214
218
|
<div className="truncate text-[15px] font-semibold">
|
|
215
219
|
{state.workspace.name ?? "Untitled workspace"}
|
|
216
220
|
</div>
|
|
217
|
-
<div className="mt-0.5 font-mono text-[12px] text-muted">
|
|
218
|
-
{state.workspace._id}
|
|
219
|
-
</div>
|
|
220
221
|
</div>
|
|
221
222
|
|
|
223
|
+
<CopyId value={state.workspace._id} />
|
|
224
|
+
|
|
222
225
|
{state.role ? (
|
|
223
226
|
<span className="shrink-0 rounded-md border border-line px-2 py-0.5 text-[12px] text-muted">
|
|
224
227
|
{state.role}
|
|
@@ -226,6 +229,8 @@ export default async function DevPage() {
|
|
|
226
229
|
) : null}
|
|
227
230
|
</div>
|
|
228
231
|
|
|
232
|
+
<AddBoardForm workspaceId={state.workspace._id} />
|
|
233
|
+
|
|
229
234
|
{state.boards.length === 0 ? (
|
|
230
235
|
<p className="text-[13px] text-muted">
|
|
231
236
|
This workspace has no boards yet. Create one in Conexus X and
|
|
@@ -242,11 +247,11 @@ export default async function DevPage() {
|
|
|
242
247
|
<div className="truncate font-medium">
|
|
243
248
|
{board.name ?? "Untitled board"}
|
|
244
249
|
</div>
|
|
245
|
-
<div className="mt-0.5 font-mono text-[12px] text-muted">
|
|
246
|
-
{board._id}
|
|
247
|
-
</div>
|
|
248
250
|
</div>
|
|
249
251
|
|
|
252
|
+
.0000000000000000000000000000000000000000000000000000000000000000006+++++++++++++++++++++++++++++++++++++++++++++++++++++++++4
|
|
253
|
+
<CopyId value={board._id} />
|
|
254
|
+
|
|
250
255
|
{board.visibility ? (
|
|
251
256
|
<span className="shrink-0 rounded-md border border-line px-2 py-0.5 text-[12px] text-muted">
|
|
252
257
|
{board.visibility}
|
package/app/page.tsx
CHANGED
|
@@ -3,13 +3,20 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* The starter view.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* the smallest thing that exercises every part of the SDK worth knowing:
|
|
9
|
-
* context, a scoped read, a scoped write, host chrome, and realtime.
|
|
6
|
+
* It renders WHATEVER THE HOST GAVE IT, which is the single most important
|
|
7
|
+
* thing to copy from this file:
|
|
10
8
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
9
|
+
* mounted on a workspace -> the boards in that workspace
|
|
10
|
+
* mounted on a board -> the records in the collection in view
|
|
11
|
+
*
|
|
12
|
+
* The first version only ever listed records, so a view mounted at workspace
|
|
13
|
+
* level — which is what the CRM preview does — sat there saying "open a
|
|
14
|
+
* collection" and looked broken. A view must read `context` and show what is
|
|
15
|
+
* actually there.
|
|
16
|
+
*
|
|
17
|
+
* Note what is NOT here: no fetch, no API base URL, no token, no socket, and no
|
|
18
|
+
* workspace id of its own. The host owns all of it and hands over context with
|
|
19
|
+
* the permissions of whoever is looking, which is why this file is short.
|
|
13
20
|
*
|
|
14
21
|
* Styling is Tailwind, already configured — see app/globals.css. The colour
|
|
15
22
|
* names (`bg-card`, `text-muted`, `border-line`) flip with the board's theme on
|
|
@@ -20,6 +27,7 @@ import {
|
|
|
20
27
|
useAutoResize,
|
|
21
28
|
useCommands,
|
|
22
29
|
useConnection,
|
|
30
|
+
useModules,
|
|
23
31
|
useRecords,
|
|
24
32
|
useScope,
|
|
25
33
|
useSettings,
|
|
@@ -34,6 +42,10 @@ interface ViewSettings {
|
|
|
34
42
|
showCompleted?: boolean;
|
|
35
43
|
}
|
|
36
44
|
|
|
45
|
+
const Skeleton = () => (
|
|
46
|
+
<div className="h-3.5 w-full animate-pulse rounded bg-line" />
|
|
47
|
+
);
|
|
48
|
+
|
|
37
49
|
export default function View() {
|
|
38
50
|
const { status, error } = useConnection();
|
|
39
51
|
const context = useViewContext();
|
|
@@ -49,12 +61,20 @@ export default function View() {
|
|
|
49
61
|
* settings receives an empty object.
|
|
50
62
|
*/
|
|
51
63
|
const settings = useSettings<ViewSettings>();
|
|
52
|
-
const title = settings.title ?? "Records";
|
|
53
64
|
const showCompleted = settings.showCompleted ?? true;
|
|
54
65
|
|
|
66
|
+
/**
|
|
67
|
+
* Both queries are declared, and only the applicable one runs.
|
|
68
|
+
*
|
|
69
|
+
* Each is disabled when its id is missing, so the board list costs nothing
|
|
70
|
+
* on a board-level mount and the record list costs nothing above one. Hooks
|
|
71
|
+
* cannot be called conditionally; queries can be skipped.
|
|
72
|
+
*/
|
|
73
|
+
const boards = useModules(context?.collectionId ? undefined : context?.workspaceId);
|
|
74
|
+
|
|
55
75
|
const {
|
|
56
76
|
data: records,
|
|
57
|
-
loading,
|
|
77
|
+
loading: recordsLoading,
|
|
58
78
|
error: recordsError,
|
|
59
79
|
refresh
|
|
60
80
|
} = useRecords(context?.collectionId);
|
|
@@ -86,6 +106,10 @@ export default function View() {
|
|
|
86
106
|
);
|
|
87
107
|
}
|
|
88
108
|
|
|
109
|
+
const onBoard = Boolean(context?.collectionId);
|
|
110
|
+
const title = settings.title ?? (onBoard ? "Records" : "Boards");
|
|
111
|
+
const failure = onBoard ? recordsError : boards.error;
|
|
112
|
+
|
|
89
113
|
return (
|
|
90
114
|
<main className="p-4">
|
|
91
115
|
<ThemeSync theme={context?.theme ?? "light"} />
|
|
@@ -97,47 +121,76 @@ export default function View() {
|
|
|
97
121
|
</span>
|
|
98
122
|
</header>
|
|
99
123
|
|
|
100
|
-
{
|
|
101
|
-
<p className="text-[13px] text-muted">Open a collection to see its records.</p>
|
|
102
|
-
) : recordsError ? (
|
|
124
|
+
{failure ? (
|
|
103
125
|
<div className="rounded-lg border border-danger bg-danger-soft px-3.5 py-3 text-danger">
|
|
104
|
-
{
|
|
105
|
-
?
|
|
106
|
-
:
|
|
126
|
+
{failure.code === "scope_denied"
|
|
127
|
+
? `This app was not granted permission to read ${onBoard ? "records" : "boards"}.`
|
|
128
|
+
: failure.message}
|
|
107
129
|
</div>
|
|
108
|
-
) :
|
|
109
|
-
|
|
130
|
+
) : onBoard ? (
|
|
131
|
+
/* ---- mounted on a board: the records in view ---- */
|
|
132
|
+
recordsLoading && !records ? (
|
|
133
|
+
<Skeleton />
|
|
134
|
+
) : records?.length === 0 ? (
|
|
135
|
+
<p className="text-[13px] text-muted">
|
|
136
|
+
No records in this collection yet.
|
|
137
|
+
</p>
|
|
138
|
+
) : (
|
|
139
|
+
<ul>
|
|
140
|
+
{(records ?? [])
|
|
141
|
+
.filter((record) => showCompleted || !record.isCompleted)
|
|
142
|
+
.map((record) => (
|
|
143
|
+
<RecordRow
|
|
144
|
+
key={record._id}
|
|
145
|
+
record={record}
|
|
146
|
+
canWrite={canWrite}
|
|
147
|
+
onOpen={() =>
|
|
148
|
+
commands
|
|
149
|
+
.openRecord(record._id)
|
|
150
|
+
.catch(() =>
|
|
151
|
+
commands.notice(
|
|
152
|
+
"This board cannot open records from a view.",
|
|
153
|
+
"info"
|
|
154
|
+
)
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
onError={(message) => {
|
|
158
|
+
void commands.notice(message, "error");
|
|
159
|
+
refresh();
|
|
160
|
+
}}
|
|
161
|
+
/>
|
|
162
|
+
))}
|
|
163
|
+
</ul>
|
|
164
|
+
)
|
|
165
|
+
) : /* ---- mounted on a workspace: the boards in it ---- */
|
|
166
|
+
boards.loading && !boards.data ? (
|
|
167
|
+
<Skeleton />
|
|
168
|
+
) : boards.data?.length === 0 ? (
|
|
169
|
+
<p className="text-[13px] text-muted">
|
|
170
|
+
This workspace has no boards yet.
|
|
171
|
+
</p>
|
|
110
172
|
) : (
|
|
111
173
|
<ul>
|
|
112
|
-
{(
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
void commands.notice(message, "error");
|
|
131
|
-
refresh();
|
|
132
|
-
}}
|
|
133
|
-
/>
|
|
134
|
-
))}
|
|
174
|
+
{(boards.data ?? []).map((board) => (
|
|
175
|
+
<li
|
|
176
|
+
key={board._id}
|
|
177
|
+
className="flex items-center gap-2.5 border-b border-line py-2 last:border-b-0"
|
|
178
|
+
>
|
|
179
|
+
<span className="min-w-0 flex-1 truncate">{board.name}</span>
|
|
180
|
+
|
|
181
|
+
{board.visibility && board.visibility !== "workspace" ? (
|
|
182
|
+
<span className="shrink-0 rounded-md border border-line px-1.5 py-0.5 text-[11px] text-muted">
|
|
183
|
+
{board.visibility}
|
|
184
|
+
</span>
|
|
185
|
+
) : null}
|
|
186
|
+
|
|
187
|
+
<span className="shrink-0 text-[13px] text-muted">
|
|
188
|
+
{board.totalRecords ?? 0} records
|
|
189
|
+
</span>
|
|
190
|
+
</li>
|
|
191
|
+
))}
|
|
135
192
|
</ul>
|
|
136
193
|
)}
|
|
137
|
-
|
|
138
|
-
{records?.length === 0 && context?.collectionId ? (
|
|
139
|
-
<p className="text-[13px] text-muted">No records in this collection yet.</p>
|
|
140
|
-
) : null}
|
|
141
194
|
</main>
|
|
142
195
|
);
|
|
143
196
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@conexus-x/next-view",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.4",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Custom view for Conexus X — Next.js starter, wired to @conexus-x/sdk.",
|
|
6
6
|
"scripts": {
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
"tunnel": "npx -y cloudflared tunnel --url http://localhost:5173"
|
|
12
12
|
},
|
|
13
13
|
"dependencies": {
|
|
14
|
-
"@conexus-x/sdk": "^0.1.
|
|
14
|
+
"@conexus-x/sdk": "^0.1.2",
|
|
15
15
|
"next": "16.2.10",
|
|
16
16
|
"react": "19.2.4",
|
|
17
17
|
"react-dom": "19.2.4"
|