Merge pull request #17806 from overleaf/rd-bootstrap-button2

[web] - Updating the Account Settings page with the Button and Icon Button wrappers

GitOrigin-RevId: 135c4ddaa64d009d3ab8cdfef9cff899fd77669c
This commit is contained in:
Rebeka Dekany 2024-04-16 17:06:42 +02:00 committed by Copybot
parent f6d5152a37
commit fa3f51fb2e
21 changed files with 211 additions and 151 deletions

View file

@ -1,11 +1,5 @@
import { useState } from 'react'
import {
Alert,
Button,
ControlLabel,
FormControl,
FormGroup,
} from 'react-bootstrap'
import { Alert, ControlLabel, FormControl, FormGroup } from 'react-bootstrap'
import { useTranslation } from 'react-i18next'
import {
getUserFacingMessage,
@ -15,6 +9,7 @@ import getMeta from '../../../utils/meta'
import { ExposedSettings } from '../../../../../types/exposed-settings'
import useAsync from '../../../shared/hooks/use-async'
import { useUserContext } from '../../../shared/context/user-context'
import ButtonWrapper from '@/features/ui/components/bootstrap-5/wrappers/button-wrapper'
function AccountInfoSection() {
const { t } = useTranslation()
@ -118,14 +113,17 @@ function AccountInfoSection() {
</FormGroup>
) : null}
{canUpdateEmail || canUpdateNames ? (
<Button
form="account-info-form"
<ButtonWrapper
type="submit"
bsStyle="primary"
disabled={isLoading || !isFormValid}
variant="primary"
form="account-info-form"
isLoading={isLoading || !isFormValid}
bs3Props={{
loading: isLoading ? `${t('saving')}` : t('update'),
}}
>
{isLoading ? <>{t('saving')}</> : t('update')}
</Button>
{t('update')}
</ButtonWrapper>
) : null}
</form>
</>

View file

@ -1,16 +1,22 @@
import { Button } from 'react-bootstrap'
import ButtonWrapper, {
ButtonWrapperProps,
} from '@/features/ui/components/bootstrap-5/wrappers/button-wrapper'
import { bsVersion } from '@/features/utils/bootstrap-5'
function PrimaryButton({ children, disabled, onClick }: Button.ButtonProps) {
function PrimaryButton({ children, disabled, onClick }: ButtonWrapperProps) {
return (
<Button
bsSize="small"
bsStyle={null}
className="btn-secondary-info btn-secondary"
<ButtonWrapper
size="small"
disabled={disabled}
onClick={onClick}
variant="secondary"
bs3Props={{ bsStyle: null }}
className={bsVersion({
bs3: 'btn-secondary btn-secondary-info',
})}
>
{children}
</Button>
</ButtonWrapper>
)
}

View file

@ -1,24 +1,38 @@
import Icon from '../../../../../shared/components/icon'
import { Button } from 'react-bootstrap'
import { useTranslation } from 'react-i18next'
import { UserEmailData } from '../../../../../../../types/user-email'
import { useUserEmailsContext } from '../../../context/user-email-context'
import { postJSON } from '../../../../../infrastructure/fetch-json'
import { UseAsyncReturnType } from '../../../../../shared/hooks/use-async'
import TooltipWrapper from '@/features/ui/components/bootstrap-5/wrappers/tooltip-wrapper'
import IconButtonWrapper, {
IconButtonWrapperProps,
} from '@/features/ui/components/bootstrap-5/wrappers/icon-button-wrapper'
import { bsVersion } from '@/features/utils/bootstrap-5'
function DeleteButton({ disabled, onClick }: Button.ButtonProps) {
type DeleteButtonProps = Pick<
IconButtonWrapperProps,
'disabled' | 'isLoading' | 'onClick'
>
function DeleteButton({ disabled, isLoading, onClick }: DeleteButtonProps) {
const { t } = useTranslation()
return (
<Button
bsSize="small"
bsStyle="danger"
<IconButtonWrapper
variant="danger"
disabled={disabled}
isLoading={isLoading}
size="small"
onClick={onClick}
>
<Icon type="trash" fw accessibilityLabel={t('remove')} />
</Button>
accessibilityLabel={t('remove') || ''}
icon={
bsVersion({
bs5: 'delete',
bs3: 'trash',
}) || 'trash'
}
bs3Props={{ fw: true }}
/>
)
}
@ -49,7 +63,7 @@ function Remove({ userEmailData, deleteEmailAsync }: RemoveProps) {
}
if (deleteEmailAsync.isLoading) {
return <DeleteButton disabled />
return <DeleteButton isLoading />
}
return (

View file

@ -18,7 +18,7 @@ import getMeta from '../../../../utils/meta'
import { ReCaptcha2 } from '../../../../shared/components/recaptcha-2'
import { useRecaptcha } from '../../../../shared/hooks/use-recaptcha'
import ColWrapper from '@/features/ui/components/bootstrap-5/wrappers/col-wrapper'
import { bsClassName } from '@/features/utils/bootstrap-5'
import { bsVersion } from '@/features/utils/bootstrap-5'
function AddEmail() {
const { t } = useTranslation()
@ -136,7 +136,7 @@ function AddEmail() {
<>
<label
htmlFor="affiliations-email"
className={bsClassName({ bs5: 'visually-hidden', bs3: 'sr-only' })}
className={bsVersion({ bs5: 'visually-hidden', bs3: 'sr-only' })}
>
{t('email')}
</label>
@ -162,7 +162,7 @@ function AddEmail() {
</ColWrapper>
<ColWrapper md={4}>
<Cell
className={bsClassName({
className={bsVersion({
bs5: 'text-md-end',
bs3: 'text-md-right',
})}
@ -207,7 +207,7 @@ function AddEmail() {
{!isSsoAvailableForDomain ? (
<ColWrapper md={4}>
<Cell
className={bsClassName({
className={bsVersion({
bs5: 'text-md-end',
bs3: 'text-md-right',
})}

View file

@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next'
import { useCombobox } from 'downshift'
import classnames from 'classnames'
import countries, { CountryCode } from '../../../data/countries-list'
import { bsClassName } from '@/features/utils/bootstrap-5'
import { bsVersion } from '@/features/utils/bootstrap-5'
type CountryInputProps = {
setValue: React.Dispatch<React.SetStateAction<CountryCode | null>>
@ -58,7 +58,7 @@ function Downshift({ setValue, inputRef }: CountryInputProps) {
{/* eslint-disable-next-line jsx-a11y/label-has-for */}
<label
{...getLabelProps()}
className={bsClassName({ bs5: 'visually-hidden', bs3: 'sr-only' })}
className={bsVersion({ bs5: 'visually-hidden', bs3: 'sr-only' })}
>
{t('country')}
</label>

View file

@ -2,7 +2,7 @@ import { useState, useEffect, forwardRef } from 'react'
import { useCombobox } from 'downshift'
import classnames from 'classnames'
import { escapeRegExp } from 'lodash'
import { bsClassName } from '@/features/utils/bootstrap-5'
import { bsVersion } from '@/features/utils/bootstrap-5'
type DownshiftInputProps = {
highlightMatches?: boolean
@ -92,7 +92,7 @@ function Downshift({
className={
showLabel
? ''
: bsClassName({ bs5: 'visually-hidden', bs3: 'sr-only' })
: bsVersion({ bs5: 'visually-hidden', bs3: 'sr-only' })
}
>
{label}

View file

@ -3,7 +3,7 @@ import EmailCell from './cell'
import ColWrapper from '@/features/ui/components/bootstrap-5/wrappers/col-wrapper'
import RowWrapper from '@/features/ui/components/bootstrap-5/wrappers/row-wrapper'
import classnames from 'classnames'
import { bsClassName } from '@/features/utils/bootstrap-5'
import { bsVersion } from '@/features/utils/bootstrap-5'
function Header() {
const { t } = useTranslation()
@ -13,7 +13,7 @@ function Header() {
<RowWrapper>
<ColWrapper
md={4}
className={bsClassName({
className={bsVersion({
bs5: 'd-none d-sm-block',
bs3: 'hidden-xs',
})}
@ -24,7 +24,7 @@ function Header() {
</ColWrapper>
<ColWrapper
md={8}
className={bsClassName({
className={bsVersion({
bs5: 'd-none d-sm-block',
bs3: 'hidden-xs',
})}
@ -36,13 +36,13 @@ function Header() {
</RowWrapper>
<div
className={classnames(
bsClassName({ bs5: 'd-none d-sm-block', bs3: 'hidden-xs' }),
bsVersion({ bs5: 'd-none d-sm-block', bs3: 'hidden-xs' }),
'horizontal-divider'
)}
/>
<div
className={classnames(
bsClassName({ bs5: 'd-none d-sm-block', bs3: 'hidden-xs' }),
bsVersion({ bs5: 'd-none d-sm-block', bs3: 'hidden-xs' }),
'horizontal-divider'
)}
/>

View file

@ -15,7 +15,7 @@ import ReconfirmationInfo from './reconfirmation-info'
import { useLocation } from '../../../../shared/hooks/use-location'
import RowWrapper from '@/features/ui/components/bootstrap-5/wrappers/row-wrapper'
import ColWrapper from '@/features/ui/components/bootstrap-5/wrappers/col-wrapper'
import { bsClassName } from '@/features/utils/bootstrap-5'
import { bsVersion } from '@/features/utils/bootstrap-5'
type EmailsRowProps = {
userEmailData: UserEmailData
@ -44,7 +44,7 @@ function EmailsRow({ userEmailData }: EmailsRowProps) {
</ColWrapper>
<ColWrapper md={3}>
<EmailCell
className={bsClassName({
className={bsVersion({
bs5: 'text-md-end',
bs3: 'text-md-right',
})}
@ -154,7 +154,7 @@ function SSOAffiliationInfo({ userEmailData }: SSOAffiliationInfoProps) {
</ColWrapper>
<ColWrapper
md={3}
className={bsClassName({
className={bsVersion({
bs5: 'text-md-end',
bs3: 'text-md-right',
})}

View file

@ -1,11 +1,5 @@
import { useEffect, useState } from 'react'
import {
Alert,
Button,
ControlLabel,
FormControl,
FormGroup,
} from 'react-bootstrap'
import { Alert, ControlLabel, FormControl, FormGroup } from 'react-bootstrap'
import { Trans, useTranslation } from 'react-i18next'
import {
getUserFacingMessage,
@ -16,6 +10,7 @@ import getMeta from '../../../utils/meta'
import { ExposedSettings } from '../../../../../types/exposed-settings'
import { PasswordStrengthOptions } from '../../../../../types/password-strength-options'
import useAsync from '../../../shared/hooks/use-async'
import ButtonWrapper from '@/features/ui/components/bootstrap-5/wrappers/button-wrapper'
type PasswordUpdateResult = {
message?: {
@ -198,14 +193,17 @@ function PasswordForm() {
</Alert>
</FormGroup>
) : null}
<Button
<ButtonWrapper
form="password-change-form"
type="submit"
bsStyle="primary"
variant="primary"
disabled={isLoading || !isFormValid}
bs3Props={{
loading: isLoading ? `${t('saving')}` : t('change'),
}}
>
{isLoading ? <>{t('saving')}</> : t('change')}
</Button>
{t('change')}
</ButtonWrapper>
</form>
)
}

View file

@ -1,7 +1,8 @@
import { Button as B5Button, Spinner } from 'react-bootstrap-5'
import { Button as BS5Button, Spinner } from 'react-bootstrap-5'
import type { ButtonProps } from '@/features/ui/components/types/button-props'
import classNames from 'classnames'
import { useTranslation } from 'react-i18next'
import MaterialIcon from '@/shared/components/material-icon'
const sizeClasses = new Map<ButtonProps['size'], string>([
['small', 'btn-sm'],
@ -12,8 +13,11 @@ const sizeClasses = new Map<ButtonProps['size'], string>([
export default function Button({
children,
className,
leadingIcon,
isLoading = false,
size = 'default',
trailingIcon,
variant = 'primary',
...props
}: ButtonProps) {
const { t } = useTranslation()
@ -24,9 +28,10 @@ export default function Button({
})
const loadingSpinnerClassName =
size === 'large' ? 'loading-spinner-large' : 'loading-spinner-small'
const materialIconClassName = size === 'large' ? 'icon-large' : 'icon-small'
return (
<B5Button className={buttonClassName} {...props}>
<BS5Button className={buttonClassName} variant={variant} {...props}>
{isLoading && (
<span className="spinner-container">
<Spinner
@ -40,8 +45,14 @@ export default function Button({
</span>
)}
<span className="button-content" aria-hidden={isLoading}>
{leadingIcon && (
<MaterialIcon type={leadingIcon} className={materialIconClassName} />
)}
{children}
{trailingIcon && (
<MaterialIcon type={trailingIcon} className={materialIconClassName} />
)}
</span>
</B5Button>
</BS5Button>
)
}

View file

@ -1,10 +1,10 @@
import React from 'react'
import {
Dropdown as B5Dropdown,
DropdownToggle as B5DropdownToggle,
DropdownMenu as B5DropdownMenu,
DropdownItem as B5DropdownItem,
DropdownDivider as B5DropdownDivider,
Dropdown as BS5Dropdown,
DropdownToggle as BS5DropdownToggle,
DropdownMenu as BS5DropdownMenu,
DropdownItem as BS5DropdownItem,
DropdownDivider as BS5DropdownDivider,
} from 'react-bootstrap-5'
import type {
DropdownProps,
@ -15,7 +15,7 @@ import type {
import MaterialIcon from '@/shared/components/material-icon'
export function Dropdown({ ...props }: DropdownProps) {
return <B5Dropdown {...props} />
return <BS5Dropdown {...props} />
}
export function DropdownItem({
@ -29,7 +29,7 @@ export function DropdownItem({
const trailingIconType = active ? 'check' : trailingIcon
return (
<li>
<B5DropdownItem
<BS5DropdownItem
active={active}
className={description ? 'dropdown-item-description-container' : ''}
role="menuitem"
@ -51,19 +51,19 @@ export function DropdownItem({
{description && (
<span className="dropdown-item-description">{description}</span>
)}
</B5DropdownItem>
</BS5DropdownItem>
</li>
)
}
export function DropdownToggle({ ...props }: DropdownToggleProps) {
return <B5DropdownToggle {...props} />
return <BS5DropdownToggle {...props} />
}
export function DropdownMenu({ as = 'ul', ...props }: DropdownMenuProps) {
return <B5DropdownMenu as={as} role="menubar" {...props} />
return <BS5DropdownMenu as={as} role="menubar" {...props} />
}
export function DropdownDivider() {
return <B5DropdownDivider aria-hidden="true" />
return <BS5DropdownDivider aria-hidden="true" />
}

View file

@ -1,7 +1,7 @@
import classNames from 'classnames'
import MaterialIcon from '@/shared/components/material-icon'
import Button from './button'
import type { IconButtonProps } from '@/features/ui/components/types/icon-button-props'
import classNames from 'classnames'
export default function IconButton({
accessibilityLabel,

View file

@ -1,26 +0,0 @@
import MaterialIcon from '@/shared/components/material-icon'
import { IconTextButtonProps } from '../types/icon-text-button-props'
import Button from './button'
export default function IconTextButton({
children,
className,
leadingIcon,
size = 'default',
trailingIcon,
...props
}: IconTextButtonProps) {
const materialIconClassName = size === 'large' ? 'icon-large' : 'icon-small'
return (
<Button size={size} {...props}>
{leadingIcon && (
<MaterialIcon type={leadingIcon} className={materialIconClassName} />
)}
{children}
{trailingIcon && (
<MaterialIcon type={trailingIcon} className={materialIconClassName} />
)}
</Button>
)
}

View file

@ -0,0 +1,49 @@
import BootstrapVersionSwitcher from '../bootstrap-version-switcher'
import { Button as BS3Button } from 'react-bootstrap'
import type { ButtonProps } from '@/features/ui/components/types/button-props'
import type { ButtonProps as BS3ButtonPropsBase } from 'react-bootstrap'
import Button from '../button'
export type ButtonWrapperProps = ButtonProps & {
bs3Props?: {
bsStyle?: string | null
loading?: React.ReactNode
}
}
// Resolve type mismatch of the onClick event handler
export type BS3ButtonProps = Omit<BS3ButtonPropsBase, 'onClick'> & {
onClick?: React.MouseEventHandler<any>
}
// maps Bootstrap 5 sizes to Bootstrap 3 sizes
export const mapBsButtonSizes = (
size: ButtonProps['size']
): 'sm' | 'lg' | undefined =>
size === 'small' ? 'sm' : size === 'large' ? 'lg' : undefined
export default function ButtonWrapper(props: ButtonWrapperProps) {
const { bs3Props, ...rest } = props
const bs3ButtonProps: BS3ButtonProps = {
bsStyle: rest.variant,
bsSize: mapBsButtonSizes(rest.size),
className: rest.className,
disabled: rest.isLoading || rest.disabled,
form: rest.form,
onClick: rest.onClick,
type: rest.type,
...bs3Props,
}
return (
<BootstrapVersionSwitcher
bs3={
<BS3Button {...bs3ButtonProps}>
{bs3Props?.loading || rest.children}
</BS3Button>
}
bs5={<Button {...rest} />}
/>
)
}

View file

@ -0,0 +1,45 @@
import { BS3ButtonProps, mapBsButtonSizes } from './button-wrapper'
import { Button as BS3Button } from 'react-bootstrap'
import type { IconButtonProps } from '@/features/ui/components/types/icon-button-props'
import BootstrapVersionSwitcher from '../bootstrap-version-switcher'
import Icon, { IconProps } from '@/shared/components/icon'
import IconButton from '../icon-button'
export type IconButtonWrapperProps = IconButtonProps & {
bs3Props?: {
loading?: React.ReactNode
fw?: IconProps['fw']
}
}
export default function IconButtonWrapper(props: IconButtonWrapperProps) {
const { bs3Props, ...rest } = props
const { fw, ...filterBs3Props } = bs3Props || {}
const bs3ButtonProps: BS3ButtonProps = {
bsStyle: rest.variant,
bsSize: mapBsButtonSizes(rest.size),
disabled: rest.isLoading || rest.disabled,
form: rest.form,
onClick: rest.onClick,
type: rest.type,
...filterBs3Props,
}
return (
<BootstrapVersionSwitcher
bs3={
<BS3Button {...bs3ButtonProps}>
{bs3Props?.loading}
<Icon
type={rest.icon}
fw={fw}
accessibilityLabel={rest.accessibilityLabel}
/>
</BS3Button>
}
bs5={<IconButton {...rest} />}
/>
)
}

View file

@ -4,10 +4,13 @@ export type ButtonProps = {
children?: ReactNode
className?: string
disabled?: boolean
form?: string
leadingIcon?: string
href?: string
isLoading?: boolean
onClick?: MouseEventHandler<HTMLButtonElement>
size?: 'small' | 'default' | 'large'
trailingIcon?: string
type?: 'button' | 'reset' | 'submit'
variant?:
| 'primary'

View file

@ -1,6 +0,0 @@
import { ButtonProps } from './button-props'
export type IconTextButtonProps = ButtonProps & {
leadingIcon?: string
trailingIcon?: string
}

View file

@ -2,6 +2,6 @@ import getMeta from '@/utils/meta'
export const isBootstrap5 = getMeta('ol-bootstrapVersion') === 5
export const bsClassName = ({ bs5, bs3 }: { bs5: string; bs3: string }) => {
export const bsVersion = ({ bs5, bs3 }: { bs5?: string; bs3?: string }) => {
return isBootstrap5 ? bs5 : bs3
}

View file

@ -1,5 +1,5 @@
import classNames from 'classnames'
import { bsClassName } from '@/features/utils/bootstrap-5'
import { bsVersion } from '@/features/utils/bootstrap-5'
type IconOwnProps = {
type: string
@ -36,9 +36,7 @@ function Icon({
<>
<i className={iconClassName} aria-hidden="true" {...rest} />
{accessibilityLabel && (
<span
className={bsClassName({ bs5: 'visually-hidden', bs3: 'sr-only' })}
>
<span className={bsVersion({ bs5: 'visually-hidden', bs3: 'sr-only' })}>
{accessibilityLabel}
</span>
)}

View file

@ -7,6 +7,18 @@ export const NewButton = (args: Args) => {
return <Button {...args} />
}
export const ButtonWithLeadingIcon = (args: Args) => {
return <Button leadingIcon="add" {...args} />
}
export const ButtonWithTrailingIcon = (args: Args) => {
return <Button trailingIcon="add" {...args} />
}
export const ButtonWithIcons = (args: Args) => {
return <Button trailingIcon="add" leadingIcon="add" {...args} />
}
const meta: Meta<typeof Button> = {
title: 'Shared / Components / Bootstrap 5 / Button',
component: Button,

View file

@ -1,42 +0,0 @@
import IconTextButton from '@/features/ui/components/bootstrap-5/icon-text-button'
import { Meta } from '@storybook/react'
type Args = React.ComponentProps<typeof IconTextButton>
export const IconText = (args: Args) => {
return <IconTextButton {...args} />
}
const meta: Meta<typeof IconTextButton> = {
title: 'Shared / Components / Bootstrap 5 / IconTextButton',
component: IconTextButton,
args: {
children: 'IconTextButton',
disabled: false,
isLoading: false,
leadingIcon: 'add',
trailingIcon: 'expand_more',
},
argTypes: {
size: {
control: 'radio',
options: ['small', 'default', 'large'],
},
variant: {
control: 'radio',
options: [
'primary',
'secondary',
'ghost',
'danger',
'danger-ghost',
'premium',
],
},
},
parameters: {
bootstrap5: true,
},
}
export default meta