mirror of
https://github.com/apache/superset.git
synced 2026-05-20 15:25:12 +00:00
Compare commits
6 Commits
docs/dashb
...
fix-asyncs
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76cc1d9cf9 | ||
|
|
d9385efa92 | ||
|
|
089f23ac2d | ||
|
|
b986e41581 | ||
|
|
1230b9091b | ||
|
|
852d0182b5 |
@@ -1,168 +0,0 @@
|
||||
---
|
||||
title: Dashboard Performance
|
||||
hide_title: true
|
||||
sidebar_position: 5
|
||||
version: 1
|
||||
---
|
||||
|
||||
<!--
|
||||
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.
|
||||
-->
|
||||
|
||||
# Dashboard Performance
|
||||
|
||||
A dashboard's perceived speed is determined by three independent things: how
|
||||
many charts have to render, how many queries the backend can execute
|
||||
concurrently, and how quickly the underlying data warehouse can return
|
||||
results. Superset gives you levers for the first two; the third belongs to
|
||||
your warehouse. This page covers the dashboard-side levers and the practical
|
||||
guidance around them.
|
||||
|
||||
## Is there a maximum chart count per dashboard?
|
||||
|
||||
**No hard limit is enforced** — Superset has no configuration key that
|
||||
caps the number of charts on a dashboard. In practice, dashboards behave
|
||||
well up to a few dozen charts. Beyond that, you'll typically feel friction
|
||||
on the initial load and during cross-filter / time-range updates, even with
|
||||
the lazy-loading optimizations described below.
|
||||
|
||||
Rough thresholds to keep in mind:
|
||||
|
||||
- **Under ~25 charts**: usually no perceptible problem.
|
||||
- **25–50 charts**: still fine, but you start to want tabs to break the
|
||||
page into chunks the user actually looks at.
|
||||
- **Over ~50 charts**: split into multiple dashboards or use tabs
|
||||
aggressively. The bottleneck is rarely Superset itself — it's the
|
||||
warehouse executing dozens of queries in parallel and the browser
|
||||
rendering dozens of chart frames.
|
||||
|
||||
These are guidelines, not guarantees. A dashboard of 100 sparkline-style
|
||||
charts hitting a fast cache behaves very differently from a dashboard of
|
||||
20 heavy aggregations against a cold warehouse.
|
||||
|
||||
## Lazy rendering — `DASHBOARD_VIRTUALIZATION`
|
||||
|
||||
Superset's dashboard layout is virtualized at the row level. Charts that
|
||||
are far below the user's current scroll position are not rendered (and
|
||||
therefore don't fetch data) until the user scrolls them into view, and they
|
||||
are unmounted again if scrolled well past. This is on by default.
|
||||
|
||||
**Feature flag**: `DASHBOARD_VIRTUALIZATION` (default: `True`)
|
||||
|
||||
The flag is `stable` and marked for path-to-deprecation — meaning the
|
||||
behavior will eventually be non-optional, but the flag still exists so
|
||||
operators can disable it if a specific layout misbehaves.
|
||||
|
||||
**Behavior** (from `superset-frontend/src/dashboard/components/gridComponents/Row/Row.tsx`):
|
||||
|
||||
- A chart is rendered when its row scrolls within **1 viewport height** of
|
||||
the visible area.
|
||||
- A chart is unmounted when its row scrolls more than **4 viewport
|
||||
heights** away from the visible area.
|
||||
- Tabs that aren't currently selected don't render their content at all
|
||||
(see below).
|
||||
- The unmounting half is skipped in **embedded** mode (so an embedded
|
||||
dashboard keeps its charts mounted once they've been seen, which avoids
|
||||
re-fetching on scroll-up). Both halves are skipped for **headless /
|
||||
bot** rendering (so screenshot / report jobs load every chart).
|
||||
|
||||
## Deferred data fetch — `DASHBOARD_VIRTUALIZATION_DEFER_DATA`
|
||||
|
||||
By default, `DASHBOARD_VIRTUALIZATION` controls *rendering* — but charts
|
||||
that don't render also don't fetch data, because Superset's chart
|
||||
components issue their data request on mount. `DASHBOARD_VIRTUALIZATION_DEFER_DATA`
|
||||
is a supplementary flag that further defers the data request itself, useful
|
||||
for backends where opening a connection or compiling a query is expensive
|
||||
even if the result is later thrown away.
|
||||
|
||||
**Feature flag**: `DASHBOARD_VIRTUALIZATION_DEFER_DATA` (default: `False`)
|
||||
|
||||
Enable this if you see warehouse load spike on dashboard *open* even
|
||||
though most charts are off-screen.
|
||||
|
||||
## Per-tab lazy loading
|
||||
|
||||
**This is on by default and has no flag.** A tab's content is not rendered
|
||||
until the user activates that tab, so charts inside an unselected tab do
|
||||
not fetch data on dashboard open. When the user clicks the tab, that
|
||||
tab's charts mount and fetch in the normal way.
|
||||
|
||||
Practically: tabs are the single most effective tool for a large
|
||||
dashboard. Splitting 60 charts across 4 tabs effectively turns dashboard
|
||||
open into "load ~15 charts," and the remaining ones lazy-load only if the
|
||||
user goes looking.
|
||||
|
||||
## Is there a switch to cap concurrent chart queries?
|
||||
|
||||
**No.** Superset does not implement a frontend-side concurrent-request
|
||||
limiter. Each chart issues its own data request when it mounts, and the
|
||||
browser handles parallelism (typically ~6 in-flight HTTP requests per
|
||||
origin, then the rest queue). Backend throughput is bounded by your
|
||||
Gunicorn worker count for synchronous query execution, or by your Celery
|
||||
worker pool when [async queries](./async-queries-celery.mdx) are enabled.
|
||||
|
||||
If you need to throttle warehouse load, the right place is:
|
||||
|
||||
1. The warehouse itself (connection pool / concurrency limits).
|
||||
2. Superset's Celery configuration (smaller worker pool when async
|
||||
queries are on).
|
||||
3. Splitting heavy charts across tabs or separate dashboards (each
|
||||
dashboard load only fetches what's visible).
|
||||
|
||||
## Splitting strategies
|
||||
|
||||
When a dashboard outgrows comfortable performance, the options in order
|
||||
of effort:
|
||||
|
||||
**1. Move sections into tabs.** Same dashboard, but only the active tab's
|
||||
charts fetch. This is the cheapest change and often the only one needed.
|
||||
|
||||
**2. Cache aggressively.** A Redis cache backend (see
|
||||
[Caching](./cache.mdx)) means repeat dashboard loads serve from cache
|
||||
rather than re-hitting the warehouse. This is especially impactful for
|
||||
dashboards opened by many users in close succession.
|
||||
|
||||
**3. Enable async queries.** [Async query execution](./async-queries-celery.mdx)
|
||||
via Celery decouples query duration from request lifetime, so a slow
|
||||
chart doesn't block the page. The user sees other charts come in as
|
||||
their queries complete.
|
||||
|
||||
**4. Split into multiple dashboards.** Group related charts into purpose-
|
||||
specific dashboards rather than one mega-dashboard. Link them from a
|
||||
landing dashboard or a navigation menu.
|
||||
|
||||
**5. Pre-aggregate at the warehouse level.** If the same expensive
|
||||
aggregation appears across many charts, materialize it as a view or
|
||||
scheduled table in the warehouse so each chart query is a cheap lookup.
|
||||
|
||||
## Operational notes
|
||||
|
||||
- The feature flags above are set in `superset_config.py`, e.g.:
|
||||
|
||||
```python
|
||||
FEATURE_FLAGS = {
|
||||
"DASHBOARD_VIRTUALIZATION": True,
|
||||
"DASHBOARD_VIRTUALIZATION_DEFER_DATA": True,
|
||||
}
|
||||
```
|
||||
|
||||
- See [Feature Flags](./feature-flags.mdx) for the full list of supported
|
||||
flags and their lifecycle stages.
|
||||
- Report and screenshot jobs (alerts, scheduled reports, dashboard
|
||||
exports) intentionally bypass row virtualization so the rendered
|
||||
artifact includes every chart, not just the ones above the fold.
|
||||
@@ -70,7 +70,7 @@
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"disabled": false,
|
||||
"disabled": true,
|
||||
"lastVersion": "current",
|
||||
"includeCurrentVersion": true,
|
||||
"onlyIncludeVersions": [
|
||||
|
||||
@@ -897,6 +897,288 @@ test('fires onChange when pasting a selection', async () => {
|
||||
await waitFor(() => expect(onChange).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
test('replaces cached options with search results instead of merging', async () => {
|
||||
const page0Data = Array.from({ length: 10 }, (_, i) => ({
|
||||
label: `Option ${i}`,
|
||||
value: i,
|
||||
}));
|
||||
const searchData = [{ label: 'Search Match', value: 100 }];
|
||||
const loadOptions = jest.fn(async (search: string) => {
|
||||
if (search === '') {
|
||||
return { data: page0Data, totalCount: 100 };
|
||||
}
|
||||
return { data: searchData, totalCount: 1 };
|
||||
});
|
||||
|
||||
render(<AsyncSelect {...defaultProps} options={loadOptions} />);
|
||||
await open();
|
||||
await waitFor(() => expect(loadOptions).toHaveBeenCalledTimes(1));
|
||||
|
||||
let options = await findAllSelectOptions();
|
||||
expect(options).toHaveLength(10);
|
||||
|
||||
await type('search');
|
||||
await waitFor(() => expect(loadOptions).toHaveBeenCalledTimes(2));
|
||||
|
||||
options = await findAllSelectOptions();
|
||||
expect(options).toHaveLength(1);
|
||||
expect(options[0]).toHaveTextContent('Search Match');
|
||||
});
|
||||
|
||||
test('shows all options when filterOption is false', async () => {
|
||||
const page0Data = Array.from({ length: 10 }, (_, i) => ({
|
||||
label: `Base ${i}`,
|
||||
value: i,
|
||||
}));
|
||||
const searchData = Array.from({ length: 5 }, (_, i) => ({
|
||||
label: `Server ${i}`,
|
||||
value: 100 + i,
|
||||
}));
|
||||
const loadOptions = jest.fn(async (search: string) =>
|
||||
search === ''
|
||||
? { data: page0Data, totalCount: 100 }
|
||||
: { data: searchData, totalCount: 5 },
|
||||
);
|
||||
|
||||
render(
|
||||
<AsyncSelect
|
||||
{...defaultProps}
|
||||
options={loadOptions}
|
||||
filterOption={false}
|
||||
/>,
|
||||
);
|
||||
await open();
|
||||
await waitFor(() => expect(loadOptions).toHaveBeenCalledTimes(1));
|
||||
|
||||
await type('zzz_no_match');
|
||||
await waitFor(() => expect(loadOptions).toHaveBeenCalledTimes(2));
|
||||
|
||||
const options = await findAllSelectOptions();
|
||||
expect(options).toHaveLength(5);
|
||||
expect(options[0]).toHaveTextContent('Server 0');
|
||||
});
|
||||
|
||||
test('preserves new option entry across search fetch when allowNewOptions is on', async () => {
|
||||
const page0Data = Array.from({ length: 10 }, (_, i) => ({
|
||||
label: `Option ${i}`,
|
||||
value: i,
|
||||
}));
|
||||
const loadOptions = jest.fn(async (search: string) => {
|
||||
if (search === '') {
|
||||
return { data: page0Data, totalCount: 100 };
|
||||
}
|
||||
return { data: [], totalCount: 0 };
|
||||
});
|
||||
|
||||
render(
|
||||
<AsyncSelect {...defaultProps} options={loadOptions} allowNewOptions />,
|
||||
);
|
||||
await open();
|
||||
await waitFor(() => expect(loadOptions).toHaveBeenCalledTimes(1));
|
||||
|
||||
await type('newval');
|
||||
await waitFor(() => expect(loadOptions).toHaveBeenCalledTimes(2));
|
||||
|
||||
const options = await findAllSelectOptions();
|
||||
expect(options).toHaveLength(1);
|
||||
expect(options[0]).toHaveTextContent('newval');
|
||||
// Stale page-0 options must not bleed through.
|
||||
expect(screen.queryByText('Option 0')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('restores base options when search is cleared', async () => {
|
||||
const page0Data = Array.from({ length: 10 }, (_, i) => ({
|
||||
label: `Option ${i}`,
|
||||
value: i,
|
||||
}));
|
||||
const searchData = [{ label: 'Search Match', value: 100 }];
|
||||
const loadOptions = jest.fn(async (search: string) => {
|
||||
if (search === '') {
|
||||
return { data: page0Data, totalCount: 100 };
|
||||
}
|
||||
return { data: searchData, totalCount: 1 };
|
||||
});
|
||||
|
||||
render(<AsyncSelect {...defaultProps} options={loadOptions} />);
|
||||
await open();
|
||||
await waitFor(() => expect(loadOptions).toHaveBeenCalledTimes(1));
|
||||
|
||||
await type('search');
|
||||
await waitFor(() => expect(loadOptions).toHaveBeenCalledTimes(2));
|
||||
let options = await findAllSelectOptions();
|
||||
expect(options).toHaveLength(1);
|
||||
expect(options[0]).toHaveTextContent('Search Match');
|
||||
|
||||
// type() clears the input before typing, so passing '' clears the search.
|
||||
await type('');
|
||||
await waitFor(async () => {
|
||||
options = await findAllSelectOptions();
|
||||
expect(options).toHaveLength(10);
|
||||
});
|
||||
expect(options[0]).toHaveTextContent('Option 0');
|
||||
expect(screen.queryByText('Search Match')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('replaces results when switching between two searches', async () => {
|
||||
const page0Data = Array.from({ length: 10 }, (_, i) => ({
|
||||
label: `Option ${i}`,
|
||||
value: i,
|
||||
}));
|
||||
const loadOptions = jest.fn(async (search: string) => {
|
||||
if (search === '') {
|
||||
return { data: page0Data, totalCount: 100 };
|
||||
}
|
||||
return {
|
||||
data: [{ label: `Match-${search}`, value: `v-${search}` }],
|
||||
totalCount: 1,
|
||||
};
|
||||
});
|
||||
|
||||
render(<AsyncSelect {...defaultProps} options={loadOptions} />);
|
||||
await open();
|
||||
await waitFor(() => expect(loadOptions).toHaveBeenCalledTimes(1));
|
||||
|
||||
await type('alpha');
|
||||
await waitFor(async () => {
|
||||
const options = await findAllSelectOptions();
|
||||
expect(options).toHaveLength(1);
|
||||
expect(options[0]).toHaveTextContent('Match-alpha');
|
||||
});
|
||||
|
||||
await type('beta');
|
||||
await waitFor(async () => {
|
||||
const options = await findAllSelectOptions();
|
||||
expect(options).toHaveLength(1);
|
||||
expect(options[0]).toHaveTextContent('Match-beta');
|
||||
});
|
||||
expect(screen.queryByText('Match-alpha')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('refetches a dropped search response when the same search is repeated', async () => {
|
||||
type OptionRow = { label: string; value: string | number };
|
||||
type PageResponse = { data: OptionRow[]; totalCount: number };
|
||||
// Resolves the in-flight loadOptions promise of the calling test.
|
||||
let resolveAlpha: ((value: PageResponse) => void) | null = null;
|
||||
const page0Data: OptionRow[] = Array.from({ length: 10 }, (_, i) => ({
|
||||
label: `Option ${i}`,
|
||||
value: i,
|
||||
}));
|
||||
const alphaData: OptionRow[] = [{ label: 'Match-alpha', value: 'va' }];
|
||||
const betaData: OptionRow[] = [{ label: 'Match-beta', value: 'vb' }];
|
||||
|
||||
const loadOptions = jest.fn((search: string) => {
|
||||
if (search === '') {
|
||||
return Promise.resolve<PageResponse>({
|
||||
data: page0Data,
|
||||
totalCount: 100,
|
||||
});
|
||||
}
|
||||
if (search === 'alpha') {
|
||||
// First call: hold the promise so it resolves only after beta returns.
|
||||
// Second call (after beta): resolve immediately so the cache MUST allow
|
||||
// a refetch.
|
||||
if (!resolveAlpha) {
|
||||
return new Promise<PageResponse>(resolve => {
|
||||
resolveAlpha = resolve;
|
||||
});
|
||||
}
|
||||
return Promise.resolve<PageResponse>({ data: alphaData, totalCount: 1 });
|
||||
}
|
||||
return Promise.resolve<PageResponse>({ data: betaData, totalCount: 1 });
|
||||
});
|
||||
|
||||
render(<AsyncSelect {...defaultProps} options={loadOptions} />);
|
||||
await open();
|
||||
await waitFor(() => expect(loadOptions).toHaveBeenCalledWith('', 0, 10));
|
||||
|
||||
await type('alpha');
|
||||
await waitFor(() => expect(loadOptions).toHaveBeenCalledWith('alpha', 0, 10));
|
||||
// alpha's promise is held; switch to beta which resolves first.
|
||||
await type('beta');
|
||||
await waitFor(async () => {
|
||||
const options = await findAllSelectOptions();
|
||||
expect(options).toHaveLength(1);
|
||||
expect(options[0]).toHaveTextContent('Match-beta');
|
||||
});
|
||||
|
||||
// Release the stale alpha response. It must be dropped — its key must not
|
||||
// be cached, or returning to "alpha" later would short-circuit the fetch.
|
||||
resolveAlpha!({ data: alphaData, totalCount: 1 });
|
||||
await waitFor(async () => {
|
||||
// Beta is still showing because alpha's response was dropped.
|
||||
const options = await findAllSelectOptions();
|
||||
expect(options[0]).toHaveTextContent('Match-beta');
|
||||
});
|
||||
|
||||
// Returning to "alpha" must re-trigger the fetch (cache wasn't poisoned).
|
||||
const callsBeforeAlphaReturn = loadOptions.mock.calls.filter(
|
||||
args => args[0] === 'alpha',
|
||||
).length;
|
||||
await type('alpha');
|
||||
await waitFor(() => {
|
||||
const callsAfter = loadOptions.mock.calls.filter(
|
||||
args => args[0] === 'alpha',
|
||||
).length;
|
||||
expect(callsAfter).toBeGreaterThan(callsBeforeAlphaReturn);
|
||||
});
|
||||
await waitFor(async () => {
|
||||
const options = await findAllSelectOptions();
|
||||
expect(options[0]).toHaveTextContent('Match-alpha');
|
||||
});
|
||||
});
|
||||
|
||||
test('re-shows search results when the same search term is repeated after a clear', async () => {
|
||||
// Regression: a prior fix cached search responses' totalCount in
|
||||
// fetchedQueries. After restore-on-clear had replaced selectOptions with
|
||||
// the base list, re-typing a previously-resolved term would hit the cache
|
||||
// short-circuit and leave selectOptions stale (empty / base-only).
|
||||
const page0Data = Array.from({ length: 10 }, (_, i) => ({
|
||||
label: `Option ${i}`,
|
||||
value: i,
|
||||
}));
|
||||
const alphaData = [{ label: 'Match-alpha', value: 'va' }];
|
||||
const loadOptions = jest.fn(async (search: string) => {
|
||||
if (search === '') {
|
||||
// totalCount > data.length so allValuesLoaded stays false and the
|
||||
// search path is not bypassed by the "all loaded" short-circuit.
|
||||
return { data: page0Data, totalCount: 100 };
|
||||
}
|
||||
return { data: alphaData, totalCount: 1 };
|
||||
});
|
||||
|
||||
render(<AsyncSelect {...defaultProps} options={loadOptions} />);
|
||||
await open();
|
||||
await waitFor(() => expect(loadOptions).toHaveBeenCalledWith('', 0, 10));
|
||||
|
||||
await type('alpha');
|
||||
await waitFor(async () => {
|
||||
const options = await findAllSelectOptions();
|
||||
expect(options).toHaveLength(1);
|
||||
expect(options[0]).toHaveTextContent('Match-alpha');
|
||||
});
|
||||
|
||||
await type('');
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText('Match-alpha')).not.toBeInTheDocument(),
|
||||
);
|
||||
|
||||
const callsBefore = loadOptions.mock.calls.filter(
|
||||
args => args[0] === 'alpha',
|
||||
).length;
|
||||
await type('alpha');
|
||||
await waitFor(() => {
|
||||
const callsAfter = loadOptions.mock.calls.filter(
|
||||
args => args[0] === 'alpha',
|
||||
).length;
|
||||
expect(callsAfter).toBeGreaterThan(callsBefore);
|
||||
});
|
||||
await waitFor(async () => {
|
||||
const options = await findAllSelectOptions();
|
||||
expect(options).toHaveLength(1);
|
||||
expect(options[0]).toHaveTextContent('Match-alpha');
|
||||
});
|
||||
});
|
||||
|
||||
test('does not duplicate options when using numeric values', async () => {
|
||||
render(
|
||||
<AsyncSelect
|
||||
|
||||
@@ -160,6 +160,8 @@ const AsyncSelect = forwardRef(
|
||||
const [allValuesLoaded, setAllValuesLoaded] = useState(false);
|
||||
const selectValueRef = useRef(selectValue);
|
||||
const fetchedQueries = useRef(new Map<string, number>());
|
||||
const initialOptionsRef = useRef<SelectOptionsType>(EMPTY_OPTIONS);
|
||||
const inputValueRef = useRef('');
|
||||
const mappedMode = isSingleMode ? undefined : 'multiple';
|
||||
const allowFetch = !fetchOnlyOnSearch || inputValue;
|
||||
const [maxTagCount, setMaxTagCount] = useState(
|
||||
@@ -183,6 +185,10 @@ const AsyncSelect = forwardRef(
|
||||
selectValueRef.current = selectValue;
|
||||
}, [selectValue]);
|
||||
|
||||
useEffect(() => {
|
||||
inputValueRef.current = inputValue;
|
||||
}, [inputValue]);
|
||||
|
||||
const sortSelectedFirst = useCallback(
|
||||
(a: AntdLabeledValue, b: AntdLabeledValue) =>
|
||||
sortSelectedFirstHelper(a, b, selectValueRef.current),
|
||||
@@ -335,15 +341,61 @@ const AsyncSelect = forwardRef(
|
||||
const fetchOptions = options as SelectOptionsPagePromise;
|
||||
fetchOptions(search, page, pageSize)
|
||||
.then(({ data, totalCount }: SelectOptionsTypePage) => {
|
||||
const mergedData = mergeData(data);
|
||||
fetchedQueries.current.set(key, totalCount);
|
||||
setTotalCount(totalCount);
|
||||
if (
|
||||
!fetchOnlyOnSearch &&
|
||||
search === '' &&
|
||||
mergedData.length >= totalCount
|
||||
) {
|
||||
setAllValuesLoaded(true);
|
||||
// Drop responses whose search arg no longer matches the user's
|
||||
// current input — otherwise a slow base fetch can land after a
|
||||
// search fetch (or a stale debounced search after a clear) and
|
||||
// re-pollute the dropdown via mergeData / search-replace. Search
|
||||
// responses are never cached in fetchedQueries: the cache stores
|
||||
// only totalCount, so a cache hit would short-circuit the fetch
|
||||
// and leave selectOptions stale (e.g. after restore-on-clear).
|
||||
// Re-issuing the search is cheap and correct.
|
||||
const matchesCurrentSearch = inputValueRef.current === search;
|
||||
if (search && !matchesCurrentSearch) {
|
||||
return;
|
||||
}
|
||||
if (!search) {
|
||||
// Accumulate base pages in a ref independent of selectOptions
|
||||
// (during an active search, selectOptions holds search results
|
||||
// and is not a safe accumulator). The accumulator is kept up
|
||||
// to date even when this response landed during a search, so
|
||||
// restore-on-clear has a complete snapshot.
|
||||
const dataValues = new Set(data.map(opt => opt.value));
|
||||
const accumulated = initialOptionsRef.current
|
||||
.filter(opt => !dataValues.has(opt.value))
|
||||
.concat(data)
|
||||
.sort(sortComparatorForNoSearch);
|
||||
initialOptionsRef.current = accumulated;
|
||||
if (!fetchOnlyOnSearch && accumulated.length >= totalCount) {
|
||||
setAllValuesLoaded(true);
|
||||
}
|
||||
fetchedQueries.current.set(key, totalCount);
|
||||
if (matchesCurrentSearch) {
|
||||
// No active search — push to live selectOptions and update
|
||||
// totalCount. When matchesCurrentSearch is false, the user
|
||||
// is mid-search; leave the search's totalCount in place so
|
||||
// pagination math stays correct.
|
||||
mergeData(data);
|
||||
setTotalCount(totalCount);
|
||||
}
|
||||
} else if (page === 0) {
|
||||
// Replace cached options with server results; preserve
|
||||
// optimistic isNewOption entries inserted by handleOnSearch
|
||||
// so allowNewOptions users can still click the value they
|
||||
// typed when the server returns no match.
|
||||
setSelectOptions(prevOptions => {
|
||||
const dataValues = new Set(data.map(opt => opt.value));
|
||||
const preservedNew = prevOptions.filter(
|
||||
opt => opt.isNewOption && !dataValues.has(opt.value),
|
||||
);
|
||||
return preservedNew
|
||||
.concat(data)
|
||||
.sort(sortComparatorForNoSearch);
|
||||
});
|
||||
setTotalCount(totalCount);
|
||||
} else {
|
||||
// page > 0 during an active search — append normally.
|
||||
mergeData(data);
|
||||
setTotalCount(totalCount);
|
||||
}
|
||||
})
|
||||
.catch(internalOnError)
|
||||
@@ -358,6 +410,7 @@ const AsyncSelect = forwardRef(
|
||||
internalOnError,
|
||||
options,
|
||||
pageSize,
|
||||
sortComparatorForNoSearch,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -500,6 +553,7 @@ const AsyncSelect = forwardRef(
|
||||
fetchedQueries.current.clear();
|
||||
setAllValuesLoaded(false);
|
||||
setSelectOptions(EMPTY_OPTIONS);
|
||||
initialOptionsRef.current = EMPTY_OPTIONS;
|
||||
}, [options]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -514,16 +568,36 @@ const AsyncSelect = forwardRef(
|
||||
[debouncedFetchPage],
|
||||
);
|
||||
|
||||
const previousInputValue = usePrevious(inputValue, '');
|
||||
useEffect(() => {
|
||||
if (loadingEnabled && allowFetch) {
|
||||
// trigger fetch every time inputValue changes
|
||||
if (inputValue) {
|
||||
debouncedFetchPage(inputValue, 0);
|
||||
} else {
|
||||
// Cancel any pending debounced search fetch so it can't fire after
|
||||
// we've already restored the base list.
|
||||
debouncedFetchPage.cancel();
|
||||
// On returning to empty input after a search, restore the cached
|
||||
// base options so the dropdown shows the original page-0 list
|
||||
// instead of the stale search results.
|
||||
if (previousInputValue && initialOptionsRef.current.length > 0) {
|
||||
setSelectOptions(
|
||||
[...initialOptionsRef.current].sort(sortComparatorForNoSearch),
|
||||
);
|
||||
}
|
||||
fetchPage('', 0);
|
||||
}
|
||||
}
|
||||
}, [loadingEnabled, fetchPage, allowFetch, inputValue, debouncedFetchPage]);
|
||||
}, [
|
||||
loadingEnabled,
|
||||
fetchPage,
|
||||
allowFetch,
|
||||
inputValue,
|
||||
previousInputValue,
|
||||
debouncedFetchPage,
|
||||
sortComparatorForNoSearch,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (loading !== undefined && loading !== isLoading) {
|
||||
|
||||
@@ -211,6 +211,10 @@ export const handleFilterOptionHelper = (
|
||||
return filterOption(search, option);
|
||||
}
|
||||
|
||||
if (filterOption === false) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (filterOption) {
|
||||
const searchValue = search.trim().toLowerCase();
|
||||
if (optionFilterProps?.length) {
|
||||
|
||||
@@ -169,6 +169,7 @@ describe('RoleListEditModal', () => {
|
||||
true,
|
||||
);
|
||||
|
||||
// updateRoleUsers is called with the hydrated user IDs
|
||||
const userArg = mockUpdateRoleUsers.mock.calls[0][1];
|
||||
expect(userArg).toEqual([5, 7]);
|
||||
expect(userArg.every((id: unknown) => typeof id === 'number')).toBe(true);
|
||||
@@ -225,6 +226,8 @@ describe('RoleListEditModal', () => {
|
||||
expect(decodedQuery).toEqual({
|
||||
page_size: 100,
|
||||
page: 0,
|
||||
order_column: 'id',
|
||||
order_direction: 'asc',
|
||||
filters: [
|
||||
{
|
||||
col: 'roles',
|
||||
|
||||
@@ -129,8 +129,18 @@ function RoleListEditModal({
|
||||
fetchPaginatedData({
|
||||
endpoint: `/api/v1/security/users/`,
|
||||
pageSize: 100,
|
||||
setData: setRoleUsers,
|
||||
setData: (users: UserObject[]) => {
|
||||
const seen = new Set<number>();
|
||||
setRoleUsers(
|
||||
users.filter(u => {
|
||||
if (seen.has(u.id)) return false;
|
||||
seen.add(u.id);
|
||||
return true;
|
||||
}),
|
||||
);
|
||||
},
|
||||
filters,
|
||||
orderBy: { column: 'id', direction: 'asc' },
|
||||
setLoadingState: (loading: boolean) => setLoadingRoleUsers(loading),
|
||||
loadingKey: 'roleUsers',
|
||||
addDangerToast,
|
||||
@@ -218,7 +228,6 @@ function RoleListEditModal({
|
||||
value: user.id,
|
||||
label: user.username,
|
||||
}));
|
||||
|
||||
formRef.current.setFieldsValue({
|
||||
roleUsers: userOptions,
|
||||
});
|
||||
@@ -279,8 +288,8 @@ function RoleListEditModal({
|
||||
|
||||
const handleFormSubmit = async (values: RoleForm) => {
|
||||
try {
|
||||
const userIds = values.roleUsers?.map(user => user.value) || [];
|
||||
const permissionIds = mapSelectedIds(values.rolePermissions);
|
||||
const userIds = mapSelectedIds(values.roleUsers);
|
||||
const groupIds = mapSelectedIds(values.roleGroups);
|
||||
await Promise.all([
|
||||
updateRoleName(id, values.roleName),
|
||||
|
||||
@@ -28,6 +28,7 @@ interface FetchPaginatedOptions {
|
||||
setData: (data: any[]) => void;
|
||||
setLoadingState: Dispatch<SetStateAction<any>>;
|
||||
filters?: SupersetFilter[];
|
||||
orderBy?: { column: string; direction: 'asc' | 'desc' };
|
||||
loadingKey: string;
|
||||
addDangerToast: (message: string) => void;
|
||||
errorMessage?: string;
|
||||
@@ -38,6 +39,8 @@ interface QueryObj {
|
||||
page_size: number;
|
||||
page: number;
|
||||
filters?: SupersetFilter[];
|
||||
order_column?: string;
|
||||
order_direction?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
interface SupersetFilter {
|
||||
@@ -51,6 +54,7 @@ export const fetchPaginatedData = async ({
|
||||
pageSize = 100,
|
||||
setData,
|
||||
filters,
|
||||
orderBy,
|
||||
setLoadingState,
|
||||
loadingKey,
|
||||
addDangerToast,
|
||||
@@ -66,6 +70,10 @@ export const fetchPaginatedData = async ({
|
||||
if (filters) {
|
||||
queryObj.filters = filters;
|
||||
}
|
||||
if (orderBy) {
|
||||
queryObj.order_column = orderBy.column;
|
||||
queryObj.order_direction = orderBy.direction;
|
||||
}
|
||||
const encodedQuery = rison.encode(queryObj);
|
||||
|
||||
const response = await SupersetClient.get({
|
||||
|
||||
Reference in New Issue
Block a user