feat: Adds the MetadataBar to the Explore header (#21560)

This commit is contained in:
Michael S. Molina 2022-09-29 14:34:57 -03:00 committed by GitHub
parent 5ea9249059
commit 0dda5fe1cf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 258 additions and 104 deletions

4
.github/CODEOWNERS vendored
View File

@ -10,13 +10,15 @@
.github/workflows/docker-ephemeral-env.yml @robdiciuccio @craig-rueda @rusackas @eschutho @dpgaspar @nytai @mistercrunch
.github/workflows/ephemeral*.yml @robdiciuccio @craig-rueda @rusackas @eschutho @dpgaspar @nytai @mistercrunch
# Notify some committers of changes in the Select component
# Notify some committers of changes in the components
/superset-frontend/src/components/Select/ @michael-s-molina @geido @ktmud
/superset-frontend/src/components/MetadataBar/ @michael-s-molina
# Notify Helm Chart maintainers about changes in it
/helm/superset/ @craig-rueda @dpgaspar @villebro
# Notify E2E test maintainers of changes
/superset-frontend/cypress-base/ @jinghua-qa @geido

View File

@ -17,7 +17,6 @@
* under the License.
*/
import React from 'react';
import moment from 'moment';
import { ensureIsArray, styled, t } from '@superset-ui/core';
import Icons from 'src/components/Icons';
import { ContentType, MetadataType } from '.';
@ -75,13 +74,10 @@ const config = (contentType: ContentType) => {
case MetadataType.LAST_MODIFIED:
return {
icon: Icons.EditOutlined,
title: moment.utc(contentType.value).fromNow(),
title: contentType.value,
tooltip: (
<div>
<Info
header={t('Last modified')}
text={moment.utc(contentType.value).fromNow()}
/>
<Info header={t('Last modified')} text={contentType.value} />
<Info header={t('Modified by')} text={contentType.modifiedBy} />
</div>
),
@ -95,10 +91,7 @@ const config = (contentType: ContentType) => {
<div>
<Info header={t('Created by')} text={contentType.createdBy} />
<Info header={t('Owners')} text={contentType.owners} />
<Info
header={t('Created on')}
text={moment.utc(contentType.createdOn).fromNow()}
/>
<Info header={t('Created on')} text={contentType.createdOn} />
</div>
),
};

View File

@ -43,7 +43,7 @@ export type Description = {
export type LastModified = {
type: MetadataType.LAST_MODIFIED;
value: Date;
value: string;
modifiedBy: string;
onClick?: (type: string) => void;
};
@ -52,7 +52,7 @@ export type Owner = {
type: MetadataType.OWNER;
createdBy: string;
owners: string[];
createdOn: Date;
createdOn: string;
onClick?: (type: string) => void;
};

View File

@ -26,7 +26,7 @@ export default {
component: MetadataBar,
};
const A_WEEK_AGO = new Date(Date.now() - 7 * 24 * 3600 * 1000);
const A_WEEK_AGO = 'a week ago';
export const Component = ({
items,

View File

@ -20,7 +20,6 @@ import React from 'react';
import { render, screen, within } from 'spec/helpers/testing-library';
import userEvent from '@testing-library/user-event';
import * as resizeDetector from 'react-resize-detector';
import moment from 'moment';
import { supersetTheme } from '@superset-ui/core';
import { hexToRgb } from 'src/utils/colorUtils';
import MetadataBar, {
@ -38,14 +37,22 @@ const ROWS_TITLE = '500 rows';
const SQL_TITLE = 'Click to view query';
const TABLE_TITLE = 'database.schema.table';
const CREATED_BY = 'Jane Smith';
const DATE = new Date(Date.parse('2022-01-01'));
const MODIFIED_BY = 'Jane Smith';
const OWNERS = ['John Doe', 'Mary Wilson'];
const TAGS = ['management', 'research', 'poc'];
const A_WEEK_AGO = 'a week ago';
const TWO_DAYS_AGO = '2 days ago';
const runWithBarCollapsed = async (func: Function) => {
const spy = jest.spyOn(resizeDetector, 'useResizeDetector');
spy.mockReturnValue({ width: 80, ref: { current: undefined } });
let width: number;
spy.mockImplementation(props => {
if (props?.onResize && !width) {
width = 80;
props.onResize(width);
}
return { ref: { current: undefined } };
});
await func();
spy.mockRestore();
};
@ -62,14 +69,14 @@ const ITEMS: ContentType[] = [
},
{
type: MetadataType.LAST_MODIFIED,
value: DATE,
value: TWO_DAYS_AGO,
modifiedBy: MODIFIED_BY,
},
{
type: MetadataType.OWNER,
createdBy: CREATED_BY,
owners: OWNERS,
createdOn: DATE,
createdOn: A_WEEK_AGO,
},
{
type: MetadataType.ROWS,
@ -162,7 +169,9 @@ test('renders clicable items with blue icons when the bar is collapsed', async (
const clickableColor = window.getComputedStyle(images[0]).color;
const nonClickableColor = window.getComputedStyle(images[1]).color;
expect(clickableColor).toBe(hexToRgb(supersetTheme.colors.primary.base));
expect(nonClickableColor).toBeFalsy();
expect(nonClickableColor).toBe(
hexToRgb(supersetTheme.colors.grayscale.base),
);
});
});
@ -196,23 +205,21 @@ test('correctly renders the description tooltip', async () => {
});
test('correctly renders the last modified tooltip', async () => {
const dateText = moment.utc(DATE).fromNow();
render(<MetadataBar items={ITEMS.slice(0, 3)} />);
userEvent.hover(screen.getByText(dateText));
userEvent.hover(screen.getByText(TWO_DAYS_AGO));
const tooltip = await screen.findByRole('tooltip');
expect(tooltip).toBeInTheDocument();
expect(within(tooltip).getByText(dateText)).toBeInTheDocument();
expect(within(tooltip).getByText(TWO_DAYS_AGO)).toBeInTheDocument();
expect(within(tooltip).getByText(MODIFIED_BY)).toBeInTheDocument();
});
test('correctly renders the owner tooltip', async () => {
const dateText = moment.utc(DATE).fromNow();
render(<MetadataBar items={ITEMS.slice(0, 4)} />);
userEvent.hover(screen.getByText(CREATED_BY));
const tooltip = await screen.findByRole('tooltip');
expect(tooltip).toBeInTheDocument();
expect(within(tooltip).getByText(CREATED_BY)).toBeInTheDocument();
expect(within(tooltip).getByText(dateText)).toBeInTheDocument();
expect(within(tooltip).getByText(A_WEEK_AGO)).toBeInTheDocument();
OWNERS.forEach(owner =>
expect(within(tooltip).getByText(owner)).toBeInTheDocument(),
);

View File

@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import React, { useEffect, useRef, useState } from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useResizeDetector } from 'react-resize-detector';
import { uniqWith } from 'lodash';
import { styled } from '@superset-ui/core';
@ -67,23 +67,38 @@ const StyledItem = styled.div<{
onClick?: () => void;
}>`
${({ theme, collapsed, last, onClick }) => `
display: flex;
max-width: ${
ICON_WIDTH +
ICON_PADDING +
TEXT_MAX_WIDTH +
(last ? 0 : SPACE_BETWEEN_ITEMS)
}px;
min-width: ${ICON_WIDTH + (last ? 0 : SPACE_BETWEEN_ITEMS)}px;
overflow: hidden;
text-overflow: ${collapsed ? 'unset' : 'ellipsis'};
white-space: nowrap;
min-width: ${
collapsed
? ICON_WIDTH + (last ? 0 : SPACE_BETWEEN_ITEMS)
: ICON_WIDTH +
ICON_PADDING +
TEXT_MIN_WIDTH +
(last ? 0 : SPACE_BETWEEN_ITEMS)
}px;
padding-right: ${last ? 0 : SPACE_BETWEEN_ITEMS}px;
text-decoration: ${onClick ? 'underline' : 'none'};
cursor: ${onClick ? 'pointer' : 'default'};
& > span {
color: ${onClick && collapsed ? theme.colors.primary.base : 'undefined'};
& .metadata-icon {
color: ${
onClick && collapsed
? theme.colors.primary.base
: theme.colors.grayscale.base
};
padding-right: ${collapsed ? 0 : ICON_PADDING}px;
}
& .metadata-text {
min-width: ${TEXT_MIN_WIDTH}px;
overflow: hidden;
text-overflow: ${collapsed ? 'unset' : 'ellipsis'};
white-space: nowrap;
text-decoration: ${onClick ? 'underline' : 'none'};
}
`}
`;
@ -124,10 +139,13 @@ const Item = ({
collapsed={collapsed}
last={last}
onClick={onClick ? () => onClick(type) : undefined}
ref={ref}
>
<Icon iconSize="l" />
{!collapsed && title}
<Icon iconSize="l" className="metadata-icon" />
{!collapsed && (
<span ref={ref} className="metadata-text">
{title}
</span>
)}
</StyledItem>
);
return isTruncated || collapsed || (tooltip && tooltip !== title) ? (
@ -156,7 +174,8 @@ export interface MetadataBarProps {
* This process is important to make sure the new type is reviewed by the design team, improving Superset consistency.
*/
const MetadataBar = ({ items }: MetadataBarProps) => {
const { width, ref } = useResizeDetector();
const [width, setWidth] = useState<number>();
const [collapsed, setCollapsed] = useState(false);
const uniqueItems = uniqWith(items, (a, b) => a.type === b.type);
const sortedItems = uniqueItems.sort((a, b) => ORDER[a.type] - ORDER[b.type]);
const count = sortedItems.length;
@ -166,12 +185,23 @@ const MetadataBar = ({ items }: MetadataBarProps) => {
if (count > MAX_NUMBER_ITEMS) {
throw Error('The maximum number of items for the metadata bar is 6.');
}
// Calculates the breakpoint width to collapse the bar.
// The last item does not have a space, so we subtract SPACE_BETWEEN_ITEMS from the total.
const breakpoint =
(ICON_WIDTH + ICON_PADDING + TEXT_MIN_WIDTH + SPACE_BETWEEN_ITEMS) * count -
SPACE_BETWEEN_ITEMS;
const collapsed = Boolean(width && width < breakpoint);
const onResize = useCallback(
width => {
// Calculates the breakpoint width to collapse the bar.
// The last item does not have a space, so we subtract SPACE_BETWEEN_ITEMS from the total.
const breakpoint =
(ICON_WIDTH + ICON_PADDING + TEXT_MIN_WIDTH + SPACE_BETWEEN_ITEMS) *
count -
SPACE_BETWEEN_ITEMS;
setWidth(width);
setCollapsed(Boolean(width && width < breakpoint));
},
[count],
);
const { ref } = useResizeDetector({ onResize });
return (
<Bar ref={ref} count={count}>
{sortedItems.map((item, index) => (

View File

@ -22,7 +22,6 @@ import { getMockStoreWithNativeFilters } from 'spec/fixtures/mockStore';
import chartQueries, { sliceId } from 'spec/fixtures/mockChartQueries';
import { QueryFormData, SupersetClient } from '@superset-ui/core';
import fetchMock from 'fetch-mock';
import moment from 'moment';
import DrillDetailPane from './DrillDetailPane';
const chart = chartQueries[sliceId];
@ -48,8 +47,8 @@ const SAMPLES_ENDPOINT =
const DATASET_ENDPOINT = 'glob:*/api/v1/dataset/*';
const MOCKED_DATASET = {
changed_on: new Date(Date.parse('2022-01-01')),
created_on: new Date(Date.parse('2022-01-01')),
changed_on_humanized: '2 days ago',
created_on_humanized: 'a week ago',
description: 'Simple description',
table_name: 'test_table',
changed_by: {
@ -170,7 +169,7 @@ test('should render the metadata bar', async () => {
),
).toBeInTheDocument();
expect(
await screen.findByText(moment.utc(MOCKED_DATASET.changed_on).fromNow()),
await screen.findByText(MOCKED_DATASET.changed_on_humanized),
).toBeInTheDocument();
});

View File

@ -249,8 +249,8 @@ export default function DrillDetailPane({
const items: ContentType[] = [];
if (result) {
const {
changed_on,
created_on,
changed_on_humanized,
created_on_humanized,
description,
table_name,
changed_by,
@ -275,14 +275,14 @@ export default function DrillDetailPane({
});
items.push({
type: MetadataType.LAST_MODIFIED,
value: changed_on,
value: changed_on_humanized,
modifiedBy,
});
items.push({
type: MetadataType.OWNER,
createdBy,
owners: formattedOwners,
createdOn: created_on,
createdOn: created_on_humanized,
});
if (description) {
items.push({

View File

@ -34,8 +34,8 @@ export type Dataset = {
first_name: string;
last_name: string;
};
changed_on: Date;
created_on: Date;
changed_on_humanized: string;
created_on_humanized: string;
description: string;
table_name: string;
owners: {

View File

@ -47,7 +47,7 @@ enum ColorSchemeType {
export const HYDRATE_EXPLORE = 'HYDRATE_EXPLORE';
export const hydrateExplore =
({ form_data, slice, dataset }: ExplorePageInitialData) =>
({ form_data, slice, dataset, metadata }: ExplorePageInitialData) =>
(dispatch: Dispatch, getState: () => ExplorePageState) => {
const { user, datasources, charts, sliceEntities, common, explore } =
getState();
@ -123,6 +123,7 @@ export const hydrateExplore =
controlsTransferred: explore.controlsTransferred,
standalone: getUrlParam(URL_PARAMS.standalone),
force: getUrlParam(URL_PARAMS.force),
metadata,
};
// apply initial mapStateToProps for all controls, must execute AFTER

View File

@ -36,7 +36,7 @@ window.featureFlags = {
[FeatureFlag.EMBEDDABLE_CHARTS]: true,
};
const createProps = () => ({
const createProps = (additionalProps = {}) => ({
chart: {
id: 1,
latestQueryFormData: {
@ -63,7 +63,7 @@ const createProps = () => ({
changed_on: '2021-03-19T16:30:56.750230',
changed_on_humanized: '7 days ago',
datasource: 'FCC 2018 Survey',
description: null,
description: 'Simple description',
description_markeddown: '',
edit_url: '/chart/edit/318',
form_data: {
@ -106,10 +106,19 @@ const createProps = () => ({
user: {
userId: 1,
},
metadata: {
created_on_humanized: 'a week ago',
changed_on_humanized: '2 days ago',
owners: ['John Doe'],
created_by: 'John Doe',
changed_by: 'John Doe',
dashboards: [{ id: 1, dashboard_title: 'Test' }],
},
onSaveChart: jest.fn(),
canOverwrite: false,
canDownload: false,
isStarred: false,
...additionalProps,
});
fetchMock.post(
@ -147,6 +156,27 @@ test('Cancelling changes to the properties should reset previous properties', as
expect(await screen.findByDisplayValue(prevChartName)).toBeInTheDocument();
});
test('renders the metadata bar when saved', async () => {
const props = createProps({ showTitlePanelItems: true });
render(<ExploreHeader {...props} />, { useRedux: true });
expect(
await screen.findByText('Added to 1 dashboard(s)'),
).toBeInTheDocument();
expect(await screen.findByText('Simple description')).toBeInTheDocument();
expect(await screen.findByText('John Doe')).toBeInTheDocument();
expect(await screen.findByText('2 days ago')).toBeInTheDocument();
});
test('does not render the metadata bar when not saved', async () => {
const props = createProps({ showTitlePanelItems: true, slice: null });
render(<ExploreHeader {...props} />, { useRedux: true });
await waitFor(() =>
expect(
screen.queryByText('Added to 1 dashboard(s)'),
).not.toBeInTheDocument(),
);
});
test('Save chart', async () => {
const props = createProps();
render(<ExploreHeader {...props} />, { useRedux: true });

View File

@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import React, { useEffect, useState } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import PropTypes from 'prop-types';
@ -24,6 +24,7 @@ import { Tooltip } from 'src/components/Tooltip';
import {
CategoricalColorNamespace,
css,
logging,
SupersetClient,
t,
} from '@superset-ui/core';
@ -35,6 +36,7 @@ import Icons from 'src/components/Icons';
import PropertiesModal from 'src/explore/components/PropertiesModal';
import { sliceUpdated } from 'src/explore/actions/exploreActions';
import { PageHeaderWithActions } from 'src/components/PageHeaderWithActions';
import MetadataBar, { MetadataType } from 'src/components/MetadataBar';
import { useExploreAdditionalActionsMenu } from '../useExploreAdditionalActionsMenu';
const propTypes = {
@ -60,6 +62,15 @@ const saveButtonStyles = theme => css`
}
`;
const additionalItemsStyles = theme => css`
display: flex;
align-items: center;
margin-left: ${theme.gridUnit}px;
& > span {
margin-right: ${theme.gridUnit * 3}px;
}
`;
export const ExploreChartHeader = ({
dashboardId,
slice,
@ -75,51 +86,51 @@ export const ExploreChartHeader = ({
sliceName,
onSaveChart,
saveDisabled,
metadata,
}) => {
const { latestQueryFormData, sliceFormData } = chart;
const [isPropertiesModalOpen, setIsPropertiesModalOpen] = useState(false);
const fetchChartDashboardData = async () => {
await SupersetClient.get({
endpoint: `/api/v1/chart/${slice.slice_id}`,
})
.then(res => {
const response = res?.json?.result;
if (response && response.dashboards && response.dashboards.length) {
const { dashboards } = response;
const dashboard =
dashboardId &&
dashboards.length &&
dashboards.find(d => d.id === dashboardId);
const updateCategoricalNamespace = async () => {
const { dashboards } = metadata || {};
const dashboard =
dashboardId && dashboards && dashboards.find(d => d.id === dashboardId);
if (dashboard && dashboard.json_metadata) {
// setting the chart to use the dashboard custom label colors if any
const metadata = JSON.parse(dashboard.json_metadata);
const sharedLabelColors = metadata.shared_label_colors || {};
const customLabelColors = metadata.label_colors || {};
const mergedLabelColors = {
...sharedLabelColors,
...customLabelColors,
};
if (dashboard) {
try {
// Dashboards from metadata don't contain the json_metadata field
// to avoid unnecessary payload. Here we query for the dashboard json_metadata.
const response = await SupersetClient.get({
endpoint: `/api/v1/dashboard/${dashboard.id}`,
});
const result = response?.json?.result;
const categoricalNamespace =
CategoricalColorNamespace.getNamespace();
// setting the chart to use the dashboard custom label colors if any
const metadata = JSON.parse(result.json_metadata);
const sharedLabelColors = metadata.shared_label_colors || {};
const customLabelColors = metadata.label_colors || {};
const mergedLabelColors = {
...sharedLabelColors,
...customLabelColors,
};
Object.keys(mergedLabelColors).forEach(label => {
categoricalNamespace.setColor(
label,
mergedLabelColors[label],
metadata.color_scheme,
);
});
}
}
})
.catch(() => {});
const categoricalNamespace = CategoricalColorNamespace.getNamespace();
Object.keys(mergedLabelColors).forEach(label => {
categoricalNamespace.setColor(
label,
mergedLabelColors[label],
metadata.color_scheme,
);
});
} catch (error) {
logging.info(t('Unable to retrieve dashboard colors'));
}
}
};
useEffect(() => {
if (dashboardId) fetchChartDashboardData();
if (dashboardId) updateCategoricalNamespace();
}, []);
const openPropertiesModal = () => {
@ -140,6 +151,38 @@ export const ExploreChartHeader = ({
ownState,
);
const metadataBar = useMemo(() => {
if (!metadata) {
return null;
}
const items = [];
items.push({
type: MetadataType.DASHBOARDS,
title:
metadata.dashboards.length > 0
? t('Added to %s dashboard(s)', metadata.dashboards.length)
: t('Not added to any dashboard'),
});
items.push({
type: MetadataType.LAST_MODIFIED,
value: metadata.changed_on_humanized,
modifiedBy: metadata.changed_by || t('Not available'),
});
items.push({
type: MetadataType.OWNER,
createdBy: metadata.created_by || t('Not available'),
owners: metadata.owners.length > 0 ? metadata.owners : t('None'),
createdOn: metadata.created_on_humanized,
});
if (slice?.description) {
items.push({
type: MetadataType.DESCRIPTION,
value: slice?.description,
});
}
return <MetadataBar items={items} />;
}, [metadata, slice?.description]);
const oldSliceName = slice?.slice_name;
return (
<>
@ -168,16 +211,19 @@ export const ExploreChartHeader = ({
showTooltip: true,
}}
titlePanelAdditionalItems={
sliceFormData ? (
<AlteredSliceTag
className="altered"
origFormData={{
...sliceFormData,
chartTitle: oldSliceName,
}}
currentFormData={{ ...formData, chartTitle: sliceName }}
/>
) : null
<div css={additionalItemsStyles}>
{sliceFormData ? (
<AlteredSliceTag
className="altered"
origFormData={{
...sliceFormData,
chartTitle: oldSliceName,
}}
currentFormData={{ ...formData, chartTitle: sliceName }}
/>
) : null}
{metadataBar}
</div>
}
rightPanelAdditionalItems={
<Tooltip

View File

@ -44,6 +44,14 @@ const reduxState = {
slice: {
slice_id: 1,
},
metadata: {
created_on_humanized: 'a week ago',
changed_on_humanized: '2 days ago',
owners: ['John Doe'],
created_by: 'John Doe',
changed_by: 'John Doe',
dashboards: [{ id: 1, dashboard_title: 'Test' }],
},
},
charts: {
1: {

View File

@ -565,6 +565,7 @@ function ExploreViewContainer(props) {
reports={props.reports}
onSaveChart={toggleModal}
saveDisabled={errorMessage || props.chart.chartStatus === 'loading'}
metadata={props.metadata}
/>
<ExplorePanelContainer id="explore-container">
<Global
@ -706,7 +707,7 @@ ExploreViewContainer.propTypes = propTypes;
function mapStateToProps(state) {
const { explore, charts, common, impressionId, dataMask, reports, user } =
state;
const { controls, slice, datasource } = explore;
const { controls, slice, datasource, metadata } = explore;
const form_data = getFormDataFromControls(controls);
const slice_id = form_data.slice_id ?? slice?.slice_id ?? 0; // 0 - unsaved chart
form_data.extra_form_data = mergeExtraFormData(
@ -752,6 +753,7 @@ function mapStateToProps(state) {
user,
exploreState: explore,
reports,
metadata,
};
}

View File

@ -159,6 +159,7 @@ function PropertiesModal({
...payload,
...res.json.result,
id: slice.slice_id,
owners: selectedOwners,
};
onSave(updatedChart);
addSuccessToast(t('Chart properties updated'));

View File

@ -245,9 +245,17 @@ export default function exploreReducer(state = {}, action) {
slice: {
...state.slice,
...action.slice,
owners: action.slice.owners ?? null,
owners: action.slice.owners
? action.slice.owners.map(owner => owner.value)
: null,
},
sliceName: action.slice.slice_name ?? state.sliceName,
metadata: {
...state.metadata,
owners: action.slice.owners
? action.slice.owners.map(owner => owner.label)
: null,
},
};
},
[actions.SET_FORCE_QUERY]() {

View File

@ -72,6 +72,13 @@ export interface ExplorePageInitialData {
dataset: Dataset;
form_data: QueryFormData;
slice: Slice | null;
metadata?: {
created_on_humanized: string;
changed_on_humanized: string;
owners: string[];
created_by?: string;
changed_by?: string;
};
}
export interface ExploreResponsePayload {

View File

@ -179,9 +179,11 @@ class DatasetRestApi(BaseSupersetModelRestApi):
"extra",
"kind",
"created_on",
"created_on_humanized",
"created_by.first_name",
"created_by.last_name",
"changed_on",
"changed_on_humanized",
"changed_by.first_name",
"changed_by.last_name",
]

View File

@ -153,11 +153,29 @@ class GetExploreCommand(BaseCommand, ABC):
except (SupersetException, SQLAlchemyError):
dataset_data = dummy_dataset_data
metadata = None
if slc:
metadata = {
"created_on_humanized": slc.created_on_humanized,
"changed_on_humanized": slc.changed_on_humanized,
"owners": [owner.get_full_name() for owner in slc.owners],
"dashboards": [
{"id": dashboard.id, "dashboard_title": dashboard.dashboard_title}
for dashboard in slc.dashboards
],
}
if slc.created_by:
metadata["created_by"] = slc.created_by.get_full_name()
if slc.changed_by:
metadata["changed_by"] = slc.changed_by.get_full_name()
return {
"dataset": sanitize_datasource_data(dataset_data),
"form_data": form_data,
"slice": slc.data if slc else None,
"message": message,
"metadata": metadata,
}
def validate(self) -> None: