Skip to content

Commit 9cdebb0

Browse files
authored
Merge branch 'dev' into dev
2 parents 7a23cf8 + 4fe351e commit 9cdebb0

8 files changed

Lines changed: 238 additions & 8 deletions

File tree

.github/workflows/testing.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ jobs:
9494
test-typing:
9595
name: Typing Tests
9696
runs-on: ubuntu-latest
97+
if: github.ref_name != 'master'
9798
needs: build
9899
timeout-minutes: 30
99100
strategy:

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ This project adheres to [Semantic Versioning](https://semver.org/).
66

77
## Added
88
- [#3669](https://github.com/plotly/dash/pull/3669) Selection for DataTable cleared with custom action settings
9+
- [#3680](https://github.com/plotly/dash/pull/3680) Added `search_order` prop to `Dropdown` to allow users to preserve original option order during search
910

1011
## Added
1112
- [#3523](https://github.com/plotly/dash/pull/3523) Fall back to background callback function names if source cannot be found

components/dash-core-components/src/fragments/Dropdown.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ const Dropdown = (props: DropdownProps) => {
4141
setProps,
4242
searchable,
4343
search_value,
44+
search_order,
4445
style,
4546
value,
4647
} = props;
@@ -81,9 +82,9 @@ const Dropdown = (props: DropdownProps) => {
8182
const filteredOptions = useMemo(
8283
() =>
8384
searchable
84-
? filterOptions(sanitized, search_value)
85+
? filterOptions(sanitized, search_value, search_order)
8586
: sanitizedOptions,
86-
[sanitized, searchable, search_value]
87+
[sanitized, searchable, search_value, search_order]
8788
);
8889

8990
const sanitizedValues: OptionValue[] = useMemo(() => {

components/dash-core-components/src/types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -746,6 +746,13 @@ export interface DropdownProps extends BaseDccProps<DropdownProps> {
746746
* Use with `closeOnSelect=False`
747747
*/
748748
debounce?: boolean;
749+
750+
/**
751+
* The order in which to search results appear. 'index' (the default) means that
752+
* options are presented based on search relevance, while 'original' keeps the
753+
* order of options as they were originally provided.
754+
*/
755+
search_order?: 'index' | 'original';
749756
}
750757

751758
export interface ChecklistProps extends BaseDccProps<ChecklistProps> {

components/dash-core-components/src/utils/dropdownSearch.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,8 @@ export function sanitizeDropdownOptions(
6060

6161
export function filterOptions(
6262
options: SanitizedOptions,
63-
searchValue?: string
63+
searchValue?: string,
64+
search_order?: 'index' | 'original'
6465
): DetailedOption[] {
6566
if (!searchValue) {
6667
return options.options;
@@ -79,5 +80,13 @@ export function filterOptions(
7980
search.addDocuments(options.options);
8081
}
8182

82-
return (search.search(searchValue) as DetailedOption[]) || [];
83+
const searchResults =
84+
(search.search(searchValue) as DetailedOption[]) || [];
85+
86+
if (search_order === 'original') {
87+
const resultSet = new Set(searchResults);
88+
return options.options.filter(option => resultSet.has(option));
89+
}
90+
91+
return searchResults;
8392
}
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
from dash import Dash, html, dcc, Input, Output
2+
from selenium.webdriver.common.keys import Keys
3+
from selenium.webdriver.common.action_chains import ActionChains
4+
from time import sleep
5+
6+
7+
def test_ddso001_search_preserves_custom_order(dash_duo):
8+
app = Dash(__name__)
9+
10+
app.layout = html.Div(
11+
[
12+
dcc.Dropdown(
13+
id="dropdown",
14+
options=["11 Text", "12", "23", "112", "111", "110", "22"],
15+
searchable=True,
16+
search_order="original",
17+
),
18+
html.Div(id="output"),
19+
]
20+
)
21+
22+
dash_duo.start_server(app)
23+
24+
dropdown = dash_duo.find_element("#dropdown")
25+
dropdown.click()
26+
dash_duo.wait_for_element(".dash-dropdown-options")
27+
28+
# Search for '11'
29+
search_input = dash_duo.find_element(".dash-dropdown-search")
30+
search_input.send_keys("11")
31+
sleep(0.2)
32+
33+
# Presents matching options in original order
34+
options = dash_duo.find_elements(".dash-dropdown-option")
35+
assert len(options) == 4
36+
assert [opt.text for opt in options] == ["11 Text", "112", "111", "110"]
37+
38+
assert dash_duo.get_logs() == []
39+
40+
41+
def test_ddso002_multi_search_preserves_custom_order(dash_duo):
42+
def send_keys(key):
43+
ActionChains(dash_duo.driver).send_keys(key).perform()
44+
45+
app = Dash(__name__)
46+
app.layout = html.Div(
47+
[
48+
dcc.Dropdown(
49+
id="dropdown",
50+
options=["11 Text", "12", "112", "111", "110"],
51+
multi=True,
52+
searchable=True,
53+
search_order="original",
54+
),
55+
html.Div(id="output"),
56+
]
57+
)
58+
59+
@app.callback(Output("output", "children"), Input("dropdown", "value"))
60+
def update_output(value):
61+
return f"Selected: {value}"
62+
63+
dash_duo.start_server(app)
64+
65+
dropdown = dash_duo.find_element("#dropdown")
66+
dropdown.click()
67+
dash_duo.wait_for_element(".dash-dropdown-options")
68+
69+
# Select '12' (second option)
70+
send_keys(Keys.ARROW_DOWN)
71+
sleep(0.2)
72+
send_keys(Keys.ARROW_DOWN)
73+
sleep(0.2)
74+
send_keys(Keys.SPACE)
75+
dash_duo.wait_for_text_to_equal("#output", "Selected: ['12']")
76+
sleep(0.2)
77+
78+
# Select '111' (fourth option)
79+
send_keys(Keys.ARROW_DOWN)
80+
sleep(0.2)
81+
send_keys(Keys.ARROW_DOWN)
82+
sleep(0.2)
83+
send_keys(Keys.SPACE)
84+
dash_duo.wait_for_text_to_equal("#output", "Selected: ['12', '111']")
85+
sleep(0.2)
86+
87+
# Search for '1'
88+
send_keys(Keys.HOME)
89+
sleep(0.2)
90+
send_keys("1")
91+
sleep(0.2)
92+
93+
# Presents selected options first and rest in original order
94+
options = dash_duo.find_elements(".dash-dropdown-option")
95+
assert len(options) == 5
96+
assert [opt.text for opt in options] == ["12", "111", "11 Text", "112", "110"]
97+
98+
assert dash_duo.get_logs() == []
99+
100+
101+
def test_ddso003_search_preserves_custom_order_full_list(dash_duo):
102+
app = Dash(__name__)
103+
104+
app.layout = html.Div(
105+
[
106+
dcc.Dropdown(
107+
id="dropdown",
108+
options=["A", "Zebra", "Apply", "Apple"],
109+
searchable=True,
110+
search_order="original",
111+
),
112+
html.Div(id="output"),
113+
]
114+
)
115+
dash_duo.start_server(app)
116+
117+
dropdown = dash_duo.find_element("#dropdown")
118+
dropdown.click()
119+
120+
search_input = dash_duo.find_element(".dash-dropdown-search")
121+
122+
# Search for 'A', returns all options
123+
search_input.send_keys("A")
124+
sleep(0.2)
125+
126+
# Presents all options in original order
127+
options = dash_duo.find_elements(".dash-dropdown-option")
128+
assert len(options) == 4
129+
assert [opt.text for opt in options] == ["A", "Zebra", "Apply", "Apple"]
130+
131+
assert dash_duo.get_logs() == []
132+
133+
134+
def test_ddso004_search_no_match(dash_duo):
135+
app = Dash(__name__)
136+
137+
app = Dash(__name__)
138+
app.layout = html.Div(
139+
[
140+
dcc.Dropdown(
141+
id="dropdown",
142+
options=["11 Text", "12", "110", "111", "112"],
143+
searchable=True,
144+
search_order="original",
145+
),
146+
html.Div(id="output"),
147+
]
148+
)
149+
dash_duo.start_server(app)
150+
151+
dropdown = dash_duo.find_element("#dropdown")
152+
dropdown.click()
153+
154+
search_input = dash_duo.find_element(".dash-dropdown-search")
155+
156+
# Search for 'A', returns no options
157+
search_input.send_keys("A")
158+
sleep(0.2)
159+
160+
options = dash_duo.find_elements(".dash-dropdown-option")
161+
162+
assert len(options) == 1
163+
assert [opt.text for opt in options] == ["No options found"]
164+
assert dash_duo.get_logs() == []

components/dash-core-components/tests/integration/loading/test_loading_component.py

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from multiprocessing import Lock
2-
from dash import Dash, Input, Output, dcc, html
2+
from dash import Dash, Input, Output, dcc, html, MATCH, ALL
33
from dash.dependencies import stringify_id
44
from dash.testing import wait
55
import time
@@ -417,7 +417,7 @@ def updateDiv(n_clicks):
417417

418418
# update multiple props of same component, only targeted id/prop triggers spinner
419419
# test that target_components id can be a dict id
420-
def test_ldcp011_loading_component_target_components(dash_dcc):
420+
def test_ldcp010_loading_component_target_components(dash_dcc):
421421
lock = Lock()
422422

423423
app = Dash(__name__)
@@ -461,7 +461,7 @@ def updateDiv2(n_clicks):
461461

462462
dash_dcc.start_server(app)
463463

464-
btn1id = "#" + stringify_id({"type": "button", "index": "one"})
464+
btn1id = "[id='" + stringify_id({"type": "button", "index": "one"}) + "']"
465465

466466
dash_dcc.wait_for_text_to_equal(btn1id, "content 1")
467467

@@ -839,3 +839,50 @@ def update_btn1_children(n_clicks):
839839
assert spinners == []
840840

841841
assert dash_dcc.get_logs() == []
842+
843+
844+
# loading spinner triggers when callback Output uses ALL wildcard
845+
def test_ldcp019_loading_component_pattern_matching(dash_dcc):
846+
lock = Lock()
847+
848+
app = Dash(__name__)
849+
850+
app.layout = html.Div(
851+
[
852+
dcc.Loading(
853+
[
854+
html.Div(
855+
id={"type": "div-1", "index": 1, "name": "test"},
856+
className="div-1",
857+
)
858+
],
859+
className="loading",
860+
)
861+
],
862+
id={"type": "root", "index": 1, "name": "test"},
863+
className="root",
864+
)
865+
866+
@app.callback(
867+
Output({"type": "div-1", "index": ALL, "name": MATCH}, "children"),
868+
Input({"type": "root", "index": ALL, "name": MATCH}, "n_clicks"),
869+
)
870+
def updateDiv(n_clicks):
871+
if n_clicks == [1]:
872+
time.sleep(0.1)
873+
return ["changed"]
874+
return ["content"]
875+
876+
with lock:
877+
dash_dcc.start_server(app)
878+
dash_dcc.wait_for_text_to_equal(".div-1", "content")
879+
880+
dash_dcc.find_element(".root").click()
881+
882+
dash_dcc.find_element(".loading .dash-spinner")
883+
# mounted but hidden, so looks like no text
884+
dash_dcc.wait_for_text_to_equal(".div-1", "")
885+
886+
dash_dcc.wait_for_text_to_equal(".div-1", "changed")
887+
888+
assert dash_dcc.get_logs() == []

dash/dash-renderer/src/actions/callbacks.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -791,7 +791,7 @@ export function executeCallback(
791791
}
792792

793793
const __execute = async (): Promise<CallbackResult> => {
794-
const loadingOutputs = outputs.map(out => ({
794+
const loadingOutputs = flatten(outputs).map(out => ({
795795
path: getPath(paths, out.id),
796796
property: out.property?.split('@')[0],
797797
id: stringifyId(out.id)

0 commit comments

Comments
 (0)