Compare commits

..

6 Commits

Author SHA1 Message Date
Joe Li
76cc1d9cf9 fix(AsyncSelect): drop stale responses without poisoning fetch cache
Search responses no longer write to fetchedQueries — caching only the
totalCount made re-typing a previously-resolved search term short-
circuit the fetch and leave selectOptions stale (e.g. after restore-on-
clear had reset to the base list). Search refetches are cheap; only the
base accumulator benefits from the totalCount cache.

Late responses whose search arg no longer matches inputValueRef are
dropped, so a slow base or search fetch cannot pollute the dropdown
after the user has moved on. Base pages accumulate in initialOptionsRef
independently of selectOptions so restore-on-clear has a complete
snapshot even when base pages land during an active search.

Also cancel any pending debounced search fetch on clear so it cannot
fire after the base list has been restored.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 16:47:40 -07:00
Joe Li
d9385efa92 refactor(AsyncSelect): simplify search-restore tracking and tighten tests
Replace wasSearchingRef with usePrevious(inputValue), restructure the
fetchPage().then() branching so allValuesLoaded lives in the non-search
branch (removes the dead resultData variable), and harden the new tests
with disjoint datasets and negative assertions so they would fail against
the original merge-on-search bug.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 16:47:40 -07:00
Joe Li
089f23ac2d fix(AsyncSelect): preserve isNewOption entries and reset refs on options-prop change
Preserve optimistic isNewOption entries inserted by handleOnSearch when
the search fetch resolves, so allowNewOptions users can still pick the
value they typed when the server returns no match (regression seen via
SaveModal "Add to dashboard"). Also reset initialOptionsRef and
wasSearchingRef when the options loader changes, so loader swaps don't
briefly restore stale options.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 16:47:40 -07:00
sadpandajoe
b986e41581 fix(select): replace cached options with search results in AsyncSelect
When searching with >100 cached records, server search results were merged
with page-0 options instead of replacing them, burying matches at the end.
Also fixes filterOption=false falling through to return false (hiding all
options) instead of returning true (show all, server-side filtering).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 16:47:40 -07:00
Evan Rusackas
1230b9091b docs: hide Component Playground top-level nav item (#40247)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-05-19 09:32:53 -07:00
madhushreeag
852d0182b5 fix(roles): prevent 404 and silent user removal on large role edits (#40178)
Co-authored-by: madhushree agarwal <madhushree_agarwal@apple.com>
2026-05-19 09:13:43 -07:00
8 changed files with 394 additions and 182 deletions

View File

@@ -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.
- **2550 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.

View File

@@ -70,7 +70,7 @@
}
},
"components": {
"disabled": false,
"disabled": true,
"lastVersion": "current",
"includeCurrentVersion": true,
"onlyIncludeVersions": [

View File

@@ -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

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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',

View File

@@ -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),

View File

@@ -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({