@salesforce/ui-bundle-template-app-react-sample-b2e 11.63.0 → 11.66.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (17) hide show
  1. package/dist/CHANGELOG.md +56 -0
  2. package/dist/force-app/main/default/uiBundles/propertymanagementapp/package.json +4 -4
  3. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/__tests__/cmsQueryFragment.test.ts +48 -5
  4. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/__tests__/searchChannel.test.ts +65 -3
  5. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/cmsQueryFragment.ts +14 -3
  6. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/index.ts +1 -0
  7. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/cms/searchChannel.ts +17 -1
  8. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/adapters/types.ts +2 -0
  9. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/components/MergedSearchResults.tsx +16 -4
  10. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/components/SearchResults.tsx +1 -0
  11. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/components/SourceSection.tsx +21 -2
  12. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/components/__tests__/MergedSearchResults.test.tsx +93 -0
  13. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/components/__tests__/SourceSection.test.tsx +90 -0
  14. package/dist/force-app/main/default/uiBundles/propertymanagementapp/src/features/search/hooks/useSearch.ts +8 -4
  15. package/dist/package-lock.json +2 -2
  16. package/dist/package.json +1 -1
  17. package/package.json +2 -2
package/dist/CHANGELOG.md CHANGED
@@ -3,6 +3,62 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ ## [11.66.1](https://github.com/salesforce-experience-platform-emu/webapps/compare/v11.66.0...v11.66.1) (2026-08-20)
7
+
8
+ **Note:** Version bump only for package @salesforce/ui-bundle-template-base-sfdx-project
9
+
10
+
11
+
12
+
13
+
14
+ ## [11.66.0](https://github.com/salesforce-experience-platform-emu/webapps/compare/v11.65.3...v11.66.0) (2026-08-19)
15
+
16
+ **Note:** Version bump only for package @salesforce/ui-bundle-template-base-sfdx-project
17
+
18
+
19
+
20
+
21
+
22
+ ## [11.65.3](https://github.com/salesforce-experience-platform-emu/webapps/compare/v11.65.2...v11.65.3) (2026-08-19)
23
+
24
+ **Note:** Version bump only for package @salesforce/ui-bundle-template-base-sfdx-project
25
+
26
+
27
+
28
+
29
+
30
+ ## [11.65.2](https://github.com/salesforce-experience-platform-emu/webapps/compare/v11.65.1...v11.65.2) (2026-08-19)
31
+
32
+ **Note:** Version bump only for package @salesforce/ui-bundle-template-base-sfdx-project
33
+
34
+
35
+
36
+
37
+
38
+ ## [11.65.1](https://github.com/salesforce-experience-platform-emu/webapps/compare/v11.65.0...v11.65.1) (2026-08-19)
39
+
40
+ **Note:** Version bump only for package @salesforce/ui-bundle-template-base-sfdx-project
41
+
42
+
43
+
44
+
45
+
46
+ ## [11.65.0](https://github.com/salesforce-experience-platform-emu/webapps/compare/v11.64.0...v11.65.0) (2026-08-19)
47
+
48
+ **Note:** Version bump only for package @salesforce/ui-bundle-template-base-sfdx-project
49
+
50
+
51
+
52
+
53
+
54
+ ## [11.64.0](https://github.com/salesforce-experience-platform-emu/webapps/compare/v11.63.0...v11.64.0) (2026-08-18)
55
+
56
+ **Note:** Version bump only for package @salesforce/ui-bundle-template-base-sfdx-project
57
+
58
+
59
+
60
+
61
+
6
62
  ## [11.63.0](https://github.com/salesforce-experience-platform-emu/webapps/compare/v11.62.1...v11.63.0) (2026-08-18)
7
63
 
8
64
  **Note:** Version bump only for package @salesforce/ui-bundle-template-base-sfdx-project
@@ -18,8 +18,8 @@
18
18
  "graphql:schema": "node scripts/get-graphql-schema.mjs"
19
19
  },
