Compare commits

...

92 Commits

Author SHA1 Message Date
Sébastien
0302b87ab2 styling 2025-10-15 16:20:25 +02:00
Sébastien
d270ebe03a img size and styling 2025-10-15 16:18:05 +02:00
Sébastien
21fa308a4d removed dead code 2025-10-15 16:03:47 +02:00
Sébastien
86ef6368b7 invalidation of user avatar 2025-10-15 16:03:00 +02:00
Cam Saul
2e79764162 Allow caching for images 2025-10-15 15:40:32 +02:00
Cam Saul
eaeeac015f Merge branch 'hackathon/image-upload' of github.com:metabase/metabase into hackathon/image-upload 2025-10-15 15:35:28 +02:00
Cam Saul
4f88b36741 Rename bucket setting 2025-10-15 15:35:10 +02:00
Sébastien
f23cec6159 multi level image browsing in documents 2025-10-15 15:33:02 +02:00
Dan Stone
70db409d41 Add assert for user/current hydration 2025-10-15 14:28:23 +01:00
Dan Stone
387986b33a Add snapshot post test 2025-10-15 14:24:08 +01:00
Cam Saul
ebeefc586b S3 upload 2025-10-15 15:23:11 +02:00
Sébastien
d2d6ff9a94 force refresh user avatar 2025-10-15 15:20:53 +02:00
Ryan Laurie
9ddd3f182d invalidating better 2025-10-15 15:01:21 +02:00
Ryan Laurie
1f82cab131 snapshot timeline 2025-10-15 15:01:21 +02:00
Sébastien
c0bbe5074d Added an images settings page 2025-10-15 15:00:23 +02:00
Sébastien
4ed9cc9e4b images in dashboards yo 2025-10-15 14:54:17 +02:00
Cam Saul
e89293f367 Merge branch 'hackathon/image-upload' of github.com:metabase/metabase into hackathon/image-upload 2025-10-15 14:52:57 +02:00
Cam Saul
9fcb29d5aa Settings for s3 2025-10-15 14:52:50 +02:00
Dan Stone
f85350c2af Hydrate image url on /current 2025-10-15 13:43:14 +01:00
Dan Stone
280335ed4f Add search spec 2025-10-15 13:21:41 +01:00
Ryan Laurie
22882d210a make snapshots great for the first time 2025-10-15 14:11:55 +02:00
Cam Saul
42b620e9dd Collection items return correct ID 2025-10-15 13:55:17 +02:00
Cam Saul
3498454508 Merge branch 'hackathon/image-upload' of github.com:metabase/metabase into hackathon/image-upload 2025-10-15 13:50:01 +02:00
Cam Saul
91d78a2132 Card snapshots return correct URLs 2025-10-15 13:49:44 +02:00
Sébastien
40df76482e styling 2025-10-15 13:47:03 +02:00
Cam Saul
c27bbc1c32 Merge branch 'hackathon/image-upload' of github.com:metabase/metabase into hackathon/image-upload 2025-10-15 13:43:35 +02:00
Cam Saul
0379264b11 Fix schema 2025-10-15 13:43:28 +02:00
Dan Stone
3b511dc42b Quick post test 2025-10-15 12:43:21 +01:00
Cam Saul
779dff147f Fix schema 2025-10-15 13:39:38 +02:00
Dan Stone
9daa217666 Return everything 2025-10-15 12:38:53 +01:00
Dan Stone
2da96b7123 Remove unused require 2025-10-15 12:38:53 +01:00
Cam Saul
fb52de99ac Delete dead code 2025-10-15 13:36:12 +02:00
Cam Saul
6f1eef8f93 Merge branch 'hackathon/image-upload' of github.com:metabase/metabase into hackathon/image-upload 2025-10-15 13:30:29 +02:00
Cam Saul
1587f83242 Return card_id in image metadata 2025-10-15 13:30:17 +02:00
Dan Stone
a3d1fa2516 Card snapshot 2025-10-15 12:23:41 +01:00
Cam Saul
2dcd8e797a Comments return creator profile image URL 2025-10-15 13:23:08 +02:00
Sébastien
d767ac56a7 added back logo 2025-10-15 13:22:43 +02:00
Sébastien
1d168467a3 clicking on an image in a collection opens it in the proper page but not in a new tab 2025-10-15 13:19:03 +02:00
Sébastien
a53b445d02 clicking on an image in a collection opens it in the proper page 2025-10-15 13:18:17 +02:00
Sébastien
da8946833b clicking on an image in a colelction opens it in _blank 2025-10-15 13:16:47 +02:00
Sébastien
ba08f8c9e1 images can be selected from the collection picker and put into documents 2025-10-15 13:11:04 +02:00
Ryan Laurie
24d3421e9b fix imkage detail page 2025-10-15 13:09:12 +02:00
Sébastien
0e18afaac6 tentatively displaying image from their url 2025-10-15 12:57:56 +02:00
Cam Saul
976856ae5d Fix fetching image 2025-10-15 12:22:43 +02:00
Cam Saul
4ccada62d5 Merge branch 'hackathon/image-upload' of github.com:metabase/metabase into hackathon/image-upload 2025-10-15 12:13:01 +02:00
Cam Saul
204f4ddc79 Return User profile images 2025-10-15 12:12:08 +02:00
Ryan Laurie
83e84b4af2 type fixes 2025-10-15 12:08:46 +02:00
Dan Stone
3bbe219d2c Null position 2025-10-15 11:07:27 +01:00
Ryan Laurie
d61bc669ec get image model in collection list 2025-10-15 12:01:45 +02:00
Ryan Laurie
1be72bf25e fix api 2025-10-15 12:01:45 +02:00
Dan Stone
606727cfaa Remove pinning on images 2025-10-15 11:01:08 +01:00
Dan Stone
519d427bc1 Add image to model filter 2025-10-15 10:52:42 +01:00
Cam Saul
fc05f078c3 Return correct URL after uploading image 2025-10-15 11:50:35 +02:00
Dan Stone
248b6d07b7 Add title 2025-10-15 10:45:42 +01:00
Cam Saul
9bc9fd04fe Merge branch 'hackathon/image-upload' of github.com:metabase/metabase into hackathon/image-upload 2025-10-15 11:43:31 +02:00
Cam Saul
c48594b49e Update tests 2025-10-15 11:43:22 +02:00
Dan Stone
1d19b7bf55 Fix child query 2025-10-15 10:42:34 +01:00
Dan Stone
ad3090c0ae Add a collection example 2025-10-15 10:42:32 +01:00
Cam Saul
d3ea34cca8 Merge branch 'hackathon/image-upload' of github.com:metabase/metabase into hackathon/image-upload 2025-10-15 11:39:09 +02:00
Cam Saul
c4f0b6d318 User profile image test (part 1) 2025-10-15 11:38:50 +02:00
Sébastien
9ac509815f ¯\_(ツ)_/¯ 2025-10-15 11:29:09 +02:00
Cam Saul
826e00611f Merge branch 'hackathon/image-upload' of github.com:metabase/metabase into hackathon/image-upload 2025-10-15 11:27:39 +02:00
Cam Saul
8b1bdff016 Fix fetch-image test 2025-10-15 11:27:24 +02:00
Dan Stone
5c2e3cf3df Use position 0 for now 2025-10-15 10:26:33 +01:00
Ryan Laurie
856a467096 allow collection uploads 2025-10-15 11:25:22 +02:00
Cam Saul
a380637c4c Merge branch 'hackathon/image-upload' of github.com:metabase/metabase into hackathon/image-upload 2025-10-15 11:20:50 +02:00
Cam Saul
6e91e8f117 Image fetching works 2025-10-15 11:20:30 +02:00
Dan Stone
3317d4cb68 Add user/collection image linkage 2025-10-15 10:19:04 +01:00
Ryan Laurie
141bcb2a9a image detail page 2025-10-15 11:13:50 +02:00
Sébastien
6b97866c68 using a dummy url after upload in docs 2025-10-15 11:00:30 +02:00
Cam Saul
8a2ca0d6e3 Merge branch 'hackathon/image-upload' of github.com:metabase/metabase into hackathon/image-upload 2025-10-15 10:55:33 +02:00
Cam Saul
660325e570 Add user.profile_image_id 2025-10-15 10:55:14 +02:00
Sébastien
fec18a244c image upload in documents 2025-10-15 10:51:15 +02:00
Cam Saul
e929fedafe Update code placeholders 2025-10-15 10:49:08 +02:00
Cam Saul
780888f304 Merge branch 'hackathon/image-upload' of github.com:metabase/metabase into hackathon/image-upload 2025-10-15 10:43:57 +02:00
Cam Saul
711ce65def API test (WIP) 2025-10-15 10:43:19 +02:00
Dan Stone
51fb79ed82 Return a url for now 2025-10-15 09:42:19 +01:00
Cam Saul
cdec717f14 Fetch image contents (WIP) 2025-10-15 10:39:16 +02:00
Ryan Laurie
592b358baa user avatars 2025-10-15 10:31:39 +02:00
Ryan Laurie
bdcf4cc7df basic image upload 2025-10-15 10:31:39 +02:00
Cam Saul
bb1a332bbb Toucan models 2025-10-15 10:14:33 +02:00
Dan Stone
79f12e00ea Hash stuff 2025-10-15 09:11:50 +01:00
Cam Saul
26b12f394c Fix API endpoint 2025-10-15 10:09:11 +02:00
Cam Saul
cd564c0e7e Merge branch 'hackathon/image-upload' of github.com:metabase/metabase into hackathon/image-upload 2025-10-15 10:07:33 +02:00
Cam Saul
4bf5468fc1 Add content_type to image 2025-10-15 10:07:08 +02:00
Cam Saul
d3c6aae2e6 Merge branch 'hackathon/image-upload' of github.com:metabase/metabase into hackathon/image-upload 2025-10-15 10:06:01 +02:00
Dan Stone
5b7b9dd36e Reminders 2025-10-15 09:05:56 +01:00
Cam Saul
20e671d222 Database migrations for images 2025-10-15 10:03:50 +02:00
Dan Stone
76f3bd5dc4 Make parents 2025-10-15 09:01:09 +01:00
Dan Stone
aedeee0c57 Copy to duck! 2025-10-15 08:48:06 +01:00
Cam Saul
68c83a7505 API placeholders part 2 2025-10-15 09:31:13 +02:00
Cam Saul
b923b49035 Image API endpoint placeholders 2025-10-15 09:05:44 +02:00
63 changed files with 1435 additions and 89 deletions

View File

@@ -7,5 +7,11 @@
"eslint.options": {
"rulePaths": ["frontend/lint/eslint-rules/"]
},
"typescript.tsdk": "node_modules/typescript/lib"
"typescript.tsdk": "node_modules/typescript/lib",
"favorites.resources": [
{
"filePath": "enterprise/frontend/src/metabase-enterprise/documents/components/Editor/shared/MenuComponents.tsx",
"group": "Default"
}
]
}

View File

@@ -33,6 +33,7 @@
com.zaxxer/HikariCP-java7]}
colorize/colorize {:mvn/version "0.1.1" ; string output with ANSI color codes (for logging)
:exclusions [org.clojure/clojure]}
com.amazonaws/aws-java-sdk-s3 {:mvn/version "1.12.792"}
com.clearspring.analytics/stream {:mvn/version "2.9.8" ; Various sketching algorithms
:exclusions [it.unimi.dsi/fastutil
org.slf4j/slf4j-api]}

View File

@@ -104,7 +104,7 @@
[:= :target_type target_type]
[:= :target_id target_id]]
:order-by [[:created_at :asc]]})
(t2/hydrate :creator :reactions))]
(t2/hydrate [:creator :profile_image_url] :reactions))]
{:comments (render-comments comments)})))
(defn notify-comment!
@@ -147,7 +147,7 @@
"Send a notification using only comment id"
[comment-id]
(notify-comment! (-> (t2/select-one :model/Comment :id comment-id)
(t2/hydrate :creator :reactions))))
(t2/hydrate [:creator :profile_image_url] :reactions))))
(api.macros/defendpoint :post "/"
"Create a new comment"
@@ -173,7 +173,7 @@
:content content
:content_html html
:creator_id api/*current-user-id*})
(t2/hydrate :creator)
(t2/hydrate [:creator :profile_image_url])
;; New comments always have empty reactions map
(assoc :reactions []))]
(notify-comment! comment {:entity entity :parent parent})

View File

@@ -20,7 +20,7 @@
[_model k comments]
(mi/instances-with-hydrated-data
comments k
#(t2/select-pk->fn identity [:model/User :id :email :first_name :last_name]
#(t2/select-pk->fn identity [:model/User :id :email :first_name :last_name :profile_image_id]
:id (keep :creator_id comments))
:creator_id
{:default {}}))

View File

@@ -91,7 +91,9 @@
:content content
:target_type "document"
:target_id doc-id
:creator {:id (mt/user->id :rasta)}
:creator {:id (mt/user->id :rasta)
:profile_image_id nil
:profile_image_url nil}
:reactions []}]
(is (=? expected1 created))
(is (=? {:comments [expected1]}

View File

@@ -2,6 +2,7 @@ import { useState } from "react";
import { t } from "ttag";
import { getCurrentUser } from "metabase/admin/datamodel/selectors";
import UserAvatar from "metabase/common/components/UserAvatar";
import { useToast } from "metabase/common/hooks";
import { useDispatch, useSelector } from "metabase/lib/redux";
import { Avatar, Stack, Timeline, rem } from "metabase/ui";
@@ -205,7 +206,7 @@ export const Discussion = ({
{!comments[0]?.is_resolved && (
<Timeline.Item
className={S.commentRoot}
bullet={<Avatar name={currentUser.common_name} />}
bullet={<UserAvatar user={currentUser} />}
>
<CommentEditor
active={false}

View File

@@ -5,6 +5,7 @@ import { useLocation } from "react-use";
import { t } from "ttag";
import { getCurrentUser } from "metabase/admin/datamodel/selectors";
import UserAvatar from "metabase/common/components/UserAvatar";
import { useSelector } from "metabase/lib/redux";
import { Avatar, Box, Group, Icon, Text, Timeline, Tooltip } from "metabase/ui";
import {
@@ -103,7 +104,7 @@ export function DiscussionComment({
className={cx(S.commentRoot, {
[S.target]: isTarget,
})}
bullet={<Avatar name={comment.creator?.common_name} />}
bullet={<UserAvatar user={comment.creator} />}
aria-current={isTarget ? "location" : undefined}
data-testid="discussion-comment"
id={getCommentNodeId(comment)}

View File

@@ -436,6 +436,7 @@ export const DocumentPage = ({
onChange={handleChange}
editable={canWrite}
isLoading={isDocumentLoading}
collectionId={documentData?.collection_id ?? null}
/>
</Box>
</Box>

View File

@@ -29,6 +29,7 @@ import { CustomStarterKit } from "metabase-enterprise/rich_text_editing/tiptap/e
import { DisableMetabotSidebar } from "metabase-enterprise/rich_text_editing/tiptap/extensions/DisableMetabotSidebar";
import { FlexContainer } from "metabase-enterprise/rich_text_editing/tiptap/extensions/FlexContainer/FlexContainer";
import { HandleEditorDrop } from "metabase-enterprise/rich_text_editing/tiptap/extensions/HandleEditorDrop/HandleEditorDrop";
import { ImageBlock } from "metabase-enterprise/rich_text_editing/tiptap/extensions/ImageBlock/ImageBlock";
import { MentionExtension } from "metabase-enterprise/rich_text_editing/tiptap/extensions/Mention/MentionExtension";
import { MentionSuggestion } from "metabase-enterprise/rich_text_editing/tiptap/extensions/Mention/MentionSuggestion";
import {
@@ -40,6 +41,7 @@ import { MetabotMentionSuggestion } from "metabase-enterprise/rich_text_editing/
import { ResizeNode } from "metabase-enterprise/rich_text_editing/tiptap/extensions/ResizeNode/ResizeNode";
import { SmartLink } from "metabase-enterprise/rich_text_editing/tiptap/extensions/SmartLink/SmartLinkNode";
import { createSuggestionRenderer } from "metabase-enterprise/rich_text_editing/tiptap/extensions/suggestionRenderer";
import type { CollectionId } from "metabase-types/api";
import S from "./Editor.module.css";
import { useCardEmbedsTracking, useQuestionSelection } from "./hooks";
@@ -52,6 +54,7 @@ const BUBBLE_MENU_DISALLOWED_NODES: string[] = [
Image.name,
FlexContainer.name,
"codeBlock",
"imageBlock",
];
const getMetabotPromptSerializer =
@@ -87,6 +90,7 @@ export interface EditorProps {
onQuestionSelect?: (cardId: number | null) => void;
editable?: boolean;
isLoading?: boolean;
collectionId?: CollectionId | null;
}
export const Editor: React.FC<EditorProps> = ({
@@ -109,6 +113,7 @@ export const Editor: React.FC<EditorProps> = ({
width: 2,
},
}),
ImageBlock,
Image.configure({
inline: false,
HTMLAttributes: {

View File

@@ -174,6 +174,15 @@ export const CommandExtension = Extension.create<CommandOptions>({
case "codeBlock":
editor.chain().focus().deleteRange(range).toggleCodeBlock().run();
break;
case "imageBlock":
editor
.chain()
.deleteRange(range)
.insertContent({
type: "imageBlock",
})
.run();
break;
}
},
selectItem: ({

View File

@@ -35,7 +35,11 @@ import { getCurrentDocument } from "metabase-enterprise/documents/selectors";
import type { SearchResult } from "metabase-types/api";
import { EntitySearchSection } from "../shared/EntitySearchSection";
import { EMBED_SEARCH_MODELS, LINK_SEARCH_MODELS } from "../shared/constants";
import {
EMBED_SEARCH_MODELS,
IMAGE_SEARCH_MODELS,
LINK_SEARCH_MODELS,
} from "../shared/constants";
import { useEntitySuggestions } from "../shared/useEntitySuggestions";
import type { CommandProps } from "./CommandExtension";
@@ -111,6 +115,7 @@ export const CommandSuggestion = forwardRef<
const [selectedIndex, setSelectedIndex] = useState(0);
const [showLinkSearch, setShowLinkSearch] = useState(false);
const [showEmbedSearch, setShowEmbedSearch] = useState(false);
const [showImageSearch, setShowImageSearch] = useState(false);
const itemRefs = useRef<(HTMLButtonElement | null)[]>([]);
const allCommandSections: CommandSection[] = useMemo(
@@ -136,6 +141,16 @@ export const CommandSuggestion = forwardRef<
label: t`Link`,
command: "linkTo",
},
// {
// icon: "snail",
// label: t`Image`,
// command: "insertImage",
// },
{
icon: "snail",
label: t`Image`,
command: "imageBlock",
},
],
},
{
@@ -230,7 +245,11 @@ export const CommandSuggestion = forwardRef<
editor,
onSelectEntity: onSelectLinkEntity,
enabled: true,
searchModels: showLinkSearch ? LINK_SEARCH_MODELS : EMBED_SEARCH_MODELS,
searchModels: showLinkSearch
? LINK_SEARCH_MODELS
: showImageSearch
? IMAGE_SEARCH_MODELS
: EMBED_SEARCH_MODELS,
});
const {
@@ -273,13 +292,18 @@ export const CommandSuggestion = forwardRef<
return;
}
if (commandName === "insertImage") {
setShowImageSearch(true);
return;
}
command({
command: commandName,
});
};
const currentItems = useMemo(() => {
if (showLinkSearch || showEmbedSearch) {
if (showLinkSearch || showEmbedSearch || showImageSearch) {
return searchMenuItems;
}
@@ -290,17 +314,24 @@ export const CommandSuggestion = forwardRef<
}
return commandOptions;
}, [showLinkSearch, showEmbedSearch, query, searchMenuItems, commandOptions]);
}, [
showLinkSearch,
showEmbedSearch,
query,
searchMenuItems,
commandOptions,
showImageSearch,
]);
let totalItems = currentItems.length;
if (showLinkSearch || showEmbedSearch) {
if (showLinkSearch || showEmbedSearch || showImageSearch) {
totalItems = searchMenuItems.length + 1;
} else if (currentItems.length === 0 && query) {
totalItems = 1; // Just the browse all footer
}
const selectItem = (index: number) => {
if (showLinkSearch || showEmbedSearch) {
if (showLinkSearch || showEmbedSearch || showImageSearch) {
entityHandlers.selectItem(index);
} else {
// When searching in command mode, handle both entity results and commands
@@ -344,6 +375,7 @@ export const CommandSuggestion = forwardRef<
currentItems.length,
showLinkSearch,
showEmbedSearch,
showImageSearch,
searchMenuItems.length,
]);
@@ -356,7 +388,7 @@ export const CommandSuggestion = forwardRef<
useImperativeHandle(ref, () => ({
onKeyDown: ({ event }: { event: KeyboardEvent }) => {
if (showLinkSearch || showEmbedSearch) {
if (showLinkSearch || showEmbedSearch || showImageSearch) {
return entityHandlers.onKeyDown({ event });
}
@@ -379,13 +411,16 @@ export const CommandSuggestion = forwardRef<
},
}));
if ((showLinkSearch || showEmbedSearch) && isSearchLoading) {
if (
(showLinkSearch || showEmbedSearch || showImageSearch) &&
isSearchLoading
) {
return <LoadingSuggestionPaper aria-label={t`Command Dialog`} />;
}
return (
<SuggestionPaper aria-label={t`Command Dialog`}>
{showLinkSearch || showEmbedSearch ? (
{showLinkSearch || showEmbedSearch || showImageSearch ? (
<EntitySearchSection
menuItems={searchMenuItems}
selectedIndex={entitySelectedIndex}

View File

@@ -0,0 +1,17 @@
.imageBlockContainer {
border: 1px solid var(--mb-color-border-secondary);
border-radius: 6px;
padding: var(--mantine-spacing-md);
background-color: var(--mb-color-bg-light);
display: flex;
&.selected {
background-color: var(--mb-color-bg-medium);
}
}
.uploadWrapper {
cursor: pointer;
display: inline-block;
}

View File

@@ -0,0 +1,152 @@
import type { NodeViewProps } from "@tiptap/core";
import {
Node,
NodeViewWrapper,
ReactNodeViewRenderer,
mergeAttributes,
} from "@tiptap/react";
import cx from "classnames";
import { useCallback, useEffect, useState } from "react";
import { t } from "ttag";
import { useUploadImageMutation } from "metabase/api";
import { QuestionPickerModal } from "metabase/common/components/Pickers/QuestionPicker";
import { useSelector } from "metabase/lib/redux";
import { Button, Flex, Icon, Text } from "metabase/ui";
import { getCurrentDocument } from "metabase-enterprise/documents/selectors";
import S from "./ImageBlock.module.css";
export const ImageBlock = Node.create({
name: "imageBlock",
group: "block",
renderHTML({ HTMLAttributes }) {
return [
"div",
mergeAttributes(HTMLAttributes, { "data-type": "draggable-item" }),
0,
];
},
addAttributes() {
return {
url: {
default: null,
},
};
},
addNodeView() {
return ReactNodeViewRenderer(ImageBlockNodeView);
},
});
export const ImageBlockNodeView = ({
updateAttributes,
node,
selected,
}: NodeViewProps) => {
const [rendered, setRendered] = useState(false); // floating ui wrongly positions things without this
const doc = useSelector(getCurrentDocument);
const [showPicker, setShowPicker] = useState(false);
useEffect(() => {
if (!rendered) {
setRendered(true);
}
}, [rendered]);
const [uploadImage] = useUploadImageMutation();
const uploadFile = useCallback(
async (file: File) => {
const { data } = await uploadImage({
file,
collectionId: doc?.collection_id as any,
});
updateAttributes({
url: data?.url,
});
},
[uploadImage, doc, updateAttributes],
);
const [selectedItem, setSelectedItem] = useState<any>(null);
return (
<NodeViewWrapper className={cx(S.root)} contentEditable={false}>
{node.attrs.url ? (
<img
src={node.attrs.url}
style={{
maxWidth: "100%",
maxHeight: "50vh",
padding: 8,
border: "1px solid var(--mb-color-border-secondary)",
margin: "0 auto",
}}
className={S.image}
/>
) : (
<Flex
gap="xs"
align="center"
direction="row"
className={cx(S.imageBlockContainer, {
[S.selected]: selected,
})}
>
<Icon name="snail" size={16} color="var(--mb-color-text-light)" />
<Text
style={{ userSelect: "none", color: "var(--mb-color-text-light)" }}
>{t`Add an image`}</Text>
<label className={S.uploadWrapper} htmlFor="file-upload-input">
<Button
onClick={() => {
document.getElementById("file-upload-input")?.click();
}}
leftSection={<Icon name="upload" size={16} />}
variant="subtle"
size="xs"
>{t`Upload new`}</Button>
<input
id="file-upload-input"
style={{ display: "none" }}
type="file"
onChange={(e) => uploadFile(e.target.files![0])}
/>
</label>
<Button
leftSection={<Icon name="folder" size={16} />}
variant="subtle"
size="xs"
onClick={() => setShowPicker(true)}
>{t`Browse collections`}</Button>
{showPicker && rendered && (
<QuestionPickerModal
title={t`Select an image yo`}
value={selectedItem}
models={["image"]}
onClose={() => setShowPicker(false)}
onChange={(item: any) => {
if (item.id === "root") {
setSelectedItem(null);
} else if (item.model === "image") {
updateAttributes({ url: `/api/images/${item.id}/contents` });
setShowPicker(false);
} else {
setSelectedItem(item);
}
}}
/>
)}
</Flex>
)}
</NodeViewWrapper>
);
};

View File

@@ -17,7 +17,7 @@ interface EntitySearchSectionProps {
onFooterClick: () => void;
query: string;
searchResults: SearchResult[];
modal: "question-picker" | null;
modal: "question-picker" | "image-picker" | null;
onModalSelect: (item: QuestionPickerValueItem) => void;
onModalClose: () => void;
onItemHover: (index: number) => void;

View File

@@ -13,6 +13,8 @@ export const LINK_SEARCH_MODELS: SuggestionModel[] = [
export const EMBED_SEARCH_MODELS: SuggestionModel[] = ["card", "dataset"];
export const IMAGE_SEARCH_MODELS: SuggestionModel[] = ["image"];
export const LINK_SEARCH_LIMIT = 5;
export const USER_SEARCH_LIMIT = LINK_SEARCH_LIMIT;

View File

@@ -97,6 +97,7 @@ export const COLLECTION_ITEM_MODELS = [
"collection",
"indexed-entity",
"document",
"image",
] as const;
export type CollectionItemModel = (typeof COLLECTION_ITEM_MODELS)[number];

View File

@@ -22,6 +22,7 @@ const ENABLED_SEARCH_MODELS = [
"action",
"indexed-entity",
"document",
"image",
] as const;
export const SEARCH_MODELS = [...ENABLED_SEARCH_MODELS, "segment"] as const;

View File

@@ -691,6 +691,9 @@ export interface EnterpriseSettings extends Settings {
"python-storage-s-3-path-style-access"?: boolean | null;
"python-runner-timeout-seconds"?: number | null;
"python-runner-test-run-timeout-seconds"?: number | null;
"image-upload-s3-bucket"?: string | null;
"image-upload-aws-access-key-id"?: string | null;
"image-upload-aws-secret-access-key"?: string | null;
/**
* @deprecated
*/

View File

@@ -1,10 +1,15 @@
import type { Path } from "history";
import { useMemo } from "react";
import Dropzone from "react-dropzone";
import { t } from "ttag";
import { useUploadImageMutation } from "metabase/api";
import Radio from "metabase/common/components/Radio";
import { useDispatch } from "metabase/lib/redux";
import { getFullName } from "metabase/lib/user";
import { PLUGIN_IS_PASSWORD_USER } from "metabase/plugins";
import { refreshCurrentUser } from "metabase/redux/user";
import { Box, Icon } from "metabase/ui";
import type { User } from "metabase-types/api";
import {
@@ -31,6 +36,8 @@ export const AccountHeader = ({
[user],
);
const dispatch = useDispatch();
const tabs = useMemo(
() => [
{ name: t`Profile`, value: "/account/profile" },
@@ -45,10 +52,34 @@ export const AccountHeader = ({
const userFullName = getFullName(user);
const [uploadImage] = useUploadImageMutation();
const handleUpload = async (files: File[]) => {
if (files.length > 0) {
const file = files[0];
await uploadImage({ file, userId: user.id });
dispatch(refreshCurrentUser());
}
};
return (
<AccountHeaderRoot data-testid="account-header">
<HeaderSection>
<HeaderAvatar user={user} />
<Dropzone onDrop={handleUpload}>
{({ getRootProps, getInputProps }) => (
<Box
{...getRootProps()}
pos="relative"
style={{ cursor: "pointer" }}
>
<input {...getInputProps()} />
<Icon name="pencil" pos="absolute" right="-10px" bottom="20px" />
<HeaderAvatar user={user} />
</Box>
)}
</Dropzone>
{userFullName && <HeaderTitle>{userFullName}</HeaderTitle>}
<HeaderSubtitle>{user.email}</HeaderSubtitle>
</HeaderSection>

View File

@@ -87,6 +87,7 @@ export function SettingsNav() {
/>,
]}
</SettingsNavItem>
<SettingsNavItem path="images" label={t`Images`} icon="snail" />
<NavDivider />
<SettingsNavItem path="uploads" label={t`Uploads`} icon="upload" />
{/* Python Runner settings are managed by Metabase Cloud for hosted instances */}

View File

@@ -0,0 +1,27 @@
import { jt, t } from "ttag";
import { SettingsPageWrapper } from "metabase/admin/components/SettingsSection";
import { AdminSettingInput } from "../widgets/AdminSettingInput";
export function ImagesSettingsPage() {
return (
<SettingsPageWrapper title={t`Images`}>
<AdminSettingInput
name="image-upload-s3-bucket"
title={t`S3 Bucket URL`}
inputType="text"
/>
<AdminSettingInput
name="image-upload-aws-access-key-id"
title={t`AWS Access Key ID`}
inputType="text"
/>
<AdminSettingInput
name="image-upload-aws-secret-access-key"
title={t`AWS Secret Access Key`}
inputType="password"
/>
</SettingsPageWrapper>
);
}

View File

@@ -15,6 +15,7 @@ import { AuthenticationSettingsPage } from "./settings/components/SettingsPages/
import { CloudSettingsPage } from "./settings/components/SettingsPages/CloudSettingsPage";
import { EmailSettingsPage } from "./settings/components/SettingsPages/EmailSettingsPage";
import { GeneralSettingsPage } from "./settings/components/SettingsPages/GeneralSettingsPage";
import { ImagesSettingsPage } from "./settings/components/SettingsPages/ImagesSettingsPage";
import { LicenseSettingsPage } from "./settings/components/SettingsPages/LicenseSettingsPage";
import { LocalizationSettingsPage } from "./settings/components/SettingsPages/LocalizationSettingsPage";
import { MapsSettingsPage } from "./settings/components/SettingsPages/MapsSettingsPage";
@@ -68,6 +69,7 @@ export const getSettingsRoutes = () => (
<Route path="public-sharing" component={PublicSharingSettingsPage} />
<Route path="license" component={LicenseSettingsPage} />
<Route path="appearance" component={() => <AppearanceSettingsPage />} />
<Route path="images" component={ImagesSettingsPage} />
<Route
path="whitelabel"
component={() => <AppearanceSettingsPage tab="branding" />}

View File

@@ -62,8 +62,10 @@ export const collectionApi = Api.injectEndpoints({
url: `/api/collection/${id}/items`,
params,
}),
providesTags: (response, error, { models }) =>
provideCollectionItemListTags(response?.data ?? [], models),
providesTags: (response, error, { models, id }) => {
// console.log
return provideCollectionItemListTags(response?.data ?? [], models, id);
},
}),
getCollection: builder.query<Collection, getCollectionRequest>({
query: ({ id, ...params }) => {

View File

@@ -0,0 +1,86 @@
import { getCard } from "metabase/query_builder/selectors";
import { Api } from "./api";
import { idTag, invalidateTags } from "./tags";
type UploadImageRequest = {
file: File;
} & (
| { collectionId: number; userId?: never }
| { userId: number; collectionId?: never }
);
type ImageResponse = {
url: string;
title: string;
};
export const imageApi = Api.injectEndpoints({
endpoints: (builder) => ({
uploadImage: builder.mutation<ImageResponse, UploadImageRequest>({
query: ({ file, collectionId, userId }) => {
const bodyFormData = new FormData();
bodyFormData.append("name", file.name);
bodyFormData.append("type", file.type);
bodyFormData.append("file", file);
const queryParams = userId
? `?user-id=${userId}`
: `?collection-id=${collectionId}`;
return {
url: `/api/images${queryParams}`,
method: "POST",
headers: {
"Content-Type": "multipart/form-data;",
},
body: { formData: bodyFormData },
formData: true,
fetch: true,
};
},
invalidatesTags: (_, error, { collectionId }) => [
idTag(`collection-item-list`, collectionId ?? 0),
],
}),
getImageData: builder.query<ImageResponse, { id: number }>({
query: ({ id }) => ({
url: `/api/images/${id}`,
}),
}),
snapshotCard: builder.mutation<{ url: string }, { cardId: number }>({
query: ({ cardId }) => ({
method: "POST",
url: `/api/images/card/${cardId}/snapshot`,
}),
invalidatesTags: ({ collection_id }, error, { id: cardId }) => {
console.log(
"Invalidating snapshot tags for card",
cardId,
"in collection",
collection_id,
);
return invalidateTags(error, [
idTag("card-snapshot-list", cardId),
idTag(`collection-item-list`, collection_id ?? 0),
]);
},
}),
getCardSnapshots: builder.query<
{ id: number; url: string; created_at: string }[],
{ cardId: number }
>({
query: ({ cardId }) => ({
url: `/api/images/card/${cardId}/snapshots`,
}),
providesTags: (response, error, { cardId }) => [
idTag("card-snapshot-list", cardId),
],
}),
}),
});
export const {
useGetImageDataQuery,
useUploadImageMutation,
useSnapshotCardMutation,
useGetCardSnapshotsQuery,
} = imageApi;

View File

@@ -17,6 +17,7 @@ export * from "./glossary";
export * from "./entity-id";
export * from "./field";
export * from "./google";
export * from "./image";
export * from "./ldap";
export * from "./login-history";
export * from "./model-index";

View File

@@ -9,6 +9,8 @@ export const TAG_TYPES = [
"channel",
"cloud-migration",
"collection",
"collection-item-list",
"card-snapshot-list",
"content-translation",
"dashboard",
"dashboard-question-candidates",

View File

@@ -9,6 +9,7 @@ import type {
CardId,
CardQueryMetadata,
Collection,
CollectionId,
CollectionItem,
CollectionItemModel,
Dashboard,
@@ -211,8 +212,10 @@ export function provideCloudMigrationTags(
export function provideCollectionItemListTags(
items: CollectionItem[],
models: CollectionItemModel[] = Array.from(COLLECTION_ITEM_MODELS),
collectionId: CollectionId,
): TagDescription<TagType>[] {
return [
idTag(`collection-item-list`, collectionId),
...models.map((model) => listTag(TAG_TYPE_MAPPING[model])),
...items.flatMap(provideCollectionItemTags),
];

View File

@@ -4,6 +4,7 @@ import { usePrevious } from "react-use";
import { t } from "ttag";
import ErrorBoundary from "metabase/ErrorBoundary";
import { useUploadImageMutation } from "metabase/api";
import { deletePermanently } from "metabase/archive/actions";
import { ArchivedEntityBanner } from "metabase/archive/components/ArchivedEntityBanner";
import { trackCollectionBookmarked } from "metabase/collections/analytics";
@@ -138,6 +139,8 @@ const CollectionContentViewInner = ({
[visibleColumns],
);
const [uploadImage] = useUploadImageMutation();
const onDrop = (acceptedFiles: File[]) => {
if (!acceptedFiles.length) {
dispatch(
@@ -149,6 +152,11 @@ const CollectionContentViewInner = ({
);
return;
}
const fileType = acceptedFiles[0].type;
console.log({ fileType });
if (fileType.startsWith("image/")) {
return uploadImage({ file: acceptedFiles[0], collectionId: Number(collection.id) });
}
saveFile(acceptedFiles[0]);
};
@@ -157,7 +165,8 @@ const CollectionContentViewInner = ({
maxFiles: 1,
noClick: true,
noDragEventsBubbling: true,
accept: { "text/csv": [".csv"], "text/tab-separated-values": [".tsv"] },
accept: {
"text/csv": [".csv"], "text/tab-separated-values": [".tsv"], "image/*": [".jpg", ".jpeg", ".png", ".gif"] },
});
const handleMove = (selectedItems: CollectionItem[]) => {

View File

@@ -33,4 +33,5 @@ export const ALL_MODELS: CollectionItemModel[] = [
"snippet",
"collection",
"document",
"image",
];

View File

@@ -0,0 +1,56 @@
import { Box, Flex, Image, Title, Icon, Menu, Button, Loader, Center } from "metabase/ui";
import { skipToken, useGetImageDataQuery } from "metabase/api";
export const ImagePage = ({ params: { id }}: { params: { id: number }}) => {
const { data, isLoading } = useGetImageDataQuery(id ? { id } : skipToken);
if (isLoading || !data || !data?.url) {
return (
<Box p="xl">
<Center>
<Loader />
</Center>
</Box>
);
}
const { url, title } = data;
return (
<Box p="xl" px="3rem">
<Flex
mb="lg" align="center" justify="space-between" gap="md" pb="lg"
style={{ borderBottom: "1px solid var(--mb-color-border)" }}
>
<Title order={3}>{title}</Title>
<Menu position="bottom-end">
<Menu.Target>
<Button variant="subtle">
<Icon name="ellipsis" />
</Button>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item onClick={() => {}}>Move</Menu.Item>
<Menu.Item onClick={() => {}}>Rename</Menu.Item>
<Menu.Item onClick={() => {}}>Share</Menu.Item>
<Menu.Item onClick={() => {}}>Download</Menu.Item>
<Menu.Item onClick={() => {}}>Delete</Menu.Item>
<Menu.Divider />
<Menu.Item onClick={() => {}}>Set as Vamsi's profile picture</Menu.Item>
</Menu.Dropdown>
</Menu>
</Flex>
<Box>
{/** if this had a card id, link to the card */}
<Image
src={url}
alt={title}
bdrs="md"
mah="70vh"
style={{ objectFit: "contain" }}
mx="auto"
/>
</Box>
</Box>
)
}

View File

@@ -50,6 +50,11 @@ const ItemLinkComponent = ({
if (isEmbeddingSdk()) {
return <ItemButton onClick={() => onClick?.(item)}>{children}</ItemButton>;
}
if (item.model === "image") {
return <ItemLink to={`/image/${item.id}`}>{children}</ItemLink>;
}
return (
<ItemLink to={item.getUrl()} onClick={() => onClick?.(item)}>
{children}

View File

@@ -4,6 +4,7 @@ import type { ActionMenuProps } from "metabase/collections/components/ActionMenu
import type { OnToggleSelectedWithItem } from "metabase/collections/types";
import type { BaseItemsTableProps } from "metabase/common/components/ItemsTable/BaseItemsTable";
import { Columns } from "metabase/common/components/ItemsTable/Columns";
import type { IconProps } from "metabase/ui";
import { color } from "metabase/ui/utils/colors";
import type Database from "metabase-lib/v1/metadata/Database";
import type { Bookmark, Collection, CollectionItem } from "metabase-types/api";
@@ -40,7 +41,8 @@ export const DefaultItemRenderer = ({
const canSelect =
collection?.can_write && typeof onToggleSelected === "function";
const icon = item.getIcon();
const icon: IconProps =
item.model === "image" ? { name: "snail" } : item.getIcon();
if (item.model === "card" || item.archived) {
icon.color = color("text-light");
}

View File

@@ -20,7 +20,7 @@ export type CollectionItemId = CollectionId | CardId | DashboardId;
// so that we can use its components for picking all of them
export type CollectionPickerModel = Extract<
CollectionItemModel,
"collection" | "card" | "dataset" | "metric" | "dashboard"
"collection" | "card" | "dataset" | "metric" | "dashboard" | "image"
>;
// we can enforce type safety at the boundary of a collection-only picker with this type

View File

@@ -41,6 +41,7 @@ const canSelectItem = (
item != null &&
(item.model === "card" ||
item.model === "dataset" ||
item.model === "image" ||
item.model === "metric")
);
};
@@ -78,11 +79,11 @@ export const QuestionPickerModal = ({
(item: QuestionPickerItem) => {
if (options.hasConfirmButtons) {
setSelectedItem(item);
} else if (canSelectItem(item)) {
handleOnChange(item);
} else if (canSelectItem(item) || models.includes("image")) {
handleOnChange(item as any);
}
},
[handleOnChange, options],
[handleOnChange, options, models],
);
const handleConfirm = () => {
@@ -94,6 +95,7 @@ export const QuestionPickerModal = ({
const [modelsPath, setModelsPath] = useState<QuestionPickerStatePath>();
const [metricsPath, setMetricsPath] = useState<QuestionPickerStatePath>();
const [questionsPath, setQuestionsPath] = useState<QuestionPickerStatePath>();
const [imagesPath, setImagesPath] = useState<QuestionPickerStatePath>();
const tabs: EntityPickerTab<
QuestionPickerItem["id"],
@@ -157,6 +159,25 @@ export const QuestionPickerModal = ({
/>
),
},
{
id: "images-tab",
displayName: t`Images`,
models: ["image" as const],
folderModels: ["collection" as const],
icon: "snail",
render: ({ onItemSelect }) => (
<QuestionPicker
initialValue={value}
models={["image"]}
options={options}
path={imagesPath}
onInit={onItemSelect}
onItemSelect={onItemSelect}
onPathChange={setImagesPath}
shouldDisableItem={shouldDisableItem}
/>
),
},
];
const filteredTabs = tabs.filter((tab) =>

View File

@@ -16,7 +16,7 @@ import type {
export type QuestionPickerModel = Extract<
CollectionPickerItem["model"],
"card" | "dataset" | "metric" | "collection"
"card" | "dataset" | "metric" | "collection" | "image"
>;
export type QuestionPickerValueModel = Extract<
CollectionPickerItem["model"],

View File

@@ -1,4 +1,5 @@
import { isEmail } from "metabase/lib/email";
import { Avatar as MantineAvatar } from "metabase/ui";
import type { AvatarProps } from "./UserAvatar.styled";
import { Avatar as StyledAvatar } from "./UserAvatar.styled";
@@ -27,7 +28,13 @@ export default function UserAvatar({
user,
...props
}: UserAvatarProps | GroupProps) {
return <StyledAvatar {...props}>{userInitials(user) || "?"}</StyledAvatar>;
const imageUrl = user.profile_image_url;
const initials = userInitials(user) || "?";
return (
<MantineAvatar {...props} src={imageUrl}>
{initials}
</MantineAvatar>
);
}
export function Avatar({ children, ...props }: { children: string }) {

View File

@@ -77,6 +77,9 @@ import { getExistingDashCards } from "./utils";
export type NewDashCardOpts = {
dashId: DashboardId;
tabId: DashboardTabId | null;
markdownContent?: string;
size_x?: number;
size_y?: number;
};
export type AddDashCardOpts = NewDashCardOpts & {
@@ -205,13 +208,18 @@ export const addHeadingDashCardToDashboard =
};
export const addMarkdownDashCardToDashboard =
({ dashId, tabId }: NewDashCardOpts) =>
({ dashId, tabId, markdownContent, size_x, size_y }: NewDashCardOpts) =>
(dispatch: Dispatch) => {
trackCardCreated("text", dashId);
const card = createVirtualCard("text");
const dashcardOverrides = {
card,
visualization_settings: { virtual_card: card },
visualization_settings: {
virtual_card: card,
text: markdownContent,
},
size_x,
size_y,
};
dispatch(addDashCardToDashboard({ dashId, tabId, dashcardOverrides }));
};

View File

@@ -7,8 +7,13 @@ import type { CardId } from "metabase-types/api";
import { QuestionPicker } from "../QuestionPicker";
export function AddCardSidebar() {
const { dashboard, selectedTabId, addCardToDashboard, closeSidebar } =
useDashboardContext();
const {
dashboard,
selectedTabId,
addCardToDashboard,
closeSidebar,
addMarkdownDashCardToDashboard,
} = useDashboardContext();
const handleAddCard = useCallback(
(cardId: CardId) => {
@@ -23,9 +28,27 @@ export function AddCardSidebar() {
[addCardToDashboard, dashboard, selectedTabId],
);
const handleAddImage = useCallback(
(imageId: CardId) => {
if (dashboard) {
addMarkdownDashCardToDashboard({
dashId: dashboard.id,
tabId: selectedTabId,
markdownContent: `![Image](/api/images/${imageId}/contents)`,
size_x: 6,
size_y: 6,
});
}
},
[addMarkdownDashCardToDashboard, dashboard, selectedTabId],
);
return (
<Sidebar data-testid="add-card-sidebar">
<QuestionPicker onSelect={handleAddCard} onClose={closeSidebar} />
<QuestionPicker
onSelect={handleAddCard}
onSelectImage={handleAddImage}
onClose={closeSidebar}
/>
</Sidebar>
);
}

View File

@@ -31,16 +31,20 @@ interface QuestionListProps {
searchText: string;
collectionId: CollectionId;
onSelect: BaseSelectListItemProps["onSelect"];
onSelectImage?: (imageId: number) => void;
hasCollections: boolean;
showOnlyPublicCollections: boolean;
showImages?: boolean;
}
export function QuestionList({
searchText,
collectionId,
onSelect,
onSelectImage,
hasCollections,
showOnlyPublicCollections,
showImages,
}: QuestionListProps) {
const [queryOffset, setQueryOffset] = useState(0);
const { handleNextPage, handlePreviousPage, page, setPage } = usePagination();
@@ -69,6 +73,10 @@ export function QuestionList({
handlePreviousPage();
};
const models = showImages
? (["card", "dataset", "image"] as const)
: (["card", "dataset"] as const);
const {
data: searchData,
error: searchError,
@@ -81,8 +89,8 @@ export function QuestionList({
filter_items_in_personal_collection: "exclude" as const,
}),
models: isEmbeddingSdk() // FIXME(sdk): remove this logic when v51 is released
? ["card", "dataset"] // ignore "metric" as SDK is used with v50 (or below) now, where we don't have this entity type
: ["card", "dataset", "metric"],
? [...models] // ignore "metric" as SDK is used with v50 (or below) now, where we don't have this entity type
: [...models, "metric"],
offset: queryOffset,
limit: DEFAULT_SEARCH_LIMIT,
}
@@ -97,8 +105,8 @@ export function QuestionList({
? {
id: collectionId,
models: isEmbeddingSdk() // FIXME(sdk): remove this logic when v51 is released
? ["card", "dataset"] // ignore "metric" as SDK is used with v50 (or below) now, where we don't have this entity type
: ["card", "dataset", "metric"],
? [...models] // ignore "metric" as SDK is used with v50 (or below) now, where we don't have this entity type
: [...models, "metric"],
offset: queryOffset,
limit: DEFAULT_SEARCH_LIMIT,
}
@@ -143,33 +151,35 @@ export function QuestionList({
label: S.QuestionListItemLabel,
}}
className={S.QuestionListItem}
name={item.getName()}
name={item.model === "image" ? item.name : item.getName()}
icon={{
name: item.getIcon().name,
name: item.model === "image" ? "snail" : item.getIcon().name,
size: item.model === "dataset" ? 18 : 16,
className: S.QuestionListItemIcon,
}}
onSelect={onSelect}
onSelect={item.model === "image" ? onSelectImage : onSelect}
rightIcon={PLUGIN_MODERATION.getStatusIcon(
item.moderated_status ?? undefined,
)}
/>
<Tooltip label={t`Visualize another way`}>
<ActionIcon
className={S.VisualizerButton}
size="41px"
aria-label={t`Visualize another way`}
onClick={() => {
trackSimpleEvent({
event: "visualize_another_way_clicked",
triggered_from: "question-list",
});
setVisualizerModalCardId(Number(item.id));
}}
>
<Icon name="lineandbar" />
</ActionIcon>
</Tooltip>
{item.model !== "image" ? (
<Tooltip label={t`Visualize another way`}>
<ActionIcon
className={S.VisualizerButton}
size="41px"
aria-label={t`Visualize another way`}
onClick={() => {
trackSimpleEvent({
event: "visualize_another_way_clicked",
triggered_from: "question-list",
});
setVisualizerModalCardId(Number(item.id));
}}
>
<Icon name="lineandbar" />
</ActionIcon>
</Tooltip>
) : null}
</Flex>
))}
</SelectList>

View File

@@ -27,12 +27,14 @@ import { addDashboardQuestion } from "./actions";
interface QuestionPickerInnerProps {
onSelect: BaseSelectListItemProps["onSelect"];
onSelectImage?: (imageId: number) => void;
collectionsById: Record<CollectionId, Collection>;
getCollectionIcon: (collection: Collection) => IconProps;
}
function QuestionPickerInner({
onSelect,
onSelectImage,
collectionsById,
getCollectionIcon,
}: QuestionPickerInnerProps) {
@@ -150,7 +152,9 @@ function QuestionPickerInner({
searchText={debouncedSearchText}
collectionId={currentCollectionId}
onSelect={onSelect}
onSelectImage={onSelectImage}
showOnlyPublicCollections={showOnlyPublicCollections}
showImages
/>
</div>
);

View File

@@ -1,5 +1,5 @@
import cx from "classnames";
import { useState } from "react";
import CS from "metabase/css/core/index.css";
import { useSelector } from "metabase/lib/redux";
import { ViewFooterDownloadWidget } from "metabase/query_builder/components/view/ViewFooter/ViewFooterDownloadWidget";
@@ -8,18 +8,21 @@ import {
getIsTimeseries,
} from "metabase/query_builder/selectors";
import { getIsObjectDetail } from "metabase/query_builder/selectors/mode";
import { Group } from "metabase/ui";
import { Group, Timeline, Image, Button, Icon, Text, Modal } from "metabase/ui";
import { ExecutionTime } from "../ExecutionTime";
import { QuestionLastUpdated } from "../QuestionLastUpdated/QuestionLastUpdated";
import QuestionRowCount from "../QuestionRowCount";
import QuestionTimelineWidget from "../QuestionTimelineWidget";
import S from "./RightViewFooterButtonGroup.module.css";
import { useGetCardSnapshotsQuery } from "metabase/api";
import { Sidesheet } from "metabase/common/components/Sidesheet";
import dayjs from "dayjs";
export const RightViewFooterButtonGroup = () => {
const isTimeseries = useSelector(getIsTimeseries);
const result = useSelector(getFirstQueryResult);
const card = useSelector((state) => state.qb.card);
const isObjectDetail = useSelector(getIsObjectDetail);
return (
@@ -42,7 +45,64 @@ export const RightViewFooterButtonGroup = () => {
{QuestionTimelineWidget.shouldRender({ isTimeseries }) && (
<QuestionTimelineWidget className={cx(CS.hide, CS.smShow)} />
)}
<SnapshotTimeline cardId={card?.id} />
</Group>
</Group>
);
};
const SnapshotTimeline = ({ cardId }: { cardId?: number | null }) => {
const { data: snapshots } = useGetCardSnapshotsQuery(
{ cardId: cardId ?? 0 },
{ skip: !cardId },
);
const [opened, setOpened] = useState(false);
if (!snapshots || snapshots.length === 0) {
return null;
}
return (
<>
<Button variant="subtle" onClick={() => setOpened((o) => !o)}>
<Icon name="snail" />
</Button>
<SnapshotSidebar snapshots={snapshots} isOpen={opened} onClose={() => setOpened(false)} />
</>
);
}
const SnapshotSidebar = ({ snapshots, onClose, isOpen }: { snapshots: any[]; onClose: () => void; isOpen: boolean }) => {
const [detailOpen, setDetailOpen] = useState<string | null>(null);
return (
<Sidesheet title="Snapshots" onClose={onClose} isOpen={isOpen} size="sm">
<Timeline bulletSize={24} lineWidth={2} active={99}>
{snapshots?.map((snapshot) => (
<Timeline.Item
key={snapshot.id}
title={<Text fw="bold" mb="md">{dayjs(snapshot.created_at).fromNow()}</Text>}
bullet={<Icon name="snail" />}
>
<Image
onClick={() => setDetailOpen(snapshot.url)}
src={snapshot.url}
style={{ maxWidth: '200px', maxHeight: '150px' }}
/>
</Timeline.Item>
))}
</Timeline>
<ImageDetails url={detailOpen} onClose={() => setDetailOpen(null)} />
</Sidesheet>
)
}
const ImageDetails = ({ url, onClose }: { url?: string | null; onClose: ()=> void }) => {
return (
<Modal opened={!!url} onClose={onClose} withCloseButton={false}>
<Image src={url} style={{ maxWidth: '100%', maxHeight: '80vh' }} />
</Modal>
);
};

View File

@@ -34,6 +34,9 @@ import type { DatasetEditorTab, QueryBuilderMode } from "metabase-types/store";
import QuestionActionsS from "./QuestionActions.module.css";
import { QuestionAlertsMenuItem } from "./QuestionAlertsMenuItem";
import { useSnapshotCardMutation } from "metabase/api";
import { useToast } from "metabase/common/hooks";
import { card } from "metabase/query_builder/reducers-typed";
const ADD_TO_DASH_TESTID = "add-to-dashboard-button";
const MOVE_TESTID = "move-button";
@@ -117,7 +120,30 @@ export const QuestionMoreActionsMenu = ({
? t`Move, duplicate, and more…`
: t`Move, trash, and more…`;
const [snapshotCard] = useSnapshotCardMutation();
const [sendToast] = useToast();
const handleSnapshot = async () => {
try {
sendToast({ message: c("toast").t`Taking snapshot...`, type: "info" });
const response = await snapshotCard({ cardId: question.id() }).unwrap();
if (response?.image?.url) {
sendToast({ message: c("toast").t`Snapshotted ${question.displayName()}`, type: "success" });
}
} catch (e) {
console.error("Failed to snapshot card", e);
}
};
const menuItems = [
<Menu.Item
onClick={handleSnapshot}
leftSection={<Icon name="snail" />}
key="snapshot"
>
{t`Snapshot this question`}
</Menu.Item>,
(isStandaloneQuestion || isMetric) && (
<Menu.Item
key="add_to_dash"

View File

@@ -18,6 +18,7 @@ import {
} from "metabase/browse";
import { ArchiveCollectionModal } from "metabase/collections/components/ArchiveCollectionModal";
import CollectionLanding from "metabase/collections/components/CollectionLanding";
import { ImagePage } from "metabase/collections/components/ImagePage";
import { MoveCollectionModal } from "metabase/collections/components/MoveCollectionModal";
import { TrashCollectionLanding } from "metabase/collections/components/TrashCollectionLanding";
import { Unauthorized } from "metabase/common/components/ErrorPages";
@@ -231,6 +232,8 @@ export const getRoutes = (store) => {
<ModalRoute path="archive" modal={ArchiveDashboardModalConnected} />
</Route>
<Route path="image/:id" component={ImagePage} />
<Route path="/question">
<Route
path="/question/entity/:entity_id(**)"

View File

@@ -1966,6 +1966,227 @@ databaseChangeLog:
SET dataset_query = legacy_query
WHERE legacy_query IS NOT NULL;
- changeSet:
id: v57.2025-10-15T09:30:00
author: camsaul
comment: >-
Create the image table.
changes:
- createTable:
tableName: image
remarks: Uploaded images.
columns:
- column:
name: id
type: int
autoIncrement: true
constraints:
primaryKey: true
nullable: false
- column:
remarks: >-
URL where the image can be served from
name: url
type: ${text.type}
constraints:
nullable: false
- column:
remarks: Display name of the image
name: title
type: ${text.type}
constraints:
nullable: true
- column:
remarks: The timestamp of when the image was uploaded
name: created_at
type: ${timestamp_type}
defaultValueComputed: current_timestamp
constraints:
nullable: false
# I think the title of an image is probably the only thing we want to support updating
- column:
remarks: The timestamp of when the image was updated
name: updated_at
type: ${timestamp_type}
defaultValueComputed: current_timestamp
constraints:
nullable: false
- changeSet:
id: v57.2025-10-15T09:30:01
author: camsaul
comment: >-
Create the collection_image table.
changes:
- createTable:
tableName: collection_image
remarks: MtM table for Images living in Collections.
columns:
- column:
name: id
type: int
autoIncrement: true
constraints:
primaryKey: true
nullable: false
- column:
name: collection_id
type: int
constraints:
nullable: false
- column:
name: image_id
type: int
constraints:
nullable: false
- column:
remarks: >-
Position of an image in a Collection
name: collection_position
type: int
constraints:
nullable: false
default: 0
- changeSet:
id: v57.2025-10-15T09:30:02
author: camsaul
comment: >-
Add FK from collection_image.collection_id > collection.id
changes:
- addForeignKeyConstraint:
baseTableName: collection_image
baseColumnNames: collection_id
referencedTableName: collection
referencedColumnNames: id
constraintName: fk_collection_image_collection_id
onDelete: CASCADE
- changeSet:
id: v57.2025-10-15T09:30:03
author: camsaul
comment: >-
Add FK from collection_image.image_id > image.id
changes:
- addForeignKeyConstraint:
baseTableName: collection_image
baseColumnNames: image_id
referencedTableName: image
referencedColumnNames: id
constraintName: fk_collection_image_image_id
onDelete: CASCADE
- changeSet:
id: v57.2025-10-15T09:30:04
author: camsaul
comment: >-
Create the card_snapshot endpoint.
changes:
- createTable:
tableName: card_snapshot
remarks: MtM table for CollectionImages that are snapshots of Cards.
columns:
- column:
name: id
type: int
autoIncrement: true
constraints:
primaryKey: true
nullable: false
- column:
name: collection_image_id
type: int
constraints:
nullable: false
- column:
name: card_id
type: int
constraints:
nullable: false
- changeSet:
id: v57.2025-10-15T09:30:05
author: camsaul
comment: >-
Add FK from card_snapshot.card_id > card.id
changes:
- addForeignKeyConstraint:
baseTableName: card_snapshot
baseColumnNames: card_id
referencedTableName: report_card
referencedColumnNames: id
constraintName: fk_card_snapshot_card_id
onDelete: CASCADE
- changeSet:
id: v57.2025-10-15T09:30:06
author: camsaul
comment: >-
Add FK from card_snapshot.collection_image_id > collection_image.id
changes:
- addForeignKeyConstraint:
baseTableName: card_snapshot
baseColumnNames: collection_image_id
referencedTableName: collection_image
referencedColumnNames: id
constraintName: fk_card_snapshot_collection_image_id
onDelete: CASCADE
- changeSet:
id: v57.2025-10-15T09:30:07
author: camsaul
comment: >-
Add content_type to image
changes:
- addColumn:
tableName: image
columns:
- column:
name: content_type
type: ${text.type}
remarks: >-
Content type the image was uploaded with
- changeSet:
id: v57.2025-10-15T09:30:08
author: camsaul
comment: >-
Add profile_image_id to user
changes:
- addColumn:
tableName: core_user
columns:
- column:
name: profile_image_id
type: int
remarks: >-
FK from User to Image; profile image for a User
- changeSet:
id: v57.2025-10-15T09:30:09
author: camsaul
comment: >-
Add user.profile_image_id FK constraint
changes:
- addForeignKeyConstraint:
baseTableName: core_user
baseColumnNames: profile_image_id
referencedTableName: image
referencedColumnNames: id
constraintName: fk_user_profile_image_id
onDelete: CASCADE
- changeSet:
id: v57.2025-10-15T09:30:10
author: wotbrew
comment: >-
Make collection_position column nullable in collection_image table
changes:
- dropNotNullConstraint:
tableName: collection_image
columnName: collection_position
columnDataType: int
# >>>>>>>>>> DO NOT ADD NEW MIGRATIONS BELOW THIS LINE! ADD THEM ABOVE <<<<<<<<<<

View File

@@ -583,4 +583,5 @@
"pulse-card" {:db-model :model/PulseCard :alias :pulse-card}
"pulse-channel" {:db-model :model/PulseChannel :alias :pulse-channel}
"timeline" {:db-model :model/Timeline :alias :timeline}
"user" {:db-model :model/User :alias :user}})
"user" {:db-model :model/User :alias :user}
"image" {:db-model :model/Image :alias :image}})

View File

@@ -23,6 +23,7 @@
[metabase.embedding.api]
[metabase.geojson.api]
[metabase.glossary.api]
[metabase.images.api]
[metabase.indexed-entities.api]
[metabase.logger.api]
[metabase.login-history.api]
@@ -71,6 +72,7 @@
metabase.eid-translation.api/keep-me
metabase.geojson.api/keep-me
metabase.glossary.api/keep-me
metabase.images.api/keep-me
metabase.indexed-entities.api/keep-me
metabase.logger.api/keep-me
metabase.login-history.api/keep-me
@@ -153,6 +155,7 @@
"/geojson" 'metabase.geojson.api
"/glossary" (+auth 'metabase.glossary.api)
"/google" (+auth metabase.sso.api/google-auth-routes)
"/images" (+auth 'metabase.images.api)
"/ldap" (+auth metabase.sso.api/ldap-routes)
"/logger" (+auth 'metabase.logger.api)
"/login-history" (+auth 'metabase.login-history.api)

View File

@@ -232,6 +232,7 @@
"dataset" ; Model. TODO : update this
"document"
"metric"
"image"
"collection"
"dashboard"
"pulse" ; I think the only kinds of Pulses we still have are Alerts?
@@ -337,6 +338,25 @@
(update :archived_directly api/bit->boolean)))
:can_write :can_restore :can_delete))
(defmethod collection-children-query :image
[_ collection {:keys [pinned-state]}]
(-> {:select [:i.id
[:i.title :name]
:ci.collection_id
:ci.collection_position
[false :archived]
[false :archived_directly]
[nil :last_edit_user]
[nil :last_edit_email]
[nil :last_edit_first_name]
[nil :last_edit_last_name]
[:i.created_at :last_edit_timestamp]
[(h2x/literal "image") :model]]
:from [[:collection_image :ci]]
:join [[:image :i] [:= :i.id :ci.image_id]]
:where [:= :ci.collection_id (:id collection)]}
(sql.helpers/where (poison-when-pinned-clause pinned-state))))
(defmethod collection-children-query :document
[_ collection {:keys [archived? pinned-state]}]
(-> {:select [:document.id
@@ -757,6 +777,7 @@
:metric :model/Card
:dashboard :model/Dashboard
:document :model/Document
:image :model/CollectionImage
:pulse :model/Pulse
:snippet :model/NativeQuerySnippet
:timeline :model/Timeline))
@@ -945,7 +966,10 @@
"Fetch a sequence of 'child' objects belonging to a Collection, filtered using `options`."
[{collection-namespace :namespace, :as collection} :- collection/CollectionWithLocationAndIDOrRoot
{:keys [models], :as options} :- CollectionChildrenOptions]
(let [valid-models (for [model-kw (cond-> [:collection :dataset :metric :card :dashboard :pulse :snippet :timeline]
(let [valid-models (for [model-kw (cond-> [:collection :dataset :metric :card :dashboard
;; hackathon 2025
:image
:pulse :snippet :timeline]
(premium-features/enable-documents?) (conj :document))
;; only fetch models that are specified by the `model` param; or everything if it's empty
:when (or (empty? models) (contains? models model-kw))

View File

@@ -27,6 +27,7 @@
[metabase.embedding.init]
[metabase.events.init]
[metabase.geojson.init]
[metabase.images.init]
[metabase.indexed-entities.init]
[metabase.lib-be.init]
[metabase.lib.init]

151
src/metabase/images/api.clj Normal file
View File

@@ -0,0 +1,151 @@
(ns metabase.images.api
(:require
[buddy.core.codecs :as buddy-codecs]
[buddy.core.hash :as buddy-hash]
[clojure.java.io :as io]
[clojure.string :as str]
[metabase.api.common :as api]
[metabase.api.macros :as api.macros]
[metabase.channel.render.core :as channel.render]
[metabase.images.models.image :as models.image]
[metabase.images.s3 :as images.s3]
[metabase.images.schema :as images.schema]
[metabase.images.settings :as images.settings]
[metabase.lib.schema.id :as lib.schema.id]
[metabase.query-processor :as qp]
[metabase.util.log :as log]
[metabase.util.malli :as mu]
[metabase.util.malli.schema :as ms]
[toucan2.core :as t2]))
(set! *warn-on-reflection* true)
(api.macros/defendpoint :get "/:id" :- [:merge
::images.schema/image
[:map
[:card_id [:maybe ::lib.schema.id/card]]]]
"Metadata about an image."
[{image-id :id, :as _route-params} :- [:map
[:id ::images.schema/id]]]
(-> (api/check-404 (t2/select-one :model/Image
{:select [:image/* :snapshot/card_id]
:from [[(t2/table-name :model/Image) :image]]
:left-join [[(t2/table-name :model/CollectionImage) :collection_image]
[:= :collection_image/image_id :image/id]
[(t2/table-name :model/CardSnapshot) :snapshot]
[:= :snapshot/collection_image_id :collection_image/id]
[(t2/table-name :model/Card) :card]
[:= :card/id :snapshot/card_id]]
:where [:= :image/id image-id]
:limit 1}))
(assoc :url (models.image/image-id->contents-url image-id))))
(api.macros/defendpoint :get "/:id/contents"
"This is our endpoint for serving the contents of an image that we have stored locally somewhere."
[{image-id :id, :as _route-params} :- [:map
[:id ::images.schema/id]]]
(let [{:keys [^String url], content-type :content_type} (api/check-404
(t2/select-one [:model/Image :url :content_type] image-id))]
{:status 200
:body (if (str/starts-with? url "s3")
(images.s3/fetch-image url)
(io/input-stream (io/as-url url)))
:headers {"Content-Type" content-type}}))
(def imgdir (io/file "/tmp/hack2025"))
(defn name-image [image-content suffix]
(let [filehash (buddy-hash/sha256 image-content)]
(format "image-%s-%s" (buddy-codecs/bytes->hex filehash) suffix)))
;; curl -X POST http://localhost:3000/api/images -H "x-metabase-session: $(cat session.txt)" -F "file=@$(pwd)/duck.jpeg"
(api.macros/defendpoint :post "/"
"Upload an image."
{:multipart true}
[_route-params
{:keys [user-id collection-id]} :- [:and
[:map
[:user-id {:optional true} [:maybe ::lib.schema.id/user]]
[:collection-id {:optional true} [:maybe ::lib.schema.id/collection]]]
[:fn
{:error/message "Either :user-id or :collection-id is required."}
(some-fn :user-id :collection-id)]
;; todo not sure why this fails
#_[:fn
{:error/message "You cannot specify both :user-id and :collection-id"}
(not (every-pred :user-id :collection-id))]]
_body
{{:strs [file]} :multipart-params, :as _request}
:- [:map
[:multipart-params
[:map
["file" (mu/with ms/File {:description "image data"})]]]]]
(let [{user-filename :filename :keys [tempfile size]} file]
(try
(let [filename (name-image tempfile user-filename)
localfile (io/file imgdir filename)]
(log/infof "Got a cool file with %d bytes with name %s" size filename)
(io/make-parents localfile)
(io/copy tempfile localfile)
(let [url (if (images.settings/image-upload-bucket)
(images.s3/upload-image! localfile)
(str (io/as-url localfile)))
image (t2/insert-returning-instance! :model/Image {:url url, :title user-filename, :content_type (:content-type file)})]
(when user-id
(t2/update! :model/User user-id {:profile_image_id (:id image)}))
(when collection-id
(t2/insert!
:model/CollectionImage
{:image_id (:id image)
:collection_id collection-id
:collection_position nil}))
(assoc image :url (models.image/image-id->contents-url (:id image)))))
(finally
(io/delete-file tempfile true)))))
(api.macros/defendpoint :post "/card/:card-id/snapshot"
"Snapshot a Card. This will create a new CollectionImage."
[{:keys [card-id]} :- [:map
[:card-id ::lib.schema.id/card]]]
(let [{:keys [dataset_query result_metadata], card-type :type, :as card} (api/check-404 (t2/select-one :model/Card :id card-id))
query-results (qp/process-query
(cond-> dataset_query
(= card-type :model)
(assoc-in [:info :metadata/model-metadata] result_metadata)))
png-bytes (channel.render/render-pulse-card-to-png (channel.render/defaulted-timezone card)
card
query-results
1000)
filename (name-image png-bytes (format "card_%d.png" card-id))
localfile (io/file imgdir filename)
url (str (io/as-url localfile))]
(io/make-parents localfile)
(io/copy png-bytes localfile)
(let [image (t2/insert-returning-instance! :model/Image {:url url
:title (format "%s (%d)" (:name card) card-id)
:content_type "image/png"})
collection-image (t2/insert-returning-instance! :model/CollectionImage {:image_id (:id image)
:collection_id (:collection_id card)})
card-snapshot (t2/insert-returning-instance! :model/CardSnapshot {:card_id card-id
:collection_image_id (:id collection-image)})]
{:image (assoc image :url (models.image/image-id->contents-url (:id image)))
:collection_image collection-image
:card_snapshot card-snapshot})))
(api.macros/defendpoint :get "/card/:card-id/snapshots"
"List all snapshots of a Card."
[{:keys [card-id]} :- [:map
[:card-id ::lib.schema.id/card]]]
(let [images (t2/select :model/Image
{:select [:image/*]
:from [[(t2/table-name :model/Image) :image]]
:left-join [[(t2/table-name :model/CollectionImage) :collection_image]
[:= :collection_image/image_id :image/id]
[(t2/table-name :model/CardSnapshot) :snapshot]
[:= :snapshot/collection_image_id :collection_image/id]
[(t2/table-name :model/Card) :card]
[:= :card/id :snapshot/card_id]]
:where [:= :card/id card-id]
:order-by [[:image/created_at :desc]]})]
(for [image images]
(assoc image :url (models.image/image-id->contents-url (:id image))))))

View File

@@ -0,0 +1,5 @@
(ns metabase.images.init
(:require
;; for the Toucan hydration methods
[metabase.images.models.image]
[metabase.images.settings]))

View File

@@ -0,0 +1,10 @@
(ns metabase.images.models.card-snapshot
(:require
[methodical.core :as methodical]
[toucan2.core :as t2]))
(derive :model/CardSnapshot :metabase/model)
(methodical/defmethod t2/table-name :model/CardSnapshot
[_model]
"card_snapshot")

View File

@@ -0,0 +1,10 @@
(ns metabase.images.models.collection-image
(:require
[methodical.core :as methodical]
[toucan2.core :as t2]))
(derive :model/CollectionImage :metabase/model)
(methodical/defmethod t2/table-name :model/CollectionImage
[_model]
"collection_image")

View File

@@ -0,0 +1,48 @@
(ns metabase.images.models.image
(:require
[metabase.images.schema :as images.schema]
[metabase.search.spec :as search.spec]
[metabase.system.core :as system]
[metabase.util.malli :as mu]
[methodical.core :as methodical]
[toucan2.core :as t2]))
(derive :model/Image :metabase/model)
(methodical/defmethod t2/table-name :model/Image
[_model]
"image")
(mu/defn image-id->contents-url :- :string
"Given an image ID return the URL the frontend can use to fetch the image contents."
[image-id :- ::images.schema/id]
(format "%s/api/images/%d/contents" (system/site-url) image-id))
(methodical/defmethod t2/batched-hydrate [:model/User :profile_image_url]
[_model _k users]
(let [image-ids (into #{} (keep :profile_image_id) users)
id->image-url (when (seq image-ids)
(t2/select-pk->fn (comp image-id->contents-url :id)
[:model/Image :id :url]
:id [:in image-ids]))]
(for [user users]
(assoc user :profile_image_url (get id->image-url (:profile_image_id user))))))
(search.spec/define-spec "image"
{:model :model/Image
:attrs {:archived false
:name :title
:collection-id false
:created-at :created_at}
:search-terms [:title]
:render-terms {:name :title
:document-name :title
:document-id :id
:collection-position false}})
(comment
(into []
(comp
(filter #(= "image" (:model %)))
(take 10))
(metabase.search.ingestion/searchable-documents)))

View File

@@ -0,0 +1,37 @@
(ns metabase.images.s3
(:require
[metabase.images.settings :as images.settings])
(:import
(software.amazon.awssdk.auth.credentials AwsBasicCredentials StaticCredentialsProvider)
(software.amazon.awssdk.core ResponseBytes)
(software.amazon.awssdk.services.s3 S3Client)
(software.amazon.awssdk.services.s3.model GetObjectRequest PutObjectRequest)))
(set! *warn-on-reflection* true)
(defn- client ^S3Client []
(.build (doto (S3Client/builder)
(.region software.amazon.awssdk.regions.Region/US_EAST_1)
(.credentialsProvider (StaticCredentialsProvider/create
(AwsBasicCredentials/create
(images.settings/image-upload-aws-access-key-id)
(images.settings/image-upload-aws-secret-access-key)))))))
(defn fetch-image ^bytes [s3-url]
(let [[_ ^String bucket ^String object-key] (re-find #"^s3://([^/]+)/(.+)$" s3-url)
^GetObjectRequest request (.build (doto (GetObjectRequest/builder)
(.key object-key)
(.bucket bucket)))
^ResponseBytes response (.getObjectAsBytes (client) request)]
(.asByteArray response)))
(defn upload-image!
"Returns the AWS S3 URL."
^String [^java.io.File file]
(let [^java.nio.file.Path path (.toPath file)
object-key (str (.getFileName path))
^PutObjectRequest request (.build (doto (PutObjectRequest/builder)
(.bucket (images.settings/image-upload-bucket))
(.key object-key)))]
(.putObject (client) request path)
(format "s3://%s/%s" (images.settings/image-upload-bucket) object-key)))

View File

@@ -0,0 +1,14 @@
(ns metabase.images.schema
(:require
[metabase.lib.schema.literal :as lib.schema.literal]
[metabase.util.malli.registry :as mr]))
(mr/def ::id
pos-int?)
(mr/def ::image
[:map
[:id ::id]
[:url :string]
[:title [:maybe :string]]
[:created_at {:optional true} ::lib.schema.literal/datetime]])

View File

@@ -0,0 +1,22 @@
(ns metabase.images.settings
(:require
[metabase.settings.core :as settings]
[metabase.util.i18n :as i18n]))
(settings/defsetting image-upload-bucket
(i18n/deferred-tru "S3 bucket to upload images to.")
:type :string
:visibility :admin
:encryption :no)
(settings/defsetting image-upload-aws-access-key-id
(i18n/deferred-tru "AWS access key ID for image uploads.")
:type :string
:visibility :admin
:encryption :no)
(settings/defsetting image-upload-aws-secret-access-key
(i18n/deferred-tru "AWS Secret Access Key for image uploads.")
:type :string
:visibility :admin
:encryption :when-encryption-key-set)

View File

@@ -20,11 +20,13 @@
:model/CacheConfig metabase.cache.models.cache-config
:model/Card metabase.queries.models.card
:model/CardBookmark metabase.bookmarks.models.bookmark
:model/CardSnapshot metabase.images.models.card-snapshot
:model/Channel metabase.channel.models.channel
:model/ChannelTemplate metabase.channel.models.channel
:model/CloudMigration metabase.cloud-migration.models.cloud-migration
:model/Collection metabase.collections.models.collection
:model/CollectionBookmark metabase.bookmarks.models.bookmark
:model/CollectionImage metabase.images.models.collection-image
:model/CollectionPermissionGraphRevision metabase.permissions.models.collection-permission-graph-revision
:model/Comment metabase-enterprise.comments.models.comment
:model/CommentReaction metabase-enterprise.comments.models.comment-reaction
@@ -48,6 +50,7 @@
:model/Glossary metabase.glossary.models.glossary
:model/Sandbox metabase-enterprise.sandbox.models.sandbox
:model/HTTPAction metabase.actions.models
:model/Image metabase.images.models.image
:model/ImplicitAction metabase.actions.models
:model/LoginHistory metabase.login-history.models.login-history
:model/Metabot metabase-enterprise.metabot-v3.models.metabot
@@ -123,7 +126,10 @@
;; been required
(not (isa? x :metabase/model)))
;; [[classloader/require]] for thread safety
(classloader/require (model->namespace x)))
(try
(classloader/require (model->namespace x))
(catch Throwable e
(throw (ex-info "Error resolving model" {:model x, :namespace (model->namespace x)})))))
x)
(methodical/defmethod t2.model/resolve-model :around clojure.lang.Symbol

View File

@@ -42,7 +42,9 @@
;; match requests that are js/css and have a cache-busting hex string
(re-matches #"^/app/dist/.+\.[a-f0-9]+\.(js|css)$" uri)
;; any resource that is named as a cache-busting hex string (e.g. fonts, images)
(re-matches #"^/app/dist/[a-f0-9]+.*$" uri))))
(re-matches #"^/app/dist/[a-f0-9]+.*$" uri)
;; image requests
(re-matches #"^/api/images/\d+/contents" uri))))
(defn https?
"True if the original request made by the frontend client (i.e., browser) was made over HTTPS.

View File

@@ -73,7 +73,7 @@
(def models-search-order
"The order of this list influences the order of the results: items earlier in the
list will be ranked higher."
(cond-> ["dashboard" "metric" "segment" "indexed-entity" "card" "dataset" "collection" "table" "action"]
(cond-> ["dashboard" "metric" "segment" "indexed-entity" "card" "dataset" "collection" "table" "action" "image"]
config/ee-available? (conj "document")
:always (conj "database")))

View File

@@ -13,7 +13,7 @@
(def search-models
"Set of search model string names. Sorted by order to index based on importance and amount of time to index"
(cond-> ["collection" "dashboard" "segment" "database" "action"]
(cond-> ["collection" "dashboard" "segment" "database" "action" "image"]
config/ee-available? (conj "document")
;; metric/card/dataset moved to the end because they take a long time due to computing has_temporal_dim etc.
;; table and indexed-entity moved to the end because there can be a large number of them

View File

@@ -201,26 +201,27 @@
clauses (user/filter-clauses status query group-id-clause include_deactivated
{:limit (request/limit)
:offset (request/offset)})]
{:data (cond-> (t2/select
(vec (cons :model/User (user-visible-columns)))
(sql.helpers/order-by clauses
[:%lower.first_name :asc]
[:%lower.last_name :asc]
[:id :asc]))
;; For admins also include the IDs of Users' Personal Collections
api/*is-superuser?*
(t2/hydrate :personal_collection_id)
{:data (-> (t2/select
(vec (cons :model/User (user-visible-columns)))
(sql.helpers/order-by clauses
[:%lower.first_name :asc]
[:%lower.last_name :asc]
[:id :asc]))
(cond-> #_user
;; For admins also include the IDs of Users' Personal Collections
api/*is-superuser?*
(t2/hydrate :personal_collection_id)
(or api/*is-superuser?*
api/*is-group-manager?*)
(t2/hydrate :group_ids)
;; if there is a group_id clause, make sure the list is deduped in case the same user is in multiple gropus
group-id-clause
distinct)
:total (-> (t2/query
(merge {:select [[[:count [:distinct :core_user.id]] :count]]
:from :core_user}
(filter-clauses-without-paging clauses)))
(or api/*is-superuser?*
api/*is-group-manager?*)
(t2/hydrate :group_ids)
;; if there is a group_id clause, make sure the list is deduped in case the same user is in multiple gropus
group-id-clause
distinct)
(t2/hydrate :profile_image_url))
:total (-> (t2/query (merge {:select [[[:count [:distinct :core_user.id]] :count]]
:from :core_user}
(filter-clauses-without-paging clauses)))
first
:count)
:limit (request/limit)
@@ -339,7 +340,7 @@
"Fetch the current `User`."
[]
(-> (api/check-404 @api/*current-user*)
(t2/hydrate :personal_collection_id :group_ids :is_installer :has_invited_second_user)
(t2/hydrate :personal_collection_id :group_ids :is_installer :has_invited_second_user :profile_image_url)
add-has-question-and-dashboard
add-first-login
maybe-add-advanced-permissions
@@ -355,7 +356,7 @@
(catch clojure.lang.ExceptionInfo _e
(perms/check-group-manager)))
(-> (api/check-404 (fetch-user :id id))
(t2/hydrate :user_group_memberships)
(t2/hydrate :user_group_memberships :profile_image_url)
add-structured-attributes))
;;; +----------------------------------------------------------------------------------------------------------------+

View File

@@ -188,7 +188,7 @@
(def ^:private default-user-columns
"Sequence of columns that are normally returned when fetching a User from the DB."
[:id :email :date_joined :first_name :last_name :last_login :is_superuser :is_qbnewb :tenant_id])
[:id :email :date_joined :first_name :last_name :last_login :is_superuser :is_qbnewb :tenant_id :profile_image_id])
(def admin-or-self-visible-columns
"Sequence of columns that we can/should return for admins fetching a list of all Users, or for the current user
@@ -199,7 +199,7 @@
"Sequence of columns that we will allow non-admin Users to see when fetching a list of Users. Why can non-admins see
other Users at all? I honestly would prefer they couldn't, but we need to give them a list of emails to power
Pulses."
[:id :email :first_name :last_name])
[:id :email :first_name :last_name :profile_image_url])
(def group-manager-visible-columns
"Sequence of columns Group Managers can see when fetching a list of Users.."

View File

@@ -0,0 +1,163 @@
(ns metabase.images.api-test
(:require
[clojure.java.io :as io]
[clojure.string :as str]
[clojure.test :refer :all]
[medley.core :as m]
[metabase.collections.models.collection :as collection]
[metabase.config.core :as config]
[metabase.images.models.image :as models.image]
[metabase.test :as mt]
[toucan2.core :as t2]))
(set! *warn-on-reflection* true)
(defn- test-image-url []
(str (.. (io/resource "frontend_client/app/assets/img/lightbulb.png")
toURI
toURL)))
(deftest ^:parallel metadata-test
(testing "GET /api/images/:id"
(mt/with-temp [:model/Image {image-id :id} {:url (test-image-url)}]
(is (=? {:id pos-int?
:url #"http://localhost:\d+/api/images/\d+/contents"}
(mt/user-http-request :crowberto :get 200 (format "images/%d" image-id)))))))
(deftest ^:parallel card-metadata-test
(testing "GET /api/images/:id"
(mt/with-temp [:model/Image {image-id :id} {:url (test-image-url)}
:model/Card {card-id :id} {}
:model/Collection {collection-id :id} {}
:model/CollectionImage {collection-image-id :id} {:collection_id collection-id
:image_id image-id}
:model/CardSnapshot {} {:card_id card-id
:collection_image_id collection-image-id}]
(is (=? {:id image-id
:card_id card-id
:url #"http://localhost:\d+/api/images/\d+/contents"}
(mt/user-http-request :crowberto :get 200 (format "images/%d" image-id)))))))
(deftest ^:parallel fetch-image-test
(testing "GET /api/images/:id/contents"
(mt/with-temp [:model/Image {image-id :id} {:url (test-image-url)}]
(let [response (mt/user-http-request-full-response :crowberto :get 200 (format "images/%d/contents" image-id))]
;; TODO -- this works IRL not sure why the test is failing
#_(is (=? {"Content-Type" "image/png"}
(:headers response)))
;; I think the input stream gets read in as a string by our test helpers?
(is (instance? String (:body response)))))))
;; TODO -- also the comments endpoint
(deftest ^:parallel user-profile-image-test
(t2/with-transaction [_conn nil {:rollback-only true}]
(let [response (mt/user-http-request :crowberto :post "images"
{:request-options {:headers {"content-type" "multipart/form-data"}}}
{:file (mt/file->bytes (io/file (java.net.URI. (test-image-url))))}
:user-id (mt/user->id :crowberto))]
(is (=? {:id pos-int?
:url #"http://localhost:\d+/api/images/\d+/contents"
:title string?
;; TODO -- fixme
;; :content_type "image/png"
}
response))
(is (= (:id response)
(t2/select-one-fn :profile_image_id :model/User (mt/user->id :crowberto))))
(testing "GET /api/user/:id"
(is (=? {:profile_image_id (:id response)
:profile_image_url string?}
(mt/user-http-request :crowberto :get (format "user/%d" (mt/user->id :crowberto))))))
(testing "GET /api/user/"
(is (=? {:profile_image_id (:id response)
:profile_image_url string?}
(m/find-first #(= (:id %) (mt/user->id :crowberto))
(:data (mt/user-http-request :crowberto :get "user"))))))
(testing "GET /api/current"
(is (=? {:profile_image_id (:id response)
:profile_image_url string?}
(mt/user-http-request :crowberto :get "user/current"))))
(when config/ee-available?
(testing "GET /api/ee/comment"
(mt/with-premium-features #{:documents}
(mt/with-temp [:model/Document {doc-id :id} {:name "New Document"
:creator_id (mt/user->id :crowberto)}]
(is (=? {:creator {:profile_image_id pos-int?
:profile_image_url #"http://localhost:\d+/api/images/\d+/contents"}}
(mt/user-http-request :crowberto :post 200 "ee/comment/"
{:target_type "document"
:target_id doc-id
:content ((requiring-resolve 'metabase-enterprise.comments.api-test/tiptap) [:p "New comment"])
:html "<p>New comment</p>"}))))))))))
(def test-image
(io/file "resources/frontend_client/app/assets/img/blue_check.png"))
(deftest ^:parallel post-user-id-image-test
(let [res (mt/user-http-request :crowberto :post "images"
{:request-options {:headers {"content-type" "multipart/form-data"}}}
{:file test-image}
:user-id (mt/user->id :crowberto))
id (:id res)]
(is (nat-int? id))
(is (=? {:id id
:title "blue_check.png"
:url (models.image/image-id->contents-url id)}
res))))
(deftest ^:parallel card-snapshots-test
(testing "POST /api/images/card/:card-id/snapshot"
(mt/with-temp [:model/Card card {:name "foo"
:collection_id (:id (collection/user->personal-collection (mt/user->id :crowberto)))
:dataset_query (mt/mbql-query :orders)}]
(let [res (mt/user-http-request :crowberto :post 200 (format "images/card/%d/snapshot" (:id card)))
{:keys [card_snapshot collection_image image]} res
{card-snapshot-id :id} card_snapshot
{collection-image-id :id} collection_image
{image-id :id} image]
(is (nat-int? card-snapshot-id))
(is (nat-int? collection-image-id))
(is (nat-int? image-id))
(is (=? {:card_id (:id card)
:collection_image_id collection-image-id}
card_snapshot))
(is (=? {:image_id image-id
:collection_id (:collection_id card)}
collection_image))
(is (=? {:title #(str/includes? % (:name card))} image)))))
(testing "GET /api/images/card/:card-id/snapshots"
(mt/with-temp [:model/Image {image-id :id} {:url (test-image-url)}
:model/Card {card-id :id} {}
:model/Collection {collection-id :id} {}
:model/CollectionImage {collection-image-id :id} {:collection_id collection-id
:image_id image-id}
:model/CardSnapshot {} {:card_id card-id
:collection_image_id collection-image-id}]
(is (=? [{:id image-id
:url #"http://localhost:\d+/api/images/\d+/contents"}]
(mt/user-http-request :crowberto :get 200 (format "images/card/%d/snapshots" card-id)))))))
(comment
(mt/user-http-request-full-response :crowberto :post "images"
{:request-options {:headers {"content-type" "multipart/form-data"}}}
{:file test-image}
:user-id (mt/user->id :crowberto))
(let [user-id (mt/user->id :crowberto)
collection-id (collection/user->personal-collection user-id)]
(mt/user-http-request-full-response :crowberto :post "images"
{:request-options {:headers {"content-type" "multipart/form-data"}}}
{:file test-image}
:collection-id (:id collection-id)))
(mt/with-temp [:model/Card card {:name "foo"
:collection_id (:id (collection/user->personal-collection (mt/user->id :crowberto)))
:dataset_query (mt/mbql-query :orders)}]
(def snapshot-post-res (mt/user-http-request-full-response :crowberto :post (format "images/card/%d/snapshot" (:id card))))
(def snapshot-list-res (mt/user-http-request-full-response :crowberto :get (format "images/card/%d/snapshots" (:id card)))))
snapshot-post-res
snapshot-list-res
#_())