diff --git a/js_modules/dagster-ui/packages/ui-components/package.json b/js_modules/dagster-ui/packages/ui-components/package.json index 6a8e2e34e44ba..b94b524083825 100644 --- a/js_modules/dagster-ui/packages/ui-components/package.json +++ b/js_modules/dagster-ui/packages/ui-components/package.json @@ -66,7 +66,7 @@ "@storybook/react-webpack5": "^7.2.0", "@testing-library/dom": "^10.0.0", "@testing-library/jest-dom": "^6.4.2", - "@testing-library/react": "^15.0.3", + "@testing-library/react": "^16.0.0", "@testing-library/user-event": "^14.5.2", "@types/babel__core": "^7", "@types/babel__preset-env": "^7", diff --git a/js_modules/dagster-ui/packages/ui-core/package.json b/js_modules/dagster-ui/packages/ui-core/package.json index d4a10b3dcb97b..08e3d9b9dc9c4 100644 --- a/js_modules/dagster-ui/packages/ui-core/package.json +++ b/js_modules/dagster-ui/packages/ui-core/package.json @@ -107,7 +107,7 @@ "@storybook/react-webpack5": "^7.6.7", "@testing-library/dom": "^10.0.0", "@testing-library/jest-dom": "^6.4.2", - "@testing-library/react": "^15.0.3", + "@testing-library/react": "^16.0.0", "@testing-library/user-event": "^14.5.2", "@types/codemirror": "^5.60.5", "@types/color": "^3.0.2", diff --git a/js_modules/dagster-ui/packages/ui-core/src/assets/__fixtures__/AssetViewDefinition.fixtures.ts b/js_modules/dagster-ui/packages/ui-core/src/assets/__fixtures__/AssetViewDefinition.fixtures.ts index 49e55730e8142..e1e76ac2e58d9 100644 --- a/js_modules/dagster-ui/packages/ui-core/src/assets/__fixtures__/AssetViewDefinition.fixtures.ts +++ b/js_modules/dagster-ui/packages/ui-core/src/assets/__fixtures__/AssetViewDefinition.fixtures.ts @@ -6,14 +6,9 @@ import { buildRepository, buildRepositoryLocation, buildResourceRequirement, - buildWorkspace, buildWorkspaceLocationEntry, } from '../../graphql/types'; -import {ROOT_WORKSPACE_QUERY} from '../../workspace/WorkspaceContext'; -import { - RootWorkspaceQuery, - RootWorkspaceQueryVariables, -} from '../../workspace/types/WorkspaceContext.types'; +import {buildWorkspaceMocks} from '../../workspace/__fixtures__/Workspace.fixtures'; import {ASSET_VIEW_DEFINITION_QUERY} from '../AssetView'; import {buildQueryMock} from '../AutoMaterializePolicyPage/__fixtures__/AutoMaterializePolicyPage.fixtures'; import { @@ -153,30 +148,19 @@ export const AssetViewDefinitionSDA = buildQueryMock< }, }); -export const RootWorkspaceWithOneLocation = buildQueryMock< - RootWorkspaceQuery, - RootWorkspaceQueryVariables ->({ - query: ROOT_WORKSPACE_QUERY, - variables: {}, - data: { - workspaceOrError: buildWorkspace({ - locationEntries: [ - buildWorkspaceLocationEntry({ - locationOrLoadError: buildRepositoryLocation({ - repositories: [ - buildRepository({ - id: '4d0b1967471d9a4682ccc97d12c1c508d0d9c2e1', - name: 'repo', - location: buildRepositoryLocation({ - id: 'test.py', - name: 'test.py', - }), - }), - ], +export const RootWorkspaceWithOneLocation = buildWorkspaceMocks([ + buildWorkspaceLocationEntry({ + locationOrLoadError: buildRepositoryLocation({ + repositories: [ + buildRepository({ + id: '4d0b1967471d9a4682ccc97d12c1c508d0d9c2e1', + name: 'repo', + location: buildRepositoryLocation({ + id: 'test.py', + name: 'test.py', }), }), ], }), - }, -}); + }), +]); diff --git a/js_modules/dagster-ui/packages/ui-core/src/assets/__tests__/AssetTables.test.tsx b/js_modules/dagster-ui/packages/ui-core/src/assets/__tests__/AssetTables.test.tsx index 96f06dd2888f4..99cf34cb1255d 100644 --- a/js_modules/dagster-ui/packages/ui-core/src/assets/__tests__/AssetTables.test.tsx +++ b/js_modules/dagster-ui/packages/ui-core/src/assets/__tests__/AssetTables.test.tsx @@ -4,9 +4,8 @@ import userEvent from '@testing-library/user-event'; import {MemoryRouter} from 'react-router'; import {RecoilRoot} from 'recoil'; -import {buildWorkspace} from '../../graphql/types'; -import {buildWorkspaceContextMockedResponse} from '../../runs/__fixtures__/RunsFilterInput.fixtures'; import {WorkspaceProvider} from '../../workspace/WorkspaceContext'; +import {buildWorkspaceMocks} from '../../workspace/__fixtures__/Workspace.fixtures'; import {AssetsCatalogTable} from '../AssetsCatalogTable'; import { AssetCatalogGroupTableMock, @@ -17,7 +16,7 @@ import { SingleAssetQueryTrafficDashboard, } from '../__fixtures__/AssetTables.fixtures'; -const workspaceMock = buildWorkspaceContextMockedResponse(buildWorkspace({})); +const workspaceMocks = buildWorkspaceMocks([]); const MOCKS = [ AssetCatalogTableMock, @@ -26,7 +25,7 @@ const MOCKS = [ SingleAssetQueryMaterializedWithLatestRun, SingleAssetQueryMaterializedStaleAndLate, SingleAssetQueryLastRunFailed, - workspaceMock, + ...workspaceMocks, ]; // This file must be mocked because Jest can't handle `import.meta.url`. diff --git a/js_modules/dagster-ui/packages/ui-core/src/assets/__tests__/AssetView.test.tsx b/js_modules/dagster-ui/packages/ui-core/src/assets/__tests__/AssetView.test.tsx index 4617116d1c57f..b4a2eaa25ff1c 100644 --- a/js_modules/dagster-ui/packages/ui-core/src/assets/__tests__/AssetView.test.tsx +++ b/js_modules/dagster-ui/packages/ui-core/src/assets/__tests__/AssetView.test.tsx @@ -59,7 +59,7 @@ describe('AssetView', () => { { it('Adding a dynamic partition when multiple assets selected', async () => { @@ -92,21 +92,23 @@ describe('launchAssetChoosePartitionsDialog', () => { assetASecondQueryMock, assetBSecondQueryMock, addPartitionMock, - workspaceMock, + ...workspaceMocks, ]} > - {}} - repoAddress={buildRepoAddress('test', 'test')} - target={{ - jobName: '__ASSET_JOB_0', - partitionSetName: '__ASSET_JOB_0_partition_set', - type: 'job', - }} - assets={[assetA, assetB]} - upstreamAssetKeys={[]} - /> + + {}} + repoAddress={buildRepoAddress('test', 'test')} + target={{ + jobName: '__ASSET_JOB_0', + partitionSetName: '__ASSET_JOB_0_partition_set', + type: 'job', + }} + assets={[assetA, assetB]} + upstreamAssetKeys={[]} + /> + , ); diff --git a/js_modules/dagster-ui/packages/ui-core/src/assets/__tests__/LaunchAssetExecutionButton.test.tsx b/js_modules/dagster-ui/packages/ui-core/src/assets/__tests__/LaunchAssetExecutionButton.test.tsx index 38e73ecc8d12a..7a2a14d345fc0 100644 --- a/js_modules/dagster-ui/packages/ui-core/src/assets/__tests__/LaunchAssetExecutionButton.test.tsx +++ b/js_modules/dagster-ui/packages/ui-core/src/assets/__tests__/LaunchAssetExecutionButton.test.tsx @@ -6,12 +6,11 @@ import userEvent from '@testing-library/user-event'; import {CustomAlertProvider} from '../../app/CustomAlertProvider'; import {CustomConfirmationProvider} from '../../app/CustomConfirmationProvider'; import {displayNameForAssetKey} from '../../asset-graph/Utils'; -import {buildWorkspace} from '../../graphql/types'; import {LaunchPartitionBackfillMutation} from '../../instance/backfill/types/BackfillUtils.types'; -import {buildWorkspaceContextMockedResponse} from '../../runs/__fixtures__/RunsFilterInput.fixtures'; import {LaunchPipelineExecutionMutation} from '../../runs/types/RunUtils.types'; import {TestProvider} from '../../testing/TestProvider'; import * as WorkspaceContext from '../../workspace/WorkspaceContext'; +import {buildWorkspaceMocks} from '../../workspace/__fixtures__/Workspace.fixtures'; import {ADDITIONAL_REQUIRED_KEYS_WARNING} from '../AssetDefinedInMultipleReposNotice'; import { AssetsInScope, @@ -46,7 +45,7 @@ import { } from '../__fixtures__/LaunchAssetExecutionButton.fixtures'; import {asAssetKeyInput} from '../asInput'; -const workspaceMock = buildWorkspaceContextMockedResponse(buildWorkspace({})); +const workspaceMocks = buildWorkspaceMocks([]); // This file must be mocked because Jest can't handle `import.meta.url`. jest.mock('../../graph/asyncGraphLayout', () => ({})); @@ -653,7 +652,7 @@ function renderButton({ }), buildLaunchAssetLoaderMock([MULTI_ASSET_OUT_1.assetKey, MULTI_ASSET_OUT_2.assetKey]), buildLaunchAssetLoaderMock(assetKeys), - workspaceMock, + ...workspaceMocks, ...(launchMock ? [launchMock] : []), ]; diff --git a/js_modules/dagster-ui/packages/ui-core/src/instance/DeploymentStatusProvider.tsx b/js_modules/dagster-ui/packages/ui-core/src/instance/DeploymentStatusProvider.tsx index 2ad2305371ab9..1f65f1913d09d 100644 --- a/js_modules/dagster-ui/packages/ui-core/src/instance/DeploymentStatusProvider.tsx +++ b/js_modules/dagster-ui/packages/ui-core/src/instance/DeploymentStatusProvider.tsx @@ -24,7 +24,7 @@ interface Props { export const DeploymentStatusProvider = (props: Props) => { const {children, include} = props; - const codeLocations = useCodeLocationsStatus(!include.has('code-locations')); + const codeLocations = useCodeLocationsStatus(); const daemons = useDaemonStatus(!include.has('daemons')); const value = React.useMemo(() => ({codeLocations, daemons}), [daemons, codeLocations]); diff --git a/js_modules/dagster-ui/packages/ui-core/src/instance/backfill/__tests__/BackfillTable.test.tsx b/js_modules/dagster-ui/packages/ui-core/src/instance/backfill/__tests__/BackfillTable.test.tsx index fce0aa4dc0331..d0c0e314a095b 100644 --- a/js_modules/dagster-ui/packages/ui-core/src/instance/backfill/__tests__/BackfillTable.test.tsx +++ b/js_modules/dagster-ui/packages/ui-core/src/instance/backfill/__tests__/BackfillTable.test.tsx @@ -1,6 +1,5 @@ import {MockedProvider} from '@apollo/client/testing'; -import {render, screen} from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import {act, render, screen, waitFor} from '@testing-library/react'; import {MemoryRouter} from 'react-router-dom'; import * as Alerting from '../../../app/CustomAlertProvider'; @@ -28,7 +27,11 @@ describe('BackfillTable', () => { expect(screen.getByRole('table')).toBeVisible(); const statusLabel = await screen.findByText('Failed'); - await userEvent.click(statusLabel); - expect(Alerting.showCustomAlert).toHaveBeenCalled(); + act(() => { + statusLabel.click(); + }); + await waitFor(() => { + expect(Alerting.showCustomAlert).toHaveBeenCalled(); + }); }); }); diff --git a/js_modules/dagster-ui/packages/ui-core/src/instance/flattenCodeLocationRows.tsx b/js_modules/dagster-ui/packages/ui-core/src/instance/flattenCodeLocationRows.tsx index e316e1a5701d0..b9d7513e2dd71 100644 --- a/js_modules/dagster-ui/packages/ui-core/src/instance/flattenCodeLocationRows.tsx +++ b/js_modules/dagster-ui/packages/ui-core/src/instance/flattenCodeLocationRows.tsx @@ -1,5 +1,5 @@ import {CodeLocationRowType} from '../workspace/VirtualizedCodeLocationRow'; -import {WorkspaceLocationNodeFragment} from '../workspace/types/WorkspaceContext.types'; +import {WorkspaceLocationNodeFragment} from '../workspace/types/WorkspaceQueries.types'; const flatten = (locationEntries: WorkspaceLocationNodeFragment[]) => { // Consider each loaded repository to be a "code location". diff --git a/js_modules/dagster-ui/packages/ui-core/src/nav/__fixtures__/LeftNavRepositorySection.fixtures.tsx b/js_modules/dagster-ui/packages/ui-core/src/nav/__fixtures__/LeftNavRepositorySection.fixtures.tsx index 506fb2014bb1b..34381877df7e0 100644 --- a/js_modules/dagster-ui/packages/ui-core/src/nav/__fixtures__/LeftNavRepositorySection.fixtures.tsx +++ b/js_modules/dagster-ui/packages/ui-core/src/nav/__fixtures__/LeftNavRepositorySection.fixtures.tsx @@ -1,16 +1,13 @@ -import {MockedResponse} from '@apollo/client/testing'; - import { + WorkspaceLocationEntry, buildAssetGroup, buildPipeline, buildRepository, buildRepositoryLocation, - buildWorkspace, buildWorkspaceLocationEntry, } from '../../graphql/types'; -import {ROOT_WORKSPACE_QUERY} from '../../workspace/WorkspaceContext'; +import {buildWorkspaceMocks} from '../../workspace/__fixtures__/Workspace.fixtures'; import {DUNDER_REPO_NAME} from '../../workspace/buildRepoAddress'; -import {RootWorkspaceQuery} from '../../workspace/types/WorkspaceContext.types'; const buildRepo = ({ name, @@ -38,136 +35,66 @@ const buildRepo = ({ }); }; -export const buildWorkspaceQueryWithZeroLocations = (): MockedResponse => { - return { - request: { - query: ROOT_WORKSPACE_QUERY, - variables: {}, - }, - result: { - data: { - __typename: 'Query', - workspaceOrError: buildWorkspace({ - locationEntries: [], - }), - }, - }, - }; -}; +export const buildWorkspaceQueryWithZeroLocations = () => buildWorkspaceMocks([]); -export const buildWorkspaceQueryWithOneLocation = (): MockedResponse => { - return { - request: { - query: ROOT_WORKSPACE_QUERY, - variables: {}, - }, - result: { - data: { - __typename: 'Query', - workspaceOrError: buildWorkspace({ - locationEntries: [ - buildWorkspaceLocationEntry({ - id: 'ipsum-entry', - name: 'ipsum-entry', - locationOrLoadError: buildRepositoryLocation({ - id: 'ipsum', - name: 'ipsum', - repositories: [ - buildRepo({name: 'lorem', jobNames: ['my_pipeline', 'other_pipeline']}), - ], - }), - }), - ], +const locationEntries = [ + buildWorkspaceLocationEntry({ + id: 'ipsum-entry', + name: 'ipsum-entry', + locationOrLoadError: buildRepositoryLocation({ + id: 'ipsum', + name: 'ipsum', + repositories: [buildRepo({name: 'lorem', jobNames: ['my_pipeline', 'other_pipeline']})], + }), + }), + buildWorkspaceLocationEntry({ + id: 'bar-entry', + name: 'bar-entry', + locationOrLoadError: buildRepositoryLocation({ + id: 'bar', + name: 'bar', + repositories: [buildRepo({name: 'foo', jobNames: ['bar_job', 'd_job', 'e_job', 'f_job']})], + }), + }), + buildWorkspaceLocationEntry({ + id: 'abc_location-entry', + name: 'abc_location-entry', + locationOrLoadError: buildRepositoryLocation({ + id: 'abc_location', + name: 'abc_location', + repositories: [ + buildRepo({ + name: DUNDER_REPO_NAME, + jobNames: ['abc_job', 'def_job', 'ghi_job', 'jkl_job', 'mno_job', 'pqr_job'], }), - }, - }, - }; + ], + }), + }), +] as [WorkspaceLocationEntry, WorkspaceLocationEntry, WorkspaceLocationEntry]; + +export const buildWorkspaceQueryWithOneLocation = () => { + return buildWorkspaceMocks([locationEntries[0]]); }; -export const buildWorkspaceQueryWithThreeLocations = (): MockedResponse => { - return { - request: { - query: ROOT_WORKSPACE_QUERY, - variables: {}, - }, - result: { - data: { - __typename: 'Query', - workspaceOrError: buildWorkspace({ - locationEntries: [ - buildWorkspaceLocationEntry({ - id: 'ipsum-entry', - name: 'ipsum-entry', - locationOrLoadError: buildRepositoryLocation({ - id: 'ipsum', - name: 'ipsum', - repositories: [ - buildRepo({name: 'lorem', jobNames: ['my_pipeline', 'other_pipeline']}), - ], - }), - }), - buildWorkspaceLocationEntry({ - id: 'bar-entry', - name: 'bar-entry', - locationOrLoadError: buildRepositoryLocation({ - id: 'bar', - name: 'bar', - repositories: [ - buildRepo({name: 'foo', jobNames: ['bar_job', 'd_job', 'e_job', 'f_job']}), - ], - }), - }), - buildWorkspaceLocationEntry({ - id: 'abc_location-entry', - name: 'abc_location-entry', - locationOrLoadError: buildRepositoryLocation({ - id: 'abc_location', - name: 'abc_location', - repositories: [ - buildRepo({ - name: DUNDER_REPO_NAME, - jobNames: ['abc_job', 'def_job', 'ghi_job', 'jkl_job', 'mno_job', 'pqr_job'], - }), - ], - }), - }), - ], - }), - }, - }, - }; +export const buildWorkspaceQueryWithThreeLocations = () => { + return buildWorkspaceMocks(locationEntries); }; -export const buildWorkspaceQueryWithOneLocationAndAssetGroup = - (): MockedResponse => { - return { - request: { - query: ROOT_WORKSPACE_QUERY, - variables: {}, - }, - result: { - data: { - __typename: 'Query', - workspaceOrError: buildWorkspace({ - locationEntries: [ - buildWorkspaceLocationEntry({ - id: 'ipsum-entry', - name: 'ipsum-entry', - locationOrLoadError: buildRepositoryLocation({ - id: 'ipsum', - name: 'ipsum', - repositories: [ - buildRepo({ - name: 'lorem', - jobNames: ['my_pipeline', 'other_pipeline'], - assetGroupNames: ['my_asset_group'], - }), - ], - }), - }), - ], - }), - }, - }, - }; - }; +const entryWithOneLocationAndAssetGroup = buildWorkspaceLocationEntry({ + id: 'unique-entry', + name: 'unique-entry', + locationOrLoadError: buildRepositoryLocation({ + id: 'unique', + name: 'unique', + repositories: [ + buildRepo({ + name: 'entry', + jobNames: ['my_pipeline', 'other_pipeline'], + assetGroupNames: ['my_asset_group'], + }), + ], + }), +}); + +export const buildWorkspaceQueryWithOneLocationAndAssetGroup = () => + buildWorkspaceMocks([entryWithOneLocationAndAssetGroup]); diff --git a/js_modules/dagster-ui/packages/ui-core/src/nav/__fixtures__/useDaemonStatus.fixtures.tsx b/js_modules/dagster-ui/packages/ui-core/src/nav/__fixtures__/useDaemonStatus.fixtures.tsx index 37fb0d1bbde44..daabb6cd721c0 100644 --- a/js_modules/dagster-ui/packages/ui-core/src/nav/__fixtures__/useDaemonStatus.fixtures.tsx +++ b/js_modules/dagster-ui/packages/ui-core/src/nav/__fixtures__/useDaemonStatus.fixtures.tsx @@ -12,13 +12,11 @@ import { buildRepositoryLocation, buildSchedule, buildSensor, - buildWorkspace, buildWorkspaceLocationEntry, } from '../../graphql/types'; import {InstanceWarningQuery} from '../../instance/types/useDaemonStatus.types'; import {INSTANCE_WARNING_QUERY} from '../../instance/useDaemonStatus'; -import {ROOT_WORKSPACE_QUERY} from '../../workspace/WorkspaceContext'; -import {RootWorkspaceQuery} from '../../workspace/types/WorkspaceContext.types'; +import {buildWorkspaceMocks} from '../../workspace/__fixtures__/Workspace.fixtures'; const buildRepo = ({ name, @@ -55,33 +53,18 @@ const buildRepo = ({ }); }; -export const buildWorkspaceQueryWithNoSchedulesOrSensors = - (): MockedResponse => { - return { - request: { - query: ROOT_WORKSPACE_QUERY, - variables: {}, - }, - result: { - data: { - __typename: 'Query', - workspaceOrError: buildWorkspace({ - locationEntries: [ - buildWorkspaceLocationEntry({ - id: 'ipsum-entry', - name: 'ipsum-entry', - locationOrLoadError: buildRepositoryLocation({ - id: 'ipsum', - name: 'ipsum', - repositories: [buildRepo({name: 'lorem'})], - }), - }), - ], - }), - }, - }, - }; - }; +export const buildWorkspaceQueryWithNoSchedulesOrSensors = () => + buildWorkspaceMocks([ + buildWorkspaceLocationEntry({ + id: 'ipsum-entry', + name: 'ipsum-entry', + locationOrLoadError: buildRepositoryLocation({ + id: 'ipsum', + name: 'ipsum', + repositories: [buildRepo({name: 'lorem'})], + }), + }), + ]); export const buildWorkspaceQueryWithScheduleAndSensor = ({ schedule, @@ -89,37 +72,24 @@ export const buildWorkspaceQueryWithScheduleAndSensor = ({ }: { schedule: InstigationStatus; sensor: InstigationStatus; -}): MockedResponse => { - return { - request: { - query: ROOT_WORKSPACE_QUERY, - variables: {}, - }, - result: { - data: { - __typename: 'Query', - workspaceOrError: buildWorkspace({ - locationEntries: [ - buildWorkspaceLocationEntry({ - id: 'ipsum-entry', - name: 'ipsum-entry', - locationOrLoadError: buildRepositoryLocation({ - id: 'ipsum', - name: 'ipsum', - repositories: [ - buildRepo({ - name: 'lorem', - schedules: {'my-schedule': schedule}, - sensors: {'my-sensor': sensor}, - }), - ], - }), - }), - ], - }), - }, - }, - }; +}) => { + return buildWorkspaceMocks([ + buildWorkspaceLocationEntry({ + id: 'ipsum-entry' + Math.random(), + name: 'ipsum-entry' + Math.random(), + locationOrLoadError: buildRepositoryLocation({ + id: 'ipsum', + name: 'ipsum', + repositories: [ + buildRepo({ + name: 'lorem', + schedules: {'my-schedule': schedule}, + sensors: {'my-sensor': sensor}, + }), + ], + }), + }), + ]); }; type DaemonHealth = {daemonType: string; healthy: boolean; required: boolean}[]; diff --git a/js_modules/dagster-ui/packages/ui-core/src/nav/__tests__/LeftNavRepositorySection.test.tsx b/js_modules/dagster-ui/packages/ui-core/src/nav/__tests__/LeftNavRepositorySection.test.tsx index 39bd0ab452211..83b411950e634 100644 --- a/js_modules/dagster-ui/packages/ui-core/src/nav/__tests__/LeftNavRepositorySection.test.tsx +++ b/js_modules/dagster-ui/packages/ui-core/src/nav/__tests__/LeftNavRepositorySection.test.tsx @@ -29,27 +29,27 @@ describe('Repository options', () => { let nativeGBRC: any; - beforeAll(() => { + beforeEach(() => { + window.localStorage.clear(); nativeGBRC = window.Element.prototype.getBoundingClientRect; window.Element.prototype.getBoundingClientRect = jest .fn() .mockReturnValue({height: 400, width: 400}); }); - afterAll(() => { - window.Element.prototype.getBoundingClientRect = nativeGBRC; - }); - afterEach(() => { + window.Element.prototype.getBoundingClientRect = nativeGBRC; window.localStorage.clear(); __resetForJest(); + jest.resetModules(); + jest.resetAllMocks(); }); it('Correctly displays the current repository state', async () => { await act(() => render( - + @@ -71,7 +71,7 @@ describe('Repository options', () => { await act(() => render( - + @@ -94,7 +94,7 @@ describe('Repository options', () => { await act(() => render( - + @@ -116,7 +116,7 @@ describe('Repository options', () => { await act(() => render( - + @@ -127,18 +127,14 @@ describe('Repository options', () => { const loremHeader = await screen.findByRole('button', {name: /lorem/i}); expect(loremHeader).toBeVisible(); - const fooHeader = screen.getByRole('button', {name: /foo/i}); + const fooHeader = await waitFor(() => screen.getByRole('button', {name: /foo/i})); expect(fooHeader).toBeVisible(); - const dunderHeader = screen.getByRole('button', {name: /abc_location/i}); + const dunderHeader = await waitFor(() => screen.getByRole('button', {name: /abc_location/i})); expect(dunderHeader).toBeVisible(); await userEvent.click(loremHeader); - await userEvent.click(fooHeader); - await userEvent.click(dunderHeader); - await waitFor(() => { - // Twelve jobs total. No repo name link since multiple repos are visible. - expect(screen.queryAllByRole('link')).toHaveLength(12); + expect(screen.queryAllByRole('link')).toHaveLength(6); }); }); @@ -151,7 +147,7 @@ describe('Repository options', () => { await act(() => render( - + @@ -175,7 +171,7 @@ describe('Repository options', () => { await act(() => render( - + @@ -214,7 +210,7 @@ describe('Repository options', () => { act(() => { render( - + @@ -247,8 +243,8 @@ describe('Repository options', () => { }; const mocks = [ - buildWorkspaceQueryWithZeroLocations(), - buildWorkspaceQueryWithThreeLocations(), + ...buildWorkspaceQueryWithZeroLocations(), + ...buildWorkspaceQueryWithThreeLocations(), ]; await act(() => @@ -269,7 +265,7 @@ describe('Repository options', () => { const reloadButton = screen.getByRole('button', {name: /refetch workspace/i}); await userEvent.click(reloadButton); - const loremHeader = await screen.findByRole('button', {name: /lorem/i}); + const loremHeader = await waitFor(() => screen.findByRole('button', {name: /lorem/i})); await waitFor(() => { expect(loremHeader).toBeVisible(); }); @@ -303,7 +299,10 @@ describe('Repository options', () => { ); }; - const mocks = [buildWorkspaceQueryWithOneLocation(), buildWorkspaceQueryWithZeroLocations()]; + const mocks = [ + ...buildWorkspaceQueryWithOneLocation(), + ...buildWorkspaceQueryWithZeroLocations(), + ]; await act(() => render( @@ -339,7 +338,7 @@ describe('Repository options', () => { await act(() => render( - + @@ -348,7 +347,7 @@ describe('Repository options', () => { ), ); - const repoHeader = await screen.findByRole('button', {name: /lorem/i}); + const repoHeader = await screen.findByRole('button', {name: /unique/i}); await userEvent.click(repoHeader); await waitFor(() => { diff --git a/js_modules/dagster-ui/packages/ui-core/src/nav/__tests__/useDaemonStatus.test.tsx b/js_modules/dagster-ui/packages/ui-core/src/nav/__tests__/useDaemonStatus.test.tsx index d93b970707fef..f143ace001c66 100644 --- a/js_modules/dagster-ui/packages/ui-core/src/nav/__tests__/useDaemonStatus.test.tsx +++ b/js_modules/dagster-ui/packages/ui-core/src/nav/__tests__/useDaemonStatus.test.tsx @@ -1,10 +1,10 @@ import {MockedProvider} from '@apollo/client/testing'; -import {render, screen} from '@testing-library/react'; -import {renderHook} from '@testing-library/react-hooks'; +import {render, renderHook, screen, waitFor} from '@testing-library/react'; import * as React from 'react'; import {InstigationStatus} from '../../graphql/types'; import {useDaemonStatus} from '../../instance/useDaemonStatus'; +import {__resetForJest} from '../../search/useIndexedDBCachedQuery'; import {WorkspaceProvider} from '../../workspace/WorkspaceContext'; import { buildInstanceWarningQuery, @@ -12,15 +12,18 @@ import { buildWorkspaceQueryWithScheduleAndSensor, } from '../__fixtures__/useDaemonStatus.fixtures'; +afterEach(() => { + __resetForJest(); +}); describe('useDaemonStatus', () => { describe('Scheduler daemon', () => { it('does not surface scheduler errors if there are no schedules', async () => { const daemonHealth = [{daemonType: 'SCHEDULER', healthy: false, required: true}]; - const {result, waitForNextUpdate} = renderHook(() => useDaemonStatus(), { + const {result} = renderHook(() => useDaemonStatus(), { wrapper: ({children}: {children: React.ReactNode}) => ( @@ -29,18 +32,17 @@ describe('useDaemonStatus', () => { ), }); - await waitForNextUpdate(); expect(result.current).toBeNull(); }); it('does not surface scheduler errors if there are no running schedules', async () => { const daemonHealth = [{daemonType: 'SCHEDULER', healthy: false, required: true}]; - const {result, waitForNextUpdate} = renderHook(() => useDaemonStatus(), { + const {result} = renderHook(() => useDaemonStatus(), { wrapper: ({children}: {children: React.ReactNode}) => ( { ), }); - await waitForNextUpdate(); expect(result.current).toBeNull(); }); it('does surface scheduler errors if there is a running schedule', async () => { const daemonHealth = [{daemonType: 'SCHEDULER', healthy: false, required: true}]; - const {result, waitFor} = renderHook(() => useDaemonStatus(), { + const {result} = renderHook(() => useDaemonStatus(), { wrapper: ({children}: {children: React.ReactNode}) => ( { it('does not surface sensor daemon errors if there are no sensors', async () => { const daemonHealth = [{daemonType: 'SENSOR', healthy: false, required: true}]; - const {result, waitForNextUpdate} = renderHook(() => useDaemonStatus(), { + const {result} = renderHook(() => useDaemonStatus(), { wrapper: ({children}: {children: React.ReactNode}) => ( @@ -100,18 +101,17 @@ describe('useDaemonStatus', () => { ), }); - await waitForNextUpdate(); expect(result.current).toBeNull(); }); it('does not surface sensor daemon errors if there are no running sensors', async () => { const daemonHealth = [{daemonType: 'SENSOR', healthy: false, required: true}]; - const {result, waitForNextUpdate} = renderHook(() => useDaemonStatus(), { + const {result} = renderHook(() => useDaemonStatus(), { wrapper: ({children}: {children: React.ReactNode}) => ( { ), }); - await waitForNextUpdate(); expect(result.current).toBeNull(); }); it('does surface sensor daemon errors if there is a running sensor', async () => { const daemonHealth = [{daemonType: 'SENSOR', healthy: false, required: true}]; - const {result, waitFor} = renderHook(() => useDaemonStatus(), { + const {result} = renderHook(() => useDaemonStatus(), { wrapper: ({children}: {children: React.ReactNode}) => ( { it('does not surface backfill daemon errors if there are no backfills', async () => { const daemonHealth = [{daemonType: 'BACKFILL', healthy: false, required: true}]; - const {result, waitForNextUpdate} = renderHook(() => useDaemonStatus(), { + const {result} = renderHook(() => useDaemonStatus(), { wrapper: ({children}: {children: React.ReactNode}) => ( @@ -171,18 +170,17 @@ describe('useDaemonStatus', () => { ), }); - await waitForNextUpdate(); expect(result.current).toBeNull(); }); it('does surface backfill daemon errors if there is a backfill', async () => { const daemonHealth = [{daemonType: 'BACKFILL', healthy: false, required: true}]; - const {result, waitFor} = renderHook(() => useDaemonStatus(), { + const {result} = renderHook(() => useDaemonStatus(), { wrapper: ({children}: {children: React.ReactNode}) => ( @@ -207,11 +205,11 @@ describe('useDaemonStatus', () => { {daemonType: 'BACKFILL', healthy: false, required: true}, ]; - const {result, waitFor} = renderHook(() => useDaemonStatus(), { + const {result} = renderHook(() => useDaemonStatus(), { wrapper: ({children}: {children: React.ReactNode}) => ( ; - -export type CodeLocationStatusQuery = { - __typename: 'Query'; - locationStatusesOrError: - | {__typename: 'PythonError'} - | { - __typename: 'WorkspaceLocationStatusEntries'; - entries: Array<{ - __typename: 'WorkspaceLocationStatusEntry'; - id: string; - name: string; - loadStatus: Types.RepositoryLocationLoadStatus; - }>; - }; -}; diff --git a/js_modules/dagster-ui/packages/ui-core/src/nav/useCodeLocationsStatus.tsx b/js_modules/dagster-ui/packages/ui-core/src/nav/useCodeLocationsStatus.tsx index 907fd21f22570..1a3f0db69db01 100644 --- a/js_modules/dagster-ui/packages/ui-core/src/nav/useCodeLocationsStatus.tsx +++ b/js_modules/dagster-ui/packages/ui-core/src/nav/useCodeLocationsStatus.tsx @@ -1,18 +1,14 @@ -import {gql, useQuery} from '@apollo/client'; import {Box, ButtonLink, Colors} from '@dagster-io/ui-components'; -import {useCallback, useContext, useState} from 'react'; +import {useCallback, useContext, useLayoutEffect, useState} from 'react'; import {useHistory} from 'react-router-dom'; +import {atom, useRecoilValue} from 'recoil'; import styled from 'styled-components'; -import { - CodeLocationStatusQuery, - CodeLocationStatusQueryVariables, -} from './types/useCodeLocationsStatus.types'; import {showSharedToaster} from '../app/DomUtils'; -import {useQueryRefreshAtInterval} from '../app/QueryRefresh'; import {RepositoryLocationLoadStatus} from '../graphql/types'; import {StatusAndMessage} from '../instance/DeploymentStatusType'; import {WorkspaceContext} from '../workspace/WorkspaceContext'; +import {CodeLocationStatusQuery} from '../workspace/types/WorkspaceQueries.types'; type LocationStatusEntry = { loadStatus: RepositoryLocationLoadStatus; @@ -20,12 +16,15 @@ type LocationStatusEntry = { name: string; }; -const POLL_INTERVAL = 5 * 1000; - type EntriesById = Record; -export const useCodeLocationsStatus = (skip = false): StatusAndMessage | null => { - const {locationEntries, refetch} = useContext(WorkspaceContext); +export const codeLocationStatusAtom = atom({ + key: 'codeLocationStatusQuery', + default: undefined, +}); + +export const useCodeLocationsStatus = (): StatusAndMessage | null => { + const {locationEntries, data} = useContext(WorkspaceContext); const [previousEntriesById, setPreviousEntriesById] = useState(null); const history = useHistory(); @@ -37,28 +36,19 @@ export const useCodeLocationsStatus = (skip = false): StatusAndMessage | null => }, [history]); // Reload the workspace, but don't toast about it. - const reloadWorkspaceQuietly = useCallback(async () => { - setShowSpinner(true); - await refetch(); - setShowSpinner(false); - }, [refetch]); // Reload the workspace, and show a success or error toast upon completion. - const reloadWorkspaceLoudly = useCallback(async () => { - setShowSpinner(true); - const result = await refetch(); - setShowSpinner(false); - - const anyErrors = - result.data.workspaceOrError.__typename === 'PythonError' || - result.data.workspaceOrError.locationEntries.some( - (entry) => entry.locationOrLoadError?.__typename === 'PythonError', - ); + useLayoutEffect(() => { + const anyErrors = Object.values(data).some( + (entry) => + entry.__typename === 'PythonError' || + entry.locationOrLoadError?.__typename === 'PythonError', + ); const showViewButton = !alreadyViewingCodeLocations(); if (anyErrors) { - await showSharedToaster({ + showSharedToaster({ intent: 'warning', message: ( @@ -68,31 +58,38 @@ export const useCodeLocationsStatus = (skip = false): StatusAndMessage | null => ), icon: 'check_circle', }); - } else { - await showSharedToaster({ - intent: 'success', - message: ( - -
Definitions reloaded
- {showViewButton ? : null} -
- ), - icon: 'check_circle', - }); } - }, [onClickViewButton, refetch]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [data, onClickViewButton]); + + const codeLocationStatusQueryData = useRecoilValue(codeLocationStatusAtom); - const onLocationUpdate = async (data: CodeLocationStatusQuery) => { + useLayoutEffect(() => { const isFreshPageload = previousEntriesById === null; + const showViewButton = !alreadyViewingCodeLocations(); // Given the previous and current code locations, determine whether to show a) a loading spinner // and/or b) a toast indicating that a code location is being reloaded. const entries = - data?.locationStatusesOrError?.__typename === 'WorkspaceLocationStatusEntries' - ? data?.locationStatusesOrError.entries + codeLocationStatusQueryData?.locationStatusesOrError?.__typename === + 'WorkspaceLocationStatusEntries' + ? codeLocationStatusQueryData?.locationStatusesOrError.entries : []; let hasUpdatedEntries = entries.length !== Object.keys(previousEntriesById || {}).length; + + if (!isFreshPageload && hasUpdatedEntries) { + showSharedToaster({ + intent: 'success', + message: ( + +
Definitions reloaded
+ {showViewButton ? : null} +
+ ), + icon: 'check_circle', + }); + } const currEntriesById: {[key: string]: LocationStatusEntry} = {}; entries.forEach((entry) => { const previousEntry = previousEntriesById && previousEntriesById[entry.id]; @@ -132,12 +129,9 @@ export const useCodeLocationsStatus = (skip = false): StatusAndMessage | null => // At least one code location has been removed. Reload, but don't make a big deal about it // since this was probably done manually. if (previousEntries.length > currentEntries.length) { - reloadWorkspaceQuietly(); return; } - const showViewButton = !alreadyViewingCodeLocations(); - // We have a new entry, and it has already finished loading. Wow! It's surprisingly fast for it // to have finished loading so quickly, but go ahead and indicate that the location has // been added, then reload the workspace. @@ -168,7 +162,7 @@ export const useCodeLocationsStatus = (skip = false): StatusAndMessage | null => return {addedEntries.length} code locations added; }; - await showSharedToaster({ + showSharedToaster({ intent: 'primary', message: ( @@ -179,7 +173,6 @@ export const useCodeLocationsStatus = (skip = false): StatusAndMessage | null => icon: 'add_circle', }); - reloadWorkspaceLoudly(); return; } @@ -192,7 +185,7 @@ export const useCodeLocationsStatus = (skip = false): StatusAndMessage | null => if (!anyPreviouslyLoading && anyCurrentlyLoading) { setShowSpinner(true); - await showSharedToaster({ + showSharedToaster({ intent: 'primary', message: ( @@ -211,30 +204,8 @@ export const useCodeLocationsStatus = (skip = false): StatusAndMessage | null => return; } - - // A location was previously loading, and no longer is. Our workspace is ready. Refetch it. - if (anyPreviouslyLoading && !anyCurrentlyLoading) { - reloadWorkspaceLoudly(); - return; - } - - if (hasUpdatedEntries) { - reloadWorkspaceLoudly(); - return; - } - }; - - const queryData = useQuery( - CODE_LOCATION_STATUS_QUERY, - { - fetchPolicy: 'network-only', - notifyOnNetworkStatusChange: true, - skip, - onCompleted: onLocationUpdate, - }, - ); - - useQueryRefreshAtInterval(queryData, POLL_INTERVAL); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [codeLocationStatusQueryData]); if (showSpinner) { return { @@ -274,17 +245,3 @@ const ViewCodeLocationsButton = ({onClick}: {onClick: () => void}) => { const ViewButton = styled(ButtonLink)` white-space: nowrap; `; - -const CODE_LOCATION_STATUS_QUERY = gql` - query CodeLocationStatusQuery { - locationStatusesOrError { - ... on WorkspaceLocationStatusEntries { - entries { - id - name - loadStatus - } - } - } - } -`; diff --git a/js_modules/dagster-ui/packages/ui-core/src/overview/OverviewSensors.tsx b/js_modules/dagster-ui/packages/ui-core/src/overview/OverviewSensors.tsx index a7b4f3c51bd43..922220e762798 100644 --- a/js_modules/dagster-ui/packages/ui-core/src/overview/OverviewSensors.tsx +++ b/js_modules/dagster-ui/packages/ui-core/src/overview/OverviewSensors.tsx @@ -32,7 +32,6 @@ import {SENSOR_TYPE_META} from '../workspace/VirtualizedSensorRow'; import {WorkspaceContext} from '../workspace/WorkspaceContext'; import {buildRepoAddress} from '../workspace/buildRepoAddress'; import {repoAddressAsHumanString} from '../workspace/repoAddressAsString'; -import {RootWorkspaceQuery} from '../workspace/types/WorkspaceContext.types'; function toSetFilterValue(type: SensorType) { const label = SENSOR_TYPE_META[type].name; @@ -55,12 +54,7 @@ const SENSOR_TYPE_TO_FILTER: Partial { - const { - allRepos, - visibleRepos, - loading: workspaceLoading, - data: cachedData, - } = useContext(WorkspaceContext); + const {allRepos, visibleRepos, loading: workspaceLoading} = useContext(WorkspaceContext); const repoCount = allRepos.length; const [searchValue, setSearchValue] = useQueryPersistedState({ @@ -103,12 +97,7 @@ export const OverviewSensors = () => { notifyOnNetworkStatusChange: true, }, ); - const {data: currentData, loading} = queryResultOverview; - const data = - currentData ?? - (cachedData?.workspaceOrError.__typename === 'Workspace' - ? (cachedData as Extract) - : null); + const {data, loading} = queryResultOverview; useBlockTraceOnQueryResult(queryResultOverview, 'OverviewSensorsQuery'); @@ -340,7 +329,7 @@ export const OverviewSensors = () => { ) : ( <> @@ -351,7 +340,7 @@ export const OverviewSensors = () => { ); }; -const buildBuckets = (data?: null | OverviewSensorsQuery | RootWorkspaceQuery) => { +const buildBuckets = (data?: null | OverviewSensorsQuery) => { if (data?.workspaceOrError.__typename !== 'Workspace') { return []; } diff --git a/js_modules/dagster-ui/packages/ui-core/src/runs/HourlyDataCache/HourlyDataCache.tsx b/js_modules/dagster-ui/packages/ui-core/src/runs/HourlyDataCache/HourlyDataCache.tsx index 992aaccff860e..0995a457a79b3 100644 --- a/js_modules/dagster-ui/packages/ui-core/src/runs/HourlyDataCache/HourlyDataCache.tsx +++ b/js_modules/dagster-ui/packages/ui-core/src/runs/HourlyDataCache/HourlyDataCache.tsx @@ -7,7 +7,7 @@ export const ONE_HOUR_S = 60 * 60; type Subscription = (data: T[]) => void; export const defaultOptions = { - expiry: new Date('3000-01-01'), // never expire, + expiry: new Date('3030-01-01'), // never expire, }; export class HourlyDataCache { diff --git a/js_modules/dagster-ui/packages/ui-core/src/runs/__fixtures__/RunActionsMenu.fixtures.tsx b/js_modules/dagster-ui/packages/ui-core/src/runs/__fixtures__/RunActionsMenu.fixtures.tsx index e0c89d4cccf42..1ba3297465285 100644 --- a/js_modules/dagster-ui/packages/ui-core/src/runs/__fixtures__/RunActionsMenu.fixtures.tsx +++ b/js_modules/dagster-ui/packages/ui-core/src/runs/__fixtures__/RunActionsMenu.fixtures.tsx @@ -7,11 +7,9 @@ import { buildRepositoryLocation, buildRepositoryOrigin, buildRun, - buildWorkspace, buildWorkspaceLocationEntry, } from '../../graphql/types'; -import {ROOT_WORKSPACE_QUERY} from '../../workspace/WorkspaceContext'; -import {RootWorkspaceQuery} from '../../workspace/types/WorkspaceContext.types'; +import {buildWorkspaceMocks} from '../../workspace/__fixtures__/Workspace.fixtures'; import {PIPELINE_ENVIRONMENT_QUERY} from '../RunActionsMenu'; import { PipelineEnvironmentQuery, @@ -57,41 +55,28 @@ export const buildRunActionsMenuFragment = ({hasReExecutePermission}: RunConfigI }); }; -export const buildMockRootWorkspaceQuery = (): MockedResponse => { - return { - request: { - query: ROOT_WORKSPACE_QUERY, - }, - result: { - data: { - __typename: 'Query', - workspaceOrError: buildWorkspace({ - id: 'workspace', - locationEntries: [ - buildWorkspaceLocationEntry({ - id: LOCATION_NAME, - locationOrLoadError: buildRepositoryLocation({ - id: LOCATION_NAME, - repositories: [ - buildRepository({ - id: REPO_NAME, - name: REPO_NAME, - pipelines: [ - buildPipeline({ - id: JOB_NAME, - name: JOB_NAME, - pipelineSnapshotId: SNAPSHOT_ID, - }), - ], - }), - ], +export const buildMockRootWorkspaceQuery = () => { + return buildWorkspaceMocks([ + buildWorkspaceLocationEntry({ + id: LOCATION_NAME, + locationOrLoadError: buildRepositoryLocation({ + id: LOCATION_NAME, + repositories: [ + buildRepository({ + id: REPO_NAME, + name: REPO_NAME, + pipelines: [ + buildPipeline({ + id: JOB_NAME, + name: JOB_NAME, + pipelineSnapshotId: SNAPSHOT_ID, }), - }), - ], - }), - }, - }, - }; + ], + }), + ], + }), + }), + ]); }; export const buildPipelineEnvironmentQuery = ( diff --git a/js_modules/dagster-ui/packages/ui-core/src/runs/__fixtures__/RunsFilterInput.fixtures.tsx b/js_modules/dagster-ui/packages/ui-core/src/runs/__fixtures__/RunsFilterInput.fixtures.tsx index c9691a984b294..86ef39f9f01d8 100644 --- a/js_modules/dagster-ui/packages/ui-core/src/runs/__fixtures__/RunsFilterInput.fixtures.tsx +++ b/js_modules/dagster-ui/packages/ui-core/src/runs/__fixtures__/RunsFilterInput.fixtures.tsx @@ -1,26 +1,10 @@ import {MockedResponse} from '@apollo/client/testing'; -import {WorkspaceOrError, buildPipelineTagAndValues, buildRunTags} from '../../graphql/types'; -import {ROOT_WORKSPACE_QUERY} from '../../workspace/WorkspaceContext'; -import {RootWorkspaceQuery} from '../../workspace/types/WorkspaceContext.types'; +import {buildPipelineTagAndValues, buildRunTags} from '../../graphql/types'; import {DagsterTag} from '../RunTag'; import {RUN_TAG_VALUES_QUERY} from '../RunsFilterInput'; import {RunTagValuesQuery} from '../types/RunsFilterInput.types'; -export const buildWorkspaceContextMockedResponse = ( - workspaceOrError: WorkspaceOrError, -): MockedResponse => ({ - request: { - query: ROOT_WORKSPACE_QUERY, - }, - result: { - data: { - __typename: 'Query', - workspaceOrError, - }, - }, -}); - export function buildRunTagValuesQueryMockedResponse( tagKey: DagsterTag, values: string[], diff --git a/js_modules/dagster-ui/packages/ui-core/src/runs/__tests__/RunActionButtons.test.tsx b/js_modules/dagster-ui/packages/ui-core/src/runs/__tests__/RunActionButtons.test.tsx index f70825b651d47..ae9a8752ee3fc 100644 --- a/js_modules/dagster-ui/packages/ui-core/src/runs/__tests__/RunActionButtons.test.tsx +++ b/js_modules/dagster-ui/packages/ui-core/src/runs/__tests__/RunActionButtons.test.tsx @@ -26,7 +26,7 @@ describe('RunActionButtons', () => { const Test = ({run}: {run: RunPageFragment}) => { return ( - + diff --git a/js_modules/dagster-ui/packages/ui-core/src/runs/__tests__/RunActionsMenu.test.tsx b/js_modules/dagster-ui/packages/ui-core/src/runs/__tests__/RunActionsMenu.test.tsx index 48e0c83b49c39..b171bc746a8fa 100644 --- a/js_modules/dagster-ui/packages/ui-core/src/runs/__tests__/RunActionsMenu.test.tsx +++ b/js_modules/dagster-ui/packages/ui-core/src/runs/__tests__/RunActionsMenu.test.tsx @@ -17,7 +17,7 @@ describe('RunActionsMenu', () => { render( @@ -45,7 +45,7 @@ describe('RunActionsMenu', () => { render( diff --git a/js_modules/dagster-ui/packages/ui-core/src/runs/__tests__/RunFilterInput.test.tsx b/js_modules/dagster-ui/packages/ui-core/src/runs/__tests__/RunFilterInput.test.tsx index 49124ba600e2c..6758f47510328 100644 --- a/js_modules/dagster-ui/packages/ui-core/src/runs/__tests__/RunFilterInput.test.tsx +++ b/js_modules/dagster-ui/packages/ui-core/src/runs/__tests__/RunFilterInput.test.tsx @@ -9,11 +9,11 @@ import { buildRepository, buildRepositoryLocation, buildRunTagKeys, - buildWorkspace, buildWorkspaceLocationEntry, } from '../../graphql/types'; import {calculateTimeRanges} from '../../ui/Filters/useTimeRangeFilter'; import {WorkspaceProvider} from '../../workspace/WorkspaceContext'; +import {buildWorkspaceMocks} from '../../workspace/__fixtures__/Workspace.fixtures'; import {DagsterTag} from '../RunTag'; import { RUN_TAG_KEYS_QUERY, @@ -24,34 +24,27 @@ import { useRunsFilterInput, useTagDataFilterValues, } from '../RunsFilterInput'; -import { - buildRunTagValuesQueryMockedResponse, - buildWorkspaceContextMockedResponse, -} from '../__fixtures__/RunsFilterInput.fixtures'; +import {buildRunTagValuesQueryMockedResponse} from '../__fixtures__/RunsFilterInput.fixtures'; import {RunTagKeysQuery} from '../types/RunsFilterInput.types'; -const workspaceMock = buildWorkspaceContextMockedResponse( - buildWorkspace({ - locationEntries: [ - buildWorkspaceLocationEntry({ - name: 'some_workspace', - locationOrLoadError: buildRepositoryLocation({ - name: 'some_location', - repositories: [ - buildRepository({ - name: 'some_repo', - pipelines: [ - buildPipeline({ - name: 'some_job', - }), - ], +const workspaceMocks = buildWorkspaceMocks([ + buildWorkspaceLocationEntry({ + name: 'some_workspace', + locationOrLoadError: buildRepositoryLocation({ + name: 'some_location', + repositories: [ + buildRepository({ + name: 'some_repo', + pipelines: [ + buildPipeline({ + name: 'some_job', }), ], }), - }), - ], + ], + }), }), -); +]); const runTagKeysMock: MockedResponse = { request: { @@ -168,7 +161,7 @@ function TestRunsFilterInput({ ); } return ( - + @@ -219,7 +212,7 @@ describe('', () => { tokens={tokens} onChange={onChange} enabledFilters={['job']} - mocks={[workspaceMock]} + mocks={workspaceMocks} />, ); diff --git a/js_modules/dagster-ui/packages/ui-core/src/search/__tests__/useIndexedDBCachedQuery.test.tsx b/js_modules/dagster-ui/packages/ui-core/src/search/__tests__/useIndexedDBCachedQuery.test.tsx index 2529134c9ab4d..747099649b26b 100644 --- a/js_modules/dagster-ui/packages/ui-core/src/search/__tests__/useIndexedDBCachedQuery.test.tsx +++ b/js_modules/dagster-ui/packages/ui-core/src/search/__tests__/useIndexedDBCachedQuery.test.tsx @@ -24,6 +24,7 @@ jest.mock('idb-lru-cache', () => { has: jest.fn(), get: jest.fn(), set: jest.fn(), + delete: jest.fn(), }; return { cache: jest.fn(() => mockedCache), diff --git a/js_modules/dagster-ui/packages/ui-core/src/search/useIndexedDBCachedQuery.tsx b/js_modules/dagster-ui/packages/ui-core/src/search/useIndexedDBCachedQuery.tsx index eaab4a86bb66d..16d016d6b9804 100644 --- a/js_modules/dagster-ui/packages/ui-core/src/search/useIndexedDBCachedQuery.tsx +++ b/js_modules/dagster-ui/packages/ui-core/src/search/useIndexedDBCachedQuery.tsx @@ -1,118 +1,195 @@ -import {ApolloQueryResult, DocumentNode, OperationVariables, useApolloClient} from '@apollo/client'; +import {ApolloClient, DocumentNode, OperationVariables, useApolloClient} from '@apollo/client'; import {cache} from 'idb-lru-cache'; -import React from 'react'; +import memoize from 'lodash/memoize'; +import React, {createContext, useCallback, useContext} from 'react'; type CacheData = { data: TQuery; version: number; }; -let fetchState: Record< - string, - { - onFetched: ((value: any) => void)[]; +export const KEY_PREFIX = 'indexdbQueryCache:'; + +export class CacheManager { + private cache: ReturnType>>; + private key: string; + + constructor(key: string) { + this.key = `${KEY_PREFIX}${key}`; + this.cache = cache>({dbName: this.key, maxCount: 1}); + } + + async get(version: number): Promise { + if (await this.cache.has('cache')) { + const {value} = await this.cache.get('cache'); + if (value && version === value.version) { + return value.data; + } + } + return null; + } + + async set(data: TQuery, version: number): Promise { + return this.cache.set('cache', {data, version}, {expiry: new Date('3030-01-01')}); + } + + async clear() { + await this.cache.delete('cache'); } -> = {}; +} + +interface QueryHookParams { + key: string; + query: DocumentNode; + version: number; + variables?: TVariables; + onCompleted?: (data: TQuery) => void; +} -/** - * Returns data from the indexedDB cache initially while loading is true. - * Fetches data from the network/cache initially and does not receive any updates afterwards - * Uses fetch-policy: no-cache to avoid slow apollo cache normalization - */ export function useIndexedDBCachedQuery({ key, query, version, variables, -}: { - key: string; - query: DocumentNode; - version: number; - variables?: TVariables; -}) { +}: QueryHookParams) { const client = useApolloClient(); + const [data, setData] = React.useState(null); + const [loading, setLoading] = React.useState(true); + + const getData = useGetData(); - const lru = React.useMemo( - () => cache>({dbName: `indexdbQueryCache:${key}`, maxCount: 1}), - [key], + const fetch = useCallback( + async (bypassCache = false) => { + setLoading(true); + const newData = await getData({ + client, + key, + query, + variables, + version, + bypassCache, + }); + setData(newData); + setLoading(false); + }, + [getData, client, key, query, variables, version], ); - const [data, setData] = React.useState(null); + React.useEffect(() => { + fetch(); + }, [fetch]); - const [loading, setLoading] = React.useState(false); - const [cacheBreaker, setCacheBreaker] = React.useState(0); + return { + data, + loading, + fetch: useCallback(() => fetch(true), [fetch]), + }; +} - React.useEffect(() => { - (async () => { - if (await lru.has('cache')) { - const {value} = await lru.get('cache'); - if (value) { - if (version === (value.version || null)) { - setData(value.data); - } +interface FetchParams { + client: ApolloClient; + key: string; + query: DocumentNode; + variables?: TVariables; + version: number; + bypassCache?: boolean; +} + +export function useGetData() { + const {getCacheManager, fetchState} = useContext(IndexedDBCacheContext); + + return useCallback( + async ({ + client, + key, + query, + variables, + version, + bypassCache = false, + }: FetchParams): Promise => { + const cacheManager = getCacheManager(key); + + if (!bypassCache) { + const cachedData = await cacheManager.get(version); + if (cachedData !== null) { + return cachedData; } } - })(); - }, [lru, version, cacheBreaker]); - - const fetch = React.useCallback(async () => { - setLoading(true); - if (fetchState[key]) { - return await new Promise>((res) => { - fetchState[key]?.onFetched.push((value) => { - setCacheBreaker((v) => v + 1); - res(value); + + const currentState = fetchState[key]; + // Handle concurrent fetch requests + if (currentState) { + return new Promise((resolve) => { + currentState!.onFetched.push(resolve as any); }); + } + + const state = {onFetched: [] as ((value: any) => void)[]}; + fetchState[key] = state; + + const queryResult = await client.query({ + query, + variables, + fetchPolicy: 'no-cache', }); - } - fetchState[key] = {onFetched: []}; - // Use client.query here so that we initially use the apollo cache if any data is available in it - // and so that we don't subscribe to any updates to that cache (useLazyQuery and useQuery would both subscribe to updates to the - // cache which can be very slow) - const queryResult = await client.query({ - query, - variables, - fetchPolicy: 'no-cache', // Don't store the result in the cache, - // should help avoid page stuttering due to granular updates to the data - }); - const {data} = queryResult; - setLoading(false); - lru.set( - 'cache', - {data, version}, - { - expiry: new Date('3000-01-01'), // never expire, - }, - ); - delete fetchState[key]; - const onFetched = fetchState[key]?.onFetched; - try { - setData(data); - } catch (e) { - setTimeout(() => { - throw e; - }); - } - onFetched?.forEach((cb) => { - try { - cb(queryResult); - } catch (e) { - setTimeout(() => { - throw e; - }); + + const {data} = queryResult; + await cacheManager.set(data, version); + + const onFetchedHandlers = state.onFetched; + if (fetchState[key] === state) { + delete fetchState[key]; // Clean up fetch state after handling } - }); - return queryResult; - }, [client, key, lru, query, variables, version]); + onFetchedHandlers.forEach((handler) => handler(data)); // Notify all waiting fetches + return data; + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [], + ); +} + +export function useGetCachedData() { + const {getCacheManager} = useContext(IndexedDBCacheContext); + + return useCallback( + async ({key, version}: {key: string; version: number}) => { + const cacheManager = getCacheManager(key); + return await cacheManager.get(version); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [], + ); +} +export function useClearCachedData() { + const {getCacheManager} = useContext(IndexedDBCacheContext); + return useCallback( + async ({key}: {key: string}) => { + const cacheManager = getCacheManager(key); + await cacheManager.clear(); + }, + [getCacheManager], + ); +} + +const contextValue = createIndexedDBCacheContextValue(); +export const IndexedDBCacheContext = createContext(contextValue); + +export function createIndexedDBCacheContextValue() { return { - fetch, - data, - loading, + getCacheManager: memoize((key: string) => { + return new CacheManager(key); + }), + fetchState: {} as Record< + string, + { + onFetched: ((value: any) => void)[]; + } + >, }; } export const __resetForJest = () => { - fetchState = {}; + Object.assign(contextValue, createIndexedDBCacheContextValue()); }; diff --git a/js_modules/dagster-ui/packages/ui-core/src/workspace/CodeLocationRowSet.tsx b/js_modules/dagster-ui/packages/ui-core/src/workspace/CodeLocationRowSet.tsx index 84c4a300e8935..0f684461839a6 100644 --- a/js_modules/dagster-ui/packages/ui-core/src/workspace/CodeLocationRowSet.tsx +++ b/js_modules/dagster-ui/packages/ui-core/src/workspace/CodeLocationRowSet.tsx @@ -4,23 +4,16 @@ import { ButtonLink, Colors, Icon, - JoinedButtons, MiddleTruncate, Tag, Tooltip, } from '@dagster-io/ui-components'; import {useCallback, useMemo, useState} from 'react'; -import {Link} from 'react-router-dom'; import styled from 'styled-components'; -import {CodeLocationMenu} from './CodeLocationMenu'; -import {RepositoryCountTags} from './RepositoryCountTags'; import {RepositoryLocationNonBlockingErrorDialog} from './RepositoryLocationErrorDialog'; import {WorkspaceRepositoryLocationNode} from './WorkspaceContext'; -import {buildRepoAddress} from './buildRepoAddress'; -import {repoAddressAsHumanString} from './repoAddressAsString'; -import {WorkspaceDisplayMetadataFragment} from './types/WorkspaceContext.types'; -import {workspacePathFromAddress} from './workspacePath'; +import {WorkspaceDisplayMetadataFragment} from './types/WorkspaceQueries.types'; import {showSharedToaster} from '../app/DomUtils'; import {useCopyToClipboard} from '../app/browser'; import { @@ -31,81 +24,6 @@ import { buildReloadFnForLocation, useRepositoryLocationReload, } from '../nav/useRepositoryLocationReload'; -import {TimeFromNow} from '../ui/TimeFromNow'; - -interface Props { - locationNode: WorkspaceRepositoryLocationNode; -} - -export const CodeLocationRowSet = ({locationNode}: Props) => { - const {name, locationOrLoadError} = locationNode; - - if (!locationOrLoadError || locationOrLoadError?.__typename === 'PythonError') { - return ( - - - - - - - - - - - {'\u2013'} - - - - - - - - ); - } - - const repositories = [...locationOrLoadError.repositories].sort((a, b) => - a.name.localeCompare(b.name), - ); - - return ( - <> - {repositories.map((repository) => { - const repoAddress = buildRepoAddress(repository.name, name); - const allMetadata = [...locationNode.displayMetadata, ...repository.displayMetadata]; - return ( - - - -
- - - -
- - -
- - - - - - - - - - - - - - - - - - ); - })} - - ); -}; export const ImageName = ({metadata}: {metadata: WorkspaceDisplayMetadataFragment[]}) => { const copy = useCopyToClipboard(); diff --git a/js_modules/dagster-ui/packages/ui-core/src/workspace/VirtualizedCodeLocationRow.tsx b/js_modules/dagster-ui/packages/ui-core/src/workspace/VirtualizedCodeLocationRow.tsx index 7b3b8cbc90ac6..109e95b561d7a 100644 --- a/js_modules/dagster-ui/packages/ui-core/src/workspace/VirtualizedCodeLocationRow.tsx +++ b/js_modules/dagster-ui/packages/ui-core/src/workspace/VirtualizedCodeLocationRow.tsx @@ -12,7 +12,7 @@ import {repoAddressAsHumanString} from './repoAddressAsString'; import { WorkspaceLocationNodeFragment, WorkspaceRepositoryFragment, -} from './types/WorkspaceContext.types'; +} from './types/WorkspaceQueries.types'; import {workspacePathFromAddress} from './workspacePath'; import {TimeFromNow} from '../ui/TimeFromNow'; import {HeaderCell, HeaderRow, RowCell} from '../ui/VirtualizedTable'; diff --git a/js_modules/dagster-ui/packages/ui-core/src/workspace/WorkspaceContext.tsx b/js_modules/dagster-ui/packages/ui-core/src/workspace/WorkspaceContext.tsx index 89f3b359a3d0a..57c69bd6be8c0 100644 --- a/js_modules/dagster-ui/packages/ui-core/src/workspace/WorkspaceContext.tsx +++ b/js_modules/dagster-ui/packages/ui-core/src/workspace/WorkspaceContext.tsx @@ -1,30 +1,40 @@ -import {ApolloQueryResult, gql} from '@apollo/client'; +import {useApolloClient} from '@apollo/client'; import sortBy from 'lodash/sortBy'; -import * as React from 'react'; -import {useContext, useMemo} from 'react'; +import React, {useCallback, useContext, useLayoutEffect, useMemo, useRef, useState} from 'react'; +import {useSetRecoilState} from 'recoil'; -import {REPOSITORY_INFO_FRAGMENT} from './RepositoryInformation'; +import {CODE_LOCATION_STATUS_QUERY, LOCATION_WORKSPACE_QUERY} from './WorkspaceQueries'; import {buildRepoAddress} from './buildRepoAddress'; import {findRepoContainingPipeline} from './findRepoContainingPipeline'; import {RepoAddress} from './types'; import { - RootWorkspaceQuery, - RootWorkspaceQueryVariables, + CodeLocationStatusQuery, + CodeLocationStatusQueryVariables, + LocationWorkspaceQuery, + LocationWorkspaceQueryVariables, WorkspaceLocationFragment, WorkspaceLocationNodeFragment, WorkspaceRepositoryFragment, WorkspaceScheduleFragment, WorkspaceSensorFragment, -} from './types/WorkspaceContext.types'; +} from './types/WorkspaceQueries.types'; import {AppContext} from '../app/AppContext'; -import {PYTHON_ERROR_FRAGMENT} from '../app/PythonErrorFragment'; +import {useRefreshAtInterval} from '../app/QueryRefresh'; import {PythonErrorFragment} from '../app/types/PythonErrorFragment.types'; import {PipelineSelector} from '../graphql/types'; import {useStateWithStorage} from '../hooks/useStateWithStorage'; -import {BASIC_INSTIGATION_STATE_FRAGMENT} from '../overview/BasicInstigationStateFragment'; -import {useIndexedDBCachedQuery} from '../search/useIndexedDBCachedQuery'; -import {SENSOR_SWITCH_FRAGMENT} from '../sensors/SensorSwitch'; - +import {useUpdatingRef} from '../hooks/useUpdatingRef'; +import {codeLocationStatusAtom} from '../nav/useCodeLocationsStatus'; +import { + useClearCachedData, + useGetCachedData, + useGetData, + useIndexedDBCachedQuery, +} from '../search/useIndexedDBCachedQuery'; + +export const CODE_LOCATION_STATUS_QUERY_KEY = '/CodeLocationStatusQuery'; +export const CODE_LOCATION_STATUS_QUERY_VERSION = 1; +export const LOCATION_WORKSPACE_QUERY_VERSION = 1; type Repository = WorkspaceRepositoryFragment; type RepositoryLocation = WorkspaceLocationFragment; @@ -40,14 +50,13 @@ export interface DagsterRepoOption { type SetVisibleOrHiddenFn = (repoAddresses: RepoAddress[]) => void; type WorkspaceState = { - error: PythonErrorFragment | null; loading: boolean; locationEntries: WorkspaceRepositoryLocationNode[]; allRepos: DagsterRepoOption[]; visibleRepos: DagsterRepoOption[]; - data: RootWorkspaceQuery | null; + data: Record; + refetch: () => Promise; - refetch: () => Promise>; toggleVisible: SetVisibleOrHiddenFn; setVisible: SetVisibleOrHiddenFn; setHidden: SetVisibleOrHiddenFn; @@ -59,180 +68,216 @@ export const WorkspaceContext = React.createContext( export const HIDDEN_REPO_KEYS = 'dagster.hidden-repo-keys'; -export const ROOT_WORKSPACE_QUERY = gql` - query RootWorkspaceQuery { - workspaceOrError { - ... on Workspace { - id - locationEntries { - id - ...WorkspaceLocationNode - } +export const WorkspaceProvider = ({children}: {children: React.ReactNode}) => { + const {localCacheIdPrefix} = useContext(AppContext); + const codeLocationStatusQueryResult = useIndexedDBCachedQuery< + CodeLocationStatusQuery, + CodeLocationStatusQueryVariables + >({ + query: CODE_LOCATION_STATUS_QUERY, + version: CODE_LOCATION_STATUS_QUERY_VERSION, + key: `${localCacheIdPrefix}${CODE_LOCATION_STATUS_QUERY_KEY}`, + }); + if (typeof jest === 'undefined') { + // Only do this outside of jest for now so that we don't need to add RecoilRoot around everything... + // we will switch to jotai at some point instead... which doesnt require a + // eslint-disable-next-line react-hooks/rules-of-hooks + const setCodeLocationStatusAtom = useSetRecoilState(codeLocationStatusAtom); + // eslint-disable-next-line react-hooks/rules-of-hooks + useLayoutEffect(() => { + if (codeLocationStatusQueryResult.data) { + setCodeLocationStatusAtom(codeLocationStatusQueryResult.data); } - ...PythonErrorFragment - } + }, [codeLocationStatusQueryResult.data, setCodeLocationStatusAtom]); } + indexedDB.deleteDatabase('indexdbQueryCache:RootWorkspace'); - fragment WorkspaceLocationNode on WorkspaceLocationEntry { - id - name - loadStatus - displayMetadata { - ...WorkspaceDisplayMetadata - } - updatedTimestamp - featureFlags { - name - enabled - } - locationOrLoadError { - ... on RepositoryLocation { - id - ...WorkspaceLocation - } - ...PythonErrorFragment - } - } + const fetch = codeLocationStatusQueryResult.fetch; + useRefreshAtInterval({ + refresh: useCallback(async () => { + return await fetch(); + }, [fetch]), + intervalMs: 5000, + leading: true, + }); - fragment WorkspaceDisplayMetadata on RepositoryMetadata { - key - value - } + const {data, loading: loadingCodeLocationStatus} = codeLocationStatusQueryResult; - fragment WorkspaceLocation on RepositoryLocation { - id - isReloadSupported - serverId - name - dagsterLibraryVersions { - name - version - } - repositories { - id - ...WorkspaceRepository - } - } + const locations = useMemo(() => getLocations(data), [data]); + const prevLocations = useRef({}); - fragment WorkspaceRepository on Repository { - id - name - pipelines { - id - name - isJob - isAssetJob - pipelineSnapshotId - } - schedules { - id - ...WorkspaceSchedule - } - sensors { - id - ...WorkspaceSensor - } - partitionSets { - id - mode - pipelineName + const didInitiateFetchFromCache = useRef(false); + const [didLoadCachedData, setDidLoadCachedData] = useState(false); + + const [locationsData, setLocationsData] = React.useState< + Record + >({}); + + const getCachedData = useGetCachedData(); + const getData = useGetData(); + const clearCachedData = useClearCachedData(); + + useLayoutEffect(() => { + // Load data from the cache + if (didInitiateFetchFromCache.current) { + return; } - assetGroups { - id - groupName + didInitiateFetchFromCache.current = true; + new Promise(async (res) => { + /** + * 1. Load the cached code location status query + * 2. Load the cached data for those locations + * 3. Set the cached data to `locationsData` state + * 4. Set prevLocations equal to these cached locations so that we can check if they + * have changed after the next call to codeLocationStatusQuery + * 5. set didLoadCachedData to true to unblock the `locationsToFetch` memo so that it can compare + * the latest codeLocationStatusQuery result to what was in the cache. + */ + const data = await getCachedData({ + key: `${localCacheIdPrefix}${CODE_LOCATION_STATUS_QUERY_KEY}`, + version: CODE_LOCATION_STATUS_QUERY_VERSION, + }); + const cachedLocations = getLocations(data); + const prevCachedLocations: typeof locations = {}; + + await Promise.all([ + ...Object.values(cachedLocations).map(async (location) => { + const locationData = await getCachedData({ + key: `${localCacheIdPrefix}${locationWorkspaceKey(location.name)}`, + version: LOCATION_WORKSPACE_QUERY_VERSION, + }); + const entry = locationData?.workspaceLocationEntryOrError; + + if (!entry) { + return; + } + setLocationsData((locationsData) => + Object.assign({}, locationsData, { + [location.name]: entry, + }), + ); + + if (entry.__typename === 'WorkspaceLocationEntry') { + prevCachedLocations[location.name] = location; + } + }), + ]); + prevLocations.current = prevCachedLocations; + res(void 0); + }).then(() => { + setDidLoadCachedData(true); + }); + }, [getCachedData, localCacheIdPrefix, locations]); + + const client = useApolloClient(); + + const refetchLocation = useCallback( + async (name: string) => { + const locationData = await getData({ + client, + query: LOCATION_WORKSPACE_QUERY, + key: `${localCacheIdPrefix}${locationWorkspaceKey(name)}`, + version: LOCATION_WORKSPACE_QUERY_VERSION, + variables: { + name, + }, + bypassCache: true, + }); + const entry = locationData?.workspaceLocationEntryOrError; + setLocationsData((locationsData) => + Object.assign({}, locationsData, { + [name]: entry, + }), + ); + return locationData; + }, + [client, getData, localCacheIdPrefix], + ); + + const [isRefetching, setIsRefetching] = useState(false); + + const locationsToFetch = useMemo(() => { + if (!didLoadCachedData) { + return []; } - allTopLevelResourceDetails { - id - name + if (isRefetching) { + return []; } - ...RepositoryInfoFragment - } - - fragment WorkspaceSchedule on Schedule { - id - cronSchedule - executionTimezone - mode - name - pipelineName - scheduleState { - id - selectorId - status + const toFetch = Object.values(locations).filter((loc) => { + const prev = prevLocations.current?.[loc.name]; + const d = locationsData[loc.name]; + const entry = d?.__typename === 'WorkspaceLocationEntry' ? d : null; + return ( + prev?.updateTimestamp !== loc.updateTimestamp || + prev?.loadStatus !== loc.loadStatus || + entry?.loadStatus !== loc.loadStatus + ); + }); + prevLocations.current = locations; + return toFetch; + }, [didLoadCachedData, isRefetching, locations, locationsData]); + + useLayoutEffect(() => { + if (!locationsToFetch.length) { + return; } - } + setIsRefetching(true); + Promise.all( + locationsToFetch.map(async (location) => { + return await refetchLocation(location.name); + }), + ).then(() => { + setIsRefetching(false); + }); + }, [refetchLocation, locationsToFetch]); + + const locationsRemoved = useMemo( + () => + Array.from( + new Set([ + ...Object.values(prevLocations.current).filter((loc) => !locations[loc.name]), + ...Object.values(locationsData).filter( + (loc): loc is WorkspaceLocationNodeFragment => + loc.__typename === 'WorkspaceLocationEntry' && !locations[loc.name], + ), + ]), + ), + [locations, locationsData], + ); - fragment WorkspaceSensor on Sensor { - id - jobOriginId - name - targets { - mode - pipelineName + useLayoutEffect(() => { + if (!locationsRemoved.length) { + return; } - sensorState { - id - selectorId - status - ...BasicInstigationStateFragment + const copy = {...locationsData}; + locationsRemoved.forEach((loc) => { + delete copy[loc.name]; + clearCachedData({key: `${localCacheIdPrefix}${locationWorkspaceKey(loc.name)}`}); + }); + if (Object.keys(copy).length !== Object.keys(locationsData).length) { + setLocationsData(copy); } - sensorType - ...SensorSwitchFragment - } - - ${PYTHON_ERROR_FRAGMENT} - ${REPOSITORY_INFO_FRAGMENT} - ${SENSOR_SWITCH_FRAGMENT} - ${BASIC_INSTIGATION_STATE_FRAGMENT} -`; - -/** - * A hook that supplies the current workspace state of Dagster UI, including the current - * "active" repo based on the URL or localStorage, all fetched repositories available - * in the workspace, and loading/error state for the relevant query. - */ -const useWorkspaceState = (): WorkspaceState => { - const {localCacheIdPrefix} = useContext(AppContext); - const { - data, - loading, - fetch: refetch, - } = useIndexedDBCachedQuery({ - query: ROOT_WORKSPACE_QUERY, - key: `${localCacheIdPrefix}/RootWorkspace`, - version: 1, - }); - - // Delete old database from before the prefix, remove this at some point - indexedDB.deleteDatabase('indexdbQueryCache:RootWorkspace'); - useMemo(() => refetch(), [refetch]); - - const workspaceOrError = data?.workspaceOrError; - - const locationEntries = React.useMemo( - () => (workspaceOrError?.__typename === 'Workspace' ? workspaceOrError.locationEntries : []), - [workspaceOrError], + }, [clearCachedData, localCacheIdPrefix, locationsData, locationsRemoved]); + + const locationEntries = useMemo( + () => + Object.values(locationsData).filter( + (entry): entry is WorkspaceLocationNodeFragment => + entry.__typename === 'WorkspaceLocationEntry', + ), + [locationsData], ); - const {allRepos, error} = React.useMemo(() => { + const allRepos = React.useMemo(() => { let allRepos: DagsterRepoOption[] = []; - if (!workspaceOrError) { - return {allRepos, error: null}; - } - - if (workspaceOrError.__typename === 'PythonError') { - return {allRepos, error: workspaceOrError}; - } allRepos = sortBy( - workspaceOrError.locationEntries.reduce((accum, locationEntry) => { + locationEntries.reduce((accum, locationEntry) => { if (locationEntry.locationOrLoadError?.__typename !== 'RepositoryLocation') { return accum; } const repositoryLocation = locationEntry.locationOrLoadError; - const reposForLocation = repositoryLocation.repositories.map((repository) => { - return {repository, repositoryLocation}; - }); + const reposForLocation = repoLocationToRepos(repositoryLocation); return [...accum, ...reposForLocation]; }, [] as DagsterRepoOption[]), @@ -240,25 +285,60 @@ const useWorkspaceState = (): WorkspaceState => { (r) => `${r.repositoryLocation.name}:${r.repository.name}`, ); - return {error: null, allRepos}; - }, [workspaceOrError]); + return allRepos; + }, [locationEntries]); const {visibleRepos, toggleVisible, setVisible, setHidden} = useVisibleRepos(allRepos); - return { - refetch, - loading: loading && !data, // Only "loading" on initial load. - error, - locationEntries, - data, - allRepos, - visibleRepos, - toggleVisible, - setVisible, - setHidden, - }; + const locationsRef = useUpdatingRef(locations); + + const refetch = useCallback(async () => { + return await Promise.all( + Object.values(locationsRef.current).map((location) => refetchLocation(location.name)), + ); + }, [locationsRef, refetchLocation]); + + return ( + + {children} + + ); }; +function getLocations(d: CodeLocationStatusQuery | undefined | null) { + const locations = + d?.locationStatusesOrError?.__typename === 'WorkspaceLocationStatusEntries' + ? d?.locationStatusesOrError.entries + : []; + + return locations.reduce( + (accum, loc) => { + accum[loc.name] = loc; + return accum; + }, + {} as Record, + ); +} + +export function locationWorkspaceKey(name: string) { + return `/LocationWorkspace/${name}`; +} + /** * useVisibleRepos returns `{reposForKeys, toggleVisible, setVisible, setHidden}` and internally * mirrors the current selection into localStorage so that the default selection in new browser @@ -355,16 +435,9 @@ const useVisibleRepos = ( const getRepositoryOptionHash = (a: DagsterRepoOption) => `${a.repository.name}:${a.repositoryLocation.name}`; - -export const WorkspaceProvider = ({children}: {children: React.ReactNode}) => { - const workspaceState = useWorkspaceState(); - - return {children}; -}; - export const useRepositoryOptions = () => { - const {allRepos: options, loading, error} = React.useContext(WorkspaceContext); - return {options, loading, error}; + const {allRepos: options, loading} = React.useContext(WorkspaceContext); + return {options, loading}; }; export const useRepository = (repoAddress: RepoAddress | null) => { @@ -412,7 +485,7 @@ export const getFeatureFlagForCodeLocation = ( }; export const useFeatureFlagForCodeLocation = (locationName: string, flagName: string) => { - const {locationEntries} = useWorkspaceState(); + const {locationEntries} = useContext(WorkspaceContext); return getFeatureFlagForCodeLocation(locationEntries, locationName, flagName); }; @@ -452,3 +525,9 @@ export const buildPipelineSelector = ( export const optionToRepoAddress = (option: DagsterRepoOption) => buildRepoAddress(option.repository.name, option.repository.location.name); + +export function repoLocationToRepos(repositoryLocation: RepositoryLocation) { + return repositoryLocation.repositories.map((repository) => { + return {repository, repositoryLocation}; + }); +} diff --git a/js_modules/dagster-ui/packages/ui-core/src/workspace/WorkspaceQueries.tsx b/js_modules/dagster-ui/packages/ui-core/src/workspace/WorkspaceQueries.tsx new file mode 100644 index 0000000000000..cc253de00c2b2 --- /dev/null +++ b/js_modules/dagster-ui/packages/ui-core/src/workspace/WorkspaceQueries.tsx @@ -0,0 +1,140 @@ +import {gql} from '@apollo/client'; + +import {REPOSITORY_INFO_FRAGMENT} from './RepositoryInformation'; +import {PYTHON_ERROR_FRAGMENT} from '../app/PythonErrorFragment'; +import {BASIC_INSTIGATION_STATE_FRAGMENT} from '../overview/BasicInstigationStateFragment'; +import {SENSOR_SWITCH_FRAGMENT} from '../sensors/SensorSwitch'; + +export const LOCATION_WORKSPACE_QUERY = gql` + query LocationWorkspaceQuery($name: String!) { + workspaceLocationEntryOrError(name: $name) { + ...WorkspaceLocationNode + } + } + + fragment WorkspaceLocationNode on WorkspaceLocationEntry { + id + name + loadStatus + displayMetadata { + ...WorkspaceDisplayMetadata + } + updatedTimestamp + featureFlags { + name + enabled + } + locationOrLoadError { + ... on RepositoryLocation { + id + ...WorkspaceLocation + } + ...PythonErrorFragment + } + } + + fragment WorkspaceDisplayMetadata on RepositoryMetadata { + key + value + } + + fragment WorkspaceLocation on RepositoryLocation { + id + isReloadSupported + serverId + name + dagsterLibraryVersions { + name + version + } + repositories { + id + ...WorkspaceRepository + } + } + + fragment WorkspaceRepository on Repository { + id + name + pipelines { + id + name + isJob + isAssetJob + pipelineSnapshotId + } + schedules { + id + ...WorkspaceSchedule + } + sensors { + id + ...WorkspaceSensor + } + partitionSets { + id + mode + pipelineName + } + assetGroups { + id + groupName + } + allTopLevelResourceDetails { + id + name + } + ...RepositoryInfoFragment + } + + fragment WorkspaceSchedule on Schedule { + id + cronSchedule + executionTimezone + mode + name + pipelineName + scheduleState { + id + selectorId + status + } + } + + fragment WorkspaceSensor on Sensor { + id + jobOriginId + name + targets { + mode + pipelineName + } + sensorState { + id + selectorId + status + ...BasicInstigationStateFragment + } + sensorType + ...SensorSwitchFragment + } + ${BASIC_INSTIGATION_STATE_FRAGMENT} + ${SENSOR_SWITCH_FRAGMENT} + ${PYTHON_ERROR_FRAGMENT} + ${REPOSITORY_INFO_FRAGMENT} +`; + +export const CODE_LOCATION_STATUS_QUERY = gql` + query CodeLocationStatusQuery { + locationStatusesOrError { + ... on WorkspaceLocationStatusEntries { + entries { + id + name + loadStatus + updateTimestamp + } + } + } + } +`; diff --git a/js_modules/dagster-ui/packages/ui-core/src/workspace/__fixtures__/Workspace.fixtures.ts b/js_modules/dagster-ui/packages/ui-core/src/workspace/__fixtures__/Workspace.fixtures.ts new file mode 100644 index 0000000000000..a90580d1959a7 --- /dev/null +++ b/js_modules/dagster-ui/packages/ui-core/src/workspace/__fixtures__/Workspace.fixtures.ts @@ -0,0 +1,62 @@ +import {MockedResponse} from '@apollo/client/testing'; + +import { + WorkspaceLocationEntry, + WorkspaceLocationStatusEntry, + buildWorkspaceLocationStatusEntries, + buildWorkspaceLocationStatusEntry, +} from '../../graphql/types'; +import {buildQueryMock} from '../../testing/mocking'; +import {CODE_LOCATION_STATUS_QUERY, LOCATION_WORKSPACE_QUERY} from '../WorkspaceQueries'; +import { + CodeLocationStatusQuery, + CodeLocationStatusQueryVariables, + LocationWorkspaceQuery, + LocationWorkspaceQueryVariables, +} from '../types/WorkspaceQueries.types'; + +export const buildCodeLocationsStatusQuery = ( + entries: WorkspaceLocationStatusEntry[], + options: Partial> = {}, +): MockedResponse => { + return buildQueryMock({ + query: CODE_LOCATION_STATUS_QUERY, + variables: {}, + data: { + locationStatusesOrError: buildWorkspaceLocationStatusEntries({ + entries, + }), + }, + ...options, + }); +}; + +export const buildWorkspaceMocks = ( + entries: WorkspaceLocationEntry[], + options: Partial> = {}, +) => { + return [ + buildCodeLocationsStatusQuery( + entries.map((entry) => + buildWorkspaceLocationStatusEntry({ + ...entry, + updateTimestamp: entry.updatedTimestamp, + __typename: 'WorkspaceLocationStatusEntry', + }), + ), + options, + ), + ...entries.map((entry) => + buildQueryMock({ + query: LOCATION_WORKSPACE_QUERY, + variables: { + name: entry.name, + }, + data: { + workspaceLocationEntryOrError: entry, + }, + ...options, + }), + ), + ]; +}; diff --git a/js_modules/dagster-ui/packages/ui-core/src/workspace/__tests__/WorkspaceContext.test.tsx b/js_modules/dagster-ui/packages/ui-core/src/workspace/__tests__/WorkspaceContext.test.tsx new file mode 100644 index 0000000000000..e0d47c18667ac --- /dev/null +++ b/js_modules/dagster-ui/packages/ui-core/src/workspace/__tests__/WorkspaceContext.test.tsx @@ -0,0 +1,436 @@ +jest.useFakeTimers(); + +import {MockedProvider, MockedResponse} from '@apollo/client/testing'; +import {act, renderHook, waitFor} from '@testing-library/react'; +import {cache} from 'idb-lru-cache'; +import {useContext} from 'react'; +import {RecoilRoot} from 'recoil'; + +import {AppContext} from '../../app/AppContext'; +import { + buildRepository, + buildRepositoryLocation, + buildWorkspaceLocationEntry, +} from '../../graphql/types'; +import { + IndexedDBCacheContext, + KEY_PREFIX, + createIndexedDBCacheContextValue, +} from '../../search/useIndexedDBCachedQuery'; +import {getMockResultFn} from '../../testing/mocking'; +import { + CODE_LOCATION_STATUS_QUERY_KEY, + CODE_LOCATION_STATUS_QUERY_VERSION, + LOCATION_WORKSPACE_QUERY_VERSION, + WorkspaceContext, + WorkspaceProvider, + locationWorkspaceKey, + repoLocationToRepos, +} from '../WorkspaceContext'; +import {buildWorkspaceMocks} from '../__fixtures__/Workspace.fixtures'; + +const mockCache = cache as any; + +const mockedCacheStore: Record = {}; + +jest.mock('idb-lru-cache', () => { + return { + cache: jest.fn(({dbName}) => { + if (!mockedCacheStore[dbName]) { + mockedCacheStore[dbName] = { + has: jest.fn(), + get: jest.fn(), + set: jest.fn(), + delete: jest.fn(), + }; + } + return mockedCacheStore[dbName]; + }), + }; +}); + +afterEach(async () => { + jest.resetModules(); + jest.clearAllMocks(); + jest.clearAllTimers(); +}); +const LOCAL_CACHE_ID_PREFIX = 'test'; +function renderWithMocks(mocks: MockedResponse[]) { + return renderHook(() => useContext(WorkspaceContext), { + wrapper({children}) { + return ( + + + + + {children} + + + + + ); + }, + }); +} + +function getLocationMocks(updatedTimestamp = 0) { + const repositoryLocation1 = buildRepositoryLocation({ + repositories: [buildRepository(), buildRepository()], + }); + const repositoryLocation2 = buildRepositoryLocation({ + repositories: [buildRepository(), buildRepository()], + }); + const repositoryLocation3 = buildRepositoryLocation({ + repositories: [buildRepository(), buildRepository()], + }); + const location1 = buildWorkspaceLocationEntry({ + name: 'location1', + updatedTimestamp, + locationOrLoadError: repositoryLocation1, + }); + const location2 = buildWorkspaceLocationEntry({ + name: 'location2', + updatedTimestamp, + locationOrLoadError: repositoryLocation2, + }); + + const location3 = buildWorkspaceLocationEntry({ + name: 'location3', + updatedTimestamp, + locationOrLoadError: repositoryLocation3, + }); + + return { + repositoryLocation1, + repositoryLocation2, + repositoryLocation3, + location1, + location2, + location3, + caches: { + codeLocationStatusQuery: mockCache({ + dbName: `${KEY_PREFIX}${LOCAL_CACHE_ID_PREFIX}${CODE_LOCATION_STATUS_QUERY_KEY}`, + }), + location1: mockCache({ + dbName: `${KEY_PREFIX}${LOCAL_CACHE_ID_PREFIX}${locationWorkspaceKey('location1')}`, + }), + location2: mockCache({ + dbName: `${KEY_PREFIX}${LOCAL_CACHE_ID_PREFIX}${locationWorkspaceKey('location2')}`, + }), + location3: mockCache({ + dbName: `${KEY_PREFIX}${LOCAL_CACHE_ID_PREFIX}${locationWorkspaceKey('location3')}`, + }), + }, + }; +} + +describe('WorkspaceContext', () => { + it('Fetches by code location when cache is empty', async () => { + const { + location1, + location2, + location3, + repositoryLocation1, + repositoryLocation2, + repositoryLocation3, + caches, + } = getLocationMocks(-1); + caches.codeLocationStatusQuery.has.mockResolvedValue(false); + caches.location1.has.mockResolvedValue(false); + caches.location2.has.mockResolvedValue(false); + caches.location3.has.mockResolvedValue(false); + const mocks = buildWorkspaceMocks([location1, location2, location3], {delay: 10}); + const mockCbs = mocks.map(getMockResultFn); + + // Include code location status mock a second time since we call runOnlyPendingTimersAsync twice + const {result} = renderWithMocks([...mocks, mocks[0]!]); + + expect(result.current.allRepos).toEqual([]); + expect(result.current.data).toEqual({}); + expect(result.current.loading).toEqual(true); + + // Runs the code location status query + await act(async () => { + await jest.runOnlyPendingTimersAsync(); + }); + + // First mock is the code location status query + expect(mockCbs[0]).toHaveBeenCalled(); + expect(mockCbs[1]).not.toHaveBeenCalled(); + expect(mockCbs[2]).not.toHaveBeenCalled(); + expect(mockCbs[3]).not.toHaveBeenCalled(); + + expect(result.current.allRepos).toEqual([]); + expect(result.current.data).toEqual({}); + expect(result.current.loading).toEqual(true); + + // Runs the individual location queries + await act(async () => { + await jest.runOnlyPendingTimersAsync(); + }); + + expect(mockCbs[1]).toHaveBeenCalled(); + expect(mockCbs[2]).toHaveBeenCalled(); + expect(mockCbs[3]).toHaveBeenCalled(); + + await waitFor(() => { + expect(result.current.loading).toEqual(false); + }); + expect(result.current.allRepos).toEqual([ + ...repoLocationToRepos(repositoryLocation1), + ...repoLocationToRepos(repositoryLocation2), + ...repoLocationToRepos(repositoryLocation3), + ]); + expect(result.current.data).toEqual({ + [location1.name]: location1, + [location2.name]: location2, + [location3.name]: location3, + }); + + await act(async () => { + // Exhaust any remaining tasks so they don't affect the next test. + await jest.runAllTicks(); + }); + }); + + it('Uses cache if it is up to date and does not query by location', async () => { + const { + location1, + location2, + location3, + repositoryLocation1, + repositoryLocation2, + repositoryLocation3, + caches, + } = getLocationMocks(); + const mocks = buildWorkspaceMocks([location1, location2, location3], {delay: 10}); + + caches.codeLocationStatusQuery.has.mockResolvedValue(true); + caches.codeLocationStatusQuery.get.mockResolvedValue({ + value: {data: (mocks[0]! as any).result.data, version: CODE_LOCATION_STATUS_QUERY_VERSION}, + }); + caches.location1.has.mockResolvedValue(true); + caches.location1.get.mockResolvedValue({ + value: {data: (mocks[1]! as any).result.data, version: LOCATION_WORKSPACE_QUERY_VERSION}, + }); + caches.location2.has.mockResolvedValue(true); + caches.location2.get.mockResolvedValue({ + value: {data: (mocks[2]! as any).result.data, version: LOCATION_WORKSPACE_QUERY_VERSION}, + }); + caches.location3.has.mockResolvedValue(true); + caches.location3.get.mockResolvedValue({ + value: {data: (mocks[3]! as any).result.data, version: LOCATION_WORKSPACE_QUERY_VERSION}, + }); + + const mockCbs = mocks.map(getMockResultFn); + + const {result} = renderWithMocks([...mocks]); + + // await act(async () => { + // await jest.runOnlyPendingTimersAsync(); + // }); + + await waitFor(async () => { + expect(result.current.loading).toEqual(false); + }); + // We queries for code location statuses but saw we were up to date + // so we didn't call any the location queries + expect(mockCbs[0]).toHaveBeenCalled(); + + expect(mockCbs[1]).not.toHaveBeenCalled(); + expect(mockCbs[2]).not.toHaveBeenCalled(); + expect(mockCbs[3]).not.toHaveBeenCalled(); + expect(result.current.allRepos).toEqual([ + ...repoLocationToRepos(repositoryLocation1), + ...repoLocationToRepos(repositoryLocation2), + ...repoLocationToRepos(repositoryLocation3), + ]); + expect(result.current.data).toEqual({ + [location1.name]: location1, + [location2.name]: location2, + [location3.name]: location3, + }); + + await act(async () => { + // Exhaust any remaining tasks so they don't affect the next test. + await jest.runAllTicks(); + }); + }); + + it('returns cached data, detects its out of date and queries by location to update it', async () => { + const { + location1, + location2, + location3, + repositoryLocation1, + repositoryLocation2, + repositoryLocation3, + caches, + } = getLocationMocks(-3); + const mocks = buildWorkspaceMocks([location1, location2, location3], {delay: 10}); + const { + location1: updatedLocation1, + location2: updatedLocation2, + location3: updatedLocation3, + } = getLocationMocks(1); + + const updatedMocks = buildWorkspaceMocks( + [updatedLocation1, updatedLocation2, updatedLocation3], + {delay: 10}, + ); + + caches.codeLocationStatusQuery.has.mockResolvedValue(true); + caches.codeLocationStatusQuery.get.mockResolvedValue({ + value: {data: (mocks[0]! as any).result.data, version: CODE_LOCATION_STATUS_QUERY_VERSION}, + }); + caches.location1.has.mockResolvedValue(true); + caches.location1.get.mockResolvedValue({ + value: {data: (mocks[1]! as any).result.data, version: LOCATION_WORKSPACE_QUERY_VERSION}, + }); + caches.location2.has.mockResolvedValue(true); + caches.location2.get.mockResolvedValue({ + value: {data: (mocks[2]! as any).result.data, version: LOCATION_WORKSPACE_QUERY_VERSION}, + }); + caches.location3.has.mockResolvedValue(true); + caches.location3.get.mockResolvedValue({ + value: {data: (mocks[3]! as any).result.data, version: LOCATION_WORKSPACE_QUERY_VERSION}, + }); + const mockCbs = updatedMocks.map(getMockResultFn); + + const {result} = renderWithMocks([...updatedMocks, updatedMocks[0]!]); + + await act(async () => { + await jest.runOnlyPendingTimersAsync(); + }); + // We queries for code location statuses and we see we are not up to date yet so the current data is still equal to the cached data + expect(mockCbs[0]).toHaveBeenCalled(); + expect(mockCbs[1]).not.toHaveBeenCalled(); + expect(mockCbs[2]).not.toHaveBeenCalled(); + expect(mockCbs[3]).not.toHaveBeenCalled(); + + expect(result.current.allRepos).toEqual([ + ...repoLocationToRepos(repositoryLocation1), + ...repoLocationToRepos(repositoryLocation2), + ...repoLocationToRepos(repositoryLocation3), + ]); + expect(result.current.data).toEqual({ + [location1.name]: location1, + [location2.name]: location2, + [location3.name]: location3, + }); + + // Run the location queries and wait for the location queries to return + await act(async () => { + await jest.runOnlyPendingTimersAsync(); + }); + + expect(result.current.data).toEqual({ + [location1.name]: updatedLocation1, + [location2.name]: updatedLocation2, + [location3.name]: updatedLocation3, + }); + expect(updatedLocation1).not.toEqual(location1); + + await act(async () => { + // Exhaust any remaining tasks so they don't affect the next test. + await jest.runAllTicks(); + }); + }); + + it('Detects and deletes removed code locations from cache', async () => { + const { + location1, + location2, + location3, + repositoryLocation1, + repositoryLocation2, + repositoryLocation3, + caches, + } = getLocationMocks(0); + const mocks = buildWorkspaceMocks([location1, location2, location3], {delay: 10}); + const {location1: updatedLocation1, location2: updatedLocation2} = getLocationMocks(1); + + // Remove location 3 + const updatedMocks = buildWorkspaceMocks([updatedLocation1, updatedLocation2], {delay: 10}); + + caches.codeLocationStatusQuery.has.mockResolvedValue(true); + caches.codeLocationStatusQuery.get.mockResolvedValue({ + value: {data: (mocks[0]! as any).result.data, version: CODE_LOCATION_STATUS_QUERY_VERSION}, + }); + caches.location1.has.mockResolvedValue(true); + caches.location1.get.mockResolvedValue({ + value: {data: (mocks[1]! as any).result.data, version: LOCATION_WORKSPACE_QUERY_VERSION}, + }); + caches.location2.has.mockResolvedValue(true); + caches.location2.get.mockResolvedValue({ + value: {data: (mocks[2]! as any).result.data, version: LOCATION_WORKSPACE_QUERY_VERSION}, + }); + caches.location3.has.mockResolvedValue(true); + caches.location3.get.mockResolvedValue({ + value: {data: (mocks[3]! as any).result.data, version: LOCATION_WORKSPACE_QUERY_VERSION}, + }); + const mockCbs = updatedMocks.map(getMockResultFn); + + const {result} = renderWithMocks([...updatedMocks, updatedMocks[0]!]); + + await act(async () => { + await jest.runAllTicks(); + }); + + await waitFor(async () => { + expect(result.current.loading).toEqual(false); + }); + + // We return the cached data + expect(result.current.allRepos).toEqual([ + ...repoLocationToRepos(repositoryLocation1), + ...repoLocationToRepos(repositoryLocation2), + ...repoLocationToRepos(repositoryLocation3), + ]); + + expect(result.current.data).toEqual({ + [location1.name]: location1, + [location2.name]: location2, + [location3.name]: location3, + }); + + expect(mockCbs[0]).toHaveBeenCalled(); + expect(mockCbs[1]).not.toHaveBeenCalled(); + expect(mockCbs[2]).not.toHaveBeenCalled(); + + await act(async () => { + // Our mocks have a delay of 10 + jest.advanceTimersByTime(10); + jest.runAllTicks(); + }); + + // We detect the third code location was deleted + expect(result.current.allRepos).toEqual([ + ...repoLocationToRepos(repositoryLocation1), + ...repoLocationToRepos(repositoryLocation2), + ]); + + expect(result.current.data).toEqual({ + [location1.name]: location1, + [location2.name]: location2, + }); + + // We query for the the updated cached locations + await act(async () => { + await jest.runOnlyPendingTimersAsync(); + }); + + // We now have the latest data + expect(result.current.data).toEqual({ + [location1.name]: updatedLocation1, + [location2.name]: updatedLocation2, + }); + }); +}); diff --git a/js_modules/dagster-ui/packages/ui-core/src/workspace/__tests__/getFeatureFlagForCodeLocation.test.tsx b/js_modules/dagster-ui/packages/ui-core/src/workspace/__tests__/getFeatureFlagForCodeLocation.test.tsx index 6c38a4ce3b699..0bfb1480559df 100644 --- a/js_modules/dagster-ui/packages/ui-core/src/workspace/__tests__/getFeatureFlagForCodeLocation.test.tsx +++ b/js_modules/dagster-ui/packages/ui-core/src/workspace/__tests__/getFeatureFlagForCodeLocation.test.tsx @@ -1,6 +1,6 @@ import {buildFeatureFlag, buildWorkspaceLocationEntry} from '../../graphql/types'; import {getFeatureFlagForCodeLocation} from '../WorkspaceContext'; -import {WorkspaceLocationNodeFragment} from '../types/WorkspaceContext.types'; +import {WorkspaceLocationNodeFragment} from '../types/WorkspaceQueries.types'; describe('getFeatureFlagForCodeLocation', () => { const locationEntries: WorkspaceLocationNodeFragment[] = [ diff --git a/js_modules/dagster-ui/packages/ui-core/src/workspace/types/WorkspaceContext.types.ts b/js_modules/dagster-ui/packages/ui-core/src/workspace/types/WorkspaceQueries.types.ts similarity index 66% rename from js_modules/dagster-ui/packages/ui-core/src/workspace/types/WorkspaceContext.types.ts rename to js_modules/dagster-ui/packages/ui-core/src/workspace/types/WorkspaceQueries.types.ts index 79e87d591d4c2..720879ed0d097 100644 --- a/js_modules/dagster-ui/packages/ui-core/src/workspace/types/WorkspaceContext.types.ts +++ b/js_modules/dagster-ui/packages/ui-core/src/workspace/types/WorkspaceQueries.types.ts @@ -2,128 +2,114 @@ import * as Types from '../../graphql/types'; -export type RootWorkspaceQueryVariables = Types.Exact<{[key: string]: never}>; +export type LocationWorkspaceQueryVariables = Types.Exact<{ + name: Types.Scalars['String']['input']; +}>; -export type RootWorkspaceQuery = { +export type LocationWorkspaceQuery = { __typename: 'Query'; - workspaceOrError: + workspaceLocationEntryOrError: + | {__typename: 'PythonError'} | { - __typename: 'PythonError'; - message: string; - stack: Array; - errorChain: Array<{ - __typename: 'ErrorChainLink'; - isExplicitLink: boolean; - error: {__typename: 'PythonError'; message: string; stack: Array}; - }>; - } - | { - __typename: 'Workspace'; + __typename: 'WorkspaceLocationEntry'; id: string; - locationEntries: Array<{ - __typename: 'WorkspaceLocationEntry'; - id: string; - name: string; - loadStatus: Types.RepositoryLocationLoadStatus; - updatedTimestamp: number; - displayMetadata: Array<{__typename: 'RepositoryMetadata'; key: string; value: string}>; - featureFlags: Array<{__typename: 'FeatureFlag'; name: string; enabled: boolean}>; - locationOrLoadError: - | { - __typename: 'PythonError'; - message: string; - stack: Array; - errorChain: Array<{ - __typename: 'ErrorChainLink'; - isExplicitLink: boolean; - error: {__typename: 'PythonError'; message: string; stack: Array}; - }>; - } - | { - __typename: 'RepositoryLocation'; + name: string; + loadStatus: Types.RepositoryLocationLoadStatus; + updatedTimestamp: number; + displayMetadata: Array<{__typename: 'RepositoryMetadata'; key: string; value: string}>; + featureFlags: Array<{__typename: 'FeatureFlag'; name: string; enabled: boolean}>; + locationOrLoadError: + | { + __typename: 'PythonError'; + message: string; + stack: Array; + errorChain: Array<{ + __typename: 'ErrorChainLink'; + isExplicitLink: boolean; + error: {__typename: 'PythonError'; message: string; stack: Array}; + }>; + } + | { + __typename: 'RepositoryLocation'; + id: string; + isReloadSupported: boolean; + serverId: string | null; + name: string; + dagsterLibraryVersions: Array<{ + __typename: 'DagsterLibraryVersion'; + name: string; + version: string; + }> | null; + repositories: Array<{ + __typename: 'Repository'; id: string; - isReloadSupported: boolean; - serverId: string | null; name: string; - dagsterLibraryVersions: Array<{ - __typename: 'DagsterLibraryVersion'; + pipelines: Array<{ + __typename: 'Pipeline'; + id: string; name: string; - version: string; - }> | null; - repositories: Array<{ - __typename: 'Repository'; + isJob: boolean; + isAssetJob: boolean; + pipelineSnapshotId: string; + }>; + schedules: Array<{ + __typename: 'Schedule'; id: string; + cronSchedule: string; + executionTimezone: string | null; + mode: string; name: string; - pipelines: Array<{ - __typename: 'Pipeline'; - id: string; - name: string; - isJob: boolean; - isAssetJob: boolean; - pipelineSnapshotId: string; - }>; - schedules: Array<{ - __typename: 'Schedule'; + pipelineName: string; + scheduleState: { + __typename: 'InstigationState'; id: string; - cronSchedule: string; - executionTimezone: string | null; - mode: string; - name: string; - pipelineName: string; - scheduleState: { - __typename: 'InstigationState'; - id: string; - selectorId: string; - status: Types.InstigationStatus; - }; - }>; - sensors: Array<{ - __typename: 'Sensor'; - id: string; - jobOriginId: string; - name: string; - sensorType: Types.SensorType; - targets: Array<{ - __typename: 'Target'; - mode: string; - pipelineName: string; - }> | null; - sensorState: { - __typename: 'InstigationState'; - id: string; - selectorId: string; - status: Types.InstigationStatus; - hasStartPermission: boolean; - hasStopPermission: boolean; - typeSpecificData: - | {__typename: 'ScheduleData'} - | {__typename: 'SensorData'; lastCursor: string | null} - | null; - }; - }>; - partitionSets: Array<{ - __typename: 'PartitionSet'; - id: string; - mode: string; - pipelineName: string; - }>; - assetGroups: Array<{__typename: 'AssetGroup'; id: string; groupName: string}>; - allTopLevelResourceDetails: Array<{ - __typename: 'ResourceDetails'; + selectorId: string; + status: Types.InstigationStatus; + }; + }>; + sensors: Array<{ + __typename: 'Sensor'; + id: string; + jobOriginId: string; + name: string; + sensorType: Types.SensorType; + targets: Array<{__typename: 'Target'; mode: string; pipelineName: string}> | null; + sensorState: { + __typename: 'InstigationState'; id: string; - name: string; - }>; - location: {__typename: 'RepositoryLocation'; id: string; name: string}; - displayMetadata: Array<{ - __typename: 'RepositoryMetadata'; - key: string; - value: string; - }>; + selectorId: string; + status: Types.InstigationStatus; + hasStartPermission: boolean; + hasStopPermission: boolean; + typeSpecificData: + | {__typename: 'ScheduleData'} + | {__typename: 'SensorData'; lastCursor: string | null} + | null; + }; }>; - } - | null; - }>; - }; + partitionSets: Array<{ + __typename: 'PartitionSet'; + id: string; + mode: string; + pipelineName: string; + }>; + assetGroups: Array<{__typename: 'AssetGroup'; id: string; groupName: string}>; + allTopLevelResourceDetails: Array<{ + __typename: 'ResourceDetails'; + id: string; + name: string; + }>; + location: {__typename: 'RepositoryLocation'; id: string; name: string}; + displayMetadata: Array<{ + __typename: 'RepositoryMetadata'; + key: string; + value: string; + }>; + }>; + } + | null; + } + | null; }; export type WorkspaceLocationNodeFragment = { @@ -394,3 +380,21 @@ export type WorkspaceSensorFragment = { | null; }; }; + +export type CodeLocationStatusQueryVariables = Types.Exact<{[key: string]: never}>; + +export type CodeLocationStatusQuery = { + __typename: 'Query'; + locationStatusesOrError: + | {__typename: 'PythonError'} + | { + __typename: 'WorkspaceLocationStatusEntries'; + entries: Array<{ + __typename: 'WorkspaceLocationStatusEntry'; + id: string; + name: string; + loadStatus: Types.RepositoryLocationLoadStatus; + updateTimestamp: number; + }>; + }; +}; diff --git a/js_modules/dagster-ui/yarn.lock b/js_modules/dagster-ui/yarn.lock index b0e76f252ee33..67f55c477689c 100644 --- a/js_modules/dagster-ui/yarn.lock +++ b/js_modules/dagster-ui/yarn.lock @@ -2486,7 +2486,7 @@ __metadata: "@storybook/react-webpack5": "npm:^7.2.0" "@testing-library/dom": "npm:^10.0.0" "@testing-library/jest-dom": "npm:^6.4.2" - "@testing-library/react": "npm:^15.0.3" + "@testing-library/react": "npm:^16.0.0" "@testing-library/user-event": "npm:^14.5.2" "@types/babel__core": "npm:^7" "@types/babel__preset-env": "npm:^7" @@ -2576,7 +2576,7 @@ __metadata: "@tanstack/react-virtual": "npm:^3.0.1" "@testing-library/dom": "npm:^10.0.0" "@testing-library/jest-dom": "npm:^6.4.2" - "@testing-library/react": "npm:^15.0.3" + "@testing-library/react": "npm:^16.0.0" "@testing-library/react-hooks": "npm:^7.0.2" "@testing-library/user-event": "npm:^14.5.2" "@types/codemirror": "npm:^5.60.5" @@ -7335,17 +7335,23 @@ __metadata: languageName: node linkType: hard -"@testing-library/react@npm:^15.0.3": - version: 15.0.3 - resolution: "@testing-library/react@npm:15.0.3" +"@testing-library/react@npm:^16.0.0": + version: 16.0.0 + resolution: "@testing-library/react@npm:16.0.0" dependencies: "@babel/runtime": "npm:^7.12.5" - "@testing-library/dom": "npm:^10.0.0" - "@types/react-dom": "npm:^18.0.0" peerDependencies: + "@testing-library/dom": ^10.0.0 + "@types/react": ^18.0.0 + "@types/react-dom": ^18.0.0 react: ^18.0.0 react-dom: ^18.0.0 - checksum: 10/3e094accf49bdfba141ac53cad4902831fbef9d2d4a2c39a908820b141569aa8ca85e35f95560749ab46ad1db1be38a5382d38a0cb41a55a5d0b8afe116f2ba0 + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10/b32894be94e31276138decfa6bcea69dfebc0c37cf91499ff6c878f41eb1154a43a7df6eb1e72e7bede78468af6cb67ca59e4acd3206b41f3ecdae2c6efdf67e languageName: node linkType: hard @@ -8006,7 +8012,7 @@ __metadata: languageName: node linkType: hard -"@types/react-dom@npm:>=16.9.0, @types/react-dom@npm:^18.0.0, @types/react-dom@npm:^18.2.0": +"@types/react-dom@npm:>=16.9.0, @types/react-dom@npm:^18.2.0": version: 18.2.7 resolution: "@types/react-dom@npm:18.2.7" dependencies: