#683 quick search on mobile (#749)

Co-authored-by: lethemanh <lethemanh@lethemanhs-MacBook-Pro.local>
This commit is contained in:
lethemanh
2026-04-08 18:02:08 +07:00
committed by GitHub
parent d5c72c9512
commit e29cd8dc42
31 changed files with 773 additions and 265 deletions
+2 -1
View File
@@ -1,4 +1,5 @@
import { PeopleSearch, User } from '@/components/Attendees/PeopleSearch'
import { PeopleSearch } from '@/components/Attendees/PeopleSearch'
import { User } from '@/components/Attendees/types'
import { searchUsers } from '@/features/User/userAPI'
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
+81
View File
@@ -0,0 +1,81 @@
import { Chip, useTheme, Icon, IconButton } from '@linagora/twake-mui'
import { User } from './types'
import { getAccessiblePair } from '@/utils/getAccessiblePair'
import { ReactElement } from 'react'
import CloseIcon from '@mui/icons-material/Close'
import CircleIcon from '@mui/icons-material/Circle'
export interface AttendeeChipProps {
option: string | User
getTagProps: (args: { index: number }) => {
key: number
className: string
disabled: boolean
'data-item-index': number
tabIndex: -1
onDelete: (event: unknown) => void
}
getChipIcon?: (user: User) => ReactElement
index: number
}
export const AttendeeChip: React.FC<AttendeeChipProps> = ({
option,
getTagProps,
getChipIcon,
index
}) => {
const theme = useTheme()
const isString = typeof option === 'string'
const label = isString ? option : option.displayName || option.email
const chipColor = isString
? theme.palette.grey[200]
: (option.color?.light ?? theme.palette.grey[200])
const textColor = getAccessiblePair(chipColor, theme)
const renderIcon = (): ReactElement | undefined => {
if (!isString && getChipIcon) {
return getChipIcon(option)
}
if (chipColor) {
return (
<Icon sx={{ display: 'flex', alignItems: 'center' }}>
<CircleIcon sx={{ color: chipColor }} />
</Icon>
)
}
}
const renderDeleteIcon = (): ReactElement => {
return (
<IconButton
sx={{
backgroundColor: theme.palette.grey[500],
width: '20px',
height: '20px'
}}
size="small"
>
<CloseIcon fontSize="small" />
</IconButton>
)
}
return (
<Chip
{...getTagProps({ index })}
key={label}
variant="filled"
color="secondary"
icon={renderIcon()}
deleteIcon={renderDeleteIcon()}
style={{
color: textColor,
maxWidth: '200px'
}}
label={label}
/>
)
}
@@ -0,0 +1,57 @@
import {
Avatar,
ListItem,
ListItemAvatar,
ListItemText
} from '@linagora/twake-mui'
import React, { HTMLAttributes } from 'react'
import { stringAvatar } from '@/components/Event/utils/eventUtils'
import { ResourceIcon } from './ResourceIcon'
import { User } from './types'
export interface AttendeeOptionsListProps extends HTMLAttributes<HTMLLIElement> {
options: User[]
onOptionClick?: (user: User) => void
selectedUsers: User[]
}
export const AttendeeOptionsList: React.FC<AttendeeOptionsListProps> = ({
options,
onOptionClick,
selectedUsers,
...props
}) => {
return (
<>
{options.map(option => {
if (selectedUsers.find(u => u.email === option.email)) return null
const isResource = option.objectType === 'resource'
return (
<ListItem
key={option.email}
onClick={() => onOptionClick?.(option)}
disableGutters
sx={{ cursor: 'pointer', py: 1 }}
{...props}
>
<ListItemAvatar>
{isResource ? (
<ResourceIcon avatarUrl={option.avatarUrl} />
) : (
<Avatar {...stringAvatar(option.displayName || option.email)} />
)}
</ListItemAvatar>
<ListItemText
primary={option.displayName || option.email}
secondary={!isResource ? option.email : undefined}
slotProps={{
primary: { variant: 'body2' },
secondary: { variant: 'caption' }
}}
/>
</ListItem>
)
})}
</>
)
}
+2 -2
View File
@@ -4,9 +4,9 @@ import { useEffect, useRef, useState } from 'react'
import { FreeBusyIndicator } from './FreeBusyIndicator'
import {
ExtendedAutocompleteRenderInputParams,
PeopleSearch,
User
PeopleSearch
} from './PeopleSearch'
import { User } from './types'
import { FreeBusyMap, useAttendeesFreeBusy } from './useFreeBusy'
const attendeeToUser = (a: userAttendee, openpaasId = ''): User => ({
+111 -97
View File
@@ -1,42 +1,32 @@
import { getAccessiblePair } from '@/utils/getAccessiblePair'
import { stringAvatar } from '@/components/Event/utils/eventUtils'
import { useUserSearch } from './useUserSearch'
import { SnackbarAlert } from '@/components/Loading/SnackBarAlert'
import CloseIcon from '@mui/icons-material/Close'
import {
Autocomplete,
Avatar,
Chip,
CircularProgress,
ListItem,
ListItemAvatar,
ListItemText,
PaperProps,
PopperProps,
TextField,
useTheme,
type AutocompleteRenderInputParams
type AutocompleteRenderInputParams,
Box
} from '@linagora/twake-mui'
import { AttendeeOptionsList } from './AttendeeOptionsList'
import PeopleOutlineOutlinedIcon from '@mui/icons-material/PeopleOutlineOutlined'
import {
HTMLAttributes,
ReactElement,
useCallback,
useEffect,
useMemo,
useRef,
type ReactNode,
type SyntheticEvent
} from 'react'
import { useI18n } from 'twake-i18n'
import { ResourceIcon } from './ResourceIcon'
import { isValidEmail } from '../../utils/isValidEmail'
import { usePasteHandler } from './usePasteHandler'
export interface User {
email: string
displayName: string
avatarUrl?: string
openpaasId?: string
color?: Record<string, string>
objectType?: string
}
import { User } from './types'
import { AttendeeChip } from './AttendeeChip'
import { SearchState } from '../Calendar/utils/tempSearchUtil'
export interface ExtendedAutocompleteRenderInputParams extends AutocompleteRenderInputParams {
error?: boolean
@@ -46,19 +36,7 @@ export interface ExtendedAutocompleteRenderInputParams extends AutocompleteRende
onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void
}
export function PeopleSearch({
selectedUsers,
onChange,
objectTypes,
disabled,
freeSolo,
onToggleEventPreview,
placeholder,
inputSlot,
customRenderInput,
customSlotProps,
getChipIcon
}: {
export interface PeopleSearchProps {
selectedUsers: User[]
onChange: (event: SyntheticEvent, users: User[]) => void
objectTypes: string[]
@@ -66,7 +44,7 @@ export function PeopleSearch({
freeSolo?: boolean
onToggleEventPreview?: () => void
placeholder?: string
inputSlot?: (params: ExtendedAutocompleteRenderInputParams) => React.ReactNode
inputSlot?: (params: ExtendedAutocompleteRenderInputParams) => ReactNode
customRenderInput?: (
params: AutocompleteRenderInputParams,
query: string,
@@ -77,12 +55,34 @@ export function PeopleSearch({
paper?: Partial<PaperProps>
listbox?: Partial<HTMLAttributes<HTMLUListElement>>
}
getChipIcon?: (user: User) => ReactNode
}) {
getChipIcon?: (user: User) => ReactElement
hideOptions?: boolean
onSearchStateChange?: (state: SearchState) => void
inputValue?: string
}
export const PeopleSearch: React.FC<PeopleSearchProps> = ({
selectedUsers,
onChange,
objectTypes,
disabled,
freeSolo,
onToggleEventPreview,
placeholder,
inputSlot,
customRenderInput,
customSlotProps,
getChipIcon,
hideOptions,
onSearchStateChange,
inputValue
}) => {
const { t } = useI18n()
const searchPlaceholder = placeholder ?? t('peopleSearch.placeholder')
const errorMessage = t('peopleSearch.searchError')
const lastQueryTimeRef = useRef(0)
const {
query,
setQuery,
@@ -96,10 +96,28 @@ export function PeopleSearch({
snackbarOpen,
setSnackbarOpen,
snackbarMessage,
setSnackbarMessage
setSnackbarMessage,
queryTime
} = useUserSearch<User>({ objectTypes, errorMessage })
const theme = useTheme()
const onSearchStateChangeRef = useRef(onSearchStateChange)
useEffect(() => {
onSearchStateChangeRef.current = onSearchStateChange
}, [onSearchStateChange])
useEffect(() => {
if (!hideOptions) return
if (queryTime !== lastQueryTimeRef.current) {
lastQueryTimeRef.current = queryTime
onSearchStateChangeRef.current?.({ query, options, loading })
}
}, [queryTime, query, options, loading, hideOptions])
useEffect(() => {
if (inputValue !== undefined) {
setQuery(inputValue)
}
}, [inputValue, setQuery])
const handleBlurCommit = useCallback(
(event: React.SyntheticEvent) => {
@@ -162,7 +180,9 @@ export function PeopleSearch({
}
}
const handleEnterKey = (e: React.KeyboardEvent<HTMLInputElement>) => {
const handleEnterKey = (
e: React.KeyboardEvent<HTMLInputElement>
): void => {
if (e.key === 'Enter' && onToggleEventPreview) {
e.preventDefault()
onToggleEventPreview()
@@ -210,6 +230,11 @@ export function PeopleSearch({
{...defaultTextFieldProps}
InputProps={inputProps}
size="medium"
sx={{
'& .MuiInputBase-input': {
maxWidth: '90%'
}
}}
/>
</>
)
@@ -225,6 +250,24 @@ export function PeopleSearch({
]
)
const isOpenOptions = useMemo(() => {
if (hideOptions) return false
if (customRenderInput) {
return (
isOpen && !!query && ((loading && hasSearched) || options.length > 0)
)
}
return isOpen && !!query && (loading || hasSearched)
}, [
customRenderInput,
hasSearched,
hideOptions,
isOpen,
loading,
options.length,
query
])
return (
<>
<Autocomplete
@@ -235,11 +278,7 @@ export function PeopleSearch({
autoComplete={false}
clearOnBlur={false}
onBlur={freeSolo ? handleBlurCommit : undefined}
open={
customRenderInput
? isOpen && !!query && (loading || options.length > 0)
: isOpen && !!query && (loading || hasSearched)
}
open={isOpenOptions}
onOpen={() => setIsOpen(true)}
onClose={() => setIsOpen(false)}
disabled={disabled}
@@ -256,8 +295,15 @@ export function PeopleSearch({
}
}}
sx={{
'& .MuiAutocomplete-inputRoot': {
py: 0
'& .MuiAutocomplete-inputRoot.MuiOutlinedInput-root': {
py: 0,
display: 'flex',
alignItems: 'center',
flexWrap: 'wrap',
flexDirection: 'row'
},
'& .MuiInputBase-input': {
maxWidth: '80%'
}
}}
filterSelectedOptions
@@ -302,58 +348,26 @@ export function PeopleSearch({
? customRenderInput(params, query, setQuery)
: defaultRenderInput(params)
}
renderOption={(props, option) => {
if (selectedUsers.find(u => u.email === option.email)) return null
const { key, ...otherProps } = props
const isResource = option.objectType === 'resource'
return (
<ListItem key={key + option?.email} {...otherProps} disableGutters>
<ListItemAvatar>
{isResource ? (
<ResourceIcon avatarUrl={option.avatarUrl} />
) : (
<Avatar
{...stringAvatar(option.displayName || option.email)}
/>
)}
</ListItemAvatar>
<ListItemText
primary={option.displayName}
secondary={isResource ? '' : option.email}
slotProps={{
primary: { variant: 'body2' },
secondary: { variant: 'caption' }
}}
renderOption={(props, option) => (
<AttendeeOptionsList
options={[option]}
selectedUsers={selectedUsers}
{...props}
/>
)}
renderValue={(value, getTagProps) => (
<Box display="flex" flexWrap="wrap">
{value.map((option, index) => (
<AttendeeChip
key={index}
option={option}
getTagProps={getTagProps}
index={index}
getChipIcon={getChipIcon}
/>
</ListItem>
)
}}
renderValue={(value, getTagProps) =>
value.map((option, index) => {
const isString = typeof option === 'string'
const label = isString ? option : option.displayName || option.email
const chipColor = isString
? theme.palette.grey[200]
: (option.color?.light ?? theme.palette.grey[200])
const textColor = getAccessiblePair(chipColor, theme)
return (
<Chip
{...getTagProps({ index })}
key={label}
icon={
!isString && getChipIcon ? getChipIcon(option) : undefined
}
deleteIcon={<CloseIcon />}
style={{
backgroundColor: chipColor,
color: textColor
}}
label={label}
/>
)
})
}
))}
</Box>
)}
/>
<SnackbarAlert
open={snackbarOpen}
+8
View File
@@ -0,0 +1,8 @@
export interface User {
email: string
displayName: string
avatarUrl?: string
openpaasId?: string
color?: Record<string, string>
objectType?: string
}
+1 -1
View File
@@ -1,7 +1,7 @@
import { useCallback } from 'react'
import type { SyntheticEvent } from 'react'
import { isValidEmail } from '../../utils/isValidEmail'
import type { User } from './PeopleSearch'
import type { User } from './types'
export function usePasteHandler({
freeSolo,
+45 -20
View File
@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'
import { useState, useEffect, useRef } from 'react'
import { searchUsers } from '@/features/User/userAPI'
export interface UseUserSearchProps {
@@ -9,7 +9,22 @@ export interface UseUserSearchProps {
export function useUserSearch<T>({
objectTypes,
errorMessage
}: UseUserSearchProps) {
}: UseUserSearchProps): {
query: string
setQuery: (query: string) => void
loading: boolean
options: T[]
hasSearched: boolean
isOpen: boolean
setIsOpen: (isOpen: boolean) => void
inputError: string | null
setInputError: (error: string | null) => void
snackbarOpen: boolean
setSnackbarOpen: (open: boolean) => void
snackbarMessage: string
setSnackbarMessage: (message: string) => void
queryTime: number
} {
const [query, setQuery] = useState('')
const [loading, setLoading] = useState(false)
const [options, setOptions] = useState<T[]>([])
@@ -20,10 +35,18 @@ export function useUserSearch<T>({
const [snackbarOpen, setSnackbarOpen] = useState(false)
const [snackbarMessage, setSnackbarMessage] = useState('')
const [queryTime, setQueryTime] = useState(0)
const objectTypesRef = useRef(objectTypes)
useEffect(() => {
objectTypesRef.current = objectTypes
}, [objectTypes])
useEffect(() => {
let cancelled = false
const delayDebounceFn = setTimeout(async () => {
const handleQueryChange = async (): Promise<void> => {
if (!query.trim()) {
if (!cancelled) {
setOptions([])
@@ -33,35 +56,36 @@ export function useUserSearch<T>({
return
}
if (!cancelled) {
setLoading(true)
setHasSearched(false)
}
if (cancelled) return
setLoading(true)
setHasSearched(false)
try {
const res = await searchUsers(query, objectTypes)
if (!cancelled) {
setOptions(res as unknown as T[])
setHasSearched(true)
}
const res = await searchUsers(query, objectTypesRef.current)
setOptions(res as unknown as T[])
setHasSearched(true)
} catch {
if (!cancelled) {
setHasSearched(false)
setSnackbarMessage(errorMessage)
setSnackbarOpen(true)
}
setHasSearched(false)
setSnackbarMessage(errorMessage)
setSnackbarOpen(true)
} finally {
if (!cancelled) {
setLoading(false)
setQueryTime(Date.now())
}
}
}
const delayDebounceFn = setTimeout(() => {
void handleQueryChange()
}, 300)
return () => {
return (): void => {
cancelled = true
clearTimeout(delayDebounceFn)
}
}, [objectTypes, query, errorMessage])
}, [objectTypesRef, query, errorMessage])
return {
query,
@@ -76,6 +100,7 @@ export function useUserSearch<T>({
snackbarOpen,
setSnackbarOpen,
snackbarMessage,
setSnackbarMessage
setSnackbarMessage,
queryTime
}
}
+18 -2
View File
@@ -27,7 +27,7 @@ import moment from 'moment-timezone'
import { MutableRefObject, useEffect, useMemo, useRef, useState } from 'react'
import { useI18n } from 'twake-i18n'
import { useCalendarDataLoader } from '../../features/Calendars/useCalendarLoader'
import { User } from '../Attendees/PeopleSearch'
import { User } from '../Attendees/types'
import { EventErrorSnackbar } from '../Error/ErrorSnackbar'
import { EventErrorHandler } from '../Error/EventErrorHandler'
import { EditModeDialog } from '../Event/EditModeDialog'
@@ -36,7 +36,6 @@ import './Calendar.styl'
import './CustomCalendar.styl'
import { useCalendarEventHandlers } from './hooks/useCalendarEventHandlers'
import { useCalendarViewHandlers } from './hooks/useCalendarViewHandlers'
import Sidebar from './Sidebar/SideBar'
import { TimezoneSelector } from './TimezoneSelector'
import {
eventToFullCalendarFormat,
@@ -44,6 +43,9 @@ import {
updateSlotLabelVisibility
} from './utils/calendarUtils'
import { CALENDAR_VIEWS } from './utils/constants'
import Sidebar from './Sidebar/SideBar'
import TempSearchDialog from './TempSearchDialog'
import { setIsMobileSearchOpen } from '@/features/Calendars/CalendarSlice'
const localeMap: Record<string, LocaleInput | undefined> = {
fr: frLocale,
@@ -609,6 +611,20 @@ const CalendarApp: React.FC<CalendarAppProps> = ({
)}
<EventErrorSnackbar messages={eventErrors} onClose={handleErrorClose} />
</div>
{isMobile && (
<TempSearchDialog
tempUsers={tempUsers}
setTempUsers={setTempUsers}
onClose={() => {
dispatch(setIsMobileSearchOpen(false))
onCloseSidebar()
}}
handleToggleEventPreview={() =>
eventHandlers.handleDateSelect(null as unknown as DateSelectArg)
}
/>
)}
</main>
)
}
@@ -19,7 +19,8 @@ import HighlightOffIcon from '@mui/icons-material/HighlightOff'
import PeopleOutlineOutlinedIcon from '@mui/icons-material/PeopleOutlineOutlined'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useI18n } from 'twake-i18n'
import { PeopleSearch, User } from '../Attendees/PeopleSearch'
import { PeopleSearch } from '../Attendees/PeopleSearch'
import { User } from '../Attendees/types'
import { FieldWithLabel } from '../Event/components/FieldWithLabel'
import { stringAvatar } from '../Event/utils/eventUtils'
import { ResourceAdmin } from './ResourceAdmins'
+2 -1
View File
@@ -16,7 +16,8 @@ import {
import CloseIcon from '@mui/icons-material/Close'
import { useState } from 'react'
import { useI18n } from 'twake-i18n'
import { PeopleSearch, User } from '../Attendees/PeopleSearch'
import { PeopleSearch } from '../Attendees/PeopleSearch'
import { User } from '../Attendees/types'
import { ResponsiveDialog } from '../Dialog'
import { stringAvatar } from '../Event/utils/eventUtils'
import { ColorPicker } from './CalendarColorPicker'
@@ -0,0 +1,60 @@
import React from 'react'
import { TextField } from '@linagora/twake-mui'
import { PeopleSearch } from '../Attendees/PeopleSearch'
import { User } from '../Attendees/types'
import { useI18n } from 'twake-i18n'
import { SearchState } from './utils/tempSearchUtil'
interface MobileTempSearchInputProps {
tempUsers: User[]
searchState: SearchState
handleToggleEventPreview: () => void
handleChange: (event: React.SyntheticEvent, users: User[]) => void
handleSearchChange: ({ query, options, loading }: SearchState) => void
}
export const MobileTempSearchInput: React.FC<MobileTempSearchInputProps> = ({
tempUsers,
searchState,
handleToggleEventPreview,
handleChange,
handleSearchChange
}) => {
const { t } = useI18n()
return (
<PeopleSearch
objectTypes={['user', 'resource']}
selectedUsers={tempUsers}
onChange={handleChange}
onToggleEventPreview={handleToggleEventPreview}
placeholder={t('peopleSearch.availabilityPlaceholder')}
hideOptions
onSearchStateChange={handleSearchChange}
inputValue={searchState.query}
inputSlot={params => (
<TextField
{...params}
autoFocus
size="small"
sx={{
'& .MuiOutlinedInput-notchedOutline': {
border: 'none'
},
...(tempUsers.length > 0
? {
'& .MuiOutlinedInput-root': {
flexDirection: 'column',
alignItems: 'start',
'& .MuiInputBase-input': {
width: '100%'
}
}
}
: undefined)
}}
/>
)}
/>
)
}
@@ -7,8 +7,9 @@ import HelpOutlineIcon from '@mui/icons-material/HelpOutline'
import { useI18n } from 'twake-i18n'
import { AppListMenu } from '@/components/Menubar/AppListMenu'
import { UserMenu } from '@/components/Menubar/UserMenu'
import { useAppSelector } from '@/app/hooks'
import { useAppDispatch, useAppSelector } from '@/app/hooks'
import { useUtilMenus } from '../hooks/useUtilMenus'
import { setIsMobileSearchOpen } from '@/features/Calendars/CalendarSlice'
export const MobileSidebar: React.FC<CalendarSidebarProps> = ({
open,
@@ -26,6 +27,7 @@ export const MobileSidebar: React.FC<CalendarSidebarProps> = ({
}) => {
const { t } = useI18n()
const user = useAppSelector(state => state.user.userData)
const dispatch = useAppDispatch()
const {
anchorEl,
@@ -39,6 +41,10 @@ export const MobileSidebar: React.FC<CalendarSidebarProps> = ({
handleLogoutClick
} = useUtilMenus()
const openSearch = (): void => {
dispatch(setIsMobileSearchOpen(true))
}
return (
<Drawer
variant="temporary"
@@ -119,6 +125,7 @@ export const MobileSidebar: React.FC<CalendarSidebarProps> = ({
setTempUsers={setTempUsers}
selectedCalendars={selectedCalendars}
setSelectedCalendars={setSelectedCalendars}
openSearchOnMobile={openSearch}
/>
</Drawer>
)
+2 -2
View File
@@ -1,7 +1,7 @@
import { useScreenSizeDetection } from '@/useScreenSizeDetection'
import { CalendarApi } from '@fullcalendar/core'
import { Dispatch, MutableRefObject, SetStateAction } from 'react'
import { User } from '../../Attendees/PeopleSearch'
import { User } from '../../Attendees/types'
import { DesktopSidebar } from './DesktopSidebar'
import { TabletSidebar } from './TabletSidebar'
import { MobileSidebar } from './MobileSidebar'
@@ -32,7 +32,7 @@ const Sidebar: React.FC<CalendarSidebarProps> = (
return <MobileSidebar {...sharedProps} />
}
return isTablet || isMobile ? (
return isTablet ? (
<TabletSidebar {...sharedProps} />
) : (
<DesktopSidebar {...sharedProps} />
@@ -3,20 +3,23 @@ import CalendarSelection from '../CalendarSelection'
import { TempCalendarsInput } from '../TempCalendarsInput'
import { CalendarSidebarProps } from './SideBar'
export function SidebarCommonContent({
export const SidebarCommonContent: React.FC<
Pick<
CalendarSidebarProps,
| 'onCreateEvent'
| 'tempUsers'
| 'setTempUsers'
| 'selectedCalendars'
| 'setSelectedCalendars'
> & { openSearchOnMobile?: () => void }
> = ({
onCreateEvent,
tempUsers,
setTempUsers,
selectedCalendars,
setSelectedCalendars
}: Pick<
CalendarSidebarProps,
| 'onCreateEvent'
| 'tempUsers'
| 'setTempUsers'
| 'selectedCalendars'
| 'setSelectedCalendars'
>) {
setSelectedCalendars,
openSearchOnMobile
}) => {
return (
<>
<Box sx={{ mb: 3, mt: 2 }}>
@@ -24,6 +27,7 @@ export function SidebarCommonContent({
tempUsers={tempUsers}
setTempUsers={setTempUsers}
handleToggleEventPreview={onCreateEvent}
onOpenSearchOnMobile={openSearchOnMobile}
/>
</Box>
<Box className="calendarList">
+42 -114
View File
@@ -1,78 +1,39 @@
import { useAppDispatch, useAppSelector } from '@/app/hooks'
import { removeTempCal } from '@/features/Calendars/CalendarSlice'
import { Calendar } from '@/features/Calendars/CalendarTypes'
import { getTempCalendarsListAsync } from '@/features/Calendars/services'
import { setView } from '@/features/Settings/SettingsSlice'
import { defaultColors } from '@/utils/defaultColors'
import { TextField } from '@linagora/twake-mui'
import { useRef } from 'react'
import {
TextField,
useTheme,
useMediaQuery,
InputAdornment
} from '@linagora/twake-mui'
import SearchIcon from '@mui/icons-material/Search'
import React from 'react'
import { useI18n } from 'twake-i18n'
import { PeopleSearch, User } from '../Attendees/PeopleSearch'
import { PeopleSearch } from '../Attendees/PeopleSearch'
import { User } from '../Attendees/types'
import { useTempSearch } from './hooks/useTempSearch'
const requestControllers = new Map<string, AbortController>()
export function TempCalendarsInput({
tempUsers,
setTempUsers,
handleToggleEventPreview
}: {
export const TempCalendarsInput: React.FC<{
tempUsers: User[]
setTempUsers: (users: User[]) => void
handleToggleEventPreview: () => void
}) {
const dispatch = useAppDispatch()
const tempcalendars = useAppSelector(state => state.calendars.templist) ?? {}
onOpenSearchOnMobile?: () => void
}> = ({
tempUsers,
setTempUsers,
handleToggleEventPreview,
onOpenSearchOnMobile
}) => {
const theme = useTheme()
const isMobile = useMediaQuery(theme.breakpoints.down('sm'))
const { t } = useI18n()
const prevUsersRef = useRef<User[]>([])
const userColorsRef = useRef(
new Map<string, { light: string; dark: string }>()
)
const { handleUserChange } = useTempSearch({ setTempUsers, tempUsers })
const handleUserChange = (_: React.SyntheticEvent, users: User[]) => {
setTempUsers(users)
const prevUsers = prevUsersRef.current
const addedUsers = users.filter(
u => !prevUsers.some(p => p.email === u.email)
)
const removedUsers = prevUsers.filter(
p => !users.some(u => u.email === p.email)
)
prevUsersRef.current = users
if (addedUsers.length > 0) {
dispatch(setView('calendar'))
for (const user of addedUsers) {
const controller = new AbortController()
requestControllers.set(user.email, controller)
if (!userColorsRef.current.has(user.email)) {
const usedLights = Array.from(userColorsRef.current.values()).map(
c => c.light
)
const colorPair = generateDistinctColor(usedLights)
userColorsRef.current.set(user.email, colorPair)
}
user.color = userColorsRef.current.get(user.email) ?? defaultColors[0]
dispatch(getTempCalendarsListAsync(user, { signal: controller.signal }))
}
}
for (const user of removedUsers) {
const controller = requestControllers.get(user.email)
if (controller) {
controller.abort()
requestControllers.delete(user.email)
}
const calIds = buildEmailToCalendarMap(tempcalendars).get(user.email)
calIds?.forEach(id => dispatch(removeTempCal(id)))
userColorsRef.current.delete(user.email)
}
const handleInputFocus = (
e: React.FocusEvent<HTMLInputElement>
): void | undefined => {
if (!isMobile || !onOpenSearchOnMobile) return
onOpenSearchOnMobile()
e.currentTarget.blur()
}
return (
@@ -86,6 +47,20 @@ export function TempCalendarsInput({
<TextField
{...params}
size="small"
onFocus={handleInputFocus}
InputProps={{
...params.InputProps,
endAdornment: (
<React.Fragment>
{params.InputProps.endAdornment}
{isMobile && (
<InputAdornment position="end">
<SearchIcon sx={{ color: 'action.active' }} />
</InputAdornment>
)}
</React.Fragment>
)
}}
sx={
tempUsers.length > 0
? {
@@ -104,50 +79,3 @@ export function TempCalendarsInput({
/>
)
}
function buildEmailToCalendarMap(calRecord: Record<string, Calendar>) {
const map = new Map<string, string[]>()
for (const [id, cal] of Object.entries(calRecord)) {
cal.owner?.emails?.forEach(email => {
const existing = map.get(email)
if (existing) {
existing.push(id)
} else {
map.set(email, [id])
}
})
}
return map
}
function shiftLightness(hex: string, amount: number): string {
const r = parseInt(hex.slice(1, 3), 16)
const g = parseInt(hex.slice(3, 5), 16)
const b = parseInt(hex.slice(5, 7), 16)
const clamp = (v: number) => Math.max(0, Math.min(255, v))
const toHex = (v: number) =>
clamp(v + amount)
.toString(16)
.padStart(2, '0')
return `#${toHex(r)}${toHex(g)}${toHex(b)}`
}
function generateDistinctColor(usedLights: string[]): {
light: string
dark: string
} {
for (const color of defaultColors) {
if (!usedLights.includes(color.light)) return color
}
const cycle = usedLights.length % defaultColors.length
const round = Math.floor(usedLights.length / defaultColors.length)
const base = defaultColors[cycle]
return {
light: shiftLightness(base.light, round * 12),
dark: shiftLightness(base.dark, -(round * 10))
}
}
@@ -0,0 +1,129 @@
import { useAppSelector } from '@/app/hooks'
import React, { useCallback, useEffect, useState } from 'react'
import {
IconButton,
Dialog,
DialogTitle,
DialogContent,
useTheme,
alpha,
DialogActions,
Button
} from '@linagora/twake-mui'
import ArrowBackIcon from '@mui/icons-material/ArrowBack'
import { User } from '../Attendees/types'
import { useI18n } from 'twake-i18n'
import { useTempSearch } from './hooks/useTempSearch'
import { AttendeeOptionsList } from '../Attendees/AttendeeOptionsList'
import { MobileTempSearchInput } from './MobileTempSearchInput'
import { SearchState } from './utils/tempSearchUtil'
interface TempSearchDialogProps {
tempUsers: User[]
setTempUsers: (users: User[]) => void
handleToggleEventPreview: () => void
onClose: () => void
}
export default function TempSearchDialog({
tempUsers,
setTempUsers,
handleToggleEventPreview,
onClose
}: TempSearchDialogProps): JSX.Element {
const { t } = useI18n()
const theme = useTheme()
const isMobileSearchOpen = useAppSelector(
state => state.calendars.isMobileSearchOpen
)
const [searchState, setSearchState] = useState<SearchState>({
query: '',
options: [] as User[],
loading: false
})
useEffect(() => {
const resetSearchState = (): void => {
setSearchState({ query: '', options: [], loading: false })
}
if (isMobileSearchOpen) {
resetSearchState()
}
}, [isMobileSearchOpen])
const { handleUserChange } = useTempSearch({ setTempUsers, tempUsers })
const handleSearchChange = useCallback(
({ query, options, loading }: SearchState): void => {
setSearchState(prev => ({
query: query ?? prev.query,
options: options ?? prev.options,
loading: loading ?? prev.loading
}))
},
[]
)
const handleChange = (event: React.SyntheticEvent, users: User[]): void => {
handleUserChange(event, users)
setSearchState(prev => ({ ...prev, query: '' }))
}
return (
<Dialog
open={isMobileSearchOpen}
onClose={onClose}
fullScreen
slotProps={{
root: {
sx: { borderRadius: 0 }
}
}}
>
<DialogTitle
sx={{
display: 'flex',
alignItems: 'center',
paddingLeft: 1,
paddingRight: 1,
paddingTop: 0.5,
paddingBottom: 0.5,
boxShadow: `0 8px 0px ${alpha(theme.palette.grey[600], theme.palette.action.focusOpacity)}`
}}
>
<IconButton
onClick={onClose}
aria-label={t('common.back')}
sx={{ mr: 1 }}
>
<ArrowBackIcon sx={{ color: '#605D62' }} />
</IconButton>
<MobileTempSearchInput
tempUsers={tempUsers}
handleToggleEventPreview={handleToggleEventPreview}
handleSearchChange={handleSearchChange}
searchState={searchState}
handleChange={handleChange}
/>
</DialogTitle>
<DialogContent sx={{ marginTop: 1 }}>
<AttendeeOptionsList
options={searchState.options || []}
onOptionClick={user =>
handleChange({} as React.SyntheticEvent, [...tempUsers, user])
}
selectedUsers={tempUsers}
/>
</DialogContent>
{tempUsers.length > 0 && (
<DialogActions sx={{ display: 'flex', justifyContent: 'center' }}>
<Button variant="contained" onClick={onClose}>
{t('common.show')}
</Button>
</DialogActions>
)}
</Dialog>
)
}
@@ -1,5 +1,5 @@
import { AppDispatch } from '@/app/store'
import { User } from '@/components/Attendees/PeopleSearch'
import { User } from '@/components/Attendees/types'
import { formatLocalDateTime } from '@/components/Event/utils/dateTimeFormatters'
import { Calendar } from '@/features/Calendars/CalendarTypes'
import {
@@ -0,0 +1,100 @@
import { useAppDispatch, useAppSelector } from '@/app/hooks'
import { removeTempCal } from '@/features/Calendars/CalendarSlice'
import { getTempCalendarsListAsync } from '@/features/Calendars/services'
import { setView } from '@/features/Settings/SettingsSlice'
import { defaultColors } from '@/utils/defaultColors'
import { useEffect, useRef } from 'react'
import {
buildEmailToCalendarMap,
generateDistinctColor
} from '../utils/tempSearchUtil'
import { User } from '../../Attendees/types'
const requestControllers = new Map<string, AbortController>()
export const useTempSearch = ({
setTempUsers,
tempUsers
}: {
setTempUsers: (users: User[]) => void
tempUsers?: User[]
}): {
handleUserChange: (event: React.SyntheticEvent, users: User[]) => void
} => {
const dispatch = useAppDispatch()
const tempcalendars = useAppSelector(state => state.calendars.templist) ?? {}
const prevUsersRef = useRef<User[]>([])
const userColorsRef = useRef(
new Map<string, { light: string; dark: string }>()
)
useEffect(() => {
prevUsersRef.current = tempUsers || []
for (const user of tempUsers || []) {
if (!userColorsRef.current.has(user.email)) {
const usedLights = Array.from(userColorsRef.current.values()).map(
c => c.light
)
const colorPair = generateDistinctColor(usedLights)
userColorsRef.current.set(user.email, colorPair)
}
}
}, [tempUsers])
const addTempEvent = (user: User): void => {
const controller = new AbortController()
requestControllers.set(user.email, controller)
if (!userColorsRef.current.has(user.email)) {
const usedLights = Array.from(userColorsRef.current.values()).map(
c => c.light
)
const colorPair = generateDistinctColor(usedLights)
userColorsRef.current.set(user.email, colorPair)
}
user.color = userColorsRef.current.get(user.email) ?? defaultColors[0]
void dispatch(
getTempCalendarsListAsync(user, { signal: controller.signal })
)
}
const removeTempEvent = (user: User): void => {
const controller = requestControllers.get(user.email)
if (controller) {
controller.abort()
requestControllers.delete(user.email)
}
const calIds = buildEmailToCalendarMap(tempcalendars).get(user.email)
calIds?.forEach(id => dispatch(removeTempCal(id)))
userColorsRef.current.delete(user.email)
}
const handleUserChange = (_: React.SyntheticEvent, users: User[]): void => {
setTempUsers(users)
const prevUsers = prevUsersRef.current
const addedUsers =
users.filter(u => !prevUsers.some(p => p.email === u.email)) ?? []
const removedUsers =
prevUsers.filter(p => !users.some(u => u.email === p.email)) ?? []
prevUsersRef.current = users
dispatch(setView('calendar'))
for (const user of addedUsers) {
addTempEvent(user)
}
for (const user of removedUsers) {
removeTempEvent(user)
}
}
return { handleUserChange }
}
@@ -0,0 +1,58 @@
import { User } from '@/components/Attendees/types'
import { Calendar } from '@/features/Calendars/CalendarTypes'
import { defaultColors } from '@/utils/defaultColors'
export interface SearchState {
query?: string
options?: User[]
loading?: boolean
}
export function buildEmailToCalendarMap(
calRecord: Record<string, Calendar>
): Map<string, string[]> {
const map = new Map<string, string[]>()
for (const [id, cal] of Object.entries(calRecord)) {
cal.owner?.emails?.forEach(email => {
const existing = map.get(email)
if (existing) {
existing.push(id)
} else {
map.set(email, [id])
}
})
}
return map
}
function shiftLightness(hex: string, amount: number): string {
const r = parseInt(hex.slice(1, 3), 16)
const g = parseInt(hex.slice(3, 5), 16)
const b = parseInt(hex.slice(5, 7), 16)
const clamp = (v: number): number => Math.max(0, Math.min(255, v))
const toHex = (v: number): string =>
clamp(v + amount)
.toString(16)
.padStart(2, '0')
return `#${toHex(r)}${toHex(g)}${toHex(b)}`
}
export function generateDistinctColor(usedLights: string[]): {
light: string
dark: string
} {
for (const color of defaultColors) {
if (!usedLights.includes(color.light)) return color
}
const cycle = usedLights.length % defaultColors.length
const round = Math.floor(usedLights.length / defaultColors.length)
const base = defaultColors[cycle]
return {
light: shiftLightness(base.light, round * 12),
dark: shiftLightness(base.dark, -(round * 10))
}
}
+2 -1
View File
@@ -29,7 +29,8 @@ import TuneIcon from '@mui/icons-material/Tune'
import { useEffect, useRef, useState } from 'react'
import { useI18n } from 'twake-i18n'
import UserSearch from '../Attendees/AttendeeSearch'
import { PeopleSearch, User } from '../Attendees/PeopleSearch'
import { PeopleSearch } from '../Attendees/PeopleSearch'
import { User } from '../Attendees/types'
import { CalendarItemList } from '../Calendar/CalendarItemList'
const SEARCH_OBJECT_TYPES = ['user', 'contact']
+4 -1
View File
@@ -40,7 +40,10 @@ export const MobileMenubar: React.FC<MobileMenubarProps> = ({
<>
<header className="menubar">
<div className="left-menu">
<IconButton onClick={onOpenSidebar}>
<IconButton
onClick={onOpenSidebar}
aria-label={t('menubar.toggleSidebar')}
>
<MenuIcon />
</IconButton>
<div className="menu-items">
+8 -2
View File
@@ -30,12 +30,14 @@ const CalendarSlice = createSlice({
list: {} as Record<string, Calendar>,
templist: {} as Record<string, Calendar>,
pending: true,
error: null as string | null
error: null as string | null,
isMobileSearchOpen: false
} as {
list: Record<string, Calendar>
templist: Record<string, Calendar>
pending: boolean
error: string | null
isMobileSearchOpen: boolean
},
reducers: {
createCalendar: (
@@ -109,6 +111,9 @@ const CalendarSlice = createSlice({
}>
) => {
state.list[action.payload.id].color = action.payload.color
},
setIsMobileSearchOpen: (state, action: PayloadAction<boolean>) => {
state.isMobileSearchOpen = action.payload
}
},
extraReducers: builder => {
@@ -554,6 +559,7 @@ export const {
emptyEventsCal,
clearFetchCache,
clearError,
updateCalColor
updateCalColor,
setIsMobileSearchOpen
} = CalendarSlice.actions
export default CalendarSlice.reducer
@@ -4,7 +4,7 @@ import { createAsyncThunk } from '@reduxjs/toolkit'
import { addSharedCalendar } from '../CalendarApi'
import { CalendarInput } from '../types/CalendarData'
import { RejectedError } from '../types/RejectedError'
import { User } from '@/components/Attendees/PeopleSearch'
import { User } from '@/components/Attendees/types'
import { fetchOwnerOfResource } from '../services/helpers'
export const addCalendarResourceAsync = createAsyncThunk<
@@ -1,4 +1,4 @@
import { User } from '@/components/Attendees/PeopleSearch'
import { User } from '@/components/Attendees/types'
import { getCalendarVisibility } from '@/components/Calendar/utils/calendarUtils'
import { formatReduxError } from '@/utils/errorUtils'
import { createAsyncThunk } from '@reduxjs/toolkit'
+1 -1
View File
@@ -1,4 +1,4 @@
import { User } from '@/components/Attendees/PeopleSearch'
import { User } from '@/components/Attendees/types'
import { CalDavLink } from '../api/types'
// Access control entry
+1 -1
View File
@@ -1,4 +1,4 @@
import { User } from '@/components/Attendees/PeopleSearch'
import { User } from '@/components/Attendees/types'
import { api } from '@/utils/apiUtils'
import { BusinessHour } from '../Settings/SettingsSlice'
import { OpenPaasUserData } from './type/OpenPaasUserData'
+3 -1
View File
@@ -56,7 +56,9 @@
"select_timezone": "Select Timezone",
"moreOptions": "More options",
"search": "Search",
"resource": "Resource"
"resource": "Resource",
"back": "Back",
"show": "Show"
},
"search": {
"searchIn": "Search in",
+3 -1
View File
@@ -56,7 +56,9 @@
"select_timezone": "Sélectionner le fuseau horaire",
"moreOptions": "Plus d'options",
"search": "Rechercher",
"resource": "Ressource"
"resource": "Ressource",
"back": "Retour",
"show": "Afficher"
},
"search": {
"searchIn": "Rechercher dans",
+3 -1
View File
@@ -56,7 +56,9 @@
"select_timezone": "Выберите часовой пояс",
"moreOptions": "Больше параметров",
"search": "Поиск",
"resource": "Ресурсы"
"resource": "Ресурсы",
"back": "Назад",
"show": "Показать"
},
"search": {
"searchIn": "Поиск в",
+3 -1
View File
@@ -56,7 +56,9 @@
"select_timezone": "Chọn múi giờ",
"moreOptions": "Thêm tùy chọn",
"search": "Tìm kiếm",
"resource": "Tài nguyên"
"resource": "Tài nguyên",
"back": "Quay lại",
"show": "Hiển thị"
},
"search": {
"searchIn": "Tìm kiếm trong",