Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.

## [Unreleased]

### Fixed

- Fix bugs in search by term of user active organizations in user widget

### Added

- Creates infinite scroll of user active organizations in user widget

## [3.0.1] - 2025-10-06

### Added
Expand Down
118 changes: 28 additions & 90 deletions react/components/UserWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ import '../css/user-widget.css'
import { sendStopImpersonateMetric } from '../utils/metrics/impersonate'
import type { ChangeTeamParams } from '../utils/metrics/changeTeam'
import { sendChangeTeamMetric } from '../utils/metrics/changeTeam'
import { UserWidgetTable } from './UserWidget/UserWidgetTable'
import { useDebounceValue } from '../hooks/useDebounce'

const CSS_HANDLES = [
'userWidgetContainer',
Expand All @@ -51,12 +53,6 @@ const CSS_HANDLES = [
'userWidgetModalInput',
'userWidgetModalJoinButton',
'userWidgetModalTotal',
'userWidgetModalTable',
'userWidgetModalTableContainer',
'userWidgetModalTableRow',
'userWidgetModalTableRowChecked',
'userWidgetModalTableCell',
'userWidgetModalTableRadio',
] as const

const SESSION_STORAGE_SHOW_MODAL = 'b2b-organizations-showModal'
Expand Down Expand Up @@ -131,6 +127,8 @@ interface UserWidgetProps {
const sortOrganizations = (a: any, b: any) =>
a.organizationName < b.organizationName ? -1 : 1

const SEARCH_DELAY = 500

const UserWidget: VtexFunctionComponent<UserWidgetProps> = ({
showDropdown = true,
showLoadingIndicator = false,
Expand All @@ -145,6 +143,7 @@ const UserWidget: VtexFunctionComponent<UserWidgetProps> = ({
const [searchTerm, setSearchTerm] = useState('')
const [radioValue, setRadioValue] = useState('')
const [checkSession, setCheckSession] = useState(false)
const searchTermDebounced = useDebounceValue(searchTerm, SEARCH_DELAY)

const [organizationsState, setOrganizationsState] = useState({
organizationOptions: [],
Expand All @@ -155,8 +154,6 @@ const UserWidget: VtexFunctionComponent<UserWidgetProps> = ({
currentRoleName: '',
currentCostCenter: '',
currentOrganizationStatus: '',
dataList: [],
totalDataList: 0,
Comment on lines -158 to -159
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For performance improvement, we removed these derived states to simply derive the query result (see below in this file).

})

const sessionResponse: any = useSessionResponse()
Expand Down Expand Up @@ -370,10 +367,6 @@ const UserWidget: VtexFunctionComponent<UserWidgetProps> = ({
})),
currentOrganization,
currentCostCenter,
dataList: userWidgetData?.getActiveOrganizationsByEmail?.sort(
sortOrganizations
),
totalDataList: userWidgetData?.getActiveOrganizationsByEmail?.length,
currentOrganizationStatus:
userWidgetData?.getOrganizationByIdStorefront?.status,
})
Expand Down Expand Up @@ -461,41 +454,26 @@ const UserWidget: VtexFunctionComponent<UserWidgetProps> = ({
)
return null

const handleSearchOrganizations = (e: any) => {
const { value }: { value: string } = e.target
let dataList

setSearchTerm(e.target.value)
const handleSearchOrganizations = (e: any) => setSearchTerm(e.target.value)

dataList = userWidgetData?.getActiveOrganizationsByEmail?.sort(
sortOrganizations
)
if (value.trim() !== '') {
dataList =
userWidgetData?.getActiveOrganizationsByEmail
?.filter((organization: any) => {
const organizationName =
organization.organizationName?.toLowerCase() ?? ''
const dataList =
userWidgetData?.getActiveOrganizationsByEmail
?.sort(sortOrganizations)
?.filter((organization: any) => {
if (!searchTermDebounced) return true

const costCenterName =
organization.costCenterName?.toLowerCase() ?? ''
const organizationName =
organization.organizationName?.toLowerCase() ?? ''

const searchValue = value.toLowerCase()
const costCenterName = organization.costCenterName?.toLowerCase() ?? ''

return (
organizationName.includes(searchValue) ??
costCenterName.includes(searchValue)
)
})
?.slice(0, 15) ?? []
}
const searchValue = searchTermDebounced.toLowerCase()

setOrganizationsState({
...organizationsState,
dataList,
totalDataList: dataList?.length,
})
}
return (
organizationName.includes(searchValue) ||
costCenterName.includes(searchValue)
)
}) ?? []

return (
<div
Expand Down Expand Up @@ -526,55 +504,15 @@ const UserWidget: VtexFunctionComponent<UserWidgetProps> = ({
</div>
</div>
<div className={`${handles.userWidgetModalTotal} pt4 mb4`}>
{organizationsState?.totalDataList}{' '}
{formatMessage(messages.organizationsFound)}
</div>
<div className={handles.userWidgetModalTableContainer}>
<table className={handles.userWidgetModalTable}>
<tbody>
{organizationsState?.dataList?.map((organization: any) => {
const id = [organization.orgId, organization.costId].join(
','
)

return (
<tr
key={id}
className={`${handles.userWidgetModalTableRow} ${
id === radioValue
? handles.userWidgetModalTableRowChecked
: ''
}`}
onClick={() => setRadioValue(id)}
>
<td className={handles.userWidgetModalTableCell}>
<input
id={id}
value={id}
checked={id === radioValue}
onChange={(e: any) =>
setRadioValue(e.target.value)
}
type="radio"
className={handles.userWidgetModalTableRadio}
/>
</td>
<td className={handles.userWidgetModalTableCell}>
<label htmlFor={id}>
{organization.organizationName}
</label>
</td>
<td className={handles.userWidgetModalTableCell}>
<label htmlFor={id}>
{organization.costCenterName}
</label>
</td>
</tr>
)
})}
</tbody>
</table>
{dataList.length} {formatMessage(messages.organizationsFound)}
</div>
{showModal && (
<UserWidgetTable
list={dataList}
radioValue={radioValue}
setRadioValue={setRadioValue}
/>
)}
</div>
<div className={handles.userWidgetModalJoinButton}>
<Button
Expand Down
109 changes: 109 additions & 0 deletions react/components/UserWidget/UserWidgetTable.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import React, { Fragment, useEffect, useState } from 'react'
import { useCssHandles } from 'vtex.css-handles'
import { ButtonWithIcon, IconCaretDown } from 'vtex.styleguide'

import { useScroll } from '../../hooks/useScroll'

type UserWidgetTableProps = Readonly<{
list?: Array<{
orgId: string
costId: string
organizationName: string
costCenterName: string
}>
radioValue: string
setRadioValue: React.Dispatch<React.SetStateAction<string>>
}>

const CSS_HANDLES = [
'userWidgetModalTotal',
'userWidgetModalTableContainer',
'userWidgetModalTable',
'userWidgetModalTableRow',
'userWidgetModalTableRowChecked',
'userWidgetModalTableRadio',
'userWidgetModalTableCell',
] as const

const DEFAULT_OFFSET = 15

export function UserWidgetTable(props: UserWidgetTableProps) {
const handles = useCssHandles(CSS_HANDLES)
const [offset, setOffset] = useState(DEFAULT_OFFSET)
const { list = [], radioValue, setRadioValue } = props
const slicedList = list.slice(0, offset)
const currentOffset = offset < list.length ? offset : list.length
const onScrollEnd = () => setOffset((prev: number) => prev + DEFAULT_OFFSET)
const scrollRef = useScroll<HTMLDivElement>({ onScrollEnd })

useEffect(() => {
scrollRef.current?.scrollTo(0, 0)
setOffset(DEFAULT_OFFSET)
}, [list.length])

return (
<>
<div
ref={scrollRef}
className={`${handles.userWidgetModalTableContainer} mb4`}
>
<table className={handles.userWidgetModalTable}>
<tbody>
{slicedList.map((organization, index) => {
const id = [organization.orgId, organization.costId, index].join()

return (
<Fragment key={id}>
<tr
className={`${handles.userWidgetModalTableRow} ${
id === radioValue
? handles.userWidgetModalTableRowChecked
: ''
}`}
onClick={() => setRadioValue(id)}
>
<td className={handles.userWidgetModalTableCell}>
<input
id={id}
value={id}
checked={id === radioValue}
onChange={(e: any) => setRadioValue(e.target.value)}
type="radio"
className={handles.userWidgetModalTableRadio}
/>
</td>
<td className={handles.userWidgetModalTableCell}>
<label htmlFor={id}>
{organization.organizationName}
</label>
</td>
<td className={handles.userWidgetModalTableCell}>
<label htmlFor={id}>{organization.costCenterName}</label>
</td>
</tr>
{index === slicedList.length - 1 &&
list.length > currentOffset && (
<tr>
<td colSpan={3} align="center">
<ButtonWithIcon
onClick={onScrollEnd}
icon={<IconCaretDown />}
variation="tertiary"
/>
</td>
</tr>
)}
</Fragment>
)
})}
</tbody>
</table>
</div>
{list.length > DEFAULT_OFFSET && (
<div className="flex justify-end mb4">
{currentOffset}/{list.length}
</div>
)}
</>
)
}
4 changes: 4 additions & 0 deletions react/css/user-widget.css
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
.userWidgetModalTableContainer {
max-height: 60vh;
overflow-y: auto;
}
.userWidgetModalRow .col:last-child {
padding: 1rem;
background: #F5F4F4;
Expand Down
37 changes: 37 additions & 0 deletions react/hooks/useDebounce.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { useCallback, useEffect, useRef, useState } from 'react'

export function useDebounceValue<T>(value: T, delay: number) {
const [debounced, setDebounced] = useState(value)

useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay)

return () => clearTimeout(timer)
}, [value, delay])

return debounced
}

export const useDebounceFunction = <T extends unknown[]>(
fn: (...args: T) => void,
delay: number
) => {
const timerRef = useRef<number>()
const fnRef = useRef(fn)

useEffect(() => {
fnRef.current = fn
}, [fn])

const fnDebounced = useCallback(
(...args: T) => {
window.clearTimeout(timerRef.current)
timerRef.current = window.setTimeout(() => fnRef.current(...args), delay)
},
[delay]
)

useEffect(() => () => window.clearTimeout(timerRef.current), [])

return fnDebounced
}
43 changes: 43 additions & 0 deletions react/hooks/useScroll.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { useCallback, useEffect, useRef } from 'react'

import { useDebounceFunction } from './useDebounce'

type UseScrollArgs = Readonly<{ onScrollEnd: () => void }>

const SCROLL_DELAY = 150
const SCROLL_END_TOLERANCE = 10

export function useScroll<T extends HTMLElement>({
onScrollEnd,
}: UseScrollArgs) {
const ref = useRef<T>(null)
const onScrollEndDebounced = useDebounceFunction(onScrollEnd, SCROLL_DELAY)

const handleScroll = useCallback(() => {
if (!ref.current) return

const scrollTop: number = Math.ceil(ref.current.scrollTop)
const scrollHeight: number = Math.round(ref.current.scrollHeight)
const clientHeight: number = Math.round(ref.current.clientHeight)

const isScrollEnd =
scrollTop + SCROLL_END_TOLERANCE >= scrollHeight - clientHeight

if (isScrollEnd) {
onScrollEndDebounced()
}
}, [onScrollEndDebounced])

useEffect(() => {
if (!ref.current) return

ref.current.addEventListener('scroll', handleScroll)
}, [handleScroll])

useEffect(
() => () => ref.current?.removeEventListener('scroll', handleScroll),
[]
)

return ref
}
Loading