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

Merge pull request #9536 from overleaf/ii-adjustable-project-dashboard-panel

[web] Adjustable project dashboard sidebar

GitOrigin-RevId: 1007ecb896bbe215af28fa92d295201b2457aeef
ilkin-overleaf 3 лет назад
Родитель
Сommit
4f35333a39

+ 2 - 13
services/web/frontend/js/features/project-list/components/project-list-root.tsx

@@ -9,8 +9,6 @@ import useWaitForI18n from '../../../shared/hooks/use-wait-for-i18n'
 import CurrentPlanWidget from './current-plan-widget/current-plan-widget'
 import NewProjectButton from './new-project-button'
 import ProjectListTable from './table/project-list-table'
-import SidebarFilters from './sidebar/sidebar-filters'
-import AddAffiliation, { useAddAffiliation } from './sidebar/add-affiliation'
 import SurveyWidget from './survey-widget'
 import WelcomeMessage from './welcome-message'
 import LoadingBranded from '../../../shared/components/loading-branded'
@@ -20,6 +18,7 @@ import SearchForm from './search-form'
 import ProjectsDropdown from './dropdown/projects-dropdown'
 import SortByDropdown from './dropdown/sort-by-dropdown'
 import ProjectTools from './table/project-tools/project-tools'
+import Sidebar from './sidebar/sidebar'
 import LoadMore from './load-more'
 import { useEffect } from 'react'
 
@@ -43,7 +42,6 @@ function ProjectListPageContent() {
     setSearchText,
     selectedProjects,
   } = useProjectListContext()
