From 7b979a8c2a0918f9ea0a8b32b73d17f3cb30f4d0 Mon Sep 17 00:00:00 2001
From: Alexander Polyankin <alexander.polyankin@metabase.com>
Date: Fri, 8 Jul 2022 16:41:20 +0300
Subject: [PATCH] Add question last cached time info (#23787)

---
 .../components/CacheSection/CacheSection.tsx  |  7 ++--
 .../caching/components/CacheSection/index.js  |  1 -
 .../caching/components/CacheSection/index.ts  |  1 +
 .../CacheTTLField/CacheTTLField.jsx           |  4 +-
 .../CacheTTLField/CacheTTLField.unit.spec.js  |  2 +-
 .../caching/components/CacheTTLField/index.js |  2 +-
 .../DashboardCacheSection.tsx                 |  6 ++-
 .../components/DashboardCacheSection/index.ts |  2 +-
 .../DatabaseCacheTTLField.jsx                 |  6 ++-
 .../DatabaseCacheTTLField.unit.spec.js        |  2 +-
 .../components/DatabaseCacheTTLField/index.js |  2 +-
 .../QuestionCacheSection.styled.tsx           |  8 ++++
 .../QuestionCacheSection.tsx                  | 25 +++++++++--
 .../QuestionCacheSection.unit.spec.tsx        | 41 +++++++++++++++++++
 .../components/QuestionCacheSection/index.js  |  1 -
 .../components/QuestionCacheSection/index.ts  |  1 +
 .../QuestionCacheTTLField.jsx                 |  4 +-
 .../QuestionCacheTTLField.styled.jsx          |  2 +-
 .../QuestionCacheTTLField.unit.spec.js        |  2 +-
 .../components/QuestionCacheTTLField/index.js |  2 +-
 .../src/metabase-enterprise/caching/index.js  | 10 ++---
 frontend/src/metabase-lib/lib/Question.ts     |  4 ++
 frontend/src/metabase-types/api/card.ts       | 19 +++++++++
 .../api/{foreignKey.ts => foreign-key.ts}     |  0
 frontend/src/metabase-types/api/index.ts      | 17 ++++----
 frontend/src/metabase-types/api/mocks/card.ts | 37 ++++++++++++++++-
 .../src/metabase-types/api/mocks/index.ts     |  1 +
 .../src/metabase-types/api/mocks/query.ts     | 37 +++++++++++++++++
 frontend/src/metabase-types/api/query.ts      | 24 +++++++++++
 frontend/src/metabase-types/api/question.ts   |  1 -
 frontend/src/metabase-types/api/table.ts      |  6 ++-
 frontend/src/metabase-types/types/Table.ts    |  2 +-
 .../components/ObjectDetail/ObjectDetail.tsx  |  2 +-
 .../ObjectDetail/ObjectRelationships.tsx      |  2 +-
 34 files changed, 239 insertions(+), 44 deletions(-)
 delete mode 100644 enterprise/frontend/src/metabase-enterprise/caching/components/CacheSection/index.js
 create mode 100644 enterprise/frontend/src/metabase-enterprise/caching/components/CacheSection/index.ts
 create mode 100644 enterprise/frontend/src/metabase-enterprise/caching/components/QuestionCacheSection/QuestionCacheSection.styled.tsx
 create mode 100644 enterprise/frontend/src/metabase-enterprise/caching/components/QuestionCacheSection/QuestionCacheSection.unit.spec.tsx
 delete mode 100644 enterprise/frontend/src/metabase-enterprise/caching/components/QuestionCacheSection/index.js
 create mode 100644 enterprise/frontend/src/metabase-enterprise/caching/components/QuestionCacheSection/index.ts
 rename frontend/src/metabase-types/api/{foreignKey.ts => foreign-key.ts} (100%)
 create mode 100644 frontend/src/metabase-types/api/mocks/query.ts
 create mode 100644 frontend/src/metabase-types/api/query.ts
 delete mode 100644 frontend/src/metabase-types/api/question.ts

diff --git a/enterprise/frontend/src/metabase-enterprise/caching/components/CacheSection/CacheSection.tsx b/enterprise/frontend/src/metabase-enterprise/caching/components/CacheSection/CacheSection.tsx
index 2645a3d610c..be7ff82ee18 100644
--- a/enterprise/frontend/src/metabase-enterprise/caching/components/CacheSection/CacheSection.tsx
+++ b/enterprise/frontend/src/metabase-enterprise/caching/components/CacheSection/CacheSection.tsx
@@ -16,10 +16,7 @@ interface CacheSectionProps {
   onSave: (cache_ttl: number | null) => Promise<any>;
 }
 
-export const CacheSection = ({
-  initialCacheTTL,
-  onSave,
-}: CacheSectionProps) => {
+const CacheSection = ({ initialCacheTTL, onSave }: CacheSectionProps) => {
   const [cacheTTL, setCacheTTL] = useState(initialCacheTTL);
 
   const handleChange = useCallback(
@@ -70,3 +67,5 @@ export const CacheSection = ({
     </CacheSectionRoot>
   );
 };
+
+export default CacheSection;
diff --git a/enterprise/frontend/src/metabase-enterprise/caching/components/CacheSection/index.js b/enterprise/frontend/src/metabase-enterprise/caching/components/CacheSection/index.js
deleted file mode 100644
index 945d4ff0f4a..00000000000
--- a/enterprise/frontend/src/metabase-enterprise/caching/components/CacheSection/index.js
+++ /dev/null
@@ -1 +0,0 @@
-export * from "./CacheSection";
diff --git a/enterprise/frontend/src/metabase-enterprise/caching/components/CacheSection/index.ts b/enterprise/frontend/src/metabase-enterprise/caching/components/CacheSection/index.ts
new file mode 100644
index 00000000000..e2d399aae7c
--- /dev/null
+++ b/enterprise/frontend/src/metabase-enterprise/caching/components/CacheSection/index.ts
@@ -0,0 +1 @@
+export { default } from "./CacheSection";
diff --git a/enterprise/frontend/src/metabase-enterprise/caching/components/CacheTTLField/CacheTTLField.jsx b/enterprise/frontend/src/metabase-enterprise/caching/components/CacheTTLField/CacheTTLField.jsx
index 1c00d2a11c9..22ea7f861b0 100644
--- a/enterprise/frontend/src/metabase-enterprise/caching/components/CacheTTLField/CacheTTLField.jsx
+++ b/enterprise/frontend/src/metabase-enterprise/caching/components/CacheTTLField/CacheTTLField.jsx
@@ -17,7 +17,7 @@ const propTypes = {
   message: PropTypes.string,
 };
 
-export function CacheTTLField({ field, message, ...props }) {
+function CacheTTLField({ field, message, ...props }) {
   const hasError = !!field.error;
   return (
     <CacheTTLFieldContainer {...props} data-testid="cache-ttl-field">
@@ -40,3 +40,5 @@ export function CacheTTLField({ field, message, ...props }) {
 }
 
 CacheTTLField.propTypes = propTypes;
+
+export default CacheTTLField;
diff --git a/enterprise/frontend/src/metabase-enterprise/caching/components/CacheTTLField/CacheTTLField.unit.spec.js b/enterprise/frontend/src/metabase-enterprise/caching/components/CacheTTLField/CacheTTLField.unit.spec.js
index 1c2bc1a09ac..206f0b1eb54 100644
--- a/enterprise/frontend/src/metabase-enterprise/caching/components/CacheTTLField/CacheTTLField.unit.spec.js
+++ b/enterprise/frontend/src/metabase-enterprise/caching/components/CacheTTLField/CacheTTLField.unit.spec.js
@@ -1,7 +1,7 @@
 import React from "react";
 import { render, screen } from "@testing-library/react";
 import userEvent from "@testing-library/user-event";
-import { CacheTTLField } from "./CacheTTLField";
+import CacheTTLField from "./CacheTTLField";
 
 function setup({ name = "cache_ttl", message, value }) {
   const onChange = jest.fn();
diff --git a/enterprise/frontend/src/metabase-enterprise/caching/components/CacheTTLField/index.js b/enterprise/frontend/src/metabase-enterprise/caching/components/CacheTTLField/index.js
index 35634c0a52f..fdb4536d31c 100644
--- a/enterprise/frontend/src/metabase-enterprise/caching/components/CacheTTLField/index.js
+++ b/enterprise/frontend/src/metabase-enterprise/caching/components/CacheTTLField/index.js
@@ -1 +1 @@
-export * from "./CacheTTLField";
+export { default } from "./CacheTTLField";
diff --git a/enterprise/frontend/src/metabase-enterprise/caching/components/DashboardCacheSection/DashboardCacheSection.tsx b/enterprise/frontend/src/metabase-enterprise/caching/components/DashboardCacheSection/DashboardCacheSection.tsx
index 70c1606ae5b..07db2a6f5b8 100644
--- a/enterprise/frontend/src/metabase-enterprise/caching/components/DashboardCacheSection/DashboardCacheSection.tsx
+++ b/enterprise/frontend/src/metabase-enterprise/caching/components/DashboardCacheSection/DashboardCacheSection.tsx
@@ -1,15 +1,17 @@
 import React from "react";
 import { Dashboard } from "metabase-types/api";
-import { CacheSection } from "../CacheSection";
+import CacheSection from "../CacheSection";
 
 interface DashboardCacheSectionProps {
   dashboard: Dashboard;
   onSave: (cache_ttl: number | null) => Promise<Dashboard>;
 }
 
-export const DashboardCacheSection = ({
+const DashboardCacheSection = ({
   dashboard,
   onSave,
 }: DashboardCacheSectionProps) => {
   return <CacheSection initialCacheTTL={dashboard.cache_ttl} onSave={onSave} />;
 };
+
+export default DashboardCacheSection;
diff --git a/enterprise/frontend/src/metabase-enterprise/caching/components/DashboardCacheSection/index.ts b/enterprise/frontend/src/metabase-enterprise/caching/components/DashboardCacheSection/index.ts
index 19cf2e99322..856e7a25f65 100644
--- a/enterprise/frontend/src/metabase-enterprise/caching/components/DashboardCacheSection/index.ts
+++ b/enterprise/frontend/src/metabase-enterprise/caching/components/DashboardCacheSection/index.ts
@@ -1 +1 @@
-export * from "./DashboardCacheSection";
+export { default } from "./DashboardCacheSection";
diff --git a/enterprise/frontend/src/metabase-enterprise/caching/components/DatabaseCacheTTLField/DatabaseCacheTTLField.jsx b/enterprise/frontend/src/metabase-enterprise/caching/components/DatabaseCacheTTLField/DatabaseCacheTTLField.jsx
index ae1574f47f4..883f1435f73 100644
--- a/enterprise/frontend/src/metabase-enterprise/caching/components/DatabaseCacheTTLField/DatabaseCacheTTLField.jsx
+++ b/enterprise/frontend/src/metabase-enterprise/caching/components/DatabaseCacheTTLField/DatabaseCacheTTLField.jsx
@@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useState } from "react";
 import PropTypes from "prop-types";
 import { t } from "ttag";
 import Select, { Option } from "metabase/core/components/Select";
-import { CacheTTLField } from "../CacheTTLField";
+import CacheTTLField from "../CacheTTLField";
 import {
   CacheFieldContainer,
   FieldContainer,
@@ -20,7 +20,7 @@ const propTypes = {
   field: PropTypes.object.isRequired,
 };
 
-export function DatabaseCacheTTLField({ field }) {
+function DatabaseCacheTTLField({ field }) {
   const [mode, setMode] = useState(
     field.value > 0 ? MODE.CUSTOM : MODE.INSTANCE_DEFAULT,
   );
@@ -55,3 +55,5 @@ export function DatabaseCacheTTLField({ field }) {
 }
 
 DatabaseCacheTTLField.propTypes = propTypes;
+
+export default DatabaseCacheTTLField;
diff --git a/enterprise/frontend/src/metabase-enterprise/caching/components/DatabaseCacheTTLField/DatabaseCacheTTLField.unit.spec.js b/enterprise/frontend/src/metabase-enterprise/caching/components/DatabaseCacheTTLField/DatabaseCacheTTLField.unit.spec.js
index e4670251a82..11a4debeb19 100644
--- a/enterprise/frontend/src/metabase-enterprise/caching/components/DatabaseCacheTTLField/DatabaseCacheTTLField.unit.spec.js
+++ b/enterprise/frontend/src/metabase-enterprise/caching/components/DatabaseCacheTTLField/DatabaseCacheTTLField.unit.spec.js
@@ -1,7 +1,7 @@
 import React from "react";
 import { render, screen } from "@testing-library/react";
 import userEvent from "@testing-library/user-event";
-import { DatabaseCacheTTLField } from "./DatabaseCacheTTLField";
+import DatabaseCacheTTLField from "./DatabaseCacheTTLField";
 
 function setup({ value = null } = {}) {
   const onChange = jest.fn();
diff --git a/enterprise/frontend/src/metabase-enterprise/caching/components/DatabaseCacheTTLField/index.js b/enterprise/frontend/src/metabase-enterprise/caching/components/DatabaseCacheTTLField/index.js
index 0e1d95230ac..eedffdc43ac 100644
--- a/enterprise/frontend/src/metabase-enterprise/caching/components/DatabaseCacheTTLField/index.js
+++ b/enterprise/frontend/src/metabase-enterprise/caching/components/DatabaseCacheTTLField/index.js
@@ -1 +1 @@
-export * from "./DatabaseCacheTTLField";
+export { default } from "./DatabaseCacheTTLField";
diff --git a/enterprise/frontend/src/metabase-enterprise/caching/components/QuestionCacheSection/QuestionCacheSection.styled.tsx b/enterprise/frontend/src/metabase-enterprise/caching/components/QuestionCacheSection/QuestionCacheSection.styled.tsx
new file mode 100644
index 00000000000..33380eb4733
--- /dev/null
+++ b/enterprise/frontend/src/metabase-enterprise/caching/components/QuestionCacheSection/QuestionCacheSection.styled.tsx
@@ -0,0 +1,8 @@
+import styled from "@emotion/styled";
+import { color } from "metabase/lib/colors";
+
+export const QueryStartLabel = styled.div`
+  color: ${color("text-dark")};
+  font-weight: bold;
+  margin-bottom: 0.5rem;
+`;
diff --git a/enterprise/frontend/src/metabase-enterprise/caching/components/QuestionCacheSection/QuestionCacheSection.tsx b/enterprise/frontend/src/metabase-enterprise/caching/components/QuestionCacheSection/QuestionCacheSection.tsx
index 14651709222..e76a23dde78 100644
--- a/enterprise/frontend/src/metabase-enterprise/caching/components/QuestionCacheSection/QuestionCacheSection.tsx
+++ b/enterprise/frontend/src/metabase-enterprise/caching/components/QuestionCacheSection/QuestionCacheSection.tsx
@@ -1,15 +1,32 @@
 import React from "react";
+import { t } from "ttag";
+import { getRelativeTime } from "metabase/lib/time";
 import Question from "metabase-lib/lib/Question";
-import { CacheSection } from "../CacheSection";
+import CacheSection from "../CacheSection";
+import { QueryStartLabel } from "./QuestionCacheSection.styled";
 
-interface QuestionCacheSectionProps {
+export interface QuestionCacheSectionProps {
   question: Question;
   onSave: (cache_ttl: number | null) => Promise<Question>;
 }
 
-export const QuestionCacheSection = ({
+const QuestionCacheSection = ({
   question,
   onSave,
 }: QuestionCacheSectionProps) => {
-  return <CacheSection initialCacheTTL={question.cacheTTL()} onSave={onSave} />;
+  const cacheTimestamp = question.lastQueryStart();
+  const cacheRelativeTime = cacheTimestamp && getRelativeTime(cacheTimestamp);
+
+  return (
+    <div>
+      {cacheTimestamp && (
+        <QueryStartLabel>
+          {t`Question last cached ${cacheRelativeTime}`}
+        </QueryStartLabel>
+      )}
+      <CacheSection initialCacheTTL={question.cacheTTL()} onSave={onSave} />
+    </div>
+  );
 };
+
+export default QuestionCacheSection;
diff --git a/enterprise/frontend/src/metabase-enterprise/caching/components/QuestionCacheSection/QuestionCacheSection.unit.spec.tsx b/enterprise/frontend/src/metabase-enterprise/caching/components/QuestionCacheSection/QuestionCacheSection.unit.spec.tsx
new file mode 100644
index 00000000000..e49bc029b83
--- /dev/null
+++ b/enterprise/frontend/src/metabase-enterprise/caching/components/QuestionCacheSection/QuestionCacheSection.unit.spec.tsx
@@ -0,0 +1,41 @@
+import React from "react";
+import { render, screen } from "@testing-library/react";
+import Question from "metabase-lib/lib/Question";
+import { createMockCard } from "metabase-types/api/mocks";
+import QuestionCacheSection, {
+  QuestionCacheSectionProps,
+} from "./QuestionCacheSection";
+
+describe("QuestionCacheSection", () => {
+  beforeEach(() => {
+    jest.useFakeTimers();
+    jest.setSystemTime(new Date(2020, 0, 10));
+  });
+
+  afterEach(() => {
+    jest.useRealTimers();
+  });
+
+  it("should show the time of the last cached query", () => {
+    const props = getProps({
+      question: new Question(
+        createMockCard({
+          last_query_start: "2020-01-05T00:00:00Z",
+        }),
+      ),
+    });
+
+    render(<QuestionCacheSection {...props} />);
+
+    const cacheLabel = screen.getByText("Question last cached 5 days ago");
+    expect(cacheLabel).toBeInTheDocument();
+  });
+});
+
+const getProps = (
+  opts?: Partial<QuestionCacheSectionProps>,
+): QuestionCacheSectionProps => ({
+  question: new Question(createMockCard()),
+  onSave: jest.fn(),
+  ...opts,
+});
diff --git a/enterprise/frontend/src/metabase-enterprise/caching/components/QuestionCacheSection/index.js b/enterprise/frontend/src/metabase-enterprise/caching/components/QuestionCacheSection/index.js
deleted file mode 100644
index 4a9bc9fc8c5..00000000000
--- a/enterprise/frontend/src/metabase-enterprise/caching/components/QuestionCacheSection/index.js
+++ /dev/null
@@ -1 +0,0 @@
-export * from "./QuestionCacheSection";
diff --git a/enterprise/frontend/src/metabase-enterprise/caching/components/QuestionCacheSection/index.ts b/enterprise/frontend/src/metabase-enterprise/caching/components/QuestionCacheSection/index.ts
new file mode 100644
index 00000000000..15d8fcdef47
--- /dev/null
+++ b/enterprise/frontend/src/metabase-enterprise/caching/components/QuestionCacheSection/index.ts
@@ -0,0 +1 @@
+export { default } from "./QuestionCacheSection";
diff --git a/enterprise/frontend/src/metabase-enterprise/caching/components/QuestionCacheTTLField/QuestionCacheTTLField.jsx b/enterprise/frontend/src/metabase-enterprise/caching/components/QuestionCacheTTLField/QuestionCacheTTLField.jsx
index 51ab91308c9..65843b980eb 100644
--- a/enterprise/frontend/src/metabase-enterprise/caching/components/QuestionCacheTTLField/QuestionCacheTTLField.jsx
+++ b/enterprise/frontend/src/metabase-enterprise/caching/components/QuestionCacheTTLField/QuestionCacheTTLField.jsx
@@ -31,7 +31,7 @@ function getInitialMode(question, implicitCacheTTL) {
   return MODE.DEFAULT;
 }
 
-export function QuestionCacheTTLField({ field, question, ...props }) {
+function QuestionCacheTTLField({ field, question, ...props }) {
   const implicitCacheTTL = useMemo(
     () => getQuestionsImplicitCacheTTL(question),
     [question],
@@ -73,3 +73,5 @@ export function QuestionCacheTTLField({ field, question, ...props }) {
 }
 
 QuestionCacheTTLField.propTypes = propTypes;
+
+export default QuestionCacheTTLField;
diff --git a/enterprise/frontend/src/metabase-enterprise/caching/components/QuestionCacheTTLField/QuestionCacheTTLField.styled.jsx b/enterprise/frontend/src/metabase-enterprise/caching/components/QuestionCacheTTLField/QuestionCacheTTLField.styled.jsx
index b10280682c6..d45fe16ef15 100644
--- a/enterprise/frontend/src/metabase-enterprise/caching/components/QuestionCacheTTLField/QuestionCacheTTLField.styled.jsx
+++ b/enterprise/frontend/src/metabase-enterprise/caching/components/QuestionCacheTTLField/QuestionCacheTTLField.styled.jsx
@@ -3,7 +3,7 @@ import { t } from "ttag";
 import styled from "@emotion/styled";
 import { space } from "metabase/styled-components/theme";
 import Radio from "metabase/core/components/Radio";
-import { CacheTTLField } from "../CacheTTLField";
+import CacheTTLField from "../CacheTTLField";
 
 export function CacheTTLInput(props) {
   return <CacheTTLField {...props} message={t`Cache results for`} />;
diff --git a/enterprise/frontend/src/metabase-enterprise/caching/components/QuestionCacheTTLField/QuestionCacheTTLField.unit.spec.js b/enterprise/frontend/src/metabase-enterprise/caching/components/QuestionCacheTTLField/QuestionCacheTTLField.unit.spec.js
index c893c54c128..4ef6867209d 100644
--- a/enterprise/frontend/src/metabase-enterprise/caching/components/QuestionCacheTTLField/QuestionCacheTTLField.unit.spec.js
+++ b/enterprise/frontend/src/metabase-enterprise/caching/components/QuestionCacheTTLField/QuestionCacheTTLField.unit.spec.js
@@ -3,7 +3,7 @@ import { render, screen } from "@testing-library/react";
 import userEvent from "@testing-library/user-event";
 import { msToMinutes, msToHours } from "metabase/lib/time";
 import MetabaseSettings from "metabase/lib/settings";
-import { QuestionCacheTTLField } from "./QuestionCacheTTLField";
+import QuestionCacheTTLField from "./QuestionCacheTTLField";
 
 const TEN_MINUTES = 10 * 60 * 1000;
 
diff --git a/enterprise/frontend/src/metabase-enterprise/caching/components/QuestionCacheTTLField/index.js b/enterprise/frontend/src/metabase-enterprise/caching/components/QuestionCacheTTLField/index.js
index bbc522fae52..ce8a3adf3bb 100644
--- a/enterprise/frontend/src/metabase-enterprise/caching/components/QuestionCacheTTLField/index.js
+++ b/enterprise/frontend/src/metabase-enterprise/caching/components/QuestionCacheTTLField/index.js
@@ -1 +1 @@
-export * from "./QuestionCacheTTLField";
+export { default } from "./QuestionCacheTTLField";
diff --git a/enterprise/frontend/src/metabase-enterprise/caching/index.js b/enterprise/frontend/src/metabase-enterprise/caching/index.js
index d3b4b61fedf..a1c91a17b24 100644
--- a/enterprise/frontend/src/metabase-enterprise/caching/index.js
+++ b/enterprise/frontend/src/metabase-enterprise/caching/index.js
@@ -3,11 +3,11 @@ import { t, jt } from "ttag";
 import { hasPremiumFeature } from "metabase-enterprise/settings";
 import { PLUGIN_CACHING, PLUGIN_FORM_WIDGETS } from "metabase/plugins";
 import Link from "metabase/core/components/Link";
-import { CacheTTLField } from "./components/CacheTTLField";
-import { DatabaseCacheTTLField } from "./components/DatabaseCacheTTLField";
-import { QuestionCacheTTLField } from "./components/QuestionCacheTTLField";
-import { QuestionCacheSection } from "./components/QuestionCacheSection";
-import { DashboardCacheSection } from "./components/DashboardCacheSection";
+import CacheTTLField from "./components/CacheTTLField";
+import DatabaseCacheTTLField from "./components/DatabaseCacheTTLField";
+import QuestionCacheTTLField from "./components/QuestionCacheTTLField";
+import QuestionCacheSection from "./components/QuestionCacheSection";
+import DashboardCacheSection from "./components/DashboardCacheSection";
 
 import {
   getQuestionsImplicitCacheTTL,
diff --git a/frontend/src/metabase-lib/lib/Question.ts b/frontend/src/metabase-lib/lib/Question.ts
index 6b2b0a9efd7..19390191428 100644
--- a/frontend/src/metabase-lib/lib/Question.ts
+++ b/frontend/src/metabase-lib/lib/Question.ts
@@ -879,6 +879,10 @@ class QuestionInner {
     return this._card && this._card["last-edit-info"];
   }
 
+  lastQueryStart() {
+    return this._card?.last_query_start;
+  }
+
   isSaved(): boolean {
     return !!this.id();
   }
diff --git a/frontend/src/metabase-types/api/card.ts b/frontend/src/metabase-types/api/card.ts
index 1b910ae11a2..250d77fab28 100644
--- a/frontend/src/metabase-types/api/card.ts
+++ b/frontend/src/metabase-types/api/card.ts
@@ -1,3 +1,21 @@
+import { DatasetQuery } from "./query";
+
+export interface Card extends UnsavedCard {
+  id: CardId;
+  name: string;
+  description: string | null;
+  dataset: boolean;
+  can_write: boolean;
+  cache_ttl: number | null;
+  last_query_start: string | null;
+}
+
+export interface UnsavedCard {
+  display: string;
+  dataset_query: DatasetQuery;
+  visualization_settings: VisualizationSettings;
+}
+
 export type VisualizationSettings = {
   [key: string]: any;
 };
@@ -9,4 +27,5 @@ export interface ModerationReview {
   most_recent: boolean;
 }
 
+export type CardId = number;
 export type ModerationReviewStatus = "verified";
diff --git a/frontend/src/metabase-types/api/foreignKey.ts b/frontend/src/metabase-types/api/foreign-key.ts
similarity index 100%
rename from frontend/src/metabase-types/api/foreignKey.ts
rename to frontend/src/metabase-types/api/foreign-key.ts
diff --git a/frontend/src/metabase-types/api/index.ts b/frontend/src/metabase-types/api/index.ts
index 8364d41e44f..32fe209bd10 100644
--- a/frontend/src/metabase-types/api/index.ts
+++ b/frontend/src/metabase-types/api/index.ts
@@ -5,16 +5,17 @@ export * from "./card";
 export * from "./collection";
 export * from "./dashboard";
 export * from "./database";
-export * from "./table";
+export * from "./dataset";
 export * from "./field";
-export * from "./timeline";
-export * from "./settings";
-export * from "./slack";
-export * from "./user";
+export * from "./foreign-key";
 export * from "./group";
-export * from "./permissions";
-export * from "./question";
-export * from "./dataset";
 export * from "./models";
 export * from "./notifications";
+export * from "./permissions";
+export * from "./query";
 export * from "./revision";
+export * from "./settings";
+export * from "./slack";
+export * from "./table";
+export * from "./timeline";
+export * from "./user";
diff --git a/frontend/src/metabase-types/api/mocks/card.ts b/frontend/src/metabase-types/api/mocks/card.ts
index deed6e5220a..41bdb3a80e3 100644
--- a/frontend/src/metabase-types/api/mocks/card.ts
+++ b/frontend/src/metabase-types/api/mocks/card.ts
@@ -1,4 +1,39 @@
-import { ModerationReview } from "metabase-types/api";
+import {
+  ModerationReview,
+  Card,
+  UnsavedCard,
+  VisualizationSettings,
+} from "metabase-types/api";
+import { createMockStructuredDatasetQuery } from "./query";
+
+export const createMockCard = (opts?: Partial<Card>): Card => ({
+  id: 1,
+  name: "Question",
+  description: null,
+  display: "table",
+  dataset_query: createMockStructuredDatasetQuery(),
+  visualization_settings: createMockVisualizationSettings(),
+  dataset: false,
+  can_write: false,
+  cache_ttl: null,
+  last_query_start: null,
+  ...opts,
+});
+
+export const createMockUnsavedCard = (
+  opts?: Partial<UnsavedCard>,
+): UnsavedCard => ({
+  display: "table",
+  dataset_query: createMockStructuredDatasetQuery(),
+  visualization_settings: createMockVisualizationSettings(),
+  ...opts,
+});
+
+export const createMockVisualizationSettings = (
+  opts?: Partial<VisualizationSettings>,
+): VisualizationSettings => ({
+  ...opts,
+});
 
 export const createMockModerationReview = (
   opts?: Partial<ModerationReview>,
diff --git a/frontend/src/metabase-types/api/mocks/index.ts b/frontend/src/metabase-types/api/mocks/index.ts
index da5f4b52d92..8aabfb3f245 100644
--- a/frontend/src/metabase-types/api/mocks/index.ts
+++ b/frontend/src/metabase-types/api/mocks/index.ts
@@ -6,6 +6,7 @@ export * from "./dashboard";
 export * from "./database";
 export * from "./dataset";
 export * from "./models";
+export * from "./query";
 export * from "./table";
 export * from "./timeline";
 export * from "./settings";
diff --git a/frontend/src/metabase-types/api/mocks/query.ts b/frontend/src/metabase-types/api/mocks/query.ts
new file mode 100644
index 00000000000..0ae85cf9d09
--- /dev/null
+++ b/frontend/src/metabase-types/api/mocks/query.ts
@@ -0,0 +1,37 @@
+import {
+  NativeDatasetQuery,
+  NativeQuery,
+  StructuredDatasetQuery,
+  StructuredQuery,
+} from "metabase-types/api";
+
+export const createMockStructuredQuery = (
+  opts?: Partial<StructuredQuery>,
+): StructuredQuery => ({
+  ...opts,
+});
+
+export const createMockNativeQuery = (
+  opts?: Partial<NativeQuery>,
+): NativeQuery => ({
+  query: "SELECT 1",
+  ...opts,
+});
+
+export const createMockStructuredDatasetQuery = (
+  opts?: Partial<StructuredDatasetQuery>,
+): StructuredDatasetQuery => ({
+  type: "query",
+  database: 1,
+  query: createMockStructuredQuery(),
+  ...opts,
+});
+
+export const createMockNativeDatasetQuery = (
+  opts?: Partial<NativeDatasetQuery>,
+): NativeDatasetQuery => ({
+  type: "native",
+  database: 1,
+  query: createMockNativeQuery(),
+  ...opts,
+});
diff --git a/frontend/src/metabase-types/api/query.ts b/frontend/src/metabase-types/api/query.ts
new file mode 100644
index 00000000000..a8a490bee16
--- /dev/null
+++ b/frontend/src/metabase-types/api/query.ts
@@ -0,0 +1,24 @@
+import { DatabaseId } from "./database";
+import { TableId } from "./table";
+
+export interface StructuredQuery {
+  "source-table"?: TableId;
+}
+
+export interface NativeQuery {
+  query: string;
+}
+
+export interface StructuredDatasetQuery {
+  type: "query";
+  database: DatabaseId;
+  query: StructuredQuery;
+}
+
+export interface NativeDatasetQuery {
+  type: "native";
+  database: DatabaseId;
+  query: NativeQuery;
+}
+
+export type DatasetQuery = StructuredDatasetQuery | NativeDatasetQuery;
diff --git a/frontend/src/metabase-types/api/question.ts b/frontend/src/metabase-types/api/question.ts
deleted file mode 100644
index 464b6cddaa5..00000000000
--- a/frontend/src/metabase-types/api/question.ts
+++ /dev/null
@@ -1 +0,0 @@
-export type CardId = number;
diff --git a/frontend/src/metabase-types/api/table.ts b/frontend/src/metabase-types/api/table.ts
index 003007194f9..91e63785d68 100644
--- a/frontend/src/metabase-types/api/table.ts
+++ b/frontend/src/metabase-types/api/table.ts
@@ -1,7 +1,9 @@
-import { ForeignKey } from "../api/foreignKey";
+import { ForeignKey } from "./foreign-key";
 import { Database } from "./database";
 import { Field } from "./field";
 
+export type TableId = number | string; // can be string for virtual questions (e.g. "card__17")
+
 export type VisibilityType =
   | null
   | "details-only"
@@ -13,7 +15,7 @@ export type VisibilityType =
   | "cruft";
 
 export interface Table {
-  id: number | string; // can be string for virtual questions (e.g. "card__17")
+  id: TableId;
   db_id: number;
   db?: Database;
   name: string;
diff --git a/frontend/src/metabase-types/types/Table.ts b/frontend/src/metabase-types/types/Table.ts
index 4597c4ffbbc..6a63bca3379 100644
--- a/frontend/src/metabase-types/types/Table.ts
+++ b/frontend/src/metabase-types/types/Table.ts
@@ -4,7 +4,7 @@ import { Field } from "./Field";
 import { Segment } from "./Segment";
 import { Metric } from "./Metric";
 import { DatabaseId } from "./Database";
-import { ForeignKey } from "../api/foreignKey";
+import { ForeignKey } from "../api/foreign-key";
 
 export type TableId = number;
 export type SchemaName = string;
diff --git a/frontend/src/metabase/visualizations/components/ObjectDetail/ObjectDetail.tsx b/frontend/src/metabase/visualizations/components/ObjectDetail/ObjectDetail.tsx
index d68c5f60c91..8202d0ec56f 100644
--- a/frontend/src/metabase/visualizations/components/ObjectDetail/ObjectDetail.tsx
+++ b/frontend/src/metabase/visualizations/components/ObjectDetail/ObjectDetail.tsx
@@ -6,7 +6,7 @@ import Question from "metabase-lib/lib/Question";
 import { isPK } from "metabase/lib/schema_metadata";
 import { Table } from "metabase-types/types/Table";
 
-import { ForeignKey } from "metabase-types/api/foreignKey";
+import { ForeignKey } from "metabase-types/api";
 import { DatasetData } from "metabase-types/types/Dataset";
 import { ObjectId, OnVisualizationClickType } from "./types";
 
diff --git a/frontend/src/metabase/visualizations/components/ObjectDetail/ObjectRelationships.tsx b/frontend/src/metabase/visualizations/components/ObjectDetail/ObjectRelationships.tsx
index 1a6c20c4db7..3aca8c44144 100644
--- a/frontend/src/metabase/visualizations/components/ObjectDetail/ObjectRelationships.tsx
+++ b/frontend/src/metabase/visualizations/components/ObjectDetail/ObjectRelationships.tsx
@@ -3,7 +3,7 @@ import { t, jt } from "ttag";
 import cx from "classnames";
 import { inflect } from "inflection";
 
-import { ForeignKey } from "metabase-types/api/foreignKey";
+import { ForeignKey } from "metabase-types/api";
 
 import IconBorder from "metabase/components/IconBorder";
 import LoadingSpinner from "metabase/components/LoadingSpinner";
-- 
GitLab