diff --git a/src/Explorer/Panes/AddCollectionPanel.tsx b/src/Explorer/Panes/AddCollectionPanel.tsx index d4e39a441..0256418d4 100644 --- a/src/Explorer/Panes/AddCollectionPanel.tsx +++ b/src/Explorer/Panes/AddCollectionPanel.tsx @@ -42,6 +42,7 @@ import { isVectorSearchEnabled, } from "Utils/CapabilityUtils"; import { getUpsellMessage } from "Utils/PricingUtils"; +import { ValidCosmosDbIdDescription, ValidCosmosDbIdInputPattern } from "Utils/ValidationUtils"; import { CollapsibleSectionComponent } from "../Controls/CollapsiblePanel/CollapsibleSectionComponent"; import { ThroughputInput } from "../Controls/ThroughputInput/ThroughputInput"; import "../Controls/ThroughputInput/ThroughputInput.less"; @@ -351,8 +352,8 @@ export class AddCollectionPanel extends React.Component = ({ type="text" aria-required="true" autoComplete="off" - pattern="[^/?#\\]*[^/?# \\]" - title="May not end with space nor contain characters '\' '/' '#' '?'" + pattern={ValidCosmosDbIdInputPattern.source} + title={ValidCosmosDbIdDescription} size={40} aria-label={databaseIdLabel} placeholder={databaseIdPlaceHolder} diff --git a/src/Explorer/Panes/AddDatabasePanel/__snapshots__/AddDatabasePanel.test.tsx.snap b/src/Explorer/Panes/AddDatabasePanel/__snapshots__/AddDatabasePanel.test.tsx.snap index 68a21f696..9afa9f981 100644 --- a/src/Explorer/Panes/AddDatabasePanel/__snapshots__/AddDatabasePanel.test.tsx.snap +++ b/src/Explorer/Panes/AddDatabasePanel/__snapshots__/AddDatabasePanel.test.tsx.snap @@ -39,7 +39,7 @@ exports[`AddDatabasePane Pane should render Default properly 1`] = ` data-lpignore={true} id="database-id" onChange={[Function]} - pattern="[^/?#\\\\]*[^/?# \\\\]" + pattern="[^\\/?#\\\\]*[^\\/?# \\\\]" placeholder="Type a new database id" size={40} styles={ diff --git a/src/Explorer/Panes/CassandraAddCollectionPane/CassandraAddCollectionPane.tsx b/src/Explorer/Panes/CassandraAddCollectionPane/CassandraAddCollectionPane.tsx index 9332652e9..49fe95ca5 100644 --- a/src/Explorer/Panes/CassandraAddCollectionPane/CassandraAddCollectionPane.tsx +++ b/src/Explorer/Panes/CassandraAddCollectionPane/CassandraAddCollectionPane.tsx @@ -7,6 +7,7 @@ import { Action } from "Shared/Telemetry/TelemetryConstants"; import * as TelemetryProcessor from "Shared/Telemetry/TelemetryProcessor"; import { userContext } from "UserContext"; import { isServerlessAccount } from "Utils/CapabilityUtils"; +import { ValidCosmosDbIdDescription, ValidCosmosDbIdInputPattern } from "Utils/ValidationUtils"; import { useSidePanel } from "hooks/useSidePanel"; import React, { FunctionComponent, useState } from "react"; import { ThroughputInput } from "../../Controls/ThroughputInput/ThroughputInput"; @@ -202,8 +203,8 @@ export const CassandraAddCollectionPane: FunctionComponent = ({ aria-required required autoComplete="off" - pattern="[^/?#\\]*[^/?# \\]" - title="May not end with space nor contain characters '\' '/' '#' '?'" + pattern={ValidCosmosDbIdInputPattern.source} + title={ValidCosmosDbIdDescription} placeholder={`e.g., ${getCollectionName()}1`} size={40} className="panelTextField" diff --git a/src/Explorer/Panes/__snapshots__/AddCollectionPanel.test.tsx.snap b/src/Explorer/Panes/__snapshots__/AddCollectionPanel.test.tsx.snap index 0725477cf..fd13dcd23 100644 --- a/src/Explorer/Panes/__snapshots__/AddCollectionPanel.test.tsx.snap +++ b/src/Explorer/Panes/__snapshots__/AddCollectionPanel.test.tsx.snap @@ -93,7 +93,7 @@ exports[`AddCollectionPanel should render Default properly 1`] = ` id="newDatabaseId" name="newDatabaseId" onChange={[Function]} - pattern="[^/?#\\\\]*[^/?# \\\\]" + pattern="[^\\/?#\\\\]*[^\\/?# \\\\]" placeholder="Type a new database id" required={true} size={40} @@ -178,7 +178,7 @@ exports[`AddCollectionPanel should render Default properly 1`] = ` id="collectionId" name="collectionId" onChange={[Function]} - pattern="[^/?#\\\\]*[^/?# \\\\]" + pattern="[^\\/?#\\\\]*[^\\/?# \\\\]" placeholder="e.g., Container1" required={true} size={40} diff --git a/src/Explorer/Tabs/ScriptTabBase.ts b/src/Explorer/Tabs/ScriptTabBase.ts index de2183c61..962d6d946 100644 --- a/src/Explorer/Tabs/ScriptTabBase.ts +++ b/src/Explorer/Tabs/ScriptTabBase.ts @@ -1,5 +1,6 @@ import * as ko from "knockout"; import Q from "q"; +import { IsValidCosmosDbResourceId } from "Utils/ValidationUtils"; import DiscardIcon from "../../../images/discard.svg"; import SaveIcon from "../../../images/save-cosmos.svg"; import * as Constants from "../../Common/Constants"; @@ -57,7 +58,7 @@ export default abstract class ScriptTabBase extends TabsBase implements ViewMode } this.id = editable.observable(); - this.id.validations([ScriptTabBase._isValidId]); + this.id.validations([IsValidCosmosDbResourceId]); this.editorContent = editable.observable(); this.editorContent.validations([ScriptTabBase._isNotEmpty]); @@ -262,29 +263,6 @@ export default abstract class ScriptTabBase extends TabsBase implements ViewMode this.updateNavbarWithTabsButtons(); } - private static _isValidId(id: string): boolean { - if (!id) { - return false; - } - - const invalidStartCharacters = /^[/?#\\]/; - if (invalidStartCharacters.test(id)) { - return false; - } - - const invalidMiddleCharacters = /^.+[/?#\\]/; - if (invalidMiddleCharacters.test(id)) { - return false; - } - - const invalidEndCharacters = /.*[/?#\\ ]$/; - if (invalidEndCharacters.test(id)) { - return false; - } - - return true; - } - private static _isNotEmpty(value: string): boolean { return !!value; } diff --git a/src/Explorer/Tabs/StoredProcedureTab/StoredProcedureTabComponent.tsx b/src/Explorer/Tabs/StoredProcedureTab/StoredProcedureTabComponent.tsx index 1f230b305..8b99758f9 100644 --- a/src/Explorer/Tabs/StoredProcedureTab/StoredProcedureTabComponent.tsx +++ b/src/Explorer/Tabs/StoredProcedureTab/StoredProcedureTabComponent.tsx @@ -1,6 +1,7 @@ import { Resource, StoredProcedureDefinition } from "@azure/cosmos"; import { Pivot, PivotItem } from "@fluentui/react"; import { KeyboardAction } from "KeyboardShortcuts"; +import { ValidCosmosDbIdDescription, ValidCosmosDbIdInputPattern } from "Utils/ValidationUtils"; import React from "react"; import ExecuteQueryIcon from "../../../../images/ExecuteQuery.svg"; import DiscardIcon from "../../../../images/discard.svg"; @@ -455,11 +456,12 @@ export default class StoredProcedureTabComponent extends React.Component< } public handleIdOnChange(event: React.ChangeEvent): void { + const isValidId: boolean = event.currentTarget.reportValidity(); if (this.state.saveButton.visible) { this.setState({ id: event.target.value, saveButton: { - enabled: true, + enabled: isValidId, visible: this.props.scriptTabBaseInstance.isNew(), }, discardButton: { @@ -528,8 +530,8 @@ export default class StoredProcedureTabComponent extends React.Component< className="formTree" type="text" required - pattern="[^/?#\\]*[^/?# \\]" - title="May not end with space nor contain characters '\' '/' '#' '?'" + pattern={ValidCosmosDbIdInputPattern.source} + title={ValidCosmosDbIdDescription} aria-label="Stored procedure id" placeholder="Enter the new stored procedure id" size={40} diff --git a/src/Explorer/Tabs/TriggerTabContent.tsx b/src/Explorer/Tabs/TriggerTabContent.tsx index 28e81ee38..c4186f653 100644 --- a/src/Explorer/Tabs/TriggerTabContent.tsx +++ b/src/Explorer/Tabs/TriggerTabContent.tsx @@ -1,6 +1,7 @@ import { TriggerDefinition } from "@azure/cosmos"; import { Dropdown, IDropdownOption, Label, TextField } from "@fluentui/react"; import { KeyboardAction } from "KeyboardShortcuts"; +import { ValidCosmosDbIdDescription, ValidCosmosDbIdInputPattern } from "Utils/ValidationUtils"; import React, { Component } from "react"; import DiscardIcon from "../../../images/discard.svg"; import SaveIcon from "../../../images/save-cosmos.svg"; @@ -192,29 +193,6 @@ export class TriggerTabContent extends Component, newValue?: string, ): void => { - this.saveButton.enabled = this.isValidId(newValue) && this.isNotEmpty(newValue); + const inputElement = _event.currentTarget as HTMLInputElement; + let isValidId: boolean = true; + if (inputElement) { + isValidId = inputElement.reportValidity(); + } + + this.saveButton.enabled = this.isNotEmpty(newValue) && isValidId; this.setState({ triggerId: newValue }); }; @@ -313,7 +297,8 @@ export class TriggerTabContent extends Component, newValue?: string, ): void => { - this.saveButton.enabled = this.isValidId(newValue) && this.isNotEmpty(newValue); + const inputElement = _event.currentTarget as HTMLInputElement; + let isValidId: boolean = true; + if (inputElement) { + isValidId = inputElement.reportValidity(); + } + + this.saveButton.enabled = this.isNotEmpty(newValue) && isValidId; this.setState({ udfId: newValue }); }; @@ -238,29 +245,6 @@ export default class UserDefinedFunctionTabContent extends Component< }); } - private isValidId(id: string): boolean { - if (!id) { - return false; - } - - const invalidStartCharacters = /^[/?#\\]/; - if (invalidStartCharacters.test(id)) { - return false; - } - - const invalidMiddleCharacters = /^.+[/?#\\]/; - if (invalidMiddleCharacters.test(id)) { - return false; - } - - const invalidEndCharacters = /.*[/?#\\ ]$/; - if (invalidEndCharacters.test(id)) { - return false; - } - - return true; - } - private isNotEmpty(value: string): boolean { return !!value; } @@ -284,7 +268,8 @@ export default class UserDefinedFunctionTabContent extends Component< required readOnly={!isUdfIdEditable} type="text" - pattern="[^/?#\\]*[^/?# \\]" + pattern={ValidCosmosDbIdInputPattern.source} + title={ValidCosmosDbIdDescription} placeholder="Enter the new user defined function id" size={40} value={udfId} diff --git a/src/Utils/ValidationUtils.test.ts b/src/Utils/ValidationUtils.test.ts new file mode 100644 index 000000000..6a47763af --- /dev/null +++ b/src/Utils/ValidationUtils.test.ts @@ -0,0 +1,18 @@ +import { IsValidCosmosDbResourceId } from "Utils/ValidationUtils"; + +const testCases = [ + ["validId", true], + ["forward/slash", false], + ["back\\slash", false], + ["question?mark", false], + ["hash#mark", false], + ["?invalidstart", false], + ["invalidEnd/", false], + ["space-at-end ", false], +]; + +describe("IsValidCosmosDbResourceId", () => { + test.each(testCases)("IsValidCosmosDbResourceId(%p). Expected: %p", (id: string, expected: boolean) => { + expect(IsValidCosmosDbResourceId(id)).toBe(expected); + }); +}); diff --git a/src/Utils/ValidationUtils.ts b/src/Utils/ValidationUtils.ts new file mode 100644 index 000000000..d2a691f83 --- /dev/null +++ b/src/Utils/ValidationUtils.ts @@ -0,0 +1,24 @@ +// +// Common methods and constants for validation +// + +// +// Validation of id for Cosmos DB resources: +// - Database +// - Container +// - Stored Procedure +// - User Defined Function (UDF) +// - Trigger +// +// Use these with elements +// eslint-disable-next-line no-useless-escape +export const ValidCosmosDbIdInputPattern: RegExp = /[^\/?#\\]*[^\/?# \\]/; +export const ValidCosmosDbIdDescription: string = "May not end with space nor contain characters '\\' '/' '#' '?'"; + +// For a standalone function regex, we need to wrap the previous reg expression, +// to test against the entire value. This is done implicitly by input elements. +const ValidCosmosDbIdRegex: RegExp = new RegExp(`^(?:${ValidCosmosDbIdInputPattern.source})$`); + +export function IsValidCosmosDbResourceId(id: string): boolean { + return id && ValidCosmosDbIdRegex.test(id); +}