@shortlink-org/portolan 0.2.4 → 0.4.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 +174 -6
- package/catalog/enum_test.go +46 -0
- package/catalog/evidence_test.go +35 -0
- package/catalog/model.go +1213 -0
- package/catalog/roundtrip_test.go +210 -0
- package/catalog/via_test.go +38 -0
- package/cli/init.test.mjs +6 -1
- package/cli/portolan.mjs +14 -1
- package/cli/portolan.test.mjs +49 -0
- package/go.mod +21 -0
- package/go.sum +34 -0
- package/internal/gocall/README.md +19 -0
- package/internal/gocall/analyze.go +189 -0
- package/internal/gocall/analyze_test.go +107 -0
- package/internal/gohttp/analyze.go +2562 -0
- package/internal/gohttp/destination.go +373 -0
- package/internal/gohttp/endpoints.go +1067 -0
- package/internal/gohttp/roots.go +320 -0
- package/internal/gohttp/typed.go +96 -0
- package/internal/goscan/constants.go +85 -0
- package/internal/goscan/goscan_test.go +227 -0
- package/internal/goscan/index.go +629 -0
- package/internal/goscan/index_test.go +66 -0
- package/internal/goscan/names.go +52 -0
- package/internal/goscan/parse_test.go +11 -0
- package/internal/goscan/source.go +37 -0
- package/internal/goscan/tree.go +284 -0
- package/internal/goscan/types.go +99 -0
- package/internal/wsdl/ids.go +127 -0
- package/internal/wsdl/ids_test.go +21 -0
- package/internal/wsdl/model.go +70 -0
- package/internal/wsdl/parse.go +949 -0
- package/internal/wsdl/parse_test.go +170 -0
- package/package.json +28 -15
- package/plugin/describe.go +156 -0
- package/plugin/describe_test.go +114 -0
- package/plugin/protocol.go +141 -0
- package/plugin/schematest/schematest.go +126 -0
- package/plugins/README.md +404 -50
- package/plugins/cmd/portolan-http-clients/main.go +19 -0
- package/plugins/extract-argocd/options.schema.json +44 -0
- package/plugins/extract-celery/extract.py +0 -2
- package/plugins/extract-celery/extract_test.py +1 -1
- package/plugins/extract-celery/main.py +1 -0
- package/plugins/extract-csharp-ddd/README.md +213 -0
- package/plugins/extract-csharp-ddd/options.schema.json +55 -0
- package/plugins/extract-django/README.md +52 -19
- package/plugins/extract-django/choices.py +47 -0
- package/plugins/extract-django/domain.py +30 -18
- package/plugins/extract-django/extract.py +21 -7
- package/plugins/extract-django/extract_test.py +68 -2
- package/plugins/extract-django/lifecycle.py +4 -28
- package/plugins/extract-django/main.py +1 -0
- package/plugins/extract-django/operations.py +1 -1
- package/plugins/extract-django/routing_test.py +109 -1
- package/plugins/extract-django/rules.py +154 -0
- package/plugins/extract-django/rules_test.py +158 -0
- package/plugins/extract-django/store.py +1 -1
- package/plugins/extract-django/transport.py +101 -55
- package/plugins/extract-django/verbs.py +241 -0
- package/plugins/extract-go/README.md +47 -0
- package/plugins/extract-go-sqs/options.schema.json +21 -0
- package/plugins/extract-http-clients/describe.go +20 -0
- package/plugins/extract-http-clients/describe_test.go +11 -0
- package/plugins/extract-http-clients/extract.go +740 -0
- package/plugins/extract-http-clients/extract_test.go +1561 -0
- package/plugins/extract-http-clients/main.go +41 -0
- package/plugins/extract-java/build/org/portolan/extract/Extract.class +0 -0
- package/plugins/extract-java/build/org/portolan/extract/Main.class +0 -0
- package/plugins/extract-java/build/org/portolan/extract/Protocol$Builder.class +0 -0
- package/plugins/extract-java/build/org/portolan/extract/Protocol$Input.class +0 -0
- package/plugins/extract-java/build/org/portolan/extract/Protocol$Options.class +0 -0
- package/plugins/extract-k8s/options.schema.json +32 -0
- package/plugins/extract-laravel/Cargo.lock +962 -0
- package/plugins/extract-laravel/Cargo.toml +20 -0
- package/plugins/extract-laravel/README.md +200 -0
- package/plugins/extract-laravel/options.schema.json +68 -0
- package/plugins/extract-laravel/rustfmt.toml +1 -0
- package/plugins/extract-php-ddd/Cargo.lock +972 -0
- package/plugins/extract-php-ddd/Cargo.toml +22 -0
- package/plugins/extract-php-ddd/README.md +141 -0
- package/plugins/extract-php-ddd/options.schema.json +50 -0
- package/plugins/extract-php-ddd/rustfmt.toml +1 -0
- package/plugins/extract-python-kafka/extract.py +0 -2
- package/plugins/extract-python-kafka/extract_test.py +1 -1
- package/plugins/extract-python-kafka/main.py +1 -0
- package/plugins/extract-terraform/options.schema.json +26 -0
- package/plugins/extract-ts/extract.test.ts +2 -2
- package/plugins/extract-ts/extract.ts +4 -5
- package/plugins/extract-ts/graphql.test.ts +1 -1
- package/plugins/extract-ts/main.ts +1 -0
- package/plugins/openapi/ids.go +261 -0
- package/plugins/openapi/ids_test.go +98 -0
- package/plugins/phpscan/Cargo.lock +953 -0
- package/plugins/phpscan/Cargo.toml +19 -0
- package/plugins/phpscan/rustfmt.toml +1 -0
- package/plugins/portolan-go.wasm +0 -0
- package/plugins/pyplugin/catalog.py +24 -1
- package/plugins/pyplugin/protocol.py +1 -5
- package/plugins/verify-otel/options.schema.json +12 -0
- package/portolan.json +132 -4
- package/rules/builtin.json +218 -0
- package/schema/portolan.schema.json +905 -4
- package/scripts/README.md +21 -13
- package/scripts/catalog-sources.mjs +6 -0
- package/scripts/delivery-presets.mjs +57 -14
- package/scripts/diff.mjs +5 -1
- package/scripts/django-aggregates.test.mjs +58 -0
- package/scripts/gen-likec4.mjs +150 -17
- package/scripts/gen-likec4.test.mjs +96 -0
- package/scripts/gen.mjs +148 -118
- package/scripts/gitops-example.test.mjs +108 -0
- package/scripts/go-discovery.test.mjs +30 -0
- package/scripts/history.mjs +186 -3
- package/scripts/history.test.mjs +1 -1
- package/scripts/host-plugins/fetch-argocd.mjs +338 -0
- package/scripts/host-plugins/fetch-argocd.options.json +49 -0
- package/scripts/host-plugins/fetch-argocd.test.mjs +274 -0
- package/scripts/host-plugins/fetch-bsr.mjs +1 -0
- package/scripts/host-plugins/fetch-csr.mjs +1 -0
- package/scripts/host-plugins/fetch-git.mjs +78 -21
- package/scripts/host-plugins/fetch-git.test.mjs +62 -8
- package/scripts/host-plugins/fetch-k8s.mjs +263 -0
- package/scripts/host-plugins/fetch-k8s.options.json +50 -0
- package/scripts/host-plugins/fetch-k8s.test.mjs +259 -0
- package/scripts/host-plugins/k8s-topology.mjs +183 -0
- package/scripts/json-format.mjs +192 -0
- package/scripts/json-format.test.mjs +97 -0
- package/scripts/local-api.mjs +287 -12
- package/scripts/local-api.test.mjs +150 -5
- package/scripts/local-discovery.mjs +94 -9
- package/scripts/manifest.mjs +14 -3
- package/scripts/manifest.test.mjs +24 -0
- package/scripts/output-diff.mjs +94 -0
- package/scripts/output-diff.test.mjs +36 -0
- package/scripts/package-smoke.mjs +62 -4
- package/scripts/plugin-host.mjs +45 -3
- package/scripts/plugin-host.test.mjs +9 -0
- package/scripts/plugin-wasm-worker.mjs +4 -1
- package/scripts/plugins-fresh.mjs +97 -0
- package/scripts/plugins-fresh.test.mjs +64 -0
- package/scripts/provenance.mjs +72 -0
- package/scripts/provenance.test.mjs +149 -0
- package/scripts/run-builtin.mjs +62 -7
- package/scripts/schema.mjs +167 -19
- package/scripts/trace-trials.mjs +176 -0
- package/scripts/trace-trials.test.mjs +142 -0
- package/scripts/warning-policy.mjs +167 -0
- package/scripts/warning-policy.test.mjs +93 -0
- package/src/app/Breadcrumbs.test.ts +3 -0
- package/src/app/Breadcrumbs.tsx +3 -0
- package/src/app/CatalogApp.tsx +2 -0
- package/src/app/Sidebar.tsx +3 -3
- package/src/app/SidebarFooter.tsx +20 -4
- package/src/catalog-docs.test.ts +64 -0
- package/src/catalog-docs.ts +35 -0
- package/src/catalog-error.test.ts +15 -0
- package/src/catalog-index.ts +25 -0
- package/src/catalog-model.ts +293 -5
- package/src/catalog-validation.ts +113 -2
- package/src/catalog.test.ts +40 -1
- package/src/chat/Starter.tsx +5 -11
- package/src/chat/tools.test.ts +27 -0
- package/src/chat/tools.ts +5 -9
- package/src/components/CatalogStamp.tsx +10 -8
- package/src/components/ChannelRows.test.tsx +76 -1
- package/src/components/ChannelRows.tsx +223 -60
- package/src/components/DeploymentRows.tsx +131 -0
- package/src/components/DocsLinks.test.tsx +27 -0
- package/src/components/DocsLinks.tsx +56 -0
- package/src/components/FieldTree.tsx +5 -2
- package/src/components/HTTPDestinationEvidence.test.tsx +23 -0
- package/src/components/HTTPDestinationEvidence.tsx +31 -0
- package/src/components/Integrations.tsx +1 -1
- package/src/components/MachineDocs.tsx +6 -5
- package/src/components/MethodRows.tsx +9 -2
- package/src/components/PluginIcon.tsx +77 -0
- package/src/components/ProblemRow.tsx +173 -153
- package/src/components/RelationEvidence.test.tsx +14 -0
- package/src/components/RelationEvidence.tsx +53 -0
- package/src/components/RuleMarks.tsx +22 -0
- package/src/components/ShapeRows.tsx +24 -20
- package/src/data.ts +46 -7
- package/src/enrich.test.ts +459 -4
- package/src/enrich.ts +308 -7
- package/src/er/ErCanvas.tsx +218 -12
- package/src/er/GroupNode.tsx +57 -0
- package/src/er/StoreHeader.tsx +1 -0
- package/src/er/layout.test.ts +85 -2
- package/src/er/layout.ts +140 -5
- package/src/er/spec.test.ts +17 -0
- package/src/er/spec.ts +23 -10
- package/src/flow/Recordings.test.tsx +52 -0
- package/src/flow/Recordings.tsx +236 -0
- package/src/flow/StepDetail.tsx +59 -0
- package/src/flow/TraceTrial.tsx +419 -0
- package/src/flow/evidence.test.ts +16 -0
- package/src/flow/evidence.ts +34 -0
- package/src/flow/examples.test.ts +33 -0
- package/src/flow/examples.ts +37 -0
- package/src/flow/outline.test.ts +28 -0
- package/src/flow/outline.ts +7 -2
- package/src/flow/trace-trial-resume.test.ts +42 -0
- package/src/flow/trace-trial-resume.ts +74 -0
- package/src/graph/elk.ts +78 -0
- package/src/index.css +68 -0
- package/src/landing/DraggableReveal.tsx +3 -2
- package/src/landing/EvidencePipeline.tsx +105 -0
- package/src/landing/LandingPage.tsx +17 -70
- package/src/landing/ProductTour.tsx +6 -6
- package/src/lib/all-problems.ts +27 -17
- package/src/lib/catalog-diff.ts +1 -1
- package/src/lib/centrality.test.ts +251 -0
- package/src/lib/centrality.ts +232 -0
- package/src/lib/confluence.test.ts +37 -0
- package/src/lib/confluence.ts +41 -0
- package/src/lib/context-color.ts +1 -1
- package/src/lib/deployment-drift.ts +22 -0
- package/src/lib/derive.ts +43 -69
- package/src/lib/django-aggregates.d.mts +9 -0
- package/src/lib/django-aggregates.mjs +36 -0
- package/src/lib/django-aggregates.test.ts +29 -0
- package/src/lib/django-aggregates.ts +5 -0
- package/src/lib/environments.test.ts +83 -0
- package/src/lib/environments.ts +57 -0
- package/src/lib/integration-url.test.ts +30 -0
- package/src/lib/integration-url.ts +63 -0
- package/src/lib/kafka-ui.ts +3 -45
- package/src/lib/local-api.ts +116 -4
- package/src/lib/notion.ts +13 -0
- package/src/lib/plugin-index.json +2765 -0
- package/src/lib/plugins.test.ts +68 -0
- package/src/lib/plugins.ts +264 -0
- package/src/lib/problem-flows.test.ts +61 -0
- package/src/lib/problem-flows.ts +78 -0
- package/src/lib/problem-rules-cel.d.mts +43 -0
- package/src/lib/problem-rules-cel.mjs +407 -0
- package/src/lib/problem-rules.test.ts +287 -0
- package/src/lib/problem-rules.ts +271 -0
- package/src/lib/problem-subjects.ts +737 -0
- package/src/lib/rule-entries.ts +39 -0
- package/src/lib/{data-problems.test.ts → rules-data.test.ts} +18 -16
- package/src/lib/rules-deploy.test.ts +133 -0
- package/src/lib/{problems.test.ts → rules-edges.test.ts} +8 -3
- package/src/lib/{proto-problems.test.ts → rules-proto.test.ts} +9 -25
- package/src/lib/{wire-problems.test.ts → rules-wire.test.ts} +13 -11
- package/src/lib/rules.test.ts +51 -0
- package/src/lib/rules.ts +86 -0
- package/src/lib/setup-info.test.ts +17 -0
- package/src/lib/setup-info.ts +58 -0
- package/src/lib/shape.test.ts +32 -0
- package/src/lib/shape.ts +30 -6
- package/src/lib/tech.ts +16 -0
- package/src/lib/trace-project.test.ts +34 -0
- package/src/lib/trace-project.ts +50 -0
- package/src/lib/use-problems.ts +23 -0
- package/src/lib/warnings.test.ts +63 -0
- package/src/lib/warnings.ts +260 -0
- package/src/likec4/ids.test.ts +6 -2
- package/src/likec4/ids.ts +43 -0
- package/src/main.tsx +23 -0
- package/src/map/ContextMapGraph.tsx +76 -32
- package/src/merge-deployments.test.ts +127 -0
- package/src/merge.test.ts +82 -0
- package/src/merge.ts +189 -18
- package/src/pages/AggregatePage.tsx +65 -14
- package/src/pages/ContextMap.tsx +45 -3
- package/src/pages/ContextPage.tsx +8 -5
- package/src/pages/EventPage.tsx +15 -5
- package/src/pages/FlowDetail.tsx +23 -2
- package/src/pages/GraphPage.tsx +40 -3
- package/src/pages/Overview.tsx +152 -12
- package/src/pages/PluginIndex.tsx +190 -0
- package/src/pages/Problems.tsx +396 -128
- package/src/pages/ServicePage.tsx +62 -5
- package/src/pages/Settings.tsx +217 -43
- package/src/pages/settings/AboutSettings.tsx +8 -1
- package/src/pages/settings/DjangoAggregateChoices.tsx +79 -0
- package/src/pages/settings/IntegrationsSettings.tsx +63 -17
- package/src/pages/settings/RecordingSettings.tsx +138 -0
- package/src/pages/settings/RulesSettings.tsx +825 -0
- package/src/routes.test.ts +9 -0
- package/src/routes.ts +22 -1
- package/src/selection/DetailPanel.tsx +15 -0
- package/src/virtual-provenance.d.ts +11 -0
- package/vite.config.ts +5 -0
- package/scripts/vendor-lock.mjs +0 -58
- package/scripts/vendor-lock.test.mjs +0 -69
- package/src/lib/data-problems.ts +0 -314
- package/src/lib/proto-problems.ts +0 -237
- package/src/lib/wire-problems.ts +0 -342
package/src/er/ErCanvas.tsx
CHANGED
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
} from "@xyflow/react";
|
|
22
22
|
import type { Edge } from "@xyflow/react";
|
|
23
23
|
import "@xyflow/react/dist/style.css";
|
|
24
|
-
import { Columns3, Eye, FileCode2, ImageDown, Maximize2, Search, Waypoints } from "lucide-react";
|
|
24
|
+
import { Columns3, Eye, FileCode2, ImageDown, LayoutGrid, ListFilter, Maximize2, Search, Waypoints, Workflow } from "lucide-react";
|
|
25
25
|
import type { Store } from "../catalog";
|
|
26
26
|
import { storeViews } from "../catalog";
|
|
27
27
|
import { index } from "../data";
|
|
@@ -29,10 +29,14 @@ import { DiagramSkeleton } from "../components/DiagramSkeleton";
|
|
|
29
29
|
import { useSelectionStore } from "../selection/store";
|
|
30
30
|
import { TableNodeCard } from "./TableNode";
|
|
31
31
|
import { ViewNodeCard } from "./ViewNode";
|
|
32
|
+
import { GroupNodeCard } from "./GroupNode";
|
|
33
|
+
import type { ErGroupNode } from "./GroupNode";
|
|
32
34
|
import type { ErFlowNode } from "./RelationCard";
|
|
33
35
|
import { EDGE_W, EDGE_W_LIT } from "../graph/theme";
|
|
34
36
|
import { ErMarkers, MARKER_FLOW, MARKER_MANY, MARKER_ONE } from "./markers";
|
|
35
|
-
import { layoutEr } from "./layout";
|
|
37
|
+
import { canGroup, groupsOf, hideGroups, layoutEr } from "./layout";
|
|
38
|
+
import type { ErGroupFrame, ErGrouping } from "./layout";
|
|
39
|
+
import { CTX_SLOTS } from "../lib/context-color";
|
|
36
40
|
import { lineageChain } from "./lineage";
|
|
37
41
|
import type { LineageMaps } from "./lineage";
|
|
38
42
|
import { erSpec, matchingNodes } from "./spec";
|
|
@@ -42,10 +46,16 @@ import { saveCanvasImage, viewportOf } from "../lib/export-canvas";
|
|
|
42
46
|
import type { ImageKind } from "../lib/export-canvas";
|
|
43
47
|
|
|
44
48
|
/** Stable across renders: React Flow re-mounts every node when this changes. */
|
|
45
|
-
const erNodeTypes = { erTable: TableNodeCard, erView: ViewNodeCard };
|
|
49
|
+
const erNodeTypes = { erTable: TableNodeCard, erView: ViewNodeCard, erGroup: GroupNodeCard };
|
|
50
|
+
|
|
51
|
+
/** One flow, roots first; or the cards by model group, the groups packed. */
|
|
52
|
+
type Arrangement = "flow" | "groups";
|
|
46
53
|
|
|
47
54
|
const DIM = 0.25;
|
|
48
55
|
|
|
56
|
+
/** More hits than this is most of a schema; framing them is framing everything. */
|
|
57
|
+
const SEARCH_FRAME_LIMIT = 30;
|
|
58
|
+
|
|
49
59
|
/** The catalog's lineage graph, walked on hover. Built once, with the index. */
|
|
50
60
|
const LINEAGE: LineageMaps = {
|
|
51
61
|
from: index.lineageFrom,
|
|
@@ -90,14 +100,18 @@ function Canvas({
|
|
|
90
100
|
const [hoverEdge, setHoverEdge] = useState<string | null>(null);
|
|
91
101
|
const [layout, setLayout] = useState<{
|
|
92
102
|
positions: Record<string, { x: number; y: number }>;
|
|
103
|
+
groups: ErGroupFrame[];
|
|
93
104
|
ready: boolean;
|
|
94
|
-
}>({ positions: {}, ready: false });
|
|
105
|
+
}>({ positions: {}, groups: [], ready: false });
|
|
106
|
+
// Null until the reader chooses: a schema big enough is grouped, the rest
|
|
107
|
+
// flow, and the toggle only appears where the choice exists.
|
|
108
|
+
const [chosen, setChosen] = useState<Arrangement | null>(null);
|
|
95
109
|
|
|
96
110
|
const select = useSelectionStore((s) => s.select);
|
|
97
111
|
const clear = useSelectionStore((s) => s.clear);
|
|
98
112
|
const selectionId = useSelectionStore((s) => s.selection?.id ?? null);
|
|
99
113
|
|
|
100
|
-
const
|
|
114
|
+
const full: ErSpec = useMemo(
|
|
101
115
|
() =>
|
|
102
116
|
erSpec(index, store, {
|
|
103
117
|
mode,
|
|
@@ -108,19 +122,53 @@ function Canvas({
|
|
|
108
122
|
}),
|
|
109
123
|
[store, mode, expanded, ghost, showViews, showLineage],
|
|
110
124
|
);
|
|
125
|
+
// Groups the reader switched off. Everything below reads the spec with
|
|
126
|
+
// them gone, so a hidden group is hidden from the search and the layout
|
|
127
|
+
// too, not only from the eye.
|
|
128
|
+
const [hidden, setHidden] = useState<ReadonlySet<string>>(new Set());
|
|
129
|
+
const spec: ErSpec = useMemo(() => hideGroups(full, hidden), [full, hidden]);
|
|
130
|
+
const nameOf = useCallback(
|
|
131
|
+
(aggregate: string) => index.aggregateById.get(aggregate)?.name ?? (aggregate.split(".").at(-1) ?? aggregate),
|
|
132
|
+
[],
|
|
133
|
+
);
|
|
134
|
+
const groupList = useMemo(() => groupsOf(full.nodes, nameOf), [full, nameOf]);
|
|
135
|
+
const onHide = useCallback((id: string, hide: boolean) => {
|
|
136
|
+
setHidden((prev) => {
|
|
137
|
+
const next = new Set(prev);
|
|
138
|
+
if (hide) next.add(id);
|
|
139
|
+
else next.delete(id);
|
|
140
|
+
return next;
|
|
141
|
+
});
|
|
142
|
+
}, []);
|
|
111
143
|
|
|
112
144
|
const matched = useMemo(() => matchingNodes(spec, term), [spec, term]);
|
|
145
|
+
const matchedIds = useMemo(() => [...matched], [matched]);
|
|
146
|
+
// Which match the reader is standing on: -1 is "all of them", what typing
|
|
147
|
+
// gives; Enter steps through them one at a time.
|
|
148
|
+
const [cursor, setCursor] = useState(-1);
|
|
149
|
+
useEffect(() => setCursor(-1), [term]);
|
|
150
|
+
|
|
151
|
+
const groupable = useMemo(() => canGroup(full), [full]);
|
|
152
|
+
const arrangement: Arrangement = groupable ? (chosen ?? "groups") : "flow";
|
|
113
153
|
|
|
114
154
|
useEffect(() => {
|
|
115
155
|
let cancelled = false;
|
|
116
156
|
setLayout((prev) => ({ ...prev, ready: false }));
|
|
117
|
-
|
|
118
|
-
|
|
157
|
+
// The packing aims at the box the picture is drawn in; a box not yet on
|
|
158
|
+
// screen has no shape, and 2:1 is what the service page's canvas is.
|
|
159
|
+
const box = wrapper.current;
|
|
160
|
+
const aspectRatio = box && box.clientWidth > 0 && box.clientHeight > 0 ? box.clientWidth / box.clientHeight : 2;
|
|
161
|
+
void layoutEr(spec, {
|
|
162
|
+
grouped: arrangement === "groups",
|
|
163
|
+
aspectRatio,
|
|
164
|
+
nameOf,
|
|
165
|
+
}).then((result) => {
|
|
166
|
+
if (!cancelled) setLayout({ positions: result.positions, groups: result.groups, ready: true });
|
|
119
167
|
});
|
|
120
168
|
return () => {
|
|
121
169
|
cancelled = true;
|
|
122
170
|
};
|
|
123
|
-
}, [spec]);
|
|
171
|
+
}, [spec, arrangement, nameOf]);
|
|
124
172
|
|
|
125
173
|
// Which columns need an anchor, per table. A column with no relationship
|
|
126
174
|
// gets no handle: React Flow measures every handle it is given, and a wide
|
|
@@ -198,6 +246,34 @@ function Canvas({
|
|
|
198
246
|
return () => observer.disconnect();
|
|
199
247
|
}, [flow, layout.ready]);
|
|
200
248
|
|
|
249
|
+
// A search moves the camera, not only the lights. One hit is brought up
|
|
250
|
+
// close; a handful are framed together; more than that is most of the
|
|
251
|
+
// schema, so the view stays and the dimming does the work. Clearing the box
|
|
252
|
+
// fits everything again, so the reader is not left zoomed into a corner.
|
|
253
|
+
const hadTerm = useRef(false);
|
|
254
|
+
useEffect(() => {
|
|
255
|
+
if (!layout.ready) return;
|
|
256
|
+
if (term.trim() === "") {
|
|
257
|
+
if (hadTerm.current) flow.fitView({ padding: 0.1, duration: 250 });
|
|
258
|
+
hadTerm.current = false;
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
hadTerm.current = true;
|
|
262
|
+
const target = cursor >= 0 ? matchedIds.slice(cursor, cursor + 1) : matchedIds;
|
|
263
|
+
if (target.length === 0 || target.length > SEARCH_FRAME_LIMIT) return;
|
|
264
|
+
void flow.fitView({
|
|
265
|
+
nodes: target.map((id) => ({ id })),
|
|
266
|
+
padding: target.length === 1 ? 0.35 : 0.2,
|
|
267
|
+
maxZoom: 1.25,
|
|
268
|
+
duration: 250,
|
|
269
|
+
});
|
|
270
|
+
}, [flow, layout.ready, term, matchedIds, cursor]);
|
|
271
|
+
|
|
272
|
+
const onJump = useCallback(() => {
|
|
273
|
+
if (matchedIds.length === 0) return;
|
|
274
|
+
setCursor((prev) => (prev + 1) % matchedIds.length);
|
|
275
|
+
}, [matchedIds]);
|
|
276
|
+
|
|
201
277
|
const onToggle = useCallback((tableId: string) => {
|
|
202
278
|
setExpanded((prev) => {
|
|
203
279
|
const next = new Set(prev);
|
|
@@ -219,7 +295,35 @@ function Canvas({
|
|
|
219
295
|
: null;
|
|
220
296
|
}, [selectionId]);
|
|
221
297
|
|
|
222
|
-
|
|
298
|
+
// The frames go first so that they are drawn under the cards; nothing
|
|
299
|
+
// about them is interactive but the label, so a click on one reaches the
|
|
300
|
+
// pane and clears the selection like empty canvas would.
|
|
301
|
+
const frames: ErGroupNode[] = useMemo(
|
|
302
|
+
() =>
|
|
303
|
+
layout.groups.map((group) => ({
|
|
304
|
+
id: `group:${group.id}`,
|
|
305
|
+
type: "erGroup" as const,
|
|
306
|
+
position: { x: group.x, y: group.y },
|
|
307
|
+
width: group.width,
|
|
308
|
+
height: group.height,
|
|
309
|
+
draggable: false,
|
|
310
|
+
selectable: false,
|
|
311
|
+
connectable: false,
|
|
312
|
+
focusable: false,
|
|
313
|
+
zIndex: -1,
|
|
314
|
+
data: {
|
|
315
|
+
name: group.name,
|
|
316
|
+
aggregate: group.aggregate,
|
|
317
|
+
count: group.count,
|
|
318
|
+
// By the group's place in the full list, so that hiding a
|
|
319
|
+
// neighbour does not recolour the rest.
|
|
320
|
+
tint: `var(--ctx-${Math.max(0, groupList.findIndex((g) => g.id === group.id)) % CTX_SLOTS})`,
|
|
321
|
+
},
|
|
322
|
+
})),
|
|
323
|
+
[layout.groups, groupList],
|
|
324
|
+
);
|
|
325
|
+
|
|
326
|
+
const cards: ErFlowNode[] = useMemo(
|
|
223
327
|
() =>
|
|
224
328
|
spec.nodes.map((node) => ({
|
|
225
329
|
id: node.id,
|
|
@@ -256,6 +360,7 @@ function Canvas({
|
|
|
256
360
|
onColumnClick,
|
|
257
361
|
],
|
|
258
362
|
);
|
|
363
|
+
const nodes: (ErFlowNode | ErGroupNode)[] = useMemo(() => [...frames, ...cards], [frames, cards]);
|
|
259
364
|
|
|
260
365
|
const edges: Edge[] = useMemo(
|
|
261
366
|
() =>
|
|
@@ -315,7 +420,7 @@ function Canvas({
|
|
|
315
420
|
[spec, layout.positions, litEdges],
|
|
316
421
|
);
|
|
317
422
|
|
|
318
|
-
const fitKey = layout.ready ? `fit-${nodes.length}-${mode}` : "pending";
|
|
423
|
+
const fitKey = layout.ready ? `fit-${nodes.length}-${mode}-${arrangement}` : "pending";
|
|
319
424
|
const views = storeViews(store).length;
|
|
320
425
|
|
|
321
426
|
return (
|
|
@@ -334,6 +439,13 @@ function Canvas({
|
|
|
334
439
|
mode={mode}
|
|
335
440
|
onMode={setMode}
|
|
336
441
|
hits={matched.size}
|
|
442
|
+
cursor={cursor}
|
|
443
|
+
onJump={onJump}
|
|
444
|
+
arrangement={groupable ? arrangement : null}
|
|
445
|
+
onArrangement={setChosen}
|
|
446
|
+
groups={groupable ? groupList : []}
|
|
447
|
+
hidden={hidden}
|
|
448
|
+
onHide={onHide}
|
|
337
449
|
views={views}
|
|
338
450
|
showViews={showViews}
|
|
339
451
|
onShowViews={setShowViews}
|
|
@@ -348,7 +460,9 @@ function Canvas({
|
|
|
348
460
|
nodes={nodes}
|
|
349
461
|
edges={edges}
|
|
350
462
|
nodeTypes={erNodeTypes}
|
|
351
|
-
onNodeClick={(_, node) =>
|
|
463
|
+
onNodeClick={(_, node) => {
|
|
464
|
+
if (node.type !== "erGroup") select(node.id, "diagram");
|
|
465
|
+
}}
|
|
352
466
|
onPaneClick={() => clear("diagram")}
|
|
353
467
|
onEdgeMouseEnter={(_, edge) => setHoverEdge(edge.id)}
|
|
354
468
|
onEdgeMouseLeave={() => setHoverEdge(null)}
|
|
@@ -377,6 +491,13 @@ function Toolbar({
|
|
|
377
491
|
mode,
|
|
378
492
|
onMode,
|
|
379
493
|
hits,
|
|
494
|
+
cursor,
|
|
495
|
+
onJump,
|
|
496
|
+
arrangement,
|
|
497
|
+
onArrangement,
|
|
498
|
+
groups,
|
|
499
|
+
hidden,
|
|
500
|
+
onHide,
|
|
380
501
|
views,
|
|
381
502
|
showViews,
|
|
382
503
|
onShowViews,
|
|
@@ -390,6 +511,16 @@ function Toolbar({
|
|
|
390
511
|
mode: ColumnMode;
|
|
391
512
|
onMode: (value: ColumnMode) => void;
|
|
392
513
|
hits: number;
|
|
514
|
+
/** The hit the reader stepped to with Enter, -1 for none yet. */
|
|
515
|
+
cursor: number;
|
|
516
|
+
onJump: () => void;
|
|
517
|
+
/** How the cards are laid out; null when the schema is too small for the choice to matter. */
|
|
518
|
+
arrangement: Arrangement | null;
|
|
519
|
+
onArrangement: (value: Arrangement) => void;
|
|
520
|
+
/** The model groups a reader can switch off; empty when the schema is too small to bother. */
|
|
521
|
+
groups: ErGrouping[];
|
|
522
|
+
hidden: ReadonlySet<string>;
|
|
523
|
+
onHide: (id: string, hide: boolean) => void;
|
|
393
524
|
/** How many views this store has; with none, the toggle is not a choice. */
|
|
394
525
|
views: number;
|
|
395
526
|
showViews: boolean;
|
|
@@ -428,13 +559,27 @@ function Toolbar({
|
|
|
428
559
|
<input
|
|
429
560
|
value={term}
|
|
430
561
|
onChange={(e) => onTerm(e.target.value)}
|
|
562
|
+
onKeyDown={(e) => {
|
|
563
|
+
// Enter walks the hits, best first; Escape lets go of the search
|
|
564
|
+
// and the camera with it.
|
|
565
|
+
if (e.key === "Enter") {
|
|
566
|
+
e.preventDefault();
|
|
567
|
+
onJump();
|
|
568
|
+
} else if (e.key === "Escape") {
|
|
569
|
+
e.preventDefault();
|
|
570
|
+
onTerm("");
|
|
571
|
+
}
|
|
572
|
+
}}
|
|
431
573
|
placeholder="find a table or column"
|
|
432
574
|
spellCheck={false}
|
|
433
575
|
aria-label="Find a table or column"
|
|
576
|
+
title="Enter steps through the hits, Escape clears"
|
|
434
577
|
className="mono w-44 bg-transparent outline-none placeholder:text-muted"
|
|
435
578
|
/>
|
|
436
579
|
{term ? (
|
|
437
|
-
<span className="mono tnum shrink-0 text-muted">
|
|
580
|
+
<span className="mono tnum shrink-0 text-muted" aria-live="polite">
|
|
581
|
+
{cursor >= 0 ? `${cursor + 1}/${hits}` : hits}
|
|
582
|
+
</span>
|
|
438
583
|
) : null}
|
|
439
584
|
</label>
|
|
440
585
|
|
|
@@ -459,6 +604,67 @@ function Toolbar({
|
|
|
459
604
|
</button>
|
|
460
605
|
</div>
|
|
461
606
|
|
|
607
|
+
{/* Only a schema big enough to come out as a column offers the choice;
|
|
608
|
+
on a small one the flow is the picture and a frame would be noise. */}
|
|
609
|
+
{arrangement ? (
|
|
610
|
+
<div className="seg bg-canvas" role="group" aria-label="Arrangement">
|
|
611
|
+
<button
|
|
612
|
+
type="button"
|
|
613
|
+
onClick={() => onArrangement("flow")}
|
|
614
|
+
aria-pressed={arrangement === "flow"}
|
|
615
|
+
className={arrangement === "flow" ? "is-on" : ""}
|
|
616
|
+
title="One flow, roots on the left"
|
|
617
|
+
>
|
|
618
|
+
<Workflow size={11} aria-hidden className="inline" /> flow
|
|
619
|
+
</button>
|
|
620
|
+
<button
|
|
621
|
+
type="button"
|
|
622
|
+
onClick={() => onArrangement("groups")}
|
|
623
|
+
aria-pressed={arrangement === "groups"}
|
|
624
|
+
className={arrangement === "groups" ? "is-on" : ""}
|
|
625
|
+
title="Tables by the model group they persist, groups packed to the canvas"
|
|
626
|
+
>
|
|
627
|
+
<LayoutGrid size={11} aria-hidden className="inline" /> groups
|
|
628
|
+
</button>
|
|
629
|
+
</div>
|
|
630
|
+
) : null}
|
|
631
|
+
|
|
632
|
+
{/* Which groups are on the canvas. A big schema is read one module at a
|
|
633
|
+
time, and a module is easier to read with its neighbours gone. */}
|
|
634
|
+
{groups.length > 0 ? (
|
|
635
|
+
<details className="relative">
|
|
636
|
+
<summary
|
|
637
|
+
className="seg mono cursor-pointer list-none bg-canvas px-2 py-1 text-muted hover:bg-surface"
|
|
638
|
+
title="Show or hide model groups"
|
|
639
|
+
>
|
|
640
|
+
<ListFilter size={11} aria-hidden className="inline" /> {groups.length - hidden.size}/{groups.length}
|
|
641
|
+
</summary>
|
|
642
|
+
<div className="absolute left-0 top-full z-20 mt-1 max-h-72 w-64 overflow-y-auto rounded-card border border-line bg-surface p-1 shadow-card">
|
|
643
|
+
<div className="flex gap-2 px-2 py-1">
|
|
644
|
+
<button type="button" className="mono text-accent hover:underline" onClick={() => groups.forEach((g) => onHide(g.id, false))}>
|
|
645
|
+
all
|
|
646
|
+
</button>
|
|
647
|
+
<button type="button" className="mono text-accent hover:underline" onClick={() => groups.forEach((g) => onHide(g.id, true))}>
|
|
648
|
+
none
|
|
649
|
+
</button>
|
|
650
|
+
</div>
|
|
651
|
+
{groups.map((group, i) => (
|
|
652
|
+
<label key={group.id} className="flex cursor-pointer items-center gap-2 rounded-control px-2 py-1 hover:bg-canvas">
|
|
653
|
+
<input
|
|
654
|
+
type="checkbox"
|
|
655
|
+
checked={!hidden.has(group.id)}
|
|
656
|
+
onChange={(e) => onHide(group.id, !e.target.checked)}
|
|
657
|
+
aria-label={`Show ${group.name}`}
|
|
658
|
+
/>
|
|
659
|
+
<span className="dot" style={{ color: `var(--ctx-${i % CTX_SLOTS})` }} />
|
|
660
|
+
<span className="mono min-w-0 flex-1 truncate">{group.name}</span>
|
|
661
|
+
<span className="mono tnum text-muted">{group.nodes.length}</span>
|
|
662
|
+
</label>
|
|
663
|
+
))}
|
|
664
|
+
</div>
|
|
665
|
+
</details>
|
|
666
|
+
) : null}
|
|
667
|
+
|
|
462
668
|
{/* Two toggles rather than one: a reader who wants the tables alone and a
|
|
463
669
|
reader who wants the views without the web of lines that joins them
|
|
464
670
|
are asking different questions, and answering both with one switch
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// The frame around one model group's tables, when a schema is laid out by
|
|
2
|
+
// group. It is a background, not a card: it takes no clicks, so the pane
|
|
3
|
+
// behind it still clears the selection, and the cards inside it are drawn on
|
|
4
|
+
// top. Only its label is live, and leads to the group's own page.
|
|
5
|
+
//
|
|
6
|
+
// Each frame has a tint of its own, cycled through the same six hues the
|
|
7
|
+
// contexts use, so that two neighbouring groups read as two at a glance and
|
|
8
|
+
// the label is not the only thing telling them apart.
|
|
9
|
+
|
|
10
|
+
import type { Node, NodeProps } from "@xyflow/react";
|
|
11
|
+
import { Link } from "react-router";
|
|
12
|
+
import { aggregatePath } from "../routes";
|
|
13
|
+
|
|
14
|
+
export interface GroupNodeData extends Record<string, unknown> {
|
|
15
|
+
name: string;
|
|
16
|
+
aggregate: string | null;
|
|
17
|
+
count: number;
|
|
18
|
+
/** A CSS colour the frame is washed with. */
|
|
19
|
+
tint: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type ErGroupNode = Node<GroupNodeData, "erGroup">;
|
|
23
|
+
|
|
24
|
+
export function GroupNodeCard({ data, width, height }: NodeProps<ErGroupNode>) {
|
|
25
|
+
const to = data.aggregate ? aggregatePath(data.aggregate) : null;
|
|
26
|
+
const label = `${data.name} · ${data.count}`;
|
|
27
|
+
return (
|
|
28
|
+
<div
|
|
29
|
+
className="rounded-card border"
|
|
30
|
+
style={{
|
|
31
|
+
width,
|
|
32
|
+
height,
|
|
33
|
+
background: `color-mix(in srgb, ${data.tint} 9%, transparent)`,
|
|
34
|
+
borderColor: `color-mix(in srgb, ${data.tint} 45%, transparent)`,
|
|
35
|
+
pointerEvents: "none",
|
|
36
|
+
}}
|
|
37
|
+
data-er-group={data.aggregate ?? "other"}
|
|
38
|
+
>
|
|
39
|
+
<div
|
|
40
|
+
className="mono inline-block rounded-br-card px-2 py-0.5"
|
|
41
|
+
style={{
|
|
42
|
+
pointerEvents: "auto",
|
|
43
|
+
color: data.tint,
|
|
44
|
+
background: `color-mix(in srgb, ${data.tint} 14%, transparent)`,
|
|
45
|
+
}}
|
|
46
|
+
>
|
|
47
|
+
{to ? (
|
|
48
|
+
<Link to={to} className="hover:underline" title={`Open ${data.name}`}>
|
|
49
|
+
{label}
|
|
50
|
+
</Link>
|
|
51
|
+
) : (
|
|
52
|
+
<span title="tables that persist no model">{label}</span>
|
|
53
|
+
)}
|
|
54
|
+
</div>
|
|
55
|
+
</div>
|
|
56
|
+
);
|
|
57
|
+
}
|
package/src/er/StoreHeader.tsx
CHANGED
package/src/er/layout.test.ts
CHANGED
|
@@ -8,7 +8,8 @@ import { describe, expect, it } from "vitest";
|
|
|
8
8
|
import { buildIndex, validateCatalog } from "../catalog";
|
|
9
9
|
import { pathologicalCatalog, wideCatalog } from "../lib/scenarios";
|
|
10
10
|
import { erSpec } from "./spec";
|
|
11
|
-
import {
|
|
11
|
+
import type { ErNode, ErSpec } from "./spec";
|
|
12
|
+
import { canGroup, groupsOf, hideGroups, layoutEr, layoutInput } from "./layout";
|
|
12
13
|
|
|
13
14
|
describe("layoutInput", () => {
|
|
14
15
|
const bad = pathologicalCatalog();
|
|
@@ -82,7 +83,89 @@ describe("layoutEr", () => {
|
|
|
82
83
|
|
|
83
84
|
it("answers for an empty store without asking elk anything", async () => {
|
|
84
85
|
const result = await layoutEr({ nodes: [], edges: [] });
|
|
85
|
-
expect(result).toEqual({ positions: {}, width: 0, height: 0 });
|
|
86
|
+
expect(result).toEqual({ positions: {}, width: 0, height: 0, groups: [] });
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("grouping a big schema", () => {
|
|
91
|
+
/** A card with only what grouping and packing read off it. */
|
|
92
|
+
const card = (id: string, aggregate: string | null): ErNode =>
|
|
93
|
+
({ id, aggregate, width: 208, height: 120, name: id.split(".").at(-1) }) as unknown as ErNode;
|
|
94
|
+
/** `count` tables over `aggregates` groups, plus `loose` that persist nothing. */
|
|
95
|
+
const schema = (count: number, aggregates: string[], loose = 0): ErSpec => {
|
|
96
|
+
const nodes: ErNode[] = [];
|
|
97
|
+
for (let i = 0; i < count; i += 1) {
|
|
98
|
+
nodes.push(card(`s.t${i}`, aggregates[i % aggregates.length] ?? null));
|
|
99
|
+
}
|
|
100
|
+
for (let i = 0; i < loose; i += 1) nodes.push(card(`s.l${i}`, null));
|
|
101
|
+
return { nodes, edges: [] };
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
it("groups the cards by the model they persist, biggest first, the loose ones last", () => {
|
|
105
|
+
const spec = schema(7, ["a.big", "a.small", "a.big"], 2);
|
|
106
|
+
const groups = groupsOf(spec.nodes, (a) => a.split(".").at(-1)?.toUpperCase() ?? a);
|
|
107
|
+
expect(groups.map((g) => `${g.name}:${g.nodes.length}`)).toEqual(["BIG:5", "SMALL:2", "other:2"]);
|
|
108
|
+
expect(groups[2]?.aggregate).toBeNull();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("offers grouping only past the size where one flow becomes a column", () => {
|
|
112
|
+
expect(canGroup(schema(39, ["a", "b", "c"]))).toBe(false);
|
|
113
|
+
expect(canGroup(schema(60, ["a", "b"]))).toBe(false);
|
|
114
|
+
expect(canGroup(schema(60, ["a", "b", "c"]))).toBe(true);
|
|
115
|
+
// Loose tables count as size but not as a group.
|
|
116
|
+
expect(canGroup(schema(10, ["a", "b", "c"], 40))).toBe(true);
|
|
117
|
+
expect(canGroup(schema(10, ["a", "b"], 40))).toBe(false);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("packs the groups and keeps every card inside its own frame", async () => {
|
|
121
|
+
const spec = schema(48, ["a.x", "a.y", "a.z"], 6);
|
|
122
|
+
// A key inside a group and one across, which the packing ignores.
|
|
123
|
+
spec.edges.push(
|
|
124
|
+
{ id: "in", kind: "fk", from: "s.t3", to: "s.t0", fromColumn: "x_id", toColumn: "id", onDelete: null } as unknown as ErSpec["edges"][number],
|
|
125
|
+
{ id: "across", kind: "fk", from: "s.t1", to: "s.t0", fromColumn: "x_id", toColumn: "id", onDelete: null } as unknown as ErSpec["edges"][number],
|
|
126
|
+
);
|
|
127
|
+
const laid = await layoutEr(spec, { grouped: true, aspectRatio: 2 });
|
|
128
|
+
expect(laid.groups.map((g) => g.id)).toEqual(["a.x", "a.y", "a.z", "other"]);
|
|
129
|
+
for (const node of spec.nodes) {
|
|
130
|
+
const at = laid.positions[node.id];
|
|
131
|
+
expect(at).toBeDefined();
|
|
132
|
+
const frame = laid.groups.find((g) => g.id === (node.aggregate ?? "other"));
|
|
133
|
+
expect(frame).toBeDefined();
|
|
134
|
+
if (!at || !frame) continue;
|
|
135
|
+
expect(at.x).toBeGreaterThanOrEqual(frame.x);
|
|
136
|
+
expect(at.y).toBeGreaterThanOrEqual(frame.y);
|
|
137
|
+
expect(at.x + node.width).toBeLessThanOrEqual(frame.x + frame.width + 0.5);
|
|
138
|
+
expect(at.y + node.height).toBeLessThanOrEqual(frame.y + frame.height + 0.5);
|
|
139
|
+
}
|
|
140
|
+
// Frames do not overlap.
|
|
141
|
+
for (const a of laid.groups) {
|
|
142
|
+
for (const b of laid.groups) {
|
|
143
|
+
if (a.id === b.id) continue;
|
|
144
|
+
const apart = a.x + a.width <= b.x || b.x + b.width <= a.x || a.y + a.height <= b.y || b.y + b.height <= a.y;
|
|
145
|
+
expect(apart).toBe(true);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
// Packed near the box's shape rather than as a column.
|
|
149
|
+
expect(laid.width / laid.height).toBeGreaterThan(1);
|
|
150
|
+
// The key inside a group put its parent left of its child.
|
|
151
|
+
expect(laid.positions["s.t0"]?.x ?? 0).toBeLessThan(laid.positions["s.t3"]?.x ?? 0);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("hides a group with every edge that touched it, and leaves the rest as it was", () => {
|
|
155
|
+
const spec = schema(6, ["a.x", "a.y"], 2);
|
|
156
|
+
spec.edges.push(
|
|
157
|
+
{ id: "in-x", kind: "fk", from: "s.t2", to: "s.t0", fromColumn: "x_id", toColumn: "id", onDelete: null } as unknown as ErSpec["edges"][number],
|
|
158
|
+
{ id: "x-y", kind: "fk", from: "s.t1", to: "s.t0", fromColumn: "x_id", toColumn: "id", onDelete: null } as unknown as ErSpec["edges"][number],
|
|
159
|
+
);
|
|
160
|
+
expect(hideGroups(spec, new Set())).toBe(spec);
|
|
161
|
+
const without = hideGroups(spec, new Set(["a.y", "other"]));
|
|
162
|
+
expect(without.nodes.map((n) => n.id)).toEqual(["s.t0", "s.t2", "s.t4"]);
|
|
163
|
+
expect(without.edges.map((e) => e.id)).toEqual(["in-x"]);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("stays one flow unless asked", async () => {
|
|
167
|
+
const laid = await layoutEr(schema(45, ["a", "b", "c"]));
|
|
168
|
+
expect(laid.groups).toEqual([]);
|
|
86
169
|
});
|
|
87
170
|
});
|
|
88
171
|
|