Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix multiselect in ComboBox #161

Open
wants to merge 19 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 18 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 7 additions & 10 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ name: CI
on: push
permissions:
contents: read
env:
GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }}
jobs:
main:
name: Check, lint & test
Expand Down Expand Up @@ -33,6 +35,11 @@ jobs:
run: yarn install
working-directory: js

- name: Install shiny.react
run: |
Rscript -e 'install.packages("remotes")'
Rscript -e 'remotes::install_github("Appsilon/shiny.react@override-props", force = TRUE)'

- name: R CMD check
if: always()
uses: r-lib/actions/check-r-package@v2
Expand All @@ -54,16 +61,6 @@ jobs:
spec: cypress/integration/e2e-test/*.js
env:
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
- name: Run Cypress on example Dashboard
if: always()
uses: cypress-io/github-action@v5
with:
start: yarn start-e2e-router-app
working-directory: js
record: true
spec: cypress/integration/dashboard/*.js
env:
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}

- name: Test coverage
run: |
Expand Down
48 changes: 24 additions & 24 deletions inst/examples/e2e-test/R/fluentInputs.R
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ fluentInputsUI <- function(id) {
actionButton(ns("toggleInputs"), "Toggle visibility"),
wellPanel(
uiOutput(ns("panelFluent"))
)
)
)
}

Expand All @@ -41,88 +41,88 @@ fluentInputsServer <- function(id) {
observeEvent(input$toggleInputs, {
show(!show())
})

output$panelFluent <- renderUI({
if (!show()) return(strong("Items are hidden"))
div(
h4("Slider"),
Slider.shinyInput(ns("sliderInput"), value = 0, min = -100, max = 234),
textOutput(ns("sliderInputValue")),

h4("TextField"),
TextField.shinyInput(ns("textField")),
textOutput(ns("textFieldValue")),

h4("Checkbox"),
Checkbox.shinyInput(ns("checkbox"), value = FALSE),
textOutput(ns("checkboxValue")),

h4("Rating"),
Rating.shinyInput(ns("rating"), value = 2),
textOutput(ns("ratingValue")),

h4("SpinButton"),
SpinButton.shinyInput(ns("spinButton"), value = 15, min = 0, max = 50, step = 5),
textOutput(ns("spinButtonValue")),

h4("Calendar"),
Calendar.shinyInput(ns("calendar"), className = "calendar", value = "2020-06-25T12:00:00.000Z", strings = dayPickerStrings),
textOutput(ns("calendarValue")),

h4("Calendar - Default value"),
Calendar.shinyInput(ns("calendarDefault"), className = "calendarDefault", strings = dayPickerStrings),
textOutput(ns("calendarDefaultValue")),

h4("Calendar - NULL value"),
Calendar.shinyInput(ns("calendarNull"), className = "calendarNull", value = NULL, strings = dayPickerStrings),
textOutput(ns("calendarNullValue")),

h4("ChoiceGroup"),
ChoiceGroup.shinyInput(ns("choiceGroup"), value = "B", options = options),
textOutput(ns("choiceGroupValue")),

h4("ColorPicker"),
ColorPicker.shinyInput(ns("colorPicker"), value = "#00FF01"),
textOutput(ns("colorPickerValue")),

h4("ComboBox"),
ComboBox.shinyInput(ns("comboBox"), value = list(text = "some text"), options = options, allowFreeform = TRUE),
ComboBox.shinyInput(ns("comboBox"), value = "some text", options = options, allowFreeform = TRUE),
textOutput(ns("comboBoxValue")),

h4("Dropdown"),
Dropdown.shinyInput(ns("dropdown"), value = "A", options = options),
textOutput(ns("dropdownValue")),

h4("Dropdown - Multiselect"),
Dropdown.shinyInput(ns("dropdownMultiselect"), value = c("A", "C"), options = options, multiSelect = TRUE),
textOutput(ns("dropdownMultiselectValue")),

h4("DatePicker"),
DatePicker.shinyInput(ns("datePicker"), value = "2020-06-25T12:00:00.000Z", strings = dayPickerStrings),
textOutput(ns("datePickerValue")),

h4("DatePicker - Default value"),
DatePicker.shinyInput(ns("datePickerDefault"), strings = dayPickerStrings),
textOutput(ns("datePickerDefaultValue")),

h4("DatePicker - NULL value"),
DatePicker.shinyInput(ns("datePickerNull"), value = NULL, strings = dayPickerStrings, placeholder = "I am placeholder!"),
textOutput(ns("datePickerNullValue")),

h4("SwatchColorPicker"),
SwatchColorPicker.shinyInput(ns("swatchColorPicker"), value = "orange", colorCells = colorCells, columnCount = length(colorCells)),
textOutput(ns("swatchColorPickerValue")),

h4("Toggle"),
Toggle.shinyInput(ns("toggle"), value = TRUE),
textOutput(ns("toggleValue")),

h4("SearchBox"),
SearchBox.shinyInput(ns("searchBox"), placeholder = "Search"),
textOutput(ns("searchBoxValue"))
)
})

observeEvent(input$updateInputs, {
updateSlider.shinyInput(session, "sliderInput", value = input$sliderInput + 100)
updateTextField.shinyInput(session, "textField", value = paste0(input$textField, "new text"))
Expand All @@ -132,15 +132,15 @@ fluentInputsServer <- function(id) {
updateCalendar.shinyInput(session, "calendar", value = "2015-06-25T12:00:00.000Z")
updateChoiceGroup.shinyInput(session, "choiceGroup", value = "C")
updateColorPicker.shinyInput(session, "colorPicker", value = "#FFFFFF")
updateComboBox.shinyInput(session, "comboBox", value = options[[2]])
updateComboBox.shinyInput(session, "comboBox", value = "C")
updateDropdown.shinyInput(session, "dropdown", value = "C")
updateDropdown.shinyInput(session, "dropdownMultiselect", options = updatedOptions, value = c("X", "Z"))
updateDropdown.shinyInput(session, "dropdownMultiselect", options = updatedOptions, value = c("A", "B"))
updateCalendar.shinyInput(session, "datePicker", value = "2015-06-25T12:00:00.000Z")
updateSwatchColorPicker.shinyInput(session, "swatchColorPicker", value = "white")
updateToggle.shinyInput(session, "toggle", value = FALSE)
updateSearchBox.shinyInput(session, "searchBox", value = "query")
})

ids <- c(
"sliderInput", "textField", "checkbox", "rating", "spinButton", "calendar", "calendarDefault", "calendarNull", "choiceGroup",
"colorPicker", "comboBox", "dropdown", "dropdownMultiselect", "datePicker", "datePickerDefault", "datePickerNull", "swatchColorPicker", "toggle", "searchBox"
Expand Down
2 changes: 1 addition & 1 deletion inst/www/shiny.fluent/shiny-fluent.js

Large diffs are not rendered by default.

30 changes: 1 addition & 29 deletions js/cypress/integration/e2e-test/fluentComponents.js
Original file line number Diff line number Diff line change
Expand Up @@ -183,21 +183,6 @@ function dropdownChangeTest() {
cy.get('#fluentInputs-dropdownValue').contains('Value: C');
}

function dropdownMultiselectDefaultTest(value = 'Option A, Option C', output = 'Value: A Value: C') {
cy.get('#fluentInputs-dropdownMultiselect').within(() => {
cy.get('#fluentInputs-dropdownMultiselect-option').should('contain', `${value}`);
});
cy.get('#fluentInputs-dropdownMultiselectValue').should('contain', `${output}`);
}

function dropdownMultiselectChangeTest() {
cy.get('#fluentInputs-dropdownMultiselect-option').click();
cy.get('#fluentInputs-dropdownMultiselect-list0').parent().click();
cy.get('#fluentInputs-dropdownMultiselect-list1').parent().click();
cy.get('#fluentInputs-dropdownMultiselect-option').should('contain', 'Option C, Option B');
cy.get('#fluentInputs-dropdownMultiselectValue').contains('Value: C Value: B');
}

function datePickerDefaultTest(date = 'Thu Jun 25 2020', dttm = '2020-06-25T12:00:00.000Z') {
cy.get('#fluentInputs-datePicker-label').should('have.attr', 'value', date);
cy.get('#fluentInputs-datePickerValue').should('contain', `Value: ${dttm}`);
Expand Down Expand Up @@ -357,14 +342,6 @@ describe('Dropdown.shinyInput()', () => {
it('value change works', () => {
dropdownChangeTest();
});

it('setting default values for multiSelect works', () => {
dropdownMultiselectDefaultTest();
});

it('updating multiSelect options and values works', () => {
dropdownMultiselectChangeTest();
});
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why did you remove these tests? They work as intended

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, I started observing some intermittency in those tests, which I don't quite understand. It seems that now it takes a bit longer to spin up the test app and that's why some fail because components are not yet rendered. For this multiselect when I run it interactively everything works fine, but on the whole Cypress run it tends to fail -- I have big troubles debugging it.

I'd prefer to refactor those tests to be more isolated, as all components being put in one big app seems to be the root cause of this intermittency. But for now I'm a bit stuck with this test, I'll give it one more try

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm confused @averissimo… Why it passed now when if didn't on multiple runs previously?

I restored multiselect tests, but since it required some changes in shiny.react, here is a PR that enables this fix, PTAL.

Copy link
Contributor

@averissimo averissimo Nov 13, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with refactoring. But that's out of scope for this PR 😁

The main issue I found was that it was running the test sequentially and keeping the state between tests. (hence the beforeEach(() => cy.reload()) I had on my diff.

I've approved the Appsilon/shiny.react#75


describe('DatePicker.shinyInput()', () => {
Expand Down Expand Up @@ -465,7 +442,6 @@ describe('Reset after toggled visibility', () => {
dropdownChangeTest();
toggleVisibility();
dropdownDefaultTest();
dropdownMultiselectChangeTest();
});

it('SwatchColorPicker.shinyInput() works', () => {
Expand Down Expand Up @@ -525,17 +501,13 @@ describe('Update from server works', () => {
});

it('ComboBox.shinyInput() works', () => {
comboBoxDefaultTest('Option B', 'B');
comboBoxDefaultTest('Option C', 'C');
});

it('Dropdown.shinyInput() works', () => {
dropdownDefaultTest('Option C', 'C');
});

it('Dropdown.shinyInput() works for multiSelects', () => {
dropdownMultiselectDefaultTest('Option X, Option Z', 'Value: X Value: Z');
});

it('DatePicker.shinyInput() works', () => {
datePickerDefaultTest('Thu Jun 25 2015', '2015-06-25T12:00:00.000Z');
});
Expand Down
54 changes: 42 additions & 12 deletions js/src/inputs.jsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,26 @@
import * as Fluent from '@fluentui/react';
import { ButtonAdapter, InputAdapter, debounce } from '@/shiny.react';
import { useState } from 'react';

function handleMultiSelect(option, selectedKeys, propsOptions) {
const options = new Set(propsOptions.map((item) => item.key));
let currentSelectedOptionKeys = (Array.isArray(selectedKeys) ? selectedKeys : [selectedKeys])
.filter((key) => options.has(key)); // Some options might have been removed.
// If option doesn't have selected property it comes from freeform, so it's selected by default.
const selected = option.selected ?? true;
currentSelectedOptionKeys = selected
? [...currentSelectedOptionKeys, option.key]
: currentSelectedOptionKeys.filter((key) => key !== option.key);
return currentSelectedOptionKeys;
}

function getSelectedOptionText(options, value, delimiter = ', ') {
const selectedKeys = new Set(value);
return options
.filter((option) => selectedKeys.has(option.key))
.map((option) => option?.text)
.join(delimiter);
}

export const ActionButton = ButtonAdapter(Fluent.ActionButton);
export const CommandBarButton = ButtonAdapter(Fluent.CommandBarButton);
Expand Down Expand Up @@ -29,11 +50,26 @@ export const ColorPicker = InputAdapter(Fluent.ColorPicker, (value, setValue) =>
onChange: (e, v) => setValue(v.str),
}), { policy: debounce, delay: 250 });

export const ComboBox = InputAdapter(Fluent.ComboBox, (value, setValue) => ({
selectedKey: value && value.key,
text: value && value.text,
onChange: (e, option, i, text) => setValue(option || (text ? { text } : null)),
}), { policy: debounce, delay: 250 });
export const ComboBox = InputAdapter(Fluent.ComboBox, (value, setValue, props) => {
const [options, setOptions] = useState(props.options);
return ({
selectedKey: value,
text: getSelectedOptionText(options, value, props.multiSelectDelimiter || ', ') || value,
Comment on lines +56 to +57
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we keep the list(text = "some text") parameters?

Suggested change
selectedKey: value,
text: getSelectedOptionText(options, value, props.multiSelectDelimiter || ', ') || value,
selectedKey: value?.key || value?.text || value,
text: getSelectedOptionText(options, value, props.multiSelectDelimiter || ', ') || value?.text || value,

options,
onChange: (_event, option, _index, text) => {
let key = option?.key || text;
const newOption = option || { key, text };
// Add new option if freeform is allowed
if (props?.allowFreeform && !option && text) {
setOptions((prevOptions) => [...prevOptions, newOption]);
}
if (props.multiSelect) {
key = handleMultiSelect(newOption, value, options);
}
setValue(key);
},
});
}, { policy: debounce, delay: 250 });

export const DatePicker = InputAdapter(Fluent.DatePicker, (value, setValue) => ({
value: value ? new Date(value) : undefined,
Expand All @@ -45,13 +81,7 @@ export const Dropdown = InputAdapter(Fluent.Dropdown, (value, setValue, props) =
selectedKey: value,
onChange: (e, v) => {
if (props.multiSelect) {
const options = new Set(props.options.map((item) => item.key));
let newValue = (Array.isArray(value) ? value : [value])
.filter((key) => options.has(key)); // Some options might have been removed.
newValue = v.selected
? [...newValue, v.key]
: newValue.filter((key) => key !== v.key);
setValue(newValue);
setValue(handleMultiSelect(v, value, props.options));
} else {
setValue(v.key);
}
Expand Down
6 changes: 3 additions & 3 deletions js/yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1765,9 +1765,9 @@ callsites@^3.0.0:
integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==

caniuse-lite@^1.0.30001173:
version "1.0.30001272"
resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001272.tgz"
integrity sha512-DV1j9Oot5dydyH1v28g25KoVm7l8MTxazwuiH3utWiAS6iL/9Nh//TGwqFEeqqN8nnWYQ8HHhUq+o4QPt9kvYw==
version "1.0.30001534"
resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001534.tgz"
integrity sha512-vlPVrhsCS7XaSh2VvWluIQEzVhefrUQcEsQWSS5A5V+dM07uv1qHeQzAOTGIMy9i3e9bH15+muvI/UHojVgS/Q==

caseless@~0.12.0:
version "0.12.0"
Expand Down