diff --git a/CHANGELOG.md b/CHANGELOG.md index 5eaece1756..5466021c68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,8 @@ The types of changes are: ## [Unreleased](https://github.com/ethyca/fides/compare/2.50.0...main) +### Developer Experience +- Migrated remaining instances of Chakra's Select component to use Ant's Select component [#5502](https://github.com/ethyca/fides/pull/5502) diff --git a/clients/admin-ui/cypress/e2e/config-wizard.cy.ts b/clients/admin-ui/cypress/e2e/config-wizard.cy.ts index e933cea1aa..430aace75a 100644 --- a/clients/admin-ui/cypress/e2e/config-wizard.cy.ts +++ b/clients/admin-ui/cypress/e2e/config-wizard.cy.ts @@ -23,7 +23,7 @@ describe("Config Wizard", () => { cy.getByTestId("authenticate-aws-form"); cy.getByTestId("input-aws_access_key_id").type("fakeAccessKey"); cy.getByTestId("input-aws_secret_access_key").type("fakeSecretAccessKey"); - cy.getByTestId("input-region_name").type("us-east-1{Enter}"); + cy.getByTestId("controlled-select-region_name").type("us-east-1{Enter}"); }); it("Allows submitting the form and reviewing the results", () => { diff --git a/clients/admin-ui/cypress/e2e/connectors.cy.ts b/clients/admin-ui/cypress/e2e/connectors.cy.ts index 10f4472f83..8c6d6d7c97 100644 --- a/clients/admin-ui/cypress/e2e/connectors.cy.ts +++ b/clients/admin-ui/cypress/e2e/connectors.cy.ts @@ -163,9 +163,7 @@ describe("Connectors", () => { it("allows the user to add an email connector", () => { cy.visit("/datastore-connection/new"); - cy.getByTestId("select-dropdown-btn").click(); - cy.getByTestId("select-dropdown-list").contains("Email connectors"); - cy.getByTestId("select-dropdown-btn").click(); + cy.getByTestId("connection-type-filter").antSelect("Email connectors"); cy.getByTestId("sovrn-item").click(); cy.url().should("contain", "/new?step=2"); diff --git a/clients/admin-ui/cypress/e2e/consent-configuration.cy.ts b/clients/admin-ui/cypress/e2e/consent-configuration.cy.ts index 56838c4781..ae3f9d72fa 100644 --- a/clients/admin-ui/cypress/e2e/consent-configuration.cy.ts +++ b/clients/admin-ui/cypress/e2e/consent-configuration.cy.ts @@ -249,11 +249,11 @@ describe("Consent configuration", () => { cy.getByTestId("add-vendor-btn").click(); cy.getByTestId("input-name").type("Aniview LTD{enter}"); cy.wait("@getDictionaryDeclarations"); - cy.getSelectValueContainer( - "input-privacy_declarations.0.consent_use", + cy.getByTestId( + "controlled-select-privacy_declarations.0.consent_use", ).contains("Marketing"); - cy.getSelectValueContainer( - "input-privacy_declarations.0.data_use", + cy.getByTestId( + "controlled-select-privacy_declarations.0.data_use", ).contains("Profiling for Advertising"); ["av_*", "aniC", "2_C_*"].forEach((cookieName) => { cy.getByTestId("input-privacy_declarations.0.cookieNames").contains( @@ -262,8 +262,8 @@ describe("Consent configuration", () => { }); // Also check one that shouldn't have any cookies - cy.getSelectValueContainer( - "input-privacy_declarations.1.data_use", + cy.getByTestId( + "controlled-select-privacy_declarations.1.data_use", ).contains("Analytics for Insights"); cy.getByTestId("input-privacy_declarations.1.cookieNames").contains( "Select...", diff --git a/clients/admin-ui/cypress/e2e/custom-fields.cy.ts b/clients/admin-ui/cypress/e2e/custom-fields.cy.ts index bb19d51a33..137ddd178c 100644 --- a/clients/admin-ui/cypress/e2e/custom-fields.cy.ts +++ b/clients/admin-ui/cypress/e2e/custom-fields.cy.ts @@ -315,12 +315,12 @@ describe("Custom Fields", () => { "have.value", "Description!!", ); - cy.getSelectValueContainer("input-resource_type").contains( + cy.getByTestId("controlled-select-resource_type").contains( "taxonomy:data category", ); // Configuration - cy.getSelectValueContainer("input-field_type").contains( + cy.getByTestId("controlled-select-field_type").contains( "Single select", ); cy.getByTestId("custom-input-allow_list.allowed_values[0]").should( @@ -336,7 +336,9 @@ describe("Custom Fields", () => { it("can edit field information", () => { const newDescription = "new description"; cy.getByTestId("custom-input-description").clear().type(newDescription); - cy.selectOption("input-field_type", "Multiple select"); + cy.getByTestId("controlled-select-field_type").antSelect( + "Multiple select", + ); cy.getByTestId("save-btn").click(); cy.wait("@putCustomFieldDefinition").then((interception) => { const { body } = interception.request; @@ -399,7 +401,9 @@ describe("Custom Fields", () => { // Configuration const allowList = ["snorlax", "eevee"]; - cy.selectOption("input-field_type", "Single select"); + cy.getByTestId("controlled-select-field_type").antSelect( + "Single select", + ); allowList.forEach((item, idx) => { cy.getByTestId("add-list-value-btn").click(); cy.getByTestId(`custom-input-allow_list.allowed_values[${idx}]`).type( @@ -428,10 +432,12 @@ describe("Custom Fields", () => { // Field info cy.getByTestId("custom-input-name").type(payload.name); cy.getByTestId("custom-input-description").type(payload.description); - cy.selectOption("input-resource_type", "taxonomy:data category"); + cy.getByTestId("controlled-select-resource_type").antSelect( + "taxonomy:data category", + ); // Configuration - cy.selectOption("input-field_type", "Open Text"); + cy.getByTestId("controlled-select-field_type").antSelect("Open Text"); cy.getByTestId("save-btn").click(); cy.wait("@postCustomFieldDefinition").then((interception) => { diff --git a/clients/admin-ui/cypress/e2e/integration-management.cy.ts b/clients/admin-ui/cypress/e2e/integration-management.cy.ts index c6a58e8188..37ab892075 100644 --- a/clients/admin-ui/cypress/e2e/integration-management.cy.ts +++ b/clients/admin-ui/cypress/e2e/integration-management.cy.ts @@ -157,7 +157,9 @@ describe("Integration management for data detection & discovery", () => { parseSpecialCharSequences: false, }, ); - cy.selectOption("input-system_fides_key", "Fidesctl System"); + cy.getByTestId("controlled-select-system_fides_key").antSelect( + "Fidesctl System", + ); cy.getByTestId("save-btn").click(); cy.wait("@patchSystemConnection"); }); @@ -279,7 +281,9 @@ describe("Integration management for data detection & discovery", () => { cy.getByTestId("add-monitor-btn").click(); cy.getByTestId("add-modal-content").should("be.visible"); cy.getByTestId("input-name").type("A new monitor"); - cy.selectOption("input-execution_frequency", "Daily"); + cy.getByTestId("controlled-select-execution_frequency").antSelect( + "Daily", + ); cy.getByTestId("input-execution_start_date").type("2034-06-03T10:00"); cy.getByTestId("next-btn").click(); cy.wait("@getDatabasesPage1"); @@ -298,7 +302,9 @@ describe("Integration management for data detection & discovery", () => { cy.getByTestId("add-monitor-btn").click(); cy.getByTestId("add-modal-content").should("be.visible"); cy.getByTestId("input-name").type("A new monitor"); - cy.selectOption("input-execution_frequency", "Daily"); + cy.getByTestId("controlled-select-execution_frequency").antSelect( + "Daily", + ); cy.getByTestId("input-execution_start_date").type("2034-06-03T10:00"); cy.getByTestId("next-btn").click(); cy.wait("@getDatabasesPage1"); @@ -321,7 +327,9 @@ describe("Integration management for data detection & discovery", () => { cy.getByTestId("add-monitor-btn").click(); cy.getByTestId("add-modal-content").should("be.visible"); cy.getByTestId("input-name").type("A new monitor"); - cy.selectOption("input-execution_frequency", "Daily"); + cy.getByTestId("controlled-select-execution_frequency").antSelect( + "Daily", + ); cy.getByTestId("input-execution_start_date").type("2034-06-03T10:00"); cy.getByTestId("next-btn").click(); cy.wait("@getDatabasesPage1"); @@ -367,7 +375,7 @@ describe("Integration management for data detection & discovery", () => { it("can edit an existing monitor by clicking the table row", () => { cy.getByTestId("row-test monitor 2").click(); cy.getByTestId("input-name").should("have.value", "test monitor 2"); - cy.getSelectValueContainer("input-execution_frequency").should( + cy.getByTestId("controlled-select-execution_frequency").should( "contain", "Weekly", ); @@ -406,12 +414,11 @@ describe("Integration management for data detection & discovery", () => { cy.intercept("GET", "/api/v1/plus/discovery-monitor*", { fixture: "detection-discovery/monitors/monitor_list.json", }).as("getMonitors"); - cy.intercept("/api/v1/plus/discovery-monitor/databases", { + cy.intercept("POST", "/api/v1/plus/discovery-monitor/databases", { fixture: "empty-pagination.json", }).as("getEmptyDatabases"); cy.getByTestId("tab-Data discovery").click(); cy.wait("@getMonitors"); - cy.clock(new Date(2034, 5, 3)); }); it("skips the project/database selection step", () => { @@ -420,7 +427,9 @@ describe("Integration management for data detection & discovery", () => { }).as("putMonitor"); cy.getByTestId("add-monitor-btn").click(); cy.getByTestId("input-name").type("A new monitor"); - cy.selectOption("input-execution_frequency", "Daily"); + cy.getByTestId("controlled-select-execution_frequency").antSelect( + "Daily", + ); cy.getByTestId("input-execution_start_date").type("2034-06-03T10:00"); cy.getByTestId("next-btn").click(); cy.wait("@putMonitor"); diff --git a/clients/admin-ui/cypress/e2e/privacy-experiences.cy.ts b/clients/admin-ui/cypress/e2e/privacy-experiences.cy.ts index 1df388b341..3a6c2c6c7b 100644 --- a/clients/admin-ui/cypress/e2e/privacy-experiences.cy.ts +++ b/clients/admin-ui/cypress/e2e/privacy-experiences.cy.ts @@ -186,7 +186,9 @@ describe("Privacy experiences", () => { it("can create an experience", () => { cy.getByTestId("input-name").type("Test experience name"); - cy.selectOption("input-component", "Banner and modal"); + cy.getByTestId("controlled-select-component").antSelect( + "Banner and modal", + ); cy.getByTestId("add-privacy-notice").click(); cy.getByTestId("select-privacy-notice").antSelect(0); cy.getByTestId("add-location").click(); @@ -230,13 +232,20 @@ describe("Privacy experiences", () => { }); it("doesn't allow component type to be changed after selection", () => { - cy.selectOption("input-component", "Banner and modal"); - cy.getByTestId("input-component").find("input").should("be.disabled"); + cy.getByTestId("controlled-select-component").antSelect( + "Banner and modal", + ); + cy.getByTestId("controlled-select-component").should( + "have.class", + "ant-select-disabled", + ); cy.getByTestId("input-dismissable").should("be.visible"); }); it("doesn't show a preview for a privacy center", () => { - cy.selectOption("input-component", "Privacy center"); + cy.getByTestId("controlled-select-component").antSelect( + "Privacy center", + ); cy.getByTestId("input-dismissable").should("not.be.visible"); cy.getByTestId("no-preview-notice").contains( "Privacy center preview not available", @@ -244,7 +253,9 @@ describe("Privacy experiences", () => { }); it("doesn't show preview until privacy notice is added", () => { - cy.selectOption("input-component", "Banner and modal"); + cy.getByTestId("controlled-select-component").antSelect( + "Banner and modal", + ); cy.getByTestId("no-preview-notice").contains( "No privacy notices added", ); @@ -256,7 +267,9 @@ describe("Privacy experiences", () => { it("shows option to display privacy notices in banner and updates preview when clicked", () => { cy.getByTestId("input-show_layer1_notices").should("not.be.visible"); - cy.selectOption("input-component", "Banner and modal"); + cy.getByTestId("controlled-select-component").antSelect( + "Banner and modal", + ); cy.getByTestId("add-privacy-notice").click(); cy.getByTestId("select-privacy-notice").antSelect(0); cy.getByTestId("input-show_layer1_notices").click(); @@ -267,7 +280,9 @@ describe("Privacy experiences", () => { }); it("allows editing experience text and shows updated text in the preview", () => { - cy.selectOption("input-component", "Banner and modal"); + cy.getByTestId("controlled-select-component").antSelect( + "Banner and modal", + ); cy.getByTestId("add-privacy-notice").click(); cy.getByTestId("select-privacy-notice").antSelect(0); cy.getByTestId("edit-experience-btn").click(); @@ -288,7 +303,10 @@ describe("Privacy experiences", () => { it("populates the form and shows the preview with the existing values", () => { cy.wait("@getExperienceDetail"); - cy.getByTestId("input-component").find("input").should("be.disabled"); + cy.getByTestId("controlled-select-component").should( + "have.class", + "ant-select-disabled", + ); cy.getByTestId("input-name").should( "have.value", "Example modal experience", diff --git a/clients/admin-ui/cypress/e2e/privacy-notices.cy.ts b/clients/admin-ui/cypress/e2e/privacy-notices.cy.ts index 53950f0d7b..49eaaa27a4 100644 --- a/clients/admin-ui/cypress/e2e/privacy-notices.cy.ts +++ b/clients/admin-ui/cypress/e2e/privacy-notices.cy.ts @@ -238,7 +238,7 @@ describe("Privacy notices", () => { cy.getByTestId("input-name").should("have.value", notice.name); // consent mechanism section - cy.getSelectValueContainer("input-consent_mechanism").contains( + cy.getByTestId("controlled-select-consent_mechanism").contains( "Notice only", ); @@ -250,11 +250,11 @@ describe("Privacy notices", () => { // configuration section notice.data_uses.forEach((dataUse) => { - cy.getSelectValueContainer("input-data_uses").contains(dataUse); + cy.getByTestId("controlled-select-data_uses").contains(dataUse); }); // enforcement level - cy.getSelectValueContainer("input-enforcement_level").contains( + cy.getByTestId("controlled-select-enforcement_level").contains( "Not applicable", ); @@ -368,11 +368,13 @@ describe("Privacy notices", () => { cy.getByTestId("input-name").type(notice.name); // consent mechanism section - cy.selectOption("input-consent_mechanism", "Opt in"); + cy.getByTestId("controlled-select-consent_mechanism").antSelect("Opt in"); cy.getByTestId("input-has_gpc_flag").click(); // configuration section - cy.selectOption("input-data_uses", notice.data_uses[0]); + cy.getByTestId("controlled-select-data_uses").antSelect( + notice.data_uses[0], + ); // translations cy.getByTestId("input-translations.0.title").type("Title"); diff --git a/clients/admin-ui/cypress/e2e/privacy-requests.cy.ts b/clients/admin-ui/cypress/e2e/privacy-requests.cy.ts index 1a3e835c4e..47ad8341c5 100644 --- a/clients/admin-ui/cypress/e2e/privacy-requests.cy.ts +++ b/clients/admin-ui/cypress/e2e/privacy-requests.cy.ts @@ -274,7 +274,10 @@ describe("Privacy Requests", () => { it("shows configured fields and values", () => { cy.getByTestId("submit-request-btn").click(); cy.wait("@getPrivacyCenterConfig"); - cy.getSelectValueContainer("input-policy_key").type("a{enter}"); + + cy.getByTestId("controlled-select-policy_key").antSelect( + "Access your data", + ); cy.getByTestId("input-identity.phone").should("not.exist"); cy.getByTestId("input-identity.email").should("exist"); cy.getByTestId( @@ -292,7 +295,7 @@ describe("Privacy Requests", () => { it("can submit a privacy request", () => { cy.getByTestId("submit-request-btn").click(); cy.wait("@getPrivacyCenterConfig"); - cy.getSelectValueContainer("input-policy_key").type("a{enter}"); + cy.getByTestId("controlled-select-policy_key").type("a{enter}"); cy.getByTestId("input-identity.email").type("email@ethyca.com"); cy.getByTestId( "input-custom_privacy_request_fields.required_field.value", diff --git a/clients/admin-ui/cypress/e2e/systems-plus.cy.ts b/clients/admin-ui/cypress/e2e/systems-plus.cy.ts index 13ca61d19b..62e30278b2 100644 --- a/clients/admin-ui/cypress/e2e/systems-plus.cy.ts +++ b/clients/admin-ui/cypress/e2e/systems-plus.cy.ts @@ -51,29 +51,31 @@ describe("System management with Plus features", () => { }); it("can display the vendor list dropdown", () => { - cy.getSelectContainer("input-name"); + cy.getByTestId("vendor-name-select"); }); it("contains type ahead dictionary entries", () => { - cy.getSelectContainer("input-name").type("A"); + cy.getByTestId("vendor-name-select").find("input").type("A"); cy.get(".ant-select-item").eq(0).contains("Aniview LTD"); cy.get(".ant-select-item").eq(1).contains("Anzu Virtual Reality LTD"); }); it("can reset suggestions by clearing vendor input", () => { - cy.getSelectContainer("input-name").type("L{enter}"); + cy.getByTestId("vendor-name-select").find("input").type("L"); + cy.antSelectDropdownVisible(); + cy.getByTestId("vendor-name-select").realPress("Enter"); cy.getByTestId("input-legal_name").should("have.value", "LINE"); cy.getByTestId("clear-btn").click(); cy.getByTestId("input-legal_name").should("be.empty"); }); it("can't refresh suggestions immediately after populating", () => { - cy.getSelectContainer("input-name").type("A{enter}"); + cy.getByTestId("vendor-name-select").find("input").type("A{enter}"); cy.getByTestId("refresh-suggestions-btn").should("be.disabled"); }); it("can refresh suggestions when editing a saved system", () => { - cy.getSelectContainer("input-name").type("A{enter}"); + cy.getByTestId("vendor-name-select").find("input").type("A{enter}"); cy.fixture("systems/dictionary-system.json").then((dictSystem) => { cy.fixture("systems/system.json").then((origSystem) => { cy.intercept( @@ -99,7 +101,7 @@ describe("System management with Plus features", () => { // the form to be mistakenly marked as dirty and the "unsaved changes" // modal to pop up incorrectly when switching tabs it("can switch between tabs after populating from dictionary", () => { - cy.getSelectContainer("input-name").type("Anzu{enter}"); + cy.getByTestId("vendor-name-select").find("input").type("Anzu{enter}"); // the form fetches the system again after saving, so update the intercept with dictionary values cy.fixture("systems/dictionary-system.json").then((dictSystem) => { cy.fixture("systems/system.json").then((origSystem) => { @@ -127,13 +129,13 @@ describe("System management with Plus features", () => { }); it("locks editing for a GVL vendor when TCF is enabled", () => { - cy.getSelectContainer("input-name").type("Aniview{enter}"); + cy.getByTestId("vendor-name-select").find("input").type("Aniview{enter}"); cy.getByTestId("locked-for-GVL-notice"); cy.getByTestId("input-description").should("be.disabled"); }); it("does not allow changes to data uses when locked", () => { - cy.getSelectContainer("input-name").type("Aniview{enter}"); + cy.getByTestId("vendor-name-select").find("input").type("Aniview{enter}"); cy.getByTestId("save-btn").click(); cy.wait(["@postSystem", "@getSystem", "@getSystems"]); cy.getByTestId("tab-Data uses").click(); @@ -144,7 +146,7 @@ describe("System management with Plus features", () => { }); it("does not lock editing for a non-GVL vendor", () => { - cy.getSelectContainer("input-name").type("L{enter}"); + cy.getByTestId("vendor-name-select").find("input").type("L{enter}"); cy.getByTestId("locked-for-GVL-notice").should("not.exist"); cy.getByTestId("input-description").should("not.be.disabled"); }); @@ -181,20 +183,22 @@ describe("System management with Plus features", () => { }); it("allows changes to data uses for non-GVL vendors", () => { - cy.getSelectContainer("input-name").type("L{enter}"); + cy.getByTestId("vendor-name-select").find("input").type("L"); + cy.antSelectDropdownVisible(); + cy.getByTestId("vendor-name-select").realPress("Enter"); cy.getByTestId("save-btn").click(); cy.wait(["@postSystem", "@getSystem", "@getSystems"]); cy.getByTestId("tab-Data uses").click(); cy.getByTestId("add-btn"); cy.getByTestId("delete-btn"); cy.getByTestId("row-functional.service.improve").click(); - cy.getByTestId("input-data_categories") + cy.getByTestId("controlled-select-data_categories") .find("input") .should("not.be.disabled"); }); it("don't allow editing declaration name after creation", () => { - cy.getSelectContainer("input-name").type("L{enter}"); + cy.getByTestId("vendor-name-select").find("input").type("L{enter}"); cy.getByTestId("save-btn").click(); cy.wait(["@postSystem", "@getSystem", "@getSystems"]); cy.getByTestId("tab-Data uses").click(); @@ -203,12 +207,16 @@ describe("System management with Plus features", () => { }); it("don't allow editing data uses after creation", () => { - cy.getSelectContainer("input-name").type("L{enter}"); + cy.getByTestId("vendor-name-select").find("input").type("L"); + cy.antSelectDropdownVisible(); + cy.getByTestId("vendor-name-select").realPress("Enter"); cy.getByTestId("save-btn").click(); cy.wait(["@postSystem", "@getSystem", "@getSystems"]); cy.getByTestId("tab-Data uses").click(); cy.getByTestId("row-functional.service.improve").click(); - cy.getByTestId("input-data_use").find("input").should("be.disabled"); + cy.getByTestId("controlled-select-data_use") + .find("input") + .should("be.disabled"); }); }); @@ -254,7 +262,7 @@ describe("System management with Plus features", () => { // Should not be able to save while form is untouched cy.getByTestId("save-btn").should("be.disabled"); const testId = - "input-customFieldValues.id-custom-field-definition-pokemon-party"; + "controlled-select-customFieldValues.id-custom-field-definition-pokemon-party"; cy.getByTestId(testId).contains("Charmander"); cy.getByTestId(testId).contains("Eevee"); cy.getByTestId(testId).contains("Snorlax"); diff --git a/clients/admin-ui/cypress/e2e/taxonomy-plus.cy.ts b/clients/admin-ui/cypress/e2e/taxonomy-plus.cy.ts index 063dd1653b..9079431a47 100644 --- a/clients/admin-ui/cypress/e2e/taxonomy-plus.cy.ts +++ b/clients/admin-ui/cypress/e2e/taxonomy-plus.cy.ts @@ -129,8 +129,8 @@ describe("Taxonomy management with Plus features", () => { it("can create a multi-select custom field", () => { cy.getByTestId("create-custom-fields-form").within(() => { cy.getByTestId("custom-input-name").type("Multi-select"); - cy.selectOption("input-field_type", "Multiple select"); - cy.selectOption("input-allow_list_id", "Prime numbers"); + cy.getByTestId("input-field_type").antSelect("Multiple select"); + cy.getByTestId("input-allow_list_id").antSelect("Prime numbers"); }); cy.intercept( @@ -204,43 +204,34 @@ describe("Taxonomy management with Plus features", () => { }); const testIdSingle = - "input-customFieldValues.id-custom-field-definition-starter-pokemon"; + "controlled-select-customFieldValues.id-custom-field-definition-starter-pokemon"; const testIdMulti = - "input-customFieldValues.id-custom-field-definition-pokemon-party"; + "controlled-select-customFieldValues.id-custom-field-definition-pokemon-party"; it("initializes form fields with values returned by the API", () => { cy.getByTestId("custom-fields-list"); - cy.getSelectValueContainer(testIdSingle).contains("Squirtle"); + cy.getByTestId(testIdSingle).contains("Squirtle"); ["Charmander", "Eevee", "Snorlax"].forEach((value) => { - cy.getSelectValueContainer(testIdMulti).contains(value); + cy.getByTestId(testIdMulti).contains(value); }); }); it("allows choosing and changing selections", () => { cy.getByTestId("custom-fields-list"); - cy.clearSingleValue(testIdSingle); - cy.selectOption(testIdSingle, "Snorlax"); - cy.getSelectValueContainer(testIdSingle).contains("Snorlax"); - cy.clearSingleValue(testIdSingle); - - cy.removeMultiValue(testIdMulti, "Eevee"); - cy.removeMultiValue(testIdMulti, "Snorlax"); - - // clicking directly on the select element as we usually do hits the - // "remove" on the Charmander tag, so force it to find the dropdown - // indicator instead - cy.getByTestId(testIdMulti) - .find(".custom-select__dropdown-indicator") - .click(); - cy.getByTestId(testIdMulti) - .find(".custom-select__menu-list") - .contains("Eevee") - .click(); + cy.getByTestId(testIdSingle).antClearSelect(); + cy.getByTestId(testIdSingle).antSelect("Snorlax"); + cy.getByTestId(testIdSingle).contains("Snorlax"); + cy.getByTestId(testIdSingle).antClearSelect(); + + cy.getByTestId(testIdMulti).antRemoveSelectTag("Eevee"); + cy.getByTestId(testIdMulti).antRemoveSelectTag("Snorlax"); + + cy.getByTestId(testIdMulti).antSelect("Eevee"); ["Charmander", "Eevee"].forEach((value) => { - cy.getSelectValueContainer(testIdMulti).contains(value); + cy.getByTestId(testIdMulti).contains(value); }); cy.intercept("POST", `/api/v1/plus/custom-metadata/custom-field/bulk`, { diff --git a/clients/admin-ui/cypress/e2e/taxonomy.cy.ts b/clients/admin-ui/cypress/e2e/taxonomy.cy.ts index a5d5aae968..40e433586d 100644 --- a/clients/admin-ui/cypress/e2e/taxonomy.cy.ts +++ b/clients/admin-ui/cypress/e2e/taxonomy.cy.ts @@ -220,9 +220,9 @@ describe("Taxonomy management page", () => { "Object", ]; rightValues.forEach((v) => { - cy.getByTestId("input-rights").should("contain", v); + cy.getByTestId("controlled-select-rights").should("contain", v); }); - cy.getByTestId("input-strategy").should("contain", "INCLUDE"); + cy.getByTestId("controlled-select-strategy").should("contain", "INCLUDE"); cy.getByTestId("input-automatic_decisions_or_profiling").within(() => { cy.getByTestId("option-true").should("have.attr", "data-checked"); // For some reason Cypress can accidentally click the dropdown selector above, @@ -256,8 +256,8 @@ describe("Taxonomy management page", () => { // check an entity that has no optional fields filled in cy.getByTestId("item-Anonymous User").trigger("mouseover"); cy.getByTestId("edit-btn").click(); - cy.getByTestId("input-rights").should("contain", "Select..."); - cy.getByTestId("input-strategy").should("not.exist"); + cy.getByTestId("controlled-select-rights").should("contain", "Select..."); + cy.getByTestId("controlled-select-strategy").should("not.exist"); }); it("Can trigger an error", () => { diff --git a/clients/admin-ui/cypress/support/ant-support.ts b/clients/admin-ui/cypress/support/ant-support.ts index f9f0569b62..9414ede492 100644 --- a/clients/admin-ui/cypress/support/ant-support.ts +++ b/clients/admin-ui/cypress/support/ant-support.ts @@ -16,14 +16,28 @@ declare global { * Clear all options from an Ant Design Select component */ antClearSelect: () => void; + /** + * Remove a tag (or selected option in mode="multiple") from an Ant Design Select component + */ + antRemoveSelectTag: (option: string) => void; + /** + * Ant Desitn Select component dropdown is visible + */ + antSelectDropdownVisible: () => void; } } } Cypress.Commands.add("getAntSelectOption", (option: string | number) => typeof option === "string" - ? cy.get(`.ant-select-item-option[title="${option}"]`) - : cy.get(`.ant-select-item-option`).eq(option), + ? cy.get( + `.ant-select-dropdown:not(.ant-select-dropdown-hidden) .ant-select-item-option[title="${option}"]`, + ) + : cy + .get( + `.ant-select-dropdown:not(.ant-select-dropdown-hidden) .ant-select-item-option`, + ) + .eq(option), ); Cypress.Commands.add( @@ -41,9 +55,14 @@ Cypress.Commands.add( throw new Error("Cannot select from a disabled Ant Select component"); } if (!classes.includes("ant-select-open")) { - cy.get(subject.selector).first().click(clickOptions); + if (classes.includes("ant-select-multiple")) { + cy.get(subject.selector).first().find("input").click(); + } else { + cy.get(subject.selector).first().click(clickOptions); + } } }); + cy.antSelectDropdownVisible(); cy.getAntSelectOption(option).should("be.visible").click(clickOptions); }, ); @@ -59,4 +78,23 @@ Cypress.Commands.add( }, ); +Cypress.Commands.add( + "antRemoveSelectTag", + { + prevSubject: "element", + }, + (subject, option) => { + cy.get(subject.selector) + .find(`.ant-select-selection-item[title="${option}"]`) + .find(".ant-select-selection-item-remove") + .click(); + }, +); + +Cypress.Commands.add("antSelectDropdownVisible", () => { + cy.get(".ant-select-dropdown:not(.ant-select-dropdown-hidden)").should( + "be.visible", + ); +}); + export {}; diff --git a/clients/admin-ui/cypress/support/commands.ts b/clients/admin-ui/cypress/support/commands.ts index a86243f456..fea018b9e4 100644 --- a/clients/admin-ui/cypress/support/commands.ts +++ b/clients/admin-ui/cypress/support/commands.ts @@ -36,14 +36,6 @@ Cypress.Commands.add("login", () => { const getSelectOptionList = (selectorId: string) => cy.getByTestId(selectorId).click().find(`.custom-select__menu-list`); -/** @deprecated */ -Cypress.Commands.add("getSelectValueContainer", (selectorId) => - cy.getByTestId(selectorId).find(`.custom-select__value-container`), -); -Cypress.Commands.add("getSelectContainer", (selectorId) => - cy.getByTestId(selectorId).find(`.ant-select`), -); - Cypress.Commands.add("selectOption", (selectorId, optionText) => { getSelectOptionList(selectorId).contains(optionText).click(); }); @@ -52,7 +44,7 @@ Cypress.Commands.add( "removeMultiValue", (selectorId: string, optionText: string) => cy - .getSelectValueContainer(selectorId) + .getByTestId(selectorId) .contains(optionText) .siblings(".custom-select__multi-value__remove") .click(), @@ -130,22 +122,9 @@ declare global { */ assumeRole(role: RoleRegistryEnum): void; /** - * @deprecated only use for legacy Chakra UI Custom Select components - * Get the container of a CustomSelect - * @example cy.getSelectValueContainer("input-allow_list_id") - */ - getSelectValueContainer( - selectorId: string, - ): Chainable>; - /** - * Get the container of an Ant Select - * @example cy.getSelectContainer("input-allow_list_id") - */ - getSelectContainer(selectorId: string): Chainable>; - /** - * Selects an option from a CustomSelect component + * @deprecated Selects an option from a CustomSelect component * - * @example cy.selectOption("input-allow_list_id", "Prime numbers"); + * @example cy.getByTestId("input-allow_list_id").antSelect("Prime numbers") */ selectOption( selectorId: string, diff --git a/clients/admin-ui/package.json b/clients/admin-ui/package.json index 9530159f46..9904c8428f 100644 --- a/clients/admin-ui/package.json +++ b/clients/admin-ui/package.json @@ -35,7 +35,6 @@ "@reduxjs/toolkit": "^1.9.3", "@tanstack/react-table": "^8.10.7", "@types/jest": "^29.5.12", - "chakra-react-select": "^3.3.7", "cytoscape": "^3.30.0", "cytoscape-klay": "^3.1.4", "date-fns": "^2.29.3", diff --git a/clients/admin-ui/src/features/common/custom-fields/CustomFieldsList.tsx b/clients/admin-ui/src/features/common/custom-fields/CustomFieldsList.tsx index f00c0b1965..a51902f107 100644 --- a/clients/admin-ui/src/features/common/custom-fields/CustomFieldsList.tsx +++ b/clients/admin-ui/src/features/common/custom-fields/CustomFieldsList.tsx @@ -4,7 +4,8 @@ import { Field, FieldInputProps } from "formik"; import SystemFormInputGroup from "~/features/system/SystemFormInputGroup"; import { AllowedTypes, ResourceTypes } from "~/types/api"; -import { CustomSelect, CustomTextInput } from "../form/inputs"; +import { ControlledSelect } from "../form/ControlledSelect"; +import { CustomTextInput } from "../form/inputs"; import { useCustomFields } from "./hooks"; type CustomFieldsListProps = { @@ -81,27 +82,21 @@ export const CustomFieldsList = ({ const { options } = allowList; return ( - - {({ - field, - }: { - field: FieldInputProps; - }) => ( - - )} - + ); })} diff --git a/clients/admin-ui/src/features/common/dropdown/FilterSelect.tsx b/clients/admin-ui/src/features/common/dropdown/FilterSelect.tsx index e21969e001..6313c79ee7 100644 --- a/clients/admin-ui/src/features/common/dropdown/FilterSelect.tsx +++ b/clients/admin-ui/src/features/common/dropdown/FilterSelect.tsx @@ -10,6 +10,8 @@ export const FilterSelect = ({ maxTagCount="responsive" allowClear dropdownStyle={isMulti ? undefined : { width: "auto", minWidth: "200px" }} + className="w-auto" + data-testid="filter-select" {...props} /> ); diff --git a/clients/admin-ui/src/features/common/dropdown/MultiSelectDropdown.tsx b/clients/admin-ui/src/features/common/dropdown/MultiSelectDropdown.tsx deleted file mode 100644 index 64d4b9c66e..0000000000 --- a/clients/admin-ui/src/features/common/dropdown/MultiSelectDropdown.tsx +++ /dev/null @@ -1,152 +0,0 @@ -import { - AntButton as Button, - ArrowDownLineIcon, - Box, - HStack, - Menu, - MenuButton, - PlacementWithLogical, - Text, - Tooltip, -} from "fidesui"; -import React, { useState } from "react"; - -import MultiSelectDropdownList from "./MultiSelectDropdownList"; - -type MultiSelectDropdwonProps = { - /** - * Boolean to determine if the dropdown is to be immediately close on a user selection - */ - closeOnSelect?: boolean; - /** - * List of key/value pairs to be rendered as a checkbox list - */ - list: Map; - /** - * Parent callback event handler invoked when list of selection values have changed - */ - onChange: (values: string[]) => void; - /** - * List of key/value pairs which are marked for selection - */ - selectedList: Map; - /** - * Placeholder - */ - label: string; - /** - * Disable showing a tooltip - */ - tooltipDisabled?: boolean; - /** - * Position of the tooltip - */ - tooltipPlacement?: PlacementWithLogical; - /** - * Fixed Width of the textbox within the Menu Button component - */ - width?: string; -}; - -const MultiSelectDropdown = ({ - closeOnSelect = false, - list, - onChange, - selectedList, - label, - tooltipDisabled = false, - tooltipPlacement = "auto", - width, -}: MultiSelectDropdwonProps) => { - const defaultItems = new Map(list); - - // Hooks - const [isOpen, setIsOpen] = useState(false); - - // Listeners - const handleClose = () => { - setIsOpen(false); - }; - const handleOpen = () => { - setIsOpen(true); - }; - const handleSelection = (items: Map) => { - const temp = new Map([...items].filter(([, v]) => v === true)); - onChange([...temp.keys()]); - handleClose(); - }; - - const getMenuButtonText = () => { - if (!tooltipDisabled) { - return selectedList.size > 0 - ? [...selectedList.keys()].sort().join(", ") - : label; - } - return label; - }; - - return ( - - - {({ onClose }) => ( - <> - 0)} - placement={tooltipPlacement} - > - } - iconPosition="end" - className="hover:bg-none active:bg-none" - > - {!tooltipDisabled && ( - - {getMenuButtonText()} - - )} - {tooltipDisabled && ( - - {getMenuButtonText()} - {selectedList.size > 0 && ( - {selectedList.size} - )} - - )} - - - {isOpen && ( - { - handleSelection(items); - onClose(); - }} - /> - )} - - )} - - - ); -}; - -export default MultiSelectDropdown; diff --git a/clients/admin-ui/src/features/common/dropdown/MultiSelectDropdownList.tsx b/clients/admin-ui/src/features/common/dropdown/MultiSelectDropdownList.tsx deleted file mode 100644 index 3cca7c65e3..0000000000 --- a/clients/admin-ui/src/features/common/dropdown/MultiSelectDropdownList.tsx +++ /dev/null @@ -1,109 +0,0 @@ -import { - AntButton as Button, - Box, - Checkbox, - CheckboxGroup, - Flex, - MenuItem, - MenuList, - Spacer, -} from "fidesui"; -import React, { useState } from "react"; - -type MultiSelectDropdownListProps = { - defaultValues?: string[]; - items: Map; - onSelection: (items: Map) => void; -}; - -/** - * @param defaultValues - List of default item values - * @param items - List of key/value pair items - * @param onSelection - Event handler invoked when user item selections are applied - */ -const MultiSelectDropdownList = ({ - defaultValues, - items, - onSelection, -}: MultiSelectDropdownListProps) => { - const [pendingItems, setPendingItems] = useState(items); - - // Listeners - const handleChange = (values: string[]) => { - // Copy items - const temp = new Map(pendingItems); - - // Uncheck all items - temp.forEach((_value, key) => { - temp.set(key, false); - }); - - // Check the selected items - values.forEach((v) => { - temp.set(v, true); - }); - - setPendingItems(temp); - }; - const handleClear = () => { - setPendingItems(items); - onSelection(new Map()); - }; - const handleDone = () => { - onSelection(pendingItems); - }; - - return ( - - - - - - - {/* MenuItems are not rendered unless Menu is open */} - - - {[...items].sort().map(([key]) => ( - { - if (e.key === " ") { - e.currentTarget.getElementsByTagName("input")[0].click(); - } - if (e.key === "Enter") { - handleDone(); - } - }} - > - e.stopPropagation()} - > - {key} - - - ))} - - - - ); -}; - -export default MultiSelectDropdownList; diff --git a/clients/admin-ui/src/features/common/dropdown/MultiSelectTags.tsx b/clients/admin-ui/src/features/common/dropdown/MultiSelectTags.tsx deleted file mode 100644 index 02377f7254..0000000000 --- a/clients/admin-ui/src/features/common/dropdown/MultiSelectTags.tsx +++ /dev/null @@ -1,124 +0,0 @@ -import { - AntButton as Button, - ArrowDownLineIcon, - Box, - Menu, - MenuButton, - MenuButtonProps, - Tag, - TagCloseButton, - TagLabel, -} from "fidesui"; -import { useMemo, useState } from "react"; - -import { createSelectedMap, getKeysFromMap } from "~/features/common/utils"; - -import MultiSelectDropdownList from "./MultiSelectDropdownList"; - -interface MultiSelectTagsProps - extends Omit { - closeOnSelect?: boolean; - options: Map; - value: T[] | undefined; - placeholder?: string; - onChange: (values: T[]) => void; -} - -/** - * Dropdown menu with a list of checkboxes for multiple selection that displays the selected values in a textbox as chips - * @param closeOnSelect - Boolean to determine if the dropdown is to be immediately close on a user selection - * @param options - Map of key/value pairs to be rendered as a checkbox list, where the value is the display text - * @param onChange - Parent callback event handler invoked when list of selection values have changed - * @param placeholder - Placeholder text to be displayed when no items are selected - */ -export const MultiSelectTags = ({ - closeOnSelect = false, - options, - value, - onChange, - placeholder = "Select one or more items", - ...props -}: MultiSelectTagsProps) => { - const [isOpen, setIsOpen] = useState(false); - const list = useMemo( - () => createSelectedMap(options, value), - [options, value], - ); - const selectedList = useMemo( - () => value?.map((selectedItem) => options.get(selectedItem)!), - [options, value], - ); - - const handleClose = () => { - setIsOpen(false); - }; - const handleOpen = () => { - setIsOpen(true); - }; - - const handleSelection = (items: Map) => { - const selectedLabels = getKeysFromMap(items, [true]); - onChange(getKeysFromMap(options, selectedLabels)); - handleClose(); - }; - - const handleRemoveItem = (item: T) => { - onChange(value!.filter((selectedItem) => selectedItem !== item)); - }; - - return ( - - {({ onClose }) => ( - <> - - {value?.length - ? value.map((selectedItem) => ( - - {options.get(selectedItem)} - handleRemoveItem(selectedItem)} - /> - - )) - : placeholder} - } - className="absolute right-0 top-0 border-none !bg-transparent" - /> - - {isOpen && ( - { - handleSelection(items as Map); - onClose(); - }} - /> - )} - - )} - - ); -}; diff --git a/clients/admin-ui/src/features/common/form/ControlledSelect.tsx b/clients/admin-ui/src/features/common/form/ControlledSelect.tsx new file mode 100644 index 0000000000..73ce0a4ca7 --- /dev/null +++ b/clients/admin-ui/src/features/common/form/ControlledSelect.tsx @@ -0,0 +1,149 @@ +import type { FormLabelProps } from "fidesui"; +import { + AntFlex as Flex, + AntSelect as Select, + AntSelectProps as SelectProps, + FormControl, + Grid, + VStack, +} from "fidesui"; +import { useField } from "formik"; +import { useState } from "react"; + +import QuestionTooltip from "../QuestionTooltip"; +import { ErrorMessage, Label } from "./inputs"; + +interface ControlledSelectProps extends SelectProps { + name: string; + label?: string; + labelProps?: FormLabelProps; + tooltip?: string | null; + isRequired?: boolean; + layout?: "inline" | "stacked"; +} + +export const ControlledSelect = ({ + name, + label, + labelProps, + tooltip, + isRequired, + layout = "inline", + ...props +}: ControlledSelectProps) => { + const [field, meta, { setValue }] = useField(name); + const isInvalid = !!(meta.touched && meta.error); + const [searchValue, setSearchValue] = useState(""); + + if (!field.value && (props.mode === "tags" || props.mode === "multiple")) { + field.value = []; + } + if (props.mode === "tags" && typeof field.value === "string") { + field.value = [field.value]; + } + + // Tags mode requires a custom option, everything else should just pass along the props or undefined + const optionRender = + props.mode === "tags" + ? (option: any, info: any) => { + if (!option) { + return undefined; + } + if ( + option.value === searchValue && + !field.value.includes(searchValue) + ) { + return `Create "${searchValue}"`; + } + if (props.optionRender) { + return props.optionRender(option, info); + } + return option.label; + } + : props.optionRender || undefined; + + // this just supports the custom tag option, otherwise it's completely unnecessary + const handleSearch = (value: string) => { + setSearchValue(value); + if (props.onSearch) { + props.onSearch(value); + } + }; + + // Pass the value to the formik field + const handleChange = (newValue: any, option: any) => { + setValue(newValue); + if (props.onChange) { + props.onChange(newValue, option); + } + }; + + if (layout === "inline") { + return ( + + + {label ? ( + + ) : null} + + + + + + + ); +}; diff --git a/clients/admin-ui/src/features/common/form/inputs.tsx b/clients/admin-ui/src/features/common/form/inputs.tsx index 73b8d263ae..4ffc5a22fa 100644 --- a/clients/admin-ui/src/features/common/form/inputs.tsx +++ b/clients/admin-ui/src/features/common/form/inputs.tsx @@ -2,21 +2,9 @@ * Various common form inputs, styled specifically for Formik forms used throughout our app */ -import { - chakraComponents, - ChakraStylesConfig, - CreatableSelect, - GroupBase, - MenuPosition, - MultiValue, - OptionProps, - Select, - SelectComponentsConfig, - SingleValue, - Size, -} from "chakra-react-select"; import { AntButton as Button, + AntDefaultOptionType as DefaultOptionType, AntSwitch as Switch, Box, Checkbox, @@ -87,7 +75,6 @@ export interface CustomInputProps { // it just for the form. Therefore, we have our form components do the work of transforming // if the value they receive is undefined. export type StringField = FieldHookConfig; -type StringArrayField = FieldHookConfig; export const Label = ({ children, @@ -175,380 +162,13 @@ export const ErrorMessage = ({ ); }; -const ClearIndicator = () => null; - -export interface Option { +export interface Option extends DefaultOptionType { value: string; label: string; description?: string | null; tooltip?: string; } -const CustomOption = ({ - children, - ...props -}: OptionProps>) => ( - - - - {props.data.label} - - - {props.data.description ? ( - - {props.data.description} - - ) : null} - - -); - -export interface SelectProps { - label?: string; - labelProps?: FormLabelProps; - placeholder?: string; - tooltip?: string | null; - options?: Option[] | []; - isDisabled?: boolean; - isSearchable?: boolean; - isClearable?: boolean; - isRequired?: boolean; - size?: Size; - isMulti?: boolean; - variant?: Variant; - menuPosition?: MenuPosition; - /** - * If true, when isMulti=false, the selected value will be rendered as a block, - * similar to how the multi values are rendered - */ - singleValueBlock?: boolean; - isFormikOnChange?: boolean; - isCustomOption?: boolean; - textColor?: string; -} - -export const SELECT_STYLES: ChakraStylesConfig< - Option, - boolean, - GroupBase