Skip to content

Commit

Permalink
Properly handle side bar being collapsed
Browse files Browse the repository at this point in the history
Fixes #101
  • Loading branch information
maxpatiiuk committed Feb 19, 2023
1 parent e5b916d commit b758bad
Show file tree
Hide file tree
Showing 6 changed files with 168 additions and 75 deletions.
24 changes: 15 additions & 9 deletions src/src/components/Charts/TimeChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,14 +86,17 @@ export function TimeChart({
</tr>
</thead>
<tbody>
{calendars.map((calendar) => (
<CalendarRow
calendar={calendar}
durations={durations[calendar.id]}
key={calendar.id}
times={times[calendar.id]}
/>
))}
{calendars.map((calendar) =>
// If calendar has just been unhidden, it's data might not be loaded yet
typeof times[calendar.id] === 'object' ? (
<CalendarRow
calendar={calendar}
durations={durations[calendar.id] ?? {}}
key={calendar.id}
times={times[calendar.id]}
/>
) : undefined
)}
<TotalsRow times={times} />
</tbody>
</table>
Expand Down Expand Up @@ -238,7 +241,10 @@ function TotalsRow({ times }: { readonly times: IR<DayHours> }): JSX.Element {
const totals = React.useMemo(
() =>
Array.from({ length: DAY / HOUR }, (_, index) =>
calendars.reduce((total, { id }) => total + times[id].hourly[index], 0)
calendars.reduce(
(total, { id }) => total + (times[id]?.hourly[index] ?? 0),
0
)
),
[times, calendars]
);
Expand Down
198 changes: 140 additions & 58 deletions src/src/components/Contexts/CalendarsContext.tsx
Original file line number Diff line number Diff line change
@@ -1,27 +1,44 @@
import React from 'react';

import { useAsyncState } from '../../hooks/useAsyncState';
import { useSimpleStorage } from '../../hooks/useStorage';
import { ajax } from '../../utils/ajax';
import React from 'react';
import { filterArray, GetOrSet, RA } from '../../utils/types';
import { formatUrl } from '../../utils/queryString';
import { sortFunction, split } from '../../utils/utils';
import { randomColor } from '../../utils/colors';
import { listen } from '../../utils/events';
import { formatUrl } from '../../utils/queryString';
import type { GetSet, RA } from '../../utils/types';
import { filterArray } from '../../utils/types';
import { debounce, sortFunction, split } from '../../utils/utils';
import { AuthContext } from './AuthContext';
import { randomColor } from '../../utils/colors';

type RawCalendarListEntry = Pick<
gapi.client.calendar.CalendarListEntry,
'id' | 'summary' | 'primary' | 'backgroundColor'
'backgroundColor' | 'id' | 'primary' | 'summary'
>;

export type CalendarListEntry = RawCalendarListEntry & {
readonly backgroundColor: string;
};

const emptyObject = [] as const;

export function CalendarsSpy({
children,
}: {
readonly children: React.ReactNode;
}): JSX.Element {
/*
* Cache the list of visible calendars so that we can use it if side menu
* is collapsed
*/
const [visibleCalendars, setVisibleCalendars] = useSimpleStorage(
'visibleCalendars',
emptyObject,
'local'
);

const isCacheEmpty = visibleCalendars === emptyObject;

const { token } = React.useContext(AuthContext);
const isAuthenticated = typeof token === 'string';
const [calendars] = useAsyncState<RA<CalendarListEntry>>(
Expand Down Expand Up @@ -54,11 +71,16 @@ export function CalendarsSpy({
false
);

const [visibleCalendars, setVisibleCalendars] = React.useState<
RA<string> | undefined
>(undefined);
/*
* If it's first time opening the extension, and the side bar is hidden,
* don't know which calendars are hidden so show all of them
*/
React.useEffect(() => {
if (isCacheEmpty && Array.isArray(calendars))
setVisibleCalendars(calendars.map(({ id }) => id));
}, [isCacheEmpty, calendars, setVisibleCalendars]);

useVisibilityChangeSpy(calendars, setVisibleCalendars);
useVisibilityChangeSpy(calendars, [visibleCalendars, setVisibleCalendars]);

const filteredCalendars = React.useMemo(
() => calendars?.filter(({ id }) => visibleCalendars?.includes(id)),
Expand All @@ -76,74 +98,134 @@ export const CalendarsContext = React.createContext<
>(undefined);
CalendarsContext.displayName = 'CalendarsContext';

function findSideBar(): HTMLElement | undefined {
const sideBar = document.querySelector('[role="complementary"]');
return (sideBar?.querySelector('input[type="checkbox"]') ?? undefined) ===
undefined
? undefined
: (sideBar as HTMLElement) ?? undefined;
}

function useVisibilityChangeSpy(
calendars: React.ContextType<typeof CalendarsContext>,
handleChange: GetOrSet<RA<string> | undefined>[1]
[visibleCalendars, setVisibleCalendars]: GetSet<RA<string> | undefined>
): void {
const [sideBar] = useAsyncState(
React.useCallback(async () => {
if (calendars === undefined) return;
const [sideBar, setSideBar] = React.useState<Element | undefined>(undefined);

const sideBar = await awaitElement(() => {
const sideBar = document.querySelector('[role="complementary"]');
if (
(sideBar?.querySelector('input[type="checkbox"]') ?? undefined) ===
undefined
)
return undefined;
else return sideBar ?? undefined;
});
if (sideBar === undefined) console.error('Unable to locate the sidebar');
return sideBar;
}, [calendars]),
false
);
const visibleCalendarsRef = React.useRef(visibleCalendars);
const cacheLoaded = Array.isArray(visibleCalendars);
visibleCalendarsRef.current = visibleCalendars;

/*
* When side menu is collapsed/expanded, <body>'s class names change
* We can use that to detect when the sidebar is opened/closed
* Note, this may break in the future
*/
React.useEffect(() => {
if (calendars === undefined || sideBar === undefined) return;
let sideBarRef: HTMLElement | undefined = undefined;

handleChange(
filterArray(
Array.from(
sideBar.querySelectorAll<HTMLInputElement>('input[type="checkbox"]'),
parseCheckbox
)
)
.filter(([_calendarId, checked]) => checked)
.map(([calendarId]) => calendarId)
);
function handleChange(): void {
if (sideBarRef === undefined) {
sideBarRef = findSideBar();
if (sideBarRef === undefined) return;
setSideBar(sideBarRef);
}
// If side bar is hidden, it has width of 0
const isVisible = sideBarRef.offsetWidth > 0;
setSideBar(isVisible ? sideBarRef : undefined);
}

handleChange();
const observer = new MutationObserver(debounce(handleChange, 60));
observer.observe(document.body, {
attributes: true,
attributeFilter: ['class'],
});
return (): void => observer.disconnect();
}, []);

function parseCheckbox(
const calendarsRef = React.useRef(calendars);
calendarsRef.current = calendars;
const parseCheckbox = React.useCallback(
(
checkbox: HTMLInputElement
): readonly [id: string, checked: boolean] | undefined {
if (calendars === undefined) return undefined;
): readonly [id: string, checked: boolean] | undefined => {
if (calendarsRef.current === undefined) return undefined;
const calendarName = checkbox.ariaLabel;
const calendar =
calendars.find(({ summary }) => summary === calendarName) ??
calendarsRef.current.find(({ summary }) => summary === calendarName) ??
/*
* Summary for the primary calendar does not match what is displayed
* in the UI
*/
calendars.find(({ primary }) => primary);
calendarsRef.current.find(({ primary }) => primary);
if (calendar === undefined) {
console.error('Unable to locate the calendar', calendarName);
return;
return undefined;
}
return [calendar.id, checkbox.checked];
},
[]
);

/*
* Detect calendars being loaded (initially the side bar contains just the
* primary calendar)
*/
React.useEffect(() => {
if (sideBar === undefined || !cacheLoaded) return undefined;

const getVisible = (): RA<string> =>
filterArray(
Array.from(
sideBar.querySelectorAll<HTMLInputElement>('input[type="checkbox"]'),
parseCheckbox
)
)
.filter(([_calendarId, checked]) => checked)
.map(([calendarId]) => calendarId);

let timeOut: ReturnType<typeof setTimeout>;

function handleChange(): void {
clearTimeout(timeOut);
setVisibleCalendars(getVisible());
}

return listen(sideBar, 'click', ({ target }) => {
const element = target as HTMLInputElement;
if (element.tagName !== 'INPUT' || element.type !== 'checkbox') return;
const data = parseCheckbox(element)?.[0];
if (data === undefined) return;
const [calendarId, checked] = data;
handleChange((visibleCalendars) =>
checked
? visibleCalendars?.filter((id) => id !== calendarId)
: [...(visibleCalendars ?? []), calendarId]
);
});
}, [calendars, sideBar]);
// Get list of calendars loaded so far
const visible = getVisible();
/*
* If side bar contains fewer elements than in the cache, it's likely
* that side bar hasn't been fully loaded yet. Wait and try it again
*/
if (visible.length < visibleCalendarsRef.current!.length)
timeOut = setTimeout(handleChange, 2000);
else handleChange();

const observer = new MutationObserver(debounce(handleChange, 60));
observer.observe(sideBar, { childList: true, subtree: true });
return (): void => observer.disconnect();
}, [sideBar, parseCheckbox, setVisibleCalendars, cacheLoaded]);

React.useEffect(
() =>
sideBar === undefined
? undefined
: listen(sideBar, 'click', ({ target }) => {
const element = target as HTMLInputElement;
if (element.tagName !== 'INPUT' || element.type !== 'checkbox')
return;
const data = parseCheckbox(element)?.[0];
if (data === undefined) return;
const [calendarId, checked] = data;
setVisibleCalendars(
checked
? visibleCalendarsRef.current?.filter((id) => id !== calendarId)
: [...(visibleCalendarsRef.current ?? []), calendarId]
);
}),
[sideBar, setVisibleCalendars]
);
}

/**
Expand Down
2 changes: 1 addition & 1 deletion src/src/components/Contexts/CurrentViewContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ function resolveBoundaries(
),
};
else if (viewName === 'week') {
// BUG: detect first of the week. This incorrectly assumes Sunday is first
// BUG: detect first day of the week. This incorrectly assumes Sunday is first
const dayOffset = selectedDay.getDate() - selectedDay.getDay();
return {
firstDay: new Date(
Expand Down
11 changes: 9 additions & 2 deletions src/src/components/Molecules/Portal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import React from 'react';
import ReactDOM from 'react-dom';

let portalRoot: HTMLElement | undefined = undefined;
let portalStack = new Set<unknown>();
const portalStack = new Set<unknown>();

/**
* A React Portal wrapper
Expand Down Expand Up @@ -42,7 +42,7 @@ export function Portal({

// Nearest parent for both main content and portal container
const commonContainer = mainContainer.parentElement!;
commonContainer.appendChild(portalRoot);
commonContainer.append(portalRoot);
}
portalRoot.append(element);

Expand All @@ -66,6 +66,13 @@ export function Portal({
export const findMainContainer = (): Element | undefined =>
document.querySelector('[role="main"]') ?? undefined;

export function useMainContainer(): HTMLElement | undefined {
return React.useMemo(
() => findMainContainer()?.parentElement ?? undefined,
[]
);
}

export const PortalContext = React.createContext<Element | undefined>(
undefined
);
Expand Down
7 changes: 2 additions & 5 deletions src/src/components/PowerTools/GhostEvents.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import React from 'react';
import { useSafeStorage } from '../../hooks/useStorage';
import { listen } from '../../utils/events';
import { f } from '../../utils/functools';
import { findMainContainer } from '../Molecules/Portal';
import { useMainContainer } from '../Molecules/Portal';
import { usePref } from '../Preferences/usePref';
import { usePageListener } from './PageListener';

Expand All @@ -33,10 +33,7 @@ export function GhostEvents(): null {
);
}, [ghostEventOpacity, isDisabled]);

const mainContainer = React.useMemo(
() => findMainContainer()?.parentElement ?? undefined,
[]
);
const mainContainer = useMainContainer();
const doGhosting = React.useCallback(
(): void =>
mainContainer === undefined
Expand Down
1 change: 1 addition & 0 deletions src/src/hooks/useStorage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export type StorageDefinitions = {
readonly customViewSize: number;
readonly timeChartMode: TimeChartMode;
readonly synonyms: RA<Synonym>;
readonly visibleCalendars: RA<string>;
};

/**
Expand Down

0 comments on commit b758bad

Please sign in to comment.