@frontera-sdk/cli 1.43.9 → 1.44.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +140 -12
- package/package.json +3 -3
- package/src/adopt.ts +436 -0
- package/src/api/apps-api.ts +30 -0
- package/src/api/blueprint-authoring-api.ts +13 -2
- package/src/api/governed-action-api.ts +192 -0
- package/src/api/platform-api.ts +4 -0
- package/src/blueprint/ontology-edit-plan.ts +195 -0
- package/src/blueprint-types.ts +252 -0
- package/src/commands/action/deploy.ts +135 -0
- package/src/commands/action/grant.ts +68 -0
- package/src/commands/action/index-commands.ts +29 -0
- package/src/commands/action/list.ts +49 -0
- package/src/commands/action/prepare.ts +48 -0
- package/src/commands/action/review.ts +94 -0
- package/src/commands/app/deploy.ts +16 -5
- package/src/commands/app/dev.ts +173 -0
- package/src/commands/app/init.ts +270 -28
- package/src/commands/app/sdk.ts +31 -0
- package/src/commands/app/versions.ts +8 -1
- package/src/commands/blueprint/editable.ts +151 -0
- package/src/commands/blueprint/generate-types.ts +58 -0
- package/src/commands/blueprint/get.ts +29 -34
- package/src/commands/blueprint/list.ts +2 -1
- package/src/commands/registry.ts +12 -0
- package/src/context.ts +4 -4
- package/src/dev-broker.ts +71 -0
- package/src/flag-help.ts +24 -1
- package/src/heal.ts +37 -2
- package/src/manifest.ts +89 -8
- package/src/packaging.ts +6 -0
- package/src/project-bootstrap.ts +176 -0
- package/src/project.ts +68 -35
- package/src/provenance.ts +89 -0
- package/src/render-evidence.ts +28 -0
- package/src/sdk-sync.ts +41 -0
- package/src/shadcn-components.ts +106 -0
- package/src/static-app-validation.ts +67 -0
- package/src/template.ts +211 -32
- package/src/templates/next-app-files.ts +1052 -0
- package/src/templates/next-skills.ts +1216 -0
- package/src/vendor/sdk-sources.json +21 -15
|
@@ -0,0 +1,1052 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Next.js App scaffold: configuration, and a WORKING reference feature.
|
|
3
|
+
*
|
|
4
|
+
* The scaffold used to be four files — a layout, a providers tree, a page that
|
|
5
|
+
* rendered one paragraph, and a stack of prose skills describing patterns that
|
|
6
|
+
* appeared nowhere in the tree. Prose alone loses: an agent asked to "add a
|
|
7
|
+
* page listing shipments" copies the nearest file it can see, and the nearest
|
|
8
|
+
* file was a single-component page with inline query branching. So it produced
|
|
9
|
+
* single-component pages with inline query branching, no matter what the skill
|
|
10
|
+
* said.
|
|
11
|
+
*
|
|
12
|
+
* What ships here instead is one complete vertical slice — route adapter →
|
|
13
|
+
* shell → feature folder → sub-components → hooks → data layer → pure helpers
|
|
14
|
+
* → tests — small enough to read in a sitting and structured exactly the way
|
|
15
|
+
* every later feature must be. The skills name this slice by path, so "follow
|
|
16
|
+
* the pattern" is a file to copy rather than a paragraph to interpret.
|
|
17
|
+
*
|
|
18
|
+
* Everything here type-checks and builds with nothing installed but the public
|
|
19
|
+
* packages in `package.json`; no registry component is required for the first
|
|
20
|
+
* build to be green.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
interface ScaffoldInput {
|
|
24
|
+
/** Project name — the package name, the display name and the page title. */
|
|
25
|
+
name: string
|
|
26
|
+
/** Design tokens + Tailwind entry, vendored from the platform stylesheet. */
|
|
27
|
+
themeCss: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function nextAppFiles({ name, themeCss }: ScaffoldInput): Record<string, string> {
|
|
31
|
+
return {
|
|
32
|
+
...projectFiles(name),
|
|
33
|
+
...sourceFiles(name, themeCss),
|
|
34
|
+
...docFiles(name),
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/* ------------------------------------------------------------------ *
|
|
39
|
+
* Project configuration
|
|
40
|
+
* ------------------------------------------------------------------ */
|
|
41
|
+
|
|
42
|
+
function projectFiles(name: string): Record<string, string> {
|
|
43
|
+
return {
|
|
44
|
+
'package.json': `${JSON.stringify(
|
|
45
|
+
{
|
|
46
|
+
name,
|
|
47
|
+
version: '0.1.0',
|
|
48
|
+
private: true,
|
|
49
|
+
packageManager: 'bun@1.3.14',
|
|
50
|
+
scripts: {
|
|
51
|
+
dev: 'next dev',
|
|
52
|
+
build: 'next build',
|
|
53
|
+
typecheck: 'tsc --noEmit',
|
|
54
|
+
test: 'bun test src',
|
|
55
|
+
// One command an agent can run before claiming done, in the order
|
|
56
|
+
// that fails fastest. `next build` does not type-check the whole
|
|
57
|
+
// project and `tsc` never sees the static-export constraints, so
|
|
58
|
+
// neither alone is a gate.
|
|
59
|
+
check: 'bun run typecheck && bun run test && bun run build',
|
|
60
|
+
'blueprint:types': 'frontera blueprint generate-types',
|
|
61
|
+
'blueprint:types:check': 'frontera blueprint generate-types --check',
|
|
62
|
+
deploy: 'bun run check && frontera app deploy',
|
|
63
|
+
},
|
|
64
|
+
dependencies: {
|
|
65
|
+
'@frontera-sdk/blueprint': '^1.43.10',
|
|
66
|
+
'@frontera-sdk/core': '^1.43.10',
|
|
67
|
+
'@tanstack/react-query': '^5.90.21',
|
|
68
|
+
// Everything from here to `tailwind-merge` is what a shadcn
|
|
69
|
+
// component imports. Every item in that registry declares NO
|
|
70
|
+
// dependencies of its own, so `shadcn add button` writes a file
|
|
71
|
+
// importing `class-variance-authority` and installs nothing —
|
|
72
|
+
// declaring them here is the difference between an added component
|
|
73
|
+
// compiling and a project that cannot resolve `cva`.
|
|
74
|
+
'class-variance-authority': '^0.7.1',
|
|
75
|
+
clsx: '^2.1.1',
|
|
76
|
+
'lucide-react': '^1.31.0',
|
|
77
|
+
next: '16.3.0',
|
|
78
|
+
'radix-ui': '^1.6.7',
|
|
79
|
+
react: '19.2.7',
|
|
80
|
+
'react-dom': '19.2.7',
|
|
81
|
+
'tailwind-merge': '^3.3.1',
|
|
82
|
+
},
|
|
83
|
+
devDependencies: {
|
|
84
|
+
'@tailwindcss/postcss': '^4.1.0',
|
|
85
|
+
// The Tailwind v4 replacement for `tailwindcss-animate`. shadcn
|
|
86
|
+
// overlays (dialog, select, popover, sheet) style their transitions
|
|
87
|
+
// with `animate-in` / `fade-in-0` / `zoom-in-95`, and an unknown
|
|
88
|
+
// utility in Tailwind is not an error — it silently produces no CSS,
|
|
89
|
+
// so the overlay simply appears without motion and nothing says why.
|
|
90
|
+
'tw-animate-css': '^1.4.0',
|
|
91
|
+
// For `bun:test` in `src/**/__tests__`. Without it `tsc` cannot
|
|
92
|
+
// resolve the module and `bun run typecheck` fails on the tests
|
|
93
|
+
// this scaffold ships.
|
|
94
|
+
'@types/bun': '^1.3.0',
|
|
95
|
+
'@types/node': '^22',
|
|
96
|
+
'@types/react': '^19',
|
|
97
|
+
'@types/react-dom': '^19',
|
|
98
|
+
tailwindcss: '^4.1.0',
|
|
99
|
+
typescript: '^5.9.3',
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
null,
|
|
103
|
+
2,
|
|
104
|
+
)}\n`,
|
|
105
|
+
|
|
106
|
+
// Frontera hosting configuration is deliberately separate from npm's
|
|
107
|
+
// package manifest. package.json stays standard package metadata; this
|
|
108
|
+
// file describes how the already-built artifact is served.
|
|
109
|
+
'frontera.config.json': `${JSON.stringify(
|
|
110
|
+
{
|
|
111
|
+
displayName: name,
|
|
112
|
+
outputDirectory: 'out',
|
|
113
|
+
routing: 'filesystem',
|
|
114
|
+
connectDomains: [],
|
|
115
|
+
resourceDomains: [],
|
|
116
|
+
},
|
|
117
|
+
null,
|
|
118
|
+
2,
|
|
119
|
+
)}\n`,
|
|
120
|
+
|
|
121
|
+
'next.config.ts': `import type { NextConfig } from 'next'
|
|
122
|
+
|
|
123
|
+
const nextConfig: NextConfig = {
|
|
124
|
+
output: 'export',
|
|
125
|
+
trailingSlash: true,
|
|
126
|
+
images: { unoptimized: true },
|
|
127
|
+
// Current Frontera SDK packages publish TypeScript source. This is Next's
|
|
128
|
+
// supported boundary for compiling those dependencies inside the App build.
|
|
129
|
+
transpilePackages: ['@frontera-sdk/core', '@frontera-sdk/blueprint'],
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export default nextConfig
|
|
133
|
+
`,
|
|
134
|
+
|
|
135
|
+
'tsconfig.json': `${JSON.stringify(
|
|
136
|
+
{
|
|
137
|
+
compilerOptions: {
|
|
138
|
+
target: 'ES2017',
|
|
139
|
+
lib: ['dom', 'dom.iterable', 'esnext'],
|
|
140
|
+
allowJs: false,
|
|
141
|
+
skipLibCheck: true,
|
|
142
|
+
strict: true,
|
|
143
|
+
noEmit: true,
|
|
144
|
+
esModuleInterop: true,
|
|
145
|
+
module: 'esnext',
|
|
146
|
+
moduleResolution: 'bundler',
|
|
147
|
+
resolveJsonModule: true,
|
|
148
|
+
isolatedModules: true,
|
|
149
|
+
jsx: 'react-jsx',
|
|
150
|
+
incremental: true,
|
|
151
|
+
plugins: [{ name: 'next' }],
|
|
152
|
+
paths: { '@/*': ['./src/*'] },
|
|
153
|
+
},
|
|
154
|
+
// `.next/dev/types` is not decoration: `next build` rewrites this file
|
|
155
|
+
// to add it on the first run, so omitting it means the very first
|
|
156
|
+
// build leaves the working tree dirty and a deploy records it as such.
|
|
157
|
+
include: [
|
|
158
|
+
'next-env.d.ts',
|
|
159
|
+
'**/*.ts',
|
|
160
|
+
'**/*.tsx',
|
|
161
|
+
'.next/types/**/*.ts',
|
|
162
|
+
'.next/dev/types/**/*.ts',
|
|
163
|
+
],
|
|
164
|
+
exclude: ['node_modules', 'out'],
|
|
165
|
+
},
|
|
166
|
+
null,
|
|
167
|
+
2,
|
|
168
|
+
)}\n`,
|
|
169
|
+
|
|
170
|
+
'next-env.d.ts': `/// <reference types="next" />
|
|
171
|
+
/// <reference types="next/image-types/global" />
|
|
172
|
+
`,
|
|
173
|
+
|
|
174
|
+
// Configures the shadcn CLI so `bunx shadcn@latest add <name>` writes into
|
|
175
|
+
// this project unmodified: Tailwind v4 (no config file), our stylesheet,
|
|
176
|
+
// our `@/` aliases, our `cn`.
|
|
177
|
+
//
|
|
178
|
+
// `cssVariables: true` with a stylesheet that already defines every
|
|
179
|
+
// variable upstream references is why `add` leaves `globals.css` alone.
|
|
180
|
+
// `shadcn init` is the command that would NOT: it rewrites the stylesheet
|
|
181
|
+
// with the vanilla palette and drops the platform surface scale.
|
|
182
|
+
'components.json': `${JSON.stringify(
|
|
183
|
+
{
|
|
184
|
+
$schema: 'https://ui.shadcn.com/schema.json',
|
|
185
|
+
style: 'new-york',
|
|
186
|
+
// These components render in the browser under a static export, and
|
|
187
|
+
// the SDK session is a client boundary — so they carry 'use client'.
|
|
188
|
+
rsc: false,
|
|
189
|
+
tsx: true,
|
|
190
|
+
tailwind: {
|
|
191
|
+
config: '',
|
|
192
|
+
css: 'src/app/globals.css',
|
|
193
|
+
baseColor: 'neutral',
|
|
194
|
+
cssVariables: true,
|
|
195
|
+
prefix: '',
|
|
196
|
+
},
|
|
197
|
+
aliases: {
|
|
198
|
+
components: '@/components',
|
|
199
|
+
ui: '@/components/ui',
|
|
200
|
+
utils: '@/lib/utils',
|
|
201
|
+
lib: '@/lib',
|
|
202
|
+
hooks: '@/hooks',
|
|
203
|
+
},
|
|
204
|
+
iconLibrary: 'lucide',
|
|
205
|
+
},
|
|
206
|
+
null,
|
|
207
|
+
2,
|
|
208
|
+
)}\n`,
|
|
209
|
+
|
|
210
|
+
'postcss.config.mjs': `export default { plugins: { '@tailwindcss/postcss': {} } }
|
|
211
|
+
`,
|
|
212
|
+
|
|
213
|
+
'.gitignore': `node_modules/
|
|
214
|
+
.next/
|
|
215
|
+
out/
|
|
216
|
+
.frontera/
|
|
217
|
+
.env
|
|
218
|
+
.env.*
|
|
219
|
+
.env.local
|
|
220
|
+
!.env.example
|
|
221
|
+
*.log
|
|
222
|
+
`,
|
|
223
|
+
|
|
224
|
+
// Present so the answer to "where do I put the API key" is a file that
|
|
225
|
+
// says: nowhere. A static App has no secret storage — every value here
|
|
226
|
+
// ends up readable in the browser bundle.
|
|
227
|
+
'.env.example': `# Build-time, PUBLIC values only. Everything in a NEXT_PUBLIC_ variable is
|
|
228
|
+
# readable by anyone who opens the App.
|
|
229
|
+
#
|
|
230
|
+
# Do NOT put a Frontera workspace key, an App token, or any customer
|
|
231
|
+
# credential here. Local development gets real Blueprint access from
|
|
232
|
+
# \`frontera app dev\`, which owns NEXT_PUBLIC_FRONTERA_DEV_SESSION_ENDPOINT and
|
|
233
|
+
# keeps the stored CLI key out of the browser.
|
|
234
|
+
|
|
235
|
+
# NEXT_PUBLIC_SUPPORT_URL=https://example.com/support
|
|
236
|
+
`,
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/* ------------------------------------------------------------------ *
|
|
241
|
+
* Application source — the reference vertical slice
|
|
242
|
+
* ------------------------------------------------------------------ */
|
|
243
|
+
|
|
244
|
+
function sourceFiles(name: string, themeCss: string): Record<string, string> {
|
|
245
|
+
return {
|
|
246
|
+
// The platform stylesheet, plus the animation utilities shadcn overlays
|
|
247
|
+
// expect. The import goes directly after Tailwind's because CSS `@import`
|
|
248
|
+
// rules must precede every other rule in the file.
|
|
249
|
+
'src/app/globals.css': themeCss.includes('tw-animate-css')
|
|
250
|
+
? themeCss
|
|
251
|
+
: themeCss.replace('@import "tailwindcss";', '@import "tailwindcss";\n@import "tw-animate-css";'),
|
|
252
|
+
|
|
253
|
+
'src/app/layout.tsx': `import type { Metadata } from 'next'
|
|
254
|
+
import type { ReactNode } from 'react'
|
|
255
|
+
|
|
256
|
+
import './globals.css'
|
|
257
|
+
import { Providers } from './providers'
|
|
258
|
+
|
|
259
|
+
export const metadata: Metadata = { title: ${JSON.stringify(name)} }
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Server root layout. Owns metadata and the document, nothing else.
|
|
263
|
+
*
|
|
264
|
+
* Never mark this file 'use client' — the client boundary belongs in
|
|
265
|
+
* providers.tsx so the document stays static-exportable.
|
|
266
|
+
*/
|
|
267
|
+
export default function RootLayout({ children }: { children: ReactNode }) {
|
|
268
|
+
return (
|
|
269
|
+
<html lang="en">
|
|
270
|
+
<body className="min-h-dvh bg-background text-foreground antialiased">
|
|
271
|
+
<Providers>{children}</Providers>
|
|
272
|
+
</body>
|
|
273
|
+
</html>
|
|
274
|
+
)
|
|
275
|
+
}
|
|
276
|
+
`,
|
|
277
|
+
|
|
278
|
+
'src/app/providers.tsx': `'use client'
|
|
279
|
+
|
|
280
|
+
import type { ReactNode } from 'react'
|
|
281
|
+
import { blueprintProvider } from '@frontera-sdk/blueprint/provider'
|
|
282
|
+
import { FronteraAppProvider } from '@frontera-sdk/core/react'
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* The one client boundary at the app root.
|
|
286
|
+
*
|
|
287
|
+
* FronteraAppProvider picks the session transport (embedded / standalone /
|
|
288
|
+
* local), completes the handshake, builds the credentialed client and only
|
|
289
|
+
* then renders children. Domain providers are contributed as a list, so a
|
|
290
|
+
* second data domain is one array entry, never a second bridge.
|
|
291
|
+
*
|
|
292
|
+
* Mount this ONCE. A per-route provider stack forks the session and the query
|
|
293
|
+
* cache, which shows up as a second handshake and rows that refuse to update.
|
|
294
|
+
*/
|
|
295
|
+
const devSessionEndpoint = process.env.NEXT_PUBLIC_FRONTERA_DEV_SESSION_ENDPOINT
|
|
296
|
+
if (devSessionEndpoint) {
|
|
297
|
+
const runtime = globalThis as typeof globalThis & {
|
|
298
|
+
__FRONTERA_CONFIG__?: Record<string, unknown>
|
|
299
|
+
}
|
|
300
|
+
runtime.__FRONTERA_CONFIG__ = { ...runtime.__FRONTERA_CONFIG__, devSessionEndpoint }
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export function Providers({ children }: { children: ReactNode }) {
|
|
304
|
+
return <FronteraAppProvider providers={[blueprintProvider]}>{children}</FronteraAppProvider>
|
|
305
|
+
}
|
|
306
|
+
`,
|
|
307
|
+
|
|
308
|
+
// Thin route adapter: reads configuration, renders ONE feature component.
|
|
309
|
+
// No JSX for the feature, no hooks, no data access.
|
|
310
|
+
'src/app/page.tsx': `import { STARTER_OBJECT_TYPE, STARTER_SEARCH_PROPERTY } from '@/lib/blueprint/starter'
|
|
311
|
+
import { AppShell } from '@/ui/app-shell/app-shell'
|
|
312
|
+
import { ObjectExplorer } from '@/ui/object-explorer/object-explorer'
|
|
313
|
+
|
|
314
|
+
export default function HomePage() {
|
|
315
|
+
return (
|
|
316
|
+
<AppShell>
|
|
317
|
+
<ObjectExplorer
|
|
318
|
+
objectType={STARTER_OBJECT_TYPE}
|
|
319
|
+
searchProperty={STARTER_SEARCH_PROPERTY}
|
|
320
|
+
/>
|
|
321
|
+
</AppShell>
|
|
322
|
+
)
|
|
323
|
+
}
|
|
324
|
+
`,
|
|
325
|
+
|
|
326
|
+
'src/lib/utils.ts': `import { clsx, type ClassValue } from 'clsx'
|
|
327
|
+
import { twMerge } from 'tailwind-merge'
|
|
328
|
+
|
|
329
|
+
export function cn(...inputs: ClassValue[]) {
|
|
330
|
+
return twMerge(clsx(inputs))
|
|
331
|
+
}
|
|
332
|
+
`,
|
|
333
|
+
|
|
334
|
+
// One place for user-visible text. Not internationalisation — the point is
|
|
335
|
+
// that wording is reviewable in one file instead of hidden in twenty JSX
|
|
336
|
+
// branches, and that an empty state and its error twin cannot drift.
|
|
337
|
+
'src/lib/copy.ts': `export const copy = {
|
|
338
|
+
app: {
|
|
339
|
+
title: ${JSON.stringify(name)},
|
|
340
|
+
embedded: 'Embedded',
|
|
341
|
+
standalone: 'Standalone',
|
|
342
|
+
local: 'Local development',
|
|
343
|
+
},
|
|
344
|
+
explorer: {
|
|
345
|
+
searchLabel: 'Search',
|
|
346
|
+
searchPlaceholder: 'Filter by the configured property…',
|
|
347
|
+
loading: 'Loading records…',
|
|
348
|
+
empty: 'No records match this view.',
|
|
349
|
+
emptyHint: 'Clear the search, or widen the filter.',
|
|
350
|
+
errorTitle: 'Could not load records',
|
|
351
|
+
retry: 'Try again',
|
|
352
|
+
unconfiguredTitle: 'Point this App at an object type',
|
|
353
|
+
unconfiguredBody:
|
|
354
|
+
'Run \`frontera blueprint list\` to see what this workspace exposes, then set STARTER_OBJECT_TYPE in src/lib/blueprint/starter.ts.',
|
|
355
|
+
previous: 'Previous',
|
|
356
|
+
next: 'Next',
|
|
357
|
+
range: (from: number, to: number, total: number | null) =>
|
|
358
|
+
total === null ? \`\${from}–\${to}\` : \`\${from}–\${to} of \${total}\`,
|
|
359
|
+
},
|
|
360
|
+
} as const
|
|
361
|
+
`,
|
|
362
|
+
|
|
363
|
+
'src/lib/blueprint/starter.ts': `/**
|
|
364
|
+
* The object type the starter page reads.
|
|
365
|
+
*
|
|
366
|
+
* \`frontera blueprint list\` shows what this workspace exposes; put an API name
|
|
367
|
+
* here — \`Shipment\`, \`LoanApplication\`, whatever the deployment models — and
|
|
368
|
+
* \`frontera blueprint get <apiName>\` lists its properties.
|
|
369
|
+
*
|
|
370
|
+
* Annotated \`: string\` on purpose. Without it TypeScript infers the literal
|
|
371
|
+
* type \`''\`, and the empty-check in the explorer becomes a "no overlap" error
|
|
372
|
+
* the moment you fill this in — a type error caused by doing exactly what the
|
|
373
|
+
* comment says.
|
|
374
|
+
*
|
|
375
|
+
* This starter reads any object type by name, which is what makes it useful
|
|
376
|
+
* before you know the data. Real features do the opposite: run
|
|
377
|
+
* \`bun run blueprint:types\` and pass a LITERAL api name to \`useObjects\`, so
|
|
378
|
+
* rows, filters and sorts are all typed. See the frontera-blueprint-data skill.
|
|
379
|
+
*/
|
|
380
|
+
export const STARTER_OBJECT_TYPE: string = ''
|
|
381
|
+
|
|
382
|
+
/** A string property to search with \`contains\`. Undefined disables search. */
|
|
383
|
+
export const STARTER_SEARCH_PROPERTY: string | undefined = undefined
|
|
384
|
+
`,
|
|
385
|
+
|
|
386
|
+
// ---- data layer -------------------------------------------------
|
|
387
|
+
'src/lib/blueprint/objects/objects-hooks.ts': `'use client'
|
|
388
|
+
|
|
389
|
+
import { keepPreviousData } from '@tanstack/react-query'
|
|
390
|
+
import { useAggregate, useObjectQuery } from '@frontera-sdk/blueprint/hooks'
|
|
391
|
+
import { objectsOf } from '@frontera-sdk/blueprint/types'
|
|
392
|
+
import type { AggregateResponse, WhereNode } from '@frontera-sdk/blueprint/types'
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* The data layer for the object explorer.
|
|
396
|
+
*
|
|
397
|
+
* Everything that talks to Blueprint lives in \`src/lib/blueprint/<domain>/\`,
|
|
398
|
+
* one folder per domain, and components receive rows as props. That separation
|
|
399
|
+
* is what keeps a filter change from turning into a fetch inside a table cell.
|
|
400
|
+
*
|
|
401
|
+
* This starter domain is deliberately generic — it takes the object type as an
|
|
402
|
+
* argument so it works before you know the data model. A real domain is named
|
|
403
|
+
* after its object type (\`src/lib/blueprint/shipment/shipment-hooks.ts\`), uses
|
|
404
|
+
* a literal api name, and gets full row/filter/sort inference from
|
|
405
|
+
* \`bun run blueprint:types\`.
|
|
406
|
+
*/
|
|
407
|
+
|
|
408
|
+
export const PAGE_SIZE = 25
|
|
409
|
+
|
|
410
|
+
export interface ObjectPageParams {
|
|
411
|
+
objectType: string
|
|
412
|
+
/** Opaque cursor returned by the previous page. Omit for the first page. */
|
|
413
|
+
pageToken?: string
|
|
414
|
+
pageSize?: number
|
|
415
|
+
search?: string
|
|
416
|
+
searchProperty?: string
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* The server-side filter.
|
|
421
|
+
*
|
|
422
|
+
* Filtering the returned rows in the component instead narrows only the page
|
|
423
|
+
* you happened to fetch: a filter matching 8,961 records renders 5 of them and
|
|
424
|
+
* the footer claims "Page 1 of 1".
|
|
425
|
+
*/
|
|
426
|
+
export function objectWhere(params: ObjectPageParams): WhereNode | undefined {
|
|
427
|
+
const search = params.search?.trim()
|
|
428
|
+
if (!search || !params.searchProperty) return undefined
|
|
429
|
+
return { property: params.searchProperty, op: 'contains', value: search }
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** One page of rows. Disabled until an object type is configured. */
|
|
433
|
+
export function useObjectPage(params: ObjectPageParams) {
|
|
434
|
+
const pageSize = params.pageSize ?? PAGE_SIZE
|
|
435
|
+
return useObjectQuery(
|
|
436
|
+
{
|
|
437
|
+
objectSet: objectsOf(params.objectType, objectWhere(params)),
|
|
438
|
+
pageToken: params.pageToken,
|
|
439
|
+
pageSize,
|
|
440
|
+
},
|
|
441
|
+
{
|
|
442
|
+
enabled: params.objectType.length > 0,
|
|
443
|
+
// Keep the previous page on screen while the next one loads, so paging
|
|
444
|
+
// dims instead of collapsing to a skeleton and losing scroll position.
|
|
445
|
+
placeholderData: keepPreviousData,
|
|
446
|
+
},
|
|
447
|
+
)
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* The total, as its own aggregate over the SAME object set.
|
|
452
|
+
*
|
|
453
|
+
* \`rows.length\` is the size of one page — never the count. The two queries
|
|
454
|
+
* must share \`objectWhere\`, or the number under the table describes a
|
|
455
|
+
* different filter than the table.
|
|
456
|
+
*/
|
|
457
|
+
export function useObjectTotal(params: ObjectPageParams) {
|
|
458
|
+
return useAggregate(
|
|
459
|
+
{
|
|
460
|
+
objectSet: objectsOf(params.objectType, objectWhere(params)),
|
|
461
|
+
aggregations: [{ alias: 'total', fn: 'count' }],
|
|
462
|
+
// Required by the service. An empty array means "grand total".
|
|
463
|
+
groupBy: [],
|
|
464
|
+
},
|
|
465
|
+
{ enabled: params.objectType.length > 0, placeholderData: keepPreviousData },
|
|
466
|
+
)
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/** Read the count out of an aggregate response, or null when it is absent. */
|
|
470
|
+
export function totalFrom(response: AggregateResponse | undefined): number | null {
|
|
471
|
+
const value = response?.rows[0]?.total
|
|
472
|
+
if (value === null || value === undefined) return null
|
|
473
|
+
const total = Number(value)
|
|
474
|
+
return Number.isFinite(total) ? total : null
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
`,
|
|
478
|
+
|
|
479
|
+
'src/lib/blueprint/objects/__tests__/objects-hooks.test.ts': `import { describe, expect, test } from 'bun:test'
|
|
480
|
+
|
|
481
|
+
import { objectWhere, totalFrom } from '../objects-hooks'
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* The parts of the data layer worth testing in a static App are the pure ones:
|
|
485
|
+
* filter construction and aggregate parsing. Hooks need a running
|
|
486
|
+
* session and are covered by \`frontera app dev\` against real data.
|
|
487
|
+
*/
|
|
488
|
+
describe('objectWhere', () => {
|
|
489
|
+
const base = { objectType: 'Shipment' }
|
|
490
|
+
|
|
491
|
+
test('is undefined without a search property, so the query stays unfiltered', () => {
|
|
492
|
+
expect(objectWhere({ ...base, search: 'jakarta' })).toBeUndefined()
|
|
493
|
+
})
|
|
494
|
+
|
|
495
|
+
test('ignores whitespace-only input rather than filtering on an empty string', () => {
|
|
496
|
+
expect(objectWhere({ ...base, search: ' ', searchProperty: 'city' })).toBeUndefined()
|
|
497
|
+
})
|
|
498
|
+
|
|
499
|
+
test('builds a server-side contains filter', () => {
|
|
500
|
+
expect(objectWhere({ ...base, search: ' jakarta ', searchProperty: 'city' })).toEqual({
|
|
501
|
+
property: 'city',
|
|
502
|
+
op: 'contains',
|
|
503
|
+
value: 'jakarta',
|
|
504
|
+
})
|
|
505
|
+
})
|
|
506
|
+
})
|
|
507
|
+
|
|
508
|
+
describe('totalFrom', () => {
|
|
509
|
+
test('reads the aliased count', () => {
|
|
510
|
+
expect(totalFrom({ rows: [{ total: 8961 }] })).toBe(8961)
|
|
511
|
+
})
|
|
512
|
+
|
|
513
|
+
test('distinguishes "no answer yet" from zero', () => {
|
|
514
|
+
expect(totalFrom(undefined)).toBeNull()
|
|
515
|
+
expect(totalFrom({ rows: [] })).toBeNull()
|
|
516
|
+
expect(totalFrom({ rows: [{ total: 0 }] })).toBe(0)
|
|
517
|
+
})
|
|
518
|
+
})
|
|
519
|
+
`,
|
|
520
|
+
|
|
521
|
+
// ---- shell ------------------------------------------------------
|
|
522
|
+
'src/ui/app-shell/app-shell.tsx': `'use client'
|
|
523
|
+
|
|
524
|
+
import type { ReactNode } from 'react'
|
|
525
|
+
import { useFronteraApp } from '@frontera-sdk/core/react'
|
|
526
|
+
|
|
527
|
+
import { copy } from '@/lib/copy'
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* The frame every page renders into.
|
|
531
|
+
*
|
|
532
|
+
* An App is shown in two containers — inside the platform in a constrained
|
|
533
|
+
* frame, and standalone at full viewport — so the shell adapts rather than
|
|
534
|
+
* assuming it owns the screen. It carries the customer's page identity, not
|
|
535
|
+
* Frontera branding: the platform chrome already says where you are.
|
|
536
|
+
*/
|
|
537
|
+
export function AppShell({ children }: { children: ReactNode }) {
|
|
538
|
+
const { mode, init } = useFronteraApp()
|
|
539
|
+
const label =
|
|
540
|
+
mode === 'embedded' ? copy.app.embedded : mode === 'local' ? copy.app.local : copy.app.standalone
|
|
541
|
+
|
|
542
|
+
return (
|
|
543
|
+
<div className="flex min-h-dvh flex-col bg-background">
|
|
544
|
+
<header className="flex flex-wrap items-center justify-between gap-2 border-b border-border px-4 py-3 sm:px-6">
|
|
545
|
+
<h1 className="text-base font-semibold text-foreground sm:text-lg">{copy.app.title}</h1>
|
|
546
|
+
<p className="text-xs text-muted-foreground">
|
|
547
|
+
{label} · {init.version}
|
|
548
|
+
</p>
|
|
549
|
+
</header>
|
|
550
|
+
{/* The App owns its own scrolling: embedded, the platform will not
|
|
551
|
+
scroll for it. */}
|
|
552
|
+
<main className="mx-auto w-full max-w-6xl flex-1 overflow-y-auto p-4 sm:p-6">{children}</main>
|
|
553
|
+
</div>
|
|
554
|
+
)
|
|
555
|
+
}
|
|
556
|
+
`,
|
|
557
|
+
|
|
558
|
+
// ---- feature: object explorer -----------------------------------
|
|
559
|
+
'src/ui/object-explorer/types.ts': `/** A Blueprint property, as the query response describes it. */
|
|
560
|
+
export interface ObjectColumn {
|
|
561
|
+
apiName: string
|
|
562
|
+
displayName?: string
|
|
563
|
+
dataType?: string
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
export type ObjectRow = Record<string, unknown>
|
|
567
|
+
`,
|
|
568
|
+
|
|
569
|
+
'src/ui/object-explorer/utils.ts': `/**
|
|
570
|
+
* Render one cell value.
|
|
571
|
+
*
|
|
572
|
+
* Blueprint returns JSON, so a cell can be null, a number, a boolean or a
|
|
573
|
+
* nested object. Formatting here keeps every table component free of
|
|
574
|
+
* defensive \`String(value ?? '')\` sprinkles that quietly print "null".
|
|
575
|
+
*/
|
|
576
|
+
export function formatCell(value: unknown): string {
|
|
577
|
+
if (value === null || value === undefined) return '—'
|
|
578
|
+
if (typeof value === 'boolean') return value ? 'Yes' : 'No'
|
|
579
|
+
if (typeof value === 'number') return Number.isFinite(value) ? value.toLocaleString() : '—'
|
|
580
|
+
if (typeof value === 'string') return value
|
|
581
|
+
return JSON.stringify(value)
|
|
582
|
+
}
|
|
583
|
+
`,
|
|
584
|
+
|
|
585
|
+
'src/ui/object-explorer/hooks/use-debounced-value.ts': `'use client'
|
|
586
|
+
|
|
587
|
+
import { useEffect, useState } from 'react'
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* Trailing debounce for text input.
|
|
591
|
+
*
|
|
592
|
+
* A query key that changes on every keystroke issues one request per
|
|
593
|
+
* character; each one is a real Blueprint scan.
|
|
594
|
+
*/
|
|
595
|
+
export function useDebouncedValue<T>(value: T, delayMs = 300): T {
|
|
596
|
+
const [debounced, setDebounced] = useState(value)
|
|
597
|
+
|
|
598
|
+
useEffect(() => {
|
|
599
|
+
const timer = setTimeout(() => setDebounced(value), delayMs)
|
|
600
|
+
return () => clearTimeout(timer)
|
|
601
|
+
}, [value, delayMs])
|
|
602
|
+
|
|
603
|
+
return debounced
|
|
604
|
+
}
|
|
605
|
+
`,
|
|
606
|
+
|
|
607
|
+
'src/ui/object-explorer/object-explorer.tsx': `'use client'
|
|
608
|
+
|
|
609
|
+
import { useState } from 'react'
|
|
610
|
+
|
|
611
|
+
import {
|
|
612
|
+
PAGE_SIZE,
|
|
613
|
+
totalFrom,
|
|
614
|
+
useObjectPage,
|
|
615
|
+
useObjectTotal,
|
|
616
|
+
} from '@/lib/blueprint/objects/objects-hooks'
|
|
617
|
+
import { copy } from '@/lib/copy'
|
|
618
|
+
|
|
619
|
+
import { EmptyPanel } from './components/empty-panel'
|
|
620
|
+
import { ObjectPager } from './components/object-pager'
|
|
621
|
+
import { ObjectSearch } from './components/object-search'
|
|
622
|
+
import { ObjectTable } from './components/object-table'
|
|
623
|
+
import { ObjectTableSkeleton } from './components/object-table-skeleton'
|
|
624
|
+
import { QueryError } from './components/query-error'
|
|
625
|
+
import { useDebouncedValue } from './hooks/use-debounced-value'
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* The feature entry point. Owns view state, calls the data layer, and renders
|
|
629
|
+
* query states in one deliberate order — configuration, error, first load,
|
|
630
|
+
* empty, content. Sub-components below it receive data as props and fetch
|
|
631
|
+
* nothing.
|
|
632
|
+
*
|
|
633
|
+
* Copy this folder shape for the next feature: entry file at the root,
|
|
634
|
+
* sub-components in \`components/\`, hooks in \`hooks/\`, shared helpers in
|
|
635
|
+
* \`utils.ts\`, shared types in \`types.ts\`.
|
|
636
|
+
*/
|
|
637
|
+
export function ObjectExplorer({
|
|
638
|
+
objectType,
|
|
639
|
+
searchProperty,
|
|
640
|
+
}: {
|
|
641
|
+
objectType: string
|
|
642
|
+
searchProperty?: string
|
|
643
|
+
}) {
|
|
644
|
+
const [search, setSearch] = useState('')
|
|
645
|
+
const [pageIndex, setPageIndex] = useState(0)
|
|
646
|
+
const [pageTokens, setPageTokens] = useState<Array<string | undefined>>([undefined])
|
|
647
|
+
const debouncedSearch = useDebouncedValue(search)
|
|
648
|
+
|
|
649
|
+
const params = {
|
|
650
|
+
objectType,
|
|
651
|
+
pageToken: pageTokens[pageIndex],
|
|
652
|
+
search: debouncedSearch,
|
|
653
|
+
searchProperty,
|
|
654
|
+
}
|
|
655
|
+
const rows = useObjectPage(params)
|
|
656
|
+
const total = useObjectTotal(params)
|
|
657
|
+
|
|
658
|
+
// A cursor belongs to one exact query shape. Changing the filter starts a
|
|
659
|
+
// new scan rather than sending a token minted for the previous search.
|
|
660
|
+
const handleSearch = (value: string) => {
|
|
661
|
+
setSearch(value)
|
|
662
|
+
setPageIndex(0)
|
|
663
|
+
setPageTokens([undefined])
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
const handlePrevious = () => setPageIndex((current) => Math.max(0, current - 1))
|
|
667
|
+
const handleNext = () => {
|
|
668
|
+
const nextPageToken = rows.data?.nextPageToken
|
|
669
|
+
if (!nextPageToken) return
|
|
670
|
+
setPageTokens((current) => [...current.slice(0, pageIndex + 1), nextPageToken])
|
|
671
|
+
setPageIndex((current) => current + 1)
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
if (objectType.length === 0) {
|
|
675
|
+
return (
|
|
676
|
+
<EmptyPanel title={copy.explorer.unconfiguredTitle} body={copy.explorer.unconfiguredBody} />
|
|
677
|
+
)
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
const records = rows.data?.rows ?? []
|
|
681
|
+
const columns = rows.data?.properties ?? []
|
|
682
|
+
const totalCount = totalFrom(total.data)
|
|
683
|
+
|
|
684
|
+
return (
|
|
685
|
+
<section className="flex flex-col gap-4">
|
|
686
|
+
<ObjectSearch
|
|
687
|
+
value={search}
|
|
688
|
+
onChange={handleSearch}
|
|
689
|
+
disabled={searchProperty === undefined}
|
|
690
|
+
/>
|
|
691
|
+
|
|
692
|
+
{rows.isError ? (
|
|
693
|
+
<QueryError message={rows.error.message} onRetry={() => void rows.refetch()} />
|
|
694
|
+
) : rows.isLoading ? (
|
|
695
|
+
<ObjectTableSkeleton />
|
|
696
|
+
) : records.length === 0 ? (
|
|
697
|
+
<EmptyPanel title={copy.explorer.empty} body={copy.explorer.emptyHint} />
|
|
698
|
+
) : (
|
|
699
|
+
<>
|
|
700
|
+
<ObjectTable columns={columns} rows={records} isRefreshing={rows.isFetching} />
|
|
701
|
+
<ObjectPager
|
|
702
|
+
page={pageIndex + 1}
|
|
703
|
+
pageSize={PAGE_SIZE}
|
|
704
|
+
rowsOnPage={records.length}
|
|
705
|
+
total={totalCount}
|
|
706
|
+
hasPrevious={pageIndex > 0}
|
|
707
|
+
hasNext={rows.data?.hasMore ?? false}
|
|
708
|
+
onPrevious={handlePrevious}
|
|
709
|
+
onNext={handleNext}
|
|
710
|
+
/>
|
|
711
|
+
</>
|
|
712
|
+
)}
|
|
713
|
+
</section>
|
|
714
|
+
)
|
|
715
|
+
}
|
|
716
|
+
`,
|
|
717
|
+
|
|
718
|
+
'src/ui/object-explorer/components/object-table.tsx': `'use client'
|
|
719
|
+
|
|
720
|
+
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
|
721
|
+
import { cn } from '@/lib/utils'
|
|
722
|
+
|
|
723
|
+
import type { ObjectColumn, ObjectRow } from '../types'
|
|
724
|
+
import { formatCell } from '../utils'
|
|
725
|
+
|
|
726
|
+
/**
|
|
727
|
+
* Presentational table. Receives rows, renders rows.
|
|
728
|
+
*
|
|
729
|
+
* The markup is the shadcn \`table\` primitive — do not hand-roll a \`<table>\`
|
|
730
|
+
* next to it. Wrapped in its own horizontal scroller so a wide object type
|
|
731
|
+
* stays readable on a phone without the page itself scrolling sideways.
|
|
732
|
+
*/
|
|
733
|
+
export function ObjectTable({
|
|
734
|
+
columns,
|
|
735
|
+
rows,
|
|
736
|
+
isRefreshing = false,
|
|
737
|
+
}: {
|
|
738
|
+
columns: ObjectColumn[]
|
|
739
|
+
rows: ObjectRow[]
|
|
740
|
+
isRefreshing?: boolean
|
|
741
|
+
}) {
|
|
742
|
+
return (
|
|
743
|
+
<div
|
|
744
|
+
// A background refetch dims the stale rows instead of replacing them
|
|
745
|
+
// with a skeleton, so the table does not flash on every page change.
|
|
746
|
+
aria-busy={isRefreshing}
|
|
747
|
+
className={cn(
|
|
748
|
+
'overflow-x-auto rounded-lg border transition-opacity',
|
|
749
|
+
isRefreshing && 'opacity-60',
|
|
750
|
+
)}
|
|
751
|
+
>
|
|
752
|
+
<Table>
|
|
753
|
+
<TableHeader>
|
|
754
|
+
<TableRow>
|
|
755
|
+
{columns.map((column) => (
|
|
756
|
+
<TableHead key={column.apiName}>{column.displayName ?? column.apiName}</TableHead>
|
|
757
|
+
))}
|
|
758
|
+
</TableRow>
|
|
759
|
+
</TableHeader>
|
|
760
|
+
<TableBody>
|
|
761
|
+
{rows.map((row, index) => (
|
|
762
|
+
<TableRow key={index}>
|
|
763
|
+
{columns.map((column) => (
|
|
764
|
+
<TableCell key={column.apiName}>{formatCell(row[column.apiName])}</TableCell>
|
|
765
|
+
))}
|
|
766
|
+
</TableRow>
|
|
767
|
+
))}
|
|
768
|
+
</TableBody>
|
|
769
|
+
</Table>
|
|
770
|
+
</div>
|
|
771
|
+
)
|
|
772
|
+
}
|
|
773
|
+
`,
|
|
774
|
+
|
|
775
|
+
'src/ui/object-explorer/components/object-table-skeleton.tsx': `import { Skeleton } from '@/components/ui/skeleton'
|
|
776
|
+
|
|
777
|
+
/**
|
|
778
|
+
* Shaped like the table it replaces — same border, same row rhythm — so the
|
|
779
|
+
* layout does not jump when data arrives. A centred spinner communicates
|
|
780
|
+
* nothing about what is loading and shifts everything below it.
|
|
781
|
+
*/
|
|
782
|
+
export function ObjectTableSkeleton({ rows = 8, columns = 4 }: { rows?: number; columns?: number }) {
|
|
783
|
+
return (
|
|
784
|
+
<div className="overflow-hidden rounded-lg border">
|
|
785
|
+
{Array.from({ length: rows }).map((_, rowIndex) => (
|
|
786
|
+
<div key={rowIndex} className="flex gap-3 border-b px-3 py-2.5 last:border-0">
|
|
787
|
+
{Array.from({ length: columns }).map((__, cellIndex) => (
|
|
788
|
+
<Skeleton key={cellIndex} className="h-3.5 flex-1" />
|
|
789
|
+
))}
|
|
790
|
+
</div>
|
|
791
|
+
))}
|
|
792
|
+
</div>
|
|
793
|
+
)
|
|
794
|
+
}
|
|
795
|
+
`,
|
|
796
|
+
|
|
797
|
+
'src/ui/object-explorer/components/object-search.tsx': `'use client'
|
|
798
|
+
|
|
799
|
+
import { Input } from '@/components/ui/input'
|
|
800
|
+
import { copy } from '@/lib/copy'
|
|
801
|
+
|
|
802
|
+
/** Controlled search input. The debounce belongs to the caller, not the field. */
|
|
803
|
+
export function ObjectSearch({
|
|
804
|
+
value,
|
|
805
|
+
onChange,
|
|
806
|
+
disabled = false,
|
|
807
|
+
}: {
|
|
808
|
+
value: string
|
|
809
|
+
onChange: (value: string) => void
|
|
810
|
+
disabled?: boolean
|
|
811
|
+
}) {
|
|
812
|
+
return (
|
|
813
|
+
<div className="flex flex-col gap-1.5">
|
|
814
|
+
<label htmlFor="object-search" className="text-xs font-medium text-muted-foreground">
|
|
815
|
+
{copy.explorer.searchLabel}
|
|
816
|
+
</label>
|
|
817
|
+
<Input
|
|
818
|
+
id="object-search"
|
|
819
|
+
type="search"
|
|
820
|
+
value={value}
|
|
821
|
+
disabled={disabled}
|
|
822
|
+
placeholder={copy.explorer.searchPlaceholder}
|
|
823
|
+
onChange={(event) => onChange(event.target.value)}
|
|
824
|
+
// h-11 keeps the target at 44px for touch; the primitive is denser by
|
|
825
|
+
// default, and a filter is a primary control on a phone.
|
|
826
|
+
className="h-11 sm:h-9 sm:max-w-sm"
|
|
827
|
+
/>
|
|
828
|
+
</div>
|
|
829
|
+
)
|
|
830
|
+
}
|
|
831
|
+
`,
|
|
832
|
+
|
|
833
|
+
'src/ui/object-explorer/components/object-pager.tsx': `'use client'
|
|
834
|
+
|
|
835
|
+
import { Button } from '@/components/ui/button'
|
|
836
|
+
import { copy } from '@/lib/copy'
|
|
837
|
+
|
|
838
|
+
/**
|
|
839
|
+
* Cursor-backed server-side pager. The numeric page is presentation only;
|
|
840
|
+
* Blueprint navigation uses opaque page tokens retained by the parent.
|
|
841
|
+
*/
|
|
842
|
+
export function ObjectPager({
|
|
843
|
+
page,
|
|
844
|
+
pageSize,
|
|
845
|
+
rowsOnPage,
|
|
846
|
+
total,
|
|
847
|
+
hasPrevious,
|
|
848
|
+
hasNext,
|
|
849
|
+
onPrevious,
|
|
850
|
+
onNext,
|
|
851
|
+
}: {
|
|
852
|
+
page: number
|
|
853
|
+
pageSize: number
|
|
854
|
+
rowsOnPage: number
|
|
855
|
+
total: number | null
|
|
856
|
+
hasPrevious: boolean
|
|
857
|
+
hasNext: boolean
|
|
858
|
+
onPrevious: () => void
|
|
859
|
+
onNext: () => void
|
|
860
|
+
}) {
|
|
861
|
+
const from = (page - 1) * pageSize + 1
|
|
862
|
+
const to = from + Math.max(rowsOnPage, 1) - 1
|
|
863
|
+
|
|
864
|
+
return (
|
|
865
|
+
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
866
|
+
<p className="text-xs text-muted-foreground">{copy.explorer.range(from, to, total)}</p>
|
|
867
|
+
<div className="flex items-center gap-2">
|
|
868
|
+
{/* h-11 on touch: the primitive's default height sits below the 44px
|
|
869
|
+
target a thumb needs, and paging is a primary control. */}
|
|
870
|
+
<Button variant="outline" className="h-11 sm:h-9" disabled={!hasPrevious} onClick={onPrevious}>
|
|
871
|
+
{copy.explorer.previous}
|
|
872
|
+
</Button>
|
|
873
|
+
<Button variant="outline" className="h-11 sm:h-9" disabled={!hasNext} onClick={onNext}>
|
|
874
|
+
{copy.explorer.next}
|
|
875
|
+
</Button>
|
|
876
|
+
</div>
|
|
877
|
+
</div>
|
|
878
|
+
)
|
|
879
|
+
}
|
|
880
|
+
`,
|
|
881
|
+
|
|
882
|
+
'src/ui/object-explorer/components/empty-panel.tsx': `/** Success with nothing in it, and configuration gaps. Never used for errors. */
|
|
883
|
+
export function EmptyPanel({ title, body }: { title: string; body?: string }) {
|
|
884
|
+
return (
|
|
885
|
+
<div className="rounded-lg border border-dashed border-border bg-card p-8 text-center">
|
|
886
|
+
<p className="text-sm font-medium text-foreground">{title}</p>
|
|
887
|
+
{body ? <p className="mt-1 text-sm text-muted-foreground">{body}</p> : null}
|
|
888
|
+
</div>
|
|
889
|
+
)
|
|
890
|
+
}
|
|
891
|
+
`,
|
|
892
|
+
|
|
893
|
+
'src/ui/object-explorer/components/query-error.tsx': `'use client'
|
|
894
|
+
|
|
895
|
+
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
|
896
|
+
import { Button } from '@/components/ui/button'
|
|
897
|
+
import { copy } from '@/lib/copy'
|
|
898
|
+
|
|
899
|
+
/**
|
|
900
|
+
* Recoverable failure, kept distinct from an empty result.
|
|
901
|
+
*
|
|
902
|
+
* Rendering a failed read as "no records" tells the customer their data is
|
|
903
|
+
* gone when the truth is that the request did not succeed. The primitive
|
|
904
|
+
* carries \`role="alert"\`, so a screen reader is told without being polled.
|
|
905
|
+
*/
|
|
906
|
+
export function QueryError({ message, onRetry }: { message: string; onRetry?: () => void }) {
|
|
907
|
+
return (
|
|
908
|
+
<Alert variant="destructive">
|
|
909
|
+
<AlertTitle>{copy.explorer.errorTitle}</AlertTitle>
|
|
910
|
+
<AlertDescription>
|
|
911
|
+
<p>{message}</p>
|
|
912
|
+
{onRetry ? (
|
|
913
|
+
<Button variant="outline" className="mt-2 h-11 sm:h-9" onClick={onRetry}>
|
|
914
|
+
{copy.explorer.retry}
|
|
915
|
+
</Button>
|
|
916
|
+
) : null}
|
|
917
|
+
</AlertDescription>
|
|
918
|
+
</Alert>
|
|
919
|
+
)
|
|
920
|
+
}
|
|
921
|
+
`,
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
/* ------------------------------------------------------------------ *
|
|
926
|
+
* Human and agent entry points
|
|
927
|
+
* ------------------------------------------------------------------ */
|
|
928
|
+
|
|
929
|
+
function docFiles(name: string): Record<string, string> {
|
|
930
|
+
return {
|
|
931
|
+
'AGENTS.md': `# ${name}
|
|
932
|
+
|
|
933
|
+
A Frontera App: an ordinary Next.js App Router project whose deployable result
|
|
934
|
+
is static files, reading platform data through Blueprint.
|
|
935
|
+
|
|
936
|
+
## Read this first
|
|
937
|
+
|
|
938
|
+
Load \`.agents/skills/using-frontera-app-patterns/SKILL.md\`. It is the
|
|
939
|
+
dispatcher — it routes you to the one skill that governs the files you are
|
|
940
|
+
about to change. The patterns there are the ones this project already follows;
|
|
941
|
+
match them rather than inventing a second style.
|
|
942
|
+
|
|
943
|
+
For the CLI itself — logging in, discovering data, deploying — load
|
|
944
|
+
\`.agents/skills/using-frontera-cli/SKILL.md\` if it is present. It is written
|
|
945
|
+
by \`frontera app init\` and covers the commands rather than the code.
|
|
946
|
+
|
|
947
|
+
## The shape of the project
|
|
948
|
+
|
|
949
|
+
| Path | What it is |
|
|
950
|
+
|---|---|
|
|
951
|
+
| \`src/app/\` | route adapters only — layout, providers, thin pages |
|
|
952
|
+
| \`src/ui/<feature>/\` | feature folders; one component per file |
|
|
953
|
+
| \`src/components/ui/\` | shadcn primitives — copied source, yours to edit |
|
|
954
|
+
| \`src/lib/blueprint/<domain>/\` | the data layer: Blueprint reads live here |
|
|
955
|
+
| \`src/lib/copy.ts\` | user-visible strings |
|
|
956
|
+
| \`src/generated/\` | \`bun run blueprint:types\` output — never edited by hand |
|
|
957
|
+
|
|
958
|
+
\`src/ui/object-explorer/\` is the reference feature. It is a complete slice —
|
|
959
|
+
route adapter, feature entry, sub-components, hooks, data layer, tests — and
|
|
960
|
+
copying its shape is the fastest way to be right.
|
|
961
|
+
|
|
962
|
+
## Non-negotiables
|
|
963
|
+
|
|
964
|
+
1. Bun runs everything: \`bun install\`, \`bun run <script>\`, \`bun test\`.
|
|
965
|
+
2. Components never fetch. Hooks in \`src/lib/blueprint/\` fetch; components take props.
|
|
966
|
+
3. Filter, sort, cursor-page and aggregate on the server. Narrowing a fetched page misreports every total.
|
|
967
|
+
4. No secrets in the browser. Not in source, not in \`NEXT_PUBLIC_*\`, not in \`.env\`.
|
|
968
|
+
5. Colors come from tokens in \`src/app/globals.css\`. No hex literals in components.
|
|
969
|
+
6. Every route must exist as a static file after \`bun run build\`. No server runtime exists.
|
|
970
|
+
|
|
971
|
+
## Verify before claiming done
|
|
972
|
+
|
|
973
|
+
\`\`\`bash
|
|
974
|
+
bun run check
|
|
975
|
+
\`\`\`
|
|
976
|
+
|
|
977
|
+
That is \`typecheck\`, then \`bun test\`, then \`build\` — the build is the only
|
|
978
|
+
thing that proves the static export still works. For anything touching data,
|
|
979
|
+
also run it against real data with \`frontera app dev\`.
|
|
980
|
+
`,
|
|
981
|
+
|
|
982
|
+
'CLAUDE.md': `./AGENTS.md
|
|
983
|
+
`,
|
|
984
|
+
|
|
985
|
+
'README.md': `# ${name}
|
|
986
|
+
|
|
987
|
+
A Frontera App — a Next.js App Router project that builds to static files and
|
|
988
|
+
reads platform data through Blueprint.
|
|
989
|
+
|
|
990
|
+
## Getting started
|
|
991
|
+
|
|
992
|
+
\`\`\`bash
|
|
993
|
+
bun install
|
|
994
|
+
\`\`\`
|
|
995
|
+
|
|
996
|
+
\`\`\`bash
|
|
997
|
+
frontera app dev
|
|
998
|
+
\`\`\`
|
|
999
|
+
|
|
1000
|
+
\`frontera app dev\` starts Next and a local session broker, so the App runs
|
|
1001
|
+
with real, short-lived Blueprint access. \`bun run dev\` alone starts Next
|
|
1002
|
+
without a session — useful for pure layout work, not for data.
|
|
1003
|
+
|
|
1004
|
+
## Point it at your data
|
|
1005
|
+
|
|
1006
|
+
\`\`\`bash
|
|
1007
|
+
frontera blueprint list
|
|
1008
|
+
\`\`\`
|
|
1009
|
+
|
|
1010
|
+
Set \`STARTER_OBJECT_TYPE\` in \`src/lib/blueprint/starter.ts\` to one of the api
|
|
1011
|
+
names it prints. Then generate typed contracts for real features:
|
|
1012
|
+
|
|
1013
|
+
\`\`\`bash
|
|
1014
|
+
bun run blueprint:types
|
|
1015
|
+
\`\`\`
|
|
1016
|
+
|
|
1017
|
+
Commit \`src/generated/frontera-blueprint.ts\`. It is a projection of the
|
|
1018
|
+
Blueprint this workspace is granted; regenerate it after a publication or a
|
|
1019
|
+
grant change.
|
|
1020
|
+
|
|
1021
|
+
## Ship it
|
|
1022
|
+
|
|
1023
|
+
\`\`\`bash
|
|
1024
|
+
bun run check
|
|
1025
|
+
\`\`\`
|
|
1026
|
+
|
|
1027
|
+
\`\`\`bash
|
|
1028
|
+
frontera app deploy --no-promote
|
|
1029
|
+
\`\`\`
|
|
1030
|
+
|
|
1031
|
+
Deploying without promoting publishes an immutable version and returns a
|
|
1032
|
+
preview URL. Promote it once it looks right:
|
|
1033
|
+
|
|
1034
|
+
\`\`\`bash
|
|
1035
|
+
frontera app promote <version>
|
|
1036
|
+
\`\`\`
|
|
1037
|
+
|
|
1038
|
+
## Layout
|
|
1039
|
+
|
|
1040
|
+
\`\`\`text
|
|
1041
|
+
src/app/ route adapters: layout, providers, pages
|
|
1042
|
+
src/ui/<feature>/ feature folders — one component per file
|
|
1043
|
+
src/lib/blueprint/<domain>/ Blueprint reads (hooks + pure helpers)
|
|
1044
|
+
src/lib/copy.ts user-visible strings
|
|
1045
|
+
src/components/ui/ shadcn primitives (bunx shadcn@latest add <name>)
|
|
1046
|
+
\`\`\`
|
|
1047
|
+
|
|
1048
|
+
Agent instructions and the full pattern set live in \`AGENTS.md\` and
|
|
1049
|
+
\`.agents/skills/\`.
|
|
1050
|
+
`,
|
|
1051
|
+
}
|
|
1052
|
+
}
|