@greensecurity/javascript-sdk 0.46.0-beta.10 → 0.46.0-beta.12
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/dist/commonjs/__tests__/apps/contract.test.js +288 -4
- package/dist/commonjs/__tests__/apps/contract.test.js.map +1 -1
- package/dist/commonjs/__tests__/apps/devpreview-routes.test.d.ts +2 -0
- package/dist/commonjs/__tests__/apps/devpreview-routes.test.d.ts.map +1 -0
- package/dist/commonjs/__tests__/apps/devpreview-routes.test.js +86 -0
- package/dist/commonjs/__tests__/apps/devpreview-routes.test.js.map +1 -0
- package/dist/commonjs/__tests__/apps/kit/example.test.js +84 -4
- package/dist/commonjs/__tests__/apps/kit/example.test.js.map +1 -1
- package/dist/commonjs/__tests__/apps/kit/pages.test.d.ts +2 -0
- package/dist/commonjs/__tests__/apps/kit/pages.test.d.ts.map +1 -0
- package/dist/commonjs/__tests__/apps/kit/pages.test.js +119 -0
- package/dist/commonjs/__tests__/apps/kit/pages.test.js.map +1 -0
- package/dist/commonjs/__tests__/apps/kit/render.test.js +24 -0
- package/dist/commonjs/__tests__/apps/kit/render.test.js.map +1 -1
- package/dist/commonjs/__tests__/apps/kit/store.test.d.ts +2 -0
- package/dist/commonjs/__tests__/apps/kit/store.test.d.ts.map +1 -0
- package/dist/commonjs/__tests__/apps/kit/store.test.js +123 -0
- package/dist/commonjs/__tests__/apps/kit/store.test.js.map +1 -0
- package/dist/commonjs/__tests__/apps/post-install-page.test.d.ts +2 -0
- package/dist/commonjs/__tests__/apps/post-install-page.test.d.ts.map +1 -0
- package/dist/commonjs/__tests__/apps/post-install-page.test.js +110 -0
- package/dist/commonjs/__tests__/apps/post-install-page.test.js.map +1 -0
- package/dist/commonjs/__tests__/apps/routes.test.d.ts +2 -0
- package/dist/commonjs/__tests__/apps/routes.test.d.ts.map +1 -0
- package/dist/commonjs/__tests__/apps/routes.test.js +238 -0
- package/dist/commonjs/__tests__/apps/routes.test.js.map +1 -0
- package/dist/commonjs/__tests__/vendors.test.js +1 -1
- package/dist/commonjs/__tests__/webhooks.test.js +1 -1
- package/dist/commonjs/__tests__/webhooks.test.js.map +1 -1
- package/dist/commonjs/__tests__/zones.test.js +22 -4
- package/dist/commonjs/__tests__/zones.test.js.map +1 -1
- package/dist/commonjs/apps/components.d.ts +321 -0
- package/dist/commonjs/apps/components.d.ts.map +1 -1
- package/dist/commonjs/apps/components.js +168 -2
- package/dist/commonjs/apps/components.js.map +1 -1
- package/dist/commonjs/apps/context.d.ts +24 -0
- package/dist/commonjs/apps/context.d.ts.map +1 -1
- package/dist/commonjs/apps/context.js.map +1 -1
- package/dist/commonjs/apps/devpreview.d.ts +20 -3
- package/dist/commonjs/apps/devpreview.d.ts.map +1 -1
- package/dist/commonjs/apps/devpreview.js +18 -7
- package/dist/commonjs/apps/devpreview.js.map +1 -1
- package/dist/commonjs/apps/hooks/index.d.ts +33 -2
- package/dist/commonjs/apps/hooks/index.d.ts.map +1 -1
- package/dist/commonjs/apps/hooks/index.js +41 -1
- package/dist/commonjs/apps/hooks/index.js.map +1 -1
- package/dist/commonjs/apps/hooks/store.d.ts +48 -0
- package/dist/commonjs/apps/hooks/store.d.ts.map +1 -0
- package/dist/commonjs/apps/hooks/store.js +87 -0
- package/dist/commonjs/apps/hooks/store.js.map +1 -0
- package/dist/commonjs/apps/manifest.d.ts +64 -3
- package/dist/commonjs/apps/manifest.d.ts.map +1 -1
- package/dist/commonjs/apps/manifest.js +128 -27
- package/dist/commonjs/apps/manifest.js.map +1 -1
- package/dist/commonjs/apps/protocol.d.ts +1 -1
- package/dist/commonjs/apps/protocol.d.ts.map +1 -1
- package/dist/commonjs/apps/route.d.ts +11 -1
- package/dist/commonjs/apps/route.d.ts.map +1 -1
- package/dist/commonjs/apps/route.js +13 -7
- package/dist/commonjs/apps/route.js.map +1 -1
- package/dist/commonjs/apps/routes.d.ts +65 -0
- package/dist/commonjs/apps/routes.d.ts.map +1 -0
- package/dist/commonjs/apps/routes.js +202 -0
- package/dist/commonjs/apps/routes.js.map +1 -0
- package/dist/commonjs/apps/surfaces.d.ts +20 -2
- package/dist/commonjs/apps/surfaces.d.ts.map +1 -1
- package/dist/commonjs/apps/surfaces.js +45 -7
- package/dist/commonjs/apps/surfaces.js.map +1 -1
- package/dist/commonjs/apps/testing/index.d.ts +17 -1
- package/dist/commonjs/apps/testing/index.d.ts.map +1 -1
- package/dist/commonjs/apps/testing/index.js +50 -1
- package/dist/commonjs/apps/testing/index.js.map +1 -1
- package/dist/commonjs/apps/ui/index.d.ts +13 -0
- package/dist/commonjs/apps/ui/index.d.ts.map +1 -1
- package/dist/commonjs/apps/ui/index.js +19 -2
- package/dist/commonjs/apps/ui/index.js.map +1 -1
- package/dist/commonjs/lib/config.d.ts +2 -2
- package/dist/commonjs/lib/config.js +2 -2
- package/dist/esm/__tests__/apps/contract.test.js +289 -5
- package/dist/esm/__tests__/apps/contract.test.js.map +1 -1
- package/dist/esm/__tests__/apps/devpreview-routes.test.d.ts +2 -0
- package/dist/esm/__tests__/apps/devpreview-routes.test.d.ts.map +1 -0
- package/dist/esm/__tests__/apps/devpreview-routes.test.js +84 -0
- package/dist/esm/__tests__/apps/devpreview-routes.test.js.map +1 -0
- package/dist/esm/__tests__/apps/kit/example.test.js +85 -5
- package/dist/esm/__tests__/apps/kit/example.test.js.map +1 -1
- package/dist/esm/__tests__/apps/kit/pages.test.d.ts +2 -0
- package/dist/esm/__tests__/apps/kit/pages.test.d.ts.map +1 -0
- package/dist/esm/__tests__/apps/kit/pages.test.js +117 -0
- package/dist/esm/__tests__/apps/kit/pages.test.js.map +1 -0
- package/dist/esm/__tests__/apps/kit/render.test.js +25 -1
- package/dist/esm/__tests__/apps/kit/render.test.js.map +1 -1
- package/dist/esm/__tests__/apps/kit/store.test.d.ts +2 -0
- package/dist/esm/__tests__/apps/kit/store.test.d.ts.map +1 -0
- package/dist/esm/__tests__/apps/kit/store.test.js +121 -0
- package/dist/esm/__tests__/apps/kit/store.test.js.map +1 -0
- package/dist/esm/__tests__/apps/post-install-page.test.d.ts +2 -0
- package/dist/esm/__tests__/apps/post-install-page.test.d.ts.map +1 -0
- package/dist/esm/__tests__/apps/post-install-page.test.js +108 -0
- package/dist/esm/__tests__/apps/post-install-page.test.js.map +1 -0
- package/dist/esm/__tests__/apps/routes.test.d.ts +2 -0
- package/dist/esm/__tests__/apps/routes.test.d.ts.map +1 -0
- package/dist/esm/__tests__/apps/routes.test.js +236 -0
- package/dist/esm/__tests__/apps/routes.test.js.map +1 -0
- package/dist/esm/__tests__/vendors.test.js +1 -1
- package/dist/esm/__tests__/webhooks.test.js +1 -1
- package/dist/esm/__tests__/webhooks.test.js.map +1 -1
- package/dist/esm/__tests__/zones.test.js +22 -4
- package/dist/esm/__tests__/zones.test.js.map +1 -1
- package/dist/esm/apps/components.d.ts +321 -0
- package/dist/esm/apps/components.d.ts.map +1 -1
- package/dist/esm/apps/components.js +163 -1
- package/dist/esm/apps/components.js.map +1 -1
- package/dist/esm/apps/context.d.ts +24 -0
- package/dist/esm/apps/context.d.ts.map +1 -1
- package/dist/esm/apps/context.js.map +1 -1
- package/dist/esm/apps/devpreview.d.ts +20 -3
- package/dist/esm/apps/devpreview.d.ts.map +1 -1
- package/dist/esm/apps/devpreview.js +19 -8
- package/dist/esm/apps/devpreview.js.map +1 -1
- package/dist/esm/apps/hooks/index.d.ts +33 -2
- package/dist/esm/apps/hooks/index.d.ts.map +1 -1
- package/dist/esm/apps/hooks/index.js +37 -1
- package/dist/esm/apps/hooks/index.js.map +1 -1
- package/dist/esm/apps/hooks/store.d.ts +48 -0
- package/dist/esm/apps/hooks/store.d.ts.map +1 -0
- package/dist/esm/apps/hooks/store.js +84 -0
- package/dist/esm/apps/hooks/store.js.map +1 -0
- package/dist/esm/apps/manifest.d.ts +64 -3
- package/dist/esm/apps/manifest.d.ts.map +1 -1
- package/dist/esm/apps/manifest.js +116 -26
- package/dist/esm/apps/manifest.js.map +1 -1
- package/dist/esm/apps/protocol.d.ts +1 -1
- package/dist/esm/apps/protocol.d.ts.map +1 -1
- package/dist/esm/apps/route.d.ts +11 -1
- package/dist/esm/apps/route.d.ts.map +1 -1
- package/dist/esm/apps/route.js +14 -8
- package/dist/esm/apps/route.js.map +1 -1
- package/dist/esm/apps/routes.d.ts +65 -0
- package/dist/esm/apps/routes.d.ts.map +1 -0
- package/dist/esm/apps/routes.js +194 -0
- package/dist/esm/apps/routes.js.map +1 -0
- package/dist/esm/apps/surfaces.d.ts +20 -2
- package/dist/esm/apps/surfaces.d.ts.map +1 -1
- package/dist/esm/apps/surfaces.js +39 -2
- package/dist/esm/apps/surfaces.js.map +1 -1
- package/dist/esm/apps/testing/index.d.ts +17 -1
- package/dist/esm/apps/testing/index.d.ts.map +1 -1
- package/dist/esm/apps/testing/index.js +49 -1
- package/dist/esm/apps/testing/index.js.map +1 -1
- package/dist/esm/apps/ui/index.d.ts +13 -0
- package/dist/esm/apps/ui/index.d.ts.map +1 -1
- package/dist/esm/apps/ui/index.js +17 -1
- package/dist/esm/apps/ui/index.js.map +1 -1
- package/dist/esm/lib/config.d.ts +2 -2
- package/dist/esm/lib/config.js +2 -2
- package/examples/apps/hello-green/src/store.ts +15 -0
- package/jsr.json +1 -1
- package/package.json +1 -1
- package/src/__tests__/apps/contract.test.ts +305 -4
- package/src/__tests__/apps/devpreview-routes.test.ts +103 -0
- package/src/__tests__/apps/kit/example.test.ts +94 -5
- package/src/__tests__/apps/kit/pages.test.ts +137 -0
- package/src/__tests__/apps/kit/render.test.ts +35 -1
- package/src/__tests__/apps/kit/store.test.ts +149 -0
- package/src/__tests__/apps/post-install-page.test.ts +126 -0
- package/src/__tests__/apps/routes.test.ts +318 -0
- package/src/__tests__/vendors.test.ts +1 -1
- package/src/__tests__/webhooks.test.ts +1 -1
- package/src/__tests__/zones.test.ts +22 -4
- package/src/apps/components.ts +183 -1
- package/src/apps/context.ts +25 -0
- package/src/apps/devpreview.ts +20 -7
- package/src/apps/hooks/index.ts +42 -2
- package/src/apps/hooks/store.ts +115 -0
- package/src/apps/manifest.ts +151 -27
- package/src/apps/protocol.ts +1 -1
- package/src/apps/route.ts +22 -10
- package/src/apps/routes.ts +230 -0
- package/src/apps/surfaces.ts +45 -12
- package/src/apps/testing/index.ts +48 -2
- package/src/apps/ui/index.ts +18 -1
- package/src/lib/config.ts +2 -2
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Hand-written. Tests for app page routes (`ui_extensions.routes`): the grammar and uniqueness
|
|
3
|
+
* rules in the manifest schema, the shared matcher, the `page` surface and the `appPage`
|
|
4
|
+
* descriptor. The route sets and match cases come from `fixtures/routes.json`, which the server
|
|
5
|
+
* specs read too; see docs/third-party-apps/fullpageview-plan.md.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { readFileSync } from "node:fs";
|
|
9
|
+
import { resolve } from "node:path";
|
|
10
|
+
import { describe, expect, test } from "vitest";
|
|
11
|
+
import {
|
|
12
|
+
APP_PAGES_SEGMENT,
|
|
13
|
+
APP_ROUTES_MAX,
|
|
14
|
+
appPagePathname,
|
|
15
|
+
effectivePostInstallAction,
|
|
16
|
+
getSurface,
|
|
17
|
+
isConcreteRoutePath,
|
|
18
|
+
isRouteDescriptor,
|
|
19
|
+
isRoutePath,
|
|
20
|
+
isSurfaceId,
|
|
21
|
+
manifestJsonSchema,
|
|
22
|
+
matchAppRoute,
|
|
23
|
+
normalizeRoutePath,
|
|
24
|
+
ROUTE_PATH_MAX_SEGMENTS,
|
|
25
|
+
ROUTE_PATH_PATTERN,
|
|
26
|
+
SURFACES,
|
|
27
|
+
surfacesForPortal,
|
|
28
|
+
validateManifest,
|
|
29
|
+
type AppManifestV2,
|
|
30
|
+
type ExtensionContext,
|
|
31
|
+
type PageRoute,
|
|
32
|
+
type ViewType,
|
|
33
|
+
} from "../../apps/index.js";
|
|
34
|
+
import { NON_BLANK_PATTERN } from "../../apps/manifest.js";
|
|
35
|
+
|
|
36
|
+
// Resolved from the package root (vitest's cwd), as contract.test.ts does for shared/app-platform.
|
|
37
|
+
const FIXTURES = resolve(process.cwd(), "src", "__tests__", "apps", "fixtures", "routes.json");
|
|
38
|
+
|
|
39
|
+
interface RouteSet {
|
|
40
|
+
name: string;
|
|
41
|
+
routes: unknown[];
|
|
42
|
+
}
|
|
43
|
+
interface RejectedSet extends RouteSet {
|
|
44
|
+
reason: string;
|
|
45
|
+
issue: string;
|
|
46
|
+
}
|
|
47
|
+
interface MatchCase {
|
|
48
|
+
name: string;
|
|
49
|
+
routes: string[];
|
|
50
|
+
pathname: string;
|
|
51
|
+
expected: { index: number; path: string; params: Record<string, string> } | null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
55
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function loadFixtures(): { accepted: RouteSet[]; rejected: RejectedSet[]; matches: MatchCase[] } {
|
|
59
|
+
const parsed: unknown = JSON.parse(readFileSync(FIXTURES, "utf8"));
|
|
60
|
+
if (
|
|
61
|
+
!isRecord(parsed) ||
|
|
62
|
+
!Array.isArray(parsed["accepted"]) ||
|
|
63
|
+
!Array.isArray(parsed["rejected"]) ||
|
|
64
|
+
!Array.isArray(parsed["matches"])
|
|
65
|
+
) {
|
|
66
|
+
throw new Error("fixtures/routes.json needs accepted, rejected and matches arrays");
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
accepted: parsed["accepted"] as RouteSet[],
|
|
70
|
+
rejected: parsed["rejected"] as RejectedSet[],
|
|
71
|
+
matches: parsed["matches"] as MatchCase[],
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const fixtures = loadFixtures();
|
|
76
|
+
|
|
77
|
+
function manifestWithRoutes(routes: unknown, overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
|
78
|
+
return {
|
|
79
|
+
id: 42,
|
|
80
|
+
name: "Acme Reports",
|
|
81
|
+
icon: "https://cdn.acme.com/icon.png",
|
|
82
|
+
short_description: "Reports about your vendors.",
|
|
83
|
+
version: "1.0.0",
|
|
84
|
+
configuration_instructions: "Install and sign in.",
|
|
85
|
+
support_instructions: "support@acme.com",
|
|
86
|
+
ui_extensions: {
|
|
87
|
+
bundle: {
|
|
88
|
+
url: "https://cdn.acme.com/gs-app/1.0.0/app.js",
|
|
89
|
+
integrity: "sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC",
|
|
90
|
+
},
|
|
91
|
+
routes,
|
|
92
|
+
},
|
|
93
|
+
...overrides,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function issuePaths(input: unknown): string[] {
|
|
98
|
+
const result = validateManifest(input);
|
|
99
|
+
return result.ok ? [] : result.issues.map((i) => i.path);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const page = (path: string) => ({ path, component: "Page", title: "Page" });
|
|
103
|
+
|
|
104
|
+
describe("route fixtures: manifest schema", () => {
|
|
105
|
+
test("the fixture file has cases of every kind", () => {
|
|
106
|
+
expect(fixtures.accepted.length).toBeGreaterThan(0);
|
|
107
|
+
expect(fixtures.rejected.length).toBeGreaterThan(0);
|
|
108
|
+
expect(fixtures.matches.length).toBeGreaterThan(0);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test.each(fixtures.accepted.map((set) => [set.name, set] as const))("accepts %s", (_name, set) => {
|
|
112
|
+
const result = validateManifest(manifestWithRoutes(set.routes));
|
|
113
|
+
expect(result.ok ? [] : result.issues).toEqual([]);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test.each(fixtures.rejected.map((set) => [set.name, set] as const))("rejects %s", (_name, set) => {
|
|
117
|
+
expect(issuePaths(manifestWithRoutes(set.routes))).toContain(`ui_extensions.${set.issue}`);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test.each(
|
|
121
|
+
fixtures.rejected
|
|
122
|
+
.filter((set) => /^(path_format|path_too_long|too_many_segments)$/.test(set.reason))
|
|
123
|
+
.map((set) => [set.name, set] as const),
|
|
124
|
+
)("isRoutePath refuses the path of %s", (_name, set) => {
|
|
125
|
+
const route = set.routes[0];
|
|
126
|
+
expect(isRecord(route) && isRoutePath(route["path"])).toBe(false);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("every accepted path is a route path, and every static-only one is concrete", () => {
|
|
130
|
+
for (const set of fixtures.accepted) {
|
|
131
|
+
for (const route of set.routes) {
|
|
132
|
+
const path = isRecord(route) ? route["path"] : undefined;
|
|
133
|
+
expect(isRoutePath(path), String(path)).toBe(true);
|
|
134
|
+
if (typeof path === "string") expect(isConcreteRoutePath(path)).toBe(!path.includes(":"));
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe("route fixtures: matchAppRoute", () => {
|
|
141
|
+
test.each(fixtures.matches.map((c) => [c.name, c] as const))("%s", (_name, c) => {
|
|
142
|
+
const routes = c.routes.map((path) => ({ path }));
|
|
143
|
+
const match = matchAppRoute(routes, c.pathname);
|
|
144
|
+
if (c.expected === null) {
|
|
145
|
+
expect(match).toBeNull();
|
|
146
|
+
} else {
|
|
147
|
+
expect(match).toEqual({ route: routes[c.expected.index], params: c.expected.params, index: c.expected.index });
|
|
148
|
+
expect(match?.route.path).toBe(c.expected.path);
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("returns the caller's own route object", () => {
|
|
153
|
+
const routes = [{ path: "/reports/:id", component: "Report", title: "Report" }];
|
|
154
|
+
expect(matchAppRoute(routes, "/reports/7")?.route).toBe(routes[0]);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe("route rules", () => {
|
|
159
|
+
test("NON_BLANK_PATTERN is JavaScript's \\S on every code point, spelled out for Ruby", () => {
|
|
160
|
+
const pattern = new RegExp(NON_BLANK_PATTERN);
|
|
161
|
+
const differ: string[] = [];
|
|
162
|
+
for (let code = 0; code <= 0x10ffff; code++) {
|
|
163
|
+
if (code >= 0xd800 && code <= 0xdfff) continue;
|
|
164
|
+
const char = String.fromCodePoint(code);
|
|
165
|
+
if (pattern.test(char) !== /\S/.test(char)) differ.push(code.toString(16));
|
|
166
|
+
}
|
|
167
|
+
expect(differ).toEqual([]);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("normalizeRoutePath renames every param to :_", () => {
|
|
171
|
+
expect(normalizeRoutePath("/")).toBe("/");
|
|
172
|
+
expect(normalizeRoutePath("/reports")).toBe("/reports");
|
|
173
|
+
expect(normalizeRoutePath("/vendors/:id/edit")).toBe("/vendors/:_/edit");
|
|
174
|
+
expect(normalizeRoutePath("/:a/:b")).toBe(normalizeRoutePath("/:x/:y"));
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("the JSON Schema carries the path grammar", () => {
|
|
178
|
+
const schema = manifestJsonSchema() as {
|
|
179
|
+
properties: {
|
|
180
|
+
ui_extensions: {
|
|
181
|
+
properties: { routes: { items: { properties: { path: { pattern: string } }; required: string[] } } };
|
|
182
|
+
};
|
|
183
|
+
};
|
|
184
|
+
};
|
|
185
|
+
const item = schema.properties.ui_extensions.properties.routes.items;
|
|
186
|
+
// zod writes `/` as `\/`; the two patterns are the same regular expression.
|
|
187
|
+
expect(item.properties.path.pattern.replace(/\\\//g, "/")).toBe(ROUTE_PATH_PATTERN);
|
|
188
|
+
expect(item.required).toEqual(["path", "component", "title"]);
|
|
189
|
+
expect(ROUTE_PATH_MAX_SEGMENTS).toBe(8);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test(`a manifest takes at most ${APP_ROUTES_MAX} routes`, () => {
|
|
193
|
+
const routes = Array.from({ length: APP_ROUTES_MAX + 1 }, (_, i) => page(`/p${i}`));
|
|
194
|
+
expect(validateManifest(manifestWithRoutes(routes.slice(0, APP_ROUTES_MAX))).ok).toBe(true);
|
|
195
|
+
expect(issuePaths(manifestWithRoutes(routes))).toContain("ui_extensions.routes");
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("routes need a bundle and a version, like views", () => {
|
|
199
|
+
const noBundle = manifestWithRoutes([page("/reports")]);
|
|
200
|
+
noBundle["ui_extensions"] = { routes: [page("/reports")] };
|
|
201
|
+
expect(issuePaths(noBundle)).toContain("ui_extensions.bundle");
|
|
202
|
+
|
|
203
|
+
const noVersion = manifestWithRoutes([page("/reports")]);
|
|
204
|
+
delete noVersion["version"];
|
|
205
|
+
noVersion["ui_extensions"] = { routes: [page("/reports")] };
|
|
206
|
+
expect(issuePaths(noVersion)).toEqual(expect.arrayContaining(["ui_extensions.bundle", "version"]));
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("a root route is a main view; any other route is not", () => {
|
|
210
|
+
const root = manifestWithRoutes([page("/")]);
|
|
211
|
+
expect(effectivePostInstallAction(root as AppManifestV2)).toEqual({ type: "app_main_view" });
|
|
212
|
+
expect(validateManifest({ ...root, post_install_action: { type: "app_main_view" } }).ok).toBe(true);
|
|
213
|
+
|
|
214
|
+
const nested = manifestWithRoutes([page("/reports")]);
|
|
215
|
+
expect(effectivePostInstallAction(nested as AppManifestV2)).toBeNull();
|
|
216
|
+
expect(issuePaths({ ...nested, post_install_action: { type: "app_main_view" } })).toContain(
|
|
217
|
+
"post_install_action.type",
|
|
218
|
+
);
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
describe("the page surface", () => {
|
|
223
|
+
test("is portal-less, wave 1, under the reserved prefix", () => {
|
|
224
|
+
expect(getSurface("page")).toEqual({
|
|
225
|
+
id: "page",
|
|
226
|
+
portal: null,
|
|
227
|
+
kind: "page",
|
|
228
|
+
objectType: null,
|
|
229
|
+
route: `/[portal]/apps/[appId]/${APP_PAGES_SEGMENT}/[...path]`,
|
|
230
|
+
idParam: "appId",
|
|
231
|
+
wave: 1,
|
|
232
|
+
});
|
|
233
|
+
expect(isSurfaceId("page")).toBe(true);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test("no other surface lives under the app pages prefix", () => {
|
|
237
|
+
// Literal portal segments too: `/company/apps/[appId]/pages` is the same place for a company user.
|
|
238
|
+
const reserved = new RegExp(`^/[^/]+/apps/\\[appId\\]/${APP_PAGES_SEGMENT}(/|$)`);
|
|
239
|
+
const offenders = SURFACES.filter((s) => s.id !== "page" && reserved.test(s.route)).map((s) => s.id);
|
|
240
|
+
expect(offenders).toEqual([]);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test("is never listed as a portal surface", () => {
|
|
244
|
+
for (const portal of ["company", "vendor", "facility", "system"] as const) {
|
|
245
|
+
expect(surfacesForPortal(portal, 2).map((s) => s.id)).not.toContain("page");
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("is not a view viewport: pages are declared as routes", () => {
|
|
250
|
+
const m = manifestWithRoutes([page("/reports")]);
|
|
251
|
+
(m["ui_extensions"] as Record<string, unknown>)["views"] = [{ viewport: "page", component: "Page" }];
|
|
252
|
+
expect(issuePaths(m)).toContain("ui_extensions.views.0.viewport");
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test("appPagePathname puts the root page at the prefix itself", () => {
|
|
256
|
+
expect(appPagePathname("company", 42, "/")).toBe("/company/apps/42/pages");
|
|
257
|
+
expect(appPagePathname("vendor", 42, "/reports/7")).toBe("/vendor/apps/42/pages/reports/7");
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test.each(["/../settings", "/reports/../../settings", "//evil.example", "/reports/:id", "reports", "", "/Reports"])(
|
|
261
|
+
"appPagePathname rejects %j, which is not a concrete route path",
|
|
262
|
+
(path) => {
|
|
263
|
+
expect(() => appPagePathname("company", 42, path)).toThrow(RangeError);
|
|
264
|
+
},
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
test.each([0, -1, 1.5, Number.NaN])("appPagePathname rejects app id %p", (appId) => {
|
|
268
|
+
expect(() => appPagePathname("company", appId, "/")).toThrow(RangeError);
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
describe("appPage route descriptors", () => {
|
|
273
|
+
test("take a concrete path in the route grammar", () => {
|
|
274
|
+
expect(isRouteDescriptor({ type: "appPage", path: "/" })).toBe(true);
|
|
275
|
+
expect(isRouteDescriptor({ type: "appPage", path: "/reports/7" })).toBe(true);
|
|
276
|
+
expect(isRouteDescriptor({ type: "appPage", path: "/pages/foo", envParams: { tab: "a" } })).toBe(true);
|
|
277
|
+
expect(isRouteDescriptor({ type: "appPage", path: "/settings" })).toBe(true);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test.each([
|
|
281
|
+
"/vendors/:id",
|
|
282
|
+
"/../settings",
|
|
283
|
+
"/reports/..",
|
|
284
|
+
"/%2e%2e/settings",
|
|
285
|
+
"/Reports",
|
|
286
|
+
"/reports/",
|
|
287
|
+
"/reports//7",
|
|
288
|
+
"reports",
|
|
289
|
+
"",
|
|
290
|
+
"/reports?x=1",
|
|
291
|
+
"https://evil.example/x",
|
|
292
|
+
`/${"a".repeat(200)}`,
|
|
293
|
+
"/a/b/c/d/e/f/g/h/i",
|
|
294
|
+
])("refuse %j", (path) => {
|
|
295
|
+
expect(isRouteDescriptor({ type: "appPage", path })).toBe(false);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test("refuse a missing path, a non-string path and bad envParams", () => {
|
|
299
|
+
expect(isRouteDescriptor({ type: "appPage" })).toBe(false);
|
|
300
|
+
expect(isRouteDescriptor({ type: "appPage", path: 7 })).toBe(false);
|
|
301
|
+
expect(isRouteDescriptor({ type: "appPage", path: "/reports", envParams: { a: 1 } })).toBe(false);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test("the page surface is not a surface target, like settings", () => {
|
|
305
|
+
expect(isRouteDescriptor({ type: "surface", surfaceId: "page" })).toBe(false);
|
|
306
|
+
expect(isRouteDescriptor({ type: "surface", surfaceId: "page", objectId: 7 })).toBe(false);
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
describe("page context types", () => {
|
|
311
|
+
test("environment.route and the page view type type-check", () => {
|
|
312
|
+
const route: PageRoute = { path: "/reports/:id", pathname: "/reports/7", params: { id: "7" } };
|
|
313
|
+
const env: Pick<ExtensionContext["environment"], "surfaceId" | "route"> = { surfaceId: "page", route };
|
|
314
|
+
const viewType: ViewType = "page";
|
|
315
|
+
expect(env.route?.params["id"]).toBe("7");
|
|
316
|
+
expect(viewType).toBe("page");
|
|
317
|
+
});
|
|
318
|
+
});
|
|
@@ -20,7 +20,7 @@ test("Webhooks List All Webhook Endpoints", async () => {
|
|
|
20
20
|
const testWebhookListForVendorUserResult = await greenSecurity.webhooks
|
|
21
21
|
.listAllWebhookEndpoints({
|
|
22
22
|
itemsPerPage: 25,
|
|
23
|
-
desc:
|
|
23
|
+
desc: true,
|
|
24
24
|
sort: "",
|
|
25
25
|
});
|
|
26
26
|
expect(testWebhookListForVendorUserResult).toBeDefined();
|
|
@@ -40,11 +40,29 @@ test("Zones List Or Search Security Zones", async () => {
|
|
|
40
40
|
},
|
|
41
41
|
{
|
|
42
42
|
facility: {
|
|
43
|
-
contacts:
|
|
43
|
+
contacts: {
|
|
44
|
+
contact: {
|
|
45
|
+
status: "Active",
|
|
46
|
+
department: 123,
|
|
47
|
+
},
|
|
48
|
+
adminContact: {
|
|
49
|
+
status: "Deleted",
|
|
50
|
+
department: 123,
|
|
51
|
+
},
|
|
52
|
+
techContact: null,
|
|
53
|
+
greenSecurityCsm: {
|
|
54
|
+
status: "Active",
|
|
55
|
+
department: 123,
|
|
56
|
+
},
|
|
57
|
+
accountManager: {
|
|
58
|
+
status: "Active",
|
|
59
|
+
department: 123,
|
|
60
|
+
},
|
|
61
|
+
},
|
|
44
62
|
vendorGuestPolicy: {
|
|
45
|
-
enabled:
|
|
63
|
+
enabled: false,
|
|
46
64
|
vendorGuestLimitAndOr: "and",
|
|
47
|
-
vendorGuestRequireEmail:
|
|
65
|
+
vendorGuestRequireEmail: false,
|
|
48
66
|
},
|
|
49
67
|
},
|
|
50
68
|
},
|
|
@@ -65,7 +83,7 @@ test("Zones Security Zone Checkin", async () => {
|
|
|
65
83
|
|
|
66
84
|
const result = await greenSecurity.zones.securityZoneCheckin({
|
|
67
85
|
qrCode: "<value>",
|
|
68
|
-
zoneId:
|
|
86
|
+
zoneId: 641156,
|
|
69
87
|
});
|
|
70
88
|
expect(result).toBeDefined();
|
|
71
89
|
expect(result).toEqual({});
|
package/src/apps/components.ts
CHANGED
|
@@ -163,11 +163,176 @@ const isConfirmCloseMessages = (v: unknown): v is ConfirmCloseMessages =>
|
|
|
163
163
|
|
|
164
164
|
const TABLE_CELL = { align: p.enum(["start", "center", "end"] as const), width: p.enum(WIDTHS) };
|
|
165
165
|
|
|
166
|
+
// ---------------------------------------------------------------------------------------------
|
|
167
|
+
// Charts
|
|
168
|
+
//
|
|
169
|
+
// Charts take data only. Colours come from a named `theme`; there are no colour, font, size or
|
|
170
|
+
// style props. Every value is a finite number >= 0 (v1 draws no negative axis), and every label
|
|
171
|
+
// and series name is visible text the host draws on an axis, legend or tooltip.
|
|
172
|
+
|
|
173
|
+
/** Named palettes the host draws charts with. The host uses `"green"` when `theme` is omitted. */
|
|
174
|
+
export const CHART_THEMES = ["green", "grayscale", "vibrant"] as const;
|
|
175
|
+
|
|
176
|
+
/** Most series a stacked, line or area chart may draw: one per palette colour. */
|
|
177
|
+
export const MAX_CHART_SERIES = 6;
|
|
178
|
+
/**
|
|
179
|
+
* Largest accepted chart value. Every chart caps its item and series counts, so with each value at
|
|
180
|
+
* most this, any total a chart computes (a stack, a pie) stays finite; two values near
|
|
181
|
+
* `Number.MAX_VALUE` would otherwise sum to `Infinity`.
|
|
182
|
+
*/
|
|
183
|
+
export const MAX_CHART_VALUE = Number.MAX_SAFE_INTEGER;
|
|
184
|
+
|
|
185
|
+
/** Longest label or series name, in characters. */
|
|
186
|
+
export const MAX_CHART_LABEL_LENGTH = 60;
|
|
187
|
+
|
|
188
|
+
/** One bar or slice: `label` names it, `value` is its size. */
|
|
189
|
+
export interface ChartDatum { label: string; value: number }
|
|
190
|
+
/** A named series with one value per label of the chart it belongs to. */
|
|
191
|
+
export interface ChartSeries { name: string; values: number[] }
|
|
192
|
+
/** Labels along the category axis and the series drawn against them. */
|
|
193
|
+
export interface SeriesChartData { labels: string[]; series: ChartSeries[] }
|
|
194
|
+
/** Bars and a line drawn over the same labels. */
|
|
195
|
+
export interface ComboChartData { labels: string[]; bars: ChartSeries; line: ChartSeries }
|
|
196
|
+
/**
|
|
197
|
+
* The detail of a chart's `onClick`: the position and label of the pressed bar, point or slice,
|
|
198
|
+
* its value, and, on a multi-series chart, the name of its series.
|
|
199
|
+
*/
|
|
200
|
+
export interface ChartClickEvent { index: number; label: string; value: number; series?: string }
|
|
201
|
+
|
|
202
|
+
/** Item-count bounds for {@link isChartData}. */
|
|
203
|
+
export interface ChartDataLimits { minItems: number; maxItems: number }
|
|
204
|
+
/** Label- and series-count bounds for {@link isSeriesChartData}; `maxSeries` defaults to {@link MAX_CHART_SERIES}. */
|
|
205
|
+
export interface SeriesChartDataLimits { minLabels: number; maxLabels: number; maxSeries?: number }
|
|
206
|
+
/** Label-count bounds for {@link isComboChartData}. */
|
|
207
|
+
export interface ComboChartDataLimits { minLabels: number; maxLabels: number }
|
|
208
|
+
|
|
209
|
+
/** The bounds each chart component's `data` is checked against. */
|
|
210
|
+
export const CHART_DATA_LIMITS = {
|
|
211
|
+
BarChart: { minItems: 1, maxItems: 31 },
|
|
212
|
+
HorizontalBarChart: { minItems: 1, maxItems: 25 },
|
|
213
|
+
StackedBarChart: { minLabels: 1, maxLabels: 31, maxSeries: MAX_CHART_SERIES },
|
|
214
|
+
LineChart: { minLabels: 2, maxLabels: 60, maxSeries: MAX_CHART_SERIES },
|
|
215
|
+
AreaChart: { minLabels: 2, maxLabels: 60, maxSeries: MAX_CHART_SERIES },
|
|
216
|
+
ComboChart: { minLabels: 1, maxLabels: 31 },
|
|
217
|
+
// The host folds everything past the sixth slice into one "Other" slice.
|
|
218
|
+
PieChart: { minItems: 1, maxItems: 50 },
|
|
219
|
+
} as const satisfies Record<string, ChartDataLimits | SeriesChartDataLimits | ComboChartDataLimits>;
|
|
220
|
+
|
|
221
|
+
// Plain data only, as JSON and remote-dom carry it: no class instances (Date, Map, a subclassed
|
|
222
|
+
// Array) whose prototype the host would otherwise have to trust.
|
|
223
|
+
const isPlainObject = (v: unknown): v is Record<string, unknown> => {
|
|
224
|
+
if (typeof v !== "object" || v === null || Array.isArray(v)) return false;
|
|
225
|
+
const proto: unknown = Object.getPrototypeOf(v);
|
|
226
|
+
return proto === Object.prototype || proto === null;
|
|
227
|
+
};
|
|
228
|
+
const isPlainArray = (v: unknown): v is unknown[] => Array.isArray(v) && Object.getPrototypeOf(v) === Array.prototype;
|
|
229
|
+
// Exactly these keys: the shapes are closed, which also bounds their nesting depth.
|
|
230
|
+
const hasExactKeys = (v: Record<string, unknown>, keys: readonly string[]): boolean => {
|
|
231
|
+
const own = Object.keys(v);
|
|
232
|
+
return own.length === keys.length && keys.every((k) => Object.prototype.hasOwnProperty.call(v, k));
|
|
233
|
+
};
|
|
234
|
+
const isChartValue = (v: unknown): v is number =>
|
|
235
|
+
typeof v === "number" && Number.isFinite(v) && v >= 0 && v <= MAX_CHART_VALUE;
|
|
236
|
+
const isChartLabel = (v: unknown): v is string => isVisibleText(v, MAX_CHART_LABEL_LENGTH);
|
|
237
|
+
|
|
238
|
+
// Indexed loops rather than `every`, which skips the holes of a sparse array.
|
|
239
|
+
function isLabelList(v: unknown, min: number, max: number): v is string[] {
|
|
240
|
+
if (!isPlainArray(v) || v.length < min || v.length > max) return false;
|
|
241
|
+
const seen = new Set<string>();
|
|
242
|
+
for (let i = 0; i < v.length; i++) {
|
|
243
|
+
const label = v[i];
|
|
244
|
+
if (!isChartLabel(label) || seen.has(label)) return false;
|
|
245
|
+
seen.add(label);
|
|
246
|
+
}
|
|
247
|
+
return true;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function isChartSeries(v: unknown, length: number): v is ChartSeries {
|
|
251
|
+
if (!isPlainObject(v) || !hasExactKeys(v, ["name", "values"]) || !isChartLabel(v["name"])) return false;
|
|
252
|
+
const values = v["values"];
|
|
253
|
+
if (!isPlainArray(values) || values.length !== length) return false;
|
|
254
|
+
for (let i = 0; i < values.length; i++) if (!isChartValue(values[i])) return false;
|
|
255
|
+
return true;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* `ChartDatum[]` with between `minItems` and `maxItems` items, each a visible, unique label of at
|
|
260
|
+
* most {@link MAX_CHART_LABEL_LENGTH} characters and a value from 0 to {@link MAX_CHART_VALUE}.
|
|
261
|
+
*/
|
|
262
|
+
export function isChartData(value: unknown, limits: ChartDataLimits): value is ChartDatum[] {
|
|
263
|
+
if (!isPlainArray(value) || value.length < limits.minItems || value.length > limits.maxItems) return false;
|
|
264
|
+
const seen = new Set<string>();
|
|
265
|
+
for (let i = 0; i < value.length; i++) {
|
|
266
|
+
const item = value[i];
|
|
267
|
+
if (!isPlainObject(item) || !hasExactKeys(item, ["label", "value"])) return false;
|
|
268
|
+
const label = item["label"];
|
|
269
|
+
if (!isChartLabel(label) || seen.has(label) || !isChartValue(item["value"])) return false;
|
|
270
|
+
seen.add(label);
|
|
271
|
+
}
|
|
272
|
+
return true;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Label of the slice a pie chart creates when it combines items beyond {@link MAX_CHART_SERIES}. */
|
|
276
|
+
export const CHART_OTHER_LABEL = "Other";
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Pie chart data: {@link isChartData} with `CHART_DATA_LIMITS.PieChart`. When there are more
|
|
280
|
+
* items than {@link MAX_CHART_SERIES}, the host combines the extras into a {@link CHART_OTHER_LABEL}
|
|
281
|
+
* slice, so a caller's own `Other` label (any case, ignoring surrounding spaces) is rejected:
|
|
282
|
+
* the chart would show two slices with that name and their click events could not be told apart.
|
|
283
|
+
*/
|
|
284
|
+
export function isPieChartData(value: unknown): value is ChartDatum[] {
|
|
285
|
+
if (!isChartData(value, CHART_DATA_LIMITS.PieChart)) return false;
|
|
286
|
+
if (value.length <= MAX_CHART_SERIES) return true;
|
|
287
|
+
const reserved = CHART_OTHER_LABEL.toLowerCase();
|
|
288
|
+
return value.every((item) => item.label.trim().toLowerCase() !== reserved);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* `SeriesChartData` with between `minLabels` and `maxLabels` unique labels and between 1 and
|
|
293
|
+
* `maxSeries` uniquely named series, each with exactly one value from 0 to {@link MAX_CHART_VALUE} per label.
|
|
294
|
+
*/
|
|
295
|
+
export function isSeriesChartData(value: unknown, limits: SeriesChartDataLimits): value is SeriesChartData {
|
|
296
|
+
if (!isPlainObject(value) || !hasExactKeys(value, ["labels", "series"])) return false;
|
|
297
|
+
const labels = value["labels"];
|
|
298
|
+
const series = value["series"];
|
|
299
|
+
const maxSeries = limits.maxSeries ?? MAX_CHART_SERIES;
|
|
300
|
+
if (!isLabelList(labels, limits.minLabels, limits.maxLabels)) return false;
|
|
301
|
+
if (!isPlainArray(series) || series.length < 1 || series.length > maxSeries) return false;
|
|
302
|
+
const names = new Set<string>();
|
|
303
|
+
for (let i = 0; i < series.length; i++) {
|
|
304
|
+
const s = series[i];
|
|
305
|
+
if (!isChartSeries(s, labels.length) || names.has(s.name)) return false;
|
|
306
|
+
names.add(s.name);
|
|
307
|
+
}
|
|
308
|
+
return true;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* `ComboChartData` with between `minLabels` and `maxLabels` unique labels, and a `bars` and a
|
|
313
|
+
* `line` series with different names, each with exactly one value from 0 to {@link MAX_CHART_VALUE} per label.
|
|
314
|
+
*/
|
|
315
|
+
export function isComboChartData(value: unknown, limits: ComboChartDataLimits): value is ComboChartData {
|
|
316
|
+
if (!isPlainObject(value) || !hasExactKeys(value, ["labels", "bars", "line"])) return false;
|
|
317
|
+
const labels = value["labels"];
|
|
318
|
+
const bars = value["bars"];
|
|
319
|
+
const line = value["line"];
|
|
320
|
+
return isLabelList(labels, limits.minLabels, limits.maxLabels) &&
|
|
321
|
+
isChartSeries(bars, labels.length) && isChartSeries(line, labels.length) && bars.name !== line.name;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const chart = <T>(check: (v: unknown) => v is T) => ({
|
|
325
|
+
props: { data: p.json(check, { required: true }), theme: p.enum(CHART_THEMES), interactive: p.boolean() },
|
|
326
|
+
events: { onClick: ev<ChartClickEvent>("click") },
|
|
327
|
+
children: [] as const,
|
|
328
|
+
});
|
|
329
|
+
|
|
166
330
|
// ---------------------------------------------------------------------------------------------
|
|
167
331
|
// Catalog
|
|
168
332
|
|
|
169
333
|
export const COMPONENTS = {
|
|
170
|
-
// View roots. An app's view component must render exactly one of these at its top level
|
|
334
|
+
// View roots. An app's view component must render exactly one of these at its top level:
|
|
335
|
+
// ContextView on a surface, SettingsView on the settings page, PageView on an app page.
|
|
171
336
|
ContextView: spec("ui-context-view", {
|
|
172
337
|
root: true,
|
|
173
338
|
props: { title: p.string({ required: true, maxLength: 100 }), description: p.string({ maxLength: 300 }), externalLink: p.json(isExternalLink) },
|
|
@@ -190,6 +355,13 @@ export const COMPONENTS = {
|
|
|
190
355
|
// `values` maps every form field's `name` to its current value.
|
|
191
356
|
events: { onSave: ev<Record<string, string>>("save") },
|
|
192
357
|
}),
|
|
358
|
+
// An app page (`ui_extensions.routes`), rendered full width inside the portal's own chrome. The
|
|
359
|
+
// title is the page header; `actions` join the portal header's actions, `banner` sits above the body.
|
|
360
|
+
PageView: spec("ui-page-view", {
|
|
361
|
+
root: true,
|
|
362
|
+
props: { title: p.string({ required: true, maxLength: 100 }), description: p.string({ maxLength: 300 }) },
|
|
363
|
+
slots: ["actions", "banner"] as const,
|
|
364
|
+
}),
|
|
193
365
|
|
|
194
366
|
// Layout
|
|
195
367
|
Box: spec("ui-box", { props: { ...layout, ...typography }, text: true }),
|
|
@@ -368,6 +540,16 @@ export const COMPONENTS = {
|
|
|
368
540
|
FormFieldGroup: spec("ui-form-field-group", {
|
|
369
541
|
props: { legend: p.string({ maxLength: 200 }), description: p.string({ maxLength: 500 }), layout: p.enum(["row", "column"] as const) },
|
|
370
542
|
}),
|
|
543
|
+
|
|
544
|
+
// Charts. Data only: `theme` picks the palette, `interactive` turns on press feedback and
|
|
545
|
+
// tooltips, and `onClick` receives a `ChartClickEvent`. Bounds are in `CHART_DATA_LIMITS`.
|
|
546
|
+
BarChart: spec("ui-bar-chart", chart((v): v is ChartDatum[] => isChartData(v, CHART_DATA_LIMITS.BarChart))),
|
|
547
|
+
HorizontalBarChart: spec("ui-horizontal-bar-chart", chart((v): v is ChartDatum[] => isChartData(v, CHART_DATA_LIMITS.HorizontalBarChart))),
|
|
548
|
+
StackedBarChart: spec("ui-stacked-bar-chart", chart((v): v is SeriesChartData => isSeriesChartData(v, CHART_DATA_LIMITS.StackedBarChart))),
|
|
549
|
+
LineChart: spec("ui-line-chart", chart((v): v is SeriesChartData => isSeriesChartData(v, CHART_DATA_LIMITS.LineChart))),
|
|
550
|
+
AreaChart: spec("ui-area-chart", chart((v): v is SeriesChartData => isSeriesChartData(v, CHART_DATA_LIMITS.AreaChart))),
|
|
551
|
+
ComboChart: spec("ui-combo-chart", chart((v): v is ComboChartData => isComboChartData(v, CHART_DATA_LIMITS.ComboChart))),
|
|
552
|
+
PieChart: spec("ui-pie-chart", chart(isPieChartData)),
|
|
371
553
|
} as const;
|
|
372
554
|
|
|
373
555
|
// Checked by assignment rather than `satisfies`: `satisfies` would hand `ComponentSpec` to every
|
package/src/apps/context.ts
CHANGED
|
@@ -14,6 +14,19 @@ export interface ObjectContext {
|
|
|
14
14
|
id: number;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
/**
|
|
18
|
+
* The app page a view is rendered on (`environment.surfaceId` is `"page"`). Paths are in the app's
|
|
19
|
+
* own space, without the host's `/{portal}/apps/{appId}/pages` prefix.
|
|
20
|
+
*/
|
|
21
|
+
export interface PageRoute {
|
|
22
|
+
/** The declared route path from `ui_extensions.routes`, e.g. `/reports/:id`. */
|
|
23
|
+
path: string;
|
|
24
|
+
/** The concrete path being shown, e.g. `/reports/7`. */
|
|
25
|
+
pathname: string;
|
|
26
|
+
/** Param values by name (`{ id: "7" }`), percent-decoded. Always strings. */
|
|
27
|
+
params: Record<string, string>;
|
|
28
|
+
}
|
|
29
|
+
|
|
17
30
|
export interface AppUserToken {
|
|
18
31
|
/** `gsu_…`. Acts as the viewer, limited to the app's manifest permissions ∩ the viewer's role. */
|
|
19
32
|
accessToken: string;
|
|
@@ -56,12 +69,24 @@ export interface ExtensionContext {
|
|
|
56
69
|
portal: Portal;
|
|
57
70
|
platform: "web" | "ios" | "android";
|
|
58
71
|
objectContext?: ObjectContext;
|
|
72
|
+
/**
|
|
73
|
+
* The query string of the page's address. On an app page anyone can set these by sharing a
|
|
74
|
+
* link, so treat them as untrusted user input. Empty on drawer, inline and settings views.
|
|
75
|
+
*/
|
|
59
76
|
queryParams: Record<string, string>;
|
|
77
|
+
/**
|
|
78
|
+
* The `envParams` of the `HostApi.navigate` call, made by this app, that led to this view.
|
|
79
|
+
* Only this app can set them, they never appear in an address, and each navigation delivers
|
|
80
|
+
* them to one view once. Empty when the view did not open from a navigation.
|
|
81
|
+
*/
|
|
82
|
+
navParams: Record<string, string>;
|
|
60
83
|
constants: AppConstants;
|
|
61
84
|
/** Base URL for `@greensecurity/javascript-sdk`, e.g. `https://www.greensecurityllc.com/api`. */
|
|
62
85
|
apiBaseUrl: string;
|
|
63
86
|
/** Set while the view is served by `apps-cli dev` instead of the published bundle. */
|
|
64
87
|
devSessionId?: string;
|
|
88
|
+
/** Set on app pages only: which of the app's routes is shown, and its params. */
|
|
89
|
+
route?: PageRoute;
|
|
65
90
|
};
|
|
66
91
|
/** Injected by the host and replaced through `SandboxExports.updateContext` before expiry. */
|
|
67
92
|
auth: AppTokens;
|