Add configurable full-text stopword policies (#2606)

This commit is contained in:
sunghyunkang1111
2026-10-05 11:31:20 -05:00
committed by GitHub
parent 38d4b8d3d9
commit 226a7a27ee
21 changed files with 2827 additions and 257 deletions
@@ -9,6 +9,14 @@ import { updateUserContext } from "../../UserContext";
import { armRequest } from "../../Utils/arm/request";
import { client } from "../CosmosClient";
import { constructRpOptions, createCollection } from "./createCollection";
import { FullTextPolicy, Collection } from "Contracts/DataModels";
import { handleError } from "Common/ErrorHandlingUtils";
import { userContext } from "UserContext";
import { readCollections } from "./readCollections";
import { updateCollection } from "./updateCollection";
import { fullTextLanguages } from "Explorer/Controls/FullTextSeach/FullTextPolicyUtils";
jest.mock("Common/ErrorHandlingUtils", () => ({ handleError: jest.fn() }));
describe("createCollection", () => {
const createCollectionParams: CreateCollectionParams = {
@@ -37,6 +45,167 @@ describe("createCollection", () => {
});
});
describe("full-text policy transport", () => {
const policy: FullTextPolicy = {
defaultLanguage: "en-US",
package: "standard",
defaultSpec: {
language: "en-US",
stopWordListKind: "basic",
addStopWords: ["Cosmos", "cosmos"],
removeStopWords: ["the"],
tokenizer: "word",
filters: ["lowercase", "stop"],
futureSetting: { preserved: true },
},
fullTextPaths: [{ path: "/text" }, { path: "/other", language: "fr-FR", stopWordListKind: "none" }],
futurePolicySetting: "preserved",
};
const resource: Partial<Collection> = {
id: "container",
fullTextPolicy: policy,
indexingPolicy: {
automatic: true,
indexingMode: "consistent",
includedPaths: [],
excludedPaths: [],
fullTextIndexes: [{ path: "/text" }],
},
};
const sdkCreate = jest.fn();
const sdkReplace = jest.fn();
const sdkRead = jest.fn();
beforeEach(() => {
jest.resetAllMocks();
updateUserContext({
apiType: "SQL",
databaseAccount: { name: "account" } as DatabaseAccount,
subscriptionId: "subscription",
resourceGroup: "group",
features: { ...userContext.features, enableSDKoperations: false },
});
useDatabases.setState({ validateCollectionId: jest.fn().mockResolvedValue(true) });
sdkCreate.mockResolvedValue({ resource });
sdkReplace.mockResolvedValue({ resource });
sdkRead.mockResolvedValue({ resources: [resource] });
const database = {
containers: { create: sdkCreate, readAll: () => ({ fetchAll: sdkRead }) },
container: () => ({ replace: sdkReplace }),
};
(client as jest.Mock).mockReturnValue({
databases: { createIfNotExists: jest.fn().mockResolvedValue({ database }) },
database: () => database,
});
jest.mocked(armRequest).mockResolvedValue({ properties: { resource } });
});
it.each([AuthType.AAD, AuthType.MasterKey])(
"preserves policy creation and dedicated indexes through %s",
async (authType) => {
updateUserContext({ authType });
const result = await createCollection({
createNewDatabase: false,
databaseId: "database",
collectionId: "container",
databaseLevelThroughput: false,
offerThroughput: 400,
fullTextPolicy: policy,
indexingPolicy: resource.indexingPolicy,
});
expect(result.fullTextPolicy).toEqual(policy);
const payload =
authType === AuthType.AAD
? (armRequest as jest.Mock).mock.lastCall[0].body.properties.resource
: sdkCreate.mock.lastCall[0];
expect(payload).toMatchObject({ fullTextPolicy: policy, indexingPolicy: resource.indexingPolicy });
const throughput =
authType === AuthType.AAD
? (armRequest as jest.Mock).mock.lastCall[0].body.properties.options.throughput
: sdkCreate.mock.lastCall[0].throughput;
expect(throughput).toBe(400);
},
);
it.each([AuthType.AAD, AuthType.MasterKey])(
"preserves analysis through read and unrelated update with %s",
async (authType) => {
updateUserContext({ authType });
jest.mocked(armRequest).mockResolvedValueOnce({ value: [{ properties: { resource } }] });
expect((await readCollections("database"))[0].fullTextPolicy).toEqual(policy);
jest.mocked(armRequest).mockResolvedValue({ properties: { resource } });
await updateCollection("database", "container", { ...resource, defaultTtl: 123 });
const payload =
authType === AuthType.AAD
? (armRequest as jest.Mock).mock.lastCall[0].body.properties.resource
: sdkReplace.mock.lastCall[0];
expect(payload).toEqual({ ...resource, defaultTtl: 123 });
},
);
it.each([AuthType.AAD, AuthType.MasterKey])(
"surfaces a rejected indexed-policy update with %s",
async (authType) => {
updateUserContext({ authType });
const failure = new Error("Cannot change analysis for an indexed path");
jest.mocked(armRequest).mockResolvedValueOnce({ properties: { resource } }).mockRejectedValueOnce(failure);
sdkReplace.mockRejectedValueOnce(failure);
await expect(updateCollection("database", "container", resource)).rejects.toBe(failure);
expect(handleError).toHaveBeenCalledWith(failure, "UpdateCollection", expect.any(String));
expect(resource.fullTextPolicy).toEqual(policy);
},
);
it.each(
fullTextLanguages.flatMap(({ value }) =>
[AuthType.AAD, AuthType.MasterKey].map((authType) => ({ language: value, authType })),
),
)("preserves 20-word $language policies through $authType create/read/update", async ({ language, authType }) => {
const words = Array.from({ length: 20 }, (_, index) => ["catalog", "Catalog", "caf\u00e9", "catalog"][index % 4]);
const localized = {
...policy,
defaultLanguage: language,
defaultSpec: { ...policy.defaultSpec, language, addStopWords: words, removeStopWords: words },
};
const current = { ...resource, fullTextPolicy: localized };
updateUserContext({ authType });
sdkCreate.mockResolvedValue({ resource: current });
sdkRead.mockResolvedValue({ resources: [current] });
sdkReplace.mockResolvedValue({ resource: current });
jest.mocked(armRequest).mockResolvedValue({ properties: { resource: current } });
const created = await createCollection({
createNewDatabase: false,
databaseId: "database",
collectionId: "container",
databaseLevelThroughput: false,
offerThroughput: 400,
fullTextPolicy: localized,
indexingPolicy: current.indexingPolicy,
});
expect(created.fullTextPolicy).toEqual(localized);
const createPayload =
authType === AuthType.AAD ? jest.mocked(armRequest).mock.lastCall[0].body : sdkCreate.mock.lastCall[0];
expect(createPayload).toMatchObject(
authType === AuthType.AAD
? { properties: { resource: { fullTextPolicy: localized } } }
: { fullTextPolicy: localized },
);
if (authType === AuthType.AAD) {
jest.mocked(armRequest).mockResolvedValueOnce({ value: [{ properties: { resource: current } }] });
}
expect((await readCollections("database"))[0].fullTextPolicy).toEqual(localized);
jest.mocked(armRequest).mockResolvedValue({ properties: { resource: current } });
await updateCollection("database", "container", { ...current, defaultTtl: 123 });
const updatePayload =
authType === AuthType.AAD ? jest.mocked(armRequest).mock.lastCall[0].body : sdkReplace.mock.lastCall[0];
expect(updatePayload).toMatchObject(
authType === AuthType.AAD
? { properties: { resource: { ...current, defaultTtl: 123 } } }
: { ...current, defaultTtl: 123 },
);
});
});
it("should call ARM if logged in with AAD", async () => {
updateUserContext({
authType: AuthType.AAD,
+17 -5
View File
@@ -465,14 +465,26 @@ export interface VectorEmbedding {
path: string;
}
export interface FullTextPolicy {
defaultLanguage: string;
fullTextPaths: FullTextPath[];
export interface FullTextAnalysisSpec {
language?: string;
stopWordListKind?: string;
addStopWords?: string[];
removeStopWords?: string[];
tokenizer?: string;
filters?: string[];
[key: string]: unknown;
}
export interface FullTextPath {
export interface FullTextPolicy {
defaultLanguage?: string;
package?: string;
defaultSpec?: FullTextAnalysisSpec;
fullTextPaths: FullTextPath[];
[key: string]: unknown;
}
export interface FullTextPath extends FullTextAnalysisSpec {
path: string;
language: string;
}
export interface ReadDatabaseOfferParams {
@@ -13,6 +13,7 @@ export interface CollapsibleSectionProps {
onDelete?: () => void;
disabled?: boolean;
disableDelete?: boolean;
deleteLabel?: string;
}
export interface CollapsibleSectionState {
@@ -76,6 +77,7 @@ export class CollapsibleSectionComponent extends React.Component<CollapsibleSect
{this.props.showDelete && (
<Stack.Item style={{ marginLeft: "auto" }}>
<IconButton
ariaLabel={this.props.deleteLabel}
disabled={this.props.disableDelete ?? this.props.disabled}
id={`delete-${this.props.title.split(" ").join("-")}`}
iconProps={{ iconName: "Delete" }}
@@ -1,6 +1,540 @@
import "@testing-library/jest-dom";
import { fireEvent, render, screen, within } from "@testing-library/react";
import { DatabaseAccount, FullTextPolicy, VectorEmbedding } from "Contracts/DataModels";
import {
ContainerPolicyComponent,
ContainerPolicyComponentProps,
} from "../Settings/SettingsSubComponents/ContainerPolicyComponent";
import React from "react";
import { ApiType, updateUserContext } from "UserContext";
import { isFullTextSearchPreviewFeaturesEnabled } from "Utils/CapabilityUtils";
import { FullTextPoliciesComponent, getFullTextLanguageOptions } from "./FullTextPoliciesComponent";
describe("AddFullTextPolicyForm", () => {
//CTODO: add tests
it.skip("should render correctly", () => {});
describe("FullTextPoliciesComponent", () => {
const account: DatabaseAccount = {
id: "test",
name: "test",
type: "Microsoft.DocumentDB/databaseAccounts",
location: "westus",
kind: "GlobalDocumentDB",
tags: {},
properties: {
capabilities: [{ name: "EnableNoSQLFullTextSearchPreviewFeatures", description: "" }],
},
};
const policy: FullTextPolicy = {
defaultLanguage: "en-US",
package: "standard",
defaultSpec: {
language: "en-US",
stopWordListKind: "basic",
addStopWords: ["cosmos"],
removeStopWords: ["the"],
tokenizer: "word",
filters: ["lowercase", "stop"],
futureField: { preserved: true },
},
fullTextPaths: [{ path: "/text" }, { path: "/other", language: "fr-FR", stopWordListKind: "none" }],
futurePolicyField: "preserved",
};
beforeEach(() => {
updateUserContext({ apiType: "SQL", databaseAccount: account });
});
describe("ContainerPolicyComponent full-text editing", () => {
const baseline: FullTextPolicy = {
package: "standard",
defaultSpec: {
language: "en-US",
stopWordListKind: "basic",
filters: ["lowercase", "stop"],
addStopWords: ["cosmos"],
future: { preserve: true },
},
fullTextPaths: [{ path: "/text" }],
};
const account: DatabaseAccount = {
id: "test",
name: "test",
type: "Microsoft.DocumentDB/databaseAccounts",
location: "westus",
kind: "GlobalDocumentDB",
properties: { capabilities: [{ name: "EnableNoSQLFullTextSearchPreviewFeatures", description: "" }] },
};
const props = (): ContainerPolicyComponentProps => ({
vectorEmbeddingPolicy: { vectorEmbeddings: [] },
vectorEmbeddingPolicyBaseline: { vectorEmbeddings: [] },
onVectorEmbeddingPolicyChange: jest.fn(),
onVectorEmbeddingPolicyDirtyChange: jest.fn(),
onVectorEmbeddingPolicyValidationChange: jest.fn(),
vectorIndexes: [],
vectorIndexesBaseline: [],
onVectorIndexesChange: jest.fn(),
isVectorSearchEnabled: false,
fullTextPolicy: baseline,
fullTextPolicyBaseline: baseline,
onFullTextPolicyChange: jest.fn(),
onFullTextPolicyDirtyChange: jest.fn(),
onFullTextPolicyValidationChange: jest.fn(),
fullTextIndexesBaseline: [],
isFullTextSearchEnabled: true,
shouldDiscardContainerPolicies: false,
resetShouldDiscardContainerPolicyChange: jest.fn(),
});
beforeEach(() => updateUserContext({ apiType: "SQL", databaseAccount: account }));
it("opens the full-text tab when vector search is unavailable and propagates validation and reversions", () => {
const callbacks = props();
const Harness = () => {
const [policy, setPolicy] = React.useState(baseline);
return (
<ContainerPolicyComponent
{...callbacks}
fullTextPolicy={policy}
onFullTextPolicyChange={(next) => {
callbacks.onFullTextPolicyChange(next);
setPolicy(next);
}}
/>
);
};
render(<Harness />);
const words = within(screen.getByRole("group", { name: "Default stopwords" })).getByRole("textbox", {
name: "Additional stopwords",
});
expect(callbacks.onFullTextPolicyDirtyChange).toHaveBeenLastCalledWith(false);
fireEvent.change(words, { target: { value: "two words" } });
expect(callbacks.onFullTextPolicyDirtyChange).toHaveBeenLastCalledWith(true);
expect(callbacks.onFullTextPolicyValidationChange).toHaveBeenLastCalledWith(false);
fireEvent.change(words, { target: { value: "cosmos" } });
expect(callbacks.onFullTextPolicyChange).toHaveBeenLastCalledWith(baseline);
expect(callbacks.onFullTextPolicyDirtyChange).toHaveBeenLastCalledWith(false);
expect(callbacks.onFullTextPolicyValidationChange).toHaveBeenLastCalledWith(true);
});
it("discards drafts without losing hidden fields or relocking against draft indexes", () => {
const callbacks = props();
const { rerender } = render(<ContainerPolicyComponent {...callbacks} />);
const words = within(screen.getByRole("group", { name: "Default stopwords" })).getByRole("textbox", {
name: "Additional stopwords",
});
fireEvent.change(words, { target: { value: "draft" } });
rerender(<ContainerPolicyComponent {...callbacks} shouldDiscardContainerPolicies />);
expect(words).toHaveValue("cosmos");
expect(callbacks.onFullTextPolicyChange).toHaveBeenLastCalledWith(baseline);
expect(callbacks.resetShouldDiscardContainerPolicyChange).toHaveBeenCalledTimes(1);
rerender(<ContainerPolicyComponent {...callbacks} fullTextIndexesBaseline={[{ path: "/text" }]} />);
expect(words).toBeDisabled();
expect(screen.getByRole("textbox", { name: "Path" })).toBeDisabled();
});
it("propagates vector removal instead of leaving a stale draft to save with full-text edits", () => {
const vector: VectorEmbedding = {
path: "/vector",
dataType: "float32",
dimensions: 8,
distanceFunction: "cosine",
};
const callbacks = props();
const Harness = () => {
const [vectorPolicy, setVectorPolicy] = React.useState({ vectorEmbeddings: [vector] });
return (
<ContainerPolicyComponent
{...callbacks}
isVectorSearchEnabled
vectorEmbeddingPolicy={vectorPolicy}
vectorEmbeddingPolicyBaseline={{ vectorEmbeddings: [vector] }}
onVectorEmbeddingPolicyChange={(next) => {
callbacks.onVectorEmbeddingPolicyChange(next);
setVectorPolicy(next);
}}
/>
);
};
const { container } = render(<Harness />);
const deleteButton = container.querySelector<HTMLButtonElement>('[id^="delete-Vector"]');
expect(deleteButton).not.toBeNull();
fireEvent.click(deleteButton!);
expect(callbacks.onVectorEmbeddingPolicyChange).toHaveBeenLastCalledWith({ vectorEmbeddings: [] });
});
});
it("preserves the complete policy on mount instead of reconstructing legacy fields", () => {
const changed = jest.fn();
render(
<FullTextPoliciesComponent
fullTextPolicy={policy}
onFullTextPathChange={changed}
allowStopwordCustomization
isEditing
/>,
);
expect(changed).toHaveBeenLastCalledWith(policy, [], true);
expect(screen.getByRole("group", { name: "Default stopwords" })).toBeVisible();
});
it("keeps capability gating independent of the English-only language restriction", () => {
render(
<FullTextPoliciesComponent
fullTextPolicy={policy}
onFullTextPathChange={jest.fn()}
allowStopwordCustomization
englishOnly
isEditing
/>,
);
expect(screen.getByRole("group", { name: "Default stopwords" })).toBeVisible();
expect(getFullTextLanguageOptions(true).map((option) => option.key)).toEqual(["en-US"]);
expect(getFullTextLanguageOptions().map((option) => option.key)).toHaveLength(7);
});
const apiTypes: ApiType[] = ["SQL", "Mongo", "Gremlin", "Tables", "Cassandra", "Postgres", "VCoreMongo"];
it.each(apiTypes)("gates stopwords and languages correctly for %s and target-account overrides", (apiType) => {
for (const preview of [false, true]) {
const capabilities = preview
? account.properties.capabilities
: [{ name: "EnableNoSQLFullTextSearch", description: "" }];
updateUserContext({ apiType, databaseAccount: { ...account, properties: { capabilities } } });
updateUserContext({ apiType });
expect(isFullTextSearchPreviewFeaturesEnabled()).toBe(apiType === "SQL" && preview);
expect(getFullTextLanguageOptions()).toHaveLength(apiType === "SQL" && preview ? 7 : 1);
expect(getFullTextLanguageOptions(true)).toHaveLength(1);
for (const targetPreview of [false, true]) {
const target = {
subscriptionId: "subscription",
resourceGroup: "group",
accountName: "target",
capabilities: targetPreview ? account.properties.capabilities : [],
};
expect(isFullTextSearchPreviewFeaturesEnabled(target)).toBe(apiType === "SQL" && targetPreview);
expect(getFullTextLanguageOptions(false, target)).toHaveLength(apiType === "SQL" && targetPreview ? 7 : 1);
}
}
});
it("preserves a custom policy when the preview capability is unavailable", () => {
updateUserContext({ databaseAccount: { ...account, properties: { capabilities: [] } } });
const changed = jest.fn();
render(
<FullTextPoliciesComponent
fullTextPolicy={policy}
onFullTextPathChange={changed}
allowStopwordCustomization
isEditing
/>,
);
expect(screen.queryByRole("group", { name: "Default stopwords" })).not.toBeInTheDocument();
expect(screen.getByText(/customization is not enabled for this connection/)).toBeVisible();
expect(changed).toHaveBeenLastCalledWith(policy, [], true);
});
it("opts into standard customization explicitly and restores a legacy-shaped new policy when unchecked", () => {
const legacy: FullTextPolicy = { defaultLanguage: "en-US", fullTextPaths: [{ path: "/text", language: "en-US" }] };
const changed = jest.fn();
render(
<FullTextPoliciesComponent fullTextPolicy={legacy} onFullTextPathChange={changed} allowStopwordCustomization />,
);
expect(changed).toHaveBeenLastCalledWith(legacy, [{ path: "/text" }], true);
const toggle = screen.getByRole("checkbox", { name: "Customize stopwords with standard analysis" });
fireEvent.click(toggle);
expect(changed.mock.calls[changed.mock.calls.length - 1][0].package).toBe("standard");
expect(changed.mock.calls[changed.mock.calls.length - 1][0].fullTextPaths).toEqual([{ path: "/text" }]);
fireEvent.click(toggle);
expect(changed).toHaveBeenLastCalledWith(legacy, [{ path: "/text" }], true);
expect(legacy).toEqual({ defaultLanguage: "en-US", fullTextPaths: [{ path: "/text", language: "en-US" }] });
});
it("inherits the current and future default filters after resetting a path override", () => {
const changed = jest.fn();
const original: FullTextPolicy = {
...policy,
fullTextPaths: [{ path: "/text", futurePathSetting: { preserved: true } }],
};
render(
<FullTextPoliciesComponent
fullTextPolicy={original}
onFullTextPathChange={changed}
allowStopwordCustomization
isEditing
/>,
);
const inherit = screen.getByRole("checkbox", { name: "Inherit the container's language and stopwords" });
fireEvent.click(inherit);
const pathSettings = within(screen.getByRole("group", { name: "Stopwords for /text" }));
expect(pathSettings.getByRole("checkbox", { name: "Enable stopword filtering" })).toBeChecked();
fireEvent.click(pathSettings.getByRole("checkbox", { name: "Enable stopword filtering" }));
expect(changed.mock.calls[changed.mock.calls.length - 1][0].fullTextPaths[0].filters).toEqual(["lowercase"]);
fireEvent.click(inherit);
expect(changed.mock.calls[changed.mock.calls.length - 1][0].fullTextPaths).toEqual(original.fullTextPaths);
expect(screen.queryByRole("group", { name: "Stopwords for /text" })).not.toBeInTheDocument();
const defaults = within(screen.getByRole("group", { name: "Default stopwords" }));
fireEvent.click(defaults.getByRole("checkbox", { name: "Enable stopword filtering" }));
expect(changed.mock.calls[changed.mock.calls.length - 1][0].fullTextPaths).toEqual(original.fullTextPaths);
fireEvent.click(inherit);
expect(
within(screen.getByRole("group", { name: "Stopwords for /text" })).getByRole("checkbox", {
name: "Enable stopword filtering",
}),
).not.toBeChecked();
expect(original.defaultSpec?.filters).toEqual(["lowercase", "stop"]);
});
it("reports analysis-only path overrides instead of hiding them as inherited", () => {
const changed = jest.fn();
render(
<FullTextPoliciesComponent
fullTextPolicy={{ ...policy, fullTextPaths: [{ path: "/text", tokenizer: "word", filters: ["lowercase"] }] }}
onFullTextPathChange={changed}
allowStopwordCustomization
isEditing
/>,
);
const inherit = screen.getByRole("checkbox", { name: "Inherit the container's language and stopwords" });
expect(inherit).not.toBeChecked();
expect(screen.getByRole("group", { name: "Stopwords for /text" })).toBeVisible();
fireEvent.click(inherit);
expect(changed.mock.calls[changed.mock.calls.length - 1][0].fullTextPaths).toEqual([{ path: "/text" }]);
});
it.each(["capability", "compatibility", "indexed"])(
"preserves uneditable package/preset combinations without blocking saves: %s",
(reason) => {
if (reason === "capability") {
updateUserContext({ databaseAccount: { ...account, properties: { capabilities: [] } } });
}
const persisted: FullTextPolicy = {
...policy,
defaultSpec: { ...policy.defaultSpec, stopWordListKind: "extended" },
fullTextPaths: [{ path: "/text" }],
};
const changed = jest.fn();
const indexes = reason === "indexed" ? [{ path: "/text" }] : [];
render(
<FullTextPoliciesComponent
fullTextPolicy={persisted}
onFullTextPathChange={changed}
allowStopwordCustomization={reason !== "compatibility"}
isEditing
fullTextIndexes={indexes}
/>,
);
expect(changed).toHaveBeenLastCalledWith(persisted, indexes, true);
expect(screen.getByRole("combobox", { name: /^Default language/ })).toHaveAttribute("aria-disabled", "true");
expect(persisted.defaultSpec?.stopWordListKind).toBe("extended");
},
);
it("still blocks an editable invalid preset until the user corrects it", () => {
const changed = jest.fn();
render(
<FullTextPoliciesComponent
fullTextPolicy={{ ...policy, defaultSpec: { ...policy.defaultSpec, stopWordListKind: "extended" } }}
onFullTextPathChange={changed}
allowStopwordCustomization
isEditing
/>,
);
expect(changed.mock.calls[changed.mock.calls.length - 1][2]).toBe(false);
fireEvent.click(
within(screen.getByRole("group", { name: "Default stopwords" })).getByRole("combobox", {
name: "Stopword list",
}),
);
fireEvent.click(screen.getByRole("option", { name: "Basic", exact: true }));
expect(changed.mock.calls[changed.mock.calls.length - 1][2]).toBe(true);
});
it.each([{ tokenizer: "future" }, { filters: ["future"] }])(
"preserves opaque path analysis without allowing an inheritance reset: %j",
(analysis) => {
const persisted: FullTextPolicy = {
...policy,
fullTextPaths: [{ path: "/text", ...analysis, futureSetting: { preserved: true } }],
};
const changed = jest.fn();
render(
<FullTextPoliciesComponent
fullTextPolicy={persisted}
onFullTextPathChange={changed}
allowStopwordCustomization
isEditing
/>,
);
expect(screen.getByRole("checkbox", { name: "Inherit the container's language and stopwords" })).toBeDisabled();
expect(changed).toHaveBeenLastCalledWith(persisted, [], true);
},
);
it("propagates invalid custom words and recovers without mutating the baseline", () => {
const changed = jest.fn();
render(
<FullTextPoliciesComponent
fullTextPolicy={policy}
onFullTextPathChange={changed}
allowStopwordCustomization
isEditing
/>,
);
const input = within(screen.getByRole("group", { name: "Default stopwords" })).getByRole("textbox", {
name: "Additional stopwords",
});
fireEvent.change(input, { target: { value: "two words" } });
expect(changed.mock.calls[changed.mock.calls.length - 1][2]).toBe(false);
expect(screen.getByRole("alert")).toHaveTextContent("cannot contain");
fireEvent.change(input, { target: { value: "Cosmos\ncosmos" } });
const updated = changed.mock.calls[changed.mock.calls.length - 1][0];
expect(updated.defaultSpec.addStopWords).toEqual(["Cosmos", "cosmos"]);
expect(updated.defaultSpec.futureField).toEqual({ preserved: true });
expect(changed.mock.calls[changed.mock.calls.length - 1][2]).toBe(true);
expect(policy.defaultSpec?.addStopWords).toEqual(["cosmos"]);
});
it("locks only persisted indexed paths and global defaults, not unrelated paths", () => {
render(
<FullTextPoliciesComponent
fullTextPolicy={policy}
onFullTextPathChange={jest.fn()}
allowStopwordCustomization
isEditing
fullTextIndexes={[{ path: "/text" }]}
/>,
);
expect(
within(screen.getByRole("group", { name: "Default stopwords" })).getByRole("textbox", {
name: "Additional stopwords",
}),
).toBeDisabled();
const paths = screen.getAllByRole("textbox", { name: "Path" });
expect(paths[0]).toBeDisabled();
expect(paths[1]).toBeEnabled();
expect(
within(screen.getByRole("group", { name: "Stopwords for /other" })).getByRole("textbox", {
name: "Additional stopwords",
}),
).toBeEnabled();
});
it("discards draft changes and restores all hidden fields from the supplied baseline", () => {
const changed = jest.fn();
const discarded = jest.fn();
const props = {
fullTextPolicy: policy,
onFullTextPathChange: changed,
onChangesDiscarded: discarded,
allowStopwordCustomization: true,
isEditing: true,
};
const { rerender } = render(<FullTextPoliciesComponent {...props} />);
const input = within(screen.getByRole("group", { name: "Default stopwords" })).getByRole("textbox", {
name: "Additional stopwords",
});
fireEvent.change(input, { target: { value: "changed" } });
rerender(<FullTextPoliciesComponent {...props} discardChanges />);
expect(input).toHaveValue("cosmos");
expect(changed).toHaveBeenLastCalledWith(policy, [], true);
expect(discarded).toHaveBeenCalledTimes(1);
});
it("keeps unknown presets read-only and lossless", () => {
const future = { ...policy, defaultSpec: { ...policy.defaultSpec, stopWordListKind: "future" } };
const changed = jest.fn();
render(
<FullTextPoliciesComponent
fullTextPolicy={future}
onFullTextPathChange={changed}
allowStopwordCustomization
isEditing
/>,
);
expect(screen.getByText(/settings this editor cannot safely change/)).toBeVisible();
expect(screen.getByRole("button", { name: "Add full text path" })).toBeDisabled();
expect(changed).toHaveBeenLastCalledWith(future, [], true);
});
it("requires explicit activation when an existing standard policy has no stop filter", () => {
const withoutFilter: FullTextPolicy = { ...policy, defaultSpec: { ...policy.defaultSpec, filters: [] } };
const changed = jest.fn();
render(
<FullTextPoliciesComponent
fullTextPolicy={withoutFilter}
onFullTextPathChange={changed}
allowStopwordCustomization
isEditing
/>,
);
const defaults = within(screen.getByRole("group", { name: "Default stopwords" }));
expect(defaults.getByRole("textbox", { name: "Additional stopwords" })).toBeDisabled();
fireEvent.click(defaults.getByRole("checkbox", { name: "Enable stopword filtering" }));
expect(defaults.getByRole("textbox", { name: "Additional stopwords" })).toBeEnabled();
expect(changed.mock.calls[changed.mock.calls.length - 1][0].defaultSpec.filters).toEqual(["stop"]);
expect(withoutFilter.defaultSpec?.filters).toEqual([]);
});
it("does not inherit filters when a path explicitly chooses a tokenizer and writes a tokenizer with new filters", () => {
const changed = jest.fn();
const explicitTokenizer = {
...policy,
fullTextPaths: [{ path: "/text", language: "en-US", tokenizer: "word", stopWordListKind: "basic" }],
};
render(
<FullTextPoliciesComponent
fullTextPolicy={explicitTokenizer}
onFullTextPathChange={changed}
allowStopwordCustomization
isEditing
/>,
);
const pathSettings = within(screen.getByRole("group", { name: "Stopwords for /text" }));
expect(pathSettings.getByRole("checkbox", { name: "Enable stopword filtering" })).not.toBeChecked();
fireEvent.click(pathSettings.getByRole("checkbox", { name: "Enable stopword filtering" }));
expect(changed.mock.calls[changed.mock.calls.length - 1][0].fullTextPaths[0]).toEqual({
...explicitTokenizer.fullTextPaths[0],
filters: ["stop"],
});
});
it("preserves customized legacy policies read-only without the capability", () => {
updateUserContext({ databaseAccount: { ...account, properties: { capabilities: [] } } });
const legacy: FullTextPolicy = {
defaultLanguage: "en-US",
fullTextPaths: [{ path: "/text", language: "en-US", stopWordListKind: "extended", addStopWords: ["cosmos"] }],
};
const changed = jest.fn();
render(
<FullTextPoliciesComponent
fullTextPolicy={legacy}
onFullTextPathChange={changed}
allowStopwordCustomization
isEditing
/>,
);
expect(screen.getByRole("textbox", { name: "Path" })).toBeDisabled();
expect(changed).toHaveBeenLastCalledWith(legacy, [], true);
});
it("keeps existing standard policies valid and unchanged in compatibility-only GSI use", () => {
const changed = jest.fn();
render(<FullTextPoliciesComponent fullTextPolicy={policy} onFullTextPathChange={changed} />);
expect(screen.getByRole("button", { name: "Add full text path" })).toBeDisabled();
expect(changed).toHaveBeenLastCalledWith(policy, [{ path: "/text" }, { path: "/other" }], true);
});
it("validates new empty paths and generates indexes without altering other policy fields", async () => {
const changed = jest.fn();
render(
<FullTextPoliciesComponent fullTextPolicy={policy} onFullTextPathChange={changed} allowStopwordCustomization />,
);
fireEvent.click(screen.getByRole("button", { name: "Add full text path" }));
expect(changed.mock.calls[changed.mock.calls.length - 1][2]).toBe(false);
expect(await screen.findByText("Full text path should not be empty")).toBeVisible();
const paths = screen.getAllByRole("textbox", { name: "Path" });
fireEvent.change(paths[2], { target: { value: "added" } });
const updated = changed.mock.calls[changed.mock.calls.length - 1];
expect(updated[0].fullTextPaths[2]).toEqual({ path: "/added" });
expect(updated[0].defaultSpec).toEqual(policy.defaultSpec);
expect(updated[1]).toEqual([{ path: "/text" }, { path: "/other" }, { path: "/added" }]);
expect(updated[2]).toBe(true);
});
});
@@ -1,17 +1,42 @@
import {
DefaultButton,
Dropdown,
IButton,
IDropdownOption,
IDropdownStyles,
IStyleFunctionOrObject,
ITextField,
ITextFieldStyleProps,
ITextFieldStyles,
Label,
Stack,
TextField,
} from "@fluentui/react";
import {
Accordion,
AccordionHeader,
AccordionItem,
AccordionPanel,
Button,
Checkbox,
makeStyles,
tokens,
useId,
} from "@fluentui/react-components";
import { DeleteRegular } from "@fluentui/react-icons";
import { AccountOverride, FullTextIndex, FullTextPath, FullTextPolicy } from "Contracts/DataModels";
import { CollapsibleSectionComponent } from "Explorer/Controls/CollapsiblePanel/CollapsibleSectionComponent";
import {
fullTextLanguages,
getFullTextDefaultLanguage,
getStopwordValidationError,
inheritFullTextPathAnalysis,
isFullTextPathInherited,
isFullTextPolicyValid,
isSupportedFullTextPolicy,
setFullTextDefaultLanguage,
} from "Explorer/Controls/FullTextSeach/FullTextPolicyUtils";
import { StopwordSettings } from "Explorer/Controls/FullTextSeach/StopwordSettings";
import { CosmosFluentProvider } from "Explorer/Theme/ThemeUtil";
import { t } from "Localization";
import * as React from "react";
import { isFullTextSearchPreviewFeaturesEnabled } from "Utils/CapabilityUtils";
@@ -26,22 +51,105 @@ export interface FullTextPoliciesComponentProps {
onChangesDiscarded?: () => void;
englishOnly?: boolean;
targetAccountOverride?: AccountOverride;
allowStopwordCustomization?: boolean;
isEditing?: boolean;
fullTextIndexes?: FullTextIndex[];
}
export interface FullTextPolicyData {
path: string;
language: string;
pathError: string;
}
const emptyPolicy: FullTextPolicy = { defaultLanguage: "en-US", fullTextPaths: [] };
const emptyIndexes: FullTextIndex[] = [];
const labelStyles = {
root: {
fontSize: 12,
color: "var(--colorNeutralForeground1)",
const useStyles = makeStyles({
editor: {
minWidth: 0,
"& .fui-Field__label": {
paddingLeft: 0,
paddingRight: 0,
whiteSpace: "normal",
},
};
"& .fui-Checkbox": {
maxWidth: "100%",
minWidth: 0,
},
"& .fui-Checkbox__label": {
minWidth: 0,
whiteSpace: "normal",
overflowWrap: "anywhere",
},
"& .fui-Textarea__textarea:disabled": {
color: tokens.colorNeutralForeground2,
},
"& .fui-Dropdown__button:disabled": {
color: tokens.colorNeutralForeground2,
},
"& .fui-Checkbox__input:disabled ~ .fui-Checkbox__label": {
color: tokens.colorNeutralForeground2,
},
},
pathsHeading: {
margin: 0,
paddingTop: tokens.spacingVerticalL,
paddingBottom: tokens.spacingVerticalS,
fontSize: tokens.fontSizeBase400,
lineHeight: tokens.lineHeightBase400,
fontWeight: tokens.fontWeightSemibold,
},
pathSection: {
borderTop: `1px solid ${tokens.colorNeutralStroke2}`,
minWidth: 0,
},
pathHeaderRow: {
display: "flex",
alignItems: "center",
columnGap: tokens.spacingHorizontalS,
},
pathHeader: {
flex: 1,
minWidth: 0,
margin: 0,
},
pathHeaderButton: {
paddingLeft: 0,
paddingRight: tokens.spacingHorizontalS,
minHeight: "56px",
minWidth: 0,
},
pathIdentity: {
display: "grid",
rowGap: tokens.spacingVerticalXXS,
minWidth: 0,
textAlign: "left",
},
pathName: {
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
fontWeight: tokens.fontWeightSemibold,
},
pathStatus: {
color: tokens.colorNeutralForeground2,
fontSize: tokens.fontSizeBase200,
lineHeight: tokens.lineHeightBase200,
fontWeight: tokens.fontWeightRegular,
whiteSpace: "normal",
},
pathError: {
color: tokens.colorPaletteRedForeground1,
},
pathPanel: {
margin: 0,
paddingBottom: tokens.spacingVerticalL,
},
pathFields: {
minWidth: 0,
paddingLeft: tokens.spacingHorizontalL,
},
});
const textFieldStyles: IStyleFunctionOrObject<ITextFieldStyleProps, ITextFieldStyles> = {
subComponentStyles: {
label: { root: { color: "var(--colorNeutralForeground1)" } },
},
fieldGroup: {
height: 27,
backgroundColor: "var(--colorNeutralBackground2)",
@@ -54,6 +162,8 @@ const textFieldStyles: IStyleFunctionOrObject<ITextFieldStyleProps, ITextFieldSt
backgroundColor: "var(--colorNeutralBackground2)",
},
root: {
width: "100%",
maxWidth: 320,
selectors: {
input: {
backgroundColor: "var(--colorNeutralBackground2)",
@@ -73,7 +183,8 @@ const textFieldStyles: IStyleFunctionOrObject<ITextFieldStyleProps, ITextFieldSt
const dropdownStyles: Partial<IDropdownStyles> = {
root: {
width: "40%",
width: "100%",
maxWidth: 240,
marginTop: "10px",
selectors: {
"&:hover .ms-Dropdown-title": {
@@ -208,167 +319,308 @@ export const FullTextPoliciesComponent: React.FunctionComponent<FullTextPolicies
onChangesDiscarded,
englishOnly,
targetAccountOverride,
allowStopwordCustomization = false,
isEditing = false,
fullTextIndexes = emptyIndexes,
}): JSX.Element => {
const getFullTextPathError = (path: string, index?: number): string => {
let error = "";
if (!path) {
error = "Full text path should not be empty";
}
if (
index >= 0 &&
fullTextPathData?.find(
(fullTextPath: FullTextPolicyData, dataIndex: number) => dataIndex !== index && fullTextPath.path === path,
)
) {
error = "Full text path is already defined";
}
return error;
};
const initializeData = (fullTextPolicy: FullTextPolicy): FullTextPolicyData[] => {
if (!fullTextPolicy) {
fullTextPolicy = { defaultLanguage: getFullTextLanguageOptions()[0].key as never, fullTextPaths: [] };
}
return fullTextPolicy.fullTextPaths.map((fullTextPath: FullTextPath) => ({
...fullTextPath,
pathError: getFullTextPathError(fullTextPath.path),
}));
};
const [fullTextPathData, setFullTextPathData] = React.useState<FullTextPolicyData[]>(initializeData(fullTextPolicy));
const [defaultLanguage, setDefaultLanguage] = React.useState<string>(
fullTextPolicy
? fullTextPolicy.defaultLanguage
: (getFullTextLanguageOptions(englishOnly, targetAccountOverride)[0].key as never),
const styles = useStyles();
const addPathButton = React.useRef<IButton>(null);
const newPathInput = React.useRef<ITextField>(null);
const focusNewPath = React.useRef(false);
const pathDescriptionId = useId("full-text-path-description");
const incomingPolicy = fullTextPolicy ?? emptyPolicy;
const [policy, setPolicy] = React.useState<FullTextPolicy>(incomingPolicy);
const initialPolicy = React.useRef(incomingPolicy);
const callbacks = React.useRef({ onFullTextPathChange, onChangesDiscarded });
callbacks.current = { onFullTextPathChange, onChangesDiscarded };
const canCustomize = allowStopwordCustomization && isFullTextSearchPreviewFeaturesEnabled(targetAccountOverride);
const showStopwords = canCustomize && (isEditing || policy.package === "standard");
const supported = isSupportedFullTextPolicy(policy);
const hasCustomization =
policy.package === "standard" ||
policy.defaultSpec !== undefined ||
policy.fullTextPaths.some(
(path) =>
path.stopWordListKind !== undefined || path.addStopWords !== undefined || path.removeStopWords !== undefined,
);
const readOnly = !supported || (hasCustomization && !canCustomize);
const defaultsLocked = readOnly || (isEditing && fullTextIndexes.length > 0);
const defaultLanguage = getFullTextDefaultLanguage(policy);
const canToggleCustomization =
!isEditing &&
initialPolicy.current.defaultSpec === undefined &&
initialPolicy.current.package === undefined &&
initialPolicy.current.fullTextPaths.every((path) =>
Object.keys(path).every((key) => key === "path" || key === "language"),
);
React.useEffect(() => {
propagateData();
}, [fullTextPathData, defaultLanguage]);
setPolicy(incomingPolicy);
}, [incomingPolicy]);
React.useEffect(() => {
if (discardChanges) {
setFullTextPathData(initializeData(fullTextPolicy));
setDefaultLanguage(fullTextPolicy.defaultLanguage);
onChangesDiscarded();
setPolicy(incomingPolicy);
callbacks.current.onChangesDiscarded?.();
}
}, [discardChanges]);
}, [discardChanges, incomingPolicy]);
const propagateData = () => {
const newFullTextPolicy: FullTextPolicy = {
defaultLanguage: defaultLanguage,
fullTextPaths: fullTextPathData.map((policy: FullTextPolicyData) => ({
path: policy.path,
language: policy.language,
})),
};
const fullTextIndexes: FullTextIndex[] = fullTextPathData.map((policy) => ({
path: policy.path,
React.useEffect(() => {
if (focusNewPath.current) {
focusNewPath.current = false;
newPathInput.current?.focus();
}
}, [policy.fullTextPaths.length]);
React.useEffect(() => {
callbacks.current.onFullTextPathChange(
policy,
isEditing ? fullTextIndexes : policy.fullTextPaths.map(({ path }) => ({ path })),
readOnly || isFullTextPolicyValid(policy, isEditing ? fullTextIndexes : emptyIndexes),
);
}, [policy, isEditing, fullTextIndexes, readOnly]);
const updatePath = (index: number, update: (path: FullTextPath) => FullTextPath): void =>
setPolicy((current) => ({
...current,
fullTextPaths: current.fullTextPaths.map((path, pathIndex) => (pathIndex === index ? update(path) : path)),
}));
const validationPassed = fullTextPathData.every((policy: FullTextPolicyData) => policy.pathError === "");
onFullTextPathChange(newFullTextPolicy, fullTextIndexes, validationPassed);
};
const onFullTextPathValueChange = (index: number, event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.target.value.trim();
const fullTextPaths = [...fullTextPathData];
if (!fullTextPaths[index]?.path && !value.startsWith("/")) {
fullTextPaths[index].path = "/" + value;
} else {
fullTextPaths[index].path = value;
const languageOptions = (currentLanguage: string): IDropdownOption[] => {
const options = getFullTextLanguageOptions(englishOnly, targetAccountOverride);
if (!options.some((option) => option.key === currentLanguage)) {
options.push({ key: currentLanguage, text: currentLanguage, disabled: true });
}
fullTextPaths[index].pathError = getFullTextPathError(value, index);
setFullTextPathData(fullTextPaths);
};
const onFullTextPathPolicyChange = (index: number, option: IDropdownOption): void => {
const policies = [...fullTextPathData];
policies[index].language = option.key as never;
setFullTextPathData(policies);
};
const onAdd = () => {
setFullTextPathData([
...fullTextPathData,
{
path: "",
language: defaultLanguage,
pathError: getFullTextPathError(""),
},
]);
};
const onDelete = (index: number) => {
const policies = fullTextPathData.filter((_uniqueKey, j) => index !== j);
setFullTextPathData(policies);
return options;
};
return (
<CosmosFluentProvider className={styles.editor}>
<Stack tokens={{ childrenGap: 4 }}>
{readOnly && (
<div role="status">{t(supported ? "fullTextPolicy.capabilityRequired" : "fullTextPolicy.unsupported")}</div>
)}
<Stack style={{ marginBottom: 10 }}>
<Label styles={labelStyles}>Default language</Label>
<Dropdown
label={t("fullTextPolicy.defaultLanguage")}
required={true}
disabled={defaultsLocked}
styles={dropdownStyles}
options={getFullTextLanguageOptions(englishOnly, targetAccountOverride)}
options={languageOptions(defaultLanguage)}
selectedKey={defaultLanguage}
onChange={(_event: React.FormEvent<HTMLDivElement>, option: IDropdownOption) =>
setDefaultLanguage(option.key as never)
onChange={(_event, option) => {
if (option && typeof option.key === "string") {
setPolicy((current) => setFullTextDefaultLanguage(current, option.key.toString()));
}
}}
></Dropdown>
</Stack>
{fullTextPathData &&
fullTextPathData.length > 0 &&
fullTextPathData.map((fullTextPolicy: FullTextPolicyData, index: number) => (
<CollapsibleSectionComponent
key={index}
isExpandedByDefault={true}
title={`Full text path ${index + 1}`}
showDelete={true}
onDelete={() => onDelete(index)}
>
<Stack horizontal tokens={{ childrenGap: 4 }}>
<Stack
styles={{
root: {
margin: "0 0 6px 20px !important",
paddingLeft: 20,
width: "80%",
borderLeft: "1px solid",
{canCustomize && canToggleCustomization && (
<Checkbox
label={t("fullTextPolicy.customize")}
checked={policy.package === "standard"}
disabled={readOnly}
onChange={(_event, data) => {
if (data.checked === true) {
setPolicy((current) => ({
...current,
package: "standard",
defaultSpec: {
language: getFullTextDefaultLanguage(current),
stopWordListKind: "basic",
tokenizer: "word",
filters: ["lowercase", "stop"],
},
fullTextPaths: current.fullTextPaths.map((path) =>
path.language === getFullTextDefaultLanguage(current) &&
Object.keys(path).every((key) => key === "path" || key === "language")
? { path: path.path }
: path,
),
}));
} else {
setPolicy((current) => {
const language = getFullTextDefaultLanguage(current);
const next = {
...current,
defaultLanguage: language,
fullTextPaths: current.fullTextPaths.map((path) => ({
...inheritFullTextPathAnalysis(path),
language: path.language ?? language,
})),
};
delete next.package;
delete next.defaultSpec;
return next;
});
}
}}
/>
)}
{showStopwords && (
<>
{!isEditing && <div>{t("fullTextPolicy.customizeDescription")}</div>}
{defaultsLocked && !readOnly && <div role="status">{t("fullTextPolicy.defaultLocked")}</div>}
<StopwordSettings
label={t("fullTextPolicy.defaultStopwords")}
description={t("fullTextPolicy.defaultDescription")}
language={defaultLanguage}
packageName={policy.package}
spec={{ ...policy.defaultSpec, language: defaultLanguage }}
disabled={defaultsLocked}
onChange={(defaultSpec) => setPolicy((current) => ({ ...current, defaultSpec }))}
/>
</>
)}
<h3 className={styles.pathsHeading}>
{t("fullTextPolicy.pathsHeading", { count: policy.fullTextPaths.length })}
</h3>
{policy.fullTextPaths.length === 0 && <div>{t("fullTextPolicy.emptyPaths")}</div>}
{policy.fullTextPaths.map((path, index) => {
const indexed = isEditing && fullTextIndexes.some((entry) => entry.path === path.path);
const pathLocked = readOnly || indexed;
const inherited = isFullTextPathInherited(path);
const pathError = !path.path.trim()
? t("fullTextPolicy.pathRequired")
: policy.fullTextPaths.some((entry, entryIndex) => entryIndex !== index && entry.path === path.path)
? t("fullTextPolicy.pathDuplicate")
: undefined;
const needsAttention = !pathLocked && (pathError || getStopwordValidationError(path, policy.package));
const analysisMode = t(inherited ? "fullTextPolicy.inheritsDefaults" : "fullTextPolicy.overridesDefaults");
const pathSummary = indexed
? t("fullTextPolicy.indexedPathSummary", { mode: analysisMode })
: readOnly
? t("fullTextPolicy.readOnlyPathSummary", { mode: analysisMode })
: analysisMode;
return (
<Accordion key={index} className={styles.pathSection} collapsible defaultOpenItems={["path"]}>
<AccordionItem value="path">
<div className={styles.pathHeaderRow}>
<AccordionHeader
as="h4"
className={styles.pathHeader}
button={{ className: styles.pathHeaderButton }}
>
<span className={styles.pathIdentity}>
<span className={styles.pathName} title={path.path}>
{path.path || t("fullTextPolicy.newPath")}
</span>
<span className={styles.pathStatus}>
{pathSummary}
{needsAttention && (
<span className={styles.pathError}>
{" - "}
{t("fullTextPolicy.needsAttention")}
</span>
)}
</span>
</span>
</AccordionHeader>
{!pathLocked && (
<Button
appearance="subtle"
icon={<DeleteRegular />}
aria-label={t("fullTextPolicy.deletePath", { index: index + 1 })}
title={t("fullTextPolicy.deletePath", { index: index + 1 })}
onClick={() => {
setPolicy((current) => ({
...current,
fullTextPaths: current.fullTextPaths.filter((_path, pathIndex) => pathIndex !== index),
}));
addPathButton.current?.focus();
}}
/>
)}
</div>
<AccordionPanel className={styles.pathPanel}>
<Stack className={styles.pathFields} tokens={{ childrenGap: 4 }}>
{pathLocked && !readOnly && <div role="status">{t("fullTextPolicy.pathLocked")}</div>}
<Stack>
<Label styles={labelStyles}>Path</Label>
<TextField
id={`full-text-policy-path-${index + 1}`}
label={t("fullTextPolicy.path")}
ariaLabel={t("fullTextPolicy.path")}
id={`${pathDescriptionId}-path-${index + 1}`}
componentRef={index === policy.fullTextPaths.length - 1 ? newPathInput : undefined}
required={true}
disabled={pathLocked}
placeholder="/fullTextPath1"
styles={textFieldStyles}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => onFullTextPathValueChange(index, event)}
value={fullTextPolicy.path || ""}
errorMessage={fullTextPolicy.pathError}
onChange={(_event, value) =>
updatePath(index, (current) => {
const next = (value ?? "").trim();
return {
...current,
path: !current.path && next.length > 0 && !next.startsWith("/") ? `/${next}` : next,
};
})
}
value={path.path}
errorMessage={pathError}
/>
</Stack>
<Stack>
<Label styles={labelStyles}>Language</Label>
<Dropdown
required={true}
styles={dropdownStyles}
options={getFullTextLanguageOptions(englishOnly, targetAccountOverride)}
selectedKey={fullTextPolicy.language}
onChange={(_event: React.FormEvent<HTMLDivElement>, option: IDropdownOption) =>
onFullTextPathPolicyChange(index, option)
{showStopwords && (
<>
<Checkbox
label={t("fullTextPolicy.inherit")}
aria-describedby={`${pathDescriptionId}-${index}`}
disabled={pathLocked}
checked={inherited}
onChange={(_event, data) =>
updatePath(index, (current) =>
data.checked === true
? inheritFullTextPathAnalysis(current)
: { ...current, language: defaultLanguage },
)
}
/>
<div id={`${pathDescriptionId}-${index}`} style={{ color: "var(--colorNeutralForeground2)" }}>
{inherited
? t("fullTextPolicy.inheritedLanguageDescription", {
language:
languageOptions(defaultLanguage).find((option) => option.key === defaultLanguage)
?.text ?? defaultLanguage,
})
: t("fullTextPolicy.overrideDescription")}
</div>
</>
)}
{(!inherited || !showStopwords) && (
<Stack>
<Dropdown
label={t("fullTextPolicy.language")}
disabled={pathLocked || inherited}
styles={dropdownStyles}
options={languageOptions(path.language ?? defaultLanguage)}
selectedKey={path.language ?? defaultLanguage}
onChange={(_event, option) => {
if (option && typeof option.key === "string") {
updatePath(index, (current) => ({ ...current, language: option.key.toString() }));
}
}}
></Dropdown>
</Stack>
)}
{showStopwords && !inherited && (
<StopwordSettings
label={t("fullTextPolicy.pathStopwords", { path: path.path })}
language={path.language ?? defaultLanguage}
packageName={policy.package}
spec={path}
inheritedFilters={policy.defaultSpec?.filters}
disabled={pathLocked}
onChange={(spec) => updatePath(index, (current) => ({ ...spec, path: current.path }))}
/>
)}
</Stack>
</Stack>
</CollapsibleSectionComponent>
))}
</AccordionPanel>
</AccordionItem>
</Accordion>
);
})}
<DefaultButton
id={`add-vector-policy`}
componentRef={addPathButton}
id="add-full-text-policy"
disabled={readOnly}
styles={{
root: {
maxWidth: 170,
@@ -376,6 +628,9 @@ export const FullTextPoliciesComponent: React.FunctionComponent<FullTextPolicies
color: "var(--colorNeutralForeground1)",
backgroundColor: "transparent",
borderColor: "var(--colorNeutralStroke1)",
selectors: {
":after": { pointerEvents: "none" },
},
},
rootHovered: {
color: "var(--colorNeutralForeground1)",
@@ -391,11 +646,23 @@ export const FullTextPoliciesComponent: React.FunctionComponent<FullTextPolicies
backgroundColor: "transparent",
},
}}
onClick={onAdd}
onClick={() => {
focusNewPath.current = true;
setPolicy((current) => ({
...current,
fullTextPaths: [
...current.fullTextPaths,
current.package === "standard"
? { path: "" }
: { path: "", language: getFullTextDefaultLanguage(current) },
],
}));
}}
>
Add full text path
{t("fullTextPolicy.addPath")}
</DefaultButton>
</Stack>
</CosmosFluentProvider>
);
};
@@ -405,28 +672,7 @@ export const getFullTextLanguageOptions = (
): IDropdownOption[] => {
const multiLanguageSupportEnabled: boolean =
isFullTextSearchPreviewFeaturesEnabled(targetAccountOverride) && !englishOnly;
const fullTextLanguageOptions: IDropdownOption[] = [
{
key: "en-US",
text: "English (US)",
},
...(multiLanguageSupportEnabled
? [
{
key: "fr-FR",
text: "French",
},
{
key: "de-DE",
text: "German",
},
{
key: "es-ES",
text: "Spanish",
},
]
: []),
];
return fullTextLanguageOptions;
return fullTextLanguages
.filter((language) => language.value === "en-US" || multiLanguageSupportEnabled)
.map(({ value, label }) => ({ key: value, text: t(`fullTextPolicy.${label}`) }));
};
@@ -0,0 +1,324 @@
import { FullTextAnalysisSpec, FullTextPolicy } from "Contracts/DataModels";
import {
fullTextLanguages,
getFullTextDefaultLanguage,
getStopwordPresets,
getStopwordValidationError,
inheritFullTextPathAnalysis,
isFullTextPathInherited,
isFullTextPolicyValid,
isSupportedFullTextPolicy,
setFullTextDefaultLanguage,
} from "./FullTextPolicyUtils";
describe("full-text policy helpers", () => {
const policy: FullTextPolicy = {
package: "standard",
defaultSpec: { language: "en-US", stopWordListKind: "basic", filters: ["lowercase", "stop"] },
fullTextPaths: [{ path: "/text" }],
};
it("lists exactly the seven verified locale choices", () => {
expect(fullTextLanguages.map(({ value }) => value)).toEqual([
"en-US",
"fr-FR",
"de-DE",
"es-ES",
"it-IT",
"pt-PT",
"pt-BR",
]);
});
describe("complete supported language and stopword policy matrix", () => {
it.each(fullTextLanguages)("$value and $legacyId expose only their supported presets", ({ value, legacyId }) => {
for (const language of [value, legacyId]) {
expect(getStopwordPresets("standard", language)).toEqual(["none", "basic"]);
const expectedLegacy = value === "en-US" ? ["none", "extended"] : ["none"];
expect(getStopwordPresets("legacy", language)).toEqual(expectedLegacy);
expect(getStopwordPresets(undefined, language)).toEqual(expectedLegacy);
for (const packageName of ["standard", "legacy", undefined]) {
for (const preset of ["none", "basic", "extended"]) {
const valid = packageName === "standard" ? preset !== "extended" : expectedLegacy.includes(preset);
const spec = {
language,
stopWordListKind: preset,
addStopWords: ["catalog", "Catalog", "catalog", "caf\u00e9"],
};
expect(getStopwordValidationError(spec, packageName)).toBe(valid ? undefined : "invalidPreset");
}
}
}
});
const transitions = fullTextLanguages.flatMap((from) =>
fullTextLanguages.map((to) => ({ from: from.value, to: to.value })),
);
it.each(transitions)("$from -> $to preserves explicit path overrides and unknown fields", ({ from, to }) => {
const paths = [
{ path: "/inherited" },
{ path: "/override", language: from, stopWordListKind: "none", addStopWords: ["Original", "original"] },
];
for (const shape of ["legacy", "modern", "both"]) {
const original: FullTextPolicy = {
...(shape !== "modern" ? { defaultLanguage: from } : {}),
...(shape !== "legacy"
? {
package: "standard",
defaultSpec: {
language: from,
stopWordListKind: "none",
addStopWords: ["keep", "Keep", "keep"],
tokenizer: "word",
filters: ["lowercase", "stop"],
future: { preserved: true },
},
}
: {}),
fullTextPaths: paths,
futurePolicy: { preserved: true },
};
const changed = setFullTextDefaultLanguage(original, to);
expect(getFullTextDefaultLanguage(changed)).toBe(to);
expect(changed.fullTextPaths).toEqual(paths);
expect(changed.futurePolicy).toEqual({ preserved: true });
expect(getFullTextDefaultLanguage(original)).toBe(from);
expect(changed.defaultLanguage).toBe(shape === "modern" ? undefined : to);
expect(changed.defaultSpec).toEqual(shape === "legacy" ? undefined : { ...original.defaultSpec, language: to });
expect(isFullTextPolicyValid(changed)).toBe(true);
}
});
const listCases = fullTextLanguages.flatMap(({ value }) =>
[0, 1, 5, 20, 100, 1001].map((count) => ({ language: value, count })),
);
it.each(listCases)("$language preserves $count custom words without an invented cap", ({ language, count }) => {
const words = Array.from(
{ length: count },
(_, index) => ["catalog", "Catalog", "caf\u00e9", "catalog"][index % 4],
);
for (const field of ["addStopWords", "removeStopWords"] as const) {
const spec = { language, stopWordListKind: "none", [field]: words };
expect(getStopwordValidationError(spec, "standard")).toBeUndefined();
expect(getStopwordValidationError(spec, "legacy")).toBeUndefined();
expect(spec[field]).toBe(words);
}
});
const forbidden = [
"two words",
"a\tb",
"a\nb",
"a\rb",
"a\u00a0b",
"a\u0000b",
"a\u007fb",
"a-b",
"can't",
"a\u2019b",
"a\u2014b",
"a/b",
"a,b",
"a.b",
"C++",
"$value",
"a_b",
"a:b",
];
it.each(fullTextLanguages)("$value rejects forbidden characters anywhere in either list", ({ value: language }) => {
for (const field of ["addStopWords", "removeStopWords"] as const) {
for (const invalid of forbidden) {
const words = Array.from({ length: 20 }, () => "valid");
words[18] = invalid;
expect(getStopwordValidationError({ language, stopWordListKind: "none", [field]: words }, "standard")).toBe(
"invalidWord",
);
}
expect(getStopwordValidationError({ language, [field]: [] }, "standard")).toBe("presetRequired");
expect(getStopwordValidationError({ stopWordListKind: "none", [field]: ["valid"] }, "standard")).toBe(
"languageRequired",
);
}
});
it.each(fullTextLanguages)(
"$value removes all supported analysis overrides when inheriting",
({ value: language }) => {
const path = {
path: "/text",
language,
stopWordListKind: "none",
addStopWords: ["one", "two"],
removeStopWords: ["three"],
tokenizer: "word",
filters: ["lowercase", "stop"],
future: { unchanged: true },
};
expect(inheritFullTextPathAnalysis(path)).toEqual({
path: "/text",
future: { unchanged: true },
});
expect(path.addStopWords).toEqual(["one", "two"]);
},
);
});
it("resolves defaults without changing the wire policy", () => {
expect(getFullTextDefaultLanguage({ fullTextPaths: [] })).toBe("en-US");
expect(getFullTextDefaultLanguage({ defaultLanguage: "fr-FR", fullTextPaths: [] })).toBe("fr-FR");
expect(getFullTextDefaultLanguage({ ...policy, defaultLanguage: "fr-FR" })).toBe("en-US");
});
it.each<[string | undefined, string, string[]]>([
[undefined, "en-US", ["none", "extended"]],
["legacy", "1033", ["none", "extended"]],
["legacy", "fr-FR", ["none"]],
["standard", "en-US", ["none", "basic"]],
["standard", "fr-FR", ["none", "basic"]],
["future", "en-US", []],
])("uses package/language-specific presets for %s / %s", (packageName, language, expected) => {
expect(getStopwordPresets(packageName, language)).toEqual(expected);
});
it("preserves unknown fields and does not introduce a legacy defaultLanguage into a modern policy", () => {
const original: FullTextPolicy = {
...policy,
futurePolicySetting: { enabled: true },
defaultSpec: { ...policy.defaultSpec, futureAnalysisSetting: ["unchanged"] },
};
const changed = setFullTextDefaultLanguage(original, "fr-FR");
expect(changed).toEqual({
...original,
defaultSpec: { ...original.defaultSpec, language: "fr-FR" },
});
expect(changed.defaultLanguage).toBeUndefined();
expect(original.defaultSpec?.language).toBe("en-US");
});
it("keeps both language fields consistent when the service returned both", () => {
const changed = setFullTextDefaultLanguage({ ...policy, defaultLanguage: "en-US" }, "de-DE");
expect(changed.defaultLanguage).toBe("de-DE");
expect(changed.defaultSpec?.language).toBe("de-DE");
});
it("resets all supported analysis overrides while preserving unknown fields", () => {
const path = {
path: "/text",
language: "fr-FR",
stopWordListKind: "none",
addStopWords: ["cosmos"],
removeStopWords: ["the"],
tokenizer: "word",
filters: ["lowercase", "ascii", "stem", "stop"],
futureSetting: { value: 1 },
};
expect(inheritFullTextPathAnalysis(path)).toEqual({
path: "/text",
futureSetting: { value: 1 },
});
expect(path.addStopWords).toEqual(["cosmos"]);
});
it("treats a path as inherited only when every supported analysis field is absent", () => {
expect(isFullTextPathInherited({ path: "/text", futureSetting: { preserved: true } })).toBe(true);
const overrides: FullTextAnalysisSpec[] = [
{ language: "en-US" },
{ stopWordListKind: "none" },
{ addStopWords: [] },
{ removeStopWords: [] },
{ tokenizer: "word" },
{ filters: [] },
];
for (const analysis of overrides) {
expect(isFullTextPathInherited({ path: "/text", ...analysis })).toBe(false);
}
});
it("requires explicit language and preset when custom lists are supplied, including empty lists", () => {
expect(getStopwordValidationError({ addStopWords: [] }, "standard")).toBe("languageRequired");
expect(getStopwordValidationError({ language: "en-US", removeStopWords: [] }, "standard")).toBe("presetRequired");
expect(
getStopwordValidationError({ language: "en-US", stopWordListKind: "none", addStopWords: [] }, "standard"),
).toBeUndefined();
expect(getStopwordValidationError({ language: "fr-FR", stopWordListKind: "extended" }, "legacy")).toBe(
"invalidPreset",
);
});
it.each(["two words", "word-word", "can't", "C++", "$money", "a\tb", "a\u0000b", "a\u2014b"])(
"rejects a custom word containing a forbidden character: %s",
(word) => {
expect(
getStopwordValidationError({ language: "en-US", stopWordListKind: "none", addStopWords: [word] }, "standard"),
).toBe("invalidWord");
},
);
it("does not silently normalize or impose an invented count limit on custom words", () => {
const words = ["Cosmos", "cosmos", "caf\u00e9", "", ...Array.from({ length: 1001 }, (_, i) => `word${i}`)];
const spec = { language: "en-US", stopWordListKind: "basic", addStopWords: words, removeStopWords: ["Cosmos"] };
expect(getStopwordValidationError(spec, "standard")).toBeUndefined();
expect(spec.addStopWords).toBe(words);
expect(spec.removeStopWords).toEqual(["Cosmos"]);
});
it("validates paths and their overrides separately from container defaults", () => {
expect(isFullTextPolicyValid(policy)).toBe(true);
expect(isFullTextPolicyValid({ ...policy, fullTextPaths: [{ path: "" }] })).toBe(false);
expect(isFullTextPolicyValid({ ...policy, fullTextPaths: [{ path: "/text" }, { path: "/text" }] })).toBe(false);
expect(
isFullTextPolicyValid({
...policy,
fullTextPaths: [{ path: "/text", language: "en-US", addStopWords: ["extra"] }],
}),
).toBe(false);
});
it("ignores only locked analysis while continuing to validate editable paths", () => {
const persisted: FullTextPolicy = {
...policy,
defaultSpec: { ...policy.defaultSpec, stopWordListKind: "extended" },
fullTextPaths: [{ path: "/text", language: "en-US", stopWordListKind: "extended" }],
};
expect(isFullTextPolicyValid(persisted)).toBe(false);
expect(isFullTextPolicyValid(persisted, [{ path: "/text" }])).toBe(true);
expect(
isFullTextPolicyValid({ ...persisted, fullTextPaths: [...persisted.fullTextPaths, { path: "/other" }] }, [
{ path: "/text" },
]),
).toBe(true);
expect(
isFullTextPolicyValid({ ...persisted, fullTextPaths: [...persisted.fullTextPaths, { path: "" }] }, [
{ path: "/text" },
]),
).toBe(false);
expect(
isFullTextPolicyValid(
{
...persisted,
fullTextPaths: [
...persisted.fullTextPaths,
{ path: "/other", language: "en-US", stopWordListKind: "basic", addStopWords: ["two words"] },
],
},
[{ path: "/text" }],
),
).toBe(false);
expect(
isFullTextPolicyValid({ ...persisted, fullTextPaths: [...persisted.fullTextPaths, { path: "/text" }] }, [
{ path: "/text" },
]),
).toBe(false);
});
it.each([
{ ...policy, package: "future" },
{ ...policy, defaultSpec: { language: "en-US", stopWordListKind: "future" } },
{ ...policy, defaultSpec: { language: "japanese" } },
{ ...policy, defaultSpec: { language: "en-US", tokenizer: "future" } },
{ ...policy, defaultSpec: { language: "en-US", filters: ["future"] } },
])("marks future analysis read-only without blocking an unrelated save", (futurePolicy) => {
expect(isSupportedFullTextPolicy(futurePolicy)).toBe(false);
expect(isFullTextPolicyValid(futurePolicy)).toBe(true);
});
});
@@ -0,0 +1,121 @@
import { FullTextAnalysisSpec, FullTextIndex, FullTextPath, FullTextPolicy } from "Contracts/DataModels";
export type StopwordValidationError = "languageRequired" | "presetRequired" | "invalidPreset" | "invalidWord";
export const fullTextLanguages = [
{ value: "en-US", legacyId: "1033", label: "english" },
{ value: "fr-FR", legacyId: "1036", label: "french" },
{ value: "de-DE", legacyId: "1031", label: "german" },
{ value: "es-ES", legacyId: "3082", label: "spanish" },
{ value: "it-IT", legacyId: "1040", label: "italian" },
{ value: "pt-PT", legacyId: "2070", label: "portuguesePortugal" },
{ value: "pt-BR", legacyId: "1046", label: "portugueseBrazil" },
] as const;
export const getFullTextDefaultLanguage = (policy: FullTextPolicy): string =>
policy.defaultSpec?.language ?? policy.defaultLanguage ?? "en-US";
export const getStopwordPresets = (packageName: string | undefined, language: string): string[] => {
if (packageName === "standard") {
return ["none", "basic"];
}
if (packageName === undefined || packageName === "legacy") {
return language === "en-US" || language === "1033" ? ["none", "extended"] : ["none"];
}
return [];
};
export const isSupportedFullTextPolicy = (policy: FullTextPolicy): boolean => {
if (policy.package !== undefined && policy.package !== "legacy" && policy.package !== "standard") {
return false;
}
const specs: (FullTextAnalysisSpec | undefined)[] = [
{ language: policy.defaultLanguage },
policy.defaultSpec,
...policy.fullTextPaths,
];
return specs.every(
(spec) =>
!spec ||
((!spec.language ||
fullTextLanguages.some(({ value, legacyId }) => value === spec.language || legacyId === spec.language)) &&
(spec.stopWordListKind === undefined || ["none", "basic", "extended"].includes(spec.stopWordListKind)) &&
(spec.tokenizer === undefined || spec.tokenizer === "word") &&
(spec.filters === undefined ||
spec.filters.every((filter) => ["lowercase", "ascii", "stop", "stem"].includes(filter)))),
);
};
export const getStopwordValidationError = (
spec: FullTextAnalysisSpec | undefined,
packageName: string | undefined,
): StopwordValidationError | undefined => {
if (!spec) {
return undefined;
}
const hasWords = spec.addStopWords !== undefined || spec.removeStopWords !== undefined;
if ((spec.language !== undefined || spec.stopWordListKind !== undefined || hasWords) && !spec.language) {
return "languageRequired";
}
if (hasWords && spec.stopWordListKind === undefined) {
return "presetRequired";
}
if (
spec.stopWordListKind !== undefined &&
!getStopwordPresets(packageName, spec.language ?? "en-US").includes(spec.stopWordListKind)
) {
return "invalidPreset";
}
// Mirror the server's whitespace/control/punctuation checks without normalizing customer words.
const invalidWord = /[\s\p{Cc}\x21-\x2f\x3a-\x40\x5b-\x60\x7b-\x7e\p{P}]/u;
if ([...(spec.addStopWords ?? []), ...(spec.removeStopWords ?? [])].some((word) => invalidWord.test(word))) {
return "invalidWord";
}
return undefined;
};
export const isFullTextPolicyValid = (
policy: FullTextPolicy,
lockedIndexes: readonly FullTextIndex[] = [],
): boolean => {
// Future policies are read-only in this editor and must not block unrelated settings saves.
if (!isSupportedFullTextPolicy(policy)) {
return true;
}
const lockedPaths = new Set(lockedIndexes.map(({ path }) => path));
const paths = policy.fullTextPaths.map(({ path }) => path);
return (
paths.every((path) => path.trim().length > 0) &&
new Set(paths).size === paths.length &&
(lockedPaths.size > 0 || getStopwordValidationError(policy.defaultSpec, policy.package) === undefined) &&
policy.fullTextPaths.every(
(path) => lockedPaths.has(path.path) || getStopwordValidationError(path, policy.package) === undefined,
)
);
};
export const setFullTextDefaultLanguage = (policy: FullTextPolicy, language: string): FullTextPolicy => ({
...policy,
...(!policy.defaultSpec || policy.defaultLanguage !== undefined ? { defaultLanguage: language } : {}),
...(policy.defaultSpec ? { defaultSpec: { ...policy.defaultSpec, language } } : {}),
});
const analysisFields = [
"language",
"stopWordListKind",
"addStopWords",
"removeStopWords",
"tokenizer",
"filters",
] as const;
export const isFullTextPathInherited = (path: FullTextPath): boolean =>
analysisFields.every((field) => path[field] === undefined);
export const inheritFullTextPathAnalysis = (path: FullTextPath): FullTextPath => {
const result = { ...path };
for (const field of analysisFields) {
delete result[field];
}
return result;
};
@@ -0,0 +1,241 @@
import "@testing-library/jest-dom";
import { fireEvent, render, screen } from "@testing-library/react";
import { FullTextAnalysisSpec } from "Contracts/DataModels";
import React from "react";
import { StopwordSettings } from "./StopwordSettings";
import { fullTextLanguages } from "./FullTextPolicyUtils";
const spec: FullTextAnalysisSpec = {
language: "en-US",
stopWordListKind: "basic",
tokenizer: "word",
filters: ["lowercase", "stop"],
addStopWords: ["cosmos"],
removeStopWords: ["the"],
};
describe("stopword design guidance", () => {
it("explains default scope without changing the selected policy", () => {
const onChange = jest.fn();
render(
<StopwordSettings
label="Default stopwords"
description="Used by full-text paths that inherit the container settings."
language="en-US"
packageName="standard"
spec={spec}
disabled={false}
onChange={onChange}
/>,
);
expect(screen.getByText("Used by full-text paths that inherit the container settings.")).toBeVisible();
expect(screen.getByRole("textbox", { name: "Additional stopwords" })).toHaveValue("cosmos");
expect(onChange).not.toHaveBeenCalled();
});
describe("multiword editor language matrix", () => {
it.each(fullTextLanguages)("$value edits either list losslessly at every tested size", ({ value: language }) => {
const changed = jest.fn();
const Harness = () => {
const [current, setCurrent] = React.useState<FullTextAnalysisSpec>({
...spec,
language,
future: { retained: true },
});
return (
<StopwordSettings
label="Words"
language={language}
packageName="standard"
spec={current}
disabled={false}
onChange={(next) => {
changed(next);
setCurrent(next);
}}
/>
);
};
render(<Harness />);
const added = screen.getByRole("textbox", { name: "Additional stopwords" });
const kept = screen.getByRole("textbox", { name: "Words to keep" });
for (const count of [0, 1, 5, 20, 100, 1001]) {
const words = Array.from(
{ length: count },
(_, index) => ["catalog", "Catalog", "caf\u00e9", "catalog"][index % 4],
);
fireEvent.change(added, { target: { value: words.join("\r\n") } });
expect(added).toHaveValue(words.join("\n"));
expect(changed).toHaveBeenLastCalledWith(expect.objectContaining({ language, addStopWords: words }));
fireEvent.change(kept, { target: { value: words.join("\n") } });
expect(kept).toHaveValue(words.join("\n"));
expect(changed).toHaveBeenLastCalledWith(
expect.objectContaining({
language,
addStopWords: words,
removeStopWords: words,
future: { retained: true },
}),
);
}
fireEvent.click(screen.getByRole("checkbox", { name: "Enable stopword filtering" }));
expect(added).toBeDisabled();
expect(kept).toBeDisabled();
expect(changed).toHaveBeenLastCalledWith(expect.objectContaining({ filters: ["lowercase"] }));
fireEvent.click(screen.getByRole("checkbox", { name: "Enable stopword filtering" }));
expect(added).toBeEnabled();
expect(kept).toBeEnabled();
expect(added).toHaveValue(
Array.from({ length: 1001 }, (_, index) => ["catalog", "Catalog", "caf\u00e9", "catalog"][index % 4]).join(
"\n",
),
);
});
it.each(fullTextLanguages)("$value associates a late error with only the offending list", ({ value: language }) => {
const words = Array.from({ length: 20 }, () => "valid");
words[18] = "invalid phrase";
const props = { label: "Words", language, packageName: "standard", disabled: false, onChange: jest.fn() };
const { rerender } = render(<StopwordSettings {...props} spec={{ ...spec, language, addStopWords: words }} />);
expect(screen.getByRole("textbox", { name: "Additional stopwords" })).toHaveAttribute("aria-invalid", "true");
expect(screen.getByRole("textbox", { name: "Words to keep" })).not.toHaveAttribute("aria-invalid", "true");
rerender(<StopwordSettings {...props} spec={{ ...spec, language, removeStopWords: words }} />);
expect(screen.getByRole("textbox", { name: "Words to keep" })).toHaveAttribute("aria-invalid", "true");
expect(screen.getByRole("textbox", { name: "Additional stopwords" })).not.toHaveAttribute("aria-invalid", "true");
rerender(
<StopwordSettings
{...props}
disabled
spec={{ ...spec, language, addStopWords: words, removeStopWords: words }}
/>,
);
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
expect(screen.getByRole("textbox", { name: "Additional stopwords" })).toHaveValue(words.join("\n"));
});
});
it("explains why service-default word fields are disabled without selecting a preset", () => {
const props = {
label: "Default stopwords",
language: "en-US",
packageName: "standard",
disabled: false,
onChange: jest.fn(),
};
const { rerender } = render(
<StopwordSettings {...props} spec={{ language: "en-US", tokenizer: "word", filters: ["lowercase", "stop"] }} />,
);
expect(screen.getByRole("combobox", { name: "Stopword list" })).toHaveAccessibleDescription(
"Select a stopword list to customize the words below.",
);
expect(screen.getByRole("textbox", { name: "Additional stopwords" })).toBeDisabled();
expect(props.onChange).not.toHaveBeenCalled();
rerender(<StopwordSettings {...props} spec={spec} />);
expect(screen.queryByText("Select a stopword list to customize the words below.")).not.toBeInTheDocument();
expect(screen.getByRole("textbox", { name: "Additional stopwords" })).toBeEnabled();
});
it.each<[string[], string[], boolean, boolean]>([
[["invalid phrase"], ["the"], true, false],
[["cosmos"], ["invalid phrase"], false, true],
[["bad!"], ["bad?"], true, true],
[["cosmos"], ["the"], false, false],
])(
"associates validation with the exact word list: %j / %j",
(addStopWords, removeStopWords, addedInvalid, removedInvalid) => {
render(
<StopwordSettings
label="Default stopwords"
language="en-US"
packageName="standard"
spec={{ ...spec, addStopWords, removeStopWords }}
disabled={false}
onChange={jest.fn()}
/>,
);
const added = screen.getByRole("textbox", { name: "Additional stopwords" });
const removed = screen.getByRole("textbox", { name: "Words to keep" });
expect(added.getAttribute("aria-invalid") === "true").toBe(addedInvalid);
expect(removed.getAttribute("aria-invalid") === "true").toBe(removedInvalid);
expect(added).toHaveAccessibleDescription(
addedInvalid
? /Stopwords cannot contain whitespace, punctuation, or control characters\./
: "Also ignore these words. Enter one word per line. Words cannot contain whitespace, punctuation, or control characters.",
);
expect(removed).toHaveAccessibleDescription(
removedInvalid
? /Stopwords cannot contain whitespace, punctuation, or control characters\./
: "Keep these words even if they are in the stopword list. Enter one word per line. Words cannot contain whitespace, punctuation, or control characters.",
);
expect(screen.queryAllByRole("alert")).toHaveLength(Number(addedInvalid) + Number(removedInvalid));
},
);
it.each([
["none", "No built-in stopwords. Only your additional stopwords are ignored."],
["basic", "Uses the built-in stopword list for this language."],
["extended", "Uses the legacy English stopword list."],
])("explains the %s preset without changing the policy", (stopWordListKind, description) => {
const onChange = jest.fn();
render(
<StopwordSettings
label="Default stopwords"
language="en-US"
packageName={stopWordListKind === "extended" ? "legacy" : "standard"}
spec={{ ...spec, stopWordListKind }}
disabled={false}
onChange={onChange}
/>,
);
expect(screen.getByRole("combobox", { name: "Stopword list" })).toHaveAccessibleDescription(description);
expect(
screen.getAllByText(
"Enter one word per line. Words cannot contain whitespace, punctuation, or control characters.",
),
).toHaveLength(1);
expect(onChange).not.toHaveBeenCalled();
});
it("does not mark locked values invalid or change serialization", () => {
const onChange = jest.fn();
const props = { label: "Default stopwords", language: "en-US", packageName: "standard", onChange };
const { rerender } = render(
<StopwordSettings {...props} spec={{ ...spec, addStopWords: ["invalid phrase"] }} disabled />,
);
expect(screen.getByRole("textbox", { name: "Additional stopwords" })).toBeDisabled();
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
rerender(<StopwordSettings {...props} spec={spec} disabled={false} />);
fireEvent.change(screen.getByRole("textbox", { name: "Additional stopwords" }), {
target: { value: "Cosmos\ncosmos\ncosmos" },
});
expect(onChange).toHaveBeenCalledWith({ ...spec, addStopWords: ["Cosmos", "cosmos", "cosmos"] });
});
it("keeps meanings but hides editing instructions for locked values", () => {
const props = {
label: "Default stopwords",
language: "en-US",
packageName: "standard",
spec,
onChange: jest.fn(),
};
const { rerender } = render(<StopwordSettings {...props} disabled />);
const added = screen.getByRole("textbox", { name: "Additional stopwords" });
expect(added).toHaveValue("cosmos");
expect(added).toBeDisabled();
expect(added).toHaveAccessibleDescription("Also ignore these words.");
expect(screen.getByRole("textbox", { name: "Words to keep" })).toHaveAccessibleDescription(
"Keep these words even if they are in the stopword list.",
);
expect(
screen.queryByText(
"Enter one word per line. Words cannot contain whitespace, punctuation, or control characters.",
),
).not.toBeInTheDocument();
rerender(<StopwordSettings {...props} disabled={false} />);
expect(added).toHaveAccessibleDescription(
"Also ignore these words. Enter one word per line. Words cannot contain whitespace, punctuation, or control characters.",
);
expect(props.onChange).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,210 @@
import { Checkbox, Dropdown, Field, makeStyles, Option, Textarea, tokens, useId } from "@fluentui/react-components";
import { FullTextAnalysisSpec } from "Contracts/DataModels";
import { getStopwordPresets, getStopwordValidationError } from "Explorer/Controls/FullTextSeach/FullTextPolicyUtils";
import { t } from "Localization";
import React from "react";
interface StopwordSettingsProps {
label: string;
description?: string;
language: string;
packageName: string | undefined;
spec: FullTextAnalysisSpec;
inheritedFilters?: string[];
disabled: boolean;
onChange: (spec: FullTextAnalysisSpec) => void;
}
const useStyles = makeStyles({
root: {
minWidth: 0,
paddingTop: tokens.spacingVerticalS,
display: "grid",
rowGap: tokens.spacingVerticalM,
},
heading: {
fontWeight: tokens.fontWeightSemibold,
fontSize: tokens.fontSizeBase300,
lineHeight: tokens.lineHeightBase300,
},
introduction: {
display: "grid",
rowGap: tokens.spacingVerticalXS,
},
wordLists: {
display: "grid",
alignItems: "start",
gridTemplateColumns: "repeat(auto-fit, minmax(min(100%, 200px), 1fr))",
gap: tokens.spacingHorizontalL,
minWidth: 0,
maxWidth: "496px",
},
wordField: {
minWidth: 0,
maxWidth: "240px",
},
hint: {
color: tokens.colorNeutralForeground2,
fontSize: tokens.fontSizeBase200,
lineHeight: tokens.lineHeightBase200,
},
});
export const StopwordSettings = ({
label,
description,
language,
packageName,
spec,
inheritedFilters,
disabled,
onChange,
}: StopwordSettingsProps): JSX.Element => {
const styles = useStyles();
const formatHintId = useId("stopword-format");
const error = getStopwordValidationError(spec, packageName);
const addedWordsInvalid =
!disabled && getStopwordValidationError({ ...spec, removeStopWords: undefined }, packageName) === "invalidWord";
const removedWordsInvalid =
!disabled && getStopwordValidationError({ ...spec, addStopWords: undefined }, packageName) === "invalidWord";
const effectiveFilters = spec.filters ?? (spec.tokenizer === undefined ? inheritedFilters : undefined) ?? [];
const filteringEnabled = packageName !== "standard" || effectiveFilters.includes("stop");
const presetLabel = (preset: string): string => {
switch (preset) {
case "none":
return t("fullTextPolicy.none");
case "basic":
return t("fullTextPolicy.basic");
case "extended":
return t("fullTextPolicy.extended");
default:
return preset;
}
};
const presetDescription = (): string | undefined => {
if (!filteringEnabled) {
return undefined;
}
switch (spec.stopWordListKind) {
case "none":
return t("fullTextPolicy.noneDescription");
case "basic":
return t("fullTextPolicy.basicDescription");
case "extended":
return t("fullTextPolicy.extendedDescription");
default:
return !disabled ? t("fullTextPolicy.selectListHint") : undefined;
}
};
const updateWords = (field: "addStopWords" | "removeStopWords", value: string): void =>
onChange({ ...spec, language, [field]: value === "" ? [] : value.split(/\r?\n/) });
return (
<div role="group" aria-label={label} className={styles.root}>
<div className={styles.introduction}>
<div className={styles.heading}>{label}</div>
{description && <div className={styles.hint}>{description}</div>}
</div>
{packageName === "standard" && (
<Checkbox
label={t("fullTextPolicy.enableStopFilter")}
checked={filteringEnabled}
disabled={disabled}
onChange={(_event, data) =>
onChange({
...spec,
language,
tokenizer: spec.tokenizer ?? "word",
filters:
data.checked === true
? [...effectiveFilters.filter((filter) => filter !== "stop"), "stop"]
: effectiveFilters.filter((filter) => filter !== "stop"),
})
}
/>
)}
{!filteringEnabled && <div role="status">{t("fullTextPolicy.stopFilterDisabled")}</div>}
<Field label={t("fullTextPolicy.preset")} hint={presetDescription()}>
<Dropdown
style={{ minWidth: 0, width: "100%", maxWidth: 240 }}
disabled={disabled || !filteringEnabled}
value={spec.stopWordListKind ? presetLabel(spec.stopWordListKind) : t("fullTextPolicy.serviceDefault")}
selectedOptions={[spec.stopWordListKind ?? ""]}
onOptionSelect={(_event, data) => {
if (data.optionValue === undefined) {
return;
}
const next = { ...spec, language };
if (data.optionValue === "") {
delete next.stopWordListKind;
delete next.addStopWords;
delete next.removeStopWords;
} else {
next.stopWordListKind = data.optionValue;
}
onChange(next);
}}
>
<Option value="" text={t("fullTextPolicy.serviceDefault")}>
{t("fullTextPolicy.resetStopwords")}
</Option>
{getStopwordPresets(packageName, language).map((preset) => (
<Option key={preset} value={preset}>
{presetLabel(preset)}
</Option>
))}
</Dropdown>
</Field>
<div className={styles.wordLists}>
<Field
className={styles.wordField}
label={t("fullTextPolicy.addedWords")}
hint={t("fullTextPolicy.addedWordsDescription")}
validationState={addedWordsInvalid ? "error" : "none"}
validationMessage={
addedWordsInvalid ? { children: t("fullTextPolicy.invalidWord"), role: "alert" } : undefined
}
>
<Textarea
style={{ width: "100%" }}
resize="vertical"
rows={2}
aria-describedby={disabled ? undefined : formatHintId}
disabled={disabled || !filteringEnabled || spec.stopWordListKind === undefined}
value={(spec.addStopWords ?? []).join("\n")}
onChange={(_event, data) => updateWords("addStopWords", data.value)}
/>
</Field>
<Field
className={styles.wordField}
label={t("fullTextPolicy.removedWords")}
hint={t("fullTextPolicy.removedWordsDescription")}
validationState={removedWordsInvalid ? "error" : "none"}
validationMessage={
removedWordsInvalid ? { children: t("fullTextPolicy.invalidWord"), role: "alert" } : undefined
}
>
<Textarea
style={{ width: "100%" }}
resize="vertical"
rows={2}
aria-describedby={disabled ? undefined : formatHintId}
disabled={disabled || !filteringEnabled || spec.stopWordListKind === undefined}
value={(spec.removeStopWords ?? []).join("\n")}
onChange={(_event, data) => updateWords("removeStopWords", data.value)}
/>
</Field>
</div>
{!disabled && (
<div id={formatHintId} className={styles.hint}>
{t("fullTextPolicy.wordsHint")}
</div>
)}
{!disabled && error && error !== "invalidWord" && (
<div role="alert" style={{ color: "var(--colorPaletteRedForeground1)" }}>
{t(`fullTextPolicy.${error}`)}
</div>
)}
</div>
);
};
@@ -1,5 +1,9 @@
import { IndexingPolicy } from "@azure/cosmos";
import { act } from "@testing-library/react";
import "@testing-library/jest-dom";
import { act, fireEvent, render, screen } from "@testing-library/react";
import { FullTextPolicy } from "Contracts/DataModels";
import { ContainerPolicyComponentProps } from "./SettingsSubComponents/ContainerPolicyComponent";
import { logConsoleError } from "Utils/NotificationConsoleUtils";
import { AuthType } from "AuthType";
import { shallow } from "enzyme";
import { useIndexingPolicyStore } from "Explorer/Tabs/QueryTab/ResultsView";
@@ -15,6 +19,7 @@ import { CollectionSettingsTabV2 } from "../../Tabs/SettingsTabV2";
import { SettingsComponent, SettingsComponentProps, SettingsComponentState } from "./SettingsComponent";
import { TtlType, isDirty } from "./SettingsUtils";
import { collection } from "./TestUtils";
let mockRenderRealContainerPolicy = false;
jest.mock("../../../Common/dataAccess/getIndexTransformationProgress", () => ({
getIndexTransformationProgress: jest.fn().mockReturnValue(undefined),
}));
@@ -37,6 +42,211 @@ jest.mock("../../../Common/dataAccess/updateCollection", () => ({
jest.mock("../../../Common/dataAccess/updateOffer", () => ({
updateOffer: jest.fn().mockReturnValue({} as DataModels.Offer),
}));
jest.mock("Utils/NotificationConsoleUtils", () => ({
...jest.requireActual("Utils/NotificationConsoleUtils"),
logConsoleError: jest.fn(),
}));
jest.mock("./SettingsSubComponents/ContainerPolicyComponent", () => ({
ContainerPolicyComponent: (props: ContainerPolicyComponentProps) => {
if (mockRenderRealContainerPolicy) {
const { ContainerPolicyComponent: ActualContainerPolicyComponent } = jest.requireActual<
typeof import("./SettingsSubComponents/ContainerPolicyComponent")
>("./SettingsSubComponents/ContainerPolicyComponent");
return <ActualContainerPolicyComponent {...props} />;
}
return (
<div>
<button onClick={() => props.onFullTextPolicyDirtyChange(true)}>Edit full-text</button>
<button onClick={() => props.onFullTextPolicyDirtyChange(false)}>Revert full-text</button>
<button onClick={() => props.onVectorEmbeddingPolicyDirtyChange(true)}>Edit vector</button>
<button onClick={() => props.onVectorEmbeddingPolicyDirtyChange(false)}>Revert vector</button>
<button onClick={() => props.onFullTextPolicyValidationChange(false)}>Invalid full-text</button>
<button onClick={() => props.onFullTextPolicyValidationChange(true)}>Valid full-text</button>
</div>
);
},
}));
describe("Settings full-text save lifecycle", () => {
const policy: FullTextPolicy = {
package: "standard",
defaultSpec: {
language: "en-US",
stopWordListKind: "basic",
filters: ["stop"],
addStopWords: ["cosmos"],
futureField: "preserve",
},
fullTextPaths: [{ path: "/text" }],
};
beforeEach(() => {
jest.clearAllMocks();
mockRenderRealContainerPolicy = false;
updateUserContext({ apiType: "SQL" });
});
afterEach(() => {
mockRenderRealContainerPolicy = false;
});
const setup = async (persistedPolicy = policy) => {
const fixture = {
...collection,
rawDataModel: { ...collection.rawDataModel, id: "test", fullTextPolicy: persistedPolicy },
id: ko.observable(collection.id()),
defaultTtl: ko.observable(collection.defaultTtl()),
analyticalStorageTtl: ko.observable(collection.analyticalStorageTtl()),
conflictResolutionPolicy: ko.observable(collection.conflictResolutionPolicy()),
changeFeedPolicy: ko.observable(collection.changeFeedPolicy()),
geospatialConfig: ko.observable(collection.geospatialConfig()),
computedProperties: ko.observable(collection.computedProperties()),
vectorEmbeddingPolicy: ko.observable(collection.vectorEmbeddingPolicy()),
dataMaskingPolicy: ko.observable(collection.dataMaskingPolicy()),
fullTextPolicy: ko.observable(persistedPolicy),
indexingPolicy: ko.observable({
...collection.indexingPolicy(),
fullTextIndexes: [{ path: "/text" }],
}),
};
const settingsTab = new CollectionSettingsTabV2({
collection: fixture,
tabKind: ViewModels.CollectionTabKind.CollectionSettingsV2,
title: "Scale & Settings",
tabPath: "",
node: undefined,
});
const ref = React.createRef<SettingsComponent>();
await act(async () => {
render(<SettingsComponent ref={ref} settingsTab={settingsTab} />);
});
const settings = ref.current!;
fireEvent.click(screen.getByRole("tab", { name: "Container Policies" }));
return { settings, settingsTab, fixture };
};
it("keeps Save enabled when either policy remains dirty and disables it for invalid full-text", async () => {
const { settings } = await setup();
fireEvent.click(screen.getByRole("button", { name: "Edit full-text" }));
fireEvent.click(screen.getByRole("button", { name: "Revert vector" }));
expect(settings.isSaveSettingsButtonEnabled()).toBe(true);
fireEvent.click(screen.getByRole("button", { name: "Edit vector" }));
fireEvent.click(screen.getByRole("button", { name: "Revert full-text" }));
expect(settings.isSaveSettingsButtonEnabled()).toBe(true);
fireEvent.click(screen.getByRole("button", { name: "Invalid full-text" }));
expect(settings.isSaveSettingsButtonEnabled()).toBe(false);
await act(async () => settings.onSaveClick());
expect(updateCollection).not.toHaveBeenCalled();
expect(logConsoleError).toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "Valid full-text" }));
expect(settings.isSaveSettingsButtonEnabled()).toBe(true);
fireEvent.click(screen.getByRole("button", { name: "Revert vector" }));
expect(settings.isSaveSettingsButtonEnabled()).toBe(false);
});
it("restores the complete baseline and clears dirty and invalid state on Discard", async () => {
const { settings } = await setup();
act(() =>
settings.setState({
fullTextPolicy: { ...policy, defaultSpec: { ...policy.defaultSpec, addStopWords: ["draft"] } },
isFullTextPolicyDirty: true,
isContainerPolicyDirty: true,
isFullTextPolicyValid: false,
}),
);
act(() => settings.onRevertClick());
expect(settings.state.fullTextPolicy).toEqual(policy);
expect(settings.state.isFullTextPolicyValid).toBe(true);
expect(settings.isDiscardSettingsButtonEnabled()).toBe(false);
});
it("preserves policy on unrelated saves, prevents duplicate requests, and uses the service response as baseline", async () => {
const { settings, fixture } = await setup();
const response = { ...fixture.rawDataModel, fullTextPolicy: { ...policy, serverField: "returned" } };
let resolveUpdate: (value: typeof response) => void;
jest.mocked(updateCollection).mockReturnValueOnce(
new Promise((resolve) => {
resolveUpdate = resolve;
}),
);
act(() => settings.setState({ isSubSettingsSaveable: true }));
let saving: Promise<void>;
act(() => {
saving = settings.onSaveClick();
});
await act(async () => settings.onSaveClick());
expect(updateCollection).toHaveBeenCalledTimes(1);
expect(updateCollection).toHaveBeenCalledWith(
"test",
"test",
expect.objectContaining({
fullTextPolicy: policy,
indexingPolicy: expect.objectContaining({ fullTextIndexes: [{ path: "/text" }] }),
}),
);
await act(async () => {
resolveUpdate!(response);
await saving;
});
expect(settings.state.fullTextPolicyBaseline).toEqual(response.fullTextPolicy);
expect(settings.state.fullTextPolicy).toEqual(response.fullTextPolicy);
expect(settings.isSaveSettingsButtonEnabled()).toBe(false);
});
it("keeps drafts and the original baseline after a service rejection", async () => {
const { settings, settingsTab } = await setup();
const draft = { ...policy, defaultSpec: { ...policy.defaultSpec, addStopWords: ["draft"] } };
act(() => settings.setState({ fullTextPolicy: draft, isFullTextPolicyDirty: true, isContainerPolicyDirty: true }));
jest.mocked(updateCollection).mockRejectedValueOnce(new Error("indexed analysis cannot change"));
await act(async () => settings.onSaveClick());
expect(settingsTab.isExecutionError()).toBe(true);
expect(settings.state.fullTextPolicy).toEqual(draft);
expect(settings.state.fullTextPolicyBaseline).toEqual(policy);
expect(settings.isSaveSettingsButtonEnabled()).toBe(true);
});
it.each([false, true])(
"preserves uneditable analysis while saving unrelated TTL changes with preview capability %s",
async (preview) => {
mockRenderRealContainerPolicy = true;
updateUserContext({
databaseAccount: {
id: "account",
name: "account",
type: "Microsoft.DocumentDB/databaseAccounts",
location: "westus",
kind: "GlobalDocumentDB",
properties: {
capabilities: preview ? [{ name: "EnableNoSQLFullTextSearchPreviewFeatures", description: "" }] : [],
},
},
});
const persisted = { ...policy, defaultSpec: { ...policy.defaultSpec, stopWordListKind: "extended" } };
const { settings, fixture } = await setup(persisted);
act(() =>
settings.setState({
timeToLive: TtlType.On,
timeToLiveSeconds: 3600,
displayedTtlSeconds: "3600",
isSubSettingsSaveable: true,
}),
);
expect(settings.isSaveSettingsButtonEnabled()).toBe(true);
jest.mocked(updateCollection).mockResolvedValueOnce({
...fixture.rawDataModel,
defaultTtl: 3600,
fullTextPolicy: persisted,
});
await act(async () => settings.onSaveClick());
expect(updateCollection).toHaveBeenCalledWith(
"test",
"test",
expect.objectContaining({ defaultTtl: 3600, fullTextPolicy: persisted }),
);
expect(settings.state.fullTextPolicy).toEqual(persisted);
},
);
});
describe("SettingsComponent", () => {
const baseProps: SettingsComponentProps = {
@@ -19,6 +19,7 @@ import { Keys, t } from "Localization";
import { isFabric, isFabricNative } from "Platform/Fabric/FabricUtil";
import { isVectorSearchEnabled } from "Utils/CapabilityUtils";
import { isRunningOnPublicCloud } from "Utils/CloudUtils";
import { logConsoleError } from "Utils/NotificationConsoleUtils";
import * as React from "react";
import DiscardIcon from "../../../../images/discard.svg";
import SaveIcon from "../../../../images/save-cosmos.svg";
@@ -125,8 +126,11 @@ export interface SettingsComponentState {
vectorEmbeddingPolicy: DataModels.VectorEmbeddingPolicy;
vectorEmbeddingPolicyBaseline: DataModels.VectorEmbeddingPolicy;
isVectorEmbeddingPolicyValid: boolean;
isVectorEmbeddingPolicyDirty: boolean;
fullTextPolicy: DataModels.FullTextPolicy;
fullTextPolicyBaseline: DataModels.FullTextPolicy;
isFullTextPolicyValid: boolean;
isFullTextPolicyDirty: boolean;
shouldDiscardContainerPolicies: boolean;
isContainerPolicyDirty: boolean;
@@ -252,8 +256,11 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
vectorEmbeddingPolicy: undefined,
vectorEmbeddingPolicyBaseline: undefined,
isVectorEmbeddingPolicyValid: true,
isVectorEmbeddingPolicyDirty: false,
fullTextPolicy: undefined,
fullTextPolicyBaseline: undefined,
isFullTextPolicyValid: true,
isFullTextPolicyDirty: false,
shouldDiscardContainerPolicies: false,
isContainerPolicyDirty: false,
@@ -379,7 +386,7 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
return false;
}
if (!this.state.isVectorEmbeddingPolicyValid) {
if (!this.state.isVectorEmbeddingPolicyValid || !this.state.isFullTextPolicyValid) {
return false;
}
@@ -449,6 +456,14 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
};
public onSaveClick = async (): Promise<void> => {
if (this.props.settingsTab.isExecuting()) {
return;
}
if (!this.state.isFullTextPolicyValid) {
this.props.settingsTab.isExecutionError(true);
logConsoleError(t(Keys.panes.addCollection.fullTextSearchPolicyError));
return;
}
this.props.settingsTab.isExecutionError(false);
this.props.settingsTab.isExecuting(true);
@@ -518,6 +533,9 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
autoPilotThroughput: this.state.autoPilotThroughputBaseline,
isAutoPilotSelected: this.state.wasAutopilotOriginallySet,
isVectorEmbeddingPolicyValid: true,
isVectorEmbeddingPolicyDirty: false,
isFullTextPolicyValid: true,
isFullTextPolicyDirty: false,
shouldDiscardContainerPolicies: true,
shouldDiscardIndexingPolicy: true,
isScaleSaveable: false,
@@ -569,7 +587,7 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
};
this.setState({
indexingPolicyContent: newIndexingPolicy,
isIndexingPolicyDirty: true,
isIndexingPolicyDirty: isDirty(newIndexingPolicy, this.state.indexingPolicyContentBaseline),
});
};
@@ -673,13 +691,22 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
this.setState({ isSubSettingsDiscardable: isSubSettingsDiscardable });
private onVectorEmbeddingPolicyDirtyChange = (isVectorEmbeddingPolicyDirty: boolean): void =>
this.setState({ isContainerPolicyDirty: isVectorEmbeddingPolicyDirty });
this.setState((state) => ({
isVectorEmbeddingPolicyDirty,
isContainerPolicyDirty: isVectorEmbeddingPolicyDirty || state.isFullTextPolicyDirty,
}));
private onVectorEmbeddingPolicyValidationChange = (isVectorEmbeddingPolicyValid: boolean): void =>
this.setState({ isVectorEmbeddingPolicyValid });
private onFullTextPolicyDirtyChange = (isFullTextPolicyDirty: boolean): void =>
this.setState({ isContainerPolicyDirty: isFullTextPolicyDirty });
this.setState((state) => ({
isFullTextPolicyDirty,
isContainerPolicyDirty: isFullTextPolicyDirty || state.isVectorEmbeddingPolicyDirty,
}));
private onFullTextPolicyValidationChange = (isFullTextPolicyValid: boolean): void =>
this.setState({ isFullTextPolicyValid });
private onIndexingPolicyDirtyChange = (isIndexingPolicyDirty: boolean): void =>
this.setState({ isIndexingPolicyDirty: isIndexingPolicyDirty });
@@ -1151,6 +1178,11 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
this.setState({
dataMaskingContentBaseline: this.state.dataMaskingContent,
fullTextPolicy: updatedCollection.fullTextPolicy,
fullTextPolicyBaseline: updatedCollection.fullTextPolicy,
isFullTextPolicyDirty: false,
isFullTextPolicyValid: true,
isVectorEmbeddingPolicyDirty: false,
isSubSettingsSaveable: false,
isSubSettingsDiscardable: false,
isContainerPolicyDirty: false,
@@ -1358,6 +1390,8 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
fullTextPolicyBaseline: this.state.fullTextPolicyBaseline,
onFullTextPolicyChange: this.onFullTextPolicyChange,
onFullTextPolicyDirtyChange: this.onFullTextPolicyDirtyChange,
onFullTextPolicyValidationChange: this.onFullTextPolicyValidationChange,
fullTextIndexesBaseline: this.state.indexingPolicyContentBaseline?.fullTextIndexes,
isFullTextSearchEnabled: this.isFullTextSearchEnabled,
shouldDiscardContainerPolicies: this.state.shouldDiscardContainerPolicies,
resetShouldDiscardContainerPolicyChange: this.resetShouldDiscardContainerPolicies,
@@ -1,5 +1,11 @@
import { DefaultButton, Pivot, PivotItem, Stack } from "@fluentui/react";
import { FullTextPolicy, VectorEmbedding, VectorEmbeddingPolicy, VectorIndex } from "Contracts/DataModels";
import {
FullTextIndex,
FullTextPolicy,
VectorEmbedding,
VectorEmbeddingPolicy,
VectorIndex,
} from "Contracts/DataModels";
import {
FullTextPoliciesComponent,
getFullTextLanguageOptions,
@@ -24,6 +30,8 @@ export interface ContainerPolicyComponentProps {
fullTextPolicyBaseline: FullTextPolicy;
onFullTextPolicyChange: (newFullTextPolicy: FullTextPolicy) => void;
onFullTextPolicyDirtyChange: (isFullTextPolicyDirty: boolean) => void;
onFullTextPolicyValidationChange: (isValid: boolean) => void;
fullTextIndexesBaseline?: FullTextIndex[];
isFullTextSearchEnabled: boolean;
shouldDiscardContainerPolicies: boolean;
resetShouldDiscardContainerPolicyChange: () => void;
@@ -36,19 +44,20 @@ export const ContainerPolicyComponent: React.FC<ContainerPolicyComponentProps> =
onVectorEmbeddingPolicyDirtyChange,
onVectorEmbeddingPolicyValidationChange,
vectorIndexes,
vectorIndexesBaseline,
onVectorIndexesChange,
isVectorSearchEnabled,
fullTextPolicy,
fullTextPolicyBaseline,
onFullTextPolicyChange,
onFullTextPolicyDirtyChange,
onFullTextPolicyValidationChange,
fullTextIndexesBaseline,
isFullTextSearchEnabled,
shouldDiscardContainerPolicies,
resetShouldDiscardContainerPolicyChange,
}) => {
const [selectedTab, setSelectedTab] = React.useState<ContainerPolicyTabTypes>(
ContainerPolicyTabTypes.VectorPolicyTab,
isVectorSearchEnabled ? ContainerPolicyTabTypes.VectorPolicyTab : ContainerPolicyTabTypes.FullTextPolicyTab,
);
const [vectorEmbeddings, setVectorEmbeddings] = React.useState<VectorEmbedding[]>(
vectorEmbeddingPolicy?.vectorEmbeddings ?? [],
@@ -79,7 +88,12 @@ export const ContainerPolicyComponent: React.FC<ContainerPolicyComponentProps> =
setDiscardFullTextChanges(true);
resetShouldDiscardContainerPolicyChange();
}
});
}, [
shouldDiscardContainerPolicies,
vectorEmbeddingPolicyBaseline,
fullTextPolicyBaseline,
resetShouldDiscardContainerPolicyChange,
]);
const checkAndSendVectorEmbeddingPoliciesToSettings = (
newVectorEmbeddings: VectorEmbedding[],
@@ -89,21 +103,18 @@ export const ContainerPolicyComponent: React.FC<ContainerPolicyComponentProps> =
onVectorEmbeddingPolicyValidationChange(validationPassed);
const isVectorDirty: boolean = isDirty(newVectorEmbeddings, vectorEmbeddingsBaseline);
onVectorEmbeddingPolicyDirtyChange(isVectorDirty);
if (isVectorDirty) {
onVectorEmbeddingPolicyChange({ vectorEmbeddings: newVectorEmbeddings });
if (isDirty(newVectorEmbeddings, vectorEmbeddingPolicy?.vectorEmbeddings)) {
onVectorEmbeddingPolicyChange({ ...vectorEmbeddingPolicy, vectorEmbeddings: newVectorEmbeddings });
}
if (isDirty(newVectorIndexes ?? [], vectorIndexesBaseline ?? [])) {
if (isDirty(newVectorIndexes ?? [], vectorIndexes ?? [])) {
onVectorIndexesChange(newVectorIndexes);
}
};
const checkAndSendFullTextPolicyToSettings = (newFullTextPolicy: FullTextPolicy): void => {
if (isDirty(newFullTextPolicy, fullTextSearchPolicyBaseline)) {
onFullTextPolicyDirtyChange(true);
const checkAndSendFullTextPolicyToSettings = (newFullTextPolicy: FullTextPolicy, validationPassed = true): void => {
onFullTextPolicyValidationChange(validationPassed);
onFullTextPolicyDirtyChange(isDirty(newFullTextPolicy, fullTextSearchPolicyBaseline));
onFullTextPolicyChange(newFullTextPolicy);
} else {
resetShouldDiscardContainerPolicyChange();
}
};
const onVectorChangesDiscarded = (): void => {
@@ -198,12 +209,15 @@ export const ContainerPolicyComponent: React.FC<ContainerPolicyComponentProps> =
style={{ marginTop: 20, color: "var(--colorNeutralForeground1)" }}
headerText={t(Keys.controls.settings.containerPolicy.fullTextPolicy)}
>
<Stack {...titleAndInputStackProps} styles={{ root: { position: "relative", maxWidth: "400px" } }}>
<Stack {...titleAndInputStackProps} styles={{ root: { position: "relative", maxWidth: "496px" } }}>
{fullTextSearchPolicy ? (
<FullTextPoliciesComponent
fullTextPolicy={fullTextSearchPolicy}
onFullTextPathChange={(newFullTextPolicy: FullTextPolicy) =>
checkAndSendFullTextPolicyToSettings(newFullTextPolicy)
allowStopwordCustomization={true}
isEditing={true}
fullTextIndexes={fullTextIndexesBaseline}
onFullTextPathChange={(newFullTextPolicy, _indexes, validationPassed) =>
checkAndSendFullTextPolicyToSettings(newFullTextPolicy, validationPassed)
}
discardChanges={discardFullTextChanges}
onChangesDiscarded={onFullTextChangesDiscarded}
@@ -234,7 +248,7 @@ export const ContainerPolicyComponent: React.FC<ContainerPolicyComponentProps> =
}}
onClick={() => {
checkAndSendFullTextPolicyToSettings({
defaultLanguage: getFullTextLanguageOptions()[0].key as never,
defaultLanguage: String(getFullTextLanguageOptions()[0].key),
fullTextPaths: [],
});
}}
@@ -333,6 +333,7 @@ exports[`SettingsComponent renders 1`] = `
isVectorSearchEnabled={false}
onFullTextPolicyChange={[Function]}
onFullTextPolicyDirtyChange={[Function]}
onFullTextPolicyValidationChange={[Function]}
onVectorEmbeddingPolicyChange={[Function]}
onVectorEmbeddingPolicyDirtyChange={[Function]}
onVectorEmbeddingPolicyValidationChange={[Function]}
@@ -1,8 +1,17 @@
import "@testing-library/jest-dom";
import { act, fireEvent, render, screen, within } from "@testing-library/react";
import { createCollection } from "Common/dataAccess/createCollection";
import { Capability } from "Contracts/DataModels";
import { shallow } from "enzyme";
import { Keys, t } from "Localization";
import React from "react";
import { updateUserContext } from "UserContext";
import Explorer from "../../Explorer";
import { AddCollectionPanel } from "./AddCollectionPanel";
import * as AddCollectionPanelUtility from "./AddCollectionPanelUtility";
jest.mock("Common/dataAccess/createCollection", () => ({ createCollection: jest.fn() }));
jest.mock("Explorer/Controls/ThroughputInput/ThroughputInput", () => ({ ThroughputInput: () => null }));
const props = {
explorer: new Explorer(),
@@ -67,4 +76,67 @@ describe("AddCollectionPanel", () => {
expect(wrapper).toBeDefined();
});
});
describe("full-text creation validation", () => {
beforeEach(() => {
jest.clearAllMocks();
updateUserContext({ apiType: "SQL" });
jest.mocked(createCollection).mockResolvedValue(undefined);
jest.spyOn(AddCollectionPanelUtility, "scrollToSection").mockImplementation(() => undefined);
jest.spyOn(props.explorer, "refreshAllDatabases").mockResolvedValue(undefined);
});
afterEach(() => jest.restoreAllMocks());
it.each([false, true])(
"blocks invalid stopwords and allows correction with vector capability %s",
async (vector) => {
const { container } = render(
<AddCollectionPanel
{...props}
targetAccountOverride={{
subscriptionId: "subscription",
resourceGroup: "group",
accountName: "account",
capabilities: [
{ name: "EnableNoSQLFullTextSearchPreviewFeatures", description: "" },
...(vector ? [{ name: "EnableNoSQLVectorSearch", description: "" }] : []),
],
}}
/>,
);
fireEvent.change(screen.getByPlaceholderText("Type a new database id"), { target: { value: "database" } });
fireEvent.change(screen.getByPlaceholderText("e.g., Container1"), { target: { value: "container" } });
fireEvent.change(screen.getByPlaceholderText(/first partition key/), { target: { value: "/pk" } });
fireEvent.click(screen.getByRole("button", { name: "Container Full Text Search Policy" }));
fireEvent.click(screen.getByLabelText("Customize stopwords with standard analysis"));
fireEvent.click(screen.getByRole("button", { name: "Add full text path" }));
fireEvent.change(screen.getByLabelText("Path"), { target: { value: "/text" } });
const words = within(screen.getByRole("group", { name: "Default stopwords" })).getByRole("textbox", {
name: "Additional stopwords",
});
fireEvent.change(words, { target: { value: "two words" } });
const form = container.querySelector("form")!;
await act(async () => {
fireEvent.submit(form);
});
expect(createCollection).not.toHaveBeenCalled();
expect(screen.getByText(t(Keys.panes.addCollection.fullTextSearchPolicyError))).toBeVisible();
fireEvent.change(words, { target: { value: "cosmos" } });
await act(async () => {
fireEvent.submit(form);
});
expect(createCollection).toHaveBeenCalledTimes(1);
expect(createCollection).toHaveBeenCalledWith(
expect.objectContaining({
fullTextPolicy: expect.objectContaining({
defaultSpec: expect.objectContaining({ addStopWords: ["cosmos"] }),
fullTextPaths: [{ path: "/text" }],
}),
}),
);
},
10000,
);
});
});
@@ -881,8 +881,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
) => {
this.setState({ fullTextPolicy, fullTextIndexes, fullTextPolicyValidated });
}}
// Remove when multi language support on container create issue is fixed
englishOnly={true}
allowStopwordCustomization={true}
/>
</Stack>
</Stack>
@@ -1230,17 +1229,15 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
return false;
}
if (this.shouldShowVectorSearchParameters()) {
if (!this.state.vectorPolicyValidated) {
if (this.shouldShowVectorSearchParameters() && !this.state.vectorPolicyValidated) {
this.setState({ errorMessage: t(Keys.panes.addCollection.vectorPolicyError) });
return false;
}
if (!this.state.fullTextPolicyValidated) {
if (this.shouldShowFullTextSearchParameters() && !this.state.fullTextPolicyValidated) {
this.setState({ errorMessage: t(Keys.panes.addCollection.fullTextSearchPolicyError) });
return false;
}
}
return true;
}
@@ -495,7 +495,7 @@ exports[`AddCollectionPanel should render Default properly 1`] = `
}
>
<FullTextPoliciesComponent
englishOnly={true}
allowStopwordCustomization={true}
fullTextPolicy={
{
"defaultLanguage": "en-US",
+59
View File
@@ -38,6 +38,65 @@
"off": "Off",
"preview": "Preview"
},
"fullTextPolicy": {
"defaultLanguage": "Default language",
"language": "Language",
"path": "Path",
"pathTitle": "Full text path {{index}}",
"pathsHeading": "Full-text paths ({{count}})",
"emptyPaths": "Add a path to choose which document fields use full-text search.",
"newPath": "New path",
"inheritsDefaults": "Inherits defaults",
"overridesDefaults": "Overrides defaults",
"indexedPathSummary": "Indexed - {{mode}}",
"readOnlyPathSummary": "Read-only - {{mode}}",
"needsAttention": "Needs attention",
"addPath": "Add full text path",
"deletePath": "Delete full text path {{index}}",
"pathRequired": "Full text path should not be empty",
"pathDuplicate": "Full text path is already defined",
"customize": "Customize stopwords with standard analysis",
"customizeDescription": "Choose which words are ignored when indexing and searching.",
"defaultStopwords": "Default stopwords",
"defaultDescription": "Used by full-text paths that inherit the container settings.",
"pathStopwords": "Stopwords for {{path}}",
"preset": "Stopword list",
"serviceDefault": "Use service default",
"selectListHint": "Select a stopword list to customize the words below.",
"resetStopwords": "Use service default (clear custom words)",
"enableStopFilter": "Enable stopword filtering",
"stopFilterDisabled": "Stopword lists are inactive until the stop filter is enabled.",
"none": "None",
"basic": "Basic",
"extended": "Extended (legacy)",
"noneDescription": "No built-in stopwords. Only your additional stopwords are ignored.",
"basicDescription": "Uses the built-in stopword list for this language.",
"extendedDescription": "Uses the legacy English stopword list.",
"addedWords": "Additional stopwords",
"addedWordsDescription": "Also ignore these words.",
"removedWords": "Words to keep",
"removedWordsDescription": "Keep these words even if they are in the stopword list.",
"wordsHint": "Enter one word per line. Words cannot contain whitespace, punctuation, or control characters.",
"inherit": "Inherit the container's language and stopwords",
"inheritedDescription": "Uses the container's language and all default analysis settings, including stopwords.",
"inheritedLanguageDescription": "Uses {{language}} and all default analysis settings, including stopwords.",
"overrideDescription": "Custom word lists replace the defaults. Inheriting resets this path's language, tokenizer, filters, and stopwords.",
"defaultLocked": "Default analysis settings cannot be changed while a full-text index exists.",
"pathLocked": "This path has a full-text index. Its analysis settings cannot be changed.",
"unsupported": "This policy contains settings this editor cannot safely change. They are preserved without modification.",
"capabilityRequired": "Stopword customization is not enabled for this connection. The existing policy is preserved.",
"languageRequired": "Select an explicit language before configuring stopwords.",
"presetRequired": "Select an explicit stopword list before adding words or choosing words to keep.",
"invalidPreset": "This stopword list is not supported for the selected language and analysis package.",
"invalidWord": "Stopwords cannot contain whitespace, punctuation, or control characters.",
"english": "English (US)",
"french": "French",
"german": "German",
"spanish": "Spanish",
"italian": "Italian",
"portuguesePortugal": "Portuguese (Portugal)",
"portugueseBrazil": "Portuguese (Brazil)"
},
"cosmosDBShell": {
"installingSdk": "Downloading and installing .NET SDK 10. First-time setup may take a few minutes.",
"installingShell": "Installing Cosmos DB Shell...",
+104
View File
@@ -167,6 +167,110 @@ The UI allows you to select a specific test to run and to see the results of the
See the [Playwright docs](https://playwright.dev/docs/running-tests) for more information on running tests.
### Full-text stopword CI coverage
The `Full-text stopwords` suite in `sql\container.spec.ts` runs in the normal
Playwright CI shards and browser projects without opt-in variables or mocked ARM
responses. It uses the existing SQL test-account configuration and requires
`EnableNoSQLFullTextSearchPreviewFeatures` on every SQL shard account. A missing
capability fails with an explicit prerequisite message rather than skipping.
The test never changes account settings.
Trace and video recording are disabled for this test file because the harness URL
contains authentication tokens.
The five cases cover multiword defaults (including case, order, and duplicates),
invalid entries in each word list, Discard, and path overrides returning to
inheritance. Saved policies are checked after a fresh app load. Each case creates
an isolated 400-RU/s container and deletes its generated database in `afterEach`,
including after failed setup or assertions. Setup and cleanup use ARM, like the
policy operations; Settings still requires data-plane access. These tests do not
assert search semantics.
The Settings-created CI policy uses legacy analysis, which does not expose the
standard-only stop-filter toggle. Its inheritance case checks the outgoing policy
contains only the inherited path, without hidden analysis overrides. Standard
filter-toggle/reset behavior is covered by the unit regressions. Restoring
inheritance resets all supported per-field analysis settings (including tokenizer
and filters) rather than copying
the current defaults; later default changes therefore still apply. Unknown fields
are preserved, and unsupported policies remain read-only.
`resources\account.bicep` includes the capability for newly provisioned SQL test
accounts. CI does not deploy that template: `.github\workflows\ci.yml` uses
pre-existing `${DE_ACCOUNT_PREFIX}-de-test-sql-1` through `-20` accounts in the
subscription/resource group configured by the `E2ETESTS_*` secrets. Their owner
must enable and verify the capability before running this suite, preserving all
existing capabilities. Do not redeploy the generic template over those accounts
to enable a single capability. The suite checks the actual account metadata, not
a feature flag or mocked response.
Run it with:
```powershell
npx playwright test container.spec.ts --grep "Full-text stopwords" --project "Microsoft Edge"
```
### Full-text stopword manual acceptance
The CI cases above use the existing Playwright harness unchanged and cover policy
management, not New Container or query semantics. Unit regressions cover creation,
full-text-only accounts without vector capability, and unrelated Settings saves
with preserved read-only or indexed analysis.
For manual browser acceptance, use an approved provisioned-throughput NoSQL test
account with `EnableNoSQLFullTextSearch` and
`EnableNoSQLFullTextSearchPreviewFeatures`. Create only uniquely named test containers
and remove them afterward. Do not change account capabilities, network access, or
permissions as part of the test.
To validate Hosted Entra sign-in, run the dev server on **port 1234** and open
`https://localhost:1234/hostedExplorer.html`.
The development sign-in bridge redirects to `https://localhost:1234/redirectBridge.html`;
a server on another port cannot complete that flow. Use real sign-in, directory,
subscription, and account selection, then verify create, indexed locks, invalid
input, Save/Discard, a full browser reload, and policy preservation during an unrelated
Settings update. Do not substitute injected tokens or fabricated account capabilities.
Configuration round trips do not prove query semantics. Report search-result
failures separately from configuration acceptance.
For visual acceptance, inspect both creation and Settings in light/dark themes and
at 1440px, 768px, and 375px widths. Confirm that labels align with their fields,
inheritance text wraps without clipping, default/override descriptions are clear,
locked values remain readable without becoming editable, service-default selection
explains disabled word fields, and errors are associated with the offending word
list. These checks can use unsaved edits followed by Discard; no new container is
needed for a visual-only audit.
Check that collapsed path headers retain the path name, inheritance/index state,
and any validation warning. Space/Enter must toggle expansion without losing
values, and Delete must be a separate keyboard target. Adding a path should focus
its input; deleting should return focus to Add, which must still respond to a mouse
click. Check the empty state and a long path name at narrow width. In wide Settings,
the word lists should sit side by side; in the create panel they should stack.
Controls should not stretch to fill the available workspace: language and stopword
dropdowns and individual word-list boxes are at most 240px wide, path inputs at
most 320px, and the Settings form at most 496px. They must still shrink to fit a
narrow panel without clipping values, errors, or accessible labels.
Repeat creation and policy management with each supported language: `en-US`,
`fr-FR`, `de-DE`, `es-ES`, `it-IT`, `pt-PT`, and `pt-BR`. Check 20-word lists with
case differences, duplicates, and accented text through creation, indexed locks,
an unindexed-path save, Discard, and full page reload. Verify that persisted custom
words affect search as expected; do not treat current backend behavior as the
expected result merely because the policy was accepted.
For UI acceptance, exercise 0, 1, 5, 20, and 100 entries in **both** word lists and
both creation/Settings. Test real newline paste, CRLF, middle edits, whole-line
deletion, long and multilingual words, a late invalid entry, differing list
lengths, filter off/on, and keyboard inspection of locked values. Include all
default/path language combinations and service-default reset. A passing
serialization test or tidy one-word screenshot is not multiword UX acceptance.
Verify modern policy fields with raw ARM JSON. Older typed management clients,
including `az cosmosdb sql container show`, may omit fields unknown to their models
even when the service preserved them.
### Testing with Data Plane RBAC Authentication
By default, the tests will use key based authentication to access the database accounts. For APIs that support data plane RBAC, the
+1 -1
View File
@@ -20,7 +20,7 @@ param totalThroughputLimit int = 10000
param testAccountType string
var kind = (testAccountType == 'mongo' || testAccountType == 'mongo32') ? 'MongoDB' : 'GlobalDocumentDB'
var capabilities = (testAccountType == 'tables') ? [{name: 'EnableTable'}] : (testAccountType == 'cassandra') ? [{name: 'EnableCassandra'}] : (testAccountType == 'gremlin') ? [{name: 'EnableGremlin'}] : []
var capabilities = (testAccountType == 'tables') ? [{name: 'EnableTable'}] : (testAccountType == 'cassandra') ? [{name: 'EnableCassandra'}] : (testAccountType == 'gremlin') ? [{name: 'EnableGremlin'}] : (testAccountType == 'sql') ? [{name: 'EnableNoSQLFullTextSearchPreviewFeatures'}] : []
var serverVersion = (testAccountType == 'mongo32') ? '3.2' : (testAccountType == 'mongo') ? '6.0' : null
resource testCosmosAccount 'Microsoft.DocumentDB/databaseAccounts@2024-02-15-preview' = {
+218 -2
View File
@@ -1,6 +1,21 @@
import { expect, test } from "@playwright/test";
import { CosmosDBManagementClient } from "@azure/arm-cosmosdb";
import { expect, Page, Response, test } from "@playwright/test";
import { DataExplorer, TEST_AUTOSCALE_THROUGHPUT_RU, TestAccount, generateUniqueName } from "../fx";
import {
CommandBarButton,
DataExplorer,
generateUniqueName,
getAccountName,
getAzureCLICredentials,
ONE_MINUTE_MS,
resourceGroupName,
subscriptionId,
TEST_AUTOSCALE_THROUGHPUT_RU,
TestAccount,
} from "../fx";
// The test harness URL contains authentication tokens.
test.use({ trace: "off", video: "off" });
test("SQL database and container CRUD", async ({ page }) => {
const databaseId = generateUniqueName("db");
@@ -50,3 +65,204 @@ test("SQL database and container CRUD", async ({ page }) => {
await expect(databaseNode.element).not.toBeAttached();
});
test.describe("Full-text stopwords", () => {
test.use({ actionTimeout: 30000 });
let armClient: CosmosDBManagementClient;
let accountName: string;
let databaseId: string | undefined;
let explorer: DataExplorer;
const containerId = "stopwords";
const additionalWords = "cosmos\nCatalog\ncosmos";
const wordsToKeep = "the\nand";
const inheritanceLabel = "Inherit the container's language and stopwords";
const openPolicy = async (page: Page): Promise<DataExplorer> => {
if (!databaseId) {
throw new Error("The stopword test database has not been created.");
}
const current = await DataExplorer.open(page, TestAccount.SQL);
const containerNode = await current.waitForContainerNode(databaseId, containerId);
await containerNode.expand();
await current.frame.getByTestId(`TreeNodeContainer:${databaseId}/${containerId}/Scale & Settings`).press("Enter");
await current.frame.getByRole("tab", { name: "Container Policies", exact: true }).press("Enter");
await current.frame.getByRole("tab", { name: "Full Text Policy", exact: true }).press("Enter");
return current;
};
const savePolicy = async (): Promise<Response> => {
const save = explorer.commandBarButton(CommandBarButton.Save);
await expect(save).toBeEnabled();
const [response] = await Promise.all([
explorer.frame
.page()
.waitForResponse(
(response) =>
response.request().method() === "PUT" &&
new URL(response.url()).pathname.endsWith(`/sqlDatabases/${databaseId}/containers/${containerId}`),
{ timeout: 2 * ONE_MINUTE_MS },
),
save.click(),
]);
expect(response.ok(), `Saving the policy returned HTTP ${response.status()}`).toBe(true);
await expect(explorer.getConsoleHeaderStatus()).toContainText(`Successfully updated container ${containerId}`, {
timeout: 2 * ONE_MINUTE_MS,
});
await expect(save).toBeDisabled({ timeout: 2 * ONE_MINUTE_MS });
return response;
};
test.beforeEach(async ({ page }) => {
databaseId = undefined;
armClient = new CosmosDBManagementClient(getAzureCLICredentials(), subscriptionId);
accountName = getAccountName(TestAccount.SQL);
const account = await armClient.databaseAccounts.get(resourceGroupName, accountName);
expect(
account.capabilities?.map(({ name }) => name),
"The SQL CI account must enable EnableNoSQLFullTextSearchPreviewFeatures.",
).toContain("EnableNoSQLFullTextSearchPreviewFeatures");
const newDatabaseId = generateUniqueName("stopwords", { length: 8 });
for await (const database of armClient.sqlResources.listSqlDatabases(resourceGroupName, accountName)) {
expect(database.name).not.toBe(newDatabaseId);
}
databaseId = newDatabaseId;
await armClient.sqlResources.beginCreateUpdateSqlDatabaseAndWait(resourceGroupName, accountName, databaseId, {
resource: { id: databaseId },
options: {},
});
await armClient.sqlResources.beginCreateUpdateSqlContainerAndWait(
resourceGroupName,
accountName,
databaseId,
containerId,
{
resource: { id: containerId, partitionKey: { paths: ["/pk"], kind: "Hash" } },
options: { throughput: 400 },
},
);
explorer = await openPolicy(page);
await explorer.frame.getByRole("button", { name: "Create new full text search policy" }).click();
await explorer.frame.getByRole("button", { name: "Add full text path", exact: true }).click();
await explorer.frame.getByRole("textbox", { name: /^Path/ }).fill("/text");
await explorer.frame.getByRole("checkbox", { name: inheritanceLabel }).check();
const defaults = explorer.frame.getByRole("group", { name: "Default stopwords" });
await defaults.getByRole("combobox", { name: "Stopword list" }).click();
await explorer.frame.getByRole("option", { name: "None", exact: true }).click();
await defaults.getByRole("textbox", { name: "Additional stopwords" }).fill(additionalWords);
await defaults.getByRole("textbox", { name: "Words to keep" }).fill(wordsToKeep);
});
test.afterEach(async () => {
test.setTimeout(2 * ONE_MINUTE_MS);
if (!databaseId) {
return;
}
await armClient.sqlResources
.beginDeleteSqlDatabaseAndWait(resourceGroupName, accountName, databaseId)
.catch((error: unknown) => {
if (!error || typeof error !== "object" || !("statusCode" in error) || error.statusCode !== 404) {
throw error;
}
});
databaseId = undefined;
});
test("saves multiword defaults and an inherited path across reload", async ({ page }) => {
await savePolicy();
explorer = await openPolicy(page);
const defaults = explorer.frame.getByRole("group", { name: "Default stopwords" });
await expect(explorer.frame.getByRole("combobox", { name: /^Default language/ })).toContainText("English (US)");
await expect(defaults.getByRole("combobox", { name: "Stopword list" })).toContainText("None");
await expect(defaults.getByRole("textbox", { name: "Additional stopwords" })).toHaveValue(additionalWords);
await expect(defaults.getByRole("textbox", { name: "Words to keep" })).toHaveValue(wordsToKeep);
await expect(explorer.frame.getByRole("textbox", { name: /^Path/ })).toHaveValue("/text");
await expect(explorer.frame.getByRole("checkbox", { name: inheritanceLabel })).toBeChecked();
await expect(explorer.commandBarButton(CommandBarButton.Save)).toBeDisabled();
});
for (const label of ["Additional stopwords", "Words to keep"]) {
test(`blocks invalid entries in ${label} until corrected`, async () => {
const defaults = explorer.frame.getByRole("group", { name: "Default stopwords" });
const field = defaults.getByRole("textbox", { name: label });
const original = await field.inputValue();
await field.fill(`${original}\ninvalid phrase`);
await expect(field).toHaveAttribute("aria-invalid", "true");
await expect(defaults.getByRole("alert")).toBeVisible();
await expect(explorer.commandBarButton(CommandBarButton.Save)).toBeDisabled();
await field.fill(original);
await expect(field).not.toHaveAttribute("aria-invalid", "true");
await expect(defaults.getByRole("alert")).toHaveCount(0);
await expect(explorer.commandBarButton(CommandBarButton.Save)).toBeEnabled();
});
}
test("discards unsaved language, preset, word-list, and path changes", async ({ page }) => {
await savePolicy();
const defaults = explorer.frame.getByRole("group", { name: "Default stopwords" });
await explorer.frame.getByRole("combobox", { name: /^Default language/ }).click();
await explorer.frame.getByRole("option", { name: "French", exact: true }).click();
await defaults.getByRole("textbox", { name: "Additional stopwords" }).fill("galaxy\nnebula");
await defaults.getByRole("textbox", { name: "Words to keep" }).fill("le\nla");
await defaults.getByRole("combobox", { name: "Stopword list" }).click();
await explorer.frame.getByRole("option", { name: "Use service default (clear custom words)", exact: true }).click();
await explorer.frame.getByRole("textbox", { name: /^Path/ }).fill("/changed");
await expect(explorer.commandBarButton(CommandBarButton.Save)).toBeEnabled();
await explorer.frame.getByRole("menuitem", { name: "Discard", exact: true }).click();
await expect(defaults.getByRole("textbox", { name: "Additional stopwords" })).toHaveValue(additionalWords);
await expect(defaults.getByRole("textbox", { name: "Words to keep" })).toHaveValue(wordsToKeep);
await expect(defaults.getByRole("combobox", { name: "Stopword list" })).toContainText("None");
await expect(explorer.frame.getByRole("combobox", { name: /^Default language/ })).toContainText("English (US)");
await expect(explorer.frame.getByRole("textbox", { name: /^Path/ })).toHaveValue("/text");
await expect(explorer.commandBarButton(CommandBarButton.Save)).toBeDisabled();
explorer = await openPolicy(page);
await expect(
explorer.frame
.getByRole("group", { name: "Default stopwords" })
.getByRole("textbox", { name: "Additional stopwords" }),
).toHaveValue(additionalWords);
await expect(explorer.frame.getByRole("textbox", { name: /^Path/ })).toHaveValue("/text");
});
test("saves a path override and resets it to inherited defaults", async ({ page }) => {
await explorer.frame.getByRole("checkbox", { name: inheritanceLabel }).uncheck();
await explorer.frame.getByRole("combobox", { name: /^Language\b/ }).click();
await explorer.frame.getByRole("option", { name: "French", exact: true }).click();
const override = explorer.frame.getByRole("group", { name: "Stopwords for /text" });
await override.getByRole("combobox", { name: "Stopword list" }).click();
await explorer.frame.getByRole("option", { name: "None", exact: true }).click();
await override.getByRole("textbox", { name: "Additional stopwords" }).fill("galaxy\nnebula");
await override.getByRole("textbox", { name: "Words to keep" }).fill("le\nla");
await savePolicy();
explorer = await openPolicy(page);
const persisted = explorer.frame.getByRole("group", { name: "Stopwords for /text" });
await expect(explorer.frame.getByRole("combobox", { name: /^Language\b/ })).toContainText("French");
await expect(persisted.getByRole("textbox", { name: "Additional stopwords" })).toHaveValue("galaxy\nnebula");
await expect(persisted.getByRole("textbox", { name: "Words to keep" })).toHaveValue("le\nla");
const defaults = explorer.frame.getByRole("group", { name: "Default stopwords" });
await expect(defaults.getByRole("textbox", { name: "Additional stopwords" })).toHaveValue(additionalWords);
await expect(defaults.getByRole("textbox", { name: "Words to keep" })).toHaveValue(wordsToKeep);
await expect(persisted.getByRole("checkbox", { name: "Enable stopword filtering" })).toHaveCount(0);
await explorer.frame.getByRole("checkbox", { name: inheritanceLabel }).check();
await expect(persisted).toHaveCount(0);
const inheritedSave = await savePolicy();
expect(inheritedSave.request().postDataJSON()).toEqual(
expect.objectContaining({
properties: expect.objectContaining({
resource: expect.objectContaining({
fullTextPolicy: expect.objectContaining({ fullTextPaths: [{ path: "/text" }] }),
}),
}),
}),
);
explorer = await openPolicy(page);
await expect(explorer.frame.getByRole("checkbox", { name: inheritanceLabel })).toBeChecked();
await expect(explorer.frame.getByRole("group", { name: "Stopwords for /text" })).toHaveCount(0);
await explorer.frame.getByRole("checkbox", { name: inheritanceLabel }).uncheck();
const reset = explorer.frame.getByRole("group", { name: "Stopwords for /text" });
await expect(reset.getByRole("textbox", { name: "Additional stopwords" })).toHaveValue("");
await expect(reset.getByRole("textbox", { name: "Words to keep" })).toHaveValue("");
});
});
+4
View File
@@ -37,6 +37,10 @@
"./src/Contracts/SelfServeContracts.ts",
"./src/Contracts/SubscriptionType.ts",
"./src/Contracts/Versions.ts",
"./src/Explorer/Controls/FullTextSeach/FullTextPolicyUtils.ts",
"./src/Explorer/Controls/FullTextSeach/FullTextPolicyUtils.test.ts",
"./src/Explorer/Controls/FullTextSeach/StopwordSettings.tsx",
"./src/Explorer/Controls/FullTextSeach/StopwordSettings.test.tsx",
"./src/Explorer/Controls/SmartUi/InputUtils.ts",
"./src/Explorer/Graph/GraphExplorerComponent/ArraysByKeyCache.test.ts",
"./src/Explorer/Graph/GraphExplorerComponent/ArraysByKeyCache.ts",