20
20
  "dependencies": {
21
- "@salesforce/platform-sdk": "^11.63.0",
22
- "@salesforce/ui-bundle": "^11.63.0",
21
+ "@salesforce/platform-sdk": "^11.66.1",
22
+ "@salesforce/ui-bundle": "^11.66.1",
23
23
  "@tailwindcss/vite": "^4.1.17",
24
24
  "class-variance-authority": "^0.7.1",
25
25
  "clsx": "^2.1.1",
@@ -46,8 +46,8 @@
46
46
  "@graphql-eslint/eslint-plugin": "^4.1.0",
47
47
  "@graphql-tools/utils": "^11.0.0",
48
48
  "@playwright/test": "^1.49.0",
49
- "@salesforce/graphiti": "^11.63.0",
50
- "@salesforce/vite-plugin-ui-bundle": "^11.63.0",
49
+ "@salesforce/graphiti": "^11.66.1",
50
+ "@salesforce/vite-plugin-ui-bundle": "^11.66.1",
51
51
  "@testing-library/jest-dom": "^6.6.3",
52
52
  "@testing-library/react": "^16.1.0",
53
53
  "@testing-library/user-event": "^14.5.2",
@@ -4,11 +4,7 @@
4
4
  * For full license text, see the LICENSE.txt file
5
5
  */
6
6
 
7
- /**
8
- * Tests for buildCmsQueryFragment (via the cmsAdapter and directly) — the
9
- * CONDITIONAL $cmsContentTypeFQNs rule, variable parameterization (no
10
- * interpolation), root placement, and afterCursor → offset decoding.
11
- */
7
+ /** Tests for buildCmsQueryFragment: conditional content-type and language args, and cursor decoding. */
12
8
  import { describe, it, expect } from "vitest";
13
9
  import { buildCmsQueryFragment } from "../cmsQueryFragment";
14
10
  import { cmsAdapter } from "../index";
@@ -93,6 +89,41 @@ describe("buildCmsQueryFragment", () => {
93
89
  expect(c.fragment).not.toContain("$cmsChannelId");
94
90
  expect(c.variables.UIBundleId).toBe("0ap1");
95
91
  });
92
+
93
+ it("no language: omits $cmsLanguage entirely (a UIBundle with no default lang would error)", () => {
94
+ const c = buildCmsQueryFragment({ keyword: "hi", uiBundleId: "0ap1", offset: 0, limit: 20 });
95
+ expect(c.variableDeclarations).not.toContain("$cmsLanguage: String");
96
+ expect(c.variables).not.toHaveProperty("cmsLanguage");
97
+ expect(c.fragment).not.toContain("language:");
98
+ expect(c.fragment).not.toContain("$cmsLanguage");
99
+ });
100
+
101
+ it("with language: declares, passes and supplies $cmsLanguage as a top-level arg", () => {
102
+ const c = buildCmsQueryFragment({
103
+ keyword: "hi",
104
+ uiBundleId: "0ap1",
105
+ offset: 0,
106
+ limit: 20,
107
+ language: "en-US",
108
+ });
109
+ expect(c.variableDeclarations).toContain("$cmsLanguage: String");
110
+ expect(c.variables.cmsLanguage).toBe("en-US");
111
+ // Top-level `language:` argument (sibling to keyword/pagination), never interpolated.
112
+ expect(c.fragment).toContain("language: $cmsLanguage");
113
+ expect(c.fragment).not.toContain('language: "');
114
+ });
115
+
116
+ it("empty language string is treated as absent (omitted)", () => {
117
+ const c = buildCmsQueryFragment({
118
+ keyword: "hi",
119
+ uiBundleId: "0ap1",
120
+ offset: 0,
121
+ limit: 20,
122
+ language: "",
123
+ });
124
+ expect(c.variableDeclarations).not.toContain("$cmsLanguage: String");
125
+ expect(c.fragment).not.toContain("language: $cmsLanguage");
126
+ });
96
127
  });
97
128
 
98
129
  describe("cmsAdapter.buildRequest", () => {
@@ -124,4 +155,16 @@ describe("cmsAdapter.buildRequest", () => {
124
155
  expect(c.variables).not.toHaveProperty("cmsContentTypeFQNs");
125
156
  expect(c.variableDeclarations).not.toContain("$cmsContentTypeFQNs: [String!]");
126
157
  });
158
+
159
+ it("threads request.language into the $cmsLanguage variable", () => {
160
+ const c = cmsAdapter.buildRequest(req({ language: "fr-FR" }));
161
+ expect(c.variables.cmsLanguage).toBe("fr-FR");
162
+ expect(c.fragment).toContain("language: $cmsLanguage");
163
+ });
164
+
165
+ it("omits language when request.language is absent", () => {
166
+ const c = cmsAdapter.buildRequest(req());
167
+ expect(c.variables).not.toHaveProperty("cmsLanguage");
168
+ expect(c.fragment).not.toContain("language: $cmsLanguage");
169
+ });
127
170
  });
@@ -4,16 +4,27 @@
4
4
  * For full license text, see the LICENSE.txt file
5
5
  */
6
6
 
7
- /** Tests for searchChannel UIBundle id resolution and validation. */
7
+ /** Tests for searchChannel UIBundle id resolution, language, and validation. */
8
8
  import { describe, it, expect, vi, beforeEach } from "vitest";
