Co-authored-by: lethemanh <lethemanh@lethemanhs-MacBook-Pro.local>
This commit is contained in:
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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 => ({
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
export interface User {
|
||||
email: string
|
||||
displayName: string
|
||||
avatarUrl?: string
|
||||
openpaasId?: string
|
||||
color?: Record<string, string>
|
||||
objectType?: string
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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']
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
import { User } from '@/components/Attendees/PeopleSearch'
|
||||
import { User } from '@/components/Attendees/types'
|
||||
import { CalDavLink } from '../api/types'
|
||||
|
||||
// Access control entry
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -56,7 +56,9 @@
|
||||
"select_timezone": "Выберите часовой пояс",
|
||||
"moreOptions": "Больше параметров",
|
||||
"search": "Поиск",
|
||||
"resource": "Ресурсы"
|
||||
"resource": "Ресурсы",
|
||||
"back": "Назад",
|
||||
"show": "Показать"
|
||||
},
|
||||
"search": {
|
||||
"searchIn": "Поиск в",
|
||||
|
||||
+3
-1
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user