blob: 040bc6ee2e4e60ce3ffe5d54fcdb67857b35c030 [file] [log] [blame]
/**
* 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 { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { omit } from 'lodash';
import jsonStringify from 'json-stringify-pretty-compact';
import {
Form,
Modal,
Collapse,
CollapseLabelInModal,
JsonEditor,
} from '@superset-ui/core/components';
import { useJsonValidation } from '@superset-ui/core/components/AsyncAceEditor';
import { type TagType } from 'src/components';
import rison from 'rison';
import {
ensureIsArray,
isFeatureEnabled,
FeatureFlag,
getCategoricalSchemeRegistry,
SupersetClient,
t,
getClientErrorObject,
} from '@superset-ui/core';
import withToasts from 'src/components/MessageToasts/withToasts';
import { fetchTags, OBJECT_TYPES } from 'src/features/tags/tags';
import {
applyColors,
getColorNamespace,
getFreshLabelsColorMapEntries,
} from 'src/utils/colorScheme';
import { useDispatch } from 'react-redux';
import {
setColorScheme,
setDashboardMetadata,
} from 'src/dashboard/actions/dashboardState';
import { areObjectsEqual } from 'src/reduxUtils';
import { StandardModal, useModalValidation } from 'src/components/Modal';
import {
BasicInfoSection,
AccessSection,
StylingSection,
RefreshSection,
CertificationSection,
AdvancedSection,
} from './sections';
type PropertiesModalProps = {
dashboardId: number;
dashboardTitle?: string;
dashboardInfo?: Record<string, any>;
show?: boolean;
onHide?: () => void;
colorScheme?: string;
onSubmit?: (params: Record<string, any>) => void;
addSuccessToast: (message: string) => void;
addDangerToast: (message: string) => void;
onlyApply?: boolean;
};
type Roles = { id: number; name: string }[];
type Owners = {
id: number;
full_name?: string;
first_name?: string;
last_name?: string;
}[];
type DashboardInfo = {
id: number;
title: string;
slug: string;
certifiedBy: string;
certificationDetails: string;
isManagedExternally: boolean;
metadata: Record<string, any>;
common?: {
conf?: {
SUPERSET_DASHBOARD_PERIODICAL_REFRESH_LIMIT?: number;
};
};
};
const PropertiesModal = ({
addSuccessToast,
addDangerToast,
colorScheme: currentColorScheme,
dashboardId,
dashboardInfo: currentDashboardInfo,
dashboardTitle,
onHide = () => {},
onlyApply = false,
onSubmit = () => {},
show = false,
}: PropertiesModalProps) => {
const dispatch = useDispatch();
const [form] = Form.useForm();
const [isLoading, setIsLoading] = useState(false);
const [isApplying, setIsApplying] = useState(false);
const [colorScheme, setCurrentColorScheme] = useState(currentColorScheme);
const [jsonMetadata, setJsonMetadata] = useState('');
const [dashboardInfo, setDashboardInfo] = useState<DashboardInfo>();
// JSON validation for metadata
const jsonAnnotations = useJsonValidation(jsonMetadata, {
errorPrefix: 'Invalid JSON metadata',
});
const [owners, setOwners] = useState<Owners>([]);
const [roles, setRoles] = useState<Roles>([]);
const saveLabel = onlyApply ? t('Apply') : t('Save');
const [tags, setTags] = useState<TagType[]>([]);
const [customCss, setCustomCss] = useState('');
const [refreshFrequency, setRefreshFrequency] = useState(0);
const [selectedThemeId, setSelectedThemeId] = useState<number | null>(null);
const [themes, setThemes] = useState<
Array<{
id: number;
theme_name: string;
}>
>([]);
const categoricalSchemeRegistry = getCategoricalSchemeRegistry();
const originalDashboardMetadata = useRef<Record<string, any>>({});
const handleErrorResponse = async (response: Response) => {
const { error, statusText, message } = await getClientErrorObject(response);
let errorText = error || statusText || t('An error has occurred');
if (typeof message === 'object' && 'json_metadata' in message) {
errorText = (message as { json_metadata: string }).json_metadata;
} else if (typeof message === 'string') {
errorText = message;
if (message === 'Forbidden') {
errorText = t('You do not have permission to edit this dashboard');
}
}
Modal.error({
title: t('Error'),
content: errorText,
okButtonProps: { danger: true, className: 'btn-danger' },
});
};
const handleDashboardData = useCallback(
dashboardData => {
const {
id,
dashboard_title,
slug,
certified_by,
certification_details,
owners,
roles,
metadata,
is_managed_externally,
theme,
css,
} = dashboardData;
const dashboardInfo = {
id,
title: dashboard_title,
slug: slug || '',
certifiedBy: certified_by || '',
certificationDetails: certification_details || '',
isManagedExternally: is_managed_externally || false,
css: css || '',
metadata,
};
form.setFieldsValue(dashboardInfo);
setDashboardInfo(dashboardInfo);
setOwners(owners);
setRoles(roles);
setCustomCss(css || '');
setCurrentColorScheme(metadata?.color_scheme);
setSelectedThemeId(theme?.id || null);
const metaDataCopy = omit(metadata, [
'positions',
'shared_label_colors',
'map_label_colors',
'color_scheme_domain',
]);
setJsonMetadata(metaDataCopy ? jsonStringify(metaDataCopy) : '');
setRefreshFrequency(metadata?.refresh_frequency || 0);
originalDashboardMetadata.current = metadata;
},
[form],
);
const fetchDashboardDetails = useCallback(() => {
setIsLoading(true);
// We fetch the dashboard details because not all code
// that renders this component have all the values we need.
// At some point when we have a more consistent frontend
// datamodel, the dashboard could probably just be passed as a prop.
SupersetClient.get({
endpoint: `/api/v1/dashboard/${dashboardId}`,
}).then(response => {
const dashboard = response.json.result;
const jsonMetadataObj = dashboard.json_metadata?.length
? JSON.parse(dashboard.json_metadata)
: {};
handleDashboardData({
...dashboard,
metadata: jsonMetadataObj,
});
setIsLoading(false);
}, handleErrorResponse);
}, [dashboardId, handleDashboardData]);
const getJsonMetadata = () => {
try {
const jsonMetadataObj = jsonMetadata?.length
? JSON.parse(jsonMetadata)
: {};
return jsonMetadataObj;
} catch (_) {
return {};
}
};
const handleOnChangeOwners = (owners: { value: number; label: string }[]) => {
const parsedOwners: Owners = ensureIsArray(owners).map(o => ({
id: o.value,
full_name: o.label,
}));
setOwners(parsedOwners);
};
const handleOnChangeRoles = (roles: { value: number; label: string }[]) => {
const parsedRoles: Roles = ensureIsArray(roles).map(r => ({
id: r.value,
name: r.label,
}));
setRoles(parsedRoles);
};
const handleOnCancel = () => onHide();
const onColorSchemeChange = (
colorScheme = '',
{ updateMetadata = true } = {},
) => {
// check that color_scheme is valid
const colorChoices = categoricalSchemeRegistry.keys();
const jsonMetadataObj = getJsonMetadata();
// only fire if the color_scheme is present and invalid
if (colorScheme && !colorChoices.includes(colorScheme)) {
Modal.error({
title: t('Error'),
content: t('A valid color scheme is required'),
okButtonProps: { danger: true, className: 'btn-danger' },
});
onHide();
throw new Error('A valid color scheme is required');
}
jsonMetadataObj.color_scheme = colorScheme;
jsonMetadataObj.label_colors = jsonMetadataObj.label_colors || {};
setCurrentColorScheme(colorScheme);
dispatch(setColorScheme(colorScheme));
// update metadata to match selection
if (updateMetadata) {
setJsonMetadata(jsonStringify(jsonMetadataObj));
}
};
const onFinish = () => {
const { title, slug, certifiedBy, certificationDetails } =
form.getFieldsValue();
let currentJsonMetadata = jsonMetadata;
// validate currentJsonMetadata
let metadata;
try {
if (
!currentJsonMetadata.startsWith('{') ||
!currentJsonMetadata.endsWith('}')
) {
throw new Error();
}
metadata = JSON.parse(currentJsonMetadata);
} catch (error) {
addDangerToast(t('JSON metadata is invalid!'));
return;
}
const colorNamespace = getColorNamespace(metadata?.color_namespace);
// color scheme in json metadata has precedence over selection
const updatedColorScheme = metadata?.color_scheme || colorScheme;
const shouldGoFresh =
updatedColorScheme !== originalDashboardMetadata.current.color_scheme;
const shouldResetCustomLabels = !areObjectsEqual(
originalDashboardMetadata.current.label_colors || {},
metadata?.label_colors || {},
);
const currentCustomLabels = Object.keys(metadata?.label_colors || {});
const prevCustomLabels = Object.keys(
originalDashboardMetadata.current.label_colors || {},
);
const resettableCustomLabels =
currentCustomLabels.length > 0 ? currentCustomLabels : prevCustomLabels;
const freshCustomLabels =
shouldResetCustomLabels && resettableCustomLabels.length > 0
? resettableCustomLabels
: false;
const jsonMetadataObj = getJsonMetadata();
jsonMetadataObj.refresh_frequency = refreshFrequency;
const customLabelColors = jsonMetadataObj.label_colors || {};
const updatedDashboardMetadata = {
...originalDashboardMetadata.current,
label_colors: customLabelColors,
color_scheme: updatedColorScheme,
};
originalDashboardMetadata.current = updatedDashboardMetadata;
applyColors(updatedDashboardMetadata, shouldGoFresh || freshCustomLabels);
dispatch(
setDashboardMetadata({
...updatedDashboardMetadata,
map_label_colors: getFreshLabelsColorMapEntries(customLabelColors),
}),
);
onColorSchemeChange(updatedColorScheme, {
updateMetadata: false,
});
currentJsonMetadata = jsonStringify(jsonMetadataObj);
const moreOnSubmitProps: { roles?: Roles; tags?: TagType[] } = {};
const morePutProps: {
roles?: number[];
tags?: (string | number | undefined)[];
} = {};
if (isFeatureEnabled(FeatureFlag.DashboardRbac)) {
moreOnSubmitProps.roles = roles;
morePutProps.roles = (roles || []).map(r => r.id);
}
if (isFeatureEnabled(FeatureFlag.TaggingSystem)) {
moreOnSubmitProps.tags = tags;
morePutProps.tags = tags.map(tag => tag.id);
}
const onSubmitProps = {
id: dashboardId,
title,
slug,
jsonMetadata: currentJsonMetadata,
owners,
colorScheme: currentColorScheme,
colorNamespace,
certifiedBy,
certificationDetails,
themeId: selectedThemeId,
css: customCss,
...moreOnSubmitProps,
};
if (onlyApply) {
setIsApplying(true);
try {
console.log('Apply CSS debug:', {
css_being_sent: customCss,
onSubmitProps_css: onSubmitProps.css,
});
onSubmit(onSubmitProps);
onHide();
addSuccessToast(t('Dashboard properties updated'));
} catch (error) {
console.error('Apply failed:', error);
} finally {
setIsApplying(false);
}
} else {
const saveData = {
dashboard_title: title,
slug: slug || null,
json_metadata: currentJsonMetadata || null,
owners: (owners || []).map(o => o.id),
certified_by: certifiedBy || null,
certification_details:
certifiedBy && certificationDetails ? certificationDetails : null,
css: customCss || null,
theme_id: selectedThemeId,
...morePutProps,
};
SupersetClient.put({
endpoint: `/api/v1/dashboard/${dashboardId}`,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(saveData),
}).then(() => {
onSubmit(onSubmitProps);
onHide();
addSuccessToast(t('The dashboard has been saved'));
}, handleErrorResponse);
}
};
useEffect(() => {
if (show) {
if (!currentDashboardInfo) {
fetchDashboardDetails();
} else {
handleDashboardData(currentDashboardInfo);
}
// Fetch themes (excluding system themes)
const themeQuery = rison.encode({
columns: ['id', 'theme_name', 'is_system'],
filters: [
{
col: 'is_system',
opr: 'eq',
value: false,
},
],
});
SupersetClient.get({ endpoint: `/api/v1/theme/?q=${themeQuery}` })
.then(({ json }) => {
const fetchedThemes = json.result;
setThemes(fetchedThemes);
})
.catch(() => {
addDangerToast(
t('An error occurred while fetching available themes'),
);
});
}
JsonEditor.preload();
}, [
currentDashboardInfo,
fetchDashboardDetails,
handleDashboardData,
show,
addDangerToast,
]);
useEffect(() => {
// the title can be changed inline in the dashboard, this catches it
if (
dashboardTitle &&
dashboardInfo &&
dashboardInfo.title !== dashboardTitle
) {
form.setFieldsValue({
...dashboardInfo,
title: dashboardTitle,
});
}
}, [dashboardInfo, dashboardTitle, form]);
useEffect(() => {
if (!isFeatureEnabled(FeatureFlag.TaggingSystem)) return;
try {
fetchTags(
{
objectType: OBJECT_TYPES.DASHBOARD,
objectId: dashboardId,
includeTypes: false,
},
(tags: TagType[]) => setTags(tags),
(error: Response) => {
addDangerToast(`Error fetching tags: ${error.text}`);
},
);
} catch (error) {
handleErrorResponse(error);
}
}, [dashboardId]);
const handleChangeTags = (tags: { label: string; value: number }[]) => {
const parsedTags: TagType[] = ensureIsArray(tags).map(r => ({
id: r.value,
name: r.label,
}));
setTags(parsedTags);
};
const handleClearTags = () => {
setTags([]);
};
// Section handlers for extracted components
const handleThemeChange = (value: any) => setSelectedThemeId(value || null);
const handleRefreshFrequencyChange = (value: any) =>
setRefreshFrequency(value);
// Helper function for styling section
const hasCustomLabelsColor = !!Object.keys(
getJsonMetadata()?.label_colors || {},
).length;
// Validation setup
const modalSections = useMemo(
() => [
{
key: 'basic',
name: t('General information'),
validator: () => {
const errors = [];
const values = form.getFieldsValue();
// Check validation - only add if title is empty
if (!values.title || values.title.trim().length === 0) {
errors.push(t('Dashboard name is required'));
}
return errors;
},
},
{
key: 'access',
name: t('Access & ownership'),
validator: () => [],
},
{
key: 'styling',
name: t('Styling'),
validator: () => [],
},
{
key: 'refresh',
name: t('Refresh settings'),
validator: () => {
const errors = [];
const refreshLimit =
dashboardInfo?.common?.conf
?.SUPERSET_DASHBOARD_PERIODICAL_REFRESH_LIMIT;
if (
refreshLimit &&
refreshFrequency > 0 &&
refreshFrequency < refreshLimit
) {
errors.push(
t(
'Refresh frequency must be at least %s seconds',
refreshLimit / 1000,
),
);
}
return errors;
},
},
{
key: 'certification',
name: t('Certification'),
validator: () => [],
},
{
key: 'advanced',
name: t('Advanced settings'),
validator: () => {
if (jsonAnnotations.length > 0) {
return [t('Invalid JSON metadata')];
}
return [];
},
},
],
[form, jsonAnnotations, refreshFrequency, dashboardInfo],
);
const {
validationStatus,
validateAll,
validateSection,
errorTooltip,
hasErrors,
} = useModalValidation({
sections: modalSections,
});
// Validate basic section when title changes
useEffect(() => {
validateSection('basic');
}, [dashboardTitle, validateSection]);
// Validate advanced section when JSON changes
useEffect(() => {
validateSection('advanced');
}, [jsonMetadata, validateSection]);
// Validate refresh section when refresh frequency changes
useEffect(() => {
validateSection('refresh');
}, [refreshFrequency, validateSection]);
return (
<StandardModal
show={show}
onHide={handleOnCancel}
onSave={() => {
if (validateAll()) {
form.submit();
}
}}
title={t('Dashboard properties')}
isEditMode
saveDisabled={
isLoading || dashboardInfo?.isManagedExternally || hasErrors
}
saveLoading={isApplying}
errorTooltip={
dashboardInfo?.isManagedExternally
? t(
"This dashboard is managed externally, and can't be edited in Superset",
)
: errorTooltip
}
saveText={saveLabel}
wrapProps={{ 'data-test': 'properties-edit-modal' }}
>
<Form
form={form}
onFinish={onFinish}
onFieldsChange={() => {
// Re-validate sections when form fields change
setTimeout(() => validateSection('basic'), 100);
}}
data-test="dashboard-edit-properties-form"
layout="vertical"
initialValues={dashboardInfo}
>
<Collapse
expandIconPosition="end"
defaultActiveKey="basic"
accordion
modalMode
items={[
{
key: 'basic',
label: (
<CollapseLabelInModal
title={t('General information')}
subtitle={t('Dashboard name and URL configuration')}
validateCheckStatus={!validationStatus.basic?.hasErrors}
testId="basic-section"
/>
),
children: (
<BasicInfoSection
form={form}
isLoading={isLoading}
validationStatus={validationStatus}
/>
),
},
{
key: 'access',
label: (
<CollapseLabelInModal
title={t('Access & ownership')}
subtitle={t('Manage dashboard owners and access permissions')}
validateCheckStatus={!validationStatus.access?.hasErrors}
testId="access-section"
/>
),
children: (
<AccessSection
isLoading={isLoading}
owners={owners}
roles={roles}
tags={tags}
onChangeOwners={handleOnChangeOwners}
onChangeRoles={handleOnChangeRoles}
onChangeTags={handleChangeTags}
onClearTags={handleClearTags}
/>
),
},
{
key: 'styling',
label: (
<CollapseLabelInModal
title={t('Styling')}
subtitle={t(
'Configure dashboard appearance, colors, and custom CSS',
)}
validateCheckStatus={!validationStatus.styling?.hasErrors}
testId="styling-section"
/>
),
children: (
<StylingSection
themes={themes}
selectedThemeId={selectedThemeId}
colorScheme={colorScheme}
customCss={customCss}
hasCustomLabelsColor={hasCustomLabelsColor}
onThemeChange={handleThemeChange}
onColorSchemeChange={onColorSchemeChange}
onCustomCssChange={setCustomCss}
addDangerToast={addDangerToast}
/>
),
},
{
key: 'refresh',
label: (
<CollapseLabelInModal
title={t('Refresh settings')}
subtitle={t('Configure automatic dashboard refresh')}
validateCheckStatus={!validationStatus.refresh?.hasErrors}
testId="refresh-section"
/>
),
children: (
<RefreshSection
refreshFrequency={refreshFrequency}
onRefreshFrequencyChange={handleRefreshFrequencyChange}
/>
),
},
{
key: 'certification',
label: (
<CollapseLabelInModal
title={t('Certification')}
subtitle={t('Add certification details for this dashboard')}
validateCheckStatus={
!validationStatus.certification?.hasErrors
}
testId="certification-section"
/>
),
children: <CertificationSection isLoading={isLoading} />,
},
{
key: 'advanced',
label: (
<CollapseLabelInModal
title={t('Advanced settings')}
subtitle={t('JSON metadata and advanced configuration')}
validateCheckStatus={!validationStatus.advanced?.hasErrors}
testId="advanced-section"
/>
),
children: (
<AdvancedSection
jsonMetadata={jsonMetadata}
jsonAnnotations={jsonAnnotations}
validationStatus={validationStatus}
onJsonMetadataChange={setJsonMetadata}
/>
),
},
]}
/>
</Form>
</StandardModal>
);
};
export default withToasts(PropertiesModal);