Просмотр исходного кода

Merge pull request #19126 from overleaf/td-bs5-contact-modal-react

React version of Contact Us modal

GitOrigin-RevId: 0bef3095f36daa88afdc6172a5531ed11e892047
Tim Down 2 лет назад
Родитель
Сommit
34fc43d59a
33 измененных файлов с 484 добавлено и 210 удалено
  1. 5 0
      services/web/frontend/extracted-translations.json
  2. 7 5
      services/web/frontend/js/features/algolia-search/search-wiki.js
  3. 1 1
      services/web/frontend/js/features/contact-form/search.js
  4. 17 13
      services/web/frontend/js/features/settings/components/account-info-section.tsx
  5. 14 12
      services/web/frontend/js/features/settings/components/password-section.tsx
  6. 7 3
      services/web/frontend/js/features/ui/components/bootstrap-5/dropdown-menu.tsx
  7. 1 1
      services/web/frontend/js/features/ui/components/bootstrap-5/form/form-control.tsx
  8. 20 0
      services/web/frontend/js/features/ui/components/bootstrap-5/form/form-feedback.tsx
  9. 31 53
      services/web/frontend/js/features/ui/components/bootstrap-5/form/form-text.tsx
  10. 3 3
      services/web/frontend/js/features/ui/components/ol/ol-form-checkbox.tsx
  11. 12 8
      services/web/frontend/js/features/ui/components/ol/ol-form-control.tsx
  12. 38 0
      services/web/frontend/js/features/ui/components/ol/ol-form-feedback.tsx
  13. 8 5
      services/web/frontend/js/features/ui/components/ol/ol-form-group.tsx
  14. 45 0
      services/web/frontend/js/features/ui/components/ol/ol-form-select.tsx
  15. 4 11
      services/web/frontend/js/features/ui/components/ol/ol-form-text.tsx
  16. 22 7
      services/web/frontend/js/features/ui/components/ol/ol-form.tsx
  17. 2 2
      services/web/frontend/js/features/ui/components/ol/ol-modal.tsx
  18. 2 1
      services/web/frontend/js/features/ui/components/types/dropdown-menu-props.ts
  19. 10 2
      services/web/frontend/js/shared/hooks/use-contact-us-modal.tsx
  20. 0 37
      services/web/frontend/stories/contact-us-modal.stories.jsx
  21. 93 0
      services/web/frontend/stories/contact-us-modal.stories.tsx
  22. 13 12
      services/web/frontend/stories/ui/form/form-input-bs5.stories.tsx
  23. 12 12
      services/web/frontend/stories/ui/form/form-select-bs5.stories.tsx
  24. 12 12
      services/web/frontend/stories/ui/form/form-textarea-bs5.stories.tsx
  25. 1 0
      services/web/frontend/stylesheets/bootstrap-5/abstracts/variable-overrides.scss
  26. 3 0
      services/web/frontend/stylesheets/bootstrap-5/abstracts/variables.scss
  27. 1 1
      services/web/frontend/stylesheets/bootstrap-5/components/form.scss
  28. 0 9
      services/web/frontend/stylesheets/bootstrap-5/components/modal.scss
  29. 6 0
      services/web/frontend/stylesheets/bootstrap-5/main-style.scss
  30. 1 0
      services/web/frontend/stylesheets/bootstrap-5/modals/all.scss
  31. 82 0
      services/web/frontend/stylesheets/bootstrap-5/modals/contact-us-modal.scss
  32. 9 0
      services/web/frontend/stylesheets/components/forms.less
  33. 2 0
      services/web/locales/en.json

+ 5 - 0
services/web/frontend/extracted-translations.json

@@ -555,6 +555,7 @@
   "have_more_days_to_try": "",
   "headers": "",
   "help": "",
+  "help_articles_matching": "",
   "help_improve_overleaf_fill_out_this_survey": "",
   "help_improve_screen_reader_fill_out_this_survey": "",
   "hide_configuration": "",
@@ -686,10 +687,12 @@
   "joined_team": "",
   "joining": "",
   "justify": "",
+  "kb_suggestions_enquiry": "",
   "keep_current_plan": "",
   "keep_personal_projects_separate": "",
   "keep_your_account_safe_add_another_email": "",
   "keybindings": "",
+  "knowledge_base": "",
   "labels_help_you_to_easily_reference_your_figures": "",
   "labels_help_you_to_reference_your_tables": "",
   "language_feedback": "",
@@ -976,6 +979,8 @@
   "please_enter_confirmation_code": "",
   "please_get_in_touch": "",
   "please_link_before_making_primary": "",
+  "please_provide_a_message": "",
+  "please_provide_a_subject": "",
   "please_reconfirm_institutional_email": "",
   "please_reconfirm_your_affiliation_before_making_this_primary": "",
   "please_refresh": "",

+ 7 - 5
services/web/frontend/js/features/algolia-search/search-wiki.js

