Skip to content
Snippets Groups Projects
Unverified Commit b769e0e2 authored by Tom Robinson's avatar Tom Robinson Committed by GitHub
Browse files

Merge pull request #7936 from metabase/nested-collection-item-pickers

Nested collection item pickers [WIP]
parents 671e9961 e32e2cd8
No related branches found
No related tags found
No related merge requests found
Showing
with 186 additions and 290 deletions
......@@ -40,6 +40,8 @@ import type {
} from "metabase/meta/types/Card";
import { MetabaseApi, CardApi } from "metabase/services";
import Questions from "metabase/entities/questions";
import AtomicQuery from "metabase-lib/lib/queries/AtomicQuery";
import type { Dataset } from "metabase/meta/types/Dataset";
......@@ -471,16 +473,30 @@ export default class Question {
}
}
// NOTE: prefer `reduxCreate` so the store is automatically updated
async apiCreate() {
const createdCard = await CardApi.create(this.card());
const createdCard = await Questions.api.create(this.card());
return this.setCard(createdCard);
}
// NOTE: prefer `reduxUpdate` so the store is automatically updated
async apiUpdate() {
const updatedCard = await CardApi.update(this.card());
const updatedCard = await Questions.api.update(this.card());
return this.setCard(updatedCard);
}
async reduxCreate(dispatch) {
const { payload } = await dispatch(Questions.actions.create(this.card()));
return this.setCard(payload.entities.questions[payload.result]);
}
async reduxUpdate(dispatch) {
const { payload } = await dispatch(
Questions.actions.update({ id: this.id() }, this.card()),
);
return this.setCard(payload.entities.questions[payload.result]);
}
// TODO: Fix incorrect Flow signature
parameters(): ParameterObject[] {
return getParametersWithExtras(this.card(), this._parameterValues);
......
......@@ -663,6 +663,7 @@ export const getDatabasesPermissionsGrid = createSelector(
import Collections from "metabase/entities/collections";
const getCollectionId = (state, props) => props && props.collectionId;
const getSingleCollectionPermissionsMode = (state, props) =>
(props && props.singleCollectionMode) || false;
......@@ -675,10 +676,14 @@ const getCollections = createSelector(
(collectionsById, collectionId, singleMode) => {
if (collectionId && collectionsById[collectionId]) {
if (singleMode) {
// pass the `singleCollectionMode=true` prop when we just want to show permissions for the provided collection, and not it's subcollections
return [collectionsById[collectionId]];
} else {
return collectionsById[collectionId].children;
return collectionsById[collectionId].children.filter(
collection => !collection.is_personal,
);
}
// default to root collection
} else if (collectionsById["root"]) {
return [collectionsById["root"]];
} else {
......
......@@ -9,16 +9,14 @@ export default class CollectionCreate extends Component {
render() {
const { params } = this.props;
const collectionId =
params && params.collectionId && parseFloat(params.collectionId);
params && params.collectionId != null && params.collectionId !== "root"
? parseInt(params.collectionId)
: null;
return (
<CollectionForm
collection={
collectionId != null
? {
parent_id: collectionId,
}
: null
}
collection={{
parent_id: collectionId,
}}
onSaved={() => this.props.goBack()}
onClose={this.props.goBack}
/>
......
......@@ -17,6 +17,7 @@
font-size: 0.75rem;
font-weight: bold;
text-transform: uppercase;
cursor: pointer;
}
:local(.breadcrumbDivider) {
......
......@@ -25,17 +25,18 @@ const mapDispatchToProps = {
export default class CreateDashboardModal extends Component {
constructor(props, context) {
super(props, context);
this.createNewDash = this.createNewDash.bind(this);
this.setDescription = this.setDescription.bind(this);
this.setName = this.setName.bind(this);
this.state = {
name: null,
description: null,
errors: null,
// collectionId in the url starts off as a string, but the select will
// compare it to the integer ID on colleciton objects
collection_id: parseInt(props.params.collectionId),
collection_id:
props.collectionId != null
? props.collectionId
: props.params.collectionId != null &&
props.params.collectionId !== "root"
? parseInt(props.params.collectionId)
: null,
};
}
......@@ -44,15 +45,15 @@ export default class CreateDashboardModal extends Component {
onClose: PropTypes.func,
};
setName(event) {
setName = event => {
this.setState({ name: event.target.value });
}
};
setDescription(event) {
setDescription = event => {
this.setState({ description: event.target.value });
}
};
async createNewDash(event) {
createNewDash = async event => {
event.preventDefault();
let name = this.state.name && this.state.name.trim();
......@@ -68,7 +69,7 @@ export default class CreateDashboardModal extends Component {
const { payload } = await this.props.createDashboard(newDash);
this.props.push(Urls.dashboard(payload.result));
this.props.onClose();
}
};
render() {
let formError;
......
......@@ -8,6 +8,7 @@ import { t } from "c-3po";
import ColumnarSelector from "metabase/components/ColumnarSelector.jsx";
import Icon from "metabase/components/Icon.jsx";
import PopoverWithTrigger from "metabase/components/PopoverWithTrigger.jsx";
import SelectButton from "./SelectButton";
import cx from "classnames";
import _ from "underscore";
......@@ -207,27 +208,6 @@ class BrowserSelect extends Component {
}
}
export const SelectButton = ({ hasValue, children }) => (
<div
className={
"AdminSelect border-med flex align-center " +
(!hasValue ? " text-grey-3" : "")
}
>
<span className="AdminSelect-content mr1">{children}</span>
<Icon
className="AdminSelect-chevron flex-align-right"
name="chevrondown"
size={12}
/>
</div>
);
SelectButton.propTypes = {
hasValue: PropTypes.bool,
children: PropTypes.any,
};
export class Option extends Component {
static propTypes = {
children: PropTypes.any,
......
......@@ -6,8 +6,9 @@ import Icon from "metabase/components/Icon.jsx";
import cx from "classnames";
const SelectButton = ({ className, children, hasValue = true }) => (
const SelectButton = ({ className, style, children, hasValue = true }) => (
<div
style={style}
className={cx(className, "AdminSelect flex align-center", {
"text-grey-3": !hasValue,
})}
......@@ -23,6 +24,7 @@ const SelectButton = ({ className, children, hasValue = true }) => (
SelectButton.propTypes = {
className: PropTypes.string,
style: PropTypes.object,
children: PropTypes.any,
hasValue: PropTypes.any,
};
......
......@@ -14,7 +14,10 @@ export default class FormField extends Component {
hidden: PropTypes.bool,
displayName: PropTypes.string,
children: PropTypes.element,
children: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.node),
PropTypes.node,
]),
// legacy
fieldName: PropTypes.string,
......
......@@ -5,6 +5,7 @@ import FormTextAreaWidget from "./widgets/FormTextAreaWidget";
import FormPasswordWidget from "./widgets/FormPasswordWidget";
import FormColorWidget from "./widgets/FormColorWidget";
import FormSelectWidget from "./widgets/FormSelectWidget";
import FormCollectionWidget from "./widgets/FormCollectionWidget";
import FormHiddenWidget from "./widgets/FormHiddenWidget";
const WIDGETS = {
......@@ -13,6 +14,7 @@ const WIDGETS = {
color: FormColorWidget,
password: FormPasswordWidget,
select: FormSelectWidget,
collection: FormCollectionWidget,
hidden: FormHiddenWidget,
};
......
......@@ -19,7 +19,7 @@ const StandardForm = ({
handleSubmit,
resetForm,
form,
formDef: form,
className,
resetButton = false,
newForm = true,
......
import React from "react";
import CollectionSelect from "metabase/containers/CollectionSelect";
const FormCollectionWidget = ({ field }) => <CollectionSelect {...field} />;
export default FormCollectionWidget;
/* @flow */
import React, { Component } from "react";
import { connect } from "react-redux";
import { t } from "c-3po";
import CreateDashboardModal from "metabase/components/CreateDashboardModal.jsx";
import Icon from "metabase/components/Icon.jsx";
import ModalContent from "metabase/components/ModalContent.jsx";
import SortableItemList from "metabase/components/SortableItemList.jsx";
import * as Urls from "metabase/lib/urls";
import DashboardForm from "metabase/containers/DashboardForm.jsx";
import DashboardPicker from "metabase/containers/DashboardPicker";
import Dashboards from "metabase/entities/dashboards";
import * as Urls from "metabase/lib/urls";
import { t } from "c-3po";
import type { Dashboard, DashboardId } from "metabase/meta/types/Dashboard";
import type { Card } from "metabase/meta/types/Card";
const mapStateToProps = state => ({
dashboards: Dashboards.selectors.getList(state),
});
const mapDispatchToProps = {
fetchDashboards: Dashboards.actions.fetchList,
createDashboard: Dashboards.actions.create,
};
@connect(mapStateToProps, mapDispatchToProps)
export default class AddToDashSelectDashModal extends Component {
state = {
shouldCreateDashboard: false,
......@@ -35,15 +22,9 @@ export default class AddToDashSelectDashModal extends Component {
onClose: () => void,
onChangeLocation: string => void,
// via connect:
dashboards: Dashboard[],
fetchDashboards: () => any,
createDashboard: Dashboard => any,
};
componentWillMount() {
this.props.fetchDashboards();
}
addToDashboard = (dashboardId: DashboardId) => {
// we send the user over to the chosen dashboard in edit mode with the current card added
this.props.onChangeLocation(
......@@ -51,26 +32,12 @@ export default class AddToDashSelectDashModal extends Component {
);
};
createDashboard = async (newDashboard: Dashboard) => {
try {
const action = await this.props.createDashboard(newDashboard);
this.addToDashboard(action.payload.result);
} catch (e) {
console.log("createDashboard failed", e);
}
};
render() {
if (this.props.dashboards === null) {
return <div />;
} else if (
this.props.dashboards.length === 0 ||
this.state.shouldCreateDashboard === true
) {
if (this.state.shouldCreateDashboard) {
return (
<CreateDashboardModal
createDashboard={this.createDashboard}
onClose={this.props.onClose}
<DashboardForm
dashboard={{ collection_id: this.props.card.collection_id }}
onSaved={dashboard => this.addToDashboard(dashboard.id)}
/>
);
} else {
......@@ -80,24 +47,12 @@ export default class AddToDashSelectDashModal extends Component {
title={t`Add Question to Dashboard`}
onClose={this.props.onClose}
>
<div className="flex flex-column">
<div
className="link flex-align-right px4 cursor-pointer"
onClick={() => this.setState({ shouldCreateDashboard: true })}
>
<div
className="mt1 flex align-center absolute"
style={{ right: 40 }}
>
<Icon name="add" size={16} />
<h3 className="ml1">{t`Add to new dashboard`}</h3>
</div>
</div>
<SortableItemList
items={this.props.dashboards}
onClickItemFn={dashboard => this.addToDashboard(dashboard.id)}
/>
</div>
<DashboardPicker onChange={this.addToDashboard} />
<button
onClick={() => this.setState({ shouldCreateDashboard: true })}
>
Create New Dashboard
</button>
</ModalContent>
);
}
......
import React from "react";
import PropTypes from "prop-types";
import _ from "underscore";
import { t } from "c-3po";
import { Flex, Box } from "grid-styled";
......@@ -9,7 +8,6 @@ import Subhead from "metabase/components/Subhead";
import Button from "metabase/components/Button";
import Icon from "metabase/components/Icon";
import CollectionListLoader from "metabase/containers/CollectionListLoader";
import CollectionPicker from "metabase/containers/CollectionPicker";
class CollectionMoveModal extends React.Component {
......@@ -24,10 +22,7 @@ class CollectionMoveModal extends React.Component {
// null = root collection
// number = non-root collection id
//
selectedCollection:
props.initialCollectionId === undefined
? undefined
: { id: props.initialCollectionId },
selectedCollectionId: props.initialCollectionId,
// whether the move action has started
// TODO: use this loading and error state in the UI
moving: false,
......@@ -43,7 +38,7 @@ class CollectionMoveModal extends React.Component {
};
render() {
const { selectedCollection } = this.state;
const { selectedCollectionId } = this.state;
return (
<Box p={3}>
......@@ -55,29 +50,24 @@ class CollectionMoveModal extends React.Component {
onClick={() => this.props.onClose()}
/>
</Flex>
<CollectionListLoader>
{({ collections, loading, error }) => (
<CollectionPicker
value={selectedCollection && selectedCollection.id}
onChange={id =>
this.setState({
selectedCollection:
id == null ? null : _.find(collections, { id }),
})
}
collections={collections}
/>
)}
</CollectionListLoader>
<CollectionPicker
value={selectedCollectionId}
onChange={selectedCollectionId =>
this.setState({ selectedCollectionId })
}
/>
<Flex mt={2}>
<Button
primary
className="ml-auto"
disabled={selectedCollection === undefined}
disabled={
selectedCollectionId === undefined ||
selectedCollectionId === this.props.initialCollectionId
}
onClick={() => {
try {
this.setState({ moving: true });
this.props.onMove(selectedCollection);
this.props.onMove({ id: selectedCollectionId });
} catch (e) {
this.setState({ error: e });
} finally {
......
import React from "react";
import { entityObjectLoader } from "metabase/entities/containers/EntityObjectLoader";
import { ROOT_COLLECTION } from "metabase/entities/collections";
const CollectionNameLoader = entityObjectLoader({
entityType: "collections",
properties: ["name"],
loadingAndErrorWrapper: false,
})(({ object }) => <span>{object && object.name}</span>);
const CollectionName = ({ collectionId }) => {
if (collectionId === undefined || isNaN(collectionId)) {
return null;
} else if (collectionId === "root" || collectionId === null) {
return <span>{ROOT_COLLECTION.name}</span>;
} else {
return <CollectionNameLoader entityId={collectionId} />;
}
};
export default CollectionName;
import React from "react";
import PropTypes from "prop-types";
import cx from "classnames";
import { Flex, Box } from "grid-styled";
import Icon from "metabase/components/Icon";
import Breadcrumbs from "metabase/components/Breadcrumbs";
import { getExpandedCollectionsById } from "metabase/entities/collections";
import colors from "metabase/lib/colors";
const COLLECTION_ICON_COLOR = colors["text-light"];
const isRoot = collection => collection.id === "root" || collection.id == null;
const getCollectionValue = collection =>
collection.id === "root" ? null : collection.id;
export default class CollectionPicker extends React.Component {
constructor(props) {
super(props);
this.state = {
parentId: "root",
};
}
static propTypes = {
// undefined = no selection
// null = root collection
// number = non-root collection id
value: PropTypes.number,
};
// returns a list of "crumbs" starting with the "root" collection
_getCrumbs(collection, collectionsById) {
if (collection && collection.path) {
return [
...collection.path.map(id => [
collectionsById[id].name,
() => this.setState({ parentId: id }),
]),
[collection.name],
];
} else {
return [
[
collectionsById["root"].name,
() => this.setState({ parentId: collectionsById["root"].id }),
],
["Unknown"],
];
}
}
render() {
const { value, onChange, collections, style, className } = this.props;
const { parentId } = this.state;
const collectionsById = getExpandedCollectionsById(collections);
const collection = collectionsById[parentId];
const crumbs = this._getCrumbs(collection, collectionsById);
let items = (collection && collection.children) || [];
// show root in itself
if (collection && isRoot(collection)) {
items = [collection, ...items];
}
return (
<Box style={style} className={className}>
<Box pb={1} mb={2} className="border-bottom">
<Breadcrumbs crumbs={crumbs} />
</Box>
{items.map(collection => (
<Box
mt={1}
p={1}
onClick={() => onChange(getCollectionValue(collection))}
className={cx(
"bg-brand-hover text-white-hover cursor-pointer rounded",
{
"bg-brand text-white": value === getCollectionValue(collection),
},
)}
>
<Flex align="center">
<Icon name="all" color={COLLECTION_ICON_COLOR} size={32} />
<h4 className="mx1">{collection.name}</h4>
{collection.children.length > 0 &&
!isRoot(collection) && (
<Icon
name="chevronright"
className="p1 ml-auto circular text-grey-2 bordered bg-white-hover cursor-pointer"
onClick={e => {
e.stopPropagation();
this.setState({ parentId: collection.id });
}}
/>
)}
</Flex>
</Box>
))}
</Box>
);
}
}
import ItemPicker from "./ItemPicker";
const CollectionPicker = ({ value, onChange, ...props }) => (
<ItemPicker
{...props}
value={value === undefined ? undefined : { model: "collection", id: value }}
onChange={collection => onChange(collection ? collection.id : undefined)}
models={["collection"]}
/>
);
CollectionPicker.propTypes = {
// a collection ID or null (for "root" collection), or undefined if none selected
value: PropTypes.number,
// callback that takes a collection ID or null (for "root" collection)
onChange: PropTypes.func.isRequired,
};
export default CollectionPicker;
import React from "react";
import PropTypes from "prop-types";
import _ from "underscore";
import PopoverWithTrigger from "metabase/components/PopoverWithTrigger";
import CollectionListLoader from "metabase/containers/CollectionListLoader";
import { SelectButton } from "metabase/components/Select";
import ItemSelect from "./ItemSelect";
import CollectionPicker from "./CollectionPicker";
import { ROOT_COLLECTION } from "metabase/entities/collections";
import CollectionName from "./CollectionName";
export default class CollectionSelect extends React.Component {
static propTypes = {
field: PropTypes.object.isRequired,
// optional collectionId to filter out so you can't move a collection into itself
collectionId: PropTypes.number,
};
const QuestionSelect = ItemSelect(
CollectionPicker,
CollectionName,
"collection",
);
render() {
const { value, onChange, collectionId } = this.props;
return (
<CollectionListLoader>
{({ collections }) => (
<PopoverWithTrigger
triggerElement={
<SelectButton hasValue={value !== undefined}>
{(_.findWhere(collections, { id: value }) || {}).name ||
ROOT_COLLECTION.name}
</SelectButton>
}
sizeToFit
pinInitialAttachment
>
{({ onClose }) => (
<CollectionPicker
style={{ minWidth: 300 }}
className="p2 overflow-auto"
value={value}
onChange={value => {
onChange(value);
onClose();
}}
collections={
// don't want to allow moving a collection into itself, so filter it out
collectionId != null
? collections.filter(c => c.id != collectionId)
: collections
}
/>
)}
</PopoverWithTrigger>
)}
</CollectionListLoader>
);
}
}
export default QuestionSelect;
import React from "react";
import { t } from "c-3po";
import EntityForm from "metabase/entities/containers/EntityForm";
import ModalContent from "metabase/components/ModalContent";
const DashboardForm = ({ dashboard, onClose, ...props }) => (
<ModalContent
title={
dashboard && dashboard.id != null ? dashboard.name : t`New dashboard`
}
onClose={onClose}
>
<EntityForm entityType="dashboards" entityObject={dashboard} {...props} />
</ModalContent>
);
export default DashboardForm;
import React from "react";
import PropTypes from "prop-types";
import ItemPicker from "./ItemPicker";
const DashboardPicker = ({ value, onChange, ...props }) => (
<ItemPicker
{...props}
value={value === undefined ? undefined : { model: "dashboard", id: value }}
onChange={dashboard => onChange(dashboard ? dashboard.id : undefined)}
models={["dashboard"]}
/>
);
DashboardPicker.propTypes = {
// a dashboard ID or null
value: PropTypes.number,
// callback that takes a dashboard ID
onChange: PropTypes.func.isRequired,
};
export default DashboardPicker;
......@@ -45,6 +45,7 @@ type Props = {
initialValues?: ?FormValues,
formName?: string,
onSubmit: (values: FormValues) => Promise<any>,
formComponent?: React$Component<any, any, any>,
};
let FORM_ID = 0;
......@@ -69,7 +70,7 @@ export default class Form_ extends React.Component {
};
static defaultProps = {
children: StandardForm,
formComponent: StandardForm,
};
// dynamically generates a component decorated with reduxForm
......@@ -91,13 +92,17 @@ export default class Form_ extends React.Component {
const mapStateToProps = (state, ownProps) => {
const values = getValues(state.form[this._formName]);
if (values) {
return { ...formConfig, fields: form.fieldNames(values) };
return {
...formConfig,
fields: form.fieldNames(values),
formDef: form,
};
} else {
return formConfig;
return { ...formConfig, formDef: form };
}
};
this._FormComponent = reduxForm(formConfig, mapStateToProps)(
({ children, ...props }) => children({ ...props, form }),
props.formComponent,
);
}
}
......
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