Skip to content
Snippets Groups Projects
Unverified Commit 9125f5d2 authored by Anton Kulyk's avatar Anton Kulyk Committed by GitHub
Browse files

Remove redundant components (#27607)

* Delete `QuestionHistoryModal`

* Delete `HistoryModal`

* Delete `AdminEmptyText`

* Delete `EditWarning`

* Delete `Expandable`

* Remove a bunch of question loaders

* Delete `CandidateListLoader`

* Delete `QuestionName`

* Revert "Remove a bunch of question loaders"

This reverts commit 209dbac68dec92083d6e3bdf34ff42ace76a3870.

* Remove `QuestionAndResultLoader`
parent 609e7578
No related branches found
No related tags found
No related merge requests found
Showing
with 35 additions and 767 deletions
......@@ -8,7 +8,6 @@ import _ from "underscore";
import { t } from "ttag";
import * as MetabaseAnalytics from "metabase/lib/analytics";
import AdminEmptyText from "metabase/components/AdminEmptyText";
import {
metrics as Metrics,
databases as Databases,
......@@ -112,13 +111,11 @@ class MetadataEditorInner extends Component {
) : (
<div style={{ paddingTop: "10rem" }} className="full text-centered">
{!loading && (
<AdminEmptyText
message={
hasLoadedDatabase
? t`Select any table to see its schema and add or edit metadata.`
: t`The page you asked for couldn't be found.`
}
/>
<h2 className="text-medium">
{hasLoadedDatabase
? t`Select any table to see its schema and add or edit metadata.`
: t`The page you asked for couldn't be found.`}
</h2>
)}
</div>
)}
......
/* eslint-disable react/prop-types */
import React, { useMemo } from "react";
import PropTypes from "prop-types";
import { t } from "ttag";
import { isAdminGroup, isDefaultGroup } from "metabase/lib/groups";
import { getFullName } from "metabase/lib/user";
import Icon from "metabase/components/Icon";
import AdminEmptyText from "metabase/components/AdminEmptyText";
import AdminContentTable from "metabase/components/AdminContentTable";
import PaginationControls from "metabase/components/PaginationControls";
......@@ -127,9 +125,7 @@ function GroupMembersTable({
)}
{!hasMembers && (
<div className="mt4 pt4 flex layout-centered">
<AdminEmptyText
message={t`A group is only as good as its members.`}
/>
<h2 className="text-medium">{t`A group is only as good as its members.`}</h2>
</div>
)}
</React.Fragment>
......
import React from "react";
import PropTypes from "prop-types";
const AdminEmptyText = ({ message }) => (
<h2 className="text-medium">{message}</h2>
);
AdminEmptyText.propTypes = {
message: PropTypes.string.isRequired,
};
export default AdminEmptyText;
/* eslint-disable react/prop-types */
import React from "react";
import Icon from "metabase/components/Icon";
import { color } from "metabase/lib/colors";
const DirectionalButton = ({ direction = "left", onClick }) => (
<div
className="shadowed cursor-pointer text-brand-hover text-medium flex align-center circle p2 bg-white transition-background transition-color"
onClick={onClick}
style={{
border: `1px solid ${color("border")}`,
boxShadow: `0 2px 4px 0 ${color("shadow")}`,
}}
>
<Icon name={`arrow_${direction}`} />
</div>
);
export default DirectionalButton;
import React from "react";
import moment from "moment-timezone";
import styled from "@emotion/styled";
import Timeline from "metabase/components/Timeline";
import { color } from "metabase/lib/colors";
import DrawerSection from "./DrawerSection";
export const component = DrawerSection;
export const category = "layout";
export const description = `
This component is similar to the CollapseSection component,
but instead of expanding downward, it expands upward.
The header situates itself at the bottom of the remaining space
in a parent component and when opened fills the remaining space
with what child components you have given it.
For this to work properly, the containing element (here, Container)
must handle overflow when the DrawerSection is open and have a display
of "flex" (plus a flex-direction of "column") so that the DrawerSection
can properly use the remaining space in the Container component.
`;
const Container = styled.div`
line-height: 1.5;
width: 350px;
border: 1px dashed ${color("bg-dark")};
border-radius: 0.5rem;
padding: 1rem;
height: 32rem;
overflow-y: auto;
display: flex;
flex-direction: column;
`;
const TextArea = styled.textarea`
width: 100%;
flex-shrink: 0;
`;
const items = [
{
icon: "verified",
title: "John Someone verified this",
description: "idk lol",
timestamp: moment().subtract(1, "day").valueOf(),
numComments: 5,
},
{
icon: "pencil",
title: "Foo edited this",
description: "Did a thing.",
timestamp: moment().subtract(1, "week").valueOf(),
},
{
icon: "close",
title: "foo foo foo",
timestamp: moment().subtract(2, "month").valueOf(),
},
{
icon: "number",
title: "bar bar bar",
timestamp: moment().subtract(1, "year").valueOf(),
numComments: 123,
},
];
export const examples = {
"Constrained container": (
<Container>
<TextArea placeholder="an element with variable height" />
<DrawerSection header="foo">
<Timeline items={items} />
</DrawerSection>
</Container>
),
};
import React, { useState } from "react";
import PropTypes from "prop-types";
import _ from "underscore";
import Icon from "metabase/components/Icon";
import {
Container,
Transformer,
Children,
Header,
} from "./DrawerSection.styled";
export const STATES = {
closed: "closed",
open: "open",
};
DrawerSection.propTypes = {
header: PropTypes.node.isRequired,
children: PropTypes.node,
state: PropTypes.oneOf([STATES.closed, STATES.open]),
onStateChange: PropTypes.func,
};
function DrawerSection({ header, children, state, onStateChange }) {
return _.isFunction(onStateChange) ? (
<ControlledDrawerSection
header={header}
state={state}
onStateChange={onStateChange}
>
{children}
</ControlledDrawerSection>
) : (
<UncontrolledDrawerSection header={header} initialState={state}>
{children}
</UncontrolledDrawerSection>
);
}
UncontrolledDrawerSection.propTypes = {
header: PropTypes.node.isRequired,
children: PropTypes.node,
initialState: PropTypes.oneOf([STATES.closed, STATES.open]),
};
function UncontrolledDrawerSection({ header, children, initialState }) {
const [state, setState] = useState(initialState);
return (
<ControlledDrawerSection
header={header}
state={state}
onStateChange={setState}
>
{children}
</ControlledDrawerSection>
);
}
ControlledDrawerSection.propTypes = {
header: PropTypes.node.isRequired,
children: PropTypes.node,
state: PropTypes.oneOf([STATES.closed, STATES.open]),
onStateChange: PropTypes.func.isRequired,
};
function ControlledDrawerSection({ header, children, state, onStateChange }) {
const isOpen = state === STATES.open;
const toggleState = () => {
if (state === STATES.open) {
onStateChange(STATES.closed);
} else {
onStateChange(STATES.open);
}
};
return (
<Container isOpen={isOpen}>
<Transformer isOpen={isOpen}>
<Header
isOpen={isOpen}
onClick={toggleState}
onKeyDown={e => e.key === "Enter" && toggleState()}
>
{header}
<Icon
className="mr1"
name={isOpen ? "chevrondown" : "chevronup"}
size={12}
/>
</Header>
<Children isOpen={isOpen}>{children}</Children>
</Transformer>
</Container>
);
}
export default DrawerSection;
import styled from "@emotion/styled";
import { color } from "metabase/lib/colors";
const HEADER_HEIGHT = "49px";
export const Container = styled.div`
min-height: ${HEADER_HEIGHT};
height: ${props => (props.isOpen ? "auto" : "100%")};
overflow: ${props => (props.isOpen ? "unset" : "hidden")};
position: relative;
width: 100%;
`;
export const Transformer = styled.div`
height: 100%;
position: relative;
width: 100%;
will-change: transform;
transform: ${props =>
props.isOpen
? "translateY(0)"
: `translateY(calc(100% - ${HEADER_HEIGHT}))`};
transition: transform 0.2s ease-in-out;
`;
export const Children = styled.div`
display: ${props => (props.isOpen ? "block" : "none")};
padding: 0 1.5rem;
`;
export const Header = styled.div`
height: ${HEADER_HEIGHT};
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between;
border-top: 1px solid ${color("border")};
font-weight: 700;
padding: 0 1.5rem;
&:hover {
color: ${color("brand")};
}
`;
Header.defaultProps = {
role: "button",
tabIndex: "0",
};
import React from "react";
import PropTypes from "prop-types";
export default function EditWarning({ title }) {
if (title) {
return (
<div className="EditHeader wrapper py1 flex align-center">
<span className="EditHeader-title">{title}</span>
</div>
);
} else {
return null;
}
}
EditWarning.propTypes = {
title: PropTypes.string.isRequired,
};
import React, { Component } from "react";
import PropTypes from "prop-types";
const Expandable = ComposedComponent =>
class extends Component {
static displayName =
"Expandable[" +
(ComposedComponent.displayName || ComposedComponent.name) +
"]";
constructor(props, context) {
super(props, context);
this.state = {
expanded: false,
};
this.expand = () => this.setState({ expanded: true });
}
static propTypes = {
items: PropTypes.array.isRequired,
initialItemLimit: PropTypes.number.isRequired,
};
static defaultProps = {
initialItemLimit: 4,
};
render() {
let { expanded } = this.state;
let { items, initialItemLimit } = this.props;
if (items.length > initialItemLimit && !expanded) {
items = items.slice(0, initialItemLimit - 1);
}
expanded = items.length >= this.props.items.length;
return (
<ComposedComponent
{...this.props}
isExpanded={expanded}
onExpand={this.expand}
items={items}
/>
);
}
};
export default Expandable;
import React, { Component } from "react";
import PropTypes from "prop-types";
import { t } from "ttag";
import moment from "moment-timezone";
import ActionButton from "metabase/components/ActionButton";
import ModalContent from "metabase/components/ModalContent";
import {
isValidRevision,
getRevisionDescription,
} from "metabase/lib/revisions";
function formatDate(date) {
const m = moment(date);
if (m.isSame(moment(), "day")) {
return t`Today, ` + m.format("h:mm a");
} else if (m.isSame(moment().subtract(1, "day"), "day")) {
return t`Yesterday, ` + m.format("h:mm a");
} else {
return m.format("MMM D YYYY, h:mm a");
}
}
export default class HistoryModal extends Component {
static propTypes = {
revisions: PropTypes.array,
onRevert: PropTypes.func,
onClose: PropTypes.func.isRequired,
};
render() {
const { revisions, onRevert, onClose } = this.props;
const cellClassName = "p1 border-bottom";
return (
<ModalContent title={t`Revision history`} onClose={onClose}>
<table className="full">
<thead>
<tr>
<th className={cellClassName}>{t`When`}</th>
<th className={cellClassName}>{t`Who`}</th>
<th className={cellClassName}>{t`What`}</th>
<th className={cellClassName} />
</tr>
</thead>
<tbody>
{revisions.filter(isValidRevision).map((revision, index) => (
<tr key={revision.id} data-testid="revision-history-row">
<td className={cellClassName}>
{formatDate(revision.timestamp)}
</td>
<td className={cellClassName}>{revision.user.common_name}</td>
<td className={cellClassName}>
<span>{getRevisionDescription(revision)}</span>
</td>
<td className={cellClassName}>
{index !== 0 && (
<ActionButton
actionFn={() => onRevert(revision)}
className="Button Button--small Button--danger text-uppercase"
normalText={t`Revert`}
activeText={t`Reverting…`}
failedText={t`Revert failed`}
successText={t`Reverted`}
/>
)}
</td>
</tr>
))}
</tbody>
</table>
</ModalContent>
);
}
}
import React from "react";
import { fireEvent, render, screen } from "@testing-library/react";
import HistoryModal from "./HistoryModal";
function getRevision({
isCreation = false,
isReversion = false,
userName = "John",
timestamp = "2016-05-08T02:02:07.441Z",
...rest
} = {}) {
return {
id: Math.random(),
is_reversion: isReversion,
is_creation: isCreation,
user: {
common_name: userName,
},
timestamp,
diff: null,
...rest,
};
}
function getSimpleChangeRevision({ field, before, after, ...rest }) {
return getRevision({
...rest,
diff: {
before: {
[field]: before,
},
after: {
[field]: after,
},
},
});
}
const CHANGE_EVENT_REVISION = getSimpleChangeRevision({
field: "archived",
before: false,
after: true,
description: 'changed archived from "false" to "true"',
});
const REVISIONS = [
getSimpleChangeRevision({ isReversion: true }),
CHANGE_EVENT_REVISION,
getSimpleChangeRevision({
field: "description",
before: null,
after: "Very helpful dashboard",
description: 'changed description from "null" to "Very helpful dashboard"',
}),
getRevision({ isCreation: true }),
];
function setup({ revisions = REVISIONS } = {}) {
const onRevert = jest.fn().mockResolvedValue({});
const onClose = jest.fn();
render(
<HistoryModal
revisions={revisions}
onRevert={onRevert}
onClose={onClose}
/>,
);
return { onRevert, onClose };
}
describe("HistoryModal", () => {
it("displays revisions", () => {
setup();
expect(screen.getByText("created this")).toBeInTheDocument();
expect(screen.getByText("added a description")).toBeInTheDocument();
expect(screen.getByText("archived this")).toBeInTheDocument();
expect(
screen.getByText("reverted to an earlier revision"),
).toBeInTheDocument();
expect(screen.getAllByTestId("revision-history-row")).toHaveLength(4);
});
it("does not display invalid revisions", () => {
setup({
revisions: [getRevision({ diff: { before: null, after: null } })],
});
expect(
screen.queryByTestId("revision-history-row"),
).not.toBeInTheDocument();
});
it("calls onClose when close icon is clicked", () => {
const { onClose } = setup();
fireEvent.click(screen.queryByLabelText("close icon"));
expect(onClose).toHaveBeenCalledTimes(1);
});
it("calls onRevert with a revision object when Revert button is clicked", () => {
const { onRevert } = setup({
revisions: [getRevision({ isCreation: true }), CHANGE_EVENT_REVISION],
});
fireEvent.click(screen.queryByRole("button", { name: "Revert" }));
expect(onRevert).toHaveBeenCalledTimes(1);
expect(onRevert).toHaveBeenCalledWith(CHANGE_EVENT_REVISION);
});
});
/* eslint-disable react/prop-types */
import React from "react";
import _ from "underscore";
import { MetabaseApi, AutoApi } from "metabase/services";
const CANDIDATES_POLL_INTERVAL = 2000;
// ensure this is 1 second offset from CANDIDATES_POLL_INTERVAL due to
// concurrency issue in candidates endpoint
const CANDIDATES_TIMEOUT = 11000;
class CandidateListLoader extends React.Component {
state = {
databaseId: null,
isSample: null,
candidates: null,
sampleCandidates: null,
};
async UNSAFE_componentWillMount() {
// If we get passed in a database id, just use that.
// Don't fall back to the sample database
if (this.props.databaseId) {
this.setState({ databaseId: this.props.databaseId }, () => {
this._loadCandidates();
});
} else {
// Otherwise, it's a fresh start. Grab the last added database
const [sampleDbs, otherDbs] = _.partition(
await MetabaseApi.db_list(),
db => db.is_sample,
);
if (otherDbs.length > 0) {
this.setState({ databaseId: otherDbs[0].id, isSample: false }, () => {
this._loadCandidates();
});
// If things are super slow for whatever reason,
// just load candidates for sample database
this._sampleTimeout = setTimeout(async () => {
this._sampleTimeout = null;
this.setState({
sampleCandidates: await AutoApi.db_candidates({
id: sampleDbs[0].id,
}),
});
}, CANDIDATES_TIMEOUT);
} else {
this.setState({ databaseId: sampleDbs[0].id, isSample: true }, () => {
this._loadCandidates();
});
}
}
this._pollTimer = setInterval(
this._loadCandidates,
CANDIDATES_POLL_INTERVAL,
);
}
componentWillUnmount() {
this._clearTimers();
}
_clearTimers() {
if (this._pollTimer != null) {
clearInterval(this._pollTimer);
this._pollTimer = null;
}
if (this._sampleTimeout != null) {
clearInterval(this._sampleTimeout);
this._sampleTimeout = null;
}
}
_loadCandidates = async () => {
try {
const { databaseId } = this.state;
if (databaseId != null) {
const database = await MetabaseApi.db_get({
dbId: databaseId,
});
const candidates = await AutoApi.db_candidates({
id: databaseId,
});
if (candidates && candidates.length > 0) {
this._clearTimers();
this.setState({ candidates, isSample: database.is_sample });
}
}
} catch (e) {
console.log(e);
}
};
render() {
const { candidates, sampleCandidates, isSample } = this.state;
return this.props.children({
candidates,
sampleCandidates,
isSample,
});
}
}
export default CandidateListLoader;
/* eslint-disable react/prop-types */
import React from "react";
import PropTypes from "prop-types";
import HistoryModal from "metabase/components/HistoryModal";
import Revision from "metabase/entities/revisions";
class HistoryModalContainer extends React.Component {
static propTypes = {
canRevert: PropTypes.bool.isRequired,
};
onRevert = async revision => {
const { onReverted, reload } = this.props;
await revision.revert();
if (onReverted) {
onReverted();
}
await reload();
};
render() {
const { revisions, canRevert, onClose } = this.props;
return (
<HistoryModal
revisions={revisions}
onRevert={canRevert ? this.onRevert : null}
onClose={onClose}
/>
);
}
}
export default Revision.loadList({
query: (state, props) => ({
model_type: props.modelType,
model_id: props.modelId,
}),
wrapped: true,
})(HistoryModalContainer);
/* eslint-disable react/prop-types */
import React from "react";
import QuestionLoader from "metabase/containers/QuestionLoader";
import QuestionResultLoader from "metabase/containers/QuestionResultLoader";
/*
* QuestionAndResultLoader
*
* Load a question and also run the query to get the result. Useful when you want
* to load both a question and its visualization at the same time.
*
* @example
*
* import QuestionAndResultLoader from 'metabase/containers/QuestionAndResultLoader'
*
* const MyNewFeature = ({ params, location }) =>
* <QuestionAndResultLoader question={question}>
* { ({ question, result, cancel, reload }) =>
* <div>
* </div>
* </QuestionAndResultLoader>
*
*/
const QuestionAndResultLoader = ({ questionId, questionHash, children }) => (
<QuestionLoader questionId={questionId} questionHash={questionHash}>
{({ loading: questionLoading, error: questionError, ...questionProps }) => (
<QuestionResultLoader question={questionProps.question}>
{({ loading: resultLoading, error: resultError, ...resultProps }) =>
children &&
children({
...questionProps,
...resultProps,
loading: resultLoading || questionLoading,
error: resultError || questionError,
})
}
</QuestionResultLoader>
)}
</QuestionLoader>
);
export default QuestionAndResultLoader;
/* eslint-disable react/prop-types */
import React from "react";
import Question from "metabase/entities/questions";
// TODO: remove this in favor of using Question.Name directly
const QuestionName = ({ questionId }) => <Question.Name id={questionId} />;
export default QuestionName;
import ItemSelect from "./ItemSelect";
import React from "react";
import Question from "metabase/entities/questions";
import type { CardId } from "metabase-types/api";
import ItemSelect from "./ItemSelect";
import QuestionPicker from "./QuestionPicker";
import QuestionName from "./QuestionName";
const QuestionName = ({ questionId }: { questionId: CardId }) => (
<Question.Name id={questionId} />
);
const QuestionSelect = ItemSelect(QuestionPicker, QuestionName, "question");
......
......@@ -72,6 +72,13 @@ export const HeaderLastEditInfoLabel = styled(LastEditInfoLabel)`
}
`;
export const EditWarning = styled.div`
display: flex;
align-items: center;
padding-top: 0.5rem;
padding-bottom: 0.5rem;
`;
interface HeaderContentProps {
showSubHeader: boolean;
hasSubHeader: boolean;
......
......@@ -14,9 +14,9 @@ import { getScrollY } from "metabase/lib/dom";
import { Dashboard } from "metabase-types/api";
import EditBar from "metabase/components/EditBar";
import EditWarning from "metabase/components/EditWarning";
import HeaderModal from "metabase/components/HeaderModal";
import {
EditWarning,
HeaderRoot,
HeaderBadges,
HeaderContent,
......@@ -122,7 +122,11 @@ const DashboardHeader = ({
buttons={editingButtons}
/>
)}
{editWarning && <EditWarning title={editWarning} />}
{editWarning && (
<EditWarning className="wrapper">
<span>{editWarning}</span>
</EditWarning>
)}
<HeaderModal
isOpen={!!headerModalMessage}
height={headerHeight}
......
/* eslint-disable react/prop-types */
import React from "react";
import { Route } from "react-router";
import QuestionAndResultLoader from "metabase/containers/QuestionAndResultLoader";
import Visualization from "metabase/visualizations/components/Visualization";
export default class QuestionApp extends React.Component {
render() {
const { location, params } = this.props;
if (!location.hash && !params.questionId) {
return (
<div className="p4 text-centered flex-full">
Visit <strong>/_internal/question/:id</strong> or{" "}
<strong>/_internal/question#:hash</strong>.
</div>
);
}
return (
<div style={{ height: 500 }}>
<QuestionAndResultLoader
questionHash={location.hash}
questionId={params.questionId ? parseInt(params.questionId) : null}
>
{({ question, rawSeries }) =>
rawSeries && (
<Visualization className="flex-full" rawSeries={rawSeries} />
)
}
</QuestionAndResultLoader>
</div>
);
}
}
QuestionApp.routes = (
<React.Fragment>
<Route path="question" component={QuestionApp} />
<Route path="question/:questionId" component={QuestionApp} />
</React.Fragment>
);
......@@ -20,7 +20,6 @@ import CollectionMoveModal from "metabase/containers/CollectionMoveModal";
import ArchiveQuestionModal from "metabase/questions/containers/ArchiveQuestionModal";
import QuestionEmbedWidget from "metabase/query_builder/containers/QuestionEmbedWidget";
import QuestionHistoryModal from "metabase/query_builder/containers/QuestionHistoryModal";
import { CreateAlertModalContent } from "metabase/query_builder/components/AlertModals";
import { ImpossibleToCreateModelModal } from "metabase/query_builder/components/ImpossibleToCreateModelModal";
import NewDatasetModal from "metabase/query_builder/components/NewDatasetModal";
......@@ -186,17 +185,6 @@ class QueryModals extends React.Component {
onClose={onCloseModal}
/>
</Modal>
) : modal === MODAL_TYPES.HISTORY ? (
<Modal onClose={onCloseModal}>
<QuestionHistoryModal
questionId={this.props.card.id}
onClose={onCloseModal}
onReverted={() => {
this.props.reloadCard();
onCloseModal();
}}
/>
</Modal>
) : modal === MODAL_TYPES.MOVE ? (
<Modal onClose={onCloseModal}>
<CollectionMoveModal
......
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