Compare commits
92 Commits
python-run
...
hackathon/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0302b87ab2 | ||
|
|
d270ebe03a | ||
|
|
21fa308a4d | ||
|
|
86ef6368b7 | ||
|
|
2e79764162 | ||
|
|
eaeeac015f | ||
|
|
4f88b36741 | ||
|
|
f23cec6159 | ||
|
|
70db409d41 | ||
|
|
387986b33a | ||
|
|
ebeefc586b | ||
|
|
d2d6ff9a94 | ||
|
|
9ddd3f182d | ||
|
|
1f82cab131 | ||
|
|
c0bbe5074d | ||
|
|
4ed9cc9e4b | ||
|
|
e89293f367 | ||
|
|
9fcb29d5aa | ||
|
|
f85350c2af | ||
|
|
280335ed4f | ||
|
|
22882d210a | ||
|
|
42b620e9dd | ||
|
|
3498454508 | ||
|
|
91d78a2132 | ||
|
|
40df76482e | ||
|
|
c27bbc1c32 | ||
|
|
0379264b11 | ||
|
|
3b511dc42b | ||
|
|
779dff147f | ||
|
|
9daa217666 | ||
|
|
2da96b7123 | ||
|
|
fb52de99ac | ||
|
|
6f1eef8f93 | ||
|
|
1587f83242 | ||
|
|
a3d1fa2516 | ||
|
|
2dcd8e797a | ||
|
|
d767ac56a7 | ||
|
|
1d168467a3 | ||
|
|
a53b445d02 | ||
|
|
da8946833b | ||
|
|
ba08f8c9e1 | ||
|
|
24d3421e9b | ||
|
|
0e18afaac6 | ||
|
|
976856ae5d | ||
|
|
4ccada62d5 | ||
|
|
204f4ddc79 | ||
|
|
83e84b4af2 | ||
|
|
3bbe219d2c | ||
|
|
d61bc669ec | ||
|
|
1be72bf25e | ||
|
|
606727cfaa | ||
|
|
519d427bc1 | ||
|
|
fc05f078c3 | ||
|
|
248b6d07b7 | ||
|
|
9bc9fd04fe | ||
|
|
c48594b49e | ||
|
|
1d19b7bf55 | ||
|
|
ad3090c0ae | ||
|
|
d3ea34cca8 | ||
|
|
c4f0b6d318 | ||
|
|
9ac509815f | ||
|
|
826e00611f | ||
|
|
8b1bdff016 | ||
|
|
5c2e3cf3df | ||
|
|
856a467096 | ||
|
|
a380637c4c | ||
|
|
6e91e8f117 | ||
|
|
3317d4cb68 | ||
|
|
141bcb2a9a | ||
|
|
6b97866c68 | ||
|
|
8a2ca0d6e3 | ||
|
|
660325e570 | ||
|
|
fec18a244c | ||
|
|
e929fedafe | ||
|
|
780888f304 | ||
|
|
711ce65def | ||
|
|
51fb79ed82 | ||
|
|
cdec717f14 | ||
|
|
592b358baa | ||
|
|
bdcf4cc7df | ||
|
|
bb1a332bbb | ||
|
|
79f12e00ea | ||
|
|
26b12f394c | ||
|
|
cd564c0e7e | ||
|
|
4bf5468fc1 | ||
|
|
d3c6aae2e6 | ||
|
|
5b7b9dd36e | ||
|
|
20e671d222 | ||
|
|
76f3bd5dc4 | ||
|
|
aedeee0c57 | ||
|
|
68c83a7505 | ||
|
|
b923b49035 |
8
.vscode/settings.json
vendored
8
.vscode/settings.json
vendored
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
1
deps.edn
1
deps.edn
@@ -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]}
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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 {}}))
|
||||
|
||||
@@ -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]}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -436,6 +436,7 @@ export const DocumentPage = ({
|
||||
onChange={handleChange}
|
||||
editable={canWrite}
|
||||
isLoading={isDocumentLoading}
|
||||
collectionId={documentData?.collection_id ?? null}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: ({
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -97,6 +97,7 @@ export const COLLECTION_ITEM_MODELS = [
|
||||
"collection",
|
||||
"indexed-entity",
|
||||
"document",
|
||||
"image",
|
||||
] as const;
|
||||
export type CollectionItemModel = (typeof COLLECTION_ITEM_MODELS)[number];
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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" />}
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
86
frontend/src/metabase/api/image.ts
Normal file
86
frontend/src/metabase/api/image.ts
Normal 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;
|
||||
@@ -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";
|
||||
|
||||
@@ -9,6 +9,8 @@ export const TAG_TYPES = [
|
||||
"channel",
|
||||
"cloud-migration",
|
||||
"collection",
|
||||
"collection-item-list",
|
||||
"card-snapshot-list",
|
||||
"content-translation",
|
||||
"dashboard",
|
||||
"dashboard-question-candidates",
|
||||
|
||||
@@ -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),
|
||||
];
|
||||
|
||||
@@ -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[]) => {
|
||||
|
||||
@@ -33,4 +33,5 @@ export const ALL_MODELS: CollectionItemModel[] = [
|
||||
"snippet",
|
||||
"collection",
|
||||
"document",
|
||||
"image",
|
||||
];
|
||||
|
||||
56
frontend/src/metabase/collections/components/ImagePage.tsx
Normal file
56
frontend/src/metabase/collections/components/ImagePage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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 }));
|
||||
};
|
||||
|
||||
@@ -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: ``,
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(**)"
|
||||
|
||||
@@ -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 <<<<<<<<<<
|
||||
|
||||
|
||||
@@ -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}})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
151
src/metabase/images/api.clj
Normal 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))))))
|
||||
5
src/metabase/images/init.clj
Normal file
5
src/metabase/images/init.clj
Normal file
@@ -0,0 +1,5 @@
|
||||
(ns metabase.images.init
|
||||
(:require
|
||||
;; for the Toucan hydration methods
|
||||
[metabase.images.models.image]
|
||||
[metabase.images.settings]))
|
||||
10
src/metabase/images/models/card_snapshot.clj
Normal file
10
src/metabase/images/models/card_snapshot.clj
Normal 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")
|
||||
10
src/metabase/images/models/collection_image.clj
Normal file
10
src/metabase/images/models/collection_image.clj
Normal 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")
|
||||
48
src/metabase/images/models/image.clj
Normal file
48
src/metabase/images/models/image.clj
Normal 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)))
|
||||
37
src/metabase/images/s3.clj
Normal file
37
src/metabase/images/s3.clj
Normal 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)))
|
||||
14
src/metabase/images/schema.clj
Normal file
14
src/metabase/images/schema.clj
Normal 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]])
|
||||
22
src/metabase/images/settings.clj
Normal file
22
src/metabase/images/settings.clj
Normal 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)
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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")))
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
;;; +----------------------------------------------------------------------------------------------------------------+
|
||||
|
||||
@@ -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.."
|
||||
|
||||
163
test/metabase/images/api_test.clj
Normal file
163
test/metabase/images/api_test.clj
Normal 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
|
||||
|
||||
#_())
|
||||
Reference in New Issue
Block a user