mirror of
https://github.com/apache/superset.git
synced 2026-06-20 23:19:18 +00:00
Compare commits
5 Commits
fix-report
...
fix/report
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59f36997cf | ||
|
|
6e2db42d98 | ||
|
|
28aedc82c3 | ||
|
|
f56524bb71 | ||
|
|
4ae9980e4c |
4
.github/dependabot.yml
vendored
4
.github/dependabot.yml
vendored
@@ -3,10 +3,6 @@ enable-beta-ecosystems: true
|
|||||||
updates:
|
updates:
|
||||||
- package-ecosystem: "github-actions"
|
- package-ecosystem: "github-actions"
|
||||||
directory: "/"
|
directory: "/"
|
||||||
ignore:
|
|
||||||
# Ignore temporarily as release schedule is too mentally taxing for dep-handling maintainers
|
|
||||||
# Additionally, very few PRs are reviewed by this action.
|
|
||||||
- dependency-name: anthropics/claude-code-action
|
|
||||||
schedule:
|
schedule:
|
||||||
interval: "daily"
|
interval: "daily"
|
||||||
cooldown:
|
cooldown:
|
||||||
|
|||||||
88
.github/workflows/claude.yml
vendored
88
.github/workflows/claude.yml
vendored
@@ -1,88 +0,0 @@
|
|||||||
name: Claude PR Assistant
|
|
||||||
|
|
||||||
on:
|
|
||||||
issue_comment:
|
|
||||||
types: [created]
|
|
||||||
pull_request_review_comment:
|
|
||||||
types: [created]
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
check-permissions:
|
|
||||||
if: |
|
|
||||||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
|
||||||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude'))
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
outputs:
|
|
||||||
allowed: ${{ steps.check.outputs.allowed }}
|
|
||||||
steps:
|
|
||||||
- name: Check if user is allowed
|
|
||||||
id: check
|
|
||||||
env:
|
|
||||||
COMMENTER: ${{ github.event.comment.user.login }}
|
|
||||||
run: |
|
|
||||||
# List of allowed users
|
|
||||||
ALLOWED_USERS="mistercrunch,rusackas"
|
|
||||||
|
|
||||||
echo "Checking permissions for user: $COMMENTER"
|
|
||||||
|
|
||||||
# Check if user is in allowed list
|
|
||||||
if [[ ",$ALLOWED_USERS," == *",$COMMENTER,"* ]]; then
|
|
||||||
echo "allowed=true" >> $GITHUB_OUTPUT
|
|
||||||
echo "✅ User $COMMENTER is allowed to use Claude"
|
|
||||||
else
|
|
||||||
echo "allowed=false" >> $GITHUB_OUTPUT
|
|
||||||
echo "❌ User $COMMENTER is not allowed to use Claude"
|
|
||||||
fi
|
|
||||||
|
|
||||||
deny-access:
|
|
||||||
needs: check-permissions
|
|
||||||
if: needs.check-permissions.outputs.allowed == 'false'
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
issues: write
|
|
||||||
pull-requests: write
|
|
||||||
steps:
|
|
||||||
- name: Comment access denied
|
|
||||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
|
||||||
env:
|
|
||||||
COMMENTER_LOGIN: ${{ github.event.comment.user.login || github.event.review.user.login || github.event.issue.user.login }}
|
|
||||||
with:
|
|
||||||
script: |
|
|
||||||
const commenter = process.env.COMMENTER_LOGIN;
|
|
||||||
const message = `👋 Hi @${commenter}!
|
|
||||||
|
|
||||||
Thanks for trying to use Claude Code, but currently only certain team members have access to this feature.
|
|
||||||
|
|
||||||
If you believe you should have access, please contact a project maintainer.`;
|
|
||||||
|
|
||||||
await github.rest.issues.createComment({
|
|
||||||
owner: context.repo.owner,
|
|
||||||
repo: context.repo.repo,
|
|
||||||
issue_number: context.issue.number,
|
|
||||||
body: message
|
|
||||||
});
|
|
||||||
|
|
||||||
claude-code-action:
|
|
||||||
needs: check-permissions
|
|
||||||
if: needs.check-permissions.outputs.allowed == 'true'
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
pull-requests: write
|
|
||||||
issues: write
|
|
||||||
id-token: write
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
fetch-depth: 1
|
|
||||||
|
|
||||||
- name: Run Claude PR Action
|
|
||||||
uses: anthropics/claude-code-action@5fb899572b81d2bb648d4d187173a2f423a9677c # beta
|
|
||||||
with:
|
|
||||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
|
||||||
timeout_minutes: "60"
|
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
* specific language governing permissions and limitations
|
* specific language governing permissions and limitations
|
||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
import { PureComponent, ReactNode } from 'react';
|
import { ReactNode, useCallback, useContext, useEffect, useRef } from 'react';
|
||||||
import { t } from '@apache-superset/core/translation';
|
import { t } from '@apache-superset/core/translation';
|
||||||
import { JsonObject } from '@superset-ui/core';
|
import { JsonObject } from '@superset-ui/core';
|
||||||
|
|
||||||
@@ -90,165 +90,61 @@ interface VisibilityEventData {
|
|||||||
ts: number;
|
ts: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
class Dashboard extends PureComponent<DashboardProps> {
|
function unload(event: BeforeUnloadEvent): string {
|
||||||
static contextType = PluginContext;
|
const message = t('You have unsaved changes.');
|
||||||
|
// Set returnValue on the actual event object to trigger the browser prompt
|
||||||
|
event.returnValue = message;
|
||||||
|
return message; // Gecko + Webkit, Safari, Chrome etc.
|
||||||
|
}
|
||||||
|
|
||||||
// Use type assertion when accessing context instead of declare field
|
function onBeforeUnload(hasChanged: boolean): void {
|
||||||
// to avoid babel transformation issues in Jest
|
if (hasChanged) {
|
||||||
|
window.addEventListener('beforeunload', unload);
|
||||||
static defaultProps = {
|
} else {
|
||||||
timeout: 60,
|
window.removeEventListener('beforeunload', unload);
|
||||||
userId: '',
|
|
||||||
};
|
|
||||||
|
|
||||||
appliedFilters: ActiveFilters;
|
|
||||||
|
|
||||||
appliedOwnDataCharts: JsonObject;
|
|
||||||
|
|
||||||
visibilityEventData: VisibilityEventData;
|
|
||||||
|
|
||||||
static onBeforeUnload(hasChanged: boolean): void {
|
|
||||||
if (hasChanged) {
|
|
||||||
window.addEventListener('beforeunload', Dashboard.unload);
|
|
||||||
} else {
|
|
||||||
window.removeEventListener('beforeunload', Dashboard.unload);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static unload(): string {
|
function Dashboard({
|
||||||
const message = t('You have unsaved changes.');
|
actions,
|
||||||
// Gecko + IE: returnValue is typed as boolean but historically accepts string
|
dashboardId,
|
||||||
(window.event as BeforeUnloadEvent).returnValue = message;
|
editMode,
|
||||||
return message; // Gecko + Webkit, Safari, Chrome etc.
|
isPublished,
|
||||||
}
|
hasUnsavedChanges,
|
||||||
|
slices,
|
||||||
|
activeFilters,
|
||||||
|
chartConfiguration,
|
||||||
|
datasources,
|
||||||
|
ownDataCharts,
|
||||||
|
layout,
|
||||||
|
impressionId,
|
||||||
|
timeout = 60,
|
||||||
|
userId = '',
|
||||||
|
children,
|
||||||
|
}: DashboardProps): JSX.Element {
|
||||||
|
const context = useContext(PluginContext) as PluginContextType;
|
||||||
|
|
||||||
constructor(props: DashboardProps) {
|
// Use refs to track mutable values that persist across renders
|
||||||
super(props);
|
const appliedFiltersRef = useRef<ActiveFilters>(activeFilters ?? {});
|
||||||
this.appliedFilters = props.activeFilters ?? {};
|
const appliedOwnDataChartsRef = useRef<JsonObject>(ownDataCharts ?? {});
|
||||||
this.appliedOwnDataCharts = props.ownDataCharts ?? {};
|
const visibilityEventDataRef = useRef<VisibilityEventData>({
|
||||||
this.visibilityEventData = { start_offset: 0, ts: 0 };
|
start_offset: 0,
|
||||||
this.onVisibilityChange = this.onVisibilityChange.bind(this);
|
ts: 0,
|
||||||
}
|
});
|
||||||
|
const prevLayoutRef = useRef<DashboardLayout>(layout);
|
||||||
|
const prevDashboardIdRef = useRef<number>(dashboardId);
|
||||||
|
|
||||||
componentDidMount(): void {
|
const refreshCharts = useCallback(
|
||||||
const bootstrapData = getBootstrapData();
|
(ids: (string | number)[]): void => {
|
||||||
const { editMode, isPublished, layout } = this.props;
|
ids.forEach(id => {
|
||||||
const eventData: Record<string, unknown> = {
|
actions.triggerQuery(true, id);
|
||||||
is_soft_navigation: Logger.timeOriginOffset > 0,
|
|
||||||
is_edit_mode: editMode,
|
|
||||||
mount_duration: Logger.getTimestamp(),
|
|
||||||
is_empty: isDashboardEmpty(layout),
|
|
||||||
is_published: isPublished,
|
|
||||||
bootstrap_data_length: JSON.stringify(bootstrapData).length,
|
|
||||||
};
|
|
||||||
const directLinkComponentId = getLocationHash();
|
|
||||||
if (directLinkComponentId) {
|
|
||||||
eventData.target_id = directLinkComponentId;
|
|
||||||
}
|
|
||||||
this.props.actions.logEvent(LOG_ACTIONS_MOUNT_DASHBOARD, eventData);
|
|
||||||
|
|
||||||
// Handle browser tab visibility change
|
|
||||||
if (document.visibilityState === 'hidden') {
|
|
||||||
this.visibilityEventData = {
|
|
||||||
start_offset: Logger.getTimestamp(),
|
|
||||||
ts: new Date().getTime(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
window.addEventListener('visibilitychange', this.onVisibilityChange);
|
|
||||||
this.applyCharts();
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidUpdate(prevProps: DashboardProps): void {
|
|
||||||
this.applyCharts();
|
|
||||||
const currentChartIds = getChartIdsFromLayout(prevProps.layout);
|
|
||||||
const nextChartIds = getChartIdsFromLayout(this.props.layout);
|
|
||||||
|
|
||||||
if (prevProps.dashboardId !== this.props.dashboardId) {
|
|
||||||
// single-page-app navigation check
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentChartIds.length < nextChartIds.length) {
|
|
||||||
const newChartIds = nextChartIds.filter(
|
|
||||||
key => currentChartIds.indexOf(key) === -1,
|
|
||||||
);
|
|
||||||
newChartIds.forEach(newChartId =>
|
|
||||||
this.props.actions.addSliceToDashboard(
|
|
||||||
newChartId,
|
|
||||||
getLayoutComponentFromChartId(this.props.layout, newChartId),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} else if (currentChartIds.length > nextChartIds.length) {
|
|
||||||
// remove chart
|
|
||||||
const removedChartIds = currentChartIds.filter(
|
|
||||||
key => nextChartIds.indexOf(key) === -1,
|
|
||||||
);
|
|
||||||
removedChartIds.forEach(removedChartId =>
|
|
||||||
this.props.actions.removeSliceFromDashboard(removedChartId),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
applyCharts(): void {
|
|
||||||
const {
|
|
||||||
activeFilters,
|
|
||||||
ownDataCharts,
|
|
||||||
chartConfiguration,
|
|
||||||
hasUnsavedChanges,
|
|
||||||
editMode,
|
|
||||||
} = this.props;
|
|
||||||
const { appliedFilters, appliedOwnDataCharts } = this;
|
|
||||||
if (!chartConfiguration) {
|
|
||||||
// For a first loading we need to wait for cross filters charts data loaded to get all active filters
|
|
||||||
// for correct comparing of filters to avoid unnecessary requests
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
!editMode &&
|
|
||||||
(!areObjectsEqual(appliedOwnDataCharts, ownDataCharts, {
|
|
||||||
ignoreUndefined: true,
|
|
||||||
}) ||
|
|
||||||
!areObjectsEqual(appliedFilters, activeFilters, {
|
|
||||||
ignoreUndefined: true,
|
|
||||||
}))
|
|
||||||
) {
|
|
||||||
this.applyFilters();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasUnsavedChanges) {
|
|
||||||
Dashboard.onBeforeUnload(true);
|
|
||||||
} else {
|
|
||||||
Dashboard.onBeforeUnload(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
componentWillUnmount(): void {
|
|
||||||
window.removeEventListener('visibilitychange', this.onVisibilityChange);
|
|
||||||
this.props.actions.clearDataMaskState();
|
|
||||||
this.props.actions.clearAllChartStates();
|
|
||||||
}
|
|
||||||
|
|
||||||
onVisibilityChange(): void {
|
|
||||||
if (document.visibilityState === 'hidden') {
|
|
||||||
// from visible to hidden
|
|
||||||
this.visibilityEventData = {
|
|
||||||
start_offset: Logger.getTimestamp(),
|
|
||||||
ts: new Date().getTime(),
|
|
||||||
};
|
|
||||||
} else if (document.visibilityState === 'visible') {
|
|
||||||
// from hidden to visible
|
|
||||||
const logStart = this.visibilityEventData.start_offset;
|
|
||||||
this.props.actions.logEvent(LOG_ACTIONS_HIDE_BROWSER_TAB, {
|
|
||||||
...this.visibilityEventData,
|
|
||||||
duration: Logger.getTimestamp() - logStart,
|
|
||||||
});
|
});
|
||||||
}
|
},
|
||||||
}
|
[actions],
|
||||||
|
);
|
||||||
|
|
||||||
applyFilters(): void {
|
const applyFilters = useCallback((): void => {
|
||||||
const { appliedFilters } = this;
|
const appliedFilters = appliedFiltersRef.current;
|
||||||
const { activeFilters, ownDataCharts, slices } = this.props;
|
|
||||||
|
|
||||||
// refresh charts if a filter was removed, added, or changed
|
// refresh charts if a filter was removed, added, or changed
|
||||||
|
|
||||||
@@ -258,7 +154,7 @@ class Dashboard extends PureComponent<DashboardProps> {
|
|||||||
const allKeys = new Set(currFilterKeys.concat(appliedFilterKeys));
|
const allKeys = new Set(currFilterKeys.concat(appliedFilterKeys));
|
||||||
const affectedChartIds: (string | number)[] = getAffectedOwnDataCharts(
|
const affectedChartIds: (string | number)[] = getAffectedOwnDataCharts(
|
||||||
ownDataCharts,
|
ownDataCharts,
|
||||||
this.appliedOwnDataCharts,
|
appliedOwnDataChartsRef.current,
|
||||||
);
|
);
|
||||||
|
|
||||||
[...allKeys].forEach(filterKey => {
|
[...allKeys].forEach(filterKey => {
|
||||||
@@ -321,24 +217,157 @@ class Dashboard extends PureComponent<DashboardProps> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// remove dup in affectedChartIds
|
// remove dup in affectedChartIds
|
||||||
this.refreshCharts([...new Set(affectedChartIds)]);
|
refreshCharts([...new Set(affectedChartIds)]);
|
||||||
this.appliedFilters = activeFilters;
|
appliedFiltersRef.current = activeFilters;
|
||||||
this.appliedOwnDataCharts = ownDataCharts;
|
appliedOwnDataChartsRef.current = ownDataCharts;
|
||||||
}
|
}, [activeFilters, ownDataCharts, slices, refreshCharts]);
|
||||||
|
|
||||||
refreshCharts(ids: (string | number)[]): void {
|
const applyCharts = useCallback((): void => {
|
||||||
ids.forEach(id => {
|
if (!chartConfiguration) {
|
||||||
this.props.actions.triggerQuery(true, id);
|
// For a first loading we need to wait for cross filters charts data loaded to get all active filters
|
||||||
});
|
// for correct comparing of filters to avoid unnecessary requests
|
||||||
}
|
return;
|
||||||
|
|
||||||
render(): ReactNode {
|
|
||||||
const context = this.context as PluginContextType;
|
|
||||||
if (context.loading) {
|
|
||||||
return <Loading />;
|
|
||||||
}
|
}
|
||||||
return this.props.children;
|
|
||||||
|
if (
|
||||||
|
!editMode &&
|
||||||
|
(!areObjectsEqual(appliedOwnDataChartsRef.current, ownDataCharts, {
|
||||||
|
ignoreUndefined: true,
|
||||||
|
}) ||
|
||||||
|
!areObjectsEqual(appliedFiltersRef.current, activeFilters, {
|
||||||
|
ignoreUndefined: true,
|
||||||
|
}))
|
||||||
|
) {
|
||||||
|
applyFilters();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasUnsavedChanges) {
|
||||||
|
onBeforeUnload(true);
|
||||||
|
} else {
|
||||||
|
onBeforeUnload(false);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
chartConfiguration,
|
||||||
|
editMode,
|
||||||
|
ownDataCharts,
|
||||||
|
activeFilters,
|
||||||
|
hasUnsavedChanges,
|
||||||
|
applyFilters,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const onVisibilityChange = useCallback((): void => {
|
||||||
|
if (document.visibilityState === 'hidden') {
|
||||||
|
// from visible to hidden
|
||||||
|
visibilityEventDataRef.current = {
|
||||||
|
start_offset: Logger.getTimestamp(),
|
||||||
|
ts: new Date().getTime(),
|
||||||
|
};
|
||||||
|
} else if (document.visibilityState === 'visible') {
|
||||||
|
// from hidden to visible
|
||||||
|
const logStart = visibilityEventDataRef.current.start_offset;
|
||||||
|
actions.logEvent(LOG_ACTIONS_HIDE_BROWSER_TAB, {
|
||||||
|
...visibilityEventDataRef.current,
|
||||||
|
duration: Logger.getTimestamp() - logStart,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [actions]);
|
||||||
|
|
||||||
|
// Refs that always point at the latest closures so the mount-only effect's
|
||||||
|
// listeners/cleanup never invoke a stale `actions` closure when `actions`
|
||||||
|
// identity changes.
|
||||||
|
const onVisibilityChangeRef = useRef(onVisibilityChange);
|
||||||
|
const actionsRef = useRef(actions);
|
||||||
|
useEffect(() => {
|
||||||
|
onVisibilityChangeRef.current = onVisibilityChange;
|
||||||
|
actionsRef.current = actions;
|
||||||
|
});
|
||||||
|
|
||||||
|
// componentDidMount equivalent
|
||||||
|
useEffect(() => {
|
||||||
|
const bootstrapData = getBootstrapData();
|
||||||
|
const eventData: Record<string, unknown> = {
|
||||||
|
is_soft_navigation: Logger.timeOriginOffset > 0,
|
||||||
|
is_edit_mode: editMode,
|
||||||
|
mount_duration: Logger.getTimestamp(),
|
||||||
|
is_empty: isDashboardEmpty(layout),
|
||||||
|
is_published: isPublished,
|
||||||
|
bootstrap_data_length: JSON.stringify(bootstrapData).length,
|
||||||
|
};
|
||||||
|
const directLinkComponentId = getLocationHash();
|
||||||
|
if (directLinkComponentId) {
|
||||||
|
eventData.target_id = directLinkComponentId;
|
||||||
|
}
|
||||||
|
actions.logEvent(LOG_ACTIONS_MOUNT_DASHBOARD, eventData);
|
||||||
|
|
||||||
|
// Handle browser tab visibility change
|
||||||
|
if (document.visibilityState === 'hidden') {
|
||||||
|
visibilityEventDataRef.current = {
|
||||||
|
start_offset: Logger.getTimestamp(),
|
||||||
|
ts: new Date().getTime(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const handleVisibilityChange = () => onVisibilityChangeRef.current();
|
||||||
|
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||||
|
|
||||||
|
// componentWillUnmount equivalent
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||||
|
onBeforeUnload(false); // Remove beforeunload listener on unmount
|
||||||
|
actionsRef.current.clearDataMaskState();
|
||||||
|
actionsRef.current.clearAllChartStates();
|
||||||
|
};
|
||||||
|
// Only run on mount/unmount - listeners/cleanup go through refs to avoid
|
||||||
|
// capturing stale closures.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Apply charts on every render (like componentDidMount + componentDidUpdate calling applyCharts)
|
||||||
|
useEffect(() => {
|
||||||
|
applyCharts();
|
||||||
|
}, [applyCharts]);
|
||||||
|
|
||||||
|
// componentDidUpdate equivalent for layout changes
|
||||||
|
useEffect(() => {
|
||||||
|
const prevLayout = prevLayoutRef.current;
|
||||||
|
const prevDashboardId = prevDashboardIdRef.current;
|
||||||
|
|
||||||
|
// Update refs for next comparison
|
||||||
|
prevLayoutRef.current = layout;
|
||||||
|
prevDashboardIdRef.current = dashboardId;
|
||||||
|
|
||||||
|
const currentChartIds = getChartIdsFromLayout(prevLayout);
|
||||||
|
const nextChartIds = getChartIdsFromLayout(layout);
|
||||||
|
|
||||||
|
if (prevDashboardId !== dashboardId) {
|
||||||
|
// single-page-app navigation check
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentChartIds.length < nextChartIds.length) {
|
||||||
|
const newChartIds = nextChartIds.filter(
|
||||||
|
key => currentChartIds.indexOf(key) === -1,
|
||||||
|
);
|
||||||
|
newChartIds.forEach(newChartId =>
|
||||||
|
actions.addSliceToDashboard(
|
||||||
|
newChartId,
|
||||||
|
getLayoutComponentFromChartId(layout, newChartId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else if (currentChartIds.length > nextChartIds.length) {
|
||||||
|
// remove chart
|
||||||
|
const removedChartIds = currentChartIds.filter(
|
||||||
|
key => nextChartIds.indexOf(key) === -1,
|
||||||
|
);
|
||||||
|
removedChartIds.forEach(removedChartId =>
|
||||||
|
actions.removeSliceFromDashboard(removedChartId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [layout, dashboardId, actions]);
|
||||||
|
|
||||||
|
if (context.loading) {
|
||||||
|
return <Loading />;
|
||||||
}
|
}
|
||||||
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Dashboard;
|
export default Dashboard;
|
||||||
|
|||||||
@@ -31,9 +31,10 @@ export const getRootLevelTabsComponent = (dashboardLayout: DashboardLayout) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const shouldFocusTabs = (
|
export const shouldFocusTabs = (
|
||||||
event: { target: { className: string } },
|
event: { target: HTMLElement },
|
||||||
container: { contains: (arg0: any) => any },
|
container: Pick<Node, 'contains'> | null,
|
||||||
) =>
|
_menuRef: HTMLDivElement | null,
|
||||||
|
): boolean =>
|
||||||
// don't focus the tabs when we click on a tab
|
// don't focus the tabs when we click on a tab
|
||||||
event.target.className === 'ant-tabs-nav-wrap' ||
|
event.target.className === 'ant-tabs-nav-wrap' ||
|
||||||
container.contains(event.target);
|
(container?.contains(event.target) ?? false);
|
||||||
|
|||||||
@@ -16,12 +16,11 @@
|
|||||||
* specific language governing permissions and limitations
|
* specific language governing permissions and limitations
|
||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
import { PureComponent, Fragment } from 'react';
|
import { Fragment, useCallback, useRef, useState } from 'react';
|
||||||
import { withTheme } from '@emotion/react';
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { addAlpha } from '@superset-ui/core';
|
import { addAlpha } from '@superset-ui/core';
|
||||||
import { css, styled, type SupersetTheme } from '@apache-superset/core/theme';
|
|
||||||
import { t } from '@apache-superset/core/translation';
|
import { t } from '@apache-superset/core/translation';
|
||||||
|
import { css, styled, useTheme } from '@apache-superset/core/theme';
|
||||||
import { EmptyState } from '@superset-ui/core/components';
|
import { EmptyState } from '@superset-ui/core/components';
|
||||||
import { Icons } from '@superset-ui/core/components/Icons';
|
import { Icons } from '@superset-ui/core/components/Icons';
|
||||||
import { navigateTo } from 'src/utils/navigationUtils';
|
import { navigateTo } from 'src/utils/navigationUtils';
|
||||||
@@ -48,11 +47,6 @@ export interface DashboardGridProps {
|
|||||||
setEditMode?: (editMode: boolean) => void;
|
setEditMode?: (editMode: boolean) => void;
|
||||||
width: number;
|
width: number;
|
||||||
dashboardId?: number;
|
dashboardId?: number;
|
||||||
theme: SupersetTheme;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DashboardGridState {
|
|
||||||
isResizing: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DropProps {
|
interface DropProps {
|
||||||
@@ -131,261 +125,235 @@ const GridColumnGuide = styled.div`
|
|||||||
`};
|
`};
|
||||||
`;
|
`;
|
||||||
|
|
||||||
class DashboardGrid extends PureComponent<
|
function DashboardGrid({
|
||||||
DashboardGridProps,
|
depth,
|
||||||
DashboardGridState
|
editMode,
|
||||||
> {
|
canEdit,
|
||||||
grid: HTMLDivElement | null;
|
gridComponent,
|
||||||
|
handleComponentDrop,
|
||||||
|
isComponentVisible,
|
||||||
|
resizeComponent,
|
||||||
|
setDirectPathToChild,
|
||||||
|
setEditMode,
|
||||||
|
width,
|
||||||
|
dashboardId,
|
||||||
|
}: DashboardGridProps) {
|
||||||
|
const theme = useTheme();
|
||||||
|
const [isResizing, setIsResizing] = useState(false);
|
||||||
|
const gridRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
constructor(props: DashboardGridProps) {
|
const setGridRef = useCallback((ref: HTMLDivElement | null): void => {
|
||||||
super(props);
|
gridRef.current = ref;
|
||||||
this.state = {
|
}, []);
|
||||||
isResizing: false,
|
|
||||||
};
|
|
||||||
this.grid = null;
|
|
||||||
this.handleResizeStart = this.handleResizeStart.bind(this);
|
|
||||||
this.handleResize = this.handleResize.bind(this);
|
|
||||||
this.handleResizeStop = this.handleResizeStop.bind(this);
|
|
||||||
this.handleTopDropTargetDrop = this.handleTopDropTargetDrop.bind(this);
|
|
||||||
this.getRowGuidePosition = this.getRowGuidePosition.bind(this);
|
|
||||||
this.setGridRef = this.setGridRef.bind(this);
|
|
||||||
this.handleChangeTab = this.handleChangeTab.bind(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
getRowGuidePosition(resizeRef: HTMLElement | null): number | null {
|
const handleResizeStart = useCallback((): void => {
|
||||||
if (resizeRef && this.grid) {
|
setIsResizing(true);
|
||||||
return (
|
}, []);
|
||||||
resizeRef.getBoundingClientRect().bottom -
|
|
||||||
this.grid.getBoundingClientRect().top -
|
|
||||||
2
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
setGridRef(ref: HTMLDivElement | null): void {
|
const handleResize = useCallback(
|
||||||
this.grid = ref;
|
(
|
||||||
}
|
_event: MouseEvent | TouchEvent,
|
||||||
|
_direction: string,
|
||||||
|
_elementRef: HTMLElement,
|
||||||
|
_delta: { width: number; height: number },
|
||||||
|
): void => {
|
||||||
|
// no-op: resize position tracking not implemented
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
handleResizeStart(): void {
|
const handleResizeStop = useCallback(
|
||||||
this.setState(() => ({
|
(
|
||||||
isResizing: true,
|
_event: MouseEvent | TouchEvent,
|
||||||
}));
|
_direction: string,
|
||||||
}
|
_elementRef: HTMLElement,
|
||||||
|
delta: { width: number; height: number },
|
||||||
handleResize(
|
id: string,
|
||||||
_event: MouseEvent | TouchEvent,
|
): void => {
|
||||||
_direction: string,
|
resizeComponent({
|
||||||
_elementRef: HTMLElement,
|
id,
|
||||||
_delta: { width: number; height: number },
|
width: delta.width,
|
||||||
): void {
|
height: delta.height,
|
||||||
// no-op: resize position is tracked via getRowGuidePosition
|
|
||||||
}
|
|
||||||
|
|
||||||
handleResizeStop(
|
|
||||||
_event: MouseEvent | TouchEvent,
|
|
||||||
_direction: string,
|
|
||||||
_elementRef: HTMLElement,
|
|
||||||
delta: { width: number; height: number },
|
|
||||||
id: string,
|
|
||||||
): void {
|
|
||||||
this.props.resizeComponent({
|
|
||||||
id,
|
|
||||||
width: delta.width,
|
|
||||||
height: delta.height,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.setState(() => ({
|
|
||||||
isResizing: false,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
handleTopDropTargetDrop(dropResult: DropResult): void {
|
|
||||||
if (dropResult?.destination) {
|
|
||||||
this.props.handleComponentDrop({
|
|
||||||
...dropResult,
|
|
||||||
destination: {
|
|
||||||
...dropResult.destination,
|
|
||||||
// force appending as the first child if top drop target
|
|
||||||
index: 0,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleChangeTab({ pathToTabIndex }: { pathToTabIndex: string[] }): void {
|
setIsResizing(false);
|
||||||
this.props.setDirectPathToChild(pathToTabIndex);
|
},
|
||||||
}
|
[resizeComponent],
|
||||||
|
);
|
||||||
|
|
||||||
render() {
|
const handleTopDropTargetDrop = useCallback(
|
||||||
const {
|
(dropResult: DropResult): void => {
|
||||||
gridComponent,
|
if (dropResult?.destination) {
|
||||||
handleComponentDrop,
|
handleComponentDrop({
|
||||||
depth,
|
...dropResult,
|
||||||
width,
|
destination: {
|
||||||
isComponentVisible,
|
...dropResult.destination,
|
||||||
editMode,
|
// force appending as the first child if top drop target
|
||||||
canEdit,
|
index: 0,
|
||||||
setEditMode,
|
},
|
||||||
dashboardId,
|
});
|
||||||
theme,
|
}
|
||||||
} = this.props;
|
},
|
||||||
const columnPlusGutterWidth =
|
[handleComponentDrop],
|
||||||
(width + GRID_GUTTER_SIZE) / GRID_COLUMN_COUNT;
|
);
|
||||||
|
|
||||||
const columnWidth = columnPlusGutterWidth - GRID_GUTTER_SIZE;
|
const handleChangeTab = useCallback(
|
||||||
const { isResizing } = this.state;
|
({ pathToTabIndex }: { pathToTabIndex: string[] }): void => {
|
||||||
|
setDirectPathToChild(pathToTabIndex);
|
||||||
|
},
|
||||||
|
[setDirectPathToChild],
|
||||||
|
);
|
||||||
|
|
||||||
const shouldDisplayEmptyState = gridComponent?.children?.length === 0;
|
const columnPlusGutterWidth = (width + GRID_GUTTER_SIZE) / GRID_COLUMN_COUNT;
|
||||||
const shouldDisplayTopLevelTabEmptyState =
|
|
||||||
shouldDisplayEmptyState && gridComponent?.type === TAB_TYPE;
|
|
||||||
|
|
||||||
const dashboardEmptyState = editMode && (
|
const columnWidth = columnPlusGutterWidth - GRID_GUTTER_SIZE;
|
||||||
<EmptyState
|
|
||||||
title={t('Drag and drop components and charts to the dashboard')}
|
|
||||||
description={t(
|
|
||||||
'You can create a new chart or use existing ones from the panel on the right',
|
|
||||||
)}
|
|
||||||
size="large"
|
|
||||||
buttonText={
|
|
||||||
<>
|
|
||||||
<Icons.PlusOutlined iconSize="m" color={theme.colorPrimary} />
|
|
||||||
{t('Create a new chart')}
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
buttonAction={() => {
|
|
||||||
navigateTo(`/chart/add?dashboard_id=${dashboardId}`, {
|
|
||||||
newWindow: true,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
image="chart.svg"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const topLevelTabEmptyState = editMode ? (
|
const shouldDisplayEmptyState = gridComponent?.children?.length === 0;
|
||||||
<EmptyState
|
const shouldDisplayTopLevelTabEmptyState =
|
||||||
title={t('Drag and drop components to this tab')}
|
shouldDisplayEmptyState && gridComponent?.type === TAB_TYPE;
|
||||||
size="large"
|
|
||||||
description={t(
|
|
||||||
`You can create a new chart or use existing ones from the panel on the right`,
|
|
||||||
)}
|
|
||||||
buttonText={
|
|
||||||
<>
|
|
||||||
<Icons.PlusOutlined iconSize="m" color={theme.colorPrimary} />
|
|
||||||
{t('Create a new chart')}
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
buttonAction={() => {
|
|
||||||
navigateTo(`/chart/add?dashboard_id=${dashboardId}`, {
|
|
||||||
newWindow: true,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
image="chart.svg"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<EmptyState
|
|
||||||
title={t('There are no components added to this tab')}
|
|
||||||
size="large"
|
|
||||||
description={
|
|
||||||
canEdit && t('You can add the components in the edit mode.')
|
|
||||||
}
|
|
||||||
buttonText={canEdit ? t('Edit the dashboard') : undefined}
|
|
||||||
buttonAction={
|
|
||||||
canEdit
|
|
||||||
? () => {
|
|
||||||
setEditMode?.(true);
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
image="chart.svg"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
return width < 100 ? null : (
|
const dashboardEmptyState = editMode && (
|
||||||
<>
|
<EmptyState
|
||||||
{shouldDisplayEmptyState && (
|
title={t('Drag and drop components and charts to the dashboard')}
|
||||||
<DashboardEmptyStateContainer>
|
description={t(
|
||||||
{shouldDisplayTopLevelTabEmptyState
|
'You can create a new chart or use existing ones from the panel on the right',
|
||||||
? topLevelTabEmptyState
|
)}
|
||||||
: dashboardEmptyState}
|
size="large"
|
||||||
</DashboardEmptyStateContainer>
|
buttonText={
|
||||||
)}
|
<>
|
||||||
<div className="dashboard-grid" ref={this.setGridRef}>
|
<Icons.PlusOutlined iconSize="m" color={theme.colorPrimary} />
|
||||||
<GridContent
|
{t('Create a new chart')}
|
||||||
className="grid-content"
|
</>
|
||||||
data-test="grid-content"
|
}
|
||||||
editMode={editMode}
|
buttonAction={() => {
|
||||||
>
|
navigateTo(`/chart/add?dashboard_id=${dashboardId}`, {
|
||||||
{/* make the area above components droppable */}
|
newWindow: true,
|
||||||
{editMode && (
|
});
|
||||||
<Droppable
|
}}
|
||||||
component={gridComponent}
|
image="chart.svg"
|
||||||
depth={depth}
|
/>
|
||||||
parentComponent={null}
|
);
|
||||||
index={0}
|
|
||||||
orientation="column"
|
const topLevelTabEmptyState = editMode ? (
|
||||||
onDrop={this.handleTopDropTargetDrop}
|
<EmptyState
|
||||||
className={classNames({
|
title={t('Drag and drop components to this tab')}
|
||||||
'empty-droptarget': true,
|
size="large"
|
||||||
'empty-droptarget--full':
|
description={t(
|
||||||
gridComponent?.children?.length === 0,
|
`You can create a new chart or use existing ones from the panel on the right`,
|
||||||
})}
|
)}
|
||||||
editMode
|
buttonText={
|
||||||
dropToChild={gridComponent?.children?.length === 0}
|
<>
|
||||||
>
|
<Icons.PlusOutlined iconSize="m" color={theme.colorPrimary} />
|
||||||
{renderDraggableContent}
|
{t('Create a new chart')}
|
||||||
</Droppable>
|
</>
|
||||||
)}
|
}
|
||||||
{gridComponent?.children?.map((id, index) => (
|
buttonAction={() => {
|
||||||
<Fragment key={id}>
|
navigateTo(`/chart/add?dashboard_id=${dashboardId}`, {
|
||||||
<DashboardComponent
|
newWindow: true,
|
||||||
id={id}
|
});
|
||||||
parentId={gridComponent.id}
|
}}
|
||||||
depth={depth + 1}
|
image="chart.svg"
|
||||||
index={index}
|
/>
|
||||||
availableColumnCount={GRID_COLUMN_COUNT}
|
) : (
|
||||||
columnWidth={columnWidth}
|
<EmptyState
|
||||||
isComponentVisible={isComponentVisible}
|
title={t('There are no components added to this tab')}
|
||||||
onResizeStart={this.handleResizeStart}
|
size="large"
|
||||||
onResize={this.handleResize}
|
description={canEdit && t('You can add the components in the edit mode.')}
|
||||||
onResizeStop={this.handleResizeStop}
|
buttonText={canEdit ? t('Edit the dashboard') : undefined}
|
||||||
onChangeTab={this.handleChangeTab}
|
buttonAction={
|
||||||
|
canEdit
|
||||||
|
? () => {
|
||||||
|
setEditMode?.(true);
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
image="chart.svg"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
return width < 100 ? null : (
|
||||||
|
<>
|
||||||
|
{shouldDisplayEmptyState && (
|
||||||
|
<DashboardEmptyStateContainer>
|
||||||
|
{shouldDisplayTopLevelTabEmptyState
|
||||||
|
? topLevelTabEmptyState
|
||||||
|
: dashboardEmptyState}
|
||||||
|
</DashboardEmptyStateContainer>
|
||||||
|
)}
|
||||||
|
<div className="dashboard-grid" ref={setGridRef}>
|
||||||
|
<GridContent
|
||||||
|
className="grid-content"
|
||||||
|
data-test="grid-content"
|
||||||
|
editMode={editMode}
|
||||||
|
>
|
||||||
|
{/* make the area above components droppable */}
|
||||||
|
{editMode && (
|
||||||
|
<Droppable
|
||||||
|
component={gridComponent}
|
||||||
|
depth={depth}
|
||||||
|
parentComponent={null}
|
||||||
|
index={0}
|
||||||
|
orientation="column"
|
||||||
|
onDrop={handleTopDropTargetDrop}
|
||||||
|
className={classNames({
|
||||||
|
'empty-droptarget': true,
|
||||||
|
'empty-droptarget--full': gridComponent?.children?.length === 0,
|
||||||
|
})}
|
||||||
|
editMode
|
||||||
|
dropToChild={gridComponent?.children?.length === 0}
|
||||||
|
>
|
||||||
|
{renderDraggableContent}
|
||||||
|
</Droppable>
|
||||||
|
)}
|
||||||
|
{gridComponent?.children?.map((id, index) => (
|
||||||
|
<Fragment key={id}>
|
||||||
|
<DashboardComponent
|
||||||
|
id={id}
|
||||||
|
parentId={gridComponent.id}
|
||||||
|
depth={depth + 1}
|
||||||
|
index={index}
|
||||||
|
availableColumnCount={GRID_COLUMN_COUNT}
|
||||||
|
columnWidth={columnWidth}
|
||||||
|
isComponentVisible={isComponentVisible}
|
||||||
|
onResizeStart={handleResizeStart}
|
||||||
|
onResize={handleResize}
|
||||||
|
onResizeStop={handleResizeStop}
|
||||||
|
onChangeTab={handleChangeTab}
|
||||||
|
/>
|
||||||
|
{/* make the area below components droppable */}
|
||||||
|
{editMode && (
|
||||||
|
<Droppable
|
||||||
|
component={gridComponent}
|
||||||
|
depth={depth}
|
||||||
|
parentComponent={null}
|
||||||
|
index={index + 1}
|
||||||
|
orientation="column"
|
||||||
|
onDrop={handleComponentDrop}
|
||||||
|
className="empty-droptarget"
|
||||||
|
editMode
|
||||||
|
>
|
||||||
|
{renderDraggableContent}
|
||||||
|
</Droppable>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
{isResizing &&
|
||||||
|
Array(GRID_COLUMN_COUNT)
|
||||||
|
.fill(null)
|
||||||
|
.map((_, i) => (
|
||||||
|
<GridColumnGuide
|
||||||
|
key={`grid-column-${i}`}
|
||||||
|
className="grid-column-guide"
|
||||||
|
style={{
|
||||||
|
left: i * GRID_GUTTER_SIZE + i * columnWidth,
|
||||||
|
width: columnWidth,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
{/* make the area below components droppable */}
|
))}
|
||||||
{editMode && (
|
</GridContent>
|
||||||
<Droppable
|
</div>
|
||||||
component={gridComponent}
|
</>
|
||||||
depth={depth}
|
);
|
||||||
parentComponent={null}
|
|
||||||
index={index + 1}
|
|
||||||
orientation="column"
|
|
||||||
onDrop={handleComponentDrop}
|
|
||||||
className="empty-droptarget"
|
|
||||||
editMode
|
|
||||||
>
|
|
||||||
{renderDraggableContent}
|
|
||||||
</Droppable>
|
|
||||||
)}
|
|
||||||
</Fragment>
|
|
||||||
))}
|
|
||||||
{isResizing &&
|
|
||||||
Array(GRID_COLUMN_COUNT)
|
|
||||||
.fill(null)
|
|
||||||
.map((_, i) => (
|
|
||||||
<GridColumnGuide
|
|
||||||
key={`grid-column-${i}`}
|
|
||||||
className="grid-column-guide"
|
|
||||||
style={{
|
|
||||||
left: i * GRID_GUTTER_SIZE + i * columnWidth,
|
|
||||||
width: columnWidth,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</GridContent>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default withTheme(DashboardGrid);
|
export default DashboardGrid;
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
* specific language governing permissions and limitations
|
* specific language governing permissions and limitations
|
||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
import { Component } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { t } from '@apache-superset/core/translation';
|
import { t } from '@apache-superset/core/translation';
|
||||||
import { Tooltip, PublishedLabel } from '@superset-ui/core/components';
|
import { Tooltip, PublishedLabel } from '@superset-ui/core/components';
|
||||||
import { HeaderProps, HeaderDropdownProps } from '../Header/types';
|
import { HeaderProps, HeaderDropdownProps } from '../Header/types';
|
||||||
@@ -43,70 +43,64 @@ const publishedTooltip = t(
|
|||||||
'This dashboard is published. Click to make it a draft.',
|
'This dashboard is published. Click to make it a draft.',
|
||||||
);
|
);
|
||||||
|
|
||||||
export default class PublishedStatus extends Component<DashboardPublishedStatusType> {
|
export default function PublishedStatus({
|
||||||
constructor(props: DashboardPublishedStatusType) {
|
dashboardId,
|
||||||
super(props);
|
userCanEdit,
|
||||||
this.togglePublished = this.togglePublished.bind(this);
|
userCanSave,
|
||||||
}
|
isPublished,
|
||||||
|
savePublished,
|
||||||
|
}: DashboardPublishedStatusType) {
|
||||||
|
const togglePublished = useCallback(() => {
|
||||||
|
savePublished(dashboardId, !isPublished);
|
||||||
|
}, [dashboardId, isPublished, savePublished]);
|
||||||
|
|
||||||
togglePublished() {
|
// Show everybody the draft badge
|
||||||
this.props.savePublished(this.props.dashboardId, !this.props.isPublished);
|
if (!isPublished) {
|
||||||
}
|
// if they can edit the dash, make the badge a button
|
||||||
|
if (userCanEdit && userCanSave) {
|
||||||
render() {
|
|
||||||
const { isPublished, userCanEdit, userCanSave } = this.props;
|
|
||||||
|
|
||||||
// Show everybody the draft badge
|
|
||||||
if (!isPublished) {
|
|
||||||
// if they can edit the dash, make the badge a button
|
|
||||||
if (userCanEdit && userCanSave) {
|
|
||||||
return (
|
|
||||||
<Tooltip
|
|
||||||
id="unpublished-dashboard-tooltip"
|
|
||||||
placement="bottom"
|
|
||||||
title={draftButtonTooltip}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<PublishedLabel
|
|
||||||
isPublished={isPublished}
|
|
||||||
onClick={this.togglePublished}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
id="unpublished-dashboard-tooltip"
|
id="unpublished-dashboard-tooltip"
|
||||||
placement="bottom"
|
placement="bottom"
|
||||||
title={draftDivTooltip}
|
title={draftButtonTooltip}
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<PublishedLabel isPublished={isPublished} />
|
|
||||||
</div>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show the published badge for the owner of the dashboard to toggle
|
|
||||||
if (userCanEdit && userCanSave) {
|
|
||||||
return (
|
|
||||||
<Tooltip
|
|
||||||
id="published-dashboard-tooltip"
|
|
||||||
placement="bottom"
|
|
||||||
title={publishedTooltip}
|
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<PublishedLabel
|
<PublishedLabel
|
||||||
isPublished={isPublished}
|
isPublished={isPublished}
|
||||||
onClick={this.togglePublished}
|
onClick={togglePublished}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
return (
|
||||||
// Don't show anything if one doesn't own the dashboard and it is published
|
<Tooltip
|
||||||
return null;
|
id="unpublished-dashboard-tooltip"
|
||||||
|
placement="bottom"
|
||||||
|
title={draftDivTooltip}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<PublishedLabel isPublished={isPublished} />
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show the published badge for the owner of the dashboard to toggle
|
||||||
|
if (userCanEdit && userCanSave) {
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
id="published-dashboard-tooltip"
|
||||||
|
placement="bottom"
|
||||||
|
title={publishedTooltip}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<PublishedLabel isPublished={isPublished} onClick={togglePublished} />
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't show anything if one doesn't own the dashboard and it is published
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,13 +17,13 @@
|
|||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
/* eslint-env browser */
|
/* eslint-env browser */
|
||||||
import { Component } from 'react';
|
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||||
import { FixedSizeList as List } from 'react-window';
|
import { FixedSizeList as List } from 'react-window';
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
import { createFilter } from 'react-search-input';
|
import { createFilter } from 'react-search-input';
|
||||||
import { t } from '@apache-superset/core/translation';
|
import { t } from '@apache-superset/core/translation';
|
||||||
import { styled, css } from '@apache-superset/core/theme';
|
import { styled, css, useTheme } from '@apache-superset/core/theme';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
@@ -49,7 +49,6 @@ import {
|
|||||||
import { debounce, pickBy } from 'lodash';
|
import { debounce, pickBy } from 'lodash';
|
||||||
import { Dispatch } from 'redux';
|
import { Dispatch } from 'redux';
|
||||||
import { Slice } from 'src/dashboard/types';
|
import { Slice } from 'src/dashboard/types';
|
||||||
import { withTheme, Theme } from '@emotion/react';
|
|
||||||
import { navigateTo } from 'src/utils/navigationUtils';
|
import { navigateTo } from 'src/utils/navigationUtils';
|
||||||
import type { ConnectDragSource } from 'react-dnd';
|
import type { ConnectDragSource } from 'react-dnd';
|
||||||
import AddSliceCard from './AddSliceCard';
|
import AddSliceCard from './AddSliceCard';
|
||||||
@@ -58,7 +57,6 @@ import { DragDroppable } from './dnd/DragDroppable';
|
|||||||
import { datasetLabelLower } from 'src/features/semanticLayers/label';
|
import { datasetLabelLower } from 'src/features/semanticLayers/label';
|
||||||
|
|
||||||
export type SliceAdderProps = {
|
export type SliceAdderProps = {
|
||||||
theme: Theme;
|
|
||||||
fetchSlices: (
|
fetchSlices: (
|
||||||
userId?: number,
|
userId?: number,
|
||||||
filter_value?: string,
|
filter_value?: string,
|
||||||
@@ -77,14 +75,6 @@ export type SliceAdderProps = {
|
|||||||
dashboardId: number;
|
dashboardId: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type SliceAdderState = {
|
|
||||||
filteredSlices: Slice[];
|
|
||||||
searchTerm: string;
|
|
||||||
sortBy: keyof Slice;
|
|
||||||
selectedSliceIdsSet: Set<number>;
|
|
||||||
showOnlyMyCharts: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
const KEYS_TO_FILTERS = ['slice_name', 'viz_type', 'datasource_name'];
|
const KEYS_TO_FILTERS = ['slice_name', 'viz_type', 'datasource_name'];
|
||||||
const KEYS_TO_SORT = {
|
const KEYS_TO_SORT = {
|
||||||
slice_name: t('name'),
|
slice_name: t('name'),
|
||||||
@@ -174,295 +164,308 @@ function getFilteredSortedSlices(
|
|||||||
.filter(createFilter(searchTerm, KEYS_TO_FILTERS))
|
.filter(createFilter(searchTerm, KEYS_TO_FILTERS))
|
||||||
.sort(sortByComparator(sortBy));
|
.sort(sortByComparator(sortBy));
|
||||||
}
|
}
|
||||||
class SliceAdder extends Component<SliceAdderProps, SliceAdderState> {
|
|
||||||
private slicesRequest?: AbortController | Promise<void>;
|
|
||||||
|
|
||||||
static defaultProps = {
|
function SliceAdder({
|
||||||
selectedSliceIds: [],
|
fetchSlices,
|
||||||
editMode: false,
|
updateSlices,
|
||||||
errorMessage: '',
|
isLoading,
|
||||||
};
|
slices,
|
||||||
|
errorMessage = '',
|
||||||
|
userId,
|
||||||
|
selectedSliceIds = [],
|
||||||
|
editMode = false,
|
||||||
|
dashboardId,
|
||||||
|
}: SliceAdderProps) {
|
||||||
|
const theme = useTheme();
|
||||||
|
const slicesRequestRef = useRef<AbortController | Promise<void>>();
|
||||||
|
|
||||||
constructor(props: SliceAdderProps) {
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
super(props);
|
const [sortBy, setSortBy] = useState<keyof Slice>(DEFAULT_SORT_KEY);
|
||||||
this.state = {
|
const [selectedSliceIdsSet, setSelectedSliceIdsSet] = useState(
|
||||||
filteredSlices: [],
|
() => new Set(selectedSliceIds),
|
||||||
searchTerm: '',
|
);
|
||||||
sortBy: DEFAULT_SORT_KEY,
|
|
||||||
selectedSliceIdsSet: new Set(props.selectedSliceIds),
|
|
||||||
showOnlyMyCharts: getItem(
|
|
||||||
LocalStorageKeys.DashboardEditorShowOnlyMyCharts,
|
|
||||||
true,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
this.rowRenderer = this.rowRenderer.bind(this);
|
|
||||||
this.searchUpdated = this.searchUpdated.bind(this);
|
|
||||||
this.handleSelect = this.handleSelect.bind(this);
|
|
||||||
this.userIdForFetch = this.userIdForFetch.bind(this);
|
|
||||||
this.onShowOnlyMyCharts = this.onShowOnlyMyCharts.bind(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
userIdForFetch() {
|
// Refs to track latest values for cleanup effect
|
||||||
return this.state.showOnlyMyCharts ? this.props.userId : undefined;
|
const latestSlicesRef = useRef(slices);
|
||||||
}
|
const latestSelectedSliceIdsSetRef = useRef(selectedSliceIdsSet);
|
||||||
|
const [showOnlyMyCharts, setShowOnlyMyCharts] = useState(() =>
|
||||||
|
getItem(LocalStorageKeys.DashboardEditorShowOnlyMyCharts, true),
|
||||||
|
);
|
||||||
|
|
||||||
componentDidMount() {
|
// Keep refs updated with latest values
|
||||||
this.slicesRequest = this.props.fetchSlices(
|
useEffect(() => {
|
||||||
this.userIdForFetch(),
|
latestSlicesRef.current = slices;
|
||||||
'',
|
}, [slices]);
|
||||||
this.state.sortBy,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidUpdate(prevProps: SliceAdderProps) {
|
useEffect(() => {
|
||||||
const nextState: SliceAdderState = {} as SliceAdderState;
|
latestSelectedSliceIdsSetRef.current = selectedSliceIdsSet;
|
||||||
if (this.props.lastUpdated !== prevProps.lastUpdated) {
|
}, [selectedSliceIdsSet]);
|
||||||
nextState.filteredSlices = getFilteredSortedSlices(
|
|
||||||
this.props.slices,
|
|
||||||
this.state.searchTerm,
|
|
||||||
this.state.sortBy,
|
|
||||||
this.state.showOnlyMyCharts,
|
|
||||||
this.props.userId,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (prevProps.selectedSliceIds !== this.props.selectedSliceIds) {
|
const filteredSlices = useMemo(
|
||||||
nextState.selectedSliceIdsSet = new Set(this.props.selectedSliceIds);
|
() =>
|
||||||
}
|
getFilteredSortedSlices(
|
||||||
|
slices,
|
||||||
if (Object.keys(nextState).length) {
|
|
||||||
this.setState(nextState);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
componentWillUnmount() {
|
|
||||||
// Clears the redux store keeping only selected items
|
|
||||||
const selectedSlices = pickBy(this.props.slices, (value: Slice) =>
|
|
||||||
this.state.selectedSliceIdsSet.has(value.slice_id),
|
|
||||||
);
|
|
||||||
|
|
||||||
this.props.updateSlices(selectedSlices);
|
|
||||||
if (this.slicesRequest instanceof AbortController) {
|
|
||||||
this.slicesRequest.abort();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleChange = debounce(value => {
|
|
||||||
this.searchUpdated(value);
|
|
||||||
this.slicesRequest = this.props.fetchSlices(
|
|
||||||
this.userIdForFetch(),
|
|
||||||
value,
|
|
||||||
this.state.sortBy,
|
|
||||||
);
|
|
||||||
}, 300);
|
|
||||||
|
|
||||||
searchUpdated(searchTerm: string) {
|
|
||||||
this.setState(prevState => ({
|
|
||||||
searchTerm,
|
|
||||||
filteredSlices: getFilteredSortedSlices(
|
|
||||||
this.props.slices,
|
|
||||||
searchTerm,
|
searchTerm,
|
||||||
prevState.sortBy,
|
|
||||||
prevState.showOnlyMyCharts,
|
|
||||||
this.props.userId,
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
handleSelect(sortBy: keyof Slice) {
|
|
||||||
this.setState(prevState => ({
|
|
||||||
sortBy,
|
|
||||||
filteredSlices: getFilteredSortedSlices(
|
|
||||||
this.props.slices,
|
|
||||||
prevState.searchTerm,
|
|
||||||
sortBy,
|
sortBy,
|
||||||
prevState.showOnlyMyCharts,
|
|
||||||
this.props.userId,
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
this.slicesRequest = this.props.fetchSlices(
|
|
||||||
this.userIdForFetch(),
|
|
||||||
this.state.searchTerm,
|
|
||||||
sortBy,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
rowRenderer({ index, style }: { index: number; style: React.CSSProperties }) {
|
|
||||||
const { filteredSlices, selectedSliceIdsSet } = this.state;
|
|
||||||
const cellData = filteredSlices[index];
|
|
||||||
|
|
||||||
const isSelected = selectedSliceIdsSet.has(cellData.slice_id);
|
|
||||||
const type = CHART_TYPE;
|
|
||||||
const id = NEW_CHART_ID;
|
|
||||||
|
|
||||||
const meta = {
|
|
||||||
chartId: cellData.slice_id,
|
|
||||||
sliceName: cellData.slice_name,
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<DragDroppable
|
|
||||||
key={cellData.slice_id}
|
|
||||||
component={{ type, id, meta }}
|
|
||||||
parentComponent={{
|
|
||||||
id: NEW_COMPONENTS_SOURCE_ID,
|
|
||||||
type: NEW_COMPONENT_SOURCE_TYPE,
|
|
||||||
}}
|
|
||||||
index={index}
|
|
||||||
depth={0}
|
|
||||||
disableDragDrop={isSelected}
|
|
||||||
editMode={this.props.editMode}
|
|
||||||
// we must use a custom drag preview within the List because
|
|
||||||
// it does not seem to work within a fixed-position container
|
|
||||||
useEmptyDragPreview
|
|
||||||
// List library expect style props here
|
|
||||||
// actual style should be applied to nested AddSliceCard component
|
|
||||||
style={{}}
|
|
||||||
>
|
|
||||||
{({ dragSourceRef }: { dragSourceRef: ConnectDragSource }) => (
|
|
||||||
<AddSliceCard
|
|
||||||
innerRef={dragSourceRef}
|
|
||||||
style={style}
|
|
||||||
sliceName={cellData.slice_name}
|
|
||||||
lastModified={cellData.changed_on_humanized}
|
|
||||||
visType={cellData.viz_type}
|
|
||||||
datasourceUrl={cellData.datasource_url}
|
|
||||||
datasourceName={cellData.datasource_name}
|
|
||||||
thumbnailUrl={cellData.thumbnail_url}
|
|
||||||
isSelected={isSelected}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</DragDroppable>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
onShowOnlyMyCharts = (showOnlyMyCharts: boolean) => {
|
|
||||||
if (!showOnlyMyCharts) {
|
|
||||||
this.slicesRequest = this.props.fetchSlices(
|
|
||||||
undefined,
|
|
||||||
this.state.searchTerm,
|
|
||||||
this.state.sortBy,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
this.setState(prevState => ({
|
|
||||||
showOnlyMyCharts,
|
|
||||||
filteredSlices: getFilteredSortedSlices(
|
|
||||||
this.props.slices,
|
|
||||||
prevState.searchTerm,
|
|
||||||
prevState.sortBy,
|
|
||||||
showOnlyMyCharts,
|
showOnlyMyCharts,
|
||||||
this.props.userId,
|
userId,
|
||||||
),
|
),
|
||||||
}));
|
[slices, searchTerm, sortBy, showOnlyMyCharts, userId],
|
||||||
setItem(LocalStorageKeys.DashboardEditorShowOnlyMyCharts, showOnlyMyCharts);
|
);
|
||||||
};
|
|
||||||
|
|
||||||
render() {
|
const userIdForFetch = useCallback(
|
||||||
const { theme } = this.props;
|
() => (showOnlyMyCharts ? userId : undefined),
|
||||||
return (
|
[showOnlyMyCharts, userId],
|
||||||
<div
|
);
|
||||||
css={css`
|
|
||||||
height: 100%;
|
// Refs so the debounced search reads the latest sortBy/userIdForFetch at
|
||||||
display: flex;
|
// fire time without recreating the debounce (which would drop a pending,
|
||||||
flex-direction: column;
|
// armed-but-not-yet-fired search when sortBy/showOnlyMyCharts change).
|
||||||
button > span > :first-of-type {
|
const sortByRef = useRef(sortBy);
|
||||||
margin-right: 0;
|
const userIdForFetchRef = useRef(userIdForFetch);
|
||||||
|
useEffect(() => {
|
||||||
|
sortByRef.current = sortBy;
|
||||||
|
}, [sortBy]);
|
||||||
|
useEffect(() => {
|
||||||
|
userIdForFetchRef.current = userIdForFetch;
|
||||||
|
}, [userIdForFetch]);
|
||||||
|
|
||||||
|
// componentDidMount
|
||||||
|
useEffect(() => {
|
||||||
|
slicesRequestRef.current = fetchSlices(userIdForFetch(), '', sortBy);
|
||||||
|
// Only run on mount
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Update selectedSliceIdsSet when selectedSliceIds prop changes
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedSliceIdsSet(new Set(selectedSliceIds));
|
||||||
|
}, [selectedSliceIds]);
|
||||||
|
|
||||||
|
// componentWillUnmount
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
// Clears the redux store keeping only selected items
|
||||||
|
// Use refs to get latest values on unmount
|
||||||
|
const selectedSlices = pickBy(latestSlicesRef.current, (value: Slice) =>
|
||||||
|
latestSelectedSliceIdsSetRef.current.has(value.slice_id),
|
||||||
|
);
|
||||||
|
|
||||||
|
updateSlices(selectedSlices);
|
||||||
|
if (slicesRequestRef.current instanceof AbortController) {
|
||||||
|
slicesRequestRef.current.abort();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[updateSlices],
|
||||||
|
);
|
||||||
|
|
||||||
|
const searchUpdated = useCallback((term: string) => {
|
||||||
|
setSearchTerm(term);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchSlicesRef = useRef(fetchSlices);
|
||||||
|
useEffect(() => {
|
||||||
|
fetchSlicesRef.current = fetchSlices;
|
||||||
|
}, [fetchSlices]);
|
||||||
|
|
||||||
|
// Create the debounce once (stable identity) so a pending search isn't
|
||||||
|
// dropped when sortBy/userIdForFetch change mid-typing. The debounced
|
||||||
|
// function reads the latest values from refs at fire time.
|
||||||
|
const handleChange = useMemo(
|
||||||
|
() =>
|
||||||
|
debounce((value: string) => {
|
||||||
|
searchUpdated(value);
|
||||||
|
slicesRequestRef.current = fetchSlicesRef.current(
|
||||||
|
userIdForFetchRef.current(),
|
||||||
|
value,
|
||||||
|
sortByRef.current,
|
||||||
|
);
|
||||||
|
}, 300),
|
||||||
|
[searchUpdated],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
handleChange.cancel();
|
||||||
|
},
|
||||||
|
[handleChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSelect = useCallback(
|
||||||
|
(newSortBy: keyof Slice) => {
|
||||||
|
setSortBy(newSortBy);
|
||||||
|
slicesRequestRef.current = fetchSlices(
|
||||||
|
userIdForFetch(),
|
||||||
|
searchTerm,
|
||||||
|
newSortBy,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[fetchSlices, searchTerm, userIdForFetch],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onShowOnlyMyCharts = useCallback(
|
||||||
|
(checked: boolean) => {
|
||||||
|
if (!checked) {
|
||||||
|
slicesRequestRef.current = fetchSlices(undefined, searchTerm, sortBy);
|
||||||
|
}
|
||||||
|
setShowOnlyMyCharts(checked);
|
||||||
|
setItem(LocalStorageKeys.DashboardEditorShowOnlyMyCharts, checked);
|
||||||
|
},
|
||||||
|
[fetchSlices, searchTerm, sortBy],
|
||||||
|
);
|
||||||
|
|
||||||
|
const rowRenderer = useCallback(
|
||||||
|
({ index, style }: { index: number; style: React.CSSProperties }) => {
|
||||||
|
const cellData = filteredSlices[index];
|
||||||
|
|
||||||
|
const isSelected = selectedSliceIdsSet.has(cellData.slice_id);
|
||||||
|
const type = CHART_TYPE;
|
||||||
|
const id = NEW_CHART_ID;
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
chartId: cellData.slice_id,
|
||||||
|
sliceName: cellData.slice_name,
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<DragDroppable
|
||||||
|
key={cellData.slice_id}
|
||||||
|
component={{ type, id, meta }}
|
||||||
|
parentComponent={{
|
||||||
|
id: NEW_COMPONENTS_SOURCE_ID,
|
||||||
|
type: NEW_COMPONENT_SOURCE_TYPE,
|
||||||
|
}}
|
||||||
|
index={index}
|
||||||
|
depth={0}
|
||||||
|
disableDragDrop={isSelected}
|
||||||
|
editMode={editMode}
|
||||||
|
// we must use a custom drag preview within the List because
|
||||||
|
// it does not seem to work within a fixed-position container
|
||||||
|
useEmptyDragPreview
|
||||||
|
// List library expect style props here
|
||||||
|
// actual style should be applied to nested AddSliceCard component
|
||||||
|
style={{}}
|
||||||
|
>
|
||||||
|
{({ dragSourceRef }: { dragSourceRef: ConnectDragSource }) => (
|
||||||
|
<AddSliceCard
|
||||||
|
innerRef={dragSourceRef}
|
||||||
|
style={style}
|
||||||
|
sliceName={cellData.slice_name}
|
||||||
|
lastModified={cellData.changed_on_humanized}
|
||||||
|
visType={cellData.viz_type}
|
||||||
|
datasourceUrl={cellData.datasource_url}
|
||||||
|
datasourceName={cellData.datasource_name}
|
||||||
|
thumbnailUrl={cellData.thumbnail_url}
|
||||||
|
isSelected={isSelected}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</DragDroppable>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[filteredSlices, selectedSliceIdsSet, editMode],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
css={css`
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
button > span > :first-of-type {
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<NewChartButtonContainer>
|
||||||
|
<NewChartButton
|
||||||
|
buttonStyle="link"
|
||||||
|
buttonSize="xsmall"
|
||||||
|
icon={
|
||||||
|
<Icons.PlusOutlined iconSize="m" iconColor={theme.colorPrimary} />
|
||||||
}
|
}
|
||||||
|
onClick={() =>
|
||||||
|
navigateTo(`/chart/add?dashboard_id=${dashboardId}`, {
|
||||||
|
newWindow: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t('Create new chart')}
|
||||||
|
</NewChartButton>
|
||||||
|
</NewChartButtonContainer>
|
||||||
|
<Controls>
|
||||||
|
<Input
|
||||||
|
placeholder={
|
||||||
|
showOnlyMyCharts ? t('Filter your charts') : t('Filter charts')
|
||||||
|
}
|
||||||
|
className="search-input"
|
||||||
|
onChange={ev => handleChange(ev.target.value)}
|
||||||
|
data-test="dashboard-charts-filter-search-input"
|
||||||
|
/>
|
||||||
|
<StyledSelect
|
||||||
|
id="slice-adder-sortby"
|
||||||
|
value={sortBy}
|
||||||
|
onChange={handleSelect}
|
||||||
|
options={Object.entries(KEYS_TO_SORT).map(([key, label]) => ({
|
||||||
|
label: t('Sort by %s', label),
|
||||||
|
value: key,
|
||||||
|
}))}
|
||||||
|
placeholder={t('Sort by')}
|
||||||
|
/>
|
||||||
|
</Controls>
|
||||||
|
<div
|
||||||
|
css={themeObj => css`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: center;
|
||||||
|
gap: ${themeObj.sizeUnit}px;
|
||||||
|
padding: 0 ${themeObj.sizeUnit * 3}px ${themeObj.sizeUnit * 4}px
|
||||||
|
${themeObj.sizeUnit * 3}px;
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
<NewChartButtonContainer>
|
<Checkbox
|
||||||
<NewChartButton
|
onChange={e => onShowOnlyMyCharts(e.target.checked)}
|
||||||
buttonStyle="link"
|
checked={showOnlyMyCharts}
|
||||||
buttonSize="xsmall"
|
/>
|
||||||
icon={
|
{t('Show only my charts')}
|
||||||
<Icons.PlusOutlined iconSize="m" iconColor={theme.colorPrimary} />
|
<InfoTooltip
|
||||||
}
|
placement="top"
|
||||||
onClick={() =>
|
tooltip={t(
|
||||||
navigateTo(`/chart/add?dashboard_id=${this.props.dashboardId}`, {
|
`You can choose to display all charts that you have access to or only the ones you own.
|
||||||
newWindow: true,
|
Your filter selection will be saved and remain active until you choose to change it.`,
|
||||||
})
|
)}
|
||||||
}
|
/>
|
||||||
>
|
</div>
|
||||||
{t('Create new chart')}
|
{isLoading && <Loading />}
|
||||||
</NewChartButton>
|
{!isLoading && filteredSlices.length > 0 && (
|
||||||
</NewChartButtonContainer>
|
<ChartList>
|
||||||
<Controls>
|
<AutoSizer>
|
||||||
<Input
|
{({ height, width }: { height: number; width: number }) => (
|
||||||
placeholder={
|
<List
|
||||||
this.state.showOnlyMyCharts
|
width={width}
|
||||||
? t('Filter your charts')
|
height={height}
|
||||||
: t('Filter charts')
|
itemCount={filteredSlices.length}
|
||||||
}
|
itemSize={DEFAULT_CELL_HEIGHT}
|
||||||
className="search-input"
|
itemKey={index => filteredSlices[index].slice_id}
|
||||||
onChange={ev => this.handleChange(ev.target.value)}
|
>
|
||||||
data-test="dashboard-charts-filter-search-input"
|
{rowRenderer}
|
||||||
/>
|
</List>
|
||||||
<StyledSelect
|
)}
|
||||||
id="slice-adder-sortby"
|
</AutoSizer>
|
||||||
value={this.state.sortBy}
|
</ChartList>
|
||||||
onChange={this.handleSelect}
|
)}
|
||||||
options={Object.entries(KEYS_TO_SORT).map(([key, label]) => ({
|
{errorMessage && (
|
||||||
label: t('Sort by %s', label),
|
|
||||||
value: key,
|
|
||||||
}))}
|
|
||||||
placeholder={t('Sort by')}
|
|
||||||
/>
|
|
||||||
</Controls>
|
|
||||||
<div
|
<div
|
||||||
css={theme => css`
|
css={css`
|
||||||
display: flex;
|
padding: 16px;
|
||||||
flex-direction: row;
|
|
||||||
justify-content: flex-start;
|
|
||||||
align-items: center;
|
|
||||||
gap: ${theme.sizeUnit}px;
|
|
||||||
padding: 0 ${theme.sizeUnit * 3}px ${theme.sizeUnit * 4}px
|
|
||||||
${theme.sizeUnit * 3}px;
|
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
<Checkbox
|
{errorMessage}
|
||||||
onChange={e => this.onShowOnlyMyCharts(e.target.checked)}
|
|
||||||
checked={this.state.showOnlyMyCharts}
|
|
||||||
/>
|
|
||||||
{t('Show only my charts')}
|
|
||||||
<InfoTooltip
|
|
||||||
placement="top"
|
|
||||||
tooltip={t(
|
|
||||||
`You can choose to display all charts that you have access to or only the ones you own.
|
|
||||||
Your filter selection will be saved and remain active until you choose to change it.`,
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
{this.props.isLoading && <Loading />}
|
)}
|
||||||
{!this.props.isLoading && this.state.filteredSlices.length > 0 && (
|
{/* Drag preview is just a single fixed-position element */}
|
||||||
<ChartList>
|
<AddSliceDragPreview slices={filteredSlices} />
|
||||||
<AutoSizer>
|
</div>
|
||||||
{({ height, width }: { height: number; width: number }) => (
|
);
|
||||||
<List
|
|
||||||
width={width}
|
|
||||||
height={height}
|
|
||||||
itemCount={this.state.filteredSlices.length}
|
|
||||||
itemSize={DEFAULT_CELL_HEIGHT}
|
|
||||||
itemKey={index => this.state.filteredSlices[index].slice_id}
|
|
||||||
>
|
|
||||||
{this.rowRenderer}
|
|
||||||
</List>
|
|
||||||
)}
|
|
||||||
</AutoSizer>
|
|
||||||
</ChartList>
|
|
||||||
)}
|
|
||||||
{this.props.errorMessage && (
|
|
||||||
<div
|
|
||||||
css={css`
|
|
||||||
padding: 16px;
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
{this.props.errorMessage}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{/* Drag preview is just a single fixed-position element */}
|
|
||||||
<AddSliceDragPreview slices={this.state.filteredSlices} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default withTheme(SliceAdder);
|
export default SliceAdder;
|
||||||
|
|||||||
@@ -43,6 +43,40 @@ test('triggers onRedo', () => {
|
|||||||
expect(onRedo).toHaveBeenCalledTimes(1);
|
expect(onRedo).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('triggers onRedo with Ctrl+Shift+Z', () => {
|
||||||
|
const onUndo = jest.fn();
|
||||||
|
const onRedo = jest.fn();
|
||||||
|
render(<UndoRedoKeyListeners onUndo={onUndo} onRedo={onRedo} />);
|
||||||
|
fireEvent.keyDown(document.body, {
|
||||||
|
key: 'z',
|
||||||
|
keyCode: 90,
|
||||||
|
ctrlKey: true,
|
||||||
|
shiftKey: true,
|
||||||
|
});
|
||||||
|
expect(onRedo).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onUndo).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('triggers onUndo via keyCode fallback for non-Latin layouts', () => {
|
||||||
|
const onUndo = jest.fn();
|
||||||
|
const onRedo = jest.fn();
|
||||||
|
render(<UndoRedoKeyListeners onUndo={onUndo} onRedo={onRedo} />);
|
||||||
|
// event.key is a non-'z' glyph (e.g. non-Latin layout), but code is KeyZ
|
||||||
|
fireEvent.keyDown(document.body, { key: 'я', code: 'KeyZ', ctrlKey: true });
|
||||||
|
expect(onUndo).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onRedo).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('triggers onRedo via keyCode fallback for non-Latin layouts', () => {
|
||||||
|
const onUndo = jest.fn();
|
||||||
|
const onRedo = jest.fn();
|
||||||
|
render(<UndoRedoKeyListeners onUndo={onUndo} onRedo={onRedo} />);
|
||||||
|
// event.key is a non-'y' glyph, but code is KeyY
|
||||||
|
fireEvent.keyDown(document.body, { key: 'н', code: 'KeyY', ctrlKey: true });
|
||||||
|
expect(onRedo).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onUndo).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
test('does not trigger when it is another key', () => {
|
test('does not trigger when it is another key', () => {
|
||||||
const onUndo = jest.fn();
|
const onUndo = jest.fn();
|
||||||
const onRedo = jest.fn();
|
const onRedo = jest.fn();
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
* specific language governing permissions and limitations
|
* specific language governing permissions and limitations
|
||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
import { PureComponent } from 'react';
|
import { useCallback, useEffect } from 'react';
|
||||||
import { HeaderProps } from '../Header/types';
|
import { HeaderProps } from '../Header/types';
|
||||||
|
|
||||||
type UndoRedoKeyListenersProps = {
|
type UndoRedoKeyListenersProps = {
|
||||||
@@ -24,43 +24,43 @@ type UndoRedoKeyListenersProps = {
|
|||||||
onRedo: HeaderProps['onRedo'];
|
onRedo: HeaderProps['onRedo'];
|
||||||
};
|
};
|
||||||
|
|
||||||
class UndoRedoKeyListeners extends PureComponent<UndoRedoKeyListenersProps> {
|
function UndoRedoKeyListeners({ onUndo, onRedo }: UndoRedoKeyListenersProps) {
|
||||||
constructor(props: UndoRedoKeyListenersProps) {
|
const handleKeydown = useCallback(
|
||||||
super(props);
|
(event: KeyboardEvent) => {
|
||||||
this.handleKeydown = this.handleKeydown.bind(this);
|
const controlOrCommand = event.ctrlKey || event.metaKey;
|
||||||
}
|
if (controlOrCommand) {
|
||||||
|
const key = event.key.toLowerCase();
|
||||||
|
// Fall back to event.code (the physical key) so undo/redo still work on
|
||||||
|
// non-Latin keyboard layouts where event.key is a different glyph.
|
||||||
|
const isZ = key === 'z' || event.code === 'KeyZ';
|
||||||
|
const isY = key === 'y' || event.code === 'KeyY';
|
||||||
|
const isUndo = isZ && !event.shiftKey;
|
||||||
|
const isRedo = isY || (isZ && event.shiftKey);
|
||||||
|
const isEditingMarkdown = document?.querySelector(
|
||||||
|
'.dashboard-markdown--editing',
|
||||||
|
);
|
||||||
|
const isEditingTitle = document?.querySelector(
|
||||||
|
'.editable-title--editing',
|
||||||
|
);
|
||||||
|
|
||||||
componentDidMount() {
|
if (!isEditingMarkdown && !isEditingTitle && (isUndo || isRedo)) {
|
||||||
document.addEventListener('keydown', this.handleKeydown);
|
event.preventDefault();
|
||||||
}
|
const func = isUndo ? onUndo : onRedo;
|
||||||
|
func();
|
||||||
componentWillUnmount() {
|
}
|
||||||
document.removeEventListener('keydown', this.handleKeydown);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleKeydown(event: KeyboardEvent) {
|
|
||||||
const controlOrCommand = event.ctrlKey || event.metaKey;
|
|
||||||
if (controlOrCommand) {
|
|
||||||
const isZChar = event.key === 'z' || event.keyCode === 90;
|
|
||||||
const isYChar = event.key === 'y' || event.keyCode === 89;
|
|
||||||
const isEditingMarkdown = document?.querySelector(
|
|
||||||
'.dashboard-markdown--editing',
|
|
||||||
);
|
|
||||||
const isEditingTitle = document?.querySelector(
|
|
||||||
'.editable-title--editing',
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!isEditingMarkdown && !isEditingTitle && (isZChar || isYChar)) {
|
|
||||||
event.preventDefault();
|
|
||||||
const func = isZChar ? this.props.onUndo : this.props.onRedo;
|
|
||||||
func();
|
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}
|
[onUndo, onRedo],
|
||||||
|
);
|
||||||
|
|
||||||
render() {
|
useEffect(() => {
|
||||||
return null;
|
document.addEventListener('keydown', handleKeydown);
|
||||||
}
|
return () => {
|
||||||
|
document.removeEventListener('keydown', handleKeydown);
|
||||||
|
};
|
||||||
|
}, [handleKeydown]);
|
||||||
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default UndoRedoKeyListeners;
|
export default UndoRedoKeyListeners;
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ import {
|
|||||||
} from 'react-dnd';
|
} from 'react-dnd';
|
||||||
import cx from 'classnames';
|
import cx from 'classnames';
|
||||||
import { css, styled } from '@apache-superset/core/theme';
|
import { css, styled } from '@apache-superset/core/theme';
|
||||||
|
|
||||||
import { dragConfig, dropConfig } from './dragDroppableConfig';
|
import { dragConfig, dropConfig } from './dragDroppableConfig';
|
||||||
import type { DragDroppableProps as BaseDragDroppableProps } from './dragDroppableConfig';
|
import type { DragDroppableProps as BaseDragDroppableProps } from './dragDroppableConfig';
|
||||||
import { DROP_FORBIDDEN } from '../../util/getDropPosition';
|
import { DROP_FORBIDDEN } from '../../util/getDropPosition';
|
||||||
@@ -122,15 +121,22 @@ const DragDroppableStyles = styled.div`
|
|||||||
}
|
}
|
||||||
`};
|
`};
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Note: This component remains a class component because it is tightly integrated
|
||||||
|
* with react-dnd's class-based HOC system (DragSource/DropTarget). The HOCs
|
||||||
|
* access component instance properties directly (mounted, ref, props, setState)
|
||||||
|
* in the hover/drop callbacks defined in dragDroppableConfig.ts.
|
||||||
|
*
|
||||||
|
* Converting to a function component would require migrating to react-dnd's
|
||||||
|
* hooks API (useDrag/useDrop), which would be a more extensive refactor.
|
||||||
|
*/
|
||||||
// export unwrapped component for testing
|
// export unwrapped component for testing
|
||||||
|
// eslint-disable-next-line react-prefer-function-component/react-prefer-function-component -- react-dnd class-based HOC requires class component instance properties
|
||||||
export class UnwrappedDragDroppable extends PureComponent<
|
export class UnwrappedDragDroppable extends PureComponent<
|
||||||
DragDroppableAllProps,
|
DragDroppableAllProps,
|
||||||
DragDroppableState
|
DragDroppableState
|
||||||
> {
|
> {
|
||||||
mounted: boolean;
|
|
||||||
|
|
||||||
ref: HTMLDivElement | null;
|
|
||||||
|
|
||||||
static defaultProps = {
|
static defaultProps = {
|
||||||
className: null,
|
className: null,
|
||||||
style: null,
|
style: null,
|
||||||
@@ -152,6 +158,10 @@ export class UnwrappedDragDroppable extends PureComponent<
|
|||||||
dragPreviewRef() {},
|
dragPreviewRef() {},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
mounted: boolean;
|
||||||
|
|
||||||
|
ref: HTMLDivElement | null;
|
||||||
|
|
||||||
constructor(props: DragDroppableAllProps) {
|
constructor(props: DragDroppableAllProps) {
|
||||||
super(props);
|
super(props);
|
||||||
this.state = {
|
this.state = {
|
||||||
@@ -283,7 +293,6 @@ export class UnwrappedDragDroppable extends PureComponent<
|
|||||||
|
|
||||||
// react-dnd's DragSource/DropTarget HOC types don't play well with
|
// react-dnd's DragSource/DropTarget HOC types don't play well with
|
||||||
// class components using spread config tuples, so we use type assertions here
|
// class components using spread config tuples, so we use type assertions here
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
const DragDroppableAsAny =
|
const DragDroppableAsAny =
|
||||||
UnwrappedDragDroppable as unknown as ReactComponentType<
|
UnwrappedDragDroppable as unknown as ReactComponentType<
|
||||||
Record<string, unknown>
|
Record<string, unknown>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
* specific language governing permissions and limitations
|
* specific language governing permissions and limitations
|
||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
import { createRef, PureComponent } from 'react';
|
import { useRef, useCallback } from 'react';
|
||||||
import { styled } from '@apache-superset/core/theme';
|
import { styled } from '@apache-superset/core/theme';
|
||||||
import {
|
import {
|
||||||
ModalTrigger,
|
ModalTrigger,
|
||||||
@@ -33,39 +33,29 @@ const FilterScopeModalBody = styled.div(({ theme: { sizeUnit } }) => ({
|
|||||||
paddingBottom: sizeUnit * 3,
|
paddingBottom: sizeUnit * 3,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export default class FilterScopeModal extends PureComponent<
|
export default function FilterScopeModal({
|
||||||
FilterScopeModalProps,
|
triggerNode,
|
||||||
{}
|
}: FilterScopeModalProps) {
|
||||||
> {
|
const modalRef = useRef<ModalTriggerRef['current']>(null);
|
||||||
modal: ModalTriggerRef;
|
|
||||||
|
|
||||||
constructor(props: FilterScopeModalProps) {
|
const handleCloseModal = useCallback((): void => {
|
||||||
super(props);
|
modalRef.current?.close?.();
|
||||||
|
}, []);
|
||||||
|
|
||||||
this.modal = createRef() as ModalTriggerRef;
|
const filterScopeProps = {
|
||||||
this.handleCloseModal = this.handleCloseModal.bind(this);
|
onCloseModal: handleCloseModal,
|
||||||
}
|
};
|
||||||
|
|
||||||
handleCloseModal(): void {
|
return (
|
||||||
this?.modal?.current?.close?.();
|
<ModalTrigger
|
||||||
}
|
ref={modalRef}
|
||||||
|
triggerNode={triggerNode}
|
||||||
render() {
|
modalBody={
|
||||||
const filterScopeProps = {
|
<FilterScopeModalBody>
|
||||||
onCloseModal: this.handleCloseModal,
|
<FilterScope {...filterScopeProps} />
|
||||||
};
|
</FilterScopeModalBody>
|
||||||
|
}
|
||||||
return (
|
width="80%"
|
||||||
<ModalTrigger
|
/>
|
||||||
ref={this.modal}
|
);
|
||||||
triggerNode={this.props.triggerNode}
|
|
||||||
modalBody={
|
|
||||||
<FilterScopeModalBody>
|
|
||||||
<FilterScope {...filterScopeProps} />
|
|
||||||
</FilterScopeModalBody>
|
|
||||||
}
|
|
||||||
width="80%"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,265 @@
|
|||||||
|
/**
|
||||||
|
* 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 {
|
||||||
|
cleanup,
|
||||||
|
render,
|
||||||
|
screen,
|
||||||
|
userEvent,
|
||||||
|
} from 'spec/helpers/testing-library';
|
||||||
|
import FilterScopeSelector from './FilterScopeSelector';
|
||||||
|
import type { DashboardLayout } from 'src/dashboard/types';
|
||||||
|
|
||||||
|
// --- Mock child components ---
|
||||||
|
|
||||||
|
jest.mock('./FilterFieldTree', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: (props: Record<string, unknown>) => (
|
||||||
|
<div data-test="filter-field-tree">
|
||||||
|
FilterFieldTree (checked={String(props.checked)})
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('./FilterScopeTree', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: (props: Record<string, unknown>) => (
|
||||||
|
<div data-test="filter-scope-tree">
|
||||||
|
FilterScopeTree (checked={String(props.checked)})
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// --- Mock utility functions ---
|
||||||
|
|
||||||
|
jest.mock('src/dashboard/util/getFilterFieldNodesTree', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: jest.fn(() => [
|
||||||
|
{
|
||||||
|
value: 'ALL_FILTERS_ROOT',
|
||||||
|
label: 'All filters',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
value: 1,
|
||||||
|
label: 'Filter A',
|
||||||
|
children: [
|
||||||
|
{ value: '1_column_b', label: 'Filter B' },
|
||||||
|
{ value: '1_column_c', label: 'Filter C' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('src/dashboard/util/getFilterScopeNodesTree', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: jest.fn(() => [
|
||||||
|
{
|
||||||
|
value: 'ROOT_ID',
|
||||||
|
label: 'All charts',
|
||||||
|
children: [{ value: 2, label: 'Chart A' }],
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('src/dashboard/util/getFilterScopeParentNodes', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: jest.fn(() => ['ROOT_ID']),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('src/dashboard/util/buildFilterScopeTreeEntry', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: jest.fn(() => ({})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('src/dashboard/util/getKeyForFilterScopeTree', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: jest.fn(() => '1_column_b'),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('src/dashboard/util/getSelectedChartIdForFilterScopeTree', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: jest.fn(() => 1),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('src/dashboard/util/getFilterScopeFromNodesTree', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: jest.fn(() => ({ scope: ['ROOT_ID'], immune: [] })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('src/dashboard/util/getRevertedFilterScope', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: jest.fn(() => ({})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('src/dashboard/util/activeDashboardFilters', () => ({
|
||||||
|
getChartIdsInFilterScope: jest.fn(() => [2, 3]),
|
||||||
|
}));
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockDashboardFilters = {
|
||||||
|
1: {
|
||||||
|
chartId: 1,
|
||||||
|
componentId: 'component-1',
|
||||||
|
filterName: 'Filter A',
|
||||||
|
datasourceId: 'ds-1',
|
||||||
|
directPathToFilter: ['ROOT_ID', 'GRID', 'CHART_1'],
|
||||||
|
isDateFilter: false,
|
||||||
|
isInstantFilter: false,
|
||||||
|
columns: { column_b: undefined, column_c: undefined },
|
||||||
|
labels: { column_b: 'Filter B', column_c: 'Filter C' },
|
||||||
|
scopes: {
|
||||||
|
column_b: { immune: [], scope: ['ROOT_ID'] },
|
||||||
|
column_c: { immune: [], scope: ['ROOT_ID'] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockLayout: DashboardLayout = {
|
||||||
|
ROOT_ID: { children: ['GRID'], id: 'ROOT_ID', type: 'ROOT' },
|
||||||
|
GRID: {
|
||||||
|
children: ['CHART_1', 'CHART_2'],
|
||||||
|
id: 'GRID',
|
||||||
|
type: 'GRID',
|
||||||
|
parents: ['ROOT_ID'],
|
||||||
|
},
|
||||||
|
CHART_1: {
|
||||||
|
meta: { chartId: 1, sliceName: 'Chart 1' },
|
||||||
|
children: [],
|
||||||
|
id: 'CHART_1',
|
||||||
|
type: 'CHART',
|
||||||
|
parents: ['ROOT_ID', 'GRID'],
|
||||||
|
},
|
||||||
|
CHART_2: {
|
||||||
|
meta: { chartId: 2, sliceName: 'Chart 2' },
|
||||||
|
children: [],
|
||||||
|
id: 'CHART_2',
|
||||||
|
type: 'CHART',
|
||||||
|
parents: ['ROOT_ID', 'GRID'],
|
||||||
|
},
|
||||||
|
} as unknown as DashboardLayout;
|
||||||
|
|
||||||
|
const defaultProps = {
|
||||||
|
dashboardFilters: mockDashboardFilters,
|
||||||
|
layout: mockLayout,
|
||||||
|
updateDashboardFiltersScope: jest.fn(),
|
||||||
|
setUnsavedChanges: jest.fn(),
|
||||||
|
onCloseModal: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
test('renders the header, filter field panel, and scope panel', () => {
|
||||||
|
render(<FilterScopeSelector {...defaultProps} />, { useRedux: true });
|
||||||
|
|
||||||
|
expect(screen.getByText('Configure filter scopes')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('filter-field-tree')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('filter-scope-tree')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders the search input with correct placeholder', () => {
|
||||||
|
render(<FilterScopeSelector {...defaultProps} />, { useRedux: true });
|
||||||
|
|
||||||
|
const searchInput = screen.getByPlaceholderText('Search...');
|
||||||
|
expect(searchInput).toBeInTheDocument();
|
||||||
|
expect(searchInput).toHaveAttribute('type', 'text');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders Close and Save buttons when filters exist', () => {
|
||||||
|
render(<FilterScopeSelector {...defaultProps} />, { useRedux: true });
|
||||||
|
|
||||||
|
expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders only Close button and a warning when no filters exist', () => {
|
||||||
|
render(<FilterScopeSelector {...defaultProps} dashboardFilters={{}} />, {
|
||||||
|
useRedux: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByText('There are no filters in this dashboard.'),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.queryByRole('button', { name: 'Save' }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not render FilterFieldTree or FilterScopeTree when no filters exist', () => {
|
||||||
|
render(<FilterScopeSelector {...defaultProps} dashboardFilters={{}} />, {
|
||||||
|
useRedux: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.queryByTestId('filter-field-tree')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId('filter-scope-tree')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('calls onCloseModal when Close button is clicked', () => {
|
||||||
|
const onCloseModal = jest.fn();
|
||||||
|
render(
|
||||||
|
<FilterScopeSelector {...defaultProps} onCloseModal={onCloseModal} />,
|
||||||
|
{ useRedux: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
userEvent.click(screen.getByRole('button', { name: 'Close' }));
|
||||||
|
|
||||||
|
expect(onCloseModal).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('calls updateDashboardFiltersScope, setUnsavedChanges, and onCloseModal when Save is clicked', () => {
|
||||||
|
const updateDashboardFiltersScope = jest.fn();
|
||||||
|
const setUnsavedChanges = jest.fn();
|
||||||
|
const onCloseModal = jest.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<FilterScopeSelector
|
||||||
|
{...defaultProps}
|
||||||
|
updateDashboardFiltersScope={updateDashboardFiltersScope}
|
||||||
|
setUnsavedChanges={setUnsavedChanges}
|
||||||
|
onCloseModal={onCloseModal}
|
||||||
|
/>,
|
||||||
|
{ useRedux: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
userEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||||
|
|
||||||
|
expect(updateDashboardFiltersScope).toHaveBeenCalledTimes(1);
|
||||||
|
expect(setUnsavedChanges).toHaveBeenCalledWith(true);
|
||||||
|
expect(onCloseModal).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders the editing filters name section with "Editing 1 filter:" label', () => {
|
||||||
|
render(<FilterScopeSelector {...defaultProps} />, { useRedux: true });
|
||||||
|
|
||||||
|
expect(screen.getByText('Editing 1 filter:')).toBeInTheDocument();
|
||||||
|
// The active filter label should appear (column_b maps to "Filter B")
|
||||||
|
expect(screen.getByText('Filter B')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updates search text when typing in the search input', () => {
|
||||||
|
render(<FilterScopeSelector {...defaultProps} />, { useRedux: true });
|
||||||
|
|
||||||
|
const searchInput = screen.getByPlaceholderText('Search...');
|
||||||
|
userEvent.type(searchInput, 'Chart');
|
||||||
|
|
||||||
|
expect(searchInput).toHaveValue('Chart');
|
||||||
|
});
|
||||||
@@ -16,12 +16,17 @@
|
|||||||
* specific language governing permissions and limitations
|
* specific language governing permissions and limitations
|
||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
import { PureComponent, ChangeEvent, type ReactElement } from 'react';
|
import {
|
||||||
|
useState,
|
||||||
|
useCallback,
|
||||||
|
useMemo,
|
||||||
|
ChangeEvent,
|
||||||
|
type ReactElement,
|
||||||
|
} from 'react';
|
||||||
import cx from 'classnames';
|
import cx from 'classnames';
|
||||||
import { Button, Input } from '@superset-ui/core/components';
|
import { Button, Input } from '@superset-ui/core/components';
|
||||||
import { css, styled } from '@apache-superset/core/theme';
|
|
||||||
import { t } from '@apache-superset/core/translation';
|
import { t } from '@apache-superset/core/translation';
|
||||||
|
import { css, styled } from '@apache-superset/core/theme';
|
||||||
import buildFilterScopeTreeEntry from 'src/dashboard/util/buildFilterScopeTreeEntry';
|
import buildFilterScopeTreeEntry from 'src/dashboard/util/buildFilterScopeTreeEntry';
|
||||||
import getFilterScopeNodesTree from 'src/dashboard/util/getFilterScopeNodesTree';
|
import getFilterScopeNodesTree from 'src/dashboard/util/getFilterScopeNodesTree';
|
||||||
import getFilterFieldNodesTree from 'src/dashboard/util/getFilterFieldNodesTree';
|
import getFilterFieldNodesTree from 'src/dashboard/util/getFilterFieldNodesTree';
|
||||||
@@ -90,30 +95,6 @@ export interface FilterScopeSelectorProps {
|
|||||||
onCloseModal: () => void;
|
onCloseModal: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FilterScopeSelectorStateWithSelector {
|
|
||||||
showSelector: true;
|
|
||||||
activeFilterField: string | null;
|
|
||||||
searchText: string;
|
|
||||||
filterScopeMap: FilterScopeMap;
|
|
||||||
filterFieldNodes: FilterFieldNode[];
|
|
||||||
checkedFilterFields: string[];
|
|
||||||
expandedFilterIds: (string | number)[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FilterScopeSelectorStateWithoutSelector {
|
|
||||||
showSelector: false;
|
|
||||||
activeFilterField?: undefined;
|
|
||||||
searchText?: undefined;
|
|
||||||
filterScopeMap?: undefined;
|
|
||||||
filterFieldNodes?: undefined;
|
|
||||||
checkedFilterFields?: undefined;
|
|
||||||
expandedFilterIds?: undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
type FilterScopeSelectorState =
|
|
||||||
| FilterScopeSelectorStateWithSelector
|
|
||||||
| FilterScopeSelectorStateWithoutSelector;
|
|
||||||
|
|
||||||
const ScopeContainer = styled.div`
|
const ScopeContainer = styled.div`
|
||||||
${({ theme }) => css`
|
${({ theme }) => css`
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -389,271 +370,358 @@ const ActionsContainer = styled.div`
|
|||||||
`}
|
`}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export default class FilterScopeSelector extends PureComponent<
|
function initializeState(
|
||||||
FilterScopeSelectorProps,
|
dashboardFilters: Record<number, DashboardFilter>,
|
||||||
FilterScopeSelectorState
|
layout: DashboardLayout,
|
||||||
> {
|
) {
|
||||||
allfilterFields: string[];
|
if (Object.keys(dashboardFilters).length === 0) {
|
||||||
|
return {
|
||||||
|
showSelector: false as const,
|
||||||
|
allFilterFields: [] as string[],
|
||||||
|
defaultFilterKey: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
defaultFilterKey: string;
|
// display filter fields in tree structure
|
||||||
|
const filterFieldNodes = getFilterFieldNodesTree({
|
||||||
|
dashboardFilters,
|
||||||
|
});
|
||||||
|
// filterFieldNodes root node is dashboard_root component,
|
||||||
|
// so that we can offer a select/deselect all link
|
||||||
|
const filtersNodes = filterFieldNodes[0].children ?? [];
|
||||||
|
const allFilterFields: string[] = [];
|
||||||
|
filtersNodes.forEach(({ children }) => {
|
||||||
|
(children ?? []).forEach(child => {
|
||||||
|
allFilterFields.push(String(child.value));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const defaultFilterKey = String(filtersNodes[0]?.children?.[0]?.value ?? '');
|
||||||
|
|
||||||
constructor(props: FilterScopeSelectorProps) {
|
// build FilterScopeTree object for each filterKey
|
||||||
super(props);
|
const filterScopeMap: FilterScopeMap = Object.values(
|
||||||
|
dashboardFilters,
|
||||||
this.allfilterFields = [];
|
).reduce<FilterScopeMap>((map, { chartId: filterId, columns }) => {
|
||||||
this.defaultFilterKey = '';
|
const filterScopeByChartId = Object.keys(columns).reduce<FilterScopeMap>(
|
||||||
|
(mapByChartId, columnName) => {
|
||||||
const { dashboardFilters, layout } = props;
|
const filterKey = getDashboardFilterKey({
|
||||||
|
chartId: String(filterId),
|
||||||
if (Object.keys(dashboardFilters).length > 0) {
|
column: columnName,
|
||||||
// display filter fields in tree structure
|
|
||||||
const filterFieldNodes = getFilterFieldNodesTree({
|
|
||||||
dashboardFilters,
|
|
||||||
});
|
|
||||||
// filterFieldNodes root node is dashboard_root component,
|
|
||||||
// so that we can offer a select/deselect all link
|
|
||||||
const filtersNodes = filterFieldNodes[0].children ?? [];
|
|
||||||
this.allfilterFields = [];
|
|
||||||
filtersNodes.forEach(({ children }) => {
|
|
||||||
(children ?? []).forEach(child => {
|
|
||||||
this.allfilterFields.push(String(child.value));
|
|
||||||
});
|
});
|
||||||
});
|
const nodes = getFilterScopeNodesTree({
|
||||||
this.defaultFilterKey = String(
|
components: layout,
|
||||||
filtersNodes[0]?.children?.[0]?.value ?? '',
|
filterFields: [filterKey],
|
||||||
);
|
selectedChartId: filterId,
|
||||||
|
});
|
||||||
// build FilterScopeTree object for each filterKey
|
const expanded = getFilterScopeParentNodes(nodes, 1);
|
||||||
const filterScopeMap: FilterScopeMap = Object.values(
|
const chartIdsInFilterScope = (
|
||||||
dashboardFilters,
|
getChartIdsInFilterScope({
|
||||||
).reduce<FilterScopeMap>((map, { chartId: filterId, columns }) => {
|
filterScope: dashboardFilters[filterId].scopes[columnName],
|
||||||
const filterScopeByChartId = Object.keys(
|
}) || []
|
||||||
columns,
|
).filter((id: number) => id !== filterId);
|
||||||
).reduce<FilterScopeMap>((mapByChartId, columnName) => {
|
|
||||||
const filterKey = getDashboardFilterKey({
|
|
||||||
chartId: String(filterId),
|
|
||||||
column: columnName,
|
|
||||||
});
|
|
||||||
const nodes = getFilterScopeNodesTree({
|
|
||||||
components: layout,
|
|
||||||
filterFields: [filterKey],
|
|
||||||
selectedChartId: filterId,
|
|
||||||
});
|
|
||||||
const expanded = getFilterScopeParentNodes(nodes, 1);
|
|
||||||
const chartIdsInFilterScope = (
|
|
||||||
getChartIdsInFilterScope({
|
|
||||||
filterScope: dashboardFilters[filterId].scopes[columnName],
|
|
||||||
}) || []
|
|
||||||
).filter((id: number) => id !== filterId);
|
|
||||||
|
|
||||||
return {
|
|
||||||
...mapByChartId,
|
|
||||||
[filterKey]: {
|
|
||||||
// unfiltered nodes
|
|
||||||
nodes,
|
|
||||||
// filtered nodes in display if searchText is not empty
|
|
||||||
nodesFiltered: [...nodes],
|
|
||||||
checked: chartIdsInFilterScope,
|
|
||||||
expanded,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}, {});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...map,
|
...mapByChartId,
|
||||||
...filterScopeByChartId,
|
[filterKey]: {
|
||||||
|
// unfiltered nodes
|
||||||
|
nodes,
|
||||||
|
// filtered nodes in display if searchText is not empty
|
||||||
|
nodesFiltered: [...nodes],
|
||||||
|
checked: chartIdsInFilterScope,
|
||||||
|
expanded,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}, {});
|
|
||||||
|
|
||||||
// initial state: active defaultFilerKey
|
|
||||||
const { chartId } = getChartIdAndColumnFromFilterKey(
|
|
||||||
this.defaultFilterKey,
|
|
||||||
);
|
|
||||||
const checkedFilterFields: string[] = [];
|
|
||||||
const activeFilterField = this.defaultFilterKey;
|
|
||||||
// expand defaultFilterKey in filter field tree
|
|
||||||
const expandedFilterIds: (string | number)[] = [
|
|
||||||
ALL_FILTERS_ROOT,
|
|
||||||
chartId,
|
|
||||||
];
|
|
||||||
|
|
||||||
const filterScopeTreeEntry = buildFilterScopeTreeEntry({
|
|
||||||
checkedFilterFields,
|
|
||||||
activeFilterField,
|
|
||||||
filterScopeMap,
|
|
||||||
layout,
|
|
||||||
});
|
|
||||||
this.state = {
|
|
||||||
showSelector: true,
|
|
||||||
activeFilterField,
|
|
||||||
searchText: '',
|
|
||||||
filterScopeMap: {
|
|
||||||
...filterScopeMap,
|
|
||||||
...filterScopeTreeEntry,
|
|
||||||
} as FilterScopeMap,
|
|
||||||
filterFieldNodes,
|
|
||||||
checkedFilterFields,
|
|
||||||
expandedFilterIds,
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
this.state = {
|
|
||||||
showSelector: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
this.filterNodes = this.filterNodes.bind(this);
|
|
||||||
this.onChangeFilterField = this.onChangeFilterField.bind(this);
|
|
||||||
this.onCheckFilterScope = this.onCheckFilterScope.bind(this);
|
|
||||||
this.onExpandFilterScope = this.onExpandFilterScope.bind(this);
|
|
||||||
this.onSearchInputChange = this.onSearchInputChange.bind(this);
|
|
||||||
this.onCheckFilterField = this.onCheckFilterField.bind(this);
|
|
||||||
this.onExpandFilterField = this.onExpandFilterField.bind(this);
|
|
||||||
this.onClose = this.onClose.bind(this);
|
|
||||||
this.onSave = this.onSave.bind(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
onCheckFilterScope(checked: (string | number)[] = []): void {
|
|
||||||
const state = this.state as FilterScopeSelectorStateWithSelector;
|
|
||||||
const { activeFilterField, filterScopeMap, checkedFilterFields } = state;
|
|
||||||
|
|
||||||
const key = getKeyForFilterScopeTree({
|
|
||||||
activeFilterField: activeFilterField ?? undefined,
|
|
||||||
checkedFilterFields,
|
|
||||||
});
|
|
||||||
const editingList = activeFilterField
|
|
||||||
? [activeFilterField]
|
|
||||||
: checkedFilterFields;
|
|
||||||
const updatedEntry = {
|
|
||||||
...filterScopeMap[key],
|
|
||||||
checked,
|
|
||||||
};
|
|
||||||
|
|
||||||
const updatedFilterScopeMap = getRevertedFilterScope({
|
|
||||||
checked,
|
|
||||||
filterFields: editingList,
|
|
||||||
filterScopeMap,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.setState(() => ({
|
|
||||||
filterScopeMap: {
|
|
||||||
...filterScopeMap,
|
|
||||||
...updatedFilterScopeMap,
|
|
||||||
[key]: updatedEntry,
|
|
||||||
} as FilterScopeMap,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
onExpandFilterScope(expanded: string[] = []): void {
|
|
||||||
const state = this.state as FilterScopeSelectorStateWithSelector;
|
|
||||||
const { activeFilterField, checkedFilterFields, filterScopeMap } = state;
|
|
||||||
const key = getKeyForFilterScopeTree({
|
|
||||||
activeFilterField: activeFilterField ?? undefined,
|
|
||||||
checkedFilterFields,
|
|
||||||
});
|
|
||||||
const updatedEntry = {
|
|
||||||
...filterScopeMap[key],
|
|
||||||
expanded,
|
|
||||||
};
|
|
||||||
this.setState(() => ({
|
|
||||||
filterScopeMap: {
|
|
||||||
...filterScopeMap,
|
|
||||||
[key]: updatedEntry,
|
|
||||||
},
|
},
|
||||||
}));
|
{},
|
||||||
}
|
);
|
||||||
|
|
||||||
onCheckFilterField(checkedFilterFields: string[] = []): void {
|
return {
|
||||||
const { layout } = this.props;
|
...map,
|
||||||
const state = this.state as FilterScopeSelectorStateWithSelector;
|
...filterScopeByChartId,
|
||||||
const { filterScopeMap } = state;
|
};
|
||||||
const filterScopeTreeEntry = buildFilterScopeTreeEntry({
|
}, {});
|
||||||
checkedFilterFields,
|
|
||||||
activeFilterField: undefined,
|
|
||||||
filterScopeMap,
|
|
||||||
layout,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.setState(() => ({
|
// initial state: active defaultFilerKey
|
||||||
activeFilterField: null,
|
const { chartId } = getChartIdAndColumnFromFilterKey(defaultFilterKey);
|
||||||
checkedFilterFields,
|
const checkedFilterFields: string[] = [];
|
||||||
|
const activeFilterField = defaultFilterKey;
|
||||||
|
// expand defaultFilterKey in filter field tree
|
||||||
|
const expandedFilterIds: (string | number)[] = [ALL_FILTERS_ROOT, chartId];
|
||||||
|
|
||||||
|
const filterScopeTreeEntry = buildFilterScopeTreeEntry({
|
||||||
|
checkedFilterFields,
|
||||||
|
activeFilterField,
|
||||||
|
filterScopeMap,
|
||||||
|
layout,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
showSelector: true as const,
|
||||||
|
allFilterFields,
|
||||||
|
defaultFilterKey,
|
||||||
|
initialState: {
|
||||||
|
activeFilterField,
|
||||||
|
searchText: '',
|
||||||
filterScopeMap: {
|
filterScopeMap: {
|
||||||
...filterScopeMap,
|
...filterScopeMap,
|
||||||
...filterScopeTreeEntry,
|
...filterScopeTreeEntry,
|
||||||
},
|
} as FilterScopeMap,
|
||||||
}));
|
filterFieldNodes,
|
||||||
}
|
|
||||||
|
|
||||||
onExpandFilterField(expandedFilterIds: (string | number)[] = []): void {
|
|
||||||
this.setState(() => ({
|
|
||||||
expandedFilterIds,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
onChangeFilterField(filterField: { value?: string } = {}): void {
|
|
||||||
const { layout } = this.props;
|
|
||||||
const nextActiveFilterField = filterField.value;
|
|
||||||
const state = this.state as FilterScopeSelectorStateWithSelector;
|
|
||||||
const {
|
|
||||||
activeFilterField: currentActiveFilterField,
|
|
||||||
checkedFilterFields,
|
checkedFilterFields,
|
||||||
filterScopeMap,
|
expandedFilterIds,
|
||||||
} = state;
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// we allow single edit and multiple edit in the same view.
|
export default function FilterScopeSelector({
|
||||||
// if user click on the single filter field,
|
dashboardFilters,
|
||||||
// will show filter scope for the single field.
|
layout,
|
||||||
// if user click on the same filter filed again,
|
updateDashboardFiltersScope,
|
||||||
// will toggle off the single filter field,
|
setUnsavedChanges,
|
||||||
// and allow multi-edit all checked filter fields.
|
onCloseModal,
|
||||||
if (nextActiveFilterField === currentActiveFilterField) {
|
}: FilterScopeSelectorProps): ReactElement {
|
||||||
const filterScopeTreeEntry = buildFilterScopeTreeEntry({
|
const initialized = useMemo(
|
||||||
|
() => initializeState(dashboardFilters, layout),
|
||||||
|
// Only initialize once on mount
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { showSelector, allFilterFields } = initialized;
|
||||||
|
|
||||||
|
const [activeFilterField, setActiveFilterField] = useState<string | null>(
|
||||||
|
() =>
|
||||||
|
initialized.showSelector
|
||||||
|
? initialized.initialState.activeFilterField
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
const [searchText, setSearchText] = useState(() =>
|
||||||
|
initialized.showSelector ? initialized.initialState.searchText : '',
|
||||||
|
);
|
||||||
|
const [filterScopeMap, setFilterScopeMap] = useState<FilterScopeMap>(() =>
|
||||||
|
initialized.showSelector ? initialized.initialState.filterScopeMap : {},
|
||||||
|
);
|
||||||
|
const [filterFieldNodes] = useState<FilterFieldNode[]>(() =>
|
||||||
|
initialized.showSelector ? initialized.initialState.filterFieldNodes : [],
|
||||||
|
);
|
||||||
|
const [checkedFilterFields, setCheckedFilterFields] = useState<string[]>(
|
||||||
|
() =>
|
||||||
|
initialized.showSelector
|
||||||
|
? initialized.initialState.checkedFilterFields
|
||||||
|
: [],
|
||||||
|
);
|
||||||
|
const [expandedFilterIds, setExpandedFilterIds] = useState<
|
||||||
|
(string | number)[]
|
||||||
|
>(() =>
|
||||||
|
initialized.showSelector ? initialized.initialState.expandedFilterIds : [],
|
||||||
|
);
|
||||||
|
|
||||||
|
const filterNodes = useCallback(
|
||||||
|
(
|
||||||
|
filtered: FilterScopeTreeNode[] = [],
|
||||||
|
node: FilterScopeTreeNode = { value: '', label: '' },
|
||||||
|
currentSearchText: string,
|
||||||
|
): FilterScopeTreeNode[] => {
|
||||||
|
const filterNodesRecursive = (
|
||||||
|
f: FilterScopeTreeNode[],
|
||||||
|
n: FilterScopeTreeNode,
|
||||||
|
): FilterScopeTreeNode[] => filterNodes(f, n, currentSearchText);
|
||||||
|
|
||||||
|
const children = (node.children || []).reduce<FilterScopeTreeNode[]>(
|
||||||
|
filterNodesRecursive,
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
// Node's label matches the search string
|
||||||
|
node.label
|
||||||
|
.toLocaleLowerCase()
|
||||||
|
.indexOf((currentSearchText ?? '').toLocaleLowerCase()) > -1 ||
|
||||||
|
// Or a children has a matching node
|
||||||
|
children.length
|
||||||
|
) {
|
||||||
|
filtered.push({ ...node, children });
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered;
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const filterTree = useCallback(
|
||||||
|
(currentSearchText: string) => {
|
||||||
|
const key = getKeyForFilterScopeTree({
|
||||||
|
activeFilterField: activeFilterField ?? undefined,
|
||||||
checkedFilterFields,
|
checkedFilterFields,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset nodes back to unfiltered state
|
||||||
|
if (!currentSearchText) {
|
||||||
|
setFilterScopeMap(prev => ({
|
||||||
|
...prev,
|
||||||
|
[key]: {
|
||||||
|
...prev[key],
|
||||||
|
nodesFiltered: prev[key].nodes,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
setFilterScopeMap(prev => {
|
||||||
|
const nodesFiltered = prev[key].nodes.reduce<FilterScopeTreeNode[]>(
|
||||||
|
(filtered, node) => filterNodes(filtered, node, currentSearchText),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const expanded = getFilterScopeParentNodes([...nodesFiltered]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
[key]: {
|
||||||
|
...prev[key],
|
||||||
|
nodesFiltered,
|
||||||
|
expanded,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[activeFilterField, checkedFilterFields, filterNodes],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onCheckFilterScope = useCallback(
|
||||||
|
(checked: (string | number)[] = []): void => {
|
||||||
|
const key = getKeyForFilterScopeTree({
|
||||||
|
activeFilterField: activeFilterField ?? undefined,
|
||||||
|
checkedFilterFields,
|
||||||
|
});
|
||||||
|
const editingList = activeFilterField
|
||||||
|
? [activeFilterField]
|
||||||
|
: checkedFilterFields;
|
||||||
|
|
||||||
|
const updatedFilterScopeMap = getRevertedFilterScope({
|
||||||
|
checked,
|
||||||
|
filterFields: editingList,
|
||||||
|
filterScopeMap,
|
||||||
|
});
|
||||||
|
|
||||||
|
setFilterScopeMap({
|
||||||
|
...filterScopeMap,
|
||||||
|
...updatedFilterScopeMap,
|
||||||
|
[key]: {
|
||||||
|
...filterScopeMap[key],
|
||||||
|
checked,
|
||||||
|
},
|
||||||
|
} as FilterScopeMap);
|
||||||
|
},
|
||||||
|
[activeFilterField, checkedFilterFields, filterScopeMap],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onExpandFilterScope = useCallback(
|
||||||
|
(expanded: string[] = []): void => {
|
||||||
|
const key = getKeyForFilterScopeTree({
|
||||||
|
activeFilterField: activeFilterField ?? undefined,
|
||||||
|
checkedFilterFields,
|
||||||
|
});
|
||||||
|
|
||||||
|
setFilterScopeMap(prev => ({
|
||||||
|
...prev,
|
||||||
|
[key]: {
|
||||||
|
...prev[key],
|
||||||
|
expanded,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
[activeFilterField, checkedFilterFields],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onCheckFilterField = useCallback(
|
||||||
|
(newCheckedFilterFields: string[] = []): void => {
|
||||||
|
const filterScopeTreeEntry = buildFilterScopeTreeEntry({
|
||||||
|
checkedFilterFields: newCheckedFilterFields,
|
||||||
activeFilterField: undefined,
|
activeFilterField: undefined,
|
||||||
filterScopeMap,
|
filterScopeMap,
|
||||||
layout,
|
layout,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.setState({
|
setActiveFilterField(null);
|
||||||
activeFilterField: null,
|
setCheckedFilterFields(newCheckedFilterFields);
|
||||||
filterScopeMap: {
|
setFilterScopeMap({
|
||||||
|
...filterScopeMap,
|
||||||
|
...filterScopeTreeEntry,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[filterScopeMap, layout],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onExpandFilterField = useCallback(
|
||||||
|
(newExpandedFilterIds: (string | number)[] = []): void => {
|
||||||
|
setExpandedFilterIds(newExpandedFilterIds);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onChangeFilterField = useCallback(
|
||||||
|
(filterField: { value?: string } = {}): void => {
|
||||||
|
const nextActiveFilterField = filterField.value;
|
||||||
|
|
||||||
|
// we allow single edit and multiple edit in the same view.
|
||||||
|
// if user click on the single filter field,
|
||||||
|
// will show filter scope for the single field.
|
||||||
|
// if user click on the same filter filed again,
|
||||||
|
// will toggle off the single filter field,
|
||||||
|
// and allow multi-edit all checked filter fields.
|
||||||
|
if (nextActiveFilterField === activeFilterField) {
|
||||||
|
const filterScopeTreeEntry = buildFilterScopeTreeEntry({
|
||||||
|
checkedFilterFields,
|
||||||
|
activeFilterField: undefined,
|
||||||
|
filterScopeMap,
|
||||||
|
layout,
|
||||||
|
});
|
||||||
|
|
||||||
|
setActiveFilterField(null);
|
||||||
|
setFilterScopeMap({
|
||||||
...filterScopeMap,
|
...filterScopeMap,
|
||||||
...filterScopeTreeEntry,
|
...filterScopeTreeEntry,
|
||||||
} as FilterScopeMap,
|
});
|
||||||
});
|
} else if (
|
||||||
} else if (
|
nextActiveFilterField &&
|
||||||
nextActiveFilterField &&
|
allFilterFields.includes(nextActiveFilterField)
|
||||||
this.allfilterFields.includes(nextActiveFilterField)
|
) {
|
||||||
) {
|
const filterScopeTreeEntry = buildFilterScopeTreeEntry({
|
||||||
const filterScopeTreeEntry = buildFilterScopeTreeEntry({
|
checkedFilterFields,
|
||||||
checkedFilterFields,
|
activeFilterField: nextActiveFilterField,
|
||||||
activeFilterField: nextActiveFilterField,
|
filterScopeMap,
|
||||||
filterScopeMap,
|
layout,
|
||||||
layout,
|
});
|
||||||
});
|
|
||||||
|
|
||||||
this.setState({
|
setActiveFilterField(nextActiveFilterField);
|
||||||
activeFilterField: nextActiveFilterField,
|
setFilterScopeMap({
|
||||||
filterScopeMap: {
|
|
||||||
...filterScopeMap,
|
...filterScopeMap,
|
||||||
...filterScopeTreeEntry,
|
...filterScopeTreeEntry,
|
||||||
} as FilterScopeMap,
|
});
|
||||||
});
|
}
|
||||||
}
|
},
|
||||||
}
|
[
|
||||||
|
activeFilterField,
|
||||||
|
allFilterFields,
|
||||||
|
checkedFilterFields,
|
||||||
|
filterScopeMap,
|
||||||
|
layout,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
onSearchInputChange(e: ChangeEvent<HTMLInputElement>): void {
|
const onSearchInputChange = useCallback(
|
||||||
this.setState({ searchText: e.target.value }, this.filterTree);
|
(e: ChangeEvent<HTMLInputElement>): void => {
|
||||||
}
|
const newSearchText = e.target.value;
|
||||||
|
setSearchText(newSearchText);
|
||||||
|
filterTree(newSearchText);
|
||||||
|
},
|
||||||
|
[filterTree],
|
||||||
|
);
|
||||||
|
|
||||||
onClose(): void {
|
const onClose = useCallback((): void => {
|
||||||
this.props.onCloseModal();
|
onCloseModal();
|
||||||
}
|
}, [onCloseModal]);
|
||||||
|
|
||||||
onSave(): void {
|
const onSave = useCallback((): void => {
|
||||||
const state = this.state as FilterScopeSelectorStateWithSelector;
|
const allFilterFieldScopes = allFilterFields.reduce<
|
||||||
const { filterScopeMap } = state;
|
|
||||||
|
|
||||||
const allFilterFieldScopes = this.allfilterFields.reduce<
|
|
||||||
Record<string, ReturnType<typeof getFilterScopeFromNodesTree>>
|
Record<string, ReturnType<typeof getFilterScopeFromNodesTree>>
|
||||||
>((map, filterKey) => {
|
>((map, filterKey) => {
|
||||||
const { nodes } = filterScopeMap[filterKey];
|
const { nodes } = filterScopeMap[filterKey];
|
||||||
@@ -669,124 +737,32 @@ export default class FilterScopeSelector extends PureComponent<
|
|||||||
};
|
};
|
||||||
}, {});
|
}, {});
|
||||||
|
|
||||||
this.props.updateDashboardFiltersScope(allFilterFieldScopes);
|
updateDashboardFiltersScope(allFilterFieldScopes);
|
||||||
this.props.setUnsavedChanges(true);
|
setUnsavedChanges(true);
|
||||||
|
|
||||||
// click Save button will do save and close modal
|
// click Save button will do save and close modal
|
||||||
this.props.onCloseModal();
|
onCloseModal();
|
||||||
}
|
}, [
|
||||||
|
allFilterFields,
|
||||||
|
filterScopeMap,
|
||||||
|
onCloseModal,
|
||||||
|
setUnsavedChanges,
|
||||||
|
updateDashboardFiltersScope,
|
||||||
|
]);
|
||||||
|
|
||||||
filterTree(): void {
|
const renderFilterFieldList = (): ReactElement | null => (
|
||||||
const state = this.state as FilterScopeSelectorStateWithSelector;
|
<FilterFieldTree
|
||||||
// Reset nodes back to unfiltered state
|
activeKey={activeFilterField}
|
||||||
if (!state.searchText) {
|
nodes={filterFieldNodes}
|
||||||
this.setState(prevState => {
|
checked={checkedFilterFields}
|
||||||
const prev = prevState as FilterScopeSelectorStateWithSelector;
|
expanded={expandedFilterIds}
|
||||||
const { activeFilterField, checkedFilterFields, filterScopeMap } = prev;
|
onClick={onChangeFilterField}
|
||||||
const key = getKeyForFilterScopeTree({
|
onCheck={onCheckFilterField}
|
||||||
activeFilterField: activeFilterField ?? undefined,
|
onExpand={onExpandFilterField}
|
||||||
checkedFilterFields,
|
/>
|
||||||
});
|
);
|
||||||
|
|
||||||
const updatedEntry = {
|
|
||||||
...filterScopeMap[key],
|
|
||||||
nodesFiltered: filterScopeMap[key].nodes,
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
filterScopeMap: {
|
|
||||||
...filterScopeMap,
|
|
||||||
[key]: updatedEntry,
|
|
||||||
},
|
|
||||||
} as Partial<FilterScopeSelectorStateWithSelector> as FilterScopeSelectorState;
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
const updater = (
|
|
||||||
prevState: FilterScopeSelectorState,
|
|
||||||
): FilterScopeSelectorState => {
|
|
||||||
const prev = prevState as FilterScopeSelectorStateWithSelector;
|
|
||||||
const { activeFilterField, checkedFilterFields, filterScopeMap } = prev;
|
|
||||||
const key = getKeyForFilterScopeTree({
|
|
||||||
activeFilterField: activeFilterField ?? undefined,
|
|
||||||
checkedFilterFields,
|
|
||||||
});
|
|
||||||
|
|
||||||
const nodesFiltered = filterScopeMap[key].nodes.reduce<
|
|
||||||
FilterScopeTreeNode[]
|
|
||||||
>(this.filterNodes, []);
|
|
||||||
const expanded = getFilterScopeParentNodes([...nodesFiltered]);
|
|
||||||
const updatedEntry = {
|
|
||||||
...filterScopeMap[key],
|
|
||||||
nodesFiltered,
|
|
||||||
expanded,
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
filterScopeMap: {
|
|
||||||
...filterScopeMap,
|
|
||||||
[key]: updatedEntry,
|
|
||||||
},
|
|
||||||
} as Partial<FilterScopeSelectorStateWithSelector> as FilterScopeSelectorState;
|
|
||||||
};
|
|
||||||
|
|
||||||
this.setState(updater);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
filterNodes(
|
|
||||||
filtered: FilterScopeTreeNode[] = [],
|
|
||||||
node: FilterScopeTreeNode = { value: '', label: '' },
|
|
||||||
): FilterScopeTreeNode[] {
|
|
||||||
const state = this.state as FilterScopeSelectorStateWithSelector;
|
|
||||||
const { searchText } = state;
|
|
||||||
const children = (node.children || []).reduce<FilterScopeTreeNode[]>(
|
|
||||||
this.filterNodes,
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (
|
|
||||||
// Node's label matches the search string
|
|
||||||
node.label
|
|
||||||
.toLocaleLowerCase()
|
|
||||||
.indexOf((searchText ?? '').toLocaleLowerCase()) > -1 ||
|
|
||||||
// Or a children has a matching node
|
|
||||||
children.length
|
|
||||||
) {
|
|
||||||
filtered.push({ ...node, children });
|
|
||||||
}
|
|
||||||
|
|
||||||
return filtered;
|
|
||||||
}
|
|
||||||
|
|
||||||
renderFilterFieldList(): ReactElement | null {
|
|
||||||
const state = this.state as FilterScopeSelectorStateWithSelector;
|
|
||||||
const {
|
|
||||||
activeFilterField,
|
|
||||||
filterFieldNodes,
|
|
||||||
checkedFilterFields,
|
|
||||||
expandedFilterIds,
|
|
||||||
} = state;
|
|
||||||
return (
|
|
||||||
<FilterFieldTree
|
|
||||||
activeKey={activeFilterField}
|
|
||||||
nodes={filterFieldNodes}
|
|
||||||
checked={checkedFilterFields}
|
|
||||||
expanded={expandedFilterIds}
|
|
||||||
onClick={this.onChangeFilterField}
|
|
||||||
onCheck={this.onCheckFilterField}
|
|
||||||
onExpand={this.onExpandFilterField}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
renderFilterScopeTree(): ReactElement {
|
|
||||||
const state = this.state as FilterScopeSelectorStateWithSelector;
|
|
||||||
const {
|
|
||||||
filterScopeMap,
|
|
||||||
activeFilterField,
|
|
||||||
checkedFilterFields,
|
|
||||||
searchText,
|
|
||||||
} = state;
|
|
||||||
|
|
||||||
|
const renderFilterScopeTree = (): ReactElement => {
|
||||||
const key = getKeyForFilterScopeTree({
|
const key = getKeyForFilterScopeTree({
|
||||||
activeFilterField: activeFilterField ?? undefined,
|
activeFilterField: activeFilterField ?? undefined,
|
||||||
checkedFilterFields,
|
checkedFilterFields,
|
||||||
@@ -803,26 +779,23 @@ export default class FilterScopeSelector extends PureComponent<
|
|||||||
placeholder={t('Search...')}
|
placeholder={t('Search...')}
|
||||||
type="text"
|
type="text"
|
||||||
value={searchText}
|
value={searchText}
|
||||||
onChange={this.onSearchInputChange}
|
onChange={onSearchInputChange}
|
||||||
/>
|
/>
|
||||||
<FilterScopeTree
|
<FilterScopeTree
|
||||||
nodes={filterScopeMap[key].nodesFiltered}
|
nodes={filterScopeMap[key].nodesFiltered}
|
||||||
checked={filterScopeMap[key].checked}
|
checked={filterScopeMap[key].checked}
|
||||||
expanded={filterScopeMap[key].expanded}
|
expanded={filterScopeMap[key].expanded}
|
||||||
onCheck={this.onCheckFilterScope}
|
onCheck={onCheckFilterScope}
|
||||||
onExpand={this.onExpandFilterScope}
|
onExpand={onExpandFilterScope}
|
||||||
// pass selectedFilterId prop to FilterScopeTree component,
|
// pass selectedFilterId prop to FilterScopeTree component,
|
||||||
// to hide checkbox for selected filter field itself
|
// to hide checkbox for selected filter field itself
|
||||||
selectedChartId={selectedChartId}
|
selectedChartId={selectedChartId}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
renderEditingFiltersName(): ReactElement {
|
const renderEditingFiltersName = (): ReactElement => {
|
||||||
const { dashboardFilters } = this.props;
|
|
||||||
const state = this.state as FilterScopeSelectorStateWithSelector;
|
|
||||||
const { activeFilterField, checkedFilterFields } = state;
|
|
||||||
const currentFilterLabels = ([] as string[])
|
const currentFilterLabels = ([] as string[])
|
||||||
.concat(activeFilterField || checkedFilterFields)
|
.concat(activeFilterField || checkedFilterFields)
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
@@ -842,50 +815,42 @@ export default class FilterScopeSelector extends PureComponent<
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
render(): ReactElement {
|
return (
|
||||||
const { showSelector } = this.state;
|
<ScopeContainer>
|
||||||
|
<ScopeHeader>
|
||||||
|
<h4>{t('Configure filter scopes')}</h4>
|
||||||
|
{showSelector && renderEditingFiltersName()}
|
||||||
|
</ScopeHeader>
|
||||||
|
|
||||||
return (
|
<ScopeBody className="filter-scope-body">
|
||||||
<ScopeContainer>
|
{!showSelector ? (
|
||||||
<ScopeHeader>
|
<div className="warning-message">
|
||||||
<h4>{t('Configure filter scopes')}</h4>
|
{t('There are no filters in this dashboard.')}
|
||||||
{showSelector && this.renderEditingFiltersName()}
|
</div>
|
||||||
</ScopeHeader>
|
) : (
|
||||||
|
<ScopeSelector className="filters-scope-selector">
|
||||||
<ScopeBody className="filter-scope-body">
|
<div className={cx('filter-field-pane multi-edit-mode')}>
|
||||||
{!showSelector ? (
|
{renderFilterFieldList()}
|
||||||
<div className="warning-message">
|
|
||||||
{t('There are no filters in this dashboard.')}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
<div className="filter-scope-pane multi-edit-mode">
|
||||||
<ScopeSelector className="filters-scope-selector">
|
{renderFilterScopeTree()}
|
||||||
<div className={cx('filter-field-pane multi-edit-mode')}>
|
</div>
|
||||||
{this.renderFilterFieldList()}
|
</ScopeSelector>
|
||||||
</div>
|
)}
|
||||||
<div className="filter-scope-pane multi-edit-mode">
|
</ScopeBody>
|
||||||
{this.renderFilterScopeTree()}
|
|
||||||
</div>
|
|
||||||
</ScopeSelector>
|
|
||||||
)}
|
|
||||||
</ScopeBody>
|
|
||||||
|
|
||||||
<ActionsContainer>
|
<ActionsContainer>
|
||||||
<Button buttonSize="small" onClick={this.onClose}>
|
<Button buttonSize="small" onClick={onClose}>
|
||||||
{t('Close')}
|
{t('Close')}
|
||||||
|
</Button>
|
||||||
|
{showSelector && (
|
||||||
|
<Button buttonSize="small" buttonStyle="primary" onClick={onSave}>
|
||||||
|
{t('Save')}
|
||||||
</Button>
|
</Button>
|
||||||
{showSelector && (
|
)}
|
||||||
<Button
|
</ActionsContainer>
|
||||||
buttonSize="small"
|
</ScopeContainer>
|
||||||
buttonStyle="primary"
|
);
|
||||||
onClick={this.onSave}
|
|
||||||
>
|
|
||||||
{t('Save')}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</ActionsContainer>
|
|
||||||
</ScopeContainer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -128,6 +128,10 @@ const SliceContainer = styled.div`
|
|||||||
|
|
||||||
const EMPTY_OBJECT: Record<string, never> = {};
|
const EMPTY_OBJECT: Record<string, never> = {};
|
||||||
|
|
||||||
|
// Stable no-op fallback for optional callbacks so we don't allocate a new
|
||||||
|
// function on every render (keeps referential equality for memoized children).
|
||||||
|
const NOOP = () => {};
|
||||||
|
|
||||||
// Helper function to get chart state with fallback
|
// Helper function to get chart state with fallback
|
||||||
const getChartStateWithFallback = (
|
const getChartStateWithFallback = (
|
||||||
chartState: { state?: JsonObject } | undefined,
|
chartState: { state?: JsonObject } | undefined,
|
||||||
@@ -763,11 +767,11 @@ const Chart = (props: ChartProps) => {
|
|||||||
},
|
},
|
||||||
slice.viz_type,
|
slice.viz_type,
|
||||||
)}
|
)}
|
||||||
queriesResponse={chart.queriesResponse ?? undefined}
|
queriesResponse={chart.queriesResponse ?? null}
|
||||||
timeout={timeout}
|
timeout={timeout}
|
||||||
triggerQuery={chart.triggerQuery}
|
triggerQuery={chart.triggerQuery}
|
||||||
vizType={slice.viz_type}
|
vizType={slice.viz_type}
|
||||||
setControlValue={props.setControlValue}
|
setControlValue={props.setControlValue ?? NOOP}
|
||||||
datasetsStatus={
|
datasetsStatus={
|
||||||
datasetsStatus as 'loading' | 'error' | 'complete' | undefined
|
datasetsStatus as 'loading' | 'error' | 'complete' | undefined
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,9 +17,8 @@
|
|||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { PureComponent } from 'react';
|
import { useCallback, memo } from 'react';
|
||||||
import { css, styled } from '@apache-superset/core/theme';
|
import { css, styled } from '@apache-superset/core/theme';
|
||||||
|
|
||||||
import { Draggable } from '../../dnd/DragDroppable';
|
import { Draggable } from '../../dnd/DragDroppable';
|
||||||
import HoverMenu from '../../menu/HoverMenu';
|
import HoverMenu from '../../menu/HoverMenu';
|
||||||
import DeleteComponentButton from '../../DeleteComponentButton';
|
import DeleteComponentButton from '../../DeleteComponentButton';
|
||||||
@@ -63,50 +62,43 @@ const DividerLine = styled.div`
|
|||||||
`}
|
`}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
class Divider extends PureComponent<DividerProps> {
|
function Divider({
|
||||||
constructor(props: DividerProps) {
|
id,
|
||||||
super(props);
|
parentId,
|
||||||
this.handleDeleteComponent = this.handleDeleteComponent.bind(this);
|
component,
|
||||||
}
|
depth,
|
||||||
|
parentComponent,
|
||||||
handleDeleteComponent() {
|
index,
|
||||||
const { deleteComponent, id, parentId } = this.props;
|
editMode,
|
||||||
|
handleComponentDrop,
|
||||||
|
deleteComponent,
|
||||||
|
}: DividerProps) {
|
||||||
|
const handleDeleteComponent = useCallback(() => {
|
||||||
deleteComponent(id, parentId);
|
deleteComponent(id, parentId);
|
||||||
}
|
}, [deleteComponent, id, parentId]);
|
||||||
|
|
||||||
render() {
|
return (
|
||||||
const {
|
<Draggable
|
||||||
component,
|
component={component}
|
||||||
depth,
|
parentComponent={parentComponent}
|
||||||
parentComponent,
|
orientation="row"
|
||||||
index,
|
index={index}
|
||||||
handleComponentDrop,
|
depth={depth}
|
||||||
editMode,
|
onDrop={handleComponentDrop}
|
||||||
} = this.props;
|
editMode={editMode}
|
||||||
|
>
|
||||||
return (
|
{({ dragSourceRef }: { dragSourceRef: ConnectDragSource }) => (
|
||||||
<Draggable
|
<div ref={dragSourceRef}>
|
||||||
component={component}
|
{editMode && (
|
||||||
parentComponent={parentComponent}
|
<HoverMenu position="left">
|
||||||
orientation="row"
|
<DeleteComponentButton onDelete={handleDeleteComponent} />
|
||||||
index={index}
|
</HoverMenu>
|
||||||
depth={depth}
|
)}
|
||||||
onDrop={handleComponentDrop}
|
<DividerLine className="dashboard-component dashboard-component-divider" />
|
||||||
editMode={editMode}
|
</div>
|
||||||
>
|
)}
|
||||||
{({ dragSourceRef }: { dragSourceRef: ConnectDragSource }) => (
|
</Draggable>
|
||||||
<div ref={dragSourceRef}>
|
);
|
||||||
{editMode && (
|
|
||||||
<HoverMenu position="left">
|
|
||||||
<DeleteComponentButton onDelete={this.handleDeleteComponent} />
|
|
||||||
</HoverMenu>
|
|
||||||
)}
|
|
||||||
<DividerLine className="dashboard-component dashboard-component-divider" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Draggable>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Divider;
|
export default memo(Divider);
|
||||||
|
|||||||
@@ -16,10 +16,9 @@
|
|||||||
* specific language governing permissions and limitations
|
* specific language governing permissions and limitations
|
||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
import { PureComponent } from 'react';
|
import { useState, useCallback, memo } from 'react';
|
||||||
import cx from 'classnames';
|
import cx from 'classnames';
|
||||||
import { css, styled } from '@apache-superset/core/theme';
|
import { css, styled } from '@apache-superset/core/theme';
|
||||||
|
|
||||||
import PopoverDropdown from '@superset-ui/core/components/PopoverDropdown';
|
import PopoverDropdown from '@superset-ui/core/components/PopoverDropdown';
|
||||||
import { EditableTitle } from '@superset-ui/core/components';
|
import { EditableTitle } from '@superset-ui/core/components';
|
||||||
import { Draggable } from 'src/dashboard/components/dnd/DragDroppable';
|
import { Draggable } from 'src/dashboard/components/dnd/DragDroppable';
|
||||||
@@ -85,10 +84,6 @@ interface HeaderProps {
|
|||||||
updateComponents: (changes: Record<string, ComponentShape>) => void;
|
updateComponents: (changes: Record<string, ComponentShape>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface HeaderState {
|
|
||||||
isFocused: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const HeaderStyles = styled.div`
|
const HeaderStyles = styled.div`
|
||||||
${({ theme }) => css`
|
${({ theme }) => css`
|
||||||
font-weight: ${theme.fontWeightStrong};
|
font-weight: ${theme.fontWeightStrong};
|
||||||
@@ -159,149 +154,141 @@ const HeaderStyles = styled.div`
|
|||||||
`}
|
`}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
class Header extends PureComponent<HeaderProps, HeaderState> {
|
function Header({
|
||||||
handleChangeSize: (nextValue: string) => void;
|
id,
|
||||||
handleChangeBackground: (nextValue: string) => void;
|
dashboardId,
|
||||||
handleChangeText: (nextValue: string) => void;
|
parentId,
|
||||||
|
component,
|
||||||
|
depth,
|
||||||
|
parentComponent,
|
||||||
|
index,
|
||||||
|
editMode,
|
||||||
|
embeddedMode,
|
||||||
|
handleComponentDrop,
|
||||||
|
deleteComponent,
|
||||||
|
updateComponents,
|
||||||
|
}: HeaderProps) {
|
||||||
|
const [isFocused, setIsFocused] = useState(false);
|
||||||
|
|
||||||
constructor(props: HeaderProps) {
|
const handleChangeFocus = useCallback((nextFocus: boolean): void => {
|
||||||
super(props);
|
setIsFocused(nextFocus);
|
||||||
this.state = {
|
}, []);
|
||||||
isFocused: false,
|
|
||||||
};
|
|
||||||
this.handleDeleteComponent = this.handleDeleteComponent.bind(this);
|
|
||||||
this.handleChangeFocus = this.handleChangeFocus.bind(this);
|
|
||||||
this.handleUpdateMeta = this.handleUpdateMeta.bind(this);
|
|
||||||
|
|
||||||
this.handleChangeSize = (nextValue: string) =>
|
const handleUpdateMeta = useCallback(
|
||||||
this.handleUpdateMeta('headerSize', nextValue);
|
(metaKey: keyof ComponentMeta, nextValue: string): void => {
|
||||||
this.handleChangeBackground = (nextValue: string) =>
|
if (nextValue && component.meta[metaKey] !== nextValue) {
|
||||||
this.handleUpdateMeta('background', nextValue);
|
updateComponents({
|
||||||
this.handleChangeText = (nextValue: string) =>
|
[component.id]: {
|
||||||
this.handleUpdateMeta('text', nextValue);
|
...component,
|
||||||
}
|
meta: {
|
||||||
|
...component.meta,
|
||||||
handleChangeFocus(nextFocus: boolean): void {
|
[metaKey]: nextValue,
|
||||||
this.setState(() => ({ isFocused: nextFocus }));
|
},
|
||||||
}
|
|
||||||
|
|
||||||
handleUpdateMeta(metaKey: keyof ComponentMeta, nextValue: string): void {
|
|
||||||
const { updateComponents, component } = this.props;
|
|
||||||
if (nextValue && component.meta[metaKey] !== nextValue) {
|
|
||||||
updateComponents({
|
|
||||||
[component.id]: {
|
|
||||||
...component,
|
|
||||||
meta: {
|
|
||||||
...component.meta,
|
|
||||||
[metaKey]: nextValue,
|
|
||||||
},
|
},
|
||||||
},
|
} as Record<string, ComponentShape>);
|
||||||
} as Record<string, ComponentShape>);
|
}
|
||||||
}
|
},
|
||||||
}
|
[component, updateComponents],
|
||||||
|
);
|
||||||
|
|
||||||
handleDeleteComponent(): void {
|
const handleChangeSize = useCallback(
|
||||||
const { deleteComponent, id, parentId } = this.props;
|
(nextValue: string) => handleUpdateMeta('headerSize', nextValue),
|
||||||
|
[handleUpdateMeta],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleChangeBackground = useCallback(
|
||||||
|
(nextValue: string) => handleUpdateMeta('background', nextValue),
|
||||||
|
[handleUpdateMeta],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleChangeText = useCallback(
|
||||||
|
(nextValue: string) => handleUpdateMeta('text', nextValue),
|
||||||
|
[handleUpdateMeta],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDeleteComponent = useCallback((): void => {
|
||||||
deleteComponent(id, parentId);
|
deleteComponent(id, parentId);
|
||||||
}
|
}, [deleteComponent, id, parentId]);
|
||||||
|
|
||||||
render() {
|
const headerStyle = headerStyleOptions.find(
|
||||||
const { isFocused } = this.state;
|
opt => opt.value === (component.meta.headerSize || SMALL_HEADER),
|
||||||
|
);
|
||||||
|
|
||||||
const {
|
const rowStyle = backgroundStyleOptions.find(
|
||||||
dashboardId,
|
opt => opt.value === (component.meta.background || BACKGROUND_TRANSPARENT),
|
||||||
component,
|
);
|
||||||
depth,
|
|
||||||
parentComponent,
|
|
||||||
index,
|
|
||||||
handleComponentDrop,
|
|
||||||
editMode,
|
|
||||||
embeddedMode,
|
|
||||||
} = this.props;
|
|
||||||
|
|
||||||
const headerStyle = headerStyleOptions.find(
|
return (
|
||||||
opt => opt.value === (component.meta.headerSize || SMALL_HEADER),
|
<Draggable
|
||||||
);
|
component={component}
|
||||||
|
parentComponent={parentComponent}
|
||||||
const rowStyle = backgroundStyleOptions.find(
|
orientation="row"
|
||||||
opt =>
|
index={index}
|
||||||
opt.value === (component.meta.background || BACKGROUND_TRANSPARENT),
|
depth={depth}
|
||||||
);
|
onDrop={handleComponentDrop}
|
||||||
|
disableDragDrop={isFocused}
|
||||||
return (
|
editMode={editMode}
|
||||||
<Draggable
|
>
|
||||||
component={component}
|
{({
|
||||||
parentComponent={parentComponent}
|
dragSourceRef,
|
||||||
orientation="row"
|
}: {
|
||||||
index={index}
|
dragSourceRef: React.Ref<HTMLDivElement> | undefined;
|
||||||
depth={depth}
|
}) => (
|
||||||
onDrop={handleComponentDrop}
|
<div ref={dragSourceRef}>
|
||||||
disableDragDrop={isFocused}
|
{editMode &&
|
||||||
editMode={editMode}
|
depth <= 2 && ( // drag handle looks bad when nested
|
||||||
>
|
<HoverMenu position="left">
|
||||||
{({
|
<DragHandle position="left" />
|
||||||
dragSourceRef,
|
</HoverMenu>
|
||||||
}: {
|
)}
|
||||||
dragSourceRef: React.Ref<HTMLDivElement> | undefined;
|
<WithPopoverMenu
|
||||||
}) => (
|
onChangeFocus={handleChangeFocus}
|
||||||
<div ref={dragSourceRef}>
|
menuItems={[
|
||||||
{editMode &&
|
<PopoverDropdown
|
||||||
depth <= 2 && ( // drag handle looks bad when nested
|
id={`${component.id}-header-style`}
|
||||||
<HoverMenu position="left">
|
options={headerStyleOptions}
|
||||||
<DragHandle position="left" />
|
value={component.meta.headerSize as string}
|
||||||
|
onChange={handleChangeSize}
|
||||||
|
/>,
|
||||||
|
<BackgroundStyleDropdown
|
||||||
|
id={`${component.id}-background`}
|
||||||
|
value={component.meta.background as string}
|
||||||
|
onChange={handleChangeBackground}
|
||||||
|
/>,
|
||||||
|
]}
|
||||||
|
editMode={editMode}
|
||||||
|
>
|
||||||
|
<HeaderStyles
|
||||||
|
className={cx(
|
||||||
|
'dashboard-component',
|
||||||
|
'dashboard-component-header',
|
||||||
|
headerStyle?.className,
|
||||||
|
rowStyle?.className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{editMode && (
|
||||||
|
<HoverMenu position="top">
|
||||||
|
<DeleteComponentButton onDelete={handleDeleteComponent} />
|
||||||
</HoverMenu>
|
</HoverMenu>
|
||||||
)}
|
)}
|
||||||
<WithPopoverMenu
|
<EditableTitle
|
||||||
onChangeFocus={this.handleChangeFocus}
|
title={component.meta.text}
|
||||||
menuItems={[
|
canEdit={editMode}
|
||||||
<PopoverDropdown
|
onSaveTitle={handleChangeText}
|
||||||
id={`${component.id}-header-style`}
|
showTooltip={false}
|
||||||
options={headerStyleOptions}
|
/>
|
||||||
value={component.meta.headerSize as string}
|
{!editMode && !embeddedMode && (
|
||||||
onChange={this.handleChangeSize}
|
<AnchorLink
|
||||||
/>,
|
id={component.id}
|
||||||
<BackgroundStyleDropdown
|
dashboardId={Number(dashboardId)}
|
||||||
id={`${component.id}-background`}
|
|
||||||
value={component.meta.background as string}
|
|
||||||
onChange={this.handleChangeBackground}
|
|
||||||
/>,
|
|
||||||
]}
|
|
||||||
editMode={editMode}
|
|
||||||
>
|
|
||||||
<HeaderStyles
|
|
||||||
className={cx(
|
|
||||||
'dashboard-component',
|
|
||||||
'dashboard-component-header',
|
|
||||||
headerStyle?.className,
|
|
||||||
rowStyle?.className,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{editMode && (
|
|
||||||
<HoverMenu position="top">
|
|
||||||
<DeleteComponentButton
|
|
||||||
onDelete={this.handleDeleteComponent}
|
|
||||||
/>
|
|
||||||
</HoverMenu>
|
|
||||||
)}
|
|
||||||
<EditableTitle
|
|
||||||
title={component.meta.text}
|
|
||||||
canEdit={editMode}
|
|
||||||
onSaveTitle={this.handleChangeText}
|
|
||||||
showTooltip={false}
|
|
||||||
/>
|
/>
|
||||||
{!editMode && !embeddedMode && (
|
)}
|
||||||
<AnchorLink
|
</HeaderStyles>
|
||||||
id={component.id}
|
</WithPopoverMenu>
|
||||||
dashboardId={Number(dashboardId)}
|
</div>
|
||||||
/>
|
)}
|
||||||
)}
|
</Draggable>
|
||||||
</HeaderStyles>
|
);
|
||||||
</WithPopoverMenu>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Draggable>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Header;
|
export default memo(Header);
|
||||||
|
|||||||
@@ -16,14 +16,16 @@
|
|||||||
* specific language governing permissions and limitations
|
* specific language governing permissions and limitations
|
||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
import { PureComponent } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import type { ErrorInfo } from 'react';
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import cx from 'classnames';
|
import cx from 'classnames';
|
||||||
import type { JsonObject } from '@superset-ui/core';
|
import type { JsonObject } from '@superset-ui/core';
|
||||||
import type { ResizeStartCallback, ResizeCallback } from 're-resizable';
|
import type { ResizeStartCallback, ResizeCallback } from 're-resizable';
|
||||||
|
import { ErrorBoundary } from 'src/components';
|
||||||
|
|
||||||
import { css, styled } from '@apache-superset/core/theme';
|
|
||||||
import { t } from '@apache-superset/core/translation';
|
import { t } from '@apache-superset/core/translation';
|
||||||
|
import { css, styled } from '@apache-superset/core/theme';
|
||||||
import { SafeMarkdown } from '@superset-ui/core/components';
|
import { SafeMarkdown } from '@superset-ui/core/components';
|
||||||
import { EditorHost } from 'src/core/editors';
|
import { EditorHost } from 'src/core/editors';
|
||||||
import { Logger, LOG_ACTIONS_RENDER_CHART } from 'src/logger/LogUtils';
|
import { Logger, LOG_ACTIONS_RENDER_CHART } from 'src/logger/LogUtils';
|
||||||
@@ -82,16 +84,6 @@ export interface MarkdownStateProps {
|
|||||||
|
|
||||||
export type MarkdownProps = MarkdownOwnProps & MarkdownStateProps;
|
export type MarkdownProps = MarkdownOwnProps & MarkdownStateProps;
|
||||||
|
|
||||||
export interface MarkdownState {
|
|
||||||
isFocused: boolean;
|
|
||||||
markdownSource: string;
|
|
||||||
editor: EditorInstance | null;
|
|
||||||
editorMode: 'preview' | 'edit';
|
|
||||||
undoLength: number;
|
|
||||||
redoLength: number;
|
|
||||||
hasError?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: localize
|
// TODO: localize
|
||||||
const MARKDOWN_PLACE_HOLDER = `# ✨Header 1
|
const MARKDOWN_PLACE_HOLDER = `# ✨Header 1
|
||||||
## ✨Header 2
|
## ✨Header 2
|
||||||
@@ -140,193 +132,199 @@ interface DragChildProps {
|
|||||||
dragSourceRef: React.RefCallback<HTMLElement>;
|
dragSourceRef: React.RefCallback<HTMLElement>;
|
||||||
}
|
}
|
||||||
|
|
||||||
class Markdown extends PureComponent<MarkdownProps, MarkdownState> {
|
function Markdown({
|
||||||
renderStartTime: number;
|
id,
|
||||||
|
parentId,
|
||||||
|
component,
|
||||||
|
parentComponent,
|
||||||
|
index,
|
||||||
|
depth,
|
||||||
|
editMode,
|
||||||
|
availableColumnCount,
|
||||||
|
columnWidth,
|
||||||
|
onResizeStart,
|
||||||
|
onResize,
|
||||||
|
onResizeStop,
|
||||||
|
deleteComponent,
|
||||||
|
handleComponentDrop,
|
||||||
|
updateComponents,
|
||||||
|
logEvent,
|
||||||
|
addDangerToast,
|
||||||
|
undoLength,
|
||||||
|
redoLength,
|
||||||
|
htmlSanitization,
|
||||||
|
htmlSchemaOverrides,
|
||||||
|
}: MarkdownProps) {
|
||||||
|
const [isFocused, setIsFocused] = useState(false);
|
||||||
|
const [markdownSource, setMarkdownSource] = useState<string>(
|
||||||
|
component.meta.code as string,
|
||||||
|
);
|
||||||
|
const [editor, setEditorState] = useState<EditorInstance | null>(null);
|
||||||
|
const [editorMode, setEditorMode] = useState<'preview' | 'edit'>('preview');
|
||||||
|
const [hasError, setHasError] = useState(false);
|
||||||
|
|
||||||
constructor(props: MarkdownProps) {
|
const renderStartTimeRef = useRef(Logger.getTimestamp());
|
||||||
super(props);
|
const prevUndoLengthRef = useRef(undoLength);
|
||||||
this.state = {
|
const prevRedoLengthRef = useRef(redoLength);
|
||||||
isFocused: false,
|
const prevComponentWidthRef = useRef(component.meta.width);
|
||||||
markdownSource: props.component.meta.code as string,
|
const prevColumnWidthRef = useRef(columnWidth);
|
||||||
editor: null,
|
|
||||||
editorMode: 'preview',
|
|
||||||
undoLength: props.undoLength,
|
|
||||||
redoLength: props.redoLength,
|
|
||||||
};
|
|
||||||
this.renderStartTime = Logger.getTimestamp();
|
|
||||||
|
|
||||||
this.handleChangeFocus = this.handleChangeFocus.bind(this);
|
// getDerivedStateFromProps equivalent for undo/redo. Run during render
|
||||||
this.handleChangeEditorMode = this.handleChangeEditorMode.bind(this);
|
// (not in an effect) so the new markdownSource is applied before the commit,
|
||||||
this.handleMarkdownChange = this.handleMarkdownChange.bind(this);
|
// avoiding a one-frame flash of the old content. React bails out of the
|
||||||
this.handleDeleteComponent = this.handleDeleteComponent.bind(this);
|
// intermediate render without committing it.
|
||||||
this.handleResizeStart = this.handleResizeStart.bind(this);
|
const isUndoRedo =
|
||||||
this.setEditor = this.setEditor.bind(this);
|
undoLength !== prevUndoLengthRef.current ||
|
||||||
this.shouldFocusMarkdown = this.shouldFocusMarkdown.bind(this);
|
redoLength !== prevRedoLengthRef.current;
|
||||||
|
if (isUndoRedo) {
|
||||||
|
setMarkdownSource(component.meta.code as string);
|
||||||
|
setHasError(false);
|
||||||
|
prevUndoLengthRef.current = undoLength;
|
||||||
|
prevRedoLengthRef.current = redoLength;
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidMount(): void {
|
// Sync external code changes (not from undo/redo) while in preview mode.
|
||||||
this.props.logEvent(LOG_ACTIONS_RENDER_CHART, {
|
useEffect(() => {
|
||||||
viz_type: 'markdown',
|
|
||||||
start_offset: this.renderStartTime,
|
|
||||||
ts: new Date().getTime(),
|
|
||||||
duration: Logger.getTimestamp() - this.renderStartTime,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
static getDerivedStateFromProps(
|
|
||||||
nextProps: MarkdownProps,
|
|
||||||
state: MarkdownState,
|
|
||||||
): MarkdownState | null {
|
|
||||||
const { hasError, editorMode, markdownSource, undoLength, redoLength } =
|
|
||||||
state;
|
|
||||||
const {
|
|
||||||
component: nextComponent,
|
|
||||||
undoLength: nextUndoLength,
|
|
||||||
redoLength: nextRedoLength,
|
|
||||||
} = nextProps;
|
|
||||||
// user click undo or redo ?
|
|
||||||
if (nextUndoLength !== undoLength || nextRedoLength !== redoLength) {
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
undoLength: nextUndoLength,
|
|
||||||
redoLength: nextRedoLength,
|
|
||||||
markdownSource: nextComponent.meta.code as string,
|
|
||||||
hasError: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (
|
if (
|
||||||
|
!isUndoRedo &&
|
||||||
!hasError &&
|
!hasError &&
|
||||||
editorMode === 'preview' &&
|
editorMode === 'preview' &&
|
||||||
nextComponent.meta.code !== markdownSource
|
component.meta.code !== markdownSource
|
||||||
) {
|
) {
|
||||||
return {
|
setMarkdownSource(component.meta.code as string);
|
||||||
...state,
|
|
||||||
markdownSource: nextComponent.meta.code as string,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
}, [isUndoRedo, component.meta.code, hasError, editorMode, markdownSource]);
|
||||||
|
|
||||||
return state;
|
// componentDidMount equivalent: log render event
|
||||||
}
|
useEffect(() => {
|
||||||
|
logEvent(LOG_ACTIONS_RENDER_CHART, {
|
||||||
|
viz_type: 'markdown',
|
||||||
|
start_offset: renderStartTimeRef.current,
|
||||||
|
ts: new Date().getTime(),
|
||||||
|
duration: Logger.getTimestamp() - renderStartTimeRef.current,
|
||||||
|
});
|
||||||
|
// Only run on mount
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
static getDerivedStateFromError(): { hasError: boolean } {
|
// componentDidUpdate equivalent: resize editor when width changes
|
||||||
return {
|
useEffect(() => {
|
||||||
hasError: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidUpdate(prevProps: MarkdownProps): void {
|
|
||||||
if (
|
if (
|
||||||
this.state.editor &&
|
editor &&
|
||||||
(prevProps.component.meta.width !== this.props.component.meta.width ||
|
(prevComponentWidthRef.current !== component.meta.width ||
|
||||||
prevProps.columnWidth !== this.props.columnWidth)
|
prevColumnWidthRef.current !== columnWidth)
|
||||||
) {
|
) {
|
||||||
// Handle both Ace editor (resize method) and EditorHandle (no resize needed)
|
// Handle both Ace editor (resize method) and EditorHandle (no resize needed)
|
||||||
if (typeof this.state.editor.resize === 'function') {
|
if (typeof editor.resize === 'function') {
|
||||||
this.state.editor.resize(true);
|
editor.resize(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
prevComponentWidthRef.current = component.meta.width;
|
||||||
|
prevColumnWidthRef.current = columnWidth;
|
||||||
|
}, [editor, component.meta.width, columnWidth]);
|
||||||
|
|
||||||
componentDidCatch(): void {
|
const updateMarkdownContent = useCallback((): void => {
|
||||||
if (this.state.editor && this.state.editorMode === 'preview') {
|
if (component.meta.code !== markdownSource) {
|
||||||
this.props.addDangerToast(
|
|
||||||
t(
|
|
||||||
'This markdown component has an error. Please revert your recent changes.',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setEditor(editor: EditorInstance): void {
|
|
||||||
// EditorHandle or Ace editor instance
|
|
||||||
// For Ace: editor.getSession().setUseWrapMode(true)
|
|
||||||
// For EditorHandle: wrapEnabled is handled via options
|
|
||||||
if (editor?.getSession) {
|
|
||||||
editor.getSession!().setUseWrapMode(true);
|
|
||||||
}
|
|
||||||
this.setState({
|
|
||||||
editor,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
handleChangeFocus(nextFocus: boolean | number): void {
|
|
||||||
const nextFocused = !!nextFocus;
|
|
||||||
const nextEditMode: 'edit' | 'preview' = nextFocused ? 'edit' : 'preview';
|
|
||||||
this.setState(() => ({ isFocused: nextFocused }));
|
|
||||||
this.handleChangeEditorMode(nextEditMode);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleChangeEditorMode(mode: 'edit' | 'preview'): void {
|
|
||||||
const nextState: MarkdownState = {
|
|
||||||
...this.state,
|
|
||||||
editorMode: mode,
|
|
||||||
};
|
|
||||||
if (mode === 'preview') {
|
|
||||||
this.updateMarkdownContent();
|
|
||||||
nextState.hasError = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.setState(nextState);
|
|
||||||
}
|
|
||||||
|
|
||||||
updateMarkdownContent(): void {
|
|
||||||
const { updateComponents, component } = this.props;
|
|
||||||
if (component.meta.code !== this.state.markdownSource) {
|
|
||||||
updateComponents({
|
updateComponents({
|
||||||
[component.id]: {
|
[component.id]: {
|
||||||
...component,
|
...component,
|
||||||
meta: {
|
meta: {
|
||||||
...component.meta,
|
...component.meta,
|
||||||
code: this.state.markdownSource,
|
code: markdownSource,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}, [component, markdownSource, updateComponents]);
|
||||||
|
|
||||||
handleMarkdownChange(nextValue: string): void {
|
const setEditor = useCallback((editorInstance: EditorInstance): void => {
|
||||||
this.setState({
|
// EditorHandle or Ace editor instance
|
||||||
markdownSource: nextValue,
|
// For Ace: editor.getSession().setUseWrapMode(true)
|
||||||
});
|
// For EditorHandle: wrapEnabled is handled via options
|
||||||
}
|
if (editorInstance?.getSession) {
|
||||||
|
editorInstance.getSession!().setUseWrapMode(true);
|
||||||
handleDeleteComponent(): void {
|
|
||||||
const { deleteComponent, id, parentId } = this.props;
|
|
||||||
deleteComponent(id, parentId);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleResizeStart(...args: Parameters<ResizeStartCallback>): void {
|
|
||||||
const { editorMode } = this.state;
|
|
||||||
const { editMode, onResizeStart } = this.props;
|
|
||||||
const isEditing = editorMode === 'edit';
|
|
||||||
onResizeStart(...args);
|
|
||||||
if (editMode && isEditing) {
|
|
||||||
this.updateMarkdownContent();
|
|
||||||
}
|
}
|
||||||
}
|
setEditorState(editorInstance);
|
||||||
|
}, []);
|
||||||
|
|
||||||
shouldFocusMarkdown(
|
const handleChangeEditorMode = useCallback(
|
||||||
event: MouseEvent,
|
(mode: 'edit' | 'preview'): void => {
|
||||||
container: HTMLElement | null,
|
if (mode === 'preview') {
|
||||||
menuRef: HTMLElement | null,
|
updateMarkdownContent();
|
||||||
): boolean {
|
setHasError(false);
|
||||||
if (container?.contains(event.target as Node)) return true;
|
}
|
||||||
if (menuRef?.contains(event.target as Node)) return true;
|
setEditorMode(mode);
|
||||||
|
},
|
||||||
|
[updateMarkdownContent],
|
||||||
|
);
|
||||||
|
|
||||||
return false;
|
const handleChangeFocus = useCallback(
|
||||||
}
|
(nextFocus: boolean | number): void => {
|
||||||
|
const nextFocused = !!nextFocus;
|
||||||
|
const nextEditMode: 'edit' | 'preview' = nextFocused ? 'edit' : 'preview';
|
||||||
|
setIsFocused(nextFocused);
|
||||||
|
handleChangeEditorMode(nextEditMode);
|
||||||
|
},
|
||||||
|
[handleChangeEditorMode],
|
||||||
|
);
|
||||||
|
|
||||||
renderEditMode(): JSX.Element {
|
const handleMarkdownChange = useCallback((nextValue: string): void => {
|
||||||
return (
|
setMarkdownSource(nextValue);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDeleteComponent = useCallback((): void => {
|
||||||
|
deleteComponent(id, parentId);
|
||||||
|
}, [deleteComponent, id, parentId]);
|
||||||
|
|
||||||
|
const handleResizeStart = useCallback(
|
||||||
|
(...args: Parameters<ResizeStartCallback>): void => {
|
||||||
|
const isEditing = editorMode === 'edit';
|
||||||
|
onResizeStart(...args);
|
||||||
|
if (editMode && isEditing) {
|
||||||
|
updateMarkdownContent();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[editorMode, editMode, onResizeStart, updateMarkdownContent],
|
||||||
|
);
|
||||||
|
|
||||||
|
const shouldFocusMarkdown = useCallback(
|
||||||
|
(
|
||||||
|
event: MouseEvent,
|
||||||
|
container: HTMLElement | null,
|
||||||
|
menuRef: HTMLElement | null,
|
||||||
|
): boolean => {
|
||||||
|
if (container?.contains(event.target as Node)) return true;
|
||||||
|
if (menuRef?.contains(event.target as Node)) return true;
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleRenderError = useCallback(
|
||||||
|
(_error: Error, _info: ErrorInfo): void => {
|
||||||
|
setHasError(true);
|
||||||
|
if (editorMode === 'preview') {
|
||||||
|
addDangerToast(
|
||||||
|
t(
|
||||||
|
'This markdown component has an error. Please revert your recent changes.',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[addDangerToast, editorMode],
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderEditMode = useMemo(
|
||||||
|
() => (
|
||||||
<EditorHost
|
<EditorHost
|
||||||
id={`markdown-editor-${this.props.id}`}
|
id={`markdown-editor-${id}`}
|
||||||
onChange={this.handleMarkdownChange}
|
onChange={handleMarkdownChange}
|
||||||
width="100%"
|
width="100%"
|
||||||
height="100%"
|
height="100%"
|
||||||
value={
|
value={
|
||||||
// this allows "select all => delete" to give an empty editor
|
// this allows "select all => delete" to give an empty editor
|
||||||
typeof this.state.markdownSource === 'string'
|
typeof markdownSource === 'string'
|
||||||
? this.state.markdownSource
|
? markdownSource
|
||||||
: MARKDOWN_PLACE_HOLDER
|
: MARKDOWN_PLACE_HOLDER
|
||||||
}
|
}
|
||||||
language="markdown"
|
language="markdown"
|
||||||
@@ -336,126 +334,116 @@ class Markdown extends PureComponent<MarkdownProps, MarkdownState> {
|
|||||||
onReady={(handle: EditorInstance) => {
|
onReady={(handle: EditorInstance) => {
|
||||||
// The handle provides access to the underlying editor for resize
|
// The handle provides access to the underlying editor for resize
|
||||||
if (handle && typeof handle.focus === 'function') {
|
if (handle && typeof handle.focus === 'function') {
|
||||||
this.setEditor(handle);
|
setEditor(handle);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
data-test="editor"
|
data-test="editor"
|
||||||
/>
|
/>
|
||||||
);
|
),
|
||||||
}
|
[id, markdownSource, handleMarkdownChange, setEditor],
|
||||||
|
);
|
||||||
|
|
||||||
renderPreviewMode(): JSX.Element {
|
const renderPreviewMode = useMemo(
|
||||||
const { hasError } = this.state;
|
() => (
|
||||||
|
|
||||||
return (
|
|
||||||
<SafeMarkdown
|
<SafeMarkdown
|
||||||
source={
|
source={
|
||||||
hasError
|
hasError
|
||||||
? MARKDOWN_ERROR_MESSAGE
|
? MARKDOWN_ERROR_MESSAGE
|
||||||
: this.state.markdownSource || MARKDOWN_PLACE_HOLDER
|
: markdownSource || MARKDOWN_PLACE_HOLDER
|
||||||
}
|
}
|
||||||
htmlSanitization={this.props.htmlSanitization}
|
htmlSanitization={htmlSanitization}
|
||||||
htmlSchemaOverrides={this.props.htmlSchemaOverrides}
|
htmlSchemaOverrides={htmlSchemaOverrides}
|
||||||
/>
|
/>
|
||||||
);
|
),
|
||||||
}
|
[hasError, markdownSource, htmlSanitization, htmlSchemaOverrides],
|
||||||
|
);
|
||||||
|
|
||||||
render() {
|
// inherit the size of parent columns
|
||||||
const { isFocused, editorMode } = this.state;
|
const widthMultiple =
|
||||||
|
parentComponent.type === COLUMN_TYPE
|
||||||
|
? parentComponent.meta.width || GRID_MIN_COLUMN_COUNT
|
||||||
|
: component.meta.width || GRID_MIN_COLUMN_COUNT;
|
||||||
|
|
||||||
const {
|
const isEditing = editorMode === 'edit';
|
||||||
component,
|
|
||||||
parentComponent,
|
|
||||||
index,
|
|
||||||
depth,
|
|
||||||
availableColumnCount,
|
|
||||||
columnWidth,
|
|
||||||
onResize,
|
|
||||||
onResizeStop,
|
|
||||||
handleComponentDrop,
|
|
||||||
editMode,
|
|
||||||
} = this.props;
|
|
||||||
|
|
||||||
// inherit the size of parent columns
|
const menuItems = useMemo(
|
||||||
const widthMultiple =
|
() => [
|
||||||
parentComponent.type === COLUMN_TYPE
|
<MarkdownModeDropdown
|
||||||
? parentComponent.meta.width || GRID_MIN_COLUMN_COUNT
|
key={`${component.id}-mode`}
|
||||||
: component.meta.width || GRID_MIN_COLUMN_COUNT;
|
id={`${component.id}-mode`}
|
||||||
|
value={editorMode}
|
||||||
|
onChange={handleChangeEditorMode}
|
||||||
|
/>,
|
||||||
|
],
|
||||||
|
[component.id, editorMode, handleChangeEditorMode],
|
||||||
|
);
|
||||||
|
|
||||||
const isEditing = editorMode === 'edit';
|
return (
|
||||||
|
<Draggable
|
||||||
return (
|
component={component}
|
||||||
<Draggable
|
parentComponent={parentComponent}
|
||||||
component={component}
|
orientation={parentComponent.type === ROW_TYPE ? 'column' : 'row'}
|
||||||
parentComponent={parentComponent}
|
index={index}
|
||||||
orientation={parentComponent.type === ROW_TYPE ? 'column' : 'row'}
|
depth={depth}
|
||||||
index={index}
|
onDrop={handleComponentDrop}
|
||||||
depth={depth}
|
disableDragDrop={isFocused}
|
||||||
onDrop={handleComponentDrop}
|
editMode={editMode}
|
||||||
disableDragDrop={isFocused}
|
>
|
||||||
editMode={editMode}
|
{({ dragSourceRef }: DragChildProps) => (
|
||||||
>
|
<WithPopoverMenu
|
||||||
{({ dragSourceRef }: DragChildProps) => (
|
onChangeFocus={handleChangeFocus}
|
||||||
<WithPopoverMenu
|
shouldFocus={shouldFocusMarkdown}
|
||||||
onChangeFocus={this.handleChangeFocus}
|
menuItems={menuItems}
|
||||||
shouldFocus={this.shouldFocusMarkdown}
|
editMode={editMode}
|
||||||
menuItems={[
|
>
|
||||||
<MarkdownModeDropdown
|
<MarkdownStyles
|
||||||
key={`${component.id}-mode`}
|
data-test="dashboard-markdown-editor"
|
||||||
id={`${component.id}-mode`}
|
className={cx(
|
||||||
value={this.state.editorMode}
|
'dashboard-markdown',
|
||||||
onChange={this.handleChangeEditorMode}
|
isEditing && 'dashboard-markdown--editing',
|
||||||
/>,
|
)}
|
||||||
]}
|
id={component.id}
|
||||||
editMode={editMode}
|
|
||||||
>
|
>
|
||||||
<MarkdownStyles
|
<ResizableContainer
|
||||||
data-test="dashboard-markdown-editor"
|
|
||||||
className={cx(
|
|
||||||
'dashboard-markdown',
|
|
||||||
isEditing && 'dashboard-markdown--editing',
|
|
||||||
)}
|
|
||||||
id={component.id}
|
id={component.id}
|
||||||
|
adjustableWidth={parentComponent.type === ROW_TYPE}
|
||||||
|
adjustableHeight
|
||||||
|
widthStep={columnWidth}
|
||||||
|
widthMultiple={widthMultiple}
|
||||||
|
heightStep={GRID_BASE_UNIT}
|
||||||
|
heightMultiple={component.meta.height ?? GRID_MIN_ROW_UNITS}
|
||||||
|
minWidthMultiple={GRID_MIN_COLUMN_COUNT}
|
||||||
|
minHeightMultiple={GRID_MIN_ROW_UNITS}
|
||||||
|
maxWidthMultiple={availableColumnCount + widthMultiple}
|
||||||
|
onResizeStart={handleResizeStart}
|
||||||
|
onResize={onResize}
|
||||||
|
onResizeStop={onResizeStop}
|
||||||
|
editMode={isFocused ? false : editMode}
|
||||||
>
|
>
|
||||||
<ResizableContainer
|
<div
|
||||||
id={component.id}
|
ref={dragSourceRef}
|
||||||
adjustableWidth={parentComponent.type === ROW_TYPE}
|
className="dashboard-component dashboard-component-chart-holder"
|
||||||
adjustableHeight
|
data-test="dashboard-component-chart-holder"
|
||||||
widthStep={columnWidth}
|
|
||||||
widthMultiple={widthMultiple}
|
|
||||||
heightStep={GRID_BASE_UNIT}
|
|
||||||
heightMultiple={component.meta.height ?? GRID_MIN_ROW_UNITS}
|
|
||||||
minWidthMultiple={GRID_MIN_COLUMN_COUNT}
|
|
||||||
minHeightMultiple={GRID_MIN_ROW_UNITS}
|
|
||||||
maxWidthMultiple={availableColumnCount + widthMultiple}
|
|
||||||
onResizeStart={this.handleResizeStart}
|
|
||||||
onResize={onResize}
|
|
||||||
onResizeStop={onResizeStop}
|
|
||||||
editMode={isFocused ? false : editMode}
|
|
||||||
>
|
>
|
||||||
<div
|
{editMode && (
|
||||||
ref={dragSourceRef}
|
<HoverMenu position="top">
|
||||||
className="dashboard-component dashboard-component-chart-holder"
|
<DeleteComponentButton onDelete={handleDeleteComponent} />
|
||||||
data-test="dashboard-component-chart-holder"
|
</HoverMenu>
|
||||||
|
)}
|
||||||
|
<ErrorBoundary
|
||||||
|
key={hasError ? 'markdown-error' : 'markdown-ok'}
|
||||||
|
onError={handleRenderError}
|
||||||
|
showMessage={false}
|
||||||
>
|
>
|
||||||
{editMode && (
|
{editMode && isEditing ? renderEditMode : renderPreviewMode}
|
||||||
<HoverMenu position="top">
|
</ErrorBoundary>
|
||||||
<DeleteComponentButton
|
</div>
|
||||||
onDelete={this.handleDeleteComponent}
|
</ResizableContainer>
|
||||||
/>
|
</MarkdownStyles>
|
||||||
</HoverMenu>
|
</WithPopoverMenu>
|
||||||
)}
|
)}
|
||||||
{editMode && isEditing
|
</Draggable>
|
||||||
? this.renderEditMode()
|
);
|
||||||
: this.renderPreviewMode()}
|
|
||||||
</div>
|
|
||||||
</ResizableContainer>
|
|
||||||
</MarkdownStyles>
|
|
||||||
</WithPopoverMenu>
|
|
||||||
)}
|
|
||||||
</Draggable>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ReduxState {
|
interface ReduxState {
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
* specific language governing permissions and limitations
|
* specific language governing permissions and limitations
|
||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
import { PureComponent } from 'react';
|
import { memo } from 'react';
|
||||||
import cx from 'classnames';
|
import cx from 'classnames';
|
||||||
import { css, styled } from '@apache-superset/core/theme';
|
import { css, styled } from '@apache-superset/core/theme';
|
||||||
import { DragDroppable } from 'src/dashboard/components/dnd/DragDroppable';
|
import { DragDroppable } from 'src/dashboard/components/dnd/DragDroppable';
|
||||||
@@ -62,37 +62,37 @@ const NewComponentPlaceholder = styled.div`
|
|||||||
`}
|
`}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export default class DraggableNewComponent extends PureComponent<DraggableNewComponentProps> {
|
function DraggableNewComponent({
|
||||||
static defaultProps = {
|
label,
|
||||||
className: null,
|
id,
|
||||||
IconComponent: undefined,
|
type,
|
||||||
};
|
className,
|
||||||
|
meta,
|
||||||
render() {
|
IconComponent,
|
||||||
const { label, id, type, className, meta, IconComponent } = this.props;
|
}: DraggableNewComponentProps) {
|
||||||
|
return (
|
||||||
return (
|
<DragDroppable
|
||||||
<DragDroppable
|
component={{ type, id, meta }}
|
||||||
component={{ type, id, meta }}
|
parentComponent={{
|
||||||
parentComponent={{
|
id: NEW_COMPONENTS_SOURCE_ID,
|
||||||
id: NEW_COMPONENTS_SOURCE_ID,
|
type: NEW_COMPONENT_SOURCE_TYPE,
|
||||||
type: NEW_COMPONENT_SOURCE_TYPE,
|
}}
|
||||||
}}
|
index={0}
|
||||||
index={0}
|
depth={0}
|
||||||
depth={0}
|
editMode
|
||||||
editMode
|
>
|
||||||
>
|
{({ dragSourceRef }: { dragSourceRef: ConnectDragSource }) => (
|
||||||
{({ dragSourceRef }: { dragSourceRef: ConnectDragSource }) => (
|
<NewComponent ref={dragSourceRef} data-test="new-component">
|
||||||
<NewComponent ref={dragSourceRef} data-test="new-component">
|
<NewComponentPlaceholder
|
||||||
<NewComponentPlaceholder
|
className={cx('new-component-placeholder', className)}
|
||||||
className={cx('new-component-placeholder', className)}
|
>
|
||||||
>
|
{IconComponent && <IconComponent iconSize="xl" />}
|
||||||
{IconComponent && <IconComponent iconSize="xl" />}
|
</NewComponentPlaceholder>
|
||||||
</NewComponentPlaceholder>
|
{label}
|
||||||
{label}
|
</NewComponent>
|
||||||
</NewComponent>
|
)}
|
||||||
)}
|
</DragDroppable>
|
||||||
</DragDroppable>
|
);
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default memo(DraggableNewComponent);
|
||||||
|
|||||||
@@ -16,11 +16,9 @@
|
|||||||
* specific language governing permissions and limitations
|
* specific language governing permissions and limitations
|
||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
import { PureComponent } from 'react';
|
|
||||||
import cx from 'classnames';
|
import cx from 'classnames';
|
||||||
import { t } from '@apache-superset/core/translation';
|
import { t } from '@apache-superset/core/translation';
|
||||||
import { css, styled } from '@apache-superset/core/theme';
|
import { css, styled } from '@apache-superset/core/theme';
|
||||||
|
|
||||||
import backgroundStyleOptions from 'src/dashboard/util/backgroundStyleOptions';
|
import backgroundStyleOptions from 'src/dashboard/util/backgroundStyleOptions';
|
||||||
import PopoverDropdown, {
|
import PopoverDropdown, {
|
||||||
OptionProps,
|
OptionProps,
|
||||||
@@ -90,18 +88,19 @@ function renderOption(option: OptionProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default class BackgroundStyleDropdown extends PureComponent<BackgroundStyleDropdownProps> {
|
export default function BackgroundStyleDropdown({
|
||||||
render() {
|
id,
|
||||||
const { id, value, onChange } = this.props;
|
value,
|
||||||
return (
|
onChange,
|
||||||
<PopoverDropdown
|
}: BackgroundStyleDropdownProps) {
|
||||||
id={id}
|
return (
|
||||||
options={backgroundStyleOptions}
|
<PopoverDropdown
|
||||||
value={value}
|
id={id}
|
||||||
onChange={onChange}
|
options={backgroundStyleOptions}
|
||||||
renderButton={renderButton}
|
value={value}
|
||||||
renderOption={renderOption}
|
onChange={onChange}
|
||||||
/>
|
renderButton={renderButton}
|
||||||
);
|
renderOption={renderOption}
|
||||||
}
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
/* eslint-disable react/no-unused-state */
|
|
||||||
/**
|
/**
|
||||||
* Licensed to the Apache Software Foundation (ASF) under one
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
* or more contributor license agreements. See the NOTICE file
|
* or more contributor license agreements. See the NOTICE file
|
||||||
@@ -17,15 +16,15 @@
|
|||||||
* specific language governing permissions and limitations
|
* specific language governing permissions and limitations
|
||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
import { RefObject, ReactNode, PureComponent } from 'react';
|
import { RefObject, ReactNode, useCallback, memo } from 'react';
|
||||||
|
|
||||||
import { styled } from '@apache-superset/core/theme';
|
import { styled } from '@apache-superset/core/theme';
|
||||||
import cx from 'classnames';
|
import cx from 'classnames';
|
||||||
|
|
||||||
interface HoverMenuProps {
|
interface HoverMenuProps {
|
||||||
position: 'left' | 'top';
|
position?: 'left' | 'top';
|
||||||
innerRef: RefObject<HTMLDivElement>;
|
innerRef?: RefObject<HTMLDivElement> | null;
|
||||||
children: ReactNode;
|
children?: ReactNode;
|
||||||
onHover?: (data: { isHovered: boolean }) => void;
|
onHover?: (data: { isHovered: boolean }) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,45 +65,41 @@ const HoverStyleOverrides = styled.div`
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export default class HoverMenu extends PureComponent<HoverMenuProps> {
|
function HoverMenu({
|
||||||
static defaultProps = {
|
position = 'left',
|
||||||
position: 'left',
|
innerRef = null,
|
||||||
innerRef: null,
|
children = null,
|
||||||
children: null,
|
onHover,
|
||||||
};
|
}: HoverMenuProps) {
|
||||||
|
const handleMouseEnter = useCallback(() => {
|
||||||
handleMouseEnter = () => {
|
|
||||||
const { onHover } = this.props;
|
|
||||||
if (onHover) {
|
if (onHover) {
|
||||||
onHover({ isHovered: true });
|
onHover({ isHovered: true });
|
||||||
}
|
}
|
||||||
};
|
}, [onHover]);
|
||||||
|
|
||||||
handleMouseLeave = () => {
|
const handleMouseLeave = useCallback(() => {
|
||||||
const { onHover } = this.props;
|
|
||||||
if (onHover) {
|
if (onHover) {
|
||||||
onHover({ isHovered: false });
|
onHover({ isHovered: false });
|
||||||
}
|
}
|
||||||
};
|
}, [onHover]);
|
||||||
|
|
||||||
render() {
|
return (
|
||||||
const { innerRef, position, children } = this.props;
|
<HoverStyleOverrides className="hover-menu-container">
|
||||||
return (
|
<div
|
||||||
<HoverStyleOverrides className="hover-menu-container">
|
ref={innerRef}
|
||||||
<div
|
className={cx(
|
||||||
ref={innerRef}
|
'hover-menu',
|
||||||
className={cx(
|
position === 'left' && 'hover-menu--left',
|
||||||
'hover-menu',
|
position === 'top' && 'hover-menu--top',
|
||||||
position === 'left' && 'hover-menu--left',
|
)}
|
||||||
position === 'top' && 'hover-menu--top',
|
onMouseEnter={handleMouseEnter}
|
||||||
)}
|
onMouseLeave={handleMouseLeave}
|
||||||
onMouseEnter={this.handleMouseEnter}
|
data-test="hover-menu"
|
||||||
onMouseLeave={this.handleMouseLeave}
|
>
|
||||||
data-test="hover-menu"
|
{children}
|
||||||
>
|
</div>
|
||||||
{children}
|
</HoverStyleOverrides>
|
||||||
</div>
|
);
|
||||||
</HoverStyleOverrides>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default memo(HoverMenu);
|
||||||
|
|||||||
@@ -16,9 +16,7 @@
|
|||||||
* specific language governing permissions and limitations
|
* specific language governing permissions and limitations
|
||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
import { PureComponent } from 'react';
|
|
||||||
import { t } from '@apache-superset/core/translation';
|
import { t } from '@apache-superset/core/translation';
|
||||||
|
|
||||||
import PopoverDropdown, {
|
import PopoverDropdown, {
|
||||||
OnChangeHandler,
|
OnChangeHandler,
|
||||||
} from '@superset-ui/core/components/PopoverDropdown';
|
} from '@superset-ui/core/components/PopoverDropdown';
|
||||||
@@ -40,18 +38,18 @@ const dropdownOptions = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export default class MarkdownModeDropdown extends PureComponent<MarkdownModeDropdownProps> {
|
export default function MarkdownModeDropdown({
|
||||||
render() {
|
id,
|
||||||
const { id, value, onChange } = this.props;
|
value,
|
||||||
|
onChange,
|
||||||
return (
|
}: MarkdownModeDropdownProps) {
|
||||||
<PopoverDropdown
|
return (
|
||||||
data-test="markdown-mode-dropdown"
|
<PopoverDropdown
|
||||||
id={id}
|
data-test="markdown-mode-dropdown"
|
||||||
options={dropdownOptions}
|
id={id}
|
||||||
value={value}
|
options={dropdownOptions}
|
||||||
onChange={onChange}
|
value={value}
|
||||||
/>
|
onChange={onChange}
|
||||||
);
|
/>
|
||||||
}
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,7 +106,9 @@ test('should unfocus when another component is clicked', async () => {
|
|||||||
<WithPopoverMenu
|
<WithPopoverMenu
|
||||||
{...props}
|
{...props}
|
||||||
editMode
|
editMode
|
||||||
shouldFocus={(event, container) => container?.contains(event.target)}
|
shouldFocus={(event, container, _menuRef) =>
|
||||||
|
container?.contains(event.target) ?? false
|
||||||
|
}
|
||||||
onChangeFocus={onChangeFocusA}
|
onChangeFocus={onChangeFocusA}
|
||||||
>
|
>
|
||||||
<div id="child-a" />
|
<div id="child-a" />
|
||||||
@@ -117,7 +119,9 @@ test('should unfocus when another component is clicked', async () => {
|
|||||||
<WithPopoverMenu
|
<WithPopoverMenu
|
||||||
{...props}
|
{...props}
|
||||||
editMode
|
editMode
|
||||||
shouldFocus={(event, container) => container?.contains(event.target)}
|
shouldFocus={(event, container, _menuRef) =>
|
||||||
|
container?.contains(event.target) ?? false
|
||||||
|
}
|
||||||
onChangeFocus={onChangeFocusB}
|
onChangeFocus={onChangeFocusB}
|
||||||
>
|
>
|
||||||
<div id="child-b" />
|
<div id="child-b" />
|
||||||
|
|||||||
@@ -16,7 +16,15 @@
|
|||||||
* specific language governing permissions and limitations
|
* specific language governing permissions and limitations
|
||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
import { ReactNode, CSSProperties, PureComponent } from 'react';
|
import {
|
||||||
|
ReactNode,
|
||||||
|
CSSProperties,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
memo,
|
||||||
|
} from 'react';
|
||||||
import cx from 'classnames';
|
import cx from 'classnames';
|
||||||
import { addAlpha } from '@superset-ui/core';
|
import { addAlpha } from '@superset-ui/core';
|
||||||
import { css, styled } from '@apache-superset/core/theme';
|
import { css, styled } from '@apache-superset/core/theme';
|
||||||
@@ -26,26 +34,32 @@ type ShouldFocusContainer = HTMLDivElement & {
|
|||||||
};
|
};
|
||||||
|
|
||||||
interface WithPopoverMenuProps {
|
interface WithPopoverMenuProps {
|
||||||
children: ReactNode;
|
children?: ReactNode;
|
||||||
disableClick: boolean;
|
disableClick?: boolean;
|
||||||
menuItems: ReactNode[];
|
menuItems?: ReactNode[];
|
||||||
onChangeFocus: (focus: boolean) => void;
|
onChangeFocus?: ((focus: boolean) => void) | null;
|
||||||
isFocused: boolean;
|
isFocused?: boolean;
|
||||||
// Event argument is left as "any" because of the clash. In defaultProps it seems
|
// Event argument is left as "any" because of the clash. In props it seems
|
||||||
// like it should be React.FocusEvent<>, however from handleClick() we can also
|
// like it should be React.FocusEvent<>, however from handleClick() we can also
|
||||||
// derive that type is EventListenerOrEventListenerObject.
|
// derive that type is EventListenerOrEventListenerObject.
|
||||||
shouldFocus: (
|
shouldFocus?: (
|
||||||
event: any,
|
event: any,
|
||||||
container: ShouldFocusContainer,
|
container: ShouldFocusContainer | null,
|
||||||
menuRef: HTMLDivElement | null,
|
menuRef: HTMLDivElement | null,
|
||||||
) => boolean;
|
) => boolean;
|
||||||
editMode: boolean;
|
editMode?: boolean;
|
||||||
style: CSSProperties;
|
style?: CSSProperties | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WithPopoverMenuState {
|
const defaultShouldFocus = (
|
||||||
isFocused: boolean;
|
event: any,
|
||||||
}
|
container: ShouldFocusContainer | null,
|
||||||
|
menuRef: HTMLDivElement | null,
|
||||||
|
): boolean => {
|
||||||
|
if (container?.contains(event.target)) return true;
|
||||||
|
if (menuRef?.contains(event.target)) return true;
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
const WithPopoverMenuStyles = styled.div`
|
const WithPopoverMenuStyles = styled.div`
|
||||||
${({ theme }) => css`
|
${({ theme }) => css`
|
||||||
@@ -104,151 +118,114 @@ const PopoverMenuStyles = styled.div`
|
|||||||
`}
|
`}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export default class WithPopoverMenu extends PureComponent<
|
function WithPopoverMenu({
|
||||||
WithPopoverMenuProps,
|
children = null,
|
||||||
WithPopoverMenuState
|
disableClick = false,
|
||||||
> {
|
menuItems = [],
|
||||||
container: ShouldFocusContainer;
|
onChangeFocus = null,
|
||||||
|
isFocused: isFocusedProp = false,
|
||||||
|
shouldFocus: shouldFocusFunc = defaultShouldFocus,
|
||||||
|
editMode = false,
|
||||||
|
style = null,
|
||||||
|
}: WithPopoverMenuProps) {
|
||||||
|
const [isFocused, setIsFocused] = useState(isFocusedProp);
|
||||||
|
const containerRef = useRef<ShouldFocusContainer | null>(null);
|
||||||
|
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
// Tracks the native event that just triggered focus via the container's
|
||||||
|
// onClick so the document-level listener (registered once focused) can
|
||||||
|
// skip it. Without this, the same click bubbles to document after a
|
||||||
|
// re-render has detached its event.target, causing shouldFocus to return
|
||||||
|
// false and immediately undoing the focus.
|
||||||
|
const focusEventRef = useRef<Event | null>(null);
|
||||||
|
|
||||||
menuRef: HTMLDivElement | null;
|
const handleClick = useCallback(
|
||||||
|
(event: any) => {
|
||||||
|
if (!editMode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
focusEvent: Event | null;
|
const nativeEvent = event.nativeEvent || event;
|
||||||
|
if (focusEventRef.current === nativeEvent) {
|
||||||
|
focusEventRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
static defaultProps = {
|
const shouldFocusResult = shouldFocusFunc(
|
||||||
children: null,
|
event,
|
||||||
disableClick: false,
|
containerRef.current,
|
||||||
onChangeFocus: null,
|
menuRef.current,
|
||||||
menuItems: [],
|
);
|
||||||
isFocused: false,
|
|
||||||
shouldFocus: (
|
if (shouldFocusResult === isFocused) return;
|
||||||
event: any,
|
|
||||||
container: ShouldFocusContainer,
|
if (!disableClick && shouldFocusResult && !isFocused) {
|
||||||
menuRef: HTMLDivElement | null,
|
focusEventRef.current = nativeEvent;
|
||||||
) => {
|
setIsFocused(true);
|
||||||
if (container?.contains(event.target)) return true;
|
if (onChangeFocus) onChangeFocus(true);
|
||||||
if (menuRef?.contains(event.target)) return true;
|
} else if (!shouldFocusResult && isFocused) {
|
||||||
return false;
|
setIsFocused(false);
|
||||||
|
if (onChangeFocus) onChangeFocus(false);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
style: null,
|
[editMode, shouldFocusFunc, isFocused, disableClick, onChangeFocus],
|
||||||
};
|
);
|
||||||
|
|
||||||
constructor(props: WithPopoverMenuProps) {
|
// Keep the latest handleClick in a ref so the document listeners can be
|
||||||
super(props);
|
// registered via a stable wrapper. This keeps the listener effect dependent
|
||||||
this.state = {
|
// only on focus/editMode transitions, instead of thrashing (remove + re-add)
|
||||||
isFocused: props.isFocused!,
|
// every time handleClick's identity changes.
|
||||||
};
|
const handleClickRef = useRef(handleClick);
|
||||||
this.menuRef = null;
|
useEffect(() => {
|
||||||
this.focusEvent = null;
|
handleClickRef.current = handleClick;
|
||||||
this.setRef = this.setRef.bind(this);
|
}, [handleClick]);
|
||||||
this.setMenuRef = this.setMenuRef.bind(this);
|
|
||||||
this.handleClick = this.handleClick.bind(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidUpdate(prevProps: WithPopoverMenuProps) {
|
// Handle prop-driven focus changes and add/remove document listeners
|
||||||
if (this.props.editMode && this.props.isFocused && !this.state.isFocused) {
|
useEffect(() => {
|
||||||
document.addEventListener('click', this.handleClick);
|
if (editMode && isFocusedProp && !isFocused) {
|
||||||
document.addEventListener('drag', this.handleClick);
|
setIsFocused(true);
|
||||||
this.setState({ isFocused: true });
|
} else if (isFocused && !editMode) {
|
||||||
} else if (this.state.isFocused && !this.props.editMode) {
|
setIsFocused(false);
|
||||||
document.removeEventListener('click', this.handleClick);
|
|
||||||
document.removeEventListener('drag', this.handleClick);
|
|
||||||
this.setState({ isFocused: false });
|
|
||||||
}
|
}
|
||||||
}
|
}, [editMode, isFocusedProp, isFocused]);
|
||||||
|
|
||||||
componentWillUnmount() {
|
// Add/remove document event listeners only on focus/editMode transitions.
|
||||||
document.removeEventListener('click', this.handleClick);
|
useEffect(() => {
|
||||||
document.removeEventListener('drag', this.handleClick);
|
if (isFocused && editMode) {
|
||||||
}
|
const listener = (event: Event) => handleClickRef.current(event);
|
||||||
|
document.addEventListener('click', listener);
|
||||||
|
document.addEventListener('drag', listener);
|
||||||
|
|
||||||
setRef(ref: ShouldFocusContainer) {
|
return () => {
|
||||||
this.container = ref;
|
document.removeEventListener('click', listener);
|
||||||
}
|
document.removeEventListener('drag', listener);
|
||||||
|
};
|
||||||
setMenuRef(ref: HTMLDivElement | null) {
|
|
||||||
this.menuRef = ref;
|
|
||||||
}
|
|
||||||
|
|
||||||
shouldHandleFocusChange(shouldFocus: boolean): boolean {
|
|
||||||
const { disableClick } = this.props;
|
|
||||||
const { isFocused } = this.state;
|
|
||||||
|
|
||||||
return (
|
|
||||||
(!disableClick && shouldFocus && !isFocused) ||
|
|
||||||
(!shouldFocus && isFocused)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleClick(event: any) {
|
|
||||||
if (!this.props.editMode) {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
return undefined;
|
||||||
|
}, [isFocused, editMode]);
|
||||||
|
|
||||||
// Skip if this is the same event that just triggered focus via onClick.
|
return (
|
||||||
// The document-level listener registered during focus will see the same
|
<WithPopoverMenuStyles
|
||||||
// event bubble up; by that time a re-render may have detached the
|
ref={containerRef}
|
||||||
// original event.target, causing shouldFocus to return false and
|
onClick={handleClick}
|
||||||
// immediately undoing the focus.
|
role="none"
|
||||||
const nativeEvent = event.nativeEvent || event;
|
className={cx(
|
||||||
if (this.focusEvent === nativeEvent) {
|
'with-popover-menu',
|
||||||
this.focusEvent = null;
|
editMode && isFocused && 'with-popover-menu--focused',
|
||||||
return;
|
)}
|
||||||
}
|
style={style ?? undefined}
|
||||||
|
>
|
||||||
const {
|
{children}
|
||||||
onChangeFocus,
|
{editMode && isFocused && menuItems?.some(Boolean) && (
|
||||||
shouldFocus: shouldFocusFunc,
|
<PopoverMenuStyles ref={menuRef}>
|
||||||
disableClick,
|
{menuItems.map((node: ReactNode, i: number) => (
|
||||||
} = this.props;
|
<div className="menu-item" key={`menu-item-${i}`}>
|
||||||
|
{node}
|
||||||
const shouldFocus = shouldFocusFunc(event, this.container, this.menuRef);
|
</div>
|
||||||
|
))}
|
||||||
if (shouldFocus === this.state.isFocused) return;
|
</PopoverMenuStyles>
|
||||||
|
)}
|
||||||
if (!disableClick && shouldFocus && !this.state.isFocused) {
|
</WithPopoverMenuStyles>
|
||||||
document.addEventListener('click', this.handleClick);
|
);
|
||||||
document.addEventListener('drag', this.handleClick);
|
|
||||||
this.focusEvent = event.nativeEvent || event;
|
|
||||||
|
|
||||||
this.setState(() => ({ isFocused: true }));
|
|
||||||
|
|
||||||
if (onChangeFocus) onChangeFocus(true);
|
|
||||||
} else if (!shouldFocus && this.state.isFocused) {
|
|
||||||
document.removeEventListener('click', this.handleClick);
|
|
||||||
document.removeEventListener('drag', this.handleClick);
|
|
||||||
|
|
||||||
this.setState(() => ({ isFocused: false }));
|
|
||||||
|
|
||||||
if (onChangeFocus) onChangeFocus(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { children, menuItems, editMode, style } = this.props;
|
|
||||||
const { isFocused } = this.state;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<WithPopoverMenuStyles
|
|
||||||
ref={this.setRef}
|
|
||||||
onClick={this.handleClick}
|
|
||||||
role="none"
|
|
||||||
className={cx(
|
|
||||||
'with-popover-menu',
|
|
||||||
editMode && isFocused && 'with-popover-menu--focused',
|
|
||||||
)}
|
|
||||||
style={style}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
{editMode && isFocused && (menuItems?.length ?? 0) > 0 && (
|
|
||||||
<PopoverMenuStyles ref={this.setMenuRef}>
|
|
||||||
{menuItems.map((node: ReactNode, i: number) => (
|
|
||||||
<div className="menu-item" key={`menu-item-${i}`}>
|
|
||||||
{node}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</PopoverMenuStyles>
|
|
||||||
)}
|
|
||||||
</WithPopoverMenuStyles>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default memo(WithPopoverMenu);
|
||||||
|
|||||||
@@ -1,148 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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 { ReactNode, useState, useCallback } from 'react';
|
|
||||||
import type { FormInstance } from '@superset-ui/core/components';
|
|
||||||
import { ErrorBoundary } from 'src/components/ErrorBoundary';
|
|
||||||
import { BaseModalBody, BaseForm, BaseModalWrapper } from './SharedStyles';
|
|
||||||
import { ModalFooter } from './ModalFooter';
|
|
||||||
|
|
||||||
export interface BaseConfigModalProps {
|
|
||||||
isOpen: boolean;
|
|
||||||
title: string;
|
|
||||||
expanded?: boolean;
|
|
||||||
onCancel: () => void;
|
|
||||||
onSave: () => void;
|
|
||||||
leftPane: ReactNode;
|
|
||||||
rightPane: ReactNode;
|
|
||||||
footer?: ReactNode;
|
|
||||||
form?: FormInstance;
|
|
||||||
onValuesChange?: (changedValues: any, allValues: any) => void;
|
|
||||||
canSave?: boolean;
|
|
||||||
saveAlertVisible?: boolean;
|
|
||||||
onDismissSaveAlert?: () => void;
|
|
||||||
onConfirmCancel?: () => void;
|
|
||||||
onToggleExpand?: () => void;
|
|
||||||
testId?: string;
|
|
||||||
maskClosable?: boolean;
|
|
||||||
destroyOnClose?: boolean;
|
|
||||||
centered?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const BaseConfigModal = ({
|
|
||||||
isOpen,
|
|
||||||
title,
|
|
||||||
expanded = false,
|
|
||||||
onCancel,
|
|
||||||
onSave,
|
|
||||||
leftPane,
|
|
||||||
rightPane,
|
|
||||||
footer,
|
|
||||||
form,
|
|
||||||
onValuesChange,
|
|
||||||
canSave = true,
|
|
||||||
saveAlertVisible = false,
|
|
||||||
onDismissSaveAlert,
|
|
||||||
onConfirmCancel,
|
|
||||||
onToggleExpand,
|
|
||||||
testId = 'base-config-modal',
|
|
||||||
maskClosable = false,
|
|
||||||
destroyOnClose = true,
|
|
||||||
centered = true,
|
|
||||||
}: BaseConfigModalProps) => {
|
|
||||||
const [internalExpanded, setInternalExpanded] = useState(false);
|
|
||||||
|
|
||||||
const isExpandedControlled = onToggleExpand !== undefined;
|
|
||||||
const isExpanded = isExpandedControlled ? expanded : internalExpanded;
|
|
||||||
|
|
||||||
const handleToggleExpand = useCallback(() => {
|
|
||||||
if (isExpandedControlled && onToggleExpand) {
|
|
||||||
onToggleExpand();
|
|
||||||
} else {
|
|
||||||
setInternalExpanded(!internalExpanded);
|
|
||||||
}
|
|
||||||
}, [isExpandedControlled, onToggleExpand, internalExpanded]);
|
|
||||||
|
|
||||||
const handleCancel = useCallback(() => {
|
|
||||||
onCancel();
|
|
||||||
}, [onCancel]);
|
|
||||||
|
|
||||||
const handleSave = useCallback(() => {
|
|
||||||
onSave();
|
|
||||||
}, [onSave]);
|
|
||||||
|
|
||||||
const handleDismissSaveAlert = useCallback(() => {
|
|
||||||
if (onDismissSaveAlert) {
|
|
||||||
onDismissSaveAlert();
|
|
||||||
}
|
|
||||||
}, [onDismissSaveAlert]);
|
|
||||||
|
|
||||||
const handleConfirmCancel = useCallback(() => {
|
|
||||||
if (onConfirmCancel) {
|
|
||||||
onConfirmCancel();
|
|
||||||
} else {
|
|
||||||
onCancel();
|
|
||||||
}
|
|
||||||
}, [onConfirmCancel, onCancel]);
|
|
||||||
|
|
||||||
const defaultFooter = (
|
|
||||||
<ModalFooter
|
|
||||||
onCancel={handleCancel}
|
|
||||||
onSave={handleSave}
|
|
||||||
onConfirmCancel={handleConfirmCancel}
|
|
||||||
onDismiss={handleDismissSaveAlert}
|
|
||||||
saveAlertVisible={saveAlertVisible}
|
|
||||||
canSave={canSave}
|
|
||||||
expanded={isExpanded}
|
|
||||||
onToggleExpand={handleToggleExpand}
|
|
||||||
saveButtonTestId={`${testId}-save-button`}
|
|
||||||
cancelButtonTestId={`${testId}-cancel-button`}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<BaseModalWrapper
|
|
||||||
open={isOpen}
|
|
||||||
onCancel={handleCancel}
|
|
||||||
onOk={handleSave}
|
|
||||||
title={title}
|
|
||||||
footer={footer || defaultFooter}
|
|
||||||
centered={centered}
|
|
||||||
destroyOnClose={destroyOnClose}
|
|
||||||
maskClosable={maskClosable}
|
|
||||||
data-test={testId}
|
|
||||||
expanded={isExpanded}
|
|
||||||
>
|
|
||||||
<ErrorBoundary>
|
|
||||||
<BaseModalBody expanded={isExpanded}>
|
|
||||||
<BaseForm
|
|
||||||
form={form}
|
|
||||||
onValuesChange={onValuesChange}
|
|
||||||
layout="vertical"
|
|
||||||
css={{ width: '100%' }}
|
|
||||||
>
|
|
||||||
{leftPane}
|
|
||||||
{rightPane}
|
|
||||||
</BaseForm>
|
|
||||||
</BaseModalBody>
|
|
||||||
</ErrorBoundary>
|
|
||||||
</BaseModalWrapper>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default BaseConfigModal;
|
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
/**
|
||||||
|
* 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 { render, screen, waitFor } from 'spec/helpers/testing-library';
|
||||||
|
import { Provider } from 'react-redux';
|
||||||
|
import configureStore from 'redux-mock-store';
|
||||||
|
import thunk from 'redux-thunk';
|
||||||
|
import { NativeFilterType } from '@superset-ui/core';
|
||||||
|
import type { Filter } from '@superset-ui/core';
|
||||||
|
import FilterValue from './FilterValue';
|
||||||
|
|
||||||
|
const mockGetChartDataRequest = jest.fn();
|
||||||
|
jest.mock('src/components/Chart/chartAction', () => ({
|
||||||
|
getChartDataRequest: (...args: unknown[]) => mockGetChartDataRequest(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('src/middleware/asyncEvent', () => ({
|
||||||
|
waitForAsyncData: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('@superset-ui/core', () => {
|
||||||
|
const original = jest.requireActual('@superset-ui/core');
|
||||||
|
return {
|
||||||
|
...original,
|
||||||
|
getChartMetadataRegistry: () => ({
|
||||||
|
get: () => ({ enableNoResults: false }),
|
||||||
|
}),
|
||||||
|
SuperChart: (props: Record<string, unknown>) => (
|
||||||
|
<div data-test="mock-super-chart" data-chart-type={props.chartType}>
|
||||||
|
SuperChart
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
isFeatureEnabled: () => false,
|
||||||
|
getClientErrorObject: (_err: unknown) =>
|
||||||
|
Promise.resolve({
|
||||||
|
message: 'Something went wrong',
|
||||||
|
errors: [
|
||||||
|
{ message: 'Test error', error_type: 'GENERIC_BACKEND_ERROR' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.mock('../useFilterOutlined', () => ({
|
||||||
|
useFilterOutlined: () => ({
|
||||||
|
outlinedFilterId: undefined,
|
||||||
|
lastUpdated: 0,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockUseFilterDependencies = jest.fn().mockReturnValue({});
|
||||||
|
const mockUseTransitiveParentIds = jest.fn().mockReturnValue([]);
|
||||||
|
jest.mock('./state', () => ({
|
||||||
|
useFilterDependencies: (...args: unknown[]) =>
|
||||||
|
mockUseFilterDependencies(...args),
|
||||||
|
useTransitiveParentIds: (...args: unknown[]) =>
|
||||||
|
mockUseTransitiveParentIds(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockStore = configureStore([thunk]);
|
||||||
|
|
||||||
|
const createMockFilter = (overrides: Partial<Filter> = {}): Filter => ({
|
||||||
|
id: 'NATIVE_FILTER-1',
|
||||||
|
name: 'Test Filter',
|
||||||
|
filterType: 'filter_select',
|
||||||
|
targets: [{ datasetId: 1, column: { name: 'country' } }],
|
||||||
|
defaultDataMask: {},
|
||||||
|
controlValues: {},
|
||||||
|
cascadeParentIds: [],
|
||||||
|
scope: { rootPath: ['ROOT_ID'], excluded: [] },
|
||||||
|
type: NativeFilterType.NativeFilter,
|
||||||
|
description: 'Test filter description',
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
const getDefaultStoreState = () => ({
|
||||||
|
dashboardInfo: { id: 1 },
|
||||||
|
dashboardState: {
|
||||||
|
isRefreshing: false,
|
||||||
|
isFiltersRefreshing: false,
|
||||||
|
directPathToChild: [],
|
||||||
|
directPathLastUpdated: 0,
|
||||||
|
},
|
||||||
|
nativeFilters: {
|
||||||
|
filters: {
|
||||||
|
'NATIVE_FILTER-1': createMockFilter(),
|
||||||
|
},
|
||||||
|
filterSets: {},
|
||||||
|
},
|
||||||
|
dataMask: {},
|
||||||
|
charts: {},
|
||||||
|
dashboardLayout: { present: {} },
|
||||||
|
});
|
||||||
|
|
||||||
|
const defaultProps = {
|
||||||
|
filter: createMockFilter(),
|
||||||
|
dataMaskSelected: {},
|
||||||
|
onFilterSelectionChange: jest.fn(),
|
||||||
|
inView: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
function renderFilterValue(
|
||||||
|
propOverrides: Record<string, unknown> = {},
|
||||||
|
stateOverrides: Record<string, unknown> = {},
|
||||||
|
) {
|
||||||
|
const state = { ...getDefaultStoreState(), ...stateOverrides };
|
||||||
|
const store = mockStore(state);
|
||||||
|
const mergedProps = { ...defaultProps, ...propOverrides };
|
||||||
|
return render(
|
||||||
|
<Provider store={store}>
|
||||||
|
<FilterValue {...(mergedProps as typeof defaultProps)} />
|
||||||
|
</Provider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders loading spinner when filter has a data source', () => {
|
||||||
|
mockGetChartDataRequest.mockReturnValue(new Promise(() => {}));
|
||||||
|
|
||||||
|
renderFilterValue();
|
||||||
|
|
||||||
|
expect(screen.getByRole('status')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId('mock-super-chart')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders SuperChart after data loads successfully', async () => {
|
||||||
|
mockGetChartDataRequest.mockResolvedValue({
|
||||||
|
response: { status: 200 },
|
||||||
|
json: { result: [{ data: [{ country: 'US' }] }] },
|
||||||
|
});
|
||||||
|
|
||||||
|
renderFilterValue();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('mock-super-chart')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.queryByRole('status')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders error state when API call fails', async () => {
|
||||||
|
mockGetChartDataRequest.mockRejectedValue(
|
||||||
|
new Response(JSON.stringify({ message: 'Server Error' }), { status: 500 }),
|
||||||
|
);
|
||||||
|
|
||||||
|
renderFilterValue();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByRole('status')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// No ErrorMessageComponent is registered for GENERIC_BACKEND_ERROR in the
|
||||||
|
// test environment, so FilterValue renders its fallback ErrorAlert.
|
||||||
|
expect(await screen.findByText('Network error')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not fetch data when filter has not been in view', () => {
|
||||||
|
renderFilterValue({ inView: false });
|
||||||
|
|
||||||
|
expect(mockGetChartDataRequest).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not render loading spinner when filter has no data source', () => {
|
||||||
|
const filterWithoutDataSource = createMockFilter({
|
||||||
|
targets: [{ column: { name: 'country' } }],
|
||||||
|
});
|
||||||
|
mockGetChartDataRequest.mockReturnValue(new Promise(() => {}));
|
||||||
|
|
||||||
|
renderFilterValue({ filter: filterWithoutDataSource });
|
||||||
|
|
||||||
|
expect(screen.queryByRole('status')).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('mock-super-chart')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('skips data fetch when cascade parent filters have no values selected', () => {
|
||||||
|
// useFilterDependencies returns dependencies with a filter (from parent defaults),
|
||||||
|
// but dataMaskSelected has no extraFormData for the parent -- counts disagree, so
|
||||||
|
// the component skips the fetch.
|
||||||
|
mockUseFilterDependencies.mockReturnValue({
|
||||||
|
filters: [{ col: 'region', op: 'IN', val: ['US'] }],
|
||||||
|
});
|
||||||
|
mockUseTransitiveParentIds.mockReturnValue(['NATIVE_FILTER-PARENT']);
|
||||||
|
|
||||||
|
const childFilter = createMockFilter({
|
||||||
|
id: 'NATIVE_FILTER-CHILD',
|
||||||
|
cascadeParentIds: ['NATIVE_FILTER-PARENT'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const stateWithParent = {
|
||||||
|
nativeFilters: {
|
||||||
|
filters: {
|
||||||
|
'NATIVE_FILTER-CHILD': childFilter,
|
||||||
|
'NATIVE_FILTER-PARENT': createMockFilter({
|
||||||
|
id: 'NATIVE_FILTER-PARENT',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
filterSets: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
renderFilterValue(
|
||||||
|
{
|
||||||
|
filter: childFilter,
|
||||||
|
dataMaskSelected: {},
|
||||||
|
},
|
||||||
|
stateWithParent,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockGetChartDataRequest).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
@@ -41,7 +41,8 @@ import {
|
|||||||
getClientErrorObject,
|
getClientErrorObject,
|
||||||
isChartCustomization,
|
isChartCustomization,
|
||||||
} from '@superset-ui/core';
|
} from '@superset-ui/core';
|
||||||
import { styled } from '@apache-superset/core/theme';
|
import { styled, SupersetTheme } from '@apache-superset/core/theme';
|
||||||
|
import { useTheme } from '@emotion/react';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
import { isEqual, isEqualWith } from 'lodash';
|
import { isEqual, isEqualWith } from 'lodash';
|
||||||
import { getChartDataRequest } from 'src/components/Chart/chartAction';
|
import { getChartDataRequest } from 'src/components/Chart/chartAction';
|
||||||
@@ -141,6 +142,7 @@ const FilterValue: FC<FilterValueProps> = ({
|
|||||||
clearAllTrigger,
|
clearAllTrigger,
|
||||||
onClearAllComplete,
|
onClearAllComplete,
|
||||||
}) => {
|
}) => {
|
||||||
|
const theme = useTheme() as SupersetTheme;
|
||||||
const { id, targets, filterType } = filter;
|
const { id, targets, filterType } = filter;
|
||||||
const isCustomization = isChartCustomization(filter);
|
const isCustomization = isChartCustomization(filter);
|
||||||
const allowedTimeGrains = isCustomization
|
const allowedTimeGrains = isCustomization
|
||||||
@@ -487,6 +489,7 @@ const FilterValue: FC<FilterValueProps> = ({
|
|||||||
enableNoResults={metadata?.enableNoResults}
|
enableNoResults={metadata?.enableNoResults}
|
||||||
isRefreshing={isRefreshing}
|
isRefreshing={isRefreshing}
|
||||||
hooks={hooks}
|
hooks={hooks}
|
||||||
|
theme={theme}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</StyledDiv>
|
</StyledDiv>
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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 { useSelector } from 'react-redux';
|
|
||||||
import { useMemo } from 'react';
|
|
||||||
import { RootState } from 'src/dashboard/types';
|
|
||||||
import getChartIdsFromLayout from '../getChartIdsFromLayout';
|
|
||||||
|
|
||||||
export const useAllChartIds = () => {
|
|
||||||
const layout = useSelector(
|
|
||||||
(state: RootState) => state.dashboardLayout.present,
|
|
||||||
);
|
|
||||||
return useMemo(() => getChartIdsFromLayout(layout), [layout]);
|
|
||||||
};
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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 { Layout, LayoutItem } from 'src/dashboard/types';
|
|
||||||
import { TAB_TYPE, DASHBOARD_GRID_TYPE } from '../componentTypes';
|
|
||||||
import { DASHBOARD_ROOT_ID } from '../constants';
|
|
||||||
import findNonTabChildChartIds from './findNonTabChildChartIds';
|
|
||||||
|
|
||||||
interface TopLevelNode {
|
|
||||||
id: string;
|
|
||||||
type: string;
|
|
||||||
parent_type: string | null;
|
|
||||||
parent_id: string | null;
|
|
||||||
index: number | null;
|
|
||||||
depth: number;
|
|
||||||
slice_ids: number[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RecurseParams {
|
|
||||||
node: LayoutItem | undefined;
|
|
||||||
index?: number | null;
|
|
||||||
depth: number;
|
|
||||||
parentType?: string | null;
|
|
||||||
parentId?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// This function traverses the layout to identify top grid + tab level components
|
|
||||||
// for which we track load times
|
|
||||||
function findTopLevelComponentIds(layout: Layout): TopLevelNode[] {
|
|
||||||
const topLevelNodes: TopLevelNode[] = [];
|
|
||||||
|
|
||||||
function recurseFromNode({
|
|
||||||
node,
|
|
||||||
index = null,
|
|
||||||
depth,
|
|
||||||
parentType = null,
|
|
||||||
parentId = null,
|
|
||||||
}: RecurseParams): void {
|
|
||||||
if (!node) return;
|
|
||||||
|
|
||||||
let nextParentType = parentType;
|
|
||||||
let nextParentId = parentId;
|
|
||||||
let nextDepth = depth;
|
|
||||||
if (node.type === TAB_TYPE || node.type === DASHBOARD_GRID_TYPE) {
|
|
||||||
const chartIds = findNonTabChildChartIds({
|
|
||||||
layout,
|
|
||||||
id: node.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
topLevelNodes.push({
|
|
||||||
id: node.id,
|
|
||||||
type: node.type,
|
|
||||||
parent_type: parentType,
|
|
||||||
parent_id: parentId,
|
|
||||||
index,
|
|
||||||
depth,
|
|
||||||
slice_ids: chartIds,
|
|
||||||
});
|
|
||||||
|
|
||||||
nextParentId = node.id;
|
|
||||||
nextParentType = node.type;
|
|
||||||
nextDepth += 1;
|
|
||||||
}
|
|
||||||
if (node.children && node.children.length) {
|
|
||||||
node.children.forEach((childId, childIndex) => {
|
|
||||||
recurseFromNode({
|
|
||||||
node: layout[childId],
|
|
||||||
index: childIndex,
|
|
||||||
parentType: nextParentType,
|
|
||||||
parentId: nextParentId,
|
|
||||||
depth: nextDepth,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
recurseFromNode({
|
|
||||||
node: layout[DASHBOARD_ROOT_ID],
|
|
||||||
depth: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
return topLevelNodes;
|
|
||||||
}
|
|
||||||
|
|
||||||
// This method is called frequently, so cache results
|
|
||||||
let cachedLayout: Layout | undefined;
|
|
||||||
let cachedTopLevelNodes: TopLevelNode[] = [];
|
|
||||||
export default function findTopLevelComponentIdsWithCache(
|
|
||||||
layout: Layout,
|
|
||||||
): TopLevelNode[] {
|
|
||||||
if (layout === cachedLayout) {
|
|
||||||
return cachedTopLevelNodes;
|
|
||||||
}
|
|
||||||
cachedLayout = layout;
|
|
||||||
cachedTopLevelNodes = findTopLevelComponentIds(layout);
|
|
||||||
|
|
||||||
return cachedTopLevelNodes;
|
|
||||||
}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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 { ChartState } from 'src/explore/types';
|
|
||||||
import { Layout } from 'src/dashboard/types';
|
|
||||||
import findTopLevelComponentIds from './findTopLevelComponentIds';
|
|
||||||
import childChartsDidLoad from './childChartsDidLoad';
|
|
||||||
|
|
||||||
interface GetLoadStatsParams {
|
|
||||||
layout: Layout;
|
|
||||||
chartQueries: Record<string, Partial<ChartState>>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface LoadStats {
|
|
||||||
didLoad: boolean;
|
|
||||||
id: string;
|
|
||||||
minQueryStartTime: number | null;
|
|
||||||
[key: string]: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function getLoadStatsPerTopLevelComponent({
|
|
||||||
layout,
|
|
||||||
chartQueries,
|
|
||||||
}: GetLoadStatsParams): Record<string, LoadStats> {
|
|
||||||
const topLevelComponents = findTopLevelComponentIds(layout);
|
|
||||||
const stats: Record<string, LoadStats> = {};
|
|
||||||
topLevelComponents.forEach(topLevelComponent => {
|
|
||||||
const { id, ...restStats } = topLevelComponent;
|
|
||||||
const { didLoad, minQueryStartTime } = childChartsDidLoad({
|
|
||||||
id,
|
|
||||||
layout,
|
|
||||||
chartQueries,
|
|
||||||
});
|
|
||||||
|
|
||||||
stats[id] = {
|
|
||||||
didLoad,
|
|
||||||
id,
|
|
||||||
minQueryStartTime,
|
|
||||||
...restStats,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
return stats;
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
export const stopPeriodicRender = (refreshTimer?: number) => {
|
|
||||||
if (refreshTimer) {
|
|
||||||
clearInterval(refreshTimer);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
interface SetPeriodicRunnerProps {
|
|
||||||
interval?: number;
|
|
||||||
periodicRender: TimerHandler;
|
|
||||||
refreshTimer?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function setPeriodicRunner({
|
|
||||||
interval = 0,
|
|
||||||
periodicRender,
|
|
||||||
refreshTimer,
|
|
||||||
}: SetPeriodicRunnerProps) {
|
|
||||||
stopPeriodicRender(refreshTimer);
|
|
||||||
|
|
||||||
if (interval > 0) {
|
|
||||||
return setInterval(periodicRender, interval);
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
@@ -437,6 +437,32 @@ describe('UploadDataModal - Form Validation', () => {
|
|||||||
expect(screen.getByText('Table name is required')).toBeInTheDocument();
|
expect(screen.getByText('Table name is required')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('database validation error clears after selecting a database', async () => {
|
||||||
|
render(<UploadDataModal {...csvProps} />, { useRedux: true });
|
||||||
|
|
||||||
|
const uploadButton = screen.getByRole('button', { name: 'Upload' });
|
||||||
|
await userEvent.click(uploadButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByText('Selecting a database is required'),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectDatabase = screen.getByRole('combobox', {
|
||||||
|
name: /select a database/i,
|
||||||
|
});
|
||||||
|
await userEvent.click(selectDatabase);
|
||||||
|
await waitFor(() => screen.getByText('database1'));
|
||||||
|
await userEvent.click(screen.getByText('database1'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.queryByText('Selecting a database is required'),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
||||||
|
|||||||
@@ -578,8 +578,11 @@ const UploadDataModal: FunctionComponent<UploadDataModalProps> = ({
|
|||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
};
|
};
|
||||||
|
|
||||||
const validateDatabase = (_: any, value: string) => {
|
const validateDatabase = (
|
||||||
if (!currentDatabaseId) {
|
_: any,
|
||||||
|
value: { value: number; label: string } | null | undefined,
|
||||||
|
) => {
|
||||||
|
if (!value?.value) {
|
||||||
return Promise.reject(t('Selecting a database is required'));
|
return Promise.reject(t('Selecting a database is required'));
|
||||||
}
|
}
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
|
|||||||
@@ -1,126 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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 { Link } from 'react-router-dom';
|
|
||||||
import { t } from '@apache-superset/core/translation';
|
|
||||||
import { isFeatureEnabled, FeatureFlag } from '@superset-ui/core';
|
|
||||||
import { CardStyles } from 'src/views/CRUD/utils';
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
Dropdown,
|
|
||||||
ConfirmStatusChange,
|
|
||||||
ListViewCard,
|
|
||||||
} from '@superset-ui/core/components';
|
|
||||||
import { MenuItem } from '@superset-ui/core/components/Menu';
|
|
||||||
import { Icons } from '@superset-ui/core/components/Icons';
|
|
||||||
import { Tag } from 'src/views/CRUD/types';
|
|
||||||
import { deleteTags } from 'src/features/tags/tags';
|
|
||||||
import { assetUrl } from 'src/utils/assetUrl';
|
|
||||||
|
|
||||||
interface TagCardProps {
|
|
||||||
tag: Tag;
|
|
||||||
hasPerm: (name: string) => boolean;
|
|
||||||
bulkSelectEnabled: boolean;
|
|
||||||
refreshData: () => void;
|
|
||||||
loading: boolean;
|
|
||||||
addDangerToast: (msg: string) => void;
|
|
||||||
addSuccessToast: (msg: string) => void;
|
|
||||||
tagFilter?: string;
|
|
||||||
userId?: string | number;
|
|
||||||
showThumbnails?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function TagCard({
|
|
||||||
tag,
|
|
||||||
hasPerm,
|
|
||||||
refreshData,
|
|
||||||
addDangerToast,
|
|
||||||
addSuccessToast,
|
|
||||||
showThumbnails,
|
|
||||||
}: TagCardProps) {
|
|
||||||
const canDelete = hasPerm('can_write');
|
|
||||||
|
|
||||||
const handleTagDelete = (tag: Tag) => {
|
|
||||||
deleteTags([tag], addSuccessToast, addDangerToast);
|
|
||||||
refreshData();
|
|
||||||
};
|
|
||||||
|
|
||||||
const menuItems: MenuItem[] = [];
|
|
||||||
if (canDelete) {
|
|
||||||
menuItems.push({
|
|
||||||
key: 'delete-tag',
|
|
||||||
label: (
|
|
||||||
<ConfirmStatusChange
|
|
||||||
title={t('Please confirm')}
|
|
||||||
description={
|
|
||||||
<>
|
|
||||||
{t('Are you sure you want to delete')} <b>{tag.name}</b>?
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
onConfirm={() => handleTagDelete(tag)}
|
|
||||||
>
|
|
||||||
{confirmDelete => (
|
|
||||||
<div
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
className="action-button"
|
|
||||||
onClick={confirmDelete}
|
|
||||||
data-test="dashboard-card-option-delete-button"
|
|
||||||
>
|
|
||||||
<Icons.DeleteOutlined iconSize="l" /> {t('Delete')}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</ConfirmStatusChange>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<CardStyles>
|
|
||||||
<ListViewCard
|
|
||||||
title={tag.name}
|
|
||||||
cover={
|
|
||||||
!isFeatureEnabled(FeatureFlag.Thumbnails) || !showThumbnails ? (
|
|
||||||
<></>
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
url={undefined}
|
|
||||||
linkComponent={Link}
|
|
||||||
imgFallbackURL={assetUrl(
|
|
||||||
'/static/assets/images/dashboard-card-fallback.svg',
|
|
||||||
)}
|
|
||||||
description={t('Modified %s', tag.changed_on_delta_humanized)}
|
|
||||||
actions={
|
|
||||||
<ListViewCard.Actions
|
|
||||||
onClick={e => {
|
|
||||||
e.stopPropagation();
|
|
||||||
e.preventDefault();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Dropdown menu={{ items: menuItems }} trigger={['click', 'hover']}>
|
|
||||||
<Button buttonSize="xsmall" buttonStyle="link">
|
|
||||||
<Icons.MoreOutlined iconSize="xl" />
|
|
||||||
</Button>
|
|
||||||
</Dropdown>
|
|
||||||
</ListViewCard.Actions>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</CardStyles>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default TagCard;
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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 { useEffect, useRef } from 'react';
|
|
||||||
import { isDefined } from '@superset-ui/core';
|
|
||||||
|
|
||||||
export const useMemoCompare = <T>(
|
|
||||||
next: T,
|
|
||||||
compare: (prev: T | undefined, next: T) => boolean,
|
|
||||||
) => {
|
|
||||||
const previousRef = useRef<T>();
|
|
||||||
const previous = previousRef.current;
|
|
||||||
const isEqual = compare(previous, next);
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isEqual) {
|
|
||||||
previousRef.current = next;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (!isDefined(previous)) {
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
return isEqual ? previous : next;
|
|
||||||
};
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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 { useEffect, useRef } from 'react';
|
|
||||||
|
|
||||||
export function useOpenerRef(active: boolean) {
|
|
||||||
const openerRef = useRef<HTMLElement | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (active) {
|
|
||||||
openerRef.current = document.activeElement as HTMLElement;
|
|
||||||
}
|
|
||||||
}, [active]);
|
|
||||||
|
|
||||||
return openerRef;
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export enum DashboardVirtualizationMode {
|
|
||||||
None = 'NONE',
|
|
||||||
Viewport = 'VIEWPORT',
|
|
||||||
Paginated = 'PAGINATED',
|
|
||||||
}
|
|
||||||
|
|
||||||
export const isDashboardVirtualizationEnabled = (
|
|
||||||
virtualizationMode: DashboardVirtualizationMode,
|
|
||||||
) =>
|
|
||||||
virtualizationMode === DashboardVirtualizationMode.Viewport ||
|
|
||||||
virtualizationMode === DashboardVirtualizationMode.Paginated;
|
|
||||||
@@ -1157,7 +1157,6 @@ class AsyncExecuteReportScheduleCommand(BaseCommand):
|
|||||||
self._scheduled_dttm = scheduled_dttm
|
self._scheduled_dttm = scheduled_dttm
|
||||||
self._execution_id = UUID(task_id)
|
self._execution_id = UUID(task_id)
|
||||||
|
|
||||||
@transaction()
|
|
||||||
def run(self) -> None:
|
def run(self) -> None:
|
||||||
try:
|
try:
|
||||||
self.validate()
|
self.validate()
|
||||||
@@ -1170,6 +1169,21 @@ class AsyncExecuteReportScheduleCommand(BaseCommand):
|
|||||||
)
|
)
|
||||||
user = security_manager.find_user(username)
|
user = security_manager.find_user(username)
|
||||||
|
|
||||||
|
with override_user(user):
|
||||||
|
# Pre-commit any permalink rows before the state machine's
|
||||||
|
# @transaction() opens. When called inside a transaction,
|
||||||
|
# CreateDashboardPermalinkCommand only flushes (not commits),
|
||||||
|
# leaving the row invisible to Playwright's separate DB
|
||||||
|
# connection. Running get_dashboard_urls() here — outside any
|
||||||
|
# transaction — lets the command commit normally. The state
|
||||||
|
# machine's inner call to get_dashboard_urls() hits get_entry()
|
||||||
|
# for the same deterministic UUID and returns the
|
||||||
|
# already-committed row without a second INSERT.
|
||||||
|
if self._model.dashboard_id:
|
||||||
|
BaseReportState(
|
||||||
|
self._model, self._scheduled_dttm, self._execution_id
|
||||||
|
).get_dashboard_urls()
|
||||||
|
|
||||||
start_time = datetime.utcnow()
|
start_time = datetime.utcnow()
|
||||||
with override_user(user):
|
with override_user(user):
|
||||||
ReportScheduleStateMachine(
|
ReportScheduleStateMachine(
|
||||||
|
|||||||
Reference in New Issue
Block a user