Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

CSV Replacement Frontend #40480

Merged
merged 5 commits into from
Mar 26, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 66 additions & 10 deletions e2e/test/scenarios/collections/uploads.cy.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ describeWithSnowplow(
cy.intercept("POST", "/api/dataset").as("dataset");
cy.intercept("POST", "/api/card/from-csv").as("uploadCSV");
cy.intercept("POST", "/api/table/*/append-csv").as("appendCSV");
cy.intercept("POST", "/api/table/*/replace-csv").as("replaceCSV");
});

it("Can upload a CSV file to an empty postgres schema", () => {
Expand Down Expand Up @@ -173,7 +174,10 @@ describeWithSnowplow(
`Showing ${validTestFiles[0].rowCount} rows`,
);

appendFile(validTestFiles[0]);
uploadToExisting({
testFile: validTestFiles[0],
uploadMode: "append",
});
cy.findByTestId("view-footer").findByText(
`Showing ${validTestFiles[0].rowCount * 2} rows`,
);
Expand All @@ -185,7 +189,44 @@ describeWithSnowplow(
`Showing ${validTestFiles[0].rowCount} rows`,
);

appendFile(validTestFiles[1], false);
uploadToExisting({
testFile: validTestFiles[1],
valid: false,
uploadMode: "append",
});
cy.findByTestId("view-footer").findByText(
`Showing ${validTestFiles[0].rowCount} rows`,
);
});
});

