#602 adminstrator can manage calendar resources (#703)

Co-authored-by: lethemanh <lethemanh@lethemanhs-MacBook-Pro.local>
This commit is contained in:
lethemanh
2026-03-25 14:28:24 +07:00
committed by GitHub
parent fdc9d7651b
commit 43424607d4
21 changed files with 237 additions and 36 deletions
@@ -25,6 +25,7 @@ const preloadedState = {
id: "667037022b752d0026472254/cal1", id: "667037022b752d0026472254/cal1",
name: "Calendar", name: "Calendar",
color: "#FF0000", color: "#FF0000",
owner: { emails: ["test@test.com"] },
events: { events: {
event1: { event1: {
uid: "event1", uid: "event1",
@@ -51,7 +51,12 @@ describe("helpers", () => {
expect(mockedGetUserDetails).toHaveBeenNthCalledWith(1, "resource-123"); expect(mockedGetUserDetails).toHaveBeenNthCalledWith(1, "resource-123");
expect(mockedGetResourceDetails).toHaveBeenCalledWith("resource-123"); expect(mockedGetResourceDetails).toHaveBeenCalledWith("resource-123");
expect(mockedGetUserDetails).toHaveBeenNthCalledWith(2, "creator-456"); expect(mockedGetUserDetails).toHaveBeenNthCalledWith(2, "creator-456");
expect(result).toEqual({ ...mockCreator, resource: true }); expect(result).toEqual({
...mockCreator,
resource: true,
administrators: undefined,
resourceIcon: undefined,
});
}); });
it("should throw error when getUserDetails fails with non-404 error", async () => { it("should throw error when getUserDetails fails with non-404 error", async () => {
@@ -129,4 +129,55 @@ describe("AttendanceValidation - delegation", () => {
expect(container.firstChild).not.toBeNull(); expect(container.firstChild).not.toBeNull();
}); });
}); });
describe("resource calendar", () => {
const resourceContext = makeContext({
isOwn: false,
calendar: {
id: "res1/cal1",
name: "Resource Cal",
delegated: false,
owner: {
emails: ["resource@example.com"],
resource: true,
administrators: [{ id: "admin1" }],
},
events: {},
} as Calendar,
});
it("renders when user is an administrator of the resource", () => {
const { getByText } = render(
<AttendanceValidation
contextualizedEvent={resourceContext}
user={
{
...makeUser("admin@example.com"),
openpaasId: "admin1",
} as userData
}
setAfterChoiceFunc={noopSetFunc}
setOpenEditModePopup={noopSetFunc}
/>
);
expect(getByText("eventPreview.authorizeQuestion")).toBeTruthy();
});
it("returns null when user is not an administrator of the resource", () => {
const { container } = render(
<AttendanceValidation
contextualizedEvent={resourceContext}
user={
{
...makeUser("other@example.com"),
openpaasId: "other1",
} as userData
}
setAfterChoiceFunc={noopSetFunc}
setOpenEditModePopup={noopSetFunc}
/>
);
expect(container.firstChild).toBeNull();
});
});
}); });
@@ -103,6 +103,7 @@ describe("Event Preview Display", () => {
id: "otherCal/cal", id: "otherCal/cal",
name: "Calendar 1", name: "Calendar 1",
color: "#FF0000", color: "#FF0000",
owner: { emails: ["other@test.com"] },
events: { events: {
event1: { event1: {
uid: "event1", uid: "event1",
@@ -901,7 +902,7 @@ describe("Event Preview Display", () => {
name: "Calendar 1", name: "Calendar 1",
id: "667037022b752d0026472254/cal1", id: "667037022b752d0026472254/cal1",
color: "#FF0000", color: "#FF0000",
// ownerEmails missing owner: {},
events: { events: {
event1: { event1: {
calId: "667037022b752d0026472254/cal1", calId: "667037022b752d0026472254/cal1",
@@ -61,7 +61,8 @@ describe("EventUpdateModal Timezone Handling", () => {
allday: false, allday: false,
organizer: { cn: "test", cal_address: "test@test.com" }, organizer: { cn: "test", cal_address: "test@test.com" },
attendee: [{ cn: "test", cal_address: "test@test.com" }], attendee: [{ cn: "test", cal_address: "test@test.com" }],
}; URL: "/calendars/667037022b752d0026472254/cal1/test-event-1.ics",
} as CalendarEvent;
const stateWithEvent = { const stateWithEvent = {
...preloadedState, ...preloadedState,
@@ -122,7 +123,8 @@ describe("EventUpdateModal Timezone Handling", () => {
allday: false, allday: false,
organizer: { cn: "test", cal_address: "test@test.com" }, organizer: { cn: "test", cal_address: "test@test.com" },
attendee: [{ cn: "test", cal_address: "test@test.com" }], attendee: [{ cn: "test", cal_address: "test@test.com" }],
}; URL: "/calendars/667037022b752d0026472254/cal1/test-event-2.ics",
} as CalendarEvent;
const stateWithEvent = { const stateWithEvent = {
...preloadedState, ...preloadedState,
@@ -181,7 +183,8 @@ describe("EventUpdateModal Timezone Handling", () => {
cutype: "RESOURCE", cutype: "RESOURCE",
}, },
], ],
}; URL: "/calendars/667037022b752d0026472254/cal1/test-event-resource.ics",
} as CalendarEvent;
const stateWithEvent = { const stateWithEvent = {
...preloadedState, ...preloadedState,
@@ -239,6 +242,10 @@ describe("EventUpdateModal Timezone Handling", () => {
expect(resource).toBeDefined(); expect(resource).toBeDefined();
expect(resource!.cn).toBe("Conference Room"); expect(resource!.cn).toBe("Conference Room");
expect(resource!.cal_address).toBe("room@test.com"); expect(resource!.cal_address).toBe("room@test.com");
expect(resource!.partstat).toBe("ACCEPTED");
expect(resource!.rsvp).toBe("TRUE");
expect(resource!.role).toBe("REQ-PARTICIPANT");
expect(resource!.cutype).toBe("RESOURCE");
}); });
}); });
@@ -294,7 +301,8 @@ describe("EventUpdateModal Recurring to Non-Recurring Conversion", () => {
organizer: { cn: "test", cal_address: "test@test.com" }, organizer: { cn: "test", cal_address: "test@test.com" },
attendee: [{ cn: "test", cal_address: "test@test.com" }], attendee: [{ cn: "test", cal_address: "test@test.com" }],
URL: `/calendars/${calId}/${baseUID}.ics`, URL: `/calendars/${calId}/${baseUID}.ics`,
}; timezone: "UTC",
} as CalendarEvent;
const instance1 = { const instance1 = {
...masterEvent, ...masterEvent,
@@ -445,6 +453,7 @@ describe("EventUpdateModal Recurring to Non-Recurring Conversion", () => {
organizer: { cn: "test", cal_address: "test@test.com" }, organizer: { cn: "test", cal_address: "test@test.com" },
attendee: [{ cn: "test", cal_address: "test@test.com" }], attendee: [{ cn: "test", cal_address: "test@test.com" }],
URL: `/calendars/${calId}/${baseUID}.ics`, URL: `/calendars/${calId}/${baseUID}.ics`,
timezone: "UTC",
} as CalendarEvent; } as CalendarEvent;
const instance1 = { const instance1 = {
@@ -582,7 +591,8 @@ describe("EventUpdateModal Recurring to Non-Recurring Conversion", () => {
organizer: { cn: "test", cal_address: "test@test.com" }, organizer: { cn: "test", cal_address: "test@test.com" },
attendee: [{ cn: "test", cal_address: "test@test.com" }], attendee: [{ cn: "test", cal_address: "test@test.com" }],
URL: `/calendars/${calId}/${baseUID}.ics`, URL: `/calendars/${calId}/${baseUID}.ics`,
}; timezone: "UTC",
} as CalendarEvent;
const instance1 = { const instance1 = {
...masterEvent, ...masterEvent,
+22 -2
View File
@@ -1,11 +1,31 @@
import { Avatar } from "@linagora/twake-mui"; import { Avatar, Box } from "@linagora/twake-mui";
import LayersOutlinedIcon from "@mui/icons-material/LayersOutlined"; import LayersOutlinedIcon from "@mui/icons-material/LayersOutlined";
interface ResourceIconProps { interface ResourceIconProps {
avatarUrl?: string; avatarUrl?: string;
colorIcon?: boolean;
color?: string;
} }
export function ResourceIcon({ avatarUrl }: ResourceIconProps) { export function ResourceIcon({
avatarUrl,
colorIcon,
color,
}: ResourceIconProps) {
if (colorIcon && avatarUrl) {
return (
<Box
sx={{
width: "24px",
height: "24px",
backgroundColor: color,
maskImage: `url(${avatarUrl})`,
maskSize: "cover",
}}
/>
);
}
return avatarUrl ? ( return avatarUrl ? (
<Avatar <Avatar
sx={{ backgroundColor: "transparent", width: "24px", height: "24px" }} sx={{ backgroundColor: "transparent", width: "24px", height: "24px" }}
@@ -133,7 +133,13 @@ export function CalendarAccessRights({
access: invite.access, access: invite.access,
}; };
}) })
?.filter((invite) => !!invite) || []) satisfies UserInCalendar[]; ?.filter(
(invite) =>
!!invite &&
!calendar.owner.administrators?.some(
(admin) => admin.id === invite.id
)
) || []) as UserInCalendar[];
const loaded = await handleLoadUsers(usersInCal, cancelled); const loaded = await handleLoadUsers(usersInCal, cancelled);
@@ -154,7 +160,13 @@ export function CalendarAccessRights({
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [calendar.invite, handleLoadUsers, onChange, onInvitesLoaded]); }, [
calendar.invite,
calendar.owner.administrators,
handleLoadUsers,
onChange,
onInvitesLoaded,
]);
useEffect(() => { useEffect(() => {
const isResource = calendar.owner.resource; const isResource = calendar.owner.resource;
+18 -8
View File
@@ -7,6 +7,7 @@ import { Box, Typography } from "@linagora/twake-mui";
import SquareRoundedIcon from "@mui/icons-material/SquareRounded"; import SquareRoundedIcon from "@mui/icons-material/SquareRounded";
import { useI18n } from "twake-i18n"; import { useI18n } from "twake-i18n";
import { OwnerCaption } from "./OwnerCaption"; import { OwnerCaption } from "./OwnerCaption";
import { ResourceIcon } from "../Attendees/ResourceIcon";
export function CalendarName({ calendar }: { calendar: Calendar }) { export function CalendarName({ calendar }: { calendar: Calendar }) {
const userData = useAppSelector((state) => state.user.userData); const userData = useAppSelector((state) => state.user.userData);
@@ -15,8 +16,9 @@ export function CalendarName({ calendar }: { calendar: Calendar }) {
const ownerId = calendar.id.split("/")[0]; const ownerId = calendar.id.split("/")[0];
const ownerDisplayName = makeDisplayName(calendar) ?? ""; const ownerDisplayName = makeDisplayName(calendar) ?? "";
const isOwnCalendar = userData.openpaasId === ownerId; const isOwnCalendar = userData.openpaasId === ownerId;
const isResource = calendar.owner?.resource;
const showCaption = const showCaption =
calendar.name !== "#default" && !isOwnCalendar && !calendar.owner?.resource; calendar.name !== "#default" && !isOwnCalendar && !isResource;
return ( return (
<Box <Box
@@ -27,13 +29,21 @@ export function CalendarName({ calendar }: { calendar: Calendar }) {
alignItems: "center", alignItems: "center",
}} }}
> >
<SquareRoundedIcon {isResource ? (
style={{ <ResourceIcon
color: calendar.color?.light ?? defaultColors[0].light, colorIcon
width: 24, avatarUrl={calendar.owner.resourceIcon}
height: 24, color={calendar.color?.light ?? defaultColors[0].light}
}} />
/> ) : (
<SquareRoundedIcon
style={{
color: calendar.color?.light ?? defaultColors[0].light,
width: 24,
height: 24,
}}
/>
)}
<Box style={{ display: "flex", flexDirection: "column" }}> <Box style={{ display: "flex", flexDirection: "column" }}>
<Typography variant="body2" sx={{ wordBreak: "break-word" }}> <Typography variant="body2" sx={{ wordBreak: "break-word" }}>
{renameDefault(calendar.name, ownerDisplayName, t, isOwnCalendar)} {renameDefault(calendar.name, ownerDisplayName, t, isOwnCalendar)}
@@ -137,7 +137,10 @@ export default function CalendarSelection({
(id) => extractEventBaseUuid(id) === userId (id) => extractEventBaseUuid(id) === userId
); );
const delegatedCalendars = Object.keys(calendars || {}).filter( const delegatedCalendars = Object.keys(calendars || {}).filter(
(id) => extractEventBaseUuid(id) !== userId && calendars[id]?.delegated (id) =>
extractEventBaseUuid(id) !== userId &&
calendars[id]?.delegated &&
!calendars?.[id]?.owner?.resource
); );
const sharedCalendars = Object.keys(calendars || {}).filter( const sharedCalendars = Object.keys(calendars || {}).filter(
(id) => (id) =>
@@ -147,9 +150,7 @@ export default function CalendarSelection({
); );
const resourceCalendars = Object.keys(calendars || {}).filter( const resourceCalendars = Object.keys(calendars || {}).filter(
(id) => (id) =>
extractEventBaseUuid(id) !== userId && extractEventBaseUuid(id) !== userId && calendars?.[id]?.owner?.resource
!calendars?.[id]?.delegated &&
calendars?.[id]?.owner?.resource
); );
const handleCalendarToggle = (name: string) => { const handleCalendarToggle = (name: string) => {
@@ -15,10 +15,22 @@ import { buildFamilyName } from "@/utils/buildFamilyName";
import { isEventOrganiser } from "@/utils/isEventOrganiser"; import { isEventOrganiser } from "@/utils/isEventOrganiser";
function updateEventAttendees( function updateEventAttendees(
calendar: Calendar,
event: CalendarEvent, event: CalendarEvent,
user: userData | undefined, user: userData | undefined,
rsvp: PartStat rsvp: PartStat
) { ) {
if (calendar.owner?.resource) {
const updatedAttendees =
event.attendee?.map((attendeeData) =>
attendeeData.cutype === "RESOURCE" && attendeeData.cn === calendar.name
? { ...attendeeData, partstat: rsvp }
: attendeeData
) || [];
return { attendee: updatedAttendees };
}
if (!user) { if (!user) {
throw new Error("Cannot update attendees without user data"); throw new Error("Cannot update attendees without user data");
} }
@@ -100,7 +112,7 @@ export async function handleRSVP(
) { ) {
const newEvent = { const newEvent = {
...event, ...event,
...updateEventAttendees(event, user, rsvp), ...updateEventAttendees(calendar, event, user, rsvp),
}; };
if (typeOfAction === "solo") { if (typeOfAction === "solo") {
@@ -10,6 +10,11 @@ export const fetchOwnerOfResource = async (
return { return {
...ownerData, ...ownerData,
administrators: data.administrators, administrators: data.administrators,
// The `icon` from resource detail contains the name only, so we must map with URL to have full URL of icon
resourceIcon:
window.CALENDAR_BASE_URL && data.icon
? `${window.CALENDAR_BASE_URL}/images/icon/${data.icon}.svg`
: undefined,
}; };
} catch (error) { } catch (error) {
console.error(`Failed to fetch resource details for ${resourceId}:`, error); console.error(`Failed to fetch resource details for ${resourceId}:`, error);
@@ -21,7 +21,7 @@ export function AttendanceValidation({
setAfterChoiceFunc, setAfterChoiceFunc,
setOpenEditModePopup, setOpenEditModePopup,
}: AttendanceValidationProps) { }: AttendanceValidationProps) {
const { currentUserAttendee, isOwn } = contextualizedEvent; const { currentUserAttendee, isOwn, calendar } = contextualizedEvent;
const { t } = useI18n(); const { t } = useI18n();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [loadingValue, setLoadingValue] = useState<PartStat | null>(null); const [loadingValue, setLoadingValue] = useState<PartStat | null>(null);
@@ -38,7 +38,16 @@ export function AttendanceValidation({
(!contextualizedEvent.event.class || (!contextualizedEvent.event.class ||
contextualizedEvent.event.class === "PUBLIC"); contextualizedEvent.event.class === "PUBLIC");
if (!(editRightInSelfCalendar || isDelegatedPublicEvent)) { const { owner: resourceOwner } = calendar;
const isAdminOfResource =
resourceOwner?.resource &&
resourceOwner?.administrators?.some(
(admin) => admin.id === user?.openpaasId
);
if (
!(editRightInSelfCalendar || isDelegatedPublicEvent || isAdminOfResource)
) {
return null; return null;
} }
@@ -60,7 +69,9 @@ export function AttendanceValidation({
return ( return (
<> <>
<Typography sx={{ marginRight: 2 }}> <Typography sx={{ marginRight: 2 }}>
{t("eventPreview.attendingQuestion")} {calendar.owner?.resource
? t("eventPreview.authorizeQuestion")
: t("eventPreview.attendingQuestion")}
</Typography> </Typography>
<Box display="flex" gap="15px" alignItems="center"> <Box display="flex" gap="15px" alignItems="center">
<RSVPButton rsvpValue="ACCEPTED" {...commonButtonProps} /> <RSVPButton rsvpValue="ACCEPTED" {...commonButtonProps} />
@@ -13,17 +13,22 @@ import { useI18n } from "twake-i18n";
import { CalendarEvent } from "../EventsTypes"; import { CalendarEvent } from "../EventsTypes";
import { EventPreviewAttendees } from "./EventPreviewAttendees"; import { EventPreviewAttendees } from "./EventPreviewAttendees";
import { makeRecurrenceString } from "./utils/makeRecurrenceString"; import { makeRecurrenceString } from "./utils/makeRecurrenceString";
import { renderAttendeeBadge } from "@/components/Event/utils/eventUtils";
interface EventPreviewDetailsProps { interface EventPreviewDetailsProps {
event: CalendarEvent; event: CalendarEvent;
isOwn: boolean; isOwn: boolean;
isNotPrivate: boolean; isNotPrivate: boolean;
isResourceEventPreview?: boolean;
calendarName?: string;
} }
export function EventPreviewDetails({ export function EventPreviewDetails({
event, event,
isOwn, isOwn,
isNotPrivate, isNotPrivate,
isResourceEventPreview,
calendarName,
}: EventPreviewDetailsProps) { }: EventPreviewDetailsProps) {
const { t } = useI18n(); const { t } = useI18n();
const theme = useTheme(); const theme = useTheme();
@@ -31,8 +36,14 @@ export function EventPreviewDetails({
const infoIconSx = { minWidth: "25px", marginRight: 2, color: infoIconColor }; const infoIconSx = { minWidth: "25px", marginRight: 2, color: infoIconColor };
const resources = useMemo( const resources = useMemo(
() => event?.attendee?.filter((attendee) => attendee.cutype === "RESOURCE"), () =>
[event?.attendee] event?.attendee?.filter(
(attendee) =>
attendee.cutype === "RESOURCE" &&
((isResourceEventPreview && calendarName !== attendee.cn) ||
!isResourceEventPreview)
),
[calendarName, event?.attendee, isResourceEventPreview]
); );
const eventAttendees = useMemo( const eventAttendees = useMemo(
() => () =>
@@ -106,8 +117,12 @@ export function EventPreviewDetails({
/> />
)} )}
{isResourceEventPreview &&
organizer &&
renderAttendeeBadge(organizer, "org", t, true, true)}
{/* Attendees */} {/* Attendees */}
{attendees.length > 0 && ( {!isResourceEventPreview && attendees.length > 0 && (
<EventPreviewAttendees <EventPreviewAttendees
attendees={attendees} attendees={attendees}
organizer={organizer} organizer={organizer}
@@ -144,6 +159,7 @@ export function EventPreviewDetails({
} }
content={resources.map((resource, index) => ( content={resources.map((resource, index) => (
<Box <Box
key={resource.cn}
sx={{ sx={{
marginRight: "5px", marginRight: "5px",
}} }}
@@ -172,7 +188,7 @@ export function EventPreviewDetails({
color: "#717D96", color: "#717D96",
}} }}
> >
{t(`eventPreview.${resource.partstat}`)} {t(`eventPreview.attendingStatus.${resource.partstat}`)}
</Typography> </Typography>
</Box> </Box>
))} ))}
@@ -75,6 +75,14 @@ export default function EventPreviewModal({
if (!user || !event || !calendar) return null; if (!user || !event || !calendar) return null;
const isAdminOfResource = Boolean(
calendar.owner?.resource &&
user.openpaasId &&
calendar.owner?.administrators?.some(
(admin) => admin.id === user.openpaasId
)
);
return ( return (
<> <>
<ResponsiveDialog <ResponsiveDialog
@@ -82,7 +90,9 @@ export default function EventPreviewModal({
onClose={() => onClose({}, "backdropClick")} onClose={() => onClose({}, "backdropClick")}
showHeaderActions={false} showHeaderActions={false}
actionsBorderTop={ actionsBorderTop={
!!(event.attendee?.find((p) => p.cal_address === user.email) && isOwn) !!(
event.attendee?.find((p) => p.cal_address === user.email) && isOwn
) || isAdminOfResource
} }
actionsJustifyContent="center" actionsJustifyContent="center"
style={{ overflow: "auto" }} style={{ overflow: "auto" }}
@@ -163,6 +173,8 @@ export default function EventPreviewModal({
event={event} event={event}
isOwn={isOwn} isOwn={isOwn}
isNotPrivate={isNotPrivate} isNotPrivate={isNotPrivate}
isResourceEventPreview={calendar.owner?.resource}
calendarName={calendar.name}
/> />
{/* Calendar label */} {/* Calendar label */}
+4 -1
View File
@@ -648,7 +648,10 @@ function EventUpdateModal({
newEvent.attendee.push({ newEvent.attendee.push({
cn: resource?.displayName ?? "", cn: resource?.displayName ?? "",
cal_address: resource?.email ?? "", cal_address: resource?.email ?? "",
partstat: "NEEDS-ACTION", partstat:
event.attendee?.find(
(a) => a.cutype === "RESOURCE" && a.cn === resource.displayName
)?.partstat || "NEEDS-ACTION",
rsvp: "TRUE", rsvp: "TRUE",
role: "REQ-PARTICIPANT", role: "REQ-PARTICIPANT",
cutype: "RESOURCE", cutype: "RESOURCE",
+5 -3
View File
@@ -15,9 +15,11 @@ export function createEventContext(
const attendeeEmail = calendar.delegated const attendeeEmail = calendar.delegated
? calendar.owner?.emails?.[0] ? calendar.owner?.emails?.[0]
: user.email; : user.email;
const currentUserAttendee = event.attendee?.find( const currentUserAttendee = calendar.owner?.resource
(person) => person.cal_address === attendeeEmail ? event.attendee?.find(
); (person) => person.cutype === "RESOURCE" && person.cn === calendar.name
)
: event.attendee?.find((person) => person.cal_address === attendeeEmail);
return { return {
event, event,
calendar, calendar,
@@ -16,6 +16,7 @@ export interface OpenPaasUserData {
id: string; id: string;
objectType: string; objectType: string;
}[]; }[];
resourceIcon?: string;
} }
export function ToUserData( export function ToUserData(
+7
View File
@@ -281,10 +281,17 @@
"tooltip": "Others see you as available during the time range of this event." "tooltip": "Others see you as available during the time range of this event."
}, },
"attendingQuestion": "Attending?", "attendingQuestion": "Attending?",
"authorizeQuestion": "Authorize?",
"ACCEPTED": "Accept", "ACCEPTED": "Accept",
"TENTATIVE": "Maybe", "TENTATIVE": "Maybe",
"DECLINED": "Decline", "DECLINED": "Decline",
"NEEDS-ACTION": "Pending", "NEEDS-ACTION": "Pending",
"attendingStatus": {
"ACCEPTED": "Accepted",
"TENTATIVE": "Maybe",
"DECLINED": "Declined",
"NEEDS-ACTION": "Pending"
},
"showMore": "Show more", "showMore": "Show more",
"showLess": "Show less", "showLess": "Show less",
"joinVideo": "Join the video conference", "joinVideo": "Join the video conference",
+7
View File
@@ -282,10 +282,17 @@
"tooltip": "Les autres vous voient comme disponible pendant la plage horaire de cet événement." "tooltip": "Les autres vous voient comme disponible pendant la plage horaire de cet événement."
}, },
"attendingQuestion": "Participer ?", "attendingQuestion": "Participer ?",
"authorizeQuestion": "Autoriser ?",
"ACCEPTED": "Accepter", "ACCEPTED": "Accepter",
"TENTATIVE": "Peut-être", "TENTATIVE": "Peut-être",
"DECLINED": "Décliner", "DECLINED": "Décliner",
"NEEDS-ACTION": "En attente", "NEEDS-ACTION": "En attente",
"attendingStatus": {
"ACCEPTED": "Accepté",
"TENTATIVE": "Peut-être",
"DECLINED": "Refusé",
"NEEDS-ACTION": "En attente"
},
"showMore": "Afficher plus", "showMore": "Afficher plus",
"showLess": "Afficher moins", "showLess": "Afficher moins",
"joinVideo": "Rejoindre la visioconférence", "joinVideo": "Rejoindre la visioconférence",
+7
View File
@@ -282,10 +282,17 @@
"tooltip": "Другие видят вас свободным." "tooltip": "Другие видят вас свободным."
}, },
"attendingQuestion": "Присоединитесь?", "attendingQuestion": "Присоединитесь?",
"authorizeQuestion": "Авторизовать?",
"ACCEPTED": "Да", "ACCEPTED": "Да",
"TENTATIVE": "Возможно", "TENTATIVE": "Возможно",
"DECLINED": "Нет", "DECLINED": "Нет",
"NEEDS-ACTION": "В ожидании", "NEEDS-ACTION": "В ожидании",
"attendingStatus": {
"ACCEPTED": "Принято",
"TENTATIVE": "Возможно",
"DECLINED": "Отклонено",
"NEEDS-ACTION": "В ожидании"
},
"showMore": "Показать больше", "showMore": "Показать больше",
"showLess": "Показать меньше", "showLess": "Показать меньше",
"joinVideo": "Присоединиться к видеоконференции", "joinVideo": "Присоединиться к видеоконференции",
+7
View File
@@ -280,10 +280,17 @@
"tooltip": "Người khác sẽ thấy bạn rảnh trong khoảng thời gian này." "tooltip": "Người khác sẽ thấy bạn rảnh trong khoảng thời gian này."
}, },
"attendingQuestion": "Tham gia?", "attendingQuestion": "Tham gia?",
"authorizeQuestion": "Cho phép?",
"ACCEPTED": "Chấp nhận", "ACCEPTED": "Chấp nhận",
"TENTATIVE": "Có thể", "TENTATIVE": "Có thể",
"DECLINED": "Từ chối", "DECLINED": "Từ chối",
"NEEDS-ACTION": "Đang chờ", "NEEDS-ACTION": "Đang chờ",
"attendingStatus": {
"ACCEPTED": "Đã chấp nhận",
"TENTATIVE": "Có thể",
"DECLINED": "Đã từ chối",
"NEEDS-ACTION": "Đang chờ"
},
"showMore": "Xem thêm", "showMore": "Xem thêm",
"showLess": "Thu gọn", "showLess": "Thu gọn",
"joinVideo": "Tham gia cuộc họp video", "joinVideo": "Tham gia cuộc họp video",