mirror of
https://github.com/Azure/cosmos-explorer.git
synced 2026-10-09 19:53:32 +01:00
Add configurable full-text stopword policies (#2606)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,194 +319,350 @@ 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 (
|
||||
<Stack tokens={{ childrenGap: 4 }}>
|
||||
<Stack style={{ marginBottom: 10 }}>
|
||||
<Label styles={labelStyles}>Default language</Label>
|
||||
<Dropdown
|
||||
required={true}
|
||||
styles={dropdownStyles}
|
||||
options={getFullTextLanguageOptions(englishOnly, targetAccountOverride)}
|
||||
selectedKey={defaultLanguage}
|
||||
onChange={(_event: React.FormEvent<HTMLDivElement>, option: IDropdownOption) =>
|
||||
setDefaultLanguage(option.key as never)
|
||||
}
|
||||
></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",
|
||||
<CosmosFluentProvider className={styles.editor}>
|
||||
<Stack tokens={{ childrenGap: 4 }}>
|
||||
{readOnly && (
|
||||
<div role="status">{t(supported ? "fullTextPolicy.capabilityRequired" : "fullTextPolicy.unsupported")}</div>
|
||||
)}
|
||||
<Stack style={{ marginBottom: 10 }}>
|
||||
<Dropdown
|
||||
label={t("fullTextPolicy.defaultLanguage")}
|
||||
required={true}
|
||||
disabled={defaultsLocked}
|
||||
styles={dropdownStyles}
|
||||
options={languageOptions(defaultLanguage)}
|
||||
selectedKey={defaultLanguage}
|
||||
onChange={(_event, option) => {
|
||||
if (option && typeof option.key === "string") {
|
||||
setPolicy((current) => setFullTextDefaultLanguage(current, option.key.toString()));
|
||||
}
|
||||
}}
|
||||
></Dropdown>
|
||||
</Stack>
|
||||
{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"],
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Stack>
|
||||
<Label styles={labelStyles}>Path</Label>
|
||||
<TextField
|
||||
id={`full-text-policy-path-${index + 1}`}
|
||||
required={true}
|
||||
placeholder="/fullTextPath1"
|
||||
styles={textFieldStyles}
|
||||
onChange={(event: React.ChangeEvent<HTMLInputElement>) => onFullTextPathValueChange(index, event)}
|
||||
value={fullTextPolicy.path || ""}
|
||||
errorMessage={fullTextPolicy.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)
|
||||
}
|
||||
></Dropdown>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</CollapsibleSectionComponent>
|
||||
))}
|
||||
<DefaultButton
|
||||
id={`add-vector-policy`}
|
||||
styles={{
|
||||
root: {
|
||||
maxWidth: 170,
|
||||
fontSize: 12,
|
||||
color: "var(--colorNeutralForeground1)",
|
||||
backgroundColor: "transparent",
|
||||
borderColor: "var(--colorNeutralStroke1)",
|
||||
},
|
||||
rootHovered: {
|
||||
color: "var(--colorNeutralForeground1)",
|
||||
backgroundColor: "transparent",
|
||||
borderColor: "var(--colorNeutralForeground1)",
|
||||
},
|
||||
rootPressed: {
|
||||
color: "var(--colorNeutralForeground1)",
|
||||
backgroundColor: "transparent",
|
||||
borderColor: "var(--colorNeutralForeground1)",
|
||||
},
|
||||
rootDisabled: {
|
||||
backgroundColor: "transparent",
|
||||
},
|
||||
}}
|
||||
onClick={onAdd}
|
||||
>
|
||||
Add full text path
|
||||
</DefaultButton>
|
||||
</Stack>
|
||||
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>
|
||||
<TextField
|
||||
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, 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>
|
||||
{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>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
);
|
||||
})}
|
||||
<DefaultButton
|
||||
componentRef={addPathButton}
|
||||
id="add-full-text-policy"
|
||||
disabled={readOnly}
|
||||
styles={{
|
||||
root: {
|
||||
maxWidth: 170,
|
||||
fontSize: 12,
|
||||
color: "var(--colorNeutralForeground1)",
|
||||
backgroundColor: "transparent",
|
||||
borderColor: "var(--colorNeutralStroke1)",
|
||||
selectors: {
|
||||
":after": { pointerEvents: "none" },
|
||||
},
|
||||
},
|
||||
rootHovered: {
|
||||
color: "var(--colorNeutralForeground1)",
|
||||
backgroundColor: "transparent",
|
||||
borderColor: "var(--colorNeutralForeground1)",
|
||||
},
|
||||
rootPressed: {
|
||||
color: "var(--colorNeutralForeground1)",
|
||||
backgroundColor: "transparent",
|
||||
borderColor: "var(--colorNeutralForeground1)",
|
||||
},
|
||||
rootDisabled: {
|
||||
backgroundColor: "transparent",
|
||||
},
|
||||
}}
|
||||
onClick={() => {
|
||||
focusNewPath.current = true;
|
||||
setPolicy((current) => ({
|
||||
...current,
|
||||
fullTextPaths: [
|
||||
...current.fullTextPaths,
|
||||
current.package === "standard"
|
||||
? { path: "" }
|
||||
: { path: "", language: getFullTextDefaultLanguage(current) },
|
||||
],
|
||||
}));
|
||||
}}
|
||||
>
|
||||
{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);
|
||||
onFullTextPolicyChange(newFullTextPolicy);
|
||||
} else {
|
||||
resetShouldDiscardContainerPolicyChange();
|
||||
}
|
||||
const checkAndSendFullTextPolicyToSettings = (newFullTextPolicy: FullTextPolicy, validationPassed = true): void => {
|
||||
onFullTextPolicyValidationChange(validationPassed);
|
||||
onFullTextPolicyDirtyChange(isDirty(newFullTextPolicy, fullTextSearchPolicyBaseline));
|
||||
onFullTextPolicyChange(newFullTextPolicy);
|
||||
};
|
||||
|
||||
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,16 +1229,14 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.shouldShowVectorSearchParameters()) {
|
||||
if (!this.state.vectorPolicyValidated) {
|
||||
this.setState({ errorMessage: t(Keys.panes.addCollection.vectorPolicyError) });
|
||||
return false;
|
||||
}
|
||||
if (this.shouldShowVectorSearchParameters() && !this.state.vectorPolicyValidated) {
|
||||
this.setState({ errorMessage: t(Keys.panes.addCollection.vectorPolicyError) });
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!this.state.fullTextPolicyValidated) {
|
||||
this.setState({ errorMessage: t(Keys.panes.addCollection.fullTextSearchPolicyError) });
|
||||
return false;
|
||||
}
|
||||
if (this.shouldShowFullTextSearchParameters() && !this.state.fullTextPolicyValidated) {
|
||||
this.setState({ errorMessage: t(Keys.panes.addCollection.fullTextSearchPolicyError) });
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
+1
-1
@@ -495,7 +495,7 @@ exports[`AddCollectionPanel should render Default properly 1`] = `
|
||||
}
|
||||
>
|
||||
<FullTextPoliciesComponent
|
||||
englishOnly={true}
|
||||
allowStopwordCustomization={true}
|
||||
fullTextPolicy={
|
||||
{
|
||||
"defaultLanguage": "en-US",
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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("");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user