mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-10-23 03:27:27 +09:00
feat: add queryable Assets resources (#5946)
Closes #5919 Ref #5920 ## Outcome Extend the existing Assets system resource with optional queries for Markdown, JSON, and standard file metadata. Unconfigured Assets resources retain the existing fetch-all behavior and activate none of the content-query infrastructure. Files are the authoring model. A standalone content engine converts an immutable snapshot of only the files required by configured queries into one bounded, deterministic runtime database. Builder preview, SSR, and SSG use the same query semantics without requiring a database service for a published site. ## Final architecture ```text Markdown / JSON files │ ▼ ContentSource adapter (FS or Webstudio Assets) │ immutable snapshot ▼ ContentCompilationPlan (union of configured query requirements) │ ▼ ContentCompiler (parse, normalize, discover fields, enforce budget) │ ▼ ContentArtifact (bounded, deterministic, immutable JSON) │ ▼ ContentDatabase (filter, OR groups, sort, paginate, project, read content) │ ├── Builder preview ├── SSR runtime └── SSG build only ``` The authoring/storage layer owns mutable files. The content engine consumes immutable snapshots and produces immutable, disposable artifacts. Query authoring is a separate OpenAPI-driven path: ```text Assets REST OpenAPI operation + standard request JSON Schema + dynamic field paths │ ▼ Generic OpenAPI adapter │ resolved schemas, controls, paths, and transport mapping ▼ Generic query-builder core and React renderer │ arbitrary provider-defined value ▼ Assets REST request adapter │ ▼ { query: value } ``` The selected standard OpenAPI operation is the only source of query UI semantics. Request parameters and JSON Schema describe the editable data; the generic renderer derives controls and owns layout. ### Activation and compilation - The compiler does not index every asset. It combines all reachable configured Assets-resource queries into one `ContentCompilationPlan`. - Static constraints such as folder, path, and file type select candidate files. Dynamic values such as a route-bound slug cannot reduce the build-time candidate set. - The plan contains the union of fields used by filtering, sorting, routing, projection, excerpts, and requested full content. - Documents shared by multiple resources are stored once in one deployment-wide runtime database. - Markdown/frontmatter, JSON properties, excerpts, and full bodies are prepared only when required. - No configured content query means no parsing, artifact, or shipped runtime dependency. Fresh generated apps need no content-runtime cleanup; incremental generation removes stale integration if a previously configured query is deleted. ### Runtime database limit - `CONTENT_DATABASE_MAX_BYTES` limits the complete serialized runtime database, not source files, images, or unrelated assets. - The default is `500 * 1024` bytes and is presented as 500 KiB. - Counted data includes indexed metadata, frontmatter/JSON properties, excerpts, the field catalog, serialization overhead, and full Markdown bodies only when requested. - Prepublish validation builds and serializes the exact database that will be attached to the immutable build, so it knows the unbounded size, bounded size, included documents, omitted documents, and affected resources. - Overflow never stores partial documents. Candidates are ordered by `createdAt` descending, canonical path ascending, and stable document ID ascending. Missing dates follow dated documents. A document that does not fit is skipped while smaller later documents are still considered. - Exceeding the limit warns but does not block publishing. - Builder preview can report exact bounded versus unbounded results when dynamic expressions have concrete preview values. Prepublish warnings remain conservative for future route values. ### Snapshot consistency The source contract guarantees a consistent compilation, while adapters provide the mechanism: - Webstudio Assets captures asset metadata and immutable content references. - Filesystem sources capture file identity and verify files did not change before accepting the artifact. - A changed source retries once, then fails clearly instead of producing a mixed-revision database. - This requires no universal snapshot service, source-tree copy, or new versioned-asset infrastructure. ### Package ownership - `@webstudio-is/content-engine`: runtime artifact schema, query execution, field catalog, compact runtime statistics. - `@webstudio-is/content-engine/compiler`: source snapshots, compilation plans, Markdown/JSON parsing, deterministic compilation, byte accounting, and compiler diagnostics. This entry exists to keep Remark/YAML/future MDX dependencies out of runtime bundles. - `@webstudio-is/query-builder`: resolves standard OpenAPI operation schemas into an internal provider-neutral editor model, validation, mutations, and source serialization. It owns no Assets, provider-authored UI contract, or fixed REST-query semantics. - `@webstudio-is/query-builder/react`: container-neutral schema renderer whose layout is renderer policy. It remains separate from the content engine. - SDK: Webstudio Assets/Resource domain models and mapping Webstudio expressions into generic content requirements. - Protocol: external Assets REST/OpenAPI transport contracts. - Asset uploader/repository: asset and folder mutations, generic metadata, immutable byte references, and storage access. Its hosted adapter supplies Webstudio Assets snapshots and parsed-metadata cache persistence to the content engine; parsing, artifact creation, and query semantics remain engine-owned. - Builder and generated-app integrations decide when to compile, cache, embed, load, or discard a database. ### Runtime lifecycle - Builder caches immutable `ContentDatabase` instances by source revision plus compilation-plan hash and coalesces concurrent compilation. Matching operations reuse the same instance; field discovery uses a dedicated minimal all-properties plan that excludes excerpts and bodies. - SSR attaches the validated bounded database to the build and includes the small runtime only when required. - SSG uses the database only while generating pages and omits it and the runtime from final output unless a future runtime-query feature requires them. - Local development compiles from the filesystem and uses identical query semantics. - Published pages query their local artifact; they do not call Builder REST endpoints. - Published artifacts need no external index store, invalidation, garbage collection, SaaS content proxy, new bucket, or new environment variable. ## Implemented foundation These existing parts remain useful and should be moved behind the final ownership boundaries rather than reimplemented. - [x] Upgrade the existing Assets system resource in place and preserve unconfigured fetch-all behavior. - [x] Implement typed filtering, nested boolean groups, deterministic sorting, pagination, projections, dynamic values, and content hydration. - [x] Add Markdown frontmatter, excerpt, JSON-property, and field-catalog extraction. - [x] Add reusable query-control implementations and a separately imported React UI as a foundation for the final OpenAPI-driven contract. - [x] Add Assets query authoring and dynamic field discovery as existing behavior to migrate into the Assets OpenAPI operation. - [x] Add synchronized visual and editable expression authoring. - [x] Unify Assets REST operations under `/rest/assets/*`. - [x] Define authenticated collection, item, content, upload, folder, query, field-catalog, and OpenAPI operations. - [x] Support Builder cookie authentication and public API token authentication. - [x] Add Builder preview, MCP/CLI operations, SSR execution, SSG path generation, and conditional activation. - [x] Add revision-keyed parsed metadata persistence for the hosted Builder. - [x] Add focused query, API, authorization, Builder, CLI, SSR, SSG, migration, and fixture coverage. ## Remaining implementation plan ### 1. Establish the content engine - [x] Rename the generic `@webstudio-is/asset-resource` implementation to `@webstudio-is/content-engine`. - [x] Move existing behavior instead of creating a parallel implementation. - [x] Keep the primary entry runtime-only and add only the compiler entry needed for bundle separation. - [x] Remove the old package after all consumers migrate. - [x] Ensure the engine has no Builder, SDK, asset-uploader, PostgreSQL, R2, or deployment-target dependency. ### 2. Define source snapshots and compilation plans - [x] Introduce `ContentSource.openSnapshot()` and a revision-bearing `ContentSourceSnapshot`. - [x] Include canonical path, stable document ID, content type, immutable content reference, size, and optional `createdAt`. - [x] Implement Webstudio Assets and filesystem snapshot guarantees using their adapter-specific mechanisms. - [x] Introduce `ContentCompilationPlan` for the union of reachable configured resource requirements. - [x] Separate statically selectable candidates from dynamic runtime predicates. - [x] Add mutation-during-compilation tests for replacement, deletion, folder movement, and filesystem changes. ### 3. Separate storage from content processing - [x] Keep upload, replacement, folders, byte storage, revisions, and generic metadata in the Assets repository/uploader. - [x] Move parsing, excerpts, field discovery, query execution, and artifact creation into the content engine. - [x] Remove content-index coordination from asset mutation business logic. - [x] Preserve original source `createdAt` across content replacement and expose it as optional queryable metadata. - [x] Keep hosted parsed-document persistence behind the content compiler's optional cache interface. ### 4. Build deterministic compilation - [x] Move the existing Markdown, frontmatter, JSON, canonicalization, hashing, document-building, and tests into the compiler. - [x] Keep frontmatter behind a dedicated module and continue using the existing Remark/Micromark pipeline. - [x] Parse only candidate files and only the tiers required by the compilation plan. - [x] Deduplicate documents and fields required by multiple resources. - [x] Define a versioned, deterministic `ContentArtifact` with integrity information. - [x] Keep verbose parse and omission diagnostics outside the runtime artifact. - [x] Reject incompatible artifact versions cleanly. ### 5. Enforce the deployment-wide database budget - [x] Replace the old asset-index limit with `CONTENT_DATABASE_MAX_BYTES`, defaulting to 500 KiB. - [x] Have applications read configuration and pass the byte limit into the compiler. - [x] Measure the exact serialized database, including selected bodies and overhead. - [x] Implement deterministic skip-and-continue packing of complete documents. - [x] Return compiler diagnostics with unbounded/bounded bytes, included/omitted counts, omitted identities, largest contributors, and affected resources. - [x] Expose only compact artifact statistics at runtime through `ContentDatabase.getStats()`. - [x] Add exact boundary, deterministic ordering, deduplication, and oversized-document tests. ### 6. Finalize the runtime database - [x] Define `ContentDatabase` over an immutable artifact. - [x] Preserve filtering, nested AND/OR groups, sorting, offset/limit, projection, field references, and content retrieval. - [x] Expose `getFieldCatalog()` and compact `getStats()`. - [x] Keep Markdown/YAML/compiler dependencies out of the runtime entry. - [x] Add execution guards and maximum-size runtime benchmarks. ### 7. Make query authoring OpenAPI-driven The selected standard OpenAPI operation is the complete query-authoring contract. The query builder resolves parameters and request-body JSON Schema, derives its internal editor model, and applies renderer-owned layout policy. It must not embed or fetch a second capabilities/UI language through `x-webstudio-query` or another side channel. - [x] Remove `x-webstudio-query` and all vendor query-definition metadata from the Assets OpenAPI operation. - [x] Resolve local OpenAPI references and derive the editable request value, defaults, validation, labels, choices, arrays, objects, and unions solely from standard operation parameters and request-body JSON Schema. - [x] Represent dynamic Assets field paths with standard JSON Schema vocabulary in the `queryAssets` request schema. - [x] Keep the normalized editor representation internal to query-builder; do not expose another provider-authored capabilities contract. - [x] Remove provider-controlled layout metadata such as `group`; make compact scalar packing and all other layout renderer policy. - [x] Keep Webstudio expression binding as a consumer/editor concern layered over schema values, not part of the OpenAPI contract. - [x] Preserve the existing Assets filter, sort, output selection, full-content/range selection, limit/offset, editable source, and configured-field behavior. - [x] Keep the Assets resource adapter responsible for serializing the edited query into its request body. - [x] Remove the obsolete Assets query-capabilities endpoint, URL, and UI-side capability parsing. - [x] Add a non-Assets OpenAPI proof with differently named parameters and no vendor metadata. - [x] Verify the generic packages contain no Assets imports, fixed Assets property contract, or provider-authored layout model. - [x] Update focused core, React, SDK, protocol/OpenAPI, Builder, and source round-trip tests. ### 8. Manage ContentDatabase lifecycle in Builder - [x] Cache databases by source revision plus normalized compilation-plan hash. - [x] Coalesce concurrent compilation requests. - [x] Reuse cached database instances across field discovery, autocomplete, query preview, and response preview when their normalized compilation plans match. - [x] Replace it when source revision or query requirements change. - [x] Preserve the optional revision-keyed hosted parsed-document cache without making it a source of truth. - [x] Show exact preview warnings when concrete values reveal missing bounded results. ### 9. Materialize and verify immutable builds - [x] Compile and serialize the exact deployment-wide database during prepublish validation. - [x] Attach that validated bounded database to the immutable build without rebuilding it from later asset state. - [x] Show non-blocking prepublish warnings with database bytes, omissions, and potentially affected resources. - [x] Avoid claiming exact future route matches when dynamic values are unknown. - [x] Include the database/runtime only when a reachable configured query requires them. - [x] Ensure detail resources requesting Markdown content include all statically eligible bodies needed by dynamic routes. ### 10. Integrate all targets - [x] SSR: load the build's bounded database and conditionally include the small runtime. - [x] SSG: query the same database during generation, then omit database/runtime output when no runtime query is needed. - [x] Local/CLI: compile from the filesystem source with the same semantics. - [x] Preserve overview pagination, alternative route fields, draft-as-user-data behavior, and full article rendering. - [x] Verify ordinary Assets and fresh query-free sites perform no parser, artifact, content-runtime dependency install, or cleanup work. ### 11. Correct API and package ownership - [x] Move external Assets REST/OpenAPI contracts to `@webstudio-is/protocol`. - [x] Keep Webstudio domain models and Assets system-resource configuration in the SDK. - [x] Keep both cookie and token authentication behavior on the unified Assets REST surface. - [x] Remove pass-through route wrappers/reexports that add no transport behavior. - [x] Keep published runtime queries local to the artifact rather than routing them through Builder REST. ### 12. Remove superseded deployment infrastructure - [x] Remove external deployment-index upload, fetch, invalidation, cleanup, and garbage-collection code. - [x] Remove the SaaS asset-origin adapter requirement; close webstudio-is/webstudio-saas#652 as superseded because it has no independent purpose. - [x] Remove index-bucket compatibility configuration and avoid adding Infisical variables. - [x] Remove obsolete external-index paths, package dependencies, routes, scripts, and tests. - [x] Consolidate unreleased intermediate migrations where safe and retain only schema required by the final hosted cache/API design. ### 13. Verification and release readiness - [x] Move and preserve existing tests while changing ownership; add new tests only for changed contracts and behavior. - [x] Run content-engine compiler/runtime tests, query-builder tests, SDK/protocol/API tests, Builder tests, CLI tests, and affected typechecks. - [x] Run formatting, generated-file idempotence, fixtures, clean Supabase-compatible migrations, E2E shards, and complete agent evaluations. - [x] Verify SSR and SSG conditional inclusion and query-free generated package manifests. - [x] Run the five-article MCP blog workflow: authoring, field discovery, overview, dynamic detail routes, full Markdown rendering, publishing, and published-site verification. - [x] Run a maximum-budget database test and a mutation-during-compilation test. - [x] Perform a final architecture, security, API compatibility, migration, bundle, correctness, and DRY review. - [x] Reconcile every checkbox and replace the verification section with results from the final architecture before release. ## Future MDX work MDX is intentionally separate from this release. - The compiler contract should allow a future build-only MDX parser. - MDX compilation must not enter the published runtime. - Embedded JSX must use an explicit compiled representation rather than runtime source evaluation. - Webstudio components, tokens, styles, and component allowlists remain Webstudio integration concerns. - The future MDX work should reuse `ContentArtifact` and `ContentDatabase` where practical. ## Superseded design The following earlier design choices are no longer the target architecture: - GROQ or GraphQL as the Assets query language. - One external or long-lived index object per resource/query. - A deployment index fetched by the Worker from SaaS/CDN infrastructure. - Lazy production reads of source Markdown bodies. - A separate index bucket, asset origin, invalidation service, or garbage collector. - A 4 MiB published index limit. - Rebuilding an old build's database from the current mutable asset set. - Bundling query infrastructure into projects without a configured content query. ## Verification baseline The embedded runtime-database architecture previously passed the checks below. The OpenAPI-driven query-authoring change additionally passed: - Query-builder core: 4 files / 19 tests. - Query-builder React: 1 file / 7 tests. - SDK Assets source/config: 1 file / 7 tests. - Protocol Assets OpenAPI: 1 file / 9 tests, including independent OpenAPI validation. - Builder Assets OpenAPI description: 1 file / 5 tests. - Query-builder, React, SDK, protocol, and Builder typechecks. - Package-boundary validation and formatting of every affected file. Local verification baseline: - Content engine: 15 files / 104 tests. - Asset uploader: 24 files / 175 tests. - Builder: 204 files / 2,423 tests. - CLI: 54 files / 866 tests. - Complete workspace typecheck, formatting, lint, package boundaries, generated fixtures, and fixture builds. - The complete migration history applied successfully to a fresh Supabase-compatible database, including every migration after the consolidated Assets migration. - Builder production build and full local E2E suite passed. - Complete agent evaluations passed: authenticated page (170,859 ms), design input (207,101 ms), and font assets (67,050 ms). - Final review additionally verified correct REST edit/view/build permits, cookie and public-token read paths, immutable snapshot retries, exact database budgeting, query-free dependency omission, and SSR/SSG conditional runtime inclusion. Final staging verification passed against the embedded runtime-database architecture: MCP uploaded and updated five Markdown articles, discovered dynamic frontmatter fields, previewed a five-item sorted overview and one full-body slug query, updated both Assets resources, found zero binding issues, rendered `/blog` and `/blog/dynamic-routes` at HTTP 200, published build version 266, and verified both published routes at HTTP 200 with the expected article metadata and Markdown body. The workflow also found and fixed offset-form asset timestamps failing canonical datetime validation and made MCP `preview.start({ port: 0 })` correctly select an available local port. ## Release constraints - No new SaaS content service, bucket, or environment variable. - No published-site database dependency. - No parser/compiler dependency in query-free sites or runtime-only bundles. - The validated content database is immutable build output. - Publishing warnings do not block deployment. - The selected standard OpenAPI operation and its request JSON Schema are the only source of query UI semantics.
This commit is contained in:
1 parent
7bc9942774
commit
efbc3098c4
582 files changed
+49577
-8969
No files matched your search
@@ -61,6 +61,10 @@ jobs:
|
||||
- name: pnpm deploy
|
||||
run: pnpm --filter 'ssg-cloudflare-pages' deploy "${{ github.workspace }}/../ssg-template"
|
||||
|
||||
- name: Verify optional Assets build dependency is omitted
|
||||
run: test ! -e node_modules/@webstudio-is/content-engine/package.json
|
||||
working-directory: ${{ github.workspace }}/../ssg-template
|
||||
|
||||
- name: Make archive
|
||||
run: |
|
||||
tar --use-compress-program="zstd -19" -cf ssg-template.tar.zst ssg-template
|
||||
|
||||
@@ -62,6 +62,10 @@ jobs:
|
||||
- name: pnpm deploy
|
||||
run: pnpm --filter 'webstudio-cloudflare-template' deploy "${{ github.workspace }}/../cloudflare-template"
|
||||
|
||||
- name: Verify optional Assets build dependency is omitted
|
||||
run: test ! -e node_modules/@webstudio-is/content-engine/package.json
|
||||
working-directory: ${{ github.workspace }}/../cloudflare-template
|
||||
|
||||
- name: Make archive
|
||||
run: |
|
||||
tar --use-compress-program="zstd -19" -cf cloudflare-template.tar.zst cloudflare-template
|
||||
|
||||
@@ -65,6 +65,9 @@ jobs:
|
||||
exit "$sync_status"
|
||||
fi
|
||||
|
||||
- name: Build CLI content runtime
|
||||
run: mkdir -p packages/cli/lib && pnpm --filter webstudio build:content-runtime
|
||||
|
||||
- name: Testing cli build command
|
||||
run: pnpm --filter='./fixtures/*' run --parallel fixtures:build
|
||||
|
||||
|
||||
@@ -147,7 +147,7 @@ jobs:
|
||||
const results = [
|
||||
await assertSize('./fixtures/ssg-cloudflare-pages/dist/client', 356),
|
||||
await assertSize('./fixtures/react-router-netlify/build/client', 376),
|
||||
await assertSize('./fixtures/webstudio-features/build/client', 3312),
|
||||
await assertSize('./fixtures/webstudio-features/build/client', 3684),
|
||||
]
|
||||
for (const result of results) {
|
||||
if (result.passed) {
|
||||
|
||||
+2
-1
@@ -11,6 +11,7 @@
|
||||
"**/*.yaml",
|
||||
"**/*.toml",
|
||||
"**/*.jsonc",
|
||||
"packages/cli/templates/**"
|
||||
"packages/cli/templates/**",
|
||||
"fixtures/**/__generated__/**"
|
||||
]
|
||||
}
|
||||
+3
-3
@@ -1,10 +1,10 @@
|
||||
# To override environment variables, add a .env.development file in the same directory.
|
||||
# For more details, visit: https://vitejs.dev/guide/env-and-mode#env-files
|
||||
DATABASE_URL=postgresql://postgres:pass@localhost:55432/webstudio?pgbouncer=true
|
||||
DIRECT_URL=postgresql://postgres:pass@localhost:55432/webstudio
|
||||
DATABASE_URL=postgresql://supabase_admin:pass@localhost:55432/webstudio?pgbouncer=true
|
||||
DIRECT_URL=postgresql://supabase_admin:pass@localhost:55432/webstudio
|
||||
PGPORT=55432
|
||||
POSTGRES_DB=webstudio
|
||||
POSTGRES_USER=postgres
|
||||
POSTGRES_USER=supabase_admin
|
||||
POSTGRES_PASSWORD=pass
|
||||
|
||||
AUTH_SECRET=0000
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
import { BrushCleaningIcon, PlusIcon } from "@webstudio-is/icons";
|
||||
import { useRef, useState } from "react";
|
||||
import { useStore } from "@nanostores/react";
|
||||
import { isTextFileAsset } from "@webstudio-is/sdk";
|
||||
import { getAssetUrl, isTextFileAsset } from "@webstudio-is/sdk";
|
||||
import { AssetManager } from "~/builder/shared/asset-manager";
|
||||
import { AssetUpload, type AssetUploadHandle } from "~/builder/shared/assets";
|
||||
import { openDeleteUnusedAssetsDialog } from "~/builder/shared/asset-manager/delete-unused-assets";
|
||||
@@ -22,7 +22,6 @@ import type { Publish } from "~/shared/pubsub";
|
||||
import { useImageAssetCanvasDrag } from "./use-image-asset-canvas-drag";
|
||||
import { TextFileEditor } from "~/builder/features/text-file-editor/text-file-editor";
|
||||
import { CreateTextFileDialog } from "~/builder/features/text-file-editor/create-text-file-dialog";
|
||||
import { getAssetUrl } from "~/builder/shared/assets/asset-utils";
|
||||
|
||||
export const AssetsPanel = ({
|
||||
publish,
|
||||
|
||||
@@ -11,12 +11,9 @@ import {
|
||||
theme,
|
||||
} from "@webstudio-is/design-system";
|
||||
import { TrashIcon, PlusIcon } from "@webstudio-is/icons";
|
||||
import { isLiteralExpression } from "@webstudio-is/sdk";
|
||||
import { isLiteralExpression } from "@webstudio-is/expression";
|
||||
import { computeExpression } from "@webstudio-is/project-build/runtime";
|
||||
import {
|
||||
BindingControl,
|
||||
BindingPopover,
|
||||
} from "~/builder/shared/binding-popover";
|
||||
import { BindableExpressionControl } from "~/builder/shared/bindable-expression";
|
||||
import { $pageRootScope } from "./page-utils";
|
||||
|
||||
type Meta = {
|
||||
@@ -75,45 +72,35 @@ const MetadataItem = (props: {
|
||||
<Label htmlFor={contentId} css={{ gridArea: "content" }}>
|
||||
Content
|
||||
</Label>
|
||||
<BindingControl>
|
||||
{props.showBindingControls && (
|
||||
<BindingPopover
|
||||
scope={scope}
|
||||
aliases={aliases}
|
||||
variant={isLiteralExpression(props.content) ? "default" : "bound"}
|
||||
value={props.content}
|
||||
onChange={(value) => {
|
||||
props.onChange(props.property, value);
|
||||
}}
|
||||
onRemove={(evaluatedValue) => {
|
||||
props.onChange(
|
||||
props.property,
|
||||
JSON.stringify(evaluatedValue ?? "")
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<BindableExpressionControl
|
||||
expression={props.content}
|
||||
value={String(content ?? "")}
|
||||
bound={isLiteralExpression(props.content) === false}
|
||||
allowBindingOverwrite={false}
|
||||
showBinding={props.showBindingControls}
|
||||
scope={scope}
|
||||
aliases={aliases}
|
||||
onChangeValue={(value) =>
|
||||
props.onChange(props.property, JSON.stringify(value))
|
||||
}
|
||||
onChangeExpression={(value) => props.onChange(props.property, value)}
|
||||
onRemove={(value) =>
|
||||
props.onChange(props.property, JSON.stringify(value ?? ""))
|
||||
}
|
||||
renderControl={({ value, readOnly, onChangeValue }) => (
|
||||
<InputErrorsTooltip errors={undefined}>
|
||||
<InputField
|
||||
css={{ gridArea: "content-input" }}
|
||||
disabled={props.disabled || readOnly}
|
||||
color={typeof content !== "string" ? "error" : undefined}
|
||||
id={contentId}
|
||||
property="path"
|
||||
value={value}
|
||||
onChange={(event) => onChangeValue(event.target.value)}
|
||||
/>
|
||||
</InputErrorsTooltip>
|
||||
)}
|
||||
<InputErrorsTooltip errors={undefined}>
|
||||
<InputField
|
||||
css={{
|
||||
gridArea: "content-input",
|
||||
}}
|
||||
disabled={
|
||||
props.disabled || isLiteralExpression(props.content) === false
|
||||
}
|
||||
color={typeof content !== "string" ? "error" : undefined}
|
||||
id={contentId}
|
||||
property="path"
|
||||
value={content}
|
||||
onChange={(event) => {
|
||||
props.onChange(
|
||||
props.property,
|
||||
JSON.stringify(event.target.value)
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</InputErrorsTooltip>
|
||||
</BindingControl>
|
||||
/>
|
||||
<Grid
|
||||
css={{
|
||||
gridArea: "button",
|
||||
|
||||
@@ -5,8 +5,8 @@ import {
|
||||
findPageByIdOrPath,
|
||||
getPageDraftabilityError,
|
||||
isPageDraft,
|
||||
isLiteralExpression,
|
||||
} from "@webstudio-is/sdk";
|
||||
import { isLiteralExpression } from "@webstudio-is/expression";
|
||||
import {
|
||||
theme,
|
||||
Button,
|
||||
|
||||
@@ -16,16 +16,10 @@ import {
|
||||
rawTheme,
|
||||
theme,
|
||||
} from "@webstudio-is/design-system";
|
||||
import {
|
||||
documentTypes,
|
||||
isLiteralExpression,
|
||||
type Pages,
|
||||
} from "@webstudio-is/sdk";
|
||||
import { isLiteralExpression } from "@webstudio-is/expression";
|
||||
import { documentTypes, type Pages } from "@webstudio-is/sdk";
|
||||
import { HomeIcon, InfoCircleIcon } from "@webstudio-is/icons";
|
||||
import {
|
||||
BindingControl,
|
||||
BindingPopover,
|
||||
} from "~/builder/shared/binding-popover";
|
||||
import { BindableExpressionControl } from "~/builder/shared/bindable-expression";
|
||||
import { computeExpression } from "@webstudio-is/project-build/runtime";
|
||||
import { $permissions } from "~/shared/nano-states";
|
||||
import { $pageRootScope } from "../page-utils";
|
||||
@@ -123,6 +117,13 @@ const StatusField = ({
|
||||
}) => {
|
||||
const id = useId();
|
||||
const { variableValues, scope, aliases } = useStore($pageRootScope);
|
||||
const parseStatus = (value: string) => {
|
||||
if (value === "") {
|
||||
return;
|
||||
}
|
||||
const number = Number(value);
|
||||
return Number.isNaN(number) || String(number) !== value ? value : number;
|
||||
};
|
||||
return (
|
||||
<Grid gap={1}>
|
||||
<Flex align="center" gap={1}>
|
||||
@@ -150,42 +151,39 @@ const StatusField = ({
|
||||
/>
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
<BindingControl>
|
||||
{showBindingControls && (
|
||||
<BindingPopover
|
||||
scope={scope}
|
||||
aliases={aliases}
|
||||
variant={isLiteralExpression(value) ? "default" : "bound"}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onRemove={(evaluatedValue) =>
|
||||
onChange(JSON.stringify(evaluatedValue ?? ""))
|
||||
}
|
||||
/>
|
||||
<BindableExpressionControl
|
||||
expression={value ?? ""}
|
||||
value={String(computeExpression(value, variableValues) ?? "")}
|
||||
bound={value !== undefined && isLiteralExpression(value) === false}
|
||||
allowBindingOverwrite={false}
|
||||
showBinding={showBindingControls}
|
||||
scope={scope}
|
||||
aliases={aliases}
|
||||
parseValue={parseStatus}
|
||||
onChangeValue={(value) => {
|
||||
const status = parseStatus(value);
|
||||
if (status === undefined) {
|
||||
onChange(undefined);
|
||||
return;
|
||||
}
|
||||
onChange(JSON.stringify(status));
|
||||
}}
|
||||
onChangeExpression={onChange}
|
||||
onRemove={(value) => onChange(JSON.stringify(value ?? ""))}
|
||||
renderControl={({ value, readOnly, onChangeValue }) => (
|
||||
<InputErrorsTooltip errors={errors}>
|
||||
<InputField
|
||||
inputMode="numeric"
|
||||
color={errors && "error"}
|
||||
id={id}
|
||||
placeholder="200"
|
||||
disabled={disabled || readOnly}
|
||||
value={value}
|
||||
onChange={(event) => onChangeValue(event.target.value)}
|
||||
/>
|
||||
</InputErrorsTooltip>
|
||||
)}
|
||||
<InputErrorsTooltip errors={errors}>
|
||||
<InputField
|
||||
inputMode="numeric"
|
||||
color={errors && "error"}
|
||||
id={id}
|
||||
placeholder="200"
|
||||
disabled={disabled || isLiteralExpression(value) === false}
|
||||
value={String(computeExpression(value, variableValues) ?? "")}
|
||||
onChange={(event) => {
|
||||
if (event.target.value === "") {
|
||||
onChange(undefined);
|
||||
} else {
|
||||
const number = Number(event.target.value);
|
||||
const status =
|
||||
Number.isNaN(number) || String(number) !== event.target.value
|
||||
? event.target.value
|
||||
: number;
|
||||
onChange(JSON.stringify(status));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</InputErrorsTooltip>
|
||||
</BindingControl>
|
||||
/>
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
@@ -249,30 +247,30 @@ const RedirectField = ({
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
|
||||
<BindingControl>
|
||||
{showBindingControls && (
|
||||
<BindingPopover
|
||||
scope={scope}
|
||||
aliases={aliases}
|
||||
variant={isLiteralExpression(value) ? "default" : "bound"}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onRemove={(evaluatedValue) =>
|
||||
onChange(JSON.stringify(evaluatedValue ?? ""))
|
||||
}
|
||||
/>
|
||||
<BindableExpressionControl
|
||||
expression={value ?? ""}
|
||||
value={computePageSettingsText(value, variableValues)}
|
||||
bound={value !== undefined && isLiteralExpression(value) === false}
|
||||
allowBindingOverwrite={false}
|
||||
showBinding={showBindingControls}
|
||||
scope={scope}
|
||||
aliases={aliases}
|
||||
onChangeValue={(value) => onChange(JSON.stringify(value))}
|
||||
onChangeExpression={onChange}
|
||||
onRemove={(value) => onChange(JSON.stringify(value ?? ""))}
|
||||
renderControl={({ value, readOnly, onChangeValue }) => (
|
||||
<InputErrorsTooltip errors={errors}>
|
||||
<InputField
|
||||
color={errors && "error"}
|
||||
id={id}
|
||||
placeholder="/another-path"
|
||||
disabled={disabled || readOnly}
|
||||
value={value}
|
||||
onChange={(event) => onChangeValue(event.target.value)}
|
||||
/>
|
||||
</InputErrorsTooltip>
|
||||
)}
|
||||
<InputErrorsTooltip errors={errors}>
|
||||
<InputField
|
||||
color={errors && "error"}
|
||||
id={id}
|
||||
placeholder="/another-path"
|
||||
disabled={disabled || isLiteralExpression(value) === false}
|
||||
value={computePageSettingsText(value, variableValues)}
|
||||
onChange={(event) => onChange(JSON.stringify(event.target.value))}
|
||||
/>
|
||||
</InputErrorsTooltip>
|
||||
</BindingControl>
|
||||
/>
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -10,11 +10,8 @@ import {
|
||||
TextArea,
|
||||
theme,
|
||||
} from "@webstudio-is/design-system";
|
||||
import { isLiteralExpression } from "@webstudio-is/sdk";
|
||||
import {
|
||||
BindingControl,
|
||||
BindingPopover,
|
||||
} from "~/builder/shared/binding-popover";
|
||||
import { isLiteralExpression } from "@webstudio-is/expression";
|
||||
import { BindableExpressionControl } from "~/builder/shared/bindable-expression";
|
||||
import { computeExpression } from "@webstudio-is/project-build/runtime";
|
||||
import type {
|
||||
PageSettingsErrors,
|
||||
@@ -44,30 +41,30 @@ const LanguageField = ({
|
||||
return (
|
||||
<Grid gap={1}>
|
||||
<Label htmlFor={id}>Language</Label>
|
||||
<BindingControl>
|
||||
{showBindingControls && (
|
||||
<BindingPopover
|
||||
scope={scope}
|
||||
aliases={aliases}
|
||||
variant={isLiteralExpression(value) ? "default" : "bound"}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onRemove={(evaluatedValue) =>
|
||||
onChange(JSON.stringify(evaluatedValue ?? ""))
|
||||
}
|
||||
/>
|
||||
<BindableExpressionControl
|
||||
expression={value}
|
||||
value={computePageSettingsText(value, variableValues)}
|
||||
bound={isLiteralExpression(value) === false}
|
||||
allowBindingOverwrite={false}
|
||||
showBinding={showBindingControls}
|
||||
scope={scope}
|
||||
aliases={aliases}
|
||||
onChangeValue={(value) => onChange(JSON.stringify(value))}
|
||||
onChangeExpression={onChange}
|
||||
onRemove={(value) => onChange(JSON.stringify(value ?? ""))}
|
||||
renderControl={({ value, readOnly, onChangeValue }) => (
|
||||
<InputErrorsTooltip errors={errors}>
|
||||
<InputField
|
||||
color={errors && "error"}
|
||||
id={id}
|
||||
placeholder="en-US"
|
||||
disabled={disabled || readOnly}
|
||||
value={value}
|
||||
onChange={(event) => onChangeValue(event.target.value)}
|
||||
/>
|
||||
</InputErrorsTooltip>
|
||||
)}
|
||||
<InputErrorsTooltip errors={errors}>
|
||||
<InputField
|
||||
color={errors && "error"}
|
||||
id={id}
|
||||
placeholder="en-US"
|
||||
disabled={disabled || isLiteralExpression(value) === false}
|
||||
value={computePageSettingsText(value, variableValues)}
|
||||
onChange={(event) => onChange(JSON.stringify(event.target.value))}
|
||||
/>
|
||||
</InputErrorsTooltip>
|
||||
</BindingControl>
|
||||
/>
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
@@ -146,150 +143,120 @@ export const SearchSection = ({
|
||||
|
||||
<Grid gap={1}>
|
||||
<Label htmlFor={titleId}>Title</Label>
|
||||
<BindingControl>
|
||||
{showBindingControls && (
|
||||
<BindingPopover
|
||||
scope={scope}
|
||||
aliases={aliases}
|
||||
variant={isLiteralExpression(values.title) ? "default" : "bound"}
|
||||
value={values.title}
|
||||
onChange={(value) => {
|
||||
onChange({
|
||||
field: "title",
|
||||
value,
|
||||
});
|
||||
}}
|
||||
onRemove={(evaluatedValue) => {
|
||||
onChange({
|
||||
field: "title",
|
||||
value: JSON.stringify(evaluatedValue ?? ""),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<BindableExpressionControl
|
||||
expression={values.title}
|
||||
value={title}
|
||||
bound={isLiteralExpression(values.title) === false}
|
||||
allowBindingOverwrite={false}
|
||||
showBinding={showBindingControls}
|
||||
scope={scope}
|
||||
aliases={aliases}
|
||||
onChangeValue={(value) =>
|
||||
onChange({ field: "title", value: JSON.stringify(value) })
|
||||
}
|
||||
onChangeExpression={(value) => onChange({ field: "title", value })}
|
||||
onRemove={(value) =>
|
||||
onChange({ field: "title", value: JSON.stringify(value ?? "") })
|
||||
}
|
||||
renderControl={({ value, readOnly, onChangeValue }) => (
|
||||
<InputErrorsTooltip errors={errors.title}>
|
||||
<InputField
|
||||
color={errors.title && "error"}
|
||||
id={titleId}
|
||||
name="title"
|
||||
placeholder="My awesome project - About"
|
||||
disabled={canEditTitle === false || readOnly}
|
||||
value={value}
|
||||
onChange={(event) => onChangeValue(event.target.value)}
|
||||
/>
|
||||
</InputErrorsTooltip>
|
||||
)}
|
||||
<InputErrorsTooltip errors={errors.title}>
|
||||
<InputField
|
||||
color={errors.title && "error"}
|
||||
id={titleId}
|
||||
name="title"
|
||||
placeholder="My awesome project - About"
|
||||
disabled={
|
||||
canEditTitle === false ||
|
||||
isLiteralExpression(values.title) === false
|
||||
}
|
||||
value={title}
|
||||
onChange={(event) => {
|
||||
onChange({
|
||||
field: "title",
|
||||
value: JSON.stringify(event.target.value),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</InputErrorsTooltip>
|
||||
</BindingControl>
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid gap={1}>
|
||||
<Label htmlFor={descriptionId}>Description</Label>
|
||||
<BindingControl>
|
||||
{showBindingControls && (
|
||||
<BindingPopover
|
||||
scope={scope}
|
||||
aliases={aliases}
|
||||
variant={
|
||||
isLiteralExpression(values.description) ? "default" : "bound"
|
||||
}
|
||||
value={values.description}
|
||||
onChange={(value) => {
|
||||
onChange({
|
||||
field: "description",
|
||||
value,
|
||||
});
|
||||
}}
|
||||
onRemove={(evaluatedValue) => {
|
||||
onChange({
|
||||
field: "description",
|
||||
value: JSON.stringify(evaluatedValue ?? ""),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<InputErrorsTooltip errors={errors.description}>
|
||||
<TextArea
|
||||
color={errors.description ? "error" : undefined}
|
||||
id={descriptionId}
|
||||
name="description"
|
||||
disabled={
|
||||
canEditDescription === false ||
|
||||
isLiteralExpression(values.description) === false
|
||||
}
|
||||
value={description}
|
||||
onChange={(value) => {
|
||||
onChange({
|
||||
field: "description",
|
||||
value: JSON.stringify(value),
|
||||
});
|
||||
}}
|
||||
autoGrow
|
||||
maxRows={10}
|
||||
/>
|
||||
</InputErrorsTooltip>
|
||||
</BindingControl>
|
||||
<BindingControl>
|
||||
<Grid
|
||||
flow={"column"}
|
||||
gap={1}
|
||||
justify={"start"}
|
||||
align={"center"}
|
||||
css={{ py: theme.spacing[2] }}
|
||||
>
|
||||
{showBindingControls && (
|
||||
<BindingPopover
|
||||
scope={scope}
|
||||
aliases={aliases}
|
||||
variant={
|
||||
isLiteralExpression(values.excludePageFromSearch)
|
||||
? "default"
|
||||
: "bound"
|
||||
}
|
||||
value={values.excludePageFromSearch}
|
||||
onChange={(value) => {
|
||||
onChange({
|
||||
field: "excludePageFromSearch",
|
||||
value,
|
||||
});
|
||||
}}
|
||||
onRemove={(evaluatedValue) => {
|
||||
onChange({
|
||||
field: "excludePageFromSearch",
|
||||
value: JSON.stringify(evaluatedValue ?? ""),
|
||||
});
|
||||
}}
|
||||
<BindableExpressionControl
|
||||
expression={values.description}
|
||||
value={description}
|
||||
bound={isLiteralExpression(values.description) === false}
|
||||
allowBindingOverwrite={false}
|
||||
showBinding={showBindingControls}
|
||||
scope={scope}
|
||||
aliases={aliases}
|
||||
onChangeValue={(value) =>
|
||||
onChange({ field: "description", value: JSON.stringify(value) })
|
||||
}
|
||||
onChangeExpression={(value) =>
|
||||
onChange({ field: "description", value })
|
||||
}
|
||||
onRemove={(value) =>
|
||||
onChange({
|
||||
field: "description",
|
||||
value: JSON.stringify(value ?? ""),
|
||||
})
|
||||
}
|
||||
renderControl={({ value, readOnly, onChangeValue }) => (
|
||||
<InputErrorsTooltip errors={errors.description}>
|
||||
<TextArea
|
||||
color={errors.description ? "error" : undefined}
|
||||
id={descriptionId}
|
||||
name="description"
|
||||
disabled={canEditDescription === false || readOnly}
|
||||
value={value}
|
||||
onChange={onChangeValue}
|
||||
autoGrow
|
||||
maxRows={10}
|
||||
/>
|
||||
)}
|
||||
<Checkbox
|
||||
id={excludePageFromSearchId}
|
||||
disabled={
|
||||
canEditExcludePageFromSearch === false ||
|
||||
isLiteralExpression(values.excludePageFromSearch) === false
|
||||
}
|
||||
checked={excludePageFromSearch}
|
||||
onCheckedChange={() => {
|
||||
const newValue = !excludePageFromSearch;
|
||||
onChange({
|
||||
field: "excludePageFromSearch",
|
||||
value: newValue.toString(),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
<InputErrorsTooltip errors={errors.excludePageFromSearch}>
|
||||
<Label htmlFor={excludePageFromSearchId}>
|
||||
Exclude this page from search results
|
||||
</Label>
|
||||
</InputErrorsTooltip>
|
||||
</Grid>
|
||||
</BindingControl>
|
||||
)}
|
||||
/>
|
||||
<BindableExpressionControl
|
||||
expression={values.excludePageFromSearch}
|
||||
value={excludePageFromSearch}
|
||||
bound={isLiteralExpression(values.excludePageFromSearch) === false}
|
||||
allowBindingOverwrite={false}
|
||||
showBinding={showBindingControls}
|
||||
scope={scope}
|
||||
aliases={aliases}
|
||||
onChangeValue={(value) =>
|
||||
onChange({
|
||||
field: "excludePageFromSearch",
|
||||
value: value.toString(),
|
||||
})
|
||||
}
|
||||
onChangeExpression={(value) =>
|
||||
onChange({ field: "excludePageFromSearch", value })
|
||||
}
|
||||
onRemove={(value) =>
|
||||
onChange({
|
||||
field: "excludePageFromSearch",
|
||||
value: JSON.stringify(value ?? ""),
|
||||
})
|
||||
}
|
||||
renderControl={({ value, readOnly, onChangeValue }) => (
|
||||
<Grid
|
||||
flow={"column"}
|
||||
gap={1}
|
||||
justify={"start"}
|
||||
align={"center"}
|
||||
css={{ py: theme.spacing[2] }}
|
||||
>
|
||||
<Checkbox
|
||||
id={excludePageFromSearchId}
|
||||
disabled={canEditExcludePageFromSearch === false || readOnly}
|
||||
checked={value}
|
||||
onCheckedChange={() => onChangeValue(!value)}
|
||||
/>
|
||||
|
||||
<InputErrorsTooltip errors={errors.excludePageFromSearch}>
|
||||
<Label htmlFor={excludePageFromSearchId}>
|
||||
Exclude this page from search results
|
||||
</Label>
|
||||
</InputErrorsTooltip>
|
||||
</Grid>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<LanguageField
|
||||
|
||||
@@ -7,17 +7,14 @@ import {
|
||||
InputField,
|
||||
Text,
|
||||
} from "@webstudio-is/design-system";
|
||||
import {
|
||||
BindingControl,
|
||||
BindingPopover,
|
||||
} from "~/builder/shared/binding-popover";
|
||||
import { BindableExpressionControl } from "~/builder/shared/bindable-expression";
|
||||
import type {
|
||||
PageSettingsErrors,
|
||||
PageSettingsValues,
|
||||
} from "@webstudio-is/project-build/runtime";
|
||||
import { ImageControl } from "~/shared/project-settings";
|
||||
import { $assets } from "~/shared/sync/data-stores";
|
||||
import { isLiteralExpression } from "@webstudio-is/sdk";
|
||||
import { isLiteralExpression } from "@webstudio-is/expression";
|
||||
import { $pageRootScope } from "../page-utils";
|
||||
import { ImageInfo } from "../image-info";
|
||||
import { SocialPreview } from "../social-preview";
|
||||
@@ -58,47 +55,39 @@ export const SocialImageSection = ({
|
||||
settings will be used. The optimal dimensions for the image are 1200x630
|
||||
px or larger with a 1.91:1 aspect ratio.
|
||||
</Text>
|
||||
<BindingControl>
|
||||
{showBindingControls && (
|
||||
<BindingPopover
|
||||
scope={scope}
|
||||
aliases={aliases}
|
||||
variant={
|
||||
isLiteralExpression(values.socialImageUrl) ? "default" : "bound"
|
||||
}
|
||||
value={values.socialImageUrl}
|
||||
onChange={(value) => {
|
||||
onChange({
|
||||
field: "socialImageUrl",
|
||||
value,
|
||||
});
|
||||
}}
|
||||
onRemove={(evaluatedValue) => {
|
||||
onChange({
|
||||
field: "socialImageUrl",
|
||||
value: JSON.stringify(evaluatedValue ?? ""),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<BindableExpressionControl
|
||||
expression={values.socialImageUrl}
|
||||
value={socialImageUrl}
|
||||
bound={isLiteralExpression(values.socialImageUrl) === false}
|
||||
allowBindingOverwrite={false}
|
||||
showBinding={showBindingControls}
|
||||
scope={scope}
|
||||
aliases={aliases}
|
||||
onChangeValue={(value) => {
|
||||
onChange({ field: "socialImageUrl", value: JSON.stringify(value) });
|
||||
onChange({ field: "socialImageAssetId", value: "" });
|
||||
}}
|
||||
onChangeExpression={(value) =>
|
||||
onChange({ field: "socialImageUrl", value })
|
||||
}
|
||||
onRemove={(value) =>
|
||||
onChange({
|
||||
field: "socialImageUrl",
|
||||
value: JSON.stringify(value ?? ""),
|
||||
})
|
||||
}
|
||||
renderControl={({ value, readOnly, onChangeValue }) => (
|
||||
<InputErrorsTooltip errors={errors.socialImageUrl}>
|
||||
<InputField
|
||||
placeholder="https://www.url.com"
|
||||
disabled={disabled || readOnly}
|
||||
color={errors.socialImageUrl && "error"}
|
||||
value={value}
|
||||
onChange={(event) => onChangeValue(event.target.value)}
|
||||
/>
|
||||
</InputErrorsTooltip>
|
||||
)}
|
||||
<InputErrorsTooltip errors={errors.socialImageUrl}>
|
||||
<InputField
|
||||
placeholder="https://www.url.com"
|
||||
disabled={
|
||||
disabled || isLiteralExpression(values.socialImageUrl) === false
|
||||
}
|
||||
color={errors.socialImageUrl && "error"}
|
||||
value={socialImageUrl}
|
||||
onChange={(event) => {
|
||||
onChange({
|
||||
field: "socialImageUrl",
|
||||
value: JSON.stringify(event.target.value),
|
||||
});
|
||||
onChange({ field: "socialImageAssetId", value: "" });
|
||||
}}
|
||||
/>
|
||||
</InputErrorsTooltip>
|
||||
</BindingControl>
|
||||
/>
|
||||
<Grid gap={1} flow={"column"}>
|
||||
<ImageControl
|
||||
onAssetIdChange={(socialImageAssetId) => {
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import { useId } from "react";
|
||||
import { useStore } from "@nanostores/react";
|
||||
import { Grid, Label, Text, TextArea } from "@webstudio-is/design-system";
|
||||
import { isLiteralExpression } from "@webstudio-is/sdk";
|
||||
import {
|
||||
BindingControl,
|
||||
BindingPopover,
|
||||
} from "~/builder/shared/binding-popover";
|
||||
import { isLiteralExpression } from "@webstudio-is/expression";
|
||||
import { BindableExpressionControl } from "~/builder/shared/bindable-expression";
|
||||
import { computeExpression } from "@webstudio-is/project-build/runtime";
|
||||
import type {
|
||||
PageSettingsErrors,
|
||||
@@ -31,39 +28,37 @@ export const TextContentSection = ({
|
||||
<Text color="subtle">The plain text content served for this page.</Text>
|
||||
<Grid gap={1}>
|
||||
<Label htmlFor={contentId}>Text</Label>
|
||||
<BindingControl>
|
||||
<BindingPopover
|
||||
scope={scope}
|
||||
aliases={aliases}
|
||||
variant={isLiteralExpression(values.content) ? "default" : "bound"}
|
||||
value={values.content}
|
||||
onChange={(value) => {
|
||||
onChange({ field: "content", value });
|
||||
}}
|
||||
onRemove={(evaluatedValue) => {
|
||||
onChange({
|
||||
field: "content",
|
||||
value: JSON.stringify(evaluatedValue ?? ""),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<TextArea
|
||||
id={contentId}
|
||||
color={errors.content ? "error" : undefined}
|
||||
disabled={isLiteralExpression(values.content) === false}
|
||||
value={String(
|
||||
computeExpression(values.content, variableValues) ?? ""
|
||||
)}
|
||||
onChange={(value) => {
|
||||
onChange({
|
||||
field: "content",
|
||||
value: JSON.stringify(value),
|
||||
});
|
||||
}}
|
||||
autoGrow
|
||||
maxRows={20}
|
||||
/>
|
||||
</BindingControl>
|
||||
<BindableExpressionControl
|
||||
expression={values.content}
|
||||
value={String(
|
||||
computeExpression(values.content, variableValues) ?? ""
|
||||
)}
|
||||
bound={isLiteralExpression(values.content) === false}
|
||||
allowBindingOverwrite={false}
|
||||
scope={scope}
|
||||
aliases={aliases}
|
||||
onChangeValue={(value) =>
|
||||
onChange({ field: "content", value: JSON.stringify(value) })
|
||||
}
|
||||
onChangeExpression={(value) => onChange({ field: "content", value })}
|
||||
onRemove={(value) =>
|
||||
onChange({
|
||||
field: "content",
|
||||
value: JSON.stringify(value ?? ""),
|
||||
})
|
||||
}
|
||||
renderControl={({ value, readOnly, onChangeValue }) => (
|
||||
<TextArea
|
||||
id={contentId}
|
||||
color={errors.content ? "error" : undefined}
|
||||
disabled={readOnly}
|
||||
value={value}
|
||||
onChange={onChangeValue}
|
||||
autoGrow
|
||||
maxRows={20}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { useState, type JSX } from "react";
|
||||
import { useStore } from "@nanostores/react";
|
||||
import {
|
||||
type PageTemplate,
|
||||
type Page,
|
||||
type Pages,
|
||||
isLiteralExpression,
|
||||
} from "@webstudio-is/sdk";
|
||||
import { type PageTemplate, type Page, type Pages } from "@webstudio-is/sdk";
|
||||
import { isLiteralExpression } from "@webstudio-is/expression";
|
||||
import {
|
||||
Button,
|
||||
DialogClose,
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { getContentDatabasePublishWarning } from "./content-database-publish-warning";
|
||||
|
||||
describe("content database publish warning", () => {
|
||||
test("reports bounded files and dynamic resources", () => {
|
||||
expect(
|
||||
getContentDatabasePublishWarning({
|
||||
stats: {
|
||||
usedBytes: 500 * 1024,
|
||||
maxBytes: 500 * 1024,
|
||||
includedDocumentCount: 80,
|
||||
omittedDocumentCount: 20,
|
||||
truncated: true,
|
||||
},
|
||||
potentiallyAffectedResources: [
|
||||
{ id: "overview", name: "Blog overview" },
|
||||
{ id: "detail", name: "Blog article" },
|
||||
],
|
||||
hasDynamicValues: true,
|
||||
})
|
||||
).toEqual({
|
||||
includedDocumentCount: 80,
|
||||
totalDocumentCount: 100,
|
||||
omittedDocumentCount: 20,
|
||||
usedKiB: 500,
|
||||
maxKiB: 500,
|
||||
affectedResourceNames: ["Blog overview", "Blog article"],
|
||||
affectedResourceKind: "dynamic",
|
||||
});
|
||||
});
|
||||
|
||||
test("does not warn for a complete database", () => {
|
||||
expect(
|
||||
getContentDatabasePublishWarning({
|
||||
stats: {
|
||||
usedBytes: 100,
|
||||
maxBytes: 500 * 1024,
|
||||
includedDocumentCount: 5,
|
||||
omittedDocumentCount: 0,
|
||||
truncated: false,
|
||||
},
|
||||
potentiallyAffectedResources: [],
|
||||
hasDynamicValues: false,
|
||||
})
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
test("identifies statically affected resources", () => {
|
||||
expect(
|
||||
getContentDatabasePublishWarning({
|
||||
stats: {
|
||||
usedBytes: 1025,
|
||||
maxBytes: 2048,
|
||||
includedDocumentCount: 1,
|
||||
omittedDocumentCount: 1,
|
||||
truncated: true,
|
||||
},
|
||||
potentiallyAffectedResources: [{ id: "overview", name: "Overview" }],
|
||||
hasDynamicValues: false,
|
||||
})
|
||||
).toMatchObject({
|
||||
usedKiB: 2,
|
||||
affectedResourceNames: ["Overview"],
|
||||
affectedResourceKind: "static",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
type ContentDatabasePublishDiagnostics = {
|
||||
stats: {
|
||||
usedBytes: number;
|
||||
maxBytes: number;
|
||||
includedDocumentCount: number;
|
||||
omittedDocumentCount: number;
|
||||
truncated: boolean;
|
||||
};
|
||||
potentiallyAffectedResources: { id: string; name: string }[];
|
||||
hasDynamicValues: boolean;
|
||||
};
|
||||
|
||||
export type ContentDatabasePublishWarning = {
|
||||
includedDocumentCount: number;
|
||||
totalDocumentCount: number;
|
||||
omittedDocumentCount: number;
|
||||
usedKiB: number;
|
||||
maxKiB: number;
|
||||
affectedResourceNames: string[];
|
||||
affectedResourceKind: "dynamic" | "static" | undefined;
|
||||
};
|
||||
|
||||
export const getContentDatabasePublishWarning = (
|
||||
diagnostics: ContentDatabasePublishDiagnostics | undefined
|
||||
) => {
|
||||
if (diagnostics?.stats.truncated !== true) {
|
||||
return;
|
||||
}
|
||||
const { stats } = diagnostics;
|
||||
const affectedResourceNames = diagnostics.potentiallyAffectedResources.map(
|
||||
({ name }) => name
|
||||
);
|
||||
return {
|
||||
includedDocumentCount: stats.includedDocumentCount,
|
||||
totalDocumentCount:
|
||||
stats.includedDocumentCount + stats.omittedDocumentCount,
|
||||
omittedDocumentCount: stats.omittedDocumentCount,
|
||||
usedKiB: Math.ceil(stats.usedBytes / 1024),
|
||||
maxKiB: Math.ceil(stats.maxBytes / 1024),
|
||||
affectedResourceNames,
|
||||
affectedResourceKind:
|
||||
affectedResourceNames.length === 0
|
||||
? undefined
|
||||
: diagnostics.hasDynamicValues
|
||||
? "dynamic"
|
||||
: "static",
|
||||
} satisfies ContentDatabasePublishWarning;
|
||||
};
|
||||
@@ -75,7 +75,10 @@ import {
|
||||
import { AddDomain } from "./add-domain";
|
||||
import { humanizeString } from "~/shared/string-utils";
|
||||
import { trpcClient, nativeClient } from "~/shared/trpc/trpc-client";
|
||||
import { type Templates } from "@webstudio-is/sdk";
|
||||
import {
|
||||
createReachableAssetContentCompilationPlan,
|
||||
type Templates,
|
||||
} from "@webstudio-is/sdk";
|
||||
import { DomainCheckbox, domainToPublishName } from "./domain-checkbox";
|
||||
import { CopyToClipboard } from "~/shared/copy-to-clipboard";
|
||||
import { $openProjectSettings } from "~/shared/nano-states/project-settings";
|
||||
@@ -102,6 +105,10 @@ import {
|
||||
runPrePublishAudit,
|
||||
type PrePublishAuditFinding,
|
||||
} from "@webstudio-is/project-build/runtime";
|
||||
import {
|
||||
getContentDatabasePublishWarning,
|
||||
type ContentDatabasePublishWarning,
|
||||
} from "./content-database-publish-warning";
|
||||
|
||||
const PrePublishAuditMessage = ({
|
||||
finding,
|
||||
@@ -183,6 +190,64 @@ const getPrePublishAuditMessages = () => {
|
||||
};
|
||||
};
|
||||
|
||||
const loadContentDatabasePublishWarning = async (projectId: string) => {
|
||||
const plan = createReachableAssetContentCompilationPlan({
|
||||
props: [...$props.get().values()],
|
||||
dataSources: [...$dataSources.get().values()],
|
||||
resources: [...$resources.get().values()],
|
||||
});
|
||||
if (plan === undefined) {
|
||||
return;
|
||||
}
|
||||
return getContentDatabasePublishWarning(
|
||||
await nativeClient.build.contentDatabasePublishDiagnostics.query({
|
||||
projectId,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const ContentDatabasePublishWarningMessage = ({
|
||||
warning,
|
||||
}: {
|
||||
warning: ContentDatabasePublishWarning;
|
||||
}) => (
|
||||
<>
|
||||
The published content database will include {warning.includedDocumentCount}{" "}
|
||||
of {warning.totalDocumentCount} files ({warning.usedKiB} of {warning.maxKiB}{" "}
|
||||
KiB). {warning.omittedDocumentCount} files will be omitted.
|
||||
{warning.affectedResourceKind === "dynamic" && (
|
||||
<>
|
||||
{" "}
|
||||
Queries with route or variable values may be incomplete in:{" "}
|
||||
{warning.affectedResourceNames.join(", ")}.
|
||||
</>
|
||||
)}
|
||||
{warning.affectedResourceKind === "static" && (
|
||||
<>
|
||||
{" "}
|
||||
Potentially affected Assets resources:{" "}
|
||||
{warning.affectedResourceNames.join(", ")}.
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
const showContentDatabasePublishWarning = async ({
|
||||
projectId,
|
||||
setWarning,
|
||||
}: {
|
||||
projectId: Project["id"];
|
||||
setWarning: (warning: JSX.Element) => void;
|
||||
}) => {
|
||||
const warning = await loadContentDatabasePublishWarning(projectId);
|
||||
if (warning === undefined) {
|
||||
return;
|
||||
}
|
||||
const message = <ContentDatabasePublishWarningMessage warning={warning} />;
|
||||
toast.warn(message);
|
||||
setWarning(message);
|
||||
};
|
||||
|
||||
type ChangeProjectDomainProps = {
|
||||
project: Project;
|
||||
projectState: "idle" | "submitting";
|
||||
@@ -412,12 +477,20 @@ const ChangeProjectDomain = ({
|
||||
};
|
||||
|
||||
const $restrictedFeatures = computed(
|
||||
[$pages, $projectSettings, $dataSources, $instances, $permissions],
|
||||
(pages, projectSettings, dataSources, instances, permissions) =>
|
||||
[
|
||||
$pages,
|
||||
$projectSettings,
|
||||
$dataSources,
|
||||
$resources,
|
||||
$instances,
|
||||
$permissions,
|
||||
],
|
||||
(pages, projectSettings, dataSources, resources, instances, permissions) =>
|
||||
getRestrictedFeatures({
|
||||
pages,
|
||||
projectSettings,
|
||||
dataSources,
|
||||
resources,
|
||||
instances,
|
||||
permissions,
|
||||
})
|
||||
@@ -645,6 +718,20 @@ const Publish = ({
|
||||
}
|
||||
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await showContentDatabasePublishWarning({
|
||||
projectId: project.id,
|
||||
setWarning: setPublishWarning,
|
||||
});
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Content database validation failed";
|
||||
toast.error(message);
|
||||
setPublishError(message);
|
||||
return;
|
||||
}
|
||||
await publish(domains);
|
||||
});
|
||||
};
|
||||
@@ -795,6 +882,11 @@ const PublishStatic = ({
|
||||
try {
|
||||
setIsPendingOptimistic(true);
|
||||
|
||||
await showContentDatabasePublishWarning({
|
||||
projectId,
|
||||
setWarning: setPublishWarning,
|
||||
});
|
||||
|
||||
const result = await nativeClient.domain.publish.mutate({
|
||||
projectId,
|
||||
destination: "static",
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type Instance,
|
||||
type Page,
|
||||
type Pages,
|
||||
type Resource,
|
||||
} from "@webstudio-is/sdk";
|
||||
import {
|
||||
getRestrictedFeatures,
|
||||
@@ -68,18 +69,21 @@ const getFeatures = ({
|
||||
projectSettings,
|
||||
permissions,
|
||||
dataSources = new Map<string, DataSource>(),
|
||||
resources = new Map<string, Resource>(),
|
||||
instances = new Map<string, Instance>(),
|
||||
}: {
|
||||
pages: Pages | undefined;
|
||||
projectSettings?: ProjectSettings;
|
||||
permissions: Partial<RestrictedFeaturesPermissions>;
|
||||
dataSources?: Map<string, DataSource>;
|
||||
resources?: Map<string, Resource>;
|
||||
instances?: Map<string, Instance>;
|
||||
}) =>
|
||||
getRestrictedFeatures({
|
||||
pages,
|
||||
projectSettings,
|
||||
dataSources,
|
||||
resources,
|
||||
instances,
|
||||
permissions: { ...defaultPermissions, ...permissions },
|
||||
});
|
||||
@@ -219,4 +223,64 @@ describe("getRestrictedFeatures", () => {
|
||||
"Resource variable",
|
||||
]);
|
||||
});
|
||||
|
||||
test("identifies an Assets resource as a restricted custom-domain feature", () => {
|
||||
const resources = new Map<string, Resource>([
|
||||
[
|
||||
"assets-resource",
|
||||
{
|
||||
id: "assets-resource",
|
||||
name: "Assets",
|
||||
control: "system",
|
||||
method: "post",
|
||||
url: '"/$resources/assets"',
|
||||
searchParams: [],
|
||||
headers: [],
|
||||
body: "({ query: {} })",
|
||||
},
|
||||
],
|
||||
]);
|
||||
const dataSources = new Map<string, DataSource>([
|
||||
[
|
||||
"assets-variable",
|
||||
{
|
||||
type: "resource",
|
||||
id: "assets-variable",
|
||||
resourceId: "assets-resource",
|
||||
scopeInstanceId: homePage.rootInstanceId,
|
||||
name: "assets",
|
||||
},
|
||||
],
|
||||
]);
|
||||
const instances = new Map<string, Instance>([
|
||||
[
|
||||
homePage.rootInstanceId,
|
||||
{
|
||||
type: "instance",
|
||||
id: homePage.rootInstanceId,
|
||||
component: "ws:element",
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
]);
|
||||
const features = getFeatures({
|
||||
pages: createPages(),
|
||||
permissions: { allowDynamicData: false },
|
||||
dataSources,
|
||||
resources,
|
||||
instances,
|
||||
});
|
||||
|
||||
expect([...features.keys()]).toEqual(["Assets resource"]);
|
||||
|
||||
expect(
|
||||
getFeatures({
|
||||
pages: createPages(),
|
||||
permissions: { allowDynamicData: true },
|
||||
dataSources,
|
||||
resources,
|
||||
instances,
|
||||
}).size
|
||||
).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,12 @@
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
getPublishablePages,
|
||||
isAssetsResource,
|
||||
isPathnamePattern,
|
||||
type DataSource,
|
||||
type Instance,
|
||||
type Pages,
|
||||
type Resources,
|
||||
} from "@webstudio-is/sdk";
|
||||
import { findPageAndSelectorByInstanceId } from "@webstudio-is/project-build/runtime";
|
||||
import type { ProjectSettings } from "@webstudio-is/project-build";
|
||||
@@ -27,12 +29,14 @@ export const getRestrictedFeatures = ({
|
||||
pages,
|
||||
projectSettings,
|
||||
dataSources,
|
||||
resources,
|
||||
instances,
|
||||
permissions,
|
||||
}: {
|
||||
pages: Pages | undefined;
|
||||
projectSettings?: ProjectSettings;
|
||||
dataSources: Map<string, DataSource>;
|
||||
resources: Resources;
|
||||
instances: Map<string, Instance>;
|
||||
permissions: RestrictedFeaturesPermissions;
|
||||
}) => {
|
||||
@@ -89,9 +93,15 @@ export const getRestrictedFeatures = ({
|
||||
if (publishablePageIds.has(navigate.pageId) === false) {
|
||||
continue;
|
||||
}
|
||||
features.set("Resource variable", {
|
||||
navigate,
|
||||
});
|
||||
const resource = resources.get(dataSource.resourceId);
|
||||
features.set(
|
||||
resource !== undefined && isAssetsResource(resource)
|
||||
? "Assets resource"
|
||||
: "Resource variable",
|
||||
{
|
||||
navigate,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { act } from "react-dom/test-utils";
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import {
|
||||
createAssetQueryJsonSchema,
|
||||
createAssetResourceOpenApi,
|
||||
} from "@webstudio-is/protocol/asset-resource-api";
|
||||
import { AssetQueryForm, __testing__ } from "./asset-query-form";
|
||||
|
||||
const { loadAssetQueryDefinition } = __testing__;
|
||||
|
||||
(
|
||||
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
let root: Root | undefined;
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root?.unmount());
|
||||
root = undefined;
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
test("renders a centered message while the OpenAPI description is loading", () => {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root?.render(
|
||||
<AssetQueryForm
|
||||
scope={{}}
|
||||
aliases={new Map()}
|
||||
fetchDescription={() => new Promise(() => {})}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("Loading query editor…");
|
||||
});
|
||||
|
||||
test("loads the external query schema declared by OpenAPI", async () => {
|
||||
const openApi = createAssetResourceOpenApi({
|
||||
builderSessionCookieName: "session",
|
||||
querySchemaReference: "/rest/assets/query-schema.json",
|
||||
});
|
||||
const querySchema = createAssetQueryJsonSchema({
|
||||
catalog: {
|
||||
format: "webstudio-builder-asset-field-catalog",
|
||||
version: 1,
|
||||
canonicalRevision: `sha256:${"a".repeat(64)}`,
|
||||
documentCount: 1,
|
||||
fields: {
|
||||
title: {
|
||||
queryPath: ["properties", "title"],
|
||||
types: ["string"],
|
||||
occurrences: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const fetchDescription = vi.fn(async (input: RequestInfo | URL) =>
|
||||
Response.json(
|
||||
String(input).includes("query-schema.json") ? querySchema : openApi
|
||||
)
|
||||
);
|
||||
|
||||
const definition = await loadAssetQueryDefinition(fetchDescription);
|
||||
|
||||
expect(fetchDescription).toHaveBeenCalledTimes(2);
|
||||
expect(fetchDescription.mock.calls[1][0]).toBe(
|
||||
"http://localhost:3000/rest/assets/query-schema.json"
|
||||
);
|
||||
expect(definition.fields).toContainEqual(
|
||||
expect.objectContaining({ path: ["properties", "title"] })
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,210 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useStore } from "@nanostores/react";
|
||||
import {
|
||||
createDefaultStructuredAssetQueryResourceConfiguration,
|
||||
createStructuredAssetQueryResourceBody,
|
||||
parseStructuredAssetQueryResourceBody,
|
||||
type Resource,
|
||||
type StructuredAssetQueryFilterBinding,
|
||||
type StructuredAssetQueryResourceConfiguration,
|
||||
} from "@webstudio-is/sdk";
|
||||
import { assetsOpenApiUrl } from "@webstudio-is/sdk/runtime";
|
||||
import {
|
||||
addConfiguredQueryFields,
|
||||
createQuerySourceCodec,
|
||||
loadOpenApiQueryDefinition,
|
||||
type QueryDefinition,
|
||||
} from "@webstudio-is/query-builder";
|
||||
import {
|
||||
getQueryConditions,
|
||||
getQueryFieldKey,
|
||||
} from "@webstudio-is/query-builder/runtime";
|
||||
import { Text, theme } from "@webstudio-is/design-system";
|
||||
import { $assets } from "~/shared/sync/data-stores";
|
||||
import { BindableQueryBuilder } from "~/builder/shared/query-builder";
|
||||
import { fetch as builderFetch } from "~/shared/fetch.client";
|
||||
import { CenteredPanelMessage, Row } from "./shared";
|
||||
|
||||
type AssetQueryDefinition = QueryDefinition<
|
||||
string,
|
||||
StructuredAssetQueryFilterBinding["operator"]
|
||||
>;
|
||||
|
||||
const defaultConfiguration: StructuredAssetQueryResourceConfiguration =
|
||||
createDefaultStructuredAssetQueryResourceConfiguration();
|
||||
|
||||
const normalizeConfiguration = (
|
||||
value: StructuredAssetQueryResourceConfiguration
|
||||
): StructuredAssetQueryResourceConfiguration => ({
|
||||
...value,
|
||||
where: "field" in value.where ? { all: [value.where] } : value.where,
|
||||
});
|
||||
|
||||
const loadAssetQueryDefinition = async (
|
||||
fetchDescription: typeof globalThis.fetch
|
||||
) => {
|
||||
const response = await fetchDescription(assetsOpenApiUrl);
|
||||
if (response.ok === false) {
|
||||
throw new Error("Builder Assets OpenAPI request failed");
|
||||
}
|
||||
const descriptionUrl =
|
||||
response.url || new URL(assetsOpenApiUrl, window.location.href).href;
|
||||
const descriptionOrigin = new URL(descriptionUrl).origin;
|
||||
const definition = await loadOpenApiQueryDefinition({
|
||||
document: await response.json(),
|
||||
documentUrl: descriptionUrl,
|
||||
operationId: "queryAssets",
|
||||
loadReference: async (url) => {
|
||||
if (new URL(url).origin !== descriptionOrigin) {
|
||||
throw new Error("Cross-origin OpenAPI references are unsupported");
|
||||
}
|
||||
const reference = await fetchDescription(url);
|
||||
if (reference.ok === false) {
|
||||
throw new Error("Builder Assets query schema request failed");
|
||||
}
|
||||
return await reference.json();
|
||||
},
|
||||
});
|
||||
return definition as AssetQueryDefinition;
|
||||
};
|
||||
|
||||
export const AssetQueryForm = ({
|
||||
resource,
|
||||
scope,
|
||||
aliases,
|
||||
sourceContainer,
|
||||
fetchDescription = builderFetch,
|
||||
}: {
|
||||
resource?: Resource;
|
||||
scope: Record<string, unknown>;
|
||||
aliases: Map<string, string>;
|
||||
sourceContainer?: Element | null;
|
||||
fetchDescription?: typeof globalThis.fetch;
|
||||
}) => {
|
||||
const assets = useStore($assets);
|
||||
const initial = useMemo(
|
||||
() =>
|
||||
parseStructuredAssetQueryResourceBody(resource?.body) ??
|
||||
defaultConfiguration,
|
||||
[resource?.body]
|
||||
);
|
||||
const [configuration, setConfiguration] =
|
||||
useState<StructuredAssetQueryResourceConfiguration>(() =>
|
||||
normalizeConfiguration(initial)
|
||||
);
|
||||
const [baseDefinition, setBaseDefinition] = useState<AssetQueryDefinition>();
|
||||
const [descriptionError, setDescriptionError] = useState<string>();
|
||||
const configuredPaths = useMemo(
|
||||
() =>
|
||||
[
|
||||
...getQueryConditions<StructuredAssetQueryFilterBinding>(
|
||||
configuration.where
|
||||
).map(({ field }) => field),
|
||||
...configuration.sort.map(({ field }) => field),
|
||||
...(configuration.output.mode === "fields"
|
||||
? configuration.output.fields
|
||||
: []),
|
||||
].sort((left, right) =>
|
||||
getQueryFieldKey(left).localeCompare(getQueryFieldKey(right))
|
||||
),
|
||||
[configuration]
|
||||
);
|
||||
const definition = useMemo(
|
||||
() =>
|
||||
baseDefinition === undefined
|
||||
? undefined
|
||||
: addConfiguredQueryFields({
|
||||
definition: baseDefinition,
|
||||
paths: configuredPaths,
|
||||
fallbackType: "string",
|
||||
}),
|
||||
[baseDefinition, configuredPaths]
|
||||
);
|
||||
const configurationError = useMemo(() => {
|
||||
if (definition === undefined) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
createQuerySourceCodec<
|
||||
string,
|
||||
StructuredAssetQueryFilterBinding["operator"],
|
||||
StructuredAssetQueryResourceConfiguration
|
||||
>(definition).format(configuration);
|
||||
} catch {
|
||||
return "Complete every Assets query field.";
|
||||
}
|
||||
}, [configuration, definition]);
|
||||
|
||||
useEffect(() => {
|
||||
setConfiguration(normalizeConfiguration(initial));
|
||||
}, [initial, resource?.id]);
|
||||
|
||||
const body =
|
||||
configurationError === undefined
|
||||
? createStructuredAssetQueryResourceBody(configuration)
|
||||
: (resource?.body ?? "");
|
||||
|
||||
useEffect(() => {
|
||||
let ignore = false;
|
||||
loadAssetQueryDefinition(fetchDescription)
|
||||
.then((definition) => {
|
||||
if (ignore) {
|
||||
return;
|
||||
}
|
||||
setBaseDefinition(definition);
|
||||
setDescriptionError(undefined);
|
||||
})
|
||||
.catch(() => {
|
||||
if (ignore === false) {
|
||||
setDescriptionError("Unable to load the Assets API description.");
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
ignore = true;
|
||||
};
|
||||
}, [assets, fetchDescription]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
type="hidden"
|
||||
name="asset-query-valid"
|
||||
value={configurationError === undefined ? "true" : "false"}
|
||||
/>
|
||||
<input type="hidden" name="header-name" value="Content-Type" />
|
||||
<input type="hidden" name="header-value" value='"application/json"' />
|
||||
<input type="hidden" name="body" value={body} />
|
||||
{descriptionError !== undefined ? (
|
||||
<CenteredPanelMessage color="destructive">
|
||||
{descriptionError}
|
||||
</CenteredPanelMessage>
|
||||
) : definition !== undefined ? (
|
||||
<BindableQueryBuilder<
|
||||
string,
|
||||
StructuredAssetQueryFilterBinding["operator"],
|
||||
StructuredAssetQueryResourceConfiguration
|
||||
>
|
||||
key={resource?.id}
|
||||
value={configuration}
|
||||
capabilities={definition}
|
||||
scope={scope}
|
||||
aliases={aliases}
|
||||
sourceContainer={sourceContainer}
|
||||
sectionPaddingInline={theme.panel.paddingInline}
|
||||
onChange={(value) => {
|
||||
setConfiguration(value);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<CenteredPanelMessage>Loading query editor…</CenteredPanelMessage>
|
||||
)}
|
||||
{descriptionError === undefined && configurationError !== undefined && (
|
||||
<Row>
|
||||
<Text color="destructive">{configurationError}</Text>
|
||||
</Row>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const __testing__ = { loadAssetQueryDefinition };
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { isDatabaseSizeNearLimit } from "./content-database-diagnostics";
|
||||
|
||||
describe("content database diagnostics", () => {
|
||||
test("warns when the database reaches 90% of its limit", () => {
|
||||
expect(isDatabaseSizeNearLimit({ usedBytes: 899, maxBytes: 1_000 })).toBe(
|
||||
false
|
||||
);
|
||||
expect(isDatabaseSizeNearLimit({ usedBytes: 900, maxBytes: 1_000 })).toBe(
|
||||
true
|
||||
);
|
||||
expect(isDatabaseSizeNearLimit({ usedBytes: 1_000, maxBytes: 1_000 })).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { AssetQueryPreviewDiagnostics } from "@webstudio-is/content-engine";
|
||||
import { PanelBanner, Text } from "@webstudio-is/design-system";
|
||||
import prettyBytes from "pretty-bytes";
|
||||
import {
|
||||
RequestDiagnosticsContent,
|
||||
RequestDiagnosticsRow,
|
||||
RequestDiagnosticsTable,
|
||||
} from "./request-inspector";
|
||||
|
||||
const databaseSizeWarningRatio = 0.9;
|
||||
|
||||
export const isDatabaseSizeNearLimit = ({
|
||||
usedBytes,
|
||||
maxBytes,
|
||||
}: Pick<AssetQueryPreviewDiagnostics, "usedBytes" | "maxBytes">) =>
|
||||
usedBytes >= maxBytes * databaseSizeWarningRatio;
|
||||
|
||||
export const ContentDatabaseDiagnostics = ({
|
||||
value,
|
||||
}: {
|
||||
value: AssetQueryPreviewDiagnostics;
|
||||
}) => {
|
||||
const totalDocumentCount =
|
||||
value.includedDocumentCount + value.omittedDocumentCount;
|
||||
const candidateFilesLabel = `${totalDocumentCount} candidate ${totalDocumentCount === 1 ? "file" : "files"}`;
|
||||
const omittedFilesLabel = `${value.omittedDocumentCount} ${value.omittedDocumentCount === 1 ? "file" : "files"}`;
|
||||
const isNearLimit = isDatabaseSizeNearLimit(value);
|
||||
return (
|
||||
<RequestDiagnosticsContent>
|
||||
<PanelBanner variant={value.truncated ? "warning" : "success"}>
|
||||
<Text>
|
||||
{value.truncated
|
||||
? `${value.includedDocumentCount} of ${candidateFilesLabel} fit in the content database. ${omittedFilesLabel} may be omitted from published query results.`
|
||||
: totalDocumentCount === 1
|
||||
? "The candidate file fits in the content database."
|
||||
: `All ${candidateFilesLabel} fit in the content database.`}
|
||||
</Text>
|
||||
</PanelBanner>
|
||||
<RequestDiagnosticsTable>
|
||||
<RequestDiagnosticsRow label="Scope" value="Query preview" />
|
||||
<RequestDiagnosticsRow
|
||||
label="Status"
|
||||
value={value.truncated ? "Truncated" : "Complete"}
|
||||
/>
|
||||
<RequestDiagnosticsRow
|
||||
label="Database size"
|
||||
value={prettyBytes(value.usedBytes)}
|
||||
valueColor={isNearLimit ? "destructive" : undefined}
|
||||
description="To reduce the database size, select only the output fields your pages use, narrow filters to fewer files, and avoid full file content when a byte range is enough."
|
||||
/>
|
||||
<RequestDiagnosticsRow
|
||||
label="Database limit"
|
||||
value={prettyBytes(value.maxBytes)}
|
||||
/>
|
||||
<RequestDiagnosticsRow
|
||||
label="Estimated full size"
|
||||
value={prettyBytes(value.unboundedBytes)}
|
||||
/>
|
||||
<RequestDiagnosticsRow
|
||||
label="Included files"
|
||||
value={value.includedDocumentCount}
|
||||
/>
|
||||
<RequestDiagnosticsRow
|
||||
label="Omitted files"
|
||||
value={value.omittedDocumentCount}
|
||||
/>
|
||||
</RequestDiagnosticsTable>
|
||||
</RequestDiagnosticsContent>
|
||||
);
|
||||
};
|
||||
@@ -1,18 +1,9 @@
|
||||
import { useStore } from "@nanostores/react";
|
||||
import { Grid, Switch, theme } from "@webstudio-is/design-system";
|
||||
import {
|
||||
BindingControl,
|
||||
BindingPopover,
|
||||
} from "~/builder/shared/binding-popover";
|
||||
import { BindableExpressionControl } from "~/builder/shared/bindable-expression";
|
||||
import { validatePrimitiveValue } from "@webstudio-is/project-build/runtime";
|
||||
import {
|
||||
type ControlProps,
|
||||
$selectedInstanceScope,
|
||||
updateExpressionValue,
|
||||
useBindingState,
|
||||
humanizeAttribute,
|
||||
} from "../shared";
|
||||
import { type ControlProps, humanizeAttribute } from "../shared";
|
||||
import { PropertyLabel } from "../property-label";
|
||||
import { useBindableControl } from "./use-bindable-control";
|
||||
|
||||
export const BooleanControl = ({
|
||||
meta,
|
||||
@@ -22,12 +13,10 @@ export const BooleanControl = ({
|
||||
onChange,
|
||||
}: ControlProps<"boolean">) => {
|
||||
const label = humanizeAttribute(meta.label || propName);
|
||||
const { scope, aliases } = useStore($selectedInstanceScope);
|
||||
const expression =
|
||||
prop?.type === "expression" ? prop.value : JSON.stringify(computedValue);
|
||||
const { overwritable, variant } = useBindingState(
|
||||
prop?.type === "expression" ? prop.value : undefined
|
||||
);
|
||||
const binding = useBindableControl({
|
||||
boundExpression: prop?.type === "expression" ? prop.value : undefined,
|
||||
fallbackExpression: JSON.stringify(computedValue),
|
||||
});
|
||||
|
||||
return (
|
||||
<Grid
|
||||
@@ -39,33 +28,27 @@ export const BooleanControl = ({
|
||||
align="center"
|
||||
gap="2"
|
||||
>
|
||||
<PropertyLabel name={propName} readOnly={overwritable === false} />
|
||||
<BindingControl>
|
||||
<Switch
|
||||
disabled={overwritable === false}
|
||||
checked={Boolean(computedValue ?? false)}
|
||||
onCheckedChange={(value) => {
|
||||
if (prop?.type === "expression") {
|
||||
updateExpressionValue(prop.value, value);
|
||||
} else {
|
||||
onChange({ type: "boolean", value });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<BindingPopover
|
||||
scope={scope}
|
||||
aliases={aliases}
|
||||
validate={(value) => validatePrimitiveValue(value, label)}
|
||||
variant={variant}
|
||||
value={expression}
|
||||
onChange={(newExpression) =>
|
||||
onChange({ type: "expression", value: newExpression })
|
||||
}
|
||||
onRemove={(evaluatedValue) =>
|
||||
onChange({ type: "boolean", value: Boolean(evaluatedValue) })
|
||||
}
|
||||
/>
|
||||
</BindingControl>
|
||||
<PropertyLabel
|
||||
name={propName}
|
||||
readOnly={binding.bindingState.overwritable === false}
|
||||
/>
|
||||
<BindableExpressionControl
|
||||
{...binding}
|
||||
value={Boolean(computedValue ?? false)}
|
||||
validate={(value) => validatePrimitiveValue(value, label)}
|
||||
onChangeValue={(value) => onChange({ type: "boolean", value })}
|
||||
onChangeExpression={(value) => onChange({ type: "expression", value })}
|
||||
onRemove={(value) =>
|
||||
onChange({ type: "boolean", value: Boolean(value) })
|
||||
}
|
||||
renderControl={({ value, readOnly, onChangeValue }) => (
|
||||
<Switch
|
||||
disabled={readOnly}
|
||||
checked={value}
|
||||
onCheckedChange={onChangeValue}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
@@ -1,20 +1,14 @@
|
||||
import { useId } from "react";
|
||||
import { useStore } from "@nanostores/react";
|
||||
import { Checkbox, CheckboxAndLabel } from "@webstudio-is/design-system";
|
||||
import {
|
||||
BindingControl,
|
||||
BindingPopover,
|
||||
} from "~/builder/shared/binding-popover";
|
||||
import { BindableExpressionControl } from "~/builder/shared/bindable-expression";
|
||||
import {
|
||||
type ControlProps,
|
||||
VerticalLayout,
|
||||
Label,
|
||||
$selectedInstanceScope,
|
||||
updateExpressionValue,
|
||||
useBindingState,
|
||||
humanizeAttribute,
|
||||
} from "../shared";
|
||||
import { PropertyLabel } from "../property-label";
|
||||
import { useBindableControl } from "./use-bindable-control";
|
||||
|
||||
const add = (array: string[], item: string) => {
|
||||
if (array.includes(item)) {
|
||||
@@ -46,66 +40,61 @@ export const CheckControl = ({
|
||||
|
||||
const id = useId();
|
||||
const label = humanizeAttribute(meta.label || propName);
|
||||
const { scope, aliases } = useStore($selectedInstanceScope);
|
||||
const expression =
|
||||
prop?.type === "expression" ? prop.value : JSON.stringify(computedValue);
|
||||
const { overwritable, variant } = useBindingState(
|
||||
prop?.type === "expression" ? prop.value : undefined
|
||||
);
|
||||
const binding = useBindableControl({
|
||||
boundExpression: prop?.type === "expression" ? prop.value : undefined,
|
||||
fallbackExpression: JSON.stringify(computedValue),
|
||||
});
|
||||
|
||||
return (
|
||||
<VerticalLayout
|
||||
label={
|
||||
<PropertyLabel name={propName} readOnly={overwritable === false} />
|
||||
<PropertyLabel
|
||||
name={propName}
|
||||
readOnly={binding.bindingState.overwritable === false}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<BindingControl>
|
||||
{options.map((option) => (
|
||||
<CheckboxAndLabel key={option}>
|
||||
<Checkbox
|
||||
disabled={overwritable === false}
|
||||
checked={value.includes(option)}
|
||||
onCheckedChange={(checked) => {
|
||||
const newValue = checked
|
||||
? add(value, option)
|
||||
: remove(value, option);
|
||||
if (prop?.type === "expression") {
|
||||
updateExpressionValue(prop.value, newValue);
|
||||
} else {
|
||||
onChange({ type: "string[]", value: newValue });
|
||||
}
|
||||
}}
|
||||
id={`${id}:${option}`}
|
||||
/>
|
||||
<Label htmlFor={`${id}:${option}`}>{option}</Label>
|
||||
</CheckboxAndLabel>
|
||||
))}
|
||||
<BindingPopover
|
||||
scope={scope}
|
||||
aliases={aliases}
|
||||
validate={(value) => {
|
||||
const valid =
|
||||
Array.isArray(value) &&
|
||||
value.every((item) => typeof item === "string");
|
||||
if (value !== undefined && valid === false) {
|
||||
return `${label} expects an array of strings`;
|
||||
}
|
||||
}}
|
||||
variant={variant}
|
||||
value={expression}
|
||||
onChange={(newExpression) =>
|
||||
onChange({ type: "expression", value: newExpression })
|
||||
<BindableExpressionControl
|
||||
{...binding}
|
||||
value={value}
|
||||
onChangeValue={(value) => onChange({ type: "string[]", value })}
|
||||
onChangeExpression={(value) => onChange({ type: "expression", value })}
|
||||
validate={(value) => {
|
||||
const valid =
|
||||
Array.isArray(value) &&
|
||||
value.every((item) => typeof item === "string");
|
||||
if (value !== undefined && valid === false) {
|
||||
return `${label} expects an array of strings`;
|
||||
}
|
||||
onRemove={(evaluatedValue) =>
|
||||
onChange({
|
||||
type: "string[]",
|
||||
value: Array.isArray(evaluatedValue)
|
||||
? evaluatedValue.map((item) => String(item))
|
||||
: [],
|
||||
})
|
||||
}
|
||||
/>
|
||||
</BindingControl>
|
||||
}}
|
||||
onRemove={(value) =>
|
||||
onChange({
|
||||
type: "string[]",
|
||||
value: Array.isArray(value)
|
||||
? value.map((item) => String(item))
|
||||
: [],
|
||||
})
|
||||
}
|
||||
renderControl={({ value, readOnly, onChangeValue }) => (
|
||||
<>
|
||||
{options.map((option) => (
|
||||
<CheckboxAndLabel key={option}>
|
||||
<Checkbox
|
||||
disabled={readOnly}
|
||||
checked={value.includes(option)}
|
||||
onCheckedChange={(checked) =>
|
||||
onChangeValue(
|
||||
checked ? add(value, option) : remove(value, option)
|
||||
)
|
||||
}
|
||||
id={`${id}:${option}`}
|
||||
/>
|
||||
<Label htmlFor={`${id}:${option}`}>{option}</Label>
|
||||
</CheckboxAndLabel>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</VerticalLayout>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useStore } from "@nanostores/react";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
@@ -16,9 +15,9 @@ import {
|
||||
import { InfoCircleIcon } from "@webstudio-is/icons";
|
||||
import { CodeEditor } from "~/shared/code-editor";
|
||||
import {
|
||||
BindingControl,
|
||||
BindingPopover,
|
||||
} from "~/builder/shared/binding-popover";
|
||||
BindableExpressionControl,
|
||||
updateBindableValue,
|
||||
} from "~/builder/shared/bindable-expression";
|
||||
import {
|
||||
validateHtmlEmbedCode,
|
||||
type HtmlEmbedCodeError,
|
||||
@@ -28,12 +27,10 @@ import { useDraftValue } from "~/builder/shared/use-draft-value";
|
||||
import {
|
||||
type ControlProps,
|
||||
VerticalLayout,
|
||||
updateExpressionValue,
|
||||
$selectedInstanceScope,
|
||||
useBindingState,
|
||||
humanizeAttribute,
|
||||
} from "../shared";
|
||||
import { PropertyLabel } from "../property-label";
|
||||
import { useBindableControl } from "./use-bindable-control";
|
||||
export type CodeIssue = HtmlEmbedCodeError & {
|
||||
severity?: "error" | "warning";
|
||||
};
|
||||
@@ -141,21 +138,19 @@ export const CodeControl = ({
|
||||
}
|
||||
}
|
||||
|
||||
if (prop?.type === "expression") {
|
||||
updateExpressionValue(prop.value, storedValue);
|
||||
} else {
|
||||
onChange({ type: "string", value: storedValue });
|
||||
}
|
||||
updateBindableValue({
|
||||
expression: prop?.type === "expression" ? prop.value : undefined,
|
||||
value: storedValue,
|
||||
onChangeValue: (value) => onChange({ type: "string", value }),
|
||||
});
|
||||
},
|
||||
{ autoSave: behavior?.autoSave ?? true }
|
||||
);
|
||||
|
||||
const { scope, aliases } = useStore($selectedInstanceScope);
|
||||
const expression =
|
||||
prop?.type === "expression" ? prop.value : JSON.stringify(computedValue);
|
||||
const { overwritable, variant } = useBindingState(
|
||||
prop?.type === "expression" ? prop.value : undefined
|
||||
);
|
||||
const binding = useBindableControl({
|
||||
boundExpression: prop?.type === "expression" ? prop.value : undefined,
|
||||
fallbackExpression: JSON.stringify(computedValue),
|
||||
});
|
||||
|
||||
const errorInfo = (
|
||||
<ErrorInfo
|
||||
@@ -173,69 +168,69 @@ export const CodeControl = ({
|
||||
<VerticalLayout
|
||||
label={
|
||||
<Flex gap="1" align="center">
|
||||
<PropertyLabel name={propName} readOnly={overwritable === false} />
|
||||
<PropertyLabel
|
||||
name={propName}
|
||||
readOnly={binding.bindingState.overwritable === false}
|
||||
/>
|
||||
{errorInfo}
|
||||
</Flex>
|
||||
}
|
||||
>
|
||||
<BindingControl>
|
||||
<CodeEditor
|
||||
lang={lang}
|
||||
title={
|
||||
<DialogTitle
|
||||
maximizable
|
||||
suffix={
|
||||
<DialogTitleActions>
|
||||
<DialogMaximize />
|
||||
<DialogClose />
|
||||
</DialogTitleActions>
|
||||
}
|
||||
>
|
||||
<Flex gap="1" align="center">
|
||||
<Text variant="labels">Code editor</Text>
|
||||
{errorInfo}
|
||||
</Flex>
|
||||
</DialogTitle>
|
||||
}
|
||||
readOnly={overwritable === false}
|
||||
invalid={error !== undefined && error.severity !== "warning"}
|
||||
value={localValue.value}
|
||||
onChange={(value) => {
|
||||
setError(undefined);
|
||||
localValue.set(value);
|
||||
}}
|
||||
onChangeComplete={localValue.save}
|
||||
/>
|
||||
<BindingPopover
|
||||
scope={scope}
|
||||
aliases={aliases}
|
||||
validate={(value) =>
|
||||
behavior
|
||||
? behavior.validateBinding(value, label)
|
||||
: validatePrimitiveValue(value, label)
|
||||
}
|
||||
variant={variant}
|
||||
value={expression}
|
||||
onChange={(newExpression) =>
|
||||
onChange({ type: "expression", value: newExpression })
|
||||
}
|
||||
onRemove={(evaluatedValue) => {
|
||||
if (behavior) {
|
||||
const fixedValue = behavior.getFixedValue(evaluatedValue, label);
|
||||
if (fixedValue.success === false) {
|
||||
setError({
|
||||
message: fixedValue.message,
|
||||
value: String(evaluatedValue),
|
||||
});
|
||||
return;
|
||||
}
|
||||
onChange({ type: "string", value: fixedValue.value });
|
||||
<BindableExpressionControl
|
||||
{...binding}
|
||||
value={localValue.value}
|
||||
validate={(value) =>
|
||||
behavior
|
||||
? behavior.validateBinding(value, label)
|
||||
: validatePrimitiveValue(value, label)
|
||||
}
|
||||
onChangeValue={(value) => onChange({ type: "string", value })}
|
||||
onChangeExpression={(value) => onChange({ type: "expression", value })}
|
||||
onRemove={(value) => {
|
||||
if (behavior) {
|
||||
const fixedValue = behavior.getFixedValue(value, label);
|
||||
if (fixedValue.success === false) {
|
||||
setError({
|
||||
message: fixedValue.message,
|
||||
value: String(value),
|
||||
});
|
||||
return;
|
||||
}
|
||||
onChange({ type: "string", value: String(evaluatedValue) });
|
||||
}}
|
||||
/>
|
||||
</BindingControl>
|
||||
onChange({ type: "string", value: fixedValue.value });
|
||||
return;
|
||||
}
|
||||
onChange({ type: "string", value: String(value) });
|
||||
}}
|
||||
renderControl={({ readOnly }) => (
|
||||
<CodeEditor
|
||||
lang={lang}
|
||||
title={
|
||||
<DialogTitle
|
||||
maximizable
|
||||
suffix={
|
||||
<DialogTitleActions>
|
||||
<DialogMaximize />
|
||||
<DialogClose />
|
||||
</DialogTitleActions>
|
||||
}
|
||||
>
|
||||
<Flex gap="1" align="center">
|
||||
<Text variant="labels">Code editor</Text>
|
||||
{errorInfo}
|
||||
</Flex>
|
||||
</DialogTitle>
|
||||
}
|
||||
readOnly={readOnly}
|
||||
invalid={error !== undefined && error.severity !== "warning"}
|
||||
value={localValue.value}
|
||||
onChange={(value) => {
|
||||
setError(undefined);
|
||||
localValue.set(value);
|
||||
}}
|
||||
onChangeComplete={localValue.save}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</VerticalLayout>
|
||||
);
|
||||
};
|
||||
@@ -1,22 +1,19 @@
|
||||
import { useStore } from "@nanostores/react";
|
||||
import { useId } from "react";
|
||||
import { Flex, InputField, theme } from "@webstudio-is/design-system";
|
||||
import {
|
||||
BindingControl,
|
||||
BindingPopover,
|
||||
} from "~/builder/shared/binding-popover";
|
||||
BindableExpressionControl,
|
||||
updateBindableValue,
|
||||
} from "~/builder/shared/bindable-expression";
|
||||
import { validatePrimitiveValue } from "@webstudio-is/project-build/runtime";
|
||||
import { useDraftValue } from "~/builder/shared/use-draft-value";
|
||||
import {
|
||||
type ControlProps,
|
||||
VerticalLayout,
|
||||
updateExpressionValue,
|
||||
$selectedInstanceScope,
|
||||
useBindingState,
|
||||
humanizeAttribute,
|
||||
} from "../shared";
|
||||
import { SelectAsset } from "./select-asset";
|
||||
import { PropertyLabel } from "../property-label";
|
||||
import { useBindableControl } from "./use-bindable-control";
|
||||
|
||||
const UrlInput = ({
|
||||
id,
|
||||
@@ -61,45 +58,44 @@ export const FileControl = ({
|
||||
(value) => {
|
||||
if (value === undefined) {
|
||||
return;
|
||||
} else if (prop?.type === "expression") {
|
||||
updateExpressionValue(prop.value, value);
|
||||
} else {
|
||||
onChange({ type: "string", value });
|
||||
}
|
||||
updateBindableValue({
|
||||
expression: prop?.type === "expression" ? prop.value : undefined,
|
||||
value,
|
||||
onChangeValue: (value) => onChange({ type: "string", value }),
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
const label = humanizeAttribute(meta.label || propName);
|
||||
const { scope, aliases } = useStore($selectedInstanceScope);
|
||||
const expression =
|
||||
prop?.type === "expression" ? prop.value : JSON.stringify(computedValue);
|
||||
const { overwritable, variant } = useBindingState(
|
||||
prop?.type === "expression" ? prop.value : undefined
|
||||
);
|
||||
|
||||
const binding = useBindableControl({
|
||||
boundExpression: prop?.type === "expression" ? prop.value : undefined,
|
||||
fallbackExpression: JSON.stringify(computedValue),
|
||||
});
|
||||
return (
|
||||
<VerticalLayout label={<PropertyLabel name={propName} />}>
|
||||
<Flex css={{ gap: theme.spacing[3] }} direction="column" justify="center">
|
||||
<BindingControl>
|
||||
<UrlInput
|
||||
id={id}
|
||||
readOnly={overwritable === false}
|
||||
localValue={localStringValue}
|
||||
/>
|
||||
<BindingPopover
|
||||
scope={scope}
|
||||
aliases={aliases}
|
||||
validate={(value) => validatePrimitiveValue(value, label)}
|
||||
variant={variant}
|
||||
value={expression}
|
||||
onChange={(newExpression) =>
|
||||
onChange({ type: "expression", value: newExpression })
|
||||
}
|
||||
onRemove={(evaluatedValue) =>
|
||||
onChange({ type: "string", value: String(evaluatedValue) })
|
||||
}
|
||||
/>
|
||||
</BindingControl>
|
||||
<BindableExpressionControl
|
||||
{...binding}
|
||||
value={localStringValue.value}
|
||||
validate={(value) => validatePrimitiveValue(value, label)}
|
||||
onChangeValue={(value) =>
|
||||
onChange({ type: "string", value: value ?? "" })
|
||||
}
|
||||
onChangeExpression={(value) =>
|
||||
onChange({ type: "expression", value })
|
||||
}
|
||||
onRemove={(value) =>
|
||||
onChange({ type: "string", value: String(value) })
|
||||
}
|
||||
renderControl={({ readOnly }) => (
|
||||
<UrlInput
|
||||
id={id}
|
||||
readOnly={readOnly}
|
||||
localValue={localStringValue}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<SelectAsset
|
||||
prop={prop?.type === "asset" ? prop : undefined}
|
||||
accept={meta.accept}
|
||||
|
||||
@@ -1,23 +1,17 @@
|
||||
import { useState } from "react";
|
||||
import { useStore } from "@nanostores/react";
|
||||
import { isLiteralExpression } from "@webstudio-is/sdk";
|
||||
import { isLiteralExpression } from "@webstudio-is/expression";
|
||||
import { useDraftValue } from "~/builder/shared/use-draft-value";
|
||||
import {
|
||||
type ControlProps,
|
||||
VerticalLayout,
|
||||
updateExpressionValue,
|
||||
$selectedInstanceScope,
|
||||
useBindingState,
|
||||
} from "../shared";
|
||||
BindableExpressionControl,
|
||||
updateBindableValue,
|
||||
} from "~/builder/shared/bindable-expression";
|
||||
import { type ControlProps, VerticalLayout } from "../shared";
|
||||
import {
|
||||
ExpressionEditor,
|
||||
formatValue,
|
||||
} from "~/builder/shared/expression-editor";
|
||||
import {
|
||||
BindingControl,
|
||||
BindingPopover,
|
||||
} from "~/builder/shared/binding-popover";
|
||||
import { PropertyLabel } from "../property-label";
|
||||
import { useBindableControl } from "./use-bindable-control";
|
||||
|
||||
export const JsonControl = ({
|
||||
prop,
|
||||
@@ -37,49 +31,46 @@ export const JsonControl = ({
|
||||
try {
|
||||
// wrap into parens to treat object expression as value instead of block
|
||||
const parsedValue = eval(`(${value})`);
|
||||
if (prop?.type === "expression") {
|
||||
updateExpressionValue(prop.value, parsedValue);
|
||||
} else {
|
||||
onChange({ type: "json", value: parsedValue });
|
||||
}
|
||||
updateBindableValue({
|
||||
expression: prop?.type === "expression" ? prop.value : undefined,
|
||||
value: parsedValue,
|
||||
onChangeValue: (value) => onChange({ type: "json", value }),
|
||||
});
|
||||
} catch {
|
||||
// empty block
|
||||
}
|
||||
});
|
||||
|
||||
const { scope, aliases } = useStore($selectedInstanceScope);
|
||||
const expression = prop?.type === "expression" ? prop.value : valueString;
|
||||
const { overwritable, variant } = useBindingState(
|
||||
prop?.type === "expression" ? prop.value : undefined
|
||||
);
|
||||
const binding = useBindableControl({
|
||||
boundExpression: prop?.type === "expression" ? prop.value : undefined,
|
||||
fallbackExpression: valueString,
|
||||
});
|
||||
|
||||
return (
|
||||
<VerticalLayout
|
||||
label={
|
||||
<PropertyLabel name={propName} readOnly={overwritable === false} />
|
||||
<PropertyLabel
|
||||
name={propName}
|
||||
readOnly={binding.bindingState.overwritable === false}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<BindingControl>
|
||||
<ExpressionEditor
|
||||
color={error ? "error" : undefined}
|
||||
readOnly={overwritable === false}
|
||||
value={localValue.value}
|
||||
onChange={localValue.set}
|
||||
onChangeComplete={localValue.save}
|
||||
/>
|
||||
<BindingPopover
|
||||
scope={scope}
|
||||
aliases={aliases}
|
||||
variant={variant}
|
||||
value={expression}
|
||||
onChange={(newExpression) =>
|
||||
onChange({ type: "expression", value: newExpression })
|
||||
}
|
||||
onRemove={(evaluatedValue) =>
|
||||
onChange({ type: "json", value: evaluatedValue })
|
||||
}
|
||||
/>
|
||||
</BindingControl>
|
||||
<BindableExpressionControl
|
||||
{...binding}
|
||||
value={computedValue}
|
||||
onChangeValue={(value) => onChange({ type: "json", value })}
|
||||
onChangeExpression={(value) => onChange({ type: "expression", value })}
|
||||
onRemove={(value) => onChange({ type: "json", value })}
|
||||
renderControl={({ readOnly }) => (
|
||||
<ExpressionEditor
|
||||
color={error ? "error" : undefined}
|
||||
readOnly={readOnly}
|
||||
value={localValue.value}
|
||||
onChange={localValue.set}
|
||||
onChangeComplete={localValue.save}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</VerticalLayout>
|
||||
);
|
||||
};
|
||||
@@ -1,20 +1,17 @@
|
||||
import { useId, useState } from "react";
|
||||
import { useStore } from "@nanostores/react";
|
||||
import { InputField } from "@webstudio-is/design-system";
|
||||
import {
|
||||
BindingControl,
|
||||
BindingPopover,
|
||||
} from "~/builder/shared/binding-popover";
|
||||
import { useDraftValue } from "~/builder/shared/use-draft-value";
|
||||
import {
|
||||
BindableExpressionControl,
|
||||
updateBindableValue,
|
||||
} from "~/builder/shared/bindable-expression";
|
||||
import {
|
||||
type ControlProps,
|
||||
ResponsiveLayout,
|
||||
updateExpressionValue,
|
||||
$selectedInstanceScope,
|
||||
useBindingState,
|
||||
humanizeAttribute,
|
||||
} from "../shared";
|
||||
import { PropertyLabel } from "../property-label";
|
||||
import { useBindableControl } from "./use-bindable-control";
|
||||
|
||||
export const NumberControl = ({
|
||||
meta,
|
||||
@@ -31,11 +28,11 @@ export const NumberControl = ({
|
||||
Number.isNaN(number) ? "" : number,
|
||||
(value) => {
|
||||
if (typeof value === "number") {
|
||||
if (prop?.type === "expression") {
|
||||
updateExpressionValue(prop.value, value);
|
||||
} else {
|
||||
onChange({ type: "number", value });
|
||||
}
|
||||
updateBindableValue({
|
||||
expression: prop?.type === "expression" ? prop.value : undefined,
|
||||
value,
|
||||
onChangeValue: (value) => onChange({ type: "number", value }),
|
||||
});
|
||||
}
|
||||
if (value === "") {
|
||||
setIsInvalid(true);
|
||||
@@ -44,59 +41,63 @@ export const NumberControl = ({
|
||||
);
|
||||
|
||||
const label = humanizeAttribute(meta.label || propName);
|
||||
const { scope, aliases } = useStore($selectedInstanceScope);
|
||||
const expression =
|
||||
prop?.type === "expression" ? prop.value : JSON.stringify(computedValue);
|
||||
const { overwritable, variant } = useBindingState(
|
||||
prop?.type === "expression" ? prop.value : undefined
|
||||
);
|
||||
const binding = useBindableControl({
|
||||
boundExpression: prop?.type === "expression" ? prop.value : undefined,
|
||||
fallbackExpression: JSON.stringify(computedValue),
|
||||
});
|
||||
|
||||
return (
|
||||
<ResponsiveLayout
|
||||
label={
|
||||
<PropertyLabel name={propName} readOnly={overwritable === false} />
|
||||
<PropertyLabel
|
||||
name={propName}
|
||||
readOnly={binding.bindingState.overwritable === false}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<BindingControl>
|
||||
<InputField
|
||||
id={id}
|
||||
disabled={overwritable === false}
|
||||
type="number"
|
||||
value={localValue.value}
|
||||
color={isInvalid ? "error" : undefined}
|
||||
onChange={({ target: { valueAsNumber, value } }) => {
|
||||
localValue.set(Number.isNaN(valueAsNumber) ? value : valueAsNumber);
|
||||
setIsInvalid(false);
|
||||
}}
|
||||
onBlur={localValue.save}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
localValue.save();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<BindingPopover
|
||||
scope={scope}
|
||||
aliases={aliases}
|
||||
validate={(value) => {
|
||||
if (value !== undefined && typeof value !== "number") {
|
||||
return `${label} expects a number value`;
|
||||
}
|
||||
}}
|
||||
variant={variant}
|
||||
value={expression}
|
||||
onChange={(newExpression) =>
|
||||
onChange({ type: "expression", value: newExpression })
|
||||
<BindableExpressionControl
|
||||
{...binding}
|
||||
value={localValue.value}
|
||||
validate={(value) => {
|
||||
if (value !== undefined && typeof value !== "number") {
|
||||
return `${label} expects a number value`;
|
||||
}
|
||||
onRemove={(evaluatedValue) => {
|
||||
const number = Number(evaluatedValue);
|
||||
onChange({
|
||||
type: "number",
|
||||
value: Number.isNaN(number) ? 0 : number,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</BindingControl>
|
||||
}}
|
||||
onChangeValue={(value) => {
|
||||
if (typeof value === "number") {
|
||||
onChange({ type: "number", value });
|
||||
}
|
||||
}}
|
||||
onChangeExpression={(value) => onChange({ type: "expression", value })}
|
||||
onRemove={(value) => {
|
||||
const number = Number(value);
|
||||
onChange({
|
||||
type: "number",
|
||||
value: Number.isNaN(number) ? 0 : number,
|
||||
});
|
||||
}}
|
||||
renderControl={({ readOnly }) => (
|
||||
<InputField
|
||||
id={id}
|
||||
disabled={readOnly}
|
||||
type="number"
|
||||
value={localValue.value}
|
||||
color={isInvalid ? "error" : undefined}
|
||||
onChange={({ target: { valueAsNumber, value } }) => {
|
||||
localValue.set(
|
||||
Number.isNaN(valueAsNumber) ? value : valueAsNumber
|
||||
);
|
||||
setIsInvalid(false);
|
||||
}}
|
||||
onBlur={localValue.save}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
localValue.save();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</ResponsiveLayout>
|
||||
);
|
||||
};
|
||||
@@ -1,20 +1,9 @@
|
||||
import { useId } from "react";
|
||||
import { useStore } from "@nanostores/react";
|
||||
import { RadioGroup, Radio, RadioAndLabel } from "@webstudio-is/design-system";
|
||||
import {
|
||||
BindingControl,
|
||||
BindingPopover,
|
||||
} from "~/builder/shared/binding-popover";
|
||||
import {
|
||||
type ControlProps,
|
||||
VerticalLayout,
|
||||
Label,
|
||||
$selectedInstanceScope,
|
||||
updateExpressionValue,
|
||||
useBindingState,
|
||||
humanizeAttribute,
|
||||
} from "../shared";
|
||||
import { BindableExpressionControl } from "~/builder/shared/bindable-expression";
|
||||
import { type ControlProps, VerticalLayout, Label } from "../shared";
|
||||
import { PropertyLabel } from "../property-label";
|
||||
import { useStringOptions } from "./use-string-options";
|
||||
|
||||
export const RadioControl = ({
|
||||
meta,
|
||||
@@ -23,21 +12,14 @@ export const RadioControl = ({
|
||||
computedValue,
|
||||
onChange,
|
||||
}: ControlProps<"radio" | "inline-radio">) => {
|
||||
const value = computedValue === undefined ? undefined : String(computedValue);
|
||||
// making sure that the current value is in the list of options
|
||||
const options =
|
||||
value === undefined || meta.options.includes(value)
|
||||
? meta.options
|
||||
: [value, ...meta.options];
|
||||
|
||||
const id = useId();
|
||||
const label = humanizeAttribute(meta.label || propName);
|
||||
const { scope, aliases } = useStore($selectedInstanceScope);
|
||||
const expression =
|
||||
prop?.type === "expression" ? prop.value : JSON.stringify(computedValue);
|
||||
const { overwritable, variant } = useBindingState(
|
||||
prop?.type === "expression" ? prop.value : undefined
|
||||
);
|
||||
const { options, overwritable, binding } = useStringOptions({
|
||||
meta,
|
||||
prop,
|
||||
propName,
|
||||
computedValue,
|
||||
onChange,
|
||||
});
|
||||
|
||||
return (
|
||||
<VerticalLayout
|
||||
@@ -45,51 +27,24 @@ export const RadioControl = ({
|
||||
<PropertyLabel name={propName} readOnly={overwritable === false} />
|
||||
}
|
||||
>
|
||||
<BindingControl>
|
||||
<RadioGroup
|
||||
disabled={overwritable === false}
|
||||
name="value"
|
||||
value={value}
|
||||
onValueChange={(value) => {
|
||||
if (prop?.type === "expression") {
|
||||
updateExpressionValue(prop.value, value);
|
||||
} else {
|
||||
onChange({ type: "string", value });
|
||||
}
|
||||
}}
|
||||
>
|
||||
{options.map((value) => (
|
||||
<RadioAndLabel key={value}>
|
||||
<Radio value={value} id={`${id}:${value}`} />
|
||||
<Label htmlFor={`${id}:${value}`}>{value}</Label>
|
||||
</RadioAndLabel>
|
||||
))}
|
||||
</RadioGroup>
|
||||
<BindingPopover
|
||||
scope={scope}
|
||||
aliases={aliases}
|
||||
validate={(value) => {
|
||||
if (
|
||||
value !== undefined &&
|
||||
meta.options.includes(String(value)) === false
|
||||
) {
|
||||
const formatter = new Intl.ListFormat(undefined, {
|
||||
type: "disjunction",
|
||||
});
|
||||
const options = formatter.format(meta.options);
|
||||
return `${label} expects one of ${options}`;
|
||||
}
|
||||
}}
|
||||
variant={variant}
|
||||
value={expression}
|
||||
onChange={(newExpression) =>
|
||||
onChange({ type: "expression", value: newExpression })
|
||||
}
|
||||
onRemove={(evaluatedValue) =>
|
||||
onChange({ type: "string", value: String(evaluatedValue) })
|
||||
}
|
||||
/>
|
||||
</BindingControl>
|
||||
<BindableExpressionControl
|
||||
{...binding}
|
||||
renderControl={({ value, readOnly, onChangeValue }) => (
|
||||
<RadioGroup
|
||||
disabled={readOnly}
|
||||
name="value"
|
||||
value={value}
|
||||
onValueChange={onChangeValue}
|
||||
>
|
||||
{options.map((value) => (
|
||||
<RadioAndLabel key={value}>
|
||||
<Radio value={value} id={`${id}:${value}`} />
|
||||
<Label htmlFor={`${id}:${value}`}>{value}</Label>
|
||||
</RadioAndLabel>
|
||||
))}
|
||||
</RadioGroup>
|
||||
)}
|
||||
/>
|
||||
</VerticalLayout>
|
||||
);
|
||||
};
|
||||
@@ -17,12 +17,9 @@ import {
|
||||
NestedInputButton,
|
||||
theme,
|
||||
} from "@webstudio-is/design-system";
|
||||
import { isLiteralExpression, type Resource } from "@webstudio-is/sdk";
|
||||
import {
|
||||
BindingControl,
|
||||
BindingPopover,
|
||||
type BindingVariant,
|
||||
} from "~/builder/shared/binding-popover";
|
||||
import { isLiteralExpression } from "@webstudio-is/expression";
|
||||
import type { Resource } from "@webstudio-is/sdk";
|
||||
import { BindableExpressionControl } from "~/builder/shared/bindable-expression";
|
||||
import { validatePrimitiveValue } from "@webstudio-is/project-build/runtime";
|
||||
import { $variableValuesByInstanceSelector } from "~/shared/nano-states";
|
||||
import { $dataSources } from "~/shared/sync/data-stores";
|
||||
@@ -278,56 +275,54 @@ export const ResourceControl = ({
|
||||
};
|
||||
|
||||
const id = useId();
|
||||
let variant: BindingVariant = "bound";
|
||||
let readOnly = true;
|
||||
if (isLiteralExpression(urlExpression)) {
|
||||
variant = "default";
|
||||
readOnly = false;
|
||||
}
|
||||
const bound = isLiteralExpression(urlExpression) === false;
|
||||
const localValue = useDraftValue(
|
||||
String(computeExpression(resource.url, variableValues) ?? ""),
|
||||
(value) => updateResource({ ...resource, url: JSON.stringify(value) })
|
||||
);
|
||||
|
||||
return (
|
||||
<VerticalLayout
|
||||
label={<PropertyLabel name={propName} readOnly={readOnly} />}
|
||||
>
|
||||
<BindingControl>
|
||||
<InputField
|
||||
id={id}
|
||||
disabled={readOnly}
|
||||
value={localValue.value}
|
||||
onChange={(event) => localValue.set(event.target.value)}
|
||||
onBlur={localValue.save}
|
||||
onSubmit={localValue.save}
|
||||
suffix={
|
||||
isFeatureEnabled("resourceProp") && (
|
||||
<ResourceControlPanel
|
||||
resource={resource}
|
||||
propName={propName}
|
||||
onChange={updateResource}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<BindingPopover
|
||||
scope={scope}
|
||||
aliases={aliases}
|
||||
validate={(value) => validatePrimitiveValue(value, "URL")}
|
||||
variant={variant}
|
||||
value={urlExpression}
|
||||
onChange={(newExpression) =>
|
||||
updateResource({ ...resource, url: newExpression })
|
||||
}
|
||||
onRemove={(evaluatedValue) =>
|
||||
updateResource({
|
||||
...resource,
|
||||
url: JSON.stringify(String(evaluatedValue)),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</BindingControl>
|
||||
<VerticalLayout label={<PropertyLabel name={propName} readOnly={bound} />}>
|
||||
<BindableExpressionControl
|
||||
expression={urlExpression}
|
||||
value={localValue.value}
|
||||
bound={bound}
|
||||
allowBindingOverwrite={false}
|
||||
scope={scope}
|
||||
aliases={aliases}
|
||||
validate={(value) => validatePrimitiveValue(value, "URL")}
|
||||
onChangeValue={(value) =>
|
||||
updateResource({ ...resource, url: JSON.stringify(value) })
|
||||
}
|
||||
onChangeExpression={(value) =>
|
||||
updateResource({ ...resource, url: value })
|
||||
}
|
||||
onRemove={(value) =>
|
||||
updateResource({
|
||||
...resource,
|
||||
url: JSON.stringify(String(value)),
|
||||
})
|
||||
}
|
||||
renderControl={({ readOnly }) => (
|
||||
<InputField
|
||||
id={id}
|
||||
disabled={readOnly}
|
||||
value={localValue.value}
|
||||
onChange={(event) => localValue.set(event.target.value)}
|
||||
onBlur={localValue.save}
|
||||
onSubmit={localValue.save}
|
||||
suffix={
|
||||
isFeatureEnabled("resourceProp") && (
|
||||
<ResourceControlPanel
|
||||
resource={resource}
|
||||
propName={propName}
|
||||
onChange={updateResource}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</VerticalLayout>
|
||||
);
|
||||
};
|
||||
@@ -1,19 +1,9 @@
|
||||
import { useId } from "react";
|
||||
import { useStore } from "@nanostores/react";
|
||||
import { Select } from "@webstudio-is/design-system";
|
||||
import {
|
||||
BindingControl,
|
||||
BindingPopover,
|
||||
} from "~/builder/shared/binding-popover";
|
||||
import {
|
||||
type ControlProps,
|
||||
VerticalLayout,
|
||||
$selectedInstanceScope,
|
||||
updateExpressionValue,
|
||||
useBindingState,
|
||||
humanizeAttribute,
|
||||
} from "../shared";
|
||||
import { BindableExpressionControl } from "~/builder/shared/bindable-expression";
|
||||
import { type ControlProps, VerticalLayout } from "../shared";
|
||||
import { PropertyLabel } from "../property-label";
|
||||
import { useStringOptions } from "./use-string-options";
|
||||
|
||||
export const SelectControl = ({
|
||||
meta,
|
||||
@@ -24,20 +14,14 @@ export const SelectControl = ({
|
||||
}: ControlProps<"select">) => {
|
||||
const id = useId();
|
||||
|
||||
const value = computedValue === undefined ? undefined : String(computedValue);
|
||||
// making sure that the current value is in the list of options
|
||||
const options =
|
||||
value === undefined || value.length === 0 || meta.options.includes(value)
|
||||
? meta.options
|
||||
: [value, ...meta.options];
|
||||
|
||||
const label = humanizeAttribute(meta.label || propName);
|
||||
const { scope, aliases } = useStore($selectedInstanceScope);
|
||||
const expression =
|
||||
prop?.type === "expression" ? prop.value : JSON.stringify(computedValue);
|
||||
const { overwritable, variant } = useBindingState(
|
||||
prop?.type === "expression" ? prop.value : undefined
|
||||
);
|
||||
const { options, overwritable, binding } = useStringOptions({
|
||||
meta,
|
||||
prop,
|
||||
propName,
|
||||
computedValue,
|
||||
onChange,
|
||||
allowEmptyValue: true,
|
||||
});
|
||||
|
||||
return (
|
||||
<VerticalLayout
|
||||
@@ -45,46 +29,19 @@ export const SelectControl = ({
|
||||
<PropertyLabel name={propName} readOnly={overwritable === false} />
|
||||
}
|
||||
>
|
||||
<BindingControl>
|
||||
<Select
|
||||
fullWidth
|
||||
id={id}
|
||||
disabled={overwritable === false}
|
||||
value={value}
|
||||
options={options}
|
||||
onChange={(value) => {
|
||||
if (prop?.type === "expression") {
|
||||
updateExpressionValue(prop.value, value);
|
||||
} else {
|
||||
onChange({ type: "string", value });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<BindingPopover
|
||||
scope={scope}
|
||||
aliases={aliases}
|
||||
validate={(value) => {
|
||||
if (
|
||||
value !== undefined &&
|
||||
meta.options.includes(String(value)) === false
|
||||
) {
|
||||
const formatter = new Intl.ListFormat(undefined, {
|
||||
type: "disjunction",
|
||||
});
|
||||
const options = formatter.format(meta.options);
|
||||
return `${label} expects one of ${options}`;
|
||||
}
|
||||
}}
|
||||
variant={variant}
|
||||
value={expression}
|
||||
onChange={(newExpression) =>
|
||||
onChange({ type: "expression", value: newExpression })
|
||||
}
|
||||
onRemove={(evaluatedValue) =>
|
||||
onChange({ type: "string", value: String(evaluatedValue) })
|
||||
}
|
||||
/>
|
||||
</BindingControl>
|
||||
<BindableExpressionControl
|
||||
{...binding}
|
||||
renderControl={({ value, readOnly, onChangeValue }) => (
|
||||
<Select
|
||||
fullWidth
|
||||
id={id}
|
||||
disabled={readOnly}
|
||||
value={value}
|
||||
options={options}
|
||||
onChange={onChangeValue}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</VerticalLayout>
|
||||
);
|
||||
};
|
||||
@@ -13,22 +13,17 @@ import {
|
||||
import type { Instance } from "@webstudio-is/sdk";
|
||||
import { AlertIcon } from "@webstudio-is/icons";
|
||||
import { $instances } from "~/shared/sync/data-stores";
|
||||
import {
|
||||
BindingControl,
|
||||
BindingPopover,
|
||||
} from "~/builder/shared/binding-popover";
|
||||
import { validatePrimitiveValue } from "@webstudio-is/project-build/runtime";
|
||||
import { useDraftValue } from "~/builder/shared/use-draft-value";
|
||||
import {
|
||||
BindableExpressionControl,
|
||||
updateBindableValue,
|
||||
} from "~/builder/shared/bindable-expression";
|
||||
import { executeRuntimeMutation } from "~/shared/instance-utils/data";
|
||||
import { CodeEditor } from "~/shared/code-editor";
|
||||
import {
|
||||
type ControlProps,
|
||||
VerticalLayout,
|
||||
$selectedInstanceScope,
|
||||
updateExpressionValue,
|
||||
useBindingState,
|
||||
} from "../shared";
|
||||
import { type ControlProps, VerticalLayout } from "../shared";
|
||||
import { FieldLabel, useIsBindingResetForbidden } from "../property-label";
|
||||
import { useBindableControl } from "./use-bindable-control";
|
||||
|
||||
const useInstance = (instanceId: Instance["id"]) => {
|
||||
const $store = useMemo(() => {
|
||||
@@ -62,14 +57,13 @@ export const TextContent = ({
|
||||
// text content control is rendered only when empty or single child are present
|
||||
const child = instance?.children?.[0] ?? { type: "text", value: "" };
|
||||
const localValue = useDraftValue(String(computedValue ?? ""), (value) => {
|
||||
if (child.type === "expression") {
|
||||
updateExpressionValue(child.value, value);
|
||||
} else {
|
||||
updateChildren(instanceId, "text", value);
|
||||
}
|
||||
updateBindableValue({
|
||||
expression: child.type === "expression" ? child.value : undefined,
|
||||
value,
|
||||
onChangeValue: (value) => updateChildren(instanceId, "text", value),
|
||||
});
|
||||
});
|
||||
|
||||
const { scope, aliases } = useStore($selectedInstanceScope);
|
||||
let expression: undefined | string;
|
||||
if (child.type === "text") {
|
||||
expression = JSON.stringify(child.value);
|
||||
@@ -78,9 +72,10 @@ export const TextContent = ({
|
||||
expression = child.value;
|
||||
}
|
||||
|
||||
const { overwritable, variant } = useBindingState(
|
||||
child.type === "expression" ? child.value : undefined
|
||||
);
|
||||
const binding = useBindableControl({
|
||||
boundExpression: child.type === "expression" ? expression : undefined,
|
||||
fallbackExpression: expression ?? "",
|
||||
});
|
||||
const isBindingResetForbidden = useIsBindingResetForbidden();
|
||||
const isResetDisabled =
|
||||
child.type === "expression" && isBindingResetForbidden;
|
||||
@@ -93,7 +88,7 @@ export const TextContent = ({
|
||||
<>
|
||||
Plain text content that can be bound to either a variable or a
|
||||
resource value.
|
||||
{overwritable === false && (
|
||||
{binding.bindingState.overwritable === false && (
|
||||
<Flex gap="1">
|
||||
<AlertIcon
|
||||
color={rawTheme.colors.backgroundAlertMain}
|
||||
@@ -123,43 +118,39 @@ export const TextContent = ({
|
||||
</FieldLabel>
|
||||
}
|
||||
>
|
||||
<BindingControl>
|
||||
<CodeEditor
|
||||
title={
|
||||
<DialogTitle
|
||||
maximizable
|
||||
suffix={
|
||||
<DialogTitleActions>
|
||||
<DialogMaximize />
|
||||
<DialogClose />
|
||||
</DialogTitleActions>
|
||||
}
|
||||
>
|
||||
<Text variant="labels">Text content</Text>
|
||||
</DialogTitle>
|
||||
}
|
||||
size="small"
|
||||
readOnly={overwritable === false}
|
||||
value={localValue.value}
|
||||
onChange={localValue.set}
|
||||
onChangeComplete={localValue.save}
|
||||
/>
|
||||
{expression !== undefined && (
|
||||
<BindingPopover
|
||||
scope={scope}
|
||||
aliases={aliases}
|
||||
validate={(value) => validatePrimitiveValue(value, "Text Content")}
|
||||
variant={variant}
|
||||
value={expression}
|
||||
onChange={(newExpression) => {
|
||||
updateChildren(instanceId, "expression", newExpression);
|
||||
}}
|
||||
onRemove={(evaluatedValue) =>
|
||||
updateChildren(instanceId, "text", String(evaluatedValue))
|
||||
<BindableExpressionControl
|
||||
{...binding}
|
||||
value={localValue.value}
|
||||
showBinding={expression !== undefined}
|
||||
validate={(value) => validatePrimitiveValue(value, "Text Content")}
|
||||
onChangeValue={(value) => updateChildren(instanceId, "text", value)}
|
||||
onChangeExpression={(value) =>
|
||||
updateChildren(instanceId, "expression", value)
|
||||
}
|
||||
onRemove={(value) => updateChildren(instanceId, "text", String(value))}
|
||||
renderControl={({ readOnly }) => (
|
||||
<CodeEditor
|
||||
title={
|
||||
<DialogTitle
|
||||
maximizable
|
||||
suffix={
|
||||
<DialogTitleActions>
|
||||
<DialogMaximize />
|
||||
<DialogClose />
|
||||
</DialogTitleActions>
|
||||
}
|
||||
>
|
||||
<Text variant="labels">Text content</Text>
|
||||
</DialogTitle>
|
||||
}
|
||||
size="small"
|
||||
readOnly={readOnly}
|
||||
value={localValue.value}
|
||||
onChange={localValue.set}
|
||||
onChangeComplete={localValue.save}
|
||||
/>
|
||||
)}
|
||||
</BindingControl>
|
||||
/>
|
||||
</VerticalLayout>
|
||||
);
|
||||
};
|
||||
@@ -1,21 +1,18 @@
|
||||
import { useId } from "react";
|
||||
import { useStore } from "@nanostores/react";
|
||||
import { TextArea } from "@webstudio-is/design-system";
|
||||
import {
|
||||
BindingControl,
|
||||
BindingPopover,
|
||||
} from "~/builder/shared/binding-popover";
|
||||
import { validatePrimitiveValue } from "@webstudio-is/project-build/runtime";
|
||||
import { useDraftValue } from "~/builder/shared/use-draft-value";
|
||||
import {
|
||||
BindableExpressionControl,
|
||||
updateBindableValue,
|
||||
} from "~/builder/shared/bindable-expression";
|
||||
import {
|
||||
type ControlProps,
|
||||
ResponsiveLayout,
|
||||
updateExpressionValue,
|
||||
$selectedInstanceScope,
|
||||
useBindingState,
|
||||
humanizeAttribute,
|
||||
} from "../shared";
|
||||
import { PropertyLabel } from "../property-label";
|
||||
import { useBindableControl } from "./use-bindable-control";
|
||||
|
||||
export const TextControl = ({
|
||||
meta,
|
||||
@@ -25,56 +22,51 @@ export const TextControl = ({
|
||||
onChange,
|
||||
}: ControlProps<"text">) => {
|
||||
const localValue = useDraftValue(String(computedValue ?? ""), (value) => {
|
||||
if (prop?.type === "expression") {
|
||||
updateExpressionValue(prop.value, value);
|
||||
} else {
|
||||
onChange({ type: "string", value });
|
||||
}
|
||||
updateBindableValue({
|
||||
expression: prop?.type === "expression" ? prop.value : undefined,
|
||||
value,
|
||||
onChangeValue: (value) => onChange({ type: "string", value }),
|
||||
});
|
||||
});
|
||||
const id = useId();
|
||||
const label = humanizeAttribute(meta.label || propName);
|
||||
const { scope, aliases } = useStore($selectedInstanceScope);
|
||||
const expression =
|
||||
prop?.type === "expression" ? prop.value : JSON.stringify(computedValue);
|
||||
const { overwritable, variant } = useBindingState(
|
||||
prop?.type === "expression" ? prop.value : undefined
|
||||
);
|
||||
const binding = useBindableControl({
|
||||
boundExpression: prop?.type === "expression" ? prop.value : undefined,
|
||||
fallbackExpression: JSON.stringify(computedValue),
|
||||
});
|
||||
|
||||
const input = (
|
||||
<BindingControl>
|
||||
<TextArea
|
||||
id={id}
|
||||
disabled={overwritable === false}
|
||||
autoGrow
|
||||
value={localValue.value}
|
||||
rows={meta.rows ?? 1}
|
||||
// Set maxRows to 3 when meta.rows is undefined or equal to 1, otherwise set it to rows * 2
|
||||
maxRows={Math.max(2 * (meta.rows ?? 1), 3)}
|
||||
onChange={localValue.set}
|
||||
onBlur={localValue.save}
|
||||
onSubmit={localValue.save}
|
||||
/>
|
||||
|
||||
<BindingPopover
|
||||
scope={scope}
|
||||
aliases={aliases}
|
||||
validate={(value) => validatePrimitiveValue(value, label)}
|
||||
variant={variant}
|
||||
value={expression}
|
||||
onChange={(newExpression) =>
|
||||
onChange({ type: "expression", value: newExpression })
|
||||
}
|
||||
onRemove={(evaluatedValue) =>
|
||||
onChange({ type: "string", value: String(evaluatedValue) })
|
||||
}
|
||||
/>
|
||||
</BindingControl>
|
||||
<BindableExpressionControl
|
||||
{...binding}
|
||||
value={localValue.value}
|
||||
validate={(value) => validatePrimitiveValue(value, label)}
|
||||
onChangeValue={(value) => onChange({ type: "string", value })}
|
||||
onChangeExpression={(value) => onChange({ type: "expression", value })}
|
||||
onRemove={(value) => onChange({ type: "string", value: String(value) })}
|
||||
renderControl={({ readOnly }) => (
|
||||
<TextArea
|
||||
id={id}
|
||||
disabled={readOnly}
|
||||
autoGrow
|
||||
value={localValue.value}
|
||||
rows={meta.rows ?? 1}
|
||||
// Set maxRows to 3 when meta.rows is undefined or equal to 1, otherwise set it to rows * 2
|
||||
maxRows={Math.max(2 * (meta.rows ?? 1), 3)}
|
||||
onChange={localValue.set}
|
||||
onBlur={localValue.save}
|
||||
onSubmit={localValue.save}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<ResponsiveLayout
|
||||
label={
|
||||
<PropertyLabel name={propName} readOnly={overwritable === false} />
|
||||
<PropertyLabel
|
||||
name={propName}
|
||||
readOnly={binding.bindingState.overwritable === false}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{input}
|
||||
|
||||
@@ -2,22 +2,20 @@ import { useId } from "react";
|
||||
import { useStore } from "@nanostores/react";
|
||||
import { matchSorter } from "match-sorter";
|
||||
import { Box, Combobox, Text, theme } from "@webstudio-is/design-system";
|
||||
import {
|
||||
BindingControl,
|
||||
BindingPopover,
|
||||
} from "~/builder/shared/binding-popover";
|
||||
import { validatePrimitiveValue } from "@webstudio-is/project-build/runtime";
|
||||
import { useDraftValue } from "~/builder/shared/use-draft-value";
|
||||
import {
|
||||
BindableExpressionControl,
|
||||
updateBindableValue,
|
||||
} from "~/builder/shared/bindable-expression";
|
||||
import { $props } from "~/shared/sync/data-stores";
|
||||
import {
|
||||
type ControlProps,
|
||||
ResponsiveLayout,
|
||||
updateExpressionValue,
|
||||
$selectedInstanceScope,
|
||||
useBindingState,
|
||||
humanizeAttribute,
|
||||
} from "../shared";
|
||||
import { PropertyLabel } from "../property-label";
|
||||
import { useBindableControl } from "./use-bindable-control";
|
||||
|
||||
type TimeZoneItem = {
|
||||
value: string;
|
||||
@@ -198,22 +196,20 @@ export const TimeZoneControl = ({
|
||||
computedValue ?? meta.defaultValue ?? defaultTimeZone
|
||||
);
|
||||
const localValue = useDraftValue(savedValue, (value) => {
|
||||
if (prop?.type === "expression") {
|
||||
updateExpressionValue(prop.value, value);
|
||||
} else {
|
||||
onChange({ type: "string", value });
|
||||
}
|
||||
updateBindableValue({
|
||||
expression: prop?.type === "expression" ? prop.value : undefined,
|
||||
value,
|
||||
onChangeValue: (value) => onChange({ type: "string", value }),
|
||||
});
|
||||
});
|
||||
const timeZoneItems = getTimeZoneItems(meta.options);
|
||||
const selectedItem = getTimeZoneItem(savedValue);
|
||||
const currentItem = getTimeZoneItem(localValue.value);
|
||||
const label = humanizeAttribute(meta.label || propName);
|
||||
const { scope, aliases } = useStore($selectedInstanceScope);
|
||||
const expression =
|
||||
prop?.type === "expression" ? prop.value : JSON.stringify(computedValue);
|
||||
const { overwritable, variant } = useBindingState(
|
||||
prop?.type === "expression" ? prop.value : undefined
|
||||
);
|
||||
const binding = useBindableControl({
|
||||
boundExpression: prop?.type === "expression" ? prop.value : undefined,
|
||||
fallbackExpression: JSON.stringify(computedValue),
|
||||
});
|
||||
|
||||
const datetime = getStringProp(
|
||||
props,
|
||||
@@ -247,57 +243,55 @@ export const TimeZoneControl = ({
|
||||
return (
|
||||
<ResponsiveLayout
|
||||
label={
|
||||
<PropertyLabel name={propName} readOnly={overwritable === false} />
|
||||
<PropertyLabel
|
||||
name={propName}
|
||||
readOnly={binding.bindingState.overwritable === false}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<BindingControl>
|
||||
<Combobox<TimeZoneItem>
|
||||
name={id}
|
||||
disabled={overwritable === false}
|
||||
getItems={() => timeZoneItems}
|
||||
itemToString={itemToString}
|
||||
value={currentItem}
|
||||
selectedItem={selectedItem}
|
||||
match={matchOrSuggestTimeZone}
|
||||
onChange={(value) => {
|
||||
if (value !== undefined) {
|
||||
localValue.set(value.trim());
|
||||
}
|
||||
}}
|
||||
onItemSelect={(item) => {
|
||||
localValue.set(item.value.trim());
|
||||
localValue.flush();
|
||||
}}
|
||||
getDescription={(item) => {
|
||||
const preview = getTimeZonePreview({
|
||||
item,
|
||||
date,
|
||||
locale,
|
||||
dateStyle,
|
||||
timeStyle,
|
||||
format,
|
||||
});
|
||||
return (
|
||||
<Box css={{ width: theme.spacing[28] }}>
|
||||
<Text>{preview ?? "Select or type an IANA timezone."}</Text>
|
||||
</Box>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<BindingPopover
|
||||
scope={scope}
|
||||
aliases={aliases}
|
||||
validate={(value) => validatePrimitiveValue(value, label)}
|
||||
variant={variant}
|
||||
value={expression}
|
||||
onChange={(newExpression) =>
|
||||
onChange({ type: "expression", value: newExpression })
|
||||
}
|
||||
onRemove={(evaluatedValue) =>
|
||||
onChange({ type: "string", value: String(evaluatedValue) })
|
||||
}
|
||||
/>
|
||||
</BindingControl>
|
||||
<BindableExpressionControl
|
||||
{...binding}
|
||||
value={localValue.value}
|
||||
validate={(value) => validatePrimitiveValue(value, label)}
|
||||
onChangeValue={(value) => onChange({ type: "string", value })}
|
||||
onChangeExpression={(value) => onChange({ type: "expression", value })}
|
||||
onRemove={(value) => onChange({ type: "string", value: String(value) })}
|
||||
renderControl={({ readOnly }) => (
|
||||
<Combobox<TimeZoneItem>
|
||||
name={id}
|
||||
disabled={readOnly}
|
||||
getItems={() => timeZoneItems}
|
||||
itemToString={itemToString}
|
||||
value={currentItem}
|
||||
selectedItem={selectedItem}
|
||||
match={matchOrSuggestTimeZone}
|
||||
onChange={(value) => {
|
||||
if (value !== undefined) {
|
||||
localValue.set(value.trim());
|
||||
}
|
||||
}}
|
||||
onItemSelect={(item) => {
|
||||
localValue.set(item.value.trim());
|
||||
localValue.flush();
|
||||
}}
|
||||
getDescription={(item) => {
|
||||
const preview = getTimeZonePreview({
|
||||
item,
|
||||
date,
|
||||
locale,
|
||||
dateStyle,
|
||||
timeStyle,
|
||||
format,
|
||||
});
|
||||
return (
|
||||
<Box css={{ width: theme.spacing[28] }}>
|
||||
<Text>{preview ?? "Select or type an IANA timezone."}</Text>
|
||||
</Box>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</ResponsiveLayout>
|
||||
);
|
||||
};
|
||||
@@ -29,26 +29,24 @@ import {
|
||||
type Page,
|
||||
} from "@webstudio-is/sdk";
|
||||
import { $instances, $pages, $props } from "~/shared/sync/data-stores";
|
||||
import {
|
||||
BindingControl,
|
||||
BindingPopover,
|
||||
} from "~/builder/shared/binding-popover";
|
||||
import { validatePrimitiveValue } from "@webstudio-is/project-build/runtime";
|
||||
import { useDraftValue } from "~/builder/shared/use-draft-value";
|
||||
import {
|
||||
BindableExpressionControl,
|
||||
updateBindableValue,
|
||||
} from "~/builder/shared/bindable-expression";
|
||||
import { getPageDisplayName } from "~/builder/features/pages/page-utils";
|
||||
import {
|
||||
type ControlProps,
|
||||
VerticalLayout,
|
||||
Label,
|
||||
updateExpressionValue,
|
||||
$selectedInstanceScope,
|
||||
useBindingState,
|
||||
humanizeAttribute,
|
||||
type PropValue,
|
||||
} from "../shared";
|
||||
import { SelectAsset } from "./select-asset";
|
||||
import { createRootFolder } from "@webstudio-is/project-build";
|
||||
import { PropertyLabel } from "../property-label";
|
||||
import { useBindableControl } from "./use-bindable-control";
|
||||
|
||||
type UrlControlProps = ControlProps<"url">;
|
||||
|
||||
@@ -110,11 +108,11 @@ const addHttpsIfMissing = (url: string) => {
|
||||
|
||||
const BaseUrl = ({ readOnly, prop, value, onChange, id }: BaseControlProps) => {
|
||||
const localValue = useDraftValue(value, (value) => {
|
||||
if (prop?.type === "expression") {
|
||||
updateExpressionValue(prop.value, value);
|
||||
} else {
|
||||
onChange({ type: "string", value });
|
||||
}
|
||||
updateBindableValue({
|
||||
expression: prop?.type === "expression" ? prop.value : undefined,
|
||||
value,
|
||||
onChangeValue: (value) => onChange({ type: "string", value }),
|
||||
});
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -156,11 +154,12 @@ const BasePhone = ({
|
||||
const localValue = useDraftValue(
|
||||
value.startsWith("tel:") ? value.slice(4) : "",
|
||||
(value) => {
|
||||
if (prop?.type === "expression") {
|
||||
updateExpressionValue(prop.value, `tel:${value}`);
|
||||
} else {
|
||||
onChange({ type: "string", value: `tel:${value}` });
|
||||
}
|
||||
const nextValue = `tel:${value}`;
|
||||
updateBindableValue({
|
||||
expression: prop?.type === "expression" ? prop.value : undefined,
|
||||
value: nextValue,
|
||||
onChangeValue: (value) => onChange({ type: "string", value }),
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
@@ -234,11 +233,11 @@ const BaseEmail = ({
|
||||
}: BaseControlProps) => {
|
||||
const localValue = useDraftValue(propToEmail(value), ({ email, subject }) => {
|
||||
const value = emailToProp({ email, subject });
|
||||
if (prop?.type === "expression") {
|
||||
updateExpressionValue(prop.value, value);
|
||||
} else {
|
||||
onChange({ type: "string", value });
|
||||
}
|
||||
updateBindableValue({
|
||||
expression: prop?.type === "expression" ? prop.value : undefined,
|
||||
value,
|
||||
onChangeValue: (value) => onChange({ type: "string", value }),
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -483,14 +482,14 @@ export const UrlInput = ({
|
||||
value,
|
||||
readOnly = false,
|
||||
onChange,
|
||||
suffix,
|
||||
renderControl,
|
||||
}: {
|
||||
instanceId: string;
|
||||
prop: UrlInputProp;
|
||||
value: string;
|
||||
readOnly?: boolean;
|
||||
onChange: (value: UrlInputValue) => void;
|
||||
suffix?: ReactNode;
|
||||
renderControl?: (control: ReactNode) => ReactNode;
|
||||
}) => {
|
||||
const { value: mode, set: setMode } = useDraftValue<Mode>(
|
||||
propToMode(prop, value),
|
||||
@@ -537,14 +536,7 @@ export const UrlInput = ({
|
||||
))}
|
||||
</ToggleGroup>
|
||||
</Flex>
|
||||
{suffix === undefined ? (
|
||||
control
|
||||
) : (
|
||||
<BindingControl>
|
||||
{control}
|
||||
{suffix}
|
||||
</BindingControl>
|
||||
)}
|
||||
{renderControl?.(control) ?? control}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -559,12 +551,10 @@ export const UrlControl = ({
|
||||
}: UrlControlProps) => {
|
||||
const value = String(computedValue ?? "");
|
||||
const label = humanizeAttribute(meta.label || propName);
|
||||
const { scope, aliases } = useStore($selectedInstanceScope);
|
||||
const expression =
|
||||
prop?.type === "expression" ? prop.value : JSON.stringify(computedValue);
|
||||
const { overwritable, variant } = useBindingState(
|
||||
prop?.type === "expression" ? prop.value : undefined
|
||||
);
|
||||
const binding = useBindableControl({
|
||||
boundExpression: prop?.type === "expression" ? prop.value : undefined,
|
||||
fallbackExpression: JSON.stringify(computedValue),
|
||||
});
|
||||
|
||||
return (
|
||||
<VerticalLayout label={<PropertyLabel name={propName} />}>
|
||||
@@ -579,23 +569,23 @@ export const UrlControl = ({
|
||||
: undefined
|
||||
}
|
||||
value={value}
|
||||
readOnly={overwritable === false}
|
||||
readOnly={binding.bindingState.overwritable === false}
|
||||
onChange={onChange}
|
||||
suffix={
|
||||
<BindingPopover
|
||||
scope={scope}
|
||||
aliases={aliases}
|
||||
renderControl={(control) => (
|
||||
<BindableExpressionControl
|
||||
{...binding}
|
||||
value={value}
|
||||
validate={(value) => validatePrimitiveValue(value, label)}
|
||||
variant={variant}
|
||||
value={expression}
|
||||
onChange={(newExpression) =>
|
||||
onChange({ type: "expression", value: newExpression })
|
||||
onChangeValue={(value) => onChange({ type: "string", value })}
|
||||
onChangeExpression={(value) =>
|
||||
onChange({ type: "expression", value })
|
||||
}
|
||||
onRemove={(evaluatedValue) =>
|
||||
onChange({ type: "string", value: String(evaluatedValue) })
|
||||
onRemove={(value) =>
|
||||
onChange({ type: "string", value: String(value) })
|
||||
}
|
||||
renderControl={() => control}
|
||||
/>
|
||||
}
|
||||
)}
|
||||
/>
|
||||
</VerticalLayout>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { useStore } from "@nanostores/react";
|
||||
import { useBindingState } from "~/builder/shared/bindable-expression";
|
||||
import { $selectedInstanceScope } from "../shared";
|
||||
|
||||
export const useBindableControl = ({
|
||||
boundExpression,
|
||||
fallbackExpression,
|
||||
}: {
|
||||
boundExpression?: string;
|
||||
fallbackExpression: string;
|
||||
}) => {
|
||||
const { scope, aliases } = useStore($selectedInstanceScope);
|
||||
const bindingState = useBindingState(boundExpression);
|
||||
const bound = boundExpression !== undefined;
|
||||
const expression = boundExpression ?? fallbackExpression;
|
||||
return { expression, bound, scope, aliases, bindingState };
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
import { humanizeAttribute, type ControlProps } from "../shared";
|
||||
import { useBindableControl } from "./use-bindable-control";
|
||||
|
||||
export const useStringOptions = ({
|
||||
meta,
|
||||
prop,
|
||||
propName,
|
||||
computedValue,
|
||||
onChange,
|
||||
allowEmptyValue = false,
|
||||
}: Omit<ControlProps<"radio" | "inline-radio" | "select">, "instanceId"> & {
|
||||
allowEmptyValue?: boolean;
|
||||
}) => {
|
||||
const value = computedValue === undefined ? undefined : String(computedValue);
|
||||
const options =
|
||||
value === undefined ||
|
||||
(allowEmptyValue && value.length === 0) ||
|
||||
meta.options.includes(value)
|
||||
? meta.options
|
||||
: [value, ...meta.options];
|
||||
const label = humanizeAttribute(meta.label || propName);
|
||||
const binding = useBindableControl({
|
||||
boundExpression: prop?.type === "expression" ? prop.value : undefined,
|
||||
fallbackExpression: JSON.stringify(computedValue),
|
||||
});
|
||||
|
||||
return {
|
||||
value,
|
||||
options,
|
||||
overwritable: binding.bindingState.overwritable,
|
||||
binding: {
|
||||
...binding,
|
||||
value,
|
||||
validate: (value: unknown) => {
|
||||
if (
|
||||
value !== undefined &&
|
||||
meta.options.includes(String(value)) === false
|
||||
) {
|
||||
const formatter = new Intl.ListFormat(undefined, {
|
||||
type: "disjunction",
|
||||
});
|
||||
return `${label} expects one of ${formatter.format(meta.options)}`;
|
||||
}
|
||||
},
|
||||
onChangeValue: (value: string | undefined) =>
|
||||
onChange({ type: "string", value: value ?? "" }),
|
||||
onChangeExpression: (value: string) =>
|
||||
onChange({ type: "expression", value }),
|
||||
onRemove: (value: unknown) =>
|
||||
onChange({ type: "string", value: String(value) }),
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { getRequestErrorDiagnostics } from "./request-error-diagnostics";
|
||||
|
||||
describe("request error diagnostics", () => {
|
||||
test("extracts HTTP and structured API error information", () => {
|
||||
expect(
|
||||
getRequestErrorDiagnostics({
|
||||
ok: false,
|
||||
status: 400,
|
||||
statusText: "",
|
||||
data: {
|
||||
ok: false,
|
||||
error: {
|
||||
code: "CONTENT_NOT_TEXT",
|
||||
message: "Selected binary asset cannot be embedded as text",
|
||||
retryable: false,
|
||||
details: { assetId: "asset", mimeType: "image/png" },
|
||||
},
|
||||
},
|
||||
})
|
||||
).toEqual({
|
||||
status: 400,
|
||||
statusText: undefined,
|
||||
code: "CONTENT_NOT_TEXT",
|
||||
message: "Selected binary asset cannot be embedded as text",
|
||||
retryable: false,
|
||||
details: { assetId: "asset", mimeType: "image/png" },
|
||||
});
|
||||
});
|
||||
|
||||
test("describes transport failures without a structured error", () => {
|
||||
expect(
|
||||
getRequestErrorDiagnostics({ ok: false, status: 502, data: "failure" })
|
||||
).toMatchObject({ status: 502, message: "Request failed with status 502" });
|
||||
expect(
|
||||
getRequestErrorDiagnostics({ ok: true, status: 200, data: {} })
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { PanelBanner, Text } from "@webstudio-is/design-system";
|
||||
import {
|
||||
RequestDiagnosticsContent,
|
||||
RequestDiagnosticsRow,
|
||||
RequestDiagnosticsTable,
|
||||
} from "./request-inspector";
|
||||
|
||||
type RequestErrorDiagnosticsValue = {
|
||||
status?: number;
|
||||
statusText?: string;
|
||||
code?: string;
|
||||
message: string;
|
||||
retryable?: boolean;
|
||||
details?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const asRecord = (value: unknown): Record<string, unknown> | undefined =>
|
||||
typeof value === "object" && value !== null && Array.isArray(value) === false
|
||||
? (value as Record<string, unknown>)
|
||||
: undefined;
|
||||
|
||||
export const getRequestErrorDiagnostics = (
|
||||
value: unknown
|
||||
): RequestErrorDiagnosticsValue | undefined => {
|
||||
const response = asRecord(value);
|
||||
if (response === undefined) {
|
||||
return;
|
||||
}
|
||||
const status =
|
||||
typeof response.status === "number" ? response.status : undefined;
|
||||
const statusText =
|
||||
typeof response.statusText === "string" && response.statusText !== ""
|
||||
? response.statusText
|
||||
: undefined;
|
||||
const payload = asRecord(response.data);
|
||||
const error = asRecord(payload?.error);
|
||||
const failed =
|
||||
response.ok === false || (status !== undefined && status >= 400);
|
||||
if (error === undefined && failed === false) {
|
||||
return;
|
||||
}
|
||||
const code = typeof error?.code === "string" ? error.code : undefined;
|
||||
const details = asRecord(error?.details);
|
||||
const errorMessage =
|
||||
typeof error?.message === "string" && error.message !== ""
|
||||
? error.message
|
||||
: undefined;
|
||||
const message =
|
||||
errorMessage ??
|
||||
statusText ??
|
||||
(status === undefined
|
||||
? "Request failed"
|
||||
: `Request failed with status ${status}`);
|
||||
return {
|
||||
status,
|
||||
statusText,
|
||||
code,
|
||||
message,
|
||||
...(typeof error?.retryable === "boolean"
|
||||
? { retryable: error.retryable }
|
||||
: {}),
|
||||
...(details === undefined ? {} : { details }),
|
||||
};
|
||||
};
|
||||
|
||||
const formatDetail = (value: unknown) =>
|
||||
typeof value === "string" ? value : JSON.stringify(value);
|
||||
|
||||
export const RequestErrorDiagnostics = ({
|
||||
value,
|
||||
}: {
|
||||
value: RequestErrorDiagnosticsValue;
|
||||
}) => (
|
||||
<RequestDiagnosticsContent>
|
||||
<PanelBanner variant="error">
|
||||
<Text>{value.message}</Text>
|
||||
</PanelBanner>
|
||||
<RequestDiagnosticsTable>
|
||||
{value.status !== undefined && (
|
||||
<RequestDiagnosticsRow
|
||||
label="HTTP status"
|
||||
value={`${value.status}${value.statusText === undefined ? "" : ` ${value.statusText}`}`}
|
||||
/>
|
||||
)}
|
||||
{value.code !== undefined && (
|
||||
<RequestDiagnosticsRow label="Error code" value={value.code} />
|
||||
)}
|
||||
<RequestDiagnosticsRow label="Message" value={value.message} />
|
||||
{value.retryable !== undefined && (
|
||||
<RequestDiagnosticsRow
|
||||
label="Retryable"
|
||||
value={value.retryable ? "Yes" : "No"}
|
||||
/>
|
||||
)}
|
||||
{Object.entries(value.details ?? {}).map(([key, detail]) => (
|
||||
<RequestDiagnosticsRow
|
||||
key={key}
|
||||
label={`Details · ${key}`}
|
||||
value={formatDetail(detail)}
|
||||
/>
|
||||
))}
|
||||
</RequestDiagnosticsTable>
|
||||
</RequestDiagnosticsContent>
|
||||
);
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { act } from "react-dom/test-utils";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, expect, test } from "vitest";
|
||||
import { RequestInspector } from "./request-inspector";
|
||||
|
||||
(
|
||||
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
let root: Root | undefined;
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root?.unmount());
|
||||
root = undefined;
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
const renderInspector = (query: boolean) => {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
act(() => {
|
||||
root?.render(
|
||||
<RequestInspector
|
||||
queryContainerRef={query ? () => {} : undefined}
|
||||
preview={<div>Preview content</div>}
|
||||
diagnostics={<div>Diagnostics content</div>}
|
||||
/>
|
||||
);
|
||||
});
|
||||
return container;
|
||||
};
|
||||
|
||||
test("places an available query editor in the first selected tab", () => {
|
||||
const container = renderInspector(true);
|
||||
const tabs = Array.from(container.querySelectorAll('[role="tab"]'));
|
||||
|
||||
expect(tabs.map(({ textContent }) => textContent)).toEqual([
|
||||
"Query",
|
||||
"Preview",
|
||||
"Diagnostics",
|
||||
]);
|
||||
expect(tabs[0].getAttribute("data-state")).toBe("active");
|
||||
});
|
||||
|
||||
test("keeps preview first when no query editor is available", () => {
|
||||
const container = renderInspector(false);
|
||||
const tabs = Array.from(container.querySelectorAll('[role="tab"]'));
|
||||
|
||||
expect(tabs.map(({ textContent }) => textContent)).toEqual([
|
||||
"Preview",
|
||||
"Diagnostics",
|
||||
]);
|
||||
expect(tabs[0].getAttribute("data-state")).toBe("active");
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
import type { ComponentProps, ReactNode, Ref } from "react";
|
||||
import {
|
||||
Flex,
|
||||
Grid,
|
||||
PanelTabs,
|
||||
PanelTabsContent,
|
||||
PanelTabsList,
|
||||
PanelTabsTrigger,
|
||||
ScrollAreaNative,
|
||||
Text,
|
||||
Tooltip,
|
||||
theme,
|
||||
} from "@webstudio-is/design-system";
|
||||
import { InfoCircleIcon } from "@webstudio-is/icons";
|
||||
|
||||
export const RequestDiagnosticsContent = ({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}) => (
|
||||
<ScrollAreaNative css={{ height: "100%", overflow: "auto" }}>
|
||||
<Flex direction="column" gap={3} css={{ padding: theme.panel.padding }}>
|
||||
{children}
|
||||
</Flex>
|
||||
</ScrollAreaNative>
|
||||
);
|
||||
|
||||
export const RequestDiagnosticsTable = ({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}) => (
|
||||
<Grid
|
||||
css={{
|
||||
border: `1px solid ${theme.colors.borderMain}`,
|
||||
borderRadius: theme.borderRadius[4],
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Grid>
|
||||
);
|
||||
|
||||
export const RequestDiagnosticsRow = ({
|
||||
label,
|
||||
value,
|
||||
description,
|
||||
valueColor,
|
||||
}: {
|
||||
label: string;
|
||||
value: ReactNode;
|
||||
description?: ReactNode;
|
||||
valueColor?: ComponentProps<typeof Text>["color"];
|
||||
}) => (
|
||||
<Grid
|
||||
columns={2}
|
||||
gap={2}
|
||||
css={{
|
||||
padding: theme.spacing[5],
|
||||
borderBottom: `1px solid ${theme.colors.borderMain}`,
|
||||
"&:last-child": { borderBottom: 0 },
|
||||
}}
|
||||
>
|
||||
<Flex align="center" gap={1}>
|
||||
<Text color="moreSubtle">{label}</Text>
|
||||
{description !== undefined && (
|
||||
<Tooltip variant="wrapped" content={description}>
|
||||
<InfoCircleIcon tabIndex={0} />
|
||||
</Tooltip>
|
||||
)}
|
||||
</Flex>
|
||||
<Text
|
||||
color={valueColor}
|
||||
userSelect="text"
|
||||
css={{ fontVariantNumeric: "tabular-nums" }}
|
||||
>
|
||||
{value}
|
||||
</Text>
|
||||
</Grid>
|
||||
);
|
||||
|
||||
export const RequestInspector = ({
|
||||
queryContainerRef,
|
||||
preview,
|
||||
diagnostics,
|
||||
}: {
|
||||
queryContainerRef?: Ref<HTMLDivElement>;
|
||||
preview: ReactNode;
|
||||
diagnostics?: ReactNode;
|
||||
}) => (
|
||||
<PanelTabs
|
||||
key={queryContainerRef === undefined ? "preview" : "query"}
|
||||
defaultValue={queryContainerRef === undefined ? "preview" : "query"}
|
||||
css={{ height: "100%", minWidth: 0, overflow: "hidden" }}
|
||||
>
|
||||
<PanelTabsList
|
||||
aria-label="Request details"
|
||||
css={{
|
||||
flexShrink: 0,
|
||||
borderBottom: `1px solid ${theme.colors.borderMain}`,
|
||||
}}
|
||||
>
|
||||
{queryContainerRef !== undefined && (
|
||||
<PanelTabsTrigger value="query">Query</PanelTabsTrigger>
|
||||
)}
|
||||
<PanelTabsTrigger value="preview">Preview</PanelTabsTrigger>
|
||||
<PanelTabsTrigger value="diagnostics">Diagnostics</PanelTabsTrigger>
|
||||
</PanelTabsList>
|
||||
{queryContainerRef !== undefined && (
|
||||
<PanelTabsContent
|
||||
value="query"
|
||||
css={{ flex: 1, position: "relative", overflow: "hidden" }}
|
||||
>
|
||||
<div ref={queryContainerRef} style={{ height: "100%" }} />
|
||||
</PanelTabsContent>
|
||||
)}
|
||||
<PanelTabsContent
|
||||
value="preview"
|
||||
css={{ flex: 1, position: "relative", overflow: "hidden" }}
|
||||
>
|
||||
{preview}
|
||||
</PanelTabsContent>
|
||||
<PanelTabsContent
|
||||
value="diagnostics"
|
||||
css={{ flex: 1, position: "relative", overflow: "hidden" }}
|
||||
>
|
||||
{diagnostics ?? (
|
||||
<Flex align="center" justify="center" css={{ height: "100%" }}>
|
||||
<Text color="moreSubtle">No diagnostics available</Text>
|
||||
</Flex>
|
||||
)}
|
||||
</PanelTabsContent>
|
||||
</PanelTabs>
|
||||
);
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,11 +1,9 @@
|
||||
import { atom, computed, type ReadableAtom } from "nanostores";
|
||||
import { useStore } from "@nanostores/react";
|
||||
import { computed } from "nanostores";
|
||||
import {
|
||||
type ComponentPropsWithoutRef,
|
||||
type ReactNode,
|
||||
useRef,
|
||||
useState,
|
||||
useMemo,
|
||||
type ComponentProps,
|
||||
} from "react";
|
||||
import {
|
||||
@@ -20,7 +18,6 @@ import {
|
||||
} from "@webstudio-is/react-sdk";
|
||||
import { showAttributeMeta } from "@webstudio-is/project-build/runtime";
|
||||
import {
|
||||
decodeDataSourceVariable,
|
||||
encodeDataSourceVariable,
|
||||
SYSTEM_VARIABLE_ID,
|
||||
systemParameter,
|
||||
@@ -40,12 +37,10 @@ import {
|
||||
rawTheme,
|
||||
} from "@webstudio-is/design-system";
|
||||
import {
|
||||
$dataSourceVariables,
|
||||
$registeredComponentMetas,
|
||||
$variableValuesByInstanceSelector,
|
||||
} from "~/shared/nano-states";
|
||||
import { $dataSources } from "~/shared/sync/data-stores";
|
||||
import type { BindingVariant } from "~/builder/shared/binding-popover";
|
||||
import { humanizeString } from "~/shared/string-utils";
|
||||
import {
|
||||
$selectedInstance,
|
||||
@@ -243,6 +238,26 @@ export const Row = ({
|
||||
</Flex>
|
||||
);
|
||||
|
||||
export const CenteredPanelMessage = ({
|
||||
children,
|
||||
color = "subtle",
|
||||
}: {
|
||||
children: ReactNode;
|
||||
color?: ComponentProps<typeof Text>["color"];
|
||||
}) => (
|
||||
<Row
|
||||
css={{
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexGrow: 1,
|
||||
minHeight: 100,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<Text color={color}>{children}</Text>
|
||||
</Row>
|
||||
);
|
||||
|
||||
export const $selectedInstanceScope = computed(
|
||||
[
|
||||
$selectedInstanceKeyWithRoot,
|
||||
@@ -274,61 +289,6 @@ export const $selectedInstanceScope = computed(
|
||||
}
|
||||
);
|
||||
|
||||
export const updateExpressionValue = (expression: string, value: unknown) => {
|
||||
const dataSources = $dataSources.get();
|
||||
// when expression contains only reference to variable update that variable
|
||||
// extract id without parsing expression
|
||||
const potentialVariableId = decodeDataSourceVariable(expression);
|
||||
if (
|
||||
potentialVariableId !== undefined &&
|
||||
dataSources.has(potentialVariableId)
|
||||
) {
|
||||
const dataSourceId = potentialVariableId;
|
||||
const dataSourceVariables = new Map($dataSourceVariables.get());
|
||||
dataSourceVariables.set(dataSourceId, value);
|
||||
$dataSourceVariables.set(dataSourceVariables);
|
||||
}
|
||||
};
|
||||
|
||||
type BindingState = {
|
||||
overwritable: boolean;
|
||||
variant: BindingVariant;
|
||||
};
|
||||
|
||||
export const useBindingState = (expression: undefined | string) => {
|
||||
const $bindingState = useMemo((): ReadableAtom<BindingState> => {
|
||||
if (expression === undefined) {
|
||||
// value is not bound to expression and can be updated
|
||||
return atom({ overwritable: true, variant: "default" });
|
||||
}
|
||||
// try to extract variable id from expression
|
||||
const potentialVariableId = decodeDataSourceVariable(expression);
|
||||
if (potentialVariableId === undefined) {
|
||||
// expression is complex and cannot be updated
|
||||
return atom({ overwritable: false, variant: "bound" });
|
||||
}
|
||||
return computed(
|
||||
[$dataSources, $dataSourceVariables],
|
||||
(dataSources, dataSourceVariables): BindingState => {
|
||||
const dataSource = dataSources.get(potentialVariableId);
|
||||
// resources and parameters cannot be updated
|
||||
if (dataSource?.type !== "variable") {
|
||||
return { overwritable: false, variant: "bound" };
|
||||
}
|
||||
const variableId = potentialVariableId;
|
||||
return {
|
||||
overwritable: true,
|
||||
variant:
|
||||
dataSourceVariables.get(variableId) === undefined
|
||||
? "bound"
|
||||
: "overwritten",
|
||||
};
|
||||
}
|
||||
);
|
||||
}, [expression]);
|
||||
return useStore($bindingState);
|
||||
};
|
||||
|
||||
export const humanizeAttribute = (string: string) => {
|
||||
if (string.includes("-")) {
|
||||
return string;
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
ProChip,
|
||||
ScrollArea,
|
||||
Select,
|
||||
SplitView,
|
||||
Switch,
|
||||
Text,
|
||||
TextArea,
|
||||
@@ -72,11 +73,10 @@ import {
|
||||
createDataVariableValueFromInput,
|
||||
createResourceValueFromFormData,
|
||||
findUnsetVariableNames,
|
||||
parseDataVariableJsonExpression,
|
||||
validateDataVariableJsonValue,
|
||||
validateDataVariableNumberValue,
|
||||
validateDataVariableStringArrayValue,
|
||||
} from "@webstudio-is/project-build/runtime";
|
||||
import { parseJsonExpression } from "@webstudio-is/expression";
|
||||
import { validateDataVariableName } from "~/builder/shared/data-variable-utils";
|
||||
import {
|
||||
GraphqlResourceForm,
|
||||
@@ -87,11 +87,20 @@ import {
|
||||
import { generateCurl } from "./curl";
|
||||
import {
|
||||
$hasPendingResources,
|
||||
$resourceDiagnosticsCache,
|
||||
$resourcesCache,
|
||||
computeResourceRequest,
|
||||
getResourceKey,
|
||||
invalidateResource,
|
||||
} from "~/shared/resources";
|
||||
import { Row } from "./shared";
|
||||
import type { AssetQueryPreviewDiagnostics } from "@webstudio-is/content-engine";
|
||||
import { RequestInspector } from "./request-inspector";
|
||||
import { ContentDatabaseDiagnostics } from "./content-database-diagnostics";
|
||||
import {
|
||||
getRequestErrorDiagnostics,
|
||||
RequestErrorDiagnostics,
|
||||
} from "./request-error-diagnostics";
|
||||
|
||||
const NameField = ({
|
||||
variable,
|
||||
@@ -179,7 +188,6 @@ type VariableType =
|
||||
| "string"
|
||||
| "number"
|
||||
| "boolean"
|
||||
| "string[]"
|
||||
| "json"
|
||||
| "resource"
|
||||
| "graphql-resource"
|
||||
@@ -214,11 +222,6 @@ const TypeField = ({
|
||||
label: "Boolean",
|
||||
description: "A boolean is a true/false switch.",
|
||||
},
|
||||
{
|
||||
value: "string[]",
|
||||
label: "String Array",
|
||||
description: "A list of text values.",
|
||||
},
|
||||
{
|
||||
value: "json",
|
||||
label: "JSON",
|
||||
@@ -250,11 +253,11 @@ const TypeField = ({
|
||||
value: "system-resource",
|
||||
label: (
|
||||
<Flex direction="row" gap="2" align="center">
|
||||
System Resource
|
||||
System resource
|
||||
{allowDynamicData === false && <ProChip>Pro</ProChip>}
|
||||
</Flex>
|
||||
),
|
||||
description: "A System Resource is a configuration for Webstudio data.",
|
||||
description: "A system resource is a configuration for Webstudio data.",
|
||||
},
|
||||
];
|
||||
const options = new Map(optionsList.map((option) => [option.value, option]));
|
||||
@@ -282,7 +285,7 @@ const TypeField = ({
|
||||
};
|
||||
|
||||
type PanelApi = {
|
||||
save: (formData: FormData) => void;
|
||||
save: (formData: FormData) => void | false;
|
||||
};
|
||||
|
||||
const ParameterForm = forwardRef<
|
||||
@@ -312,7 +315,7 @@ ParameterForm.displayName = "ParameterForm";
|
||||
|
||||
type ValueVariableType = Extract<
|
||||
VariableType,
|
||||
"string" | "number" | "boolean" | "string[]" | "json"
|
||||
"string" | "number" | "boolean" | "json"
|
||||
>;
|
||||
|
||||
const saveVariable = (
|
||||
@@ -528,51 +531,6 @@ const JsonForm = forwardRef<
|
||||
});
|
||||
JsonForm.displayName = "JsonForm";
|
||||
|
||||
const StringArrayForm = forwardRef<
|
||||
undefined | PanelApi,
|
||||
{
|
||||
variable?: DataSource;
|
||||
value: unknown;
|
||||
onChange: (value: unknown) => void;
|
||||
}
|
||||
>(({ variable, value: unknownValue, onChange }, ref) => {
|
||||
const value = typeof unknownValue === "string" ? unknownValue : "[]";
|
||||
const [valueError, setValueError] = useState("");
|
||||
const valueRef = useRef<HTMLInputElement>(null);
|
||||
useEffect(() => {
|
||||
valueRef.current?.setCustomValidity(
|
||||
validateDataVariableStringArrayValue(value)
|
||||
);
|
||||
setValueError("");
|
||||
}, [value]);
|
||||
useValuePanelRef({ ref, variable, type: "string[]" });
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
ref={valueRef}
|
||||
style={{ display: "none" }}
|
||||
name="value"
|
||||
data-color={valueError ? "error" : undefined}
|
||||
value={value}
|
||||
onChange={() => {}}
|
||||
onInvalid={(event) =>
|
||||
setValueError(event.currentTarget.validationMessage)
|
||||
}
|
||||
/>
|
||||
<Flex direction="column" css={{ gap: theme.spacing[3] }}>
|
||||
<Label>Value</Label>
|
||||
<ExpressionEditor
|
||||
color={valueError ? "error" : undefined}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onChangeComplete={() => valueRef.current?.checkValidity()}
|
||||
/>
|
||||
</Flex>
|
||||
</>
|
||||
);
|
||||
});
|
||||
StringArrayForm.displayName = "StringArrayForm";
|
||||
|
||||
const VariablePanelForm = forwardRef<
|
||||
undefined | PanelApi,
|
||||
{
|
||||
@@ -581,10 +539,20 @@ const VariablePanelForm = forwardRef<
|
||||
onVariableTypeChange: (variableType: VariableType) => void;
|
||||
value: unknown;
|
||||
onValueChange: (value: unknown) => void;
|
||||
querySourceContainer: Element | null;
|
||||
onQueryActiveChange: (active: boolean) => void;
|
||||
}
|
||||
>(
|
||||
(
|
||||
{ variable, variableType, onVariableTypeChange, value, onValueChange },
|
||||
{
|
||||
variable,
|
||||
variableType,
|
||||
onVariableTypeChange,
|
||||
value,
|
||||
onValueChange,
|
||||
querySourceContainer,
|
||||
onQueryActiveChange,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const { allowDynamicData } = useStore($permissions);
|
||||
@@ -615,56 +583,63 @@ const VariablePanelForm = forwardRef<
|
||||
direction="column"
|
||||
css={{
|
||||
overflow: "hidden",
|
||||
padding: theme.panel.padding,
|
||||
paddingBlock: theme.panel.paddingBlock,
|
||||
gap: theme.spacing[7],
|
||||
}}
|
||||
>
|
||||
<NameField variable={variable} defaultValue={variable?.name ?? ""} />
|
||||
<Row>
|
||||
<NameField
|
||||
variable={variable}
|
||||
defaultValue={variable?.name ?? ""}
|
||||
/>
|
||||
</Row>
|
||||
{variableType !== "parameter" && (
|
||||
<TypeField value={variableType} onChange={onVariableTypeChange} />
|
||||
<Row>
|
||||
<TypeField value={variableType} onChange={onVariableTypeChange} />
|
||||
</Row>
|
||||
)}
|
||||
{variableType === "parameter" && (
|
||||
<ParameterForm ref={ref} variable={variable} />
|
||||
)}
|
||||
{variableType === "string" && (
|
||||
<StringForm
|
||||
ref={ref}
|
||||
variable={variable}
|
||||
value={value}
|
||||
onChange={onValueChange}
|
||||
/>
|
||||
<Row>
|
||||
<StringForm
|
||||
ref={ref}
|
||||
variable={variable}
|
||||
value={value}
|
||||
onChange={onValueChange}
|
||||
/>
|
||||
</Row>
|
||||
)}
|
||||
{variableType === "number" && (
|
||||
<NumberForm
|
||||
ref={ref}
|
||||
variable={variable}
|
||||
value={value}
|
||||
onChange={onValueChange}
|
||||
/>
|
||||
<Row>
|
||||
<NumberForm
|
||||
ref={ref}
|
||||
variable={variable}
|
||||
value={value}
|
||||
onChange={onValueChange}
|
||||
/>
|
||||
</Row>
|
||||
)}
|
||||
{variableType === "boolean" && (
|
||||
<BooleanForm
|
||||
ref={ref}
|
||||
variable={variable}
|
||||
value={value}
|
||||
onChange={onValueChange}
|
||||
/>
|
||||
<Row>
|
||||
<BooleanForm
|
||||
ref={ref}
|
||||
variable={variable}
|
||||
value={value}
|
||||
onChange={onValueChange}
|
||||
/>
|
||||
</Row>
|
||||
)}
|
||||
{variableType === "json" && (
|
||||
<JsonForm
|
||||
ref={ref}
|
||||
variable={variable}
|
||||
value={value}
|
||||
onChange={onValueChange}
|
||||
/>
|
||||
)}
|
||||
{variableType === "string[]" && (
|
||||
<StringArrayForm
|
||||
ref={ref}
|
||||
variable={variable}
|
||||
value={value}
|
||||
onChange={onValueChange}
|
||||
/>
|
||||
<Row>
|
||||
<JsonForm
|
||||
ref={ref}
|
||||
variable={variable}
|
||||
value={value}
|
||||
onChange={onValueChange}
|
||||
/>
|
||||
</Row>
|
||||
)}
|
||||
{variableType === "resource" && (
|
||||
<ResourceForm ref={ref} variable={variable} />
|
||||
@@ -673,7 +648,12 @@ const VariablePanelForm = forwardRef<
|
||||
<GraphqlResourceForm ref={ref} variable={variable} />
|
||||
)}
|
||||
{variableType === "system-resource" && (
|
||||
<SystemResourceForm ref={ref} variable={variable} />
|
||||
<SystemResourceForm
|
||||
ref={ref}
|
||||
variable={variable}
|
||||
querySourceContainer={querySourceContainer}
|
||||
onQueryActiveChange={onQueryActiveChange}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
</>
|
||||
@@ -694,11 +674,15 @@ const VariablePreview = ({
|
||||
variableType,
|
||||
variableValue,
|
||||
onLoadData,
|
||||
queryActive,
|
||||
queryContainerRef,
|
||||
}: {
|
||||
variable?: DataSource;
|
||||
variableType: VariableType;
|
||||
variableValue: unknown;
|
||||
onLoadData: () => void;
|
||||
queryActive: boolean;
|
||||
queryContainerRef: (element: HTMLDivElement | null) => void;
|
||||
}) => {
|
||||
const isResource =
|
||||
variableType === "resource" ||
|
||||
@@ -708,12 +692,14 @@ const VariablePreview = ({
|
||||
const resources = useStore($resources);
|
||||
const variableValues = useStore($instanceVariableValues);
|
||||
const resourcesCache = useStore($resourcesCache);
|
||||
const resourceDiagnosticsCache = useStore($resourceDiagnosticsCache);
|
||||
const resourceScope = useResourceScope({ variable });
|
||||
let computedValue: unknown;
|
||||
let resourceDiagnostics: AssetQueryPreviewDiagnostics | undefined;
|
||||
if (variableType === "string" || variableType === "boolean") {
|
||||
computedValue = variableValue;
|
||||
} else if (variableType === "json") {
|
||||
computedValue = parseDataVariableJsonExpression(String(variableValue));
|
||||
computedValue = parseJsonExpression(String(variableValue));
|
||||
} else if (variableType === "number") {
|
||||
computedValue = Number(variableValue);
|
||||
if (Number.isNaN(computedValue)) {
|
||||
@@ -734,12 +720,15 @@ const VariablePreview = ({
|
||||
}
|
||||
}
|
||||
if (parsedResourceRequest) {
|
||||
computedValue = resourcesCache.get(getResourceKey(parsedResourceRequest));
|
||||
const resourceKey = getResourceKey(parsedResourceRequest);
|
||||
computedValue = resourcesCache.get(resourceKey);
|
||||
resourceDiagnostics = resourceDiagnosticsCache.get(resourceKey);
|
||||
}
|
||||
}
|
||||
const extensions = useMemo(() => [javascript({}), foldGutterExtension], []);
|
||||
const editorProps = {
|
||||
readOnly: true,
|
||||
chromeless: true,
|
||||
extensions,
|
||||
// compute value as json lazily only when dialog is open
|
||||
// by spliting into separate component which is invoked
|
||||
@@ -748,7 +737,7 @@ const VariablePreview = ({
|
||||
onChange: () => {},
|
||||
onChangeComplete: () => {},
|
||||
};
|
||||
return (
|
||||
const preview = (
|
||||
<Grid
|
||||
align="stretch"
|
||||
css={{
|
||||
@@ -756,6 +745,7 @@ const VariablePreview = ({
|
||||
overflow: "hidden",
|
||||
boxSizing: "content-box",
|
||||
position: "relative",
|
||||
gridTemplateRows: "minmax(0, 1fr)",
|
||||
}}
|
||||
>
|
||||
<EditorContent {...editorProps} />
|
||||
@@ -777,6 +767,23 @@ const VariablePreview = ({
|
||||
)}
|
||||
</Grid>
|
||||
);
|
||||
if (isResource === false) {
|
||||
return preview;
|
||||
}
|
||||
const requestErrorDiagnostics = getRequestErrorDiagnostics(computedValue);
|
||||
return (
|
||||
<RequestInspector
|
||||
queryContainerRef={queryActive ? queryContainerRef : undefined}
|
||||
preview={preview}
|
||||
diagnostics={
|
||||
requestErrorDiagnostics !== undefined ? (
|
||||
<RequestErrorDiagnostics value={requestErrorDiagnostics} />
|
||||
) : resourceDiagnostics !== undefined ? (
|
||||
<ContentDatabaseDiagnostics value={resourceDiagnostics} />
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const VariablePopoverContent = ({
|
||||
@@ -790,13 +797,17 @@ const VariablePopoverContent = ({
|
||||
}) => {
|
||||
const hasPendingResources = useStore($hasPendingResources);
|
||||
const panelRef = useRef<undefined | PanelApi>(undefined);
|
||||
const [queryActive, setQueryActive] = useState(false);
|
||||
const [querySourceContainer, setQuerySourceContainer] =
|
||||
useState<HTMLDivElement | null>(null);
|
||||
const queryContainerRef = useCallback(
|
||||
(element: HTMLDivElement | null) => setQuerySourceContainer(element),
|
||||
[]
|
||||
);
|
||||
const isSystemVariable = variable?.id === SYSTEM_VARIABLE_ID;
|
||||
const [value, setValue] = useState<unknown>(() => {
|
||||
if (variable?.type === "variable") {
|
||||
if (
|
||||
variable.value.type === "json" ||
|
||||
variable.value.type === "string[]"
|
||||
) {
|
||||
if (variable.value.type === "json") {
|
||||
return formatValue(variable.value.value);
|
||||
}
|
||||
return variable.value.value;
|
||||
@@ -820,12 +831,7 @@ const VariablePopoverContent = ({
|
||||
}
|
||||
if (variable?.type === "variable") {
|
||||
const type = variable.value.type;
|
||||
if (
|
||||
type === "string" ||
|
||||
type === "number" ||
|
||||
type === "boolean" ||
|
||||
type === "string[]"
|
||||
) {
|
||||
if (type === "string" || type === "number" || type === "boolean") {
|
||||
return type;
|
||||
}
|
||||
return "json";
|
||||
@@ -849,9 +855,6 @@ const VariablePopoverContent = ({
|
||||
// empty string gives an error
|
||||
return prev || "{}";
|
||||
}
|
||||
if (variableType === "string[]") {
|
||||
return "[]";
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
};
|
||||
@@ -887,69 +890,76 @@ const VariablePopoverContent = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Grid
|
||||
css={{
|
||||
height: "100%",
|
||||
gridTemplateColumns: "320px 1fr",
|
||||
}}
|
||||
>
|
||||
<ScrollArea
|
||||
// flex fixes content overflowing artificial scroll area
|
||||
css={{ display: "flex", flexDirection: "column" }}
|
||||
>
|
||||
<form
|
||||
ref={formRef}
|
||||
noValidate={true}
|
||||
// exclude from the flow
|
||||
style={{ display: "contents" }}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (isSystemVariable) {
|
||||
return;
|
||||
}
|
||||
const nameElement =
|
||||
event.currentTarget.elements.namedItem("name");
|
||||
// make sure only name is valid and allow to save everything else
|
||||
// to avoid loosing complex configuration when closed accidentally
|
||||
if (
|
||||
nameElement instanceof HTMLInputElement &&
|
||||
nameElement.checkValidity()
|
||||
) {
|
||||
const formData = new FormData(event.currentTarget);
|
||||
panelRef.current?.save(formData);
|
||||
// close popover whenever new variable is created
|
||||
// to prevent creating duplicated variable
|
||||
if (variable === undefined) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
}}
|
||||
<SplitView
|
||||
defaultSize={{ value: 320, unit: "px" }}
|
||||
minimumStartSize={240}
|
||||
minimumEndSize={240}
|
||||
separatorLabel="Resize variable configuration"
|
||||
start={
|
||||
<ScrollArea
|
||||
// flex fixes content overflowing artificial scroll area
|
||||
css={{ display: "flex", flexDirection: "column" }}
|
||||
>
|
||||
{/* submit is not triggered when press enter on input without submit button */}
|
||||
<button hidden></button>
|
||||
<fieldset
|
||||
<form
|
||||
ref={formRef}
|
||||
noValidate={true}
|
||||
// exclude from the flow
|
||||
style={{ display: "contents" }}
|
||||
// forbid editing system variable
|
||||
disabled={isSystemVariable}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (isSystemVariable) {
|
||||
return;
|
||||
}
|
||||
const nameElement =
|
||||
event.currentTarget.elements.namedItem("name");
|
||||
// make sure only name is valid and allow to save everything else
|
||||
// to avoid loosing complex configuration when closed accidentally
|
||||
if (
|
||||
nameElement instanceof HTMLInputElement &&
|
||||
nameElement.checkValidity()
|
||||
) {
|
||||
const formData = new FormData(event.currentTarget);
|
||||
const saved = panelRef.current?.save(formData);
|
||||
// close popover whenever new variable is created
|
||||
// to prevent creating duplicated variable
|
||||
if (variable === undefined && saved !== false) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<VariablePanelForm
|
||||
ref={panelRef}
|
||||
variable={variable}
|
||||
variableType={variableType}
|
||||
onVariableTypeChange={updateVariableType}
|
||||
value={value}
|
||||
onValueChange={setValue}
|
||||
/>
|
||||
</fieldset>
|
||||
</form>
|
||||
</ScrollArea>
|
||||
<VariablePreview
|
||||
variable={variable}
|
||||
variableType={variableType}
|
||||
variableValue={value}
|
||||
onLoadData={reloadData}
|
||||
/>
|
||||
</Grid>
|
||||
{/* submit is not triggered when press enter on input without submit button */}
|
||||
<button hidden></button>
|
||||
<fieldset
|
||||
style={{ display: "contents" }}
|
||||
// forbid editing system variable
|
||||
disabled={isSystemVariable}
|
||||
>
|
||||
<VariablePanelForm
|
||||
ref={panelRef}
|
||||
variable={variable}
|
||||
variableType={variableType}
|
||||
onVariableTypeChange={updateVariableType}
|
||||
value={value}
|
||||
onValueChange={setValue}
|
||||
querySourceContainer={querySourceContainer}
|
||||
onQueryActiveChange={setQueryActive}
|
||||
/>
|
||||
</fieldset>
|
||||
</form>
|
||||
</ScrollArea>
|
||||
}
|
||||
end={
|
||||
<VariablePreview
|
||||
variable={variable}
|
||||
variableType={variableType}
|
||||
variableValue={value}
|
||||
onLoadData={reloadData}
|
||||
queryActive={queryActive}
|
||||
queryContainerRef={queryContainerRef}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<DialogTitle
|
||||
maximizable
|
||||
@@ -1028,6 +1038,7 @@ export const VariablePopoverTrigger = ({
|
||||
return (
|
||||
<FloatingPanel
|
||||
maximizable
|
||||
resize="both"
|
||||
placement="center"
|
||||
width={740}
|
||||
height={480}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { expect, test } from "vitest";
|
||||
import type { Asset } from "@webstudio-is/sdk";
|
||||
import { __testing__ } from "./markdown-preview";
|
||||
import { renderMarkdown } from "./text-file-utils";
|
||||
|
||||
const { resolveAssetReferences } = __testing__;
|
||||
const { renderMarkdownPreview } = __testing__;
|
||||
|
||||
const image: Asset = {
|
||||
id: "image-id",
|
||||
@@ -16,11 +15,23 @@ const image: Asset = {
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
|
||||
test("resolves asset IDs in Markdown images and links", () => {
|
||||
const html = resolveAssetReferences({
|
||||
html: renderMarkdown(
|
||||
"\n\n[Download image](image-id)\n\n"
|
||||
),
|
||||
const markdown: Asset = {
|
||||
id: "markdown-id",
|
||||
projectId: "project-id",
|
||||
type: "file",
|
||||
name: "post.md",
|
||||
format: "md",
|
||||
size: 1,
|
||||
meta: {},
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
|
||||
test("resolves asset IDs and relative paths in Markdown images and links", () => {
|
||||
const html = renderMarkdownPreview({
|
||||
markdown:
|
||||
"\n\n[Relative image](./image.png)\n\n[Download image](image-id)\n\n",
|
||||
sourceAsset: markdown,
|
||||
folders: new Map(),
|
||||
assetContainers: [{ status: "uploaded", asset: image }],
|
||||
origin: "https://builder.example",
|
||||
});
|
||||
@@ -35,8 +46,10 @@ test("resolves asset IDs in Markdown images and links", () => {
|
||||
});
|
||||
|
||||
test("uses an object URL while an inserted image is uploading", () => {
|
||||
const html = resolveAssetReferences({
|
||||
html: renderMarkdown(""),
|
||||
const html = renderMarkdownPreview({
|
||||
markdown: "",
|
||||
sourceAsset: markdown,
|
||||
folders: new Map(),
|
||||
assetContainers: [
|
||||
{
|
||||
status: "uploading",
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { useMemo, type ReactNode } from "react";
|
||||
import { css, SplitView, theme } from "@webstudio-is/design-system";
|
||||
import { renderMarkdownHtml } from "@webstudio-is/sdk-components-react/markdown";
|
||||
import { discoverNamedMarkdownAssetReferenceRanges } from "@webstudio-is/content-engine/markdown-assets";
|
||||
import { rewriteMarkdownAssetReferenceRanges } from "@webstudio-is/content-engine/markdown-references";
|
||||
import {
|
||||
Box,
|
||||
css,
|
||||
numericScrubControl,
|
||||
theme,
|
||||
} from "@webstudio-is/design-system";
|
||||
createAssetFolderHierarchy,
|
||||
formatAssetName,
|
||||
getAssetUrl,
|
||||
type Asset,
|
||||
type AssetFolders,
|
||||
} from "@webstudio-is/sdk";
|
||||
import type { AssetContainer } from "~/builder/shared/assets";
|
||||
import { getAssetUrl } from "~/builder/shared/assets/asset-utils";
|
||||
import { renderMarkdown } from "./text-file-utils";
|
||||
|
||||
const minimumPaneWidth = 160;
|
||||
|
||||
const previewStyle = css({
|
||||
minWidth: 0,
|
||||
@@ -68,45 +69,31 @@ const previewStyle = css({
|
||||
"& a": { color: theme.colors.foregroundPrimary },
|
||||
});
|
||||
|
||||
const splitHandleStyle = css({
|
||||
position: "relative",
|
||||
width: 5,
|
||||
height: "100%",
|
||||
cursor: "col-resize",
|
||||
touchAction: "none",
|
||||
outline: "none",
|
||||
"&::before": {
|
||||
position: "absolute",
|
||||
content: '""',
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: 2,
|
||||
width: 1,
|
||||
background: theme.colors.backgroundPrimaryLight,
|
||||
},
|
||||
"&:hover::before, &:focus-visible::before": {
|
||||
left: 1,
|
||||
width: 3,
|
||||
},
|
||||
});
|
||||
|
||||
const clampRatio = (ratio: number) => Math.min(0.8, Math.max(0.2, ratio));
|
||||
|
||||
const resolveAssetReferences = ({
|
||||
html,
|
||||
const renderMarkdownPreview = ({
|
||||
markdown,
|
||||
sourceAsset,
|
||||
folders,
|
||||
assetContainers,
|
||||
origin,
|
||||
}: {
|
||||
html: string;
|
||||
markdown: string;
|
||||
sourceAsset: Asset;
|
||||
folders: AssetFolders;
|
||||
assetContainers: AssetContainer[];
|
||||
origin: string;
|
||||
}) => {
|
||||
if (typeof DOMParser === "undefined") {
|
||||
return html;
|
||||
}
|
||||
|
||||
const document = new DOMParser().parseFromString(html, "text/html");
|
||||
const urls = new Map(
|
||||
const hierarchy = createAssetFolderHierarchy(folders);
|
||||
const getNamedAsset = (asset: AssetContainer["asset"]) => ({
|
||||
id: asset.id,
|
||||
name: formatAssetName(asset),
|
||||
folderNames: hierarchy.getPath(asset.folderId).map(({ name }) => name),
|
||||
});
|
||||
const references = discoverNamedMarkdownAssetReferenceRanges({
|
||||
markdown,
|
||||
source: getNamedAsset(sourceAsset),
|
||||
assets: assetContainers.map(({ asset }) => getNamedAsset(asset)),
|
||||
});
|
||||
const assetUrls = Object.fromEntries(
|
||||
assetContainers.map((container) => [
|
||||
container.asset.id,
|
||||
container.status === "uploading"
|
||||
@@ -114,128 +101,57 @@ const resolveAssetReferences = ({
|
||||
: getAssetUrl(container.asset, origin).href,
|
||||
])
|
||||
);
|
||||
for (const image of document.querySelectorAll("img[src]")) {
|
||||
const url = urls.get(image.getAttribute("src") ?? "");
|
||||
if (url !== undefined) {
|
||||
image.setAttribute("src", url);
|
||||
}
|
||||
}
|
||||
for (const link of document.querySelectorAll("a[href]")) {
|
||||
const url = urls.get(link.getAttribute("href") ?? "");
|
||||
if (url !== undefined) {
|
||||
link.setAttribute("href", url);
|
||||
}
|
||||
}
|
||||
return document.body.innerHTML;
|
||||
return renderMarkdownHtml(
|
||||
rewriteMarkdownAssetReferenceRanges({ markdown, references, assetUrls }),
|
||||
{ allowBlobImages: true }
|
||||
);
|
||||
};
|
||||
|
||||
export const __testing__ = { resolveAssetReferences };
|
||||
export const __testing__ = { renderMarkdownPreview };
|
||||
|
||||
export const MarkdownSplitView = ({
|
||||
open,
|
||||
source,
|
||||
sourceAsset,
|
||||
folders,
|
||||
assetContainers,
|
||||
children,
|
||||
}: {
|
||||
open: boolean;
|
||||
source: string;
|
||||
sourceAsset: Asset;
|
||||
folders: AssetFolders;
|
||||
assetContainers: AssetContainer[];
|
||||
children: ReactNode;
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const handleRef = useRef<HTMLDivElement>(null);
|
||||
const ratioRef = useRef(0.5);
|
||||
const [ratio, setRatio] = useState(ratioRef.current);
|
||||
const html = useMemo(() => {
|
||||
if (open === false) {
|
||||
return "";
|
||||
}
|
||||
return resolveAssetReferences({
|
||||
html: renderMarkdown(source),
|
||||
return renderMarkdownPreview({
|
||||
markdown: source,
|
||||
sourceAsset,
|
||||
folders,
|
||||
assetContainers,
|
||||
origin: window.location.origin,
|
||||
});
|
||||
}, [assetContainers, open, source]);
|
||||
|
||||
const updateRatio = (nextRatio: number) => {
|
||||
ratioRef.current = clampRatio(nextRatio);
|
||||
setRatio(ratioRef.current);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handle = handleRef.current;
|
||||
if (handle === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
return numericScrubControl(handle, {
|
||||
getInitialValue: () => {
|
||||
const width = containerRef.current?.getBoundingClientRect().width ?? 0;
|
||||
return width * ratioRef.current;
|
||||
},
|
||||
getValue: (state, movement) => {
|
||||
const width = containerRef.current?.getBoundingClientRect().width ?? 0;
|
||||
const paneWidth = Math.min(minimumPaneWidth, width / 2);
|
||||
return Math.min(
|
||||
width - paneWidth,
|
||||
Math.max(paneWidth, state.value + movement)
|
||||
);
|
||||
},
|
||||
onValueInput: (event) => {
|
||||
const width = containerRef.current?.getBoundingClientRect().width ?? 0;
|
||||
if (width > 0) {
|
||||
updateRatio(event.value / width);
|
||||
}
|
||||
},
|
||||
});
|
||||
}, []);
|
||||
}, [assetContainers, folders, open, source, sourceAsset]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={containerRef}
|
||||
css={{
|
||||
display: "grid",
|
||||
height: "100%",
|
||||
minHeight: 0,
|
||||
}}
|
||||
style={{
|
||||
gridTemplateColumns: open
|
||||
? `minmax(0, ${ratio}fr) 5px minmax(0, ${1 - ratio}fr)`
|
||||
: "minmax(0, 1fr)",
|
||||
}}
|
||||
>
|
||||
<Box css={{ minWidth: 0, minHeight: 0, overflow: "hidden" }}>
|
||||
{children}
|
||||
</Box>
|
||||
<div
|
||||
ref={handleRef}
|
||||
className={splitHandleStyle()}
|
||||
hidden={open === false}
|
||||
role="separator"
|
||||
aria-label="Resize Markdown preview"
|
||||
aria-orientation="vertical"
|
||||
aria-valuemin={20}
|
||||
aria-valuemax={80}
|
||||
aria-valuenow={Math.round(ratio * 100)}
|
||||
tabIndex={0}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "ArrowLeft") {
|
||||
event.preventDefault();
|
||||
updateRatio(ratioRef.current - 0.05);
|
||||
}
|
||||
if (event.key === "ArrowRight") {
|
||||
event.preventDefault();
|
||||
updateRatio(ratioRef.current + 0.05);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className={previewStyle()}
|
||||
hidden={open === false}
|
||||
role="region"
|
||||
aria-label="Markdown preview"
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
</Box>
|
||||
<SplitView
|
||||
defaultSize={{ value: 50, unit: "%" }}
|
||||
start={children}
|
||||
end={
|
||||
open ? (
|
||||
<div
|
||||
className={previewStyle()}
|
||||
role="region"
|
||||
aria-label="Markdown preview"
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
separatorLabel="Resize Markdown preview"
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -39,13 +39,17 @@ import {
|
||||
TextStrikethroughIcon,
|
||||
} from "@webstudio-is/icons";
|
||||
import { formatAssetName } from "@webstudio-is/project-build/runtime";
|
||||
import { getPagePath, type Asset } from "@webstudio-is/sdk";
|
||||
import { getAssetUrl, getPagePath, type Asset } from "@webstudio-is/sdk";
|
||||
import { CodeEditor } from "~/shared/code-editor";
|
||||
import { EditorDialog, type EditorApi } from "~/shared/code-editor-base";
|
||||
import { $assets, $pages, $props } from "~/shared/sync/data-stores";
|
||||
import {
|
||||
$assetFolders,
|
||||
$assets,
|
||||
$pages,
|
||||
$props,
|
||||
} from "~/shared/sync/data-stores";
|
||||
import { $authPermit } from "~/shared/nano-states";
|
||||
import { AssetManager } from "~/builder/shared/asset-manager";
|
||||
import { getAssetUrl } from "~/builder/shared/assets/asset-utils";
|
||||
import {
|
||||
AssetUpload,
|
||||
updateAssetContent,
|
||||
@@ -423,6 +427,7 @@ export const TextFileEditor = ({
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) => {
|
||||
const assets = useStore($assets);
|
||||
const assetFolders = useStore($assetFolders);
|
||||
const asset = assets.get(assetId);
|
||||
const { assetContainers } = useAssets();
|
||||
const canEdit = useStore($authPermit) !== "view";
|
||||
@@ -517,7 +522,7 @@ export const TextFileEditor = ({
|
||||
languageExtensions={getTextFileEditorExtensions(asset)}
|
||||
size="full"
|
||||
expandable={false}
|
||||
showBorder={false}
|
||||
chromeless
|
||||
readOnly={canEdit === false}
|
||||
onChange={(content) => {
|
||||
setState({ status: "loaded", content });
|
||||
@@ -575,6 +580,8 @@ export const TextFileEditor = ({
|
||||
<MarkdownSplitView
|
||||
open={previewOpen}
|
||||
source={state.content}
|
||||
sourceAsset={asset}
|
||||
folders={assetFolders}
|
||||
assetContainers={assetContainers}
|
||||
>
|
||||
{editor}
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
import {
|
||||
getTextFileEditorExtensions,
|
||||
isMarkdownAsset,
|
||||
renderMarkdown,
|
||||
} from "./text-file-utils";
|
||||
|
||||
describe("text file assets", () => {
|
||||
@@ -46,14 +45,4 @@ describe("text file assets", () => {
|
||||
expect(isMarkdownAsset({ format: "MD" })).toBe(true);
|
||||
expect(isMarkdownAsset({ format: "txt" })).toBe(false);
|
||||
});
|
||||
|
||||
test("renders a safe GFM preview", () => {
|
||||
const html = renderMarkdown(
|
||||
"~~done~~\n\n| A | B |\n| - | - |\n| 1 | 2 |\n\n<script>alert(1)</script>\n\n[unsafe](javascript:alert(1))"
|
||||
);
|
||||
expect(html).toContain("<del>done</del>");
|
||||
expect(html).toContain("<table>");
|
||||
expect(html).toContain("<script>alert(1)</script>");
|
||||
expect(html).not.toContain('href="javascript:');
|
||||
});
|
||||
});
|
||||
@@ -3,8 +3,6 @@ import { css } from "@codemirror/lang-css";
|
||||
import { html } from "@codemirror/lang-html";
|
||||
import { javascript } from "@codemirror/lang-javascript";
|
||||
import { markdown } from "@codemirror/lang-markdown";
|
||||
import { micromark } from "micromark";
|
||||
import { gfm, gfmHtml } from "micromark-extension-gfm";
|
||||
import {
|
||||
getAssetTextEditorLanguage,
|
||||
type Asset,
|
||||
@@ -33,9 +31,3 @@ export const getTextFileEditorExtensions = (
|
||||
|
||||
export const isMarkdownAsset = (asset: Pick<Asset, "format">) =>
|
||||
getAssetTextEditorLanguage(asset) === "markdown";
|
||||
|
||||
export const renderMarkdown = (source: string) =>
|
||||
micromark(source, {
|
||||
extensions: [gfm()],
|
||||
htmlExtensions: [gfmHtml()],
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { act } from "react-dom/test-utils";
|
||||
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
||||
import { TooltipProvider } from "@webstudio-is/design-system";
|
||||
import { createAssetFolderHierarchy } from "@webstudio-is/sdk";
|
||||
import {
|
||||
createAssetFolderFixture,
|
||||
createAssetFoldersFixture,
|
||||
} from "@webstudio-is/sdk/testing";
|
||||
import { AssetFolderBreadcrumbs } from "./asset-folder-breadcrumbs";
|
||||
import { createAssetManagerTestRenderer } from "./test-utils";
|
||||
|
||||
const renderer = createAssetManagerTestRenderer();
|
||||
const writeText = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal(
|
||||
"ResizeObserver",
|
||||
class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
);
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
configurable: true,
|
||||
value: { writeText },
|
||||
});
|
||||
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
|
||||
configurable: true,
|
||||
value: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
renderer.cleanup();
|
||||
writeText.mockReset();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
const parent = createAssetFolderFixture({ id: "parent", name: "Documents" });
|
||||
const child = createAssetFolderFixture({
|
||||
id: "child",
|
||||
parentId: parent.id,
|
||||
name: "Articles",
|
||||
});
|
||||
const hierarchy = createAssetFolderHierarchy(
|
||||
createAssetFoldersFixture(parent, child)
|
||||
);
|
||||
|
||||
const renderBreadcrumbs = (
|
||||
selectedItem:
|
||||
| { type: "folder"; id: string }
|
||||
| { type: "asset"; folderId: string; name: string }
|
||||
) =>
|
||||
renderer.render(
|
||||
<TooltipProvider>
|
||||
<AssetFolderBreadcrumbs
|
||||
hierarchy={hierarchy}
|
||||
folderId={parent.id}
|
||||
selectedItem={selectedItem}
|
||||
onChange={vi.fn()}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
);
|
||||
|
||||
const copyPath = () => {
|
||||
act(() => {
|
||||
document
|
||||
.querySelector<HTMLButtonElement>('[aria-label="Copy path"]')
|
||||
?.click();
|
||||
});
|
||||
};
|
||||
|
||||
test("shows and copies the selected folder path", () => {
|
||||
renderBreadcrumbs({ type: "folder", id: child.id });
|
||||
|
||||
expect(document.body.textContent).toContain("RootDocumentsArticles");
|
||||
copyPath();
|
||||
expect(writeText).toHaveBeenCalledWith("Root / Documents / Articles");
|
||||
});
|
||||
|
||||
test("shows and copies the selected asset path", () => {
|
||||
renderBreadcrumbs({
|
||||
type: "asset",
|
||||
folderId: child.id,
|
||||
name: "welcome.md",
|
||||
});
|
||||
|
||||
expect(document.body.textContent).toContain(
|
||||
"RootDocumentsArticleswelcome.md"
|
||||
);
|
||||
copyPath();
|
||||
expect(writeText).toHaveBeenCalledWith(
|
||||
"Root / Documents / Articles / welcome.md"
|
||||
);
|
||||
});
|
||||
@@ -5,13 +5,24 @@ import {
|
||||
Flex,
|
||||
ScrollAreaNative,
|
||||
Separator,
|
||||
SmallIconButton,
|
||||
Text,
|
||||
theme,
|
||||
} from "@webstudio-is/design-system";
|
||||
import type { AssetFolderHierarchy } from "@webstudio-is/sdk";
|
||||
import { ChevronRightIcon } from "@webstudio-is/icons";
|
||||
import { ChevronRightIcon, CopyIcon } from "@webstudio-is/icons";
|
||||
import { useEffect, useRef, type ReactElement } from "react";
|
||||
import { CopyToClipboard } from "~/shared/copy-to-clipboard";
|
||||
import { AssetManagerItemContextMenuContent } from "./asset-manager-item-menu";
|
||||
import { formatAssetFolderPath } from "./asset-folder-utils";
|
||||
|
||||
type SelectedItem =
|
||||
| { type: "folder"; id: string }
|
||||
| {
|
||||
type: "asset";
|
||||
folderId: string | undefined;
|
||||
name: string;
|
||||
};
|
||||
|
||||
const PasteTarget = ({
|
||||
children,
|
||||
@@ -40,86 +51,157 @@ const PasteTarget = ({
|
||||
export const AssetFolderBreadcrumbs = ({
|
||||
hierarchy,
|
||||
folderId,
|
||||
selectedItem,
|
||||
onChange,
|
||||
canPaste,
|
||||
onPaste,
|
||||
}: {
|
||||
hierarchy: AssetFolderHierarchy;
|
||||
folderId: string | undefined;
|
||||
selectedItem?: SelectedItem;
|
||||
onChange: (folderId: string | undefined) => void;
|
||||
canPaste?: (folderId: string | undefined) => boolean;
|
||||
onPaste?: (folderId: string | undefined) => void;
|
||||
}) => {
|
||||
const path = hierarchy.getPath(folderId);
|
||||
const currentFolderRef = useRef<HTMLButtonElement>(null);
|
||||
const pathFolderId =
|
||||
selectedItem?.type === "folder"
|
||||
? selectedItem.id
|
||||
: (selectedItem?.folderId ?? folderId);
|
||||
const path = hierarchy.getPath(pathFolderId);
|
||||
const folderPath = formatAssetFolderPath(hierarchy, pathFolderId);
|
||||
const copyPath =
|
||||
selectedItem?.type === "asset"
|
||||
? `${folderPath} / ${selectedItem.name}`
|
||||
: folderPath;
|
||||
const currentPathRef = useRef<HTMLElement | null>(null);
|
||||
useEffect(() => {
|
||||
currentFolderRef.current?.scrollIntoView({
|
||||
currentPathRef.current?.scrollIntoView({
|
||||
block: "nearest",
|
||||
inline: "nearest",
|
||||
});
|
||||
}, [folderId]);
|
||||
}, [folderId, selectedItem]);
|
||||
return (
|
||||
<>
|
||||
<Separator />
|
||||
<ScrollAreaNative
|
||||
<Flex
|
||||
align="center"
|
||||
data-asset-folder-breadcrumbs=""
|
||||
css={{
|
||||
width: "100%",
|
||||
overflowX: "auto",
|
||||
overflowY: "hidden",
|
||||
scrollbarWidth: "none",
|
||||
"&::-webkit-scrollbar": { display: "none" },
|
||||
flexShrink: 0,
|
||||
marginBottom: `calc(-1 * ${theme.panel.paddingBlock})`,
|
||||
"& [data-copy-asset-path]": { visibility: "hidden" },
|
||||
"&:hover [data-copy-asset-path], &:focus-within [data-copy-asset-path]":
|
||||
{ visibility: "visible" },
|
||||
}}
|
||||
aria-label="Asset folder path"
|
||||
>
|
||||
<Flex
|
||||
align="center"
|
||||
gap={1}
|
||||
<ScrollAreaNative
|
||||
css={{
|
||||
minWidth: "max-content",
|
||||
minHeight: theme.spacing[12],
|
||||
paddingInline: theme.spacing[2],
|
||||
paddingBlock: theme.spacing[2],
|
||||
minWidth: 0,
|
||||
flexGrow: 1,
|
||||
overflowX: "auto",
|
||||
overflowY: "hidden",
|
||||
scrollbarWidth: "none",
|
||||
"&::-webkit-scrollbar": { display: "none" },
|
||||
}}
|
||||
aria-label="Asset path"
|
||||
>
|
||||
<PasteTarget
|
||||
folderId={undefined}
|
||||
canPaste={canPaste}
|
||||
onPaste={onPaste}
|
||||
<Flex
|
||||
align="center"
|
||||
gap={1}
|
||||
css={{
|
||||
minWidth: "max-content",
|
||||
minHeight: theme.spacing[12],
|
||||
paddingInline: theme.spacing[2],
|
||||
paddingBlock: theme.spacing[2],
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
ref={folderId === undefined ? currentFolderRef : undefined}
|
||||
color="ghost"
|
||||
aria-current={folderId === undefined ? "location" : undefined}
|
||||
onClick={() => onChange(undefined)}
|
||||
<PasteTarget
|
||||
folderId={undefined}
|
||||
canPaste={canPaste}
|
||||
onPaste={onPaste}
|
||||
>
|
||||
<Text css={{ minWidth: "3ch" }}>Root</Text>
|
||||
</Button>
|
||||
</PasteTarget>
|
||||
{path.map((folder) => (
|
||||
<Flex key={folder.id} align="center" shrink={false}>
|
||||
<ChevronRightIcon size={12} />
|
||||
<PasteTarget
|
||||
folderId={folder.id}
|
||||
canPaste={canPaste}
|
||||
onPaste={onPaste}
|
||||
<Button
|
||||
ref={(element) => {
|
||||
if (pathFolderId === undefined) {
|
||||
currentPathRef.current = element;
|
||||
}
|
||||
}}
|
||||
color="ghost"
|
||||
aria-current={
|
||||
pathFolderId === undefined && selectedItem === undefined
|
||||
? "location"
|
||||
: undefined
|
||||
}
|
||||
onClick={() => onChange(undefined)}
|
||||
>
|
||||
<Button
|
||||
ref={folder.id === folderId ? currentFolderRef : undefined}
|
||||
color="ghost"
|
||||
aria-current={folder.id === folderId ? "location" : undefined}
|
||||
onClick={() => onChange(folder.id)}
|
||||
<Text css={{ minWidth: "3ch" }}>Root</Text>
|
||||
</Button>
|
||||
</PasteTarget>
|
||||
{path.map((folder) => (
|
||||
<Flex key={folder.id} align="center" shrink={false}>
|
||||
<ChevronRightIcon size={12} />
|
||||
<PasteTarget
|
||||
folderId={folder.id}
|
||||
canPaste={canPaste}
|
||||
onPaste={onPaste}
|
||||
>
|
||||
<Text truncate css={{ minWidth: "3ch", maxWidth: 160 }}>
|
||||
{folder.name}
|
||||
</Text>
|
||||
</Button>
|
||||
</PasteTarget>
|
||||
</Flex>
|
||||
))}
|
||||
<Button
|
||||
ref={(element) => {
|
||||
if (
|
||||
folder.id === pathFolderId &&
|
||||
selectedItem?.type !== "asset"
|
||||
) {
|
||||
currentPathRef.current = element;
|
||||
}
|
||||
}}
|
||||
color="ghost"
|
||||
aria-current={
|
||||
folder.id === pathFolderId &&
|
||||
selectedItem?.type !== "asset"
|
||||
? "location"
|
||||
: undefined
|
||||
}
|
||||
onClick={() => onChange(folder.id)}
|
||||
>
|
||||
<Text truncate css={{ minWidth: "3ch", maxWidth: 160 }}>
|
||||
{folder.name}
|
||||
</Text>
|
||||
</Button>
|
||||
</PasteTarget>
|
||||
</Flex>
|
||||
))}
|
||||
{selectedItem?.type === "asset" && (
|
||||
<Flex align="center" shrink={false}>
|
||||
<ChevronRightIcon size={12} />
|
||||
<Text
|
||||
ref={(element) => {
|
||||
currentPathRef.current = element;
|
||||
}}
|
||||
aria-current="location"
|
||||
truncate
|
||||
css={{
|
||||
minWidth: "3ch",
|
||||
maxWidth: 160,
|
||||
paddingInline: theme.spacing[3],
|
||||
}}
|
||||
>
|
||||
{selectedItem.name}
|
||||
</Text>
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
</ScrollAreaNative>
|
||||
<Flex
|
||||
data-copy-asset-path=""
|
||||
shrink={false}
|
||||
css={{ paddingInline: theme.spacing[2] }}
|
||||
>
|
||||
<CopyToClipboard text={copyPath} copyText="Copy path">
|
||||
<SmallIconButton aria-label="Copy path" icon={<CopyIcon />} />
|
||||
</CopyToClipboard>
|
||||
</Flex>
|
||||
</ScrollAreaNative>
|
||||
</Flex>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { act } from "react-dom/test-utils";
|
||||
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
||||
import { TooltipProvider } from "@webstudio-is/design-system";
|
||||
import { $assetFolders } from "~/shared/sync/data-stores";
|
||||
import {
|
||||
AssetFolderSettingsDialog,
|
||||
@@ -13,6 +15,8 @@ import {
|
||||
import { createAssetManagerTestRenderer } from "./test-utils";
|
||||
|
||||
const renderer = createAssetManagerTestRenderer();
|
||||
const render = (children: ReactNode) =>
|
||||
renderer.render(<TooltipProvider>{children}</TooltipProvider>);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal(
|
||||
@@ -44,7 +48,7 @@ test("Escape closes folder settings without closing the assets panel", () => {
|
||||
const onPanelClose = vi.fn();
|
||||
const onSettingsOpenChange = vi.fn();
|
||||
|
||||
renderer.render(
|
||||
render(
|
||||
<div
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape") {
|
||||
@@ -82,7 +86,7 @@ test.each([
|
||||
});
|
||||
$assetFolders.set(createAssetFoldersFixture(folder));
|
||||
|
||||
renderer.render(
|
||||
render(
|
||||
<AssetFolderSettingsDialog
|
||||
folder={folder}
|
||||
open
|
||||
@@ -95,8 +99,32 @@ test.each([
|
||||
}
|
||||
);
|
||||
|
||||
test("shows the folder name and ID in settings", () => {
|
||||
const folder = createAssetFolderFixture({
|
||||
id: "folder-id",
|
||||
name: "Documents",
|
||||
});
|
||||
$assetFolders.set(createAssetFoldersFixture(folder));
|
||||
|
||||
render(
|
||||
<AssetFolderSettingsDialog folder={folder} open onOpenChange={vi.fn()} />
|
||||
);
|
||||
|
||||
expect(
|
||||
document.querySelector('label[for="asset-folder-name-folder-id"]')
|
||||
?.textContent
|
||||
).toBe("Name");
|
||||
expect(
|
||||
document.querySelector<HTMLInputElement>("#asset-folder-id-folder-id")
|
||||
?.value
|
||||
).toBe(folder.id);
|
||||
expect(
|
||||
document.querySelector('[aria-label="Copy folder ID"]')
|
||||
).toBeInstanceOf(HTMLButtonElement);
|
||||
});
|
||||
|
||||
test("focuses the folder name while the rightmost Create action is disabled", () => {
|
||||
renderer.render(
|
||||
render(
|
||||
<CreateAssetFolderDialog
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
@@ -123,7 +151,7 @@ test("moves items to the selected folder from the folder-only dialog", () => {
|
||||
const onMove = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
|
||||
renderer.render(
|
||||
render(
|
||||
<MoveAssetManagerItemsDialog
|
||||
initialFolderId={destination.id}
|
||||
canMove={() => true}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
Grid,
|
||||
InputField,
|
||||
Label,
|
||||
SmallIconButton,
|
||||
Text,
|
||||
theme,
|
||||
} from "@webstudio-is/design-system";
|
||||
@@ -23,9 +24,10 @@ import {
|
||||
createAssetFolderHierarchy,
|
||||
type AssetFolder,
|
||||
} from "@webstudio-is/sdk";
|
||||
import { TrashIcon } from "@webstudio-is/icons";
|
||||
import { CopyIcon, TrashIcon } from "@webstudio-is/icons";
|
||||
import { $assetFolders } from "~/shared/sync/data-stores";
|
||||
import { executeRuntimeMutation } from "~/shared/instance-utils/data";
|
||||
import { CopyToClipboard } from "~/shared/copy-to-clipboard";
|
||||
import { AssetFolderSelector } from "./asset-folder-selector";
|
||||
|
||||
type AssetFolderFormValues = {
|
||||
@@ -54,6 +56,7 @@ const AssetFolderForm = ({
|
||||
initialName,
|
||||
initialParentId,
|
||||
excludedFolderId,
|
||||
folderId,
|
||||
autoFocusSubmit = false,
|
||||
submitLabel,
|
||||
secondaryAction,
|
||||
@@ -64,6 +67,7 @@ const AssetFolderForm = ({
|
||||
initialName: string;
|
||||
initialParentId: string | undefined;
|
||||
excludedFolderId?: string;
|
||||
folderId?: string;
|
||||
autoFocusSubmit?: boolean;
|
||||
submitLabel: string;
|
||||
secondaryAction?: ReactNode;
|
||||
@@ -106,7 +110,7 @@ const AssetFolderForm = ({
|
||||
return (
|
||||
<Grid gap={3} css={{ padding: theme.panel.padding }}>
|
||||
<Grid gap={1}>
|
||||
<Label htmlFor={id}>Folder</Label>
|
||||
<Label htmlFor={id}>Name</Label>
|
||||
<InputField
|
||||
id={id}
|
||||
autoFocus={autoFocusSubmit === false}
|
||||
@@ -131,6 +135,26 @@ const AssetFolderForm = ({
|
||||
excludedFolderIds={excludedFolderIds}
|
||||
rootLabel="Parent folder"
|
||||
/>
|
||||
{folderId !== undefined && (
|
||||
<Grid gap={1}>
|
||||
<Label htmlFor={`asset-folder-id-${folderId}`}>ID</Label>
|
||||
<InputField
|
||||
id={`asset-folder-id-${folderId}`}
|
||||
readOnly
|
||||
value={folderId}
|
||||
suffix={
|
||||
<Flex justify="center" css={{ paddingInline: theme.spacing[2] }}>
|
||||
<CopyToClipboard text={folderId}>
|
||||
<SmallIconButton
|
||||
aria-label="Copy folder ID"
|
||||
icon={<CopyIcon />}
|
||||
/>
|
||||
</CopyToClipboard>
|
||||
</Flex>
|
||||
}
|
||||
/>
|
||||
</Grid>
|
||||
)}
|
||||
<Flex justify="end" gap={2}>
|
||||
{secondaryAction}
|
||||
<Button
|
||||
@@ -260,6 +284,7 @@ export const AssetFolderSettingsDialog = ({
|
||||
initialName={folder.name}
|
||||
initialParentId={folder.parentId}
|
||||
excludedFolderId={folder.id}
|
||||
folderId={folder.id}
|
||||
autoFocusSubmit
|
||||
submitLabel="Save"
|
||||
onSubmit={save}
|
||||
|
||||
@@ -217,6 +217,26 @@ const createAsset = (id: string): Asset => ({
|
||||
});
|
||||
|
||||
describe("Asset Manager multiselect interactions", () => {
|
||||
test("keeps the selected asset in the path while focusing the copy action", () => {
|
||||
const asset = createAsset("asset");
|
||||
act(() => $assets.set(new Map([[asset.id, asset]])));
|
||||
const container = renderManager();
|
||||
const assetButton = getOptions(container).at(-1)!.button;
|
||||
const path = container.querySelector<HTMLElement>(
|
||||
'[aria-label="Asset path"]'
|
||||
)!;
|
||||
|
||||
act(() => assetButton.focus());
|
||||
expect(path.textContent).toContain("Rootasset.png");
|
||||
|
||||
act(() =>
|
||||
container
|
||||
.querySelector<HTMLButtonElement>('[aria-label="Copy path"]')
|
||||
?.focus()
|
||||
);
|
||||
expect(path.textContent).toContain("Rootasset.png");
|
||||
});
|
||||
|
||||
test("marquee-selects every thumbnail intersecting the pointer rectangle", () => {
|
||||
const container = renderManager();
|
||||
const options = getOptions(container);
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
acceptToMimePatterns,
|
||||
createAssetFolderHierarchy,
|
||||
doesAssetMatchMimePatterns,
|
||||
formatAssetName,
|
||||
type Asset,
|
||||
type AllowedFileExtension,
|
||||
} from "@webstudio-is/sdk";
|
||||
@@ -422,11 +423,6 @@ export const AssetManager = ({
|
||||
const handleFocusChange = (item: AssetManagerSelection, focused: boolean) => {
|
||||
if (focused) {
|
||||
setSelection(item);
|
||||
} else if (
|
||||
forcedSelection === undefined &&
|
||||
isSameAssetManagerSelection(selection, item)
|
||||
) {
|
||||
setSelection(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -559,6 +555,32 @@ export const AssetManager = ({
|
||||
[assetContainers]
|
||||
);
|
||||
|
||||
const selectedBreadcrumbItem = useMemo(() => {
|
||||
if (selection === undefined || forcedSelection !== undefined) {
|
||||
return;
|
||||
}
|
||||
if (selection.type === "folder") {
|
||||
return folders.has(selection.id) ? selection : undefined;
|
||||
}
|
||||
const asset = compatibleContainers.find(
|
||||
({ asset }) => asset.id === selection.id
|
||||
)?.asset;
|
||||
if (asset === undefined) {
|
||||
return;
|
||||
}
|
||||
return {
|
||||
type: "asset" as const,
|
||||
folderId: folderHierarchy.resolveFolderId(asset.folderId),
|
||||
name: formatAssetName(asset),
|
||||
};
|
||||
}, [
|
||||
compatibleContainers,
|
||||
folderHierarchy,
|
||||
folders,
|
||||
forcedSelection,
|
||||
selection,
|
||||
]);
|
||||
|
||||
const normalizeItems = useCallback(
|
||||
(items: readonly AssetManagerSelection[]) =>
|
||||
normalizeAssetManagerItems({
|
||||
@@ -953,6 +975,7 @@ export const AssetManager = ({
|
||||
<AssetFolderBreadcrumbs
|
||||
hierarchy={folderHierarchy}
|
||||
folderId={currentFolderId}
|
||||
selectedItem={selectedBreadcrumbItem}
|
||||
onChange={setCurrentFolderId}
|
||||
canPaste={
|
||||
canManageFolders ? canPasteAssetManagerClipboard : undefined
|
||||
|
||||
@@ -92,6 +92,9 @@ test("uses an auto-growing textarea for the asset description", () => {
|
||||
expect(document.body.textContent).toContain("Last modified");
|
||||
expect(document.body.textContent).toContain("Jan 15, 2026");
|
||||
expect(document.body.textContent).toContain("Feb 15, 2026");
|
||||
expect(
|
||||
Array.from(document.querySelectorAll("label"), (label) => label.textContent)
|
||||
).toEqual(["Name", "Folder", "Description", "ID"]);
|
||||
});
|
||||
|
||||
test("closes asset settings before replacing an asset", () => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useDebouncedCallback } from "use-debounce";
|
||||
import prettyBytes from "pretty-bytes";
|
||||
import { computed } from "nanostores";
|
||||
import { useStore } from "@nanostores/react";
|
||||
import { getMimeTypeByExtension } from "@webstudio-is/sdk";
|
||||
import { getAssetUrl, getMimeTypeByExtension } from "@webstudio-is/sdk";
|
||||
import type { Asset, Instance } from "@webstudio-is/sdk";
|
||||
import {
|
||||
Box,
|
||||
@@ -74,7 +74,6 @@ import {
|
||||
} from "@webstudio-is/project-build/runtime";
|
||||
import { AssetFolderSelector } from "./asset-folder-selector";
|
||||
import { moveAssetManagerItems } from "./asset-manager-operations";
|
||||
import { getAssetUrl } from "~/builder/shared/assets/asset-utils";
|
||||
import { getFormattedAspectRatio } from "./utils";
|
||||
import { CopyToClipboard } from "~/shared/copy-to-clipboard";
|
||||
import {
|
||||
@@ -478,6 +477,16 @@ const AssetSettingsContent = ({
|
||||
</InputErrorsTooltip>
|
||||
</Grid>
|
||||
|
||||
<Grid css={{ padding: theme.panel.padding, gap: 4 }}>
|
||||
<AssetFolderSelector
|
||||
value={asset.folderId}
|
||||
onChange={moveToFolder}
|
||||
rootLabel="Folder"
|
||||
disabled={authPermit === "view"}
|
||||
deferChangesUntilBlur
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid css={{ padding: theme.panel.padding, gap: 4 }}>
|
||||
<Label
|
||||
htmlFor="asset-manager-description"
|
||||
@@ -503,16 +512,6 @@ const AssetSettingsContent = ({
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid css={{ padding: theme.panel.padding, gap: 4 }}>
|
||||
<AssetFolderSelector
|
||||
value={asset.folderId}
|
||||
onChange={moveToFolder}
|
||||
rootLabel="Folder"
|
||||
disabled={authPermit === "view"}
|
||||
deferChangesUntilBlur
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid css={{ padding: theme.panel.padding, gap: 4 }}>
|
||||
<Label htmlFor="asset-manager-id">ID</Label>
|
||||
<InputField
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
FILE_EXTENSIONS_BY_CATEGORY,
|
||||
IMAGE_MIME_TYPES,
|
||||
detectAssetType,
|
||||
getAssetUrl,
|
||||
isResizableImageFileName,
|
||||
} from "@webstudio-is/sdk";
|
||||
import type { MimeCategory } from "@webstudio-is/sdk";
|
||||
@@ -25,7 +26,6 @@ import { draggable } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
|
||||
import { $authPermit, $permissions } from "~/shared/nano-states";
|
||||
import { replaceAsset } from "~/builder/shared/assets";
|
||||
import { validateFiles } from "~/builder/shared/assets/asset-upload";
|
||||
import { getAssetUrl } from "~/builder/shared/assets/asset-utils";
|
||||
import { createAssetManagerClipboardActions } from "./asset-manager-clipboard";
|
||||
import { setAssetManagerDragPreview } from "./asset-manager-drag-preview";
|
||||
import { type AssetManagerItemActions } from "./asset-manager-item-menu";
|
||||
|
||||
@@ -10,12 +10,9 @@ import {
|
||||
getFileExtension,
|
||||
IMAGE_EXTENSIONS,
|
||||
detectAssetType,
|
||||
getAssetUrl,
|
||||
getAssetContentHash,
|
||||
} from "@webstudio-is/sdk";
|
||||
import type { UploadingFileData } from "~/shared/nano-states";
|
||||
import { getContentHash } from "@webstudio-is/asset-uploader";
|
||||
|
||||
export { getAssetUrl };
|
||||
|
||||
export const getImageNameAndType = (fileName: string) => {
|
||||
const extractedExt = getFileExtension(fileName)?.toLowerCase();
|
||||
@@ -58,7 +55,7 @@ const extractImageNameAndMimeTypeFromUrl = (url: URL) => {
|
||||
|
||||
export const getSha256Hash = async (data: string) => {
|
||||
const encoder = new TextEncoder();
|
||||
return getContentHash(encoder.encode(data));
|
||||
return getAssetContentHash(encoder.encode(data));
|
||||
};
|
||||
|
||||
const readFileAsArrayBuffer = (file: File): Promise<ArrayBuffer> =>
|
||||
@@ -71,7 +68,7 @@ const readFileAsArrayBuffer = (file: File): Promise<ArrayBuffer> =>
|
||||
|
||||
export const getSha256HashOfFile = async (file: File) => {
|
||||
const arrayBuffer = await readFileAsArrayBuffer(file);
|
||||
return getContentHash(arrayBuffer);
|
||||
return getAssetContentHash(arrayBuffer);
|
||||
};
|
||||
|
||||
export const getFileUploadFingerprint = async (
|
||||
|
||||
@@ -20,10 +20,3 @@ export type UploadingAssetContainer = {
|
||||
* Assets that can be shown in the UI
|
||||
*/
|
||||
export type AssetContainer = UploadedAssetContainer | UploadingAssetContainer;
|
||||
|
||||
export type AssetActionResponse = {
|
||||
uploadedAssets?: Array<Asset>;
|
||||
deduplicated?: boolean;
|
||||
deletedAssets?: Array<Asset>;
|
||||
errors?: string;
|
||||
};
|
||||
@@ -1,13 +1,14 @@
|
||||
import warnOnce from "warn-once";
|
||||
import invariant from "tiny-invariant";
|
||||
import { getFileNameParts, type Asset } from "@webstudio-is/sdk";
|
||||
import { assetsUploadsApiUrl } from "@webstudio-is/sdk/runtime";
|
||||
import type { AssetUploadResult } from "@webstudio-is/protocol/asset-resource-api";
|
||||
import type { AssetType, UploadTicket } from "@webstudio-is/asset-uploader";
|
||||
import { Box, toast, css, theme } from "@webstudio-is/design-system";
|
||||
import { sanitizeS3Key } from "@webstudio-is/asset-uploader";
|
||||
import { Image, wsImageLoader } from "@webstudio-is/image";
|
||||
import { restAssetsUploadPath, restAssetsPath } from "~/shared/router-utils";
|
||||
import { restAssetsUploadPath } from "~/shared/router-utils";
|
||||
import { fetch } from "~/shared/fetch.client";
|
||||
import type { AssetActionResponse } from "~/builder/shared/assets";
|
||||
import {
|
||||
$authToken,
|
||||
$uploadingFilesDataStore,
|
||||
@@ -27,6 +28,8 @@ import {
|
||||
getSha256HashOfFile,
|
||||
} from "./asset-utils";
|
||||
|
||||
type AssetActionResponse = AssetUploadResult | { errors: string };
|
||||
|
||||
const safeDeleteAssets = (assetIds: Asset["id"][], projectId: string) => {
|
||||
const currentProjectId = $project.get()?.id;
|
||||
|
||||
@@ -235,7 +238,7 @@ const submitAssetUpload = async ({
|
||||
authToken: undefined | string;
|
||||
uploadName: string;
|
||||
fileOrUrl: File | URL;
|
||||
onCompleted: (data: AssetActionResponse) => void;
|
||||
onCompleted: (data: AssetUploadResult) => void;
|
||||
onError: (error: string) => void;
|
||||
request?: typeof fetch;
|
||||
}) => {
|
||||
@@ -321,7 +324,7 @@ const createUploadTicket = async ({
|
||||
|
||||
const authHeaders = createAssetUploadHeaders(authToken);
|
||||
|
||||
const metaResponse = await request(restAssetsPath(), {
|
||||
const metaResponse = await request(assetsUploadsApiUrl, {
|
||||
method: "POST",
|
||||
body: metaFormData,
|
||||
headers: authHeaders,
|
||||
@@ -338,16 +341,16 @@ const createUploadTicket = async ({
|
||||
|
||||
const handleAfterSubmit = (
|
||||
assetId: string,
|
||||
data: AssetActionResponse,
|
||||
data: AssetUploadResult,
|
||||
projectId: string,
|
||||
folderId?: string
|
||||
) => {
|
||||
warnOnce(
|
||||
data.uploadedAssets?.length !== 1,
|
||||
data.uploadedAssets.length !== 1,
|
||||
"Expected exactly 1 uploaded asset"
|
||||
);
|
||||
|
||||
const uploadedAsset = data.uploadedAssets?.[0];
|
||||
const uploadedAsset = data.uploadedAssets[0];
|
||||
|
||||
if (uploadedAsset === undefined) {
|
||||
warnOnce(true, "An uploaded asset is undefined");
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { act } from "react-dom/test-utils";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import { encodeDataSourceVariable, type DataSource } from "@webstudio-is/sdk";
|
||||
import { $dataSourceVariables } from "~/shared/nano-states";
|
||||
import { $dataSources } from "~/shared/sync/data-stores";
|
||||
import { BindableExpressionControl } from "./bindable-expression";
|
||||
|
||||
(
|
||||
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
let root: Root | undefined;
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root?.unmount());
|
||||
root = undefined;
|
||||
document.body.innerHTML = "";
|
||||
$dataSources.set(new Map());
|
||||
$dataSourceVariables.set(new Map());
|
||||
});
|
||||
|
||||
const renderControl = ({
|
||||
expression,
|
||||
bound,
|
||||
allowBindingOverwrite,
|
||||
parseValue,
|
||||
onChangeValue,
|
||||
}: {
|
||||
expression: string;
|
||||
bound: boolean;
|
||||
allowBindingOverwrite?: boolean;
|
||||
parseValue?: (value: string) => unknown;
|
||||
onChangeValue: (value: string) => void;
|
||||
}) => {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
act(() => {
|
||||
root?.render(
|
||||
<BindableExpressionControl
|
||||
expression={expression}
|
||||
value="displayed"
|
||||
bound={bound}
|
||||
showBinding={false}
|
||||
allowBindingOverwrite={allowBindingOverwrite}
|
||||
parseValue={parseValue}
|
||||
scope={{}}
|
||||
aliases={new Map()}
|
||||
onChangeValue={onChangeValue}
|
||||
onChangeExpression={() => {}}
|
||||
onRemove={() => {}}
|
||||
renderControl={({ value, readOnly, onChangeValue }) => (
|
||||
<button
|
||||
type="button"
|
||||
disabled={readOnly}
|
||||
onClick={() => onChangeValue("changed")}
|
||||
>
|
||||
{value}
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
return container.querySelector("button") as HTMLButtonElement;
|
||||
};
|
||||
|
||||
test("updates a directly bound variable without replacing its expression", () => {
|
||||
const variable: DataSource = {
|
||||
type: "variable",
|
||||
id: "variable-id",
|
||||
name: "Variable",
|
||||
value: { type: "string", value: "initial" },
|
||||
};
|
||||
$dataSources.set(new Map([[variable.id, variable]]));
|
||||
const onChangeValue = vi.fn();
|
||||
const button = renderControl({
|
||||
expression: encodeDataSourceVariable(variable.id),
|
||||
bound: true,
|
||||
onChangeValue,
|
||||
});
|
||||
|
||||
act(() => button.click());
|
||||
|
||||
expect($dataSourceVariables.get().get(variable.id)).toBe("changed");
|
||||
expect(onChangeValue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("passes literal input changes to its consumer", () => {
|
||||
const onChangeValue = vi.fn();
|
||||
const button = renderControl({
|
||||
expression: '"displayed"',
|
||||
bound: false,
|
||||
onChangeValue,
|
||||
});
|
||||
|
||||
act(() => button.click());
|
||||
|
||||
expect(onChangeValue).toHaveBeenCalledExactlyOnceWith("changed");
|
||||
});
|
||||
|
||||
test("parses bound values before updating the variable", () => {
|
||||
const variable: DataSource = {
|
||||
type: "variable",
|
||||
id: "variable-id",
|
||||
name: "Variable",
|
||||
value: { type: "string", value: "initial" },
|
||||
};
|
||||
$dataSources.set(new Map([[variable.id, variable]]));
|
||||
const parseValue = vi.fn((value: string) => ({ value }));
|
||||
const button = renderControl({
|
||||
expression: encodeDataSourceVariable(variable.id),
|
||||
bound: true,
|
||||
parseValue,
|
||||
onChangeValue: () => {},
|
||||
});
|
||||
|
||||
act(() => button.click());
|
||||
|
||||
expect(parseValue).toHaveBeenCalledExactlyOnceWith("changed");
|
||||
expect($dataSourceVariables.get().get(variable.id)).toEqual({
|
||||
value: "changed",
|
||||
});
|
||||
});
|
||||
|
||||
test("does not parse literal input", () => {
|
||||
const parseValue = vi.fn((value: string) => ({ value }));
|
||||
const onChangeValue = vi.fn();
|
||||
const literalButton = renderControl({
|
||||
expression: '"displayed"',
|
||||
bound: false,
|
||||
parseValue,
|
||||
onChangeValue,
|
||||
});
|
||||
act(() => literalButton.click());
|
||||
expect(parseValue).not.toHaveBeenCalled();
|
||||
expect(onChangeValue).toHaveBeenCalledExactlyOnceWith("changed");
|
||||
});
|
||||
|
||||
test("can keep directly bound values read-only", () => {
|
||||
const variable: DataSource = {
|
||||
type: "variable",
|
||||
id: "variable-id",
|
||||
name: "Variable",
|
||||
value: { type: "string", value: "initial" },
|
||||
};
|
||||
$dataSources.set(new Map([[variable.id, variable]]));
|
||||
const button = renderControl({
|
||||
expression: encodeDataSourceVariable(variable.id),
|
||||
bound: true,
|
||||
allowBindingOverwrite: false,
|
||||
onChangeValue: () => {},
|
||||
});
|
||||
|
||||
expect(button.disabled).toBe(true);
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
import { useMemo, type ReactNode } from "react";
|
||||
import { useStore } from "@nanostores/react";
|
||||
import { atom, computed, type ReadableAtom } from "nanostores";
|
||||
import { decodeDataSourceVariable } from "@webstudio-is/sdk";
|
||||
import { $dataSourceVariables } from "~/shared/nano-states";
|
||||
import { $dataSources } from "~/shared/sync/data-stores";
|
||||
import {
|
||||
BindingControl,
|
||||
BindingPopover,
|
||||
type BindingVariant,
|
||||
} from "./binding-popover";
|
||||
|
||||
export type BindingState = {
|
||||
overwritable: boolean;
|
||||
variant: BindingVariant;
|
||||
};
|
||||
|
||||
const updateExpressionValue = (expression: string, value: unknown) => {
|
||||
const potentialVariableId = decodeDataSourceVariable(expression);
|
||||
if (
|
||||
potentialVariableId === undefined ||
|
||||
$dataSources.get().has(potentialVariableId) === false
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const dataSourceVariables = new Map($dataSourceVariables.get());
|
||||
dataSourceVariables.set(potentialVariableId, value);
|
||||
$dataSourceVariables.set(dataSourceVariables);
|
||||
};
|
||||
|
||||
export const updateBindableValue = <Value,>({
|
||||
expression,
|
||||
value,
|
||||
expressionValue = value,
|
||||
onChangeValue,
|
||||
}: {
|
||||
expression: string | undefined;
|
||||
value: Value;
|
||||
expressionValue?: unknown;
|
||||
onChangeValue: (value: Value) => void;
|
||||
}) => {
|
||||
if (expression !== undefined) {
|
||||
updateExpressionValue(expression, expressionValue);
|
||||
return;
|
||||
}
|
||||
onChangeValue(value);
|
||||
};
|
||||
|
||||
export const useBindingState = (expression: string | undefined) => {
|
||||
const $bindingState = useMemo((): ReadableAtom<BindingState> => {
|
||||
if (expression === undefined) {
|
||||
return atom({ overwritable: true, variant: "default" });
|
||||
}
|
||||
const potentialVariableId = decodeDataSourceVariable(expression);
|
||||
if (potentialVariableId === undefined) {
|
||||
return atom({ overwritable: false, variant: "bound" });
|
||||
}
|
||||
return computed(
|
||||
[$dataSources, $dataSourceVariables],
|
||||
(dataSources, dataSourceVariables): BindingState => {
|
||||
const dataSource = dataSources.get(potentialVariableId);
|
||||
if (dataSource?.type !== "variable") {
|
||||
return { overwritable: false, variant: "bound" };
|
||||
}
|
||||
return {
|
||||
overwritable: true,
|
||||
variant:
|
||||
dataSourceVariables.get(potentialVariableId) === undefined
|
||||
? "bound"
|
||||
: "overwritten",
|
||||
};
|
||||
}
|
||||
);
|
||||
}, [expression]);
|
||||
return useStore($bindingState);
|
||||
};
|
||||
|
||||
export const BindableExpressionControl = <Value,>({
|
||||
expression,
|
||||
value,
|
||||
bound,
|
||||
scope,
|
||||
aliases,
|
||||
validate,
|
||||
showBinding = true,
|
||||
allowBindingOverwrite = true,
|
||||
renderControl,
|
||||
parseValue = (value) => value,
|
||||
onChangeValue,
|
||||
onChangeExpression,
|
||||
onRemove,
|
||||
bindingState,
|
||||
}: {
|
||||
expression: string;
|
||||
value: Value;
|
||||
bound: boolean;
|
||||
scope: Record<string, unknown>;
|
||||
aliases: Map<string, string>;
|
||||
validate?: (value: unknown) => string | undefined;
|
||||
showBinding?: boolean;
|
||||
allowBindingOverwrite?: boolean;
|
||||
renderControl: (props: {
|
||||
value: Value;
|
||||
readOnly: boolean;
|
||||
onChangeValue: (value: Value) => void;
|
||||
}) => ReactNode;
|
||||
parseValue?: (value: Value) => unknown;
|
||||
onChangeValue: (value: Value) => void;
|
||||
onChangeExpression: (expression: string) => void;
|
||||
onRemove: (evaluatedValue: unknown) => void;
|
||||
bindingState?: BindingState;
|
||||
}) => {
|
||||
if (bindingState === undefined) {
|
||||
return (
|
||||
<BindableExpressionControlWithState
|
||||
expression={expression}
|
||||
value={value}
|
||||
bound={bound}
|
||||
scope={scope}
|
||||
aliases={aliases}
|
||||
validate={validate}
|
||||
showBinding={showBinding}
|
||||
allowBindingOverwrite={allowBindingOverwrite}
|
||||
renderControl={renderControl}
|
||||
parseValue={parseValue}
|
||||
onChangeValue={onChangeValue}
|
||||
onChangeExpression={onChangeExpression}
|
||||
onRemove={onRemove}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const { overwritable, variant } = bindingState;
|
||||
const readOnly =
|
||||
overwritable === false || (bound && allowBindingOverwrite === false);
|
||||
return (
|
||||
<BindingControl>
|
||||
{renderControl({
|
||||
value,
|
||||
readOnly,
|
||||
onChangeValue: (nextValue) => {
|
||||
updateBindableValue({
|
||||
expression: bound ? expression : undefined,
|
||||
value: nextValue,
|
||||
expressionValue: bound ? parseValue(nextValue) : nextValue,
|
||||
onChangeValue,
|
||||
});
|
||||
},
|
||||
})}
|
||||
{showBinding && (
|
||||
<BindingPopover
|
||||
scope={scope}
|
||||
aliases={aliases}
|
||||
validate={validate}
|
||||
variant={variant}
|
||||
value={expression}
|
||||
onChange={onChangeExpression}
|
||||
onRemove={onRemove}
|
||||
/>
|
||||
)}
|
||||
</BindingControl>
|
||||
);
|
||||
};
|
||||
|
||||
const BindableExpressionControlWithState = <Value,>(
|
||||
props: Omit<
|
||||
Parameters<typeof BindableExpressionControl<Value>>[0],
|
||||
"bindingState"
|
||||
>
|
||||
) => {
|
||||
const bindingState = useBindingState(
|
||||
props.bound ? props.expression : undefined
|
||||
);
|
||||
return <BindableExpressionControl {...props} bindingState={bindingState} />;
|
||||
};
|
||||
@@ -32,10 +32,8 @@ import {
|
||||
Tooltip,
|
||||
theme,
|
||||
} from "@webstudio-is/design-system";
|
||||
import {
|
||||
decodeDataSourceVariable,
|
||||
getExpressionIdentifiers,
|
||||
} from "@webstudio-is/sdk";
|
||||
import { getExpressionIdentifiers } from "@webstudio-is/expression";
|
||||
import { decodeDataSourceVariable } from "@webstudio-is/sdk";
|
||||
import { getExpressionErrorMessages } from "@webstudio-is/project-build/runtime";
|
||||
import { $dataSourceVariables, $isDesignMode } from "~/shared/nano-states";
|
||||
import {
|
||||
@@ -358,6 +356,7 @@ export const BindingPopover = ({
|
||||
return (
|
||||
<FloatingPanel
|
||||
placement="left-start"
|
||||
anchor="trigger"
|
||||
open={isOpen}
|
||||
onOpenChange={(newOpen) => {
|
||||
// handle special case for popover close
|
||||
|
||||
@@ -2,7 +2,10 @@ import { describe, test, expect } from "vitest";
|
||||
import { CompletionContext } from "@codemirror/autocomplete";
|
||||
import { EditorState } from "@codemirror/state";
|
||||
import { javascript } from "@codemirror/lang-javascript";
|
||||
import { allowedArrayMethods, allowedStringMethods } from "@webstudio-is/sdk";
|
||||
import {
|
||||
allowedArrayMethods,
|
||||
allowedStringMethods,
|
||||
} from "@webstudio-is/expression";
|
||||
import {
|
||||
completionPath,
|
||||
formatValue,
|
||||
|
||||
@@ -28,18 +28,19 @@ import {
|
||||
import { javascript } from "@codemirror/lang-javascript";
|
||||
import { textVariants, css, rawTheme } from "@webstudio-is/design-system";
|
||||
import {
|
||||
decodeDataVariableId,
|
||||
lintExpression,
|
||||
allowedStringMethods,
|
||||
allowedArrayMethods,
|
||||
getExpressionValueKind,
|
||||
} from "@webstudio-is/sdk";
|
||||
} from "@webstudio-is/expression";
|
||||
import { decodeDataVariableId } from "@webstudio-is/sdk";
|
||||
import {
|
||||
EditorContent,
|
||||
EditorDialog,
|
||||
EditorDialogButton,
|
||||
EditorDialogControl,
|
||||
type EditorApi,
|
||||
getCodeEditorCssVars,
|
||||
normalizeEditorValue,
|
||||
} from "~/shared/code-editor-base";
|
||||
import {
|
||||
@@ -519,9 +520,20 @@ const emptyScope: Scope = {};
|
||||
const emptyAliases: Aliases = new Map();
|
||||
|
||||
const wrapperStyle = css({
|
||||
// 1 line is 16px
|
||||
// set and max 20 lines
|
||||
"--ws-code-editor-max-height": "320px",
|
||||
variants: {
|
||||
size: {
|
||||
default: {
|
||||
// 1 line is 16px; show at most 20 lines.
|
||||
"--ws-code-editor-max-height": "320px",
|
||||
},
|
||||
full: {
|
||||
...getCodeEditorCssVars({ minHeight: "100%", maxHeight: "100%" }),
|
||||
height: "100%",
|
||||
"& > div, & .cm-editor, & .cm-scroller": { height: "100%" },
|
||||
},
|
||||
},
|
||||
},
|
||||
defaultVariants: { size: "default" },
|
||||
});
|
||||
|
||||
const linterTooltipTheme = EditorView.theme({
|
||||
@@ -556,16 +568,20 @@ const expressionLinter = linter((view) => {
|
||||
});
|
||||
|
||||
export const ExpressionEditor = ({
|
||||
"aria-label": ariaLabel,
|
||||
editorApiRef,
|
||||
scope = emptyScope,
|
||||
aliases = emptyAliases,
|
||||
color,
|
||||
size,
|
||||
chromeless = false,
|
||||
autoFocus = false,
|
||||
readOnly = false,
|
||||
value,
|
||||
onChange,
|
||||
onChangeComplete,
|
||||
}: {
|
||||
"aria-label"?: string;
|
||||
editorApiRef?: RefObject<undefined | EditorApi>;
|
||||
/**
|
||||
* object with variables and their data to autocomplete
|
||||
@@ -576,6 +592,8 @@ export const ExpressionEditor = ({
|
||||
*/
|
||||
aliases?: Aliases;
|
||||
color?: "error";
|
||||
size?: "default" | "full";
|
||||
chromeless?: boolean;
|
||||
autoFocus?: boolean;
|
||||
readOnly?: boolean;
|
||||
value?: string;
|
||||
@@ -663,9 +681,11 @@ export const ExpressionEditor = ({
|
||||
|
||||
const content = (
|
||||
<EditorContent
|
||||
aria-label={ariaLabel}
|
||||
editorApiRef={editorApiRef}
|
||||
extensions={extensions}
|
||||
invalid={color === "error"}
|
||||
chromeless={chromeless}
|
||||
readOnly={readOnly}
|
||||
autoFocus={autoFocus}
|
||||
value={expressionWithUnsetVariables}
|
||||
@@ -686,8 +706,12 @@ export const ExpressionEditor = ({
|
||||
/>
|
||||
);
|
||||
|
||||
if (chromeless) {
|
||||
return <div className={wrapperStyle({ size })}>{content}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={wrapperStyle.toString()}>
|
||||
<div className={wrapperStyle({ size })}>
|
||||
<EditorDialogControl>
|
||||
{content}
|
||||
<EditorDialog title="Expression Editor" content={content}>
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { act } from "react-dom/test-utils";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, expect, test } from "vitest";
|
||||
import type { QueryDefinition } from "@webstudio-is/query-builder";
|
||||
import { BindableQueryBuilder } from "./query-builder";
|
||||
|
||||
(
|
||||
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
const scope = {
|
||||
$ws$system: {
|
||||
pathname: "/blog/article",
|
||||
page: 20,
|
||||
},
|
||||
};
|
||||
const aliases = new Map([["$ws$system", "system"]]);
|
||||
let root: Root | undefined;
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root?.unmount());
|
||||
root = undefined;
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
const renderQueryBuilder = <Query extends Record<string, unknown>>({
|
||||
value,
|
||||
capabilities,
|
||||
}: {
|
||||
value: Query;
|
||||
capabilities: QueryDefinition<string, string>;
|
||||
}) => {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
act(() => {
|
||||
root?.render(
|
||||
<BindableQueryBuilder
|
||||
value={value}
|
||||
capabilities={capabilities}
|
||||
scope={scope}
|
||||
aliases={aliases}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
return container;
|
||||
};
|
||||
|
||||
test("shows public variable names without changing the stored expression", () => {
|
||||
const value = {
|
||||
where: {
|
||||
all: [
|
||||
{
|
||||
field: ["path"],
|
||||
operator: "eq",
|
||||
value: "$ws$system.pathname",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const capabilities = {
|
||||
version: 1,
|
||||
fields: [{ path: ["path"], label: "Path", types: ["string"] }],
|
||||
operators: [
|
||||
{
|
||||
value: "eq",
|
||||
label: "Equals",
|
||||
types: ["string"],
|
||||
input: { control: "expression", defaultValue: '""' },
|
||||
},
|
||||
],
|
||||
source: {
|
||||
fieldPathSchema: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
minItems: 1,
|
||||
},
|
||||
controls: [
|
||||
{
|
||||
type: "filter",
|
||||
key: "where",
|
||||
label: "Filters",
|
||||
defaultValue: { all: [] },
|
||||
combinators: ["all"],
|
||||
limits: { conditions: 8, depth: 3 },
|
||||
defaultCondition: { field: ["path"], operator: "eq" },
|
||||
},
|
||||
],
|
||||
},
|
||||
} as const satisfies QueryDefinition<string, string>;
|
||||
|
||||
const container = renderQueryBuilder({ value, capabilities });
|
||||
const editor = container.querySelector(".cm-content");
|
||||
const source = Array.from(container.querySelectorAll(".cm-content")).at(-1);
|
||||
|
||||
expect(editor?.textContent).toBe('"/blog/article"');
|
||||
expect(source?.textContent).toContain("system.pathname");
|
||||
expect(source?.textContent).not.toContain("$ws$system.pathname");
|
||||
expect(value.where.all[0].value).toBe("$ws$system.pathname");
|
||||
});
|
||||
|
||||
test("shows an evaluated value for a bound number input", () => {
|
||||
const capabilities = {
|
||||
version: 1,
|
||||
fields: [],
|
||||
operators: [],
|
||||
source: {
|
||||
fieldPathSchema: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
minItems: 1,
|
||||
},
|
||||
controls: [
|
||||
{
|
||||
type: "expression",
|
||||
key: "limit",
|
||||
label: "Limit",
|
||||
defaultValue: "20",
|
||||
input: "number",
|
||||
integer: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
} as const satisfies QueryDefinition<string, string>;
|
||||
|
||||
const container = renderQueryBuilder({
|
||||
value: { limit: "$ws$system.page" },
|
||||
capabilities,
|
||||
});
|
||||
const input = container.querySelector<HTMLInputElement>(
|
||||
'input[aria-label="Limit"]'
|
||||
);
|
||||
|
||||
expect(input?.value).toBe("20");
|
||||
expect(input?.step).toBe("1");
|
||||
expect(input?.disabled).toBe(true);
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
import { isLiteralExpression } from "@webstudio-is/expression";
|
||||
import { Grid, InputField } from "@webstudio-is/design-system";
|
||||
import { createPortal } from "react-dom";
|
||||
import {
|
||||
StructuredQueryBuilder,
|
||||
type QueryValueEditorProps,
|
||||
} from "@webstudio-is/query-builder-react";
|
||||
import type { QueryDefinition } from "@webstudio-is/query-builder";
|
||||
import { evaluateExpressionWithinScope } from "./binding-popover";
|
||||
import { BindableExpressionControl } from "./bindable-expression";
|
||||
import { ExpressionEditor, formatValue } from "./expression-editor";
|
||||
|
||||
const BoundExpression = ({
|
||||
"aria-label": label,
|
||||
value,
|
||||
scope,
|
||||
aliases,
|
||||
onChange,
|
||||
input,
|
||||
min,
|
||||
max,
|
||||
exclusiveMin,
|
||||
exclusiveMax,
|
||||
integer,
|
||||
}: QueryValueEditorProps & {
|
||||
scope: Record<string, unknown>;
|
||||
aliases: Map<string, string>;
|
||||
}) => {
|
||||
const bound = isLiteralExpression(value) === false;
|
||||
const evaluatedValue = evaluateExpressionWithinScope(value, scope);
|
||||
|
||||
if (input === "number") {
|
||||
const number = Number(evaluatedValue);
|
||||
const displayedValue = Number.isNaN(number) ? "" : String(number);
|
||||
return (
|
||||
<BindableExpressionControl
|
||||
expression={value}
|
||||
value={displayedValue}
|
||||
bound={bound}
|
||||
scope={scope}
|
||||
aliases={aliases}
|
||||
validate={(value) => {
|
||||
if (value !== undefined && typeof value !== "number") {
|
||||
return `${label} expects a number value`;
|
||||
}
|
||||
if (typeof value !== "number") {
|
||||
return;
|
||||
}
|
||||
if (integer && Number.isInteger(value) === false) {
|
||||
return `${label} expects an integer value`;
|
||||
}
|
||||
if (
|
||||
min !== undefined &&
|
||||
(exclusiveMin ? value <= min : value < min)
|
||||
) {
|
||||
return `${label} expects a value ${exclusiveMin ? "greater than" : "greater than or equal to"} ${min}`;
|
||||
}
|
||||
if (
|
||||
max !== undefined &&
|
||||
(exclusiveMax ? value >= max : value > max)
|
||||
) {
|
||||
return `${label} expects a value ${exclusiveMax ? "less than" : "less than or equal to"} ${max}`;
|
||||
}
|
||||
}}
|
||||
parseValue={Number}
|
||||
onChangeValue={onChange}
|
||||
onChangeExpression={onChange}
|
||||
onRemove={(value) => onChange(String(Number(value) || 0))}
|
||||
renderControl={({ value, readOnly, onChangeValue }) => (
|
||||
<InputField
|
||||
aria-label={label}
|
||||
type="number"
|
||||
min={min}
|
||||
max={max}
|
||||
step={integer === true ? 1 : "any"}
|
||||
disabled={readOnly}
|
||||
value={value}
|
||||
onChange={(event) => onChangeValue(event.target.value)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const displayedValue = bound ? formatValue(evaluatedValue) : value;
|
||||
return (
|
||||
<BindableExpressionControl
|
||||
expression={value}
|
||||
value={displayedValue}
|
||||
bound={bound}
|
||||
scope={scope}
|
||||
aliases={aliases}
|
||||
parseValue={(value) => evaluateExpressionWithinScope(value, {})}
|
||||
onChangeValue={onChange}
|
||||
onChangeExpression={onChange}
|
||||
onRemove={(value) => onChange(JSON.stringify(value) ?? "undefined")}
|
||||
renderControl={({ value, readOnly, onChangeValue }) => {
|
||||
const updateValue = (nextValue: string) => {
|
||||
if (bound && isLiteralExpression(nextValue) === false) {
|
||||
return;
|
||||
}
|
||||
onChangeValue(nextValue);
|
||||
};
|
||||
return (
|
||||
<div>
|
||||
<ExpressionEditor
|
||||
aria-label={label}
|
||||
readOnly={readOnly}
|
||||
value={value}
|
||||
onChange={updateValue}
|
||||
onChangeComplete={updateValue}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const BindableQueryBuilder = <
|
||||
FieldType extends string,
|
||||
Operator extends string,
|
||||
Query extends Record<string, unknown>,
|
||||
>({
|
||||
value,
|
||||
capabilities,
|
||||
scope,
|
||||
aliases,
|
||||
sourceContainer,
|
||||
sectionPaddingInline,
|
||||
onChange,
|
||||
}: {
|
||||
value: Query;
|
||||
capabilities: QueryDefinition<FieldType, Operator>;
|
||||
scope: Record<string, unknown>;
|
||||
aliases: Map<string, string>;
|
||||
sourceContainer?: Element | null;
|
||||
sectionPaddingInline?: string;
|
||||
onChange: (value: Query) => void;
|
||||
}) => (
|
||||
<StructuredQueryBuilder<FieldType, Operator, Query>
|
||||
value={value}
|
||||
capabilities={capabilities}
|
||||
sectionPaddingInline={sectionPaddingInline}
|
||||
editors={{
|
||||
expression: (props) => (
|
||||
<BoundExpression {...props} scope={scope} aliases={aliases} />
|
||||
),
|
||||
source: (props) => (
|
||||
<ExpressionEditor
|
||||
{...props}
|
||||
scope={scope}
|
||||
aliases={aliases}
|
||||
size={sourceContainer === undefined ? "default" : "full"}
|
||||
chromeless={sourceContainer !== undefined}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
renderSource={
|
||||
sourceContainer === undefined
|
||||
? undefined
|
||||
: (source) =>
|
||||
sourceContainer === null
|
||||
? null
|
||||
: createPortal(
|
||||
<Grid
|
||||
css={{
|
||||
height: "100%",
|
||||
minHeight: 0,
|
||||
gridTemplateRows: "minmax(0, 1fr) auto",
|
||||
}}
|
||||
>
|
||||
{source}
|
||||
</Grid>,
|
||||
sourceContainer
|
||||
)
|
||||
}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
@@ -1,66 +1,54 @@
|
||||
import { json, type ActionFunctionArgs } from "@remix-run/server-runtime";
|
||||
import {
|
||||
AssetRevisionConflictError,
|
||||
updateAssetContent,
|
||||
} from "@webstudio-is/asset-uploader/index.server";
|
||||
import type { Asset } from "@webstudio-is/sdk";
|
||||
import { createAssetClient } from "~/shared/asset-client";
|
||||
import { createContext } from "~/shared/context.server";
|
||||
import { assetResourceApiOperations } from "@webstudio-is/protocol/asset-resource-api";
|
||||
import { preventCrossOriginCookie } from "~/services/no-cross-origin-cookie";
|
||||
import { checkCsrf } from "~/services/csrf-session.server";
|
||||
import { parseError } from "~/shared/error/error-parse";
|
||||
import { privateNoStoreResponseHeaders } from "~/services/cache-control.server";
|
||||
import { createAssetContentLoader } from "~/services/asset-rest-route-handlers.server";
|
||||
import { ensureApiCsrf } from "~/services/api-auth.server";
|
||||
import {
|
||||
assetRestErrorResponse,
|
||||
assetRestMethodNotAllowed,
|
||||
createAssetRestRepository,
|
||||
parseAssetRestFilename,
|
||||
parseAssetRestIdentifier,
|
||||
requireAssetRestBody,
|
||||
} from "~/services/asset-rest.server";
|
||||
|
||||
export type AssetContentActionResponse = { asset: Asset } | { errors: string };
|
||||
export const loader = createAssetContentLoader();
|
||||
|
||||
export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
preventCrossOriginCookie(request);
|
||||
if (request.headers.has("x-auth-token") === false) {
|
||||
await checkCsrf(request);
|
||||
if (
|
||||
request.method.toLowerCase() !==
|
||||
assetResourceApiOperations.replaceAssetContent.method
|
||||
) {
|
||||
return assetRestMethodNotAllowed([
|
||||
assetResourceApiOperations.replaceAssetContent,
|
||||
]);
|
||||
}
|
||||
await ensureApiCsrf(request);
|
||||
|
||||
try {
|
||||
if (request.method !== "PUT" || request.body === null) {
|
||||
return json(
|
||||
{ errors: "Method not allowed" } satisfies AssetContentActionResponse,
|
||||
{ status: 405, headers: privateNoStoreResponseHeaders }
|
||||
);
|
||||
}
|
||||
if (params.assetId === undefined) {
|
||||
throw new Error("Asset id is required");
|
||||
}
|
||||
const assetId = parseAssetRestIdentifier(params.assetId);
|
||||
|
||||
const url = new URL(request.url);
|
||||
const projectId = url.searchParams.get("projectId");
|
||||
const expectedName = url.searchParams.get("expectedName");
|
||||
if (projectId === null || expectedName === null) {
|
||||
throw new Error("Project id and expected asset name are required");
|
||||
}
|
||||
|
||||
const context = await createContext(request);
|
||||
const asset = await updateAssetContent(
|
||||
{
|
||||
assetId: params.assetId,
|
||||
projectId,
|
||||
expectedName,
|
||||
data: request.body,
|
||||
},
|
||||
createAssetClient(),
|
||||
context
|
||||
const expectedName = parseAssetRestFilename(
|
||||
url.searchParams.get("expectedName")
|
||||
);
|
||||
return json({ asset } satisfies AssetContentActionResponse, {
|
||||
headers: privateNoStoreResponseHeaders,
|
||||
|
||||
const asset = await (
|
||||
await createAssetRestRepository(request, "edit")
|
||||
).updateContent({
|
||||
assetId,
|
||||
expectedName,
|
||||
data: requireAssetRestBody(request),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return json(
|
||||
{ asset },
|
||||
{
|
||||
errors: parseError(error).message,
|
||||
} satisfies AssetContentActionResponse,
|
||||
{
|
||||
status: error instanceof AssetRevisionConflictError ? 409 : 400,
|
||||
headers: privateNoStoreResponseHeaders,
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
return assetRestErrorResponse(error);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
import { json, type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { assetResourceApiOperations } from "@webstudio-is/protocol/asset-resource-api";
|
||||
import { privateNoStoreResponseHeaders } from "~/services/cache-control.server";
|
||||
import { createAssetAction } from "~/services/asset-rest-route-handlers.server";
|
||||
import { preventCrossOriginCookie } from "~/services/no-cross-origin-cookie";
|
||||
import {
|
||||
assetRestErrorResponse,
|
||||
assetRestMethodNotAllowed,
|
||||
createAssetRestRepository,
|
||||
parseAssetRestIdentifier,
|
||||
} from "~/services/asset-rest.server";
|
||||
|
||||
export const action = createAssetAction();
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
preventCrossOriginCookie(request);
|
||||
if (
|
||||
request.method.toLowerCase() !== assetResourceApiOperations.getAsset.method
|
||||
) {
|
||||
return assetRestMethodNotAllowed([assetResourceApiOperations.getAsset]);
|
||||
}
|
||||
try {
|
||||
const assetId = parseAssetRestIdentifier(params.assetId);
|
||||
const asset = await (await createAssetRestRepository(request)).get(assetId);
|
||||
return json({ asset }, { headers: privateNoStoreResponseHeaders });
|
||||
} catch (error) {
|
||||
return assetRestErrorResponse(error);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createAssetDescriptionLoader } from "~/shared/$resources/assets-description.server";
|
||||
|
||||
export const loader = createAssetDescriptionLoader({
|
||||
createValue: (catalog) => catalog,
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import {
|
||||
json,
|
||||
type ActionFunctionArgs,
|
||||
type LoaderFunctionArgs,
|
||||
} from "@remix-run/server-runtime";
|
||||
import {
|
||||
assetFolderUpdateRequest,
|
||||
assetResourceApiOperations,
|
||||
} from "@webstudio-is/protocol/asset-resource-api";
|
||||
import { ensureApiCsrf } from "~/services/api-auth.server";
|
||||
import {
|
||||
assetRestErrorResponse,
|
||||
assetRestMethodNotAllowed,
|
||||
createAssetRestRepository,
|
||||
parseAssetRestIdentifier,
|
||||
readAssetRestJson,
|
||||
} from "~/services/asset-rest.server";
|
||||
import { privateNoStoreResponseHeaders } from "~/services/cache-control.server";
|
||||
import { preventCrossOriginCookie } from "~/services/no-cross-origin-cookie";
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
preventCrossOriginCookie(request);
|
||||
if (
|
||||
request.method.toLowerCase() !==
|
||||
assetResourceApiOperations.getAssetFolder.method
|
||||
) {
|
||||
return assetRestMethodNotAllowed([
|
||||
assetResourceApiOperations.getAssetFolder,
|
||||
]);
|
||||
}
|
||||
try {
|
||||
const folderId = parseAssetRestIdentifier(params.folderId);
|
||||
const folder = await (
|
||||
await createAssetRestRepository(request, "view")
|
||||
).getFolder(folderId);
|
||||
return json({ folder }, { headers: privateNoStoreResponseHeaders });
|
||||
} catch (error) {
|
||||
return assetRestErrorResponse(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
preventCrossOriginCookie(request);
|
||||
const method = request.method.toLowerCase();
|
||||
if (
|
||||
method !== assetResourceApiOperations.updateAssetFolder.method &&
|
||||
method !== assetResourceApiOperations.deleteAssetFolder.method
|
||||
) {
|
||||
return assetRestMethodNotAllowed([
|
||||
assetResourceApiOperations.updateAssetFolder,
|
||||
assetResourceApiOperations.deleteAssetFolder,
|
||||
]);
|
||||
}
|
||||
await ensureApiCsrf(request);
|
||||
try {
|
||||
const folderId = parseAssetRestIdentifier(params.folderId);
|
||||
const repository = await createAssetRestRepository(request, "edit");
|
||||
if (method === assetResourceApiOperations.updateAssetFolder.method) {
|
||||
const folder = await repository.updateFolder(
|
||||
folderId,
|
||||
assetFolderUpdateRequest.parse(await readAssetRestJson(request))
|
||||
);
|
||||
return json({ folder }, { headers: privateNoStoreResponseHeaders });
|
||||
}
|
||||
await repository.deleteFolder(folderId);
|
||||
return new Response(null, {
|
||||
status: 204,
|
||||
headers: privateNoStoreResponseHeaders,
|
||||
});
|
||||
} catch (error) {
|
||||
return assetRestErrorResponse(error);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
import {
|
||||
json,
|
||||
type ActionFunctionArgs,
|
||||
type LoaderFunctionArgs,
|
||||
} from "@remix-run/server-runtime";
|
||||
import {
|
||||
assetFolderCreateRequest,
|
||||
assetResourceApiOperations,
|
||||
} from "@webstudio-is/protocol/asset-resource-api";
|
||||
import { ensureApiCsrf } from "~/services/api-auth.server";
|
||||
import {
|
||||
assetRestErrorResponse,
|
||||
assetRestMethodNotAllowed,
|
||||
createAssetRestRepository,
|
||||
readAssetRestJson,
|
||||
} from "~/services/asset-rest.server";
|
||||
import { privateNoStoreResponseHeaders } from "~/services/cache-control.server";
|
||||
import { preventCrossOriginCookie } from "~/services/no-cross-origin-cookie";
|
||||
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
preventCrossOriginCookie(request);
|
||||
if (
|
||||
request.method.toLowerCase() !==
|
||||
assetResourceApiOperations.listAssetFolders.method
|
||||
) {
|
||||
return assetRestMethodNotAllowed([
|
||||
assetResourceApiOperations.listAssetFolders,
|
||||
]);
|
||||
}
|
||||
try {
|
||||
const folders = await (
|
||||
await createAssetRestRepository(request, "view")
|
||||
).listFolders();
|
||||
return json({ folders }, { headers: privateNoStoreResponseHeaders });
|
||||
} catch (error) {
|
||||
return assetRestErrorResponse(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
preventCrossOriginCookie(request);
|
||||
if (
|
||||
request.method.toLowerCase() !==
|
||||
assetResourceApiOperations.createAssetFolder.method
|
||||
) {
|
||||
return assetRestMethodNotAllowed([
|
||||
assetResourceApiOperations.createAssetFolder,
|
||||
]);
|
||||
}
|
||||
await ensureApiCsrf(request);
|
||||
try {
|
||||
const folder = await (
|
||||
await createAssetRestRepository(request, "edit")
|
||||
).createFolder(
|
||||
assetFolderCreateRequest.parse(await readAssetRestJson(request))
|
||||
);
|
||||
return json(
|
||||
{ folder },
|
||||
{ status: 201, headers: privateNoStoreResponseHeaders }
|
||||
);
|
||||
} catch (error) {
|
||||
return assetRestErrorResponse(error);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
import { createAssetIndexRefreshAction } from "~/services/asset-rest-route-handlers.server";
|
||||
|
||||
export const action = createAssetIndexRefreshAction();
|
||||
@@ -0,0 +1,13 @@
|
||||
import { createAssetResourceOpenApi } from "@webstudio-is/protocol/asset-resource-api";
|
||||
import { createStaticAssetDescriptionLoader } from "~/shared/$resources/assets-description.server";
|
||||
import { builderSessionCookieName } from "~/services/builder-session.server";
|
||||
import { assetsQuerySchemaApiUrl } from "@webstudio-is/sdk/runtime";
|
||||
|
||||
export const loader = createStaticAssetDescriptionLoader({
|
||||
createValue: ({ projectId }) =>
|
||||
createAssetResourceOpenApi({
|
||||
builderSessionCookieName,
|
||||
querySchemaReference: `${assetsQuerySchemaApiUrl}?projectId=${encodeURIComponent(projectId)}`,
|
||||
}),
|
||||
contentType: "application/vnd.oai.openapi+json;version=3.1",
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import { createAssetQueryJsonSchema } from "@webstudio-is/protocol/asset-resource-api";
|
||||
import { createAssetDescriptionLoader } from "~/shared/$resources/assets-description.server";
|
||||
|
||||
export const loader = createAssetDescriptionLoader({
|
||||
createValue: (catalog) => createAssetQueryJsonSchema({ catalog }),
|
||||
contentType: "application/schema+json",
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { ActionFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { assetResourceApiOperations } from "@webstudio-is/protocol/asset-resource-api";
|
||||
import { executeAssetQuery } from "../shared/$resources/assets-query.server";
|
||||
import { assetRestMethodNotAllowed } from "~/services/asset-rest.server";
|
||||
|
||||
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
if (
|
||||
request.method.toLowerCase() !==
|
||||
assetResourceApiOperations.queryAssets.method
|
||||
) {
|
||||
return assetRestMethodNotAllowed([assetResourceApiOperations.queryAssets]);
|
||||
}
|
||||
return await executeAssetQuery({ request, resourceRequest: request });
|
||||
};
|
||||
@@ -1,72 +1,27 @@
|
||||
import { json, type ActionFunctionArgs } from "@remix-run/server-runtime";
|
||||
import {
|
||||
createUploadTicket,
|
||||
isContentHash,
|
||||
} from "@webstudio-is/asset-uploader/index.server";
|
||||
import isValidFilename from "valid-filename";
|
||||
import { createContext } from "~/shared/context.server";
|
||||
import { preventCrossOriginCookie } from "~/services/no-cross-origin-cookie";
|
||||
import { checkCsrf } from "~/services/csrf-session.server";
|
||||
import { parseError } from "~/shared/error/error-parse";
|
||||
import { json, type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { assetResourceApiOperations } from "@webstudio-is/protocol/asset-resource-api";
|
||||
import { privateNoStoreResponseHeaders } from "~/services/cache-control.server";
|
||||
import {
|
||||
assetRestErrorResponse,
|
||||
assetRestMethodNotAllowed,
|
||||
createAssetRestRepository,
|
||||
} from "~/services/asset-rest.server";
|
||||
import { preventCrossOriginCookie } from "~/services/no-cross-origin-cookie";
|
||||
|
||||
export const loader = async () => {
|
||||
return json(
|
||||
{ errors: "Method not allowed" },
|
||||
{ status: 405, headers: privateNoStoreResponseHeaders }
|
||||
);
|
||||
};
|
||||
|
||||
export const action = async (props: ActionFunctionArgs) => {
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
preventCrossOriginCookie(request);
|
||||
if (
|
||||
request.method.toLowerCase() !==
|
||||
assetResourceApiOperations.listAssets.method
|
||||
) {
|
||||
return assetRestMethodNotAllowed([assetResourceApiOperations.listAssets]);
|
||||
}
|
||||
try {
|
||||
preventCrossOriginCookie(props.request);
|
||||
await checkCsrf(props.request);
|
||||
|
||||
const { request } = props;
|
||||
|
||||
const context = await createContext(request);
|
||||
|
||||
if (request.method === "POST") {
|
||||
const formData = await request.formData();
|
||||
const projectId = formData.get("projectId");
|
||||
const type = formData.get("type");
|
||||
const filename = formData.get("filename");
|
||||
const displayFilename = formData.get("displayFilename");
|
||||
const contentHash = formData.get("contentHash");
|
||||
if (
|
||||
typeof projectId !== "string" ||
|
||||
typeof type !== "string" ||
|
||||
typeof filename !== "string" ||
|
||||
(contentHash !== null && isContentHash(contentHash) === false) ||
|
||||
(displayFilename !== null &&
|
||||
(typeof displayFilename !== "string" ||
|
||||
isValidFilename(displayFilename) === false))
|
||||
) {
|
||||
throw Error("Project id, type or filename are invalid");
|
||||
}
|
||||
const ticket = await createUploadTicket(
|
||||
{
|
||||
projectId,
|
||||
type,
|
||||
filename,
|
||||
displayFilename: displayFilename ?? undefined,
|
||||
contentHash: contentHash ?? undefined,
|
||||
},
|
||||
context
|
||||
);
|
||||
return json(ticket, { headers: privateNoStoreResponseHeaders });
|
||||
}
|
||||
|
||||
return json(
|
||||
{ errors: "Method not allowed" },
|
||||
{ status: 405, headers: privateNoStoreResponseHeaders }
|
||||
);
|
||||
const assets = await (
|
||||
await createAssetRestRepository(request, "view")
|
||||
).list();
|
||||
return json({ assets }, { headers: privateNoStoreResponseHeaders });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
||||
return json(
|
||||
{ errors: parseError(error).message },
|
||||
{ headers: privateNoStoreResponseHeaders }
|
||||
);
|
||||
return assetRestErrorResponse(error);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
import { json, type ActionFunctionArgs } from "@remix-run/server-runtime";
|
||||
import isValidFilename from "valid-filename";
|
||||
import {
|
||||
assetResourceApiOperations,
|
||||
assetUploadReservationRequest,
|
||||
} from "@webstudio-is/protocol/asset-resource-api";
|
||||
import { preventCrossOriginCookie } from "~/services/no-cross-origin-cookie";
|
||||
import { privateNoStoreResponseHeaders } from "~/services/cache-control.server";
|
||||
import { ensureApiCsrf } from "~/services/api-auth.server";
|
||||
import {
|
||||
AssetRestRequestError,
|
||||
assetRestErrorResponse,
|
||||
assetRestMethodNotAllowed,
|
||||
createAssetRestRepositoryForProject,
|
||||
readAssetRestFormData,
|
||||
readAssetRestJson,
|
||||
} from "~/services/asset-rest.server";
|
||||
|
||||
// Keep upload commands under an explicit collection so read-only Assets API
|
||||
// operations can use stable names without colliding with uploaded filenames.
|
||||
|
||||
export const loader = async () => {
|
||||
return assetRestMethodNotAllowed([
|
||||
assetResourceApiOperations.reserveAssetUpload,
|
||||
]);
|
||||
};
|
||||
|
||||
export const action = async (props: ActionFunctionArgs) => {
|
||||
const { request } = props;
|
||||
preventCrossOriginCookie(request);
|
||||
if (
|
||||
request.method.toLowerCase() !==
|
||||
assetResourceApiOperations.reserveAssetUpload.method
|
||||
) {
|
||||
return assetRestMethodNotAllowed([
|
||||
assetResourceApiOperations.reserveAssetUpload,
|
||||
]);
|
||||
}
|
||||
await ensureApiCsrf(request);
|
||||
try {
|
||||
const contentType = request.headers.get("content-type") ?? "";
|
||||
const rawInput = contentType.includes("application/json")
|
||||
? await readAssetRestJson(request)
|
||||
: Object.fromEntries(await readAssetRestFormData(request));
|
||||
const input = assetUploadReservationRequest.parse(rawInput);
|
||||
if (
|
||||
input.displayFilename !== undefined &&
|
||||
isValidFilename(input.displayFilename) === false
|
||||
) {
|
||||
throw new AssetRestRequestError(
|
||||
"Project id, type or filename are invalid"
|
||||
);
|
||||
}
|
||||
const repository = await createAssetRestRepositoryForProject(
|
||||
request,
|
||||
input.projectId,
|
||||
"edit"
|
||||
);
|
||||
const ticket = await repository.createUploadTicket({
|
||||
type: input.type,
|
||||
filename: input.filename,
|
||||
displayFilename: input.displayFilename,
|
||||
description: input.description,
|
||||
folderId: input.folderId,
|
||||
contentHash: input.contentHash,
|
||||
});
|
||||
return json(ticket, { headers: privateNoStoreResponseHeaders });
|
||||
} catch (error) {
|
||||
return assetRestErrorResponse(error);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,186 @@
|
||||
import { json, type ActionFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { arrayBuffer } from "node:stream/consumers";
|
||||
import {
|
||||
createSizeLimiter,
|
||||
assetDataOverride,
|
||||
PostgresAssetRepository,
|
||||
} from "@webstudio-is/asset-uploader/server";
|
||||
import { isAssetFileName } from "@webstudio-is/protocol";
|
||||
import {
|
||||
assetResourceApiOperations,
|
||||
type AssetUploadResult,
|
||||
} from "@webstudio-is/protocol/asset-resource-api";
|
||||
import { getAssetContentHash, type Asset } from "@webstudio-is/sdk";
|
||||
import {
|
||||
createAssetClient,
|
||||
getMaxAssetUploadSize,
|
||||
} from "~/shared/asset-client";
|
||||
import { preventCrossOriginCookie } from "~/services/no-cross-origin-cookie";
|
||||
import { privateNoStoreResponseHeaders } from "~/services/cache-control.server";
|
||||
import { ensureApiCsrf } from "~/services/api-auth.server";
|
||||
import { createContext } from "~/shared/context.server";
|
||||
import {
|
||||
AssetRestRequestError,
|
||||
assetRestErrorResponse,
|
||||
assetRestMethodNotAllowed,
|
||||
createAssetRestRepositoryForProject,
|
||||
parseAssetRestDescription,
|
||||
parseAssetRestFilename,
|
||||
parseAssetRestIdentifier,
|
||||
parseAssetRestMetadataHeader,
|
||||
requireAssetRestBody,
|
||||
} from "~/services/asset-rest.server";
|
||||
import {
|
||||
getAssetInfoFallback,
|
||||
getBrowserAssetFormat,
|
||||
parseAssetType,
|
||||
} from "@webstudio-is/project-build/runtime";
|
||||
import { getBrowserUploadBody } from "~/services/asset-upload.server";
|
||||
|
||||
// The explicit uploads segment prevents asset names such as `query` from
|
||||
// shadowing the read-only Assets API.
|
||||
|
||||
const assetUploadResponse = ({
|
||||
asset,
|
||||
deduplicated,
|
||||
}: {
|
||||
asset: Asset;
|
||||
deduplicated: boolean;
|
||||
}) => {
|
||||
return json(
|
||||
{
|
||||
uploadedAssets: [asset],
|
||||
deduplicated,
|
||||
} satisfies AssetUploadResult,
|
||||
{
|
||||
headers: privateNoStoreResponseHeaders,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const createRequestBody = (data: Uint8Array) =>
|
||||
new Blob([data as Uint8Array<ArrayBuffer>]).stream();
|
||||
|
||||
const readRequestBody = async (
|
||||
body: ReadableStream<Uint8Array>,
|
||||
name: string
|
||||
) =>
|
||||
new Uint8Array(
|
||||
await arrayBuffer(
|
||||
createSizeLimiter(
|
||||
getMaxAssetUploadSize(),
|
||||
name
|
||||
)(body as unknown as AsyncIterable<Uint8Array>)
|
||||
)
|
||||
);
|
||||
|
||||
export const action = async (props: ActionFunctionArgs) => {
|
||||
preventCrossOriginCookie(props.request);
|
||||
|
||||
const { request, params } = props;
|
||||
if (
|
||||
request.method.toLowerCase() !==
|
||||
assetResourceApiOperations.uploadAssetContent.method
|
||||
) {
|
||||
return assetRestMethodNotAllowed([
|
||||
assetResourceApiOperations.uploadAssetContent,
|
||||
]);
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
const projectId = url.searchParams.get("projectId");
|
||||
const folderIdValue = url.searchParams.get("folderId");
|
||||
const rawAssetMeta = request.headers.get("x-webstudio-asset-meta");
|
||||
const rawAssetType = url.searchParams.get("type");
|
||||
const isApiUpload = projectId !== null || rawAssetType !== null;
|
||||
|
||||
await ensureApiCsrf(request);
|
||||
|
||||
try {
|
||||
if (params.name === undefined) {
|
||||
throw new AssetRestRequestError("Asset name is required");
|
||||
}
|
||||
const requestBody = requireAssetRestBody(request);
|
||||
|
||||
const assetType = parseAssetType(rawAssetType);
|
||||
|
||||
if (isApiUpload) {
|
||||
const filename = parseAssetRestFilename(params.name);
|
||||
if (isAssetFileName(filename) === false) {
|
||||
throw new AssetRestRequestError("Asset name is invalid");
|
||||
}
|
||||
const parsedProjectId = parseAssetRestIdentifier(projectId);
|
||||
const folderId =
|
||||
folderIdValue === null
|
||||
? undefined
|
||||
: parseAssetRestIdentifier(folderIdValue);
|
||||
const description = parseAssetRestDescription(
|
||||
request.headers.get("x-webstudio-asset-description")
|
||||
);
|
||||
if (assetType === undefined) {
|
||||
throw new AssetRestRequestError("Asset type is invalid");
|
||||
}
|
||||
const assetInfoFallback = getAssetInfoFallback({
|
||||
format:
|
||||
assetType === "image"
|
||||
? (url.searchParams.get("format") ?? undefined)
|
||||
: undefined,
|
||||
searchParams: url.searchParams,
|
||||
});
|
||||
const assetInfoOverride = assetDataOverride.parse({
|
||||
format: url.searchParams.get("format") ?? undefined,
|
||||
meta: parseAssetRestMetadataHeader(rawAssetMeta),
|
||||
});
|
||||
|
||||
const repository = await createAssetRestRepositoryForProject(
|
||||
request,
|
||||
parsedProjectId,
|
||||
"edit"
|
||||
);
|
||||
const data = await readRequestBody(requestBody, params.name);
|
||||
const force = url.searchParams.get("force") === "true";
|
||||
const ticket = await repository.createUploadTicket({
|
||||
type: assetType,
|
||||
filename,
|
||||
description,
|
||||
folderId,
|
||||
contentHash: force ? undefined : await getAssetContentHash(data),
|
||||
});
|
||||
if (ticket.deduplicated) {
|
||||
return assetUploadResponse({ asset: ticket.asset, deduplicated: true });
|
||||
}
|
||||
const asset = await repository.completeUpload({
|
||||
name: ticket.name,
|
||||
data: createRequestBody(data),
|
||||
assetInfoFallback,
|
||||
assetDataOverride: assetInfoOverride,
|
||||
assetId: ticket.assetId,
|
||||
});
|
||||
return assetUploadResponse({ asset, deduplicated: false });
|
||||
}
|
||||
|
||||
const contentType = request.headers.get("Content-Type");
|
||||
const body = await getBrowserUploadBody(request, contentType);
|
||||
const format = getBrowserAssetFormat({ contentType, name: params.name });
|
||||
const assetInfoFallback = getAssetInfoFallback({
|
||||
format,
|
||||
searchParams: url.searchParams,
|
||||
});
|
||||
|
||||
const context = await createContext(request);
|
||||
const assetClient = createAssetClient();
|
||||
const repository = await PostgresAssetRepository.forUpload({
|
||||
name: params.name,
|
||||
context,
|
||||
assetStore: assetClient,
|
||||
});
|
||||
const asset = await repository.completeUpload({
|
||||
name: params.name,
|
||||
data: body,
|
||||
assetInfoFallback,
|
||||
});
|
||||
return assetUploadResponse({ asset, deduplicated: false });
|
||||
} catch (error) {
|
||||
return assetRestErrorResponse(error);
|
||||
}
|
||||
};
|
||||
@@ -1,222 +0,0 @@
|
||||
import { json, type ActionFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { arrayBuffer } from "node:stream/consumers";
|
||||
import {
|
||||
createUploadTicket,
|
||||
createSizeLimiter,
|
||||
assetDataOverride,
|
||||
getContentHash,
|
||||
uploadFile,
|
||||
type UploadErrorCleanup,
|
||||
} from "@webstudio-is/asset-uploader/index.server";
|
||||
import { isAssetFileName } from "@webstudio-is/protocol";
|
||||
import type { Asset } from "@webstudio-is/sdk";
|
||||
import type { AssetActionResponse } from "~/builder/shared/assets";
|
||||
import {
|
||||
createAssetClient,
|
||||
getMaxAssetUploadSize,
|
||||
} from "~/shared/asset-client";
|
||||
import { createContext } from "~/shared/context.server";
|
||||
import { preventCrossOriginCookie } from "~/services/no-cross-origin-cookie";
|
||||
import { checkCsrf } from "~/services/csrf-session.server";
|
||||
import { parseError } from "~/shared/error/error-parse";
|
||||
import { privateNoStoreResponseHeaders } from "~/services/cache-control.server";
|
||||
import { assertApiProjectPermit } from "~/services/api-permits.server";
|
||||
import {
|
||||
getAssetInfoFallback,
|
||||
getBrowserAssetFormat,
|
||||
parseAssetType,
|
||||
type AssetInfoFallback,
|
||||
} from "@webstudio-is/project-build/runtime";
|
||||
import { getBrowserUploadBody } from "~/services/asset-upload.server";
|
||||
|
||||
const createAssetUploadResponse = async ({
|
||||
body,
|
||||
context,
|
||||
name,
|
||||
assetInfoFallback,
|
||||
assetInfoOverride,
|
||||
onUploadError,
|
||||
}: {
|
||||
body: ReadableStream<Uint8Array>;
|
||||
context: Awaited<ReturnType<typeof createContext>>;
|
||||
name: string;
|
||||
assetInfoFallback: AssetInfoFallback;
|
||||
assetInfoOverride?: {
|
||||
format?: string;
|
||||
meta?: Record<string, unknown>;
|
||||
};
|
||||
onUploadError?: UploadErrorCleanup;
|
||||
}) => {
|
||||
const asset = await uploadFile(
|
||||
name,
|
||||
body,
|
||||
createAssetClient(),
|
||||
context,
|
||||
assetInfoFallback,
|
||||
assetInfoOverride,
|
||||
onUploadError
|
||||
);
|
||||
return json(
|
||||
{
|
||||
uploadedAssets: [asset],
|
||||
deduplicated: false,
|
||||
} satisfies AssetActionResponse,
|
||||
{
|
||||
headers: privateNoStoreResponseHeaders,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const createDeduplicatedAssetResponse = (asset: Asset) => {
|
||||
return json(
|
||||
{
|
||||
uploadedAssets: [asset],
|
||||
deduplicated: true,
|
||||
} satisfies AssetActionResponse,
|
||||
{
|
||||
headers: privateNoStoreResponseHeaders,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const createRequestBody = (data: Uint8Array) =>
|
||||
new Blob([data as Uint8Array<ArrayBuffer>]).stream();
|
||||
|
||||
const readRequestBody = async (
|
||||
body: ReadableStream<Uint8Array>,
|
||||
name: string
|
||||
) =>
|
||||
new Uint8Array(
|
||||
await arrayBuffer(
|
||||
createSizeLimiter(
|
||||
getMaxAssetUploadSize(),
|
||||
name
|
||||
)(body as unknown as AsyncIterable<Uint8Array>)
|
||||
)
|
||||
);
|
||||
|
||||
const createApiUploadErrorCleanup =
|
||||
(assetId: string, projectId: string): UploadErrorCleanup =>
|
||||
async (name, context) => {
|
||||
const deleteAsset = await context.postgrest.client
|
||||
.from("Asset")
|
||||
.delete()
|
||||
.eq("id", assetId)
|
||||
.eq("projectId", projectId);
|
||||
if (deleteAsset.error) {
|
||||
throw deleteAsset.error;
|
||||
}
|
||||
const deleteFile = await context.postgrest.client
|
||||
.from("File")
|
||||
.delete()
|
||||
.eq("name", name);
|
||||
if (deleteFile.error) {
|
||||
throw deleteFile.error;
|
||||
}
|
||||
};
|
||||
|
||||
export const action = async (props: ActionFunctionArgs) => {
|
||||
preventCrossOriginCookie(props.request);
|
||||
|
||||
const { request, params } = props;
|
||||
|
||||
if (params.name === undefined) {
|
||||
throw new Error("Name is undefined");
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
const projectId = url.searchParams.get("projectId");
|
||||
const folderId = url.searchParams.get("folderId") ?? undefined;
|
||||
const description =
|
||||
request.headers.get("x-webstudio-asset-description") ?? undefined;
|
||||
const rawAssetMeta = request.headers.get("x-webstudio-asset-meta");
|
||||
const rawAssetType = url.searchParams.get("type");
|
||||
const isApiUpload = projectId !== null || rawAssetType !== null;
|
||||
|
||||
if (isApiUpload === false) {
|
||||
await checkCsrf(request);
|
||||
}
|
||||
|
||||
try {
|
||||
if (request.method !== "POST" || request.body === null) {
|
||||
return json(
|
||||
{ errors: "Method not allowed" } satisfies AssetActionResponse,
|
||||
{ status: 405, headers: privateNoStoreResponseHeaders }
|
||||
);
|
||||
}
|
||||
|
||||
const assetType = parseAssetType(rawAssetType);
|
||||
|
||||
if (isApiUpload) {
|
||||
if (isAssetFileName(params.name) === false) {
|
||||
throw new Error("Asset name is invalid");
|
||||
}
|
||||
if (projectId === null) {
|
||||
throw new Error("Project id is required");
|
||||
}
|
||||
if (assetType === undefined) {
|
||||
throw new Error("Asset type is invalid");
|
||||
}
|
||||
const assetInfoFallback = getAssetInfoFallback({
|
||||
format:
|
||||
assetType === "image"
|
||||
? (url.searchParams.get("format") ?? undefined)
|
||||
: undefined,
|
||||
searchParams: url.searchParams,
|
||||
});
|
||||
const assetInfoOverride = assetDataOverride.parse({
|
||||
format: url.searchParams.get("format") ?? undefined,
|
||||
meta: rawAssetMeta === null ? undefined : JSON.parse(rawAssetMeta),
|
||||
});
|
||||
|
||||
const context = await createContext(request);
|
||||
await assertApiProjectPermit(context, projectId, "build");
|
||||
const data = await readRequestBody(request.body, params.name);
|
||||
const force = url.searchParams.get("force") === "true";
|
||||
const ticket = await createUploadTicket(
|
||||
{
|
||||
projectId,
|
||||
type: assetType,
|
||||
filename: params.name,
|
||||
description,
|
||||
folderId,
|
||||
contentHash: force ? undefined : await getContentHash(data),
|
||||
},
|
||||
context
|
||||
);
|
||||
if (ticket.deduplicated) {
|
||||
return createDeduplicatedAssetResponse(ticket.asset);
|
||||
}
|
||||
return await createAssetUploadResponse({
|
||||
body: createRequestBody(data),
|
||||
context,
|
||||
name: ticket.name,
|
||||
assetInfoFallback,
|
||||
assetInfoOverride,
|
||||
onUploadError: createApiUploadErrorCleanup(ticket.assetId, projectId),
|
||||
});
|
||||
}
|
||||
|
||||
const contentType = request.headers.get("Content-Type");
|
||||
const body = await getBrowserUploadBody(request, contentType);
|
||||
const format = getBrowserAssetFormat({ contentType, name: params.name });
|
||||
const assetInfoFallback = getAssetInfoFallback({
|
||||
format,
|
||||
searchParams: url.searchParams,
|
||||
});
|
||||
|
||||
const context = await createContext(request);
|
||||
return await createAssetUploadResponse({
|
||||
body,
|
||||
context,
|
||||
name: params.name,
|
||||
assetInfoFallback,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return json(
|
||||
{ errors: parseError(error).message } satisfies AssetActionResponse,
|
||||
{ headers: privateNoStoreResponseHeaders }
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -2,9 +2,12 @@ import { z } from "zod";
|
||||
import { type ActionFunctionArgs, data, json } from "@remix-run/server-runtime";
|
||||
import { type ResourceRequest, resourceRequest } from "@webstudio-is/sdk";
|
||||
import { isLocalResource, loadResource } from "@webstudio-is/sdk/runtime";
|
||||
import { parseBuilderUrl } from "@webstudio-is/protocol";
|
||||
import { loader as siteMapLoader } from "../shared/$resources/sitemap.xml.server";
|
||||
import { loader as currentDateLoader } from "../shared/$resources/current-date.server";
|
||||
import { loader as assetsLoader } from "../shared/$resources/assets.server";
|
||||
import { loader as assetsFieldCatalogLoader } from "./rest.assets.field-catalog";
|
||||
import { executeAssetQuery } from "../shared/$resources/assets-query.server";
|
||||
import { loader as assetsOpenApiLoader } from "./rest.assets.openapi[.]json";
|
||||
import { preventCrossOriginCookie } from "~/services/no-cross-origin-cookie";
|
||||
import { checkCsrf } from "~/services/csrf-session.server";
|
||||
import { getResourceKey } from "~/shared/resources";
|
||||
@@ -29,13 +32,23 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
}
|
||||
|
||||
if (isLocalResource(input, "assets")) {
|
||||
return assetsLoader({ request });
|
||||
const resourceRequest = new Request(new URL(input, request.url), init);
|
||||
return executeAssetQuery({ request, resourceRequest });
|
||||
}
|
||||
|
||||
if (isLocalResource(input, "assets/field-catalog")) {
|
||||
return assetsFieldCatalogLoader({ request });
|
||||
}
|
||||
|
||||
if (isLocalResource(input, "assets/openapi.json")) {
|
||||
return assetsOpenApiLoader({ request });
|
||||
}
|
||||
|
||||
return fetch(input, init);
|
||||
};
|
||||
|
||||
const requestJson = await request.json();
|
||||
const { sourceOrigin } = parseBuilderUrl(request.url);
|
||||
const requestList = z.array(z.unknown()).safeParse(requestJson);
|
||||
|
||||
if (requestList.success === false) {
|
||||
@@ -62,7 +75,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
}
|
||||
return [
|
||||
getResourceKey(resource.data),
|
||||
await loadResource(customFetch, resource.data),
|
||||
await loadResource(customFetch, resource.data, sourceOrigin),
|
||||
];
|
||||
})
|
||||
);
|
||||
|
||||
@@ -3,27 +3,17 @@ import type { ActionFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { createContext, isServiceAuthorization } from "~/shared/context.server";
|
||||
import { appRouter } from "~/services/trcp-router.server";
|
||||
import { preventCrossOriginCookie } from "~/services/no-cross-origin-cookie";
|
||||
import { checkCsrf } from "~/services/csrf-session.server";
|
||||
import { getTrpcResponseMeta } from "~/services/trpc-response-meta.server";
|
||||
import { isCliApiRequest } from "~/services/trpc-request.server";
|
||||
import { ensureApiCsrf } from "~/services/api-auth.server";
|
||||
|
||||
const isServiceRequest = (request: Request) => {
|
||||
return isServiceAuthorization(request.headers.get("Authorization"));
|
||||
};
|
||||
|
||||
const isAuthTokenRequest = (request: Request) => {
|
||||
return request.headers.has("x-auth-token");
|
||||
};
|
||||
|
||||
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
if (
|
||||
isServiceRequest(request) === false &&
|
||||
isCliApiRequest(request) === false
|
||||
) {
|
||||
if (isServiceRequest(request) === false) {
|
||||
preventCrossOriginCookie(request);
|
||||
if (isAuthTokenRequest(request) === false) {
|
||||
await checkCsrf(request);
|
||||
}
|
||||
await ensureApiCsrf(request);
|
||||
}
|
||||
|
||||
// https://trpc.io/docs/server/adapters/fetch
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
import { expect, test, vi } from "vitest";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { AuthorizationError } from "@webstudio-is/trpc-interface/index.server";
|
||||
import {
|
||||
authorizeApiProject,
|
||||
ensureApiCsrf,
|
||||
getApiAuthorizationFailure,
|
||||
requiresApiCsrf,
|
||||
} from "./api-auth.server";
|
||||
|
||||
test.each([
|
||||
[
|
||||
new TRPCError({ code: "UNAUTHORIZED", message: "login required" }),
|
||||
{ status: 401, code: "UNAUTHORIZED", message: "login required" },
|
||||
],
|
||||
[
|
||||
new TRPCError({ code: "FORBIDDEN", message: "denied" }),
|
||||
{ status: 403, code: "FORBIDDEN", message: "denied" },
|
||||
],
|
||||
[
|
||||
new AuthorizationError("project denied"),
|
||||
{ status: 403, code: "FORBIDDEN", message: "project denied" },
|
||||
],
|
||||
[
|
||||
new Response("CSRF failed", { status: 403 }),
|
||||
{ status: 403, code: "FORBIDDEN", message: "Forbidden" },
|
||||
],
|
||||
])("classifies API authorization failures", (error, expected) => {
|
||||
expect(getApiAuthorizationFailure(error)).toEqual(expected);
|
||||
});
|
||||
|
||||
test("does not classify unrelated failures as authorization failures", () => {
|
||||
expect(
|
||||
getApiAuthorizationFailure(new Error("database failed"))
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
test("requires CSRF for cookie-authenticated API requests", () => {
|
||||
expect(requiresApiCsrf(new Request("https://example.com/rest/assets"))).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
test("uses explicit token authentication without cookie CSRF", () => {
|
||||
expect(
|
||||
requiresApiCsrf(
|
||||
new Request("https://example.com/rest/assets", {
|
||||
headers: { "x-auth-token": "share-token" },
|
||||
})
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("requires CSRF for Builder share-token requests", () => {
|
||||
expect(
|
||||
requiresApiCsrf(
|
||||
new Request("https://example.com/rest/assets", {
|
||||
headers: {
|
||||
"x-auth-token": "share-token",
|
||||
"x-webstudio-client": "browser",
|
||||
},
|
||||
})
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("requires API enablement for explicit API tokens", async () => {
|
||||
const context = { authorization: { type: "token" } } as never;
|
||||
const dependencies = {
|
||||
createContext: vi.fn().mockResolvedValue(context),
|
||||
assertApiProjectPermit: vi.fn().mockResolvedValue(undefined),
|
||||
hasProjectPermit: vi.fn().mockResolvedValue(true),
|
||||
ensureApiCsrf: vi.fn(),
|
||||
};
|
||||
|
||||
await expect(
|
||||
authorizeApiProject(
|
||||
new Request("https://example.com/rest/assets"),
|
||||
"project-1",
|
||||
"view",
|
||||
dependencies
|
||||
)
|
||||
).resolves.toBe(context);
|
||||
expect(dependencies.assertApiProjectPermit).toHaveBeenCalledWith(
|
||||
context,
|
||||
"project-1",
|
||||
"view"
|
||||
);
|
||||
expect(dependencies.hasProjectPermit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("checks project permits for cookie-authenticated requests", async () => {
|
||||
const context = { authorization: { type: "user" } } as never;
|
||||
const dependencies = {
|
||||
createContext: vi.fn().mockResolvedValue(context),
|
||||
assertApiProjectPermit: vi.fn(),
|
||||
hasProjectPermit: vi.fn().mockResolvedValue(true),
|
||||
ensureApiCsrf: vi.fn(),
|
||||
};
|
||||
|
||||
await authorizeApiProject(
|
||||
new Request("https://example.com/rest/assets"),
|
||||
"project-1",
|
||||
"build",
|
||||
dependencies
|
||||
);
|
||||
expect(dependencies.assertApiProjectPermit).toHaveBeenCalledWith(
|
||||
context,
|
||||
"project-1",
|
||||
"build"
|
||||
);
|
||||
expect(dependencies.hasProjectPermit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("keeps Builder share-token requests on project authorization", async () => {
|
||||
const context = { authorization: { type: "token" } } as never;
|
||||
const dependencies = {
|
||||
createContext: vi.fn().mockResolvedValue(context),
|
||||
assertApiProjectPermit: vi.fn(),
|
||||
hasProjectPermit: vi.fn().mockResolvedValue(true),
|
||||
ensureApiCsrf: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
await authorizeApiProject(
|
||||
new Request("https://example.com/rest/assets", {
|
||||
headers: { "x-webstudio-client": "browser", "x-auth-token": "token" },
|
||||
}),
|
||||
"project-1",
|
||||
"build",
|
||||
dependencies
|
||||
);
|
||||
expect(dependencies.assertApiProjectPermit).not.toHaveBeenCalled();
|
||||
expect(dependencies.hasProjectPermit).toHaveBeenCalledWith(
|
||||
{ projectId: "project-1", permit: "build" },
|
||||
context
|
||||
);
|
||||
expect(dependencies.ensureApiCsrf).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test("rejects Builder share tokens for a different project", async () => {
|
||||
const context = { authorization: { type: "token" } } as never;
|
||||
const dependencies = {
|
||||
createContext: vi.fn().mockResolvedValue(context),
|
||||
assertApiProjectPermit: vi.fn(),
|
||||
hasProjectPermit: vi.fn().mockResolvedValue(false),
|
||||
ensureApiCsrf: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
await expect(
|
||||
authorizeApiProject(
|
||||
new Request("https://example.com/rest/assets", {
|
||||
headers: { "x-webstudio-client": "browser", "x-auth-token": "token" },
|
||||
}),
|
||||
"other-project",
|
||||
"view",
|
||||
dependencies
|
||||
)
|
||||
).rejects.toThrow("You don't have access to this project");
|
||||
expect(dependencies.assertApiProjectPermit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("does not trust the Builder client marker without valid CSRF", async () => {
|
||||
const context = { authorization: { type: "token" } } as never;
|
||||
const csrfError = new Response("Forbidden", { status: 403 });
|
||||
const dependencies = {
|
||||
createContext: vi.fn().mockResolvedValue(context),
|
||||
assertApiProjectPermit: vi.fn(),
|
||||
hasProjectPermit: vi.fn().mockResolvedValue(true),
|
||||
ensureApiCsrf: vi.fn().mockRejectedValue(csrfError),
|
||||
};
|
||||
|
||||
await expect(
|
||||
authorizeApiProject(
|
||||
new Request("https://example.com/rest/assets", {
|
||||
headers: { "x-webstudio-client": "browser", "x-auth-token": "token" },
|
||||
}),
|
||||
"project-1",
|
||||
"view",
|
||||
dependencies
|
||||
)
|
||||
).rejects.toBe(csrfError);
|
||||
expect(dependencies.assertApiProjectPermit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("validates CSRF only once for one request", async () => {
|
||||
const request = new Request("https://example.com/rest/assets", {
|
||||
headers: { "x-webstudio-client": "browser" },
|
||||
});
|
||||
const validate = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
await ensureApiCsrf(request, validate);
|
||||
await ensureApiCsrf(request, validate);
|
||||
|
||||
expect(validate).toHaveBeenCalledOnce();
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import {
|
||||
AuthorizationError,
|
||||
authorizeProject,
|
||||
type ProjectPermit,
|
||||
} from "@webstudio-is/trpc-interface/index.server";
|
||||
import { apiClientHeader } from "@webstudio-is/trpc-interface/api-compatibility";
|
||||
import { createContext } from "~/shared/context.server";
|
||||
import { assertApiProjectPermit } from "./api-permits.server";
|
||||
import { checkCsrf } from "./csrf-session.server";
|
||||
|
||||
/**
|
||||
* Authorize one project-scoped API request. Explicit API tokens are subject to
|
||||
* public API enablement and token permits. Browser sessions and Builder share
|
||||
* links retain the repository's normal project authorization.
|
||||
*/
|
||||
export const authorizeApiProject = async (
|
||||
request: Request,
|
||||
projectId: string,
|
||||
permit: ProjectPermit,
|
||||
dependencies = {
|
||||
createContext,
|
||||
assertApiProjectPermit,
|
||||
hasProjectPermit: authorizeProject.hasProjectPermit,
|
||||
ensureApiCsrf,
|
||||
}
|
||||
) => {
|
||||
const context = await dependencies.createContext(request);
|
||||
if (
|
||||
context.authorization.type === "token" &&
|
||||
request.headers.get(apiClientHeader) === "browser"
|
||||
) {
|
||||
await dependencies.ensureApiCsrf(request);
|
||||
if (
|
||||
(await dependencies.hasProjectPermit({ projectId, permit }, context)) ===
|
||||
false
|
||||
) {
|
||||
throw new AuthorizationError("You don't have access to this project");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
if (
|
||||
context.authorization.type === "token" ||
|
||||
context.authorization.type === "user"
|
||||
) {
|
||||
await dependencies.assertApiProjectPermit(context, projectId, permit);
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
/**
|
||||
* Requests without an explicit API token require CSRF validation at API
|
||||
* transports that can mutate. Builder share-token requests identify themselves
|
||||
* as browser traffic and remain CSRF-protected.
|
||||
*/
|
||||
export const requiresApiCsrf = (request: Request) =>
|
||||
request.headers.get(apiClientHeader) === "browser" ||
|
||||
request.headers.has("x-auth-token") === false;
|
||||
|
||||
const csrfValidatedRequests = new WeakSet<Request>();
|
||||
|
||||
/** Validate one request at most once across its transport and authorization. */
|
||||
export const ensureApiCsrf = async (
|
||||
request: Request,
|
||||
validate: typeof checkCsrf = checkCsrf
|
||||
) => {
|
||||
if (
|
||||
requiresApiCsrf(request) === false ||
|
||||
csrfValidatedRequests.has(request)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await validate(request);
|
||||
csrfValidatedRequests.add(request);
|
||||
};
|
||||
|
||||
export const getApiAuthorizationFailure = (error: unknown) => {
|
||||
if (
|
||||
error instanceof Response &&
|
||||
(error.status === 401 || error.status === 403)
|
||||
) {
|
||||
return {
|
||||
status: error.status,
|
||||
code:
|
||||
error.status === 401
|
||||
? ("UNAUTHORIZED" as const)
|
||||
: ("FORBIDDEN" as const),
|
||||
message:
|
||||
error.statusText ||
|
||||
(error.status === 401 ? "Authentication required" : "Forbidden"),
|
||||
};
|
||||
}
|
||||
if (error instanceof TRPCError && error.code === "UNAUTHORIZED") {
|
||||
return {
|
||||
status: 401 as const,
|
||||
code: "UNAUTHORIZED" as const,
|
||||
message: error.message,
|
||||
};
|
||||
}
|
||||
if (
|
||||
(error instanceof TRPCError && error.code === "FORBIDDEN") ||
|
||||
error instanceof AuthorizationError
|
||||
) {
|
||||
return {
|
||||
status: 403 as const,
|
||||
code: "FORBIDDEN" as const,
|
||||
message: error.message,
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -46,36 +46,56 @@ export const assertApiTokenPermit = async (ctx: AppContext) => {
|
||||
return { token, permits };
|
||||
};
|
||||
|
||||
export const assertApiProjectPermit = async (
|
||||
const assertProjectPermit = async (
|
||||
ctx: AppContext,
|
||||
projectId: string,
|
||||
permit: ProjectApiPermit
|
||||
) => {
|
||||
const { token, permits } = await assertApiTokenPermit(ctx);
|
||||
if (token.projectId !== projectId) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Authorization token is not valid for project",
|
||||
});
|
||||
}
|
||||
|
||||
if (permits.includes(permit) === false) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: `Authorization token does not have ${permit} permission`,
|
||||
});
|
||||
}
|
||||
|
||||
const canUseProject = await authorizeProject.hasProjectPermit(
|
||||
const allowed = await authorizeProject.hasProjectPermit(
|
||||
{ projectId, permit },
|
||||
ctx
|
||||
);
|
||||
if (canUseProject === false) {
|
||||
if (allowed === false) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "You don't have access to this project",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return { token, permits };
|
||||
export const assertApiProjectPermit = async (
|
||||
ctx: AppContext,
|
||||
projectId: string,
|
||||
permit: ProjectApiPermit
|
||||
) => {
|
||||
if (ctx.authorization.type === "user") {
|
||||
await assertProjectPermit(ctx, projectId, permit);
|
||||
const permits: ProjectApiPermit[] = [permit];
|
||||
if (
|
||||
permit === "edit" &&
|
||||
(await authorizeProject.hasProjectPermit(
|
||||
{ projectId, permit: "build" },
|
||||
ctx
|
||||
))
|
||||
) {
|
||||
permits.push("build");
|
||||
}
|
||||
return { type: "user" as const, permits };
|
||||
}
|
||||
|
||||
const tokenAuth = await assertApiTokenPermit(ctx);
|
||||
if (tokenAuth.token.projectId !== projectId) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Authorization token is not valid for project",
|
||||
});
|
||||
}
|
||||
if (tokenAuth.permits.includes(permit) === false) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: `Authorization token does not have ${permit} permission`,
|
||||
});
|
||||
}
|
||||
await assertProjectPermit(ctx, projectId, permit);
|
||||
return { type: "token" as const, ...tokenAuth };
|
||||
};
|
||||
@@ -18,7 +18,8 @@ import {
|
||||
} from "@webstudio-is/trpc-interface/index.server";
|
||||
import { db as authDb } from "@webstudio-is/authorization-token/index.server";
|
||||
import { blockComponent } from "@webstudio-is/sdk";
|
||||
import * as assetUploader from "@webstudio-is/asset-uploader/index.server";
|
||||
import { AssetIndexRevisionError } from "@webstudio-is/content-engine";
|
||||
import * as assetUploader from "@webstudio-is/asset-uploader/server";
|
||||
import { apiRouter, __testing__ } from "./api-router.server";
|
||||
import {
|
||||
getApiRouterProcedures,
|
||||
@@ -74,6 +75,115 @@ const createCaller = (context: AppContext) =>
|
||||
apiRouter.createCaller(context) as ApiRouterCaller & RuntimeApiCaller;
|
||||
|
||||
describe("api router build operation adapters", () => {
|
||||
test("exposes query validation and persisted asset query reads to API clients", async () => {
|
||||
vi.spyOn(authDb, "getTokenInfo").mockResolvedValue(createToken());
|
||||
vi.spyOn(authorizeProject, "hasProjectPermit").mockResolvedValue(true);
|
||||
vi.spyOn(assetUploader, "previewAssetResourceQuery").mockResolvedValue({
|
||||
data: {
|
||||
items: [{ id: "post" }],
|
||||
totalCount: 1,
|
||||
hasMore: false,
|
||||
},
|
||||
__diagnostics__: {
|
||||
scope: "query-preview",
|
||||
usedBytes: 100,
|
||||
maxBytes: 512_000,
|
||||
unboundedBytes: 100,
|
||||
includedDocumentCount: 1,
|
||||
omittedDocumentCount: 0,
|
||||
truncated: false,
|
||||
},
|
||||
} as never);
|
||||
vi.spyOn(assetUploader, "loadBuilderAssetFieldCatalog").mockResolvedValue({
|
||||
format: "webstudio-builder-asset-field-catalog",
|
||||
version: 1,
|
||||
canonicalRevision: `sha256:${"a".repeat(64)}`,
|
||||
documentCount: 1,
|
||||
fields: {
|
||||
"properties.slug": {
|
||||
types: ["string"],
|
||||
occurrences: 1,
|
||||
queryPath: ["properties", "slug"],
|
||||
},
|
||||
},
|
||||
});
|
||||
const caller = createCaller(createContext(true));
|
||||
|
||||
await expect(
|
||||
caller.assetQueries.validate({
|
||||
projectId: "project-1",
|
||||
query: {
|
||||
where: {
|
||||
all: [
|
||||
{
|
||||
field: ["properties", "slug"],
|
||||
operator: "eq",
|
||||
value: "post",
|
||||
},
|
||||
],
|
||||
},
|
||||
limit: 1,
|
||||
},
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
valid: true,
|
||||
referencedFieldPaths: expect.arrayContaining([["properties", "slug"]]),
|
||||
filterCount: 1,
|
||||
});
|
||||
await expect(
|
||||
caller.assetQueries.validate({
|
||||
projectId: "project-1",
|
||||
query: {
|
||||
where: {
|
||||
all: [
|
||||
{
|
||||
field: ["properties", "missing"],
|
||||
operator: "exists",
|
||||
value: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
valid: true,
|
||||
warnings: ["Asset field properties.missing is not currently observed"],
|
||||
});
|
||||
await expect(
|
||||
caller.assetQueries.preview({
|
||||
projectId: "project-1",
|
||||
query: { limit: 10 },
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
data: {
|
||||
items: [{ id: "post" }],
|
||||
totalCount: 1,
|
||||
},
|
||||
__diagnostics__: { scope: "query-preview", truncated: false },
|
||||
});
|
||||
expect(assetUploader.previewAssetResourceQuery).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ contentDatabaseMaxBytes: 512_000 })
|
||||
);
|
||||
vi.mocked(assetUploader.previewAssetResourceQuery).mockRejectedValueOnce(
|
||||
new AssetIndexRevisionError()
|
||||
);
|
||||
await expect(
|
||||
caller.assetQueries.preview({
|
||||
projectId: "project-1",
|
||||
query: { limit: 10 },
|
||||
indexRevision: `sha256:${"0".repeat(64)}`,
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: "CONFLICT",
|
||||
cause: { webstudioCode: "STALE_INDEX" },
|
||||
});
|
||||
await expect(
|
||||
caller.assetQueries.fieldCatalog({ projectId: "project-1" })
|
||||
).resolves.toMatchObject({
|
||||
fields: { "properties.slug": { types: ["string"] } },
|
||||
});
|
||||
});
|
||||
|
||||
test("allows CLI clients to refresh pages without project settings", async () => {
|
||||
const build = {
|
||||
id: "build-1",
|
||||
@@ -258,7 +368,9 @@ describe("api router permits", () => {
|
||||
).toEqual(["view", "edit", "build", "admin", "api"]);
|
||||
});
|
||||
|
||||
test("requires token auth for project-scoped api procedures", async () => {
|
||||
test("accepts cookie auth for project-scoped api procedures", async () => {
|
||||
vi.spyOn(authorizeProject, "hasProjectPermit").mockResolvedValue(true);
|
||||
|
||||
await expect(
|
||||
assertApiProjectPermit(
|
||||
createContext(true, {
|
||||
@@ -270,12 +382,50 @@ describe("api router permits", () => {
|
||||
"project-1",
|
||||
"view"
|
||||
)
|
||||
).rejects.toMatchObject({
|
||||
code: "FORBIDDEN",
|
||||
message: "Builder API requires an API token",
|
||||
).resolves.toEqual({
|
||||
type: "user",
|
||||
permits: ["view"],
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects cookie auth without the requested project permit", async () => {
|
||||
vi.spyOn(authorizeProject, "hasProjectPermit").mockResolvedValue(false);
|
||||
|
||||
await expect(
|
||||
assertApiProjectPermit(
|
||||
createContext(true, {
|
||||
type: "user",
|
||||
userId: "user-1",
|
||||
sessionCreatedAt: 0,
|
||||
isLoggedInToBuilder: async () => true,
|
||||
}),
|
||||
"project-1",
|
||||
"edit"
|
||||
)
|
||||
).rejects.toThrow("You don't have access to this project");
|
||||
});
|
||||
|
||||
test("preserves content-only permissions for cookie-authenticated editors", async () => {
|
||||
const hasProjectPermit = vi
|
||||
.spyOn(authorizeProject, "hasProjectPermit")
|
||||
.mockImplementation(async ({ permit }) => permit === "edit");
|
||||
const context = createContext(true, {
|
||||
type: "user",
|
||||
userId: "user-1",
|
||||
sessionCreatedAt: 0,
|
||||
isLoggedInToBuilder: async () => true,
|
||||
});
|
||||
|
||||
await expect(
|
||||
assertApiProjectPermit(context, "project-1", "edit")
|
||||
).resolves.toEqual({ type: "user", permits: ["edit"] });
|
||||
|
||||
hasProjectPermit.mockResolvedValue(true);
|
||||
await expect(
|
||||
assertApiProjectPermit(context, "project-1", "edit")
|
||||
).resolves.toEqual({ type: "user", permits: ["edit", "build"] });
|
||||
});
|
||||
|
||||
test("rejects tokens from another project", async () => {
|
||||
vi.spyOn(authDb, "getTokenInfo").mockResolvedValue(
|
||||
createToken({ projectId: "project-2" })
|
||||
@@ -436,6 +586,7 @@ describe("api router permits", () => {
|
||||
await expect(
|
||||
assertApiProjectPermit(createContext(true), "project-1", "edit")
|
||||
).resolves.toEqual({
|
||||
type: "token",
|
||||
token,
|
||||
permits: ["view", "edit", "api"],
|
||||
});
|
||||
@@ -444,9 +595,18 @@ describe("api router permits", () => {
|
||||
test("requires publish permission for api publish domains", () => {
|
||||
const project = { domain: "project.wstd.dev" };
|
||||
|
||||
expect(() =>
|
||||
assertApiPublishDomains({
|
||||
auth: { type: "user", permits: ["edit"] },
|
||||
domains: ["custom.example.com"],
|
||||
project,
|
||||
})
|
||||
).not.toThrow();
|
||||
|
||||
expect(() =>
|
||||
assertApiPublishDomains({
|
||||
auth: {
|
||||
type: "token",
|
||||
token: createToken({ relation: "editors", canPublish: false }),
|
||||
permits: ["view", "edit", "api"],
|
||||
},
|
||||
@@ -458,6 +618,7 @@ describe("api router permits", () => {
|
||||
expect(() =>
|
||||
assertApiPublishDomains({
|
||||
auth: {
|
||||
type: "token",
|
||||
token: createToken({ relation: "editors", canPublish: true }),
|
||||
permits: ["view", "edit", "api"],
|
||||
},
|
||||
@@ -469,6 +630,7 @@ describe("api router permits", () => {
|
||||
expect(() =>
|
||||
assertApiPublishDomains({
|
||||
auth: {
|
||||
type: "token",
|
||||
token: createToken({ relation: "builders", canPublish: false }),
|
||||
permits: ["view", "edit", "build", "api"],
|
||||
},
|
||||
@@ -480,6 +642,7 @@ describe("api router permits", () => {
|
||||
expect(() =>
|
||||
assertApiPublishDomains({
|
||||
auth: {
|
||||
type: "token",
|
||||
token: createToken({ relation: "builders", canPublish: false }),
|
||||
permits: ["view", "edit", "build", "api"],
|
||||
},
|
||||
@@ -491,6 +654,7 @@ describe("api router permits", () => {
|
||||
expect(() =>
|
||||
assertApiPublishDomains({
|
||||
auth: {
|
||||
type: "token",
|
||||
token: createToken({
|
||||
relation: "administrators",
|
||||
canPublish: true,
|
||||
@@ -536,10 +700,12 @@ describe("api router permits", () => {
|
||||
marketplaceProduct: {},
|
||||
} as unknown as CompactBuild;
|
||||
const editorAuth = {
|
||||
type: "token",
|
||||
token: createToken({ relation: "editors" }),
|
||||
permits: ["view", "edit", "api"],
|
||||
} satisfies Awaited<ReturnType<typeof assertApiProjectPermit>>;
|
||||
const builderAuth = {
|
||||
type: "token",
|
||||
token: createToken({ relation: "builders" }),
|
||||
permits: ["view", "edit", "build", "api"],
|
||||
} satisfies Awaited<ReturnType<typeof assertApiProjectPermit>>;
|
||||
@@ -660,7 +826,9 @@ describe("api router permits", () => {
|
||||
projectId: "project-1",
|
||||
clientVersion: 3,
|
||||
}),
|
||||
expect.anything()
|
||||
expect.objectContaining({
|
||||
authorization: expect.objectContaining({ type: "token" }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -28,9 +28,17 @@ import {
|
||||
paginatedOutputInputSchema,
|
||||
} from "@webstudio-is/project-build/runtime";
|
||||
import {
|
||||
getAssetResourceQueryError,
|
||||
assetQueryRequest,
|
||||
getAssetQueryWhereMetrics,
|
||||
validateAssetQueryAgainstCatalog,
|
||||
} from "@webstudio-is/content-engine";
|
||||
import {
|
||||
loadBuilderAssetFieldCatalog,
|
||||
loadAssetDataByProject,
|
||||
loadAssetFoldersByProject,
|
||||
} from "@webstudio-is/asset-uploader/index.server";
|
||||
previewAssetResourceQuery,
|
||||
} from "@webstudio-is/asset-uploader/server";
|
||||
import { buildPatchTransaction } from "@webstudio-is/protocol/schema";
|
||||
import {
|
||||
publicApiContractVersion,
|
||||
@@ -72,6 +80,8 @@ import {
|
||||
executeApiRuntimeMutation,
|
||||
executeApiRuntimeOperation,
|
||||
} from "./api-runtime.server";
|
||||
import { createAssetClient } from "../shared/asset-client";
|
||||
import { getContentDatabaseMaxBytes } from "./content-database.server";
|
||||
|
||||
const assertApiPublishDomains = ({
|
||||
auth,
|
||||
@@ -82,6 +92,9 @@ const assertApiPublishDomains = ({
|
||||
domains: string[];
|
||||
project: { domain: string };
|
||||
}) => {
|
||||
if (auth.type === "user") {
|
||||
return;
|
||||
}
|
||||
const { token } = auth;
|
||||
if (token.canPublish === true) {
|
||||
return;
|
||||
@@ -100,6 +113,24 @@ const assertApiPublishDomains = ({
|
||||
|
||||
const projectIdInput = z.object({ projectId: z.string() });
|
||||
|
||||
const assetQueryInput = projectIdInput.extend(assetQueryRequest.shape);
|
||||
const assetQueryValidationInput = projectIdInput.extend({
|
||||
query: assetQueryRequest.shape.query,
|
||||
});
|
||||
const throwAssetQueryApiError = (error: unknown): never => {
|
||||
const queryError = getAssetResourceQueryError(error);
|
||||
if (queryError !== undefined) {
|
||||
return throwApiError(
|
||||
queryError.status === 409 ? "CONFLICT" : "BAD_REQUEST",
|
||||
queryError.message,
|
||||
{
|
||||
webstudioCode: queryError.code,
|
||||
}
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
};
|
||||
|
||||
const paginatedProjectInput = projectIdInput.extend(
|
||||
paginatedOutputInputSchema.shape
|
||||
);
|
||||
@@ -691,6 +722,76 @@ export const apiRouter = router({
|
||||
|
||||
...runtimeOperationRouters,
|
||||
|
||||
assetQueries: router({
|
||||
validate: projectQuery(
|
||||
assetQueryValidationInput,
|
||||
"view",
|
||||
async ({ ctx, input }) => {
|
||||
try {
|
||||
const catalog = await loadBuilderAssetFieldCatalog({
|
||||
projectId: input.projectId,
|
||||
context: ctx,
|
||||
assetClient: createAssetClient(),
|
||||
});
|
||||
const validated = validateAssetQueryAgainstCatalog({
|
||||
query: input.query,
|
||||
catalog,
|
||||
});
|
||||
return {
|
||||
valid: true as const,
|
||||
referencedFieldPaths: validated.referencedFieldPaths,
|
||||
filterCount: getAssetQueryWhereMetrics(validated.query.where)
|
||||
.filters,
|
||||
sortCount: validated.query.sort.length,
|
||||
warnings: validated.warnings,
|
||||
};
|
||||
} catch (error) {
|
||||
return throwAssetQueryApiError(error);
|
||||
}
|
||||
},
|
||||
{
|
||||
command: "validate-asset-query",
|
||||
client: "validateAssetQuery",
|
||||
}
|
||||
),
|
||||
preview: projectQuery(
|
||||
assetQueryInput,
|
||||
"view",
|
||||
async ({ ctx, input }) => {
|
||||
try {
|
||||
const { projectId, ...request } = input;
|
||||
return await previewAssetResourceQuery({
|
||||
projectId,
|
||||
request,
|
||||
context: ctx,
|
||||
assetClient: createAssetClient(),
|
||||
contentDatabaseMaxBytes: getContentDatabaseMaxBytes(),
|
||||
});
|
||||
} catch (error) {
|
||||
return throwAssetQueryApiError(error);
|
||||
}
|
||||
},
|
||||
{
|
||||
command: "preview-asset-query",
|
||||
client: "previewAssetQuery",
|
||||
}
|
||||
),
|
||||
fieldCatalog: projectQuery(
|
||||
projectIdInput,
|
||||
"view",
|
||||
async ({ ctx, input }) =>
|
||||
await loadBuilderAssetFieldCatalog({
|
||||
projectId: input.projectId,
|
||||
context: ctx,
|
||||
assetClient: createAssetClient(),
|
||||
}),
|
||||
{
|
||||
command: "get-asset-field-catalog",
|
||||
client: "getAssetFieldCatalog",
|
||||
}
|
||||
),
|
||||
}),
|
||||
|
||||
publish: router({
|
||||
list: projectQuery(
|
||||
paginatedProjectInput,
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import type { Asset } from "@webstudio-is/sdk";
|
||||
import {
|
||||
createAssetContentLoader,
|
||||
parseRequestRange,
|
||||
} from "./asset-rest-route-handlers.server";
|
||||
|
||||
const asset: Asset = {
|
||||
id: "asset-1",
|
||||
projectId: "project-1",
|
||||
name: "post.md",
|
||||
type: "file",
|
||||
format: "md",
|
||||
size: 10,
|
||||
description: null,
|
||||
createdAt: "2026-07-25T00:00:00.000Z",
|
||||
meta: {},
|
||||
};
|
||||
|
||||
describe("Assets REST content download", () => {
|
||||
test("parses one standard byte range", () => {
|
||||
expect(parseRequestRange("bytes=2-5", 10)).toEqual({
|
||||
offset: 2,
|
||||
length: 4,
|
||||
});
|
||||
expect(() => parseRequestRange("bytes=0-1,4-5", 10)).toThrow(
|
||||
"single valid byte range"
|
||||
);
|
||||
expect(() => parseRequestRange("items=0-1", 10)).toThrow(
|
||||
"single valid byte range"
|
||||
);
|
||||
});
|
||||
|
||||
test("streams an authorized partial response", async () => {
|
||||
const readContent = vi.fn(async () => ({
|
||||
asset,
|
||||
data: {
|
||||
async *[Symbol.asyncIterator]() {
|
||||
yield new TextEncoder().encode("post");
|
||||
},
|
||||
},
|
||||
contentLength: 4,
|
||||
}));
|
||||
const dependencies = {
|
||||
preventCrossOriginCookie: vi.fn(),
|
||||
createRepository: vi.fn(async () => ({
|
||||
get: vi.fn(async () => asset),
|
||||
readContent,
|
||||
})) as never,
|
||||
};
|
||||
const response = await createAssetContentLoader(dependencies)({
|
||||
request: new Request(
|
||||
"https://webstudio.is/rest/assets/asset-1/content?projectId=project-1",
|
||||
{ headers: { range: "bytes=2-5", "x-auth-token": "token" } }
|
||||
),
|
||||
params: { assetId: "asset-1" },
|
||||
} as never);
|
||||
|
||||
expect(response.status).toBe(206);
|
||||
expect(response.headers.get("content-range")).toBe("bytes 2-5/10");
|
||||
expect(response.headers.get("content-length")).toBe("4");
|
||||
expect(await response.text()).toBe("post");
|
||||
expect(readContent).toHaveBeenCalledWith({
|
||||
assetId: "asset-1",
|
||||
range: { offset: 2, length: 4 },
|
||||
asset,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { createAssetIndexRefreshAction } from "./asset-rest-route-handlers.server";
|
||||
|
||||
const call = (
|
||||
action: ReturnType<typeof createAssetIndexRefreshAction>,
|
||||
headers?: HeadersInit
|
||||
) =>
|
||||
action({
|
||||
request: new Request(
|
||||
"https://webstudio.is/rest/assets/index/refresh?projectId=project-1",
|
||||
{ method: "POST", headers }
|
||||
),
|
||||
params: {},
|
||||
} as never);
|
||||
|
||||
const createDependencies = (issues: Array<Record<string, string>> = []) => ({
|
||||
preventCrossOriginCookie: vi.fn(),
|
||||
ensureApiCsrf: vi.fn(),
|
||||
createRepository: vi.fn(async () => ({
|
||||
synchronize: vi.fn(async () => ({
|
||||
scanned: 1,
|
||||
indexed: issues.length === 0 ? 1 : 0,
|
||||
metadataUpdated: 0,
|
||||
unchanged: 0,
|
||||
removed: 0,
|
||||
skipped: issues.length,
|
||||
inconsistent: 0,
|
||||
issues,
|
||||
})),
|
||||
})) as never,
|
||||
});
|
||||
|
||||
describe("Assets index repair", () => {
|
||||
test("repairs metadata and protects cookie mutations with CSRF", async () => {
|
||||
const dependencies = createDependencies();
|
||||
const response = await call(createAssetIndexRefreshAction(dependencies));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(dependencies.ensureApiCsrf).toHaveBeenCalledOnce();
|
||||
expect(dependencies.createRepository).toHaveBeenCalledWith(
|
||||
expect.any(Request),
|
||||
"edit"
|
||||
);
|
||||
expect(await response.json()).toMatchObject({ indexed: 1 });
|
||||
});
|
||||
|
||||
test("reports incomplete repair and accepts project-token authentication", async () => {
|
||||
const dependencies = createDependencies([
|
||||
{
|
||||
assetId: "asset-1",
|
||||
storageName: "post.md",
|
||||
revision: "revision-1",
|
||||
message: "Object is unavailable",
|
||||
},
|
||||
]);
|
||||
const response = await call(createAssetIndexRefreshAction(dependencies), {
|
||||
"x-auth-token": "share-token",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(503);
|
||||
expect(dependencies.ensureApiCsrf).toHaveBeenCalledOnce();
|
||||
expect(await response.json()).toMatchObject({
|
||||
issues: [expect.objectContaining({ assetId: "asset-1" })],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,220 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import type { Asset } from "@webstudio-is/sdk";
|
||||
import { assetResourceLimits } from "@webstudio-is/sdk/asset-resource-limits";
|
||||
import { AuthorizationError } from "@webstudio-is/trpc-interface/index.server";
|
||||
import { AssetRepositoryNotFoundError } from "@webstudio-is/asset-uploader/server";
|
||||
import { createAssetAction } from "./asset-rest-route-handlers.server";
|
||||
|
||||
const asset: Asset = {
|
||||
id: "asset-1",
|
||||
projectId: "project-1",
|
||||
name: "post.md",
|
||||
filename: "Post",
|
||||
description: null,
|
||||
type: "file",
|
||||
format: "md",
|
||||
size: 10,
|
||||
createdAt: "2026-07-25T00:00:00.000Z",
|
||||
meta: {},
|
||||
};
|
||||
|
||||
const createDependencies = () => {
|
||||
const updateMetadata = vi.fn().mockResolvedValue(asset);
|
||||
const deleteAsset = vi.fn().mockResolvedValue(undefined);
|
||||
const createRepository = vi.fn(async () => ({
|
||||
updateMetadata,
|
||||
delete: deleteAsset,
|
||||
}));
|
||||
return {
|
||||
updateMetadata,
|
||||
deleteAsset,
|
||||
createRepository,
|
||||
dependencies: {
|
||||
preventCrossOriginCookie: vi.fn(),
|
||||
ensureApiCsrf: vi.fn(),
|
||||
createRepository: createRepository as never,
|
||||
} satisfies Parameters<typeof createAssetAction>[0],
|
||||
};
|
||||
};
|
||||
|
||||
const callAction = (
|
||||
action: ReturnType<typeof createAssetAction>,
|
||||
request: Request
|
||||
) => action({ request, params: { assetId: "asset-1" } } as never);
|
||||
|
||||
describe("mutable Assets REST route", () => {
|
||||
test("rejects unsupported methods before authorization", async () => {
|
||||
const { dependencies, createRepository } = createDependencies();
|
||||
const response = await callAction(
|
||||
createAssetAction(dependencies),
|
||||
new Request(
|
||||
"https://webstudio.is/rest/assets/asset-1?projectId=project-1",
|
||||
{ method: "POST" }
|
||||
)
|
||||
);
|
||||
|
||||
expect(response.status).toBe(405);
|
||||
expect(response.headers.get("allow")).toBe("PATCH, DELETE");
|
||||
expect(dependencies.ensureApiCsrf).not.toHaveBeenCalled();
|
||||
expect(createRepository).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("updates metadata through the repository with browser CSRF protection", async () => {
|
||||
const { dependencies, updateMetadata } = createDependencies();
|
||||
const action = createAssetAction(dependencies);
|
||||
const response = await callAction(
|
||||
action,
|
||||
new Request(
|
||||
"https://webstudio.is/rest/assets/asset-1?projectId=project-1",
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ filename: "Post", folderId: null }),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toEqual({ asset });
|
||||
expect(dependencies.ensureApiCsrf).toHaveBeenCalledOnce();
|
||||
expect(updateMetadata).toHaveBeenCalledWith("asset-1", {
|
||||
filename: "Post",
|
||||
folderId: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("returns the same metadata contract for project-token authentication", async () => {
|
||||
const { dependencies, createRepository, updateMetadata } =
|
||||
createDependencies();
|
||||
const response = await callAction(
|
||||
createAssetAction(dependencies),
|
||||
new Request(
|
||||
"https://webstudio.is/rest/assets/asset-1?projectId=project-1",
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-auth-token": "token",
|
||||
},
|
||||
body: JSON.stringify({ filename: "Post", folderId: null }),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toEqual({ asset });
|
||||
expect(dependencies.ensureApiCsrf).toHaveBeenCalledOnce();
|
||||
expect(createRepository).toHaveBeenCalledWith(expect.any(Request), "edit");
|
||||
expect(updateMetadata).toHaveBeenCalledWith("asset-1", {
|
||||
filename: "Post",
|
||||
folderId: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("deletes through the repository and accepts token-authenticated requests", async () => {
|
||||
const { dependencies, deleteAsset } = createDependencies();
|
||||
const action = createAssetAction(dependencies);
|
||||
const response = await callAction(
|
||||
action,
|
||||
new Request(
|
||||
"https://webstudio.is/rest/assets/asset-1?projectId=project-1",
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: { "x-auth-token": "token" },
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
expect(response.status).toBe(204);
|
||||
expect(dependencies.ensureApiCsrf).toHaveBeenCalledOnce();
|
||||
expect(deleteAsset).toHaveBeenCalledWith(["asset-1"]);
|
||||
});
|
||||
|
||||
test("returns a forbidden response for repository authorization failures", async () => {
|
||||
const { dependencies, createRepository } = createDependencies();
|
||||
createRepository.mockRejectedValue(
|
||||
new AuthorizationError("You don't have access") as never
|
||||
);
|
||||
const report = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const response = await callAction(
|
||||
createAssetAction(dependencies),
|
||||
new Request(
|
||||
"https://webstudio.is/rest/assets/asset-1?projectId=project-1",
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-auth-token": "token",
|
||||
},
|
||||
body: JSON.stringify({ description: "Updated" }),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(await response.json()).toEqual({
|
||||
errors: "You don't have access",
|
||||
});
|
||||
report.mockRestore();
|
||||
});
|
||||
|
||||
test.each([
|
||||
["PATCH", "updateMetadata"],
|
||||
["DELETE", "deleteAsset"],
|
||||
] as const)(
|
||||
"returns not found when %s targets a missing asset",
|
||||
async (method, operation) => {
|
||||
const dependencies = createDependencies();
|
||||
dependencies[operation].mockRejectedValue(
|
||||
new AssetRepositoryNotFoundError("Asset not found")
|
||||
);
|
||||
const requestInit: RequestInit = {
|
||||
method,
|
||||
headers: { "x-auth-token": "token" },
|
||||
};
|
||||
if (method === "PATCH") {
|
||||
requestInit.headers = {
|
||||
...requestInit.headers,
|
||||
"content-type": "application/json",
|
||||
};
|
||||
requestInit.body = JSON.stringify({ description: "Missing" });
|
||||
}
|
||||
|
||||
const response = await callAction(
|
||||
createAssetAction(dependencies.dependencies),
|
||||
new Request(
|
||||
"https://webstudio.is/rest/assets/missing?projectId=project-1",
|
||||
requestInit
|
||||
)
|
||||
);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(await response.json()).toEqual({ errors: "Asset not found" });
|
||||
}
|
||||
);
|
||||
|
||||
test("rejects oversized JSON before calling the repository", async () => {
|
||||
const { dependencies, updateMetadata } = createDependencies();
|
||||
const response = await callAction(
|
||||
createAssetAction(dependencies),
|
||||
new Request(
|
||||
"https://webstudio.is/rest/assets/asset-1?projectId=project-1",
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-auth-token": "token",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
description: "x".repeat(
|
||||
assetResourceLimits.restMutationRequestBytes
|
||||
),
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
expect(response.status).toBe(413);
|
||||
expect(updateMetadata).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
import {
|
||||
json,
|
||||
type ActionFunctionArgs,
|
||||
type LoaderFunctionArgs,
|
||||
} from "@remix-run/server-runtime";
|
||||
import { Readable } from "node:stream";
|
||||
import parseRange from "range-parser";
|
||||
import { getAssetMime } from "@webstudio-is/sdk";
|
||||
import {
|
||||
assetMetadataUpdate,
|
||||
assetResourceApiOperations,
|
||||
} from "@webstudio-is/protocol/asset-resource-api";
|
||||
import { privateNoStoreResponseHeaders } from "./cache-control.server";
|
||||
import { ensureApiCsrf } from "./api-auth.server";
|
||||
import {
|
||||
AssetRestRangeError,
|
||||
assetRestErrorResponse,
|
||||
assetRestMethodNotAllowed,
|
||||
createAssetRestRepository,
|
||||
parseAssetRestIdentifier,
|
||||
readAssetRestJson,
|
||||
} from "./asset-rest.server";
|
||||
import { preventCrossOriginCookie } from "./no-cross-origin-cookie";
|
||||
|
||||
export const parseRequestRange = (value: string | null, size: number) => {
|
||||
if (value === null) {
|
||||
return;
|
||||
}
|
||||
const parsed = parseRange(size, value, { combine: true });
|
||||
if (
|
||||
typeof parsed === "number" ||
|
||||
parsed.type !== "bytes" ||
|
||||
parsed.length !== 1
|
||||
) {
|
||||
throw new AssetRestRangeError("A single valid byte range is required");
|
||||
}
|
||||
const [{ start, end }] = parsed;
|
||||
return { offset: start, length: end - start + 1 };
|
||||
};
|
||||
|
||||
type AssetContentLoaderDependencies = {
|
||||
preventCrossOriginCookie: typeof preventCrossOriginCookie;
|
||||
createRepository: typeof createAssetRestRepository;
|
||||
};
|
||||
|
||||
const defaultAssetContentLoaderDependencies: AssetContentLoaderDependencies = {
|
||||
preventCrossOriginCookie,
|
||||
createRepository: createAssetRestRepository,
|
||||
};
|
||||
|
||||
export const createAssetContentLoader =
|
||||
(
|
||||
dependencies: AssetContentLoaderDependencies = defaultAssetContentLoaderDependencies
|
||||
) =>
|
||||
async ({ request, params }: LoaderFunctionArgs) => {
|
||||
dependencies.preventCrossOriginCookie(request);
|
||||
if (
|
||||
request.method.toLowerCase() !==
|
||||
assetResourceApiOperations.downloadAssetContent.method
|
||||
) {
|
||||
return assetRestMethodNotAllowed([
|
||||
assetResourceApiOperations.downloadAssetContent,
|
||||
]);
|
||||
}
|
||||
try {
|
||||
const assetId = parseAssetRestIdentifier(params.assetId);
|
||||
const repository = await dependencies.createRepository(request, "view");
|
||||
const asset = await repository.get(assetId);
|
||||
const range = parseRequestRange(request.headers.get("range"), asset.size);
|
||||
const content = await repository.readContent({ assetId, range, asset });
|
||||
const headers = new Headers(privateNoStoreResponseHeaders);
|
||||
headers.set(
|
||||
"content-type",
|
||||
getAssetMime(asset) ?? "application/octet-stream"
|
||||
);
|
||||
headers.set("accept-ranges", "bytes");
|
||||
headers.set(
|
||||
"content-length",
|
||||
String(content.contentLength ?? range?.length ?? asset.size)
|
||||
);
|
||||
if (range !== undefined) {
|
||||
headers.set(
|
||||
"content-range",
|
||||
`bytes ${range.offset}-${range.offset + range.length - 1}/${asset.size}`
|
||||
);
|
||||
}
|
||||
return new Response(
|
||||
Readable.toWeb(Readable.from(content.data)) as ReadableStream,
|
||||
{ status: range === undefined ? 200 : 206, headers }
|
||||
);
|
||||
} catch (error) {
|
||||
return assetRestErrorResponse(error);
|
||||
}
|
||||
};
|
||||
|
||||
type AssetActionDependencies = {
|
||||
preventCrossOriginCookie: typeof preventCrossOriginCookie;
|
||||
ensureApiCsrf: typeof ensureApiCsrf;
|
||||
createRepository: typeof createAssetRestRepository;
|
||||
};
|
||||
|
||||
const defaultAssetActionDependencies: AssetActionDependencies = {
|
||||
preventCrossOriginCookie,
|
||||
ensureApiCsrf,
|
||||
createRepository: createAssetRestRepository,
|
||||
};
|
||||
|
||||
export const createAssetAction =
|
||||
(dependencies: AssetActionDependencies = defaultAssetActionDependencies) =>
|
||||
async ({ request, params }: ActionFunctionArgs) => {
|
||||
dependencies.preventCrossOriginCookie(request);
|
||||
const method = request.method.toLowerCase();
|
||||
if (
|
||||
method !== assetResourceApiOperations.updateAsset.method &&
|
||||
method !== assetResourceApiOperations.deleteAsset.method
|
||||
) {
|
||||
return assetRestMethodNotAllowed([
|
||||
assetResourceApiOperations.updateAsset,
|
||||
assetResourceApiOperations.deleteAsset,
|
||||
]);
|
||||
}
|
||||
await dependencies.ensureApiCsrf(request);
|
||||
|
||||
try {
|
||||
const assetId = parseAssetRestIdentifier(params.assetId);
|
||||
const repository = await dependencies.createRepository(request, "edit");
|
||||
|
||||
if (method === assetResourceApiOperations.updateAsset.method) {
|
||||
const asset = await repository.updateMetadata(
|
||||
assetId,
|
||||
assetMetadataUpdate.parse(await readAssetRestJson(request))
|
||||
);
|
||||
return json({ asset }, { headers: privateNoStoreResponseHeaders });
|
||||
}
|
||||
await repository.delete([assetId]);
|
||||
return new Response(null, {
|
||||
status: 204,
|
||||
headers: privateNoStoreResponseHeaders,
|
||||
});
|
||||
} catch (error) {
|
||||
return assetRestErrorResponse(error);
|
||||
}
|
||||
};
|
||||
|
||||
const defaultAssetIndexRefreshDependencies = {
|
||||
preventCrossOriginCookie,
|
||||
ensureApiCsrf,
|
||||
createRepository: createAssetRestRepository,
|
||||
};
|
||||
|
||||
export const createAssetIndexRefreshAction =
|
||||
(dependencies = defaultAssetIndexRefreshDependencies) =>
|
||||
async ({ request }: ActionFunctionArgs) => {
|
||||
dependencies.preventCrossOriginCookie(request);
|
||||
if (
|
||||
request.method.toLowerCase() !==
|
||||
assetResourceApiOperations.refreshAssetIndex.method
|
||||
) {
|
||||
return assetRestMethodNotAllowed([
|
||||
assetResourceApiOperations.refreshAssetIndex,
|
||||
]);
|
||||
}
|
||||
await dependencies.ensureApiCsrf(request);
|
||||
try {
|
||||
const result = await (
|
||||
await dependencies.createRepository(request, "edit")
|
||||
).synchronize();
|
||||
return json(result, {
|
||||
status: result.issues.length === 0 ? 200 : 503,
|
||||
headers: privateNoStoreResponseHeaders,
|
||||
});
|
||||
} catch (error) {
|
||||
return assetRestErrorResponse(error);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,203 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import {
|
||||
AssetRepositoryConflictError,
|
||||
AssetRepositoryNotFoundError,
|
||||
} from "@webstudio-is/asset-uploader/server";
|
||||
import { assetFolders } from "@webstudio-is/sdk";
|
||||
import { assetResourceLimits } from "@webstudio-is/sdk/asset-resource-limits";
|
||||
import { assetResourceApiOperations } from "@webstudio-is/protocol/asset-resource-api";
|
||||
import { AuthorizationError } from "@webstudio-is/trpc-interface/index.server";
|
||||
import {
|
||||
AssetRestRangeError,
|
||||
AssetRestPayloadTooLargeError,
|
||||
AssetRestRequestError,
|
||||
assetRestErrorResponse,
|
||||
assetRestMethodNotAllowed,
|
||||
parseAssetRestDescription,
|
||||
parseAssetRestMetadataHeader,
|
||||
readAssetRestFormData,
|
||||
readAssetRestJson,
|
||||
requireAssetRestBody,
|
||||
} from "./asset-rest.server";
|
||||
import { AssetUploadSizeLimitError } from "@webstudio-is/asset-uploader/server";
|
||||
|
||||
describe("Assets REST responses", () => {
|
||||
test.each([
|
||||
[new AssetRestRequestError("bad request"), 400],
|
||||
[new AuthorizationError("forbidden"), 403],
|
||||
[new TRPCError({ code: "UNAUTHORIZED" }), 401],
|
||||
[new TRPCError({ code: "FORBIDDEN" }), 403],
|
||||
[new AssetRepositoryNotFoundError("missing"), 404],
|
||||
[new AssetRepositoryConflictError("conflict"), 409],
|
||||
[new AssetRestRangeError("bad range"), 416],
|
||||
[new AssetRestPayloadTooLargeError("too large"), 413],
|
||||
[new AssetUploadSizeLimitError("post.md", 10), 413],
|
||||
])("maps domain errors to HTTP status", async (error, status) => {
|
||||
const response = assetRestErrorResponse(error);
|
||||
expect(response.status).toBe(status);
|
||||
expect(await response.json()).toEqual({ errors: error.message });
|
||||
});
|
||||
|
||||
test("maps duplicate sibling folders to conflict", () => {
|
||||
const error = assetFolders.safeParse(
|
||||
new Map([
|
||||
[
|
||||
"one",
|
||||
{
|
||||
id: "one",
|
||||
projectId: "project-1",
|
||||
name: "Blog",
|
||||
createdAt: "2026-07-25T00:00:00.000Z",
|
||||
},
|
||||
],
|
||||
[
|
||||
"two",
|
||||
{
|
||||
id: "two",
|
||||
projectId: "project-1",
|
||||
name: "Blog",
|
||||
createdAt: "2026-07-25T00:00:00.000Z",
|
||||
},
|
||||
],
|
||||
])
|
||||
).error;
|
||||
expect(error).toBeDefined();
|
||||
expect(assetRestErrorResponse(error).status).toBe(409);
|
||||
});
|
||||
|
||||
test("does not swallow CSRF responses and advertises allowed methods", () => {
|
||||
const csrf = new Response("Forbidden", { status: 403 });
|
||||
expect(assetRestErrorResponse(csrf)).toBe(csrf);
|
||||
const response = assetRestMethodNotAllowed([
|
||||
assetResourceApiOperations.getAsset,
|
||||
assetResourceApiOperations.updateAsset,
|
||||
]);
|
||||
expect(response.status).toBe(405);
|
||||
expect(response.headers.get("allow")).toBe("GET, PATCH");
|
||||
});
|
||||
|
||||
test("does not expose unexpected internal errors", async () => {
|
||||
const report = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const response = assetRestErrorResponse(new Error("database password"));
|
||||
expect(response.status).toBe(500);
|
||||
expect(await response.json()).toEqual({
|
||||
errors: "Internal Assets API error",
|
||||
});
|
||||
report.mockRestore();
|
||||
});
|
||||
|
||||
test("bounds mutation bodies before parsing JSON", async () => {
|
||||
await expect(
|
||||
readAssetRestJson(
|
||||
new Request("https://example.com/rest/assets", {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"content-length": String(
|
||||
assetResourceLimits.restMutationRequestBytes + 1
|
||||
),
|
||||
},
|
||||
body: "{}",
|
||||
})
|
||||
)
|
||||
).rejects.toBeInstanceOf(AssetRestPayloadTooLargeError);
|
||||
|
||||
await expect(
|
||||
readAssetRestJson(
|
||||
new Request("https://example.com/rest/assets", {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
description: "x".repeat(
|
||||
assetResourceLimits.restMutationRequestBytes
|
||||
),
|
||||
}),
|
||||
})
|
||||
)
|
||||
).rejects.toBeInstanceOf(AssetRestPayloadTooLargeError);
|
||||
});
|
||||
|
||||
test("rejects malformed UTF-8 in JSON bodies", async () => {
|
||||
const prefix = new TextEncoder().encode('{"description":"');
|
||||
const suffix = new TextEncoder().encode('"}');
|
||||
const body = new Uint8Array(prefix.byteLength + 1 + suffix.byteLength);
|
||||
body.set(prefix);
|
||||
body[prefix.byteLength] = 0xff;
|
||||
body.set(suffix, prefix.byteLength + 1);
|
||||
|
||||
await expect(
|
||||
readAssetRestJson(
|
||||
new Request("https://example.com/rest/assets", {
|
||||
method: "PATCH",
|
||||
body,
|
||||
})
|
||||
)
|
||||
).rejects.toBeInstanceOf(AssetRestRequestError);
|
||||
});
|
||||
|
||||
test("distinguishes a missing content body from an unsupported method", () => {
|
||||
expect(() =>
|
||||
requireAssetRestBody(
|
||||
new Request("https://example.com/rest/assets/asset/content", {
|
||||
method: "POST",
|
||||
})
|
||||
)
|
||||
).toThrow(AssetRestRequestError);
|
||||
expect(
|
||||
requireAssetRestBody(
|
||||
new Request("https://example.com/rest/assets/asset/content", {
|
||||
method: "POST",
|
||||
body: "content",
|
||||
})
|
||||
)
|
||||
).toBeInstanceOf(ReadableStream);
|
||||
});
|
||||
|
||||
test("bounds upload metadata carried in headers", () => {
|
||||
expect(() =>
|
||||
parseAssetRestDescription(
|
||||
"x".repeat(assetResourceLimits.assetDescriptionCharacters + 1)
|
||||
)
|
||||
).toThrow(AssetRestRequestError);
|
||||
expect(() =>
|
||||
parseAssetRestMetadataHeader(
|
||||
"x".repeat(assetResourceLimits.restMutationRequestBytes + 1)
|
||||
)
|
||||
).toThrow(AssetRestPayloadTooLargeError);
|
||||
expect(() => parseAssetRestMetadataHeader("{")).toThrow(
|
||||
AssetRestRequestError
|
||||
);
|
||||
});
|
||||
|
||||
test("parses bounded multipart forms and rejects oversized forms", async () => {
|
||||
const form = new FormData();
|
||||
form.set("projectId", "project-1");
|
||||
form.set("filename", "post.md");
|
||||
const parsed = await readAssetRestFormData(
|
||||
new Request("https://example.com/rest/assets/uploads", {
|
||||
method: "POST",
|
||||
body: form,
|
||||
})
|
||||
);
|
||||
expect(Object.fromEntries(parsed)).toEqual({
|
||||
projectId: "project-1",
|
||||
filename: "post.md",
|
||||
});
|
||||
|
||||
await expect(
|
||||
readAssetRestFormData(
|
||||
new Request("https://example.com/rest/assets/uploads", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "multipart/form-data; boundary=test",
|
||||
"content-length": String(
|
||||
assetResourceLimits.restMutationRequestBytes + 1
|
||||
),
|
||||
},
|
||||
body: "--test--",
|
||||
})
|
||||
)
|
||||
).rejects.toBeInstanceOf(AssetRestPayloadTooLargeError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,245 @@
|
||||
import { json } from "@remix-run/server-runtime";
|
||||
import {
|
||||
readBoundedRequestBytes,
|
||||
RequestByteLimitError,
|
||||
} from "@webstudio-is/content-engine";
|
||||
import { decodeUtf8 } from "@webstudio-is/content-engine/compiler";
|
||||
import {
|
||||
AssetRepositoryConflictError,
|
||||
AssetRepositoryNotFoundError,
|
||||
AssetRevisionConflictError,
|
||||
AssetUploadSizeLimitError,
|
||||
PostgresAssetRepository,
|
||||
} from "@webstudio-is/asset-uploader/server";
|
||||
import type { ProjectPermit } from "@webstudio-is/trpc-interface/index.server";
|
||||
import { assetFolderIssue } from "@webstudio-is/sdk";
|
||||
import { assetResourceLimits } from "@webstudio-is/sdk/asset-resource-limits";
|
||||
import { parseBuilderUrl } from "@webstudio-is/protocol";
|
||||
import { ZodError } from "zod";
|
||||
import { createAssetClient } from "~/shared/asset-client";
|
||||
import { parseError } from "~/shared/error/error-parse";
|
||||
import { privateNoStoreResponseHeaders } from "./cache-control.server";
|
||||
import {
|
||||
authorizeApiProject,
|
||||
getApiAuthorizationFailure,
|
||||
} from "./api-auth.server";
|
||||
|
||||
export class AssetRestRequestError extends Error {}
|
||||
export class AssetRestRangeError extends Error {}
|
||||
export class AssetRestPayloadTooLargeError extends Error {}
|
||||
|
||||
export const requireAssetRestBody = (request: Request) => {
|
||||
if (request.body === null) {
|
||||
throw new AssetRestRequestError("Asset content is required");
|
||||
}
|
||||
return request.body;
|
||||
};
|
||||
|
||||
const readAssetRestBody = async (request: Request) => {
|
||||
try {
|
||||
return await readBoundedRequestBytes(
|
||||
request,
|
||||
assetResourceLimits.restMutationRequestBytes
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof RequestByteLimitError) {
|
||||
throw new AssetRestPayloadTooLargeError(
|
||||
"Assets API request exceeds the byte limit",
|
||||
{ cause: error }
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const readAssetRestJson = async (request: Request) => {
|
||||
const bytes = await readAssetRestBody(request);
|
||||
try {
|
||||
return JSON.parse(decodeUtf8(bytes)) as unknown;
|
||||
} catch (error) {
|
||||
throw new AssetRestRequestError("Assets API request JSON is invalid", {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const readAssetRestFormData = async (request: Request) => {
|
||||
const bytes = await readAssetRestBody(request);
|
||||
try {
|
||||
return await new Request(request.url, {
|
||||
method: request.method,
|
||||
headers: request.headers,
|
||||
body: bytes,
|
||||
}).formData();
|
||||
} catch (error) {
|
||||
throw new AssetRestRequestError("Assets API form data is invalid", {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const parseRequiredAssetString = ({
|
||||
value,
|
||||
maximumCharacters,
|
||||
name,
|
||||
}: {
|
||||
value: string | undefined | null;
|
||||
maximumCharacters: number;
|
||||
name: string;
|
||||
}) => {
|
||||
if (
|
||||
value === undefined ||
|
||||
value === null ||
|
||||
value.length === 0 ||
|
||||
value.length > maximumCharacters
|
||||
) {
|
||||
throw new AssetRestRequestError(`Assets API ${name} is invalid`);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
export const parseAssetRestIdentifier = (value: string | undefined | null) =>
|
||||
parseRequiredAssetString({
|
||||
value,
|
||||
maximumCharacters: assetResourceLimits.assetIdentifierCharacters,
|
||||
name: "identifier",
|
||||
});
|
||||
|
||||
export const parseAssetRestFilename = (value: string | undefined | null) =>
|
||||
parseRequiredAssetString({
|
||||
value,
|
||||
maximumCharacters: assetResourceLimits.assetFilenameCharacters,
|
||||
name: "filename",
|
||||
});
|
||||
|
||||
export const parseAssetRestDescription = (value: string | undefined | null) => {
|
||||
if (
|
||||
value !== undefined &&
|
||||
value !== null &&
|
||||
value.length > assetResourceLimits.assetDescriptionCharacters
|
||||
) {
|
||||
throw new AssetRestRequestError("Assets API description is invalid");
|
||||
}
|
||||
return value ?? undefined;
|
||||
};
|
||||
|
||||
export const parseAssetRestMetadataHeader = (
|
||||
value: string | undefined | null
|
||||
) => {
|
||||
if (value === undefined || value === null) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
new TextEncoder().encode(value).byteLength >
|
||||
assetResourceLimits.restMutationRequestBytes
|
||||
) {
|
||||
throw new AssetRestPayloadTooLargeError(
|
||||
"Assets API metadata header exceeds the byte limit"
|
||||
);
|
||||
}
|
||||
try {
|
||||
return JSON.parse(value) as unknown;
|
||||
} catch (error) {
|
||||
throw new AssetRestRequestError("Assets API metadata header is invalid", {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const getAssetRestProjectId = (request: Request) => {
|
||||
const projectId =
|
||||
parseBuilderUrl(request.url).projectId ??
|
||||
new URL(request.url).searchParams.get("projectId");
|
||||
return parseAssetRestIdentifier(projectId);
|
||||
};
|
||||
|
||||
export const createAssetRestRepositoryForProject = async (
|
||||
request: Request,
|
||||
projectId: string,
|
||||
permit: ProjectPermit = "view"
|
||||
) =>
|
||||
new PostgresAssetRepository({
|
||||
projectId,
|
||||
context: await authorizeApiProject(request, projectId, permit),
|
||||
assetStore: createAssetClient(),
|
||||
});
|
||||
|
||||
export const createAssetRestRepository = async (
|
||||
request: Request,
|
||||
permit: ProjectPermit = "view"
|
||||
) =>
|
||||
createAssetRestRepositoryForProject(
|
||||
request,
|
||||
getAssetRestProjectId(request),
|
||||
permit
|
||||
);
|
||||
|
||||
const getAssetRestErrorStatus = (error: unknown) => {
|
||||
const authorizationFailure = getApiAuthorizationFailure(error);
|
||||
if (authorizationFailure !== undefined) {
|
||||
return authorizationFailure.status;
|
||||
}
|
||||
if (error instanceof AssetRepositoryNotFoundError) {
|
||||
return 404;
|
||||
}
|
||||
if (
|
||||
error instanceof AssetRepositoryConflictError ||
|
||||
error instanceof AssetRevisionConflictError
|
||||
) {
|
||||
return 409;
|
||||
}
|
||||
if (
|
||||
error instanceof ZodError &&
|
||||
error.issues.some(
|
||||
({ message }) => message === assetFolderIssue.duplicateName
|
||||
)
|
||||
) {
|
||||
return 409;
|
||||
}
|
||||
if (error instanceof AssetRestRangeError) {
|
||||
return 416;
|
||||
}
|
||||
if (
|
||||
error instanceof AssetRestPayloadTooLargeError ||
|
||||
error instanceof AssetUploadSizeLimitError
|
||||
) {
|
||||
return 413;
|
||||
}
|
||||
if (error instanceof AssetRestRequestError || error instanceof ZodError) {
|
||||
return 400;
|
||||
}
|
||||
return 500;
|
||||
};
|
||||
|
||||
export const assetRestErrorResponse = (error: unknown) => {
|
||||
if (error instanceof Response) {
|
||||
return error;
|
||||
}
|
||||
const status = getAssetRestErrorStatus(error);
|
||||
if (status === 500) {
|
||||
console.error(error);
|
||||
}
|
||||
return json(
|
||||
{
|
||||
errors:
|
||||
status === 500
|
||||
? "Internal Assets API error"
|
||||
: parseError(error).message,
|
||||
},
|
||||
{ status, headers: privateNoStoreResponseHeaders }
|
||||
);
|
||||
};
|
||||
|
||||
export const assetRestMethodNotAllowed = (
|
||||
allowed: readonly { method: string }[]
|
||||
) =>
|
||||
json(
|
||||
{ errors: "Method not allowed" },
|
||||
{
|
||||
status: 405,
|
||||
headers: {
|
||||
...privateNoStoreResponseHeaders,
|
||||
Allow: allowed.map(({ method }) => method.toUpperCase()).join(", "),
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -9,10 +9,13 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
test("keeps raw browser upload request bodies unchanged", async () => {
|
||||
const request = new Request("https://webstudio.is/rest/assets/image.png", {
|
||||
method: "POST",
|
||||
body: "raw file",
|
||||
});
|
||||
const request = new Request(
|
||||
"https://webstudio.is/rest/assets/uploads/image.png",
|
||||
{
|
||||
method: "POST",
|
||||
body: "raw file",
|
||||
}
|
||||
);
|
||||
|
||||
await expect(
|
||||
new Response(
|
||||
@@ -22,11 +25,14 @@ test("keeps raw browser upload request bodies unchanged", async () => {
|
||||
});
|
||||
|
||||
test("keeps browser JSON file uploads as JSON file content", async () => {
|
||||
const request = new Request("https://webstudio.is/rest/assets/data.json", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ value: 1 }),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
const request = new Request(
|
||||
"https://webstudio.is/rest/assets/uploads/data.json",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ value: 1 }),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
|
||||
await expect(
|
||||
new Response(await getBrowserUploadBody(request, "application/json")).text()
|
||||
@@ -36,11 +42,14 @@ test("keeps browser JSON file uploads as JSON file content", async () => {
|
||||
test("keeps browser URL image uploads by fetching the remote image body", async () => {
|
||||
const fetch = vi.fn(async () => new Response("remote image"));
|
||||
vi.stubGlobal("fetch", fetch);
|
||||
const request = new Request("https://webstudio.is/rest/assets/image.png", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ url: "https://example.com/image.png" }),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
const request = new Request(
|
||||
"https://webstudio.is/rest/assets/uploads/image.png",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ url: "https://example.com/image.png" }),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
|
||||
await expect(
|
||||
new Response(await getBrowserUploadBody(request, "application/json")).text()
|
||||
@@ -56,11 +65,14 @@ test("reports browser URL image fetch failures", async () => {
|
||||
"fetch",
|
||||
vi.fn(async () => new Response("not found", { status: 404 }))
|
||||
);
|
||||
const request = new Request("https://webstudio.is/rest/assets/image.png", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ url: "https://example.com/image.png" }),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
const request = new Request(
|
||||
"https://webstudio.is/rest/assets/uploads/image.png",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ url: "https://example.com/image.png" }),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
|
||||
await expect(
|
||||
getBrowserUploadBody(request, "application/json")
|
||||
|
||||
@@ -4,11 +4,17 @@ import { createPublishedProjectBundleFixture } from "@webstudio-is/protocol/fixt
|
||||
import { buildRouter, __testing__ } from "./build-router.server";
|
||||
import { authorizeProject } from "@webstudio-is/trpc-interface/index.server";
|
||||
import { getApiCompatibilityPayload } from "@webstudio-is/trpc-interface/api-compatibility";
|
||||
import { createStructuredAssetQueryResourceBody } from "@webstudio-is/sdk";
|
||||
import {
|
||||
createAssetIndex,
|
||||
createCanonicalAssetFileEntry,
|
||||
} from "@webstudio-is/content-engine/compiler";
|
||||
|
||||
const {
|
||||
assertCliBundleVersion,
|
||||
createImportProjectBundleHandler,
|
||||
prepareProjectBundleForClient,
|
||||
getContentDatabasePublishDiagnostics,
|
||||
} = __testing__;
|
||||
|
||||
afterEach(() => {
|
||||
@@ -75,6 +81,91 @@ describe("build router project bundle compatibility", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("content database publish diagnostics", () => {
|
||||
test("reports bounded database stats and conservatively affected resources", async () => {
|
||||
const resourceId = "blog-posts";
|
||||
const createEntry = (id: string, bytes: number) =>
|
||||
createCanonicalAssetFileEntry({
|
||||
projectId: "source-project",
|
||||
document: {
|
||||
_id: id,
|
||||
_type: "asset.file",
|
||||
name: `${id}.md`,
|
||||
path: `blog/${id}.md`,
|
||||
key: id,
|
||||
extension: "md",
|
||||
mimeType: "text/markdown",
|
||||
size: bytes,
|
||||
revision: `revision-${id}`,
|
||||
contentRef: `files/${id}.md`,
|
||||
properties: { value: "x".repeat(bytes) },
|
||||
},
|
||||
});
|
||||
const assetIndex = await createAssetIndex({
|
||||
projectId: "source-project",
|
||||
entries: [createEntry("small", 100), createEntry("large", 10_000)],
|
||||
maxBytes: 5_000,
|
||||
});
|
||||
const bundle = createPublishedProjectBundleFixture({
|
||||
build: {
|
||||
props: [
|
||||
[
|
||||
"posts-prop",
|
||||
{
|
||||
id: "posts-prop",
|
||||
instanceId: "collection",
|
||||
name: "data",
|
||||
type: "resource",
|
||||
value: resourceId,
|
||||
},
|
||||
],
|
||||
],
|
||||
resources: [
|
||||
[
|
||||
resourceId,
|
||||
{
|
||||
id: resourceId,
|
||||
name: "Blog posts",
|
||||
control: "system",
|
||||
method: "post",
|
||||
url: '"/$resources/assets"',
|
||||
headers: [],
|
||||
body: createStructuredAssetQueryResourceBody({
|
||||
where: { all: [] },
|
||||
sort: [],
|
||||
limit: "pageSize",
|
||||
offset: "0",
|
||||
output: { mode: "base", includeMetadata: true },
|
||||
content: { mode: "none" },
|
||||
}),
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
assetIndex,
|
||||
});
|
||||
|
||||
expect(getContentDatabasePublishDiagnostics(bundle)).toMatchObject({
|
||||
stats: {
|
||||
maxBytes: 5_000,
|
||||
includedDocumentCount: 1,
|
||||
omittedDocumentCount: 1,
|
||||
truncated: true,
|
||||
},
|
||||
potentiallyAffectedResources: [{ id: resourceId, name: "Blog posts" }],
|
||||
hasDynamicValues: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("does nothing when the build has no content query", () => {
|
||||
expect(
|
||||
getContentDatabasePublishDiagnostics(
|
||||
createPublishedProjectBundleFixture()
|
||||
)
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("build router jsx fragment conversion", () => {
|
||||
test("converts Webstudio JSX through the shared runtime converter", async () => {
|
||||
vi.spyOn(authorizeProject, "hasProjectPermit").mockResolvedValue(true);
|
||||
|
||||
@@ -22,7 +22,12 @@ import type { BuildPatchTransaction } from "@webstudio-is/project/index.server";
|
||||
import { loadDevBuildByProjectId } from "@webstudio-is/project-build/server";
|
||||
import { parseWebstudioJsxFragment } from "@webstudio-is/project-build/transfer/server";
|
||||
import { serializePages } from "@webstudio-is/project-migrations/pages";
|
||||
import { loadAssetDataByProject } from "@webstudio-is/asset-uploader/index.server";
|
||||
import { loadAssetDataByProject } from "@webstudio-is/asset-uploader/server";
|
||||
import {
|
||||
createContentDatabase,
|
||||
hasDynamicContentCompilationValues,
|
||||
} from "@webstudio-is/content-engine";
|
||||
import { createReachableAssetContentCompilationPlan } from "@webstudio-is/sdk";
|
||||
import {
|
||||
checkProjectBuildPermissionInput,
|
||||
importProjectBundleInput,
|
||||
@@ -33,6 +38,7 @@ import { removeAgentInstructionsFromProjectSettings } from "@webstudio-is/projec
|
||||
import { hydrateRestorePointTransaction } from "@webstudio-is/project-build/project-session";
|
||||
import {
|
||||
loadProjectBundleByBuildId,
|
||||
loadProjectBundleByProjectId,
|
||||
loadPublishedProjectBundleByProjectId,
|
||||
} from "~/shared/db";
|
||||
import {
|
||||
@@ -60,6 +66,36 @@ const buildBundleInput = z.object({
|
||||
bundleVersion: z.union([z.string(), z.number()]).optional(),
|
||||
});
|
||||
|
||||
const getContentDatabasePublishDiagnostics = (
|
||||
bundle: z.infer<typeof publishedProjectBundle>
|
||||
) => {
|
||||
if (bundle.assetIndex === undefined) {
|
||||
return;
|
||||
}
|
||||
const plan = createReachableAssetContentCompilationPlan({
|
||||
props: bundle.build.props.map(([, prop]) => prop),
|
||||
dataSources: bundle.build.dataSources.map(([, dataSource]) => dataSource),
|
||||
resources: bundle.build.resources.map(([, resource]) => resource),
|
||||
});
|
||||
if (plan === undefined) {
|
||||
return;
|
||||
}
|
||||
const resourceNameById = new Map(
|
||||
bundle.build.resources.map(([, resource]) => [resource.id, resource.name])
|
||||
);
|
||||
const stats = createContentDatabase({
|
||||
artifact: bundle.assetIndex,
|
||||
}).getStats();
|
||||
return {
|
||||
stats,
|
||||
potentiallyAffectedResources: plan.queries.map(({ id }) => ({
|
||||
id,
|
||||
name: resourceNameById.get(id) ?? id,
|
||||
})),
|
||||
hasDynamicValues: hasDynamicContentCompilationValues(plan),
|
||||
};
|
||||
};
|
||||
|
||||
const assertCliBundleVersion = (
|
||||
ctx: AppContext,
|
||||
clientBundleVersion: string | number | undefined
|
||||
@@ -284,6 +320,15 @@ export const buildRouter = router({
|
||||
);
|
||||
}),
|
||||
|
||||
contentDatabasePublishDiagnostics: procedure
|
||||
.input(z.object({ projectId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
await assertProjectBuildPermit({ ctx, projectId: input.projectId });
|
||||
return getContentDatabasePublishDiagnostics(
|
||||
await loadProjectBundleByProjectId(input.projectId, ctx)
|
||||
);
|
||||
}),
|
||||
|
||||
checkProjectBuildPermission: procedure
|
||||
.input(checkProjectBuildPermissionInput)
|
||||
.query(async ({ ctx, input }) => {
|
||||
@@ -412,4 +457,5 @@ export const __testing__ = {
|
||||
createImportProjectBundleHandler,
|
||||
assertCliBundleVersion,
|
||||
prepareProjectBundleForClient,
|
||||
getContentDatabasePublishDiagnostics,
|
||||
};
|
||||
@@ -2,12 +2,14 @@ import { createCookieSessionStorage } from "@remix-run/node";
|
||||
import env from "~/env/env.server";
|
||||
import { getSessionCookieNameVersion } from "./auth.server.utils";
|
||||
|
||||
export const builderSessionCookieName = `__Host-_session_builder_session_${getSessionCookieNameVersion()}`;
|
||||
|
||||
export const builderSessionStorage = createCookieSessionStorage({
|
||||
cookie: {
|
||||
// Using the __Host- prefix to prevent a malicious user from setting another person's session cookie
|
||||
// on all subdomains of apps.webstudio.is, e.g., setting Domain=.apps.webstudio.is.
|
||||
// For more information, see: https://developer.mozilla.org/en-US/docs/Web/Security/Practical_implementation_guides/Cookies#name
|
||||
name: `__Host-_session_builder_session_${getSessionCookieNameVersion()}`,
|
||||
name: builderSessionCookieName,
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
httpOnly: true,
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import { parseContentDatabaseMaxBytes } from "@webstudio-is/content-engine";
|
||||
|
||||
export const getContentDatabaseMaxBytes = () =>
|
||||
parseContentDatabaseMaxBytes(process.env.CONTENT_DATABASE_MAX_BYTES);
|
||||
Loaded 100 of 582 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user