From fd41d16385919eb27b1f43f9572c4e03741e22ea Mon Sep 17 00:00:00 2001 From: Mohamed Khelif Date: Fri, 15 Sep 2023 16:06:17 +0100 Subject: [PATCH 1/8] EVG-20835 Upgrade tooltip (#2040) --- package.json | 4 +- .../GroupedTaskStatusBadge.test.tsx | 2 +- src/gql/generated/types.ts | 14 +++++ yarn.lock | 54 +++++++++++-------- 4 files changed, 48 insertions(+), 26 deletions(-) diff --git a/package.json b/package.json index 1d3206524a..a34d3e78ef 100644 --- a/package.json +++ b/package.json @@ -84,7 +84,7 @@ "@leafygreen-ui/number-input": "1.0.4", "@leafygreen-ui/pagination": "1.0.12", "@leafygreen-ui/palette": "4.0.7", - "@leafygreen-ui/popover": "11.0.1", + "@leafygreen-ui/popover": "11.0.17", "@leafygreen-ui/radio-box-group": "12.0.1", "@leafygreen-ui/radio-group": "10.1.1", "@leafygreen-ui/search-input": "2.0.8", @@ -99,7 +99,7 @@ "@leafygreen-ui/toast": "6.1.4", "@leafygreen-ui/toggle": "10.0.1", "@leafygreen-ui/tokens": "2.1.0", - "@leafygreen-ui/tooltip": "9.1.6", + "@leafygreen-ui/tooltip": "10.0.10", "@leafygreen-ui/typography": "16.4.1", "@rjsf/core": "4.2.3", "@sentry/react": "7.56.0", diff --git a/src/components/GroupedTaskStatusBadge/GroupedTaskStatusBadge.test.tsx b/src/components/GroupedTaskStatusBadge/GroupedTaskStatusBadge.test.tsx index eab9fd0eb5..59e317231f 100644 --- a/src/components/GroupedTaskStatusBadge/GroupedTaskStatusBadge.test.tsx +++ b/src/components/GroupedTaskStatusBadge/GroupedTaskStatusBadge.test.tsx @@ -84,7 +84,7 @@ describe("groupedTaskStatusBadgeIcon", () => { await waitFor(() => { expect( screen.getByDataCy("grouped-task-status-badge-tooltip") - ).toBeInTheDocument(); + ).toBeVisible(); }); expect(screen.queryByText("30")).toBeVisible(); expect(screen.queryByText("Running")).toBeVisible(); diff --git a/src/gql/generated/types.ts b/src/gql/generated/types.ts index 59f47f7dd8..a518e5d843 100644 --- a/src/gql/generated/types.ts +++ b/src/gql/generated/types.ts @@ -258,6 +258,19 @@ export enum CommunicationMethod { Ssh = "SSH", } +export type ContainerPool = { + __typename?: "ContainerPool"; + distro: Scalars["String"]["output"]; + id: Scalars["String"]["output"]; + maxContainers: Scalars["Int"]["output"]; + port: Scalars["Int"]["output"]; +}; + +export type ContainerPoolsConfig = { + __typename?: "ContainerPoolsConfig"; + pools: Array; +}; + export type ContainerResources = { __typename?: "ContainerResources"; cpu: Scalars["Int"]["output"]; @@ -2330,6 +2343,7 @@ export type SpruceConfig = { __typename?: "SpruceConfig"; banner?: Maybe; bannerTheme?: Maybe; + containerPools?: Maybe; githubOrgs: Array; jira?: Maybe; keys: Array; diff --git a/yarn.lock b/yarn.lock index c070c3963e..e7a8247a5e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3840,17 +3840,17 @@ resolved "https://registry.yarnpkg.com/@leafygreen-ui/polymorphic/-/polymorphic-1.3.6.tgz#9df179df9176a5c1eaa1120bc22a4cc5356f3ed2" integrity sha512-ZJqrYNAAO/CLgl3vtl01jQl2xz6pvzPRMEDqOgCykEn2/vk6wZUOJJ4FVK0cbLZuzwvKixbrTgOSw4WrF19sKg== -"@leafygreen-ui/popover@11.0.1": - version "11.0.1" - resolved "https://registry.yarnpkg.com/@leafygreen-ui/popover/-/popover-11.0.1.tgz#864fb3d39cab2e98b600bd1b5bf930f3592af188" - integrity sha512-OLAjYWomhc3wL+pZnmzrbQOOLtCH0Fm0cLfK0AY3h1QVV0XGZQVDt1+soa6KKZInb7wcAtHZpDHqxWzVlwkuZg== +"@leafygreen-ui/popover@11.0.17", "@leafygreen-ui/popover@^11.0.17": + version "11.0.17" + resolved "https://registry.yarnpkg.com/@leafygreen-ui/popover/-/popover-11.0.17.tgz#27ee654712fdf5f41bf3233a9c26a1d76affd2ff" + integrity sha512-8ikJSmLTsDBpXou2lNY54ZCOVRwsb6v8L6e6Fxanb/74OElrYU38iNoUdR/inW+pzdRiUQT6qx8oaqZ7EKrS0Q== dependencies: - "@leafygreen-ui/emotion" "^4.0.3" - "@leafygreen-ui/hooks" "^7.3.3" - "@leafygreen-ui/lib" "^10.0.0" - "@leafygreen-ui/portal" "^4.0.7" - "@leafygreen-ui/tokens" "^1.4.0" - react-transition-group "^4.4.1" + "@leafygreen-ui/emotion" "^4.0.7" + "@leafygreen-ui/hooks" "^8.0.0" + "@leafygreen-ui/lib" "^11.0.0" + "@leafygreen-ui/portal" "^5.0.1" + "@leafygreen-ui/tokens" "^2.2.0" + react-transition-group "^4.4.5" "@leafygreen-ui/popover@^11.0.0", "@leafygreen-ui/popover@^11.0.12", "@leafygreen-ui/popover@^11.0.4", "@leafygreen-ui/popover@^11.0.5", "@leafygreen-ui/popover@^11.0.8", "@leafygreen-ui/popover@^11.0.9": version "11.0.12" @@ -3876,7 +3876,7 @@ "@leafygreen-ui/tokens" "^2.1.4" react-transition-group "^4.4.1" -"@leafygreen-ui/portal@^4.0.7", "@leafygreen-ui/portal@^4.0.9", "@leafygreen-ui/portal@^4.1.1", "@leafygreen-ui/portal@^4.1.2", "@leafygreen-ui/portal@^4.1.4": +"@leafygreen-ui/portal@^4.0.9", "@leafygreen-ui/portal@^4.1.1", "@leafygreen-ui/portal@^4.1.2", "@leafygreen-ui/portal@^4.1.4": version "4.1.4" resolved "https://registry.yarnpkg.com/@leafygreen-ui/portal/-/portal-4.1.4.tgz#31513ce87c8619cdfa514900cac5f4bb882d28e5" integrity sha512-gtzQno7yXGmbblHIVBWpJ6+TIFwep/PL4M6vcloCzAaV88+gSMJ9lhrBJbqYQkfd65xeEaycClDsfCpI35p/nA== @@ -3900,6 +3900,14 @@ "@leafygreen-ui/hooks" "^7.7.8" "@leafygreen-ui/lib" "^10.4.3" +"@leafygreen-ui/portal@^5.0.1": + version "5.0.1" + resolved "https://registry.yarnpkg.com/@leafygreen-ui/portal/-/portal-5.0.1.tgz#a5b6962210bca1809cd2dc67ae4c767e755a0f34" + integrity sha512-SL+Kw2088aopilmzKOTfzWE4tVKyRyClr1nnOaX+xplQ+6gu22C+LDFL4cJZkkWyP8WlqAE+2s1C631W3uHbPg== + dependencies: + "@leafygreen-ui/hooks" "^8.0.0" + "@leafygreen-ui/lib" "^11.0.0" + "@leafygreen-ui/radio-box-group@12.0.1": version "12.0.1" resolved "https://registry.yarnpkg.com/@leafygreen-ui/radio-box-group/-/radio-box-group-12.0.1.tgz#64f44ad30d99958ccc18e2303269175c4cad715d" @@ -4179,19 +4187,19 @@ dependencies: "@leafygreen-ui/palette" "^4.0.7" -"@leafygreen-ui/tooltip@9.1.6": - version "9.1.6" - resolved "https://registry.yarnpkg.com/@leafygreen-ui/tooltip/-/tooltip-9.1.6.tgz#0a750a3d043a0049b5c74bd317aa026073e7a377" - integrity sha512-ROy8EqIqMPaZH4zuU7Abq2KrqOA0i/C+kFRXqdohkIeDHlO3j0t8NlcrhbWClbz1L1QUcCkj5G9qyvOKMobaJQ== +"@leafygreen-ui/tooltip@10.0.10": + version "10.0.10" + resolved "https://registry.yarnpkg.com/@leafygreen-ui/tooltip/-/tooltip-10.0.10.tgz#bebf502c7b8ef12fdb40a2a9bdcbafa1273b410b" + integrity sha512-DdEyUJUXBXOZhIpEg2N7TIgcj4tGNBmFJiakJ2iHr9dJPWxWnwvdl+J/LkHe87XUx+3aBLNtPQff+RSAmxAUgA== dependencies: - "@leafygreen-ui/emotion" "^4.0.3" - "@leafygreen-ui/hooks" "^7.5.0" - "@leafygreen-ui/icon" "^11.12.5" - "@leafygreen-ui/lib" "^10.2.2" - "@leafygreen-ui/palette" "^4.0.0" - "@leafygreen-ui/popover" "^11.0.4" - "@leafygreen-ui/tokens" "^2.0.1" - "@leafygreen-ui/typography" "^16.1.0" + "@leafygreen-ui/emotion" "^4.0.7" + "@leafygreen-ui/hooks" "^8.0.0" + "@leafygreen-ui/icon" "^11.23.0" + "@leafygreen-ui/lib" "^11.0.0" + "@leafygreen-ui/palette" "^4.0.7" + "@leafygreen-ui/popover" "^11.0.17" + "@leafygreen-ui/tokens" "^2.2.0" + "@leafygreen-ui/typography" "^17.0.0" lodash "^4.17.21" polished "^4.2.2" From 577deb5ebbba4c3a0b8bd8aa1241c25f599b6d59 Mon Sep 17 00:00:00 2001 From: "malik.hadjri" Date: Fri, 15 Sep 2023 12:38:53 -0400 Subject: [PATCH 2/8] v3.0.137 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index a34d3e78ef..51222e38af 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "spruce", - "version": "3.0.136", + "version": "3.0.137", "private": true, "scripts": { "bootstrap-logkeeper": "./scripts/bootstrap-logkeeper.sh", From c519da04764ed00653e0879c340c37dca3fa48b1 Mon Sep 17 00:00:00 2001 From: Mohamed Khelif Date: Fri, 15 Sep 2023 18:49:06 +0100 Subject: [PATCH 3/8] EVG-20833 Update modal to latest version (#2039) --- __mocks__/focus-trap-react.js | 7 + __mocks__/tabbable.js | 16 ++ config/jest/setupTests.ts | 12 -- .../integration/version/name_change_modal.ts | 24 ++- package.json | 5 +- src/components/WelcomeModal/WelcomeModal.tsx | 1 - yarn.lock | 189 +++++++----------- 7 files changed, 111 insertions(+), 143 deletions(-) create mode 100644 __mocks__/focus-trap-react.js create mode 100644 __mocks__/tabbable.js diff --git a/__mocks__/focus-trap-react.js b/__mocks__/focus-trap-react.js new file mode 100644 index 0000000000..0069bcfea7 --- /dev/null +++ b/__mocks__/focus-trap-react.js @@ -0,0 +1,7 @@ +// Mock focus-trap-react to prevent errors in tests that use modals. focus-trap-react is a package used +// by LeafyGreen and is not a direct dependency of Spruce. +const lib = jest.requireActual("focus-trap-react"); + +lib.prototype.setupFocusTrap = () => null; + +module.exports = lib; diff --git a/__mocks__/tabbable.js b/__mocks__/tabbable.js new file mode 100644 index 0000000000..d968824eb4 --- /dev/null +++ b/__mocks__/tabbable.js @@ -0,0 +1,16 @@ +// https://github.com/focus-trap/tabbable#testing-in-jsdom +const lib = jest.requireActual("tabbable"); + +const tabbable = { + ...lib, + tabbable: (node, options) => + lib.tabbable(node, { ...options, displayCheck: "none" }), + focusable: (node, options) => + lib.focusable(node, { ...options, displayCheck: "none" }), + isFocusable: (node, options) => + lib.isFocusable(node, { ...options, displayCheck: "none" }), + isTabbable: (node, options) => + lib.isTabbable(node, { ...options, displayCheck: "none" }), +}; + +module.exports = tabbable; diff --git a/config/jest/setupTests.ts b/config/jest/setupTests.ts index 712d9679d3..ca1c3dc481 100644 --- a/config/jest/setupTests.ts +++ b/config/jest/setupTests.ts @@ -17,15 +17,3 @@ window.crypto.randomUUID = (() => { }; })(); -// Mock focus-trap-react to prevent errors in tests that use modals. focus-trap-react is a package used -// by LeafyGreen and is not a direct dependency of Spruce. -jest.mock( - "focus-trap-react", - () => { - const focusTrap = jest.requireActual( - "focus-trap-react" - ); - focusTrap.prototype.setupFocusTrap = () => null; - return focusTrap; - } -); diff --git a/cypress/integration/version/name_change_modal.ts b/cypress/integration/version/name_change_modal.ts index 974b60703a..5e4f78866a 100644 --- a/cypress/integration/version/name_change_modal.ts +++ b/cypress/integration/version/name_change_modal.ts @@ -8,14 +8,16 @@ describe("Name change modal", () => { cy.contains(originalName); cy.dataCy("name-change-modal-trigger").click(); const newName = "a different name"; - cy.get("textarea").clear().type(newName); + cy.get("textarea").clear(); + cy.get("textarea").type(newName); cy.contains("Confirm").click(); cy.get("textarea").should("not.exist"); cy.contains(newName); cy.validateToast("success", "Patch name was successfully updated.", true); // revert name change cy.dataCy("name-change-modal-trigger").click(); - cy.get("textarea").clear().type(originalName); + cy.get("textarea").clear(); + cy.get("textarea").type(originalName); cy.contains("Confirm").click(); cy.get("textarea").should("not.exist"); cy.validateToast("success", "Patch name was successfully updated.", true); @@ -25,13 +27,25 @@ describe("Name change modal", () => { it("The confirm button is disabled when the text area value is empty or greater than 300 characters", () => { cy.dataCy("name-change-modal-trigger").click(); cy.get("textarea").clear(); - cy.contains("button", "Confirm").should("be.disabled"); + cy.contains("button", "Confirm").should( + "have.attr", + "aria-disabled", + "true" + ); cy.get("textarea").type("lol"); - cy.contains("button", "Confirm").should("not.be.disabled"); + cy.contains("button", "Confirm").should( + "have.attr", + "aria-disabled", + "false" + ); const over300Chars = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; cy.get("textarea").type(over300Chars); - cy.contains("button", "Confirm").should("be.disabled"); + cy.contains("button", "Confirm").should( + "have.attr", + "aria-disabled", + "true" + ); cy.contains("Value cannot exceed 300 characters"); }); }); diff --git a/package.json b/package.json index 51222e38af..7d9a8ad062 100644 --- a/package.json +++ b/package.json @@ -68,7 +68,7 @@ "@leafygreen-ui/checkbox": "12.0.5", "@leafygreen-ui/code": "14.0.1", "@leafygreen-ui/combobox": "5.0.7", - "@leafygreen-ui/confirmation-modal": "4.0.3", + "@leafygreen-ui/confirmation-modal": "5.0.6", "@leafygreen-ui/emotion": "4.0.3", "@leafygreen-ui/expandable-card": "3.0.5", "@leafygreen-ui/guide-cue": "3.0.0", @@ -78,9 +78,8 @@ "@leafygreen-ui/interaction-ring": "7.0.2", "@leafygreen-ui/leafygreen-provider": "3.1.0", "@leafygreen-ui/loading-indicator": "2.0.5", - "@leafygreen-ui/marketing-modal": "^4.0.5", "@leafygreen-ui/menu": "20.0.1", - "@leafygreen-ui/modal": "14.0.1", + "@leafygreen-ui/modal": "16.0.1", "@leafygreen-ui/number-input": "1.0.4", "@leafygreen-ui/pagination": "1.0.12", "@leafygreen-ui/palette": "4.0.7", diff --git a/src/components/WelcomeModal/WelcomeModal.tsx b/src/components/WelcomeModal/WelcomeModal.tsx index eea557e6af..6a095dcd88 100644 --- a/src/components/WelcomeModal/WelcomeModal.tsx +++ b/src/components/WelcomeModal/WelcomeModal.tsx @@ -78,7 +78,6 @@ const WelcomeModal: React.FC = ({ z-index: ${zIndex.max_do_not_use}; `} size="large" - initialFocus=".slick-active" > {title && {title}} Date: Mon, 18 Sep 2023 09:50:30 -0400 Subject: [PATCH 4/8] EVG-20661: Fix useless Sentry error (#2041) --- src/gql/GQLWrapper.tsx | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/src/gql/GQLWrapper.tsx b/src/gql/GQLWrapper.tsx index c89ee7d686..ba3193c943 100644 --- a/src/gql/GQLWrapper.tsx +++ b/src/gql/GQLWrapper.tsx @@ -136,17 +136,11 @@ const authLink = (logout: () => void): ApolloLink => const logErrorsLink = onError(({ graphQLErrors, operation }) => { if (Array.isArray(graphQLErrors)) { graphQLErrors.forEach((gqlErr) => { - reportError( - { - message: "GraphQL Error", - name: gqlErr.message, - }, - { - gqlErr, - operationName: operation.operationName, - variables: operation.variables, - } - ).warning(); + reportError(new Error(gqlErr.message), { + gqlErr, + operationName: operation.operationName, + variables: operation.variables, + }).warning(); }); } // dont track network errors here because they are From 34da89a7df68778b9173619eed38fc2059266932 Mon Sep 17 00:00:00 2001 From: Sophie Stadler Date: Mon, 18 Sep 2023 10:50:47 -0400 Subject: [PATCH 5/8] EVG-19954: Force save when changing providers (#2028) --- .../integration/distroSettings/navigation.ts | 49 +++++++ src/pages/distroSettings/HeaderButtons.tsx | 109 ++------------- src/pages/distroSettings/SaveModal.tsx | 130 ++++++++++++++++++ src/pages/distroSettings/Tabs.tsx | 1 + .../tabs/ProviderTab/ProviderTab.tsx | 32 ++++- .../tabs/ProviderTab/UnsavedModal.tsx | 44 ++++++ .../distroSettings/tabs/ProviderTab/types.ts | 3 +- 7 files changed, 263 insertions(+), 105 deletions(-) create mode 100644 src/pages/distroSettings/SaveModal.tsx create mode 100644 src/pages/distroSettings/tabs/ProviderTab/UnsavedModal.tsx diff --git a/cypress/integration/distroSettings/navigation.ts b/cypress/integration/distroSettings/navigation.ts index a8b5a7ec3d..5b0cf7e368 100644 --- a/cypress/integration/distroSettings/navigation.ts +++ b/cypress/integration/distroSettings/navigation.ts @@ -12,4 +12,53 @@ describe("using the distro dropdown", () => { cy.location("pathname").should("not.contain", "localhost"); cy.location("pathname").should("contain", "rhel71-power8-large"); }); + + describe("warning modal", () => { + it("warns when navigating away from distro settings with unsaved changes and allows returning to distro settings", () => { + cy.getInputByLabel("Notes").type("my note"); + cy.dataCy("save-settings-button").should( + "not.have.attr", + "aria-disabled", + "true" + ); + cy.dataCy("project-health-link").click(); + cy.dataCy("navigation-warning-modal").should("be.visible"); + cy.contains("button", "Cancel").click(); + cy.dataCy("navigation-warning-modal").should("not.exist"); + cy.location("pathname").should( + "eq", + "/distro/localhost/settings/general" + ); + }); + + describe("modifying the distro provider", () => { + beforeEach(() => { + cy.visit("/distro/localhost/settings/provider"); + }); + + it("warns when navigating to another distro settings tab after the provider has changed and allows save", () => { + cy.selectLGOption("Provider", "Docker"); + cy.dataCy("save-settings-button").should( + "not.have.attr", + "aria-disabled", + "true" + ); + cy.contains("a", "Task Settings").click(); + cy.dataCy("save-modal").should("be.visible"); + cy.dataCy("provider-warning-banner").should("be.visible"); + }); + + it("shows the standard save warning modal when non-provider fields have changed", () => { + cy.getInputByLabel("User Data").type("test user data"); + cy.dataCy("save-settings-button").should( + "not.have.attr", + "aria-disabled", + "true" + ); + cy.dataCy("project-health-link").click(); + cy.dataCy("navigation-warning-modal").should("be.visible"); + cy.dataCy("provider-warning-banner").should("not.exist"); + }); + }); + }); }); diff --git a/src/pages/distroSettings/HeaderButtons.tsx b/src/pages/distroSettings/HeaderButtons.tsx index 4818fbf476..2a0b63c960 100644 --- a/src/pages/distroSettings/HeaderButtons.tsx +++ b/src/pages/distroSettings/HeaderButtons.tsx @@ -1,24 +1,9 @@ import { useState } from "react"; -import { useMutation } from "@apollo/client"; -import styled from "@emotion/styled"; import Button from "@leafygreen-ui/button"; -import { Radio, RadioGroup } from "@leafygreen-ui/radio-group"; -import { Body, BodyProps } from "@leafygreen-ui/typography"; -import pluralize from "pluralize"; -import { useDistroSettingsAnalytics } from "analytics"; -import { ConfirmationModal } from "components/ConfirmationModal"; -import { size } from "constants/tokens"; -import { useToastContext } from "context/toast"; -import { - DistroOnSaveOperation, - DistroQuery, - SaveDistroMutation, - SaveDistroMutationVariables, -} from "gql/generated/types"; -import { SAVE_DISTRO } from "gql/mutations"; +import { DistroQuery } from "gql/generated/types"; import { useDistroSettingsContext } from "./Context"; -import { formToGqlMap } from "./tabs/transformers"; -import { FormToGqlFunction, WritableDistroSettingsType } from "./tabs/types"; +import { SaveModal } from "./SaveModal"; +import { WritableDistroSettingsType } from "./tabs/types"; interface Props { distro: DistroQuery["distro"]; @@ -26,56 +11,10 @@ interface Props { } export const HeaderButtons: React.FC = ({ distro, tab }) => { - const { sendEvent } = useDistroSettingsAnalytics(); - const dispatchToast = useToastContext(); - - const { getTab, saveTab } = useDistroSettingsContext(); - const { formData, hasChanges, hasError } = getTab(tab); + const { getTab } = useDistroSettingsContext(); + const { hasChanges, hasError } = getTab(tab); const [modalOpen, setModalOpen] = useState(false); - const [onSaveOperation, setOnSaveOperation] = useState( - DistroOnSaveOperation.None - ); - - const [saveDistro] = useMutation< - SaveDistroMutation, - SaveDistroMutationVariables - >(SAVE_DISTRO, { - onCompleted({ saveDistro: { hostCount } }) { - saveTab(tab); - dispatchToast.success( - `Updated distro${ - onSaveOperation !== DistroOnSaveOperation.None - ? ` and scheduled ${hostCount} ${pluralize( - "host", - hostCount - )} to update` - : "" - }.` - ); - }, - onError(err) { - dispatchToast.error(err.message); - }, - refetchQueries: ["Distro"], - }); - - const handleSave = () => { - // Only perform the save operation is the tab is valid. - // eslint-disable-next-line no-prototype-builtins - if (formToGqlMap.hasOwnProperty(tab)) { - const formToGql: FormToGqlFunction = formToGqlMap[tab]; - const changes = formToGql(formData, distro); - saveDistro({ - variables: { - distro: changes, - onSave: onSaveOperation, - }, - }); - setModalOpen(false); - sendEvent({ name: "Save distro", section: tab }); - } - }; return ( <> @@ -87,41 +26,13 @@ export const HeaderButtons: React.FC = ({ distro, tab }) => { > Save changes on page - setModalOpen(false)} onCancel={() => setModalOpen(false)} - onConfirm={handleSave} - title="Save page" - > - - Evergreen can perform one of the following actions on save: - - - setOnSaveOperation(e.target.value as DistroOnSaveOperation) - } - value={onSaveOperation} - > - - Nothing, only new hosts will have updated distro settings applied - - - Decommission hosts of this distro - - - Restart Jasper service on running hosts of this distro - - - Reprovision running hosts of this distro - - - + tab={tab} + /> ); }; - -const StyledBody = styled(Body)` - margin-bottom: ${size.xs}; -`; diff --git a/src/pages/distroSettings/SaveModal.tsx b/src/pages/distroSettings/SaveModal.tsx new file mode 100644 index 0000000000..661abf4f21 --- /dev/null +++ b/src/pages/distroSettings/SaveModal.tsx @@ -0,0 +1,130 @@ +import { useState } from "react"; +import { useMutation } from "@apollo/client"; +import styled from "@emotion/styled"; +import { Radio, RadioGroup } from "@leafygreen-ui/radio-group"; +import { Body, BodyProps } from "@leafygreen-ui/typography"; +import pluralize from "pluralize"; +import { useDistroSettingsAnalytics } from "analytics"; +import { ConfirmationModal } from "components/ConfirmationModal"; +import { size } from "constants/tokens"; +import { useToastContext } from "context/toast"; +import { + DistroOnSaveOperation, + DistroQuery, + SaveDistroMutation, + SaveDistroMutationVariables, +} from "gql/generated/types"; +import { SAVE_DISTRO } from "gql/mutations"; +import { useDistroSettingsContext } from "./Context"; +import { formToGqlMap } from "./tabs/transformers"; +import { FormToGqlFunction, WritableDistroSettingsType } from "./tabs/types"; + +type SaveModalProps = { + banner?: React.ReactNode; + distro: DistroQuery["distro"]; + onCancel?: () => void; + onConfirm?: () => void; + open: boolean; + tab: WritableDistroSettingsType; +}; + +export const SaveModal: React.FC = ({ + banner, + distro, + onCancel, + onConfirm, + open, + tab, +}) => { + const { sendEvent } = useDistroSettingsAnalytics(); + const dispatchToast = useToastContext(); + + const { getTab, saveTab } = useDistroSettingsContext(); + const { formData } = getTab(tab); + const [onSaveOperation, setOnSaveOperation] = useState( + DistroOnSaveOperation.None + ); + + const [saveDistro] = useMutation< + SaveDistroMutation, + SaveDistroMutationVariables + >(SAVE_DISTRO, { + onCompleted({ saveDistro: { hostCount } }) { + saveTab(tab); + dispatchToast.success( + `Updated distro${ + onSaveOperation !== DistroOnSaveOperation.None + ? ` and scheduled ${hostCount} ${pluralize( + "host", + hostCount + )} to update` + : "" + }.` + ); + }, + onError(err) { + dispatchToast.error(err.message); + }, + refetchQueries: ["Distro"], + }); + + const handleSave = () => { + // Only perform the save operation if the tab is valid. + // eslint-disable-next-line no-prototype-builtins + if (formToGqlMap.hasOwnProperty(tab)) { + const formToGql: FormToGqlFunction = formToGqlMap[tab]; + const changes = formToGql(formData, distro); + saveDistro({ + variables: { + distro: changes, + onSave: onSaveOperation, + }, + }); + sendEvent({ name: "Save distro", section: tab }); + } + }; + + return ( + { + onCancel?.(); + }} + onConfirm={() => { + onConfirm?.(); + handleSave(); + }} + title="Save page" + > + {banner} + + Evergreen can perform one of the following actions on save: + + + setOnSaveOperation(e.target.value as DistroOnSaveOperation) + } + value={onSaveOperation} + > + + Nothing, only new hosts will have updated distro settings applied + + + Decommission hosts of this distro + + + Restart Jasper service on running hosts of this distro + + + Reprovision running hosts of this distro + + + + ); +}; + +const StyledBody = styled(Body)` + margin-bottom: ${size.xs}; +`; diff --git a/src/pages/distroSettings/Tabs.tsx b/src/pages/distroSettings/Tabs.tsx index 65e5a374af..51f2299a84 100644 --- a/src/pages/distroSettings/Tabs.tsx +++ b/src/pages/distroSettings/Tabs.tsx @@ -50,6 +50,7 @@ export const DistroSettingsTabs: React.FC = ({ distro }) => { path={DistroSettingsTabRoutes.Provider} element={ } diff --git a/src/pages/distroSettings/tabs/ProviderTab/ProviderTab.tsx b/src/pages/distroSettings/tabs/ProviderTab/ProviderTab.tsx index f6c1b1fcd2..0b71a23728 100644 --- a/src/pages/distroSettings/tabs/ProviderTab/ProviderTab.tsx +++ b/src/pages/distroSettings/tabs/ProviderTab/ProviderTab.tsx @@ -1,14 +1,36 @@ import { useMemo } from "react"; +import { useDistroSettingsContext } from "../../Context"; import { BaseTab } from "../BaseTab"; +import { + FormToGqlFunction, + WritableDistroSettingsTabs, + WritableDistroSettingsType, +} from "../types"; import { getFormSchema } from "./getFormSchema"; -import { TabProps } from "./types"; - -export const ProviderTab: React.FC = ({ distroData }) => { - const initialFormState = distroData; +import { ProviderFormState, TabProps } from "./types"; +import { UnsavedModal } from "./UnsavedModal"; +export const ProviderTab: React.FC = ({ distro, distroData }) => { const formSchema = useMemo(() => getFormSchema(), []); + const { getTab } = useDistroSettingsContext(); + + // @ts-expect-error - see TabState for details. + const { + formData, + initialData, + }: { + formData: ProviderFormState; + initialData: ReturnType>; + } = getTab(WritableDistroSettingsTabs.Provider); + return ( - + <> + {/* Use conditional rendering instead of the shouldBlock prop so that modifying fields other than the provider triggers the standard navigation warning modal */} + {initialData?.provider !== formData?.provider?.providerName && ( + + )} + + ); }; diff --git a/src/pages/distroSettings/tabs/ProviderTab/UnsavedModal.tsx b/src/pages/distroSettings/tabs/ProviderTab/UnsavedModal.tsx new file mode 100644 index 0000000000..9116939822 --- /dev/null +++ b/src/pages/distroSettings/tabs/ProviderTab/UnsavedModal.tsx @@ -0,0 +1,44 @@ +import styled from "@emotion/styled"; +import Banner from "@leafygreen-ui/banner"; +import { + unstable_BlockerFunction as BlockerFunction, + unstable_useBlocker as useBlocker, +} from "react-router-dom"; +import { size } from "constants/tokens"; +import { DistroQuery } from "gql/generated/types"; +import { SaveModal } from "../../SaveModal"; +import { WritableDistroSettingsTabs } from "../types"; + +type UnsavedModalProps = { + distro: DistroQuery["distro"]; + shouldBlock: boolean | BlockerFunction; +}; + +export const UnsavedModal: React.FC = ({ + distro, + shouldBlock, +}) => { + const blocker = useBlocker(shouldBlock); + + return ( + blocker.state === "blocked" && ( + + Your distro provider changes must be saved or reverted before + navigating to a new page. + + } + distro={distro} + open + onCancel={() => blocker.reset?.()} + onConfirm={() => blocker.proceed?.()} + tab={WritableDistroSettingsTabs.Provider} + /> + ) + ); +}; + +const StyledBanner = styled(Banner)` + margin-bottom: ${size.xs}; +`; diff --git a/src/pages/distroSettings/tabs/ProviderTab/types.ts b/src/pages/distroSettings/tabs/ProviderTab/types.ts index c100aead99..c13a4e68f2 100644 --- a/src/pages/distroSettings/tabs/ProviderTab/types.ts +++ b/src/pages/distroSettings/tabs/ProviderTab/types.ts @@ -1,4 +1,4 @@ -import { Provider } from "gql/generated/types"; +import { DistroQuery, Provider } from "gql/generated/types"; interface StaticProviderFormState { provider: { @@ -15,5 +15,6 @@ interface StaticProviderFormState { export type ProviderFormState = StaticProviderFormState; export type TabProps = { + distro: DistroQuery["distro"]; distroData: ProviderFormState; }; From f14f680c0042ce5f0975ab1398b7ffdbd12b5cd1 Mon Sep 17 00:00:00 2001 From: SupaJoon Date: Mon, 18 Sep 2023 11:25:13 -0400 Subject: [PATCH 6/8] EVG-20177: Show Admin button to EVG admins (#2035) --- cypress/constants/index.ts | 2 + cypress/integration/nav_bar.ts | 58 +++++++++++++++---- cypress/integration/version/action_buttons.ts | 2 +- .../unscheduled_patch/configure_patch.ts | 3 +- cypress/support/commands.ts | 17 +++++- cypress/support/e2e.ts | 6 ++ cypress/utils/graphql-test-utils.ts | 2 - cypress/utils/mockErrorResponse.ts | 3 +- src/analytics/navbar/useNavbarAnalytics.ts | 1 + src/components/Header/NavDropdown/index.tsx | 2 +- src/components/Header/UserDropdown.tsx | 16 +++-- src/constants/externalResources.ts | 2 + src/gql/generated/types.ts | 2 + src/gql/mocks/getUser.ts | 3 + src/gql/queries/get-user.graphql | 3 + 15 files changed, 98 insertions(+), 24 deletions(-) create mode 100644 cypress/constants/index.ts diff --git a/cypress/constants/index.ts b/cypress/constants/index.ts new file mode 100644 index 0000000000..b93b86707e --- /dev/null +++ b/cypress/constants/index.ts @@ -0,0 +1,2 @@ +export const EVG_BASE_URL = "http://localhost:9090"; +export const GQL_URL = `${EVG_BASE_URL}/graphql/query`; diff --git a/cypress/integration/nav_bar.ts b/cypress/integration/nav_bar.ts index c5ebb3a876..b910aa2128 100644 --- a/cypress/integration/nav_bar.ts +++ b/cypress/integration/nav_bar.ts @@ -1,3 +1,5 @@ +import { EVG_BASE_URL } from "../constants"; + const PATCH_ID = "5e4ff3abe3c3317e352062e4"; const USER_ID = "admin"; const SPRUCE_URLS = { @@ -6,9 +8,10 @@ const SPRUCE_URLS = { cli: `/preferences/cli`, }; const LEGACY_URLS = { - version: `/version/${PATCH_ID}`, - userPatches: `/patches/user/${USER_ID}`, - distros: `/distros`, + version: `${EVG_BASE_URL}/version/${PATCH_ID}`, + userPatches: `${EVG_BASE_URL}/patches/user/${USER_ID}`, + distros: `${EVG_BASE_URL}/distros`, + admin: `${EVG_BASE_URL}/admin`, }; describe("Nav Bar", () => { const projectCookie = "mci-project-cookie"; @@ -16,9 +19,11 @@ describe("Nav Bar", () => { it("Should have a nav bar linking to the proper page on the legacy UI", () => { cy.visit(SPRUCE_URLS.version); cy.dataCy("legacy-ui-link").should("exist"); - cy.dataCy("legacy-ui-link") - .should("have.attr", "href") - .and("include", LEGACY_URLS.version); + cy.dataCy("legacy-ui-link").should( + "have.attr", + "href", + LEGACY_URLS.version + ); }); it("Navigating to a different page should change the nav link to the legacy UI", () => { cy.visit(SPRUCE_URLS.version); @@ -28,9 +33,11 @@ describe("Nav Bar", () => { .and("include", LEGACY_URLS.version); cy.visit(SPRUCE_URLS.userPatches); cy.dataCy("legacy-ui-link").should("exist"); - cy.dataCy("legacy-ui-link") - .should("have.attr", "href") - .and("include", LEGACY_URLS.userPatches); + cy.dataCy("legacy-ui-link").should( + "have.attr", + "href", + LEGACY_URLS.userPatches + ); }); it("Visiting a page with no legacy equivalent should not display a nav link", () => { cy.visit(SPRUCE_URLS.cli); @@ -41,9 +48,7 @@ describe("Nav Bar", () => { cy.dataCy("legacy_route").should("not.exist"); cy.dataCy("auxiliary-dropdown-link").click(); cy.dataCy("legacy_route").should("exist"); - cy.dataCy("legacy_route") - .should("have.attr", "href") - .and("include", LEGACY_URLS.distros); + cy.dataCy("legacy_route").should("have.attr", "href", LEGACY_URLS.distros); }); it("Nav Dropdown should link to patches page of most recent project if cookie exists", () => { cy.setCookie(projectCookie, "spruce"); @@ -100,4 +105,33 @@ describe("Nav Bar", () => { ); cy.getCookie(projectCookie).should("have.property", "value", "spruce"); }); + + describe("Admin settings", () => { + it("Should not show Admin button to non-admins", () => { + const userData = { + data: { + user: { + userId: "admin", + displayName: "Evergreen Admin", + emailAddress: "admin@evergreen.com", + permissions: { + canEditAdminSettings: false, + }, + }, + }, + }; + cy.overwriteGQL("User", userData); + cy.visit(SPRUCE_URLS.version); + cy.dataCy("user-dropdown-link").click(); + cy.dataCy("admin-link").should("not.exist"); + }); + + it("Should show Admin button to admins", () => { + cy.visit(SPRUCE_URLS.version); + cy.dataCy("user-dropdown-link").click(); + cy.dataCy("admin-link") + .should("be.visible") + .should("have.attr", "href", LEGACY_URLS.admin); + }); + }); }); diff --git a/cypress/integration/version/action_buttons.ts b/cypress/integration/version/action_buttons.ts index 1750a88d40..2d3c19ed19 100644 --- a/cypress/integration/version/action_buttons.ts +++ b/cypress/integration/version/action_buttons.ts @@ -51,7 +51,7 @@ describe("Action Buttons", () => { it("Clicking 'Set Priority' button shows popconfirm with input and toast on success", () => { const priority = "99"; cy.dataCy("prioritize-patch").click(); - cy.dataCy("patch-priority-input").type(priority).type("{enter}"); + cy.dataCy("patch-priority-input").type(`${priority}{enter}`); cy.validateToast("success", priority); }); diff --git a/cypress/integration/version/unscheduled_patch/configure_patch.ts b/cypress/integration/version/unscheduled_patch/configure_patch.ts index 45e0da27e3..e8f215760a 100644 --- a/cypress/integration/version/unscheduled_patch/configure_patch.ts +++ b/cypress/integration/version/unscheduled_patch/configure_patch.ts @@ -1,4 +1,5 @@ -import { hasOperationName, GQL_URL } from "../../../utils/graphql-test-utils"; +import { GQL_URL } from "../../../constants"; +import { hasOperationName } from "../../../utils/graphql-test-utils"; import { mockErrorResponse } from "../../../utils/mockErrorResponse"; describe("Configure Patch Page", () => { diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index 83fb2affbd..3fdb8998fe 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -1,3 +1,6 @@ +import { EVG_BASE_URL, GQL_URL } from "../constants"; +import { hasOperationName } from "../utils/graphql-test-utils"; + const user = { username: "admin", password: "password", @@ -61,14 +64,14 @@ Cypress.Commands.add("getInputByLabel", (label: string) => { Cypress.Commands.add("login", () => { cy.getCookie("mci-token").then((c) => { if (!c) { - cy.request("POST", "http://localhost:9090/login", { ...user }); + cy.request("POST", `${EVG_BASE_URL}/login`, { ...user }); } }); }); /* logout */ Cypress.Commands.add("logout", () => { - cy.origin("http://localhost:9090", () => { + cy.origin(EVG_BASE_URL, () => { cy.request({ url: "/logout", followRedirect: false }); }); }); @@ -113,3 +116,13 @@ Cypress.Commands.add( }); } ); + +Cypress.Commands.add("overwriteGQL", (operationName: string, body: any) => { + cy.intercept("POST", GQL_URL, (req) => { + if (hasOperationName(req, operationName)) { + req.reply((res) => { + res.body = body; + }); + } + }); +}); diff --git a/cypress/support/e2e.ts b/cypress/support/e2e.ts index 7d3f6af7e6..7e4938f400 100644 --- a/cypress/support/e2e.ts +++ b/cypress/support/e2e.ts @@ -114,6 +114,12 @@ declare global { message?: string, shouldClose?: boolean ): void; + /** + * Custom command to overwrite a GQL response + * @param operationName - The operation name of the query + * @param body - The replacement response body + */ + overwriteGQL(operationName: string, body: any); } } } diff --git a/cypress/utils/graphql-test-utils.ts b/cypress/utils/graphql-test-utils.ts index 062d85141f..8c7f60ae20 100644 --- a/cypress/utils/graphql-test-utils.ts +++ b/cypress/utils/graphql-test-utils.ts @@ -30,5 +30,3 @@ export const aliasMutation = ( req.alias = `gql${operationName}Mutation`; } }; - -export const GQL_URL = "http://localhost:9090/graphql/query"; diff --git a/cypress/utils/mockErrorResponse.ts b/cypress/utils/mockErrorResponse.ts index e3bb2d1c20..3ce16a730e 100644 --- a/cypress/utils/mockErrorResponse.ts +++ b/cypress/utils/mockErrorResponse.ts @@ -1,4 +1,5 @@ -import { GQL_URL, hasOperationName } from "./graphql-test-utils"; +import { hasOperationName } from "./graphql-test-utils"; +import { GQL_URL } from "../constants"; interface Args { errorMessage: string; diff --git a/src/analytics/navbar/useNavbarAnalytics.ts b/src/analytics/navbar/useNavbarAnalytics.ts index 46db8bdc8a..71c5c25841 100644 --- a/src/analytics/navbar/useNavbarAnalytics.ts +++ b/src/analytics/navbar/useNavbarAnalytics.ts @@ -1,6 +1,7 @@ import { useAnalyticsRoot } from "analytics/useAnalyticsRoot"; type Action = + | { name: "Click Admin Link" } | { name: "Click Legacy UI Link" } | { name: "Click Logo Link" } | { name: "Click Waterfall Link" } diff --git a/src/components/Header/NavDropdown/index.tsx b/src/components/Header/NavDropdown/index.tsx index b9fe0156ec..9c38ff02ee 100644 --- a/src/components/Header/NavDropdown/index.tsx +++ b/src/components/Header/NavDropdown/index.tsx @@ -12,7 +12,7 @@ const NavDropdownMenuIcon: React.FC<{ open: boolean }> = ({ open }) => ( ); -interface MenuItemType { +export interface MenuItemType { "data-cy"?: string; text: string; href?: string; diff --git a/src/components/Header/UserDropdown.tsx b/src/components/Header/UserDropdown.tsx index eec1b6cd99..aac144d773 100644 --- a/src/components/Header/UserDropdown.tsx +++ b/src/components/Header/UserDropdown.tsx @@ -1,20 +1,21 @@ import { useQuery } from "@apollo/client"; import { useNavbarAnalytics } from "analytics"; +import { adminSettingsURL } from "constants/externalResources"; import { PreferencesTabRoutes, getPreferencesRoute } from "constants/routes"; import { useAuthDispatchContext } from "context/auth"; import { UserQuery } from "gql/generated/types"; import { GET_USER } from "gql/queries"; -import { NavDropdown } from "./NavDropdown"; +import { MenuItemType, NavDropdown } from "./NavDropdown"; export const UserDropdown = () => { const { data } = useQuery(GET_USER); const { user } = data || {}; - const { displayName } = user || {}; + const { displayName, permissions } = user || {}; const { logoutAndRedirect } = useAuthDispatchContext(); const { sendEvent } = useNavbarAnalytics(); - const menuItems = [ + const menuItems: MenuItemType[] = [ { text: "Preferences", to: getPreferencesRoute(PreferencesTabRoutes.Profile), @@ -31,7 +32,14 @@ export const UserDropdown = () => { onClick: () => logoutAndRedirect(), }, ]; - + if (permissions?.canEditAdminSettings) { + menuItems.splice(1, 0, { + "data-cy": "admin-link", + text: "Admin", + href: adminSettingsURL, + onClick: () => sendEvent({ name: "Click Admin Link" }), + }); + } return ( = { userId: "admin", displayName: "Evergreen Admin", emailAddress: "admin@evergreen.com", + permissions: { + canEditAdminSettings: true, + }, }, }, }, diff --git a/src/gql/queries/get-user.graphql b/src/gql/queries/get-user.graphql index e0ee7e3034..33d97d438b 100644 --- a/src/gql/queries/get-user.graphql +++ b/src/gql/queries/get-user.graphql @@ -2,6 +2,9 @@ query User { user { displayName emailAddress + permissions { + canEditAdminSettings + } userId } } From 7cb16221bdd257fa751326bd1b93c44fcf63af49 Mon Sep 17 00:00:00 2001 From: SupaJoon Date: Mon, 18 Sep 2023 11:27:09 -0400 Subject: [PATCH 7/8] EVG-20677 & EVG-20868: Make type.ts validation more robust (#2038) --- codegen.ts | 27 ++- lint-staged.config.js | 2 +- package.json | 2 +- .../check-schema-and-codegen/index.test.ts | 70 ++++++++ scripts/check-schema-and-codegen/index.ts | 45 +++++ scripts/check-schema-and-codegen/script.ts | 3 + scripts/check-schema-and-codegen/utils.ts | 73 ++++++++ .../diffTypes.test.ts | 94 ---------- .../diffTypes.ts | 49 ------ .../diff-local-schema-with-remote/index.ts | 3 - .../diff-local-schema-with-remote/utils.ts | 163 ------------------ 11 files changed, 206 insertions(+), 325 deletions(-) create mode 100644 scripts/check-schema-and-codegen/index.test.ts create mode 100644 scripts/check-schema-and-codegen/index.ts create mode 100644 scripts/check-schema-and-codegen/script.ts create mode 100644 scripts/check-schema-and-codegen/utils.ts delete mode 100644 scripts/diff-local-schema-with-remote/diffTypes.test.ts delete mode 100644 scripts/diff-local-schema-with-remote/diffTypes.ts delete mode 100644 scripts/diff-local-schema-with-remote/index.ts delete mode 100644 scripts/diff-local-schema-with-remote/utils.ts diff --git a/codegen.ts b/codegen.ts index 1280b80b39..650679d02e 100644 --- a/codegen.ts +++ b/codegen.ts @@ -3,35 +3,35 @@ import path from "path"; export const getConfig = ({ generatedFileName, - schema, + silent, }: { - schema: string; generatedFileName: string; -}): CodegenConfig => ({ - schema, +} & Pick): CodegenConfig => ({ documents: ["./src/**/*.ts", "./src/**/*.graphql", "./src/**/*.gql"].map( (d) => path.resolve(__dirname, d) ), - hooks: { - afterAllFileWrite: [ - `${path.resolve(__dirname, "./node_modules/.bin/prettier")} --write`, - ], - }, - overwrite: true, generates: { [generatedFileName]: { - plugins: ["typescript", "typescript-operations"], config: { - preResolveTypes: true, arrayInputCoercion: false, + preResolveTypes: true, scalars: { + Duration: "number", StringMap: "{ [key: string]: any }", Time: "Date", - Duration: "number", }, }, + plugins: ["typescript", "typescript-operations"], }, }, + hooks: { + afterAllFileWrite: [ + `${path.resolve(__dirname, "./node_modules/.bin/prettier")} --write`, + ], + }, + overwrite: true, + schema: "sdlschema/**/*.graphql", + silent, }); export const generatedFileName = path.resolve( @@ -40,6 +40,5 @@ export const generatedFileName = path.resolve( ); export default getConfig({ - schema: "sdlschema/**/*.graphql", generatedFileName, }); diff --git a/lint-staged.config.js b/lint-staged.config.js index 43a6bf8cb8..c6d4d071ae 100644 --- a/lint-staged.config.js +++ b/lint-staged.config.js @@ -7,5 +7,5 @@ module.exports = { "yarn prettier --parser graphql", ], // For GraphQL files, run eslint and prettier "*.{ts,tsx}": () => "tsc -p tsconfig.json --noEmit", // For TypeScript files, run tsc - "*": () => "yarn diff-schema", + "*": () => "yarn check-schema-and-codegen", }; diff --git a/package.json b/package.json index 7d9a8ad062..58ea007dfa 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "deploy:prod": "env-cmd -e production ts-node scripts/deploy/run-deploy", "deploy:do-not-use": ". ./scripts/deploy/deploy.sh", "dev": "env-cmd -e devLocal -r .env-cmdrc.local.json yarn start", - "diff-schema": "ts-node scripts/diff-local-schema-with-remote/index.ts", + "check-schema-and-codegen": "ts-node scripts/check-schema-and-codegen/script.ts", "eslint:fix": "yarn eslint:strict --fix", "eslint:staged": "STRICT=1 eslint", "eslint:strict": "STRICT=1 eslint '*.{js,ts,tsx}' 'src/**/*.ts?(x)' 'scripts/**/*.js' 'cypress/**/*.ts' 'src/gql/**/*.graphql'", diff --git a/scripts/check-schema-and-codegen/index.test.ts b/scripts/check-schema-and-codegen/index.test.ts new file mode 100644 index 0000000000..d5b692bb1f --- /dev/null +++ b/scripts/check-schema-and-codegen/index.test.ts @@ -0,0 +1,70 @@ +import fs from "fs"; +import { checkSchemaAndCodegenCore } from "."; +import { checkIsAncestor, getLatestCommitFromRemote } from "./utils"; + +jest.mock("fs", () => ({ + readFileSync: jest.fn().mockReturnValue(Buffer.from("file-contents")), +})); +jest.mock("path", () => ({ + resolve: jest.fn().mockReturnValue("{path.resolve()}"), +})); +jest.mock("./utils.ts", () => ({ + canResolveDNS: jest.fn(), + getLatestCommitFromRemote: jest.fn(), + checkIsAncestor: jest.fn(), + generateTypes: jest.fn(), +})); + +describe("checkSchemaAndCodegen", () => { + let consoleErrorSpy; + beforeEach(() => { + consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {}); + (checkIsAncestor as jest.Mock).mockResolvedValue(true); + (getLatestCommitFromRemote as jest.Mock).mockResolvedValue( + "{getLatestCommitFromRemote()}" + ); + }); + + it("returns 0 when offline", async () => { + (getLatestCommitFromRemote as jest.Mock).mockRejectedValueOnce( + new Error("TypeError: fetch failed") + ); + await expect(checkSchemaAndCodegenCore()).resolves.toBe(0); + expect(consoleErrorSpy).toHaveBeenCalledWith( + "An error occured during GQL types validation: Error: TypeError: fetch failed" + ); + }); + + it("returns 1 when checkIsAncestor is false and the files are the same", async () => { + (checkIsAncestor as jest.Mock).mockResolvedValue(false); + await expect(checkSchemaAndCodegenCore()).resolves.toBe(1); + expect(consoleErrorSpy).toHaveBeenCalledWith( + "GQL types validation failed: Your local Evergreen code is missing commit {getLatestCommitFromRemote()}. Pull Evergreen and run 'yarn codegen'." + ); + }); + + it("returns 1 when checkIsAncestor is false and the files are different", async () => { + (checkIsAncestor as jest.Mock).mockResolvedValue(false); + (fs.readFileSync as jest.Mock) + .mockReturnValueOnce(Buffer.from("content1")) + .mockReturnValueOnce(Buffer.from("content2")); + await expect(checkSchemaAndCodegenCore()).resolves.toBe(1); + expect(consoleErrorSpy).toHaveBeenCalledWith( + "GQL types validation failed: Your local Evergreen code is missing commit {getLatestCommitFromRemote()}. Pull Evergreen and run 'yarn codegen'." + ); + }); + + it("returns 0 when checkIsAncestor is true and the files are the same", async () => { + await expect(checkSchemaAndCodegenCore()).resolves.toBe(0); + }); + + it("returns 1 when checkIsAncestor returns true and the files are different", async () => { + (fs.readFileSync as jest.Mock) + .mockReturnValueOnce(Buffer.from("content1")) + .mockReturnValueOnce(Buffer.from("content2")); + await expect(checkSchemaAndCodegenCore()).resolves.toBe(1); + expect(consoleErrorSpy).toHaveBeenCalledWith( + "GQL types validation failed: Your GQL types file ({path.resolve()}) is outdated. Run 'yarn codegen'." + ); + }); +}); diff --git a/scripts/check-schema-and-codegen/index.ts b/scripts/check-schema-and-codegen/index.ts new file mode 100644 index 0000000000..11ca1d3bfa --- /dev/null +++ b/scripts/check-schema-and-codegen/index.ts @@ -0,0 +1,45 @@ +import fs from "fs"; +import process from "process"; +import { generatedFileName as existingTypesFileName } from "../../codegen"; +import { + checkIsAncestor, + generateTypes, + getLatestCommitFromRemote, +} from "./utils"; + +const failCopy = "GQL types validation failed:"; + +/** + * An async function that returns 1 if the local types file is outdated and 0 otherwise. + * @returns Promise + */ +export const checkSchemaAndCodegenCore = async (): Promise => { + try { + const commit = await getLatestCommitFromRemote(); + const hasLatestCommit = await checkIsAncestor(commit); + if (!hasLatestCommit) { + console.error( + `${failCopy} Your local Evergreen code is missing commit ${commit}. Pull Evergreen and run 'yarn codegen'.` + ); + return 1; + } + // Finally check to see if 'yarn codegen' was ran. + const filenames = [await generateTypes(), existingTypesFileName]; + const [file1, file2] = filenames.map((filename) => + fs.readFileSync(filename) + ); + if (!file1.equals(file2)) { + console.error( + `${failCopy} Your GQL types file (${existingTypesFileName}) is outdated. Run 'yarn codegen'.` + ); + return 1; + } + } catch (error) { + console.error(`An error occured during GQL types validation: ${error}`); + } + return 0; +}; + +export const checkSchemaAndCodegen = async () => { + process.exit(await checkSchemaAndCodegenCore()); +}; diff --git a/scripts/check-schema-and-codegen/script.ts b/scripts/check-schema-and-codegen/script.ts new file mode 100644 index 0000000000..5dc20421f7 --- /dev/null +++ b/scripts/check-schema-and-codegen/script.ts @@ -0,0 +1,3 @@ +import { checkSchemaAndCodegen } from "."; + +checkSchemaAndCodegen(); diff --git a/scripts/check-schema-and-codegen/utils.ts b/scripts/check-schema-and-codegen/utils.ts new file mode 100644 index 0000000000..b23ffb984d --- /dev/null +++ b/scripts/check-schema-and-codegen/utils.ts @@ -0,0 +1,73 @@ +import dns from "dns"; +import fs from "fs"; +import os from "os"; +import { generate } from "@graphql-codegen/cli"; +import { execSync } from "child_process"; +import process from "process"; +import { getConfig } from "../../codegen"; + +const GITHUB_API = "https://api.github.com"; +const GQL_DIR = "graphql/schema"; +const LOCAL_SCHEMA = "sdlschema"; +const REPO = "/repos/evergreen-ci/evergreen"; + +/** + * Get the latest commit that was made to the GQL folder of the remote Evergreen repository. + * @returns A Promise that resolves to the SHA of the latest commit. + * @throws {Error} When failed to fetch commits. + */ +export const getLatestCommitFromRemote = async (): Promise => { + const url = `${GITHUB_API}${REPO}/commits?path=${GQL_DIR}&sha=main`; + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to fetch ${url}. Status: ${response.status}`); + } + + const commits = await response.json(); + + if (commits.length > 0) { + return commits[0].sha; + } + throw new Error(`No commits found for this path: ${url}`); +}; + +/** + * Check if the local Evergreen repo contains a given commit. + * @param commit The commit string that will be checked to see if it exists in the Evergreen repo. + * @returns A Promise that resolves to true if local repo contains the given commit, and false otherwise. + * @throws {Error} When an error occurs while executing the command. + */ +export const checkIsAncestor = async (commit: string): Promise => { + const localSchemaSymlink = fs.readlinkSync(LOCAL_SCHEMA); + const originalDir = process.cwd(); + try { + process.chdir(localSchemaSymlink); + execSync(`git merge-base --is-ancestor ${commit} HEAD`); + process.chdir(originalDir); + return true; + } catch (error) { + process.chdir(originalDir); + // Error status 1 and 128 means that the commit is not an anecestor and the user must fetch. + // Error code docs: https://www.git-scm.com/docs/api-error-handling/ + if (error.status === 1 || error.status === 128) { + return false; + } + throw new Error(`Error checking ancestor: ${error.message}`); + } +}; + +/** + * Generate types based on sdlschema. + * @returns A Promise that resolves to the path of the generated file. + */ +export const generateTypes = async (): Promise => { + const generatedFileName = `${os.tmpdir()}/types.ts`; + await generate( + getConfig({ + generatedFileName, + silent: true, + }), + true + ); + return generatedFileName; +}; diff --git a/scripts/diff-local-schema-with-remote/diffTypes.test.ts b/scripts/diff-local-schema-with-remote/diffTypes.test.ts deleted file mode 100644 index 3511b64a46..0000000000 --- a/scripts/diff-local-schema-with-remote/diffTypes.test.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { diffTypes } from "./diffTypes"; -import fs from "fs"; -import process from "process"; -import { canResolveDNS, checkIsAncestor } from "./utils"; - -jest.mock("fs", () => ({ - existsSync: jest.fn().mockReturnValue(true), - readFileSync: jest.fn().mockReturnValue(Buffer.from("file-contents")), -})); -jest.mock("path"); -jest.mock("./utils.ts", () => ({ - canResolveDNS: jest.fn().mockResolvedValue(true), - getRemoteLatestCommitSha: jest.fn().mockResolvedValue("mocked-sha"), - checkIsAncestor: jest.fn().mockResolvedValue(true), - downloadAndSaveFile: jest.fn().mockResolvedValue(undefined), - fetchFiles: jest.fn().mockResolvedValue(undefined), - downloadAndGenerate: jest.fn().mockResolvedValue("mocked-path/types.ts"), -})); - -describe("diffTypes", () => { - let exitSpy; - let consoleInfoSpy; - let consoleErrorSpy; - beforeEach(() => { - exitSpy = jest.spyOn(process, "exit").mockImplementation(() => {}); - consoleInfoSpy = jest.spyOn(console, "info").mockImplementation(() => {}); - consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {}); - }); - - afterEach(() => { - jest.resetAllMocks(); - }); - - it("exit with 0 when internet is unavailable", async () => { - (canResolveDNS as jest.Mock).mockResolvedValue(false); - await diffTypes(); - expect(exitSpy).toHaveBeenCalledWith(0); - expect(consoleInfoSpy).toHaveBeenCalledWith( - "Skipping GQL codegen validation because I can't connect to github.com." - ); - }); - - it("exit with 1 when one of the generated types files does not exist", async () => { - (fs.existsSync as jest.Mock) - .mockReturnValueOnce(true) - .mockReturnValueOnce(false); - await diffTypes(); - expect(consoleErrorSpy).toHaveBeenCalledWith( - "Types file located at undefined does not exist. Validation failed." - ); - expect(exitSpy).toHaveBeenCalledWith(1); - }); - - it("exit with 1 when types files are different and checkIsAncestor is false", async () => { - (fs.readFileSync as jest.Mock) - .mockReturnValueOnce(Buffer.from("content1")) - .mockReturnValueOnce(Buffer.from("content2")); - (checkIsAncestor as jest.Mock).mockResolvedValue(false); - await diffTypes(); - expect(consoleErrorSpy).toHaveBeenCalledWith( - "You are developing against an outdated schema and the codegen task will fail in CI. Run 'yarn codegen' against the latest Evergreen code." - ); - expect(exitSpy).toHaveBeenCalledWith(1); - }); - - it("exit with 0 when types files are different and checkIsAncestor is true", async () => { - (fs.readFileSync as jest.Mock) - .mockReturnValueOnce(Buffer.from("content1")) - .mockReturnValueOnce(Buffer.from("content2")); - (checkIsAncestor as jest.Mock).mockResolvedValue(true); - await diffTypes(); - expect(exitSpy).toHaveBeenCalledWith(0); - }); - - it("exit with 1 when types files are the same and checkIsAncestor is false", async () => { - (checkIsAncestor as jest.Mock).mockResolvedValue(false); - await diffTypes(); - expect(exitSpy).toHaveBeenCalledWith(0); - }); - - it("exit with 1 when types files are the same and checkIsAncestor is true", async () => { - await diffTypes(); - expect(exitSpy).toHaveBeenCalledWith(0); - }); - - it("handle error and exit with 1", async () => { - (canResolveDNS as jest.Mock).mockRejectedValue(new Error("Test Error")); - await diffTypes(); - expect(consoleErrorSpy).toHaveBeenCalledWith( - "An issue occurred validating the generated GQL types file: Error: Test Error" - ); - expect(exitSpy).toHaveBeenCalledWith(1); - }); -}); diff --git a/scripts/diff-local-schema-with-remote/diffTypes.ts b/scripts/diff-local-schema-with-remote/diffTypes.ts deleted file mode 100644 index 2c7e79e436..0000000000 --- a/scripts/diff-local-schema-with-remote/diffTypes.ts +++ /dev/null @@ -1,49 +0,0 @@ -import fs from "fs"; -import { generatedFileName as localGeneratedTypesFileName } from "../../codegen"; -import process from "process"; -import { canResolveDNS, checkIsAncestor, downloadAndGenerate } from "./utils"; - -/** - * Compare the local generated types with the remote version. - * Exit with code 1 if the local schema is outdated or validation fails and 0 otherwise. - * @returns {Promise} - */ -export const diffTypes = async (): Promise => { - try { - const hasInternetAccess = await canResolveDNS("github.com"); - if (!hasInternetAccess) { - console.info( - "Skipping GQL codegen validation because I can't connect to github.com." - ); - process.exit(0); - } - const latestGeneratedTypesFileName = await downloadAndGenerate(); - const filenames = [ - latestGeneratedTypesFileName, - localGeneratedTypesFileName, - ]; - filenames.forEach((filename) => { - if (!fs.existsSync(filename)) { - console.error( - `Types file located at ${filename} does not exist. Validation failed.` - ); - process.exit(1); - } - }); - const [file1, file2] = filenames.map((filename) => - fs.readFileSync(filename) - ); - if (!file1.equals(file2) && !(await checkIsAncestor())) { - console.error( - "You are developing against an outdated schema and the codegen task will fail in CI. Run 'yarn codegen' against the latest Evergreen code." - ); - process.exit(1); - } - process.exit(0); - } catch (error) { - console.error( - `An issue occurred validating the generated GQL types file: ${error}` - ); - process.exit(1); - } -}; diff --git a/scripts/diff-local-schema-with-remote/index.ts b/scripts/diff-local-schema-with-remote/index.ts deleted file mode 100644 index a4ba018467..0000000000 --- a/scripts/diff-local-schema-with-remote/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { diffTypes } from "./diffTypes"; - -diffTypes(); diff --git a/scripts/diff-local-schema-with-remote/utils.ts b/scripts/diff-local-schema-with-remote/utils.ts deleted file mode 100644 index 96839ee570..0000000000 --- a/scripts/diff-local-schema-with-remote/utils.ts +++ /dev/null @@ -1,163 +0,0 @@ -import dns from "dns"; -import fs from "fs"; -import os from "os"; -import path from "path"; -import { generate } from "@graphql-codegen/cli"; -import { getConfig } from "../../codegen"; -import { execSync } from "child_process"; -import process from "process"; - -const GITHUB_API = "https://api.github.com"; -const GQL_DIR = "graphql/schema"; -const LOCAL_SCHEMA = "sdlschema"; -const REPO = "/repos/evergreen-ci/evergreen"; -const REPO_CONTENTS = `${REPO}/contents/`; -const USER_AGENT = "Mozilla/5.0"; - -/** - * Checks if a given domain can be resolved. - * - * @async - * @function - * @param {string} domain - The domain name to check. - * @returns {Promise} - Resolves to `true` if the domain can be resolved, `false` otherwise. - */ -export const canResolveDNS = (domain: string) => - new Promise((resolve) => { - dns.lookup(domain, (err) => { - if (err) { - resolve(false); - } else { - resolve(true); - } - }); - }); - -/** - * Get the latest commit that was made to the GQL folder. - * @returns {Promise} A Promise that resolves to the SHA of the latest commit. - * @throws {Error} When failed to fetch commits. - */ -export async function getRemoteLatestCommitSha(): Promise { - const url = `${GITHUB_API}${REPO}/commits?path=${GQL_DIR}&sha=main`; - const response = await fetch(url); - if (!response.ok) { - throw new Error(`Failed to fetch ${url}. Status: ${response.status}`); - } - - const commits = await response.json(); - - if (commits.length > 0) { - return commits[0].sha; - } else { - throw new Error(`No commits found for this path: ${url}`); - } -} - -/** - * Check if the local Evergreen repo contains the latest commit made to the GQL folder. - * @returns {Promise} A Promise that resolves to true if local repo contains the latest commit, and false otherwise. - * @throws {Error} When an error occurs while executing the command. - */ -export const checkIsAncestor = async (): Promise => { - const remoteSha = await getRemoteLatestCommitSha(); - const localSchemaSymlink = fs.readlinkSync(LOCAL_SCHEMA); - console.log(localSchemaSymlink); - const originalDir = process.cwd(); - try { - process.chdir(localSchemaSymlink); - execSync(`git merge-base --is-ancestor ${remoteSha} HEAD`); - process.chdir(originalDir); - return true; - } catch (error) { - process.chdir(originalDir); - if (error.status === 1) { - return false; - } - throw new Error(`Error executing command: ${error.message}`); - } -}; - -/** - * Download the file at the given url and save it to the given savePath. - * @param {string} url - The URL of the file to be downloaded. - * @param {string} savePath - The local path where the file should be saved. - * @returns {Promise} - * @throws {Error} When failed to fetch the file. - */ -export const downloadAndSaveFile = async ( - url: string, - savePath: string -): Promise => { - const response = await fetch(url, { - headers: { - "User-Agent": USER_AGENT, - }, - }); - if (!response.ok) { - throw new Error(`Failed to fetch ${url}. Status: ${response.status}`); - } - const data = await response.arrayBuffer(); - fs.writeFileSync(savePath, Buffer.from(data)); -}; - -/** - * Recursively fetch and save the files at the given github repoPath to the given localPath. - * @param {string} repoPath - The path in the GitHub repository. - * @param {string} localPath - The local path where the files should be saved. - * @returns {Promise} - * @throws {Error} When failed to fetch the files. - */ -export const fetchFiles = async ( - repoPath: string, - localPath: string -): Promise => { - const response = await fetch(`${GITHUB_API}${repoPath}`, { - headers: { - "User-Agent": USER_AGENT, - }, - }); - - if (!response.ok) { - throw new Error( - `Failed to fetch ${GITHUB_API}${repoPath}. Status: ${response.status}` - ); - } - - const files = await response.json(); - const promises = files.map((file) => { - const fileSavePath = path.join(localPath, file.name); - if (file.type === "file") { - return downloadAndSaveFile(file.download_url, fileSavePath); - } else if (file.type === "dir") { - if (!fs.existsSync(fileSavePath)) { - fs.mkdirSync(fileSavePath, { recursive: true }); - } - return fetchFiles(`${REPO_CONTENTS}${file.path}`, fileSavePath); - } - }); - - await Promise.all(promises); -}; - -/** - * Download GQL files from remote and generate types. - * @returns {Promise} A Promise that resolves to the path of the generated file. - */ -export const downloadAndGenerate = async (): Promise => { - const tempDir = os.tmpdir(); - fs.mkdirSync(tempDir, { recursive: true }); - await fetchFiles( - path.join(REPO_CONTENTS, GQL_DIR), - path.join(tempDir, GQL_DIR) - ); - const latestGeneratedTypesFileName = `${tempDir}/types.ts`; - await generate( - getConfig({ - schema: `${tempDir}/${GQL_DIR}/**/*.graphql`, - generatedFileName: latestGeneratedTypesFileName, - }), - true - ); - return latestGeneratedTypesFileName; -}; From 74015b7f72f405ac41d4291078c621821fed8c1c Mon Sep 17 00:00:00 2001 From: julianedwards Date: Mon, 18 Sep 2023 13:46:05 -0400 Subject: [PATCH 8/8] v3.0.138 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 58ea007dfa..e3051ad5ca 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "spruce", - "version": "3.0.137", + "version": "3.0.138", "private": true, "scripts": { "bootstrap-logkeeper": "./scripts/bootstrap-logkeeper.sh",