Compare commits

...

8 Commits

Author SHA1 Message Date
Romeo Van Snick
c4f410fe2e Fix more types 2026-03-18 16:31:41 +11:00
Romeo Van Snick
2032155e01 Fix types 2026-03-18 16:31:41 +11:00
Romeo Van Snick
bab2613614 Fix types 2026-03-18 16:31:41 +11:00
Romeo Van Snick
cda59efae8 Convert query_builder selectors to ts 2026-03-18 16:31:40 +11:00
Romeo Van Snick
bf8eff4b81 Convert query_builder reducers to js 2026-03-11 16:11:07 +11:00
Romeo Van Snick
820c4a3526 Convert native editor mocks to ts 2026-03-11 14:20:55 +11:00
Romeo Van Snick
efda4aa47e Convert dataref to ts 2026-03-11 14:20:55 +11:00
Romeo Van Snick
4614779a95 Move query_builder/analytics 2026-03-11 14:20:27 +11:00
54 changed files with 1592 additions and 1175 deletions

View File

@@ -403,8 +403,11 @@ class Question {
* The `visualization_settings` in card object doesn't contain default settings,
* so you can provide the complete visualization settings object to `alertType`
* for taking those into account
*
*/
alertType(visualizationSettings): NotificationTriggerType | null {
alertType(
visualizationSettings?: VisualizationSettings,
): NotificationTriggerType | null {
const display = this.display();
if (!this.canRun()) {

View File

@@ -696,6 +696,11 @@ export type MetricCreatedEvent = ValidateEvent<{
target_id: number | null;
}>;
export type ChartGeneratedEvent = ValidateEvent<{
event: "chart_generated";
event_detail: VisualizationDisplay;
}>;
export type DataStudioEvent =
| DataStudioOpenedEvent
| DataStudioLibraryCreatedEvent
@@ -783,4 +788,5 @@ export type SimpleEvent =
| RemoteSyncEvent
| ClickActionPerformedEvent
| DataStudioEvent
| UnsavedChangesWarningDisplayedEvent;
| UnsavedChangesWarningDisplayedEvent
| ChartGeneratedEvent;

View File

@@ -24,7 +24,7 @@ export const createMockQueryBuilderUIControlsState = (
isShowingRawTable: false,
isNativeEditorOpen: false,
queryBuilderMode: "view",
previousQueryBuilderMode: false,
previousQueryBuilderMode: null,
snippetCollectionId: null,
datasetEditorTab: "query",
isShowingNotebookNativePreview: false,
@@ -35,6 +35,8 @@ export const createMockQueryBuilderUIControlsState = (
dataReferenceStack: null,
highlightedNativeQueryLineNumbers: [],
isShowingListViewConfiguration: false,
questionDetailsTimelineDrawerState: null,
nativeEditorSelectedRange: [],
...opts,
});
@@ -76,6 +78,7 @@ export const createMockQueryBuilderState = (
selectedTimelineEventIds: [],
metadataDiff: {},
visibleTimelineEventIds: [],
currentState: null,

View File

@@ -1,4 +1,5 @@
import type { Deferred } from "metabase/lib/promise";
import type { DataReferenceItem } from "metabase/query_builder/components/dataref/types";
import type { QueryModalType } from "metabase/query_builder/constants";
import type { Widget } from "metabase/visualizations/types";
import type {
@@ -6,6 +7,7 @@ import type {
CollectionItemModel,
Dataset,
Field,
NativeQuerySnippet,
ParameterValuesMap,
TimelineEventId,
} from "metabase-types/api";
@@ -23,6 +25,16 @@ export type ForeignKeyReference = {
value: number;
};
type Position = {
row: number;
column: number;
};
export type Range = {
start: Position;
end: Position;
};
export interface QueryBuilderUIControls {
isModifiedFromNotebook: boolean;
isShowingDataReference: boolean;
@@ -40,8 +52,8 @@ export interface QueryBuilderUIControls {
isShowingAIQuestionAnalysisSidebar: boolean;
initialChartSetting: InitialChartSettingState;
isShowingRawTable: boolean;
queryBuilderMode: QueryBuilderMode | false;
previousQueryBuilderMode: boolean;
queryBuilderMode: QueryBuilderMode;
previousQueryBuilderMode: QueryBuilderMode | null;
snippetCollectionId: number | null;
datasetEditorTab: DatasetEditorTab;
isShowingNotebookNativePreview: boolean;
@@ -49,9 +61,16 @@ export interface QueryBuilderUIControls {
showSidebarTitle: boolean;
modal: QueryModalType | null;
modalContext: TimelineEventId | null;
dataReferenceStack: null;
modalSnippet?:
| NativeQuerySnippet
| Partial<Omit<NativeQuerySnippet, "id">>
| null;
dataReferenceStack: DataReferenceItem[] | null;
highlightedNativeQueryLineNumbers: number[];
isShowingListViewConfiguration: boolean;
scrollToLastColumn?: boolean;
questionDetailsTimelineDrawerState: string | null;
nativeEditorSelectedRange: Range[];
}
export interface QueryBuilderLoadingControls {
@@ -94,4 +113,6 @@ export interface QueryBuilderState {
cardId?: number;
serializedCard: string;
} | null;
visibleTimelineEventIds: TimelineEventId[];
}

View File

@@ -3,6 +3,7 @@ import { t } from "ttag";
import { Button } from "metabase/common/components/Button";
import { DataReference } from "metabase/query_builder/components/dataref/DataReference";
import type { DataReferenceItem } from "metabase/query_builder/components/dataref/types";
import { Tooltip } from "metabase/ui";
export const DataReferenceInline = ({
@@ -12,10 +13,10 @@ export const DataReferenceInline = ({
onClose?: () => void;
onBack?: () => void;
}) => {
const [dataRefStack, setDataRefStack] = useState<any[]>([]);
const [dataRefStack, setDataRefStack] = useState<DataReferenceItem[]>([]);
const pushRefStack = (ref: any) => {
setDataRefStack([...dataRefStack, ref]);
const pushRefStack = (item: DataReferenceItem) => {
setDataRefStack([...dataRefStack, item]);
};
const popRefStack = () => {

View File

@@ -1,7 +1,7 @@
import * as Lib from "metabase-lib";
import type Field from "metabase-lib/v1/metadata/Field";
import { isDate, isID, isNumber } from "metabase-lib/v1/types/utils/isa";
import type { DatasetColumn } from "metabase-types/api";
import type { Field as ApiField, DatasetColumn } from "metabase-types/api";
import { DateTimeFingerprint } from "./DateTimeFingerprint";
import { GlobalFingerprint } from "./GlobalFingerprint";
@@ -14,7 +14,7 @@ type BaseProps = {
};
type TableColumnFingerprintInfoProps = BaseProps & {
field: Field | DatasetColumn;
field: Field | DatasetColumn | ApiField;
};
/**

View File

@@ -2,7 +2,7 @@ import { t } from "ttag";
import * as Lib from "metabase-lib";
import type Field from "metabase-lib/v1/metadata/Field";
import type { DatasetColumn } from "metabase-types/api";
import type { Field as ApiField, DatasetColumn } from "metabase-types/api";
import {
QueryColumnFingerprintInfo,
@@ -15,7 +15,7 @@ import { InfoContainer, Small } from "./ColumnInfo.styled";
export type TableColumnInfoProps = {
className?: string;
field: Field | DatasetColumn;
field: Field | DatasetColumn | ApiField;
timezone?: string;
showAllFieldValues?: boolean;
showFingerprintInfo?: boolean;

View File

@@ -7,7 +7,7 @@ import {
listModelIndexes,
} from "metabase/api";
import type Question from "metabase-lib/v1/Question";
import type { Field, FieldReference } from "metabase-types/api";
import type { DatasetColumn, Field, FieldReference } from "metabase-types/api";
import type { ModelIndex } from "metabase-types/api/modelIndexes";
import type { Dispatch } from "metabase-types/store";
@@ -125,14 +125,14 @@ function getIndexIdsToRemove(
return indexIdsToRemove;
}
export function cleanIndexFlags(fields: Field[] = []) {
export function cleanIndexFlags(fields: (DatasetColumn | Field)[] = []) {
const indexesToClean = fields.reduce(
(
indexesToClean: number[],
field: FieldWithMaybeIndex,
field: FieldWithMaybeIndex | DatasetColumn,
thisIndex: number,
) => {
if (field.should_index !== undefined) {
if ("should_index" in field && field.should_index !== undefined) {
indexesToClean.push(thisIndex);
}
return indexesToClean;

View File

@@ -67,7 +67,7 @@ export const softReloadCard = createThunkAction(SOFT_RELOAD_CARD, () => {
const outdatedCard = getCard(getState());
const action = await dispatch(
Questions.actions.fetch({ id: outdatedCard.id }, { reload: true }),
Questions.actions.fetch({ id: outdatedCard?.id }, { reload: true }),
);
return Questions.HACK_getObjectFromAction(action);
@@ -295,7 +295,7 @@ export const apiUpdateQuestion = (
const { isNative } = Lib.queryDisplayInfo(question.query());
if (!isNative) {
rerunQuery = rerunQuery ?? isResultDirty;
rerunQuery = rerunQuery ?? isResultDirty ?? false;
}
const submittableQuestion = getSubmittableQuestion(getState(), question);

View File

@@ -14,6 +14,7 @@ import type {
} from "metabase-types/api";
import type { Dispatch, GetState } from "metabase-types/store";
import type { DataReferenceItem } from "../components/dataref/types";
import {
getDataReferenceStack,
getNativeEditorCursorOffset,
@@ -44,11 +45,10 @@ export const PUSH_DATA_REFERENCE_STACK =
"metabase/qb/PUSH_DATA_REFERENCE_STACK";
export const pushDataReferenceStack = createThunkAction(
PUSH_DATA_REFERENCE_STACK,
(item: { type: string; item: unknown }) =>
(dispatch: Dispatch, getState: GetState) => {
const stack = getDataReferenceStack(getState());
dispatch(setDataReferenceStack(stack.concat([item])));
},
(item: DataReferenceItem) => (dispatch: Dispatch, getState: GetState) => {
const stack = getDataReferenceStack(getState());
dispatch(setDataReferenceStack(stack.concat([item])));
},
);
export const OPEN_DATA_REFERENCE_AT_QUESTION =
@@ -143,14 +143,17 @@ export const insertSnippet =
return;
}
const query = question.legacyNativeQuery() as NativeQuery;
const nativeEditorCursorOffset = getNativeEditorCursorOffset(getState());
const nativeEditorSelectedText = getNativeEditorSelectedText(getState());
const queryText = query.queryText();
const nativeEditorCursorOffset =
getNativeEditorCursorOffset(getState()) ?? queryText.length;
const nativeEditorSelectedText =
getNativeEditorSelectedText(getState()) ?? "";
const selectionStart =
nativeEditorCursorOffset - (nativeEditorSelectedText || "").length;
nativeEditorCursorOffset - nativeEditorSelectedText.length;
const newText =
query.queryText().slice(0, selectionStart) +
queryText.slice(0, selectionStart) +
`{{snippet: ${name}}}` +
query.queryText().slice(nativeEditorCursorOffset);
queryText.slice(nativeEditorCursorOffset);
const datasetQuery = query.setQueryText(newText).datasetQuery();
dispatch(updateQuestion(question.setDatasetQuery(datasetQuery)));
};

View File

@@ -117,7 +117,7 @@ export const loadObjectDetailFKReferences = createThunkAction(
return null;
}
const card: Card = getCard(state);
const card = getCard(state);
const queryResult = getFirstQueryResult(state);
async function getFKCount(
@@ -174,18 +174,20 @@ export const loadObjectDetailFKReferences = createThunkAction(
// run a query on FK origin table where FK origin field = objectDetailIdValue
const fkReferences: Record<FieldId, FKInfo | undefined> = {};
for (let i = 0; i < tableForeignKeys.length; i++) {
const fk = tableForeignKeys[i];
const info = await getFKCount(card, fk);
if (card) {
for (let i = 0; i < tableForeignKeys.length; i++) {
const fk = tableForeignKeys[i];
const info = await getFKCount(card, fk);
fkReferences[fk.origin_id] = info;
fkReferences[fk.origin_id] = info;
}
}
// It's possible that while we were running those queries, the object
// detail id changed. If so, these fk reference are stale and we shouldn't
// put them in state. The detail id is used in the query so we check that.
const updatedQueryResult = getFirstQueryResult(getState());
if (!_.isEqual(queryResult.json_query, updatedQueryResult.json_query)) {
if (!_.isEqual(queryResult?.json_query, updatedQueryResult?.json_query)) {
return null;
}
return fkReferences;

View File

@@ -205,14 +205,16 @@ export const queryCompleted = (question: Question, queryResults: Dataset[]) => {
const originalQuestion = getOriginalQuestionWithParameterValues(getState());
const { isEditable } = Lib.queryDisplayInfo(question.query());
const isDirty = isEditable && question.isDirtyComparedTo(originalQuestion);
const isDirty =
isEditable &&
(!originalQuestion || question.isDirtyComparedTo(originalQuestion));
if (isDirty) {
const series = [{ card: question.card(), data, error }];
const previousSeries =
prevCard && (prevData || prevError)
prevCard && prevData
? [{ card: prevCard, data: prevData, error: prevError }]
: null;
: undefined;
if (series && previousSeries) {
question = question.setSettings(
syncVizSettingsWithSeries(

View File

@@ -54,43 +54,56 @@ export const setQueryBuilderMode =
}
};
export const onEditSummary = createAction("metabase/qb/EDIT_SUMMARY");
export const onCloseSummary = createAction("metabase/qb/CLOSE_SUMMARY");
export const ON_EDIT_SUMMARY = "metabase/qb/ON_EDIT_SUMMARY";
export const onEditSummary = createAction(ON_EDIT_SUMMARY);
export const ON_CLOSE_SUMMARY = "metabase/qb/ON_CLOSE_SUMMARY";
export const onCloseSummary = createAction(ON_CLOSE_SUMMARY);
export const OPEN_AI_QUESTION_ANALYSIS_SIDEBAR =
"metabase/qb/OPEN_AI_QUESTION_ANALYSIS_SIDEBAR";
export const onOpenAIQuestionAnalysisSidebar = createAction(
"metabase/qb/OPEN_AI_QUESTION_ANALYSIS_SIDEBAR",
OPEN_AI_QUESTION_ANALYSIS_SIDEBAR,
);
export const CLOSE_AI_QUESTION_ANALYSIS_SIDEBAR =
"metabase/qb/CLOSE_AI_QUESTION_ANALYSIS_SIDEBAR";
export const onCloseAIQuestionAnalysisSidebar = createAction(
"metabase/qb/CLOSE_AI_QUESTION_ANALYSIS_SIDEBAR",
CLOSE_AI_QUESTION_ANALYSIS_SIDEBAR,
);
export const onOpenChartSettings = createAction(
"metabase/qb/OPEN_CHART_SETTINGS",
);
export const onCloseChartSettings = createAction(
"metabase/qb/CLOSE_CHART_SETTINGS",
);
export const onOpenChartType = createAction("metabase/qb/OPEN_CHART_TYPE");
export const OPEN_CHART_SETTINGS = "metabase/qb/OPEN_CHART_SETTINGS";
export const onOpenChartSettings = createAction(OPEN_CHART_SETTINGS);
export const onOpenQuestionInfo = createAction(
"metabase/qb/OPEN_QUESTION_INFO",
);
export const onCloseQuestionInfo = createAction(
"metabase/qb/CLOSE_QUESTION_INFO",
);
export const CLOSE_CHART_SETTINGS = "metabase/qb/CLOSE_CHART_SETTINGS";
export const onCloseChartSettings = createAction(CLOSE_CHART_SETTINGS);
export const onOpenQuestionSettings = createAction(
"metabase/qb/OPEN_QUESTION_SETTINGS",
);
export const onCloseQuestionSettings = createAction(
"metabase/qb/CLOSE_QUESTION_SETTINGS",
);
export const OPEN_CHART_TYPE = "metabase/qb/OPEN_CHART_TYPE";
export const onOpenChartType = createAction(OPEN_CHART_TYPE);
export const onOpenTimelines = createAction("metabase/qb/OPEN_TIMELINES");
export const onCloseTimelines = createAction("metabase/qb/CLOSE_TIMELINES");
export const OPEN_QUESTION_INFO = "metabase/qb/OPEN_QUESTION_INFO";
export const onOpenQuestionInfo = createAction(OPEN_QUESTION_INFO);
export const onCloseChartType = createAction("metabase/qb/CLOSE_CHART_TYPE");
export const onCloseSidebars = createAction("metabase/qb/CLOSE_SIDEBARS");
export const CLOSE_QUESTION_INFO = "metabase/qb/CLOSE_QUESTION_INFO";
export const onCloseQuestionInfo = createAction(CLOSE_QUESTION_INFO);
export const OPEN_QUESTION_SETTINGS = "metabase/qb/OPEN_QUESTION_SETTINGS";
export const onOpenQuestionSettings = createAction(OPEN_QUESTION_SETTINGS);
export const CLOSE_QUESTION_SETTINGS = "metabase/qb/CLOSE_QUESTION_SETTINGS";
export const onCloseQuestionSettings = createAction(CLOSE_QUESTION_SETTINGS);
export const OPEN_TIMELINES = "metabase/qb/OPEN_TIMELINES";
export const onOpenTimelines = createAction(OPEN_TIMELINES);
export const CLOSE_TIMELINES = "metabase/qb/CLOSE_TIMELINES";
export const onCloseTimelines = createAction(CLOSE_TIMELINES);
export const CLOSE_CHART_TYPE = "metabase/qb/CLOSE_CHART_TYPE";
export const onCloseChartType = createAction(CLOSE_CHART_TYPE);
export const CLOSE_SIDEBARS = "metabase/qb/CLOSE_SIDEBARS";
export const onCloseSidebars = createAction(CLOSE_SIDEBARS);
export const SHOW_CHART_SETTINGS = "metabase/qb/SHOW_CHART_SETTINGS";
export const showChartSettings = createAction(SHOW_CHART_SETTINGS);

View File

@@ -78,7 +78,9 @@ export const updateUrl = createThunkAction(
datasetEditorTab = getDatasetEditorTab(getState());
}
const card = isAdHocModelOrMetric ? getCard(getState()) : question.card();
const card = isAdHocModelOrMetric
? getCard(getState())!
: question.card();
const newState = {
card,
cardId: question.id(),

View File

@@ -1,29 +1,35 @@
import { trackSchemaEvent, trackSimpleEvent } from "metabase/lib/analytics";
import * as Lib from "metabase-lib";
import type Question from "metabase-lib/v1/Question";
import type { NewQuestionSavedEvent } from "metabase-types/analytics";
import type { Card } from "metabase-types/api";
export const trackNewQuestionSaved = (
draftQuestion,
createdQuestion,
isBasedOnExistingQuestion,
draftQuestion: Question,
createdQuestion: Question,
isBasedOnExistingQuestion?: boolean,
) => {
trackSchemaEvent("question", {
event: "new_question_saved",
question_id: createdQuestion.id(),
database_id: createdQuestion.databaseId(),
visualization_type: createdQuestion.display(),
type: draftQuestion.creationType(),
source: isBasedOnExistingQuestion ? "existing_question" : "from_scratch",
type: draftQuestion.creationType() as NewQuestionSavedEvent["type"],
method: isBasedOnExistingQuestion ? "existing_question" : "from_scratch",
});
};
export const trackTurnIntoModelClicked = (question) => {
export const trackTurnIntoModelClicked = (question: Question) => {
trackSchemaEvent("question", {
event: "turn_into_model_clicked",
question_id: question.id(),
});
};
export const trackNotebookNativePreviewShown = (question, isShown) => {
export const trackNotebookNativePreviewShown = (
question: Question,
isShown?: boolean,
) => {
trackSchemaEvent("question", {
event: isShown
? "notebook_native_preview_shown"
@@ -33,7 +39,10 @@ export const trackNotebookNativePreviewShown = (question, isShown) => {
});
};
export const trackColumnCombineViaShortcut = (query, question) => {
export const trackColumnCombineViaShortcut = (
query: Lib.Query,
question?: Question,
) => {
trackSchemaEvent("question", {
event: "column_combine_via_shortcut",
custom_expressions_used: ["concat"],
@@ -42,7 +51,10 @@ export const trackColumnCombineViaShortcut = (query, question) => {
});
};
export const trackColumnCombineViaPlusModal = (query, question) => {
export const trackColumnCombineViaPlusModal = (
query: Lib.Query,
question?: Question,
) => {
trackSchemaEvent("question", {
event: "column_combine_via_plus_modal",
custom_expressions_used: ["concat"],
@@ -52,10 +64,10 @@ export const trackColumnCombineViaPlusModal = (query, question) => {
};
export const trackColumnExtractViaShortcut = (
query,
stageIndex,
extraction,
question,
query: Lib.Query,
stageIndex: number,
extraction: Lib.ColumnExtraction,
question?: Question,
) => {
trackSchemaEvent("question", {
event: "column_extract_via_shortcut",
@@ -70,10 +82,10 @@ export const trackColumnExtractViaShortcut = (
};
export const trackColumnExtractViaPlusModal = (
query,
stageIndex,
extraction,
question,
query: Lib.Query,
stageIndex: number,
extraction: Lib.ColumnExtraction,
question?: Question,
) => {
trackSchemaEvent("question", {
event: "column_extract_via_plus_modal",
@@ -87,14 +99,14 @@ export const trackColumnExtractViaPlusModal = (
});
};
export const trackFirstNonTableChartGenerated = (card) => {
export const trackFirstNonTableChartGenerated = (card: Card) => {
trackSimpleEvent({
event: "chart_generated",
event_detail: card.display,
});
};
export const trackCardBookmarkAdded = (card) => {
export const trackCardBookmarkAdded = (card: Card) => {
trackSimpleEvent({
event: "bookmark_added",
event_detail: card.type,

View File

@@ -61,6 +61,9 @@ const defaultDatasetEditorProps = {
toggleSnippetSidebar: noop,
toggleTemplateTagsEditor: noop,
runDirtyQuestionQuery: noop,
dataReferenceStack: [],
pushDataReferenceStack: noop,
popDataReferenceStack: noop,
};
const renderDatasetEditor = async (card: Card | UnsavedCard) => {

View File

@@ -74,7 +74,13 @@ import type {
VisualizationDisplay,
VisualizationSettings,
} from "metabase-types/api";
import type { DatasetEditorTab, QueryBuilderMode } from "metabase-types/store";
import type {
DatasetEditorTab,
QueryBuilderMode,
State,
} from "metabase-types/store";
import type { DataReferenceItem } from "../dataref/types";
import DatasetEditorS from "./DatasetEditor.module.css";
import {
@@ -95,11 +101,11 @@ export type DatasetEditorInnerProps = {
metadata?: Metadata;
metadataDiff: MetadataDiff;
resultsMetadata?: ResultsMetadata | null;
isMetadataDirty: boolean;
isMetadataDirty?: boolean;
result?: { error?: unknown } | null;
height?: number;
isDirty: boolean;
isResultDirty: boolean;
isResultDirty?: boolean;
isRunning: boolean;
setQueryBuilderMode: (
mode: QueryBuilderMode,
@@ -129,12 +135,16 @@ export type DatasetEditorInnerProps = {
toggleDataReference: () => void;
toggleSnippetSidebar: () => void;
forwardedRef?: React.Ref<HTMLDivElement>;
dataReferenceStack: DataReferenceItem[];
pushDataReferenceStack: (item: DataReferenceItem) => void;
popDataReferenceStack: () => void;
};
const INITIAL_NOTEBOOK_EDITOR_HEIGHT = 500;
const TABLE_HEADER_HEIGHT = 45;
function mapStateToProps(state: any) {
function mapStateToProps(state: State) {
return {
metadata: getMetadata(state),
metadataDiff: getMetadataDiff(state),
@@ -753,7 +763,9 @@ const DatasetEditorInnerView = (props: DatasetEditorInnerProps) => {
isShowingDetailsOnlyColumns={datasetEditorTab !== "metadata"}
hasMetadataPopovers={false}
handleVisualizationClick={handleTableElementClick}
tableHeaderHeight={isEditingColumns && TABLE_HEADER_HEIGHT}
tableHeaderHeight={
isEditingColumns ? TABLE_HEADER_HEIGHT : undefined
}
renderTableHeader={renderTableHeader}
scrollToColumn={focusedFieldIndex + scrollToColumnModifier}
renderEmptyMessage={isEditingColumns}

View File

@@ -52,7 +52,7 @@ import {
getInitialEditorHeight,
} from "./utils";
type NativeQueryEditorProps = Omit<CodeMirrorEditorProps, "query"> & {
export type NativeQueryEditorProps = Omit<CodeMirrorEditorProps, "query"> & {
availableHeight?: number;
canChangeDatabase?: boolean;
cancelQuery?: () => void;

View File

@@ -1,7 +1,11 @@
/* eslint-disable react/prop-types */
import type { ChangeEvent } from "react";
import CS from "metabase/css/core/index.css";
import { DataSourceSelectors } from "metabase/query_builder/components/NativeQueryEditor/DataSourceSelectors";
import { SyncedParametersList } from "metabase/query_builder/components/SyncedParametersList";
import type { DatabaseId, TableId } from "metabase-types/api";
import type { NativeQueryEditorProps } from "../NativeQueryEditor";
export const NativeQueryEditor = ({
canChangeDatabase = true,
@@ -12,18 +16,18 @@ export const NativeQueryEditor = ({
readOnly,
setDatasetQuery,
setParameterValue,
}) => {
const onChange = (evt) => {
}: NativeQueryEditorProps) => {
const onChange = (evt: ChangeEvent<HTMLTextAreaElement>) => {
setDatasetQuery(query.setQueryText(evt.target.value));
};
const onDatabaseIdChange = (databaseId) => {
const onDatabaseIdChange = (databaseId: DatabaseId) => {
if (question.databaseId() !== databaseId) {
setDatasetQuery(query.setDatabaseId(databaseId).setDefaultCollection());
}
};
const onTableIdChange = (tableId) => {
const onTableIdChange = (tableId: TableId) => {
const table = query.metadata().table(tableId);
if (table && table.name !== query.collection()) {
setDatasetQuery(query.setCollectionName(table.name));

View File

@@ -1,10 +1,10 @@
/* eslint-disable react/prop-types */
import cx from "classnames";
import { useState } from "react";
import { type ReactNode, useState } from "react";
import { useTimeout } from "react-use";
import { c, t } from "ttag";
import EmptyCodeResult from "assets/img/empty-states/code.svg";
import type { NavigateToNewCardParams } from "embedding-sdk-bundle/types";
import { LoadingSpinner } from "metabase/common/components/LoadingSpinner";
import CS from "metabase/css/core/index.css";
import QueryBuilderS from "metabase/css/query_builder.module.css";
@@ -13,8 +13,16 @@ import { SERVER_ERROR_TYPES } from "metabase/lib/errors";
import { useSelector } from "metabase/lib/redux";
import { getWhiteLabeledLoadingMessageFactory } from "metabase/selectors/whitelabel";
import { Box, Flex, Stack, Text, Title } from "metabase/ui";
import type {
ClickActionsMode,
QueryClickActionsMode,
} from "metabase/visualizations/types";
import * as Lib from "metabase-lib";
import type Question from "metabase-lib/v1/Question";
import { HARD_ROW_LIMIT } from "metabase-lib/v1/queries/utils";
import type { Dataset, DatasetColumn, RawSeries } from "metabase-types/api";
import type { EntityToken } from "metabase-types/api/entity";
import type { QueryBuilderMode } from "metabase-types/store";
import { RunButtonWithTooltip } from "./RunButtonWithTooltip";
import { VisualizationError } from "./VisualizationError";
@@ -23,7 +31,47 @@ import { Warnings } from "./Warnings";
const SLOW_MESSAGE_TIMEOUT = 4000;
export function QueryVisualization(props) {
type QueryVisualizationProps = {
className?: string;
question: Question;
isRunnable?: boolean;
isRunning?: boolean;
isObjectDetail?: boolean;
isDirty?: boolean;
isResultDirty?: boolean;
isNativeEditorOpen?: boolean;
isDirtyStateShownForError?: boolean;
runQuestionQuery?: () => void;
cancelQuery?: () => void;
queryBuilderMode?: QueryBuilderMode;
result?: Dataset | Pick<Dataset, "error" | "error_type"> | null;
rawSeries?: RawSeries | null;
maxTableRows?: number;
navigateToNewCardInsideQB?:
| ((params: NavigateToNewCardParams) => Promise<void>)
| null;
onNavigateBack?: () => void;
onHeaderColumnReorder?: (colIndex: number) => void;
onUpdateQuestion?: () => void;
isShowingDetailsOnlyColumns?: boolean;
hasMetadataPopovers?: boolean;
handleVisualizationClick?: (clicked: {
element: unknown;
column: DatasetColumn;
}) => void;
tableHeaderHeight?: number;
renderTableHeader?: (
column: DatasetColumn,
columnIndex: number,
) => JSX.Element;
noHeader?: boolean;
mode?: QueryClickActionsMode | ClickActionsMode | null | undefined;
token?: EntityToken | null | undefined;
scrollToColumn?: number;
renderEmptyMessage?: boolean;
};
export function QueryVisualization(props: QueryVisualizationProps) {
const {
className,
question,
@@ -38,7 +86,7 @@ export function QueryVisualization(props) {
} = props;
const canRun = Lib.canRun(question.query(), question.type());
const [warnings, setWarnings] = useState([]);
const [warnings, setWarnings] = useState<string[]>([]);
const isDirtyStateShown =
canRun &&
isResultDirty &&
@@ -91,12 +139,14 @@ export function QueryVisualization(props) {
) : result?.data ? (
<VisualizationResult
{...props}
result={result as Dataset}
rawSeries={props.rawSeries ?? undefined}
maxTableRows={maxTableRows}
className={CS.spread}
onUpdateWarnings={setWarnings}
/>
) : !isRunning && !isDirtyStateShown ? (
<VisualizationEmptyState className={CS.spread}>
<VisualizationEmptyState>
{t`Here's where your results will appear`}
</VisualizationEmptyState>
) : null}
@@ -105,7 +155,7 @@ export function QueryVisualization(props) {
);
}
const VisualizationEmptyState = ({ children }) => {
const VisualizationEmptyState = ({ children }: { children: ReactNode }) => {
const keyboardShortcut = getRunQueryShortcut();
return (
@@ -133,7 +183,7 @@ export function VisualizationRunningState({ className = "" }) {
// show the slower loading message only when the loadingMessage is
// not customized
const message = getLoadingMessage(isSlow());
const message = getLoadingMessage(isSlow() ?? false);
return (
<Flex
@@ -153,12 +203,18 @@ export function VisualizationRunningState({ className = "" }) {
export const VisualizationDirtyState = ({
className,
result,
isRunning,
isResultDirty,
runQuestionQuery,
cancelQuery,
hidden,
}: {
className?: string;
isRunning?: boolean;
isResultDirty?: boolean;
runQuestionQuery: () => void;
cancelQuery: () => void;
hidden?: boolean;
}) => {
const keyboardShortcut = getRunQueryShortcut();

View File

@@ -1,4 +1,3 @@
/* eslint-disable react/prop-types */
import { t } from "ttag";
import { duration } from "metabase/lib/formatting";

View File

@@ -1,177 +0,0 @@
/* eslint-disable react/prop-types */
import cx from "classnames";
import { Component } from "react";
import { jt, t } from "ttag";
import _ from "underscore";
import { ErrorMessage } from "metabase/common/components/ErrorMessage";
import ButtonsS from "metabase/css/components/buttons.module.css";
import CS from "metabase/css/core/index.css";
import { isEmbeddingSdk } from "metabase/embedding-sdk/config";
import { CreateOrEditQuestionAlertModalWithQuestion } from "metabase/notifications/modals/CreateOrEditQuestionAlertModal/CreateOrEditQuestionAlertModal";
import Visualization from "metabase/visualizations/components/Visualization";
import * as Lib from "metabase-lib";
import { ALERT_TYPE_ROWS } from "metabase-lib/v1/Alert";
import { datasetContainsNoResults } from "metabase-lib/v1/queries/utils/dataset";
const ALLOWED_VISUALIZATION_PROPS = [
// Table
"isShowingDetailsOnlyColumns",
// Table Interactive
"hasMetadataPopovers",
"tableHeaderHeight",
"scrollToColumn",
"renderTableHeader",
"mode",
"renderEmptyMessage",
"zoomedRowIndex",
// Legend
"hideLegend",
];
export class VisualizationResult extends Component {
state = {
showCreateAlertModal: false,
};
showCreateAlertModal = () => {
this.setState({ showCreateAlertModal: true });
};
onCloseCreateAlertModal = () => {
this.setState({ showCreateAlertModal: false });
};
getObjectDetailData = (series) => {
return [
{
...series[0],
card: { ...series[0].card, display: "object" },
},
];
};
render() {
const {
question,
token,
isDirty,
queryBuilderMode,
navigateToNewCardInsideQB,
result,
rawSeries,
timelineEvents,
selectedTimelineEventIds,
onNavigateBack,
className,
isRunning,
isShowingSummarySidebar,
onEditSummary,
renderEmptyMessage,
} = this.props;
const { showCreateAlertModal } = this.state;
const noResults = datasetContainsNoResults(result.data);
if (noResults && !isRunning && !renderEmptyMessage) {
const supportsRowsPresentAlert = question.alertType() === ALERT_TYPE_ROWS;
const supportsBackToPreviousResult =
!isEmbeddingSdk() || !!onNavigateBack;
// successful query but there were 0 rows returned with the result
return (
<div className={cx(className, CS.flex)}>
<ErrorMessage
type="noRows"
title={t`No results!`}
message={t`This may be the answer you’re looking for. If not, try removing or changing your filters to make them less specific.`}
action={
<div>
{supportsRowsPresentAlert && !isDirty && (
<p>
{jt`You can also ${(
<a
className={CS.link}
key="link"
onClick={this.showCreateAlertModal}
>
{t`get an alert`}
</a>
)} when there are some results.`}
</p>
)}
{supportsBackToPreviousResult && (
<button
className={ButtonsS.Button}
onClick={() =>
onNavigateBack ? onNavigateBack() : window.history.back()
}
>
{t`Back to previous results`}
</button>
)}
</div>
}
/>
{showCreateAlertModal && (
<CreateOrEditQuestionAlertModalWithQuestion
onClose={this.onCloseCreateAlertModal}
onAlertCreated={this.onCloseCreateAlertModal}
/>
)}
</div>
);
} else {
const vizSpecificProps = _.pick(
this.props,
...ALLOWED_VISUALIZATION_PROPS,
);
const { isEditable } = Lib.queryDisplayInfo(question.query());
const hasDrills = isEditable;
return (
<>
<Visualization
className={className}
rawSeries={rawSeries}
onChangeCardAndRun={
hasDrills ? navigateToNewCardInsideQB : undefined
}
isEditing={true}
isObjectDetail={false}
isQueryBuilder={true}
isShowingSummarySidebar={isShowingSummarySidebar}
isRunning={isRunning}
onEditSummary={onEditSummary}
queryBuilderMode={queryBuilderMode}
showTitle={false}
canToggleSeriesVisibility
metadata={question.metadata()}
timelineEvents={timelineEvents}
token={token}
selectedTimelineEventIds={selectedTimelineEventIds}
handleVisualizationClick={this.props.handleVisualizationClick}
onOpenTimelines={this.props.onOpenTimelines}
onSelectTimelineEvents={this.props.selectTimelineEvents}
onDeselectTimelineEvents={this.props.deselectTimelineEvents}
onOpenChartSettings={this.props.onOpenChartSettings}
onUpdateQuestion={this.props.onUpdateQuestion}
onUpdateWarnings={this.props.onUpdateWarnings}
onHeaderColumnReorder={this.props.onHeaderColumnReorder}
onUpdateVisualizationSettings={
this.props.onUpdateVisualizationSettings
}
onVisualizationRendered={this.props.onVisualizationRendered}
{...vizSpecificProps}
/>
{this.props.isObjectDetail && (
<Visualization
isObjectDetail={true}
rawSeries={this.getObjectDetailData(rawSeries)}
/>
)}
</>
);
}
}
}

View File

@@ -0,0 +1,211 @@
import cx from "classnames";
import { useState } from "react";
import { jt, t } from "ttag";
import _ from "underscore";
import type { NavigateToNewCardParams } from "embedding-sdk-bundle/types";
import { ErrorMessage } from "metabase/common/components/ErrorMessage";
import ButtonsS from "metabase/css/components/buttons.module.css";
import CS from "metabase/css/core/index.css";
import { isEmbeddingSdk } from "metabase/embedding-sdk/config";
import { CreateOrEditQuestionAlertModalWithQuestion } from "metabase/notifications/modals/CreateOrEditQuestionAlertModal/CreateOrEditQuestionAlertModal";
import Visualization from "metabase/visualizations/components/Visualization";
import * as Lib from "metabase-lib";
import { ALERT_TYPE_ROWS } from "metabase-lib/v1/Alert";
import type Question from "metabase-lib/v1/Question";
import { datasetContainsNoResults } from "metabase-lib/v1/queries/utils/dataset";
import type {
Dataset,
DatasetColumn,
RawSeries,
TimelineEvent,
TimelineEventId,
} from "metabase-types/api";
import type { QueryBuilderMode } from "metabase-types/store";
const ALLOWED_VISUALIZATION_PROPS = [
// Table
"isShowingDetailsOnlyColumns",
// Table Interactive
"hasMetadataPopovers",
"tableHeaderHeight",
"scrollToColumn",
"renderTableHeader",
"mode",
"renderEmptyMessage",
"zoomedRowIndex",
// Legend
"hideLegend",
];
type VisualizationResultProps = {
className?: string;
isDirty?: boolean;
isObjectDetail?: boolean;
isRunning?: boolean;
navigateToNewCardInsideQB?:
| ((opts: NavigateToNewCardParams) => Promise<void>)
| null;
queryBuilderMode?: QueryBuilderMode;
question: Question;
rawSeries?: RawSeries | null;
renderEmptyMessage?: boolean;
result: Dataset;
maxTableRows?: number;
deselectTimelineEvents?: () => void;
onOpenTimelines?: () => void;
selectTimelineEvents?: (events: TimelineEvent[]) => void;
selectedTimelineEventIds?: TimelineEventId[];
timelineEvents?: TimelineEvent[];
isShowingSummarySidebar?: boolean;
onEditSummary?: () => void;
handleVisualizationClick?: (
clicked: {
element: unknown;
column: DatasetColumn;
} | null,
) => void;
onHeaderColumnReorder?: (columnName: string) => void;
onNavigateBack?: () => void;
onOpenChartSettings?: () => void;
onUpdateQuestion?: () => void;
onUpdateVisualizationSettings?: () => void;
onUpdateWarnings?: (warnings: string[]) => void;
onVisualizationRendered?: () => void;
};
const getObjectDetailData = (series: RawSeries): RawSeries => [
{
...series[0],
card: { ...series[0].card, display: "object" },
},
];
export function VisualizationResult(props: VisualizationResultProps) {
const {
question,
isDirty,
queryBuilderMode,
navigateToNewCardInsideQB,
result,
rawSeries,
timelineEvents,
selectedTimelineEventIds,
onNavigateBack,
className,
isRunning,
isShowingSummarySidebar,
onEditSummary,
renderEmptyMessage,
handleVisualizationClick,
onOpenTimelines,
selectTimelineEvents,
deselectTimelineEvents,
onOpenChartSettings,
onUpdateQuestion,
onUpdateWarnings,
onHeaderColumnReorder,
onUpdateVisualizationSettings,
onVisualizationRendered,
isObjectDetail,
} = props;
const [showCreateAlertModal, setShowCreateAlertModal] = useState(false);
const noResults = datasetContainsNoResults(result.data);
if (noResults && !isRunning && !renderEmptyMessage) {
const supportsRowsPresentAlert = question.alertType() === ALERT_TYPE_ROWS;
const supportsBackToPreviousResult = !isEmbeddingSdk() || !!onNavigateBack;
// successful query but there were 0 rows returned with the result
return (
<div className={cx(className, CS.flex)}>
<ErrorMessage
type="noRows"
title={t`No results!`}
message={t`This may be the answer you’re looking for. If not, try removing or changing your filters to make them less specific.`}
action={
<div>
{supportsRowsPresentAlert && !isDirty && (
<p>
{jt`You can also ${(
<a
className={CS.link}
key="link"
onClick={() => setShowCreateAlertModal(true)}
>
{t`get an alert`}
</a>
)} when there are some results.`}
</p>
)}
{supportsBackToPreviousResult && (
<button
className={ButtonsS.Button}
onClick={() =>
onNavigateBack ? onNavigateBack() : window.history.back()
}
>
{t`Back to previous results`}
</button>
)}
</div>
}
/>
{showCreateAlertModal && (
<CreateOrEditQuestionAlertModalWithQuestion
onClose={() => setShowCreateAlertModal(false)}
onAlertCreated={() => setShowCreateAlertModal(false)}
/>
)}
</div>
);
} else {
const vizSpecificProps = _.pick(props, ...ALLOWED_VISUALIZATION_PROPS);
const { isEditable } = Lib.queryDisplayInfo(question.query());
const hasDrills = isEditable;
return (
<>
<Visualization
className={className}
rawSeries={rawSeries ?? undefined}
onChangeCardAndRun={hasDrills ? navigateToNewCardInsideQB : undefined}
isEditing={true}
isObjectDetail={false}
isQueryBuilder={true}
isShowingSummarySidebar={isShowingSummarySidebar}
isRunning={isRunning}
onEditSummary={onEditSummary}
queryBuilderMode={queryBuilderMode}
showTitle={false}
canToggleSeriesVisibility
metadata={question.metadata()}
timelineEvents={timelineEvents}
selectedTimelineEventIds={selectedTimelineEventIds}
onDeselectTimelineEvents={deselectTimelineEvents}
handleVisualizationClick={handleVisualizationClick}
onOpenTimelines={onOpenTimelines}
onSelectTimelineEvents={selectTimelineEvents}
onOpenChartSettings={onOpenChartSettings}
onUpdateQuestion={onUpdateQuestion}
onUpdateWarnings={onUpdateWarnings}
onHeaderColumnReorder={onHeaderColumnReorder}
onUpdateVisualizationSettings={onUpdateVisualizationSettings}
onVisualizationRendered={onVisualizationRendered}
{...vizSpecificProps}
/>
{isObjectDetail && (
<Visualization
isObjectDetail={true}
rawSeries={rawSeries ? getObjectDetailData(rawSeries) : undefined}
/>
)}
</>
);
}
}

View File

@@ -1,58 +0,0 @@
/* eslint "react/prop-types": "warn" */
import PropTypes from "prop-types";
import { useCallback } from "react";
import { DatabasePane } from "./DatabasePane";
import { FieldPane } from "./FieldPane";
import { MainPane } from "./MainPane";
import { QuestionPane } from "./QuestionPane";
import { SchemaPane } from "./SchemaPane";
import { TablePane } from "./TablePane";
const PANES = {
database: DatabasePane, // lists schemas, tables and models of a database
schema: SchemaPane, // lists tables of a schema
table: TablePane, // lists fields of a table
question: QuestionPane, // lists fields of a question
field: FieldPane, // field details and metadata
};
const DataReferencePropTypes = {
dataReferenceStack: PropTypes.array.isRequired,
popDataReferenceStack: PropTypes.func.isRequired,
pushDataReferenceStack: PropTypes.func.isRequired,
onClose: PropTypes.func,
onBack: PropTypes.func,
};
export const DataReference = ({
dataReferenceStack,
popDataReferenceStack,
pushDataReferenceStack,
onClose,
onBack,
}) => {
const onItemClick = useCallback(
(type, item) => pushDataReferenceStack({ type, item }),
[pushDataReferenceStack],
);
if (dataReferenceStack.length) {
const page = dataReferenceStack[dataReferenceStack.length - 1];
const Pane = PANES[page.type];
return (
<Pane
{...{ [page.type]: page.item }}
onItemClick={onItemClick}
onClose={onClose}
onBack={popDataReferenceStack}
/>
);
} else {
return (
<MainPane onItemClick={onItemClick} onClose={onClose} onBack={onBack} />
);
}
};
DataReference.propTypes = DataReferencePropTypes;

View File

@@ -0,0 +1,45 @@
import type { ComponentType } from "react";
import { MainPane } from "./MainPane";
import {
type DataReferenceItem,
type DataReferencePaneProps,
PANES,
} from "./types";
export const DataReference = ({
dataReferenceStack,
popDataReferenceStack,
pushDataReferenceStack,
onClose,
onBack,
}: {
dataReferenceStack: DataReferenceItem[];
popDataReferenceStack: () => void;
pushDataReferenceStack: (item: DataReferenceItem) => void;
onClose?: () => void;
onBack?: () => void;
}) => {
if (dataReferenceStack.length) {
const page = dataReferenceStack[dataReferenceStack.length - 1];
const Pane = PANES[page.type] as ComponentType<
DataReferencePaneProps<typeof page>
>;
return (
<Pane
{...page}
onItemClick={pushDataReferenceStack}
onClose={onClose}
onBack={popDataReferenceStack}
/>
);
} else {
return (
<MainPane
onItemClick={pushDataReferenceStack}
onClose={onClose}
onBack={onBack}
/>
);
}
};

View File

@@ -17,11 +17,13 @@ import type {
} from "metabase/common/components/tree/types";
import CS from "metabase/css/core/index.css";
import { SidebarContent } from "metabase/query_builder/components/SidebarContent";
import type Database from "metabase-lib/v1/metadata/Database";
import type {
CollectionId,
SchemaName,
SearchResult,
import {
type Card,
type CollectionId,
type SchemaName,
type SearchResult,
type Table,
isConcreteTableId,
} from "metabase-types/api";
import {
@@ -34,6 +36,11 @@ import {
NodeListTitleText,
} from "./NodeList";
import { ResourceTreeNode } from "./ResourceTreeNode";
import type {
DataReferenceDatabaseItem,
DataReferencePaneProps,
OnItemClick,
} from "./types";
const groupModelsByCollection = (models: SearchResult[]) => {
const grouped = _.groupBy(
@@ -77,21 +84,13 @@ const groupTablesBySchema = (tables: SearchResult[]) => {
);
};
export interface TablesListProps {
export type TablesListProps = {
schemas: string[];
tables: SearchResult[];
onItemClick: (type: string, item: unknown) => void;
}
onItemClick: OnItemClick;
};
const TablesList = ({
schemas,
tables,
onItemClick,
}: {
schemas: string[];
tables: SearchResult[];
onItemClick: (type: string, item: unknown) => void;
}) => {
const TablesList = ({ schemas, tables, onItemClick }: TablesListProps) => {
const hasMultipleSchemas = schemas.length > 1;
if (hasMultipleSchemas) {
@@ -117,10 +116,15 @@ const TablesList = ({
</NodeListTitle>
<Tree
data={tablesBySchema}
TreeNode={(props: TreeNodeProps<ITreeNodeItem>) => (
<ResourceTreeNode
TreeNode={(props: TreeNodeProps<Table>) => (
<ResourceTreeNode<Table>
{...props}
onItemClick={() => onItemClick("table", props.item.data)}
onItemClick={() => {
const { id } = props.item.data ?? {};
if (isConcreteTableId(id)) {
onItemClick({ type: "table", id });
}
}}
/>
)}
/>
@@ -145,7 +149,11 @@ const TablesList = ({
<li key={table.id}>
<NodeListItemLink
disabled={table.initial_sync_status !== "complete"}
onClick={() => onItemClick("table", table)}
onClick={() => {
if (isConcreteTableId(table.id)) {
onItemClick({ type: "table", id: table.id });
}
}}
>
<NodeListItemIcon
disabled={table.initial_sync_status !== "complete"}
@@ -164,18 +172,12 @@ const TablesList = ({
);
};
export interface CollectionsListProps {
export type CollectionsListProps = {
models: SearchResult[];
onItemClick: (type: string, item: unknown) => void;
}
onItemClick: OnItemClick;
};
const CollectionsList = ({
models,
onItemClick,
}: {
models: SearchResult[];
onItemClick: (type: string, item: unknown) => void;
}) => {
const CollectionsList = ({ models, onItemClick }: CollectionsListProps) => {
if (models.length === 0) {
return null;
}
@@ -202,10 +204,14 @@ const CollectionsList = ({
</NodeListTitle>
<Tree
data={modelsByCollection}
TreeNode={(props: TreeNodeProps<ITreeNodeItem>) => (
<ResourceTreeNode
TreeNode={(props: TreeNodeProps<Card>) => (
<ResourceTreeNode<Card>
{...props}
onItemClick={() => onItemClick("question", props.item.data)}
onItemClick={() => {
if (props.item.data) {
onItemClick({ type: "question", id: props.item.data.id });
}
}}
displayId
/>
)}
@@ -214,21 +220,15 @@ const CollectionsList = ({
);
};
export interface DatabasePaneProps {
database: Database;
onBack: () => void;
onClose: () => void;
onItemClick: (type: string, item: unknown) => void;
}
export type DatabasePaneProps =
DataReferencePaneProps<DataReferenceDatabaseItem>;
export const DatabasePane = ({
database,
id: databaseId,
onBack,
onClose,
onItemClick,
}: DatabasePaneProps) => {
const databaseId = database.id;
const {
data: databaseData,
isLoading: isLoadingDatabase,

View File

@@ -50,7 +50,8 @@ const completeTableSearchResult = createMockSearchResult({
});
const defaultProps: DatabasePaneProps = {
database,
type: "database",
id: database.id,
onBack: jest.fn(),
onClose: jest.fn(),
onItemClick: jest.fn(),
@@ -161,7 +162,7 @@ describe("DatabasePane", () => {
initial_sync_status: "complete",
});
setup({ database: singleSchemaDatabase });
setup({ id: singleSchemaDatabase.id });
setupDatabaseEndpoints(singleSchemaDb);
setupSearchEndpoints([table1]);
@@ -211,7 +212,7 @@ describe("DatabasePane", () => {
initial_sync_status: "complete",
});
setup({ database: multiSchemaDatabase });
setup({ id: multiSchemaDatabase.id });
setupDatabaseEndpoints(multiSchemaDb);
setupSearchEndpoints([table1, table2]);
@@ -232,7 +233,7 @@ describe("DatabasePane", () => {
emptySchemasMetadata.database(emptySchemasDb.id),
);
setup({ database: emptySchemasDatabase });
setup({ id: emptySchemasDatabase.id });
setupDatabaseEndpoints(emptySchemasDb);
setupSearchEndpoints([]);
@@ -314,7 +315,7 @@ describe("DatabasePane", () => {
initial_sync_status: "complete",
});
setup({ database: multiSchemaDatabase });
setup({ id: multiSchemaDatabase.id });
setupDatabaseEndpoints(multiSchemaDb);
setupSearchEndpoints([table1, table2]);

View File

@@ -1,25 +1,35 @@
import { useGetFieldQuery } from "metabase/api";
import { LoadingAndErrorWrapper } from "metabase/common/components/LoadingAndErrorWrapper";
import { TableColumnInfo } from "metabase/common/components/MetadataInfo/ColumnInfo";
import { SidebarContent } from "metabase/query_builder/components/SidebarContent";
import type Field from "metabase-lib/v1/metadata/Field";
interface FieldPaneProps {
onBack: () => void;
onClose: () => void;
field: Field;
}
import type { DataReferenceFieldItem, DataReferencePaneProps } from "./types";
export const FieldPane = ({
onBack,
onClose,
id,
}: DataReferencePaneProps<DataReferenceFieldItem>) => {
const { data: field, isLoading, error } = useGetFieldQuery({ id });
if (!field || isLoading || error) {
return (
<LoadingAndErrorWrapper loading={isLoading || !field} error={error} />
);
}
export const FieldPane = ({ onBack, onClose, field }: FieldPaneProps) => {
return (
<SidebarContent
title={field.name}
icon={"field"}
icon="field"
onBack={onBack}
onClose={onClose}
>
<SidebarContent.Pane>
<TableColumnInfo
field={field}
timezone={field.table?.database?.timezone}
// TODO
// timezone={field.table?.database?.timezone}
showAllFieldValues
showFingerprintInfo
/>

View File

@@ -1,46 +0,0 @@
/* eslint "react/prop-types": "warn" */
import cx from "classnames";
import PropTypes from "prop-types";
import { t } from "ttag";
import CS from "metabase/css/core/index.css";
import { Databases } from "metabase/entities/databases";
import { SidebarContent } from "metabase/query_builder/components/SidebarContent";
import {
NodeListItemIcon,
NodeListItemLink,
NodeListItemName,
} from "./NodeList";
const MainPaneInner = ({ databases, onClose, onItemClick, onBack }) => (
<SidebarContent title={t`Data Reference`} onClose={onClose} onBack={onBack}>
<SidebarContent.Pane>
<p className={cx(CS.mt2, CS.mb3, CS.textSpaced)}>
{t`Browse the contents of your databases, tables, and columns. Pick a database to get started.`}
</p>
<ul>
{databases &&
databases.map((database) => (
<li key={database.id}>
<NodeListItemLink
onClick={() => onItemClick("database", database)}
>
<NodeListItemIcon name="database" />
<NodeListItemName>{database.name}</NodeListItemName>
</NodeListItemLink>
</li>
))}
</ul>
</SidebarContent.Pane>
</SidebarContent>
);
MainPaneInner.propTypes = {
databases: PropTypes.array,
onClose: PropTypes.func,
onBack: PropTypes.func,
onItemClick: PropTypes.func.isRequired,
};
export const MainPane = Databases.loadList()(MainPaneInner);

View File

@@ -0,0 +1,50 @@
import cx from "classnames";
import { t } from "ttag";
import { useListDatabasesQuery } from "metabase/api";
import { LoadingAndErrorWrapper } from "metabase/common/components/LoadingAndErrorWrapper";
import CS from "metabase/css/core/index.css";
import { SidebarContent } from "metabase/query_builder/components/SidebarContent";
import {
NodeListItemIcon,
NodeListItemLink,
NodeListItemName,
} from "./NodeList";
import type { DataReferencePaneProps } from "./types";
export const MainPane = ({
onClose,
onItemClick,
onBack,
}: DataReferencePaneProps) => {
const { data, isLoading, error } = useListDatabasesQuery();
if (isLoading || error) {
return <LoadingAndErrorWrapper loading={isLoading} error={error} />;
}
return (
<SidebarContent title={t`Data Reference`} onClose={onClose} onBack={onBack}>
<SidebarContent.Pane>
<p className={cx(CS.mt2, CS.mb3, CS.textSpaced)}>
{t`Browse the contents of your databases, tables, and columns. Pick a database to get started.`}
</p>
<ul>
{data?.data?.map((database) => (
<li key={database.id}>
<NodeListItemLink
onClick={() =>
onItemClick({ type: "database", id: database.id })
}
>
<NodeListItemIcon name="database" />
<NodeListItemName>{database.name}</NodeListItemName>
</NodeListItemLink>
</li>
))}
</ul>
</SidebarContent.Pane>
</SidebarContent>
);
};

View File

@@ -20,24 +20,16 @@ import { Box, Flex, Icon, type IconName } from "metabase/ui";
import type Question from "metabase-lib/v1/Question";
import { getQuestionVirtualTableId } from "metabase-lib/v1/metadata/utils/saved-questions";
import * as ML_Urls from "metabase-lib/v1/urls";
import type { CardId } from "metabase-types/api";
import { FieldList } from "../FieldList";
import { NodeListTitleText } from "../NodeList";
import type {
DataReferencePaneProps,
DataReferenceQuestionItem,
} from "../types";
import S from "./QuestionPane.module.css";
type QuestionItem = {
id: CardId;
};
type QuestionPaneProps = {
question: QuestionItem;
onItemClick: (type: string, item: unknown) => void;
onBack: () => void;
onClose: () => void;
};
const getIcon = (question: Question): IconName => {
return match(question.type())
.returnType<IconName>()
@@ -48,11 +40,11 @@ const getIcon = (question: Question): IconName => {
};
export const QuestionPane = ({
question: { id },
id,
onBack,
onItemClick,
onClose,
}: QuestionPaneProps) => {
}: DataReferencePaneProps<DataReferenceQuestionItem>) => {
const {
data: card,
isLoading: isLoadingCard,
@@ -147,7 +139,11 @@ export const QuestionPane = ({
{table.fields && (
<FieldList
fields={table.fields}
onFieldClick={(f) => onItemClick("field", f)}
onFieldClick={(field) => {
if (typeof field.id === "number") {
onItemClick({ type: "field", id: field.id });
}
}}
/>
)}
</Box>

View File

@@ -1,7 +1,4 @@
import type {
ITreeNodeItem,
TreeNodeProps,
} from "metabase/common/components/tree/types";
import type { TreeNodeProps } from "metabase/common/components/tree/types";
import type { IconName } from "metabase/ui";
import {
@@ -12,14 +9,14 @@ import {
} from "./NodeList";
import S from "./ResourceTreeNode.module.css";
export const ResourceTreeNode = ({
export const ResourceTreeNode = <TData = unknown,>({
item,
depth,
isExpanded,
displayId = false,
onToggleExpand,
onItemClick,
}: TreeNodeProps<ITreeNodeItem> & {
}: TreeNodeProps<TData> & {
displayId?: boolean;
onItemClick: () => void;
}) => {

View File

@@ -1,10 +1,9 @@
import { useMemo } from "react";
import { msgid, ngettext } from "ttag";
import { Schemas } from "metabase/entities/schemas";
import { useListDatabaseSchemaTablesQuery } from "metabase/api";
import { LoadingAndErrorWrapper } from "metabase/common/components/LoadingAndErrorWrapper";
import { SidebarContent } from "metabase/query_builder/components/SidebarContent";
import type Schema from "metabase-lib/v1/metadata/Schema";
import type { State } from "metabase-types/store";
import { isConcreteTableId } from "metabase-types/api";
import {
NodeListContainer,
@@ -15,27 +14,30 @@ import {
NodeListTitle,
NodeListTitleText,
} from "./NodeList";
import type { DataReferencePaneProps, DataReferenceSchemaItem } from "./types";
interface SchemaPaneProps {
onBack: () => void;
onClose: () => void;
onItemClick: (type: string, item: unknown) => void;
schema: Schema;
}
type SchemaPaneProps = DataReferencePaneProps<DataReferenceSchemaItem>;
const SchemaPaneInner = ({
export const SchemaPane = ({
onBack,
onClose,
onItemClick,
schema,
schemaName,
databaseId,
}: SchemaPaneProps) => {
const tables = useMemo(
() => schema.getTables().sort((a, b) => a.name.localeCompare(b.name)),
[schema],
);
const {
data: tables = [],
isLoading,
error,
} = useListDatabaseSchemaTablesQuery({ id: databaseId, schema: schemaName });
if (isLoading || error) {
return <LoadingAndErrorWrapper loading={isLoading} error={error} />;
}
return (
<SidebarContent
title={schema.name}
title={schemaName}
icon={"folder"}
onBack={onBack}
onClose={onClose}
@@ -53,21 +55,22 @@ const SchemaPaneInner = ({
</NodeListTitleText>
</NodeListTitle>
<ul>
{tables.map((table) => (
<li key={table.id}>
<NodeListItemLink onClick={() => onItemClick("table", table)}>
<NodeListItemIcon name="table" />
<NodeListItemName>{table.name}</NodeListItemName>
</NodeListItemLink>
</li>
))}
{tables.map(
({ id, name }) =>
isConcreteTableId(id) && (
<li key={id}>
<NodeListItemLink
onClick={() => onItemClick({ type: "table", id })}
>
<NodeListItemIcon name="table" />
<NodeListItemName>{name}</NodeListItemName>
</NodeListItemLink>
</li>
),
)}
</ul>
</NodeListContainer>
</SidebarContent.Pane>
</SidebarContent>
);
};
export const SchemaPane = Schemas.load({
id: (_state: State, props: SchemaPaneProps) => props.schema.id,
})(SchemaPaneInner);

View File

@@ -11,7 +11,7 @@ import { useSelector } from "metabase/lib/redux";
import { SidebarContent } from "metabase/query_builder/components/SidebarContent";
import { ConnectedTableList } from "metabase/query_builder/components/dataref/ConnectedTableList";
import { getMetadata } from "metabase/selectors/metadata";
import type { ConcreteTableId } from "metabase-types/api";
import { isConcreteTableId } from "metabase-types/api";
import { FieldList } from "./FieldList";
import {
@@ -24,24 +24,14 @@ import {
NodeListTitleText,
} from "./NodeList";
import { TableInfoLoader } from "./TableInfoLoader";
type TableItem = {
id: ConcreteTableId;
};
type TablePaneProps = {
table: TableItem;
onBack: () => void;
onClose: () => void;
onItemClick: (type: string, item: unknown) => void;
};
import type { DataReferencePaneProps, DataReferenceTableItem } from "./types";
export function TablePane({
table: { id },
id,
onItemClick,
onBack,
onClose,
}: TablePaneProps) {
}: DataReferencePaneProps<DataReferenceTableItem>) {
const { isLoading, error } = useGetTableQuery({ id });
const metadata = useSelector(getMetadata);
@@ -75,12 +65,20 @@ export function TablePane({
<>
<FieldList
fields={table.fields}
onFieldClick={(f) => onItemClick("field", f)}
onFieldClick={(field) => {
if (typeof field.id === "number") {
onItemClick({ type: "field", id: field.id });
}
}}
/>
{table.connectedTables() && (
<ConnectedTableList
tables={table.connectedTables()}
onTableClick={(t) => onItemClick("table", t)}
onTableClick={(table) => {
if (isConcreteTableId(table.id)) {
onItemClick({ type: "table", id: table.id });
}
}}
/>
)}
</>
@@ -101,7 +99,12 @@ export function TablePane({
{table.metrics?.map((metric) => (
<li key={metric.card().id}>
<NodeListItemLink
onClick={() => onItemClick("question", metric.card())}
onClick={() =>
onItemClick({
type: "question",
id: metric.card().id,
})
}
>
<NodeListItemIcon name="metric" />
<NodeListItemName>

View File

@@ -0,0 +1,61 @@
import type {
CardId,
ConcreteTableId,
DatabaseId,
FieldId,
SchemaName,
} from "metabase-types/api";
import { DatabasePane } from "./DatabasePane";
import { FieldPane } from "./FieldPane";
import { QuestionPane } from "./QuestionPane";
import { SchemaPane } from "./SchemaPane";
import { TablePane } from "./TablePane";
export const PANES = {
database: DatabasePane, // lists schemas, tables and models of a database
schema: SchemaPane, // lists tables of a schema
table: TablePane, // lists fields of a table
question: QuestionPane, // lists fields of a question
field: FieldPane, // field details and metadata
};
export type OnItemClick = (item: DataReferenceItem) => void;
export type DataReferencePaneProps<TItem = unknown> = {
onClose?: () => void;
onBack?: () => void;
onItemClick: OnItemClick;
} & TItem;
export type DataReferenceItem =
| DataReferenceDatabaseItem
| DataReferenceSchemaItem
| DataReferenceTableItem
| DataReferenceQuestionItem
| DataReferenceFieldItem;
export type DataReferenceDatabaseItem = {
type: "database";
id: DatabaseId;
};
export type DataReferenceSchemaItem = {
type: "schema";
schemaName: SchemaName;
databaseId: DatabaseId;
};
export type DataReferenceTableItem = {
type: "table";
id: ConcreteTableId;
};
export type DataReferenceQuestionItem = {
type: "question";
id: CardId;
};
export type DataReferenceFieldItem = {
type: "field";
id: FieldId;
};

View File

@@ -1,6 +1,7 @@
import { t } from "ttag";
import { FixedSizeIcon, Flex, Tooltip } from "metabase/ui";
import type { Dataset } from "metabase-types/api";
import { formatDuration } from "./utils";
@@ -34,5 +35,5 @@ export const ExecutionTime = ({ time }: Props) => {
);
};
ExecutionTime.shouldRender = ({ result }: { result: any }) =>
result && !result.cached && result.running_time !== undefined;
ExecutionTime.shouldRender = ({ result }: { result: Dataset | null }) =>
Boolean(result && !result.cached && result.running_time !== undefined);

View File

@@ -3,6 +3,7 @@ import { match } from "ts-pattern";
import { PLUGIN_AI_ENTITY_ANALYSIS } from "metabase/plugins";
import type { EmbeddingParameterVisibility } from "metabase/public/lib/types";
import { DataReference } from "metabase/query_builder/components/dataref/DataReference";
import type { DataReferenceItem } from "metabase/query_builder/components/dataref/types";
import { SnippetSidebar } from "metabase/query_builder/components/template_tags/SnippetSidebar";
import { TagEditorSidebar } from "metabase/query_builder/components/template_tags/TagEditorSidebar";
import { QuestionInfoSidebar } from "metabase/query_builder/components/view/sidebars/QuestionInfoSidebar";
@@ -51,6 +52,10 @@ interface NativeQueryRightSidebarProps {
getEmbeddedParameterVisibility: (
slug: string,
) => EmbeddingParameterVisibility;
dataReferenceStack: DataReferenceItem[];
pushDataReferenceStack: (item: DataReferenceItem) => void;
popDataReferenceStack: () => void;
}
export const NativeQueryRightSidebar = (

View File

@@ -28,11 +28,11 @@ export const RightViewFooterButtonGroup = () => {
result,
isObjectDetail,
}) && <QuestionRowCount />}
{ExecutionTime.shouldRender({ result }) && (
{result && ExecutionTime.shouldRender({ result }) && (
<ExecutionTime time={result.running_time} />
)}
<Group gap="sm" wrap="nowrap">
{QuestionLastUpdated.shouldRender({ result }) && (
{result && QuestionLastUpdated.shouldRender({ result }) && (
<QuestionLastUpdated
className={cx(CS.hide, CS.smShow)}
result={result}

View File

@@ -16,6 +16,7 @@ export const ViewFooterDownloadWidget = () => {
return (
question &&
result &&
QuestionDownloadPopover.shouldRender({ result }) && (
<QuestionDownloadPopover
className={cx(CS.hide, CS.smShow)}

View File

@@ -41,9 +41,12 @@ export function QueryBuilderBackButton({
onClick?.();
};
const url = Urls.modelToUrl(parent);
if (!parent.model) {
return null;
}
if (!parent.model || !url) {
const url = Urls.modelToUrl(parent as Urls.UrlableModel);
if (!url) {
return null;
}

View File

@@ -269,7 +269,9 @@ function QueryBuilderInner(props: QueryBuilderInnerProps) {
!didTrackFirstNonTableChartGeneratedRef.current &&
isNonTable
) {
setDidFirstNonTableChartRender(card);
if (card) {
setDidFirstNonTableChartRender(card);
}
didTrackFirstNonTableChartGeneratedRef.current = true;
}
},

View File

@@ -21,7 +21,7 @@ export const DEFAULT_UI_CONTROLS: QueryBuilderUIControls = {
initialChartSetting: {},
isShowingRawTable: false, // table/viz toggle
queryBuilderMode: "view", // "view" | "notebook" | "dataset"
previousQueryBuilderMode: false,
previousQueryBuilderMode: null,
snippetCollectionId: null,
datasetEditorTab: "query",
isShowingQuestionDetailsSidebar: false,
@@ -33,6 +33,8 @@ export const DEFAULT_UI_CONTROLS: QueryBuilderUIControls = {
modalContext: null,
highlightedNativeQueryLineNumbers: [],
isShowingListViewConfiguration: false,
nativeEditorSelectedRange: [],
questionDetailsTimelineDrawerState: null,
};
export const DEFAULT_LOADING_CONTROLS: QueryBuilderLoadingControls = {

View File

@@ -1,533 +0,0 @@
import { assoc, merge } from "icepick";
import { handleActions } from "redux-actions";
import _ from "underscore";
import {
EDIT_QUESTION,
NAVIGATE_TO_NEW_CARD,
} from "metabase/dashboard/actions";
import { TimelineEvents } from "metabase/entities/timeline-events";
import { copy } from "metabase/lib/utils";
import {
API_CREATE_QUESTION,
API_UPDATE_QUESTION,
CANCEL_QUERY,
CANCEL_QUESTION_CHANGES,
CLEAR_OBJECT_DETAIL_FK_REFERENCES,
CLEAR_QUERY_RESULT,
CLOSE_QB,
CLOSE_QB_NEWB_MODAL,
DESELECT_TIMELINE_EVENTS,
HIDE_TIMELINE_EVENTS,
INITIALIZE_QB,
LOAD_OBJECT_DETAIL_FK_REFERENCES,
OPEN_DATA_REFERENCE_AT_QUESTION,
QUERY_COMPLETED,
QUERY_ERRORED,
RELOAD_CARD,
RESET_QB,
RESET_ROW_ZOOM,
RESET_UI_CONTROLS,
RUN_QUERY,
SELECT_TIMELINE_EVENTS,
SET_CARD_AND_RUN,
SET_CURRENT_STATE,
SET_DATA_REFERENCE_STACK,
SET_DOCUMENT_TITLE,
SET_DOCUMENT_TITLE_TIMEOUT_ID,
SET_IS_SHOWING_TEMPLATE_TAGS_EDITOR,
SET_METADATA_DIFF,
SET_MODAL_SNIPPET,
SET_NATIVE_EDITOR_SELECTED_RANGE,
SET_PARAMETER_VALUE,
SET_SHOW_LOADING_COMPLETE_FAVICON,
SET_SNIPPET_COLLECTION_ID,
SET_UI_CONTROLS,
SHOW_CHART_SETTINGS,
SHOW_TIMELINE_EVENTS,
TOGGLE_DATA_REFERENCE,
TOGGLE_SNIPPET_SIDEBAR,
TOGGLE_TEMPLATE_TAGS_EDITOR,
UPDATE_QUESTION,
ZOOM_IN_ROW,
onCloseAIQuestionAnalysisSidebar,
onCloseChartSettings,
onCloseChartType,
onCloseQuestionInfo,
onCloseQuestionSettings,
onCloseSidebars,
onCloseSummary,
onCloseTimelines,
onEditSummary,
onOpenAIQuestionAnalysisSidebar,
onOpenChartSettings,
onOpenChartType,
onOpenQuestionInfo,
onOpenQuestionSettings,
onOpenTimelines,
} from "./actions";
import {
CLOSED_NATIVE_EDITOR_SIDEBARS,
DEFAULT_LOADING_CONTROLS,
DEFAULT_PARENT_ENTITY_STATE,
DEFAULT_QUERY_STATUS,
DEFAULT_UI_CONTROLS,
UI_CONTROLS_SIDEBAR_DEFAULTS,
} from "./defaults";
function setUIControls(state, changes) {
const { queryBuilderMode: currentQBMode, ...currentState } = state;
const { queryBuilderMode: nextQBMode, ...nextStateChanges } = changes;
const isChangingQBMode = nextQBMode && currentQBMode !== nextQBMode;
const isOpeningEditingQBMode = isChangingQBMode && nextQBMode !== "view";
const queryBuilderMode = nextQBMode || currentQBMode;
const previousQueryBuilderMode = isChangingQBMode
? currentQBMode
: state.previousQueryBuilderMode;
// Close all the sidebars when entering notebook/dataset QB modes
const extraState = isOpeningEditingQBMode ? UI_CONTROLS_SIDEBAR_DEFAULTS : {};
return {
...currentState,
...extraState,
...nextStateChanges,
queryBuilderMode,
previousQueryBuilderMode,
};
}
export const uiControls = handleActions(
{
[SET_UI_CONTROLS]: {
next: (state, { payload }) => setUIControls(state, payload),
},
[RESET_UI_CONTROLS]: {
next: (state) => ({
...DEFAULT_UI_CONTROLS,
isRunning: state.isRunning,
}),
},
[INITIALIZE_QB]: {
next: (state, { payload }) => {
return {
...state,
...DEFAULT_UI_CONTROLS,
...payload.uiControls,
};
},
},
[UPDATE_QUESTION]: {
next: (state, { payload }) => ({
...state,
highlightedNativeQueryLineNumbers:
DEFAULT_UI_CONTROLS.highlightedNativeQueryLineNumbers,
}),
},
[TOGGLE_DATA_REFERENCE]: {
next: (state, { payload }) => ({
...state,
...CLOSED_NATIVE_EDITOR_SIDEBARS,
isShowingDataReference: !state.isShowingDataReference,
}),
},
[SET_DATA_REFERENCE_STACK]: {
next: (state, { payload }) => ({
...state,
dataReferenceStack: payload,
}),
},
[OPEN_DATA_REFERENCE_AT_QUESTION]: {
next: (state, { payload }) => {
return payload
? {
...state,
dataReferenceStack: payload,
isShowingDataReference: true,
}
: state;
},
},
[TOGGLE_TEMPLATE_TAGS_EDITOR]: {
next: (state, { payload }) => ({
...state,
...CLOSED_NATIVE_EDITOR_SIDEBARS,
isShowingTemplateTagsEditor: !state.isShowingTemplateTagsEditor,
}),
},
[TOGGLE_SNIPPET_SIDEBAR]: {
next: (state, { payload }) => ({
...state,
...CLOSED_NATIVE_EDITOR_SIDEBARS,
isShowingSnippetSidebar: !state.isShowingSnippetSidebar,
snippetCollectionId: null,
}),
},
[SET_IS_SHOWING_TEMPLATE_TAGS_EDITOR]: {
next: (state, { isShowingTemplateTagsEditor }) => ({
...state,
...CLOSED_NATIVE_EDITOR_SIDEBARS,
isShowingTemplateTagsEditor,
}),
},
[SET_NATIVE_EDITOR_SELECTED_RANGE]: (state, { payload }) => ({
...state,
nativeEditorSelectedRange: payload,
}),
[SET_MODAL_SNIPPET]: (state, { payload }) => ({
...state,
modalSnippet: payload,
}),
[SET_SNIPPET_COLLECTION_ID]: (state, { payload }) => ({
...state,
snippetCollectionId: payload,
}),
[CLOSE_QB_NEWB_MODAL]: {
next: (state, { payload }) => ({ ...state, isShowingNewbModal: false }),
},
[RUN_QUERY]: (state) => ({
...state,
isRunning: true,
highlightedNativeQueryLineNumbers:
DEFAULT_UI_CONTROLS.highlightedNativeQueryLineNumbers,
}),
[CANCEL_QUERY]: {
next: (state, { payload }) => ({ ...state, isRunning: false }),
},
[QUERY_COMPLETED]: {
next: (state, { payload }) => ({
...state,
isRunning: false,
}),
},
[QUERY_ERRORED]: {
next: (state, { payload }) => ({ ...state, isRunning: false }),
},
[SHOW_CHART_SETTINGS]: {
next: (state, { payload }) => ({
...state,
...UI_CONTROLS_SIDEBAR_DEFAULTS,
isShowingChartSettingsSidebar: true,
initialChartSetting: payload,
}),
},
[CANCEL_QUESTION_CHANGES]: {
next: (state) => ({
...state,
isModifiedFromNotebook: false,
}),
},
// AGGREGATION
[onEditSummary]: (state) => ({
...state,
...UI_CONTROLS_SIDEBAR_DEFAULTS,
isShowingSummarySidebar: true,
}),
[onCloseSummary]: (state) => ({
...state,
...UI_CONTROLS_SIDEBAR_DEFAULTS,
}),
[onOpenAIQuestionAnalysisSidebar]: (state) => ({
...state,
...UI_CONTROLS_SIDEBAR_DEFAULTS,
isShowingAIQuestionAnalysisSidebar: true,
}),
[onCloseAIQuestionAnalysisSidebar]: (state) => ({
...state,
...UI_CONTROLS_SIDEBAR_DEFAULTS,
}),
[onOpenChartSettings]: (
state,
{ payload: { initialChartSettings, showSidebarTitle = false } = {} },
) => ({
...state,
...UI_CONTROLS_SIDEBAR_DEFAULTS,
isShowingChartSettingsSidebar: true,
initialChartSetting: initialChartSettings,
showSidebarTitle: showSidebarTitle,
}),
[onCloseChartSettings]: (state) => ({
...state,
...UI_CONTROLS_SIDEBAR_DEFAULTS,
}),
[onOpenChartType]: (state) => ({
...state,
...UI_CONTROLS_SIDEBAR_DEFAULTS,
isShowingChartTypeSidebar: true,
}),
[onCloseChartType]: (state) => ({
...state,
...UI_CONTROLS_SIDEBAR_DEFAULTS,
}),
[onOpenQuestionInfo]: (state) =>
setUIControls(state, {
...UI_CONTROLS_SIDEBAR_DEFAULTS,
...CLOSED_NATIVE_EDITOR_SIDEBARS,
isShowingQuestionInfoSidebar: true,
queryBuilderMode: "view",
}),
[onCloseQuestionInfo]: (state) => ({
...state,
isShowingQuestionInfoSidebar: false,
}),
[onOpenQuestionSettings]: (state) =>
setUIControls(state, {
...UI_CONTROLS_SIDEBAR_DEFAULTS,
isShowingQuestionSettingsSidebar: true,
queryBuilderMode: "view",
}),
[onCloseQuestionSettings]: (state) => ({
...state,
isShowingQuestionSettingsSidebar: false,
}),
[onOpenTimelines]: (state) => ({
...state,
...UI_CONTROLS_SIDEBAR_DEFAULTS,
...CLOSED_NATIVE_EDITOR_SIDEBARS,
isShowingTimelineSidebar: true,
}),
[onCloseTimelines]: (state) => ({
...state,
...UI_CONTROLS_SIDEBAR_DEFAULTS,
}),
[onCloseSidebars]: (state) => ({
...state,
...UI_CONTROLS_SIDEBAR_DEFAULTS,
}),
},
DEFAULT_UI_CONTROLS,
);
export const loadingControls = handleActions(
{
[SET_DOCUMENT_TITLE]: (state, { payload }) => ({
...state,
documentTitle: payload,
}),
[SET_SHOW_LOADING_COMPLETE_FAVICON]: (state, { payload }) => ({
...state,
showLoadCompleteFavicon: payload,
}),
[SET_DOCUMENT_TITLE_TIMEOUT_ID]: (state, { payload }) => ({
...state,
timeoutId: payload,
}),
},
DEFAULT_LOADING_CONTROLS,
);
export const queryStatus = handleActions(
{
[RUN_QUERY]: (state) => "running",
[QUERY_COMPLETED]: (state) => "complete",
[CANCEL_QUERY]: (state) => "idle",
},
DEFAULT_QUERY_STATUS,
);
export const zoomedRowObjectId = handleActions(
{
[INITIALIZE_QB]: {
next: (state, { payload }) => payload?.objectId ?? null,
},
[ZOOM_IN_ROW]: {
next: (state, { payload }) => payload.objectId,
},
[RESET_ROW_ZOOM]: { next: () => null },
[RESET_QB]: { next: () => null },
},
null,
);
// a copy of the card being worked on at it's last known saved state. if the card is NEW then this should be null.
// NOTE: we use JSON serialization/deserialization to ensure a deep clone of the object which is required
// because we can't have any links between the active card being modified and the "originalCard" for testing dirtiness
// ALSO: we consistently check for payload.id because an unsaved card has no "originalCard"
export const originalCard = handleActions(
{
[INITIALIZE_QB]: {
next: (state, { payload }) =>
payload.originalCard ? copy(payload.originalCard) : null,
},
[RELOAD_CARD]: {
next: (state, { payload }) => (payload.id ? copy(payload) : null),
},
[SET_CARD_AND_RUN]: {
next: (state, { payload }) =>
payload.originalCard ? copy(payload.originalCard) : null,
},
[API_CREATE_QUESTION]: {
next: (state, { payload }) => copy(payload),
},
[API_UPDATE_QUESTION]: {
next: (state, { payload }) => copy(payload),
},
},
null,
);
// references to FK tables specifically used on the ObjectDetail page.
export const tableForeignKeyReferences = handleActions(
{
[LOAD_OBJECT_DETAIL_FK_REFERENCES]: {
next: (state, { payload }) => payload,
},
[CLEAR_OBJECT_DETAIL_FK_REFERENCES]: () => null,
},
null,
);
export const lastRunCard = handleActions(
{
[RESET_QB]: { next: (state, { payload }) => null },
[QUERY_COMPLETED]: { next: (state, { payload }) => payload.card },
[QUERY_ERRORED]: { next: (state, { payload }) => null },
},
null,
);
// The results of a query execution. optionally an error if the query fails to complete successfully.
export const queryResults = handleActions(
{
[RESET_QB]: { next: (state, { payload }) => null },
[QUERY_COMPLETED]: {
next: (state, { payload: { queryResults } }) => queryResults,
},
[QUERY_ERRORED]: {
next: (state, { payload }) => (payload ? [payload] : state),
},
[CLEAR_QUERY_RESULT]: { next: (state, { payload }) => null },
},
null,
);
export const metadataDiff = handleActions(
{
[RESET_QB]: { next: () => ({}) },
[API_CREATE_QUESTION]: { next: () => ({}) },
[API_UPDATE_QUESTION]: { next: () => ({}) },
[SET_METADATA_DIFF]: {
next: (state, { payload }) => {
const { name, changes } = payload;
return {
...state,
[name]: state[name] ? merge(state[name], changes) : changes,
};
},
},
[CANCEL_QUESTION_CHANGES]: { next: () => ({}) },
},
{},
);
// promise used for tracking a query execution in progress. when a query is started we capture this.
export const cancelQueryDeferred = handleActions(
{
[RUN_QUERY]: {
next: (state, { payload: { cancelQueryDeferred } }) =>
cancelQueryDeferred,
},
[CANCEL_QUERY]: { next: (state, { payload }) => null },
[QUERY_COMPLETED]: { next: (state, { payload }) => null },
[QUERY_ERRORED]: { next: (state, { payload }) => null },
},
null,
);
export const queryStartTime = handleActions(
{
[RUN_QUERY]: { next: (state, { payload }) => performance.now() },
[CANCEL_QUERY]: { next: (state, { payload }) => null },
[QUERY_COMPLETED]: { next: (state, { payload }) => null },
[QUERY_ERRORED]: { next: (state, { payload }) => null },
},
null,
);
export const parameterValues = handleActions(
{
[INITIALIZE_QB]: {
next: (state, { payload: { parameterValues } }) => parameterValues,
},
[SET_PARAMETER_VALUE]: {
next: (state, { payload: { id, value } }) => assoc(state, id, value),
},
},
{},
);
export const currentState = handleActions(
{
[SET_CURRENT_STATE]: { next: (state, { payload }) => payload },
},
null,
);
export const parentEntity = handleActions(
{
[NAVIGATE_TO_NEW_CARD]: {
next: (_state, { payload: { id, model, name } }) => ({
id,
model,
name,
isEditing: false,
}),
},
[EDIT_QUESTION]: {
next: (_state, { payload: { id, model, name } }) => ({
id,
model,
name,
isEditing: true,
}),
},
[CLOSE_QB]: { next: () => DEFAULT_PARENT_ENTITY_STATE },
},
DEFAULT_PARENT_ENTITY_STATE,
);
export const visibleTimelineEventIds = handleActions(
{
[INITIALIZE_QB]: { next: () => [] },
[SHOW_TIMELINE_EVENTS]: {
next: (state, { payload: events }) =>
_.uniq([...state, ...events.map((event) => event.id)]),
},
[HIDE_TIMELINE_EVENTS]: {
next: (state, { payload: events }) => {
const eventIdsToHide = events.map((event) => event.id);
return state.filter((eventId) => !eventIdsToHide.includes(eventId));
},
},
[TimelineEvents.actionTypes.CREATE]: {
next: (state, { payload }) => [...state, payload.timelineEvent.id],
},
[RESET_QB]: { next: () => [] },
},
[],
);
export const selectedTimelineEventIds = handleActions(
{
[INITIALIZE_QB]: { next: () => [] },
[SELECT_TIMELINE_EVENTS]: {
next: (state, { payload: events = [] }) => events.map((e) => e.id),
},
[DESELECT_TIMELINE_EVENTS]: {
next: () => [],
},
[onCloseTimelines]: { next: () => [] },
[RESET_QB]: { next: () => [] },
},
[],
);
export * from "./reducers-typed";

View File

@@ -0,0 +1,622 @@
import { createReducer } from "@reduxjs/toolkit";
import { assoc, merge } from "icepick";
import _ from "underscore";
import {
EDIT_QUESTION,
NAVIGATE_TO_NEW_CARD,
} from "metabase/dashboard/actions";
import { TimelineEvents } from "metabase/entities/timeline-events";
import type { Deferred } from "metabase/lib/promise";
import { copy } from "metabase/lib/utils";
import type {
Card,
CollectionItemModel,
Dataset,
Field,
NativeQuerySnippet,
ParameterValuesMap,
TimelineEvent,
} from "metabase-types/api";
import type {
ForeignKeyReference,
InitialChartSettingState,
QueryBuilderLoadingControls,
QueryBuilderParentEntityState,
QueryBuilderQueryStatus,
QueryBuilderUIControls,
Range,
} from "metabase-types/store";
import {
API_CREATE_QUESTION,
API_UPDATE_QUESTION,
CANCEL_QUERY,
CANCEL_QUESTION_CHANGES,
CLEAR_OBJECT_DETAIL_FK_REFERENCES,
CLEAR_QUERY_RESULT,
CLOSE_AI_QUESTION_ANALYSIS_SIDEBAR,
CLOSE_CHART_SETTINGS,
CLOSE_CHART_TYPE,
CLOSE_QB,
CLOSE_QB_NEWB_MODAL,
CLOSE_QUESTION_INFO,
CLOSE_QUESTION_SETTINGS,
CLOSE_SIDEBARS,
CLOSE_TIMELINES,
DESELECT_TIMELINE_EVENTS,
HIDE_TIMELINE_EVENTS,
INITIALIZE_QB,
LOAD_OBJECT_DETAIL_FK_REFERENCES,
ON_CLOSE_SUMMARY,
ON_EDIT_SUMMARY,
OPEN_AI_QUESTION_ANALYSIS_SIDEBAR,
OPEN_CHART_SETTINGS,
OPEN_CHART_TYPE,
OPEN_DATA_REFERENCE_AT_QUESTION,
OPEN_QUESTION_INFO,
OPEN_QUESTION_SETTINGS,
OPEN_TIMELINES,
QUERY_COMPLETED,
QUERY_ERRORED,
RELOAD_CARD,
RESET_QB,
RESET_ROW_ZOOM,
RESET_UI_CONTROLS,
RUN_QUERY,
SELECT_TIMELINE_EVENTS,
SET_CARD_AND_RUN,
SET_CURRENT_STATE,
SET_DATA_REFERENCE_STACK,
SET_DOCUMENT_TITLE,
SET_DOCUMENT_TITLE_TIMEOUT_ID,
SET_IS_SHOWING_TEMPLATE_TAGS_EDITOR,
SET_METADATA_DIFF,
SET_MODAL_SNIPPET,
SET_NATIVE_EDITOR_SELECTED_RANGE,
SET_PARAMETER_VALUE,
SET_SHOW_LOADING_COMPLETE_FAVICON,
SET_SNIPPET_COLLECTION_ID,
SET_UI_CONTROLS,
SHOW_CHART_SETTINGS,
SHOW_TIMELINE_EVENTS,
TOGGLE_DATA_REFERENCE,
TOGGLE_SNIPPET_SIDEBAR,
TOGGLE_TEMPLATE_TAGS_EDITOR,
UPDATE_QUESTION,
ZOOM_IN_ROW,
} from "./actions";
import {
CLOSED_NATIVE_EDITOR_SIDEBARS,
DEFAULT_LOADING_CONTROLS,
DEFAULT_PARENT_ENTITY_STATE,
DEFAULT_QUERY_STATUS,
DEFAULT_UI_CONTROLS,
UI_CONTROLS_SIDEBAR_DEFAULTS,
} from "./defaults";
function setUIControls(
state: QueryBuilderUIControls,
changes: Partial<QueryBuilderUIControls>,
): QueryBuilderUIControls {
const { queryBuilderMode: currentQBMode, ...currentState } = state;
const { queryBuilderMode: nextQBMode, ...nextStateChanges } = changes;
const isChangingQBMode = nextQBMode && currentQBMode !== nextQBMode;
const isOpeningEditingQBMode = isChangingQBMode && nextQBMode !== "view";
const queryBuilderMode = nextQBMode || currentQBMode;
// previousQueryBuilderMode is typed as boolean but stores QueryBuilderMode | false in practice
const previousQueryBuilderMode = isChangingQBMode
? currentQBMode
: state.previousQueryBuilderMode;
// Close all the sidebars when entering notebook/dataset QB modes
const extraState = isOpeningEditingQBMode ? UI_CONTROLS_SIDEBAR_DEFAULTS : {};
return {
...currentState,
...extraState,
...nextStateChanges,
queryBuilderMode,
previousQueryBuilderMode,
};
}
export const uiControls = createReducer<QueryBuilderUIControls>(
DEFAULT_UI_CONTROLS,
(builder) => {
builder
.addCase<
string,
{ type: string; payload: Partial<QueryBuilderUIControls> }
>(SET_UI_CONTROLS, (state, action) =>
setUIControls(state, action.payload),
)
.addCase(RESET_UI_CONTROLS, (state) => ({
...DEFAULT_UI_CONTROLS,
isRunning: state.isRunning,
}))
.addCase<
string,
{ type: string; payload: { uiControls: QueryBuilderUIControls } }
>(INITIALIZE_QB, (state, action) => ({
...state,
...DEFAULT_UI_CONTROLS,
...action.payload.uiControls,
}))
.addCase(UPDATE_QUESTION, (state) => ({
...state,
highlightedNativeQueryLineNumbers:
DEFAULT_UI_CONTROLS.highlightedNativeQueryLineNumbers,
}))
.addCase(TOGGLE_DATA_REFERENCE, (state) => ({
...state,
...CLOSED_NATIVE_EDITOR_SIDEBARS,
isShowingDataReference: !state.isShowingDataReference,
}))
.addCase<
string,
{ type: string; payload: QueryBuilderUIControls["dataReferenceStack"] }
>(SET_DATA_REFERENCE_STACK, (state, action) => ({
...state,
dataReferenceStack: action.payload,
}))
.addCase<
string,
{ type: string; payload: QueryBuilderUIControls["dataReferenceStack"] }
>(OPEN_DATA_REFERENCE_AT_QUESTION, (state, action) =>
action.payload
? {
...state,
dataReferenceStack: action.payload,
isShowingDataReference: true,
}
: state,
)
.addCase(TOGGLE_TEMPLATE_TAGS_EDITOR, (state) => ({
...state,
...CLOSED_NATIVE_EDITOR_SIDEBARS,
isShowingTemplateTagsEditor: !state.isShowingTemplateTagsEditor,
}))
.addCase(TOGGLE_SNIPPET_SIDEBAR, (state) => ({
...state,
...CLOSED_NATIVE_EDITOR_SIDEBARS,
isShowingSnippetSidebar: !state.isShowingSnippetSidebar,
snippetCollectionId: null,
}))
.addCase<string, { type: string; isShowingTemplateTagsEditor: boolean }>(
SET_IS_SHOWING_TEMPLATE_TAGS_EDITOR,
(state, action) => ({
...state,
...CLOSED_NATIVE_EDITOR_SIDEBARS,
isShowingTemplateTagsEditor: action.isShowingTemplateTagsEditor,
}),
)
.addCase<string, { type: string; payload: Range[] }>(
SET_NATIVE_EDITOR_SELECTED_RANGE,
(state, action) => ({
...state,
nativeEditorSelectedRange: action.payload,
}),
)
.addCase<
string,
{
type: string;
payload:
| NativeQuerySnippet
| Partial<Omit<NativeQuerySnippet, "id">>
| null;
}
>(SET_MODAL_SNIPPET, (state, action) => ({
...state,
modalSnippet: action.payload,
}))
.addCase<
string,
{ type: string; payload: QueryBuilderUIControls["snippetCollectionId"] }
>(SET_SNIPPET_COLLECTION_ID, (state, action) => ({
...state,
snippetCollectionId: action.payload,
}))
.addCase(CLOSE_QB_NEWB_MODAL, (state) => ({
...state,
isShowingNewbModal: false,
}))
.addCase(RUN_QUERY, (state) => ({
...state,
isRunning: true,
highlightedNativeQueryLineNumbers:
DEFAULT_UI_CONTROLS.highlightedNativeQueryLineNumbers,
}))
.addCase(CANCEL_QUERY, (state) => ({ ...state, isRunning: false }))
.addCase(QUERY_COMPLETED, (state) => ({ ...state, isRunning: false }))
.addCase(QUERY_ERRORED, (state) => ({ ...state, isRunning: false }))
.addCase<string, { type: string; payload: InitialChartSettingState }>(
SHOW_CHART_SETTINGS,
(state, action) => ({
...state,
...UI_CONTROLS_SIDEBAR_DEFAULTS,
isShowingChartSettingsSidebar: true,
initialChartSetting: action.payload,
}),
)
.addCase(CANCEL_QUESTION_CHANGES, (state) => ({
...state,
isModifiedFromNotebook: false,
}))
.addCase(ON_EDIT_SUMMARY, (state) => ({
...state,
...UI_CONTROLS_SIDEBAR_DEFAULTS,
isShowingSummarySidebar: true,
}))
.addCase(ON_CLOSE_SUMMARY, (state) => ({
...state,
...UI_CONTROLS_SIDEBAR_DEFAULTS,
}))
.addCase(OPEN_AI_QUESTION_ANALYSIS_SIDEBAR, (state) => ({
...state,
...UI_CONTROLS_SIDEBAR_DEFAULTS,
isShowingAIQuestionAnalysisSidebar: true,
}))
.addCase(CLOSE_AI_QUESTION_ANALYSIS_SIDEBAR, (state) => ({
...state,
...UI_CONTROLS_SIDEBAR_DEFAULTS,
}))
.addCase<
string,
{
type: string;
payload:
| {
initialChartSettings?: InitialChartSettingState;
showSidebarTitle?: boolean;
}
| undefined;
}
>(OPEN_CHART_SETTINGS, (state, action) => {
const { initialChartSettings, showSidebarTitle = false } =
action.payload ?? {};
return {
...state,
...UI_CONTROLS_SIDEBAR_DEFAULTS,
isShowingChartSettingsSidebar: true,
initialChartSetting: initialChartSettings ?? {},
showSidebarTitle,
};
})
.addCase(CLOSE_CHART_SETTINGS, (state) => ({
...state,
...UI_CONTROLS_SIDEBAR_DEFAULTS,
}))
.addCase(OPEN_CHART_TYPE, (state) => ({
...state,
...UI_CONTROLS_SIDEBAR_DEFAULTS,
isShowingChartTypeSidebar: true,
}))
.addCase(CLOSE_CHART_TYPE, (state) => ({
...state,
...UI_CONTROLS_SIDEBAR_DEFAULTS,
}))
.addCase(OPEN_QUESTION_INFO, (state) =>
setUIControls(state, {
...UI_CONTROLS_SIDEBAR_DEFAULTS,
...CLOSED_NATIVE_EDITOR_SIDEBARS,
isShowingQuestionInfoSidebar: true,
queryBuilderMode: "view",
}),
)
.addCase(CLOSE_QUESTION_INFO, (state) => ({
...state,
isShowingQuestionInfoSidebar: false,
}))
.addCase(OPEN_QUESTION_SETTINGS, (state) =>
setUIControls(state, {
...(UI_CONTROLS_SIDEBAR_DEFAULTS as Partial<QueryBuilderUIControls>),
isShowingQuestionSettingsSidebar: true,
queryBuilderMode: "view",
} as Partial<QueryBuilderUIControls>),
)
.addCase(CLOSE_QUESTION_SETTINGS, (state) => ({
...state,
isShowingQuestionSettingsSidebar: false,
}))
.addCase(OPEN_TIMELINES, (state) => ({
...state,
...UI_CONTROLS_SIDEBAR_DEFAULTS,
...CLOSED_NATIVE_EDITOR_SIDEBARS,
isShowingTimelineSidebar: true,
}))
.addCase(CLOSE_TIMELINES, (state) => ({
...state,
...UI_CONTROLS_SIDEBAR_DEFAULTS,
}))
.addCase(CLOSE_SIDEBARS, (state) => ({
...state,
...UI_CONTROLS_SIDEBAR_DEFAULTS,
}));
},
);
export const loadingControls = createReducer<QueryBuilderLoadingControls>(
DEFAULT_LOADING_CONTROLS,
(builder) => {
builder
.addCase<string, { type: string; payload: string }>(
SET_DOCUMENT_TITLE,
(state, action) => ({
...state,
documentTitle: action.payload,
}),
)
.addCase<string, { type: string; payload: boolean }>(
SET_SHOW_LOADING_COMPLETE_FAVICON,
(state, action) => ({
...state,
showLoadCompleteFavicon: action.payload,
}),
)
.addCase<string, { type: string; payload: string }>(
SET_DOCUMENT_TITLE_TIMEOUT_ID,
(state, action) => ({
...state,
timeoutId: action.payload,
}),
);
},
);
export const queryStatus = createReducer<QueryBuilderQueryStatus>(
DEFAULT_QUERY_STATUS,
(builder) => {
builder
.addCase(RUN_QUERY, () => "running" as const)
.addCase(QUERY_COMPLETED, () => "complete" as const)
.addCase(CANCEL_QUERY, () => "idle" as const);
},
);
export const zoomedRowObjectId = createReducer<number | string | null>(
null,
(builder) => {
builder
.addCase<
string,
{ type: string; payload: { objectId?: number | string } | undefined }
>(INITIALIZE_QB, (_state, action) => action.payload?.objectId ?? null)
.addCase<
string,
{ type: string; payload: { objectId: number | string } }
>(ZOOM_IN_ROW, (_state, action) => action.payload.objectId)
.addCase(RESET_ROW_ZOOM, () => null)
.addCase(RESET_QB, () => null);
},
);
// a copy of the card being worked on at its last known saved state. if the card is NEW then this should be null.
// NOTE: we use JSON serialization/deserialization to ensure a deep clone of the object which is required
// because we can't have any links between the active card being modified and the "originalCard" for testing dirtiness
// ALSO: we consistently check for payload.id because an unsaved card has no "originalCard"
export const originalCard = createReducer<Card | null>(null, (builder) => {
builder
.addCase<string, { type: string; payload: { originalCard?: Card } }>(
INITIALIZE_QB,
(_state, action) =>
action.payload.originalCard ? copy(action.payload.originalCard) : null,
)
.addCase<string, { type: string; payload: Card }>(
RELOAD_CARD,
(_state, action) => (action.payload.id ? copy(action.payload) : null),
)
.addCase<string, { type: string; payload: { originalCard?: Card } }>(
SET_CARD_AND_RUN,
(_state, action) =>
action.payload.originalCard ? copy(action.payload.originalCard) : null,
)
.addCase<string, { type: string; payload: Card }>(
API_CREATE_QUESTION,
(_state, action) => copy(action.payload),
)
.addCase<string, { type: string; payload: Card }>(
API_UPDATE_QUESTION,
(_state, action) => copy(action.payload),
);
});
// references to FK tables specifically used on the ObjectDetail page.
export const tableForeignKeyReferences = createReducer<Record<
number,
ForeignKeyReference
> | null>(null, (builder) => {
builder
.addCase<
string,
{ type: string; payload: Record<number, ForeignKeyReference> }
>(LOAD_OBJECT_DETAIL_FK_REFERENCES, (_state, action) => action.payload)
.addCase(CLEAR_OBJECT_DETAIL_FK_REFERENCES, () => null);
});
export const lastRunCard = createReducer<Card | null>(null, (builder) => {
builder
.addCase(RESET_QB, () => null)
.addCase<string, { type: string; payload: { card: Card } }>(
QUERY_COMPLETED,
(_state, action) => action.payload.card,
)
.addCase(QUERY_ERRORED, () => null);
});
// The results of a query execution. optionally an error if the query fails to complete successfully.
export const queryResults = createReducer<Dataset[] | null>(null, (builder) => {
// @ts-expect-error — Draft<Dataset[]> triggers TS2589 due to Dataset's recursive types
builder
.addCase(RESET_QB, () => null)
.addCase<string, { type: string; payload: { queryResults: Dataset[] } }>(
QUERY_COMPLETED,
(_state, action) => action.payload.queryResults,
)
.addCase<string, { type: string; payload: Dataset | null }>(
QUERY_ERRORED,
(state, action) => (action.payload ? [action.payload] : state),
)
.addCase(CLEAR_QUERY_RESULT, () => null);
});
export const metadataDiff = createReducer<Record<string, Partial<Field>>>(
{},
(builder) => {
builder
.addCase(RESET_QB, () => ({}))
.addCase(API_CREATE_QUESTION, () => ({}))
.addCase(API_UPDATE_QUESTION, () => ({}))
.addCase<
string,
{ type: string; payload: { name: string; changes: Partial<Field> } }
>(SET_METADATA_DIFF, (state, action) => {
const { name, changes } = action.payload;
return {
...state,
[name]: state[name] ? merge(state[name], changes) : changes,
};
})
.addCase(CANCEL_QUESTION_CHANGES, () => ({}));
},
);
// promise used for tracking a query execution in progress. when a query is started we capture this.
export const cancelQueryDeferred = createReducer<Deferred<void> | null>(
null,
(builder) => {
builder
.addCase<
string,
{ type: string; payload: { cancelQueryDeferred: Deferred<void> } }
>(RUN_QUERY, (_state, action) => action.payload.cancelQueryDeferred)
.addCase(CANCEL_QUERY, () => null)
.addCase(QUERY_COMPLETED, () => null)
.addCase(QUERY_ERRORED, () => null);
},
);
export const queryStartTime = createReducer<number | null>(null, (builder) => {
builder
.addCase(RUN_QUERY, () => performance.now())
.addCase(CANCEL_QUERY, () => null)
.addCase(QUERY_COMPLETED, () => null)
.addCase(QUERY_ERRORED, () => null);
});
export const parameterValues = createReducer<ParameterValuesMap>(
{},
(builder) => {
builder
.addCase<
string,
{ type: string; payload: { parameterValues: ParameterValuesMap } }
>(INITIALIZE_QB, (_state, action) => action.payload.parameterValues)
.addCase<
string,
{ type: string; payload: { id: string; value: unknown } }
>(SET_PARAMETER_VALUE, (state, action) => assoc(state, action.payload.id, action.payload.value));
},
);
export const currentState = createReducer<{
card: Card;
cardId?: number;
serializedCard: string;
} | null>(null, (builder) => {
builder.addCase<
string,
{
type: string;
payload: { card: Card; cardId?: number; serializedCard: string };
}
>(SET_CURRENT_STATE, (_state, action) => action.payload);
});
export const parentEntity = createReducer<QueryBuilderParentEntityState>(
DEFAULT_PARENT_ENTITY_STATE,
(builder) => {
builder
.addCase<
string,
{
type: string;
payload: {
id: number | string | null;
model: CollectionItemModel | null;
name: string | null;
};
}
>(NAVIGATE_TO_NEW_CARD, (_state, action) => ({
id: action.payload.id,
model: action.payload.model,
name: action.payload.name,
isEditing: false,
}))
.addCase<
string,
{
type: string;
payload: {
id: number | string | null;
model: CollectionItemModel | null;
name: string | null;
};
}
>(EDIT_QUESTION, (_state, action) => ({
id: action.payload.id,
model: action.payload.model,
name: action.payload.name,
isEditing: true,
}))
.addCase(CLOSE_QB, () => DEFAULT_PARENT_ENTITY_STATE);
},
);
export const visibleTimelineEventIds = createReducer<number[]>(
[],
(builder) => {
builder
.addCase(INITIALIZE_QB, () => [])
.addCase<string, { type: string; payload: TimelineEvent[] }>(
SHOW_TIMELINE_EVENTS,
(state, action) =>
_.uniq([...state, ...action.payload.map((event) => event.id)]),
)
.addCase<string, { type: string; payload: TimelineEvent[] }>(
HIDE_TIMELINE_EVENTS,
(state, action) => {
const eventIdsToHide = action.payload.map((event) => event.id);
return state.filter((eventId) => !eventIdsToHide.includes(eventId));
},
)
.addCase<
string,
{ type: string; payload: { timelineEvent: { id: number } } }
>(TimelineEvents.actionTypes.CREATE, (state, action) => [
...state,
action.payload.timelineEvent.id,
])
.addCase(RESET_QB, () => []);
},
);
export const selectedTimelineEventIds = createReducer<number[]>(
[],
(builder) => {
builder
.addCase(INITIALIZE_QB, () => [])
.addCase<string, { type: string; payload: TimelineEvent[] | undefined }>(
SELECT_TIMELINE_EVENTS,
(_state, action) => (action.payload ?? []).map((e) => e.id),
)
.addCase(DESELECT_TIMELINE_EVENTS, () => [])
.addCase(CLOSE_TIMELINES, () => [])
.addCase(RESET_QB, () => []);
},
);
export * from "./reducers-typed";

View File

@@ -21,6 +21,8 @@ import {
extractRemappings,
getVisualizationTransformed,
} from "metabase/visualizations";
import type { ObjectId } from "metabase/visualizations/components/ObjectDetail/types";
import type { TimeSeriesInterval } from "metabase/visualizations/echarts/cartesian/model/types";
import {
computeTimeseriesDataInterval,
minTimeseriesUnit,
@@ -32,13 +34,27 @@ import {
import { getComputedSettingsForSeries } from "metabase/visualizations/lib/settings/visualization";
import * as Lib from "metabase-lib";
import Question from "metabase-lib/v1/Question";
import type Database from "metabase-lib/v1/metadata/Database";
import type Table from "metabase-lib/v1/metadata/Table";
import { getCardUiParameters } from "metabase-lib/v1/parameters/utils/cards";
import {
normalizeParameterValue,
normalizeParameters,
} from "metabase-lib/v1/parameters/utils/parameter-values";
import { getIsPKFromTablePredicate } from "metabase-lib/v1/types/utils/isa";
import type { TimelineEvent } from "metabase-types/analytics";
import type {
Bookmark,
Dataset,
DatasetColumn,
DatasetQuery,
Field,
RowValue,
Series,
Timeline,
} from "metabase-types/api";
import { isAbsoluteDateTimeUnit } from "metabase-types/guards/date-time";
import type { State } from "metabase-types/store";
import { getQuestionWithDefaultVisualizationSettings } from "./actions/core/utils";
import { createRawSeries, getWritableColumnProperties } from "./utils";
@@ -49,22 +65,22 @@ import {
} from "./utils/question";
// This selector can be called from public questions / dashboards, which do not have state.qb
export const getUiControls = (state) => state.qb?.uiControls;
export const getQueryStatus = (state) => state.qb.queryStatus;
export const getLoadingControls = (state) => state.qb.loadingControls;
export const getUiControls = (state: State) => state.qb?.uiControls;
export const getQueryStatus = (state: State) => state.qb.queryStatus;
export const getLoadingControls = (state: State) => state.qb.loadingControls;
export const getIsShowingTemplateTagsEditor = (state) =>
export const getIsShowingTemplateTagsEditor = (state: State) =>
getUiControls(state).isShowingTemplateTagsEditor;
export const getIsShowingSnippetSidebar = (state) =>
export const getIsShowingSnippetSidebar = (state: State) =>
getUiControls(state).isShowingSnippetSidebar;
export const getIsShowingDataReference = (state) =>
export const getIsShowingDataReference = (state: State) =>
getUiControls(state).isShowingDataReference;
export const getHighlightedNativeQueryLineNumbers = (state) =>
export const getHighlightedNativeQueryLineNumbers = (state: State) =>
getUiControls(state).highlightedNativeQueryLineNumbers;
// This selector can be called from public questions / dashboards, which do not
// have state.qb
export const getIsShowingRawTable = (state) =>
export const getIsShowingRawTable = (state: State) =>
!!state.qb?.uiControls.isShowingRawTable;
const SIDEBARS = [
@@ -79,37 +95,38 @@ const SIDEBARS = [
"isShowingDataReference",
"isShowingTemplateTagsEditor",
"isShowingSnippetSidebar",
];
] as const;
export const getIsAnySidebarOpen = createSelector(
[getUiControls],
(uiControls) => SIDEBARS.some((sidebar) => uiControls[sidebar]),
);
export const getIsRunning = (state) => getUiControls(state).isRunning;
export const getIsLoadingComplete = (state) =>
export const getIsRunning = (state: State) => getUiControls(state).isRunning;
export const getIsLoadingComplete = (state: State) =>
getQueryStatus(state) === "complete";
export const getCard = (state) => state.qb.card;
export const getOriginalCard = (state) => state.qb.originalCard;
export const getLastRunCard = (state) => state.qb.lastRunCard;
export const getCard = (state: State) => state.qb.card;
export const getOriginalCard = (state: State) => state.qb.originalCard;
export const getLastRunCard = (state: State) => state.qb.lastRunCard;
export const getParameterValues = (state) => state.qb.parameterValues;
export const getParameterValuesSearchCache = (state) =>
state.qb.parameterValuesSearchCache;
export const getParameterValues = (state: State) => state.qb.parameterValues;
export const getMetadataDiff = (state) => state.qb.metadataDiff;
export const getMetadataDiff = (state: State) => state.qb.metadataDiff;
export const getEntities = (state) => state.entities;
export const getVisibleTimelineEventIds = (state) =>
export const getEntities = (state: State) => state.entities;
export const getVisibleTimelineEventIds = (state: State) =>
state.qb.visibleTimelineEventIds;
export const getSelectedTimelineEventIds = (state) =>
export const getSelectedTimelineEventIds = (state: State) =>
state.qb.selectedTimelineEventIds;
const getRawQueryResults = (state) => state.qb.queryResults;
const getRawQueryResults = (state: State) => state.qb.queryResults;
export const getIsBookmarked = (state, props) =>
props.bookmarks.some(
export const getIsBookmarked = (
state: State,
{ bookmarks }: { bookmarks: Bookmark[] },
) =>
bookmarks.some(
(bookmark) =>
bookmark.type === "card" && bookmark.item_id === state.qb.card?.id,
);
@@ -119,18 +136,18 @@ export const getQueryBuilderMode = createSelector(
(uiControls) => uiControls.queryBuilderMode,
);
export const getQueryStartTime = (state) => state.qb.queryStartTime;
export const getQueryStartTime = (state: State) => state.qb.queryStartTime;
export const getDatabaseId = createSelector(
[getCard],
(card) => card && card.dataset_query && card.dataset_query.database,
);
export const getTableForeignKeyReferences = (state) =>
export const getTableForeignKeyReferences = (state: State) =>
state.qb.tableForeignKeyReferences;
const getDatabasesListDefaultValue = [];
export const getDatabasesList = (state) =>
const getDatabasesListDefaultValue: Database[] = [];
export const getDatabasesList = (state: State) =>
Databases.selectors.getList(state, {
entityQuery: { include: "tables", saved: true },
}) || getDatabasesListDefaultValue;
@@ -146,7 +163,7 @@ export const getSampleDatabaseId = createSelector(
export const getParameters = createSelector(
[getCard, getMetadata, getParameterValues],
(card, metadata, parameterValues) =>
getCardUiParameters(card, metadata, parameterValues),
card ? getCardUiParameters(card, metadata, parameterValues) : [],
);
const getLastRunDatasetQuery = createSelector(
@@ -166,7 +183,8 @@ export const getDatasetEditorTab = createSelector(
export const getOriginalQuestion = createSelector(
[getMetadata, getOriginalCard],
(metadata, card) => metadata && card && new Question(card, metadata),
(metadata, card) =>
(metadata && card && new Question(card, metadata)) ?? undefined,
);
export const getOriginalQuestionWithParameterValues = createSelector(
@@ -218,9 +236,8 @@ export const getQuestion = createSelector(
/**
* Returns whether the current question is a native query
*/
export const getIsNative = createSelector(
[getQuestion],
(question) => question && Lib.queryDisplayInfo(question.query()).isNative,
export const getIsNative = createSelector([getQuestion], (question) =>
question ? Lib.queryDisplayInfo(question.query()).isNative : false,
);
const getCardResultMetadata = createSelector(
@@ -252,7 +269,7 @@ const getModelMetadataDiff = createSelector(
export const getQueryResults = createSelector(
[getRawQueryResults, getModelMetadataDiff],
(queryResults, metadataDiff) => {
(queryResults, metadataDiff): Dataset[] | null => {
if (!Array.isArray(queryResults) || !queryResults.length) {
return null;
}
@@ -263,7 +280,7 @@ export const getQueryResults = createSelector(
}
const { cols, results_metadata } = result.data;
function applyMetadataDiff(column) {
function applyMetadataDiff(column: Field | DatasetColumn) {
const columnDiff = metadataDiff[column.name];
return columnDiff ? merge(column, columnDiff) : column;
}
@@ -347,7 +364,7 @@ export const getPKColumnIndex = createSelector(
[getFirstQueryResult, getTableId],
(result, tableId) => {
if (!result || !result.data) {
return;
return -1;
}
const { cols } = result.data;
@@ -364,17 +381,17 @@ export const getPKColumnIndex = createSelector(
export const getPKRowIndexMap = createSelector(
[getFirstQueryResult, getPKColumnIndex],
(result, PKColumnIndex) => {
const map = new Map<RowValue, number>();
if (!result || !result.data || !Number.isSafeInteger(PKColumnIndex)) {
return {};
return map;
}
const { rows } = result.data;
if (PKColumnIndex < 0) {
return rows.map((_, index) => index);
}
const map = {};
rows.forEach((row, index) => {
const PKValue = row[PKColumnIndex];
map[PKValue] = index;
map.set(PKValue, index);
});
return map;
},
@@ -394,7 +411,7 @@ export const getRowIndexToPKMap = createSelector(
if (PKColumnIndex < 0) {
return rows.map((_, index) => index);
}
const map = {};
const map: Record<number, ObjectId> = {};
rows.forEach((row, index) => {
const PKValue = row[PKColumnIndex];
map[index] = PKValue;
@@ -403,11 +420,20 @@ export const getRowIndexToPKMap = createSelector(
},
);
function areLegacyQueriesEqual(queryA, queryB, tableMetadata) {
function areLegacyQueriesEqual(
queryA: DatasetQuery | undefined,
queryB: DatasetQuery | undefined,
tableMetadata: Table,
) {
if (queryA == null || queryB == null) {
return false;
}
return Lib.areLegacyQueriesEqual(
queryA,
queryB,
tableMetadata?.fields.map(({ id }) => id),
tableMetadata?.fields
?.map(({ id }) => id)
?.filter((id): id is number => typeof id === "number") ?? [],
);
}
@@ -420,6 +446,11 @@ function areComposedEntitiesEquivalent({
lastRunQuestion,
currentQuestion,
tableMetadata,
}: {
originalQuestion?: Question | null;
lastRunQuestion?: Question | null;
currentQuestion?: Question | null;
tableMetadata: Table;
}) {
const isQuestion = originalQuestion?.type() === "question";
if (!originalQuestion || !lastRunQuestion || !currentQuestion || isQuestion) {
@@ -463,6 +494,11 @@ export function areQueriesEquivalent({
lastRunQuestion,
currentQuestion,
tableMetadata,
}: {
originalQuestion?: Question | null;
lastRunQuestion?: Question | null;
currentQuestion?: Question | null;
tableMetadata: Table;
}) {
return (
areLegacyQueriesEqual(
@@ -500,20 +536,21 @@ export const getIsResultDirty = createSelector(
const isEditable =
!!currentQuestion &&
Lib.queryDisplayInfo(currentQuestion.query()).isEditable;
return (
return Boolean(
haveParametersChanged ||
(isEditable &&
!areQueriesEquivalent({
originalQuestion,
lastRunQuestion,
currentQuestion,
tableMetadata,
}))
(isEditable &&
tableMetadata &&
!areQueriesEquivalent({
originalQuestion,
lastRunQuestion,
currentQuestion,
tableMetadata,
})),
);
},
);
export const getZoomedObjectId = (state) => state.qb.zoomedRowObjectId;
export const getZoomedObjectId = (state: State) => state.qb.zoomedRowObjectId;
export const getZoomedObjectRowIndex = createSelector(
[getPKRowIndexMap, getZoomedObjectId],
@@ -521,14 +558,22 @@ export const getZoomedObjectRowIndex = createSelector(
if (!PKRowIndexMap) {
return;
}
return PKRowIndexMap[objectId] ?? PKRowIndexMap[parseInt(objectId)];
if (PKRowIndexMap instanceof Map) {
return PKRowIndexMap.get(objectId);
}
const index =
typeof objectId === "string" ? parseInt(objectId) : (objectId ?? -1);
return PKRowIndexMap[index];
},
);
export const getPreviousRowPKValue = createSelector(
[getFirstQueryResult, getPKColumnIndex, getZoomedObjectRowIndex],
(result, PKColumnIndex, rowIndex) => {
if (!result) {
if (!result || rowIndex == null) {
return;
}
if (PKColumnIndex === -1) {
@@ -542,7 +587,7 @@ export const getPreviousRowPKValue = createSelector(
export const getNextRowPKValue = createSelector(
[getFirstQueryResult, getPKColumnIndex, getZoomedObjectRowIndex],
(result, PKColumnIndex, rowIndex) => {
if (!result) {
if (!result || rowIndex == null) {
return;
}
if (PKColumnIndex === -1) {
@@ -572,7 +617,11 @@ export const getCanZoomNextRow = createSelector(
export const getZoomRow = createSelector(
[getQueryResults, getZoomedObjectRowIndex],
(queryResults, rowIndex) => {
if (!Array.isArray(queryResults) || !queryResults.length) {
if (
!Array.isArray(queryResults) ||
!queryResults.length ||
rowIndex == null
) {
return;
}
return queryResults[0].data.rows[rowIndex];
@@ -662,9 +711,9 @@ export const getShouldShowUnsavedChangesWarning = createSelector(
*/
export const getRawSeries = createSelector(
[getCard, getFirstQueryResult, getLastRunDatasetQuery, getIsShowingRawTable],
(card, queryResult, lastRunDatasetQuery, isShowingRawTable) => {
(card, queryResult, lastRunDatasetQuery, isShowingRawTable): Series => {
const rawSeries = createRawSeries({
card,
card: card!,
queryResult,
datasetQuery: lastRunDatasetQuery,
});
@@ -702,12 +751,12 @@ const _getVisualizationTransformed = createSelector(
*/
export const getTransformedSeries = createSelector(
[_getVisualizationTransformed],
(transformed) => transformed && transformed.series,
(transformed) => transformed?.series,
);
export const getTransformedVisualization = createSelector(
[_getVisualizationTransformed],
(transformed) => transformed && transformed.visualization,
(transformed) => transformed?.visualization,
);
/**
@@ -728,12 +777,12 @@ export const getIsNativeEditorOpen = createSelector(
export const getNativeEditorSelectedRange = createSelector(
[getUiControls],
(uiControls) => uiControls && uiControls.nativeEditorSelectedRange?.[0],
(uiControls) => uiControls?.nativeEditorSelectedRange?.[0],
);
const getNativeEditorSelectedRanges = createSelector(
[getUiControls],
(uiControls) => uiControls && uiControls.nativeEditorSelectedRange,
(uiControls) => uiControls?.nativeEditorSelectedRange,
);
export const getIsTimeseries = createSelector(
@@ -744,7 +793,9 @@ export const getIsTimeseries = createSelector(
export const getTimeseriesXValues = createSelector(
[getIsTimeseries, getTransformedSeries, getVisualizationSettings],
(isTimeseries, series, settings) =>
isTimeseries && series && settings && getXValues({ series, settings }),
isTimeseries && series && settings
? getXValues({ series, settings })
: null,
);
const getTimeseriesDataInterval = createSelector(
@@ -754,7 +805,7 @@ const getTimeseriesDataInterval = createSelector(
getIsTimeseries,
getTimeseriesXValues,
],
(series, settings, isTimeseries, xValues) => {
(series, settings, isTimeseries, xValues): TimeSeriesInterval | null => {
if (!isTimeseries || !xValues) {
return null;
}
@@ -768,29 +819,32 @@ const getTimeseriesDataInterval = createSelector(
isAbsoluteDateTimeUnit(column?.unit) ? column.unit : null,
)
.filter(isNotNull);
return computeTimeseriesDataInterval(
xValues,
minTimeseriesUnit(columnUnits),
return (
computeTimeseriesDataInterval(xValues, minTimeseriesUnit(columnUnits)) ??
null
);
},
);
type Domain = [number, number];
export const getTimeseriesXDomain = createSelector(
[getIsTimeseries, getTimeseriesXValues],
(isTimeseries, xValues) => {
return (
isTimeseries &&
Array.isArray(xValues) &&
xValues.length > 0 &&
d3.extent(xValues)
);
if (isTimeseries && Array.isArray(xValues) && xValues.length > 0) {
return d3.extent(xValues) as Domain;
}
return null;
},
);
export const getFetchedTimelines = createSelector([getEntities], (entities) => {
const entityQuery = { include: "events" };
return Timelines.selectors.getList({ entities }, { entityQuery }) ?? [];
});
export const getFetchedTimelines = createSelector(
[getEntities],
(entities): Timeline[] => {
const entityQuery = { include: "events" };
return Timelines.selectors.getList({ entities }, { entityQuery }) ?? [];
},
);
export const getTransformedTimelines = createSelector(
[getFetchedTimelines],
@@ -808,11 +862,14 @@ export const getTransformedTimelines = createSelector(
},
);
function isEventWithinDomain(event, xDomain) {
function isEventWithinDomain(event: TimelineEvent, xDomain: Domain) {
return event.timestamp.isBetween(xDomain[0], xDomain[1], undefined, "[]");
}
function getXDomainForTimelines(xDomain, dataInterval) {
function getXDomainForTimelines(
xDomain: Domain | null,
dataInterval: TimeSeriesInterval | null,
): Domain | null {
// When looking at, let's say, count of orders over years, last year value is Jan 1, 2024
// If we filter timeline events up until Jan 1, 2024, we won't see any events from 2024,
// so we need to extend xDomain by dataInterval.count * dataInterval.unit to include them
@@ -837,15 +894,17 @@ export const getFilteredTimelines = createSelector(
const timelineXDomain = getXDomainForTimelines(xDomain, dataInterval);
return timelines
.map((timeline) =>
updateIn(timeline, ["events"], (events) =>
updateIn(timeline, ["events"], (events: TimelineEvent[]) =>
xDomain
? events.filter((event) =>
isEventWithinDomain(event, timelineXDomain),
? events.filter(
(event) =>
timelineXDomain &&
isEventWithinDomain(event, timelineXDomain),
)
: events,
),
)
.filter((timeline) => timeline.events.length > 0);
.filter((timeline) => (timeline.events ?? []).length > 0);
},
);
@@ -861,7 +920,10 @@ export const getVisibleTimelineEvents = createSelector(
.value(),
);
export function getOffsetForQueryAndPosition(queryText, { row, column }) {
export function getOffsetForQueryAndPosition(
queryText: string,
{ row, column }: { row: number; column: number },
) {
const queryLines = queryText.split("\n");
return (
// the total length of the previous rows
@@ -926,12 +988,12 @@ export const getAllNativeEditorSelectedText = createSelector(
export const getModalSnippet = createSelector(
[getUiControls],
(uiControls) => uiControls && uiControls.modalSnippet,
(uiControls) => uiControls?.modalSnippet,
);
export const getSnippetCollectionId = createSelector(
[getUiControls],
(uiControls) => uiControls && uiControls.snippetCollectionId,
(uiControls) => uiControls?.snippetCollectionId,
);
export const getIsVisualized = createSelector(
@@ -966,7 +1028,7 @@ export const getIsLiveResizable = createSelector(
export const getQuestionDetailsTimelineDrawerState = createSelector(
[getUiControls],
(uiControls) => uiControls && uiControls.questionDetailsTimelineDrawerState,
(uiControls) => uiControls?.questionDetailsTimelineDrawerState,
);
export const isBasedOnExistingQuestion = createSelector(
@@ -1015,21 +1077,18 @@ export const getIsAdditionalInfoVisible = createSelector(
export const getDataReferenceStack = createSelector(
[getUiControls, getDatabaseId],
(uiControls, dbId) =>
uiControls.dataReferenceStack
? uiControls.dataReferenceStack
: dbId
? [{ type: "database", item: { id: dbId } }]
: [],
uiControls.dataReferenceStack ??
(dbId ? [{ type: "database", id: dbId }] : []),
);
export const getIsEditingInDashboard = (state) => {
export const getIsEditingInDashboard = (state: State) => {
return (
state.qb.parentEntity.model === "dashboard" &&
state.qb.parentEntity.isEditing
);
};
export const getParentEntity = (state) => {
export const getParentEntity = (state: State) => {
return state.qb.parentEntity;
};
@@ -1044,7 +1103,7 @@ export const getEmbeddingParameters = createSelector([getCard], (card) => {
// Embeddings might be published without passing embedding_params to the server,
// in which case it's an empty object. We should treat such situations with
// caution, assuming that an absent parameter is "disabled".
export function getEmbeddedParameterVisibility(state, slug) {
export function getEmbeddedParameterVisibility(state: State, slug: string) {
const card = getCard(state);
if (!card?.enable_embedding) {
return null;
@@ -1054,9 +1113,10 @@ export function getEmbeddedParameterVisibility(state, slug) {
return embeddingParams[slug] ?? "disabled";
}
export const getSubmittableQuestion = (state, question) => {
export const getSubmittableQuestion = (state: State, question: Question) => {
const card = getCard(state) ?? question.card();
const rawSeries = createRawSeries({
card: getCard(state),
card,
queryResult: getFirstQueryResult(state),
datasetQuery: getLastRunDatasetQuery(state),
});
@@ -1089,7 +1149,7 @@ export const getSubmittableQuestion = (state, question) => {
return submittableQuestion;
};
export const getNotebookNativePreviewSidebarWidth = (state) =>
export const getNotebookNativePreviewSidebarWidth = (state: State) =>
getSetting(state, "notebook-native-preview-sidebar-width");
export const getIsListViewConfigurationShown = createSelector(

View File

@@ -12,6 +12,7 @@ import {
import registerVisualizations from "metabase/visualizations/register";
import * as Lib from "metabase-lib";
import Question from "metabase-lib/v1/Question";
import type { Card, StructuredDatasetQuery } from "metabase-types/api";
import {
createMockCard,
createMockColumn,
@@ -31,6 +32,10 @@ import {
SAMPLE_DB_ID,
createSampleDatabase,
} from "metabase-types/api/mocks/presets";
import type {
QueryBuilderState,
QueryBuilderUIControls,
} from "metabase-types/store";
import {
createMockQueryBuilderState,
createMockQueryBuilderUIControlsState,
@@ -39,7 +44,14 @@ import {
registerVisualizations();
function getBaseState({ uiControls = {}, ...state } = {}) {
type BaseQueryBuilderState = Omit<Partial<QueryBuilderState>, "uiControls"> & {
uiControls?: Partial<QueryBuilderUIControls>;
};
function getBaseState({
uiControls = {},
...state
}: BaseQueryBuilderState = {}) {
return createMockState({
entities: createMockEntitiesState({
databases: [createSampleDatabase()],
@@ -55,7 +67,11 @@ function getBaseState({ uiControls = {}, ...state } = {}) {
});
}
function getBaseCard(opts) {
type BaseCardOpts = Omit<Partial<Card>, "dataset_query"> & {
dataset_query?: Partial<StructuredDatasetQuery>;
};
function getBaseCard(opts: BaseCardOpts) {
return {
...opts,
dataset_query: {
@@ -72,7 +88,7 @@ describe("getQuestion", () => {
});
it("should return question instance correctly", () => {
const card = {
const card = createMockCard({
id: 5,
dataset_query: {
database: 1,
@@ -81,16 +97,16 @@ describe("getQuestion", () => {
"source-table": 1,
},
},
};
});
const question = getQuestion(getBaseState({ card }));
expect(question).toBeInstanceOf(Question);
expect(question._doNotCallSerializableCard()).toEqual(card);
expect(question?._doNotCallSerializableCard()).toEqual(card);
});
it("should return composed dataset when dataset is open", () => {
const card = {
const card = createMockCard({
id: 1,
type: "model",
dataset_query: {
@@ -100,11 +116,13 @@ describe("getQuestion", () => {
"source-table": ORDERS_ID,
},
},
};
});
const question = getQuestion(getBaseState({ card }));
expect(Lib.sourceTableOrCardId(question.query())).toBe("card__1");
expect(question && Lib.sourceTableOrCardId(question.query())).toBe(
"card__1",
);
});
it("should return real dataset when dataset is open in 'dataset' QB mode", () => {
@@ -351,7 +369,7 @@ describe("getIsResultDirty", () => {
});
}
function getState(state) {
function getState(state: Partial<QueryBuilderState>) {
return getBaseState(state);
}

View File

@@ -3,10 +3,11 @@ import { createSelector } from "@reduxjs/toolkit";
import { getMetadata } from "metabase/selectors/metadata";
import { getMode as getQuestionMode } from "metabase/visualizations/click-actions/lib/modes";
import Question from "metabase-lib/v1/Question";
import type { State } from "metabase-types/store";
const getLastRunCard = (state) => state.qb.lastRunCard;
const getParameterValues = (state) => state.qb.parameterValues;
const getZoomedObjectId = (state) => state.qb.zoomedRowObjectId;
const getLastRunCard = (state: State) => state.qb.lastRunCard;
const getParameterValues = (state: State) => state.qb.parameterValues;
const getZoomedObjectId = (state: State) => state.qb.zoomedRowObjectId;
const getLastRunQuestion = createSelector(
[getMetadata, getLastRunCard, getParameterValues],

View File

@@ -39,14 +39,14 @@ export function syncVizSettingsWithSeries(
settings: VisualizationSettings,
newQuery: Lib.Query,
newSeries: Series,
oldSeries: Series,
oldSeries?: Series,
): VisualizationSettings {
if (!isNativeQuery(newQuery)) {
return settings;
}
const [newSingleSeries] = newSeries;
const [oldSingleSeries] = oldSeries;
const [oldSingleSeries] = oldSeries ?? [];
if (!isValidSeries(newSingleSeries) || !isValidSeries(oldSingleSeries)) {
return settings;
}

View File

@@ -14,6 +14,7 @@ import { isMac } from "metabase/lib/browser";
import { useDispatch, useSelector } from "metabase/lib/redux";
import { NativeQueryEditor } from "metabase/query_builder/components/NativeQueryEditor";
import { DataReference } from "metabase/query_builder/components/dataref/DataReference";
import type { DataReferenceItem } from "metabase/query_builder/components/dataref/types";
import { createRawSeries } from "metabase/query_builder/utils";
import { getMetadata } from "metabase/selectors/metadata";
import { Box, Button, Flex, Loader, Modal, Stack, Text } from "metabase/ui";
@@ -26,11 +27,6 @@ import type { Card, DatabaseId, Dataset, RawSeries } from "metabase-types/api";
import S from "./NativeQueryModal.module.css";
type DataReferenceStackItem = {
type: string;
item: unknown;
};
interface NativeQueryModalProps {
card: Card;
isOpen: boolean;
@@ -124,7 +120,7 @@ export const NativeQueryModal = ({
useState(false);
const [isShowingDataReference, setIsShowingDataReference] = useState(false);
const [dataReferenceStack, setDataReferenceStack] = useState<
DataReferenceStackItem[]
DataReferenceItem[]
>([]);
const [
@@ -357,7 +353,7 @@ export const NativeQueryModal = ({
const databaseId = modifiedQuestion.databaseId();
if (databaseId) {
setDataReferenceStack([
{ type: "database", item: { id: databaseId } },
{ type: "database", id: databaseId },
]);
}
}
@@ -442,7 +438,7 @@ export const NativeQueryModal = ({
setDataReferenceStack(dataReferenceStack.slice(0, -1));
}
}}
pushDataReferenceStack={(item: DataReferenceStackItem) => {
pushDataReferenceStack={(item: DataReferenceItem) => {
setDataReferenceStack([...dataReferenceStack, item]);
}}
/>

View File

@@ -6,7 +6,7 @@ import type {
Card,
DatasetData,
QuestionDashboardCard,
RowValue,
RowValues,
} from "metabase-types/api";
export type ObjectId = number | string;
@@ -31,7 +31,7 @@ export interface ObjectDetailProps {
dashcard?: QuestionDashboardCard;
isObjectDetail?: boolean; // whether this should be shown in a modal
table?: Table | null;
zoomedRow?: RowValue[] | undefined;
zoomedRow?: RowValues | undefined;
zoomedRowID?: ObjectId;
tableForeignKeys?: ForeignKey[];
tableForeignKeyReferences?: {

View File

@@ -121,7 +121,7 @@ export const getIdValue = ({
};
export function getSingleResultsRow(data: DatasetData) {
return data.rows.length === 1 ? data.rows[0] : null;
return data.rows.length === 1 ? data.rows[0] : undefined;
}
export const getSinglePKIndex = (cols: DatasetColumn[]) => {

View File

@@ -82,6 +82,7 @@ import type {
import type { Dispatch, State } from "metabase-types/store";
import { EmptyVizState } from "../EmptyVizState";
import type { ObjectId } from "../ObjectDetail/types";
import ChartSettingsErrorButton from "./ChartSettingsErrorButton";
import { ErrorView } from "./ErrorView";
@@ -116,7 +117,7 @@ type ForwardedRefProps = {
type OnChangeCardAndRunOpts = {
nextCard: Card;
previousCard: Card;
objectId?: number;
objectId: ObjectId;
};
type VisualizationOwnProps = {
@@ -212,7 +213,7 @@ const mapStateToProps = (state: State): StateProps => ({
fontFamily: getFont(state),
isRawTable: getIsShowingRawTable(state),
isEmbeddingSdk: isEmbeddingSdk(),
scrollToLastColumn: getUiControls(state)?.scrollToLastColumn,
scrollToLastColumn: getUiControls(state)?.scrollToLastColumn ?? false,
isDownloadingToImage: getIsDownloadingToImage(state),
});