describe("CSV replacement", () => {
it("Can replace data in an existing table", () => {
uploadFile(validTestFiles[0]);
cy.findByTestId("view-footer").findByText(
`Showing ${validTestFiles[0].rowCount} rows`,
);

uploadToExisting({
testFile: validTestFiles[0],
uploadMode: "replace",
});
cy.findByTestId("view-footer").findByText(
`Showing ${validTestFiles[0].rowCount} rows`,
);
});

it("Cannot data in a table with a different schema", () => {
uploadFile(validTestFiles[0]);
cy.findByTestId("view-footer").findByText(
`Showing ${validTestFiles[0].rowCount} rows`,
);

uploadToExisting({
testFile: validTestFiles[1],
valid: false,
uploadMode: "replace",
});
cy.findByTestId("view-footer").findByText(
`Showing ${validTestFiles[0].rowCount} rows`,
);
Expand Down Expand Up @@ -332,12 +373,24 @@ function uploadFile(testFile, valid = true) {
}
}

function appendFile(testFile, valid = true) {
function uploadToExisting({ testFile, valid = true, uploadMode = "append" }) {
// assumes we're already looking at an uploadable model page
cy.findByTestId("qb-header").icon("upload");
cy.findByTestId("qb-header").icon("upload").click();

const uploadOptions = {
append: "Append data to this model",
replace: "Replace all data in this model",
};

const uploadEndpoints = {
append: "@appendCSV",
replace: "@replaceCSV",
};

popover().findByText(uploadOptions[uploadMode]).click();

cy.fixture(`${FIXTURE_PATH}/${testFile.fileName}`).then(file => {
cy.get("#append-file-input").selectFile(
cy.get("#upload-file-input").selectFile(
{
contents: Cypress.Buffer.from(file),
fileName: testFile.fileName,
Expand All @@ -352,13 +405,16 @@ function appendFile(testFile, valid = true) {
.should("contain", "Uploading data to")
.and("contain", testFile.fileName);

cy.wait("@appendCSV");
cy.wait(uploadEndpoints[uploadMode]);

cy.findByTestId("status-root-container").findByText(/Data added to/i, {
timeout: 10 * 1000,
});
cy.findByTestId("status-root-container").findByText(
/Data (added|replaced)/i,
{
timeout: 10 * 1000,
},
);
} else {
cy.wait("@appendCSV");
cy.wait(uploadEndpoints[uploadMode]);

cy.findByTestId("status-root-container").findByText(
"Error uploading your file",
Expand Down
7 changes: 7 additions & 0 deletions frontend/src/metabase-types/store/upload.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import type { CollectionId, TableId } from "metabase-types/api";

export enum UploadMode {
append = "append",
create = "create",
replace = "replace",
}

export type FileUpload = {
status: "complete" | "in-progress" | "error";
name: string;
Expand All @@ -8,6 +14,7 @@ export type FileUpload = {
tableId?: TableId;
message?: string;
error?: string;
uploadMode?: UploadMode;
id: number;
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,11 @@ export function CollectionContent({
modelId,
collectionId,
tableId,
uploadMode,
}: UploadFileProps) =>
dispatch(uploadFileAction({ file, modelId, collectionId, tableId }));
dispatch(
uploadFileAction({ file, modelId, collectionId, tableId, uploadMode }),
);

const error =
bookmarksError || databasesError || collectionsError || collectionError;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,10 +77,16 @@ export function CollectionContentView({
};

const handleUploadFile = useCallback(
({ collectionId, tableId, modelId }) => {
({ collectionId, tableId, modelId, uploadMode }) => {
if (uploadedFile && (collectionId || tableId)) {
closeModelUploadModal();
uploadFile({ file: uploadedFile, collectionId, tableId, modelId });
uploadFile({
file: uploadedFile,
collectionId,
tableId,
modelId,
uploadMode,
});
}
},
[uploadFile, uploadedFile, closeModelUploadModal],
Expand Down
52 changes: 39 additions & 13 deletions frontend/src/metabase/collections/components/ModelUploadModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,22 @@ import {
Icon,
} from "metabase/ui";
import type { CollectionId, TableId, CardId } from "metabase-types/api";
import { UploadMode } from "metabase-types/store/upload";

import { findLastEditedCollectionItem } from "./utils";

enum UploadMode {
append = "append",
create = "create",
}

type CollectionOrTableIdProps =
| { collectionId: CollectionId; tableId?: never }
| { collectionId?: never; tableId: TableId; modelId?: CardId };
| {
uploadMode: UploadMode.create;
collectionId: CollectionId;
tableId?: never;
}
| {
uploadMode: UploadMode.append | UploadMode.replace;
collectionId?: never;
tableId: TableId;
modelId?: CardId;
};

export function ModelUploadModal({
opened,
Expand All @@ -33,7 +38,11 @@ export function ModelUploadModal({
}: {
opened: boolean;
onClose: () => void;
onUpload: ({ collectionId, tableId }: CollectionOrTableIdProps) => void;
onUpload: ({
collectionId,
tableId,
uploadMode,
}: CollectionOrTableIdProps) => void;
collectionId: CollectionId;
}) {
const [uploadMode, setUploadMode] = useState<UploadMode>(UploadMode.create);
Expand Down Expand Up @@ -63,24 +72,25 @@ export function ModelUploadModal({
);

const handleUpload = () => {
if (uploadMode === "append" && tableId) {
if (uploadMode !== UploadMode.create && tableId) {
const modelForTableId = uploadableModels.find(
model => model.based_on_upload === Number(tableId),
);
return onUpload({
tableId: Number(tableId),
modelId: modelForTableId?.id,
uploadMode: uploadMode,
});
}

return onUpload({ collectionId });
return onUpload({ collectionId, uploadMode: UploadMode.create });
};

useEffect(() => {
// if we trigger the modal, and there's no uploadable models, just
// automatically upload a new one
if (opened && uploadableModels.length === 0) {
onUpload({ collectionId });
onUpload({ collectionId, uploadMode: UploadMode.create });
onClose();
}
}, [onUpload, onClose, collectionId, uploadableModels, opened]);
Expand All @@ -91,6 +101,17 @@ export function ModelUploadModal({

const isFormValid = uploadMode === UploadMode.create || !!tableId;

const buttonText = (() => {
switch (uploadMode) {
case UploadMode.create:
return t`Create model`;
case UploadMode.append:
return t`Append to model`;
case UploadMode.replace:
return t`Replace model data`;
}
})();

return (
<Modal
opened={opened}
Expand All @@ -116,8 +137,13 @@ export function ModelUploadModal({
label={t`Append to a model`}
value={UploadMode.append}
/>
<Radio
mt="md"
label={t`Replace data in a model`}
value={UploadMode.replace}
/>
</Radio.Group>
{uploadMode === UploadMode.append && (
{uploadMode !== UploadMode.create && (
<Select
icon={<Icon name="model" />}
placeholder="Select a model"
Expand All @@ -136,7 +162,7 @@ export function ModelUploadModal({
<Flex justify="flex-end" gap="sm">
<Button onClick={onClose}>Cancel</Button>
<Button onClick={handleUpload} variant="filled" disabled={!isFormValid}>
{uploadMode === UploadMode.append ? t`Append` : t`Create`}
{buttonText}
</Button>
</Flex>
</Modal>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import type { ChangeEvent } from "react";
import { useCallback, useRef } from "react";
import { useCallback, useState, useRef } from "react";
import { t } from "ttag";

import { useSetting } from "metabase/common/hooks";
import EntityMenu from "metabase/components/EntityMenu";
import BookmarkToggle from "metabase/core/components/BookmarkToggle";
import Button from "metabase/core/components/Button";
Expand All @@ -19,14 +20,15 @@ import { softReloadCard } from "metabase/query_builder/actions";
import { trackTurnIntoModelClicked } from "metabase/query_builder/analytics";
import { MODAL_TYPES } from "metabase/query_builder/constants";
import { uploadFile } from "metabase/redux/uploads";
import { getSetting } from "metabase/selectors/settings";
import { getUserIsAdmin } from "metabase/selectors/user";
import { Icon, Menu } from "metabase/ui";
import * as Lib from "metabase-lib";
import type Question from "metabase-lib/v1/Question";
import {
checkCanBeModel,
checkDatabaseCanPersistDatasets,
} from "metabase-lib/v1/metadata/utils/models";
import { UploadMode } from "metabase-types/store/upload";

import { canUploadToQuestion } from "../../../../../selectors";
import { ViewHeaderIconButtonContainer } from "../../ViewHeader.styled";
Expand Down Expand Up @@ -71,10 +73,8 @@ export const QuestionActions = ({
onInfoClick,
onModelPersistenceChange,
}: Props) => {
const isMetabotEnabled = useSelector(state =>
getSetting(state, "is-metabot-enabled"),
);

const [uploadMode, setUploadMode] = useState<UploadMode>(UploadMode.append);
const isMetabotEnabled = useSetting("is-metabot-enabled");
const canUpload = useSelector(canUploadToQuestion(question));

const isModerator = useSelector(getUserIsAdmin) && question.canWrite?.();
Expand Down Expand Up @@ -232,8 +232,11 @@ export const QuestionActions = ({

const fileInputRef = useRef<HTMLInputElement>(null);

const handleUploadClick = () => {
const handleUploadClick = (
newUploadMode: UploadMode.append | UploadMode.replace,
) => {
if (fileInputRef.current) {
setUploadMode(newUploadMode);
fileInputRef.current.click();
}
};
Expand All @@ -245,6 +248,7 @@ export const QuestionActions = ({
file,
tableId: question._card.based_on_upload,
reloadQuestionData: true,
uploadMode,
})(dispatch);

// reset the file input so that subsequent uploads of the same file trigger the change handler
Expand Down Expand Up @@ -281,21 +285,39 @@ export const QuestionActions = ({
<input
type="file"
accept="text/csv"
id="append-file-input"
id="upload-file-input"
ref={fileInputRef}
onChange={handleFileUpload}
style={{ display: "none" }}
/>
<Tooltip tooltip={t`Upload data to this model`}>
<ViewHeaderIconButtonContainer>
<Button
onlyIcon
icon="upload"
iconSize={HEADER_ICON_SIZE}
onClick={handleUploadClick}
color={infoButtonColor}
data-testid="qb-header-append-button"
/>
<Menu position="bottom-end">
<Menu.Target>
<Button
onlyIcon
icon="upload"
iconSize={HEADER_ICON_SIZE}
color={infoButtonColor}
data-testid="qb-header-append-button"
/>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
icon={<Icon name="add" />}
onClick={() => handleUploadClick(UploadMode.append)}
>
{t`Append data to this model`}
</Menu.Item>

<Menu.Item
icon={<Icon name="refresh" />}
onClick={() => handleUploadClick(UploadMode.replace)}
>
{t`Replace all data in this model`}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</ViewHeaderIconButtonContainer>
</Tooltip>
</>
Expand Down
Loading
Loading