[#668] Tablet view (#716)

* draft for tablet view
* adapt sidebar to tablet view
* split menubar to handle tablet and desktop separatly
* split sidebar to handle tablet and desktop separatly
* prettier day navigationin tablet view
* added missing translations
* coderabbit comments
* fix UI of view switcher on tablet, remove hard text
* fix style of selected view option
* fix wrong I18n key
* extracted menubar recurring components to own files
* adjust test case of calendar component
* refactor function change calendar view

Co-authored-by: Camille Moussu <cmoussu@linagora.com>
Co-authored-by: lethemanh <lethemanh@lethemanhs-MacBook-Pro.local>
This commit is contained in:
Camille Moussu
2026-04-03 08:49:37 +02:00
committed by GitHub
parent c1d3cf5f3d
commit 4e57f6b342
36 changed files with 1549 additions and 576 deletions
+93 -15
View File
@@ -1,5 +1,6 @@
import CalendarApp from '@/components/Calendar/Calendar'
import CalendarLayout from '@/components/Calendar/CalendarLayout'
import { CALENDAR_VIEWS } from '@/components/Calendar/utils/constants'
import * as calendarDetailThunks from '@/features/Calendars/services'
import * as servicesModule from '@/features/Calendars/services'
import { searchUsers } from '@/features/User/userAPI'
@@ -14,7 +15,16 @@ const mockedSearchUsers = searchUsers as jest.MockedFunction<typeof searchUsers>
// Test wrapper component to provide calendarRef
function CalendarTestWrapper() {
const calendarRef = useRef(null)
return <CalendarApp calendarRef={calendarRef} />
return (
<CalendarApp
setCurrentView={jest.fn()}
onViewChange={jest.fn()}
openSidebar={false}
onCloseSidebar={jest.fn()}
currentView="timeGridWeek"
calendarRef={calendarRef}
/>
)
}
describe('CalendarSelection', () => {
@@ -143,7 +153,14 @@ describe('CalendarSelection', () => {
const mockCalendarRef = { current: null }
await act(async () => {
renderWithProviders(
<CalendarApp calendarRef={mockCalendarRef} />,
<CalendarApp
setCurrentView={jest.fn()}
onViewChange={jest.fn()}
openSidebar={false}
onCloseSidebar={jest.fn()}
currentView="timeGridWeek"
calendarRef={mockCalendarRef}
/>,
preloadedState
)
})
@@ -159,7 +176,14 @@ describe('CalendarSelection', () => {
const mockCalendarRef = { current: null }
await act(async () => {
renderWithProviders(
<CalendarApp calendarRef={mockCalendarRef} />,
<CalendarApp
setCurrentView={jest.fn()}
onViewChange={jest.fn()}
openSidebar={false}
onCloseSidebar={jest.fn()}
currentView="timeGridWeek"
calendarRef={mockCalendarRef}
/>,
preloadedState
)
})
@@ -194,7 +218,14 @@ describe('CalendarSelection', () => {
)
await act(async () => {
renderWithProviders(
<CalendarApp calendarRef={mockCalendarRef} />,
<CalendarApp
setCurrentView={jest.fn()}
onViewChange={jest.fn()}
openSidebar={false}
onCloseSidebar={jest.fn()}
currentView="timeGridWeek"
calendarRef={mockCalendarRef}
/>,
preloadedState
)
})
@@ -207,7 +238,14 @@ describe('CalendarSelection', () => {
const mockCalendarRef = { current: null }
await act(async () => {
renderWithProviders(
<CalendarApp calendarRef={mockCalendarRef} />,
<CalendarApp
setCurrentView={jest.fn()}
onViewChange={jest.fn()}
openSidebar={false}
onCloseSidebar={jest.fn()}
currentView="timeGridWeek"
calendarRef={mockCalendarRef}
/>,
preloadedState
)
})
@@ -423,8 +461,9 @@ describe('calendar Availability search', () => {
})
const calendarApi = calendarRef.current
expect(calendarApi).not.toBeNull()
await act(async () => {
calendarApi.changeView('dayGridMonth')
calendarApi?.changeView(CALENDAR_VIEWS.dayGridMonth)
})
await waitFor(() => {
expect(screen.queryAllByRole('columnheader').length).toBe(14)
@@ -472,9 +511,10 @@ describe('calendar Availability search', () => {
const calendarRef = window.__calendarRef
const calendarApi = calendarRef.current
expect(calendarApi).not.toBeNull()
await act(async () => {
calendarApi.changeView('dayGridMonth')
calendarApi?.changeView(CALENDAR_VIEWS.dayGridMonth)
fireEvent.click(screen.getByTestId('ChevronRightIcon'))
})
@@ -554,7 +594,14 @@ describe('calendar Availability search', () => {
await act(async () => {
renderWithProviders(
<CalendarApp calendarRef={{ current: null }} />,
<CalendarApp
setCurrentView={jest.fn()}
onViewChange={jest.fn()}
openSidebar={false}
onCloseSidebar={jest.fn()}
currentView="timeGridWeek"
calendarRef={{ current: null }}
/>,
stateWithManyCalendars
)
})
@@ -612,7 +659,14 @@ describe('calendar Availability search', () => {
await act(async () => {
renderWithProviders(
<CalendarApp calendarRef={{ current: null }} />,
<CalendarApp
setCurrentView={jest.fn()}
onViewChange={jest.fn()}
openSidebar={false}
onCloseSidebar={jest.fn()}
currentView="timeGridWeek"
calendarRef={{ current: null }}
/>,
calendarsWithSelected
)
})
@@ -646,10 +700,20 @@ describe('calendar Availability search', () => {
)
await act(async () => {
renderWithProviders(<CalendarApp calendarRef={{ current: null }} />, {
...preloadedState,
calendars: { ...preloadedState.calendars, pending: false }
})
renderWithProviders(
<CalendarApp
setCurrentView={jest.fn()}
onViewChange={jest.fn()}
openSidebar={false}
onCloseSidebar={jest.fn()}
currentView="timeGridWeek"
calendarRef={{ current: null }}
/>,
{
...preloadedState,
calendars: { ...preloadedState.calendars, pending: false }
}
)
})
await waitFor(
@@ -682,7 +746,14 @@ describe('calendar Availability search', () => {
await act(async () => {
renderWithProviders(
<CalendarApp calendarRef={{ current: null }} />,
<CalendarApp
setCurrentView={jest.fn()}
onViewChange={jest.fn()}
openSidebar={false}
onCloseSidebar={jest.fn()}
currentView="timeGridWeek"
calendarRef={{ current: null }}
/>,
stateWithUndefinedCalendars
)
})
@@ -701,7 +772,14 @@ describe('calendar Availability search', () => {
await act(async () => {
renderWithProviders(
<CalendarApp calendarRef={{ current: null }} />,
<CalendarApp
setCurrentView={jest.fn()}
onViewChange={jest.fn()}
openSidebar={false}
onCloseSidebar={jest.fn()}
currentView="timeGridWeek"
calendarRef={{ current: null }}
/>,
stateWithUndefinedTemp
)
})
@@ -1,4 +1,5 @@
import CalendarLayout from '@/components/Calendar/CalendarLayout'
import { CALENDAR_VIEWS } from '@/components/Calendar/utils/constants'
import { act, waitFor } from '@testing-library/react'
import { renderWithProviders } from '../utils/Renderwithproviders'
@@ -51,7 +52,7 @@ describe('Calendar dayHeaderContent respects selected timezone', () => {
await waitFor(() => expect(calendarRef?.current).not.toBeNull())
await act(async () => {
calendarRef.current?.changeView('timeGridWeek')
calendarRef.current?.changeView(CALENDAR_VIEWS.timeGridWeek)
})
await waitFor(() => {
+79 -41
View File
@@ -75,7 +75,14 @@ describe('CalendarApp integration', () => {
const mockCalendarRef = { current: null }
renderWithProviders(
<CalendarApp calendarRef={mockCalendarRef} />,
<CalendarApp
setCurrentView={jest.fn()}
onViewChange={jest.fn()}
openSidebar={false}
onCloseSidebar={jest.fn()}
currentView="timeGridWeek"
calendarRef={mockCalendarRef}
/>,
preloadedState
)
}
@@ -172,7 +179,14 @@ describe('CalendarApp integration', () => {
})
const mockCalendarRef = { current: null }
renderWithProviders(
<CalendarApp calendarRef={mockCalendarRef} />,
<CalendarApp
setCurrentView={jest.fn()}
onViewChange={jest.fn()}
openSidebar={false}
onCloseSidebar={jest.fn()}
currentView="timeGridWeek"
calendarRef={mockCalendarRef}
/>,
preloadedState
)
const card = screen.getByTestId('event-card-event1')
@@ -191,7 +205,14 @@ describe('CalendarApp integration', () => {
})
const mockCalendarRef = { current: null }
renderWithProviders(
<CalendarApp calendarRef={mockCalendarRef} />,
<CalendarApp
setCurrentView={jest.fn()}
onViewChange={jest.fn()}
openSidebar={false}
onCloseSidebar={jest.fn()}
currentView="timeGridWeek"
calendarRef={mockCalendarRef}
/>,
preloadedState
)
@@ -211,7 +232,14 @@ describe('CalendarApp integration', () => {
})
const mockCalendarRef = { current: null }
renderWithProviders(
<CalendarApp calendarRef={mockCalendarRef} />,
<CalendarApp
setCurrentView={jest.fn()}
onViewChange={jest.fn()}
openSidebar={false}
onCloseSidebar={jest.fn()}
currentView="timeGridWeek"
calendarRef={mockCalendarRef}
/>,
preloadedState
)
@@ -222,46 +250,56 @@ describe('CalendarApp integration', () => {
it('does render a title for events without any attendees or user as organizer', async () => {
const mockCalendarRef = { current: null }
renderWithProviders(<CalendarApp calendarRef={mockCalendarRef} />, {
user: {
userData: {
sub: 'test',
email: 'test@test.com',
sid: 'mockSid',
openpaasId: '667037022b752d0026472254'
},
tokens: {
accessToken: 'token'
}
},
calendars: {
list: {
'667037022b752d0026472254/cal1': {
name: 'Calendar 1',
id: '667037022b752d0026472254/cal1',
color: { light: '#FF0000', dark: '#000' },
owner: { emails: ['alice@example.com'] },
events: {
event1: {
id: 'event1',
calId: '667037022b752d0026472254/cal1',
uid: 'event1',
start: new Date().toISOString(),
end: new Date(Date.now() + 3600000).toISOString(),
partstat: 'ACCEPTED',
organizer: {
cn: 'Alice',
cal_address: 'alice@example.com'
},
class: 'PUBLIC',
title: 'Public Event'
}
}
renderWithProviders(
<CalendarApp
setCurrentView={jest.fn()}
onViewChange={jest.fn()}
openSidebar={false}
onCloseSidebar={jest.fn()}
currentView="timeGridWeek"
calendarRef={mockCalendarRef}
/>,
{
user: {
userData: {
sub: 'test',
email: 'test@test.com',
sid: 'mockSid',
openpaasId: '667037022b752d0026472254'
},
tokens: {
accessToken: 'token'
}
},
pending: false
calendars: {
list: {
'667037022b752d0026472254/cal1': {
name: 'Calendar 1',
id: '667037022b752d0026472254/cal1',
color: { light: '#FF0000', dark: '#000' },
owner: { emails: ['alice@example.com'] },
events: {
event1: {
id: 'event1',
calId: '667037022b752d0026472254/cal1',
uid: 'event1',
start: new Date().toISOString(),
end: new Date(Date.now() + 3600000).toISOString(),
partstat: 'ACCEPTED',
organizer: {
cn: 'Alice',
cal_address: 'alice@example.com'
},
class: 'PUBLIC',
title: 'Public Event'
}
}
}
},
pending: false
}
}
})
)
expect(screen.getByText('Public Event')).toBeInTheDocument()
})
+70 -37
View File
@@ -1,3 +1,4 @@
import { CALENDAR_VIEWS } from '@/components/Calendar/utils/constants'
import { Menubar } from '@/components/Menubar/Menubar'
import * as oidcAuth from '@/features/User/oidcAuth'
import { redirectTo } from '@/utils/navigation'
@@ -35,8 +36,9 @@ describe('Calendar App Component Display Tests', () => {
<Menubar
calendarRef={mockCalendarRef}
onRefresh={mockOnRefresh}
onToggleSidebar={() => {}}
currentDate={mockCurrentDate}
currentView="dayGridMonth"
currentView={CALENDAR_VIEWS.dayGridMonth}
/>,
preloadedState
)
@@ -53,8 +55,9 @@ describe('Calendar App Component Display Tests', () => {
<Menubar
calendarRef={mockCalendarRef}
onRefresh={mockOnRefresh}
onToggleSidebar={() => {}}
currentDate={mockCurrentDate}
currentView="dayGridMonth"
currentView={CALENDAR_VIEWS.dayGridMonth}
/>,
preloadedState
)
@@ -71,8 +74,9 @@ describe('Calendar App Component Display Tests', () => {
<Menubar
calendarRef={mockCalendarRef}
onRefresh={mockOnRefresh}
onToggleSidebar={() => {}}
currentDate={mockCurrentDate}
currentView="dayGridMonth"
currentView={CALENDAR_VIEWS.dayGridMonth}
/>,
preloadedState
)
@@ -100,8 +104,9 @@ describe('Calendar App Component Display Tests', () => {
<Menubar
calendarRef={mockCalendarRef}
onRefresh={mockOnRefresh}
onToggleSidebar={() => {}}
currentDate={mockCurrentDate}
currentView="dayGridMonth"
currentView={CALENDAR_VIEWS.dayGridMonth}
/>,
preloadedState
)
@@ -131,8 +136,9 @@ describe('Calendar App Component Display Tests', () => {
<Menubar
calendarRef={mockCalendarRef}
onRefresh={mockOnRefresh}
onToggleSidebar={() => {}}
currentDate={mockCurrentDate}
currentView="dayGridMonth"
currentView={CALENDAR_VIEWS.dayGridMonth}
/>,
preloadedState
)
@@ -161,8 +167,9 @@ describe('Calendar App Component Display Tests', () => {
<Menubar
calendarRef={mockCalendarRef}
onRefresh={mockOnRefresh}
onToggleSidebar={() => {}}
currentDate={mockCurrentDate}
currentView="dayGridMonth"
currentView={CALENDAR_VIEWS.dayGridMonth}
/>,
preloadedState
)
@@ -191,8 +198,9 @@ describe('Calendar App Component Display Tests', () => {
<Menubar
calendarRef={mockCalendarRef}
onRefresh={mockOnRefresh}
onToggleSidebar={() => {}}
currentDate={mockCurrentDate}
currentView="dayGridMonth"
currentView={CALENDAR_VIEWS.dayGridMonth}
/>,
preloadedState
)
@@ -221,8 +229,9 @@ describe('Calendar App Component Display Tests', () => {
<Menubar
calendarRef={mockCalendarRef}
onRefresh={mockOnRefresh}
onToggleSidebar={() => {}}
currentDate={mockCurrentDate}
currentView="dayGridMonth"
currentView={CALENDAR_VIEWS.dayGridMonth}
/>,
preloadedState
)
@@ -251,8 +260,9 @@ describe('Calendar App Component Display Tests', () => {
<Menubar
calendarRef={mockCalendarRef}
onRefresh={mockOnRefresh}
onToggleSidebar={() => {}}
currentDate={mockCurrentDate}
currentView="dayGridMonth"
currentView={CALENDAR_VIEWS.dayGridMonth}
/>,
preloadedState
)
@@ -283,8 +293,9 @@ describe('Calendar App Component Display Tests', () => {
<Menubar
calendarRef={mockCalendarRef}
onRefresh={mockOnRefresh}
onToggleSidebar={() => {}}
currentDate={mockCurrentDate}
currentView="dayGridMonth"
currentView={CALENDAR_VIEWS.dayGridMonth}
/>,
preloadedState
)
@@ -315,8 +326,9 @@ describe('Calendar App Component Display Tests', () => {
<Menubar
calendarRef={mockCalendarRef}
onRefresh={mockOnRefresh}
onToggleSidebar={() => {}}
currentDate={mockCurrentDate}
currentView="dayGridMonth"
currentView={CALENDAR_VIEWS.dayGridMonth}
/>,
preloadedState
)
@@ -335,8 +347,9 @@ describe('Calendar App Component Display Tests', () => {
<Menubar
calendarRef={mockCalendarRef}
onRefresh={mockOnRefresh}
onToggleSidebar={() => {}}
currentDate={mockCurrentDate}
currentView="dayGridMonth"
currentView={CALENDAR_VIEWS.dayGridMonth}
/>,
preloadedState
)
@@ -356,8 +369,9 @@ describe('Calendar App Component Display Tests', () => {
<Menubar
calendarRef={mockCalendarRef}
onRefresh={mockOnRefresh}
onToggleSidebar={() => {}}
currentDate={mockCurrentDate}
currentView="dayGridMonth"
currentView={CALENDAR_VIEWS.dayGridMonth}
/>,
preloadedState
)
@@ -377,8 +391,9 @@ describe('Calendar App Component Display Tests', () => {
<Menubar
calendarRef={mockCalendarRef}
onRefresh={mockOnRefresh}
onToggleSidebar={() => {}}
currentDate={mockCurrentDate}
currentView="dayGridMonth"
currentView={CALENDAR_VIEWS.dayGridMonth}
/>,
preloadedState
)
@@ -427,8 +442,9 @@ describe('Menubar interaction with expanded Dialog', () => {
<Menubar
calendarRef={mockCalendarRef}
onRefresh={mockOnRefresh}
onToggleSidebar={() => {}}
currentDate={mockCurrentDate}
currentView="dayGridMonth"
currentView={CALENDAR_VIEWS.dayGridMonth}
/>,
preloadedState
)
@@ -447,8 +463,9 @@ describe('Menubar interaction with expanded Dialog', () => {
<Menubar
calendarRef={mockCalendarRef}
onRefresh={mockOnRefresh}
onToggleSidebar={() => {}}
currentDate={mockCurrentDate}
currentView="dayGridMonth"
currentView={CALENDAR_VIEWS.dayGridMonth}
/>,
preloadedState
)
@@ -467,8 +484,9 @@ describe('Menubar interaction with expanded Dialog', () => {
<Menubar
calendarRef={mockCalendarRef}
onRefresh={mockOnRefresh}
onToggleSidebar={() => {}}
currentDate={mockCurrentDate}
currentView="dayGridMonth"
currentView={CALENDAR_VIEWS.dayGridMonth}
/>,
preloadedState
)
@@ -487,8 +505,9 @@ describe('Menubar interaction with expanded Dialog', () => {
<Menubar
calendarRef={mockCalendarRef}
onRefresh={mockOnRefresh}
onToggleSidebar={() => {}}
currentDate={mockCurrentDate}
currentView="dayGridMonth"
currentView={CALENDAR_VIEWS.dayGridMonth}
/>,
preloadedState
)
@@ -509,8 +528,9 @@ describe('Menubar interaction with expanded Dialog', () => {
<Menubar
calendarRef={mockCalendarRef}
onRefresh={mockOnRefresh}
onToggleSidebar={() => {}}
currentDate={mockCurrentDate}
currentView="dayGridMonth"
currentView={CALENDAR_VIEWS.dayGridMonth}
/>,
preloadedState
)
@@ -531,8 +551,9 @@ describe('Menubar interaction with expanded Dialog', () => {
<Menubar
calendarRef={mockCalendarRef}
onRefresh={mockOnRefresh}
onToggleSidebar={() => {}}
currentDate={mockCurrentDate}
currentView="dayGridMonth"
currentView={CALENDAR_VIEWS.dayGridMonth}
/>,
preloadedState
)
@@ -553,8 +574,9 @@ describe('Menubar interaction with expanded Dialog', () => {
<Menubar
calendarRef={mockCalendarRef}
onRefresh={mockOnRefresh}
onToggleSidebar={() => {}}
currentDate={mockCurrentDate}
currentView="dayGridMonth"
currentView={CALENDAR_VIEWS.dayGridMonth}
/>,
preloadedState
)
@@ -579,8 +601,9 @@ describe('Menubar interaction with expanded Dialog', () => {
<Menubar
calendarRef={mockCalendarRef}
onRefresh={mockOnRefresh}
onToggleSidebar={() => {}}
currentDate={mockCurrentDate}
currentView="dayGridMonth"
currentView={CALENDAR_VIEWS.dayGridMonth}
/>,
preloadedState
)
@@ -618,8 +641,9 @@ describe('Menubar interaction with expanded Dialog', () => {
<Menubar
calendarRef={mockCalendarRef}
onRefresh={mockOnRefresh}
onToggleSidebar={() => {}}
currentDate={mockCurrentDate}
currentView="dayGridMonth"
currentView={CALENDAR_VIEWS.dayGridMonth}
/>,
preloadedState
)
@@ -650,8 +674,9 @@ describe('Menubar interaction with expanded Dialog', () => {
<Menubar
calendarRef={mockCalendarRef}
onRefresh={mockOnRefresh}
onToggleSidebar={() => {}}
currentDate={mockCurrentDate}
currentView="dayGridMonth"
currentView={CALENDAR_VIEWS.dayGridMonth}
/>,
preloadedStateWithSettings
)
@@ -681,8 +706,9 @@ describe('Menubar interaction with expanded Dialog', () => {
<Menubar
calendarRef={mockCalendarRef}
onRefresh={mockOnRefresh}
onToggleSidebar={() => {}}
currentDate={mockCurrentDate}
currentView="dayGridMonth"
currentView={CALENDAR_VIEWS.dayGridMonth}
/>,
preloadedState
)
@@ -707,8 +733,9 @@ describe('Menubar interaction with expanded Dialog', () => {
<Menubar
calendarRef={mockCalendarRef}
onRefresh={mockOnRefresh}
onToggleSidebar={() => {}}
currentDate={mockCurrentDate}
currentView="dayGridMonth"
currentView={CALENDAR_VIEWS.dayGridMonth}
/>,
preloadedState
)
@@ -745,8 +772,9 @@ describe('Menubar logout flow', () => {
<Menubar
calendarRef={{ current: null }}
onRefresh={() => {}}
onToggleSidebar={() => {}}
currentDate={new Date()}
currentView="dayGridMonth"
currentView={CALENDAR_VIEWS.dayGridMonth}
/>,
preloadedState
)
@@ -815,8 +843,9 @@ describe('Logo click navigation to current week', () => {
<Menubar
calendarRef={mockCalendarRef}
onRefresh={mockOnRefresh}
onToggleSidebar={() => {}}
currentDate={mockCurrentDate}
currentView="dayGridMonth"
currentView={CALENDAR_VIEWS.dayGridMonth}
onViewChange={mockOnViewChange}
onDateChange={mockOnDateChange}
/>,
@@ -832,9 +861,9 @@ describe('Logo click navigation to current week', () => {
expect(store.getState().settings.view).toBe('calendar')
})
expect(mockChangeView).toHaveBeenCalledWith('timeGridWeek')
expect(mockChangeView).toHaveBeenCalledWith(CALENDAR_VIEWS.timeGridWeek)
expect(mockToday).toHaveBeenCalled()
expect(mockOnViewChange).toHaveBeenCalledWith('timeGridWeek')
expect(mockOnViewChange).toHaveBeenCalledWith(CALENDAR_VIEWS.timeGridWeek)
expect(mockOnDateChange).toHaveBeenCalled()
})
@@ -857,8 +886,9 @@ describe('Logo click navigation to current week', () => {
<Menubar
calendarRef={mockCalendarRef}
onRefresh={mockOnRefresh}
onToggleSidebar={() => {}}
currentDate={mockCurrentDate}
currentView="timeGridDay"
currentView={CALENDAR_VIEWS.timeGridDay}
onViewChange={mockOnViewChange}
onDateChange={mockOnDateChange}
/>,
@@ -874,9 +904,9 @@ describe('Logo click navigation to current week', () => {
expect(store.getState().settings.view).toBe('calendar')
})
expect(mockChangeView).toHaveBeenCalledWith('timeGridWeek')
expect(mockChangeView).toHaveBeenCalledWith(CALENDAR_VIEWS.timeGridWeek)
expect(mockToday).toHaveBeenCalled()
expect(mockOnViewChange).toHaveBeenCalledWith('timeGridWeek')
expect(mockOnViewChange).toHaveBeenCalledWith(CALENDAR_VIEWS.timeGridWeek)
expect(mockOnDateChange).toHaveBeenCalled()
})
@@ -899,8 +929,9 @@ describe('Logo click navigation to current week', () => {
<Menubar
calendarRef={mockCalendarRef}
onRefresh={mockOnRefresh}
onToggleSidebar={() => {}}
currentDate={mockCurrentDate}
currentView="timeGridWeek"
currentView={CALENDAR_VIEWS.timeGridWeek}
onViewChange={mockOnViewChange}
onDateChange={mockOnDateChange}
/>,
@@ -932,8 +963,9 @@ describe('Logo click navigation to current week', () => {
<Menubar
calendarRef={mockCalendarRef}
onRefresh={mockOnRefresh}
onToggleSidebar={() => {}}
currentDate={mockCurrentDate}
currentView="dayGridMonth"
currentView={CALENDAR_VIEWS.dayGridMonth}
onViewChange={mockOnViewChange}
onDateChange={mockOnDateChange}
/>,
@@ -970,8 +1002,9 @@ describe('Logo click navigation to current week', () => {
<Menubar
calendarRef={mockCalendarRef}
onRefresh={mockOnRefresh}
onToggleSidebar={() => {}}
currentDate={mockCurrentDate}
currentView="dayGridMonth"
currentView={CALENDAR_VIEWS.dayGridMonth}
/>,
preloadedState
)
@@ -985,7 +1018,7 @@ describe('Logo click navigation to current week', () => {
expect(store.getState().settings.view).toBe('calendar')
})
expect(mockChangeView).toHaveBeenCalledWith('timeGridWeek')
expect(mockChangeView).toHaveBeenCalledWith(CALENDAR_VIEWS.timeGridWeek)
expect(mockToday).toHaveBeenCalled()
})
})
@@ -49,7 +49,14 @@ describe('MiniCalendar', () => {
}
const mockCalendarRef = { current: null }
renderWithProviders(
<CalendarApp calendarRef={mockCalendarRef} />,
<CalendarApp
setCurrentView={jest.fn()}
onViewChange={jest.fn()}
openSidebar={false}
onCloseSidebar={jest.fn()}
currentView="timeGridWeek"
calendarRef={mockCalendarRef}
/>,
preloadedState
)
}
@@ -43,7 +43,14 @@ describe('Calendar - Timezone Integration', () => {
it('renders TimezoneSelector in week view', async () => {
renderWithProviders(
<CalendarApp calendarRef={mockCalendarRef} />,
<CalendarApp
setCurrentView={jest.fn()}
onViewChange={jest.fn()}
openSidebar={false}
onCloseSidebar={jest.fn()}
currentView="timeGridWeek"
calendarRef={mockCalendarRef}
/>,
baseState
)
@@ -57,7 +64,14 @@ describe('Calendar - Timezone Integration', () => {
const setTimeZoneSpy = jest.spyOn(SettingsSlice, 'setTimeZone')
renderWithProviders(
<CalendarApp calendarRef={mockCalendarRef} />,
<CalendarApp
setCurrentView={jest.fn()}
onViewChange={jest.fn()}
openSidebar={false}
onCloseSidebar={jest.fn()}
currentView="timeGridWeek"
calendarRef={mockCalendarRef}
/>,
baseState
)
+1 -1
View File
@@ -43,7 +43,7 @@ const isValidLanguage = (
return !!lang && SUPPORTED_LANGUAGES.includes(lang as SupportedLanguage)
}
function App() {
function App(): JSX.Element {
const error = useAppSelector(state => state.user.error)
const appLoading = useAppSelector(state => state.loading.isLoading)
const userLanguage = useAppSelector(state => state.user.coreConfig.language)
+76 -76
View File
@@ -5,6 +5,7 @@ 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 { useScreenSizeDetection } from '@/useScreenSizeDetection'
import { setDisplayedDateAndRange } from '@/utils/CalendarRangeManager'
import { extractEventBaseUuid } from '@/utils/extractEventBaseUuid'
import { setSelectedCalendars as setSelectedCalendarsToStorage } from '@/utils/storage/setSelectedCalendars'
@@ -20,7 +21,7 @@ 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 { Fab } from '@linagora/twake-mui'
import AddIcon from '@mui/icons-material/Add'
import moment from 'moment-timezone'
import { MutableRefObject, useEffect, useMemo, useRef, useState } from 'react'
@@ -32,18 +33,17 @@ 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 Sidebar from './Sidebar/SideBar'
import { TimezoneSelector } from './TimezoneSelector'
import {
eventToFullCalendarFormat,
extractEvents,
updateSlotLabelVisibility
} from './utils/calendarUtils'
import { CALENDAR_VIEWS } from './utils/constants'
const localeMap: Record<string, LocaleInput | undefined> = {
fr: frLocale,
@@ -55,16 +55,24 @@ const localeMap: Record<string, LocaleInput | undefined> = {
interface CalendarAppProps {
calendarRef: MutableRefObject<CalendarApi | null>
onDateChange?: (date: Date) => void
onViewChange?: (view: string) => void
onViewChange: (view: string) => void
menubarProps?: MenubarProps
openSidebar: boolean
onCloseSidebar: () => void
setCurrentView: (view: string) => void
currentView: string
}
export default function CalendarApp({
calendarRef,
onDateChange,
onViewChange,
menubarProps
}: CalendarAppProps) {
menubarProps,
openSidebar,
onCloseSidebar,
setCurrentView,
currentView
}: CalendarAppProps): JSX.Element {
const [selectedDate, setSelectedDate] = useState(new Date())
const [debouncedDate, setDebouncedDate] = useState(new Date())
useEffect(() => {
@@ -84,6 +92,9 @@ export default function CalendarApp({
const hideDeclinedEvents = useAppSelector(
state => state.settings.hideDeclinedEvents
)
const { isTablet } = useScreenSizeDetection()
const hiddenDays = useMemo(() => {
if (!hideWorkingDays || !workingDays || workingDays.length === 0) return []
const validWorkingDays = workingDays.filter(d => d >= 0 && d <= 6)
@@ -113,7 +124,6 @@ export default function CalendarApp({
[calendarIdsString]
)
const [currentView, setCurrentView] = useState('timeGridWeek')
const timezone =
useAppSelector(state => state.settings.timeZone) ?? browserDefaultTimeZone
@@ -297,6 +307,20 @@ export default function CalendarApp({
const [tempUsers, setTempUsers] = useState<User[]>([])
const [tempEvent, setTempEvent] = useState<CalendarEvent>({} as CalendarEvent)
useEffect(() => {
if (view !== 'calendar') return
const targetView =
currentView ||
(isTablet ? CALENDAR_VIEWS.timeGridDay : CALENDAR_VIEWS.timeGridWeek)
if (calendarRef.current?.view.type === targetView) return
const id = requestAnimationFrame(() => {
if (calendarRef.current?.view.type !== targetView) {
calendarRef.current?.changeView(targetView)
}
})
return () => cancelAnimationFrame(id)
}, [view, isTablet, currentView, calendarRef])
// Event handlers
const eventHandlers = useCalendarEventHandlers({
setSelectedRange,
@@ -321,7 +345,7 @@ export default function CalendarApp({
calendarRef,
setSelectedDate,
setSelectedMiniDate,
onViewChange,
onViewChange: setCurrentView,
calendars,
tempcalendars,
// Note: To preserve current logic, this will temporarily disable eslint for react-hooks/refs
@@ -341,68 +365,40 @@ export default function CalendarApp({
<main
className={`main-layout calendar-layout ${menubarProps?.isIframe ? ' isInIframe' : ''}`}
>
<Box
className="sidebar"
sx={{
paddingTop: 0,
paddingBottom: 3,
paddingLeft: 3,
paddingRight: 2,
width: '270px'
}}
>
<Box
sx={{
position: 'sticky',
top: 0,
zIndex: 10,
backgroundColor: '#fff',
paddingTop: menubarProps?.isIframe ? '10px' : 3
}}
>
<Button
size="medium"
variant="contained"
fullWidth
onClick={() =>
eventHandlers.handleDateSelect(null as unknown as DateSelectArg)
}
sx={{
borderRadius: radius.lg,
fontSize: '16px',
fontWeight: 500,
lineHeight: 'normal'
}}
>
<AddIcon sx={{ marginRight: 0.5, fontSize: '20px' }} />{' '}
{t('event.createEvent')}
</Button>
</Box>
<MiniCalendar
calendarRef={calendarRef}
selectedDate={selectedMiniDate}
setSelectedMiniDate={setSelectedMiniDate}
/>
<Box sx={{ mb: 3, mt: 2 }}>
<TempCalendarsInput
tempUsers={tempUsers}
setTempUsers={setTempUsers}
handleToggleEventPreview={() => {
eventHandlers.handleDateSelect(null as unknown as DateSelectArg)
}}
/>
</Box>
<div className="calendarList">
<CalendarSelection
selectedCalendars={selectedCalendars}
setSelectedCalendars={setSelectedCalendars}
/>
</div>
</Box>
<Sidebar
open={openSidebar}
onClose={onCloseSidebar}
calendarRef={calendarRef}
isIframe={menubarProps?.isIframe}
onCreateEvent={() => eventHandlers.handleDateSelect(null)}
onViewChange={onViewChange}
selectedMiniDate={selectedMiniDate}
setSelectedMiniDate={setSelectedMiniDate}
selectedCalendars={selectedCalendars}
setSelectedCalendars={setSelectedCalendars}
tempUsers={tempUsers}
setTempUsers={setTempUsers}
currentView={currentView}
/>
<div className="calendar">
<ImportAlert />
{menubarProps?.isIframe && <Menubar {...menubarProps} />}
{isTablet && (
<Fab
color="primary"
aria-label={t('event.createEvent')}
onClick={() => eventHandlers.handleDateSelect(null)}
sx={{
position: 'fixed',
bottom: 24,
right: 24,
zIndex: 20,
borderRadius: '16px'
}}
>
<AddIcon />
</Fab>
)}
{view === 'calendar' && (
<FullCalendar
key={hiddenDays.join(',')}
@@ -417,7 +413,12 @@ export default function CalendarApp({
interactionPlugin,
momentTimezonePlugin
]}
initialView="timeGridWeek"
initialView={
currentView ||
(isTablet
? CALENDAR_VIEWS.timeGridDay
: CALENDAR_VIEWS.timeGridWeek)
}
firstDay={1}
editable={true}
locale={localeMap[lang]}
@@ -448,7 +449,8 @@ export default function CalendarApp({
a.extendedProps.priority - b.extendedProps.priority
}
weekNumbers={
currentView === 'timeGridWeek' || currentView === 'timeGridDay'
currentView === CALENDAR_VIEWS.timeGridWeek ||
currentView === CALENDAR_VIEWS.timeGridDay
}
weekNumberFormat={{ week: 'long' }}
weekNumberContent={arg => {
@@ -474,7 +476,7 @@ export default function CalendarApp({
month: 'short',
timeZone: timezone
})
if (arg.view.type === 'dayGridMonth') {
if (arg.view.type === CALENDAR_VIEWS.dayGridMonth) {
return (
<span
className={`fc-daygrid-day-number ${
@@ -502,7 +504,7 @@ export default function CalendarApp({
calendarRef.current?.getDate() || new Date(arg.start)
setDisplayedDateAndRange(calendarCurrentDate)
if (arg.view.type === 'dayGridMonth') {
if (arg.view.type === CALENDAR_VIEWS.dayGridMonth) {
const start = new Date(arg.start).getTime()
const end = new Date(arg.end).getTime()
const middle = start + (end - start) / 2
@@ -519,9 +521,7 @@ export default function CalendarApp({
}
// Notify parent about view change
if (onViewChange) {
onViewChange(arg.view.type)
}
setCurrentView(arg.view.type)
// Update slot label visibility when view changes
setTimeout(() => {
@@ -543,7 +543,7 @@ export default function CalendarApp({
return (
<div className="fc-daygrid-day-top">
<small>{weekDay}</small>
{arg.view.type !== 'dayGridMonth' && (
{arg.view.type !== CALENDAR_VIEWS.dayGridMonth && (
<span
className={`fc-daygrid-day-number ${arg.isToday ? 'current-date' : ''}`}
>
+44 -3
View File
@@ -1,5 +1,6 @@
import { useAppDispatch, useAppSelector } from '@/app/hooks'
import SettingsPage from '@/features/Settings/SettingsPage'
import { useScreenSizeDetection } from '@/useScreenSizeDetection'
import { getViewRange } from '@/utils/dateUtils'
import type { CalendarApi } from '@fullcalendar/core'
import CozyBridge from 'cozy-external-bridge'
@@ -8,6 +9,8 @@ import { ErrorSnackbar } from '../Error/ErrorSnackbar'
import { refreshCalendars } from '../Event/utils/eventUtils'
import { Menubar, MenubarProps } from '../Menubar/Menubar'
import CalendarApp from './Calendar'
import { CALENDAR_VIEWS } from './utils/constants'
import { setView } from '@/features/Settings/SettingsSlice'
export default function CalendarLayout() {
const calendarRef = useRef<CalendarApi | null>(null)
@@ -16,8 +19,31 @@ export default function CalendarLayout() {
const selectedCalendars = useAppSelector(state => state.calendars.list)
const tempcalendars = useAppSelector(state => state.calendars.templist)
const view = useAppSelector(state => state.settings.view)
const { isTablet } = useScreenSizeDetection()
const [openSidebar, setOpenSideBar] = useState(false)
const [currentDate, setCurrentDate] = useState<Date>(new Date())
const [currentView, setCurrentView] = useState<string>('timeGridWeek')
const [currentView, setCurrentView] = useState<string>(
isTablet ? CALENDAR_VIEWS.timeGridDay : CALENDAR_VIEWS.timeGridWeek
)
useEffect(() => {
const setView = () =>
setCurrentView(prev => {
if (
prev !== CALENDAR_VIEWS.timeGridDay &&
prev !== CALENDAR_VIEWS.timeGridWeek
) {
return prev
}
return isTablet
? CALENDAR_VIEWS.timeGridDay
: CALENDAR_VIEWS.timeGridWeek
})
setView()
}, [isTablet])
const isInIframe = useMemo(() => new CozyBridge().isInIframe(), [])
const handleRefresh = async () => {
@@ -47,8 +73,18 @@ export default function CalendarLayout() {
setCurrentDate(date)
}
const handleViewChange = (view: string) => {
const handleViewChange = (view: string): void => {
if (!calendarRef.current) return
dispatch(setView('calendar'))
calendarRef.current.changeView(view)
// Notify parent about view change
setCurrentView(view)
// Notify parent about date change after view change
const newDate = calendarRef.current.getDate()
handleDateChange(newDate)
}
// Hide topbar navigation elements when in settings view (same as fullscreen dialog mode)
@@ -72,7 +108,8 @@ export default function CalendarLayout() {
onDateChange: handleDateChange,
currentView,
onViewChange: handleViewChange,
isIframe: isInIframe
isIframe: isInIframe,
onToggleSidebar: () => setOpenSideBar(true)
}
return (
@@ -84,6 +121,10 @@ export default function CalendarLayout() {
onDateChange={handleDateChange}
onViewChange={handleViewChange}
menubarProps={menubarProps}
openSidebar={openSidebar}
onCloseSidebar={() => setOpenSideBar(false)}
setCurrentView={setCurrentView}
currentView={currentView}
/>
)}
{view === 'settings' && <SettingsPage isInIframe={isInIframe} />}
+3 -2
View File
@@ -9,6 +9,7 @@ import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'
import moment from 'moment'
import { useEffect, useState } from 'react'
import { useI18n } from 'twake-i18n'
import { CALENDAR_VIEWS } from './utils/constants'
export function MiniCalendar({
calendarRef,
@@ -70,11 +71,11 @@ export function MiniCalendar({
const isToday = date.getTime() === today.getTime()
const isSelectedDay =
calendarRef.current?.view.type === 'timeGridDay' &&
calendarRef.current?.view.type === CALENDAR_VIEWS.timeGridDay &&
date.getTime() === selected.getTime()
const isInSelectedWeek =
calendarRef.current?.view.type === 'timeGridWeek' ||
calendarRef.current?.view.type === CALENDAR_VIEWS.timeGridWeek ||
calendarRef.current?.view.type === undefined
? (() => {
const startOfWeek = computeStartOfTheWeek(selected)
+148
View File
@@ -0,0 +1,148 @@
import { CalendarApi } from '@fullcalendar/core'
import { Box, Button, Drawer, radius } from '@linagora/twake-mui'
import AddIcon from '@mui/icons-material/Add'
import CalendarViewDayOutlinedIcon from '@mui/icons-material/CalendarViewDayOutlined'
import CalendarViewMonthOutlinedIcon from '@mui/icons-material/CalendarViewMonthOutlined'
import CalendarViewWeekOutlinedIcon from '@mui/icons-material/CalendarViewWeekOutlined'
import { Dispatch, MutableRefObject, SetStateAction } from 'react'
import { useI18n } from 'twake-i18n'
import { User } from '../Attendees/PeopleSearch'
import { FieldWithLabel } from '../Event/components/FieldWithLabel'
import CalendarSelection from './CalendarSelection'
import { MiniCalendar } from './MiniCalendar'
import { TempCalendarsInput } from './TempCalendarsInput'
interface CalendarSidebarProps {
isTablet: boolean
open: boolean
onClose: () => void
calendarRef: MutableRefObject<CalendarApi | null>
isIframe?: boolean
onCreateEvent: () => void
onViewChange: (view: string) => void
selectedMiniDate: Date
setSelectedMiniDate: (date: Date) => void
selectedCalendars: string[]
setSelectedCalendars: Dispatch<SetStateAction<string[]>>
tempUsers: User[]
setTempUsers: Dispatch<SetStateAction<User[]>>
}
export default function Sidebar({
isTablet,
open,
onClose,
calendarRef,
isIframe,
onCreateEvent,
onViewChange,
selectedMiniDate,
setSelectedMiniDate,
selectedCalendars,
setSelectedCalendars,
tempUsers,
setTempUsers
}: CalendarSidebarProps) {
const { t } = useI18n()
return (
<Drawer
variant={isTablet ? 'temporary' : 'permanent'}
open={isTablet ? open : true}
onClose={onClose}
className="sidebar"
sx={{
[`& .MuiDrawer-paper`]: {
paddingTop: isTablet ? 2 : 0,
paddingBottom: 3,
paddingLeft: 3,
paddingRight: 2,
width: '270px',
marginTop: isTablet ? 0 : '70px'
},
zIndex: isTablet ? 3000 : 5
}}
slotProps={{ paper: { className: 'sidebar' } }}
>
{!isTablet && (
<>
<Box
sx={{
position: 'sticky',
top: 0,
zIndex: 10,
backgroundColor: '#fff',
paddingTop: isIframe ? '10px' : 3
}}
>
<Button
size="medium"
variant="contained"
fullWidth
onClick={onCreateEvent}
sx={{
borderRadius: radius.lg,
fontSize: '16px',
fontWeight: 500,
lineHeight: 'normal'
}}
>
<AddIcon sx={{ marginRight: 0.5, fontSize: '20px' }} />{' '}
{t('event.createEvent')}
</Button>
</Box>
<Box>
<MiniCalendar
calendarRef={calendarRef}
selectedDate={selectedMiniDate}
setSelectedMiniDate={setSelectedMiniDate}
/>
</Box>
</>
)}
{isTablet && (
<FieldWithLabel label={t('sidebar.displayMode')} isExpanded={false}>
<Button
variant="text"
onClick={() => onViewChange('timeGridDay')}
startIcon={<CalendarViewDayOutlinedIcon />}
>
{t('menubar.views.day')}
</Button>
<Button
variant="text"
onClick={() => {
onViewChange('timeGridWeek')
}}
startIcon={<CalendarViewWeekOutlinedIcon />}
>
{t('menubar.views.week')}
</Button>
<Button
variant="text"
onClick={() => onViewChange('dayGridMonth')}
startIcon={<CalendarViewMonthOutlinedIcon />}
>
{t('menubar.views.month')}
</Button>
</FieldWithLabel>
)}
<Box sx={{ mb: 3, mt: 2 }}>
<TempCalendarsInput
tempUsers={tempUsers}
setTempUsers={setTempUsers}
handleToggleEventPreview={onCreateEvent}
/>
</Box>
<Box className="calendarList">
<CalendarSelection
selectedCalendars={selectedCalendars}
setSelectedCalendars={setSelectedCalendars}
/>
</Box>
</Drawer>
)
}
@@ -0,0 +1,82 @@
import { Box, Button, Drawer, radius } from '@linagora/twake-mui'
import AddIcon from '@mui/icons-material/Add'
import { useI18n } from 'twake-i18n'
import { MiniCalendar } from '../MiniCalendar'
import { CalendarSidebarProps } from './SideBar'
import { SidebarCommonContent } from './SidebarCommonContent'
export function DesktopSidebar({
calendarRef,
isIframe,
onCreateEvent,
selectedMiniDate,
setSelectedMiniDate,
tempUsers,
setTempUsers,
selectedCalendars,
setSelectedCalendars
}: CalendarSidebarProps) {
const { t } = useI18n()
return (
<Drawer
variant="permanent"
open
className="sidebar"
sx={{
[`& .MuiDrawer-paper`]: {
paddingTop: 0,
paddingBottom: 3,
paddingLeft: 3,
paddingRight: 2,
width: '270px',
marginTop: '70px'
},
zIndex: 5
}}
slotProps={{ paper: { className: 'sidebar' } }}
>
<Box
sx={{
position: 'sticky',
top: 0,
zIndex: 10,
backgroundColor: '#fff',
paddingTop: isIframe ? '10px' : 3
}}
>
<Button
size="medium"
variant="contained"
fullWidth
onClick={onCreateEvent}
sx={{
borderRadius: radius.lg,
fontSize: '16px',
fontWeight: 500,
lineHeight: 'normal'
}}
>
<AddIcon sx={{ marginRight: 0.5, fontSize: '20px' }} />{' '}
{t('event.createEvent')}
</Button>
</Box>
<Box>
<MiniCalendar
calendarRef={calendarRef}
selectedDate={selectedMiniDate}
setSelectedMiniDate={setSelectedMiniDate}
/>
</Box>
<SidebarCommonContent
onCreateEvent={onCreateEvent}
tempUsers={tempUsers}
setTempUsers={setTempUsers}
selectedCalendars={selectedCalendars}
setSelectedCalendars={setSelectedCalendars}
/>
</Drawer>
)
}
@@ -0,0 +1,32 @@
import { useScreenSizeDetection } from '@/useScreenSizeDetection'
import { CalendarApi } from '@fullcalendar/core'
import { Dispatch, MutableRefObject, SetStateAction } from 'react'
import { User } from '../../Attendees/PeopleSearch'
import { DesktopSidebar } from './DesktopSidebar'
import { TabletSidebar } from './TabletSidebar'
export interface CalendarSidebarProps {
open: boolean
onClose: () => void
calendarRef: MutableRefObject<CalendarApi | null>
isIframe?: boolean
onCreateEvent: () => void
onViewChange: (view: string) => void
selectedMiniDate: Date
setSelectedMiniDate: (date: Date) => void
selectedCalendars: string[]
setSelectedCalendars: Dispatch<SetStateAction<string[]>>
tempUsers: User[]
setTempUsers: Dispatch<SetStateAction<User[]>>
currentView: string
}
export default function Sidebar(sharedProps: CalendarSidebarProps) {
const { isTablet } = useScreenSizeDetection()
return isTablet ? (
<TabletSidebar {...sharedProps} />
) : (
<DesktopSidebar {...sharedProps} />
)
}
@@ -0,0 +1,37 @@
import { Box } from '@linagora/twake-mui'
import CalendarSelection from '../CalendarSelection'
import { TempCalendarsInput } from '../TempCalendarsInput'
import { CalendarSidebarProps } from './SideBar'
export function SidebarCommonContent({
onCreateEvent,
tempUsers,
setTempUsers,
selectedCalendars,
setSelectedCalendars
}: Pick<
CalendarSidebarProps,
| 'onCreateEvent'
| 'tempUsers'
| 'setTempUsers'
| 'selectedCalendars'
| 'setSelectedCalendars'
>) {
return (
<>
<Box sx={{ mb: 3, mt: 2 }}>
<TempCalendarsInput
tempUsers={tempUsers}
setTempUsers={setTempUsers}
handleToggleEventPreview={onCreateEvent}
/>
</Box>
<Box className="calendarList">
<CalendarSelection
selectedCalendars={selectedCalendars}
setSelectedCalendars={setSelectedCalendars}
/>
</Box>
</>
)
}
@@ -0,0 +1,121 @@
import {
Drawer,
ListItemIcon,
ListItemText,
MenuItem,
MenuList,
useTheme
} from '@linagora/twake-mui'
import CalendarViewDayOutlinedIcon from '@mui/icons-material/CalendarViewDayOutlined'
import CalendarViewMonthOutlinedIcon from '@mui/icons-material/CalendarViewMonthOutlined'
import CalendarViewWeekOutlinedIcon from '@mui/icons-material/CalendarViewWeekOutlined'
import { useI18n } from 'twake-i18n'
import { FieldWithLabel } from '../../Event/components/FieldWithLabel'
import { CALENDAR_VIEWS } from '../utils/constants'
import { CalendarSidebarProps } from './SideBar'
import { SidebarCommonContent } from './SidebarCommonContent'
const VIEW_OPTIONS = [
{
label: 'menubar.views.day',
value: CALENDAR_VIEWS.timeGridDay,
icon: <CalendarViewDayOutlinedIcon />
},
{
label: 'menubar.views.week',
value: CALENDAR_VIEWS.timeGridWeek,
icon: <CalendarViewWeekOutlinedIcon />
},
{
label: 'menubar.views.month',
value: CALENDAR_VIEWS.dayGridMonth,
icon: <CalendarViewMonthOutlinedIcon />
}
]
export function TabletSidebar({
open,
onClose,
onViewChange,
onCreateEvent,
tempUsers,
setTempUsers,
selectedCalendars,
setSelectedCalendars,
currentView
}: CalendarSidebarProps) {
const { t } = useI18n()
const theme = useTheme()
const changeViewAndClose = (view: string): void => {
onViewChange(view)
onClose()
}
return (
<Drawer
variant="temporary"
open={open}
onClose={onClose}
className="sidebar"
sx={{
[`& .MuiDrawer-paper`]: {
paddingTop: 2,
paddingBottom: 3,
paddingLeft: 3,
paddingRight: 2,
width: '270px',
marginTop: 0
},
zIndex: 3000
}}
slotProps={{ paper: { className: 'sidebar' } }}
>
<FieldWithLabel label={t('sidebar.displayMode')} isExpanded={false}>
<MenuList>
{VIEW_OPTIONS.map(option => {
const isSelected = option.value === currentView
return (
<MenuItem
key={option.value}
selected={isSelected}
onClick={() => changeViewAndClose(option.value)}
>
<ListItemIcon
sx={{
color: isSelected
? theme.palette.primary.main
: theme.palette.text.primary
}}
>
{option.icon}
</ListItemIcon>
<ListItemText
primary={t(option.label)}
primaryTypographyProps={{
sx: {
fontSize: '14px',
fontWeight: 500,
lineHeight: '20px',
color: isSelected
? theme.palette.primary.main
: theme.palette.text.primary
}
}}
/>
</MenuItem>
)
})}
</MenuList>
</FieldWithLabel>
<SidebarCommonContent
onCreateEvent={onCreateEvent}
tempUsers={tempUsers}
setTempUsers={setTempUsers}
selectedCalendars={selectedCalendars}
setSelectedCalendars={setSelectedCalendars}
/>
</Drawer>
)
}
@@ -63,7 +63,7 @@ export const createEventHandlers = (props: EventHandlersProps) => {
timezone
} = props
const handleDateSelect = (selectInfo: DateSelectArg) => {
const handleDateSelect = (selectInfo: DateSelectArg | null) => {
setSelectedRange(selectInfo)
if (tempUsers) {
const newEvent: CalendarEvent = {
@@ -11,6 +11,7 @@ import {
ViewMountArg
} from '@fullcalendar/core'
import React from 'react'
import { CALENDAR_VIEWS } from '../utils/constants'
import { createMouseHandlers } from './mouseHandlers'
export interface ViewHandlersProps {
@@ -54,16 +55,16 @@ export const createViewHandlers = (props: ViewHandlersProps) => {
}
const handleDayHeaderDidMount = (arg: DayHeaderMountArg) => {
if (arg.view.type === 'timeGridWeek') {
if (arg.view.type === CALENDAR_VIEWS.timeGridWeek) {
const headerEl = arg.el
const handleDayHeaderClick = () => {
calendarRef.current?.changeView('timeGridDay', arg.date)
calendarRef.current?.changeView(CALENDAR_VIEWS.timeGridDay, arg.date)
setSelectedDate(new Date(arg.date))
setSelectedMiniDate(new Date(arg.date))
if (onViewChange) {
onViewChange('timeGridDay')
onViewChange(CALENDAR_VIEWS.timeGridDay)
}
}
@@ -81,7 +82,10 @@ export const createViewHandlers = (props: ViewHandlersProps) => {
}
const handleViewDidMount = (arg: ViewMountArg) => {
if (arg.view.type === 'timeGridWeek' || arg.view.type === 'timeGridDay') {
if (
arg.view.type === CALENDAR_VIEWS.timeGridWeek ||
arg.view.type === CALENDAR_VIEWS.timeGridDay
) {
const calendarEl = document.querySelector('.fc') as HTMLElement
if (calendarEl) {
const mouseHandlers = createMouseHandlers({ calendarEl })
@@ -0,0 +1,5 @@
export const CALENDAR_VIEWS = {
dayGridMonth: 'dayGridMonth',
timeGridWeek: 'timeGridWeek',
timeGridDay: 'timeGridDay'
}
+2 -1
View File
@@ -1,3 +1,4 @@
import { CALENDAR_VIEWS } from '@/components/Calendar/utils/constants'
import {
Avatar,
Box,
@@ -76,7 +77,7 @@ export function EventChip({
}
// View and time calculations
const isMonthView = arg.view.type === 'dayGridMonth'
const isMonthView = arg.view.type === CALENDAR_VIEWS.dayGridMonth
const timeZone = arg.view.calendar?.getOption('timeZone') || 'UTC'
const { startTime, endTime } = getEventTimes(event, timeZone)
const eventLength = getEventDuration(event)
+40
View File
@@ -0,0 +1,40 @@
import { Box, Typography } from '@linagora/twake-mui'
export type AppIconProps = {
name: string
link: string
icon: string
}
export function AppIcon({ prop }: { prop: AppIconProps }) {
return (
<Box
component="a"
href={prop.link}
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'
}
}}
>
<Box
component="img"
src={prop.icon}
alt={prop.name}
sx={{ maxWidth: 42, height: 42 }}
/>
<Typography sx={{ mt: 0.75, textAlign: 'center', fontSize: 12 }}>
{prop.name}
</Typography>
</Box>
)
}
+53
View File
@@ -0,0 +1,53 @@
import { IconButton, Popover } from '@linagora/twake-mui'
import WidgetsOutlinedIcon from '@mui/icons-material/WidgetsOutlined'
import { useI18n } from 'twake-i18n'
import { AppIcon, AppIconProps } from './AppIcon'
export function AppListMenu({
anchorEl,
onAppMenuOpen,
onAppMenuClose
}: {
anchorEl: HTMLElement | null
onAppMenuOpen: (event: React.MouseEvent<HTMLElement, MouseEvent>) => void
onAppMenuClose: () => void
}) {
const { t } = useI18n()
const applist: AppIconProps[] = window.appList ?? []
if (!(applist.length > 0)) {
return null
}
return (
<>
<IconButton
onClick={onAppMenuOpen}
style={{ marginRight: 8 }}
aria-label={t('menubar.apps')}
title={t('menubar.apps')}
>
<WidgetsOutlinedIcon />
</IconButton>
<Popover
open={Boolean(anchorEl)}
anchorEl={anchorEl}
onClose={onAppMenuClose}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
slotProps={{
paper: {
sx: { minWidth: 230, mt: 2, p: '14px 8px', borderRadius: '14px' }
}
}}
>
<div className="app-grid">
{applist.map((prop: AppIconProps) => (
<AppIcon key={prop.name} prop={prop} />
))}
</div>
</Popover>
</>
)
}
+123
View File
@@ -0,0 +1,123 @@
import { IconButton } from '@linagora/twake-mui'
import HelpOutlineIcon from '@mui/icons-material/HelpOutline'
import RefreshIcon from '@mui/icons-material/Refresh'
import { useI18n } from 'twake-i18n'
import { AppListMenu } from './AppListMenu'
import SearchBar from './EventSearchBar'
import { MainTitle } from './MainTitle'
import { SharedMenubarProps } from './Menubar'
import { NavigationControls } from './NavigationControls'
import { SelectView } from './SelectView'
import { UserMenu } from './UserMenu'
export function DesktopMenubar({
calendarRef,
currentView,
isIframe,
dateLabel,
supportLink,
anchorEl,
onAppMenuOpen,
onAppMenuClose,
onUserMenuOpen,
onSettingsClick,
onLogoutClick,
onNavigate,
onRefresh,
onViewChange,
onDateChange,
user,
userMenuAnchorEl,
onUserMenuClose
}: SharedMenubarProps) {
const { t } = useI18n()
return (
<header className="menubar">
<div className="left-menu">
{!isIframe && (
<div className="menu-items">
<MainTitle
calendarRef={calendarRef}
currentView={currentView}
onViewChange={onViewChange}
onDateChange={onDateChange}
/>
</div>
)}
<div className="menu-items" style={{ marginLeft: '65px' }}>
<NavigationControls onNavigate={onNavigate} />
</div>
<div className="menu-items">
<div className="current-date-time">
<p>{dateLabel}</p>
</div>
</div>
</div>
<div className="right-menu">
<div className="search-container">
<SearchBar />
</div>
<div className="menu-items">
<IconButton
className="refresh-button"
onClick={onRefresh}
aria-label={t('menubar.refresh')}
title={t('menubar.refresh')}
sx={{ mr: 1 }}
>
<RefreshIcon />
</IconButton>
</div>
<div className="menu-items">
<SelectView currentView={currentView} onViewChange={onViewChange} />
</div>
{!isIframe && (
<>
{supportLink && (
<div className="menu-items">
<IconButton
component="a"
href={supportLink}
target="_blank"
rel="noopener noreferrer"
style={{ marginRight: 8 }}
aria-label={t('menubar.help')}
title={t('menubar.help')}
>
<HelpOutlineIcon />
</IconButton>
</div>
)}
<div className="menu-items">
<AppListMenu
anchorEl={anchorEl}
onAppMenuOpen={onAppMenuOpen}
onAppMenuClose={onAppMenuClose}
/>
</div>
</>
)}
<div className="menu-items">
<UserMenu
anchorEl={userMenuAnchorEl}
onClose={onUserMenuClose}
onSettingsClick={onSettingsClick}
onLogoutClick={onLogoutClick}
onUserMenuOpen={onUserMenuOpen}
isIframe={isIframe}
user={user}
/>
</div>
</div>
</header>
)
}
+62
View File
@@ -0,0 +1,62 @@
import { useAppDispatch } from '@/app/hooks'
import { setView } from '@/features/Settings/SettingsSlice'
import logo from '@/static/header-logo.svg'
import { CalendarApi } from '@fullcalendar/core'
import { Button } from '@linagora/twake-mui'
import React from 'react'
import { useI18n } from 'twake-i18n'
import { CALENDAR_VIEWS } from '../Calendar/utils/constants'
export type MainTitleProps = {
calendarRef: React.RefObject<CalendarApi | null>
currentView: string
onViewChange?: (view: string) => void
onDateChange?: (date: Date) => void
}
export function MainTitle({
calendarRef,
currentView,
onViewChange,
onDateChange
}: MainTitleProps) {
const { t } = useI18n()
const dispatch = useAppDispatch()
const handleLogoClick = async () => {
if (!calendarRef.current) return
await dispatch(setView('calendar'))
if (currentView !== CALENDAR_VIEWS.timeGridWeek) {
calendarRef.current.changeView(CALENDAR_VIEWS.timeGridWeek)
if (onViewChange) {
onViewChange(CALENDAR_VIEWS.timeGridWeek)
}
}
calendarRef.current.today()
if (onDateChange) {
const newDate = calendarRef.current.getDate()
onDateChange(newDate)
}
}
return (
<div className="menubar-item tc-home">
<Button
onClick={handleLogoClick}
aria-label={t('menubar.logoAlt')}
style={{ background: 'none', border: 0, padding: 0, cursor: 'pointer' }}
>
<img
className="logo"
src={logo}
alt={t('menubar.logoAlt')}
onClick={handleLogoClick}
/>
</Button>
</div>
)
}
-1
View File
@@ -44,7 +44,6 @@
.menubar-item
display flex
align-items center
margin-right 65px
.tc-home
cursor pointer
+65 -385
View File
@@ -1,37 +1,16 @@
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 { userData } from '@/features/User/userDataTypes'
import { useScreenSizeDetection } from '@/useScreenSizeDetection'
import { redirectTo } from '@/utils/navigation'
import { CalendarApi } from '@fullcalendar/core'
import {
Avatar,
Box,
Button,
ButtonGroup,
Divider,
FormControl,
IconButton,
Menu,
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 { DesktopMenubar } from './DesktopMenubar'
import './Menubar.styl'
import { TabletMenubar } from './TabletMenubar'
export type AppIconProps = {
name: string
@@ -47,26 +26,53 @@ export type MenubarProps = {
currentView: string
onViewChange?: (view: string) => void
isIframe?: boolean
onToggleSidebar: () => void
}
export function Menubar({
export type SharedMenubarProps = MenubarProps & {
dateLabel: string
supportLink: string | undefined
anchorEl: HTMLElement | null
onAppMenuOpen: (event: React.MouseEvent<HTMLElement>) => void
onAppMenuClose: () => void
userMenuAnchorEl: HTMLElement | null
onUserMenuOpen: (event: React.MouseEvent<HTMLElement>) => void
onUserMenuClose: () => void
onSettingsClick: () => void
onLogoutClick: () => void
onNavigate: (action: 'prev' | 'next' | 'today') => void
onViewChange: (view: string) => void
user: userData | null
}
export const Menubar: React.FC<MenubarProps> = ({
calendarRef,
onRefresh,
currentDate,
onDateChange,
currentView,
onViewChange,
isIframe
}: MenubarProps) {
isIframe,
onToggleSidebar
}) => {
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 { isTablet } = useScreenSizeDetection()
useEffect(() => {
const resetMenuAnchorsOnResize = (): void => {
setAnchorEl(null)
setUserMenuAnchorEl(null)
}
resetMenuAnchorsOnResize()
}, [isTablet])
useEffect(() => {
if (!user) {
@@ -77,13 +83,6 @@ export function Menubar({
if (!user) {
return null
}
const handleOpen = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget)
}
const handleClose = () => {
setAnchorEl(null)
}
const handleNavigation = async (action: 'prev' | 'next' | 'today') => {
if (!calendarRef.current) return
@@ -108,29 +107,19 @@ export function Menubar({
}
const handleViewChange = async (view: string) => {
if (!calendarRef.current) return
await dispatch(setView('calendar'))
calendarRef.current.changeView(view)
// Notify parent about view change
if (onViewChange) {
onViewChange(view)
}
// Notify parent about date change after view change
if (onDateChange) {
const newDate = calendarRef.current.getDate()
onDateChange(newDate)
}
}
const open = Boolean(anchorEl)
const userMenuOpen = Boolean(userMenuAnchorEl)
const handleAppMenuOpen = (event: React.MouseEvent<HTMLElement>) =>
setAnchorEl(event.currentTarget)
const handleUserMenuClick = (event: React.MouseEvent<HTMLElement>) => {
const handleAppMenuClose = () => setAnchorEl(null)
const handleUserMenuOpen = (event: React.MouseEvent<HTMLElement>) =>
setUserMenuAnchorEl(event.currentTarget)
}
const handleUserMenuClose = () => {
setUserMenuAnchorEl(null)
@@ -154,341 +143,32 @@ export function Menubar({
const monthName = t(`months.standalone.${monthIndex}`)
const dateLabel = `${monthName} ${year}`
return (
<>
<header className="menubar">
<div className="left-menu">
{!isIframe && (
<div className="menu-items">
<MainTitle
calendarRef={calendarRef}
currentView={currentView}
onViewChange={onViewChange}
onDateChange={onDateChange}
/>
</div>
)}
<div className="menu-items">
<div className="navigation-controls">
<ButtonGroup
variant="outlined"
size="medium"
sx={{
'& 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')}
>
<ChevronLeftIcon sx={{ height: 20 }} />
</Button>
<Button onClick={() => handleNavigation('today')}>
{t('menubar.today')}
</Button>
<Button
sx={{ width: 20 }}
onClick={() => handleNavigation('next')}
>
<ChevronRightIcon sx={{ height: 20 }} />
</Button>
</ButtonGroup>
</div>
</div>
<div className="menu-items">
<div className="current-date-time">
<p>{dateLabel}</p>
</div>
</div>
</div>
<div className="right-menu">
<div className="search-container">
<SearchBar />
</div>
<div className="menu-items">
<IconButton
className="refresh-button"
onClick={onRefresh}
aria-label={t('menubar.refresh')}
title={t('menubar.refresh')}
sx={{ mr: 1 }}
>
<RefreshIcon />
</IconButton>
</div>
<div className="menu-items">
<FormControl
size="small"
style={{ minWidth: 120, marginRight: 16 }}
className="select-display"
>
<Select
value={currentView}
onChange={e => handleViewChange(e.target.value)}
variant="outlined"
aria-label={t('menubar.viewSelector')}
sx={{
borderRadius: '12px',
marginLeft: 1,
'& fieldset': { borderRadius: '12px' }
}}
>
<MenuItem value="dayGridMonth">
{t('menubar.views.month')}
</MenuItem>
<MenuItem value="timeGridWeek">
{t('menubar.views.week')}
</MenuItem>
<MenuItem value="timeGridDay">
{t('menubar.views.day')}
</MenuItem>
</Select>
</FormControl>
</div>
{!isIframe && (
<>
{supportLink && (
<div className="menu-items">
<IconButton
component="a"
href={supportLink}
target="_blank"
rel="noopener noreferrer"
style={{ marginRight: 8 }}
aria-label={t('menubar.help')}
title={t('menubar.help')}
>
<HelpOutlineIcon />
</IconButton>
</div>
)}
<div className="menu-items">
{applist.length > 0 && (
<IconButton
onClick={handleOpen}
style={{ marginRight: 8 }}
aria-label={t('menubar.apps')}
title={t('menubar.apps')}
>
<WidgetsOutlinedIcon />
</IconButton>
)}
</div>
</>
)}
<div className="menu-items">
<IconButton
onClick={!isIframe ? handleUserMenuClick : handleSettingsClick}
aria-label={isIframe ? t('menubar.settings') : undefined}
>
{!isIframe ? (
<Avatar
color={stringToGradient(getUserDisplayName(user))}
size="m"
aria-label={t('menubar.userProfile')}
>
{getInitials(getUserDisplayName(user))}
</Avatar>
) : (
<SettingsOutlinedIcon />
)}
</IconButton>
</div>
</div>
</header>
<Popover
open={open}
anchorEl={anchorEl}
onClose={handleClose}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'right'
}}
transformOrigin={{
vertical: 'top',
horizontal: 'right'
}}
PaperProps={{
sx: {
minWidth: 230,
mt: 2,
p: '14px 8px',
borderRadius: '14px'
}
}}
>
<div className="app-grid">
{applist.map((prop: AppIconProps) => (
<AppIcon key={prop.name} prop={prop} />
))}
</div>
</Popover>
<Menu
open={userMenuOpen}
anchorEl={userMenuAnchorEl}
onClose={handleUserMenuClose}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'right'
}}
transformOrigin={{
vertical: 'top',
horizontal: 'right'
}}
PaperProps={{
sx: {
minWidth: 280,
mt: 1,
padding: '0 !important',
borderRadius: '14px'
}
}}
>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
padding: '24px'
}}
>
<Avatar
color={stringToGradient(getUserDisplayName(user))}
size="l"
sx={{ marginBottom: '8px' }}
>
{getInitials(getUserDisplayName(user))}
</Avatar>
<Typography
sx={{
color: '#424244',
fontFamily: 'Inter',
fontSize: 22,
fontWeight: 600
}}
>
{getUserDisplayName(user)}
</Typography>
<Typography
sx={{
fontSize: 14,
fontWeight: 500
}}
>
{user?.email}
</Typography>
</Box>
<MenuItem onClick={handleSettingsClick} sx={{ py: 1.5 }}>
<SettingsOutlinedIcon
sx={{
mr: 2,
color: 'rgba(28, 27, 31, 0.48)',
fontSize: 20
}}
/>
{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
}}
/>
{t('menubar.logout') || 'Logout'}
</MenuItem>
</Menu>
</>
)
}
export type MainTitleProps = {
calendarRef: React.RefObject<CalendarApi | null>
currentView: string
onViewChange?: (view: string) => void
onDateChange?: (date: Date) => void
}
export function MainTitle({
calendarRef,
currentView,
onViewChange,
onDateChange
}: MainTitleProps) {
const { t } = useI18n()
const dispatch = useAppDispatch()
const handleLogoClick = async () => {
if (!calendarRef.current) return
await dispatch(setView('calendar'))
if (currentView !== 'timeGridWeek') {
calendarRef.current.changeView('timeGridWeek')
if (onViewChange) {
onViewChange('timeGridWeek')
}
}
calendarRef.current.today()
if (onDateChange) {
const newDate = calendarRef.current.getDate()
onDateChange(newDate)
}
const sharedProps: SharedMenubarProps = {
calendarRef,
onRefresh,
currentDate,
onDateChange,
currentView,
onViewChange: handleViewChange,
isIframe,
onToggleSidebar,
dateLabel,
supportLink,
anchorEl,
onAppMenuOpen: handleAppMenuOpen,
onAppMenuClose: handleAppMenuClose,
userMenuAnchorEl,
onUserMenuOpen: handleUserMenuOpen,
onUserMenuClose: handleUserMenuClose,
onSettingsClick: handleSettingsClick,
onLogoutClick: handleLogoutClick,
onNavigate: handleNavigation,
user
}
return (
<div className="menubar-item tc-home">
<img
className="logo"
src={logo}
alt={t('menubar.logoAlt')}
onClick={handleLogoClick}
/>
</div>
)
}
function AppIcon({ prop }: { prop: AppIconProps }) {
return (
<Box
component="a"
href={prop.link}
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'
}
}}
>
<Box
component="img"
src={prop.icon}
alt={prop.name}
sx={{ maxWidth: 42, height: 42 }}
/>
<Typography sx={{ mt: 0.75, textAlign: 'center', fontSize: 12 }}>
{prop.name}
</Typography>
</Box>
return isTablet ? (
<TabletMenubar {...sharedProps} />
) : (
<DesktopMenubar {...sharedProps} />
)
}
@@ -0,0 +1,44 @@
import { Button, ButtonGroup } from '@linagora/twake-mui'
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft'
import ChevronRightIcon from '@mui/icons-material/ChevronRight'
import { useI18n } from 'twake-i18n'
export function NavigationControls({
onNavigate
}: {
onNavigate: (action: 'today' | 'next' | 'prev') => void
}) {
const { t } = useI18n()
return (
<div className="navigation-controls">
<ButtonGroup
variant="outlined"
size="medium"
sx={{
'& button:first-of-type': { borderRadius: '12px 0 0 12px' },
'& button:last-of-type': { borderRadius: '0 12px 12px 0' }
}}
>
<Button
sx={{ width: 20 }}
onClick={() => onNavigate('prev')}
aria-label={t('menubar.prev')}
title={t('menubar.prev')}
>
<ChevronLeftIcon sx={{ height: 20 }} />
</Button>
<Button onClick={() => onNavigate('today')}>
{t('menubar.today')}
</Button>
<Button
sx={{ width: 20 }}
onClick={() => onNavigate('next')}
aria-label={t('menubar.next')}
title={t('menubar.next')}
>
<ChevronRightIcon sx={{ height: 20 }} />
</Button>
</ButtonGroup>
</div>
)
}
+42
View File
@@ -0,0 +1,42 @@
import { FormControl, MenuItem, Select } from '@linagora/twake-mui'
import { useI18n } from 'twake-i18n'
import { CALENDAR_VIEWS } from '../Calendar/utils/constants'
export function SelectView({
currentView,
onViewChange
}: {
currentView: string
onViewChange: (view: string) => void
}) {
const { t } = useI18n()
return (
<FormControl
size="small"
style={{ minWidth: 120, marginRight: 16 }}
className="select-display"
>
<Select
value={currentView}
onChange={e => onViewChange(e.target.value)}
variant="outlined"
aria-label={t('menubar.viewSelector')}
sx={{
borderRadius: '12px',
marginLeft: 1,
'& fieldset': { borderRadius: '12px' }
}}
>
<MenuItem value={CALENDAR_VIEWS.dayGridMonth}>
{t('menubar.views.month')}
</MenuItem>
<MenuItem value={CALENDAR_VIEWS.timeGridWeek}>
{t('menubar.views.week')}
</MenuItem>
<MenuItem value={CALENDAR_VIEWS.timeGridDay}>
{t('menubar.views.day')}
</MenuItem>
</Select>
</FormControl>
)
}
+124
View File
@@ -0,0 +1,124 @@
import { IconButton, Stack } from '@linagora/twake-mui'
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft'
import ChevronRightIcon from '@mui/icons-material/ChevronRight'
import MenuIcon from '@mui/icons-material/Menu'
import RefreshIcon from '@mui/icons-material/Refresh'
import TodayIcon from '@mui/icons-material/Today'
import { useI18n } from 'twake-i18n'
import SearchBar from './EventSearchBar'
import { MainTitle } from './MainTitle'
import { SharedMenubarProps } from './Menubar'
import { UserMenu } from './UserMenu'
export function TabletMenubar({
calendarRef,
currentView,
isIframe,
dateLabel,
onUserMenuOpen,
onSettingsClick,
onLogoutClick,
onNavigate,
onRefresh,
onToggleSidebar,
user,
userMenuAnchorEl,
onUserMenuClose,
onViewChange,
onDateChange
}: SharedMenubarProps) {
const { t } = useI18n()
return (
<header className="menubar">
<div className="left-menu">
<IconButton
onClick={onToggleSidebar}
aria-label={t('menubar.toggleSidebar')}
title={t('menubar.toggleSidebar')}
>
<MenuIcon />
</IconButton>
{!isIframe && (
<div className="menu-items">
<MainTitle
calendarRef={calendarRef}
currentView={currentView}
onViewChange={onViewChange}
onDateChange={onDateChange}
/>
</div>
)}
<div className="menu-items" style={{ marginLeft: 0 }}>
<div className="navigation-controls">
<Stack direction="row">
<IconButton
onClick={() => onNavigate('prev')}
aria-label={t('menubar.prev')}
title={t('menubar.prev')}
>
<ChevronLeftIcon sx={{ height: 20 }} />
</IconButton>
<IconButton
color="primary"
sx={{
border: '1px solid',
borderRadius: '12px'
}}
onClick={() => onNavigate('today')}
aria-label={t('menubar.today')}
title={t('menubar.today')}
>
<TodayIcon />
</IconButton>
<IconButton
onClick={() => onNavigate('next')}
aria-label={t('menubar.next')}
title={t('menubar.next')}
>
<ChevronRightIcon sx={{ height: 20 }} />
</IconButton>
</Stack>
</div>
</div>
<div className="menu-items">
<div className="current-date-time">
<p>{dateLabel}</p>
</div>
</div>
</div>
<div className="right-menu">
<div className="search-container">
<SearchBar />
</div>
<div className="menu-items">
<IconButton
className="refresh-button"
onClick={onRefresh}
aria-label={t('menubar.refresh')}
title={t('menubar.refresh')}
sx={{ mr: 1 }}
>
<RefreshIcon />
</IconButton>
</div>
<div className="menu-items">
<UserMenu
anchorEl={userMenuAnchorEl}
onClose={onUserMenuClose}
onSettingsClick={onSettingsClick}
onLogoutClick={onLogoutClick}
onUserMenuOpen={onUserMenuOpen}
isIframe={isIframe}
user={user}
/>
</div>
</div>
</header>
)
}
+134
View File
@@ -0,0 +1,134 @@
import { userData } from '@/features/User/userDataTypes'
import { getInitials, stringToGradient } from '@/utils/avatarUtils'
import { getUserDisplayName } from '@/utils/userUtils'
import {
alpha,
Avatar,
Box,
Divider,
IconButton,
Menu,
MenuItem,
Typography,
useTheme
} from '@linagora/twake-mui'
import LogoutIcon from '@mui/icons-material/Logout'
import SettingsOutlinedIcon from '@mui/icons-material/SettingsOutlined'
import { MouseEvent } from 'react'
import { useI18n } from 'twake-i18n'
export type UserMenuProps = {
anchorEl: HTMLElement | null
onClose: () => void
onSettingsClick: () => void
onLogoutClick: () => void
onUserMenuOpen: (event: MouseEvent<HTMLElement>) => void
user: userData | null
isIframe?: boolean
}
export function UserMenu({
anchorEl,
onClose,
onSettingsClick,
onLogoutClick,
onUserMenuOpen,
user,
isIframe = false
}: UserMenuProps): JSX.Element {
const { t } = useI18n()
const theme = useTheme()
const displayName = getUserDisplayName(user)
const open = Boolean(anchorEl)
return (
<>
<IconButton
onClick={!isIframe ? onUserMenuOpen : onSettingsClick}
aria-label={isIframe ? t('menubar.settings') : t('menubar.userProfile')}
title={isIframe ? t('menubar.settings') : t('menubar.userProfile')}
>
{!isIframe ? (
<Avatar color={stringToGradient(displayName)} size="m">
{getInitials(displayName)}
</Avatar>
) : (
<SettingsOutlinedIcon />
)}
</IconButton>
<Menu
open={open}
anchorEl={anchorEl}
onClose={onClose}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
slotProps={{
paper: {
sx: {
minWidth: 280,
mt: 1,
padding: '0 !important',
borderRadius: '14px'
}
}
}}
>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
padding: '24px'
}}
>
<Avatar
color={stringToGradient(displayName)}
size="l"
sx={{ marginBottom: '8px' }}
>
{getInitials(displayName)}
</Avatar>
<Typography
sx={{
color: theme.palette.grey[900],
fontFamily: 'Inter',
fontSize: 22,
fontWeight: 600
}}
>
{displayName}
</Typography>
<Typography sx={{ fontSize: 14, fontWeight: 500 }}>
{user?.email}
</Typography>
</Box>
<MenuItem onClick={onSettingsClick} sx={{ py: 1.5 }}>
<SettingsOutlinedIcon
sx={{
mr: 2,
color: alpha(theme.palette.grey.A900, 0.48),
fontSize: 20
}}
/>
{t('menubar.settings') || 'Settings'}
</MenuItem>
<Divider />
<MenuItem onClick={onLogoutClick} sx={{ py: 1.5 }}>
<LogoutIcon
sx={{
mr: 2,
color: alpha(theme.palette.grey.A900, 0.48),
fontSize: 20
}}
/>
{t('menubar.logout') || 'Logout'}
</MenuItem>
</Menu>
</>
)
}
+2 -1
View File
@@ -1,4 +1,5 @@
import { useAppDispatch, useAppSelector } from '@/app/hooks'
import { CALENDAR_VIEWS } from '@/components/Calendar/utils/constants'
import { getCalendarDetailAsync } from '@/features/Calendars/services'
import {
formatDateToYYYYMMDDTHHMMSS,
@@ -78,7 +79,7 @@ export function useCalendarDataLoader({
const { visibleStart, visibleEnd, prefetchEnd } = useMemo(() => {
const { start, end } = getViewRange(selectedDate, currentView)
const prefetchEnd =
currentView === 'dayGridMonth'
currentView === CALENDAR_VIEWS.dayGridMonth
? end.getTime()
: getAdjacentWeekRange(selectedDate).end.getTime()
return {
+6
View File
@@ -230,7 +230,10 @@
"duplicateEvent": "Duplicate event"
},
"menubar": {
"toggleSidebar": "Toggle sidebar",
"today": "Today",
"next": "Next",
"prev": "Previous",
"refresh": "Refresh",
"viewSelector": "Select view",
"languageSelector": "Select language",
@@ -378,5 +381,8 @@
"searchError": "Unable to fetch resources right now. Please try again.",
"noResults": "No results",
"loading": "Loading..."
},
"sidebar": {
"displayMode": "Display mode"
}
}
+7 -1
View File
@@ -232,6 +232,8 @@
},
"menubar": {
"today": "Aujourd'hui",
"next": "Précédent",
"prev": "Suivant",
"refresh": "Actualiser",
"viewSelector": "Sélectionner la vue",
"languageSelector": "Sélectionner la langue",
@@ -245,7 +247,8 @@
"logoAlt": "Calendrier",
"settings": "Paramètres",
"help": "Aide",
"logout": "Déconnexion"
"logout": "Déconnexion",
"toggleSidebar": "Afficher/masquer la barre latérale"
},
"settings": {
"title": "Paramètres",
@@ -379,5 +382,8 @@
"searchError": "Impossible de récupérer les ressources pour le moment. Veuillez réessayer.",
"noResults": "Aucun résultat",
"loading": "Chargement..."
},
"sidebar": {
"displayMode": "Mode d'affichage"
}
}
+7 -1
View File
@@ -232,6 +232,8 @@
},
"menubar": {
"today": "Сегодня",
"next": "Следующий",
"prev": "Предыдущий",
"refresh": "Обновить",
"viewSelector": "Выбор вида",
"languageSelector": "Язык",
@@ -245,7 +247,8 @@
"logoAlt": "Календарь",
"settings": "Настройки",
"help": "Помощь",
"logout": "Выйти"
"logout": "Выйти",
"toggleSidebar": "Показать/скрыть боковую панель"
},
"settings": {
"title": "Настройки",
@@ -379,5 +382,8 @@
"searchError": "Не удалось получить ресурсы. Попробуйте снова.",
"noResults": "Нет результатов",
"loading": "Загрузка..."
},
"sidebar": {
"displayMode": "Режим отображения"
}
}
+7 -1
View File
@@ -230,6 +230,8 @@
},
"menubar": {
"today": "Hôm nay",
"next": "Tiếp theo",
"prev": "Trước đó",
"refresh": "Làm mới",
"viewSelector": "Chọn chế độ xem",
"languageSelector": "Chọn ngôn ngữ",
@@ -243,7 +245,8 @@
"logoAlt": "Lịch",
"settings": "Cài đặt",
"help": "Giúp đỡ",
"logout": "Đăng xuất"
"logout": "Đăng xuất",
"toggleSidebar": "Ẩn/hiện thanh bên"
},
"settings": {
"title": "Cài đặt",
@@ -377,5 +380,8 @@
"searchError": "Không thể lấy tài nguyên. Vui lòng thử lại.",
"noResults": "Không có kết quả",
"loading": "Đang tải..."
},
"sidebar": {
"displayMode": "Chế độ hiển thị"
}
}
+3
View File
@@ -62,6 +62,9 @@ if (typeof window !== 'undefined') {
window.WS_PING_PERIOD_MS = 5000
window.WS_PING_TIMEOUT_PERIOD_MS = 5000
}
jest.mock('@/useScreenSizeDetection', () => ({
useScreenSizeDetection: jest.fn(() => ({ isMobile: false, isTablet: false }))
}))
// Suppress jsdom CSS selector parsing errors for Emotion/MUI
if (typeof window !== 'undefined' && window.getComputedStyle) {
const originalGetComputedStyle = window.getComputedStyle
+2 -1
View File
@@ -1,3 +1,4 @@
import { CALENDAR_VIEWS } from '@/components/Calendar/utils/constants'
import moment from 'moment'
export function formatDateToYYYYMMDDTHHMMSS(date: Date) {
@@ -69,7 +70,7 @@ export function getViewRange(
date = new Date(),
view: string
): { start: Date; end: Date } {
if (view === 'dayGridMonth') {
if (view === CALENDAR_VIEWS.dayGridMonth) {
return getCalendarRange(date)
}
return computeWeekRange(date)