#708 apply strictier linting rules (#717)

* #708 apply strictier linting rules and fix simple eslint bugs

* #708 fix eslint errors relate to promise

* #708 fix eslint import/no-extraneous-dependencies

* #708 fix eslint errors of react-hook

* #708 enable eslint check for typescript

---------

Co-authored-by: lethemanh <lethemanh@lethemanhs-MacBook-Pro.local>
This commit is contained in:
lethemanh
2026-04-01 22:15:10 +07:00
committed by GitHub
parent 2bff6aae78
commit cadfa70e60
321 changed files with 27452 additions and 27600 deletions
+75 -64
View File
@@ -1,22 +1,22 @@
import { userAttendee } from "@/features/User/models/attendee";
import { createAttendee } from "@/features/User/models/attendee.mapper";
import { useRef, useState } from "react";
import { FreeBusyIndicator } from "./FreeBusyIndicator";
import { userAttendee } from '@/features/User/models/attendee'
import { createAttendee } from '@/features/User/models/attendee.mapper'
import { useEffect, useRef, useState } from 'react'
import { FreeBusyIndicator } from './FreeBusyIndicator'
import {
ExtendedAutocompleteRenderInputParams,
PeopleSearch,
User,
} from "./PeopleSearch";
import { FreeBusyMap, useAttendeesFreeBusy } from "./useFreeBusy";
User
} from './PeopleSearch'
import { FreeBusyMap, useAttendeesFreeBusy } from './useFreeBusy'
const attendeeToUser = (a: userAttendee, openpaasId = ""): User => ({
const attendeeToUser = (a: userAttendee, openpaasId = ''): User => ({
email: a.cal_address,
displayName: a.cn ?? "",
avatarUrl: "",
openpaasId,
});
displayName: a.cn ?? '',
avatarUrl: '',
openpaasId
})
const hasCalendar = (u: User) => u.objectType === "user" && !!u.openpaasId;
const hasCalendar = (u: User) => u.objectType === 'user' && !!u.openpaasId
export default function AttendeeSearch({
attendees,
@@ -27,98 +27,109 @@ export default function AttendeeSearch({
start,
end,
timezone,
eventUid,
eventUid
}: {
attendees: userAttendee[];
setAttendees: (attendees: userAttendee[]) => void;
disabled?: boolean;
inputSlot?: (
params: ExtendedAutocompleteRenderInputParams
) => React.ReactNode;
placeholder?: string;
start?: string;
end?: string;
timezone?: string;
eventUid?: string | null;
attendees: userAttendee[]
setAttendees: (attendees: userAttendee[]) => void
disabled?: boolean
inputSlot?: (params: ExtendedAutocompleteRenderInputParams) => React.ReactNode
placeholder?: string
start?: string
end?: string
timezone?: string
eventUid?: string | null
}) {
const [userIdMap, setUserIdMap] = useState<Record<string, string>>({});
const [addedUsers, setAddedUsers] = useState<User[]>([]);
const initialEmailsRef = useRef<Set<string> | null>(null);
if (initialEmailsRef.current === null && !!eventUid && attendees.length > 0) {
initialEmailsRef.current = new Set(attendees.map((a) => a.cal_address));
}
const initialEmails = eventUid
? (initialEmailsRef.current ?? new Set<string>())
: new Set<string>();
const [userIdMap, setUserIdMap] = useState<Record<string, string>>({})
const [addedUsers, setAddedUsers] = useState<User[]>([])
const initialEmailsRef = useRef<Set<string> | null>(null)
const [initialEmailsSet, setInitialEmailsSet] = useState<Set<string>>(
new Set()
)
useEffect(() => {
const updateInitialEmailsSet = () => {
if (
initialEmailsRef.current === null &&
!!eventUid &&
attendees.length > 0
) {
initialEmailsRef.current = new Set(attendees.map(a => a.cal_address))
setInitialEmailsSet(initialEmailsRef.current)
}
}
updateInitialEmailsSet()
}, [eventUid, attendees])
const initialEmails = eventUid ? initialEmailsSet : new Set<string>()
const selectedUsers: User[] = [
...addedUsers,
...attendees
.map((a) => attendeeToUser(a, userIdMap[a.cal_address]))
.filter((a) => !addedUsers.find((u) => a.email === u.email)),
];
.map(a => attendeeToUser(a, userIdMap[a.cal_address]))
.filter(a => !addedUsers.find(u => a.email === u.email))
]
const toAttendee = (u: User) => ({
email: u.email,
userId: u.openpaasId || userIdMap[u.email] || null,
});
userId: u.openpaasId || userIdMap[u.email] || null
})
const existingAttendees = selectedUsers
.filter((u) => initialEmails.has(u.email))
.map(toAttendee);
.filter(u => initialEmails.has(u.email))
.map(toAttendee)
const newAttendees = selectedUsers
.filter((u) => !initialEmails.has(u.email) && hasCalendar(u))
.map(toAttendee);
.filter(u => !initialEmails.has(u.email) && hasCalendar(u))
.map(toAttendee)
// Contacts and freeSolo users get a static "contact" status — no API call needed
const contactMap: FreeBusyMap = Object.fromEntries(
selectedUsers
.filter((u) => !initialEmails.has(u.email) && !hasCalendar(u))
.map((u) => [u.email, "contact" as const])
);
.filter(u => !initialEmails.has(u.email) && !hasCalendar(u))
.map(u => [u.email, 'contact' as const])
)
const freeBusyMap = useAttendeesFreeBusy({
existingAttendees,
newAttendees,
start: start ?? "",
end: end ?? "",
timezone: timezone ?? "",
start: start ?? '',
end: end ?? '',
timezone: timezone ?? '',
eventUid,
enabled: !!(start && end && selectedUsers.length > 0),
});
enabled: !!(start && end && selectedUsers.length > 0)
})
const statusMap = { ...freeBusyMap, ...contactMap };
const statusMap = { ...freeBusyMap, ...contactMap }
return (
<PeopleSearch
selectedUsers={selectedUsers}
objectTypes={["user", "contact"]}
objectTypes={['user', 'contact']}
disabled={disabled}
inputSlot={inputSlot}
placeholder={placeholder}
getChipIcon={
start && end
? (user) => (
<FreeBusyIndicator status={statusMap[user.email] ?? "unknown"} />
? user => (
<FreeBusyIndicator status={statusMap[user.email] ?? 'unknown'} />
)
: undefined
}
onChange={(_event, value: User[]) => {
setUserIdMap((prev) => {
const next = { ...prev };
setUserIdMap(prev => {
const next = { ...prev }
for (const u of value) {
if (u.openpaasId && u.email) next[u.email] = u.openpaasId;
if (u.openpaasId && u.email) next[u.email] = u.openpaasId
}
return next;
});
setAddedUsers(value.filter((u) => !initialEmails.has(u.email)));
return next
})
setAddedUsers(value.filter(u => !initialEmails.has(u.email)))
setAttendees(
value.map((u) =>
value.map(u =>
createAttendee({ cal_address: u.email, cn: u.displayName })
)
);
)
}}
freeSolo
/>
);
)
}
+24 -21
View File
@@ -1,32 +1,35 @@
import { Tooltip } from "@linagora/twake-mui";
import AccessTimeFilledIcon from "@mui/icons-material/AccessTimeFilled";
import CloseIcon from "@mui/icons-material/Close";
import { useEffect, useState } from "react";
import { useI18n } from "twake-i18n";
import { FreeBusyStatus } from "./useFreeBusy";
import { Tooltip } from '@linagora/twake-mui'
import AccessTimeFilledIcon from '@mui/icons-material/AccessTimeFilled'
import CloseIcon from '@mui/icons-material/Close'
import { useEffect, useState } from 'react'
import { useI18n } from 'twake-i18n'
import { FreeBusyStatus } from './useFreeBusy'
interface FreeBusyIndicatorProps {
status: FreeBusyStatus;
size?: number;
status: FreeBusyStatus
size?: number
}
export function FreeBusyIndicator({ status }: FreeBusyIndicatorProps) {
const { t } = useI18n();
const [open, setOpen] = useState(false);
const { t } = useI18n()
const [open, setOpen] = useState(false)
useEffect(() => {
if (status === "busy") setOpen(true);
}, [status]);
const triggerOpen = () => {
if (status === 'busy') setOpen(true)
}
triggerOpen()
}, [status])
if (status !== "busy") return null;
if (status !== 'busy') return null
return (
<Tooltip
title={
<span style={{ display: "flex", alignItems: "center", gap: 4 }}>
{t("event.freeBusy.busy")}
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
{t('event.freeBusy.busy')}
<CloseIcon
fontSize="inherit"
style={{ cursor: "pointer" }}
style={{ cursor: 'pointer' }}
onClick={() => setOpen(false)}
/>
</span>
@@ -35,16 +38,16 @@ export function FreeBusyIndicator({ status }: FreeBusyIndicatorProps) {
disableHoverListener
placement="bottom-start"
onClose={() => setOpen(false)}
slotProps={{ tooltip: { sx: { opacity: 1, bgcolor: "grey.900" } } }}
slotProps={{ tooltip: { sx: { opacity: 1, bgcolor: 'grey.900' } } }}
>
<AccessTimeFilledIcon
aria-label={t("event.freeBusy.busy")}
aria-label={t('event.freeBusy.busy')}
color="warning"
style={{
margin: "0 -6px 0 5px",
flexShrink: 0,
margin: '0 -6px 0 5px',
flexShrink: 0
}}
/>
</Tooltip>
);
)
}
+128 -132
View File
@@ -1,8 +1,8 @@
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 { 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,
@@ -15,35 +15,35 @@ import {
PopperProps,
TextField,
useTheme,
type AutocompleteRenderInputParams,
} from "@linagora/twake-mui";
import PeopleOutlineOutlinedIcon from "@mui/icons-material/PeopleOutlineOutlined";
type AutocompleteRenderInputParams
} from '@linagora/twake-mui'
import PeopleOutlineOutlinedIcon from '@mui/icons-material/PeopleOutlineOutlined'
import {
HTMLAttributes,
useCallback,
type ReactNode,
type SyntheticEvent,
} from "react";
import { useI18n } from "twake-i18n";
import { ResourceIcon } from "./ResourceIcon";
import { isValidEmail } from "../../utils/isValidEmail";
import { usePasteHandler } from "./usePasteHandler";
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;
email: string
displayName: string
avatarUrl?: string
openpaasId?: string
color?: Record<string, string>
objectType?: string
}
export interface ExtendedAutocompleteRenderInputParams extends AutocompleteRenderInputParams {
error?: boolean;
helperText?: string | null;
placeholder?: string;
label?: string;
onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void;
error?: boolean
helperText?: string | null
placeholder?: string
label?: string
onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void
}
export function PeopleSearch({
@@ -57,33 +57,31 @@ export function PeopleSearch({
inputSlot,
customRenderInput,
customSlotProps,
getChipIcon,
getChipIcon
}: {
selectedUsers: User[];
onChange: (event: SyntheticEvent, users: User[]) => void;
objectTypes: string[];
disabled?: boolean;
freeSolo?: boolean;
onToggleEventPreview?: () => void;
placeholder?: string;
inputSlot?: (
params: ExtendedAutocompleteRenderInputParams
) => React.ReactNode;
selectedUsers: User[]
onChange: (event: SyntheticEvent, users: User[]) => void
objectTypes: string[]
disabled?: boolean
freeSolo?: boolean
onToggleEventPreview?: () => void
placeholder?: string
inputSlot?: (params: ExtendedAutocompleteRenderInputParams) => React.ReactNode
customRenderInput?: (
params: AutocompleteRenderInputParams,
query: string,
setQuery: (value: string) => void
) => ReactNode;
) => ReactNode
customSlotProps?: {
popper?: Partial<PopperProps>;
paper?: Partial<PaperProps>;
listbox?: Partial<HTMLAttributes<HTMLUListElement>>;
};
getChipIcon?: (user: User) => ReactNode;
popper?: Partial<PopperProps>
paper?: Partial<PaperProps>
listbox?: Partial<HTMLAttributes<HTMLUListElement>>
}
getChipIcon?: (user: User) => ReactNode
}) {
const { t } = useI18n();
const searchPlaceholder = placeholder ?? t("peopleSearch.placeholder");
const errorMessage = t("peopleSearch.searchError");
const { t } = useI18n()
const searchPlaceholder = placeholder ?? t('peopleSearch.placeholder')
const errorMessage = t('peopleSearch.searchError')
const {
query,
@@ -98,32 +96,32 @@ export function PeopleSearch({
snackbarOpen,
setSnackbarOpen,
snackbarMessage,
setSnackbarMessage,
} = useUserSearch<User>({ objectTypes, errorMessage });
setSnackbarMessage
} = useUserSearch<User>({ objectTypes, errorMessage })
const theme = useTheme();
const theme = useTheme()
const handleBlurCommit = useCallback(
(event: React.SyntheticEvent) => {
const trimmed = query.trim();
if (!trimmed) return;
const trimmed = query.trim()
if (!trimmed) return
if (!isValidEmail(trimmed)) {
setInputError(
t("peopleSearch.invalidEmail").replace("%{email}", trimmed)
);
return;
t('peopleSearch.invalidEmail').replace('%{email}', trimmed)
)
return
}
if (selectedUsers.find((u) => u.email === trimmed)) {
setQuery("");
return;
if (selectedUsers.find(u => u.email === trimmed)) {
setQuery('')
return
}
setInputError(null);
const newUser: User = { email: trimmed, displayName: trimmed };
onChange(event, [...selectedUsers, newUser]);
setQuery("");
setInputError(null)
const newUser: User = { email: trimmed, displayName: trimmed }
onChange(event, [...selectedUsers, newUser])
setQuery('')
},
[query, selectedUsers, onChange, t, setInputError, setQuery]
);
)
const handlePaste = usePasteHandler({
freeSolo,
@@ -131,8 +129,8 @@ export function PeopleSearch({
onChange,
setQuery,
setInputError,
t,
});
t
})
const defaultRenderInput = useCallback(
(params: AutocompleteRenderInputParams) => {
@@ -143,7 +141,7 @@ export function PeopleSearch({
) : (
<PeopleOutlineOutlinedIcon
fontSize="small"
sx={{ mr: 1, color: "action.active" }}
sx={{ mr: 1, color: 'action.active' }}
/>
),
endAdornment: (
@@ -151,61 +149,61 @@ export function PeopleSearch({
{loading ? <CircularProgress color="inherit" size={20} /> : null}
{params.InputProps.endAdornment}
</>
),
};
)
}
const enhancedParams = {
...params,
InputProps: inputProps,
inputProps: {
...params.inputProps,
autoComplete: "off",
onPaste: handlePaste,
},
};
autoComplete: 'off',
onPaste: handlePaste
}
}
const handleEnterKey = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" && onToggleEventPreview) {
e.preventDefault();
onToggleEventPreview();
if (e.key === 'Enter' && onToggleEventPreview) {
e.preventDefault()
onToggleEventPreview()
}
};
}
const defaultTextFieldProps = {
error: !!inputError,
helperText: inputError,
placeholder: searchPlaceholder,
label: "",
label: '',
onKeyDown: handleEnterKey,
slotProps: {
input: {
...inputProps,
},
},
};
...inputProps
}
}
}
if (inputSlot) {
return (
<>
<label htmlFor={params.id} className="visually-hidden">
{t("peopleSearch.label")}
{t('peopleSearch.label')}
</label>
{inputSlot({
...enhancedParams,
error: !!inputError,
helperText: inputError,
placeholder: searchPlaceholder,
label: "",
onKeyDown: handleEnterKey,
label: '',
onKeyDown: handleEnterKey
})}
</>
);
)
}
return (
<>
<label htmlFor={params.id} className="visually-hidden">
{t("peopleSearch.label")}
{t('peopleSearch.label')}
</label>
<TextField
{...enhancedParams}
@@ -214,7 +212,7 @@ export function PeopleSearch({
size="medium"
/>
</>
);
)
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[
@@ -223,9 +221,9 @@ export function PeopleSearch({
onToggleEventPreview,
loading,
searchPlaceholder,
handlePaste,
handlePaste
]
);
)
return (
<>
@@ -246,68 +244,68 @@ export function PeopleSearch({
onClose={() => setIsOpen(false)}
disabled={disabled}
loading={loading}
filterOptions={(x) => x}
filterOptions={x => x}
fullWidth
noOptionsText={t("peopleSearch.noResults")}
loadingText={t("peopleSearch.loading")}
getOptionLabel={(option) => {
if (typeof option === "object") {
return option.displayName || option.email;
noOptionsText={t('peopleSearch.noResults')}
loadingText={t('peopleSearch.loading')}
getOptionLabel={option => {
if (typeof option === 'object') {
return option.displayName || option.email
} else {
return option;
return option
}
}}
sx={{
"& .MuiAutocomplete-inputRoot": {
py: 0,
},
'& .MuiAutocomplete-inputRoot': {
py: 0
}
}}
filterSelectedOptions
value={selectedUsers}
inputValue={query}
onInputChange={(_event, value) => setQuery(value)}
onChange={(event, value) => {
const last = value[value.length - 1];
if (typeof last === "string" && !isValidEmail(last.trim())) {
const invalidEmailMessage = t("peopleSearch.invalidEmail").replace(
"%{email}",
const last = value[value.length - 1]
if (typeof last === 'string' && !isValidEmail(last.trim())) {
const invalidEmailMessage = t('peopleSearch.invalidEmail').replace(
'%{email}',
last
);
setInputError(invalidEmailMessage);
return;
)
setInputError(invalidEmailMessage)
return
}
setInputError(null);
setInputError(null)
const mapped = value
.map((v: string | User) =>
typeof v === "string"
typeof v === 'string'
? { email: v.trim(), displayName: v.trim() }
: v
)
.filter(
(user, index, self) =>
self.findIndex((u) => u.email === user.email) === index
);
onChange(event, mapped);
self.findIndex(u => u.email === user.email) === index
)
onChange(event, mapped)
}}
slotProps={{
...customSlotProps,
popper: {
placement: "bottom-start",
sx: { minWidth: "300px", ...customSlotProps?.popper?.sx },
...customSlotProps?.popper,
},
placement: 'bottom-start',
sx: { minWidth: '300px', ...customSlotProps?.popper?.sx },
...customSlotProps?.popper
}
}}
forcePopupIcon={false}
disableClearable
renderInput={(params) =>
renderInput={params =>
customRenderInput
? 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";
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>
@@ -321,25 +319,23 @@ export function PeopleSearch({
</ListItemAvatar>
<ListItemText
primary={option.displayName}
secondary={isResource ? "" : option.email}
secondary={isResource ? '' : option.email}
slotProps={{
primary: { variant: "body2" },
secondary: { variant: "caption" },
primary: { variant: 'body2' },
secondary: { variant: 'caption' }
}}
/>
</ListItem>
);
)
}}
renderValue={(value, getTagProps) =>
value.map((option, index) => {
const isString = typeof option === "string";
const label = isString
? option
: option.displayName || option.email;
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);
: (option.color?.light ?? theme.palette.grey[200])
const textColor = getAccessiblePair(chipColor, theme)
return (
<Chip
@@ -351,25 +347,25 @@ export function PeopleSearch({
deleteIcon={<CloseIcon />}
style={{
backgroundColor: chipColor,
color: textColor,
color: textColor
}}
label={label}
/>
);
)
})
}
/>
<SnackbarAlert
open={snackbarOpen}
setOpen={(open: boolean) => {
setSnackbarOpen(open);
setSnackbarOpen(open)
if (!open) {
setSnackbarMessage("");
setSnackbarMessage('')
}
}}
message={snackbarMessage}
severity="error"
/>
</>
);
)
}
+13 -13
View File
@@ -1,41 +1,41 @@
import { Avatar, Box } from "@linagora/twake-mui";
import LayersOutlinedIcon from "@mui/icons-material/LayersOutlined";
import { Avatar, Box } from '@linagora/twake-mui'
import LayersOutlinedIcon from '@mui/icons-material/LayersOutlined'
interface ResourceIconProps {
avatarUrl?: string;
colorIcon?: boolean;
color?: string;
avatarUrl?: string
colorIcon?: boolean
color?: string
}
export function ResourceIcon({
avatarUrl,
colorIcon,
color,
color
}: ResourceIconProps) {
if (colorIcon && avatarUrl) {
return (
<Box
sx={{
width: "24px",
height: "24px",
width: '24px',
height: '24px',
backgroundColor: color,
maskImage: `url(${avatarUrl})`,
maskSize: "cover",
maskSize: 'cover'
}}
/>
);
)
}
return avatarUrl ? (
<Avatar
sx={{ backgroundColor: "transparent", width: "24px", height: "24px" }}
sx={{ backgroundColor: 'transparent', width: '24px', height: '24px' }}
src={avatarUrl}
/>
) : (
<Avatar
sx={{ backgroundColor: "transparent", width: "24px", height: "24px" }}
sx={{ backgroundColor: 'transparent', width: '24px', height: '24px' }}
>
<LayersOutlinedIcon />
</Avatar>
);
)
}
+106 -110
View File
@@ -1,7 +1,7 @@
import { getAccessiblePair } from "@/utils/getAccessiblePair";
import { useUserSearch } from "./useUserSearch";
import { ResourceIcon } from "./ResourceIcon";
import { SnackbarAlert } from "@/components/Loading/SnackBarAlert";
import { getAccessiblePair } from '@/utils/getAccessiblePair'
import { useUserSearch } from './useUserSearch'
import { ResourceIcon } from './ResourceIcon'
import { SnackbarAlert } from '@/components/Loading/SnackBarAlert'
import {
Autocomplete,
Chip,
@@ -14,31 +14,31 @@ import {
TextField,
useTheme,
Typography,
type AutocompleteRenderInputParams,
} from "@linagora/twake-mui";
import SearchIcon from "@mui/icons-material/Search";
type AutocompleteRenderInputParams
} from '@linagora/twake-mui'
import SearchIcon from '@mui/icons-material/Search'
import {
HTMLAttributes,
useCallback,
type ReactNode,
type SyntheticEvent,
} from "react";
import { useI18n } from "twake-i18n";
type SyntheticEvent
} from 'react'
import { useI18n } from 'twake-i18n'
export interface Resource {
email?: string;
displayName: string;
avatarUrl?: string;
openpaasId?: string;
color?: Record<string, string>;
email?: string
displayName: string
avatarUrl?: string
openpaasId?: string
color?: Record<string, string>
}
export interface ExtendedAutocompleteRenderInputParams extends AutocompleteRenderInputParams {
error?: boolean;
helperText?: string | null;
placeholder?: string;
label?: string;
onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void;
error?: boolean
helperText?: string | null
placeholder?: string
label?: string
onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void
}
export function ResourceSearch({
@@ -52,33 +52,31 @@ export function ResourceSearch({
inputSlot,
customRenderInput,
customSlotProps,
hideLabel,
hideLabel
}: {
selectedResources: Resource[];
onChange: (event: SyntheticEvent, users: Resource[]) => void;
objectTypes: string[];
disabled?: boolean;
freeSolo?: boolean;
onToggleEventPreview?: () => void;
placeholder?: string;
inputSlot?: (
params: ExtendedAutocompleteRenderInputParams
) => React.ReactNode;
selectedResources: Resource[]
onChange: (event: SyntheticEvent, users: Resource[]) => void
objectTypes: string[]
disabled?: boolean
freeSolo?: boolean
onToggleEventPreview?: () => void
placeholder?: string
inputSlot?: (params: ExtendedAutocompleteRenderInputParams) => React.ReactNode
customRenderInput?: (
params: AutocompleteRenderInputParams,
query: string,
setQuery: (value: string) => void
) => ReactNode;
) => ReactNode
customSlotProps?: {
popper?: Partial<PopperProps>;
paper?: Partial<PaperProps>;
listbox?: Partial<HTMLAttributes<HTMLUListElement>>;
};
hideLabel?: boolean;
popper?: Partial<PopperProps>
paper?: Partial<PaperProps>
listbox?: Partial<HTMLAttributes<HTMLUListElement>>
}
hideLabel?: boolean
}) {
const { t } = useI18n();
const searchPlaceholder = placeholder ?? t("resourceSearch.placeholder");
const errorMessage = t("resourceSearch.searchError");
const { t } = useI18n()
const searchPlaceholder = placeholder ?? t('resourceSearch.placeholder')
const errorMessage = t('resourceSearch.searchError')
const {
query,
@@ -93,26 +91,26 @@ export function ResourceSearch({
snackbarOpen,
setSnackbarOpen,
snackbarMessage,
setSnackbarMessage,
} = useUserSearch<Resource>({ objectTypes, errorMessage });
setSnackbarMessage
} = useUserSearch<Resource>({ objectTypes, errorMessage })
const theme = useTheme();
const theme = useTheme()
const handleBlurCommit = useCallback(
(event: React.SyntheticEvent) => {
const trimmed = query.trim();
if (!trimmed) return;
if (selectedResources.find((u) => u.displayName === trimmed)) {
setQuery("");
return;
const trimmed = query.trim()
if (!trimmed) return
if (selectedResources.find(u => u.displayName === trimmed)) {
setQuery('')
return
}
setInputError(null);
const newResource: Resource = { displayName: trimmed };
onChange(event, [...selectedResources, newResource]);
setQuery("");
setInputError(null)
const newResource: Resource = { displayName: trimmed }
onChange(event, [...selectedResources, newResource])
setQuery('')
},
[query, selectedResources, onChange, setInputError, setQuery]
);
)
const defaultRenderInput = useCallback(
(params: AutocompleteRenderInputParams) => {
@@ -123,7 +121,7 @@ export function ResourceSearch({
{!selectedResources?.length ? (
<SearchIcon
fontSize="small"
sx={{ mr: 1, color: "action.active" }}
sx={{ mr: 1, color: 'action.active' }}
/>
) : null}
{params.InputProps.startAdornment}
@@ -134,44 +132,44 @@ export function ResourceSearch({
{loading ? <CircularProgress color="inherit" size={20} /> : null}
{!selectedResources?.length ? params.InputProps.endAdornment : null}
</>
),
};
)
}
const enhancedParams = {
...params,
InputProps: inputProps,
inputProps: {
...params.inputProps,
autoComplete: "off",
},
};
autoComplete: 'off'
}
}
const handleEnterKey = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" && onToggleEventPreview) {
e.preventDefault();
onToggleEventPreview();
if (e.key === 'Enter' && onToggleEventPreview) {
e.preventDefault()
onToggleEventPreview()
}
};
}
const defaultTextFieldProps = {
error: !!inputError,
helperText: inputError,
placeholder: searchPlaceholder,
label: "",
label: '',
onKeyDown: handleEnterKey,
slotProps: {
input: {
...inputProps,
},
},
};
...inputProps
}
}
}
if (inputSlot) {
return (
<>
{!hideLabel && (
<Typography variant="h6" sx={{ marginBottom: "10px" }}>
{t("resourceSearch.label")}
<Typography variant="h6" sx={{ marginBottom: '10px' }}>
{t('resourceSearch.label')}
</Typography>
)}
{inputSlot({
@@ -179,18 +177,18 @@ export function ResourceSearch({
error: !!inputError,
helperText: inputError,
placeholder: searchPlaceholder,
label: "",
onKeyDown: handleEnterKey,
label: '',
onKeyDown: handleEnterKey
})}
</>
);
)
}
return (
<>
{!hideLabel && (
<Typography variant="h6" sx={{ marginBottom: "10px" }}>
{t("resourceSearch.label")}
<Typography variant="h6" sx={{ marginBottom: '10px' }}>
{t('resourceSearch.label')}
</Typography>
)}
<TextField
@@ -200,7 +198,7 @@ export function ResourceSearch({
size="medium"
/>
</>
);
)
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[
@@ -209,9 +207,9 @@ export function ResourceSearch({
onToggleEventPreview,
loading,
searchPlaceholder,
selectedResources?.length,
selectedResources?.length
]
);
)
return (
<>
@@ -234,55 +232,53 @@ export function ResourceSearch({
loading={loading}
filterOptions={(options: Resource[]) => options}
fullWidth
noOptionsText={t("resourceSearch.noResults")}
loadingText={t("resourceSearch.loading")}
noOptionsText={t('resourceSearch.noResults')}
loadingText={t('resourceSearch.loading')}
getOptionLabel={(option: Resource | string) => {
if (typeof option === "object") {
return option.displayName;
if (typeof option === 'object') {
return option.displayName
} else {
return option;
return option
}
}}
sx={{
"& .MuiAutocomplete-inputRoot": {
py: 0,
},
'& .MuiAutocomplete-inputRoot': {
py: 0
}
}}
filterSelectedOptions
value={selectedResources}
inputValue={query}
onInputChange={(_event, value: string) => setQuery(value)}
onChange={(event, value: string[] | Resource[]) => {
setInputError(null);
setInputError(null)
const mapped = value
.map((v: string | Resource) =>
typeof v === "string" ? { displayName: v.trim() } : v
typeof v === 'string' ? { displayName: v.trim() } : v
)
.filter((v) => v.displayName.trim().length > 0);
onChange(event, mapped);
.filter(v => v.displayName.trim().length > 0)
onChange(event, mapped)
}}
slotProps={{
...customSlotProps,
popper: {
placement: "bottom-start",
sx: { minWidth: "300px", ...customSlotProps?.popper?.sx },
...customSlotProps?.popper,
},
placement: 'bottom-start',
sx: { minWidth: '300px', ...customSlotProps?.popper?.sx },
...customSlotProps?.popper
}
}}
// When render input is custom, the adornments should be handled by the custom component
forcePopupIcon={!customRenderInput}
disableClearable={!!customRenderInput}
renderInput={(params) =>
renderInput={params =>
customRenderInput
? customRenderInput(params, query, setQuery)
: defaultRenderInput(params)
}
renderOption={(props, option: Resource) => {
if (
selectedResources.find((u) => u.displayName === option.displayName)
)
return null;
const { key, ...otherProps } = props;
if (selectedResources.find(u => u.displayName === option.displayName))
return null
const { key, ...otherProps } = props
return (
<ListItem
key={key + option?.displayName}
@@ -294,16 +290,16 @@ export function ResourceSearch({
</ListItemAvatar>
<ListItemText primary={option.displayName} />
</ListItem>
);
)
}}
renderValue={(value: string[] | Resource[], getTagProps) =>
value.map((option: string | Resource, index) => {
const isString = typeof option === "string";
const label = isString ? option : option.displayName;
const isString = typeof option === 'string'
const label = isString ? option : option.displayName
const chipColor = isString
? theme.palette.grey[300]
: (option.color?.light ?? theme.palette.grey[300]);
const textColor = getAccessiblePair(chipColor, theme);
: (option.color?.light ?? theme.palette.grey[300])
const textColor = getAccessiblePair(chipColor, theme)
return (
<Chip
@@ -311,25 +307,25 @@ export function ResourceSearch({
key={label}
style={{
backgroundColor: chipColor,
color: textColor,
color: textColor
}}
label={label}
/>
);
)
})
}
/>
<SnackbarAlert
open={snackbarOpen}
setOpen={(open: boolean) => {
setSnackbarOpen(open);
setSnackbarOpen(open)
if (!open) {
setSnackbarMessage("");
setSnackbarMessage('')
}
}}
message={snackbarMessage}
severity="error"
/>
</>
);
)
}
+119 -122
View File
@@ -1,99 +1,92 @@
import { getFreeBusyForAddedAttendeesREPORT } from "@/features/Events/api/getFreeBusyForAddedAttendeesREPORT";
import { getFreeBusyForEventAttendeesPOST } from "@/features/Events/api/getFreeBusyForEventAttendeesPOST";
import { getUserDataFromEmail } from "@/features/Events/api/getUserDataFromEmail";
import moment from "moment-timezone";
import { useEffect, useRef, useState } from "react";
import { getFreeBusyForAddedAttendeesREPORT } from '@/features/Events/api/getFreeBusyForAddedAttendeesREPORT'
import { getFreeBusyForEventAttendeesPOST } from '@/features/Events/api/getFreeBusyForEventAttendeesPOST'
import { getUserDataFromEmail } from '@/features/Events/api/getUserDataFromEmail'
import moment from 'moment-timezone'
import { useEffect, useRef, useState } from 'react'
export type FreeBusyStatus =
| "free"
| "busy"
| "loading"
| "contact"
| "unknown";
export type FreeBusyMap = Record<string, FreeBusyStatus>;
export type FreeBusyStatus = 'free' | 'busy' | 'loading' | 'contact' | 'unknown'
export type FreeBusyMap = Record<string, FreeBusyStatus>
interface Attendee {
email: string;
userId?: string | null;
email: string
userId?: string | null
}
interface ResolvedAttendee {
email: string;
userId: string;
email: string
userId: string
}
// Helpers
async function resolveUserId(attendee: Attendee): Promise<string | null> {
if (attendee.userId) return attendee.userId;
if (attendee.userId) return attendee.userId
return getUserDataFromEmail(attendee.email)
.then((u) => u[0]?._id ?? null)
.catch(() => null);
.then(u => u[0]?._id ?? null)
.catch(() => null)
}
async function resolveAll(attendees: Attendee[]): Promise<ResolvedAttendee[]> {
const results = await Promise.all(
attendees.map(async (a) => {
const userId = await resolveUserId(a);
return userId ? { email: a.email, userId } : null;
attendees.map(async a => {
const userId = await resolveUserId(a)
return userId ? { email: a.email, userId } : null
})
);
return results.filter((r): r is ResolvedAttendee => r !== null);
)
return results.filter((r): r is ResolvedAttendee => r !== null)
}
export function hasFreeBusyConflict(data: unknown): boolean {
try {
const jcal = (data as { data: unknown[] }).data;
if (!Array.isArray(jcal) || jcal[0] !== "vcalendar") return false;
const components = jcal[2] as unknown[][];
return (
Array.isArray(components) && components.some(isVFreeBusyWithConflict)
);
const jcal = (data as { data: unknown[] }).data
if (!Array.isArray(jcal) || jcal[0] !== 'vcalendar') return false
const components = jcal[2] as unknown[][]
return Array.isArray(components) && components.some(isVFreeBusyWithConflict)
} catch {
return false;
return false
}
}
function isVFreeBusyWithConflict(component: unknown): boolean {
if (!Array.isArray(component) || component[0] !== "vfreebusy") return false;
const props = component[1] as unknown[][];
if (!Array.isArray(component) || component[0] !== 'vfreebusy') return false
const props = component[1] as unknown[][]
return (
Array.isArray(props) &&
props.some((p) => Array.isArray(p) && p[0] === "freebusy")
);
props.some(p => Array.isArray(p) && p[0] === 'freebusy')
)
}
function toUtcIcal(datetime: string, timezone: string): string {
return moment.tz(datetime, timezone).utc().format("YYYYMMDDTHHmmss");
return moment.tz(datetime, timezone).utc().format('YYYYMMDDTHHmmss')
}
async function fetchFreeBusyMap(
attendees: Attendee[],
fetcher: (resolved: ResolvedAttendee[]) => Promise<FreeBusyMap>
): Promise<FreeBusyMap> {
const resolved = await resolveAll(attendees);
const resolved = await resolveAll(attendees)
const unresolved: FreeBusyMap = Object.fromEntries(
attendees
.filter((a) => !resolved.find((r) => r.email === a.email))
.map((a) => [a.email, "unknown" as FreeBusyStatus])
);
.filter(a => !resolved.find(r => r.email === a.email))
.map(a => [a.email, 'unknown' as FreeBusyStatus])
)
if (resolved.length === 0) return unresolved;
if (resolved.length === 0) return unresolved
const fetched = await fetcher(resolved);
return { ...unresolved, ...fetched };
const fetched = await fetcher(resolved)
return { ...unresolved, ...fetched }
}
function toLoadingMap(attendees: Attendee[]): FreeBusyMap {
return Object.fromEntries(
attendees.map((a) => [a.email, "loading" as FreeBusyStatus])
);
attendees.map(a => [a.email, 'loading' as FreeBusyStatus])
)
}
function toUnknownMap(attendees: Attendee[]): FreeBusyMap {
return Object.fromEntries(
attendees.map((a) => [a.email, "unknown" as FreeBusyStatus])
);
attendees.map(a => [a.email, 'unknown' as FreeBusyStatus])
)
}
function toFreeBusyMap(
@@ -101,26 +94,26 @@ function toFreeBusyMap(
): (busyByUserId: Record<string, boolean>) => FreeBusyMap {
const userIdToEmail = Object.fromEntries(
resolved.map(({ email, userId }) => [userId, email])
);
return (busyByUserId) =>
)
return busyByUserId =>
Object.fromEntries(
Object.entries(busyByUserId).flatMap(([uid, busy]) => {
const email = userIdToEmail[uid];
const email = userIdToEmail[uid]
return email
? [[email, (busy ? "busy" : "free") as FreeBusyStatus]]
: [];
? [[email, (busy ? 'busy' : 'free') as FreeBusyStatus]]
: []
})
);
)
}
interface UseAttendeesFreeBusyOptions {
existingAttendees: Attendee[];
newAttendees: Attendee[];
start: string;
end: string;
timezone: string;
eventUid?: string | null;
enabled?: boolean;
existingAttendees: Attendee[]
newAttendees: Attendee[]
start: string
end: string
timezone: string
eventUid?: string | null
enabled?: boolean
}
export function useAttendeesFreeBusy({
@@ -130,18 +123,18 @@ export function useAttendeesFreeBusy({
end,
timezone,
eventUid,
enabled = true,
enabled = true
}: UseAttendeesFreeBusyOptions): FreeBusyMap {
const [statusMap, setStatusMap] = useState<FreeBusyMap>({});
const fetchedNewEmailsRef = useRef<Set<string>>(new Set());
const [statusMap, setStatusMap] = useState<FreeBusyMap>({})
const fetchedNewEmailsRef = useRef<Set<string>>(new Set())
const existingKey = existingAttendees.map((a) => a.email).join(",");
const newKey = newAttendees.map((a) => a.email).join(",");
const existingKey = existingAttendees.map(a => a.email).join(',')
const newKey = newAttendees.map(a => a.email).join(',')
useEffect(() => {
fetchedNewEmailsRef.current = new Set();
setStatusMap({});
}, [start, end, timezone]);
fetchedNewEmailsRef.current = new Set()
setStatusMap({})
}, [start, end, timezone])
useEffect(() => {
if (
@@ -151,101 +144,105 @@ export function useAttendeesFreeBusy({
!eventUid ||
existingAttendees.length === 0
)
return;
return
let cancelled = false;
setStatusMap((prev) => ({ ...prev, ...toLoadingMap(existingAttendees) }));
let cancelled = false
setStatusMap(prev => ({ ...prev, ...toLoadingMap(existingAttendees) }))
fetchFreeBusyMap(existingAttendees, (resolved) =>
fetchFreeBusyMap(existingAttendees, resolved =>
getFreeBusyForEventAttendeesPOST(
resolved.map((r) => r.userId),
resolved.map(r => r.userId),
toUtcIcal(start, timezone),
toUtcIcal(end, timezone),
eventUid!
eventUid
).then(toFreeBusyMap(resolved))
)
.then((updates) => {
if (!cancelled) setStatusMap((prev) => ({ ...prev, ...updates }));
.then(updates => {
if (!cancelled) {
setStatusMap(prev => ({ ...prev, ...updates }))
}
return
})
.catch(() => {
if (!cancelled)
setStatusMap((prev) => ({
setStatusMap(prev => ({
...prev,
...toUnknownMap(existingAttendees),
}));
});
...toUnknownMap(existingAttendees)
}))
})
return () => {
cancelled = true;
};
cancelled = true
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [existingKey, start, end, eventUid, enabled, timezone]);
}, [existingKey, start, end, eventUid, enabled, timezone])
useEffect(() => {
if (!enabled || !start || !end) return;
if (!enabled || !start || !end) return
const currentEmails = new Set(newAttendees.map((a) => a.email));
const currentEmails = new Set(newAttendees.map(a => a.email))
const removedEmails = [...fetchedNewEmailsRef.current].filter(
(e) => !currentEmails.has(e)
);
e => !currentEmails.has(e)
)
if (removedEmails.length > 0) {
removedEmails.forEach((e) => {
fetchedNewEmailsRef.current.delete(e);
});
setStatusMap((prev) => {
const next = { ...prev };
removedEmails.forEach((e) => {
delete next[e];
});
return next;
});
removedEmails.forEach(e => {
fetchedNewEmailsRef.current.delete(e)
})
setStatusMap(prev => {
const next = { ...prev }
removedEmails.forEach(e => {
delete next[e]
})
return next
})
}
const toFetch = newAttendees.filter(
(a) => !fetchedNewEmailsRef.current.has(a.email)
);
if (toFetch.length === 0) return;
a => !fetchedNewEmailsRef.current.has(a.email)
)
if (toFetch.length === 0) return
let cancelled = false;
setStatusMap((prev) => ({ ...prev, ...toLoadingMap(toFetch) }));
fetchFreeBusyMap(toFetch, (resolved) =>
let cancelled = false
setStatusMap(prev => ({ ...prev, ...toLoadingMap(toFetch) }))
fetchFreeBusyMap(toFetch, resolved =>
Promise.all(
resolved.map(async ({ email, userId }) => {
try {
const busy = await getFreeBusyForAddedAttendeesREPORT(
userId,
moment.tz(start, timezone).utc().format("YYYYMMDDTHHmmss"),
moment.tz(end, timezone).utc().format("YYYYMMDDTHHmmss")
);
return [email, (busy ? "busy" : "free") as FreeBusyStatus] as const;
moment.tz(start, timezone).utc().format('YYYYMMDDTHHmmss'),
moment.tz(end, timezone).utc().format('YYYYMMDDTHHmmss')
)
return [email, (busy ? 'busy' : 'free') as FreeBusyStatus] as const
} catch {
return [email, "unknown" as FreeBusyStatus] as const;
return [email, 'unknown' as FreeBusyStatus] as const
}
})
).then(Object.fromEntries)
)
.then((updates) => {
.then(updates => {
if (!cancelled) {
Object.keys(updates).forEach((e) => {
fetchedNewEmailsRef.current.add(e);
});
setStatusMap((prev) => ({ ...prev, ...updates }));
Object.keys(updates).forEach(e => {
fetchedNewEmailsRef.current.add(e)
})
setStatusMap(prev => ({ ...prev, ...updates }))
}
return
})
.catch(() => {
if (!cancelled) {
toFetch.forEach((a) => {
fetchedNewEmailsRef.current.add(a.email);
});
setStatusMap((prev) => ({ ...prev, ...toUnknownMap(toFetch) }));
toFetch.forEach(a => {
fetchedNewEmailsRef.current.add(a.email)
})
setStatusMap(prev => ({ ...prev, ...toUnknownMap(toFetch) }))
}
});
})
return () => {
cancelled = true;
};
cancelled = true
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [newKey, start, end, enabled, timezone]);
}, [newKey, start, end, enabled, timezone])
return statusMap;
return statusMap
}
+31 -31
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 { useCallback } from 'react'
import type { SyntheticEvent } from 'react'
import { isValidEmail } from '../../utils/isValidEmail'
import type { User } from './PeopleSearch'
export function usePasteHandler({
freeSolo,
@@ -9,62 +9,62 @@ export function usePasteHandler({
onChange,
setQuery,
setInputError,
t,
t
}: {
freeSolo?: boolean;
selectedUsers: User[];
onChange: (event: SyntheticEvent, users: User[]) => void;
setQuery: (value: string) => void;
setInputError: (error: string | null) => void;
t: (key: string) => string;
freeSolo?: boolean
selectedUsers: User[]
onChange: (event: SyntheticEvent, users: User[]) => void
setQuery: (value: string) => void
setInputError: (error: string | null) => void
t: (key: string) => string
}) {
return useCallback(
(event: React.ClipboardEvent<HTMLInputElement>) => {
if (!freeSolo) return;
if (!freeSolo) return
const pasted = event.clipboardData.getData("text/plain");
if (!pasted) return;
const pasted = event.clipboardData.getData('text/plain')
if (!pasted) return
// Split by comma, semicolon, newline, or whitespace
const chunks = pasted
.split(/[,;\n\r\s]+/)
.map((s) => s.trim())
.filter(Boolean);
.map(s => s.trim())
.filter(Boolean)
// If there is only one chunk, let the default Autocomplete behaviour handle it
if (chunks.length <= 1) return;
if (chunks.length <= 1) return
event.preventDefault();
event.preventDefault()
const existingEmails = new Set(selectedUsers.map((u) => u.email));
const validUsers: User[] = [];
const invalid: string[] = [];
const existingEmails = new Set(selectedUsers.map(u => u.email))
const validUsers: User[] = []
const invalid: string[] = []
for (const chunk of chunks) {
if (!isValidEmail(chunk)) {
invalid.push(chunk);
invalid.push(chunk)
} else if (!existingEmails.has(chunk)) {
existingEmails.add(chunk);
validUsers.push({ email: chunk, displayName: chunk });
existingEmails.add(chunk)
validUsers.push({ email: chunk, displayName: chunk })
}
// silently skip duplicates
}
if (validUsers.length > 0) {
onChange(event, [...selectedUsers, ...validUsers]);
onChange(event, [...selectedUsers, ...validUsers])
}
if (invalid.length > 0) {
// Leave the invalid text in the input for manual correction
setQuery(invalid.join(", "));
setQuery(invalid.join(', '))
setInputError(
t("peopleSearch.invalidEmail").replace("%{email}", invalid.join(", "))
);
t('peopleSearch.invalidEmail').replace('%{email}', invalid.join(', '))
)
} else {
setQuery("");
setInputError(null);
setQuery('')
setInputError(null)
}
},
[freeSolo, selectedUsers, onChange, setQuery, setInputError, t]
);
)
}
+34 -34
View File
@@ -1,67 +1,67 @@
import { useState, useEffect } from "react";
import { searchUsers } from "@/features/User/userAPI";
import { useState, useEffect } from 'react'
import { searchUsers } from '@/features/User/userAPI'
export interface UseUserSearchProps {
objectTypes: string[];
errorMessage: string;
objectTypes: string[]
errorMessage: string
}
export function useUserSearch<T>({
objectTypes,
errorMessage,
errorMessage
}: UseUserSearchProps) {
const [query, setQuery] = useState("");
const [loading, setLoading] = useState(false);
const [options, setOptions] = useState<T[]>([]);
const [hasSearched, setHasSearched] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const [query, setQuery] = useState('')
const [loading, setLoading] = useState(false)
const [options, setOptions] = useState<T[]>([])
const [hasSearched, setHasSearched] = useState(false)
const [isOpen, setIsOpen] = useState(false)
const [inputError, setInputError] = useState<string | null>(null);
const [snackbarOpen, setSnackbarOpen] = useState(false);
const [snackbarMessage, setSnackbarMessage] = useState("");
const [inputError, setInputError] = useState<string | null>(null)
const [snackbarOpen, setSnackbarOpen] = useState(false)
const [snackbarMessage, setSnackbarMessage] = useState('')
useEffect(() => {
let cancelled = false;
let cancelled = false
const delayDebounceFn = setTimeout(async () => {
if (!query.trim()) {
if (!cancelled) {
setOptions([]);
setLoading(false);
setHasSearched(false);
setOptions([])
setLoading(false)
setHasSearched(false)
}
return;
return
}
if (!cancelled) {
setLoading(true);
setHasSearched(false);
setLoading(true)
setHasSearched(false)
}
try {
const res = await searchUsers(query, objectTypes);
const res = await searchUsers(query, objectTypes)
if (!cancelled) {
setOptions(res as unknown as T[]);
setHasSearched(true);
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);
setLoading(false)
}
}
}, 300);
}, 300)
return () => {
cancelled = true;
clearTimeout(delayDebounceFn);
};
}, [objectTypes, query, errorMessage]);
cancelled = true
clearTimeout(delayDebounceFn)
}
}, [objectTypes, query, errorMessage])
return {
query,
@@ -76,6 +76,6 @@ export function useUserSearch<T>({
snackbarOpen,
setSnackbarOpen,
snackbarMessage,
setSnackbarMessage,
};
setSnackbarMessage
}
}
+69 -72
View File
@@ -1,8 +1,5 @@
import {
exportCalendar,
getSecretLink,
} from "@/features/Calendars/CalendarApi";
import { Calendar } from "@/features/Calendars/CalendarTypes";
import { exportCalendar, getSecretLink } from '@/features/Calendars/CalendarApi'
import { Calendar } from '@/features/Calendars/CalendarTypes'
import {
Box,
Button,
@@ -10,90 +7,90 @@ import {
IconButton,
InputAdornment,
TextField,
Typography,
} from "@linagora/twake-mui";
import ContentCopyIcon from "@mui/icons-material/ContentCopy";
import FileDownloadOutlinedIcon from "@mui/icons-material/FileDownloadOutlined";
import { useEffect, useMemo, useState } from "react";
import { useI18n } from "twake-i18n";
import { ErrorSnackbar } from "../Error/ErrorSnackbar";
import { FieldWithLabel } from "../Event/components/FieldWithLabel";
import { SnackbarAlert } from "../Loading/SnackBarAlert";
import { CalendarAccessRights, UserWithAccess } from "./CalendarAccessRights";
Typography
} from '@linagora/twake-mui'
import ContentCopyIcon from '@mui/icons-material/ContentCopy'
import FileDownloadOutlinedIcon from '@mui/icons-material/FileDownloadOutlined'
import { useEffect, useMemo, useState } from 'react'
import { useI18n } from 'twake-i18n'
import { ErrorSnackbar } from '../Error/ErrorSnackbar'
import { FieldWithLabel } from '../Event/components/FieldWithLabel'
import { SnackbarAlert } from '../Loading/SnackBarAlert'
import { CalendarAccessRights, UserWithAccess } from './CalendarAccessRights'
interface AccessTabProps {
calendar: Calendar;
usersWithAccess: UserWithAccess[];
onUsersWithAccessChange: (users: UserWithAccess[]) => void;
onInvitesLoaded: (users: UserWithAccess[]) => void;
calendar: Calendar
usersWithAccess: UserWithAccess[]
onUsersWithAccessChange: (users: UserWithAccess[]) => void
onInvitesLoaded: (users: UserWithAccess[]) => void
}
export function AccessTab({
calendar,
usersWithAccess,
onUsersWithAccessChange,
onInvitesLoaded,
onInvitesLoaded
}: AccessTabProps) {
const { t } = useI18n();
const { t } = useI18n()
const calDAVLink = `${window.DAV_BASE_URL}${calendar.link.replace(".json", "")}`;
const calDAVLink = `${window.DAV_BASE_URL}${calendar.link.replace('.json', '')}`
const isResource = useMemo(
() => calendar?.owner?.resource,
[calendar?.owner?.resource]
);
)
const [secretLink, setSecretLink] = useState("");
const [open, setOpen] = useState(false);
const [secretLink, setSecretLink] = useState('')
const [open, setOpen] = useState(false)
useEffect(() => {
async function fetchSecret() {
const existing = await getSecretLink(
calendar.link.replace(".json", ""),
calendar.link.replace('.json', ''),
false
);
setSecretLink(existing.secretLink);
)
setSecretLink(existing.secretLink)
}
fetchSecret();
}, [calendar.link]);
fetchSecret()
}, [calendar.link])
const handleCopy = (content: string) => {
navigator.clipboard.writeText(content);
setOpen(true);
};
navigator.clipboard.writeText(content)
setOpen(true)
}
const handleResetSecretLink = async () => {
const newSecret = await getSecretLink(
calendar.link.replace(".json", ""),
calendar.link.replace('.json', ''),
true
);
setSecretLink(newSecret.secretLink);
};
)
setSecretLink(newSecret.secretLink)
}
const [exportLoading, setExportLoading] = useState(false);
const [exportError, setExportError] = useState("");
const [exportLoading, setExportLoading] = useState(false)
const [exportError, setExportError] = useState('')
const handleExport = async () => {
try {
setExportLoading(true);
setExportLoading(true)
const exportedData = await exportCalendar(
calendar.link.replace(".json", "")
);
const blob = new Blob([exportedData], { type: "text/calendar" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `${calendar.id.split("/")[1]}.ics`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
calendar.link.replace('.json', '')
)
const blob = new Blob([exportedData], { type: 'text/calendar' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `${calendar.id.split('/')[1]}.ics`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
} catch (e) {
setExportError((e as Error).message);
setExportError((e as Error).message)
} finally {
setExportLoading(false);
setExportLoading(false)
}
};
}
return (
<>
@@ -105,13 +102,13 @@ export function AccessTab({
/>
{!!window.DAV_BASE_URL && !isResource && (
<FieldWithLabel label={t("calendar.caldav_access")} isExpanded={false}>
<FieldWithLabel label={t('calendar.caldav_access')} isExpanded={false}>
<Box mt={2}>
<TextField
disabled
fullWidth
label=""
inputProps={{ "aria-label": t("calendar.caldav_access") }}
inputProps={{ 'aria-label': t('calendar.caldav_access') }}
value={calDAVLink}
size="small"
InputProps={{
@@ -124,20 +121,20 @@ export function AccessTab({
<ContentCopyIcon fontSize="small" />
</IconButton>
</InputAdornment>
),
)
}}
/>
</Box>
</FieldWithLabel>
)}
<FieldWithLabel label={t("calendar.secretUrl")} isExpanded={false}>
<FieldWithLabel label={t('calendar.secretUrl')} isExpanded={false}>
<Box mt={3} display="flex" alignItems="center" gap={1}>
<TextField
disabled
fullWidth
label=""
inputProps={{ "aria-label": t("calendar.secretUrl") }}
inputProps={{ 'aria-label': t('calendar.secretUrl') }}
value={secretLink}
size="small"
InputProps={{
@@ -147,32 +144,32 @@ export function AccessTab({
<ContentCopyIcon fontSize="small" />
</IconButton>
</InputAdornment>
),
)
}}
/>
<Button
variant="contained"
color="secondary"
onClick={handleResetSecretLink}
sx={{ borderRadius: "4px" }}
sx={{ borderRadius: '4px' }}
>
{t("actions.reset")}
{t('actions.reset')}
</Button>
</Box>
<Typography
variant="body2"
sx={{ color: "text.secondary", mt: 1, lineHeight: 1.5 }}
sx={{ color: 'text.secondary', mt: 1, lineHeight: 1.5 }}
>
{t("calendar.secretUrlDesc")}
{t('calendar.secretUrlDesc')}
</Typography>
</FieldWithLabel>
<FieldWithLabel label={t("calendar.exportCalendar")} isExpanded={false}>
<FieldWithLabel label={t('calendar.exportCalendar')} isExpanded={false}>
<Typography
variant="body2"
sx={{ color: "text.secondary", my: 1, lineHeight: 1.5 }}
sx={{ color: 'text.secondary', my: 1, lineHeight: 1.5 }}
>
{t("calendar.exportDesc")}
{t('calendar.exportDesc')}
</Typography>
<Button
variant="contained"
@@ -180,15 +177,15 @@ export function AccessTab({
onClick={handleExport}
startIcon={!exportLoading && <FileDownloadOutlinedIcon />}
disabled={exportLoading}
sx={{ borderRadius: "4px" }}
sx={{ borderRadius: '4px' }}
>
{exportLoading ? (
<Box display="flex" alignItems="center" gap={1}>
<CircularProgress size={18} />
{t("actions.exporting")}
{t('actions.exporting')}
</Box>
) : (
t("actions.export")
t('actions.export')
)}
</Button>
</FieldWithLabel>
@@ -196,9 +193,9 @@ export function AccessTab({
<SnackbarAlert
setOpen={setOpen}
open={open}
message={t("common.link_copied")}
message={t('common.link_copied')}
/>
<ErrorSnackbar error={exportError} type="calendar" />
</>
);
)
}
+250 -246
View File
@@ -1,195 +1,199 @@
import { useAppDispatch, useAppSelector } from "@/app/hooks";
import EventPopover from "@/features/Events/EventModal";
import EventPreviewModal from "@/features/Events/EventPreview";
import { CalendarEvent } from "@/features/Events/EventsTypes";
import ImportAlert from "@/features/Events/ImportAlert";
import SearchResultsPage from "@/features/Search/SearchResultsPage";
import { setTimeZone } from "@/features/Settings/SettingsSlice";
import { setDisplayedDateAndRange } from "@/utils/CalendarRangeManager";
import { extractEventBaseUuid } from "@/utils/extractEventBaseUuid";
import { setSelectedCalendars as setSelectedCalendarsToStorage } from "@/utils/storage/setSelectedCalendars";
import { useSelectedCalendars } from "@/utils/storage/useSelectedCalendars";
import { browserDefaultTimeZone } from "@/utils/timezone";
import type { EventApi, LocaleInput } from "@fullcalendar/core";
import { CalendarApi, DateSelectArg } from "@fullcalendar/core";
import frLocale from "@fullcalendar/core/locales/fr";
import ruLocale from "@fullcalendar/core/locales/ru";
import viLocale from "@fullcalendar/core/locales/vi";
import dayGridPlugin from "@fullcalendar/daygrid";
import interactionPlugin from "@fullcalendar/interaction";
import momentTimezonePlugin from "@fullcalendar/moment-timezone";
import FullCalendar from "@fullcalendar/react";
import timeGridPlugin from "@fullcalendar/timegrid";
import { Box, Button, radius } from "@linagora/twake-mui";
import AddIcon from "@mui/icons-material/Add";
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 { EventErrorSnackbar } from "../Error/ErrorSnackbar";
import { EventErrorHandler } from "../Error/EventErrorHandler";
import { EditModeDialog } from "../Event/EditModeDialog";
import { Menubar, MenubarProps } from "../Menubar/Menubar";
import "./Calendar.styl";
import CalendarSelection from "./CalendarSelection";
import "./CustomCalendar.styl";
import { useCalendarEventHandlers } from "./hooks/useCalendarEventHandlers";
import { useCalendarViewHandlers } from "./hooks/useCalendarViewHandlers";
import { MiniCalendar } from "./MiniCalendar";
import { TempCalendarsInput } from "./TempCalendarsInput";
import { TimezoneSelector } from "./TimezoneSelector";
import { useAppDispatch, useAppSelector } from '@/app/hooks'
import EventPopover from '@/features/Events/EventModal'
import EventPreviewModal from '@/features/Events/EventPreview'
import { CalendarEvent } from '@/features/Events/EventsTypes'
import ImportAlert from '@/features/Events/ImportAlert'
import SearchResultsPage from '@/features/Search/SearchResultsPage'
import { setTimeZone } from '@/features/Settings/SettingsSlice'
import { setDisplayedDateAndRange } from '@/utils/CalendarRangeManager'
import { extractEventBaseUuid } from '@/utils/extractEventBaseUuid'
import { setSelectedCalendars as setSelectedCalendarsToStorage } from '@/utils/storage/setSelectedCalendars'
import { useSelectedCalendars } from '@/utils/storage/useSelectedCalendars'
import { browserDefaultTimeZone } from '@/utils/timezone'
import type { EventApi, LocaleInput } from '@fullcalendar/core'
import { CalendarApi, DateSelectArg } from '@fullcalendar/core'
import frLocale from '@fullcalendar/core/locales/fr'
import ruLocale from '@fullcalendar/core/locales/ru'
import viLocale from '@fullcalendar/core/locales/vi'
import dayGridPlugin from '@fullcalendar/daygrid'
import interactionPlugin from '@fullcalendar/interaction'
import momentTimezonePlugin from '@fullcalendar/moment-timezone'
import FullCalendar from '@fullcalendar/react'
import timeGridPlugin from '@fullcalendar/timegrid'
import { Box, Button, radius } from '@linagora/twake-mui'
import AddIcon from '@mui/icons-material/Add'
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 { EventErrorSnackbar } from '../Error/ErrorSnackbar'
import { EventErrorHandler } from '../Error/EventErrorHandler'
import { EditModeDialog } from '../Event/EditModeDialog'
import { Menubar, MenubarProps } from '../Menubar/Menubar'
import './Calendar.styl'
import CalendarSelection from './CalendarSelection'
import './CustomCalendar.styl'
import { useCalendarEventHandlers } from './hooks/useCalendarEventHandlers'
import { useCalendarViewHandlers } from './hooks/useCalendarViewHandlers'
import { MiniCalendar } from './MiniCalendar'
import { TempCalendarsInput } from './TempCalendarsInput'
import { TimezoneSelector } from './TimezoneSelector'
import {
eventToFullCalendarFormat,
extractEvents,
updateSlotLabelVisibility,
} from "./utils/calendarUtils";
updateSlotLabelVisibility
} from './utils/calendarUtils'
const localeMap: Record<string, LocaleInput | undefined> = {
fr: frLocale,
ru: ruLocale,
vi: viLocale,
en: undefined,
};
en: undefined
}
interface CalendarAppProps {
calendarRef: MutableRefObject<CalendarApi | null>;
onDateChange?: (date: Date) => void;
onViewChange?: (view: string) => void;
menubarProps?: MenubarProps;
calendarRef: MutableRefObject<CalendarApi | null>
onDateChange?: (date: Date) => void
onViewChange?: (view: string) => void
menubarProps?: MenubarProps
}
export default function CalendarApp({
calendarRef,
onDateChange,
onViewChange,
menubarProps,
menubarProps
}: CalendarAppProps) {
const [selectedDate, setSelectedDate] = useState(new Date());
const [debouncedDate, setDebouncedDate] = useState(new Date());
const [selectedDate, setSelectedDate] = useState(new Date())
const [debouncedDate, setDebouncedDate] = useState(new Date())
useEffect(() => {
const t = setTimeout(() => setDebouncedDate(selectedDate), 300);
return () => clearTimeout(t);
}, [selectedDate]);
const [selectedMiniDate, setSelectedMiniDate] = useState(new Date());
const userId =
useAppSelector((state) => state.user.userData?.openpaasId) ?? "";
const dispatch = useAppDispatch();
const view = useAppSelector((state) => state.settings.view);
const userData = useAppSelector((state) => state.user.userData);
const t = setTimeout(() => setDebouncedDate(selectedDate), 300)
return () => clearTimeout(t)
}, [selectedDate])
const [selectedMiniDate, setSelectedMiniDate] = useState(new Date())
const userId = useAppSelector(state => state.user.userData?.openpaasId) ?? ''
const dispatch = useAppDispatch()
const view = useAppSelector(state => state.settings.view)
const userData = useAppSelector(state => state.user.userData)
const workingDays = useAppSelector(
(state) => state.settings.businessHours?.daysOfWeek
);
const hideWorkingDays = useAppSelector((state) => state.settings.workingDays);
state => state.settings.businessHours?.daysOfWeek
)
const hideWorkingDays = useAppSelector(state => state.settings.workingDays)
const hideDeclinedEvents = useAppSelector(
(state) => state.settings.hideDeclinedEvents
);
state => state.settings.hideDeclinedEvents
)
const hiddenDays = useMemo(() => {
if (!hideWorkingDays || !workingDays || workingDays.length === 0) return [];
const validWorkingDays = workingDays.filter((d) => d >= 0 && d <= 6);
if (validWorkingDays.length === 0) return [];
return [0, 1, 2, 3, 4, 5, 6].filter((d) => !validWorkingDays.includes(d));
}, [hideWorkingDays, workingDays]);
if (!hideWorkingDays || !workingDays || workingDays.length === 0) return []
const validWorkingDays = workingDays.filter(d => d >= 0 && d <= 6)
if (validWorkingDays.length === 0) return []
return [0, 1, 2, 3, 4, 5, 6].filter(d => !validWorkingDays.includes(d))
}, [hideWorkingDays, workingDays])
const calendars = useAppSelector((state) => state.calendars.list);
const isPending = useAppSelector((state) => state.calendars.pending);
const calendars = useAppSelector(state => state.calendars.list)
const isPending = useAppSelector(state => state.calendars.pending)
const displayWeekNumbers = useAppSelector(
(state) => state.settings.displayWeekNumbers
);
const tempcalendars = useAppSelector((state) => state.calendars.templist);
const storedCalendars = useSelectedCalendars();
state => state.settings.displayWeekNumbers
)
const tempcalendars = useAppSelector(state => state.calendars.templist)
const storedCalendars = useSelectedCalendars()
const [selectedCalendars, setSelectedCalendars] =
useState<string[]>(storedCalendars);
useState<string[]>(storedCalendars)
const calendarIdsString = useMemo(
() =>
Object.keys(calendars || {})
.sort()
.join(","),
.join(','),
[calendars]
);
)
const calendarIds = useMemo(
() => (calendarIdsString ? calendarIdsString.split(",") : []),
() => (calendarIdsString ? calendarIdsString.split(',') : []),
[calendarIdsString]
);
)
const [currentView, setCurrentView] = useState("timeGridWeek");
const [currentView, setCurrentView] = useState('timeGridWeek')
const timezone =
useAppSelector((state) => state.settings.timeZone) ??
browserDefaultTimeZone;
useAppSelector(state => state.settings.timeZone) ?? browserDefaultTimeZone
// Auto-select personal calendars when first loaded
const initialLoadRef = useRef(true);
const [eventErrors, setEventErrors] = useState<string[]>([]);
const errorHandler = useRef(new EventErrorHandler());
const initialLoadRef = useRef(true)
const [eventErrors, setEventErrors] = useState<string[]>([])
const errorHandler = useRef(new EventErrorHandler())
useEffect(() => {
const handler = errorHandler.current;
handler.setErrorCallback(setEventErrors);
return () => handler.setErrorCallback(() => {});
}, []);
const handler = errorHandler.current
handler.setErrorCallback(setEventErrors)
return () => handler.setErrorCallback(() => {})
}, [])
const handleErrorClose = () => {
setEventErrors([]);
errorHandler.current.clearAll();
};
setEventErrors([])
errorHandler.current.clearAll()
}
useEffect(() => {
if (initialLoadRef.current && calendarIds.length > 0 && userId) {
const cached = localStorage.getItem("selectedCalendars");
if (cached && cached.length > 0) {
const parsed = JSON.parse(cached) as string[];
const valid = parsed.filter((id) => calendars[id]);
setSelectedCalendars(valid);
} else {
const personalCalendarIds = calendarIds.filter(
(id) => extractEventBaseUuid(id) === userId
);
setSelectedCalendars(personalCalendarIds);
const updateSelectedCalendars = () => {
if (initialLoadRef.current && calendarIds.length > 0 && userId) {
const cached = localStorage.getItem('selectedCalendars')
if (cached && cached.length > 0) {
const parsed = JSON.parse(cached) as string[]
const valid = parsed.filter(id => calendars[id])
setSelectedCalendars(valid)
} else {
const personalCalendarIds = calendarIds.filter(
id => extractEventBaseUuid(id) === userId
)
setSelectedCalendars(personalCalendarIds)
}
initialLoadRef.current = false
}
initialLoadRef.current = false;
}
}, [calendarIds, calendars, userId]);
updateSelectedCalendars()
}, [calendarIds, calendars, userId])
// Save selected cals to cache
useEffect(() => {
if (calendarIds.length > 0) {
setSelectedCalendarsToStorage(selectedCalendars);
setSelectedCalendarsToStorage(selectedCalendars)
}
}, [selectedCalendars, calendarIds.length]);
}, [selectedCalendars, calendarIds.length])
useEffect(() => {
if (calendarIds.length === 0) return;
const validCalendarIds = new Set(calendarIds);
setSelectedCalendars((prev) => {
const filtered = prev.filter((calId) => validCalendarIds.has(calId));
if (filtered.length === prev.length) {
const unchanged = filtered.every((id, index) => id === prev[index]);
if (unchanged) {
return prev;
const updateSelectedCalendarsOnCalendarChange = () => {
if (calendarIds.length === 0) return
const validCalendarIds = new Set(calendarIds)
setSelectedCalendars(prev => {
const filtered = prev.filter(calId => validCalendarIds.has(calId))
if (filtered.length === prev.length) {
const unchanged = filtered.every((id, index) => id === prev[index])
if (unchanged) {
return prev
}
}
}
return filtered;
});
}, [calendarIds]);
return filtered
})
}
updateSelectedCalendarsOnCalendarChange()
}, [calendarIds])
const sortedSelectedCalendars = useMemo(
() => [...selectedCalendars].sort(),
[selectedCalendars]
);
)
const tempCalendarIdsString = useMemo(
() =>
Object.keys(tempcalendars || {})
.sort()
.join(","),
.join(','),
[tempcalendars]
);
)
const tempCalendarIds = useMemo(
() => (tempCalendarIdsString ? tempCalendarIdsString.split(",") : []),
() => (tempCalendarIdsString ? tempCalendarIdsString.split(',') : []),
[tempCalendarIdsString]
);
)
useCalendarDataLoader({
selectedDate: debouncedDate,
@@ -198,47 +202,47 @@ export default function CalendarApp({
sortedSelectedCalendars,
calendarIds,
calendarIdsString,
tempCalendarIds,
});
tempCalendarIds
})
const filteredEvents: CalendarEvent[] = extractEvents(
selectedCalendars,
calendars || {},
userData?.email,
hideDeclinedEvents
);
)
const filteredTempEvents: CalendarEvent[] = extractEvents(
tempCalendarIds,
tempcalendars || {},
userData?.email,
hideDeclinedEvents
);
)
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const [openEventDisplay, setOpenEventDisplay] = useState(false);
const [eventDisplayedId, setEventDisplayedId] = useState("");
const [eventDisplayedTemp, setEventDisplayedTemp] = useState(false);
const [eventDisplayedCalId, setEventDisplayedCalId] = useState("");
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null)
const [openEventDisplay, setOpenEventDisplay] = useState(false)
const [eventDisplayedId, setEventDisplayedId] = useState('')
const [eventDisplayedTemp, setEventDisplayedTemp] = useState(false)
const [eventDisplayedCalId, setEventDisplayedCalId] = useState('')
// Listen for eventModalError event to reopen modal on API failure
useEffect(() => {
const handleEventModalError = (event: CustomEvent) => {
if (event.detail?.type === "create") {
if (event.detail?.type === 'create') {
// Reopen create event modal
setAnchorEl(document.body);
} else if (event.detail?.type === "update") {
setAnchorEl(document.body)
} else if (event.detail?.type === 'update') {
// Store update modal info to sessionStorage for EventDisplayPreview to pick up
try {
sessionStorage.setItem(
"eventUpdateModalReopen",
'eventUpdateModalReopen',
JSON.stringify({
eventId: event.detail.eventId,
calId: event.detail.calId,
typeOfAction: event.detail.typeOfAction,
timestamp: Date.now(),
timestamp: Date.now()
})
);
)
// Open EventDisplayPreview if it's not already open with matching event, so it can pick up the sessionStorage
if (
@@ -246,56 +250,52 @@ export default function CalendarApp({
eventDisplayedId !== event.detail.eventId ||
eventDisplayedCalId !== event.detail.calId
) {
setEventDisplayedId(event.detail.eventId);
setEventDisplayedCalId(event.detail.calId);
setEventDisplayedTemp(false);
setOpenEventDisplay(true);
setEventDisplayedId(event.detail.eventId)
setEventDisplayedCalId(event.detail.calId)
setEventDisplayedTemp(false)
setOpenEventDisplay(true)
} else {
// If EventDisplayPreview is already open, trigger reopen by dispatching a custom event
window.dispatchEvent(
new CustomEvent("eventUpdateModalReopen", {
new CustomEvent('eventUpdateModalReopen', {
detail: {
eventId: event.detail.eventId,
calId: event.detail.calId,
typeOfAction: event.detail.typeOfAction,
},
typeOfAction: event.detail.typeOfAction
}
})
);
)
}
} catch {
// Ignore sessionStorage errors
}
}
};
}
window.addEventListener(
"eventModalError",
'eventModalError',
handleEventModalError as EventListener
);
)
return () => {
window.removeEventListener(
"eventModalError",
'eventModalError',
handleEventModalError as EventListener
);
};
}, [openEventDisplay, eventDisplayedId, eventDisplayedCalId]);
)
}
}, [openEventDisplay, eventDisplayedId, eventDisplayedCalId])
const [openEditModePopup, setOpenEditModePopup] = useState<string | null>(
null
);
const [, setTypeOfAction] = useState<"solo" | "all" | undefined>(undefined);
)
const [, setTypeOfAction] = useState<'solo' | 'all' | undefined>(undefined)
const [afterChoiceFunc, setAfterChoiceFunc] = useState<
((type: "solo" | "all" | undefined) => void) | undefined
>();
const [, setSelectedEvent] = useState<CalendarEvent>({} as CalendarEvent);
const [selectedRange, setSelectedRange] = useState<DateSelectArg | null>(
null
);
((type: 'solo' | 'all' | undefined) => void) | undefined
>()
const [, setSelectedEvent] = useState<CalendarEvent>({} as CalendarEvent)
const [selectedRange, setSelectedRange] = useState<DateSelectArg | null>(null)
const [tempUsers, setTempUsers] = useState<User[]>([]);
const [tempEvent, setTempEvent] = useState<CalendarEvent>(
{} as CalendarEvent
);
const [tempUsers, setTempUsers] = useState<User[]>([])
const [tempEvent, setTempEvent] = useState<CalendarEvent>({} as CalendarEvent)
// Event handlers
const eventHandlers = useCalendarEventHandlers({
@@ -313,8 +313,8 @@ export default function CalendarApp({
setOpenEditModePopup,
tempUsers,
setTempEvent,
timezone,
});
timezone
})
// View handlers
const viewHandlers = useCalendarViewHandlers({
@@ -324,18 +324,22 @@ export default function CalendarApp({
onViewChange,
calendars,
tempcalendars,
errorHandler: errorHandler.current,
});
// Note: To preserve current logic, this will temporarily disable eslint for react-hooks/refs
// eslint-disable-next-line react-hooks/refs
errorHandler: errorHandler.current
})
if (process.env.NODE_ENV === "test") {
window.__calendarRef = calendarRef;
}
useEffect(() => {
if (process.env.NODE_ENV === 'test') {
window.__calendarRef = calendarRef
}
}, [calendarRef])
const { t, lang } = useI18n();
const { t, lang } = useI18n()
return (
<main
className={`main-layout calendar-layout ${menubarProps?.isIframe ? " isInIframe" : ""}`}
className={`main-layout calendar-layout ${menubarProps?.isIframe ? ' isInIframe' : ''}`}
>
<Box
className="sidebar"
@@ -344,16 +348,16 @@ export default function CalendarApp({
paddingBottom: 3,
paddingLeft: 3,
paddingRight: 2,
width: "270px",
width: '270px'
}}
>
<Box
sx={{
position: "sticky",
position: 'sticky',
top: 0,
zIndex: 10,
backgroundColor: "#fff",
paddingTop: menubarProps?.isIframe ? "10px" : 3,
backgroundColor: '#fff',
paddingTop: menubarProps?.isIframe ? '10px' : 3
}}
>
<Button
@@ -365,13 +369,13 @@ export default function CalendarApp({
}
sx={{
borderRadius: radius.lg,
fontSize: "16px",
fontSize: '16px',
fontWeight: 500,
lineHeight: "normal",
lineHeight: 'normal'
}}
>
<AddIcon sx={{ marginRight: 0.5, fontSize: "20px" }} />{" "}
{t("event.createEvent")}
<AddIcon sx={{ marginRight: 0.5, fontSize: '20px' }} />{' '}
{t('event.createEvent')}
</Button>
</Box>
@@ -385,7 +389,7 @@ export default function CalendarApp({
tempUsers={tempUsers}
setTempUsers={setTempUsers}
handleToggleEventPreview={() => {
eventHandlers.handleDateSelect(null as unknown as DateSelectArg);
eventHandlers.handleDateSelect(null as unknown as DateSelectArg)
}}
/>
</Box>
@@ -399,19 +403,19 @@ export default function CalendarApp({
<div className="calendar">
<ImportAlert />
{menubarProps?.isIframe && <Menubar {...menubarProps} />}
{view === "calendar" && (
{view === 'calendar' && (
<FullCalendar
key={hiddenDays.join(",")}
ref={(ref) => {
key={hiddenDays.join(',')}
ref={ref => {
if (ref) {
calendarRef.current = ref.getApi();
calendarRef.current = ref.getApi()
}
}}
plugins={[
dayGridPlugin,
timeGridPlugin,
interactionPlugin,
momentTimezonePlugin,
momentTimezonePlugin
]}
initialView="timeGridWeek"
firstDay={1}
@@ -420,16 +424,16 @@ export default function CalendarApp({
hiddenDays={hiddenDays}
selectable={true}
timeZone={timezone}
height={"100%"}
height="100%"
select={eventHandlers.handleDateSelect}
nowIndicator
slotLabelClassNames={(arg) => [
updateSlotLabelVisibility(new Date(), arg, timezone),
slotLabelClassNames={arg => [
updateSlotLabelVisibility(new Date(), arg, timezone)
]}
nowIndicatorContent={viewHandlers.handleNowIndicatorContent}
headerToolbar={false}
views={{
timeGridWeek: { titleFormat: { month: "long", year: "numeric" } },
timeGridWeek: { titleFormat: { month: 'long', year: 'numeric' } }
}}
dayMaxEvents={true}
events={eventToFullCalendarFormat(
@@ -444,15 +448,15 @@ export default function CalendarApp({
a.extendedProps.priority - b.extendedProps.priority
}
weekNumbers={
currentView === "timeGridWeek" || currentView === "timeGridDay"
currentView === 'timeGridWeek' || currentView === 'timeGridDay'
}
weekNumberFormat={{ week: "long" }}
weekNumberContent={(arg) => {
weekNumberFormat={{ week: 'long' }}
weekNumberContent={arg => {
return (
<div className="weekSelector">
{displayWeekNumbers && (
<div>
{t("menubar.views.week")} {arg.num}
{t('menubar.views.week')} {arg.num}
</div>
)}
<TimezoneSelector
@@ -463,91 +467,91 @@ export default function CalendarApp({
}
/>
</div>
);
)
}}
dayCellContent={(arg) => {
const month = arg.date.toLocaleDateString(t("locale"), {
month: "short",
timeZone: timezone,
});
if (arg.view.type === "dayGridMonth") {
dayCellContent={arg => {
const month = arg.date.toLocaleDateString(t('locale'), {
month: 'short',
timeZone: timezone
})
if (arg.view.type === 'dayGridMonth') {
return (
<span
className={`fc-daygrid-day-number ${
arg.isToday ? "current-date" : ""
arg.isToday ? 'current-date' : ''
}`}
>
{arg.dayNumberText === "1" ? month : ""} {arg.dayNumberText}
{arg.dayNumberText === '1' ? month : ''} {arg.dayNumberText}
</span>
);
)
}
}}
slotDuration={"00:30:00"}
slotLabelInterval={"01:00:00"}
slotDuration="00:30:00"
slotLabelInterval="01:00:00"
scrollTime="12:00:00"
unselectAuto={false}
allDayText=""
slotLabelFormat={{
hour: "2-digit",
minute: "2-digit",
hour12: false,
hour: '2-digit',
minute: '2-digit',
hour12: false
}}
datesSet={(arg) => {
setCurrentView(arg.view.type);
datesSet={arg => {
setCurrentView(arg.view.type)
const calendarCurrentDate =
calendarRef.current?.getDate() || new Date(arg.start);
setDisplayedDateAndRange(calendarCurrentDate);
calendarRef.current?.getDate() || new Date(arg.start)
setDisplayedDateAndRange(calendarCurrentDate)
if (arg.view.type === "dayGridMonth") {
const start = new Date(arg.start).getTime();
const end = new Date(arg.end).getTime();
const middle = start + (end - start) / 2;
setSelectedDate(new Date(middle));
setSelectedMiniDate(calendarCurrentDate);
if (arg.view.type === 'dayGridMonth') {
const start = new Date(arg.start).getTime()
const end = new Date(arg.end).getTime()
const middle = start + (end - start) / 2
setSelectedDate(new Date(middle))
setSelectedMiniDate(calendarCurrentDate)
} else {
setSelectedDate(calendarCurrentDate);
setSelectedMiniDate(calendarCurrentDate);
setSelectedDate(calendarCurrentDate)
setSelectedMiniDate(calendarCurrentDate)
}
// Always use the calendar's current date for consistency
if (onDateChange) {
onDateChange(calendarCurrentDate);
onDateChange(calendarCurrentDate)
}
// Notify parent about view change
if (onViewChange) {
onViewChange(arg.view.type);
onViewChange(arg.view.type)
}
// Update slot label visibility when view changes
setTimeout(() => {
updateSlotLabelVisibility(new Date());
}, 100);
updateSlotLabelVisibility(new Date())
}, 100)
}}
dayHeaderContent={(arg) => {
const m = moment.tz(arg.date, timezone);
dayHeaderContent={arg => {
const m = moment.tz(arg.date, timezone)
const date = m.date();
const date = m.date()
const weekDay = m
.toDate()
.toLocaleDateString(t("locale"), {
weekday: "short",
timeZone: timezone,
.toLocaleDateString(t('locale'), {
weekday: 'short',
timeZone: timezone
})
.toUpperCase();
.toUpperCase()
return (
<div className="fc-daygrid-day-top">
<small>{weekDay}</small>
{arg.view.type !== "dayGridMonth" && (
{arg.view.type !== 'dayGridMonth' && (
<span
className={`fc-daygrid-day-number ${arg.isToday ? "current-date" : ""}`}
className={`fc-daygrid-day-number ${arg.isToday ? 'current-date' : ''}`}
>
{date}
</span>
)}
</div>
);
)
}}
dayHeaderDidMount={viewHandlers.handleDayHeaderDidMount}
dayHeaderWillUnmount={viewHandlers.handleDayHeaderWillUnmount}
@@ -561,7 +565,7 @@ export default function CalendarApp({
eventDidMount={viewHandlers.handleEventDidMount}
/>
)}
{view === "search" && <SearchResultsPage />}
{view === 'search' && <SearchResultsPage />}
<EventPopover
anchorEl={anchorEl}
open={Boolean(anchorEl)}
@@ -574,10 +578,10 @@ export default function CalendarApp({
<EditModeDialog
type={openEditModePopup}
setOpen={setOpenEditModePopup}
eventAction={(type: "solo" | "all" | undefined) => {
setTypeOfAction(type);
eventAction={(type: 'solo' | 'all' | undefined) => {
setTypeOfAction(type)
if (afterChoiceFunc) {
afterChoiceFunc(type);
afterChoiceFunc(type)
}
}}
/>
@@ -593,5 +597,5 @@ export default function CalendarApp({
<EventErrorSnackbar messages={eventErrors} onClose={handleErrorClose} />
</div>
</main>
);
)
}
+178 -178
View File
@@ -1,8 +1,8 @@
import { useAppSelector } from "@/app/hooks";
import { AccessRight, Calendar } from "@/features/Calendars/CalendarTypes";
import { getUserDetails } from "@/features/User/userAPI";
import { makeDisplayName } from "@/utils/makeDisplayName";
import { normalizeEmail } from "@/utils/normalizeEmail";
import { useAppSelector } from '@/app/hooks'
import { AccessRight, Calendar } from '@/features/Calendars/CalendarTypes'
import { getUserDetails } from '@/features/User/userAPI'
import { makeDisplayName } from '@/utils/makeDisplayName'
import { normalizeEmail } from '@/utils/normalizeEmail'
import {
AutocompleteRenderInputParams,
Avatar,
@@ -13,236 +13,236 @@ import {
MenuItem,
Select,
TextField,
Typography,
} from "@linagora/twake-mui";
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 { FieldWithLabel } from "../Event/components/FieldWithLabel";
import { stringAvatar } from "../Event/utils/eventUtils";
import { ResourceAdmin } from "./ResourceAdmins";
Typography
} from '@linagora/twake-mui'
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 { FieldWithLabel } from '../Event/components/FieldWithLabel'
import { stringAvatar } from '../Event/utils/eventUtils'
import { ResourceAdmin } from './ResourceAdmins'
export interface UserWithAccess extends User {
accessRight: AccessRight;
accessRight: AccessRight
}
interface CalendarAccessRightsProps {
calendar: Calendar;
value: UserWithAccess[];
onChange: (users: UserWithAccess[]) => void;
onInvitesLoaded: (users: UserWithAccess[]) => void;
calendar: Calendar
value: UserWithAccess[]
onChange: (users: UserWithAccess[]) => void
onInvitesLoaded: (users: UserWithAccess[]) => void
}
interface UserInCalendar {
id: string;
access: AccessRight;
id: string
access: AccessRight
}
export function CalendarAccessRights({
calendar,
value: usersWithAccess,
onChange,
onInvitesLoaded,
onInvitesLoaded
}: CalendarAccessRightsProps) {
const { t } = useI18n();
const userData = useAppSelector((state) => state.user.userData);
const isPersonalCalendar = userData?.openpaasId === calendar.id.split("/")[0];
const currentUserEmail = normalizeEmail(userData?.email);
const isDelegatedWithAdministration = !!calendar.invite?.some((invite) => {
const invitedEmail = normalizeEmail(invite.href.replace(/^mailto:/i, ""));
return invitedEmail === currentUserEmail && invite.access === 5;
});
const { t } = useI18n()
const userData = useAppSelector(state => state.user.userData)
const isPersonalCalendar = userData?.openpaasId === calendar.id.split('/')[0]
const currentUserEmail = normalizeEmail(userData?.email)
const isDelegatedWithAdministration = !!calendar.invite?.some(invite => {
const invitedEmail = normalizeEmail(invite.href.replace(/^mailto:/i, ''))
return invitedEmail === currentUserEmail && invite.access === 5
})
const ownerEmail =
calendar.owner?.preferredEmail ?? calendar.owner?.emails?.[0] ?? "";
const ownerName = makeDisplayName(calendar) ?? ownerEmail;
calendar.owner?.preferredEmail ?? calendar.owner?.emails?.[0] ?? ''
const ownerName = makeDisplayName(calendar) ?? ownerEmail
const containerRef = useRef<HTMLDivElement>(null);
const [searchWidth, setSearchWidth] = useState<number | undefined>(undefined);
const [accessRight, setAccessRight] = useState<AccessRight>(2);
const [inviteLoading, setInvitesLoading] = useState(false);
const [resourceAdmins, setResourceAdmins] = useState<UserWithAccess[]>([]);
const [adminLoading, setAdminLoading] = useState(false);
const containerRef = useRef<HTMLDivElement>(null)
const [searchWidth, setSearchWidth] = useState<number | undefined>(undefined)
const [accessRight, setAccessRight] = useState<AccessRight>(2)
const [inviteLoading, setInvitesLoading] = useState(false)
const [resourceAdmins, setResourceAdmins] = useState<UserWithAccess[]>([])
const [adminLoading, setAdminLoading] = useState(false)
const currentUsersRef = useRef<UserWithAccess[]>(usersWithAccess);
const currentUsersRef = useRef<UserWithAccess[]>(usersWithAccess)
useEffect(() => {
currentUsersRef.current = usersWithAccess;
}, [usersWithAccess]);
currentUsersRef.current = usersWithAccess
}, [usersWithAccess])
useEffect(() => {
if (!containerRef.current) return;
const observer = new ResizeObserver((entries) => {
if (!containerRef.current) return
const observer = new ResizeObserver(entries => {
for (const entry of entries) {
setSearchWidth(entry.contentRect.width);
setSearchWidth(entry.contentRect.width)
}
});
observer.observe(containerRef.current);
return () => observer.disconnect();
}, []);
})
observer.observe(containerRef.current)
return () => observer.disconnect()
}, [])
const handleLoadUsers = useCallback(
async (usersInCal: UserInCalendar[], cancelled: boolean) => {
const results = await Promise.allSettled(
usersInCal.map(async (user) => {
const details = await getUserDetails(user.id);
const email = details?.preferredEmail ?? details?.emails?.[0] ?? "";
usersInCal.map(async user => {
const details = await getUserDetails(user.id)
const email = details?.preferredEmail ?? details?.emails?.[0] ?? ''
return {
openpaasId: user.id,
displayName:
[details?.firstname, details?.lastname]
.filter(Boolean)
.join(" ")
.join(' ')
.trim() || email,
email,
accessRight: user.access as AccessRight,
} satisfies UserWithAccess;
accessRight: user.access
} satisfies UserWithAccess
})
);
)
if (cancelled) {
return [] as UserWithAccess[];
return [] as UserWithAccess[]
}
const loaded: UserWithAccess[] = results
.filter((r) => r.status === "fulfilled")
.map((r) => (r as PromiseFulfilledResult<UserWithAccess>).value)
.filter(Boolean) as UserWithAccess[];
.filter(r => r.status === 'fulfilled')
.map(r => (r as PromiseFulfilledResult<UserWithAccess>).value)
.filter(Boolean)
return loaded;
return loaded
},
[]
);
)
useEffect(() => {
if (!calendar.invite?.length) return;
if (!calendar.invite?.length) return
let cancelled = false;
let cancelled = false
async function loadInvitedUsers() {
setInvitesLoading(true);
setInvitesLoading(true)
try {
const usersInCal = (calendar.invite
?.map((invite) => {
const principalId = invite.principal.split("/").pop();
if (!principalId) return null;
?.map(invite => {
const principalId = invite.principal.split('/').pop()
if (!principalId) return null
return {
id: principalId,
access: invite.access,
};
access: invite.access
}
})
?.filter(
(invite) =>
invite =>
!!invite &&
!calendar.owner.administrators?.some(
(admin) => admin.id === invite.id
admin => admin.id === invite.id
)
) || []) as UserInCalendar[];
) || []) as UserInCalendar[]
const loaded = await handleLoadUsers(usersInCal, cancelled);
const loaded = await handleLoadUsers(usersInCal, cancelled)
const loadedIds = new Set(loaded.map((u) => normalizeEmail(u.email)));
const loadedIds = new Set(loaded.map(u => normalizeEmail(u.email)))
const manuallyAdded = currentUsersRef.current.filter(
(u) => !loadedIds.has(normalizeEmail(u.email))
);
const merged = [...loaded, ...manuallyAdded];
u => !loadedIds.has(normalizeEmail(u.email))
)
const merged = [...loaded, ...manuallyAdded]
onInvitesLoaded(loaded);
onChange(merged);
onInvitesLoaded(loaded)
onChange(merged)
} finally {
if (!cancelled) setInvitesLoading(false);
if (!cancelled) setInvitesLoading(false)
}
}
loadInvitedUsers();
loadInvitedUsers()
return () => {
cancelled = true;
};
cancelled = true
}
}, [
calendar.invite,
calendar.owner.administrators,
handleLoadUsers,
onChange,
onInvitesLoaded,
]);
onInvitesLoaded
])
useEffect(() => {
const isResource = calendar.owner.resource;
const resourceAdmins = calendar.owner.administrators || [];
if (!isResource || !resourceAdmins?.length) return;
const isResource = calendar.owner.resource
const resourceAdmins = calendar.owner.administrators || []
if (!isResource || !resourceAdmins?.length) return
let cancelled = false;
let cancelled = false
async function loadAdmins() {
try {
setAdminLoading(true);
setAdminLoading(true)
const resourceAdminsWithoutOwner = resourceAdmins
.filter((admin) => admin.id !== calendar.owner._id)
.map((admin) => ({
.filter(admin => admin.id !== calendar.owner._id)
.map(admin => ({
id: admin.id,
access: 5, // ADMIN
})) satisfies UserInCalendar[];
access: 5 // ADMIN
})) satisfies UserInCalendar[]
const admins = await handleLoadUsers(
resourceAdminsWithoutOwner,
cancelled
);
)
setResourceAdmins(admins);
setResourceAdmins(admins)
} catch (error) {
console.error(error);
console.error(error)
} finally {
if (!cancelled) setAdminLoading(false);
if (!cancelled) setAdminLoading(false)
}
}
loadAdmins();
loadAdmins()
return () => {
cancelled = true;
};
}, [calendar.owner, handleLoadUsers, setResourceAdmins]);
cancelled = true
}
}, [calendar.owner, handleLoadUsers, setResourceAdmins])
const handleUserSelect = (_event: unknown, users: User[]) => {
const updated: UserWithAccess[] = users.map((user) => {
const updated: UserWithAccess[] = users.map(user => {
const existing = usersWithAccess.find(
(u) => normalizeEmail(u.email) === normalizeEmail(user.email)
);
return existing ?? { ...user, accessRight };
});
onChange(updated);
};
u => normalizeEmail(u.email) === normalizeEmail(user.email)
)
return existing ?? { ...user, accessRight }
})
onChange(updated)
}
const handleRemoveUser = (email: string) => {
onChange(
usersWithAccess.filter(
(u) => normalizeEmail(u.email) !== normalizeEmail(email)
u => normalizeEmail(u.email) !== normalizeEmail(email)
)
);
};
)
}
const handleChangeUserRight = (email: string, right: AccessRight) => {
onChange(
usersWithAccess.map((u) =>
usersWithAccess.map(u =>
normalizeEmail(u.email) === normalizeEmail(email)
? { ...u, accessRight: right }
: u
)
);
};
)
}
const accessRightOptions: { value: AccessRight; label: string }[] = [
{ value: 2, label: t("calendarPopover.access.viewAllEvents") },
{ value: 3, label: t("calendarPopover.access.editor") },
{ value: 5, label: t("calendarPopover.access.administrator") },
];
{ value: 2, label: t('calendarPopover.access.viewAllEvents') },
{ value: 3, label: t('calendarPopover.access.editor') },
{ value: 5, label: t('calendarPopover.access.administrator') }
]
return (
<FieldWithLabel
label={
isPersonalCalendar || isDelegatedWithAdministration
? t("calendarPopover.access.grantAccessRights")
: t("calendarPopover.access.accessRights")
? t('calendarPopover.access.grantAccessRights')
: t('calendarPopover.access.accessRights')
}
isExpanded={false}
>
@@ -251,27 +251,27 @@ export function CalendarAccessRights({
<PeopleSearch
selectedUsers={usersWithAccess}
onChange={handleUserSelect}
objectTypes={["user"]}
objectTypes={['user']}
onToggleEventPreview={() => {}}
customSlotProps={{
popper: {
anchorEl: containerRef.current,
placement: "bottom-start",
placement: 'bottom-start',
sx: {
minWidth: searchWidth,
"& .MuiPaper-root": {
width: "100%",
},
'& .MuiPaper-root': {
width: '100%'
}
},
modifiers: [
{
name: "offset",
name: 'offset',
options: {
offset: [0, 8],
},
},
],
},
offset: [0, 8]
}
}
]
}
}}
customRenderInput={(
params: AutocompleteRenderInputParams,
@@ -282,33 +282,33 @@ export function CalendarAccessRights({
{...params}
fullWidth
autoFocus
placeholder={t("peopleSearch.label")}
placeholder={t('peopleSearch.label')}
value={query}
inputRef={(el) => {
const ref = params.InputProps.ref;
if (typeof ref === "function") {
ref(el);
} else if (ref && "current" in ref) {
(
inputRef={el => {
const ref = params.InputProps.ref
if (typeof ref === 'function') {
ref(el)
} else if (ref && 'current' in ref) {
;(
ref as React.MutableRefObject<HTMLInputElement | null>
).current = el;
).current = el
}
}}
onChange={(e) => setQuery(e.target.value)}
onChange={e => setQuery(e.target.value)}
variant="outlined"
inputProps={{
...params.inputProps,
sx: {
fontSize: "14px",
"&::placeholder": { fontSize: "14px" },
},
fontSize: '14px',
'&::placeholder': { fontSize: '14px' }
}
}}
InputProps={{
...params.InputProps,
startAdornment: (
<InputAdornment position="start">
<PeopleOutlineOutlinedIcon
sx={{ color: "text.secondary" }}
sx={{ color: 'text.secondary' }}
/>
</InputAdornment>
),
@@ -316,34 +316,34 @@ export function CalendarAccessRights({
<InputAdornment position="end">
<Select
value={accessRight}
onChange={(e) =>
onChange={e =>
setAccessRight(e.target.value as AccessRight)
}
variant="standard"
disableUnderline
sx={{
fontSize: "0.875rem",
color: "text.secondary",
"& .MuiSelect-select": {
paddingRight: "24px !important",
paddingY: 0,
fontSize: '0.875rem',
color: 'text.secondary',
'& .MuiSelect-select': {
paddingRight: '24px !important',
paddingY: 0
},
"& .MuiSelect-icon": { fontSize: "1rem" },
"&:before, &:after": { display: "none" },
'& .MuiSelect-icon': { fontSize: '1rem' },
'&:before, &:after': { display: 'none' }
}}
>
{accessRightOptions.map((opt) => (
{accessRightOptions.map(opt => (
<MenuItem
key={opt.value}
value={opt.value}
sx={{ color: "text.secondary" }}
sx={{ color: 'text.secondary' }}
>
{opt.label}
</MenuItem>
))}
</Select>
</InputAdornment>
),
)
}}
/>
)}
@@ -360,14 +360,14 @@ export function CalendarAccessRights({
px={1}
py={0.5}
sx={{
borderRadius: "8px",
"&:hover": { backgroundColor: "action.hover" },
borderRadius: '8px',
'&:hover': { backgroundColor: 'action.hover' }
}}
>
<Box display="flex" alignItems="center" gap={1.5} minWidth={0}>
<Avatar
{...stringAvatar(ownerName)}
sx={{ width: 28, height: 28, fontSize: "0.875rem" }}
sx={{ width: 28, height: 28, fontSize: '0.875rem' }}
/>
<Box minWidth={0} display="flex" flexDirection="column" gap={0}>
<Typography noWrap>{ownerName}</Typography>
@@ -379,7 +379,7 @@ export function CalendarAccessRights({
<Box display="flex" alignItems="center" gap={0.5} flexShrink={0}>
<Typography variant="caption">
{t("calendarPopover.access.owner")}
{t('calendarPopover.access.owner')}
</Typography>
</Box>
</Box>
@@ -388,7 +388,7 @@ export function CalendarAccessRights({
<CircularProgress size={24} />
</Box>
) : (
resourceAdmins.map((admin) => (
resourceAdmins.map(admin => (
<ResourceAdmin key={admin.email} admin={admin} />
))
)}
@@ -398,7 +398,7 @@ export function CalendarAccessRights({
</Box>
) : (
usersWithAccess.length > 0 &&
usersWithAccess.map((user) => (
usersWithAccess.map(user => (
<Box
key={user.email}
display="flex"
@@ -407,14 +407,14 @@ export function CalendarAccessRights({
px={1}
py={0.5}
sx={{
borderRadius: "8px",
"&:hover": { backgroundColor: "action.hover" },
borderRadius: '8px',
'&:hover': { backgroundColor: 'action.hover' }
}}
>
<Box display="flex" alignItems="center" gap={1.5} minWidth={0}>
<Avatar
{...stringAvatar(user.displayName)}
sx={{ width: 28, height: 28, fontSize: "0.875rem" }}
sx={{ width: 28, height: 28, fontSize: '0.875rem' }}
/>
<Box minWidth={0} display="flex" flexDirection="column" gap={0}>
<Typography noWrap>{user.displayName}</Typography>
@@ -427,7 +427,7 @@ export function CalendarAccessRights({
<Box display="flex" alignItems="center" gap={0.5} flexShrink={0}>
<Select
value={user.accessRight}
onChange={(e) =>
onChange={e =>
handleChangeUserRight(
user.email,
e.target.value as AccessRight
@@ -439,20 +439,20 @@ export function CalendarAccessRights({
!(isPersonalCalendar || isDelegatedWithAdministration)
}
sx={{
fontSize: "0.875rem",
color: "text.secondary",
"& .MuiSelect-select": {
paddingRight: "24px !important",
paddingY: 0,
fontSize: '0.875rem',
color: 'text.secondary',
'& .MuiSelect-select': {
paddingRight: '24px !important',
paddingY: 0
},
"& .MuiSelect-icon": { fontSize: "1rem" },
'& .MuiSelect-icon': { fontSize: '1rem' }
}}
>
{accessRightOptions.map((opt) => (
{accessRightOptions.map(opt => (
<MenuItem
key={opt.value}
value={opt.value}
sx={{ color: "text.secondary" }}
sx={{ color: 'text.secondary' }}
>
{opt.label}
</MenuItem>
@@ -461,9 +461,9 @@ export function CalendarAccessRights({
{(isPersonalCalendar || isDelegatedWithAdministration) && (
<IconButton
size="small"
aria-label={t("actions.remove")}
aria-label={t('actions.remove')}
onClick={() => handleRemoveUser(user.email)}
sx={{ color: "text.secondary" }}
sx={{ color: 'text.secondary' }}
>
<HighlightOffIcon fontSize="small" />
</IconButton>
@@ -474,5 +474,5 @@ export function CalendarAccessRights({
)}
</Box>
</FieldWithLabel>
);
)
}
+102 -113
View File
@@ -1,45 +1,35 @@
import AddIcon from "@mui/icons-material/Add";
import CheckIcon from "@mui/icons-material/Check";
import AddIcon from '@mui/icons-material/Add'
import CheckIcon from '@mui/icons-material/Check'
import {
Box,
Button,
Popover,
TextField,
Typography,
useTheme,
} from "@linagora/twake-mui";
import { useState, useEffect } from "react";
import { HexColorPicker } from "react-colorful";
import { useI18n } from "twake-i18n";
import { getAccessiblePair } from "@/utils/getAccessiblePair";
import { defaultColors } from "@/utils/defaultColors";
useTheme
} from '@linagora/twake-mui'
import { useState } from 'react'
import { HexColorPicker } from 'react-colorful'
import { useI18n } from 'twake-i18n'
import { getAccessiblePair } from '@/utils/getAccessiblePair'
import { defaultColors } from '@/utils/defaultColors'
export function ColorPicker({
selectedColor,
colors = defaultColors.slice(0, 4),
onChange,
onChange
}: {
selectedColor: Record<string, string>;
colors?: Record<string, string>[];
onChange: (color: Record<string, string>) => void;
selectedColor: Record<string, string>
colors?: Record<string, string>[]
onChange: (color: Record<string, string>) => void
}) {
const [customColor, setCustomColor] = useState(
!colors.find((c) => c.light === selectedColor?.light)
? selectedColor
: undefined
);
useEffect(() => {
if (!colors.find((c) => c.light === selectedColor?.light)) {
setCustomColor(selectedColor);
} else {
setCustomColor(undefined);
}
}, [selectedColor, colors]);
const customColor = !colors.find(c => c.light === selectedColor?.light)
? selectedColor
: undefined
return (
<Box display="flex" alignItems="center" gap={1}>
{colors.map((c) => (
{colors.map(c => (
<ColorBox
key={c.light}
color={c}
@@ -56,24 +46,23 @@ export function ColorPicker({
)}
<ColorPickerBox
onChange={(c) => {
onChange(c);
setCustomColor(c);
onChange={c => {
onChange(c)
}}
selectedColor={selectedColor}
/>
</Box>
);
)
}
function ColorBox({
color,
onChange,
selectedColor,
selectedColor
}: {
color: Record<string, string>;
onChange: (color: Record<string, string>) => void;
selectedColor: Record<string, string>;
color: Record<string, string>
onChange: (color: Record<string, string>) => void
selectedColor: Record<string, string>
}) {
return (
<Box
@@ -81,109 +70,109 @@ function ColorBox({
aria-label={`select color ${color.light}`}
onClick={() => onChange(color)}
style={{
width: "46px",
height: "32px",
width: '46px',
height: '32px',
padding: 0,
borderRadius: "4px",
borderRadius: '4px',
backgroundColor: color.light,
cursor: "pointer",
transition: "all 0.2s",
display: "flex",
flexDirection: "column",
alignItems: "center",
cursor: 'pointer',
transition: 'all 0.2s',
display: 'flex',
flexDirection: 'column',
alignItems: 'center'
}}
>
<Box
style={{
height: "7px",
width: "100%",
borderRadius: "4px 4px 0px 0px",
backgroundColor: color.dark,
height: '7px',
width: '100%',
borderRadius: '4px 4px 0px 0px',
backgroundColor: color.dark
}}
></Box>
<CheckIcon
style={{
visibility:
selectedColor?.light === color.light ? "visible" : "hidden",
color: color.dark,
selectedColor?.light === color.light ? 'visible' : 'hidden',
color: color.dark
}}
/>
</Box>
);
)
}
function ColorPickerBox({
onChange,
selectedColor,
selectedColor
}: {
onChange: (color: Record<string, string>) => void;
selectedColor: Record<string, string>;
onChange: (color: Record<string, string>) => void
selectedColor: Record<string, string>
}) {
const { t } = useI18n();
const { t } = useI18n()
const [oldColor] = useState(
selectedColor ?? { light: "#ffffff", dark: "#808080" }
);
const [color, setColor] = useState(oldColor);
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const open = Boolean(anchorEl);
const theme = useTheme();
selectedColor ?? { light: '#ffffff', dark: '#808080' }
)
const [color, setColor] = useState(oldColor)
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null)
const open = Boolean(anchorEl)
const theme = useTheme()
const handleClick = (event: React.MouseEvent<HTMLDivElement>) => {
setAnchorEl(event.currentTarget);
};
setAnchorEl(event.currentTarget)
}
const handleClose = () => {
onChange(oldColor);
setAnchorEl(null);
};
onChange(oldColor)
setAnchorEl(null)
}
const handleSave = () => {
onChange(color);
setAnchorEl(null);
};
onChange(color)
setAnchorEl(null)
}
const handleColorChange = (c: string) => {
const newLight = c;
const newLight = c
const newColor = {
light: newLight,
dark: getAccessiblePair(newLight, theme),
};
setColor(newColor);
onChange(newColor);
};
dark: getAccessiblePair(newLight, theme)
}
setColor(newColor)
onChange(newColor)
}
return (
<>
<Box
key={"colorPicker"}
key="colorPicker"
role="button"
aria-label={t("colorPicker.selectCustom")}
aria-label={t('colorPicker.selectCustom')}
onClick={handleClick}
style={{
width: "46px",
height: "32px",
width: '46px',
height: '32px',
padding: 0,
borderRadius: "4px",
border: "1px solid #CBD2E0",
backgroundColor: "#FFF",
cursor: "pointer",
transition: "all 0.2s",
display: "flex",
flexDirection: "column",
alignItems: "center",
borderRadius: '4px',
border: '1px solid #CBD2E0',
backgroundColor: '#FFF',
cursor: 'pointer',
transition: 'all 0.2s',
display: 'flex',
flexDirection: 'column',
alignItems: 'center'
}}
>
<Box
style={{
height: "7px",
width: "100%",
borderRadius: "4px 4px 0px 0px",
backgroundColor: "#CBD2E0",
height: '7px',
width: '100%',
borderRadius: '4px 4px 0px 0px',
backgroundColor: '#CBD2E0'
}}
></Box>
<AddIcon
style={{
color: "#CBD2E0",
color: '#CBD2E0'
}}
/>
</Box>
@@ -192,63 +181,63 @@ function ColorPickerBox({
anchorEl={anchorEl}
onClose={handleClose}
anchorOrigin={{
vertical: "center",
horizontal: "center",
vertical: 'center',
horizontal: 'center'
}}
transformOrigin={{
vertical: "top",
horizontal: "left",
vertical: 'top',
horizontal: 'left'
}}
slotProps={{
paper: {
style: {
padding: "24px",
width: "294px",
borderRadius: "8px",
boxShadow: "0px 1px 3px #3C404326",
},
},
padding: '24px',
width: '294px',
borderRadius: '8px',
boxShadow: '0px 1px 3px #3C404326'
}
}
}}
>
<Typography variant="subtitle1" fontWeight="600">
{t("colorPicker.title")}
{t('colorPicker.title')}
</Typography>
<Typography variant="body2" sx={{ mb: 2, color: "text.secondary" }}>
{t("colorPicker.subtitle")}
<Typography variant="body2" sx={{ mb: 2, color: 'text.secondary' }}>
{t('colorPicker.subtitle')}
</Typography>
<Box sx={{ mb: 2 }}>
<HexColorPicker
color={color.light}
onChange={handleColorChange}
style={{ width: "100%" }}
style={{ width: '100%' }}
/>
</Box>
<Box sx={{ display: "flex", alignItems: "center", mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<Typography variant="body2" sx={{ mr: 1 }}>
{t("colorPicker.hex")}
{t('colorPicker.hex')}
</Typography>
<TextField
value={color.light?.toUpperCase()}
onChange={(e) => handleColorChange(e.target.value)}
onChange={e => handleColorChange(e.target.value)}
variant="standard"
size="small"
slotProps={{ inputLabel: { shrink: true } }}
/>
</Box>
<Box sx={{ display: "flex", justifyContent: "flex-end", gap: 1 }}>
<Button onClick={handleClose}>{t("common.cancel")}</Button>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
<Button onClick={handleClose}>{t('common.cancel')}</Button>
<Button
variant="contained"
onClick={handleSave}
sx={{ textTransform: "none" }}
sx={{ textTransform: 'none' }}
>
{t("actions.save")}
{t('actions.save')}
</Button>
</Box>
</Popover>
</>
);
)
}
+6 -6
View File
@@ -1,14 +1,14 @@
import { Calendar } from "@/features/Calendars/CalendarTypes";
import { MenuItem } from "@linagora/twake-mui";
import React from "react";
import { CalendarName } from "./CalendarName";
import { Calendar } from '@/features/Calendars/CalendarTypes'
import { MenuItem } from '@linagora/twake-mui'
import React from 'react'
import { CalendarName } from './CalendarName'
export function CalendarItemList(
userPersonalCalendars: Calendar[]
): React.ReactNode {
return Object.values(userPersonalCalendars).map((calendar) => (
return Object.values(userPersonalCalendars).map(calendar => (
<MenuItem key={calendar.id} value={calendar.id}>
<CalendarName calendar={calendar} />
</MenuItem>
));
))
}
+40 -40
View File
@@ -1,69 +1,69 @@
import { useAppDispatch, useAppSelector } from "@/app/hooks";
import SettingsPage from "@/features/Settings/SettingsPage";
import { getViewRange } from "@/utils/dateUtils";
import type { CalendarApi } from "@fullcalendar/core";
import CozyBridge from "cozy-external-bridge";
import { useEffect, useMemo, useRef, useState } from "react";
import { ErrorSnackbar } from "../Error/ErrorSnackbar";
import { refreshCalendars } from "../Event/utils/eventUtils";
import { Menubar, MenubarProps } from "../Menubar/Menubar";
import CalendarApp from "./Calendar";
import { useAppDispatch, useAppSelector } from '@/app/hooks'
import SettingsPage from '@/features/Settings/SettingsPage'
import { getViewRange } from '@/utils/dateUtils'
import type { CalendarApi } from '@fullcalendar/core'
import CozyBridge from 'cozy-external-bridge'
import { useEffect, useMemo, useRef, useState } from 'react'
import { ErrorSnackbar } from '../Error/ErrorSnackbar'
import { refreshCalendars } from '../Event/utils/eventUtils'
import { Menubar, MenubarProps } from '../Menubar/Menubar'
import CalendarApp from './Calendar'
export default function CalendarLayout() {
const calendarRef = useRef<CalendarApi | null>(null);
const dispatch = useAppDispatch();
const error = useAppSelector((state) => state.calendars.error);
const selectedCalendars = useAppSelector((state) => state.calendars.list);
const tempcalendars = useAppSelector((state) => state.calendars.templist);
const view = useAppSelector((state) => state.settings.view);
const [currentDate, setCurrentDate] = useState<Date>(new Date());
const [currentView, setCurrentView] = useState<string>("timeGridWeek");
const isInIframe = useMemo(() => new CozyBridge().isInIframe(), []);
const calendarRef = useRef<CalendarApi | null>(null)
const dispatch = useAppDispatch()
const error = useAppSelector(state => state.calendars.error)
const selectedCalendars = useAppSelector(state => state.calendars.list)
const tempcalendars = useAppSelector(state => state.calendars.templist)
const view = useAppSelector(state => state.settings.view)
const [currentDate, setCurrentDate] = useState<Date>(new Date())
const [currentView, setCurrentView] = useState<string>('timeGridWeek')
const isInIframe = useMemo(() => new CozyBridge().isInIframe(), [])
const handleRefresh = async () => {
// Get current calendar range
if (calendarRef.current) {
const view = calendarRef.current.view;
const calendarRange = getViewRange(view.activeStart, view.type);
const view = calendarRef.current.view
const calendarRange = getViewRange(view.activeStart, view.type)
// Refresh events for selected calendars
await refreshCalendars(
dispatch,
Object.values(selectedCalendars),
calendarRange
);
)
if (tempcalendars) {
await refreshCalendars(
dispatch,
Object.values(tempcalendars),
calendarRange,
"temp"
);
'temp'
)
}
}
};
}
const handleDateChange = (date: Date) => {
setCurrentDate(date);
};
setCurrentDate(date)
}
const handleViewChange = (view: string) => {
setCurrentView(view);
};
setCurrentView(view)
}
// Hide topbar navigation elements when in settings view (same as fullscreen dialog mode)
useEffect(() => {
if (view === "settings") {
document.body.classList.add("fullscreen-view");
if (view === 'settings') {
document.body.classList.add('fullscreen-view')
} else {
document.body.classList.remove("fullscreen-view");
document.body.classList.remove('fullscreen-view')
}
// Cleanup on unmount
return () => {
document.body.classList.remove("fullscreen-view");
};
}, [view]);
document.body.classList.remove('fullscreen-view')
}
}, [view])
const menubarProps: MenubarProps = {
calendarRef,
@@ -72,13 +72,13 @@ export default function CalendarLayout() {
onDateChange: handleDateChange,
currentView,
onViewChange: handleViewChange,
isIframe: isInIframe,
};
isIframe: isInIframe
}
return (
<div className="App">
{!isInIframe && <Menubar {...menubarProps} />}
{(view === "calendar" || view === "search") && (
{(view === 'calendar' || view === 'search') && (
<CalendarApp
calendarRef={calendarRef}
onDateChange={handleDateChange}
@@ -86,8 +86,8 @@ export default function CalendarLayout() {
menubarProps={menubarProps}
/>
)}
{view === "settings" && <SettingsPage isInIframe={isInIframe} />}
{view === 'settings' && <SettingsPage isInIframe={isInIframe} />}
<ErrorSnackbar error={error} type="calendar" />
</div>
);
)
}
+167 -164
View File
@@ -1,190 +1,193 @@
import { useAppDispatch, useAppSelector } from "@/app/hooks";
import { Calendar } from "@/features/Calendars/CalendarTypes";
import { useAppDispatch, useAppSelector } from '@/app/hooks'
import { Calendar } from '@/features/Calendars/CalendarTypes'
import {
createCalendarAsync,
importEventFromFileAsync,
patchACLCalendarAsync,
patchCalendarAsync,
} from "@/features/Calendars/services";
import { updateDelegationCalendarAsync } from "@/features/Calendars/services/updateDelegationCalendarAsync";
import { accessRightToDavProp } from "@/utils/accessRightToDavProp";
import { defaultColors } from "@/utils/defaultColors";
import { extractEventBaseUuid } from "@/utils/extractEventBaseUuid";
import { Button, Tab, Tabs } from "@linagora/twake-mui";
import { useCallback, useEffect, useRef, useState } from "react";
import { useI18n } from "twake-i18n";
import { ResponsiveDialog } from "../Dialog";
import { ErrorSnackbar } from "../Error/ErrorSnackbar";
import { AccessTab } from "./AccessTab";
import { UserWithAccess } from "./CalendarAccessRights";
import { ImportTab } from "./ImportTab";
import { SettingsTab } from "./SettingsTab";
patchCalendarAsync
} from '@/features/Calendars/services'
import { updateDelegationCalendarAsync } from '@/features/Calendars/services/updateDelegationCalendarAsync'
import { accessRightToDavProp } from '@/utils/accessRightToDavProp'
import { defaultColors } from '@/utils/defaultColors'
import { extractEventBaseUuid } from '@/utils/extractEventBaseUuid'
import { Button, Tab, Tabs } from '@linagora/twake-mui'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useI18n } from 'twake-i18n'
import { ResponsiveDialog } from '../Dialog'
import { ErrorSnackbar } from '../Error/ErrorSnackbar'
import { AccessTab } from './AccessTab'
import { UserWithAccess } from './CalendarAccessRights'
import { ImportTab } from './ImportTab'
import { SettingsTab } from './SettingsTab'
function CalendarPopover({
open,
onClose,
calendar,
calendar
}: {
open: boolean;
open: boolean
onClose: (
event: object | null,
reason: "backdropClick" | "escapeKeyDown"
) => void;
calendar?: Calendar;
reason: 'backdropClick' | 'escapeKeyDown'
) => void
calendar?: Calendar
}) {
const { t } = useI18n();
const dispatch = useAppDispatch();
const userData = useAppSelector((state) => state.user.userData) ?? {};
const calendars = useAppSelector((state) => state.calendars.list);
const { t } = useI18n()
const dispatch = useAppDispatch()
const userData = useAppSelector(state => state.user.userData) ?? {}
const calendars = useAppSelector(state => state.calendars.list)
const isOwn = calendar?.id
? extractEventBaseUuid(calendar.id) === userData.openpaasId
: true;
: true
const canManageInvites =
isOwn ||
!!calendar?.invite?.some((invite) => {
!!calendar?.invite?.some(invite => {
const inviteEmail = invite.href
.replace(/^mailto:/i, "")
.replace(/^mailto:/i, '')
.trim()
.toLowerCase();
const currentEmail = userData.email?.trim().toLowerCase();
return inviteEmail === currentEmail && invite.access === 5;
});
.toLowerCase()
const currentEmail = userData.email?.trim().toLowerCase()
return inviteEmail === currentEmail && invite.access === 5
})
// existing calendar params
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [color, setColor] = useState<Record<string, string>>(defaultColors[0]);
const [visibility, setVisibility] = useState<"private" | "public">("public");
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [color, setColor] = useState<Record<string, string>>(defaultColors[0])
const [visibility, setVisibility] = useState<'private' | 'public'>('public')
// access tab state
const [usersWithAccess, setUsersWithAccess] = useState<UserWithAccess[]>([]);
const [usersWithAccess, setUsersWithAccess] = useState<UserWithAccess[]>([])
// Snapshot of the invitee list as loaded from calendar.invite on open.
// Used to diff on save: what changed vs what was removed.
const initialUsersRef = useRef<UserWithAccess[]>([]);
const initialUsersRef = useRef<UserWithAccess[]>([])
// import tab state
const [tab, setTab] = useState<"settings" | "access" | "import">("settings");
const [importedContent, setImportedContent] = useState<File | null>(null);
const [importTarget, setImportTarget] = useState("new");
const [tab, setTab] = useState<'settings' | 'access' | 'import'>('settings')
const [importedContent, setImportedContent] = useState<File | null>(null)
const [importTarget, setImportTarget] = useState('new')
// new calendar params (for import new)
const [newCalName, setNewCalName] = useState("");
const [newCalDescription, setNewCalDescription] = useState("");
const [newCalColor, setNewCalColor] = useState(defaultColors[0]);
const [newCalName, setNewCalName] = useState('')
const [newCalDescription, setNewCalDescription] = useState('')
const [newCalColor, setNewCalColor] = useState(defaultColors[0])
const [newCalVisibility, setNewCalVisibility] = useState<
"public" | "private"
>("public");
'public' | 'private'
>('public')
useEffect(() => {
if (!open) return;
if (calendar) {
setName(calendar.name);
setDescription(calendar.description ?? "");
setColor(calendar.color ?? defaultColors[0]);
setVisibility(calendar.visibility ?? "public");
setImportTarget(calendar.id ?? "new");
} else {
setName("");
setDescription("");
setColor(defaultColors[0]);
setVisibility("public");
setImportTarget("new");
if (!open) return
const fillCalendarData = () => {
if (calendar) {
setName(calendar.name)
setDescription(calendar.description ?? '')
setColor(calendar.color ?? defaultColors[0])
setVisibility(calendar.visibility ?? 'public')
setImportTarget(calendar.id ?? 'new')
} else {
setName('')
setDescription('')
setColor(defaultColors[0])
setVisibility('public')
setImportTarget('new')
}
setUsersWithAccess([])
initialUsersRef.current = []
}
setUsersWithAccess([]);
initialUsersRef.current = [];
}, [calendar, open]);
fillCalendarData()
}, [calendar, open])
const handleUsersWithAccessChange = useCallback((users: UserWithAccess[]) => {
setUsersWithAccess(users);
}, []);
setUsersWithAccess(users)
}, [])
const handleInvitesLoaded = useCallback((users: UserWithAccess[]) => {
if (initialUsersRef.current.length === 0) {
initialUsersRef.current = users;
initialUsersRef.current = users
}
}, []);
}, [])
const [saveError, setSaveError] = useState("");
const [saveError, setSaveError] = useState('')
const updateCalendar = async (calId: string, calLink: string) => {
const nameChanged = name.trim() !== calendar?.name;
const descChanged = description.trim() !== (calendar?.description ?? "");
const nameChanged = name.trim() !== calendar?.name
const descChanged = description.trim() !== (calendar?.description ?? '')
const colorChanged =
JSON.stringify(color) !==
JSON.stringify(calendar?.color ?? defaultColors[0]);
JSON.stringify(calendar?.color ?? defaultColors[0])
if (nameChanged || descChanged || colorChanged) {
await dispatch(
patchCalendarAsync({
calId,
calLink,
patch: { name: name.trim(), desc: description.trim(), color },
patch: { name: name.trim(), desc: description.trim(), color }
})
).unwrap();
).unwrap()
}
if (canManageInvites && visibility !== calendar?.visibility) {
await dispatch(
patchACLCalendarAsync({
calId,
calLink,
request: visibility === "public" ? "{DAV:}read" : "",
request: visibility === 'public' ? '{DAV:}read' : ''
})
).unwrap();
).unwrap()
}
};
}
async function updateCalendarInvites(
calLink: string,
initialUsers: UserWithAccess[]
) {
const normaliseEmail = (u: UserWithAccess) =>
u.email?.trim().toLowerCase() ?? "";
u.email?.trim().toLowerCase() ?? ''
const initialMap = new Map(
initialUsers
.filter((u) => !!normaliseEmail(u))
.map((u) => [normaliseEmail(u), u])
);
.filter(u => !!normaliseEmail(u))
.map(u => [normaliseEmail(u), u])
)
const currentMap = new Map(
usersWithAccess
.filter((u) => !!normaliseEmail(u))
.map((u) => [normaliseEmail(u), u])
);
.filter(u => !!normaliseEmail(u))
.map(u => [normaliseEmail(u), u])
)
const hasChanges =
usersWithAccess.some((u) => {
const email = normaliseEmail(u);
if (!email) return false;
const initial = initialMap.get(email);
return !initial || initial.accessRight !== u.accessRight;
usersWithAccess.some(u => {
const email = normaliseEmail(u)
if (!email) return false
const initial = initialMap.get(email)
return !initial || initial.accessRight !== u.accessRight
}) ||
initialUsers.some(
(u) => !!normaliseEmail(u) && !currentMap.has(normaliseEmail(u))
);
u => !!normaliseEmail(u) && !currentMap.has(normaliseEmail(u))
)
if (!hasChanges || !canManageInvites) return;
if (!hasChanges || !canManageInvites) return
// Send all remaining users in `set`: the server treats it as the full list
const set = usersWithAccess
.filter((u) => !!normaliseEmail(u))
.map((u) => ({
"dav:href": `mailto:${normaliseEmail(u)}`,
[accessRightToDavProp(u.accessRight)]: true,
}));
.filter(u => !!normaliseEmail(u))
.map(u => ({
'dav:href': `mailto:${normaliseEmail(u)}`,
[accessRightToDavProp(u.accessRight)]: true
}))
const remove = initialUsers
.filter((u) => !!normaliseEmail(u) && !currentMap.has(normaliseEmail(u)))
.map((u) => ({ "dav:href": `mailto:${normaliseEmail(u)}` }));
.filter(u => !!normaliseEmail(u) && !currentMap.has(normaliseEmail(u)))
.map(u => ({ 'dav:href': `mailto:${normaliseEmail(u)}` }))
await dispatch(
updateDelegationCalendarAsync({
calId: calendar?.id,
calId: calendar?.id ?? '',
calLink,
share: { set, remove },
share: { set, remove }
})
).unwrap();
).unwrap()
}
const createCalendar = async (
@@ -200,39 +203,39 @@ function CalendarPopover({
desc: desc.trim(),
color: color,
userData,
calId,
calId
})
);
)
dispatch(
patchACLCalendarAsync({
calId: `${userData.openpaasId}/${calId}`,
calLink: `/calendars/${userData.openpaasId}/${calId}.json`,
request: visibility === "public" ? "{DAV:}read" : "",
request: visibility === 'public' ? '{DAV:}read' : ''
})
);
};
)
}
const handleSave = async () => {
if (!name.trim()) return;
if (!name.trim()) return
if (calendar) {
// Snapshot before any await: handleClose resets the ref immediately after
// calling handleSave(), so we must read it synchronously here.
const initialUsersSnapshot = [...initialUsersRef.current];
const initialUsersSnapshot = [...initialUsersRef.current]
try {
await updateCalendar(calendar.id, calendar.link);
await updateCalendarInvites(calendar.link, initialUsersSnapshot);
await updateCalendar(calendar.id, calendar.link)
await updateCalendarInvites(calendar.link, initialUsersSnapshot)
} catch {
setSaveError(t("error.title"));
return;
setSaveError(t('error.title'))
return
}
} else {
createCalendar(crypto.randomUUID(), name, description, color, visibility);
createCalendar(crypto.randomUUID(), name, description, color, visibility)
}
};
}
const handleImport = async () => {
if (importTarget === "new") {
const calId = crypto.randomUUID();
if (importTarget === 'new') {
const calId = crypto.randomUUID()
if (newCalName.trim()) {
await createCalendar(
calId,
@@ -240,14 +243,14 @@ function CalendarPopover({
newCalDescription,
newCalColor,
newCalVisibility
);
)
if (importedContent) {
dispatch(
importEventFromFileAsync({
calLink: `/calendars/${userData.openpaasId}/${calId}.json`,
file: importedContent,
file: importedContent
})
);
)
}
}
} else {
@@ -255,91 +258,91 @@ function CalendarPopover({
dispatch(
importEventFromFileAsync({
calLink: calendars[importTarget].link,
file: importedContent,
file: importedContent
})
);
)
}
}
handleClose({}, "backdropClick");
};
handleClose({}, 'backdropClick')
}
const handleClose = (
e: object | null,
reason: "backdropClick" | "escapeKeyDown" | "cancel"
reason: 'backdropClick' | 'escapeKeyDown' | 'cancel'
): void => {
if (reason !== "cancel") {
handleSave();
onClose(e, reason);
if (reason !== 'cancel') {
handleSave()
onClose(e, reason)
} else {
onClose(e, "backdropClick");
onClose(e, 'backdropClick')
}
setName("");
setDescription("");
setColor(defaultColors[0]);
setTab("settings");
setVisibility("public");
setImportTarget("new");
setImportedContent(null);
setUsersWithAccess([]);
initialUsersRef.current = [];
setSaveError("");
setNewCalName("");
setNewCalDescription("");
setNewCalColor(defaultColors[0]);
setNewCalVisibility("public");
};
setName('')
setDescription('')
setColor(defaultColors[0])
setTab('settings')
setVisibility('public')
setImportTarget('new')
setImportedContent(null)
setUsersWithAccess([])
initialUsersRef.current = []
setSaveError('')
setNewCalName('')
setNewCalDescription('')
setNewCalColor(defaultColors[0])
setNewCalVisibility('public')
}
return (
<ResponsiveDialog
open={open}
onClose={() => handleClose({}, "backdropClick")}
onClose={() => handleClose({}, 'backdropClick')}
title={
<Tabs value={tab} onChange={(_e, v) => setTab(v)}>
<Tab
value="settings"
label={
calendar
? t("calendarPopover.tabs.settings")
: t("calendarPopover.tabs.addNew")
? t('calendarPopover.tabs.settings')
: t('calendarPopover.tabs.addNew')
}
/>
{calendar && (
<Tab value="access" label={t("calendarPopover.tabs.access")} />
<Tab value="access" label={t('calendarPopover.tabs.access')} />
)}
{isOwn && (
<Tab value="import" label={t("calendarPopover.tabs.import")} />
<Tab value="import" label={t('calendarPopover.tabs.import')} />
)}
</Tabs>
}
actions={
<>
<Button variant="outlined" onClick={() => handleClose({}, "cancel")}>
{t("common.cancel")}
<Button variant="outlined" onClick={() => handleClose({}, 'cancel')}>
{t('common.cancel')}
</Button>
<Button
disabled={tab === "import" ? !importedContent : !name.trim()}
disabled={tab === 'import' ? !importedContent : !name.trim()}
variant="contained"
onClick={
tab === "import"
tab === 'import'
? handleImport
: () => handleClose({}, "backdropClick")
: () => handleClose({}, 'backdropClick')
}
>
{tab === "import"
? t("actions.import")
{tab === 'import'
? t('actions.import')
: calendar
? t("actions.save")
: t("actions.create")}
? t('actions.save')
: t('actions.create')}
</Button>
</>
}
>
{tab === "import" && (
{tab === 'import' && (
<ImportTab
importTarget={importTarget}
setImportTarget={setImportTarget}
setImportedContent={setImportedContent}
userId={userData.openpaasId ?? ""}
userId={userData.openpaasId ?? ''}
newCalParams={{
name: newCalName,
setName: setNewCalName,
@@ -348,11 +351,11 @@ function CalendarPopover({
color: newCalColor,
setColor: setNewCalColor,
visibility: newCalVisibility,
setVisibility: setNewCalVisibility,
setVisibility: setNewCalVisibility
}}
/>
)}
{tab === "settings" && (
{tab === 'settings' && (
<SettingsTab
name={name}
setName={setName}
@@ -365,7 +368,7 @@ function CalendarPopover({
calendar={calendar}
/>
)}
{tab === "access" && calendar && (
{tab === 'access' && calendar && (
<AccessTab
calendar={calendar}
usersWithAccess={usersWithAccess}
@@ -375,7 +378,7 @@ function CalendarPopover({
)}
<ErrorSnackbar error={saveError} type="calendar" />
</ResponsiveDialog>
);
)
}
export default CalendarPopover;
export default CalendarPopover
+25 -25
View File
@@ -1,32 +1,32 @@
import { useAppSelector } from "@/app/hooks";
import { Calendar } from "@/features/Calendars/CalendarTypes";
import { defaultColors } from "@/utils/defaultColors";
import { makeDisplayName } from "@/utils/makeDisplayName";
import { renameDefault } from "@/utils/renameDefault";
import { Box, Typography } from "@linagora/twake-mui";
import SquareRoundedIcon from "@mui/icons-material/SquareRounded";
import { useI18n } from "twake-i18n";
import { OwnerCaption } from "./OwnerCaption";
import { ResourceIcon } from "../Attendees/ResourceIcon";
import { useAppSelector } from '@/app/hooks'
import { Calendar } from '@/features/Calendars/CalendarTypes'
import { defaultColors } from '@/utils/defaultColors'
import { makeDisplayName } from '@/utils/makeDisplayName'
import { renameDefault } from '@/utils/renameDefault'
import { Box, Typography } from '@linagora/twake-mui'
import SquareRoundedIcon from '@mui/icons-material/SquareRounded'
import { useI18n } from 'twake-i18n'
import { OwnerCaption } from './OwnerCaption'
import { ResourceIcon } from '../Attendees/ResourceIcon'
export function CalendarName({ calendar }: { calendar: Calendar }) {
const userData = useAppSelector((state) => state.user.userData);
const { t } = useI18n();
const userData = useAppSelector(state => state.user.userData)
const { t } = useI18n()
const ownerId = calendar.id.split("/")[0];
const ownerDisplayName = makeDisplayName(calendar) ?? "";
const isOwnCalendar = userData.openpaasId === ownerId;
const isResource = calendar.owner?.resource;
const ownerId = calendar.id.split('/')[0]
const ownerDisplayName = makeDisplayName(calendar) ?? ''
const isOwnCalendar = userData.openpaasId === ownerId
const isResource = calendar.owner?.resource
const showCaption =
calendar.name !== "#default" && !isOwnCalendar && !isResource;
calendar.name !== '#default' && !isOwnCalendar && !isResource
return (
<Box
style={{
display: "flex",
flexDirection: "row",
gap: "16px",
alignItems: "center",
display: 'flex',
flexDirection: 'row',
gap: '16px',
alignItems: 'center'
}}
>
{isResource ? (
@@ -40,12 +40,12 @@ export function CalendarName({ calendar }: { calendar: Calendar }) {
style={{
color: calendar.color?.light ?? defaultColors[0].light,
width: 24,
height: 24,
height: 24
}}
/>
)}
<Box style={{ display: "flex", flexDirection: "column" }}>
<Typography variant="body2" sx={{ wordBreak: "break-word" }}>
<Box style={{ display: 'flex', flexDirection: 'column' }}>
<Typography variant="body2" sx={{ wordBreak: 'break-word' }}>
{renameDefault(calendar.name, ownerDisplayName, t, isOwnCalendar)}
</Typography>
<OwnerCaption
@@ -54,5 +54,5 @@ export function CalendarName({ calendar }: { calendar: Calendar }) {
/>
</Box>
</Box>
);
)
}
+137 -140
View File
@@ -1,46 +1,46 @@
import { useAppDispatch, useAppSelector } from "@/app/hooks";
import { getCalendars } from "@/features/Calendars/CalendarApi";
import { Calendar } from "@/features/Calendars/CalendarTypes";
import { CalendarData } from "@/features/Calendars/types/CalendarData";
import { renameDefault } from "@/utils/renameDefault";
import { useAppDispatch, useAppSelector } from '@/app/hooks'
import { getCalendars } from '@/features/Calendars/CalendarApi'
import { Calendar } from '@/features/Calendars/CalendarTypes'
import { CalendarData } from '@/features/Calendars/types/CalendarData'
import { renameDefault } from '@/utils/renameDefault'
import {
Box,
Button,
IconButton,
TextField,
Typography,
useTheme,
} from "@linagora/twake-mui";
import CloseIcon from "@mui/icons-material/Close";
import { useRef, useState } from "react";
import { useI18n } from "twake-i18n";
import { ResponsiveDialog } from "../Dialog";
import { ColorPicker } from "./CalendarColorPicker";
import { getAccessiblePair } from "@/utils/getAccessiblePair";
import { defaultColors } from "@/utils/defaultColors";
import { addCalendarResourceAsync } from "@/features/Calendars/api/addCalendarResourceAsync";
import { Resource, ResourceSearch } from "../Attendees/ResourceSearch";
import { ResourceIcon } from "../Attendees/ResourceIcon";
useTheme
} from '@linagora/twake-mui'
import CloseIcon from '@mui/icons-material/Close'
import { useRef, useState } from 'react'
import { useI18n } from 'twake-i18n'
import { ResponsiveDialog } from '../Dialog'
import { ColorPicker } from './CalendarColorPicker'
import { getAccessiblePair } from '@/utils/getAccessiblePair'
import { defaultColors } from '@/utils/defaultColors'
import { addCalendarResourceAsync } from '@/features/Calendars/api/addCalendarResourceAsync'
import { Resource, ResourceSearch } from '../Attendees/ResourceSearch'
import { ResourceIcon } from '../Attendees/ResourceIcon'
interface CalendarWithOwner {
cal: CalendarData;
owner: Resource;
cal: CalendarData
owner: Resource
}
function CalendarItem({
cal,
onRemove,
onColorChange,
onColorChange
}: {
cal: CalendarWithOwner;
onRemove: () => void;
onColorChange: (color: Record<string, string>) => void;
cal: CalendarWithOwner
onRemove: () => void
onColorChange: (color: Record<string, string>) => void
}) {
const theme = useTheme();
const { t } = useI18n();
const theme = useTheme()
const { t } = useI18n()
return (
<Box
key={cal.cal["dav:name"]}
key={cal.cal['dav:name']}
display="flex"
justifyContent="space-between"
gap={2}
@@ -48,17 +48,17 @@ function CalendarItem({
<Box display="flex" alignItems="center" gap={1}>
<ResourceIcon avatarUrl={cal.owner.avatarUrl} />
<Typography variant="body1">
{renameDefault(cal.cal["dav:name"], cal.owner.displayName, t, false)}
{renameDefault(cal.cal['dav:name'], cal.owner.displayName, t, false)}
</Typography>
</Box>
<Box display="flex" alignItems="center" gap={1}>
<ColorPicker
selectedColor={{
light: cal.cal["apple:color"] ?? defaultColors[0].light,
dark: cal.cal["apple:color"]
? getAccessiblePair(cal.cal["apple:color"], theme)
: defaultColors[0].dark,
light: cal.cal['apple:color'] ?? defaultColors[0].light,
dark: cal.cal['apple:color']
? getAccessiblePair(cal.cal['apple:color'], theme)
: defaultColors[0].dark
}}
onChange={onColorChange}
/>
@@ -67,33 +67,30 @@ function CalendarItem({
</IconButton>
</Box>
</Box>
);
)
}
function SelectedCalendarsList({
calendars,
selectedCal,
onRemove,
onColorChange,
onColorChange
}: {
calendars: Record<string, Calendar>;
selectedCal: CalendarWithOwner[];
onRemove: (cal: CalendarWithOwner) => void;
onColorChange: (
cal: CalendarWithOwner,
color: Record<string, string>
) => void;
calendars: Record<string, Calendar>
selectedCal: CalendarWithOwner[]
onRemove: (cal: CalendarWithOwner) => void
onColorChange: (cal: CalendarWithOwner, color: Record<string, string>) => void
}) {
const { t } = useI18n();
if (selectedCal.length === 0) return null;
const { t } = useI18n()
if (selectedCal.length === 0) return null
const groupedByOwner = selectedCal.reduce<
Record<
string,
{
owner: Resource;
visibleCals: CalendarWithOwner[];
alreadyExisting: boolean;
owner: Resource
visibleCals: CalendarWithOwner[]
alreadyExisting: boolean
}
>
>((acc, cal) => {
@@ -101,106 +98,106 @@ function SelectedCalendarsList({
(existing: Calendar) =>
existing.id ===
cal.cal?._links?.self?.href
?.replace("/calendars/", "")
.replace(".json", "")
);
?.replace('/calendars/', '')
.replace('.json', '')
)
if (!acc[cal.owner.displayName]) {
acc[cal.owner.displayName] = {
owner: cal.owner,
visibleCals: [],
alreadyExisting: false,
};
alreadyExisting: false
}
}
if (exists) {
acc[cal.owner.displayName].alreadyExisting = true;
acc[cal.owner.displayName].alreadyExisting = true
} else {
acc[cal.owner.displayName].visibleCals.push(cal);
acc[cal.owner.displayName].visibleCals.push(cal)
}
return acc;
}, {});
return acc
}, {})
return (
<Box mt={2}>
<Typography variant="h6" sx={{ margin: 0 }}>
{t("common.resource")}
{t('common.resource')}
</Typography>
{Object.values(groupedByOwner).map(
({ owner, visibleCals, alreadyExisting }) => (
<Box key={owner.displayName} mb={2}>
{visibleCals.length > 0 ? (
visibleCals.map((cal) =>
visibleCals.map(cal =>
cal.cal ? (
<CalendarItem
key={cal.owner.displayName + cal.cal["dav:name"]}
key={cal.owner.displayName + cal.cal['dav:name']}
cal={cal}
onRemove={() => onRemove(cal)}
onColorChange={(color) => onColorChange(cal, color)}
onColorChange={color => onColorChange(cal, color)}
/>
) : (
<Typography
key={t("calendar.noPublicCalendarsFor", {
name: owner.displayName,
key={t('calendar.noPublicCalendarsFor', {
name: owner.displayName
})}
color="textSecondary"
>
{t("calendar.noPublicCalendarsFor", {
name: owner.displayName,
{t('calendar.noPublicCalendarsFor', {
name: owner.displayName
})}
</Typography>
)
)
) : alreadyExisting ? (
<Typography
key={t("calendar.noMoreCalendarsFor", {
name: owner.displayName,
key={t('calendar.noMoreCalendarsFor', {
name: owner.displayName
})}
color="textSecondary"
>
{t("calendar.noMoreCalendarsFor", { name: owner.displayName })}
{t('calendar.noMoreCalendarsFor', { name: owner.displayName })}
</Typography>
) : null}
</Box>
)
)}
</Box>
);
)
}
export default function CalendarResources({
open,
onClose,
onClose
}: {
open: boolean;
onClose: (ids?: string[]) => void;
open: boolean
onClose: (ids?: string[]) => void
}) {
const dispatch = useAppDispatch();
const theme = useTheme();
const dispatch = useAppDispatch()
const theme = useTheme()
const openpaasId =
useAppSelector((state) => state.user.userData?.openpaasId) ?? "";
const calendars = useAppSelector((state) => state.calendars.list);
useAppSelector(state => state.user.userData?.openpaasId) ?? ''
const calendars = useAppSelector(state => state.calendars.list)
const [selectedCal, setSelectedCalendars] = useState<CalendarWithOwner[]>([]);
const [selectedResources, setSelectedResources] = useState<Resource[]>([]);
const [selectedCal, setSelectedCalendars] = useState<CalendarWithOwner[]>([])
const [selectedResources, setSelectedResources] = useState<Resource[]>([])
const fetchSeqRef = useRef(0);
const fetchSeqRef = useRef(0)
const handleSave = async () => {
if (selectedCal.length > 0) {
const results = await Promise.allSettled(
selectedCal.map(async (cal) => {
const calId = crypto.randomUUID();
selectedCal.map(async cal => {
const calId = crypto.randomUUID()
const exists = Object.values(calendars).some(
(existing: Calendar) =>
existing.id ===
cal.cal?._links?.self?.href
?.replace("/calendars/", "")
.replace(".json", "")
);
?.replace('/calendars/', '')
.replace('.json', '')
)
if (!exists && cal.cal) {
await dispatch(
addCalendarResourceAsync({
@@ -208,134 +205,134 @@ export default function CalendarResources({
calId,
cal: {
...cal,
color: cal.cal["apple:color"]
color: cal.cal['apple:color']
? {
light: cal.cal["apple:color"],
dark: getAccessiblePair(cal.cal["apple:color"], theme),
light: cal.cal['apple:color'],
dark: getAccessiblePair(cal.cal['apple:color'], theme)
}
: defaultColors[0],
},
: defaultColors[0]
}
})
).unwrap();
).unwrap()
return cal.cal._links.self?.href
?.replace("/calendars/", "")
.replace(".json", "");
?.replace('/calendars/', '')
.replace('.json', '')
}
return null;
return null
})
);
)
const idList = results
.filter((r) => r.status === "fulfilled")
.map((r) => (r as PromiseFulfilledResult<string | null>).value)
.filter(Boolean) as string[];
.filter(r => r.status === 'fulfilled')
.map(r => (r as PromiseFulfilledResult<string | null>).value)
.filter(Boolean) as string[]
onClose(idList);
onClose(idList)
} else {
onClose();
onClose()
}
setSelectedCalendars([]);
setSelectedResources([]);
};
const { t } = useI18n();
setSelectedCalendars([])
setSelectedResources([])
}
const { t } = useI18n()
const handleClose = () => {
fetchSeqRef.current += 1; // invalidate in-flight fetch results
onClose();
setSelectedCalendars([]);
setSelectedResources([]);
};
fetchSeqRef.current += 1 // invalidate in-flight fetch results
onClose()
setSelectedCalendars([])
setSelectedResources([])
}
return (
<ResponsiveDialog
open={open}
contentSx={{ paddingTop: "8px !important" }}
contentSx={{ paddingTop: '8px !important' }}
onClose={handleClose}
title={t("calendar.browseResources")}
title={t('calendar.browseResources')}
actions={
<>
<Button variant="outlined" onClick={handleClose}>
{t("common.cancel")}
{t('common.cancel')}
</Button>
<Button variant="contained" onClick={handleSave}>
{t("actions.add")}
{t('actions.add')}
</Button>
</>
}
>
<ResourceSearch
objectTypes={["resource"]}
objectTypes={['resource']}
selectedResources={selectedResources}
inputSlot={(params) => <TextField {...params} size="small" />}
inputSlot={params => <TextField {...params} size="small" />}
onChange={async (_event: React.SyntheticEvent, value: Resource[]) => {
const requestSeq = ++fetchSeqRef.current;
setSelectedResources(value);
const requestSeq = ++fetchSeqRef.current
setSelectedResources(value)
const results = await Promise.allSettled(
value.map(async (user: Resource) => {
if (user?.openpaasId) {
const cals = await getCalendars(
user.openpaasId,
"sharedPublic=true&"
);
return cals._embedded?.["dav:calendar"]
? cals._embedded["dav:calendar"].map((cal) => ({
'sharedPublic=true&'
)
return cals._embedded?.['dav:calendar']
? cals._embedded['dav:calendar'].map(cal => ({
cal,
owner: user,
owner: user
}))
: [{ cal: undefined, owner: user }];
: [{ cal: undefined, owner: user }]
}
return null;
return null
})
);
)
const successfulCals = results
.filter((result) => result.status === "fulfilled")
.filter(result => result.status === 'fulfilled')
.map(
(result) =>
result =>
(result as PromiseFulfilledResult<CalendarWithOwner[]>).value
)
.flat()
.filter(Boolean);
.filter(Boolean)
if (requestSeq !== fetchSeqRef.current) return;
setSelectedCalendars(successfulCals as CalendarWithOwner[]);
if (requestSeq !== fetchSeqRef.current) return
setSelectedCalendars(successfulCals)
}}
/>
<SelectedCalendarsList
calendars={calendars}
selectedCal={selectedCal}
onRemove={(cal) => {
if (!cal.cal?._links?.self?.href) return;
setSelectedCalendars((prev) =>
onRemove={cal => {
if (!cal.cal?._links?.self?.href) return
setSelectedCalendars(prev =>
prev.filter(
(c) => c.cal?._links?.self?.href !== cal.cal._links.self?.href
c => c.cal?._links?.self?.href !== cal.cal._links.self?.href
)
);
)
if (
!selectedCal.find(
(c) =>
c =>
cal.owner.displayName === c.owner.displayName &&
c.cal?._links?.self?.href !== cal.cal._links.self?.href
)
) {
setSelectedResources((prev) =>
prev.filter((u) => u.displayName !== cal.owner.displayName)
);
setSelectedResources(prev =>
prev.filter(u => u.displayName !== cal.owner.displayName)
)
}
}}
onColorChange={(cal, color) =>
setSelectedCalendars((prev) =>
prev.map((prevcal) =>
setSelectedCalendars(prev =>
prev.map(prevcal =>
prevcal.owner.displayName === cal.owner.displayName &&
prevcal.cal._links.self?.href === cal.cal._links.self?.href
? {
...prevcal,
cal: {
...prevcal.cal,
"apple:color": color.light,
},
'apple:color': color.light
}
}
: prevcal
)
@@ -343,5 +340,5 @@ export default function CalendarResources({
}
/>
</ResponsiveDialog>
);
)
}
+133 -136
View File
@@ -1,9 +1,9 @@
import { useAppDispatch, useAppSelector } from "@/app/hooks";
import { getCalendars } from "@/features/Calendars/CalendarApi";
import { Calendar } from "@/features/Calendars/CalendarTypes";
import { addSharedCalendarAsync } from "@/features/Calendars/services";
import { CalendarData } from "@/features/Calendars/types/CalendarData";
import { renameDefault } from "@/utils/renameDefault";
import { useAppDispatch, useAppSelector } from '@/app/hooks'
import { getCalendars } from '@/features/Calendars/CalendarApi'
import { Calendar } from '@/features/Calendars/CalendarTypes'
import { addSharedCalendarAsync } from '@/features/Calendars/services'
import { CalendarData } from '@/features/Calendars/types/CalendarData'
import { renameDefault } from '@/utils/renameDefault'
import {
Avatar,
Box,
@@ -11,61 +11,61 @@ import {
IconButton,
TextField,
Typography,
useTheme,
} from "@linagora/twake-mui";
import CloseIcon from "@mui/icons-material/Close";
import { useState } from "react";
import { useI18n } from "twake-i18n";
import { PeopleSearch, User } from "../Attendees/PeopleSearch";
import { ResponsiveDialog } from "../Dialog";
import { stringAvatar } from "../Event/utils/eventUtils";
import { ColorPicker } from "./CalendarColorPicker";
import { getAccessiblePair } from "@/utils/getAccessiblePair";
import { defaultColors } from "@/utils/defaultColors";
useTheme
} from '@linagora/twake-mui'
import CloseIcon from '@mui/icons-material/Close'
import { useState } from 'react'
import { useI18n } from 'twake-i18n'
import { PeopleSearch, User } from '../Attendees/PeopleSearch'
import { ResponsiveDialog } from '../Dialog'
import { stringAvatar } from '../Event/utils/eventUtils'
import { ColorPicker } from './CalendarColorPicker'
import { getAccessiblePair } from '@/utils/getAccessiblePair'
import { defaultColors } from '@/utils/defaultColors'
interface CalendarWithOwner {
cal: CalendarData;
owner: User;
cal: CalendarData
owner: User
}
function CalendarItem({
cal,
onRemove,
onColorChange,
onColorChange
}: {
cal: CalendarWithOwner;
onRemove: () => void;
onColorChange: (color: Record<string, string>) => void;
cal: CalendarWithOwner
onRemove: () => void
onColorChange: (color: Record<string, string>) => void
}) {
const theme = useTheme();
const { t } = useI18n();
const theme = useTheme()
const { t } = useI18n()
return (
<Box
key={cal.owner.email + cal.cal["dav:name"]}
key={cal.owner.email + cal.cal['dav:name']}
display="flex"
flexDirection="column"
alignItems="flex-start"
style={{
borderRadius: 8,
border: "1px solid #e5e7eb",
border: '1px solid #e5e7eb',
padding: 8,
marginBottom: 8,
marginBottom: 8
}}
>
<Box display="flex" alignItems="center" gap={2} mb={1}>
<Avatar
{...stringAvatar(cal.owner.displayName || cal.owner.email)}
style={{
border: `2px solid ${cal.cal["apple:color"] || defaultColors[0].light}`,
boxShadow: cal.cal["apple:color"]
? `0 0 0 2px ${cal.cal["apple:color"]}`
: `0 0 0 2px ${defaultColors[0].light}`,
border: `2px solid ${cal.cal['apple:color'] || defaultColors[0].light}`,
boxShadow: cal.cal['apple:color']
? `0 0 0 2px ${cal.cal['apple:color']}`
: `0 0 0 2px ${defaultColors[0].light}`
}}
/>
<Box>
<Typography variant="body1">
{renameDefault(
cal.cal["dav:name"],
cal.cal['dav:name'],
cal.owner.displayName,
t,
false
@@ -80,10 +80,10 @@ function CalendarItem({
<Box display="flex" alignItems="center" gap={1}>
<ColorPicker
selectedColor={{
light: cal.cal["apple:color"] ?? defaultColors[0].light,
dark: cal.cal["apple:color"]
? getAccessiblePair(cal.cal["apple:color"], theme)
: defaultColors[0].dark,
light: cal.cal['apple:color'] ?? defaultColors[0].light,
dark: cal.cal['apple:color']
? getAccessiblePair(cal.cal['apple:color'], theme)
: defaultColors[0].dark
}}
onChange={onColorChange}
/>
@@ -92,33 +92,30 @@ function CalendarItem({
</IconButton>
</Box>
</Box>
);
)
}
function SelectedCalendarsList({
calendars,
selectedCal,
onRemove,
onColorChange,
onColorChange
}: {
calendars: Record<string, Calendar>;
selectedCal: CalendarWithOwner[];
onRemove: (cal: CalendarWithOwner) => void;
onColorChange: (
cal: CalendarWithOwner,
color: Record<string, string>
) => void;
calendars: Record<string, Calendar>
selectedCal: CalendarWithOwner[]
onRemove: (cal: CalendarWithOwner) => void
onColorChange: (cal: CalendarWithOwner, color: Record<string, string>) => void
}) {
const { t } = useI18n();
if (selectedCal.length === 0) return null;
const { t } = useI18n()
if (selectedCal.length === 0) return null
const groupedByOwner = selectedCal.reduce<
Record<
string,
{
owner: User;
visibleCals: CalendarWithOwner[];
alreadyExisting: boolean;
owner: User
visibleCals: CalendarWithOwner[]
alreadyExisting: boolean
}
>
>((acc, cal) => {
@@ -126,107 +123,107 @@ function SelectedCalendarsList({
(existing: Calendar) =>
existing.id ===
cal.cal?._links?.self?.href
?.replace("/calendars/", "")
.replace(".json", "")
);
?.replace('/calendars/', '')
.replace('.json', '')
)
if (!acc[cal.owner.email]) {
acc[cal.owner.email] = {
owner: cal.owner,
visibleCals: [],
alreadyExisting: false,
};
alreadyExisting: false
}
}
if (exists) {
acc[cal.owner.email].alreadyExisting = true;
acc[cal.owner.email].alreadyExisting = true
} else {
acc[cal.owner.email].visibleCals.push(cal);
acc[cal.owner.email].visibleCals.push(cal)
}
return acc;
}, {});
return acc
}, {})
return (
<Box mt={2}>
<Typography variant="subtitle1" gutterBottom>
{t("common.name")}
{t('common.name')}
</Typography>
{Object.values(groupedByOwner).map(
({ owner, visibleCals, alreadyExisting }) => (
<Box key={owner.email} mb={2}>
{visibleCals.length > 0 ? (
visibleCals.map((cal) =>
visibleCals.map(cal =>
cal.cal ? (
<CalendarItem
key={cal.owner.email + cal.cal["dav:name"]}
key={cal.owner.email + cal.cal['dav:name']}
cal={cal}
onRemove={() => onRemove(cal)}
onColorChange={(color) => onColorChange(cal, color)}
onColorChange={color => onColorChange(cal, color)}
/>
) : (
<Typography
key={t("calendar.noPublicCalendarsFor", {
name: owner.displayName,
key={t('calendar.noPublicCalendarsFor', {
name: owner.displayName
})}
color="textSecondary"
>
{t("calendar.noPublicCalendarsFor", {
name: owner.displayName,
{t('calendar.noPublicCalendarsFor', {
name: owner.displayName
})}
</Typography>
)
)
) : alreadyExisting ? (
<Typography
key={t("calendar.noMoreCalendarsFor", {
name: owner.displayName,
key={t('calendar.noMoreCalendarsFor', {
name: owner.displayName
})}
color="textSecondary"
>
{t("calendar.noMoreCalendarsFor", { name: owner.displayName })}
{t('calendar.noMoreCalendarsFor', { name: owner.displayName })}
</Typography>
) : null}
</Box>
)
)}
</Box>
);
)
}
export default function CalendarSearch({
open,
onClose,
onClose
}: {
open: boolean;
open: boolean
onClose: (
result?: string[] | Record<string, never>,
reason?: "backdropClick" | "escapeKeyDown"
) => void;
reason?: 'backdropClick' | 'escapeKeyDown'
) => void
}) {
const dispatch = useAppDispatch();
const theme = useTheme();
const dispatch = useAppDispatch()
const theme = useTheme()
const openpaasId =
useAppSelector((state) => state.user.userData?.openpaasId) ?? "";
const calendars = useAppSelector((state) => state.calendars.list);
useAppSelector(state => state.user.userData?.openpaasId) ?? ''
const calendars = useAppSelector(state => state.calendars.list)
const [selectedCal, setSelectedCalendars] = useState<CalendarWithOwner[]>([]);
const [selectedUsers, setSelectedUsers] = useState<User[]>([]);
const [selectedCal, setSelectedCalendars] = useState<CalendarWithOwner[]>([])
const [selectedUsers, setSelectedUsers] = useState<User[]>([])
const handleSave = async () => {
if (selectedCal.length > 0) {
const idList = await Promise.all(
selectedCal.map(async (cal) => {
const calId = crypto.randomUUID();
selectedCal.map(async cal => {
const calId = crypto.randomUUID()
const exists = Object.values(calendars).some(
(existing: Calendar) =>
existing.id ===
cal.cal?._links?.self?.href
?.replace("/calendars/", "")
.replace(".json", "")
);
?.replace('/calendars/', '')
.replace('.json', '')
)
if (!exists && cal.cal) {
await dispatch(
addSharedCalendarAsync({
@@ -234,117 +231,117 @@ export default function CalendarSearch({
calId,
cal: {
...cal,
color: cal.cal["apple:color"]
color: cal.cal['apple:color']
? {
light: cal.cal["apple:color"],
dark: getAccessiblePair(cal.cal["apple:color"], theme),
light: cal.cal['apple:color'],
dark: getAccessiblePair(cal.cal['apple:color'], theme)
}
: defaultColors[0],
},
: defaultColors[0]
}
})
);
)
return cal.cal._links.self?.href
?.replace("/calendars/", "")
.replace(".json", "");
?.replace('/calendars/', '')
.replace('.json', '')
}
return null;
return null
})
);
)
onClose(idList.filter(Boolean) as string[]);
setSelectedCalendars([]);
setSelectedUsers([]);
onClose(idList.filter(Boolean) as string[])
setSelectedCalendars([])
setSelectedUsers([])
}
};
const { t } = useI18n();
}
const { t } = useI18n()
return (
<ResponsiveDialog
open={open}
contentSx={{ paddingTop: "8px !important" }}
contentSx={{ paddingTop: '8px !important' }}
onClose={() => {
onClose({}, "backdropClick");
setSelectedCalendars([]);
setSelectedUsers([]);
onClose({}, 'backdropClick')
setSelectedCalendars([])
setSelectedUsers([])
}}
title={t("calendar.browseOtherCalendars")}
title={t('calendar.browseOtherCalendars')}
actions={
<>
<Button
variant="outlined"
onClick={() => onClose({}, "backdropClick")}
onClick={() => onClose({}, 'backdropClick')}
>
{t("common.cancel")}
{t('common.cancel')}
</Button>
<Button variant="contained" onClick={handleSave}>
{t("actions.add")}
{t('actions.add')}
</Button>
</>
}
>
<PeopleSearch
objectTypes={["user"]}
objectTypes={['user']}
selectedUsers={selectedUsers}
inputSlot={(params) => <TextField {...params} size="small" />}
inputSlot={params => <TextField {...params} size="small" />}
onChange={async (_event: React.SyntheticEvent, value: User[]) => {
setSelectedUsers(value);
setSelectedUsers(value)
const cals = await Promise.all(
value.map(async (user: User) => {
if (user?.openpaasId) {
const cals = await getCalendars(
user.openpaasId,
"sharedPublic=true&"
);
return cals._embedded?.["dav:calendar"]
? cals._embedded["dav:calendar"].map((cal) => ({
'sharedPublic=true&'
)
return cals._embedded?.['dav:calendar']
? cals._embedded['dav:calendar'].map(cal => ({
cal,
owner: user,
owner: user
}))
: { cal: undefined, owner: user };
: { cal: undefined, owner: user }
}
return null;
return null
})
);
)
setSelectedCalendars(
cals.flat().filter(Boolean) as CalendarWithOwner[]
);
)
}}
/>
<SelectedCalendarsList
calendars={calendars}
selectedCal={selectedCal}
onRemove={(cal) => {
if (!cal.cal?._links?.self?.href) return;
setSelectedCalendars((prev) =>
onRemove={cal => {
if (!cal.cal?._links?.self?.href) return
setSelectedCalendars(prev =>
prev.filter(
(c) => c.cal?._links?.self?.href !== cal.cal._links.self?.href
c => c.cal?._links?.self?.href !== cal.cal._links.self?.href
)
);
)
if (
!selectedCal.find(
(c) =>
c =>
cal.owner.email === c.owner.email &&
c.cal?._links?.self?.href !== cal.cal._links.self?.href
)
) {
setSelectedUsers((prev) =>
prev.filter((u) => u.email !== cal.owner.email)
);
setSelectedUsers(prev =>
prev.filter(u => u.email !== cal.owner.email)
)
}
}}
onColorChange={(cal, color) =>
setSelectedCalendars((prev) =>
prev.map((prevcal) =>
setSelectedCalendars(prev =>
prev.map(prevcal =>
prevcal.owner.email === cal.owner.email &&
prevcal.cal._links.self?.href === cal.cal._links.self?.href
? {
...prevcal,
cal: {
...prevcal.cal,
"apple:color": color.light,
},
'apple:color': color.light
}
}
: prevcal
)
@@ -352,5 +349,5 @@ export default function CalendarSearch({
}
/>
</ResponsiveDialog>
);
)
}
+160 -156
View File
@@ -1,10 +1,10 @@
import { useAppDispatch, useAppSelector } from "@/app/hooks";
import { Calendar } from "@/features/Calendars/CalendarTypes";
import { removeCalendarAsync } from "@/features/Calendars/services";
import { extractEventBaseUuid } from "@/utils/extractEventBaseUuid";
import { makeDisplayName } from "@/utils/makeDisplayName";
import { renameDefault } from "@/utils/renameDefault";
import { trimLongTextWithoutSpace } from "@/utils/textUtils";
import { useAppDispatch, useAppSelector } from '@/app/hooks'
import { Calendar } from '@/features/Calendars/CalendarTypes'
import { removeCalendarAsync } from '@/features/Calendars/services'
import { extractEventBaseUuid } from '@/utils/extractEventBaseUuid'
import { makeDisplayName } from '@/utils/makeDisplayName'
import { renameDefault } from '@/utils/renameDefault'
import { trimLongTextWithoutSpace } from '@/utils/textUtils'
import {
Accordion,
AccordionDetails,
@@ -14,18 +14,18 @@ import {
IconButton,
ListItem,
Menu,
MenuItem,
} from "@linagora/twake-mui";
import AddIcon from "@mui/icons-material/Add";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import MoreHorizIcon from "@mui/icons-material/MoreHoriz";
import { SetStateAction, useEffect, useMemo, useState } from "react";
import { useI18n } from "twake-i18n";
import CalendarPopover from "./CalendarModal";
import CalendarSearch from "./CalendarSearch";
import { DeleteCalendarDialog } from "./DeleteCalendarDialog";
import { OwnerCaption } from "./OwnerCaption";
import CalendarResources from "./CalendarResources";
MenuItem
} from '@linagora/twake-mui'
import AddIcon from '@mui/icons-material/Add'
import ExpandMoreIcon from '@mui/icons-material/ExpandMore'
import MoreHorizIcon from '@mui/icons-material/MoreHoriz'
import { SetStateAction, useEffect, useMemo, useState } from 'react'
import { useI18n } from 'twake-i18n'
import CalendarPopover from './CalendarModal'
import CalendarSearch from './CalendarSearch'
import { DeleteCalendarDialog } from './DeleteCalendarDialog'
import { OwnerCaption } from './OwnerCaption'
import CalendarResources from './CalendarResources'
function CalendarAccordion({
title,
@@ -36,40 +36,46 @@ function CalendarAccordion({
onAddClick,
defaultExpanded = false,
setOpen,
hideOwner,
hideOwner
}: {
title: string;
calendars: string[];
selectedCalendars: string[];
handleToggle: (id: string) => void;
showAddButton?: boolean;
onAddClick?: () => void;
defaultExpanded?: boolean;
setOpen: (id: string) => void;
hideOwner?: boolean;
title: string
calendars: string[]
selectedCalendars: string[]
handleToggle: (id: string) => void
showAddButton?: boolean
onAddClick?: () => void
defaultExpanded?: boolean
setOpen: (id: string) => void
hideOwner?: boolean
}) {
const allCalendars = useAppSelector((state) => state.calendars.list);
const { t } = useI18n();
const allCalendars = useAppSelector(state => state.calendars.list)
const { t } = useI18n()
const [expended, setExpended] = useState(defaultExpanded);
useEffect(() => setExpended(defaultExpanded), [defaultExpanded]);
const [expended, setExpended] = useState(defaultExpanded)
if (calendars.length === 0 && !showAddButton) return null;
useEffect(() => {
const handleExpendedChange = () => {
setExpended(defaultExpanded)
}
handleExpendedChange()
}, [defaultExpanded])
if (calendars.length === 0 && !showAddButton) return null
return (
<Accordion
defaultExpanded={defaultExpanded}
expanded={expended}
style={{
width: "100%",
width: '100%',
padding: 0,
margin: 0,
marginBottom: "12px",
boxShadow: "none",
marginBottom: '12px',
boxShadow: 'none'
}}
sx={{
"&::before": {
display: "none",
},
'&::before': {
display: 'none'
}
}}
>
<AccordionSummary
@@ -79,23 +85,23 @@ function CalendarAccordion({
className="calendarListHeader"
onClick={() => setExpended(!expended)}
sx={{
"& .MuiAccordionSummary-content": {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
},
'& .MuiAccordionSummary-content': {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between'
}
}}
>
<span>{title}</span>
{showAddButton && (
<IconButton
component="span"
onClick={(e) => {
onClick={e => {
if (expended) {
e.stopPropagation();
e.stopPropagation()
}
if (onAddClick) {
onAddClick();
onAddClick()
}
}}
>
@@ -103,13 +109,13 @@ function CalendarAccordion({
</IconButton>
)}
</AccordionSummary>
<AccordionDetails style={{ textAlign: "left", padding: 0 }}>
{calendars.map((id) => (
<AccordionDetails style={{ textAlign: 'left', padding: 0 }}>
{calendars.map(id => (
<CalendarSelector
key={id}
calendars={allCalendars}
id={id}
isPersonal={title === t("calendar.personal")}
isPersonal={title === t('calendar.personal')}
selectedCalendars={selectedCalendars}
handleCalendarToggle={handleToggle}
setOpen={() => setOpen(id)}
@@ -118,111 +124,110 @@ function CalendarAccordion({
))}
</AccordionDetails>
</Accordion>
);
)
}
export default function CalendarSelection({
selectedCalendars,
setSelectedCalendars,
setSelectedCalendars
}: {
selectedCalendars: string[];
setSelectedCalendars: (value: SetStateAction<string[]>) => void;
selectedCalendars: string[]
setSelectedCalendars: (value: SetStateAction<string[]>) => void
}) {
const { t } = useI18n();
const userId =
useAppSelector((state) => state.user.userData?.openpaasId) ?? "";
const calendars = useAppSelector((state) => state.calendars.list);
const { t } = useI18n()
const userId = useAppSelector(state => state.user.userData?.openpaasId) ?? ''
const calendars = useAppSelector(state => state.calendars.list)
const personalCalendars = Object.keys(calendars || {}).filter(
(id) => extractEventBaseUuid(id) === userId
);
id => extractEventBaseUuid(id) === userId
)
const delegatedCalendars = Object.keys(calendars || {}).filter(
(id) =>
id =>
extractEventBaseUuid(id) !== userId &&
calendars[id]?.delegated &&
!calendars?.[id]?.owner?.resource
);
)
const sharedCalendars = Object.keys(calendars || {}).filter(
(id) =>
id =>
extractEventBaseUuid(id) !== userId &&
!calendars?.[id]?.delegated &&
!calendars?.[id]?.owner?.resource
);
)
const resourceCalendars = Object.keys(calendars || {}).filter(
(id) =>
id =>
extractEventBaseUuid(id) !== userId && calendars?.[id]?.owner?.resource
);
)
const handleCalendarToggle = (name: string) => {
setSelectedCalendars((prev: string[]) =>
prev.includes(name) ? prev.filter((n) => n !== name) : [...prev, name]
);
};
const [selectedCalId, setSelectedCalId] = useState("");
prev.includes(name) ? prev.filter(n => n !== name) : [...prev, name]
)
}
const [selectedCalId, setSelectedCalId] = useState('')
const [anchorElCal, setAnchorElCal] = useState<HTMLElement | null>(null);
const [anchorElCal, setAnchorElCal] = useState<HTMLElement | null>(null)
const [anchorElCalOthers, setAnchorElCalOthers] =
useState<HTMLElement | null>(null);
useState<HTMLElement | null>(null)
const [anchorElCalResources, setAnchorElCalResources] =
useState<HTMLElement | null>(null);
useState<HTMLElement | null>(null)
return (
<>
<div>
<CalendarAccordion
title={t("calendar.personal")}
title={t('calendar.personal')}
calendars={personalCalendars}
selectedCalendars={selectedCalendars}
handleToggle={handleCalendarToggle}
showAddButton
onAddClick={() => setAnchorElCal(document.body)}
setOpen={(id: string) => {
setAnchorElCal(document.body);
setSelectedCalId(id);
setAnchorElCal(document.body)
setSelectedCalId(id)
}}
defaultExpanded
/>
<CalendarAccordion
title={t("calendar.delegated")}
title={t('calendar.delegated')}
calendars={delegatedCalendars}
selectedCalendars={selectedCalendars}
handleToggle={handleCalendarToggle}
setOpen={(id: string) => {
setAnchorElCal(document.body);
setSelectedCalId(id);
setAnchorElCal(document.body)
setSelectedCalId(id)
}}
defaultExpanded
/>
<CalendarAccordion
title={t("calendar.other")}
title={t('calendar.other')}
calendars={sharedCalendars}
selectedCalendars={selectedCalendars}
showAddButton
onAddClick={() => {
setAnchorElCalOthers(document.body);
setAnchorElCalOthers(document.body)
}}
handleToggle={handleCalendarToggle}
setOpen={(id: string) => {
setAnchorElCal(document.body);
setSelectedCalId(id);
setAnchorElCal(document.body)
setSelectedCalId(id)
}}
defaultExpanded
/>
<CalendarAccordion
title={t("calendar.resources")}
title={t('calendar.resources')}
calendars={resourceCalendars}
selectedCalendars={selectedCalendars}
onAddClick={() => {
setAnchorElCalResources(document.body);
setAnchorElCalResources(document.body)
}}
showAddButton
handleToggle={handleCalendarToggle}
setOpen={(id: string) => {
setAnchorElCal(document.body);
setSelectedCalId(id);
setAnchorElCal(document.body)
setSelectedCalId(id)
}}
defaultExpanded
hideOwner={true}
@@ -232,32 +237,32 @@ export default function CalendarSelection({
open={Boolean(anchorElCal)}
calendar={calendars?.[selectedCalId] ?? undefined}
onClose={() => {
setSelectedCalId("");
setAnchorElCal(null);
setSelectedCalId('')
setAnchorElCal(null)
}}
/>
<CalendarSearch
open={Boolean(anchorElCalOthers)}
onClose={(newCalIds?: string[]) => {
setAnchorElCalOthers(null);
setAnchorElCalOthers(null)
if (newCalIds?.length) {
newCalIds.forEach((id) => handleCalendarToggle(id));
newCalIds.forEach(id => handleCalendarToggle(id))
}
}}
/>
<CalendarResources
open={Boolean(anchorElCalResources)}
onClose={(newResourceIds?: string[]) => {
setAnchorElCalResources(null);
setAnchorElCalResources(null)
if (newResourceIds?.length) {
newResourceIds.forEach((id) => {
handleCalendarToggle(id);
});
newResourceIds.forEach(id => {
handleCalendarToggle(id)
})
}
}}
/>
</>
);
)
}
function CalendarSelector({
@@ -267,113 +272,112 @@ function CalendarSelector({
selectedCalendars,
handleCalendarToggle,
setOpen,
hideOwner,
hideOwner
}: {
calendars: Record<string, Calendar>;
id: string;
isPersonal: boolean;
selectedCalendars: string[];
handleCalendarToggle: (name: string) => void;
setOpen: () => void;
hideOwner?: boolean;
calendars: Record<string, Calendar>
id: string
isPersonal: boolean
selectedCalendars: string[]
handleCalendarToggle: (name: string) => void
setOpen: () => void
hideOwner?: boolean
}) {
const { t } = useI18n();
const dispatch = useAppDispatch();
const calLink =
useAppSelector((state) => state.calendars.list[id].link) ?? "";
const { t } = useI18n()
const dispatch = useAppDispatch()
const calLink = useAppSelector(state => state.calendars.list[id].link) ?? ''
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const open = Boolean(anchorEl);
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null)
const open = Boolean(anchorEl)
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
setAnchorEl(event.currentTarget);
};
setAnchorEl(event.currentTarget)
}
const handleClose = () => {
setAnchorEl(null);
};
const [userId, calId] = id.split("/");
const isDefault = isPersonal && userId === calId;
setAnchorEl(null)
}
const [userId, calId] = id.split('/')
const isDefault = isPersonal && userId === calId
const [deletePopupOpen, setDeletePopupOpen] = useState(false);
const [deletePopupOpen, setDeletePopupOpen] = useState(false)
const handleDeleteConfirm = () => {
dispatch(removeCalendarAsync({ calId: id, calLink }));
setDeletePopupOpen(false);
handleClose();
};
dispatch(removeCalendarAsync({ calId: id, calLink }))
setDeletePopupOpen(false)
handleClose()
}
const trimmedName = useMemo(
() => trimLongTextWithoutSpace(calendars[id].name),
[calendars, id]
);
)
const ownerDisplayName = useMemo(
() => makeDisplayName(calendars[id]),
[calendars, id]
);
)
const displayName = useMemo(
() => renameDefault(trimmedName, ownerDisplayName ?? "", t, isPersonal),
() => renameDefault(trimmedName, ownerDisplayName ?? '', t, isPersonal),
[trimmedName, ownerDisplayName, t, isPersonal]
);
)
const showCaption =
!isPersonal &&
trimmedName !== "#default" &&
trimmedName !== '#default' &&
ownerDisplayName != null &&
!hideOwner;
!hideOwner
return (
<>
<ListItem
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
transition: "background-color 0.2s ease",
"& .MoreBtn": { opacity: 0 },
"&:hover": {
backgroundColor: "#F3F3F6",
"& .MoreBtn": { opacity: 1 },
},
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
transition: 'background-color 0.2s ease',
'& .MoreBtn': { opacity: 0 },
'&:hover': {
backgroundColor: '#F3F3F6',
'& .MoreBtn': { opacity: 1 }
}
}}
>
<label
style={{
display: "flex",
alignItems: "center",
maxWidth: "calc(100% - 40px)",
overflow: "hidden",
display: 'flex',
alignItems: 'center',
maxWidth: 'calc(100% - 40px)',
overflow: 'hidden'
}}
>
<Checkbox
sx={{
color: calendars[id].color?.light,
"&.Mui-checked": { color: calendars[id].color?.light },
'&.Mui-checked': { color: calendars[id].color?.light }
}}
size="small"
checked={selectedCalendars.includes(id)}
onChange={() => handleCalendarToggle(id)}
inputProps={{ "aria-label": displayName }}
inputProps={{ 'aria-label': displayName }}
/>
<div
style={{
display: "flex",
flexDirection: "column",
overflow: "hidden",
padding: showCaption ? "6px" : undefined,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
padding: showCaption ? '6px' : undefined
}}
>
<span
style={{
overflow: "hidden",
textOverflow: "ellipsis",
wordBreak: "break-word",
overflow: 'hidden',
textOverflow: 'ellipsis',
wordBreak: 'break-word'
}}
>
{displayName}
</span>
<OwnerCaption
showCaption={showCaption}
ownerDisplayName={ownerDisplayName ?? ""}
ownerDisplayName={ownerDisplayName ?? ''}
/>
</div>
</label>
@@ -384,16 +388,16 @@ function CalendarSelector({
<Menu id={id} anchorEl={anchorEl} open={open} onClose={handleClose}>
<MenuItem
onClick={() => {
setOpen();
handleClose();
setOpen()
handleClose()
}}
>
{t("actions.modify")}
{t('actions.modify')}
</MenuItem>
{!isDefault && <Divider />}
{!isDefault && (
<MenuItem onClick={() => setDeletePopupOpen(!deletePopupOpen)}>
{isPersonal ? t("actions.delete") : t("actions.remove")}
{isPersonal ? t('actions.delete') : t('actions.remove')}
</MenuItem>
)}
</Menu>
@@ -407,5 +411,5 @@ function CalendarSelector({
handleDeleteConfirm={handleDeleteConfirm}
/>
</>
);
)
}
@@ -1,13 +1,13 @@
import { Calendar } from "@/features/Calendars/CalendarTypes";
import { Calendar } from '@/features/Calendars/CalendarTypes'
import {
Button,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
} from "@linagora/twake-mui";
import { useI18n } from "twake-i18n";
DialogTitle
} from '@linagora/twake-mui'
import { useI18n } from 'twake-i18n'
export function DeleteCalendarDialog({
deletePopupOpen,
@@ -15,39 +15,39 @@ export function DeleteCalendarDialog({
calendars,
id,
isPersonal,
handleDeleteConfirm,
handleDeleteConfirm
}: {
deletePopupOpen: boolean;
setDeletePopupOpen: (e: boolean) => void;
calendars: Record<string, Calendar>;
id: string;
isPersonal: boolean;
handleDeleteConfirm: () => void;
deletePopupOpen: boolean
setDeletePopupOpen: (e: boolean) => void
calendars: Record<string, Calendar>
id: string
isPersonal: boolean
handleDeleteConfirm: () => void
}) {
const { t } = useI18n();
const { t } = useI18n()
return (
<Dialog open={deletePopupOpen} onClose={() => setDeletePopupOpen(false)}>
<DialogTitle>
{t("calendar.delete.title", { name: calendars[id].name })}
{t('calendar.delete.title', { name: calendars[id].name })}
</DialogTitle>
<DialogContent>
<DialogContentText>
{isPersonal
? t("calendar.delete.personalWarning")
: t("calendar.delete.sharedWarning")}
? t('calendar.delete.personalWarning')
: t('calendar.delete.sharedWarning')}
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={() => setDeletePopupOpen(false)}>
{t("common.cancel")}
{t('common.cancel')}
</Button>
<Button onClick={handleDeleteConfirm} variant="contained">
{isPersonal ? t("actions.delete") : t("actions.remove")}
{isPersonal ? t('actions.delete') : t('actions.remove')}
</Button>
</DialogActions>
</Dialog>
);
)
}
+55 -55
View File
@@ -1,5 +1,5 @@
import { useAppSelector } from "@/app/hooks";
import { extractEventBaseUuid } from "@/utils/extractEventBaseUuid";
import { useAppSelector } from '@/app/hooks'
import { extractEventBaseUuid } from '@/utils/extractEventBaseUuid'
import {
Box,
Button,
@@ -8,72 +8,72 @@ import {
MenuItem,
Select,
TextField,
Typography,
} from "@linagora/twake-mui";
import { useEffect, useState } from "react";
import { useI18n } from "twake-i18n";
import { CalendarItemList } from "./CalendarItemList";
import { SettingsTab } from "./SettingsTab";
Typography
} from '@linagora/twake-mui'
import { useEffect, useState } from 'react'
import { useI18n } from 'twake-i18n'
import { CalendarItemList } from './CalendarItemList'
import { SettingsTab } from './SettingsTab'
export function ImportTab({
userId,
importTarget,
setImportTarget,
setImportedContent,
newCalParams,
newCalParams
}: {
userId: string;
importTarget: string;
setImportTarget: (target: string) => void;
setImportedContent: (content: File | null) => void;
userId: string
importTarget: string
setImportTarget: (target: string) => void
setImportedContent: (content: File | null) => void
newCalParams: {
name: string;
setName: (name: string) => void;
description: string;
setDescription: (d: string) => void;
color: Record<string, string>;
setColor: (color: Record<string, string>) => void;
visibility: "public" | "private";
setVisibility: (visibility: "public" | "private") => void;
};
name: string
setName: (name: string) => void
description: string
setDescription: (d: string) => void
color: Record<string, string>
setColor: (color: Record<string, string>) => void
visibility: 'public' | 'private'
setVisibility: (visibility: 'public' | 'private') => void
}
}) {
const { t } = useI18n();
const [importMode] = useState<"file" | "url">("file");
const [importFile, setImportFile] = useState<File | null>(null);
const [importUrl, setImportUrl] = useState("");
const calendars = useAppSelector((state) => state.calendars.list);
const { t } = useI18n()
const [importMode] = useState<'file' | 'url'>('file')
const [importFile, setImportFile] = useState<File | null>(null)
const [importUrl, setImportUrl] = useState('')
const calendars = useAppSelector(state => state.calendars.list)
const personalCalendars = Object.values(calendars).filter(
(cal) => extractEventBaseUuid(cal.id) === userId
);
cal => extractEventBaseUuid(cal.id) === userId
)
useEffect(() => {
setImportedContent(importMode === "file" ? importFile : null);
}, [importFile, importUrl, importMode, setImportedContent]);
setImportedContent(importMode === 'file' ? importFile : null)
}, [importFile, importUrl, importMode, setImportedContent])
return (
<>
{/* Form group 1: Select file button - first group, margin top 0 */}
{importMode === "file" && (
{importMode === 'file' && (
<Box mt={0}>
<Button
variant="outlined"
component="label"
size="medium"
sx={{ borderRadius: "12px" }}
sx={{ borderRadius: '12px' }}
>
{t("common.select_file")}
{t('common.select_file')}
<input
type="file"
hidden
accept=".ics"
onChange={(e) => setImportFile(e.target.files?.[0] ?? null)}
onChange={e => setImportFile(e.target.files?.[0] ?? null)}
/>
</Button>
{importFile && (
<Typography
variant="body2"
color="text.secondary"
sx={{ marginTop: "6px" }}
sx={{ marginTop: '6px' }}
>
{importFile.name}
</Typography>
@@ -82,28 +82,28 @@ export function ImportTab({
variant="caption"
color="text.secondary"
display="block"
sx={{ marginTop: "6px" }}
sx={{ marginTop: '6px' }}
>
{t("calendar.import_file_description")}
{t('calendar.import_file_description')}
</Typography>
</Box>
)}
{/* Form group 2: URL field */}
{importMode === "url" && (
{importMode === 'url' && (
<Box mt={0}>
<TextField
fullWidth
label={t("calendar.ics_feed_url")}
label={t('calendar.ics_feed_url')}
value={importUrl}
onChange={(e) => setImportUrl(e.target.value)}
onChange={e => setImportUrl(e.target.value)}
size="small"
margin="dense"
sx={{
"&.MuiFormControl-root.MuiFormControl-marginDense": {
marginTop: "6px",
marginBottom: 0,
},
'&.MuiFormControl-root.MuiFormControl-marginDense': {
marginTop: '6px',
marginBottom: 0
}
}}
/>
</Box>
@@ -116,33 +116,33 @@ export function ImportTab({
size="small"
margin="dense"
sx={{
"&.MuiFormControl-root.MuiFormControl-marginDense": {
marginTop: "6px",
marginBottom: 0,
},
'&.MuiFormControl-root.MuiFormControl-marginDense': {
marginTop: '6px',
marginBottom: 0
}
}}
>
<InputLabel id="import-to-label">
{t("calendar.import_to")}
{t('calendar.import_to')}
</InputLabel>
<Select
labelId="import-to-label"
label={t("calendar.import_to")}
label={t('calendar.import_to')}
value={importTarget}
onChange={(e) => setImportTarget(e.target.value)}
onChange={e => setImportTarget(e.target.value)}
>
<MenuItem value="new">{t("calendar.new_calendar")}</MenuItem>
<MenuItem value="new">{t('calendar.new_calendar')}</MenuItem>
{CalendarItemList(personalCalendars)}
</Select>
</FormControl>
</Box>
{/* Form group 4: SettingsTab (when importing to new calendar) */}
{importTarget === "new" && (
{importTarget === 'new' && (
<Box mt={2}>
<SettingsTab {...newCalParams} />
</Box>
)}
</>
);
)
}
+79 -74
View File
@@ -1,121 +1,126 @@
import { useAppDispatch } from "@/app/hooks";
import { setView } from "@/features/Settings/SettingsSlice";
import { computeStartOfTheWeek } from "@/utils/dateUtils";
import type { CalendarApi } from "@fullcalendar/core";
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
import { DateCalendar } from "@mui/x-date-pickers";
import { AdapterMoment } from "@mui/x-date-pickers/AdapterMoment";
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
import moment from "moment";
import { useEffect, useState } from "react";
import { useI18n } from "twake-i18n";
import { useAppDispatch } from '@/app/hooks'
import { setView } from '@/features/Settings/SettingsSlice'
import { computeStartOfTheWeek } from '@/utils/dateUtils'
import type { CalendarApi } from '@fullcalendar/core'
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'
import { DateCalendar } from '@mui/x-date-pickers'
import { AdapterMoment } from '@mui/x-date-pickers/AdapterMoment'
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'
import moment from 'moment'
import { useEffect, useState } from 'react'
import { useI18n } from 'twake-i18n'
export function MiniCalendar({
calendarRef,
selectedDate,
setSelectedMiniDate,
setSelectedMiniDate
}: {
calendarRef: React.MutableRefObject<CalendarApi | null>;
selectedDate: Date;
setSelectedMiniDate: (d: Date) => void;
calendarRef: React.MutableRefObject<CalendarApi | null>
selectedDate: Date
setSelectedMiniDate: (d: Date) => void
}) {
const dispatch = useAppDispatch();
const [visibleDate, setVisibleDate] = useState(selectedDate);
const { t } = useI18n();
const dispatch = useAppDispatch()
const [visibleDate, setVisibleDate] = useState(selectedDate)
const { t } = useI18n()
useEffect(() => setVisibleDate(selectedDate), [selectedDate]);
useEffect(() => {
const handleVisibleDateChange = () => {
setVisibleDate(selectedDate)
}
handleVisibleDateChange()
}, [selectedDate])
return (
<LocalizationProvider
dateAdapter={AdapterMoment}
adapterLocale={t("locale") ?? "en-gb"}
adapterLocale={t('locale') ?? 'en-gb'}
>
<DateCalendar
value={moment(visibleDate)}
onChange={async (dateMoment, selectionState) => {
if (!dateMoment) return;
const date = dateMoment.toDate();
if (selectionState === "finish") {
await dispatch(setView("calendar"));
setSelectedMiniDate(date);
calendarRef.current?.gotoDate(date);
if (!dateMoment) return
const date = dateMoment.toDate()
if (selectionState === 'finish') {
await dispatch(setView('calendar'))
setSelectedMiniDate(date)
calendarRef.current?.gotoDate(date)
}
}}
showDaysOutsideCurrentMonth
onMonthChange={(month) => {
setVisibleDate(month.toDate());
onMonthChange={month => {
setVisibleDate(month.toDate())
}}
views={["month", "day"]}
views={['month', 'day']}
slots={{
switchViewIcon: KeyboardArrowDownIcon,
switchViewIcon: KeyboardArrowDownIcon
}}
sx={{
width: "100%",
height: "300px",
"& .MuiPickersCalendarHeader-root": {
marginTop: 3,
},
width: '100%',
height: '300px',
'& .MuiPickersCalendarHeader-root': {
marginTop: 3
}
}}
slotProps={{
day: (ownerState) => {
const date = ownerState.day.toDate();
const today = new Date();
today.setHours(0, 0, 0, 0);
const selected = new Date(selectedDate);
selected.setHours(0, 0, 0, 0);
day: ownerState => {
const date = ownerState.day.toDate()
const today = new Date()
today.setHours(0, 0, 0, 0)
const selected = new Date(selectedDate)
selected.setHours(0, 0, 0, 0)
const isToday = date.getTime() === today.getTime();
const isToday = date.getTime() === today.getTime()
const isSelectedDay =
calendarRef.current?.view.type === "timeGridDay" &&
date.getTime() === selected.getTime();
calendarRef.current?.view.type === 'timeGridDay' &&
date.getTime() === selected.getTime()
const isInSelectedWeek =
calendarRef.current?.view.type === "timeGridWeek" ||
calendarRef.current?.view.type === 'timeGridWeek' ||
calendarRef.current?.view.type === undefined
? (() => {
const startOfWeek = computeStartOfTheWeek(selected);
const endOfWeek = new Date(startOfWeek);
endOfWeek.setDate(startOfWeek.getDate() + 6);
endOfWeek.setHours(23, 59, 59, 999);
return date >= startOfWeek && date <= endOfWeek;
const startOfWeek = computeStartOfTheWeek(selected)
const endOfWeek = new Date(startOfWeek)
endOfWeek.setDate(startOfWeek.getDate() + 6)
endOfWeek.setHours(23, 59, 59, 999)
return date >= startOfWeek && date <= endOfWeek
})()
: false;
: false
const classNames = [
isToday ? "today" : "",
isSelectedDay ? "selectedDay" : "",
isInSelectedWeek ? "selectedWeek" : "",
].join(" ");
isToday ? 'today' : '',
isSelectedDay ? 'selectedDay' : '',
isInSelectedWeek ? 'selectedWeek' : ''
].join(' ')
return {
className: classNames,
selected: classNames.includes("selectedWeek"),
selected: classNames.includes('selectedWeek'),
outsideCurrentMonth: ownerState.isDayOutsideMonth,
disableMargin: false,
style: {
backgroundColor: "transparent",
position: "relative",
flexDirection: "column",
border: 0,
backgroundColor: 'transparent',
position: 'relative',
flexDirection: 'column',
border: 0
},
sx: {
"&.Mui-selected": {
color: "inherit !important",
fontWeight: "inherit !important",
'&.Mui-selected': {
color: 'inherit !important',
fontWeight: 'inherit !important'
},
"&.selectedDay": {
backgroundColor: "lightgray !important",
},
"&.today": {
background: "orange !important",
color: "white !important",
'&.selectedDay': {
backgroundColor: 'lightgray !important'
},
'&.today': {
background: 'orange !important',
color: 'white !important'
}
},
"data-testid": `date-${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`,
children: <>{ownerState.day.date()}</>,
};
},
'data-testid': `date-${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`,
children: <>{ownerState.day.date()}</>
}
}
}}
/>
</LocalizationProvider>
);
)
}
+8 -8
View File
@@ -1,23 +1,23 @@
import { Typography } from "@linagora/twake-mui";
import { Typography } from '@linagora/twake-mui'
export function OwnerCaption({
showCaption,
ownerDisplayName,
ownerDisplayName
}: {
showCaption: boolean;
ownerDisplayName: string;
showCaption: boolean
ownerDisplayName: string
}) {
return (
<Typography
variant="caption"
color="text.secondary"
style={{
overflow: "hidden",
textOverflow: "ellipsis",
wordBreak: "break-word",
overflow: 'hidden',
textOverflow: 'ellipsis',
wordBreak: 'break-word'
}}
>
{showCaption && ownerDisplayName}
</Typography>
);
)
}
+11 -11
View File
@@ -1,14 +1,14 @@
import { Avatar, Box, Typography } from "@linagora/twake-mui";
import { useI18n } from "twake-i18n";
import { stringAvatar } from "../Event/utils/eventUtils";
import { UserWithAccess } from "./CalendarAccessRights";
import { Avatar, Box, Typography } from '@linagora/twake-mui'
import { useI18n } from 'twake-i18n'
import { stringAvatar } from '../Event/utils/eventUtils'
import { UserWithAccess } from './CalendarAccessRights'
interface ResourceAdminProps {
admin: UserWithAccess;
admin: UserWithAccess
}
export function ResourceAdmin({ admin }: ResourceAdminProps) {
const { t } = useI18n();
const { t } = useI18n()
return (
<Box
@@ -18,14 +18,14 @@ export function ResourceAdmin({ admin }: ResourceAdminProps) {
px={1}
py={0.5}
sx={{
borderRadius: "8px",
"&:hover": { backgroundColor: "action.hover" },
borderRadius: '8px',
'&:hover': { backgroundColor: 'action.hover' }
}}
>
<Box display="flex" alignItems="center" gap={1.5} minWidth={0}>
<Avatar
{...stringAvatar(admin.displayName)}
sx={{ width: 28, height: 28, fontSize: "0.875rem" }}
sx={{ width: 28, height: 28, fontSize: '0.875rem' }}
/>
<Box minWidth={0} display="flex" flexDirection="column" gap={0}>
<Typography noWrap>{admin.displayName}</Typography>
@@ -37,9 +37,9 @@ export function ResourceAdmin({ admin }: ResourceAdminProps) {
<Box display="flex" alignItems="center" gap={0.5} flexShrink={0}>
<Typography variant="caption">
{t("calendarPopover.access.administrator")}
{t('calendarPopover.access.administrator')}
</Typography>
</Box>
</Box>
);
)
}
+60 -58
View File
@@ -1,23 +1,23 @@
import { useAppSelector } from "@/app/hooks";
import { Calendar } from "@/features/Calendars/CalendarTypes";
import { extractEventBaseUuid } from "@/utils/extractEventBaseUuid";
import { useAppSelector } from '@/app/hooks'
import { Calendar } from '@/features/Calendars/CalendarTypes'
import { extractEventBaseUuid } from '@/utils/extractEventBaseUuid'
import {
Box,
TextField,
ToggleButton,
ToggleButtonGroup,
Typography,
useTheme,
} from "@linagora/twake-mui";
import LockOutlineIcon from "@mui/icons-material/LockOutline";
import LayersOutlinedIcon from "@mui/icons-material/LayersOutlined";
import { alpha } from "@mui/material/styles";
import PublicIcon from "@mui/icons-material/Public";
import { useEffect, useMemo, useState } from "react";
import { useI18n } from "twake-i18n";
import { AddDescButton } from "../Event/AddDescButton";
import { ColorPicker } from "./CalendarColorPicker";
import { InfoRow } from "../Event/InfoRow";
useTheme
} from '@linagora/twake-mui'
import LockOutlineIcon from '@mui/icons-material/LockOutline'
import LayersOutlinedIcon from '@mui/icons-material/LayersOutlined'
import { alpha } from '@mui/material/styles'
import PublicIcon from '@mui/icons-material/Public'
import { useEffect, useMemo, useState } from 'react'
import { useI18n } from 'twake-i18n'
import { AddDescButton } from '../Event/AddDescButton'
import { ColorPicker } from './CalendarColorPicker'
import { InfoRow } from '../Event/InfoRow'
export function SettingsTab({
name,
@@ -28,35 +28,37 @@ export function SettingsTab({
setColor,
visibility,
setVisibility,
calendar,
calendar
}: {
name: string;
setName: (name: string) => void;
description: string;
setDescription: (d: string) => void;
color: Record<string, string>;
setColor: (color: Record<string, string>) => void;
visibility: "public" | "private";
setVisibility: (visibility: "public" | "private") => void;
calendar?: Calendar;
name: string
setName: (name: string) => void
description: string
setDescription: (d: string) => void
color: Record<string, string>
setColor: (color: Record<string, string>) => void
visibility: 'public' | 'private'
setVisibility: (visibility: 'public' | 'private') => void
calendar?: Calendar
}) {
const { t } = useI18n();
const [toggleDesc, setToggleDesc] = useState(Boolean(description));
const userId =
useAppSelector((state) => state.user.userData?.openpaasId) ?? "";
const isOwn = calendar ? extractEventBaseUuid(calendar.id) === userId : true;
const theme = useTheme();
const infoIconColor = alpha(theme.palette.grey[900], 0.9);
const infoIconSx = { minWidth: "25px", marginRight: 2, color: infoIconColor };
const { t } = useI18n()
const [toggleDesc, setToggleDesc] = useState(Boolean(description))
const userId = useAppSelector(state => state.user.userData?.openpaasId) ?? ''
const isOwn = calendar ? extractEventBaseUuid(calendar.id) === userId : true
const theme = useTheme()
const infoIconColor = alpha(theme.palette.grey[900], 0.9)
const infoIconSx = { minWidth: '25px', marginRight: 2, color: infoIconColor }
const isResource = useMemo(
() => calendar?.owner?.resource,
[calendar?.owner?.resource]
);
)
useEffect(() => {
if (description) setToggleDesc(true);
}, [description]);
const handleToggleDesc = () => {
if (description) setToggleDesc(true)
}
handleToggleDesc()
}, [description])
return (
<>
@@ -64,15 +66,15 @@ export function SettingsTab({
<Box mt={0}>
<Typography
variant="h6"
sx={{ margin: 0, marginBottom: isResource ? "16px" : 0 }}
sx={{ margin: 0, marginBottom: isResource ? '16px' : 0 }}
>
{t(
isResource
? "calendarPopover.settings.resourceName"
: "calendarPopover.settings.calendarName"
? 'calendarPopover.settings.resourceName'
: 'calendarPopover.settings.calendarName'
)}
</Typography>
<Box sx={{ marginTop: "6px" }}>
<Box sx={{ marginTop: '6px' }}>
{isResource ? (
<InfoRow
alignItems="flex-start"
@@ -83,24 +85,24 @@ export function SettingsTab({
}
text={name}
style={{
fontSize: "16px",
fontFamily: "'Inter', sans-serif",
fontSize: '16px',
fontFamily: "'Inter', sans-serif"
}}
/>
) : (
<TextField
fullWidth
label=""
inputProps={{ "aria-label": t("common.name") }}
placeholder={t("common.name")}
inputProps={{ 'aria-label': t('common.name') }}
placeholder={t('common.name')}
value={name}
onChange={(e) => setName(e.target.value)}
onChange={e => setName(e.target.value)}
size="small"
sx={{
"&.MuiFormControl-root": {
'&.MuiFormControl-root': {
marginTop: 0,
marginBottom: 0,
},
marginBottom: 0
}
}}
/>
)}
@@ -123,11 +125,11 @@ export function SettingsTab({
{/* Form group 3: Color */}
<Box mt={2}>
<Typography variant="h6" sx={{ margin: 0 }}>
{t("calendar.color")}
{t('calendar.color')}
</Typography>
<Box sx={{ marginTop: "6px" }}>
<Box sx={{ marginTop: '6px' }}>
<ColorPicker
onChange={(color) => setColor(color)}
onChange={color => setColor(color)}
selectedColor={color}
/>
</Box>
@@ -137,29 +139,29 @@ export function SettingsTab({
{isOwn && (
<Box mt={2}>
<Typography variant="h6" sx={{ margin: 0 }}>
{t("calendar.newEventsVisibility")}
{t('calendar.newEventsVisibility')}
</Typography>
<Box sx={{ marginTop: "6px" }}>
<Box sx={{ marginTop: '6px' }}>
<ToggleButtonGroup
value={visibility}
exclusive
onChange={(e, val) => val && setVisibility(val)}
size="medium"
sx={{ borderRadius: "12px" }}
sx={{ borderRadius: '12px' }}
>
<ToggleButton value="public" sx={{ width: "140px" }}>
<ToggleButton value="public" sx={{ width: '140px' }}>
<PublicIcon fontSize="small" sx={{ mr: 1 }} />
{t("common.all")}
{t('common.all')}
</ToggleButton>
<ToggleButton value="private" sx={{ width: "140px" }}>
<ToggleButton value="private" sx={{ width: '140px' }}>
<LockOutlineIcon fontSize="small" sx={{ mr: 1 }} />
{t("common.you")}
{t('common.you')}
</ToggleButton>
</ToggleButtonGroup>
</Box>
</Box>
)}
</>
);
)
}
+76 -79
View File
@@ -1,156 +1,153 @@
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 { useI18n } from "twake-i18n";
import { PeopleSearch, User } from "../Attendees/PeopleSearch";
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 { useI18n } from 'twake-i18n'
import { PeopleSearch, User } from '../Attendees/PeopleSearch'
const requestControllers = new Map<string, AbortController>();
const requestControllers = new Map<string, AbortController>()
export function TempCalendarsInput({
tempUsers,
setTempUsers,
handleToggleEventPreview,
handleToggleEventPreview
}: {
tempUsers: User[];
setTempUsers: (users: User[]) => void;
handleToggleEventPreview: () => void;
tempUsers: User[]
setTempUsers: (users: User[]) => void
handleToggleEventPreview: () => void
}) {
const dispatch = useAppDispatch();
const tempcalendars =
useAppSelector((state) => state.calendars.templist) ?? {};
const { t } = useI18n();
const dispatch = useAppDispatch()
const tempcalendars = useAppSelector(state => state.calendars.templist) ?? {}
const { t } = useI18n()
const prevUsersRef = useRef<User[]>([]);
const prevUsersRef = useRef<User[]>([])
const userColorsRef = useRef(
new Map<string, { light: string; dark: string }>()
);
)
const handleUserChange = async (_: React.SyntheticEvent, users: User[]) => {
setTempUsers(users);
const handleUserChange = (_: React.SyntheticEvent, users: User[]) => {
setTempUsers(users)
const prevUsers = prevUsersRef.current;
const prevUsers = prevUsersRef.current
const addedUsers = users.filter(
(u) => !prevUsers.some((p) => p.email === u.email)
);
u => !prevUsers.some(p => p.email === u.email)
)
const removedUsers = prevUsers.filter(
(p) => !users.some((u) => u.email === p.email)
);
p => !users.some(u => u.email === p.email)
)
prevUsersRef.current = users;
prevUsersRef.current = users
if (addedUsers.length > 0) {
dispatch(setView("calendar"));
dispatch(setView('calendar'))
for (const user of addedUsers) {
const controller = new AbortController();
requestControllers.set(user.email, controller);
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);
c => c.light
)
const colorPair = generateDistinctColor(usedLights)
userColorsRef.current.set(user.email, colorPair)
}
user.color = userColorsRef.current.get(user.email)!;
dispatch(
getTempCalendarsListAsync(user, { signal: controller.signal })
);
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);
const controller = requestControllers.get(user.email)
if (controller) {
controller.abort();
requestControllers.delete(user.email);
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 calIds = buildEmailToCalendarMap(tempcalendars).get(user.email)
calIds?.forEach(id => dispatch(removeTempCal(id)))
userColorsRef.current.delete(user.email)
}
};
}
return (
<PeopleSearch
objectTypes={["user", "resource"]}
objectTypes={['user', 'resource']}
selectedUsers={tempUsers}
onChange={handleUserChange}
onToggleEventPreview={handleToggleEventPreview}
placeholder={t("peopleSearch.availabilityPlaceholder")}
inputSlot={(params) => (
placeholder={t('peopleSearch.availabilityPlaceholder')}
inputSlot={params => (
<TextField
{...params}
size="small"
sx={
tempUsers.length > 0
? {
"& .MuiOutlinedInput-root": {
flexDirection: "column",
alignItems: "start",
"& .MuiInputBase-input": {
width: "100%",
},
},
'& .MuiOutlinedInput-root': {
flexDirection: 'column',
alignItems: 'start',
'& .MuiInputBase-input': {
width: '100%'
}
}
}
: undefined
}
/>
)}
/>
);
)
}
function buildEmailToCalendarMap(calRecord: Record<string, Calendar>) {
const map = new 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);
cal.owner?.emails?.forEach(email => {
const existing = map.get(email)
if (existing) {
existing.push(id);
existing.push(id)
} else {
map.set(email, [id]);
map.set(email, [id])
}
});
})
}
return map;
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 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 clamp = (v: number) => Math.max(0, Math.min(255, v))
const toHex = (v: number) =>
clamp(v + amount)
.toString(16)
.padStart(2, "0");
.padStart(2, '0')
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
return `#${toHex(r)}${toHex(g)}${toHex(b)}`
}
function generateDistinctColor(usedLights: string[]): {
light: string;
dark: string;
light: string
dark: string
} {
for (const color of defaultColors) {
if (!usedLights.includes(color.light)) return color;
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];
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)),
};
dark: shiftLightness(base.dark, -(round * 10))
}
}
+40 -40
View File
@@ -1,45 +1,45 @@
import {
browserDefaultTimeZone,
getTimezoneOffset,
resolveTimezone,
} from "@/utils/timezone";
import { TIMEZONES } from "@/utils/timezone-data";
import { Button, Popover } from "@linagora/twake-mui";
import { MouseEvent, useMemo, useRef, useState } from "react";
import { useI18n } from "twake-i18n";
import { TimezoneAutocomplete } from "../Timezone/TimezoneAutocomplete";
resolveTimezone
} from '@/utils/timezone'
import { TIMEZONES } from '@/utils/timezone-data'
import { Button, Popover } from '@linagora/twake-mui'
import { MouseEvent, useMemo, useRef, useState } from 'react'
import { useI18n } from 'twake-i18n'
import { TimezoneAutocomplete } from '../Timezone/TimezoneAutocomplete'
interface TimezoneSelectProps {
value: string;
onChange: (value: string) => void;
referenceDate: Date;
value: string
onChange: (value: string) => void
referenceDate: Date
}
export function TimezoneSelector({
value,
onChange,
referenceDate,
referenceDate
}: TimezoneSelectProps) {
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null)
const inputRef = useRef<HTMLInputElement>(null)
const timezoneList = useTimeZoneList();
const timezoneList = useTimeZoneList()
const effectiveTimezone = value
? resolveTimezone(value)
: timezoneList.browserTz;
const selectedOffset = getTimezoneOffset(effectiveTimezone, referenceDate);
: timezoneList.browserTz
const selectedOffset = getTimezoneOffset(effectiveTimezone, referenceDate)
const handleOpen = (event: MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget);
};
setAnchorEl(event.currentTarget)
}
const handleClose = () => {
setAnchorEl(null);
};
setAnchorEl(null)
}
const open = Boolean(anchorEl);
const { t } = useI18n();
const open = Boolean(anchorEl)
const { t } = useI18n()
return (
<>
<Button
@@ -47,14 +47,14 @@ export function TimezoneSelector({
size="small"
onClick={handleOpen}
sx={{
textTransform: "none",
minWidth: "auto",
padding: "2px 4px",
textTransform: 'none',
minWidth: 'auto',
padding: '2px 4px',
margin: 0,
lineHeight: 1.2,
lineHeight: 1.2
}}
>
{selectedOffset || t("common.select_timezone")}
{selectedOffset || t('common.select_timezone')}
</Button>
<Popover
@@ -62,22 +62,22 @@ export function TimezoneSelector({
anchorEl={anchorEl}
onClose={handleClose}
anchorOrigin={{
vertical: "bottom",
horizontal: "left",
vertical: 'bottom',
horizontal: 'left'
}}
transformOrigin={{
vertical: "top",
horizontal: "left",
vertical: 'top',
horizontal: 'left'
}}
slotProps={{
paper: {
sx: { width: 280, maxHeight: 400, overflow: "hidden", p: 0 },
sx: { width: 280, maxHeight: 400, overflow: 'hidden', p: 0 }
},
transition: {
onEntered: () => {
inputRef.current?.focus();
},
},
inputRef.current?.focus()
}
}
}}
>
<TimezoneAutocomplete
@@ -98,14 +98,14 @@ export function TimezoneSelector({
/>
</Popover>
</>
);
)
}
export function useTimeZoneList() {
return useMemo(() => {
const zones = Object.keys(TIMEZONES.zones).sort();
const browserTz = resolveTimezone(browserDefaultTimeZone);
const zones = Object.keys(TIMEZONES.zones).sort()
const browserTz = resolveTimezone(browserDefaultTimeZone)
return { zones, browserTz, getTimezoneOffset };
}, []);
return { zones, browserTz, getTimezoneOffset }
}, [])
}
+114 -114
View File
@@ -1,47 +1,47 @@
import { AppDispatch } from "@/app/store";
import { User } from "@/components/Attendees/PeopleSearch";
import { formatLocalDateTime } from "@/components/Event/utils/dateTimeFormatters";
import { Calendar } from "@/features/Calendars/CalendarTypes";
import { AppDispatch } from '@/app/store'
import { User } from '@/components/Attendees/PeopleSearch'
import { formatLocalDateTime } from '@/components/Event/utils/dateTimeFormatters'
import { Calendar } from '@/features/Calendars/CalendarTypes'
import {
getEventAsync,
putEventAsync,
updateEventInstanceAsync,
updateSeriesAsync,
} from "@/features/Calendars/services";
import { getEvent } from "@/features/Events/EventApi";
import { CalendarEvent } from "@/features/Events/EventsTypes";
import { updateAttendeesAfterTimeChange } from "@/features/Events/updateEventHelpers/updateAttendeesAfterTimeChange";
updateSeriesAsync
} from '@/features/Calendars/services'
import { getEvent } from '@/features/Events/EventApi'
import { CalendarEvent } from '@/features/Events/EventsTypes'
import { updateAttendeesAfterTimeChange } from '@/features/Events/updateEventHelpers/updateAttendeesAfterTimeChange'
import {
AttendeeOptions,
createAttendee,
} from "@/features/User/models/attendee.mapper";
import { getDeltaInMilliseconds } from "@/utils/dateUtils";
createAttendee
} from '@/features/User/models/attendee.mapper'
import { getDeltaInMilliseconds } from '@/utils/dateUtils'
import {
CalendarApi,
DateSelectArg,
EventClickArg,
EventDropArg,
} from "@fullcalendar/core";
import { EventResizeDoneArg } from "@fullcalendar/interaction";
EventDropArg
} from '@fullcalendar/core'
import { EventResizeDoneArg } from '@fullcalendar/interaction'
export interface EventHandlersProps {
setSelectedRange: (range: DateSelectArg | null) => void;
setAnchorEl: (el: HTMLElement | null) => void;
calendarRef: React.RefObject<CalendarApi | null>;
dispatch: AppDispatch;
setOpenEventDisplay: (open: boolean) => void;
setEventDisplayedId: (id: string) => void;
setEventDisplayedCalId: (id: string) => void;
setEventDisplayedTemp: (temp: boolean) => void;
calendars: Record<string, Calendar>;
setSelectedEvent: (event: CalendarEvent) => void;
setSelectedRange: (range: DateSelectArg | null) => void
setAnchorEl: (el: HTMLElement | null) => void
calendarRef: React.RefObject<CalendarApi | null>
dispatch: AppDispatch
setOpenEventDisplay: (open: boolean) => void
setEventDisplayedId: (id: string) => void
setEventDisplayedCalId: (id: string) => void
setEventDisplayedTemp: (temp: boolean) => void
calendars: Record<string, Calendar>
setSelectedEvent: (event: CalendarEvent) => void
setAfterChoiceFunc: (
func: ((type: "solo" | "all" | undefined) => void) | undefined
) => void;
setOpenEditModePopup: (open: string) => void;
tempUsers: User[];
setTempEvent: (event: CalendarEvent) => void;
timezone: string;
func: ((type: 'solo' | 'all' | undefined) => void) | undefined
) => void
setOpenEditModePopup: (open: string) => void
tempUsers: User[]
setTempEvent: (event: CalendarEvent) => void
timezone: string
}
export const createEventHandlers = (props: EventHandlersProps) => {
@@ -60,55 +60,55 @@ export const createEventHandlers = (props: EventHandlersProps) => {
setOpenEditModePopup,
tempUsers,
setTempEvent,
timezone,
} = props;
timezone
} = props
const handleDateSelect = (selectInfo: DateSelectArg) => {
setSelectedRange(selectInfo);
setSelectedRange(selectInfo)
if (tempUsers) {
const newEvent: CalendarEvent = {
start: selectInfo?.start
? formatLocalDateTime(selectInfo?.start, timezone)
: "",
: '',
end: selectInfo?.end
? formatLocalDateTime(selectInfo?.end, timezone)
: "",
attendee: tempUsers.map((user) => {
: '',
attendee: tempUsers.map(user => {
const attendeeOption: AttendeeOptions = {
cal_address: user.email,
cn: user.displayName,
rsvp: "TRUE",
};
if (user.objectType === "resource") {
attendeeOption.cutype = "RESOURCE";
rsvp: 'TRUE'
}
return createAttendee(attendeeOption);
}),
} as CalendarEvent;
setTempEvent(newEvent);
if (user.objectType === 'resource') {
attendeeOption.cutype = 'RESOURCE'
}
return createAttendee(attendeeOption)
})
} as CalendarEvent
setTempEvent(newEvent)
}
setAnchorEl(document.body);
};
setAnchorEl(document.body)
}
const handleClosePopover = () => {
calendarRef.current?.unselect();
setAnchorEl(null);
setSelectedRange(null);
};
calendarRef.current?.unselect()
setAnchorEl(null)
setSelectedRange(null)
}
const handleCloseEventDisplay = () => {
setOpenEventDisplay(false);
};
setOpenEventDisplay(false)
}
const handleEventClick = (info: EventClickArg) => {
info.jsEvent.preventDefault();
info.jsEvent.preventDefault()
if (info.event.url) {
window.open(info.event.url);
window.open(info.event.url)
} else {
setOpenEventDisplay(true);
setOpenEventDisplay(true)
if (
calendars[info.event.extendedProps.calId] &&
calendars[info.event.extendedProps.calId].events[
@@ -121,57 +121,57 @@ export const createEventHandlers = (props: EventHandlersProps) => {
info.event.extendedProps.uid
]
)
);
)
}
setEventDisplayedId(info.event.extendedProps.uid);
setEventDisplayedCalId(info.event.extendedProps.calId);
setEventDisplayedTemp(info.event._def.extendedProps.temp);
setEventDisplayedId(info.event.extendedProps.uid)
setEventDisplayedCalId(info.event.extendedProps.calId)
setEventDisplayedTemp(info.event._def.extendedProps.temp)
}
};
}
const handleEventAllow = () => {
return true;
};
return true
}
const handleEventDrop = async (arg: EventDropArg) => {
if (!arg.event || !arg.event._def || !arg.event._def.extendedProps) {
return;
return
}
const event =
calendars[arg.event._def.extendedProps.calId].events[
arg.event._def.extendedProps.uid
];
const calendar = calendars[arg.event._def.extendedProps.calId];
]
const calendar = calendars[arg.event._def.extendedProps.calId]
const isRecurring = event.uid.includes("/");
const totalDeltaMs = getDeltaInMilliseconds(arg.delta);
const isRecurring = event.uid.includes('/')
const totalDeltaMs = getDeltaInMilliseconds(arg.delta)
const originalStart = new Date(event.start);
const computedNewStart = new Date(originalStart.getTime() + totalDeltaMs);
const originalEnd = new Date(event.end ?? "");
const computedNewEnd = new Date(originalEnd.getTime() + totalDeltaMs);
const originalStart = new Date(event.start)
const computedNewStart = new Date(originalStart.getTime() + totalDeltaMs)
const originalEnd = new Date(event.end ?? '')
const computedNewEnd = new Date(originalEnd.getTime() + totalDeltaMs)
const newEvent = updateAttendeesAfterTimeChange(
{
...event,
start: computedNewStart.toISOString(),
end: computedNewEnd.toISOString(),
sequence: (event.sequence ?? 1) + 1,
sequence: (event.sequence ?? 1) + 1
} as CalendarEvent,
true
);
)
if (isRecurring) {
setSelectedEvent(event);
setOpenEditModePopup("edit");
setSelectedEvent(event)
setOpenEditModePopup('edit')
setAfterChoiceFunc(
() => async (typeOfAction: "solo" | "all" | undefined) => {
if (typeOfAction === "solo") {
() => async (typeOfAction: 'solo' | 'all' | undefined) => {
if (typeOfAction === 'solo') {
await dispatch(
updateEventInstanceAsync({ cal: calendar, event: newEvent })
);
} else if (typeOfAction === "all") {
const master = await getEvent(newEvent, true);
)
} else if (typeOfAction === 'all') {
const master = await getEvent(newEvent, true)
await dispatch(
updateSeriesAsync({
cal: calendar,
@@ -179,61 +179,61 @@ export const createEventHandlers = (props: EventHandlersProps) => {
...master,
start: computedNewStart.toISOString(),
end: computedNewEnd.toISOString(),
sequence: (master.sequence ?? 1) + 1,
},
sequence: (master.sequence ?? 1) + 1
}
})
);
)
}
}
);
)
} else {
await dispatch(
putEventAsync({ cal: calendars[newEvent.calId], newEvent })
);
)
}
};
}
const handleEventResize = async (arg: EventResizeDoneArg) => {
if (!arg.event || !arg.event._def || !arg.event._def.extendedProps) {
return;
return
}
const event =
calendars[arg.event._def.extendedProps.calId].events[
arg.event._def.extendedProps.uid
];
const calendar = calendars[arg.event._def.extendedProps.calId];
]
const calendar = calendars[arg.event._def.extendedProps.calId]
const isRecurring = event.uid.includes("/");
const isRecurring = event.uid.includes('/')
const originalStart = new Date(event.start);
const originalStart = new Date(event.start)
const computedNewStart = new Date(
originalStart.getTime() + getDeltaInMilliseconds(arg.startDelta)
);
const originalEnd = new Date(event.end ?? "");
)
const originalEnd = new Date(event.end ?? '')
const computedNewEnd = new Date(
originalEnd.getTime() + getDeltaInMilliseconds(arg.endDelta)
);
)
const newEvent = updateAttendeesAfterTimeChange(
{
...event,
start: computedNewStart.toISOString(),
end: computedNewEnd.toISOString(),
sequence: (event.sequence ?? 1) + 1,
sequence: (event.sequence ?? 1) + 1
} as CalendarEvent,
true
);
)
if (isRecurring) {
setSelectedEvent(event);
setOpenEditModePopup("edit");
setSelectedEvent(event)
setOpenEditModePopup('edit')
setAfterChoiceFunc(
() => async (typeOfAction: "solo" | "all" | undefined) => {
if (typeOfAction === "solo") {
() => async (typeOfAction: 'solo' | 'all' | undefined) => {
if (typeOfAction === 'solo') {
await dispatch(
updateEventInstanceAsync({ cal: calendar, event: newEvent })
);
} else if (typeOfAction === "all") {
const master = await getEvent(newEvent, true);
)
} else if (typeOfAction === 'all') {
const master = await getEvent(newEvent, true)
await dispatch(
updateSeriesAsync({
@@ -242,19 +242,19 @@ export const createEventHandlers = (props: EventHandlersProps) => {
...master,
start: computedNewStart.toISOString(),
end: computedNewEnd.toISOString(),
sequence: (master.sequence ?? 1) + 1,
},
sequence: (master.sequence ?? 1) + 1
}
})
);
)
}
}
);
)
} else {
await dispatch(
putEventAsync({ cal: calendars[newEvent.calId], newEvent })
);
)
}
};
}
return {
handleDateSelect,
@@ -263,6 +263,6 @@ export const createEventHandlers = (props: EventHandlersProps) => {
handleEventClick,
handleEventAllow,
handleEventDrop,
handleEventResize,
};
};
handleEventResize
}
}
@@ -1,109 +1,108 @@
interface CalendarHTMLElement extends HTMLElement {
__calendarMouseMoveHandler?: (e: MouseEvent) => void;
__calendarMouseLeaveHandler?: () => void;
__calendarMouseMoveHandler?: (e: MouseEvent) => void
__calendarMouseLeaveHandler?: () => void
}
export interface MouseHandlersProps {
calendarEl: CalendarHTMLElement;
calendarEl: CalendarHTMLElement
}
export const createMouseHandlers = (props: MouseHandlersProps) => {
const { calendarEl } = props;
const { calendarEl } = props
const handleMouseMove = (e: MouseEvent) => {
const timegridEl = calendarEl.querySelector(".fc-timegrid-body");
if (!timegridEl) return;
const timegridEl = calendarEl.querySelector('.fc-timegrid-body')
if (!timegridEl) return
const allDayTable = calendarEl.querySelector(".fc-scrollgrid-sync-table");
const allDayTable = calendarEl.querySelector('.fc-scrollgrid-sync-table')
if (allDayTable) {
const allDayRect = allDayTable.getBoundingClientRect();
const allDayRect = allDayTable.getBoundingClientRect()
if (e.clientY >= allDayRect.top && e.clientY <= allDayRect.bottom) {
timegridEl
.querySelectorAll(".hour-highlight")
.forEach((el: Element) => el.remove());
return;
.querySelectorAll('.hour-highlight')
.forEach((el: Element) => el.remove())
return
}
}
const target = e.target as HTMLElement;
if (target && target.closest(".fc-timegrid-slot-label")) {
const target = e.target as HTMLElement
if (target && target.closest('.fc-timegrid-slot-label')) {
timegridEl
.querySelectorAll(".hour-highlight")
.forEach((el: Element) => el.remove());
return;
.querySelectorAll('.hour-highlight')
.forEach((el: Element) => el.remove())
return
}
const dayColumns = timegridEl.querySelectorAll(".fc-timegrid-col");
if (dayColumns.length === 0) return;
const dayColumns = timegridEl.querySelectorAll('.fc-timegrid-col')
if (dayColumns.length === 0) return
timegridEl
.querySelectorAll(".hour-highlight")
.forEach((el: Element) => el.remove());
.querySelectorAll('.hour-highlight')
.forEach((el: Element) => el.remove())
let targetColumn: Element | null = null;
let targetColumn: Element | null = null
for (const column of dayColumns) {
const rect = column.getBoundingClientRect();
const rect = column.getBoundingClientRect()
if (e.clientX >= rect.left && e.clientX <= rect.right) {
targetColumn = column;
break;
targetColumn = column
break
}
}
if (targetColumn) {
const rect = targetColumn.getBoundingClientRect();
const relativeY = e.clientY - rect.top;
const slotHeight = rect.height / 48;
const slotIndex = Math.floor(relativeY / slotHeight);
const rect = targetColumn.getBoundingClientRect()
const relativeY = e.clientY - rect.top
const slotHeight = rect.height / 48
const slotIndex = Math.floor(relativeY / slotHeight)
if (relativeY >= 0 && relativeY <= rect.height) {
const highlight = document.createElement("div");
highlight.className = "hour-highlight";
highlight.style.top = `${slotIndex * slotHeight}px`;
highlight.style.height = `${slotHeight}px`;
(targetColumn as HTMLElement).style.position = "relative";
targetColumn.appendChild(highlight);
const highlight = document.createElement('div')
highlight.className = 'hour-highlight'
highlight.style.top = `${slotIndex * slotHeight}px`
highlight.style.height = `${slotHeight}px`
;(targetColumn as HTMLElement).style.position = 'relative'
targetColumn.appendChild(highlight)
}
}
};
}
const handleMouseLeave = () => {
const timegridEl = calendarEl.querySelector(".fc-timegrid-body");
const timegridEl = calendarEl.querySelector('.fc-timegrid-body')
if (timegridEl) {
timegridEl
.querySelectorAll(".hour-highlight")
.forEach((el: Element) => el.remove());
.querySelectorAll('.hour-highlight')
.forEach((el: Element) => el.remove())
}
};
}
const addMouseEventListeners = () => {
calendarEl.addEventListener("mousemove", handleMouseMove);
calendarEl.addEventListener("mouseleave", handleMouseLeave);
calendarEl.addEventListener('mousemove', handleMouseMove)
calendarEl.addEventListener('mouseleave', handleMouseLeave)
calendarEl.__calendarMouseMoveHandler = handleMouseMove;
calendarEl.__calendarMouseLeaveHandler = handleMouseLeave;
};
calendarEl.__calendarMouseMoveHandler = handleMouseMove
calendarEl.__calendarMouseLeaveHandler = handleMouseLeave
}
const removeMouseEventListeners = () => {
if (calendarEl.__calendarMouseMoveHandler) {
calendarEl.removeEventListener(
"mousemove",
'mousemove',
calendarEl.__calendarMouseMoveHandler
);
delete calendarEl.__calendarMouseMoveHandler;
)
delete calendarEl.__calendarMouseMoveHandler
}
if (calendarEl.__calendarMouseLeaveHandler) {
calendarEl.removeEventListener(
"mouseleave",
'mouseleave',
calendarEl.__calendarMouseLeaveHandler
);
delete calendarEl.__calendarMouseLeaveHandler;
)
delete calendarEl.__calendarMouseLeaveHandler
}
};
}
return {
handleMouseMove,
handleMouseLeave,
addMouseEventListeners,
removeMouseEventListeners,
};
};
removeMouseEventListeners
}
}
@@ -1,26 +1,26 @@
import { EventErrorHandler } from "@/components/Error/EventErrorHandler";
import { EventChip } from "@/components/Event/EventChip/EventChip";
import { Calendar } from "@/features/Calendars/CalendarTypes";
import { userAttendee } from "@/features/User/models/attendee";
import { EventErrorHandler } from '@/components/Error/EventErrorHandler'
import { EventChip } from '@/components/Event/EventChip/EventChip'
import { Calendar } from '@/features/Calendars/CalendarTypes'
import { userAttendee } from '@/features/User/models/attendee'
import {
CalendarApi,
DayHeaderMountArg,
EventContentArg,
EventMountArg,
NowIndicatorContentArg,
ViewMountArg,
} from "@fullcalendar/core";
import React from "react";
import { createMouseHandlers } from "./mouseHandlers";
ViewMountArg
} from '@fullcalendar/core'
import React from 'react'
import { createMouseHandlers } from './mouseHandlers'
export interface ViewHandlersProps {
calendarRef: React.RefObject<CalendarApi | null>;
setSelectedDate: (date: Date) => void;
setSelectedMiniDate: (date: Date) => void;
onViewChange?: (view: string) => void;
calendars: Record<string, Calendar>;
tempcalendars: Record<string, Calendar>;
errorHandler: EventErrorHandler;
calendarRef: React.RefObject<CalendarApi | null>
setSelectedDate: (date: Date) => void
setSelectedMiniDate: (date: Date) => void
onViewChange?: (view: string) => void
calendars: Record<string, Calendar>
tempcalendars: Record<string, Calendar>
errorHandler: EventErrorHandler
}
export const createViewHandlers = (props: ViewHandlersProps) => {
@@ -31,126 +31,126 @@ export const createViewHandlers = (props: ViewHandlersProps) => {
onViewChange,
calendars,
tempcalendars,
errorHandler,
} = props;
errorHandler
} = props
const handleNowIndicatorContent = (arg: NowIndicatorContentArg) => {
if (arg.isAxis) {
return React.createElement(
"div",
{ style: { display: "flex", alignItems: "center" } },
'div',
{ style: { display: 'flex', alignItems: 'center' } },
React.createElement(
"div",
{ className: "now-time-label" },
'div',
{ className: 'now-time-label' },
new Date().toLocaleTimeString(undefined, {
hour: "2-digit",
minute: "2-digit",
hour: '2-digit',
minute: '2-digit',
hour12: false,
timeZone: arg.view.dateEnv.timeZone,
timeZone: arg.view.dateEnv.timeZone
})
)
);
)
}
};
}
const handleDayHeaderDidMount = (arg: DayHeaderMountArg) => {
if (arg.view.type === "timeGridWeek") {
const headerEl = arg.el;
if (arg.view.type === 'timeGridWeek') {
const headerEl = arg.el
const handleDayHeaderClick = () => {
calendarRef.current?.changeView("timeGridDay", arg.date);
setSelectedDate(new Date(arg.date));
setSelectedMiniDate(new Date(arg.date));
calendarRef.current?.changeView('timeGridDay', arg.date)
setSelectedDate(new Date(arg.date))
setSelectedMiniDate(new Date(arg.date))
if (onViewChange) {
onViewChange("timeGridDay");
onViewChange('timeGridDay')
}
};
}
headerEl.addEventListener("click", handleDayHeaderClick);
headerEl.__dayHeaderClickHandler = handleDayHeaderClick;
headerEl.addEventListener('click', handleDayHeaderClick)
headerEl.__dayHeaderClickHandler = handleDayHeaderClick
}
};
}
const handleDayHeaderWillUnmount = (arg: DayHeaderMountArg) => {
const headerEl = arg.el;
const headerEl = arg.el
if (headerEl.__dayHeaderClickHandler) {
headerEl.removeEventListener("click", headerEl.__dayHeaderClickHandler);
delete headerEl.__dayHeaderClickHandler;
headerEl.removeEventListener('click', headerEl.__dayHeaderClickHandler)
delete headerEl.__dayHeaderClickHandler
}
};
}
const handleViewDidMount = (arg: ViewMountArg) => {
if (arg.view.type === "timeGridWeek" || arg.view.type === "timeGridDay") {
const calendarEl = document.querySelector(".fc") as HTMLElement;
if (arg.view.type === 'timeGridWeek' || arg.view.type === 'timeGridDay') {
const calendarEl = document.querySelector('.fc') as HTMLElement
if (calendarEl) {
const mouseHandlers = createMouseHandlers({ calendarEl });
mouseHandlers.addMouseEventListeners();
const mouseHandlers = createMouseHandlers({ calendarEl })
mouseHandlers.addMouseEventListeners()
}
}
};
}
const handleViewWillUnmount = (arg: ViewMountArg) => {
if (arg.el.__timeInterval) {
clearInterval(arg.el.__timeInterval);
delete arg.el.__timeInterval;
clearInterval(arg.el.__timeInterval)
delete arg.el.__timeInterval
}
if (arg.el.__timeObserver) {
arg.el.__timeObserver.disconnect();
delete arg.el.__timeObserver;
arg.el.__timeObserver.disconnect()
delete arg.el.__timeObserver
}
const calendarEl = document.querySelector(".fc") as HTMLElement;
const calendarEl = document.querySelector('.fc') as HTMLElement
if (calendarEl) {
const mouseHandlers = createMouseHandlers({ calendarEl });
mouseHandlers.removeMouseEventListeners();
const mouseHandlers = createMouseHandlers({ calendarEl })
mouseHandlers.removeMouseEventListeners()
}
};
}
const handleEventContent = (arg: EventContentArg) => {
return React.createElement(EventChip, {
arg,
calendars,
tempcalendars,
errorHandler,
});
};
errorHandler
})
}
const handleEventDidMount = (arg: EventMountArg) => {
const attendees = arg.event._def.extendedProps.attendee || [];
if (!calendars[arg.event._def.extendedProps.calId]) return;
const attendees = arg.event._def.extendedProps.attendee || []
if (!calendars[arg.event._def.extendedProps.calId]) return
const ownerEmails = new Set(
calendars[arg.event._def.extendedProps.calId].owner?.emails?.map(
(email) => email.toLowerCase()
calendars[arg.event._def.extendedProps.calId].owner?.emails?.map(email =>
email.toLowerCase()
)
);
)
const showSpecialDisplay = attendees.filter((att: userAttendee) =>
ownerEmails.has(att.cal_address.toLowerCase())
);
)
if (!showSpecialDisplay[0]) return;
if (!showSpecialDisplay[0]) return
arg.el.classList.remove(
"declined-event",
"tentative-event",
"needs-action-event"
);
'declined-event',
'tentative-event',
'needs-action-event'
)
switch (showSpecialDisplay[0].partstat) {
case "DECLINED":
arg.el.classList.add("declined-event");
break;
case "TENTATIVE":
arg.el.classList.add("tentative-event");
break;
case "NEEDS-ACTION":
arg.el.classList.add("needs-action-event");
break;
case 'DECLINED':
arg.el.classList.add('declined-event')
break
case 'TENTATIVE':
arg.el.classList.add('tentative-event')
break
case 'NEEDS-ACTION':
arg.el.classList.add('needs-action-event')
break
default:
break;
break
}
};
}
return {
handleNowIndicatorContent,
@@ -159,6 +159,6 @@ export const createViewHandlers = (props: ViewHandlersProps) => {
handleViewDidMount,
handleViewWillUnmount,
handleEventContent,
handleEventDidMount,
};
};
handleEventDidMount
}
}
@@ -1,12 +1,12 @@
/* eslint-disable react-hooks/exhaustive-deps */
import { useCallback } from "react";
import { useCallback } from 'react'
import {
createEventHandlers,
EventHandlersProps,
} from "../handlers/eventHandlers";
EventHandlersProps
} from '../handlers/eventHandlers'
export const useCalendarEventHandlers = (props: EventHandlersProps) => {
const eventHandlers = createEventHandlers(props);
const eventHandlers = createEventHandlers(props)
return {
handleDateSelect: useCallback(eventHandlers.handleDateSelect, [
@@ -14,13 +14,13 @@ export const useCalendarEventHandlers = (props: EventHandlersProps) => {
props.setAnchorEl,
props.setTempEvent,
props.tempUsers,
props.timezone,
props.timezone
]),
handleClosePopover: useCallback(eventHandlers.handleClosePopover, [
props.calendarRef,
props.setAnchorEl,
props.setSelectedRange,
props.dispatch,
props.dispatch
]),
handleCloseEventDisplay: useCallback(
eventHandlers.handleCloseEventDisplay,
@@ -32,7 +32,7 @@ export const useCalendarEventHandlers = (props: EventHandlersProps) => {
props.setEventDisplayedCalId,
props.setEventDisplayedTemp,
props.calendars,
props.dispatch,
props.dispatch
]),
handleEventAllow: useCallback(eventHandlers.handleEventAllow, []),
handleEventDrop: useCallback(eventHandlers.handleEventDrop, [
@@ -40,11 +40,11 @@ export const useCalendarEventHandlers = (props: EventHandlersProps) => {
props.dispatch,
props.setSelectedEvent,
props.setOpenEditModePopup,
props.setAfterChoiceFunc,
props.setAfterChoiceFunc
]),
handleEventResize: useCallback(eventHandlers.handleEventResize, [
props.calendars,
props.dispatch,
]),
};
};
props.dispatch
])
}
}
@@ -1,19 +1,16 @@
/* eslint-disable react-hooks/exhaustive-deps */
import { useCallback } from "react";
import {
createViewHandlers,
ViewHandlersProps,
} from "../handlers/viewHandlers";
import { useCallback } from 'react'
import { createViewHandlers, ViewHandlersProps } from '../handlers/viewHandlers'
export const useCalendarViewHandlers = (props: ViewHandlersProps) => {
const viewHandlers = createViewHandlers(props);
const viewHandlers = createViewHandlers(props)
return {
handleDayHeaderDidMount: useCallback(viewHandlers.handleDayHeaderDidMount, [
props.calendarRef,
props.setSelectedDate,
props.setSelectedMiniDate,
props.onViewChange,
props.onViewChange
]),
handleDayHeaderWillUnmount: useCallback(
viewHandlers.handleDayHeaderWillUnmount,
@@ -23,14 +20,14 @@ export const useCalendarViewHandlers = (props: ViewHandlersProps) => {
handleViewWillUnmount: useCallback(viewHandlers.handleViewWillUnmount, []),
handleEventContent: useCallback(viewHandlers.handleEventContent, [
props.calendars,
props.tempcalendars,
props.tempcalendars
]),
handleEventDidMount: useCallback(viewHandlers.handleEventDidMount, [
props.calendars,
props.calendars
]),
handleNowIndicatorContent: useCallback(
viewHandlers.handleNowIndicatorContent,
[]
),
};
};
)
}
}
+136 -136
View File
@@ -1,100 +1,100 @@
import { AppDispatch } from "@/app/store";
import { Calendar, DelegationAccess } from "@/features/Calendars/CalendarTypes";
import { getCalendarDetailAsync } from "@/features/Calendars/services";
import { AclEntry } from "@/features/Calendars/types/CalendarData";
import { CalendarEvent } from "@/features/Events/EventsTypes";
import { formatDateToYYYYMMDDTHHMMSS } from "@/utils/dateUtils";
import { extractEventBaseUuid } from "@/utils/extractEventBaseUuid";
import { getEffectiveEmail } from "@/utils/getEffectiveEmail";
import { isEventOrganiser } from "@/utils/isEventOrganiser";
import { convertEventDateTimeToISO } from "@/utils/timezone";
import { EventInput, SlotLabelContentArg } from "@fullcalendar/core";
import moment from "moment-timezone";
import { useI18n } from "twake-i18n";
import { AppDispatch } from '@/app/store'
import { Calendar, DelegationAccess } from '@/features/Calendars/CalendarTypes'
import { getCalendarDetailAsync } from '@/features/Calendars/services'
import { AclEntry } from '@/features/Calendars/types/CalendarData'
import { CalendarEvent } from '@/features/Events/EventsTypes'
import { formatDateToYYYYMMDDTHHMMSS } from '@/utils/dateUtils'
import { extractEventBaseUuid } from '@/utils/extractEventBaseUuid'
import { getEffectiveEmail } from '@/utils/getEffectiveEmail'
import { isEventOrganiser } from '@/utils/isEventOrganiser'
import { convertEventDateTimeToISO } from '@/utils/timezone'
import { EventInput, SlotLabelContentArg } from '@fullcalendar/core'
import moment from 'moment-timezone'
import { useI18n } from 'twake-i18n'
export const updateSlotLabelVisibility = (
currentTime: Date,
slotLabel: SlotLabelContentArg,
timezone: string
) => {
const isCurrentWeekOrDay = checkIfCurrentWeekOrDay();
const isCurrentWeekOrDay = checkIfCurrentWeekOrDay()
if (!isCurrentWeekOrDay) {
return "fc-timegrid-slot-label";
return 'fc-timegrid-slot-label'
}
const current = moment.tz(currentTime, timezone);
const currentMinutes = current.hours() * 60 + current.minutes();
const timeText = slotLabel?.text?.trim();
const current = moment.tz(currentTime, timezone)
const currentMinutes = current.hours() * 60 + current.minutes()
const timeText = slotLabel?.text?.trim()
if (timeText && timeText.match(/^\d{1,2}:\d{2}$/)) {
const [hours, minutes] = timeText.split(":").map(Number);
const labelMinutes = hours * 60 + minutes;
const [hours, minutes] = timeText.split(':').map(Number)
const labelMinutes = hours * 60 + minutes
let timeDiff = Math.abs(currentMinutes - labelMinutes);
let timeDiff = Math.abs(currentMinutes - labelMinutes)
if (timeDiff > 12 * 60) {
timeDiff = 24 * 60 - timeDiff;
timeDiff = 24 * 60 - timeDiff
}
if (timeDiff <= 15) {
return "timegrid-slot-label-hidden";
return 'timegrid-slot-label-hidden'
}
}
return "fc-timegrid-slot-label";
};
return 'fc-timegrid-slot-label'
}
export const checkIfCurrentWeekOrDay = (): boolean => {
const todayColumn = document.querySelector(".fc-day-today");
const todayColumn = document.querySelector('.fc-day-today')
if (!todayColumn) {
return false;
return false
}
const nowIndicator = document.querySelector(
".fc-timegrid-now-indicator-arrow"
);
return !!nowIndicator;
};
'.fc-timegrid-now-indicator-arrow'
)
return !!nowIndicator
}
export function formatEventChipTitle(
e: CalendarEvent,
t: (key: string) => string
) {
if (!e.title) {
return t("event.untitled");
return t('event.untitled')
}
return e.title === "Busy" && e.class === "PRIVATE"
? t("event.form.busy")
: e.title;
return e.title === 'Busy' && e.class === 'PRIVATE'
? t('event.form.busy')
: e.title
}
type ConvertedEvent = CalendarEvent & {
colors: Record<string, string> | undefined;
editable: boolean;
priority: number;
};
colors: Record<string, string> | undefined
editable: boolean
priority: number
}
function applyTimezoneToEvent(
event: CalendarEvent,
convertedEvent: ConvertedEvent
): void {
const eventTimezone = event.timezone || "Etc/UTC";
const isAllDay = event.allday ?? false;
const eventTimezone = event.timezone || 'Etc/UTC'
const isAllDay = event.allday ?? false
if (!isAllDay && event.start) {
const startISO = convertEventDateTimeToISO(event.start, eventTimezone, {
isAllDay,
});
if (startISO) convertedEvent.start = startISO;
isAllDay
})
if (startISO) convertedEvent.start = startISO
}
if (!isAllDay && event.end && eventTimezone) {
const endISO = convertEventDateTimeToISO(event.end, eventTimezone, {
isAllDay,
});
if (endISO) convertedEvent.end = endISO;
isAllDay
})
if (endISO) convertedEvent.end = endISO
}
}
@@ -109,28 +109,28 @@ function buildConvertedEvent(
const isWriteDelegated =
(calendar?.delegated &&
calendar.access?.write &&
(!event.class || event.class === "PUBLIC")) ??
false;
(!event.class || event.class === 'PUBLIC')) ??
false
const effectiveEmail = getEffectiveEmail(
calendar,
isWriteDelegated,
userAddress
);
const isOrganiser = isEventOrganiser(event, effectiveEmail);
const isPersonalEvent = extractEventBaseUuid(event.calId) === userId;
)
const isOrganiser = isEventOrganiser(event, effectiveEmail)
const isPersonalEvent = extractEventBaseUuid(event.calId) === userId
const convertedEvent: ConvertedEvent = {
...event,
title: formatEventChipTitle(event, t),
colors: event.color,
editable: (isPersonalEvent || isWriteDelegated) && isOrganiser && !pending,
priority: isPersonalEvent ? 1 : 0,
};
priority: isPersonalEvent ? 1 : 0
}
applyTimezoneToEvent(event, convertedEvent);
applyTimezoneToEvent(event, convertedEvent)
return convertedEvent;
return convertedEvent
}
export const eventToFullCalendarFormat = (
@@ -142,10 +142,10 @@ export const eventToFullCalendarFormat = (
calendars: Record<string, Calendar>
): EventInput[] => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const { t } = useI18n();
const { t } = useI18n()
return filteredEvents
.concat(filteredTempEvents.map((event) => ({ ...event, temp: true })))
.map((event) =>
.concat(filteredTempEvents.map(event => ({ ...event, temp: true })))
.map(event =>
buildConvertedEvent(
event,
calendars[event.calId],
@@ -154,8 +154,8 @@ export const eventToFullCalendarFormat = (
pending,
t
)
) as EventInput[];
};
) as EventInput[]
}
export const extractEvents = (
selectedCalendars: string[],
@@ -163,29 +163,29 @@ export const extractEvents = (
userAddress?: string,
hideDeclinedEvents?: boolean | null
) => {
const allEvents: CalendarEvent[] = [];
const allEvents: CalendarEvent[] = []
selectedCalendars.forEach((id) => {
const calendar = calendars[id];
selectedCalendars.forEach(id => {
const calendar = calendars[id]
if (calendar?.events) {
allEvents.push(...Object.values(calendar.events));
allEvents.push(...Object.values(calendar.events))
}
});
})
return allEvents
.filter((event) => event.status !== "CANCELLED")
.filter(event => event.status !== 'CANCELLED')
.filter(
(event) =>
event =>
!(
hideDeclinedEvents &&
event.attendee?.some(
(a) =>
a =>
calendars[event.calId].owner.emails.includes(a.cal_address) &&
a.partstat === "DECLINED"
a.partstat === 'DECLINED'
)
)
);
};
)
}
export const updateCalsDetails = (
selectedCalendars: string[],
@@ -195,141 +195,141 @@ export const updateCalsDetails = (
previousRangeKey: string,
dispatch: AppDispatch,
calendarRange: { start: Date; end: Date },
calType?: "temp",
calType?: 'temp',
controllers?: Map<string, AbortController>
) => {
if (pending || !rangeKey) return;
if (pending || !rangeKey) return
const newCalendars = selectedCalendars.filter(
(id) => !previousSelectedCalendars.includes(id)
);
id => !previousSelectedCalendars.includes(id)
)
newCalendars.forEach((id) => {
newCalendars.forEach(id => {
if (controllers) {
const controller = new AbortController();
controllers.set(id, controller);
const controller = new AbortController()
controllers.set(id, controller)
dispatch(
getCalendarDetailAsync({
calId: id,
match: {
start: formatDateToYYYYMMDDTHHMMSS(calendarRange.start),
end: formatDateToYYYYMMDDTHHMMSS(calendarRange.end),
end: formatDateToYYYYMMDDTHHMMSS(calendarRange.end)
},
calType,
signal: controller.signal,
signal: controller.signal
})
);
)
} else {
dispatch(
getCalendarDetailAsync({
calId: id,
match: {
start: formatDateToYYYYMMDDTHHMMSS(calendarRange.start),
end: formatDateToYYYYMMDDTHHMMSS(calendarRange.end),
end: formatDateToYYYYMMDDTHHMMSS(calendarRange.end)
},
calType,
calType
})
);
)
}
});
})
if (rangeKey !== previousRangeKey) {
selectedCalendars?.forEach((id) => {
selectedCalendars?.forEach(id => {
if (id) {
if (controllers) {
const controller = new AbortController();
controllers.set(id, controller);
const controller = new AbortController()
controllers.set(id, controller)
dispatch(
getCalendarDetailAsync({
calId: id,
match: {
start: formatDateToYYYYMMDDTHHMMSS(calendarRange.start),
end: formatDateToYYYYMMDDTHHMMSS(calendarRange.end),
end: formatDateToYYYYMMDDTHHMMSS(calendarRange.end)
},
calType,
signal: controller.signal,
signal: controller.signal
})
);
)
} else {
dispatch(
getCalendarDetailAsync({
calId: id,
match: {
start: formatDateToYYYYMMDDTHHMMSS(calendarRange.start),
end: formatDateToYYYYMMDDTHHMMSS(calendarRange.end),
end: formatDateToYYYYMMDDTHHMMSS(calendarRange.end)
},
calType,
calType
})
);
)
}
}
});
})
}
};
}
export function getCalendarVisibility(acl: AclEntry[]): "private" | "public" {
let hasRead = false;
export function getCalendarVisibility(acl: AclEntry[]): 'private' | 'public' {
let hasRead = false
if (acl) {
for (const entry of acl) {
if (entry.principal !== "{DAV:}authenticated") continue;
if (entry.principal !== '{DAV:}authenticated') continue
if (entry.privilege === "{DAV:}read") {
hasRead = true;
break; // highest visibility, can stop
if (entry.privilege === '{DAV:}read') {
hasRead = true
break // highest visibility, can stop
}
}
}
if (hasRead) return "public";
return "private";
if (hasRead) return 'public'
return 'private'
}
export function getCalendarDelegationAccess(
acl: AclEntry[],
userId: string
): DelegationAccess {
const userPrincipal = `principals/users/${userId}`;
const userPrincipal = `principals/users/${userId}`
const access: DelegationAccess = {
freebusy: false,
read: false,
write: false,
"write-properties": false,
all: false,
};
for (const entry of acl ?? []) {
if (entry.principal !== userPrincipal) continue;
privilegeToAccess(entry.privilege, access);
'write-properties': false,
all: false
}
return access;
for (const entry of acl ?? []) {
if (entry.principal !== userPrincipal) continue
privilegeToAccess(entry.privilege, access)
}
return access
}
function privilegeToAccess(privilege: string, currentAccess: DelegationAccess) {
switch (privilege) {
case "{urn:ietf:params:xml:ns:caldav}read-free-busy":
currentAccess["freebusy"] = true;
break;
case "{DAV:}read":
currentAccess["read"] = true;
currentAccess["freebusy"] = true; // read implies read-free-busy
break;
case "{DAV:}write-properties":
currentAccess["write-properties"] = true;
break;
case "{DAV:}write":
currentAccess["write-properties"] = true; // write implies write-properties
currentAccess["write"] = true;
break;
case "{DAV:}all":
currentAccess["freebusy"] = true;
currentAccess["read"] = true;
currentAccess["write-properties"] = true;
currentAccess["write"] = true;
currentAccess["all"] = true;
break;
case '{urn:ietf:params:xml:ns:caldav}read-free-busy':
currentAccess['freebusy'] = true
break
case '{DAV:}read':
currentAccess['read'] = true
currentAccess['freebusy'] = true // read implies read-free-busy
break
case '{DAV:}write-properties':
currentAccess['write-properties'] = true
break
case '{DAV:}write':
currentAccess['write-properties'] = true // write implies write-properties
currentAccess['write'] = true
break
case '{DAV:}all':
currentAccess['freebusy'] = true
currentAccess['read'] = true
currentAccess['write-properties'] = true
currentAccess['write'] = true
currentAccess['all'] = true
break
default:
break;
break
}
}
+10 -10
View File
@@ -18,12 +18,12 @@ A highly reusable dialog component that supports both normal and expanded (fulls
## Basic Usage
```tsx
import { ResponsiveDialog } from "@/components/Dialog";
import { useState } from "react";
import { ResponsiveDialog } from '@/components/Dialog'
import { useState } from 'react'
function MyComponent() {
const [open, setOpen] = useState(false);
const [showMore, setShowMore] = useState(false);
const [open, setOpen] = useState(false)
const [showMore, setShowMore] = useState(false)
const actions = (
<>
@@ -32,7 +32,7 @@ function MyComponent() {
)}
<Button onClick={() => setOpen(false)}>Close</Button>
</>
);
)
return (
<ResponsiveDialog
@@ -47,7 +47,7 @@ function MyComponent() {
<TextField label="Email" fullWidth />
{/* Wrapped in Stack with spacing={2} for both normal and expanded modes */}
</ResponsiveDialog>
);
)
}
```
@@ -84,12 +84,12 @@ function MyComponent() {
title="Custom Styled Dialog"
isExpanded={showMore}
contentSx={{
backgroundColor: "#f5f5f5",
padding: 4,
backgroundColor: '#f5f5f5',
padding: 4
}}
titleSx={{
backgroundColor: "primary.main",
color: "white",
backgroundColor: 'primary.main',
color: 'white'
}}
>
<TextField label="Field" />
+77 -81
View File
@@ -10,13 +10,13 @@ import {
IconButton,
Stack,
SxProps,
Theme,
} from "@linagora/twake-mui";
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import CloseIcon from "@mui/icons-material/Close";
import OpenInFullIcon from "@mui/icons-material/OpenInFull";
import CozyBridge from "cozy-external-bridge";
import React, { ReactNode, useMemo } from "react";
Theme
} from '@linagora/twake-mui'
import ArrowBackIcon from '@mui/icons-material/ArrowBack'
import CloseIcon from '@mui/icons-material/Close'
import OpenInFullIcon from '@mui/icons-material/OpenInFull'
import CozyBridge from 'cozy-external-bridge'
import React, { ReactNode, useMemo } from 'react'
/**
* ResponsiveDialog - A reusable dialog component that can switch between normal and expanded modes
@@ -43,52 +43,48 @@ import React, { ReactNode, useMemo } from "react";
*/
interface ResponsiveDialogProps extends Omit<
DialogProps,
"maxWidth" | "title"
'maxWidth' | 'title'
> {
/** Whether the dialog is open */
open: boolean;
open: boolean
/** Callback fired when the dialog should be closed */
onClose: () => void;
onClose: () => void
/** Dialog title - can be string or custom ReactNode */
title: string | ReactNode;
title: string | ReactNode
/** Dialog content - form fields, text, etc. */
children: ReactNode;
children: ReactNode
/** Optional actions rendered in DialogActions (buttons, etc.) */
actions?: ReactNode;
actions?: ReactNode
/** Toggle between normal and expanded (fullscreen) mode */
isExpanded?: boolean;
isExpanded?: boolean
/** Callback when expand/collapse button is clicked (required if using isExpanded) */
onExpandToggle?: () => void;
onExpandToggle?: () => void
/** Max width in normal mode (default: "570px") */
normalMaxWidth?: string;
normalMaxWidth?: string
/** Max width of content container in expanded mode (default: "990px") */
expandedContentMaxWidth?: string;
expandedContentMaxWidth?: string
/** Height of app header to preserve visibility (default: "90px") */
headerHeight?: string;
headerHeight?: string
/** Spacing between children in normal mode (default: 2 = 16px) */
normalSpacing?: number;
normalSpacing?: number
/** Spacing between children in expanded mode (default: 2 = 16px) */
expandedSpacing?: number;
expandedSpacing?: number
/** Custom styles for DialogContent - merged with base styles */
contentSx?: SxProps<Theme>;
contentSx?: SxProps<Theme>
/** Custom styles for DialogTitle */
titleSx?: SxProps<Theme>;
titleSx?: SxProps<Theme>
/** Additional props for DialogContent (excluding sx) */
dialogContentProps?: Omit<DialogContentProps, "sx">;
dialogContentProps?: Omit<DialogContentProps, 'sx'>
/** Additional props for DialogTitle (excluding sx) */
dialogTitleProps?: Omit<DialogTitleProps, "sx">;
dialogTitleProps?: Omit<DialogTitleProps, 'sx'>
/** Whether to display dividers between title/content/actions */
dividers?: boolean;
dividers?: boolean
/** Whether to show header action icons (expand/close) in normal mode (default: true) */
showHeaderActions?: boolean;
showHeaderActions?: boolean
/** Whether to add border-top to DialogActions */
actionsBorderTop?: boolean;
actionsBorderTop?: boolean
/** Justify content alignment for DialogActions */
actionsJustifyContent?:
| "flex-start"
| "center"
| "flex-end"
| "space-between";
actionsJustifyContent?: 'flex-start' | 'center' | 'flex-end' | 'space-between'
}
function ResponsiveDialog({
@@ -99,9 +95,9 @@ function ResponsiveDialog({
actions,
isExpanded = false,
onExpandToggle,
normalMaxWidth = "570px",
expandedContentMaxWidth = "990px",
headerHeight = "70px",
normalMaxWidth = '570px',
expandedContentMaxWidth = '990px',
headerHeight = '70px',
normalSpacing = 2,
expandedSpacing = 2,
contentSx,
@@ -111,68 +107,68 @@ function ResponsiveDialog({
dividers = false,
showHeaderActions = true,
actionsBorderTop = false,
actionsJustifyContent = "flex-end",
actionsJustifyContent = 'flex-end',
sx,
...otherDialogProps
}: ResponsiveDialogProps) {
const isInIframe = useMemo(() => new CozyBridge().isInIframe(), []);
const isInIframe = useMemo(() => new CozyBridge().isInIframe(), [])
const baseSx: SxProps<Theme> = {
"& .MuiBackdrop-root": {
backgroundColor: "rgba(0, 0, 0, 0.1)",
opacity: isExpanded ? "0 !important" : undefined,
transition: isExpanded ? "none !important" : undefined,
pointerEvents: isExpanded ? "none" : undefined,
'& .MuiBackdrop-root': {
backgroundColor: 'rgba(0, 0, 0, 0.1)',
opacity: isExpanded ? '0 !important' : undefined,
transition: isExpanded ? 'none !important' : undefined,
pointerEvents: isExpanded ? 'none' : undefined
},
"& .MuiDialog-paper": {
maxWidth: isExpanded ? "100%" : normalMaxWidth,
width: "100%",
'& .MuiDialog-paper': {
maxWidth: isExpanded ? '100%' : normalMaxWidth,
width: '100%',
height: isExpanded
? `calc(100vh - ${isInIframe ? "0px" : headerHeight})`
? `calc(100vh - ${isInIframe ? '0px' : headerHeight})`
: undefined,
maxHeight: isExpanded && isInIframe ? "100%" : undefined,
margin: isExpanded ? `${isInIframe ? 0 : headerHeight} 0 0 0` : "32px",
boxShadow: isExpanded ? "none !important" : undefined,
transition: isExpanded ? "none !important" : undefined,
zIndex: isExpanded ? 1200 : 1300,
maxHeight: isExpanded && isInIframe ? '100%' : undefined,
margin: isExpanded ? `${isInIframe ? 0 : headerHeight} 0 0 0` : '32px',
boxShadow: isExpanded ? 'none !important' : undefined,
transition: isExpanded ? 'none !important' : undefined,
zIndex: isExpanded ? 1200 : 1300
},
"& .MuiDialogActions-root .MuiBox-root": {
'& .MuiDialogActions-root .MuiBox-root': {
maxWidth: isExpanded ? expandedContentMaxWidth : undefined,
margin: isExpanded ? "0 auto" : undefined,
padding: "0",
width: isExpanded ? "100%" : undefined,
justifyContent: isExpanded ? "flex-end" : undefined,
},
};
margin: isExpanded ? '0 auto' : undefined,
padding: '0',
width: isExpanded ? '100%' : undefined,
justifyContent: isExpanded ? 'flex-end' : undefined
}
}
const baseContentSx: SxProps<Theme> = {
width: "100%",
};
width: '100%'
}
const contentWrapperSx: SxProps<Theme> = {
maxWidth: isExpanded ? expandedContentMaxWidth : "100%",
margin: isExpanded ? "0 auto" : "0",
width: "100%",
};
maxWidth: isExpanded ? expandedContentMaxWidth : '100%',
margin: isExpanded ? '0 auto' : '0',
width: '100%'
}
React.useEffect(() => {
if (isExpanded) {
document.body.classList.add("fullscreen-view");
document.body.classList.add('fullscreen-view')
} else {
document.body.classList.remove("fullscreen-view");
document.body.classList.remove('fullscreen-view')
}
}, [isExpanded]);
}, [isExpanded])
const handleClose = (
event: unknown,
reason: "backdropClick" | "escapeKeyDown"
reason: 'backdropClick' | 'escapeKeyDown'
) => {
if (isExpanded && reason === "backdropClick") {
return;
if (isExpanded && reason === 'backdropClick') {
return
}
onClose();
};
onClose()
}
const currentSpacing = isExpanded ? expandedSpacing : normalSpacing;
const currentSpacing = isExpanded ? expandedSpacing : normalSpacing
return (
<Dialog
@@ -190,9 +186,9 @@ function ResponsiveDialog({
<IconButton
onClick={onExpandToggle}
aria-label="show less"
sx={{ marginLeft: "-8px" }}
sx={{ marginLeft: '-8px' }}
>
<ArrowBackIcon sx={{ color: "#605D62", fontSize: 30 }} />
<ArrowBackIcon sx={{ color: '#605D62', fontSize: 30 }} />
</IconButton>
) : showHeaderActions ? (
<Box
@@ -210,7 +206,7 @@ function ResponsiveDialog({
size="small"
sx={{ marginRight: 1 }}
>
<OpenInFullIcon sx={{ padding: "2px" }} />
<OpenInFullIcon sx={{ padding: '2px' }} />
</IconButton>
)}
<IconButton onClick={onClose} aria-label="close" size="small">
@@ -226,7 +222,7 @@ function ResponsiveDialog({
dividers={dividers}
sx={[
baseContentSx,
...(Array.isArray(contentSx) ? contentSx : [contentSx]),
...(Array.isArray(contentSx) ? contentSx : [contentSx])
]}
{...dialogContentProps}
>
@@ -242,16 +238,16 @@ function ResponsiveDialog({
<DialogActions
sx={{
borderTop: actionsBorderTop
? (theme) => `1px solid ${theme.palette.divider}`
? theme => `1px solid ${theme.palette.divider}`
: undefined,
justifyContent: actionsJustifyContent,
justifyContent: actionsJustifyContent
}}
>
{actions}
</DialogActions>
)}
</Dialog>
);
)
}
export default ResponsiveDialog;
export default ResponsiveDialog
+1 -1
View File
@@ -1 +1 @@
export { default as ResponsiveDialog } from "./ResponsiveDialog";
export { default as ResponsiveDialog } from './ResponsiveDialog'
+34 -34
View File
@@ -1,43 +1,43 @@
import { useAppDispatch, useAppSelector } from "@/app/hooks";
import { useAppDispatch, useAppSelector } from '@/app/hooks'
import {
Box,
Button,
Fade,
Paper,
Stack,
Typography,
} from "@linagora/twake-mui";
import ErrorOutlineIcon from "@mui/icons-material/ErrorOutline";
import ReplayIcon from "@mui/icons-material/Replay";
import { useEffect, useRef } from "react";
import { push } from "redux-first-history";
import { useI18n } from "twake-i18n";
Typography
} from '@linagora/twake-mui'
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'
import ReplayIcon from '@mui/icons-material/Replay'
import { useEffect, useRef } from 'react'
import { push } from 'redux-first-history'
import { useI18n } from 'twake-i18n'
export function Error() {
const { t } = useI18n();
const dispatch = useAppDispatch();
const userError = useAppSelector((state) => state.user.error);
const calendarError = useAppSelector((state) => state.calendars.error);
const initialUserError = useRef(userError);
const { t } = useI18n()
const dispatch = useAppDispatch()
const userError = useAppSelector(state => state.user.error)
const calendarError = useAppSelector(state => state.calendars.error)
const initialUserError = useRef(userError)
useEffect(() => {
if (!initialUserError.current) {
dispatch(push("/"));
dispatch(push('/'))
}
}, [dispatch]);
}, [dispatch])
const errorMessage = userError || calendarError || t("error.unknown");
const errorMessage = userError || calendarError || t('error.unknown')
return (
<Fade in timeout={500}>
<Box
sx={{
minHeight: "100vh",
display: "flex",
alignItems: "center",
justifyContent: "center",
bgcolor: "background.default",
p: 3,
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'background.default',
p: 3
}}
>
<Paper
@@ -45,28 +45,28 @@ export function Error() {
sx={{
borderRadius: 4,
p: 6,
textAlign: "center",
textAlign: 'center',
maxWidth: 420,
width: "100%",
width: '100%'
}}
>
<Stack spacing={2} alignItems="center">
<Box
sx={{
color: "error.main",
borderRadius: "50%",
color: 'error.main',
borderRadius: '50%',
width: 72,
height: 72,
display: "flex",
alignItems: "center",
justifyContent: "center",
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
<ErrorOutlineIcon sx={{ fontSize: 40 }} />
</Box>
<Typography variant="h5" fontWeight={600}>
{t("error.title")}
{t('error.title')}
</Typography>
<Typography variant="body1" color="text.secondary" sx={{ mb: 2 }}>
@@ -79,19 +79,19 @@ export function Error() {
startIcon={<ReplayIcon />}
onClick={() => window.location.reload()}
sx={{
textTransform: "none",
textTransform: 'none',
fontWeight: 600,
borderRadius: 2,
px: 3,
py: 1,
boxShadow: "none",
boxShadow: 'none'
}}
>
{t("error.retry")}
{t('error.retry')}
</Button>
</Stack>
</Paper>
</Box>
</Fade>
);
)
}
+40 -40
View File
@@ -1,109 +1,109 @@
import { useAppDispatch } from "@/app/hooks";
import { clearError as calendarClearError } from "@/features/Calendars/CalendarSlice";
import { clearError as userClearError } from "@/features/User/userSlice";
import { Alert, Button, Snackbar } from "@linagora/twake-mui";
import { useI18n } from "twake-i18n";
import { useAppDispatch } from '@/app/hooks'
import { clearError as calendarClearError } from '@/features/Calendars/CalendarSlice'
import { clearError as userClearError } from '@/features/User/userSlice'
import { Alert, Button, Snackbar } from '@linagora/twake-mui'
import { useI18n } from 'twake-i18n'
export function ErrorSnackbar({
error,
type,
type
}: {
error: string | null;
type: "user" | "calendar";
error: string | null
type: 'user' | 'calendar'
}) {
const { t } = useI18n();
const dispatch = useAppDispatch();
const { t } = useI18n()
const dispatch = useAppDispatch()
const handleCloseSnackbar = () => {
dispatch(type === "calendar" ? calendarClearError() : userClearError());
};
dispatch(type === 'calendar' ? calendarClearError() : userClearError())
}
const getErrorMessage = () => {
if (!error) return t("error.unknown");
if (!error) return t('error.unknown')
// Check if error message is a translation key with params
// Format: TRANSLATION:key|param1=value1|param2=value2
if (error.startsWith("TRANSLATION:")) {
const parts = error.substring(12).split("|");
const translationKey = parts[0];
const params: Record<string, string> = {};
if (error.startsWith('TRANSLATION:')) {
const parts = error.substring(12).split('|')
const translationKey = parts[0]
const params: Record<string, string> = {}
for (let i = 1; i < parts.length; i++) {
const equalIndex = parts[i].indexOf("=");
if (equalIndex === -1) continue;
const key = parts[i].substring(0, equalIndex);
const value = parts[i].substring(equalIndex + 1);
const equalIndex = parts[i].indexOf('=')
if (equalIndex === -1) continue
const key = parts[i].substring(0, equalIndex)
const value = parts[i].substring(equalIndex + 1)
if (key && value) {
try {
params[key] = decodeURIComponent(value);
params[key] = decodeURIComponent(value)
} catch {
params[key] = value;
params[key] = value
}
}
}
return t(translationKey, params);
return t(translationKey, params)
}
return error;
};
return error
}
return (
<Snackbar
open={!!error}
onClose={handleCloseSnackbar}
anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert
severity="error"
onClose={handleCloseSnackbar}
sx={{ width: "100%" }}
sx={{ width: '100%' }}
action={
<Button color="inherit" size="small" onClick={handleCloseSnackbar}>
{t("common.ok")}
{t('common.ok')}
</Button>
}
>
{getErrorMessage()}
</Alert>
</Snackbar>
);
)
}
export function EventErrorSnackbar({
messages,
onClose,
onClose
}: {
messages: string[];
onClose: () => void;
messages: string[]
onClose: () => void
}) {
const { t } = useI18n();
const open = messages.length > 0;
const { t } = useI18n()
const open = messages.length > 0
const summary =
messages.length === 1
? messages[0]
: t("error.multipleEvents", { count: messages.length });
: t('error.multipleEvents', { count: messages.length })
return (
<Snackbar
open={open}
autoHideDuration={6000}
onClose={onClose}
anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert
severity="error"
onClose={onClose}
sx={{ width: "100%" }}
sx={{ width: '100%' }}
action={
<Button color="inherit" size="small" onClick={onClose}>
{t("common.ok")}
{t('common.ok')}
</Button>
}
>
{summary}
</Alert>
</Snackbar>
);
)
}
+11 -11
View File
@@ -1,27 +1,27 @@
export class EventErrorHandler {
private errors = new Map<string, string>();
private reportedEvents = new Set<string>();
private onErrorCallback?: (messages: string[]) => void;
private errors = new Map<string, string>()
private reportedEvents = new Set<string>()
private onErrorCallback?: (messages: string[]) => void
setErrorCallback(callback: (messages: string[]) => void) {
this.onErrorCallback = callback;
this.onErrorCallback = callback
}
reportError(eventId: string, message: string) {
if (!this.reportedEvents.has(eventId)) {
this.reportedEvents.add(eventId);
this.errors.set(eventId, message);
console.warn(`[EventErrorHandler] ${eventId}: ${message}`);
this.emit();
this.reportedEvents.add(eventId)
this.errors.set(eventId, message)
console.warn(`[EventErrorHandler] ${eventId}: ${message}`)
this.emit()
}
}
clearAll() {
this.errors.clear();
this.emit();
this.errors.clear()
this.emit()
}
private emit() {
this.onErrorCallback?.(Array.from(this.errors.values()));
this.onErrorCallback?.(Array.from(this.errors.values()))
}
}
+31 -31
View File
@@ -1,35 +1,35 @@
import { TextField } from "@linagora/twake-mui";
import { Notes as NotesIcon } from "@mui/icons-material";
import React from "react";
import { useI18n } from "twake-i18n";
import { FieldWithLabel } from "./components/FieldWithLabel";
import { SectionPreviewRow } from "./components/SectionPreviewRow";
import { TextField } from '@linagora/twake-mui'
import { Notes as NotesIcon } from '@mui/icons-material'
import React from 'react'
import { useI18n } from 'twake-i18n'
import { FieldWithLabel } from './components/FieldWithLabel'
import { SectionPreviewRow } from './components/SectionPreviewRow'
export function AddDescButton({
showDescription,
setShowDescription,
showMore,
description,
setDescription,
setDescription
}: {
showDescription: boolean;
setShowDescription: (b: boolean) => void;
showMore: boolean;
description: string;
setDescription: (d: string) => void;
showDescription: boolean
setShowDescription: (b: boolean) => void
showMore: boolean
description: string
setDescription: (d: string) => void
}) {
const { t } = useI18n();
const descriptionInputRef = React.useRef<HTMLTextAreaElement>(null);
const { t } = useI18n()
const descriptionInputRef = React.useRef<HTMLTextAreaElement>(null)
React.useEffect(() => {
if (showDescription) {
descriptionInputRef.current?.focus();
descriptionInputRef.current?.focus()
}
}, [showDescription]);
}, [showDescription])
const descriptionField = (
<FieldWithLabel
label={t("event.form.description")}
label={t('event.form.description')}
isExpanded={showMore}
sx={{ padding: 0, margin: 0 }}
>
@@ -37,31 +37,31 @@ export function AddDescButton({
fullWidth
label=""
inputRef={descriptionInputRef}
inputProps={{ "aria-label": t("event.form.description") }}
placeholder={t("event.form.descriptionPlaceholder")}
inputProps={{ 'aria-label': t('event.form.description') }}
placeholder={t('event.form.descriptionPlaceholder')}
value={description}
onChange={(e) => setDescription(e.target.value)}
onChange={e => setDescription(e.target.value)}
size="small"
margin="dense"
multiline
minRows={2}
maxRows={10}
sx={{
"& .MuiInputBase-root": {
maxHeight: "33%",
overflowY: "auto",
padding: 0,
},
"& textarea": {
resize: "vertical",
'& .MuiInputBase-root': {
maxHeight: '33%',
overflowY: 'auto',
padding: 0
},
'& textarea': {
resize: 'vertical'
}
}}
/>
</FieldWithLabel>
);
)
if (showMore) {
return descriptionField;
return descriptionField
}
return (
@@ -72,11 +72,11 @@ export function AddDescButton({
icon={<NotesIcon />}
onClick={() => setShowDescription(true)}
>
{t("event.form.addDescription")}
{t('event.form.addDescription')}
</SectionPreviewRow>
</FieldWithLabel>
)}
{showDescription && descriptionField}
</>
);
)
}
+29 -29
View File
@@ -7,64 +7,64 @@ import {
DialogTitle,
FormControlLabel,
Radio,
RadioGroup,
} from "@linagora/twake-mui";
import { useState } from "react";
import { useI18n } from "twake-i18n";
RadioGroup
} from '@linagora/twake-mui'
import { useState } from 'react'
import { useI18n } from 'twake-i18n'
export function EditModeDialog({
type,
setOpen,
eventAction,
eventAction
}: {
type: string | null;
setOpen: (e: string | null) => void;
eventAction: (type: "solo" | "all" | undefined) => void;
type: string | null
setOpen: (e: string | null) => void
eventAction: (type: 'solo' | 'all' | undefined) => void
}) {
const { t } = useI18n();
const [typeOfAction, setTypeOfAction] = useState<"solo" | "all" | undefined>(
"solo"
);
const handleEvent = async () => {
eventAction(typeOfAction);
handleClose();
};
const { t } = useI18n()
const [typeOfAction, setTypeOfAction] = useState<'solo' | 'all' | undefined>(
'solo'
)
const handleEvent = () => {
eventAction(typeOfAction)
handleClose()
}
const handleClose = () => {
setOpen(null);
setTypeOfAction("solo");
};
setOpen(null)
setTypeOfAction('solo')
}
return (
<Dialog open={Boolean(type)} onClose={handleClose}>
<DialogTitle>
{type === "edit" && t("editModeDialog.updateRecurrentEvent")}
{type === "delete" && t("editModeDialog.deleteRecurrentEvent")}
{type === "attendance" && t("editModeDialog.updateParticipationStatus")}
{type === 'edit' && t('editModeDialog.updateRecurrentEvent')}
{type === 'delete' && t('editModeDialog.deleteRecurrentEvent')}
{type === 'attendance' && t('editModeDialog.updateParticipationStatus')}
</DialogTitle>
<DialogContent>
<RadioGroup
value={typeOfAction}
onChange={(e) =>
setTypeOfAction(e.target.value as "solo" | "all" | undefined)
onChange={e =>
setTypeOfAction(e.target.value as 'solo' | 'all' | undefined)
}
>
<FormControlLabel
value="solo"
control={<Radio />}
label={t("editModeDialog.thisEvent")}
label={t('editModeDialog.thisEvent')}
/>
<FormControlLabel
value="all"
control={<Radio />}
label={t("editModeDialog.allEvents")}
label={t('editModeDialog.allEvents')}
/>
</RadioGroup>
</DialogContent>
<DialogActions>
<ButtonGroup>
<Button onClick={handleClose}>{t("common.cancel")}</Button>
<Button onClick={handleEvent}>{t("common.ok")}</Button>
<Button onClick={handleClose}>{t('common.cancel')}</Button>
<Button onClick={handleEvent}>{t('common.ok')}</Button>
</ButtonGroup>
</DialogActions>
</Dialog>
);
)
}
@@ -1,44 +1,44 @@
import { EventErrorHandler } from "@/components/Error/EventErrorHandler";
import { EventContentArg } from "@fullcalendar/core";
import { Card, Typography } from "@linagora/twake-mui";
import { useEffect, useRef } from "react";
import { EventErrorHandler } from '@/components/Error/EventErrorHandler'
import { EventContentArg } from '@fullcalendar/core'
import { Card, Typography } from '@linagora/twake-mui'
import { useEffect, useRef } from 'react'
export function ErrorEventChip({
event,
errorHandler,
error,
error
}: {
event: EventContentArg["event"];
errorHandler: EventErrorHandler;
error: unknown;
event: EventContentArg['event']
errorHandler: EventErrorHandler
error: unknown
}) {
const hasReported = useRef(false);
const hasReported = useRef(false)
useEffect(() => {
if (!hasReported.current) {
let message: string;
let message: string
if (error instanceof Error) {
message = error.message;
message = error.message
} else if (
typeof error === "object" &&
typeof error === 'object' &&
error !== null &&
"class" in error
'class' in error
) {
message = `${String(
(error as { class: string }).class
)} error during rendering ${event._def.extendedProps.uid || event.id}`;
)} error during rendering ${event._def.extendedProps.uid || event.id}`
} else {
message = `Unknown error during rendering ${event._def.extendedProps.uid || event.id}`;
message = `Unknown error during rendering ${event._def.extendedProps.uid || event.id}`
}
errorHandler.reportError(
event._def.extendedProps.uid || event.id,
message
);
hasReported.current = true;
)
hasReported.current = true
}
}, [event, errorHandler, error]);
}, [event, errorHandler, error])
return (
<Card
@@ -46,11 +46,11 @@ export function ErrorEventChip({
sx={{
px: 0.5,
py: 0.2,
borderRadius: "4px",
boxShadow: "none",
borderRadius: '4px',
boxShadow: 'none'
}}
>
<Typography variant="body2">{event.title}</Typography>
</Card>
);
)
}
+100 -104
View File
@@ -4,11 +4,11 @@ import {
Card,
CardContent,
CardHeader,
Typography,
} from "@linagora/twake-mui";
import { useRef } from "react";
import { stringAvatar } from "../utils/eventUtils";
import { ErrorEventChip } from "./ErrorEventChip";
Typography
} from '@linagora/twake-mui'
import { useRef } from 'react'
import { stringAvatar } from '../utils/eventUtils'
import { ErrorEventChip } from './ErrorEventChip'
import {
DisplayedIcons,
EventChipProps,
@@ -19,76 +19,71 @@ import {
getOwnerAttendee,
getTitleStyle,
IconDisplayConfig,
useCompactMode,
} from "./EventChipUtils";
useCompactMode
} from './EventChipUtils'
const PRIVATE_CLASSIFICATIONS = ["PRIVATE", "CONFIDENTIAL"];
const PRIVATE_CLASSIFICATIONS = ['PRIVATE', 'CONFIDENTIAL']
export const EVENT_DURATION = {
SHORT: 15,
MEDIUM: 30,
LONG: 60,
} as const;
LONG: 60
} as const
export function EventChip({
arg,
calendars,
tempcalendars,
errorHandler,
errorHandler
}: EventChipProps) {
const cardRef = useRef<HTMLDivElement>(null);
const showCompact = useCompactMode(cardRef);
const cardRef = useRef<HTMLDivElement>(null)
const showCompact = useCompactMode(cardRef)
const event = arg.event;
const props = event._def.extendedProps;
const {
calId,
temp,
attendee: attendees = [],
class: classification,
} = props;
const event = arg.event
const props = event._def.extendedProps
const { calId, temp, attendee: attendees = [], class: classification } = props
try {
// Calendar validation
const calendarsSource = temp ? tempcalendars : calendars;
const calendar = calendarsSource[calId];
if (!calendar) return null;
const calendarsSource = temp ? tempcalendars : calendars
const calendar = calendarsSource[calId]
if (!calendar) return null
// Event properties
const isPrivate = PRIVATE_CLASSIFICATIONS.includes(classification);
const isPrivate = PRIVATE_CLASSIFICATIONS.includes(classification)
const ownerEmails = new Set(
calendar.owner?.emails?.map((e) => e.toLowerCase())
);
calendar.owner?.emails?.map(e => e.toLowerCase())
)
// const delegated = calendar.delegated;
// Determine owner attendee
const ownerAttendee = getOwnerAttendee(attendees, ownerEmails);
const ownerAttendee = getOwnerAttendee(attendees, ownerEmails)
// Color and contrast logic
const bestColor = getBestColor(
(calendar.color as { light: string; dark: string }) ?? {
light: "#fff",
dark: "#000",
light: '#fff',
dark: '#000'
}
);
)
// Icon display configuration
const IconDisplayed: IconDisplayConfig = {
declined: ownerAttendee?.partstat === "DECLINED",
tentative: ownerAttendee?.partstat === "TENTATIVE",
needAction: ownerAttendee?.partstat === "NEEDS-ACTION",
private: isPrivate,
};
declined: ownerAttendee?.partstat === 'DECLINED',
tentative: ownerAttendee?.partstat === 'TENTATIVE',
needAction: ownerAttendee?.partstat === 'NEEDS-ACTION',
private: isPrivate
}
// View and time calculations
const isMonthView = arg.view.type === "dayGridMonth";
const timeZone = arg.view.calendar?.getOption("timeZone") || "UTC";
const { startTime, endTime } = getEventTimes(event, timeZone);
const eventLength = getEventDuration(event);
const isMonthView = arg.view.type === 'dayGridMonth'
const timeZone = arg.view.calendar?.getOption('timeZone') || 'UTC'
const { startTime, endTime } = getEventTimes(event, timeZone)
const eventLength = getEventDuration(event)
const isMoreThan15 = eventLength > EVENT_DURATION.SHORT;
const isMoreThan30 = eventLength > EVENT_DURATION.MEDIUM;
const isMoreThan60 = eventLength > EVENT_DURATION.LONG;
const isMoreThan15 = eventLength > EVENT_DURATION.SHORT
const isMoreThan30 = eventLength > EVENT_DURATION.MEDIUM
const isMoreThan60 = eventLength > EVENT_DURATION.LONG
// Style calculation
const titleStyle = getTitleStyle(
@@ -96,7 +91,7 @@ export function EventChip({
ownerAttendee?.partstat,
calendar,
isPrivate
);
)
const cardStyle = getCardStyle(
bestColor,
@@ -104,7 +99,7 @@ export function EventChip({
ownerAttendee?.partstat,
calendar,
isPrivate
);
)
// Organizer avatar
const OrganizerAvatar = event._def.extendedProps.organizer
@@ -112,28 +107,28 @@ export function EventChip({
event._def.extendedProps.organizer?.cn ??
event._def.extendedProps.organizer?.cal_address
)
: { color: undefined, children: null };
: { color: undefined, children: null }
return (
<Card
variant="outlined"
style={{
...cardStyle,
...(!isMoreThan15 || isMonthView ? { height: "auto" } : {}),
...(!isMoreThan15 || isMonthView ? { height: 'auto' } : {}),
...(!isMoreThan15 && !isMonthView
? { transform: "translateY(2px)" }
: {}),
? { transform: 'translateY(2px)' }
: {})
}}
ref={cardRef}
data-testid={`event-card-${event._def.extendedProps.uid}`}
>
<CardHeader
sx={{
py: "0px",
px: "0px",
"& .MuiCardHeader-content": {
overflow: "hidden",
},
py: '0px',
px: '0px',
'& .MuiCardHeader-content': {
overflow: 'hidden'
}
}}
title={
showCompact ? (
@@ -143,14 +138,14 @@ export function EventChip({
) : (
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
width: "100%",
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
width: '100%'
}}
>
<Box
sx={{ display: "flex", alignItems: "center", minWidth: 0 }}
sx={{ display: 'flex', alignItems: 'center', minWidth: 0 }}
>
{(!isMoreThan30 || isMonthView) &&
!event._def.extendedProps.allday && (
@@ -159,12 +154,12 @@ export function EventChip({
className="compactStartTime"
style={{
...titleStyle,
textDecoration: "none",
overflow: "visible",
opacity: "70%",
letterSpacing: "0%",
fontSize: "10px",
marginRight: "4px",
textDecoration: 'none',
overflow: 'visible',
opacity: '70%',
letterSpacing: '0%',
fontSize: '10px',
marginRight: '4px'
}}
>
{startTime}
@@ -185,13 +180,13 @@ export function EventChip({
<Typography
style={{
color: titleStyle.color,
opacity: "70%",
fontFamily: "Inter",
fontWeight: "500",
fontSize: "10px",
lineHeight: "16px",
letterSpacing: "0%",
verticalAlign: "middle",
opacity: '70%',
fontFamily: 'Inter',
fontWeight: '500',
fontSize: '10px',
lineHeight: '16px',
letterSpacing: '0%',
verticalAlign: 'middle'
}}
>
{startTime} {!showCompact && ` - ${endTime}`}
@@ -206,50 +201,50 @@ export function EventChip({
<CardContent
sx={{
p: 0,
"& .MuiCardContent-content": {
overflow: "hidden",
},
'& .MuiCardContent-content': {
overflow: 'hidden'
}
}}
>
{event._def.extendedProps.location && (
<Typography
style={{
marginRight: 2,
fontFamily: "Roboto",
fontWeight: "500",
fontStyle: "Medium",
fontSize: "12px",
lineHeight: "16px",
letterSpacing: "0%",
verticalAlign: "middle",
fontFamily: 'Roboto',
fontWeight: '500',
fontStyle: 'Medium',
fontSize: '12px',
lineHeight: '16px',
letterSpacing: '0%',
verticalAlign: 'middle',
color: titleStyle.color,
textOverflow: "ellipsis",
overflow: "hidden",
whiteSpace: "nowrap",
textOverflow: 'ellipsis',
overflow: 'hidden',
whiteSpace: 'nowrap'
}}
>
{event._def.extendedProps.location}
</Typography>
)}
<Box sx={{ display: "flex", alignItems: "flex-start", mt: 0.5 }}>
<Box sx={{ display: 'flex', alignItems: 'flex-start', mt: 0.5 }}>
{event._def.extendedProps.description && (
<Typography
sx={{
fontFamily: "Roboto",
fontFamily: 'Roboto',
fontWeight: 500,
fontSize: "10px",
lineHeight: "16px",
letterSpacing: "0%",
fontSize: '10px',
lineHeight: '16px',
letterSpacing: '0%',
opacity: 0.8,
overflow: "hidden",
textOverflow: "ellipsis",
display: "-webkit-box",
overflow: 'hidden',
textOverflow: 'ellipsis',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: "vertical",
whiteSpace: "normal",
WebkitBoxOrient: 'vertical',
whiteSpace: 'normal',
flex: 1,
maxWidth: "75%",
color: titleStyle.color,
maxWidth: '75%',
color: titleStyle.color
}}
>
{event._def.extendedProps.description}
@@ -265,23 +260,24 @@ export function EventChip({
!showCompact &&
window.displayOrgAvatar && (
<Avatar
children={OrganizerAvatar.children}
color={OrganizerAvatar.color}
size="xs"
sx={{
bottom: 0,
right: 0,
margin: "8px",
position: "absolute",
border: "2px solid white",
margin: '8px',
position: 'absolute',
border: '2px solid white'
}}
/>
>
{OrganizerAvatar.children}
</Avatar>
)}
</Card>
);
)
} catch (e) {
return (
<ErrorEventChip event={event} errorHandler={errorHandler} error={e} />
);
)
}
}
+114 -116
View File
@@ -1,56 +1,54 @@
import { EventErrorHandler } from "@/components/Error/EventErrorHandler";
import { Calendar } from "@/features/Calendars/CalendarTypes";
import { userAttendee } from "@/features/User/models/attendee";
import { EventContentArg } from "@fullcalendar/core";
import { getContrastRatio } from "@linagora/twake-mui";
import CancelIcon from "@mui/icons-material/Cancel";
import HelpOutlineIcon from "@mui/icons-material/HelpOutline";
import LockOutlineIcon from "@mui/icons-material/LockOutline";
import moment from "moment";
import React, { useLayoutEffect, useState } from "react";
import { EVENT_DURATION } from "./EventChip";
import { EventErrorHandler } from '@/components/Error/EventErrorHandler'
import { Calendar } from '@/features/Calendars/CalendarTypes'
import { userAttendee } from '@/features/User/models/attendee'
import { EventContentArg } from '@fullcalendar/core'
import { getContrastRatio } from '@linagora/twake-mui'
import CancelIcon from '@mui/icons-material/Cancel'
import HelpOutlineIcon from '@mui/icons-material/HelpOutline'
import LockOutlineIcon from '@mui/icons-material/LockOutline'
import moment from 'moment'
import React, { useLayoutEffect, useState } from 'react'
import { EVENT_DURATION } from './EventChip'
const COMPACT_WIDTH_THRESHOLD = 100;
const COMPACT_WIDTH_THRESHOLD = 100
export interface EventChipProps {
arg: EventContentArg["event"];
calendars: Record<string, Calendar>;
tempcalendars: Record<string, Calendar>;
errorHandler: EventErrorHandler;
arg: EventContentArg['event']
calendars: Record<string, Calendar>
tempcalendars: Record<string, Calendar>
errorHandler: EventErrorHandler
}
export interface IconDisplayConfig {
declined: boolean;
tentative: boolean;
needAction: boolean;
private: boolean;
declined: boolean
tentative: boolean
needAction: boolean
private: boolean
}
export function getEventDuration(event: EventContentArg["event"]): number {
export function getEventDuration(event: EventContentArg['event']): number {
return moment(event._instance?.range.end).diff(
moment(event._instance?.range.start),
"minutes"
);
'minutes'
)
}
export function getBestColor(colors: { light: string; dark: string }): string {
const contrastToDark = getContrastRatio(colors?.dark, "#fff");
const contrastToLight = getContrastRatio(colors?.light, "#fff");
return contrastToDark > contrastToLight ? colors?.dark : colors?.light;
const contrastToDark = getContrastRatio(colors?.dark, '#fff')
const contrastToLight = getContrastRatio(colors?.light, '#fff')
return contrastToDark > contrastToLight ? colors?.dark : colors?.light
}
export function getEventTimes(
event: EventContentArg["event"],
event: EventContentArg['event'],
timeZone: string
) {
return {
startTime: moment.tz(event.start, timeZone).format("HH:mm"),
endTime: moment.tz(event.end, timeZone).format("HH:mm"),
};
startTime: moment.tz(event.start, timeZone).format('HH:mm'),
endTime: moment.tz(event.end, timeZone).format('HH:mm')
}
}
export function getOwnerAttendee(
attendees: userAttendee[],
ownerEmails: Set<string>
): userAttendee | undefined {
return attendees.find((att) =>
ownerEmails.has(att.cal_address.toLowerCase())
);
return attendees.find(att => ownerEmails.has(att.cal_address.toLowerCase()))
}
export function getTitleStyle(
@@ -60,41 +58,41 @@ export function getTitleStyle(
isPrivate?: boolean
): React.CSSProperties {
const baseStyle: React.CSSProperties = {
fontFamily: "Roboto",
fontWeight: "500",
fontStyle: "Medium",
fontSize: "12px",
lineHeight: "16px",
letterSpacing: "0.5px",
verticalAlign: "middle",
overflow: "hidden",
textOverflow: "ellipsis",
font: "Roboto",
whiteSpace: "nowrap",
color: bestColor,
};
fontFamily: 'Roboto',
fontWeight: '500',
fontStyle: 'Medium',
fontSize: '12px',
lineHeight: '16px',
letterSpacing: '0.5px',
verticalAlign: 'middle',
overflow: 'hidden',
textOverflow: 'ellipsis',
font: 'Roboto',
whiteSpace: 'nowrap',
color: bestColor
}
switch (partstat) {
case "DECLINED":
return { ...baseStyle, textDecoration: "line-through" };
case "TENTATIVE":
case "ACCEPTED":
return { ...baseStyle, color: calendar?.color?.dark };
case "NEEDS-ACTION":
return baseStyle;
case 'DECLINED':
return { ...baseStyle, textDecoration: 'line-through' }
case 'TENTATIVE':
case 'ACCEPTED':
return { ...baseStyle, color: calendar?.color?.dark }
case 'NEEDS-ACTION':
return baseStyle
default:
if (isPrivate) {
return { ...baseStyle, color: calendar?.color?.dark };
return { ...baseStyle, color: calendar?.color?.dark }
}
return baseStyle;
return baseStyle
}
}
function getEventVariant(duration: number) {
if (duration <= EVENT_DURATION.SHORT) return "short";
if (duration <= EVENT_DURATION.MEDIUM) return "medium";
if (duration <= EVENT_DURATION.LONG) return "long";
return "extraLong";
if (duration <= EVENT_DURATION.SHORT) return 'short'
if (duration <= EVENT_DURATION.MEDIUM) return 'medium'
if (duration <= EVENT_DURATION.LONG) return 'long'
return 'extraLong'
}
function getCardVariantStyle(
@@ -102,27 +100,27 @@ function getCardVariantStyle(
baseColor: string
): React.CSSProperties {
const shared: React.CSSProperties = {
width: "100%",
height: "100%",
borderRadius: "8px",
boxShadow: "none",
width: '100%',
height: '100%',
borderRadius: '8px',
boxShadow: 'none',
border: `1px solid ${baseColor}`,
color: baseColor,
display: "flex",
flexDirection: "column",
justifyContent: "flex-start",
};
display: 'flex',
flexDirection: 'column',
justifyContent: 'flex-start'
}
switch (variant) {
case "short":
return { ...shared, padding: "0px 6px", justifyContent: "center" };
case "medium":
return { ...shared, padding: "4px 6px", justifyContent: "center" };
case 'short':
return { ...shared, padding: '0px 6px', justifyContent: 'center' }
case 'medium':
return { ...shared, padding: '4px 6px', justifyContent: 'center' }
default:
return {
...shared,
padding: "4px 6px",
};
padding: '4px 6px'
}
}
}
@@ -136,40 +134,40 @@ export function getCardStyle(
const baseStyle: React.CSSProperties = getCardVariantStyle(
getEventVariant(eventLength),
bestColor
);
)
switch (partstat) {
case "DECLINED":
return baseStyle;
case "TENTATIVE":
case 'DECLINED':
return baseStyle
case 'TENTATIVE':
return {
...baseStyle,
backgroundColor: calendar?.color?.light,
color: calendar?.color?.dark,
border: "1px solid white",
};
case "NEEDS-ACTION":
border: '1px solid white'
}
case 'NEEDS-ACTION':
return {
...baseStyle,
backgroundColor: "#fff",
};
case "ACCEPTED":
backgroundColor: '#fff'
}
case 'ACCEPTED':
return {
...baseStyle,
backgroundColor: calendar?.color?.light,
color: calendar?.color?.dark,
border: "1px solid white",
};
border: '1px solid white'
}
default:
if (isPrivate) {
return {
...baseStyle,
backgroundColor: calendar?.color?.light,
color: calendar?.color?.dark,
border: "1px solid white",
};
border: '1px solid white'
}
}
return baseStyle;
return baseStyle
}
}
@@ -177,26 +175,26 @@ export function DisplayedIcons(
IconDisplayed: IconDisplayConfig,
iconColor?: string
) {
if (!Object.values(IconDisplayed).find((b) => b === true)) return;
if (!Object.values(IconDisplayed).find(b => b === true)) return
const iconStyle: React.CSSProperties = {
fontSize: "15px",
color: iconColor || "inherit",
marginRight: 2,
};
fontSize: '15px',
color: iconColor || 'inherit',
marginRight: 2
}
return (
<span
className="event-chip-icons"
style={{
display: "inline-flex",
flexDirection: "row",
gap: "1px",
color: iconColor || "inherit",
display: 'inline-flex',
flexDirection: 'row',
gap: '1px',
color: iconColor || 'inherit',
margin: 0,
marginRight: "4px",
fontFamily: "inherit",
fontWeight: "inherit",
lineHeight: "inherit",
letterSpacing: "inherit",
marginRight: '4px',
fontFamily: 'inherit',
fontWeight: 'inherit',
lineHeight: 'inherit',
letterSpacing: 'inherit'
}}
>
{IconDisplayed.needAction && <HelpOutlineIcon style={iconStyle} />}
@@ -204,34 +202,34 @@ export function DisplayedIcons(
{IconDisplayed.tentative && <HelpOutlineIcon style={iconStyle} />}
{IconDisplayed.private && <LockOutlineIcon style={iconStyle} />}
</span>
);
)
}
export function useCompactMode(
cardRef: React.RefObject<HTMLDivElement | null>
): boolean {
const [showCompact, setShowCompact] = useState(false);
const [showCompact, setShowCompact] = useState(false)
useLayoutEffect(() => {
if (!cardRef.current) return;
if (!cardRef.current) return
const checkWidth = () => {
const width = cardRef.current?.offsetWidth ?? 0;
const newCompact = width < COMPACT_WIDTH_THRESHOLD;
const width = cardRef.current?.offsetWidth ?? 0
const newCompact = width < COMPACT_WIDTH_THRESHOLD
setShowCompact((prev) => (prev !== newCompact ? newCompact : prev));
};
setShowCompact(prev => (prev !== newCompact ? newCompact : prev))
}
checkWidth();
checkWidth()
const resizeObserver = new ResizeObserver(() => {
requestAnimationFrame(checkWidth);
});
requestAnimationFrame(checkWidth)
})
resizeObserver.observe(cardRef.current);
resizeObserver.observe(cardRef.current)
return () => resizeObserver.disconnect();
}, [cardRef]);
return () => resizeObserver.disconnect()
}, [cardRef])
return showCompact;
return showCompact
}
@@ -1,27 +1,27 @@
import { Card, Typography } from "@linagora/twake-mui";
import { Card, Typography } from '@linagora/twake-mui'
export function SimpleEventChip({ title }: { title: string }) {
return (
<Card
variant="outlined"
sx={{
borderRadius: "4px",
borderRadius: '4px',
px: 0.5,
py: 0.2,
boxShadow: "none",
boxShadow: 'none'
}}
>
<Typography
variant="body2"
sx={{
fontSize: "0.75rem",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
fontSize: '0.75rem',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}
>
{title}
</Typography>
</Card>
);
)
}
+8 -8
View File
@@ -1,19 +1,19 @@
import { MenuItem } from "@linagora/twake-mui";
import { useI18n } from "twake-i18n";
import { MenuItem } from '@linagora/twake-mui'
import { useI18n } from 'twake-i18n'
export default function EventDuplication({
onOpenDuplicate,
onOpenDuplicate
}: {
onOpenDuplicate?: () => void;
onOpenDuplicate?: () => void
}) {
const { t } = useI18n();
const { t } = useI18n()
return (
<MenuItem
onClick={() => {
onOpenDuplicate?.();
onOpenDuplicate?.()
}}
>
{t("eventDuplication.duplicateEvent")}
{t('eventDuplication.duplicateEvent')}
</MenuItem>
);
)
}
File diff suppressed because it is too large Load Diff
+105 -105
View File
@@ -1,4 +1,4 @@
import { RepetitionObject } from "@/features/Events/EventsTypes";
import { RepetitionObject } from '@/features/Events/EventsTypes'
import {
Box,
FormControl,
@@ -10,134 +10,134 @@ import {
SelectChangeEvent,
Stack,
TextField,
Typography,
} from "@linagora/twake-mui";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import { DatePicker } from "@mui/x-date-pickers/DatePicker";
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
import dayjs from "dayjs";
import "dayjs/locale/en";
import "dayjs/locale/fr";
import "dayjs/locale/ru";
import "dayjs/locale/vi";
import { useI18n } from "twake-i18n";
import { ReadOnlyDateField } from "./components/ReadOnlyPickerField";
import { LONG_DATE_FORMAT } from "./utils/dateTimeFormatters";
import { FC_DAYS, WeekDaySelector } from "./WeekDaySelector";
Typography
} from '@linagora/twake-mui'
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'
import { DatePicker } from '@mui/x-date-pickers/DatePicker'
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'
import dayjs from 'dayjs'
import 'dayjs/locale/en'
import 'dayjs/locale/fr'
import 'dayjs/locale/ru'
import 'dayjs/locale/vi'
import { useI18n } from 'twake-i18n'
import { ReadOnlyDateField } from './components/ReadOnlyPickerField'
import { LONG_DATE_FORMAT } from './utils/dateTimeFormatters'
import { FC_DAYS, WeekDaySelector } from './WeekDaySelector'
export default function RepeatEvent({
repetition,
eventStart,
setRepetition,
isOwn = true,
isOwn = true
}: {
repetition: RepetitionObject;
eventStart: Date;
setRepetition: (repetition: RepetitionObject) => void;
isOwn?: boolean;
repetition: RepetitionObject
eventStart: Date
setRepetition: (repetition: RepetitionObject) => void
isOwn?: boolean
}) {
const { t } = useI18n();
const days = ["MO", "TU", "WE", "TH", "FR", "SA", "SU"];
const day = new Date(eventStart);
const { t } = useI18n()
const days = ['MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU']
const day = new Date(eventStart)
const dateCalendarLayoutSx = {
"& .MuiDateCalendar-root.MuiDateCalendar-root": {
width: "260px",
maxWidth: "260px",
padding: "0 15px",
},
};
'& .MuiDateCalendar-root.MuiDateCalendar-root': {
width: '260px',
maxWidth: '260px',
padding: '0 15px'
}
}
// Fully derived — occurrences takes priority over endDate
const getEndOption = () => {
if (repetition.occurrences) return "after";
if (repetition.endDate) return "on";
return "never";
};
if (repetition.occurrences) return 'after'
if (repetition.endDate) return 'on'
return 'never'
}
const endOption = getEndOption();
const endOption = getEndOption()
const defaultEndDate = dayjs(eventStart).add(1, "day").format("YYYY-MM-DD");
const defaultEndDate = dayjs(eventStart).add(1, 'day').format('YYYY-MM-DD')
return (
<Box>
<Stack>
{/* Interval */}
<Box display="flex" alignItems="center" gap={2} mb={1}>
<Typography variant="h6">{t("event.repeat.every")}</Typography>
<Typography variant="h6">{t('event.repeat.every')}</Typography>
<TextField
type="number"
value={repetition.interval ?? 1}
disabled={!isOwn}
onChange={(e) =>
onChange={e =>
setRepetition({
...repetition,
interval: Number(e.target.value),
interval: Number(e.target.value)
})
}
size="small"
style={{ width: 80 }}
inputProps={{
min: 1,
"data-testid": "repeat-interval",
'data-testid': 'repeat-interval',
style: {
textAlign: "center",
paddingRight: 5,
},
textAlign: 'center',
paddingRight: 5
}
}}
/>
<FormControl size="small" style={{ minWidth: 120 }}>
<Select
value={repetition.freq ?? "daily"}
value={repetition.freq ?? 'daily'}
disabled={!isOwn}
onChange={(e: SelectChangeEvent) => {
if (e.target.value === "weekly") {
const jsDay = day.getDay();
const icsDay = days[(jsDay + 6) % 7];
if (e.target.value === 'weekly') {
const jsDay = day.getDay()
const icsDay = days[(jsDay + 6) % 7]
setRepetition({
...repetition,
freq: e.target.value,
byday: [icsDay],
});
byday: [icsDay]
})
} else {
setRepetition({
...repetition,
freq: e.target.value,
byday: null,
});
byday: null
})
}
}}
>
<MenuItem value={"daily"}>
{t("event.repeat.frequency.days")}
<MenuItem value="daily">
{t('event.repeat.frequency.days')}
</MenuItem>
<MenuItem value={"weekly"}>
{t("event.repeat.frequency.weeks")}
<MenuItem value="weekly">
{t('event.repeat.frequency.weeks')}
</MenuItem>
<MenuItem value={"monthly"}>
{t("event.repeat.frequency.months")}
<MenuItem value="monthly">
{t('event.repeat.frequency.months')}
</MenuItem>
<MenuItem value={"yearly"}>
{t("event.repeat.frequency.years")}
<MenuItem value="yearly">
{t('event.repeat.frequency.years')}
</MenuItem>
</Select>
</FormControl>
</Box>
{/* Weekly selection */}
{repetition.freq === "weekly" && (
{repetition.freq === 'weekly' && (
<Box mb={2}>
<WeekDaySelector
selectedDays={(repetition.byday ?? [])
.map((ics) => FC_DAYS.find((d) => d.ics === ics)?.fc ?? -1)
.filter((d) => d !== -1)}
onChange={(fcDays) => {
.map(ics => FC_DAYS.find(d => d.ics === ics)?.fc ?? -1)
.filter(d => d !== -1)}
onChange={fcDays => {
const icsDays = fcDays
.map((fc) => FC_DAYS.find((d) => d.fc === fc)?.ics ?? "")
.filter(Boolean);
.map(fc => FC_DAYS.find(d => d.fc === fc)?.ics ?? '')
.filter(Boolean)
setRepetition({
...repetition,
byday: icsDays.length > 0 ? icsDays : null,
});
byday: icsDays.length > 0 ? icsDays : null
})
}}
disabled={!isOwn}
/>
@@ -147,37 +147,37 @@ export default function RepeatEvent({
{/* End options */}
<Box>
<Typography variant="h6" gutterBottom>
{t("event.repeat.end.label")}
{t('event.repeat.end.label')}
</Typography>
<RadioGroup
value={endOption}
onChange={(e) => {
const value = e.target.value;
if (value === endOption) return;
onChange={e => {
const value = e.target.value
if (value === endOption) return
if (value === "never") {
if (value === 'never') {
setRepetition({
...repetition,
occurrences: null,
endDate: null,
});
endDate: null
})
}
if (value === "after") {
if (value === 'after') {
setRepetition({
...repetition,
occurrences:
repetition.occurrences && repetition.occurrences > 0
? repetition.occurrences
: 1,
endDate: null,
});
endDate: null
})
}
if (value === "on") {
if (value === 'on') {
setRepetition({
...repetition,
occurrences: null,
endDate: repetition.endDate || defaultEndDate,
});
endDate: repetition.endDate || defaultEndDate
})
}
}}
>
@@ -187,7 +187,7 @@ export default function RepeatEvent({
control={<Radio />}
label={
<Typography variant="h6">
{t("event.repeat.end.never")}
{t('event.repeat.end.never')}
</Typography>
}
/>
@@ -200,21 +200,21 @@ export default function RepeatEvent({
label={
<Box display="flex" alignItems="center" gap={1}>
<Typography variant="h6">
{t("event.repeat.end.on")}
{t('event.repeat.end.on')}
</Typography>
<LocalizationProvider
dateAdapter={AdapterDayjs}
adapterLocale={t("locale") ?? "en"}
adapterLocale={t('locale') ?? 'en'}
>
<Box
sx={{
width: 220,
"& .MuiInputBase-root": { fontSize: 14 },
"& .MuiInputBase-input": { fontSize: 14 },
'& .MuiInputBase-root': { fontSize: 14 },
'& .MuiInputBase-input': { fontSize: 14 }
}}
>
<DatePicker
sx={{ width: "100%" }}
sx={{ width: '100%' }}
format={LONG_DATE_FORMAT}
minDate={dayjs(eventStart)}
value={
@@ -222,27 +222,27 @@ export default function RepeatEvent({
? dayjs(repetition.endDate)
: dayjs(defaultEndDate)
}
onChange={(value) => {
if (!value || !value.isValid()) return;
const newDateStr = value.format("YYYY-MM-DD");
onChange={value => {
if (!value || !value.isValid()) return
const newDateStr = value.format('YYYY-MM-DD')
setRepetition({
...repetition,
occurrences: null,
endDate: newDateStr,
});
endDate: newDateStr
})
}}
onOpen={() => {
if (!isOwn || endOption === "on") return;
if (!isOwn || endOption === 'on') return
setRepetition({
...repetition,
occurrences: null,
endDate: repetition.endDate || defaultEndDate,
});
endDate: repetition.endDate || defaultEndDate
})
}}
slots={{ field: ReadOnlyDateField }}
slotProps={{
field: {},
layout: { sx: dateCalendarLayoutSx },
layout: { sx: dateCalendarLayoutSx }
}}
disabled={!isOwn}
/>
@@ -263,38 +263,38 @@ export default function RepeatEvent({
alignItems="center"
gap={1}
onClick={() => {
if (!isOwn || endOption === "after") return;
if (!isOwn || endOption === 'after') return
setRepetition({
...repetition,
endDate: null,
occurrences:
repetition.occurrences && repetition.occurrences > 0
? repetition.occurrences
: 1,
});
: 1
})
}}
>
<Typography variant="h6">
{t("event.repeat.end.after")}
{t('event.repeat.end.after')}
</Typography>
<TextField
type="number"
inputProps={{ min: 1, "data-testid": "occurrences-input" }}
inputProps={{ min: 1, 'data-testid': 'occurrences-input' }}
size="small"
value={repetition.occurrences || 1}
onChange={(e) => {
const value = Number(e.target.value);
onChange={e => {
const value = Number(e.target.value)
setRepetition({
...repetition,
endDate: null,
occurrences: value > 0 ? value : 1,
});
occurrences: value > 0 ? value : 1
})
}}
style={{ width: 100 }}
disabled={!isOwn}
/>
<Typography variant="h6">
{t("event.repeat.end.occurrences")}
{t('event.repeat.end.occurrences')}
</Typography>
</Box>
}
@@ -303,5 +303,5 @@ export default function RepeatEvent({
</Box>
</Stack>
</Box>
);
)
}
+34 -34
View File
@@ -1,23 +1,23 @@
import { Box, Link, Typography } from "@linagora/twake-mui";
import React from "react";
import { Box, Link, Typography } from '@linagora/twake-mui'
import React from 'react'
type InfoRowProps = {
icon: React.ReactNode;
text?: string;
error?: boolean;
data?: string; // optional link target
content?: React.ReactNode; // if provided, overrides text rendering
style?: React.CSSProperties;
alignItems?: React.CSSProperties["alignItems"];
flexWrap?: React.CSSProperties["flexWrap"];
};
icon: React.ReactNode
text?: string
error?: boolean
data?: string // optional link target
content?: React.ReactNode // if provided, overrides text rendering
style?: React.CSSProperties
alignItems?: React.CSSProperties['alignItems']
flexWrap?: React.CSSProperties['flexWrap']
}
function detectUrls(text: string) {
// Simple regex that captures whole URLs without splitting them apart
const urlRegex = /(https?:\/\/[^\s]+|www\.[^\s]+)/gi;
const urlRegex = /(https?:\/\/[^\s]+|www\.[^\s]+)/gi
const parts = [];
let lastIndex = 0;
const parts = []
let lastIndex = 0
text.replace(urlRegex, (match, _, offset) => {
// Push the text before the match
@@ -26,11 +26,11 @@ function detectUrls(text: string) {
<React.Fragment key={lastIndex}>
{text.slice(lastIndex, offset)}
</React.Fragment>
);
)
}
// Normalize href
const href = match.startsWith("http") ? match : `https://${match}`;
const href = match.startsWith('http') ? match : `https://${match}`
parts.push(
<Link
key={offset}
@@ -41,20 +41,20 @@ function detectUrls(text: string) {
>
{match}
</Link>
);
)
lastIndex = offset + match.length;
return match;
});
lastIndex = offset + match.length
return match
})
// Push remaining text after last URL
if (lastIndex < text.length) {
parts.push(
<React.Fragment key={lastIndex}>{text.slice(lastIndex)}</React.Fragment>
);
)
}
return parts;
return parts
}
export function InfoRow({
@@ -64,17 +64,17 @@ export function InfoRow({
data,
content,
style,
alignItems = "center",
flexWrap = "nowrap",
alignItems = 'center',
flexWrap = 'nowrap'
}: InfoRowProps) {
return (
<Box
style={{
display: "flex",
display: 'flex',
alignItems,
gap: 1,
marginBottom: 1,
flexWrap,
flexWrap
}}
>
{icon}
@@ -83,14 +83,14 @@ export function InfoRow({
) : (
<Typography
variant="body2"
color={error ? "error" : "textPrimary"}
color={error ? 'error' : 'textPrimary'}
sx={{
wordBreak: "break-word",
whiteSpace: "pre-line",
maxHeight: "33vh",
overflowY: "auto",
width: "100%",
...style,
wordBreak: 'break-word',
whiteSpace: 'pre-line',
maxHeight: '33vh',
overflowY: 'auto',
width: '100%',
...style
}}
>
{data ? (
@@ -108,5 +108,5 @@ export function InfoRow({
</Typography>
)}
</Box>
);
)
}
+48 -48
View File
@@ -1,55 +1,55 @@
import { Box } from "@linagora/twake-mui";
import { useI18n } from "twake-i18n";
import { Box } from '@linagora/twake-mui'
import { useI18n } from 'twake-i18n'
interface WeekDaySelectorProps {
selectedDays: number[]; // FullCalendar format: 0=Sun, 1=Mon...
onChange: (days: number[]) => void;
disabled?: boolean;
selectedDays: number[] // FullCalendar format: 0=Sun, 1=Mon...
onChange: (days: number[]) => void
disabled?: boolean
}
export const FC_DAYS = [
{ fc: 1, ics: "MO" },
{ fc: 2, ics: "TU" },
{ fc: 3, ics: "WE" },
{ fc: 4, ics: "TH" },
{ fc: 5, ics: "FR" },
{ fc: 6, ics: "SA" },
{ fc: 0, ics: "SU" },
];
{ fc: 1, ics: 'MO' },
{ fc: 2, ics: 'TU' },
{ fc: 3, ics: 'WE' },
{ fc: 4, ics: 'TH' },
{ fc: 5, ics: 'FR' },
{ fc: 6, ics: 'SA' },
{ fc: 0, ics: 'SU' }
]
export function WeekDaySelector({
selectedDays,
onChange,
disabled,
disabled
}: WeekDaySelectorProps) {
const { t } = useI18n();
const { t } = useI18n()
const getDayLabel = (ics: string) => {
const dayMap: Record<string, string> = {
MO: t("event.repeat.days.monday"),
TU: t("event.repeat.days.tuesday"),
WE: t("event.repeat.days.wednesday"),
TH: t("event.repeat.days.thursday"),
FR: t("event.repeat.days.friday"),
SA: t("event.repeat.days.saturday"),
SU: t("event.repeat.days.sunday"),
};
return dayMap[ics] || ics;
};
MO: t('event.repeat.days.monday'),
TU: t('event.repeat.days.tuesday'),
WE: t('event.repeat.days.wednesday'),
TH: t('event.repeat.days.thursday'),
FR: t('event.repeat.days.friday'),
SA: t('event.repeat.days.saturday'),
SU: t('event.repeat.days.sunday')
}
return dayMap[ics] || ics
}
const handleToggle = (fcDay: number) => {
if (disabled) return;
if (disabled) return
const updated = selectedDays.includes(fcDay)
? selectedDays.filter((d) => d !== fcDay)
: [...selectedDays, fcDay];
onChange(updated);
};
? selectedDays.filter(d => d !== fcDay)
: [...selectedDays, fcDay]
onChange(updated)
}
return (
<Box display="flex" gap={1}>
{FC_DAYS.map(({ fc, ics }) => {
const isSelected = selectedDays.includes(fc);
const fullLabel = getDayLabel(ics);
const isSelected = selectedDays.includes(fc)
const fullLabel = getDayLabel(ics)
return (
<Box
@@ -63,30 +63,30 @@ export function WeekDaySelector({
sx={{
width: 40,
height: 40,
borderRadius: "4px",
border: "1px solid",
borderColor: isSelected ? "primary.main" : "#AEAEC0",
color: isSelected ? "#fff" : "#8C9CAF",
borderRadius: '4px',
border: '1px solid',
borderColor: isSelected ? 'primary.main' : '#AEAEC0',
color: isSelected ? '#fff' : '#8C9CAF',
fontSize: 16,
fontWeight: 400,
display: "flex",
alignItems: "center",
justifyContent: "center",
bgcolor: isSelected ? "primary.main" : "transparent",
cursor: disabled ? "default" : "pointer",
"&:hover": !disabled
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bgcolor: isSelected ? 'primary.main' : 'transparent',
cursor: disabled ? 'default' : 'pointer',
'&:hover': !disabled
? {
borderColor: "primary.main",
bgcolor: "primary.main",
color: "#fff",
borderColor: 'primary.main',
bgcolor: 'primary.main',
color: '#fff'
}
: undefined,
: undefined
}}
>
{fullLabel.charAt(0)}
</Box>
);
)
})}
</Box>
);
)
}
@@ -1,14 +1,14 @@
import { Box, Typography, useTheme } from "@linagora/twake-mui";
import { alpha } from "@mui/material/styles";
import React from "react";
import { Box, Typography, useTheme } from '@linagora/twake-mui'
import { alpha } from '@mui/material/styles'
import React from 'react'
interface ClickableFieldProps {
icon: React.ReactNode;
text?: string;
onClick: () => void;
iconColor?: string;
children?: React.ReactNode;
ariaLabel?: string;
icon: React.ReactNode
text?: string
onClick: () => void
iconColor?: string
children?: React.ReactNode
ariaLabel?: string
}
export const ClickableField: React.FC<ClickableFieldProps> = ({
@@ -17,9 +17,9 @@ export const ClickableField: React.FC<ClickableFieldProps> = ({
onClick,
iconColor,
children,
ariaLabel,
ariaLabel
}) => {
const theme = useTheme();
const theme = useTheme()
return (
<Box
@@ -27,44 +27,44 @@ export const ClickableField: React.FC<ClickableFieldProps> = ({
role="button"
tabIndex={0}
aria-label={ariaLabel ?? text}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onClick();
onKeyDown={e => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onClick()
}
}}
sx={{
display: "flex",
alignItems: children ? "flex-start" : "center",
cursor: "pointer",
padding: "8px 12px",
borderRadius: "4px",
"&:hover": {
backgroundColor: "action.hover",
display: 'flex',
alignItems: children ? 'flex-start' : 'center',
cursor: 'pointer',
padding: '8px 12px',
borderRadius: '4px',
'&:hover': {
backgroundColor: 'action.hover'
},
"&:focus-visible": {
'&:focus-visible': {
outline: `2px solid ${theme.palette.primary.main}`,
outlineOffset: "2px",
},
outlineOffset: '2px'
}
}}
>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "center",
maxWidth: "24px",
maxHeight: "24px",
marginRight: "12px",
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
maxWidth: '24px',
maxHeight: '24px',
marginRight: '12px',
color: iconColor || alpha(theme.palette.grey[900], 0.9),
"& svg": {
width: "24px",
height: "24px",
},
"& img": {
width: "24px",
height: "24px",
'& svg': {
width: '24px',
height: '24px'
},
'& img': {
width: '24px',
height: '24px'
}
}}
>
{icon}
@@ -74,14 +74,14 @@ export const ClickableField: React.FC<ClickableFieldProps> = ({
) : (
<Typography
sx={{
fontSize: "14px",
fontSize: '14px',
color: alpha(theme.palette.grey[900], 0.9),
fontWeight: 500,
fontWeight: 500
}}
>
{text}
</Typography>
)}
</Box>
);
};
)
}
+232 -232
View File
@@ -1,73 +1,73 @@
import { Box, TextFieldProps, Typography } from "@linagora/twake-mui";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import { Box, TextFieldProps, Typography } from '@linagora/twake-mui'
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'
import {
DatePicker,
DatePickerFieldProps,
} from "@mui/x-date-pickers/DatePicker";
import { PickerValue } from "@mui/x-date-pickers/internals";
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
DatePickerFieldProps
} from '@mui/x-date-pickers/DatePicker'
import { PickerValue } from '@mui/x-date-pickers/internals'
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'
import {
TimePicker,
TimePickerFieldProps,
} from "@mui/x-date-pickers/TimePicker";
import dayjs, { Dayjs } from "dayjs";
import "dayjs/locale/en";
import "dayjs/locale/fr";
import "dayjs/locale/ru";
import "dayjs/locale/vi";
import customParseFormat from "dayjs/plugin/customParseFormat";
import React, { useMemo } from "react";
import { useI18n } from "twake-i18n";
import { LONG_DATE_FORMAT } from "../utils/dateTimeFormatters";
import { dtDate, dtTime, toDateTime } from "../utils/dateTimeHelpers";
import { EditableTimeField } from "./EditableTimeField";
import { ReadOnlyDateField } from "./ReadOnlyPickerField";
TimePickerFieldProps
} from '@mui/x-date-pickers/TimePicker'
import dayjs, { Dayjs } from 'dayjs'
import 'dayjs/locale/en'
import 'dayjs/locale/fr'
import 'dayjs/locale/ru'
import 'dayjs/locale/vi'
import customParseFormat from 'dayjs/plugin/customParseFormat'
import React, { useMemo } from 'react'
import { useI18n } from 'twake-i18n'
import { LONG_DATE_FORMAT } from '../utils/dateTimeFormatters'
import { dtDate, dtTime, toDateTime } from '../utils/dateTimeHelpers'
import { EditableTimeField } from './EditableTimeField'
import { ReadOnlyDateField } from './ReadOnlyPickerField'
dayjs.extend(customParseFormat);
dayjs.extend(customParseFormat)
const timePickerPopperSx = {
"& .MuiPaper-root": {
width: "110px",
minWidth: "110px",
'& .MuiPaper-root': {
width: '110px',
minWidth: '110px'
},
"& .MuiMultiSectionDigitalClockSection-item": {
justifyContent: "flex-start",
width: "100%",
textAlign: "left",
},
};
'& .MuiMultiSectionDigitalClockSection-item': {
justifyContent: 'flex-start',
width: '100%',
textAlign: 'left'
}
}
// twake-mui datePickerOverrides also uses this selector. Repeating ensures our override wins.
const dateCalendarLayoutSx = {
"& .MuiDateCalendar-root.MuiDateCalendar-root": {
width: "260px",
maxWidth: "260px",
padding: "0 15px",
},
};
'& .MuiDateCalendar-root.MuiDateCalendar-root': {
width: '260px',
maxWidth: '260px',
padding: '0 15px'
}
}
/**
* Props for DateTimeFields component
*/
export interface DateTimeFieldsProps {
startDate: string;
startTime: string;
endDate: string;
endTime: string;
allday: boolean;
showMore: boolean;
hasEndDateChanged: boolean;
showEndDate: boolean;
onToggleEndDate: () => void;
startDate: string
startTime: string
endDate: string
endTime: string
allday: boolean
showMore: boolean
hasEndDateChanged: boolean
showEndDate: boolean
onToggleEndDate: () => void
validation: {
errors: {
dateTime: string;
};
};
onStartDateChange: (date: string) => void;
onStartTimeChange: (time: string) => void;
onEndDateChange: (date: string, time?: string) => void;
onEndTimeChange: (time: string) => void;
dateTime: string
}
}
onStartDateChange: (date: string) => void
onStartTimeChange: (time: string) => void
onEndDateChange: (date: string, time?: string) => void
onEndTimeChange: (time: string) => void
}
/**
@@ -87,39 +87,39 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
onStartDateChange,
onStartTimeChange,
onEndDateChange,
onEndTimeChange,
onEndTimeChange
}) => {
const { t, lang } = useI18n();
const { t, lang } = useI18n()
const initialDurationRef = React.useRef<number | null>(null);
const isUserActionRef = React.useRef(false);
const initialDurationRef = React.useRef<number | null>(null)
const isUserActionRef = React.useRef(false)
const getCurrentDuration = React.useCallback((): number => {
const start = toDateTime(startDate, startTime);
const end = toDateTime(endDate, endTime);
const start = toDateTime(startDate, startTime)
const end = toDateTime(endDate, endTime)
if (allday) {
return Math.max(end.startOf("day").diff(start.startOf("day"), "day"), 0);
return Math.max(end.startOf('day').diff(start.startOf('day'), 'day'), 0)
} else {
return Math.max(end.diff(start, "minute"), 0);
return Math.max(end.diff(start, 'minute'), 0)
}
}, [startDate, startTime, endDate, endTime, allday]);
}, [startDate, startTime, endDate, endTime, allday])
React.useEffect(() => {
if (!isUserActionRef.current && startDate && endDate) {
initialDurationRef.current = getCurrentDuration();
initialDurationRef.current = getCurrentDuration()
}
queueMicrotask(() => {
isUserActionRef.current = false;
});
}, [startDate, endDate, startTime, endTime, getCurrentDuration]);
isUserActionRef.current = false
})
}, [startDate, endDate, startTime, endTime, getCurrentDuration])
const spansMultipleDays = React.useMemo(() => {
return startDate !== endDate;
}, [startDate, endDate]);
return startDate !== endDate
}, [startDate, endDate])
const isExpanded = showMore;
const shouldShowEndDateNormal = allday || showEndDate;
const isExpanded = showMore
const shouldShowEndDateNormal = allday || showEndDate
// Show full 4 fields when:
// 1. Non-allday with hasEndDateChanged
// 2. Multiple days with hasEndDateChanged (supports drag from week/month view grid with allday checked)
@@ -127,144 +127,144 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
const shouldShowFullFieldsInNormal =
(!allday && hasEndDateChanged) ||
(hasEndDateChanged && spansMultipleDays) ||
(spansMultipleDays && !allday);
(spansMultipleDays && !allday)
const showSingleDateField =
!isExpanded && !shouldShowEndDateNormal && !shouldShowFullFieldsInNormal;
!isExpanded && !shouldShowEndDateNormal && !shouldShowFullFieldsInNormal
// When shouldShowFullFieldsInNormal is true, show time fields even if allday is true
// This supports the case: drag from week/month view grid with multiple days
const shouldShowTimeFields = !allday || shouldShowFullFieldsInNormal;
const shouldShowTimeFields = !allday || shouldShowFullFieldsInNormal
const startDateLabel = showSingleDateField
? t("dateTimeFields.date")
: t("dateTimeFields.startDate");
? t('dateTimeFields.date')
: t('dateTimeFields.startDate')
const handleStartDateChange = (value: PickerValue) => {
if (!value || !value.isValid()) return;
if (!value || !value.isValid()) return
isUserActionRef.current = true;
const newDateStr = dtDate(value as Dayjs);
const duration = initialDurationRef.current ?? getCurrentDuration();
isUserActionRef.current = true
const newDateStr = dtDate(value as Dayjs)
const duration = initialDurationRef.current ?? getCurrentDuration()
onStartDateChange(newDateStr);
onStartDateChange(newDateStr)
// Preserve duration by adjusting end
const newStart = toDateTime(newDateStr, startTime);
let newEnd: Dayjs;
const newStart = toDateTime(newDateStr, startTime)
let newEnd: Dayjs
if (allday) {
newEnd = newStart.add(duration, "day");
newEnd = newStart.add(duration, 'day')
} else {
newEnd = newStart.add(duration, "minute");
newEnd = newStart.add(duration, 'minute')
}
const newEndDate = dtDate(newEnd);
const newEndTime = dtTime(newEnd);
const newEndDate = dtDate(newEnd)
const newEndTime = dtTime(newEnd)
if (newEndDate !== endDate) {
onEndDateChange(newEndDate);
onEndDateChange(newEndDate)
}
if (!allday && newEndTime !== endTime) {
onEndTimeChange(newEndTime);
onEndTimeChange(newEndTime)
}
};
}
const handleStartTimeChange = (value: PickerValue) => {
if (!value || !value.isValid()) return;
if (!value || !value.isValid()) return
isUserActionRef.current = true;
const newTimeStr = dtTime(value as Dayjs);
const duration = initialDurationRef.current ?? getCurrentDuration();
isUserActionRef.current = true
const newTimeStr = dtTime(value as Dayjs)
const duration = initialDurationRef.current ?? getCurrentDuration()
onStartTimeChange(newTimeStr);
onStartTimeChange(newTimeStr)
const newStart = toDateTime(startDate, newTimeStr);
const newEnd = newStart.add(duration, "minute");
const newStart = toDateTime(startDate, newTimeStr)
const newEnd = newStart.add(duration, 'minute')
const newEndDate = dtDate(newEnd);
const newEndTime = dtTime(newEnd);
const newEndDate = dtDate(newEnd)
const newEndTime = dtTime(newEnd)
if (newEndDate !== endDate && newEndTime !== endTime) {
onEndDateChange(newEndDate, newEndTime);
onEndDateChange(newEndDate, newEndTime)
} else {
if (newEndDate !== endDate) {
onEndDateChange(newEndDate);
onEndDateChange(newEndDate)
}
if (!allday && newEndTime !== endTime) {
onEndTimeChange(newEndTime);
onEndTimeChange(newEndTime)
}
}
};
}
const handleEndDateChange = (value: PickerValue) => {
if (!value || !value.isValid()) return;
if (!value || !value.isValid()) return
isUserActionRef.current = true;
const newDateStr = dtDate(value as Dayjs);
const newEnd = toDateTime(newDateStr, endTime);
const currentStart = toDateTime(startDate, startTime);
isUserActionRef.current = true
const newDateStr = dtDate(value as Dayjs)
const newEnd = toDateTime(newDateStr, endTime)
const currentStart = toDateTime(startDate, startTime)
if (newEnd.isBefore(currentStart)) {
const duration = initialDurationRef.current ?? getCurrentDuration();
let newStart: Dayjs;
const duration = initialDurationRef.current ?? getCurrentDuration()
let newStart: Dayjs
if (allday) {
newStart = newEnd.subtract(duration, "day");
newStart = newEnd.subtract(duration, 'day')
} else {
newStart = newEnd.subtract(duration, "minute");
newStart = newEnd.subtract(duration, 'minute')
}
const newStartDate = dtDate(newStart);
const newStartTime = dtTime(newStart);
const newStartDate = dtDate(newStart)
const newStartTime = dtTime(newStart)
if (newStartDate !== startDate) {
onStartDateChange(newStartDate);
onStartDateChange(newStartDate)
}
if (!allday && newStartTime !== startTime) {
onStartTimeChange(newStartTime);
onStartTimeChange(newStartTime)
}
} else {
initialDurationRef.current = newEnd.diff(
currentStart,
allday ? "day" : "minute"
);
allday ? 'day' : 'minute'
)
}
onEndDateChange(newDateStr);
};
onEndDateChange(newDateStr)
}
const handleEndTimeChange = (value: PickerValue) => {
if (!value || !value.isValid()) return;
if (!value || !value.isValid()) return
isUserActionRef.current = true;
const newTimeStr = dtTime(value as Dayjs);
const newEnd = toDateTime(endDate, newTimeStr);
const currentStart = toDateTime(startDate, startTime);
isUserActionRef.current = true
const newTimeStr = dtTime(value as Dayjs)
const newEnd = toDateTime(endDate, newTimeStr)
const currentStart = toDateTime(startDate, startTime)
// Always update duration ref to reflect current state, even if invalid (end < start)
// This prevents stale duration from being used when user edits start time
initialDurationRef.current = newEnd.diff(currentStart, "minute");
initialDurationRef.current = newEnd.diff(currentStart, 'minute')
onEndTimeChange(newTimeStr);
};
onEndTimeChange(newTimeStr)
}
// Memoize parsed date/time values
const startDateValue = useMemo(
() => (startDate ? dayjs(startDate) : null),
[startDate]
);
)
const startTimeValue = useMemo(
() => (startTime ? dayjs(startTime, "HH:mm") : null),
() => (startTime ? dayjs(startTime, 'HH:mm') : null),
[startTime]
);
)
const endDateValue = useMemo(
() => (endDate ? dayjs(endDate) : null),
[endDate]
);
)
const endTimeValue = useMemo(
() => (endTime ? dayjs(endTime, "HH:mm") : null),
() => (endTime ? dayjs(endTime, 'HH:mm') : null),
[endTime]
);
)
const getSlotProps = (
testId: string,
@@ -272,18 +272,18 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
testLabel?: string
) => ({
textField: {
size: "small" as const,
margin: "dense" as const,
size: 'small' as const,
margin: 'dense' as const,
fullWidth: true,
InputLabelProps: { shrink: true },
error: hasError,
sx: { width: "100%" },
sx: { width: '100%' },
inputProps: {
"data-testid": testId,
...(testLabel ? { "aria-label": testLabel } : {}),
},
},
});
'data-testid': testId,
...(testLabel ? { 'aria-label': testLabel } : {})
}
}
})
const getFieldSlotProps = (
testId: string,
@@ -292,25 +292,25 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
): Partial<DatePickerFieldProps | TimePickerFieldProps> &
Pick<
TextFieldProps,
| "size"
| "margin"
| "fullWidth"
| "InputLabelProps"
| "error"
| "sx"
| "inputProps"
| 'size'
| 'margin'
| 'fullWidth'
| 'InputLabelProps'
| 'error'
| 'sx'
| 'inputProps'
> => ({
size: "small" as const,
margin: "dense" as const,
size: 'small' as const,
margin: 'dense' as const,
fullWidth: true,
InputLabelProps: { shrink: true },
error: hasError,
sx: { width: "100%" },
sx: { width: '100%' },
inputProps: {
"data-testid": testId,
...(testLabel ? { "aria-label": testLabel } : {}),
},
});
'data-testid': testId,
...(testLabel ? { 'aria-label': testLabel } : {})
}
})
const getTimeFieldSlotProps = (
testId: string,
@@ -319,49 +319,49 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
): Partial<TimePickerFieldProps> &
Pick<
TextFieldProps,
| "size"
| "margin"
| "fullWidth"
| "InputLabelProps"
| "error"
| "sx"
| "inputProps"
| 'size'
| 'margin'
| 'fullWidth'
| 'InputLabelProps'
| 'error'
| 'sx'
| 'inputProps'
> => ({
size: "small" as const,
margin: "dense" as const,
size: 'small' as const,
margin: 'dense' as const,
fullWidth: true,
InputLabelProps: { shrink: true },
error: hasError,
sx: { width: "100%" },
sx: { width: '100%' },
inputProps: {
"data-testid": testId,
...(testLabel ? { "aria-label": testLabel } : {}),
},
});
'data-testid': testId,
...(testLabel ? { 'aria-label': testLabel } : {})
}
})
return (
<LocalizationProvider
key={lang}
dateAdapter={AdapterDayjs}
adapterLocale={lang ?? "en"}
adapterLocale={lang ?? 'en'}
localeText={{
okButtonLabel: t("common.ok"),
cancelButtonLabel: t("common.cancel"),
todayButtonLabel: t("menubar.today"),
okButtonLabel: t('common.ok'),
cancelButtonLabel: t('common.cancel'),
todayButtonLabel: t('menubar.today')
}}
>
<Box
display="flex"
flexDirection="column"
className={`date-time-group ${
isExpanded || shouldShowFullFieldsInNormal ? "show-full-field" : ""
isExpanded || shouldShowFullFieldsInNormal ? 'show-full-field' : ''
}`.trim()}
sx={{ maxWidth: showMore ? "calc(100% - 145px)" : "100%" }}
sx={{ maxWidth: showMore ? 'calc(100% - 145px)' : '100%' }}
>
{isExpanded || shouldShowFullFieldsInNormal ? (
<>
<Box display="flex" gap={1} flexDirection="row" alignItems="center">
<Box sx={{ maxWidth: "300px", width: "48%" }}>
<Box sx={{ maxWidth: '300px', width: '48%' }}>
<DatePicker
format={LONG_DATE_FORMAT}
value={startDateValue}
@@ -369,21 +369,21 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
slots={{ field: ReadOnlyDateField }}
slotProps={{
...getSlotProps(
"start-date-input",
'start-date-input',
false,
t("dateTimeFields.startDate")
t('dateTimeFields.startDate')
),
field: getFieldSlotProps(
"start-date-input",
'start-date-input',
false,
t("dateTimeFields.startDate")
t('dateTimeFields.startDate')
),
layout: { sx: dateCalendarLayoutSx },
layout: { sx: dateCalendarLayoutSx }
}}
/>
</Box>
{shouldShowTimeFields && (
<Box sx={{ width: "110px" }}>
<Box sx={{ width: '110px' }}>
<TimePicker
ampm={false}
value={startTimeValue}
@@ -392,23 +392,23 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
timeSteps={{ minutes: 30 }}
slots={{
field: EditableTimeField,
actionBar: () => null,
actionBar: () => null
}}
slotProps={{
openPickerButton: { sx: { display: "none" } },
openPickerButton: { sx: { display: 'none' } },
popper: { sx: timePickerPopperSx },
field: getTimeFieldSlotProps(
"start-time-input",
'start-time-input',
false,
t("dateTimeFields.startTime")
),
t('dateTimeFields.startTime')
)
}}
/>
</Box>
)}
</Box>
<Box display="flex" gap={1} flexDirection="row" alignItems="center">
<Box sx={{ maxWidth: "300px", width: "48%" }}>
<Box sx={{ maxWidth: '300px', width: '48%' }}>
<DatePicker
format={LONG_DATE_FORMAT}
value={endDateValue}
@@ -416,21 +416,21 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
slots={{ field: ReadOnlyDateField }}
slotProps={{
...getSlotProps(
"end-date-input",
'end-date-input',
!!validation.errors.dateTime,
t("dateTimeFields.endDate")
t('dateTimeFields.endDate')
),
field: getFieldSlotProps(
"end-date-input",
'end-date-input',
!!validation.errors.dateTime,
t("dateTimeFields.endDate")
t('dateTimeFields.endDate')
),
layout: { sx: dateCalendarLayoutSx },
layout: { sx: dateCalendarLayoutSx }
}}
/>
</Box>
{shouldShowTimeFields && (
<Box sx={{ width: "110px" }}>
<Box sx={{ width: '110px' }}>
<TimePicker
ampm={false}
value={endTimeValue}
@@ -439,16 +439,16 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
timeSteps={{ minutes: 30 }}
slots={{
field: EditableTimeField,
actionBar: () => null,
actionBar: () => null
}}
slotProps={{
openPickerButton: { sx: { display: "none" } },
openPickerButton: { sx: { display: 'none' } },
popper: { sx: timePickerPopperSx },
field: getTimeFieldSlotProps(
"end-time-input",
'end-time-input',
!!validation.errors.dateTime,
t("dateTimeFields.endTime")
),
t('dateTimeFields.endTime')
)
}}
/>
</Box>
@@ -457,7 +457,7 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
</>
) : shouldShowEndDateNormal ? (
<Box display="flex" gap={1} flexDirection="row" alignItems="center">
<Box sx={{ maxWidth: "300px", width: "48%" }}>
<Box sx={{ maxWidth: '300px', width: '48%' }}>
<DatePicker
format={LONG_DATE_FORMAT}
value={startDateValue}
@@ -465,20 +465,20 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
slots={{ field: ReadOnlyDateField }}
slotProps={{
...getSlotProps(
"start-date-input",
'start-date-input',
false,
t("dateTimeFields.startDate")
t('dateTimeFields.startDate')
),
field: getFieldSlotProps(
"start-date-input",
'start-date-input',
false,
t("dateTimeFields.startDate")
t('dateTimeFields.startDate')
),
layout: { sx: dateCalendarLayoutSx },
layout: { sx: dateCalendarLayoutSx }
}}
/>
</Box>
<Box sx={{ maxWidth: "300px", width: "48%" }}>
<Box sx={{ maxWidth: '300px', width: '48%' }}>
<DatePicker
format={LONG_DATE_FORMAT}
value={endDateValue}
@@ -486,40 +486,40 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
slots={{ field: ReadOnlyDateField }}
slotProps={{
...getSlotProps(
"end-date-input",
'end-date-input',
!!validation.errors.dateTime,
t("dateTimeFields.endDate")
t('dateTimeFields.endDate')
),
field: getFieldSlotProps(
"end-date-input",
'end-date-input',
!!validation.errors.dateTime,
t("dateTimeFields.endDate")
t('dateTimeFields.endDate')
),
layout: { sx: dateCalendarLayoutSx },
layout: { sx: dateCalendarLayoutSx }
}}
/>
</Box>
</Box>
) : (
<Box display="flex" gap={1} flexDirection="row" alignItems="center">
<Box sx={{ maxWidth: "300px", width: "48%" }}>
<Box sx={{ maxWidth: '300px', width: '48%' }}>
<DatePicker
format={LONG_DATE_FORMAT}
value={startDateValue}
onChange={handleStartDateChange}
slots={{ field: ReadOnlyDateField }}
slotProps={{
...getSlotProps("start-date-input", false, startDateLabel),
...getSlotProps('start-date-input', false, startDateLabel),
field: getFieldSlotProps(
"start-date-input",
'start-date-input',
false,
startDateLabel
),
layout: { sx: dateCalendarLayoutSx },
layout: { sx: dateCalendarLayoutSx }
}}
/>
</Box>
<Box sx={{ maxWidth: "110px" }}>
<Box sx={{ maxWidth: '110px' }}>
<TimePicker
ampm={false}
value={startTimeValue}
@@ -529,31 +529,31 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
timeSteps={{ minutes: 30 }}
slots={{
field: EditableTimeField,
actionBar: () => null,
actionBar: () => null
}}
slotProps={{
openPickerButton: { sx: { display: "none" } },
openPickerButton: { sx: { display: 'none' } },
popper: { sx: timePickerPopperSx },
field: getTimeFieldSlotProps(
"start-time-input",
'start-time-input',
false,
t("dateTimeFields.startTime")
),
t('dateTimeFields.startTime')
)
}}
/>
</Box>
{!allday && (
<Typography
sx={{
alignSelf: "center",
alignSelf: 'center',
mx: 0.5,
mt: 0.5,
mt: 0.5
}}
>
-
</Typography>
)}
<Box sx={{ maxWidth: "110px" }}>
<Box sx={{ maxWidth: '110px' }}>
<TimePicker
ampm={false}
value={endTimeValue}
@@ -563,16 +563,16 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
timeSteps={{ minutes: 30 }}
slots={{
field: EditableTimeField,
actionBar: () => null,
actionBar: () => null
}}
slotProps={{
openPickerButton: { sx: { display: "none" } },
openPickerButton: { sx: { display: 'none' } },
popper: { sx: timePickerPopperSx },
field: getTimeFieldSlotProps(
"end-time-input",
'end-time-input',
!!validation.errors.dateTime,
t("dateTimeFields.endTime")
),
t('dateTimeFields.endTime')
)
}}
/>
</Box>
@@ -584,11 +584,11 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
<Box display="flex" gap={1} flexDirection="row">
<Box
sx={{
width: "1%",
width: '1%'
}}
/>
<Box>
<Typography variant="caption" sx={{ color: "error.main" }}>
<Typography variant="caption" sx={{ color: 'error.main' }}>
{validation.errors.dateTime}
</Typography>
</Box>
@@ -596,5 +596,5 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
)}
</Box>
</LocalizationProvider>
);
};
)
}
@@ -1,29 +1,29 @@
import { Box, Typography, useTheme } from "@linagora/twake-mui";
import { alpha } from "@mui/material/styles";
import AccessTimeIcon from "@mui/icons-material/AccessTime";
import dayjs from "dayjs";
import "dayjs/locale/en";
import "dayjs/locale/fr";
import "dayjs/locale/ru";
import "dayjs/locale/vi";
import React from "react";
import { useI18n } from "twake-i18n";
import { getTimezoneOffset } from "@/utils/timezone";
import { RepetitionObject } from "@/features/Events/EventsTypes";
import { LONG_DATE_FORMAT } from "../utils/dateTimeFormatters";
import { SectionPreviewRow } from "./SectionPreviewRow";
import { makeRecurrenceString } from "@/features/Events/EventPreview/utils/makeRecurrenceString";
import { Box, Typography, useTheme } from '@linagora/twake-mui'
import { alpha } from '@mui/material/styles'
import AccessTimeIcon from '@mui/icons-material/AccessTime'
import dayjs from 'dayjs'
import 'dayjs/locale/en'
import 'dayjs/locale/fr'
import 'dayjs/locale/ru'
import 'dayjs/locale/vi'
import React from 'react'
import { useI18n } from 'twake-i18n'
import { getTimezoneOffset } from '@/utils/timezone'
import { RepetitionObject } from '@/features/Events/EventsTypes'
import { LONG_DATE_FORMAT } from '../utils/dateTimeFormatters'
import { SectionPreviewRow } from './SectionPreviewRow'
import { makeRecurrenceString } from '@/features/Events/EventPreview/utils/makeRecurrenceString'
interface DateTimeSummaryProps {
startDate: string;
startTime: string;
endDate: string;
endTime: string;
allday: boolean;
timezone: string;
repetition: RepetitionObject;
showEndDate: boolean;
onClick: () => void;
startDate: string
startTime: string
endDate: string
endTime: string
allday: boolean
timezone: string
repetition: RepetitionObject
showEndDate: boolean
onClick: () => void
}
export const DateTimeSummary: React.FC<DateTimeSummaryProps> = ({
@@ -35,111 +35,110 @@ export const DateTimeSummary: React.FC<DateTimeSummaryProps> = ({
timezone,
repetition,
showEndDate,
onClick,
onClick
}) => {
const { t, lang } = useI18n();
const theme = useTheme();
const { t, lang } = useI18n()
const theme = useTheme()
// Format date with current locale. VI: "Thứ 4, 4 Tháng 2, 2026"; FR: "mercredi, 5 février 2026"; RU: first letter capitalized
const formatDate = (dateStr: string): string => {
if (!dateStr) return "";
const date = dayjs(dateStr);
const locale =
lang && ["en", "vi", "fr", "ru"].includes(lang) ? lang : "en";
if (!dateStr) return ''
const date = dayjs(dateStr)
const locale = lang && ['en', 'vi', 'fr', 'ru'].includes(lang) ? lang : 'en'
if (locale === "vi") {
const dow = date.day(); // 0 = Sunday, 1 = Monday, ..., 6 = Saturday
const weekdayLabel = dow === 0 ? "Chủ nhật" : `Thứ ${dow + 1}`; // Mon=Thứ 2, Wed=Thứ 4, ...
const day = date.date();
const month = date.month() + 1;
const year = date.year();
return `${weekdayLabel}, ${day} Tháng ${month}, ${year}`;
if (locale === 'vi') {
const dow = date.day() // 0 = Sunday, 1 = Monday, ..., 6 = Saturday
const weekdayLabel = dow === 0 ? 'Chủ nhật' : `Thứ ${dow + 1}` // Mon=Thứ 2, Wed=Thứ 4, ...
const day = date.date()
const month = date.month() + 1
const year = date.year()
return `${weekdayLabel}, ${day} Tháng ${month}, ${year}`
}
// French: "5 février" (day before month), not "février 5"
if (locale === "fr") {
const formatted = date.locale("fr").format("dddd, D MMMM YYYY");
return formatted.charAt(0).toUpperCase() + formatted.slice(1);
if (locale === 'fr') {
const formatted = date.locale('fr').format('dddd, D MMMM YYYY')
return formatted.charAt(0).toUpperCase() + formatted.slice(1)
}
const formatted = date.locale(locale).format(LONG_DATE_FORMAT);
if (locale === "ru") {
return formatted.charAt(0).toUpperCase() + formatted.slice(1);
const formatted = date.locale(locale).format(LONG_DATE_FORMAT)
if (locale === 'ru') {
return formatted.charAt(0).toUpperCase() + formatted.slice(1)
}
return formatted;
};
return formatted
}
// Format time in 24h: "03:30 - 16:30"
const formatTime = (startTimeStr: string, endTimeStr: string): string => {
if (allday || !startTimeStr || !endTimeStr) return "";
if (allday || !startTimeStr || !endTimeStr) return ''
const toHHmm = (timeStr: string): string => {
const [h, m] = timeStr.split(":").map((s) => parseInt(s, 10) || 0);
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
};
const [h, m] = timeStr.split(':').map(s => parseInt(s, 10) || 0)
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`
}
return `${toHHmm(startTimeStr)} - ${toHHmm(endTimeStr)}`;
};
return `${toHHmm(startTimeStr)} - ${toHHmm(endTimeStr)}`
}
// Format timezone: "(UTC+2) Paris". Use event date for offset (DST correctness).
const formatTimezone = (tz: string, dateStr?: string): string => {
if (!tz) return "";
if (!tz) return ''
try {
const dateForOffset = dateStr ? dayjs(dateStr).toDate() : new Date();
const offset = getTimezoneOffset(tz, dateForOffset);
const tzName = tz.replace(/_/g, " ");
return `(${offset}) ${tzName}`;
const dateForOffset = dateStr ? dayjs(dateStr).toDate() : new Date()
const offset = getTimezoneOffset(tz, dateForOffset)
const tzName = tz.replace(/_/g, ' ')
return `(${offset}) ${tzName}`
} catch {
return tz.replace(/_/g, " ");
return tz.replace(/_/g, ' ')
}
};
}
// Format repeat: "Doesn't repeat" or repeat info
const formatRepeat = (rep: RepetitionObject): string => {
if (!rep || !rep.freq) {
return t("event.repeat.doesNotRepeat");
return t('event.repeat.doesNotRepeat')
}
return (
makeRecurrenceString({
repetition: rep,
t,
startText: rep.interval === 1 ? t("event.repeat.every") : "",
joinChar: "",
enableStrForOneTimeInterval: true,
}) || ""
);
};
startText: rep.interval === 1 ? t('event.repeat.every') : '',
joinChar: '',
enableStrForOneTimeInterval: true
}) || ''
)
}
// Format date text: show both start and end date if showEndDate is true
const formatDateText = (): string => {
if (showEndDate && endDate && endDate !== startDate) {
const startDateText = formatDate(startDate);
const endDateText = formatDate(endDate);
return `${startDateText} - ${endDateText}`;
const startDateText = formatDate(startDate)
const endDateText = formatDate(endDate)
return `${startDateText} - ${endDateText}`
}
return formatDate(startDate);
};
return formatDate(startDate)
}
const dateText = formatDateText();
const timeText = formatTime(startTime, endTime);
const timezoneText = formatTimezone(timezone, startDate);
const repeatText = formatRepeat(repetition);
const dateText = formatDateText()
const timeText = formatTime(startTime, endTime)
const timezoneText = formatTimezone(timezone, startDate)
const repeatText = formatRepeat(repetition)
// Don't render if no date
if (!startDate) {
return null;
return null
}
const primaryStyle = {
fontSize: "14px",
fontSize: '14px',
fontWeight: 500,
color: alpha(theme.palette.grey[900], 0.9),
};
color: alpha(theme.palette.grey[900], 0.9)
}
return (
<SectionPreviewRow
icon={<AccessTimeIcon sx={{ color: "text.secondary" }} />}
icon={<AccessTimeIcon sx={{ color: 'text.secondary' }} />}
onClick={onClick}
>
<Box>
@@ -153,14 +152,14 @@ export const DateTimeSummary: React.FC<DateTimeSummaryProps> = ({
)}
</Typography>
<Box display="flex" gap={2} alignItems="center" mt={0.5}>
<Typography variant="caption" sx={{ color: "#444746" }}>
<Typography variant="caption" sx={{ color: '#444746' }}>
{timezoneText}
</Typography>
<Typography variant="caption" sx={{ color: "#444746" }}>
<Typography variant="caption" sx={{ color: '#444746' }}>
{repeatText}
</Typography>
</Box>
</Box>
</SectionPreviewRow>
);
};
)
}
@@ -1,274 +1,294 @@
import { TextField } from "@linagora/twake-mui";
// EditableTimeField
import { TextField } from '@linagora/twake-mui'
import {
useParsedFormat,
usePickerActionsContext,
usePickerContext,
useSplitFieldProps,
} from "@mui/x-date-pickers/hooks";
import { PickerFieldProps } from "@mui/x-date-pickers/models";
import { TimePickerFieldProps } from "@mui/x-date-pickers/TimePicker";
useSplitFieldProps
} from '@mui/x-date-pickers/hooks'
import { PickerFieldProps } from '@mui/x-date-pickers/models'
import { TimePickerFieldProps } from '@mui/x-date-pickers/TimePicker'
import {
PickerFieldAdapter,
PickerValidationScope,
useValidation,
validateTime,
} from "@mui/x-date-pickers/validation";
import { Dayjs } from "dayjs";
import React, { useCallback, useEffect, useRef, useState } from "react";
import { parseTimeInput } from "../utils/dateTimeHelpers";
validateTime
} from '@mui/x-date-pickers/validation'
import { Dayjs } from 'dayjs'
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { parseTimeInput } from '../utils/dateTimeHelpers'
type FieldType = "date" | "time" | "date-time";
type FieldType = 'date' | 'time' | 'date-time'
type GenericPickerFieldProps = PickerFieldProps<Dayjs, false, false> & {
fieldType: FieldType;
fieldType: FieldType
validator: (
value: Dayjs | null,
context: PickerValidationScope,
adapter: PickerFieldAdapter<Dayjs>
) => string | null;
};
) => string | null
}
const TIME_DISPLAY_FORMAT = "HH:mm";
const TIME_DISPLAY_FORMAT = 'HH:mm'
/**
* Editable field for time pickers. Allows free typing with format on blur/enter.
* Click anywhere in the field to open picker.
*/
function EditableTimePickerField(props: GenericPickerFieldProps) {
const { fieldType, validator, ...fieldProps } = props;
const { fieldType, validator, ...fieldProps } = props
const { internalProps, forwardedProps } = useSplitFieldProps(
fieldProps,
fieldType
);
)
const pickerContext = usePickerContext();
const pickerActions = usePickerActionsContext();
const parsedFormat = useParsedFormat();
const inputRef = useRef<HTMLInputElement>(null);
const {
value: pickerValue,
open,
setOpen,
timezone,
triggerRef,
rootClassName,
rootSx,
rootRef,
name: pickerName
} = usePickerContext()
const pickerActions = usePickerActionsContext()
const parsedFormat = useParsedFormat()
const inputRef = useRef<HTMLInputElement>(null)
const getFormattedValue = useCallback(() => {
if (pickerContext.value == null) return "";
if (!pickerContext.value.isValid()) return "";
return pickerContext.value.format(TIME_DISPLAY_FORMAT);
}, [pickerContext.value]);
if (pickerValue == null) return ''
if (!pickerValue.isValid()) return ''
return pickerValue.format(TIME_DISPLAY_FORMAT)
}, [pickerValue])
const [inputValue, setInputValue] = useState(getFormattedValue);
const [isFocused, setIsFocused] = useState(false);
const [inputValue, setInputValue] = useState(getFormattedValue)
const [isFocused, setIsFocused] = useState(false)
const [pendingCommitValue, setPendingCommitValue] = useState<string | null>(
null
);
const prevFormattedValueRef = useRef(getFormattedValue());
const isDispatchingCloseEventRef = useRef(false);
const hasDispatchedInMicrotaskRef = useRef(false);
)
const prevFormattedValueRef = useRef(getFormattedValue())
const isDispatchingCloseEventRef = useRef(false)
const hasDispatchedInMicrotaskRef = useRef(false)
// Sync input value when picker value changes from dropdown selection or external
useEffect(() => {
const newFormattedValue = getFormattedValue();
const valueChanged = newFormattedValue !== prevFormattedValueRef.current;
const updateInputValue = () => {
const newFormattedValue = getFormattedValue()
const valueChanged = newFormattedValue !== prevFormattedValueRef.current
// Sync when:
// 1. Value changed (from dropdown selection or external)
// 2. Not focused (external change)
if (valueChanged || !isFocused) {
setInputValue(newFormattedValue);
// Sync when:
// 1. Value changed (from dropdown selection or external)
// 2. Not focused (external change)
if (valueChanged || !isFocused) {
setInputValue(newFormattedValue)
}
// Close dropdown after selection (value changed while dropdown is open)
if (valueChanged && open) {
setOpen(false)
// Clear focus after dropdown selection
setIsFocused(false)
inputRef.current?.blur()
}
prevFormattedValueRef.current = newFormattedValue
}
updateInputValue()
}, [getFormattedValue, isFocused, open, setOpen])
// Close dropdown after selection (value changed while dropdown is open)
if (valueChanged && pickerContext.open) {
pickerContext.setOpen(false);
// Clear focus after dropdown selection
setIsFocused(false);
inputRef.current?.blur();
}
prevFormattedValueRef.current = newFormattedValue;
}, [getFormattedValue, isFocused, pickerContext]);
const wasOpenRef = useRef(pickerContext.open);
const wasOpenRef = useRef(open)
// Handle dropdown open/close
useEffect(() => {
const wasOpen = wasOpenRef.current;
const isOpen = pickerContext.open;
const handleDropdown = () => {
const wasOpen = wasOpenRef.current
const isOpen = open
if (isOpen && !wasOpen && inputRef.current) {
// Dropdown just opened - refocus input to allow typing
const timer = setTimeout(() => {
inputRef.current?.focus();
}, 10);
wasOpenRef.current = isOpen;
return () => clearTimeout(timer);
}
if (!isOpen && wasOpen) {
// Dropdown just closed - check if we need to commit user input
const current = getFormattedValue();
if (isFocused && inputValue.trim() && inputValue !== current) {
// Store input value to commit later (after parseAndUpdateTime is defined)
setPendingCommitValue(inputValue);
} else {
setInputValue(current);
if (isOpen && !wasOpen && inputRef.current) {
// Dropdown just opened - refocus input to allow typing
const timer = setTimeout(() => {
inputRef.current?.focus()
}, 10)
wasOpenRef.current = isOpen
return () => clearTimeout(timer)
}
setIsFocused(false);
// Blur input to clear DOM focus (removes Mui-focused class)
inputRef.current?.blur();
}
wasOpenRef.current = isOpen;
}, [pickerContext.open, getFormattedValue, isFocused, inputValue]);
if (!isOpen && wasOpen) {
// Dropdown just closed - check if we need to commit user input
const current = getFormattedValue()
if (isFocused && inputValue.trim() && inputValue !== current) {
// Store input value to commit later (after parseAndUpdateTime is defined)
setPendingCommitValue(inputValue)
} else {
setInputValue(current)
}
setIsFocused(false)
// Blur input to clear DOM focus (removes Mui-focused class)
inputRef.current?.blur()
}
wasOpenRef.current = isOpen
}
handleDropdown()
}, [open, getFormattedValue, isFocused, inputValue])
const { hasValidationError } = useValidation({
validator,
value: pickerContext.value,
timezone: pickerContext.timezone,
props: internalProps,
});
value: pickerValue,
timezone: timezone,
props: internalProps
})
const parseAndUpdateTime = useCallback(
(value: string) => {
const trimmed = value.trim();
const trimmed = value.trim()
if (!trimmed) {
setInputValue(getFormattedValue());
return;
setInputValue(getFormattedValue())
return
}
const newValue = parseTimeInput(
trimmed,
pickerContext.value as Dayjs | null
);
const newValue = parseTimeInput(trimmed, pickerValue as Dayjs | null)
if (newValue) {
// Update picker internal state and trigger onChange in one call
pickerActions.setValue(newValue, { changeImportance: "accept" });
setInputValue(newValue.format(TIME_DISPLAY_FORMAT));
pickerActions.setValue(newValue, { changeImportance: 'accept' })
setInputValue(newValue.format(TIME_DISPLAY_FORMAT))
} else {
// Invalid input - reset to current value
setInputValue(getFormattedValue());
setInputValue(getFormattedValue())
}
},
[pickerContext, pickerActions, getFormattedValue]
);
[pickerValue, pickerActions, getFormattedValue]
)
// Commit pending input value when picker closes (after parseAndUpdateTime is defined)
useEffect(() => {
if (pendingCommitValue !== null) {
parseAndUpdateTime(pendingCommitValue);
setPendingCommitValue(null);
const updateTimeAndClearPending = () => {
if (pendingCommitValue !== null) {
parseAndUpdateTime(pendingCommitValue)
setPendingCommitValue(null)
}
}
}, [pendingCommitValue, parseAndUpdateTime]);
updateTimeAndClearPending()
}, [pendingCommitValue, parseAndUpdateTime])
// Listen for close events from other picker fields
useEffect(() => {
const handleCloseOtherPickers = () => {
// Don't close if this field is the one dispatching the event
if (isDispatchingCloseEventRef.current) {
return;
return
}
// If this picker is open and another field is requesting to close others
if (pickerContext.open) {
pickerContext.setOpen(false);
if (open) {
setOpen(false)
}
};
}
window.addEventListener(
"close-other-time-pickers",
handleCloseOtherPickers
);
window.addEventListener('close-other-time-pickers', handleCloseOtherPickers)
return () => {
window.removeEventListener(
"close-other-time-pickers",
'close-other-time-pickers',
handleCloseOtherPickers
);
};
}, [pickerContext]);
)
}
}, [open, setOpen])
const dispatchCloseOtherPickers = useCallback(() => {
// Guard: prevent duplicate dispatch in the same microtask
if (hasDispatchedInMicrotaskRef.current) {
return;
return
}
// Mark that we've dispatched in this microtask
hasDispatchedInMicrotaskRef.current = true;
hasDispatchedInMicrotaskRef.current = true
// Set flag to prevent this field from closing its own picker
isDispatchingCloseEventRef.current = true;
isDispatchingCloseEventRef.current = true
// Notify other pickers to close
window.dispatchEvent(new CustomEvent("close-other-time-pickers"));
window.dispatchEvent(new CustomEvent('close-other-time-pickers'))
// Reset flags in next microtask to ensure event is processed first
Promise.resolve().then(() => {
isDispatchingCloseEventRef.current = false;
hasDispatchedInMicrotaskRef.current = false;
});
}, []);
Promise.resolve()
.then(() => {
isDispatchingCloseEventRef.current = false
hasDispatchedInMicrotaskRef.current = false
return
})
.catch(error => {
console.error('Error resetting picker close flags:', error)
})
}, [])
const handleClick = (e: React.MouseEvent) => {
if (forwardedProps.disabled || forwardedProps.readOnly) return;
if (forwardedProps.disabled || forwardedProps.readOnly) return
// Stop propagation to prevent parent from toggling the picker
e.stopPropagation();
e.stopPropagation()
dispatchCloseOtherPickers();
dispatchCloseOtherPickers()
// Always keep it open when clicked, or open it if closed
if (!pickerContext.open) {
pickerContext.setOpen(true);
if (!open) {
setOpen(true)
}
};
}
const handleFocus = () => {
if (forwardedProps.disabled || forwardedProps.readOnly) return;
if (forwardedProps.disabled || forwardedProps.readOnly) return
setIsFocused(true);
dispatchCloseOtherPickers();
};
setIsFocused(true)
dispatchCloseOtherPickers()
}
const handleBlur = (_e: React.FocusEvent<HTMLInputElement>) => {
// If dropdown is open, don't parse input
// MUI will handle selection and sync value via useEffect
if (pickerContext.open) {
return;
if (open) {
return
}
// Dropdown is closed - parse input and update value
setIsFocused(false);
parseAndUpdateTime(inputValue);
};
setIsFocused(false)
parseAndUpdateTime(inputValue)
}
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(e.target.value);
};
setInputValue(e.target.value)
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
e.preventDefault();
parseAndUpdateTime(inputValue);
setIsFocused(false);
pickerContext.setOpen(false);
inputRef.current?.blur();
} else if (e.key === "Escape") {
setInputValue(getFormattedValue());
setIsFocused(false);
pickerContext.setOpen(false);
inputRef.current?.blur();
if (e.key === 'Enter') {
e.preventDefault()
parseAndUpdateTime(inputValue)
setIsFocused(false)
setOpen(false)
inputRef.current?.blur()
} else if (e.key === 'Escape') {
setInputValue(getFormattedValue())
setIsFocused(false)
setOpen(false)
inputRef.current?.blur()
}
};
}
const mergedInputProps = {
...forwardedProps.InputProps,
ref: pickerContext.triggerRef,
ref: triggerRef,
sx: {
cursor: "text",
...forwardedProps.InputProps?.sx,
},
};
cursor: 'text',
...forwardedProps.InputProps?.sx
}
}
return (
<TextField
{...forwardedProps}
value={inputValue}
onChange={handleChange}
placeholder={parsedFormat as string}
placeholder={parsedFormat}
InputProps={mergedInputProps}
inputRef={inputRef}
error={hasValidationError || forwardedProps.error}
@@ -277,17 +297,17 @@ function EditableTimePickerField(props: GenericPickerFieldProps) {
onFocus={handleFocus}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
className={pickerContext.rootClassName}
className={rootClassName}
sx={{
...pickerContext.rootSx,
"& input": {
textAlign: "center",
},
...rootSx,
'& input': {
textAlign: 'center'
}
}}
ref={pickerContext.rootRef}
name={pickerContext.name}
ref={rootRef}
name={pickerName}
/>
);
)
}
export function EditableTimeField(props: TimePickerFieldProps) {
@@ -297,5 +317,5 @@ export function EditableTimeField(props: TimePickerFieldProps) {
fieldType="time"
validator={validateTime}
/>
);
)
}
@@ -1,5 +1,5 @@
import { Box, SxProps, Theme, Typography } from "@linagora/twake-mui";
import React from "react";
import { Box, SxProps, Theme, Typography } from '@linagora/twake-mui'
import React from 'react'
/**
* Helper component for field with label
@@ -10,64 +10,64 @@ export const FieldWithLabel = React.memo(
label,
isExpanded,
children,
sx,
sx
}: {
label: string | React.ReactNode;
isExpanded: boolean;
children: React.ReactNode;
sx?: SxProps<Theme>;
label: string | React.ReactNode
isExpanded: boolean
children: React.ReactNode
sx?: SxProps<Theme>
}) => {
if (!isExpanded) {
// Normal mode: label on top
const isEmptyLabel =
label === null ||
label === undefined ||
(typeof label === "string" && label.trim() === "");
(typeof label === 'string' && label.trim() === '')
return (
<Box
sx={[
{
"& > *:not(:first-of-type)": {
marginTop: isEmptyLabel ? 0 : "6px",
'& > *:not(:first-of-type)': {
marginTop: isEmptyLabel ? 0 : '6px'
},
// Only apply margin to direct child MuiTextField-root
"& > .MuiFormControl-root": {
marginTop: "6px",
marginBottom: 0,
'& > .MuiFormControl-root': {
marginTop: '6px',
marginBottom: 0
},
"& > .MuiTextField-root": {
marginTop: "6px",
marginBottom: 0,
'& > .MuiTextField-root': {
marginTop: '6px',
marginBottom: 0
},
// Reset margin for nested MuiTextField-root
"& .MuiFormControl-root .MuiTextField-root": {
'& .MuiFormControl-root .MuiTextField-root': {
marginTop: 0,
marginBottom: 0,
marginBottom: 0
},
"& .MuiTextField-root .MuiTextField-root": {
'& .MuiTextField-root .MuiTextField-root': {
marginTop: 0,
marginBottom: 0,
marginBottom: 0
},
// Reset margin for nested Box children (DateTimeFields structure)
"& > .MuiBox-root > .MuiBox-root": {
marginTop: 0,
},
"& > .MuiBox-root > .MuiBox-root > .MuiBox-root": {
marginTop: 0,
'& > .MuiBox-root > .MuiBox-root': {
marginTop: 0
},
'& > .MuiBox-root > .MuiBox-root > .MuiBox-root': {
marginTop: 0
}
},
...(Array.isArray(sx) ? sx : sx ? [sx] : []),
...(Array.isArray(sx) ? sx : sx ? [sx] : [])
]}
>
{!isEmptyLabel && (
<Typography component="div" variant="h6" sx={{ display: "block" }}>
<Typography component="div" variant="h6" sx={{ display: 'block' }}>
{label}
</Typography>
)}
{children}
</Box>
);
)
}
// Extended mode: label on left
@@ -77,9 +77,9 @@ export const FieldWithLabel = React.memo(
component="div"
variant="h6"
sx={{
minWidth: "115px",
marginRight: "12px",
flexShrink: 0,
minWidth: '115px',
marginRight: '12px',
flexShrink: 0
}}
>
{label}
@@ -88,23 +88,25 @@ export const FieldWithLabel = React.memo(
flexGrow={1}
sx={{
// Set margin-top: 8px for second row in DateTimeFields (4 fields layout)
"& > .MuiBox-root > .MuiBox-root:nth-of-type(2)": {
marginTop: "8px",
'& > .MuiBox-root > .MuiBox-root:nth-of-type(2)': {
marginTop: '8px'
},
// Remove margin from MuiFormControl-root MuiFormControl-marginDense in extended mode
"& .MuiFormControl-root.MuiFormControl-marginDense": {
'& .MuiFormControl-root.MuiFormControl-marginDense': {
marginTop: 0,
marginBottom: 0,
marginBottom: 0
},
"& .MuiTextField-root.MuiFormControl-marginDense": {
'& .MuiTextField-root.MuiFormControl-marginDense': {
marginTop: 0,
marginBottom: 0,
},
marginBottom: 0
}
}}
>
{children}
</Box>
</Box>
);
)
}
);
)
FieldWithLabel.displayName = 'FieldWithLabel'
@@ -1,88 +1,98 @@
import { TextField } from "@linagora/twake-mui";
import { DatePickerFieldProps } from "@mui/x-date-pickers/DatePicker";
// ReadOnlyPickerField
import { TextField } from '@linagora/twake-mui'
import { DatePickerFieldProps } from '@mui/x-date-pickers/DatePicker'
import {
useParsedFormat,
usePickerContext,
useSplitFieldProps,
} from "@mui/x-date-pickers/hooks";
import { PickerFieldProps } from "@mui/x-date-pickers/models";
useSplitFieldProps
} from '@mui/x-date-pickers/hooks'
import { PickerFieldProps } from '@mui/x-date-pickers/models'
import {
PickerFieldAdapter,
PickerValidationScope,
useValidation,
validateDate,
} from "@mui/x-date-pickers/validation";
import { Dayjs } from "dayjs";
validateDate
} from '@mui/x-date-pickers/validation'
import { Dayjs } from 'dayjs'
type FieldType = "date" | "time" | "date-time";
type FieldType = 'date' | 'time' | 'date-time'
type GenericPickerFieldProps = PickerFieldProps<Dayjs, false, false> & {
fieldType: FieldType;
fieldType: FieldType
validator: (
value: Dayjs | null,
context: PickerValidationScope,
adapter: PickerFieldAdapter<Dayjs>
) => string | null;
};
) => string | null
}
/**
* Shared read-only field for date/time pickers. Disables typing, removes icon,
* and opens the picker when clicking anywhere in the field.
*/
function ReadOnlyPickerField(props: GenericPickerFieldProps) {
const { fieldType, validator, ...fieldProps } = props;
const { fieldType, validator, ...fieldProps } = props
const { internalProps, forwardedProps } = useSplitFieldProps(
fieldProps,
fieldType
);
)
const pickerContext = usePickerContext();
const parsedFormat = useParsedFormat();
const {
value,
timezone,
fieldFormat,
open,
setOpen,
triggerRef,
rootClassName,
rootSx,
rootRef,
name: pickerName
} = usePickerContext()
const parsedFormat = useParsedFormat()
const { hasValidationError } = useValidation({
validator,
value: pickerContext.value,
timezone: pickerContext.timezone,
props: internalProps,
});
value: value,
timezone: timezone,
props: internalProps
})
const valueToDisplay =
pickerContext.value == null
? ""
: pickerContext.value.isValid()
? pickerContext.value.format(pickerContext.fieldFormat)
: "";
value == null ? '' : value.isValid() ? value.format(fieldFormat) : ''
const mergedInputProps = {
...forwardedProps.InputProps,
ref: pickerContext.triggerRef,
ref: triggerRef,
readOnly: true,
sx: {
cursor: "pointer",
"& *": { cursor: "inherit" },
...forwardedProps.InputProps?.sx,
},
};
cursor: 'pointer',
'& *': { cursor: 'inherit' },
...forwardedProps.InputProps?.sx
}
}
return (
<TextField
{...forwardedProps}
value={valueToDisplay}
placeholder={parsedFormat as string}
placeholder={parsedFormat}
InputProps={mergedInputProps}
error={hasValidationError}
focused={pickerContext.open}
onClick={() => pickerContext.setOpen((prev) => !prev)}
className={pickerContext.rootClassName}
sx={pickerContext.rootSx}
ref={pickerContext.rootRef}
name={pickerContext.name}
focused={open}
onClick={() => setOpen((prev: boolean) => !prev)}
className={rootClassName}
sx={rootSx}
ref={rootRef}
name={pickerName}
/>
);
)
}
export function ReadOnlyDateField(props: DatePickerFieldProps) {
return (
<ReadOnlyPickerField {...props} fieldType="date" validator={validateDate} />
);
)
}
@@ -1,13 +1,13 @@
import { Box, Typography, useTheme } from "@linagora/twake-mui";
import { alpha } from "@mui/material/styles";
import React from "react";
import { Box, Typography, useTheme } from '@linagora/twake-mui'
import { alpha } from '@mui/material/styles'
import React from 'react'
export interface SectionPreviewRowProps {
icon: React.ReactNode;
icon: React.ReactNode
/** Primary text or custom content. If string, rendered with fontSize 14px, fontWeight 500. */
children: React.ReactNode;
onClick: () => void;
iconColor?: string;
children: React.ReactNode
onClick: () => void
iconColor?: string
}
/**
@@ -18,17 +18,17 @@ export const SectionPreviewRow: React.FC<SectionPreviewRowProps> = ({
icon,
children,
onClick,
iconColor,
iconColor
}) => {
const theme = useTheme();
const color = iconColor ?? alpha(theme.palette.grey[900], 0.9);
const theme = useTheme()
const color = iconColor ?? alpha(theme.palette.grey[900], 0.9)
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onClick();
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onClick()
}
};
}
return (
<Box
@@ -37,45 +37,45 @@ export const SectionPreviewRow: React.FC<SectionPreviewRowProps> = ({
onClick={onClick}
onKeyDown={handleKeyDown}
sx={{
display: "flex",
alignItems: "center",
cursor: "pointer",
padding: "8px 12px",
borderRadius: "4px",
"&:hover": {
backgroundColor: "action.hover",
},
display: 'flex',
alignItems: 'center',
cursor: 'pointer',
padding: '8px 12px',
borderRadius: '4px',
'&:hover': {
backgroundColor: 'action.hover'
}
}}
>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "center",
maxWidth: "24px",
maxHeight: "24px",
marginRight: "12px",
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
maxWidth: '24px',
maxHeight: '24px',
marginRight: '12px',
flexShrink: 0,
color,
"& svg": {
maxWidth: "24px",
maxHeight: "24px",
},
"& img": {
maxWidth: "24px",
maxHeight: "24px",
'& svg': {
maxWidth: '24px',
maxHeight: '24px'
},
'& img': {
maxWidth: '24px',
maxHeight: '24px'
}
}}
>
{icon}
</Box>
<Box flex={1} minWidth={0}>
{typeof children === "string" ? (
{typeof children === 'string' ? (
<Typography
sx={{
fontSize: "14px",
fontSize: '14px',
fontWeight: 500,
color,
color
}}
>
{children}
@@ -85,5 +85,5 @@ export const SectionPreviewRow: React.FC<SectionPreviewRowProps> = ({
)}
</Box>
</Box>
);
};
)
}
@@ -1,18 +1,18 @@
import { AppDispatch } from "@/app/store";
import { Calendar } from "@/features/Calendars/CalendarTypes";
import { AppDispatch } from '@/app/store'
import { Calendar } from '@/features/Calendars/CalendarTypes'
import {
deleteEventAsync,
deleteEventInstanceAsync,
putEventAsync,
updateEventInstanceAsync,
} from "@/features/Calendars/services";
import { updateSeriesPartstat } from "@/features/Events/EventApi";
import { CalendarEvent } from "@/features/Events/EventsTypes";
import { PartStat } from "@/features/User/models/attendee";
import { createAttendee } from "@/features/User/models/attendee.mapper";
import { userData } from "@/features/User/userDataTypes";
import { buildFamilyName } from "@/utils/buildFamilyName";
import { isEventOrganiser } from "@/utils/isEventOrganiser";
updateEventInstanceAsync
} from '@/features/Calendars/services'
import { updateSeriesPartstat } from '@/features/Events/EventApi'
import { CalendarEvent } from '@/features/Events/EventsTypes'
import { PartStat } from '@/features/User/models/attendee'
import { createAttendee } from '@/features/User/models/attendee.mapper'
import { userData } from '@/features/User/userDataTypes'
import { buildFamilyName } from '@/utils/buildFamilyName'
import { isEventOrganiser } from '@/utils/isEventOrganiser'
function updateEventAttendees(
calendar: Calendar,
@@ -22,60 +22,60 @@ function updateEventAttendees(
) {
if (calendar.owner?.resource) {
const updatedAttendees =
event.attendee?.map((attendeeData) =>
attendeeData.cutype === "RESOURCE" && attendeeData.cn === calendar.name
event.attendee?.map(attendeeData =>
attendeeData.cutype === 'RESOURCE' && attendeeData.cn === calendar.name
? { ...attendeeData, partstat: rsvp }
: attendeeData
) || [];
) || []
return { attendee: updatedAttendees };
return { attendee: updatedAttendees }
}
if (!user) {
throw new Error("Cannot update attendees without user data");
throw new Error('Cannot update attendees without user data')
}
const eventHasNoAttendees = !event?.attendee || event.attendee.length === 0;
const isOrganizer = isEventOrganiser(event, user.email);
const eventHasNoAttendees = !event?.attendee || event.attendee.length === 0
const isOrganizer = isEventOrganiser(event, user.email)
if (eventHasNoAttendees) {
const userdata = createAttendee({
cal_address: user.email,
cn: buildFamilyName(user.given_name, user.family_name, user.email),
role: isOrganizer ? "CHAIR" : "REQ-PARTICIPANT",
partstat: rsvp,
});
role: isOrganizer ? 'CHAIR' : 'REQ-PARTICIPANT',
partstat: rsvp
})
return {
organizer: isOrganizer ? userdata : event.organizer,
attendee: [userdata],
};
attendee: [userdata]
}
}
return {
attendee: (() => {
const userEmailLower = user.email?.toLowerCase();
const userEmailLower = user.email?.toLowerCase()
const userExists = event.attendee.some(
(attendee) => attendee.cal_address?.toLowerCase() === userEmailLower
);
attendee => attendee.cal_address?.toLowerCase() === userEmailLower
)
const updatedAttendees = event.attendee.map((attendeeData) =>
const updatedAttendees = event.attendee.map(attendeeData =>
attendeeData.cal_address?.toLowerCase() === userEmailLower
? { ...attendeeData, partstat: rsvp }
: attendeeData
);
)
if (!userExists) {
const newUserAttendee = createAttendee({
cal_address: user.email,
cn: buildFamilyName(user.given_name, user.family_name, user.email),
role: "REQ-PARTICIPANT",
partstat: rsvp,
});
return [...updatedAttendees, newUserAttendee];
role: 'REQ-PARTICIPANT',
partstat: rsvp
})
return [...updatedAttendees, newUserAttendee]
}
return updatedAttendees;
})(),
};
return updatedAttendees
})()
}
}
async function handleSoloRSVP(
@@ -83,7 +83,7 @@ async function handleSoloRSVP(
calendar: Calendar,
event: CalendarEvent
) {
dispatch(updateEventInstanceAsync({ cal: calendar, event }));
await dispatch(updateEventInstanceAsync({ cal: calendar, event }))
}
async function handleAllRSVP(
@@ -91,7 +91,7 @@ async function handleAllRSVP(
userEmail: string,
rsvp: PartStat
) {
await updateSeriesPartstat(event, userEmail, rsvp);
await updateSeriesPartstat(event, userEmail, rsvp)
}
async function handleDefaultRSVP(
@@ -99,7 +99,7 @@ async function handleDefaultRSVP(
calendar: Calendar,
newEvent: CalendarEvent
) {
dispatch(putEventAsync({ cal: calendar, newEvent }));
await dispatch(putEventAsync({ cal: calendar, newEvent }))
}
export async function handleRSVP(
@@ -112,42 +112,42 @@ export async function handleRSVP(
) {
const newEvent = {
...event,
...updateEventAttendees(calendar, event, user, rsvp),
};
...updateEventAttendees(calendar, event, user, rsvp)
}
if (typeOfAction === "solo") {
await handleSoloRSVP(dispatch, calendar, newEvent);
} else if (typeOfAction === "all") {
if (typeOfAction === 'solo') {
await handleSoloRSVP(dispatch, calendar, newEvent)
} else if (typeOfAction === 'all') {
if (!user?.email) {
throw new Error("Cannot update all occurrences without user email");
throw new Error('Cannot update all occurrences without user email')
}
await handleAllRSVP(event, user.email, rsvp);
await handleAllRSVP(event, user.email, rsvp)
} else {
await handleDefaultRSVP(dispatch, calendar, newEvent);
await handleDefaultRSVP(dispatch, calendar, newEvent)
}
}
export function handleDelete(
isRecurring: boolean,
typeOfAction: "solo" | "all" | undefined,
onClose: (event: unknown, reason: "backdropClick" | "escapeKeyDown") => void,
typeOfAction: 'solo' | 'all' | undefined,
onClose: (event: unknown, reason: 'backdropClick' | 'escapeKeyDown') => void,
dispatch: AppDispatch,
calendar: Calendar,
event: CalendarEvent,
calId: string,
eventId: string
) {
onClose({}, "backdropClick");
onClose({}, 'backdropClick')
if (isRecurring && typeOfAction === "solo") {
dispatch(deleteEventInstanceAsync({ cal: calendar, event }));
if (isRecurring && typeOfAction === 'solo') {
dispatch(deleteEventInstanceAsync({ cal: calendar, event }))
} else {
dispatch(
deleteEventAsync({
calId,
eventId,
eventURL: event.URL,
eventURL: event.URL
})
);
)
}
}
+60 -60
View File
@@ -1,23 +1,23 @@
import React from "react";
import { combineDateTime } from "../utils/dateTimeHelpers";
import React from 'react'
import { combineDateTime } from '../utils/dateTimeHelpers'
/**
* Parameters for all-day toggle hook
*/
export interface AllDayToggleParams {
allday: boolean;
start: string;
end: string;
startDate: string;
startTime: string;
endDate: string;
endTime: string;
setStartTime: (time: string) => void;
setEndTime: (time: string) => void;
setStart: (start: string) => void;
setEnd: (end: string) => void;
setAllDay: (allday: boolean) => void;
onAllDayChange?: (allday: boolean, start: string, end: string) => void;
allday: boolean
start: string
end: string
startDate: string
startTime: string
endDate: string
endTime: string
setStartTime: (time: string) => void
setEndTime: (time: string) => void
setStart: (start: string) => void
setEnd: (end: string) => void
setAllDay: (allday: boolean) => void
onAllDayChange?: (allday: boolean, start: string, end: string) => void
}
/**
@@ -25,12 +25,12 @@ export interface AllDayToggleParams {
*/
export interface AllDayToggleHandlers {
originalTimeRef: React.MutableRefObject<{
start: string;
end: string;
endDate?: string;
fromAllDaySlot?: boolean;
} | null>;
handleAllDayToggle: () => void;
start: string
end: string
endDate?: string
fromAllDaySlot?: boolean
} | null>
handleAllDayToggle: () => void
}
/**
@@ -53,58 +53,58 @@ export function useAllDayToggle(
setStart,
setEnd,
setAllDay,
onAllDayChange,
} = params;
onAllDayChange
} = params
// Store original time before toggling to all-day
const originalTimeRef = React.useRef<{
start: string;
end: string;
endDate?: string;
fromAllDaySlot?: boolean;
} | null>(null);
start: string
end: string
endDate?: string
fromAllDaySlot?: boolean
} | null>(null)
const handleAllDayToggle = React.useCallback(() => {
const newAllDay = !allday;
let newStart = start;
let newEnd = end;
const newAllDay = !allday
let newStart = start
let newEnd = end
if (!newAllDay) {
const hasTimeParts = start.includes("T") && end.includes("T");
const hasTimeParts = start.includes('T') && end.includes('T')
if (!hasTimeParts && !startTime && !endTime) {
const now = new Date();
now.setSeconds(0);
now.setMilliseconds(0);
const nextHour = new Date(now);
nextHour.setMinutes(0);
nextHour.setHours(now.getHours() + 1);
const now = new Date()
now.setSeconds(0)
now.setMilliseconds(0)
const nextHour = new Date(now)
nextHour.setMinutes(0)
nextHour.setHours(now.getHours() + 1)
const startHours = String(nextHour.getHours()).padStart(2, "0");
const startMinutes = String(nextHour.getMinutes()).padStart(2, "0");
const startTimeStr = `${startHours}:${startMinutes}`;
const startHours = String(nextHour.getHours()).padStart(2, '0')
const startMinutes = String(nextHour.getMinutes()).padStart(2, '0')
const startTimeStr = `${startHours}:${startMinutes}`
const endHourDate = new Date(nextHour);
endHourDate.setHours(endHourDate.getHours() + 1);
const endHours = String(endHourDate.getHours()).padStart(2, "0");
const endMinutes = String(endHourDate.getMinutes()).padStart(2, "0");
const endTimeStr = `${endHours}:${endMinutes}`;
const endHourDate = new Date(nextHour)
endHourDate.setHours(endHourDate.getHours() + 1)
const endHours = String(endHourDate.getHours()).padStart(2, '0')
const endMinutes = String(endHourDate.getMinutes()).padStart(2, '0')
const endTimeStr = `${endHours}:${endMinutes}`
const startDateOnly = start.split("T")[0] || startDate;
const endDateOnly = end.split("T")[0] || endDate || startDateOnly;
newStart = combineDateTime(startDateOnly, startTimeStr);
newEnd = combineDateTime(endDateOnly, endTimeStr);
const startDateOnly = start.split('T')[0] || startDate
const endDateOnly = end.split('T')[0] || endDate || startDateOnly
newStart = combineDateTime(startDateOnly, startTimeStr)
newEnd = combineDateTime(endDateOnly, endTimeStr)
setStartTime(startTimeStr);
setEndTime(endTimeStr);
setStartTime(startTimeStr)
setEndTime(endTimeStr)
}
}
if (!onAllDayChange) {
setStart(newStart);
setEnd(newEnd);
setAllDay(newAllDay);
setStart(newStart)
setEnd(newEnd)
setAllDay(newAllDay)
} else {
onAllDayChange(newAllDay, newStart, newEnd);
onAllDayChange(newAllDay, newStart, newEnd)
}
}, [
allday,
@@ -119,11 +119,11 @@ export function useAllDayToggle(
setStart,
setEnd,
setAllDay,
onAllDayChange,
]);
onAllDayChange
])
return {
originalTimeRef,
handleAllDayToggle,
};
handleAllDayToggle
}
}
+17 -17
View File
@@ -1,12 +1,12 @@
// Date rules helpers to normalize end date/time behavior
import { combineDateTime } from "./dateTimeHelpers";
import { combineDateTime } from './dateTimeHelpers'
/** Adds a number of days to a YYYY-MM-DD string and returns YYYY-MM-DD */
export function addDays(dateStr: string, days: number): string {
const d = new Date(dateStr);
d.setDate(d.getDate() + days);
return d.toISOString().split("T")[0];
const d = new Date(dateStr)
d.setDate(d.getDate() + days)
return d.toISOString().split('T')[0]
}
/**
@@ -18,8 +18,8 @@ export function getEndDateForStartChange(
startDate: string,
isAllDay: boolean
): string {
if (!startDate) return "";
return isAllDay ? addDays(startDate, 1) : startDate;
if (!startDate) return ''
return isAllDay ? addDays(startDate, 1) : startDate
}
/**
@@ -32,23 +32,23 @@ export function getEndDateForStartChange(
* - end restored from originalEndDate if any, else previousEndDate, else start
*/
export function getEndDateForToggle(params: {
nextAllDay: boolean;
fromAllDaySlot?: boolean;
startDate: string;
previousEndDate: string;
originalEndDate?: string;
nextAllDay: boolean
fromAllDaySlot?: boolean
startDate: string
previousEndDate: string
originalEndDate?: string
}): string {
const {
nextAllDay,
fromAllDaySlot,
startDate,
previousEndDate,
originalEndDate,
} = params;
originalEndDate
} = params
if (nextAllDay) {
return fromAllDaySlot ? startDate : addDays(startDate, 1);
return fromAllDaySlot ? startDate : addDays(startDate, 1)
}
return originalEndDate || previousEndDate || startDate;
return originalEndDate || previousEndDate || startDate
}
/** Utility to combine date with a fallback time (HH:mm) safely */
@@ -57,6 +57,6 @@ export function combineWithFallback(
timeHHmm: string | undefined,
fallbackTime: string
): string {
const time = timeHHmm && timeHHmm.trim() ? timeHHmm : fallbackTime;
return combineDateTime(dateStr, time);
const time = timeHHmm && timeHHmm.trim() ? timeHHmm : fallbackTime
return combineDateTime(dateStr, time)
}
@@ -11,27 +11,27 @@
export function formatLocalDateTime(date: Date, timeZone?: string): string {
// Guard against invalid or undefined dates
if (!date || isNaN(date.getTime())) {
return "";
return ''
}
if (timeZone) {
const formatter = new Intl.DateTimeFormat("en-CA", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
const formatter = new Intl.DateTimeFormat('en-CA', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
timeZone,
});
const formatted = formatter.format(date);
return formatted.replace(", ", "T");
timeZone
})
const formatted = formatter.format(date)
return formatted.replace(', ', 'T')
}
const pad = (n: number) => n.toString().padStart(2, "0");
const pad = (n: number) => n.toString().padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(
date.getDate()
)}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
)}T${pad(date.getHours())}:${pad(date.getMinutes())}`
}
/**
@@ -45,24 +45,24 @@ export function formatDateTimeInTimezone(
timezone: string
): string {
// Parse the ISO string as UTC
const utcDate = new Date(isoString);
const utcDate = new Date(isoString)
// Format the date in the target timezone
const formatter = new Intl.DateTimeFormat("en-CA", {
const formatter = new Intl.DateTimeFormat('en-CA', {
timeZone: timezone,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false
})
const parts = formatter.formatToParts(utcDate);
const parts = formatter.formatToParts(utcDate)
const getValue = (type: string) =>
parts.find((p) => p.type === type)?.value || "";
parts.find(p => p.type === type)?.value || ''
return `${getValue("year")}-${getValue("month")}-${getValue("day")}T${getValue("hour")}:${getValue("minute")}`;
return `${getValue('year')}-${getValue('month')}-${getValue('day')}T${getValue('hour')}:${getValue('minute')}`
}
/**
@@ -70,14 +70,14 @@ export function formatDateTimeInTimezone(
* @returns Rounded Date object
*/
export function getRoundedCurrentTime(): Date {
const now = new Date();
const minutes = now.getMinutes();
const roundedMinutes = minutes < 30 ? 0 : 30;
now.setMinutes(roundedMinutes);
now.setSeconds(0);
now.setMilliseconds(0);
return now;
const now = new Date()
const minutes = now.getMinutes()
const roundedMinutes = minutes < 30 ? 0 : 30
now.setMinutes(roundedMinutes)
now.setSeconds(0)
now.setMilliseconds(0)
return now
}
/** Long date display format for date pickers */
export const LONG_DATE_FORMAT = "dddd, MMMM D, YYYY";
export const LONG_DATE_FORMAT = 'dddd, MMMM D, YYYY'
+41 -41
View File
@@ -1,21 +1,21 @@
import dayjs, { Dayjs } from "dayjs";
import customParseFormat from "dayjs/plugin/customParseFormat";
import moment from "moment-timezone";
import dayjs, { Dayjs } from 'dayjs'
import customParseFormat from 'dayjs/plugin/customParseFormat'
import moment from 'moment-timezone'
dayjs.extend(customParseFormat);
dayjs.extend(customParseFormat)
/**
* Helper functions for date/time string manipulation
*/
export const DATETIME_WITH_SECONDS_LENGTH = 19;
export const DATETIME_FORMAT_WITH_SECONDS = "YYYY-MM-DDTHH:mm:ss";
export const DATETIME_FORMAT_WITHOUT_SECONDS = "YYYY-MM-DDTHH:mm";
export const DATETIME_WITH_SECONDS_LENGTH = 19
export const DATETIME_FORMAT_WITH_SECONDS = 'YYYY-MM-DDTHH:mm:ss'
export const DATETIME_FORMAT_WITHOUT_SECONDS = 'YYYY-MM-DDTHH:mm'
export const TIME_PARSE_FORMATS = ["HH:mm", "H:mm", "HHmm", "Hmm", "HH", "H"];
export const TIME_PARSE_FORMATS = ['HH:mm', 'H:mm', 'HHmm', 'Hmm', 'HH', 'H']
// Strict parsing mode - input must exactly match the provided format(s)
const STRICT_PARSING = true;
const STRICT_PARSING = true
/**
* Detect datetime format based on string length
@@ -25,7 +25,7 @@ const STRICT_PARSING = true;
export function detectDateTimeFormat(datetime: string): string {
return datetime.length >= DATETIME_WITH_SECONDS_LENGTH
? DATETIME_FORMAT_WITH_SECONDS
: DATETIME_FORMAT_WITHOUT_SECONDS;
: DATETIME_FORMAT_WITHOUT_SECONDS
}
/**
@@ -34,15 +34,15 @@ export function detectDateTimeFormat(datetime: string): string {
* @returns Object with date and time strings
*/
export function splitDateTime(datetime: string): {
date: string;
time: string;
date: string
time: string
} {
if (!datetime) return { date: "", time: "" };
const parts = datetime.split("T");
if (!datetime) return { date: '', time: '' }
const parts = datetime.split('T')
return {
date: parts[0] || "",
time: parts[1]?.slice(0, 5) || "", // HH:mm only
};
date: parts[0] || '',
time: parts[1]?.slice(0, 5) || '' // HH:mm only
}
}
/**
@@ -52,9 +52,9 @@ export function splitDateTime(datetime: string): {
* @returns Combined datetime string or date only if no time
*/
export function combineDateTime(date: string, time: string): string {
if (!date) return "";
if (!time) return date; // Date only for all-day
return `${date}T${time}`;
if (!date) return ''
if (!time) return date // Date only for all-day
return `${date}T${time}`
}
/**
@@ -65,32 +65,32 @@ export function convertFormDateTimeToISO(
datetime: string,
timezone: string
): string {
if (!datetime) return "";
const tz = timezone || "Etc/UTC";
const format = detectDateTimeFormat(datetime);
const momentDate = moment.tz(datetime, format, tz);
if (!datetime) return ''
const tz = timezone || 'Etc/UTC'
const format = detectDateTimeFormat(datetime)
const momentDate = moment.tz(datetime, format, tz)
if (!momentDate.isValid()) {
console.warn(
`[convertFormDateTimeToISO] Invalid datetime: "${datetime}" with format "${format}" in timezone "${tz}"`
);
return "";
)
return ''
}
return momentDate.toDate().toISOString();
return momentDate.toDate().toISOString()
}
/** Convert date + time strings → Dayjs */
export const toDateTime = (date: string, time: string): Dayjs => {
const d = dayjs(date, "YYYY-MM-DD", STRICT_PARSING);
if (!time) return d.startOf("day");
const [h, m] = time.split(":").map(Number);
return d.hour(h).minute(m).second(0).millisecond(0);
};
const d = dayjs(date, 'YYYY-MM-DD', STRICT_PARSING)
if (!time) return d.startOf('day')
const [h, m] = time.split(':').map(Number)
return d.hour(h).minute(m).second(0).millisecond(0)
}
/** Extract date “YYYY-MM-DD” */
export const dtDate = (d: Dayjs) => d.format("YYYY-MM-DD");
export const dtDate = (d: Dayjs) => d.format('YYYY-MM-DD')
/** Extract time “HH:mm” */
export const dtTime = (d: Dayjs) => d.format("HH:mm");
export const dtTime = (d: Dayjs) => d.format('HH:mm')
/**
* Parse flexible time string input into Dayjs object
@@ -101,25 +101,25 @@ export function parseTimeInput(
value: string,
currentDate: Dayjs | null
): Dayjs | null {
let trimmed = value.trim();
let trimmed = value.trim()
if (!trimmed) {
return null;
return null
}
// Normalize 3-digit input (e.g. "830" → "0830" → 08:30, "123" → "0123" → 01:23)
if (/^\d{3}$/.test(trimmed)) {
trimmed = trimmed.padStart(4, "0");
trimmed = trimmed.padStart(4, '0')
}
const parsed = dayjs(trimmed, TIME_PARSE_FORMATS, STRICT_PARSING);
const parsed = dayjs(trimmed, TIME_PARSE_FORMATS, STRICT_PARSING)
if (parsed.isValid()) {
const baseDate = currentDate || dayjs();
const baseDate = currentDate || dayjs()
return baseDate
.hour(parsed.hour())
.minute(parsed.minute())
.second(0)
.millisecond(0);
.millisecond(0)
}
return null;
return null
}
+50 -50
View File
@@ -1,17 +1,17 @@
import { AppDispatch } from "@/app/store";
import { emptyEventsCal } from "@/features/Calendars/CalendarSlice";
import { Calendar } from "@/features/Calendars/CalendarTypes";
import { AppDispatch } from '@/app/store'
import { emptyEventsCal } from '@/features/Calendars/CalendarSlice'
import { Calendar } from '@/features/Calendars/CalendarTypes'
import {
getCalendarDetailAsync,
getCalendarsListAsync,
refreshCalendarWithSyncToken,
} from "@/features/Calendars/services";
import { userAttendee } from "@/features/User/models/attendee";
import { getInitials, stringToGradient } from "@/utils/avatarUtils";
import { formatDateToYYYYMMDDTHHMMSS } from "@/utils/dateUtils";
import { Avatar, Badge, Box, Typography } from "@linagora/twake-mui";
import CancelIcon from "@mui/icons-material/Cancel";
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
refreshCalendarWithSyncToken
} from '@/features/Calendars/services'
import { userAttendee } from '@/features/User/models/attendee'
import { getInitials, stringToGradient } from '@/utils/avatarUtils'
import { formatDateToYYYYMMDDTHHMMSS } from '@/utils/dateUtils'
import { Avatar, Badge, Box, Typography } from '@linagora/twake-mui'
import CancelIcon from '@mui/icons-material/Cancel'
import CheckCircleIcon from '@mui/icons-material/CheckCircle'
export function renderAttendeeBadge(
a: userAttendee,
@@ -22,40 +22,40 @@ export function renderAttendeeBadge(
caption?: string
) {
const classIcon =
a.partstat === "ACCEPTED" ? (
a.partstat === 'ACCEPTED' ? (
<CheckCircleIcon fontSize="inherit" color="success" />
) : a.partstat === "DECLINED" ? (
) : a.partstat === 'DECLINED' ? (
<CancelIcon fontSize="inherit" color="error" />
) : null;
) : null
if (!isFull) {
return <Avatar key={key} {...stringAvatar(a.cn || a.cal_address)} />;
return <Avatar key={key} {...stringAvatar(a.cn || a.cal_address)} />
} else {
return (
<Box
key={key}
style={{
display: "flex",
alignItems: "center",
display: 'flex',
alignItems: 'center',
gap: 1.5,
marginBottom: 0.5,
padding: 0.5,
borderRadius: 1,
borderRadius: 1
}}
>
<Badge
overlap="circular"
sx={{ marginRight: 2 }}
anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
badgeContent={
classIcon && (
<Box
style={{
fontSize: 14,
lineHeight: 0,
backgroundColor: "white",
borderRadius: "50%",
padding: "1px",
backgroundColor: 'white',
borderRadius: '50%',
padding: '1px'
}}
>
{classIcon}
@@ -65,13 +65,13 @@ export function renderAttendeeBadge(
>
<Avatar {...stringAvatar(a.cn || a.cal_address)} />
</Badge>
<Box style={{ display: "flex", flexDirection: "column", minWidth: 0 }}>
<Box style={{ display: 'flex', flexDirection: 'column', minWidth: 0 }}>
<Typography variant="body2" noWrap>
{a.cn || a.cal_address}
</Typography>
{isOrganizer && (
<Typography variant="caption" color="text.secondary">
{t("event.organizer")}
{t('event.organizer')}
</Typography>
)}
{caption && (
@@ -81,76 +81,76 @@ export function renderAttendeeBadge(
)}
</Box>
</Box>
);
)
}
}
export function stringToColor(string: string) {
let hash = 0;
let hash = 0
for (let i = 0; i < string.length; i++) {
hash = string.charCodeAt(i) + ((hash << 5) - hash);
hash = string.charCodeAt(i) + ((hash << 5) - hash)
}
let color = "#";
let color = '#'
for (let i = 0; i < 3; i++) {
const value = (hash >> (i * 8)) & 0xff;
color += `00${value.toString(16)}`.slice(-2);
const value = (hash >> (i * 8)) & 0xff
color += `00${value.toString(16)}`.slice(-2)
}
return color;
return color
}
export function stringAvatar(name: string) {
return {
color: stringToGradient(name),
children: getInitials(name),
};
children: getInitials(name)
}
}
export async function refreshCalendars(
dispatch: AppDispatch,
calendars: Calendar[],
calendarRange: {
start: Date;
end: Date;
start: Date
end: Date
},
calType?: "temp"
calType?: 'temp'
) {
if (process.env.NODE_ENV === "test") return;
if (process.env.NODE_ENV === 'test') return
if (!calType) {
await dispatch(getCalendarsListAsync());
await dispatch(getCalendarsListAsync())
}
const results = await Promise.allSettled(
calendars.map((calendar) =>
calendars.map(calendar =>
dispatch(
refreshCalendarWithSyncToken({ calendar, calType, calendarRange })
).unwrap()
)
);
)
results.forEach((result, index) => {
if (result.status === "rejected") {
if (result.status === 'rejected') {
console.error(
`Failed to refresh calendar ${calendars[index].id}:`,
result.reason
);
)
}
});
})
}
export async function refreshSingularCalendar(
dispatch: AppDispatch,
calendar: Calendar,
calendarRange: { start: Date; end: Date },
calType?: "temp"
calType?: 'temp'
) {
const isTestEnv = process.env.NODE_ENV === "test";
dispatch(emptyEventsCal({ calId: calendar.id, calType }));
const isTestEnv = process.env.NODE_ENV === 'test'
dispatch(emptyEventsCal({ calId: calendar.id, calType }))
if (isTestEnv) {
return;
return
}
await dispatch(
@@ -158,9 +158,9 @@ export async function refreshSingularCalendar(
calId: calendar.id,
match: {
start: formatDateToYYYYMMDDTHHMMSS(calendarRange.start),
end: formatDateToYYYYMMDDTHHMMSS(calendarRange.end),
end: formatDateToYYYYMMDDTHHMMSS(calendarRange.end)
},
calType,
calType
})
);
)
}
+61 -61
View File
@@ -1,27 +1,27 @@
import { combineDateTime } from "./dateTimeHelpers";
import { combineDateTime } from './dateTimeHelpers'
/**
* Validation parameters for event form
*/
export interface ValidationParams {
startDate: string;
startTime: string;
endDate: string;
endTime: string;
allday: boolean;
showValidationErrors: boolean;
hasEndDateChanged?: boolean;
showMore?: boolean;
startDate: string
startTime: string
endDate: string
endTime: string
allday: boolean
showValidationErrors: boolean
hasEndDateChanged?: boolean
showMore?: boolean
}
/**
* Validation result for event form
*/
export interface ValidationResult {
isValid: boolean;
isValid: boolean
errors: {
dateTime: string;
};
dateTime: string
}
}
/**
@@ -38,102 +38,102 @@ export function validateEventForm(params: ValidationParams): ValidationResult {
allday,
showValidationErrors,
hasEndDateChanged = false,
showMore = false,
} = params;
showMore = false
} = params
let isDateTimeValid = true;
let dateTimeError = "";
let isDateTimeValid = true
let dateTimeError = ''
// Determine which fields are visible based on UI mode
const showFullFields =
showMore ||
allday ||
hasEndDateChanged ||
(!showMore && !allday && startDate !== endDate);
const showTimeOnly = !allday && !showFullFields;
(!showMore && !allday && startDate !== endDate)
const showTimeOnly = !allday && !showFullFields
// Validate start date
if (!startDate || startDate.trim() === "") {
isDateTimeValid = false;
dateTimeError = "Start date is required";
if (!startDate || startDate.trim() === '') {
isDateTimeValid = false
dateTimeError = 'Start date is required'
}
// Validate start time (if not all-day)
else if (!allday && (!startTime || startTime.trim() === "")) {
isDateTimeValid = false;
dateTimeError = "Start time is required";
else if (!allday && (!startTime || startTime.trim() === '')) {
isDateTimeValid = false
dateTimeError = 'Start time is required'
}
// Validate end fields based on UI mode
else if (showFullFields) {
// 4 fields mode: validate both end date and end time
if (!endDate || endDate.trim() === "") {
isDateTimeValid = false;
dateTimeError = "End date is required";
} else if (!allday && (!endTime || endTime.trim() === "")) {
isDateTimeValid = false;
dateTimeError = "End time is required";
if (!endDate || endDate.trim() === '') {
isDateTimeValid = false
dateTimeError = 'End date is required'
} else if (!allday && (!endTime || endTime.trim() === '')) {
isDateTimeValid = false
dateTimeError = 'End time is required'
} else {
// Validate total datetime
if (allday) {
const toLocalDate = (ymd: string) => {
const [y, m, d] = ymd.split("-").map((v) => parseInt(v, 10));
if (!y || !m || !d) return new Date(NaN);
return new Date(y, m - 1, d);
};
const [y, m, d] = ymd.split('-').map(v => parseInt(v, 10))
if (!y || !m || !d) return new Date(NaN)
return new Date(y, m - 1, d)
}
const startOnly = toLocalDate(startDate);
const endOnly = toLocalDate(endDate);
const startOnly = toLocalDate(startDate)
const endOnly = toLocalDate(endDate)
if (isNaN(startOnly.getTime()) || isNaN(endOnly.getTime())) {
isDateTimeValid = false;
dateTimeError = "Invalid date";
isDateTimeValid = false
dateTimeError = 'Invalid date'
} else if (endOnly < startOnly) {
isDateTimeValid = false;
dateTimeError = "End date must be on or after start date";
isDateTimeValid = false
dateTimeError = 'End date must be on or after start date'
}
} else {
const startDateTime = new Date(combineDateTime(startDate, startTime));
const endDateTime = new Date(combineDateTime(endDate, endTime));
const startDateTime = new Date(combineDateTime(startDate, startTime))
const endDateTime = new Date(combineDateTime(endDate, endTime))
if (isNaN(startDateTime.getTime()) || isNaN(endDateTime.getTime())) {
isDateTimeValid = false;
dateTimeError = "Invalid date/time";
isDateTimeValid = false
dateTimeError = 'Invalid date/time'
} else if (endDateTime <= startDateTime) {
isDateTimeValid = false;
dateTimeError = "End date/time must be after start date/time";
isDateTimeValid = false
dateTimeError = 'End date/time must be after start date/time'
}
}
}
} else if (showTimeOnly) {
// 3 fields mode: validate time only (end time > start time, same day)
if (!endTime || endTime.trim() === "") {
isDateTimeValid = false;
dateTimeError = "End time is required";
if (!endTime || endTime.trim() === '') {
isDateTimeValid = false
dateTimeError = 'End time is required'
} else {
// Compare times only (same day is assumed)
const startTimeParts = startTime.split(":");
const endTimeParts = endTime.split(":");
const startTimeParts = startTime.split(':')
const endTimeParts = endTime.split(':')
if (startTimeParts.length === 2 && endTimeParts.length === 2) {
const startMinutes =
parseInt(startTimeParts[0]) * 60 + parseInt(startTimeParts[1]);
parseInt(startTimeParts[0]) * 60 + parseInt(startTimeParts[1])
const endMinutes =
parseInt(endTimeParts[0]) * 60 + parseInt(endTimeParts[1]);
parseInt(endTimeParts[0]) * 60 + parseInt(endTimeParts[1])
if (endMinutes <= startMinutes) {
isDateTimeValid = false;
dateTimeError = "End time must be after start time";
isDateTimeValid = false
dateTimeError = 'End time must be after start time'
}
} else {
isDateTimeValid = false;
dateTimeError = "Invalid time format";
isDateTimeValid = false
dateTimeError = 'Invalid time format'
}
}
}
const isValid = isDateTimeValid;
const isValid = isDateTimeValid
return {
isValid,
errors: {
dateTime: showValidationErrors ? dateTimeError : "",
},
};
dateTime: showValidationErrors ? dateTimeError : ''
}
}
}
+18 -18
View File
@@ -1,41 +1,41 @@
import React from "react";
import { DotLottieReact } from "@lottiefiles/dotlottie-react";
import { Box } from "@linagora/twake-mui";
import twakeLogo from "../../static/twake-workplace.svg";
import React from 'react'
import { DotLottieReact } from '@lottiefiles/dotlottie-react'
import { Box } from '@linagora/twake-mui'
import twakeLogo from '../../static/twake-workplace.svg'
export function Loading() {
return (
<Box
data-testid="loading"
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
width: "100vw",
height: "100vh",
position: "fixed",
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
width: '100vw',
height: '100vh',
position: 'fixed',
top: 0,
left: 0,
left: 0
}}
>
<DotLottieReact
src="/loadercalendar.lottie"
loop
autoplay
style={{ width: "175px" }}
style={{ width: '175px' }}
/>
<Box
component="img"
src={twakeLogo}
alt="twake workplace"
sx={{
position: "absolute",
bottom: "50px",
left: "50%",
transform: "translateX(-50%)",
width: "210px",
position: 'absolute',
bottom: '50px',
left: '50%',
transform: 'translateX(-50%)',
width: '210px'
}}
/>
</Box>
);
)
}
+10 -10
View File
@@ -1,31 +1,31 @@
import type { AlertColor } from "@linagora/twake-mui";
import { Alert, Snackbar } from "@linagora/twake-mui";
import type { AlertColor } from '@linagora/twake-mui'
import { Alert, Snackbar } from '@linagora/twake-mui'
export function SnackbarAlert({
open,
setOpen,
message,
severity = "success",
severity = 'success'
}: {
open: boolean;
setOpen: (o: boolean) => void;
message: string;
severity?: AlertColor;
open: boolean
setOpen: (o: boolean) => void
message: string
severity?: AlertColor
}) {
return (
<Snackbar
open={open}
autoHideDuration={2000}
onClose={() => setOpen(false)}
anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert
severity={severity}
onClose={() => setOpen(false)}
sx={{ width: "100%" }}
sx={{ width: '100%' }}
>
{message}
</Alert>
</Snackbar>
);
)
}
+215 -217
View File
@@ -1,10 +1,10 @@
import { useAppDispatch, useAppSelector } from "@/app/hooks";
import { selectCalendars } from "@/app/selectors/selectCalendars";
import { searchEventsAsync } from "@/features/Search/SearchSlice";
import { setView } from "@/features/Settings/SettingsSlice";
import { userAttendee } from "@/features/User/models/attendee";
import { createAttendee } from "@/features/User/models/attendee.mapper";
import { extractEventBaseUuid } from "@/utils/extractEventBaseUuid";
import { useAppDispatch, useAppSelector } from '@/app/hooks'
import { selectCalendars } from '@/app/selectors/selectCalendars'
import { searchEventsAsync } from '@/features/Search/SearchSlice'
import { setView } from '@/features/Settings/SettingsSlice'
import { userAttendee } from '@/features/User/models/attendee'
import { createAttendee } from '@/features/User/models/attendee.mapper'
import { extractEventBaseUuid } from '@/utils/extractEventBaseUuid'
import {
Box,
Button,
@@ -21,199 +21,199 @@ import {
Stack,
TextField,
Typography,
type AutocompleteRenderInputParams,
} from "@linagora/twake-mui";
import HighlightOffIcon from "@mui/icons-material/HighlightOff";
import SearchIcon from "@mui/icons-material/Search";
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 { CalendarItemList } from "../Calendar/CalendarItemList";
type AutocompleteRenderInputParams
} from '@linagora/twake-mui'
import HighlightOffIcon from '@mui/icons-material/HighlightOff'
import SearchIcon from '@mui/icons-material/Search'
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 { CalendarItemList } from '../Calendar/CalendarItemList'
const SEARCH_OBJECT_TYPES = ["user", "contact"];
const SEARCH_OBJECT_TYPES = ['user', 'contact']
export default function SearchBar() {
const { t } = useI18n();
const dispatch = useAppDispatch();
const calendars = useAppSelector(selectCalendars);
const userId = useAppSelector((state) => state.user.userData?.openpaasId);
const { t } = useI18n()
const dispatch = useAppDispatch()
const calendars = useAppSelector(selectCalendars)
const userId = useAppSelector(state => state.user.userData?.openpaasId)
const personnalCalendars = userId
? calendars.filter((c) => extractEventBaseUuid(c.id) === userId)
: [];
? calendars.filter(c => extractEventBaseUuid(c.id) === userId)
: []
const [search, setSearch] = useState("");
const [selectedContacts, setSelectedContacts] = useState<User[]>([]);
const [extended, setExtended] = useState(false);
const [search, setSearch] = useState('')
const [selectedContacts, setSelectedContacts] = useState<User[]>([])
const [extended, setExtended] = useState(false)
const [filters, setFilters] = useState({
searchIn: "my-calendars",
keywords: "",
searchIn: 'my-calendars',
keywords: '',
organizers: [] as userAttendee[],
attendees: [] as userAttendee[],
});
attendees: [] as userAttendee[]
})
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const filterOpen = Boolean(anchorEl);
const [filterError, setFilterError] = useState(false);
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null)
const filterOpen = Boolean(anchorEl)
const [filterError, setFilterError] = useState(false)
const searchWidth = {
xs: "10vw",
sm: "20vw",
md: "35vw",
xl: "35vw",
"@media (min-width: 2000px)": {
width: "55vw",
},
};
xs: '10vw',
sm: '20vw',
md: '35vw',
xl: '35vw',
'@media (min-width: 2000px)': {
width: '55vw'
}
}
const inputRef = useRef<HTMLInputElement | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
const shouldCollapseRef = useRef(false);
const inputRef = useRef<HTMLInputElement | null>(null)
const containerRef = useRef<HTMLDivElement | null>(null)
const shouldCollapseRef = useRef(false)
type FilterField = "searchIn" | "keywords" | "organizers" | "attendees";
type FilterField = 'searchIn' | 'keywords' | 'organizers' | 'attendees'
const handleFilterChange = (
field: FilterField,
value: string | userAttendee[]
) => {
setFilters((prev) => ({ ...prev, [field]: value }));
if (field === "organizers") {
setFilters(prev => ({ ...prev, [field]: value }))
if (field === 'organizers') {
setSelectedContacts(
(value as userAttendee[]).map((a: userAttendee) => ({
displayName: a.cn ?? a.cal_address,
email: a.cal_address || "",
email: a.cal_address || ''
}))
);
)
}
};
}
function buildQuery(
searchQuery: string,
filters: {
searchIn: string;
keywords: string;
organizers: userAttendee[];
attendees: userAttendee[];
searchIn: string
keywords: string
organizers: userAttendee[]
attendees: userAttendee[]
}
) {
const trimmedSearch = searchQuery.trim();
const trimmedKeywords = filters.keywords.trim();
const trimmedSearch = searchQuery.trim()
const trimmedKeywords = filters.keywords.trim()
// Block search if all search criteria are empty
const hasSearchCriteria =
trimmedSearch ||
trimmedKeywords ||
filters.organizers.length > 0 ||
filters.attendees.length > 0;
filters.attendees.length > 0
if (!hasSearchCriteria) {
return;
return
}
let searchInCalendars: string[];
let searchInCalendars: string[]
if (filters.searchIn === "" || !filters.searchIn) {
searchInCalendars = calendars.map((c) => c.id);
} else if (filters.searchIn === "my-calendars") {
searchInCalendars = personnalCalendars.map((c) => c.id);
if (filters.searchIn === '' || !filters.searchIn) {
searchInCalendars = calendars.map(c => c.id)
} else if (filters.searchIn === 'my-calendars') {
searchInCalendars = personnalCalendars.map(c => c.id)
} else {
searchInCalendars = [filters.searchIn];
searchInCalendars = [filters.searchIn]
}
const cleanedFilters = {
keywords: trimmedKeywords,
organizers: filters.organizers.map((u) => u.cal_address),
attendees: filters.attendees.map((u) => u.cal_address),
searchIn: searchInCalendars,
};
organizers: filters.organizers.map(u => u.cal_address),
attendees: filters.attendees.map(u => u.cal_address),
searchIn: searchInCalendars
}
return {
search: trimmedSearch,
filters: cleanedFilters,
};
filters: cleanedFilters
}
}
const handleClearFilters = () => {
setFilters({
searchIn: "my-calendars",
keywords: "",
searchIn: 'my-calendars',
keywords: '',
organizers: [] as userAttendee[],
attendees: [] as userAttendee[],
});
setAnchorEl(null);
setFilterError(false);
};
attendees: [] as userAttendee[]
})
setAnchorEl(null)
setFilterError(false)
}
const handleContactSelect = (contacts: User[]) => {
setSelectedContacts(contacts);
setSearch("");
setSelectedContacts(contacts)
setSearch('')
if (contacts.length > 0) {
handleSearch("", {
handleSearch('', {
...filters,
organizers: contacts.map((contact) =>
organizers: contacts.map(contact =>
createAttendee({
cal_address: contact.email,
cn: contact.displayName,
cn: contact.displayName
})
),
});
)
})
}
};
}
const handleSearch = async (
searchQuery: string,
filters: {
searchIn: string;
keywords: string;
organizers: userAttendee[];
attendees: userAttendee[];
searchIn: string
keywords: string
organizers: userAttendee[]
attendees: userAttendee[]
}
) => {
const cleanedQuery = buildQuery(searchQuery, filters);
const cleanedQuery = buildQuery(searchQuery, filters)
if (cleanedQuery) {
dispatch(searchEventsAsync(cleanedQuery));
dispatch(setView("search"));
setAnchorEl(null);
await dispatch(searchEventsAsync(cleanedQuery))
dispatch(setView('search'))
setAnchorEl(null)
} else {
if (filterOpen) {
setFilterError(true);
setFilterError(true)
}
}
};
}
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
const target = event.target as Node;
const target = event.target as Node
if (filterOpen) {
return;
return
}
if (
containerRef.current?.contains(target) ||
inputRef.current?.contains(target) ||
(target as HTMLElement).closest(".MuiAutocomplete-popper")
(target as HTMLElement).closest('.MuiAutocomplete-popper')
) {
return;
return
}
if (!search.trim() && selectedContacts.length === 0) {
setExtended(false);
setExtended(false)
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [filterOpen, search, selectedContacts]);
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [filterOpen, search, selectedContacts])
return (
<>
<Box
ref={containerRef}
sx={{
margin: "0 auto",
position: "relative",
width: extended ? searchWidth : "auto",
transition: "width 0.25s ease-out",
margin: '0 auto',
position: 'relative',
width: extended ? searchWidth : 'auto',
transition: 'width 0.25s ease-out'
}}
>
{!extended && (
@@ -226,29 +226,29 @@ export default function SearchBar() {
<PeopleSearch
selectedUsers={selectedContacts}
onChange={(_event, users) => {
handleContactSelect(users);
handleContactSelect(users)
}}
objectTypes={SEARCH_OBJECT_TYPES}
onToggleEventPreview={() => {}}
customSlotProps={{
popper: {
anchorEl: containerRef.current,
placement: "bottom-start",
placement: 'bottom-start',
sx: {
minWidth: searchWidth,
"& .MuiPaper-root": {
width: "100%",
},
'& .MuiPaper-root': {
width: '100%'
}
},
modifiers: [
{
name: "offset",
name: 'offset',
options: {
offset: [0, 8],
},
},
],
},
offset: [0, 8]
}
}
]
}
}}
customRenderInput={(
params: AutocompleteRenderInputParams,
@@ -259,51 +259,51 @@ export default function SearchBar() {
{...params}
fullWidth
autoFocus
placeholder={t("common.search")}
placeholder={t('common.search')}
value={query}
inputRef={(el) => {
inputRef.current = el;
const ref = params.InputProps.ref;
if (typeof ref === "function") {
ref(el);
} else if (ref && "current" in ref) {
(
inputRef={el => {
inputRef.current = el
const ref = params.InputProps.ref
if (typeof ref === 'function') {
ref(el)
} else if (ref && 'current' in ref) {
;(
ref as React.MutableRefObject<HTMLInputElement | null>
).current = el;
).current = el
}
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleSearch(query, filters);
onKeyDown={e => {
if (e.key === 'Enter') {
handleSearch(query, filters)
}
}}
onChange={(e) => {
const value = e.target.value;
setQuery(value);
setSearch(value);
onChange={e => {
const value = e.target.value
setQuery(value)
setSearch(value)
}}
variant="outlined"
sx={{
borderRadius: "999px",
"& .MuiInputBase-input": { padding: "12px 10px" },
animation: "scaleIn 0.25s ease-out",
"@keyframes scaleIn": {
from: { transform: "scaleX(0)", opacity: 0 },
to: { transform: "scaleX(1)", opacity: 1 },
borderRadius: '999px',
'& .MuiInputBase-input': { padding: '12px 10px' },
animation: 'scaleIn 0.25s ease-out',
'@keyframes scaleIn': {
from: { transform: 'scaleX(0)', opacity: 0 },
to: { transform: 'scaleX(1)', opacity: 1 }
},
transformOrigin: "right",
"& .MuiOutlinedInput-root": {
borderRadius: "999px",
transformOrigin: 'right',
'& .MuiOutlinedInput-root': {
borderRadius: '999px',
height: 40,
padding: "0 10px",
},
padding: '0 10px'
}
}}
InputProps={{
...params.InputProps,
startAdornment: (
<>
<InputAdornment position="start">
<SearchIcon sx={{ color: "#605D62" }} />
<SearchIcon sx={{ color: '#605D62' }} />
</InputAdornment>
{params.InputProps.startAdornment}
</>
@@ -312,38 +312,38 @@ export default function SearchBar() {
<InputAdornment position="end">
{(query || selectedContacts.length > 0) && (
<IconButton
aria-label={t("common.clear")}
aria-label={t('common.clear')}
onClick={() => {
setQuery("");
setSearch("");
handleFilterChange("keywords", "");
setSelectedContacts([]);
setQuery('')
setSearch('')
handleFilterChange('keywords', '')
setSelectedContacts([])
}}
>
<HighlightOffIcon />
</IconButton>
)}
<IconButton
aria-label={t("search.filter.filters")}
onMouseDown={(e) => e.preventDefault()}
aria-label={t('search.filter.filters')}
onMouseDown={e => e.preventDefault()}
onClick={() => {
setAnchorEl(containerRef.current);
handleFilterChange("keywords", query);
setAnchorEl(containerRef.current)
handleFilterChange('keywords', query)
handleFilterChange(
"organizers",
'organizers',
selectedContacts.map((attendee: User) =>
createAttendee({
cal_address: attendee.email,
cn: attendee.displayName,
cn: attendee.displayName
})
)
);
)
}}
>
<TuneIcon />
</IconButton>
</InputAdornment>
),
)
}}
/>
)}
@@ -355,11 +355,11 @@ export default function SearchBar() {
open={filterOpen}
anchorEl={anchorEl}
onClose={handleClearFilters}
anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
transformOrigin={{ vertical: "top", horizontal: "right" }}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
slotProps={{
paper: {
sx: { mt: 1.2, width: extended ? searchWidth : "auto" },
sx: { mt: 1.2, width: extended ? searchWidth : 'auto' }
},
transition: {
onExited: () => {
@@ -368,11 +368,11 @@ export default function SearchBar() {
selectedContacts.length === 0 &&
shouldCollapseRef.current
) {
setExtended(false);
setExtended(false)
}
shouldCollapseRef.current = false;
},
},
shouldCollapseRef.current = false
}
}
}}
>
<Card sx={{ p: 2, pb: 1 }}>
@@ -380,56 +380,54 @@ export default function SearchBar() {
<Stack spacing={2}>
<Box
sx={{
display: "grid",
gridTemplateColumns: "140px 1fr",
display: 'grid',
gridTemplateColumns: '140px 1fr',
gap: 2,
alignItems: "center",
alignItems: 'center'
}}
>
<InputLabel id="search-in" sx={{ m: 0 }}>
{t("search.searchIn")}
{t('search.searchIn')}
</InputLabel>
<Select
displayEmpty
value={filters.searchIn}
onChange={(e) =>
handleFilterChange("searchIn", e.target.value)
}
onChange={e => handleFilterChange('searchIn', e.target.value)}
MenuProps={{
PaperProps: {
style: {
maxHeight: 300,
color: "#8C9CAF",
},
},
color: '#8C9CAF'
}
}
}}
sx={{ height: "40px" }}
sx={{ height: '40px' }}
>
<MenuItem value="">
<Typography
sx={{
color: "#243B55",
font: "Roboto",
fontSize: "16px",
color: '#243B55',
font: 'Roboto',
fontSize: '16px',
weight: 400,
pointerEvents: "auto",
pointerEvents: 'auto'
}}
>
{t("search.filter.allCalendar")}
{t('search.filter.allCalendar')}
</Typography>
</MenuItem>
<Divider />
<MenuItem
value="my-calendars"
sx={{
color: "#243B55",
font: "Roboto",
fontSize: "12px",
color: '#243B55',
font: 'Roboto',
fontSize: '12px',
weight: 400,
pointerEvents: "auto",
pointerEvents: 'auto'
}}
>
{t("search.filter.myCalendar")}
{t('search.filter.myCalendar')}
</MenuItem>
{CalendarItemList(personnalCalendars)}
</Select>
@@ -437,25 +435,25 @@ export default function SearchBar() {
<Box
sx={{
display: "grid",
gridTemplateColumns: "140px 1fr",
display: 'grid',
gridTemplateColumns: '140px 1fr',
gap: 2,
alignItems: "center",
alignItems: 'center'
}}
>
<InputLabel id="keywords" sx={{ m: 0 }}>
{t("search.keywords")}
{t('search.keywords')}
</InputLabel>
<TextField
fullWidth
error={filterError}
helperText={filterError ? t("search.error.emptySearch") : ""}
placeholder={t("search.keywordsPlaceholder")}
helperText={filterError ? t('search.error.emptySearch') : ''}
placeholder={t('search.keywordsPlaceholder')}
value={filters.keywords}
onChange={(e) => {
handleFilterChange("keywords", e.target.value);
onChange={e => {
handleFilterChange('keywords', e.target.value)
if (e.target.value.trim()) {
setFilterError(false);
setFilterError(false)
}
}}
size="small"
@@ -464,21 +462,21 @@ export default function SearchBar() {
<Box
sx={{
display: "grid",
gridTemplateColumns: "140px 1fr",
display: 'grid',
gridTemplateColumns: '140px 1fr',
gap: 2,
alignItems: "center",
alignItems: 'center'
}}
>
<InputLabel id="from" sx={{ m: 0 }}>
{t("search.organizers")}
{t('search.organizers')}
</InputLabel>
<UserSearch
attendees={filters.organizers}
setAttendees={(users: userAttendee[]) => {
handleFilterChange("organizers", users);
handleFilterChange('organizers', users)
if (users.length > 0) {
setFilterError(false);
setFilterError(false)
}
}}
/>
@@ -486,21 +484,21 @@ export default function SearchBar() {
<Box
sx={{
display: "grid",
gridTemplateColumns: "140px 1fr",
display: 'grid',
gridTemplateColumns: '140px 1fr',
gap: 2,
alignItems: "center",
alignItems: 'center'
}}
>
<InputLabel id="participant" sx={{ m: 0 }}>
{t("search.participants")}
{t('search.participants')}
</InputLabel>
<UserSearch
attendees={filters.attendees}
setAttendees={(users: userAttendee[]) => {
handleFilterChange("attendees", users);
handleFilterChange('attendees', users)
if (users.length > 0) {
setFilterError(false);
setFilterError(false)
}
}}
/>
@@ -508,27 +506,27 @@ export default function SearchBar() {
</Stack>
</CardContent>
<CardActions sx={{ justifyContent: "flex-end", p: 2, gap: 2 }}>
<CardActions sx={{ justifyContent: 'flex-end', p: 2, gap: 2 }}>
<Button
variant="text"
onClick={() => {
handleClearFilters();
setSelectedContacts([]);
setSearch("");
shouldCollapseRef.current = true;
handleClearFilters()
setSelectedContacts([])
setSearch('')
shouldCollapseRef.current = true
}}
>
{t("common.cancel")}
{t('common.cancel')}
</Button>
<Button
variant="contained"
onClick={() => handleSearch(filters.keywords, filters)}
>
{t("common.search")}
{t('common.search')}
</Button>
</CardActions>
</Card>
</Popover>
</>
);
)
}
+174 -174
View File
@@ -1,11 +1,11 @@
import { useAppDispatch, useAppSelector } from "@/app/hooks";
import { setView } from "@/features/Settings/SettingsSlice";
import { Logout } from "@/features/User/oidcAuth";
import logo from "@/static/header-logo.svg";
import { getInitials, stringToGradient } from "@/utils/avatarUtils";
import { getUserDisplayName } from "@/utils/userUtils";
import { redirectTo } from "@/utils/navigation";
import { CalendarApi } from "@fullcalendar/core";
import { useAppDispatch, useAppSelector } from '@/app/hooks'
import { setView } from '@/features/Settings/SettingsSlice'
import { Logout } from '@/features/User/oidcAuth'
import logo from '@/static/header-logo.svg'
import { getInitials, stringToGradient } from '@/utils/avatarUtils'
import { getUserDisplayName } from '@/utils/userUtils'
import { redirectTo } from '@/utils/navigation'
import { CalendarApi } from '@fullcalendar/core'
import {
Avatar,
Box,
@@ -18,36 +18,36 @@ import {
MenuItem,
Popover,
Select,
Typography,
} from "@linagora/twake-mui";
import WidgetsOutlinedIcon from "@mui/icons-material/WidgetsOutlined";
import HelpOutlineIcon from "@mui/icons-material/HelpOutline";
import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
import LogoutIcon from "@mui/icons-material/Logout";
import RefreshIcon from "@mui/icons-material/Refresh";
import SettingsOutlinedIcon from "@mui/icons-material/SettingsOutlined";
import React, { useEffect, useState } from "react";
import { push } from "redux-first-history";
import { useI18n } from "twake-i18n";
import SearchBar from "./EventSearchBar";
import "./Menubar.styl";
Typography
} from '@linagora/twake-mui'
import WidgetsOutlinedIcon from '@mui/icons-material/WidgetsOutlined'
import HelpOutlineIcon from '@mui/icons-material/HelpOutline'
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft'
import ChevronRightIcon from '@mui/icons-material/ChevronRight'
import LogoutIcon from '@mui/icons-material/Logout'
import RefreshIcon from '@mui/icons-material/Refresh'
import SettingsOutlinedIcon from '@mui/icons-material/SettingsOutlined'
import React, { useEffect, useState } from 'react'
import { push } from 'redux-first-history'
import { useI18n } from 'twake-i18n'
import SearchBar from './EventSearchBar'
import './Menubar.styl'
export type AppIconProps = {
name: string;
link: string;
icon: string;
};
name: string
link: string
icon: string
}
export type MenubarProps = {
calendarRef: React.RefObject<CalendarApi | null>;
onRefresh: () => void;
currentDate: Date;
onDateChange?: (date: Date) => void;
currentView: string;
onViewChange?: (view: string) => void;
isIframe?: boolean;
};
calendarRef: React.RefObject<CalendarApi | null>
onRefresh: () => void
currentDate: Date
onDateChange?: (date: Date) => void
currentView: string
onViewChange?: (view: string) => void
isIframe?: boolean
}
export function Menubar({
calendarRef,
@@ -56,103 +56,103 @@ export function Menubar({
onDateChange,
currentView,
onViewChange,
isIframe,
isIframe
}: MenubarProps) {
const { t } = useI18n(); // deliberately NOT using f()
const user = useAppSelector((state) => state.user.userData);
const applist: AppIconProps[] = window.appList ?? [];
const supportLink = window.SUPPORT_URL;
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const { t } = useI18n() // deliberately NOT using f()
const user = useAppSelector(state => state.user.userData)
const applist: AppIconProps[] = window.appList ?? []
const supportLink = window.SUPPORT_URL
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null)
const [userMenuAnchorEl, setUserMenuAnchorEl] = useState<null | HTMLElement>(
null
);
const dispatch = useAppDispatch();
)
const dispatch = useAppDispatch()
useEffect(() => {
if (!user) {
dispatch(push("/"));
dispatch(push('/'))
}
}, [dispatch, user]);
}, [dispatch, user])
if (!user) {
return null;
return null
}
const handleOpen = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget);
};
setAnchorEl(event.currentTarget)
}
const handleClose = () => {
setAnchorEl(null);
};
setAnchorEl(null)
}
const handleNavigation = async (action: "prev" | "next" | "today") => {
if (!calendarRef.current) return;
await dispatch(setView("calendar"));
const handleNavigation = async (action: 'prev' | 'next' | 'today') => {
if (!calendarRef.current) return
await dispatch(setView('calendar'))
switch (action) {
case "prev":
calendarRef.current.prev();
break;
case "next":
calendarRef.current.next();
break;
case "today":
calendarRef.current.today();
break;
case 'prev':
calendarRef.current.prev()
break
case 'next':
calendarRef.current.next()
break
case 'today':
calendarRef.current.today()
break
}
// Notify parent about date change after navigation
if (onDateChange) {
const newDate = calendarRef.current.getDate();
onDateChange(newDate);
const newDate = calendarRef.current.getDate()
onDateChange(newDate)
}
};
}
const handleViewChange = async (view: string) => {
if (!calendarRef.current) return;
await dispatch(setView("calendar"));
if (!calendarRef.current) return
await dispatch(setView('calendar'))
calendarRef.current.changeView(view);
calendarRef.current.changeView(view)
// Notify parent about view change
if (onViewChange) {
onViewChange(view);
onViewChange(view)
}
// Notify parent about date change after view change
if (onDateChange) {
const newDate = calendarRef.current.getDate();
onDateChange(newDate);
const newDate = calendarRef.current.getDate()
onDateChange(newDate)
}
};
}
const open = Boolean(anchorEl);
const userMenuOpen = Boolean(userMenuAnchorEl);
const open = Boolean(anchorEl)
const userMenuOpen = Boolean(userMenuAnchorEl)
const handleUserMenuClick = (event: React.MouseEvent<HTMLElement>) => {
setUserMenuAnchorEl(event.currentTarget);
};
setUserMenuAnchorEl(event.currentTarget)
}
const handleUserMenuClose = () => {
setUserMenuAnchorEl(null);
};
setUserMenuAnchorEl(null)
}
const handleSettingsClick = () => {
dispatch(setView("settings"));
handleUserMenuClose();
};
dispatch(setView('settings'))
handleUserMenuClose()
}
const handleLogoutClick = async () => {
const logoutUrl = await Logout();
sessionStorage.removeItem("tokenSet");
redirectTo(logoutUrl.href);
handleUserMenuClose();
};
const logoutUrl = await Logout()
sessionStorage.removeItem('tokenSet')
redirectTo(logoutUrl.href)
handleUserMenuClose()
}
// Use i18n for month names instead of date-fns
const monthIndex = currentDate.getMonth();
const year = currentDate.getFullYear();
const monthName = t(`months.standalone.${monthIndex}`);
const dateLabel = `${monthName} ${year}`;
const monthIndex = currentDate.getMonth()
const year = currentDate.getFullYear()
const monthName = t(`months.standalone.${monthIndex}`)
const dateLabel = `${monthName} ${year}`
return (
<>
@@ -174,26 +174,26 @@ export function Menubar({
variant="outlined"
size="medium"
sx={{
"& button:first-of-type": {
borderRadius: "12px 0 0 12px",
},
"& button:last-of-type": {
borderRadius: "0 12px 12px 0",
'& button:first-of-type': {
borderRadius: '12px 0 0 12px'
},
'& button:last-of-type': {
borderRadius: '0 12px 12px 0'
}
}}
>
<Button
sx={{ width: 20 }}
onClick={() => handleNavigation("prev")}
onClick={() => handleNavigation('prev')}
>
<ChevronLeftIcon sx={{ height: 20 }} />
</Button>
<Button onClick={() => handleNavigation("today")}>
{t("menubar.today")}
<Button onClick={() => handleNavigation('today')}>
{t('menubar.today')}
</Button>
<Button
sx={{ width: 20 }}
onClick={() => handleNavigation("next")}
onClick={() => handleNavigation('next')}
>
<ChevronRightIcon sx={{ height: 20 }} />
</Button>
@@ -214,8 +214,8 @@ export function Menubar({
<IconButton
className="refresh-button"
onClick={onRefresh}
aria-label={t("menubar.refresh")}
title={t("menubar.refresh")}
aria-label={t('menubar.refresh')}
title={t('menubar.refresh')}
sx={{ mr: 1 }}
>
<RefreshIcon />
@@ -229,23 +229,23 @@ export function Menubar({
>
<Select
value={currentView}
onChange={(e) => handleViewChange(e.target.value)}
onChange={e => handleViewChange(e.target.value)}
variant="outlined"
aria-label={t("menubar.viewSelector")}
aria-label={t('menubar.viewSelector')}
sx={{
borderRadius: "12px",
borderRadius: '12px',
marginLeft: 1,
"& fieldset": { borderRadius: "12px" },
'& fieldset': { borderRadius: '12px' }
}}
>
<MenuItem value="dayGridMonth">
{t("menubar.views.month")}
{t('menubar.views.month')}
</MenuItem>
<MenuItem value="timeGridWeek">
{t("menubar.views.week")}
{t('menubar.views.week')}
</MenuItem>
<MenuItem value="timeGridDay">
{t("menubar.views.day")}
{t('menubar.views.day')}
</MenuItem>
</Select>
</FormControl>
@@ -260,8 +260,8 @@ export function Menubar({
target="_blank"
rel="noopener noreferrer"
style={{ marginRight: 8 }}
aria-label={t("menubar.help")}
title={t("menubar.help")}
aria-label={t('menubar.help')}
title={t('menubar.help')}
>
<HelpOutlineIcon />
</IconButton>
@@ -273,8 +273,8 @@ export function Menubar({
<IconButton
onClick={handleOpen}
style={{ marginRight: 8 }}
aria-label={t("menubar.apps")}
title={t("menubar.apps")}
aria-label={t('menubar.apps')}
title={t('menubar.apps')}
>
<WidgetsOutlinedIcon />
</IconButton>
@@ -286,13 +286,13 @@ export function Menubar({
<div className="menu-items">
<IconButton
onClick={!isIframe ? handleUserMenuClick : handleSettingsClick}
aria-label={isIframe ? t("menubar.settings") : undefined}
aria-label={isIframe ? t('menubar.settings') : undefined}
>
{!isIframe ? (
<Avatar
color={stringToGradient(getUserDisplayName(user))}
size="m"
aria-label={t("menubar.userProfile")}
aria-label={t('menubar.userProfile')}
>
{getInitials(getUserDisplayName(user))}
</Avatar>
@@ -308,20 +308,20 @@ export function Menubar({
anchorEl={anchorEl}
onClose={handleClose}
anchorOrigin={{
vertical: "bottom",
horizontal: "right",
vertical: 'bottom',
horizontal: 'right'
}}
transformOrigin={{
vertical: "top",
horizontal: "right",
vertical: 'top',
horizontal: 'right'
}}
PaperProps={{
sx: {
minWidth: 230,
mt: 2,
p: "14px 8px",
borderRadius: "14px",
},
p: '14px 8px',
borderRadius: '14px'
}
}}
>
<div className="app-grid">
@@ -336,43 +336,43 @@ export function Menubar({
anchorEl={userMenuAnchorEl}
onClose={handleUserMenuClose}
anchorOrigin={{
vertical: "bottom",
horizontal: "right",
vertical: 'bottom',
horizontal: 'right'
}}
transformOrigin={{
vertical: "top",
horizontal: "right",
vertical: 'top',
horizontal: 'right'
}}
PaperProps={{
sx: {
minWidth: 280,
mt: 1,
padding: "0 !important",
borderRadius: "14px",
},
padding: '0 !important',
borderRadius: '14px'
}
}}
>
<Box
sx={{
display: "flex",
flexDirection: "column",
alignItems: "center",
padding: "24px",
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
padding: '24px'
}}
>
<Avatar
color={stringToGradient(getUserDisplayName(user))}
size="l"
sx={{ marginBottom: "8px" }}
sx={{ marginBottom: '8px' }}
>
{getInitials(getUserDisplayName(user))}
</Avatar>
<Typography
sx={{
color: "#424244",
fontFamily: "Inter",
color: '#424244',
fontFamily: 'Inter',
fontSize: 22,
fontWeight: 600,
fontWeight: 600
}}
>
{getUserDisplayName(user)}
@@ -380,7 +380,7 @@ export function Menubar({
<Typography
sx={{
fontSize: 14,
fontWeight: 500,
fontWeight: 500
}}
>
{user?.email}
@@ -390,74 +390,74 @@ export function Menubar({
<SettingsOutlinedIcon
sx={{
mr: 2,
color: "rgba(28, 27, 31, 0.48)",
fontSize: 20,
color: 'rgba(28, 27, 31, 0.48)',
fontSize: 20
}}
/>
{t("menubar.settings") || "Settings"}
{t('menubar.settings') || 'Settings'}
</MenuItem>
<Divider />
<MenuItem onClick={handleLogoutClick} sx={{ py: 1.5 }}>
<LogoutIcon
sx={{
mr: 2,
color: "rgba(28, 27, 31, 0.48)",
fontSize: 20,
color: 'rgba(28, 27, 31, 0.48)',
fontSize: 20
}}
/>
{t("menubar.logout") || "Logout"}
{t('menubar.logout') || 'Logout'}
</MenuItem>
</Menu>
</>
);
)
}
export type MainTitleProps = {
calendarRef: React.RefObject<CalendarApi | null>;
currentView: string;
onViewChange?: (view: string) => void;
onDateChange?: (date: Date) => void;
};
calendarRef: React.RefObject<CalendarApi | null>
currentView: string
onViewChange?: (view: string) => void
onDateChange?: (date: Date) => void
}
export function MainTitle({
calendarRef,
currentView,
onViewChange,
onDateChange,
onDateChange
}: MainTitleProps) {
const { t } = useI18n();
const dispatch = useAppDispatch();
const { t } = useI18n()
const dispatch = useAppDispatch()
const handleLogoClick = async () => {
if (!calendarRef.current) return;
if (!calendarRef.current) return
await dispatch(setView("calendar"));
await dispatch(setView('calendar'))
if (currentView !== "timeGridWeek") {
calendarRef.current.changeView("timeGridWeek");
if (currentView !== 'timeGridWeek') {
calendarRef.current.changeView('timeGridWeek')
if (onViewChange) {
onViewChange("timeGridWeek");
onViewChange('timeGridWeek')
}
}
calendarRef.current.today();
calendarRef.current.today()
if (onDateChange) {
const newDate = calendarRef.current.getDate();
onDateChange(newDate);
const newDate = calendarRef.current.getDate()
onDateChange(newDate)
}
};
}
return (
<div className="menubar-item tc-home">
<img
className="logo"
src={logo}
alt={t("menubar.logoAlt")}
alt={t('menubar.logoAlt')}
onClick={handleLogoClick}
/>
</div>
);
)
}
function AppIcon({ prop }: { prop: AppIconProps }) {
@@ -468,16 +468,16 @@ function AppIcon({ prop }: { prop: AppIconProps }) {
target="_blank"
rel="noreferrer"
sx={{
textDecoration: "none",
color: "inherit",
display: "flex",
flexDirection: "column",
alignItems: "center",
p: "8px 12px 5px",
borderRadius: "14px",
"&:hover": {
backgroundColor: "action.hover",
},
textDecoration: 'none',
color: 'inherit',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
p: '8px 12px 5px',
borderRadius: '14px',
'&:hover': {
backgroundColor: 'action.hover'
}
}}
>
<Box
@@ -486,9 +486,9 @@ function AppIcon({ prop }: { prop: AppIconProps }) {
alt={prop.name}
sx={{ maxWidth: 42, height: 42 }}
/>
<Typography sx={{ mt: 0.75, textAlign: "center", fontSize: 12 }}>
<Typography sx={{ mt: 0.75, textAlign: 'center', fontSize: 12 }}>
{prop.name}
</Typography>
</Box>
);
)
}
@@ -1,29 +1,29 @@
import { Autocomplete, TextField } from "@linagora/twake-mui";
import { PublicOutlined as TimezoneIcon } from "@mui/icons-material";
import { useMemo } from "react";
import { Autocomplete, TextField } from '@linagora/twake-mui'
import { PublicOutlined as TimezoneIcon } from '@mui/icons-material'
import { useMemo } from 'react'
interface TimezoneOption {
value: string;
label: string;
offset: string;
value: string
label: string
offset: string
}
interface TimezoneAutocompleteProps {
value: string;
onChange: (timezone: string) => void;
zones: string[];
getTimezoneOffset: (tzName: string) => string;
showIcon?: boolean;
inputRef?: React.Ref<HTMLInputElement>;
width?: number | string;
size?: "small" | "medium";
placeholder?: string;
inputFontSize?: string;
inputPadding?: string;
onClose?: () => void;
disableClearable?: boolean;
hideBorder?: boolean;
openOnFocus?: boolean;
value: string
onChange: (timezone: string) => void
zones: string[]
getTimezoneOffset: (tzName: string) => string
showIcon?: boolean
inputRef?: React.Ref<HTMLInputElement>
width?: number | string
size?: 'small' | 'medium'
placeholder?: string
inputFontSize?: string
inputPadding?: string
onClose?: () => void
disableClearable?: boolean
hideBorder?: boolean
openOnFocus?: boolean
}
export function TimezoneAutocomplete({
@@ -34,24 +34,24 @@ export function TimezoneAutocomplete({
showIcon = false,
inputRef,
width,
size = "small",
placeholder = "Select timezone",
size = 'small',
placeholder = 'Select timezone',
inputFontSize,
inputPadding,
onClose,
disableClearable = false,
hideBorder = false,
openOnFocus = false,
openOnFocus = false
}: TimezoneAutocompleteProps) {
const options = useMemo<TimezoneOption[]>(() => {
return zones.map((tz) => ({
return zones.map(tz => ({
value: tz,
label: tz.replace(/_/g, " "),
offset: getTimezoneOffset(tz),
}));
}, [zones, getTimezoneOffset]);
label: tz.replace(/_/g, ' '),
offset: getTimezoneOffset(tz)
}))
}, [zones, getTimezoneOffset])
const selectedOption = options.find((opt) => opt.value === value) || null;
const selectedOption = options.find(opt => opt.value === value) || null
return (
<Autocomplete
@@ -59,35 +59,35 @@ export function TimezoneAutocomplete({
value={selectedOption}
onChange={(event, newValue) => {
if (newValue) {
onChange(newValue.value);
onClose?.();
onChange(newValue.value)
onClose?.()
}
}}
options={options}
getOptionLabel={(option) => `${option.label} (${option.offset})`}
getOptionLabel={option => `${option.label} (${option.offset})`}
size={size}
sx={width ? { width } : undefined}
disableClearable={disableClearable}
renderInput={(params) => (
renderInput={params => (
<TextField
{...params}
placeholder={placeholder}
onFocus={(e) => e.target.select()}
onFocus={e => e.target.select()}
variant="outlined"
autoComplete="off"
inputRef={inputRef}
sx={
hideBorder
? {
"& .MuiOutlinedInput-notchedOutline": {
border: "none",
'& .MuiOutlinedInput-notchedOutline': {
border: 'none'
},
"&:hover .MuiOutlinedInput-notchedOutline": {
border: "none",
},
"&.Mui-focused .MuiOutlinedInput-notchedOutline": {
border: "none",
'&:hover .MuiOutlinedInput-notchedOutline': {
border: 'none'
},
'&.Mui-focused .MuiOutlinedInput-notchedOutline': {
border: 'none'
}
}
: undefined
}
@@ -99,7 +99,7 @@ export function TimezoneAutocomplete({
<TimezoneIcon
style={{
marginRight: 8,
color: "rgba(0, 0, 0, 0.54)",
color: 'rgba(0, 0, 0, 0.54)'
}}
/>
{params.InputProps.startAdornment}
@@ -111,18 +111,18 @@ export function TimezoneAutocomplete({
? {
style: {
...(inputFontSize ? { fontSize: inputFontSize } : {}),
...(inputPadding ? { padding: inputPadding } : {}),
},
...(inputPadding ? { padding: inputPadding } : {})
}
}
: {}),
},
: {})
}
}}
inputProps={{
...params.inputProps,
autoComplete: "new-password",
autoComplete: 'new-password'
}}
/>
)}
/>
);
)
}