Compare commits

...

18 Commits

Author SHA1 Message Date
Oisin Coveney
4a4820f8cf Merge branch 'master' into sdk-do-not-run-question-on-update 2024-08-14 13:01:44 +03:00
Phoomparin Mano
dfd311f64f revert originalQuestion check to try to fix e2e failures 2024-08-14 17:43:43 +08:00
Phoomparin Mano
cf35abf7c8 Merge remote-tracking branch 'origin/master' into sdk-do-not-run-question-on-update 2024-08-14 17:14:08 +08:00
Phoomparin Mano
f17cf1f627 remove unused import 2024-08-14 16:26:03 +08:00
Phoomparin Mano
1b6ef871ba change imports to support metric 2024-08-14 16:19:30 +08:00
Phoomparin Mano
2090fd5618 Merge remote-tracking branch 'origin/master' into sdk-do-not-run-question-on-update 2024-08-14 16:13:00 +08:00
Phoomparin Mano
acaf29fec7 document the non-intuitive loadQuestion useEffect 2024-08-14 15:39:30 +08:00
Phoomparin Mano
4b76bbf497 naming tweaks to be more self-explanatory 2024-08-14 15:33:44 +08:00
Phoomparin Mano
df328ac63b fix query builder predicate imports 2024-08-14 15:31:24 +08:00
Phoomparin Mano
234a29ff6d create utils subfolder for query builder 2024-08-13 23:07:35 +08:00
Phoomparin Mano
7c54c58cf1 update types 2024-08-13 23:00:01 +08:00
Phoomparin Mano
2796a9a0e6 extract question predicate functions to question.ts 2024-08-13 22:14:41 +08:00
Phoomparin Mano
69d5d70c5a Merge remote-tracking branch 'origin/master' into sdk-do-not-run-question-on-update 2024-08-13 15:16:56 +08:00
Phoomparin Mano
668f81e5a4 fix cannot run question 2024-08-09 01:41:56 +07:00
Phoomparin Mano
5964d0e3ee fix runnable queries 2024-08-09 01:24:56 +07:00
Phoomparin Mano
10e8c852b3 reload metadata for runQuestionOnNavigate 2024-08-08 23:46:06 +07:00
Phoomparin Mano
1c440f8913 update question state 2024-08-08 23:40:21 +07:00
Phoomparin Mano
023da744c5 update question state 2024-08-08 22:17:50 +07:00
17 changed files with 213 additions and 118 deletions

View File