9
- import { getUIBundleId, isConfiguredUIBundleId } from "../searchChannel";
9
+ import {
10
+ DEFAULT_LANGUAGE,
11
+ getUIBundleId,
12
+ getUserLanguage,
13
+ isConfiguredUIBundleId,
14
+ } from "../searchChannel";
10
15
  import type { CurrentApp } from "@salesforce/platform-sdk";
11
16
 
12
17
  vi.mock("@salesforce/platform-sdk", () => ({
13
18
  getCurrentApp: vi.fn(),
19
+ createDataSDK: vi.fn(),
14
20
  }));
15
21
 
16
- const { getCurrentApp } = await import("@salesforce/platform-sdk");
22
+ vi.mock("@salesforce/platform-sdk/i18n", () => ({
23
+ fetchI18nContext: vi.fn(),
24
+ }));
25
+
26
+ const { getCurrentApp, createDataSDK } = await import("@salesforce/platform-sdk");
27
+ const { fetchI18nContext } = await import("@salesforce/platform-sdk/i18n");
17
28
 
18
29
  describe("searchChannel", () => {
19
30
  beforeEach(() => {
@@ -70,6 +81,57 @@ describe("searchChannel", () => {
70
81
  });
71
82
  });
72
83
 
84
+ describe("getUserLanguage", () => {
85
+ const sdkWithGraphql = { graphql: {} } as unknown as Awaited<ReturnType<typeof createDataSDK>>;
86
+
87
+ it("returns the i18n context's lang tag", async () => {
88
+ vi.mocked(createDataSDK).mockResolvedValue(sdkWithGraphql);
89
+ vi.mocked(fetchI18nContext).mockResolvedValue({
90
+ lang: "en-US",
91
+ locale: "en-US",
92
+ dir: "ltr",
93
+ currency: "USD",
94
+ timeZone: "America/Los_Angeles",
95
+ });
96
+
97
+ expect(await getUserLanguage()).toBe("en-US");
98
+ expect(fetchI18nContext).toHaveBeenCalledWith(sdkWithGraphql);
99
+ });
100
+
101
+ it("falls back to the default when the context lang is empty", async () => {
102
+ vi.mocked(createDataSDK).mockResolvedValue(sdkWithGraphql);
103
+ vi.mocked(fetchI18nContext).mockResolvedValue({
104
+ lang: "",
105
+ locale: "",
106
+ dir: "ltr",
107
+ currency: "USD",
108
+ timeZone: "America/Los_Angeles",
109
+ });
110
+
111
+ expect(await getUserLanguage()).toBe(DEFAULT_LANGUAGE);
112
+ });
113
+
114
+ it("falls back to the default when the SDK has no graphql surface", async () => {
115
+ vi.mocked(createDataSDK).mockResolvedValue({} as Awaited<ReturnType<typeof createDataSDK>>);
116
+
117
+ expect(await getUserLanguage()).toBe(DEFAULT_LANGUAGE);
118
+ expect(fetchI18nContext).not.toHaveBeenCalled();
119
+ });
120
+
121
+ it("falls back to the default (never throws) when the SDK init rejects", async () => {
122
+ vi.mocked(createDataSDK).mockRejectedValue(new Error("no SDK on this surface"));
123
+
124
+ await expect(getUserLanguage()).resolves.toBe(DEFAULT_LANGUAGE);
125
+ });
126
+
127
+ it("falls back to the default when the i18n query rejects", async () => {
128
+ vi.mocked(createDataSDK).mockResolvedValue(sdkWithGraphql);
129
+ vi.mocked(fetchI18nContext).mockRejectedValue(new Error("I18nDetect failed"));
130
+
131
+ await expect(getUserLanguage()).resolves.toBe(DEFAULT_LANGUAGE);
132
+ });
133
+ });
134
+
73
135
  describe("isConfiguredUIBundleId", () => {
74
136
  it("returns true for a well-formed 9YE-shaped UIBundle id", () => {
75
137
  expect(isConfiguredUIBundleId("9YE000001abcdEFGHi")).toBe(true);
@@ -5,7 +5,8 @@
5
5
  *
6
6
  * The `$cmsContentTypeFQNs` declaration, its `contentTypeFQNs:` argument, and
7
7
  * its variable value are all emitted only when a non-empty content-type list is
8
- * passed; on the bootstrap call (no types known) they are omitted entirely.
8
+ * passed; on the bootstrap call (no types known) they are omitted entirely. The
9
+ * `$cmsLanguage` pieces follow the same conditional pattern.
9
10
  */
10
11
 
11
12
  import type { QueryFragmentContribution } from "../types";
@@ -26,6 +27,8 @@ export interface BuildCmsFragmentParams {
26
27
  * `isValidCmsFqn`-validated values.
27
28
  */
28
29
  contentTypes?: string[] | null;
30
+ /** User language tag for the `language` argument; emitted only when a non-empty string. */
31
+ language?: string | null;
29
32
  }
30
33
 
31
34
  /**
@@ -33,9 +36,10 @@ export interface BuildCmsFragmentParams {
33
36
  * included only when `params.contentTypes` is a non-empty array.
34
37
  */
35
38
  export function buildCmsQueryFragment(params: BuildCmsFragmentParams): QueryFragmentContribution {
36
- const { keyword, uiBundleId, offset, limit, contentTypes } = params;
39
+ const { keyword, uiBundleId, offset, limit, contentTypes, language } = params;
37
40
 
38
41
  const hasContentTypes = Array.isArray(contentTypes) && contentTypes.length > 0;
42
+ const hasLanguage = typeof language === "string" && language.length > 0;
39
43
 
40
44
  const variableDeclarations = [
41
45
  "$cmsKeyword: String!",
@@ -61,12 +65,19 @@ export function buildCmsQueryFragment(params: BuildCmsFragmentParams): QueryFrag
61
65
  variables.cmsContentTypeFQNs = contentTypes;
62
66
  }
63
67
 
68
+ // Top-level `language: String` arg, emitted only when a non-empty tag is provided.
69
+ const languageArg = hasLanguage ? "\n language: $cmsLanguage" : "";
70
+ if (hasLanguage) {
71
+ variableDeclarations.push("$cmsLanguage: String");
72
+ variables.cmsLanguage = language;
73
+ }
74
+
64
75
  const fragment = `managed_content {
65
76
  search {
66
77
  searchContentInChannels(
67
78
  searchIdentifiers: { uibundleIds: [$UIBundleId] }
68
79
  keyword: $cmsKeyword
69
- pagination: { limit: $cmsLimit, offset: $cmsOffset }${contentTypesArg}
80
+ pagination: { limit: $cmsLimit, offset: $cmsOffset }${languageArg}${contentTypesArg}
70
81
  ) {
71
82
  total
72
83
  offset
@@ -44,6 +44,7 @@ function buildCmsRequest(request: SourceRequest<CmsSourceConfig>): QueryFragment
44
44
  offset,
45
45
  limit: request.pageSize,
46
46
  contentTypes: contentTypes && contentTypes.length > 0 ? contentTypes : null,
47
+ language: request.language ?? null,
47
48
  });
48
49
  }
49
50
 
@@ -1,4 +1,5 @@
1
- import { getCurrentApp } from "@salesforce/platform-sdk";
1
+ import { createDataSDK, getCurrentApp } from "@salesforce/platform-sdk";
2
+ import { fetchI18nContext } from "@salesforce/platform-sdk/i18n";
2
3
 
3
4
  /** Returns the UIBundle id for CMS search, resolved from the running app. */
4
5
  export async function getUIBundleId(): Promise<string> {
@@ -6,6 +7,21 @@ export async function getUIBundleId(): Promise<string> {
6
7
  return app.identity?.bundleId ?? "";
7
8
  }
8
9
 
10
+ /** Fallback language tag used when the user's own language can't be resolved. */
11
+ export const DEFAULT_LANGUAGE = "en-US";
12
+
13
+ /** Returns the current user's language tag (e.g. `"en-US"`), falling back to {@link DEFAULT_LANGUAGE}. */
14
+ export async function getUserLanguage(): Promise<string> {
15
+ try {
16
+ const sdk = await createDataSDK();
17
+ if (!sdk?.graphql) return DEFAULT_LANGUAGE;
18
+ const ctx = await fetchI18nContext(sdk);
19
+ return ctx.lang || DEFAULT_LANGUAGE;
20
+ } catch {
21
+ return DEFAULT_LANGUAGE;
22
+ }
23
+ }
24
+
9
25
  /** Shape guard for a UIBundle record id (9YE + 12-15 alphanumerics). */
10
26
  export const UI_BUNDLE_ID_PATTERN = /^9YE[a-zA-Z0-9]{12,15}$/;
11
27
 
@@ -81,6 +81,8 @@ export interface SourceRequest<T extends SourceConfig = SourceConfig> {
81
81
  * synchronous. Ignored by non-CMS adapters.
82
82
  */
83
83
  uiBundleId?: string;
84
+ /** CMS-only, optional. The current user's language tag fed to the CMS search `language` argument. */
85
+ language?: string;
84
86
  }
85
87
 
86
88
  /**
@@ -40,6 +40,7 @@ import { FilterProvider, FilterResetButton } from "./filters/FilterContext";
40
40
  import { PaginationControls } from "./controls/PaginationControls";
41
41
  import { SortControl } from "./controls/SortControl";
42
42
  import { resolveResultRenderer, type ResultRenderer } from "./results/resolveResultRenderer";
43
+ import { MIN_QUERY_LENGTH } from "../constants";
43
44
  import { ALL_SCOPE, type SearchHandle, type SourceController } from "../types";
44
45
 
45
46
  export interface MergedSearchResultsProps {
@@ -52,6 +53,8 @@ export interface MergedSearchResultsProps {
52
53
  renderResult?: Record<string, ResultRenderer>;
53
54
  /** Message shown when there are no results. Defaults to "No results found." */
54
55
  emptyMessage?: string;
56
+ /** Message shown when the query is below {@link MIN_QUERY_LENGTH}. Defaults to "Start your search…" */
57
+ shortQueryMessage?: string;
55
58
  /** Number of skeleton placeholders to show while loading. Defaults to `pageSize`. */
56
59
  skeletonCount?: number;
57
60
  /** Extra classes on the grid container. Defaults to a responsive 1/2/3-column grid. */
@@ -81,11 +84,16 @@ export function MergedSearchResults({
81
84
  handle,
82
85
  renderResult,
83
86
  emptyMessage,
87
+ shortQueryMessage = "Start your search…",
84
88
  skeletonCount,
85
89
  gridClassName = DEFAULT_GRID_CLASS,
86
90
  className,
87
91
  }: MergedSearchResultsProps) {
88
- const { globalPagination: page, mergedResults, loading, error } = handle;
92
+ const { globalPagination: page, mergedResults, loading, error, q } = handle;
93
+ // Distinguishes "haven't typed enough yet" (useSearch short-circuits below
94
+ // MIN_QUERY_LENGTH without fetching) from a genuine zero-match search — both
95
+ // otherwise present identically as loading=false, mergedResults=[].
96
+ const isShortQuery = q.trim().length < MIN_QUERY_LENGTH;
89
97
 
90
98
  // A single source is in scope when the scope is narrowed (selected from the
91
99
  // dropdown) or locked (`lockedScope` / restrictTo). Its filter chrome can
@@ -111,9 +119,11 @@ export function MergedSearchResults({
111
119
  ? "Searching…"
112
120
  : error
113
121
  ? "Search failed."
114
- : mergedResults.length === 0
115
- ? (emptyMessage ?? "No results found.")
116
- : `${page.totalCount} result${page.totalCount === 1 ? "" : "s"}`;
122
+ : isShortQuery
123
+ ? shortQueryMessage
124
+ : mergedResults.length === 0
125
+ ? (emptyMessage ?? "No results found.")
126
+ : `${page.totalCount} result${page.totalCount === 1 ? "" : "s"}`;
117
127
 
118
128
  return (
119
129
  <div className={`space-y-6 ${className ?? ""}`}>
@@ -161,6 +171,8 @@ export function MergedSearchResults({
161
171
  <Skeleton key={i} className="h-48 w-full rounded-lg" />
162
172
  ))}
163
173
  </div>
174
+ ) : isShortQuery ? (
175
+ <p className="py-12 text-center text-sm text-muted-foreground">{shortQueryMessage}</p>
164
176
  ) : mergedResults.length === 0 ? (
165
177
  <p className="py-12 text-center text-sm text-muted-foreground">
166
178
  {emptyMessage ?? "No results found."}
@@ -80,6 +80,7 @@ export function SearchResults({
80
80
  renderFilters={resolvedRenderFilters}
81
81
  hideFilterChrome={filtersHidden}
82
82
  emptyMessage={emptyMessages?.[key]}
83
+ q={handle.q}
83
84
  />
84
85
  );
85
86
  })}
@@ -16,6 +16,7 @@ import { ActiveFilters } from "./filters/ActiveFilters";
16
16
  import { FilterProvider, FilterResetButton } from "./filters/FilterContext";
17
17
  import { PaginationControls } from "./controls/PaginationControls";
18
18
  import { SortControl } from "./controls/SortControl";
19
+ import { MIN_QUERY_LENGTH } from "../constants";
19
20
  import type { SourceController } from "../types";
20
21
 
21
22
  interface SourceSectionProps {
@@ -35,6 +36,15 @@ interface SourceSectionProps {
35
36
  */
36
37
  hideFilterChrome?: boolean;
37
38
  emptyMessage?: string;
39
+ /**
40
+ * The current search query. Used to distinguish a not-yet-started search
41
+ * (query below {@link MIN_QUERY_LENGTH}, so no fetch was issued) from a
42
+ * genuine zero-match result, so the empty state can prompt instead of
43
+ * claiming "No … found."
44
+ */
45
+ q?: string;
46
+ /** Message shown when the query is below {@link MIN_QUERY_LENGTH}. Defaults to "Start your search…" */
47
+ shortQueryMessage?: string;
38
48
  className?: string;
39
49
  }
40
50
 
@@ -44,6 +54,8 @@ export function SourceSection({
44
54
  renderFilters,
45
55
  hideFilterChrome,
46
56
  emptyMessage,
57
+ q = "",
58
+ shortQueryMessage = "Start your search…",
47
59
  className,
48
60
  }: SourceSectionProps) {
49
61
  const { config, result, loading, error, filters, sort, pagination } = controller;
@@ -51,6 +63,9 @@ export function SourceSection({
51
63
  const totalCount = result?.totalCount;
52
64
  const showResults = !loading && !error && nodes.length > 0;
53
65
  const showEmpty = !loading && !error && nodes.length === 0;
66
+ // A query below MIN_QUERY_LENGTH never triggers a fetch, so an empty result
67
+ // means "not searched yet" — prompt rather than report zero matches.
68
+ const isShortQuery = q.trim().length < MIN_QUERY_LENGTH;
54
69
 
55
70
  const pageSizeOptions = pagination.pageSizeOptions ?? [pagination.pageSize];
56
71
 
@@ -70,7 +85,9 @@ export function SourceSection({
70
85
  : error
71
86
  ? `Failed to load ${config.label}.`
72
87
  : showEmpty
73
- ? (emptyMessage ?? `No ${config.label.toLowerCase()} found.`)
88
+ ? isShortQuery
89
+ ? shortQueryMessage
90
+ : (emptyMessage ?? `No ${config.label.toLowerCase()} found.`)
74
91
  : totalCount != null
75
92
  ? `${config.label}: ${totalCount} ${totalCount === 1 ? "result" : "results"}`
76
93
  : "";
@@ -155,7 +172,9 @@ export function SourceSection({
155
172
  <div className="flex flex-col items-center justify-center py-10 text-center">
156
173
  <SearchX className="size-10 text-muted-foreground mb-3" />
157
174
  <p className="text-sm text-muted-foreground">
158
- {emptyMessage ?? `No ${config.label.toLowerCase()} found.`}
175
+ {isShortQuery
176
+ ? shortQueryMessage
177
+ : (emptyMessage ?? `No ${config.label.toLowerCase()} found.`)}
159
178
  </p>
160
179
  </div>
161
180
  )}
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Copyright (c) 2026, Salesforce, Inc.,
3
+ * All rights reserved.
4
+ * For full license text, see the LICENSE.txt file
5
+ */
6
+
7
+ /**
8
+ * Regression test for the short-query vs. zero-match empty-state messaging in
9
+ * `<MergedSearchResults>`. Both states present identically as
10
+ * `loading=false, mergedResults=[]`, so the component must distinguish them via
11
+ * `q.trim().length < MIN_QUERY_LENGTH` rather than always falling back to
12
+ * "No results found." — asserted for both the visible empty-state paragraph and
13
+ * the aria-live region, which share the same three-way branch.
14
+ */
15
+ import { describe, it, expect, vi } from "vitest";
16
+ import { render, screen } from "@testing-library/react";
17
+ import { MemoryRouter } from "react-router";
18
+ import { MergedSearchResults } from "../MergedSearchResults";
19
+ import type { SearchHandle } from "../../types";
20
+ import { ALL_SCOPE } from "../../types";
21
+
22
+ // The component tree transitively imports the platform SDK (via
23
+ // DefaultFilterPanel's `useDistinctValues`), even though this test never
24
+ // exercises the filter panel. Stub it so vite doesn't try to resolve its
25
+ // internals under jsdom — matches the convention in Search.test.tsx.
26
+ vi.mock("@salesforce/platform-sdk", () => ({
27
+ createDataSDK: vi.fn(),
28
+ getCurrentApp: vi.fn(() => ({ identity: { bundleId: "" } })),
29
+ }));
30
+
31
+ const noop = () => {};
32
+
33
+ function makeHandle(overrides: Partial<SearchHandle>): SearchHandle {
34
+ return {
35
+ q: "",
36
+ setQ: noop,
37
+ scope: ALL_SCOPE,
38
+ setScope: noop,
39
+ scopeLocked: false,
40
+ sources: {},
41
+ inScopeSources: [],
42
+ mergedResults: [],
43
+ globalPagination: {
44
+ pageIndex: 0,
45
+ pageCount: 0,
46
+ pageSize: 10,
47
+ pageSizeOptions: [10],
48
+ hasNextPage: false,
49
+ hasPreviousPage: false,
50
+ totalCount: 0,
51
+ goToNextPage: noop,
52
+ goToPreviousPage: noop,
53
+ goToPage: noop,
54
+ setPageSize: noop,
55
+ },
56
+ loading: false,
57
+ error: null,
58
+ discoveredContentTypes: [],
59
+ resetAll: noop,
60
+ ...overrides,
61
+ };
62
+ }
63
+
64
+ function renderMerged(handle: SearchHandle) {
65
+ return render(
66
+ <MemoryRouter>
67
+ <MergedSearchResults handle={handle} />
68
+ </MemoryRouter>,
69
+ );
70
+ }
71
+
72
+ describe("<MergedSearchResults> empty vs. short-query messaging", () => {
73
+ it("prompts for a longer query below MIN_QUERY_LENGTH, and reports zero matches at/above it", () => {
74
+ const { rerender } = renderMerged(makeHandle({ q: "ab", mergedResults: [] }));
75
+
76
+ // Below MIN_QUERY_LENGTH (3): the short-query prompt renders in both the
77
+ // visible empty state and the aria-live region; "No results found." must
78
+ // not appear — that would misreport an un-fetched search as a zero-match one.
79
+ expect(screen.getAllByText("Start your search…")).toHaveLength(2);
80
+ expect(screen.queryByText("No results found.")).not.toBeInTheDocument();
81
+
82
+ rerender(
83
+ <MemoryRouter>
84
+ <MergedSearchResults handle={makeHandle({ q: "abc", mergedResults: [] })} />
85
+ </MemoryRouter>,
86
+ );
87
+
88
+ // At MIN_QUERY_LENGTH with a genuine zero-match fetch: "No results
89
+ // found." renders in both spots; the short-query prompt must not appear.
90
+ expect(screen.getAllByText("No results found.")).toHaveLength(2);
91
+ expect(screen.queryByText("Start your search…")).not.toBeInTheDocument();
92
+ });
93
+ });
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Copyright (c) 2026, Salesforce, Inc.,
3
+ * All rights reserved.
4
+ * For full license text, see the LICENSE.txt file
5
+ */
6
+
7
+ /**
8
+ * Regression test for the short-query vs. zero-match empty-state messaging in
9
+ * `<SourceSection>` — the per-source renderer used in the "all"/per-source scope
10
+ * (a sibling to `<MergedSearchResults>`, which owns merged mode). Both states
11
+ * present identically as `loading=false, result.nodes=[]`, so the component must
12
+ * distinguish them via `q.trim().length < MIN_QUERY_LENGTH` rather than always
13
+ * falling back to "No <label> found." — asserted for both the visible empty-state
14
+ * paragraph and the aria-live region, which share the same three-way branch.
15
+ */
16
+ import { describe, it, expect, vi } from "vitest";
17
+ import { render, screen } from "@testing-library/react";
18
+ import { MemoryRouter } from "react-router";
19
+ import { SourceSection } from "../SourceSection";
20
+ import type { SourceController } from "../../types";
21
+
22
+ // The component tree transitively imports the platform SDK (via
23
+ // FilterContext / control internals), even though this test never exercises the
24
+ // filter panel. Stub it so vite doesn't try to resolve its internals under
25
+ // jsdom — matches the convention in MergedSearchResults.test.tsx.
26
+ vi.mock("@salesforce/platform-sdk", () => ({
27
+ createDataSDK: vi.fn(),
28
+ getCurrentApp: vi.fn(() => ({ identity: { bundleId: "" } })),
29
+ }));
30
+
31
+ const noop = () => {};
32
+
33
+ function makeController(overrides: Partial<SourceController> = {}): SourceController {
34
+ return {
35
+ config: {
36
+ kind: "sobject",
37
+ key: "account",
38
+ objectName: "Account",
39
+ label: "Accounts",
40
+ displayFields: [],
41
+ },
42
+ result: { nodes: [], pageInfo: null, totalCount: null },
43
+ loading: false,
44
+ error: null,
45
+ filters: { active: [], set: noop, remove: noop },
46
+ sort: { current: null, set: noop },
47
+ pagination: {
48
+ pageSize: 10,
49
+ pageSizeOptions: [10],
50
+ pageIndex: 0,
51
+ hasNextPage: false,
52
+ hasPreviousPage: false,
53
+ setPageSize: noop,
54
+ goToNextPage: noop,
55
+ goToPreviousPage: noop,
56
+ },
57
+ ...overrides,
58
+ } as SourceController;
59
+ }
60
+
61
+ function renderSection(q: string) {
62
+ return render(
63
+ <MemoryRouter>
64
+ <SourceSection controller={makeController()} renderResult={() => null} q={q} />
65
+ </MemoryRouter>,
66
+ );
67
+ }
68
+
69
+ describe("<SourceSection> empty vs. short-query messaging", () => {
70
+ it("prompts for a longer query below MIN_QUERY_LENGTH, and reports zero matches at/above it", () => {
71
+ const { rerender } = renderSection("ab");
72
+
73
+ // Below MIN_QUERY_LENGTH (3): the short-query prompt renders in both the
74
+ // visible empty state and the aria-live region; "No accounts found." must
75
+ // not appear — that would misreport an un-fetched search as a zero-match one.
76
+ expect(screen.getAllByText("Start your search…")).toHaveLength(2);
77
+ expect(screen.queryByText("No accounts found.")).not.toBeInTheDocument();
78
+
79
+ rerender(
80
+ <MemoryRouter>
81
+ <SourceSection controller={makeController()} renderResult={() => null} q="abc" />
82
+ </MemoryRouter>,
83
+ );
84
+
85
+ // At MIN_QUERY_LENGTH with a genuine zero-match fetch: "No accounts found."
86
+ // renders in both spots; the short-query prompt must not appear.
87
+ expect(screen.getAllByText("No accounts found.")).toHaveLength(2);
88
+ expect(screen.queryByText("Start your search…")).not.toBeInTheDocument();
89
+ });
90
+ });
@@ -40,7 +40,11 @@ import { MIN_QUERY_LENGTH } from "../constants";
40
40
  // `resolveChannelId` derives the discovery channel id from a CMS result;
41
41
  // `useSearchableContentTypes` is the session-cached discovery hook;
42
42
  // `isValidCmsFqn` validates a selected FQN before it enters state or a query.
43
- import { getUIBundleId, isConfiguredUIBundleId } from "../adapters/cms/searchChannel";
43
+ import {
44
+ getUIBundleId,
45
+ getUserLanguage,
46
+ isConfiguredUIBundleId,
47
+ } from "../adapters/cms/searchChannel";
44
48
  import { getOrgSupportsCmsSearch } from "../adapters/cms/api/orgApiVersionService";
45
49
  import { resolveChannelId } from "../adapters/cms/channelResolver";
46
50
  import { useSearchableContentTypes } from "../adapters/cms/hooks/useSearchableContentTypes";
@@ -503,8 +507,8 @@ export function useSearch(config: SearchConfig, options?: UseSearchOptions): Sea
503
507
  // page-level error — content-type narrowing is an enhancement, not a
504
508
  // hard requirement. Read in `.catch` below.
505
509
  let isDiscoveryRefetch = false;
506
- Promise.all([getUIBundleId(), getOrgSupportsCmsSearch()])
507
- .then(([uiBundleId, orgSupportsCmsSearch]) => {
510
+ Promise.all([getUIBundleId(), getUserLanguage(), getOrgSupportsCmsSearch()])
511
+ .then(([uiBundleId, userLanguage, orgSupportsCmsSearch]) => {
508
512
  if (cancelled || generation !== fetchGenRef.current) return;
509
513
 
510
514
  const requests: SourceRequest[] = activeSources.flatMap((source) => {
@@ -541,7 +545,7 @@ export function useSearch(config: SearchConfig, options?: UseSearchOptions): Sea
541
545
  if (!cmsFqn && discoveredContentTypeFqns.length > 0) {
542
546
  isDiscoveryRefetch = true;
543
547
  }
544
- return [{ ...base, uiBundleId, contentTypes }];
548
+ return [{ ...base, uiBundleId, language: userLanguage, contentTypes }];
545
549
  });
546
550
 
547
551
  // Every in-scope source was gated out (e.g. a CMS-only scope while CMS
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@salesforce/ui-bundle-template-base-sfdx-project",
3
- "version": "11.63.0",
3
+ "version": "11.66.1",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@salesforce/ui-bundle-template-base-sfdx-project",
9
- "version": "11.63.0",
9
+ "version": "11.66.1",
10
10
  "license": "SEE LICENSE IN LICENSE.txt",
11
11
  "dependencies": {
12
12
  "fast-xml-parser": "^5.9.3",
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@salesforce/ui-bundle-template-base-sfdx-project",
3
- "version": "11.63.0",
3
+ "version": "11.66.1",
4
4
  "description": "Base SFDX project template",
5
5
  "license": "SEE LICENSE IN LICENSE.txt",
6
6
  "publishConfig": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@salesforce/ui-bundle-template-app-react-sample-b2e",
3
- "version": "11.63.0",
3
+ "version": "11.66.1",
4
4
  "description": "Salesforce sample property rental React app",
5
5
  "license": "SEE LICENSE IN LICENSE.txt",
6
6
  "author": "",
@@ -16,7 +16,7 @@
16
16
  "clean": "rm -rf dist"
17
17
  },
18
18
  "dependencies": {
19
- "@salesforce/ui-bundle": "^11.63.0",
19
+ "@salesforce/ui-bundle": "^11.66.1",
20
20
  "sonner": "^1.7.0"
21
21
  },
22
22
  "devDependencies": {