#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
+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))
}
}