diff --git a/pkg/ui/pnpm-lock.yaml b/pkg/ui/pnpm-lock.yaml
index b7f8afd2df1e..85a6d1277a36 100644
--- a/pkg/ui/pnpm-lock.yaml
+++ b/pkg/ui/pnpm-lock.yaml
@@ -248,6 +248,9 @@ importers:
'@testing-library/dom':
specifier: ^8.11.1
version: 8.11.1
+ '@testing-library/jest-dom':
+ specifier: 6.5.0
+ version: 6.5.0
'@testing-library/react':
specifier: ^12.1.0
version: 12.1.0(react-dom@16.12.0)(react@16.12.0)
@@ -1195,6 +1198,10 @@ packages:
resolution: {integrity: sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA==}
engines: {node: '>=0.10.0'}
+ /@adobe/css-tools@4.4.0:
+ resolution: {integrity: sha512-Ff9+ksdQQB3rMncgqDK78uLznstjyfIf2Arnh22pW8kBpLs6rpKDwgnZT46hin5Hl1WzazzK64DOrhSwYpS7bQ==}
+ dev: true
+
/@ampproject/remapping@2.2.1:
resolution: {integrity: sha512-lFMjJTrFL3j7L9yBxwYfCq2k6qqwHyzuUl/XBnif78PWTJYyL/dfowQHWE3sp6U6ZzqWiiIZnpTMO96zhkjwtg==}
engines: {node: '>=6.0.0'}
@@ -7460,6 +7467,19 @@ packages:
pretty-format: 27.3.1(@babel/runtime@7.12.13)
dev: true
+ /@testing-library/jest-dom@6.5.0:
+ resolution: {integrity: sha512-xGGHpBXYSHUUr6XsKBfs85TWlYKpTc37cSBBVrXcib2MkHLboWlkClhWF37JKlDb9KEq3dHs+f2xR7XJEWGBxA==}
+ engines: {node: '>=14', npm: '>=6', yarn: '>=1'}
+ dependencies:
+ '@adobe/css-tools': 4.4.0
+ aria-query: 5.0.1
+ chalk: 3.0.0
+ css.escape: 1.5.1
+ dom-accessibility-api: 0.6.3
+ lodash: 4.17.20
+ redent: 3.0.0
+ dev: true
+
/@testing-library/react@12.1.0(react-dom@16.12.0)(react@16.12.0):
resolution: {integrity: sha512-Ge3Ht3qXE82Yv9lyPpQ7ZWgzo/HgOcHu569Y4ZGWcZME38iOFiOg87qnu6hTEa8jTJVL7zYovnvD3GE2nsNIoQ==}
engines: {node: '>=12'}
@@ -11479,6 +11499,10 @@ packages:
engines: {node: '>= 6'}
dev: true
+ /css.escape@1.5.1:
+ resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==}
+ dev: true
+
/cssesc@3.0.0:
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
engines: {node: '>=4'}
@@ -12108,6 +12132,10 @@ packages:
resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==}
dev: true
+ /dom-accessibility-api@0.6.3:
+ resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==}
+ dev: true
+
/dom-align@1.12.4:
resolution: {integrity: sha512-R8LUSEay/68zE5c8/3BDxiTEvgb4xZTF0RKmAHfiEVN3klfIpXfi2/QCoiWPccVQ0J/ZGdz9OjzL4uJEP/MRAw==}
dev: false
@@ -20997,6 +21025,14 @@ packages:
minimatch: 3.0.4
dev: true
+ /redent@3.0.0:
+ resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==}
+ engines: {node: '>=8'}
+ dependencies:
+ indent-string: 4.0.0
+ strip-indent: 3.0.0
+ dev: true
+
/reduce-css-calc@2.1.8:
resolution: {integrity: sha512-8liAVezDmUcH+tdzoEGrhfbGcP7nOV4NkGE3a74+qqvE7nt9i4sKLGBuZNOnpI4WiGksiNPklZxva80061QiPg==}
dependencies:
diff --git a/pkg/ui/workspaces/cluster-ui/package.json b/pkg/ui/workspaces/cluster-ui/package.json
index 8f7b498b76c0..40988c27d0a2 100644
--- a/pkg/ui/workspaces/cluster-ui/package.json
+++ b/pkg/ui/workspaces/cluster-ui/package.json
@@ -97,6 +97,7 @@
"@storybook/addons": "^6.3.1",
"@storybook/react": "^6.3.1",
"@testing-library/dom": "^8.11.1",
+ "@testing-library/jest-dom": "6.5.0",
"@testing-library/react": "^12.1.0",
"@testing-library/user-event": "^13.5.0",
"@types/chai": "^4.2.11",
diff --git a/pkg/ui/workspaces/cluster-ui/src/components/tableMetadataLastUpdated/tableMetadataJobControl.spec.tsx b/pkg/ui/workspaces/cluster-ui/src/components/tableMetadataLastUpdated/tableMetadataJobControl.spec.tsx
new file mode 100644
index 000000000000..817a050bb75f
--- /dev/null
+++ b/pkg/ui/workspaces/cluster-ui/src/components/tableMetadataLastUpdated/tableMetadataJobControl.spec.tsx
@@ -0,0 +1,178 @@
+// Copyright 2024 The Cockroach Authors.
+//
+// Use of this software is governed by the CockroachDB Software License
+// included in the /LICENSE file.
+
+import "@testing-library/jest-dom";
+import { render, screen, fireEvent, act } from "@testing-library/react";
+import moment from "moment-timezone";
+import React from "react";
+
+import * as api from "src/api/databases/tableMetaUpdateJobApi";
+import { TimezoneContext } from "src/contexts";
+
+import { TableMetadataJobControl } from "./tableMetadataJobControl";
+
+jest.mock("src/api/databases/tableMetaUpdateJobApi");
+
+describe("TableMetadataJobControl", () => {
+ const mockOnDataUpdated = jest.fn();
+ const mockRefreshJobStatus = jest.fn();
+ const mockLastCompletedTime = moment("2024-01-01T12:00:00Z");
+ const mockLastUpdatedTime = moment("2024-01-01T12:05:00Z");
+ const mockLastStartTime = moment("2024-01-01T11:59:00Z");
+
+ beforeEach(() => {
+ jest.useFakeTimers();
+ jest.spyOn(api, "useTableMetaUpdateJob").mockReturnValue({
+ jobStatus: {
+ dataValidDuration: moment.duration(1, "hour"),
+ currentStatus: api.TableMetadataJobStatus.NOT_RUNNING,
+ progress: 0,
+ lastCompletedTime: mockLastCompletedTime,
+ lastStartTime: mockLastStartTime,
+ lastUpdatedTime: mockLastUpdatedTime,
+ automaticUpdatesEnabled: false,
+ },
+ refreshJobStatus: mockRefreshJobStatus,
+ isLoading: false,
+ });
+ jest.spyOn(api, "triggerUpdateTableMetaJobApi").mockResolvedValue({
+ message: "Job triggered",
+ job_triggered: true,
+ });
+ });
+
+ afterEach(() => {
+ jest.useRealTimers();
+ jest.clearAllMocks();
+ });
+
+ it("renders the last refreshed time", () => {
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByText(/Last refreshed:/)).toBeInTheDocument();
+ expect(
+ screen.getByText(/Jan 01, 2024 at 12:00:00 UTC/),
+ ).toBeInTheDocument();
+ });
+
+ it('renders "Never" when lastCompletedTime is null', () => {
+ jest.spyOn(api, "useTableMetaUpdateJob").mockReturnValue({
+ jobStatus: {
+ lastCompletedTime: null,
+ dataValidDuration: moment.duration(1, "hour"),
+ currentStatus: api.TableMetadataJobStatus.NOT_RUNNING,
+ progress: 0,
+ lastStartTime: mockLastUpdatedTime,
+ lastUpdatedTime: mockLastUpdatedTime,
+ automaticUpdatesEnabled: false,
+ },
+ refreshJobStatus: mockRefreshJobStatus,
+ isLoading: false,
+ });
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByText(/Last refreshed: Never/)).toBeInTheDocument();
+ });
+
+ it("triggers update when refresh button is clicked", async () => {
+ render(
+
+
+ ,
+ );
+
+ const refreshButton = screen.getByRole("button");
+ await act(async () => {
+ fireEvent.click(refreshButton);
+ });
+
+ expect(api.triggerUpdateTableMetaJobApi).toHaveBeenCalledWith({
+ onlyIfStale: false,
+ });
+ expect(mockRefreshJobStatus).toHaveBeenCalled();
+ });
+
+ it("schedules next update after dataValidDuration", async () => {
+ render(
+
+
+ ,
+ );
+
+ await act(async () => {
+ // Advance timer 1 hour and 30s.
+ jest.advanceTimersByTime(3600000 + 30000);
+ });
+
+ expect(api.triggerUpdateTableMetaJobApi).toHaveBeenCalledWith({
+ onlyIfStale: true,
+ });
+ });
+
+ it("calls onDataUpdated when lastCompletedTime changes", () => {
+ const { rerender } = render(
+
+
+ ,
+ );
+
+ // Update the mock to return a new lastCompletedTime
+ jest.spyOn(api, "useTableMetaUpdateJob").mockReturnValue({
+ jobStatus: {
+ lastCompletedTime: moment("2024-01-01T13:00:00Z"),
+ lastStartTime: moment("2024-01-01T13:00:00Z"),
+ lastUpdatedTime: moment.utc(),
+ dataValidDuration: moment.duration(1, "hour"),
+ currentStatus: api.TableMetadataJobStatus.NOT_RUNNING,
+ progress: 0,
+ automaticUpdatesEnabled: false,
+ },
+ refreshJobStatus: mockRefreshJobStatus,
+ isLoading: false,
+ });
+
+ // Rerender the component with the updated mock
+ rerender(
+
+
+ ,
+ );
+
+ expect(mockOnDataUpdated).toHaveBeenCalled();
+ });
+
+ it("disables refresh button when job is running", () => {
+ jest.spyOn(api, "useTableMetaUpdateJob").mockReturnValue({
+ jobStatus: {
+ lastCompletedTime: moment("2024-01-01T12:00:00Z"),
+ dataValidDuration: moment.duration(1, "hour"),
+ currentStatus: api.TableMetadataJobStatus.RUNNING,
+ progress: 0,
+ lastStartTime: moment.utc(),
+ lastUpdatedTime: moment.utc(),
+ automaticUpdatesEnabled: false,
+ },
+ refreshJobStatus: mockRefreshJobStatus,
+ isLoading: false,
+ });
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByRole("button")).toBeDisabled();
+ });
+});
diff --git a/pkg/ui/workspaces/cluster-ui/src/components/tableMetadataLastUpdated/tableMetadataJobControl.tsx b/pkg/ui/workspaces/cluster-ui/src/components/tableMetadataLastUpdated/tableMetadataJobControl.tsx
index 8f4e3233ada1..05f43ff91bff 100644
--- a/pkg/ui/workspaces/cluster-ui/src/components/tableMetadataLastUpdated/tableMetadataJobControl.tsx
+++ b/pkg/ui/workspaces/cluster-ui/src/components/tableMetadataLastUpdated/tableMetadataJobControl.tsx
@@ -34,7 +34,7 @@ export const TableMetadataJobControl: React.FC<
);
const lastUpdateCompletedUnixSecs = jobStatus?.lastCompletedTime?.unix();
const timezone = useContext(TimezoneContext);
- const lastUpdatedText = jobStatus?.lastUpdatedTime
+ const lastUpdatedText = jobStatus?.lastCompletedTime
? FormatWithTimezone(
jobStatus?.lastCompletedTime,
DATE_WITH_SECONDS_FORMAT_24_TZ,