Răsfoiți Sursa

Add confirm modal on accept/reject selected changes (#21540)

* Add showGenericConfirmModal in ModalsContext

* Add confirm modal on accept/reject selected changes

* plural in translations

* change tooltip to include selected changes

* add _plural to all translated languages

* lowercase title/tooltip

* count replacements as single change

* use new translation key

GitOrigin-RevId: afadbe1eeb2a290688b96f2b5388485f40c958d0
Domagoj Kriskovic 1 an în urmă
părinte
comite
8a90ffa3fb

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

@@ -37,6 +37,7 @@
   "accept_change_error_title": "",
   "accept_invitation": "",
   "accept_or_reject_each_changes_individually": "",
+  "accept_selected_changes": "",
   "accept_terms_and_conditions": "",
   "accepted_invite": "",
   "accepting_invite_as": "",
@@ -258,11 +259,15 @@
   "compromised_password": "",
   "configure_sso": "",
   "confirm": "",
+  "confirm_accept_selected_changes": "",
+  "confirm_accept_selected_changes_plural": "",
   "confirm_affiliation": "",
   "confirm_affiliation_to_relink_dropbox": "",
   "confirm_delete_user_type_email_address": "",
   "confirm_new_password": "",
   "confirm_primary_email_change": "",
+  "confirm_reject_selected_changes": "",
+  "confirm_reject_selected_changes_plural": "",
   "confirm_remove_sso_config_enter_email": "",
   "confirm_your_email": "",
   "confirming": "",
@@ -1203,6 +1208,7 @@
   "reject": "",
   "reject_all": "",
   "reject_change": "",
+  "reject_selected_changes": "",
   "relink_your_account": "",
   "reload_editor": "",
   "remind_before_trial_ends": "",

+ 53 - 0
services/web/frontend/js/features/ide-react/components/modals/generic-confirm-modal.tsx

@@ -0,0 +1,53 @@
+import { useTranslation } from 'react-i18next'
+import { memo } from 'react'
+import OLModal, {
+  OLModalBody,
+  OLModalFooter,
+  OLModalHeader,
+  OLModalTitle,
+} from '@/features/ui/components/ol/ol-modal'
+import OLButton from '@/features/ui/components/ol/ol-button'
+import { ButtonProps } from '@/features/ui/components/types/button-props'
+
+export type GenericConfirmModalOwnProps = {
+  title: string
+  message: string
+  onConfirm: () => void
+  confirmLabel?: string
+  primaryVariant?: ButtonProps['variant']
+}
+
+type GenericConfirmModalProps = React.ComponentProps<typeof OLModal> &
+  GenericConfirmModalOwnProps
+
+function GenericConfirmModal({
+  title,
+  message,
+  confirmLabel,
+  primaryVariant = 'primary',
+  ...modalProps
+}: GenericConfirmModalProps) {
+  const { t } = useTranslation()
+  const handleConfirmClick = modalProps.onConfirm
+
+  return (
+    <OLModal {...modalProps}>
+      <OLModalHeader closeButton>
+        <OLModalTitle>{title}</OLModalTitle>
+      </OLModalHeader>
+
+      <OLModalBody className="modal-generic-confirm">{message}</OLModalBody>
+
+      <OLModalFooter>
+        <OLButton variant="secondary" onClick={() => modalProps.onHide()}>
+          {t('cancel')}
+        </OLButton>
+        <OLButton variant={primaryVariant} onClick={handleConfirmClick}>
+          {confirmLabel || t('ok')}
+        </OLButton>
+      </OLModalFooter>
+    </OLModal>
+  )
+}
+
+export default memo(GenericConfirmModal)

+ 41 - 1
services/web/frontend/js/features/ide-react/context/modals-context.tsx

@@ -12,9 +12,13 @@ import GenericMessageModal, {
 import OutOfSyncModal, {
   OutOfSyncModalProps,
 } from '@/features/ide-react/components/modals/out-of-sync-modal'
+import GenericConfirmModal, {
+  GenericConfirmModalOwnProps,
+} from '../components/modals/generic-confirm-modal'
 
 type ModalsContextValue = {
   genericModalVisible: boolean
+  showGenericConfirmModal: (data: GenericConfirmModalOwnProps) => void
   showGenericMessageModal: (
     title: GenericMessageModalOwnProps['title'],
     message: GenericMessageModalOwnProps['message']
@@ -28,8 +32,15 @@ const ModalsContext = createContext<ModalsContextValue | undefined>(undefined)
 
 export const ModalsContextProvider: FC = ({ children }) => {
   const [showGenericModal, setShowGenericModal] = useState(false)
+  const [showConfirmModal, setShowConfirmModal] = useState(false)
   const [genericMessageModalData, setGenericMessageModalData] =
     useState<GenericMessageModalOwnProps>({ title: '', message: '' })
+  const [genericConfirmModalData, setGenericConfirmModalData] =
+    useState<GenericConfirmModalOwnProps>({
+      title: '',
+      message: '',
+      onConfirm: () => {},
+    })
 
   const [shouldShowOutOfSyncModal, setShouldShowOutOfSyncModal] =
     useState(false)
@@ -41,6 +52,15 @@ export const ModalsContextProvider: FC = ({ children }) => {
     setShowGenericModal(false)
   }, [])
 
+  const handleHideGenericConfirmModal = useCallback(() => {
+    setShowConfirmModal(false)
+  }, [])
+
+  const handleConfirmGenericConfirmModal = useCallback(() => {
+    genericConfirmModalData.onConfirm()
+    setShowConfirmModal(false)
+  }, [genericConfirmModalData])
+
   const showGenericMessageModal = useCallback(
     (
       title: GenericMessageModalOwnProps['title'],
@@ -52,6 +72,14 @@ export const ModalsContextProvider: FC = ({ children }) => {
     []
   )
 
+  const showGenericConfirmModal = useCallback(
+    (data: GenericConfirmModalOwnProps) => {
+      setGenericConfirmModalData(data)
+      setShowConfirmModal(true)
+    },
+    []
+  )
+
   const handleHideOutOfSyncModal = useCallback(() => {
     setShouldShowOutOfSyncModal(false)
   }, [])
@@ -64,10 +92,16 @@ export const ModalsContextProvider: FC = ({ children }) => {
   const value = useMemo<ModalsContextValue>(
     () => ({
       showGenericMessageModal,
+      showGenericConfirmModal,
       genericModalVisible: showGenericModal,
       showOutOfSyncModal,
     }),
-    [showGenericMessageModal, showGenericModal, showOutOfSyncModal]
+    [
+      showGenericMessageModal,
+      showGenericConfirmModal,
+      showGenericModal,
+      showOutOfSyncModal,
+    ]
   )
 
   return (
@@ -78,6 +112,12 @@ export const ModalsContextProvider: FC = ({ children }) => {
         onHide={handleHideGenericModal}
         {...genericMessageModalData}
       />
+      <GenericConfirmModal
+        show={showConfirmModal}
+        onHide={handleHideGenericConfirmModal}
+        {...genericConfirmModalData}
+        onConfirm={handleConfirmGenericConfirmModal}
+      />
       <OutOfSyncModal
         {...outOfSyncModalData}
         show={shouldShowOutOfSyncModal}

+ 45 - 6
services/web/frontend/js/features/review-panel-new/components/review-tooltip-menu.tsx

@@ -30,6 +30,8 @@ import {
 import { isInsertOperation } from '@/utils/operations'
 import { isCursorNearViewportEdge } from '@/features/source-editor/utils/is-cursor-near-edge'
 import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
+import { useModalsContext } from '@/features/ide-react/context/modals-context'
+import { numberOfChangesInSelection } from '../utils/changes-in-selection'
 
 const ReviewTooltipMenu: FC = () => {
   const state = useCodeMirrorStateContext()
@@ -72,6 +74,7 @@ const ReviewTooltipMenuContent: FC<{
   const { setView } = useReviewPanelViewActionsContext()
   const ranges = useRangesContext()
   const { acceptChanges, rejectChanges } = useRangesActionsContext()
+  const { showGenericConfirmModal } = useModalsContext()
 
   const addComment = useCallback(() => {
     setReviewPanelOpen(true)
@@ -109,12 +112,42 @@ const ReviewTooltipMenuContent: FC<{
   }, [ranges, state.selection.main])
 
   const acceptChangesHandler = useCallback(() => {
-    acceptChanges(...changeIdsInSelection)
-  }, [acceptChanges, changeIdsInSelection])
+    const nChanges = numberOfChangesInSelection(ranges, state.selection.main)
+    showGenericConfirmModal({
+      message: t('confirm_accept_selected_changes', { count: nChanges }),
+      title: t('accept_selected_changes'),
+      onConfirm: () => {
+        acceptChanges(...changeIdsInSelection)
+      },
+      primaryVariant: 'danger',
+    })
+  }, [
+    acceptChanges,
+    changeIdsInSelection,
+    ranges,
+    showGenericConfirmModal,
+    state.selection.main,
+    t,
+  ])
 
   const rejectChangesHandler = useCallback(() => {
-    rejectChanges(...changeIdsInSelection)
-  }, [rejectChanges, changeIdsInSelection])
+    const nChanges = numberOfChangesInSelection(ranges, state.selection.main)
+    showGenericConfirmModal({
+      message: t('confirm_reject_selected_changes', { count: nChanges }),
+      title: t('reject_selected_changes'),
+      onConfirm: () => {
+        rejectChanges(...changeIdsInSelection)
+      },
+      primaryVariant: 'danger',
+    })
+  }, [
+    showGenericConfirmModal,
+    t,
+    ranges,
+    state.selection.main,
+    rejectChanges,
+    changeIdsInSelection,
+  ])
 
   const showChangesButtons = changeIdsInSelection.length > 0
 
@@ -130,7 +163,10 @@ const ReviewTooltipMenuContent: FC<{
       {showChangesButtons && (
         <>
           <div className="review-tooltip-menu-divider" />
-          <OLTooltip id="accept-all-changes" description={t('accept_all')}>
+          <OLTooltip
+            id="accept-all-changes"
+            description={t('accept_selected_changes')}
+          >
             <button
               className="review-tooltip-menu-button"
               onClick={acceptChangesHandler}
@@ -139,7 +175,10 @@ const ReviewTooltipMenuContent: FC<{
             </button>
           </OLTooltip>
 
-          <OLTooltip id="reject-all-changes" description={t('reject_all')}>
+          <OLTooltip
+            id="reject-all-changes"
+            description={t('reject_selected_changes')}
+          >
             <button
               className="review-tooltip-menu-button"
               onClick={rejectChangesHandler}

+ 44 - 0
services/web/frontend/js/features/review-panel-new/utils/changes-in-selection.ts

@@ -0,0 +1,44 @@
+import { SelectionRange } from '@codemirror/state'
+import { Ranges } from '@/features/review-panel-new/context/ranges-context'
+import { isDeleteChange, isInsertChange } from '@/utils/operations'
+import { canAggregate } from './can-aggregate'
+import { Change, EditOperation } from '../../../../../types/change'
+
+export function numberOfChangesInSelection(
+  ranges: Ranges | undefined,
+  selection: SelectionRange
+) {
+  if (!ranges) {
+    return 0
+  }
+
+  let count = 0
+  let precedingChange: Change<EditOperation> | null = null
+
+  for (const change of ranges.changes) {
+    if (
+      precedingChange &&
+      isInsertChange(precedingChange) &&
+      isDeleteChange(change) &&
+      canAggregate(change, precedingChange)
+    ) {
+      // only count once for the aggregated change
+      continue
+    } else if (
+      isInsertChange(change) &&
+      change.op.p >= selection.from &&
+      change.op.p + change.op.i.length <= selection.to
+    ) {
+      count++
+    } else if (
+      isDeleteChange(change) &&
+      selection.from <= change.op.p &&
+      change.op.p <= selection.to
+    ) {
+      count++
+    }
+    precedingChange = change
+  }
+
+  return count
+}

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

@@ -41,6 +41,7 @@
   "accept_change_error_title": "Accept Change Error",
   "accept_invitation": "Accept invitation",
   "accept_or_reject_each_changes_individually": "Accept or reject each change individually",
+  "accept_selected_changes": "Accept selected changes",
   "accept_terms_and_conditions": "Accept terms and conditions",
   "accepted_invite": "Accepted invite",
   "accepting_invite_as": "You are accepting this invite as",
@@ -348,12 +349,16 @@
   "configure_sso": "Configure SSO",
   "configured": "Configured",
   "confirm": "Confirm",
+  "confirm_accept_selected_changes": "Are you sure you want to accept the selected change?",
+  "confirm_accept_selected_changes_plural": "Are you sure you want to accept the selected __count__ changes?",
   "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.",
   "confirm_delete_user_type_email_address": "To confirm you want to delete __userName__ please type the email address associated with their account",
   "confirm_email": "Confirm Email",
   "confirm_new_password": "Confirm New Password",
   "confirm_primary_email_change": "Confirm primary email change",
+  "confirm_reject_selected_changes": "Are you sure you want to reject the selected change?",
+  "confirm_reject_selected_changes_plural": "Are you sure you want to reject the selected __count__ changes?",
   "confirm_remove_sso_config_enter_email": "To confirm you want to remove your SSO configuration, enter your email address:",
   "confirm_your_email": "Confirm your email address",
   "confirmation_link_broken": "Sorry, something is wrong with your confirmation link. Please try copy and pasting the link from the bottom of your confirmation email.",
@@ -1712,6 +1717,7 @@
   "reject": "Reject",
   "reject_all": "Reject all",
   "reject_change": "Reject change",
+  "reject_selected_changes": "Reject selected changes",
   "related_tags": "Related Tags",
   "relink_your_account": "Re-link your account",
   "reload_editor": "Reload editor",