@@ -24,10 +24,12 @@ export function formatWikiHit(hit) {
   const pagePath = hit.kb ? 'how-to' : 'latex'
 
   let pageAnchor = ''
-  let pageName = hit._highlightResult.pageName.value
-  if (hit.sectionName) {
-    pageAnchor = `#${hit.sectionName.replace(/\s/g, '_')}`
-    pageName += ' - ' + hit.sectionName
+  const rawPageName = hit._highlightResult.pageName.value
+  const sectionName = hit.sectionName
+  let pageName = rawPageName
+  if (sectionName) {
+    pageAnchor = `#${sectionName.replace(/\s/g, '_')}`
+    pageName += ' - ' + sectionName
   }
 
   const body = hit._highlightResult.content.value
@@ -37,5 +39,5 @@ export function formatWikiHit(hit) {
     .join('\n...\n')
 
   const url = `/learn/${pagePath}/${pageSlug}${pageAnchor}`
-  return { url, pageName, content }
+  return { url, pageName, rawPageName, sectionName, content }
 }

+ 1 - 1
services/web/frontend/js/features/contact-form/search.js

@@ -48,7 +48,7 @@ export function setupSearch(formEl) {
         const iconEl = document.createElement('i')
         iconEl.className = 'fa fa-angle-right'
         iconEl.setAttribute('aria-hidden', 'true')
-        linkEl.append(contentEl)
+        linkEl.append(iconEl)
 
         resultsEl.append(liEl)
       }

+ 17 - 13
services/web/frontend/js/features/settings/components/account-info-section.tsx

@@ -118,18 +118,20 @@ function AccountInfoSection() {
           </OLFormGroup>
         ) : null}
         {canUpdateEmail || canUpdateNames ? (
-          <OLButton
-            type="submit"
-            variant="primary"
-            form="account-info-form"
-            disabled={!isFormValid}
-            isLoading={isLoading}
-            bs3Props={{
-              loading: isLoading ? `${t('saving')}…` : t('update'),
-            }}
-          >
-            {t('update')}
-          </OLButton>
+          <OLFormGroup>
+            <OLButton
+              type="submit"
+              variant="primary"
+              form="account-info-form"
+              disabled={!isFormValid}
+              isLoading={isLoading}
+              bs3Props={{
+                loading: isLoading ? `${t('saving')}…` : t('update'),
+              }}
+            >
+              {t('update')}
+            </OLButton>
+          </OLFormGroup>
         ) : null}
       </form>
     </>
@@ -188,7 +190,9 @@ function ReadOrWriteFormGroup({
         onChange={handleChangeAndValidity}
         onInvalid={handleInvalid}
       />
-      {validationMessage && <FormText isError>{validationMessage}</FormText>}
+      {validationMessage && (
+        <FormText type="error">{validationMessage}</FormText>
+      )}
     </OLFormGroup>
   )
 }

+ 14 - 12
services/web/frontend/js/features/settings/components/password-section.tsx

@@ -196,18 +196,20 @@ function PasswordForm() {
           />
         </OLFormGroup>
       ) : null}
-      <OLButton
-        form="password-change-form"
-        type="submit"
-        variant="primary"
-        disabled={!isFormValid}
-        isLoading={isLoading}
-        bs3Props={{
-          loading: isLoading ? `${t('saving')}…` : t('change'),
-        }}
-      >
-        {t('change')}
-      </OLButton>
+      <OLFormGroup>
+        <OLButton
+          form="password-change-form"
+          type="submit"
+          variant="primary"
+          disabled={!isFormValid}
+          isLoading={isLoading}
+          bs3Props={{
+            loading: isLoading ? `${t('saving')}…` : t('change'),
+          }}
+        >
+          {t('change')}
+        </OLButton>
+      </OLFormGroup>
     </form>
   )
 }

+ 7 - 3
services/web/frontend/js/features/ui/components/bootstrap-5/dropdown-menu.tsx

@@ -91,9 +91,13 @@ export function DropdownToggle({ ...props }: DropdownToggleProps) {
   return <BS5DropdownToggle {...props} />
 }
 
-export function DropdownMenu({ as = 'ul', ...props }: DropdownMenuProps) {
-  return <BS5DropdownMenu as={as} role="menu" {...props} />
-}
+export const DropdownMenu = forwardRef<
+  typeof BS5DropdownMenu,
+  DropdownMenuProps
+>(({ as = 'ul', ...props }, ref) => {
+  return <BS5DropdownMenu as={as} role="menu" {...props} ref={ref} />
+})
+DropdownMenu.displayName = 'DropdownMenu'
 
 export function DropdownDivider({ as = 'li' }: DropdownDividerProps) {
   return <BS5DropdownDivider as={as} />

+ 1 - 1
services/web/frontend/js/features/ui/components/bootstrap-5/form/form-control.tsx

@@ -36,7 +36,7 @@ const FormControl = forwardRef<HTMLInputElement, OLFormControlProps>(
       )
     }
 
-    return <Form.Control className={className} {...props} />
+    return <Form.Control ref={ref} className={className} {...props} />
   }
 )
 FormControl.displayName = 'FormControl'

+ 20 - 0
services/web/frontend/js/features/ui/components/bootstrap-5/form/form-feedback.tsx

@@ -0,0 +1,20 @@
+import { Form } from 'react-bootstrap-5'
+import FormText from '@/features/ui/components/bootstrap-5/form/form-text'
+import { ComponentProps } from 'react'
+
+export type FormFeedbackProps = Pick<
+  ComponentProps<typeof Form.Control.Feedback>,
+  'type' | 'className' | 'children'
+>
+
+function FormFeedback(props: FormFeedbackProps) {
+  return (
+    <Form.Control.Feedback {...props}>
+      <FormText type={props.type === 'invalid' ? 'error' : 'success'}>
+        {props.children}
+      </FormText>
+    </Form.Control.Feedback>
+  )
+}
+
+export default FormFeedback

+ 31 - 53
services/web/frontend/js/features/ui/components/bootstrap-5/form/form-text.tsx

@@ -1,71 +1,49 @@
 import { Form } from 'react-bootstrap-5'
-import { MergeAndOverride } from '../../../../../../../types/utils'
 import MaterialIcon from '@/shared/components/material-icon'
 import classnames from 'classnames'
 
-type FormTextProps = MergeAndOverride<
-  React.ComponentProps<(typeof Form)['Text']>,
-  | {
-      isInfo?: boolean
-      isError?: never
-      isWarning?: never
-      isSuccess?: never
-    }
-  | {
-      isInfo?: never
-      isError?: boolean
-      isWarning?: never
-      isSuccess?: never
-    }
-  | {
-      isInfo?: never
-      isError?: never
-      isWarning?: boolean
-      isSuccess?: never
-    }
-  | {
-      isInfo?: never
-      isError?: never
-      isWarning?: never
-      isSuccess?: boolean
-    }
->
+type TextType = 'default' | 'info' | 'success' | 'warning' | 'error'
 
