@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.
@@ -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
- * Lists the records in whichever collection the person is looking at, lets them
7
- * tick one complete, and stays live while colleagues edit the same board. It is
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
- * Note what is NOT here: no fetch, no API base URL, no token, no socket. The
12
- * host owns all four, which is why this file is short.
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
- {!context?.collectionId ? (
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
- {recordsError.code === "scope_denied"
105
- ? "This app was not granted permission to read records."
106
- : recordsError.message}
126
+ {failure.code === "scope_denied"
127
+ ? `This app was not granted permission to read ${onBoard ? "records" : "boards"}.`
128
+ : failure.message}
107
129
  </div>
108
- ) : loading && !records ? (
109
- <div className="h-3.5 w-full animate-pulse rounded bg-line" />
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
- {(records ?? [])
113
- .filter((record) => showCompleted || !record.isCompleted)
114
- .map((record) => (
115
- <RecordRow
116
- key={record._id}
117
- record={record}
118
- canWrite={canWrite}
119
- onOpen={() =>
120
- commands
121
- .openRecord(record._id)
122
- .catch(() =>
123
- commands.notice(
124
- "This board cannot open records from a view.",
125
- "info"
126
- )
127
- )
128
- }
129
- onError={(message) => {
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",
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.0",
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"