mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-21 20:47:08 -05:00
Merge pull request #14585 from overleaf/msm-sso-config-modal
[web] SSO Config modal GitOrigin-RevId: e704afa61fe14390b64ce29a27ccbce7e884b396
This commit is contained in:
parent
4b72908940
commit
0f30edf69f
10 changed files with 502 additions and 6 deletions
|
@ -70,6 +70,7 @@ async function setupDb() {
|
|||
db.samlLogs = internalDb.collection('samlLogs')
|
||||
db.spellingPreferences = internalDb.collection('spellingPreferences')
|
||||
db.splittests = internalDb.collection('splittests')
|
||||
db.ssoConfigs = internalDb.collection('ssoConfigs')
|
||||
db.subscriptions = internalDb.collection('subscriptions')
|
||||
db.surveys = internalDb.collection('surveys')
|
||||
db.systemmessages = internalDb.collection('systemmessages')
|
||||
|
|
22
services/web/app/src/models/SSOConfig.js
Normal file
22
services/web/app/src/models/SSOConfig.js
Normal file
|
@ -0,0 +1,22 @@
|
|||
const mongoose = require('../infrastructure/Mongoose')
|
||||
const { Schema } = mongoose
|
||||
|
||||
const SSOConfigSchema = new Schema(
|
||||
{
|
||||
entryPoint: { type: String, required: true },
|
||||
certificate: { type: String, required: true },
|
||||
signatureAlgorithm: { type: String, required: true },
|
||||
userIdAttribute: { type: String, required: true },
|
||||
userFirstNameAttribute: { type: String },
|
||||
userLastNameAttribute: { type: String },
|
||||
enabled: { type: Boolean, default: false },
|
||||
},
|
||||
|
||||
{
|
||||
collection: 'ssoConfigs',
|
||||
minimize: false,
|
||||
}
|
||||
)
|
||||
|
||||
exports.SSOConfig = mongoose.model('SSOConfig', SSOConfigSchema)
|
||||
exports.SSOConfigSchema = SSOConfigSchema
|
|
@ -55,6 +55,7 @@ const SubscriptionSchema = new Schema(
|
|||
type: Date,
|
||||
},
|
||||
},
|
||||
ssoConfig: { type: ObjectId, ref: 'SSOConfig' },
|
||||
},
|
||||
{ minimize: false }
|
||||
)
|
||||
|
|
|
@ -127,6 +127,7 @@
|
|||
"category_operators": "",
|
||||
"category_relations": "",
|
||||
"center": "",
|
||||
"certificate": "",
|
||||
"change": "",
|
||||
"change_currency": "",
|
||||
"change_or_cancel-cancel": "",
|
||||
|
@ -178,6 +179,7 @@
|
|||
"compile_terminated_by_user": "",
|
||||
"compiler": "",
|
||||
"compiling": "",
|
||||
"configure_sso": "",
|
||||
"confirm": "",
|
||||
"confirm_affiliation": "",
|
||||
"confirm_affiliation_to_relink_dropbox": "",
|
||||
|
@ -293,6 +295,7 @@
|
|||
"edit_dictionary_empty": "",
|
||||
"edit_dictionary_remove": "",
|
||||
"edit_figure": "",
|
||||
"edit_sso_configuration": "",
|
||||
"edit_tag": "",
|
||||
"editing": "",
|
||||
"editing_captions": "",
|
||||
|
@ -311,6 +314,7 @@
|
|||
"enabling": "",
|
||||
"end_of_document": "",
|
||||
"enter_image_url": "",
|
||||
"entry_point": "",
|
||||
"error": "",
|
||||
"error_performing_request": "",
|
||||
"example_project": "",
|
||||
|
@ -995,6 +999,7 @@
|
|||
"showing_x_out_of_n_projects": "",
|
||||
"showing_x_results": "",
|
||||
"showing_x_results_of_total": "",
|
||||
"signature_algorithm": "",
|
||||
"single_sign_on_sso": "",
|
||||
"something_went_wrong_loading_pdf_viewer": "",
|
||||
"something_went_wrong_processing_the_request": "",
|
||||
|
@ -1007,6 +1012,11 @@
|
|||
"sort_by_x": "",
|
||||
"source": "",
|
||||
"spell_check": "",
|
||||
"sso_config_prop_help_certificate": "",
|
||||
"sso_config_prop_help_first_name": "",
|
||||
"sso_config_prop_help_last_name": "",
|
||||
"sso_config_prop_help_user_entry_point": "",
|
||||
"sso_config_prop_help_user_id": "",
|
||||
"sso_explanation": "",
|
||||
"sso_link_error": "",
|
||||
"start_by_adding_your_email": "",
|
||||
|
@ -1204,6 +1214,9 @@
|
|||
"used_when_referring_to_the_figure_elsewhere_in_the_document": "",
|
||||
"user_deletion_error": "",
|
||||
"user_deletion_password_reset_tip": "",
|
||||
"user_first_name_attribute": "",
|
||||
"user_id_attribute": "",
|
||||
"user_last_name_attribute": "",
|
||||
"user_sessions": "",
|
||||
"validation_issue_entry_description": "",
|
||||
"vat": "",
|
||||
|
|
|
@ -1,20 +1,38 @@
|
|||
/* eslint-disable jsx-a11y/label-has-for */
|
||||
/* eslint-disable jsx-a11y/label-has-associated-control */
|
||||
import { useRef, useEffect } from 'react'
|
||||
import classNames from 'classnames'
|
||||
import { useSelect } from 'downshift'
|
||||
import Icon from './icon'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
type SelectProps<T> = {
|
||||
export type SelectProps<T> = {
|
||||
// The items rendered as dropdown options.
|
||||
items: T[]
|
||||
itemToString: (item: T | null) => string
|
||||
// Stringifies an item of type T. The resulting string is rendered as a dropdown option.
|
||||
itemToString: (item: T | null | undefined) => string
|
||||
// Caption for the dropdown.
|
||||
label?: string
|
||||
// Attribute used to identify the component inside a Form. This name is used to
|
||||
// retrieve FormData when the form is submitted. The value of the FormData entry
|
||||
// is the string returned by `itemToString(selectedItem)`.
|
||||
name?: string
|
||||
// Hint text displayed in the initial render.
|
||||
defaultText?: string
|
||||
itemToSubtitle?: (item: T | null) => string
|
||||
// Initial selected item, displayed in the initial render. When both `defaultText`
|
||||
// and `defaultItem` are set the latter is ignored.
|
||||
defaultItem?: T
|
||||
// Stringifies an item. The resulting string is rendered as a subtitle in a dropdown option.
|
||||
itemToSubtitle?: (item: T | null | undefined) => string
|
||||
// Stringifies an item. The resulting string is rendered as a React `key` for each item.
|
||||
itemToKey: (item: T) => string
|
||||
// Callback invoked after the selected item is updated.
|
||||
onSelectedItemChanged?: (item: T | null | undefined) => void
|
||||
// When `true` item selection is disabled.
|
||||
disabled?: boolean
|
||||
// When `true` displays an "Optional" subtext after the `label` caption.
|
||||
optionalLabel?: boolean
|
||||
// When `true` displays a spinner next to the `label` caption.
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
|
@ -22,7 +40,9 @@ export const Select = <T,>({
|
|||
items,
|
||||
itemToString = item => (item === null ? '' : String(item)),
|
||||
label,
|
||||
name,
|
||||
defaultText = 'Items',
|
||||
defaultItem,
|
||||
itemToSubtitle,
|
||||
itemToKey,
|
||||
onSelectedItemChanged,
|
||||
|
@ -48,8 +68,37 @@ export const Select = <T,>({
|
|||
}
|
||||
},
|
||||
})
|
||||
|
||||
const rootRef = useRef<HTMLDivElement | null>(null)
|
||||
useEffect(() => {
|
||||
if (!name || !rootRef.current) return
|
||||
|
||||
const parentForm: HTMLFormElement | null | undefined =
|
||||
rootRef.current?.closest('form')
|
||||
if (!parentForm) return
|
||||
|
||||
function handleFormDataEvent(event: FormDataEvent) {
|
||||
const data = event.formData
|
||||
const key = name as string // can't be undefined due to early exit in the effect
|
||||
if (selectedItem || defaultItem) {
|
||||
data.append(key, itemToString(selectedItem || defaultItem))
|
||||
}
|
||||
}
|
||||
|
||||
parentForm.addEventListener('formdata', handleFormDataEvent)
|
||||
return () => {
|
||||
parentForm.removeEventListener('formdata', handleFormDataEvent)
|
||||
}
|
||||
}, [name, itemToString, selectedItem, defaultItem])
|
||||
|
||||
let value: string | undefined
|
||||
if (selectedItem || defaultItem) {
|
||||
value = itemToString(selectedItem || defaultItem)
|
||||
} else {
|
||||
value = defaultText
|
||||
}
|
||||
return (
|
||||
<div className="select-wrapper">
|
||||
<div className="select-wrapper" ref={rootRef}>
|
||||
<div>
|
||||
{label ? (
|
||||
<label {...getLabelProps()}>
|
||||
|
@ -59,14 +108,14 @@ export const Select = <T,>({
|
|||
({t('optional')})
|
||||
</span>
|
||||
)}{' '}
|
||||
{loading && <Icon fw type="spinner" spin />}
|
||||
{loading && <Icon data-testid="spinner" fw type="spinner" spin />}
|
||||
</label>
|
||||
) : null}
|
||||
<div
|
||||
className={classNames({ disabled }, 'select-trigger')}
|
||||
{...getToggleButtonProps({ disabled })}
|
||||
>
|
||||
<div>{selectedItem ? itemToString(selectedItem) : defaultText}</div>
|
||||
<div>{value}</div>
|
||||
<div>
|
||||
{isOpen ? (
|
||||
<Icon type="chevron-up" fw />
|
||||
|
|
|
@ -0,0 +1,39 @@
|
|||
import GroupSettings from '../../../../modules/managed-users/frontend/js/components/group-settings'
|
||||
import { useMeta } from '../../hooks/use-meta'
|
||||
|
||||
export const GroupSettingsWithManagedUsersDisabledAndNoSSOFeature = () => {
|
||||
useMeta({
|
||||
'ol-managedUsersEnabled': false,
|
||||
'ol-hasGroupSSOFeature': false,
|
||||
})
|
||||
return <GroupSettings />
|
||||
}
|
||||
|
||||
export const GroupSettingsWithManagedUsersDisabledAndSSOFeature = () => {
|
||||
useMeta({
|
||||
'ol-managedUsersEnabled': false,
|
||||
'ol-hasGroupSSOFeature': true,
|
||||
})
|
||||
return <GroupSettings />
|
||||
}
|
||||
|
||||
export const GroupSettingsWithManagedUsersEnabledAndNoSSOFeature = () => {
|
||||
useMeta({
|
||||
'ol-managedUsersEnabled': true,
|
||||
'ol-hasGroupSSOFeature': false,
|
||||
})
|
||||
return <GroupSettings />
|
||||
}
|
||||
|
||||
export const GroupSettingsWithManagedUsersEnabledAndSSOFeature = () => {
|
||||
useMeta({
|
||||
'ol-managedUsersEnabled': true,
|
||||
'ol-hasGroupSSOFeature': true,
|
||||
})
|
||||
return <GroupSettings />
|
||||
}
|
||||
|
||||
export default {
|
||||
title: 'Subscription / Managed Users',
|
||||
component: GroupSettings,
|
||||
}
|
|
@ -0,0 +1,88 @@
|
|||
import useFetchMock from '../../hooks/use-fetch-mock'
|
||||
import { useMeta } from '../../hooks/use-meta'
|
||||
import SSOConfigurationModal, {
|
||||
SSOConfigurationModalProps,
|
||||
} from '../../../../modules/managed-users/frontend/js/components/modals/sso-configuration-modal'
|
||||
|
||||
const config = {
|
||||
entryPoint: 'http://idp.example.com/entry_point',
|
||||
certificate:
|
||||
'MIIDXTCCAkWgAwIBAgIJAOvOeQ4xFTzsMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNVBAYTAkdCMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwHhcNMTYxMTE1MTQxMjU5WhcNMjYxMTE1MTQxMjU5WjBFMQswCQYDVQQGEwJHQjETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxCT6MBe5G9VoLU8MfztOEbUhnwLp17ak8eFUqxqeXkkqtWB0b/cmIBU3xoQoO3dIF8PBzfqehqfYVhrNt/TFgcmDfmJnPJRL1RJWMW3VmiP5odJ3LwlkKbZpkeT3wZ8HEJIR1+zbpxiBNkbd2GbdR1iumcsHzMYX1A2CBj+ZMV5VijC+K4P0e9c05VsDEUtLmfeAasJAiumQoVVgAe/BpiXjICGGewa6EPFI7mKkifIRKOGxdRESwZZjxP30bI31oDN0cgKqIgSJtJ9nfCn9jgBMBkQHu42WMuaWD4jrGd7+vYdX+oIfArs9aKgAH5kUGhGdew2R9SpBefrhbNxG8QIDAQABo1AwTjAdBgNVHQ4EFgQU+aSojSyyLChP/IpZcafvSdhj7KkwHwYDVR0jBBgwFoAU+aSojSyyLChP/IpZcafvSdhj7KkwDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEABl3+OOVLBWMKs6PjA8lPuloWDNzSr3v76oUcHqAb+cfbucjXrOVsS9RJ0X9yxvCQyfM9FfY43DbspnN3izYhdvbJD8kKLNf0LA5st+ZxLfy0ACyL2iyAwICaqndqxAjQYplFAHmpUiu1DiHckyBPekokDJd+ze95urHMOsaGS5RWPoKJVE0bkaAeZCmEu0NNpXRSBiuxXSTeSAJfv6kyE/rkdhzUKyUl/cGQFrsVYfAFQVA+W6CKOh74ErSEzSHQQYndl7nD33snD/YqdU1ROxV6aJzLKCg+sdj+wRXSP2u/UHnM4jW9TGJfhO42jzL6WVuEvr9q4l7zWzUQKKKhtQ==',
|
||||
signatureAlgorithm: 'sha256',
|
||||
userIdAttribute: 'email',
|
||||
}
|
||||
|
||||
export const ConfigurationModalLoadingError = (
|
||||
args: SSOConfigurationModalProps
|
||||
) => {
|
||||
useMeta({ 'ol-groupId': '123' })
|
||||
useFetchMock(fetchMock => {
|
||||
fetchMock.get(
|
||||
'express:/manage/groups/:id/settings/sso',
|
||||
{ status: 500 },
|
||||
{
|
||||
delay: 1000,
|
||||
}
|
||||
)
|
||||
fetchMock.post('express:/manage/groups/:id/settings/sso', 200, {
|
||||
delay: 500,
|
||||
})
|
||||
})
|
||||
return <SSOConfigurationModal {...args} />
|
||||
}
|
||||
|
||||
export const ConfigurationModal = (args: SSOConfigurationModalProps) => {
|
||||
useMeta({ 'ol-groupId': '123' })
|
||||
useFetchMock(fetchMock => {
|
||||
fetchMock.get('express:/manage/groups/:id/settings/sso', config, {
|
||||
delay: 500,
|
||||
})
|
||||
fetchMock.post('express:/manage/groups/:id/settings/sso', 200, {
|
||||
delay: 500,
|
||||
})
|
||||
})
|
||||
return <SSOConfigurationModal {...args} />
|
||||
}
|
||||
|
||||
export const ConfigurationModalEmpty = (args: SSOConfigurationModalProps) => {
|
||||
useMeta({ 'ol-groupId': '123' })
|
||||
useFetchMock(fetchMock => {
|
||||
fetchMock.get(
|
||||
'express:/manage/groups/:id/settings/sso',
|
||||
{},
|
||||
{
|
||||
delay: 500,
|
||||
}
|
||||
)
|
||||
fetchMock.post('express:/manage/groups/:id/settings/sso', 200, {
|
||||
delay: 500,
|
||||
})
|
||||
})
|
||||
return <SSOConfigurationModal {...args} />
|
||||
}
|
||||
|
||||
export const ConfigurationModalSaveError = (
|
||||
args: SSOConfigurationModalProps
|
||||
) => {
|
||||
useMeta({ 'ol-groupId': '123' })
|
||||
useFetchMock(fetchMock => {
|
||||
fetchMock.get('express:/manage/groups/:id/settings/sso', config, {
|
||||
delay: 500,
|
||||
})
|
||||
fetchMock.post('express:/manage/groups/:id/settings/sso', 500, {
|
||||
delay: 1000,
|
||||
})
|
||||
})
|
||||
return <SSOConfigurationModal {...args} />
|
||||
}
|
||||
|
||||
export default {
|
||||
title: 'Subscription / SSO',
|
||||
component: SSOConfigurationModal,
|
||||
args: {
|
||||
show: true,
|
||||
},
|
||||
argTypes: {
|
||||
handleHide: { action: 'close modal' },
|
||||
},
|
||||
}
|
|
@ -215,6 +215,7 @@
|
|||
"category_operators": "Operators",
|
||||
"category_relations": "Relations",
|
||||
"center": "Center",
|
||||
"certificate": "Certificate",
|
||||
"change": "Change",
|
||||
"change_currency": "Change currency",
|
||||
"change_or_cancel-cancel": "cancel",
|
||||
|
@ -286,6 +287,7 @@
|
|||
"compiler": "Compiler",
|
||||
"compiling": "Compiling",
|
||||
"complete": "Complete",
|
||||
"configure_sso": "Configure SSO",
|
||||
"confirm": "Confirm",
|
||||
"confirm_affiliation": "Confirm Affiliation",
|
||||
"confirm_affiliation_to_relink_dropbox": "Please confirm you are still at the institution and on their license, or upgrade your account in order to relink your Dropbox account.",
|
||||
|
@ -449,6 +451,7 @@
|
|||
"edit_dictionary_empty": "Your custom dictionary is empty.",
|
||||
"edit_dictionary_remove": "Remove from dictionary",
|
||||
"edit_figure": "Edit figure",
|
||||
"edit_sso_configuration": "Edit SSO Configuration",
|
||||
"edit_tag": "Edit Tag",
|
||||
"editing": "Editing",
|
||||
"editing_captions": "Editing captions",
|
||||
|
@ -489,6 +492,7 @@
|
|||
"enter_your_email_address": "Enter your email address",
|
||||
"enter_your_email_address_below_and_we_will_send_you_a_link_to_reset_your_password": "Enter your email address below, and we will send you a link to reset your password",
|
||||
"enter_your_new_password": "Enter your new password",
|
||||
"entry_point": "Entry point",
|
||||
"error": "Error",
|
||||
"error_performing_request": "An error has occurred while performing your request.",
|
||||
"es": "Spanish",
|
||||
|
@ -1565,6 +1569,7 @@
|
|||
"showing_x_results": "Showing __x__ results",
|
||||
"showing_x_results_of_total": "Showing __x__ results of __total__",
|
||||
"sign_up_now": "Sign Up Now",
|
||||
"signature_algorithm": "Signature Algorithm",
|
||||
"single_sign_on_sso": "Single Sign-On (SSO)",
|
||||
"single_version_easy_collab_blurb": "__appName__ makes sure that you’re always up to date with your collaborators and what they are doing. There is only a single master version of each document which everyone has access to. It’s impossible to make conflicting changes, and you don’t have to wait for your colleagues to send you the latest draft before you can keep working.",
|
||||
"site_description": "An online LaTeX editor that’s easy to use. No installation, real-time collaboration, version control, hundreds of LaTeX templates, and more.",
|
||||
|
@ -1591,6 +1596,11 @@
|
|||
"spell_check": "Spell check",
|
||||
"spread_the_word_and_fill_bar": "Spread the word and fill this bar up",
|
||||
"sso_account_already_linked": "Account already linked to another __appName__ user",
|
||||
"sso_config_prop_help_certificate": "Base64 encoded certificate without whitespace",
|
||||
"sso_config_prop_help_first_name": "Property in SAML assertion to use for first name",
|
||||
"sso_config_prop_help_last_name": "Property in SAML assertion to use for last name",
|
||||
"sso_config_prop_help_user_entry_point": "URL for SAML SSO redirect flow",
|
||||
"sso_config_prop_help_user_id": "Property in SAML assertion to use for unique id",
|
||||
"sso_explanation": "SAML 2.0 - based single sign-on gives your team members access to Overleaf through ADFS, Azure, Okta, OneLogin, or your custom Identity Provider. <0>Learn more</0>",
|
||||
"sso_integration": "SSO integration",
|
||||
"sso_integration_info": "Overleaf offers a standard SAML-based Single Sign On integration.",
|
||||
|
@ -1871,7 +1881,10 @@
|
|||
"user_already_added": "User already added",
|
||||
"user_deletion_error": "Sorry, something went wrong deleting your account. Please try again in a minute.",
|
||||
"user_deletion_password_reset_tip": "If you cannot remember your password, or if you are using Single-Sign-On with another provider to sign in (such as Twitter or Google), please <0>reset your password</0> and try again.",
|
||||
"user_first_name_attribute": "User First Name Attribute",
|
||||
"user_id_attribute": "User ID Attribute",
|
||||
"user_is_not_part_of_group": "User is not part of group",
|
||||
"user_last_name_attribute": "User Last Name Attribute",
|
||||
"user_management": "User management",
|
||||
"user_management_info": "Group plan admins have access to an admin panel where users can be added and removed easily. For site-wide plans, users are automatically upgraded when they register or add their email address to Overleaf (domain-based enrollment or SSO).",
|
||||
"user_not_found": "User not found",
|
||||
|
|
|
@ -21,6 +21,7 @@ class Subscription {
|
|||
this.teamInvites = options.teamInvites || []
|
||||
this.planCode = options.planCode
|
||||
this.recurlySubscription_id = options.recurlySubscription_id
|
||||
this.features = options.features
|
||||
}
|
||||
|
||||
ensureExists(callback) {
|
||||
|
|
269
services/web/test/frontend/components/shared/select.spec.tsx
Normal file
269
services/web/test/frontend/components/shared/select.spec.tsx
Normal file
|
@ -0,0 +1,269 @@
|
|||
import { useCallback, FormEvent } from 'react'
|
||||
import { Button, Form, FormControl } from 'react-bootstrap'
|
||||
import {
|
||||
Select,
|
||||
SelectProps,
|
||||
} from '../../../../frontend/js/shared/components/select'
|
||||
|
||||
const testData = [1, 2, 3].map(index => ({
|
||||
key: index,
|
||||
value: `Demo item ${index}`,
|
||||
sub: `Subtitle ${index}`,
|
||||
}))
|
||||
|
||||
type RenderProps = Partial<SelectProps<typeof testData[number]>> & {
|
||||
onSubmit?: (formData: object) => void
|
||||
}
|
||||
|
||||
function render(props: RenderProps) {
|
||||
const submitHandler = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
if (props.onSubmit) {
|
||||
const formData = new FormData(event.target as HTMLFormElement)
|
||||
// a plain object is more convenient to work later with assertions
|
||||
props.onSubmit(Object.fromEntries(formData.entries()))
|
||||
}
|
||||
}
|
||||
cy.mount(
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100vh',
|
||||
}}
|
||||
>
|
||||
<form onSubmit={submitHandler}>
|
||||
<Select
|
||||
items={testData}
|
||||
itemToString={x => String(x?.value)}
|
||||
label={props.label}
|
||||
name="select_control"
|
||||
defaultText={props.defaultText}
|
||||
defaultItem={props.defaultItem}
|
||||
itemToSubtitle={props.itemToSubtitle}
|
||||
itemToKey={x => String(x.key)}
|
||||
onSelectedItemChanged={props.onSelectedItemChanged}
|
||||
disabled={props.disabled}
|
||||
optionalLabel={props.optionalLabel}
|
||||
loading={props.loading}
|
||||
/>
|
||||
<button type="submit">submit</button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
describe('<Select />', function () {
|
||||
describe('initial rendering', function () {
|
||||
it('renders default text', function () {
|
||||
render({ defaultText: 'Choose an item' })
|
||||
cy.findByTestId('spinner').should('not.exist')
|
||||
cy.findByText('Choose an item')
|
||||
})
|
||||
|
||||
it('renders default item', function () {
|
||||
render({ defaultItem: testData[2] })
|
||||
cy.findByText('Demo item 3')
|
||||
})
|
||||
|
||||
it('default item takes precedence over default text', function () {
|
||||
render({ defaultText: 'Choose an item', defaultItem: testData[2] })
|
||||
cy.findByText('Demo item 3')
|
||||
})
|
||||
|
||||
it('renders label', function () {
|
||||
render({
|
||||
defaultText: 'Choose an item',
|
||||
label: 'test label',
|
||||
optionalLabel: false,
|
||||
})
|
||||
cy.findByText('test label')
|
||||
cy.findByText('(Optional)').should('not.exist')
|
||||
})
|
||||
|
||||
it('renders optional label', function () {
|
||||
render({
|
||||
defaultText: 'Choose an item',
|
||||
label: 'test label',
|
||||
optionalLabel: true,
|
||||
})
|
||||
cy.findByText('test label')
|
||||
cy.findByText('(Optional)')
|
||||
})
|
||||
|
||||
it('renders a spinner while loading when there is a label', function () {
|
||||
render({
|
||||
defaultText: 'Choose an item',
|
||||
label: 'test label',
|
||||
loading: true,
|
||||
})
|
||||
cy.findByTestId('spinner')
|
||||
})
|
||||
|
||||
it('does not render a spinner while loading if there is no label', function () {
|
||||
render({
|
||||
defaultText: 'Choose an item',
|
||||
loading: true,
|
||||
})
|
||||
cy.findByTestId('spinner').should('not.exist')
|
||||
})
|
||||
})
|
||||
|
||||
describe('items rendering', function () {
|
||||
it('renders all items', function () {
|
||||
render({ defaultText: 'Choose an item' })
|
||||
cy.findByText('Choose an item').click()
|
||||
|
||||
cy.findByText('Demo item 1')
|
||||
cy.findByText('Demo item 2')
|
||||
cy.findByText('Demo item 3')
|
||||
})
|
||||
|
||||
it('renders subtitles', function () {
|
||||
render({
|
||||
defaultText: 'Choose an item',
|
||||
itemToSubtitle: x => String(x?.sub),
|
||||
})
|
||||
cy.findByText('Choose an item').click()
|
||||
|
||||
cy.findByText('Subtitle 1')
|
||||
cy.findByText('Subtitle 2')
|
||||
cy.findByText('Subtitle 3')
|
||||
})
|
||||
})
|
||||
|
||||
describe('item selection', function () {
|
||||
it('cannot select an item when disabled', function () {
|
||||
render({ defaultText: 'Choose an item', disabled: true })
|
||||
cy.findByText('Choose an item').click()
|
||||
|
||||
cy.findByText('Demo item 1').should('not.exist')
|
||||
cy.findByText('Demo item 2').should('not.exist')
|
||||
cy.findByText('Demo item 3').should('not.exist')
|
||||
cy.findByText('Choose an item')
|
||||
})
|
||||
|
||||
it('renders only the selected item after selection', function () {
|
||||
render({ defaultText: 'Choose an item' })
|
||||
cy.findByText('Choose an item').click()
|
||||
|
||||
cy.findByText('Demo item 1')
|
||||
cy.findByText('Demo item 2')
|
||||
cy.findByText('Demo item 3').click()
|
||||
|
||||
cy.findByText('Choose an item').should('not.exist')
|
||||
cy.findByText('Demo item 1').should('not.exist')
|
||||
cy.findByText('Demo item 2').should('not.exist')
|
||||
cy.findByText('Demo item 3')
|
||||
})
|
||||
|
||||
it('invokes callback after selection', function () {
|
||||
const selectionHandler = cy.stub().as('selectionHandler')
|
||||
|
||||
render({
|
||||
defaultText: 'Choose an item',
|
||||
onSelectedItemChanged: selectionHandler,
|
||||
})
|
||||
cy.findByText('Choose an item').click()
|
||||
cy.findByText('Demo item 2').click()
|
||||
|
||||
cy.get('@selectionHandler').should(
|
||||
'have.been.calledOnceWith',
|
||||
testData[1]
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when the form is submitted', function () {
|
||||
it('populates FormData with the default selected item', function () {
|
||||
const submitHandler = cy.stub().as('submitHandler')
|
||||
render({ defaultItem: testData[1], onSubmit: submitHandler })
|
||||
|
||||
cy.findByText('submit').click()
|
||||
cy.get('@submitHandler').should('have.been.calledOnceWith', {
|
||||
select_control: 'Demo item 2',
|
||||
})
|
||||
})
|
||||
|
||||
it('populates FormData with the selected item', function () {
|
||||
const submitHandler = cy.stub().as('submitHandler')
|
||||
render({ defaultItem: testData[1], onSubmit: submitHandler })
|
||||
|
||||
cy.findByText('Demo item 2').click() // open dropdown
|
||||
cy.findByText('Demo item 3').click() // choose a different item
|
||||
|
||||
cy.findByText('submit').click()
|
||||
cy.get('@submitHandler').should('have.been.calledOnceWith', {
|
||||
select_control: 'Demo item 3',
|
||||
})
|
||||
})
|
||||
|
||||
it('does not populate FormData when no item is selected', function () {
|
||||
const submitHandler = cy.stub().as('submitHandler')
|
||||
render({ defaultText: 'Choose an item', onSubmit: submitHandler })
|
||||
|
||||
cy.findByText('submit').click()
|
||||
cy.get('@submitHandler').should('have.been.calledOnceWith', {})
|
||||
})
|
||||
})
|
||||
|
||||
describe('with react-bootstrap forms', function () {
|
||||
type FormWithSelectProps = {
|
||||
onSubmit: (formData: object) => void
|
||||
}
|
||||
|
||||
const FormWithSelect = ({ onSubmit }: FormWithSelectProps) => {
|
||||
const selectComponent = useCallback(
|
||||
() => (
|
||||
<Select
|
||||
name="select_control"
|
||||
items={testData}
|
||||
defaultItem={testData[0]}
|
||||
itemToString={x => String(x?.value)}
|
||||
itemToKey={x => String(x.key)}
|
||||
/>
|
||||
),
|
||||
[]
|
||||
)
|
||||
|
||||
function handleSubmit(event: FormEvent<Form>) {
|
||||
event.preventDefault()
|
||||
const formData = new FormData(event.target as HTMLFormElement)
|
||||
// a plain object is more convenient to work later with assertions
|
||||
onSubmit(Object.fromEntries(formData.entries()))
|
||||
}
|
||||
|
||||
return (
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<FormControl componentClass={selectComponent} />
|
||||
<Button type="submit">submit</Button>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
|
||||
it('populates FormData with the selected item when the form is submitted', function () {
|
||||
const submitHandler = cy.stub().as('submitHandler')
|
||||
cy.mount(
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100vh',
|
||||
}}
|
||||
>
|
||||
<FormWithSelect onSubmit={submitHandler} />
|
||||
</div>
|
||||
)
|
||||
|
||||
cy.findByText('Demo item 1').click() // open dropdown
|
||||
cy.findByText('Demo item 3').click() // choose a different item
|
||||
|
||||
cy.findByText('submit').click()
|
||||
cy.get('@submitHandler').should('have.been.calledOnceWith', {
|
||||
select_control: 'Demo item 3',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
Loading…
Reference in a new issue