Newer
Older
import cx from "classnames";
import type * as React from "react";
import { useState } from "react";
Aleksandr Lesnenko
committed
import { useAsyncFn } from "react-use";
import { jt, t } from "ttag";
import _ from "underscore";
Aleksandr Lesnenko
committed
import { useGetCardQuery, skipToken, useGetTableQuery } from "metabase/api";
import { QuestionPickerModal } from "metabase/common/components/QuestionPicker";
import ActionButton from "metabase/components/ActionButton";
import QuestionLoader from "metabase/containers/QuestionLoader";
Aleksandr Lesnenko
committed
import Radio from "metabase/core/components/Radio";
import CS from "metabase/css/core/index.css";
import { EntityName } from "metabase/entities/containers/EntityName";
import { useToggle } from "metabase/hooks/use-toggle";
import { GTAPApi } from "metabase/services";
import type { IconName } from "metabase/ui";
import { Icon, Button } from "metabase/ui";
Aleksandr Lesnenko
committed
GroupTableAccessPolicyDraft,
GroupTableAccessPolicyParams,
} from "metabase-enterprise/sandboxes/types";
import { getRawDataQuestionForTable } from "metabase-enterprise/sandboxes/utils";
import * as Lib from "metabase-lib";
import type Question from "metabase-lib/v1/Question";
import type {
GroupTableAccessPolicy,
Table,
UserAttribute,
} from "metabase-types/api";
Aleksandr Lesnenko
committed
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
import AttributeMappingEditor, {
AttributeOptionsEmptyState,
} from "../AttributeMappingEditor";
const ERROR_MESSAGE = t`An error occurred.`;
const getNormalizedPolicy = (
policy: GroupTableAccessPolicy | GroupTableAccessPolicyDraft,
shouldUseSavedQuestion: boolean,
): GroupTableAccessPolicy => {
return {
...policy,
card_id: shouldUseSavedQuestion ? policy.card_id : null,
attribute_remappings: _.pick(
policy.attribute_remappings,
(value, key) => value != null && key != null,
),
} as GroupTableAccessPolicy;
};
const getDraftPolicy = ({
tableId,
groupId,
}: GroupTableAccessPolicyParams): GroupTableAccessPolicyDraft => {
return {
table_id: parseInt(tableId),
group_id: parseInt(groupId),
card_id: null,
attribute_remappings: { "": null },
};
};
const isPolicyValid = (
policy: GroupTableAccessPolicy,
shouldUseSavedQuestion: boolean,
) => {
if (shouldUseSavedQuestion) {
return policy.card_id != null;
}
return Object.entries(policy.attribute_remappings).length > 0;
};
export interface EditSandboxingModalProps {
policy?: GroupTableAccessPolicy;
attributes: UserAttribute[];
params: GroupTableAccessPolicyParams;
onCancel: () => void;
onSave: (policy: GroupTableAccessPolicy) => void;
}
const EditSandboxingModal = ({
policy: originalPolicy,
attributes,
params,
onCancel,
onSave,
}: EditSandboxingModalProps) => {
const [policy, setPolicy] = useState<
GroupTableAccessPolicy | GroupTableAccessPolicyDraft
>(originalPolicy ?? getDraftPolicy(params));
const [shouldUseSavedQuestion, setShouldUseSavedQuestion] = useState(
policy.card_id != null,
);
const normalizedPolicy = getNormalizedPolicy(policy, shouldUseSavedQuestion);
const isValid = isPolicyValid(normalizedPolicy, shouldUseSavedQuestion);
const [showPickerModal, { turnOn: showModal, turnOff: hideModal }] =
useToggle(false);
Aleksandr Lesnenko
committed
const [{ error }, savePolicy] = useAsyncFn(async () => {
const shouldValidate = normalizedPolicy.card_id != null;
if (shouldValidate) {
await GTAPApi.validate(normalizedPolicy);
}
onSave(normalizedPolicy);
}, [normalizedPolicy]);
const remainingAttributesOptions = attributes.filter(
attribute => !(attribute in policy.attribute_remappings),
);
const hasAttributesOptions = attributes.length > 0;
const hasValidMappings =
Object.keys(normalizedPolicy.attribute_remappings || {}).length > 0;
const canSave =
isValid &&
(!_.isEqual(originalPolicy, normalizedPolicy) ||
normalizedPolicy.id == null);
const { data: policyCard } = useGetCardQuery(
policy.card_id != null ? { id: policy.card_id } : skipToken,
);
const { data: policyTable } = useGetTableQuery(
policy.table_id != null ? { id: policy.table_id } : skipToken,
);
Aleksandr Lesnenko
committed
return (
<div>
<h2 className={CS.p3}>{t`Restrict access to this table`}</h2>
Aleksandr Lesnenko
committed
<div>
<div className={cx(CS.px3, CS.pb3)}>
<div className={CS.pb2}>
{t`When the following rules are applied, this group will see a customized version of the table.`}
Aleksandr Lesnenko
committed
</div>
<div className={CS.pb4}>
{t`These rules don’t apply to native queries.`}
</div>
<h4 className={CS.pb1}>{t`How do you want to filter this table?`}</h4>
Aleksandr Lesnenko
committed
<Radio
value={!shouldUseSavedQuestion}
options={[
{ name: t`Filter by a column in the table`, value: true },
Aleksandr Lesnenko
committed
{
name: t`Use a saved question to create a custom view for this table`,
Aleksandr Lesnenko
committed
value: false,
},
]}
onChange={shouldUseSavedQuestion =>
setShouldUseSavedQuestion(!shouldUseSavedQuestion)
}
vertical
/>
</div>
{shouldUseSavedQuestion && (
<div className={cx(CS.px3, CS.pb3)}>
<div className={CS.pb2}>
Aleksandr Lesnenko
committed
{t`Pick a saved question that returns the custom view of this table that these users should see.`}
</div>
<Button
data-testid="collection-picker-button"
onClick={showModal}
fullWidth
rightIcon={<Icon name="ellipsis" />}
styles={{
inner: {
justifyContent: "space-between",
},
root: { "&:active": { transform: "none" } },
}}
>
{policyCard?.name ?? t`Select a question`}
</Button>
{showPickerModal && (
<QuestionPickerModal
value={
policyCard && policy.card_id != null
model: policyCard.type === "model" ? "dataset" : "card",
}
: undefined
}
onChange={newCard => {
setPolicy({ ...policy, card_id: newCard.id });
hideModal();
}}
onClose={hideModal}
/>
)}
Aleksandr Lesnenko
committed
</div>
)}
{(!shouldUseSavedQuestion || policy.card_id != null) &&
(hasAttributesOptions || hasValidMappings ? (
<div className={cx(CS.p3, CS.borderTop, CS.borderBottom)}>
Aleksandr Lesnenko
committed
{shouldUseSavedQuestion && (
<div className={CS.pb2}>
Aleksandr Lesnenko
committed
{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={policy.attribute_remappings}
policyTable={policyTable}
Aleksandr Lesnenko
committed
onChange={attribute_remappings =>
setPolicy({ ...policy, attribute_remappings })
}
shouldUseSavedQuestion={shouldUseSavedQuestion}
policy={policy}
attributesOptions={remainingAttributesOptions}
/>
</div>
) : (
<div className={CS.px3}>
Aleksandr Lesnenko
committed
<AttributeOptionsEmptyState
title={
shouldUseSavedQuestion
? t`To add additional filters, your users need to have some attributes`
: t`For this option to work, your users need to have some attributes`
}
/>
</div>
))}
</div>
<div className={CS.p3}>
Aleksandr Lesnenko
committed
{isValid && (
<div className={CS.pb1}>
<PolicySummary
policy={normalizedPolicy}
policyTable={policyTable}
/>
Aleksandr Lesnenko
committed
</div>
)}
<div className={cx(CS.flex, CS.alignCenter, CS.justifyEnd)}>
Aleksandr Lesnenko
committed
<Button onClick={onCancel}>{t`Cancel`}</Button>
<ActionButton
error={error}
Aleksandr Lesnenko
committed
actionFn={savePolicy}
primary
disabled={!canSave}
>
{t`Save`}
</ActionButton>
</div>
{error && (
<div className={cx(CS.flex, CS.alignCenter, CS.my2, CS.textError)}>
Aleksandr Lesnenko
committed
{typeof error === "string"
? error
Uladzimir Havenchyk
committed
: // @ts-expect-error provide correct type for error
Aleksandr Lesnenko
committed
error.data.message ?? ERROR_MESSAGE}
</div>
)}
</div>
</div>
);
};
// eslint-disable-next-line import/no-default-export -- deprecated usage
Aleksandr Lesnenko
committed
export default EditSandboxingModal;
interface SummaryRowProps {
Aleksandr Lesnenko
committed
content: React.ReactNode;
}
const SummaryRow = ({ icon, content }: SummaryRowProps) => (
<div className={cx(CS.flex, CS.alignCenter)}>
<Icon className={CS.p1} name={icon} />
Aleksandr Lesnenko
committed
<span>{content}</span>
</div>
);
interface PolicySummaryProps {
policy: GroupTableAccessPolicy;
policyTable: Table | undefined;
Aleksandr Lesnenko
committed
}
const PolicySummary = ({ policy, policyTable }: PolicySummaryProps) => {
Aleksandr Lesnenko
committed
return (
<div>
<div className={cx(CS.px1, CS.pb2, CS.textUppercase, CS.textSmall)}>
{t`Summary`}
Aleksandr Lesnenko
committed
</div>
<SummaryRow
icon="group"
content={jt`Users in ${(
<strong key="group-name">
Aleksandr Lesnenko
committed
<EntityName entityType="groups" entityId={policy.group_id} />
</strong>
)} can view`}
/>
<SummaryRow
icon="table"
content={
policy.card_id
? jt`rows in the ${(
<strong key="question-name">
Aleksandr Lesnenko
committed
<EntityName
entityType="questions"
entityId={policy.card_id}
/>
</strong>
)} question`
: jt`rows in the ${(
<strong key="table-name">
Aleksandr Lesnenko
committed
<EntityName
entityType="tables"
entityId={policy.table_id}
property="display_name"
/>
</strong>
)} table`
}
/>
{Object.entries(policy.attribute_remappings).map(
([attribute, target], index) => (
<SummaryRow
key={attribute}
Aleksandr Lesnenko
committed
content={
index === 0
? jt`where ${(
<TargetName
key="target"
policy={policy}
policyTable={policyTable}
target={target}
/>
)} equals ${(
<span key="attr" className={CS.textCode}>
{attribute}
</span>
)}`
Aleksandr Lesnenko
committed
: jt`and ${(
<TargetName
key="target"
policy={policy}
policyTable={policyTable}
target={target}
/>
)} equals ${(
<span key="attr" className={CS.textCode}>
{attribute}
</span>
)}`
Aleksandr Lesnenko
committed
}
/>
),
)}
</div>
);
};
interface TargetNameProps {
policy: GroupTableAccessPolicy;
policyTable: Table | undefined;
Aleksandr Lesnenko
committed
target: any[];
}
const TargetName = ({ policy, policyTable, target }: TargetNameProps) => {
Aleksandr Lesnenko
committed
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") {
const fieldRef = target[1];
return (
<QuestionLoader
Uladzimir Havenchyk
committed
questionHash={undefined}
Aleksandr Lesnenko
committed
questionId={policy.card_id}
questionObject={
policy.card_id == null && policyTable
? getRawDataQuestionForTable(policyTable)
Aleksandr Lesnenko
committed
: null
}
>
{({ question }: { question: Question }) => {
if (!question) {
return null;
}
const query = question.query();
const stageIndex = -1;
const columns = Lib.visibleColumns(query, stageIndex);
const [index] = Lib.findColumnIndexesFromLegacyRefs(
query,
stageIndex,
columns,
[fieldRef],
);
const column = columns[index];
if (!column) {
return null;
}
const columnInfo = Lib.displayInfo(query, stageIndex, column);
Aleksandr Lesnenko
committed
return (
<span>
<strong>{columnInfo.displayName}</strong> field