mirror of
https://github.com/apache/superset.git
synced 2026-06-05 23:59:25 +00:00
Co-authored-by: cccs-nik <68961854+cccs-nik@users.noreply.github.com> Co-authored-by: GITHUB_USERNAME <EMAIL>
452 lines
14 KiB
TypeScript
452 lines
14 KiB
TypeScript
/**
|
|
* Licensed to the Apache Software Foundation (ASF) under one
|
|
* or more contributor license agreements. See the NOTICE file
|
|
* distributed with this work for additional information
|
|
* regarding copyright ownership. The ASF licenses this file
|
|
* to you under the Apache License, Version 2.0 (the
|
|
* "License"); you may not use this file except in compliance
|
|
* with the License. You may obtain a copy of the License at
|
|
*
|
|
* http://www.apache.org/licenses/LICENSE-2.0
|
|
*
|
|
* Unless required by applicable law or agreed to in writing,
|
|
* software distributed under the License is distributed on an
|
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
* KIND, either express or implied. See the License for the
|
|
* specific language governing permissions and limitations
|
|
* under the License.
|
|
*/
|
|
import React, { useMemo, useState, useCallback, useEffect } from 'react';
|
|
import Modal from 'src/components/Modal';
|
|
import { Input, TextArea } from 'src/components/Input';
|
|
import Button from 'src/components/Button';
|
|
import { AsyncSelect, Row, Col, AntdForm } from 'src/components';
|
|
import { SelectValue } from 'antd/lib/select';
|
|
import rison from 'rison';
|
|
import {
|
|
t,
|
|
SupersetClient,
|
|
styled,
|
|
isFeatureEnabled,
|
|
FeatureFlag,
|
|
} from '@superset-ui/core';
|
|
import Chart, { Slice } from 'src/types/Chart';
|
|
import { getClientErrorObject } from 'src/utils/getClientErrorObject';
|
|
import withToasts from 'src/components/MessageToasts/withToasts';
|
|
import { loadTags } from 'src/components/Tags/utils';
|
|
import { addTag, deleteTaggedObjects, fetchTags, OBJECT_TYPES } from 'src/tags';
|
|
import TagType from 'src/types/TagType';
|
|
|
|
export type PropertiesModalProps = {
|
|
slice: Slice;
|
|
show: boolean;
|
|
onHide: () => void;
|
|
onSave: (chart: Chart) => void;
|
|
permissionsError?: string;
|
|
existingOwners?: SelectValue;
|
|
addSuccessToast: (msg: string) => void;
|
|
};
|
|
|
|
const FormItem = AntdForm.Item;
|
|
|
|
const StyledFormItem = styled(AntdForm.Item)`
|
|
margin-bottom: 0;
|
|
`;
|
|
|
|
const StyledHelpBlock = styled.span`
|
|
margin-bottom: 0;
|
|
`;
|
|
|
|
function PropertiesModal({
|
|
slice,
|
|
onHide,
|
|
onSave,
|
|
show,
|
|
addSuccessToast,
|
|
}: PropertiesModalProps) {
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const [form] = AntdForm.useForm();
|
|
// values of form inputs
|
|
const [name, setName] = useState(slice.slice_name || '');
|
|
const [selectedOwners, setSelectedOwners] = useState<SelectValue | null>(
|
|
null,
|
|
);
|
|
|
|
const [tags, setTags] = useState<TagType[]>([]);
|
|
|
|
const tagsAsSelectValues = useMemo(() => {
|
|
const selectTags = tags.map(tag => ({
|
|
value: tag.name,
|
|
label: tag.name,
|
|
key: tag.name,
|
|
}));
|
|
return selectTags;
|
|
}, [tags.length]);
|
|
|
|
function showError({ error, statusText, message }: any) {
|
|
let errorText = error || statusText || t('An error has occurred');
|
|
if (message === 'Forbidden') {
|
|
errorText = t('You do not have permission to edit this chart');
|
|
}
|
|
Modal.error({
|
|
title: t('Error'),
|
|
content: errorText,
|
|
okButtonProps: { danger: true, className: 'btn-danger' },
|
|
});
|
|
}
|
|
|
|
const fetchChartOwners = useCallback(
|
|
async function fetchChartOwners() {
|
|
try {
|
|
const response = await SupersetClient.get({
|
|
endpoint: `/api/v1/chart/${slice.slice_id}`,
|
|
});
|
|
const chart = response.json.result;
|
|
setSelectedOwners(
|
|
chart?.owners?.map((owner: any) => ({
|
|
value: owner.id,
|
|
label: `${owner.first_name} ${owner.last_name}`,
|
|
})),
|
|
);
|
|
} catch (response) {
|
|
const clientError = await getClientErrorObject(response);
|
|
showError(clientError);
|
|
}
|
|
},
|
|
[slice.slice_id],
|
|
);
|
|
|
|
const loadOptions = useMemo(
|
|
() =>
|
|
(input = '', page: number, pageSize: number) => {
|
|
const query = rison.encode({
|
|
filter: input,
|
|
page,
|
|
page_size: pageSize,
|
|
});
|
|
return SupersetClient.get({
|
|
endpoint: `/api/v1/chart/related/owners?q=${query}`,
|
|
}).then(response => ({
|
|
data: response.json.result
|
|
.filter((item: { extra: { active: boolean } }) => item.extra.active)
|
|
.map((item: { value: number; text: string }) => ({
|
|
value: item.value,
|
|
label: item.text,
|
|
})),
|
|
totalCount: response.json.count,
|
|
}));
|
|
},
|
|
[],
|
|
);
|
|
|
|
const updateTags = (oldTags: TagType[], newTags: TagType[]) => {
|
|
// update the tags for this object
|
|
// add tags that are in new tags, but not in old tags
|
|
// eslint-disable-next-line array-callback-return
|
|
newTags.map((tag: TagType) => {
|
|
if (!oldTags.some(t => t.name === tag.name)) {
|
|
addTag(
|
|
{
|
|
objectType: OBJECT_TYPES.CHART,
|
|
objectId: slice.slice_id,
|
|
includeTypes: false,
|
|
},
|
|
tag.name,
|
|
() => {},
|
|
() => {},
|
|
);
|
|
}
|
|
});
|
|
// delete tags that are in old tags, but not in new tags
|
|
// eslint-disable-next-line array-callback-return
|
|
oldTags.map((tag: TagType) => {
|
|
if (!newTags.some(t => t.name === tag.name)) {
|
|
deleteTaggedObjects(
|
|
{
|
|
objectType: OBJECT_TYPES.CHART,
|
|
objectId: slice.slice_id,
|
|
},
|
|
tag,
|
|
() => {},
|
|
() => {},
|
|
);
|
|
}
|
|
});
|
|
};
|
|
|
|
const onSubmit = async (values: {
|
|
certified_by?: string;
|
|
certification_details?: string;
|
|
description?: string;
|
|
cache_timeout?: number;
|
|
}) => {
|
|
setSubmitting(true);
|
|
const {
|
|
certified_by: certifiedBy,
|
|
certification_details: certificationDetails,
|
|
description,
|
|
cache_timeout: cacheTimeout,
|
|
} = values;
|
|
const payload: { [key: string]: any } = {
|
|
slice_name: name || null,
|
|
description: description || null,
|
|
cache_timeout: cacheTimeout || null,
|
|
certified_by: certifiedBy || null,
|
|
certification_details:
|
|
certifiedBy && certificationDetails ? certificationDetails : null,
|
|
};
|
|
if (selectedOwners) {
|
|
payload.owners = (
|
|
selectedOwners as {
|
|
value: number;
|
|
label: string;
|
|
}[]
|
|
).map(o => o.value);
|
|
}
|
|
if (isFeatureEnabled(FeatureFlag.TAGGING_SYSTEM)) {
|
|
// update tags
|
|
try {
|
|
fetchTags(
|
|
{
|
|
objectType: OBJECT_TYPES.CHART,
|
|
objectId: slice.slice_id,
|
|
includeTypes: false,
|
|
},
|
|
(currentTags: TagType[]) => updateTags(currentTags, tags),
|
|
error => {
|
|
showError(error);
|
|
},
|
|
);
|
|
} catch (error) {
|
|
showError(error);
|
|
}
|
|
}
|
|
|
|
try {
|
|
const res = await SupersetClient.put({
|
|
endpoint: `/api/v1/chart/${slice.slice_id}`,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
// update the redux state
|
|
const updatedChart = {
|
|
...payload,
|
|
...res.json.result,
|
|
tags,
|
|
id: slice.slice_id,
|
|
owners: selectedOwners,
|
|
};
|
|
onSave(updatedChart);
|
|
addSuccessToast(t('Chart properties updated'));
|
|
onHide();
|
|
} catch (res) {
|
|
const clientError = await getClientErrorObject(res);
|
|
showError(clientError);
|
|
}
|
|
setSubmitting(false);
|
|
};
|
|
|
|
const ownersLabel = t('Owners');
|
|
|
|
// get the owners of this slice
|
|
useEffect(() => {
|
|
fetchChartOwners();
|
|
}, [fetchChartOwners]);
|
|
|
|
// update name after it's changed in another modal
|
|
useEffect(() => {
|
|
setName(slice.slice_name || '');
|
|
}, [slice.slice_name]);
|
|
|
|
useEffect(() => {
|
|
if (!isFeatureEnabled(FeatureFlag.TAGGING_SYSTEM)) return;
|
|
try {
|
|
fetchTags(
|
|
{
|
|
objectType: OBJECT_TYPES.CHART,
|
|
objectId: slice.slice_id,
|
|
includeTypes: false,
|
|
},
|
|
(tags: TagType[]) => setTags(tags),
|
|
error => {
|
|
showError(error);
|
|
},
|
|
);
|
|
} catch (error) {
|
|
showError(error);
|
|
}
|
|
}, [slice.slice_id]);
|
|
|
|
const handleChangeTags = (values: { label: string; value: number }[]) => {
|
|
// triggered whenever a new tag is selected or a tag was deselected
|
|
// on new tag selected, add the tag
|
|
|
|
const uniqueTags = [...new Set(values.map(v => v.label))];
|
|
setTags([...uniqueTags.map(t => ({ name: t }))]);
|
|
};
|
|
|
|
const handleClearTags = () => {
|
|
setTags([]);
|
|
};
|
|
|
|
return (
|
|
<Modal
|
|
show={show}
|
|
onHide={onHide}
|
|
title={t('Edit Chart Properties')}
|
|
footer={
|
|
<>
|
|
<Button
|
|
data-test="properties-modal-cancel-button"
|
|
htmlType="button"
|
|
buttonSize="small"
|
|
onClick={onHide}
|
|
cta
|
|
>
|
|
{t('Cancel')}
|
|
</Button>
|
|
<Button
|
|
data-test="properties-modal-save-button"
|
|
htmlType="submit"
|
|
buttonSize="small"
|
|
buttonStyle="primary"
|
|
onClick={form.submit}
|
|
disabled={submitting || !name || slice.is_managed_externally}
|
|
tooltip={
|
|
slice.is_managed_externally
|
|
? t(
|
|
"This chart is managed externally, and can't be edited in Superset",
|
|
)
|
|
: ''
|
|
}
|
|
cta
|
|
>
|
|
{t('Save')}
|
|
</Button>
|
|
</>
|
|
}
|
|
responsive
|
|
wrapProps={{ 'data-test': 'properties-edit-modal' }}
|
|
>
|
|
<AntdForm
|
|
form={form}
|
|
onFinish={onSubmit}
|
|
layout="vertical"
|
|
initialValues={{
|
|
name: slice.slice_name || '',
|
|
description: slice.description || '',
|
|
cache_timeout: slice.cache_timeout != null ? slice.cache_timeout : '',
|
|
certified_by: slice.certified_by || '',
|
|
certification_details:
|
|
slice.certified_by && slice.certification_details
|
|
? slice.certification_details
|
|
: '',
|
|
}}
|
|
>
|
|
<Row gutter={16}>
|
|
<Col xs={24} md={12}>
|
|
<h3>{t('Basic information')}</h3>
|
|
<FormItem label={t('Name')} required>
|
|
<Input
|
|
aria-label={t('Name')}
|
|
name="name"
|
|
data-test="properties-modal-name-input"
|
|
type="text"
|
|
value={name}
|
|
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
|
|
setName(event.target.value ?? '')
|
|
}
|
|
/>
|
|
</FormItem>
|
|
<FormItem>
|
|
<StyledFormItem label={t('Description')} name="description">
|
|
<TextArea rows={3} style={{ maxWidth: '100%' }} />
|
|
</StyledFormItem>
|
|
<StyledHelpBlock className="help-block">
|
|
{t(
|
|
'The description can be displayed as widget headers in the dashboard view. Supports markdown.',
|
|
)}
|
|
</StyledHelpBlock>
|
|
</FormItem>
|
|
<h3>{t('Certification')}</h3>
|
|
<FormItem>
|
|
<StyledFormItem label={t('Certified by')} name="certified_by">
|
|
<Input aria-label={t('Certified by')} />
|
|
</StyledFormItem>
|
|
<StyledHelpBlock className="help-block">
|
|
{t('Person or group that has certified this chart.')}
|
|
</StyledHelpBlock>
|
|
</FormItem>
|
|
<FormItem>
|
|
<StyledFormItem
|
|
label={t('Certification details')}
|
|
name="certification_details"
|
|
>
|
|
<Input aria-label={t('Certification details')} />
|
|
</StyledFormItem>
|
|
<StyledHelpBlock className="help-block">
|
|
{t(
|
|
'Any additional detail to show in the certification tooltip.',
|
|
)}
|
|
</StyledHelpBlock>
|
|
</FormItem>
|
|
</Col>
|
|
<Col xs={24} md={12}>
|
|
<h3>{t('Configuration')}</h3>
|
|
<FormItem>
|
|
<StyledFormItem label={t('Cache timeout')} name="cache_timeout">
|
|
<Input aria-label="Cache timeout" />
|
|
</StyledFormItem>
|
|
<StyledHelpBlock className="help-block">
|
|
{t(
|
|
"Duration (in seconds) of the caching timeout for this chart. Note this defaults to the dataset's timeout if undefined.",
|
|
)}
|
|
</StyledHelpBlock>
|
|
</FormItem>
|
|
<h3 style={{ marginTop: '1em' }}>{t('Access')}</h3>
|
|
<FormItem label={ownersLabel}>
|
|
<AsyncSelect
|
|
ariaLabel={ownersLabel}
|
|
mode="multiple"
|
|
name="owners"
|
|
value={selectedOwners || []}
|
|
onChange={setSelectedOwners}
|
|
options={loadOptions}
|
|
disabled={!selectedOwners}
|
|
allowClear
|
|
/>
|
|
<StyledHelpBlock className="help-block">
|
|
{t(
|
|
'A list of users who can alter the chart. Searchable by name or username.',
|
|
)}
|
|
</StyledHelpBlock>
|
|
</FormItem>
|
|
{isFeatureEnabled(FeatureFlag.TAGGING_SYSTEM) && (
|
|
<h3 css={{ marginTop: '1em' }}>{t('Tags')}</h3>
|
|
)}
|
|
{isFeatureEnabled(FeatureFlag.TAGGING_SYSTEM) && (
|
|
<FormItem>
|
|
<AsyncSelect
|
|
ariaLabel="Tags"
|
|
mode="multiple"
|
|
allowNewOptions
|
|
value={tagsAsSelectValues}
|
|
options={loadTags}
|
|
onChange={handleChangeTags}
|
|
onClear={handleClearTags}
|
|
allowClear
|
|
/>
|
|
<StyledHelpBlock className="help-block">
|
|
{t('A list of tags that have been applied to this chart.')}
|
|
</StyledHelpBlock>
|
|
</FormItem>
|
|
)}
|
|
</Col>
|
|
</Row>
|
|
</AntdForm>
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
export default withToasts(PropertiesModal);
|