@smartmemory/wiki-components 0.1.0 → 0.2.0
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 +8 -2
- package/package.json +1 -1
- package/src/Decisions.jsx +34 -0
- package/src/data-client.d.ts +5 -1
- package/src/data-client.jsx +1 -0
- package/src/index.d.ts +14 -1
- package/src/index.js +4 -0
- package/src/paged.jsx +26 -0
- package/src/parts/Pager.jsx +17 -0
- package/src/parts/Table.jsx +17 -0
package/README.md
CHANGED
|
@@ -26,7 +26,7 @@ npm install @smartmemory/wiki-components react react-dom date-fns lucide-react
|
|
|
26
26
|
|
|
27
27
|
## Contract
|
|
28
28
|
|
|
29
|
-
`src/data-client.d.ts` is the contract:
|
|
29
|
+
`src/data-client.d.ts` is the contract: five calls (`identity`, `featureHub`, `decisions`, `decisionsPage`, `search`), the endpoint and
|
|
30
30
|
request body each maps to on the wiki API, and the result types (from `wiki/contracts/query.schema.json`). The same list is
|
|
31
31
|
runtime data in `DATA_CLIENT_CALLS`; `ui/test/wiki-components.test.jsx` pins the typed file, the table and the app's client
|
|
32
32
|
to each other. `<WikiDataProvider>` refuses a client missing a method.
|
|
@@ -34,16 +34,22 @@ to each other. `<WikiDataProvider>` refuses a client missing a method.
|
|
|
34
34
|
## Use
|
|
35
35
|
|
|
36
36
|
```jsx
|
|
37
|
-
import { WikiDataProvider, FeatureHub, Search, wikiPath } from "@smartmemory/wiki-components";
|
|
37
|
+
import { WikiDataProvider, FeatureHub, Search, Decisions, wikiPath } from "@smartmemory/wiki-components";
|
|
38
38
|
|
|
39
39
|
<WikiDataProvider client={myClient}>
|
|
40
40
|
<FeatureHub featureId="CORE-1" workspace="wiki-eng" />
|
|
41
41
|
<Search query="recall" workspaces={["wiki-eng", "wiki-gtm"]}
|
|
42
42
|
onNavigate={(target) => /* {kind: "search", query?, workspace?} | {kind: "note", workspace, docId} */ go(target)}
|
|
43
43
|
hrefFor={(target) => `http://127.0.0.1:8791/ui${wikiPath(target)}`} />
|
|
44
|
+
<Decisions workspace="wiki-eng" />
|
|
44
45
|
</WikiDataProvider>
|
|
45
46
|
```
|
|
46
47
|
|
|
48
|
+
- `Decisions` is the paged list of every decision of the workspace, newest first, 50 a page. Its data is
|
|
49
|
+
`client.decisionsPage({workspace, cursor?, size?})` (`POST /query/decisions`, `filters: {}`, `page: {size, cursor?}`),
|
|
50
|
+
which resolves `{items, total, next_cursor, generated_at}`. `cursor` is the previous page's `next_cursor`, opaque.
|
|
51
|
+
The paging pieces are exported for the next screens: `usePagedCall(key, run, {size})` (the cursor stack in component
|
|
52
|
+
state, no react-query), `Table`, `Pager`.
|
|
47
53
|
- `workspace` / `workspaces` are optional: without them a screen calls `client.identity()` and uses the caller's grants
|
|
48
54
|
(FeatureHub the first one).
|
|
49
55
|
- `onNavigate(target)` receives a plain left click on a link and the search box's submit. `hrefFor(target)` is each link's
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@smartmemory/wiki-components",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "The wiki's feature hub and search screens as router-free React components fed by an injected DataClient (WIKI-UI-COMPONENTS-1). Ships JSX source for a bundler host (Vite).",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Regression Analytics LLC",
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { useDataClient, useWorkspaces } from "./data-client";
|
|
3
|
+
import { usePagedCall } from "./paged";
|
|
4
|
+
import { fmtDate } from "./lib";
|
|
5
|
+
import ListPage from "./parts/ListPage";
|
|
6
|
+
import PageHeader from "./parts/PageHeader";
|
|
7
|
+
import Table from "./parts/Table";
|
|
8
|
+
import Pager from "./parts/Pager";
|
|
9
|
+
import StatusChip from "./parts/StatusChip";
|
|
10
|
+
|
|
11
|
+
/** The decisions list: every recorded decision of `workspace` (default: the first of identity().workspaces), newest first,
|
|
12
|
+
* 50 a page with a pager. `onNavigate` / `hrefFor` are accepted for the host contract shared by the screens; the list has
|
|
13
|
+
* no links yet. Data: DataClient decisionsPage, one call per page. */
|
|
14
|
+
export default function Decisions({ workspace: given }) {
|
|
15
|
+
const client = useDataClient();
|
|
16
|
+
const ws = useWorkspaces(given ? [given] : undefined);
|
|
17
|
+
const workspace = given || ws.workspaces?.[0];
|
|
18
|
+
const p = usePagedCall(["decisions", workspace], (page) => client.decisionsPage({ workspace, ...page }), { enabled: !!workspace });
|
|
19
|
+
return (
|
|
20
|
+
<ListPage header={<PageHeader title="Decisions">Recorded decisions and the docs they came from.</PageHeader>} foot={<Pager p={p} />}>
|
|
21
|
+
<Table head={["Entry", "Text", "Status", "Refs", "Date"]} loading={p.isLoading || ws.isLoading} error={p.error || ws.error} count={p.items.length}>
|
|
22
|
+
{p.items.map((e) => (
|
|
23
|
+
<tr key={e.entry_id} className="align-top hover:bg-neutral-50/60">
|
|
24
|
+
<td className="whitespace-nowrap px-3 py-2 font-mono text-[12px] text-neutral-600">{e.entry_id}</td>
|
|
25
|
+
<td className="px-3 py-2 text-neutral-800">{e.text}</td>
|
|
26
|
+
<td className="px-3 py-2">{e.status && <StatusChip status={e.status} />}</td>
|
|
27
|
+
<td className="px-3 py-2 font-mono text-[11px] text-neutral-500">{(e.refs || []).join(", ")}</td>
|
|
28
|
+
<td className="whitespace-nowrap px-3 py-2 text-xs tabular-nums text-neutral-500">{fmtDate(e.ts)}</td>
|
|
29
|
+
</tr>
|
|
30
|
+
))}
|
|
31
|
+
</Table>
|
|
32
|
+
</ListPage>
|
|
33
|
+
);
|
|
34
|
+
}
|
package/src/data-client.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// WIKI-UI-COMPONENTS-1: the DataClient contract. Every call the FeatureHub and
|
|
1
|
+
// WIKI-UI-COMPONENTS-1: the DataClient contract. Every call the FeatureHub, Search and Decisions screens make, and nothing else.
|
|
2
2
|
// A host (the wiki's /ui/ app, Compose's wiki tab) passes one object with these async methods to <WikiDataProvider>.
|
|
3
3
|
// The components never fetch: each call below is the whole of their I/O.
|
|
4
4
|
//
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
// identity GET /identity (none) -> identity_response, wiki/contracts/identity.schema.json
|
|
10
10
|
// featureHub POST /query/feature_hub {query: "feature_hub", workspace, filters: {feature_id}, page: {size: 200}}
|
|
11
11
|
// decisions POST /query/decisions {query: "decisions", workspace, filters: {feature_id}, page: {size: 50}}
|
|
12
|
+
// decisionsPage POST /query/decisions {query: "decisions", workspace, filters: {}, page: {size: 50, cursor?}}
|
|
12
13
|
// search POST /query/search {query: "search", workspace, filters: {q}, page: {}}
|
|
13
14
|
//
|
|
14
15
|
// The same table is runtime data in data-client.jsx (DATA_CLIENT_CALLS); a test pins the two to each other.
|
|
@@ -106,6 +107,9 @@ export interface DataClient {
|
|
|
106
107
|
featureHub(args: { workspace: Workspace; featureId: string }): Promise<FeatureHubResult>;
|
|
107
108
|
/** POST /query/decisions, filters {feature_id}, page {size: 50}. */
|
|
108
109
|
decisions(args: { workspace: Workspace; featureId: string }): Promise<QueryResult<EntryItem>>;
|
|
110
|
+
/** POST /query/decisions, filters {}, page {size (default 50), cursor?}: one page of every decision of the workspace, newest first.
|
|
111
|
+
* `cursor` is the previous page's `next_cursor`, opaque. */
|
|
112
|
+
decisionsPage(args: { workspace: Workspace; cursor?: string; size?: number }): Promise<QueryResult<EntryItem>>;
|
|
109
113
|
/** POST /query/search, filters {q} (1 to 256 characters), page {}. One call per workspace searched. */
|
|
110
114
|
search(args: { workspace: Workspace; q: string }): Promise<SearchResult>;
|
|
111
115
|
}
|
package/src/data-client.jsx
CHANGED
|
@@ -6,6 +6,7 @@ export const DATA_CLIENT_CALLS = [
|
|
|
6
6
|
{ method: "identity", endpoint: "GET /identity" },
|
|
7
7
|
{ method: "featureHub", endpoint: "POST /query/feature_hub", query: "feature_hub", page: { size: 200 } },
|
|
8
8
|
{ method: "decisions", endpoint: "POST /query/decisions", query: "decisions", page: { size: 50 } },
|
|
9
|
+
{ method: "decisionsPage", endpoint: "POST /query/decisions", query: "decisions", page: { size: 50 } },
|
|
9
10
|
{ method: "search", endpoint: "POST /query/search", query: "search", page: {} },
|
|
10
11
|
];
|
|
11
12
|
|
package/src/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Types for @smartmemory/wiki-components. The DataClient contract and NavTarget: data-client.d.ts.
|
|
2
2
|
import type { ReactElement, ReactNode } from "react";
|
|
3
|
-
import type { DataClient, NavTarget, Workspace } from "./data-client";
|
|
3
|
+
import type { DataClient, NavTarget, QueryResult, Workspace } from "./data-client";
|
|
4
4
|
|
|
5
5
|
export * from "./data-client";
|
|
6
6
|
|
|
@@ -22,6 +22,19 @@ export function Search(props: { query?: string; workspace?: Workspace | null; wo
|
|
|
22
22
|
|
|
23
23
|
export const DATA_CLIENT_CALLS: { method: keyof DataClient; endpoint: string; query?: string; page?: Record<string, number> }[];
|
|
24
24
|
|
|
25
|
+
/** Every recorded decision of `workspace` (default: the first of identity().workspaces), 50 a page with a pager. */
|
|
26
|
+
export function Decisions(props: { workspace?: Workspace | null } & NavProps): ReactElement;
|
|
27
|
+
|
|
28
|
+
/** One DataClient call paged by cursor. `run({cursor?, size})` fetches a page; a new `key` restarts at page one. */
|
|
29
|
+
export function usePagedCall<T>(
|
|
30
|
+
key: unknown,
|
|
31
|
+
run: (page: { cursor?: string; size: number }) => Promise<QueryResult<T>>,
|
|
32
|
+
opts?: { size?: number; enabled?: boolean },
|
|
33
|
+
): { items: T[]; result?: QueryResult<T>; total?: number; isLoading: boolean; error: Error | null; index: number; size: number; next: (() => void) | null; prev: (() => void) | null };
|
|
34
|
+
|
|
35
|
+
export function Table(props: { head: ReactNode[]; children?: ReactNode; loading?: boolean; error?: Error | null; empty?: string; count?: number }): ReactElement;
|
|
36
|
+
export function Pager(props: { p: { total?: number; index: number; size: number; items: unknown[]; next: (() => void) | null; prev: (() => void) | null } }): ReactElement | null;
|
|
37
|
+
|
|
25
38
|
/** A NavTarget as a path of the wiki's /ui/ app (relative to its basename, e.g. "/search?q=x"). */
|
|
26
39
|
export function wikiPath(target: NavTarget): string;
|
|
27
40
|
export function wsName(workspace: Workspace): string;
|
package/src/index.js
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
// data from an injected DataClient (data-client.d.ts). The wiki's /ui/ app and Compose's wiki tab both render these.
|
|
3
3
|
export { default as FeatureHub, prefixOnly } from "./FeatureHub";
|
|
4
4
|
export { default as Search } from "./Search";
|
|
5
|
+
export { default as Decisions } from "./Decisions";
|
|
6
|
+
export { usePagedCall } from "./paged";
|
|
5
7
|
export { WikiDataProvider, useDataClient, useClientCall, DATA_CLIENT_CALLS } from "./data-client";
|
|
6
8
|
export { wikiPath, wsName, wsUrl, noteUrl, toDate, fmtDate, summaryOf } from "./lib";
|
|
7
9
|
// The parts the screens are built from; the /ui/ app's other pages use them too (ui/src/components/wiki/* re-export them).
|
|
@@ -18,3 +20,5 @@ export { default as ListPage } from "./parts/ListPage";
|
|
|
18
20
|
export { default as SearchGroup } from "./parts/SearchGroup";
|
|
19
21
|
export { default as SearchBox } from "./parts/SearchBox";
|
|
20
22
|
export { default as NavLink } from "./parts/NavLink";
|
|
23
|
+
export { default as Table } from "./parts/Table";
|
|
24
|
+
export { default as Pager } from "./parts/Pager";
|
package/src/paged.jsx
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { useClientCall } from "./data-client";
|
|
3
|
+
|
|
4
|
+
/** A DataClient call paged by cursor (no react-query). `run({cursor, size})` is one page: the host's method with the
|
|
5
|
+
* caller's fixed arguments. The cursor stack lives in this component's state: `next` pushes the page's `next_cursor`,
|
|
6
|
+
* `prev` pops it, and a new `key` (any JSON value naming the arguments) starts again at the first page. The result is
|
|
7
|
+
* the shape the Pager takes: `items`, `total`, `index` (0-based page), `size`, `next`/`prev` (null when unavailable). */
|
|
8
|
+
export function usePagedCall(key, run, { size = 50, enabled = true } = {}) {
|
|
9
|
+
const k = JSON.stringify(key);
|
|
10
|
+
const [state, setState] = useState({ key: k, cursors: [null] });
|
|
11
|
+
const cursors = state.key === k ? state.cursors : [null];
|
|
12
|
+
const cursor = cursors[cursors.length - 1];
|
|
13
|
+
const q = useClientCall(["paged", key, size, cursor], () => run({ ...(cursor ? { cursor } : {}), size }), { enabled, keepPrevious: true });
|
|
14
|
+
const next = q.data?.next_cursor;
|
|
15
|
+
return {
|
|
16
|
+
items: q.data?.items || [],
|
|
17
|
+
result: q.data,
|
|
18
|
+
total: q.data?.total,
|
|
19
|
+
isLoading: q.isLoading,
|
|
20
|
+
error: q.error,
|
|
21
|
+
index: cursors.length - 1,
|
|
22
|
+
size,
|
|
23
|
+
next: next ? () => setState({ key: k, cursors: [...cursors, next] }) : null,
|
|
24
|
+
prev: cursors.length > 1 ? () => setState({ key: k, cursors: cursors.slice(0, -1) }) : null,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { ChevronLeft, ChevronRight } from "lucide-react";
|
|
3
|
+
|
|
4
|
+
const btn = "inline-flex h-7 w-7 items-center justify-center rounded border border-neutral-200 text-neutral-600 hover:border-blue-600 hover:text-blue-600 disabled:opacity-30 disabled:hover:border-neutral-200 disabled:hover:text-neutral-600";
|
|
5
|
+
|
|
6
|
+
export default function Pager({ p }) {
|
|
7
|
+
if (!p.total) return null;
|
|
8
|
+
const start = p.index * p.size + 1;
|
|
9
|
+
const end = Math.min(start + p.items.length - 1, p.total);
|
|
10
|
+
return (
|
|
11
|
+
<div className="mt-3 flex shrink-0 items-center justify-end gap-3 text-xs tabular-nums text-neutral-500">
|
|
12
|
+
<span>{start.toLocaleString()}–{end.toLocaleString()} of {p.total.toLocaleString()}</span>
|
|
13
|
+
<button className={btn} disabled={!p.prev} onClick={p.prev} aria-label="Previous page"><ChevronLeft className="h-4 w-4" /></button>
|
|
14
|
+
<button className={btn} disabled={!p.next} onClick={p.next} aria-label="Next page"><ChevronRight className="h-4 w-4" /></button>
|
|
15
|
+
</div>
|
|
16
|
+
);
|
|
17
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import Loading from "./Loading";
|
|
3
|
+
import ErrorBox from "./ErrorBox";
|
|
4
|
+
|
|
5
|
+
export default function Table({ head, children, loading, error, empty = "Nothing here.", count }) {
|
|
6
|
+
return (
|
|
7
|
+
<div className="rounded-lg border border-neutral-200">
|
|
8
|
+
<table className="w-full text-left text-[13px]">
|
|
9
|
+
<thead className="sticky top-0 z-10 bg-neutral-50 text-[11px] uppercase tracking-wider text-neutral-500">
|
|
10
|
+
<tr>{head.map((h, i) => <th key={i} className="whitespace-nowrap px-3 py-2 font-medium">{h}</th>)}</tr>
|
|
11
|
+
</thead>
|
|
12
|
+
<tbody className="divide-y divide-neutral-100">{children}</tbody>
|
|
13
|
+
</table>
|
|
14
|
+
{error ? <ErrorBox error={error} className="m-3" /> : loading ? <div className="px-3"><Loading /></div> : count === 0 && <p className="px-3 py-6 text-sm text-neutral-400">{empty}</p>}
|
|
15
|
+
</div>
|
|
16
|
+
);
|
|
17
|
+
}
|