* #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:
@@ -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
|
||||
/>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
</>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
));
|
||||
))
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}, [])
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
[]
|
||||
),
|
||||
};
|
||||
};
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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 @@
|
||||
export { default as ResponsiveDialog } from "./ResponsiveDialog";
|
||||
export { default as ResponsiveDialog } from './ResponsiveDialog'
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
);
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
);
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 : ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user