@conexus-x/next-view 1.0.2 → 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/.env +19 -9
- package/README.md +24 -14
- 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 +196 -80
- package/app/page.tsx +96 -43
- package/package.json +2 -3
- package/.env.example +0 -21
package/.env
CHANGED
|
@@ -1,21 +1,31 @@
|
|
|
1
1
|
# ---------------------------------------------------------------------------
|
|
2
|
-
# DEVELOPER MODE
|
|
2
|
+
# DEVELOPER MODE ONLY — none of this ships with your extension.
|
|
3
3
|
#
|
|
4
|
-
#
|
|
5
|
-
#
|
|
4
|
+
# A deployed view holds no key and picks no workspace. It is framed by a board,
|
|
5
|
+
# and the board tells it which workspace it is in and who is looking at it. The
|
|
6
|
+
# values here exist so you can build against real data before that happens.
|
|
6
7
|
# ---------------------------------------------------------------------------
|
|
7
8
|
|
|
8
|
-
# Your Conexus X API key
|
|
9
|
+
# Your Conexus X API key — find it in the app under Developer > API Key.
|
|
9
10
|
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
# credential in every browser devtools panel that ever opens your view.
|
|
11
|
+
# Read by the dev server ONLY; it never reaches the browser. A permanent
|
|
12
|
+
# credential in client-side JavaScript is one devtools panel away from being
|
|
13
|
+
# somebody else's.
|
|
14
14
|
API_KEY=Conexus_x_api_key
|
|
15
15
|
|
|
16
|
+
# The workspace this extension is built for.
|
|
17
|
+
#
|
|
18
|
+
# An extension is written against a SHAPE — particular boards, particular
|
|
19
|
+
# columns — and every workspace holds different ones, so a view built for one
|
|
20
|
+
# is meaningless pointed at another. Naming it here makes the dev page show
|
|
21
|
+
# that workspace's boards and nothing else.
|
|
22
|
+
#
|
|
23
|
+
# Leave it blank the first time: the dev page then lists your workspaces with
|
|
24
|
+
# their ids so you can copy one in.
|
|
25
|
+
WORKSPACE_ID=
|
|
26
|
+
|
|
16
27
|
# Where the Conexus X API lives.
|
|
17
28
|
CONEXUS_API_URL=http://localhost:4040/api
|
|
18
29
|
|
|
19
30
|
# Which origins may frame this view, space or comma separated.
|
|
20
|
-
# Add your tunnel's CRM origin here when previewing inside the real app.
|
|
21
31
|
CONEXUS_ORIGINS=http://localhost:3000 https://conexus-x.vercel.app
|
package/README.md
CHANGED
|
@@ -28,33 +28,43 @@ Nothing in this repo records the link, which is the point.
|
|
|
28
28
|
|
|
29
29
|
## Developer mode
|
|
30
30
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
31
|
+
An extension is built for **one workspace**. Every workspace holds different
|
|
32
|
+
boards and different columns, so a view written for Sales is meaningless
|
|
33
|
+
pointed at Marketing — you name the one you mean, once, in `.env`.
|
|
34
34
|
|
|
35
35
|
```bash
|
|
36
|
-
# .env —
|
|
37
|
-
API_KEY=Conexus_x_api_key
|
|
38
|
-
|
|
36
|
+
# .env — the only env file in this project. Fill in two values.
|
|
37
|
+
API_KEY=Conexus_x_api_key # <- your key: Conexus X > Developer > API Key
|
|
38
|
+
WORKSPACE_ID= # <- leave blank the first time
|
|
39
39
|
```
|
|
40
40
|
|
|
41
41
|
```bash
|
|
42
42
|
npm run dev
|
|
43
43
|
```
|
|
44
44
|
|
|
45
|
-
|
|
45
|
+
Open **http://localhost:5173/dev**.
|
|
46
46
|
|
|
47
|
-
|
|
47
|
+
With `WORKSPACE_ID` blank it lists your workspaces **with their ids**. Copy the
|
|
48
|
+
one you are building for into `.env`, reload, and the page then shows that
|
|
49
|
+
workspace and its boards — and only those.
|
|
50
|
+
|
|
51
|
+
**The pin is development only, and it has to be.** In production the board
|
|
52
|
+
decides which workspace your view is mounted in and hands it over as context. A
|
|
53
|
+
view that chose its own workspace would be a view that could read one it was
|
|
54
|
+
never mounted in. This page mirrors what the host will give you; it does not
|
|
55
|
+
override it.
|
|
56
|
+
|
|
57
|
+
**The key never reaches the browser.** The dev page is a React Server Component: `API_KEY` is read in Node and only the finished list is sent down.
|
|
48
58
|
A permanent credential in client-side JavaScript is one devtools panel away
|
|
49
59
|
from being somebody else's.
|
|
50
60
|
|
|
51
|
-
`.env` is gitignored, so your real key cannot be committed by accident
|
|
52
|
-
|
|
61
|
+
`.env` is gitignored, so your real key cannot be committed by accident — and it
|
|
62
|
+
still ships inside the npm package, so a scaffolded project always has one
|
|
63
|
+
ready to fill in.
|
|
53
64
|
|
|
54
|
-
The
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
development only.
|
|
65
|
+
The dev page is not part of a production build, and **your view never uses the
|
|
66
|
+
API key**: a deployed view receives data through the SDK, with the permissions
|
|
67
|
+
of whoever is looking at the board.
|
|
58
68
|
|
|
59
69
|
## Previewing inside the real CRM
|
|
60
70
|
|
|
@@ -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,23 +1,30 @@
|
|
|
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
|
*
|
|
6
|
-
* Put your API key in .env, reload, and
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* renders.
|
|
9
|
+
* Put your API key and the workspace you are building for in .env, reload, and
|
|
10
|
+
* this shows that workspace's boards. It is the shortest proof that your key
|
|
11
|
+
* works, the API is reachable, and you are pointed at the right data — which is
|
|
12
|
+
* what is actually wrong the first time nothing renders.
|
|
10
13
|
*
|
|
11
|
-
* A
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
14
|
+
* WHY A WORKSPACE IS PINNED IN .env: an extension is written against a SHAPE —
|
|
15
|
+
* particular boards, particular columns — and every workspace holds different
|
|
16
|
+
* ones. A view built for Sales is meaningless pointed at Marketing, so the
|
|
17
|
+
* developer names the one they mean instead of browsing all of them.
|
|
18
|
+
*
|
|
19
|
+
* THAT PIN IS DEVELOPMENT ONLY, and it has to be. In production the BOARD
|
|
20
|
+
* decides which workspace a view is mounted in and hands it over as context; a
|
|
21
|
+
* view that chose its own would be a view that could read a workspace it was
|
|
22
|
+
* never mounted in. This file mirrors what the host will hand you. It does not
|
|
23
|
+
* override it.
|
|
17
24
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
25
|
+
* A SERVER COMPONENT, deliberately. The key is read here, in Node, and only the
|
|
26
|
+
* finished list is sent to the browser — so it never appears in a bundle, a
|
|
27
|
+
* network tab, or anybody's devtools.
|
|
21
28
|
*/
|
|
22
29
|
|
|
23
30
|
// Never prerendered: it reads .env and the network at request time.
|
|
@@ -25,74 +32,112 @@ export const dynamic = "force-dynamic";
|
|
|
25
32
|
|
|
26
33
|
const PLACEHOLDER = "Conexus_x_api_key";
|
|
27
34
|
|
|
35
|
+
interface Workspace {
|
|
36
|
+
_id: string;
|
|
37
|
+
name?: string;
|
|
38
|
+
totalModules?: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
28
41
|
interface WorkspaceMembership {
|
|
29
42
|
_id: string;
|
|
30
43
|
role?: string;
|
|
31
|
-
|
|
32
|
-
workspace?: {
|
|
33
|
-
_id: string;
|
|
34
|
-
name?: string;
|
|
35
|
-
description?: string;
|
|
36
|
-
totalModules?: number;
|
|
37
|
-
} | null;
|
|
44
|
+
workspace?: Workspace | null;
|
|
38
45
|
}
|
|
39
46
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
47
|
+
interface Board {
|
|
48
|
+
_id: string;
|
|
49
|
+
name?: string;
|
|
50
|
+
description?: string;
|
|
51
|
+
visibility?: string;
|
|
52
|
+
totalRecords?: number;
|
|
53
|
+
}
|
|
43
54
|
|
|
44
|
-
|
|
55
|
+
type DevState =
|
|
56
|
+
| { kind: "error"; title: string; detail: string }
|
|
57
|
+
/** No WORKSPACE_ID yet — show what is available so one can be copied in. */
|
|
58
|
+
| { kind: "pick"; workspaces: WorkspaceMembership[] }
|
|
59
|
+
| { kind: "pinned"; workspace: Workspace; role: string | undefined; boards: Board[] };
|
|
60
|
+
|
|
61
|
+
const apiUrl = () =>
|
|
62
|
+
(process.env.CONEXUS_API_URL ?? "http://localhost:4040/api").replace(/\/$/, "");
|
|
63
|
+
|
|
64
|
+
const load = async (): Promise<DevState> => {
|
|
45
65
|
const apiKey = process.env.API_KEY;
|
|
46
|
-
const
|
|
66
|
+
const workspaceId = (process.env.WORKSPACE_ID ?? "").trim();
|
|
47
67
|
|
|
48
68
|
if (!apiKey) {
|
|
49
69
|
return {
|
|
50
|
-
|
|
70
|
+
kind: "error",
|
|
51
71
|
title: "No API key",
|
|
52
|
-
detail: "Add API_KEY to .env in this project, then reload."
|
|
72
|
+
detail: "Add API_KEY to .env in this project, then reload.",
|
|
53
73
|
};
|
|
54
74
|
}
|
|
55
75
|
|
|
56
76
|
if (apiKey === PLACEHOLDER) {
|
|
57
77
|
return {
|
|
58
|
-
|
|
78
|
+
kind: "error",
|
|
59
79
|
title: "The API key is still the placeholder",
|
|
60
|
-
detail: `Replace API_KEY=${PLACEHOLDER} in .env with your own key from Conexus X (
|
|
80
|
+
detail: `Replace API_KEY=${PLACEHOLDER} in .env with your own key from Conexus X (Developer › API Key), then reload.`,
|
|
61
81
|
};
|
|
62
82
|
}
|
|
63
83
|
|
|
64
|
-
|
|
65
|
-
const response = await fetch(`${apiUrl}
|
|
84
|
+
const call = async (path: string) => {
|
|
85
|
+
const response = await fetch(`${apiUrl()}${path}`, {
|
|
66
86
|
headers: { "x-api-key": apiKey },
|
|
67
|
-
cache: "no-store"
|
|
87
|
+
cache: "no-store",
|
|
68
88
|
});
|
|
69
89
|
|
|
70
90
|
if (response.status === 401) {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
detail: "The API answered 401. Check the key, and that the account it belongs to is still active."
|
|
75
|
-
};
|
|
91
|
+
throw new Error(
|
|
92
|
+
"The API answered 401. Check the key, and that the account it belongs to is still active."
|
|
93
|
+
);
|
|
76
94
|
}
|
|
77
95
|
|
|
78
96
|
if (!response.ok) {
|
|
97
|
+
throw new Error(`The API answered ${response.status}: ${(await response.text()).slice(0, 200)}`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return response.json() as Promise<Record<string, unknown>>;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
const body = await call("/workspaces");
|
|
105
|
+
const memberships = (body["workspaces"] as WorkspaceMembership[] | undefined) ?? [];
|
|
106
|
+
|
|
107
|
+
if (!workspaceId) {
|
|
108
|
+
return { kind: "pick", workspaces: memberships };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const mine = memberships.find((row) => row.workspace?._id === workspaceId);
|
|
112
|
+
|
|
113
|
+
// A pinned id that is not one of yours is the single most confusing
|
|
114
|
+
// failure here — every call succeeds and the page is simply empty — so
|
|
115
|
+
// it is named rather than left to look like "no boards".
|
|
116
|
+
if (!mine?.workspace) {
|
|
79
117
|
return {
|
|
80
|
-
|
|
81
|
-
title:
|
|
82
|
-
detail:
|
|
118
|
+
kind: "error",
|
|
119
|
+
title: "WORKSPACE_ID does not match a workspace you are in",
|
|
120
|
+
detail:
|
|
121
|
+
`.env points at ${workspaceId}, which is not among the ${memberships.length} ` +
|
|
122
|
+
"workspace(s) this key can see. Clear WORKSPACE_ID to list them again.",
|
|
83
123
|
};
|
|
84
124
|
}
|
|
85
125
|
|
|
86
|
-
const
|
|
126
|
+
const boardsBody = await call(`/modules/${workspaceId}`);
|
|
87
127
|
|
|
88
|
-
return {
|
|
128
|
+
return {
|
|
129
|
+
kind: "pinned",
|
|
130
|
+
workspace: mine.workspace,
|
|
131
|
+
role: mine.role,
|
|
132
|
+
boards: (boardsBody["modules"] as Board[] | undefined) ?? [],
|
|
133
|
+
};
|
|
89
134
|
} catch (error) {
|
|
90
135
|
return {
|
|
91
|
-
|
|
136
|
+
kind: "error",
|
|
92
137
|
title: "Could not reach the API",
|
|
93
138
|
detail:
|
|
94
|
-
|
|
95
|
-
|
|
139
|
+
`${error instanceof Error ? error.message : String(error)}\n\n` +
|
|
140
|
+
`Tried ${apiUrl()}. Is the Conexus X API running, and is CONEXUS_API_URL right?`,
|
|
96
141
|
};
|
|
97
142
|
}
|
|
98
143
|
};
|
|
@@ -102,60 +147,131 @@ export default async function DevPage() {
|
|
|
102
147
|
// business in a deployed view, so it is not there.
|
|
103
148
|
if (process.env.NODE_ENV === "production") notFound();
|
|
104
149
|
|
|
105
|
-
const
|
|
150
|
+
const state = await load();
|
|
106
151
|
|
|
107
152
|
return (
|
|
108
153
|
<main className="mx-auto max-w-2xl p-6">
|
|
109
154
|
<header className="mb-5">
|
|
110
155
|
<h1 className="text-lg font-semibold">Developer mode</h1>
|
|
111
156
|
<p className="mt-1 text-[13px] text-muted">
|
|
112
|
-
|
|
157
|
+
{state.kind === "pinned"
|
|
158
|
+
? "The workspace this extension is built for, and its boards."
|
|
159
|
+
: "Reading the API key in .env."}
|
|
113
160
|
</p>
|
|
114
161
|
</header>
|
|
115
162
|
|
|
116
|
-
{
|
|
163
|
+
{state.kind === "error" ? (
|
|
117
164
|
<div className="rounded-lg border border-danger bg-danger-soft px-4 py-3 text-danger">
|
|
118
|
-
<strong className="font-semibold">{
|
|
119
|
-
<p className="mt-1.5 text-[13px] whitespace-pre-wrap">{
|
|
165
|
+
<strong className="font-semibold">{state.title}</strong>
|
|
166
|
+
<p className="mt-1.5 text-[13px] whitespace-pre-wrap">{state.detail}</p>
|
|
120
167
|
</div>
|
|
121
|
-
) :
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
168
|
+
) : state.kind === "pick" ? (
|
|
169
|
+
<>
|
|
170
|
+
<div className="mb-4 rounded-lg border border-line bg-accent-soft px-4 py-3">
|
|
171
|
+
<strong className="font-semibold">Pick the workspace you are building for</strong>
|
|
172
|
+
<p className="mt-1 text-[13px]">
|
|
173
|
+
Copy its id into <code>WORKSPACE_ID</code> in <code>.env</code> and
|
|
174
|
+
reload. This view will then show that workspace's boards — and
|
|
175
|
+
only those.
|
|
176
|
+
</p>
|
|
177
|
+
</div>
|
|
178
|
+
|
|
179
|
+
{state.workspaces.length === 0 ? (
|
|
180
|
+
<p className="text-[13px] text-muted">
|
|
181
|
+
The key works — this account is not a member of any workspace yet.
|
|
182
|
+
</p>
|
|
183
|
+
) : (
|
|
184
|
+
<ul className="rounded-lg border border-line">
|
|
185
|
+
{state.workspaces.map((membership) => (
|
|
186
|
+
<li
|
|
187
|
+
key={membership._id}
|
|
188
|
+
className="flex items-center gap-3 border-b border-line px-4 py-3 last:border-b-0"
|
|
189
|
+
>
|
|
190
|
+
<div className="min-w-0 flex-1">
|
|
191
|
+
<div className="truncate font-medium">
|
|
192
|
+
{membership.workspace?.name ?? "Untitled workspace"}
|
|
193
|
+
</div>
|
|
194
|
+
</div>
|
|
195
|
+
|
|
196
|
+
{membership.workspace?._id ? (
|
|
197
|
+
<CopyId value={membership.workspace._id} />
|
|
198
|
+
) : null}
|
|
199
|
+
|
|
200
|
+
{membership.role ? (
|
|
201
|
+
<span className="shrink-0 rounded-md border border-line px-2 py-0.5 text-[12px] text-muted">
|
|
202
|
+
{membership.role}
|
|
203
|
+
</span>
|
|
204
|
+
) : null}
|
|
205
|
+
|
|
206
|
+
<span className="shrink-0 text-[12px] text-muted">
|
|
207
|
+
{membership.workspace?.totalModules ?? 0} boards
|
|
208
|
+
</span>
|
|
209
|
+
</li>
|
|
210
|
+
))}
|
|
211
|
+
</ul>
|
|
212
|
+
)}
|
|
213
|
+
</>
|
|
125
214
|
) : (
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
<
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
>
|
|
132
|
-
<div className="min-w-0 flex-1">
|
|
133
|
-
<div className="truncate font-medium">
|
|
134
|
-
{membership.workspace?.name ?? "Untitled workspace"}
|
|
135
|
-
</div>
|
|
136
|
-
<div className="mt-0.5 font-mono text-[12px] text-muted">
|
|
137
|
-
{membership.workspace?._id}
|
|
138
|
-
</div>
|
|
215
|
+
<>
|
|
216
|
+
<div className="mb-4 flex items-center gap-3 rounded-lg border border-line px-4 py-3">
|
|
217
|
+
<div className="min-w-0 flex-1">
|
|
218
|
+
<div className="truncate text-[15px] font-semibold">
|
|
219
|
+
{state.workspace.name ?? "Untitled workspace"}
|
|
139
220
|
</div>
|
|
221
|
+
</div>
|
|
140
222
|
|
|
141
|
-
|
|
142
|
-
<span className="shrink-0 rounded-md border border-line px-2 py-0.5 text-[12px] text-muted">
|
|
143
|
-
{membership.role}
|
|
144
|
-
</span>
|
|
145
|
-
) : null}
|
|
223
|
+
<CopyId value={state.workspace._id} />
|
|
146
224
|
|
|
147
|
-
|
|
148
|
-
|
|
225
|
+
{state.role ? (
|
|
226
|
+
<span className="shrink-0 rounded-md border border-line px-2 py-0.5 text-[12px] text-muted">
|
|
227
|
+
{state.role}
|
|
149
228
|
</span>
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
229
|
+
) : null}
|
|
230
|
+
</div>
|
|
231
|
+
|
|
232
|
+
<AddBoardForm workspaceId={state.workspace._id} />
|
|
233
|
+
|
|
234
|
+
{state.boards.length === 0 ? (
|
|
235
|
+
<p className="text-[13px] text-muted">
|
|
236
|
+
This workspace has no boards yet. Create one in Conexus X and
|
|
237
|
+
reload.
|
|
238
|
+
</p>
|
|
239
|
+
) : (
|
|
240
|
+
<ul className="rounded-lg border border-line">
|
|
241
|
+
{state.boards.map((board) => (
|
|
242
|
+
<li
|
|
243
|
+
key={board._id}
|
|
244
|
+
className="flex items-center gap-3 border-b border-line px-4 py-3 last:border-b-0"
|
|
245
|
+
>
|
|
246
|
+
<div className="min-w-0 flex-1">
|
|
247
|
+
<div className="truncate font-medium">
|
|
248
|
+
{board.name ?? "Untitled board"}
|
|
249
|
+
</div>
|
|
250
|
+
</div>
|
|
251
|
+
|
|
252
|
+
.0000000000000000000000000000000000000000000000000000000000000000006+++++++++++++++++++++++++++++++++++++++++++++++++++++++++4
|
|
253
|
+
<CopyId value={board._id} />
|
|
254
|
+
|
|
255
|
+
{board.visibility ? (
|
|
256
|
+
<span className="shrink-0 rounded-md border border-line px-2 py-0.5 text-[12px] text-muted">
|
|
257
|
+
{board.visibility}
|
|
258
|
+
</span>
|
|
259
|
+
) : null}
|
|
260
|
+
|
|
261
|
+
<span className="shrink-0 text-[12px] text-muted">
|
|
262
|
+
{board.totalRecords ?? 0} records
|
|
263
|
+
</span>
|
|
264
|
+
</li>
|
|
265
|
+
))}
|
|
266
|
+
</ul>
|
|
267
|
+
)}
|
|
268
|
+
</>
|
|
153
269
|
)}
|
|
154
270
|
|
|
155
271
|
<p className="mt-5 text-[12px] text-muted">
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
272
|
+
Development-only, and returns 404 in a production build. Your view itself
|
|
273
|
+
never uses the API key and never chooses a workspace — the board it is
|
|
274
|
+
mounted in tells it which one, with the permissions of whoever is looking.
|
|
159
275
|
</p>
|
|
160
276
|
</main>
|
|
161
277
|
);
|
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"
|
|
@@ -35,7 +35,6 @@
|
|
|
35
35
|
"tsconfig.json",
|
|
36
36
|
"conexus.manifest.json",
|
|
37
37
|
".env",
|
|
38
|
-
".env.example",
|
|
39
38
|
"README.md",
|
|
40
39
|
".gitignore"
|
|
41
40
|
]
|
package/.env.example
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
# ---------------------------------------------------------------------------
|
|
2
|
-
# DEVELOPER MODE. None of this is used by your view in production.
|
|
3
|
-
#
|
|
4
|
-
# The values here power the dev preview at /dev, which frames your view the way
|
|
5
|
-
# a Conexus X board does and feeds it REAL data from your workspace.
|
|
6
|
-
# ---------------------------------------------------------------------------
|
|
7
|
-
|
|
8
|
-
# Your Conexus X API key. Find it in the app under Settings > API.
|
|
9
|
-
#
|
|
10
|
-
# It is read by the dev server ONLY and never reaches the browser: requests go
|
|
11
|
-
# to /api/conexus/* on this app, which attaches the key server-side and forwards
|
|
12
|
-
# them. A permanent credential in client-side JavaScript is a permanent
|
|
13
|
-
# credential in every browser devtools panel that ever opens your view.
|
|
14
|
-
API_KEY=Conexus_x_api_key
|
|
15
|
-
|
|
16
|
-
# Where the Conexus X API lives.
|
|
17
|
-
CONEXUS_API_URL=http://localhost:4040/api
|
|
18
|
-
|
|
19
|
-
# Which origins may frame this view, space or comma separated.
|
|
20
|
-
# Add your tunnel's CRM origin here when previewing inside the real app.
|
|
21
|
-
CONEXUS_ORIGINS=http://localhost:3000 https://conexus-x.vercel.app
|