-  const { show: showAddAffiliationWidget } = useAddAffiliation()
 
   useEffect(() => {
     eventTracking.sendMB('loads_v2_dash', {})
@@ -59,16 +57,7 @@ function ProjectListPageContent() {
       <div className="project-list-wrapper clearfix">
         {totalProjectsCount > 0 ? (
           <>
-            <div className="project-list-sidebar-wrapper-react hidden-xs">
-              <div className="project-list-sidebar-subwrapper">
-                <aside className="project-list-sidebar-react">
-                  <NewProjectButton id="new-project-button-sidebar" />
-                  <SidebarFilters />
-                  {showAddAffiliationWidget && <hr />}
-                  <AddAffiliation />
-                </aside>
-              </div>
-            </div>
+            <Sidebar />
             <div className="project-list-main-react">
               {error ? <DashApiError /> : ''}
               <Row>

+ 45 - 0
services/web/frontend/js/features/project-list/components/sidebar/sidebar.tsx

@@ -0,0 +1,45 @@
+import NewProjectButton from '../new-project-button'
+import SidebarFilters from './sidebar-filters'
+import AddAffiliation, { useAddAffiliation } from './add-affiliation'
+import { usePersistedResize } from '../../../../shared/hooks/use-resize'
+
+function Sidebar() {
+  const { show: showAddAffiliationWidget } = useAddAffiliation()
+  const { mousePos, getHandleProps, getTargetProps } = usePersistedResize({
+    name: 'project-sidebar',
+  })
+
+  return (
+    <div
+      className="project-list-sidebar-wrapper-react hidden-xs"
+      {...getTargetProps({
+        style: {
+          ...(mousePos?.x && { flexBasis: `${mousePos.x}px` }),
+        },
+      })}
+    >
+      <div className="project-list-sidebar-subwrapper">
+        <aside className="project-list-sidebar-react">
+          <NewProjectButton id="new-project-button-sidebar" />
+          <SidebarFilters />
+          {showAddAffiliationWidget && <hr />}
+          <AddAffiliation />
+        </aside>
+      </div>
+      <div
+        {...getHandleProps({
+          style: {
+            position: 'absolute',
+            zIndex: 1,
+            top: 0,
+            right: '-2px',
+            height: '100%',
+            width: '4px',
+          },
+        })}
+      />
+    </div>
+  )
+}
+
+export default Sidebar

+ 113 - 0
services/web/frontend/js/shared/hooks/use-resize.ts

@@ -0,0 +1,113 @@
+import { useState, useEffect, useRef } from 'react'
+import usePersistedState from './use-persisted-state'
+import { Nullable } from '../../../../types/utils'
+
+type Pos = Nullable<{
+  x: number
+}>
+
+function useResizeBase(
+  state: [Pos, React.Dispatch<React.SetStateAction<Pos>>]
+) {
+  const [mousePos, setMousePos] = state
+  const isResizingRef = useRef(false)
+  const handleRef = useRef<HTMLElement | null>(null)
+  const defaultHandleStyles = useRef<React.CSSProperties>({
+    cursor: 'col-resize',
+    userSelect: 'none',
+  })
+
+  useEffect(() => {
+    const handleMouseDown = function (e: MouseEvent) {
+      if (e.button !== 0) {
+        return
+      }
+
+      if (defaultHandleStyles.current.cursor) {
+        document.body.style.cursor = defaultHandleStyles.current.cursor
+      }
+
+      isResizingRef.current = true
+    }
+
+    const handle = handleRef.current
+    handle?.addEventListener('mousedown', handleMouseDown)
+
+    return () => {
+      handle?.removeEventListener('mousedown', handleMouseDown)
+    }
+  }, [])
+
+  useEffect(() => {
+    const handleMouseUp = function () {
+      document.body.style.cursor = 'default'
+      isResizingRef.current = false
+    }
+
+    document.addEventListener('mouseup', handleMouseUp)
+
+    return () => {
+      document.removeEventListener('mouseup', handleMouseUp)
+    }
+  }, [])
+
+  useEffect(() => {
+    const handleMouseMove = function (e: MouseEvent) {
+      if (isResizingRef.current) {
+        setMousePos({ x: e.clientX })
+      }
+    }
+
+    document.addEventListener('mousemove', handleMouseMove)
+
+    return () => {
+      document.removeEventListener('mousemove', handleMouseMove)
+    }
+  }, [setMousePos])
+
+  const getTargetProps = ({ style }: { style?: React.CSSProperties } = {}) => {
+    return {
+      style: {
+        ...style,
+      },
+    }
+  }
+
+  const setHandleRef = (node: HTMLElement | null) => {
+    handleRef.current = node
+  }
+
+  const getHandleProps = ({ style }: { style?: React.CSSProperties } = {}) => {
+    if (style?.cursor) {
+      defaultHandleStyles.current.cursor = style.cursor
+    }
+
+    return {
+      style: {
+        ...defaultHandleStyles.current,
+        ...style,
+      },
+      ref: setHandleRef,
+    }
+  }
+
+  return <const>{
+    mousePos,
+    getHandleProps,
+    getTargetProps,
+  }
+}
+
+function useResize() {
+  const state = useState<Pos>(null)
+
+  return useResizeBase(state)
+}
+
+function usePersistedResize({ name }: { name: string }) {
+  const state = usePersistedState<Pos>(`resizeable-${name}`, null)
+
+  return useResizeBase(state)
+}
+
+export { useResize, usePersistedResize }

+ 4 - 0
services/web/frontend/stylesheets/app/project-list-react.less

@@ -33,9 +33,12 @@
   }
 
   .project-list-sidebar-wrapper-react {
+    position: relative;
     background-color: @sidebar-bg;
     flex: @project-list-sidebar-wrapper-flex;
     min-height: calc(~'100vh -' @header-height);
+    max-width: 320px;
+    min-width: 200px;
 
     .project-list-sidebar-subwrapper {
       display: flex;
@@ -722,6 +725,7 @@
 
 .project-list-sidebar-survey-wrapper {
   position: fixed;
+  z-index: 1;
   bottom: 0;
   left: 0;
   width: 15%;

+ 117 - 0
services/web/test/frontend/shared/hooks/use-resize.spec.tsx

@@ -0,0 +1,117 @@
+import {
+  usePersistedResize,
+  useResize,
+} from '../../../../frontend/js/shared/hooks/use-resize'
+
+function Template({
+  mousePos,
+  getTargetProps,
+  getHandleProps,
+}: ReturnType<typeof useResize>) {
+  return (
+    <div
+      style={{
+        height: '100vh',
+        display: 'flex',
+        alignItems: 'center',
+      }}
+    >
+      <div style={{ position: 'relative' }}>
+        <div
+          id="target"
+          {...getTargetProps({
+            style: {
+              width: '200px',
+              height: '200px',
+              border: '2px solid black',
+              ...(mousePos?.x && { width: `${mousePos.x}px` }),
+            },
+          })}
+        >
+          Demo content demo content demo content demo content demo content demo
+          content
+        </div>
+        <div
+          id="handle"
+          {...getHandleProps({
+            style: {
+              position: 'absolute',
+              top: 0,
+              right: 0,
+              height: '100%',
+              width: '4px',
+              backgroundColor: 'red',
+            },
+          })}
+        />
+      </div>
+    </div>
+  )
+}
+
+function PersistedResizeTest() {
+  const props = usePersistedResize({ name: 'test' })
+
+  return <Template {...props} />
+}
+
+function ResizeTest() {
+  const props = useResize()
+
+  return <Template {...props} />
+}
+
+describe('useResize', function () {
+  it('should apply provided styles to the target', function () {
+    cy.mount(<ResizeTest />)
+
+    // test a css prop being applied
+    cy.get('#target').should('have.css', 'width', '200px')
+  })
+
+  it('should apply provided styles to the handle', function () {
+    cy.mount(<ResizeTest />)
+
+    // test a css prop being applied
+    cy.get('#handle').should('have.css', 'width', '4px')
+  })
+
+  it('should apply default styles to the handle', function () {
+    cy.mount(<ResizeTest />)
+
+    cy.get('#handle')
+      .should('have.css', 'cursor', 'col-resize')
+      .and('have.css', 'user-select', 'none')
+  })
+
+  it('should resize the target horizontally on mousedown and mousemove', function () {
+    const xPos = 400
+    cy.mount(<ResizeTest />)
+
+    cy.get('#handle')
+      .trigger('mousedown', { button: 0 })
+      .trigger('mousemove', { clientX: xPos })
+      .trigger('mouseup')
+
+    cy.get('#target').should('have.css', 'width', `${xPos}px`)
+  })
+
+  it('should persist the resize data', function () {
+    const xPos = 400
+    cy.mount(<PersistedResizeTest />)
+
+    cy.get('#handle')
+      .trigger('mousedown', { button: 0 })
+      .trigger('mousemove', { clientX: xPos })
+      .trigger('mouseup')
+
+    cy.window()
+      .its('localStorage.resizeable-test')
+      .should('eq', `{"x":${xPos}}`)
+
+    // render the component again
+    cy.mount(<PersistedResizeTest />)
+
+    cy.get('#target').should('have.css', 'width', `${xPos}px`)
+  })
+})