diff --git a/apps/builder/.env b/apps/builder/.env index 0a25a02949ce..1a61a8efd6f8 100644 --- a/apps/builder/.env +++ b/apps/builder/.env @@ -24,6 +24,8 @@ POSTGREST_API_KEY= # S3 envs # S3_BUCKET= +# Optional dedicated bucket for resource indexes; defaults to S3_BUCKET +# S3_RESOURCE_INDEX_BUCKET= # S3_REGION= # S3_ACL="public-read" # S3_ENDPOINT= diff --git a/apps/builder/app/builder/features/settings-panel/asset-query-form-utils.test.ts b/apps/builder/app/builder/features/settings-panel/asset-query-form-utils.test.ts new file mode 100644 index 000000000000..f67956d7bfb9 --- /dev/null +++ b/apps/builder/app/builder/features/settings-panel/asset-query-form-utils.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, test } from "vitest"; +import { computeExpression } from "@webstudio-is/project-build/runtime"; +import { + createAssetQueryResourceBody, + getAssetIndexStatusLabel, + getAssetFileTypeGroqPredicate, + getAssetQueryConfigurationError, + isEmptyAssetQueryResult, + normalizeAssetQueryParameterBindings, + parseAssetQueryResourceBody, +} from "./asset-query-form-utils"; + +describe("asset query resource body", () => { + test("preserves runtime parameter bindings as expressions", () => { + const body = createAssetQueryResourceBody({ + query: "*[properties.slug == $slug]", + parameters: [ + { name: "slug", value: "$ws$dataSource$routeSlug" }, + { name: "locale", value: '"en"' }, + ], + resultLimit: 1, + }); + + expect( + computeExpression(body, new Map([["routeSlug", "hello-world"]])) + ).toEqual({ + query: "*[properties.slug == $slug]", + parameters: { slug: "hello-world", locale: "en" }, + resultLimit: 1, + content: { mode: "none" }, + }); + expect(parseAssetQueryResourceBody(body)).toEqual({ + queryExpression: '"*[properties.slug == $slug]"', + resultLimitExpression: "1", + contentExpression: '{ "mode": "none" }', + parameters: [ + { name: "slug", value: "$ws$dataSource$routeSlug" }, + { name: "locale", value: '"en"' }, + ], + }); + }); + + test("serializes result and selected-file hydration limits", () => { + const body = createAssetQueryResourceBody({ + query: "*[0]{_id, revision, contentRef}", + parameters: [], + resultLimit: 1, + contentExpression: JSON.stringify({ + mode: "range", + offset: 100, + length: 500, + }), + }); + + expect(computeExpression(body, new Map())).toEqual({ + query: "*[0]{_id, revision, contentRef}", + parameters: {}, + resultLimit: 1, + content: { mode: "range", offset: 100, length: 500 }, + }); + expect(parseAssetQueryResourceBody(body)).toMatchObject({ + resultLimitExpression: "1", + contentExpression: '{"mode":"range","offset":100,"length":500}', + }); + }); + + test("classifies index and empty-result preview states", () => { + const base = { + resourceId: "posts", + queryHash: "query-revision", + assetRevision: "asset-revision", + updatedAt: "2026-07-18T12:00:00.000Z", + }; + expect(getAssetIndexStatusLabel(undefined)).toContain("No index"); + expect(getAssetIndexStatusLabel({ ...base, state: "indexing" })).toContain( + "first index" + ); + expect(getAssetIndexStatusLabel({ ...base, state: "stale" })).toContain( + "stale" + ); + expect(getAssetIndexStatusLabel({ ...base, state: "failed" })).toContain( + "failed" + ); + expect( + getAssetIndexStatusLabel({ + ...base, + state: "active", + activeRevision: "active-revision", + }) + ).toContain("active"); + expect(isEmptyAssetQueryResult([])).toBe(true); + expect(isEmptyAssetQueryResult(null)).toBe(true); + expect(isEmptyAssetQueryResult({})).toBe(false); + }); + + test("file-type helpers produce visible GROQ source", () => { + expect(getAssetFileTypeGroqPredicate("md")).toBe('extension == "md"'); + }); + + test("normalizes runtime parameter names without changing expressions", () => { + expect( + normalizeAssetQueryParameterBindings([ + { name: " slug ", value: "system.params.slug" }, + ]) + ).toEqual([{ name: "slug", value: "system.params.slug" }]); + }); + + test("rejects invalid and duplicate query options before saving", () => { + expect( + getAssetQueryConfigurationError({ + query: "*[]", + parameters: [{ name: "slug" }, { name: "slug" }], + resultLimit: 1, + content: { mode: "none" }, + }) + ).toContain("unique"); + expect( + getAssetQueryConfigurationError({ + query: "*[]", + parameters: [], + resultLimit: 0, + content: { mode: "none" }, + }) + ).toContain("Result limit"); + expect( + getAssetQueryConfigurationError({ + query: "*[properties.slug == $slug]", + parameters: [{ name: "slug" }], + resultLimit: 1, + content: { mode: "range", offset: 0, length: 10 }, + }) + ).toBeUndefined(); + expect( + getAssetQueryConfigurationError({ + query: "*[properties.slug == $slug]", + parameters: [{ name: " slug " }], + resultLimit: 1, + content: { mode: "none" }, + }) + ).toBeUndefined(); + expect( + getAssetQueryConfigurationError({ + query: "*[properties.slug == $slug]", + parameters: [], + resultLimit: 1, + content: { mode: "none" }, + }) + ).toContain("$slug"); + }); +}); diff --git a/apps/builder/app/builder/features/settings-panel/asset-query-form-utils.ts b/apps/builder/app/builder/features/settings-panel/asset-query-form-utils.ts new file mode 100644 index 000000000000..1b4368ea8687 --- /dev/null +++ b/apps/builder/app/builder/features/settings-panel/asset-query-form-utils.ts @@ -0,0 +1,104 @@ +import { + assetResourceParameterName, + assetResourceContentOptions, + assetResourceLimits, + normalizeAssetQueryParameterName, + type AssetResourceContentOptions, + type AssetResourceIndexStatus, +} from "@webstudio-is/sdk"; +import { validateAssetResourceQuery } from "@webstudio-is/asset-resource"; +export { + createAssetQueryResourceBody, + parseAssetQueryResourceBody, +} from "@webstudio-is/sdk"; +export type { AssetQueryParameterBinding } from "@webstudio-is/sdk"; + +export const normalizeAssetQueryParameterBindings = < + Binding extends { name: string }, +>( + parameters: readonly Binding[] +) => + parameters.map((parameter) => ({ + ...parameter, + name: normalizeAssetQueryParameterName(parameter.name), + })); + +export const getAssetIndexStatusLabel = ( + status: AssetResourceIndexStatus | undefined +) => { + if (status === undefined) { + return "No index has been created yet."; + } + if (status.state === "indexing") { + return status.activeRevision === undefined + ? "Building the first index…" + : "Building a replacement index; the last active revision is preserved."; + } + if (status.state === "stale") { + return "The saved query index is stale."; + } + if (status.state === "failed") { + return status.activeRevision === undefined + ? "The index build failed." + : "The replacement build failed; the last active revision is preserved."; + } + return "The query index is active."; +}; + +export const isEmptyAssetQueryResult = (result: unknown) => + result === null || (Array.isArray(result) && result.length === 0); + +export const getAssetFileTypeGroqPredicate = (extension: string) => + `extension == ${JSON.stringify(extension)}`; + +export const getAssetQueryConfigurationError = ({ + query, + parameters, + resultLimit, + content, +}: { + query: string; + parameters: readonly { name: string }[]; + resultLimit: number; + content: AssetResourceContentOptions; +}) => { + let referencedParameterNames: string[]; + try { + referencedParameterNames = validateAssetResourceQuery(query).parameterNames; + } catch { + return "Enter a valid GROQ query."; + } + if (parameters.length > assetResourceLimits.parameterCount) { + return `Use at most ${assetResourceLimits.parameterCount} runtime parameters.`; + } + const names = normalizeAssetQueryParameterBindings(parameters).map( + ({ name }) => name + ); + if ( + names.some( + (name) => assetResourceParameterName.safeParse(name).success === false + ) + ) { + return "Runtime parameter names must be valid identifiers."; + } + if (new Set(names).size !== names.length) { + return "Runtime parameter names must be unique."; + } + const configuredNames = new Set(names); + const missingName = referencedParameterNames.find( + (name) => configuredNames.has(name) === false + ); + if (missingName !== undefined) { + return `Add a runtime binding for $${missingName}.`; + } + if ( + Number.isSafeInteger(resultLimit) === false || + resultLimit < 1 || + resultLimit > assetResourceLimits.resultCount + ) { + return `Result limit must be between 1 and ${assetResourceLimits.resultCount}.`; + } + if (assetResourceContentOptions.safeParse(content).success === false) { + return "Selected-file content options are outside the supported limits."; + } +}; diff --git a/apps/builder/app/builder/features/settings-panel/asset-query-form.tsx b/apps/builder/app/builder/features/settings-panel/asset-query-form.tsx new file mode 100644 index 000000000000..3a9700f313d7 --- /dev/null +++ b/apps/builder/app/builder/features/settings-panel/asset-query-form.tsx @@ -0,0 +1,603 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { useStore } from "@nanostores/react"; +import { + assetResourceParameterName, + assetResourceContentOptions, + assetResourceLimits, + assetResourceIndexStatus, + assetResourceQueryResponse, + builderAssetFieldCatalog, + isLiteralExpression, + type AssetResourceContentOptions, + type AssetResourceIndexStatus, + type AssetResourceQuerySuccess, + type BuilderAssetFieldCatalog, + type Resource, +} from "@webstudio-is/sdk"; +import { + Button, + Flex, + Grid, + InputField, + Label, + Select, + SmallIconButton, + Switch, + Text, +} from "@webstudio-is/design-system"; +import { PlusIcon, TrashIcon } from "@webstudio-is/icons"; +import { getAssetResourceReferencedFieldPaths } from "@webstudio-is/asset-resource"; +import { $assets } from "~/shared/sync/data-stores"; +import { + BindingControl, + BindingPopover, + evaluateExpressionWithinScope, +} from "~/builder/shared/binding-popover"; +import { ExpressionEditor } from "~/builder/shared/expression-editor"; +import { CodeEditor } from "~/shared/code-editor"; +import type { EditorApi } from "~/shared/code-editor-base"; +import { + loadBuilderAssetFieldCatalog, + loadBuilderAssetIndexStatus, + previewBuilderAssetQuery, +} from "~/shared/asset-resource-api.client"; +import { + createAssetQueryResourceBody, + getAssetFileTypeGroqPredicate, + getAssetIndexStatusLabel, + getAssetQueryConfigurationError, + isEmptyAssetQueryResult, + normalizeAssetQueryParameterBindings, + parseAssetQueryResourceBody, + type AssetQueryParameterBinding, +} from "./asset-query-form-utils"; + +const AssetQueryParameters = ({ + scope, + aliases, + parameters, + onChange, +}: { + scope: Record; + aliases: Map; + parameters: AssetQueryParameterBinding[]; + onChange: (parameters: AssetQueryParameterBinding[]) => void; +}) => ( + + + + } + disabled={parameters.length >= assetResourceLimits.parameterCount} + onClick={() => onChange([...parameters, { name: "", value: '""' }])} + /> + + + {parameters.map((parameter, index) => ( + + { + const next = [...parameters]; + next[index] = { ...parameter, name: event.target.value }; + onChange(next); + }} + /> + +
+ { + const next = [...parameters]; + next[index] = { ...parameter, value }; + onChange(next); + }} + onChangeComplete={(value) => { + const next = [...parameters]; + next[index] = { ...parameter, value }; + onChange(next); + }} + /> +
+ { + const next = [...parameters]; + next[index] = { ...parameter, value }; + onChange(next); + }} + onRemove={(value) => { + const next = [...parameters]; + next[index] = { ...parameter, value: JSON.stringify(value) }; + onChange(next); + }} + /> +
+ } + onClick={() => + onChange(parameters.filter((_, item) => item !== index)) + } + /> +
+ ))} + {parameters.length === 0 && ( + + No runtime parameters + + )} +
+
+); + +const contentModeLabels: Record = { + none: "No content", + full: "Complete text", + range: "Byte range", + "markdown-body": "Markdown body", +}; + +const AssetQueryOptions = ({ + resultLimit, + content, + onResultLimitChange, + onContentChange, +}: { + resultLimit: number; + content: AssetResourceContentOptions; + onResultLimitChange: (value: number) => void; + onContentChange: (value: AssetResourceContentOptions) => void; +}) => ( + + + + { + if (Number.isNaN(event.target.valueAsNumber) === false) { + onResultLimitChange(event.target.valueAsNumber); + } + }} + /> + + + + + aria-label="Selected-file content" + options={["none", "full", "range", "markdown-body"]} + getLabel={(mode: AssetResourceContentOptions["mode"]) => + contentModeLabels[mode] + } + value={content.mode} + onChange={(mode) => + onContentChange( + mode === "range" ? { mode, offset: 0, length: 16 * 1024 } : { mode } + ) + } + /> + + {(content.mode === "full" || content.mode === "markdown-body") && ( + + + { + if (Number.isNaN(event.target.valueAsNumber) === false) { + onContentChange({ + mode: content.mode, + maxBytes: event.target.valueAsNumber, + }); + } + }} + /> + + )} + {content.mode === "range" && ( + + + + { + if (Number.isNaN(event.target.valueAsNumber) === false) { + onContentChange({ + ...content, + offset: event.target.valueAsNumber, + }); + } + }} + /> + + + + { + if (Number.isNaN(event.target.valueAsNumber) === false) { + onContentChange({ + ...content, + length: event.target.valueAsNumber, + }); + } + }} + /> + + + )} + +); + +const AssetQueryPreview = ({ + resourceId, + resourceRevision, + query, + parameters, + scope, + resultLimit, + content, + indexStatus, + onIndexStatusChange, +}: { + resourceId?: string; + resourceRevision?: string; + query: string; + parameters: AssetQueryParameterBinding[]; + scope: Record; + resultLimit: number; + content: AssetResourceContentOptions; + indexStatus?: AssetResourceIndexStatus; + onIndexStatusChange: (status: AssetResourceIndexStatus | undefined) => void; +}) => { + const [previewState, setPreviewState] = useState< + | { type: "idle" } + | { type: "loading" } + | { type: "error"; message: string } + | { type: "success"; response: AssetResourceQuerySuccess } + >({ type: "idle" }); + const previewInputKey = JSON.stringify({ + query, + parameters, + resultLimit, + content, + }); + const previewInputKeyRef = useRef(previewInputKey); + previewInputKeyRef.current = previewInputKey; + + useEffect(() => { + setPreviewState({ type: "idle" }); + }, [previewInputKey]); + + useEffect(() => { + if (resourceId === undefined) { + onIndexStatusChange(undefined); + return; + } + let ignore = false; + let pollTimeout: ReturnType | undefined; + const load = async () => { + try { + const response = await loadBuilderAssetIndexStatus(resourceId); + if (ignore) { + return; + } + const data = response.data as { status?: unknown }; + const parsed = assetResourceIndexStatus.safeParse(data.status); + const status = parsed.success ? parsed.data : undefined; + onIndexStatusChange(status); + if (status?.state === "indexing") { + pollTimeout = setTimeout(load, 1000); + } + } catch { + if (ignore === false) { + onIndexStatusChange(undefined); + } + } + }; + void load(); + return () => { + ignore = true; + if (pollTimeout !== undefined) { + clearTimeout(pollTimeout); + } + }; + }, [onIndexStatusChange, resourceId, resourceRevision]); + + const preview = async () => { + const requestedInputKey = previewInputKey; + setPreviewState({ type: "loading" }); + try { + const response = await previewBuilderAssetQuery({ + query, + parameters: Object.fromEntries( + normalizeAssetQueryParameterBindings(parameters) + .filter(({ name }) => name.trim().length > 0) + .map(({ name, value }) => [ + name, + evaluateExpressionWithinScope(value, scope), + ]) + ), + resultLimit, + content, + }); + if (previewInputKeyRef.current !== requestedInputKey) { + return; + } + const parsed = assetResourceQueryResponse.safeParse(response.data); + if (parsed.success === false) { + setPreviewState({ + type: "error", + message: "The preview response was invalid.", + }); + return; + } + if (parsed.data.ok === false) { + setPreviewState({ type: "error", message: parsed.data.error.message }); + return; + } + setPreviewState({ type: "success", response: parsed.data }); + } catch { + if (previewInputKeyRef.current !== requestedInputKey) { + return; + } + setPreviewState({ type: "error", message: "The query preview failed." }); + } + }; + const isEmpty = + previewState.type === "success" && + isEmptyAssetQueryResult(previewState.response.result); + + return ( + + + + + + + {getAssetIndexStatusLabel(indexStatus)} + + {previewState.type === "error" && ( + {previewState.message} + )} + {isEmpty && The query returned no results.} + {previewState.type === "success" && isEmpty === false && ( + {}} + onChangeComplete={() => {}} + /> + )} + + ); +}; + +export const AssetQueryForm = ({ + resource, + scope, + aliases, + enabled, + enabledId, + onEnabledChange, +}: { + resource?: Resource; + scope: Record; + aliases: Map; + enabled: boolean; + enabledId: string; + onEnabledChange: (enabled: boolean) => void; +}) => { + const assets = useStore($assets); + const parsedBody = useMemo( + () => parseAssetQueryResourceBody(resource?.body), + [resource?.body] + ); + const initialQuery = evaluateExpressionWithinScope( + parsedBody.queryExpression ?? "", + {} + ); + const [query, setQuery] = useState( + typeof initialQuery === "string" + ? initialQuery + : '*[_type == "asset.file"] | order(_id asc) [0...20]' + ); + const [parameters, setParameters] = useState(parsedBody.parameters); + const parsedResultLimit = evaluateExpressionWithinScope( + parsedBody.resultLimitExpression ?? "", + {} + ); + const [resultLimit, setResultLimit] = useState( + typeof parsedResultLimit === "number" ? parsedResultLimit : 100 + ); + const parsedContent = assetResourceContentOptions.safeParse( + evaluateExpressionWithinScope(parsedBody.contentExpression ?? "", {}) + ); + const [content, setContent] = useState( + parsedContent.success ? parsedContent.data : { mode: "none" } + ); + const [fieldCatalog, setFieldCatalog] = useState(); + const [indexStatus, setIndexStatus] = useState(); + const configurationError = getAssetQueryConfigurationError({ + query, + parameters, + resultLimit, + content, + }); + + useEffect(() => { + if (enabled === false) { + return; + } + let ignore = false; + loadBuilderAssetFieldCatalog() + .then((response) => { + if (ignore) { + return; + } + const parsed = builderAssetFieldCatalog.safeParse(response.data); + setFieldCatalog(parsed.success ? parsed.data : undefined); + }) + .catch(() => { + if (ignore === false) { + setFieldCatalog(undefined); + } + }); + return () => { + ignore = true; + }; + }, [assets, enabled]); + + const editorApiRef = useRef(); + const groqCompletion = useMemo(() => { + let resourceFieldPaths: Set | undefined; + if (indexStatus?.activeRevision !== undefined) { + try { + resourceFieldPaths = new Set( + getAssetResourceReferencedFieldPaths(query) + ); + } catch { + // Keep project catalog completions while the query is temporarily invalid. + } + } + return { + catalog: fieldCatalog, + parameterNames: Array.from( + new Set( + normalizeAssetQueryParameterBindings(parameters) + .map(({ name }) => name) + .filter( + (name) => assetResourceParameterName.safeParse(name).success + ) + ) + ), + resourceFieldPaths, + }; + }, [fieldCatalog, indexStatus?.activeRevision, parameters, query]); + + return ( + <> + + + + + {enabled && ( + <> + + + + + + + + + + + + + + {configurationError !== undefined && ( + {configurationError} + )} + + + )} + + ); +}; diff --git a/apps/builder/app/builder/features/settings-panel/resource-panel.tsx b/apps/builder/app/builder/features/settings-panel/resource-panel.tsx index 0adc756f6244..a725b5b07b2c 100644 --- a/apps/builder/app/builder/features/settings-panel/resource-panel.tsx +++ b/apps/builder/app/builder/features/settings-panel/resource-panel.tsx @@ -21,6 +21,7 @@ import { encodeDataVariableId, generateObjectExpression, isLiteralExpression, + isStoredAssetQueryResource, parseObjectExpression, SYSTEM_VARIABLE_ID, systemParameter, @@ -30,6 +31,7 @@ import { sitemapResourceUrl, currentDateResourceUrl, assetsResourceUrl, + assetsQueryResourceUrl, } from "@webstudio-is/sdk/runtime"; import { Box, @@ -46,6 +48,7 @@ import { theme, } from "@webstudio-is/design-system"; import { TrashIcon, InfoCircleIcon, PlusIcon } from "@webstudio-is/icons"; +import { isFeatureEnabled } from "@webstudio-is/feature-flags"; import { humanizeString } from "~/shared/string-utils"; import { $variableValuesByInstanceSelector } from "~/shared/nano-states"; import { $dataSources } from "~/shared/sync/data-stores"; @@ -76,6 +79,7 @@ import { type ResourceBodyInputType, } from "@webstudio-is/project-build/runtime"; import { parseCurl, type CurlRequest } from "./curl"; +import { AssetQueryForm } from "./asset-query-form"; export const UrlField = ({ scope, @@ -353,7 +357,7 @@ const HeaderPair = ({ value={name} onChange={(event) => onChange(event.target.value, value)} /> - + { }; type PanelApi = { - save: (formData: FormData) => void; + save: (formData: FormData) => void | false; }; type BodyType = ResourceBodyInputType; @@ -912,13 +916,22 @@ export const SystemResourceForm = forwardRef< undefined | PanelApi, { variable?: DataSource } >(({ variable }, ref) => { + const { scope, aliases } = useResourceScope({ variable }); const resources = useStore($resources); const resource = variable?.type === "resource" ? resources.get(variable.resourceId) : undefined; + const isStoredAssetQuery = + resource !== undefined && isStoredAssetQueryResource(resource); + const assetsLocalResource = { + label: "Assets", + value: JSON.stringify(assetsResourceUrl), + description: + "Loads all project assets by default, with optional GROQ querying and selected-file content.", + }; const localResources = [ { label: "Sitemap", @@ -931,24 +944,31 @@ export const SystemResourceForm = forwardRef< description: "Provides current date information (year, month, day) normalized to midnight UTC. Time components are set to 00:00:00 to prevent React hydration errors.", }, - { - label: "Assets", - value: JSON.stringify(assetsResourceUrl), - description: - "Resource that loads the list of assets of the current project.", - }, + assetsLocalResource, ]; const [localResource, setLocalResource] = useState(() => { + if (isStoredAssetQuery) { + return assetsLocalResource; + } return ( localResources.find( (localResource) => localResource.value === resource?.url ) ?? localResources[0] ); }); - + const isAssetsResource = + localResource.value === JSON.stringify(assetsResourceUrl); + const canConfigureAssetQuery = + isFeatureEnabled("assetResource") || isStoredAssetQuery; + const [isAssetQueryEnabled, setIsAssetQueryEnabled] = + useState(isStoredAssetQuery); + const isAssetQuery = isAssetsResource && isAssetQueryEnabled; useImperativeHandle(ref, () => ({ save: (formData) => { + if (formData.get("asset-query-valid") === "false") { + return false; + } // preserve existing instance scope when edit const scopeInstanceId = variable?.scopeInstanceId ?? $selectedInstance.get()?.id; @@ -973,11 +993,24 @@ export const SystemResourceForm = forwardRef< })); const resourceId = useId(); + const assetQueryEnabledId = useId(); return ( <> - - + +