@mistriwork/engine 0.0.0-stage → 1.0.0-rc.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +17 -2
- package/dist/adapters/adapter.d.ts +693 -0
- package/dist/adapters/adapter.js +444 -0
- package/dist/adapters/cache/cacheStore.d.ts +75 -0
- package/dist/adapters/cache/cacheStore.js +160 -0
- package/dist/adapters/cache/resp.d.ts +52 -0
- package/dist/adapters/cache/resp.js +237 -0
- package/dist/adapters/commands/canonicalJson.d.ts +19 -0
- package/dist/adapters/commands/canonicalJson.js +154 -0
- package/dist/adapters/connect.d.ts +29 -0
- package/dist/adapters/connect.js +40 -0
- package/dist/adapters/connectionBudget.d.ts +101 -0
- package/dist/adapters/connectionBudget.js +128 -0
- package/dist/adapters/dbLatency.d.ts +35 -0
- package/dist/adapters/dbLatency.js +79 -0
- package/dist/adapters/epochCache.d.ts +50 -0
- package/dist/adapters/epochCache.js +85 -0
- package/dist/adapters/http/egress.d.ts +48 -0
- package/dist/adapters/http/egress.js +175 -0
- package/dist/adapters/http/httpAdapter.d.ts +71 -0
- package/dist/adapters/http/httpAdapter.js +512 -0
- package/dist/adapters/http/httpDescription.d.ts +32 -0
- package/dist/adapters/http/httpDescription.js +246 -0
- package/dist/adapters/http/openapi.d.ts +98 -0
- package/dist/adapters/http/openapi.js +487 -0
- package/dist/adapters/layoutRefusal.d.ts +24 -0
- package/dist/adapters/layoutRefusal.js +35 -0
- package/dist/adapters/net/networkPolicy.d.ts +44 -0
- package/dist/adapters/net/networkPolicy.js +123 -0
- package/dist/adapters/net/untrustedDatabase.d.ts +18 -0
- package/dist/adapters/net/untrustedDatabase.js +120 -0
- package/dist/adapters/poolAdmission.d.ts +43 -0
- package/dist/adapters/poolAdmission.js +184 -0
- package/dist/adapters/poolMetrics.d.ts +121 -0
- package/dist/adapters/poolMetrics.js +185 -0
- package/dist/adapters/postgres.d.ts +436 -0
- package/dist/adapters/postgres.js +1890 -0
- package/dist/adapters/postgresCatalog.d.ts +96 -0
- package/dist/adapters/postgresCatalog.js +387 -0
- package/dist/adapters/postgresCatalogDigest.d.ts +30 -0
- package/dist/adapters/postgresCatalogDigest.js +87 -0
- package/dist/adapters/postgresCatalogRows.d.ts +62 -0
- package/dist/adapters/postgresCatalogRows.js +37 -0
- package/dist/adapters/postgresDates.d.ts +1 -0
- package/dist/adapters/postgresDates.js +39 -0
- package/dist/adapters/postgresMaintenance.d.ts +33 -0
- package/dist/adapters/postgresMaintenance.js +179 -0
- package/dist/adapters/postgresPool.d.ts +255 -0
- package/dist/adapters/postgresPool.js +838 -0
- package/dist/adapters/postgresPooler.d.ts +28 -0
- package/dist/adapters/postgresPooler.js +267 -0
- package/dist/adapters/postgresReadCatalog.d.ts +31 -0
- package/dist/adapters/postgresReadCatalog.js +226 -0
- package/dist/adapters/postgresReadSnapshot.d.ts +46 -0
- package/dist/adapters/postgresReadSnapshot.js +133 -0
- package/dist/adapters/postgresSchemaEpoch.d.ts +24 -0
- package/dist/adapters/postgresSchemaEpoch.js +149 -0
- package/dist/adapters/postgresTransactionHandle.d.ts +173 -0
- package/dist/adapters/postgresTransactionHandle.js +524 -0
- package/dist/adapters/postgresValues.d.ts +30 -0
- package/dist/adapters/postgresValues.js +75 -0
- package/dist/adapters/replicas.d.ts +70 -0
- package/dist/adapters/replicas.js +208 -0
- package/dist/adapters/reservedSql.d.ts +38 -0
- package/dist/adapters/reservedSql.js +264 -0
- package/dist/adapters/rolePrivileges.d.ts +17 -0
- package/dist/adapters/rolePrivileges.js +22 -0
- package/dist/adapters/scoped/keyRows.d.ts +65 -0
- package/dist/adapters/scoped/keyRows.js +173 -0
- package/dist/adapters/scoped/postgresScopeCatalog.d.ts +16 -0
- package/dist/adapters/scoped/postgresScopeCatalog.js +117 -0
- package/dist/adapters/scoped/scopedCommandOperations.d.ts +5 -0
- package/dist/adapters/scoped/scopedCommandOperations.js +48 -0
- package/dist/adapters/scoped/scopedReads.d.ts +20 -0
- package/dist/adapters/scoped/scopedReads.js +174 -0
- package/dist/adapters/scoped/scopedTransaction.d.ts +89 -0
- package/dist/adapters/scoped/scopedTransaction.js +303 -0
- package/dist/adapters/scoped/sqliteScopeCatalog.d.ts +16 -0
- package/dist/adapters/scoped/sqliteScopeCatalog.js +241 -0
- package/dist/adapters/scoped/updateGuard.d.ts +3 -0
- package/dist/adapters/scoped/updateGuard.js +40 -0
- package/dist/adapters/serverSettings.d.ts +45 -0
- package/dist/adapters/serverSettings.js +46 -0
- package/dist/adapters/sqlite.d.ts +262 -0
- package/dist/adapters/sqlite.js +1434 -0
- package/dist/adapters/sqliteCostlyReads.d.ts +33 -0
- package/dist/adapters/sqliteCostlyReads.js +49 -0
- package/dist/adapters/sqliteDecimals.d.ts +22 -0
- package/dist/adapters/sqliteDecimals.js +57 -0
- package/dist/adapters/sqliteHandles.d.ts +43 -0
- package/dist/adapters/sqliteHandles.js +143 -0
- package/dist/adapters/sqliteStatementProcess.d.ts +29 -0
- package/dist/adapters/sqliteStatementProcess.js +131 -0
- package/dist/adapters/statementControl.d.ts +34 -0
- package/dist/adapters/statementControl.js +90 -0
- package/dist/adapters/storeHost.d.ts +130 -0
- package/dist/adapters/storeHost.js +13 -0
- package/dist/adapters/telemetry/drivers.d.ts +21 -0
- package/dist/adapters/telemetry/drivers.js +321 -0
- package/dist/adapters/telemetry/metrics.d.ts +63 -0
- package/dist/adapters/telemetry/metrics.js +122 -0
- package/dist/adapters/telemetry/otlp.d.ts +12 -0
- package/dist/adapters/telemetry/otlp.js +320 -0
- package/dist/adapters/telemetry/otlpEncode.d.ts +48 -0
- package/dist/adapters/telemetry/otlpEncode.js +366 -0
- package/dist/adapters/telemetry/otlpEnv.d.ts +1 -0
- package/dist/adapters/telemetry/otlpEnv.js +12 -0
- package/dist/adapters/telemetry/serverTiming.d.ts +83 -0
- package/dist/adapters/telemetry/serverTiming.js +503 -0
- package/dist/adapters/telemetry/start.d.ts +10 -0
- package/dist/adapters/telemetry/start.js +25 -0
- package/dist/adapters/telemetry/statement.d.ts +16 -0
- package/dist/adapters/telemetry/statement.js +134 -0
- package/dist/adapters/telemetry/trace.d.ts +127 -0
- package/dist/adapters/telemetry/trace.js +295 -0
- package/dist/adapters/writeAhead.d.ts +27 -0
- package/dist/adapters/writeAhead.js +46 -0
- package/dist/adapters/writeAttribution.d.ts +18 -0
- package/dist/adapters/writeAttribution.js +23 -0
- package/dist/adapters/writeCapture.d.ts +73 -0
- package/dist/adapters/writeCapture.js +20 -0
- package/dist/adapters/writeLanes.d.ts +94 -0
- package/dist/adapters/writeLanes.js +232 -0
- package/dist/engine.d.ts +15 -0
- package/dist/engine.js +13 -0
- package/dist/server/auth/credentials.d.ts +61 -0
- package/dist/server/auth/credentials.js +260 -0
- package/dist/server/cacheService.d.ts +50 -0
- package/dist/server/cacheService.js +110 -0
- package/dist/server/cli/args.d.ts +176 -0
- package/dist/server/cli/args.js +342 -0
- package/dist/server/cli/bin.d.ts +5 -0
- package/dist/server/cli/bin.js +66 -0
- package/dist/server/cli/browser.d.ts +15 -0
- package/dist/server/cli/browser.js +37 -0
- package/dist/server/cli/config.d.ts +50 -0
- package/dist/server/cli/config.js +206 -0
- package/dist/server/cli/startup.d.ts +36 -0
- package/dist/server/cli/startup.js +128 -0
- package/dist/server/cli/startupOptions.d.ts +63 -0
- package/dist/server/cli/startupOptions.js +143 -0
- package/dist/server/cli/subcommand.d.ts +28 -0
- package/dist/server/cli/subcommand.js +32 -0
- package/dist/server/cli/usage.d.ts +13 -0
- package/dist/server/cli/usage.js +31 -0
- package/dist/server/clientHttp.d.ts +184 -0
- package/dist/server/clientHttp.js +700 -0
- package/dist/server/clientSurface.d.ts +128 -0
- package/dist/server/clientSurface.js +182 -0
- package/dist/server/columnGrants.d.ts +103 -0
- package/dist/server/columnGrants.js +730 -0
- package/dist/server/compileCache.d.ts +1 -0
- package/dist/server/compileCache.js +30 -0
- package/dist/server/config/locations.d.ts +1 -0
- package/dist/server/config/locations.js +15 -0
- package/dist/server/configPaths.d.ts +7 -0
- package/dist/server/configPaths.js +34 -0
- package/dist/server/configSections.d.ts +64 -0
- package/dist/server/configSections.js +72 -0
- package/dist/server/connectionRedaction.d.ts +30 -0
- package/dist/server/connectionRedaction.js +77 -0
- package/dist/server/connections.d.ts +353 -0
- package/dist/server/connections.js +978 -0
- package/dist/server/constraints.d.ts +82 -0
- package/dist/server/constraints.js +169 -0
- package/dist/server/demo.d.ts +9 -0
- package/dist/server/demo.js +554 -0
- package/dist/server/devclient.d.ts +108 -0
- package/dist/server/devclient.js +361 -0
- package/dist/server/evidence.d.ts +55 -0
- package/dist/server/evidence.js +323 -0
- package/dist/server/hiddenColumns.d.ts +32 -0
- package/dist/server/hiddenColumns.js +238 -0
- package/dist/server/http/baselineHeaders.d.ts +24 -0
- package/dist/server/http/baselineHeaders.js +12 -0
- package/dist/server/http/captured.d.ts +35 -0
- package/dist/server/http/captured.js +73 -0
- package/dist/server/http/clock.d.ts +1 -0
- package/dist/server/http/clock.js +14 -0
- package/dist/server/http/connectionSource.d.ts +4 -0
- package/dist/server/http/connectionSource.js +48 -0
- package/dist/server/http/context.d.ts +189 -0
- package/dist/server/http/context.js +11 -0
- package/dist/server/http/crawlerText.d.ts +15 -0
- package/dist/server/http/crawlerText.js +32 -0
- package/dist/server/http/dataRoutes.d.ts +88 -0
- package/dist/server/http/dataRoutes.js +137 -0
- package/dist/server/http/door.d.ts +57 -0
- package/dist/server/http/door.js +39 -0
- package/dist/server/http/exportRoute.d.ts +15 -0
- package/dist/server/http/exportRoute.js +320 -0
- package/dist/server/http/firstPaint.d.ts +108 -0
- package/dist/server/http/firstPaint.js +262 -0
- package/dist/server/http/httpOperationRoutes.d.ts +11 -0
- package/dist/server/http/httpOperationRoutes.js +50 -0
- package/dist/server/http/pageReads.d.ts +20 -0
- package/dist/server/http/pageReads.js +205 -0
- package/dist/server/http/pageRoutes.d.ts +39 -0
- package/dist/server/http/pageRoutes.js +291 -0
- package/dist/server/http/probes.d.ts +22 -0
- package/dist/server/http/probes.js +27 -0
- package/dist/server/http/rateLimit.d.ts +131 -0
- package/dist/server/http/rateLimit.js +351 -0
- package/dist/server/http/readRoutes.d.ts +9 -0
- package/dist/server/http/readRoutes.js +131 -0
- package/dist/server/http/readRouting.d.ts +20 -0
- package/dist/server/http/readRouting.js +80 -0
- package/dist/server/http/recordCompose.d.ts +67 -0
- package/dist/server/http/recordCompose.js +167 -0
- package/dist/server/http/recordWrites.d.ts +89 -0
- package/dist/server/http/recordWrites.js +298 -0
- package/dist/server/http/refusalReport.d.ts +38 -0
- package/dist/server/http/refusalReport.js +63 -0
- package/dist/server/http/reply.d.ts +130 -0
- package/dist/server/http/reply.js +394 -0
- package/dist/server/http/requestOrigin.d.ts +11 -0
- package/dist/server/http/requestOrigin.js +8 -0
- package/dist/server/http/router.d.ts +45 -0
- package/dist/server/http/router.js +22 -0
- package/dist/server/http/serverCore.d.ts +68 -0
- package/dist/server/http/serverCore.js +177 -0
- package/dist/server/http/traceRequest.d.ts +30 -0
- package/dist/server/http/traceRequest.js +224 -0
- package/dist/server/http/trust.d.ts +261 -0
- package/dist/server/http/trust.js +472 -0
- package/dist/server/http/writeRefusal.d.ts +33 -0
- package/dist/server/http/writeRefusal.js +24 -0
- package/dist/server/httpConnection.d.ts +49 -0
- package/dist/server/httpConnection.js +170 -0
- package/dist/server/impact.d.ts +55 -0
- package/dist/server/impact.js +47 -0
- package/dist/server/journal.d.ts +134 -0
- package/dist/server/journal.js +398 -0
- package/dist/server/keysetReadHttp.d.ts +52 -0
- package/dist/server/keysetReadHttp.js +85 -0
- package/dist/server/layouts.d.ts +49 -0
- package/dist/server/layouts.js +192 -0
- package/dist/server/migrate/cli.d.ts +55 -0
- package/dist/server/migrate/cli.js +517 -0
- package/dist/server/migrate/database.d.ts +37 -0
- package/dist/server/migrate/database.js +247 -0
- package/dist/server/migrate/desired.d.ts +76 -0
- package/dist/server/migrate/desired.js +292 -0
- package/dist/server/migrate/files.d.ts +34 -0
- package/dist/server/migrate/files.js +141 -0
- package/dist/server/migrate/flyway.d.ts +7 -0
- package/dist/server/migrate/flyway.js +69 -0
- package/dist/server/migrate/history.d.ts +70 -0
- package/dist/server/migrate/history.js +211 -0
- package/dist/server/migrate/runner.d.ts +100 -0
- package/dist/server/migrate/runner.js +409 -0
- package/dist/server/migrate/snapshot.d.ts +30 -0
- package/dist/server/migrate/snapshot.js +239 -0
- package/dist/server/migrate/target.d.ts +33 -0
- package/dist/server/migrate/target.js +82 -0
- package/dist/server/openapiDraft.d.ts +25 -0
- package/dist/server/openapiDraft.js +171 -0
- package/dist/server/operations/diagnostics.d.ts +101 -0
- package/dist/server/operations/diagnostics.js +284 -0
- package/dist/server/operations/requestMetrics.d.ts +33 -0
- package/dist/server/operations/requestMetrics.js +94 -0
- package/dist/server/packageVersion.d.ts +1 -0
- package/dist/server/packageVersion.js +11 -0
- package/dist/server/poolConfig.d.ts +34 -0
- package/dist/server/poolConfig.js +114 -0
- package/dist/server/product.d.ts +20 -0
- package/dist/server/product.js +12 -0
- package/dist/server/recordHistoryOperations.d.ts +39 -0
- package/dist/server/recordHistoryOperations.js +43 -0
- package/dist/server/recordWriteOperations.d.ts +111 -0
- package/dist/server/recordWriteOperations.js +329 -0
- package/dist/server/resourceReadHttp.d.ts +81 -0
- package/dist/server/resourceReadHttp.js +606 -0
- package/dist/server/resourceReadOperations.d.ts +147 -0
- package/dist/server/resourceReadOperations.js +452 -0
- package/dist/server/scaffoldMachine.d.ts +54 -0
- package/dist/server/scaffoldMachine.js +184 -0
- package/dist/server/seed/cli.d.ts +20 -0
- package/dist/server/seed/cli.js +164 -0
- package/dist/server/seed/lexicon.d.ts +26 -0
- package/dist/server/seed/lexicon.js +585 -0
- package/dist/server/seed/seed.d.ts +70 -0
- package/dist/server/seed/seed.js +779 -0
- package/dist/server/seed/words.d.ts +21 -0
- package/dist/server/seed/words.js +164 -0
- package/dist/server/serverRequests.d.ts +6 -0
- package/dist/server/serverRequests.js +33 -0
- package/dist/server/serverTimeouts.d.ts +33 -0
- package/dist/server/serverTimeouts.js +32 -0
- package/dist/server/shapeonly.d.ts +32 -0
- package/dist/server/shapeonly.js +110 -0
- package/dist/server/startupWarnings.d.ts +32 -0
- package/dist/server/startupWarnings.js +92 -0
- package/dist/shared/addressParams.d.ts +28 -0
- package/dist/shared/addressParams.js +47 -0
- package/dist/shared/appCards.d.ts +123 -0
- package/dist/shared/appCards.js +385 -0
- package/dist/shared/appComponents.d.ts +177 -0
- package/dist/shared/appComponents.js +422 -0
- package/dist/shared/appDocuments.d.ts +43 -0
- package/dist/shared/appDocuments.js +131 -0
- package/dist/shared/appEmbeds.d.ts +21 -0
- package/dist/shared/appEmbeds.js +24 -0
- package/dist/shared/appIcons.d.ts +28 -0
- package/dist/shared/appIcons.js +223 -0
- package/dist/shared/appLinks.d.ts +17 -0
- package/dist/shared/appLinks.js +124 -0
- package/dist/shared/appListColumns.d.ts +3 -0
- package/dist/shared/appListColumns.js +66 -0
- package/dist/shared/appLocale.d.ts +36 -0
- package/dist/shared/appLocale.js +86 -0
- package/dist/shared/appMessages.d.ts +44 -0
- package/dist/shared/appMessages.js +78 -0
- package/dist/shared/appNoun.d.ts +21 -0
- package/dist/shared/appNoun.js +32 -0
- package/dist/shared/autoBucket.d.ts +98 -0
- package/dist/shared/autoBucket.js +305 -0
- package/dist/shared/autoValues.d.ts +25 -0
- package/dist/shared/autoValues.js +25 -0
- package/dist/shared/breakdown.d.ts +228 -0
- package/dist/shared/breakdown.js +557 -0
- package/dist/shared/breakdowntext.d.ts +41 -0
- package/dist/shared/breakdowntext.js +400 -0
- package/dist/shared/builtInThemes.d.ts +2 -0
- package/dist/shared/builtInThemes.js +771 -0
- package/dist/shared/chartSeparation.d.ts +11 -0
- package/dist/shared/chartSeparation.js +58 -0
- package/dist/shared/clauses.d.ts +22 -0
- package/dist/shared/clauses.js +97 -0
- package/dist/shared/columnCaption.d.ts +16 -0
- package/dist/shared/columnCaption.js +90 -0
- package/dist/shared/commandFieldPath.d.ts +2 -0
- package/dist/shared/commandFieldPath.js +7 -0
- package/dist/shared/commandWire.d.ts +26 -0
- package/dist/shared/commandWire.js +41 -0
- package/dist/shared/computed.d.ts +161 -0
- package/dist/shared/computed.js +378 -0
- package/dist/shared/computedParse.d.ts +13 -0
- package/dist/shared/computedParse.js +278 -0
- package/dist/shared/computedValue.d.ts +47 -0
- package/dist/shared/computedValue.js +191 -0
- package/dist/shared/connection.d.ts +30 -0
- package/dist/shared/connection.js +1 -0
- package/dist/shared/csv.d.ts +20 -0
- package/dist/shared/csv.js +63 -0
- package/dist/shared/dashLayout.d.ts +30 -0
- package/dist/shared/dashLayout.js +132 -0
- package/dist/shared/dashboardAsks.d.ts +93 -0
- package/dist/shared/dashboardAsks.js +129 -0
- package/dist/shared/dashboardTrend.d.ts +20 -0
- package/dist/shared/dashboardTrend.js +50 -0
- package/dist/shared/declaredReferences.d.ts +50 -0
- package/dist/shared/declaredReferences.js +202 -0
- package/dist/shared/definedFields.d.ts +63 -0
- package/dist/shared/definedFields.js +103 -0
- package/dist/shared/definedValues.d.ts +63 -0
- package/dist/shared/definedValues.js +184 -0
- package/dist/shared/displayDefaults.d.ts +135 -0
- package/dist/shared/displayDefaults.js +529 -0
- package/dist/shared/dueDate.d.ts +6 -0
- package/dist/shared/dueDate.js +6 -0
- package/dist/shared/duration.d.ts +4 -0
- package/dist/shared/duration.js +23 -0
- package/dist/shared/fileBounds.d.ts +9 -0
- package/dist/shared/fileBounds.js +9 -0
- package/dist/shared/filter.d.ts +217 -0
- package/dist/shared/filter.js +305 -0
- package/dist/shared/firstPaint.d.ts +24 -0
- package/dist/shared/firstPaint.js +33 -0
- package/dist/shared/form.d.ts +21 -0
- package/dist/shared/form.js +25 -0
- package/dist/shared/formValues.d.ts +104 -0
- package/dist/shared/formValues.js +352 -0
- package/dist/shared/funnel.d.ts +45 -0
- package/dist/shared/funnel.js +108 -0
- package/dist/shared/httpOperations.d.ts +48 -0
- package/dist/shared/httpOperations.js +9 -0
- package/dist/shared/iconGlyphs.d.ts +4 -0
- package/dist/shared/iconGlyphs.js +151 -0
- package/dist/shared/iconWeights.d.ts +3 -0
- package/dist/shared/iconWeights.js +162 -0
- package/dist/shared/imageWidths.d.ts +32 -0
- package/dist/shared/imageWidths.js +38 -0
- package/dist/shared/keysetPage.d.ts +64 -0
- package/dist/shared/keysetPage.js +161 -0
- package/dist/shared/like.d.ts +20 -0
- package/dist/shared/like.js +29 -0
- package/dist/shared/listEmpty.d.ts +12 -0
- package/dist/shared/listEmpty.js +36 -0
- package/dist/shared/listFilters.d.ts +28 -0
- package/dist/shared/listFilters.js +84 -0
- package/dist/shared/listPresentations.d.ts +199 -0
- package/dist/shared/listPresentations.js +7 -0
- package/dist/shared/listQuestion.d.ts +52 -0
- package/dist/shared/listQuestion.js +137 -0
- package/dist/shared/lookPalettes.d.ts +62 -0
- package/dist/shared/lookPalettes.js +306 -0
- package/dist/shared/maskQuoted.d.ts +24 -0
- package/dist/shared/maskQuoted.js +48 -0
- package/dist/shared/mentionLabel.d.ts +19 -0
- package/dist/shared/mentionLabel.js +38 -0
- package/dist/shared/mentionLookup.d.ts +17 -0
- package/dist/shared/mentionLookup.js +64 -0
- package/dist/shared/menuCounts.d.ts +16 -0
- package/dist/shared/menuCounts.js +74 -0
- package/dist/shared/model.d.ts +79 -0
- package/dist/shared/model.js +153 -0
- package/dist/shared/money.d.ts +44 -0
- package/dist/shared/money.js +81 -0
- package/dist/shared/moneyEntry.d.ts +7 -0
- package/dist/shared/moneyEntry.js +27 -0
- package/dist/shared/mount.d.ts +118 -0
- package/dist/shared/mount.js +248 -0
- package/dist/shared/niceTicks.d.ts +26 -0
- package/dist/shared/niceTicks.js +50 -0
- package/dist/shared/order.d.ts +59 -0
- package/dist/shared/order.js +133 -0
- package/dist/shared/page.d.ts +135 -0
- package/dist/shared/page.js +1187 -0
- package/dist/shared/pageAnatomy.d.ts +99 -0
- package/dist/shared/pageAnatomy.js +370 -0
- package/dist/shared/pageConfig.d.ts +25 -0
- package/dist/shared/pageConfig.js +1308 -0
- package/dist/shared/pageEmptyActions.d.ts +12 -0
- package/dist/shared/pageEmptyActions.js +20 -0
- package/dist/shared/pageLayouts.d.ts +72 -0
- package/dist/shared/pageLayouts.js +205 -0
- package/dist/shared/pageModel.d.ts +785 -0
- package/dist/shared/pageModel.js +121 -0
- package/dist/shared/pagePaths.d.ts +180 -0
- package/dist/shared/pagePaths.js +869 -0
- package/dist/shared/pagePlans.d.ts +52 -0
- package/dist/shared/pagePlans.js +108 -0
- package/dist/shared/pageRoutes.d.ts +9 -0
- package/dist/shared/pageRoutes.js +9 -0
- package/dist/shared/query.d.ts +166 -0
- package/dist/shared/query.js +1302 -0
- package/dist/shared/queryDates.d.ts +12 -0
- package/dist/shared/queryDates.js +101 -0
- package/dist/shared/queryOperands.d.ts +99 -0
- package/dist/shared/queryOperands.js +131 -0
- package/dist/shared/reads.d.ts +103 -0
- package/dist/shared/reads.js +154 -0
- package/dist/shared/recordLabel.d.ts +51 -0
- package/dist/shared/recordLabel.js +123 -0
- package/dist/shared/recordReadKeys.d.ts +30 -0
- package/dist/shared/recordReadKeys.js +51 -0
- package/dist/shared/recordReads.d.ts +268 -0
- package/dist/shared/recordReads.js +368 -0
- package/dist/shared/recordview.d.ts +198 -0
- package/dist/shared/recordview.js +356 -0
- package/dist/shared/referenceModel.d.ts +58 -0
- package/dist/shared/referenceModel.js +194 -0
- package/dist/shared/refusal.d.ts +64 -0
- package/dist/shared/refusal.js +144 -0
- package/dist/shared/relatedCreate.d.ts +30 -0
- package/dist/shared/relatedCreate.js +162 -0
- package/dist/shared/resourceHttp.d.ts +464 -0
- package/dist/shared/resourceHttp.js +1 -0
- package/dist/shared/richText.d.ts +85 -0
- package/dist/shared/richText.js +305 -0
- package/dist/shared/rollups.d.ts +143 -0
- package/dist/shared/rollups.js +64 -0
- package/dist/shared/rowScope.d.ts +72 -0
- package/dist/shared/rowScope.js +376 -0
- package/dist/shared/runtimeTables.d.ts +44 -0
- package/dist/shared/runtimeTables.js +79 -0
- package/dist/shared/schema.d.ts +523 -0
- package/dist/shared/schema.js +627 -0
- package/dist/shared/schematext.d.ts +38 -0
- package/dist/shared/schematext.js +101 -0
- package/dist/shared/scopeCatalog.d.ts +29 -0
- package/dist/shared/scopeCatalog.js +468 -0
- package/dist/shared/servedApp.d.ts +681 -0
- package/dist/shared/servedApp.js +45 -0
- package/dist/shared/sessionLease.d.ts +34 -0
- package/dist/shared/sessionLease.js +3 -0
- package/dist/shared/shape.d.ts +55 -0
- package/dist/shared/shape.js +168 -0
- package/dist/shared/slug.d.ts +1 -0
- package/dist/shared/slug.js +12 -0
- package/dist/shared/sortText.d.ts +14 -0
- package/dist/shared/sortText.js +26 -0
- package/dist/shared/sqlRelation.d.ts +4 -0
- package/dist/shared/sqlRelation.js +8 -0
- package/dist/shared/sqlsaved.d.ts +44 -0
- package/dist/shared/sqlsaved.js +63 -0
- package/dist/shared/stitchedPaths.d.ts +27 -0
- package/dist/shared/stitchedPaths.js +38 -0
- package/dist/shared/structuredFilter.d.ts +22 -0
- package/dist/shared/structuredFilter.js +1 -0
- package/dist/shared/subtitlePath.d.ts +20 -0
- package/dist/shared/subtitlePath.js +24 -0
- package/dist/shared/suggest.d.ts +18 -0
- package/dist/shared/suggest.js +59 -0
- package/dist/shared/tableSizing.d.ts +4 -0
- package/dist/shared/tableSizing.js +9 -0
- package/dist/shared/tagLists.d.ts +57 -0
- package/dist/shared/tagLists.js +34 -0
- package/dist/shared/textRules.d.ts +54 -0
- package/dist/shared/textRules.js +133 -0
- package/dist/shared/theme.d.ts +94 -0
- package/dist/shared/theme.js +800 -0
- package/dist/shared/themeColor.d.ts +34 -0
- package/dist/shared/themeColor.js +109 -0
- package/dist/shared/themeFaces.d.ts +30 -0
- package/dist/shared/themeFaces.js +42 -0
- package/dist/shared/themeInk.d.ts +20 -0
- package/dist/shared/themeInk.js +52 -0
- package/dist/shared/themeLooks.d.ts +9 -0
- package/dist/shared/themeLooks.js +572 -0
- package/dist/shared/themeParts.d.ts +26 -0
- package/dist/shared/themeParts.js +71 -0
- package/dist/shared/themePartsCheck.d.ts +21 -0
- package/dist/shared/themePartsCheck.js +91 -0
- package/dist/shared/themePresets.d.ts +85 -0
- package/dist/shared/themePresets.js +139 -0
- package/dist/shared/themeRecipeWords.d.ts +156 -0
- package/dist/shared/themeRecipeWords.js +156 -0
- package/dist/shared/themeRecipes.d.ts +213 -0
- package/dist/shared/themeRecipes.js +234 -0
- package/dist/shared/themeResolve.d.ts +55 -0
- package/dist/shared/themeResolve.js +52 -0
- package/dist/shared/themeTokens.d.ts +107 -0
- package/dist/shared/themeTokens.js +116 -0
- package/dist/shared/themeType.d.ts +38 -0
- package/dist/shared/themeType.js +205 -0
- package/dist/shared/unitWord.d.ts +4 -0
- package/dist/shared/unitWord.js +37 -0
- package/dist/shared/view.d.ts +195 -0
- package/dist/shared/view.js +987 -0
- package/dist/shared/viewLabels.d.ts +47 -0
- package/dist/shared/viewLabels.js +459 -0
- package/dist/shared/viewPaths.d.ts +248 -0
- package/dist/shared/viewPaths.js +294 -0
- package/dist/shared/viewtext.d.ts +57 -0
- package/dist/shared/viewtext.js +282 -0
- package/dist/shared/vocabulary.d.ts +71 -0
- package/dist/shared/vocabulary.js +249 -0
- package/dist/shared/weekStart.d.ts +7 -0
- package/dist/shared/weekStart.js +36 -0
- package/mark.svg +1 -0
- package/package.json +261 -4
- package/schema/model.d.ts +298 -0
- package/schema/theme.d.ts +587 -0
|
@@ -0,0 +1,1308 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Reading pages from configuration: the `pages` section of a tablewalk.json
|
|
3
|
+
* and an App's page bodies. A malformed page throws with the page and, where
|
|
4
|
+
* there is one, the fix; nothing is skipped.
|
|
5
|
+
*/
|
|
6
|
+
import { isMessageRef, parseCaption } from './appMessages.js';
|
|
7
|
+
import { parseTableSizing } from './tableSizing.js';
|
|
8
|
+
import { parseRelatedCreate } from './relatedCreate.js';
|
|
9
|
+
import { parseListEmpty } from './listEmpty.js';
|
|
10
|
+
import { CHART_SHAPES, FIGURE_FORMATS, FIGURE_FORMAT_WORDS, allowKeys, humanName } from './model.js';
|
|
11
|
+
import { headingOf } from './appNoun.js';
|
|
12
|
+
import { CARDS_MAX_LINES, parseCards, parseFacts } from './appCards.js';
|
|
13
|
+
import { parseIcon } from './appIcons.js';
|
|
14
|
+
import { slug } from './slug.js';
|
|
15
|
+
import { parsePageLayout } from './pageLayouts.js';
|
|
16
|
+
import { parseSortText } from './sortText.js';
|
|
17
|
+
import { componentPropValues, parseComponentDecl, parsePageReads, parsePageTotals, parseRecordFields } from './appComponents.js';
|
|
18
|
+
import { parseAppLinkAction, parseAppTarget } from './appLinks.js';
|
|
19
|
+
import { WIDTHS, OFFSETS, SECTION_REGIONS, FIELD_COLUMNS, MAX_STREAMS, MAX_HISTORY_LIMIT, MAX_SECTIONS, MAX_PAGE_SECTIONS, screenSections, GROUPS_MOST, GROUP_ROWS_MOST, } from './pageModel.js';
|
|
20
|
+
/* ---------- config ---------- */
|
|
21
|
+
/**
|
|
22
|
+
* Read the `pages` section of a tablewalk.json.
|
|
23
|
+
*
|
|
24
|
+
* Throws on a malformed file rather than skipping the bad entry: a page that
|
|
25
|
+
* silently did not appear is a bug report that starts "I definitely wrote
|
|
26
|
+
* it". Everything a config file can get wrong is named with the page it is
|
|
27
|
+
* in and, where there is one, the fix.
|
|
28
|
+
*/
|
|
29
|
+
export function parsePagesConfig(raw, where) {
|
|
30
|
+
const pages = raw?.pages;
|
|
31
|
+
if (pages === undefined)
|
|
32
|
+
return [];
|
|
33
|
+
if (!Array.isArray(pages))
|
|
34
|
+
throw new Error(`${where}: "pages" must be an array.`);
|
|
35
|
+
const seen = new Set();
|
|
36
|
+
return pages.map((entry, i) => {
|
|
37
|
+
const at = `${where}: page ${i + 1}`;
|
|
38
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
39
|
+
throw new Error(`${at} is not an object.`);
|
|
40
|
+
}
|
|
41
|
+
const p = entry;
|
|
42
|
+
const said = typeof p.label === 'string' && p.label.trim() ? ` ("${p.label.trim()}")` : '';
|
|
43
|
+
if (p.id !== undefined && (typeof p.id !== 'string' || !p.id.trim())) {
|
|
44
|
+
throw new Error(`${at}${said} has an "id" that is not a non-empty string.`);
|
|
45
|
+
}
|
|
46
|
+
const named = p.label !== undefined ? parseCaption(p.label, `${at}.label`) : typeof p.base === 'string' ? humanName(p.base.trim()) : '';
|
|
47
|
+
const id = p.id === undefined ? `config-${slug(named, 'page')}` : String(p.id);
|
|
48
|
+
const page = parsePageBody(p, at, id, true);
|
|
49
|
+
/* An embed and a note's action name an App's views; a tablewalk.json has none to name. */
|
|
50
|
+
for (const [n, section] of page.sections.entries()) {
|
|
51
|
+
if (section.kind === 'list' && section.view !== undefined) {
|
|
52
|
+
throw new Error(`${at} ("${page.name}") section ${n + 1} embeds the view "${section.view}", and a config file has no views: write a related list ({ kind: "list", from, whose }) instead.`);
|
|
53
|
+
}
|
|
54
|
+
if (section.kind === 'note' && section.action) {
|
|
55
|
+
throw new Error(`${at} ("${page.name}") section ${n + 1} is a note whose "action" needs an App, and a config file has no views to open. Remove "action".`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (seen.has(id))
|
|
59
|
+
throw new Error(`${at} ("${page.name}") repeats the id "${id}".`);
|
|
60
|
+
seen.add(id);
|
|
61
|
+
return page;
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
/* Spellings renamed before 1.0, refused with the word that replaced them (allowKeys' `refused`). */
|
|
65
|
+
/* A call to action's emphasis is its `intent`, as a button's is; `kind` names what a thing is. */
|
|
66
|
+
const CUSTOM_RENAMED = {
|
|
67
|
+
totals: 'is now "figures": the named aggregate reads its component receives as figures.<name> ("totals" is a list\'s footer of sums)',
|
|
68
|
+
};
|
|
69
|
+
const INTENT_RENAMED = { kind: 'is now "intent", as a button says it: "primary" or "secondary"' };
|
|
70
|
+
const PAGE_RENAMED = { onlyOn: 'is now "connection", as the root of the config file says it' };
|
|
71
|
+
/** The keys a page takes. A tablewalk.json page also names its `id`; an App page is keyed by it. */
|
|
72
|
+
export const PAGE_KEYS = ['label', 'base', 'connection', 'listTabs', 'empty', 'sections', 'aside', 'layout'];
|
|
73
|
+
/** An App page has no `connection`: the App names its one connection, and a page scoped to another would never show. Nor a `label`: it is called its resource's `noun`. */
|
|
74
|
+
export const APP_PAGE_KEYS = PAGE_KEYS.filter((key) => key !== 'connection' && key !== 'label');
|
|
75
|
+
/* `aside` and `layout` are an App page's alone: the Workbench's pages draw what they say, in the record's one skeleton. */
|
|
76
|
+
const WORKBENCH_PAGE_KEYS = PAGE_KEYS.filter((key) => key !== 'aside' && key !== 'layout');
|
|
77
|
+
const SECTION_COMMON = ['kind', 'title', 'region', 'tab', 'width', 'offsetLeft', 'offsetRight', 'hidden', 'when'];
|
|
78
|
+
/** A tab's name is a word or two across the strip, not a sentence. */
|
|
79
|
+
const MAX_TAB_NAME = 40;
|
|
80
|
+
/** The keys each page section kind takes. Anything else is refused by name. A `segment` is read before the
|
|
81
|
+
section is (appSegments.ts unfoldSegments), as its word in `query`. */
|
|
82
|
+
/* `view` is an authored kind with no section of its own: `embed(view, { whose })`
|
|
83
|
+
is read into a related list that remembers the view (PageList.view). */
|
|
84
|
+
export const PAGE_SECTION_KEYS = {
|
|
85
|
+
fields: [...SECTION_COMMON, 'show', 'columns', 'names', 'presentation', 'editable'],
|
|
86
|
+
/* The record a reference names, in a box of its own: the first shown field is its title, the rest its facts. */
|
|
87
|
+
card: [...SECTION_COMMON, 'show'],
|
|
88
|
+
/* A figure with its facts, its earlier value, its estimate and the calls to action beside it. */
|
|
89
|
+
headline: [...SECTION_COMMON, 'show', 'links', 'subfigure', 'was', 'note'],
|
|
90
|
+
/* The record's head: drawn where the head is, so nothing that would place it elsewhere. */
|
|
91
|
+
header: ['kind', 'status', 'facts', 'primary', 'destructive', 'actions'],
|
|
92
|
+
/* The one current related row as a card: nothing to page, sum or add under it. */
|
|
93
|
+
current: ['kind', 'title', 'region', 'tab', 'width', 'when', 'from', 'whose', 'show', 'query', 'sort', 'empty'],
|
|
94
|
+
timeline: [...SECTION_COMMON, 'streams', 'limit', 'create', 'newest', 'composer', 'tone', 'compact', 'side'],
|
|
95
|
+
history: [...SECTION_COMMON, 'limit'],
|
|
96
|
+
flows: [...SECTION_COMMON],
|
|
97
|
+
diagram: [...SECTION_COMMON],
|
|
98
|
+
custom: [...SECTION_COMMON, 'component', 'record', 'rows', 'figures', 'props'],
|
|
99
|
+
metric: [...SECTION_COMMON, 'from', 'whose', 'about', 'measure', 'query', 'segment', 'format', 'unit'],
|
|
100
|
+
breakdown: [...SECTION_COMMON, 'from', 'whose', 'about', 'by', 'measure', 'query', 'segment', 'limit', 'format', 'unit'],
|
|
101
|
+
chart: [
|
|
102
|
+
...SECTION_COMMON,
|
|
103
|
+
'from',
|
|
104
|
+
'whose',
|
|
105
|
+
'about',
|
|
106
|
+
'by',
|
|
107
|
+
'measure',
|
|
108
|
+
'shape',
|
|
109
|
+
'sort',
|
|
110
|
+
'query',
|
|
111
|
+
'segment',
|
|
112
|
+
'limit',
|
|
113
|
+
'height',
|
|
114
|
+
'target',
|
|
115
|
+
'format',
|
|
116
|
+
'unit',
|
|
117
|
+
],
|
|
118
|
+
note: [...SECTION_COMMON, 'text', 'icon', 'action', 'variant'],
|
|
119
|
+
view: [...SECTION_COMMON, 'view', 'whose', 'about', 'limit'],
|
|
120
|
+
list: [
|
|
121
|
+
...SECTION_COMMON,
|
|
122
|
+
'from',
|
|
123
|
+
'whose',
|
|
124
|
+
'show',
|
|
125
|
+
'query',
|
|
126
|
+
'segment',
|
|
127
|
+
'limit',
|
|
128
|
+
'sort',
|
|
129
|
+
'create',
|
|
130
|
+
'empty',
|
|
131
|
+
'sizing',
|
|
132
|
+
'presentation',
|
|
133
|
+
'totals',
|
|
134
|
+
'same',
|
|
135
|
+
'near',
|
|
136
|
+
'rowActions',
|
|
137
|
+
'actions',
|
|
138
|
+
'group',
|
|
139
|
+
],
|
|
140
|
+
};
|
|
141
|
+
export const STREAM_KEYS = ['from', 'whose', 'at', 'show', 'detail', 'author', 'query', 'about', 'side', 'tone', 'compact'];
|
|
142
|
+
/**
|
|
143
|
+
* One page body: a tablewalk.json page, whose `id` is its own or derives
|
|
144
|
+
* from the name, or an App page, whose id is its key. The body is the same
|
|
145
|
+
* either way, so the page builder, the scaffold and mistri check emit one
|
|
146
|
+
* shape. Every key is checked: an unknown one is refused with the list.
|
|
147
|
+
*/
|
|
148
|
+
export function parsePageBody(p, at, id, withId = false, noun, keys = WORKBENCH_PAGE_KEYS) {
|
|
149
|
+
const caption = p.label === undefined ? undefined : parseCaption(p.label, `${at}.label`);
|
|
150
|
+
if (typeof p.base !== 'string' || !p.base.trim()) {
|
|
151
|
+
throw new Error(`${at}${typeof p.label === 'string' ? ` ("${p.label.trim()}")` : ''} has no "base" table.`);
|
|
152
|
+
}
|
|
153
|
+
/* A page is called what its records are: its resource's noun, else its table's name. */
|
|
154
|
+
const name = caption ?? (noun ? headingOf(noun) : humanName(p.base.trim()));
|
|
155
|
+
/* Said by name rather than as an unknown key: an author who copied an App's page into a tablewalk.json is told
|
|
156
|
+
why it does not apply there, not that the word does not exist. */
|
|
157
|
+
if (p.layout !== undefined && !keys.includes('layout')) {
|
|
158
|
+
throw new Error(`[page-layout] ${at} ("${name}") has "layout", which is an App page's: a config file's page draws the record's one skeleton. Remove it.`);
|
|
159
|
+
}
|
|
160
|
+
allowKeys(p, withId ? [...keys, 'id'] : keys, `${at} ("${name}")`, keys.includes('connection') ? PAGE_RENAMED : undefined);
|
|
161
|
+
if (p.connection !== undefined && typeof p.connection !== 'string') {
|
|
162
|
+
throw new Error(`${at} ("${name}") has a "connection" that is not a string.`);
|
|
163
|
+
}
|
|
164
|
+
/* Refused rather than coerced, like every other boolean in this file:
|
|
165
|
+
`"listTabs": "false"` is a truthy string. */
|
|
166
|
+
if (p.listTabs !== undefined && typeof p.listTabs !== 'boolean') {
|
|
167
|
+
throw new Error(`${at} ("${name}") has "listTabs": ${JSON.stringify(p.listTabs)}. It must be true or false, without quotes.`);
|
|
168
|
+
}
|
|
169
|
+
/* The only value is the opt-out: a default aside is not something an author switches on. */
|
|
170
|
+
if (p.aside !== undefined && p.aside !== false) {
|
|
171
|
+
throw new Error(`${at} ("${name}") has "aside": ${JSON.stringify(p.aside)}. It can only be false, to leave the default aside out.`);
|
|
172
|
+
}
|
|
173
|
+
/* What every related list that says nothing of its own does while it has no rows. A list's own `empty` wins. */
|
|
174
|
+
if (p.empty !== undefined && p.empty !== 'hide' && p.empty !== 'collapse') {
|
|
175
|
+
throw new Error(`${at} ("${name}") has "empty": ${JSON.stringify(p.empty)}. A page's is 'hide' (leave an empty list out) or 'collapse' (say so in one line); a list takes guidance of its own.`);
|
|
176
|
+
}
|
|
177
|
+
const pageEmpty = p.empty;
|
|
178
|
+
const sections = parseSections(p.sections, at, name, keys.includes('connection')).map((section) => pageEmpty && section.kind === 'list' && section.presentation !== 'card' && !section.empty && !section.emptyMode
|
|
179
|
+
? { ...section, emptyMode: pageEmpty }
|
|
180
|
+
: section);
|
|
181
|
+
return {
|
|
182
|
+
id,
|
|
183
|
+
name,
|
|
184
|
+
base: p.base.trim(),
|
|
185
|
+
sections,
|
|
186
|
+
connection: p.connection,
|
|
187
|
+
/* Stored as written: an App's page draws a tab strip of its lists only
|
|
188
|
+
when it says `true`, and the Workbench's only unless it says `false`. */
|
|
189
|
+
...(typeof p.listTabs === 'boolean' ? { listTabs: p.listTabs } : {}),
|
|
190
|
+
...(pageEmpty ? { empty: pageEmpty } : {}),
|
|
191
|
+
...(p.aside === false ? { aside: false } : {}),
|
|
192
|
+
...(p.layout !== undefined ? { layout: parsePageLayout(p.layout, `${at} ("${name}")`) } : {}),
|
|
193
|
+
source: 'config',
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
function parseSections(raw, at, name, config) {
|
|
197
|
+
if (raw === undefined)
|
|
198
|
+
return [{ kind: 'fields', width: 'full' }];
|
|
199
|
+
if (!Array.isArray(raw))
|
|
200
|
+
throw new Error(`${at} ("${name}") has "sections" that are not an array.`);
|
|
201
|
+
if (raw.length > MAX_PAGE_SECTIONS) {
|
|
202
|
+
throw new Error(`${at} ("${name}") has more than ${MAX_PAGE_SECTIONS} sections, its tabs' included.`);
|
|
203
|
+
}
|
|
204
|
+
const sections = raw.map((entry, i) => parseSection(entry, `${at} ("${name}") section ${i + 1}`, config));
|
|
205
|
+
/* A page is a screen, not a report: what one screen draws is counted, so a record that holds more says it in
|
|
206
|
+
tabs, each a screen of its own (a major incident's comms log, bridge and review beside the ticket's work). */
|
|
207
|
+
const drawn = screenSections(sections);
|
|
208
|
+
if (drawn > MAX_SECTIONS) {
|
|
209
|
+
throw new Error(`${at} ("${name}") has more than ${MAX_SECTIONS} sections on one screen (${drawn}). A page is a screen, not a report: ` +
|
|
210
|
+
'give some of them a "tab", and each tab is counted as a screen of its own.');
|
|
211
|
+
}
|
|
212
|
+
/* A record has one head. */
|
|
213
|
+
if (sections.filter((s) => s.kind === 'fields' && s.presentation === 'header').length > 1) {
|
|
214
|
+
throw new Error(`${at} ("${name}") has two record headers. A record has one head: say every fact in one header({ … }).`);
|
|
215
|
+
}
|
|
216
|
+
/* A strip of one tab is a heading with a border round it: the name says
|
|
217
|
+
"these are tabs of each other", which takes two. Each region has its own. */
|
|
218
|
+
for (const region of SECTION_REGIONS) {
|
|
219
|
+
const tabs = new Set(sections.filter((s) => (s.region ?? 'main') === region && s.tab).map((s) => s.tab));
|
|
220
|
+
if (tabs.size === 1) {
|
|
221
|
+
throw new Error(`${at} ("${name}") names one tab, "${[...tabs][0]}"${region === 'aside' ? ' in its side panel' : ''}. A tab strip needs two tabs or more: name another, or leave "tab" out.`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return sections;
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* A column count, refused rather than clamped.
|
|
228
|
+
*
|
|
229
|
+
* Clamping `"columns": 7` to 3 would render a layout nobody asked for and say
|
|
230
|
+
* nothing about it; the file is the thing being edited, so the file is where
|
|
231
|
+
* the mistake belongs.
|
|
232
|
+
*/
|
|
233
|
+
function parseFieldColumns(value, at) {
|
|
234
|
+
if (value === undefined || value === null)
|
|
235
|
+
return undefined;
|
|
236
|
+
const n = Number(value);
|
|
237
|
+
if (!FIELD_COLUMNS.includes(n)) {
|
|
238
|
+
throw new Error(`${at} has "columns" of ${JSON.stringify(value)}. Use 1, 2 or 3.`);
|
|
239
|
+
}
|
|
240
|
+
return n;
|
|
241
|
+
}
|
|
242
|
+
/** A boolean the file must spell as one — `"true"` is a truthy string. */
|
|
243
|
+
function parseFlag(raw, at, key) {
|
|
244
|
+
if (raw === undefined)
|
|
245
|
+
return undefined;
|
|
246
|
+
if (typeof raw !== 'boolean') {
|
|
247
|
+
throw new Error(`${at} has "${key}": ${JSON.stringify(raw)}. It must be true or false, without quotes.`);
|
|
248
|
+
}
|
|
249
|
+
return raw || undefined;
|
|
250
|
+
}
|
|
251
|
+
/* The spellings a section may not use any more, each mapped to the word
|
|
252
|
+
that replaced it. Refused by name, in the parser's usual voice, so a page
|
|
253
|
+
written against the pre-rename format fails with its fix rather than
|
|
254
|
+
half-working. The renames (docs/apps.md, "The respelling"): `whose`
|
|
255
|
+
says the direction `path` hid, `about` moves the subject the
|
|
256
|
+
way `of` only implied, `show` and `sort` are the query
|
|
257
|
+
language's own words. */
|
|
258
|
+
const RENAMED = {
|
|
259
|
+
tint: '"tone", as a display rule says it: { kind: { internal: \'warn\' } } on the timeline, or one tone on a stream',
|
|
260
|
+
order: "\"newest\": 'first' (the default) or 'last', the way a conversation reads",
|
|
261
|
+
edit: '"editable", as a list view says it',
|
|
262
|
+
path: '"whose" — the column on the section\'s table that names this record, e.g. "customer_id" or "invoice_id.customer_id"',
|
|
263
|
+
of: '"about" — the record this section is about, named by a reference column of the base, e.g. "site_id"',
|
|
264
|
+
paths: '"show" — the fields to show, e.g. ["name", "account_id.name"]',
|
|
265
|
+
orderBy: '"sort" — written like the query bar, e.g. "due_on desc, name"',
|
|
266
|
+
};
|
|
267
|
+
function refuseRenamed(s, at, keys) {
|
|
268
|
+
for (const key of keys) {
|
|
269
|
+
if (s[key] !== undefined)
|
|
270
|
+
throw new Error(`${at} has "${key}", which is now ${RENAMED[key]}.`);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
/* The forms of a fields section that are gone, and what each is now, in one place: they were refused one at a time,
|
|
274
|
+
each in its own words, so an upgrade met them one load after another with no sight of the rest. Every refusal of
|
|
275
|
+
one of them ends with this whole table. */
|
|
276
|
+
const RETIRED_FIELDS_FORMS = [
|
|
277
|
+
["fields(…, { presentation: 'card' })", "card(title, 'dealer_id', ['rating', 'city']): a section of its own"],
|
|
278
|
+
["fields(…, { presentation: 'headline' })", "headline('price', { was, subfigure, note, links }): a section of its own"],
|
|
279
|
+
["fields(…, { presentation: 'accordion' })", "gone: a group of fields is always open; use 'summary', 'facts', 'table' or 'properties'"],
|
|
280
|
+
['fields(…, { collapsed })', 'gone with the accordion: leave it out'],
|
|
281
|
+
['fields(…, { actions })', 'headline(figure, { links })'],
|
|
282
|
+
['fields(…, { subfigure | was | note })', 'headline(figure, { subfigure | was | note })'],
|
|
283
|
+
];
|
|
284
|
+
/** The mapping as lines for a refusal's end, without a closing full stop. */
|
|
285
|
+
const retiredFieldsForms = () => `Fields forms that are gone, and what each is now:\n${RETIRED_FIELDS_FORMS.map(([was, now]) => ` ${was} → ${now}`).join('\n')}`;
|
|
286
|
+
/* What `fields` itself says; a headline and a card are sections of their own, and read into fields on the way. */
|
|
287
|
+
const FIELD_PRESENTATIONS = ['summary', 'facts', 'table', 'properties', 'headline', 'card'];
|
|
288
|
+
/* A field name or a label path, as `show` takes them: "price", "dealer_id.name". */
|
|
289
|
+
const FIELD_PATH = /^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$/;
|
|
290
|
+
/**
|
|
291
|
+
* `measure` is the dashboard's word — "count", "sum amount" — and the one a
|
|
292
|
+
* page metric or chart says, read into the planner's fn and column. Absent
|
|
293
|
+
* is a count.
|
|
294
|
+
*/
|
|
295
|
+
function parseMeasure(raw, at) {
|
|
296
|
+
if (raw === undefined)
|
|
297
|
+
return { fn: 'count' };
|
|
298
|
+
const m = /^\s*(count|sum|avg|min|max)(?:\s+(?:distinct\s+)?([A-Za-z_][\w]*))?(?:\s+as\s+\w+)?\s*$/i.exec(String(raw));
|
|
299
|
+
if (!m)
|
|
300
|
+
throw new Error(`${at} has a "measure" of "${String(raw)}" — write it like "count", "sum amount" or "avg total as mean".`);
|
|
301
|
+
const fn = m[1].toLowerCase();
|
|
302
|
+
if (fn !== 'count' && !m[2])
|
|
303
|
+
throw new Error(`${at} has a "measure" of "${String(raw).trim()}" with no column to ${fn}: write it like "${fn} amount".`);
|
|
304
|
+
return { fn, ...(m[2] === undefined ? {} : { column: m[2].trim() }) };
|
|
305
|
+
}
|
|
306
|
+
function parseSection(entry, at, config) {
|
|
307
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
308
|
+
throw new Error(`${at} is not an object.`);
|
|
309
|
+
}
|
|
310
|
+
let s = entry;
|
|
311
|
+
let kind = s.kind === undefined ? 'fields' : String(s.kind);
|
|
312
|
+
/* What a tablewalk.json page may say that an App's page may not: the options only the Workbench's page builder
|
|
313
|
+
sets, which an App leaves out as it leaves out a section (a hidden one is simply not written). */
|
|
314
|
+
if (!config) {
|
|
315
|
+
for (const key of ['hidden', 'offsetLeft', 'names', 'sizing']) {
|
|
316
|
+
if (s[key] !== undefined)
|
|
317
|
+
throw new Error(`${at} has "${key}", which only a config file page takes. ${key === 'hidden'
|
|
318
|
+
? 'An App leaves a section out instead of hiding it'
|
|
319
|
+
: key === 'sizing'
|
|
320
|
+
? /* Nothing on a page's related list keeps its columns from crowding; said so, rather than a replacement invented. */
|
|
321
|
+
'An App page does not use it: a related table fits its pane. For a wide one, name fewer columns in "show", or give the table a list view of its own, where `sizing: \'scroll\'` is taken, and link to it'
|
|
322
|
+
: 'An App page does not use it'}.`);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
/* The record's head and the current card are kinds of their own as they
|
|
326
|
+
are written, so a refusal says "header" and "current" as the author
|
|
327
|
+
did; the page draws them as the fields and the list they are. */
|
|
328
|
+
if (kind === 'header') {
|
|
329
|
+
/* `factLabels` is the page model's reading of the facts (pageModel.ts),
|
|
330
|
+
and its type is published, so an author finds it there and writes it.
|
|
331
|
+
A fact's word has one spelling, in `facts` itself. */
|
|
332
|
+
if (Object.hasOwn(s, 'factLabels'))
|
|
333
|
+
throw new Error(`${at} has no "factLabels": a fact's word is said in "facts" itself, as { column: 'steps', after: 'step' }. It takes: ${PAGE_SECTION_KEYS.header.join(', ')}.`);
|
|
334
|
+
allowKeys(s, PAGE_SECTION_KEYS.header, at);
|
|
335
|
+
return parseHeader(s, at);
|
|
336
|
+
}
|
|
337
|
+
const current = kind === 'current';
|
|
338
|
+
if (current) {
|
|
339
|
+
allowKeys(s, PAGE_SECTION_KEYS.current, at);
|
|
340
|
+
s = { ...s, kind: 'list', presentation: 'card' };
|
|
341
|
+
kind = 'list';
|
|
342
|
+
}
|
|
343
|
+
else if (kind === 'list' && s.presentation === 'card') {
|
|
344
|
+
throw new Error(`${at}: a related list's presentation is "gallery" or a cards object; the one current row as a card is its own section, current(title, from, whose, { query, sort, show }).`);
|
|
345
|
+
}
|
|
346
|
+
/* A card and a headline are written as the sections they are and drawn as the fields they show. */
|
|
347
|
+
const written = kind;
|
|
348
|
+
if (written === 'card' || written === 'headline') {
|
|
349
|
+
allowKeys(s, PAGE_SECTION_KEYS[written], at, written === 'headline' ? { actions: 'is now "links": the calls to action beside the figure' } : {});
|
|
350
|
+
s = { ...s, kind: 'fields', presentation: written };
|
|
351
|
+
kind = 'fields';
|
|
352
|
+
}
|
|
353
|
+
if (s.sizing !== undefined && kind !== 'list')
|
|
354
|
+
throw new Error(`${at}: sizing belongs on a related list.`);
|
|
355
|
+
if (s.region !== undefined && !SECTION_REGIONS.includes(s.region)) {
|
|
356
|
+
throw new Error(`${at} has an invalid "region". Use "main" or "aside".`);
|
|
357
|
+
}
|
|
358
|
+
const tab = s.tab === undefined ? undefined : parseCaption(s.tab, `${at}.tab`);
|
|
359
|
+
if (tab && tab.length > MAX_TAB_NAME) {
|
|
360
|
+
throw new Error(`${at} has a "tab" that is not a name of at most ${MAX_TAB_NAME} characters, like "Details".`);
|
|
361
|
+
}
|
|
362
|
+
/* Where a section is drawn: its region, and the tab of that region's strip. The sections that are about the
|
|
363
|
+
record's surroundings rather than the record — a card of another record, the one current row, the change
|
|
364
|
+
history — stand beside it unless the author puts them in the main column. */
|
|
365
|
+
const region = s.region ?? (written === 'card' || current || kind === 'history' ? 'aside' : undefined);
|
|
366
|
+
const placement = {
|
|
367
|
+
...(region === undefined ? {} : { region }),
|
|
368
|
+
...(tab === undefined ? {} : { tab }),
|
|
369
|
+
};
|
|
370
|
+
if (s.create !== undefined && kind !== 'list' && kind !== 'timeline')
|
|
371
|
+
throw new Error(`${at}: create belongs on a related list or a timeline.`);
|
|
372
|
+
if (s.presentation !== undefined && kind !== 'fields' && kind !== 'list')
|
|
373
|
+
throw new Error(`${at}: presentation belongs on fields (summary, facts, table, properties) or a related list (gallery, cards).`);
|
|
374
|
+
const width = parseWidth(s.width, at);
|
|
375
|
+
const offsetLeft = parseOffset(s.offsetLeft, at, 'offsetLeft');
|
|
376
|
+
const offsetRight = parseOffset(s.offsetRight, at, 'offsetRight');
|
|
377
|
+
const title = s.title === undefined ? undefined : isMessageRef(s.title) ? parseCaption(s.title, `${at}.title`) : String(s.title);
|
|
378
|
+
const hidden = parseFlag(s.hidden, at, 'hidden');
|
|
379
|
+
if (s.when !== undefined && (typeof s.when !== 'string' || !s.when.trim())) {
|
|
380
|
+
throw new Error(`${at} has a "when" that is not a condition like "status = converted".`);
|
|
381
|
+
}
|
|
382
|
+
const when = s.when === undefined ? undefined : s.when.trim();
|
|
383
|
+
if (kind === 'fields') {
|
|
384
|
+
refuseRenamed(s, at, ['paths', 'edit']);
|
|
385
|
+
if (s.presentation === 'header')
|
|
386
|
+
throw new Error(`${at}: the record's head is a section of its own, header({ status, facts, primary }), not a fields presentation.`);
|
|
387
|
+
if (written === 'fields' && (s.presentation === 'card' || s.presentation === 'headline'))
|
|
388
|
+
throw new Error(`${at}: a ${s.presentation} is its own section, ${s.presentation === 'card' ? "card(title, 'dealer_id', ['rating', 'city'])" : "headline('price', { was, subfigure, note, links })"}, not a fields presentation. ${retiredFieldsForms()}.`);
|
|
389
|
+
const authored = written === 'card' || written === 'headline' ? Object.fromEntries(Object.entries(s).filter(([k]) => k !== 'presentation')) : s;
|
|
390
|
+
allowKeys(authored, PAGE_SECTION_KEYS[written === 'card' || written === 'headline' ? written : 'fields'], at, {
|
|
391
|
+
collapsed: `is gone with the accordion: a group of fields is always open. ${retiredFieldsForms()}`,
|
|
392
|
+
...(written === 'fields'
|
|
393
|
+
? {
|
|
394
|
+
actions: `belongs on a headline(figure, { links }). ${retiredFieldsForms()}`,
|
|
395
|
+
subfigure: `belongs on a headline(figure, { subfigure }). ${retiredFieldsForms()}`,
|
|
396
|
+
was: `belongs on a headline(figure, { was }). ${retiredFieldsForms()}`,
|
|
397
|
+
note: `belongs on a headline(figure, { note }). ${retiredFieldsForms()}`,
|
|
398
|
+
}
|
|
399
|
+
: {}),
|
|
400
|
+
});
|
|
401
|
+
if (s.show !== undefined && !Array.isArray(s.show)) {
|
|
402
|
+
throw new Error(`${at} has a "show" that is not an array of field names.`);
|
|
403
|
+
}
|
|
404
|
+
if (s.presentation !== undefined && !FIELD_PRESENTATIONS.includes(s.presentation)) {
|
|
405
|
+
throw new Error(`${at}: a fields presentation is "summary", "facts", "table" or "properties"${s.presentation === 'accordion' ? `; the accordion is gone: a group of fields is always open. ${retiredFieldsForms()}` : ''}.`);
|
|
406
|
+
}
|
|
407
|
+
if (written === 'headline' && (!Array.isArray(s.show) || !s.show.length)) {
|
|
408
|
+
throw new Error(`${at}: a headline shows its figure and its facts; name them in "show", the figure first.`);
|
|
409
|
+
}
|
|
410
|
+
if (s.presentation === 'facts' && (!Array.isArray(s.show) || !s.show.length)) {
|
|
411
|
+
throw new Error(`${at}: a facts line says the values it names; name them in "show".`);
|
|
412
|
+
}
|
|
413
|
+
if (written === 'card' && (!Array.isArray(s.show) || !s.show.length)) {
|
|
414
|
+
throw new Error(`${at}: a card is titled by its first field and says the rest as facts; name them in "show", the title first.`);
|
|
415
|
+
}
|
|
416
|
+
if (s.editable !== undefined && s.editable !== true)
|
|
417
|
+
throw new Error(`${at} has "editable": ${JSON.stringify(s.editable)}. It is true, to edit the properties in place; leave it out otherwise.`);
|
|
418
|
+
if (s.editable === true && s.presentation !== undefined && !['summary', 'properties'].includes(s.presentation)) {
|
|
419
|
+
throw new Error(`${at}: "editable" belongs on the fields a reader edits — a summary or a property rail; a ${String(s.presentation)} says values, it does not hold them.`);
|
|
420
|
+
}
|
|
421
|
+
if (s.was !== undefined && (typeof s.was !== 'string' || !FIELD_PATH.test(s.was.trim()))) {
|
|
422
|
+
throw new Error(`${at} has a "was" that is not a field name, like "was_price": the figure's earlier value.`);
|
|
423
|
+
}
|
|
424
|
+
if (s.subfigure !== undefined && (typeof s.subfigure !== 'string' || !FIELD_PATH.test(s.subfigure.trim()))) {
|
|
425
|
+
throw new Error(`${at} has a "subfigure" that is not a field name or a label path like "account_id.name".`);
|
|
426
|
+
}
|
|
427
|
+
if (s.note !== undefined && (typeof s.note !== 'string' || !s.note.trim() || s.note.trim().length > 300)) {
|
|
428
|
+
throw new Error(`${at} has a "note" that is not text of at most 300 characters.`);
|
|
429
|
+
}
|
|
430
|
+
/* A reference says the record it names by default in an App, so the
|
|
431
|
+
one thing worth writing is the opt-out. */
|
|
432
|
+
if (s.names !== undefined && s.names !== false) {
|
|
433
|
+
throw new Error(`${at} has "names": ${JSON.stringify(s.names)}. A reference shows the record it names by default; say false to keep the raw id, or leave it out.`);
|
|
434
|
+
}
|
|
435
|
+
return {
|
|
436
|
+
kind: 'fields',
|
|
437
|
+
...placement,
|
|
438
|
+
title,
|
|
439
|
+
width,
|
|
440
|
+
offsetLeft,
|
|
441
|
+
offsetRight,
|
|
442
|
+
hidden,
|
|
443
|
+
...(when ? { when } : {}),
|
|
444
|
+
paths: s.show?.map(String),
|
|
445
|
+
columns: parseFieldColumns(s.columns, at),
|
|
446
|
+
names: s.names === false ? false : undefined,
|
|
447
|
+
...(s.presentation === undefined ? {} : { presentation: s.presentation }),
|
|
448
|
+
...(s.editable === true ? { edit: true } : {}),
|
|
449
|
+
...(s.links === undefined ? {} : { actions: parseHeadlineActions(s.links, at) }),
|
|
450
|
+
...(s.subfigure === undefined ? {} : { subfigure: s.subfigure.trim() }),
|
|
451
|
+
...(s.was === undefined ? {} : { was: s.was.trim() }),
|
|
452
|
+
...(s.note === undefined ? {} : { note: s.note.trim() }),
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
if (kind === 'timeline') {
|
|
456
|
+
refuseRenamed(s, at, ['tint', 'order']);
|
|
457
|
+
allowKeys(s, PAGE_SECTION_KEYS.timeline, at);
|
|
458
|
+
if (!Array.isArray(s.streams) || !s.streams.length) {
|
|
459
|
+
throw new Error(`${at} is a timeline and needs "streams" — the dated child rows it merges.`);
|
|
460
|
+
}
|
|
461
|
+
if (s.streams.length > MAX_STREAMS) {
|
|
462
|
+
throw new Error(`${at} merges more than ${MAX_STREAMS} streams.`);
|
|
463
|
+
}
|
|
464
|
+
const streams = s.streams.map((raw, i) => {
|
|
465
|
+
const here = `${at}, stream ${i + 1}`;
|
|
466
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
467
|
+
throw new Error(`${here} is not an object.`);
|
|
468
|
+
const t = raw;
|
|
469
|
+
refuseRenamed(t, here, ['path', 'tint']);
|
|
470
|
+
allowKeys(t, STREAM_KEYS, here);
|
|
471
|
+
const from = typeof t.from === 'string' ? t.from.trim() : '';
|
|
472
|
+
if (!from)
|
|
473
|
+
throw new Error(`${here} has no "from" table.`);
|
|
474
|
+
const path = typeof t.whose === 'string' ? t.whose.trim() : '';
|
|
475
|
+
if (!path)
|
|
476
|
+
throw new Error(`${here} has no "whose" — the column that leads back to this record.`);
|
|
477
|
+
const atCol = typeof t.at === 'string' ? t.at.trim() : '';
|
|
478
|
+
if (!atCol)
|
|
479
|
+
throw new Error(`${here} has no "at" — the date column the stream is ordered by.`);
|
|
480
|
+
const show = typeof t.show === 'string' ? t.show.trim() : '';
|
|
481
|
+
if (!show)
|
|
482
|
+
throw new Error(`${here} has no "show" — the line each row becomes.`);
|
|
483
|
+
/* `query` is the one spelling; the internal stream keeps its `filter`.
|
|
484
|
+
`author` says who wrote a row, where `by` groups one everywhere else;
|
|
485
|
+
the internal stream keeps its `by`. */
|
|
486
|
+
const filterRaw = t.query;
|
|
487
|
+
/* `about` as a list's: the stream's rows lead to the row a reference of the record names (a deal's company),
|
|
488
|
+
not to the record, so a timeline reaches its parent's other children. */
|
|
489
|
+
if (t.about !== undefined && (typeof t.about !== 'string' || !t.about.trim() || t.about.includes('.')))
|
|
490
|
+
throw new Error(`${here} has an "about" that is not a reference column of the page's table, e.g. "company_id".`);
|
|
491
|
+
/* The stream's own far side, where its rows name the other party in their own words (`person_id`, not `author_id`). */
|
|
492
|
+
if (t.side !== undefined && (typeof t.side !== 'string' || !t.side.trim()))
|
|
493
|
+
throw new Error(`${here} has a "side" that is not a condition like "person_id = record.caller_id".`);
|
|
494
|
+
/* The stream's own dress, where every row of it is one kind (time logged, a system event): no column to name. */
|
|
495
|
+
if (t.tone !== undefined && !TIMELINE_TONES.includes(t.tone))
|
|
496
|
+
throw new Error(`${here} has a "tone" that is not one of ${TIMELINE_TONES.join(', ')}: every row of this stream wears it.`);
|
|
497
|
+
if (t.compact !== undefined && t.compact !== true)
|
|
498
|
+
throw new Error(`${here} has a "compact" that is not true: every row of this stream is one line, or leave it out.`);
|
|
499
|
+
return {
|
|
500
|
+
...(typeof t.about === 'string' ? { of: t.about.trim() } : {}),
|
|
501
|
+
from,
|
|
502
|
+
path,
|
|
503
|
+
at: atCol,
|
|
504
|
+
show,
|
|
505
|
+
detail: t.detail === undefined ? undefined : String(t.detail).trim() || undefined,
|
|
506
|
+
by: t.author === undefined ? undefined : String(t.author).trim() || undefined,
|
|
507
|
+
filter: filterRaw === undefined ? undefined : String(filterRaw).trim() || undefined,
|
|
508
|
+
...(typeof t.side === 'string' ? { side: t.side.trim() } : {}),
|
|
509
|
+
...(t.tone === undefined ? {} : { tone: t.tone }),
|
|
510
|
+
...(t.compact === true ? { compact: true } : {}),
|
|
511
|
+
};
|
|
512
|
+
});
|
|
513
|
+
const limit = s.limit === undefined ? undefined : Number(s.limit);
|
|
514
|
+
if (limit !== undefined && (!Number.isInteger(limit) || limit < 1)) {
|
|
515
|
+
throw new Error(`${at} has a "limit" that is not a positive whole number.`);
|
|
516
|
+
}
|
|
517
|
+
if (s.newest !== undefined && s.newest !== 'first' && s.newest !== 'last') {
|
|
518
|
+
throw new Error(`${at} has a "newest" that is neither 'first' (the default) nor 'last' (a conversation, read down to the latest).`);
|
|
519
|
+
}
|
|
520
|
+
const composer = s.composer === undefined ? undefined : parseTimelineComposer(s.composer, at);
|
|
521
|
+
const tone = s.tone === undefined ? undefined : parseTimelineTone(s.tone, at);
|
|
522
|
+
const compact = s.compact === undefined ? undefined : parseTimelineCompact(s.compact, at);
|
|
523
|
+
/* The other party's rows: a condition in the query language over each stream's rows, where `record.<column>` is
|
|
524
|
+
the page's record. Judged per stream against the catalog with the rest of the section (page.ts timelineSides). */
|
|
525
|
+
if (s.side !== undefined && (typeof s.side !== 'string' || !s.side.trim())) {
|
|
526
|
+
throw new Error(`${at} has a "side" that is not a condition like "author_id = record.caller_id".`);
|
|
527
|
+
}
|
|
528
|
+
const side = typeof s.side === 'string' ? s.side.trim() : undefined;
|
|
529
|
+
if (composer && s.create !== undefined) {
|
|
530
|
+
throw new Error(`${at} has both "composer" and "create": a conversation's composer is the commands' (reply, note), and "create" is the form's. Keep one.`);
|
|
531
|
+
}
|
|
532
|
+
return {
|
|
533
|
+
kind: 'timeline',
|
|
534
|
+
...placement,
|
|
535
|
+
title,
|
|
536
|
+
streams,
|
|
537
|
+
limit,
|
|
538
|
+
width,
|
|
539
|
+
offsetLeft,
|
|
540
|
+
offsetRight,
|
|
541
|
+
hidden,
|
|
542
|
+
...(s.newest === 'last' ? { newest: 'last' } : {}),
|
|
543
|
+
...(composer ? { composer } : {}),
|
|
544
|
+
...(tone ? { tone } : {}),
|
|
545
|
+
...(compact ? { compact } : {}),
|
|
546
|
+
...(side ? { side } : {}),
|
|
547
|
+
...(s.create === undefined ? {} : { create: parseRelatedCreate(s.create, at) }),
|
|
548
|
+
...(when ? { when } : {}),
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
if (kind === 'custom') {
|
|
552
|
+
allowKeys(s, PAGE_SECTION_KEYS.custom, at, CUSTOM_RENAMED);
|
|
553
|
+
/* The author's own component, placed like any section: its reads are
|
|
554
|
+
declared, so the planner checks and runs them (appComponents.ts). */
|
|
555
|
+
const component = parseComponentDecl(s.component, `${at}, "component"`, 'section');
|
|
556
|
+
const parsed = parsePageReads(s.rows, at);
|
|
557
|
+
const reads = parsed &&
|
|
558
|
+
Object.fromEntries(Object.entries(parsed).map(([name, read]) => [
|
|
559
|
+
name,
|
|
560
|
+
read.sort === undefined ? read : { ...read, orderBy: parseSort(read.sort, `${at}, rows "${name}"`) },
|
|
561
|
+
]));
|
|
562
|
+
const totals = parsePageTotals(s.figures, at);
|
|
563
|
+
return {
|
|
564
|
+
kind: 'custom',
|
|
565
|
+
...placement,
|
|
566
|
+
title,
|
|
567
|
+
width,
|
|
568
|
+
offsetLeft,
|
|
569
|
+
offsetRight,
|
|
570
|
+
hidden,
|
|
571
|
+
...(when ? { when } : {}),
|
|
572
|
+
component,
|
|
573
|
+
...(s.record === undefined ? {} : { record: parseRecordFields(s.record, at) }),
|
|
574
|
+
...(reads ? { reads } : {}),
|
|
575
|
+
...(totals ? { aggregates: totals } : {}),
|
|
576
|
+
props: componentPropValues(component, s.props, at),
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
if (kind === 'history') {
|
|
580
|
+
allowKeys(s, PAGE_SECTION_KEYS.history, at);
|
|
581
|
+
const limit = s.limit === undefined ? undefined : Number(s.limit);
|
|
582
|
+
if (limit !== undefined && (!Number.isInteger(limit) || limit < 1 || limit > MAX_HISTORY_LIMIT)) {
|
|
583
|
+
throw new Error(`${at} has a "limit" that is not a whole number from 1 to ${MAX_HISTORY_LIMIT}.`);
|
|
584
|
+
}
|
|
585
|
+
return {
|
|
586
|
+
kind: 'history',
|
|
587
|
+
...placement,
|
|
588
|
+
title,
|
|
589
|
+
width,
|
|
590
|
+
offsetLeft,
|
|
591
|
+
offsetRight,
|
|
592
|
+
hidden,
|
|
593
|
+
...(limit === undefined ? {} : { limit }),
|
|
594
|
+
...(when ? { when } : {}),
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
if (kind === 'flows' || kind === 'diagram') {
|
|
598
|
+
allowKeys(s, PAGE_SECTION_KEYS[kind], at);
|
|
599
|
+
return {
|
|
600
|
+
kind,
|
|
601
|
+
...placement,
|
|
602
|
+
title,
|
|
603
|
+
width,
|
|
604
|
+
offsetLeft,
|
|
605
|
+
offsetRight,
|
|
606
|
+
hidden,
|
|
607
|
+
...(when ? { when } : {}),
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
if (kind === 'note') {
|
|
611
|
+
allowKeys(s, PAGE_SECTION_KEYS.note, at);
|
|
612
|
+
if (typeof s.text !== 'string' || !s.text.trim())
|
|
613
|
+
throw new Error(`${at} is a note and has no "text" — the words it says.`);
|
|
614
|
+
let action;
|
|
615
|
+
if (s.action !== undefined) {
|
|
616
|
+
action = parseAppLinkAction(s.action, `${at}, "action"`, true);
|
|
617
|
+
}
|
|
618
|
+
if (s.variant !== undefined && s.variant !== 'quiet' && s.variant !== 'callout') {
|
|
619
|
+
throw new Error(`${at} has a "variant" of ${JSON.stringify(s.variant)}: a note is "quiet" (small print, no mark) or a "callout" (a ground of its own); leave it out for the plain note.`);
|
|
620
|
+
}
|
|
621
|
+
return {
|
|
622
|
+
kind: 'note',
|
|
623
|
+
...placement,
|
|
624
|
+
title,
|
|
625
|
+
width,
|
|
626
|
+
offsetLeft,
|
|
627
|
+
offsetRight,
|
|
628
|
+
hidden,
|
|
629
|
+
...(when ? { when } : {}),
|
|
630
|
+
...(s.variant === undefined ? {} : { variant: s.variant }),
|
|
631
|
+
text: s.text.trim(),
|
|
632
|
+
...(s.icon === undefined ? {} : { icon: parseIcon(s.icon, at) }),
|
|
633
|
+
...(action ? { action } : {}),
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
if (kind === 'view') {
|
|
637
|
+
allowKeys(s, PAGE_SECTION_KEYS.view, at);
|
|
638
|
+
if (typeof s.view !== 'string' || !s.view.trim())
|
|
639
|
+
throw new Error(`${at} embeds no "view" — the key of one of the App's list views.`);
|
|
640
|
+
const path = typeof s.whose === 'string' ? s.whose.trim() : '';
|
|
641
|
+
if (!path) {
|
|
642
|
+
throw new Error(`${at} embeds "${s.view.trim()}" with no "whose" — on a page, an embedded list shows the rows *whose* named column ` +
|
|
643
|
+
"is this record: embed('invoices', { whose: 'customer_id' }).");
|
|
644
|
+
}
|
|
645
|
+
if (s.about !== undefined && (typeof s.about !== 'string' || !s.about.trim())) {
|
|
646
|
+
throw new Error(`${at} has an "about" that is not a non-empty string.`);
|
|
647
|
+
}
|
|
648
|
+
if (s.limit !== undefined && (typeof s.limit !== 'number' || !Number.isInteger(s.limit) || s.limit < 1)) {
|
|
649
|
+
throw new Error(`${at} has a "limit" that is not a positive whole number.`);
|
|
650
|
+
}
|
|
651
|
+
return {
|
|
652
|
+
kind: 'list',
|
|
653
|
+
...placement,
|
|
654
|
+
title,
|
|
655
|
+
width,
|
|
656
|
+
offsetLeft,
|
|
657
|
+
offsetRight,
|
|
658
|
+
hidden,
|
|
659
|
+
...(when ? { when } : {}),
|
|
660
|
+
view: s.view.trim(),
|
|
661
|
+
from: '',
|
|
662
|
+
path,
|
|
663
|
+
...(s.about === undefined ? {} : { of: s.about.trim() }),
|
|
664
|
+
...(s.limit === undefined ? {} : { limit: s.limit }),
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
if (kind !== 'metric' && kind !== 'list' && kind !== 'breakdown' && kind !== 'chart') {
|
|
668
|
+
throw new Error(`${at} has an unknown kind "${kind}". Use "fields", "metric", "list", "breakdown", "chart", "note", "timeline", "history", "flows", "diagram", "custom" or "view" (an embedded list view); ` +
|
|
669
|
+
'in an App, each is written with its helper: fields(), metric(), related(), breakdown(), chart(), note(), timeline(), history(), flows(), diagram(), custom(), embed().');
|
|
670
|
+
}
|
|
671
|
+
refuseRenamed(s, at, kind === 'list' ? ['path', 'of', 'orderBy'] : ['path', 'of', 'orderBy', 'edit']);
|
|
672
|
+
if (kind === 'list' && s.columns !== undefined) {
|
|
673
|
+
throw new Error(`${at} has "columns", which is now "show" — the columns the list shows, e.g. ["due_on", "status"].`);
|
|
674
|
+
}
|
|
675
|
+
allowKeys(s, PAGE_SECTION_KEYS[kind], at, kind === 'list'
|
|
676
|
+
? {
|
|
677
|
+
about: 'a related list no longer takes: a row that shares a column with the record is found by { same: [column] }',
|
|
678
|
+
similar: "is now the list's own match: related(title, from, { same: [column], near: { column: 0.25 } })",
|
|
679
|
+
editable: 'is gone: the lines of a record are edited in its form (a form\'s "lines")',
|
|
680
|
+
order: 'is gone with the editable lines; a list sorts with "sort"',
|
|
681
|
+
}
|
|
682
|
+
: {});
|
|
683
|
+
const from = typeof s.from === 'string' ? s.from.trim() : '';
|
|
684
|
+
if (!from)
|
|
685
|
+
throw new Error(`${at} has no "from" table.`);
|
|
686
|
+
const similar = kind === 'list' && s.same !== undefined ? parseMatch(s, at) : undefined;
|
|
687
|
+
if (kind === 'list' && s.near !== undefined && s.same === undefined)
|
|
688
|
+
throw new Error(`${at} has "near" without "same": a match is the columns a row shares with the record, and the numbers it keeps near.`);
|
|
689
|
+
const path = similar ? '' : typeof s.whose === 'string' ? s.whose.trim() : '';
|
|
690
|
+
if (!path && !similar) {
|
|
691
|
+
throw new Error(`${at} has no "whose" — the rows shown are the ${from} rows *whose* named column ` +
|
|
692
|
+
'is this record: "customer_id" for the customer\'s own rows, ' +
|
|
693
|
+
'"invoice_id.customer_id" for rows two references away; or, to list the rows that share its values, "same".');
|
|
694
|
+
}
|
|
695
|
+
/* `query` is the one word for narrowing rows, on a page section as on a
|
|
696
|
+
dashboard's; the internal section keeps its `filter`. */
|
|
697
|
+
const filter = s.query === undefined ? undefined : String(s.query).trim() || undefined;
|
|
698
|
+
/* `about` moves a figure's subject one reference away from the
|
|
699
|
+
root: "tickets at this order's site" is { from: "ticket", whose:
|
|
700
|
+
"site_id", about: "site_id" }. Absent means the root itself. */
|
|
701
|
+
if (s.about !== undefined && (typeof s.about !== 'string' || !s.about.trim())) {
|
|
702
|
+
throw new Error(`${at} has an "about" that is not a non-empty string.`);
|
|
703
|
+
}
|
|
704
|
+
const of = s.about === undefined ? undefined : s.about.trim();
|
|
705
|
+
/* A figure said as a dashboard's is (appViews.ts): an average of a 1–5 rating has no column rule to say it, and
|
|
706
|
+
drew "3.333" with nothing after it; a win rate `avg won * 100` is a percent (G111). */
|
|
707
|
+
if (s.format !== undefined && !FIGURE_FORMATS.includes(String(s.format)))
|
|
708
|
+
throw new Error(`${at} has a "format" of "${String(s.format)}". Use ${FIGURE_FORMAT_WORDS}.`);
|
|
709
|
+
if (s.unit !== undefined && (typeof s.unit !== 'string' || !s.unit.trim() || s.unit.length > 24))
|
|
710
|
+
throw new Error(`${at} has a "unit" that is not a short word said after the figure, like "days".`);
|
|
711
|
+
const figureWords = {
|
|
712
|
+
...(s.format === undefined ? {} : { format: s.format }),
|
|
713
|
+
...(s.unit === undefined ? {} : { unit: s.unit.trim() }),
|
|
714
|
+
};
|
|
715
|
+
if (kind === 'metric') {
|
|
716
|
+
const { fn, column: columnRaw } = parseMeasure(s.measure, at);
|
|
717
|
+
return {
|
|
718
|
+
kind: 'metric',
|
|
719
|
+
...placement,
|
|
720
|
+
title,
|
|
721
|
+
width,
|
|
722
|
+
offsetLeft,
|
|
723
|
+
offsetRight,
|
|
724
|
+
hidden,
|
|
725
|
+
...(when ? { when } : {}),
|
|
726
|
+
fn: fn,
|
|
727
|
+
from,
|
|
728
|
+
path,
|
|
729
|
+
of,
|
|
730
|
+
column: columnRaw === undefined ? undefined : String(columnRaw).trim(),
|
|
731
|
+
filter,
|
|
732
|
+
...figureWords,
|
|
733
|
+
};
|
|
734
|
+
}
|
|
735
|
+
if (kind === 'chart') {
|
|
736
|
+
const by = typeof s.by === 'string' ? s.by.trim() : '';
|
|
737
|
+
if (!by)
|
|
738
|
+
throw new Error(`${at} has no "by" — what its groups are: "status", or a bucketed date like "month issued_on".`);
|
|
739
|
+
if (s.shape !== undefined && !CHART_SHAPES.includes(String(s.shape))) {
|
|
740
|
+
throw new Error(`${at} has a "shape" of "${String(s.shape)}". Use "line", "bar", "donut" or "stacked".`);
|
|
741
|
+
}
|
|
742
|
+
if (s.shape === 'stacked' && !by.includes(',')) {
|
|
743
|
+
throw new Error(`${at} is a stacked chart and needs a two-key "by" — "month issued_on, status": a bar for each month, divided by status.`);
|
|
744
|
+
}
|
|
745
|
+
if (s.limit !== undefined && (typeof s.limit !== 'number' || !Number.isInteger(s.limit) || s.limit < 1)) {
|
|
746
|
+
throw new Error(`${at} has a "limit" that is not a positive whole number.`);
|
|
747
|
+
}
|
|
748
|
+
if (s.height !== undefined && (typeof s.height !== 'number' || !Number.isFinite(s.height) || s.height < 120)) {
|
|
749
|
+
throw new Error(`${at} has a "height" that is not a number of pixels (120 or more).`);
|
|
750
|
+
}
|
|
751
|
+
if (s.target !== undefined && (typeof s.target !== 'number' || !Number.isFinite(s.target))) {
|
|
752
|
+
throw new Error(`${at} has a "target" that is not a number.`);
|
|
753
|
+
}
|
|
754
|
+
if (s.target !== undefined && s.shape === 'donut')
|
|
755
|
+
throw new Error(`${at} is a donut, which has no scale to draw a "target" on. Use a line or stacked bars.`);
|
|
756
|
+
let sort;
|
|
757
|
+
if (s.sort !== undefined) {
|
|
758
|
+
if (s.shape !== 'bar' && s.shape !== 'donut')
|
|
759
|
+
throw new Error(`${at} has a "sort", which orders the categories of a bar chart or a donut; a line runs along its key.`);
|
|
760
|
+
if (s.sort === 'value' || s.sort === 'category')
|
|
761
|
+
sort = s.sort;
|
|
762
|
+
else {
|
|
763
|
+
const parsed = parseSortText(s.sort, at, 'starts_on');
|
|
764
|
+
if (parsed.length !== 1 || parsed[0].column.includes('.'))
|
|
765
|
+
throw new Error(`${at} has a "sort" of more than one column or a path; categories are ordered by 'value', 'category' or one column of the rows: "starts_on desc".`);
|
|
766
|
+
sort = parsed[0].direction === 'desc' ? `${parsed[0].column} desc` : parsed[0].column;
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
const { fn, column } = parseMeasure(s.measure, at);
|
|
770
|
+
return {
|
|
771
|
+
kind: 'chart',
|
|
772
|
+
...placement,
|
|
773
|
+
title,
|
|
774
|
+
width,
|
|
775
|
+
offsetLeft,
|
|
776
|
+
offsetRight,
|
|
777
|
+
hidden,
|
|
778
|
+
...(when ? { when } : {}),
|
|
779
|
+
from,
|
|
780
|
+
path,
|
|
781
|
+
of,
|
|
782
|
+
by,
|
|
783
|
+
fn,
|
|
784
|
+
...(column === undefined ? {} : { column }),
|
|
785
|
+
...(s.shape === undefined ? {} : { shape: s.shape }),
|
|
786
|
+
...(sort ? { sort } : {}),
|
|
787
|
+
filter,
|
|
788
|
+
...(s.limit === undefined ? {} : { limit: s.limit }),
|
|
789
|
+
...(s.height === undefined ? {} : { height: s.height }),
|
|
790
|
+
...(s.target === undefined ? {} : { target: s.target }),
|
|
791
|
+
...figureWords,
|
|
792
|
+
};
|
|
793
|
+
}
|
|
794
|
+
if (kind === 'breakdown') {
|
|
795
|
+
const by = typeof s.by === 'string' ? s.by.trim() : '';
|
|
796
|
+
if (!by) {
|
|
797
|
+
throw new Error(`${at} has no "by" — the column its groups are counted under.`);
|
|
798
|
+
}
|
|
799
|
+
if (s.limit !== undefined && (typeof s.limit !== 'number' || !Number.isInteger(s.limit) || s.limit < 1)) {
|
|
800
|
+
throw new Error(`${at} has a "limit" that is not a positive whole number.`);
|
|
801
|
+
}
|
|
802
|
+
const measure = parseMeasure(s.measure, at);
|
|
803
|
+
return {
|
|
804
|
+
kind: 'breakdown',
|
|
805
|
+
// A count is what a breakdown says when it says nothing.
|
|
806
|
+
...(measure.fn === 'count' && measure.column === undefined
|
|
807
|
+
? {}
|
|
808
|
+
: { fn: measure.fn, ...(measure.column === undefined ? {} : { column: measure.column }) }),
|
|
809
|
+
...placement,
|
|
810
|
+
title,
|
|
811
|
+
width,
|
|
812
|
+
offsetLeft,
|
|
813
|
+
offsetRight,
|
|
814
|
+
hidden,
|
|
815
|
+
...(when ? { when } : {}),
|
|
816
|
+
from,
|
|
817
|
+
path,
|
|
818
|
+
of,
|
|
819
|
+
by,
|
|
820
|
+
filter,
|
|
821
|
+
limit: s.limit,
|
|
822
|
+
...figureWords,
|
|
823
|
+
};
|
|
824
|
+
}
|
|
825
|
+
if (s.limit !== undefined && (typeof s.limit !== 'number' || !Number.isInteger(s.limit) || s.limit < 1)) {
|
|
826
|
+
throw new Error(`${at} has a "limit" that is not a positive whole number.`);
|
|
827
|
+
}
|
|
828
|
+
if (s.show !== undefined && !Array.isArray(s.show)) {
|
|
829
|
+
throw new Error(`${at} has a "show" that is not an array of column names.`);
|
|
830
|
+
}
|
|
831
|
+
const shown = s.show === undefined ? undefined : parseShowEntries(s.show, at);
|
|
832
|
+
/* A gallery draws a related list of image files; whether the rows are
|
|
833
|
+
files is the App's to say (`file` on the resource), judged where the
|
|
834
|
+
page meets the App. */
|
|
835
|
+
/* Or its rows as cards, in the list view's own cards grammar: the same
|
|
836
|
+
keys, judged against the catalog where the page meets the App. */
|
|
837
|
+
let cards;
|
|
838
|
+
/* The one current row as a card — the active lease, the latest visit —
|
|
839
|
+
chosen by the list's query and sort: one row, so nothing to page, sum
|
|
840
|
+
or add under it. */
|
|
841
|
+
if (current && (s.empty === false || s.empty === 'hide' || s.empty === 'collapse'))
|
|
842
|
+
throw new Error(`${at} is a current() card; say what no row means with "empty" guidance rather than leaving it out.`);
|
|
843
|
+
if (s.presentation !== undefined && s.presentation !== 'gallery' && !current) {
|
|
844
|
+
const raw = s.presentation;
|
|
845
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw) || raw.kind !== 'cards') {
|
|
846
|
+
throw new Error(`${at}: a related list's presentation is "gallery" or a cards object { kind: 'cards', primary, … }.`);
|
|
847
|
+
}
|
|
848
|
+
const c = raw;
|
|
849
|
+
allowKeys(c, ['kind', 'primary', 'description', 'value', 'badge', 'icon', 'properties', 'facts', 'footer', 'image', 'media'], `${at}, presentation (cards)`, { lines: 'is now "properties": the captioned values under the title ("facts" stays the dotted line)' });
|
|
850
|
+
if (typeof c.primary !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)?$/.test(c.primary.trim()))
|
|
851
|
+
throw new Error(`${at}, presentation (cards): "primary" must be a local column or a one-hop path like "vehicle_id.title".`);
|
|
852
|
+
if (c.properties !== undefined &&
|
|
853
|
+
(!Array.isArray(c.properties) ||
|
|
854
|
+
c.properties.length > CARDS_MAX_LINES ||
|
|
855
|
+
c.properties.some((line) => typeof line !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)?$/.test(line.trim())))) {
|
|
856
|
+
throw new Error(`${at}, presentation (cards): "properties" must be at most ${CARDS_MAX_LINES} local columns or one-hop label paths.`);
|
|
857
|
+
}
|
|
858
|
+
cards = parseCards(c, `${at}, presentation (cards)`, c.primary.trim(), (c.properties ?? []).map((line) => line.trim()));
|
|
859
|
+
}
|
|
860
|
+
/* `empty` is the guidance a list with no rows shows, or what to do with the list instead: 'hide' leaves it out
|
|
861
|
+
until it has a row (a manager's direct reports, on a page most people's are none), 'collapse' says it in one
|
|
862
|
+
quiet line with the other empty lists beside it. Where the reader may add a row, 'hide' collapses rather than
|
|
863
|
+
hides: a list that offers a door is never taken away (PageSections.tsx). */
|
|
864
|
+
if (s.empty === true)
|
|
865
|
+
throw new Error(`${at} has "empty: true". "empty" is guidance ({ title, description, action }), 'hide' to leave the list out while it has no rows, or 'collapse' to say so in one line.`);
|
|
866
|
+
if (s.empty === false)
|
|
867
|
+
throw new Error(`${at} has "empty: false", which is now "empty: 'hide'".`);
|
|
868
|
+
const emptyMode = s.empty === 'hide' || s.empty === 'collapse' ? s.empty : undefined;
|
|
869
|
+
/* A footer of sums, the list view's `totals`: over every row that points
|
|
870
|
+
at the record, not the page, so it is one aggregate over the rows the
|
|
871
|
+
path reaches and never through a filter. */
|
|
872
|
+
let totals;
|
|
873
|
+
if (s.totals !== undefined) {
|
|
874
|
+
if (!Array.isArray(s.totals) ||
|
|
875
|
+
!s.totals.length ||
|
|
876
|
+
s.totals.some((c) => typeof c !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(c))) {
|
|
877
|
+
throw new Error(`${at} has "totals" that are not a list of the rows' numeric columns, rollups or computed numbers.`);
|
|
878
|
+
}
|
|
879
|
+
if (new Set(s.totals).size !== s.totals.length)
|
|
880
|
+
throw new Error(`${at} has "totals" that name one column twice.`);
|
|
881
|
+
if (s.presentation !== undefined)
|
|
882
|
+
throw new Error(`${at} has "totals" and a ${s.presentation === 'gallery' ? 'gallery' : 'cards'} presentation; a footer of totals belongs to a table.`);
|
|
883
|
+
const narrowed = ['query', 'near'].filter((key) => s[key] !== undefined && s[key] !== false);
|
|
884
|
+
if (narrowed.length) {
|
|
885
|
+
throw new Error(`${at} has "totals" and "${narrowed[0]}". Totals sum every row that points at the record and cannot be narrowed; drop one, or add a total() metric beside the list.`);
|
|
886
|
+
}
|
|
887
|
+
totals = s.totals;
|
|
888
|
+
}
|
|
889
|
+
const group = parseGroup(s, at, { similar: Boolean(similar), presentation: s.presentation, current });
|
|
890
|
+
/* The child's own verbs, from a ⋯ at the end of each row, as its own page offers them: a table's, never a card's or a photo's. */
|
|
891
|
+
if (s.rowActions !== undefined && s.rowActions !== true && s.rowActions !== false)
|
|
892
|
+
throw new Error(`${at}: "rowActions" must be true (a ⋯ menu on each row with the row's own actions) or false.`);
|
|
893
|
+
if (s.rowActions === true && s.presentation !== undefined)
|
|
894
|
+
throw new Error(`${at} has "rowActions" and a presentation; a row menu belongs to a table.`);
|
|
895
|
+
/* The record's own verbs that add to this list — "Add to the committee" on a deal — drawn in the list's head
|
|
896
|
+
rather than the record's, by the ids its resource's actions have (checked against them where the App is). */
|
|
897
|
+
let listActions;
|
|
898
|
+
if (s.actions !== undefined) {
|
|
899
|
+
if (!Array.isArray(s.actions) ||
|
|
900
|
+
!s.actions.length ||
|
|
901
|
+
s.actions.length > 4 ||
|
|
902
|
+
s.actions.some((id) => typeof id !== 'string' || !id.trim()))
|
|
903
|
+
throw new Error(`${at}: "actions" is one to four of the record's own action ids, drawn in the list's head: ['addBuyer'].`);
|
|
904
|
+
listActions = s.actions.map((id) => id.trim());
|
|
905
|
+
if (new Set(listActions).size !== listActions.length)
|
|
906
|
+
throw new Error(`${at} has "actions" naming one action twice.`);
|
|
907
|
+
}
|
|
908
|
+
return {
|
|
909
|
+
kind: 'list',
|
|
910
|
+
...placement,
|
|
911
|
+
...(s.sizing === undefined ? {} : { sizing: parseTableSizing(s.sizing, at) }),
|
|
912
|
+
...(s.presentation === 'gallery' || s.presentation === 'card'
|
|
913
|
+
? { presentation: s.presentation }
|
|
914
|
+
: cards
|
|
915
|
+
? { presentation: cards }
|
|
916
|
+
: {}),
|
|
917
|
+
...(similar ? { similar } : {}),
|
|
918
|
+
title,
|
|
919
|
+
width,
|
|
920
|
+
offsetLeft,
|
|
921
|
+
offsetRight,
|
|
922
|
+
hidden,
|
|
923
|
+
...(when ? { when } : {}),
|
|
924
|
+
from,
|
|
925
|
+
path,
|
|
926
|
+
columns: shown?.columns,
|
|
927
|
+
...(shown?.labels ? { labels: shown.labels } : {}),
|
|
928
|
+
filter,
|
|
929
|
+
limit: s.presentation === 'card' ? 1 : s.limit,
|
|
930
|
+
orderBy: parseSort(s.sort, at),
|
|
931
|
+
...(s.create === undefined ? {} : s.create === false ? { noCreate: true } : { create: parseRelatedCreate(s.create, at) }),
|
|
932
|
+
...(s.empty === undefined || emptyMode ? {} : { empty: parseListEmpty(s.empty, at) }),
|
|
933
|
+
...(emptyMode ? { emptyMode } : {}),
|
|
934
|
+
...(group ? { group } : {}),
|
|
935
|
+
...(totals ? { totals } : {}),
|
|
936
|
+
...(s.rowActions === true ? { rowActions: true } : {}),
|
|
937
|
+
...(listActions ? { listActions } : {}),
|
|
938
|
+
};
|
|
939
|
+
}
|
|
940
|
+
/**
|
|
941
|
+
* A related list's `group`: one header for each record its rows share through `by`, the rows to a group under it.
|
|
942
|
+
* One level only. `limit` beside it is the rows to a group, so it is held to what a group opens to.
|
|
943
|
+
*/
|
|
944
|
+
function parseGroup(s, at, beside) {
|
|
945
|
+
if (s.group === undefined)
|
|
946
|
+
return undefined;
|
|
947
|
+
const raw = s.group;
|
|
948
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
949
|
+
throw new Error(`${at} has a "group" that is not an object: group: { by: 'estimate_id', show: ['vehicle_id', 'total'], sort: 'created_on desc', limit: 5 }.`);
|
|
950
|
+
if (beside.current)
|
|
951
|
+
throw new Error(`${at} is a current() card, which is one row and has nothing to group.`);
|
|
952
|
+
if (beside.similar)
|
|
953
|
+
throw new Error(`${at} has "group" and "same": a matched list is not the record's children, so it has no chain worth grouping.`);
|
|
954
|
+
if (beside.presentation !== undefined)
|
|
955
|
+
throw new Error(`${at} has "group" and a ${beside.presentation === 'gallery' ? 'gallery' : 'cards'} presentation; groups are drawn as tables.`);
|
|
956
|
+
const g = raw;
|
|
957
|
+
allowKeys(g, ['by', 'show', 'sort', 'limit', 'empty', 'sum'], `${at}, group`);
|
|
958
|
+
if (typeof g.by !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$/.test(g.by.trim()))
|
|
959
|
+
throw new Error(`${at}, group has no "by": the column or reference the rows are grouped under, like "estimate_id".`);
|
|
960
|
+
if (g.show !== undefined) {
|
|
961
|
+
if (!Array.isArray(g.show) ||
|
|
962
|
+
!g.show.length ||
|
|
963
|
+
g.show.length > 4 ||
|
|
964
|
+
g.show.some((c) => typeof c !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(c)))
|
|
965
|
+
throw new Error(`${at}, group has a "show" that is not one to four columns of the record each group is: ['vehicle_id', 'total'].`);
|
|
966
|
+
if (new Set(g.show).size !== g.show.length)
|
|
967
|
+
throw new Error(`${at}, group has a "show" that names one column twice.`);
|
|
968
|
+
}
|
|
969
|
+
let sort;
|
|
970
|
+
if (g.sort !== undefined) {
|
|
971
|
+
const parsed = parseSortText(g.sort, `${at}, group`, 'created_on desc');
|
|
972
|
+
if (parsed.length !== 1 || parsed[0].column.includes('.'))
|
|
973
|
+
throw new Error(`${at}, group has a "sort" of more than one column or a path; groups are ordered by one column of their record: "created_on desc".`);
|
|
974
|
+
sort = { column: parsed[0].column, direction: parsed[0].direction };
|
|
975
|
+
}
|
|
976
|
+
if (g.limit !== undefined && (typeof g.limit !== 'number' || !Number.isInteger(g.limit) || g.limit < 1 || g.limit > GROUPS_MOST))
|
|
977
|
+
throw new Error(`${at}, group has a "limit" that is not a whole number of groups from 1 to ${GROUPS_MOST}.`);
|
|
978
|
+
// A msg() is said by the loader before a page is read; one that reaches here has no catalog to say it.
|
|
979
|
+
if (isMessageRef(g.empty))
|
|
980
|
+
parseCaption(g.empty, `${at}.group.empty`);
|
|
981
|
+
if (g.empty !== undefined && (typeof g.empty !== 'string' || !g.empty.trim() || g.empty.length > 60))
|
|
982
|
+
throw new Error(`${at}, group has an "empty" that is not a short name for the rows with no value: 'Backlog'.`);
|
|
983
|
+
let sum;
|
|
984
|
+
if (g.sum !== undefined) {
|
|
985
|
+
const named = typeof g.sum === 'string' ? [g.sum] : g.sum;
|
|
986
|
+
if (!Array.isArray(named) ||
|
|
987
|
+
named.length < 1 ||
|
|
988
|
+
named.length > 2 ||
|
|
989
|
+
named.some((c) => typeof c !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(c)))
|
|
990
|
+
throw new Error(`${at}, group has a "sum" that is not one or two numeric columns of the rows: 'points' or ['points', 'hours'].`);
|
|
991
|
+
if (new Set(named).size !== named.length)
|
|
992
|
+
throw new Error(`${at}, group has a "sum" that names one column twice.`);
|
|
993
|
+
sum = named;
|
|
994
|
+
}
|
|
995
|
+
if (typeof s.limit === 'number' && s.limit > GROUP_ROWS_MOST)
|
|
996
|
+
throw new Error(`${at} has a "limit" of ${s.limit} beside "group", where it is the rows to a group: at most ${GROUP_ROWS_MOST}.`);
|
|
997
|
+
return {
|
|
998
|
+
by: g.by.trim(),
|
|
999
|
+
...(g.show ? { show: g.show } : {}),
|
|
1000
|
+
...(sort ? { sort } : {}),
|
|
1001
|
+
...(g.limit === undefined ? {} : { limit: g.limit }),
|
|
1002
|
+
...(g.empty === undefined ? {} : { empty: g.empty.trim() }),
|
|
1003
|
+
...(sum ? { sum } : {}),
|
|
1004
|
+
};
|
|
1005
|
+
}
|
|
1006
|
+
/* The record's header: the head the page already draws — its noun over the
|
|
1007
|
+
name, the verbs beside it — with the status chip and the facts line an
|
|
1008
|
+
author names. It is drawn in the head, not where it is written, so what
|
|
1009
|
+
would move it (a region, a tab, a width) is refused rather than ignored. */
|
|
1010
|
+
function parseHeader(s, at) {
|
|
1011
|
+
if (s.status !== undefined && (typeof s.status !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(s.status.trim()))) {
|
|
1012
|
+
throw new Error(`${at} has a "status" that is not one of the record's own columns, like "stage".`);
|
|
1013
|
+
}
|
|
1014
|
+
if (s.destructive !== undefined &&
|
|
1015
|
+
(!Array.isArray(s.destructive) || !s.destructive.length || s.destructive.some((id) => typeof id !== 'string' || !id.trim()))) {
|
|
1016
|
+
throw new Error(`${at} has a "destructive" that is not a list of the record's action ids, like ['markLost'].`);
|
|
1017
|
+
}
|
|
1018
|
+
/* The one verb drawn filled: the one the record most needs next, which
|
|
1019
|
+
is never one that takes something away. Whether each id is one of the
|
|
1020
|
+
base resource's actions is the App's to judge (apps.ts), where both are known. */
|
|
1021
|
+
if (s.primary !== undefined && (typeof s.primary !== 'string' || !s.primary.trim())) {
|
|
1022
|
+
throw new Error(`${at} has a "primary" that is not the id of one of the record's actions, like 'renew'.`);
|
|
1023
|
+
}
|
|
1024
|
+
if (s.actions !== undefined &&
|
|
1025
|
+
(!Array.isArray(s.actions) ||
|
|
1026
|
+
!s.actions.length ||
|
|
1027
|
+
s.actions.some((id) => typeof id !== 'string' || !id.trim()) ||
|
|
1028
|
+
new Set(s.actions.map((id) => String(id).trim())).size !== s.actions.length)) {
|
|
1029
|
+
throw new Error(`${at} has "actions" that are not a list of the record's action ids, each once, like ['resolve', 'reassign'].`);
|
|
1030
|
+
}
|
|
1031
|
+
/* The filled verb is one the head draws. */
|
|
1032
|
+
if (Array.isArray(s.actions) &&
|
|
1033
|
+
typeof s.primary === 'string' &&
|
|
1034
|
+
!s.actions.some((id) => String(id).trim() === s.primary.trim())) {
|
|
1035
|
+
throw new Error(`${at} has "primary" "${s.primary}", which is not one of its "actions": the head cannot fill a verb it does not draw.`);
|
|
1036
|
+
}
|
|
1037
|
+
if (typeof s.primary === 'string' &&
|
|
1038
|
+
Array.isArray(s.destructive) &&
|
|
1039
|
+
s.destructive.some((id) => typeof id === 'string' && id.trim() === s.primary.trim())) {
|
|
1040
|
+
throw new Error(`${at} names "${s.primary}" both "primary" and "destructive": the filled button is the forward verb, and a destructive one is drawn last and quiet.`);
|
|
1041
|
+
}
|
|
1042
|
+
/* A header with only a `primary` or its `destructive` verbs is the head
|
|
1043
|
+
the page draws anyway, told which verb to fill: worth writing. One that
|
|
1044
|
+
says nothing is a slip. */
|
|
1045
|
+
if (['status', 'facts', 'primary', 'destructive', 'actions'].every((key) => s[key] === undefined)) {
|
|
1046
|
+
throw new Error(`${at}: a record header says something: a "status", "facts", its "primary" verb, its "destructive" ones or the "actions" it draws.`);
|
|
1047
|
+
}
|
|
1048
|
+
const facts = s.facts === undefined ? undefined : parseFacts(s.facts, `${at}, header`);
|
|
1049
|
+
return {
|
|
1050
|
+
kind: 'fields',
|
|
1051
|
+
presentation: 'header',
|
|
1052
|
+
paths: facts?.facts ?? [],
|
|
1053
|
+
...(facts?.factLabels ? { factLabels: facts.factLabels } : {}),
|
|
1054
|
+
...(s.status === undefined ? {} : { status: s.status.trim() }),
|
|
1055
|
+
...(s.primary === undefined ? {} : { primary: s.primary.trim() }),
|
|
1056
|
+
...(s.destructive === undefined ? {} : { destructive: s.destructive.map((id) => id.trim()) }),
|
|
1057
|
+
...(s.actions === undefined ? {} : { headerActions: s.actions.map((id) => id.trim()) }),
|
|
1058
|
+
};
|
|
1059
|
+
}
|
|
1060
|
+
/* A conversation's composer: the commands its two modes run, named as the App's actions name them. */
|
|
1061
|
+
function parseTimelineComposer(raw, at) {
|
|
1062
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
1063
|
+
throw new Error(`${at} has a "composer" that is not { reply, note? }: the names of the commands a Reply and an Internal note run.`);
|
|
1064
|
+
const c = raw;
|
|
1065
|
+
allowKeys(c, ['reply', 'note', 'templates'], `${at}, composer`);
|
|
1066
|
+
for (const key of ['reply', 'note']) {
|
|
1067
|
+
if (c[key] !== undefined && (typeof c[key] !== 'string' || !c[key].trim()))
|
|
1068
|
+
throw new Error(`${at}: composer.${key} is not a command's name.`);
|
|
1069
|
+
}
|
|
1070
|
+
if (typeof c.reply !== 'string')
|
|
1071
|
+
throw new Error(`${at}: a composer needs "reply", the command a Reply runs.`);
|
|
1072
|
+
const reply = c.reply.trim();
|
|
1073
|
+
const note = typeof c.note === 'string' ? c.note.trim() : undefined;
|
|
1074
|
+
if (note !== undefined && note === reply)
|
|
1075
|
+
throw new Error(`${at}: composer.reply and composer.note name the same command "${reply}"; a note is a different command from a reply.`);
|
|
1076
|
+
const templates = c.templates === undefined ? undefined : parseComposerTemplates(c.templates, at);
|
|
1077
|
+
return { reply, ...(note === undefined ? {} : { note }), ...(templates ? { templates } : {}) };
|
|
1078
|
+
}
|
|
1079
|
+
/* Canned replies: the rows of a resource, each a label to pick and the text it puts in the box. */
|
|
1080
|
+
function parseComposerTemplates(raw, at) {
|
|
1081
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
1082
|
+
throw new Error(`${at}: composer.templates is not { from, labelColumn, text, query? }: the resource whose rows are canned replies, and their two columns.`);
|
|
1083
|
+
const t = raw;
|
|
1084
|
+
allowKeys(t, ['from', 'labelColumn', 'text', 'query'], `${at}, composer.templates`, {
|
|
1085
|
+
label: 'is now "labelColumn": the column a reader picks a reply by',
|
|
1086
|
+
});
|
|
1087
|
+
for (const key of ['from', 'labelColumn', 'text']) {
|
|
1088
|
+
if (typeof t[key] !== 'string' || !t[key].trim())
|
|
1089
|
+
throw new Error(`${at}: composer.templates needs "${key}", ${key === 'from' ? 'the resource whose rows are the canned replies' : key === 'labelColumn' ? 'the column a reader picks by' : 'the column whose words fill the box'}.`);
|
|
1090
|
+
}
|
|
1091
|
+
/* Which rows are offered, beyond what the reader may read: a retired response is still readable. */
|
|
1092
|
+
if (t.query !== undefined && (typeof t.query !== 'string' || !t.query.trim()))
|
|
1093
|
+
throw new Error(`${at}: composer.templates has a "query" that is not a condition, like 'active = true'.`);
|
|
1094
|
+
return {
|
|
1095
|
+
from: t.from.trim(),
|
|
1096
|
+
labelColumn: t.labelColumn.trim(),
|
|
1097
|
+
text: t.text.trim(),
|
|
1098
|
+
...(typeof t.query === 'string' ? { query: t.query.trim() } : {}),
|
|
1099
|
+
};
|
|
1100
|
+
}
|
|
1101
|
+
const TIMELINE_TONES = ['good', 'warn', 'bad', 'info', 'neutral'];
|
|
1102
|
+
/* A tone rule says "this column's value → a tone": one or two columns at most, each a short list. */
|
|
1103
|
+
function parseTimelineTone(raw, at) {
|
|
1104
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw) || !Object.keys(raw).length) {
|
|
1105
|
+
throw new Error(`${at} has a "tone" that is not { column: { value: tone } }, like { kind: { internal: 'warn' } }.`);
|
|
1106
|
+
}
|
|
1107
|
+
const out = {};
|
|
1108
|
+
for (const [column, values] of Object.entries(raw)) {
|
|
1109
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(column))
|
|
1110
|
+
throw new Error(`${at}: tone names "${column}", which is not one of the stream's own columns.`);
|
|
1111
|
+
if (!values || typeof values !== 'object' || Array.isArray(values) || !Object.keys(values).length) {
|
|
1112
|
+
throw new Error(`${at}: tone.${column} is not { value: tone }, like { internal: 'warn' }.`);
|
|
1113
|
+
}
|
|
1114
|
+
out[column] = {};
|
|
1115
|
+
for (const [value, tone] of Object.entries(values)) {
|
|
1116
|
+
if (!TIMELINE_TONES.includes(tone))
|
|
1117
|
+
throw new Error(`${at}: tone.${column}.${value} is "${String(tone)}", not one of ${TIMELINE_TONES.join(', ')}.`);
|
|
1118
|
+
out[column][value] = tone;
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
return out;
|
|
1122
|
+
}
|
|
1123
|
+
/* Rows of these column values are one line: { kind: ['system'] }. */
|
|
1124
|
+
function parseTimelineCompact(raw, at) {
|
|
1125
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw) || !Object.keys(raw).length) {
|
|
1126
|
+
throw new Error(`${at} has a "compact" that is not { column: [values] }, like { kind: ['system'] }.`);
|
|
1127
|
+
}
|
|
1128
|
+
const out = {};
|
|
1129
|
+
for (const [column, values] of Object.entries(raw)) {
|
|
1130
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(column))
|
|
1131
|
+
throw new Error(`${at}: compact names "${column}", which is not one of the stream's own columns.`);
|
|
1132
|
+
if (!Array.isArray(values) || !values.length || values.some((value) => typeof value !== 'string' || !value)) {
|
|
1133
|
+
throw new Error(`${at}: compact.${column} is not a list of values, like ['system'].`);
|
|
1134
|
+
}
|
|
1135
|
+
out[column] = values.map(String);
|
|
1136
|
+
}
|
|
1137
|
+
return out;
|
|
1138
|
+
}
|
|
1139
|
+
/* Few enough columns that each still says why a row is on the list. */
|
|
1140
|
+
const MATCH_MAX_SAME = 4;
|
|
1141
|
+
const MATCH_MAX_NEAR = 2;
|
|
1142
|
+
/**
|
|
1143
|
+
* A list's match: the rows that share the record's own values, `same` equal
|
|
1144
|
+
* and `near` within a fraction. It is found by those values rather than by a
|
|
1145
|
+
* reference, so each word that only means something beside a reference is
|
|
1146
|
+
* refused here rather than ignored: there is no `whose` to walk, no parent
|
|
1147
|
+
* to `create` under (`empty` is kept: a match that finds nothing says so), and where the rows are the page's own table the record
|
|
1148
|
+
* is always left out.
|
|
1149
|
+
*/
|
|
1150
|
+
function parseMatch(s, section) {
|
|
1151
|
+
/* Named by its title where it has one: a page may hold several related lists, and "section 3" alone is a count. */
|
|
1152
|
+
const at = typeof s.title === 'string' && s.title.trim() ? `${section} ("${s.title.trim()}")` : section;
|
|
1153
|
+
const beside = {
|
|
1154
|
+
whose: "its rows share the record's values rather than point at it",
|
|
1155
|
+
create: "its rows are not the record's children: a row added here would need the record's values in every matched column, and a near column has no value to fill, so there is nothing to add them under (add through the list's own page)",
|
|
1156
|
+
};
|
|
1157
|
+
for (const [key, why] of Object.entries(beside)) {
|
|
1158
|
+
if (s[key] !== undefined && s[key] !== false)
|
|
1159
|
+
throw new Error(`${at} has "${key}" beside "same" — ${why}.`);
|
|
1160
|
+
}
|
|
1161
|
+
const column = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
1162
|
+
if (!Array.isArray(s.same) ||
|
|
1163
|
+
!s.same.length ||
|
|
1164
|
+
s.same.length > MATCH_MAX_SAME ||
|
|
1165
|
+
s.same.some((c) => typeof c !== 'string' || !column.test(c))) {
|
|
1166
|
+
throw new Error(`${at}: "same" must be one to ${MATCH_MAX_SAME} columns a row shares with the record, e.g. ['body_type'].`);
|
|
1167
|
+
}
|
|
1168
|
+
const same = s.same;
|
|
1169
|
+
if (new Set(same).size !== same.length)
|
|
1170
|
+
throw new Error(`${at}: "same" names a column twice.`);
|
|
1171
|
+
if (s.near === undefined)
|
|
1172
|
+
return { same };
|
|
1173
|
+
if (!s.near || typeof s.near !== 'object' || Array.isArray(s.near)) {
|
|
1174
|
+
throw new Error(`${at}: "near" must be an object of number columns and fractions, e.g. { price: 0.25 }.`);
|
|
1175
|
+
}
|
|
1176
|
+
const near = Object.entries(s.near);
|
|
1177
|
+
if (!near.length || near.length > MATCH_MAX_NEAR) {
|
|
1178
|
+
throw new Error(`${at}: "near" must name one or ${MATCH_MAX_NEAR === 2 ? 'two' : MATCH_MAX_NEAR} columns.`);
|
|
1179
|
+
}
|
|
1180
|
+
for (const [name, fraction] of near) {
|
|
1181
|
+
if (!column.test(name))
|
|
1182
|
+
throw new Error(`${at}: "near" names "${name}", which is not a column name.`);
|
|
1183
|
+
if (same.includes(name))
|
|
1184
|
+
throw new Error(`${at}: "${name}" is in both "same" and "near" — equal already is near.`);
|
|
1185
|
+
if (typeof fraction !== 'number' || !Number.isFinite(fraction) || fraction <= 0 || fraction > 1) {
|
|
1186
|
+
throw new Error(`${at}: "near" on "${name}" must be a fraction above 0 and at most 1 — 0.25 is within ±25% of the record's value.`);
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
return { same, near: Object.fromEntries(near) };
|
|
1190
|
+
}
|
|
1191
|
+
/**
|
|
1192
|
+
* A list's `show`, in the nav's grammar: a column path, or `{ column,
|
|
1193
|
+
* label }` with the app's word over it. The paths stay the planner's
|
|
1194
|
+
* `columns`; the labels ride beside them for whoever draws the header.
|
|
1195
|
+
*/
|
|
1196
|
+
function parseShowEntries(raw, at) {
|
|
1197
|
+
const labels = {};
|
|
1198
|
+
const columns = raw.map((entry, i) => {
|
|
1199
|
+
if (typeof entry === 'string')
|
|
1200
|
+
return entry;
|
|
1201
|
+
if (entry && typeof entry === 'object' && !Array.isArray(entry)) {
|
|
1202
|
+
const c = entry;
|
|
1203
|
+
for (const key of Object.keys(c)) {
|
|
1204
|
+
if (key !== 'column' && key !== 'label')
|
|
1205
|
+
throw new Error(`${at}, "show" entry ${i + 1} has no "${key}". It takes: column, label.`);
|
|
1206
|
+
}
|
|
1207
|
+
if (typeof c.column !== 'string' || !c.column.trim())
|
|
1208
|
+
throw new Error(`${at}, "show" entry ${i + 1} has no "column".`);
|
|
1209
|
+
if (c.label !== undefined && (typeof c.label !== 'string' || !c.label.trim())) {
|
|
1210
|
+
throw new Error(`${at}, "show" entry ${i + 1} has a "label" that is not a non-empty string.`);
|
|
1211
|
+
}
|
|
1212
|
+
if (c.label !== undefined)
|
|
1213
|
+
labels[c.column.trim()] = c.label.trim();
|
|
1214
|
+
return c.column.trim();
|
|
1215
|
+
}
|
|
1216
|
+
throw new Error(`${at}, "show" entry ${i + 1} is not a column name or a { "column", "label" } object.`);
|
|
1217
|
+
});
|
|
1218
|
+
return { columns, ...(Object.keys(labels).length ? { labels } : {}) };
|
|
1219
|
+
}
|
|
1220
|
+
/**
|
|
1221
|
+
* A headline's calls to action: one or two, each opening one of the App's
|
|
1222
|
+
* views — a public form, a list, a dashboard — by its key: the landing's
|
|
1223
|
+
* call-to-action shape. Whether the key names such a view is judged where
|
|
1224
|
+
* the page meets the App.
|
|
1225
|
+
*/
|
|
1226
|
+
function parseHeadlineActions(raw, at) {
|
|
1227
|
+
if (!Array.isArray(raw) || !raw.length || raw.length > 2)
|
|
1228
|
+
throw new Error(`${at}: "links" must be one or two { label, to }.`);
|
|
1229
|
+
return raw.map((entry, i) => {
|
|
1230
|
+
const here = `${at}, link ${i + 1}`;
|
|
1231
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry))
|
|
1232
|
+
throw new Error(`${here} is not an object { label, to }.`);
|
|
1233
|
+
const a = entry;
|
|
1234
|
+
if (a.to !== undefined && typeof a.to !== 'string') {
|
|
1235
|
+
allowKeys(a, ['label', 'to', 'intent'], here, INTENT_RENAMED);
|
|
1236
|
+
if (typeof a.label !== 'string' || !a.label.trim() || a.label.trim().length > 60)
|
|
1237
|
+
throw new Error(`${here} needs a "label" of at most 60 characters.`);
|
|
1238
|
+
if (a.intent !== undefined && a.intent !== 'primary' && a.intent !== 'secondary')
|
|
1239
|
+
throw new Error(`${here} has an "intent" of "${String(a.intent)}". Use "primary" or "secondary".`);
|
|
1240
|
+
return {
|
|
1241
|
+
label: a.label.trim(),
|
|
1242
|
+
to: parseAppTarget(a.to, `${here}.to`, true),
|
|
1243
|
+
...(a.intent === undefined ? {} : { intent: a.intent }),
|
|
1244
|
+
};
|
|
1245
|
+
}
|
|
1246
|
+
allowKeys(a, ['label', 'to', 'intent', 'prefill', 'defaults', 'open'], here, INTENT_RENAMED);
|
|
1247
|
+
if (typeof a.label !== 'string' || !a.label.trim() || a.label.trim().length > 60)
|
|
1248
|
+
throw new Error(`${here} needs a "label" of at most 60 characters.`);
|
|
1249
|
+
if (typeof a.to !== 'string' || !/^[a-z0-9][a-z0-9-]{0,63}$/.test(a.to))
|
|
1250
|
+
throw new Error(`${here} needs a "to": the key of one of the App's views, or an appLink().`);
|
|
1251
|
+
if (a.intent !== undefined && a.intent !== 'primary' && a.intent !== 'secondary')
|
|
1252
|
+
throw new Error(`${here} has an "intent" of "${String(a.intent)}". Use "primary" or "secondary".`);
|
|
1253
|
+
/* A public form over the record, rather than a page of its own. Only
|
|
1254
|
+
'modal': "drawer" is a list's word for a row, and a page is the default
|
|
1255
|
+
because it is what an address opens. */
|
|
1256
|
+
if (a.open !== undefined && a.open !== 'modal')
|
|
1257
|
+
throw new Error(`${here} has an "open" of "${String(a.open)}". Use "modal", or leave it out for a page.`);
|
|
1258
|
+
/* Where the form the action opens starts: an input filled from this
|
|
1259
|
+
record (input → field path), or with the author's words — `defaults`, the
|
|
1260
|
+
word a New form and a related list's create say a stated starting value with. */
|
|
1261
|
+
const inputs = (raw, key) => {
|
|
1262
|
+
if (raw === undefined)
|
|
1263
|
+
return undefined;
|
|
1264
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw) || !Object.keys(raw).length)
|
|
1265
|
+
throw new Error(`${here} has a "${key}" that is not an object of form input → ${key === 'prefill' ? 'field path' : 'text'}.`);
|
|
1266
|
+
return Object.fromEntries(Object.entries(raw).map(([input, value]) => {
|
|
1267
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(input))
|
|
1268
|
+
throw new Error(`${here}, ${key}: "${input}" is not a form input's name.`);
|
|
1269
|
+
if (key === 'prefill' && (typeof value !== 'string' || !FIELD_PATH.test(value.trim())))
|
|
1270
|
+
throw new Error(`${here}, prefill: "${input}" must name one of this record's fields.`);
|
|
1271
|
+
if (key === 'defaults' && (typeof value !== 'string' || !value.trim() || value.length > 500))
|
|
1272
|
+
throw new Error(`${here}, defaults: "${input}" must be text of at most 500 characters.`);
|
|
1273
|
+
return [input, value.trim()];
|
|
1274
|
+
}));
|
|
1275
|
+
};
|
|
1276
|
+
const prefill = inputs(a.prefill, 'prefill');
|
|
1277
|
+
const defaults = inputs(a.defaults, 'defaults');
|
|
1278
|
+
return {
|
|
1279
|
+
label: a.label.trim(),
|
|
1280
|
+
view: a.to,
|
|
1281
|
+
...(a.intent === undefined ? {} : { intent: a.intent }),
|
|
1282
|
+
...(prefill ? { prefill } : {}),
|
|
1283
|
+
...(defaults ? { defaults } : {}),
|
|
1284
|
+
...(a.open === undefined ? {} : { open: 'modal' }),
|
|
1285
|
+
};
|
|
1286
|
+
});
|
|
1287
|
+
}
|
|
1288
|
+
function parseWidth(raw, at) {
|
|
1289
|
+
if (raw === undefined)
|
|
1290
|
+
return undefined;
|
|
1291
|
+
const width = String(raw);
|
|
1292
|
+
if (!WIDTHS.includes(width)) {
|
|
1293
|
+
throw new Error(`${at} has a "width" of "${width}". Use ${WIDTHS.join(', ')}.`);
|
|
1294
|
+
}
|
|
1295
|
+
return width;
|
|
1296
|
+
}
|
|
1297
|
+
function parseOffset(raw, at, side) {
|
|
1298
|
+
if (raw === undefined)
|
|
1299
|
+
return undefined;
|
|
1300
|
+
const offset = String(raw);
|
|
1301
|
+
if (!OFFSETS.includes(offset)) {
|
|
1302
|
+
throw new Error(`${at} has an "${side}" of "${offset}". Use ${OFFSETS.join(', ')}.`);
|
|
1303
|
+
}
|
|
1304
|
+
return offset;
|
|
1305
|
+
}
|
|
1306
|
+
function parseSort(raw, at) {
|
|
1307
|
+
return parseSortText(raw, at, 'due_on desc, name');
|
|
1308
|
+
}
|