-
Anton Kulyk authoredAnton Kulyk authored
Code owners
Assign users and groups as approvers for specific file changes. Learn more.
GTAPModal.jsx 14.99 KiB
/* eslint-disable react/prop-types */
import React from "react";
import _ from "underscore";
import { jt, t } from "ttag";
import { withRouter } from "react-router";
import { connect } from "react-redux";
import { push } from "react-router-redux";
import MappingEditor from "./MappingEditor";
import QuestionPicker from "metabase/containers/QuestionPicker";
import QuestionParameterTargetWidget from "../containers/QuestionParameterTargetWidget";
import Button from "metabase/core/components/Button";
import ActionButton from "metabase/components/ActionButton";
import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper";
import Select, { Option } from "metabase/core/components/Select";
import Radio from "metabase/core/components/Radio";
import Icon from "metabase/components/Icon";
import Tooltip from "metabase/components/Tooltip";
import { GTAPApi } from "metabase/services";
import EntityObjectLoader from "metabase/entities/containers/EntityObjectLoader";
import QuestionLoader from "metabase/containers/QuestionLoader";
import Dimension from "metabase-lib/lib/Dimension";
import { getParentPath } from "metabase/hoc/ModalRoute";
import { updateTableSandboxingPermission } from "../actions";
const mapStateToProps = () => ({});
const mapDispatchToProps = {
push,
updateTableSandboxingPermission,
};
@withRouter
@connect(mapStateToProps, mapDispatchToProps)
export default class GTAPModal extends React.Component {
state = {
gtap: null,
attributesOptions: null,
simple: true,
error: null,
};
async UNSAFE_componentWillMount() {
const { params } = this.props;
GTAPApi.attributes().then(attributesOptions =>
this.setState({ attributesOptions }),
);
const groupId = parseInt(params.groupId);
const tableId = parseInt(params.tableId);
const gtaps = await GTAPApi.list();
let gtap = _.findWhere(gtaps, { group_id: groupId, table_id: tableId });
if (!gtap) {
gtap = {
table_id: tableId,
group_id: groupId,
card_id: null,
attribute_remappings: { "": null },
};
}
if (Object.keys(gtap.attribute_remappings).length === 0) {
gtap.attribute_remappings = { "": null };
}
this.setState({ gtap, simple: gtap.card_id == null });
}
close = () => {
const { push, route, location } = this.props;
return push(getParentPath(route, location));
};
_getCanonicalGTAP() {
const { gtap, simple } = this.state;
if (!gtap) {
return null;
}
return {
...gtap,
card_id: simple ? null : gtap.card_id,
attribute_remappings: _.pick(
gtap.attribute_remappings,
(value, key) => value && key,
),
};
}
save = async () => {
const gtap = this._getCanonicalGTAP();
if (!gtap) {
throw new Error("No GTAP");
}
try {
if (gtap.id != null) {
await GTAPApi.update(gtap);
} else {
await GTAPApi.create(gtap);
}
this.props.updateTableSandboxingPermission(this.props.params);
this.close();
} catch (error) {
console.error("Error saving GTAP", error);
const message = error
? error.data
? error.data.message || JSON.stringify(error.data)
: JSON.stringify(error)
: t`Unknown error encountered`;
this.setState({ error: message });
throw new Error(message);
}
};
isValid() {
const gtap = this._getCanonicalGTAP();
const { simple } = this.state;
if (!gtap) {
return false;
} else if (simple) {
return Object.entries(gtap.attribute_remappings).length > 0;
} else {
return gtap.card_id != null;
}
}
render() {
const { gtap, simple, attributesOptions } = this.state;
const valid = this.isValid();
const canonicalGTAP = this._getCanonicalGTAP();
const remainingAttributesOptions =
gtap && attributesOptions
? attributesOptions.filter(
attribute => !(attribute in gtap.attribute_remappings),
)
: [];
const hasAttributesOptions =
attributesOptions && attributesOptions.length > 0;
const hasValidMappings =
Object.keys((canonicalGTAP || {}).attribute_remappings || {}).length > 0;
return (
<div>
<h2 className="p3">{t`Grant sandboxed access to this table`}</h2>
<LoadingAndErrorWrapper loading={!gtap}>
{() =>
gtap && (
<div>
<div className="px3 pb3">
<div className="pb3">
{t`When users in this group view this table they'll see a version of it that's filtered by their user attributes, or a custom view of it based on a saved question.`}
</div>
<h4 className="pb1">
{t`How do you want to filter this table for users in this group?`}
</h4>
<Radio
value={simple}
options={[
{ name: "Filter by a column in the table", value: true },
{
name:
"Use a saved question to create a custom view for this table",
value: false,
},
]}
onChange={simple => this.setState({ simple })}
vertical
/>
</div>
{!simple && (
<div className="px3 pb3">
<div className="pb2">
{t`Pick a saved question that returns the custom view of this table that these users should see.`}
</div>
<QuestionPicker
value={gtap.card_id}
onChange={card_id =>
this.setState({ gtap: { ...gtap, card_id } })
}
/>
</div>
)}
{gtap &&
attributesOptions &&
// show if in simple mode, or the admin has selected a card
(simple || gtap.card_id != null) &&
(hasAttributesOptions || hasValidMappings ? (
<div className="p3 border-top border-bottom">
{!simple && (
<div className="pb2">
{t`You can optionally add additional filters here based on user attributes. These filters will be applied on top of any filters that are already in this saved question.`}
</div>
)}
<AttributeMappingEditor
value={gtap.attribute_remappings}
onChange={attribute_remappings =>
this.setState({
gtap: { ...gtap, attribute_remappings },
})
}
simple={simple}
gtap={gtap}
attributesOptions={remainingAttributesOptions}
/>
</div>
) : (
<div className="px3">
<AttributeOptionsEmptyState
title={
simple
? t`For this option to work, your users need to have some attributes`
: t`To add additional filters, your users need to have some attributes`
}
/>
</div>
))}
</div>
)
}
</LoadingAndErrorWrapper>
<div className="p3">
{valid && canonicalGTAP && (
<div className="pb1">
<GTAPSummary gtap={canonicalGTAP} />
</div>
)}
<div className="flex align-center justify-end">
<Button onClick={this.close}>{t`Cancel`}</Button>
<ActionButton
className="ml1"
actionFn={this.save}
primary
disabled={!valid}
>
{t`Save`}
</ActionButton>
</div>
{this.state.error && (
<div className="flex align-center my2 text-error">
{this.state.error}
</div>
)}
</div>
</div>
);
}
}
const AttributePicker = ({ value, onChange, attributesOptions }) => (
<div style={{ minWidth: 200 }}>
<Select
value={value}
onChange={e => onChange(e.target.value)}
placeholder={
attributesOptions.length === 0
? t`No user attributes`
: t`Pick a user attribute`
}
disabled={attributesOptions.length === 0}
>
{attributesOptions.map(attributesOption => (
<Option key={attributesOption} value={attributesOption}>
{attributesOption}
</Option>
))}
</Select>
</div>
);
const QuestionTargetPicker = ({ value, onChange, questionId }) => (
<div style={{ minWidth: 200 }}>
<QuestionParameterTargetWidget
questionId={questionId}
target={value}
onChange={onChange}
placeholder={t`Pick a parameter`}
/>
</div>
);
const rawDataQuestionForTable = tableId => ({
dataset_query: {
type: "query",
query: { "source-table": tableId },
},
});
const TableTargetPicker = ({ value, onChange, tableId }) => (
<div style={{ minWidth: 200 }}>
<QuestionParameterTargetWidget
questionObject={rawDataQuestionForTable(tableId)}
target={value}
onChange={onChange}
placeholder={t`Pick a column`}
/>
</div>
);
const SummaryRow = ({ icon, content }) => (
<div className="flex align-center">
<Icon className="p1" name={icon} />
<span>{content}</span>
</div>
);
const GTAPSummary = ({ gtap }) => {
return (
<div>
<div className="px1 pb2 text-uppercase text-small text-grey-4">
Summary
</div>
<SummaryRow
icon="group"
content={jt`Users in ${(
<GroupName groupId={gtap.group_id} />
)} can view`}
/>
<SummaryRow
icon="table"
content={
gtap.card_id
? jt`rows in the ${(
<QuestionName questionId={gtap.card_id} />
)} question`
: jt`rows in the ${(<TableName tableId={gtap.table_id} />)} table`
}
/>
{Object.entries(gtap.attribute_remappings).map(
([attribute, target], index) => (
<SummaryRow
key={attribute}
icon="funneloutline"
content={
index === 0
? jt`where ${(
<TargetName gtap={gtap} target={target} />
)} equals ${(<span className="text-code">{attribute}</span>)}`
: jt`and ${(
<TargetName gtap={gtap} target={target} />
)} equals ${(<span className="text-code">{attribute}</span>)}`
}
/>
),
)}
</div>
);
};
// TODO: use EntityName component
const GroupName = ({ groupId }) => (
<EntityObjectLoader
entityType="groups"
entityId={groupId}
properties={["name"]}
loadingAndErrorWrapper={false}
>
{({ object }) => <strong>{object && object.name}</strong>}
</EntityObjectLoader>
);
// TODO: use EntityName component
const QuestionName = ({ questionId }) => (
<EntityObjectLoader
entityType="questions"
entityId={questionId}
properties={["name"]}
loadingAndErrorWrapper={false}
>
{({ object }) => <strong>{object && object.name}</strong>}
</EntityObjectLoader>
);
// TODO: use EntityName component
const TableName = ({ tableId }) => (
<EntityObjectLoader
entityType="tables"
entityId={tableId}
properties={["display_name"]}
loadingAndErrorWrapper={false}
>
{({ object }) => <strong>{object && object.display_name}</strong>}
</EntityObjectLoader>
);
const TargetName = ({ gtap, target }) => {
if (Array.isArray(target)) {
if (
(target[0] === "variable" || target[0] === "dimension") &&
target[1][0] === "template-tag"
) {
return (
<span>
<strong>{target[1][1]}</strong> variable
</span>
);
} else if (target[0] === "dimension") {
return (
<QuestionLoader
questionId={gtap.card_id}
questionObject={
gtap.card_id == null ? rawDataQuestionForTable(gtap.table_id) : null
}
>
{({ question }) =>
question && (
<span>
<strong>
{Dimension.parseMBQL(target[1], question.metadata()).render()}
</strong>{" "}
field
</span>
)
}
</QuestionLoader>
);
}
}
return <emphasis>[Unknown target]</emphasis>;
};
const AttributeOptionsEmptyState = ({ title }) => (
<div className="flex align-center rounded bg-slate-extra-light p2">
<img
src="app/assets/img/attributes_illustration.png"
srcSet="
app/assets/img/attributes_illustration.png 1x,
app/assets/img/attributes_illustration@2x.png 2x,
"
className="mr2"
/>
<div>
<h3 className="pb1">{title}</h3>
<div>{t`You can add attributes automatically by setting up an SSO that uses SAML, or you can enter them manually by going to the People section and clicking on the … menu on the far right.`}</div>
</div>
</div>
);
const AttributeMappingEditor = ({
value,
onChange,
simple,
attributesOptions,
gtap,
}) => (
<MappingEditor
style={{ width: "100%" }}
value={value}
onChange={onChange}
keyPlaceholder={t`Pick a user attribute`}
keyHeader={
<div className="text-uppercase text-small text-grey-4 flex align-center">
{t`User attribute`}
<Tooltip
tooltip={t`We can automatically get your users’ attributes if you’ve set up SSO, or you can add them manually from the "…" menu in the People section of the Admin Panel.`}
>
<Icon className="ml1" name="info_outline" />
</Tooltip>
</div>
}
renderKeyInput={({ value, onChange }) => (
<AttributePicker
value={value}
onChange={onChange}
attributesOptions={(value ? [value] : []).concat(attributesOptions)}
/>
)}
render
valuePlaceholder={simple ? t`Pick a column` : t`Pick a parameter`}
valueHeader={
<div className="text-uppercase text-small text-grey-4">
{simple ? t`Column` : t`Parameter or variable`}
</div>
}
renderValueInput={({ value, onChange }) =>
simple && gtap.table_id != null ? (
<TableTargetPicker
value={value}
onChange={onChange}
tableId={gtap.table_id}
/>
) : !simple && gtap.card_id != null ? (
<QuestionTargetPicker
value={value}
onChange={onChange}
questionId={gtap.card_id}
/>
) : null
}
divider={<span className="px2 text-bold">{t`equals`}</span>}
addText={t`Add a filter`}
canAdd={attributesOptions.length > 0}
canDelete={true}
swapKeyAndValue
/>
);