Skip to content
Snippets Groups Projects
Unverified Commit 90919d31 authored by Cal Herries's avatar Cal Herries Committed by GitHub
Browse files

[Feature] Allow actions to be shared via public link: Milestone 1 (#27777)

* Add public_uuid and made_public_by_id to actions table & endpoints to enable/disable the public sharing on individual actions (#27721)

* Add migration for public_uuid (indexed) and made_public_by_id

* Add UUIDString schema

* Add endpoints for enabling/disabling sharing of actions

* Test that the new fields are returned with `GET /api/action/:id`

* Remove validCheckSum on migration

* Remove trailing whitespace

* Fix DELETE test

* Fix tests

* Please migration linter

* Update the default public_uuid every test run

* Please migration linter

* Add FK onDelete cascade

* Replace ü

* Add GET endpoint and post-select for action

* Revert "Add GET endpoint and post-select for action"

This reverts commit 8cc8b57d6034146dd726b54bc4199830ec1fda21.

* Fix merge

* Reorder migrations

* Update test for GET `api/action?model-id=<id>` endpoint to include public sharing keys (#27802)

* Add GET: /api/public/action/:uuid endpoint (#27781)

* Add test

* Remove non-public columns and check for 404

* Fix docstring

* Rename for clarity

* Fix missing ns

* Remove unneccessary keys from action

* Update test

* Remove unused refer

* Reorder migrations

* Use mt

* Add require and refactor

* Use mt

* Add endpoint for executing a public action (#27793)

* Add endpoint and test

* Add more tests and TODOs

* Use mt

* Reorder migrations

* Remove unused require

* Rate limit actions at 1 per second

* Fix the tests for the throttle

* Refactor tests

* Fix test

* Fix docstring

* Add test for failed execution if actions are disabled

* Use crowberto in tests

* Fix using crowberto in tests

* Fix cyclic load dependency

* Refactor ActionCreator (#27832)

* Refactor make ActionCreator more sane

* Render sidebar conditionally with JS rather than hiding it in CSS

* Make action public (#27809)

* Move action creator action buttons to the header following design

* Remove double border which make it looks thicker

* Draft toggle action public

* Add confirmation when disabling public link similiar to questions and dashboards

* Add an action public UUID input and copy button

* Show action settings based on user permission

* Add public action toggle tests

* Remove unused import :face_palm:‍

* Attempt to fix flaky CI unit tests

The problem seems to be because of how long it takes for the response
to be received even on unit tests, it took longer than 1 second which is
the default timeout for `waitFor`.

* See if not using `userEvent` could make the involving nock faster

* Improve test speed to reduce flakiness

* Add public action page (#27747)

* Add basic public action page

* Add form submit logic placeholder

* Use "Submit" as default action form's button label

* Add "big" variant to `EmbedFrame's` footer

* Use new footer variant for action page

* Break down the page, add document title

* Handle long forms better

* Add `PublicWritebackAction` type

* Use public action GET endpoint

* Add endpoint to execute public actions

* Use action execution endpoint

* Add tests

* Handle actions without parameters

* Rename variant prop

* Replace `waitFor` with `findBy`

* Define `FooterVariant` type

* Fix router setup in `renderWithProviders`

* Show which action is publicly accessible on model detail page (#28039)

* Bring e2e tests back

Manually bringing back tests added in: https://github.com/metabase/metabase/pull/28056



* Update typo

Co-authored-by: default avatarTim Macdonald <tim@metabase.com>

* Remove TODO and use malli for defendpoint

* Add malli schema for endpoints

* Update permissions checks for POST and DELETE

---------

Co-authored-by: default avatarMahatthana (Kelvin) Nomsawadi <me@bboykelvin.dev>
Co-authored-by: default avatarMahatthana Nomsawadi <mahatthana.n@gmail.com>
Co-authored-by: default avatarAnton Kulyk <kuliks.anton@gmail.com>
Co-authored-by: default avatarTim Macdonald <tim@metabase.com>
parent fd771456
No related branches found
No related tags found
No related merge requests found
Showing
with 666 additions and 145 deletions
......@@ -28,8 +28,14 @@ export interface WritebackActionBase {
creator: UserInfo;
updated_at: string;
created_at: string;
public_uuid: string | null;
}
export type PublicWritebackAction = Pick<
WritebackActionBase,
"id" | "name" | "parameters" | "visualization_settings"
>;
export interface QueryAction {
type: "query";
dataset_query: NativeDatasetQuery;
......
import {
CardId,
PublicWritebackAction,
WritebackParameter,
WritebackQueryAction,
WritebackImplicitQueryAction,
......@@ -8,18 +9,20 @@ import { createMockNativeDatasetQuery } from "./query";
import { createMockParameter } from "./parameters";
import { createMockUserInfo } from "./user";
export const createMockActionParameter = (
opts?: Partial<WritebackParameter>,
): WritebackParameter => ({
target: opts?.target || ["variable", ["template-tag", "id"]],
...createMockParameter({
id: "id",
export const createMockActionParameter = ({
id = "id",
target = ["variable", ["template-tag", id]],
...opts
}: Partial<WritebackParameter> = {}): WritebackParameter => {
const parameter = createMockParameter({
id,
name: "ID",
type: "type/Integer",
slug: "id",
...opts,
}),
});
});
return { ...parameter, target };
};
export const createMockQueryAction = ({
dataset_query = createMockNativeDatasetQuery(),
......@@ -37,6 +40,7 @@ export const createMockQueryAction = ({
creator,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
public_uuid: null,
...opts,
type: "query",
};
......@@ -57,6 +61,7 @@ export const createMockImplicitQueryAction = ({
creator,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
public_uuid: null,
...opts,
type: "implicit",
});
......@@ -83,3 +88,12 @@ export const createMockImplicitCUDActions = (
model_id: modelId,
}),
];
export const createMockPublicAction = (
opts?: Partial<PublicWritebackAction>,
): PublicWritebackAction => ({
id: 1,
name: "Public Action",
parameters: [],
...opts,
});
......@@ -190,7 +190,7 @@ export const ActionForm = ({
{onClose && <Button onClick={onClose}>{t`Cancel`}</Button>}
<FormSubmitButton
disabled={!dirty && hasFormFields}
title={submitTitle ?? t`Save`}
title={submitTitle ?? t`Submit`}
{...submitButtonVariant}
/>
</ActionFormButtonContainer>
......
......@@ -60,6 +60,7 @@ const setup = ({
<ActionForm
initialValues={initialValues}
parameters={parameters}
submitTitle="Save"
formSettings={formSettings}
setFormSettings={isSettings ? setFormSettings : undefined}
onSubmit={onSubmit}
......
import React, { useState, useMemo, useEffect } from "react";
import React, { useState, useMemo, useEffect, useCallback } from "react";
import { t } from "ttag";
import _ from "underscore";
import { connect } from "react-redux";
import { push } from "react-router-redux";
import Button from "metabase/core/components/Button";
import Modal from "metabase/components/Modal";
import { useToggle } from "metabase/hooks/use-toggle";
import Actions, { ActionParams } from "metabase/entities/actions";
import Database from "metabase/entities/databases";
import { getMetadata } from "metabase/selectors/metadata";
......@@ -17,32 +13,22 @@ import { createQuestionFromAction } from "metabase/actions/selectors";
import type {
WritebackQueryAction,
ActionFormSettings,
WritebackActionId,
} from "metabase-types/api";
import type { State } from "metabase-types/store";
import type { SavedCard } from "metabase-types/types/Card";
import Modal from "metabase/components/Modal";
import { getUserIsAdmin } from "metabase/selectors/user";
import { getSetting } from "metabase/selectors/settings";
import type NativeQuery from "metabase-lib/queries/NativeQuery";
import type Metadata from "metabase-lib/metadata/Metadata";
import type Question from "metabase-lib/Question";
import { getTemplateTagParametersFromCard } from "metabase-lib/parameters/utils/template-tags";
import CreateActionForm from "../CreateActionForm";
import ActionCreatorHeader from "./ActionCreatorHeader";
import QueryActionEditor from "./QueryActionEditor";
import FormCreator from "./FormCreator";
import {
DataReferenceTriggerButton,
DataReferenceInline,
} from "./InlineDataReference";
import {
ActionCreatorBodyContainer,
EditorContainer,
ModalRoot,
ModalActions,
ModalLeft,
} from "./ActionCreator.styled";
import { newQuestion, convertActionToQuestionCard } from "./utils";
import ActionCreatorView from "./ActionCreatorView";
const mapStateToProps = (
state: State,
......@@ -51,6 +37,8 @@ const mapStateToProps = (
metadata: getMetadata(state),
question: action ? createQuestionFromAction(state, action) : undefined,
actionId: action ? action.id : undefined,
isAdmin: getUserIsAdmin(state),
isPublicSharingEnabled: getSetting(state, "enable-public-sharing"),
});
const mapDispatchToProps = {
......@@ -68,9 +56,11 @@ interface OwnProps {
}
interface StateProps {
actionId?: number;
actionId?: WritebackActionId;
question?: Question;
metadata: Metadata;
isAdmin: boolean;
isPublicSharingEnabled: boolean;
}
interface DispatchProps {
......@@ -88,6 +78,8 @@ function ActionCreatorComponent({
databaseId,
update,
onClose,
isAdmin,
isPublicSharingEnabled,
}: ActionCreatorProps) {
const [question, setQuestion] = useState(
passedQuestion ?? newQuestion(metadata, databaseId),
......@@ -97,15 +89,21 @@ function ActionCreatorComponent({
>(undefined);
const [showSaveModal, setShowSaveModal] = useState(false);
const [isDataRefOpen, { toggle: toggleDataRef, turnOff: closeDataRef }] =
useToggle(false);
useEffect(() => {
setQuestion(passedQuestion ?? newQuestion(metadata, databaseId));
// we do not want to update this any time the props or metadata change, only if action id changes
}, [actionId]); // eslint-disable-line react-hooks/exhaustive-deps
const handleChangeQuestionQuery = useCallback(
(newQuery: NativeQuery) => {
const newQuestion = newQuery.question();
const newParams = getTemplateTagParametersFromCard(newQuestion.card());
setQuestion(newQuestion.setQuery(newQuery).setParameters(newParams));
},
[setQuestion],
);
const defaultModelId: number | undefined = useMemo(() => {
if (modelId) {
return modelId;
......@@ -123,7 +121,7 @@ function ActionCreatorComponent({
const isNew = !actionId && !(question.card() as SavedCard).id;
const handleSaveClick = () => {
const handleClickSave = () => {
if (isNew) {
setShowSaveModal(true);
} else {
......@@ -139,16 +137,16 @@ function ActionCreatorComponent({
}
};
const handleOnSave = (action: WritebackQueryAction) => {
const handleSave = (action: WritebackQueryAction) => {
const actionCard = convertActionToQuestionCard(action);
setQuestion(question.setCard(actionCard));
setTimeout(() => setShowSaveModal(false), 1000);
onClose?.();
};
const handleClose = () => setShowSaveModal(false);
const handleCloseNewActionModal = () => setShowSaveModal(false);
const handleExampleClick = () => {
const handleClickExample = () => {
setQuestion(
question.setQuery(query.setQueryText(query.queryText() + EXAMPLE_QUERY)),
);
......@@ -156,64 +154,29 @@ function ActionCreatorComponent({
return (
<>
<Modal wide onClose={onClose}>
<ModalRoot>
<ActionCreatorBodyContainer>
<ModalLeft>
<DataReferenceTriggerButton onClick={toggleDataRef} />
<ActionCreatorHeader
type="query"
name={question.displayName() ?? t`New Action`}
onChangeName={newName =>
setQuestion(q => q.setDisplayName(newName))
}
/>
<EditorContainer>
<QueryActionEditor
question={question}
setQuestion={setQuestion}
/>
</EditorContainer>
<ModalActions>
<Button onClick={onClose} borderless>
{t`Cancel`}
</Button>
<Button
primary
disabled={query.isEmpty()}
onClick={handleSaveClick}
>
{isNew ? t`Save` : t`Update`}
</Button>
</ModalActions>
</ModalLeft>
<DataReferenceInline
isOpen={isDataRefOpen}
onClose={closeDataRef}
/>
{!isDataRefOpen && (
<FormCreator
params={question?.parameters() ?? []}
formSettings={
question?.card()?.visualization_settings as ActionFormSettings
}
onChange={setFormSettings}
onExampleClick={handleExampleClick}
/>
)}
</ActionCreatorBodyContainer>
</ModalRoot>
</Modal>
<ActionCreatorView
isNew={isNew}
hasSharingPermission={isAdmin && isPublicSharingEnabled}
canSave={query.isEmpty()}
actionId={actionId}
question={question}
onChangeQuestionQuery={handleChangeQuestionQuery}
onChangeName={newName =>
setQuestion(question => question.setDisplayName(newName))
}
onCloseModal={onClose}
onChangeFormSettings={setFormSettings}
onClickSave={handleClickSave}
onClickExample={handleClickExample}
/>
{showSaveModal && (
<Modal title={t`New Action`} onClose={handleClose}>
<Modal title={t`New Action`} onClose={handleCloseNewActionModal}>
<CreateActionForm
question={question}
formSettings={formSettings as ActionFormSettings}
modelId={defaultModelId}
onCreate={handleOnSave}
onCancel={handleClose}
onCreate={handleSave}
onCancel={handleCloseNewActionModal}
/>
</Modal>
)}
......
......@@ -4,7 +4,10 @@ import nock from "nock";
import {
renderWithProviders,
screen,
waitFor,
waitForElementToBeRemoved,
getIcon,
queryIcon,
} from "__support__/ui";
import { setupDatabasesEndpoints } from "__support__/server-mocks";
import { SAMPLE_DATABASE } from "__support__/sample_database_fixture";
......@@ -12,7 +15,13 @@ import { SAMPLE_DATABASE } from "__support__/sample_database_fixture";
import {
createMockActionParameter,
createMockQueryAction,
createMockUser,
} from "metabase-types/api/mocks";
import {
createMockSettingsState,
createMockState,
} from "metabase-types/store/mocks";
import type { WritebackQueryAction } from "metabase-types/api";
import type Database from "metabase-lib/metadata/Database";
import type Table from "metabase-lib/metadata/Table";
......@@ -35,19 +44,38 @@ function getTableObject(table: Table) {
type SetupOpts = {
action?: WritebackQueryAction;
isAdmin?: boolean;
isPublicSharingEnabled?: boolean;
};
async function setup({ action }: SetupOpts = {}) {
async function setup({
action,
isAdmin,
isPublicSharingEnabled,
}: SetupOpts = {}) {
const scope = nock(location.origin);
setupDatabasesEndpoints(scope, [getDatabaseObject(SAMPLE_DATABASE)]);
if (action) {
scope.get(`/api/action/${action.id}`).reply(200, action);
scope.delete(`/api/action/${action.id}/public_link`).reply(204);
scope
.post(`/api/action/${action.id}/public_link`)
.reply(200, { uuid: "mock-uuid" });
}
renderWithProviders(<ActionCreator actionId={action?.id} />, {
withSampleDatabase: true,
storeInitialState: createMockState({
currentUser: createMockUser({
is_superuser: isAdmin,
}),
settings: createMockSettingsState({
"enable-public-sharing": isPublicSharingEnabled,
"site-url": SITE_URL,
}),
}),
});
await waitForElementToBeRemoved(() =>
......@@ -58,11 +86,13 @@ async function setup({ action }: SetupOpts = {}) {
async function setupEditing({
action = createMockQueryAction(),
...opts
} = {}) {
}: SetupOpts = {}) {
await setup({ action, ...opts });
return { action };
}
const SITE_URL = "http://localhost:3000";
describe("ActionCreator", () => {
afterEach(() => {
nock.cleanAll();
......@@ -73,7 +103,6 @@ describe("ActionCreator", () => {
await setup();
expect(screen.getByText(/New action/i)).toBeInTheDocument();
expect(screen.getByText(SAMPLE_DATABASE.name)).toBeInTheDocument();
expect(
screen.getByTestId("mock-native-query-editor"),
).toBeInTheDocument();
......@@ -91,6 +120,21 @@ describe("ActionCreator", () => {
expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Cancel" })).toBeEnabled();
});
it("should show clickable data reference icon", async () => {
await setup();
getIcon("reference", "button").click();
expect(screen.getByText("Data Reference")).toBeInTheDocument();
expect(screen.getByText(SAMPLE_DATABASE.name)).toBeInTheDocument();
});
it("should not show action settings button", async () => {
await setup({ isAdmin: true, isPublicSharingEnabled: true });
expect(
screen.queryByRole("button", { name: "Action settings" }),
).not.toBeInTheDocument();
});
});
describe("editing action", () => {
......@@ -99,7 +143,6 @@ describe("ActionCreator", () => {
expect(screen.getByText(action.name)).toBeInTheDocument();
expect(screen.queryByText(/New action/i)).not.toBeInTheDocument();
expect(screen.getByText(SAMPLE_DATABASE.name)).toBeInTheDocument();
expect(
screen.getByTestId("mock-native-query-editor"),
).toBeInTheDocument();
......@@ -122,5 +165,106 @@ describe("ActionCreator", () => {
expect(screen.getByText("FooBar")).toBeInTheDocument();
});
describe("admin users and has public sharing enabled", () => {
const mockUuid = "mock-uuid";
it("should show action settings button", async () => {
await setupEditing({
isAdmin: true,
isPublicSharingEnabled: true,
});
expect(
screen.getByRole("button", { name: "Action settings" }),
).toBeInTheDocument();
});
it("should be able to enable action public sharing", async () => {
await setupEditing({
isAdmin: true,
isPublicSharingEnabled: true,
});
screen.getByRole("button", { name: "Action settings" }).click();
expect(screen.getByText("Action settings")).toBeInTheDocument();
const makePublicToggle = screen.getByRole("switch", {
name: "Make public",
});
expect(makePublicToggle).not.toBeChecked();
expect(
screen.queryByRole("textbox", { name: "Public action link URL" }),
).not.toBeInTheDocument();
screen.getByRole("switch", { name: "Make public" }).click();
await waitFor(() => {
expect(makePublicToggle).toBeChecked();
});
const expectedPublicLinkUrl = `${SITE_URL}/public/action/${mockUuid}`;
expect(
screen.getByRole("textbox", { name: "Public action link URL" }),
).toHaveValue(expectedPublicLinkUrl);
});
it("should be able to disable action public sharing", async () => {
await setupEditing({
action: createMockQueryAction({ public_uuid: mockUuid }),
isAdmin: true,
isPublicSharingEnabled: true,
});
screen.getByRole("button", { name: "Action settings" }).click();
expect(screen.getByText("Action settings")).toBeInTheDocument();
const makePublicToggle = screen.getByRole("switch", {
name: "Make public",
});
expect(makePublicToggle).toBeChecked();
const expectedPublicLinkUrl = `${SITE_URL}/public/action/${mockUuid}`;
expect(
screen.getByRole("textbox", { name: "Public action link URL" }),
).toHaveValue(expectedPublicLinkUrl);
makePublicToggle.click();
expect(
screen.getByRole("heading", { name: "Disable this public link?" }),
).toBeInTheDocument();
screen.getByRole("button", { name: "Yes" }).click();
await waitFor(() => {
expect(makePublicToggle).not.toBeChecked();
});
expect(
screen.queryByRole("textbox", { name: "Public action link URL" }),
).not.toBeInTheDocument();
});
});
describe("no permission to see action settings", () => {
it("should not show action settings button when user is admin but public sharing is disabled", async () => {
await setupEditing({
isAdmin: true,
isPublicSharingEnabled: false,
});
expect(
screen.queryByRole("button", { name: "Action settings" }),
).not.toBeInTheDocument();
});
it("should not show action settings button when user is not admin but public sharing is enabled", async () => {
await setupEditing({
isAdmin: false,
isPublicSharingEnabled: true,
});
expect(
screen.queryByRole("button", { name: "Action settings" }),
).not.toBeInTheDocument();
});
});
});
});
......@@ -54,3 +54,11 @@ export const CompactSelect = styled(Select)`
}
}
`;
export const ActionButtons = styled.div`
/* Since the button is borderless, adding the negative margin
will make it look flush with the container */
&:last-child {
margin-right: -${space(1)};
}
`;
......@@ -8,6 +8,7 @@ import {
LeftHeader,
EditableText,
CompactSelect,
ActionButtons,
} from "./ActionCreatorHeader.styled";
type Props = {
......@@ -15,6 +16,7 @@ type Props = {
type: WritebackActionType;
onChangeName: (name: string) => void;
onChangeType?: (type: WritebackActionType) => void;
actionButtons: React.ReactElement[];
};
const OPTS = [{ value: "query", name: t`Query`, disabled: true }];
......@@ -24,6 +26,7 @@ const ActionCreatorHeader = ({
type,
onChangeName,
onChangeType,
actionButtons,
}: Props) => {
return (
<Container>
......@@ -33,6 +36,9 @@ const ActionCreatorHeader = ({
<CompactSelect options={OPTS} value={type} onChange={onChangeType} />
)}
</LeftHeader>
{actionButtons.length > 0 && (
<ActionButtons>{actionButtons}</ActionButtons>
)}
</Container>
);
};
......
import React, { useCallback, useState } from "react";
import { t } from "ttag";
import Button from "metabase/core/components/Button";
import Modal from "metabase/components/Modal";
import { ActionFormSettings, WritebackActionId } from "metabase-types/api";
import ActionCreatorHeader from "metabase/actions/containers/ActionCreator/ActionCreatorHeader";
import QueryActionEditor from "metabase/actions/containers/ActionCreator/QueryActionEditor";
import FormCreator from "metabase/actions/containers/ActionCreator/FormCreator";
import {
DataReferenceTriggerButton,
DataReferenceInline,
} from "metabase/actions/containers/ActionCreator/InlineDataReference";
import {
ActionCreatorBodyContainer,
EditorContainer,
ModalRoot,
ModalActions,
ModalLeft,
} from "metabase/actions/containers/ActionCreator/ActionCreator.styled";
import { isNotNull } from "metabase/core/utils/types";
import type NativeQuery from "metabase-lib/queries/NativeQuery";
import Question from "metabase-lib/Question";
import type { SideView } from "./types";
import InlineActionSettings, {
ActionSettingsTriggerButton,
} from "./InlineActionSettings";
interface ActionCreatorProps {
isNew: boolean;
hasSharingPermission: boolean;
canSave: boolean;
actionId?: WritebackActionId;
question: Question;
onChangeQuestionQuery: (query: NativeQuery) => void;
onChangeName: (name: string) => void;
onCloseModal?: () => void;
onChangeFormSettings: (formSettings: ActionFormSettings) => void;
onClickSave: () => void;
onClickExample: () => void;
}
const DEFAULT_SIDE_VIEW: SideView = "actionForm";
export default function ActionCreatorView({
isNew,
hasSharingPermission,
canSave,
actionId,
question,
onChangeQuestionQuery,
onChangeName,
onCloseModal,
onChangeFormSettings,
onClickSave,
onClickExample,
}: ActionCreatorProps) {
const [activeSideView, setActiveSideView] =
useState<SideView>(DEFAULT_SIDE_VIEW);
const toggleDataRef = useCallback(() => {
setActiveSideView(activeSideView => {
if (activeSideView !== "dataReference") {
return "dataReference";
}
return DEFAULT_SIDE_VIEW;
});
}, []);
const toggleActionSettings = useCallback(() => {
setActiveSideView(activeSideView => {
if (activeSideView !== "actionSettings") {
return "actionSettings";
}
return DEFAULT_SIDE_VIEW;
});
}, []);
const closeSideView = useCallback(() => {
setActiveSideView(DEFAULT_SIDE_VIEW);
}, []);
return (
<Modal wide onClose={onCloseModal}>
<ModalRoot>
<ActionCreatorBodyContainer>
<ModalLeft>
<ActionCreatorHeader
type="query"
name={question.displayName() ?? t`New Action`}
onChangeName={onChangeName}
actionButtons={[
<DataReferenceTriggerButton
key="dataReference"
onClick={toggleDataRef}
/>,
!isNew && hasSharingPermission ? (
<ActionSettingsTriggerButton
key="actionSettings"
onClick={toggleActionSettings}
/>
) : null,
].filter(isNotNull)}
/>
<EditorContainer>
<QueryActionEditor
query={question.query() as NativeQuery}
onChangeQuestionQuery={onChangeQuestionQuery}
/>
</EditorContainer>
<ModalActions>
<Button onClick={onCloseModal} borderless>
{t`Cancel`}
</Button>
<Button primary disabled={canSave} onClick={onClickSave}>
{isNew ? t`Save` : t`Update`}
</Button>
</ModalActions>
</ModalLeft>
{
(
{
actionForm: (
<FormCreator
params={question?.parameters() ?? []}
formSettings={
question?.card()
?.visualization_settings as ActionFormSettings
}
onChange={onChangeFormSettings}
onExampleClick={onClickExample}
/>
),
dataReference: <DataReferenceInline onClose={closeSideView} />,
actionSettings: actionId ? (
<InlineActionSettings
onClose={closeSideView}
actionId={actionId}
/>
) : null,
} as Record<SideView, React.ReactElement>
)[activeSideView]
}
</ActionCreatorBodyContainer>
</ModalRoot>
</Modal>
);
}
import styled from "@emotion/styled";
import { color } from "metabase/lib/colors";
import SidebarContent from "metabase/query_builder/components/SidebarContent";
import { space } from "metabase/styled-components/theme";
export const ActionSettingsContainer = styled.div`
${SidebarContent.Header.Root} {
position: sticky;
top: 0;
padding: 1.5rem 1.5rem 0.5rem 1.5rem;
margin: 0;
background-color: ${color("white")};
}
`;
export const ActionSettingsContent = styled.div`
margin: 1rem 1.5rem;
`;
export const ToggleContainer = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
`;
export const ToggleLabel = styled.label`
font-size: 0.875rem;
color: ${color("text-medium")};
font-weight: 700;
margin-right: ${space(1)};
`;
export const CopyWidgetContainer = styled.div`
margin-top: ${space(2)};
`;
import React from "react";
import { t } from "ttag";
import _ from "underscore";
import { connect } from "react-redux";
import * as Urls from "metabase/lib/urls";
import { getSetting } from "metabase/selectors/settings";
import type { WritebackAction, WritebackActionId } from "metabase-types/api";
import type { State } from "metabase-types/store";
import Tooltip from "metabase/core/components/Tooltip";
import Button from "metabase/core/components/Button";
import Toggle from "metabase/core/components/Toggle";
import SidebarContent from "metabase/query_builder/components/SidebarContent";
import { useUniqueId } from "metabase/hooks/use-unique-id";
import Icon from "metabase/components/Icon";
import Actions from "metabase/entities/actions/actions";
import ConfirmContent from "metabase/components/ConfirmContent";
import Modal from "metabase/components/Modal";
import { useToggle } from "metabase/hooks/use-toggle";
import CopyWidget from "metabase/components/CopyWidget";
import {
ActionSettingsContainer,
ActionSettingsContent,
CopyWidgetContainer,
ToggleContainer,
ToggleLabel,
} from "./InlineActionSettings.styled";
type PublicWritebackAction = WritebackAction & {
public_uuid: string;
};
interface OwnProps {
onClose: () => void;
actionId: WritebackActionId;
}
interface EntityLoaderProps {
action: WritebackAction;
}
interface StateProps {
siteUrl: string;
createPublicLink: ({ id }: { id: WritebackActionId }) => void;
deletePublicLink: ({ id }: { id: WritebackActionId }) => void;
}
type ActionSettingsInlineProps = OwnProps & EntityLoaderProps & StateProps;
export const ActionSettingsTriggerButton = ({
onClick,
}: {
onClick: () => void;
}) => (
<Tooltip tooltip={t`Action settings`}>
<Button
onlyIcon
onClick={onClick}
icon="gear"
iconSize={16}
aria-label={t`Action settings`}
/>
</Tooltip>
);
const mapStateToProps = (state: State) => ({
siteUrl: getSetting(state, "site-url"),
});
const mapDispatchToProps = {
createPublicLink: Actions.actions.createPublicLink,
deletePublicLink: Actions.actions.deletePublicLink,
};
const InlineActionSettings = ({
onClose,
actionId,
action,
siteUrl,
createPublicLink,
deletePublicLink,
}: ActionSettingsInlineProps) => {
const id = useUniqueId();
const isPublic = isActionPublic(action);
const [isModalOpen, { turnOn: openModal, turnOff: closeModal }] = useToggle();
const handleTogglePublic = (isPublic: boolean) => {
if (isPublic) {
createPublicLink({ id: actionId });
} else {
openModal();
}
};
const handleDisablePublicLink = () => {
deletePublicLink({ id: actionId });
};
return (
<ActionSettingsContainer>
<SidebarContent title={t`Action settings`} onClose={onClose}>
<ActionSettingsContent>
<ToggleContainer>
<span>
<ToggleLabel htmlFor={id}>{t`Make public`}</ToggleLabel>
<Tooltip
tooltip={t`Creates a publicly shareable link to this action.`}
>
<Icon name="info" size={10} />
</Tooltip>
</span>
<Toggle id={id} value={isPublic} onChange={handleTogglePublic} />
<Modal isOpen={isModalOpen}>
<ConfirmContent
title={t`Disable this public link?`}
content={t`This will cause the existing link to stop working. You can re-enable it, but when you do it will be a different link.`}
onAction={handleDisablePublicLink}
onClose={closeModal}
/>
</Modal>
</ToggleContainer>
{isPublic && (
<CopyWidgetContainer>
<CopyWidget
value={Urls.publicAction(siteUrl, action.public_uuid)}
aria-label={t`Public action link URL`}
/>
</CopyWidgetContainer>
)}
</ActionSettingsContent>
</SidebarContent>
</ActionSettingsContainer>
);
};
function isActionPublic(
action: WritebackAction,
): action is PublicWritebackAction {
return Boolean(action.public_uuid);
}
export default _.compose(
Actions.load({
id: (_: State, props: OwnProps) => props.actionId,
}),
connect(mapStateToProps, mapDispatchToProps),
)(InlineActionSettings) as (props: OwnProps) => JSX.Element;
import styled from "@emotion/styled";
import { color } from "metabase/lib/colors";
import Button from "metabase/core/components/Button";
import SidebarContent from "metabase/query_builder/components/SidebarContent";
export const DataReferenceContainer = styled.div<{ isOpen: boolean }>`
display: ${props => (props.isOpen ? "block" : "none")};
export const DataReferenceContainer = styled.div`
overflow: hidden;
position: relative;
height: 100%;
background-color: ${color("white")};
border-left: 1px solid ${color("border")};
border-right: 1px solid ${color("border")};
${SidebarContent.Header.Root} {
position: sticky;
......@@ -21,10 +17,3 @@ export const DataReferenceContainer = styled.div<{ isOpen: boolean }>`
background-color: ${color("white")};
}
`;
export const TriggerButton = styled(Button)`
position: absolute;
top: 76px;
right: 10px;
z-index: 10;
`;
......@@ -2,21 +2,13 @@ import React, { useState } from "react";
import { t } from "ttag";
import Tooltip from "metabase/core/components/Tooltip";
import Button from "metabase/core/components/Button";
import DataReference from "metabase/query_builder/components/dataref/DataReference";
import {
DataReferenceContainer,
TriggerButton,
} from "./InlineDataReference.styled";
import { DataReferenceContainer } from "./InlineDataReference.styled";
export const DataReferenceInline = ({
onClose,
isOpen,
}: {
onClose: () => void;
isOpen: boolean;
}) => {
export const DataReferenceInline = ({ onClose }: { onClose: () => void }) => {
const [dataRefStack, setDataRefStack] = useState<any[]>([]);
const pushRefStack = (ref: any) => {
......@@ -28,7 +20,7 @@ export const DataReferenceInline = ({
};
return (
<DataReferenceContainer isOpen={isOpen}>
<DataReferenceContainer>
<DataReference
dataReferenceStack={dataRefStack}
popDataReferenceStack={popRefStack}
......@@ -45,6 +37,6 @@ export const DataReferenceTriggerButton = ({
onClick: () => void;
}) => (
<Tooltip tooltip={t`Data Reference`}>
<TriggerButton onlyIcon onClick={onClick} icon="reference" iconSize={16} />
<Button onlyIcon onClick={onClick} icon="reference" iconSize={16} />
</Tooltip>
);
import React, { useCallback } from "react";
import React from "react";
import NativeQueryEditor from "metabase/query_builder/components/NativeQueryEditor";
import type NativeQuery from "metabase-lib/queries/NativeQuery";
import type Question from "metabase-lib/Question";
import { getTemplateTagParametersFromCard } from "metabase-lib/parameters/utils/template-tags";
function QueryActionEditor({
question,
setQuestion,
query,
onChangeQuestionQuery,
}: {
question: Question;
setQuestion: (q: Question) => void;
query: NativeQuery;
onChangeQuestionQuery: (query: NativeQuery) => void;
}) {
const handleChange = useCallback(
(newQuery: NativeQuery) => {
const newQuestion = newQuery.question();
const newParams = getTemplateTagParametersFromCard(newQuestion.card());
setQuestion(newQuestion.setQuery(newQuery).setParameters(newParams));
},
[setQuestion],
);
return (
<>
<NativeQueryEditor
query={question.query()}
query={query}
viewHeight="full"
setDatasetQuery={handleChange}
setDatasetQuery={onChangeQuestionQuery}
enableRun={false}
hasEditingSidebar={false}
isNativeEditorOpen
......
export type SideView = "dataReference" | "actionForm" | "actionSettings";
/* eslint-disable react/prop-types */
import React from "react";
import _ from "underscore";
import { t } from "ttag";
import ModalContent from "metabase/components/ModalContent";
import Button from "metabase/core/components/Button";
const nop = () => {};
interface ConfirmContentProps {
title: string;
content?: string | null;
message?: string;
onClose?: () => void;
onAction?: () => void;
onCancel?: () => void;
confirmButtonText?: string;
cancelButtonText?: string;
}
const ConfirmContent = ({
title,
content = null,
message = t`Are you sure you want to do this?`,
onClose = nop,
onAction = nop,
onCancel = nop,
onClose = _.noop,
onAction = _.noop,
onCancel = _.noop,
confirmButtonText = t`Yes`,
cancelButtonText = t`Cancel`,
}) => (
}: ConfirmContentProps) => (
<ModalContent
title={title}
formModal
......
......@@ -8,6 +8,7 @@ import React, {
} from "react";
import styled from "@emotion/styled";
import { color, space } from "styled-system";
import type { SpaceProps } from "styled-system";
import _ from "underscore";
import Icon from "metabase/components/Icon";
import {
......@@ -127,7 +128,7 @@ const BaseButton = forwardRef(function BaseButton(
);
});
const Button = styled(BaseButton)`
const Button = styled(BaseButton)<SpaceProps>`
${color};
${space};
`;
......
......@@ -54,4 +54,6 @@ const getSubmitButtonTitle = (
}
};
export default FormSubmitButton;
export default Object.assign(FormSubmitButton, {
Button: Button.Root,
});
import { t } from "ttag";
import { createAction } from "redux-actions";
import { updateIn } from "icepick";
import { createEntity } from "metabase/lib/entities";
import type {
......@@ -6,6 +8,7 @@ import type {
ImplicitQueryAction,
WritebackActionBase,
WritebackAction,
WritebackActionId,
} from "metabase-types/api";
import type { Dispatch } from "metabase-types/store";
......@@ -176,6 +179,9 @@ const enableImplicitActionsForModel =
dispatch(Actions.actions.invalidateLists());
};
const CREATE_PUBLIC_LINK = "metabase/entities/actions/CREATE_PUBLIC_LINK";
const DELETE_PUBLIC_LINK = "metabase/entities/actions/DELETE_PUBLIC_LINK";
const Actions = createEntity({
name: "actions",
nameOne: "action",
......@@ -201,6 +207,50 @@ const Actions = createEntity({
actions: {
enableImplicitActionsForModel,
},
objectActions: {
createPublicLink: createAction(
CREATE_PUBLIC_LINK,
({ id }: { id: WritebackActionId }) => {
return ActionsApi.createPublicLink({ id }).then(
({ uuid }: { uuid: string }) => {
return {
id,
uuid,
};
},
);
},
),
deletePublicLink: createAction(
DELETE_PUBLIC_LINK,
({ id }: { id: WritebackActionId }) => {
return ActionsApi.deletePublicLink({ id }).then(() => {
return {
id,
};
});
},
),
},
reducer: (state = {}, { type, payload }: { type: string; payload: any }) => {
switch (type) {
case CREATE_PUBLIC_LINK: {
const { id, uuid } = payload;
return updateIn(state, [id], action => {
return { ...action, public_uuid: uuid };
});
}
case DELETE_PUBLIC_LINK: {
const { id } = payload;
return updateIn(state, [id], action => {
return { ...action, public_uuid: null };
});
}
default: {
return state;
}
}
},
});
export default Actions;
export function publicAction(siteUrl: string, uuid: string) {
return `${siteUrl}/public/action/${uuid}`;
}
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment