@dropsh/plugin-tui 0.5.2
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/LICENSE +51 -0
- package/package.json +32 -0
- package/src/browse-app.tsx +94 -0
- package/src/entity-list.tsx +34 -0
- package/src/entity-view.tsx +45 -0
- package/src/index.ts +94 -0
- package/src/jsonapi-type.ts +17 -0
- package/src/link.tsx +78 -0
- package/src/registry.ts +106 -0
- package/src/router.ts +56 -0
- package/src/types.ts +59 -0
- package/tests/unit/browse-app.test.tsx +142 -0
- package/tests/unit/entity-list.test.tsx +29 -0
- package/tests/unit/entity-view.test.tsx +29 -0
- package/tests/unit/index.test.tsx +77 -0
- package/tests/unit/jsonapi-type.test.ts +24 -0
- package/tests/unit/link.test.tsx +64 -0
- package/tests/unit/registry.test.ts +34 -0
- package/tests/unit/router.test.ts +91 -0
- package/tsconfig.json +14 -0
- package/vitest.config.ts +15 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
drupal-cli is dual-licensed. You may use this software under the terms of
|
|
2
|
+
either the MIT License or the GNU General Public License version 2 or any
|
|
3
|
+
later version, at your option.
|
|
4
|
+
|
|
5
|
+
SPDX-License-Identifier: (MIT OR GPL-2.0-or-later)
|
|
6
|
+
|
|
7
|
+
================================================================================
|
|
8
|
+
MIT License
|
|
9
|
+
================================================================================
|
|
10
|
+
|
|
11
|
+
Copyright (c) 2026 Christian Wiedemann
|
|
12
|
+
|
|
13
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
14
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
15
|
+
in the Software without restriction, including without limitation the rights
|
|
16
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
17
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
18
|
+
furnished to do so, subject to the following conditions:
|
|
19
|
+
|
|
20
|
+
The above copyright notice and this permission notice shall be included in all
|
|
21
|
+
copies or substantial portions of the Software.
|
|
22
|
+
|
|
23
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
24
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
25
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
26
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
27
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
28
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
29
|
+
SOFTWARE.
|
|
30
|
+
|
|
31
|
+
================================================================================
|
|
32
|
+
GNU General Public License, version 2 or later
|
|
33
|
+
================================================================================
|
|
34
|
+
|
|
35
|
+
The full text of the GNU GPL v2 is available at:
|
|
36
|
+
https://www.gnu.org/licenses/old-licenses/gpl-2.0.txt
|
|
37
|
+
|
|
38
|
+
The full text of any later GPL version is available at:
|
|
39
|
+
https://www.gnu.org/licenses/
|
|
40
|
+
|
|
41
|
+
Copyright (C) 2026 Christian Wiedemann
|
|
42
|
+
|
|
43
|
+
This program is free software; you can redistribute it and/or modify it under
|
|
44
|
+
the terms of the GNU General Public License as published by the Free Software
|
|
45
|
+
Foundation; either version 2 of the License, or (at your option) any later
|
|
46
|
+
version.
|
|
47
|
+
|
|
48
|
+
This program is distributed in the hope that it will be useful, but WITHOUT
|
|
49
|
+
ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
|
50
|
+
FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
|
|
51
|
+
details.
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@dropsh/plugin-tui",
|
|
3
|
+
"version": "0.5.2",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "./dist/plugins/tui/src/index.js",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./dist/plugins/tui/src/index.js"
|
|
8
|
+
},
|
|
9
|
+
"publishConfig": {
|
|
10
|
+
"access": "public"
|
|
11
|
+
},
|
|
12
|
+
"peerDependencies": {
|
|
13
|
+
"dropsh": "*"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"ink": "^5.0.0",
|
|
17
|
+
"react": "^18.3.0",
|
|
18
|
+
"@dropsh/plugin-markdown": "0.5.2"
|
|
19
|
+
},
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"@types/react": "^18.3.0",
|
|
22
|
+
"ink-testing-library": "^4.0.0",
|
|
23
|
+
"typescript": "^5.6.0",
|
|
24
|
+
"vitest": "^2.1.0",
|
|
25
|
+
"dropsh": "0.5.2"
|
|
26
|
+
},
|
|
27
|
+
"scripts": {
|
|
28
|
+
"build": "tsc",
|
|
29
|
+
"test": "vitest run --passWithNoTests",
|
|
30
|
+
"typecheck": "tsc --noEmit"
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { Box, Text, useInput } from "ink";
|
|
2
|
+
import type React from "react";
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { FocusRegistryProvider, useFocusRegistry } from "./link.js";
|
|
5
|
+
import type { Router } from "./router.js";
|
|
6
|
+
|
|
7
|
+
export interface BrowseProps {
|
|
8
|
+
router: Router;
|
|
9
|
+
onExit: () => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Inner component: has access to the focus registry so Enter can read the
|
|
13
|
+
// focused link target and ask the router to navigate to it.
|
|
14
|
+
function Keys({
|
|
15
|
+
router,
|
|
16
|
+
onExit,
|
|
17
|
+
focusedIndex,
|
|
18
|
+
setFocusedIndex,
|
|
19
|
+
bump,
|
|
20
|
+
setError,
|
|
21
|
+
setNavSeq,
|
|
22
|
+
}: {
|
|
23
|
+
router: Router;
|
|
24
|
+
onExit: () => void;
|
|
25
|
+
focusedIndex: number;
|
|
26
|
+
setFocusedIndex: (fn: (i: number) => number) => void;
|
|
27
|
+
bump: () => void;
|
|
28
|
+
setError: (msg: string | null) => void;
|
|
29
|
+
setNavSeq: (fn: (n: number) => number) => void;
|
|
30
|
+
}): null {
|
|
31
|
+
const reg = useFocusRegistry();
|
|
32
|
+
useInput((input, key) => {
|
|
33
|
+
if (input === "q" || key.escape) {
|
|
34
|
+
const hasMore = router.back();
|
|
35
|
+
if (!hasMore) onExit();
|
|
36
|
+
else {
|
|
37
|
+
setFocusedIndex(() => 0);
|
|
38
|
+
setError(null);
|
|
39
|
+
setNavSeq((n) => n + 1);
|
|
40
|
+
bump();
|
|
41
|
+
}
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (key.upArrow) setFocusedIndex((i) => Math.max(0, i - 1));
|
|
45
|
+
if (key.downArrow) setFocusedIndex((i) => Math.min(Math.max(0, reg.count() - 1), i + 1));
|
|
46
|
+
if (key.return) {
|
|
47
|
+
const target = reg.targetAt(focusedIndex);
|
|
48
|
+
if (target) {
|
|
49
|
+
void router
|
|
50
|
+
.navigate(target.route, target.params)
|
|
51
|
+
.then(() => {
|
|
52
|
+
setFocusedIndex(() => 0);
|
|
53
|
+
setError(null);
|
|
54
|
+
setNavSeq((n) => n + 1);
|
|
55
|
+
bump();
|
|
56
|
+
})
|
|
57
|
+
.catch((err: unknown) => {
|
|
58
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
59
|
+
bump();
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function Browse({ router, onExit }: BrowseProps): React.ReactElement {
|
|
68
|
+
const [focusedIndex, setFocusedIndex] = useState(0);
|
|
69
|
+
const [, setNonce] = useState(0);
|
|
70
|
+
const [error, setError] = useState<string | null>(null);
|
|
71
|
+
const [navSeq, setNavSeq] = useState(0);
|
|
72
|
+
const bump = () => setNonce((n) => n + 1);
|
|
73
|
+
const element = router.current();
|
|
74
|
+
return (
|
|
75
|
+
<FocusRegistryProvider focusedIndex={focusedIndex}>
|
|
76
|
+
<Box flexDirection="column">
|
|
77
|
+
<Box key={navSeq} flexDirection="column">
|
|
78
|
+
{element}
|
|
79
|
+
</Box>
|
|
80
|
+
{error !== null ? <Text color="red">Error: {error}</Text> : null}
|
|
81
|
+
<Text dimColor>(↑/↓: move, enter: open, q/esc: back)</Text>
|
|
82
|
+
</Box>
|
|
83
|
+
<Keys
|
|
84
|
+
router={router}
|
|
85
|
+
onExit={onExit}
|
|
86
|
+
focusedIndex={focusedIndex}
|
|
87
|
+
setFocusedIndex={setFocusedIndex}
|
|
88
|
+
bump={bump}
|
|
89
|
+
setError={setError}
|
|
90
|
+
setNavSeq={setNavSeq}
|
|
91
|
+
/>
|
|
92
|
+
</FocusRegistryProvider>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { JsonApiResource } from "dropsh/plugin";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
import React, { type ReactElement } from "react";
|
|
4
|
+
import { label, splitType } from "./jsonapi-type.js";
|
|
5
|
+
import { TuiLink } from "./link.js";
|
|
6
|
+
import type { BuildContext } from "./types.js";
|
|
7
|
+
|
|
8
|
+
export abstract class TuiEntityList {
|
|
9
|
+
static entityType: string;
|
|
10
|
+
static bundle?: string;
|
|
11
|
+
abstract build(resources: JsonApiResource[], ctx: BuildContext): ReactElement;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class GenericEntityList extends TuiEntityList {
|
|
15
|
+
static override entityType = "*";
|
|
16
|
+
|
|
17
|
+
build(resources: JsonApiResource[], ctx: BuildContext): ReactElement {
|
|
18
|
+
if (resources.length === 0) return <Text dimColor>(no results)</Text>;
|
|
19
|
+
return (
|
|
20
|
+
<Box flexDirection="column">
|
|
21
|
+
{resources.map((res) => {
|
|
22
|
+
const { entityType, bundle } = splitType(res.type);
|
|
23
|
+
const params: Record<string, string> = { type: entityType, id: res.id };
|
|
24
|
+
if (bundle) params.bundle = bundle;
|
|
25
|
+
return (
|
|
26
|
+
<TuiLink key={res.id} target={ctx.link("entity.canonical", params)}>
|
|
27
|
+
{label(res)}
|
|
28
|
+
</TuiLink>
|
|
29
|
+
);
|
|
30
|
+
})}
|
|
31
|
+
</Box>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { JsonApiResource } from "dropsh/plugin";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
import React, { type ReactElement } from "react";
|
|
4
|
+
import { splitType } from "./jsonapi-type.js";
|
|
5
|
+
import { TuiLink } from "./link.js";
|
|
6
|
+
import type { BuildContext, ViewModeMap } from "./types.js";
|
|
7
|
+
|
|
8
|
+
export abstract class TuiEntityView {
|
|
9
|
+
static entityType: string;
|
|
10
|
+
static bundle?: string;
|
|
11
|
+
static viewModes: ViewModeMap = { default: {} };
|
|
12
|
+
abstract build(entity: JsonApiResource, ctx: BuildContext): ReactElement;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class GenericEntityView extends TuiEntityView {
|
|
16
|
+
static override entityType = "*";
|
|
17
|
+
static override viewModes: ViewModeMap = { default: {} };
|
|
18
|
+
|
|
19
|
+
build(entity: JsonApiResource, ctx: BuildContext): ReactElement {
|
|
20
|
+
const attrs = entity.attributes ?? {};
|
|
21
|
+
const rels = entity.relationships ?? {};
|
|
22
|
+
return (
|
|
23
|
+
<Box flexDirection="column">
|
|
24
|
+
{Object.entries(attrs).map(([key, value]) => (
|
|
25
|
+
<Text key={`a:${key}`}>
|
|
26
|
+
{key}: {typeof value === "string" ? value : JSON.stringify(value)}
|
|
27
|
+
</Text>
|
|
28
|
+
))}
|
|
29
|
+
{Object.entries(rels).map(([key, rel]) => {
|
|
30
|
+
const data = (rel as { data?: { type: string; id: string } }).data;
|
|
31
|
+
if (!data || Array.isArray(data)) return <Text key={`r:${key}`}>{key}: —</Text>;
|
|
32
|
+
const { entityType, bundle } = splitType(data.type);
|
|
33
|
+
const params: Record<string, string> = { type: entityType, id: data.id };
|
|
34
|
+
if (bundle) params.bundle = bundle;
|
|
35
|
+
return (
|
|
36
|
+
<Box key={`r:${key}`}>
|
|
37
|
+
<Text>{key}: </Text>
|
|
38
|
+
<TuiLink target={ctx.link("entity.canonical", params)}>{data.id}</TuiLink>
|
|
39
|
+
</Box>
|
|
40
|
+
);
|
|
41
|
+
})}
|
|
42
|
+
</Box>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
DropSHPlugin,
|
|
3
|
+
JsonApiDocument,
|
|
4
|
+
JsonApiResource,
|
|
5
|
+
RenderContext,
|
|
6
|
+
RenderServices,
|
|
7
|
+
} from "dropsh/plugin";
|
|
8
|
+
import { ConfigError } from "dropsh/plugin";
|
|
9
|
+
import { splitType } from "./jsonapi-type.js";
|
|
10
|
+
import { buildRegistry } from "./registry.js";
|
|
11
|
+
import { createRouter } from "./router.js";
|
|
12
|
+
import type { TuiSubPlugin } from "./types.js";
|
|
13
|
+
|
|
14
|
+
export { GenericEntityList, TuiEntityList } from "./entity-list.js";
|
|
15
|
+
export { GenericEntityView, TuiEntityView } from "./entity-view.js";
|
|
16
|
+
export { TuiLink } from "./link.js";
|
|
17
|
+
export type {
|
|
18
|
+
BuildContext,
|
|
19
|
+
LinkDescriptor,
|
|
20
|
+
TuiController,
|
|
21
|
+
TuiRoute,
|
|
22
|
+
TuiSubPlugin,
|
|
23
|
+
ViewModeMap,
|
|
24
|
+
} from "./types.js";
|
|
25
|
+
|
|
26
|
+
export interface TuiOptions {
|
|
27
|
+
plugins?: TuiSubPlugin[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function assertTty(isTty: boolean): void {
|
|
31
|
+
if (!isTty) throw new ConfigError("--format tui requires an interactive terminal");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function initialTarget(
|
|
35
|
+
doc: JsonApiDocument,
|
|
36
|
+
ctx: RenderContext,
|
|
37
|
+
): {
|
|
38
|
+
route: string;
|
|
39
|
+
params: Record<string, string>;
|
|
40
|
+
} {
|
|
41
|
+
if (ctx.command === "search") {
|
|
42
|
+
const params: Record<string, string> = { type: ctx.entityType ?? "" };
|
|
43
|
+
if (ctx.bundle) params.bundle = ctx.bundle;
|
|
44
|
+
return { route: "entity.collection", params };
|
|
45
|
+
}
|
|
46
|
+
const first = (Array.isArray(doc.data) ? doc.data[0] : doc.data) as JsonApiResource | undefined;
|
|
47
|
+
const { entityType, bundle } = splitType(first?.type ?? "");
|
|
48
|
+
const params: Record<string, string> = { type: entityType, id: first?.id ?? "" };
|
|
49
|
+
if (bundle) params.bundle = bundle;
|
|
50
|
+
return { route: "entity.canonical", params };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function runTui(
|
|
54
|
+
doc: JsonApiDocument,
|
|
55
|
+
ctx: RenderContext,
|
|
56
|
+
opts: TuiOptions,
|
|
57
|
+
services?: RenderServices,
|
|
58
|
+
): Promise<void> {
|
|
59
|
+
assertTty(Boolean(process.stdout.isTTY));
|
|
60
|
+
if (!services) throw new ConfigError("--format tui requires runtime services (client)");
|
|
61
|
+
const registry = buildRegistry(opts.plugins ?? []);
|
|
62
|
+
const router = createRouter({
|
|
63
|
+
registry,
|
|
64
|
+
client: services.client,
|
|
65
|
+
viewMode: ctx.viewMode ?? "default",
|
|
66
|
+
});
|
|
67
|
+
const target = initialTarget(doc, ctx);
|
|
68
|
+
await router.navigate(target.route, target.params, doc);
|
|
69
|
+
|
|
70
|
+
const [{ render }, React, { Browse }] = await Promise.all([
|
|
71
|
+
import("ink"),
|
|
72
|
+
import("react"),
|
|
73
|
+
import("./browse-app.js"),
|
|
74
|
+
]);
|
|
75
|
+
const app = render(React.createElement(Browse, { router, onExit: () => app.unmount() }));
|
|
76
|
+
await app.waitUntilExit();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function tuiPlugin(tuiOpts: TuiOptions = {}): DropSHPlugin {
|
|
80
|
+
return {
|
|
81
|
+
id: "tui",
|
|
82
|
+
requiredModules: [],
|
|
83
|
+
async extendSchema(_entityType, _bundle, schema) {
|
|
84
|
+
return schema;
|
|
85
|
+
},
|
|
86
|
+
renderers: [
|
|
87
|
+
{
|
|
88
|
+
id: "tui",
|
|
89
|
+
interactive: true,
|
|
90
|
+
run: (doc, ctx, services) => runTui(doc, ctx, tuiOpts, services),
|
|
91
|
+
},
|
|
92
|
+
],
|
|
93
|
+
};
|
|
94
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { JsonApiResource } from "dropsh/plugin";
|
|
2
|
+
|
|
3
|
+
// Splits a JSON:API type "node--article" into { entityType: "node", bundle: "article" }.
|
|
4
|
+
export function splitType(type: string): { entityType: string; bundle?: string } {
|
|
5
|
+
const [entityType, bundle] = type.split("--") as [string, string?];
|
|
6
|
+
return bundle ? { entityType, bundle } : { entityType };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// Best-effort human label for a resource: first string-valued title/name/label attribute, else id.
|
|
10
|
+
export function label(res: JsonApiResource): string {
|
|
11
|
+
const attrs = res.attributes ?? {};
|
|
12
|
+
for (const key of ["title", "name", "label"]) {
|
|
13
|
+
const v = attrs[key];
|
|
14
|
+
if (typeof v === "string") return v;
|
|
15
|
+
}
|
|
16
|
+
return res.id;
|
|
17
|
+
}
|
package/src/link.tsx
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { Text } from "ink";
|
|
2
|
+
import type React from "react";
|
|
3
|
+
import { createContext, useContext, useEffect, useMemo, useRef, useState } from "react";
|
|
4
|
+
import type { LinkDescriptor } from "./types.js";
|
|
5
|
+
|
|
6
|
+
interface Entry {
|
|
7
|
+
id: symbol;
|
|
8
|
+
target: LinkDescriptor;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface FocusRegistry {
|
|
12
|
+
// register/unregister are called from TuiLink effects, never during render,
|
|
13
|
+
// so a TuiLink re-rendering without a Provider re-render cannot create
|
|
14
|
+
// duplicate entries. Registration order (via mount-effect order) is DOM order.
|
|
15
|
+
register(id: symbol, target: LinkDescriptor): void;
|
|
16
|
+
unregister(id: symbol): void;
|
|
17
|
+
indexOf(id: symbol): number;
|
|
18
|
+
count(): number;
|
|
19
|
+
focusedIndex: number;
|
|
20
|
+
targetAt(i: number): LinkDescriptor | undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const Ctx = createContext<FocusRegistry | null>(null);
|
|
24
|
+
|
|
25
|
+
export function useFocusRegistry(): FocusRegistry {
|
|
26
|
+
const reg = useContext(Ctx);
|
|
27
|
+
if (!reg) throw new Error("useFocusRegistry must be used within FocusRegistryProvider");
|
|
28
|
+
return reg;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function FocusRegistryProvider({
|
|
32
|
+
focusedIndex = 0,
|
|
33
|
+
children,
|
|
34
|
+
}: {
|
|
35
|
+
focusedIndex?: number;
|
|
36
|
+
children: React.ReactNode;
|
|
37
|
+
}): React.ReactElement {
|
|
38
|
+
const [entries, setEntries] = useState<Entry[]>([]);
|
|
39
|
+
const reg = useMemo<FocusRegistry>(
|
|
40
|
+
() => ({
|
|
41
|
+
register(id, target) {
|
|
42
|
+
setEntries((prev) => (prev.some((e) => e.id === id) ? prev : [...prev, { id, target }]));
|
|
43
|
+
},
|
|
44
|
+
unregister(id) {
|
|
45
|
+
setEntries((prev) => prev.filter((e) => e.id !== id));
|
|
46
|
+
},
|
|
47
|
+
indexOf: (id) => entries.findIndex((e) => e.id === id),
|
|
48
|
+
count: () => entries.length,
|
|
49
|
+
focusedIndex,
|
|
50
|
+
targetAt: (i) => entries[i]?.target,
|
|
51
|
+
}),
|
|
52
|
+
[entries, focusedIndex],
|
|
53
|
+
);
|
|
54
|
+
return <Ctx.Provider value={reg}>{children}</Ctx.Provider>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function TuiLink({
|
|
58
|
+
target,
|
|
59
|
+
children,
|
|
60
|
+
}: {
|
|
61
|
+
target: LinkDescriptor;
|
|
62
|
+
children: React.ReactNode;
|
|
63
|
+
}): React.ReactElement {
|
|
64
|
+
const reg = useFocusRegistry();
|
|
65
|
+
const idRef = useRef<symbol | null>(null);
|
|
66
|
+
if (idRef.current === null) idRef.current = Symbol("tuilink");
|
|
67
|
+
const id = idRef.current;
|
|
68
|
+
// Register once on mount, unregister on unmount. A link's target is fixed for
|
|
69
|
+
// its lifetime (derived from stable entity data), so no per-render update.
|
|
70
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: register once on mount only
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
reg.register(id, target);
|
|
73
|
+
return () => reg.unregister(id);
|
|
74
|
+
}, []);
|
|
75
|
+
const index = reg.indexOf(id);
|
|
76
|
+
const isFocused = index === reg.focusedIndex;
|
|
77
|
+
return <Text inverse={isFocused}>{children}</Text>;
|
|
78
|
+
}
|
package/src/registry.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import type { JsonApiDocument, JsonApiResource } from "dropsh/plugin";
|
|
2
|
+
import { DrupalJsonApiParams } from "drupal-jsonapi-params";
|
|
3
|
+
import React from "react";
|
|
4
|
+
import { GenericEntityList, TuiEntityList } from "./entity-list.js";
|
|
5
|
+
import { GenericEntityView, TuiEntityView } from "./entity-view.js";
|
|
6
|
+
import type {
|
|
7
|
+
ControllerContext,
|
|
8
|
+
EntityListClass,
|
|
9
|
+
EntityViewClass,
|
|
10
|
+
TuiRoute,
|
|
11
|
+
TuiSubPlugin,
|
|
12
|
+
} from "./types.js";
|
|
13
|
+
|
|
14
|
+
export interface Registry {
|
|
15
|
+
resolveView(entityType: string, bundle?: string): EntityViewClass;
|
|
16
|
+
resolveList(entityType: string, bundle?: string): EntityListClass;
|
|
17
|
+
routes: Map<string, TuiRoute>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function key(entityType: string, bundle?: string): string {
|
|
21
|
+
return `${entityType}:${bundle ?? ""}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function isView(c: unknown): c is EntityViewClass {
|
|
25
|
+
return typeof c === "function" && (c as typeof TuiEntityView).prototype instanceof TuiEntityView;
|
|
26
|
+
}
|
|
27
|
+
function isList(c: unknown): c is EntityListClass {
|
|
28
|
+
return typeof c === "function" && (c as typeof TuiEntityList).prototype instanceof TuiEntityList;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function buildCtxLink(ctx: ControllerContext) {
|
|
32
|
+
return {
|
|
33
|
+
viewMode: ctx.viewMode,
|
|
34
|
+
link: (route: string, params: Record<string, string>) => ({ route, params }),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function coreRoutes(): TuiRoute[] {
|
|
39
|
+
const canonical: TuiRoute = {
|
|
40
|
+
name: "entity.canonical",
|
|
41
|
+
path: "/{type}/{bundle}/{id}",
|
|
42
|
+
controller: async (params, ctx) => {
|
|
43
|
+
const ViewClass = ctx.resolveView(params.type as string, params.bundle);
|
|
44
|
+
const include = ViewClass?.viewModes?.[ctx.viewMode]?.include;
|
|
45
|
+
const path = params.bundle
|
|
46
|
+
? `${params.type}/${params.bundle}/${params.id}`
|
|
47
|
+
: `${params.type}/${params.id}`;
|
|
48
|
+
const doc = (ctx.seededDoc ??
|
|
49
|
+
(include?.length
|
|
50
|
+
? await ctx.client.get(path, new DrupalJsonApiParams().addInclude(include))
|
|
51
|
+
: await ctx.client.get(path))) as JsonApiDocument;
|
|
52
|
+
const entity = (Array.isArray(doc.data) ? doc.data[0] : doc.data) as JsonApiResource;
|
|
53
|
+
const instance = new (
|
|
54
|
+
ViewClass as unknown as { new (): { build: TuiEntityView["build"] } }
|
|
55
|
+
)();
|
|
56
|
+
return React.createElement(
|
|
57
|
+
React.Fragment,
|
|
58
|
+
null,
|
|
59
|
+
instance.build(entity, { doc, ...buildCtxLink(ctx) }),
|
|
60
|
+
);
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
const collection: TuiRoute = {
|
|
64
|
+
name: "entity.collection",
|
|
65
|
+
path: "/{type}",
|
|
66
|
+
controller: async (params, ctx) => {
|
|
67
|
+
const ListClass = ctx.resolveList(params.type as string, params.bundle);
|
|
68
|
+
const doc = (ctx.seededDoc ??
|
|
69
|
+
(await ctx.client.get(
|
|
70
|
+
params.bundle ? `${params.type}/${params.bundle}` : (params.type as string),
|
|
71
|
+
))) as JsonApiDocument;
|
|
72
|
+
const rows = (Array.isArray(doc.data) ? doc.data : [doc.data]) as JsonApiResource[];
|
|
73
|
+
const instance = new (
|
|
74
|
+
ListClass as unknown as { new (): { build: TuiEntityList["build"] } }
|
|
75
|
+
)();
|
|
76
|
+
return React.createElement(
|
|
77
|
+
React.Fragment,
|
|
78
|
+
null,
|
|
79
|
+
instance.build(rows, { doc, ...buildCtxLink(ctx) }),
|
|
80
|
+
);
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
return [canonical, collection];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function buildRegistry(plugins: TuiSubPlugin[]): Registry {
|
|
87
|
+
const views = new Map<string, EntityViewClass>();
|
|
88
|
+
const lists = new Map<string, EntityListClass>();
|
|
89
|
+
const routes = new Map<string, TuiRoute>();
|
|
90
|
+
|
|
91
|
+
for (const r of coreRoutes()) routes.set(r.name, r);
|
|
92
|
+
|
|
93
|
+
for (const p of plugins) {
|
|
94
|
+
for (const entity of p.entities ?? []) {
|
|
95
|
+
if (isView(entity)) views.set(key(entity.entityType, entity.bundle), entity);
|
|
96
|
+
else if (isList(entity)) lists.set(key(entity.entityType, entity.bundle), entity);
|
|
97
|
+
}
|
|
98
|
+
for (const route of p.routes ?? []) routes.set(route.name, route);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
resolveView: (t, b) => views.get(key(t, b)) ?? views.get(key(t)) ?? GenericEntityView,
|
|
103
|
+
resolveList: (t, b) => lists.get(key(t, b)) ?? lists.get(key(t)) ?? GenericEntityList,
|
|
104
|
+
routes,
|
|
105
|
+
};
|
|
106
|
+
}
|
package/src/router.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { JsonApiClient } from "dropsh/plugin";
|
|
2
|
+
import type { ReactElement } from "react";
|
|
3
|
+
import type { Registry } from "./registry.js";
|
|
4
|
+
import type { ControllerContext } from "./types.js";
|
|
5
|
+
|
|
6
|
+
export interface Router {
|
|
7
|
+
stackDepth(): number;
|
|
8
|
+
current(): ReactElement | null;
|
|
9
|
+
navigate(
|
|
10
|
+
route: string,
|
|
11
|
+
params: Record<string, string>,
|
|
12
|
+
seededDoc?: import("dropsh/plugin").JsonApiDocument,
|
|
13
|
+
): Promise<void>;
|
|
14
|
+
back(): boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function createRouter(opts: {
|
|
18
|
+
registry: Registry;
|
|
19
|
+
client: JsonApiClient;
|
|
20
|
+
viewMode: string;
|
|
21
|
+
}): Router {
|
|
22
|
+
const stack: ReactElement[] = [];
|
|
23
|
+
|
|
24
|
+
async function navigate(
|
|
25
|
+
routeName: string,
|
|
26
|
+
params: Record<string, string>,
|
|
27
|
+
seededDoc?: import("dropsh/plugin").JsonApiDocument,
|
|
28
|
+
): Promise<void> {
|
|
29
|
+
const route = opts.registry.routes.get(routeName);
|
|
30
|
+
if (!route) throw new Error(`unknown route: ${routeName}`);
|
|
31
|
+
const ctx: ControllerContext = {
|
|
32
|
+
client: opts.client,
|
|
33
|
+
viewMode: opts.viewMode,
|
|
34
|
+
...(seededDoc !== undefined ? { seededDoc } : {}),
|
|
35
|
+
navigate: (r, p) => {
|
|
36
|
+
// Host-level navigation surfaces errors in the UI; a controller-initiated
|
|
37
|
+
// navigation failure must not become an unhandled rejection / crash.
|
|
38
|
+
void navigate(r, p).catch(() => {});
|
|
39
|
+
},
|
|
40
|
+
resolveView: opts.registry.resolveView,
|
|
41
|
+
resolveList: opts.registry.resolveList,
|
|
42
|
+
};
|
|
43
|
+
const element = await route.controller(params, ctx);
|
|
44
|
+
stack.push(element);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
stackDepth: () => stack.length,
|
|
49
|
+
current: () => stack[stack.length - 1] ?? null,
|
|
50
|
+
navigate,
|
|
51
|
+
back() {
|
|
52
|
+
stack.pop();
|
|
53
|
+
return stack.length > 0;
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { JsonApiClient, JsonApiDocument } from "dropsh/plugin";
|
|
2
|
+
import type { ReactElement } from "react";
|
|
3
|
+
|
|
4
|
+
export interface LinkDescriptor {
|
|
5
|
+
route: string;
|
|
6
|
+
params: Record<string, string>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface BuildContext {
|
|
10
|
+
viewMode: string;
|
|
11
|
+
doc: JsonApiDocument;
|
|
12
|
+
link(route: string, params: Record<string, string>): LinkDescriptor;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type ViewModeMap = Record<string, { include?: string[] }>;
|
|
16
|
+
|
|
17
|
+
export interface ControllerContext {
|
|
18
|
+
client: JsonApiClient;
|
|
19
|
+
viewMode: string;
|
|
20
|
+
seededDoc?: JsonApiDocument;
|
|
21
|
+
navigate(route: string, params: Record<string, string>): void;
|
|
22
|
+
resolveView(entityType: string, bundle?: string): EntityViewClass | undefined;
|
|
23
|
+
resolveList(entityType: string, bundle?: string): EntityListClass | undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type TuiController = (
|
|
27
|
+
params: Record<string, string>,
|
|
28
|
+
ctx: ControllerContext,
|
|
29
|
+
) => Promise<ReactElement>;
|
|
30
|
+
|
|
31
|
+
export interface TuiRoute {
|
|
32
|
+
name: string;
|
|
33
|
+
path: string;
|
|
34
|
+
controller: TuiController;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Structural handler class shapes; concrete bases live in entity-view.tsx / entity-list.tsx.
|
|
38
|
+
export interface EntityViewClass {
|
|
39
|
+
entityType: string;
|
|
40
|
+
bundle?: string;
|
|
41
|
+
viewModes: ViewModeMap;
|
|
42
|
+
new (): {
|
|
43
|
+
build(entity: import("dropsh/plugin").JsonApiResource, ctx: BuildContext): ReactElement;
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
export interface EntityListClass {
|
|
47
|
+
entityType: string;
|
|
48
|
+
bundle?: string;
|
|
49
|
+
new (): {
|
|
50
|
+
build(resources: import("dropsh/plugin").JsonApiResource[], ctx: BuildContext): ReactElement;
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
export type EntityHandlerClass = EntityViewClass | EntityListClass;
|
|
54
|
+
|
|
55
|
+
export interface TuiSubPlugin {
|
|
56
|
+
id: string;
|
|
57
|
+
entities?: EntityHandlerClass[];
|
|
58
|
+
routes?: TuiRoute[];
|
|
59
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import type { JsonApiClient } from "dropsh/plugin";
|
|
2
|
+
import { render } from "ink-testing-library";
|
|
3
|
+
import React from "react";
|
|
4
|
+
import { describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { Browse } from "../../src/browse-app.js";
|
|
6
|
+
import { buildRegistry } from "../../src/registry.js";
|
|
7
|
+
import { createRouter } from "../../src/router.js";
|
|
8
|
+
|
|
9
|
+
function client(): JsonApiClient {
|
|
10
|
+
return {
|
|
11
|
+
get: vi.fn(async () => ({
|
|
12
|
+
data: { type: "node--article", id: "u9", attributes: { title: "Fetched" } },
|
|
13
|
+
})),
|
|
14
|
+
post: vi.fn(),
|
|
15
|
+
patch: vi.fn(),
|
|
16
|
+
delete: vi.fn(),
|
|
17
|
+
upload: vi.fn(),
|
|
18
|
+
} as unknown as JsonApiClient;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const tick = () => new Promise((r) => setTimeout(r, 20));
|
|
22
|
+
|
|
23
|
+
describe("Browse", () => {
|
|
24
|
+
it("renders the seeded entity via the generic view", async () => {
|
|
25
|
+
const c = client();
|
|
26
|
+
const router = createRouter({ registry: buildRegistry([]), client: c, viewMode: "default" });
|
|
27
|
+
await router.navigate(
|
|
28
|
+
"entity.canonical",
|
|
29
|
+
{ type: "node", bundle: "article", id: "u1" },
|
|
30
|
+
{
|
|
31
|
+
data: {
|
|
32
|
+
type: "node--article",
|
|
33
|
+
id: "u1",
|
|
34
|
+
attributes: { title: "Alpha" },
|
|
35
|
+
relationships: { uid: { data: { type: "user--user", id: "a1" } } },
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
);
|
|
39
|
+
const { lastFrame } = render(<Browse router={router} onExit={() => {}} />);
|
|
40
|
+
await tick();
|
|
41
|
+
expect(lastFrame()).toContain("Alpha");
|
|
42
|
+
expect(lastFrame()).toContain("uid");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("exits when back() empties the stack", async () => {
|
|
46
|
+
const c = client();
|
|
47
|
+
const router = createRouter({ registry: buildRegistry([]), client: c, viewMode: "default" });
|
|
48
|
+
await router.navigate(
|
|
49
|
+
"entity.canonical",
|
|
50
|
+
{ type: "node", bundle: "article", id: "u1" },
|
|
51
|
+
{
|
|
52
|
+
data: { type: "node--article", id: "u1", attributes: { title: "Alpha" } },
|
|
53
|
+
},
|
|
54
|
+
);
|
|
55
|
+
const onExit = vi.fn();
|
|
56
|
+
const { stdin } = render(<Browse router={router} onExit={onExit} />);
|
|
57
|
+
await tick();
|
|
58
|
+
stdin.write("q");
|
|
59
|
+
await tick();
|
|
60
|
+
expect(onExit).toHaveBeenCalled();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("surfaces a navigation error instead of crashing", async () => {
|
|
64
|
+
const c = {
|
|
65
|
+
get: vi.fn(async () => {
|
|
66
|
+
throw new Error("boom");
|
|
67
|
+
}),
|
|
68
|
+
post: vi.fn(),
|
|
69
|
+
patch: vi.fn(),
|
|
70
|
+
delete: vi.fn(),
|
|
71
|
+
upload: vi.fn(),
|
|
72
|
+
} as unknown as JsonApiClient;
|
|
73
|
+
const router = createRouter({ registry: buildRegistry([]), client: c, viewMode: "default" });
|
|
74
|
+
await router.navigate(
|
|
75
|
+
"entity.canonical",
|
|
76
|
+
{ type: "node", bundle: "article", id: "u1" },
|
|
77
|
+
{
|
|
78
|
+
data: {
|
|
79
|
+
type: "node--article",
|
|
80
|
+
id: "u1",
|
|
81
|
+
attributes: { title: "Alpha" },
|
|
82
|
+
relationships: { uid: { data: { type: "user--user", id: "a1" } } },
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
);
|
|
86
|
+
const { lastFrame, stdin } = render(<Browse router={router} onExit={() => {}} />);
|
|
87
|
+
await tick();
|
|
88
|
+
stdin.write("\r"); // Enter → navigate to user/a1 → client.get rejects
|
|
89
|
+
await tick();
|
|
90
|
+
expect(lastFrame()).toContain("boom");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("navigates using the newly-rendered entity's links, not the previous entity's (regression)", async () => {
|
|
94
|
+
const rel = (id: string) => ({
|
|
95
|
+
field_ref: { data: { type: "node--article", id } },
|
|
96
|
+
});
|
|
97
|
+
const get = vi.fn(async (path: string) => {
|
|
98
|
+
// A already seeded; B links to C; C links to D.
|
|
99
|
+
if (path === "node/article/B") {
|
|
100
|
+
return {
|
|
101
|
+
data: {
|
|
102
|
+
type: "node--article",
|
|
103
|
+
id: "B",
|
|
104
|
+
attributes: { title: "Bee" },
|
|
105
|
+
relationships: rel("C"),
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
return {
|
|
110
|
+
data: { type: "node--article", id: path.split("/").pop(), attributes: { title: "X" } },
|
|
111
|
+
};
|
|
112
|
+
});
|
|
113
|
+
const c = {
|
|
114
|
+
get,
|
|
115
|
+
post: vi.fn(),
|
|
116
|
+
patch: vi.fn(),
|
|
117
|
+
delete: vi.fn(),
|
|
118
|
+
upload: vi.fn(),
|
|
119
|
+
} as unknown as JsonApiClient;
|
|
120
|
+
const router = createRouter({ registry: buildRegistry([]), client: c, viewMode: "default" });
|
|
121
|
+
// Seed A, which links to B.
|
|
122
|
+
await router.navigate(
|
|
123
|
+
"entity.canonical",
|
|
124
|
+
{ type: "node", bundle: "article", id: "A" },
|
|
125
|
+
{
|
|
126
|
+
data: {
|
|
127
|
+
type: "node--article",
|
|
128
|
+
id: "A",
|
|
129
|
+
attributes: { title: "Ay" },
|
|
130
|
+
relationships: rel("B"),
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
);
|
|
134
|
+
const { stdin } = render(<Browse router={router} onExit={() => {}} />);
|
|
135
|
+
await tick();
|
|
136
|
+
stdin.write("\r"); // Enter on A's field_ref → fetch B
|
|
137
|
+
await tick();
|
|
138
|
+
stdin.write("\r"); // Enter on the link now shown for B → must fetch C, NOT B again
|
|
139
|
+
await tick();
|
|
140
|
+
expect(get).toHaveBeenCalledWith("node/article/C");
|
|
141
|
+
});
|
|
142
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { JsonApiDocument, JsonApiResource } from "dropsh/plugin";
|
|
2
|
+
import { render } from "ink-testing-library";
|
|
3
|
+
import React from "react";
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
import { GenericEntityList } from "../../src/entity-list.js";
|
|
6
|
+
import { FocusRegistryProvider } from "../../src/link.js";
|
|
7
|
+
|
|
8
|
+
const link = (route: string, params: Record<string, string>) => ({ route, params });
|
|
9
|
+
|
|
10
|
+
describe("GenericEntityList", () => {
|
|
11
|
+
it("renders one navigable row per resource with a title label", () => {
|
|
12
|
+
const rows: JsonApiResource[] = [
|
|
13
|
+
{ type: "node--article", id: "u1", attributes: { title: "Alpha" } },
|
|
14
|
+
{ type: "node--article", id: "u2", attributes: { title: "Beta" } },
|
|
15
|
+
];
|
|
16
|
+
const doc: JsonApiDocument = { data: rows };
|
|
17
|
+
const el = new GenericEntityList().build(rows, { viewMode: "default", doc, link });
|
|
18
|
+
const { lastFrame } = render(<FocusRegistryProvider>{el}</FocusRegistryProvider>);
|
|
19
|
+
expect(lastFrame()).toContain("Alpha");
|
|
20
|
+
expect(lastFrame()).toContain("Beta");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("shows (no results) for an empty collection", () => {
|
|
24
|
+
const doc: JsonApiDocument = { data: [] };
|
|
25
|
+
const el = new GenericEntityList().build([], { viewMode: "default", doc, link });
|
|
26
|
+
const { lastFrame } = render(<FocusRegistryProvider>{el}</FocusRegistryProvider>);
|
|
27
|
+
expect(lastFrame()).toContain("(no results)");
|
|
28
|
+
});
|
|
29
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { JsonApiDocument, JsonApiResource } from "dropsh/plugin";
|
|
2
|
+
import { render } from "ink-testing-library";
|
|
3
|
+
import React from "react";
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
import { GenericEntityView } from "../../src/entity-view.js";
|
|
6
|
+
import { FocusRegistryProvider } from "../../src/link.js";
|
|
7
|
+
|
|
8
|
+
const link = (route: string, params: Record<string, string>) => ({ route, params });
|
|
9
|
+
|
|
10
|
+
function ctx(doc: JsonApiDocument) {
|
|
11
|
+
return { viewMode: "default", doc, link };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe("GenericEntityView", () => {
|
|
15
|
+
it("renders attributes and a navigable relationship", () => {
|
|
16
|
+
const entity: JsonApiResource = {
|
|
17
|
+
type: "node--article",
|
|
18
|
+
id: "u1",
|
|
19
|
+
attributes: { title: "Alpha" },
|
|
20
|
+
relationships: { uid: { data: { type: "user--user", id: "a1" } } },
|
|
21
|
+
};
|
|
22
|
+
const doc: JsonApiDocument = { data: entity };
|
|
23
|
+
const el = new GenericEntityView().build(entity, ctx(doc));
|
|
24
|
+
const { lastFrame } = render(<FocusRegistryProvider>{el}</FocusRegistryProvider>);
|
|
25
|
+
expect(lastFrame()).toContain("title");
|
|
26
|
+
expect(lastFrame()).toContain("Alpha");
|
|
27
|
+
expect(lastFrame()).toContain("uid");
|
|
28
|
+
});
|
|
29
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { JsonApiDocument, RenderContext, RenderServices } from "dropsh/plugin";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { initialTarget, tuiPlugin } from "../../src/index.js";
|
|
4
|
+
|
|
5
|
+
function services(): RenderServices {
|
|
6
|
+
return {
|
|
7
|
+
client: { get: vi.fn(async () => ({ data: [] })) } as never,
|
|
8
|
+
baseUrl: "https://x.test",
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
describe("tuiPlugin", () => {
|
|
13
|
+
it("exposes an interactive renderer with id 'tui'", () => {
|
|
14
|
+
const plugin = tuiPlugin();
|
|
15
|
+
const renderer = plugin.renderers?.[0];
|
|
16
|
+
expect(renderer?.id).toBe("tui");
|
|
17
|
+
expect((renderer as { interactive?: boolean }).interactive).toBe(true);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("run() rejects when stdout is not a TTY", async () => {
|
|
21
|
+
const plugin = tuiPlugin();
|
|
22
|
+
const renderer = plugin.renderers?.[0] as {
|
|
23
|
+
run: (d: JsonApiDocument, c: RenderContext, s?: RenderServices) => Promise<void>;
|
|
24
|
+
};
|
|
25
|
+
const orig = process.stdout.isTTY;
|
|
26
|
+
Object.defineProperty(process.stdout, "isTTY", { value: false, configurable: true });
|
|
27
|
+
await expect(
|
|
28
|
+
renderer.run({ data: { type: "node--article", id: "u1" } }, { command: "read" }, services()),
|
|
29
|
+
).rejects.toThrow(/interactive terminal/i);
|
|
30
|
+
Object.defineProperty(process.stdout, "isTTY", { value: orig, configurable: true });
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe("initialTarget", () => {
|
|
35
|
+
it("read of a single resource → canonical route with type/bundle/id", () => {
|
|
36
|
+
const t = initialTarget({ data: { type: "node--article", id: "u1" } }, {
|
|
37
|
+
command: "read",
|
|
38
|
+
} as RenderContext);
|
|
39
|
+
expect(t).toEqual({
|
|
40
|
+
route: "entity.canonical",
|
|
41
|
+
params: { type: "node", bundle: "article", id: "u1" },
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("read of a collection → canonical route for the first resource", () => {
|
|
46
|
+
const t = initialTarget(
|
|
47
|
+
{
|
|
48
|
+
data: [
|
|
49
|
+
{ type: "node--page", id: "p1" },
|
|
50
|
+
{ type: "node--page", id: "p2" },
|
|
51
|
+
],
|
|
52
|
+
},
|
|
53
|
+
{ command: "read" } as RenderContext,
|
|
54
|
+
);
|
|
55
|
+
expect(t).toEqual({
|
|
56
|
+
route: "entity.canonical",
|
|
57
|
+
params: { type: "node", bundle: "page", id: "p1" },
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("search → collection route with type and bundle from ctx", () => {
|
|
62
|
+
const t = initialTarget({ data: [] }, {
|
|
63
|
+
command: "search",
|
|
64
|
+
entityType: "node",
|
|
65
|
+
bundle: "article",
|
|
66
|
+
} as RenderContext);
|
|
67
|
+
expect(t).toEqual({ route: "entity.collection", params: { type: "node", bundle: "article" } });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("search without a bundle → collection route with only type", () => {
|
|
71
|
+
const t = initialTarget({ data: [] }, {
|
|
72
|
+
command: "search",
|
|
73
|
+
entityType: "node",
|
|
74
|
+
} as RenderContext);
|
|
75
|
+
expect(t).toEqual({ route: "entity.collection", params: { type: "node" } });
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { JsonApiResource } from "dropsh/plugin";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { label, splitType } from "../../src/jsonapi-type.js";
|
|
4
|
+
|
|
5
|
+
describe("splitType", () => {
|
|
6
|
+
it("splits type--bundle", () => {
|
|
7
|
+
expect(splitType("node--article")).toEqual({ entityType: "node", bundle: "article" });
|
|
8
|
+
});
|
|
9
|
+
it("handles a type with no bundle segment", () => {
|
|
10
|
+
expect(splitType("user")).toEqual({ entityType: "user" });
|
|
11
|
+
});
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
describe("label", () => {
|
|
15
|
+
it("prefers title, then name, then label, else id", () => {
|
|
16
|
+
expect(label({ type: "n--a", id: "1", attributes: { title: "T" } } as JsonApiResource)).toBe(
|
|
17
|
+
"T",
|
|
18
|
+
);
|
|
19
|
+
expect(label({ type: "n--a", id: "1", attributes: { name: "N" } } as JsonApiResource)).toBe(
|
|
20
|
+
"N",
|
|
21
|
+
);
|
|
22
|
+
expect(label({ type: "n--a", id: "1", attributes: {} } as JsonApiResource)).toBe("1");
|
|
23
|
+
});
|
|
24
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
// Force chalk (used internally by ink to render styles such as `inverse`) to
|
|
4
|
+
// emit real ANSI escapes even though tests run outside a TTY. This must be
|
|
5
|
+
// set before ink — and anything that imports it — is loaded, so those
|
|
6
|
+
// modules are imported dynamically below, after this line runs.
|
|
7
|
+
process.env.FORCE_COLOR = "1";
|
|
8
|
+
|
|
9
|
+
const { Text } = await import("ink");
|
|
10
|
+
const { render } = await import("ink-testing-library");
|
|
11
|
+
const { FocusRegistryProvider, TuiLink, useFocusRegistry } = await import("../../src/link.js");
|
|
12
|
+
|
|
13
|
+
const tick = () => new Promise((r) => setTimeout(r, 30));
|
|
14
|
+
|
|
15
|
+
// Renders the registry's observable state so the test can assert registration
|
|
16
|
+
// count and which target is focused — after mount effects have run.
|
|
17
|
+
function Probe() {
|
|
18
|
+
const reg = useFocusRegistry();
|
|
19
|
+
return (
|
|
20
|
+
<Text>
|
|
21
|
+
count={reg.count()} focus={reg.targetAt(reg.focusedIndex)?.params.id ?? "none"}
|
|
22
|
+
</Text>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function tree(focusedIndex: number) {
|
|
27
|
+
return (
|
|
28
|
+
<FocusRegistryProvider focusedIndex={focusedIndex}>
|
|
29
|
+
<TuiLink target={{ route: "entity.canonical", params: { id: "u1" } }}>Alpha</TuiLink>
|
|
30
|
+
<TuiLink target={{ route: "entity.canonical", params: { id: "u2" } }}>Beta</TuiLink>
|
|
31
|
+
<Probe />
|
|
32
|
+
</FocusRegistryProvider>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe("TuiLink + FocusRegistry", () => {
|
|
37
|
+
it("registers each link once, in order, and resolves the focused target", async () => {
|
|
38
|
+
const { lastFrame } = render(tree(1));
|
|
39
|
+
await tick();
|
|
40
|
+
expect(lastFrame()).toContain("Alpha");
|
|
41
|
+
expect(lastFrame()).toContain("Beta");
|
|
42
|
+
// Both links registered exactly once (no render-time duplicate registration).
|
|
43
|
+
expect(lastFrame()).toContain("count=2");
|
|
44
|
+
// focusedIndex 1 resolves to the second registered link (Beta / u2), proving
|
|
45
|
+
// registration order is DOM order and targetAt/focusedIndex wiring works.
|
|
46
|
+
expect(lastFrame()).toContain("focus=u2");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("wraps only the focused link in inverse styling", async () => {
|
|
50
|
+
const a = render(tree(0));
|
|
51
|
+
await tick();
|
|
52
|
+
const f0 = a.lastFrame() ?? "";
|
|
53
|
+
const b = render(tree(1));
|
|
54
|
+
await tick();
|
|
55
|
+
const f1 = b.lastFrame() ?? "";
|
|
56
|
+
const INV = "[7m"; // ANSI SGR: inverse on
|
|
57
|
+
// focusedIndex 0 → only "Alpha" is highlighted.
|
|
58
|
+
expect(f0).toContain(`${INV}Alpha`);
|
|
59
|
+
expect(f0).not.toContain(`${INV}Beta`);
|
|
60
|
+
// focusedIndex 1 → only "Beta" is highlighted.
|
|
61
|
+
expect(f1).toContain(`${INV}Beta`);
|
|
62
|
+
expect(f1).not.toContain(`${INV}Alpha`);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { GenericEntityList } from "../../src/entity-list.js";
|
|
3
|
+
import { GenericEntityView, TuiEntityView } from "../../src/entity-view.js";
|
|
4
|
+
import { buildRegistry } from "../../src/registry.js";
|
|
5
|
+
|
|
6
|
+
class ArticleView extends TuiEntityView {
|
|
7
|
+
static override entityType = "node";
|
|
8
|
+
static override bundle = "article";
|
|
9
|
+
static override viewModes = { default: {}, teaser: {} };
|
|
10
|
+
build() {
|
|
11
|
+
return null as never;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe("buildRegistry", () => {
|
|
16
|
+
it("resolves a registered view by type+bundle, else the generic default", () => {
|
|
17
|
+
const reg = buildRegistry([{ id: "x", entities: [ArticleView] }]);
|
|
18
|
+
expect(reg.resolveView("node", "article")).toBe(ArticleView);
|
|
19
|
+
expect(reg.resolveView("node", "page")).toBe(GenericEntityView);
|
|
20
|
+
expect(reg.resolveView("user")).toBe(GenericEntityView);
|
|
21
|
+
expect(reg.resolveList("node", "article")).toBe(GenericEntityList);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("provides core default routes and lets sub-plugin routes override by name", () => {
|
|
25
|
+
const custom = {
|
|
26
|
+
name: "entity.canonical",
|
|
27
|
+
path: "/custom/{id}",
|
|
28
|
+
controller: async () => null as never,
|
|
29
|
+
};
|
|
30
|
+
const reg = buildRegistry([{ id: "x", routes: [custom] }]);
|
|
31
|
+
expect(reg.routes.get("entity.collection")?.path).toBe("/{type}");
|
|
32
|
+
expect(reg.routes.get("entity.canonical")).toBe(custom);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { JsonApiClient } from "dropsh/plugin";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { TuiEntityView } from "../../src/entity-view.js";
|
|
4
|
+
import { buildRegistry } from "../../src/registry.js";
|
|
5
|
+
import { createRouter } from "../../src/router.js";
|
|
6
|
+
|
|
7
|
+
function client(): JsonApiClient {
|
|
8
|
+
return {
|
|
9
|
+
get: vi.fn(async (path: string) => ({
|
|
10
|
+
data: { type: "node--article", id: path.split("/").pop() ?? "x" },
|
|
11
|
+
})),
|
|
12
|
+
post: vi.fn(),
|
|
13
|
+
patch: vi.fn(),
|
|
14
|
+
delete: vi.fn(),
|
|
15
|
+
upload: vi.fn(),
|
|
16
|
+
} as unknown as JsonApiClient;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe("createRouter", () => {
|
|
20
|
+
it("uses the seeded doc without fetching on the first navigate", async () => {
|
|
21
|
+
const c = client();
|
|
22
|
+
const router = createRouter({ registry: buildRegistry([]), client: c, viewMode: "default" });
|
|
23
|
+
await router.navigate(
|
|
24
|
+
"entity.canonical",
|
|
25
|
+
{ type: "node", bundle: "article", id: "u1" },
|
|
26
|
+
{
|
|
27
|
+
data: { type: "node--article", id: "u1", attributes: { title: "Seed" } },
|
|
28
|
+
},
|
|
29
|
+
);
|
|
30
|
+
expect(router.stackDepth()).toBe(1);
|
|
31
|
+
expect(c.get).not.toHaveBeenCalled();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("fetches (upcasts) when navigating without a seeded doc, and back() pops", async () => {
|
|
35
|
+
const c = client();
|
|
36
|
+
const router = createRouter({ registry: buildRegistry([]), client: c, viewMode: "default" });
|
|
37
|
+
await router.navigate("entity.canonical", { type: "node", bundle: "article", id: "u1" });
|
|
38
|
+
await router.navigate("entity.canonical", { type: "user", id: "a1" });
|
|
39
|
+
expect(c.get).toHaveBeenCalledTimes(2);
|
|
40
|
+
expect(router.stackDepth()).toBe(2);
|
|
41
|
+
expect(router.back()).toBe(true);
|
|
42
|
+
expect(router.stackDepth()).toBe(1);
|
|
43
|
+
expect(router.back()).toBe(false);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("throws on an unknown route name", async () => {
|
|
47
|
+
const router = createRouter({
|
|
48
|
+
registry: buildRegistry([]),
|
|
49
|
+
client: client(),
|
|
50
|
+
viewMode: "default",
|
|
51
|
+
});
|
|
52
|
+
await expect(router.navigate("nope", {})).rejects.toThrow(/unknown route/i);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("passes the view mode's include hints to client.get on a non-seeded canonical fetch", async () => {
|
|
56
|
+
class ArticleView extends TuiEntityView {
|
|
57
|
+
static override entityType = "node";
|
|
58
|
+
static override bundle = "article";
|
|
59
|
+
static override viewModes = { default: { include: ["uid"] } };
|
|
60
|
+
build() {
|
|
61
|
+
return null as never;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const get = vi.fn(
|
|
65
|
+
async (_path: string, _params?: import("drupal-jsonapi-params").DrupalJsonApiParams) => ({
|
|
66
|
+
data: { type: "node--article", id: "x" },
|
|
67
|
+
}),
|
|
68
|
+
);
|
|
69
|
+
const c = {
|
|
70
|
+
get,
|
|
71
|
+
post: vi.fn(),
|
|
72
|
+
patch: vi.fn(),
|
|
73
|
+
delete: vi.fn(),
|
|
74
|
+
upload: vi.fn(),
|
|
75
|
+
} as unknown as JsonApiClient;
|
|
76
|
+
const router = createRouter({
|
|
77
|
+
registry: buildRegistry([{ id: "x", entities: [ArticleView] }]),
|
|
78
|
+
client: c,
|
|
79
|
+
viewMode: "default",
|
|
80
|
+
});
|
|
81
|
+
await router.navigate("entity.canonical", { type: "node", bundle: "article", id: "x" });
|
|
82
|
+
expect(get.mock.calls[0]?.[1]?.getQueryString()).toContain("include=uid");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("fetches the bundle-scoped path for a collection when no seededDoc is given", async () => {
|
|
86
|
+
const c = client();
|
|
87
|
+
const router = createRouter({ registry: buildRegistry([]), client: c, viewMode: "default" });
|
|
88
|
+
await router.navigate("entity.collection", { type: "node", bundle: "article" });
|
|
89
|
+
expect(c.get).toHaveBeenCalledWith("node/article");
|
|
90
|
+
});
|
|
91
|
+
});
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "../../tsconfig.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"outDir": "dist",
|
|
5
|
+
"noEmit": false,
|
|
6
|
+
"jsx": "react-jsx",
|
|
7
|
+
"baseUrl": ".",
|
|
8
|
+
"paths": {
|
|
9
|
+
"dropsh/plugin": ["../../src/plugin-api.ts"],
|
|
10
|
+
"@dropsh/plugin-markdown/render": ["../markdown/src/render-md.ts"]
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"include": ["src", "tests"]
|
|
14
|
+
}
|
package/vitest.config.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { defineConfig } from "vitest/config";
|
|
3
|
+
|
|
4
|
+
export default defineConfig({
|
|
5
|
+
resolve: {
|
|
6
|
+
alias: {
|
|
7
|
+
"dropsh/plugin": resolve(__dirname, "../../src/plugin-api.ts"),
|
|
8
|
+
"@dropsh/plugin-markdown/render": resolve(__dirname, "../markdown/src/render-md.ts"),
|
|
9
|
+
},
|
|
10
|
+
},
|
|
11
|
+
test: {
|
|
12
|
+
include: ["tests/unit/**/*.test.{ts,tsx}"],
|
|
13
|
+
environment: "node",
|
|
14
|
+
},
|
|
15
|
+
});
|