From 8ab3ea8f98c8c3fc34d0292f51c7fc435e6601f4 Mon Sep 17 00:00:00 2001 From: Mark Noonan Date: Mon, 24 Oct 2022 15:37:36 -0400 Subject: [PATCH] feat: implement new login and "connect project" logic (#23762) Co-authored-by: Stokes Player --- .vscode/cspell.json | 3 +- graphql-codegen.yml | 7 +- packages/app/cypress/e2e/runs.cy.ts | 48 +- .../cypress/e2e/specs_list_latest_runs.cy.ts | 6 +- .../createCloudOrgModal-subscription.cy.ts | 6 +- packages/app/cypress/e2e/top-nav.cy.ts | 29 + packages/app/src/App.vue | 20 +- packages/app/src/layouts/default.vue | 32 +- .../app/src/runs/CloudConnectButton.cy.tsx | 91 +-- packages/app/src/runs/CloudConnectButton.vue | 64 +- packages/app/src/runs/RunsConnect.cy.tsx | 10 +- packages/app/src/runs/RunsConnect.vue | 20 +- packages/app/src/runs/RunsContainer.cy.tsx | 10 + packages/app/src/runs/RunsContainer.vue | 21 +- .../app/src/runs/RunsErrorRenderer.spec.tsx | 15 +- packages/app/src/runs/RunsErrorRenderer.vue | 17 +- .../src/settings/project/CloudSettings.vue | 5 +- .../app/src/settings/project/ProjectId.vue | 1 - .../app/src/specs/RequestAccessButton.vue | 3 + .../specs/SpecHeaderCloudDataTooltip.cy.tsx | 44 +- .../src/specs/SpecHeaderCloudDataTooltip.vue | 43 +- packages/app/src/specs/SpecsList.vue | 56 +- .../app/src/specs/SpecsListBanners.cy.tsx | 102 ++- packages/app/src/specs/SpecsListBanners.vue | 140 ++-- .../app/src/specs/SpecsListCloudButton.cy.tsx | 10 +- .../app/src/specs/SpecsListCloudButton.vue | 17 +- .../specs/banners/ConnectProjectBanner.cy.tsx | 6 +- .../specs/banners/ConnectProjectBanner.vue | 47 +- .../banners/CreateOrganizationBanner.cy.tsx | 6 +- .../banners/CreateOrganizationBanner.vue | 12 +- .../app/src/specs/banners/LoginBanner.cy.tsx | 6 +- .../app/src/specs/banners/LoginBanner.vue | 38 +- .../app/src/specs/banners/RecordBanner.cy.tsx | 4 +- .../app/src/specs/banners/RecordBanner.vue | 7 - .../src/specs/banners/TrackedBanner.cy.tsx | 6 +- .../app/src/specs/banners/TrackedBanner.vue | 41 +- packages/app/tsconfig.json | 1 + .../src/actions/LocalSettingsActions.ts | 5 +- .../src/actions/ProjectActions.ts | 26 +- .../src/codegen/code-generator.ts | 22 + .../src/sources/ProjectDataSource.ts | 9 + .../unit/actions/LocalSettingsActions.spec.ts | 89 +++ .../test/unit/actions/ProjectActions.spec.ts | 106 +++ .../test/unit/codegen/code-generator.spec.ts | 54 +- .../examples/UseCohortsExample.vue | 2 +- .../src/gql-components/Auth.vue | 57 +- .../gql-components/CloudViewerAndProject.vue | 140 ++++ .../gql-components/HeaderBarContent.cy.tsx | 36 +- .../src/gql-components/HeaderBarContent.vue | 46 +- .../src/gql-components/LoginConnectModals.vue | 51 ++ .../LoginConnectModalsContent.cy.tsx | 102 +++ .../LoginConnectModalsContent.vue | 92 +++ .../src/gql-components/RecordRunModal.cy.tsx | 23 +- .../src/gql-components/RecordRunModal.vue | 18 +- .../composables/useCohorts.ts | 2 +- .../composables/usePromptManager.ts | 42 ++ .../modals/CloudConnectModals.spec.tsx | 4 + .../modals/CloudConnectModals.vue | 28 +- .../modals/CreateCloudOrgModal.spec.tsx | 0 .../modals/CreateCloudOrgModal.vue | 5 +- .../{topnav => modals}/LoginModal.cy.tsx | 87 +-- .../{topnav => modals}/LoginModal.vue | 55 +- .../modals/NeedManualUpdateModal.spec.tsx | 0 .../modals/NeedManualUpdateModal.vue | 0 .../modals/SelectCloudProjectModal.cy.tsx | 0 .../modals/SelectCloudProjectModal.vue | 53 +- .../src/gql-components/topnav/TopNav.vue | 2 +- .../src/store/login-connect-store.ts | 34 +- .../src/utils/isAllowedFeature.cy.ts | 5 + packages/graphql/schemas/cloud.graphql | 2 +- packages/graphql/schemas/schema.graphql | 8 +- .../objectTypes/gql-CurrentProject.ts | 5 + .../schemaTypes/objectTypes/gql-Mutation.ts | 5 + .../cypress/e2e/top-nav-launchpad.cy.ts | 714 ++++++++++++++++++ packages/launchpad/src/Main.vue | 4 + packages/launchpad/src/main.ts | 2 + 76 files changed, 2100 insertions(+), 829 deletions(-) create mode 100644 packages/data-context/test/unit/actions/LocalSettingsActions.spec.ts create mode 100644 packages/data-context/test/unit/actions/ProjectActions.spec.ts create mode 100644 packages/frontend-shared/src/gql-components/CloudViewerAndProject.vue create mode 100644 packages/frontend-shared/src/gql-components/LoginConnectModals.vue create mode 100644 packages/frontend-shared/src/gql-components/LoginConnectModalsContent.cy.tsx create mode 100644 packages/frontend-shared/src/gql-components/LoginConnectModalsContent.vue rename packages/frontend-shared/src/{ => gql-components}/composables/useCohorts.ts (97%) create mode 100644 packages/frontend-shared/src/gql-components/composables/usePromptManager.ts rename packages/{app/src/runs => frontend-shared/src/gql-components}/modals/CloudConnectModals.spec.tsx (95%) rename packages/{app/src/runs => frontend-shared/src/gql-components}/modals/CloudConnectModals.vue (72%) rename packages/{app/src/runs => frontend-shared/src/gql-components}/modals/CreateCloudOrgModal.spec.tsx (100%) rename packages/{app/src/runs => frontend-shared/src/gql-components}/modals/CreateCloudOrgModal.vue (97%) rename packages/frontend-shared/src/gql-components/{topnav => modals}/LoginModal.cy.tsx (74%) rename packages/frontend-shared/src/gql-components/{topnav => modals}/LoginModal.vue (79%) rename packages/{app/src/runs => frontend-shared/src/gql-components}/modals/NeedManualUpdateModal.spec.tsx (100%) rename packages/{app/src/runs => frontend-shared/src/gql-components}/modals/NeedManualUpdateModal.vue (100%) rename packages/{app/src/runs => frontend-shared/src/gql-components}/modals/SelectCloudProjectModal.cy.tsx (100%) rename packages/{app/src/runs => frontend-shared/src/gql-components}/modals/SelectCloudProjectModal.vue (91%) create mode 100644 packages/launchpad/cypress/e2e/top-nav-launchpad.cy.ts diff --git a/.vscode/cspell.json b/.vscode/cspell.json index 5649488074b9..17e85156931c 100644 --- a/.vscode/cspell.json +++ b/.vscode/cspell.json @@ -37,6 +37,7 @@ "topnav", "unconfigured", "unplugin", + "unref", "unrunnable", "unstaged", "urql", @@ -48,4 +49,4 @@ ], "ignoreWords": [], "import": [] -} \ No newline at end of file +} diff --git a/graphql-codegen.yml b/graphql-codegen.yml index 496238eb8d1d..e2b11c51214b 100644 --- a/graphql-codegen.yml +++ b/graphql-codegen.yml @@ -18,7 +18,7 @@ vueOperations: &vueOperations - 'typescript-operations' - 'typed-document-node': # Intentionally specified under typed-document-node rather than top level config, - # becuase we don't want it flattening the types for the operations + # because we don't want it flattening the types for the operations flattenGeneratedTypes: true vueTesting: &vueTesting @@ -122,9 +122,10 @@ generates: './packages/app/src/generated/graphql-test.ts': documents: - './packages/app/src/**/*.{vue,ts,tsx,js,jsx}' - - './packages/frontend-shared/src/**/*.{vue,ts,tsx,js,jsx}' + - './packages/frontend-shared/src/gql-components/**/*.{vue,ts,tsx,js,jsx}' <<: *vueTesting './packages/frontend-shared/src/generated/graphql-test.ts': - documents: './packages/frontend-shared/src/gql-components/**/*.{vue,ts,tsx,js,jsx}' + documents: + - './packages/frontend-shared/src/gql-components/**/*.{vue,ts,tsx,js,jsx}' <<: *vueTesting diff --git a/packages/app/cypress/e2e/runs.cy.ts b/packages/app/cypress/e2e/runs.cy.ts index c03d84a13489..263a65218663 100644 --- a/packages/app/cypress/e2e/runs.cy.ts +++ b/packages/app/cypress/e2e/runs.cy.ts @@ -116,6 +116,10 @@ describe('App: Runs', { viewportWidth: 1200 }, () => { obj.result.data.cloudViewer.organizations.nodes = [] } + if (obj.result.data?.cloudViewer?.firstOrganization?.nodes) { + obj.result.data.cloudViewer.firstOrganization.nodes = [] + } + return obj.result }) @@ -150,8 +154,14 @@ describe('App: Runs', { viewportWidth: 1200 }, () => { cy.startAppServer('component') cy.remoteGraphQLIntercept(async (obj) => { - if (obj?.result?.data?.cloudViewer?.organizations?.nodes) { - obj.result.data.cloudViewer.organizations.nodes = [] + if ((obj.operationName !== 'CreateCloudOrgModal_CloudOrganizationsCheck_refreshOrganizations_cloudViewer')) { + if (obj.result.data?.cloudViewer?.organizations?.nodes) { + obj.result.data.cloudViewer.organizations.nodes = [] + } + + if (obj.result.data?.cloudViewer?.firstOrganization?.nodes) { + obj.result.data.cloudViewer.firstOrganization.nodes = [] + } } return obj.result @@ -181,7 +191,7 @@ describe('App: Runs', { viewportWidth: 1200 }, () => { cy.startAppServer('component') cy.remoteGraphQLIntercept(async (obj, testState) => { - if (obj.operationName === 'CloudConnectModals_RefreshCloudViewer_refreshCloudViewer_cloudViewer') { + if (obj.operationName === 'LoginConnectModals_LoginConnectModalsQuery_cloudViewer') { testState.refetchCalled = true } @@ -312,7 +322,7 @@ describe('App: Runs', { viewportWidth: 1200 }, () => { moveToRunsPage() cy.findByText(defaultMessages.runs.connect.buttonProject).click() - cy.get('button').contains(defaultMessages.runs.connect.modal.selectProject.createProject).click() + cy.contains('button', defaultMessages.runs.connect.modal.selectProject.createProject).click() cy.findByText(defaultMessages.runs.connectSuccessAlert.title).should('be.visible') cy.withCtx(async (ctx) => { @@ -351,7 +361,7 @@ describe('App: Runs', { viewportWidth: 1200 }, () => { cy.get('[href="#/runs"]').click() cy.findByText(defaultMessages.runs.connect.buttonProject).click() - cy.get('button').contains(defaultMessages.runs.connect.modal.selectProject.createProject).click() + cy.contains('button', defaultMessages.runs.connect.modal.selectProject.createProject).click() cy.get('[data-cy="alert"]').within(() => { cy.contains(defaultMessages.runs.connect.errors.baseError.title) @@ -386,7 +396,7 @@ describe('App: Runs', { viewportWidth: 1200 }, () => { cy.get('[href="#/runs"]').click() cy.findByText(defaultMessages.runs.connect.buttonProject).click() - cy.get('button').contains(defaultMessages.runs.connect.modal.selectProject.createProject).click() + cy.contains('button', defaultMessages.runs.connect.modal.selectProject.createProject).click() cy.get('[data-cy="alert"]').within(() => { cy.contains(defaultMessages.runs.connect.errors.internalServerError.title) @@ -417,7 +427,7 @@ describe('App: Runs', { viewportWidth: 1200 }, () => { } if (obj.result.data?.cloudViewer?.organizations?.nodes) { - const projectNodes = obj.result.data?.cloudViewer.organizations.nodes[0].projects.nodes + const projectNodes = obj.result.data.cloudViewer.organizations.nodes[0].projects.nodes projectNodes.push({ __typename: 'CloudProject', @@ -440,9 +450,10 @@ describe('App: Runs', { viewportWidth: 1200 }, () => { }) it('opens Connect Project modal after clicking Reconnect Project button', () => { - cy.findByText(defaultMessages.runs.errors.notFound.button).should('be.visible').click() + cy.findByText(defaultMessages.runs.errors.notFound.button).click() + cy.get('[aria-modal="true"]').should('exist') - cy.get('[data-cy="selectProject"] button').should('have.text', 'Mock Project') + cy.contains('[data-cy="selectProject"] button', 'Mock Project') cy.get('[data-cy="connect-project"]').click() cy.get('[data-cy="runs"]', { timeout: 7500 }) }) @@ -510,12 +521,13 @@ describe('App: Runs', { viewportWidth: 1200 }, () => { it('updates the button text when the request access button is clicked', () => { cy.remoteGraphQLIntercept(async (obj, testState) => { - if (obj.operationName === 'Runs_currentProject_cloudProject_cloudProjectBySlug') { + if (obj.operationName?.includes('cloudProject_cloudProjectBySlug')) { const proj = obj!.result!.data!.cloudProjectBySlug proj.__typename = 'CloudProjectUnauthorized' proj.message = 'Cloud Project Unauthorized' proj.hasRequestedAccess = false + testState.project = proj } else if (obj.operationName === 'RunsErrorRenderer_RequestAccess_cloudProjectRequestAccess') { obj!.result!.data!.cloudProjectRequestAccess = { @@ -696,7 +708,7 @@ describe('App: Runs', { viewportWidth: 1200 }, () => { cy.loginUser() cy.remoteGraphQLIntercept((obj) => { - if (obj.operationName === 'Runs_currentProject_cloudProject_cloudProjectBySlug') { + if (obj.operationName?.includes('cloudProject_cloudProjectBySlug')) { cloudData = obj.result obj.result = {} @@ -714,7 +726,7 @@ describe('App: Runs', { viewportWidth: 1200 }, () => { // cy.percySnapshot() // TODO: restore when Percy CSS is fixed. See https://github.com/cypress-io/cypress/issues/23435 cy.remoteGraphQLIntercept((obj) => { - if (obj.operationName === 'Runs_currentProject_cloudProject_cloudProjectBySlug') { + if (obj.operationName?.includes('cloudProject_cloudProjectBySlug')) { return cloudData } @@ -732,10 +744,6 @@ describe('App: Runs', { viewportWidth: 1200 }, () => { cy.startAppServer('component') }) - afterEach(() => { - cy.goOnline() - }) - it('shows alert warning if runs have been returned already', () => { cy.loginUser() cy.visitApp() @@ -773,11 +781,15 @@ describe('App: Runs', { viewportWidth: 1200 }, () => { cy.openProject('component-tests', ['--config-file', 'cypressWithoutProjectId.config.js']) cy.startAppServer('component') - cy.remoteGraphQLIntercept(async (obj) => { + cy.remoteGraphQLIntercept((obj) => { if (obj.result.data?.cloudViewer?.organizations?.nodes) { obj.result.data.cloudViewer.organizations.nodes = [] } + if (obj.result.data?.cloudViewer?.firstOrganization?.nodes) { + obj.result.data.cloudViewer.firstOrganization.nodes = [] + } + return obj.result }) @@ -845,7 +857,7 @@ describe('App: Runs', { viewportWidth: 1200 }, () => { cy.openProject('component-tests') cy.startAppServer('component') cy.loginUser() - cy.remoteGraphQLIntercept((obj, testState) => { + cy.remoteGraphQLIntercept((obj) => { if (obj.result.data?.cloudProjectBySlug?.runs?.nodes.length) { obj.result.data.cloudProjectBySlug.runs.nodes.map((run) => { run.status = 'RUNNING' diff --git a/packages/app/cypress/e2e/specs_list_latest_runs.cy.ts b/packages/app/cypress/e2e/specs_list_latest_runs.cy.ts index 6d99a9b6a384..7a661af52f78 100644 --- a/packages/app/cypress/e2e/specs_list_latest_runs.cy.ts +++ b/packages/app/cypress/e2e/specs_list_latest_runs.cy.ts @@ -160,7 +160,7 @@ describe('App/Cloud Integration - Latest runs and Average duration', { viewportW }) context('when no runs are recorded', () => { - beforeEach(() => { + it('shows placeholders for all visible specs', { defaultCommandTimeout: 6000 }, () => { cy.loginUser() cy.remoteGraphQLIntercept(async (obj) => { @@ -181,10 +181,6 @@ describe('App/Cloud Integration - Latest runs and Average duration', { viewportW }) cy.visitApp() - cy.findByTestId('sidebar-link-specs-page').click() - }) - - it('shows placeholders for all visible specs', () => { allVisibleSpecsShouldBePlaceholders() }) }) diff --git a/packages/app/cypress/e2e/subscriptions/createCloudOrgModal-subscription.cy.ts b/packages/app/cypress/e2e/subscriptions/createCloudOrgModal-subscription.cy.ts index 41f607621d12..1d4365597b50 100644 --- a/packages/app/cypress/e2e/subscriptions/createCloudOrgModal-subscription.cy.ts +++ b/packages/app/cypress/e2e/subscriptions/createCloudOrgModal-subscription.cy.ts @@ -1,7 +1,7 @@ import defaultMessages from '@packages/frontend-shared/src/locales/en-US.json' import type { SinonStub } from 'sinon' -describe('App: Runs', { viewportWidth: 1200 }, () => { +describe('CreateCloudOrgModalSubscription', { viewportWidth: 1200 }, () => { beforeEach(() => { cy.scaffoldProject('component-tests') cy.openProject('component-tests') @@ -22,6 +22,10 @@ describe('App: Runs', { viewportWidth: 1200 }, () => { obj.result.data.cloudViewer.organizations.nodes = [] } + if (obj.result.data?.cloudViewer?.firstOrganization?.nodes) { + obj.result.data.cloudViewer.firstOrganization.nodes = [] + } + return obj.result }) diff --git a/packages/app/cypress/e2e/top-nav.cy.ts b/packages/app/cypress/e2e/top-nav.cy.ts index 330e5f0796b1..e65a10d484f7 100644 --- a/packages/app/cypress/e2e/top-nav.cy.ts +++ b/packages/app/cypress/e2e/top-nav.cy.ts @@ -453,6 +453,17 @@ describe('App Top Nav Workflows', () => { cy.openProject('component-tests', ['--config-file', 'cypressWithoutProjectId.config.js']) cy.startAppServer() cy.visitApp() + cy.remoteGraphQLIntercept(async (obj) => { + if (obj.result.data?.cloudViewer) { + obj.result.data.cloudViewer.organizations = { + __typename: 'CloudOrganizationConnection', + id: 'test', + nodes: [{ __typename: 'CloudOrganization', id: '987' }], + } + } + + return obj.result + }) mockLogInActionsForUser(mockUser) logIn({ expectedNextStepText: 'Connect project', displayName: mockUser.name }) @@ -494,6 +505,24 @@ describe('App Top Nav Workflows', () => { cy.findByTestId('app-header-bar').findByTestId('user-avatar-title').should('be.visible') }) + it('if the project has no runs, shows "record your first run" prompt after clicking', () => { + cy.remoteGraphQLIntercept((obj) => { + if (obj.result?.data?.cloudProjectBySlug?.runs?.nodes?.length) { + obj.result.data.cloudProjectBySlug.runs.nodes = [] + } + + return obj.result + }) + + mockLogInActionsForUser(mockUserNoName) + + logIn({ expectedNextStepText: 'Continue', displayName: mockUserNoName.email }) + + cy.contains('[data-cy=standard-modal] h2', defaultMessages.specPage.banners.record.title).should('be.visible') + cy.contains('[data-cy=standard-modal]', defaultMessages.specPage.banners.record.content).should('be.visible') + cy.contains('button', 'Copy').should('be.visible') + }) + it('shows correct error when browser cannot launch', () => { cy.withCtx((ctx, o) => { o.sinon.stub(ctx._apis.authApi, 'logIn').callsFake(async (onMessage) => { diff --git a/packages/app/src/App.vue b/packages/app/src/App.vue index d76b3823321d..c0e8a339e7fe 100644 --- a/packages/app/src/App.vue +++ b/packages/app/src/App.vue @@ -4,13 +4,31 @@ :is="Component" /> + + + +