@@ -24,10 +24,10 @@ const FilterInner = ({
}: {
question: Question;
} & FilterProps) => {
const { onQuestionChange } = useInteractiveQuestionContext();
const { updateQuestion } = useInteractiveQuestionContext();
const onQueryChange = (query: Lib.Query) =>
onQuestionChange(question.setQuery(query));
updateQuestion(question.setQuery(query), { run: true });
const {
query,

View File

@@ -2,7 +2,7 @@ import { useInteractiveQuestionContext } from "embedding-sdk/components/public/I
import { QuestionFiltersHeader } from "metabase/query_builder/components/view/ViewHeader/components";
export const FilterBar = () => {
const { question, onQuestionChange } = useInteractiveQuestionContext();
const { question, updateQuestion } = useInteractiveQuestionContext();
const shouldRender =
question &&
@@ -22,7 +22,7 @@ export const FilterBar = () => {
<QuestionFiltersHeader
expanded
question={question}
updateQuestion={onQuestionChange}
updateQuestion={updateQuestion}
/>
);
};

View File

@@ -1,33 +1,33 @@
import { useMemo } from "react";
import { useInteractiveQuestionContext } from "embedding-sdk/components/public/InteractiveQuestion/context";
import { useDispatch, useSelector } from "metabase/lib/redux";
import {
runQuestionQuery,
updateQuestion,
} from "metabase/query_builder/actions";
import { useSelector } from "metabase/lib/redux";
import { default as QBNotebook } from "metabase/query_builder/components/notebook/Notebook";
import {
getIsDirty,
getIsResultDirty,
getIsRunnable,
} from "metabase/query_builder/selectors";
isQuestionDirty,
isQuestionRunnable,
} from "metabase/query_builder/utils/question";
import { getSetting } from "metabase/selectors/settings";
import { ScrollArea } from "metabase/ui";
import type Question from "metabase-lib/v1/Question";
type NotebookProps = { onApply?: () => void };
export const Notebook = ({ onApply = () => {} }: NotebookProps) => {
const { question } = useInteractiveQuestionContext();
const { question, originalQuestion, updateQuestion, runQuestion } =
useInteractiveQuestionContext();
const isDirty = useMemo(() => {
return isQuestionDirty(question, originalQuestion);
}, [question, originalQuestion]);
const isRunnable = useMemo(() => {
return isQuestionRunnable(question, isDirty);
}, [question, isDirty]);
const isDirty = useSelector(getIsDirty);
const isRunnable = useSelector(getIsRunnable);
const isResultDirty = useSelector(getIsResultDirty);
const reportTimezone = useSelector(state =>
getSetting(state, "report-timezone-long"),
);
const dispatch = useDispatch();
return (
question && (
<ScrollArea w="100%" h="100%">
@@ -35,14 +35,14 @@ export const Notebook = ({ onApply = () => {} }: NotebookProps) => {
question={question}
isDirty={isDirty}
isRunnable={isRunnable}
isResultDirty={Boolean(isResultDirty)}
isResultDirty={isDirty}
reportTimezone={reportTimezone}
readOnly={false}
updateQuestion={(question: Question) =>
dispatch(updateQuestion(question))
updateQuestion={nextQuestion =>
updateQuestion(nextQuestion, { run: false })
}
runQuestionQuery={() => {
dispatch(runQuestionQuery());
runQuestion();
onApply();
}}
setQueryBuilderMode={() => {}}

View File

@@ -28,10 +28,10 @@ const SummarizeInner = ({
}: {
question: Question;
} & SummarizeProps) => {
const { onQuestionChange } = useInteractiveQuestionContext();
const { updateQuestion } = useInteractiveQuestionContext();
const onQueryChange = (query: Lib.Query) =>
onQuestionChange(question.setQuery(query));
updateQuestion(question.setQuery(query));
// save initial question in case we close without making changes
const initialQuestion = useRef(question.query());

View File

@@ -17,8 +17,8 @@ export const QuestionVisualization = () => {
mode,
isQuestionLoading,
isQueryRunning,
navigateToNewCard,
onNavigateBack,
onNavigateToNewCard,
} = useInteractiveQuestionContext();
if (isQuestionLoading) {
@@ -44,7 +44,7 @@ export const QuestionVisualization = () => {
result={result}
noHeader
mode={mode}
navigateToNewCardInsideQB={onNavigateToNewCard}
navigateToNewCardInsideQB={navigateToNewCard}
onNavigateBack={onNavigateBack}
/>
);

View File

@@ -55,14 +55,17 @@ export const InteractiveQuestionProvider = ({
}: InteractiveQuestionProviderProps) => {
const {
question,
originalQuestion,
queryResults,
isQuestionLoading,
isQueryRunning,
runQuestion,
loadQuestion,
onQuestionChange,
onNavigateToNewCard,
updateQuestion,
navigateToNewCard,
} = useLoadQuestion({
cardId,
options,
@@ -85,14 +88,18 @@ export const InteractiveQuestionProvider = ({
resetQuestion: loadQuestion,
onReset: onReset || loadQuestion,
onNavigateBack,
onQuestionChange,
onNavigateToNewCard,
runQuestion,
updateQuestion,
navigateToNewCard,
plugins: combinedPlugins,
question,
originalQuestion,
queryResults,
mode,
};
// When the `cardId` changes, the reference to the loadQuestion method changes,
// and the question will be reloaded. We should improve this to be more obvious.
useEffect(() => {
loadQuestion();
}, [loadQuestion]);

View File

@@ -1,30 +1,43 @@
import { useRef, useState } from "react";
import { useReducer, useRef } from "react";
import { useAsyncFn, useUnmount } from "react-use";
import {
runQuestionOnLoadSdk,
runQuestionOnQueryChangeSdk,
updateQuestionSdk,
runQuestionOnNavigateSdk,
} from "embedding-sdk/lib/interactive-question";
import { runQuestionQuerySdk } from "embedding-sdk/lib/interactive-question/run-question-query";
import type {
LoadSdkQuestionParams,
NavigateToNewCardParams,
SdkQuestionResult,
SdkQuestionState,
} from "embedding-sdk/types/question";
import { defer, type Deferred } from "metabase/lib/promise";
import { useDispatch } from "metabase/lib/redux";
import type Question from "metabase-lib/v1/Question";
type LoadQuestionResult = Promise<
SdkQuestionState & { originalQuestion?: Question }
>;
export interface LoadQuestionHookResult {
question?: Question;
originalQuestion?: Question;
queryResults?: any[];
isQuestionLoading: boolean;
isQueryRunning: boolean;
loadQuestion(): Promise<SdkQuestionResult & { originalQuestion?: Question }>;
onQuestionChange(question: Question): Promise<void>;
onNavigateToNewCard(params: NavigateToNewCardParams): Promise<void>;
runQuestion(): Promise<void>;
loadQuestion(): LoadQuestionResult;
updateQuestion(
question: Question,
options?: { run?: boolean },
): Promise<void>;
navigateToNewCard(params: NavigateToNewCardParams): Promise<void>;
}
export function useLoadQuestion({
@@ -36,8 +49,8 @@ export function useLoadQuestion({
// Keep track of the latest question and query results.
// They can be updated from the below actions.
const [result, setQuestionResult] = useState<SdkQuestionResult>({});
const { question, queryResults } = result;
const [questionState, setQuestionState] = useReducer(questionReducer, {});
const { question, queryResults } = questionState;
const deferredRef = useRef<Deferred>();
@@ -55,7 +68,7 @@ export function useLoadQuestion({
});
const [loadQuestionState, loadQuestion] = useAsyncFn(async () => {
const result = await dispatch(
const state = await dispatch(
runQuestionOnLoadSdk({
options,
deserializedCard,
@@ -64,62 +77,93 @@ export function useLoadQuestion({
}),
);
setQuestionResult(result);
setQuestionState(state);
return result;
return state;
}, [dispatch, options, deserializedCard, cardId]);
const { originalQuestion } = loadQuestionState.value ?? {};
const [questionChangeState, onQuestionChange] = useAsyncFn(
async (nextQuestion: Question) => {
const [runQuestionState, runQuestion] = useAsyncFn(async () => {
if (!question) {
return;
}
const state = await runQuestionQuerySdk({
question,
originalQuestion,
cancelDeferred: deferred(),
});
setQuestionState(state);
}, [dispatch, question, originalQuestion]);
const [updateQuestionState, updateQuestion] = useAsyncFn(
async (nextQuestion: Question, options: { run?: boolean }) => {
const { run = false } = options ?? {};
if (!question) {
return;
}
const result = await dispatch(
runQuestionOnQueryChangeSdk({
const state = await dispatch(
updateQuestionSdk({
nextQuestion,
previousQuestion: question,
originalQuestion,
cancelDeferred: deferred(),
optimisticUpdateQuestion: question => setQuestionState({ question }),
shouldRunQueryOnQuestionChange: run,
}),
);
setQuestionResult(result);
setQuestionState(state);
},
[dispatch, question, originalQuestion],
);
const [navigateToNewCardState, onNavigateToNewCard] = useAsyncFn(
const [navigateToNewCardState, navigateToNewCard] = useAsyncFn(
async (params: NavigateToNewCardParams) => {
const result = await dispatch(
const state = await dispatch(
runQuestionOnNavigateSdk({
...params,
originalQuestion,
cancelDeferred: deferred(),
onQuestionChange: question => setQuestionState({ question }),
}),
);
if (!result) {
if (!state) {
return;
}
setQuestionResult(result);
setQuestionState(state);
},
[dispatch, originalQuestion],
);
const isQueryRunning =
runQuestionState.loading ||
updateQuestionState.loading ||
navigateToNewCardState.loading;
return {
question,
originalQuestion,
queryResults,
isQuestionLoading: loadQuestionState.loading,
isQueryRunning:
questionChangeState.loading || navigateToNewCardState.loading,
isQueryRunning,
runQuestion,
loadQuestion,
onQuestionChange,
onNavigateToNewCard,
updateQuestion,
navigateToNewCard,
};
}
const questionReducer = (state: SdkQuestionState, next: SdkQuestionState) => ({
...state,
...next,
});

View File

@@ -1,3 +1,3 @@
export { updateQuestionSdk } from "./update-question";
export { runQuestionOnLoadSdk } from "./run-question-on-load";
export { runQuestionOnNavigateSdk } from "./run-question-on-navigate";
export { runQuestionOnQueryChangeSdk } from "./run-question-on-query-change";

View File

@@ -1,6 +1,6 @@
import type {
LoadSdkQuestionParams,
SdkQuestionResult,
SdkQuestionState,
} from "embedding-sdk/types/question";
import { resolveCards } from "metabase/query_builder/actions";
import { loadMetadataForCard } from "metabase/questions/actions";
@@ -20,7 +20,7 @@ export const runQuestionOnLoadSdk =
async (
dispatch: Dispatch,
getState: GetState,
): Promise<SdkQuestionResult & { originalQuestion?: Question }> => {
): Promise<SdkQuestionState & { originalQuestion?: Question }> => {
const { card, originalCard } = await resolveCards({
cardId,
options,

View File

@@ -1,18 +1,19 @@
import { runQuestionQuerySdk } from "embedding-sdk/lib/interactive-question/run-question-query";
import type {
NavigateToNewCardParams,
SdkQuestionResult,
SdkQuestionState,
} from "embedding-sdk/types/question";
import { loadCard } from "metabase/lib/card";
import { loadMetadataForCard } from "metabase/questions/actions";
import { getMetadata } from "metabase/selectors/metadata";
import { getCardAfterVisualizationClick } from "metabase/visualizations/lib/utils";
import Question from "metabase-lib/v1/Question";
import { cardIsEquivalent } from "metabase-lib/v1/queries/utils/card";
import type { Dispatch, GetState } from "metabase-types/store";
import { runQuestionOnQueryChangeSdk } from "./run-question-on-query-change";
interface RunQuestionOnNavigateParams extends NavigateToNewCardParams {
originalQuestion?: Question;
onQuestionChange: (question: Question) => void;
}
export const runQuestionOnNavigateSdk =
@@ -20,16 +21,20 @@ export const runQuestionOnNavigateSdk =
async (
dispatch: Dispatch,
getState: GetState,
): Promise<SdkQuestionResult | null> => {
let { nextCard, previousCard, originalQuestion } = params;
): Promise<SdkQuestionState | null> => {
let {
nextCard,
previousCard,
originalQuestion,
cancelDeferred,
onQuestionChange,
} = params;
// Do not reload questions with breakouts when clicking on a legend item
if (previousCard === nextCard) {
return null;
}
const metadata = getMetadata(getState());
// Fallback when a visualization legend is clicked
if (cardIsEquivalent(previousCard, nextCard)) {
nextCard = await loadCard(nextCard.id, { dispatch, getState });
@@ -37,16 +42,16 @@ export const runQuestionOnNavigateSdk =
nextCard = getCardAfterVisualizationClick(nextCard, previousCard);
}
const previousQuestion = new Question(previousCard, metadata);
const nextQuestion = new Question(nextCard, metadata);
// Optimistic update the UI before we re-fetch the query metadata.
onQuestionChange(new Question(nextCard, getMetadata(getState())));
const result = await dispatch(
runQuestionOnQueryChangeSdk({
previousQuestion,
nextQuestion,
originalQuestion,
}),
);
await dispatch(loadMetadataForCard(nextCard));
return result as SdkQuestionResult;
const state = await runQuestionQuerySdk({
question: new Question(nextCard, getMetadata(getState())),
originalQuestion,
cancelDeferred,
});
return state as SdkQuestionState;
};

View File

@@ -1,4 +1,4 @@
import type { SdkQuestionResult } from "embedding-sdk/types/question";
import type { SdkQuestionState } from "embedding-sdk/types/question";
import type { Deferred } from "metabase/lib/promise";
import { runQuestionQuery } from "metabase/services";
import * as Lib from "metabase-lib";
@@ -12,7 +12,7 @@ interface RunQuestionQueryParams {
export async function runQuestionQuerySdk(
params: RunQuestionQueryParams,
): Promise<SdkQuestionResult> {
): Promise<SdkQuestionState> {
let { question, originalQuestion, cancelDeferred } = params;
const query = question.query();

View File

@@ -1,6 +1,6 @@
import _ from "underscore";
import type { SdkQuestionResult } from "embedding-sdk/types/question";
import type { SdkQuestionState } from "embedding-sdk/types/question";
import type { Deferred } from "metabase/lib/promise";
import { computeQuestionPivotTable } from "metabase/query_builder/actions/core/pivot-table";
import { getAdHocQuestionWithVizSettings } from "metabase/query_builder/actions/core/utils";
@@ -13,21 +13,24 @@ import type { Dispatch, GetState } from "metabase-types/store";
import { runQuestionQuerySdk } from "./run-question-query";
interface RunQuestionOnQueryChangeParams {
interface UpdateQuestionParams {
previousQuestion: Question;
nextQuestion: Question;
originalQuestion?: Question;
shouldStartAdHocQuestion?: boolean;
queryResults?: any[];
cancelDeferred?: Deferred;
/** Optimistic update the question in the query builder UI */
optimisticUpdateQuestion: (question: Question) => void;
/** Whether to run the query by default? */
shouldRunQueryOnQuestionChange?: boolean;
}
export const runQuestionOnQueryChangeSdk =
(params: RunQuestionOnQueryChangeParams) =>
async (
dispatch: Dispatch,
getState: GetState,
): Promise<SdkQuestionResult> => {
export const updateQuestionSdk =
(params: UpdateQuestionParams) =>
async (dispatch: Dispatch, getState: GetState): Promise<SdkQuestionState> => {
let {
previousQuestion,
nextQuestion,
@@ -35,6 +38,8 @@ export const runQuestionOnQueryChangeSdk =
shouldStartAdHocQuestion = true,
cancelDeferred,
queryResults,
optimisticUpdateQuestion: onQuestionChange,
shouldRunQueryOnQuestionChange = false,
} = params;
nextQuestion = getAdHocQuestionWithVizSettings({
@@ -43,19 +48,30 @@ export const runQuestionOnQueryChangeSdk =
shouldStartAdHocQuestion,
});
if (!nextQuestion.canAutoRun()) {
shouldRunQueryOnQuestionChange = false;
}
const rawSeries = createRawSeries({
question: nextQuestion,
queryResult: queryResults?.[0],
datasetQuery: undefined,
});
const questionPivotResult = computeQuestionPivotTable({
const computedPivotQuestion = computeQuestionPivotTable({
question: nextQuestion,
currentQuestion: previousQuestion,
rawSeries,
});
nextQuestion = questionPivotResult.question;
nextQuestion = computedPivotQuestion.question;
if (computedPivotQuestion.shouldRun !== null) {
shouldRunQueryOnQuestionChange = computedPivotQuestion.shouldRun;
}
// Optimistic update the UI before we re-fetch the query metadata.
onQuestionChange(nextQuestion);
const currentDependencies = previousQuestion
? Lib.dependentMetadata(
@@ -76,10 +92,17 @@ export const runQuestionOnQueryChangeSdk =
}
const metadata = getMetadata(getState());
nextQuestion = new Question(nextQuestion.card(), metadata);
return runQuestionQuerySdk({
question: new Question(nextQuestion.card(), metadata),
originalQuestion,
cancelDeferred,
});
// In most cases, we only update the question when the query change.
// We don't usually run the query right away unless specified.
if (shouldRunQueryOnQuestionChange) {
return runQuestionQuerySdk({
question: nextQuestion,
originalQuestion,
cancelDeferred,
});
}
return { question: nextQuestion };
};

View File

@@ -4,7 +4,7 @@ import type { ObjectId } from "metabase/visualizations/components/ObjectDetail/t
import type Question from "metabase-lib/v1/Question";
import type { Card, CardId } from "metabase-types/api";
export interface SdkQuestionResult {
export interface SdkQuestionState {
question?: Question;
queryResults?: any[];
}

View File

@@ -37,7 +37,6 @@ import {
isTimeseries,
} from "metabase/visualizations/lib/renderer_utils";
import { isAbsoluteDateTimeUnit } from "metabase-types/guards/date-time";
import { isAdHocModelOrMetricQuestion } from "metabase-lib/v1/metadata/utils/models";
import { getCardUiParameters } from "metabase-lib/v1/parameters/utils/cards";
import {
normalizeParameters,
@@ -49,6 +48,7 @@ import { LOAD_COMPLETE_FAVICON } from "metabase/hoc/Favicon";
import { isNotNull } from "metabase/lib/types";
import { getQuestionWithDefaultVisualizationSettings } from "./actions/core/utils";
import { createRawSeries, getWritableColumnProperties } from "./utils";
import { isQuestionDirty, isQuestionRunnable } from "./utils/question";
export const getUiControls = state => state.qb.uiControls;
export const getQueryStatus = state => state.qb.queryStatus;
@@ -458,7 +458,7 @@ function areComposedEntitiesEquivalent({
return isLastRunEquivalentToCurrent || isCurrentEquivalentToLastRun;
}
function areQueriesEquivalent({
export function areQueriesEquivalent({
originalQuestion,
lastRunQuestion,
currentQuestion,
@@ -596,17 +596,7 @@ export const getIsObjectDetail = createSelector(
export const getIsDirty = createSelector(
[getQuestion, getOriginalQuestion],
(question, originalQuestion) => {
// When viewing a dataset, its dataset_query is swapped with a clean query using the dataset as a source table
// (it's necessary for datasets to behave like tables opened in simple mode)
// We need to escape the isDirty check as it will always be true in this case,
// and the page will always be covered with a 'rerun' overlay.
// Once the dataset_query changes, the question will loose the "dataset" flag and it'll work normally
if (!question || isAdHocModelOrMetricQuestion(question, originalQuestion)) {
return false;
}
return question.isDirtyComparedToWithoutParameters(originalQuestion);
},
isQuestionDirty,
);
export const getIsSavedQuestionChanged = createSelector(
@@ -627,16 +617,7 @@ export const getIsSavedQuestionChanged = createSelector(
export const getIsRunnable = createSelector(
[getQuestion, getIsDirty],
(question, isDirty) => {
if (!question) {
return false;
}
if (!question.isSaved() || isDirty) {
const { isEditable } = Lib.queryDisplayInfo(question.query());
return question.canRun() && isEditable;
}
return question.canRun();
},
isQuestionRunnable,
);
export const getQuestionAlerts = createSelector(

View File

@@ -0,0 +1,35 @@
import * as Lib from "metabase-lib";
import type Question from "metabase-lib/v1/Question";
import { isAdHocModelOrMetricQuestion } from "metabase-lib/v1/metadata/utils/models";
export function isQuestionDirty(
question: Question | undefined,
originalQuestion: Question | undefined,
) {
// When viewing a dataset, its dataset_query is swapped with a clean query using the dataset as a source table
// (it's necessary for datasets to behave like tables opened in simple mode)
// We need to escape the isDirty check as it will always be true in this case,
// and the page will always be covered with a 'rerun' overlay.
// Once the dataset_query changes, the question will loose the "dataset" flag and it'll work normally
if (!question || isAdHocModelOrMetricQuestion(question, originalQuestion)) {
return false;
}
return question.isDirtyComparedToWithoutParameters(originalQuestion!);
}
export function isQuestionRunnable(
question: Question | undefined,
isDirty: boolean,
) {
if (!question) {
return false;
}
if (!question.isSaved() || isDirty) {
const { isEditable } = Lib.queryDisplayInfo(question.query());
return question.canRun() && isEditable;
}
return question.canRun();
}

View File

@@ -10,7 +10,7 @@ import {
} from "metabase-types/api/mocks";
import { createMockLocation } from "metabase-types/store/mocks";
import { isNavigationAllowed } from "./utils";
import { isNavigationAllowed } from ".";
const structuredCard = createMockCard({
id: getNextId(),