-export const getFormTextColor = ({
-  isError,
-  isSuccess,
-  isWarning,
-}: {
-  isError?: boolean
-  isSuccess?: boolean
-  isWarning?: boolean
-}) => ({
-  'text-danger': isError,
-  'text-success': isSuccess,
-  'text-warning': isWarning,
-})
+export type FormTextProps = React.ComponentProps<typeof Form.Text> & {
+  type?: TextType
+}
+
+const typeClassMap: Partial<Record<TextType, string>> = {
+  error: 'text-danger',
+  success: 'text-success',
+  warning: 'text-warning',
+}
+
+export const getFormTextClass = (type?: TextType) =>
+  typeClassMap[type || 'default']
+
+function FormTextIcon({ type }: { type?: TextType }) {
+  switch (type) {
+    case 'info':
+      return <MaterialIcon type="info" className="text-info" />
+    case 'success':
+      return <MaterialIcon type="check_circle" />
+    case 'warning':
+      return <MaterialIcon type="warning" />
+    case 'error':
+      return <MaterialIcon type="error" />
+    default:
+      return null
+  }
+}
 
 function FormText({
-  isInfo,
-  isError,
-  isWarning,
-  isSuccess,
+  type = 'default',
   children,
   className,
   ...rest
 }: FormTextProps) {
   return (
     <Form.Text
-      className={classnames(
-        className,
-        getFormTextColor({ isError, isSuccess, isWarning })
-      )}
+      className={classnames(className, getFormTextClass(type))}
       {...rest}
     >
-      {isInfo && <MaterialIcon type="info" className="text-info" />}
-      {isError && <MaterialIcon type="error" />}
-      {isWarning && <MaterialIcon type="warning" />}
-      {isSuccess && <MaterialIcon type="check_circle" />}
+      <FormTextIcon type={type} />
       {children}
     </Form.Text>
   )

+ 3 - 3
services/web/frontend/js/features/ui/components/ol/ol-form-checkbox.tsx

@@ -11,7 +11,7 @@ type OLFormCheckboxProps = React.ComponentProps<(typeof Form)['Check']> & {
 function OLFormCheckbox(props: OLFormCheckboxProps) {
   const { bs3Props, inputRef, ...rest } = props
 
-  const bs3FormLabelProps: React.ComponentProps<typeof BS3Checkbox> = {
+  const bs3FormCheckboxProps: React.ComponentProps<typeof BS3Checkbox> = {
     children: rest.label,
     checked: rest.checked,
     required: rest.required,
@@ -34,9 +34,9 @@ function OLFormCheckbox(props: OLFormCheckboxProps) {
     <BootstrapVersionSwitcher
       bs3={
         rest.type === 'radio' ? (
-          <BS3Radio {...bs3FormLabelProps} />
+          <BS3Radio {...bs3FormCheckboxProps} />
         ) : (
-          <BS3Checkbox {...bs3FormLabelProps} />
+          <BS3Checkbox {...bs3FormCheckboxProps} />
         )
       }
       bs5={<Form.Check ref={inputRef} {...rest} />}

+ 12 - 8
services/web/frontend/js/features/ui/components/ol/ol-form-control.tsx

@@ -1,25 +1,29 @@
-import { forwardRef } from 'react'
+import { forwardRef, ComponentProps } from 'react'
 import { getAriaAndDataProps } from '@/features/utils/bootstrap-5'
-import BS3FormControl from '@/features/ui/components/bootstrap-3/form/form-control'
 import FormControl from '@/features/ui/components/bootstrap-5/form/form-control'
+import BS3FormControl from '@/features/ui/components/bootstrap-3/form/form-control'
 import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
 
-type OLFormControlProps = React.ComponentProps<typeof FormControl> & {
+type OLFormControlProps = ComponentProps<typeof FormControl> & {
   bs3Props?: Record<string, unknown>
   'data-ol-dirty'?: unknown
 }
 
+type BS3FormControlProps = ComponentProps<typeof BS3FormControl>
+
 const OLFormControl = forwardRef<HTMLInputElement, OLFormControlProps>(
   (props, ref) => {
     const { bs3Props, ...rest } = props
 
-    let bs3FormControlProps: React.ComponentProps<typeof BS3FormControl> = {
+    let bs3FormControlProps: BS3FormControlProps = {
+      componentClass: rest.as,
       id: rest.id,
       name: rest.name,
       className: rest.className,
       style: rest.style,
       type: rest.type,
       value: rest.value,
+      defaultValue: rest.defaultValue,
       required: rest.required,
       disabled: rest.disabled,
       placeholder: rest.placeholder,
@@ -28,10 +32,10 @@ const OLFormControl = forwardRef<HTMLInputElement, OLFormControlProps>(
       autoFocus: rest.autoFocus,
       minLength: rest.minLength,
       maxLength: rest.maxLength,
-      onChange: rest.onChange as (e: React.ChangeEvent<unknown>) => void,
-      onKeyDown: rest.onKeyDown as (e: React.KeyboardEvent<unknown>) => void,
-      onFocus: rest.onFocus as (e: React.FocusEvent<unknown>) => void,
-      onInvalid: rest.onInvalid as (e: React.InvalidEvent<unknown>) => void,
+      onChange: rest.onChange as BS3FormControlProps['onChange'],
+      onKeyDown: rest.onKeyDown as BS3FormControlProps['onKeyDown'],
+      onFocus: rest.onFocus as BS3FormControlProps['onFocus'],
+      onInvalid: rest.onInvalid as BS3FormControlProps['onInvalid'],
       inputRef: (inputElement: HTMLInputElement) => {
         if (typeof ref === 'function') {
           ref(inputElement)

+ 38 - 0
services/web/frontend/js/features/ui/components/ol/ol-form-feedback.tsx

@@ -0,0 +1,38 @@
+import { Form } from 'react-bootstrap-5'
+import {
+  HelpBlock as BS3HelpBlock,
+  HelpBlockProps as BS3HelpBlockProps,
+} from 'react-bootstrap'
+import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
+import { ComponentProps } from 'react'
+import classnames from 'classnames'
+import FormFeedback from '@/features/ui/components/bootstrap-5/form/form-feedback'
+
+type OLFormFeedbackProps = Pick<
+  ComponentProps<typeof Form.Control.Feedback>,
+  'type' | 'className' | 'children'
+> & {
+  bs3Props?: Record<string, unknown>
+}
+
+function OLFormFeedback(props: OLFormFeedbackProps) {
+  const { bs3Props, children, ...bs5Props } = props
+
+  const bs3HelpBlockProps: BS3HelpBlockProps = {
+    className: classnames(
+      bs5Props.className,
+      bs5Props.type === 'invalid' ? 'invalid-only' : null
+    ),
+    children,
+    ...bs3Props,
+  }
+
+  return (
+    <BootstrapVersionSwitcher
+      bs3={<BS3HelpBlock {...bs3HelpBlockProps} />}
+      bs5={<FormFeedback {...bs5Props}>{children}</FormFeedback>}
+    />
+  )
+}
+
+export default OLFormFeedback

+ 8 - 5
services/web/frontend/js/features/ui/components/ol/ol-form-group.tsx

@@ -1,5 +1,8 @@
 import { FormGroupProps } from 'react-bootstrap-5'
-import { FormGroup as BS3FormGroup } from 'react-bootstrap'
+import {
+  FormGroup as BS3FormGroup,
+  FormGroupProps as BS3FormGroupProps,
+} from 'react-bootstrap'
 import FormGroup from '@/features/ui/components/bootstrap-5/form/form-group'
 import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
 
@@ -8,19 +11,19 @@ type OLFormGroupProps = FormGroupProps & {
 }
 
 function OLFormGroup(props: OLFormGroupProps) {
-  const { bs3Props, ...rest } = props
+  const { bs3Props, className, ...rest } = props
 
-  const bs3FormGroupProps: React.ComponentProps<typeof BS3FormGroup> = {
+  const bs3FormGroupProps: BS3FormGroupProps = {
     children: rest.children,
     controlId: rest.controlId,
-    className: rest.className,
+    className,
     ...bs3Props,
   }
 
   return (
     <BootstrapVersionSwitcher
       bs3={<BS3FormGroup {...bs3FormGroupProps} />}
-      bs5={<FormGroup {...rest} />}
+      bs5={<FormGroup className={className} {...rest} />}
     />
   )
 }

+ 45 - 0
services/web/frontend/js/features/ui/components/ol/ol-form-select.tsx

@@ -0,0 +1,45 @@
+import { Form, FormSelectProps } from 'react-bootstrap-5'
+import {
+  FormControl as BS3FormControl,
+  FormControlProps as BS3FormControlProps,
+} from 'react-bootstrap'
+import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
+import { getAriaAndDataProps } from '@/features/utils/bootstrap-5'
+
+type OLFormSelectProps = FormSelectProps & {
+  bs3Props?: Record<string, unknown>
+}
+
+function OLFormSelect(props: OLFormSelectProps) {
+  const { bs3Props, ...bs5Props } = props
+
+  const bs3FormSelectProps: BS3FormControlProps = {
+    bsSize: bs5Props.size,
+    name: bs5Props.name,
+    value: bs5Props.value,
+    disabled: bs5Props.disabled,
+    onChange: bs5Props.onChange as BS3FormControlProps['onChange'],
+    required: bs5Props.required,
+    placeholder: bs5Props.placeholder,
+    className: bs5Props.className,
+    ...bs3Props,
+  }
+
+  // Get all `aria-*` and `data-*` attributes
+  const extraProps = getAriaAndDataProps(bs5Props)
+
+  return (
+    <BootstrapVersionSwitcher
+      bs3={
+        <BS3FormControl
+          componentClass="select"
+          {...bs3FormSelectProps}
+          {...extraProps}
+        />
+      }
+      bs5={<Form.Select {...bs5Props} />}
+    />
+  )
+}
+
+export default OLFormSelect

+ 4 - 11
services/web/frontend/js/features/ui/components/ol/ol-form-text.tsx

@@ -1,11 +1,12 @@
 import FormText, {
-  getFormTextColor,
+  FormTextProps,
+  getFormTextClass,
 } from '@/features/ui/components/bootstrap-5/form/form-text'
 import PolymorphicComponent from '@/shared/components/polymorphic-component'
 import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
 import classnames from 'classnames'
 
-type OLFormTextProps = React.ComponentProps<typeof FormText> & {
+type OLFormTextProps = FormTextProps & {
   bs3Props?: Record<string, unknown>
 }
 
@@ -14,15 +15,7 @@ function OLFormText(props: OLFormTextProps) {
 
   const bs3HelpBlockProps = {
     children: rest.children,
-    className: classnames(
-      'small',
-      rest.className,
-      getFormTextColor({
-        isError: rest.isError,
-        isSuccess: rest.isSuccess,
-        isWarning: rest.isWarning,
-      })
-    ),
+    className: classnames('small', rest.className, getFormTextClass(rest.type)),
     as: 'span',
     ...bs3Props,
   } as const satisfies React.ComponentProps<typeof PolymorphicComponent>

+ 22 - 7
services/web/frontend/js/features/ui/components/ol/ol-form.tsx

@@ -1,27 +1,42 @@
 import { Form } from 'react-bootstrap-5'
-import { Form as BS3Form } from 'react-bootstrap'
+import { Form as BS3Form, FormProps as BS3FormProps } from 'react-bootstrap'
 import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
+import { ComponentProps } from 'react'
+import classnames from 'classnames'
+import { getAriaAndDataProps } from '@/features/utils/bootstrap-5'
 
-type OLFormProps = React.ComponentProps<typeof Form> & {
-  bs3Props?: React.ComponentProps<typeof BS3Form>
+type OLFormProps = ComponentProps<typeof Form> & {
+  bs3Props?: ComponentProps<typeof BS3Form>
 }
 
 function OLForm(props: OLFormProps) {
   const { bs3Props, ...rest } = props
 
-  const bs3FormProps: React.ComponentProps<typeof BS3Form> = {
+  const bs3FormProps: BS3FormProps = {
     componentClass: rest.as,
     children: rest.children,
     id: rest.id,
-    onSubmit: rest.onSubmit as React.FormEventHandler<BS3Form> | undefined,
-    className: rest.className,
+    onSubmit: rest.onSubmit as BS3FormProps['onSubmit'],
+    onClick: rest.onClick as BS3FormProps['onClick'],
+    name: rest.name,
+    noValidate: rest.noValidate,
     role: rest.role,
     ...bs3Props,
   }
 
+  const bs3ClassName = classnames(
+    rest.className,
+    rest.validated ? 'was-validated' : null
+  )
+
+  // Get all `aria-*` and `data-*` attributes
+  const extraProps = getAriaAndDataProps(rest)
+
   return (
     <BootstrapVersionSwitcher
-      bs3={<BS3Form {...bs3FormProps} />}
+      bs3={
+        <BS3Form className={bs3ClassName} {...bs3FormProps} {...extraProps} />
+      }
       bs5={<Form {...rest} />}
     />
   )

+ 2 - 2
services/web/frontend/js/features/ui/components/ol/ol-modal.tsx

@@ -102,7 +102,7 @@ export function OLModalBody({ children, ...props }: OLModalBodyProps) {
 
   const bs3ModalProps: BS3ModalBodyProps = {
     componentClass: bs5Props.as,
-    bsClass: bs5Props.className,
+    className: bs5Props.className,
   }
 
   return (
@@ -118,7 +118,7 @@ export function OLModalFooter({ children, ...props }: OLModalFooterProps) {
 
   const bs3ModalProps: BS3ModalFooterProps = {
     componentClass: bs5Props.as,
-    bsClass: bs5Props.className,
+    className: bs5Props.className,
   }
 
   return (

+ 2 - 1
services/web/frontend/js/features/ui/components/types/dropdown-menu-props.ts

@@ -21,7 +21,7 @@ export type DropdownProps = {
 export type DropdownItemProps = PropsWithChildren<{
   active?: boolean
   as?: ElementType
-  description?: string
+  description?: ReactNode
   disabled?: boolean
   eventKey?: string | number
   href?: string
@@ -32,6 +32,7 @@ export type DropdownItemProps = PropsWithChildren<{
   className?: string
   role?: string
   tabIndex?: number
+  target?: string
 }>
 
 export type DropdownToggleProps = PropsWithChildren<{

+ 10 - 2
services/web/frontend/js/shared/hooks/use-contact-us-modal.tsx

@@ -1,13 +1,15 @@
 import importOverleafModules from '../../../macros/import-overleaf-module.macro'
 import { JSXElementConstructor, useCallback, useState } from 'react'
+import { HelpSuggestionSearchProvider } from '../../../../modules/support/frontend/js/context/help-suggestion-search-context'
 
 const [contactUsModalModules] = importOverleafModules('contactUsModal')
 const ContactUsModal: JSXElementConstructor<{
   show: boolean
   handleHide: () => void
+  autofillProjectUrl: boolean
 }> = contactUsModalModules?.import.default
 
-export const useContactUsModal = () => {
+export const useContactUsModal = (options = { autofillProjectUrl: true }) => {
   const [show, setShow] = useState(false)
 
   const hideModal = useCallback((event?: Event) => {
@@ -21,7 +23,13 @@ export const useContactUsModal = () => {
   }, [])
 
   const modal = ContactUsModal && (
-    <ContactUsModal show={show} handleHide={hideModal} />
+    <HelpSuggestionSearchProvider>
+      <ContactUsModal
+        show={show}
+        handleHide={hideModal}
+        autofillProjectUrl={options.autofillProjectUrl}
+      />
+    </HelpSuggestionSearchProvider>
   )
 
   return { modal, hideModal, showModal }

+ 0 - 37
services/web/frontend/stories/contact-us-modal.stories.jsx

@@ -1,37 +0,0 @@
-import { useState } from 'react'
-import useFetchMock from './hooks/use-fetch-mock'
-import ContactUsModal from '../../modules/support/frontend/js/components/contact-us-modal'
-import { ScopeDecorator } from './decorators/scope'
-
-export const Generic = () => {
-  const [show, setShow] = useState(true)
-
-  const handleHide = () => setShow(false)
-
-  useFetchMock(fetchMock => {
-    fetchMock.post('/support', { status: 200 }, { delay: 1000 })
-  })
-
-  return <ContactUsModal show={show} handleHide={handleHide} />
-}
-
-export const RequestError = args => {
-  useFetchMock(fetchMock => {
-    fetchMock.post('/support', { status: 404 }, { delay: 250 })
-  })
-
-  return <ContactUsModal {...args} />
-}
-
-export default {
-  title: 'Shared / Modals / Contact Us',
-  component: ContactUsModal,
-  args: {
-    show: true,
-    handleHide: () => {},
-  },
-  argTypes: {
-    handleHide: { action: 'close modal' },
-  },
-  decorators: [ScopeDecorator],
-}

+ 93 - 0
services/web/frontend/stories/contact-us-modal.stories.tsx

@@ -0,0 +1,93 @@
+import { ComponentProps } from 'react'
+import useFetchMock from './hooks/use-fetch-mock'
+import ContactUsModal from '../../modules/support/frontend/js/components/contact-us-modal'
+import { ScopeDecorator } from './decorators/scope'
+import { StoryObj } from '@storybook/react'
+import { FixedHelpSuggestionSearchProvider } from '../../modules/support/test/frontend/helpers/contact-us-modal-base-tests'
+
+type Story = StoryObj<typeof ContactUsModal>
+type ContactUsModalProps = ComponentProps<typeof ContactUsModal>
+
+function bootstrap3Story(render: Story['render']): Story {
+  return {
+    render,
+    decorators: [
+      story => {
+        return ScopeDecorator(story)
+      },
+    ],
+  }
+}
+
+function bootstrap5Story(render: Story['render']): Story {
+  return {
+    render,
+    decorators: [
+      story => {
+        return ScopeDecorator(story, undefined, {
+          'ol-bootstrapVersion': 5,
+        })
+      },
+    ],
+    parameters: {
+      bootstrap5: true,
+    },
+  }
+}
+
+function GenericContactUsModal(args: ContactUsModalProps) {
+  useFetchMock(fetchMock => {
+    fetchMock.post('/support', { status: 200 }, { delay: 1000 })
+  })
+
+  return (
+    <FixedHelpSuggestionSearchProvider>
+      <ContactUsModal {...args} />
+    </FixedHelpSuggestionSearchProvider>
+  )
+}
+
+export const Generic: Story = bootstrap3Story(args => (
+  <GenericContactUsModal {...args} />
+))
+
+export const GenericBootstrap5: Story = bootstrap5Story(args => (
+  <GenericContactUsModal {...args} />
+))
+
+const ContactUsModalWithRequestError = (args: ContactUsModalProps) => {
+  useFetchMock(fetchMock => {
+    fetchMock.post('/support', { status: 404 }, { delay: 250 })
+  })
+
+  return (
+    <FixedHelpSuggestionSearchProvider>
+      <ContactUsModal {...args} />
+    </FixedHelpSuggestionSearchProvider>
+  )
+}
+
+const renderContactUsModalWithRequestError = (args: ContactUsModalProps) => (
+  <ContactUsModalWithRequestError {...args} />
+)
+
+export const RequestError: Story = bootstrap3Story(
+  renderContactUsModalWithRequestError
+)
+
+export const RequestErrorBootstrap5: Story = bootstrap5Story(
+  renderContactUsModalWithRequestError
+)
+
+export default {
+  title: 'Shared / Modals / Contact Us',
+  component: ContactUsModal,
+  args: {
+    show: true,
+    handleHide: () => {},
+    autofillProjectUrl: true,
+  },
+  argTypes: {
+    handleHide: { action: 'close modal' },
+  },
+}

+ 13 - 12
services/web/frontend/stories/ui/form/form-input-bs5.stories.tsx

@@ -4,6 +4,7 @@ import FormGroup from '@/features/ui/components/bootstrap-5/form/form-group'
 import FormText from '@/features/ui/components/bootstrap-5/form/form-text'
 import FormControl from '@/features/ui/components/bootstrap-5/form/form-control'
 import MaterialIcon from '@/shared/components/material-icon'
+import FormFeedback from '@/features/ui/components/bootstrap-5/form/form-feedback'
 
 const meta: Meta<React.ComponentProps<typeof FormControl>> = {
   title: 'Shared / Components / Bootstrap 5 / Form / Input',
@@ -57,7 +58,7 @@ export const Info: Story = {
             size="lg"
             {...args}
           />
-          <FormText isInfo>Info</FormText>
+          <FormText type="info">Info</FormText>
         </FormGroup>
         <hr />
         <FormGroup controlId="id-2">
@@ -67,7 +68,7 @@ export const Info: Story = {
             defaultValue="Regular input"
             {...args}
           />
-          <FormText isInfo>Info</FormText>
+          <FormText type="info">Info</FormText>
         </FormGroup>
         <hr />
         <FormGroup controlId="id-3">
@@ -78,7 +79,7 @@ export const Info: Story = {
             size="sm"
             {...args}
           />
-          <FormText isInfo>Info</FormText>
+          <FormText type="info">Info</FormText>
         </FormGroup>
       </>
     )
@@ -98,7 +99,7 @@ export const Error: Story = {
             isInvalid
             {...args}
           />
-          <FormText isError>Error</FormText>
+          <FormFeedback type="invalid">Error</FormFeedback>
         </FormGroup>
         <hr />
         <FormGroup controlId="id-2">
@@ -109,7 +110,7 @@ export const Error: Story = {
             isInvalid
             {...args}
           />
-          <FormText isError>Error</FormText>
+          <FormFeedback type="invalid">Error</FormFeedback>
         </FormGroup>
         <hr />
         <FormGroup controlId="id-3">
@@ -121,7 +122,7 @@ export const Error: Story = {
             isInvalid
             {...args}
           />
-          <FormText isError>Error</FormText>
+          <FormFeedback type="invalid">Error</FormFeedback>
         </FormGroup>
       </>
     )
@@ -140,7 +141,7 @@ export const Warning: Story = {
             size="lg"
             {...args}
           />
-          <FormText isWarning>Warning</FormText>
+          <FormText type="warning">Warning</FormText>
         </FormGroup>
         <hr />
         <FormGroup controlId="id-2">
@@ -150,7 +151,7 @@ export const Warning: Story = {
             defaultValue="Regular input"
             {...args}
           />
-          <FormText isWarning>Warning</FormText>
+          <FormText type="warning">Warning</FormText>
         </FormGroup>
         <hr />
         <FormGroup controlId="id-3">
@@ -161,7 +162,7 @@ export const Warning: Story = {
             size="sm"
             {...args}
           />
-          <FormText isWarning>Warning</FormText>
+          <FormText type="warning">Warning</FormText>
         </FormGroup>
       </>
     )
@@ -180,7 +181,7 @@ export const Success: Story = {
             size="lg"
             {...args}
           />
-          <FormText isSuccess>Success</FormText>
+          <FormText type="success">Success</FormText>
         </FormGroup>
         <hr />
         <FormGroup controlId="id-2">
@@ -190,7 +191,7 @@ export const Success: Story = {
             defaultValue="Regular input"
             {...args}
           />
-          <FormText isSuccess>Success</FormText>
+          <FormText type="success">Success</FormText>
         </FormGroup>
         <hr />
         <FormGroup controlId="id-3">
@@ -201,7 +202,7 @@ export const Success: Story = {
             size="sm"
             {...args}
           />
-          <FormText isSuccess>Success</FormText>
+          <FormText type="success">Success</FormText>
         </FormGroup>
       </>
     )

+ 12 - 12
services/web/frontend/stories/ui/form/form-select-bs5.stories.tsx

@@ -70,7 +70,7 @@ export const Info: Story = {
             <option value="2">Two</option>
             <option value="3">Three</option>
           </Form.Select>
-          <FormText isInfo>Info</FormText>
+          <FormText type="info">Info</FormText>
         </FormGroup>
         <hr />
         <FormGroup controlId="id-2">
@@ -81,7 +81,7 @@ export const Info: Story = {
             <option value="2">Two</option>
             <option value="3">Three</option>
           </Form.Select>
-          <FormText isInfo>Info</FormText>
+          <FormText type="info">Info</FormText>
         </FormGroup>
         <hr />
         <FormGroup controlId="id-3">
@@ -92,7 +92,7 @@ export const Info: Story = {
             <option value="2">Two</option>
             <option value="3">Three</option>
           </Form.Select>
-          <FormText isInfo>Info</FormText>
+          <FormText type="info">Info</FormText>
         </FormGroup>
       </>
     )
@@ -111,7 +111,7 @@ export const Error: Story = {
             <option value="2">Two</option>
             <option value="3">Three</option>
           </Form.Select>
-          <FormText isError>Error</FormText>
+          <FormText type="error">Error</FormText>
         </FormGroup>
         <hr />
         <FormGroup controlId="id-2">
@@ -122,7 +122,7 @@ export const Error: Story = {
             <option value="2">Two</option>
             <option value="3">Three</option>
           </Form.Select>
-          <FormText isError>Error</FormText>
+          <FormText type="error">Error</FormText>
         </FormGroup>
         <hr />
         <FormGroup controlId="id-3">
@@ -133,7 +133,7 @@ export const Error: Story = {
             <option value="2">Two</option>
             <option value="3">Three</option>
           </Form.Select>
-          <FormText isError>Error</FormText>
+          <FormText type="error">Error</FormText>
         </FormGroup>
       </>
     )
@@ -152,7 +152,7 @@ export const Warning: Story = {
             <option value="2">Two</option>
             <option value="3">Three</option>
           </Form.Select>
-          <FormText isWarning>Warning</FormText>
+          <FormText type="warning">Warning</FormText>
         </FormGroup>
         <hr />
         <FormGroup controlId="id-2">
@@ -163,7 +163,7 @@ export const Warning: Story = {
             <option value="2">Two</option>
             <option value="3">Three</option>
           </Form.Select>
-          <FormText isWarning>Warning</FormText>
+          <FormText type="warning">Warning</FormText>
         </FormGroup>
         <hr />
         <FormGroup controlId="id-3">
@@ -174,7 +174,7 @@ export const Warning: Story = {
             <option value="2">Two</option>
             <option value="3">Three</option>
           </Form.Select>
-          <FormText isWarning>Warning</FormText>
+          <FormText type="warning">Warning</FormText>
         </FormGroup>
       </>
     )
@@ -193,7 +193,7 @@ export const Success: Story = {
             <option value="2">Two</option>
             <option value="3">Three</option>
           </Form.Select>
-          <FormText isSuccess>Success</FormText>
+          <FormText type="success">Success</FormText>
         </FormGroup>
         <hr />
         <FormGroup controlId="id-2">
@@ -204,7 +204,7 @@ export const Success: Story = {
             <option value="2">Two</option>
             <option value="3">Three</option>
           </Form.Select>
-          <FormText isSuccess>Success</FormText>
+          <FormText type="success">Success</FormText>
         </FormGroup>
         <hr />
         <FormGroup controlId="id-3">
@@ -215,7 +215,7 @@ export const Success: Story = {
             <option value="2">Two</option>
             <option value="3">Three</option>
           </Form.Select>
-          <FormText isSuccess>Success</FormText>
+          <FormText type="success">Success</FormText>
         </FormGroup>
       </>
     )

+ 12 - 12
services/web/frontend/stories/ui/form/form-textarea-bs5.stories.tsx

@@ -67,7 +67,7 @@ export const Info: Story = {
             size="lg"
             {...args}
           />
-          <FormText isInfo>Info</FormText>
+          <FormText type="info">Info</FormText>
         </FormGroup>
         <hr />
         <FormGroup controlId="id-2">
@@ -78,7 +78,7 @@ export const Info: Story = {
             defaultValue="Regular input"
             {...args}
           />
-          <FormText isInfo>Info</FormText>
+          <FormText type="info">Info</FormText>
         </FormGroup>
         <hr />
         <FormGroup controlId="id-3">
@@ -90,7 +90,7 @@ export const Info: Story = {
             size="sm"
             {...args}
           />
-          <FormText isInfo>Info</FormText>
+          <FormText type="info">Info</FormText>
         </FormGroup>
       </>
     )
@@ -111,7 +111,7 @@ export const Error: Story = {
             isInvalid
             {...args}
           />
-          <FormText isError>Error</FormText>
+          <FormText type="error">Error</FormText>
         </FormGroup>
         <hr />
         <FormGroup controlId="id-2">
@@ -123,7 +123,7 @@ export const Error: Story = {
             isInvalid
             {...args}
           />
-          <FormText isError>Error</FormText>
+          <FormText type="error">Error</FormText>
         </FormGroup>
         <hr />
         <FormGroup controlId="id-3">
@@ -136,7 +136,7 @@ export const Error: Story = {
             isInvalid
             {...args}
           />
-          <FormText isError>Error</FormText>
+          <FormText type="error">Error</FormText>
         </FormGroup>
       </>
     )
@@ -156,7 +156,7 @@ export const Warning: Story = {
             size="lg"
             {...args}
           />
-          <FormText isWarning>Warning</FormText>
+          <FormText type="warning">Warning</FormText>
         </FormGroup>
         <hr />
         <FormGroup controlId="id-2">
@@ -167,7 +167,7 @@ export const Warning: Story = {
             defaultValue="Regular input"
             {...args}
           />
-          <FormText isWarning>Warning</FormText>
+          <FormText type="warning">Warning</FormText>
         </FormGroup>
         <hr />
         <FormGroup controlId="id-3">
@@ -179,7 +179,7 @@ export const Warning: Story = {
             size="sm"
             {...args}
           />
-          <FormText isWarning>Warning</FormText>
+          <FormText type="warning">Warning</FormText>
         </FormGroup>
       </>
     )
@@ -199,7 +199,7 @@ export const Success: Story = {
             size="lg"
             {...args}
           />
-          <FormText isSuccess>Success</FormText>
+          <FormText type="success">Success</FormText>
         </FormGroup>
         <hr />
         <FormGroup controlId="id-2">
@@ -210,7 +210,7 @@ export const Success: Story = {
             defaultValue="Regular input"
             {...args}
           />
-          <FormText isSuccess>Success</FormText>
+          <FormText type="success">Success</FormText>
         </FormGroup>
         <hr />
         <FormGroup controlId="id-3">
@@ -222,7 +222,7 @@ export const Success: Story = {
             size="sm"
             {...args}
           />
-          <FormText isSuccess>Success</FormText>
+          <FormText type="success">Success</FormText>
         </FormGroup>
       </>
     )

+ 1 - 0
services/web/frontend/stylesheets/bootstrap-5/abstracts/variable-overrides.scss

@@ -120,6 +120,7 @@ $form-check-input-disabled-opacity: 1;
 // form-feedback-variables
 $form-feedback-invalid-color: $bg-danger-01;
 $form-feedback-icon-invalid: null;
+$form-feedback-margin-top: 0; // Our feedback component wraps Form.Text, which takes care of top margin
 
 // form-validation-colors
 $form-invalid-color: $form-feedback-invalid-color;

+ 3 - 0
services/web/frontend/stylesheets/bootstrap-5/abstracts/variables.scss

@@ -3,3 +3,6 @@ $footer-height: 50px;
 
 // Header
 $header-height: 68px;
+
+// Forms
+$form-group-margin-bottom: $spacing-06;

+ 1 - 1
services/web/frontend/stylesheets/bootstrap-5/components/form.scss

@@ -90,7 +90,7 @@
 }
 
 .form-group {
-  margin-bottom: var(--spacing-06);
+  margin-bottom: $form-group-margin-bottom;
 }
 
 .form-control-wrapper {

+ 0 - 9
services/web/frontend/stylesheets/bootstrap-5/components/modal.scss

@@ -85,12 +85,3 @@
 .git-bridge-optional-tokens-actions {
   margin-top: var(--spacing-05);
 }
-
-// Contact us modal
-.contact-us-modal-textarea {
-  height: 120px;
-}
-
-.modal-form-messages .notification {
-  margin-bottom: var(--spacing-05);
-}

+ 6 - 0
services/web/frontend/stylesheets/bootstrap-5/main-style.scss

@@ -32,3 +32,9 @@ $is-overleaf-light: false;
 
 // Page layout that isn't related to a particular component or page
 @import 'base/layout';
+
+// Modals
+@import 'modals/all';
+
+// Pages custom style
+@import 'pages/all';

+ 1 - 0
services/web/frontend/stylesheets/bootstrap-5/modals/all.scss

@@ -0,0 +1 @@
+@import 'contact-us-modal';

+ 82 - 0
services/web/frontend/stylesheets/bootstrap-5/modals/contact-us-modal.scss

@@ -0,0 +1,82 @@
+.contact-us-modal-textarea {
+  height: 120px;
+}
+
+.modal-form-messages .notification {
+  margin-bottom: var(--spacing-05);
+}
+
+.contact-suggestions {
+  @include body-sm;
+
+  margin: 0 calc(-1 * var(--bs-modal-padding)) var(--spacing-05);
+  padding: var(--spacing-05) 0;
+  color: var(--content-secondary);
+  background-color: var(--bg-light-secondary);
+  border-top: solid 1px var(--border-primary-dark);
+  border-bottom: solid 1px var(--border-primary-dark);
+}
+
+.contact-suggestion-label {
+  margin-bottom: var(--spacing-05);
+  padding: 0 var(--spacing-07);
+}
+
+.contact-suggestion-list {
+  padding-left: 0;
+  list-style: none;
+  background-color: var(--white);
+  border-top: solid 1px var(--border-primary-dark);
+  border-bottom: solid 1px var(--border-primary-dark);
+  margin: 0;
+
+  li:last-child .contact-suggestion-list-item {
+    border-bottom: none;
+  }
+}
+
+.contact-suggestion-list-item {
+  display: flex;
+  justify-content: space-between;
+  color: var(--content-secondary);
+  padding: var(--spacing-05) var(--spacing-07);
+  border-bottom: solid 1px var(--border-divider);
+  cursor: pointer;
+  text-decoration: none;
+
+  &:hover,
+  &:focus {
+    background-color: var(--bg-light-secondary);
+
+    span {
+      text-decoration: underline;
+    }
+
+    .fa {
+      color: inherit;
+      text-decoration: none;
+    }
+  }
+
+  .fa {
+    color: var(--neutral-30);
+  }
+}
+
+.contact-suggestions-dropdown {
+  width: calc(100% - 2 * var(--bs-modal-padding));
+
+  .dropdown-header {
+    @include body-sm;
+
+    padding: var(--bs-dropdown-item-padding-y) var(--bs-dropdown-item-padding-x);
+  }
+
+  .dropdown-item {
+    white-space: normal;
+  }
+
+  .form-group + & {
+    margin-top: $spacing-02 - $form-group-margin-bottom;
+  }
+}

+ 9 - 0
services/web/frontend/stylesheets/components/forms.less

@@ -357,6 +357,15 @@ input[type='checkbox'],
   margin-top: 5px;
   margin-bottom: 10px;
   color: lighten(@text-color, 25%); // lighten the text some for contrast
+
+  // Hide help blocks used as validation messages by default
+  &.invalid-only {
+    display: none;
+
+    .has-error & {
+      display: block;
+    }
+  }
 }
 
 // Inline forms

+ 2 - 0
services/web/locales/en.json

@@ -1455,6 +1455,8 @@
   "please_enter_email": "Please enter your email address",
   "please_get_in_touch": "Please get in touch",
   "please_link_before_making_primary": "Please confirm your email by linking to your institutional account before making it the primary email.",
+  "please_provide_a_message": "Please provide a message",
+  "please_provide_a_subject": "Please provide a subject",
   "please_reconfirm_institutional_email": "Please take a moment to confirm your institutional email address or <0>remove it</0> from your account.",
   "please_reconfirm_your_affiliation_before_making_this_primary": "Please confirm your affiliation before making this the primary.",
   "please_refresh": "Please refresh the page to continue.",