create-keboola-app 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +64 -2
- package/README.md +16 -11
- package/dist/{blocks-DYU5Ke29.js → blocks-D_-NWhwg.js} +31 -12
- package/dist/cli.js +34 -4
- package/dist/extract.js +13 -2
- package/dist/template/AGENTS.md +20 -6
- package/dist/template/README.md +5 -0
- package/dist/template/oxlint.config.ts +2 -4
- package/dist/template/package.json +7 -7
- package/dist/template/src/auth/AuthScreen.test.tsx +44 -0
- package/dist/template/src/auth/AuthScreen.tsx +63 -0
- package/dist/template/src/auth/session.test.tsx +64 -0
- package/dist/template/src/auth/session.ts +58 -0
- package/dist/template/src/auth/states.tsx +88 -0
- package/dist/template/src/components/AppChrome.tsx +1 -1
- package/dist/template/src/nav.ts +1 -1
- package/dist/template/src/{routes/-fixtures.ts → overview-fixtures.ts} +1 -1
- package/dist/template/src/{routes/-hooks.ts → overview-hooks.ts} +1 -1
- package/dist/template/src/overview-sections.tsx +4 -1
- package/dist/template/src/recipes/DataGrid.test.tsx +1 -1
- package/dist/template/src/recipes/DataGrid.tsx +2 -2
- package/dist/template/src/routes/__root.tsx +28 -0
- package/dist/template/src/routes/{-index.test.tsx → index.test.tsx} +1 -1
- package/dist/template/src/routes/index.tsx +2 -3
- package/dist/template/src/routes/signed-out.test.tsx +56 -0
- package/dist/template/src/routes/signed-out.tsx +35 -0
- package/dist/template/src/routes/tokens/$id.tsx +2 -2
- package/dist/template/src/routes/tokens/{-index.test.tsx → index.test.tsx} +2 -2
- package/dist/template/src/routes/tokens/index.tsx +2 -3
- package/dist/template/src/routes/tokens/new.tsx +2 -2
- package/dist/template/src/{routes/tokens/-fixtures.test.ts → tokens/fixtures.test.ts} +1 -1
- package/dist/template/src/{routes/tokens/-fixtures.ts → tokens/fixtures.ts} +1 -1
- package/dist/template/src/{routes/tokens/-hooks.ts → tokens/hooks.ts} +2 -2
- package/dist/template/src/{routes/tokens/-nav.ts → tokens/nav.ts} +1 -1
- package/dist/template/vite.config.ts +8 -0
- package/package.json +21 -19
- /package/dist/template/src/{routes/tokens/-types.ts → tokens/types.ts} +0 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,10 +1,72 @@
|
|
|
1
1
|
# create-keboola-app
|
|
2
2
|
|
|
3
|
+
## 0.2.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- New `auth` block: a generated app can now arrive with its signed-out screen already built.
|
|
8
|
+
|
|
9
|
+
Four apps in the platform hand-build the same pre-auth screen, and a Storybook pattern does not
|
|
10
|
+
stop a fifth from doing it again — new apps come from this generator, so that is where the screen
|
|
11
|
+
has to arrive. Pick `auth` and the app gets `/signed-out`, rendered without the app chrome: brand
|
|
12
|
+
lockup from `BrandLogo`, one centred card, a colour-scheme control, correct landmarks and heading
|
|
13
|
+
level, and the four pre-project states — signed out, restoring, expired, service unreachable —
|
|
14
|
+
under one piece of chrome, so the common path does not jump when one replaces another. The
|
|
15
|
+
emitted `gate:ui` scores that route as well as `/`.
|
|
16
|
+
|
|
17
|
+
The block is the screen and nothing behind it: no dependency, no network. Only the app knows what
|
|
18
|
+
it authenticates against, so wiring it up is two edits — the form's `onSubmit`, and where the
|
|
19
|
+
state comes from instead of the `?state=` search param. `--blocks none` still yields an app that
|
|
20
|
+
builds, type-checks and passes its own gate.
|
|
21
|
+
|
|
22
|
+
A routed block now brings the whole brief for its own route rather than a shared suffix. The
|
|
23
|
+
suffix described a searchable table, which graded a signed-out screen against qualities it is
|
|
24
|
+
supposed not to have.
|
|
25
|
+
|
|
26
|
+
- A generated app keeps `src/routes/` for route files. Everything else — hooks, fixtures, types, a
|
|
27
|
+
module's rail entry — arrives in a domain directory beside it.
|
|
28
|
+
|
|
29
|
+
TanStack Router excludes a file from the route tree when its name starts with `-`, and the
|
|
30
|
+
emitted layout leaned on that to keep a module's code inside its route folder. It does not scale:
|
|
31
|
+
one app grew to six route files and fifty-four dashed ones, and route folders that own domain code
|
|
32
|
+
end up importing across each other, which is the coupling the co-location was meant to prevent. A
|
|
33
|
+
marker on nine files out of ten marks nothing.
|
|
34
|
+
|
|
35
|
+
So the crud block now emits `routes/<kebab>/{index,new,$id}.tsx` and `<kebab>/{hooks,fixtures,
|
|
36
|
+
types,nav}.ts`, and the overview's hooks and fixtures sit at `src/overview-*.ts`. Nothing emitted
|
|
37
|
+
carries the `-` prefix.
|
|
38
|
+
|
|
39
|
+
A route's test is the one thing that stays with its route, because that is the file it exercises.
|
|
40
|
+
It exports no `Route`, so the emitted `vite.config.ts` declares
|
|
41
|
+
`routeFileIgnorePattern: '\\.test\\.tsx?$'`. Without it the generator prints a
|
|
42
|
+
`does not export a Route` warning for every such file on every build, which is what makes the
|
|
43
|
+
prefix feel obligatory in the first place. The CLI passes the same pattern when it writes the
|
|
44
|
+
first `routeTree.gen.ts`, which happens before the app has a Vite build to read the config from.
|
|
45
|
+
|
|
46
|
+
## 0.1.1
|
|
47
|
+
|
|
48
|
+
### Patch Changes
|
|
49
|
+
|
|
50
|
+
- A generated app pins `nwsapi`, so a userEvent test does not hang.
|
|
51
|
+
|
|
52
|
+
nwsapi 2.2.26 — jsdom's selector engine — answers `:modal`, `:fullscreen` and `:open` through a helper that falls back to the element's own `matches`. Under jsdom that is nwsapi itself, so the two recurse, and `:modal` doubles the work per level through its `|| isFullscreen` branch. `@floating-ui/dom` probes `:modal` on every popper placement, which is once per dropdown or tooltip interaction: measured at 9s where 2.2.25 takes 26ms, so a test driving a menu with `userEvent` hits its timeout instead of asserting.
|
|
53
|
+
|
|
54
|
+
Generated apps now ship a `pnpm-workspace.yaml` holding `nwsapi` at 2.2.25 — the only file pnpm reads `overrides` from. It carries the reason and the condition for removing it.
|
|
55
|
+
|
|
56
|
+
- Name the generated app in its own chrome. The breadcrumb was hard-coded to the template's own
|
|
57
|
+
name, so every scaffold shipped labelled after the template rather than the name the user passed
|
|
58
|
+
— the first thing on screen, and the one place the app names itself back to the reader. The
|
|
59
|
+
extract step now parameterises it alongside the page title and the store's storage keys.
|
|
60
|
+
|
|
61
|
+
A `boilerplate-identity` sanitize pattern makes this class of leak a red build instead of a
|
|
62
|
+
silent default: any un-templated mention of the source app's name fails extraction, so a rewrite
|
|
63
|
+
that stops matching is caught rather than shipped.
|
|
64
|
+
|
|
3
65
|
## 0.1.0
|
|
4
66
|
|
|
5
67
|
### Minor Changes
|
|
6
68
|
|
|
7
|
-
-
|
|
69
|
+
- Initial release. `npm create keboola-app` scaffolds a Keboola-shaped frontend app — Vite,
|
|
8
70
|
React 19, TanStack Router/Query/Form, Zustand, `@keboola/design` with
|
|
9
71
|
`@keboola/brand-registry`, oxlint/oxfmt and vitest — from a minimal shell plus the blocks you
|
|
10
72
|
pick: `kpi`, `chart`, `grid`, `crud`, `assistant`. Blocks are independent, so any combination
|
|
@@ -19,6 +81,6 @@
|
|
|
19
81
|
|
|
20
82
|
### Patch Changes
|
|
21
83
|
|
|
22
|
-
-
|
|
84
|
+
- In `--workspace` mode the generated app inherits the host repo's `engines`, and the monorepo entry point is `pnpm scaffold:app`.
|
|
23
85
|
|
|
24
86
|
The template carries a standalone `engines.node` floor for apps generated outside a monorepo. Inside one that floor is not a default but a disagreement: it was the lowest value across every workspace, so syncpack wanted to rewrite the other 21 manifests and `pnpm deps:check` went red on a freshly scaffolded app. Workspace mode now reads the root manifest's `engines` instead, and the gate asserts it.
|
package/README.md
CHANGED
|
@@ -25,22 +25,27 @@ npm create keboola-app -- --name my-app --blocks kpi,grid --yes --json
|
|
|
25
25
|
|
|
26
26
|
## Blocks
|
|
27
27
|
|
|
28
|
-
| Block | What you get
|
|
29
|
-
| ----------- |
|
|
30
|
-
| `kpi` | A KPI card row on the overview.
|
|
31
|
-
| `chart` | A token-themed line chart card on the overview.
|
|
32
|
-
| `grid` | An editable per-row budget grid (TanStack Table + design `Table`).
|
|
33
|
-
| `crud` | A list → detail → form slice over `/tokens`, exercising router params, Query and Form.
|
|
34
|
-
| `assistant` | A docked assistant rail, a page composer and an explain-on-selection floater.
|
|
28
|
+
| Block | What you get |
|
|
29
|
+
| ----------- | ----------------------------------------------------------------------------------------- |
|
|
30
|
+
| `kpi` | A KPI card row on the overview. |
|
|
31
|
+
| `chart` | A token-themed line chart card on the overview. |
|
|
32
|
+
| `grid` | An editable per-row budget grid (TanStack Table + design `Table`). |
|
|
33
|
+
| `crud` | A list → detail → form slice over `/tokens`, exercising router params, Query and Form. |
|
|
34
|
+
| `assistant` | A docked assistant rail, a page composer and an explain-on-selection floater. |
|
|
35
|
+
| `auth` | A signed-out screen at `/signed-out`, with the restoring, expired and unreachable states. |
|
|
35
36
|
|
|
36
37
|
Every block is independent — none imports another, so any combination compiles. Blocks are
|
|
37
38
|
additive: pick what you need rather than taking everything and deleting, since an unpicked
|
|
38
39
|
block costs no files and pulls none of its npm dependencies.
|
|
39
40
|
|
|
40
|
-
Blocks join the shell through
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
app by editing those, not the app chrome.
|
|
41
|
+
Blocks join the shell through four files the generator writes rather than patches: `src/nav.ts`
|
|
42
|
+
(the nav tuple), `src/overview-sections.tsx` (the ordered section registry), `src/store.ts` (the
|
|
43
|
+
shell slice plus the selected block slices) and `src/routes/__root.tsx` (whether a route gets the
|
|
44
|
+
app chrome). Add a section to a generated app by editing those, not the app chrome.
|
|
45
|
+
|
|
46
|
+
`auth` is the screen and nothing behind it. It renders with no dependency and reaches no network,
|
|
47
|
+
because only your app knows what it authenticates against — swap the form's `onSubmit` and pick
|
|
48
|
+
the state from your session instead of from the `?state=` search param.
|
|
44
49
|
|
|
45
50
|
## Naming
|
|
46
51
|
|
|
@@ -4,7 +4,8 @@ const BLOCK_IDS = [
|
|
|
4
4
|
"chart",
|
|
5
5
|
"grid",
|
|
6
6
|
"crud",
|
|
7
|
-
"assistant"
|
|
7
|
+
"assistant",
|
|
8
|
+
"auth"
|
|
8
9
|
];
|
|
9
10
|
const isBlockId = (value) => BLOCK_IDS.includes(value);
|
|
10
11
|
const BLOCKS = {
|
|
@@ -55,19 +56,19 @@ const BLOCKS = {
|
|
|
55
56
|
label: "CRUD slice",
|
|
56
57
|
summary: "List → detail → form over `/tokens`: router params, Query and Form in one flow.",
|
|
57
58
|
files: [
|
|
58
|
-
"src/routes/tokens/-fixtures.test.ts",
|
|
59
|
-
"src/routes/tokens/-fixtures.ts",
|
|
60
|
-
"src/routes/tokens/-hooks.ts",
|
|
61
|
-
"src/routes/tokens/-index.test.tsx",
|
|
62
|
-
"src/routes/tokens/-nav.ts",
|
|
63
|
-
"src/routes/tokens/-types.ts",
|
|
64
59
|
"src/routes/tokens/$id.tsx",
|
|
60
|
+
"src/routes/tokens/index.test.tsx",
|
|
65
61
|
"src/routes/tokens/index.tsx",
|
|
66
|
-
"src/routes/tokens/new.tsx"
|
|
62
|
+
"src/routes/tokens/new.tsx",
|
|
63
|
+
"src/tokens/fixtures.test.ts",
|
|
64
|
+
"src/tokens/fixtures.ts",
|
|
65
|
+
"src/tokens/hooks.ts",
|
|
66
|
+
"src/tokens/nav.ts",
|
|
67
|
+
"src/tokens/types.ts"
|
|
67
68
|
],
|
|
68
69
|
deps: ["@tanstack/react-form", "zod"],
|
|
69
70
|
glue: ["src/nav.ts"],
|
|
70
|
-
brief: "
|
|
71
|
+
brief: "tokens list page: on-brand @keboola/design shell, a searchable table of API tokens, no runtime or a11y errors",
|
|
71
72
|
route: "/tokens"
|
|
72
73
|
},
|
|
73
74
|
assistant: {
|
|
@@ -87,6 +88,24 @@ const BLOCKS = {
|
|
|
87
88
|
glue: ["src/store.ts"],
|
|
88
89
|
brief: "a prompt input and a docked assistant rail",
|
|
89
90
|
route: null
|
|
91
|
+
},
|
|
92
|
+
auth: {
|
|
93
|
+
id: "auth",
|
|
94
|
+
label: "Signed-out screen",
|
|
95
|
+
summary: "The pre-project screen: brand lockup, one centred card, and the signed-out, restoring, expired and unreachable states.",
|
|
96
|
+
files: [
|
|
97
|
+
"src/auth/AuthScreen.test.tsx",
|
|
98
|
+
"src/auth/AuthScreen.tsx",
|
|
99
|
+
"src/auth/session.test.tsx",
|
|
100
|
+
"src/auth/session.ts",
|
|
101
|
+
"src/auth/states.tsx",
|
|
102
|
+
"src/routes/signed-out.test.tsx",
|
|
103
|
+
"src/routes/signed-out.tsx"
|
|
104
|
+
],
|
|
105
|
+
deps: [],
|
|
106
|
+
glue: ["src/routes/__root.tsx"],
|
|
107
|
+
brief: "signed-out screen: a brand lockup over one centred sign-in card on a muted page, no navigation rail and no breadcrumb, a colour-scheme control in the corner, no runtime or a11y errors",
|
|
108
|
+
route: "/signed-out"
|
|
90
109
|
}
|
|
91
110
|
};
|
|
92
111
|
/** Files every emitted app carries, whatever the selection. */
|
|
@@ -103,12 +122,12 @@ const SHELL_FILES = [
|
|
|
103
122
|
"src/main.tsx",
|
|
104
123
|
"src/nav.test.ts",
|
|
105
124
|
"src/nav.ts",
|
|
125
|
+
"src/overview-fixtures.ts",
|
|
126
|
+
"src/overview-hooks.ts",
|
|
106
127
|
"src/overview-sections.test.tsx",
|
|
107
128
|
"src/overview-sections.tsx",
|
|
108
|
-
"src/routes/-fixtures.ts",
|
|
109
|
-
"src/routes/-hooks.ts",
|
|
110
|
-
"src/routes/-index.test.tsx",
|
|
111
129
|
"src/routes/__root.tsx",
|
|
130
|
+
"src/routes/index.test.tsx",
|
|
112
131
|
"src/routes/index.tsx",
|
|
113
132
|
"src/slices/shell.ts",
|
|
114
133
|
"src/store.test.tsx",
|
package/dist/cli.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { a as isBlockId, n as BLOCK_IDS, o as ownerOf, t as BLOCKS } from "./blocks-
|
|
2
|
+
import { a as isBlockId, n as BLOCK_IDS, o as ownerOf, t as BLOCKS } from "./blocks-D_-NWhwg.js";
|
|
3
3
|
import { a as substitute, i as renderRegions, n as assertNoMarkers, r as blockIdOf } from "./regions-B3r5LhXl.js";
|
|
4
4
|
import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync, writeFileSync } from "node:fs";
|
|
5
5
|
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
@@ -132,7 +132,9 @@ const evalCommand = (route, brief) => `npx --yes @keboola/ui-gen-bench eval --se
|
|
|
132
132
|
*
|
|
133
133
|
* - The overview's brief names the blocks that are actually on the page, because the score's
|
|
134
134
|
* brief-conformance axis marks down a page for missing what the brief promises.
|
|
135
|
-
* - A block with a `route` adds
|
|
135
|
+
* - A block with a `route` adds an invocation for it, briefed entirely by the block. The
|
|
136
|
+
* conformance axis is graded against this text, so a shared suffix here would grade every
|
|
137
|
+
* routed block against one page's qualities.
|
|
136
138
|
* - Not wired into `test`: it needs a model key, and a first run that fails on a missing
|
|
137
139
|
* credential is a worse welcome than an opt-in gate.
|
|
138
140
|
*/
|
|
@@ -143,7 +145,7 @@ const gateUiScript = (appTitle, blocks) => {
|
|
|
143
145
|
...clauses,
|
|
144
146
|
"no runtime or a11y errors"
|
|
145
147
|
].join(", ");
|
|
146
|
-
const extras = blocks.filter((id) => BLOCKS[id].route !== null).map((id) => evalCommand(BLOCKS[id].route ?? "/", `${appTitle} ${BLOCKS[id].brief}
|
|
148
|
+
const extras = blocks.filter((id) => BLOCKS[id].route !== null).map((id) => evalCommand(BLOCKS[id].route ?? "/", `${appTitle} ${BLOCKS[id].brief}`));
|
|
147
149
|
return [evalCommand("/", overview), ...extras].join(" && ");
|
|
148
150
|
};
|
|
149
151
|
const walk = (root, dir, out) => {
|
|
@@ -196,6 +198,29 @@ const writeManifest = (opts) => {
|
|
|
196
198
|
};
|
|
197
199
|
writeFileSync(join(targetDir, "package.json"), `${JSON.stringify(pkg, null, 2)}\n`);
|
|
198
200
|
};
|
|
201
|
+
/**
|
|
202
|
+
* `pnpm-workspace.yaml` for a standalone app.
|
|
203
|
+
*
|
|
204
|
+
* - It is the only file pnpm 11 reads `overrides` from; the `pnpm` field in `package.json` is
|
|
205
|
+
* ignored with a warning.
|
|
206
|
+
* - Written only outside a monorepo. Under `--workspace` the app inherits the root's file, and a
|
|
207
|
+
* nested one would declare a second workspace root inside the first.
|
|
208
|
+
*/
|
|
209
|
+
const writeWorkspaceConfig = (targetDir) => {
|
|
210
|
+
writeFileSync(join(targetDir, "pnpm-workspace.yaml"), `# pnpm reads \`overrides\` from this file only — the \`pnpm\` field in package.json is ignored.
|
|
211
|
+
overrides:
|
|
212
|
+
# jsdom's selector engine, held at the last release that does not re-enter itself.
|
|
213
|
+
#
|
|
214
|
+
# - 2.2.26 answers \`:modal\`, \`:fullscreen\` and \`:open\` through a \`matchesNative\` helper
|
|
215
|
+
# that falls back to the element's own \`matches\` — under jsdom that is nwsapi again, so the
|
|
216
|
+
# two recurse, and \`:modal\` doubles the work per level via its \`|| isFullscreen\` branch.
|
|
217
|
+
# - \`@floating-ui/dom\` probes \`:modal\` on every popper placement, so one dropdown or tooltip
|
|
218
|
+
# interaction costs ~9s instead of ~30ms and a userEvent test hits its timeout.
|
|
219
|
+
# - Remove the pin once nwsapi stops resolving its own \`matches\` back into itself:
|
|
220
|
+
# https://github.com/dperini/nwsapi
|
|
221
|
+
nwsapi: 2.2.25
|
|
222
|
+
`);
|
|
223
|
+
};
|
|
199
224
|
const scaffold = (opts) => {
|
|
200
225
|
const { templateDir, targetDir, appName, blocks, workspace } = opts;
|
|
201
226
|
if (existsSync(targetDir)) throw new Error(`${targetDir} already exists. Choose another name or remove the directory first.`);
|
|
@@ -231,6 +256,10 @@ const scaffold = (opts) => {
|
|
|
231
256
|
packageName
|
|
232
257
|
});
|
|
233
258
|
written.push("package.json");
|
|
259
|
+
if (!workspace) {
|
|
260
|
+
writeWorkspaceConfig(targetDir);
|
|
261
|
+
written.push("pnpm-workspace.yaml");
|
|
262
|
+
}
|
|
234
263
|
return {
|
|
235
264
|
path: targetDir,
|
|
236
265
|
appName,
|
|
@@ -287,7 +316,8 @@ const generateRouteTree = async (appDir) => {
|
|
|
287
316
|
await new Generator({
|
|
288
317
|
config: getConfig({
|
|
289
318
|
quoteStyle: "single",
|
|
290
|
-
semicolons: true
|
|
319
|
+
semicolons: true,
|
|
320
|
+
routeFileIgnorePattern: "\\.test\\.tsx?$"
|
|
291
321
|
}, appDir),
|
|
292
322
|
root: appDir
|
|
293
323
|
}).run();
|
package/dist/extract.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { a as isBlockId, i as SHELL_FILES, n as BLOCK_IDS, r as NOT_IN_TEMPLATE, t as BLOCKS } from "./blocks-
|
|
1
|
+
import { a as isBlockId, i as SHELL_FILES, n as BLOCK_IDS, r as NOT_IN_TEMPLATE, t as BLOCKS } from "./blocks-D_-NWhwg.js";
|
|
2
2
|
import { i as renderRegions, r as blockIdOf } from "./regions-B3r5LhXl.js";
|
|
3
3
|
import { r as scan, t as formatFindings } from "./sanitize-CKs8pAzZ.js";
|
|
4
4
|
import { cpSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
@@ -47,9 +47,20 @@ const decideRegion = (regionId) => {
|
|
|
47
47
|
if (id === null || !isBlockId(id)) throw new Error(`unknown region id "${regionId}" — expected "internal" or "block:<blockId>"`);
|
|
48
48
|
return "keep-with-markers";
|
|
49
49
|
};
|
|
50
|
-
/**
|
|
50
|
+
/**
|
|
51
|
+
* Per-file rewrites that turn this app's identity into placeholders.
|
|
52
|
+
*
|
|
53
|
+
* - Every rewrite has a matching assertion in `extract.test.ts`, and the sanitize scan fails
|
|
54
|
+
* the build on any surviving mention of this app's name — a rewrite that stops matching is
|
|
55
|
+
* a red build, not a scaffold that quietly names itself after the template.
|
|
56
|
+
* - Match structurally, not by the literal name. The name must not appear here: the packed
|
|
57
|
+
* tarball is scanned too, and a rewrite that spells its own needle makes the scanner match
|
|
58
|
+
* its own implementation.
|
|
59
|
+
*/
|
|
51
60
|
const parameterise = (file, text) => {
|
|
52
61
|
if (file === "index.html") return text.replace(/<title>[^<]*<\/title>/, "<title>__APP_TITLE__</title>");
|
|
62
|
+
if (file === "src/components/AppChrome.tsx") return text.replace(/(<Link to="\/">)[^<]*(<\/Link>)/, "$1__APP_TITLE__$2");
|
|
63
|
+
if (file === "src/auth/AuthScreen.tsx") return text.replace(/^const PRODUCT = '[^']*';$/m, "const PRODUCT = '__APP_TITLE__';");
|
|
53
64
|
if (file === "src/store.ts") return text.replaceAll(/'boilerplate\/([^']+)'/g, "'__APP_NAME__/$1'");
|
|
54
65
|
return text;
|
|
55
66
|
};
|
package/dist/template/AGENTS.md
CHANGED
|
@@ -60,13 +60,14 @@ src/
|
|
|
60
60
|
routeTree.gen.ts # Generated from src/routes/** by the Vite plugin
|
|
61
61
|
nav.ts # The rail's destinations and the active-match rule
|
|
62
62
|
overview-sections.tsx # The ordered registry the overview renders
|
|
63
|
+
overview-hooks.ts # useOverview, AbortSignal threaded
|
|
64
|
+
overview-fixtures.ts # In-memory overview stub — swap for a real client call
|
|
63
65
|
store.ts # The Zustand store: storage key, partialize, merge guard
|
|
64
66
|
slices/shell.ts # Sidebar mode + help visibility
|
|
65
67
|
components/AppChrome.tsx # AppShell + Navbar + Sidebar
|
|
66
|
-
routes/__root.tsx # Root layout
|
|
68
|
+
routes/__root.tsx # Root layout — and which routes get the app chrome
|
|
67
69
|
routes/index.tsx # / — the overview
|
|
68
|
-
routes
|
|
69
|
-
routes/-fixtures.ts # In-memory overview stub — swap for a real client call
|
|
70
|
+
routes/index.test.tsx # The overview's branches — a route's test sits with the route
|
|
70
71
|
```
|
|
71
72
|
<!-- #region block:kpi -->
|
|
72
73
|
- `components/KpiCard.tsx`, `sections/kpi.tsx` — the KPI row.
|
|
@@ -78,21 +79,34 @@ src/
|
|
|
78
79
|
- `recipes/DataGrid.tsx`, `sections/grid.tsx` — the editable grid.
|
|
79
80
|
<!-- #endregion block:grid -->
|
|
80
81
|
<!-- #region block:crud -->
|
|
81
|
-
- `routes/tokens/**`
|
|
82
|
-
|
|
83
|
-
the fixture rows are labels only.
|
|
82
|
+
- `routes/tokens/**` (`index.tsx`, `$id.tsx`, `new.tsx`) and `tokens/**` (`hooks.ts`,
|
|
83
|
+
`fixtures.ts`, `types.ts`, `nav.ts`) — the CRUD slice. **The entity is a placeholder.**
|
|
84
|
+
Rename it to yours; the fixture rows are labels only.
|
|
84
85
|
<!-- #endregion block:crud -->
|
|
85
86
|
<!-- #region block:assistant -->
|
|
86
87
|
- `components/AssistantPanel.tsx`, `AssistantToggle.tsx`, `HomePrompt.tsx`,
|
|
87
88
|
`SelectionFloater.tsx`, `slices/assistant.ts` — the assistant. `slices/assistant.ts`
|
|
88
89
|
answers with a canned reply; swap that branch for a real request.
|
|
89
90
|
<!-- #endregion block:assistant -->
|
|
91
|
+
<!-- #region block:auth -->
|
|
92
|
+
- `auth/AuthScreen.tsx`, `auth/states.tsx`, `auth/session.ts`, `routes/signed-out.tsx` — the
|
|
93
|
+
signed-out screen. **Presentational: it reaches no network.** Wiring it up is two edits —
|
|
94
|
+
the form's `onSubmit` in `states.tsx`, and where the state comes from. `routes/__root.tsx`
|
|
95
|
+
drops the app chrome for this route, so a visitor with no session sees no rail; a real app
|
|
96
|
+
usually goes further and renders the screen ahead of the router, which `AuthScreen` supports
|
|
97
|
+
by carrying its own `tailwindcss-preflight` scope.
|
|
98
|
+
<!-- #endregion block:auth -->
|
|
90
99
|
|
|
91
100
|
## Adding a route
|
|
92
101
|
|
|
93
102
|
Drop a file in `src/routes/`. The Vite plugin regenerates `routeTree.gen.ts` on the next
|
|
94
103
|
`pnpm dev` or `pnpm build`; nothing needs registering by hand.
|
|
95
104
|
|
|
105
|
+
`src/routes/` holds route files and their tests, nothing else. A route's hooks, fixtures,
|
|
106
|
+
types and components live in a domain directory beside it (`src/reports/`), the way
|
|
107
|
+
`src/auth/` and `src/tokens/` do — a non-route module under `src/routes/` is a route the
|
|
108
|
+
generator has to be told to ignore.
|
|
109
|
+
|
|
96
110
|
```tsx
|
|
97
111
|
import { createFileRoute } from '@tanstack/react-router';
|
|
98
112
|
|
package/dist/template/README.md
CHANGED
|
@@ -47,6 +47,11 @@ fails for a missing credential is a worse welcome than an opt-in gate.
|
|
|
47
47
|
- A docked assistant rail on every route, a composer on the overview, and
|
|
48
48
|
explain-on-selection. All three share one transcript.
|
|
49
49
|
<!-- #endregion block:assistant -->
|
|
50
|
+
<!-- #region block:auth -->
|
|
51
|
+
- **`/signed-out`** — the pre-project screen, rendered without the app chrome: brand lockup,
|
|
52
|
+
one centred card, a colour-scheme control, and the restoring, expired and unreachable
|
|
53
|
+
states. Presentational: the form submits nowhere, and `?state=` stands in for your session.
|
|
54
|
+
<!-- #endregion block:auth -->
|
|
50
55
|
|
|
51
56
|
Every data call is an in-memory fixture that honours an `AbortSignal`. Replacing a fixture with
|
|
52
57
|
a real `@keboola/api-client` call is a drop-in — the query hooks already thread `signal`.
|
|
@@ -19,10 +19,8 @@ export default defineConfig({
|
|
|
19
19
|
},
|
|
20
20
|
},
|
|
21
21
|
jsPlugins: [
|
|
22
|
-
// Local design-system + brand-safety lint rules
|
|
23
|
-
//
|
|
24
|
-
// `design/<rule>` in lint output. Boilerplate is the canonical scaffold
|
|
25
|
-
// cloned by `pnpm scaffold:app`, so every generated app inherits these.
|
|
22
|
+
// Local design-system + brand-safety lint rules. Aliased to `design` so rule
|
|
23
|
+
// IDs read `design/<rule>` in lint output.
|
|
26
24
|
{ name: 'design', specifier: '@keboola/eslint-plugin-design' },
|
|
27
25
|
],
|
|
28
26
|
rules: {
|
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
"type-check": "tsc"
|
|
16
16
|
},
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"@keboola/brand-registry": "^1.9.
|
|
19
|
-
"@keboola/design": "^11.0
|
|
18
|
+
"@keboola/brand-registry": "^1.9.4",
|
|
19
|
+
"@keboola/design": "^11.5.0",
|
|
20
20
|
"@tanstack/react-form": "^1.29.1",
|
|
21
21
|
"@tanstack/react-query": "^5.96.2",
|
|
22
22
|
"@tanstack/react-router": "^1.120.4",
|
|
@@ -28,9 +28,9 @@
|
|
|
28
28
|
"zustand": "^5.0.8"
|
|
29
29
|
},
|
|
30
30
|
"devDependencies": {
|
|
31
|
-
"@keboola/eslint-plugin-design": "^0.4.
|
|
32
|
-
"@keboola/oxlint-config": "^10.0.
|
|
33
|
-
"@keboola/tsconfig": "^0.2.
|
|
31
|
+
"@keboola/eslint-plugin-design": "^0.4.3",
|
|
32
|
+
"@keboola/oxlint-config": "^10.0.2",
|
|
33
|
+
"@keboola/tsconfig": "^0.2.1",
|
|
34
34
|
"@tailwindcss/vite": "^4.3.3",
|
|
35
35
|
"@tanstack/react-query-devtools": "^5.96.2",
|
|
36
36
|
"@testing-library/jest-dom": "^6.8.0",
|
|
@@ -43,8 +43,8 @@
|
|
|
43
43
|
"@vitejs/plugin-react": "^6.0.0",
|
|
44
44
|
"eslint-plugin-better-tailwindcss": "^4.7.0",
|
|
45
45
|
"jsdom": "^26.1.0",
|
|
46
|
-
"oxfmt": "^0.
|
|
47
|
-
"oxlint": "^1.
|
|
46
|
+
"oxfmt": "^0.66.0",
|
|
47
|
+
"oxlint": "^1.81.0",
|
|
48
48
|
"tailwindcss": "^4.3.3",
|
|
49
49
|
"typescript": "npm:@typescript/typescript6@^6.0.2",
|
|
50
50
|
"vite": "^8.1.5",
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import { keboola } from '@keboola/brand-registry';
|
|
5
|
+
import { Button, ThemeProvider } from '@keboola/design';
|
|
6
|
+
|
|
7
|
+
import { AuthScreen } from './AuthScreen';
|
|
8
|
+
|
|
9
|
+
// The provider is required, not scenery: the header's `ColorModeToggle` reads the scheme through
|
|
10
|
+
// `useTheme`, and `BrandLogo` renders nothing without a brand registered.
|
|
11
|
+
const renderScreen = () =>
|
|
12
|
+
render(
|
|
13
|
+
<ThemeProvider brands={[keboola]} defaultBrandId={keboola.id} defaultScheme="light">
|
|
14
|
+
<AuthScreen title="Sign in">
|
|
15
|
+
<Button>Continue</Button>
|
|
16
|
+
</AuthScreen>
|
|
17
|
+
</ThemeProvider>,
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
describe('<AuthScreen />', () => {
|
|
21
|
+
it('carries the page heading at h1', () => {
|
|
22
|
+
renderScreen();
|
|
23
|
+
|
|
24
|
+
expect(screen.getByRole('heading', { level: 1, name: 'Sign in' })).toBeInTheDocument();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('keeps the colour-scheme control inside the header landmark', () => {
|
|
28
|
+
// The control is positioned away from the lockup, so its landmark is what can silently
|
|
29
|
+
// regress — outside one, axe reports it as unreachable by landmark navigation.
|
|
30
|
+
renderScreen();
|
|
31
|
+
|
|
32
|
+
expect(screen.getByRole('banner')).toContainElement(
|
|
33
|
+
screen.getByRole('button', { name: /^Color mode:/ }),
|
|
34
|
+
);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('renders the card body inside the main landmark', () => {
|
|
38
|
+
renderScreen();
|
|
39
|
+
|
|
40
|
+
expect(screen.getByRole('main')).toContainElement(
|
|
41
|
+
screen.getByRole('button', { name: 'Continue' }),
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
import { BrandLogo, Card } from '@keboola/design';
|
|
4
|
+
|
|
5
|
+
import { ColorModeToggle } from '../components/ColorModeToggle';
|
|
6
|
+
|
|
7
|
+
// The product name beside the brand mark.
|
|
8
|
+
//
|
|
9
|
+
// - `create-keboola-app` rewrites this literal to the generated app's title while extracting
|
|
10
|
+
// the template (`parameterise` in the package's `extract.ts`), so it has to stay a top-level
|
|
11
|
+
// single-quoted `const`.
|
|
12
|
+
const PRODUCT = '__APP_TITLE__';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The chrome every pre-project screen shares: brand lockup, one centred card, no navigation.
|
|
16
|
+
*
|
|
17
|
+
* - Renders *instead of* `AppChrome`, not inside it — `routes/__root.tsx` picks one from
|
|
18
|
+
* `useIsAuthRoute`. A visitor with no session gets no rail, no breadcrumb and no history.
|
|
19
|
+
* - `tailwindcss-preflight` and `text-foreground` are set here rather than inherited. A real app
|
|
20
|
+
* moves this screen ahead of the router, because the session decides whether the router mounts
|
|
21
|
+
* at all, and then nothing above it sets either: without the preflight scope the card's
|
|
22
|
+
* controls pick up UA chrome, and without the ink `BrandLogo`'s inline SVG inherits
|
|
23
|
+
* `currentColor` and goes near-black on the dark page.
|
|
24
|
+
* - `header` and `main` are landmarks, not decoration. Outside one, axe reports these controls as
|
|
25
|
+
* unreachable by landmark navigation — which is why the colour-scheme control sits inside
|
|
26
|
+
* `header` even though it is positioned away from the lockup.
|
|
27
|
+
* - `Card.Title as="h1"` — the card carries the page's only heading, and the default `h3` opens
|
|
28
|
+
* the outline three levels down.
|
|
29
|
+
* - `variant="mark"` beside the product name as text. The wordmark already spells the brand, so
|
|
30
|
+
* pairing it with a product name states the brand twice; the mark reads as one lockup. It also
|
|
31
|
+
* degrades in the right order — `BrandLogo` falls back to the wordmark for a brand that ships
|
|
32
|
+
* no mark and returns `null` for one that ships neither, which is why the name is text and not
|
|
33
|
+
* part of the image.
|
|
34
|
+
* - `h-8`, not the navbar's `h-6`: with no chrome around it the lockup is the only thing naming
|
|
35
|
+
* the product, and at bar size it reads as chrome that lost its bar.
|
|
36
|
+
* - Every pre-project state reuses this chrome — see `AUTH_STATES` in `states.tsx` — so the
|
|
37
|
+
* common path does not jump when one state replaces another.
|
|
38
|
+
*/
|
|
39
|
+
export function AuthScreen({ title, children }: { title: string; children: ReactNode }) {
|
|
40
|
+
return (
|
|
41
|
+
<div className="tailwindcss-preflight tw:relative tw:flex tw:min-h-screen tw:flex-col tw:items-center tw:justify-center tw:gap-6 tw:bg-muted tw:px-4 tw:py-12 tw:text-foreground">
|
|
42
|
+
<header>
|
|
43
|
+
<div className="tw:flex tw:items-center tw:gap-3">
|
|
44
|
+
<BrandLogo variant="mark" className="tw:h-8" />
|
|
45
|
+
<span className="tw:text-lg tw:font-semibold">{PRODUCT}</span>
|
|
46
|
+
</div>
|
|
47
|
+
|
|
48
|
+
<div className="tw:absolute tw:top-4 tw:right-4">
|
|
49
|
+
<ColorModeToggle />
|
|
50
|
+
</div>
|
|
51
|
+
</header>
|
|
52
|
+
|
|
53
|
+
<main className="tw:w-full tw:max-w-md">
|
|
54
|
+
<Card>
|
|
55
|
+
<Card.Header layout="stack">
|
|
56
|
+
<Card.Title as="h1">{title}</Card.Title>
|
|
57
|
+
</Card.Header>
|
|
58
|
+
<Card.Content className="tw:flex tw:flex-col tw:gap-4">{children}</Card.Content>
|
|
59
|
+
</Card>
|
|
60
|
+
</main>
|
|
61
|
+
</div>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react';
|
|
2
|
+
import userEvent from '@testing-library/user-event';
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
4
|
+
|
|
5
|
+
import type { AuthState } from './session';
|
|
6
|
+
import { AUTH_STATES, isAuthState } from './session';
|
|
7
|
+
|
|
8
|
+
const renderState = (state: AuthState, onRetry = () => {}) => {
|
|
9
|
+
const { body: Body } = AUTH_STATES[state];
|
|
10
|
+
return render(<Body onRetry={onRetry} />);
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
describe('AUTH_STATES', () => {
|
|
14
|
+
it('asks for a credential when signed out', () => {
|
|
15
|
+
renderState('signed-out');
|
|
16
|
+
|
|
17
|
+
expect(screen.getByLabelText(/Email/)).toBeInTheDocument();
|
|
18
|
+
expect(screen.getByLabelText(/Password/)).toBeInTheDocument();
|
|
19
|
+
expect(screen.getByRole('button', { name: 'Sign in' })).toBeInTheDocument();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('announces the wait while restoring', () => {
|
|
23
|
+
renderState('restoring');
|
|
24
|
+
|
|
25
|
+
// The wait resolves without the reader acting, so the region has to be live.
|
|
26
|
+
const message = screen.getByText(/still signed in/);
|
|
27
|
+
expect(message.closest('[aria-live="polite"]')).not.toBeNull();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it.each([
|
|
31
|
+
['expired', 'Sign in again'],
|
|
32
|
+
['unavailable', 'Try again'],
|
|
33
|
+
] as const)('lets the reader retry from %s', async (state, action) => {
|
|
34
|
+
const user = userEvent.setup();
|
|
35
|
+
const onRetry = vi.fn();
|
|
36
|
+
renderState(state, onRetry);
|
|
37
|
+
|
|
38
|
+
await user.click(screen.getByRole('button', { name: action }));
|
|
39
|
+
|
|
40
|
+
expect(onRetry).toHaveBeenCalledOnce();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('distinguishes an expired session from an unreachable service', () => {
|
|
44
|
+
// Telling someone their session expired while the network is down sends them to re-enter a
|
|
45
|
+
// password that would have worked, so the two copies must not converge.
|
|
46
|
+
const { unmount } = renderState('expired');
|
|
47
|
+
expect(screen.getByText(/session ended/)).toBeInTheDocument();
|
|
48
|
+
unmount();
|
|
49
|
+
|
|
50
|
+
renderState('unavailable');
|
|
51
|
+
expect(screen.getByText(/could not reach the service/)).toBeInTheDocument();
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('isAuthState', () => {
|
|
56
|
+
it('accepts every declared state and nothing else', () => {
|
|
57
|
+
for (const state of Object.keys(AUTH_STATES)) expect(isAuthState(state)).toBe(true);
|
|
58
|
+
|
|
59
|
+
expect(isAuthState('signedout')).toBe(false);
|
|
60
|
+
expect(isAuthState(undefined)).toBe(false);
|
|
61
|
+
// `Object.hasOwn` rather than `in`, so a prototype key is not a state.
|
|
62
|
+
expect(isAuthState('toString')).toBe(false);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { ComponentType } from 'react';
|
|
2
|
+
import { useRouterState } from '@tanstack/react-router';
|
|
3
|
+
|
|
4
|
+
import { Expired, Restoring, SignInForm, Unavailable } from './states';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The signed-out screen's path.
|
|
8
|
+
*
|
|
9
|
+
* - Spelled again as a literal in `createFileRoute`. TanStack's generator reads that argument as
|
|
10
|
+
* source text, so it cannot be a reference.
|
|
11
|
+
*/
|
|
12
|
+
export const AUTH_ROUTE = '/signed-out';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Whether the router is on the signed-out screen.
|
|
16
|
+
*
|
|
17
|
+
* - The root layout asks, because it is the only component the chromed and chromeless branches
|
|
18
|
+
* share.
|
|
19
|
+
* - `select` returns the boolean, not the pathname, so navigating between two chromed routes does
|
|
20
|
+
* not re-render the root.
|
|
21
|
+
* - A real app asks its session instead. This screen belongs to *having no session*, not to a path
|
|
22
|
+
* a visitor can type — the path exists so the screen is reachable without a backend.
|
|
23
|
+
*/
|
|
24
|
+
export const useIsAuthRoute = (): boolean =>
|
|
25
|
+
useRouterState({ select: (state) => state.location.pathname === AUTH_ROUTE });
|
|
26
|
+
|
|
27
|
+
/** One pre-project state: the card's heading, and the body that goes under it. */
|
|
28
|
+
type AuthStateView = {
|
|
29
|
+
title: string;
|
|
30
|
+
/** Handed `onRetry`, which returns the screen to the sign-in form. States that offer no retry
|
|
31
|
+
* simply ignore it. */
|
|
32
|
+
body: ComponentType<{ onRetry: () => void }>;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The pre-project states, keyed by the session status that produces each.
|
|
37
|
+
*
|
|
38
|
+
* - Four, not one: a signed-out screen that models only "signed out" leaves the other three to be
|
|
39
|
+
* invented per app, and they are the ones that get a11y and copy wrong.
|
|
40
|
+
* - Every state renders inside the same `AuthScreen`, so the common path does not jump when one
|
|
41
|
+
* replaces another.
|
|
42
|
+
* - The keys are the contract — `AuthState` is derived from them, so adding a state here is all it
|
|
43
|
+
* takes for the route to accept and render it.
|
|
44
|
+
* - Components, not render functions, so this module holds no JSX and stays the one place the
|
|
45
|
+
* route's search param is validated.
|
|
46
|
+
*/
|
|
47
|
+
export const AUTH_STATES = {
|
|
48
|
+
'signed-out': { title: 'Sign in', body: SignInForm },
|
|
49
|
+
restoring: { title: 'Restoring your session', body: Restoring },
|
|
50
|
+
expired: { title: 'Session expired', body: Expired },
|
|
51
|
+
unavailable: { title: 'Cannot reach the service', body: Unavailable },
|
|
52
|
+
} as const satisfies Readonly<Record<string, AuthStateView>>;
|
|
53
|
+
|
|
54
|
+
export type AuthState = keyof typeof AUTH_STATES;
|
|
55
|
+
|
|
56
|
+
/** Narrows an unvalidated search param. Anything else is not a state, including `undefined`. */
|
|
57
|
+
export const isAuthState = (value: unknown): value is AuthState =>
|
|
58
|
+
typeof value === 'string' && Object.hasOwn(AUTH_STATES, value);
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { Alert, Button, DotsLoader, useAppForm } from '@keboola/design';
|
|
2
|
+
|
|
3
|
+
/** What every state's body is handed. `onRetry` returns the screen to the sign-in form. */
|
|
4
|
+
type AuthStateProps = { onRetry: () => void };
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The credential form — the one part that legitimately differs per app.
|
|
8
|
+
*
|
|
9
|
+
* - Presentational on purpose: `onSubmit` reaches no network, because only the app knows what it
|
|
10
|
+
* authenticates against (password, PAT, OAuth) and what to do with the answer. Replace the body
|
|
11
|
+
* of `onSubmit`; nothing else in this directory has to move.
|
|
12
|
+
* - `useAppForm` rather than a bare `useForm`: the connectors carry the label, the required marker
|
|
13
|
+
* and the error slot, so the field markup does not restate them.
|
|
14
|
+
* - `w-full` on the submit: it is the card's only action, so a content-width button reads as one
|
|
15
|
+
* option among several.
|
|
16
|
+
*/
|
|
17
|
+
export const SignInForm = () => {
|
|
18
|
+
const form = useAppForm({
|
|
19
|
+
defaultValues: { email: '', password: '' },
|
|
20
|
+
onSubmit: async ({ value }) => {
|
|
21
|
+
await Promise.resolve(value);
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<form.AppForm>
|
|
27
|
+
<form.FormBody>
|
|
28
|
+
<form.AppField name="email">
|
|
29
|
+
{(field) => <field.TextField label="Email" type="email" required />}
|
|
30
|
+
</form.AppField>
|
|
31
|
+
<form.AppField name="password">
|
|
32
|
+
{(field) => <field.PasswordField label="Password" required />}
|
|
33
|
+
</form.AppField>
|
|
34
|
+
<form.SubmitButton className="tw:w-full">Sign in</form.SubmitButton>
|
|
35
|
+
</form.FormBody>
|
|
36
|
+
</form.AppForm>
|
|
37
|
+
);
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A persisted session being rehydrated.
|
|
42
|
+
*
|
|
43
|
+
* `aria-live="polite"`: the wait resolves without the reader acting, so the outcome has to be
|
|
44
|
+
* announced rather than found.
|
|
45
|
+
*/
|
|
46
|
+
export const Restoring = () => (
|
|
47
|
+
<div
|
|
48
|
+
className="tw:flex tw:items-center tw:gap-3 tw:text-sm tw:text-muted-foreground"
|
|
49
|
+
aria-live="polite"
|
|
50
|
+
>
|
|
51
|
+
<DotsLoader />
|
|
52
|
+
Checking whether you are still signed in.
|
|
53
|
+
</div>
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* A credential that was accepted once and is not any more.
|
|
58
|
+
*
|
|
59
|
+
* `warning`, not `error`: nothing malfunctioned and there is nothing to report — the session ran
|
|
60
|
+
* out, and the fix is one click away.
|
|
61
|
+
*/
|
|
62
|
+
export const Expired = ({ onRetry }: AuthStateProps) => (
|
|
63
|
+
<>
|
|
64
|
+
<Alert variant="warning">
|
|
65
|
+
Your session ended. Sign in again to pick up where you left off.
|
|
66
|
+
</Alert>
|
|
67
|
+
<Button variant="primary" className="tw:w-full" onClick={onRetry}>
|
|
68
|
+
Sign in again
|
|
69
|
+
</Button>
|
|
70
|
+
</>
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The service could not be reached, so the credential was never judged.
|
|
75
|
+
*
|
|
76
|
+
* Distinct from `expired` on purpose: telling someone their session expired while the network is
|
|
77
|
+
* down sends them to re-enter a password that would have worked.
|
|
78
|
+
*/
|
|
79
|
+
export const Unavailable = ({ onRetry }: AuthStateProps) => (
|
|
80
|
+
<>
|
|
81
|
+
<Alert variant="error">
|
|
82
|
+
We could not reach the service. Your credentials are fine — the request never got an answer.
|
|
83
|
+
</Alert>
|
|
84
|
+
<Button variant="outline" className="tw:w-full" onClick={onRetry}>
|
|
85
|
+
Try again
|
|
86
|
+
</Button>
|
|
87
|
+
</>
|
|
88
|
+
);
|
|
@@ -89,7 +89,7 @@ export function AppChrome({ children }: { children: ReactNode }) {
|
|
|
89
89
|
<Navbar.Menu marker="forward-slash">
|
|
90
90
|
<Navbar.MenuItem>
|
|
91
91
|
<Navbar.MenuButton asChild>
|
|
92
|
-
<Link to="/">
|
|
92
|
+
<Link to="/">__APP_TITLE__</Link>
|
|
93
93
|
</Navbar.MenuButton>
|
|
94
94
|
</Navbar.MenuItem>
|
|
95
95
|
</Navbar.Menu>
|
package/dist/template/src/nav.ts
CHANGED
|
@@ -3,7 +3,7 @@ import type { ComponentType } from 'react';
|
|
|
3
3
|
import { LayoutDashboard } from '@keboola/design/icons';
|
|
4
4
|
|
|
5
5
|
// #region block:crud
|
|
6
|
-
import { tokensNavItem } from './
|
|
6
|
+
import { tokensNavItem } from './tokens/nav';
|
|
7
7
|
// #endregion block:crud
|
|
8
8
|
|
|
9
9
|
export type NavItem = { to: string; label: string; icon: ComponentType };
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// In-memory stand-in for a real overview endpoint. Swap `fetchOverview` for the
|
|
2
2
|
// api-client call (e.g. `api.metrics.overview(signal)`) and delete this file.
|
|
3
3
|
// The call takes an AbortSignal and honors it, so the query hook threads
|
|
4
|
-
// cancellation exactly as it would against a real client — see -hooks.ts.
|
|
4
|
+
// cancellation exactly as it would against a real client — see overview-hooks.ts.
|
|
5
5
|
|
|
6
6
|
export type Kpi = {
|
|
7
7
|
label: string;
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import type { ReactNode } from 'react';
|
|
2
2
|
|
|
3
|
-
import type { Overview } from './routes/-fixtures';
|
|
4
3
|
// #region block:chart
|
|
5
4
|
import { chartSection } from './sections/chart';
|
|
6
5
|
// #endregion block:chart
|
|
@@ -10,6 +9,10 @@ import { gridSection } from './sections/grid';
|
|
|
10
9
|
// #region block:kpi
|
|
11
10
|
import { kpiSection } from './sections/kpi';
|
|
12
11
|
// #endregion block:kpi
|
|
12
|
+
// `simple-import-sort` orders a sibling path after every nested one, so this import belongs
|
|
13
|
+
// past the final section region rather than inside it — the shell needs it whether or not a
|
|
14
|
+
// scaffold keeps that block.
|
|
15
|
+
import type { Overview } from './overview-fixtures';
|
|
13
16
|
|
|
14
17
|
/**
|
|
15
18
|
* One block's contribution to the overview.
|
|
@@ -2,7 +2,7 @@ import { render, screen } from '@testing-library/react';
|
|
|
2
2
|
import userEvent from '@testing-library/user-event';
|
|
3
3
|
import { expect, it } from 'vitest';
|
|
4
4
|
|
|
5
|
-
import type { BudgetRow } from '../
|
|
5
|
+
import type { BudgetRow } from '../overview-fixtures';
|
|
6
6
|
|
|
7
7
|
import { CreditBudgetGrid } from './DataGrid';
|
|
8
8
|
|
|
@@ -8,8 +8,8 @@ import {
|
|
|
8
8
|
|
|
9
9
|
import { NumberInput, Table } from '@keboola/design';
|
|
10
10
|
|
|
11
|
-
import type { BudgetRow } from '../
|
|
12
|
-
import { BUDGET_MONTHS } from '../
|
|
11
|
+
import type { BudgetRow } from '../overview-fixtures';
|
|
12
|
+
import { BUDGET_MONTHS } from '../overview-fixtures';
|
|
13
13
|
|
|
14
14
|
// Instantiates the DS editable-grid recipe (`Table.DataGridRecipe` story,
|
|
15
15
|
// `EditableDataGrid`): TanStack Table v8 (headless) for the column/row model,
|
|
@@ -2,6 +2,9 @@ import { createRootRoute, Outlet } from '@tanstack/react-router';
|
|
|
2
2
|
|
|
3
3
|
import { Toaster } from '@keboola/design';
|
|
4
4
|
|
|
5
|
+
// #region block:auth
|
|
6
|
+
import { useIsAuthRoute } from '../auth/session';
|
|
7
|
+
// #endregion block:auth
|
|
5
8
|
import { AppChrome } from '../components/AppChrome';
|
|
6
9
|
|
|
7
10
|
export const Route = createRootRoute({
|
|
@@ -9,6 +12,31 @@ export const Route = createRootRoute({
|
|
|
9
12
|
});
|
|
10
13
|
|
|
11
14
|
function RootLayout() {
|
|
15
|
+
// #region block:auth
|
|
16
|
+
// The signed-out screen replaces the chrome instead of rendering inside it.
|
|
17
|
+
//
|
|
18
|
+
// - A visitor with no session must see no rail, no breadcrumb and no history.
|
|
19
|
+
// - Nesting it would also give the page a second `header`/`main` pair inside AppChrome's, which
|
|
20
|
+
// axe reports as duplicate landmarks.
|
|
21
|
+
// - Decided here because the root layout is the only component both branches share, and
|
|
22
|
+
// TanStack nests every file route under it.
|
|
23
|
+
// - The preflight scope is restated even though `AuthScreen` carries its own: that scope is a
|
|
24
|
+
// class on an element, so it does not reach `Toaster` beside it, and design's preflight is
|
|
25
|
+
// opt-in. Measured outside it, a bare `button` gets a 2px outset border and Arial 13.3px.
|
|
26
|
+
// - The blank line above the region's closing marker is inside the region on purpose. Left
|
|
27
|
+
// outside, it becomes the whole body of an app generated without this block, and that app's
|
|
28
|
+
// `oxfmt --check` fails on a file nobody wrote.
|
|
29
|
+
const isAuthRoute = useIsAuthRoute();
|
|
30
|
+
if (isAuthRoute) {
|
|
31
|
+
return (
|
|
32
|
+
<div className="tailwindcss-preflight">
|
|
33
|
+
<Outlet />
|
|
34
|
+
<Toaster />
|
|
35
|
+
</div>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// #endregion block:auth
|
|
12
40
|
return (
|
|
13
41
|
<div className="tailwindcss-preflight">
|
|
14
42
|
<AppChrome>
|
|
@@ -6,9 +6,9 @@ import userEvent from '@testing-library/user-event';
|
|
|
6
6
|
// #endregion block:assistant
|
|
7
7
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
8
8
|
|
|
9
|
+
import * as fixtures from '../overview-fixtures';
|
|
9
10
|
import { useAppStore } from '../store';
|
|
10
11
|
|
|
11
|
-
import * as fixtures from './-fixtures';
|
|
12
12
|
import { HomePage } from './index';
|
|
13
13
|
|
|
14
14
|
const renderPage = () => {
|
|
@@ -7,12 +7,11 @@ import { ErrorState, PageHeader, PageLayout } from '@keboola/design';
|
|
|
7
7
|
import { HomePrompt } from '../components/HomePrompt';
|
|
8
8
|
import { SelectionFloater } from '../components/SelectionFloater';
|
|
9
9
|
// #endregion block:assistant
|
|
10
|
+
import type { Overview } from '../overview-fixtures';
|
|
11
|
+
import { useOverview } from '../overview-hooks';
|
|
10
12
|
import { OVERVIEW_SECTIONS } from '../overview-sections';
|
|
11
13
|
import { useAppStore } from '../store';
|
|
12
14
|
|
|
13
|
-
import type { Overview } from './-fixtures';
|
|
14
|
-
import { useOverview } from './-hooks';
|
|
15
|
-
|
|
16
15
|
export const Route = createFileRoute('/')({
|
|
17
16
|
component: HomePage,
|
|
18
17
|
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { createMemoryHistory, createRouter, RouterProvider } from '@tanstack/react-router';
|
|
2
|
+
import { render, screen } from '@testing-library/react';
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
|
|
5
|
+
import { keboola } from '@keboola/brand-registry';
|
|
6
|
+
import { ThemeProvider } from '@keboola/design';
|
|
7
|
+
|
|
8
|
+
import { routeTree } from '../routeTree.gen';
|
|
9
|
+
|
|
10
|
+
// jsdom implements no scrolling, and Router's scroll restoration calls this on every navigation —
|
|
11
|
+
// unstubbed it floods the run with `Not implemented: window.scrollTo`.
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
vi.stubGlobal('scrollTo', vi.fn());
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
vi.unstubAllGlobals();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
// The real tree, not a stub: the behaviour under test is the root layout choosing between
|
|
21
|
+
// AppChrome and the bare outlet, and a hand-built tree would not exercise that choice.
|
|
22
|
+
const renderAt = (path: string) => {
|
|
23
|
+
const router = createRouter({
|
|
24
|
+
routeTree,
|
|
25
|
+
history: createMemoryHistory({ initialEntries: [path] }),
|
|
26
|
+
});
|
|
27
|
+
return render(
|
|
28
|
+
<ThemeProvider brands={[keboola]} defaultBrandId={keboola.id} defaultScheme="light">
|
|
29
|
+
<RouterProvider router={router} />
|
|
30
|
+
</ThemeProvider>,
|
|
31
|
+
);
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
describe('/signed-out', () => {
|
|
35
|
+
it('renders without the app chrome', async () => {
|
|
36
|
+
renderAt('/signed-out');
|
|
37
|
+
|
|
38
|
+
expect(await screen.findByRole('heading', { level: 1, name: 'Sign in' })).toBeInTheDocument();
|
|
39
|
+
// The rail is the tell: a signed-out visitor must reach no destination in the app.
|
|
40
|
+
expect(screen.queryByRole('navigation', { name: 'Sections' })).not.toBeInTheDocument();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('picks the state named by the search param', async () => {
|
|
44
|
+
renderAt('/signed-out?state=expired');
|
|
45
|
+
|
|
46
|
+
expect(
|
|
47
|
+
await screen.findByRole('heading', { level: 1, name: 'Session expired' }),
|
|
48
|
+
).toBeInTheDocument();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('falls back to the sign-in form for a state it does not know', async () => {
|
|
52
|
+
renderAt('/signed-out?state=nonsense');
|
|
53
|
+
|
|
54
|
+
expect(await screen.findByRole('heading', { level: 1, name: 'Sign in' })).toBeInTheDocument();
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
|
2
|
+
|
|
3
|
+
import { AuthScreen } from '../auth/AuthScreen';
|
|
4
|
+
import type { AuthState } from '../auth/session';
|
|
5
|
+
import { AUTH_ROUTE, AUTH_STATES, isAuthState } from '../auth/session';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `/signed-out` — the pre-project screen.
|
|
9
|
+
*
|
|
10
|
+
* - `?state=` stands in for the app's session status, so all four states are reachable in a
|
|
11
|
+
* browser and in the UI gate without a backend. A real app drops the search param and picks the
|
|
12
|
+
* state from its session hook; nothing in `auth/` changes when it does.
|
|
13
|
+
* - `validateSearch` narrows by hand rather than through a schema. One optional enum does not
|
|
14
|
+
* need a validation library, and the block deliberately carries no dependency of its own.
|
|
15
|
+
* - An unknown or missing `state` falls back to the sign-in form rather than throwing: this is the
|
|
16
|
+
* screen a visitor lands on with no session, so it has to render whatever the URL says.
|
|
17
|
+
*/
|
|
18
|
+
export const Route = createFileRoute('/signed-out')({
|
|
19
|
+
validateSearch: (search: Record<string, unknown>): { state: AuthState } => ({
|
|
20
|
+
state: isAuthState(search.state) ? search.state : 'signed-out',
|
|
21
|
+
}),
|
|
22
|
+
component: SignedOutPage,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
function SignedOutPage() {
|
|
26
|
+
const { state } = Route.useSearch();
|
|
27
|
+
const navigate = useNavigate();
|
|
28
|
+
const { title, body: Body } = AUTH_STATES[state];
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<AuthScreen title={title}>
|
|
32
|
+
<Body onRetry={() => void navigate({ to: AUTH_ROUTE, search: { state: 'signed-out' } })} />
|
|
33
|
+
</AuthScreen>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
@@ -2,8 +2,8 @@ import { createFileRoute, Link } from '@tanstack/react-router';
|
|
|
2
2
|
|
|
3
3
|
import { Breadcrumb, Button, ErrorState, PageHeader, PageLayout, Skeleton } from '@keboola/design';
|
|
4
4
|
|
|
5
|
-
import { useToken } from '
|
|
6
|
-
import type { Token } from '
|
|
5
|
+
import { useToken } from '../../tokens/hooks';
|
|
6
|
+
import type { Token } from '../../tokens/types';
|
|
7
7
|
|
|
8
8
|
export const Route = createFileRoute('/tokens/$id')({
|
|
9
9
|
component: TokenDetailPage,
|
|
@@ -3,7 +3,7 @@ import { render, screen } from '@testing-library/react';
|
|
|
3
3
|
import userEvent from '@testing-library/user-event';
|
|
4
4
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
5
5
|
|
|
6
|
-
import type { Token } from '
|
|
6
|
+
import type { Token } from '../../tokens/types';
|
|
7
7
|
|
|
8
8
|
// Render the page without a RouterProvider: mock the two router exports it uses
|
|
9
9
|
// (`createFileRoute` returns the Route object; `Link` renders a plain anchor).
|
|
@@ -14,7 +14,7 @@ vi.mock('@tanstack/react-router', () => ({
|
|
|
14
14
|
|
|
15
15
|
// Drive each async branch deterministically by mocking the query hook boundary.
|
|
16
16
|
const { useTokensMock } = vi.hoisted(() => ({ useTokensMock: vi.fn() }));
|
|
17
|
-
vi.mock('
|
|
17
|
+
vi.mock('../../tokens/hooks', () => ({ useTokens: () => useTokensMock() }));
|
|
18
18
|
|
|
19
19
|
import { Route } from './index';
|
|
20
20
|
|
|
@@ -14,9 +14,8 @@ import {
|
|
|
14
14
|
} from '@keboola/design';
|
|
15
15
|
|
|
16
16
|
import { useAppStore } from '../../store';
|
|
17
|
-
|
|
18
|
-
import {
|
|
19
|
-
import type { Token } from './-types';
|
|
17
|
+
import { useTokens } from '../../tokens/hooks';
|
|
18
|
+
import type { Token } from '../../tokens/types';
|
|
20
19
|
|
|
21
20
|
export const Route = createFileRoute('/tokens/')({
|
|
22
21
|
component: TokensListPage,
|
|
@@ -13,8 +13,8 @@ import {
|
|
|
13
13
|
toast,
|
|
14
14
|
} from '@keboola/design';
|
|
15
15
|
|
|
16
|
-
import { useCreateToken } from '
|
|
17
|
-
import { type CreateTokenInput, EXPIRY_OPTIONS, type TokenExpiry } from '
|
|
16
|
+
import { useCreateToken } from '../../tokens/hooks';
|
|
17
|
+
import { type CreateTokenInput, EXPIRY_OPTIONS, type TokenExpiry } from '../../tokens/types';
|
|
18
18
|
|
|
19
19
|
export const Route = createFileRoute('/tokens/new')({
|
|
20
20
|
component: NewTokenPage,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { beforeEach, describe, expect, it } from 'vitest';
|
|
2
2
|
|
|
3
|
-
import { createToken, fetchToken, fetchTokens, resetFixtures } from '
|
|
3
|
+
import { createToken, fetchToken, fetchTokens, resetFixtures } from './fixtures';
|
|
4
4
|
|
|
5
5
|
describe('fixtures', () => {
|
|
6
6
|
beforeEach(() => {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { CreateTokenInput, Token } from '
|
|
1
|
+
import type { CreateTokenInput, Token } from './types';
|
|
2
2
|
|
|
3
3
|
// Hand-rolled in-memory store — replaces a real API until @keboola/api-client
|
|
4
4
|
// gains a typed Tokens namespace. Swap fetchTokens / createToken for real API
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
2
2
|
|
|
3
|
-
import { createToken, fetchToken, fetchTokens } from '
|
|
4
|
-
import type { CreateTokenInput } from '
|
|
3
|
+
import { createToken, fetchToken, fetchTokens } from './fixtures';
|
|
4
|
+
import type { CreateTokenInput } from './types';
|
|
5
5
|
|
|
6
6
|
const queryKeys = {
|
|
7
7
|
all: ['tokens'] as const,
|
|
@@ -9,6 +9,14 @@ export default defineConfig({
|
|
|
9
9
|
TanStackRouterVite({
|
|
10
10
|
routesDirectory: './src/routes',
|
|
11
11
|
generatedRouteTree: './src/routeTree.gen.ts',
|
|
12
|
+
/**
|
|
13
|
+
* `routes/` holds route files and their tests, nothing else.
|
|
14
|
+
*
|
|
15
|
+
* - A test file exports no `Route`, so unmatched it costs a generator warning per build.
|
|
16
|
+
* - Matched against the file name, not the path (`routeFileIgnorePrefix` still defaults
|
|
17
|
+
* to `-`; nothing here relies on it).
|
|
18
|
+
*/
|
|
19
|
+
routeFileIgnorePattern: '\\.test\\.tsx?$',
|
|
12
20
|
}),
|
|
13
21
|
react(),
|
|
14
22
|
tailwindcss(),
|
package/package.json
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-keboola-app",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Scaffold a Keboola-shaped frontend app — Vite + React 19 + TanStack Router/Query/Form + Zustand + @keboola/design — from a minimal shell plus the blocks you pick",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
|
-
"bin": "./dist/cli.js",
|
|
8
7
|
"files": [
|
|
9
8
|
"dist",
|
|
10
9
|
"README.md",
|
|
@@ -16,17 +15,6 @@
|
|
|
16
15
|
"engines": {
|
|
17
16
|
"node": ">=20"
|
|
18
17
|
},
|
|
19
|
-
"scripts": {
|
|
20
|
-
"build": "tsdown && node dist/extract.js",
|
|
21
|
-
"lint": "pnpm lint:format && pnpm lint:oxlint",
|
|
22
|
-
"lint:fix": "oxlint --fix . && oxfmt",
|
|
23
|
-
"lint:format": "oxfmt --check",
|
|
24
|
-
"lint:oxlint": "oxlint . --quiet",
|
|
25
|
-
"knip:check": "pnpm --dir ../.. exec knip --workspace packages/create-keboola-app",
|
|
26
|
-
"knip:fix": "pnpm --dir ../.. exec knip --workspace packages/create-keboola-app --fix-type types,exports,files",
|
|
27
|
-
"test": "vitest run --passWithNoTests --reporter=dot",
|
|
28
|
-
"type-check": "tsc"
|
|
29
|
-
},
|
|
30
18
|
"repository": {
|
|
31
19
|
"type": "git",
|
|
32
20
|
"url": "https://github.com/keboola/ui.git",
|
|
@@ -50,15 +38,29 @@
|
|
|
50
38
|
"@tanstack/router-generator": "^1.120.4"
|
|
51
39
|
},
|
|
52
40
|
"devDependencies": {
|
|
53
|
-
"@keboola/oxlint-config": "workspace:^",
|
|
54
|
-
"@keboola/tsconfig": "workspace:^",
|
|
55
41
|
"@types/node": "24.12.0",
|
|
56
42
|
"@typescript/native": "npm:typescript@^7.0.2",
|
|
57
|
-
"oxfmt": "^0.
|
|
58
|
-
"oxlint": "^1.
|
|
43
|
+
"oxfmt": "^0.66.0",
|
|
44
|
+
"oxlint": "^1.81.0",
|
|
59
45
|
"tsdown": "^0.22.14",
|
|
60
46
|
"typescript": "npm:@typescript/typescript6@^6.0.2",
|
|
61
47
|
"vite": "^8.1.5",
|
|
62
|
-
"vitest": "^4.1.10"
|
|
48
|
+
"vitest": "^4.1.10",
|
|
49
|
+
"@keboola/tsconfig": "^0.2.1",
|
|
50
|
+
"@keboola/oxlint-config": "^10.0.2"
|
|
51
|
+
},
|
|
52
|
+
"scripts": {
|
|
53
|
+
"build": "tsdown && node dist/extract.js",
|
|
54
|
+
"lint": "pnpm lint:format && pnpm lint:oxlint",
|
|
55
|
+
"lint:fix": "oxlint --fix . && oxfmt",
|
|
56
|
+
"lint:format": "oxfmt --check",
|
|
57
|
+
"lint:oxlint": "oxlint . --quiet",
|
|
58
|
+
"knip:check": "pnpm --dir ../.. exec knip --workspace packages/create-keboola-app",
|
|
59
|
+
"knip:fix": "pnpm --dir ../.. exec knip --workspace packages/create-keboola-app --fix-type types,exports,files",
|
|
60
|
+
"test": "vitest run --passWithNoTests --reporter=dot",
|
|
61
|
+
"type-check": "tsc"
|
|
62
|
+
},
|
|
63
|
+
"bin": {
|
|
64
|
+
"create-keboola-app": "./dist/cli.js"
|
|
63
65
|
}
|
|
64
|
-
}
|
|
66
|
+
}
|
|
File without changes
|